diff options
Diffstat (limited to 'packages/meshbay-client/src/cast-relay.js')
| -rw-r--r-- | packages/meshbay-client/src/cast-relay.js | 114 |
1 files changed, 111 insertions, 3 deletions
diff --git a/packages/meshbay-client/src/cast-relay.js b/packages/meshbay-client/src/cast-relay.js index 12939c4..71c07a4 100644 --- a/packages/meshbay-client/src/cast-relay.js +++ b/packages/meshbay-client/src/cast-relay.js @@ -10,10 +10,16 @@ * properly framed fMP4 fragments. The BoxAccumulator reassembles the byte * stream and emits complete moof+mdat pairs. * + * Subtitles ride alongside the video as a side-loaded WebVTT file at + * `/subs.vtt`. A receiver fetches that one with XHR rather than handing it to + * a media element, so unlike the stream it needs CORS headers to be readable + * at all. + * * Mitigations: * · Bind to the LAN interface, never 0.0.0.0 * · Fixed port range (19550-19553), opened only during active cast * · Unguessable token in the URL (32 hex chars) + * · CORS is granted on the subtitle path only, and still behind the token * · Cache-Control: no-store on every response * · Server destroyed when playback stops — zero residual surface */ @@ -30,6 +36,16 @@ const MOOF = 0x6d6f6f66; const PORT_BASE = 19550; const PORT_COUNT = 4; +// A receiver reads a side-loaded subtitle with XHR, from its own origin, so +// the three headers it sends have to be allowed by name — `Range` included, +// which it sends even for a document it will read whole. +const CORS_HEADERS = Object.freeze({ + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Accept-Encoding, Range', + 'Access-Control-Expose-Headers': 'Content-Length, Content-Range', +}); + function lanAddress() { const ifaces = os.networkInterfaces(); for (const name of Object.keys(ifaces)) { @@ -122,6 +138,8 @@ class CastRelay { this._fragCount = 0; this._chunkCount = 0; this._bytesSent = 0; + this._subtitle = null; + this._subtitleVersion = 0; } get active() { return this._server !== null; } @@ -131,7 +149,54 @@ class CastRelay { return `http://${this._lanIP}:${this._port}/stream.mp4?t=${this._token}`; } - async start({ codec, initSegment }) { + /** + * Where the current subtitle can be fetched, or null when there is none. + * + * The version is part of the URL because a receiver caches a side-loaded + * track by its address: changing the cues behind a fixed URL would leave the + * old ones on screen. + */ + get subtitleUrl() { + if (!this._server || !this._subtitle) return null; + return `http://${this._lanIP}:${this._port}/subs.vtt` + + `?t=${this._token}&v=${this._subtitleVersion}`; + } + + get subtitle() { + if (!this._subtitle) return null; + return { + url: this.subtitleUrl, + language: this._subtitle.language, + label: this._subtitle.label, + }; + } + + /** + * Carry a WebVTT document for the receiver to side-load. + * + * The cues must already be expressed on the *stream's* timeline, which + * starts at zero at the seek point — not on the film's. The renderer shifts + * them before they get here; the relay only serves bytes. + */ + setSubtitle(sub) { + if (!sub || !sub.vtt) { + this._subtitle = null; + this._subtitleVersion++; + console.log('[cast-relay] subtitle cleared'); + return null; + } + this._subtitle = { + vtt: Buffer.from(sub.vtt, 'utf8'), + language: sub.language || '', + label: sub.label || '', + }; + this._subtitleVersion++; + console.log(`[cast-relay] subtitle set: ${this._subtitle.vtt.length} bytes` + + `, lang "${this._subtitle.language}", v${this._subtitleVersion}`); + return this.subtitle; + } + + async start({ codec, initSegment, subtitle }) { if (this._server) await this.stop(); this._token = crypto.randomBytes(16).toString('hex'); @@ -143,6 +208,8 @@ class CastRelay { this._fragCount = 0; this._chunkCount = 0; this._bytesSent = 0; + this._subtitle = null; + this.setSubtitle(subtitle); const server = http.createServer((req, res) => this._handle(req, res)); @@ -173,7 +240,12 @@ class CastRelay { this._server = server; console.log(`[cast-relay] started on ${this.url}`); console.log(`[cast-relay] init segment: ${this._initSegment ? this._initSegment.length + ' bytes' : 'none'}`); - return { url: this.url, port: this._port, token: this._token }; + return { + url: this.url, + port: this._port, + token: this._token, + subtitle: this.subtitle, + }; } pushSegment(data) { @@ -227,6 +299,7 @@ class CastRelay { this._port = null; this._token = null; this._initSegment = null; + this._subtitle = null; this._ring = []; this._accum.reset(); this._fragCount = 0; @@ -235,7 +308,7 @@ class CastRelay { } _handle(req, res) { - if (req.method !== 'GET') { + if (req.method !== 'GET' && req.method !== 'OPTIONS') { res.writeHead(405); res.end(); return; @@ -250,12 +323,26 @@ class CastRelay { return; } + // The preflight is answered before the token is examined: a browser sends + // it without credentials and rejecting it here would read, on the receiver, + // as a network failure rather than as a refusal. + if (req.method === 'OPTIONS') { + res.writeHead(204, CORS_HEADERS); + res.end(); + return; + } + if (url.searchParams.get('t') !== this._token) { res.writeHead(403); res.end(); return; } + if (url.pathname === '/subs.vtt') { + this._handleSubtitle(res); + return; + } + if (url.pathname !== '/stream.mp4') { res.writeHead(404); res.end(); @@ -264,6 +351,11 @@ class CastRelay { let sent = 0; res.writeHead(200, { + // A receiver that has been given a side-loaded track reads the media + // through the same CORS-checked path as the track, so the headers go on + // both or neither. They widen nothing: the URL is already unguessable, + // and a page that has it could embed it in a media element regardless. + ...CORS_HEADERS, 'Content-Type': 'video/mp4', 'Cache-Control': 'no-store', 'Accept-Ranges': 'none', @@ -288,6 +380,22 @@ class CastRelay { console.log(`[cast-relay] client disconnected, ${this._clients.size} remaining`); }); } + + _handleSubtitle(res) { + if (!this._subtitle) { + res.writeHead(404, CORS_HEADERS); + res.end(); + return; + } + res.writeHead(200, { + ...CORS_HEADERS, + 'Content-Type': 'text/vtt; charset=utf-8', + 'Content-Length': this._subtitle.vtt.length, + 'Cache-Control': 'no-store', + }); + res.end(this._subtitle.vtt); + console.log(`[cast-relay] subtitle served: ${this._subtitle.vtt.length} bytes`); + } } module.exports = CastRelay; |