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>
This commit is contained in:
76
server/services/castMediaUrlService.js
Normal file
76
server/services/castMediaUrlService.js
Normal file
@ -0,0 +1,76 @@
|
||||
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 };
|
||||
Reference in New Issue
Block a user