- Stats page (/stats): play style radar (TAG/LAG/rock/fish), VPIP, PFR, ATS, 3bet, WTSD, W$SD, WWSF, per-street fold rate, aggression frequency, hand win rate and net in BB, per table type, each with a bands-and-explanation sheet. Hands are counted per human player when they finish and stored as totals under data/stats/, keyed by a hash of the player ID (sent in a POST body, never in the URL). - Ranking sheet at the table: everyone seated, ordered by stack or by net (stack minus buy-ins), updating live. - The host can reorder seats by dragging (with a mini table preview and shuffle). Takes effect immediately between hands, otherwise at the start of the next hand; the dealer button stays with the same player. - The full-session review opens in the same tab and its back button returns to the table it was opened from. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
410 lines
17 KiB
JavaScript
410 lines
17 KiB
JavaScript
'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, MODES } = require('./tables');
|
|
const reviews = require('./review/store');
|
|
const { summarize } = require('./review/summary');
|
|
const { buildHandPrompt } = require('./review/prompt');
|
|
const stats = require('./stats/store');
|
|
const { summarize: summarizeStats } = require('./stats/summary');
|
|
|
|
const PUBLIC = path.join(__dirname, '..', 'public');
|
|
// 管理者専用のファイル(public の外に置き、管理者の Cookie があるときだけ返す)
|
|
const ADMIN = path.join(__dirname, '..', 'admin');
|
|
const page = (file, dir = PUBLIC) => (req, res) => res.sendFile(path.join(dir, 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('/favicon.ico', (req, res) => res.sendFile(path.join(PUBLIC, 'assets', 'favicon.ico')));
|
|
app.get('/robots.txt', (req, res) => res.type('text/plain').send('User-agent: *\nDisallow: /\n'));
|
|
app.get('/key', (req, res) => {
|
|
// 入室済みなら飛ばす。ただし /key?admin は管理者キーを入れるために開ける
|
|
const wantsAdmin = req.query.admin !== undefined && !auth.isAdmin(req);
|
|
if (auth.isAuthed(req) && !wantsAdmin) 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('/stats', page('stats.html'));
|
|
|
|
// ---------- 管理者専用: ハンドレンジ学習 ----------
|
|
app.get('/range', auth.requireAdmin, page('range.html', ADMIN));
|
|
app.use(
|
|
'/range/files',
|
|
auth.requireAdmin,
|
|
express.static(ADMIN, {
|
|
index: false,
|
|
dotfiles: 'deny',
|
|
// カード画像を毎回読み込まないよう、本人のブラウザにだけキャッシュさせる
|
|
// (スクリプトと CSS は更新がすぐ反映されるよう毎回確認させる)
|
|
setHeaders: (res, file) =>
|
|
res.setHeader('Cache-Control', file.endsWith('.png') ? 'private, max-age=86400' : 'private, no-cache'),
|
|
})
|
|
);
|
|
|
|
// ---------- 管理者専用: レビュー付きCPU戦の振り返り ----------
|
|
app.get('/review', auth.requireAdmin, page('review.html', ADMIN));
|
|
app.get('/api/reviews', auth.requireAdmin, async (req, res, next) => {
|
|
try {
|
|
res.json(await reviews.list());
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
app.get('/api/reviews/:id', auth.requireAdmin, async (req, res, next) => {
|
|
try {
|
|
const session = await reviews.get(req.params.id);
|
|
if (!session) return res.status(404).json({ error: '見つかりません' });
|
|
res.json({ ...session, summary: summarize(session.hands) });
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
// 1ハンドの状況を AI に質問するための文章
|
|
app.get('/api/reviews/:id/hands/:number/prompt', auth.requireAdmin, async (req, res, next) => {
|
|
try {
|
|
const session = await reviews.get(req.params.id);
|
|
const hand = session && session.hands.find((h) => String(h.number) === req.params.number);
|
|
if (!hand) return res.status(404).json({ error: '見つかりません' });
|
|
res.json({ text: buildHandPrompt(hand) });
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
app.get('/api/me', (req, res) => res.json({ admin: auth.isAdmin(req) }));
|
|
|
|
// 自分の統計。プレイヤーID(席のトークン)を URL やアクセスログに残さないよう POST の本文で受け取る
|
|
app.post('/api/stats', async (req, res, next) => {
|
|
const token = cleanToken(req.body && req.body.token);
|
|
if (!token) return res.status(400).json({ error: 'プレイヤーIDがありません' });
|
|
try {
|
|
res.json(summarizeStats(await stats.get(token)));
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
app.get('/api/tables', (req, res) => res.json(tables.list(auth.isAdmin(req))));
|
|
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 mode = b.mode;
|
|
const cpuCount = b.cpuCount;
|
|
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 + 1, startingStack)) return res.status(400).json({ error: 'BBはSBより大きく・開始スタック以下にしてください' });
|
|
if (blindTimerEnabled && !intIn(blindIntervalMin, 1, 240)) return res.status(400).json({ error: 'インターバルは1〜240分で入力してください' });
|
|
// レビュー付きCPU戦は管理者だけが作れる
|
|
if (!MODES.includes(mode) || (mode === 'review' && !auth.isAdmin(req))) {
|
|
return res.status(400).json({ error: 'テーブルの種類が不正です' });
|
|
}
|
|
const withBots = mode === 'cpu' || mode === 'review';
|
|
if (withBots && !intIn(cpuCount, 1, config.MAX_SEATS - 1)) {
|
|
return res.status(400).json({ error: `CPU の人数は1〜${config.MAX_SEATS - 1}人にしてください` });
|
|
}
|
|
if (!hostToken) return res.status(400).json({ error: 'プレイヤーIDがありません' });
|
|
|
|
const table = tables.create({
|
|
name,
|
|
startingStack,
|
|
smallBlind,
|
|
bigBlind,
|
|
blindTimerEnabled,
|
|
blindIntervalMin: blindTimerEnabled ? blindIntervalMin : 10,
|
|
mode,
|
|
cpuCount: withBots ? cpuCount : 0,
|
|
hostToken,
|
|
});
|
|
broadcastLobby();
|
|
res.json({ id: table.id });
|
|
});
|
|
|
|
// 管理者はどのテーブルでも削除できる(着席者・観戦者にはテーブルが閉じられたことを知らせる)
|
|
app.delete('/api/tables/:id', auth.requireAdmin, (req, res) => {
|
|
const table = tables.get(req.params.id);
|
|
if (!table) return res.status(404).json({ error: 'テーブルが見つかりません' });
|
|
console.log(`[admin] テーブル「${table.name}」を削除`);
|
|
closeTable(table, null, 'deleted');
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
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));
|
|
}
|
|
|
|
// レビュー付きCPU戦は管理者のロビーにだけ出す
|
|
function broadcastLobby() {
|
|
io.to('lobby').emit('lobby:tables', tables.list(false));
|
|
io.to('lobby:admin').emit('lobby:tables', tables.list(true));
|
|
}
|
|
|
|
function changed(table) {
|
|
broadcastTable(table);
|
|
broadcastLobby();
|
|
}
|
|
|
|
// テーブルを削除し、残っている観戦者に知らせる(leaver: 退席した本人。すでにロビーへ戻るので知らせない)
|
|
// reason: 'empty'(人間が全員退席した)/ 'deleted'(管理者が削除した)
|
|
function closeTable(table, leaver, reason = 'empty') {
|
|
tables.delete(table.id);
|
|
for (const s of roomSockets(table.id)) {
|
|
s.leave(roomOf(table.id));
|
|
if (s.data.tableId === table.id) s.data.tableId = null;
|
|
if (s !== leaver) s.emit('table:closed', { reason });
|
|
}
|
|
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);
|
|
socket.data.admin = auth.isAdminCookieHeader(socket.handshake.headers.cookie);
|
|
|
|
// イベントの連打を制限
|
|
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(socket.data.admin ? 'lobby:admin' : 'lobby');
|
|
socket.emit('lobby:tables', tables.list(socket.data.admin));
|
|
});
|
|
|
|
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' });
|
|
// レビュー付きCPU戦は作成者(管理者)だけが入れる。ほかの人にはテーブルがあることも見せない
|
|
if (table.mode === 'review' && !(socket.data.admin && token === table.hostToken)) {
|
|
return reply(ack, { ok: false, error: 'NOT_FOUND' });
|
|
}
|
|
|
|
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);
|
|
});
|
|
|
|
// プレイヤー操作・ホスト操作は同じ形で処理する(操作者は join 時のトークンで決まる)
|
|
const ACTION_TYPES = new Set(['fold', 'check', 'call', 'raise']);
|
|
const actions = {
|
|
'table:leave': (t, token) => (t.leave(token) ? null : '着席していません'),
|
|
'table:start': (t, token) => t.start(token),
|
|
'table:pause': (t, token) => t.togglePause(token),
|
|
'table:action': (t, token, p) => {
|
|
if (!p || typeof p !== 'object' || !ACTION_TYPES.has(p.type)) return '不正なアクションです';
|
|
const amount = p.type === 'raise' ? p.amount : 0;
|
|
if (!Number.isSafeInteger(amount)) return '額が不正です';
|
|
return t.act(token, p.type, amount);
|
|
},
|
|
'table:award': (t, token, p) => {
|
|
// winners: ポットごとの勝者の席番号の配列
|
|
const w = p && typeof p === 'object' ? p.winners : null;
|
|
const ok =
|
|
Array.isArray(w) &&
|
|
w.length <= config.MAX_SEATS &&
|
|
w.every((a) => Array.isArray(a) && a.length <= config.MAX_SEATS && a.every((s) => Number.isInteger(s)));
|
|
return ok ? t.award(token, w) : '勝者の指定が不正です';
|
|
},
|
|
'table:sitOut': (t, token) => t.setSittingOut(token, true),
|
|
'table:sitIn': (t, token) => t.setSittingOut(token, false),
|
|
'table:rebuy': (t, token) => t.rebuy(token),
|
|
'table:next': (t, token) => t.reviewNext(token),
|
|
'table:seating': (t, token, p) => {
|
|
// order: 時計回りに並べた席番号
|
|
const order = p && typeof p === 'object' ? p.order : null;
|
|
const ok = Array.isArray(order) && order.length <= config.MAX_SEATS && order.every((i) => Number.isInteger(i));
|
|
return ok ? t.setSeating(token, order) : '席順の指定が不正です';
|
|
},
|
|
};
|
|
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, p);
|
|
reply(ack, error ? { ok: false, error } : { ok: true });
|
|
if (error) return;
|
|
if (event === 'table:leave' && table.shouldClose()) return closeTable(table, socket);
|
|
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);
|
|
}
|
|
}, 250);
|
|
|
|
// 誰も見ていないテーブルを片付ける
|
|
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 のリバースプロキシ経由での公開を推奨します。');
|
|
}
|
|
});
|