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:
110
redsys.ts
Normal file
110
redsys.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { createCipheriv, createHmac } from "node:crypto";
|
||||
|
||||
// ── Key derivation ────────────────────────────────────────────────────────────
|
||||
// Redsys HMAC_SHA256_V1: derive a per-order signature key by 3DES-CBC encrypting
|
||||
// the padded order ID with the merchant secret key.
|
||||
|
||||
function deriveKey(secretKeyBase64: string, orderId: string): Buffer {
|
||||
const rawKey = Buffer.from(secretKeyBase64, "base64");
|
||||
// 3DES-CBC requires exactly 24 bytes
|
||||
const key24 = Buffer.alloc(24);
|
||||
rawKey.copy(key24, 0, 0, Math.min(rawKey.length, 24));
|
||||
|
||||
// Pad order ID to a multiple of 8 bytes with zeros
|
||||
const orderBuf = Buffer.from(orderId, "ascii");
|
||||
const remainder = orderBuf.length % 8;
|
||||
const paddedOrder =
|
||||
remainder === 0 ? orderBuf : Buffer.concat([orderBuf, Buffer.alloc(8 - remainder)]);
|
||||
|
||||
// 3DES-CBC with 8-byte zero IV, no auto-padding
|
||||
const iv = Buffer.alloc(8, 0);
|
||||
const cipher = createCipheriv("des-ede3-cbc", key24, iv);
|
||||
cipher.setAutoPadding(false);
|
||||
return Buffer.concat([cipher.update(paddedOrder), cipher.final()]);
|
||||
}
|
||||
|
||||
function normalizeBase64(s: string): string {
|
||||
return s.replace(/-/g, "+").replace(/_/g, "/").replace(/=/g, "");
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function encodeParams(params: Record<string, string>): string {
|
||||
return Buffer.from(JSON.stringify(params)).toString("base64");
|
||||
}
|
||||
|
||||
export function decodeParams(merchantParams: string): Record<string, string> {
|
||||
// Redsys sends back URL-safe Base64; normalize before decoding
|
||||
const normalized = merchantParams.replace(/-/g, "+").replace(/_/g, "/");
|
||||
return JSON.parse(Buffer.from(normalized, "base64").toString("utf8"));
|
||||
}
|
||||
|
||||
export function sign(secretKeyBase64: string, orderId: string, data: string): string {
|
||||
const derivedKey = deriveKey(secretKeyBase64, orderId);
|
||||
return createHmac("sha256", derivedKey).update(data).digest("base64");
|
||||
}
|
||||
|
||||
export function isPaymentApproved(decodedParams: Record<string, string>): boolean {
|
||||
const code = parseInt(decodedParams["Ds_Response"] ?? "9999", 10);
|
||||
return Number.isFinite(code) && code >= 0 && code <= 99;
|
||||
}
|
||||
|
||||
export type RedsysConfig = {
|
||||
secretKey: string;
|
||||
merchantCode: string;
|
||||
terminal: string;
|
||||
isTest: boolean;
|
||||
orderId: string;
|
||||
amount: number; // in cents
|
||||
urlOk: string;
|
||||
urlKo: string;
|
||||
merchantUrl: string;
|
||||
productDescription?: string;
|
||||
};
|
||||
|
||||
export function buildPaymentForm(config: RedsysConfig): {
|
||||
tpvUrl: string;
|
||||
merchantParams: string;
|
||||
signature: string;
|
||||
signatureVersion: string;
|
||||
} {
|
||||
const tpvUrl = config.isTest
|
||||
? "https://sis-t.redsys.es:25443/sis/realizarPago"
|
||||
: "https://sis.redsys.es/sis/realizarPago";
|
||||
|
||||
const params: Record<string, string> = {
|
||||
DS_MERCHANT_AMOUNT: String(config.amount),
|
||||
DS_MERCHANT_ORDER: config.orderId,
|
||||
DS_MERCHANT_MERCHANTCODE: config.merchantCode,
|
||||
DS_MERCHANT_CURRENCY: "978",
|
||||
DS_MERCHANT_TRANSACTIONTYPE: "0",
|
||||
DS_MERCHANT_TERMINAL: config.terminal,
|
||||
DS_MERCHANT_URLOK: config.urlOk,
|
||||
DS_MERCHANT_URLKO: config.urlKo,
|
||||
DS_MERCHANT_MERCHANTURL: config.merchantUrl,
|
||||
DS_MERCHANT_CONSUMERLANGUAGE: "001",
|
||||
};
|
||||
if (config.productDescription) {
|
||||
params["DS_MERCHANT_PRODUCTDESCRIPTION"] = config.productDescription;
|
||||
}
|
||||
|
||||
const merchantParams = encodeParams(params);
|
||||
const signature = sign(config.secretKey, config.orderId, merchantParams);
|
||||
return { tpvUrl, merchantParams, signature, signatureVersion: "HMAC_SHA256_V1" };
|
||||
}
|
||||
|
||||
export function verifyNotification(
|
||||
secretKeyBase64: string,
|
||||
merchantParams: string,
|
||||
receivedSignature: string,
|
||||
): boolean {
|
||||
try {
|
||||
const decoded = decodeParams(merchantParams);
|
||||
const orderId = decoded["Ds_Order"] ?? decoded["DS_MERCHANT_ORDER"] ?? "";
|
||||
if (!orderId) return false;
|
||||
const expectedSig = sign(secretKeyBase64, orderId, merchantParams);
|
||||
return normalizeBase64(expectedSig) === normalizeBase64(receivedSignature);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user