画像や PDF へのリンクをクリックすると、多くのブラウザーは、ファイルを保存せずにその場で表示します。資料の配布ページなどで「表示ではなく、ダウンロードさせたい」ときは、サーバーの応答のヘッダーか、HTML の属性で、ダウンロードとして扱うように伝えます。なお、保存先を選ぶダイアログを出すか、決まったフォルダーへすぐに保存するかは、ブラウザーと利用者の設定で決まり、Web ページの側からは選べません。
Content-Disposition: attachment を送る
HTTP の応答に Content-Disposition: attachment というヘッダーを付けると、ブラウザーはファイルを表示せず、ダウンロードとして扱います。PHP では、header() でヘッダーを送ってから、readfile() でファイルの中身を出力します。
<?php
$path = __DIR__ . '/../private/report.pdf'; // 公開フォルダーの外に置いたファイル
$name = '月次レポート.pdf'; // 保存するときのファイル名
header('Content-Type: application/pdf');
header('Content-Length: ' . filesize($path));
header("Content-Disposition: attachment; filename=\"report.pdf\"; filename*=UTF-8''" . rawurlencode($name));
header('X-Content-Type-Options: nosniff');
readfile($path);
exit;
header()は、ほかの出力より前に呼びます。PHP のファイルの先頭の空行や BOM (ファイルの先頭に付く目印のバイト) があると、「headers already sent」というエラーになりますX-Content-Type-Options: nosniffは、ブラウザーが中身から種類を推測するのを止めるヘッダーです。Content-Typeと合わせて付けておくと安全です- 出力バッファリングが有効だと、大きなファイルがメモリーにたまることがあります。その場合は、
readfile()の前にob_end_clean()などでバッファーを閉じます
PHP を使わない静的なファイルなら、Apache の設定でヘッダーを付けることもできます (mod_headers が必要です)。
# このフォルダーの PDF は、ダウンロードとして扱わせる
<FilesMatch "\.pdf$">
Header set Content-Disposition attachment
</FilesMatch>
日本語のファイル名は filename* で指定する
HTTP のヘッダーには、もともと ASCII の文字しか書けません。日本語のファイル名は、filename*=UTF-8'' に続けて、UTF-8 のバイト列をパーセント符号化して書きます。PHP では rawurlencode() を使います。
上の例では、filename* に対応しないブラウザーのために、ASCII の filename も並べて書いています。両方あるときは、対応するブラウザーは filename* を使います。filename に日本語をそのまま書く方法は、ブラウザーによって文字化けするので避けます。
HTML だけで済ませる download 属性
同じサイトにあるファイルなら、a 要素に download 属性を付けるだけで、ダウンロードとして扱わせられます。値を書くと、保存するときのファイル名の候補になります。
<a href="/files/report.pdf" download="月次レポート.pdf">月次レポートをダウンロード (PDF)</a>
ただし、download 属性が効くのは、同じオリジン (スキーム・ホスト名・ポート番号の組) のファイルと、blob: や data: の URL だけです。ほかのサイトのファイルでは無視され、ふつうのリンクとして開きます。また、サーバーが Content-Disposition でファイル名を指定していると、そちらが優先されます。
JavaScript で作ったデータを保存させるときも、この属性を使えます。
// 文字列を CSV ファイルとしてダウンロードさせる
function downloadCsv(text, filename) {
const blob = new Blob([text], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.append(link);
link.click();
link.remove();
// ダウンロードが始まってから、URL を解放する
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
downloadCsv("name,count\napple,3\n", "items.csv");
ファイルのパスを入力から作らない
「download.php?file=report.pdf で、指定したファイルをダウンロードさせる」という作りは、よく見かける一方で、危険が潜んでいます。受け取った値をそのままパスに使うと、file=../config.php のような値で、公開するつもりの無いファイルまで読み出されてしまいます。これをディレクトリトラバーサルと呼びます。
いちばん安全なのは、利用者にはファイルのパスではなく、決めておいた ID を選ばせる方法です。
<?php
// ダウンロードさせてよいファイルの一覧 (ID => パスとファイル名)
$files = [
'report' => [__DIR__ . '/../private/report.pdf', '月次レポート.pdf'],
'guide' => [__DIR__ . '/../private/guide.pdf', '利用の手引き.pdf'],
];
$id = $_GET['id'] ?? '';
if (!is_string($id) || !isset($files[$id])) {
http_response_code(404);
exit;
}
[$path, $name] = $files[$id];
// ここで、ログインしているかなどの権限も確かめる
// そのあと、最初の例と同じようにヘッダーを送って readfile() する
ダウンロードの前に権限を確かめるなら、ファイルは公開フォルダー (ドキュメントルート) の外に置きます。公開フォルダーの中にあると、URL を直接開くだけで、PHP の確認を通らずに読めてしまいます。
まとめ
- ダウンロードさせるには、
Content-Disposition: attachmentを送る。PHP ではheader()とreadfile()を使う - 日本語のファイル名は
filename*=UTF-8''とrawurlencode()で指定し、ASCII のfilenameも並べる - 同じオリジンのファイルなら、
a要素のdownload属性だけで済む - 利用者の入力からファイルのパスを作らない。決めておいた ID から選ばせる