Files
nanodrop/src/routes/pages.ts
Brendan Chen bbd292c085
All checks were successful
Deploy to Homelab / deploy (push) Successful in 18s
feat(auth): wire lockout, rate-limit, and constant-time login into both routes
Both POST /login (HTML form) and POST /api/v1/auth/login now flow through
the shared attemptLogin() handler. Locked accounts respond with 401 +
Retry-After (generic body "Invalid credentials" / "Invalid username or
password") so attackers can't use lockout state as a username-existence
oracle.

@fastify/rate-limit registered with global=false; only the two login
routes opt in via per-route rateLimit config. File uploads and downloads
keep full throughput. Custom errorResponseBuilder logs AUTH_RATE_LIMITED
fire-and-forget so fail2ban can pick it up.

createTestApp now accepts Partial<Config> overrides so integration tests
can dial thresholds down without env-var mutation.
2026-05-03 03:41:51 -07:00

235 lines
7.4 KiB
TypeScript

import type { FastifyPluginAsync } from 'fastify';
import { extname } from 'path';
import { createReadStream } from 'fs';
import { nanoid } from 'nanoid';
import type Database from 'better-sqlite3';
import type { Config } from '../config.ts';
import type { Logger } from '../middleware/logging.ts';
import type { JwtPayload } from '../types.ts';
import type { LockoutService } from '../services/lockout.ts';
import { createFile, getFileById, getFilesByUserId, deleteFile } from '../db/files.ts';
import { saveFile, deleteStoredFile, getFilePath } from '../services/storage.ts';
import { attemptLogin } from '../services/login-handler.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';
import { fileViewPage } from '../views/file-view.ts';
import { notFoundPage } from '../views/not-found.ts';
interface Deps {
db: Database.Database;
config: Config;
logger: Logger;
lockout: LockoutService;
}
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;
const loginRateLimit = {
rateLimit: {
max: config.loginRateLimitMax,
timeWindow: config.loginRateLimitWindowSeconds * 1000,
},
};
// GET / — login page or redirect if authed
app.get('/', async (request, reply) => {
try {
await request.jwtVerify();
return reply.redirect('/upload');
} catch {
return reply.type('text/html').send(loginPage());
}
});
// POST /login — form login
app.post<{ Body: { username?: string; password?: string } }>(
'/login',
{ config: loginRateLimit },
async (request, reply) => {
const { username = '', password = '' } = request.body ?? {};
const result = await attemptLogin(deps, {
username,
password,
ip: request.ip,
userAgent: request.headers['user-agent'] ?? '',
});
if (result.kind === 'locked') {
return reply
.type('text/html')
.header('Retry-After', String(result.retryAfterSeconds))
.send(loginPage({ error: 'Invalid username or password' }));
}
if (result.kind !== 'success') {
return reply.type('text/html').send(loginPage({ error: 'Invalid username or password' }));
}
const token = app.jwt.sign(
{ sub: result.user.id, username: result.user.username },
{ expiresIn: config.jwtExpiry },
);
reply.setCookie('token', token, tokenCookieOptions(config.cookieSecure)).redirect('/upload');
},
);
// POST /logout
app.post('/logout', { preHandler: requireAuth }, async (_request, reply) => {
reply.clearCookie('token', { path: '/' }).redirect('/');
});
// GET /upload
app.get('/upload', { preHandler: requireAuth }, async (_request, reply) => {
reply.type('text/html').send(uploadPage());
});
// POST /upload
app.post('/upload', { preHandler: requireAuth }, async (request, reply) => {
const { sub: userId } = request.user as JwtPayload;
const data = await request.file();
if (!data) {
return reply.type('text/html').send(uploadPage({ error: 'No file selected' }));
}
const fileBuffer = await data.toBuffer();
const id = nanoid();
const rawExt = extname(data.filename);
const ext = /^\.[a-zA-Z0-9]+$/.test(rawExt) ? rawExt : '';
const storedName = `${id}${ext}`;
await saveFile(config.uploadDir, storedName, fileBuffer);
createFile(db, {
id,
userId,
originalName: data.filename,
mimeType: data.mimetype,
size: fileBuffer.length,
storedName,
});
const shareUrl = `${config.baseUrl}/f/${id}`;
reply.type('text/html').send(uploadResultPage(shareUrl, data.filename));
});
// GET /files
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));
});
// POST /files/:id/delete
app.post<{ Params: { id: string } }>('/files/:id/delete', { preHandler: requireAuth }, async (request, reply) => {
const { sub: userId } = request.user as JwtPayload;
const { id } = request.params;
const file = getFileById(db, id);
if (file) {
const deleted = deleteFile(db, id, userId);
if (deleted) {
await deleteStoredFile(config.uploadDir, file.stored_name);
}
}
reply.redirect('/files');
});
// GET /f/:id — public file view (owner-aware)
app.get<{ Params: { id: string } }>('/f/:id', async (request, reply) => {
const { id } = request.params;
let userId: number | null = null;
try {
await request.jwtVerify();
userId = (request.user as JwtPayload).sub;
} catch { /* not logged in — fine */ }
const file = getFileById(db, id);
if (!file) {
await logger.fileNotFound({ ip: request.ip, userAgent: request.headers['user-agent'] ?? '', fileId: id });
return reply.status(404).type('text/html').send(notFoundPage());
}
const isOwner = userId !== null && userId === file.user_id;
reply.type('text/html').send(fileViewPage(file, isOwner));
});
// 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);
if (!file) {
await logger.fileNotFound({ ip: request.ip, userAgent: request.headers['user-agent'] ?? '', fileId: id });
return reply.status(404).send({ error: 'Not found' });
}
const filePath = getFilePath(config.uploadDir, file.stored_name);
const safeFilename = file.original_name.replace(/["\\\r\n]/g, '_');
reply
.header('Content-Type', file.mime_type)
.header('Content-Disposition', `inline; filename="${safeFilename}"`)
.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
app.setNotFoundHandler((_request, reply) => {
reply.status(404).type('text/html').send(notFoundPage());
});
};