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:
commit
b2c91062f1
22 files changed
+3450
No files matched your search
+214
@@ -0,0 +1,214 @@
|
||||
'use strict';
|
||||
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const net = require('net');
|
||||
const path = require('path');
|
||||
const config = require('./config');
|
||||
|
||||
const COOKIE_NAME = 'pk_auth';
|
||||
// ブラウザの上限が400日なので、アクセスのたびに延長する(実質無期限)
|
||||
const COOKIE_MAX_AGE_SEC = 400 * 24 * 60 * 60;
|
||||
|
||||
// Cookie 署名用の秘密値。再起動してもログイン状態が保たれるようファイルに保存する。
|
||||
// data/session-secret を削除して再起動すると、全員の入室状態を取り消せる。
|
||||
function loadSecret() {
|
||||
if (process.env.SESSION_SECRET) return process.env.SESSION_SECRET;
|
||||
const file = path.join(__dirname, '..', 'data', 'session-secret');
|
||||
try {
|
||||
const s = fs.readFileSync(file, 'utf8').trim();
|
||||
if (s.length >= 32) return s;
|
||||
} catch { /* 初回 */ }
|
||||
const secret = crypto.randomBytes(32).toString('hex');
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
||||
fs.writeFileSync(file, secret, { mode: 0o600 });
|
||||
return secret;
|
||||
}
|
||||
|
||||
// 鍵を変えると既存の Cookie はすべて無効になる
|
||||
const AUTH_TOKEN = crypto
|
||||
.createHmac('sha256', loadSecret())
|
||||
.update('access:' + config.ACCESS_KEY)
|
||||
.digest('base64url');
|
||||
|
||||
function parseCookies(header) {
|
||||
const out = {};
|
||||
if (typeof header !== 'string') return out;
|
||||
for (const part of header.split(';')) {
|
||||
const i = part.indexOf('=');
|
||||
if (i < 0) continue;
|
||||
try {
|
||||
out[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim());
|
||||
} catch { /* 不正なエンコードは無視 */ }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function safeEqual(a, b) {
|
||||
// 長さの違いも漏らさないようハッシュ同士を比較する
|
||||
const ha = crypto.createHash('sha256').update(String(a)).digest();
|
||||
const hb = crypto.createHash('sha256').update(String(b)).digest();
|
||||
return crypto.timingSafeEqual(ha, hb);
|
||||
}
|
||||
|
||||
function isAuthedCookieHeader(cookieHeader) {
|
||||
const value = parseCookies(cookieHeader)[COOKIE_NAME];
|
||||
return Boolean(value) && safeEqual(value, AUTH_TOKEN);
|
||||
}
|
||||
|
||||
function isAuthed(req) {
|
||||
return isAuthedCookieHeader(req.headers.cookie);
|
||||
}
|
||||
|
||||
function setAuthCookie(req, res) {
|
||||
const parts = [
|
||||
`${COOKIE_NAME}=${AUTH_TOKEN}`,
|
||||
'Path=/',
|
||||
`Max-Age=${COOKIE_MAX_AGE_SEC}`,
|
||||
'HttpOnly',
|
||||
'SameSite=Lax',
|
||||
];
|
||||
if (req.secure) parts.push('Secure');
|
||||
res.setHeader('Set-Cookie', parts.join('; '));
|
||||
}
|
||||
|
||||
// ブラウザからのリクエストが同じサイト発かどうか(CSRF / Cross-Site WebSocket 対策)
|
||||
function isSameOrigin(headers) {
|
||||
const origin = headers.origin;
|
||||
if (!origin) return false;
|
||||
if (config.PUBLIC_ORIGIN) return origin === config.PUBLIC_ORIGIN && isAllowedHost(headers);
|
||||
try {
|
||||
return new URL(origin).host === headers.host;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// PUBLIC_ORIGIN 設定時は、そのドメイン宛て以外のリクエスト(IP直打ち等)を拒否する
|
||||
const PUBLIC_HOST = config.PUBLIC_ORIGIN ? new URL(config.PUBLIC_ORIGIN).host : null;
|
||||
function isAllowedHost(headers) {
|
||||
return !PUBLIC_HOST || String(headers.host || '').toLowerCase() === PUBLIC_HOST;
|
||||
}
|
||||
|
||||
function requireAllowedHost(req, res, next) {
|
||||
if (isAllowedHost(req.headers)) return next();
|
||||
res.status(421).type('text/plain').send('Misdirected Request');
|
||||
}
|
||||
|
||||
// ---- 失敗回数制限 ----
|
||||
|
||||
// IPv6 は1人が /64 を丸ごと持っていることが多いので /64 単位でまとめる
|
||||
function clientKey(ip) {
|
||||
let addr = String(ip || '');
|
||||
if (addr.startsWith('::ffff:')) addr = addr.slice(7);
|
||||
if (net.isIPv6(addr)) {
|
||||
const full = expandIPv6(addr);
|
||||
return full ? full.slice(0, 4).join(':') + '::/64' : addr;
|
||||
}
|
||||
return addr;
|
||||
}
|
||||
|
||||
function expandIPv6(addr) {
|
||||
const [head, tail] = addr.split('::');
|
||||
const h = head ? head.split(':') : [];
|
||||
const t = tail !== undefined && tail ? tail.split(':') : [];
|
||||
if (tail === undefined && h.length !== 8) return null;
|
||||
const fill = new Array(8 - h.length - t.length).fill('0');
|
||||
return [...h, ...fill, ...t];
|
||||
}
|
||||
|
||||
class Window {
|
||||
constructor(ms) {
|
||||
this.ms = ms;
|
||||
this.count = 0;
|
||||
this.resetAt = Date.now() + ms;
|
||||
}
|
||||
get expired() {
|
||||
return this.resetAt <= Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
const perClient = new Map(); // clientKey -> Window
|
||||
let global = new Window(config.AUTH_GLOBAL_WINDOW_MS);
|
||||
|
||||
function clientWindow(key) {
|
||||
let w = perClient.get(key);
|
||||
if (!w || w.expired) {
|
||||
w = new Window(config.AUTH_FAILURE_WINDOW_MS);
|
||||
perClient.set(key, w);
|
||||
}
|
||||
return w;
|
||||
}
|
||||
|
||||
setInterval(() => {
|
||||
for (const [k, w] of perClient) if (w.expired) perClient.delete(k);
|
||||
}, 60 * 1000).unref();
|
||||
|
||||
function tooMany(res, w) {
|
||||
const retryMin = Math.max(1, Math.ceil((w.resetAt - Date.now()) / 60000));
|
||||
res.setHeader('Retry-After', String(retryMin * 60));
|
||||
return res.status(429).json({ error: `試行回数が多すぎます。${retryMin}分後に再度お試しください。` });
|
||||
}
|
||||
|
||||
function handleLogin(req, res) {
|
||||
if (!isSameOrigin(req.headers)) return res.status(403).json({ error: 'forbidden' });
|
||||
|
||||
const key = clientKey(req.ip);
|
||||
const w = clientWindow(key);
|
||||
if (global.expired) global = new Window(config.AUTH_GLOBAL_WINDOW_MS);
|
||||
if (w.count >= config.AUTH_MAX_FAILURES) return tooMany(res, w);
|
||||
if (global.count >= config.AUTH_GLOBAL_MAX_FAILURES) return tooMany(res, global);
|
||||
|
||||
const input = typeof (req.body && req.body.key) === 'string' ? req.body.key.trim() : '';
|
||||
if (!/^\d{6}$/.test(input)) {
|
||||
return res.status(400).json({ error: '6桁の数字を入力してください。' });
|
||||
}
|
||||
if (!safeEqual(input, config.ACCESS_KEY)) {
|
||||
w.count++;
|
||||
global.count++;
|
||||
console.warn(`[auth] 鍵の入力ミス from ${key} (${w.count}/${config.AUTH_MAX_FAILURES}, 全体 ${global.count}/${config.AUTH_GLOBAL_MAX_FAILURES})`);
|
||||
if (global.count === config.AUTH_GLOBAL_MAX_FAILURES) {
|
||||
console.warn('[auth] 全体の失敗回数が上限に達したため、新規の鍵入力を一時停止します');
|
||||
}
|
||||
const left = config.AUTH_MAX_FAILURES - w.count;
|
||||
return res.status(401).json({ error: left > 0 ? `鍵が違います。(あと${left}回)` : '鍵が違います。' });
|
||||
}
|
||||
perClient.delete(key);
|
||||
console.log(`[auth] 入室 from ${key}`);
|
||||
setAuthCookie(req, res);
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
// /key 以外のページ・API を保護する
|
||||
function requireAuth(req, res, next) {
|
||||
if (isAuthed(req)) {
|
||||
if (req.method === 'GET' && !req.path.startsWith('/api/')) setAuthCookie(req, res);
|
||||
return next();
|
||||
}
|
||||
if (req.path.startsWith('/api/')) return res.status(401).json({ error: 'unauthorized' });
|
||||
if (req.method === 'GET') return res.redirect('/key?next=' + encodeURIComponent(req.originalUrl));
|
||||
res.status(401).end();
|
||||
}
|
||||
|
||||
// 状態を変える API は同じサイトからのみ受け付ける
|
||||
function requireSameOrigin(req, res, next) {
|
||||
if (req.method === 'GET' || req.method === 'HEAD' || isSameOrigin(req.headers)) return next();
|
||||
res.status(403).json({ error: 'forbidden' });
|
||||
}
|
||||
|
||||
// オープンリダイレクト防止: 自サイト内の通常のパスだけ許可("//x" や "/\x" は外部扱いされるので拒否)
|
||||
function safeNext(next) {
|
||||
return typeof next === 'string' && /^\/(?![/\\])[^\\\s]*$/.test(next) && next.length <= 200 ? next : '/';
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isAuthed,
|
||||
isAuthedCookieHeader,
|
||||
isSameOrigin,
|
||||
isAllowedHost,
|
||||
requireAllowedHost,
|
||||
handleLogin,
|
||||
requireAuth,
|
||||
requireSameOrigin,
|
||||
safeNext,
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// プロジェクト直下の .env を読み込む(既存の環境変数は上書きしない)。
|
||||
// 秘密情報はソースコードに書かず .env(git 管理外)か環境変数で渡す。
|
||||
function loadDotEnv(file) {
|
||||
let text;
|
||||
try {
|
||||
text = fs.readFileSync(file, 'utf8');
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (process.platform !== 'win32' && (fs.statSync(file).mode & 0o077) !== 0) {
|
||||
console.warn(`[config] ${file} が他ユーザーから読める状態です。chmod 600 してください。`);
|
||||
}
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*?)\s*$/);
|
||||
if (!m || line.trim().startsWith('#')) continue;
|
||||
const value = m[2].replace(/^(['"])(.*)\1$/, '$2');
|
||||
if (process.env[m[1]] === undefined) process.env[m[1]] = value;
|
||||
}
|
||||
}
|
||||
loadDotEnv(path.join(__dirname, '..', '.env'));
|
||||
|
||||
const ACCESS_KEY = process.env.ACCESS_KEY;
|
||||
if (!ACCESS_KEY || !/^\d{6}$/.test(ACCESS_KEY)) {
|
||||
console.error('[config] ACCESS_KEY(6桁の数字)が未設定です。.env か環境変数で指定してください。');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PORT: Number(process.env.PORT) || 4671,
|
||||
|
||||
// 既定では自分自身(127.0.0.1)でのみ待ち受け、外部には nginx 等の HTTPS 経由で公開する。
|
||||
// どうしても直接公開する場合のみ HOST=0.0.0.0 にする(通信が平文になるので非推奨)。
|
||||
HOST: process.env.HOST || '127.0.0.1',
|
||||
|
||||
// 公開URL(例: https://poker.kanawo.net)。設定するとこのドメイン以外からのアクセスを拒否する。
|
||||
// ローカル開発時は未設定のままにする。
|
||||
PUBLIC_ORIGIN: process.env.PUBLIC_ORIGIN ? new URL(process.env.PUBLIC_ORIGIN).origin : null,
|
||||
|
||||
// 参加キー(6桁の数字)。.env の ACCESS_KEY
|
||||
ACCESS_KEY,
|
||||
|
||||
// 1テーブルの最大着席数
|
||||
MAX_SEATS: 9,
|
||||
|
||||
// 同時に存在できるテーブル数の上限
|
||||
MAX_TABLES: 30,
|
||||
|
||||
// 誰も接続していないテーブルを削除するまでの時間
|
||||
EMPTY_TABLE_TTL_MS: 30 * 60 * 1000,
|
||||
|
||||
// 鍵入力のブルートフォース対策
|
||||
// IP(IPv6 は /64 単位)ごと: WINDOW 内で MAX_FAILURES 回まで
|
||||
AUTH_MAX_FAILURES: 5,
|
||||
AUTH_FAILURE_WINDOW_MS: 30 * 60 * 1000,
|
||||
// サーバ全体: 1時間に GLOBAL_MAX_FAILURES 回失敗したら新規の鍵入力を一時停止
|
||||
// (入室済みの人は Cookie があるので影響なし)
|
||||
AUTH_GLOBAL_MAX_FAILURES: 30,
|
||||
AUTH_GLOBAL_WINDOW_MS: 60 * 60 * 1000,
|
||||
|
||||
// Socket.IO のイベント数上限(1接続あたり / 秒)
|
||||
SOCKET_EVENTS_PER_SEC: 10,
|
||||
};
|
||||
+274
@@ -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 のリバースプロキシ経由での公開を推奨します。');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,271 @@
|
||||
'use strict';
|
||||
|
||||
const crypto = require('crypto');
|
||||
const config = require('./config');
|
||||
const Blinds = require('../public/assets/blinds');
|
||||
|
||||
// 進行フェーズ。プレイ処理は未実装なので、ホストが手動で進めるスタブ。
|
||||
const HAND_PHASES = ['PREFLOP', 'FLOP', 'TURN', 'RIVER', 'SHOWDOWN'];
|
||||
|
||||
class Table {
|
||||
constructor(opts) {
|
||||
this.id = opts.id;
|
||||
this.name = opts.name;
|
||||
this.startingStack = opts.startingStack;
|
||||
this.baseSmallBlind = opts.smallBlind;
|
||||
this.baseBigBlind = opts.bigBlind;
|
||||
this.timerEnabled = opts.blindTimerEnabled;
|
||||
this.intervalMin = opts.blindIntervalMin;
|
||||
this.hostToken = opts.hostToken || null;
|
||||
this.maxSeats = config.MAX_SEATS;
|
||||
|
||||
this.seats = new Array(this.maxSeats).fill(null); // { token, name, stack }
|
||||
this.phase = 'WAITING';
|
||||
this.started = false;
|
||||
this.paused = false;
|
||||
this.level = 1;
|
||||
this.handNumber = 0;
|
||||
this.pot = 0;
|
||||
this.buttonSeat = null;
|
||||
this.levelEndsAt = null;
|
||||
this.levelRemainingMs = this.intervalMs;
|
||||
|
||||
this.createdAt = Date.now();
|
||||
this.emptySince = null;
|
||||
}
|
||||
|
||||
get intervalMs() {
|
||||
return this.intervalMin * 60 * 1000;
|
||||
}
|
||||
|
||||
get blinds() {
|
||||
return Blinds.blindsAt(this.baseSmallBlind, this.baseBigBlind, this.level);
|
||||
}
|
||||
|
||||
get playerCount() {
|
||||
return this.seats.filter(Boolean).length;
|
||||
}
|
||||
|
||||
seatOf(token) {
|
||||
return this.seats.findIndex((s) => s && s.token === token);
|
||||
}
|
||||
|
||||
isHost(token) {
|
||||
return Boolean(token) && token === this.hostToken;
|
||||
}
|
||||
|
||||
// 着席した席番号を返す。満席なら -1(観戦)
|
||||
join(token, name) {
|
||||
const existing = this.seatOf(token);
|
||||
if (existing >= 0) {
|
||||
this.seats[existing].name = name;
|
||||
return existing;
|
||||
}
|
||||
const free = this.seats.findIndex((s) => !s);
|
||||
if (free < 0) return -1;
|
||||
this.seats[free] = { token, name, stack: this.startingStack };
|
||||
// ホスト(通常は作成者)が着席していなければ、この人がホストになる
|
||||
if (this.seatOf(this.hostToken) < 0) this.hostToken = token;
|
||||
return free;
|
||||
}
|
||||
|
||||
leave(token) {
|
||||
const i = this.seatOf(token);
|
||||
if (i < 0) return false;
|
||||
this.seats[i] = null;
|
||||
if (this.hostToken === token) {
|
||||
const next = this.seats.find(Boolean);
|
||||
this.hostToken = next ? next.token : null;
|
||||
}
|
||||
if (this.playerCount < 2 && this.phase !== 'WAITING') {
|
||||
this.phase = 'WAITING';
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// 次に埋まっている席(時計回り = 席番号の昇順)
|
||||
nextOccupied(from) {
|
||||
for (let k = 1; k <= this.maxSeats; k++) {
|
||||
const i = (from + k) % this.maxSeats;
|
||||
if (this.seats[i]) return i;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
blindSeats() {
|
||||
if (this.buttonSeat == null || this.phase === 'WAITING' || this.playerCount < 2) {
|
||||
return { sbSeat: null, bbSeat: null };
|
||||
}
|
||||
let sbSeat;
|
||||
if (this.playerCount === 2 && this.seats[this.buttonSeat]) {
|
||||
sbSeat = this.buttonSeat; // ヘッズアップはボタンがSB
|
||||
} else {
|
||||
sbSeat = this.nextOccupied(this.buttonSeat);
|
||||
}
|
||||
return { sbSeat, bbSeat: this.nextOccupied(sbSeat) };
|
||||
}
|
||||
|
||||
startNewHand() {
|
||||
const occupied = this.seats.map((s, i) => (s ? i : -1)).filter((i) => i >= 0);
|
||||
this.buttonSeat =
|
||||
this.buttonSeat == null
|
||||
? occupied[Math.floor(Math.random() * occupied.length)]
|
||||
: this.nextOccupied(this.buttonSeat);
|
||||
this.handNumber++;
|
||||
this.pot = 0;
|
||||
this.phase = 'PREFLOP';
|
||||
}
|
||||
|
||||
// ---- ホスト操作(エラー時はメッセージを返す) ----
|
||||
|
||||
start(token) {
|
||||
if (!this.isHost(token)) return 'ホストのみ開始できます';
|
||||
if (this.phase !== 'WAITING') return 'すでに進行中です';
|
||||
if (this.playerCount < 2) return '2人以上必要です';
|
||||
if (!this.started) {
|
||||
this.started = true;
|
||||
if (this.timerEnabled) this.levelEndsAt = Date.now() + this.intervalMs;
|
||||
}
|
||||
this.startNewHand();
|
||||
return null;
|
||||
}
|
||||
|
||||
// スタブ: フェーズを1つ進める(SHOWDOWN の次は新しいハンド)
|
||||
nextPhase(token) {
|
||||
if (!this.isHost(token)) return 'ホストのみ操作できます';
|
||||
if (this.phase === 'WAITING') return 'まだ開始していません';
|
||||
if (this.playerCount < 2) return '2人以上必要です';
|
||||
const i = HAND_PHASES.indexOf(this.phase);
|
||||
if (i === HAND_PHASES.length - 1) this.startNewHand();
|
||||
else this.phase = HAND_PHASES[i + 1];
|
||||
return null;
|
||||
}
|
||||
|
||||
togglePause(token) {
|
||||
if (!this.isHost(token)) return 'ホストのみ操作できます';
|
||||
if (!this.started) return 'まだ開始していません';
|
||||
const now = Date.now();
|
||||
if (this.paused) {
|
||||
this.paused = false;
|
||||
if (this.timerEnabled) this.levelEndsAt = now + this.levelRemainingMs;
|
||||
} else {
|
||||
this.paused = true;
|
||||
if (this.timerEnabled) {
|
||||
this.levelRemainingMs = Math.max(0, this.levelEndsAt - now);
|
||||
this.levelEndsAt = null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ブラインドタイマー。レベルが上がったら true
|
||||
tick(now) {
|
||||
let changed = false;
|
||||
while (this.levelEndsAt != null && now >= this.levelEndsAt) {
|
||||
this.level++;
|
||||
this.levelEndsAt += this.intervalMs;
|
||||
changed = true;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
summary() {
|
||||
const { sb, bb } = this.blinds;
|
||||
return {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
playerCount: this.playerCount,
|
||||
maxSeats: this.maxSeats,
|
||||
startingStack: this.startingStack,
|
||||
smallBlind: sb,
|
||||
bigBlind: bb,
|
||||
level: this.level,
|
||||
phase: this.phase,
|
||||
paused: this.paused,
|
||||
timerEnabled: this.timerEnabled,
|
||||
intervalMin: this.intervalMin,
|
||||
createdAt: this.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
// token = 閲覧者のトークン(他人のトークンは送らない)
|
||||
publicState(token, connectedTokens) {
|
||||
const { sb, bb } = this.blinds;
|
||||
const next = Blinds.blindsAt(this.baseSmallBlind, this.baseBigBlind, this.level + 1);
|
||||
const host = this.seatOf(this.hostToken);
|
||||
return {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
startingStack: this.startingStack,
|
||||
maxSeats: this.maxSeats,
|
||||
level: this.level,
|
||||
smallBlind: sb,
|
||||
bigBlind: bb,
|
||||
nextBlinds: { smallBlind: next.sb, bigBlind: next.bb },
|
||||
timer: {
|
||||
enabled: this.timerEnabled,
|
||||
intervalMin: this.intervalMin,
|
||||
endsAt: this.levelEndsAt,
|
||||
remainingMs: this.levelEndsAt != null ? this.levelEndsAt - Date.now() : this.levelRemainingMs,
|
||||
running: this.levelEndsAt != null,
|
||||
},
|
||||
phase: this.phase,
|
||||
started: this.started,
|
||||
paused: this.paused,
|
||||
pot: this.pot,
|
||||
handNumber: this.handNumber,
|
||||
buttonSeat: this.phase === 'WAITING' ? null : this.buttonSeat,
|
||||
...this.blindSeats(),
|
||||
hostSeat: host >= 0 ? host : null,
|
||||
seats: this.seats.map((s, i) =>
|
||||
s ? { seat: i, name: s.name, stack: s.stack, connected: connectedTokens.has(s.token) } : null
|
||||
),
|
||||
mySeat: this.seatOf(token),
|
||||
isHost: this.isHost(token),
|
||||
serverNow: Date.now(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class TableManager {
|
||||
constructor() {
|
||||
this.tables = new Map();
|
||||
}
|
||||
|
||||
get(id) {
|
||||
return this.tables.get(id);
|
||||
}
|
||||
|
||||
list() {
|
||||
return [...this.tables.values()]
|
||||
.sort((a, b) => b.createdAt - a.createdAt)
|
||||
.map((t) => t.summary());
|
||||
}
|
||||
|
||||
nameTaken(name) {
|
||||
const n = name.toLowerCase();
|
||||
return [...this.tables.values()].some((t) => t.name.toLowerCase() === n);
|
||||
}
|
||||
|
||||
suggestName() {
|
||||
for (;;) {
|
||||
const name = `Table #${1000 + crypto.randomInt(9000)}`;
|
||||
if (!this.nameTaken(name)) return name;
|
||||
}
|
||||
}
|
||||
|
||||
create(opts) {
|
||||
let id;
|
||||
do id = crypto.randomBytes(4).toString('hex');
|
||||
while (this.tables.has(id));
|
||||
const table = new Table({ ...opts, id });
|
||||
this.tables.set(id, table);
|
||||
return table;
|
||||
}
|
||||
|
||||
delete(id) {
|
||||
this.tables.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { TableManager };
|
||||
Reference in new issue
Block a user