Add custom 404 pages and error report email to admins
- NotFoundPage component with Go Back, Go Home (role-aware), and Report to Admin buttons - Catch-all routes inside AppLayout, VolunteerLayout, and top-level PublicLayout - POST /api/public/error-report endpoint sends 404 notification emails to super admins - Express API 404 handler returns consistent JSON error envelope for /api/* routes - Fastify media API 404 handler via setNotFoundHandler - Rate-limited error reports (5/hour per IP) Bunker Admin
This commit is contained in:
@@ -153,6 +153,11 @@ const start = async () => {
|
||||
await fastify.register(photosPublicRoutes, { prefix: '/api' });
|
||||
await fastify.register(photoEngagementRoutes, { prefix: '/api' });
|
||||
|
||||
// 404 handler for unmatched routes
|
||||
fastify.setNotFoundHandler((_request, reply) => {
|
||||
reply.status(404).send({ error: { message: 'Route not found', code: 'NOT_FOUND' } });
|
||||
});
|
||||
|
||||
const port = env.MEDIA_API_PORT;
|
||||
const host = '0.0.0.0';
|
||||
|
||||
|
||||
@@ -360,6 +360,23 @@ export const eventSubmissionRateLimit = rateLimit({
|
||||
},
|
||||
});
|
||||
|
||||
export const errorReportRateLimit = rateLimit({
|
||||
windowMs: 60 * 60 * 1000, // 1 hour
|
||||
max: 5,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
store: new RedisStore({
|
||||
sendCommand: (command: string, ...args: string[]) => redis.call(command, ...args) as Promise<any>,
|
||||
prefix: 'rl:error-report:',
|
||||
}),
|
||||
message: {
|
||||
error: {
|
||||
message: 'Too many error reports, please try again later',
|
||||
code: 'ERROR_REPORT_RATE_LIMIT_EXCEEDED',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const healthMetricsRateLimit = rateLimit({
|
||||
windowMs: 60 * 1000, // 1 minute
|
||||
max: 30, // 30 requests per minute
|
||||
|
||||
83
api/src/modules/reports/error-report.routes.ts
Normal file
83
api/src/modules/reports/error-report.routes.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { UserRole } from '@prisma/client';
|
||||
import { errorReportRateLimit } from '../../middleware/rate-limit';
|
||||
import { getAdminEmailsByRole } from '../../services/notification.helper';
|
||||
import { emailService } from '../../services/email.service';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
const errorReportSchema = z.object({
|
||||
url: z.string().min(1).max(2000),
|
||||
message: z.string().max(500).optional(),
|
||||
userAgent: z.string().max(500).optional(),
|
||||
});
|
||||
|
||||
export const errorReportRouter = Router();
|
||||
|
||||
errorReportRouter.post('/', errorReportRateLimit, async (req, res) => {
|
||||
try {
|
||||
const parsed = errorReportSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: { message: 'Invalid input', code: 'VALIDATION_ERROR' } });
|
||||
return;
|
||||
}
|
||||
|
||||
const { url, message: userMessage, userAgent } = parsed.data;
|
||||
|
||||
const adminEmails = await getAdminEmailsByRole([UserRole.SUPER_ADMIN]);
|
||||
if (adminEmails.length === 0) {
|
||||
logger.warn('No super admin emails found for 404 error report');
|
||||
res.json({ success: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
const ip = req.ip || req.socket.remoteAddress || 'unknown';
|
||||
|
||||
const html = `
|
||||
<h2>404 Error Report</h2>
|
||||
<table style="border-collapse:collapse; font-family:sans-serif;">
|
||||
<tr><td style="padding:4px 12px 4px 0; font-weight:bold;">URL:</td><td>${escapeHtml(url)}</td></tr>
|
||||
${userMessage ? `<tr><td style="padding:4px 12px 4px 0; font-weight:bold;">Message:</td><td>${escapeHtml(userMessage)}</td></tr>` : ''}
|
||||
<tr><td style="padding:4px 12px 4px 0; font-weight:bold;">IP:</td><td>${escapeHtml(ip)}</td></tr>
|
||||
${userAgent ? `<tr><td style="padding:4px 12px 4px 0; font-weight:bold;">User Agent:</td><td>${escapeHtml(userAgent)}</td></tr>` : ''}
|
||||
<tr><td style="padding:4px 12px 4px 0; font-weight:bold;">Timestamp:</td><td>${timestamp}</td></tr>
|
||||
</table>
|
||||
`;
|
||||
|
||||
const text = [
|
||||
'404 Error Report',
|
||||
`URL: ${url}`,
|
||||
userMessage ? `Message: ${userMessage}` : '',
|
||||
`IP: ${ip}`,
|
||||
userAgent ? `User Agent: ${userAgent}` : '',
|
||||
`Timestamp: ${timestamp}`,
|
||||
].filter(Boolean).join('\n');
|
||||
|
||||
// Send to each admin (fire-and-forget per recipient)
|
||||
for (const email of adminEmails) {
|
||||
emailService.sendEmail({
|
||||
to: email,
|
||||
subject: `[404 Report] Page not found: ${url.slice(0, 100)}`,
|
||||
html,
|
||||
text,
|
||||
}).catch((err) => {
|
||||
logger.error('Failed to send 404 report email', { to: email, error: err instanceof Error ? err.message : String(err) });
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
logger.error('Error report submission failed', { error: err instanceof Error ? err.message : String(err) });
|
||||
res.status(500).json({ error: { message: 'Internal server error', code: 'INTERNAL_ERROR' } });
|
||||
}
|
||||
});
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
@@ -100,6 +100,7 @@ import { eventsListPublicRouter } from './modules/events/events-public.routes';
|
||||
import { homepageRouter } from './modules/homepage/homepage.routes';
|
||||
import { ogRouter } from './modules/og/og.routes';
|
||||
import { socialRouter } from './modules/social/social.routes';
|
||||
import { errorReportRouter } from './modules/reports/error-report.routes';
|
||||
import { sseService } from './modules/social/sse.service';
|
||||
import { presenceService } from './modules/social/presence.service';
|
||||
|
||||
@@ -258,6 +259,12 @@ app.use('/api/events', eventsListPublicRouter); // Public event
|
||||
app.use('/api/homepage', homepageRouter); // Public homepage aggregation (no auth, cached)
|
||||
app.use('/api/og', ogRouter); // OG meta tags for social sharing bots (no auth, cached)
|
||||
app.use('/api/social', socialRouter); // Social connections (auth required)
|
||||
app.use('/api/public/error-report', errorReportRouter); // Public 404 error reporting (rate-limited)
|
||||
|
||||
// --- API 404 Handler (catch unmatched /api/* routes) ---
|
||||
app.use('/api/*', (_req, res) => {
|
||||
res.status(404).json({ error: { message: 'Route not found', code: 'NOT_FOUND' } });
|
||||
});
|
||||
|
||||
// --- Error Handler (must be last) ---
|
||||
app.use(errorHandler);
|
||||
|
||||
Reference in New Issue
Block a user