From 80389e7bb2b4eb8d6661dc2cbbd7a09ddddefee6 Mon Sep 17 00:00:00 2001 From: kamijo-haruto Date: Sat, 25 Jul 2026 16:51:33 +0900 Subject: [PATCH 01/11] =?UTF-8?q?[docs]=20=E8=B2=A0=E8=8D=B7=E3=83=86?= =?UTF-8?q?=E3=82=B9=E3=83=88=E8=A8=88=E7=94=BB=E3=81=A8=E3=83=AA=E3=83=AA?= =?UTF-8?q?=E3=83=BC=E3=82=B9=E5=89=8D=E3=83=81=E3=82=A7=E3=83=83=E3=82=AF?= =?UTF-8?q?=E3=83=AA=E3=82=B9=E3=83=88=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本番リリースに向けた負荷テストの手順とリリース前の確認事項を docs/load-testing.md に整理した。修正作業を先に進めるため、計画を 先にリポジトリへ残す。 主な内容: - Free プランの上限とスキャン1回あたりのコスト(D1書き込み1行 + 読み取り2行 = 1日あたり最大10万スキャン) - 測定結果を読み違えないための注意点(1台からはエッジを飽和できない、 見るべきはCPU時間とrows read/written、429/1015はCloudflare側の 濫用対策の可能性) - k6 を選ぶ理由(オープンモデル負荷、redirects:0、thresholds) - シードデータに偏りを付ける理由と複数プロジェクト分散の必要性 - シナリオ S1〜S6(ローカル)と P1〜P3(本番、計約14,000件) - 後片付けでDELETEもrows writtenに計上される点と、DB作り直しが 書き込みゼロで済むこと - リリース前チェックリストとロールバック手順 - 将来のシール印刷ビューに向けた設計メモ あわせて .idea を .gitignore に追加した。 Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 3 + docs/load-testing.md | 274 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 277 insertions(+) create mode 100644 docs/load-testing.md diff --git a/.gitignore b/.gitignore index 29c98f8..301b003 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,9 @@ worker-configuration.d.ts *.log *.tsbuildinfo +# Editor / IDE +.idea + # Local machine-specific / internal-only files .claude/settings.local.json /DS MTG_Tracking-Link要件定義.md diff --git a/docs/load-testing.md b/docs/load-testing.md new file mode 100644 index 0000000..1171ec5 --- /dev/null +++ b/docs/load-testing.md @@ -0,0 +1,274 @@ +# 負荷テスト計画とリリース前チェックリスト + +NUTFES 本番運用に向けた、TrackingLink の負荷テスト手順とリリース前の確認事項。 + +対象は 2 つの経路: + +- **スキャン経路** — `GET /?id=`。ポスターの QR を読んだ人が通る。バースト性が高い。 +- **管理経路** — `GET /projects` などの管理 API と CSV 出力。件数が増えると劣化する。 + +--- + +## 1. 前提: Cloudflare Workers Free プランの上限 + +本番は Free プランで運用する。**この算術がこのドキュメントで最も重要**で、イベント中にコードを出しても直せない唯一の制約。 + +| 項目 | Free プランの上限 | +|---|---| +| リクエスト | 100,000 / 日 | +| **CPU 時間** | **10 ms / リクエスト** | +| **D1 書き込み行数** | **100,000 行 / 日** | +| D1 読み取り行数 | 5,000,000 行 / 日 | +| サブリクエスト(D1 呼び出しを含む) | 50 件 / リクエスト | +| D1 データベースサイズ | 500 MB / DB | + +**スキャン 1 回のコスト = D1 書き込み 1 行 + 読み取り 2 行。** +→ 書き込みが実質の天井で、**1 日あたり最大 10 万スキャン**。 + +クォータのリセットは **00:00 UTC = 09:00 JST**。 + +想定イベント規模(数千〜2 万スキャン)には十分な余裕がある。ただし: + +- **CPU 10 ms は CSV 出力と `GET /projects` の集計で実際に超える。** これはスループットの集計値には現れず、**リクエスト単位の CPU 時間**として Workers Logs に出る(超過時はエラー 1102)。 +- **D1 は `DELETE` した行も「rows written」に計上される。** 負荷テストの後片付けそれ自体がクォータを食う(§5.3 参照)。 + +もし Paid プラン($5/月)に移る場合、CPU 上限・書き込み上限・サブリクエスト上限・リクエスト上限がまとめて緩む。本ドキュメントは Free 前提で書かれている。 + +--- + +## 2. 読み違えやすい点 + +負荷テストの結果を解釈する前に必ず読むこと。 + +1. **1 台の PC からは自分の上り帯域を測っているだけ。** Cloudflare は 1 台では飽和させられないエッジで受け止める。「500 rps 捌けた」を成果として引用してはいけない。 +2. **見るべきは集計値ではなくリクエスト単位の指標。** Worker の **CPU 時間**(Free は 10 ms 上限)と **D1 の rows read / rows written per request**。CPU と読み取り行数が半分になれば本物の改善、ノート PC の RPS が上がっただけなら NIC が温まっただけ。 +3. **エラー率を信じる前に Cloudflare ダッシュボードの Security → Events を見る。** 単一 IP から `*.workers.dev` を叩くと Cloudflare 自身の濫用対策で 429 / 1015 が返り、**アプリの障害に見える**。 +4. **Cloudflare は大規模なストレステストについて事前連絡を求めている。** 1 台から数千リクエストはその水準には遠いが、分散クラウド負荷生成を本番ホスト名に向けないこと。 +5. **`*.workers.dev` では Cache API と `Cache-Control` が効かない。** キャッシュのベンチマークを workers.dev に対して回しても何も測れない。独自ドメインに移行するまでキャッシュ戦略は Worker 内メモリのみ。 +6. **D1 は 1 データベースあたり単一ライター(SQLite)。** `AccessLogs` は単一テーブルなので、**アクセスを複数プロジェクトに分散させても書き込み競合は緩和しない**。期待してはいけない。 + +--- + +## 3. ツールと配置 + +**k6 を使う。** Windows なら `winget install k6`(または `scoop install k6` / `choco install k6`)の 1 コマンドで、単一 Go バイナリが入る。 + +k6 を選ぶ理由: + +1. **`ramping-arrival-rate` によるオープンモデル負荷。** QR スキャンは「サーバが遅くなっても人は同じ頻度でスキャンする」到着率の現象であって、同時接続数の現象ではない。`-c N` 系のクローズドモデルツール(autocannon など)は Worker が遅くなると勝手に自己抑制し、**まさに探している劣化を隠す**。 +2. **`redirects: 0`。** スキャン経路はリダイレクトを返すので、リダイレクトを追跡するツールは遷移先(Instagram など)を測ってしまう。 +3. **`thresholds` で終了コードが立つ。** CI ゲートにできる。 +4. **負荷生成が Go。** Node 製ツールはノート PC で測定対象と CPU を奪い合う。 + +k6 バイナリが用意できない人向けに autocannon のスモークだけ残してあるが、計画の土台にはしない。 + +### ディレクトリ + +``` +packages/api/loadtest/ + README.md # §2 の「読み違えやすい点」を冒頭に転記 + seed/generate.mjs # SQL + qrids.csv を生成 + seed/run.mjs # wrangler を child_process で叩く Node ドライバ + k6/lib/config.js # BASE_URL / 共通しきい値 / SharedArray(qrIds) + k6/scan-sustained.js # S1 + k6/scan-spike.js # S2 + k6/admin-projects.js # S3 + k6/csv-export.js # S4 + k6/login-bruteforce.js # S5 + k6/smoke.js # S6 (CI ゲート) + k6/scan-ramping-prod.js # P1 + autocannon/smoke.mjs +``` + +`packages/api/` 配下に置くのは、`wrangler` と `schema.sql` への相対パスで完結するため。トップレベルに置くとワークスペースパッケージ化したくなるが、利点がない。 + +### Windows での規律 + +オーケストレーションはすべて `run.mjs` の `child_process` 内で行う。npm script のシェル連結(`&&`、`$(...)`、単一引用符で囲んだ JSON)は Windows で壊れる。 + +### npm スクリプト + +``` +loadtest:seed:local node loadtest/seed/run.mjs --target=local --logs=500000 +loadtest:scan k6 run loadtest/k6/scan-sustained.js +loadtest:spike k6 run loadtest/k6/scan-spike.js +loadtest:admin k6 run loadtest/k6/admin-projects.js +loadtest:csv k6 run loadtest/k6/csv-export.js +loadtest:login k6 run loadtest/k6/login-bruteforce.js +loadtest:smoke k6 run loadtest/k6/smoke.js +loadtest:prod:ramp k6 run loadtest/k6/scan-ramping-prod.js +``` + +--- + +## 4. シードデータ + +### 4.1 ローカル(大量) + +`generate.mjs` が `.sql` を吐き、`wrangler d1 execute --local --file=` で流す。D1 の制約への対処: + +- **バインドパラメータは 1 クエリ約 100 件が上限** → プレースホルダを使わず**リテラル値**を出力して回避する。 +- **1 文の SQL サイズ上限** → `INSERT ... VALUES (...),(...)` を **500 行 / 文**にする。50 万行 = 1,000 文を約 10 ファイルに分割。 +- **`BEGIN` / `COMMIT` は D1 API では使えない** → 明示トランザクションを出力しない。速度を買うのは「文を大きくすること」であってトランザクションではない。 +- **Free は 1 DB 500 MB** → 50 万行 ≈ 100 MB + インデックス。収まるが同じオーダーなので、ローカルで 500 万行入れて本番で驚かないこと。 + +### 4.2 データを一様乱数にしない + +一様乱数データはインデックスの選択性を過大評価させ、p95 が実際より良く見える。以下の偏りを付ける。 + +- **Zipf 的な偏り** — 1 プロジェクトに全スキャンの約 60%、その中の 1 QR に約 30%。 +- **`accessed_at`** — 3 日間に日内変動 + 10 分の急峻なスパイク 1 回(ステージ告知の再現)。複合インデックス `(project_id, accessed_at)` が範囲走査する対象。 +- **User Agent** — 実物のプールから引く。 + - 実ユーザー: iOS Safari / Android Chrome / **`Line/14.x`(LINE アプリ内ブラウザ)** + - ボット: `facebookexternalhit/1.1;line-poker/0.1` / `Twitterbot/1.0` / `Slackbot-LinkExpanding` + - **同じプールでボット判定(`classifyUserAgent`)をアサートする。** `Line/` を誤ってボット扱いすると最大の実トラフィックを消すので、ここは必ずテストする。 +- **IP** — 学内 /16 + キャリアレンジ、**繰り返しあり**(実スキャンは端末ごとに繰り返す)。 + +### 4.3 複数プロジェクトへの分散はなぜ必要か + +経路によって効き方が違う。 + +**スキャン経路** — プロジェクト数はリダイレクト自体(主キー参照)には影響しない。効くのは 2 点: + +- Worker 内メモリキャッシュのヒット率。**単一 QR ID を叩き続けると非現実的に良い数字が出る**ので、必ず Zipf 重みで分散させる。 +- 逆に、**D1 の書き込み競合は分散しても減らない**(§2-6)。 + +**管理経路** — ここが本命。**`PAGE_SIZE = 10` を超えるプロジェクト数(ローカルは 25 件)がないと、`ORDER BY` 欠落によるページング重複/欠落バグも、集計をページ内に絞る修正の効果も観測できない。** + +**ユニーク制約の確認** — 同じ名前が別プロジェクトでは作れること(意図した挙動)、および場所が空の QR を複数作れることを確認する。 + +### 4.4 本番(少量) + +管理 API 経由で **5 プロジェクト × 各 10〜50 QR** を作る。11 件以上にして、管理画面のページングを実 D1 レイテンシで確認する。遷移先は `https://example.com/...` にしておく。 + +--- + +## 5. シナリオ + +想定実ピークは数百スキャン/分(約 5 rps)、最悪でも 50 rps × 30 秒程度。以下の目標は概ねその 10 倍なので、通れば実質的な余裕がある。 + +### 5.1 ローカルで回すもの + +| # | シナリオ | 形 | データ | 合否基準 | +|---|---|---|---|---| +| **S1** | スキャン持続 | `constant-arrival-rate` 50 rps × 5 分(15,000 件)、qrId は Zipf 重み、`redirects: 0` | 50 万ログ | 失敗率 < 0.1% / 全レスポンスが 302 / p95 < 50 ms / **CPU p95 < 8 ms**(10 ms 上限へのマージン)/ D1 書き込み数 = リクエスト数 ±0.1% | +| **S2** | **スキャンスパイク** | `ramping-arrival-rate` 5 → **500 rps** を 30 秒でランプ、60 秒維持、降下 | 50 万ログ | 5xx ゼロ / 失敗率 < 0.5% / p99 < 1 秒 / **`SQLITE_BUSY`・`database is locked`・D1 書き込み競合エラーがゼロ** | +| **S3** | 管理ダッシュボード | `GET /projects?page=1&limit=10` を 5 VU × 1 分 + コールド 1 発、`page=3` でも同様 | **50 万ログ / 25 プロジェクト** | p95 < 200 ms / CPU < 8 ms / **リクエストあたり `rows_read` < 5,000** / page 1→3 で projectId が重複せず欠落しない | +| **S4** | CSV 出力 | 1 VU 逐次 3 回、1 万行 / 5 万行 / 上限超え | 5 万 / 10 万 | 200 / **TTFB < 2 秒で行数にほぼ依存しない** / エラー 1102 なし / メモリ超過なし / 上限超えは 500 ではなく **413 + 実件数** | +| **S5** | ログイン総当たり | 誤パスワードで 20 rps × 30 秒 | 任意 | 90% 以上が 429 / 別キーでの正パスワードログインは成功する | +| **S6** | スモーク・回帰(**CI ゲート**) | 1 VU で全エンドポイント 1 周 | 200 ログ | §5.4 参照 | + +**S2 がこの計画で最も価値が高い。** D1 は単一ライターなので、**500 並列 INSERT が優雅に直列化するのか、エラーを吐き始めるのかを答える唯一のシナリオ**。他のどのシナリオもこの問いには答えない。書き込み 3 万件は Free の日次クォータの 30% を消費するため、**必ずローカルのみで回す**。 + +**S1・S3 は修正前のベースラインを先に記録する。** 悪い数字がそのまま before/after の証拠になる。特に S3 の `rows_read` は本当のアサーションで、レイテンシだけ見ていると温まったページキャッシュが全件走査を隠す。 + +### 5.2 本番で回すもの(合計約 14,000 リクエスト) + +| # | 形 | 件数 | 目的 | +|---|---|---|---| +| **P1** | `ramping-arrival-rate` **5 → 200 rps を 60 秒でランプ、60 秒維持**、qrId は 5 プロジェクトに Zipf 分散、`redirects: 0` | 約 9,000 | 実 D1 レイテンシでの**劣化の境界**と信頼できる p95/p99 分布。段階的に上げるので「どこで壊れるか」が分かる(ステップ関数は「どこかで壊れた」しか言わない) | +| **P2** | soak 約 0.5 rps × 2〜3 時間 | 約 5,000 | ログ書き込みが**リクエスト数と 1:1 で着地するか**、isolate の安定性、ログが全部届いているか | +| **P3** | S3 / S4 / S5 を各 1 回 | 約 100 | 実環境の CPU 時間の実測 | + +書き込み消費 ≈ 14,100 / 100,000 = **14%**。修正後の再実行が 2 回分残るのが、この数字を選んだ理由。 + +**避けるべき形**: + +- **「同時 5,000 リクエストを 1 発」は測定にならない。** 1 台の PC ではエフェメラルポート・TLS ハンドシェイクの CPU・上り帯域が先に詰まるので、測っているのは Worker ではなく自分の PC。さらに単一 IP からの 5,000 同時は Cloudflare 自身の濫用対策に引っかかり、429/1015 を「アプリが落ちた」と誤読することになる。サンプル 1 個なので p95/p99 も信頼できない。 +- **2 万リクエストを 2〜3 時間(約 2 rps)は soak であって負荷テストではない。** 2 rps で競合は絶対に出ない。5,000 件と同じ情報しか買えないので、差額のクォータを捨てているだけ。 + +### 5.3 本番実行の手順 + +1. 実行前に Cloudflare ダッシュボードで **D1 の当日 rows written** を確認(0 に近いこと)。リセットは 09:00 JST。 +2. P1 の直前にバックアップ: `wrangler d1 export trackinglink-db --remote --output=backup-YYYYMMDD.sql` +3. 実行中に見るもの: + - Workers → Metrics: **5xx** と **CPU 時間 p99** + - Workers → Logs: `access_log_insert_failed` / `Unhandled error` / エラーコード 1101(uncaught throw)・1102(CPU 超過)・1015(レート制限) + - Security → Events: Cloudflare 自身のブロックと自分の障害を区別する +4. **後片付け** — **D1 は `DELETE` した行も rows written に計上する。** 14,000 行を消すとさらに 14,000 書き込みで合計 28% になる。 + - **推奨**: リリース前にどうせ本番を空にするなら、`wrangler d1 delete trackinglink-db` → `wrangler d1 create trackinglink-db` → `schema.sql` と移行を流し直すのが**書き込みゼロ**で済む。`database_id` が変わるので `wrangler.jsonc` の更新を忘れないこと。 + - 行単位で消すなら、**テストと別日**にしてクォータを分ける。 + +### 5.4 S6(CI ゲート)のアサーション + +CI には **S6 のみ**を `smoke` ジョブとして追加する。**これがこのリポジトリ初の自動テストになる**のが導入の本当の理由。 + +手順: `schema.sql --local` を適用 → 200 行シード → `wrangler dev --local --port 8789` をバックグラウンド起動 → `/healthz` を待つ → `k6 run smoke.js` → kill。 + +挙動のアサーション: + +- スキャンが **302** を返し、`Cache-Control: no-store` が付いている +- 不明な `id` は 404 +- ボット UA は 200 の HTML(OG メタ) +- **`Line/` UA は実ユーザーとして `is_bot = 0` で記録される** +- ページングが安定(重複・欠落なし) +- `ANALYTICS` 権限なしの CSV は 403 +- `/healthz` が 200 + +しきい値は緩め(p95 < 500 ms)にして、**数値ではなく挙動**をゲートする。 + +**S1〜S5 は CI に入れない。** 共有ランナーでは安定した負荷数値が出ず、1 ヶ月でジョブを無効化することになる。 + +--- + +## 6. リリース前チェックリスト + +### 6.1 リリースブロッカー + +- [ ] **ヘルスチェック** — `/healthz`(`{ok, version}`)と `/readyz`(`SELECT 1`、失敗で 503)。`GET /` は `?id=` なしで 404 を返すので**ヘルスチェック先には使えない**。無料の外部監視(UptimeRobot)を `/healthz` に 1〜5 分間隔で向ける。`/readyz` を 1 分間隔で叩いても約 1,440 読み取り/日で無視できる。 +- [ ] **`ALLOWED_ORIGINS`** — 自ホストの Web ドメインを追加する。オリジンは scheme 込み・末尾スラッシュなし・`www` の有無は別オリジン。**加えて fail-open を直す**: `index.ts` の `allowed.length > 0 ? allowed : '*'` は、変数の設定漏れやタイポが**無言で `Authorization` 許可のワイルドカード CORS になる**。設定ミスが目立つよう fail-closed にする。 +- [ ] **`CSV_EXPORT_ENABLED`** — `"false"` のままリリースする。ストリーミング化・件数上限・期間指定・`ANALYTICS` 権限チェックがすべて入り、S4 が通ってから `"true"` にする。フラグを立てるのは 1 行コミットで自動デプロイされるので、無効のまま出すコストはゼロ。 +- [ ] **セッション TTL** — `auth/local.ts` の `SESSION_TTL_SECONDS` を 24 時間 → **8 時間**(1 イベント日)に短縮。 +- [ ] **遷移先 URL のプロトコル制限** — zod の `.string().url()` は `javascript:` や `data:` も通す。その値は Worker が 302 でリダイレクトし管理 UI がリンクとして描画するため、**保存型 XSS / オープンリダイレクト**になる。`http:` / `https:` のみに制限する。 +- [ ] **セキュリティヘッダ** — `dist/` を配る静的サーバに `Content-Security-Policy` / `X-Content-Type-Options: nosniff` / `X-Frame-Options: DENY`。管理 UI は他に何もホストしていないドメインで配る。 +- [ ] **構造化ログ** — 裸の `console.error` を 1 行 JSON(`{event, qrId, projectId, status, durMs, ray}`)に置き換える。`observability.enabled` は既に `true` なので Workers Logs が索引化し、`access_log_insert_failed` が「干し草の中の針」ではなく実際のメトリクスになる。 +- [ ] **バックアップ** — リリース前・破壊的な移行の前・各イベント日の後に `wrangler d1 export`。マシン外に保存する。 +- [ ] **ロールバック手順を書いて 1 度リハーサルする**(§6.3)。 + +### 6.2 判断が必要な事項 + +- [ ] **プライバシー** — 生 IP をスキャンごとに保存し CSV にも出している。学祭・個人情報保護法の観点で **/24 に丸めるか salt 付きハッシュ**にするか判断する。DB と CSV も小さくなる。1 行の変更なので「既定のまま」ではなく明示的な判断として記録する。 +- [ ] **独自ドメイン** — `wrangler.jsonc` にコメントアウト済みの `routes` がある。移行すると Cache API・WAF レート制限ルール・同一サイト Cookie が使えるようになる。**QR に焼き込む URL が変わるので、やるならポスター印刷前が唯一のタイミング。** 見送る場合は「`*.workers.dev` サブドメインを将来も保持できる」前提を受け入れることになる。 +- [ ] **トークン保存先** — 24 時間(→8 時間)の全権限 JWT が `localStorage` にあり、XSS で読める。Web が自ホスト・API が `workers.dev` でクロスサイトなので、Cookie 化には `SameSite=None; Secure` + `credentials: 'include'` + 厳密な CORS が必要で、防げる範囲に対して割に合わない。学祭後に、Web と API を同一の独自ドメインに載せるのとセットで再検討する。 + +### 6.3 ロールバック + +**19:00 の学祭当日に `wrangler` のフラグを学ぶことにならないよう、事前に 1 度練習する。** + +- **コード** — 本番は `main` への push で Workers Builds が走るので、`git revert` はビルド 1 周分(数分)かかる。速い経路は**ダッシュボードの「Rollback」**、または `wrangler deployments list` / `wrangler rollback`。**1 度練習しておく。** +- **データ** — D1 Time Travel が 30 日の PITR を提供する。 + ``` + wrangler d1 time-travel info trackinglink-db + wrangler d1 time-travel restore trackinglink-db --timestamp= + ``` + **データベース全体を巻き戻す破壊的操作**なので、少なくとも `info` を 1 度読んでおくこと。`--remote` の export は読み取りクォータを消費する。 +- **ブレークグラス** — ポスターが出回った後に Worker が壊れたら、**無条件に学祭トップへ 302 する 5 行の Worker** が 500 を返し続けるより圧倒的にまし。**今のうちに書いて手元に置く。** + ```ts + // は実際の学祭トップに差し替えてから保管する + export default { + fetch: () => Response.redirect('', 302), + }; + ``` +- **移行のリスク** — `AccessLogs` の外部キー追加はテーブル再作成(create → copy → drop → rename)を伴い、唯一戻しにくい手順。**export を先に取り、実データが無いうちに流す。** + +### 6.4 イベント当日に監視するもの + +1. **D1 → Metrics の当日 rows written vs 100,000 の上限。** — **アラームすべき唯一の数字。** 「上限の 60% を超えたら即 Paid プランにする」を事前合意しておく。 +2. Workers → Metrics: リクエスト/秒、**5xx**、CPU 時間 p50 / p99。 +3. Workers → Logs: `access_log_insert_failed` / `Unhandled error` / 1101 / 1102 / 1015。 +4. D1: データベースサイズ vs Free の 500 MB。 +5. **数時間ごとに自分で実物のポスターをスキャンする。** 「印刷された QR が間違ったホストを指している」はどのダッシュボードにも出ない。 + +--- + +## 7. 参考: 将来のシール印刷ビューに向けた設計メモ + +今回のリリースでは一括印刷(シール印刷)は実装せず、物ごとに QR を 1 つ生成して単体でダウンロードする運用。将来一括印刷を作るときのために、調査済みの要点を残す。 + +- **専用ルート `/links/:id/print`** を `ProtectedRoute` の中・`AppLayout` の外に置く。サイドバーとトップバーが印刷時の DOM に存在しなくなる。`?ids=a,b,c` を受ける形にすれば、選択印刷は純粋な追加で足せる(URL 長のため 40 件程度で上限、超過/不在なら全件フォールバック)。 +- **QR は同期計算のインライン SVG で描く。** `qrcode` の `QRCodeLib.create(text)` は**同期**でモジュール行列を返すので、印刷ページに非同期 QR 状態が一切不要になる(20 個の Promise 調整もローディングプレースホルダも `useEffect` の競合もない)。ベクターなのでプリンタの DPI に依らず綺麗。 +- **モジュールは CSS 背景ではなく SVG `` で描く。** Chrome の印刷ダイアログでデフォルト ON の「背景のグラフィック」を OFF にしても消えない。`background-image` 方式だとここで無言で白紙になる。 +- `QRCodeLib.toString({type: 'svg'})` + `dangerouslySetInnerHTML` は避ける。Biome の `security/noDangerouslySetInnerHtml` が有効なので抑制コメントが必要になる。`a11y/noSvgWithoutTitle` も有効なので `` を実際に入れる。 +- カードには **name / 媒体 / 場所のキャプション**を必ず入れる。`break-inside: avoid` でカードがページ境界で割れないようにする。サイズは A4 あたり 2 / 6 / 12 の 3 段階。 +- **`modules.get(row, col)` の引数順を取り違えるとシンボルが鏡像になって読めない。** これは画面では分からないので、**A4 を 1 枚実際に印刷して全コードをスマホでスキャンする**のが唯一の検証手段。 +- Safari は `break-inside: avoid` のサポートが最も弱いので、Mac 利用の可能性があれば Safari でも PDF を確認する。 From fa285448ffff44e542a1fedd6567879d1e1e8592 Mon Sep 17 00:00:00 2001 From: kamijo-haruto <kamijoharuto@gmail.com> Date: Sat, 25 Jul 2026 16:58:44 +0900 Subject: [PATCH 02/11] =?UTF-8?q?[chore]=20QR=E3=82=B3=E3=83=BC=E3=83=89?= =?UTF-8?q?=E3=81=AE=E3=83=A6=E3=83=8B=E3=83=BC=E3=82=AF=E5=88=B6=E7=B4=84?= =?UTF-8?q?=E3=82=92=E5=90=8D=E5=89=8D=E5=9F=BA=E6=BA=96=E3=81=AB=E5=A4=89?= =?UTF-8?q?=E6=9B=B4=E3=81=97AccessLogs=E3=81=AE=E3=82=A4=E3=83=B3?= =?UTF-8?q?=E3=83=87=E3=83=83=E3=82=AF=E3=82=B9=E3=81=A8=E5=A4=96=E9=83=A8?= =?UTF-8?q?=E3=82=AD=E3=83=BC=E3=82=92=E6=95=B4=E5=82=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 想定運用は「ポスターなどの物ごとにQRを1つ生成し、必要であれば場所を 記載する」だが、ユニーク制約が (project_id, medium, location) だった ため、場所を空にした2枚目のポスターが409で作成できなかった。制約が 「媒体×場所ごとに1つ」をモデル化していて運用と矛盾していた。 - ユニーク制約を (project_id, name) に張り替え、location を任意化 (未設定は NULL ではなく '' で保持し、読み取り・CSV・インデックスが 見る表現を1つにする)。同じポスターへのQR二重発行を検知でき、名前から 組むダウンロードファイル名もプロジェクト内で一意になる。 - AccessLogs に qr_id → QRCodes(id) の外部キーを ON DELETE CASCADE で 追加。従来は外部キーが無く、プロジェクト削除でスキャン履歴が孤児化 してUIから到達できない行が残っていた。ローカルD1で、カスケード削除と 存在しないqr_idの拒否がどちらも実際に効くことを確認済み。 - (project_id, accessed_at DESC) の複合インデックスを追加し、冗長に なった idx_access_logs_project_id を削除。アクセスログ一覧とCSVは どちらも project_id で絞って accessed_at 降順に並べるのでソート段が 消え、あわせてスキャン1件あたりのインデックス書き込みが1つ減る。 - AccessLogs に is_bot 列を追加。ボットのヒットは捨てずにフラグで残す ことで、判定を後から改良して過去分を再集計できる。 - Drizzleスキーマに accessLogs.id を追加。SQL側には存在するのにモデルに 無く、accessed_at の安定したタイブレーカもCSVのキーセットカーソルも 表現できなかった。 移行は migrations/0002 に用意した。孤児化したログは新しい外部キーを 満たせないため移行時に削除される(件数を事前に数えるクエリをコメントに 記載)。schema.sql と 0001 のコメントも旧インデックス前提だったので更新。 QRCodesテーブルがQR画像ではなくメタデータを保持していること(画像は クライアント側で都度生成する)も、PR #1 のレビュー指摘を受けてコメントで 明示した。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .../0001_add_qrcode_name_medium.sql | 9 ++- ...002_qrcode_unique_name_and_access_logs.sql | 70 +++++++++++++++++++ packages/api/schema.sql | 50 +++++++++---- packages/api/src/db/schema.ts | 67 ++++++++++++++---- 4 files changed, 165 insertions(+), 31 deletions(-) create mode 100644 packages/api/migrations/0002_qrcode_unique_name_and_access_logs.sql diff --git a/packages/api/migrations/0001_add_qrcode_name_medium.sql b/packages/api/migrations/0001_add_qrcode_name_medium.sql index 63fea2f..c005763 100644 --- a/packages/api/migrations/0001_add_qrcode_name_medium.sql +++ b/packages/api/migrations/0001_add_qrcode_name_medium.sql @@ -3,15 +3,14 @@ -- error ("duplicate column name"). A brand-new database doesn't need this at -- all: schema.sql's CREATE TABLE already includes these columns. -- --- Before running, make sure no project has more than one QRCodes row sharing --- the same (now-blank) medium+location — schema.sql's unique index creation --- will otherwise fail. Give any such duplicates distinct location values --- first. +-- The columns are added blank. QRCodes is unique on (project_id, name) as of +-- migration 0002, so before creating that index you have to give the blank +-- names distinct values — run 0002 next and follow its instructions. -- -- Apply with: -- wrangler d1 execute trackinglink-db --local --file=./migrations/0001_add_qrcode_name_medium.sql -- wrangler d1 execute trackinglink-db --remote --file=./migrations/0001_add_qrcode_name_medium.sql --- Then re-run schema.sql to create the unique index. +-- Then run 0002, then re-run schema.sql. ALTER TABLE QRCodes ADD COLUMN name TEXT NOT NULL DEFAULT ''; ALTER TABLE QRCodes ADD COLUMN medium TEXT NOT NULL DEFAULT ''; diff --git a/packages/api/migrations/0002_qrcode_unique_name_and_access_logs.sql b/packages/api/migrations/0002_qrcode_unique_name_and_access_logs.sql new file mode 100644 index 0000000..2df4d23 --- /dev/null +++ b/packages/api/migrations/0002_qrcode_unique_name_and_access_logs.sql @@ -0,0 +1,70 @@ +-- One-time migration for databases created before: +-- * QRCodes was unique on (project_id, name) instead of +-- (project_id, medium, location) +-- * AccessLogs had an id column exposed, an is_bot column, a foreign key to +-- QRCodes, and a (project_id, accessed_at) index +-- +-- Not idempotent. A brand-new database does not need this at all — schema.sql +-- already creates everything below. +-- +-- BEFORE RUNNING: +-- +-- 1. Take a backup. This rebuilds AccessLogs, which is the only step here that +-- is hard to undo: +-- wrangler d1 export trackinglink-db --remote --output=backup-YYYYMMDD.sql +-- +-- 2. Check for duplicate names within a project. The new unique index will fail +-- to create if this returns any rows — rename the duplicates first: +-- SELECT project_id, name, COUNT(*) FROM QRCodes +-- GROUP BY 1, 2 HAVING COUNT(*) > 1; +-- +-- 3. Note that the AccessLogs rebuild DROPS rows whose qr_id no longer exists +-- in QRCodes. Those are logs orphaned by earlier project deletions: the old +-- schema had no foreign key, so deleting a project left its scan history +-- behind with nothing in the UI able to reach it. Count them first if you +-- want to know what you are losing: +-- SELECT COUNT(*) FROM AccessLogs +-- WHERE qr_id NOT IN (SELECT id FROM QRCodes); +-- +-- Apply with: +-- wrangler d1 execute trackinglink-db --local --file=./migrations/0002_qrcode_unique_name_and_access_logs.sql +-- wrangler d1 execute trackinglink-db --remote --file=./migrations/0002_qrcode_unique_name_and_access_logs.sql +-- Then re-run schema.sql. + +-- 1. QRCodes: one QR code per named item, instead of one per medium+location. +-- The old constraint made the intended workflow impossible: with location +-- optional, a second poster with a blank location collided with the first. +DROP INDEX IF EXISTS idx_qrcodes_project_medium_location; +CREATE UNIQUE INDEX IF NOT EXISTS idx_qrcodes_project_name ON QRCodes(project_id, name); + +-- 2. AccessLogs: rebuild to add the foreign key and is_bot. +-- SQLite cannot ALTER TABLE ... ADD CONSTRAINT, so this is the standard +-- create-copy-drop-rename. Do it while the table is small. +CREATE TABLE AccessLogs_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + qr_id TEXT NOT NULL, + project_id TEXT NOT NULL, + accessed_at TEXT NOT NULL, + user_agent TEXT, + ip_address TEXT, + is_bot INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY (qr_id) REFERENCES QRCodes(id) ON DELETE CASCADE +); + +-- The WHERE clause is required, not optional: rows referencing a deleted QR +-- code would fail the new foreign key. +INSERT INTO AccessLogs_new (qr_id, project_id, accessed_at, user_agent, ip_address) +SELECT qr_id, project_id, accessed_at, user_agent, ip_address +FROM AccessLogs +WHERE qr_id IN (SELECT id FROM QRCodes); + +DROP TABLE AccessLogs; +ALTER TABLE AccessLogs_new RENAME TO AccessLogs; + +-- 3. Indexes. The composite index serves the log list and the CSV export +-- (WHERE project_id = ? ORDER BY accessed_at DESC) without a sort step, and +-- its leading column covers what idx_access_logs_project_id used to do — +-- dropping it removes one index write per scan on the hot path. +DROP INDEX IF EXISTS idx_access_logs_project_id; +CREATE INDEX IF NOT EXISTS idx_access_logs_project_accessed_at ON AccessLogs(project_id, accessed_at DESC); +CREATE INDEX IF NOT EXISTS idx_access_logs_qr_id ON AccessLogs(qr_id); diff --git a/packages/api/schema.sql b/packages/api/schema.sql index a5046fc..dea5b0b 100644 --- a/packages/api/schema.sql +++ b/packages/api/schema.sql @@ -1,8 +1,11 @@ --- TrackingLink schema +-- TrackingLink schema — source of truth. Safe to re-run any number of times. -- -- Apply with: -- wrangler d1 execute trackinglink-db --local --file=./schema.sql -- wrangler d1 execute trackinglink-db --remote --file=./schema.sql +-- +-- Upgrading a database created before a schema change? Run the matching file in +-- migrations/ once first (in numeric order), then re-run this file. CREATE TABLE IF NOT EXISTS Projects ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -13,15 +16,25 @@ CREATE TABLE IF NOT EXISTS Projects ( admin_user_id TEXT ); +-- QR code *metadata*. The QR image itself is never stored: the web app renders +-- it client-side on every view (see useQRDataUrl in QRCodesPage.tsx). A row +-- exists because the id is baked into the printed URL (`/?id=<id>`), so it has +-- to be stable forever — regenerating it would break already-posted flyers — +-- and because AccessLogs joins on it to attribute scans to a medium/location. CREATE TABLE IF NOT EXISTS QRCodes ( id TEXT PRIMARY KEY, - project_id TEXT NOT NULL, + -- What the QR code is printed on, e.g. "造形大ポスター". Unique per project: + -- one QR code per physical item. name TEXT NOT NULL, + project_id TEXT NOT NULL, + -- Source channel/type, e.g. "ポスター", "チラシ", "Instagram". medium TEXT NOT NULL, - location TEXT NOT NULL, + -- Where the item is posted/handed out. Optional — staff record it only when + -- it is useful. Stored as '' rather than NULL when unset, so that reads, + -- CSV export and the unique index all see a single representation. + location TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, - creator_id TEXT, - FOREIGN KEY (project_id) REFERENCES Projects(project_id) ON DELETE CASCADE + creator_id TEXT ); CREATE TABLE IF NOT EXISTS AccessLogs ( @@ -30,14 +43,27 @@ CREATE TABLE IF NOT EXISTS AccessLogs ( project_id TEXT NOT NULL, accessed_at TEXT NOT NULL, user_agent TEXT, - ip_address TEXT + ip_address TEXT, + -- 1 when the request came from a link-preview crawler rather than a person. + -- Bot hits are recorded and flagged rather than dropped, so the classifier + -- can be refined later and historical counts recomputed. + is_bot INTEGER NOT NULL DEFAULT 0, + -- Deleting a QR code (or a project, which cascades to its QR codes) takes + -- its scan history with it. Without this, deletes left unreachable rows + -- behind. + FOREIGN KEY (qr_id) REFERENCES QRCodes(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_qrcodes_project_id ON QRCodes(project_id); -CREATE INDEX IF NOT EXISTS idx_access_logs_project_id ON AccessLogs(project_id); -CREATE INDEX IF NOT EXISTS idx_access_logs_qr_id ON AccessLogs(qr_id); -CREATE UNIQUE INDEX IF NOT EXISTS idx_qrcodes_project_medium_location ON QRCodes(project_id, medium, location); --- Upgrading a database created before the name/medium columns existed? --- Run migrations/0001_add_qrcode_name_medium.sql once first, then re-run this --- file (it's safe to re-run any number of times after that). +-- One QR code per named item within a project. Guards against issuing a second +-- QR code for a poster that already has one, and keeps the download filename +-- (built from the name) unique. +CREATE UNIQUE INDEX IF NOT EXISTS idx_qrcodes_project_name ON QRCodes(project_id, name); + +-- Serves both the access-log list and the CSV export, which filter by +-- project_id and order by accessed_at DESC — the leading column also covers +-- plain project_id lookups and COUNT(*), so no separate index on project_id is +-- needed (one less index to write on every scan). +CREATE INDEX IF NOT EXISTS idx_access_logs_project_accessed_at ON AccessLogs(project_id, accessed_at DESC); +CREATE INDEX IF NOT EXISTS idx_access_logs_qr_id ON AccessLogs(qr_id); diff --git a/packages/api/src/db/schema.ts b/packages/api/src/db/schema.ts index 3d9c2b6..b31a23a 100644 --- a/packages/api/src/db/schema.ts +++ b/packages/api/src/db/schema.ts @@ -1,4 +1,10 @@ -import { sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core'; +import { + index, + integer, + sqliteTable, + text, + uniqueIndex, +} from 'drizzle-orm/sqlite-core'; export const projects = sqliteTable('Projects', { projectId: text('project_id').primaryKey(), @@ -8,6 +14,10 @@ export const projects = sqliteTable('Projects', { adminUserId: text('admin_user_id'), }); +// QR code *metadata*. The image is never persisted — the web app regenerates it +// client-side on every view. The row exists because `id` is baked into the +// printed URL (`/?id=<id>`) and so has to stay stable forever, and because +// AccessLogs joins on it. export const qrCodes = sqliteTable( 'QRCodes', { @@ -15,27 +25,56 @@ export const qrCodes = sqliteTable( projectId: text('project_id') .notNull() .references(() => projects.projectId, { onDelete: 'cascade' }), - // Generic, non-unique label for the source (e.g. "造形大ポスター"). + // What the QR code is printed on (e.g. "造形大ポスター"). Unique per + // project: one QR code per physical item. name: text('name').notNull(), // Source channel/type (e.g. "Instagram", "ポスター"). medium: text('medium').notNull(), - // Where the source is posted/displayed or handed out (free text, or a URL - // for online sources) — unrelated to Projects.destinationUrl. + // Where the item is posted/handed out — unrelated to + // Projects.destinationUrl. Optional to the *caller*: stored as '' rather + // than NULL when unset, so reads, CSV export and the unique index see one + // representation. + // + // Deliberately NOT `.default('')` here even though schema.sql declares + // DEFAULT '': a database upgraded via migrations/0002 still has plain + // `location TEXT NOT NULL` with no default, so relying on the column + // default would fail there. Keeping it required in Drizzle forces every + // insert to pass `location ?? ''` explicitly, which works on both. location: text('location').notNull(), createdAt: text('created_at').notNull(), creatorId: text('creator_id'), }, (table) => ({ - projectMediumLocation: uniqueIndex( - 'idx_qrcodes_project_medium_location', - ).on(table.projectId, table.medium, table.location), + projectName: uniqueIndex('idx_qrcodes_project_name').on( + table.projectId, + table.name, + ), }), ); -export const accessLogs = sqliteTable('AccessLogs', { - qrId: text('qr_id').notNull(), - projectId: text('project_id').notNull(), - accessedAt: text('accessed_at').notNull(), - userAgent: text('user_agent'), - ipAddress: text('ip_address'), -}); +export const accessLogs = sqliteTable( + 'AccessLogs', + { + // Autoincrement rowid. Needed as a tiebreaker for `accessed_at` ordering + // (two scans can share a millisecond) and as the keyset cursor the + // streaming CSV export pages on. + id: integer('id').primaryKey({ autoIncrement: true }), + qrId: text('qr_id') + .notNull() + .references(() => qrCodes.id, { onDelete: 'cascade' }), + projectId: text('project_id').notNull(), + accessedAt: text('accessed_at').notNull(), + userAgent: text('user_agent'), + ipAddress: text('ip_address'), + // 1 for link-preview crawlers. Bot hits are flagged rather than dropped so + // the classifier can be refined and past counts recomputed. + isBot: integer('is_bot').notNull().default(0), + }, + (table) => ({ + projectAccessedAt: index('idx_access_logs_project_accessed_at').on( + table.projectId, + table.accessedAt, + ), + qrId: index('idx_access_logs_qr_id').on(table.qrId), + }), +); From b223b3ba650e11e2880ff537ce0c9febd17f7513 Mon Sep 17 00:00:00 2001 From: kamijo-haruto <kamijoharuto@gmail.com> Date: Sat, 25 Jul 2026 16:59:13 +0900 Subject: [PATCH 03/11] =?UTF-8?q?[chore]=20.gitattributes=20=E3=81=A7?= =?UTF-8?q?=E6=94=B9=E8=A1=8C=E3=82=B3=E3=83=BC=E3=83=89=E3=82=92LF?= =?UTF-8?q?=E3=81=AB=E5=9B=BA=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit core.autocrlf=true のWindows環境では作業ツリーがCRLFになり、biomeが LFで整形するため `pnpm lint` が全ファイルで改行差分のエラーを出していた。 一方CI(ubuntu)はLFでチェックアウトするので通ってしまい、ローカルだけが 原因不明に落ちる状態だった。 git側は既に全ファイルLFで保存されていたので、リポジトリの内容は変わらず 作業ツリーの改行が揃うだけ。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .gitattributes | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..3bb5650 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +# Normalize to LF in the repository and check out LF in the working tree, on +# every platform. Without this, a Windows clone with core.autocrlf=true gets +# CRLF working files, and `pnpm lint` fails on every file with a wall of +# line-ending diffs — while CI (ubuntu, LF) passes, so the failure looks like a +# local mystery. Biome formats to LF, so the working tree has to be LF too. +* text=auto eol=lf From 0579de68171482ede94699b680f8a2d6c8658d64 Mon Sep 17 00:00:00 2001 From: kamijo-haruto <kamijoharuto@gmail.com> Date: Sat, 25 Jul 2026 17:01:20 +0900 Subject: [PATCH 04/11] =?UTF-8?q?[chore]=20biome=E3=81=8C.gitignore?= =?UTF-8?q?=E3=82=92=E5=B0=8A=E9=87=8D=E3=81=99=E3=82=8B=E3=82=88=E3=81=86?= =?UTF-8?q?=E8=A8=AD=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .claude/settings.local.json のようなマシン固有の未追跡ファイルが lint 対象になっており、CI には存在しないためローカルだけが落ちていた。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- biome.jsonc | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/biome.jsonc b/biome.jsonc index d8accd3..66529a8 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -1,5 +1,14 @@ { "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + // Respect .gitignore so that locally-generated, untracked files are not + // linted. Without this, `pnpm lint` fails on machine-specific files such as + // .claude/settings.local.json, which CI never sees — so the failure only ever + // happens on a developer's machine. + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, "files": { "ignore": [ "**/dist", From 52c6f89f429cc283ddef296f4cf00573c19660d8 Mon Sep 17 00:00:00 2001 From: kamijo-haruto <kamijoharuto@gmail.com> Date: Sat, 25 Jul 2026 17:07:23 +0900 Subject: [PATCH 05/11] =?UTF-8?q?[fix]=20=E3=82=B9=E3=82=AD=E3=83=A3?= =?UTF-8?q?=E3=83=B3=E6=99=82=E3=81=AE=E3=83=AA=E3=83=80=E3=82=A4=E3=83=AC?= =?UTF-8?q?=E3=82=AF=E3=83=88=E3=82=92302=E3=81=AB=E3=81=97DB=E5=BE=80?= =?UTF-8?q?=E5=BE=A9=E3=81=A8=E3=83=AD=E3=82=B0=E6=9B=B8=E3=81=8D=E8=BE=BC?= =?UTF-8?q?=E3=81=BF=E3=82=92=E6=9C=80=E9=81=A9=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit スキャンのホットパスは全来場者が通る唯一の経路だが、301を返しD1へ3回 逐次アクセスし、しかもログのINSERTがレスポンスをブロックしていた。 - 301 → 302 + Cache-Control: no-store。301はブラウザとアプリ内WebViewに 無期限キャッシュされるため、(1) 同一端末の再スキャンがAPIに届かず計測が 過少になり、(2) 遷移先を変更しても既にスキャンした人には永久に届かな かった。既に301を受け取った端末は救済できない(QR再発行=再印刷しかない) ため、ポスター印刷前・本番負荷テスト前に入れる必要がある。GETなので307の メソッド保持は無意味で、QRリーダー内蔵ブラウザの互換性から302を選んだ。 - QR参照とプロジェクト参照を1本のleftJoinに統合し、逐次往復を1回削減。 innerJoinではなくleftJoinにして「QRが無い」「プロジェクトが無い」の 既存2分岐とステータスコードをそのまま再現している。 - ログのINSERTを executionCtx.waitUntil に移した。D1の書き込みはプライマリ 宛で3つのクエリの中で最も遅いのに、来場者にとっては不要な待ち時間だった。 代償として書き込み失敗がクライアントから見えなくなるので、失敗時に access_log_insert_failed を1行JSONで出しWorkers Logsで数えられるように し、さらに LOG_WRITE_MODE=sync でデプロイなしに同期へ戻せるようにした。 - ボット判定を src/user-agent.ts に切り出し、除外せず is_bot=1 で記録する ように変更。従来は facebook / Discordbot の部分一致だけで、Twitterbot や Slackbot が実スキャンとして黙って計上されていた。逆に LINE のアプリ内 ブラウザ(Line/13.x)は実ユーザーなので、明示的に除外して誤ってボット扱い しないようにしている(ここを逆にすると最大の実トラフィックが消える)。 ローカルD1で検証済み: 人間UA/LINEアプリ内ブラウザは302 + no-store、 line-poker と Twitterbot は200のOGメタHTML、不明IDは404で書き込みゼロ。 waitUntilの書き込みはリクエスト数と1:1で着地し、is_bot は Line/13.x が0、 line-poker と Twitterbot が1。LOG_WRITE_MODE=sync も動作を確認した。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- README.md | 8 +-- packages/api/src/auth/types.ts | 8 +++ packages/api/src/log.ts | 29 ++++++++ packages/api/src/routes/forward.ts | 109 +++++++++++++++++++++-------- packages/api/src/user-agent.ts | 71 +++++++++++++++++++ packages/api/wrangler.jsonc | 6 +- 6 files changed, 196 insertions(+), 35 deletions(-) create mode 100644 packages/api/src/log.ts create mode 100644 packages/api/src/user-agent.ts diff --git a/README.md b/README.md index fa40917..2e61c7c 100644 --- a/README.md +++ b/README.md @@ -7,12 +7,12 @@ QRコード/リンクのアクセスを記録し、リダイレクトするた ## できること - **プロジェクト**は遷移先(Instagramアカウント、特定のWebページなど)を表し、同じ遷移先URLを持つQRコードをグループ化する -- 各**QRコード**は遷移"元"のソース情報を持つ: **名前**(総称、例:「造形大ポスター」)、**媒体**(例:「Instagram」「ポスター」)、**場所**(貼ってある場所や渡された場所、例:「1F掲示板」)。媒体+場所はプロジェクト内で重複登録できない -- スキャンのたびにアクセスログ(日時・User-Agent・IP)を記録し、301リダイレクトで指定URLへ遷移 -- 管理画面でプロジェクトごとのスキャン数、QRコード管理(作成/編集/印刷/削除)、アクセスログのCSVダウンロード(現在は無効化中)を確認可能 +- 各**QRコード**は遷移"元"のソース情報を持つ: **名前**(貼る物の名前、例:「造形大ポスター」)、**媒体**(例:「Instagram」「ポスター」)、**場所**(貼ってある場所や渡された場所、例:「1F掲示板」。任意)。**物ごとに1つのQRコードを発行する**運用なので、名前はプロジェクト内で重複登録できない +- スキャンのたびにアクセスログ(日時・User-Agent・IP)を記録し、302リダイレクトで指定URLへ遷移。SNSのリンクプレビュー用クローラーからのアクセスは `is_bot` フラグを立てて記録する(捨てないので、判定を後から改良して過去分を再集計できる) +- 管理画面でプロジェクトごとのスキャン数、QRコード管理(作成/編集/削除)、アクセスログのCSVダウンロード(現在は無効化中)を確認可能 ``` - QRコードをスキャン ──▶ GET /?id={qrId} (packages/api) ──▶ ログ記録 ──▶ 301リダイレクト + QRコードをスキャン ──▶ GET /?id={qrId} (packages/api) ──▶ ログ記録 ──▶ 302リダイレクト │ ▼ D1データベース (Projects, QRCodes, AccessLogs) diff --git a/packages/api/src/auth/types.ts b/packages/api/src/auth/types.ts index bc56af6..1ed102b 100644 --- a/packages/api/src/auth/types.ts +++ b/packages/api/src/auth/types.ts @@ -8,6 +8,14 @@ export interface Bindings { ALLOWED_ORIGINS?: string; /** Set to "true" to enable the CSV export endpoint. Defaults to disabled. */ CSV_EXPORT_ENABLED?: string; + /** + * How the scan endpoint writes its access log. Defaults to "async", which + * takes the D1 write off the response path via waitUntil. Set to "sync" to + * await it instead — an escape hatch that can be flipped from the Cloudflare + * dashboard, without a deploy, if logged writes ever look like they are being + * dropped during an event. + */ + LOG_WRITE_MODE?: 'async' | 'sync'; } export interface AuthUser { diff --git a/packages/api/src/log.ts b/packages/api/src/log.ts new file mode 100644 index 0000000..8898643 --- /dev/null +++ b/packages/api/src/log.ts @@ -0,0 +1,29 @@ +// Structured logging. +// +// Cloudflare's Workers Logs indexes the fields of single-line JSON, so emitting +// objects instead of interpolated strings is what makes an event like +// `access_log_insert_failed` a filterable metric rather than a needle in a +// haystack. `observability` is enabled in wrangler.jsonc, so these show up +// without any further setup. + +type LogFields = Record<string, unknown>; + +/** Emit an informational event. */ +export function logEvent(event: string, fields: LogFields = {}): void { + console.log(JSON.stringify({ event, ...fields })); +} + +/** Emit a failure. `error` is reduced to its message — stacks are noise here. */ +export function logFailure( + event: string, + error: unknown, + fields: LogFields = {}, +): void { + console.error( + JSON.stringify({ + event, + ...fields, + error: error instanceof Error ? error.message : String(error), + }), + ); +} diff --git a/packages/api/src/routes/forward.ts b/packages/api/src/routes/forward.ts index 32fcb8d..8108d27 100644 --- a/packages/api/src/routes/forward.ts +++ b/packages/api/src/routes/forward.ts @@ -4,10 +4,16 @@ import { getConnInfo } from 'hono/cloudflare-workers'; import { html } from 'hono/html'; import type { HonoEnv } from '../auth'; import { getDb, schema } from '../db'; +import { logFailure } from '../log'; +import { isBotUserAgent, wantsLinkPreview } from '../user-agent'; const forwardApp = new Hono<HonoEnv>(); // GET /?id=<qrId> — scan a QR code: look it up, log the access, redirect. +// +// This is the only hot path in the app: every person who scans a printed poster +// goes through it, in bursts. It is deliberately one D1 read (a primary-key +// lookup) on the critical path — see the join and the waitUntil below. forwardApp.get('/', async (c) => { const { id } = c.req.query(); const userAgent = c.req.header('User-Agent') || 'unknown'; @@ -15,16 +21,28 @@ forwardApp.get('/', async (c) => { if (!id) return c.text('Not found', 404); const db = getDb(c.env.DB); - const qrCode = await db - .select() + + // One round trip instead of two. A leftJoin (rather than an inner join) so + // that "QR code missing" and "project missing" stay distinguishable, which + // the two 404 branches below rely on. + const target = await db + .select({ + projectId: schema.qrCodes.projectId, + location: schema.qrCodes.location, + projectName: schema.projects.name, + destinationUrl: schema.projects.destinationUrl, + }) .from(schema.qrCodes) + .leftJoin( + schema.projects, + eq(schema.qrCodes.projectId, schema.projects.projectId), + ) .where(eq(schema.qrCodes.id, id)) .get(); - const isLinkPreviewBot = - userAgent.includes('facebook') || userAgent.includes('Discordbot'); + const isLinkPreviewBot = wantsLinkPreview(userAgent); - if (!qrCode) { + if (!target) { if (isLinkPreviewBot) { return c.html( html`<!DOCTYPE html> @@ -45,14 +63,51 @@ forwardApp.get('/', async (c) => { return c.text('QR code not found', 404); } - const project = await db - .select() - .from(schema.projects) - .where(eq(schema.projects.projectId, qrCode.projectId)) - .get(); - if (!project) return c.text('Project not found', 404); + // destination_url is NOT NULL, so a null here means the leftJoin found no + // matching project rather than a project with no URL. + if (!target.destinationUrl) return c.text('Project not found', 404); - const location = qrCode.location; + const { remote } = getConnInfo(c); + + // Record the hit — including crawler hits, flagged rather than dropped, so a + // refined classifier can recount them later. + const write = db + .insert(schema.accessLogs) + .values({ + qrId: id, + projectId: target.projectId, + accessedAt: new Date().toISOString(), + userAgent, + ipAddress: remote.address ?? null, + isBot: isBotUserAgent(userAgent) ? 1 : 0, + }) + .then(() => undefined) + .catch((error: unknown) => { + // Swallowed on purpose: a lost analytics row must never cost the visitor + // a 500. Emitted as a structured event so dropped writes are countable + // in Workers Logs rather than invisible. + logFailure('access_log_insert_failed', error, { + qrId: id, + projectId: target.projectId, + }); + }); + + // D1 writes go to the primary and are the slowest of the queries here, while + // the visitor does not care about them at all — so they come off the response + // path. The trade-off is that a failed write is no longer visible to the + // client; LOG_WRITE_MODE=sync forces the old behaviour back without a deploy + // if the numbers ever look wrong mid-event. + if (c.env.LOG_WRITE_MODE === 'sync') { + await write; + } else { + try { + c.executionCtx.waitUntil(write); + } catch { + // No execution context (e.g. app.request() in a test) — fall back to + // awaiting rather than dropping the write. + await write; + } + } if (isLinkPreviewBot) { const accessedAt = new Date().toISOString(); @@ -61,8 +116,11 @@ forwardApp.get('/', async (c) => { <html lang="en"> <head prefix="og: http://ogp.me/ns#"> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> - <meta property="og:title" content="${project.name} QR code" /> - <meta property="og:description" content="${accessedAt} · location: ${location}" /> + <meta property="og:title" content="${target.projectName} QR code" /> + <meta + property="og:description" + content="${accessedAt} · location: ${target.location}" + /> </head> </html>`, 200, @@ -70,22 +128,13 @@ forwardApp.get('/', async (c) => { ); } - const { remote } = getConnInfo(c); - - try { - await db.insert(schema.accessLogs).values({ - qrId: id, - projectId: qrCode.projectId, - accessedAt: new Date().toISOString(), - userAgent, - ipAddress: remote.address ?? null, - }); - } catch (error) { - console.error('[GET /] failed to insert access log:', error); - // Don't block the redirect just because logging failed. - } - - return c.redirect(project.destinationUrl, 301); + // 302, not 301. A permanent redirect is cached indefinitely by browsers and + // in-app webviews, which would (a) hide every repeat scan from the same + // device, undercounting the campaign, and (b) pin visitors to an old + // destination forever after an edit to the project. no-store makes that + // explicit for intermediaries that get creative. + c.header('Cache-Control', 'no-store, max-age=0'); + return c.redirect(target.destinationUrl, 302); }); export default forwardApp; diff --git a/packages/api/src/user-agent.ts b/packages/api/src/user-agent.ts new file mode 100644 index 0000000..f890f9b --- /dev/null +++ b/packages/api/src/user-agent.ts @@ -0,0 +1,71 @@ +// Telling link-preview crawlers apart from people. +// +// Two things make this worth its own file rather than an inline `includes()`: +// +// 1. Getting it backwards is expensive. LINE is the dominant sharing app for +// this audience and it shows up as *two different things* — its link-preview +// crawler (`facebookexternalhit/1.1;line-poker/0.1`) is a bot, while its +// in-app browser (`Line/13.15.0`) is a real person holding a phone. Sweeping +// the latter into the bot bucket would delete the largest slice of real +// traffic. +// +// 2. Bot hits are recorded with `is_bot = 1` rather than dropped, so a wrong +// answer here is recoverable: the classifier can be refined later and past +// rows recounted. That is the whole reason the column exists. +// +// This list is a starting point, not a finished answer. Before the event, look +// at what actually arrives and tune from real data rather than guesswork: +// +// SELECT user_agent, COUNT(*) FROM AccessLogs +// GROUP BY 1 ORDER BY 2 DESC LIMIT 50; + +const BOT_PATTERNS: RegExp[] = [ + // Messaging / social link previews + /facebookexternalhit/i, + /line-poker/i, // LINE's preview crawler (not its in-app browser) + /Discordbot/i, + /Twitterbot/i, + /Slackbot/i, + /TelegramBot/i, + /WhatsApp/i, + /SkypeUriPreview/i, + /LinkedInBot/i, + /Pinterest/i, + /redditbot/i, + // Search engines + /Googlebot/i, + /Google-InspectionTool/i, + /bingbot/i, + /DuckDuckBot/i, + /YandexBot/i, + /Baiduspider/i, + /Applebot/i, + // Generic. `bot\b` matches "Somebot/1.0" and "…bot)" but not a word like + // "robotics" mid-token, and no mainstream browser UA contains it. + /bot\b/i, + /crawler/i, + /spider/i, +]; + +// Checked first, and wins outright. LINE's in-app browser must never be +// classified as a bot, whatever the patterns above happen to match. +const HUMAN_PATTERNS: RegExp[] = [/\bLine\/\d/i]; + +/** True when the request looks like a link-preview crawler rather than a person. */ +export function isBotUserAgent(userAgent: string): boolean { + if (HUMAN_PATTERNS.some((pattern) => pattern.test(userAgent))) return false; + return BOT_PATTERNS.some((pattern) => pattern.test(userAgent)); +} + +/** + * True when the crawler should be served OG meta tags instead of a redirect. + * + * Currently the same set as `isBotUserAgent` — every crawler we recognise wants + * a preview. Kept as a separate function because the two questions are + * genuinely different ("should this count as a scan?" vs "what should we send + * back?") and will diverge the moment we want to log a crawler without + * rendering a preview for it. + */ +export function wantsLinkPreview(userAgent: string): boolean { + return isBotUserAgent(userAgent); +} diff --git a/packages/api/wrangler.jsonc b/packages/api/wrangler.jsonc index 0e617e0..fb8331c 100644 --- a/packages/api/wrangler.jsonc +++ b/packages/api/wrangler.jsonc @@ -25,7 +25,11 @@ // Use "*" for local development; restrict this in production. "ALLOWED_ORIGINS": "http://localhost:5173,http://127.0.0.1:5173,https://trackinglink-web.nutfes-nutmeg9488.workers.dev", // Set to "true" to turn on the CSV export endpoint. Stays "false" until told to activate. - "CSV_EXPORT_ENABLED": "false" + "CSV_EXPORT_ENABLED": "false", + // "async" (default) takes the access-log write off the redirect's response + // path. Flip to "sync" from the dashboard — no deploy needed — if writes + // ever look like they are going missing during an event. + "LOG_WRITE_MODE": "async" } // Secrets (set with `wrangler secret put <NAME>`, never committed): // JWT_SECRET - random string used to sign/verify admin session tokens From 79db96d46f01f9a2698fdf1b17a8a8450d80b5d0 Mon Sep 17 00:00:00 2001 From: kamijo-haruto <kamijoharuto@gmail.com> Date: Sat, 25 Jul 2026 17:12:54 +0900 Subject: [PATCH 06/11] =?UTF-8?q?[fix]=20API=E3=82=A8=E3=83=A9=E3=83=BC?= =?UTF-8?q?=E3=81=AB=E6=A9=9F=E6=A2=B0=E5=8F=AF=E8=AA=AD=E3=82=B3=E3=83=BC?= =?UTF-8?q?=E3=83=89=E3=82=92=E4=BB=98=E4=B8=8E=E3=81=97=E5=A0=B4=E6=89=80?= =?UTF-8?q?=E3=82=92=E4=BB=BB=E6=84=8F=E5=8C=96=E3=83=BB=E4=B8=80=E8=A6=A7?= =?UTF-8?q?=E3=81=AE=E4=B8=A6=E3=81=B3=E9=A0=86=E3=81=A8=E6=A8=A9=E9=99=90?= =?UTF-8?q?=E3=83=81=E3=82=A7=E3=83=83=E3=82=AF=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## エラーコード 文言をサーバーが決めていたため翻訳が原理的に不可能だった。英語の開発者 向け文字列("Invalid request body")がそのまま利用者に出る一方、409だけは 日本語がハードコードされていて、日本語UIの利用者には英語が、英語UIの 利用者には日本語が出ていた。src/errors.ts に安定したコードを定義し、 文言はクライアントが持つ形に変えた。ログイン失敗(INVALID_PASSWORD)を セッション切れ(UNAUTHORIZED)と別コードにしたのは、パスワード間違いを 「セッションが切れました」と表示させないため。 ## 遷移先URLの検証(セキュリティ) zod の .string().url() は javascript: / data: / vbscript: / file: を全て 通すことを実測で確認した(zod 3.25.76)。その値をWorkerが302でリダイレクト し、管理UIが <a href> として描画するため、プロジェクトを作成・編集できる 人による保存型XSS/オープンリダイレクトになっていた(管理画面のオリジンには APIトークンがある)。http/https のみに制限した。 ## 場所の任意化 「物ごとにQRを1つ生成し、必要であれば場所を記載する」運用に合わせ location を任意にした。あわせて更新時の判定を真偽値から `!== undefined` に変更した。 空文字が偽なので、従来は一度入力した場所を消すことが不可能だった。 ## 並び順 ORDER BY が無く、D1が返す順序は不定だった。そのため作成直後のプロジェクトが 1ページ目に現れる保証がなく(利用者は作成に失敗したと考えて作り直す)、 ページ間で行が重複・欠落しうる。全一覧エンドポイントに作成日時の降順を付け、 同一ミリ秒の並びを確定させるため第2キー(project_id / id)を足した。 ## 集計の絞り込み GET /projects は10行を描画するために、ページに関係なく AccessLogs と QRCodes の全件を GROUP BY していた。現ページのIDに絞る形(inArray)にし、D1のバインド パラメータ上限(約100)に対して余裕を持たせるためこのエンドポイントの limit 上限を50に下げた。 ## 権限チェック GET /projects, GET /:id, POST /, PUT /:id, GET /:id/access-logs, CSV は 認証のみで認可が無く、READMEの表と食い違っていた。単一管理者に ALL_PERMISSIONS が付く現状では露出しないが、Verifierの差し替え口を用意した 意味が消えるので VIEW / EDIT / ANALYTICS を付けた。 ## その他 - GET /projects/qrcodes が全プロジェクト横断の無制限 .all() だったのを ページング化(Webからは未使用のため戻り値の形の変更は安全)。 - zod 全文字列に .max() を付与し、1行のサイズ=DBとCSVのサイズを有界にした。 - QR削除時の明示的な db.delete(accessLogs) を削除。migration 0002 で追加した ON DELETE CASCADE が実際のD1バインディング経由で効くことを確認済み (アクセスログ2件 → QR削除 → 0件)。削除あたりD1書き込みが1つ減る。 注意: Web側がエラーコードを見るのは後続PRなので、それまでの間は日本語UIでも 英語のフォールバック文言が出る。APIだけ先にデプロイする場合はその窓が開く。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- packages/api/src/auth/middleware.ts | 13 +- packages/api/src/errors.ts | 85 ++++++ packages/api/src/routes/auth.ts | 11 +- packages/api/src/routes/projects.ts | 384 +++++++++++++++++++++------- 4 files changed, 390 insertions(+), 103 deletions(-) create mode 100644 packages/api/src/errors.ts diff --git a/packages/api/src/auth/middleware.ts b/packages/api/src/auth/middleware.ts index d090134..08b0806 100644 --- a/packages/api/src/auth/middleware.ts +++ b/packages/api/src/auth/middleware.ts @@ -1,4 +1,5 @@ import type { MiddlewareHandler } from 'hono'; +import { ErrorCodes, fail } from '../errors'; import { verifyLocalSession } from './local'; import type { HonoEnv, Verifier } from './types'; @@ -23,14 +24,22 @@ export function createAuthMiddleware( verify: Verifier, ): MiddlewareHandler<HonoEnv> { return async (c, next) => { + // Both failures share one code: the web app turns UNAUTHORIZED into "your + // session expired, sign in again" and sends the user to /login, and the + // distinction between "no header" and "bad token" is not something a user + // can act on differently. const token = extractBearerToken(c.req.header('Authorization')); if (!token) { - return c.json({ error: 'Authorization header required' }, 401); + return fail(c, 401, ErrorCodes.UNAUTHORIZED, { + message: 'Authorization header required', + }); } const user = await verify(token, c.env); if (!user) { - return c.json({ error: 'Invalid or expired token' }, 401); + return fail(c, 401, ErrorCodes.UNAUTHORIZED, { + message: 'Invalid or expired token', + }); } c.set('user', user); diff --git a/packages/api/src/errors.ts b/packages/api/src/errors.ts new file mode 100644 index 0000000..a5149bc --- /dev/null +++ b/packages/api/src/errors.ts @@ -0,0 +1,85 @@ +import type { Context } from 'hono'; +import type { ContentfulStatusCode } from 'hono/utils/http-status'; + +/** + * Stable, machine-readable error codes. + * + * The point of these is that the *client* owns the wording. Before this existed, + * the API returned English developer strings ("Invalid request body") and, in + * the 409 case, a hardcoded Japanese sentence — so a Japanese user saw English, + * an English user saw Japanese, and neither could be translated because the + * message was decided on the server. The web app maps `code` to an i18n key + * instead; `error` is only a fallback for curl and logs. + * + * Codes are part of the API contract: rename one and the web app silently falls + * back to a generic message. + */ +export const ErrorCodes = { + UNAUTHORIZED: 'UNAUTHORIZED', + PASSWORD_REQUIRED: 'PASSWORD_REQUIRED', + INVALID_PASSWORD: 'INVALID_PASSWORD', + PERMISSION_REQUIRED: 'PERMISSION_REQUIRED', + NOT_OWNER: 'NOT_OWNER', + INVALID_BODY: 'INVALID_BODY', + NO_FIELDS_TO_UPDATE: 'NO_FIELDS_TO_UPDATE', + PROJECT_NOT_FOUND: 'PROJECT_NOT_FOUND', + QR_CODE_NOT_FOUND: 'QR_CODE_NOT_FOUND', + DUPLICATE_NAME: 'DUPLICATE_NAME', + CSV_EXPORT_DISABLED: 'CSV_EXPORT_DISABLED', + TOO_MANY_ROWS: 'TOO_MANY_ROWS', + RATE_LIMITED: 'RATE_LIMITED', +} as const; + +export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes]; + +const FALLBACK_MESSAGES: Record<ErrorCode, string> = { + UNAUTHORIZED: 'Authentication required', + PASSWORD_REQUIRED: 'Password is required', + INVALID_PASSWORD: 'Invalid password', + PERMISSION_REQUIRED: 'Insufficient permissions', + NOT_OWNER: 'You can only modify items you created', + INVALID_BODY: 'Invalid request body', + NO_FIELDS_TO_UPDATE: 'No fields to update', + PROJECT_NOT_FOUND: 'Project not found', + QR_CODE_NOT_FOUND: 'QR code not found', + DUPLICATE_NAME: 'A QR code with this name already exists in this project', + CSV_EXPORT_DISABLED: 'CSV export is not enabled', + TOO_MANY_ROWS: 'Too many rows — narrow the date range', + RATE_LIMITED: 'Too many attempts — try again shortly', +}; + +export interface FailOptions { + /** Fields the client should mark invalid, e.g. ['name']. */ + fields?: string[]; + /** Machine-readable context, e.g. { total, max } for TOO_MANY_ROWS. */ + meta?: Record<string, unknown>; + /** Overrides the English fallback text. Never shown to a logged-in user. */ + message?: string; + /** Zod's flattened issues, for debugging a rejected body. */ + details?: unknown; +} + +/** + * Respond with a coded error. + * + * Takes a `Context<any>` rather than `Context<HonoEnv>` deliberately: importing + * HonoEnv here would make errors.ts and auth/ import each other, and this + * helper genuinely does not care about the env. + */ +export function fail( + c: Context<any>, + status: ContentfulStatusCode, + code: ErrorCode, + options: FailOptions = {}, +) { + return c.json( + { + code, + error: options.message ?? FALLBACK_MESSAGES[code], + ...(options.fields ? { fields: options.fields } : {}), + ...(options.meta ? { meta: options.meta } : {}), + ...(options.details ? { details: options.details } : {}), + }, + status, + ); +} diff --git a/packages/api/src/routes/auth.ts b/packages/api/src/routes/auth.ts index 24c1deb..662de33 100644 --- a/packages/api/src/routes/auth.ts +++ b/packages/api/src/routes/auth.ts @@ -1,10 +1,13 @@ import { Hono } from 'hono'; import * as z from 'zod'; import { type HonoEnv, authMiddleware, signLocalSession } from '../auth'; +import { ErrorCodes, fail } from '../errors'; import { ALL_PERMISSIONS } from '../permissions'; const loginBodySchema = z.object({ - password: z.string().min(1), + // Capped so an unauthenticated caller cannot make the Worker hash/compare an + // arbitrarily large body. + password: z.string().min(1).max(200), }); const authApp = new Hono<HonoEnv>(); @@ -22,11 +25,13 @@ authApp.post('/login', async (c) => { const body = await c.req.json().catch(() => null); const parsed = loginBodySchema.safeParse(body); if (!parsed.success) { - return c.json({ error: 'password is required' }, 400); + return fail(c, 400, ErrorCodes.PASSWORD_REQUIRED, { fields: ['password'] }); } + // A dedicated code, distinct from UNAUTHORIZED: the web app must not treat a + // wrong password on the login form as "your session expired". if (parsed.data.password !== c.env.ADMIN_PASSWORD) { - return c.json({ error: 'Invalid password' }, 401); + return fail(c, 401, ErrorCodes.INVALID_PASSWORD, { fields: ['password'] }); } const token = await signLocalSession( diff --git a/packages/api/src/routes/projects.ts b/packages/api/src/routes/projects.ts index caecc63..a4f7fa8 100644 --- a/packages/api/src/routes/projects.ts +++ b/packages/api/src/routes/projects.ts @@ -1,41 +1,83 @@ -import { count, desc, eq } from 'drizzle-orm'; -import { Hono } from 'hono'; +import { count, desc, eq, inArray } from 'drizzle-orm'; +import { type Context, Hono } from 'hono'; import * as z from 'zod'; import type { HonoEnv } from '../auth'; import { getDb, schema } from '../db'; +import { ErrorCodes, fail } from '../errors'; import { Permissions, hasPermission } from '../permissions'; +// Length caps keep a single row (and therefore the database, and the CSV export) +// bounded. Without them a 10k-character name is accepted and then wrecks every +// table that renders it. +const NAME_MAX = 200; +const URL_MAX = 2048; + +// z.string().url() accepts *any* parseable URL, including `javascript:`, +// `data:`, `vbscript:` and `file:` (verified against zod 3.25). That value is +// then 302-redirected to by the Worker and rendered as `<a href>` in the admin +// UI, which makes it a stored-XSS / open-redirect vector reachable by anyone who +// can create or edit a project — and the admin session holds the API token. +const httpUrl = z + .string() + .url() + .max(URL_MAX) + .refine((value) => { + try { + const { protocol } = new URL(value); + return protocol === 'http:' || protocol === 'https:'; + } catch { + return false; + } + }, 'Only http(s) URLs are allowed'); + const createProjectBodySchema = z.object({ - projectName: z.string().min(1, 'Project name is required'), - destinationUrl: z.string().url('Enter a valid URL'), + projectName: z.string().min(1, 'Project name is required').max(NAME_MAX), + destinationUrl: httpUrl, }); const updateProjectBodySchema = z.object({ - projectName: z.string().min(1).optional(), - destinationUrl: z.string().url().optional(), + projectName: z.string().min(1).max(NAME_MAX).optional(), + destinationUrl: httpUrl.optional(), }); const createQRCodeBodySchema = z.object({ projectId: z.string().min(1, 'projectId is required'), - name: z.string().min(1, 'name is required'), - medium: z.string().min(1, 'medium is required'), - location: z.string().min(1, 'location is required'), + name: z.string().min(1, 'name is required').max(NAME_MAX), + medium: z.string().min(1, 'medium is required').max(NAME_MAX), + // Optional: staff record where an item is posted only when it is useful. + // Stored as '' when absent — see the note on schema.qrCodes.location. + location: z.string().max(NAME_MAX).optional(), }); const updateQRCodeBodySchema = z.object({ - name: z.string().min(1).optional(), - medium: z.string().min(1).optional(), - location: z.string().min(1).optional(), + name: z.string().min(1).max(NAME_MAX).optional(), + medium: z.string().min(1).max(NAME_MAX).optional(), + location: z.string().max(NAME_MAX).optional(), }); -// Pagination params, capped at 100 rows/page. -function parsePagination(query: Record<string, string | undefined>) { - const page = Math.max(1, Number(query.page ?? '1')); - const limit = Math.min(Math.max(1, Number(query.limit ?? '10')), 100); +/** + * Pagination params. + * + * `maxLimit` is per-endpoint because /projects fans out into an `inArray` over + * the page's ids, and D1 caps a query at roughly 100 bound parameters. + */ +function parsePagination( + query: Record<string, string | undefined>, + maxLimit = 100, +) { + const page = Math.max(1, Number(query.page ?? '1') || 1); + const limit = Math.min( + Math.max(1, Number(query.limit ?? '10') || 10), + maxLimit, + ); const offset = (page - 1) * limit; return { page, limit, offset }; } +// Half of D1's ~100 bound-parameter ceiling, so the aggregation below has room +// to spare. +const PROJECTS_MAX_LIMIT = 50; + // True if `error` is a SQLite UNIQUE constraint violation, regardless of the // exact wrapper D1's driver throws it in. function isUniqueConstraintError(error: unknown): boolean { @@ -44,17 +86,65 @@ function isUniqueConstraintError(error: unknown): boolean { ); } +/** + * Returns a 403 response when the caller lacks `permission`, or null to proceed. + * + * Every read and write below is gated. Previously only DELETE and the QR code + * mutations checked, while the README documented otherwise — harmless while a + * single admin login grants ALL_PERMISSIONS, but it silently leaks the moment a + * lesser-privileged token exists, which is the entire point of the Verifier + * seam. + */ +function denyUnlessPermitted( + c: Context<HonoEnv>, + permission: number, + permissionName: string, +) { + const user = c.get('user'); + if (!hasPermission(user?.permissions ?? 0, permission)) { + return fail(c, 403, ErrorCodes.PERMISSION_REQUIRED, { + message: `${permissionName} permission required`, + meta: { required: permissionName }, + }); + } + return null; +} + const projectsApp = new Hono<HonoEnv>(); -// GET /projects/qrcodes — every QR code across all projects +// GET /projects/qrcodes — paginated QR codes across every project projectsApp.get('/qrcodes', async (c) => { + const denied = denyUnlessPermitted( + c, + Permissions.TRACKING_LINK_VIEW, + 'TRACKING_LINK_VIEW', + ); + if (denied) return denied; + + const { limit, offset } = parsePagination(c.req.query()); const db = getDb(c.env.DB); - const qrCodes = await db.select().from(schema.qrCodes).all(); - return c.json(qrCodes); + const [qrCodes, totalRows] = await Promise.all([ + db + .select() + .from(schema.qrCodes) + .orderBy(desc(schema.qrCodes.createdAt), desc(schema.qrCodes.id)) + .limit(limit) + .offset(offset) + .all(), + db.select({ total: count() }).from(schema.qrCodes), + ]); + return c.json({ data: qrCodes, total: totalRows[0]?.total ?? 0 }); }); // GET /projects/qrcodes/:id — a single QR code projectsApp.get('/qrcodes/:id', async (c) => { + const denied = denyUnlessPermitted( + c, + Permissions.TRACKING_LINK_VIEW, + 'TRACKING_LINK_VIEW', + ); + if (denied) return denied; + const qrId = c.req.param('id'); const db = getDb(c.env.DB); const qrCode = await db @@ -62,28 +152,62 @@ projectsApp.get('/qrcodes/:id', async (c) => { .from(schema.qrCodes) .where(eq(schema.qrCodes.id, qrId)) .get(); - if (!qrCode) return c.json({ error: 'QR code not found' }, 404); + if (!qrCode) return fail(c, 404, ErrorCodes.QR_CODE_NOT_FOUND); return c.json(qrCode); }); // GET /projects — paginated project list with access and QR code counts projectsApp.get('/', async (c) => { - const { limit, offset } = parsePagination(c.req.query()); + const denied = denyUnlessPermitted( + c, + Permissions.TRACKING_LINK_VIEW, + 'TRACKING_LINK_VIEW', + ); + if (denied) return denied; + + const { limit, offset } = parsePagination(c.req.query(), PROJECTS_MAX_LIMIT); const db = getDb(c.env.DB); - const [rows, totalRows, accessCounts, qrCounts] = await Promise.all([ - db.select().from(schema.projects).limit(limit).offset(offset).all(), - db.select({ total: count() }).from(schema.projects), - db - .select({ projectId: schema.accessLogs.projectId, accessCount: count() }) - .from(schema.accessLogs) - .groupBy(schema.accessLogs.projectId) - .all(), + + // ORDER BY is not cosmetic: without it D1 returns rows in whatever order it + // likes, so a freshly created project need not appear on page 1 (users + // conclude the create failed and make another), and rows can repeat or vanish + // between pages. ISO-8601 sorts chronologically as text; project_id breaks + // same-millisecond ties so paging is deterministic. + const [rows, totalRows] = await Promise.all([ db - .select({ projectId: schema.qrCodes.projectId, qrCodeCount: count() }) - .from(schema.qrCodes) - .groupBy(schema.qrCodes.projectId) + .select() + .from(schema.projects) + .orderBy(desc(schema.projects.createdAt), desc(schema.projects.projectId)) + .limit(limit) + .offset(offset) .all(), + db.select({ total: count() }).from(schema.projects), ]); + + // Scoped to the current page's ids. This used to GROUP BY over *all* of + // AccessLogs and *all* of QRCodes on every request regardless of page — a + // full scan of the largest table in the system to render ten rows. + const projectIds = rows.map((row) => row.projectId); + const [accessCounts, qrCounts] = projectIds.length + ? await Promise.all([ + db + .select({ + projectId: schema.accessLogs.projectId, + accessCount: count(), + }) + .from(schema.accessLogs) + .where(inArray(schema.accessLogs.projectId, projectIds)) + .groupBy(schema.accessLogs.projectId) + .all(), + db + .select({ projectId: schema.qrCodes.projectId, qrCodeCount: count() }) + .from(schema.qrCodes) + .where(inArray(schema.qrCodes.projectId, projectIds)) + .groupBy(schema.qrCodes.projectId) + .all(), + ]) + : [[], []]; + const accessCountMap = Object.fromEntries( accessCounts.map((row) => [row.projectId, row.accessCount]), ); @@ -107,14 +231,21 @@ projectsApp.get('/', async (c) => { // POST /projects — create a project projectsApp.post('/', async (c) => { + const denied = denyUnlessPermitted( + c, + Permissions.TRACKING_LINK_EDIT, + 'TRACKING_LINK_EDIT', + ); + if (denied) return denied; + const parsed = createProjectBodySchema.safeParse( await c.req.json().catch(() => null), ); if (!parsed.success) { - return c.json( - { error: 'Invalid request body', details: parsed.error.flatten() }, - 400, - ); + return fail(c, 400, ErrorCodes.INVALID_BODY, { + fields: Object.keys(parsed.error.flatten().fieldErrors), + details: parsed.error.flatten(), + }); } const { projectName, destinationUrl } = parsed.data; const user = c.get('user'); @@ -138,6 +269,13 @@ projectsApp.post('/', async (c) => { // GET /projects/:id — a single project projectsApp.get('/:id', async (c) => { + const denied = denyUnlessPermitted( + c, + Permissions.TRACKING_LINK_VIEW, + 'TRACKING_LINK_VIEW', + ); + if (denied) return denied; + const projectId = c.req.param('id'); const db = getDb(c.env.DB); const project = await db @@ -145,7 +283,7 @@ projectsApp.get('/:id', async (c) => { .from(schema.projects) .where(eq(schema.projects.projectId, projectId)) .get(); - if (!project) return c.json({ error: 'Project not found' }, 404); + if (!project) return fail(c, 404, ErrorCodes.PROJECT_NOT_FOUND); return c.json({ id: project.projectId, projectId: project.projectId, @@ -158,23 +296,35 @@ projectsApp.get('/:id', async (c) => { // PUT /projects/:id — update a project projectsApp.put('/:id', async (c) => { + const denied = denyUnlessPermitted( + c, + Permissions.TRACKING_LINK_EDIT, + 'TRACKING_LINK_EDIT', + ); + if (denied) return denied; + const projectId = c.req.param('id'); const parsed = updateProjectBodySchema.safeParse( await c.req.json().catch(() => null), ); if (!parsed.success) { - return c.json( - { error: 'Invalid request body', details: parsed.error.flatten() }, - 400, - ); + return fail(c, 400, ErrorCodes.INVALID_BODY, { + fields: Object.keys(parsed.error.flatten().fieldErrors), + details: parsed.error.flatten(), + }); } + // `!== undefined` rather than truthiness, so "field omitted" and "field set + // to a blank string" stay distinguishable. Both of these have .min(1) so they + // cannot actually be blanked, but the QR code handler below relies on the + // same shape to let a location be cleared. const values: Partial<typeof schema.projects.$inferInsert> = {}; - if (parsed.data.projectName) values.name = parsed.data.projectName; - if (parsed.data.destinationUrl) + if (parsed.data.projectName !== undefined) + values.name = parsed.data.projectName; + if (parsed.data.destinationUrl !== undefined) values.destinationUrl = parsed.data.destinationUrl; if (Object.keys(values).length === 0) { - return c.json({ error: 'No fields to update' }, 400); + return fail(c, 400, ErrorCodes.NO_FIELDS_TO_UPDATE); } const db = getDb(c.env.DB); @@ -183,30 +333,38 @@ projectsApp.put('/:id', async (c) => { .set(values) .where(eq(schema.projects.projectId, projectId)); if (result.meta.changes === 0) - return c.json({ error: 'Project not found' }, 404); + return fail(c, 404, ErrorCodes.PROJECT_NOT_FOUND); return c.json({ message: 'Project updated' }); }); -// DELETE /projects/:id — delete a project (QR codes cascade) +// DELETE /projects/:id — delete a project (QR codes and their access logs cascade) projectsApp.delete('/:id', async (c) => { - const user = c.get('user'); - if ( - !hasPermission(user?.permissions ?? 0, Permissions.TRACKING_LINK_DELETE) - ) { - return c.json({ error: 'TRACKING_LINK_DELETE permission required' }, 403); - } + const denied = denyUnlessPermitted( + c, + Permissions.TRACKING_LINK_DELETE, + 'TRACKING_LINK_DELETE', + ); + if (denied) return denied; + const projectId = c.req.param('id'); const db = getDb(c.env.DB); const result = await db .delete(schema.projects) .where(eq(schema.projects.projectId, projectId)); if (result.meta.changes === 0) - return c.json({ error: 'Project not found' }, 404); + return fail(c, 404, ErrorCodes.PROJECT_NOT_FOUND); return c.json({ message: 'Project deleted' }); }); // GET /projects/:id/qrcodes — paginated QR codes for a project projectsApp.get('/:id/qrcodes', async (c) => { + const denied = denyUnlessPermitted( + c, + Permissions.TRACKING_LINK_VIEW, + 'TRACKING_LINK_VIEW', + ); + if (denied) return denied; + const projectId = c.req.param('id'); const { limit, offset } = parsePagination(c.req.query()); const db = getDb(c.env.DB); @@ -215,6 +373,7 @@ projectsApp.get('/:id/qrcodes', async (c) => { .select() .from(schema.qrCodes) .where(eq(schema.qrCodes.projectId, projectId)) + .orderBy(desc(schema.qrCodes.createdAt), desc(schema.qrCodes.id)) .limit(limit) .offset(offset) .all(), @@ -228,22 +387,33 @@ projectsApp.get('/:id/qrcodes', async (c) => { // GET /projects/:id/access-logs — paginated, newest-first raw access log projectsApp.get('/:id/access-logs', async (c) => { + const denied = denyUnlessPermitted( + c, + Permissions.TRACKING_LINK_ANALYTICS, + 'TRACKING_LINK_ANALYTICS', + ); + if (denied) return denied; + const projectId = c.req.param('id'); const { limit, offset } = parsePagination(c.req.query()); const db = getDb(c.env.DB); const [logs, totalRows] = await Promise.all([ db .select({ + id: schema.accessLogs.id, qrId: schema.accessLogs.qrId, projectId: schema.accessLogs.projectId, accessedAt: schema.accessLogs.accessedAt, ipAddress: schema.accessLogs.ipAddress, + isBot: schema.accessLogs.isBot, location: schema.qrCodes.location, }) .from(schema.accessLogs) .leftJoin(schema.qrCodes, eq(schema.accessLogs.qrId, schema.qrCodes.id)) .where(eq(schema.accessLogs.projectId, projectId)) - .orderBy(desc(schema.accessLogs.accessedAt)) + // accessed_at is not unique — two scans can land in the same + // millisecond — so id breaks the tie and keeps paging stable. + .orderBy(desc(schema.accessLogs.accessedAt), desc(schema.accessLogs.id)) .limit(limit) .offset(offset) .all(), @@ -259,10 +429,18 @@ projectsApp.get('/:id/access-logs', async (c) => { }); // GET /projects/:id/access-logs/csv — full access log as a CSV download. -// Gated by CSV_EXPORT_ENABLED so it can be shipped disabled and turned on later. +// Gated by CSV_EXPORT_ENABLED so it can ship disabled and be turned on later. projectsApp.get('/:id/access-logs/csv', async (c) => { + // The README documented ANALYTICS as required here; the check was missing. + const denied = denyUnlessPermitted( + c, + Permissions.TRACKING_LINK_ANALYTICS, + 'TRACKING_LINK_ANALYTICS', + ); + if (denied) return denied; + if (c.env.CSV_EXPORT_ENABLED !== 'true') { - return c.json({ error: 'CSV export is not enabled' }, 403); + return fail(c, 403, ErrorCodes.CSV_EXPORT_DISABLED); } const projectId = c.req.param('id'); @@ -279,7 +457,7 @@ projectsApp.get('/:id/access-logs/csv', async (c) => { .from(schema.accessLogs) .leftJoin(schema.qrCodes, eq(schema.accessLogs.qrId, schema.qrCodes.id)) .where(eq(schema.accessLogs.projectId, projectId)) - .orderBy(desc(schema.accessLogs.accessedAt)) + .orderBy(desc(schema.accessLogs.accessedAt), desc(schema.accessLogs.id)) .all(); const header = ['日時', '名前', '媒体', '場所', 'User Agent', 'IPアドレス']; @@ -312,21 +490,24 @@ function toCsvRow(values: (string | null)[]): string { // POST /projects/:id/qrcodes — create a QR code for a project projectsApp.post('/:id/qrcodes', async (c) => { - const user = c.get('user'); - if (!hasPermission(user?.permissions ?? 0, Permissions.TRACKING_LINK_EDIT)) { - return c.json({ error: 'TRACKING_LINK_EDIT permission required' }, 403); - } + const denied = denyUnlessPermitted( + c, + Permissions.TRACKING_LINK_EDIT, + 'TRACKING_LINK_EDIT', + ); + if (denied) return denied; + const user = c.get('user'); const projectId = c.req.param('id'); const parsed = createQRCodeBodySchema.safeParse({ ...(await c.req.json().catch(() => ({}))), projectId, }); if (!parsed.success) { - return c.json( - { error: 'Invalid request body', details: parsed.error.flatten() }, - 400, - ); + return fail(c, 400, ErrorCodes.INVALID_BODY, { + fields: Object.keys(parsed.error.flatten().fieldErrors), + details: parsed.error.flatten(), + }); } const { name, medium, location } = parsed.data; @@ -340,50 +521,61 @@ projectsApp.post('/:id/qrcodes', async (c) => { projectId, name, medium, - location, + location: location ?? '', createdAt, creatorId: user?.sub ?? null, }); } catch (error) { if (isUniqueConstraintError(error)) { - return c.json( - { error: 'この媒体・場所の組み合わせは既に登録されています' }, - 409, - ); + // One QR code per named item within a project. `fields` lets the web app + // mark the offending input rather than making the user guess. + return fail(c, 409, ErrorCodes.DUPLICATE_NAME, { fields: ['name'] }); } throw error; } return c.json( - { id: qrId, projectId, name, medium, location, createdAt }, + { + id: qrId, + projectId, + name, + medium, + location: location ?? '', + createdAt, + }, 201, ); }); // PUT /projects/qrcodes/:id — update a QR code's name/medium/location projectsApp.put('/qrcodes/:id', async (c) => { - const user = c.get('user'); - if (!hasPermission(user?.permissions ?? 0, Permissions.TRACKING_LINK_EDIT)) { - return c.json({ error: 'TRACKING_LINK_EDIT permission required' }, 403); - } + const denied = denyUnlessPermitted( + c, + Permissions.TRACKING_LINK_EDIT, + 'TRACKING_LINK_EDIT', + ); + if (denied) return denied; const qrId = c.req.param('id'); const parsed = updateQRCodeBodySchema.safeParse( await c.req.json().catch(() => null), ); if (!parsed.success) { - return c.json( - { error: 'Invalid request body', details: parsed.error.flatten() }, - 400, - ); + return fail(c, 400, ErrorCodes.INVALID_BODY, { + fields: Object.keys(parsed.error.flatten().fieldErrors), + details: parsed.error.flatten(), + }); } + // `!== undefined` so that location: '' clears the location. Truthiness checks + // silently ignored it, which made an entered location impossible to remove. const values: Partial<typeof schema.qrCodes.$inferInsert> = {}; - if (parsed.data.name) values.name = parsed.data.name; - if (parsed.data.medium) values.medium = parsed.data.medium; - if (parsed.data.location) values.location = parsed.data.location; + if (parsed.data.name !== undefined) values.name = parsed.data.name; + if (parsed.data.medium !== undefined) values.medium = parsed.data.medium; + if (parsed.data.location !== undefined) + values.location = parsed.data.location; if (Object.keys(values).length === 0) { - return c.json({ error: 'No fields to update' }, 400); + return fail(c, 400, ErrorCodes.NO_FIELDS_TO_UPDATE); } const db = getDb(c.env.DB); @@ -393,20 +585,17 @@ projectsApp.put('/qrcodes/:id', async (c) => { .set(values) .where(eq(schema.qrCodes.id, qrId)); if (result.meta.changes === 0) - return c.json({ error: 'QR code not found' }, 404); + return fail(c, 404, ErrorCodes.QR_CODE_NOT_FOUND); } catch (error) { if (isUniqueConstraintError(error)) { - return c.json( - { error: 'この媒体・場所の組み合わせは既に登録されています' }, - 409, - ); + return fail(c, 409, ErrorCodes.DUPLICATE_NAME, { fields: ['name'] }); } throw error; } return c.json({ message: 'QR code updated', qrId }); }); -// DELETE /projects/qrcodes/:id — delete a QR code (and its access logs) +// DELETE /projects/qrcodes/:id — delete a QR code (its access logs cascade) // TRACKING_LINK_DELETE can delete any QR code; TRACKING_LINK_EDIT only its own. projectsApp.delete('/qrcodes/:id', async (c) => { const user = c.get('user'); @@ -421,12 +610,10 @@ projectsApp.delete('/qrcodes/:id', async (c) => { ); if (!canDeleteAny && !canEdit) { - return c.json( - { - error: 'TRACKING_LINK_EDIT or TRACKING_LINK_DELETE permission required', - }, - 403, - ); + return fail(c, 403, ErrorCodes.PERMISSION_REQUIRED, { + message: 'TRACKING_LINK_EDIT or TRACKING_LINK_DELETE permission required', + meta: { required: 'TRACKING_LINK_EDIT|TRACKING_LINK_DELETE' }, + }); } const qrId = c.req.param('id'); @@ -436,12 +623,13 @@ projectsApp.delete('/qrcodes/:id', async (c) => { .from(schema.qrCodes) .where(eq(schema.qrCodes.id, qrId)) .get(); - if (!qr) return c.json({ error: 'QR code not found' }, 404); + if (!qr) return fail(c, 404, ErrorCodes.QR_CODE_NOT_FOUND); if (!canDeleteAny && qr.creatorId !== user?.sub) { - return c.json({ error: 'You can only delete QR codes you created' }, 403); + return fail(c, 403, ErrorCodes.NOT_OWNER); } - await db.delete(schema.accessLogs).where(eq(schema.accessLogs.qrId, qrId)); + // Access logs go with it via AccessLogs.qr_id ON DELETE CASCADE (added in + // migration 0002), so there is no explicit delete here — one less D1 write. await db.delete(schema.qrCodes).where(eq(schema.qrCodes.id, qrId)); return c.json({ message: 'QR code deleted', qrId }); }); From acf70c717c563ed1e1275d4f8ce5d9ec321af587 Mon Sep 17 00:00:00 2001 From: kamijo-haruto <kamijoharuto@gmail.com> Date: Sat, 25 Jul 2026 17:22:11 +0900 Subject: [PATCH 07/11] =?UTF-8?q?[feat]=20CSV=E5=87=BA=E5=8A=9B=E3=82=92?= =?UTF-8?q?=E3=82=B9=E3=83=88=E3=83=AA=E3=83=BC=E3=83=9F=E3=83=B3=E3=82=B0?= =?UTF-8?q?=E5=8C=96=E3=81=97=E4=BB=B6=E6=95=B0=E4=B8=8A=E9=99=90=E3=81=A8?= =?UTF-8?q?=E6=9C=9F=E9=96=93=E6=8C=87=E5=AE=9A=E3=83=BB=E6=A8=A9=E9=99=90?= =?UTF-8?q?=E3=83=81=E3=82=A7=E3=83=83=E3=82=AF=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 従来は全件を .all() してメモリ上で1本の巨大文字列を組み立てていた。10万行 だと Workers Free の CPU 10ms 上限(エラー1102)を確実に超え、128MBの isolate上限にも近づくため、混雑したプロジェクトで1回クリックするだけで Workerが落ちうる状態だった。 - ReadableStream + (accessed_at, id) のキーセットページングに変更。OFFSET ページングは大きなテーブルで二次的に劣化する(SQLiteがスキップ分を毎回 走査して捨てる)が、カーソルなら各ページが idx_access_logs_project_accessed_at 上の有界な範囲走査になる。 2,000行/ページ × 25ページ = 50,000行で、サブリクエスト50件上限の半分。 - 件数上限を超えたら 500 ではなく 413 + 実件数と上限を返し、期間を絞れば よいことが分かるようにした。上限は CSV_MAX_ROWS でデプロイなしに変更可能。 - ?from / ?to の期間指定を追加。日付のみ(YYYY-MM-DD)ならUTCの1日全体に広げる。 - ボット列を追加。is_bot を出すことで、リンクプレビュークローラー由来の アクセスをExcel側で除外して集計できる。 - ファイル名を UUID からプロジェクト名ベースに変更(RFC 5987 のUTF-8形式と ASCIIフォールバックの両方を付与)。従来は全ダウンロードが access-logs-3f2b….csv だった。 - CSV_EXPORT_ENABLED の判定を真偽値でも受けるようにした。wrangler.jsonc の vars はJSONなので `true` と引用符なしで書くのはありがちな間違いで、 厳密な === 'true' だと理由の分からないまま無効のままになる。 検証: 5,000行を「5種類の日時のみ」でシードし、2,000行のページ境界が同一 日時のグループ内に落ちる条件で確認した(id のタイブレーカが無いと壊れる ケース)。結果は 5,000行・重複0・欠落0、BOMとCRLF維持、ボット列500行、 新しい順。期間指定は 1日=1000行 / 2日=2000行 と一致。上限超過は 413 + {total:5000,max:1000} を返し、期間を1日に絞ると200で1000行取得できた。 あわせて、ローカルD1へのシードで実測した2つの落とし穴を docs に記録した (wrangler dev 起動中は書き込めない / 1ファイルに約4MBの文を詰めると workerd が HashIndex エラーで落ちる)。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- docs/load-testing.md | 9 ++ packages/api/src/auth/types.ts | 14 +- packages/api/src/routes/projects.ts | 243 ++++++++++++++++++++++++---- packages/api/wrangler.jsonc | 5 + 4 files changed, 239 insertions(+), 32 deletions(-) diff --git a/docs/load-testing.md b/docs/load-testing.md index 1171ec5..1a4524d 100644 --- a/docs/load-testing.md +++ b/docs/load-testing.md @@ -112,6 +112,15 @@ loadtest:prod:ramp k6 run loadtest/k6/scan-ramping-prod.js - **`BEGIN` / `COMMIT` は D1 API では使えない** → 明示トランザクションを出力しない。速度を買うのは「文を大きくすること」であってトランザクションではない。 - **Free は 1 DB 500 MB** → 50 万行 ≈ 100 MB + インデックス。収まるが同じオーダーなので、ローカルで 500 万行入れて本番で驚かないこと。 +**ローカル D1 に流すときの実測済みの落とし穴**(どちらも `run.mjs` が吸収する): + +- **`wrangler dev` を止めてからシードする。** 起動中にローカル D1 へ書き込むと workerd が + `kj/table.c++:57: HashIndex detected hash table inconsistency` を吐いて失敗する。 +- **1 ファイルあたりの文数を抑える。** 500 行/文 × 92 文(約 4 MB)を 1 ファイルで流すと、 + サーバーを止めていても同じ workerd エラーで失敗した。**10〜20 文ずつのファイルに分割して + 順に流す**。`run.mjs` は分割と、失敗時にどのファイルまで進んだかの出力(再開可能にするため) + を担当する。 + ### 4.2 データを一様乱数にしない 一様乱数データはインデックスの選択性を過大評価させ、p95 が実際より良く見える。以下の偏りを付ける。 diff --git a/packages/api/src/auth/types.ts b/packages/api/src/auth/types.ts index 1ed102b..4e71b89 100644 --- a/packages/api/src/auth/types.ts +++ b/packages/api/src/auth/types.ts @@ -6,8 +6,18 @@ export interface Bindings { ADMIN_PASSWORD: string; /** Comma-separated list of origins allowed to call this API from a browser. */ ALLOWED_ORIGINS?: string; - /** Set to "true" to enable the CSV export endpoint. Defaults to disabled. */ - CSV_EXPORT_ENABLED?: string; + /** + * Set to "true" to enable the CSV export endpoint. Defaults to disabled. + * A real JSON boolean is accepted too — `vars` in wrangler.jsonc is JSON, so + * `true` without quotes is an easy slip and should not silently do nothing. + */ + CSV_EXPORT_ENABLED?: string | boolean; + /** + * Maximum rows one CSV export may contain before it returns 413 and asks the + * caller to narrow the date range. Defaults to 50,000, which costs 25 of the + * Workers Free 50-subrequest budget at the streaming page size. + */ + CSV_MAX_ROWS?: string | number; /** * How the scan endpoint writes its access log. Defaults to "async", which * takes the D1 write off the response path via waitUntil. Set to "sync" to diff --git a/packages/api/src/routes/projects.ts b/packages/api/src/routes/projects.ts index a4f7fa8..2c05122 100644 --- a/packages/api/src/routes/projects.ts +++ b/packages/api/src/routes/projects.ts @@ -1,4 +1,4 @@ -import { count, desc, eq, inArray } from 'drizzle-orm'; +import { and, count, desc, eq, gte, inArray, lt, lte, or } from 'drizzle-orm'; import { type Context, Hono } from 'hono'; import * as z from 'zod'; import type { HonoEnv } from '../auth'; @@ -428,7 +428,46 @@ projectsApp.get('/:id/access-logs', async (c) => { }); }); -// GET /projects/:id/access-logs/csv — full access log as a CSV download. +// Default ceiling on one export. The old implementation loaded every row and +// built a single string in memory: at 100k rows that blows the Workers Free 10ms +// CPU budget (error 1102) and approaches the 128MB isolate limit, so one click on +// a busy project could take the Worker down. +// +// Overridable via the CSV_MAX_ROWS var so the limit can be raised or lowered from +// the dashboard without a deploy. If you raise it, raise CSV_PAGE_SIZE too — the +// two are tied to the subrequest budget below. +const DEFAULT_MAX_CSV_ROWS = 50_000; +// Rows per D1 query while streaming. One query is one subrequest, so the cap +// above costs at most 25 — half of the Workers Free ceiling of 50, leaving room +// for the count and project lookups. Raise both together or not at all. +const CSV_PAGE_SIZE = 2_000; + +/** + * Reads a boolean-ish env var. + * + * Accepts a real boolean as well as the string form, because `vars` in + * wrangler.jsonc is JSON: writing `"CSV_EXPORT_ENABLED": true` instead of + * `"true"` is an easy mistake, and a strict `=== 'true'` would then silently + * keep the feature off with no hint as to why. Anything unrecognised is off. + */ +function isFlagEnabled(value: unknown): boolean { + return value === true || value === 'true' || value === '1'; +} + +const csvQuerySchema = z.object({ + // Compared as text against the stored ISO-8601 UTC timestamps, which sort + // chronologically. A bare date is widened to cover the whole UTC day. + from: z.string().min(4).max(40).optional(), + to: z.string().min(4).max(40).optional(), +}); + +/** Widens a bare `YYYY-MM-DD` to the start or end of that UTC day. */ +function normalizeBound(value: string, edge: 'start' | 'end'): string { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return value; + return edge === 'start' ? `${value}T00:00:00.000Z` : `${value}T23:59:59.999Z`; +} + +// GET /projects/:id/access-logs/csv — access log as a streamed CSV download. // Gated by CSV_EXPORT_ENABLED so it can ship disabled and be turned on later. projectsApp.get('/:id/access-logs/csv', async (c) => { // The README documented ANALYTICS as required here; the check was missing. @@ -439,44 +478,188 @@ projectsApp.get('/:id/access-logs/csv', async (c) => { ); if (denied) return denied; - if (c.env.CSV_EXPORT_ENABLED !== 'true') { + if (!isFlagEnabled(c.env.CSV_EXPORT_ENABLED)) { return fail(c, 403, ErrorCodes.CSV_EXPORT_DISABLED); } + const parsedQuery = csvQuerySchema.safeParse(c.req.query()); + if (!parsedQuery.success) { + return fail(c, 400, ErrorCodes.INVALID_BODY, { + details: parsedQuery.error.flatten(), + }); + } + const from = parsedQuery.data.from + ? normalizeBound(parsedQuery.data.from, 'start') + : undefined; + const to = parsedQuery.data.to + ? normalizeBound(parsedQuery.data.to, 'end') + : undefined; + const projectId = c.req.param('id'); const db = getDb(c.env.DB); - const logs = await db - .select({ - accessedAt: schema.accessLogs.accessedAt, - name: schema.qrCodes.name, - medium: schema.qrCodes.medium, - location: schema.qrCodes.location, - userAgent: schema.accessLogs.userAgent, - ipAddress: schema.accessLogs.ipAddress, - }) - .from(schema.accessLogs) - .leftJoin(schema.qrCodes, eq(schema.accessLogs.qrId, schema.qrCodes.id)) - .where(eq(schema.accessLogs.projectId, projectId)) - .orderBy(desc(schema.accessLogs.accessedAt), desc(schema.accessLogs.id)) - .all(); - - const header = ['日時', '名前', '媒体', '場所', 'User Agent', 'IPアドレス']; - const rows = logs.map((log) => [ - log.accessedAt, - log.name ?? '', - log.medium ?? '', - log.location ?? '', - log.userAgent ?? '', - log.ipAddress ?? '', + + const rangeFilter = and( + eq(schema.accessLogs.projectId, projectId), + ...(from ? [gte(schema.accessLogs.accessedAt, from)] : []), + ...(to ? [lte(schema.accessLogs.accessedAt, to)] : []), + ); + + const [project, countRows] = await Promise.all([ + db + .select({ name: schema.projects.name }) + .from(schema.projects) + .where(eq(schema.projects.projectId, projectId)) + .get(), + db.select({ total: count() }).from(schema.accessLogs).where(rangeFilter), ]); - const csv = [header, ...rows].map(toCsvRow).join('\r\n'); + if (!project) return fail(c, 404, ErrorCodes.PROJECT_NOT_FOUND); + + const maxRows = + Number(c.env.CSV_MAX_ROWS ?? DEFAULT_MAX_CSV_ROWS) || DEFAULT_MAX_CSV_ROWS; + const total = countRows[0]?.total ?? 0; + if (total > maxRows) { + // 413 with the real numbers, so the client can say "narrow the range" and + // show how far over the limit the request was — rather than a bare 500 once + // the Worker ran out of CPU. + return fail(c, 413, ErrorCodes.TOO_MANY_ROWS, { + meta: { total, max: maxRows }, + }); + } + + const header = [ + '日時', + '名前', + '媒体', + '場所', + 'ボット', + 'User Agent', + 'IPアドレス', + ]; + const encoder = new TextEncoder(); + // Keyset cursor on (accessed_at, id). OFFSET paging degrades quadratically + // over a large table because SQLite has to walk and discard every skipped row; + // a cursor turns each page into a bounded index range scan on + // idx_access_logs_project_accessed_at. + let cursor: { accessedAt: string; id: number } | null = null; + let done = false; + + const stream = new ReadableStream<Uint8Array>({ + start(controller) { + // UTF-8 BOM so Excel on Windows detects the encoding, and CRLF per + // RFC 4180 — both preserved from the original implementation. + controller.enqueue(encoder.encode(`${toCsvRow(header)}\r\n`)); + }, + async pull(controller) { + if (done) { + controller.close(); + return; + } + const rows = await db + .select({ + id: schema.accessLogs.id, + accessedAt: schema.accessLogs.accessedAt, + isBot: schema.accessLogs.isBot, + userAgent: schema.accessLogs.userAgent, + ipAddress: schema.accessLogs.ipAddress, + name: schema.qrCodes.name, + medium: schema.qrCodes.medium, + location: schema.qrCodes.location, + }) + .from(schema.accessLogs) + .leftJoin(schema.qrCodes, eq(schema.accessLogs.qrId, schema.qrCodes.id)) + .where( + cursor + ? and( + rangeFilter, + or( + lt(schema.accessLogs.accessedAt, cursor.accessedAt), + and( + eq(schema.accessLogs.accessedAt, cursor.accessedAt), + lt(schema.accessLogs.id, cursor.id), + ), + ), + ) + : rangeFilter, + ) + .orderBy(desc(schema.accessLogs.accessedAt), desc(schema.accessLogs.id)) + .limit(CSV_PAGE_SIZE) + .all(); + + if (rows.length === 0) { + controller.close(); + return; + } + + controller.enqueue( + encoder.encode( + `${rows + .map((log) => + toCsvRow([ + log.accessedAt, + log.name ?? '', + log.medium ?? '', + log.location ?? '', + log.isBot ? '1' : '0', + log.userAgent ?? '', + log.ipAddress ?? '', + ]), + ) + .join('\r\n')}\r\n`, + ), + ); + + const last = rows[rows.length - 1]; + cursor = { accessedAt: last.accessedAt, id: last.id }; + if (rows.length < CSV_PAGE_SIZE) done = true; + }, + }); - return c.body(`${csv}`, 200, { - 'Content-Type': 'text/csv; charset=utf-8', - 'Content-Disposition': `attachment; filename="access-logs-${projectId}.csv"`, + return new Response(stream, { + status: 200, + headers: { + 'Content-Type': 'text/csv; charset=utf-8', + 'Content-Disposition': contentDisposition( + csvFilename(project.name, from, to), + ), + 'Cache-Control': 'no-store', + }, }); }); +/** `アクセスログ_造形大祭2026_2026-07-25.csv`, sanitised for a filesystem. */ +function csvFilename(projectName: string, from?: string, to?: string): string { + const safeName = + projectName + // Characters no filesystem accepts. Japanese is deliberately preserved — + // the whole point is that the file is identifiable in a downloads folder. + .replace(/[\\/:*?"<>|]/g, '') + // Whitespace, including anything exotic, collapses to one underscore. + .replace(/\s+/g, '_') + .trim() + .slice(0, 60) || 'project'; + const range = [from?.slice(0, 10), to?.slice(0, 10)] + .filter(Boolean) + .join('_'); + return `アクセスログ_${safeName}${range ? `_${range}` : ''}.csv`; +} + +/** + * Content-Disposition with both an ASCII fallback and an RFC 5987 UTF-8 form. + * The old header interpolated a raw UUID, so every download was named + * `access-logs-3f2b….csv`. + * + * The fallback is built by iterating rather than with a regex range so there are + * no hex escapes for a formatter to mangle into literal control bytes: anything + * outside printable ASCII, plus the quote that would end the header value, + * becomes an underscore. + */ +function contentDisposition(filename: string): string { + const ascii = Array.from(filename) + .map((ch) => (ch >= ' ' && ch <= '~' && ch !== '"' ? ch : '_')) + .join(''); + return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(filename)}`; +} + // Escapes a row of values per RFC 4180: quote fields containing a comma, // quote, or newline, doubling any embedded quotes. function toCsvRow(values: (string | null)[]): string { diff --git a/packages/api/wrangler.jsonc b/packages/api/wrangler.jsonc index fb8331c..cd3f6d6 100644 --- a/packages/api/wrangler.jsonc +++ b/packages/api/wrangler.jsonc @@ -25,7 +25,12 @@ // Use "*" for local development; restrict this in production. "ALLOWED_ORIGINS": "http://localhost:5173,http://127.0.0.1:5173,https://trackinglink-web.nutfes-nutmeg9488.workers.dev", // Set to "true" to turn on the CSV export endpoint. Stays "false" until told to activate. + // A real JSON boolean (true) is accepted too, so an unquoted value is not a silent no-op. "CSV_EXPORT_ENABLED": "false", + // Rows one CSV export may contain before it returns 413 and asks for a narrower + // date range. Defaults to 50000, which costs 25 of the Workers Free + // 50-subrequest budget while streaming. Raising it needs CSV_PAGE_SIZE raised too. + "CSV_MAX_ROWS": "50000", // "async" (default) takes the access-log write off the redirect's response // path. Flip to "sync" from the dashboard — no deploy needed — if writes // ever look like they are going missing during an event. From a03af0a92841642986c80854aa91e46e3a5d4ebe Mon Sep 17 00:00:00 2001 From: kamijo-haruto <kamijoharuto@gmail.com> Date: Sat, 25 Jul 2026 17:25:34 +0900 Subject: [PATCH 08/11] =?UTF-8?q?[feat]=20=E3=83=AD=E3=82=B0=E3=82=A4?= =?UTF-8?q?=E3=83=B3=E3=81=AB=E3=83=AC=E3=83=BC=E3=83=88=E5=88=B6=E9=99=90?= =?UTF-8?q?=E3=81=A8=E3=83=98=E3=83=AB=E3=82=B9=E3=83=81=E3=82=A7=E3=83=83?= =?UTF-8?q?=E3=82=AF=E3=83=BB=E6=A7=8B=E9=80=A0=E5=8C=96=E3=83=AD=E3=82=B0?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0=E3=81=97CORS=E3=82=92fail-closed?= =?UTF-8?q?=E3=81=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ログインのレート制限 共有パスワード1つに対してレート制限が一切なく、インターネットから自由に 総当たりできる状態だった。*.workers.dev には WAF のレート制限ルールが 適用されないため、Workers のレート制限バインディングを使う(追加サービス 不要・無料・wrangler dev でも動く)。キーは CF-Connecting-IP なので、 攻撃者のIPが制限されるだけで正規の管理者は影響を受けない。 バインディングが無い場合は意図的に fail-open にし、代わりに login_rate_limiter_missing を構造化ログに出す。バインディング欠落で全ログイン を拒否するのは自傷的な障害で、総当たりのリスクより悪い。 ## ヘルスチェック /healthz(GIT_SHAを返す)と /readyz(SELECT 1 でD1到達性を確認、失敗で503)。 GET / はQRリダイレクトのもので ?id= が無いと404を返すため、監視先に 使うと永遠にアラートが鳴る。/readyz を1分間隔で叩いても約1,440読み取り/日で 500万/日のクォータに対して無視できる。 ## CORS を fail-closed に allowed が空のとき '*' にフォールバックしていた。つまり ALLOWED_ORIGINS の 設定漏れやタイポが、Authorization を許可したワイルドカードCORS——最も 許容的な設定——に静かに変わる。最もありがちなミスで最悪の設定になるので、 空の許可リストはブラウザからの呼び出しを拒否するようにした。QRリダイレクト やcurl・監視はCORSの対象外なので影響しない。 ## その他 - 未処理エラーのログを1行JSONにし、method/path/cf-ray で検索できるようにした。 - セッションTTLを24時間から8時間(1イベント日)に短縮。全権限のBearer トークンが localStorage にあるため、寿命を縮めるのが最も安価な影響範囲の 縮小になる。24時間だと初日に漏れたトークンが翌日も使えた。 検証: /healthz と /readyz が200、GET / は404。誤パスワード14回で 401×10 → 429×4 と正確に10回で切り替わり RATE_LIMITED を返す。JWTの iat→exp が8時間。許可オリジンには ACAO が付き、未許可オリジンには付かない。 プリフライトは204で必要なヘッダが揃う。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- packages/api/src/auth/local.ts | 5 ++++- packages/api/src/auth/types.ts | 16 +++++++++++++++ packages/api/src/index.ts | 36 +++++++++++++++++++++++++++++++-- packages/api/src/routes/auth.ts | 21 +++++++++++++++++++ packages/api/wrangler.jsonc | 14 +++++++++++++ 5 files changed, 89 insertions(+), 3 deletions(-) diff --git a/packages/api/src/auth/local.ts b/packages/api/src/auth/local.ts index 1a53cd4..f3ae5e0 100644 --- a/packages/api/src/auth/local.ts +++ b/packages/api/src/auth/local.ts @@ -1,7 +1,10 @@ import { SignJWT, jwtVerify } from 'jose'; import type { AuthUser, Verifier } from './types'; -const SESSION_TTL_SECONDS = 60 * 60 * 24; // 24h +// One event day. The token is an all-permissions bearer credential living in +// localStorage, so a shorter life is the cheapest reduction of its blast radius; +// 24h meant a token stolen on day one still worked on day two. +const SESSION_TTL_SECONDS = 60 * 60 * 8; // 8h /** Issues a session token for the built-in single-admin-password login (see `../routes/auth.ts`). */ export async function signLocalSession( diff --git a/packages/api/src/auth/types.ts b/packages/api/src/auth/types.ts index 4e71b89..73b6b00 100644 --- a/packages/api/src/auth/types.ts +++ b/packages/api/src/auth/types.ts @@ -26,6 +26,22 @@ export interface Bindings { * dropped during an event. */ LOG_WRITE_MODE?: 'async' | 'sync'; + /** + * Rate limiter guarding POST /auth/login. + * + * Declared by hand because `unsafe` bindings are excluded from + * `wrangler types` generation. Optional so that a deployment without the + * binding still serves logins rather than locking everyone out — the handler + * logs loudly when it is missing instead. + */ + LOGIN_LIMITER?: RateLimiter; + /** Build identifier surfaced by /healthz, for confirming what is deployed. */ + GIT_SHA?: string; +} + +/** The shape of a Workers Rate Limiting binding (`type: "ratelimit"`). */ +export interface RateLimiter { + limit(options: { key: string }): Promise<{ success: boolean }>; } export interface AuthUser { diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index adf7086..ddd41b3 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -1,6 +1,7 @@ import { Hono } from 'hono'; import { cors } from 'hono/cors'; import { type HonoEnv, authMiddleware } from './auth'; +import { logFailure } from './log'; import authApp from './routes/auth'; import forwardApp from './routes/forward'; import projectsApp from './routes/projects'; @@ -13,14 +14,39 @@ app.use('*', async (c, next) => { .map((origin) => origin.trim()) .filter(Boolean); + // Fail closed. This used to fall back to '*' when the list was empty, which + // meant a missing or misspelled ALLOWED_ORIGINS silently became wildcard CORS + // with Authorization allowed — the most permissive possible setting reached by + // the most ordinary possible mistake. An empty allow-list now blocks browser + // callers, so a config error shows up immediately instead of quietly widening + // access. Non-browser callers (the QR redirect, curl, monitoring) are + // unaffected: CORS only governs cross-origin browser requests. return cors({ - origin: allowed.length > 0 ? allowed : '*', + origin: allowed, allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], allowHeaders: ['Content-Type', 'Authorization'], maxAge: 86400, })(c, next); }); +// Liveness. Deliberately not `GET /`, which belongs to the QR redirect and +// answers 404 without an `?id=` — pointing a monitor at it would alert forever. +app.get('/healthz', (c) => + c.json({ ok: true, version: c.env.GIT_SHA ?? 'dev' }), +); + +// Readiness: liveness plus "can we actually reach D1". Costs one row read, so +// it is safe to poll every minute (~1,440 reads/day against a 5M/day quota). +app.get('/readyz', async (c) => { + try { + await c.env.DB.prepare('SELECT 1').first(); + return c.json({ ok: true }); + } catch (error) { + logFailure('readyz_db_unreachable', error); + return c.json({ ok: false }, 503); + } +}); + // QR scan → access log → redirect. app.route('/', forwardApp); @@ -33,7 +59,13 @@ app.route('/projects', projectsApp); app.notFound((c) => c.text('404 Not Found', 404)); app.onError((err, c) => { - console.error('Unhandled error:', err); + // Structured so Workers Logs indexes the fields and an unhandled error is + // searchable by path/method/ray rather than being one line of prose. + logFailure('unhandled_error', err, { + method: c.req.method, + path: new URL(c.req.url).pathname, + ray: c.req.header('cf-ray') ?? null, + }); return c.text('Internal Server Error', 500); }); diff --git a/packages/api/src/routes/auth.ts b/packages/api/src/routes/auth.ts index 662de33..b4548e7 100644 --- a/packages/api/src/routes/auth.ts +++ b/packages/api/src/routes/auth.ts @@ -2,6 +2,7 @@ import { Hono } from 'hono'; import * as z from 'zod'; import { type HonoEnv, authMiddleware, signLocalSession } from '../auth'; import { ErrorCodes, fail } from '../errors'; +import { logEvent } from '../log'; import { ALL_PERMISSIONS } from '../permissions'; const loginBodySchema = z.object({ @@ -22,6 +23,26 @@ const authApp = new Hono<HonoEnv>(); // since the rest of the API only cares that it ends up with a Bearer token // that resolves to `{ sub, permissions }`. authApp.post('/login', async (c) => { + // A single shared password with no rate limit is freely brute-forceable from + // the internet. WAF rate-limiting rules do not apply to *.workers.dev, so this + // uses the Workers rate limiter binding: no extra service, no cost, and it + // works in `wrangler dev`. Note it is per-colo rather than global, which is + // ample for an admin panel but not a defence against a distributed attacker. + const limiter = c.env.LOGIN_LIMITER; + if (limiter) { + const clientIp = c.req.header('CF-Connecting-IP') ?? 'unknown'; + const { success } = await limiter.limit({ key: `login:${clientIp}` }); + if (!success) { + logEvent('login_rate_limited', { ip: clientIp }); + return fail(c, 429, ErrorCodes.RATE_LIMITED); + } + } else { + // Deliberately fail *open*: refusing every login because a binding is + // missing would be a self-inflicted outage, which is worse than the + // brute-force risk. Loud so it cannot go unnoticed. + logEvent('login_rate_limiter_missing'); + } + const body = await c.req.json().catch(() => null); const parsed = loginBodySchema.safeParse(body); if (!parsed.success) { diff --git a/packages/api/wrangler.jsonc b/packages/api/wrangler.jsonc index cd3f6d6..5e4e5ad 100644 --- a/packages/api/wrangler.jsonc +++ b/packages/api/wrangler.jsonc @@ -13,6 +13,20 @@ // Custom domain / route the redirect endpoint should live on. // Leave this out entirely to use the workers.dev subdomain instead. // "routes": [{ "pattern": "links.example.com", "custom_domain": true }], + // Rate limiter for POST /auth/login. `unsafe` bindings are excluded from + // `wrangler types`, so the interface is declared by hand in src/auth/types.ts. + // The limiter is per-colo, not global — plenty for an admin panel. + "unsafe": { + "bindings": [ + { + "name": "LOGIN_LIMITER", + "type": "ratelimit", + "namespace_id": "1001", + // `period` only accepts 10 or 60. + "simple": { "limit": 10, "period": 60 } + } + ] + }, "d1_databases": [ { "binding": "DB", From b601b81f5eb7a367dd452f743eb2e712109567c6 Mon Sep 17 00:00:00 2001 From: kamijo-haruto <kamijoharuto@gmail.com> Date: Sun, 26 Jul 2026 19:16:43 +0900 Subject: [PATCH 09/11] =?UTF-8?q?[feat]=20D1=E9=9A=9C=E5=AE=B3=E6=99=82?= =?UTF-8?q?=E3=81=AB=E3=82=AD=E3=83=BC=E3=83=AF=E3=83=BC=E3=83=89=E5=8D=98?= =?UTF-8?q?=E4=BD=8D=E3=81=AE=E3=83=95=E3=82=A9=E3=83=BC=E3=83=AB=E3=83=90?= =?UTF-8?q?=E3=83=83=E3=82=AF=E5=85=88=E3=81=B8=E3=83=AA=E3=83=80=E3=82=A4?= =?UTF-8?q?=E3=83=AC=E3=82=AF=E3=83=88=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit スキャン経路はリクエストごとにD1から遷移先を読む。その読み取りが例外を投げると onError に落ち、**全来場者に素の 500 が返る**——貼り出したポスターが一斉に無反応に なる。最も起こりやすいのはD1の障害そのものより**クォータ枯渇**で、Freeプランの 読み取り500万行/日を超えると Worker は生きたままD1呼び出しだけが失敗する。 ## なぜ遷移先URLではなくキーワードなのか QRから復元できるURLは、QRに入っていなければならない。暗号技術でこれは回避できない ——ハッシュは一方向なのでWorkerがどんな鍵を持っていても復元できず、認証付き暗号は 平文より長い暗号文になる。実測すると 53x53 → 69x69(署名)/ 73x73(暗号化)まで シンボルが育った。キーワードなら +4モジュール(57x57)で収まる。 副次的に、**オープンリダイレクトが構造的に不可能**になる。`&p=` は運用者が設定した 一覧から選ぶだけなので攻撃者は任意のURLを指定できず、署名も不要で、誤り訂正Hも 下げずに済む。 ## 変更 - `src/fallback.ts`: キーワード → 設定 → 静的FALLBACK_URL の解決を純粋関数に。 設定はオブジェクトでもJSON文字列でも受ける(`vars` はJSONなので引用符の有無で 静かに無効化されるのを防ぐ、CSV_EXPORT_ENABLED と同じ理由)。**解決したURLは http(s) 検証を通す**——自分の設定由来でも `javascript:` を貼るミスがオープン リダイレクトになるため、parse時とresolve時の二層で弾く。 - `src/url.ts`: その protocol 判定を projects.ts と共有。 - `forward.ts`: D1参照を try/catch で囲み、**「見つからない」と「繋がらない」を 例外の有無で区別**する。`.get()` は not-found では throw せず undefined を返すので、 404 の意味は一切変わらない。フォールバック時は 302 + `Cache-Control: no-store` (キャッシュされると復旧後もフォールバック先に飛び続けるため必須)、解決できなければ 素の500ではなく **503 + 日本語の分かるページ**。 - **正常時の自己診断**: キーワードが設定に無ければ `scan_fallback_not_configured` を isolate あたり1回だけ出す。障害が起きて初めて設定漏れに気づく事態を防ぐ。全スキャンで 出すとログが溢れるのでモジュールスコープのSetで抑制し、`reason` で「設定漏れ」と 「QRがキーワードを持たない」を区別する。 - `Projects.fallback_key` 列(migration 0003、ALTER一行。0002のようなテーブル再作成なし)。 zod は ASCII 限定——日本語キーワードはURLエンコードで1文字9文字になり 61x61 に育つ。 更新は `!== undefined` 判定で空文字によるクリアを可能にする。 - `SIMULATE_DB_FAILURE`: ローカル検証専用。ローカルD1は組み込みで「落とす」ことが できず、これなしでは**今回の主目的である障害経路を手で確認できない**。使用のたびに ログを出すので本番に残っても気づける。 - packages/api に vitest を追加(11本)。この経路はD1が壊れている時しか動かないので、 通常利用では一度も実行されない——だからこそ単体テストの価値がある。 ## 受け入れる代償 **D1障害中はリダイレクトは動くがスキャンは計上されない。** `AccessLogs.project_id` は NOT NULL で `qr_id` は QRCodes への外部キーがあり、DBが読めない状況では有効な行を 作れない。`scan_unlogged_due_to_db_failure` で件数だけは分かるようにした。 ## 検証(SIMULATE_DB_FAILURE で実測) - **正常時は設定値ではなくDBの遷移先に飛ぶ**(最重要の非退行)。存在しないIDは404のまま。 - 障害時: 設定ありのキーワード → その遷移先 / 設定なし → 静的FALLBACK_URL / `&p=` なしの旧QR → 静的FALLBACK_URL / 何も設定なし → 503+日本語ページ。 すべて `Cache-Control: no-store` 付き。 - 自己診断: 設定漏れのキーで15リクエスト → ログは**キーごとに1行のみの計2行**、 設定済みキーでは0行。 注意: 検証中、TaskStop が wrangler の親プロセスを止めても **workerd の子プロセスが 残ってポートを保持し続ける**ため、古いサーバーに対してテストしてしまう事故があった。 ポート単位で kill してから起動し直す必要がある。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .github/workflows/ci.yml | 1 + package.json | 5 +- .../0003_add_project_fallback_key.sql | 30 + packages/api/package.json | 9 +- packages/api/schema.sql | 13 + packages/api/src/auth/types.ts | 21 + packages/api/src/db/schema.ts | 8 + packages/api/src/fallback.test.ts | 111 ++++ packages/api/src/fallback.ts | 105 ++++ packages/api/src/routes/forward.ts | 181 +++++- packages/api/src/routes/projects.ts | 37 +- packages/api/src/url.ts | 22 + packages/api/vitest.config.ts | 11 + packages/api/wrangler.jsonc | 16 +- pnpm-lock.yaml | 564 ++++++++++++++++++ 15 files changed, 1106 insertions(+), 28 deletions(-) create mode 100644 packages/api/migrations/0003_add_project_fallback_key.sql create mode 100644 packages/api/src/fallback.test.ts create mode 100644 packages/api/src/fallback.ts create mode 100644 packages/api/src/url.ts create mode 100644 packages/api/vitest.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4b60704..4548e1e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,4 +18,5 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm lint - run: pnpm typecheck + - run: pnpm test - run: pnpm build diff --git a/package.json b/package.json index f93294f..d1e91f0 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,9 @@ "description": "Self-hosted QR code link tracker on Cloudflare Workers + D1.", "license": "MIT", "packageManager": "pnpm@9.15.0", - "engines": { "node": ">=20" }, + "engines": { + "node": ">=20" + }, "scripts": { "dev": "concurrently --names \"api,web\" --prefix \"[{name}]\" \"pnpm --filter @tracking-link/api dev\" \"pnpm --filter @tracking-link/web dev\"", "dev:api": "pnpm --filter @tracking-link/api dev", @@ -14,6 +16,7 @@ "deploy:api": "pnpm --filter @tracking-link/api run deploy", "deploy:web": "pnpm --filter @tracking-link/web run deploy", "typecheck": "pnpm -r run typecheck", + "test": "pnpm -r run test", "lint": "biome check .", "lint:fix": "biome check --write ." }, diff --git a/packages/api/migrations/0003_add_project_fallback_key.sql b/packages/api/migrations/0003_add_project_fallback_key.sql new file mode 100644 index 0000000..ba41044 --- /dev/null +++ b/packages/api/migrations/0003_add_project_fallback_key.sql @@ -0,0 +1,30 @@ +-- Adds Projects.fallback_key: a short keyword identifying where a project's QR +-- codes should send people when D1 cannot be reached. +-- +-- Background: the scan endpoint reads the destination URL out of D1 on every +-- request. If D1 is unreachable — most plausibly because a Free-plan daily quota +-- ran out, which leaves the Worker running while every D1 call fails — the +-- redirect used to answer with a bare 500. Printed posters would all go dead at +-- once. QR codes now carry `&p=<fallback_key>`, and the Worker maps that keyword +-- to a URL from the FALLBACK_DESTINATIONS var, with no database involved. +-- +-- A keyword rather than the destination URL itself: putting a recoverable URL in +-- the QR costs roughly its own length in payload (no amount of hashing or +-- encryption avoids that — a hash is one-way, and ciphertext is longer than +-- plaintext), which grew the symbol from 53x53 to 69x69 modules. A keyword costs +-- 4 modules. It also makes an open redirect structurally impossible, because the +-- parameter only ever selects from a list the operator configured. +-- +-- Existing rows get ''. Nothing needs backfilling: the admin UI derives a keyword +-- from the destination host when the column is blank, so QR codes generated for +-- older projects still carry a usable one. +-- +-- Idempotent? No — re-running errors with "duplicate column name". A brand-new +-- database does not need this file at all; schema.sql already has the column. +-- +-- Apply with: +-- wrangler d1 execute trackinglink-db --local --file=./migrations/0003_add_project_fallback_key.sql +-- wrangler d1 execute trackinglink-db --remote --file=./migrations/0003_add_project_fallback_key.sql +-- Then re-run schema.sql. + +ALTER TABLE Projects ADD COLUMN fallback_key TEXT NOT NULL DEFAULT ''; diff --git a/packages/api/package.json b/packages/api/package.json index 09546dc..1c7858a 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -5,14 +5,18 @@ "version": "0.1.0", "description": "QR redirect/logging Worker and admin API for TrackingLink.", "license": "MIT", - "engines": { "node": ">=20" }, + "engines": { + "node": ">=20" + }, "scripts": { "dev": "wrangler dev", "deploy": "wrangler deploy --minify", "typecheck": "tsc --noEmit", "cf-typegen": "wrangler types --env-interface CloudflareBindings", "db:apply:local": "wrangler d1 execute trackinglink-db --local --file=./schema.sql", - "db:apply:remote": "wrangler d1 execute trackinglink-db --remote --file=./schema.sql" + "db:apply:remote": "wrangler d1 execute trackinglink-db --remote --file=./schema.sql", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "drizzle-orm": "^0.36.4", @@ -23,6 +27,7 @@ "devDependencies": { "@cloudflare/workers-types": "^4.20241127.0", "typescript": "^5.7.2", + "vitest": "2.1.8", "wrangler": "^3.90.0" } } diff --git a/packages/api/schema.sql b/packages/api/schema.sql index dea5b0b..66091fa 100644 --- a/packages/api/schema.sql +++ b/packages/api/schema.sql @@ -12,6 +12,19 @@ CREATE TABLE IF NOT EXISTS Projects ( project_id TEXT NOT NULL UNIQUE, name TEXT NOT NULL, destination_url TEXT NOT NULL, + -- Short ASCII keyword baked into this project's QR codes as `&p=<key>`, used + -- to pick a destination from the FALLBACK_DESTINATIONS var when D1 cannot be + -- reached. Deliberately not the destination URL itself: a recoverable URL in + -- the QR costs its own length in payload and grows the symbol from 53x53 to + -- 69x69 modules, while a keyword costs 4 — and because the parameter only + -- selects from an operator-configured list, an open redirect is impossible. + -- + -- Optional; '' means "no keyword". The admin UI then derives one from the + -- destination host, so no backfill is needed for older projects. + -- + -- Changing it does not update QR codes that are already printed — those keep + -- the keyword they were generated with. + fallback_key TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, admin_user_id TEXT ); diff --git a/packages/api/src/auth/types.ts b/packages/api/src/auth/types.ts index 73b6b00..7a35447 100644 --- a/packages/api/src/auth/types.ts +++ b/packages/api/src/auth/types.ts @@ -37,6 +37,27 @@ export interface Bindings { LOGIN_LIMITER?: RateLimiter; /** Build identifier surfaced by /healthz, for confirming what is deployed. */ GIT_SHA?: string; + /** + * Where a scan goes when D1 cannot be reached at all — the last resort behind + * FALLBACK_DESTINATIONS. Covers QR codes printed before `&p=` existed, and + * keywords with no configured entry. Unset means answer 503 instead. + */ + FALLBACK_URL?: string; + /** + * Keyword → destination URL, consulted only when the database read fails. + * + * Accepts an object or a JSON string, because `vars` in wrangler.jsonc is JSON + * and which one arrives depends on how the value was quoted. Unusable entries + * are dropped rather than throwing — see src/fallback.ts. + */ + FALLBACK_DESTINATIONS?: string | Record<string, string>; + /** + * Local testing only: makes the scan endpoint's database read throw, so the + * fallback path can actually be exercised (the local D1 is embedded and cannot + * be switched off). Every use is logged as `db_failure_simulated`, so this + * cannot sit unnoticed in a deployed environment. + */ + SIMULATE_DB_FAILURE?: string; } /** The shape of a Workers Rate Limiting binding (`type: "ratelimit"`). */ diff --git a/packages/api/src/db/schema.ts b/packages/api/src/db/schema.ts index b31a23a..8fbea24 100644 --- a/packages/api/src/db/schema.ts +++ b/packages/api/src/db/schema.ts @@ -10,6 +10,14 @@ export const projects = sqliteTable('Projects', { projectId: text('project_id').primaryKey(), name: text('name').notNull(), destinationUrl: text('destination_url').notNull(), + // Keyword baked into this project's QR codes as `&p=<key>`, resolved against + // the FALLBACK_DESTINATIONS var when D1 is unreachable. See schema.sql for why + // this is a keyword rather than the destination URL. + // + // Deliberately not `.default('')`, for the same reason as qrCodes.location: + // keeping it required in Drizzle forces every insert to pass a value, so the + // code never depends on a column default being present in a given database. + fallbackKey: text('fallback_key').notNull(), createdAt: text('created_at').notNull(), adminUserId: text('admin_user_id'), }); diff --git a/packages/api/src/fallback.test.ts b/packages/api/src/fallback.test.ts new file mode 100644 index 0000000..871b5db --- /dev/null +++ b/packages/api/src/fallback.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest'; +import { parseFallbackMap, resolveFallbackUrl } from './fallback'; + +/** + * This is the code path that only runs when D1 is already broken, so it will never + * be exercised by ordinary use — which is exactly why it is worth unit testing. + * A mistake here stays invisible until the one moment it matters. + */ + +const INSTAGRAM = 'https://www.instagram.com/nutfes_official/'; +const SITE = 'https://www.nutfes.net/'; + +describe('parseFallbackMap', () => { + it('reads a plain object', () => { + expect(parseFallbackMap({ instagram: INSTAGRAM })).toEqual({ + instagram: INSTAGRAM, + }); + }); + + it('reads the same config given as a JSON string', () => { + // wrangler.jsonc `vars` is JSON, so the value arrives as an object or a + // string depending on how it was quoted. Both spellings must work. + expect(parseFallbackMap(JSON.stringify({ instagram: INSTAGRAM }))).toEqual({ + instagram: INSTAGRAM, + }); + }); + + it('returns an empty map for anything unset or unusable, without throwing', () => { + // Throwing here would turn a config typo into a 500 for every visitor — the + // exact failure this module exists to prevent. + for (const input of [ + undefined, + null, + '', + ' ', + 'not json', + 42, + [], + true, + ]) { + expect(parseFallbackMap(input)).toEqual({}); + } + }); + + it('drops entries whose URL is not http(s)', () => { + const map = parseFallbackMap({ + evil: 'javascript:alert(1)', + data: 'data:text/html,<script>x</script>', + file: 'file:///etc/passwd', + ok: INSTAGRAM, + }); + expect(map).toEqual({ ok: INSTAGRAM }); + }); + + it('drops non-string values and over-long keys', () => { + const map = parseFallbackMap({ + nested: { url: INSTAGRAM }, + number: 1, + ['a'.repeat(41)]: INSTAGRAM, + ok: INSTAGRAM, + }); + expect(map).toEqual({ ok: INSTAGRAM }); + }); +}); + +describe('resolveFallbackUrl', () => { + const map = { instagram: INSTAGRAM }; + + it('prefers the keyword entry', () => { + expect(resolveFallbackUrl('instagram', map, SITE)).toEqual({ + url: INSTAGRAM, + tier: 'keyword', + }); + }); + + it('falls back to the site-wide URL when the keyword is not configured', () => { + expect(resolveFallbackUrl('shop', map, SITE)).toEqual({ + url: SITE, + tier: 'static', + }); + }); + + it('falls back to the site-wide URL for a QR code printed before &p= existed', () => { + expect(resolveFallbackUrl(undefined, map, SITE)).toEqual({ + url: SITE, + tier: 'static', + }); + expect(resolveFallbackUrl('', map, SITE)).toEqual({ + url: SITE, + tier: 'static', + }); + }); + + it('returns null when nothing is configured, so the caller can answer 503', () => { + expect(resolveFallbackUrl('instagram', {}, undefined)).toBeNull(); + expect(resolveFallbackUrl(undefined, {}, '')).toBeNull(); + }); + + it('refuses a non-http(s) site-wide fallback rather than redirecting to it', () => { + expect(resolveFallbackUrl('shop', map, 'javascript:alert(1)')).toBeNull(); + }); + + it('refuses a non-http(s) keyword entry even in a hand-built map', () => { + // parseFallbackMap already filters these out, so this only arises if a caller + // builds the map itself. Validating here too means no caller can introduce a + // redirect to another scheme, and the site-wide fallback still answers. + expect( + resolveFallbackUrl('evil', { evil: 'javascript:alert(1)' }, SITE), + ).toEqual({ url: SITE, tier: 'static' }); + }); +}); diff --git a/packages/api/src/fallback.ts b/packages/api/src/fallback.ts new file mode 100644 index 0000000..a7c10f9 --- /dev/null +++ b/packages/api/src/fallback.ts @@ -0,0 +1,105 @@ +import { isHttpUrl } from './url'; + +/** + * Where to send a scan when D1 cannot be reached. + * + * The scan endpoint normally reads the destination URL out of the database on + * every request. When that read throws — most plausibly because a Free-plan daily + * quota ran out, which leaves the Worker serving traffic while every D1 call + * fails — it used to fall through to the global error handler and answer with a + * bare 500. Every printed poster would go dead simultaneously. + * + * QR codes therefore carry `&p=<keyword>`, and this module maps that keyword to a + * URL taken purely from configuration, touching no database. + * + * Why a keyword instead of the destination URL itself: a URL that can be + * recovered from the QR has to *be* in the QR, which costs roughly its own length + * in payload. No cryptography avoids that — a hash is one-way so it cannot be + * reversed whatever key the Worker holds, and authenticated encryption produces + * ciphertext longer than the plaintext. Measured, that grew the symbol from 53x53 + * to 69x69 modules; a keyword costs 4 modules. It also means the parameter can + * only ever select from a list the operator wrote, so no attacker-supplied + * destination is reachable and no signature is needed. + * + * These functions are pure so the resolution order can be tested directly. + */ + +/** Longest keyword accepted, matching the column and form limits. */ +const MAX_KEY_LENGTH = 40; + +export interface FallbackTarget { + url: string; + /** Which tier answered — surfaced in logs to show how degraded the response was. */ + tier: 'keyword' | 'static'; +} + +/** + * Reads the FALLBACK_DESTINATIONS var into a keyword → URL map. + * + * Accepts an object as well as a JSON string, for the same reason + * `isFlagEnabled` accepts a boolean: `vars` in wrangler.jsonc is JSON, so whether + * the value ends up a string or an object depends on how it was quoted, and a + * strict reader would silently ignore one of the two spellings. + * + * Entries that are not usable are dropped rather than throwing, because throwing + * here would turn a config typo into a 500 for every visitor — the exact failure + * this module exists to prevent. A dropped entry behaves like a missing one, and + * the caller's `scan_fallback_not_configured` log surfaces it. + */ +export function parseFallbackMap(raw: unknown): Record<string, string> { + let source: unknown = raw; + + if (typeof source === 'string') { + const trimmed = source.trim(); + if (!trimmed) return {}; + try { + source = JSON.parse(trimmed); + } catch { + return {}; + } + } + + if (!source || typeof source !== 'object' || Array.isArray(source)) return {}; + + const map: Record<string, string> = {}; + for (const [key, value] of Object.entries( + source as Record<string, unknown>, + )) { + if (!key || key.length > MAX_KEY_LENGTH) continue; + if (typeof value !== 'string') continue; + // A `javascript:` entry in a var would be redirected to just like any other. + if (!isHttpUrl(value)) continue; + map[key] = value; + } + return map; +} + +/** + * Picks a destination: the keyword's own entry, then the site-wide fallback. + * + * Returns null when neither is configured, which the caller turns into a 503 with + * an explanation rather than a redirect to nowhere. + */ +export function resolveFallbackUrl( + key: string | undefined, + map: Record<string, string>, + staticFallback: string | undefined, +): FallbackTarget | null { + if (key) { + const forKeyword = map[key]; + // Re-checked even though parseFallbackMap already filters: validating at both + // layers costs one call and means no caller — including a future one building + // the map by hand — can introduce a redirect to a non-http(s) scheme. + if (forKeyword && isHttpUrl(forKeyword)) { + return { url: forKeyword, tier: 'keyword' }; + } + } + + // Covers QR codes printed before `&p=` existed, keywords missing from the + // config, and entries dropped for being unusable. + if (staticFallback && isHttpUrl(staticFallback)) { + return { url: staticFallback, tier: 'static' }; + } + + return null; +} diff --git a/packages/api/src/routes/forward.ts b/packages/api/src/routes/forward.ts index 8108d27..819799b 100644 --- a/packages/api/src/routes/forward.ts +++ b/packages/api/src/routes/forward.ts @@ -1,46 +1,89 @@ import { eq } from 'drizzle-orm'; -import { Hono } from 'hono'; +import { type Context, Hono } from 'hono'; import { getConnInfo } from 'hono/cloudflare-workers'; import { html } from 'hono/html'; import type { HonoEnv } from '../auth'; import { getDb, schema } from '../db'; -import { logFailure } from '../log'; +import { parseFallbackMap, resolveFallbackUrl } from '../fallback'; +import { logEvent, logFailure } from '../log'; import { isBotUserAgent, wantsLinkPreview } from '../user-agent'; const forwardApp = new Hono<HonoEnv>(); -// GET /?id=<qrId> — scan a QR code: look it up, log the access, redirect. +/** + * Keywords already reported as missing from FALLBACK_DESTINATIONS. + * + * Module scope, so the warning below is emitted at most once per isolate. Logging + * it on every scan would flood Workers Logs for an unconfigured project — the + * point is to notice the gap, not to narrate it. + */ +const reportedMissingKeywords = new Set<string>(); + +// GET /?id=<qrId>&p=<fallbackKey> — scan a QR code: look it up, log the access, +// redirect. // // This is the only hot path in the app: every person who scans a printed poster // goes through it, in bursts. It is deliberately one D1 read (a primary-key // lookup) on the critical path — see the join and the waitUntil below. forwardApp.get('/', async (c) => { - const { id } = c.req.query(); + const { id, p: fallbackKey } = c.req.query(); const userAgent = c.req.header('User-Agent') || 'unknown'; if (!id) return c.text('Not found', 404); + const isLinkPreviewBot = wantsLinkPreview(userAgent); const db = getDb(c.env.DB); - // One round trip instead of two. A leftJoin (rather than an inner join) so - // that "QR code missing" and "project missing" stay distinguishable, which - // the two 404 branches below rely on. - const target = await db - .select({ - projectId: schema.qrCodes.projectId, - location: schema.qrCodes.location, - projectName: schema.projects.name, - destinationUrl: schema.projects.destinationUrl, - }) - .from(schema.qrCodes) - .leftJoin( - schema.projects, - eq(schema.qrCodes.projectId, schema.projects.projectId), - ) - .where(eq(schema.qrCodes.id, id)) - .get(); + // `target` stays undefined for "no such QR code" and dbUnavailable flips for + // "could not ask". Keeping them apart matters: `.get()` returns undefined + // rather than throwing when a row is missing, so the 404 branches below mean + // exactly what they did before, and only a genuine failure reaches the + // fallback. + let target: + | { + projectId: string; + location: string; + projectName: string | null; + destinationUrl: string | null; + } + | undefined; + let dbUnavailable = false; - const isLinkPreviewBot = wantsLinkPreview(userAgent); + try { + if (c.env.SIMULATE_DB_FAILURE) { + // Local-testing escape hatch. Without it the fallback path — the whole + // point of this handler's new branch — cannot be exercised by hand, since + // the local database is embedded and cannot be "turned off". Logged on + // every use so it cannot sit unnoticed in a deployed environment. + logEvent('db_failure_simulated', { qrId: id }); + throw new Error('SIMULATE_DB_FAILURE is set'); + } + + // One round trip instead of two. A leftJoin (rather than an inner join) so + // that "QR code missing" and "project missing" stay distinguishable, which + // the two 404 branches below rely on. + target = await db + .select({ + projectId: schema.qrCodes.projectId, + location: schema.qrCodes.location, + projectName: schema.projects.name, + destinationUrl: schema.projects.destinationUrl, + }) + .from(schema.qrCodes) + .leftJoin( + schema.projects, + eq(schema.qrCodes.projectId, schema.projects.projectId), + ) + .where(eq(schema.qrCodes.id, id)) + .get(); + } catch (error) { + dbUnavailable = true; + logFailure('scan_db_unavailable', error, { qrId: id, key: fallbackKey }); + } + + if (dbUnavailable) { + return serveFallback(c, { qrId: id, fallbackKey }); + } if (!target) { if (isLinkPreviewBot) { @@ -67,6 +110,11 @@ forwardApp.get('/', async (c) => { // matching project rather than a project with no URL. if (!target.destinationUrl) return c.text('Project not found', 404); + // Self-check on the healthy path. A keyword missing from the config only shows + // up as a degraded redirect during an outage, which is the worst possible time + // to discover it — so notice it now, while everything works. + warnIfKeywordUnconfigured(c, fallbackKey, id); + const { remote } = getConnInfo(c); // Record the hit — including crawler hits, flagged rather than dropped, so a @@ -88,7 +136,7 @@ forwardApp.get('/', async (c) => { // in Workers Logs rather than invisible. logFailure('access_log_insert_failed', error, { qrId: id, - projectId: target.projectId, + projectId: target?.projectId, }); }); @@ -137,4 +185,91 @@ forwardApp.get('/', async (c) => { return c.redirect(target.destinationUrl, 302); }); +type ForwardContext = Context<HonoEnv>; + +/** + * Answers a scan without touching the database. + * + * Reached only when the lookup itself failed. The destination comes from + * configuration alone, so it works while D1 is unreachable — which, on the Free + * plan, most plausibly means a daily quota ran out rather than an outage. + */ +function serveFallback( + c: ForwardContext, + { qrId, fallbackKey }: { qrId: string; fallbackKey?: string }, +) { + const target = resolveFallbackUrl( + fallbackKey, + parseFallbackMap(c.env.FALLBACK_DESTINATIONS), + c.env.FALLBACK_URL, + ); + + // The scan is genuinely lost, not merely deferred: AccessLogs.project_id is NOT + // NULL and qr_id is a foreign key into QRCodes, so without a readable database + // there is no valid row to write. Counted here so the size of the gap is known + // afterwards. + logEvent('scan_unlogged_due_to_db_failure', { qrId, key: fallbackKey }); + + if (!target) { + logEvent('scan_fallback_unavailable', { qrId, key: fallbackKey }); + // A readable page rather than the bare "Internal Server Error" this path used + // to produce. + return c.html( + html`<!DOCTYPE html> + <html lang="ja"> + <head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>一時的にアクセスできません + + +

