皆さん、こんにちは。 自宅では、トラドラオニタイジン極がご本尊みたいになっています。おっくんです。
今回は、「Deno で掲示板サイトを作ろう! with upstash & supabase」企画の2回目として、掲示板の登録と参照の実装を進めていきます。
今回の実装で、次のように、掲示板の登録ができるようになります。
前回記事はこちら
訂正
始めに、第1回で取り扱った環境変数の取り扱いについて、一部訂正をさせていただきます。
第1回に紹介した、以下のdotenvの実装がありました。
[anonymous-board/util/config.ts]
import { config } from "dotenv/mod.ts"; export const envConfig = await config({ safe: true });
こちらの実装が Deno CLI では動作しますが、Deno Deploy 環境では動作しないのを確認しました。 次のように実装を訂正いたします。
[util/config.ts(変更)]
import "dotenv/load.ts"; const envConfig = { SUPABASE_EDGE_FUNCTION_END_POINT: Deno.env.get( "SUPABASE_EDGE_FUNCTION_END_POINT", )!, SUPABASE_ANON_KEY: Deno.env.get("SUPABASE_ANON_KEY")!, }; export { envConfig };
実装
今回行った実装は、GitHub にて公開中です。先にソースを見たい場合はこちらをどうぞ。先の訂正も反映されています。
登録機能、その前に
Fresh では、routes ディレクトリ以下に、_middleware.ts を置き export することで各リクエストについて、処理本体の前後に処理を登録することができます。
公式のドキュメントはこちら。
FRESH docks - 3. Concepts - 3.5. Route middleware
今回実装する掲示板では、Twitter連携をしたユーザーだけが掲示板の登録と個別の投稿を可能にする予定です。 その際のセッション管理をさせたいので、ミドルウェアとしてその機能を先に入れておきます。 またこの機能を利用して、CSRF対策モジュールも埋め込みます。
後からミドルウェアの定義も含め _middleware.ts ですべて作り込むと肥大化していくので、切り分けながら実装します。 手始めに、middleware ディレクトリを用意し、session.tsを作成します。こちらで、fresh-session を利用した実装を用意します。session.ts 含めた必要な実装は次の通りです。
[anonymous-board/util/config.ts(変更)]
import "dotenv/load.ts"; const envConfig = { SUPABASE_EDGE_FUNCTION_END_POINT: Deno.env.get( "SUPABASE_EDGE_FUNCTION_END_POINT", )!, SUPABASE_ANON_KEY: Deno.env.get("SUPABASE_ANON_KEY")!, SESSION_SECONDS: Deno.env.get("SESSION_SECONDS")!, // <= 追記 }; export { envConfig };
[anonymous-board/import_map.jsonc(追記)]
{ "imports": { "redis/": "https://deno.land/x/redis@v0.25.0/", // <=追記 "fresh_session/": "https://deno.land/x/fresh_session@0.2.0/" // <=追記 } }
import_map.jsonc で、redis と fresh_session モジュールを定義しておきます。
[anonymous-board/routes/_middleware.ts(新規)]
import { sessionHandler } from "../middlewares/session.ts"; export const handler = [ sessionHandler ];
_middleware.ts から handler で export された関数群が実行の対象になります。 sessionHandler の定義も含め記述もできますが、肥大化するので以下のファイルに分離しています。
[anonymous-board/middleware/session.ts(新規)]
import { MiddlewareHandlerContext } from "$fresh/server.ts"; import { WithSession } from "fresh_session/mod.ts"; import { redisSession } from "../util/redis_session.ts"; export type State = WithSession; export function sessionHandler( req: Request, ctx: MiddlewareHandlerContext<State>, ) { return redisSession(req, ctx); }
[anonymous-board/util/redis_session.ts(新規)]
import { redisSession as redisSessionModule, } from "fresh_session/mod.ts"; import { redisConnect } from "./redis.ts"; import { envConfig } from "./config.ts"; const redis = await redisConnect; export const redisSession = redisSessionModule(redis, { maxAge: envConfig.SESSION_SECONDS, // .envで SESSION_SECONDS=3600 を設定しています path: "/", httpOnly: true, });
こちらが redis セッションの定義などの設定になっています。 今回は切り分けていますが、middleware/session.ts にまとめて定義でも良かったかもしれません。
[anonymous-board/util/redis.ts(新規)]
import { connect } from "redis/mod.ts"; export const redisConnect = (() => { return connect({ hostname: "redis", port: 6379, }); // 後で Deno Deploy 環境で Redisを使う際のモジュール切り替えを行うので // 即時関数の処理結果としてクライアントを返すようにしておきます。 })();
コメントにも有りますが、Deno Deploy 環境では、redisサーバーの直接使用ではなく upstash redis を使用予定です。 そのため使用モジュールの切り替えを見越して1枚即時関数で包んでいます。
この状態で、Fresh を起動して適当なページにアクセスして開発者ツールのcookiesを見てみましょう。
sessionId キーが登録されているのが確認できます。
セッションが使用できるようになったので、CSRFの対策モジュールも埋め込む
実は、本題はこちらです。 登録機能を作っていくにあたり、CSRF対策のためのトークンの管理をミドルウェアとして実装します。
[anonymous-board/util/config.ts(変更)]
import "dotenv/load.ts"; const envConfig = { SUPABASE_EDGE_FUNCTION_END_POINT: Deno.env.get( "SUPABASE_EDGE_FUNCTION_END_POINT", )!, SUPABASE_ANON_KEY: Deno.env.get("SUPABASE_ANON_KEY")!, SESSION_SECONDS: Deno.env.get("SESSION_SECONDS")!, SECRET: Deno.env.get("SECRET")!, SALT: Deno.env.get("SALT")!, }; export { envConfig };
[anonymous-board/import_map.jsonc(追記)]
{ "imports": { "deno_csrf/": "https://deno.land/x/deno_csrf@0.0.5/", // <=追記 "std_cookie": "https://deno.land/std@0.159.0/http/cookie.ts", // <=追記 } }
import_map.jsonc で、deno_csrf と std_cookie モジュールを定義しておきます。
[anonymous-board/routes/_middleware.ts(新規)]
import { sessionHandler } from "../middlewares/session.ts"; import { csrfHandler } from "../middlewares/csrf.ts"; export const handler = [ sessionHandler, csrfHandler, ];
追加で呼び出すcsrfHandlerはsessionに依存するので、exportするhandlerにて、sessionHandler の後に記述します。
[anonymous-board/middleware/csrf.ts(新規)]
import { MiddlewareHandlerContext } from "$fresh/server.ts"; import { matchAuthRoutePath, matchLangRoutePath, matchRootRoutePath, } from "../util/url.ts"; import { computeTokenPair, computeVerifyTokenPair } from "../util/csrf.ts"; import { Cookie, getCookies, setCookie } from "std_cookie"; import { type State } from "./session.ts"; export async function csrfHandler( req: Request, ctx: MiddlewareHandlerContext<State>, ) { const { session } = ctx.state; const urlObject = new URL(req.url); if ( matchRootRoutePath(urlObject) || // '/' に対応 matchAppRoutePath(urlObject) // 後で用意する '/topics' '/topic/**' に対応 ) { const tmpReq = req.clone(); if (tmpReq.method === "POST") { const form = await tmpReq.formData(); const formToken = form.get("csrfToken"); if (!formToken || typeof formToken !== "string") throw new Error(); const cookie = getCookies(tmpReq.headers); const cookieToken = cookie._cookie_token; // 検証処理に失敗した場合は / へのリダイレクトとトークンの付与 if (!computeVerifyTokenPair(formToken, cookieToken)) { const pair = computeTokenPair(); session.set("csrf", { tokenStr: pair.tokenStr, cookieStr: pair.cookieStr, }); session.flash("Error", "不適切なリクエスト"); return new Response("", { status: 303, headers: { Location: new URL(tmpReq.url).pathname }, }); } } // session にトークンを持っていなければ発行 if (!session.get("csrf")) { const pair = computeTokenPair(); session.set("csrf", { tokenStr: pair.tokenStr, cookieStr: pair.cookieStr, }); } const response = await ctx.next(); // cookie トークンをレスポンスに付与 if (session.has("csrf")) { const cookie: Cookie = { name: "_cookie_token", value: session.get("csrf").cookieStr, path: "/", }; setCookie(response.headers, cookie); } return response; } // パスとマッチしないリクエストは、何もせず次の処理へまわす return ctx.next(); }
CSRF ミドルウェアの本体です。 POST method でリクエストを受け付けた時には、フォームに埋め込まれたトークンと cookieに持ったトークンとの検証をします。 また、トークンをセッションで持っていない場合には、発行を行います。
[anonymous-board/util/csrf.ts(新規)]
import { computeHmacTokenPair, computeVerifyHmacTokenPair, } from "deno_csrf/mod.ts"; import { envConfig } from "./config.ts"; export function computeTokenPair() { return computeHmacTokenPair(envConfig.SECRET, envConfig.SALT); } export function computeVerifyTokenPair(tokenStr: string, cookieStr: string) { return computeVerifyHmacTokenPair(envConfig.SECRET, tokenStr, cookieStr); }
CSRF トークン2種の発行と、検証を担当します。
[anonymous-board/util/url.ts]
export function matchRootRoutePath(url: URL): boolean { const pattern = new URLPattern({ pathname: "/", }); return pattern.test(url); } export function matchAppRoutePath(url: URL): boolean { const patternTopic = new URLPattern({ pathname: "/topic", }); const patternTopics = new URLPattern({ pathname: "/topics/**", }); return patternTopic.test(url) || patternTopics.test(url); }
routes に定義したパスとマッチした場合だけトークンの検証をさせることを目的として、URL検証処理を切り出しておきます。
改めて Fresh を起動してみると、Fresh を起動して適当なページにアクセスして開発者ツールのcookiesを見てみましょう。
_cookie_token キーが登録されているのが確認できます。
テーブルの作成
掲示板を登録をするにあたってミドルウェアの実装が済んだので、引き続き処理本体を作ります。 まずは、データの登録先となるデータべ―スにテーブルを作ります。
ここでは、マイグレーションツールのnessieを使用します。
nessie は少々コマンドが長くなるので、先に deno task に登録しして使う方が楽です。 次のように記述します。
[anonymous-board/deno.json]
{ "tasks": { "start": "deno run -A --watch=static/,routes/ dev.ts", "nessie:init": "deno run -A --unstable https://deno.land/x/nessie/cli.ts init --mode folders & deno run -A --unstable https://deno.land/x/nessie/cli.ts init --mode config --dialect pg", "db:migrate": "deno run -A --import-map=./import_map.json --unstable https://deno.land/x/nessie/cli.ts migrate", "make:migrate": "deno run -A --import-map=./import_map.json --unstable https://deno.land/x/nessie/cli.ts make:migration", "db:rollback": "deno run -A --import-map=./import_map.json --unstable https://deno.land/x/nessie/cli.ts rollback" }, "importMap": "./import_map.json", "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "preact" } }
次の操作で使用します。
$ deno task nessie:init Warning deno task is unstable and may drastically change in the future Task nessie:init deno run -A --unstable https://deno.land/x/nessie/cli.ts init --mode folders & deno run -A --unstable https://deno.land/x/nessie/cli.ts init --mode config --dialect pg Download https://deno.land/x/nessie/cli.ts # 省略 Created config file If you are using Nessie commercially, please consider supporting the future development. Give a donation here: https://github.com/halvardssm/deno-nessie Created migration folder Created seed folder If you are using Nessie commercially, please consider supporting the future development. Give a donation here: https://github.com/halvardssm/deno-nessie
db ディレクトリと nessie.config.ts が作成されます。 nessie.config.ts は次のように書き換えます
[nessie.config.ts(変更前)]
import { ClientPostgreSQL, NessieConfig, } from "https://deno.land/x/nessie@2.0.10/mod.ts"; const client = new ClientPostgreSQL({ database: "nessie", hostname: "localhost", port: 5432, user: "root", password: "pwd", }); /** This is the final config object */ const config: NessieConfig = { client, migrationFolders: ["./db/migrations"], seedFolders: ["./db/seeds"], }; export default config;
[nessie.config.ts(変更後)]
import { ClientPostgreSQL, NessieConfig, } from "https://deno.land/x/nessie@2.0.10/mod.ts"; const client = new ClientPostgreSQL({ database: Deno.env.get("SUPABASE_POSTGRES_DB")!, hostname: Deno.env.get("SUPABASE_POSTGRES_HOST")!, port: Deno.env.get("SUPABASE_POSTGRES_PORT")!, user: Deno.env.get("SUPABASE_POSTGRES_USER")!, password: Deno.env.get("SUPABASE_POSTGRES_PASSWORD")!, }); /** This is the final config object */ const config: NessieConfig = { client, migrationFolders: ["./db/migrations"], seedFolders: ["./db/seeds"], }; export default config;
接続先情報はすべて環境変数で管理します。
別途 GitHub Actions で自動デプロイを組んでいく際のことを考えての対応です。
環境変数は、util/config.ts に定義するのを参照させても良いのですが、ここでの supabase が提供する postgres に直接アクセスするのはマイグレーションの時だけなので直接 Deno.env.get
を使用しています。
引き続き次の操作で、マイグレーションファイルを作成します
$ deno task make:migrate create_topics Warning deno task is unstable and may drastically change in the future Task make:migrate deno run -A --import-map=./import_map.json --unstable https://deno.land/x/nessie/cli.ts make:migration "create_topics" Created migration /usr/src/app/anonymous-board/db/migrations/20221112200345_create_topics.ts
20221112200345_create_topics.ts の中身には特に定義の記述は無いので、SQLを書いていきます
[anonymous-board/db/migrations/20221112200345_create_topics.ts(変更前)]
import { AbstractMigration, Info, ClientPostgreSQL } from "https://deno.land/x/nessie@2.0.10/mod.ts"; export default class extends AbstractMigration<ClientPostgreSQL> { /** Runs on migrate */ async up(info: Info): Promise<void> { } /** Runs on rollback */ async down(info: Info): Promise<void> { } }
[anonymous-board/db/migrations/20221112200345_create_topics.ts(変更後)]
import { AbstractMigration, Info, ClientPostgreSQL } from "https://deno.land/x/nessie@2.0.10/mod.ts"; export default class extends AbstractMigration<ClientPostgreSQL> { /** Runs on migrate */ async up(info: Info): Promise<void> { await this.client.queryArray(` CREATE TABLE IF NOT EXISTS public.topics ( id serial primary key, title char(100), created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); `); } /** Runs on rollback */ async down(info: Info): Promise<void> { await this.client.queryArray("DROP TABLE public.topics;"); } }
作成ができたら、マイグレーションを実行します。
$ deno task db:migrate Task db:migrate deno run -A --import-map=./import_map.json --unstable https://deno.land/x/nessie/cli.ts migrate Starting migration of 1 files ---- Migrating 20221112200345_create_topics.ts Done in 0.04 seconds ---- Migrations completed in 0.04 seconds
実行できたら、supabase の管理画面を確認します。 nessie_migrations と topics のテーブルが作成されているのが確認できます。
続けて、次の3つのマイグレーションファイルを作成します。
- topics テーブルの updated_at を更新するトリガー
- 各掲示板の投稿本体 の posts テーブル
- posts テーブルの updated_at を更新するトリガー
$ deno task make:migrate create_topics_trigger Task make:migrate deno run -A --import-map=./import_map.json --unstable https://deno.land/x/nessie/cli.ts make:migration "create_topics_trigger" Created migration /usr/src/app/anonymous-board/db/migrations/20221113055320_create_topics_trigger.ts $ deno task make:migrate create_posts Task make:migrate deno run -A --import-map=./import_map.json --unstable https://deno.land/x/nessie/cli.ts make:migration "create_posts" Created migration /usr/src/app/anonymous-board/db/migrations/20221113055332_create_posts.ts $ deno task make:migrate create_posts_trigger Task make:migrate deno run -A --import-map=./import_map.json --unstable https://deno.land/x/nessie/cli.ts make:migration "create_posts_trigger" Created migration /usr/src/app/anonymous-board/db/migrations/20221113055340_create_posts_trigger.ts
SQLの記述を行ったファイルの内容は次の通りです。
[20221113055320_create_topics_trigger.ts(修正後)]
import { AbstractMigration, ClientPostgreSQL, Info, } from "https://deno.land/x/nessie@2.0.10/mod.ts"; export default class extends AbstractMigration<ClientPostgreSQL> { /** Runs on migrate */ async up(info: Info): Promise<void> { await this.client.queryArray(` CREATE FUNCTION topics_set_update_time() RETURNS trigger AS ' begin new.updated_at := ''now''; return new; end; ' LANGUAGE plpgsql; CREATE TRIGGER update_trigger BEFORE UPDATE ON public.topics FOR EACH ROW EXECUTE PROCEDURE topics_set_update_time(); `); } /** Runs on rollback */ async down(info: Info): Promise<void> { await this.client.queryArray( "DROP FUNCTION IF EXISTS topics_set_update_time() CASCADE;", ); } }
[20221113055332_create_posts.ts(修正後)]
import { AbstractMigration, ClientPostgreSQL, Info, } from "https://deno.land/x/nessie@2.0.10/mod.ts"; export default class extends AbstractMigration<ClientPostgreSQL> { /** Runs on migrate */ async up(info: Info): Promise<void> { await this.client.queryArray(` CREATE TABLE IF NOT EXISTS public.posts ( id serial primary key, topic_id int references public.topics(id), comment text, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); `); } /** Runs on rollback */ async down(info: Info): Promise<void> { await this.client.queryArray("DROP TABLE public.posts;"); } }
[20221113055340_create_posts_trigger.ts(修正後)]
import { AbstractMigration, ClientPostgreSQL, Info, } from "https://deno.land/x/nessie@2.0.10/mod.ts"; export default class extends AbstractMigration<ClientPostgreSQL> { /** Runs on migrate */ async up(info: Info): Promise<void> { await this.client.queryArray(` CREATE FUNCTION posts_set_update_time() RETURNS trigger AS ' begin new.updated_at := ''now''; return new; end; ' LANGUAGE plpgsql; CREATE TRIGGER update_trigger BEFORE UPDATE ON public.posts FOR EACH ROW EXECUTE PROCEDURE posts_set_update_time(); `); } /** Runs on rollback */ async down(info: Info): Promise<void> { await this.client.queryArray( "DROP FUNCTION IF EXISTS posts_set_update_time() CASCADE;", ); } }
マイグレーションは、先と同様に以下の通り操作します。
deno task db:migrate Task db:migrate deno run -A --import-map=./import_map.json --unstable https://deno.land/x/nessie/cli.ts migrate Starting migration of 3 files ---- Migrating 20221113055320_create_topics_trigger.ts Done in 0.02 seconds ---- Migrating 20221113055332_create_posts.ts Done in 0.02 seconds ---- Migrating 20221113055340_create_posts_trigger.ts Done in 0.02 seconds ---- Migrations completed in 0.06 seconds
supabase edge functions での API を用意
データベースが用意できたので、APIを用意します。
用意するのは、掲示板の登録と一覧の取得をする二つのAPIです。 supabase cli で作成していきます。
$ npx supabase functions new board_api Created new Function at supabase\functions\board_api
supabase/functions/board_api/index.ts が作成されているので、次のように改修します。
[supabase/functions/board_api/index.ts(新規)]
import { createClient } from "https://esm.sh/@supabase/supabase-js@^1.33.2"; import { serve } from "https://deno.land/std@0.131.0/http/server.ts"; import { Router } from "https://deno.land/x/acorn/mod.ts"; export const supabaseClient = createClient( Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_ANON_KEY")!, ); const router = new Router(); router.get("/board_api/topics", async (ctx) => { const topics = await supabaseClient.from("topics").select( "id, title", ); if (topics.error) { console.error(topics.error); throw new Error(); } return { topics: topics.data }; }); router.post("/board_api/topic", async (ctx) => { const params = (await ctx.body()) as { comment: string; topic_id: number; }; console.log(params); const { data, error } = await supabaseClient.from("topics").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 では、データベースの接続情報を環境変数で提供してくれるので楽ちんです。 ルーティングには、acorn を使用しています。
https://deno.land/x/acorn@0.1.1
シンプルなルーティングのみのモジュールです。 というのも supabase edge function では、実装した処理に到達する以前に supabase edge function としての認証機能によりチェックが働くためです。 もし、supabase edge function が提供する認証処理以外に独自に認証機能を作る場合には、別途高機能なモジュールを使うなどの検討ができると思います。
$ npx supabase functions serve board_api
掲示板の登録処理の作成
データベースとAPIの用意ができたので、掲示板の登録処理を作っていきます。
[anonymous-board/routes/topics.tsx(新規)]
import { Context, Handlers, PageProps } from "$fresh/server.ts"; import { envConfig } from "../util/config.ts"; import { WithSession } from "fresh_session/mod.ts"; interface Topic { id: number; title: string; } type Topics = Topic[]; interface TopicsResource { topics: Topics; tokenStr: string; errorMessage: string; } export const handler: Handlers = { async POST(req: Request, ctx: Context<WithSession>) { const form = await req.formData(); const title = form.get("title"); if ( !title || typeof title !== "string" || title.length === 0 || title.length > 90 ) { return new Response("", { status: 303, headers: { Location: "/topics" }, }); } const result = await fetch( `${envConfig.SUPABASE_EDGE_FUNCTION_END_POINT}/topic`, { method: "POST", headers: { Authorization: `Bearer ${envConfig.SUPABASE_ANON_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ title, }), }, ); const topic = await result.json(); // ここで登録した本体の情報をチェックしますが、 // 本来は作成した掲示板にリダイレクトさせることを目的とします。 // 今回は、トピックの一覧ページへ戻します。 return new Response("", { status: 303, headers: { Location: "/topics" }, }); }, async GET(req: Request, ctx: Context<WithSession>) { const { session } = ctx.state; const result = await fetch( `${envConfig.SUPABASE_EDGE_FUNCTION_END_POINT}/topics`, { headers: { Authorization: `Bearer ${envConfig.SUPABASE_ANON_KEY}`, "Content-Type": "application/json", }, }, ); const data = await result.json(); const resource: TopicsResource = { ...data, tokenStr: session.get("csrf").tokenStr, errorMessage: session.flash("Error"), }; return await ctx.render(resource); }, }; export default function Topics(props: PageProps<TopicsResource>) { return ( <div class="mb-2"> <div class="mb-2"> <form method="POST" action="/topics"> <input type="text" name="title" placeholder="新しい掲示板タイトル" class="w-full mb-1 rounded h-12 text-lg text-center" /> <input type="hidden" name="csrfToken" value={props.data.tokenStr} /> {props.data.errorMessage ? ( <div class="mt-4 h-12 p-2 bg-red-200 bg-red-200 rounded text-center text-red-500"> {props.data.errorMessage} </div> ) : ""} <button class="bg-indigo-400 w-full rounded py-2 text-white"> 登録 </button> </form> </div> {!props.data.topics ? "" : props.data.topics.map((topic) => ( <a href={"/topic/" + topic.id}> <div class="w-full mb-2 select-none border-l-4 border-gray-400 bg-gray-100 p-4 font-medium hover:border-blue-500"> <div> <p class="text-9x1 break-all">{topic.title}</p> </div> </div> </a> ))} </div> ); }
先に用意した セッションとCSRFミドルウェアによりトークンを用意しているので、それを埋め込みます。
async GET ~
部分が GET /topics
へのアクセスされた際に function Topics ~
が処理される前に実行され、ctx.render
を明示的に実行できます。
async POST ~
部分が、POST /topics
に対応します。
今回は、簡単なバリデーションにとどめいています。
細かいバリデーションやサニタイズ処理はまた次回以降の連載においておき、大きな流れを眺めてもらえればと思います。
ここまでできたら、新しい掲示板の登録とタイトルの一覧が取得できるようになっているはずです。
開発者ツールで、Formの中に埋め込んだ csrfToken の値を書き換えると、「不適切なリクエスト」と表示され登録ができないことも確認できるはずです。
今回は、ミドルウェアの導入と、supabase でのデータベースの用意、部屋データの登録と参照画面を用意しました。 ミドルウェアが作れるとできることがかなり広がるので、手始めにアクセスログやキャッシュ、アクセスレートリミット機能など作ってみると良いかと思います。
意外と実装を1回にまとめ切るのは難しく、次回も引き続き掲示板内での書き込み投稿や詳細なバリデーション、テストの実装などを進めていく予定です。
今回進めた内容は、冒頭の通り以下のリポジトリに上げていますので全体感を確認したい場合はこちらをご参考ください。
P.S.
採用
虎の穴では一緒に働く仲間を募集中です!
この記事を読んで、興味を持っていただけた方はぜひ弊社の採用情報をご覧下さい。
yumenosora.co.jp
LINEスタンプ
エンジニア専用のメイドちゃんスタンプが完成しました!
「あの場面」で思わず使いたくなるようなスタンプから、日常で役立つスタンプを合計40個用意しました。
エンジニアの皆さん、エンジニアでない方もぜひスタンプを確認してみてください。
store.line.me