diff --git a/admin/src/App.tsx b/admin/src/App.tsx index 6bd2a05c..f64d85e3 100644 --- a/admin/src/App.tsx +++ b/admin/src/App.tsx @@ -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() { } /> } /> } /> + } /> {/* Redirect old canvass routes to map with query param */} @@ -816,8 +818,12 @@ export default function App() { } /> + } /> + + } /> + }> + } /> - } /> diff --git a/admin/src/pages/NotFoundPage.tsx b/admin/src/pages/NotFoundPage.tsx new file mode 100644 index 00000000..58cca5f3 --- /dev/null +++ b/admin/src/pages/NotFoundPage.tsx @@ -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 ( + + + + + + + {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

+ + + ${userMessage ? `` : ''} + + ${userAgent ? `` : ''} + +
URL:${escapeHtml(url)}
Message:${escapeHtml(userMessage)}
IP:${escapeHtml(ip)}
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);