虎の穴開発室ブログ

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

MENU

Deno で掲示板サイトを作ろう! with upstash & supabase その 6 (Twitter 連携)

皆さん、こんにちは。おっくんです。

今回は、「Deno で掲示板サイトを作ろう! with upstash & supabase」企画の 6 回目として、Twitter 連携機能を組み立てます。
(記事作成の途上でTwitter連携機能に関する状況が変わってきていますが、OAuth連携およびログインを使うアプリの実装方針としてご参考ください。2023年4月10日時点で動作確認済みです。)

前回記事はこちら

toranoana-lab.hatenablog.com

実装方針

Twitter 連携機能は、Twitter Developer Platform のドキュメントにある、OAuth 1.0a に対応し、3-legged OAuth flow を使用し導入します。

developer.twitter.com

こう書くとピンと来ないかもしれませんが、いわゆる「Twitterで認証」ボタンを押し、リダイレクト先で承認することで、TwitterのAPIを連携先からのアクセス許可する1度はやったことがあるだろう流れを導入します。

3-legged OAuth flow のドキュメントにある詳細な実装については取り扱いません。
この部分にだけフォーカスしたモジュールを使用します。

github.com

(個人的に作って公開しているモジュールです。)

こちらを使用して、2つの機能を作ります。

  • Twitterで認証し、掲示板サイトとしてのユーザー登録を行う
  • 掲示板の書き込みにユーザーを関連付けし、書き込みの直下に公開用のユーザーIDを表示する

実装

deno_twitter_oauth の導入

認証回りの機能は、URLとしては、/auth/ 以下にまとめていきます。

URLの構成と機能は、次のようにします。

GET /auth/twitter/login     twitter認証用のリダイレクトします 
GET /auth/twitter/callback  twitterからコールバックで返ってきたときの処理を担当します
GET /auth/logout            ログアウト処理します

実装本体を進める前に

Twitter で認証する機能を作るにあたり、各種トークンやシークレットを環境変数を介して使用します。

[app\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")!,
  DENO_ENV: Deno.env.get("DENO_ENV")!,
  UPSTASH_REDIS_REST_URL: Deno.env.get("UPSTASH_REDIS_REST_URL")!,
  UPSTASH_REDIS_REST_TOKEN: Deno.env.get("UPSTASH_REDIS_REST_TOKEN")!,
  UPSTASH_QSTASH_CURRENT_SIGNING_KEY: Deno.env.get(
    "UPSTASH_QSTASH_CURRENT_SIGNING_KEY",
  ),
  UPSTASH_QSTASH_NEXT_SIGNING_KEY: Deno.env.get(
    "UPSTASH_QSTASH_NEXT_SIGNING_KEY",
  ),
  TWITTER_API_KEY: Deno.env.get("TWITTER_API_KEY")!,          // 追加
  TWITTER_API_SECRET: Deno.env.get("TWITTER_API_SECRET")!,    // 追加
  TWITTER_CALLBACK_URL: Deno.env.get("TWITTER_CALLBACK_URL")!,// 追加
  DOMAIN: Deno.env.get("HOST")!,                              // 追加
  PORT: Deno.env.get("PORT")                                  // 追加
};

export { envConfig };

Twitter のAPIを使用するには、Developer 登録が必要になるので、個別に対応ください。

GET /auth/twitter/login の実装

/auth/twitter/login の実装は次のようにします。

[routes/auth/twitter/login.tsx(新規)]

import { Context, Handlers, ResponseBody } from "$fresh/server.ts";
import { envConfig } from "../../../util/config.ts";
import {
  getAuthenticateLink,
  type GetAuthLinkParam,
} from "twitter_oauth/mod.ts";

const oauthConsumerKey = envConfig.TWITTER_API_KEY;
const oauthConsumerSecret = envConfig.TWITTER_API_SECRET;
const oauthCallback = envConfig.TWITTER_CALLBACK_URL;

const authParam: GetAuthLinkParam = {
  oauthConsumerKey,
  oauthConsumerSecret,
  oauthCallback,
};

function matchHost(src: string): boolean {
  const url = new URLPattern(src);
  return url.hostname == envConfig.HOST;
}

function getDefaultRedirectURL(protocol: string): string {
  if (!envConfig.PORT) return `${protocol}://${envConfig.HOST}`;
  return `${protocol}://${envConfig.HOST}:${envConfig.PORT}`;
}

