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 SocialModerationPage from '@/pages/social/SocialModerationPage';
|
||||||
import MeetingJoinPage from '@/pages/public/MeetingJoinPage';
|
import MeetingJoinPage from '@/pages/public/MeetingJoinPage';
|
||||||
import JitsiAuthPage from '@/pages/JitsiAuthPage';
|
import JitsiAuthPage from '@/pages/JitsiAuthPage';
|
||||||
|
import NotFoundPage from '@/pages/NotFoundPage';
|
||||||
import CommandPalette from '@/components/command-palette/CommandPalette';
|
import CommandPalette from '@/components/command-palette/CommandPalette';
|
||||||
|
|
||||||
function RoleAwareRedirect() {
|
function RoleAwareRedirect() {
|
||||||
@ -307,6 +308,7 @@ export default function App() {
|
|||||||
<Route path="/volunteer/groups/:id" element={<GroupDetailPage />} />
|
<Route path="/volunteer/groups/:id" element={<GroupDetailPage />} />
|
||||||
<Route path="/volunteer/achievements" element={<AchievementsPage />} />
|
<Route path="/volunteer/achievements" element={<AchievementsPage />} />
|
||||||
<Route path="/volunteer/chat" element={<VolunteerChatPage />} />
|
<Route path="/volunteer/chat" element={<VolunteerChatPage />} />
|
||||||
|
<Route path="/volunteer/*" element={<NotFoundPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
{/* Redirect old canvass routes to map with query param */}
|
{/* Redirect old canvass routes to map with query param */}
|
||||||
@ -816,8 +818,12 @@ export default function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
|
</Route>
|
||||||
|
<Route path="/" element={<RoleAwareRedirect />} />
|
||||||
|
<Route path="*" element={<PublicLayout />}>
|
||||||
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<RoleAwareRedirect />} />
|
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</AntApp>
|
</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(photosPublicRoutes, { prefix: '/api' });
|
||||||
await fastify.register(photoEngagementRoutes, { 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 port = env.MEDIA_API_PORT;
|
||||||
const host = '0.0.0.0';
|
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({
|
export const healthMetricsRateLimit = rateLimit({
|
||||||
windowMs: 60 * 1000, // 1 minute
|
windowMs: 60 * 1000, // 1 minute
|
||||||
max: 30, // 30 requests per 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 { homepageRouter } from './modules/homepage/homepage.routes';
|
||||||
import { ogRouter } from './modules/og/og.routes';
|
import { ogRouter } from './modules/og/og.routes';
|
||||||
import { socialRouter } from './modules/social/social.routes';
|
import { socialRouter } from './modules/social/social.routes';
|
||||||
|
import { errorReportRouter } from './modules/reports/error-report.routes';
|
||||||
import { sseService } from './modules/social/sse.service';
|
import { sseService } from './modules/social/sse.service';
|
||||||
import { presenceService } from './modules/social/presence.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/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/og', ogRouter); // OG meta tags for social sharing bots (no auth, cached)
|
||||||
app.use('/api/social', socialRouter); // Social connections (auth required)
|
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) ---
|
// --- Error Handler (must be last) ---
|
||||||
app.use(errorHandler);
|
app.use(errorHandler);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user