JWT認証アプリケーションで認可つきCSVダウンロードを実装した話

JavaScriptLaravelPHPVue.js

背景

  • Laravel + Vue.jsアプリケーション
  • JWT認証のSPA

    • つまり、すべてのリクエストのAuthorizationヘッダにJWTを載せている
axios.defaults.headers.common['Authorization'] = `Bearer ${jwt}`;
  • 集計したデータをCSVファイルとしてダウンロードさせたい
  • ただし、認可もつけたい

    • 未ログイン状態ではダウンロードさせない

困ったこと

  • RESTful APIを叩いているうちは何も問題はない
  • CSVダウンロードで困った

    • <a href="csvダウンロードリンク">...ではダメ

      • Authorizationヘッダが載らないため、認可が通らない

やったこと

サーバ側でCSV生成

  • こんな感じのCsvDownloaderクラスを作り、コントローラに注入して使った
  • 参考
<?php
declare (strict_types = 1);

namespace App\Http\Controllers\Concerns;

use Illuminate\Http\Response;
use Illuminate\Support\Collection;

/**
 * データをCSVファイルとしてダウンロードする
 */
class CsvDownloader
{
    /**
     * @param array|Collection $header 見出し
     * @param array|Collection $body データ本体
     * @param string $fileName
     * @return Response
     */
    public function downloadCsv(
        $header,
        $body,
        string $fileName
    ): Response {
        // 仮ファイル
        $stream = fopen('php://temp', 'w');

        // 見出し書き込み
        fputcsv(
            $stream,
            collect($header)->toArray()
        );

        // データ本体書き込み
        collect($body)->each(
            function ($row) use (&$stream) {
                fputcsv(
                    $stream,
                    $row
                );
            }
        );

        // ポインタの先頭へ
        rewind($stream);

        // 変換
        // - 改行コード
        $csv = str_replace(
            PHP_EOL,
            "\r\n",
            stream_get_contents($stream)
        );
        // BOM
        $csv= pack('C*',0xEF,0xBB,0xBF) . $csv;


        $httpHeaders = [
            'Content-Type' => 'text/csv',
            'Content-Disposition' => 'attachment; filename="' . $fileName . '"',
        ];

        return response(
            $csv,
            Response::HTTP_OK,
            $httpHeaders
        );
    }
  • 何も考えずにBOMなしUTF-8で出力すると、Excelで開いた時に文字化けしてしまう
  • 回避方法は下記のようなものがある:

    • BOMつきUTF-8にする
    • SJISにする
    • (他にもUTF-16とか)
  • フロントエンドとの兼ね合いで、SJISではなくBOMつきUTF-8にした (後述)

フロントエンド側でBlob生成

  • CSVデータのダウンロードはAjaxで行い、フロントエンドでBlob化しダウンロードリンクを生成するようにした
  • 参考
 export default {
...
     methods: {
         // AjaxでサーバからCSVを落としてくる
         downloadCsvReport() {
             axios.get('/download/report')
                  .then(response => response.data)
                  .then(data => this.downloadCommon(data, 'filename.csv'));
         },
         // 落としてきたCSVをBlobにしてダウンロードリンクを生成する
         downloadCommon(data, filename) {
             const anchor = document.createElement('a');
             document.body.appendChild(anchor);
             // ここでもBOMをつける必要がある
             // さもないとExcelで文字化けする
             const bom = new Uint8Array([0xEF, 0xBB, 0xBF]);
             const blob = new Blob([bom, data], {type: 'text/csv'});
             const objectUrl = window.URL.createObjectURL(blob);
             anchor.href = objectUrl;
             anchor.download = filename;
             anchor.click();

             window.URL.revokeObjectURL(objectUrl);
         }
...
  • buttonの@clickか何かで実行すればCSVファイルをダウンロードできる
  • Chromeで動作確認

踏んだ文字化けのパターン

  • サーバ側でCSVのエンコーディングをSJISにしてしまった

    • フロントエンド側でUTF-8としてデコードし、文字化けしてしまう
  • フロントエンド側でBlob生成時にBOMを付与し忘れた

    • フロントエンドでHTTPレスポンスをデコードした時点でBOMは外れる
    • つまりサーバ側でBOMを付与する必要はない

      • が、サーバ単体でまともなCSVファイルをレスポンスできないのは気持ち悪いので付与した