From 03aa7a0673790718e99c7842e0060046fc1394bd Mon Sep 17 00:00:00 2001 From: ZGaetano Date: Tue, 26 May 2026 17:40:02 +0000 Subject: [PATCH] =?UTF-8?q?fix(video):=20revert=20S3=20redirect=20?= =?UTF-8?q?=E2=80=94=20RustFS=20rejects=20range+Origin;=20proxy=20with=20c?= =?UTF-8?q?ache=20headers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S3 at broadcastmgmt.cloud (RustFS/openresty) returns 403 on range requests that include an Origin header on presigned URLs. The HMAC signature only covers 'host' in X-Amz-SignedHeaders, so the browser's cross-origin Origin header breaks signature validation. Reverted: /stream and /video no longer redirect to signed S3 URLs. Fixed: /video now pipes through Node with: Cache-Control: private, max-age=3600 ETag and Last-Modified forwarded from S3 This means the browser caches video segments for 1h. On seek the browser checks its cache first — only uncached byte ranges hit the server. Combined with the 1.5Mbps proxy (was 4Mbps), seeks should be responsive for clips under ~10 minutes. --- services/mam-api/src/routes/assets.js | 37 +++++++++++++++++++-------- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/services/mam-api/src/routes/assets.js b/services/mam-api/src/routes/assets.js index 1f75042..3f5a114 100644 --- a/services/mam-api/src/routes/assets.js +++ b/services/mam-api/src/routes/assets.js @@ -523,14 +523,11 @@ router.get('/:id/stream', async (req, res, next) => { const a = r.rows[0]; if (a.status === 'live') return res.json({ url: `/live/${a.id}/index.m3u8`, type: 'hls', live: true }); const VIDEO_EXTS = ['.mp4', '.mov', '.mxf', '.ts', '.m4v', '.mkv', '.avi', '.webm']; - // Return signed S3 URL directly — browser talks to S3, no Node proxy bottleneck. - // Sign for 4 hours so the player doesn't expire mid-session. const key = a.proxy_s3_key || (a.original_s3_key && VIDEO_EXTS.some(ext => a.original_s3_key.toLowerCase().endsWith(ext)) ? a.original_s3_key : null); if (key) { - const signedUrl = await getSignedUrlForObject(key, 14400); - return res.json({ url: signedUrl, type: 'mp4', source: a.proxy_s3_key ? 'proxy' : 'original' }); + return res.json({ url: `/api/v1/assets/${id}/video`, type: 'mp4', source: a.proxy_s3_key ? 'proxy' : 'original' }); } return res.json({ url: null, type: null, reason: 'no_proxy', has_source: !!a.original_s3_key }); } catch (err) { next(err); } @@ -562,9 +559,14 @@ router.get('/:id/live-path', async (req, res, next) => { }); // GET /:id/video -// Redirects to a signed S3 URL so the browser streams directly from S3. -// This eliminates the Node.js proxy bottleneck and lets S3 handle range -// requests natively — critical for smooth seeking in the player. +// Proxies the S3 object through Node with proper cache headers. +// Direct S3 redirect doesn't work because broadcastmgmt.cloud (RustFS/openresty) +// rejects range requests that include an Origin header on presigned URLs — the +// signature only covers 'host', so adding Origin breaks signature validation. +// Instead we pipe through Node with: +// - ETag + Last-Modified for conditional requests (304 on repeat visits) +// - Cache-Control: private, max-age=3600 so the browser caches segments +// and doesn't re-fetch them on every seek within a session router.get('/:id/video', async (req, res, next) => { try { const { id } = req.params; @@ -575,9 +577,24 @@ router.get('/:id/video', async (req, res, next) => { const origIsVideo = a.original_s3_key && VIDEO_EXTS.some(ext => a.original_s3_key.toLowerCase().endsWith(ext)); const key = a.proxy_s3_key || (origIsVideo ? a.original_s3_key : null); if (!key) return res.status(404).json({ error: 'No browser-playable source' }); - // Sign for 4 hours — enough for an editing session without frequent re-fetches - const url = await getSignedUrlForObject(key, 14400); - res.redirect(302, url); + const params = { Bucket: getS3Bucket(), Key: key }; + const rangeHeader = req.headers.range; + if (rangeHeader) params.Range = rangeHeader; + const s3Res = await s3Client.send(new GetObjectCommand(params)); + const status = rangeHeader ? 206 : 200; + const headers = { + 'Content-Type': 'video/mp4', + 'Accept-Ranges': 'bytes', + // Cache for 1 hour so the browser reuses buffered segments on seek + // instead of re-fetching. 'private' keeps it out of shared caches. + 'Cache-Control': 'private, max-age=3600', + }; + if (s3Res.ContentLength) headers['Content-Length'] = String(s3Res.ContentLength); + if (s3Res.ContentRange) headers['Content-Range'] = s3Res.ContentRange; + if (s3Res.ETag) headers['ETag'] = s3Res.ETag; + if (s3Res.LastModified) headers['Last-Modified'] = s3Res.LastModified.toUTCString(); + res.writeHead(status, headers); + s3Res.Body.pipe(res); } catch (err) { next(err); } });