Reviews and replays get a "copy for AI" button that builds a text describing the hand (stacks, positions, actions, pots, board, revealed cards, result and the site's own judgments) plus questions, ready to paste into an AI chat. Hidden opponent cards are left out so the evaluation is based only on what Hero could see. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
390 lines
16 KiB
JavaScript
390 lines
16 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 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('/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) }));
|
|
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),
|
|
};
|
|
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 のリバースプロキシ経由での公開を推奨します。');
|
|
}
|
|
});
|