Initial commit: private Texas Hold'em ring game site (lobby + table stub)

- 6-digit access key gate with long-lived HttpOnly cookie, brute-force limits
- Lobby with saved player name and live table list
- Table creation (name, starting stack, blinds, blind timer)
- Table play screen stub (seats, button/SB/BB, phase, blinds, timer)
- Security hardening: strict CSP, origin checks, host pinning, input sanitizing

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
agomizi1cmandClaude Opus 5.5 committed 2026-10-15 16:51:15 +09:00
commit b2c91062f1
22 files changed
+3450

No files matched your search

+274
View File
@@ -0,0 +1,274 @@
'use strict';
const path = require('path');
const http = require('http');
const express = require('express');
const { Server } = require('socket.io');
const config = require('./config');
const auth = require('./auth');
const { TableManager } = require('./tables');
const PUBLIC = path.join(__dirname, '..', 'public');
const page = (file) => (req, res) => res.sendFile(path.join(PUBLIC, file));
const app = express();
app.set('trust proxy', 'loopback'); // nginx 等のリバースプロキシ越しでも req.ip / req.secure を正しく
app.disable('x-powered-by');
app.use(auth.requireAllowedHost);
// ---------- セキュリティヘッダ ----------
const CSP = [
"default-src 'self'",
"script-src 'self'",
"style-src 'self'",
"img-src 'self' data:",
"connect-src 'self'",
"font-src 'self'",
"object-src 'none'",
"base-uri 'none'",
"form-action 'self'",
"frame-ancestors 'none'",
].join('; ');
app.use((req, res, next) => {
res.setHeader('Content-Security-Policy', CSP);
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('Referrer-Policy', 'no-referrer');
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
res.setHeader('Cross-Origin-Resource-Policy', 'same-origin');
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=()');
res.setHeader('X-Robots-Tag', 'noindex, nofollow');
if (req.secure) res.setHeader('Strict-Transport-Security', 'max-age=31536000');
// HTML と API はキャッシュさせない(共有端末やプロキシに残さない)
if (!req.path.startsWith('/assets/')) res.setHeader('Cache-Control', 'no-store');
next();
});
app.use(express.json({ limit: '4kb', strict: true }));
const server = http.createServer(app);
server.headersTimeout = 20 * 1000;
server.requestTimeout = 30 * 1000;
const io = new Server(server, {
// WebSocket のみ(ハンドシェイクに必ず Origin が付くので Cross-Site WebSocket を確実に弾ける)
transports: ['websocket'],
maxHttpBufferSize: 4 * 1024,
allowRequest: (req, callback) => {
callback(
null,
auth.isAllowedHost(req.headers) && auth.isSameOrigin(req.headers) && auth.isAuthedCookieHeader(req.headers.cookie)
);
},
});
const tables = new TableManager();
// ---------- 認証不要 ----------
app.use('/assets', express.static(path.join(PUBLIC, 'assets'), { index: false, dotfiles: 'deny' }));
app.get('/robots.txt', (req, res) => res.type('text/plain').send('User-agent: *\nDisallow: /\n'));
app.get('/key', (req, res) => {
if (auth.isAuthed(req)) return res.redirect(auth.safeNext(req.query.next));
page('key.html')(req, res);
});
app.post('/api/auth', auth.handleLogin);
// ---------- ここから先は鍵が必要 ----------
app.use(auth.requireAuth);
app.use(auth.requireSameOrigin);
app.get('/', page('lobby.html'));
app.get('/create', page('create.html'));
app.get('/table/:id', page('table.html'));
app.get('/api/tables', (req, res) => res.json(tables.list()));
app.get('/api/tables/suggest-name', (req, res) => res.json({ name: tables.suggestName() }));
const intIn = (v, min, max) => Number.isInteger(v) && v >= min && v <= max;
app.post('/api/tables', (req, res) => {
const b = req.body && typeof req.body === 'object' ? req.body : {};
const name = cleanText(b.name, 24);
const startingStack = b.startingStack;
const smallBlind = b.smallBlind;
const bigBlind = b.bigBlind;
const blindTimerEnabled = b.blindTimerEnabled === true;
const blindIntervalMin = b.blindIntervalMin;
const hostToken = cleanToken(b.token);
if (tables.tables.size >= config.MAX_TABLES) return res.status(429).json({ error: 'テーブル数が上限に達しています' });
if (!name) return res.status(400).json({ error: 'テーブル名は1〜24文字で入力してください' });
if (tables.nameTaken(name)) return res.status(409).json({ error: '同じ名前のテーブルがすでにあります' });
if (!intIn(startingStack, 10, 100000000)) return res.status(400).json({ error: '開始スタックが不正です' });
if (!intIn(smallBlind, 1, startingStack)) return res.status(400).json({ error: 'SBが不正です' });
if (!intIn(bigBlind, smallBlind, startingStack)) return res.status(400).json({ error: 'BBはSB以上・開始スタック以下にしてください' });
if (blindTimerEnabled && !intIn(blindIntervalMin, 1, 240)) return res.status(400).json({ error: 'インターバルは1〜240分で入力してください' });
if (!hostToken) return res.status(400).json({ error: 'プレイヤーIDがありません' });
const table = tables.create({
name,
startingStack,
smallBlind,
bigBlind,
blindTimerEnabled,
blindIntervalMin: blindTimerEnabled ? blindIntervalMin : 10,
hostToken,
});
broadcastLobby();
res.json({ id: table.id });
});
app.use((req, res) => res.status(404).type('text/plain').send('Not Found'));
// 内部情報(スタックトレース等)を返さない
app.use((err, req, res, _next) => {
const status = err.status || err.statusCode || 500;
if (status >= 500) console.error(err);
res.status(status).json({ error: status >= 500 ? 'サーバエラー' : '不正なリクエストです' });
});
// ---------- 入力の正規化 ----------
function cleanToken(t) {
return typeof t === 'string' && /^[0-9a-f]{32}$/.test(t) ? t : null;
}
// 制御文字・不可視文字(方向制御など)を除き、空白をまとめて文字数で切る
function cleanText(s, max) {
if (typeof s !== 'string') return '';
const chars = Array.from(s.replace(/[\p{Cc}\p{Cf}\p{Co}\p{Cn}\p{Zl}\p{Zp}]/gu, '').replace(/\s+/g, ' ').trim());
return chars.slice(0, max).join('').trim();
}
// ---------- Socket.IO ----------
const roomOf = (id) => 'table:' + id;
function roomSockets(tableId) {
const ids = io.sockets.adapter.rooms.get(roomOf(tableId));
return ids ? [...ids].map((id) => io.sockets.sockets.get(id)).filter(Boolean) : [];
}
function broadcastTable(table) {
const sockets = roomSockets(table.id);
const connected = new Set(sockets.map((s) => s.data.token));
for (const s of sockets) s.emit('table:state', table.publicState(s.data.token, connected));
}
function broadcastLobby() {
io.to('lobby').emit('lobby:tables', tables.list());
}
function changed(table) {
broadcastTable(table);
broadcastLobby();
}
// 二重チェック(allowRequest を通った後も念のため)
io.use((socket, next) => {
if (auth.isAuthedCookieHeader(socket.handshake.headers.cookie)) next();
else next(new Error('unauthorized'));
});
io.on('connection', (socket) => {
const reply = (ack, payload) => typeof ack === 'function' && ack(payload);
// イベントの連打を制限
let windowStart = Date.now();
let count = 0;
socket.use((_packet, next) => {
const now = Date.now();
if (now - windowStart >= 1000) {
windowStart = now;
count = 0;
}
if (++count > config.SOCKET_EVENTS_PER_SEC) return socket.disconnect(true);
next();
});
socket.on('lobby:subscribe', () => {
socket.join('lobby');
socket.emit('lobby:tables', tables.list());
});
socket.on('table:join', (p, ack) => {
if (!p || typeof p !== 'object') return reply(ack, { ok: false, error: 'BAD_REQUEST' });
const table = typeof p.tableId === 'string' && tables.get(p.tableId);
if (!table) return reply(ack, { ok: false, error: 'NOT_FOUND' });
const token = cleanToken(p.token);
const name = cleanText(p.name, 12);
if (!token) return reply(ack, { ok: false, error: 'BAD_TOKEN' });
if (!name) return reply(ack, { ok: false, error: 'NAME_REQUIRED' });
const prev = socket.data.tableId && tables.get(socket.data.tableId);
if (prev && prev !== table) {
socket.leave(roomOf(prev.id));
broadcastTable(prev);
}
socket.data.token = token;
socket.data.tableId = table.id;
socket.join(roomOf(table.id));
table.emptySince = null;
const seat = table.join(token, name);
reply(ack, { ok: true, seat });
changed(table);
});
// ホスト操作・退席は同じ形で処理する
const actions = {
'table:leave': (t, token) => (t.leave(token) ? null : '着席していません'),
'table:start': (t, token) => t.start(token),
'table:nextPhase': (t, token) => t.nextPhase(token),
'table:pause': (t, token) => t.togglePause(token),
};
for (const [event, fn] of Object.entries(actions)) {
socket.on(event, (_p, ack) => {
const table = socket.data.tableId && tables.get(socket.data.tableId);
if (!table) return reply(ack, { ok: false, error: 'NOT_FOUND' });
const error = fn(table, socket.data.token);
reply(ack, error ? { ok: false, error } : { ok: true });
if (!error) changed(table);
});
}
socket.on('disconnect', () => {
const table = socket.data.tableId && tables.get(socket.data.tableId);
if (table) broadcastTable(table); // 接続中表示を更新
});
});
// ブラインドタイマー
setInterval(() => {
const now = Date.now();
for (const table of tables.tables.values()) {
if (table.tick(now)) changed(table);
}
}, 1000);
// 誰も見ていないテーブルを片付ける
setInterval(() => {
const now = Date.now();
let removed = false;
for (const table of [...tables.tables.values()]) {
if (roomSockets(table.id).length > 0) {
table.emptySince = null;
continue;
}
if (table.emptySince == null) table.emptySince = now;
if (now - table.emptySince > config.EMPTY_TABLE_TTL_MS) {
tables.delete(table.id);
removed = true;
}
}
if (removed) broadcastLobby();
}, 60 * 1000);
server.listen(config.PORT, config.HOST, () => {
console.log(`Poker server listening on http://${config.HOST}:${config.PORT}`);
if (config.PUBLIC_ORIGIN) console.log(`[config] 公開URL: ${config.PUBLIC_ORIGIN}(他のホスト名は拒否)`);
if (config.HOST !== '127.0.0.1' && config.HOST !== '::1') {
console.warn('[config] 外部から直接アクセスできる設定です。HTTPS のリバースプロキシ経由での公開を推奨します。');
}
});