メールトークンが既に消費されているため、中断後のバックアップダウンロード再開ができません

2026.01.0-latest で修正されたような類似の問題を思い出しました。したがって、これは旧来の初期ダウンロード失敗ではなく、中断されたバックアップダウンロードからの復旧に関わる別の現在進行中の問題だと考えられます。

一度限りのバックアップリンクが、ブラウザによる中断されたダウンロードの再開を妨げていると考えるのは妥当ですが、単一のリンクでバックアップを2回ダウンロードすることはできません。そのため、一度失敗すると、ダウンロードの再開は Discourse 自身によってキャンセルされている可能性があります。

現在、この動作を現在の Discourse で正確に再現することができました。

再現手順

Safari (iOS) を使用して、以下のローカル Discourse バックアップをダウンロードしていました:

4,231,639,143 バイト

「ダウンロードは正常に進行していました。以下の値から増加している様子のスクリーンレコーディングがあります:」

https://youtube.com/shorts/_Pjc-kxJ1OE?feature=shared

313.3 MB から 317.8 MB へ

その後、Safari から約15秒間だけ離れました。

Safari に戻ると、ダウンロードは約以下の位置で停止していました:

319.8 MB

そして Safari は再試行コントロールを表示していました。


元のリクエストの Caddy アクセスログには以下が示されています:

GET /admin/backups/...

HTTP/3

Range: none

status: 200

Content-Length: 4231639143

size: 319864024

その後、Safari 自身の再試行コントロールを使用しました。

Safari は本物の HTTP バイト範囲リクエストを送信しました:

GET /admin/backups/...

HTTP/3

Range: bytes=319799904-

status: 422

つまり、Safari は単に別の完全なダウンロードを試行していたわけではありません。以前の転送が停止した約その位置で既存のファイルを再開しようと正しく試行していました。

Discourse はその Range リクエストを 422 で拒否しました。

現在の Discourse コード

現在の Admin::BackupsController#show を確認すると、シーケンスは実質的に以下のようになっています:

EmailBackupToken.compare(current_user.id, token)

        ↓

find backup

        ↓

EmailBackupToken.del(current_user.id)

        ↓

send_file

つまり、初期の認証済みリクエストは、数 GB に及ぶ転送が実際に完了する前に、メールトークンを消費してしまいます。

その転送がその後中断された場合、Safari は例えば次のようなリクエストを行います:

Range: bytes=319799904-

しかし、そのリクエストは再び /admin/backups/… を通るため、メールトークンはすでに削除されています。

これが、私が捕捉した 422 を説明しているようです。

認証済みセッションに加えてバックアップトークンも必要とされるため、既存のトークンを無期限に再利用可能にするよう提案しているわけではありません。

むしろ、同じ認証済み管理者が、すでに認可された同じバックアップダウンロードを再開するための、何らかの範囲を限定された方法があるべきではないかと考えています。

例えば、同じユーザーとバックアップに関連付けられた短命なダウンロード認可などが考えられますが、メンテナー側により良い処理方法があるかもしれません。

なぜ iOS で再現しやすくなっているのか

この問題を顕在化した中断は、最近の iOS バージョンのユーザーからの報告と似ているようです。

Apple Community に少なくとも3つの報告が投稿されています:

古い報告

「Safari Background Downloads Failing After IOS 26 update」 — 2025年10月

Safari Background Downloads Failing After… - Apple Community

ユーザーは、iOS 26 にアップグレードした後、バックグラウンドで実行中に大きな Safari ダウンロードが失敗すると報告しています。また、iPhone 15 Pro を iOS 26 にアップグレードし、同じ動作を報告しています。

「Safari iOS Background Download Issue - Cancels on Minimize」 — 2025年12月

Safari iOS Background Download Issue - Ca… - Apple Community

iOS 26.1 を実行する iPhone 15 で報告されています。ダウンロードは正常に開始されますが、別のアプリに切り替えたり Safari を最小化したりするとキャンセルされます。

「Safari can’t complete a large download」 — 2026年4月

iOS 26.4 を実行する iPhone 17 Pro Max で報告されています。大きなダウンロードは、デバイスをロックした直後、またはモバイル通信と Wi-Fi を切り替えた際に、 reportedly 一時停止するとのことです。著者は特に信号が良好であり、同じダウンロードは PC、Mac、Android では完了すると述べています。

これらのコミュニティ報告だけでは、Apple が iOS のリグレッションを確認したとは言えないと思います。

しかし、それらは私のスクリーンレコーディングと整合しています:バックアップは正常にダウンロードされており、私は一時的に Safari から離れ、戻った時点で転送が中断になっていたのです。

中断が発生した理由に関わらず、Discourse 側の重要なポイントは以下の通りです:

large backup transfer is interrupted

        ↓

browser attempts standards-based Range resume

        ↓

backup token has already been consumed

        ↓

resume receives 422

サーバーサイドの観察

これは現在の nginx/sendfile パスにおける一般的な問題ではないようです。

同じ数 GB のバックアップパスは、以下で正常に完了しています:

  • HTTP/3 を通じた Firefox/Linux から;および
  • 同じ iPhone から Wi-Fi を使用した HTTP/3 を通じて。

