0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

個人開発で掲示板とSNSの融合サービス『Pomate』を作ったよー

0
Last updated at Posted at 2026-06-02

はじめに

ありそうでなかった匿名とニックネームの自由な切り替えを主軸のコンセプトとして、日本のかつてのコミュニティの中心であった匿名掲示板の流れを引き継ぎつつ、
今主流となったモダンなSNSのように使えるユーザー体験を実現してみました。

この記事では、Pomateのコンセプトと、それを実現するための技術スタック・設計について詳しく書いていきます。

サービス概要

Pomate は 「半匿名型掲示板式SNS」 です。

  • 5ちゃんねるのような 匿名での書き込み
  • TwitterのようなSNSアカウント連携での 顕名(ニックネーム)投稿

この2つを 同じアカウントで自由に切り替えられる ところが最大の特徴です。

設計思想:人ベースから話題ベースへ

XやInstagramは「誰をフォローするか」が体験の中心にある 人ベースのSNS です。一方、Pomateは「どの話題で交流したいか」を中心とした 話題ベースのSNS を目指しています。

掲示板というと5chのようなアングラなイメージに引っ張られてしまいますが、BBSという形式の良さは、知らない人ともフラットに同じ話題で盛り上がれることだと思うんですよね。決して無法地帯だからいいという話ではなく。
一方で、SNSの良さは自分のアイデンティティを持って継続的な発信ができることです。
Pomateはこの両方を、ユーザーが場面に応じて選べる形で提供しています。

主な機能

  • コミュニティ(板)単位のスレッド — 話題ごとに分かれたグループ。誰でも板を作成可能
  • 匿名/顕名のシームレスな切替 — ボタン1つで投稿モードを変更
  • スレッド管理権限 — スレッド作成者が自分のスレッド内のモデレーションを行える
  • リアクション機能 — 絵文字でのリアクション、特殊リアクションでスター獲得
  • タイムライン — 全板の最新投稿をまとめて表示(おすすめ/最新の切替)
  • お絵描き投稿 — レイヤー対応の本格的なペイントツール内蔵
  • 実況スレッド — チャット形式のリアルタイム投稿

ここはQiitaなので技術寄りの内容で書いていきます。「サービスのことだけ知れればいい」という方は、noteの紹介記事をご覧ください。

技術スタック

フロントエンド

カテゴリ 採用技術
フレームワーク React Router v7 (SSR)
ホスティング Cloudflare Workers
言語 TypeScript
スタイリング Tailwind CSS
状態管理 Zustand
フォーム react-hook-form + Zod
お絵描き Konva.js
画像ギャラリー PhotoSwipe
リアルタイム Socket.IO Client

バックエンド

カテゴリ 採用技術
フレームワーク Hono
ホスティング Railway
言語 TypeScript (Node.js)
認証 BetterAuth (OAuth)
ORM Drizzle ORM
データベース CockroachDB Cloud
キャッシュ Upstash Redis
ストレージ Cloudflare R2
リアルタイム Socket.IO

構成上のポイント

  • フロントエンドは Cloudflare Workers です。React Router v7 の SSR をエッジで動かしています。
  • バックエンドは Railway で動かしています。本当はバックエンドもWorkersで統一させたかったけど、WebSocket(Socket.IO)を使うためにNode.jsランタイムが必要だったためやむを得ず。
  • DBは CockroachDB Cloud を選択。PostgreSQLが使えるということと値段しか見てません。

費用想定

個人開発ということで、ランニングコストをできるだけ抑えられる構成を意識して選定しています。現状の規模感だと以下のような想定です。

サービス プラン 費用目安(月) 備考
Cloudflare Workers Paid $5 無料枠でも十分だが SSR のCPU時間を考慮して有料プランに
Cloudflare R2 従量課金 ~$0 〜 数ドル 10GBまで無料。エグレス無料が大きい
Railway (Hobby) Hobby + 従量 $5 〜 $10 小規模運用向け。常時稼働のNode.js + Socket.IO用
Railway (Pro) Pro + 従量 $20 〜 $40 本番運用向け。水平スケール・優先サポート・高可用性
CockroachDB Cloud Basic (Serverless) ~$0 〜 数ドル 無料枠内に収まるケースが多い
Upstash Redis Pay-as-you-go ~$0 〜 数ドル 日次コマンド数が少ないので無料〜微課金
ドメイン (pomate.cc) Cloudflare Registrar 約$10 / 年 月割すると $1 以下
合計(Hobby構成) $15 〜 $30程度 個人開発・検証フェーズ想定
合計(Pro構成) $30 〜 $60程度 本番運用・ユーザー増加後を想定

