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:
11
.env.example
Normal file
11
.env.example
Normal 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
34
Dockerfile
Normal 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
13
docker-compose.yml
Normal 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:
|
||||
@@ -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: '/' };
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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) => `
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
|
||||
Reference in New Issue
Block a user