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:
173
server.ts
173
server.ts
@@ -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();
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user