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>
77 lines
3.4 KiB
JavaScript
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 };
|