HugoとGitHub PagesだけでWordPressライクな予約投稿を実現する

このブログはHugo + GitHub Pagesという、いわゆる「静的サイト」構成で動いている。ホスティングは無料でCDNも効くし、サーバーの面倒を見る必要もない。ただしこの構成には、WordPressのような動的CMSなら当たり前にある機能がひとつ欠けている。予約投稿だ。 Hugoはビルド時点の現在時刻より未来のdate:を持つ記事をデフォルトでビルド対象から除外する(buildFuture: false相当の挙動)。裏を返せば、「未来日付の記事をmasterにマージしておいて、その時刻が来たら勝手にサイトに出てくる」ということは、誰かが正しいタイミングでもう一度ビルドを起動しない限り起きない。動的CMSなら公開時刻をDBに書いておいてリクエストのたびに判定すればいいが、静的サイトは「ビルドした瞬間の世界」を切り出して配信しているだけなので、時間の経過そのものをトリガーにできない。 この記事では、GitHub Actionsだけを使ってこの制約を回避し、実際に予約投稿を動かしている仕組みを、詰まった点も含めて書いておく。 全体構成 flowchart TD Author[記事を書く] -->|"date: に未来日時を指定してPR作成"| PR[Pull Request] PR -->|opened / synchronize| DateCheck[correct-manual-post-dates.yaml] DateCheck -->|"date <= 現在時刻なら現在時刻に補正<br/>未来ならそのまま"| PR PR -->|レビュー後マージ| Master[masterにpush] Master -->|pushイベント| HugoBuild[hugo.yaml: ビルド&デプロイ] HugoBuild -->|"未来日付記事は除外"| Pages[GitHub Pages] Cron["Cron: 15分ごと"] -->|起動| Checker[publish-checker.yaml] Checker -->|"content/posts/**/*.md をスキャン"| Due{公開時刻到来?} Due -->|Yes| Dispatch["gh workflow run hugo.yaml"] Dispatch --> HugoBuild DailyCron["Cron: 毎日0時UTC"] -->|安全網| HugoBuild 登場するワークフローは3つ。 correct-manual-post-dates.yaml: PRを開いた/更新した時点で、新規追加記事の日時を補正する hugo.yaml: Hugoでビルドし、GitHub Pagesにデプロイする(未来日付の記事は自動的に除外される) publish-checker.yaml: 15分ごとに全記事をスキャンし、公開時刻が到来した未来日付記事があればhugo.yamlを起動する 1. 記事のdate:と「実際に公開された時刻」を一致させる 普段の運用(予約投稿ではなく、書いたらすぐ公開したい記事)では、date:フィールドに正確な時刻を毎回手で入れるのは面倒だし、書き始めた時刻とマージされた時刻がずれることも多い。そこで、PRのopened/synchronizeイベントをトリガーに、新規追加されたMarkdownファイルのdate:を機械的に補正するワークフローを用意した。 - name: Correct publish dates of added posts run: | files_to_check=$(gh api "repos/${{ github.repository }}/pulls/$PR_NUMBER/files" \ --paginate --jq '.[] | select(.status=="added") | .filename' \ | grep -E '^content/posts/.*\.md$' | grep -v '_index.md' || true) if [ -n "$files_to_check" ]; then git fetch origin "$BRANCH" git checkout -B "$BRANCH" origin/"$BRANCH" python3 scripts/correct_publish_dates.py $files_to_check # ... 差分があれば github-actions[bot] としてPRブランチにコミット&push fi ポイントは3つ。 ...

出前館の「ゴースト店舗」を見分けるTampermonkeyスクリプトを作った

