Livewireでは日本語ファイル名でダウンロードができないから乗っ取ってみた

## もっとTL; DR

__LiveWireではすでに対応済み。この記事は気にするな！__

[Fix SupportFileDownloads - Multibyte character support - GitHub Livewire ](https://github.com/livewire/livewire/pull/6553)

## TL; DR;

- Livewire v2のファイルダンロード処理は``filename*``に対応していない。
- ``Livewire\Features\SupportFileDownloads``をカスタマイズすれば対応できる
- ``Livewire\Features\SupportFileDownloads``カスタマイズを適用するためには``Livewire\LivewireManager``をハックする必要あり。
- 大人しく今後のバージョンアップでの改修を待ったほうがいいかも


## バージョン

|FW / MW| バージョン |
|:-:|:--|
| laravel/framework | 10.1 |
| livewire/livewire | 2.12 |

## 問題

Livewire公式にあるコードをもじって、日本語のファイル名をダウンロードする。

``` PHP
class ExportButton extends Component
{
    public function export()
    {
        return Storage::disk('exports')->download('結果.csv');
    }
}
```

このとき、ダウンロードされるファイル名はどうなるか示せ。

### 答え

``__.csv``

__なぜ？__

## からくりは、レスポンス→JSONへの変換処理でした

Laravelがダウンロードレスポンスを生成する際は正しいレスポンスを生成しています。
上記コードとしては、レスポンスヘッダは以下のような形となります。

``` http]
Content-Disposition attachment; filename=__.csv; filename*=utf-8''%E7%B5%90%E6%9E%9C.csv
```

filename属性はASCIIのみの対応であるため、日本語ファイル（というよりもマルチバイト文字）の場合、filename*属性にファイル名を定義します。
また、 __filename&ast;属性とfilename属性が設定されている場合、filename&ast;属性が優先されなければならない__ 仕様です。

[Content-Disposition](https://developer.mozilla.org/ja/docs/Web/HTTP/Headers/Content-Disposition)

さて、Laravelが返却しているレスポンスをLivewireはJSONのレスポンスとして加工します。
これはフロントエンド側の``Alpine.js``と通信するための処理なのですが、__ここでやらかしています__。

以下のコードはファイルダウンロードを制御するLaravelのソースコードです。

``` livewire/src/Features/SupportFileDownloads.php 
<?php

namespace Livewire\Features;

use Illuminate\Contracts\Support\Responsable;
use Livewire\Livewire;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\HttpFoundation\BinaryFileResponse;

class SupportFileDownloads
{
    static function init() { return new static; }

    protected $downloadsById = [];

    function __construct()
    {
        Livewire::listen('action.returned', function ($component, $action, $returned) {
            if($returned instanceof Responsable){
                $returned = $returned->toResponse(request());
            }

            if ($this->valueIsntAFileResponse($returned)) return;

            $response = $returned;

            $name = $this->getFilenameFromContentDispositionHeader(
                $response->headers->get('Content-Disposition')
            );

            $binary = $this->captureOutput(function () use ($response) {
                $response->sendContent();
            });

            $content = base64_encode($binary);

            $this->downloadsById[$component->id] = [
                'name' => $name,
                'content' => $content,
                'contentType' => $response->headers->get('Content-Type'),
            ];
        });

        Livewire::listen('component.dehydrate.subsequent', function ($component, $response) {
            if (! $download = $this->downloadsById[$component->id] ?? false) return;

            $response->effects['download'] = $download;
        });

        Livewire::listen('flush-state', function() {
            $this->downloadsById = [];
        });
    }

    function valueIsntAFileResponse($value)
    {
        return ! $value instanceof StreamedResponse
            && ! $value instanceof BinaryFileResponse;
    }

    function captureOutput($callback)
    {
        ob_start();

        $callback();

        return ob_get_clean();
    }

    function getFilenameFromContentDispositionHeader($header)
    {
        /**
         * The following conditionals are here to allow for quoted and
         * non quoted filenames in the Content-Disposition header.
         *
         * Both of these values should return the correct filename without quotes.
         *
         * Content-Disposition: attachment; filename=filename.jpg
         * Content-Disposition: attachment; filename="test file.jpg"
         */

        if (preg_match('/.*?filename="(.+?)"/', $header, $matches)) {
            return $matches[1];
        }

        if (preg_match('/.*?filename=([^; ]+)/', $header, $matches)) {
            return $matches[1];
        }

        return 'download';
    }
}
```

注目したいのが``getFilenameFromContentDispositionHeader``メソッドです。ここでは``filename``属性のみしか参照していません。つまり、どれだけLaravel上の処理で日本語ファイルを設定したところで、``filename*``属性が無視されるため、日本語ファイル名にならないわけです。

## 日本語ファイルをダウンロードできるようにするために。

日本語のファイルをダウンロードできるようにするため、Livewireの __わりかしコアな部分__ をカスタマイズします。

### SupportFileDownloadsをハックする

``SupportFileDownloads``クラスを拡張して、``filename*``を考慮するようにします。
``getFilenameFromContentDispositionHeader``をオーバーライドする形で完了です。

``` app/Livewire/Features/SupportFileDownloads.php 
<?php

namespace App\Livewire\Features;

use Livewire\Features\SupportFileDownloads as LivewireSupportFileDownloads;
use illuminate\support\Str;

/**
 * Livewire上でのファイルダウンロード処理カスタマイズ
 *
 * 標準のLivewireでは考慮してくれないfilename*属性をカバーする実装
 */
class SupportFileDownloads extends LivewireSupportFileDownloads
{
    /**
     * @override
     * @inheritDoc
     */
    public function getFilenameFromContentDispositionHeader($header)
    {
        $rfc5987Filename = match (true) {
            preg_match('/.*?filename\*=([^; ]+)/', $header, $matches) === 1 => $matches[1],
            preg_match('/.*?filename\*="(.+?)"/', $header, $matches) === 1 => $matches[1],
            default => null,
        };

        // filename*属性がなければ元の処理を使用する
        if (is_null($rfc5987Filename)) {
            return parent::getFilenameFromContentDispositionHeader($header);
        }

        // エンコード・未デコード文字列を分割
        // ファイル名をデコードして返却
        $filename = Str::after($rfc5987Filename, '\'\'');
        return rawurldecode($filename);
    }
}
```

### LivewireManager / Livewireファサードをハックする

Livewireのコア処理はイベント駆動で動いています。
``SupportFileDownloads``クラスの``Livewire::listen('action.returned', function ($component, $action, $returned) { ... })``のところがまさにイベントを定義するところですね。

ただ、このイベント駆動、追加しかできません。__リスナーを置き換えたり削除したりすることができないようになっています__。そりゃそうだ、フレームワークのコアに関わる部分だから。

しかし、日本語ファイルのダウンロードを行うにあたって、``action.returned``イベントに元の``SupportFileDownloads``クラスが設定するリスナーがあると不都合があります。

問題は以下のコード。

``` livewire/src/Features/SupportFileDownloads.php 
$binary = $this->captureOutput(function () use ($response) {
    $response->sendContent();
});
```

ファイルデータをバイナリで取得するため、``$response->sendContent()``を実行しています。
このメソッドが実行されるとコンテンツがすべて流れてしまうため、以降のリスナーでコンテンツが取得できなくなります（もしかしたらシーク的なことをすれば取れるのかもしれないですが）。

なので、イベントリスナーを弄らないといけないわけで。

イベントを管理するのは``Livewire\LivewireManager``、インターフェースとしては``Livewire\Livewire``ファサードです。

以下はlivewireが定義するLivewireファサードです。

``` livewire/src/Livewire.php 
<?php

namespace Livewire;

use Illuminate\Support\Facades\Facade;

/**
 * @method static void component($alias, $viewClass)
 * @method static \Livewire\Testing\TestableLivewire test($name, $params = [])
 * @method static \Livewire\LivewireManager actingAs($user, $driver = null)
 * @method static \Livewire\LivewireManager withQueryParams($queryParams)
 *
 * @see \Livewire\LivewireManager
 */
class Livewire extends Facade
{
    public static function getFacadeAccessor()
    {
        return 'livewire';
    }
}
```

``livewire``というエイリアスで依存性注入をしているので、カスタマイズ版LivewireManagerを上書きで注入すれば __乗っ取ることができそう__ です。

以下 LivewireManagerのカスタマイズです。

``` app/Livewire/LivewireManager.php
<?php

namespace App\Livewire;

use Livewire\LivewireManager as BaseLivewireManager;

/**
 * LivewireManagerのカスタマイズ
 */
class LivewireManager extends BaseLivewireManager
{
    /**
     * Livewireのイベントリスナをリセットする
     *
     * @return void
     */
    public function resetListener()
    {
        $this->listeners = [];
    }
}
```

LievewireManagerに対してイベントリスナをリセットする処理を実装します。内部的に``$listeners``プロパティがイベントリスナを保持しているため、これを初期化します。

また、特にイジる必要もないかもしれませんが、ドキュメントコメント的に``resetListener``メソッドが表示されない気がするので、独自のLivewireファサードを定義します。ただし、Livewireが持っている``Livewire``ファサードと独自のLivewireファサードは同じLivewireManagerインスタンス（乗っ取り版）を参照します。

``` app/Livewire/Livewire.php
<?php

namespace App\Livewire;

use Illuminate\Support\Facades\Facade;

/**
 * @method static void component($alias, $viewClass)
 * @method static \Livewire\Testing\TestableLivewire test($name, $params = [])
 * @method static \Livewire\LivewireManager actingAs($user, $driver = null)
 * @method static \Livewire\LivewireManager withQueryParams($queryParams)
 * @method static void resetListener()
 *
 * @see \App\Livewire\Livewire\LivewireManager
 */
class Livewire extends Facade
{
    public static function getFacadeAccessor()
    {
        return 'livewire';
    }
}
```

### サービスプロバイダでLivewireを乗っ取る

さて、Livewireで日本語ファイルを扱うための部品を実装しました。
最後、残っているタスクは以下の2つです。

- DIとして``livewire``エイリアスにLivewireManager(乗っ取り版）を設定する
- Livewireの初期化として、カスタマイズ版``SupportFileDownloads``を利用するようにする。

Livewire本体が持っているサービスプロバイダの書き方に寄せて以下のように実装してみます。

``` app/Providers/LivewireCustomServiceProvider.php
<?php

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use App\Livewire\Features\SupportFileDownloads;
use App\Livewire\Livewire;
use App\Livewire\LivewireManager;
use Livewire\Features\OptimizeRenderedDom;
use Livewire\Features\SupportActionReturns;
use Livewire\Features\SupportBootMethod;
use Livewire\Features\SupportBrowserHistory;
use Livewire\Features\SupportChildren;
use Livewire\Features\SupportComponentTraits;
use Livewire\Features\SupportEvents;
use Livewire\Features\SupportFileUploads;
use Livewire\Features\SupportLocales;
use Livewire\Features\SupportPostDeploymentInvalidation;
use Livewire\Features\SupportRedirects;
use Livewire\Features\SupportRootElementTracking;
use Livewire\Features\SupportStacks;
use Livewire\Features\SupportValidation;

/**
 * LivewireServiceProviderカスタマイズプロバイダ
 */
class LivewireCustomServiceProvider extends ServiceProvider
{
    /**
     * サービスの初期登録処理
     *
     * @return void
     */
    public function register()
    {
        $this->registerLivewireSingleton();
    }

    /**
     * サービスの初期起動処理
     *
     * @return void
     */
    public function boot(): void
    {
        $this->registerFeatures();
    }

    /**
     * @override
     * @inheritDoc
     */
    protected function registerLivewireSingleton()
    {
        // LivewireManagerをカスタマイズ版に切り替えて登録する
        $this->app->singleton(LivewireManager::class);
        $this->app->alias(LivewireManager::class, 'livewire');
    }
    /**
     * @override
     * @inheritDoc
     */
    protected function registerFeatures()
    {
        // イベントリスナをすべてリセット
        Livewire::resetListener();
        // 元のLivewireServiceProviderで行っていたイベントリスナ登録処理を実行
        // NOTICE: Livewireの更新に合わせて変更をする必要あり
        SupportEvents::init();
        SupportStacks::init();
        SupportLocales::init();
        SupportChildren::init();
        SupportRedirects::init();
        SupportValidation::init();
        SupportBootMethod::init();
        SupportFileUploads::init();
        OptimizeRenderedDom::init();
        // ただしファイルダウンロードだけはカスタマイズ版イベントリスナを登録
        // Features\SupportFileDownloads::init();
        SupportFileDownloads::init();
        SupportActionReturns::init();
        SupportBrowserHistory::init();
        SupportComponentTraits::init();
        SupportRootElementTracking::init();
        SupportPostDeploymentInvalidation::init();
    }
}
```

``registerLivewireSingleton``メソッドでLivewireManager及びLivewireファサードを乗っ取ります。

そして``registerFeatures``メソッドにてLivewireManager(乗っ取り版)にLivewireのイベントリスナーを登録していきます。もちろんファイルダウンロードについてはカスタマイズ版のリスナー版に切り替えます。

``Support***::init()``メソッドですが、内部的には``Livewire::listen``メソッドでイベントリスナー登録を行います。
``registerLivewireSingleton``メソッドでファサードを乗っ取っているため、Livewireそのものが持つLivewireManagerではなくLivewireManager(乗っ取り版)にイベントリスナーが登録されます。

ここまでしてようやく、Livewire上で日本語ファイル名のダウンロードが機能するようになります。

## 乗っ取っておいてなんだけれど……

さて、目的の日本語ファイル名でダウンロードができましたが。

素直に今後のバージョンアップでの改修を待ったほうがよいかもしれないです。
__乗っ取る__ という言葉を使っているのはわざとで、Livewireのコア処理を書き換える試みなので、今後のバージョンアップの影響をもろに受けます。

特に``SupportFileDownloads``クラスとサービスプロバイダの``registerFeatures``メソッドは丁寧にリリースノートを追跡しないといつの間に破壊的変更が……なんてことになりかねません。

この事象、「マルチバイト文字を使用したファイル名でダウンロードしようとするとただしい名称にならない」という限りなく黒に近い不具合なので。
すでに先人がDiscussionを立てていますし。

[File dowload response replaces greek characters from the filename with underscores](https://github.com/livewire/livewire/discussions/4946)

いつかこの記事が役に立たなくなることを願って。