Compare commits

...

5 Commits

Author SHA1 Message Date
418a553429 refactor: remove unused exported type interfaces
All checks were successful
Deploy to Homelab / deploy (push) Successful in 19s
CreateFileParams, UserRow, CreateUserParams, and MultipartFile were
exported but never imported outside their own modules. Narrowed visibility
to module-local to keep the public surface minimal.

Confirmed with knip (zero findings) and all 61 tests passing.
2026-03-17 10:44:23 -07:00
e25c715fb7 Merge branch 'main' of https://gitea.bchen.dev/brendan/nanodrop
All checks were successful
Deploy to Homelab / deploy (push) Successful in 48s
2026-03-16 16:18:03 -07:00
9fefe5c65d 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>
2026-03-16 16:12:21 -07:00
e2944fd828 Export max file size variable
All checks were successful
Deploy to Homelab / deploy (push) Successful in 19s
2026-03-05 20:39:45 +00:00
a4e6a5784a Ensure that app is not accessible via LAN
All checks were successful
Deploy to Homelab / deploy (push) Successful in 17s
2026-03-04 09:48:58 -08:00
7 changed files with 103 additions and 9 deletions

View File

@@ -41,6 +41,7 @@ jobs:
export JWT_SECRET=${{ secrets.JWT_SECRET }}
export PORT=${{ vars.PORT }}
export BASE_URL=${{ vars.BASE_URL }}
export MAX_FILE_SIZE=${{ vars.MAX_FILE_SIZE }}
docker compose -f docker-compose.yml down
docker compose -f docker-compose.yml up -d --build
EOF

View File

@@ -15,7 +15,7 @@ services:
nanodrop:
build: .
ports:
- "${PORT:-3000}:${PORT:-3000}"
- "127.0.0.1:${PORT:-3000}:${PORT:-3000}"
environment: { <<: *env }
volumes:
- nanodrop-data:/app/data

View File

@@ -10,7 +10,7 @@ export interface FileRow {
created_at: string;
}
export interface CreateFileParams {
interface CreateFileParams {
id: string;
userId: number;
originalName: string;

View File

@@ -1,13 +1,13 @@
import type Database from 'better-sqlite3';
export interface UserRow {
interface UserRow {
id: number;
username: string;
password_hash: string;
created_at: string;
}
export interface CreateUserParams {
interface CreateUserParams {
username: string;
passwordHash: string;
}

View File

@@ -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

View File

@@ -18,7 +18,7 @@ export async function loginAs(app: FastifyInstance, username: string, password:
return cookie.split(';')[0].replace('token=', '');
}
export interface MultipartFile {
interface MultipartFile {
filename: string;
contentType: string;
data: Buffer;

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` });
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 () => {