画像配信に Cloudflare R2 を選んでいるのは、S3互換でありながらエグレス(転送量)料金がかからないためで、画像が多くなりがちな掲示板系サービスとは相性が良いです。

DB・Redis・ストレージは基本的に無料枠もしくは従量課金で、ユーザーが増えても比例して増えていく形なので、個人開発でも破綻しない構成になっています。

各構成で捌けるMAU目安

Pomateは Socket.IO による常時接続が重めの負荷要因になる点だけ注意が必要ですが、SSRはCloudflare Workersで分散し、画像はR2から直配信なので、バックエンド(Railway)以外はボトルネックになりにくいです。そのため実質的な上限は Railwayのインスタンスリソース で決まります。

構成 Railwayリソース 想定MAU 同時接続数の目安 備考
Hobby 8GB RAM / 8 vCPU (共有) 〜 1万程度 数百程度 単一インスタンス。アクセス集中時は応答遅延の可能性
Pro 32GB RAM / 32 vCPU 5万 〜 20万 数千程度 水平スケール可能。複数レプリカで冗長化も可能

この数値はあくまで目安で、実際は「1人あたりの投稿・閲覧頻度」「同時実況スレッドの数」などで大きく変動します。Pomateのようにリアルタイム要素のあるサービスは MAUよりも同時接続数 がリソース消費のボトルネックになりやすいので、そこを先に見ながらスケール判断していくのが現実的です。

掲示板とSNSの融合 — タイムラインという接着剤



Pomateの体験を一言で表すと、「スレッドに書き込んだら、それがそのままSNSのタイムラインに流れる」というものです。

従来の掲示板は、板→スレッド→レスという階層構造の中で完結していました。あるスレッドで面白いやりとりがあっても、そのスレッドを見に行かない限り気づけません。一方、SNSのタイムラインは全ての投稿がフラットに流れてくるので発見性は高いですが、話題ごとのまとまりがありません。

Pomateではこの2つを、スレッド内のレスがタイムラインにも流れるという設計で繋いでいます。

ユーザーが板のスレッドに書いたレスは、そのスレッド内のツリー表示で見られるだけでなく、ホームのタイムラインにも1つのカードとして出現します。タイムライン上ではどの板のどのスレッドに書かれたレスなのかが表示され、カードをタップすればそのスレッドの文脈に入れます。

これにより、「特定の話題について深く話したい」ときはスレッドに入って議論に参加し、「何か面白いことが起きてないかな」というときはタイムラインを眺める、という2つの体験がシームレスに繋がります。

タイムラインには「おすすめ」と「新着」の2つのタブがあり、おすすめタブではお気に入りに登録した板やスレッドの投稿がスコアリングで上位に表示されます。掲示板のように話題を深掘りしながら、SNSのように新しい話題に出会える。この二面性がPomateの軸になっています。

設計のポイント

ここからは、Pomateならではの「半匿名性」を実現するために工夫した部分をいくつか紹介します。

1. 匿名/顕名を同じアカウントで両立する設計

Pomateの根幹は「同じアカウントから匿名と顕名の両方で投稿できる」ことです。これを成立させるには、データモデルとAPI設計の両方で配慮が必要でした。

  • 投稿レコードには isAnonymous フラグと、認証ユーザーの内部ID(userId)を持たせる
  • 内部IDは匿名・顕名を問わず、APIレスポンスから常に除去する(フロントエンドには渡らない)
  • 匿名投稿の場合はこれに加えて、著者プロフィール(表示名・アバター・外部連携ID)も null 化して返す

これにより、「同じユーザーの匿名投稿と顕名投稿を、外部から照合する手段が存在しない」 状態を作っています。

