一度気に入ると、より使って見たくなるのが人の常。ということでさらにマクロの活用です。今回は、前回と同様なレスポンスマクロを使用して、サイトからダウンロードするファイルを保護します。

ファイルのダウンロード

ファイルのダウンロードの機能は、すでにLaravelでは提供されています。例えば、storage/data.txtのファイルのダウンロードは以下のようにコントローラのレスポンスにdownload()をチェーンして、ファイルダウンロードのレスポンスとします。

...
class DownloadFileController extends Controller
{
...
    public function download()
    {
        $pathToFile = storage_path() . '/data.csv';
        return response()->download($pathToFile);
    }
}

さて、ブラウザーで、このファイルのダウンロードのレスポンスのヘッダーを見ると、

HTTP/1.1 200 OK
Date: Fri, 17 Aug 2018 22:05:43 GMT
Server: Apache/2.4.27 (Fedora) OpenSSL/1.0.2k-fips PHP/7.1.14
X-Powered-By: PHP/7.1.14
Cache-Control: public
...

Cache-Control: publicの部分、パブリックと設定されているので、ファイルの中身がサーバーとクライアント(ユーザー)の間のプロキシでキャッシュされるかもしれません。このファイルが例えば公共に見られてよい画像とかなら問題はないのですが、ファイルがサイトの会員とか注文データだとセキュリティの問題となります。私としては以下と設定したいのです。

...
Cache-Control: private
...

ここを修正すべく、まず思ったのは、download()のパラメータを使用してのCache-Controlの設定です。

return response()->download($pathToFile, 'data.csv', ['Cache-control' => 'private']);

しかし、これが効きません! 相変わらず、レスポンスのヘッダーはCache-Control: publicです。試しに、以下のように違うCache-Controlの属性を設定してみると、

return response()->download($pathToFile, 'data.csv', ['Cache-control' => 'no-store']);

今度は、Cache-Control: no-store, publicという変な結果になりましたが、設定はできるようです。となると、どこかでprivateを弾いているみたいです。

どこでpublicが設定されている?

ymikomeさんが探偵のようにコードを追跡してくれました。

問題は、

namespace Illuminate\Routing;
...
class ResponseFactory implements FactoryContract
{
...
    public function download($file, $name = null, array $headers = [], $disposition = 'attachment')
    {
        $response = new BinaryFileResponse($file, 200, $headers, true, $disposition); //ここの4番目のtrueの設定が問題!

        if (! is_null($name)) {
            return $response->setContentDisposition($disposition, $name, str_replace('%', '', Str::ascii($name)));
        }

        return $response;
    }
...

download()の定義の中の、BinaryFileResponse()のコールで、その4番目のtrueの値が、以下ようにsetPublic()のコールとなります。注意してください。もうここからは、Laravelのコードでなく、Symfonyのコードです。

namespace Symfony\Component\HttpFoundation;
...
class BinaryFileResponse extends Response
{
...
    public function __construct($file, $status = 200, $headers = array(), $public = true, $contentDisposition = null, $autoEtag = false, $autoLastModified = true)
    {
        parent::__construct(null, $status, $headers);

        $this->setFile($file, $contentDisposition, $autoEtag, $autoLastModified);

        if ($public) {
            $this->setPublic(); // $public = trueだからここを実行!
        }
    }
...

そして、このsetPublic()の定義は、

namespace Symfony\Component\HttpFoundation;

/**
 * Response represents an HTTP response.
 *
 * @author Fabien Potencier <fabien@symfony.com>
 */
class Response
{
...
    public function setPublic()
    {
        $this->headers->addCacheControlDirective('public');
        $this->headers->removeCacheControlDirective('private'); //ここでprivateが弾かれる!
    
        return $this;
    }
...

headersでprivateを設定してもここで抜かれる訳です。

どうprivateを設定する?

さて、Cache-Controlがいつもpublicとなる原因はわかりましたが、どうこれをprivateと設定するのが今度は問題となりました。ResponseFactory::download()を上書きするのが良いように思えますが、ResponseFactoryのクラスそのものを継承とかなるようで、trueをfalseとする目的のためには大袈裟すぎます。

困ったと思ったときに、見たのは先のSymfonyのコード、setPublic()の定義を含むファイルには、setPrivate()もあるではないですか!

ということで、以下のようなマクロの定義となりました。内部では一旦publicにしたものをprivateにすることになりますが、ここでは意図が明確でスッキリとしています。

       Response::macro('downloadNoCache', function ($path) {
            return $this->download($path, basename($path), [
                'Cache-Control' => 'no-store',
            ])->setPrivate();   // Cache-Controlにpublicが追加されるのを回避
        });

no-storeですが、これはブラウザがファイルをキャッシュするのを防ぎます。毎回ダウンロードでデータが変わるときには必要です。

このマクロの使用は、以下のようになります。

return response()->downloadNoCache($pathToFile);

この実行は以下のように思い通りのヘッダーとなりました。

HTTP/1.1 200 OK
Date: Fri, 17 Aug 2018 23:40:33 GMT
Server: Apache/2.4.27 (Fedora) OpenSSL/1.0.2k-fips PHP/7.1.14
X-Powered-By: PHP/7.1.14
Cache-Control: no-store, private
...

By khino