📯

【③ セッション】SvelteKit on Cloudflareでお問い合わせフォームをつくる

2024/11/13に公開

私たちは都内を中心に活動しているアマチュアオーケストラの Orchestra Canvas Tokyo です。

弊団のホームページ、ブログのリファクタリングにおいてできた、お問い合わせフォーム実装に関する知見をまとめた本シリーズ。
今回は SvelteKit x Cloudflare KV でセッションを実装します!


このシリーズの記事一覧

  1. ① サイト作成:SvelteKit x Cloudflare Pages
  2. ② フォーム作成:SvelteKit x Zod x Google reCAPTCHA v3
  3. ③ セッション:SvelteKit x Cloudflare KV ← 今回の記事
  4. ④ データベース:SvelteKit x Cloudflare D1
  5. ⑤ メール送信:SvelteKit x Resend

このシリーズで作成したお問い合わせフォームはこちら。
https://github.com/horn553/zenn-contact-form


はじめに

お問い合わせフォームには、セッションを用意することが望ましいです。
CSRF 対策のためです。

IPA のガイドを参考に、CSRF トークンを発行し、検証するように実装します。

参考:安全なウェブサイトの作り方 - 1.6 CSRF(クロスサイト・リクエスト・フォージェリ) | 情報セキュリティ | IPA 独立行政法人 情報処理推進機構

技術選定

今回は、シンプルなセッション機能があれば十分です。

svelte-kit-sessions パッケージを用いることとします。
このパッケージは、Cloudflare が提供する Key-Value ストレージである Cloudflare KV をサポートする(サブ)パッケージも開発されています。

Cloudflare Pages x KV

Cloudflare Pages からも Pages Functions という機能を用いて Cloudflare KV などを呼び出すことができます。
Cloudflare Workers で Cloudflare KV を呼び出すのと似たイメージです。

やや設定は異なるようですが、「Cloudflare Workers でできることは Pages でもできる」ということです。
これは大変便利ですね!

費用

Cloudflare KV の無料枠は次の通りです。
参考︰Pricing - Cloudflare Workers KV

  • 読み取り上限: 100,000 回/日
  • 書き込み、削除、リスト上限: 1,000 回/日
  • 容量上限: 1GB

Cloudflare Pages Functions は Workers と枠が同様です。無料枠は次の通りです。
参考:Pricing - Cloudflare Workers

  • リクエスト上限: 100,000 件/日
  • CPU 時間上限: 10ms/呼び出し

今回のような、シンプルかつ頻用されないアプリケーションであれば、問題なく使える水準ではないでしょうか。

環境構築

依存関係のインストール

まずは依存関係をインストールします。

npm i svelte-kit-sessions svelte-kit-connect-cloudflare-kv

Wrangler の設定

Cloudflare Pages Functions の設定は Wrangler という CLI ツールを用いて行います。
これも Workers と一緒です。

グローバルインストールしてもいいですが、ここではnpx コマンドで実行します。
(好みの問題です)

ログインのうえ、現在の設定をダウンロードします。
参考:Commands - Wrangler

npx wrangler login
npx wrangler pages download config zenn-contact-form

そうすると、ルートに wrangler.toml が作成されます。

/wrangler.toml
# Generated by Wrangler on Fri Nov 08 2024 18:04:42 GMT+0900 (日本標準時)
name = "zenn-contact-form"
pages_build_output_dir = ".svelte-kit/cloudflare"
compatibility_date = "2024-11-05"

KV 名前空間の作成

Cloudflare ダッシュボード(GUI)で作成してもいいですが、ここでは Wrangler(CLI)で作成します。
また、この規模だとプレビュー環境と本番環境は同じでもよい気がしますが、ここではあえて異なる名前空間で管理する方法をまとめます。

攻めの姿勢です。

npx wrangler kv namespace create zenn-contact-form # (リモートの)プレビュー環境用
npx wrangler kv namespace create zenn-contact-form --preview # ローカル環境用
npx wrangler kv namespace create zenn-contact-form-production # プロダクション環境用

こうすると 3 つの id あるいは preview_id が生成されます。
それぞれを Wrangler の設定ファイルに反映させます。

キー binding の値は、SvelteKit から呼び出す際のキーの名称です。
プロジェクト内で一意であればよいので、KV とします。

/wrangler.toml
  # Generated by Wrangler on Fri Nov 08 2024 18:04:42 GMT+0900 (日本標準時)
  name = "zenn-contact-form"
  pages_build_output_dir = ".svelte-kit/cloudflare"
  compatibility_date = "2024-11-05"
+
+ # ローカル環境
+ [[kv_namespaces]]
+ binding = "KV"
+ id = "6653db06adad47b39dd539b60abe5e6c"
+ preview_id = "6653db06adad47b39dd539b60abe5e6c"
+
+ # プレビュー環境
+ [[env.preview.kv_namespaces]]
+ binding = "KV"
+ id = "95ae2b54ae2f4977a23f144e2eb575ef"
+
+ [[env.production.kv_namespaces]]
+ binding = "KV"
+ id = "353de8673ca54e078cfdc52b6735947c"

.gitignore の更新

Wrangler のキャッシュなどを ignore します。

/.gitignore
+ # wrangler
+ /.wrangler

SvelteKit の設定

KV がバインドされていることを明示します。

/src/app.d.ts
  // See https://kit.svelte.dev/docs/types#app
  // for information about these interfaces
  declare global {
    namespace App {
      // interface Error {}
      // interface Locals {}
      // interface PageData {}
      // interface PageState {}
-     // interface Platform {}
+     interface Platform {
+       env: {
+         KV: KVNamespace;
+       };
+     }
    }
  }

  export {};

環境構築は以上です!

サーバーサイドの実装

セッションの初期化

