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
@@ -0,0 +1,99 @@
|
||||
// 各ページ共通のユーティリティ
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function lsGet(key) {
|
||||
try { return localStorage.getItem(key); } catch { return null; }
|
||||
}
|
||||
function lsSet(key, value) {
|
||||
try { localStorage.setItem(key, value); } catch { /* プライベートモード等 */ }
|
||||
}
|
||||
|
||||
// 非HTTPS環境でも動くよう crypto.randomUUID ではなく getRandomValues を使う
|
||||
function randomHex(bytes) {
|
||||
const a = new Uint8Array(bytes);
|
||||
crypto.getRandomValues(a);
|
||||
return Array.from(a, (b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
const Store = {
|
||||
get name() { return lsGet('poker.name') || ''; },
|
||||
set name(v) { lsSet('poker.name', v); },
|
||||
// 席の再接続用。ブラウザごとに固定
|
||||
get token() {
|
||||
let t = lsGet('poker.token');
|
||||
if (!t || !/^[0-9a-f]{32}$/.test(t)) {
|
||||
t = randomHex(16);
|
||||
lsSet('poker.token', t);
|
||||
}
|
||||
return t;
|
||||
},
|
||||
};
|
||||
|
||||
function esc(s) {
|
||||
return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
|
||||
function chips(n) {
|
||||
return '$' + Number(n).toLocaleString('en-US');
|
||||
}
|
||||
|
||||
let toastTimer;
|
||||
function toast(msg) {
|
||||
let el = document.querySelector('.toast');
|
||||
if (!el) {
|
||||
el = document.createElement('div');
|
||||
el.className = 'toast';
|
||||
document.body.appendChild(el);
|
||||
}
|
||||
el.textContent = msg;
|
||||
el.hidden = false;
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => { el.hidden = true; }, 2500);
|
||||
}
|
||||
|
||||
async function api(method, url, body) {
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
if (res.status === 401 && url !== '/api/auth') {
|
||||
location.href = '/key?next=' + encodeURIComponent(location.pathname);
|
||||
throw new Error('unauthorized');
|
||||
}
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || 'エラーが発生しました');
|
||||
return data;
|
||||
}
|
||||
|
||||
// サーバは WebSocket のみ受け付ける。接続を拒否されたら鍵が無効になっていないか確認する
|
||||
function connectSocket() {
|
||||
const socket = io({ transports: ['websocket'] });
|
||||
socket.on('connect_error', () => {
|
||||
fetch('/api/tables', { credentials: 'same-origin' })
|
||||
.then((res) => {
|
||||
if (res.status === 401) location.href = '/key?next=' + encodeURIComponent(location.pathname);
|
||||
})
|
||||
.catch(() => {});
|
||||
});
|
||||
return socket;
|
||||
}
|
||||
|
||||
// 自サイト内のパスだけ許可("//x" や "/\x" は外部サイト扱いになるので拒否)
|
||||
function safeNext(next) {
|
||||
return typeof next === 'string' && /^\/(?![/\\])[^\\\s]*$/.test(next) && next.length <= 200 ? next : '/';
|
||||
}
|
||||
|
||||
const PHASE_LABELS = {
|
||||
WAITING: 'WAITING',
|
||||
PREFLOP: 'PREFLOP',
|
||||
FLOP: 'FLOP',
|
||||
TURN: 'TURN',
|
||||
RIVER: 'RIVER',
|
||||
SHOWDOWN: 'SHOWDOWN',
|
||||
};
|
||||
|
||||
window.App = { Store, esc, chips, toast, api, connectSocket, safeNext, PHASE_LABELS };
|
||||
})();
|
||||
Reference in new issue
Block a user