+
+ } onClick={() => navigate(-1)}>
+ Go Back
+
+ } onClick={() => navigate(homePath)}>
+ Go Home
+
+ }
+ onClick={handleReport}
+ loading={reporting}
+ >
+ Report to Admin
+
+
+ {showInput && (
+
+ setReportMessage(e.target.value)}
+ rows={3}
+ maxLength={500}
+ showCount
+ style={{ marginBottom: 8 }}
+ />
+
+
+ )}
+
+ }
+ />
+ );
+}
diff --git a/api/src/media-server.ts b/api/src/media-server.ts
index de16fd54..12549117 100644
--- a/api/src/media-server.ts
+++ b/api/src/media-server.ts
@@ -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';
diff --git a/api/src/middleware/rate-limit.ts b/api/src/middleware/rate-limit.ts
index 47aef6c4..9ca8aa2c 100644
--- a/api/src/middleware/rate-limit.ts
+++ b/api/src/middleware/rate-limit.ts
@@ -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,
+ 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
diff --git a/api/src/modules/reports/error-report.routes.ts b/api/src/modules/reports/error-report.routes.ts
new file mode 100644
index 00000000..7c93f99f
--- /dev/null
+++ b/api/src/modules/reports/error-report.routes.ts
@@ -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 = `
+ 404 Error Report
+
+ | URL: | ${escapeHtml(url)} |
+ ${userMessage ? `| Message: | ${escapeHtml(userMessage)} |
` : ''}
+ | IP: | ${escapeHtml(ip)} |
+ ${userAgent ? `| User Agent: | ${escapeHtml(userAgent)} |
` : ''}
+ | Timestamp: | ${timestamp} |
+
+ `;
+
+ 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, ''');
+}
diff --git a/api/src/server.ts b/api/src/server.ts
index dcd69cca..25a1584a 100644
--- a/api/src/server.ts
+++ b/api/src/server.ts
@@ -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);