feat: footer, auto-pause on no viewers, and Redsys question submissions

- Footer: add site-footer with one-liner + Cloud Host branding linking to cloudhost.es (mobile only, hidden on desktop)
- Auto-pause: game pauses automatically after AUTOPAUSE_DELAY_MS (default 60s) with no viewers connected; auto-resumes when first viewer connects; shows "Esperando espectadores…" in the UI
- Redsys: new /pregunta page lets viewers pay 1€ via Redsys to submit a fill-in question; submitted questions are used as prompts in the next round instead of AI generation; new redsys.ts implements HMAC_SHA256_V1 signing (3DES key derivation + HMAC-SHA256); notification endpoint at /api/redsys/notificacion handles server-to-server confirmation
- db.ts: questions table with createPendingQuestion / markQuestionPaid / getNextPendingQuestion / markQuestionUsed
- .env.sample: added AUTOPAUSE_DELAY_MS, PUBLIC_URL, REDSYS_* vars

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 14:03:30 +01:00
parent 4b0b9f8f50
commit 2fac92356d
12 changed files with 747 additions and 20 deletions

173
server.ts
View File

@@ -4,7 +4,9 @@ import indexHtml from "./index.html";
import historyHtml from "./history.html";
import adminHtml from "./admin.html";
import broadcastHtml from "./broadcast.html";
import { clearAllRounds, getRounds, getAllRounds } from "./db.ts";
import preguntaHtml from "./pregunta.html";
import { clearAllRounds, getRounds, getAllRounds, createPendingQuestion, markQuestionPaid } from "./db.ts";
import { buildPaymentForm, verifyNotification, decodeParams, isPaymentApproved } from "./redsys.ts";
import {
MODELS,
LOG_FILE,
@@ -67,6 +69,7 @@ const gameState: GameState = {
viewerScores: initialViewerScores,
done: false,
isPaused: false,
autoPaused: false,
generation: 0,
};
@@ -105,6 +108,7 @@ const VIEWER_VOTE_BROADCAST_DEBOUNCE_MS = parsePositiveInt(
process.env.VIEWER_VOTE_BROADCAST_DEBOUNCE_MS,
250,
);
const AUTOPAUSE_DELAY_MS = parsePositiveInt(process.env.AUTOPAUSE_DELAY_MS, 60_000);
const ADMIN_COOKIE = "argumentes_admin";
const ADMIN_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30;
@@ -293,6 +297,30 @@ function applyViewerVote(voterId: string, side: ViewerVoteSide): boolean {
return true;
}
// ── Auto-pause ───────────────────────────────────────────────────────────────
let autoPauseTimer: ReturnType<typeof setTimeout> | null = null;
function scheduleAutoPause() {
if (autoPauseTimer) return;
autoPauseTimer = setTimeout(() => {
autoPauseTimer = null;
if (clients.size === 0 && !gameState.isPaused) {
gameState.isPaused = true;
gameState.autoPaused = true;
broadcast();
log("INFO", "server", "Auto-paused game — no viewers");
}
}, AUTOPAUSE_DELAY_MS);
}
function cancelAutoPause() {
if (autoPauseTimer) {
clearTimeout(autoPauseTimer);
autoPauseTimer = null;
}
}
// ── WebSocket clients ───────────────────────────────────────────────────────
const clients = new Set<ServerWebSocket<WsData>>();
@@ -315,6 +343,7 @@ function getClientState() {
viewerScores: gameState.viewerScores,
done: gameState.done,
isPaused: gameState.isPaused,
autoPaused: gameState.autoPaused,
generation: gameState.generation,
};
}
@@ -369,6 +398,7 @@ const server = Bun.serve<WsData>({
"/history": historyHtml,
"/admin": adminHtml,
"/broadcast": broadcastHtml,
"/pregunta": preguntaHtml,
},
async fetch(req, server) {
const url = new URL(req.url);
@@ -422,6 +452,107 @@ const server = Bun.serve<WsData>({
});
}
if (url.pathname === "/api/pregunta/iniciar") {
if (req.method !== "POST") {
return new Response("Method Not Allowed", {
status: 405,
headers: { Allow: "POST" },
});
}
const secretKey = process.env.REDSYS_SECRET_KEY;
const merchantCode = process.env.REDSYS_MERCHANT_CODE;
if (!secretKey || !merchantCode) {
return new Response("Pagos no configurados", { status: 503 });
}
if (isRateLimited(`pregunta:${ip}`, 5, WINDOW_MS)) {
return new Response("Too Many Requests", { status: 429 });
}
let text = "";
try {
const body = await req.json();
text = String((body as Record<string, unknown>).text ?? "").trim();
} catch {
return new Response("Invalid JSON body", { status: 400 });
}
if (text.length < 10 || text.length > 200) {
return new Response("La pregunta debe tener entre 10 y 200 caracteres", { status: 400 });
}
const orderId = String(Date.now()).slice(-12);
createPendingQuestion(text, orderId);
const isTest = process.env.REDSYS_TEST !== "false";
const terminal = process.env.REDSYS_TERMINAL ?? "1";
const baseUrl =
process.env.PUBLIC_URL?.replace(/\/$/, "") ??
`${url.protocol}//${url.host}`;
const form = buildPaymentForm({
secretKey,
merchantCode,
terminal,
isTest,
orderId,
amount: 100,
urlOk: `${baseUrl}/pregunta?ok=1`,
urlKo: `${baseUrl}/pregunta?ko=1`,
merchantUrl: `${baseUrl}/api/redsys/notificacion`,
productDescription: "Pregunta argument.es",
});
log("INFO", "pregunta", "Payment initiated", { orderId, ip });
return new Response(JSON.stringify({ ok: true, ...form }), {
status: 200,
headers: {
"Content-Type": "application/json",
"Cache-Control": "no-store",
},
});
}
if (url.pathname === "/api/redsys/notificacion") {
// Always return 200 so Redsys doesn't retry; log errors internally.
const secretKey = process.env.REDSYS_SECRET_KEY;
if (!secretKey || req.method !== "POST") {
return new Response("ok", { status: 200 });
}
let merchantParams = "";
let receivedSignature = "";
try {
const body = await req.text();
const fd = new URLSearchParams(body);
merchantParams = fd.get("Ds_MerchantParameters") ?? "";
receivedSignature = fd.get("Ds_Signature") ?? "";
} catch {
return new Response("ok", { status: 200 });
}
if (!verifyNotification(secretKey, merchantParams, receivedSignature)) {
log("WARN", "redsys", "Invalid notification signature");
return new Response("ok", { status: 200 });
}
const decoded = decodeParams(merchantParams);
if (isPaymentApproved(decoded)) {
const orderId = decoded["Ds_Order"] ?? "";
if (orderId) {
const marked = markQuestionPaid(orderId);
log("INFO", "redsys", "Question marked as paid", { orderId, marked });
}
} else {
log("INFO", "redsys", "Payment not approved", {
response: decoded["Ds_Response"],
});
}
return new Response("ok", { status: 200 });
}
if (url.pathname === "/api/admin/login") {
if (req.method !== "POST") {
return new Response("Method Not Allowed", {
@@ -559,6 +690,7 @@ const server = Bun.serve<WsData>({
gameState.viewerScores = Object.fromEntries(MODELS.map((m) => [m.name, 0]));
gameState.done = false;
gameState.isPaused = true;
gameState.autoPaused = false;
gameState.generation += 1;
broadcast();
@@ -593,8 +725,11 @@ const server = Bun.serve<WsData>({
if (url.pathname.endsWith("/pause")) {
gameState.isPaused = true;
gameState.autoPaused = false;
cancelAutoPause();
} else {
gameState.isPaused = false;
gameState.autoPaused = false;
}
broadcast();
const action = url.pathname.endsWith("/pause") ? "Paused" : "Resumed";
@@ -712,17 +847,28 @@ const server = Bun.serve<WsData>({
totalClients: clients.size,
uniqueIps: wsByIp.size,
});
// Send current state to the new client only
ws.send(
JSON.stringify({
type: "state",
data: getClientState(),
totalRounds: runs,
viewerCount: clients.size,
version: VERSION,
}),
);
// Notify everyone else with just the viewer count
cancelAutoPause();
if (gameState.autoPaused) {
gameState.isPaused = false;
gameState.autoPaused = false;
log("INFO", "server", "Auto-resumed game — viewer connected");
// Broadcast updated state to all clients (including this new one)
broadcast();
} else {
// Send current state to the new client only
ws.send(
JSON.stringify({
type: "state",
data: getClientState(),
totalRounds: runs,
viewerCount: clients.size,
version: VERSION,
}),
);
}
// Notify everyone of updated viewer count
broadcastViewerCount();
},
message() {
@@ -736,6 +882,9 @@ const server = Bun.serve<WsData>({
totalClients: clients.size,
uniqueIps: wsByIp.size,
});
if (clients.size === 0 && !gameState.isPaused) {
scheduleAutoPause();
}
broadcastViewerCount();
},
},