export const handler: Handlers<ResponseBody | null> = {
  async GET(req: Request, ctx: Context) {
    const { session } = ctx.state;
    const referer = req.headers.get("referer");
    const protocol = new URLPattern(req.url).protocol;

    // リファラーを確認し、適切でなければ、トップページにリダイレクト
    if (!referer || !matchHost(referer)) {
      return new Response("", {
        status: 303,
        headers: { Location: getDefaultRedirectURL(protocol) },
      });
    }

    // twitter で認証をするURLを取得し、リダイレクトさせる
    const urlResponse = await getAuthenticateLink(authParam);
    session.set("oauthTokenSecret", urlResponse.oauthTokenSecret);
    session.set("referer_path", referer);

    return new Response("", {
      status: 303,
      headers: { Location: urlResponse.url },
    });
  },
};

併せて、トップページとなる routes/index.tsx に /auth/twitter/login へのリンクを設置します。

[routes/index.tsx(変更)]

export default function Home() {
  return (
    <div class="p-4 mx-auto max-w-screen-md">
      <a href="/auth/twitter/login">/auth/twitter/login</a>
    </div>
  );
}

アプリケーションを起動し、リンクを踏んでください。

リダイレクト先では、次のような連携アプリ認証の画面が表示されるはずです。

また、リンクを踏まず、/auth/twitter/login を直接ブラウザのURLに打ち込むとトップページにリダイレクトされることが確認できるはずです。

GET /auth/twitter/callback に必要なAPIの準備

先の実装で、連携アプリ認証の画面に到達できました。
/auth/twitter/callback は、この結果に基づいての処理を行います。

結果の情報からユーザーの登録を進めるので、必要なAPIを先に組み立てます。

supabase で管理しているデータベースへ、アカウントを登録するテーブルの追加から始めます。

[db/migrations/20230325170834_create_accounts.ts(新規)]

import {
  AbstractMigration,
  ClientPostgreSQL,
  Info,
} from "https://deno.land/x/nessie@2.0.7/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.accounts (
              id serial primary key,
              twitter_user_id char(100),
              public_id uuid,
              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.accounts;");
  }
}

[db/migrations/20230325170900_create_accounts_trigger.ts(新規)]

import {
  AbstractMigration,
  ClientPostgreSQL,
  Info,
} from "https://deno.land/x/nessie@2.0.7/mod.ts";

export default class extends AbstractMigration<ClientPostgreSQL> {
  /** Runs on migrate */
  async up(info: Info): Promise<void> {
    await this.client.queryArray(`
          CREATE FUNCTION accounts_set_update_time() RETURNS trigger AS '
            begin
              new.updated_at := ''now'';
              return new;
            end;
          ' LANGUAGE plpgsql;
          CREATE TRIGGER update_trigger BEFORE UPDATE ON public.accounts FOR EACH ROW EXECUTE PROCEDURE accounts_set_update_time();        
      `);
  }

  /** Runs on rollback */
  async down(info: Info): Promise<void> {
    await this.client.queryArray(
      "DROP FUNCTION IF EXISTS accounts_set_update_time() CASCADE;"
    );
  }
}

アカウントと、書き込みの関連付けを持ちたいので、カラム追加も行います。

[db/migrations/20230325171703_add_column_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(`
      ALTER TABLE public.topics ADD COLUMN account_id INT NOT NULL REFERENCES public.accounts(id);
    `);
  }
  /** Runs on rollback */
  async down(info: Info): Promise<void> {
    await this.client.queryArray(`
      ALTER TABLE public.topics DROP COLUMN account_id;
    `);
  }
}

[db/migrations/20230325171712_add_column_posts.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(`
      ALTER TABLE public.posts ADD COLUMN account_id INT NOT NULL REFERENCES public.accounts(id);
    `);
  }
  /** Runs on rollback */
  async down(info: Info): Promise<void> {
    await this.client.queryArray(`
      ALTER TABLE public.posts DROP COLUMN account_id;
    `);
  }
}

マイグレーションファイルを作成できたので、適用してください。

データベースを追加できたら、APIを追加します。

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

// 省略

router.post("/board_api/accounts", async (ctx) => {
  const params = (await ctx.body()) as { twitter_user_id: string };

  const selectResult = await supabaseClient.from("accounts").select().eq(
    "twitter_user_id",
    params.twitter_user_id,
  ).limit(1).single();

  if (selectResult.statusText === "OK") {
    return { id: selectResult.data.id, public_id: selectResult.data.public_id };
  }

  const public_id = crypto.randomUUID();

  const insertResult = await supabaseClient.from("accounts").insert([
    { twitter_user_id: params.twitter_user_id, public_id },
  ]).single();

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

  return { ...insertResult.data };
});

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

