Browse Source

Let a sidebar app open a Bambuddy page in place

The frame an external link opens in can't navigate the Bambuddy
window, so links like Bambuddy Orders' "In Bambuddy" did nothing.
The framed page can now post bambuddy:navigate with a path; it is
accepted only from the frame, from the link's origin, and for a path
inside Bambuddy. The theme message announces canNavigate.
maziggy 1 day ago
parent
commit
181db5b307

File diff suppressed because it is too large
+ 1 - 0
CHANGELOG.md


+ 85 - 0
frontend/src/__tests__/pages/ExternalLinkPageNavigate.test.tsx

@@ -0,0 +1,85 @@
+/**
+ * The framed app may ask Bambuddy to open one of its own pages in place
+ * (Bambuddy Orders' "In Bambuddy" link): only from the frame, only from the
+ * link's origin, only for a path inside Bambuddy.
+ */
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { screen } from '@testing-library/react';
+import { Route, Routes, useLocation } from 'react-router-dom';
+import { render } from '../utils';
+import { api } from '../../api/client';
+import { ExternalLinkPage } from '../../pages/ExternalLinkPage';
+
+const ORIGIN = 'http://orders.local:8090';
+const LINK = {
+  id: 5,
+  name: 'Orders',
+  url: `${ORIGIN}/`,
+  icon: 'shopping-cart',
+  open_in_new_tab: false,
+  custom_icon: null,
+  sort_order: 0,
+  created_at: '2026-09-27T00:00:00Z',
+  updated_at: '2026-09-27T00:00:00Z',
+};
+
+function Where() {
+  const location = useLocation();
+  return <p data-testid="where">{location.pathname + location.search}</p>;
+}
+
+async function renderFrame() {
+  window.history.pushState({}, '', '/external/5');
+  render(
+    <Routes>
+      <Route path="/external/:id" element={<ExternalLinkPage />} />
+      <Route path="*" element={<Where />} />
+    </Routes>,
+  );
+  return (await screen.findByTitle('Orders')) as HTMLIFrameElement;
+}
+
+function ask(frame: HTMLIFrameElement | Window, path: unknown, origin = ORIGIN) {
+  const source = frame instanceof HTMLIFrameElement ? frame.contentWindow : frame;
+  window.dispatchEvent(new MessageEvent('message', { data: { type: 'bambuddy:navigate', path }, origin, source }));
+}
+
+beforeEach(() => {
+  vi.spyOn(api, 'getExternalLink').mockResolvedValue(LINK);
+});
+
+afterEach(() => {
+  vi.restoreAllMocks();
+});
+
+describe('ExternalLinkPage navigate requests', () => {
+  it('opens the asked-for Bambuddy page', async () => {
+    const frame = await renderFrame();
+    ask(frame, '/queue?batch=12');
+    expect(await screen.findByTestId('where')).toHaveTextContent('/queue?batch=12');
+  });
+
+  it.each([
+    ['another site', 'https://evil.example/login'],
+    ['a protocol-relative address', '//evil.example/login'],
+    ['a backslash trick', '/\\evil.example'],
+    ['a script', 'javascript:alert(1)'],
+    ['a relative path', 'queue'],
+    ['no string', 42],
+  ])('ignores %s', async (_name, path) => {
+    const frame = await renderFrame();
+    ask(frame, path);
+    await new Promise((r) => setTimeout(r, 20));
+    expect(screen.getByTitle('Orders')).toBeInTheDocument();
+  });
+
+  it('ignores requests from other windows or origins', async () => {
+    const frame = await renderFrame();
+    ask(window, '/queue');
+    ask(frame, '/queue', 'http://evil.example');
+    await new Promise((r) => setTimeout(r, 20));
+    expect(screen.getByTitle('Orders')).toBeInTheDocument();
+    expect(screen.queryByTestId('where')).not.toBeInTheDocument();
+  });
+});

+ 1 - 0
frontend/src/__tests__/pages/ExternalLinkPageTheme.test.tsx

@@ -47,6 +47,7 @@ const THEME = {
   style: expect.any(String),
   background: expect.any(String),
   accent: expect.any(String),
+  canNavigate: true,
 };
 
 describe('ExternalLinkPage theme', () => {

+ 63 - 3
frontend/src/pages/ExternalLinkPage.tsx

@@ -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;
+}

File diff suppressed because it is too large
+ 0 - 0
static/assets/ImagePreviewModal-BNS3PW0l.js


File diff suppressed because it is too large
+ 0 - 1
static/assets/PdfPreviewModal-BvXcfvYe.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SpreadsheetPreviewModal-BNJIB5jH.js


File diff suppressed because it is too large
+ 1 - 1
static/assets/index-CV30AFBJ.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/pdf-BsRY7zBD.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-DCfEMLCI.js"></script>
+    <script type="module" crossorigin src="/assets/index-CV30AFBJ.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/chunk-aKtaBQYM.js">
     <link rel="stylesheet" crossorigin href="/assets/index-CyW_3l56.css">
   </head>

Some files were not shown because too many files changed in this diff