純規の暇人趣味ブログ

首を突っ込んで足を洗う

X-SendFile、X-Accel-Redirectの使い方

      2016/11/16    HimaJyun

普通の用途でPHPなどのサーバサイドのプログラムから巨大なファイルを送り出そうと言うのはバカ(自粛)以外の何物でもないとして……(普通にWebサーバにやらせれば良いから)

やはり、ユーザを認証してからファイルを送信したい(例えば有料コンテンツとか)、なんて用途は無きにしも非ずであって、Apacheやnginxにはそう言う時のための「X-SendFile」や「X-Accel-Redirect」があるので使い方を紹介しようと思います。

巨大なファイルを送りたいゾ

先の通り、ただ単に「ファイルをダウンロードさせたい」だけならあなたはブラウザの戻るボタンを押すべきです。

なぜって?ただ単にそのファイルをサーバに置いてダウンロードさせれば良いだけだから。

早い話、サーバ上に置いたzipやexeに対して「<a href="/なんとか.zip">押すなよ!!絶対押すなよ!!</a>」みたいにすれば良い訳。(要は「ユーザ<->Webサーバ<->ファイル」であってPHPなどのプログラムの出る幕ではない)

え?ダウンロードされずにブラウザに変な文字がズラズラ?それはContent-TypeかContent-Dispositionがおかしい

ダウンロードさせたいファイルのContent-Typeはoctet-stream、大抵のブラウザはこれだけでダウンロード画面が出る。

それでも出なければContent-Dispositionにattachmentを指定する。

今回やりたいのはそう言うのではなく、「ユーザ<->Webサーバ<->プログラム(PHPとか)<->ファイル」みたいな処理

要はPHPなどで正規の(例えば購入済みなどの)ユーザかを確認してから、ファイルを送信したいって訳(例えばエッチなゲームを有料でDL販売したい、とかね。)

これではダメなのか?

まさか「ファイルを設置しておいて認証してからリダイレクトすればいい」なんて言う無知は居ないとして(こんなやり方したらリダイレクト後のURLがバレたら(簡単に調べられる)誰でもダウンロード出来てしまう)

例えば、PHPを使うなら普通はこんな感じを想像するかな?

<?php
header('Content-Type: octet-stream');
header('Content-Disposition: attachment; filename="学術論文.exe"');
echo file_get_contents('./ヒミツのフォルダ/エッチなゲーム.exe');

見かけ上はこれで良いように見えますし、実際これで動作します、でもダメです死にます。

file_get_contentsはファイルを一旦メモリ上に溜め込みます。

ファイルサイズが数kbなどで小さな物であれば問題に気付かない事が多いですが、気付いていないだけで良くない手法なのは変わりません。

つまり、巨大なファイルを送信しようとしたらOutOfMemoryで爆散する訳です。(手元にあるエロビデオでも使って試してみれば分かります。)

この辺りの挙動は今回の記事とは関係ないですが、気になるのであれば「[PHP]readfileは巨大ファイルを扱える」をご覧ください。

解決方法

さて、長ったらしい前置きを終えて本文に入るとしましょう。

こう言う時のために、プログラム側からWebサーバにファイルの送信を依頼(委譲)できる機能が、Apacheには「X-SendFile」、nginxには「X-Accel-Redirect」として備わっています。(lighttpdにもあるんだけどlighttpdを使った事がないので割愛)

X-SendFileは素直で良い娘なんですが、どうにもX-Accel-Redirectが天邪鬼なので両方とも解説してしまいましょう。

前提条件

まず大前提として、データベースに入っているデータをこの手法で送り出す事は出来ません。

そもそもそう言う物(バイナリ)をデータベースに入れる設計が間違いです、データベースに入れるべきはファイルパスであってファイルその物ではないでしょう。

保存されいるファイルはWebサーバから読めさえすれば(読み取り権限があれば)良いだけで、ファイルそのものがDocumentRoot以下に置かれている必要はありません。(なので、言い換えるとそれらに認証無しでアクセスされる可能性はなく、安全に配信出来る)

X-Sendfileを使う

mod_xsendfileをインストールする必要があります、UbuntuやRaspbianなら以下の通り(RHELは知らん)

sudo apt-get install libapache2-mod-xsendfile

後はVirtualHostなどにX-SendFileを有効にする設定を書き加えます。

<VirtualHost *:80>
  # ~~~ 略 ~~~
  XSendFile on
  XSendFilePath /ほげほげ/ふがふが/ヒミツのフォルダ
</VirtualHost>

XSendFilePathで指定したディレクトリ以下にあるファイルの送信が許可されます。

