4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2023-03-21

もっとTL; DR

LiveWireではすでに対応済み。この記事は気にするな!

Fix SupportFileDownloads - Multibyte character support - GitHub Livewire

TL; DR;

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

バージョン

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

問題

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

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

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

答え

__.csv

なぜ?

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

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

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

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

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

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

4
1
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?