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 type { Logger } from '../../../middleware/logging.ts';
|
||||||
import { getUserByUsername } from '../../../db/users.ts';
|
import { getUserByUsername } from '../../../db/users.ts';
|
||||||
import { verifyPassword } from '../../../services/auth.ts';
|
import { verifyPassword } from '../../../services/auth.ts';
|
||||||
import { requireAuth } from '../../../middleware/auth.ts';
|
import { requireAuth, tokenCookieOptions } from '../../../middleware/auth.ts';
|
||||||
|
|
||||||
interface Deps {
|
interface Deps {
|
||||||
db: Database.Database;
|
db: Database.Database;
|
||||||
@@ -40,14 +40,7 @@ export const authApiRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { d
|
|||||||
await logger.authSuccess({ ip, userAgent, username });
|
await logger.authSuccess({ ip, userAgent, username });
|
||||||
|
|
||||||
const token = app.jwt.sign({ sub: user.id, username: user.username }, { expiresIn: config.jwtExpiry });
|
const token = app.jwt.sign({ sub: user.id, username: user.username }, { expiresIn: config.jwtExpiry });
|
||||||
reply
|
reply.setCookie('token', token, tokenCookieOptions(config.cookieSecure)).send({ ok: true });
|
||||||
.setCookie('token', token, {
|
|
||||||
httpOnly: true,
|
|
||||||
sameSite: 'strict',
|
|
||||||
secure: config.cookieSecure,
|
|
||||||
path: '/',
|
|
||||||
})
|
|
||||||
.send({ ok: true });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/logout', { preHandler: requireAuth }, async (_request, reply) => {
|
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 fileBuffer = await data.toBuffer();
|
||||||
const id = nanoid();
|
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}`;
|
const storedName = `${id}${ext}`;
|
||||||
|
|
||||||
await saveFile(config.uploadDir, storedName, fileBuffer);
|
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 { createFile, getFileById, getFilesByUserId, deleteFile } from '../db/files.ts';
|
||||||
import { verifyPassword } from '../services/auth.ts';
|
import { verifyPassword } from '../services/auth.ts';
|
||||||
import { saveFile, deleteStoredFile, getFilePath } from '../services/storage.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 { loginPage } from '../views/login.ts';
|
||||||
import { uploadPage, uploadResultPage } from '../views/upload.ts';
|
import { uploadPage, uploadResultPage } from '../views/upload.ts';
|
||||||
import { fileListPage } from '../views/file-list.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 });
|
await logger.authSuccess({ ip, userAgent, username });
|
||||||
|
|
||||||
const token = app.jwt.sign({ sub: user.id, username: user.username }, { expiresIn: config.jwtExpiry });
|
const token = app.jwt.sign({ sub: user.id, username: user.username }, { expiresIn: config.jwtExpiry });
|
||||||
reply
|
reply.setCookie('token', token, tokenCookieOptions(config.cookieSecure)).redirect('/upload');
|
||||||
.setCookie('token', token, {
|
|
||||||
httpOnly: true,
|
|
||||||
sameSite: 'strict',
|
|
||||||
secure: config.cookieSecure,
|
|
||||||
path: '/',
|
|
||||||
})
|
|
||||||
.redirect('/upload');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /logout
|
// POST /logout
|
||||||
@@ -84,7 +77,8 @@ export const pageRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps
|
|||||||
|
|
||||||
const fileBuffer = await data.toBuffer();
|
const fileBuffer = await data.toBuffer();
|
||||||
const id = nanoid();
|
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}`;
|
const storedName = `${id}${ext}`;
|
||||||
|
|
||||||
await saveFile(config.uploadDir, storedName, fileBuffer);
|
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) => {
|
app.get('/files', { preHandler: requireAuth }, async (request, reply) => {
|
||||||
const { sub: userId } = request.user as JwtPayload;
|
const { sub: userId } = request.user as JwtPayload;
|
||||||
const files = getFilesByUserId(db, userId);
|
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
|
// 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 filePath = getFilePath(config.uploadDir, file.stored_name);
|
||||||
const data = await readFile(filePath);
|
const data = await readFile(filePath);
|
||||||
|
const safeFilename = file.original_name.replace(/["\\\r\n]/g, '_');
|
||||||
reply
|
reply
|
||||||
.header('Content-Type', file.mime_type)
|
.header('Content-Type', file.mime_type)
|
||||||
.header('Content-Disposition', `inline; filename="${file.original_name}"`)
|
.header('Content-Disposition', `inline; filename="${safeFilename}"`)
|
||||||
.send(data);
|
.send(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { layout, escHtml } from './layout.ts';
|
import { layout, escHtml } from './layout.ts';
|
||||||
import type { FileRow } from '../db/files.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
|
const rows = files.length === 0
|
||||||
? '<tr><td colspan="5">No files yet. <a href="/upload">Upload one.</a></td></tr>'
|
? '<tr><td colspan="5">No files yet. <a href="/upload">Upload one.</a></td></tr>'
|
||||||
: files.map((f) => `
|
: files.map((f) => `
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"module": "NodeNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "NodeNext",
|
"moduleResolution": "Bundler",
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user