Astro

Astro ブログに自作管理画面を生やす — Cloudflare Access × Workers × GitHub Contents API

スマートフォンの編集画面と、その奥にぼんやり見えるノートPCのブラウザ
スマホからブログ記事をぱぱっと直すための、ささやかな管理画面の話

出先で typo を見つけてしまったとき問題

Astro + Cloudflare Workers Static Assets でこのブログを再立ち上げしてから半年弱、運用は気持ちいいぐらい静かで、書く以外で触ることがほとんどありません。詳しくは Astro ブログを Cloudflare Workers で運用する という記事で書きました。

ただ、ひとつだけ困ることがあったんですよね。

それが 「外出中に typo を見つけても、その場では直せない」 というやつです。家の PC を立ち上げて Markdown を直して git push するまでが手順なので、移動中に思いついた直しはだいたいそのまま忘却の彼方へ流れていきます。

GitHub の Web UI でスマホから直接編集することもできるんですが、Markdown のテキストエリアがモバイルでまともに使えるサイズじゃないんですよね。Astro 移行の前段で Decap CMS を試した形跡もあるんですが、OAuth 設定が止まって public/admin/ だけ残骸として転がっていました。

ということで、自分のブログ専用の小さな管理画面を生やすことにしました。タイトルと本文を直せて、保存ボタンを押すと裏で commit が走ってサイトが自動で再ビルドされる、というだけのものです。

この記事は、その実装の判断と、特に「どうセキュリティを担保したか」のところをまとめたものです。攻撃面を増やさない構成にするために結構考えたので、似たことをやろうとしている方の参考になれば。

設計の分岐点 — ランタイム上書きにしない

最初に決めたのは「保存先を Git のままにする」というところでした。

選択肢としては、KV や D1 みたいなキー・バリュー / DB に記事本文をオーバーライドして、リクエスト時にそっちを優先で返す、という構成もあり得ます。Astro のビルド済みページに動的な書き換え層を被せる形ですね。これだとリビルドを待たずに反映されるので、UX 上のメリットは大きいです。

ただ、自分のブログ運用にはここまで重い仕掛けは要らないなと判断しました。理由はだいたいこの 3 つです。

  • 記事の真実 (Source of Truth) を Git に一本化したい。動的レイヤーを足すと「リポジトリの内容」と「サイトの実際の表示」がズレうるので、二重管理が前提になる
  • 静的サイトの良さは、運用に動くピースが少ないこと。動的層を足すと、それが新しい障害点になる
  • 書き換えは編集者本人のみ・頻度も極小。リビルドの ~1 分待ちは UX の妥協点としては受け入れられるレベル

ということで採用したのは「保存ボタン → GitHub Contents API で commit → Workers Builds が自動で再ビルド・デプロイ」というシンプルな経路です。commit 履歴がそのまま編集履歴になるし、復旧したくなったら git revert 一発、という安心感も付いてきます。

アーキテクチャ — Astro 静的 + 単一 Worker に同居

実装の置き場所も悩みました。管理画面用に別の Worker を切り出すか、既存の Worker に同居させるか。

別 Worker のメリットは、責務分離 / secret の局所化 / 障害域の分離あたりですが、個人運用 1 ユーザーの規模感だとオーバーキルだなと感じました。Cloudflare Access の保護はパス単位でかけられるので、Worker を分けなくても境界は引けます。

なので、こんな構成にしています。

システム全体構成図。スマホブラウザ → Cloudflare Access → 単一 Worker (静的アセット配信 + 管理 API) → GitHub Contents API → Workers Builds → サイト再デプロイ

