構成
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 で保護することもできます。
createGoodnightWorker()/api/contact/*- フォームの項目と送信
/api/setup- 最初の管理者だけ
/api/auth/*- Better Auth(Passkey)
/api/admin/*- 問い合わせの API
/admin/*- ビルド済みの管理画面
ctx.waitUntil部品
| 部品 | 役割 |
|---|---|
| Worker | Webサイトごとに 1 つ。Static Assets で静的なページを配信し、API と管理画面を動かします。 |
| Static Assets | Astro のビルド結果(/、/blog/、/contact/ など)。Worker のコードを動かさずに、Cloudflare が配信します。 |
| Hono アプリ | Worker の中で動く Web フレームワーク。@goodnight-web/server の createGoodnightWorker(options) で作ります。run_worker_first により、/api/*、/admin、/admin/* だけが届きます。 |
| D1 | SQLite のデータベース 1 つ。問い合わせ、設定、Better Auth のテーブルを保存します。マイグレーションは npm パッケージに入っています。 |
| Turnstile | Cloudflare のボット対策。問い合わせフォームで使い、Worker がトークンをサーバー側で検証します。 |
| Rate Limiting binding | Workers の 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 は、次の順に処理します。
- Origin とサイズ。 サイト自身のオリジンからのリクエストか、
Content-Typeが想定どおりか、32 KB 以下かを確認します。満たさなければ、本文を読む前に拒否します。 - honeypot。 フォームには、人には見えない欄
gnw_hp_xがあります。Worker は、この欄が埋まっているかを記録して先へ進みます。埋まっていても応答は変わりません。手順 5 でスパムの疑いの印が付くだけです。 - Turnstile。 Worker がトークンを Turnstile の siteverify API に送り、
success、hostname、actionを確認します。失敗したら 400 を返し、何も保存しません。 - 回数制限。 Rate Limiting binding が、
ip_hashごとにリクエストを数えます。上限を超えたら 429 を返します。 - 入力の検証とスパムスコア。
- zod でメールアドレスと同意を確認します。
- フォームの項目は、D1 にある現在のフォームの定義から作った zod のスキーマで確認します。ブラウザから送られた定義は使いません。定義にない項目や、リクエスト本文の想定外のキーは拒否します。
- 訪問者が古いバージョンのフォームを開いていたら、409
form_outdatedを返します。 - 簡単なルールでスパムスコアを足します。スコアが 3 以上か、honeypot が埋まっていれば、スパムの疑いの印を付けます。印が付いても、状態
unread(未読)で保存します。
- 保存してから応答。 直近 1 時間に保存された問い合わせが全体で 500 件以上のときは、何も保存せずに 503
contact_busyを返します。そうでなければ D1 に行を INSERT し、成功してから訪問者に 200 を返します。これより後の処理で、応答が変わることはありません。 - 応答の後(
ctx.waitUntilの中)。- スパムの疑いがなく、Slack Webhook が設定されていれば、新しい問い合わせの通知として、サイト名、ページのパス、受信日時、管理画面の URL を送り、
notified_atを記録します。 - 前回の削除から 1 時間以上たっていれば、保持期間(既定は 180 日)を過ぎた問い合わせと、期限切れの Passkey のチャレンジとセッションを削除します。
- スパムの疑いがなく、Slack Webhook が設定されていれば、新しい問い合わせの通知として、サイト名、ページのパス、受信日時、管理画面の URL を送り、
問い合わせ 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 のファクトリー関数に渡すだけです。
import { createGoodnightWorker } from '@goodnight-web/server';
import { siteConfig } from '../site.config';
export default createGoodnightWorker({ locale: siteConfig.defaultLocale });wrangler.jsonc では、API と管理画面のパスだけを Worker に送ります。ほかのパスは、Worker のコードを動かさずに Static Assets が返します。
"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 に渡ります。
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
| 名前 | 種類 | 用途 |
|---|---|---|
DB | D1 binding | 保存するすべてのデータ |
ASSETS | Static Assets binding | Astro でビルドしたページ |
CONTACT_RATE_LIMITER | Rate Limiting binding | ip_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 つの値を変えれば、サイト全体の色が変わります。
: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 の中に持っています。サイトの色を変えても、管理画面の色は変わりません。