Merge pull request #284 from Ark0N/fix/file-viewer-video

fix(file-viewer): make previewed video seekable and stop it on close
This commit is contained in:
Ark0N
2026-08-14 01:00:55 +02:00
committed by GitHub
9 changed files with 681 additions and 25 deletions
+2
View File
@@ -234,6 +234,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**File Viewer edit mode** (issue #212): the file-preview overlay edits workspace text files in place — `GET .../file-content?edit=1` + `PUT /api/sessions/:id/file-content`, policy in `src/config/file-editing.ts`. This is a **third file surface and the only one that WRITES**: read-path confinement (realpath + workspace + ownership) plus sensitive/blocked/`.git` denies and an extension **allowlist**; writes are `wx`-temp + rename (no `O_CREAT` anywhere = edit-in-place is structural); optimistic concurrency via sha256 `baseHash` → 409. ⚠️ `edit=1` never truncates and the client must never save a plain-preview buffer (the 500-line truncation would silently delete the rest). ⚠️ CRLF/UTF-8 guards: EOL re-applied server-side, non-UTF-8 refused via round-trip compare. → [architecture-invariants#file-viewer-edit-mode](docs/architecture-invariants.md#file-viewer-edit-mode), `docs/file-viewer-edit-plan.md`
**Raw file bodies are streamed and range-aware**: `file-raw` and the attachments `/raw` route always advertise `Accept-Ranges: bytes` and answer a `Range` header with `206` + `Content-Range` (single-range only; parser is pure + unit-tested in `src/web/http-range.ts`, a malformed spec is ignored → 200 while an out-of-bounds one is a 416). ⚠️ A 200-only response is what made the File Viewer's `<video>` unseekable: Chrome then reports `video.seekable` as `[0, 0]`, the scrub bar is inert and `currentTime = x` silently reverts (measured on an 18MB mp4), and Safari refuses to start the media at all. ⚠️ These bodies go out through `reply.hijack()`, which bypasses Fastify's status handling — `sendRawStream` must copy the status onto `reply.raw` by hand or a partial body ships labelled `200` and the browser treats a slice as the whole file. ⚠️ Closing the preview must **pause and unload** the media (`_stopFilePreviewMedia` in panels-ui.js): dropping the overlay's `visible` class is `display:none` and nothing else, and a DETACHED `HTMLMediaElement` keeps playing, which is how the X button used to leave a video audible with no player to pause.
**Ultracode / workflow-run visualization** (opt-in, default OFF): the Workflow tool writes a completion artifact only at run *end*, so live in-flight runs exist solely as transcript dirs. `workflow-run-watcher.ts` therefore synthesizes ACTIVE runs from transcripts until the completion artifact appears and supersedes them. It is **STANDALONE** and deliberately never imports or touches `subagent-watcher.ts`, despite reading the same tree. Two independent toggles: `showUltracodeAgents` (docked panel) and `ultracodeFloatingWindows` (floating windows); the watcher starts if **either** is on. → [architecture-invariants#ultracode--workflow-run-visualization](docs/architecture-invariants.md#ultracode-and-workflow-run-visualization)
**Clone a repository as a case** (issue #236, Add Case → **Clone Repo**): `POST /api/cases/clone` clones a public repo into the caller's case space synchronously (request held open, bounded by `GIT_CLONE_TIMEOUT_MS`, no job store); `POST /api/cases/clone-preflight` reports whether the URL can be cloned anonymously plus its real branches/tags. Core in `src/git-clone.ts`. ⚠️ **The URL is a code-execution surface**: `ext::sh -c <cmd>` (and ANY `<name>::<payload>` helper) makes git run a command, so every `::` form is refused, a leading `-` is refused, and every spawn is an argv array with `--` before the operands. ⚠️ **Non-interactive or the open request hangs** — `gitNonInteractiveEnv()` closes the terminal/askpass/ssh/GCM prompt paths; `HOME`/`PATH` stay inherited, so a user's OWN credential helper may authenticate (Codeman still never collects or stores credentials, and refuses a `user:password@` URL). ⚠️ Timeout kills the process GROUP (clone fans out into child processes), the destination is removed only if this attempt created it, and repository contents win over scaffolding (existing `CLAUDE.md` kept, hooks merged, repo-shipped `.claude/settings*` reported as a warning since its hooks run locally). The **Brain** picker sets the toolbar run mode on success. → [architecture-invariants#clone-a-repository-as-a-case](docs/architecture-invariants.md#clone-a-repository-as-a-case)
+1 -1
View File
@@ -312,7 +312,7 @@ TOCTOU window.
| Route | Cap | Notes |
|-------|-----|-------|
| `file-content` | 10 MB | text preview |
| `file-raw` | 50 MB | inline MIME map; **`X-Content-Type-Options: nosniff` on all responses** |
| `file-raw` | 50 MB | inline MIME map; **`X-Content-Type-Options: nosniff` on all responses**; streamed, `Range`-aware (206 slices come from the same validated path, and the cap is checked before the range) |
| `POST /api/download` | 50 MB | forced `attachment`; sensitive‑path blocklist |
### SVG / content‑type XSS
+86
View File
@@ -0,0 +1,86 @@
/**
* @fileoverview Pure HTTP byte-range parsing for the raw file-serving routes.
*
* Why this exists: a `<video>`/`<audio>` element is only seekable when the
* server advertises `Accept-Ranges: bytes` and answers `Range` requests with
* `206 Partial Content`. Serving the whole file with `200 OK` (what file-raw
* did) makes Chrome report `video.seekable === [0, 0]`, so the scrub bar is
* inert and `currentTime = x` is silently ignored; Safari refuses to start the
* media at all. Parsing lives here, away from the IO, so the edge cases
* (suffix ranges, open-ended ranges, oversized specs, empty files) are unit
* testable without touching the filesystem.
*
* Deliberately single-range only: multi-range responses require a
* `multipart/byteranges` body that no media element asks for, and RFC 9110
* §14.2 lets a server ignore a Range it does not want to honor and answer with
* the full representation. Same for syntactically invalid specs — those are
* ignored (200), while a syntactically valid but out-of-bounds spec is the one
* case that earns a 416.
*/
/** Result of parsing a `Range` header against a known representation size. */
export type ByteRangeRequest =
/** No range, an unsupported unit, or a malformed spec — serve the whole file with 200. */
| { kind: 'full' }
/** A satisfiable single range, inclusive on both ends — serve 206. */
| { kind: 'partial'; start: number; end: number }
/** Syntactically valid but outside the representation — serve 416. */
| { kind: 'unsatisfiable' };
const BYTES_RANGE_SPEC = /^(\d*)-(\d*)$/;
/**
* Digits → number, bounded. A range spec is arbitrary client input, so a
* 100-digit first-byte-pos must not become `Infinity` (which would then flow
* into a `createReadStream` offset). Anything longer than a safe integer is
* clamped, which the callers then treat as "past the end of the file".
*/
function parseBoundedInt(digits: string): number {
return digits.length > 15 ? Number.MAX_SAFE_INTEGER : Number(digits);
}
/**
* Parse a `Range` request header against a file of `size` bytes.
*
* @param header - Raw header value (`req.headers.range`). Arrays (a duplicated
* header) are ignored rather than guessed at.
* @param size - Size of the full representation in bytes.
*/
export function parseByteRange(header: string | string[] | undefined, size: number): ByteRangeRequest {
if (typeof header !== 'string') return { kind: 'full' };
const trimmed = header.trim();
const eq = trimmed.indexOf('=');
if (eq < 0 || trimmed.slice(0, eq).trim().toLowerCase() !== 'bytes') return { kind: 'full' };
const spec = trimmed.slice(eq + 1).trim();
// Multi-range requests would need a multipart/byteranges body; ignoring the
// header and serving the full representation is a valid answer.
if (!spec || spec.includes(',')) return { kind: 'full' };
const match = BYTES_RANGE_SPEC.exec(spec);
if (!match) return { kind: 'full' };
const [, rawStart, rawEnd] = match;
if (!rawStart && !rawEnd) return { kind: 'full' };
// Suffix range: `bytes=-N` means the LAST N bytes, not "from N to the end".
if (!rawStart) {
const suffix = parseBoundedInt(rawEnd);
if (suffix === 0 || size === 0) return { kind: 'unsatisfiable' };
return { kind: 'partial', start: Math.max(0, size - suffix), end: size - 1 };
}
const start = parseBoundedInt(rawStart);
if (size === 0 || start >= size) return { kind: 'unsatisfiable' };
// `bytes=N-` — from N to the end of the file. This is the form Chrome opens
// a media element with (`bytes=0-`), so it must answer 206, not 200.
if (!rawEnd) return { kind: 'partial', start, end: size - 1 };
const requestedEnd = parseBoundedInt(rawEnd);
// last-byte-pos < first-byte-pos is an invalid spec, not an unsatisfiable
// one: RFC 9110 §14.1.1 says the whole header field is then ignored.
if (requestedEnd < start) return { kind: 'full' };
return { kind: 'partial', start, end: Math.min(requestedEnd, size - 1) };
}
+35 -2
View File
@@ -3246,6 +3246,9 @@ Object.assign(CodemanApp.prototype, {
// Edit mode: reset any prior editor state whenever a preview (re)loads.
this._resetFilePreviewEdit();
// Stop whatever the previous preview was playing. Overwriting innerHTML
// only DETACHES a <video>/<audio>; a detached media element keeps playing.
this._stopFilePreviewMedia();
// Show overlay with loading state
overlay.classList.add('visible');
@@ -3330,10 +3333,13 @@ Object.assign(CodemanApp.prototype, {
bodyEl.innerHTML = `<img src="${data.url}" alt="${escapeHtml(filePath)}">`;
footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`;
} else if (data.type === 'video') {
bodyEl.innerHTML = `<video src="${data.url}" controls autoplay></video>`;
// playsinline: iOS otherwise hijacks playback into its fullscreen
// player, which leaves the overlay behind it and its own close button
// as the only way back.
bodyEl.innerHTML = `<video src="${escapeHtml(data.url)}" controls autoplay playsinline preload="metadata"></video>`;
footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`;
} else if (data.type === 'audio') {
bodyEl.innerHTML = `<audio src="${data.url}" controls autoplay></audio>`;
bodyEl.innerHTML = `<audio src="${escapeHtml(data.url)}" controls autoplay preload="metadata"></audio>`;
footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`;
} else if (data.type === 'binary') {
const downloadHref = `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}&download=true`;
@@ -3366,9 +3372,36 @@ Object.assign(CodemanApp.prototype, {
if (overlay) {
overlay.classList.remove('visible');
}
// The overlay is hidden with display:none, which stops it being PAINTED and
// nothing else: a <video>/<audio> inside it keeps playing, keeps its audio
// audible and keeps streaming from the server. Closing has to stop it.
this._stopFilePreviewMedia();
this.filePreviewContent = '';
},
/**
* Pause and unload every media element in the preview body, then empty it.
*
* Removing the element from the DOM is NOT enough — a detached HTMLMediaElement
* plays on until it is garbage collected, which is why the X button used to
* leave a video audible. pause() stops playback, dropping src + load() aborts
* the in-flight network fetch and puts the element back in NETWORK_EMPTY.
*/
_stopFilePreviewMedia() {
const bodyEl = this.$('filePreviewBody');
if (!bodyEl) return;
for (const media of bodyEl.querySelectorAll('video, audio')) {
try {
media.pause();
media.removeAttribute('src');
media.load();
} catch (err) {
console.warn('Failed to stop preview media:', err);
}
}
bodyEl.innerHTML = '';
},
// ═══════════════════════════════════════════════════════════════
// File Viewer edit mode (issue #212 — docs/file-viewer-edit-plan.md)
// ═══════════════════════════════════════════════════════════════
+64 -18
View File
@@ -46,6 +46,7 @@ import {
} from '../route-helpers.js';
import type { FastifyRequest } from 'fastify';
import type { SessionAttachmentHistoryItem, SessionState } from '../../types/session.js';
import { parseByteRange } from '../http-range.js';
import { isSensitivePath } from '../sensitive-path.js';
import { SseEvent } from '../sse-events.js';
import type { ConfigPort, EventPort, SessionPort } from '../ports/index.js';
@@ -86,7 +87,13 @@ function buildContentDisposition(disposition: 'inline' | 'attachment', fileName:
function sendRawStream(reply: FastifyReply, content: ReadStream): void {
const headers = reply.getHeaders();
// hijack() answers on reply.raw, which keeps Fastify's own status handling out
// of the picture — so a 206 set with reply.code() has to be carried across by
// hand or a partial body would go out labelled 200 and the browser would treat
// it as the whole file.
const statusCode = reply.statusCode;
reply.hijack();
reply.raw.statusCode = statusCode;
for (const [name, value] of Object.entries(headers)) {
if (value !== undefined) {
@@ -106,12 +113,54 @@ function sendRawStream(reply: FastifyReply, content: ReadStream): void {
content.pipe(reply.raw);
}
/**
* Stream a file body, honoring a `Range` request header.
*
* Callers set Content-Type/Content-Disposition first; this adds the
* range-related headers and the body. Range support is what makes the file
* viewer's `<video>`/`<audio>` seekable: with a plain 200 and no
* `Accept-Ranges`, Chrome reports `video.seekable` as `[0, 0]`, the scrub bar
* does nothing and `currentTime = x` is silently reverted (measured against an
* 18MB mp4 before this existed). It also stops each seek from re-reading the
* whole file into memory.
*/
function sendFileBody(
reply: FastifyReply,
resolvedPath: string,
size: number,
rangeHeader: string | string[] | undefined
): void {
reply.header('Accept-Ranges', 'bytes');
const range = parseByteRange(rangeHeader, size);
if (range.kind === 'unsatisfiable') {
reply
.code(416)
.header('Content-Range', `bytes */${size}`)
.type('application/json; charset=utf-8')
.send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Requested range not satisfiable'));
return;
}
if (range.kind === 'partial') {
reply.code(206);
reply.header('Content-Range', `bytes ${range.start}-${range.end}/${size}`);
reply.header('Content-Length', range.end - range.start + 1);
sendRawStream(reply, createReadStream(resolvedPath, { start: range.start, end: range.end }));
return;
}
reply.header('Content-Length', size);
sendRawStream(reply, createReadStream(resolvedPath));
}
async function serveRawFile(
reply: FastifyReply,
resolvedPath: string,
fileName: string,
extension: string,
download?: boolean
download?: boolean,
rangeHeader?: string | string[]
): Promise<void> {
const stat = await fs.stat(resolvedPath);
const MAX_RAW_ATTACHMENT_SIZE = 50 * 1024 * 1024; // 50MB, matching file-raw / download
@@ -126,24 +175,21 @@ async function serveRawFile(
);
return;
}
const content = createReadStream(resolvedPath);
if (download || extension === 'svg') {
reply.header(
'Content-Type',
extension === 'svg' ? 'application/octet-stream' : MIME_TYPES[extension] || 'application/octet-stream'
);
reply.header('Content-Disposition', buildContentDisposition('attachment', fileName));
reply.header('Content-Length', stat.size);
reply.header('X-Content-Type-Options', 'nosniff');
sendRawStream(reply, content);
sendFileBody(reply, resolvedPath, stat.size, rangeHeader);
return;
}
reply.header('Content-Type', MIME_TYPES[extension] || 'application/octet-stream');
reply.header('Content-Disposition', buildContentDisposition('inline', fileName));
reply.header('Content-Length', stat.size);
reply.header('X-Content-Type-Options', 'nosniff');
sendRawStream(reply, content);
sendFileBody(reply, resolvedPath, stat.size, rangeHeader);
}
function getAttachmentOr404(
@@ -849,7 +895,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
await serveConvertedPreview(reply, resolvedPath, fileName, extension);
return;
}
await serveRawFile(reply, resolvedPath, fileName, extension);
await serveRawFile(reply, resolvedPath, fileName, extension, false, req.headers.range);
});
// File tree listing
@@ -1369,24 +1415,24 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
json: 'application/json',
};
const content = await fs.readFile(resolvedPath);
const rawBasename = filePath!.split('/').pop() || 'download';
// Sanitize filename for Content-Disposition header (prevent header injection)
const basename = rawBasename.replace(/["\\\r\n]/g, '_');
if (download === 'true' || ext === 'svg') {
reply.raw.writeHead(200, {
...inheritedHeaders(reply),
'Content-Type': ext === 'svg' ? 'application/octet-stream' : mimeTypes[ext] || 'application/octet-stream',
'Content-Disposition': `attachment; filename="${basename}"`,
'Content-Length': content.length,
'X-Content-Type-Options': 'nosniff',
});
reply.raw.end(content);
reply.header(
'Content-Type',
ext === 'svg' ? 'application/octet-stream' : mimeTypes[ext] || 'application/octet-stream'
);
reply.header('Content-Disposition', `attachment; filename="${basename}"`);
reply.header('X-Content-Type-Options', 'nosniff');
sendFileBody(reply, resolvedPath, stat.size, req.headers.range);
return;
}
reply.header('Content-Type', mimeTypes[ext] || 'application/octet-stream');
reply.header('X-Content-Type-Options', 'nosniff');
reply.send(content);
// Streamed, range-aware: this is the <video>/<audio> source the file
// viewer points at, and a 200-only response makes the media unseekable.
sendFileBody(reply, resolvedPath, stat.size, req.headers.range);
} catch (err) {
reply
.code(500)
@@ -1503,7 +1549,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
if (!servePath) return;
try {
await serveRawFile(reply, servePath, record.fileName, record.extension, download === 'true');
await serveRawFile(reply, servePath, record.fileName, record.extension, download === 'true', req.headers.range);
} catch (err) {
reply
.code(500)
+178
View File
@@ -0,0 +1,178 @@
/**
* @fileoverview File viewer media teardown: closing the preview must stop the video.
*
* `closeFilePreview()` used to do nothing but drop the overlay's `visible`
* class. That hides the overlay (`display: none`) and hides it ONLY: the
* `<video>` inside carried on playing, so the audio kept going after the user
* pressed X, with no visible player to pause. Detaching the element is not a fix
* either — a detached HTMLMediaElement plays until it is garbage collected —
* which is why the teardown has to pause() and unload the element explicitly.
*
* What is pinned here:
* 1. close pauses AND unloads every media element (not just the first),
* 2. close still works with no media in the body (the common text case),
* 3. opening a NEW preview stops what the previous one was playing, since
* overwriting innerHTML only detaches it,
* 4. a dirty edit buffer still wins: cancelling the discard prompt must not
* tear the buffer down.
*
* Loaded via `vm` against a stub app, same harness style as
* file-browser-hidden.test.ts (no jsdom).
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const panelsJs = readFileSync(resolve(PUBLIC, 'panels-ui.js'), 'utf8');
interface FakeMedia {
tag: 'video' | 'audio';
paused: boolean;
src: string | null;
loadCalls: number;
pause: () => void;
removeAttribute: (name: string) => void;
load: () => void;
}
function fakeMedia(tag: 'video' | 'audio'): FakeMedia {
const el: FakeMedia = {
tag,
paused: false,
src: 'https://example.test/clip.mp4',
loadCalls: 0,
pause() {
el.paused = true;
},
removeAttribute(name: string) {
if (name === 'src') el.src = null;
},
load() {
el.loadCalls += 1;
},
};
return el;
}
function loadApp(media: FakeMedia[]) {
const CodemanApp = function CodemanApp(this: unknown) {} as unknown as new () => Record<string, unknown>;
const context = vm.createContext({
CodemanApp,
console: { ...console, warn: vi.fn() },
localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
escapeHtml: (s: string) => String(s),
document: { getElementById: () => null, addEventListener: vi.fn() },
window: { addEventListener: vi.fn() },
setTimeout,
clearTimeout,
confirm: () => true,
fetch: () => {
throw new Error('fetch not stubbed');
},
});
vm.runInContext(panelsJs, context, { filename: 'panels-ui.js' });
const body = {
innerHTML: '<video src="/api/sessions/s1/file-raw?path=clip.mp4" controls></video>',
querySelectorAll: (sel: string) => {
expect(sel).toBe('video, audio');
return media;
},
};
const overlay = {
classes: new Set<string>(['visible']),
classList: {
add: (c: string) => overlay.classes.add(c),
remove: (c: string) => overlay.classes.delete(c),
contains: (c: string) => overlay.classes.has(c),
},
};
const elements: Record<string, unknown> = { filePreviewBody: body, filePreviewOverlay: overlay };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const app = new CodemanApp() as Record<string, any>;
app.$ = (id: string) => elements[id] ?? null;
app.filePreviewContent = 'previous content';
app.context = context;
return { app, body, overlay, context };
}
describe('file viewer media teardown', () => {
let media: FakeMedia[];
beforeEach(() => {
media = [fakeMedia('video')];
});
it('pauses and unloads the video when the preview is closed', () => {
const { app, overlay, body } = loadApp(media);
app.closeFilePreview();
expect(overlay.classList.contains('visible')).toBe(false);
expect(media[0].paused).toBe(true);
// src dropped + load() is what aborts the in-flight fetch; pause() alone
// leaves the browser downloading the rest of the file.
expect(media[0].src).toBeNull();
expect(media[0].loadCalls).toBe(1);
expect(body.innerHTML).toBe('');
});
it('stops every media element, not just the first', () => {
media = [fakeMedia('video'), fakeMedia('audio')];
const { app } = loadApp(media);
app.closeFilePreview();
expect(media.every((m) => m.paused && m.src === null)).toBe(true);
});
it('closes cleanly when the preview holds no media (the text case)', () => {
const { app, overlay } = loadApp([]);
expect(() => app.closeFilePreview()).not.toThrow();
expect(overlay.classList.contains('visible')).toBe(false);
expect(app.filePreviewContent).toBe('');
});
it('survives a media element that throws on teardown', () => {
const hostile = fakeMedia('video');
hostile.pause = () => {
throw new Error('detached');
};
const { app, overlay } = loadApp([hostile]);
expect(() => app.closeFilePreview()).not.toThrow();
expect(overlay.classList.contains('visible')).toBe(false);
});
it('stops the previous video when another file is previewed', async () => {
const { app, context } = loadApp(media);
// openFilePreview bails right after the teardown: the fetch stub rejects and
// the handler swallows it, which is enough to pin the teardown ordering.
context.fetch = async () => ({ ok: false, json: async () => ({ success: false }) });
app._resetFilePreviewEdit = () => {};
app.$ = ((orig) => (id: string) => (id === 'filePreviewTitle' || id === 'filePreviewFooter' ? {} : orig(id)))(
app.$
);
await app.openFilePreview('other.txt', 's1');
expect(media[0].paused).toBe(true);
expect(media[0].src).toBeNull();
});
it('keeps the editor buffer when the discard prompt is declined', () => {
const { app, overlay, context } = loadApp(media);
context.confirm = () => false;
app.filePreviewEdit = { dirty: true };
app.closeFilePreview();
expect(overlay.classList.contains('visible')).toBe(true);
expect(media[0].paused).toBe(false);
});
});
+101
View File
@@ -0,0 +1,101 @@
/**
* @fileoverview Byte-range parsing for the raw file-serving routes.
*
* The file viewer's video player is only seekable when file-raw answers `Range`
* requests with 206 (measured before the fix: `video.seekable` was `[0, 0]` and
* `currentTime = x` silently reverted). What that correctness rests on is this
* parser, so the cases pinned here are the ones a media element actually emits
* plus the malformed input a browser never sends but a client can:
*
* - `bytes=0-` — how Chrome opens EVERY media element. Must be 206, not 200.
* - `bytes=-N` — the SUFFIX form (last N bytes), not "from N onwards"; mp4
* players use it to read a trailing moov atom.
* - out of bounds -> 416, malformed -> ignored (200), which are different
* answers for what looks like the same "bad range".
*/
import { describe, expect, it } from 'vitest';
import { parseByteRange } from '../src/web/http-range.js';
describe('parseByteRange', () => {
it('serves the full file when there is no Range header', () => {
expect(parseByteRange(undefined, 1000)).toEqual({ kind: 'full' });
expect(parseByteRange('', 1000)).toEqual({ kind: 'full' });
});
it('answers bytes=0- with a partial range (the form Chrome opens media with)', () => {
expect(parseByteRange('bytes=0-', 1000)).toEqual({ kind: 'partial', start: 0, end: 999 });
});
it('parses a closed range inclusive of both ends', () => {
expect(parseByteRange('bytes=100-199', 1000)).toEqual({ kind: 'partial', start: 100, end: 199 });
});
it('clamps an end past EOF instead of rejecting the range', () => {
expect(parseByteRange('bytes=900-5000', 1000)).toEqual({ kind: 'partial', start: 900, end: 999 });
});
it('reads bytes=-N as the LAST N bytes, not as an offset', () => {
expect(parseByteRange('bytes=-100', 1000)).toEqual({ kind: 'partial', start: 900, end: 999 });
});
it('clamps a suffix longer than the file to the whole file', () => {
expect(parseByteRange('bytes=-5000', 1000)).toEqual({ kind: 'partial', start: 0, end: 999 });
});
it('accepts a single-byte range', () => {
expect(parseByteRange('bytes=0-0', 1000)).toEqual({ kind: 'partial', start: 0, end: 0 });
});
it('tolerates whitespace and a capitalised unit', () => {
expect(parseByteRange(' BYTES = 10-20 ', 1000)).toEqual({ kind: 'partial', start: 10, end: 20 });
});
it('reports a start at or past EOF as unsatisfiable (416)', () => {
expect(parseByteRange('bytes=1000-', 1000)).toEqual({ kind: 'unsatisfiable' });
expect(parseByteRange('bytes=1500-1600', 1000)).toEqual({ kind: 'unsatisfiable' });
});
it('reports a zero-length suffix as unsatisfiable', () => {
expect(parseByteRange('bytes=-0', 1000)).toEqual({ kind: 'unsatisfiable' });
});
it('reports any range against an empty file as unsatisfiable', () => {
expect(parseByteRange('bytes=0-', 0)).toEqual({ kind: 'unsatisfiable' });
expect(parseByteRange('bytes=-10', 0)).toEqual({ kind: 'unsatisfiable' });
});
it('ignores an inverted range rather than 416-ing it (invalid spec, not unsatisfiable)', () => {
expect(parseByteRange('bytes=500-100', 1000)).toEqual({ kind: 'full' });
});
it('ignores units it does not implement', () => {
expect(parseByteRange('items=0-10', 1000)).toEqual({ kind: 'full' });
expect(parseByteRange('bytes 0-10', 1000)).toEqual({ kind: 'full' });
});
it('ignores multi-range requests instead of answering only the first range', () => {
// A multipart/byteranges body is the only correct answer to these, and no
// media element asks for one — serving the whole file is spec-legal.
expect(parseByteRange('bytes=0-99,200-299', 1000)).toEqual({ kind: 'full' });
});
it('ignores malformed specs', () => {
expect(parseByteRange('bytes=', 1000)).toEqual({ kind: 'full' });
expect(parseByteRange('bytes=-', 1000)).toEqual({ kind: 'full' });
expect(parseByteRange('bytes=abc-def', 1000)).toEqual({ kind: 'full' });
expect(parseByteRange('bytes=1.5-2', 1000)).toEqual({ kind: 'full' });
});
it('ignores a duplicated Range header rather than guessing which one won', () => {
expect(parseByteRange(['bytes=0-10', 'bytes=20-30'], 1000)).toEqual({ kind: 'full' });
});
it('bounds an absurdly long offset instead of producing Infinity', () => {
// A 100-digit first-byte-pos must not reach createReadStream as Infinity.
const huge = '9'.repeat(100);
expect(parseByteRange(`bytes=${huge}-`, 1000)).toEqual({ kind: 'unsatisfiable' });
const range = parseByteRange(`bytes=0-${huge}`, 1000);
expect(range).toEqual({ kind: 'partial', start: 0, end: 999 });
});
});
+204
View File
@@ -0,0 +1,204 @@
/**
* @fileoverview Range-request coverage for the raw file-serving routes.
*
* The file viewer points a `<video>` at `GET /api/sessions/:id/file-raw`. That
* route used to read the whole file and answer 200 with no `Accept-Ranges`,
* which makes a browser treat the media as unseekable: measured against an 18MB
* mp4, `video.seekable` was `[0, 0]` and assigning `currentTime` was reverted on
* the next tick, so the scrub bar looked dead.
*
* These tests pin the wire contract that makes seeking work, since none of it is
* visible from a plain 200-vs-404 assertion:
* 1. `Accept-Ranges: bytes` on the un-ranged response (what tells the browser
* it MAY seek at all),
* 2. 206 + `Content-Range` + the sliced body for a range request,
* 3. the slice actually coming from a bounded read, not a full-file read that
* is then truncated,
* 4. 416 (with `Content-Range: bytes *​/size`) for a range past EOF, rather
* than a silent full-body 200 the media element cannot interpret.
*
* Uses app.inject() — no real ports.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { Readable } from 'node:stream';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
import { registerFileRoutes } from '../../src/web/routes/file-routes.js';
const FILE_BYTES = Buffer.from('0123456789ABCDEFGHIJ'); // 20 bytes, index == value position
vi.mock('node:fs/promises', () => ({
default: {
readFile: vi.fn(async () => Buffer.from('unused')),
stat: vi.fn(async () => ({ size: 20, isFile: () => true, isDirectory: () => false, mtimeMs: 1 })),
readdir: vi.fn(async () => []),
},
}));
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
return {
...actual,
realpathSync: vi.fn((p: string) => p),
// Honour start/end so a test can tell a real bounded read from a full read.
createReadStream: vi.fn((_path: string, opts?: { start?: number; end?: number }) => {
const start = opts?.start ?? 0;
const end = opts?.end ?? FILE_BYTES.length - 1;
return Readable.from([FILE_BYTES.subarray(start, end + 1)]);
}),
};
});
vi.mock('../../src/file-stream-manager.js', () => ({
fileStreamManager: {
createStream: vi.fn(async () => ({ success: true, streamId: 'stream-1' })),
closeStream: vi.fn(() => true),
},
}));
import fs from 'node:fs/promises';
import { createReadStream, realpathSync } from 'node:fs';
const mockedStat = vi.mocked(fs.stat);
const mockedRealpathSync = vi.mocked(realpathSync);
const mockedCreateReadStream = vi.mocked(createReadStream);
describe('file-raw range requests', () => {
let harness: RouteTestHarness;
let sid: string;
beforeEach(async () => {
harness = await createRouteTestHarness(registerFileRoutes);
vi.clearAllMocks();
mockedRealpathSync.mockImplementation((p: string) => p as never);
mockedStat.mockResolvedValue({ size: FILE_BYTES.length, isFile: () => true } as never);
mockedCreateReadStream.mockImplementation(
(_path: unknown, opts?: unknown) =>
Readable.from([
FILE_BYTES.subarray(
(opts as { start?: number })?.start ?? 0,
((opts as { end?: number })?.end ?? FILE_BYTES.length - 1) + 1
),
]) as never
);
sid = harness.ctx._sessionId as string;
});
afterEach(() => {
vi.restoreAllMocks();
});
const rawUrl = (name = 'clip.mp4') => `/api/sessions/${sid}/file-raw?path=${name}`;
it('advertises Accept-Ranges on an un-ranged response, so the browser knows it may seek', async () => {
const res = await harness.app.inject({ method: 'GET', url: rawUrl() });
expect(res.statusCode).toBe(200);
expect(res.headers['accept-ranges']).toBe('bytes');
expect(res.headers['content-type']).toBe('video/mp4');
expect(res.headers['content-length']).toBe(String(FILE_BYTES.length));
expect(res.rawPayload.equals(FILE_BYTES)).toBe(true);
});
it('answers bytes=0- with 206 (Chrome opens every media element this way)', async () => {
const res = await harness.app.inject({
method: 'GET',
url: rawUrl(),
headers: { range: 'bytes=0-' },
});
expect(res.statusCode).toBe(206);
expect(res.headers['content-range']).toBe(`bytes 0-19/${FILE_BYTES.length}`);
expect(res.headers['content-length']).toBe(String(FILE_BYTES.length));
expect(res.rawPayload.equals(FILE_BYTES)).toBe(true);
});
it('serves a mid-file slice from a bounded read', async () => {
const res = await harness.app.inject({
method: 'GET',
url: rawUrl(),
headers: { range: 'bytes=5-9' },
});
expect(res.statusCode).toBe(206);
expect(res.headers['content-range']).toBe('bytes 5-9/20');
expect(res.headers['content-length']).toBe('5');
expect(res.rawPayload.toString()).toBe('56789');
// The read itself must be bounded: a full read that is sliced afterwards
// would still pull an 18MB video into memory on every seek.
expect(mockedCreateReadStream).toHaveBeenCalledWith(expect.any(String), { start: 5, end: 9 });
});
it('serves a suffix range as the LAST N bytes', async () => {
const res = await harness.app.inject({
method: 'GET',
url: rawUrl(),
headers: { range: 'bytes=-4' },
});
expect(res.statusCode).toBe(206);
expect(res.headers['content-range']).toBe('bytes 16-19/20');
expect(res.rawPayload.toString()).toBe('GHIJ');
});
it('answers a range past EOF with 416 instead of a full-body 200', async () => {
const res = await harness.app.inject({
method: 'GET',
url: rawUrl(),
headers: { range: 'bytes=100-200' },
});
expect(res.statusCode).toBe(416);
expect(res.headers['content-range']).toBe('bytes */20');
expect(JSON.parse(res.body).success).toBe(false);
});
it('ignores a malformed range and serves the whole file', async () => {
const res = await harness.app.inject({
method: 'GET',
url: rawUrl(),
headers: { range: 'bytes=abc-def' },
});
expect(res.statusCode).toBe(200);
expect(res.rawPayload.equals(FILE_BYTES)).toBe(true);
});
it('keeps the security headers on a partial response', async () => {
// 206 bodies go out through reply.hijack(), which bypasses Fastify's own
// header write — the nosniff/type headers have to be carried across by hand.
const res = await harness.app.inject({
method: 'GET',
url: rawUrl(),
headers: { range: 'bytes=0-3' },
});
expect(res.statusCode).toBe(206);
expect(res.headers['x-content-type-options']).toBe('nosniff');
expect(res.headers['content-type']).toBe('video/mp4');
});
it('supports resuming a download (?download=true) as well as inline playback', async () => {
const res = await harness.app.inject({
method: 'GET',
url: `${rawUrl('clip.mp4')}&download=true`,
headers: { range: 'bytes=10-14' },
});
expect(res.statusCode).toBe(206);
expect(res.headers['content-disposition']).toContain('attachment; filename="clip.mp4"');
expect(res.rawPayload.toString()).toBe('ABCDE');
});
it('still refuses files past the raw size cap before looking at Range', async () => {
mockedStat.mockResolvedValue({ size: 100 * 1024 * 1024, isFile: () => true } as never);
const res = await harness.app.inject({
method: 'GET',
url: rawUrl('huge.mp4'),
headers: { range: 'bytes=0-99' },
});
expect(res.statusCode).toBe(400);
});
});
+10 -4
View File
@@ -6,6 +6,7 @@
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { Readable } from 'node:stream';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
import { registerFileRoutes } from '../../src/web/routes/file-routes.js';
import { ApiErrorCode } from '../../src/types.js';
@@ -19,12 +20,15 @@ vi.mock('node:fs/promises', () => ({
},
}));
// Mock realpathSync for symlink resolution
// Mock realpathSync for symlink resolution, plus createReadStream: file-raw
// STREAMS its body (range support), so an unmocked read would hit the real
// filesystem and fail with ENOENT rather than serving the fixture bytes.
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
return {
...actual,
realpathSync: vi.fn((p: string) => p),
createReadStream: vi.fn(() => Readable.from([Buffer.from('fake file bytes')])),
};
});
@@ -37,13 +41,14 @@ vi.mock('../../src/file-stream-manager.js', () => ({
}));
import fs from 'node:fs/promises';
import { realpathSync } from 'node:fs';
import { createReadStream, realpathSync } from 'node:fs';
import { fileStreamManager } from '../../src/file-stream-manager.js';
const mockedReaddir = vi.mocked(fs.readdir);
const mockedReadFile = vi.mocked(fs.readFile);
const mockedStat = vi.mocked(fs.stat);
const mockedRealpathSync = vi.mocked(realpathSync);
const mockedCreateReadStream = vi.mocked(createReadStream);
const mockedFileStreamManager = vi.mocked(fileStreamManager);
describe('file-routes', () => {
@@ -55,6 +60,7 @@ describe('file-routes', () => {
// Default: realpathSync returns the path unchanged
mockedRealpathSync.mockImplementation((p: string) => p as never);
mockedCreateReadStream.mockImplementation(() => Readable.from([Buffer.from('fake file bytes')]) as never);
// Default stat
mockedStat.mockResolvedValue({ size: 100, isFile: () => true, isDirectory: () => true } as never);
mockedReadFile.mockImplementation(async (path) =>
@@ -739,7 +745,7 @@ describe('file-routes', () => {
it('serves raw file with correct content type', async () => {
const content = Buffer.from('fake png data');
mockedReadFile.mockResolvedValue(content as never);
mockedCreateReadStream.mockReturnValue(Readable.from([content]) as never);
mockedStat.mockResolvedValue({ size: content.length } as never);
const res = await harness.app.inject({
@@ -752,7 +758,7 @@ describe('file-routes', () => {
it('serves workspace SVG as an untrusted attachment instead of inline image/svg+xml', async () => {
const content = Buffer.from('<svg><script>alert("xss")</script></svg>');
mockedReadFile.mockResolvedValue(content as never);
mockedCreateReadStream.mockReturnValue(Readable.from([content]) as never);
mockedStat.mockResolvedValue({ size: content.length } as never);
const res = await harness.app.inject({