Hata's Blog

SSGのブログでブラウザから記事を書いてGitHub API 経由で記事を保存できるようにした

背景

このブログは Hono + MDX の SSG で、記事は app/routes/articles/<YYYYMM>/<id>.mdx として Git リポジトリに置いている。main に push すると Cloudflare Pages がビルドしてデプロイする。

構成としてはシンプルで気に入っているのだが、記事を書くにはローカルにリポジトリを clone して、エディタで書いて、commit して push する必要がある。ちょっとした誤字を直したいだけでも PC を開くことになるし、出先でメモ程度に書き始めることもできない。

かといって、Next.js などの大きめのフレームワークや記事をデータベースに移したりはしたくなかった。記事の正はあくまで Git リポジトリのままにしておきたい。

ここで、 GitHub API を使ってブラウザからブログコンテンツを更新する方法を知る。きっかけは unvalley さんのこちらの記事。

git管理しているブログをブラウザから編集可能にするMDXで書いてgit管理しているブログ記事をブラウザで編集する方法について書きました
unvalley.me

こちらをパクる参考に、ブラウザで MDX を編集して保存すると、GitHub API 経由で main に直接コミットされる仕組みを作った。公開ページは今までどおり SSG のままで、push をきっかけにデプロイが走る流れも変わらない。

全体像

ブラウザ (管理画面)
  │  fetch (JSON)
  ▼
Cloudflare Access  ── 自分のメールアドレスだけ通す
  │  Cf-Access-Jwt-Assertion
  ▼
