Files
nanodrop/src/routes/pages.ts
Brendan Chen 86870db726 feat(auth): family-wide session constants + mint primitive + auth factory
Adds src/constants.ts exporting the family-wide session policy
(SESSION_TTL_DAYS=30, SESSION_TTL_SECONDS=2_592_000,
SESSION_RENEW_THRESHOLD_SECONDS=3600, LOGOUT_PATHS) so every
bchen.dev app shares the same persistence window.

Introduces issueSessionCookie as the single mint site for
fastify-jwt sign + setCookie, replacing inlined jwt.sign +
setCookie calls in pages.ts and api/v1/auth.ts. The cookie now
carries Max-Age=SESSION_TTL_SECONDS so it persists across browser
restarts.

Converts requireAuth into a makeRequireAuth(config) factory; route
plugins build their own preHandler at registration time. Threads
through pages.ts, api/v1/auth.ts, and api/v1/files.ts.

SESSION_COOKIE_NAME stays 'token' in this commit so existing tests
remain green; the rename to 'nanodrop_session' lands in a follow-up.
JWT_EXPIRY env var is still read; its removal also lands in a
follow-up so each commit builds cleanly.
2026-05-09 10:10:47 -07:00

241 lines
7.6 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 { makeRequireAuth, issueSessionCookie } from '../middleware/auth.ts';
import { SESSION_COOKIE_NAME } from '../constants.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 requireAuth = makeRequireAuth(config);
const loginRateLimit = {
rateLimit: {
max: config.loginRateLimitMax,
timeWindow: config.loginRateLimitWindowSeconds * 1000,
},
};
// GET / — login page or redirect if authed
// opportunistic auth — does not slide the session; see middleware/session-renewal.ts
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' }));
}
issueSessionCookie(
reply,
app,
{ sub: result.user.id, username: result.user.username },
config.cookieSecure,
);
reply.redirect('/upload');
},
);
// POST /logout
app.post('/logout', { preHandler: requireAuth }, async (_request, reply) => {
reply.clearCookie(SESSION_COOKIE_NAME, { 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)
// opportunistic auth — does not slide the session; see middleware/session-renewal.ts
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());
});
};