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?

【第041回】存在しない部屋へのアクセスを遮断せよ!P2Pチャット参加時のルーム存在検証とエラーハンドリング

0
Posted at

こんにちは!2ちゃんねる互換掲示板システム「 つゔぁいちゃんねる(2ch.biz) 」の開発録、第41回です。

前回(第40回)までに、モバイルWebにおけるソフトウェアキーボード追従とヘッダー固定の死闘を制し、スマートフォンでも極上の操作性を手に入れました。

しかし、本格的にP2Pチャットが運用され、掲示板内でルームの招待URLが飛び交うようになると、今度はユーザーから不可解な問い合わせが相次ぐようになりました。

「友達から『いま雑談部屋にいるから来て!』とURLをもらったのに、入室したら 誰もいなくて自分1人しかいない んですが…」
「30分待っても誰も発言しないので友達に聞いたら、実はURLの末尾が1文字抜けていただけでした!」

なぜ、存在しないはずのURLで入室できてしまい、ユーザーは孤独な待ちぼうけを食らっていたのか?
本稿では、シグナリングサーバーに潜んでいた「無条件自動生成の罠」を解明し、存在しない部屋へのアクセスを確実にブロックするルーム存在検証とエラーハンドリングの設計を解説します!


1. 誰もいない異次元空間:幽霊部屋の正体

一般的なWebサービスであれば、存在しないURL(/room/xyz_not_exist)にアクセスすれば 404 Not Found が返るのが当たり前です。

しかし、私たちのP2PチャットのシグナリングAPI(join エンドポイント)には、開発初期のプロトタイプ段階で書かれた 「あるコード」 がそのまま残っていました。

// 【Before: 開発初期のシグナリングコード】
app.post('/api/p2p/signal', (req, res) => {
    const { action, roomId, peerId } = req.body;

    if (action === 'join') {
        let room = rooms.get(roomId);

        // ★罠: 部屋が存在しなかったら、親切心で勝手に新規作成していた!
        if (!room) {
            room = createNewRoom(roomId);
            rooms.set(roomId, room);
        }

        addPeerToRoom(room, peerId);
        res.json({ success: true, isHost: room.hostId === peerId });
    }
});

このコードの何が問題だったのでしょうか?

部屋を「新しく作る(Create)」ときと、「既存の部屋に参加する(Join)」ときで、まったく同じAPIエンドポイントを叩いていたため、サーバー側から見ると 「このユーザーが部屋を建てようとしているのか、既存の部屋に入ろうとしているのか」が区別できなかった のです。

その結果、以下のような喜劇(ユーザーにとっては悲劇)が発生していました。


2. 解決策:参加モード(isCreate: false)の明示と404ハンドリング

この問題を解決するための設計は明快です。
クライアントがシグナリングを送る際、 「新規作成なのか、既存参加なのか」という意図(Intent) を厳格に宣言させることです。

必須要件

  1. 意図の分離 : 「部屋を作る」フォームからは isCreate: true を送信し、「参加する」フォームや招待URLクリック時は isCreate: false を送信する。
  2. 存在判定 : isCreate: false で部屋が存在しない場合、シグナリングサーバーは一切メモリを確保せず、ROOM_NOT_FOUND エラーを返す。
  3. 安全なロールバック : クライアントはエラーを受信した際、チャット画面を展開せず、ロビー画面(入室画面)に留まって「指定された部屋は存在しないか、終了しました」と明確に警告する。

3. 実装の全貌

(1) クライアント側:意図フラグの付与

入室関数 joinRoom において、自分が部屋のイニシエーター(作成者)かどうかを示すフラグ isInitiator をサーバーへ伝達します。

/**
 * ルーム参加処理
 */
