Import source: app/backend/src/routes/errors
Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
This commit is contained in:
@@ -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<Array<{ message: string; count: bigint; lastSeen: Date }>>`
|
||||||
|
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<Array<{ day: string; count: bigint }>>`
|
||||||
|
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) })),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user