Code review fixes, Docker, and deployment config

- Fix tsconfig: switch to ESNext/Bundler module resolution (tsx compatible)
- Sanitize file extensions against path traversal (^.[a-zA-Z0-9]+$ only)
- Sanitize Content-Disposition filename to prevent header injection
- Extract tokenCookieOptions helper to eliminate duplication across auth handlers
- Remove unused baseUrl param from fileListPage
- Add Dockerfile (multi-stage build with alpine + native tools for bcrypt)
- Add docker-compose.yml with named volume for data persistence
- Add .env.example with all environment variables documented

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 15:58:39 -08:00
parent 8fd1464b9d
commit 6d8fb9105d
9 changed files with 81 additions and 25 deletions

11
.env.example Normal file
View File

@@ -0,0 +1,11 @@
PORT=3000
HOST=0.0.0.0
JWT_SECRET=change-me-to-a-long-random-secret
JWT_EXPIRY=7d
DB_PATH=./data/nanodrop.db
UPLOAD_DIR=./data/uploads
LOG_FILE=./data/nanodrop.log
MAX_FILE_SIZE=104857600
BASE_URL=http://localhost:3000
COOKIE_SECURE=false
TRUST_PROXY=false

34
Dockerfile Normal file
View File

@@ -0,0 +1,34 @@
# Build stage
FROM node:22-alpine AS build
# Install native build tools for bcrypt
RUN apk add --no-cache python3 make g++
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
# Runtime stage
FROM node:22-alpine AS runtime
RUN apk add --no-cache python3 make g++
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
COPY public ./public
RUN mkdir -p /app/data/uploads
EXPOSE 3000
CMD ["node", "dist/index.js"]

13
docker-compose.yml Normal file
View File

@@ -0,0 +1,13 @@
services:
nanodrop:
build: .
ports:
- "3000:3000"
env_file:
- .env
volumes:
- nanodrop-data:/app/data
restart: unless-stopped
volumes:
nanodrop-data:

View File

@@ -13,3 +13,12 @@ export async function requireAuth(request: FastifyRequest, reply: FastifyReply):
}
}
}
export function tokenCookieOptions(secure: boolean): {
httpOnly: boolean;
sameSite: 'strict';
secure: boolean;
path: string;
} {
return { httpOnly: true, sameSite: 'strict', secure, path: '/' };
}

View File

@@ -4,7 +4,7 @@ import type { Config } from '../../../config.ts';
import type { Logger } from '../../../middleware/logging.ts';
import { getUserByUsername } from '../../../db/users.ts';
import { verifyPassword } from '../../../services/auth.ts';
import { requireAuth } from '../../../middleware/auth.ts';
import { requireAuth, tokenCookieOptions } from '../../../middleware/auth.ts';
interface Deps {
db: Database.Database;
@@ -40,14 +40,7 @@ export const authApiRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { d
await logger.authSuccess({ ip, userAgent, username });
const token = app.jwt.sign({ sub: user.id, username: user.username }, { expiresIn: config.jwtExpiry });
reply
.setCookie('token', token, {
httpOnly: true,
sameSite: 'strict',
secure: config.cookieSecure,
path: '/',
})
.send({ ok: true });
reply.setCookie('token', token, tokenCookieOptions(config.cookieSecure)).send({ ok: true });
});
app.post('/logout', { preHandler: requireAuth }, async (_request, reply) => {

View File

@@ -34,7 +34,8 @@ export const filesApiRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, {
const fileBuffer = await data.toBuffer();
const id = nanoid();
const ext = extname(data.filename);
const rawExt = extname(data.filename);
const ext = /^\.[a-zA-Z0-9]+$/.test(rawExt) ? rawExt : '';
const storedName = `${id}${ext}`;
await saveFile(config.uploadDir, storedName, fileBuffer);

View File

@@ -10,7 +10,7 @@ import { getUserByUsername } from '../db/users.ts';
import { createFile, getFileById, getFilesByUserId, deleteFile } from '../db/files.ts';
import { verifyPassword } from '../services/auth.ts';
import { saveFile, deleteStoredFile, getFilePath } from '../services/storage.ts';
import { requireAuth } from '../middleware/auth.ts';
import { requireAuth, tokenCookieOptions } from '../middleware/auth.ts';
import { loginPage } from '../views/login.ts';
import { uploadPage, uploadResultPage } from '../views/upload.ts';
import { fileListPage } from '../views/file-list.ts';
@@ -53,14 +53,7 @@ export const pageRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps
await logger.authSuccess({ ip, userAgent, username });
const token = app.jwt.sign({ sub: user.id, username: user.username }, { expiresIn: config.jwtExpiry });
reply
.setCookie('token', token, {
httpOnly: true,
sameSite: 'strict',
secure: config.cookieSecure,
path: '/',
})
.redirect('/upload');
reply.setCookie('token', token, tokenCookieOptions(config.cookieSecure)).redirect('/upload');
});
// POST /logout
@@ -84,7 +77,8 @@ export const pageRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps
const fileBuffer = await data.toBuffer();
const id = nanoid();
const ext = extname(data.filename);
const rawExt = extname(data.filename);
const ext = /^\.[a-zA-Z0-9]+$/.test(rawExt) ? rawExt : '';
const storedName = `${id}${ext}`;
await saveFile(config.uploadDir, storedName, fileBuffer);
@@ -106,7 +100,7 @@ export const pageRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps
app.get('/files', { preHandler: requireAuth }, async (request, reply) => {
const { sub: userId } = request.user as JwtPayload;
const files = getFilesByUserId(db, userId);
reply.type('text/html').send(fileListPage(files, config.baseUrl));
reply.type('text/html').send(fileListPage(files));
});
// POST /files/:id/delete
@@ -150,9 +144,10 @@ 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="${file.original_name}"`)
.header('Content-Disposition', `inline; filename="${safeFilename}"`)
.send(data);
});

View File

@@ -1,7 +1,7 @@
import { layout, escHtml } from './layout.ts';
import type { FileRow } from '../db/files.ts';
export function fileListPage(files: FileRow[], baseUrl: string): string {
export function fileListPage(files: FileRow[]): string {
const rows = files.length === 0
? '<tr><td colspan="5">No files yet. <a href="/upload">Upload one.</a></td></tr>'
: files.map((f) => `

View File

@@ -1,8 +1,8 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"outDir": "dist",
"rootDir": "src",
"strict": true,