fix(video): revert S3 redirect — RustFS rejects range+Origin; proxy with cache headers

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.
This commit is contained in:
Zac Gaetano 2026-05-26 17:40:02 +00:00
parent 37247fdfea
commit 03aa7a0673

View file

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