Pages Functions (/api/*)
  │  JWT 検証 → CSRF チェック → MDX の検証
  │  Fine-grained PAT
  ▼
GitHub API (Contents API / Git Trees API / GraphQL)
  │  main にコミット
  ▼
Cloudflare Pages のビルド・デプロイ

サーバー側は Cloudflare Pages Functions(functions/)だけで、別途サーバーやデータベースは持たない。API は次の 2 系統。

エンドポイント用途
GET /api/articles記事の一覧
GET /api/articles/<YYYYMM>/<id>記事の本文と sha
PUT /api/articles/<YYYYMM>/<id>記事の保存(コミット)
PUT / GET / DELETE /api/images/<path>画像のアップロード・存在確認・削除

GitHub API の使い方

読み書きは Contents API

1 ファイルの読み書きには Contents API を使っている。PUT /repos/{owner}/{repo}/contents/{path} に base64 にした中身とコミットメッセージを渡せば、それだけで 1 コミットになる。

const { status, body } = await request(config, `/contents/${encodePath(repoPath)}`, {
  method: "PUT",
  body: JSON.stringify({
    message: `docs(article): ${sha ? "update" : "add"} ${path}`,
    content: contentBase64,
    branch: config.branch,
    ...(sha ? { sha } : {}),
  }),
});

一覧は Git Trees API(/git/trees/main?recursive=1)でリポジトリ全体のパスを取り、記事のパス規則に合うものだけ残している。

楽観ロックとしての sha

Contents API で既存ファイルを更新するときは、今のファイルの blob sha を一緒に送る必要がある。送った sha が GitHub 上の最新と違えば 409 Conflict が返る。これがそのまま楽観ロックとして使える。

  • 記事を開いたときに content と sha を受け取って覚えておく
  • 保存時にその sha を送る
  • 409 が返ったら、誰か(多くは別の端末の自分)が先に更新している

競合したら三者マージ

409 のときに「保存できませんでした」で終わると、書いた内容を手で移すことになってつらい。そこで、クライアント側で三者マージをするようにした。

編集を始めた版(base)、自分の版(mine)、GitHub の最新版(theirs)がそろっているので、node-diff3 で行単位にマージする。

export const mergeThreeWay = (mine: string, base: string, theirs: string) => {
  const regions = diff3Merge(mine.split("\n"), base.split("\n"), theirs.split("\n"), {
    excludeFalseConflicts: true,
  });
  let conflicts = 0;
  const lines = regions.flatMap((region) => {
    if (region.conflict) {
      conflicts++;
      return region.conflict.a; // 両方が同じ箇所を変えたら自分の版を残す
    }
    return region.ok ?? [];
  });
  return { content: lines.join("\n"), conflicts };
};

片方だけが変えた箇所は自動で取り込まれ、両方が同じ箇所を変えたところだけ自分の版を残して件数を数える。マージ後はエディタで GitHub の最新版との差分を見せ、確認してから保存し直す。保存時に送る sha はマージ相手(theirs)のものに差し替える。

1MB を超えるファイルと GraphQL

画像を消すときは、他の記事がその画像を参照していないかを確かめたい。が、記事ごとに Contents API を叩くと、記事数ぶんのリクエストが飛び、Pages Functions のサブリクエスト上限にすぐ届いてしまう。

そこで、ここだけ GraphQL API を使い、main:app/routes/articles 配下の 2 階層ぶんの blob の text を 1 リクエストでまとめて取っている。isTruncated が true のものがあれば、参照の有無を判断できないので削除を止める。

また、画像の削除には sha が必要だが、1MB を超えるファイルは既定の Accept だと Contents API が 403 を返す。Accept: application/vnd.github.object を付けると中身を空にしたオブジェクト形式で返ってくるので、sha だけ取れる。

認証とトークンの扱い

GitHub のトークンを持つ API を公開サイトに生やすので慎重に作る必要がある。基本的なことをやっただけだが。

  • Fine-grained PAT が見れるリポジトリを絞る
  • Cloudflare Access で /admin* と /api/* を守り、自分のメールアドレスだけ通す
  • Functions 側でも Cf-Access-Jwt-Assertion を検証する。検証は jose で、JWKS は isolate 内でキャッシュする
  • CSRF 対策
  • 保存できるパスは app/routes/articles/<YYYYMM>/<id>.mdx の形に限る。任意のパスに書けると、ワークフローの定義なども書き換えられてしまう
export const onRequest: PagesFunction<Env, string, FunctionData> = async (context) => {
  const config = parseConfig(context.env);
  if (!config) return new Response("Not Found", { status: 404 });
 
  const verified = await verifyAccessJwt(
    context.request.headers.get("Cf-Access-Jwt-Assertion"),
    { ...config.access, getKey: accessJwks(config.access.issuer) },
  );
  if (!verified) return jsonResponse({ error: "unauthorized" }, 401);
 
  if (!passesCsrfCheck(context.request)) {
    return jsonResponse({ error: "forbidden" }, 403);
  }
 
  context.data.config = config;
  return context.next();
};

壊れた記事を main に入れない

main に直接コミットするので、ビルドが通らない MDX が入るとサイトのデプロイが止まる。そこで、保存前に validateArticle で MDX をコンパイルし、frontmatter(title があるか、date が実在する日付か)や、使ってよいコンポーネントだけを使っているかを確かめるようにした。

検証はブラウザ側でも入力のたびに回してエラー行を出しつつ、サーバー側でも保存前に必ず通す。サーバー側で失敗したら 422 と診断結果を返す。

エディタは CodeMirror

管理画面のエディタには CodeMirror 6 を使っている。basicSetup と Markdown のハイライトを入れるだけで、行番号、undo / redo、検索・置換(Cmd + F)など、エディタとしての基本はひととおりそろう。

const view = new EditorView({
  parent,
  extensions: [
    compare.of([]), // 競合したときだけ unifiedMergeView に差し替える
    basicSetup,
    markdown(),
    lintGutter(),
    EditorView.lineWrapping,
    keymap.of([{ key: "Mod-s", run: () => { onSave(); return true; } }]),
  ],
});

その上で、ここまでの仕組みとつなぐ部分だけ足している。

  • validateArticle の結果を @codemirror/lint の setDiagnostics に渡し、エラーのある行に印とメッセージを出す
  • 競合したら @codemirror/merge の unifiedMergeView で GitHub の最新版との差分を行内に出し、変更ごとに「この変更を採用」「GitHub 側に戻す」を選べるようにする
  • 画像の貼り付けとドロップは domEventHandlers で受けて、次に書く IndexedDB への保存に回す

記事内の画像

エディタに画像を貼り付けると、まずはブラウザの IndexedDB に置いておき、記事を保存したときに本文が参照しているものだけを public/images/ にコミットする。順番は「画像 → 記事」にしていて、記事だけ入って画像が欠けた状態を作らないようにしている。

画像のコミットメッセージの先頭には [CF-Pages-Skip] を付け、画像のコミットではデプロイを走らせない。続く記事のコミットでまとめてデプロイされる。

まとめ

快適になった。

Shoki Hata

決済領域のソフトウェアエンジニア。週末はコーヒー豆を焙煎しています。