2. オラクル攻撃への対策

匿名性を真面目に設計すると、必ず突き当たるのが オラクル攻撃 です。
Pomateには個別にユーザーをNG(ミュート)できる機能があります。
このNGは、認証ユーザーの内部ID(userId)を用いて行うことで書き込みしたユーザーが匿名だろうと顕名だろうと非表示にすることができます。

ただ、これをそのまま実装すると一つ問題が起きます。

例えば「ある顕名ユーザーをNG(ミュート)に入れたら、別の匿名投稿も同時に消えた」という現象が起きると、観察者は「あの匿名投稿はあの人だった」と推測できてしまいます。

Pomateではこれを技術的に防ぐために、NG登録時の状態と投稿時の状態が一致した場合のみフィルタリング する設計にしました。

// NGユーザー判定(簡略版)
function matchesNgUser(post, ngUser) {
  // NG登録時の匿名状態と投稿の匿名状態が一致しない場合はフィルタしない
  if (ngUser.registeredFromAnonymous !== post.isAnonymous) {
    return false;
  }
  return ngUser.blockedUserId === post.userId;
}

つまり、

  • 顕名投稿からNG登録した → そのユーザーの 顕名投稿だけ が非表示になる
  • 匿名投稿からNG登録した → そのユーザーの 匿名投稿だけ が非表示になる

匿名と顕名のNGリストが完全に分離されているため、クロスリファレンスによる特定が技術的に不可能になっています。

もちろん、場合によっては同じユーザーを2回NGにしなくてはならないですが、おそらく問題を起こすようなユーザーは匿名投稿のみを用いるかと思われますので、そこまで影響はないと思います。

3. dailyUserId — スレッド内の同一性とプライバシーの両立

匿名掲示板の良さの1つに「同じスレッドの中では誰の発言かなんとなく分かる」という体験があります。Pomateもこれを実現したいですが、 スレッドをまたいで追跡されると匿名性が崩壊 します。

そこで採用したのが、スレッドIDと日付を含むハッシュベースの一時IDです。

// 簡略版
const idSource = `user:${userId}:${topicId}:${dateString}`;
// SHA-256 ハッシュを 7 文字の英数字IDに変換(実装では自演検出用にIP由来の桁も混ぜている)
const dailyUserId = toShortId(sha256(idSource));
  • 同一スレッド・同一日: 同じユーザーは同じIDになる → 会話の流れが追える
  • 別スレッド or 翌日: 同じユーザーでもIDが変わる → 横断追跡を阻止

この設計により、「スレッド内の文脈把握」と「長期的な行動追跡の防止」を両立しています。

4. パフォーマンス最適化

正直、ここが一番の課題であり弱点です。以下はいろいろ試行錯誤した履歴

Zustandストアの skipHydration パターン

Zustandの persist ミドルウェアでlocalStorageに状態を保存していますが、SSR時にサーバーとクライアントで状態が食い違うとハイドレーションエラーが起きます。各 persist ストアに skipHydration: true を設定し、クライアントマウント後に一括 rehydrate() する方式で解決しています。

// ストア定義
export const useSettingsStore = create<SettingsStore>()(
  persist(
    (set) => ({
      /* ... */
    }),
    { name: "settings-storage", skipHydration: true },
  ),
);

// アプリ初期化時に一括復元
await Promise.all([
  useSettingsStore.persist.rehydrate(),
  useNSFWStore.persist.rehydrate(),
  useDataSaverStore.persist.rehydrate(),
  // ... 約20個のストア
]);

コード分割と遅延読み込み

重いコンポーネントは React.lazy + Suspense で分割し、初期バンドルサイズを削減しています。

const ScrollJumpButtons = lazy(
  () => import("@/components/layout/ScrollJumpButtons"),
);
const FloatingActionButton = lazy(
  () => import("@/components/layout/FloatingActionButton"),
);
const TopicPageModals = lazy(
  () => import("@/components/topic/TopicPageModals"),
);

お絵描きエディタ(Konva.js)、管理画面、設定ページなど、初回表示に不要なものは全て遅延読み込みにしています。

クライアントサイドキャッシュ(SWRパターン)

板一覧やスレッドデータはモジュールレベルでキャッシュを保持し、SWR(Stale-While-Revalidate)パターンで管理しています。キャッシュが新鮮なら即座に表示し、古ければバックグラウンドで再取得して差し替えます。

const cacheStatus = getCacheStatus(cache.timestamp, config);
if (cacheStatus === "fresh") {
  // APIを呼ばず即表示
  return cache.data;
}
if (cacheStatus === "stale") {
  // 古いデータを即表示 + バックグラウンドで更新
  setData(cache.data);
  fetchInBackground();
}

スレッドデータも同様にZustandストアにキャッシュし、同じスレッドの再訪問時はAPIコールなしで表示できます。

バックエンドのRedisキャッシュ

頻繁にアクセスされるAPIレスポンス(板一覧、スレッド詳細、ウィジェットデータなど)はUpstash Redisにキャッシュしています。DBへのクエリ数を大幅に削減し、応答速度を向上させています。

Service Worker

静的アセット(JS、CSS、画像、フォント)のキャッシュにService Workerを使っています。2回目以降のアクセスではネットワークリクエストなしでアセットを返せるため、体感速度が大きく向上します。

5. お絵描き機能

板にお絵描きを直接投稿できる機能を Konva.js で実装しています。

  • レイヤー対応のラスターペイント
  • ペン、消しゴム、ペイントブラシ、各種ブレンドモード
  • スレッド作成・返信フォームから直接遷移できる別ページとして実装
  • お絵描き完了後はZustandストアに画像Blobとプロジェクトデータ(gzip圧縮レイヤー)を保持し、元の投稿フォームに戻ったときに自動的に添付される

別ページにした理由は、モバイルでモーダル内にペイントツールを置くとUIが複雑になりすぎたためです。「ページとして開いて、保存したら元の場所に戻る」という挙動の方がモバイルでも使いやすくなりました。

戻ってきたときに 入力中の本文がリセットされない ように、ストアにフォームの下書きも一緒に保存する仕組みを入れています。

6. 実況スレッドとプレイリスト共有

Pomateには「実況スレッド」という特殊なスレッドタイプがあります。通常のレス形式ではなく、チャット形式のUIで動画を見ながらリアルタイムにコメントできます。
King gnu「白日」

核となるのは プレイリスト共有 機能です。スレッドに参加している全員が同じ動画を同じ再生位置で視聴できます。

動画同期の仕組み:

「全員が同じ動画の同じ位置を見ている」状態を作るために、サーバー側に権威的な再生時刻を持たせる設計にしています。

再生状態(videoStatecurrentVideoTimecurrentVideoIndex)はDBのスレッドレコードに保存され、サーバー側の SimpleVideoTimer クラスが1秒間隔で currentVideoTime をインクリメントします。

┌─────────────────────────────────────────────────────┐
│ サーバー (SimpleVideoTimer)                          │
│                                                     │
│  1秒ごとに tick() →  DB の currentVideoTime += 1    │
│                   →  5秒ごとに Socket.IO で全員に通知│
│                   →  終了時間に達したら次の動画へ     │
└──────────────────┬──────────────────────────────────┘
                   │ video_sync_update (5秒ごと)
          ┌────────┼────────┐
          ▼        ▼        ▼
       Client A  Client B  Client C
       (各自のプレイヤーが受信した currentTime に追従)

クライアント側のプレイヤーは5秒ごとに届く video_sync_update イベントでサーバーの再生時刻と自分の再生位置を比較し、ズレが大きければシークして補正します。WebSocket通知を毎秒ではなく5秒間隔にしているのは帯域の節約のためで、その間は各クライアントが自律的に再生を進めます。

途中参加のユーザーはREST API(GET /video-sync/:topicId/state)で現在の再生状態を取得し、その時点の currentVideoTime から再生を開始するため、途中からでも全員と同じ位置で視聴できます。

動画終了と自動遷移: サーバーの tick() 内で currentVideoTime >= videoDuration を検出すると、VideoCompletionHandler が次の動画へ自動遷移します。ここでは冪等性ガード(5秒間のTTL付き処理中フラグ)を入れて、タイマーの重複起動やネットワーク遅延による二重遷移を防いでいます。