async function joinRoom(roomId, pass, isInitiator = false, policy = 'open', persistPolicy = 'allow') {
    try {
        const payload = {
            roomId: roomId,
            passcode: pass,
            isCreate: isInitiator, // ★部屋作成モードなら true、既存参加なら false
            policy: policy,
            persistPolicy: persistPolicy
        };

        const res = await sendSignal('join', payload);

        // サーバー側で部屋が存在しないと判定された場合
        if (!res.success && res.error === 'ROOM_NOT_FOUND') {
            showToast('⚠️ ' + (res.message || '指定されたチャットルームは存在しないか、全員が退席したため終了しました。'), 'error');
            // チャット画面を開かず、ロビー画面を維持
            return false;
        }

        // 正常な入室処理を続行...
        enterChatUI(res);
        return true;
    } catch (err) {
        showToast('通信エラーが発生しました: ' + err.message, 'error');
        return false;
    }
}

(2) サーバー側:存在検証とエラー拒否 (サーバー & API)

// server.js: シグナリング参加ハンドラ
if (action === 'join') {
    const isCreate = (payload && payload.isCreate === true);
    let room = rooms.get(roomId);

    // ★部屋が存在しない場合の分岐処理
    if (!room) {
        if (!isCreate) {
            // 参加モードなのに部屋がない場合は即座に 404 拒否!
            return res.json({
                success: false,
                error: 'ROOM_NOT_FOUND',
                message: '指定されたチャットルームは存在しないか、全員が退席したため終了しました'
            });
        }

        // 作成モードの場合のみ、新規作成を許可
        room = createNewRoom(roomId, payload);
        rooms.set(roomId, room);
    }

    // 既存ルームへの追加処理...
}

4. 自動テストによる検証

この修正が正しく機能するかを検証するため、テストスクリプトを作成して実行しました。

// test_room_validation.js
const assert = require('assert');

async function runTests() {
    // 1. 存在しない部屋に isCreate: false で参加を試みる
    const res1 = await postSignal({
        action: 'join',
        roomId: 'non_existent_room_9999',
        isCreate: false
    });

    assert.strictEqual(res1.success, false, '存在しない部屋への参加は失敗すること');
    assert.strictEqual(res1.error, 'ROOM_NOT_FOUND', 'エラーコードが ROOM_NOT_FOUND であること');
    console.log('✅ テスト1通過: 存在しない部屋へのアクセスが正しく拒否されました');

    // 2. isCreate: true で新規作成する
    const res2 = await postSignal({
        action: 'join',
        roomId: 'valid_room_123',
        isCreate: true
    });
    assert.strictEqual(res2.success, true, '部屋の新規作成は成功すること');
    console.log('✅ テスト2通過: 部屋の新規作成が成功しました');

    // 3. 作成された部屋に isCreate: false で参加する
    const res3 = await postSignal({
        action: 'join',
        roomId: 'valid_room_123',
        isCreate: false
    });
    assert.strictEqual(res3.success, true, '実在する部屋への参加は成功すること');
    console.log('✅ テスト3通過: 実在する部屋への通常参加が成功しました');
}

runTests();

テストはすべてパスし、タイポや終了済みURLへのアクセスが 100% 確実に遮断されることが実証されました。


5. まとめと次回予告

「存在しない部屋には入れない」「タイポしたら即座に教える」――。
当たり前のことのようですが、サーバーレスP2Pという特殊な環境下では、意識的に境界線を引かなければ「親切心の自動生成」が仇となってユーザー体験を壊してしまいます。

しかし、このルーム存在検証を導入したことで、私たちはさらに深い 「P2P特有のゾンビ部屋問題」 に直面することになります。

「部屋の参加者全員が一度退席して部屋が消滅した」
「その後、別の誰かが たまたま同じルームID で部屋を新しく建てた」
「すると、古い部屋のキャッシュを持った過去の参加者が、まったく無関係な新しい部屋にゾンビのように乱入してきてしまった!」

同じルームIDの「世代交代」をどうやって見分けるのか?

次回は、 【第042回】振り出し回数カウンターで幽霊部屋を排除!ルームIDの世代管理とステートレス識別 をお届けします。お楽しみに!

本番サービス : https://2ch.biz/

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?