- 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>
111 lines
4.1 KiB
TypeScript
111 lines
4.1 KiB
TypeScript
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;
|
|
}
|
|
}
|