現在の nginx もバイト範囲サポートを宣伝しています。

したがって、HTTP/3、Caddy、または nginx が本質的にバックアップを配信できないと提案しているわけではありません。

この特定の再現において、元の転送は約 320 MB で中断され、その後 Safari は有効なように見える Range リクエストを行い、その2番目のリクエストが Discourse によって拒否されました。

すでに認可されたバックアップダウンロードに対して、中断された転送後に同じファイルを同じ認証済み管理者が再開できるようにする、短命なメカニズムを持つのは理にかなっているでしょうか?

履歴と現在のテストカバレッジを確認し、修正時に何を保持すべきかをより深く理解しようとしました。

この使い捨ての挙動は、2017年3月のセキュリティ強化の変更から来ています:

このコミットは、メールで送付されるバックアップダウンロードトークンを導入し、ダウンロードリクエストが認可された時点でトークンを即座に消費するようにしました。

現在の Admin::BackupsController#show でも、同じ順序が維持されています:

基盤となるトークン実装も、依然として非常にシンプルです:

EmailBackupToken は、ユーザーごとに1つのRedisトークンを保存し、有効期限は1日です。これは特定のバックアップに紐付けられるのではなく、ユーザースコープで管理されています。

このファイルの履歴を確認したところ、2017年以降の変更は、認可モデルの変更というよりは、機械的・スタイル的な変更であるように見えます。

そのため、既存のメールで送られたトークンを再利用可能にする単純なアプローチは望ましくないと思われます。それは元の使い捨て制限を弱め、かつトークン自体がURL内の特定のバックアップにスコープされていないためです。

現在のリクエストテストはここにあります:

これらは、最初の有効なダウンロード、無効なトークン、無効または欠落しているバックアップID、および権限をカバーしていますが、中断された転送後の Range: リクエストのカバレッジは見つかりませんでした。

APIリクエストのスペックもまた、通常のファイル名 + トークンによるダウンロードパスを文書化しています:

したがって、可能なパッチの方向性は、バックアップダウンロードの開始には既存の使い捨てメールトークンを保持しつつ、すでに認可されたローカルバックアップに対して、別個の短命なレジューム認可を提供することのように思われます。

そのようなレジューム認可は、認証済みユーザーと特定のバックアップに厳密にスコープされ、元の1日間のユーザー全体で有効なメールトークンを一般的に再利用可能にすることなく、後続のバイトレンジリクエストを許可するものになると予想されます。

保持・追加しようとする挙動は以下の通りです:

  • 有効なメールトークンによる最初の GET は引き続き許可される;
  • 消費済みのメールトークンによる別の通常の完全 GET は引き続き拒否される;
  • 同じ認証済み管理者による同じバックアップのバイトレンジ再試行は、短い限定された期間内にレジュームできる;
  • 他のバックアップに対するレジューム試行は引き続き拒否される;
  • 期限切れのレジューム認可は引き続き拒否される。

これが必ずしもメンテイナーが好む実装であるとは仮定していませんが、コード/テストの範囲は比較的限定されているように見えます。

もしこの一般的なセキュリティモデルが合理的に思えるのであれば、Safariの Range: 再現のためのドラフトパッチと回帰テストを作成することに問題ありません。

この件についてPRを作成しました:

この実装では、ダウンロードの開始に使用する既存の一回限りのメール送信トークンを維持したまま、ローカルバックアップに対して、以下の範囲に限定された独立した範囲付きリジューム認可を作成できるようにしています:

  • 同一の認証済み管理者;

  • 同一のバックアップ;

  • 同一の元のトークン値; および

  • Range ヘッダーを含む後続のリクエスト。

消費済みのトークンを使用した通常の2回目の完全な GET は引き続き拒否されます。また、他のバックアップに対する Range リクエストも拒否されます。リモート/S3バックアップは既存の動作を維持します。

リジューム期間を無制限の期間ではなく、列挙型(enum)として定義しました:

  • Disabled

  • 1 hour

  • 6 hours (recommended)

  • 12 hours

  • Until the original email token expires

有効なリジューム期間は、初期ダウンロードが開始された時点で、元のメール送信トークンの残存有効期間に制限されます。

後方互換性のために、現在のDiscourseの動作(Disabled)がデフォルトのまま維持されています。一方、リジューム可能なダウンロードが有効な場合、6 hours が推奨オプションとして提示されます。

このPRでは、この互換性を維持するデフォルトを維持すべきか、それともこの問題を修正する一環として、推奨されるリジューム可能な値をデフォルトとするべきかをメンテナーに尋ねています。

また、成功したリジュームパスと主な認可境界(以下を含む)に対する回帰テストカバレッジを追加しました:

  • 通常のトークンの再利用が引き続き拒否されること;

  • バックアップをまたぐリジュームが引き続き拒否されること;

  • 設定を無効にすると、直ちにさらなるリジュームが防止されること;

  • S3にローカルリジューム認可が与えられないこと; および

  • 初期ダウンロードとそのリジュームが、バックアップダウンロードの監査エントリを1つだけ生成すること。

完全なCIスイートはパスしており、PRはレビュー準備完了です。

セキュリティモデル、特に6時間の推奨値とデフォルト設定に関するフィードバックをお待ちしています。