|
|
@@ -1,5 +1,5 @@
|
|
|
import { useCallback, useEffect, useRef, type RefObject } from 'react';
|
|
|
-import { useParams } from 'react-router-dom';
|
|
|
+import { useNavigate, useParams } from 'react-router-dom';
|
|
|
import { useQuery } from '@tanstack/react-query';
|
|
|
import { Loader2, AlertTriangle } from 'lucide-react';
|
|
|
import { useTranslation } from 'react-i18next';
|
|
|
@@ -12,6 +12,7 @@ export function ExternalLinkPage() {
|
|
|
const { mode } = useTheme();
|
|
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
|
|
const sendTheme = useThemeMessenger(iframeRef);
|
|
|
+ useNavigateMessages(iframeRef);
|
|
|
|
|
|
const { data: link, isLoading, error } = useQuery({
|
|
|
queryKey: ['external-link', id],
|
|
|
@@ -53,7 +54,9 @@ export function ExternalLinkPage() {
|
|
|
* Tell the framed page which theme Bambuddy is showing, so an app built to
|
|
|
* sit in the sidebar (Bambuddy Orders) can match it. Sent when the page
|
|
|
* loads, when the theme changes, and when the page asks for it. Only to the
|
|
|
- * link's own origin, and it carries nothing but the theme names.
|
|
|
+ * link's own origin, and it carries nothing but the theme names, plus
|
|
|
+ * ``canNavigate``: this Bambuddy takes the page's navigate requests (see
|
|
|
+ * useNavigateMessages), so the page can tell it apart from older builds.
|
|
|
*/
|
|
|
function useThemeMessenger(iframeRef: RefObject<HTMLIFrameElement | null>) {
|
|
|
const { resolvedMode, darkStyle, darkBackground, darkAccent, lightStyle, lightBackground, lightAccent } = useTheme();
|
|
|
@@ -71,7 +74,10 @@ function useThemeMessenger(iframeRef: RefObject<HTMLIFrameElement | null>) {
|
|
|
} catch {
|
|
|
return;
|
|
|
}
|
|
|
- frame.contentWindow.postMessage({ type: 'bambuddy:theme', mode: resolvedMode, style, background, accent }, origin);
|
|
|
+ frame.contentWindow.postMessage(
|
|
|
+ { type: 'bambuddy:theme', mode: resolvedMode, style, background, accent, canNavigate: true },
|
|
|
+ origin,
|
|
|
+ );
|
|
|
}, [iframeRef, resolvedMode, style, background, accent]);
|
|
|
|
|
|
useEffect(() => sendTheme(), [sendTheme]);
|
|
|
@@ -86,3 +92,57 @@ function useThemeMessenger(iframeRef: RefObject<HTMLIFrameElement | null>) {
|
|
|
|
|
|
return sendTheme;
|
|
|
}
|
|
|
+
|
|
|
+/** Longest in-app path a framed page may ask for; real ones are far shorter. */
|
|
|
+const MAX_NAVIGATE_PATH = 512;
|
|
|
+
|
|
|
+/**
|
|
|
+ * Let the framed page open a Bambuddy page in place, e.g. Bambuddy Orders'
|
|
|
+ * "In Bambuddy" link to a batch. The sandbox rightly doesn't let the frame
|
|
|
+ * navigate the top window itself, so it asks:
|
|
|
+ * ``{type: 'bambuddy:navigate', path: '/queue?batch=12'}``.
|
|
|
+ *
|
|
|
+ * Accepted only from the frame, only from the link's own origin, and only for
|
|
|
+ * a path inside Bambuddy: anything else is ignored, so the request can never
|
|
|
+ * lead anywhere a typed address bar couldn't. The theme message's
|
|
|
+ * ``canNavigate`` tells the page it can ask; without it the page opens a new
|
|
|
+ * tab instead.
|
|
|
+ */
|
|
|
+function useNavigateMessages(iframeRef: RefObject<HTMLIFrameElement | null>) {
|
|
|
+ const navigate = useNavigate();
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ const onMessage = (e: MessageEvent) => {
|
|
|
+ const frame = iframeRef.current;
|
|
|
+ if (!frame?.contentWindow || e.source !== frame.contentWindow) return;
|
|
|
+ if (e.data?.type !== 'bambuddy:navigate') return;
|
|
|
+ let origin: string;
|
|
|
+ try {
|
|
|
+ origin = new URL(frame.src).origin;
|
|
|
+ } catch {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (e.origin !== origin) return;
|
|
|
+ const target = inAppPath(e.data.path);
|
|
|
+ if (target === null) return;
|
|
|
+ navigate(target);
|
|
|
+ };
|
|
|
+ window.addEventListener('message', onMessage);
|
|
|
+ return () => window.removeEventListener('message', onMessage);
|
|
|
+ }, [iframeRef, navigate]);
|
|
|
+}
|
|
|
+
|
|
|
+/** The path, query and hash of a same-origin path, or null for anything else. */
|
|
|
+function inAppPath(path: unknown): string | null {
|
|
|
+ if (typeof path !== 'string' || path.length > MAX_NAVIGATE_PATH) return null;
|
|
|
+ // "//host" and "/\\host" are other sites to a browser; only a plain absolute path is in-app.
|
|
|
+ if (!path.startsWith('/') || path.startsWith('//') || path.includes('\\')) return null;
|
|
|
+ let url: URL;
|
|
|
+ try {
|
|
|
+ url = new URL(path, window.location.origin);
|
|
|
+ } catch {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ if (url.origin !== window.location.origin) return null;
|
|
|
+ return url.pathname + url.search + url.hash;
|
|
|
+}
|