スキップ投票: 視聴者が「この動画をスキップしたい」と投票でき、視聴者数の過半数(最低2票)に達すると自動的に次の動画に飛ぶ民主的なスキップ機能も実装しています。

プレイリスト管理:

  • YouTube / Twitch / ニコニコ動画 の URL に対応
  • 参加者は誰でもプレイリストに動画を追加できる(削除は追加者本人またはスレッド作成者のみ)
  • 1人あたりの追加本数の上限はスレッド作成時に設定可能。同じ動画の重複を防いだり、n分以上の動画は弾くなど(無制限も可)

動画モード:

実況スレッドは作成時に3つのモードから選べます。

  • single: 1つの動画を固定で表示(製品発表会とか長尺動画とか)
  • multiple: プレイリスト機能付き(みんなで動画を持ち寄る使い方)
  • normal: 動画なし、チャットのみ(雑談ルーム的な使い方)

描画パフォーマンスの改善

掲示板の特性上、200件超の長文スレッドが珍しくありません。モバイルで開いたときの「読み込みが終わらない」「スクロールがカクつく」体感をどう抑えるかは、SNSライクなUIと両立させる上での最大の難所でした。

最初は React Router v7 + SSR の力を借りつつ「画面に表示される範囲だけDOMを生成する」仮想スクロール方式を採っていましたが、特定レスへのジャンプ・スクロール位置復元・モバイルの慣性スクロール体験との相性などで運用しづらく、結果的にやめました。今は 「全件DOMに出して、ブラウザの最適化機能と整合のとれた書き方で軽くする」 方式に振り切っています。

バックエンド側の最適化

Markdown を SSR でプレキャッシュ

レスのMarkdown→HTML変換は元はクライアントサイドで毎回行っていました。これをサーバーサイドに寄せ、生成済みHTMLを content_html カラムに保存する方式に変更しています。

  • POST時に buildContentHtml()isomorphic-dompurify のサニタイズ込みでHTMLを生成・保存
  • 取得時はDBの content_html をそのまま返す
  • パースロジックを変えたときは parse_version をbumpして遅延再生成(古いバージョンのHTMLはAPIアクセス時にlazyに更新)
  • 既存レコードは backfillContentHtml.ts で一括バックフィル

これでクライアントは dangerouslySetInnerHTML で表示するだけになり、200件レスの初期描画中に走っていたMarkdownパース処理が消えました。

画像メタデータをDBに保存(CLS=0 + プレースホルダ)