出前館で店を選んでいると、店名からは実店舗なのかデリバリー専用ブランドなのか分からないことがよくある。いわゆる「ゴーストレストラン」——実店舗を持たず、間借りキッチンや既存店の厨房から複数のブランド名で出前だけを展開している業態——が一覧に紛れ込んでいて、選ぶたびに個別に住所を調べる羽目になっていた。この手間を減らしたくて、demaecan-no-ghostsというTampermonkeyユーザースクリプトを作った。 できること 店舗カードに情報アイコン — 一覧ページ(「過去に注文したお店」カルーセルも含む)の各店舗カード右上にアイコンが付く。クリックまたはホバーすると、店名・住所(初回のみ取得)・Googleマップへのリンク・Google検索へのリンクが載ったポップオーバーが開く。 ゴースト / 実店舗の判定を保存 — ポップオーバーや店舗ページから、その店を👻(ゴースト)か🏠(実店舗)として判定できる。判定は保存され、その店が出てくるすべての箇所でアイコンの絵柄に反映される。 判定済みの店を一覧からフィルタ — 画面右下のトグルで、ゴースト判定済みの店を一覧から非表示にできる。 店舗ページの判定パネル — 店舗自身のページを開くと画面左下に判定パネルが出る。出前館はSPAなので、画面遷移してもパネルがちゃんと追従するようにしてある。 インストールはTampermonkeyを入れた状態でこのURLを開くだけ。あとは自動更新される。 なぜ作ったか 出前館の店舗一覧は、実店舗の写真や住所が載っているように"見える"カードと、ゴーストレストランのカードが同じ見た目で並ぶ。判別する手がかりはほぼ店名だけで、結局は店ごとに検索して確認する作業が発生する。この手作業を「一度判定したら覚えておいてくれる」仕組みに置き換えたかったのが動機だ。 判定自体は主観に委ねている。スクリプトが自動でゴーストかどうかを推定するのではなく、住所を見やすく出す・調べやすくする・一度判定したら記憶する、という「意思決定を助けるためのUI」に徹している。 技術的なところ TypeScript + Vite + Vitest(カバレッジ計測込み)+ ESLintという、割とふつうの構成でビルドしてユーザースクリプト1ファイルに固める形にしている。出前館側はSPAなので、店舗ページへの遷移やカードの動的な差し替えにDOM監視で追従させる必要があり、このあたりが一番手を焼いたところだった。 開発フローはOpenSpecで変更を提案 → 実装 → アーカイブ、というサイクルを回している。openspec/changes/にproposalとdesignとtasksを書いてから実装に入るので、あとから「なぜこの挙動にしたか」を追いやすい。実際、右上アイコンの絵柄出し分けやホバー範囲の調整など、細かい挙動修正もすべてこのサイクルの中でproposalとして残っている。 今後 店名だけからゴーストらしさを判定するヒューリスティック(候補が1件なら実店舗寄り、2件以上絡むなら除外、など)をどこまでスクリプト側に持たせるかは検討中。ユーザーの判定を尊重しつつ、初見の店でも多少のヒントが出せるとさらに楽になりそうだ。 リポジトリはkuchida1981/demaecan-no-ghosts。ISCライセンスなので、気になった人は覗いてみてほしい。

Titanセキュリティキーが登録はできるのにログインだけ失敗する: VaultwardenのWebAuthn 2FAで踏んだ罠