一時的にアクセスできません

+

+ 現在このQRコードの転送先を取得できません。少し時間をおいて、もう一度読み取ってください。 +

+ + `, + 503, + { 'Cache-Control': 'no-store' }, + ); + } + + logEvent('scan_served_from_fallback', { + qrId, + key: fallbackKey, + tier: target.tier, + }); + + // no-store is load-bearing here, more so than on the healthy path: a cached + // fallback redirect would keep sending people to the fallback long after D1 + // recovered. + c.header('Cache-Control', 'no-store, max-age=0'); + return c.redirect(target.url, 302); +} + +/** Logs once per isolate when a scanned keyword has no configured destination. */ +function warnIfKeywordUnconfigured( + c: ForwardContext, + fallbackKey: string | undefined, + qrId: string, +) { + const key = fallbackKey ?? ''; + if (reportedMissingKeywords.has(key)) return; + + const map = parseFallbackMap(c.env.FALLBACK_DESTINATIONS); + if (key && map[key]) return; + + reportedMissingKeywords.add(key); + logEvent('scan_fallback_not_configured', { + qrId, + key: fallbackKey ?? null, + // Distinguishes "this QR predates the keyword" from "the keyword is simply + // not in FALLBACK_DESTINATIONS", which need different fixes. + reason: key ? 'keyword_missing_from_config' : 'qr_has_no_keyword', + hasStaticFallback: Boolean(c.env.FALLBACK_URL), + }); +} + export default forwardApp; diff --git a/packages/api/src/routes/projects.ts b/packages/api/src/routes/projects.ts index 2c05122..52c596a 100644 --- a/packages/api/src/routes/projects.ts +++ b/packages/api/src/routes/projects.ts @@ -30,14 +30,33 @@ const httpUrl = z } }, 'Only http(s) URLs are allowed'); +/** + * Keyword baked into this project's QR codes as `&p=`, resolved against the + * FALLBACK_DESTINATIONS var when D1 is unreachable (see src/fallback.ts). + * + * ASCII-only on purpose: a Japanese keyword is percent-encoded at 9 characters per + * character, which grows the printed symbol from 57x57 to 61x61 modules. Optional + * — '' means the admin UI derives one from the destination host instead. + */ +const FALLBACK_KEY_MAX = 40; +const fallbackKey = z + .string() + .max(FALLBACK_KEY_MAX) + .regex( + /^$|^[a-z0-9][a-z0-9-]*$/, + 'Use lowercase letters, digits and hyphens only', + ); + const createProjectBodySchema = z.object({ projectName: z.string().min(1, 'Project name is required').max(NAME_MAX), destinationUrl: httpUrl, + fallbackKey: fallbackKey.optional(), }); const updateProjectBodySchema = z.object({ projectName: z.string().min(1).max(NAME_MAX).optional(), destinationUrl: httpUrl.optional(), + fallbackKey: fallbackKey.optional(), }); const createQRCodeBodySchema = z.object({ @@ -220,6 +239,7 @@ projectsApp.get('/', async (c) => { projectId: p.projectId, name: p.name, destinationUrl: p.destinationUrl, + fallbackKey: p.fallbackKey, createdAt: p.createdAt, adminUserId: p.adminUserId, accessCount: accessCountMap[p.projectId] ?? 0, @@ -251,18 +271,28 @@ projectsApp.post('/', async (c) => { const user = c.get('user'); const projectId = crypto.randomUUID(); const createdAt = new Date().toISOString(); + // Passed explicitly rather than relying on the column default, because Drizzle + // deliberately keeps the field required — see the note on schema.projects. + const key = parsed.data.fallbackKey ?? ''; const db = getDb(c.env.DB); await db.insert(schema.projects).values({ projectId, name: projectName, destinationUrl, + fallbackKey: key, createdAt, adminUserId: user?.sub ?? null, }); return c.json( - { projectId, name: projectName, destinationUrl, createdAt }, + { + projectId, + name: projectName, + destinationUrl, + fallbackKey: key, + createdAt, + }, 201, ); }); @@ -289,6 +319,7 @@ projectsApp.get('/:id', async (c) => { projectId: project.projectId, name: project.name, destinationUrl: project.destinationUrl, + fallbackKey: project.fallbackKey, createdAt: project.createdAt, adminUserId: project.adminUserId, }); @@ -323,6 +354,10 @@ projectsApp.put('/:id', async (c) => { values.name = parsed.data.projectName; if (parsed.data.destinationUrl !== undefined) values.destinationUrl = parsed.data.destinationUrl; + // `!== undefined` so that fallbackKey: '' clears the keyword; a truthiness check + // would silently ignore that, as it did for qrCodes.location. + if (parsed.data.fallbackKey !== undefined) + values.fallbackKey = parsed.data.fallbackKey; if (Object.keys(values).length === 0) { return fail(c, 400, ErrorCodes.NO_FIELDS_TO_UPDATE); } diff --git a/packages/api/src/url.ts b/packages/api/src/url.ts new file mode 100644 index 0000000..155dccf --- /dev/null +++ b/packages/api/src/url.ts @@ -0,0 +1,22 @@ +/** + * True only for http(s) URLs. + * + * Shared because two different places need exactly this guarantee and getting + * either wrong is an open redirect: + * + * - `destinationUrl` on a project. zod's `.string().url()` accepts `javascript:`, + * `data:`, `vbscript:` and `file:` (verified against zod 3.25), and that value + * is both 302-redirected to by the Worker and rendered as `` in the + * admin UI — whose origin holds the API token. + * - the fallback destinations in FALLBACK_DESTINATIONS / FALLBACK_URL. Those come + * from our own config rather than from a user, but a pasted `javascript:` in a + * var would be redirected to just the same, so it is checked too. + */ +export function isHttpUrl(value: string): boolean { + try { + const { protocol } = new URL(value); + return protocol === 'http:' || protocol === 'https:'; + } catch { + return false; + } +} diff --git a/packages/api/vitest.config.ts b/packages/api/vitest.config.ts new file mode 100644 index 0000000..3e5e08a --- /dev/null +++ b/packages/api/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + // Node, not workerd: only pure logic is covered here (see fallback.test.ts). + // Anything needing real D1 or a request context is verified through + // `wrangler dev --local` and the k6 smoke scenario instead. + environment: 'node', + include: ['src/**/*.test.ts'], + }, +}); diff --git a/packages/api/wrangler.jsonc b/packages/api/wrangler.jsonc index 5e4e5ad..db97cc3 100644 --- a/packages/api/wrangler.jsonc +++ b/packages/api/wrangler.jsonc @@ -48,7 +48,21 @@ // "async" (default) takes the access-log write off the redirect's response // path. Flip to "sync" from the dashboard — no deploy needed — if writes // ever look like they are going missing during an event. - "LOG_WRITE_MODE": "async" + "LOG_WRITE_MODE": "async", + // Last resort when D1 cannot be reached: without this the scan endpoint + // answers 503 rather than sending the visitor anywhere. + "FALLBACK_URL": "https://www.nutfes.net/", + // Keyword -> destination, used only while D1 is unreachable. The keyword is + // the one baked into each project's QR codes as `&p=` (Projects.fallback_key). + // + // Add an entry when you settle a project's keyword. Forgetting is safe: the + // scan falls through to FALLBACK_URL above, and `scan_fallback_not_configured` + // shows up in Workers Logs during normal operation so you find out before an + // outage rather than during one. + "FALLBACK_DESTINATIONS": { + // "instagram": "https://www.instagram.com/nutfes_official/", + // "web": "https://www.nutfes.net/event/" + } } // Secrets (set with `wrangler secret put `, never committed): // JWT_SECRET - random string used to sign/verify admin session tokens diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 68eaf0e..c525168 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,6 +36,9 @@ importers: typescript: specifier: ^5.7.2 version: 5.9.3 + vitest: + specifier: 2.1.8 + version: 2.1.8(@types/node@22.20.0)(lightningcss@1.32.0) wrangler: specifier: ^3.90.0 version: 3.114.17(@cloudflare/workers-types@4.20260702.1) @@ -296,6 +299,12 @@ packages: peerDependencies: esbuild: '*' + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} engines: {node: '>=18'} @@ -308,6 +317,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.25.12': resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} engines: {node: '>=18'} @@ -320,6 +335,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.25.12': resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} engines: {node: '>=18'} @@ -332,6 +353,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.25.12': resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} engines: {node: '>=18'} @@ -344,6 +371,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.25.12': resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} engines: {node: '>=18'} @@ -356,6 +389,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.25.12': resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} engines: {node: '>=18'} @@ -368,6 +407,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.25.12': resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} engines: {node: '>=18'} @@ -380,6 +425,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.25.12': resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} engines: {node: '>=18'} @@ -392,6 +443,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.25.12': resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} engines: {node: '>=18'} @@ -404,6 +461,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.25.12': resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} engines: {node: '>=18'} @@ -416,6 +479,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.25.12': resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} engines: {node: '>=18'} @@ -428,6 +497,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.25.12': resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} engines: {node: '>=18'} @@ -440,6 +515,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.25.12': resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} engines: {node: '>=18'} @@ -452,6 +533,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.25.12': resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} engines: {node: '>=18'} @@ -464,6 +551,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.25.12': resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} engines: {node: '>=18'} @@ -476,6 +569,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.25.12': resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} engines: {node: '>=18'} @@ -488,6 +587,12 @@ packages: cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.25.12': resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} engines: {node: '>=18'} @@ -506,6 +611,12 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.25.12': resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} engines: {node: '>=18'} @@ -524,6 +635,12 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.25.12': resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} engines: {node: '>=18'} @@ -542,6 +659,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.25.12': resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} engines: {node: '>=18'} @@ -554,6 +677,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.25.12': resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} engines: {node: '>=18'} @@ -566,6 +695,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.25.12': resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} engines: {node: '>=18'} @@ -578,6 +713,12 @@ packages: cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.25.12': resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} engines: {node: '>=18'} @@ -972,6 +1113,38 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitest/expect@2.1.8': + resolution: {integrity: sha512-8ytZ/fFHq2g4PJVAtDX57mayemKgDR6X3Oa2Foro+EygiOJHUXhCqBAAKQYYajZpFoIfvBCF1j6R6IYRSIUFuw==} + + '@vitest/mocker@2.1.8': + resolution: {integrity: sha512-7guJ/47I6uqfttp33mgo6ga5Gr1VnL58rcqYKyShoRK9ebu8T5Rs6HN3s1NABiBeVTdWNrwUMcHH54uXZBN4zA==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@2.1.8': + resolution: {integrity: sha512-9HiSZ9zpqNLKlbIDRWOnAWqgcA7xu+8YxXSekhr0Ykab7PAYFkhkwoqVArPOtJhPmYeE2YHgKZlj3CP36z2AJQ==} + + '@vitest/pretty-format@2.1.9': + resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + + '@vitest/runner@2.1.8': + resolution: {integrity: sha512-17ub8vQstRnRlIU5k50bG+QOMLHRhYPAna5tw8tYbj+jzjcspnwnwtPtiOlkuKC4+ixDPTuLZiqiWWQ2PSXHVg==} + + '@vitest/snapshot@2.1.8': + resolution: {integrity: sha512-20T7xRFbmnkfcmgVEz+z3AU/3b0cEzZOt/zmnvZEctg64/QZbSDJEVm9fLnnlSi74KibmRsO9/Qabi+t0vCRPg==} + + '@vitest/spy@2.1.8': + resolution: {integrity: sha512-5swjf2q95gXeYPevtW0BLk6H8+bPlMb4Vw/9Em4hFxDcaOxS+e0LOX4yqNxoHzMR2akEB2xfpnWUzkZokmgWDg==} + + '@vitest/utils@2.1.8': + resolution: {integrity: sha512-dwSoui6djdwbfFmIgbIjX2ZhIoG7Ex/+xpxyiEgIGzjliY8xGkcpITKTlp6B4MgtGkF2ilvm97cPM96XZaAgcA==} + acorn-walk@8.3.2: resolution: {integrity: sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==} engines: {node: '>=0.4.0'} @@ -992,6 +1165,10 @@ packages: as-table@1.0.55: resolution: {integrity: sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + baseline-browser-mapping@2.10.41: resolution: {integrity: sha512-WwS7MHhqGHHlaVsqRZnhvCEMS0owDX+SxRlve7JkuH7My1Ara3ZriTmCQupPfYjxMZ8I/tgxtJYr2t7taHaH4A==} engines: {node: '>=6.0.0'} @@ -1005,6 +1182,10 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + camelcase@5.3.1: resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} engines: {node: '>=6'} @@ -1012,10 +1193,18 @@ packages: caniuse-lite@1.0.30001800: resolution: {integrity: sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==} + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + cliui@6.0.0: resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} @@ -1072,6 +1261,10 @@ packages: resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} engines: {node: '>=0.10.0'} + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + defu@6.1.7: resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} @@ -1184,11 +1377,19 @@ packages: resolution: {integrity: sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==} engines: {node: '>=10.13.0'} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + esbuild@0.17.19: resolution: {integrity: sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw==} engines: {node: '>=12'} hasBin: true + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + esbuild@0.25.12: resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} engines: {node: '>=18'} @@ -1205,10 +1406,17 @@ packages: estree-walker@0.6.1: resolution: {integrity: sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + exit-hook@2.2.1: resolution: {integrity: sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==} engines: {node: '>=6'} + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + exsolve@1.1.0: resolution: {integrity: sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==} @@ -1360,6 +1568,9 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -1422,9 +1633,16 @@ packages: path-to-regexp@6.3.0: resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1522,6 +1740,9 @@ packages: resolution: {integrity: sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + simple-swizzle@0.2.4: resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==} @@ -1537,9 +1758,15 @@ packages: resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==} deprecated: Please use @jridgewell/sourcemap-codec instead + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + stacktracey@2.2.0: resolution: {integrity: sha512-ETyQEz+CzXiLjEbyJqpbp+/T79RQD/6wqFucRBIlVNZfYq2Ay7wbretD4cxpbymZlaPWx58aIhPEY1Cr8DlVvg==} + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + stoppable@1.1.0: resolution: {integrity: sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==} engines: {node: '>=4', npm: '>=6'} @@ -1570,10 +1797,28 @@ packages: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} + + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -1605,6 +1850,42 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + vite-node@2.1.8: + resolution: {integrity: sha512-uPAwSr57kYjAUux+8E2j0q0Fxpn8M9VoyfGiRI8Kfktz9NcYMCenwY5RnZxnF1WTu3TGiYipirIzacLL3VVGFg==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + vite@6.4.3: resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -1645,9 +1926,39 @@ packages: yaml: optional: true + vitest@2.1.8: + resolution: {integrity: sha512-1vBKTZskHw/aosXqQUlVWWlGUxSJR8YtiyZDJAFeW2kPAeX6S3Sool0mjspO+kXLuxVWlEDDowBAeqeAQefqLQ==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 2.1.8 + '@vitest/ui': 2.1.8 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + which-module@2.0.1: resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + workerd@1.20250718.0: resolution: {integrity: sha512-kqkIJP/eOfDlUyBzU7joBg+tl8aB25gEAGqDap+nFWb+WHhnooxjGHgxPBy3ipw2hnShPFNOQt5lFRxbwALirg==} engines: {node: '>=16'} @@ -1913,102 +2224,153 @@ snapshots: escape-string-regexp: 4.0.0 rollup-plugin-node-polyfills: 0.2.1 + '@esbuild/aix-ppc64@0.21.5': + optional: true + '@esbuild/aix-ppc64@0.25.12': optional: true '@esbuild/android-arm64@0.17.19': optional: true + '@esbuild/android-arm64@0.21.5': + optional: true + '@esbuild/android-arm64@0.25.12': optional: true '@esbuild/android-arm@0.17.19': optional: true + '@esbuild/android-arm@0.21.5': + optional: true + '@esbuild/android-arm@0.25.12': optional: true '@esbuild/android-x64@0.17.19': optional: true + '@esbuild/android-x64@0.21.5': + optional: true + '@esbuild/android-x64@0.25.12': optional: true '@esbuild/darwin-arm64@0.17.19': optional: true + '@esbuild/darwin-arm64@0.21.5': + optional: true + '@esbuild/darwin-arm64@0.25.12': optional: true '@esbuild/darwin-x64@0.17.19': optional: true + '@esbuild/darwin-x64@0.21.5': + optional: true + '@esbuild/darwin-x64@0.25.12': optional: true '@esbuild/freebsd-arm64@0.17.19': optional: true + '@esbuild/freebsd-arm64@0.21.5': + optional: true + '@esbuild/freebsd-arm64@0.25.12': optional: true '@esbuild/freebsd-x64@0.17.19': optional: true + '@esbuild/freebsd-x64@0.21.5': + optional: true + '@esbuild/freebsd-x64@0.25.12': optional: true '@esbuild/linux-arm64@0.17.19': optional: true + '@esbuild/linux-arm64@0.21.5': + optional: true + '@esbuild/linux-arm64@0.25.12': optional: true '@esbuild/linux-arm@0.17.19': optional: true + '@esbuild/linux-arm@0.21.5': + optional: true + '@esbuild/linux-arm@0.25.12': optional: true '@esbuild/linux-ia32@0.17.19': optional: true + '@esbuild/linux-ia32@0.21.5': + optional: true + '@esbuild/linux-ia32@0.25.12': optional: true '@esbuild/linux-loong64@0.17.19': optional: true + '@esbuild/linux-loong64@0.21.5': + optional: true + '@esbuild/linux-loong64@0.25.12': optional: true '@esbuild/linux-mips64el@0.17.19': optional: true + '@esbuild/linux-mips64el@0.21.5': + optional: true + '@esbuild/linux-mips64el@0.25.12': optional: true '@esbuild/linux-ppc64@0.17.19': optional: true + '@esbuild/linux-ppc64@0.21.5': + optional: true + '@esbuild/linux-ppc64@0.25.12': optional: true '@esbuild/linux-riscv64@0.17.19': optional: true + '@esbuild/linux-riscv64@0.21.5': + optional: true + '@esbuild/linux-riscv64@0.25.12': optional: true '@esbuild/linux-s390x@0.17.19': optional: true + '@esbuild/linux-s390x@0.21.5': + optional: true + '@esbuild/linux-s390x@0.25.12': optional: true '@esbuild/linux-x64@0.17.19': optional: true + '@esbuild/linux-x64@0.21.5': + optional: true + '@esbuild/linux-x64@0.25.12': optional: true @@ -2018,6 +2380,9 @@ snapshots: '@esbuild/netbsd-x64@0.17.19': optional: true + '@esbuild/netbsd-x64@0.21.5': + optional: true + '@esbuild/netbsd-x64@0.25.12': optional: true @@ -2027,6 +2392,9 @@ snapshots: '@esbuild/openbsd-x64@0.17.19': optional: true + '@esbuild/openbsd-x64@0.21.5': + optional: true + '@esbuild/openbsd-x64@0.25.12': optional: true @@ -2036,24 +2404,36 @@ snapshots: '@esbuild/sunos-x64@0.17.19': optional: true + '@esbuild/sunos-x64@0.21.5': + optional: true + '@esbuild/sunos-x64@0.25.12': optional: true '@esbuild/win32-arm64@0.17.19': optional: true + '@esbuild/win32-arm64@0.21.5': + optional: true + '@esbuild/win32-arm64@0.25.12': optional: true '@esbuild/win32-ia32@0.17.19': optional: true + '@esbuild/win32-ia32@0.21.5': + optional: true + '@esbuild/win32-ia32@0.25.12': optional: true '@esbuild/win32-x64@0.17.19': optional: true + '@esbuild/win32-x64@0.21.5': + optional: true + '@esbuild/win32-x64@0.25.12': optional: true @@ -2359,6 +2739,50 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/expect@2.1.8': + dependencies: + '@vitest/spy': 2.1.8 + '@vitest/utils': 2.1.8 + chai: 5.3.3 + tinyrainbow: 1.2.0 + + '@vitest/mocker@2.1.8(vite@5.4.21(@types/node@22.20.0)(lightningcss@1.32.0))': + dependencies: + '@vitest/spy': 2.1.8 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21(@types/node@22.20.0)(lightningcss@1.32.0) + + '@vitest/pretty-format@2.1.8': + dependencies: + tinyrainbow: 1.2.0 + + '@vitest/pretty-format@2.1.9': + dependencies: + tinyrainbow: 1.2.0 + + '@vitest/runner@2.1.8': + dependencies: + '@vitest/utils': 2.1.8 + pathe: 1.1.2 + + '@vitest/snapshot@2.1.8': + dependencies: + '@vitest/pretty-format': 2.1.8 + magic-string: 0.30.21 + pathe: 1.1.2 + + '@vitest/spy@2.1.8': + dependencies: + tinyspy: 3.0.2 + + '@vitest/utils@2.1.8': + dependencies: + '@vitest/pretty-format': 2.1.8 + loupe: 3.2.1 + tinyrainbow: 1.2.0 + acorn-walk@8.3.2: {} acorn@8.14.0: {} @@ -2373,6 +2797,8 @@ snapshots: dependencies: printable-characters: 1.0.42 + assertion-error@2.0.1: {} + baseline-browser-mapping@2.10.41: {} blake3-wasm@2.1.5: {} @@ -2385,15 +2811,27 @@ snapshots: node-releases: 2.0.50 update-browserslist-db: 1.2.3(browserslist@4.28.4) + cac@6.7.14: {} + camelcase@5.3.1: {} caniuse-lite@1.0.30001800: {} + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 supports-color: 7.2.0 + check-error@2.1.3: {} + cliui@6.0.0: dependencies: string-width: 4.2.3 @@ -2449,6 +2887,8 @@ snapshots: decamelize@1.2.0: {} + deep-eql@5.0.2: {} + defu@6.1.7: {} detect-libc@2.1.2: {} @@ -2470,6 +2910,8 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.3 + es-module-lexer@1.7.0: {} + esbuild@0.17.19: optionalDependencies: '@esbuild/android-arm': 0.17.19 @@ -2495,6 +2937,32 @@ snapshots: '@esbuild/win32-ia32': 0.17.19 '@esbuild/win32-x64': 0.17.19 + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + esbuild@0.25.12: optionalDependencies: '@esbuild/aix-ppc64': 0.25.12 @@ -2530,8 +2998,14 @@ snapshots: estree-walker@0.6.1: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + exit-hook@2.2.1: {} + expect-type@1.4.0: {} + exsolve@1.1.0: {} fdir@6.5.0(picomatch@4.0.5): @@ -2635,6 +3109,8 @@ snapshots: dependencies: js-tokens: 4.0.0 + loupe@3.2.1: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -2694,8 +3170,12 @@ snapshots: path-to-regexp@6.3.0: {} + pathe@1.1.2: {} + pathe@2.0.3: {} + pathval@2.0.1: {} + picocolors@1.1.1: {} picomatch@4.0.5: {} @@ -2833,6 +3313,8 @@ snapshots: shell-quote@1.8.4: {} + siginfo@2.0.0: {} + simple-swizzle@0.2.4: dependencies: is-arrayish: 0.3.4 @@ -2844,11 +3326,15 @@ snapshots: sourcemap-codec@1.4.8: {} + stackback@0.0.2: {} + stacktracey@2.2.0: dependencies: as-table: 1.0.55 get-source: 2.0.12 + std-env@3.10.0: {} + stoppable@1.1.0: {} string-width@4.2.3: @@ -2875,11 +3361,21 @@ snapshots: tapable@2.3.3: {} + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.5) picomatch: 4.0.5 + tinypool@1.1.1: {} + + tinyrainbow@1.2.0: {} + + tinyspy@3.0.2: {} + tree-kill@1.2.2: {} tslib@2.8.1: {} @@ -2908,6 +3404,34 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + vite-node@2.1.8(@types/node@22.20.0)(lightningcss@1.32.0): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.21(@types/node@22.20.0)(lightningcss@1.32.0) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vite@5.4.21(@types/node@22.20.0)(lightningcss@1.32.0): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.16 + rollup: 4.62.2 + optionalDependencies: + '@types/node': 22.20.0 + fsevents: 2.3.3 + lightningcss: 1.32.0 + vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0): dependencies: esbuild: 0.25.12 @@ -2922,8 +3446,48 @@ snapshots: jiti: 2.7.0 lightningcss: 1.32.0 + vitest@2.1.8(@types/node@22.20.0)(lightningcss@1.32.0): + dependencies: + '@vitest/expect': 2.1.8 + '@vitest/mocker': 2.1.8(vite@5.4.21(@types/node@22.20.0)(lightningcss@1.32.0)) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.8 + '@vitest/snapshot': 2.1.8 + '@vitest/spy': 2.1.8 + '@vitest/utils': 2.1.8 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.21(@types/node@22.20.0)(lightningcss@1.32.0) + vite-node: 2.1.8(@types/node@22.20.0)(lightningcss@1.32.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.20.0 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + which-module@2.0.1: {} + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + workerd@1.20250718.0: optionalDependencies: '@cloudflare/workerd-darwin-64': 1.20250718.0 From b49661d33f741a8f3417e337d84e1b361f732c7f Mon Sep 17 00:00:00 2001 From: kamijo-haruto Date: Mon, 27 Jul 2026 09:02:11 +0900 Subject: [PATCH 10/11] =?UTF-8?q?[feat]=20=E3=83=95=E3=82=A9=E3=83=BC?= =?UTF-8?q?=E3=83=AB=E3=83=90=E3=83=83=E3=82=AF=E5=80=99=E8=A3=9C=E3=81=AE?= =?UTF-8?q?=E4=B8=80=E8=A6=A7=E3=82=92=E8=BF=94=E3=81=99=E3=82=A8=E3=83=B3?= =?UTF-8?q?=E3=83=89=E3=83=9D=E3=82=A4=E3=83=B3=E3=83=88=E3=82=92=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 管理画面は FALLBACK_DESTINATIONS を自分で読めない——Workerのバインディングで ブラウザからは見えないため。一覧をAPIが返すことで、キーワード欄を自由入力から 選択式にできる。 これにより「設定に無いキーワードを打ち込んでしまい、フォールバックが何もしない」 という失敗が構造的に起きなくなり、wrangler.jsonc の増減がコード変更なしで そのままフォームに反映される。 `/:id` より前に登録してリテラルパスがルートマッチを取るようにし、実在 プロジェクトの取得と衝突しないことを確認済み。並び順はJSONの書き方に依存しない よう key でソートする。静的な FALLBACK_URL も併せて返し、キーワード未設定時に どこへ飛ぶのかをUI側で説明できるようにした。 Co-Authored-By: Claude Opus 5 (1M context) --- packages/api/src/routes/projects.ts | 34 +++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/packages/api/src/routes/projects.ts b/packages/api/src/routes/projects.ts index 52c596a..43a484d 100644 --- a/packages/api/src/routes/projects.ts +++ b/packages/api/src/routes/projects.ts @@ -4,6 +4,7 @@ import * as z from 'zod'; import type { HonoEnv } from '../auth'; import { getDb, schema } from '../db'; import { ErrorCodes, fail } from '../errors'; +import { parseFallbackMap } from '../fallback'; import { Permissions, hasPermission } from '../permissions'; // Length caps keep a single row (and therefore the database, and the CSV export) @@ -175,6 +176,39 @@ projectsApp.get('/qrcodes/:id', async (c) => { return c.json(qrCode); }); +// GET /projects/fallback-destinations — the keywords a project may be assigned +// +// Registered before `/:id` so the literal path wins the route match. +// +// Exists because the admin UI cannot read FALLBACK_DESTINATIONS itself — it is a +// Worker binding, not something the browser can see. Serving the list turns the +// keyword field from free text into a picker, which removes the whole class of +// "typed a keyword that is not in the config, so the fallback silently does +// nothing" mistakes, and means adding or removing a destination in wrangler.jsonc +// updates the form with no code change. +projectsApp.get('/fallback-destinations', async (c) => { + const denied = denyUnlessPermitted( + c, + Permissions.TRACKING_LINK_VIEW, + 'TRACKING_LINK_VIEW', + ); + if (denied) return denied; + + const map = parseFallbackMap(c.env.FALLBACK_DESTINATIONS); + // Sorted so the dropdown order does not depend on how the JSON happened to be + // written. + const data = Object.entries(map) + .map(([key, url]) => ({ key, url })) + .sort((a, b) => a.key.localeCompare(b.key)); + + return c.json({ + data, + // Lets the UI say "if the database is unreachable, scans go here instead" + // rather than leaving the no-keyword case unexplained. + staticFallbackUrl: c.env.FALLBACK_URL ?? null, + }); +}); + // GET /projects — paginated project list with access and QR code counts projectsApp.get('/', async (c) => { const denied = denyUnlessPermitted( From 1c8fff0bba024aa8baed44bbd6121d4638dd9802 Mon Sep 17 00:00:00 2001 From: kamijo-haruto Date: Mon, 27 Jul 2026 09:28:51 +0900 Subject: [PATCH 11/11] =?UTF-8?q?[chore]=20=E3=83=95=E3=82=A9=E3=83=BC?= =?UTF-8?q?=E3=83=AB=E3=83=90=E3=83=83=E3=82=AF=E5=85=88=E3=81=AE=E5=AE=9F?= =?UTF-8?q?URL=E3=82=92=E8=A8=AD=E5=AE=9A=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - instagram -> https://www.instagram.com/nutfes?igsh=... - web -> https://www.nutfes.net/ wrangler dev の実設定で確認: 候補一覧に2件が出て、D1障害中に &p=instagram で Instagram、&p=web で公式サイトへリダイレクトする。Instagram側の共有パラメータ (igsh)も欠けずに渡っている。 なお現時点で web の転送先は FALLBACK_URL と同一なので、「web」を選んでも 「なし」でも結果は変わらない。将来どちらかを変えたときに初めて分岐する。 Co-Authored-By: Claude Opus 5 (1M context) --- packages/api/wrangler.jsonc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/api/wrangler.jsonc b/packages/api/wrangler.jsonc index db97cc3..982de01 100644 --- a/packages/api/wrangler.jsonc +++ b/packages/api/wrangler.jsonc @@ -60,8 +60,8 @@ // shows up in Workers Logs during normal operation so you find out before an // outage rather than during one. "FALLBACK_DESTINATIONS": { - // "instagram": "https://www.instagram.com/nutfes_official/", - // "web": "https://www.nutfes.net/event/" + "instagram": "https://www.instagram.com/nutfes?igsh=MTB1eXhpcWJ4YnQ0cA==", + "web": "https://www.nutfes.net/" } } // Secrets (set with `wrangler secret put `, never committed):