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];
|
const a = r.rows[0];
|
||||||
if (a.status === 'live') return res.json({ url: `/live/${a.id}/index.m3u8`, type: 'hls', live: true });
|
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'];
|
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 ||
|
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 && VIDEO_EXTS.some(ext => a.original_s3_key.toLowerCase().endsWith(ext))
|
||||||
? a.original_s3_key : null);
|
? a.original_s3_key : null);
|
||||||
if (key) {
|
if (key) {
|
||||||
const signedUrl = await getSignedUrlForObject(key, 14400);
|
return res.json({ url: `/api/v1/assets/${id}/video`, type: 'mp4', source: a.proxy_s3_key ? 'proxy' : 'original' });
|
||||||
return res.json({ url: signedUrl, 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 });
|
return res.json({ url: null, type: null, reason: 'no_proxy', has_source: !!a.original_s3_key });
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
|
|
@ -562,9 +559,14 @@ router.get('/:id/live-path', async (req, res, next) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /:id/video
|
// GET /:id/video
|
||||||
// Redirects to a signed S3 URL so the browser streams directly from S3.
|
// Proxies the S3 object through Node with proper cache headers.
|
||||||
// This eliminates the Node.js proxy bottleneck and lets S3 handle range
|
// Direct S3 redirect doesn't work because broadcastmgmt.cloud (RustFS/openresty)
|
||||||
// requests natively — critical for smooth seeking in the player.
|
// 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) => {
|
router.get('/:id/video', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
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 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);
|
const key = a.proxy_s3_key || (origIsVideo ? a.original_s3_key : null);
|
||||||
if (!key) return res.status(404).json({ error: 'No browser-playable source' });
|
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 params = { Bucket: getS3Bucket(), Key: key };
|
||||||
const url = await getSignedUrlForObject(key, 14400);
|
const rangeHeader = req.headers.range;
|
||||||
res.redirect(302, url);
|
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); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue