Chienomi

XXWMP 〜 ついにインターネットに対応したLWMPの仕立て違い

開発::util

新年早々の新作である。

XXWMPは以前から言及していたLWMPをインターネット経由で使うためのものだ。

もともとはLWMPに薄いレイヤーを噛ませることでインターネットでも使えるようにできないかということを検討していたのだけど、そもそもの目的の違いと、やはりローカルでやるのと公開サービスでやるのでは前提が違いすぎるためforkとして開発した。

XXWMPはアプリケーションの主要な部分のコードをLWMPから流用している一方で、目的は全く異なる。

LWMPは自宅にあるPCのメディアをスマートフォンで再生するのが目的だ。 別PCで、というのもあるが、Linuxの場合SSHFSを使えば事足りるためその点はあまり重視されていない。

XXWMPは主にself-hostedなクラウドドライブ上にあるメディアファイルを再生するのが目的だ。 self-hostedであるべき理由は、XXWMP自体がウェブサーバー(基本的にはNginx)を必要とし、かつサーバーのローカルファイルとして対象のファイル群にアクセスできる必要があるためで、サーバーがない人がローカルから使うのには適していない。

また、LWMPの発展版ということではなく目的が違うので、LWMPの用途で使うのであればLWMPのほうが良い。

コードの話

auth_request

LWMPにとって重要なのはPartial Contentの配信効率、つまりはRangeリクエストへの対応だった。

アプリケーションでこれに対応するのは難しく、少なくともRuby/RackやNode.jsはこれに適さない。 それにアプリケーションサーバー側でがんばるよりは、それを本業とするウェブサーバーで配信したい。

Rangeリクエストを無視するのであれば実装は簡単なのだが、Rangeリクエストを効率的にさばきたい、というのがLWMPをインターネット対応する上でのネックだった。

これを解決する方法がauth_request

auth_requestはNginxの機能で、ディレクティブがauth_requestを持っていると、アクセスを当該APIに転送する。

そしてその結果として2xx系が得られれば配信を続行する。 401または403が得られた場合はそれをクライアントに返す。 それ以外が得られた場合は500になる。

location /media {
  auth_request /auth;
  root   /srv/http/xxwmp;
}

location /auth {
  proxy_pass http://xxwmp;
  proxy_set_header Host $host;
  proxy_set_header X-Original-Request-Path $request_uri;
  proxy_no_cache "1";
}

似たような機能は各ウェブサーバーにあるらしいが、試した限りほかは動かなかったのでとりあえずNginxだけサポート。 Caddyはがんばりたかったけど、そもそも私がCaddyfileを書いたことがないのでそんなすぐにはできない。

まぁ実際はできそうな気はしているけど、とりあえず公開時点では入れなかった。

認証方式

  • ログイン処理はform形式でPOST
  • ログイン成功時、Set-Cookieで期限つきトークンを保存
  • /authではクッキーのトークンを検証
  • トークンの検証に成功した場合、有効期限を延長する
  • ログインに失敗したとき、またはトークン検証に失敗したときは401 Unauthorizedを返す

という方式が取られた。

当初、Bearerトークンを使用し、そのトークンの取得はBasic認証を使う方式だった。

トークン取得にBasic認証を使うのは、MS Copilotが「OAuth2の標準的なやり方」と説明したので採用したのだが、問題があった。 ちなみに、Geminiは「非推奨の方法」と説明した。

本命はBearerトークンを使う方法だが、これはやってみたらだめだった。 LWMPはブラウザ任せにメディアファイルを取得するし、ウェブサーバー任せに配信している。 が、ブラウザ任せにするとリクエストにBearerトークンは含まれないし、これを全部制御しようとするとあまりに大変だった。

これは筋が悪いので、クッキーにトークンを入れる方式にすることでブラウザ任せにするようにした。

トークン取得のログインリクエストも同じような問題があり、ブラウザ任せにする場合Basic認証をするURIは現在のURIに固定されるし、これをトリガーして転送することもできないため、Basic認証を採用しても制御は自分でやる必要がある。 そうするとBase64でエンコードしたヘッダーをセットする、というのはちょっと手間だ。 そんなことをするくらいなら普通にフォームにしたほうがいい。

あと、トークンが無効でログインし直すべきときにブラウザがBasic認証を覚えているという問題が発生する可能性がある。 XXWMPではログインURLは専用だから問題はないはずだが、扱いにくい。

リクエストを保留するログインモーダル

普通にPOSTでログインするという方式をとった際、最初は専用ページへの転送という方式をとった。

これはリクエストの途中でログインを要求する必要があり、リクエストを中断すると状態の整合性が難しいことになるため、専用ページに飛ばしたほうが楽だと考えたためだ。

実際、飛ばすだけなら簡単だったのだが、戻るのが大変。 それに、LWMPはステートを抱えた状態で初期化されることを想定していないから、予期せぬ問題が起きやすい。

そこで、一度は難しいとして断念したログインモーダルを表示する方式を採用した。 問題は、ログインモーダルを表示するときにリクエストを中断し、ログイン後再開する必要があるということだ。

これを解決するためのものがcontentfetch.mjsである。 LWMPはFetchWrapper(httpclient.mjs)を使っているのだが、contentfetch.mjsは同じhttp.get()APIを提供しつつ、専用の処理になっている。

まずfetch()401が得られた場合、モーダルを開いて新しいPromiseを返す。

