'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'); // 管理者専用のファイル(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', // カード画像を毎回読み込まないよう、本人のブラウザにだけキャッシュさせる setHeaders: (res) => res.setHeader('Cache-Control', 'private, max-age=86400'), }) ); app.get('/api/me', (req, res) => res.json({ admin: auth.isAdmin(req) })); 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); }); // プレイヤー操作・ホスト操作は同じ形で処理する(操作者は 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), }; 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) 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 のリバースプロキシ経由での公開を推奨します。'); } });