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:
parent
37247fdfea
commit
03aa7a0673
1 changed files with 27 additions and 10 deletions
|
|
@ -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); }
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue