const crypto = require('crypto'); function encode(value) { return Buffer.from(value).toString('base64url'); } function timingSafeEqual(left, right) { const a = Buffer.from(left); const b = Buffer.from(right); return a.length === b.length && crypto.timingSafeEqual(a, b); } function createCastMediaUrlService(options = {}) { const secret = String(options.secret || process.env.CAST_SIGNING_SECRET || ''); const publicBaseUrl = String(options.publicBaseUrl || process.env.CAST_PUBLIC_BASE_URL || '').replace(/\/$/, ''); const ttlMs = Number(options.ttlMs || 5 * 60 * 1000); const now = options.now || Date.now; const paths = new Map(); function requireSecret() { if (Buffer.byteLength(secret, 'utf8') < 32) { throw Object.assign(new Error('CAST_SIGNING_SECRET must contain at least 32 bytes'), { code: 'CAST_CONFIG_INVALID' }); } } function signature(payload) { requireSecret(); return crypto.createHmac('sha256', secret).update(payload).digest('base64url'); } function sign(path) { if (!path || !String(path).startsWith('/')) throw new Error('Cast media path must be absolute'); if (!publicBaseUrl) throw new Error('CAST_PUBLIC_BASE_URL is required for cast media URLs'); const id = crypto.randomBytes(18).toString('base64url'); const exp = now() + ttlMs; const pathHash = crypto.createHash('sha256').update(String(path)).digest('base64url'); const payload = encode(JSON.stringify({ id, exp, pathHash })); paths.set(id, { path: String(path), exp }); const token = `${payload}.${signature(payload)}`; return `${publicBaseUrl}/api/cast/media/${id}?token=${encodeURIComponent(token)}`; } function validate(id, token) { requireSecret(); if (!id || typeof id !== 'string') throw new Error('Invalid cast media token'); if (!token || typeof token !== 'string') throw new Error('Invalid cast media token'); const [payload, providedSignature, extra] = token.split('.'); if (!payload || !providedSignature || extra || !timingSafeEqual(signature(payload), providedSignature)) { throw new Error('Invalid cast media token'); } let claims; try { claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')); } catch { throw new Error('Invalid cast media token'); } if (claims.id !== id) throw new Error('Invalid cast media token'); if (!Number.isFinite(claims.exp) || now() > claims.exp) { paths.delete(claims.id); throw new Error('Expired cast media token'); } const entry = paths.get(claims.id); if (!entry || entry.exp !== claims.exp) throw new Error('Invalid cast media token'); const pathHash = crypto.createHash('sha256').update(entry.path).digest('base64url'); if (!claims.pathHash || !timingSafeEqual(pathHash, claims.pathHash)) throw new Error('Invalid cast media token'); return { path: entry.path }; } function revoke(idOrUrl) { const value = String(idOrUrl || ''); let id = value; try { id = new URL(value, publicBaseUrl || 'http://invalid').pathname.split('/').filter(Boolean).at(-1); } catch { /* use raw id */ } return paths.delete(id); } return { revoke, sign, validate }; } module.exports = { createCastMediaUrlService };