mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Two bugs in the File Viewer's media player, both reproduced in a real browser against an 18MB mp4 before and after the fix. 1. Closing the preview left the video playing. closeFilePreview() only dropped the overlay's `visible` class, which is display:none and nothing else, so the audio kept going with no visible player to pause. Detaching the element is not a fix either: a detached HTMLMediaElement plays on until it is garbage collected. _stopFilePreviewMedia() now pauses, drops src and load()s every media element (also on re-open, where overwriting innerHTML had the same effect), which additionally aborts the in-flight download. 2. The scrub bar was inert. file-raw read the whole file and answered 200 with no Accept-Ranges, so Chrome reported video.seekable as [0, 0] and silently reverted `currentTime = x`; Safari refuses to start such media at all. Raw bodies are now streamed and range-aware: Accept-Ranges: bytes on every response, 206 + Content-Range for a Range request, 416 for one past EOF, and a malformed spec ignored (200) per RFC 9110. Parsing is pure in src/web/http-range.ts. Measured on tmp/codeman-crt-v5-66s.mp4 (18MB, 66.6s): before seekable [0, 0] seek to 56.6s reverted to 3.9s close: still playing after seekable [0, 66.56] seek to 56.6s landed at 60.2s close: paused, NETWORK_EMPTY Range slices are byte-identical to `dd`, the full-file path is byte-identical to the file, and the SVG octet-stream/attachment hardening and the 50MB cap are unchanged (the cap is still checked before the range). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
205 lines
7.4 KiB
TypeScript
205 lines
7.4 KiB
TypeScript
/**
|
||
* @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);
|
||
});
|
||
});
|