【24日目】ストリーミング再生に対応する【作曲の補助ツールを作るまでの日記】

プログラミング

2024年3月27日~2024年4月10日

”多分動くからリリースする”くらいの気持ちじゃないとそもそもリリースに辿り着けない気がするので、本当にもうリリースにいきたい。
……と言っても本当に最低限やらなきゃいけないことはあるので、そこだけやる。

流れとしては

  • 音源データをストリーミングメディア化できないか
  • IPBANの実装
  • Cokkieの承認はいるか
  • サービス名を決める
  • 限定公開は電気通信事業法に抵触しないか
  • Consoleの出力を消す
  • テストの確認(Duskは必要そうならでおk)
  • OWASPやる
  • プライバシーポリシー、利用規約
  • メールの文章
  • クラウドの契約
  • ドメイン取得
  • HTTPS化
  • デプロイ
  • サービス開始
  1. 音源データをメディアストリーミング化できないか
    1. ストリーミングメディアとは何か
      1. ストリーミングメディアとプログレッシブダウンロードの違いは何か
      2. ストリーミングメディアの代表プロトコルHLSとMPEG-DASH
    2. FFmpeg
      1. FFmpegとは
    3. FFmpeg-Laravelを利用してmp3をHLSにするまで
      1. FFmpegを入れる
      2. Laravel-FFMpegを入れる
      3. Laravel-FFMpegを使ってみる
      4. HLSへ変換するコードを見てみる
      5. HLSのtsファイルが1つしか生成されない
      6. Audioにカバーが付いているとVideo判定を食らう
      7. ソースコードを変えて修正する
      8. カバーを削除する処理を加える
    4. HLS(.m3u8)を再生する
    5. hls.jsを入れる
    6. HLSをどう私のサイトに落とし込むか
    7. UserUploadedFileの仕組みを変える
      1. UserUploadedFileをポリモーフィックで実装する
      2. 複数の物理ファイル削除をどうするか
      3. Slackに通知を飛ばす
    8. 非同期処理をしたい
      1. 非同期処理のテストを書く
    9. メインファイルを閲覧できないようにする
    10. 最後にHLSの再生用JSを作る
  2. CKEditorからレスポンスを貰う
    1. バリデーションエラーをJSONで返す
    2. エラーレスポンスの方法を考える
      1. Imageのエラーレスポンスはどうするか
      2. Audioも通知で表示するシステムにしてみる
  3. フロントエンドのサニタイズはセキュリティ的に必要か
    1. 結果を共有出来る場合
    2. Self-XSS
  4. いったん終わり
  5. 【追記】ポリモーフィックリレーションの削除を設定する

音源データをメディアストリーミング化できないか

私のサイトは現在audio要素を直接埋め込むことで記事内で音を扱えるようにしている。

利便性的には問題ないのだけど、これ、簡単にダウンロードできてしまうのがマジで良くない。

一応「controlslist=”nodownload”」をaudioに付ければダウンロードボタンは出なくなるらしいんだけど、どちらにしろ開発者ツールで簡単にダウンロードできてしまう。

Webに公開する以上、完全な対策は厳しいんだけど、音は基本的にどんなものも著作権があるので気軽にダウンロードできるようにはしたくない。そういう使われ方をしてほしくない。

他のサイトはどうやって対策しているかなんだけど、YoutubeやSoundCloudはストリーミングメディアという方式で対策? している。副作用で結果対策になっているとも言える。

ストリーミングメディアとは何か

ストリーミングメディアを理解する。

ソースは以下の通り

ストリーミングメディアを簡単に言うと、メディアである動画や音声ファイルを一括でダウンロードするんじゃなく、分割でダウンロードして再生していく方式。

Youtubeって読み込んだ部分がグレーになるじゃん。もし、一括でダウンロードしてたら全部がグレーのはずだよね。

Youtubeは動画を何分割にもして、部分部分で提供してる。そして、違和感ないよう繋げて再生している。だから、1回でダウンロードする量は少なくてすむし、分割されているので違法ダウンロードもしにくい。

違法ダウンロードはしにくいっていうだけで、ツールとかを使えば1つになったものをダウンロードできちゃうんだけど、しにくいことに意味がある。

ストリーミングメディアとプログレッシブダウンロードの違いは何か

ストリーミングメディア以外にも、プログレッシブダウンロードというものがある。
ストリーミングメディアとプログレッシブダウンロードは似てるんだけど、ちょっと違う。

ストリーミングメディアもプログレッシブダウンロードも分割してメディアをダウンロードしていくんだけど、ストリーミングメディアはダウンロードしたセグメントを随時削除していく。それに対しプログレッシブダウンロードは部分的にダウンロードしたものを1つの固まりとして形成させていく。

だから、最終的にプログレッシブダウンロードだと1つのファイルとしてキャッシュに残ってしまい、著作権的にまずい。というか、audioタグは勝手に裏でプログレッシブダウンロードをやってるんじゃないかな。しらないけど。

それに対しストリーミングメディアは、分割してダウンロードしたらファイルは統合せずに随時古いものから削除していくので、完全体がキャッシュに残らないし違法ダウンロードもしにくい。

つまり、私がしたいのはストリーミングメディアである。

ストリーミングメディアの代表プロトコルHLSとMPEG-DASH

ストリーミングメディアには代表的なプロトコルが2つある。
それがHLSとMPEG-DASH。

この2つの理解はMPEG-DASHとは?| HLSとDASHというCloudflareのサイトが分かりやすい。

一応雑にまとめると

  • HLS(HTTP Live Streaming)
    • Appleが開発
    • 国際規格じゃないが2022年時点で最も使われている
    • Appleデバイスが対応している唯一の規格
    • 2009年にリリース
    • エンコードフォーマットが限られている
  • MPEG-DASH(Dynamic Adaptive Streaming over HTTP)
    • 国際規格
    • Appleデバイスに対応してない
    • 2012年に国際化
    • エンコードフォーマットがHLSと比べ自由

DASHの方が新しくて多機能だけどAppleが完全には対応してないので、HLSのみ、またはHLSとDASHの併用になるのかな。

FFmpeg

どうやらHLSなどはFFmpegというのを使えば比較的簡単に対応できるらしい。

また、Laravel-FFMpegというHLSに対応してる素晴らしいパッケージを提供してくれている方もいるので頑張ればできそう!

HTMLPurifierを教訓に、まず本家であるFFmpegから学びたい。

FFmpegとは

公式ドキュメントにあるものをまとめると、色んなメディアファイルを色んな環境で色んなことができるよ! ってこと。

だから、Linuxでも動くし、音声ファイルも弄れるし、ストリーミングもできるということ。
しかも無料。FFmpegのライセンスGNU Lesser General Public License (LGPL) バージョン 2.1

なんかこれから先もお世話になりそうな予感がするね。

FFmpeg-Laravelを利用してmp3をHLSにするまで

恐らく、HLS化の流れとしては

  1. ユーザーがmp3をアップロード
  2. mp3をpublicじゃないところに保存(外からアクセスできないように)
  3. 保存と同時にpublicにHLS化
  4. それぞれの場所をDBに保存?
  5. 再生時はHLS化されたものを提供
  6. HLSプレイヤーで再生

こんな感じだと思う。

FFmpegを入れる

まず、RHEL9環境でFFmpegを入れる。

RHELの標準リポジトリにFFmpegは入っていないので、サードパーティーリポジトリから落とすことになるんだけど、公式ページのRHELを押してみるとRPM Fusionというリポジトリに飛んだ。

このRPM FusionからダウンロードするのがFFmpeg的には推奨されてるのかな。

どうやらRPM Fusionにはfreeとnonfreeのリポジトリがある。

引用すると

  • free for Open Source Software (as defined by the Fedora Licensing Guidelines) which the Fedora project cannot ship due to other reasons
  • nonfree for redistributable software that is not Open Source Software (as defined by the Fedora Licensing Guidelines); this includes software with publicly available source-code that has “no commercial use”-like restrictions

これは無料・有料のfreeではなく、商用利用が自由か否かみたいな話みたい。

FFmpegのダウンロード方法をみるとfreeを使ってダウンロードしてる人もいるし、nonfreeを併用して使ってダウンロードしてる人もいるし、正解がわからない。
正直どうダウンロードすればいいのかわからないので、とりあえずfreeだけを利用してダウンロードしてみる。

まずRPM Fusionのfree版をダウンロード
sudo dnf install https://mirrors.rpmfusion.org/free/el/rpmfusion-free-release-$(rpm -E %rhel).noarch.rpm

ダウンロードが終わったら、恐らくデフォルトで有効になってしまっているので、
sudo vi /etc/yum.repos.d/rpmfusion-free-updates.repo
でenable=0にする。

そしたら
sudo dnf –enablerepo=rpmfusion-free-updates –enablerepo=epel install ffmpeg
でインストール。

このコマンドはepelとrpmfusion-freeを許可してる。

FFmpeg自体はRPMFusionのupdatesリポジトリでダウンロードするんだけど、その依存関係をEPELでダウンロードしてる。

また、RPM Fusionの公式サイトを見ると現時点updatesのリポジトリでEL系の場合、FFmpegは5.1.4までしか対応してないみたい。

Laravel-FFMpegではver.4とver.5でテスト済みとあるので、恐らく問題は無い。

インストールしたら「ffmpeg -version」でバージョンが返ってくればおk。

Laravel-FFMpegを入れる

FFmpegを入れたらLaravel-FFMpegを入れる。
composer require pbmedia/Laravel-FFMpeg
でcomposerから入れればおk。

Laravel-FFMpegを使ってみる

実際に使えるかを試してみる。

公式のGitHubにある

FFMpeg::fromDisk('songs')
    ->open('yesterday.mp3')
    ->export()
    ->toDisk('converted_songs')
    ->inFormat(new \FFMpeg\Format\Audio\Aac)
    ->save('yesterday.aac');

をちょっと弄ってmp3をaacに変換する作業を行う。

fromDisk()とtoDisk()はconfig/filesystems.phpで定義されたディスク名。
open()とsave()はそのディスクからの相対パス。
export()は恐らく変換を始めますよ宣言。
inFormat()はどのフォーマットで変換するか。

つまり、私の場合は

FFMpeg::fromDisk('public')
   ->open($path)
   ->export()
   ->toDisk('public')
   ->inFormat(new \FFMpeg\Format\Audio\Aac)
   ->save('ffmpegTest/test.aac');

このようにすれば音をアップロードするときにその音をffmpegTest/test.aacとして変換してくれるはず。

うん、動かない。

「tail -n 100 storage/logs/laravel.log」でエラーログをみてみると
Unknown encoder ‘libfdk_aac’
との文字が。

どうやらlibfdk_aacというaacエンコーダーが入ってないのが問題みたい。

FFmpegのページをみるとこれはインストールミスとかではなく、libfdk_aacがGPLライセンスと互換性が無いからlibfdk_aacを入れて配布することができないんだって。難しいね。

どうやらaacというネイティブエンコーダーを使えば出来なくはないみたいなので、libfdk_aacじゃなくaacを指定してみる。

独自のフォーマットクラスを作り適用する

\FFMpeg\Format\Audio\AacにあるのはPHP-FFMpegのフォーマットクラスというものらしい。
中身は以下のようになっていた

namespace FFMpeg\Format\Audio;

class Aac extends DefaultAudio
{
    public function __construct()
    {
        $this->audioCodec = 'libfdk_aac';
    }

    public function getAvailableAudioCodecs()
    {
        return ['libfdk_aac'];
    }
}

これ、Laravel-FFMpegでPHP-FFMpegを使って、PHP-FFMpegでFFmpegを使ってるんだね。

つまり、DefaultAuidoを継承したフォーマットクラスを作成し、’libfdk_aac’の部分を’aac’にしてそれを使えば変換できるはず。

app/Format/AudioにCustomAacFormat.phpを作り、以下のように定義する

<?php

namespace App\Format\Audio;

use FFMpeg\Format\Audio\DefaultAudio;

class CustomAacFormat extends DefaultAudio
{
   public function __construct()
   {
       $this->audioCodec = 'aac';
   }

   /**
    * @inheritDoc
    */
   public function getAvailableAudioCodecs()
   {
       return ['aac'];
   }
}

定義できたらcomposer dump-autoloadして

FFMpeg::fromDisk('public')
   ->open($path)
   ->export()
   ->toDisk('public')
   ->inFormat(new \App\Format\Audio\CustomAacFormat)
   ->save('ffmpegTest/test.aac');

のように使えばおk。

実際に変換してみると……

いいね!

HLSへ変換するコードを見てみる

mp3をaacに変換するのはできたので、一番大事なHLSへの変換もやってみよう。

公式GitHubの説明にあるのは以下のようなコード

$lowBitrate = (new X264)->setKiloBitrate(250);
$midBitrate = (new X264)->setKiloBitrate(500);
$highBitrate = (new X264)->setKiloBitrate(1000);

FFMpeg::fromDisk('videos')
    ->open('steve_howe.mp4')
    ->exportForHLS()
    ->setSegmentLength(10) // optional
    ->setKeyFrameInterval(48) // optional
    ->addFormat($lowBitrate)
    ->addFormat($midBitrate)
    ->addFormat($highBitrate)
    ->save('adaptive_steve.m3u8');
  • $xxxBitrateというのが3つあるのはアダプティブビットレートストリーミングに対応するため。
    Youtubeを見てるとき、いきなり画質が悪くなったりすることがあるよね。あれはネットの帯域幅≒ビットレートによって動的に提供する動画の品質を変えてるから起こること。
    もし、そんな感じでユーザーの状態によって提供する品質を変えたければ何個かビットレートを変えたものを予め出力しておく。
  • setSegmentLength(10)は、1ファイルあたり何秒のセグメントにするかの設定。デフォルトは6秒なのかな?
  • setKeyFrameInterval(48)は、キーフレームの設定を変えられるものらしいけど、音声ファイルを弄るときにはあまり関係ないっぽいのでスルー。
  • addFormat($xxxBitrate)で、そのフォーマットに変換
  • save(‘adaptive_steve.m3u8’)で、saveしてるんだけど.m3u8という拡張子は見覚えが無いよね。
    この.m3u8がHLSのメインファイルで、ここに各セグメントの再生順序とかパスとかが書いてある。

GitHubでプルリクしてた方のコードを参考にして、以下のようにした。

$bitrate = (new X264)->setAudioKiloBitrate(128);
FFMpeg::fromDisk('public')
   ->open("ffmpegTest/output.mp3")
   ->exportForHLS()
   ->addFormat($bitrate)
   ->setSegmentLength(10)
   ->save('ffmpegTest/output.m3u8');

実際に音声を入れてみると

お~、なんかできてる。

恐らくtest.m3u8を読み込ませればHLSで再生できるはず。

ちなみに元のmp3が55.4KBで、m3u8が.tsファイルも含めて21.7KB。aacってすごいなぁ。

HLSのtsファイルが1つしか生成されない

一応、これで再生できるようにはなった。

しかし、3分などの長い音楽でもtsファイルが1つしか生成されない現象が

本来は3分であれば10秒区切りで18個くらい? のtsファイルがあるはずなんだけど……1個しかない。

しかも、これ色々検証してたらtsファイルが正常に生成されるmp3と1個しか生成されないmp3がある。

具体的には、bandcampからダウンロードしたmp3はtsファイルが1個になっちゃって、魔王魂からダウンロードしたmp3はしっかりHLS化される。

色々試してみて、コマンドラインからFFmpegのHLSを実行したらどうなるんだろうと思ってやってみたら普通にbandcampからダウンロードしたmp3でもHLS化できた。

これ、Laravel-FFmpegかPHP-FFmpegでなんか変なことなってるよね。うーん。どうしたもんか。
ちなみに、issueとかは見たんだけど同じような問題を訴えている人はいないっぽい。しかも、2か月前くらいにaudioのhls化が正常に動くようになったらしく、バグがまだ残っててもおかしくない状況。

Laravelのログをみるとご丁寧にFFmpegでどんなコマンドが動いたのかが出力されているんだけど、以下のようになっている。

ffmpeg running command '/usr/bin/ffmpeg' '-y' '-threads' '12' '-i' '/media/sf_Share/QTM/storage/app/public/uploads/user/279/articles/01HT0A90FQYTQ9A8P9CQ85KZ22/xlM7rAH2SHkaemiPjqG8xSIHmt67irqxYSCtaFYz.mp3' '-map' '0' '-b:v' '1000k' '-sc_threshold' '0' '-g' '48' '-hls_playlist_type' 'vod' '-hls_time' '10' '-hls_segment_filename' '/media/sf_Share/QTM/storage/app/public/ffmpegTest/test_0_1000_%05d.ts' '-master_pl_name' 'temporary_segment_playlist_0.m3u8' '-acodec' 'aac' '-b:a' '128k' '/media/sf_Share/QTM/storage/app/public/ffmpegTest/test_0_1000.m3u8'

このログはbandcampからダウンロードしたmp3を変換したときのものなんだけど、どうやらVideo判定を食らって‘-b:v’ ‘1000k’ という不適切なオプションが付いている。本当は‘-b:a’ ‘128k’ だけでいいんだけどね。

ソースコードを見ていないので憶測でしかないんだけど、bandcampからダウンロードしたmp3は恐らくどこかでVideoの判定をもらって‘-b:v’ ‘1000k’というオプションがついちゃってるっぽいなぁ。

ソースコードを辿って修正するかFFmpegをLaravelから直接使うかなんだけど、直接FFmpegを使うのはそれはそれで面倒そうなのよね。

また、さっきのHLS化するコマンドの前にも何個かFFmpegコマンドは実行されていて、その中でも注目したいのが

ffprobe running command '/usr/bin/ffprobe' '/media/sf_Share/QTM/storage/app/public/uploads/user/279/articles/01HT0A90FQYTQ9A8P9CQ85KZ22/xlM7rAH2SHkaemiPjqG8xSIHmt67irqxYSCtaFYz.mp3' '-show_streams' '-print_format' 'json'

というコマンド。

ffprobeというのはメディアファイルを解析するものらしく、恐らく何かの判断の為に実行されているはず。

実際にこれをコマンドラインで実行してみるとmp3の情報がめっちゃ出てくる。というか、bandcampの場合メタデータに歌詞とかも入ってるから著作権的に見せれない。

恐らくここで参照したデータの何かでVideoと判断してしまっていると思うんだよね。

Audioにカバーが付いているとVideo判定を食らう

原因がわかった。

issueは建ってないといったけど、Laravel-FFMpegじゃなくPHP-FFMpegに建ってた

issue曰く、audioにカバーが付いているとメタデータにvideoが入っちゃってそれに反応してvideoの処理になるっぽい。

実際の事件現場を見てみよう。

public function open($pathfile)
{
   if (null === $streams = $this->ffprobe->streams($pathfile)) {
       throw new RuntimeException(sprintf('Unable to probe "%s".', $pathfile));
   }

   if (0 < count($streams->videos())) {
       return new Video($pathfile, $this->driver, $this->ffprobe);
   } elseif (0 < count($streams->audios())) {
       return new Audio($pathfile, $this->driver, $this->ffprobe);
   }

   throw new InvalidArgumentException('Unable to detect file format, only audio and video supported');
}

この「   if (0 < count($streams->videos())) {」が犯人だね。

ちなみに、viedoのカウントは以下のようなコードでしてる。

public function isVideo()
{
   return 'video' === $this->get('codec_type');
}

つまり、codec_typeがvideoのメタタグが1つでも入ってたらそれはVideo判定ってこと。

これだけ聞くとまっとうな実装なんだけど、カバーが付いたmp3は以下のようになぜかvideoのcodec_typeが付いてしまうので誤判断するんだね。

            "index": 1,
            "codec_name": "mjpeg",
            "codec_long_name": "Motion JPEG",
            "profile": "Baseline",
            "codec_type": "video",

さっきも貼った「I have an audio file ,But the open method in FFMpeg \ FFMpeg will be considered a video file」というissueで解決策が提案されているのでそれをみてみる

$ffmpeg = FFMpeg::create();
$streams = $ffmpeg->getFFProbe()->streams('/path/to/file.mp3');

// Validate the codec types are actually video streams.
$videoStreams = $streams->videos()->count();
foreach ($streams->videos() as $stream) {
  if (in_array($stream->get('codec_name'), ['png', 'jpeg', 'mjpeg'])) {
    $videoStreams--;
  }
}

if ($videoStreams) {
  $stream = new Video($uri, $ffmpeg->getFFMpegDriver(), $ffmpeg->getFFProbe());
}
elseif ($streams->audios()->count()) {
  $stream = new Audio($uri, $ffmpeg->getFFMpegDriver(), $ffmpeg->getFFProbe());
}

// Do something with the abstract streamable media.

この改善案で重要なのは

$videoStreams = $streams->videos()->count();
foreach ($streams->videos() as $stream) {
  if (in_array($stream->get('codec_name'), ['png', 'jpeg', 'mjpeg'])) {
    $videoStreams--;
  }
}

の部分。

ずばり、videoと判断された上でpng,jpeg,mjpegのいずれだったらカウントをマイナスにしてなかった事にする作戦。

将来的にpng,jpeg,mjpegじゃないカバーも増えたら面倒なんだけど、もうしょうがないね。
この修正で行こう。

というかissue建ってたのにcloseされていまだに修正来てないのはなんなんだろう。
OSSの運営とか、プルリクエストとか一回もやったことないからなんもわかんないんだけど、なんか理由があるのだろうか。

前のCKEditorの修正もプルリクエストしてみようかなとも思ったんだけど、やり方とか作法とか英語がわからな過ぎてやめちゃったんだよね。
いつかそういうのもやっていきたい。

ソースコードを変えて修正する

前回はpatch-packageを使ってCKEditorの修正をしたんだけど、今回はnpmじゃなくcomposerなので別の方法でパッチを適用する。

composerではcweagans/composer-patchesというパッケージを使ってパッチを当てるみたい。
使い方は「PHP の Carbon をだいたい 3 倍くらい高速化した話 (または composer-patches の使い方)」がわかりやすかった。

とりあえず
composer require cweagans/composer-patches
でインストール。

今回修正するのはPHP-FFMpegのコードなので、Laravel-FFMpegではなくPHP-FFMpegをcloneする。
git clone https://github.com/PHP-FFMpeg/PHP-FFMpeg.git

クローンしたら修正を加えたいところを修正する。

今回は「src\FFMpeg\FFMpeg.php」のopenメソッドを以下のような感じにした。

    public function open($pathfile)
    {
        if (null === $streams = $this->ffprobe->streams($pathfile)) {
            throw new RuntimeException(sprintf('Unable to probe "%s".', $pathfile));
        }

        $videoStreams = $streams->videos()->count();
        $audioStreams = $streams->audios()->count();

        // カバーをビデオストリームから除外
        foreach ($streams->videos() as $stream) {
            if (in_array($stream->get('codec_name'), ['png', 'jpeg', 'mjpeg'])) {
                $videoStreams--;
            }
        }

        if (0 < $videoStreams) {
            return new Video($pathfile, $this->driver, $this->ffprobe);
        } elseif (0 < $audioStreams) {
            return new Audio($pathfile, $this->driver, $this->ffprobe);
        }

        throw new InvalidArgumentException('Unable to detect file format, only audio and video supported');
    }

修正したらgit diffして出てきたものを「好きな名前.patch」で保存。
おすすめは「git diff > 好きな名前.patch」で保存しちゃう。windowsならgit bashとかで。

diff --git a/src/FFMpeg/FFMpeg.php b/src/FFMpeg/FFMpeg.php
index f919a61..4fb29ec 100644
--- a/src/FFMpeg/FFMpeg.php
+++ b/src/FFMpeg/FFMpeg.php
@@ -94,9 +94,19 @@ class FFMpeg
             throw new RuntimeException(sprintf('Unable to probe "%s".', $pathfile));
         }

