Compare commits
10 Commits
00ab308280
...
chore/depe
| Author | SHA1 | Date | |
|---|---|---|---|
| f27ba4922a | |||
| 4e89e9783c | |||
| 455cb53aa8 | |||
| 418a553429 | |||
| e25c715fb7 | |||
| 9fefe5c65d | |||
| e2944fd828 | |||
| a4e6a5784a | |||
| 241078316c | |||
| 85b6f8df2c |
3
.github/workflows/deploy-homelab.yml
vendored
3
.github/workflows/deploy-homelab.yml
vendored
@@ -39,6 +39,9 @@ jobs:
|
||||
export TRUST_PROXY=true
|
||||
export COOKIE_SECURE=true
|
||||
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
3
.gitignore
vendored
@@ -141,3 +141,6 @@ dist
|
||||
# Project
|
||||
data/
|
||||
dist/
|
||||
|
||||
# Claude Code (personal, machine-specific)
|
||||
.claude/settings.local.json
|
||||
|
||||
13
CLAUDE.md
13
CLAUDE.md
@@ -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.
|
||||
|
||||
@@ -15,7 +15,7 @@ services:
|
||||
nanodrop:
|
||||
build: .
|
||||
ports:
|
||||
- "${PORT:-3000}:3000"
|
||||
- "127.0.0.1:${PORT:-3000}:${PORT:-3000}"
|
||||
environment: { <<: *env }
|
||||
volumes:
|
||||
- nanodrop-data:/app/data
|
||||
|
||||
56
package-lock.json
generated
56
package-lock.json
generated
@@ -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": {
|
||||
|
||||
@@ -10,7 +10,7 @@ export interface FileRow {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CreateFileParams {
|
||||
interface CreateFileParams {
|
||||
id: string;
|
||||
userId: number;
|
||||
originalName: string;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user