リクエストに基づいて、登録されていれば登録結果を返し、無ければ表示用のID(public_id)を発行して保存し、保存内容を返すAPIを用意しました。

GET /auth/twitter/callback の実装

/auth/twitter/callback は、連携アプリ認証の画面の操作結果に基づいての処理を行います。

次のように実装しました。

[routes/auth/twitter/callback.tsx(新規)]

import { Context, Handlers, ResponseBody } from "$fresh/server.ts";
import { envConfig } from "../../../util/config.ts";
import { getAccessToken } from "twitter_oauth/mod.ts";

const oauthConsumerKey = envConfig.TWITTER_API_KEY;
const oauthConsumerSecret = envConfig.TWITTER_API_SECRET;

export const handler: Handlers<ResponseBody | null> = {
  async GET(req: Request, ctx: Context) {
    const { session } = ctx.state;
    const query = new URL(req.url).searchParams
    const denied = query.get("denied")
    const oauthToken = query.get("oauth_token")
    const oauthVerifier = query.get("oauth_verifier")

    // 承認されていないか、次の処理に必要なコードが取得できない時エラーとしてリダイレクト
    // キャンセル で操作を進めた時だけ、denied がパラメータに含まれる
    if(denied || !oauthToken || !oauthVerifier){
      return new Response("", {
        status: 303,
        headers: { Location: "/" },
      });
    }
  
    const oauthTokenSecret = await ctx.state.session.get("oauthTokenSecret");
    
    // twitter API に問い合わせし、アクセストークンを取得(twitter 上のユーザーIDも返ってくる)
    const accessToken = await getAccessToken({
      oauthConsumerKey,
      oauthConsumerSecret,
      oauthToken: oauthToken.toString(),
      oauthVerifier: oauthVerifier.toString(),
      oauthTokenSecret,
    });

    // アクセスコードが取れない時は、エラーとしてリダイレクト
    if (!accessToken.status) {
      return new Response("", {
        status: 303,
        headers: { Location: "/" },
      });
    }

    // supabase に用意したアカウント登録APIを呼び出し
    const result = await fetch(
      `${envConfig.SUPABASE_EDGE_FUNCTION_END_POINT}/accounts`,
      {
        method: "POST",
        headers: {
          Authorization: `Bearer ${envConfig.SUPABASE_ANON_KEY}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          twitter_user_id: accessToken.userId,
        }),
      },
    );

  // アカウント登録できない時は、エラーとしてリダイレクト
    if (!result.ok) {
      return new Response("", {
        status: 303,
        headers: { Location: "/" },
      });
    }

   // アカウント情報をセッションに保管
    const account = await result.json();
    session.set("account", account);

    // セッションに持っていた、/auth/twitter/login 到達時の情報に基づいて、リダイレクト処理
    // 以降このアカウントは、ログインしたものとして取り扱える
    const refererPath = session.get("referer_path");

    return new Response("", {
      status: 303,
      headers: { Location: refererPath },
    });
  },
};

ここまでできたら、アプリケーションを再起動し、リンクを踏んでください。

「キャンセル」を押し、進めるとアプリケーションの画面に返ってきてもデータベース上には、何も書き込みが無いことが確認できるはずです。

「連携アプリを認証」を押すと、リダイレクトで返ってきた後、データベースに書き込みがあるのが確認できるはずです。

セッションにアカウント情報を持つことができました。 一度Twitter側で連携アプリ登録を解除すると、同じ操作ができます。 再度実行した時には、既にアカウントは登録済みなので、データベースに書かれないことも確認できるはずです。

GET /auth/logout の実装

アカウント情報はセッションに持っている状態になり、ログイン状態を作れました。 今度は、セッションを破棄しログアウトする処理を作ります。

[routes/auth/logout.tsx(新規)]

import { Context, Handlers } from "$fresh/server.ts";

export const handler: Handlers = {
  GET(req: Request, ctx: Context) {
    const referer = req.headers.get("referer");
    const { session } = ctx.state;

    session.clear();

    return new Response("", {
      status: 303,
      headers: { Location: referer },
    });
  },
};

こちらに到達するためのリンクも、トップページとなる routes/index.tsx に設置します。

[routes/index.tsx(変更)]

export default function Home() {
  return (
    <div class="p-4 mx-auto max-w-screen-md">
      <a href="/auth/twitter/login">/auth/twitter/login</a>
      <a href="/auth/logout">/auth/logout</a>
    </div>
  );
}

こちらは、現時点では差分が確認し難いかと思います。 次のユーザー情報をページに埋め込む処理を作る中で確認します。

アカウント情報のページへの埋め込み

ログイン状況がわかるように、掲示板のページの共通のヘッダーを作成します。

デザインを1から起こすのは、少々たいへんなので、Fresh Components を参考に必要部分を移植させていただきました。

Fresh Components は、Freshのために作られたコンポーネントのコレクションです。

fresh.deno.dev

[components/header.tsx(新規)]

type Props = {
  publicId: string;
};

export default function Header({ publicId }: Props) {
  return (
    <div class="bg-white w-full py-6 px-8 flex flex-col md:flex-row gap-4">
      <div class="flex items-center flex-1">
        <div class="text-2xl ml-1 font-bold">Anonymous-Board</div>
      </div>
      <ul class="flex items-center gap-6">
        {!!publicId ? (
          <li>
            <span>ID: {publicId}</span>
          </li>
        ) : (
          ""
        )}
        <li>
          {publicId ? (
            <a
              href="/auth/logout"
              class="bg-indigo-400 w-full rounded p-2 text-white"
            >
              ログアウト
            </a>
          ) : (
            <a
              href="/auth/twitter/login"
              class="bg-indigo-400 w-full rounded p-2 text-white"
            >
              ログイン
            </a>
          )}
        </li>
      </ul>
    </div>
  );
}

コンポーネントの外部から、publicId が渡されているかに基づいて表示を切り替えています。
こちらを、掲示板ページへ埋め込みます。

[routes/topics.tsx(修正)]

import { HandlerContext, Handlers, PageProps } from "$fresh/server.ts";
import { envConfig } from "../util/config.ts";
import { WithSession } from "fresh_session/mod.ts";
import { TopicsResource } from "../interfaces.ts";
import { sanitize } from "../util/html_sanitizer.ts";
import { validateUserInputTitle } from "../util/zod_validate.ts";
import Header from "../components/header.tsx"; // <= 先に作成したコンポーネントを呼び出し


export const handler: Handlers = {
    async POST(req: Request, ctx: HandlerContext<WithSession>) {
      // 省略
    },
    async GET(req: Request, ctx: HandlerContext<WithSession>) {
    const { session } = ctx.state;

    // セッションに保存した情報から、アカウントの公開用IDを取得
    const publicId = session.get("account")
      ? session.get("account").public_id
      : null;

    const result = await fetch(
      `${envConfig.SUPABASE_EDGE_FUNCTION_END_POINT}/get_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("errorMessage"),
      publicId,
    };

    return await ctx.render(resource);
  },
};

export default function Topics(props: PageProps<TopicsResource>) {
  return (
    <div class="p-2">
      {/* コンポーネントの埋め込みと、公開用IDの受け渡し */}
      <Header publicId={props.data.publicId} />
      <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={"/topics/" + 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>
  );
}

実装できたら、アプリケーションを再起動しましょう。 掲示板のページを開くと、次のように表示されているはずです。

ログインを押すと、先に実装した「Twitterで認証」の画面へ飛びます。認証済みであれば、自動的にアプリケーションの画面に戻ってくるはずです。

ログインできている。すなわちアカウント登録できていてセッションにアカウント情報を抱えた状態になっているので、「ログイン」ボタンは、「ログアウト」に切り替わり、公開用のIDが表示された次のように表示されているはずです。

「ログアウト」を押すと、再度公開用IDが表示されない状態に戻ります。

ログイン機能が作成できました。 ヘッダーを表示するための実装はその他のページでも同様に実装しておきます。

掲示板と投稿にアカウントを関連付け

それでは、仕上げにアカウント情報と掲示板のトピック、投稿本体を関連付けし、投稿の隅に投稿者を示す公開用IDを挿入しましょう。

手始めに、APIがアカウント情報を受け入れるように改修します。

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

// 省略

router.get("/board_api/topics", async (ctx) => {
  const topics = await supabaseClient.from("topics").select(
    "id, title, accounts(public_id)" // <= accounts(public_id) を追加
  );

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

  return { topics: topics.data };
});

router.post("/board_api/topics", async (ctx) => {
  const params = (await ctx.body()) as {
    comment: string;
    account_id: number; // <= account_id を追加
  };
  console.log(params);

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

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

  return { ...data };
});

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, accounts(public_id)" // <= accounts(public_id) を追加
  ).eq("topic_id", ctx.params.id);

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

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

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

  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);
  },
);