-        if (0 < count($streams->videos())) {
+        $videoStreams = $streams->videos()->count();
+        $audioStreams = $streams->audios()->count();
+
+        // カバーをビデオストリームから除外
+        foreach ($streams->videos() as $stream) {
+            if (in_array($stream->get('codec_name'), ['png', 'jpeg', 'mjpeg'])) {
+                $videoStreams--;
+            }
+        }
+
+        if (0 < $videoStreams) {
             return new Video($pathfile, $this->driver, $this->ffprobe);
-        } elseif (0 < count($streams->audios())) {
+        } elseif (0 < $audioStreams) {
             return new Audio($pathfile, $this->driver, $this->ffprobe);
         }

保存したらそれをLaravelのどこかに置く。私はLaravelのルートフォルダ直下にpatchesディレクトリを作ってそこに置いた。

そんで、composer.jsonで以下のようにする。

"extra": {
   "patches": {
       "php-ffmpeg/php-ffmpeg": {
           "カバーが付いたオーディオファイルをVideoと判定しちゃうのを修正": "./patches/fix-video-detection.patch"
       }
   }
},

こうしたうえでcomposer installすると完了……なんだけど。エラー。

「composer install -vvv」で詳細情報を見れるみたいなので、みてみると
line 1: patch: command not found
とのこと。

どうやらpatchというコマンドが無いらしい。RHEL系なので
sudo dnf install patch
でインストール。

リベンジcomposer install。

無事適用できた。

これでしっかりとHLSできるかな?

うん。できなかった。

この流れでできないことある?

というかソースコード無理やり

if (0 < $videoStreams) {
   return new Audio($pathfile, $this->driver, $this->ffprobe);
} elseif (0 < $audioStreams) {
   return new Audio($pathfile, $this->driver, $this->ffprobe);
}

みたいにしてもダメだった。

キャッシュクリア系の問題かと思い、あらゆるキャッシュをクリアしたけどダメだった。

うーん。どちらにしろcodec_typeにVideoがあるからっていうのは合ってると思うんだけどなぁ。

……

ソースコードにerror_log()突っ込んでデバッグしたりしてみたんだけど、恐らくLaravel-FFMpeg側でもmp3にカバーが付いているとVideoとして処理してるみたい。

というか、元々Videoにしか対応してなかったHLSに無理やりAudio対応も実装してるからこうなってるっぽい? なんか出力されるコマンドを見る限り、videoのHLS化のコマンドにaudioのHLS化のコマンドも足してFFMpegが空気読んでaudioの処理をしてくれるみたいな感じになってる。つまり、カバーが付いてるとFFMpegの空気読みがビデオになるという感じ。

そうなるとやっぱり元凶はmp3にカバーが付いてしまっていることだし、Laravel-FFMpegの修正をしようとすると、完全に新しいAudioのHLS処理を1から書く必要があるのかなぁ。

カバーを削除するアプローチに切り替える。

カバーを削除する処理を加える

具体的にやりたい処理が
ffmpeg -i input.mp3 -vn -acodec copy output.mp3
というもの。

それぞれ、

  • -iがインプット
  • -vnがビデオストリームの削除。つまりカバーのあれを消してくれる
  • -acodecがコーデックの設定で
  • -acodec copyがエンコードせずにそのままコピーでいいよっていう命令

実際にこれでbandcampからダウンロードしたオーディオのカバーを消したら、普通にHLSは生成できた。

正直、セキュリティ度外視なら
exec(‘ffmpeg -i input.mp3 -vn -acodec copy output.mp3’);
でコマンドは打てるらしいんだけど、外部変数を入れてコマンド生成して実行とか怖すぎてできない。

その為、Laravel-FFMpegで処理を書く。
実際に書いてみると

FFMpeg::fromDisk('public')
   ->open($path)
   // ビデオストリームを削除し、カバーアートを削除している。 カバーアートがあるとHLS変換時にビデオ判定される。
   ->addFilter(['-vn'])
   ->export()
   ->toDisk('public')
   ->inFormat(new CopyFormat())
   ->save("ffmpegTest/output." . $file->getClientOriginalExtension());

のようにできた。

addFiliterで-vnも適用できるし、CopyFormatでコピーの処理もできる。
すごいぞLaravel-FFMpeg!

処理時間も以下みたいなのを入れて測ってみた。

error_log(
   "mp3への変換処理開始" . microtime(true) - LARAVEL_START . "\n",
   3,
   "/media/sf_Share/QTM/storage/logs/laravel.log"
);

5.8MBの3分の曲の場合、カバー削除は0.8秒でHLS化が11秒。HTTPレスポンスまで13秒という感じ。
そんな3分の曲をアップロードすることは稀だと思うんだけど、長いなあ。

とりあえず、カバーを削除することで無理やり対応はできたのかなぁ。

正直、カバー付きのwavだとどうなるかわかんないし、カバーの付与にも色々方法があるっぽい? のでテスト作らないとダメだねこれ。

HLS(.m3u8)を再生する

HLS化することはできたので、後は再生の仕組みを作る。

HLSはAppleが作成したものなので、audio要素でm3u8を指定すればsafariとかならそのままでも動くらしい(未検証)。
しかし、ChromeやFirefoxでは動かないので、JavaScriptでプレーヤーを作る必要がある。

有名なプレイヤー作成サービスは、hls.jsVideo.jsShakaらへんっぽい。

Video.jsとかShakaは高機能、hls.jsは機能が限られているけどその分シンプルって感じ。

出来るだけシンプルなものがよいので、hls.jsを使う。

hls.jsを入れる

npmから入れる。
npm install hls.js
でおk

入れたらhls.js用のjsファイルを作って取り込む。

import Hls from 'hls.js';

const audioSrc = 'http://qtm.test/storage/ffmpegTest/output.m3u8';

var audioPlayer = document.getElementById('audioPlayer');
if (Hls.isSupported()) {
   var hls = new Hls();
   hls.loadSource(audioSrc);
   hls.attachMedia(audioPlayer);
   // ブラウザがデフォルトで対応してたらそのまま埋め込む
} else if (audioPlayer.canPlayType('application/vnd.apple.mpegurl')) {
   audioPlayer.src = audioSrc;
}

以上のコードは、auidoPlayerのidが付いたauido要素をHLS化するというもの。
超簡単で素晴らしい。

実際にみてみると、以下のような感じでHLS再生に対応できた。

最初は見た目も変えようかなと思ったんだけど、別にこの見た目で問題は無い気もするので保留。

HLSをどう私のサイトに落とし込むか

とりあえず、mp3とwavのHLS化と再生はできるようになった。
後はこの技術をどうサイトに落とし込むか。

正直、

  1. ユーザーが音源をアップロード
  2. その音源のカバー(Videoストリーム)を削除したものを保存
  3. 音源のHLS化を始める(非同期)
  4. DBに保存するのはカバーを削除した音源のみで、HLS化は元の音源とフォルダの構造を一緒にして使う?
  5. 非同期なのでHLS化が終わる前にCKEditorにレスポンス
  6. 再生はHLSで再生

という感じで良さそうではあるんだけど、懸念点がある。

  1. 勝手にmp3を改変するなら著作権的に利用規約とかに書いとかないとダメかも。(画像や文章でも同じことは言える)
  2. 元の音源やHLSをどう守るか
  3. HLS化の処理は重いから、1つのサーバーだとそもそも厳しいかも

UserUploadedFileの仕組みを変える

現在UserUploadedFileというモデルでAudioもImageも管理してるんだけど、将来的な事を考えて分離しておきたいとコードを弄ってて思った。

具体的には、今ある以下のようなUploadedFilesを以下のようなImagesとAudiosにしたい。

user_uploaded_filesimagesaudios
ididid
user_iduser_iduser_id
file_namefile_namefile_name
file_pathfile_pathfile_path
file_extensionfile_extensionfile_extension
file_sizefile_sizefile_size
datetimedatetimedatetime
dimentionduration

結構被る部分もあるし、ポリモーフィックリレーションとかにしようかと思ったんだけど、アンチパターンという意見もあるみたい。
これは、参照整合性制約を満たせないから。つまり、外部キーを使って他のテーブルと関係を保証できないから良くないねという話みたい。
また、複雑性も上がって理解しにくいねというのもある。

うーん。めっちゃシンプルにするなら完全に分離させた方がいいかなぁ。でも、シンプルにはなるんだけど、直観に反するんだよね。

Fileという骨格があって、それをImageとAudioが継承しているみたいな考え方の方が直観的なんだよね。
うーん。一度ポリモーフィックリレーションを使ってみる。
確かに参照整合性の制約は満たせないかもしれないけど、一回使って体験してみたい。

UserUploadedFileをポリモーフィックで実装する

直観的には、user_uploaded_filesという親があって、そこにaudiosとimagesが属するという感じ。
しかし、システム上ではaudiosとimagesにuser_uploaded_filesが属するという扱いになるらしい。

それぞれのテーブルは以下の通り。
また、user_uploaded_filesはmedia_filesに変更。

media_files

カラム名備考
id普通のid
user_idこのファイルを投稿したユーザー。onDelete
file_nameファイルについてた名前
file_pathファイルが保存されているパス
file_extensionファイルの拡張子
file_sizeファイルの大きさ
mediale_idAudioまたはImageのid
fileable_typeAuidoまたはImageのモデル
created_at
updated_at

audios

カラム名備考
idulid
duration音源ファイルの秒数
hls_pathHLSの保存先
created_at
updated_at

images

カラム名備考
idulid
dimention画像の寸法
created_at
updated_at

注意したいのが、参照整合性をLaravel側で取らなきゃいけないということ。

外部キー制約が付けれると、userとmedia_filesのようにuserが消えたときmedia_filesも削除みたいなことができるし、参照整合性を勝手に取ってくれる。
しかし、今回はポリモーフィックなので外部キー制約が無い。つまり、media_filesを削除したときimagesまたはaudiosが消えるようにしなきゃいけない。その逆もまたしかり。

まずモデルの定義からする。
今回MediaFileとAudio,Imageは一対一の関係を持つ。

その為MediaFileモデルでは

public function mediable(): MorphTo
{
   return $this->morphTo();
}

で、Audio,Imageモデルでは

use HasUlids;

public function mediaFile(): MorphOne
{
   return $this->morphOne(mediaFile::class, 'mediable');
}

みたいになる。

また、MediaFIleとUserでもリレーション設定をすると

public function user()
{
   return $this->belongsTo(User::class);
}
public function mediaFiles()
{
   return $this->hasMany(MediaFile::class);
}

以下のようにしてユーザーのAudioとImageを同時に取得できるようになる。

$mediaFiles = $user->mediaFiles()
   ->with("mediable")
   ->paginate(config("const.pagination.perPage"));

便利!
with(“mediable”)をつけることでAudioとImageに保存されている情報も取得している。

また、例えばAudioとImageの保存は以下のようになっている。

// audiosへの保存
$audio = new Audio();
$audio->duration = $processedDuration;
$audio->hls_path = $saveHLSPath;
$audio->save();

//media_filesへの保存
$mediaFile = new MediaFile();
$mediaFile->user_id = $user->id;
$mediaFile->file_name = $file->getClientOriginalName();
$mediaFile->file_path = $savePath;
$mediaFile->file_extension = $file->getClientOriginalExtension();
$mediaFile->file_size = $processedFileSize;
$mediaFile->mediable_id = $audio->id; // AudioインスタンスのID
$mediaFile->mediable_type = Audio::class; // モデルのクラス名
$mediaFile->save();

これをトランザクション処理とかですれば問題はない……はず。

AudioはFFMpegで、ImageはInterventionで処理している。

削除をどうするかなんだけど、AudioはHLSの削除処理があったりするのでAudioとImageで分けたい。
しかし、MediaFilesで処理をまとめたほうが楽ではあるのでMediaFilesのコントローラ内で分岐する形で良いとは思う。

複数の物理ファイル削除をどうするか

DBの削除はトランザクションでロールバックできるけど、物理ファイルの削除はロールバックできない。
だから、2個消したいファイルがあるときに2個目の削除でエラーになると1個目は消えているのに2個目は消えていないという状況になり得る。

これをどう解決するかなんだけど、解決と言えるかはわからないけど、私はログをSlackに通知する方式にした。

Slackに通知を飛ばす

私の場合は【簡単!】SlackのIncoming Webhook URLを取得する方法を参考にWebhookのURLを取得して、そこに通知を飛ばす方式にした。
日本語ドキュメント

config/logging.phpを以下のようにし

'stack' => [
   'driver' => 'stack',
   'channels' => ['single', 'slack'],
   'ignore_exceptions' => false,
],
'slack' => [
   'driver' => 'slack',
   'url' => env('LOG_SLACK_WEBHOOK_URL'),
   'username' => 'Laravel Log',
   'emoji' => ':boom:',
   'level' => env('LOG_LEVEL', 'critical'),
],

.envを以下のようにすれば

LOG_CHANNEL=stack
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
LOG_SLACK_WEBHOOK_URL=ウェブフックのURL
\Log::debug('これはSlackに送られるdebugメッセージです。');

こんな感じでSlackに通知を送れる。ひとまずこれでいいのではないだろうか。

また、エラーレベルについてはこちらの「ログレベルちゃんと使い分けてますか?」がわかりやすい。
とりあえずerror以上をslackに通知してもらうようにする。

以下のように、削除に失敗したらそのpathを送ってもらうようにして完了。

非同期処理をしたい

HLSの処理は時間が掛かるので非同期処理を実装したい。というかそもそもできるのだろうか。

どうやらキューを使うと非同期処理が可能っぽい。
解説は「Laravelでの非同期処理についてまとめてみた」がわかりやすい。

また、キューの日本語ドキュメントも良い。しかしドキュメント長いな。

仕組み的には、

  1. 処理内容をジョブとして定義
  2. 処理したいとき、キューにジョブをプッシュ
  3. 後からワーカーがジョブを順次実行で処理

という感じ。

つまり、HLSの処理は直接やらずに、ジョブとしてキューにプッシュし後からワーカーにやってもらうという仕組み。

実際にやってみる。

キューへのプッシュを記録するためにテーブルを作成する。
php artisan queue:table
php artisan migrate

これで出来るのはjobsとfailed_jobs。名前のまんまの機能。
基本jobsは古いものから処理され、失敗したらfailed_jobsに記録される。

次にJobクラスを作る。

今回はHLS化する処理なので
php artisan make:job ConvertToHLSJob
で作成。

以下のように記述した。

class ConvertToHLSJob implements ShouldQueue
{
   use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

   protected $audioPath;
   protected $hlsPath;

   /**
    * Create a new job instance.
    */
   public function __construct($audioPath, $hlsPath)
   {
       $this->audioPath = $audioPath;
       $this->hlsPath = $hlsPath;
   }

   /**
    * Execute the job.
    */
   public function handle(): void
   {
       // HLS化
       $midBitrate = (new X264)->setAudioKiloBitrate(128);
       FFMpeg::fromDisk('public')
           ->open($this->audioPath)
           ->exportForHLS()
           ->addFormat($midBitrate)
           ->setSegmentLength(6)
           ->save($this->hlsPath);
   }
}

これをdispatchで呼び出すと、使えるようになる。

ConvertToHLSJob::dispatch($path, $hlsPath);

最初は同期処理の設定になってるので.envを

QUEUE_CONNECTION=database

で非同期にして
php artisan queue:work
でワーカーを起動する。

こうすると……

いけた!

ワーカーは何らかの原因で止まる場合があるので、本番環境では「supervisor」というのを使うみたい。
本番環境になったら忘れずにやろう。

非同期処理のテストを書く

非同期処理のテストってどうやって書くんだろうってとこでメモ。

日本語ドキュメント

Queue::fake();」を入れると実際にQueueされなくなるので、付ける。
そしたら

// ジョブを1回投入したことをアサート
Queue::assertPushed(ConvertToHLSJob::class, 1);

とかでテストしてあげるといいっぽい。

簡単な非同期処理だから思ったより簡単だった。

メインファイルを閲覧できないようにする

HLS化の実装が完了したので、オリジナルのmp3を閲覧できないようにしたい。

現在の実装では、URLさえ当てることができたら普通にオリジナルのmp3を見れる仕様なのでそれを変えたいということ。

恐らくpublicディスクでなく、オリジナルのprivateディスクを作成してそこに保存すれば良さそうなんだけど、どうなんだろうか。

日本語ドキュメントがダウンしてたので、本家ドキュメントで学ぶ。

config/filesystems.phpをみてみると

'local' => [
   'driver' => 'local',
   'root' => storage_path('app'),
   'throw' => false,
],

'public' => [
   'driver' => 'local',
   'root' => storage_path('app/public'),
   'url' => env('APP_URL').'/storage',
   'visibility' => 'public',
   'throw' => false,
],

のようになっていて、publicは「’visibility’ => ‘public’」だから外部からでもアクセスできるんだねこれ。
つまり、localディスクを使えばプライベートにはなりそうなんだけど、local/publicにpublicストレージがあるので、私もlocal/privateにprivateストレージを作ってそこで管理しようと思う。

つまり、

// audioのオリジナルなどを保存するディスク
'private' => [
   'driver' => 'local',
   'root' => storage_path('app/private'),
   'visibility' => 'private',
   'throw' => false,
],

のようにディスクを作成する。

これで普段publicを指定している所をprivateにすれば問題なく保存できるし、外部から参照できない。良い。

最後にHLSの再生用JSを作る

HLSの再生用JSが完成はしていなかったのでそれを作成してHLS化を終了としたい。

JSの再生は記事閲覧画面以外でも使うので、jsファイルにしちゃってviteに読み込ませるのがいいかなぁ。

とりあえず、hlsPlayerというクラスが付いたaudio要素をhlsに変換するコードを書けばいいかな。
コードは以下のようにした。

import Hls from 'hls.js';

// HLSPlayerというクラスのaudio要素をすべて取得
const hlsPlayers = document.querySelectorAll(".hlsPlayer");

hlsPlayers.forEach(hlsPlayer => {
   const hlsSrc = hlsPlayer.getAttribute('src');  // src属性からHLSのURLを取得

   if (Hls.isSupported()) {
       const hls = new Hls({
           maxMaxBufferLength: 30
       });
       hls.loadSource(hlsSrc);
       hls.attachMedia(hlsPlayer);
   } else if (hlsPlayer.canPlayType('application/vnd.apple.mpegurl')) {
       hlsPlayer.src = hlsSrc;
   }
});

maxMaxBufferLength: 30というのはHls.jsのAPIの1つで、MaxBufferLengthの最大値を30秒に指定している。
MaxBufferLengthは先行読み込み(バッファ)をどこまでするかの値。この値の最大値を決めているので、MaxBufferLengthが0~30の間で動的に変わるという感じだと思う。ドキュメント英語すぎてわからんけど。

後はバックエンド側でaudio要素をclass=”hlsPlayer”とsrcがhlsのものに変換するシステムを作れば……

記事閲覧は正常に表示できた!
50秒の音源であれば30秒程度のところまでしか読み込まれてないのがわかるね。

後、音源管理画面も対応するようにする。

音源の管理画面は確か前回以下のようにaudio要素を使わずに実装しなおした

<div class="flex w-64 flex-shrink-0 content-center justify-center md:w-104">
   {{--ボタンの状態は各インスタンスでisPlaying変数を作り管理する--}}
   <div class="flex flex-col justify-center"
        x-data="{ audio: new Audio('{{ asset('storage/'.$mediaFile->file_path) }}'), isPlaying: false }"
        x-init="audio.addEventListener('play', () => isPlaying = true);
                   audio.addEventListener('pause', () => isPlaying = false);
                   audio.addEventListener('ended', () => isPlaying = false);">
       <button x-on:click="$store.audioPlayer.togglePlay(audio)" class="h-40 w-40">
           <x-icons.play x-show="!isPlaying"
                         class="h-full w-full rounded-full fill-white py-8 pl-4 bg-sumi-500 lr-8 hover:bg-sumi-600"/>
           <x-icons.pause x-show="isPlaying"
                          class="h-full w-full rounded-full fill-white p-8 bg-sumi-500 hover:bg-sumi-600"/>
       </button>
   </div>
</div>

しかし、hls.jsを使うならaudio要素を使うのがメジャーなので、audio要素を使うパターンに変更する。

作成できたのが以下のようなもの

<div class="flex w-64 flex-shrink-0 justify-center md:w-104">
   {{-- audioとisPlayingの初期化 --}}
   <div class="flex justify-center"
        x-data="{ audio: null, isPlaying: false }"
        x-init="
        audio = $refs.audioPlayer;
        audio.addEventListener('play', () => isPlaying = true);
        audio.addEventListener('pause', () => isPlaying = false);
        audio.addEventListener('ended', () => isPlaying = false);
        ">
       <audio src="{{ asset('storage/'.$mediaFile->mediable->hls_path) }}"
              class="hlsPlayer"
              x-ref="audioPlayer">
       </audio>
       <div class="flex flex-col justify-center">
           <button @click="$store.audioPlayer.togglePlay(audio)" class="h-40 w-40">
               <x-icons.play x-show="!isPlaying"
                             class="h-full w-full rounded-full fill-white py-8 pl-4 bg-sumi-500 lr-8 hover:bg-sumi-600"/>
               <x-icons.pause x-show="isPlaying"
                              class="h-full w-full rounded-full fill-white p-8 bg-sumi-500 hover:bg-sumi-600"/>
           </button>
       </div>
   </div>

JS自体は変えてなくて、audioインスタンスをJS内で作るんじゃなく普通に要素として作るように。

$refが便利。

CKEditorからレスポンスを貰う

ぬけが無ければHLSの実装は完了。
編集画面で音源が再生できない等は必須じゃないと判断して飛ばす。

次はCKEditorからのレスポンスを貰えるようにしたい。
現在の実装では、CKEditor経由で画像や音源を投稿したとき、エラーになってもレスポンスがなんにもない。

正確には、開発者ツールを開けばエラーが出てることはわかるけど、開発者ツールからしか見れないしエラーが起きたということしかわからない。

ちょっとさすがに使えないのでここだけどうにかして終わりたい。

バリデーションエラーをJSONで返す

そもそも、バリデーションエラーって初期のままだとHTMLでエラーを返すらしい。
CKEditorが期待しているのはJSON形式なので、バリデーションエラーをJSONで返すようにする。

具体的には、Requestに

protected function failedValidation(\Illuminate\Contracts\Validation\Validator $validator)
{
   $response["errors"] = $validator->errors()->toArray();


   throw new HttpResponseException(response()->json($response, 422));
}

のようなコードを追加した。
failedValidation()をオーバーライドすることでレスポンスを弄れるみたい。

バリデーションエラーのステータスは422なので、422とエラーの配列をjson形式で返すコードになっている。

これでレスポンスを見てみると

しっかりjson形式になっている。

あとはこれをCKEditorのエラーハンドリングでどうにかしてあげればおk。

エラーレスポンスの方法を考える

ユーザーにどう伝えるかなんだけど、Qiitaの方式がおしゃれでわかりやすいので真似る。

Qiitaではこんな感じで本文に出力して教えてくれる。

わかりやすくて良い。

どうやらwriterを使うと要素を挿入できるみたい。
つまり、

                .catch(error  => {
                    this.editor.model.change(writer => {
                        const pElement = writer.createElement('paragraph');
                        const textNode = writer.createText('エラー: ' + error.message, { 'bold': true });
                        writer.append(textNode, pElement);
                        this.editor.model.insertContent(pElement, this.editor.model.document.selection);
                    });
                });

のようにすれば

のように出力される。

これ、懸念点が2つある。

  1. エラーってそんな公表していいもんなのか
  2. XSSされないか

今回のエラーはLaravel側で定義したバリデーションエラーなので日本語のエラー文になっているが、PHPやサーバーエラーはバンバン英語のエラーになる。

以下はPHPのエラーがJSONじゃなくて、JSがエラーになっている例

これだけなら、このエラーもエラーハンドリングすれば問題ないんだけど、ここら辺が疎すぎてこれで問題ないのかが超疑問。

Imageのエラーレスポンスはどうするか

audioはこんなオリジナル性があっても問題なかったんだけど、imageはどうするんだろう。

とおもってドキュメントをみたら、エラーレスポンスについての部分があった。

つまり、

{
    "error": {
        "message": "The image upload failed because the image was too big (max 1.5MB)."
    }
}

のようにjsonで返してあげればshowWarningで表示してくれるらしい。

これ、audioも同じ仕組みの方が良さそうなだなぁ。
とりあえず、Imageの対応をしてみる。

imageはerrorの中にmessageが入ったものにしてあげればいいので

protected function failedValidation(\Illuminate\Contracts\Validation\Validator $validator)
{
   $errorMessage = $validator->errors()->first();
   $response["error"] = ["message" => $errorMessage];


   throw new HttpResponseException(response()->json($response, 422));
}

のようにすればできる。

protected $stopOnFirstFailure = true;
をつけておけば最初のエラーで処理が止まるからエラーは1つになるはず。

うん、いい感じ。

Audioも通知で表示するシステムにしてみる

以下のようにしてみた

.then(response => {
                    if (response.ok && response.headers.get('Content-Type').includes('application/json')){
                        return response.json();
                    } else if (response.status === 422 && response.headers.get('Content-Type').includes('application/json')) {
                        return response.json().then( data=> {
                            throw new Error(data.error && data.error.message ? data.error.message : '何か問題が発生しました')
                        })
                    } else if (response.status === 413) {
                        throw new Error('ファイルの容量が大きすぎます');
                    } else {
                        throw new Error('サーバーからの応答が適切ではありません');
                    }
                })
                .then(data => {
                    if (data?.hls_url) {
                        this._insertAudioElement(data.hls_url);
                        resolve({ default: data.hls_url });
                    } else {
                        throw new Error(data.error && data.error.message ? data.error.message : '正常な処理ができませんでした');
                    }
                })
                .catch(error  => {
                    this.editor.plugins.get(Notification).showWarning(error.message,{
                        title: 'アップロード失敗',
                    })
                    reject(error);
                });

実際に動かしてみると、

うん。シンプルで良いね。

アップロードしすぎとか、容量が超えている場合も作成した。
うん。これで今度こそ終了。

フロントエンドのサニタイズはセキュリティ的に必要か

最近Twitter(X)で身長から適正体重を算出するサイトが話題になっていた。

なぜ話題になっていたかというと、身長の入力・出力がバリエーションされておらず、任意のJSなどが実行可能だったから。
つまり、身長入力欄にalart(“”)を入れればJSのアラートが出せた。

これが危ないという話で話題になっていたんだけど、フロントエンドだけで完結するシステムにセキュリティ上バリエーションが必要かが気になったのでまとめる。

元のサイトは詳しく調べる前に404になっちゃったんだけど、以下のような定義とする

  • 身長から適正体重を求めるサイト
  • 全てJSで処理され、サーバーを介することは無い
  • そのサイトのドメインにはこの機能しかない
  • 身長の入力欄に何もバリデーションは無く、出力にも無い

このような定義のサイトのとき、果たしてバリデーションが無いことは問題なのだろうか。

JSを実行したとしても、サーバーを介していないので誰かにそのJSのコードや結果が渡るわけではない……はず。だとすればユーザビリティが低いとしてもセキュリティ的に問題は無いように感じる。

IPAのXSSなどを調べた感じ、やはり自己完結する今回のパターンであればXSSとは言えなさそうな気がする。自分の書いたJSが他人に届いて初めてXSSは成立するので、上の定義されたサイトだとすると特に問題は無い……よね?

しかし、考慮できることは何個かある。

結果を共有出来る場合

確か、元のサイトには結果を共有するボタンがあった。実際に共有したわけではないので、どのような共有方法かはわからないんだけど、「入力と結果の内容を受け継いだサイトに飛ぶ」という形で共有した場合、その共有を開いた第三者に攻撃が届くのでXSSになりそう。

Self-XSS

海外wikipediaにSelf-XSSというページがある。

要約すると、ユーザーを騙して開発者コンソールとかで任意のJSを自分自身で実行させるというもの。

これに近いことが今回のパターンだと発生しうる。

もし、今回の身長から理想体重を測るサイトが、Yahoo機能の一部だとする。

この場合、対象のセッションIDを取得し、IDを攻撃者に送るJSを何らかの形で騙されて実行してしまうとYahooの認証を攻撃者に突破される可能性はあるよね。

今回の体重サイトはTwitterで話題になってたので、「このコード打ったら面白いよ!」みたいな形で騙される可能性は全然ありそう。

どちらも可能性としては低いし、そもそも今回のパターンであれば恐らく問題ないんだけど、別にバリデーションしても損は無いならバリデーション、エスケープ処理はフロントエンドのみでもした方が良いと思った。

いったん終わり

思ったよりストリーミング再生の対応で時間がかかってしまい、記事がだいぶ長くなったのでここで区切る。

後は以下を進める予定

  • IPBANの実装
  • Cokkieの承認について
  • サービス名を決める
  • 限定公開は電気通信事業法に抵触しないか
  • Consoleの出力を消す
  • テストの確認(Duskは必要そうならでおk)
  • OWASPやる
  • プライバシーポリシー、利用規約
  • メールの文章
  • クラウドの契約
  • ドメイン取得
  • HTTPS化
  • デプロイ
  • サービス開始

【追記】ポリモーフィックリレーションの削除を設定する

後から気づいたので追記。2024年4月15日。

現在のmedia_filesとaudio,imagesのポリモーフィックリレーションは一対一の関係を持っている。
ということは、media_files側かaudio,images側が削除されたときそのもう一方も削除される仕組みが欲しい。

一応、コントローラではどちらも削除するようにしていたので問題ないかなぁと思ってたんだけど、ユーザー退会時とかを考慮していなかった。

ユーザー退会時、userは削除されるわけだけど、この時カスケード設定しているmedia_filesは削除される。でも、media_filesとポリモーフィックリレーションを持つaudio,imageはカスケード設定できないので残っちゃう。

この仕様は流石に良くないし、これから先もこういうのありそうなのでmedia_filesとaudio,imagesのお互いどちらか削除されたときに対になるもう一方も削除する仕組みに変える。

どうやら、イベントを使えば実装できそう。

日本語ドキュメント

MediaFileのdeletingイベント発生時にそれに関連するポリモーフィックリレーション先を消すようにすればいいのかな。
最初はお互いに消えるように仕様かと思ったんだけど、Audio,Image側だけ消えることはよっぽどのことがない限りあり得ないのと、削除が循環しちゃうのを制御するのが難しそうだった。
なので、MediaFileが削除されたときにそれに関連するポリモーフィックリレーション先を削除する処理だけ実装する。

実装は以下のような感じ

// mediaFile削除時にそれと対になるポリモーフィックリレーションを削除する
protected static function booted(): void
{
   static::deleting(function ($mediaFile) {
       if ($mediaFile->mediable) {
           $mediaFile->mediable->delete();
       }
   });
}

bootedはMediaFileモデルが初期化完了後に呼び出されるもので、初期化完了後deletingイベントに

       if ($mediaFile->mediable) {
           $mediaFile->mediable->delete();
       }

という処理を登録している。

このテストを書いてみよう。

以下のようにしてみた

public function test_mediafile削除時にそれに関連するポリモーフィックリレーション先を削除(): void
{
   $user = $this->login();
   $audio = Audio::factory()->create();
   $mediaFileAudio = MediaFile::factory()->for($user)->mp3()->create(
       [
           "mediable_id" => $audio->id,
           "mediable_type" => Audio::class
       ]
   );
   $image = Image::factory()->create();
   $mediaFileImage = MediaFile::factory()->for($user)->png()->create(
       [
           "mediable_id" => $image->id,
           "mediable_type" => Image::class
       ]
   );

   // データが存在することを確認
   $this->assertDatabaseHas('audio', ['id' => $audio->id]);
   $this->assertDatabaseHas('media_files', ['mediable_id' => $audio->id]);
   $this->assertDatabaseHas('media_files', ['mediable_id' => $image->id]);
   $this->assertDatabaseHas('images', ['id' => $image->id]);

   // アカウントの削除
   $response = $this
       ->from('/settings/delete')
       ->delete(route('settings.delete.destroy'), [
           'password' => 'password',
       ]);
   $response
       ->assertSessionHasNoErrors()
       ->assertRedirect('/');

   // それぞれのデータが削除されていることの確認
   $this->assertDatabaseMissing('users', ['id' => $user->id]);
   $this->assertDatabaseMissing('audio', ['id' => $audio->id]);
   $this->assertDatabaseMissing('media_files', ['mediable_id' => $audio->id]);
   $this->assertDatabaseMissing('media_files', ['mediable_id' => $image->id]);
   $this->assertDatabaseMissing('images', ['id' => $image->id]);
}

これを確認すると

だめだね。

仕組みを考えれば当然っちゃ当然なんだけど、UserとMediaFileにはDBの方でカスケードの設定がしてある。その為、Userが削除されたときMediaFileはLaravelを通らずにDBの方で削除される。

だから、deletingイベントを発火させられないという感じだと思う。

日本語ドキュメントには

Warning! Eloquentを介して一括更新または削除クエリを発行する場合、影響を受けるモデルに対して、saved、updated、deleting、deletedモデルイベントをディスパッチしません。これは、一括更新または一括削除を実行するときにモデルを実際に取得しないためです。

https://readouble.com/laravel/10.x/ja/eloquent.html#events:~:text=%E4%BD%BF%E7%94%A8%E3%81%97%E3%81%BE%E3%81%99%E3%80%82-,warning,%E3%81%99%E3%82%8B%E3%81%A8%E3%81%8D%E3%81%AB%E3%83%A2%E3%83%87%E3%83%AB%E3%82%92%E5%AE%9F%E9%9A%9B%E3%81%AB%E5%8F%96%E5%BE%97%E3%81%97%E3%81%AA%E3%81%84%E3%81%9F%E3%82%81%E3%81%A7%E3%81%99%E3%80%82,-%E3%82%AF%E3%83%AD%E3%83%BC%E3%82%B8%E3%83%A3

ともあったけど、これは恐らく関係ない?

つまり、私が取れる選択肢は

  1. ユーザー削除時の処理に$mediaFile->mediableを削除する処理を追加する
  2. MediaFileとUserのカスケード関係をやめて、ユーザー削除時にLaravelを通してMediaFileを削除する処理を書く
  3. Audio,ImageにもUserとのリレーションを持たせる

なんか3番が楽そうなんだけど、リレーション増やすのは将来的にヤバそうなにおいもする。

一番シンプルなのは1番なんだけど、根本的解決にはなってないんだよね。3番もなってないか。


これ、根本的解決になってるのは2番なのか。
理由はMediaFileを削除したときAudio、Imageが削除されるようになっているから。

じゃあ、2番のMediaFileとUserのカスケード関係をやめて、ユーザー削除時にLaravelを通してMediaFileを削除する処理を書く方針にする。
だから、さっき書いたコードはそのままでおk。

media_filesの
$table->foreignId(“user_id”)->constrained()->onDelete(“cascade”);

$table->foreignId(“user_id”);
にしてカスケード設定をなくす。

次に、

protected static function booted()
{
   static::deleting(function ($user) {
       // ユーザーに関連するMediaFileを削除
       foreach ($user->mediaFiles as $mediaFile) {
           $mediaFile->delete(); // Eloquentのdeletingイベントが発火
       }
   });
}

のようなdeletingイベントを登録し、ユーザー削除は
$user->delete();
でするように。

こうすれば

  1. ユーザー削除時にUserモデルのdeletingイベントが発火
  2. 関連するmediaFilesを1個ずつ削除
  3. mediaFileを削除するたびにMediaFileモデルのdeletingイベントが発火
  4. mediaFileに関連するaudioまたはimaegを削除

という感じになる。

DBだけの処理じゃなくなるし一括削除じゃないのでパフォーマンスは落ちるけど、これが良さそう。

AudioとImageはMediaFile削除時に自動で削除されるので、元々あった削除処理を削除する。

これでテストを実行すると

おk。