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:
arsam
2026-09-18 23:20:08 +03:30
parent 286439cf2c
commit 504dab2394
+122
View File
@@ -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) })),
};
});
}