虎の穴開発室ブログ

虎の穴ラボ株式会社所属のエンジニアが書く技術ブログです

MENU

Deno で掲示板サイトを作ろう! with upstash & supabase その 3 (投稿の登録 - バリデーションとサニタイズ)

皆さん、こんにちは。
年末最後の大きなお買い物は布団乾燥機でした。おっくんです。

今回は、「Deno で掲示板サイトを作ろう! with upstash & supabase」企画の 3 回目として、前回に引き続き掲示板への投稿の登録本体の実装を行うとともに、バリデーションとサニタイズを導入します。

前回記事はこちら

toranoana-lab.hatenablog.com

実装

掲示板の詳細を作る

ダイナミックルーティング導入

現在、掲示板のトピックの登録と一覧の表示ができています。
このページからのリンク先として、投稿一覧を作成します。

Fresh は、routes 以下にファイルを作っていくことでルーティングが作成されます。
また、[pageId].tsx のような名前でファイルを作っていくことでダイナミックルーティングが使用できます。
これから作るのは、掲示板のトピックに紐づいたページの作成になるので、routes/topics/[pageId].tsx を作成してダイナミックルーティングを使用します。

手始めに、次のようにファイルを作成します。

[routes/topics/[topicId].tsx]

import { PageProps } from "$fresh/server.ts";

export default function Topic(props: PageProps) {
  return <div class="p-2">投稿詳細 {props.params.TopicId}</div>;
}

再度読み込むと次のように動作します。

ルーティングに問題がないことが確認出来たら、ページの詳細を作り進めます。

掲示板の詳細 - 投稿一覧

このページでは、掲示板内の投稿一覧と投稿を登録する機能を持たせます。

掲示板内の投稿の一覧を表示するのは、GET リクエストで処理し、投稿の登録は POST リクエストで処理します。

ここでは、投稿の一覧表示を実装します。
先の routes/topics/[topicId].tsx を編集します。
編集結果は以下の通りです。

[routes/topics/[topicId].tsx(変更)]

import { HandlerContext, Handlers, PageProps } from "$fresh/server.ts";
import { envConfig } from "../../util/config.ts";
import { WithSession } from "fresh_session/mod.ts";

interface Post {
  id: number
  comment: string;
}

interface Topic{
  title: string
}

interface TopicResource {
  topic?: Topic;
  posts?: Post[];
  isSuccess: boolean;
}

export const handler: Handlers = {
  async GET(req: Request, ctx: HandlerContext<WithSession>) {
    const result = await fetch(
      `${envConfig.SUPABASE_EDGE_FUNCTION_END_POINT}/topics/${ctx.params.topicId}`,
      {
        headers: {
          Authorization: `Bearer ${envConfig.SUPABASE_ANON_KEY}`,
          "Content-Type": "application/json",
        },
      },
    );

    if (!result.ok) {
      return ctx.render({
        isSuccess: false,
      });
    }

    const resultJson = await result.json();

    if (!resultJson.topic) {
      return ctx.render({
        isSuccess: false,
      });
    }

    const topicData: TopicResource = {
      ...resultJson,
      isSuccess: true,
    };

    return ctx.render(topicData);
  },
};


export default function Topic(props: PageProps<TopicResource>) {
  return (
    <div class="p-2">
      {props.data.isSuccess
        ? (
          <div>
            <div class="flex justify-between w-full mb-2 border-rl-4 border-indigo-200 bg-gray-50 p-2 font-large text-center rounded">
              <div class="w-full">
                {props.data.topic?.title}
              </div>
            </div>

            {props.data.posts?.map((post) => (
              <div
                class="w-full mb-2 border-l-4 border-gray-400 bg-gray-50 p-4 font-medium rounded"
                key={post.id}
                id={post.id.toString()}
              >
                <div>
                  <p class="text-9x1 text-gray-600 break-all">{post.comment}</p>
                </div>
              </div>
            ))}
          </div>
        )
        : (
          <div>
            <div class="flex justify-between w-full mb-2 border-rl-4 border-red-600 bg-gray-50 p-2 font-large text-center rounded">
              <div class="justify-center w-full">
                掲示板を取得できませんでした
              </div>
            </div>
          </div>
        )}
    </div>
  );
}

これに合わせて、supabase Edge Functions で用意する API も増やします。