みたいな説明が多いのですが、ぶっちゃけ何の為にあるのか分かりません(設定したパスより上にも行けた)

後はPHPなどでファイルを送信する処理の代わりに、「X-Sendfile: ファイルパス」と言うヘッダを送り出せば良いのです。

<?php
header('Content-Type: octet-stream');
header('Content-Disposition: attachment; filename="学術論文.exe"');
header('X-Sendfile: /ほげほげ/ふがふが/ヒミツのフォルダ/エッチなゲーム.exe');
exit;

これでApacheが良い具合にファイルを送信してくれます、詳しくは検証していませんが「X-Sendfile」で指定するファイルパスは絶対パスじゃないとダメかと思われます。

あと、X-Sendfileヘッダはmod_xsendfileが有効であればきちんと取り除かれるので大丈夫です。

「../」とかの文字があるとそれもそのまま処理されるみたいなので、ディレクトリトラバーサルには注意する必要があるでしょう。

X-Accel-Redirectを使う

X-Sendfileの素直さとは一転して天邪鬼なX-Accel-Redirect、なんだか色々面倒です。(nginxってのはその天邪鬼さを理解して使いこなせる人間だけが使う物ですし……

設定ファイルに以下の様な設定を行う、多分それ用のモジュールとかは要らないはず……

location ^~ /eroge/ {
  alias /ほげほげ/ふがふが/ヒミツのフォルダ;
  internal;
}

最初のlocationにファイルのダウンロード用URLみたいなのを設定する、ここは何でも良い(実際にユーザがここにアクセスする事は無い)が、演算子は「^~」にする事

こうしないと「~*」とかが設定された別のlocationに一致してしまう可能性があるため。

2行目、ここに実際のファイルがあるディレクトリを指定する。

3行目、ここ重要、「internal;」を付けていないとそのパスから誰でもダウンロード出来てしまって意味がない。

PHPで送信する場合の例として、以下の様にする。

<?php
header('Content-Type: octet-stream');
header('Content-Disposition: attachment; filename="学術論文.exe"');
header('X-Accel-Redirect: /eroge/エッチなゲーム.exe');
exit;

このヘッダをnginxが受け取ると、「/ほげほげ/ふがふが/ヒミツのフォルダ/エッチなゲーム.exe」を代わりに送ってくれる。(/erogeはただ単にX-Accel-Redirectであると言う事を識別するためだけに使用される)

aliasの代わりにrootを使う事も出来る、その場合は以下の通り

location ^~ /ヒミツのフォルダ/ {
  root /ほげほげ/ふがふが;
  internal;
}

この場合、送り出すヘッダは以下の通りになる。

header('X-Accel-Redirect: /ヒミツのフォルダ/エッチなゲーム.exe');

非常に分かりづらい(し、説明しづらい)ので、ぜひ手元のエロビデオでも使って試してください、個人的にはaliasの方が分かりやすくてオススメかな。

他にも「X-Accel-Limit-Rate」みたいなヘッダを使って帯域制限をしたり、と色々出来るらしいが、この記事の範囲を超えるので公式ページをお読みくださりやがれ。

オマケ:PHPで送る

X-SendfileやX-Accel-Redirectを使用するとほんの少し早いだけでなく、無駄にPHPのプロセスを圧迫しないので使えるなら積極的に使っていくべきなのだが……

大人の事情でどうしてもPHPから送り出さなければ行けない時は以下の様にすれば出来る。

<?php
header('Content-Type: octet-stream');
header('Content-Disposition: attachment; filename="学術論文.exe"');

// 出力バッファを無効化
while (ob_get_level()) {
  ob_end_flush();
}
flush();

// ファイル送信
readfile('/ヒミツのフォルダ/エッチなゲーム.exe');

「readfileはメモリに溜め込むからダメだ!!」なんてのは幻想であり、readfileはメモリに溜め込んだりはしない。

PHPの公式マニュアルにはご丁寧に日本語で「巨大なファイルを送ってもかまいません」と書かれてある、書かれてあるのに間違えると言う事は読んでいないと言う事、丁寧に日本語化されたマニュアルを読まないなんて言語に対する冒涜でしかない。

でもって、readfileがメモリに溜め込んでいるように見えたら、それは出力バッファ(ob)の所為、その事も当然ながらマニュアルに書いてある。

最初にチラッと説明した通り、詳しい挙動が気になる場合は「[PHP]readfileは巨大ファイルを扱える」をご覧ください。

ちなみに今見たらマニュアルのコメント部分にはXSendfileに関する記載もあった。

結論:マニュアル読もう!!

 - プログラミング ,