セルフホストのVaultwardenにGoogle Titanセキュリティキーを2段階認証(FIDO2 WebAuthn)として登録したら、登録自体は一発で成功するのに、その後のログインが必ず失敗する、という不可解な症状にはまった。同じ物理キーはGoogleやwebauthn.ioでは何の問題もなく使える。原因を突き止めるのに数週間かかったので、記録として残しておく。 症状 Titanキーを2FAとして登録すると成功する(タッチ要求 → 登録完了、というふつうの流れ) ログイン時、ブラウザの「セキュリティキーを挿入してタッチしてください」ダイアログが出たまま反応がなく、最終的にNotAllowedError: The operation either timed out or was not allowedでタイムアウトする 同じ現象がLinux(Chrome)、Mac mini(Chrome/Firefox)、スマートフォンでも再現。OSやマシンには依存しない 同じキーをGoogleアカウントやwebauthn.ioに登録すると問題なく動く。キー自体は壊れていない 除外していった仮説 原因を絞り込むまでに、以下をひとつずつ潰していった。 RP ID/ドメインの不一致 — ブラウザのネイティブダイアログには正しいドメインが表示されている ブラウザ拡張機能・広告ブロッカーの干渉 — 無効化しても変化なし webauthn-connector.htmlのPermissions-Policyヘッダーにpublickey-credentials-getが含まれていない — Caddy側でヘッダーを上書きするテストを本番で実施したが変化なし サーバー側での認証情報の保存/シリアライズ不具合 — DBに保存されたcred_idはログイン時にそのままバイト単位で一致しており、サーバー側で壊れている様子はない U2F互換用のappid拡張を無条件に送っていることが原因では? — 移行済み(migrated: true)のクレデンシャルにだけappidを送るようパッチしたVaultwardenをビルドして本番にデプロイし、Chromeの内部FIDO/CTAPログ(--vmodule="*fido*=3,*webauthn*=3")で比較したが、タイムアウトまでの挙動パターンは無変化。この仮説は外れ 本家Bitwarden(C#実装)との差異 — WebAuthnTokenProvider.csを確認したが、Vaultwardenとほぼ同じ流れ(UserVerificationRequirement.Discouraged + 無条件のAppID拡張)で組まれており、Vaultwarden固有のバグという線も薄い 突破口: Firefoxで登録し直すとPINを聞かれた 外堀を埋めていく中で、ふと「登録済みのキーを消して、Firefoxで登録し直してみたらどうなるか」を試した。すると、Chromeでは一度も出なかったセキュリティキーのPIN入力を、Firefoxは要求してきた。 このFirefoxで登録し直したクレデンシャルでログインすると、Chrome・Firefoxどちらからでも成功する。念のため2回繰り返して確認した。 Chromeで再登録 → 失敗(何度やっても同じ) Firefoxで再登録 → 成功(Chrome/Firefoxどちらのログインも通る) 「登録時にPINによるユーザー検証(UV)が実際に行われたかどうか」が明暗を分けているのは間違いなさそうだった。 DBレコードを直接比較する 同じ物理キーを、登録に使うブラウザだけ変えて2回登録し、Vaultwardenのtwofactorテーブル(SQLite)に保存されるレコードを直接比較した。 Chromeで登録(PINなし) Firefoxで登録(PINあり) user_verified false true counter(ログイン後) 0(一度も成功していない) ログインのたびに増分 クレデンシャルIDの長さ 160 bytes 288 bytes registration_policyはどちらもdiscouragedのまま変わらないが、実際にUVが行われたかどうかでクレデンシャルIDの長さそのものが変わっている。Titanキーは、UVなしで作られた短い方のクレデンシャルではログインのGetAssertionを完走できない、という個体差(あるいは仕様)を持っているらしい。 ...

Vaultwarden運用まとめ: GCP/Terraform/Tailscale/GitHub Actionsで宣言的に管理する構成

パスワードマネージャーを自前ホストするなら、Bitwarden 互換の軽量サーバー実装である Vaultwarden が定番の選択肢だ。ただ、動かすだけなら Docker Compose 一発で済むところを、ぼくのプロジェクトでは GCP + Terraform + GitHub Actions + Tailscale を組み合わせて、それなりに作り込んだ運用基盤にしている。 意識したことはいくつかあり、主には可用性・信頼性・セキュリティだ。Vaultwarden の /admin パネルは「招待リンクを見る」以外の用途では触らない。 サインアップ制限、2段階認証の許可手段、SMTP、管理パネルそのものの保護方式まで、設定はすべて環境変数・Terraform・GitHub Actions のどこかに書いてあり、git の差分として残る。この記事ではその全体像を、インフラ層 → コンテナ設定 → ネットワーク境界 → デプロイパイプライン → バックアップの順に紹介する。 全体構成 flowchart TD GHA["GitHub Actions<br/>(WIF, 承認ゲート付き)"] -->|IAPトンネル経由でデプロイ| Caddy subgraph VM["GCP e2-micro VM (asia-northeast1)"] Caddy["Caddy<br/>:80 / :443"] VW["Vaultwarden<br/>(docker internal)"] TSServe["tailscale serve<br/>tailnet経由の /admin"] Disk[("Persistent Disk<br/>SQLite / 添付 / 鍵")] Timer["systemd timer"] Caddy -->|reverse_proxy| VW Caddy -->|":8080 (127.0.0.1のみ)"| TSServe VW -.->|データ永続化| Disk Timer -->|毎日rsync| NAS end NAS[("自宅 Synology NAS")] 1. インフラ: GCPに最安構成で、しかし守るべき所は守る Terraform でプロビジョニングしているのは以下の通り(terraform/main/)。 ...