[supabase/functions/board_api/index.ts(変更)]

// 既存APIは省略

router.get("/board_api/topics/:id", async (ctx) => {
  const topic = await supabaseClient
    .from("topics")
    .select("title")
    .eq("id", ctx.params.id)
    .limit(1)
    .single();

  if (topic.error) {
    console.error(topic.error);
    throw new Error();
  }

  const posts = await supabaseClient
    .from("posts")
    .select("id, comment")
    .eq("topic_id", ctx.params.id);

  if (posts.error) {
    console.error(posts.error);
    throw new Error();
  }

  return { topic: topic.data, posts: posts.data };
});

await serve((req) => {
  console.info(req);
  return router.handle(req);
});

現在まだ、投稿機能は作っていませんので、supabase Studio の Table Editor で適当なデータを登録しておきます。
(確認後は、削除を行ってください。登録の仕方次第で、データの登録操作の確認の際に、プライマリキーが重複しているというエラーが起きる可能性があります。)

supabase edge functions の再起動、再度読み込みを行うと次のように表示されます。

掲示板の詳細 - 投稿の登録

投稿の一覧が参照できるようになったので、続けて投稿自体を登録する機能を作ります。

これまで同様に、routes/topics/[topicId].tsx と、supabase/functions/board_api/index.ts を編集します。

実装物は次の通りです。

[routes/topics/[topicId].tsx(変更)]

import { HandlerContext, Handlers, PageProps } from "$fresh/server.ts";
import { envConfig } from "../../util/config.ts";
import { WithSession } from "fresh_session/mod.ts";

interface Post {
  id: number;
  comment: string;
}
interface Topic {
  title: string;
}

interface TopicResource {
  topic?: Topic;
  posts?: Post[];
  isSuccess: boolean;
  tokenStr?: string;
}

