diff --git a/app/backend/src/routes/errors.ts b/app/backend/src/routes/errors.ts new file mode 100644 index 0000000..03c961f --- /dev/null +++ b/app/backend/src/routes/errors.ts @@ -0,0 +1,122 @@ +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import { z } from 'zod'; +import { prisma } from '../db.js'; +import { redis } from '../redis.js'; +import { requireAuth } from '../plugins/session.js'; +import { checkRateLimit, rateLimitResponse } from '../lib/rate-limit.js'; +import { logSecurityEvent } from '../lib/security-audit.js'; + +const errorSchema = z.object({ + message: z.string().trim().min(1).max(1000), + source: z.string().trim().max(500).optional(), + stack: z.string().trim().max(5000).optional(), + path: z.string().trim().max(500).optional(), + userAgent: z.string().trim().max(500).optional(), +}); + +const cspReportSchema = z.object({ + 'csp-report': z.object({ + 'document-uri': z.string().max(500).optional(), + 'violated-directive': z.string().max(200).optional(), + 'blocked-uri': z.string().max(500).optional(), + 'source-file': z.string().max(500).optional(), + 'line-number': z.number().optional(), + 'column-number': z.number().optional(), + }).passthrough(), +}).passthrough(); + +function badInput(reply: FastifyReply) { + return reply.code(400).send({ error: 'invalid_input' }); +} + +export default async function errorRoutes(app: FastifyInstance) { + app.post('/api/v1/errors', async (req: FastifyRequest, reply) => { + const rl = await checkRateLimit(req.ip, 'errors', 30, 60); + if (rl.limited) return rateLimitResponse(reply, rl.retryAfter); + + const parsed = errorSchema.safeParse(req.body); + if (!parsed.success) return badInput(reply); + const body = parsed.data; + req.log.error({ clientError: body }, 'client_error_reported'); + await prisma.clientError.create({ + data: { + message: body.message, + stack: body.stack ?? null, + url: body.path ?? body.source ?? null, + userAgent: body.userAgent ?? null, + ...(req.session?.userId ? { userId: req.session.userId } : {}), + }, + }).catch(() => undefined); + return reply.code(202).send({ ok: true }); + }); + + app.post('/api/v1/csp-report', async (req: FastifyRequest, reply) => { + const parsed = cspReportSchema.safeParse(req.body); + if (!parsed.success) return reply.code(400).send({ ok: false }); + + const report = parsed.data['csp-report']; + await logSecurityEvent({ + type: 'suspicious_activity', + userId: req.session?.userId ?? null, + ip: req.ip, + userAgent: req.headers['user-agent'], + metadata: { + cspViolation: true, + violatedDirective: report['violated-directive'], + blockedUri: report['blocked-uri'], + documentUri: report['document-uri'], + sourceFile: report['source-file'], + }, + }); + + req.log.warn({ cspReport: report }, 'csp_violation_reported'); + return reply.code(204).send(); + }); + + app.get('/api/v1/admin/security/events', { preHandler: requireAuth(['admin']) }, async (req, reply) => { + const { getRecentSecurityEvents } = await import('../lib/security-audit.js'); + const query = z.object({ type: z.string().trim().max(50).optional(), userId: z.string().trim().max(100).optional() }).safeParse(req.query); + if (!query.success) return badInput(reply); + const events = await getRecentSecurityEvents(query.data.type, query.data.userId, 100); + return { events }; + }); + + app.post('/api/v1/admin/errors/digest', { preHandler: requireAuth(['admin']) }, async (_req, reply) => { + const { sendErrorDigest } = await import('../lib/error-digest.js'); + const result = await sendErrorDigest(); + return reply.send(result); + }); + + app.get('/api/v1/admin/errors', { preHandler: requireAuth(['admin']) }, async (_req, _reply) => { + const since = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + + const [topErrors, recent, dailyCounts] = await Promise.all([ + prisma.$queryRaw>` + SELECT message, COUNT(*) AS count, MAX("createdAt") AS "lastSeen" + FROM "ClientError" + WHERE "createdAt" >= ${since} + GROUP BY message + ORDER BY count DESC + LIMIT 10 + `, + prisma.clientError.findMany({ + orderBy: { createdAt: 'desc' }, + take: 20, + select: { id: true, message: true, url: true, userAgent: true, userId: true, createdAt: true }, + }), + prisma.$queryRaw>` + SELECT DATE("createdAt") AS day, COUNT(*) AS count + FROM "ClientError" + WHERE "createdAt" >= ${since} + GROUP BY day + ORDER BY day ASC + `, + ]); + + return { + topErrors: topErrors.map((r) => ({ message: r.message, count: Number(r.count), lastSeen: r.lastSeen })), + recent, + dailyCounts: dailyCounts.map((r) => ({ day: r.day, count: Number(r.count) })), + }; + }); +}