データの登録には、アカウントのIDを追加し、検索結果には、public_id を含めるようにしました。

続けて、これら改修したAPIを Fresh 側で使用するようにします。

[routes/topics.tsx(変更)]

import { HandlerContext, Handlers, PageProps } from "$fresh/server.ts";
import { envConfig } from "../util/config.ts";
import { WithSession } from "fresh_session/mod.ts";
import { TopicsResource } from "../interfaces.ts";
import { sanitize } from "../util/html_sanitizer.ts";
import { validateUserInputTitle } from "../util/zod_validate.ts";
import Header from "../components/header.tsx";
export const handler: Handlers = {
  async POST(req: Request, ctx: HandlerContext<WithSession>) {
    const { session } = ctx.state;
    const form = await req.formData();
    const title = form.get("title");
    const accountId = session.get("account") ? session.get("account").id : null;

    // アカウントIDが取得できない、すなわちログインしていない場合は、エラーメッセージとともにリダイレクト
    if (!accountId) {
      session.flash("errorMessage", "ログインしていません");
      return new Response("", {
        status: 303,
        headers: { Location: "/topics" },
      });
    }

    if (typeof title !== "string") {
      return new Response("", {
        status: 303,
        headers: { Location: "/topics" },
      });
    }

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

    const sanitizedTitle = sanitize(title);

    const validateResult = validateUserInputTitle(sanitizedTitle);

    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}/topics`,
      {
        method: "POST",
        headers: {
          Authorization: `Bearer ${envConfig.SUPABASE_ANON_KEY}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          title: validateResult.data,
          account_id: accountId,
        }),
      },
    );

    const topic = await result.json();

    return new Response("", {
      status: 303,
      headers: { Location: "/topics" },
    });
  },
  async GET(req: Request, ctx: HandlerContext<WithSession>) {
    const { session } = ctx.state;
    const publicId = session.get("account")
      ? session.get("account").public_id
      : null;

    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("errorMessage"),
      publicId,
    };

    return await ctx.render(resource);
  },
};

