Files
Nodecast/server/services/castMediaUrlService.js
blomios 5ee07570e0 feat(cast): add server-managed Google Cast playback (#34)
Add server-side Google Cast discovery and sessions, signed cast media URLs, browser device picker integration, and deployment documentation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-06 22:40:17 +02:00

77 lines
3.4 KiB
JavaScript

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 };