export const handler: Handlers = {
  async POST(req: Request, ctx: HandlerContext<WithSession>) {
    const form = await req.formData();
    const comment = form.get("comment");

    if (typeof comment !== "string") {
      return new Response("", {
        status: 303,
        headers: { Location: req.headers.get("referer") || "/" },
      });
    }

    const result = await fetch(
      `${envConfig.SUPABASE_EDGE_FUNCTION_END_POINT}/posts`,
      {
        method: "POST",
        headers: {
          Authorization: `Bearer ${envConfig.SUPABASE_ANON_KEY}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          comment: comment,
          topic_id: ctx.params.topicId,
        }),
      }
    );

    if (!result.ok) {
      return new Response("", {
        status: 303,
        headers: { Location: `${new URL(req.url).pathname}` },
      });
    }

    const post = await result.json();

    if (!post.id || typeof post.id !== "number") {
      return new Response("", {
        status: 303,
        headers: { Location: `${new URL(req.url).pathname}` },
      });
    }

    return new Response("", {
      status: 303,
      headers: { Location: `${new URL(req.url).pathname}#${post.id}` },
    });
  },
  async GET(req: Request, ctx: HandlerContext<WithSession>) {
    const { session } = ctx.state;

    const result = await fetch(
      `${envConfig.SUPABASE_EDGE_FUNCTION_END_POINT}/topics/${ctx.params.topicId}`,
      {
        headers: {
          Authorization: `Bearer ${envConfig.SUPABASE_ANON_KEY}`,
          "Content-Type": "application/json",
        },
      }
    );

    if (!result.ok) {
      return ctx.render({
        isSuccess: false,
      });
    }

    const resultJson = await result.json();

    if (!resultJson.topic) {
      return ctx.render({
        isSuccess: false,
      });
    }

    const topicData: TopicResource = {
      ...resultJson,
      isSuccess: true,
      tokenStr: session.get("csrf").tokenStr,
    };

    return ctx.render(topicData);
  },
};

export default function Topic(props: PageProps<TopicResource>) {
  return (
    <div class="p-2">
      {props.data.isSuccess ? (
        <div>
          <div class="flex justify-between w-full mb-2 border-rl-4 border-indigo-200 bg-gray-50 p-2 font-large text-center rounded">
            <div class="w-full">{props.data.topic?.title}</div>
          </div>
          <div class="mb-2">
            <form method="POST" action={`/topics/${props.params.topicId}`}>
              <input
                type="text"
                name="comment"
                placeholder="いまどうしてる?"
                class="w-full mb-1 rounded h-12 text-lg text-center"
              />
              <input
                type="hidden"
                name="csrfToken"
                value={props.data.tokenStr}
              />
              <button class="bg-indigo-400 w-full rounded py-2 text-white">
                登録
              </button>
            </form>
          </div>

          {props.data.posts?.map((post) => (
            <div
              class="w-full mb-2 border-l-4 border-gray-400 bg-gray-50 p-4 font-medium rounded"
              key={post.id}
              id={post.id.toString()}
            >
              <div>
                <p class="text-9x1 text-gray-600 break-all">{post.comment}</p>
              </div>
            </div>
          ))}
        </div>
      ) : (
        <div>
          <div class="flex justify-between w-full mb-2 border-rl-4 border-red-600 bg-gray-50 p-2 font-large text-center rounded">
            <div class="justify-center w-full">
              掲示板を取得できませんでした
            </div>
          </div>
        </div>
      )}
    </div>
  );
}

フォームには、前回実装したトークンの埋め込みを行っています。
リロードした時に、同じ内容を POST するか?という確認はしてほしくないので、ユーザーは必ず GET のページへリダイレクトさせています。
この動作は、投稿の内容の如何に問わず統一させています。

投稿の登録には、また API の追加が必要なので、こちらも追加で実装を行っています。

[supabase/functions/board_api/index.ts(変更)]

// 既存APIは省略

router.post("/board_api/posts", async (ctx) => {
  const params = (await ctx.body()) as {
    comment: string;
    topic_id: number;
  };

  const { data, error } = await supabaseClient.from("posts").insert([
    params,
  ]).single();

  if (error) {
    console.error(error);
    throw new Error();
  }

  return { ...data };
});

await serve(
  (req) => {
    console.info(req);
    return router.handle(req);
  },
);

ここまでできたら、supabase edge functions の再起動、再度読み込みを行うと次のように動作します。

ユーザー入力のバリデーションと、サニタイズをしてみよう

ここまで、ユーザーから任意の文字列を受け付ける形で、これといったチェックをしていませんでした。

今回は、Zod を使用し、文字数のチェックを行います。
併せてサニタイジングに ammonia の wasm バインディングの ammonia-wasm を使用します。

github.com

github.com

Zod を導入

Zod は、npm だけでなく deno.land/x からも取得できるので、こちらから導入します。
import-map に以下の記述を追加します。

[import_map.json(変更)]

{
  "imports": {
    "zod": "https://deno.land/x/zod@v3.20.0" // <= 追加
  }
}

続けて、util/zod_validate.ts にバリデーションの定義を用意します。

[util/zod_validate.ts(新規)]

import { z } from "zod";

// 最小1文字、最大140文字の文字列のスキーマを定義、次のテスト動作確認では、.max(10) と一旦書き換えています。
const userInputCommentSchema = z.string().min(1).max(140); 

export function validateUserInputComment(src: unknown) {
  return userInputCommentSchema.safeParse(src);
}

こちらを導入した実装は、次の様になります。

[routes/topics/[topicId].tsx(変更)]

import { HandlerContext, Handlers, PageProps } from "$fresh/server.ts";
import { envConfig } from "../../util/config.ts";
import { WithSession } from "fresh_session/mod.ts";
// 作成したバリデーションの読み込み
import { validateUserInputComment } from "../../util/zod_validate.ts";

interface Post {
  id: number;
  comment: string;
}
interface Topic {
  title: string;
}

interface TopicResource {
  topic?: Topic;
  posts?: Post[];
  isSuccess: boolean;
  tokenStr?: string;
  errorMessage?: string;
}

export const handler: Handlers = {
  async POST(req: Request, ctx: HandlerContext<WithSession>) {
    const { session } = ctx.state;
    const form = await req.formData();
    const comment = form.get("comment");

    if (typeof comment !== "string") {
      return new Response("", {
        status: 303,
        headers: { Location: req.headers.get("referer") || "/" },
      });
    }

    // バリデーションの実行
    const validateResult = validateUserInputComment(comment);

    if (!validateResult.success) {
      // バリデーションに失敗した場合は、フラッシュメッセージを設定してリダイレクト
      session.flash("errorMessage", "入力が不適切です");
      return new Response("", {
        status: 303,
        headers: { Location: req.headers.get("referer") || "/" },
      });
    }

    const result = await fetch(
      `${envConfig.SUPABASE_EDGE_FUNCTION_END_POINT}/posts`,
      {
        method: "POST",
        headers: {
          Authorization: `Bearer ${envConfig.SUPABASE_ANON_KEY}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          comment: validateResult.data,
          topic_id: ctx.params.topicId,
        }),
      }
    );

    if (!result.ok) {
      return new Response("", {
        status: 303,
        headers: { Location: `${new URL(req.url).pathname}` },
      });
    }

    const post = await result.json();

    if (!post.id || typeof post.id !== "number") {
      return new Response("", {
        status: 303,
        headers: { Location: `${new URL(req.url).pathname}` },
      });
    }

    return new Response("", {
      status: 303,
      headers: { Location: `${new URL(req.url).pathname}#${post.id}` },
    });
  },
  async GET(req: Request, ctx: HandlerContext<WithSession>) {
    const { session } = ctx.state;

    const result = await fetch(
      `${envConfig.SUPABASE_EDGE_FUNCTION_END_POINT}/topics/${ctx.params.topicId}`,
      {
        headers: {
          Authorization: `Bearer ${envConfig.SUPABASE_ANON_KEY}`,
          "Content-Type": "application/json",
        },
      }
    );

    if (!result.ok) {
      return ctx.render({
        isSuccess: false,
      });
    }

    const resultJson = await result.json();

    if (!resultJson.topic) {
      return ctx.render({
        isSuccess: false,
      });
    }

    const topicData: TopicResource = {
      ...resultJson,
      isSuccess: true,
      tokenStr: session.get("csrf").tokenStr,
      // フラッシュメッセージの読み込み
      errorMessage: session.flash("errorMessage"),
    };

    return ctx.render(topicData);
  },
};

export default function Topic(props: PageProps<TopicResource>) {
  return (
    <div class="p-2">
      {props.data.isSuccess ? (
        <div>
          <div class="flex justify-between w-full mb-2 border-rl-4 border-indigo-200 bg-gray-50 p-2 font-large text-center rounded">
            <div class="w-full">{props.data.topic?.title}</div>
          </div>
          <div class="mb-2">
            {/* フラッシュメッセージの表示処理 */}
            {props.data.errorMessage ? (
              <div class="flex justify-between w-full mb-2 border-rl-4 border-red-500 text-red-400 bg-red-100 p-2 font-large text-center rounded">
                <div class="w-full">{props.data.errorMessage}</div>
              </div>
            ) : (
              ""
            )}
            <form method="POST" action={`/topics/${props.params.topicId}`}>
              <input
                type="text"
                name="comment"
                placeholder="いまどうしてる?"
                class="w-full mb-1 rounded h-12 text-lg text-center"
              />
              <input
                type="hidden"
                name="csrfToken"
                value={props.data.tokenStr}
              />
              <button class="bg-indigo-400 w-full rounded py-2 text-white">
                登録
              </button>
            </form>
          </div>

          {props.data.posts?.map((post) => (
            <div
              class="w-full mb-2 border-l-4 border-gray-400 bg-gray-50 p-4 font-medium rounded"
              key={post.id}
              id={post.id.toString()}
            >
              <div>
                <p class="text-9x1 text-gray-600 break-all">{post.comment}</p>
              </div>
            </div>
          ))}
        </div>
      ) : (
        <div>
          <div class="flex justify-between w-full mb-2 border-rl-4 border-red-600 bg-gray-50 p-2 font-lerge text-center rounded">
            <div class="justify-center w-full">
              掲示板を取得できませんでした
            </div>
          </div>
        </div>
      )}
    </div>
  );
}

ここまでできたら、supabase edge functions の再起動、再度読み込みを行うと次のように動作します。
確認に当たって、入力で受け取る最大文字列長は、一旦 10 文字にしています。

文字列長が超過しているので、「入力が不適切です」というメッセージが表示されました。
入力文字列のバリデーションができました。

ammonia の導入

引き続き、サニタイズ処理を導入します。
先と同じように、import_map.json を編集し、ammonia を導入します。

[import_map.json(変更)]

{
  "imports": {
    "ammonia": "https://deno.land/x/ammonia@0.3.1/mod.ts" // <= 追加
  }
}

ammonia は使用前に呼び出す処理がいくつかあるので、こちらも utils に切り出し、サニタイズ処理する関数だけ export してしまいます。

[util/html_sanitizer.ts(新規)]

import * as ammonia from "ammonia";
await ammonia.init();

export function sanitize(text: string) {
  return ammonia.clean(text);
}

ammonia の用意ができたので、こちらを導入します。

[routes/topics/[topicId].tsx(変更/抜粋)]

import { HandlerContext, Handlers, PageProps } from "$fresh/server.ts";
import { envConfig } from "../../util/config.ts";
import { WithSession } from "fresh_session/mod.ts";
import { validateUserInputComment } from "../../util/zod_validate.ts";
import { sanitize } from "../../util/html_sanitizer.ts";

export const handler: Handlers = {
  async POST(req: Request, ctx: HandlerContext<WithSession>) {
    const { session } = ctx.state;
    const form = await req.formData();
    const comment = form.get("comment");

    if (typeof comment !== "string") {
      return new Response("", {
        status: 303,
        headers: { Location: req.headers.get("referer") || "/" },
      });
    }

    // サニタイズ処理を追加
    const sanitizedComment = sanitize(comment);

    const validateResult = validateUserInputComment(sanitizedComment);

    if (!validateResult.success) {
      session.flash("errorMessage", "入力が不適切です");
      return new Response("", {
        status: 303,
        headers: { Location: req.headers.get("referer") || "/" },
      });
    }

    // 省略

    return new Response("", {
      status: 303,
      headers: { Location: `${new URL(req.url).pathname}#${post.id}` },
    });
  },
  async GET(req: Request, ctx: HandlerContext<WithSession>) {
    // 省略
  }
};

export default function Topic(props: PageProps<TopicResource>) {
  return (
    /* 省略 */
  )
}

ここまでできたら、supabase edge functions の再起動、再度読み込みを行うと次のように動作します。

ammonia の README にも記載がある XSS に当たる、XSS<script>attack</script> を入力すると、XSS の部分だけが登録できています。

本記事では掲載を省略しますが、掲示板の登録にもバリデーションとサニタイズ処理を追加しておきます。

ソースを整理

これまで作成した次の2つのファイルには、同じ型定義がそれぞれ書かれています。

  • routes/topics.tsx
  • routes/topics/[topicId].tsx

interfaces.ts を次の様に作成して、型定義をまとめておきます。

[interfaces.ts]

export interface Post {
  id: number;
  comment: string;
}
export interface Topic {
  id: number;
  title: string;
}

export interface TopicResource {
  topic?: Topic;
  posts?: Post[];
  isSuccess: boolean;
  tokenStr?: string;
  errorMessage?: string;
}

type Topics = Topic[];

export interface TopicsResource {
  topics: Topics;
  tokenStr: string;
  errorMessage: string;
}

これに伴い、routes/topics.tsx, routes/topics/[topicId].tsx の2つのファイルを修正しています。


今回は、掲示板への投稿の登録を実装するとともに、バリデーションとサニタイズを導入しました。
前回も「掲示板の作成」をしていますので、おさらいにあたる内容とその拡張にあたる内容でした。
Freshは、ファイルシステムルーティングが導入されるとともに、ハンドラで GET/POST リクエストの振り分けを容易に記述できます。
その特徴も感じられる内容になっていれば幸いです。

基本的な投稿に関する実装は、今回で終わる見込みです。
次回は、Twitter 連携機能、もしくは一旦 Deno Deploy と supabase へのデプロイを取り扱う予定です。

今回進めた内容は、以下のリポジトリに上げていますので全体感を確認したい場合はこちらをご参考ください。

github.com

P.S.

採用

虎の穴では一緒に働く仲間を募集中です!
この記事を読んで、興味を持っていただけた方はぜひ弊社の採用情報をご覧下さい。
yumenosora.co.jp

LINEスタンプ

エンジニア専用のメイドちゃんスタンプが完成しました!
「あの場面」で思わず使いたくなるようなスタンプから、日常で役立つスタンプを合計40個用意しました。
エンジニアの皆さん、エンジニアでない方もぜひスタンプを確認してみてください。 store.line.me