export default function Topics(props: PageProps<TopicsResource>) {
  return (
    <div class="p-2">
      <Header publicId={props.data.publicId} />
      <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={"/topics/" + 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>
                {/* public_id の記載を追加 */}
                <small>
                  <p class="text-gray-400">by {topic.accounts.public_id}</p>
                </small>
              </div>
            </div>
          </a>
        ))}
    </div>
  );
}

[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";
import { TopicResource } from "../../interfaces.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");
    const accountId = session.get("account") ? session.get("account").id : null;

    // アカウントIDが取得できない、すなわちログインしていない場合は、エラーメッセージとともにリダイレクト
    if (!accountId) {
      return new Response("", {
        status: 303,
        headers: { Location: "/topics" },
      });
    }

    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") || "/" },
      });
    }

    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,
          account_id: accountId,
        }),
      },
    );

    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>
                  {/* public_id の記載を追加 */}
                  <small>
                    <p class="text-gray-400">by {post.accounts.public_id}</p>
                  </small>
                </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>
  );
}

これらが実装できたらアプリケーションを再起動します。 ログインして、掲示板のトピックを追加すると次のように表示されます。

掲示板の中で書き込みをすると次のようになります。

トピックも、書き込み本体にも書いた本人の公開用IDが表示されるようになりました。


今回は「Twitterで認証」の機能、具体的には、Twitter API の 3-legged OAuth flow を用いた、ログイン機能と、 各種データ登録との関連付けを行いました。

匿名掲示板として、いくらか様になったでしょうか?

このような外部のサービスに依存した認証機能としては「GitHub で認証」であるとか、多数のものがあります。 それらと連携させる上でもFreshで問題なく取り扱うことが証明できていれば幸いです。

本連載の当初は、次のリストの機能を実装予定でした。

  • 閲覧は自由(済)
  • Twitter 連携をすることで、できることが増える
    • 書き込み(済)
    • 他のアカウントの書き込みをクリップ(一時保存/ブクマ/イイね的なこと)できる
  • 書き込みは、一定時間で非表示にされる(済)
  • 非表示になった投稿はバッチ処理で物理削除される(済)
  • 一定数のクリップがされると削除までの猶予時間が伸びる(最大 120 時間程度で検討)

次回は、これらの中のいずれかの機能か、少し画面の整理を行いたいと考えています。

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

github.com

P.S.

採用

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