feat(html-view): answer Range requests through the shared sendFileBody()

A rendered page's own <video> or <audio> could not seek, because the
route streamed a plain 200 without Accept-Ranges. sendFileBody() and
localFileSource() are now exported from file-routes.ts and the serving
route uses them, so it advertises Accept-Ranges and answers a Range with
206 + Content-Range (or 416), exactly like file-raw, the attachment raw
route and /api/download. The body sender hijacks the reply and copies
every header set before it (the sandbox CSP, CORS, no-store), which the
new test pins on a 206.

Media types stay in ASSET_TYPES: with Range support they work, and they
are served under the same sandbox CSP as everything else.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-13 17:11:50 +02:00
1 parent bc940a3953
commit fa23f8be2a
5 files changed
+37 -8

No files matched your search

+6 -3
View File
@@ -200,8 +200,8 @@ interface FileBodySource {
cleanup?: () => void;
}
/** Byte source for a LOCAL file (the historical, only path). */
function localFileSource(resolvedPath: string) {
/** Byte source for a LOCAL file (the historical, only path). Shared with html-view-routes.ts. */
export function localFileSource(resolvedPath: string) {
return (range?: { start: number; end: number }): FileBodySource => ({
content: createReadStream(resolvedPath, range ? { start: range.start, end: range.end } : undefined),
});
@@ -221,8 +221,11 @@ function localFileSource(resolvedPath: string) {
* `open` supplies the bytes for the (optional) range, which is what lets a remote
* case reuse this instead of re-implementing the 206/416 contract: same headers,
* same status codes, whether the file sits on this host or behind an ssh pipe.
*
* Exported for the rendered-HTML route (html-view-routes.ts), so a page's own
* `<video>`/`<audio>` can seek as well.
*/
function sendFileBody(
export function sendFileBody(
reply: FastifyReply,
size: number,
rangeHeader: string | string[] | undefined,
+7 -3
View File
@@ -25,7 +25,7 @@
*/
import type { FastifyInstance } from 'fastify';
import { createReadStream, realpathSync } from 'node:fs';
import { realpathSync } from 'node:fs';
import fs from 'node:fs/promises';
import { homedir, tmpdir } from 'node:os';
import { basename, dirname, extname, isAbsolute, relative, resolve, sep } from 'node:path';
@@ -34,6 +34,7 @@ import { isBlockedAttachmentPath, isUnderTree, loadAttachmentGuardConfig } from
import { HTML_VIEW_PREFIX, htmlViewCapabilities } from '../../html-view-capabilities.js';
import { CASES_DIR, findSessionOrFail, ownerFor, parseBody, validateSessionFilePath } from '../route-helpers.js';
import { HtmlViewOpenSchema } from '../schemas.js';
import { localFileSource, sendFileBody } from './file-routes.js';
import { getUserSpacesDir } from '../../config/multiuser.js';
import { exceedsDownloadLimit } from '../../config/buffer-limits.js';
import type { SessionPort } from '../ports/index.js';
@@ -254,7 +255,6 @@ export function registerHtmlViewRoutes(app: FastifyInstance, ctx: SessionPort):
reply.header('Content-Security-Policy', SANDBOX_CSP);
reply.header('Content-Type', contentType);
reply.header('Content-Length', String(stat.size));
reply.header('Cache-Control', 'no-store');
reply.header('Referrer-Policy', 'no-referrer');
// The sandboxed page is opaque-origin, so even its fetch() of a sibling
@@ -262,6 +262,10 @@ export function registerHtmlViewRoutes(app: FastifyInstance, ctx: SessionPort):
// enough, and widens nothing: these bytes are already readable by anyone
// holding the capability URL.
reply.header('Access-Control-Allow-Origin', '*');
return reply.send(createReadStream(resolvedPath));
// The file routes' body sender: Content-Length, and `Range` answered with 206
// (or 416), so a page's own <video>/<audio> can seek. It hijacks the reply, and
// carries every header set above across to it.
sendFileBody(reply, stat.size, req.headers.range, localFileSource(resolvedPath));
return reply;
});
}