画像投稿時に sharp で寸法(width/height)と thumbhash のbase64を取得して、DBに保存しています。

  • width / height<img> 属性に渡せるので、画像ロード前から正しい縦横比でレイアウトが確定(CLS=0
  • thumbhash は LQIP として画像ロード中の placeholder に使う(ぼかし画像が一瞬出る、Twitterと同じ感じ)
  • メイン画像は WebP 化、サムネイル(200px)と中サイズ(800px)バリアントを生成
  • 既存画像はバッチジョブでバックフィル

スクロール中の画像ロードによるレイアウトジャンプがなくなり、サムネイルへの差し替えで帯域も節約できるようになりました。

APIレスポンスのスリム化

JSONの中身も削れる場所を削っていきました。

  • authorauthorProfile の重複を廃止し author のみに
  • null / 空配列のフィールドは omitNullish ヘルパで省略
  • userId(内部ID)/ isActive / deletedAt などフロント不要の内部フィールドを除去

タイムラインAPIで1アイテムあたり 1336B → 843B(約37%削減)、/full エンドポイントで 482B/item まで縮みました。圧縮後の差はもっと小さくなりますが、圧縮前のメモリ使用量・パース時間が減るので地味に効きます。

Brotli/gzip 圧縮

地味だけど効果が大きかったのが、Hono バックエンドへの compress() ミドルウェア追加です。

import { compress } from "hono/compress";
app.use("*", compress());

それまで api.pomate.ccContent-Encoding なしで生JSONを返していました。200件レスのレスポンスは200KB前後あったので、Brotliをかければ20-30KBに縮みます。フロントの pomate.cc(Cloudflare Workers)は元から自動圧縮されていたのですが、API(Railway上のNode.js + Hono)は手当てが必要でした。Node 18+ の CompressionStream を使うので追加の依存もなしで済みます。

フロントエンド側の軽量化

SVGアイコンのスプライト化

lucide-react は各使用箇所で <svg> + <path> × 4-5個 をインラインで吐き出します。1レスに5個アイコンがあれば、200レスで 5,000ノード前後がアイコンだけで占められる計算です。

そこで、よく使う21個のアイコンを1枚のスプライトSVGに <symbol> として集約し、各使用箇所では <svg><use href="#i-name"/></svg> の2ノードだけで参照する方式に切り替えました。

// アプリのルートで一度だけrender
<svg style={{ display: 'none' }}>
  <defs>
    <symbol id="i-trash2" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
      <path d="M10 11v6" />
      {/* ... */}
    </symbol>
  </defs>
</svg>

// 各使用箇所
<svg className="w-4 h-4"><use href="#i-trash2" /></svg>

ReplyItem と TimelineItemCard の lucide-react 利用を Icon コンポーネントに置き換えるだけで、ノード数とHTMLサイズが目に見えて減りました。

LCP候補画像の preload

スレッド先頭の画像(OPの画像)が LCP(Largest Contentful Paint)になりやすいので、SSRの meta() 関数で <link rel="preload" as="image" fetchpriority="high"> を出力するようにしました。

const lcpImage = thread.mediumUrls?.[0] || thread.imageUrls?.[0];
if (lcpImage) {
  return [
    /* ... */
    {
      tagName: "link",
      rel: "preload",
      as: "image",
      href: lcpImage,
      fetchpriority: "high",
    },
  ];
}

ブラウザは <img> がDOMに現れる前から画像のダウンロードを開始できるため、LCPが100-300ms程度短縮します。

Critical CSS のインライン

Tailwind の本体CSSは <Links /> で render-blocking として読み込まれるため、その間 真っ白な画面が出てしまいます。これを防ぐために、最低限のスタイル(背景色、フォント、画像のmax-width)を <style> で head にインラインしました。

<style>
  html,
  body {
    margin: 0;
    background-color: #f9fafb;
    font-family: Inter, ...;
  }
  html.dark,
  html.dark body {
    background-color: #202225;
    color: #dcddde;
  }
  img {
    max-width: 100%;
    height: auto;
  }
</style>

ダーク/ライト判定はハイドレーション前に動く themeInitScript<html class="dark"> を付ける処理と同期しています。これで 真っ白フラッシュ → ガクッと表示 の体験が消えました。

軽量化の限界

元も子もないですが、 「軽量化のテクを足すより、機能を足さない方が圧倒的に効く」 という当たり前の話でした。content-visibility も SVG sprite も、結局のところ per-reply の重さを軽減するためのテクニックであって、DOMの本質的な重さ(要素数 × ノード数)はあまり変わりません。
ここまでかなりの軽量化を試してみましたが現状、まだ快適とは思っていません…。
ここらへんは設計時点で明暗が分かれそう。SSRにしたのが間違いとは思いませんが、根本的な設計を間違えてるかもしれないです…(もうどうしようもないので見て見ぬふり)

セキュリティ

半匿名SNSにとってセキュリティは「あると安心」ではなく「なければサービスが成立しない」ものです。匿名性が破られた時点で信頼が崩壊するため、多層防御(Defense-in-Depth)の考え方で設計しています。

認証層:  OAuth2 → BetterAuth → セッション管理
認可層:  RBAC → レベルベース権限 → スレッドBAN → タイムアウト
通信層:  HTTPS → HSTS → CSP → CSRF → CORS
入力層:  Zod → レート制限 → SafeRegex → Unicode正規化 → Turnstile
出力層:  HTMLエスケープ → DOMPurify → CSP
ファイル層: MIMEタイプ → Magic Number → シグネチャ検出 → EXIF除去

全部は書ききれないので、特に個人開発で意識した部分をいくつか紹介します。

DDoS対策

個人開発のサービスにとって、受信した時点でインフラコストが爆発するという金銭的なリスクがあるDDoSは最優先で対策するべきセキュリティです。Pomateでは、これを多層で防ぐ設計にしています。とはいえCloudflare様に基本的にはお任せって感じです。

Cloudflareによるネットワーク層の保護:

フロントエンドをCloudflare Workersに載せ、画像配信もCloudflare R2から行っているため、すべてのトラフィックが自動的にCloudflareのネットワークを経由します。これにより L3/L4レイヤのDDoS攻撃(SYNフラッド、UDP増幅攻撃など)はCloudflareが無料で吸収 してくれます(神)
Cloudflare WAFとレート制限ルールも併用することで、アプリケーション層のアタックにもある程度対応してます。

バックエンドの直接露出を避ける:

フロントエンドは前述の通りCloudflareが守ってくれるので、穴があるとしたらこっちです。Railway側のバックエンドAPIはオリジンを直接叩かれないようにCloudflare経由でアクセスするよう設定してます。RailwayのURLを知っている攻撃者に直叩きされると保護が効かなくなるため、オリジン側ではCloudflare経由のリクエストのみ受け付ける制御を入れています。

Cloudflare Turnstileによるボット対策:

投稿などのアクションにはCloudflare Turnstileを導入していたのですが、一部環境だと自動でチェックがつかないなどUXを損なう場面が多かったので投稿のたびに求めるのは廃止しました。
その代わり、ログイン不要で送信できるお問い合わせフォームに関してはTurnstileを実装しています。

アプリケーション層のレート制限:

API単位でレート制限を実装しています。投稿系APIはIP + ユーザー単位、読み取り系APIはIP単位など、用途に応じて閾値を設定。カウンタはアプリケーションのインメモリで保持しています。

コスト爆発の防止:

従量課金のサービス(R2の読み取りリクエストやRedisコマンドなど)については、異常値を検知するアラートを設定。万が一DDoSを受けたときに 請求額が青天井にならない ようにガードを敷いています。

匿名性の保護

前述のオラクル攻撃対策やdailyUserIdに加え、APIレスポンスの設計自体が防御層になっています。投稿者の内部ID(userId)は匿名・顕名を問わずレスポンスから常に除外しており、フロントエンドのDevToolsでネットワークを覗いても特定に使える情報が存在しません。管理者向けのAPIですら、匿名投稿者のアカウント情報を直接返さない設計にしています。

OAuthトークンの即時削除とパスワードの無保管

OAuth認証直後、SNS側のAPIから必要なプロフィール情報(ユーザー名・表示名・Bluesky の DID/ハンドル、Misskey のユーザー名など)を取得したらすぐに、BetterAuthが保存しているaccessTokenrefreshTokenidTokenをDBから即座に無効化(null上書き)しています。Pomateは認証後に継続してユーザーのSNSアカウントを操作する必要がないため、トークンを保持する理由がありません。万が一DBが漏洩しても、OAuthトークンが存在しなければ二次被害を防げます。

さらに、認証をOAuthに一本化したことは、セキュリティ面でも大きなメリットがありました。パスワードをDBに保存しないのでハッシュ漏洩のリスクがゼロです。セッション管理はBetterAuthに委ね、自前で暗号化やトークンローテーションを実装する必要がなくなりました。個人開発ではセキュリティの実装を減らすこと自体が最大の防御だと考えています。

XSSの多層防御

ユーザーが投稿する本文はマークダウンとして処理されるため、XSS対策には3つの層を設けています。

  1. 生成時: markdownFormatterでHTML属性値をエスケープし、URLスキームをhttp:/https:のみに制限(javascript:data:をブロック)
  2. 表示時: DOMPurifyで許可タグ・許可属性をホワイトリスト制御。onclick等のイベントハンドラー属性は一切通さない
  3. ブラウザ側: CSPヘッダーでobject-src 'none'frame-ancestors 'none'等を設定し、攻撃面を制限

スポイラー(ネタバレ隠し)機能はinline onclickを使わず、data-spoiler属性 + イベント委譲で実装しています。CSPと共存させるための工夫です。

ファイルアップロードのセキュリティ

画像・動画のアップロードは拡張子やMIMEタイプだけでなく、ファイルヘッダーのバイナリ(Magic Number)を検証しています。さらにシグネチャベースの検出で、実行可能ファイル(PE/ELF)、PHPウェブシェル、JavaScript注入、SVG内のXSS、Officeマクロ等をブロックしています。

加えて、JPEG/PNG/WebPの画像はsharpでEXIFメタデータを自動除去しています。GPS座標やデバイス情報がうっかり公開されることを防ぐためです。描画データ(gzip圧縮)には展開サイズの上限を設けてgzip爆弾を防いでいます。

SSRF対策

リンクプレビュー機能(URLを貼ると自動でOGP情報を取得する機能)では、取得先のIPアドレスを検証してプライベートIPへのアクセスをブロックしています。DNS rebinding攻撃にも対応するため、DNSで解決した後のIPアドレスを再検証しています。

NGワードのバイパス対策

NGワードフィルタでは、正規表現パターンに対するReDoS(正規表現サービス拒否)攻撃を防ぐため、パターンの複雑度を事前に検証するsafeRegexTestを挟んでいます。また、Unicode正規化(NFKC)を適用することで、見た目が同じだが内部表現が異なる文字(ホモグリフ)を使ったNGワードのバイパスも防いでいます。

開発で意識したこと

モバイル前提

利用者の大半がスマホからアクセスする想定なので、最初からモバイルファーストで設計しています。デスクトップでも使いやすいよう、サイドバーやウィジェットを充実させていますが、コア体験はモバイルで成立するよう作っています。

引き算の設計

個人開発では「何を作るか」より「何を作らないか」の判断のほうが重要だと思っています。Pomateでは意図的に実装しなかった機能がいくつかあります。

フォロー機能を作らない: 「人をフォローする」機能を入れると、匿名ユーザーの行動パターンから個人を特定する手がかりになりえます。また、フォロー/フォロワー数がユーザー間の序列を生み、匿名掲示板的なフラットさが失われます。話題ベースのSNSというコンセプトとも合わないため、現時点では実装しない判断をしています。

独自アカウントを作らない: ちょっとこれは攻めた判断かもしれないですが、認証は外部アカウント連携(X / GitHub / Google / Discord / Misskey.io / Bluesky / Steam)のみに絞り、メールアドレスとパスワードによるアカウント作成は用意していません。パスワード管理・リセットフロー・漏洩対策といったセキュリティ上の負担を個人開発で抱えるのはリスクが大きすぎるのと、ユーザーにとっても「既存のアカウントでワンクリックログイン」のほうが圧倒的に楽です。ただ、何かしらのアカウントとの連携を強いることは一定のユーザーにとって抵抗感があることも事実の為、今後は何かしらの調整を入れるかもしれません。

プロフィールページを用意しない: SNSならプロフィールページが当然のように存在しますが、Pomateでは意図的に用意していません。自己紹介文・投稿一覧・フォロワー数といった要素は「人ベース」の体験を強化する方向であり、話題ベースのコンセプトから遠ざかります。現時点ではページ自体用意せず、設定ページの下部で自分の名前とアバターが確認できるだけとなっています。

こうした引き算は、コンセプトの一貫性を保つためだけでなく、個人開発の限られたリソースを本質的な機能に集中させるためでもあります。

これからやりたいこと

  • ネイティブアプリの開発(結局WEBアプリでの快適な操作性には限界がある)
  • メモ機能(クラスタ単位で備忘録を保管・共有できる仕組み)

開発体制

Pomateは企画・設計・実装・デザイン・運用すべてを一人で行っています。開発にはClaude Codeを活用していますが、設計やスタック選定は自身で考えて実装しました。開発期間は6か月です。

個人開発でもここまでの規模のサービスを作れる時代になったのは、こうしたAIツールの進化によるところが大きいと感じています。

おわりに

「モダンSNSのUXで現代的な掲示板体験」というコンセプトを軸に作ってみました。

できたばかりで人はほとんどいないですが、もし気になった方がいたら一度覗くだけでも来てくれると嬉しいです!(ついでに書き込んでもらえると…)

「こういう機能あったらいいな」「ここが使いにくい」といったフィードバックもお待ちしています。最後まで読んでいただきありがとうございました。

0
0
0

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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?