Add HTTP range request support for video streaming

Safari and other browsers require Accept-Ranges: bytes and 206 Partial
Content responses to play video. Without this, large videos fail to load
(especially in Safari) because the entire file had to buffer in memory
before sending.

- Replace readFile + Buffer with createReadStream for efficient streaming
- Parse Range header (start-end, start-, and suffix -N forms)
- Return 206 Partial Content with Content-Range for range requests
- Return 416 Range Not Satisfiable for out-of-bounds ranges
- Add Accept-Ranges: bytes to all raw file responses

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-16 16:12:21 -07:00
parent a4e6a5784a
commit 9fefe5c65d
2 changed files with 97 additions and 4 deletions

View File

@@ -1,6 +1,6 @@
import type { FastifyPluginAsync } from 'fastify'; import type { FastifyPluginAsync } from 'fastify';
import { extname } from 'path'; import { extname } from 'path';
import { readFile } from 'fs/promises'; import { createReadStream } from 'fs';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import type Database from 'better-sqlite3'; import type Database from 'better-sqlite3';
import type { Config } from '../config.ts'; import type { Config } from '../config.ts';
@@ -23,6 +23,29 @@ interface Deps {
logger: Logger; logger: Logger;
} }
function parseRangeHeader(header: string, fileSize: number): { start: number; end: number } | null {
const match = header.match(/^bytes=(\d*)-(\d*)$/);
if (!match) return null;
const [, rawStart, rawEnd] = match;
let start: number;
let end: number;
if (rawStart === '') {
// Suffix range: bytes=-N (last N bytes)
const suffix = parseInt(rawEnd, 10);
start = Math.max(0, fileSize - suffix);
end = fileSize - 1;
} else {
start = parseInt(rawStart, 10);
end = rawEnd === '' ? fileSize - 1 : parseInt(rawEnd, 10);
}
if (start > end || end >= fileSize) return null;
return { start, end };
}
export const pageRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps }) => { export const pageRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps }) => {
const { db, config, logger } = deps; const { db, config, logger } = deps;
@@ -140,7 +163,7 @@ export const pageRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps
reply.type('text/html').send(fileViewPage(file, isOwner)); reply.type('text/html').send(fileViewPage(file, isOwner));
}); });
// GET /f/:id/raw — serve raw file // GET /f/:id/raw — serve raw file with range request support
app.get<{ Params: { id: string } }>('/f/:id/raw', async (request, reply) => { app.get<{ Params: { id: string } }>('/f/:id/raw', async (request, reply) => {
const { id } = request.params; const { id } = request.params;
const file = getFileById(db, id); const file = getFileById(db, id);
@@ -151,12 +174,37 @@ export const pageRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps
} }
const filePath = getFilePath(config.uploadDir, file.stored_name); const filePath = getFilePath(config.uploadDir, file.stored_name);
const data = await readFile(filePath);
const safeFilename = file.original_name.replace(/["\\\r\n]/g, '_'); const safeFilename = file.original_name.replace(/["\\\r\n]/g, '_');
reply reply
.header('Content-Type', file.mime_type) .header('Content-Type', file.mime_type)
.header('Content-Disposition', `inline; filename="${safeFilename}"`) .header('Content-Disposition', `inline; filename="${safeFilename}"`)
.send(data); .header('Accept-Ranges', 'bytes');
const rangeHeader = request.headers['range'];
if (!rangeHeader) {
reply.header('Content-Length', file.size);
return reply.send(createReadStream(filePath));
}
const range = parseRangeHeader(rangeHeader, file.size);
if (!range) {
return reply
.status(416)
.header('Content-Range', `bytes */${file.size}`)
.send();
}
const { start, end } = range;
const chunkSize = end - start + 1;
return reply
.status(206)
.header('Content-Range', `bytes ${start}-${end}/${file.size}`)
.header('Content-Length', chunkSize)
.send(createReadStream(filePath, { start, end }));
}); });
// 404 handler // 404 handler

View File

@@ -125,6 +125,51 @@ describe('GET /f/:id and GET /f/:id/raw', () => {
const res = await ctx.app.inject({ method: 'GET', url: `/f/${fileId}/raw` }); const res = await ctx.app.inject({ method: 'GET', url: `/f/${fileId}/raw` });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
expect(res.body).toBe('hello!'); expect(res.body).toBe('hello!');
expect(res.headers['accept-ranges']).toBe('bytes');
});
it('returns 206 for a byte range request', async () => {
const res = await ctx.app.inject({
method: 'GET',
url: `/f/${fileId}/raw`,
headers: { range: 'bytes=0-3' },
});
expect(res.statusCode).toBe(206);
expect(res.headers['content-range']).toBe('bytes 0-3/6');
expect(res.headers['content-length']).toBe('4');
expect(res.body).toBe('hell');
});
it('returns 206 for an open-ended range request', async () => {
const res = await ctx.app.inject({
method: 'GET',
url: `/f/${fileId}/raw`,
headers: { range: 'bytes=2-' },
});
expect(res.statusCode).toBe(206);
expect(res.headers['content-range']).toBe('bytes 2-5/6');
expect(res.body).toBe('llo!');
});
it('returns 206 for a suffix range request', async () => {
const res = await ctx.app.inject({
method: 'GET',
url: `/f/${fileId}/raw`,
headers: { range: 'bytes=-3' },
});
expect(res.statusCode).toBe(206);
expect(res.headers['content-range']).toBe('bytes 3-5/6');
expect(res.body).toBe('lo!');
});
it('returns 416 for an unsatisfiable range', async () => {
const res = await ctx.app.inject({
method: 'GET',
url: `/f/${fileId}/raw`,
headers: { range: 'bytes=100-200' },
});
expect(res.statusCode).toBe(416);
expect(res.headers['content-range']).toBe('bytes */6');
}); });
it('returns 404 for unknown file', async () => { it('returns 404 for unknown file', async () => {