Skip to content

NENE2 でダイレクトメッセージシステムを構築する方法

FT リファレンス: FT278 (NENE2-FT/messagelog) — ダイレクトメッセージ: 会話スレッディング、UNIQUE(initiator_id, recipient_id) + CHECK(initiator_id != recipient_id)、参加者のみのアクセス制御、方向非依存のルックアップ、冪等な会話開始、31 テスト / 96 アサーション PASS。

FT135 でも検証済み — 元の実装。

このガイドでは、Twitter/Instagram スタイルのダイレクトメッセージ(DM)システムを構築します — ユーザーが互いに会話を開始し、メッセージを送信し、参加者のみが会話を読んだり送信したりできます。

NENE2 バージョン: ^1.5
カバートピック: 会話スレッディング、参加者アクセス制御、方向非依存の会話ルックアップ、冪等な会話開始


構築するもの

REST API で:

  • 任意の 2 ユーザーが会話を開始できる(冪等 — 再開始すると既存の会話を返す)
  • 参加者のみがメッセージを送信したり会話のメッセージを読んだりできる
  • ユーザーは自分の会話を一覧表示できる(他のユーザーのはできない)
  • メッセージは会話内で最古順に並べられる

データベーススキーマ

sql
CREATE TABLE users (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    name       TEXT NOT NULL,
    created_at TEXT NOT NULL
);

CREATE TABLE conversations (
    id           INTEGER PRIMARY KEY AUTOINCREMENT,
    initiator_id INTEGER NOT NULL,
    recipient_id INTEGER NOT NULL,
    created_at   TEXT    NOT NULL,
    UNIQUE (initiator_id, recipient_id),
    CHECK  (initiator_id != recipient_id),
    FOREIGN KEY (initiator_id) REFERENCES users(id),
    FOREIGN KEY (recipient_id) REFERENCES users(id)
);

CREATE TABLE messages (
    id              INTEGER PRIMARY KEY AUTOINCREMENT,
    conversation_id INTEGER NOT NULL,
    sender_id       INTEGER NOT NULL,
    content         TEXT    NOT NULL,
    created_at      TEXT    NOT NULL,
    FOREIGN KEY (conversation_id) REFERENCES conversations(id),
    FOREIGN KEY (sender_id)       REFERENCES users(id)
);

UNIQUE (initiator_id, recipient_id) 制約は順序付きペアごとに 1 つの会話を強制します。アプリケーション層は逆方向(Bob→Alice は Alice→Bob と同じ会話を返す)を処理します。


API エンドポイント

メソッドパス説明
POST/usersユーザーを作成する
POST/conversations会話を開始する(冪等)
POST/conversations/{id}/messagesメッセージを送信する(参加者のみ)
GET/conversations/{id}/messagesメッセージを読む(参加者のみ、X-User-Id)
GET/users/{userId}/conversationsユーザーの会話を一覧表示する(自分のみ、X-User-Id)

方向非依存の会話ルックアップ

主な課題: Alice が Bob との会話を開始します(initiator=Alice, recipient=Bob)。その後 Bob も Alice との会話を開始します。2 つの別々の会話ではなく、同じ会話が返る必要があります。

php
public function findConversation(int $userA, int $userB): ?int
{
    $row = $this->executor->fetchOne(
        'SELECT id FROM conversations
         WHERE (initiator_id = ? AND recipient_id = ?)
            OR (initiator_id = ? AND recipient_id = ?)',
        [$userA, $userB, $userB, $userA],
    );

    if ($row === null) {
        return null;
    }

    $arr = (array) $row;

    return isset($arr['id']) ? (int) $arr['id'] : null;
}

public function findOrCreateConversation(int $initiatorId, int $recipientId, string $now): int
{
    $existing = $this->findConversation($initiatorId, $recipientId);

    if ($existing !== null) {
        return $existing;
    }

    $this->executor->execute(
        'INSERT INTO conversations (initiator_id, recipient_id, created_at) VALUES (?, ?, ?)',
        [$initiatorId, $recipientId, $now],
    );

    return (int) $this->executor->lastInsertId();
}

参加者チェック

メッセージを読んだり送信したりする前に、呼び出し元が会話に参加していることを確認します:

php
public function isParticipant(int $conversationId, int $userId): bool
{
    return $this->executor->fetchOne(
        'SELECT id FROM conversations
         WHERE id = ? AND (initiator_id = ? OR recipient_id = ?)',
        [$conversationId, $userId, $userId],
    ) !== null;
}

アクター識別 — X-User-Id ヘッダー

保護されたエンドポイントは呼び出し元を識別するシンプルな X-User-Id ヘッダーを使用します。本番システムでは代わりに JWT クレームを使用します。

php
private function resolveActorId(ServerRequestInterface $request): int
{
    $header = $request->getHeaderLine('X-User-Id');

    return is_numeric($header) ? (int) $header : 0;
}

注意: is_numeric() は非数値文字列に対して false を返します。X-User-Id: adminactorId = 0 → 404。


メッセージ送信ハンドラー

php
private function sendMessage(ServerRequestInterface $request): ResponseInterface
{
    $params         = (array) $request->getAttribute(Router::PARAMETERS_ATTRIBUTE);
    $conversationId = isset($params['conversationId']) && is_numeric($params['conversationId'])
        ? (int) $params['conversationId'] : 0;

    if ($conversationId <= 0 || $this->repo->findConversationById($conversationId) === null) {
        return $this->responseFactory->create(['error' => 'conversation not found'], 404);
    }

    $body     = JsonRequestBodyParser::parse($request);
    $senderId = isset($body['sender_id']) && is_int($body['sender_id']) ? $body['sender_id'] : 0;
    $content  = isset($body['content']) && is_string($body['content']) ? trim($body['content']) : '';

    if ($senderId <= 0 || !$this->repo->findUserById($senderId)) {
        return $this->responseFactory->create(['error' => 'sender not found'], 404);
    }

    if (!$this->repo->isParticipant($conversationId, $senderId)) {
        return $this->responseFactory->create(['error' => 'not a participant'], 403);
    }

    if ($content === '') {
        return $this->responseFactory->create(['error' => 'content is required'], 422);
    }

    $now       = date('Y-m-d H:i:s');
    $messageId = $this->repo->sendMessage($conversationId, $senderId, $content, $now);

    return $this->responseFactory->create([...], 201);
}