パッケージ svelte-kit-sessions のガイドに従い、hook としてセッションの初期化処理を記述します。
SvelteKit では、リクエストに関連するイベント処理を hooks と呼びます。

ここでは、hooks のうちリクエスト時に呼び出される handle を利用します。

/src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';
import { sveltekitSessionHandle } from 'svelte-kit-sessions';
import KvStore from 'svelte-kit-connect-cloudflare-kv';
import type { Store } from 'svelte-kit-sessions';

declare module 'svelte-kit-sessions' {
  interface SessionData {
    csrfToken: string;
  }
}

export const handle: Handle = async ({ event, resolve }) => {
  let sessionHandle: Handle | null = null;

  if (event.platform && event.platform.env) {
    // https://kit.svelte.dev/docs/adapter-cloudflare#bindings
    const store = new KvStore({ client: event.platform.env.KV }) as Store;
    sessionHandle = sveltekitSessionHandle({
      secret: 'secret',
      store
    });
  }

  return sessionHandle ? sessionHandle({ event, resolve }) : resolve(event);
};

CSRF トークンの生成

/contact へのリクエスト時、生成するようにします。

/src/routes/contact/+page.server.ts(抜粋)
- export const load: PageServerLoad = async () => {
+ export const load: PageServerLoad = async ({ locals }) => {
+   const { session } = locals;
+   await session.setData({
+     csrfToken: crypto.randomUUID()
+   });
+   await session.save();
+
    return {
      NAME_MAX_LENGTH,
      EMAIL_MAX_LENGTH,
      CATEGORY_OPTIONS,
-     BODY_MAX_LENGTH
+     BODY_MAX_LENGTH,
+     csrfToken: session.data.csrfToken
+   };
+ };

session.save() を実行することを忘れないようにしないといけません!(1 敗)

schema の更新

もちろん CSRF トークンはクライアントサイドからのリクエストボディに含まれます。

/src/routes/contact/schema.ts(抜粋)
  export const requestBodySchema = z.object({
    name: z.string().max(NAME_MAX_LENGTH),
    email: z.string().email().max(EMAIL_MAX_LENGTH),
    category: z.enum(categoryKeys),
    body: z.string().max(BODY_MAX_LENGTH),
+   csrfToken: z.string(),
    reCaptchaToken: z.string()
  });

CSRF トークンの検証

検証に関するロジックも記述しておきます。

/src/routes/contact/+page.server.ts(抜粋)
  export const actions = {
-   default: async ({ request }) => {
+   default: async ({ locals, request }) => {
+     const { session } = locals;
      const rawRequestBody = convertToObject(await request.formData());

      // バリデーションをかける
      const validationResult = requestBodySchema.safeParse(rawRequestBody);
      if (!validationResult.success) {
        return { success: false, message: 'Invalid request body' };
      }
      const requestBody = validationResult.data;
+
+     // CSRFトークンを検証
+     const csrfResult = requestBody.csrfToken === session.data.csrfToken;
+     if (!csrfResult) {
+       return { success: false, message: 'Invalid CSRF token' };
+     }

      // reCAPTCHAを検証
      const captchaResult = verifyCaptcha(requestBody.reCaptchaToken);
      if (!captchaResult) {
        return { success: false, message: 'Invalid CAPTCHA token' };
      }

      return { success: true };
    }
  } satisfies Actions;

クライアントサイドの実装

受け取ったトークンを、hidden な input に入れておくだけです。

/src/routes/conact/+page.svelte(抜粋)
    <label>
      本文
      <textarea name="body" maxLength={data.BODY_MAX_LENGTH} disabled={isSubmitting}></textarea>
    </label>
+   <input name="csrfToken" type="hidden" value={data.csrfToken} />
    <button type="submit" disabled={isSubmitting}>送信</button>

prerender の解除

セッションを利用しているため、prerender はできません。

/src/routes/+page.ts
- export const prerender = true;
/src/routes/about/+page.ts
- export const prerender = true;
/src/routes/sverdle/how-to-play/+page.ts
- export const prerender = true;
/src/routes/+layout.ts
+ export const prerender = false; // すべてのエンドポイントを一括指定

検証

ローカル環境

Cloudflare KV は Wrangler がローカルでエミュレートしてくれます。
そのため、SvelteKit が Vite を用いて実行している npm run dev は利用できません。

一度ビルドし、Wrangler に実行してもらう必要があります。

npm run build
npx wrangler pages dev ./.svelte-kit/cloudflare/

きちんと CSRF トークンがクライアントサイドに渡され、input に格納されています!

クライアントサイドとデベロッパーツールのスクリーンショット

送信すると、CSRF トークンの検証に成功し、success: true が返されたため、フォームが初期化されました。

プレビュー環境、プロダクション環境

これをデプロイすると、それぞれの環境でそれぞれの環境の KV に接続し動作します。
一方で、それぞれの環境の中では同一の KV に接続するため、過去のデプロイで作成・編集したデータを引き継ぎます。

Cloudflare ダッシュボードから KV のページに行き、各名前空間の内容を直接見て確認できます。
Wrangler 経由でも確認できます。

おわりに

たったこれだけで Key-Value ストレージ と接続できてしまう!なんと便利な時代なのでしょうか。

次回の Cloudflare D1 も基本的な連携方法は同じです。
ORM も交わる、より深く便利な世界へ進んでいきます!


  1. ① サイト作成:SvelteKit x Cloudflare Pages
  2. ② フォーム作成:SvelteKit x Zod x Google reCAPTCHA v3
  3. ③ セッション管理:SvelteKit x Cloudflare KV
  4. ④ データベース管理:SvelteKit x Cloudflare D1 ← 次の記事
  5. ⑤ メール送信:SvelteKit x Resend
GitHubで編集を提案
OCTテックブログ

Discussion