const res = await fetch(url)
if (res.status == 401) {
  openModal()
  
  return new Promise((resolve, reject) => {
    failedQueue.push({resolve, reject})
  }).then(() => {
    return http.get(url)
  })
}

awaitPromiseが返ってくるのを待っているのではなく、resolve()されるのを待っている。 だからresolve()されないPromiseを返すとawaitから先に進まない。

そして実は今まで勘違いしていたのだけど、awaitはスレッドをブロックしない。 だからresolveされないPromiseを返してもフリーズはしない。

このPromiseはログイン成功時にresolve()される。

const processQueue = (error, token = null) => {
  failedQueue.forEach(prom => {
    if (error) {
      prom.reject(error)
    } else {
      prom.resolve(token)
    }
  })
  failedQueue = []
}

resolve()されるとthen()が呼ばれる。 .then()のついたPromiseawaitするの、なかなか珍しい気がするけどawaitPromiseを待っていて、.then()Promiseの一部なのでちゃんと機能する。

この.then()がそもそも解決されないPromiseを返したhttp.get()を呼ぶので、「401が返ってきたからログインモーダルを出して成功になったけどまた401が返ってきた」という状況でも正しく動作する。

ログイン処理

実はちょっと躓いたところ。

document.getElementById("LoginSubmit").addEventListener("click", event => {
  event.preventDefault()
  event.stopPropagation()
  handleLoginSubmit()
})

これ、最初はhandleLoginSubmitevent.preventDefault(); event.stopPropagation()していた。 けど、どうしてもデフォルト動作が発火してしまい困った。

これは、handleLoginSubmitasync関数であるためだった。

async関数も通常、後回しにされる要素がなけれればそのまま同期的に実行される。 だから

async function foo() {
  alert("ASYNC")
}

function bar() {
  foo()
  alert("SYNC")
}

ASYNC -> SYNCの順になる。

しかし後回しにされるものが積まれると、そのasync関数の実行自体が後回しにされるので呼び出し元が先に進む。 なので

async function foo() {
  await fetch("https://chienomi.org")
  alert("ASYNC")
}

function bar() {
  foo()
  alert("SYNC")
}

SYNC -> ASYNCになる。

だからhandleLoginSubmit(event)の最初でevent.preventDefault()すれば止まるはずだが、別にこの挙動は保証されているわけではないので呼び出し元のコールバック関数内で止めたほうが確実だった。

Roda

Chienomi APがRodaを使っているのだけど、こっちの中身がまだ公開されていないので、公開している中では初のRodaプロジェクト。

RodaはSinatraよりも柔軟に書きやすくて便利、JSON APIにも対応しやすい。 ドキュメントが少ないのが難点だが、ソースコードを読めばだいたいなんとかなる。

全体的に書き心地はいいのだが、Sinatra同様、APIを別ファイルにするような書き方が難しい。 これは、リクエストやレスポンスに関わる部分がbinding固有になってしまうためだ。

ある程度ならrequestresponseを渡すことでなんとかなるが、もうちょっとうまくやる方法を探りたい。

ソフトウェアの話

認証とユーザーの追加

もちろん一番のメインは認証が加わったこと。 前述の通りauth_requestを使って認証されることになったのと、これに伴ってログインモーダルが追加された。

この認証は「ユーザー/パスワード」ペアなのでマルチユーザーに対応する形になった。

ファイル配置が.../media/<user>/*になり、ファイルブラウザもこれを参照するのでユーザー個別にファイルを配置できる。

XXWMPはファイルをアップロードする機能は持っていないので、他の何かと組み合わせる前提。

対ディレクトリトラバーサルの強化

LWMPも一応やってはいたけど、ちゃんと厳密にやることになった。

ここは認証と並んでインターネット対応のメイン部分。 リンクを解消し、ディレクトリとしてルートディレクトリより下にあることを確認するという処理を追加した。

メタデータ機能の無効化

LWMPではオプショナルな機能としてffprobeでメタデータを取得し、プレイリストロード時に含めて返すという機能があるが、ffprobeを使って安全性を担保するのが難しいこと、ffprobeの呼び出しがコストが高い処理であることからなくしている。

メタデータサポートを追加するなら、Rubyでちゃんと書く必要があると思う。

ダークモードをサポート

prefer-color-schemeを使ったダークモードをサポート。 大した違いではないけど、UIにもちょっと変化を持たせたいなと思ったため。

SVGアイコンの変更はfilterプロパティを利用しており、値はこれを使った。これでもよかったなと思う。

FetchWrapperの廃止

fetch()を直接使うよりは使いやすいのだけど、今回contentfetch.mjsがレスポンスステータスでケース分けするのでメリットが薄かった。

サーバー構成の変更

Lighttpd + CGIからNginx + Rodaに。

多くの人は「普通になった」と感じるだろう。 このあたりは目的による選択という色が強い。

コード的にかなり流用できたのは、アプリケーションサーバーで利用されることも最初から視野にあったため。

感想

やってることも見た目もLWMPと変わらないので、個人的には代わり映えしないなという印象。 どちらかというと私は目的に合わせて割り切った作りのほうが好きなので、あんまり心躍らない制作ではあった。

まぁ、auth_requestという新要素で遊べたのはよかったけど。

現状、既にmTLSで認証してLWMPにアクセスする形で私が使ってしまっているので、XXWMPに私の需要がなかったというのもある。

まあでも、そこそこ難しくて面白い要素も多いので、新年一発目としてはなかなかインパクトあるものに仕上がったとは思う。