diff --git a/apps/web/src/actions.ts b/apps/web/src/actions.ts index 5bd10b6..06e8555 100644 --- a/apps/web/src/actions.ts +++ b/apps/web/src/actions.ts @@ -69,6 +69,7 @@ import { renameSession, } from './chunked.js'; import { boundaryOf } from './multipart.js'; +import { parseByteRange } from './range.js'; import { UploadError, receiveVideoUpload } from './receive.js'; import { beginUpload, @@ -939,20 +940,18 @@ export const registerActions = (app: Hono): void => { c.header('accept-ranges', 'bytes'); c.header('content-type', 'video/mp4'); - const match = range === undefined ? null : /^bytes=(\d*)-(\d*)$/.exec(range.trim()); - if (match === null) { + const parsedRange = parseByteRange(range, size); + if (parsedRange.kind === 'none') { c.header('content-length', String(size)); return c.body(Readable.toWeb(createReadStream(file)) as ReadableStream); } - // An open-ended range ("bytes=1000-") is what a seeking player sends. - const start = match[1] === '' ? 0 : Number(match[1]); - const end = match[2] === '' ? size - 1 : Math.min(Number(match[2]), size - 1); - if (!Number.isFinite(start) || start > end || start >= size) { + if (parsedRange.kind === 'invalid') { c.header('content-range', `bytes */${size}`); return c.body(null, 416); } + const { start, end } = parsedRange; c.header('content-range', `bytes ${start}-${end}/${size}`); c.header('content-length', String(end - start + 1)); return c.body( diff --git a/apps/web/src/range.test.ts b/apps/web/src/range.test.ts new file mode 100644 index 0000000..a8c31b6 --- /dev/null +++ b/apps/web/src/range.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; + +import { parseByteRange } from './range.js'; + +describe('byte ranges', () => { + it('leaves absent and unsupported range headers alone', () => { + expect(parseByteRange(undefined, 1_000)).toEqual({ kind: 'none' }); + expect(parseByteRange('items=0-10', 1_000)).toEqual({ kind: 'none' }); + }); + + it('parses bounded and open-ended ranges', () => { + expect(parseByteRange('bytes=100-199', 1_000)).toEqual({ + kind: 'range', + start: 100, + end: 199, + }); + expect(parseByteRange('bytes=100-', 1_000)).toEqual({ + kind: 'range', + start: 100, + end: 999, + }); + }); + + it('serves suffix ranges from the end of the file', () => { + expect(parseByteRange('bytes=-500', 1_000)).toEqual({ + kind: 'range', + start: 500, + end: 999, + }); + expect(parseByteRange('bytes=-2000', 1_000)).toEqual({ + kind: 'range', + start: 0, + end: 999, + }); + }); + + it('rejects syntactically valid but unsatisfiable ranges', () => { + expect(parseByteRange('bytes=-', 1_000)).toEqual({ kind: 'invalid' }); + expect(parseByteRange('bytes=-0', 1_000)).toEqual({ kind: 'invalid' }); + expect(parseByteRange('bytes=1000-', 1_000)).toEqual({ kind: 'invalid' }); + expect(parseByteRange('bytes=200-100', 1_000)).toEqual({ kind: 'invalid' }); + }); +}); diff --git a/apps/web/src/range.ts b/apps/web/src/range.ts new file mode 100644 index 0000000..1d886b4 --- /dev/null +++ b/apps/web/src/range.ts @@ -0,0 +1,35 @@ +export type ParsedByteRange = + { kind: 'none' } | { kind: 'invalid' } | { kind: 'range'; start: number; end: number }; + +export const parseByteRange = (header: string | undefined, size: number): ParsedByteRange => { + if (header === undefined) return { kind: 'none' }; + + const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim()); + if (match === null) return { kind: 'none' }; + + const first = match[1] ?? ''; + const last = match[2] ?? ''; + if (size <= 0 || (first === '' && last === '')) return { kind: 'invalid' }; + + if (first === '') { + const suffixLength = Number(last); + if (!Number.isSafeInteger(suffixLength) || suffixLength <= 0) return { kind: 'invalid' }; + + return { + kind: 'range', + start: Math.max(0, size - suffixLength), + end: size - 1, + }; + } + + const start = Number(first); + const requestedEnd = last === '' ? size - 1 : Number(last); + if (!Number.isSafeInteger(start) || !Number.isSafeInteger(requestedEnd)) { + return { kind: 'invalid' }; + } + + const end = Math.min(requestedEnd, size - 1); + if (start > end || start >= size) return { kind: 'invalid' }; + + return { kind: 'range', start, end }; +};