diff --git a/src/routes/pages.ts b/src/routes/pages.ts index ac1a1b9..5e71162 100644 --- a/src/routes/pages.ts +++ b/src/routes/pages.ts @@ -1,6 +1,6 @@ import type { FastifyPluginAsync } from 'fastify'; import { extname } from 'path'; -import { readFile } from 'fs/promises'; +import { createReadStream } from 'fs'; import { nanoid } from 'nanoid'; import type Database from 'better-sqlite3'; import type { Config } from '../config.ts'; @@ -23,6 +23,29 @@ interface Deps { 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 }) => { 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)); }); - // 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) => { const { id } = request.params; 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 data = await readFile(filePath); const safeFilename = file.original_name.replace(/["\\\r\n]/g, '_'); + reply .header('Content-Type', file.mime_type) .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 diff --git a/tests/integration/pages.test.ts b/tests/integration/pages.test.ts index 322d60b..686a8b5 100644 --- a/tests/integration/pages.test.ts +++ b/tests/integration/pages.test.ts @@ -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` }); expect(res.statusCode).toBe(200); 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 () => {