A new table type, "review" (レビュー付きCPU戦), can only be created by admins. Only the creator and CPUs sit there: friends cannot join, and the table is hidden from non-admin lobbies. After every hand a review opens, and the next hand waits until the creator presses "次のハンドへ". - Preflop: shows which color of the registered hand range the hole cards are and whether the action followed it. With no entrant before you, open with the color for the number of players still to act; facing an entrant, estimate their range from their position, call one color above it and raise two above. Purple ranks just below white (SB only). - Flop onward: equity vs ranges estimated from those colors (narrowed by later bets/calls) gives a rough EV per option. The score is the EV lost against the best option, relative to the pot. It is a guide, not GTO. - Also: pot-odds call check, all-in equity and luck-adjusted P&L, VPIP/PFR/3bet/Cbet/WTSD/W$SD/AF, per-color and per-position compliance, habits to watch, worst-5 hands, a P&L chart and a step-by-step replay on the admin-only /review page. Hand now logs every action for the review. The range data moves to admin/range-data.js, shared by the range page and the server. Reviews are saved under data/reviews/ (newest 50 sessions). Admin scripts and CSS are no longer cached for a day, so updates show up immediately. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
249 lines
8.7 KiB
JavaScript
249 lines
8.7 KiB
JavaScript
'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 の値はキーと役割から作る。キーを変えると既存の Cookie はすべて無効になる
|
|
const SECRET = loadSecret();
|
|
const tokenFor = (role, key) => crypto.createHmac('sha256', SECRET).update(role + ':' + key).digest('base64url');
|
|
const TOKENS = {
|
|
member: tokenFor('access', config.ACCESS_KEY), // 以前と同じ値(既存の Cookie はそのまま有効)
|
|
admin: config.ADMIN_KEY ? tokenFor('admin', config.ADMIN_KEY) : null,
|
|
};
|
|
|
|
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);
|
|
}
|
|
|
|
// 'admin' / 'member' / null
|
|
function roleOf(cookieHeader) {
|
|
const value = parseCookies(cookieHeader)[COOKIE_NAME];
|
|
if (!value) return null;
|
|
// どちらも必ず比較する(どちらに一致したかを処理時間から推測させない)
|
|
const admin = TOKENS.admin ? safeEqual(value, TOKENS.admin) : false;
|
|
const member = safeEqual(value, TOKENS.member);
|
|
return admin ? 'admin' : member ? 'member' : null;
|
|
}
|
|
|
|
function isAuthedCookieHeader(cookieHeader) {
|
|
return roleOf(cookieHeader) !== null;
|
|
}
|
|
|
|
function isAuthed(req) {
|
|
return isAuthedCookieHeader(req.headers.cookie);
|
|
}
|
|
|
|
function isAdminCookieHeader(cookieHeader) {
|
|
return roleOf(cookieHeader) === 'admin';
|
|
}
|
|
|
|
function isAdmin(req) {
|
|
return isAdminCookieHeader(req.headers.cookie);
|
|
}
|
|
|
|
function setAuthCookie(req, res, role) {
|
|
const parts = [
|
|
`${COOKIE_NAME}=${TOKENS[role]}`,
|
|
'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桁の数字を入力してください。' });
|
|
}
|
|
// どちらも必ず比較する
|
|
const adminMatch = config.ADMIN_KEY ? safeEqual(input, config.ADMIN_KEY) : false;
|
|
const memberMatch = safeEqual(input, config.ACCESS_KEY);
|
|
if (!adminMatch && !memberMatch) {
|
|
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);
|
|
// 管理者がメンバー用のキーを入れ直しても管理者のまま
|
|
const role = adminMatch || isAdmin(req) ? 'admin' : 'member';
|
|
console.log(`[auth] 入室 from ${key}${role === 'admin' ? '(管理者)' : ''}`);
|
|
setAuthCookie(req, res, role);
|
|
res.json({ ok: true, admin: role === 'admin' });
|
|
}
|
|
|
|
// /key 以外のページ・API を保護する
|
|
function requireAuth(req, res, next) {
|
|
const role = roleOf(req.headers.cookie);
|
|
if (role) {
|
|
if (req.method === 'GET' && !req.path.startsWith('/api/')) setAuthCookie(req, res, role);
|
|
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();
|
|
}
|
|
|
|
// 管理者専用。管理者以外にはページがあること自体を知らせない(404)
|
|
function requireAdmin(req, res, next) {
|
|
if (isAdmin(req)) return next();
|
|
res.status(404).type('text/plain').send('Not Found');
|
|
}
|
|
|
|
// 状態を変える 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,
|
|
isAdmin,
|
|
isAuthedCookieHeader,
|
|
isAdminCookieHeader,
|
|
isSameOrigin,
|
|
isAllowedHost,
|
|
requireAllowedHost,
|
|
handleLogin,
|
|
requireAuth,
|
|
requireAdmin,
|
|
requireSameOrigin,
|
|
safeNext,
|
|
};
|