diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index 069e62b..b96f09e 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -2,6 +2,7 @@ import { lazy, Suspense } from "react"; import { HashRouter, Route, Routes } from "react-router-dom"; import { ScrollToTop } from "./components/ScrollToTop"; import { DeepLinkHandler } from "./components/DeepLinkHandler"; +import { RouteErrorBoundary } from "./components/RouteErrorBoundary"; // Eagerly load the map — it's now the homepage import MapPage from "./pages/MapPage"; @@ -83,8 +84,12 @@ export function AppRouter() { - }> - + {/* Per-route boundary: a crash in one page shows a recoverable message + instead of blanking the whole app. Resets automatically on navigate + (keyed by pathname inside RouteErrorBoundary). */} + + }> + {/* Map is the homepage */} } /> {/* /map redirects to / */} @@ -163,8 +168,9 @@ export function AppRouter() { } /> {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} } /> - - + + + ); } diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx index c19d500..f8a3778 100644 --- a/src/components/ErrorBoundary.tsx +++ b/src/components/ErrorBoundary.tsx @@ -31,6 +31,9 @@ export class ErrorBoundary extends Component -
-
-

- Something went wrong +
+
+ 🛹 +
+
+

+ Slammed

-

- An unexpected error occurred. The error has been reported. +

+ Something tripped up this screen. Your account and data are safe — + let's get you rolling again.

-
-
- - Error details - -
-
- Message: -

- {this.state.error?.message} -

-
- {this.state.error?.stack && ( -
- Stack trace: -
-                        {this.state.error.stack}
-                      
-
- )} -
-
-
- -
+
diff --git a/src/components/RouteErrorBoundary.tsx b/src/components/RouteErrorBoundary.tsx new file mode 100644 index 0000000..b145630 --- /dev/null +++ b/src/components/RouteErrorBoundary.tsx @@ -0,0 +1,52 @@ +import { useLocation } from 'react-router-dom'; +import { ErrorBoundary } from '@/components/ErrorBoundary'; + +/** + * Per-route error boundary. + * + * The app has a single top-level ErrorBoundary (in main.tsx) as a last resort, + * but on its own that means ANY component throwing blanks the ENTIRE app. This + * wrapper adds a second, inner boundary around the routed page content so a + * crash in one page shows a recoverable message while the app keeps running. + * + * It is keyed by the current pathname: navigating to a different route remounts + * the boundary and clears the error automatically, so a broken page never + * "sticks" once the user moves away. + */ +export function RouteErrorBoundary({ children }: { children: React.ReactNode }) { + const location = useLocation(); + + return ( + +
+
+ 🛹 +
+
+

+ This page slammed +

+

+ Something on this screen tripped up. Your account and data are safe. + Head back and try again. +

+
+ +
+
+ } + > + {children} + + ); +} + +export default RouteErrorBoundary; diff --git a/src/lib/sanitizeUrl.ts b/src/lib/sanitizeUrl.ts index 3d28d3b..10ac5a8 100644 --- a/src/lib/sanitizeUrl.ts +++ b/src/lib/sanitizeUrl.ts @@ -2,9 +2,13 @@ * Sanitizes a user-supplied URL to prevent phishing and script injection. * * Rules: - * - Only allows https:// and http:// schemes - * - Blocks javascript:, data:, vbscript:, and all other schemes - * - Returns null for invalid / unsafe URLs (caller should hide the link) + * - Only emits https:// URLs. `javascript:`, `data:`, `vbscript:`, `file:`, + * relative paths, and every other scheme are rejected (returns null). + * - Bare `http://` links are UPGRADED to `https://` rather than dropped, so we + * don't silently lose links that work fine over TLS. Plain `http` is a + * downgrade / mixed-content / IP-leak vector and also fails outright inside + * the native (Capacitor) HTTPS-only WebView, so we never emit it. + * - Returns null for invalid / unsafe URLs (caller should hide the link). * * VPN/Tor users are unaffected — this validates the URL scheme & structure, * not the user's network origin. @@ -17,17 +21,40 @@ export function sanitizeUrl(url: string | undefined | null): string | null { try { const parsed = new URL(trimmed); - // Only allow safe schemes - if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { - return null; + + if (parsed.protocol === 'https:') { + return parsed.href; } - return parsed.href; + + // Upgrade http → https instead of dropping the link outright. + if (parsed.protocol === 'http:') { + parsed.protocol = 'https:'; + return parsed.href; + } + + // Everything else (javascript:, data:, file:, etc.) is unsafe. + return null; } catch { - // Not a valid URL + // Not a valid absolute URL (also rejects relative paths — intentional, + // since untrusted relative URLs can trigger same-origin requests). return null; } } +/** + * Allowlists safe characters for interpolating an untrusted string into a CSS + * value (font-family, animation-name, etc.). Keeps Unicode letters/numbers, + * spaces, hyphens, underscores, apostrophes and periods; strips everything + * that could break out of the CSS string context (`"`, `)`, `}`, `;`, `\`). + * + * URLs in `url(...)` must use `sanitizeUrl()` instead — the URL constructor + * percent-encodes the dangerous characters and rejects non-http(s) schemes. + */ +export function sanitizeCssString(value: string | undefined | null): string { + if (!value) return ''; + return value.replace(/[^\p{L}\p{N} _\-'.]/gu, ''); +} + /** * Extracts just the hostname for display (e.g. "Opens: example.com") */ diff --git a/src/test/ErrorBoundary.test.tsx b/src/test/ErrorBoundary.test.tsx index 3f49427..7e3634f 100644 --- a/src/test/ErrorBoundary.test.tsx +++ b/src/test/ErrorBoundary.test.tsx @@ -33,8 +33,10 @@ describe('ErrorBoundary', () => { ); - expect(screen.getByText('Something went wrong')).toBeInTheDocument(); - expect(screen.getByText('An unexpected error occurred. The error has been reported.')).toBeInTheDocument(); + expect(screen.getByText('Slammed')).toBeInTheDocument(); + expect( + screen.getByText(/Something tripped up this screen/i) + ).toBeInTheDocument(); consoleSpy.mockRestore(); });