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()); }); };