All checks were successful
Deploy to Homelab / deploy (push) Successful in 18s
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.
235 lines
7.4 KiB
TypeScript
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());
|
|
});
|
|
};
|