1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめに

サーバーを一切用意せず、GitHub PagesとSupabaseだけで動く匿名掲示板「40ちゃんねる」を作りました。スレッドを立てる仕組みはなく、1つの場所にみんなでコメントを投稿していくだけの、いわゆる「単発ID無し掲示板」です。

この記事では、使用した技術構成と実装のポイントをまとめます。

デモサイト: https://40channel.com

技術構成

  • フロントエンド: HTML / CSS / JavaScript(フレームワークなし、単一ファイル)
  • ホスティング: GitHub Pages(独自ドメイン運用)
  • データベース: Supabase(PostgreSQL)
  • リアルタイム通信: Supabase Realtime
  • 画像アップロード: ImgBB API
  • 広告: 忍者AdMax

サーバーサイドのコードは一切書いていません。ブラウザから直接Supabaseにアクセスして読み書きし、投稿があった瞬間に他のユーザーの画面にも反映される、という構成です。

データベース設計

Supabaseで用意したテーブルは主に2つです。

コメント本体(comments)

comments.sql
create table comments (
  id bigint generated by default as identity primary key,
  created_at timestamp with time zone default timezone('utc'::text, now()) not null,
  text text not null,
  ip_address text not null,
  image_url text
);

alter table comments enable row level security;
create policy "Allow public read" on comments for select using (true);
create policy "Allow public insert" on comments for insert with check (true);

匿名掲示板なのでログイン機能はなく、insertとselectを誰でもできるようにRLS(Row Level Security)のポリシーを設定しています。

ip_addressは画面には一切表示しませんが、荒らし対策や開示請求への対応のためにサーバー(Supabase)側には記録しています。

削除依頼(delete_requests)

delete_requests.sql
create table delete_requests (
  id bigint generated by default as identity primary key,
  created_at timestamp with time zone default timezone('utc'::text, now()) not null,
  comment_id bigint,
  reason text not null,
  status text default 'pending' not null
);

alter table delete_requests enable row level security;
create policy "Allow public insert" on delete_requests for insert with check (true);

このテーブルはinsertしかポリシーを許可していません。つまり、誰でも削除依頼は送信できますが、送信された依頼一覧を見られるのは運営者がSupabaseの管理画面(Table Editor)にログインした場合のみです。実際の削除は自動化せず、運営者が内容を確認してから手動でコメントを消す、という設計にしました。匿名掲示板は自動化しすぎると悪用のリスクが上がるため、あえて人の目を挟むようにしています。

リアルタイム更新の実装

投稿があった瞬間に他のユーザーの画面にも反映されるように、Supabase Realtimeを使っています。

realtime.js
client
  .channel("comments-realtime")
  .on(
    "postgres_changes",
    { event: "INSERT", schema: "public", table: "comments" },
    (payload) => {
      const row = payload.new;
      // 受信したコメントを配列に追加して再描画
      allComments.push(row);
      renderComments();
    }
  )
  .on(
    "postgres_changes",
    { event: "DELETE", schema: "public", table: "comments" },
    (payload) => {
      const oldRow = payload.old;
      allComments = allComments.filter((c) => c.id !== oldRow.id);
      renderComments();
    }
  )
  .subscribe();

DELETEイベントをきちんと受け取るには、テーブル側の設定が1つ必要でした。

realtime_setting.sql
alter publication supabase_realtime add table comments;
alter table comments replica identity full;

replica identity fullを設定しないと、削除イベントのペイロードにidが含まれず、どのコメントが消えたのか判別できませんでした。ここは少しハマったポイントです。

URLの自動リンク化

コメント本文中にhttps://から始まるURLがあれば、自動的にクリック可能なリンクに変換しています。http://のみのURLは意図的にリンク化していません。

linkify.js
function linkifyAndEscape(text) {
  const escaped = escapeHtml(text);
  return escaped.replace(/(https:\/\/[^\s<>"]+)/g, (match) => {
    return `<a href="#" class="external-link" data-url="${match}">${match}</a>`;
  });
}

リンクをクリックした際にいきなり別サイトへ飛ばすのではなく、「このサイトを離れますか?リンク先の安全性については保障しません。」という確認モーダルを一度挟むようにしています。匿名掲示板は悪意あるリンクが貼られる可能性もあるため、ワンクッション入れることでユーザーに注意を促す狙いです。

#コメントIDによる返信表示

2chのアンカー機能(>>7のような記法)に近いものを、簡易的に実装しました。コメント本文中に#7のように書くと、そのコメントIDへの返信として認識され、コメントの先頭に返信バッジが表示されます。

reply.js
function renderReplyBadge(text) {
  const m = text.match(/#(\d+)/);
  if (!m) return "";
  const targetId = m[1];
  const exists = allComments.some((c) => String(c.id) === targetId);
  if (exists) {
    return `<div class="reply-badge">&gt;&gt; <a href="#comment-${targetId}" class="reply-link" data-target="${targetId}">${targetId}</a> への返信</div>`;
  }
  return `<div class="reply-badge">&gt;&gt; ${targetId}(該当コメントが見つかりません)</div>`;
}

DBに返信関係を保存する仕組みではなく、表示側で本文中の#数字を毎回解析しているだけのシンプルな作りです。バッジのリンクをクリックすると該当コメントまでスクロールし、一瞬ハイライト表示させています。

ポイントは、レス番号を投稿順の連番ではなく、Supabaseのcommentsテーブルの実際のidをそのまま表示するようにしたことです。これにより、削除依頼で使うIDと画面上の表示が完全に一致し、#7と書けばそのまま該当コメントを参照できます。

画像アップロード(ImgBB)

画像はSupabaseには保存せず、ImgBBにアップロードしてURLだけをcomments.image_urlに保存する構成にしました。

imgbb_upload.js
async function uploadImageToImgbb(file) {
  const formData = new FormData();
  formData.append("image", file);
  const res = await fetch(`https://api.imgbb.com/1/upload?key=${IMGBB_API_KEY}`, {
    method: "POST",
    body: formData,
  });
  const json = await res.json();
  if (!json.success) throw new Error("ImgBB upload failed");
  return json.data.url;
}

Supabaseの無料枠にはストレージ容量の制限があるため、画像は外部サービスに逃がすことでDB容量を圧迫しない設計にしています。

年齢確認・利用規約同意のモーダル

匿名掲示板という性質上、以下の2つのモーダルを実装しました。

  1. 年齢確認モーダル: ページを開くと最前面に表示され、「いいえ」を選ぶとコンテンツが完全に隠れて閲覧できなくなる
  2. 投稿確認モーダル: 「書き込む」を押すと、利用規約への同意とIPアドレス記録・開示の可能性について案内するモーダルが表示される

どちらもブラウザに結果を保存していません。年齢確認はページを開くたびに毎回表示され、投稿確認モーダルは「そのページを開いてから最初の投稿時にだけ表示し、同じページを開いている間は再表示しない」という仕様にしています。localStorageのような永続化は一切使わず、ページ内のJavaScript変数だけでフラグ管理をしているため、タブを閉じれば必ずリセットされます。

まとめ

  • サーバーを持たなくても、Supabase + GitHub Pagesだけで匿名掲示板が作れる
  • Row Level Security(RLS)をうまく使うことで、認証なしでも安全に読み書きの権限を制御できる
  • Supabase Realtimeを使うと、ポーリングなしで簡単にリアルタイム更新が実装できる
  • 削除機能のような「悪用されると困る操作」は、あえて自動化せず人の確認を挟む設計にするのも一つの選択肢

今後は、NGワードフィルタやレート制限など、荒らし対策まわりも強化していきたいと思っています。

1
1
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?