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:
bunker-admin 2026-02-28 09:04:09 -07:00
parent 18997da3eb
commit 98acd4917d
6 changed files with 211 additions and 1 deletions

View File

@ -116,6 +116,7 @@ import SocialGraphPage from '@/pages/social/SocialGraphPage';
import SocialModerationPage from '@/pages/social/SocialModerationPage';
import MeetingJoinPage from '@/pages/public/MeetingJoinPage';
import JitsiAuthPage from '@/pages/JitsiAuthPage';
import NotFoundPage from '@/pages/NotFoundPage';
import CommandPalette from '@/components/command-palette/CommandPalette';
function RoleAwareRedirect() {
@ -307,6 +308,7 @@ export default function App() {
<Route path="/volunteer/groups/:id" element={<GroupDetailPage />} />
<Route path="/volunteer/achievements" element={<AchievementsPage />} />
<Route path="/volunteer/chat" element={<VolunteerChatPage />} />
<Route path="/volunteer/*" element={<NotFoundPage />} />
</Route>
{/* Redirect old canvass routes to map with query param */}
@ -816,8 +818,12 @@ export default function App() {
</ProtectedRoute>
}
/>
<Route path="*" element={<NotFoundPage />} />
</Route>
<Route path="/" element={<RoleAwareRedirect />} />
<Route path="*" element={<PublicLayout />}>
<Route path="*" element={<NotFoundPage />} />
</Route>
<Route path="*" element={<RoleAwareRedirect />} />
</Routes>
</BrowserRouter>
</AntApp>

View File

@ -0,0 +1,92 @@
import { useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { Result, Button, Input, App, Space } from 'antd';
import { ArrowLeftOutlined, HomeOutlined, MailOutlined } from '@ant-design/icons';
import axios from 'axios';
import { useAuthStore } from '@/stores/auth.store';
import { isAdmin } from '@/utils/roles';
const API_URL = import.meta.env.VITE_API_URL || '';
export default function NotFoundPage() {
const navigate = useNavigate();
const location = useLocation();
const { message } = App.useApp();
const { user, isAuthenticated } = useAuthStore();
const [reporting, setReporting] = useState(false);
const [showInput, setShowInput] = useState(false);
const [reportMessage, setReportMessage] = useState('');
const homePath = !isAuthenticated
? '/home'
: user && isAdmin(user)
? '/app'
: '/volunteer';
const handleReport = async () => {
if (!showInput) {
setShowInput(true);
return;
}
setReporting(true);
try {
await axios.post(`${API_URL}/api/public/error-report`, {
url: location.pathname + location.search,
message: reportMessage || undefined,
userAgent: navigator.userAgent,
});
message.success('Report sent to admins. Thank you!');
setShowInput(false);
setReportMessage('');
} catch {
message.error('Failed to send report. Please try again later.');
} finally {
setReporting(false);
}
};
return (
<Result
status="404"
title="404"
subTitle={`The page "${location.pathname}" was not found.`}
extra={
<Space direction="vertical" size="middle" style={{ width: '100%', maxWidth: 400 }}>
<Space wrap>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate(-1)}>
Go Back
</Button>
<Button type="primary" icon={<HomeOutlined />} onClick={() => navigate(homePath)}>
Go Home
</Button>
<Button
icon={<MailOutlined />}
onClick={handleReport}
loading={reporting}
>
Report to Admin
</Button>
</Space>
{showInput && (
<div>
<Input.TextArea
placeholder="Optional: describe what you were looking for..."
value={reportMessage}
onChange={(e) => setReportMessage(e.target.value)}
rows={3}
maxLength={500}
showCount
style={{ marginBottom: 8 }}
/>
<Button type="primary" onClick={handleReport} loading={reporting} block>
Send Report
</Button>
</div>
)}
</Space>
}
/>
);
}

View File

@ -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';

View File

@ -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

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}

View File

@ -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);