チェックの順序: 会話が存在する → 送信者が存在する → 送信者が参加者 → コンテンツが有効。アクセスチェックの前に存在チェックを行うことで会話 ID に関する情報漏洩を防ぎます。


メッセージ読み取りハンドラー — ボディなしの GET

アイデンティティを必要とする GET エンドポイント(listMessageslistUserConversations)では、アクターは X-User-Id ヘッダーから取得します。GET リクエストで JsonRequestBodyParser::parse() を呼び出さないでください — GET リクエストには JSON ボディがないため 400 を返します。

php
private function listMessages(ServerRequestInterface $request): ResponseInterface
{
    $params         = (array) $request->getAttribute(Router::PARAMETERS_ATTRIBUTE);
    $conversationId = isset($params['conversationId']) && is_numeric($params['conversationId'])
        ? (int) $params['conversationId'] : 0;

    if ($conversationId <= 0 || $this->repo->findConversationById($conversationId) === null) {
        return $this->responseFactory->create(['error' => 'conversation not found'], 404);
    }

    // ここで JsonRequestBodyParser::parse() は呼ばない — アクターはヘッダーのみから取得
    $actorId = $this->resolveActorId($request);

    if ($actorId <= 0 || !$this->repo->findUserById($actorId)) {
        return $this->responseFactory->create(['error' => 'actor not found'], 404);
    }

    if (!$this->repo->isParticipant($conversationId, $actorId)) {
        return $this->responseFactory->create(['error' => 'not a participant'], 403);
    }

    $messages = $this->repo->listMessages($conversationId);

    return $this->responseFactory->create(['items' => $messages, 'count' => count($messages)]);
}

メッセージの順序

メッセージは ORDER BY id ASC を使用します — 最古順、チャット UI の慣例に合わせています。フォロー/通知リストは ORDER BY id DESC(最新順)を使用します。UI の期待に基づいて選択してください。


脆弱性評価(FT135)

12 の脆弱性テストで検証:

ID攻撃期待される結果実際の結果
VULN-A他のユーザーの会話からメッセージを読む(IDOR)403Pass
VULN-B参加していない会話にメッセージを送る(IDOR)403Pass
VULN-C他のユーザーの会話リストを読む(IDOR)403Pass
VULN-DlistMessages で X-User-Id が欠落404/403Pass
VULN-E会話リストで X-User-Id が欠落403Pass
VULN-Fパスの負のユーザー ID404Pass
VULN-Gパスのゼロの会話 ID404Pass
VULN-H非数値の X-User-Id ヘッダー200 以外Pass
VULN-Iメッセージコンテンツの SQL インジェクション201(そのまま保存)Pass
VULN-Jメッセージコンテンツの XSS201(そのまま保存)Pass
VULN-K自己会話の試み422Pass
VULN-L100KB のメッセージコンテンツ201 または 413Pass

12 の脆弱性テストすべてが合格。脆弱性なし。


よくある落とし穴

落とし穴修正
GET リクエストで JsonRequestBodyParser::parse() を呼び出すボディを期待する POST/PUT/PATCH ハンドラーのみで呼び出す
UNIQUE (initiator_id, recipient_id) が A→B と B→A を 2 つの会話として防がないINSERT の前に OR クエリで方向非依存のルックアップを行う
コンテンツの有効性確認の後に参加者を確認する情報漏洩を避けるためにコンテンツのに参加者を確認する
ユーザー存在確認なしで任意の非ゼロ整数をアクター ID として受け入れる参加確認の前に常に findUserById(actorId) を確認する

やってはいけないこと

アンチパターンリスク
会話を方向付きで (user_a, user_b) として保存 — A→B と B→A に 2 つの別々の行同じ 2 ユーザーが重複した会話を蓄積する。方向非依存のルックアップが失敗する
CHECK (initiator_id != recipient_id) 制約なしユーザーが自分自身にメッセージを送れる。混乱する自己会話を作成する
UNIQUE (initiator_id, recipient_id) 制約なし並行する会話開始リクエストが同じペアで重複行を作成する
非参加者アクセスに 403 の代わりに 404 を返す非参加者に会話 ID の存在が分かる
GET /conversations/{id}/messagesJsonRequestBodyParser::parse() を呼び出すGET リクエストにボディはない。パーサーは 400 を返す
参加者確認の前にコンテンツの有効性を確認する情報漏洩 — 攻撃者が空のコンテンツを送信して 403 vs 422 を確認することで有効な会話 ID を探れる
is_numeric() を使って int にキャストせず > 0 でチェックしないis_numeric("0") は true。ユーザー ID 0 が有効として扱われる
参加確認後のユーザー存在チェックをスキップisParticipant() は FK のみを確認する — DB にカスケードがない場合、削除または存在しないユーザーが残る
任意のユーザーが他のユーザーの会話を一覧表示することを許可IDOR — 会話リストを返す前に常に actorId === targetUserId を確認する
メッセージの id ASC のインデックスのみインデックスがないと大きなメッセージ履歴で ORDER BY が遅くなる

MIT ライセンスの下で公開されています。