8 Commits

Author SHA1 Message Date
f27ba4922a chore: patch dependency vulnerabilities via npm audit fix
Resolves 7 advisories (1 critical, 3 high, 3 moderate) without
package.json range changes:
- fast-jwt: algorithm confusion, cache key collision, ReDoS
- fastify: content-type validation bypass, host spoofing
- @fastify/static: path traversal & encoded-separator route bypass
- vite (dev only): WS file read, fs.deny bypass, .map traversal
- postcss/picomatch/brace-expansion (transitive): XSS, ReDoS, DoS

npm audit clean; 61 tests pass.
2026-05-03 03:06:47 -07:00
4e89e9783c chore: gitignore .claude/settings.local.json
All checks were successful
Deploy to Homelab / deploy (push) Successful in 1m16s
2026-05-03 02:52:37 -07:00
455cb53aa8 docs: authorize autonomous commits and pushes for this project
Standing authorization so any Claude Code instance commits work after
each logical change (build+tests green) and pushes to the upstream
branch, without waiting to be asked. Force-push and pushes to main from
a feature branch still require explicit approval.
2026-05-03 02:52:27 -07:00
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
10 changed files with 149 additions and 35 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

3
.gitignore vendored
View File

@@ -141,3 +141,6 @@ dist
# Project
data/
dist/
# Claude Code (personal, machine-specific)
.claude/settings.local.json

View File

@@ -26,3 +26,16 @@ Simple file-sharing platform. TypeScript + Fastify + SQLite.
- Break large functions into smaller ones, extract duplicate code.
- Search for duplicated code in tests and extract into reusable helpers.
- Commit after every logical set of changes. Keep commits small and focused.
## Autonomous Commits (Standing Authorization)
Any Claude Code instance working on this project has standing authorization to commit work without asking. Apply this proactively, not when reminded.
- After every logical set of changes that builds and tests cleanly (`npm run build && npm test`), create a commit immediately. Do not wait for the user to ask.
- Use Conventional Commits (`feat:`, `fix:`, `refactor:`, `docs:`, `test:`, `chore:`, `perf:`, `ci:`) with a concise subject. Add a body only when the *why* isn't obvious from the diff.
- One logical change per commit. If you touched multiple unrelated things, split into separate commits.
- After committing, also push to the tracked remote (`git push`) — standard `git push` to the current branch's upstream is authorized. **Never** force-push (`--force`, `--force-with-lease`) and **never** push to `main` from a feature branch directly without an explicit request.
- **Never** stage `.env`, `data/`, secrets, or anything matching a credential pattern. Prefer `git add <specific files>` over `git add -A`/`git add .`.
- If the build or tests fail, do **not** commit. Fix or revert, then either commit a working state or report the failure.
- Skip the commit if there are no changes (don't create empty commits).
- If you find yourself amending or rewriting prior commits, stop and ask first — autonomy covers new commits, not history rewrites.

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

56
package-lock.json generated
View File

@@ -729,9 +729,9 @@
}
},
"node_modules/@fastify/static": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/@fastify/static/-/static-9.0.0.tgz",
"integrity": "sha512-r64H8Woe/vfilg5RTy7lwWlE8ZZcTrc3kebYFMEUBrMqlydhQyoiExQXdYAy2REVpST/G35+stAM8WYp1WGmMA==",
"version": "9.1.3",
"resolved": "https://registry.npmjs.org/@fastify/static/-/static-9.1.3.tgz",
"integrity": "sha512-aXrYtsiryLhRxRNaxNqsn7FUISeb7rB9q4eHUPIot5aeQBLNahnz1m6thzm7JWC1poSGXS9XrX8DvuMivp2hkQ==",
"funding": [
{
"type": "github",
@@ -1471,9 +1471,9 @@
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
@@ -1738,15 +1738,16 @@
}
},
"node_modules/fast-jwt": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/fast-jwt/-/fast-jwt-6.1.0.tgz",
"integrity": "sha512-cGK/TXlud8INL49Iv7yRtZy0PHzNJId1shfqNCqdF0gOlWiy+1FPgjxX+ZHp/CYxFYDaoNnxeYEGzcXSkahUEQ==",
"version": "6.2.4",
"resolved": "https://registry.npmjs.org/fast-jwt/-/fast-jwt-6.2.4.tgz",
"integrity": "sha512-IoQa53wI6TbARU2yelb0L44ggFQnP2qVcwswCSYHbCAWuwpr70icDb3QjG0v01I8Tt01rVGDkN/rRvpk0lKFTA==",
"license": "Apache-2.0",
"dependencies": {
"@lukeed/ms": "^2.0.2",
"asn1.js": "^5.4.1",
"ecdsa-sig-formatter": "^1.0.11",
"mnemonist": "^0.40.0"
"mnemonist": "^0.40.0",
"safe-regex2": "^5.1.0"
},
"engines": {
"node": ">=20"
@@ -1790,9 +1791,9 @@
}
},
"node_modules/fastify": {
"version": "5.7.4",
"resolved": "https://registry.npmjs.org/fastify/-/fastify-5.7.4.tgz",
"integrity": "sha512-e6l5NsRdaEP8rdD8VR0ErJASeyaRbzXYpmkrpr2SuvuMq6Si3lvsaVy5C+7gLanEkvjpMDzBXWE5HPeb/hgTxA==",
"version": "5.8.5",
"resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.5.tgz",
"integrity": "sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==",
"funding": [
{
"type": "github",
@@ -1814,7 +1815,7 @@
"fast-json-stringify": "^6.0.0",
"find-my-way": "^9.0.0",
"light-my-request": "^6.0.0",
"pino": "^10.1.0",
"pino": "^9.14.0 || ^10.1.0",
"process-warning": "^5.0.0",
"rfdc": "^1.3.1",
"secure-json-parse": "^4.0.0",
@@ -2304,9 +2305,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2354,9 +2355,9 @@
"license": "MIT"
},
"node_modules/postcss": {
"version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
"version": "8.5.13",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz",
"integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==",
"dev": true,
"funding": [
{
@@ -2608,9 +2609,9 @@
"license": "MIT"
},
"node_modules/safe-regex2": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.0.0.tgz",
"integrity": "sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw==",
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz",
"integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==",
"funding": [
{
"type": "github",
@@ -2624,6 +2625,9 @@
"license": "MIT",
"dependencies": {
"ret": "~0.5.0"
},
"bin": {
"safe-regex2": "bin/safe-regex2.js"
}
},
"node_modules/safe-stable-stringify": {
@@ -2977,9 +2981,9 @@
"license": "MIT"
},
"node_modules/vite": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"version": "7.3.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
"dev": true,
"license": "MIT",
"dependencies": {

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