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:
@@ -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) => `
|
||||
|
||||
Reference in New Issue
Block a user