構成

Worker 1 つと D1 1 つで動く部品の一覧と、問い合わせ 1 件の処理の流れです。

このページでは、goodnight-web を構成する部品と、問い合わせ 1 件が届いてから保存され、その後の処理が終わるまでの流れがわかります。Webサイト、問い合わせ API、管理画面は、Static Assets 付きの Worker 1 つと D1 データベース 1 つで動きます。

設計は次の制約で決まっています。

  • Worker 1 つと D1 データベース 1 つ。Queues、Cron Triggers、KV は使いません。
  • 必須の外部サービスは Cloudflare と GitHub だけです。
  • フェイルクローズ。設定やシークレットが足りないときは、それを使う機能を止めます。

構成図: 訪問者と管理者は 1 つの Worker にアクセスします。サイトは Static Assets が配信します。API と管理画面は Hono アプリが担当し、D1、Rate Limiting binding、Turnstile、任意の Slack Webhook を使います。管理画面のパスは Cloudflare Access で保護することもできます。

訪問者ページを見る・フォームを送る
管理者問い合わせを読む
Cloudflare Access(任意)/admin/*, /api/admin/*
Worker(1 つ)
Static AssetsAstro のビルド: /, /blog/, /contact/Worker のコードを動かさずに配信
Hono アプリcreateGoodnightWorker()
/api/contact/*
フォームの項目と送信
/api/setup
最初の管理者だけ
/api/auth/*
Better Auth(Passkey)
/api/admin/*
問い合わせの API
/admin/*
ビルド済みの管理画面
D1問い合わせ・フォーム・設定・認証
Rate Limitingbinding(ip_hash ごと)
Turnstileサーバー側で siteverify
Slack(任意)件数・受信日時・リンクだけctx.waitUntil
すべてを 1 つの Worker が配信します。破線は任意の部品です。Worker のコードが動くのは API と管理画面のパスだけです。

部品

部品役割
WorkerWebサイトごとに 1 つ。Static Assets で静的なページを配信し、API と管理画面を動かします。
Static AssetsAstro のビルド結果(/、/blog/、/contact/ など)。Worker のコードを動かさずに、Cloudflare が配信します。
Hono アプリWorker の中で動く Web フレームワーク。@goodnight-web/server の createGoodnightWorker(options) で作ります。run_worker_first により、/api/*、/admin、/admin/* だけが届きます。
D1SQLite のデータベース 1 つ。問い合わせ、設定、Better Auth のテーブルを保存します。マイグレーションは npm パッケージに入っています。
TurnstileCloudflare のボット対策。問い合わせフォームで使い、Worker がトークンをサーバー側で検証します。
Rate Limiting bindingWorkers の binding。問い合わせの送信、セットアップ、Passkey でのログインの回数を ip_hash(IP アドレスのハッシュ)ごとに制限します。無料プランで使えます。
Better Auth と Passkey管理者のログイン。Passkey だけを使います。メールとパスワードでのログインは無効です。
管理画面React、TanStack Router・Form・Table で作り、Vite でビルドしたアプリ。ビルド済みの状態で @goodnight-web/server に入っていて、Worker が配信します。
Slack Webhook(任意)新しい問い合わせを短く知らせます。個人情報は含めません。
Cloudflare Access(任意)/admin、/admin/*、/api/admin/*、/api/setup、/api/auth/* の前に置く、もう 1 段のログイン。推奨ですが必須ではありません。ACCESS_AUD と ACCESS_TEAM_DOMAIN を設定すると、Worker も Access のトークンを確認します。
Cloudflare WAF(任意)ご自身で設定するルールで、ドメインへのリクエストを Worker に届く前に制限します。WAF がなくても goodnight-web は同じように動きます。

ルート

パス呼び出し元内容
/、/blog/*、/contact/ など訪問者静的なページ。Worker のコードは動きません。
GET /api/contact/form問い合わせページ現在のフォームの項目と、フォームが使えるかどうかを返します。公開で、読み取り専用です。
POST /api/contact問い合わせフォーム送信内容を確認し、回数を制限して、保存します。
/api/setup最初の管理者最初の管理者を作ります。最初の Passkey を登録すると閉じます。
/api/auth/*管理者Better Auth。Passkey の登録とログインです。
/api/admin/*管理画面問い合わせの一覧・詳細・削除、未読/既読とスパムの疑いの切り替え、CSV の書き出し、フォームの編集とバージョン。問い合わせフォームに足りない設定と Slack 通知の有無を返すステータス API もあります。
/admin、/admin/*管理者ビルド済みの管理画面(React アプリ)。データを取るたびにセッションが必要です。

管理画面の問い合わせ一覧は 1 ページ 50 件で、受信日時の順に並びます。一覧の API は未読の件数とスパムの疑いの件数も返し、メールアドレスで検索できます。

問い合わせ 1 件の流れ

POST /api/contact は、次の順に処理します。

  1. Origin とサイズ。 サイト自身のオリジンからのリクエストか、Content-Type が想定どおりか、32 KB 以下かを確認します。満たさなければ、本文を読む前に拒否します。
  2. honeypot。 フォームには、人には見えない欄 gnw_hp_x があります。Worker は、この欄が埋まっているかを記録して先へ進みます。埋まっていても応答は変わりません。手順 5 でスパムの疑いの印が付くだけです。
  3. Turnstile。 Worker がトークンを Turnstile の siteverify API に送り、success、hostname、action を確認します。失敗したら 400 を返し、何も保存しません。
  4. 回数制限。 Rate Limiting binding が、ip_hash ごとにリクエストを数えます。上限を超えたら 429 を返します。
  5. 入力の検証とスパムスコア。
    • zod でメールアドレスと同意を確認します。
    • フォームの項目は、D1 にある現在のフォームの定義から作った zod のスキーマで確認します。ブラウザから送られた定義は使いません。定義にない項目や、リクエスト本文の想定外のキーは拒否します。
    • 訪問者が古いバージョンのフォームを開いていたら、409 form_outdated を返します。
    • 簡単なルールでスパムスコアを足します。スコアが 3 以上か、honeypot が埋まっていれば、スパムの疑いの印を付けます。印が付いても、状態 unread(未読)で保存します。
  6. 保存してから応答。 直近 1 時間に保存された問い合わせが全体で 500 件以上のときは、何も保存せずに 503 contact_busy を返します。そうでなければ D1 に行を INSERT し、成功してから訪問者に 200 を返します。これより後の処理で、応答が変わることはありません。
  7. 応答の後(ctx.waitUntil の中)。
    • スパムの疑いがなく、Slack Webhook が設定されていれば、新しい問い合わせの通知として、サイト名、ページのパス、受信日時、管理画面の URL を送り、notified_at を記録します。
    • 前回の削除から 1 時間以上たっていれば、保持期間(既定は 180 日)を過ぎた問い合わせと、期限切れの Passkey のチャレンジとセッションを削除します。

問い合わせ API はフェイルクローズです。次のどれかに当てはまると、API は 503 を返し、フォームには「お問い合わせフォームは準備中です」と表示します。

  • TURNSTILE_SECRET か TURNSTILE_SITE_KEY が設定されていない。
  • APP_SECRET がないか、32 文字より短いか、明らかにランダムでなく、IP_HASH_SECRET も設定されていない。設定した IP_HASH_SECRET が短すぎる場合や、明らかにランダムでない場合も止まります。
  • Rate Limiting binding がない。

設定は Turnstile を設定するを参照してください。

コードでは

Webサイトの Worker は 3 行です。既定の言語を、@goodnight-web/server のファクトリー関数に渡すだけです。

templates/starter/src/worker.ts
import { createGoodnightWorker } from '@goodnight-web/server';
import { siteConfig } from '../site.config';

export default createGoodnightWorker({ locale: siteConfig.defaultLocale });

wrangler.jsonc では、API と管理画面のパスだけを Worker に送ります。ほかのパスは、Worker のコードを動かさずに Static Assets が返します。

templates/starter/wrangler.jsonc
"assets": {
  "directory": "./dist",
  "binding": "ASSETS",
  "not_found_handling": "404-page",
  // The contact API and the admin screen run in the Worker. Every other
  // path is a page from ./dist.
  "run_worker_first": ["/api/*", "/admin", "/admin/*"],
},

ファクトリーは Hono アプリを 1 つ作ります。最初にセキュリティヘッダー、HTTPS、Cloudflare Access のミドルウェアが動き、続いて機能ごとのルートが動きます。どのルートにも一致しないリクエストは、ASSETS binding に渡ります。

packages/server/src/app.ts
app.use('*', securityHeaders);
// The admin UI and /api/*: HTTPS only (except on localhost). Then the
// admin UI, admin API, setup, and Better Auth: Cloudflare Access when
// ACCESS_AUD and ACCESS_TEAM_DOMAIN are set.
app.use('*', requireHttps);
app.use('*', requireAccess);
app.route('/api/contact', contactRoutes);
app.route('/api/setup', setupRoutes);
app.route('/api/auth', authRoutes);
app.route('/api/admin', adminRoutes);
app.all('/api/*', (c) => apiError(c, 404, 'not_found'));
app.on(['GET', 'HEAD'], ['/admin', '/admin/*'], (c) =>
  serveAdminFile(c.req.raw),
);

問い合わせ、セットアップ、認証、管理 API、管理画面のコードは、packages/server/src/features/ の下で、機能ごとのフォルダーに分かれています。

シークレットと binding

名前種類用途
DBD1 binding保存するすべてのデータ
ASSETSStatic Assets bindingAstro でビルドしたページ
CONTACT_RATE_LIMITERRate Limiting bindingip_hash ごとの回数制限(問い合わせ、セットアップ、Passkey でのログイン)
TURNSTILE_SITE_KEY変数公開してよい Turnstile のサイトキー。wrangler.jsonc に書きます。ないと問い合わせ API は止まります。
APP_SECRETシークレット最初の管理者を作るときのセットアップトークンで、セッションの鍵と IP アドレスのハッシュの鍵の元にもなります。32 文字以上。セットアップ後も残します。
ADMIN_EMAILSシークレットまたは変数管理者になれるメールアドレス
TURNSTILE_SECRETシークレットTurnstile のシークレットキー。siteverify で使います。ないと問い合わせ API は止まります。
SLACK_WEBHOOK_URLシークレット(任意)新しい問い合わせの通知
ACCESS_AUD変数(任意)Cloudflare Access アプリケーションの AUD タグ。ACCESS_TEAM_DOMAIN と一緒に設定します。
ACCESS_TEAM_DOMAIN変数(任意)Cloudflare Access のチームドメイン。たとえば your-team.cloudflareaccess.com。
SETUP_TOKENシークレット(任意)個別のセットアップトークン。設定すると、セットアップではこの値だけを受け付けます。セットアップ後に削除します。
BETTER_AUTH_SECRETシークレット(任意)個別の Better Auth の署名用の鍵。APP_SECRET から作る鍵の代わりに使います。
IP_HASH_SECRETシークレット(任意)HMAC-SHA256(ip, key) の個別の鍵。APP_SECRET から作る鍵の代わりに使います。

BETTER_AUTH_SECRET か IP_HASH_SECRET が設定されていないときは、Worker が Web Crypto の HKDF を使い、用途ごとに異なる決まったラベルで、その鍵を APP_SECRET から作ります。32 文字より短いシークレットや、明らかにランダムでないシークレット(同じ文字の繰り返しなど)はないものとして扱い、それを使う機能を止めます。設定した値が短すぎる場合も、APP_SECRET から作った鍵には置き換えません。シークレットは Worker の中だけで使います。ブラウザに返すことも、D1 に書くこともありません。

/admin、/admin/* と、問い合わせ API を含む /api/ の下のすべてのパスは、https:// でだけ動きます(localhost を除く)。セキュリティモデルを参照してください。

スタイルとフォント

Web フォントは、Webサイト、管理画面、このドキュメントサイトのどこでも読み込みません。文字は、訪問者の端末にあるフォントで表示されます。たとえば macOS と iOS ではヒラギノ、Windows ではメイリオや游ゴシックです。

3 つとも、システムフォントの並びを CSS のトークンとして 1 か所で決めています。フォントのファイルも配信サービスも使わないので、Content Security Policy にフォント用のホストはありません。

starter は、独自のデザイントークン(CSS 変数)を持ちます。テーマの 3 色は site.config.ts の theme.primary、theme.background、theme.foreground から取り、CSS 変数として <html> に設定します。

ほかの色合いは、この 3 色を混ぜて作ります。3 つの値を変えれば、サイト全体の色が変わります。

templates/starter/src/styles/global.css
:root {
  --site-primary: #2f6b4e;
  --site-background: #fbfaf5;
  --site-foreground: #253229;
}

@theme inline {
  /* Fonts: the visitor's system fonts. The site loads no web fonts. */
  --font-sans:
    system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Hiragino Sans',
    'Hiragino Kaku Gothic ProN', 'Noto Sans JP', 'Yu Gothic UI', Meiryo,
    sans-serif;

  --color-background: var(--site-background);
  --color-foreground: var(--site-foreground);

管理画面は、ライトとダークのモードを含む独自のトークンを @goodnight-web/server の中に持っています。サイトの色を変えても、管理画面の色は変わりません。

関連ページ

目次