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();
});