ポイントは、

  • /<管理画面パス>/ 配下と /api/<管理画面パス>/* だけを Cloudflare Access の保護対象にする
  • 公開ブログ部分 (/, /blog/* 等) はそのまま無認証で配信
  • 管理 API は同じ Worker 内で url.pathname.startsWith で振り分け

UI 側 (一覧・編集) は Astro でそのままビルドした静的ページにしています。これは「管理画面用に別のデザインシステムや CSS 体系を持ちたくない」という意図です。本文プレビューで使う prose-jp ユーティリティをそのまま流用できるので、見た目の同期コストがゼロになります。Worker 側で HTML を組む構成にすると、ここのトークン同期が必ず負債化します。

認証 — Cloudflare Access (Zero Trust Free)

ここが一番考えたところでした。

候補としては、

  • Basic 認証: 一番ラクだけど、スマホで毎回 ID / パスワード入力が辛いのと、漏洩時の影響が読みにくい
  • GitHub OAuth 自前実装: Decap CMS でも採用されてるパターンだけど、コード量が増えて保守責任を背負うのが重い
  • Cloudflare Access (Zero Trust): パス単位で IdP 認証を前段に挟める。コードは何も書かなくていい

最終的に Cloudflare Access にしました。Zero Trust Free プランは 50 シートまで無料で使えるので、個人運用なら実質タダです。

ログイン方式は One-time PIN にしました。指定したメールアドレスに使い捨てコードが届いて、それを入れればセッションが発行される、という仕組みです。Google IdP 連携も検討したんですが、追加で IdP 設定をすることに対して、One-time PIN はメールアドレスを 1 件登録するだけで済むので、シンプルさで OTP に倒しました。セッションは 24 時間で切れるので、雑に出しっぱなしのスマホでも被害が広がりにくいというのもあります。

実際のログイン画面はこんな感じです。Email を入れてコードを送信 → 受信したコードを次の画面で入力、というフローです。

Cloudflare Access の One-time PIN ログイン画面。メールアドレスを入れて Send me a code を押すとコードが送られる

そして大事なのが、workers_dev = false*.workers.dev 直叩きを完全に封鎖すること。これをやらないと、techikoma-blog.<account>.workers.dev 経由で Worker に直接アクセスされて、Cloudflare Access を素通りされる可能性があります。wrangler.toml に 1 行入れるだけです。

# wrangler.toml
name = "techikoma-blog"
main = "./src/worker.ts"
workers_dev = false

Worker 側で JWT を厳格検証する 3 重ガード

Cloudflare Access が前段にいる、というだけで安心しきってしまうのは危ないので、Worker 側でも JWT を再検証します。CF Access が壊れたとき / 設定をうっかり緩めたときの安全網になります。

リクエストの流れはこんな感じです。

認証フロー図。スマホ → Cloudflare Access (One-time PIN) → JWT 発行 (Cf-Access-Jwt-Assertion ヘッダ) → Worker 側で iss/aud/exp/alg/email を再検証 → 通過なら GitHub API、不通なら 401/403

Worker 側でやっている検証は、

  • Cf-Access-Jwt-Assertion ヘッダの存在 (なければ即 401)
  • iss = https://<team>.cloudflareaccess.com の一致
  • aud = Application Audience Tag の一致
  • exp / nbf / alg=RS256 の妥当性
  • email claim を allowlist と再照合

JWKS (公開鍵) は /cdn-cgi/access/certs から取得できますが、毎リクエスト取りに行くのは無駄なので caches.default に TTL 1 時間でキャッシュしています。

ここで気をつけたいのは、Cloudflare Access 側の allowlist と、Worker 側の email allowlist を別々に持つこと。CF Access 側の policy をうっかり緩めても、Worker 側の照合で弾けます。「Cloudflare Access を信じきらない」「自分の Worker でも一段落とす」というのが Defense in Depth の基本です。

なので、

  • 第 1 層: Cloudflare Access が前段で許可制 (One-time PIN + email allowlist)
  • 第 2 層: Worker が JWT を再検証 (改ざん・期限切れチェック)
  • 第 3 層: email claim を Worker 側でも再照合

の 3 重で守る、という設計です。

編集スコープを絞る判断

これも書いておきたい話です。最初の v1 では、編集できるのは「タイトル (frontmatter) と本文」だけにしています。tags / pubDate / draft / image などの他のフィールドは v1 では一切触れません。

理由は 2 つあって、

ひとつは データ破損のリスクを最小化したかったこと。frontmatter を雑に書き換えると YAML パースが壊れて、ビルドそのものがコケます。タイトルと本文だけに限定すれば、書き換えロジックも極小で済みます。具体的には「frontmatter ブロックの title: 行だけをピンポイントで置換、他はそのままバイト単位で保持」という実装にしています。yaml ライブラリでラウンドトリップさせると引用符の好みや行の順番が変わって差分がノイズだらけになるので、これは敢えてやっていません。

もうひとつは frontmatter インジェクション対策。タイトル文字列に改行や --- や制御文字が混じると、frontmatter ブロックを途中で閉じてしまって本文を frontmatter として再解釈させる、みたいな攻撃が成立しえます。なので、

  • 改行・---・制御文字を含む title は 400 で reject
  • title の長さは 120 文字制限
  • 本文は 100 KB 制限
  • リクエストボディ全体も Content-Length 200 KB で早期 reject

というガードを入れています。タグや pubDate を編集したくなったら GitHub の Web UI で直接 frontmatter を直す、という運用に逃がしました。レアケースなので、それで十分です。

保存フロー — GitHub Contents API + sha 必須 + localStorage バックアップ

保存処理はこんな流れです。

保存フロー図。スマホで保存ボタン → PUT /api/<管理画面パス>/posts/<slug> (title, body, sha) → Worker が GitHub Contents API GET で現 sha 取得 → mismatch なら 409、一致なら frontmatter ビルド → PUT で commit → 成功なら commitSha を返却

ここで肝になるのが、GitHub Contents API の sha を race condition 対策に使うところです。記事を取得したときの sha をクライアントに渡しておいて、保存時にもう一度送ってもらいます。サーバーで現状の sha と照合して、ズレていたら 409 を返す、という Optimistic Locking のパターンです。

ズレるケースとしては、

  • スマホとローカルで同じ記事を別経路で書き換えた
  • ブラウザで開いたまま放置していて、その間に別タブから直した

みたいな状況があります。雑に上書きすると相手の変更が消えるので、409 が出たら 「最新を再読み込みすると現在の編集内容は失われます」 という確認ダイアログを出して、ユーザー側で判断してもらう設計にしました。自動マージはしません。

クライアント側はクライアント側で、通信断やブラウザクラッシュに備えて 2 秒 debounce で textarea を localStorage に退避しています。再読み込み時に「未保存の下書きがあります、復元しますか?」と聞いて、ユーザーが OK なら復元、キャンセルならクリア、というやつですね。書きかけが消える事故をどれだけ減らせるかは、書き手の信頼に直結するので、ここはサボらずに入れました。

使ってみた感想

リリース後、移動中や寝る前のちょっとした時間に typo を直したり段落を整えたり、という用途で使っています。「保存ボタン → 約 1 分でサイト反映」というリズムは、もともと欲しかったユースケースに収まっています。

複数人で運用するなら polling と「反映済み」バッジが欲しいところですが、自分しか触らない前提では現状で十分です。

似たことをやってみたい方の参考になれば嬉しいです。

参照リンク