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:
parent
18997da3eb
commit
98acd4917d
@ -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>
|
||||
|
||||
92
admin/src/pages/NotFoundPage.tsx
Normal file
92
admin/src/pages/NotFoundPage.tsx
Normal 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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user