ExternalLinkPage.tsx 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148
  1. import { useCallback, useEffect, useRef, type RefObject } from 'react';
  2. import { useNavigate, useParams } from 'react-router-dom';
  3. import { useQuery } from '@tanstack/react-query';
  4. import { Loader2, AlertTriangle } from 'lucide-react';
  5. import { useTranslation } from 'react-i18next';
  6. import { api } from '../api/client';
  7. import { useTheme } from '../contexts/ThemeContext';
  8. export function ExternalLinkPage() {
  9. const { t } = useTranslation();
  10. const { id } = useParams<{ id: string }>();
  11. const { mode } = useTheme();
  12. const iframeRef = useRef<HTMLIFrameElement>(null);
  13. const sendTheme = useThemeMessenger(iframeRef);
  14. useNavigateMessages(iframeRef);
  15. const { data: link, isLoading, error } = useQuery({
  16. queryKey: ['external-link', id],
  17. queryFn: () => api.getExternalLink(Number(id)),
  18. enabled: !!id,
  19. });
  20. if (isLoading) {
  21. return (
  22. <div className="flex items-center justify-center h-full">
  23. <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
  24. </div>
  25. );
  26. }
  27. if (error || !link) {
  28. return (
  29. <div className="flex flex-col items-center justify-center h-full gap-4 text-bambu-gray">
  30. <AlertTriangle className="w-12 h-12" />
  31. <p>{t('common.linkNotFound')}</p>
  32. </div>
  33. );
  34. }
  35. return (
  36. <iframe
  37. ref={iframeRef}
  38. src={link.url}
  39. onLoad={sendTheme}
  40. className="h-full w-full border-0"
  41. style={{ colorScheme: mode }}
  42. title={link.name}
  43. sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox"
  44. />
  45. );
  46. }
  47. /**
  48. * Tell the framed page which theme Bambuddy is showing, so an app built to
  49. * sit in the sidebar (Bambuddy Orders) can match it. Sent when the page
  50. * loads, when the theme changes, and when the page asks for it. Only to the
  51. * link's own origin, and it carries nothing but the theme names, plus
  52. * ``canNavigate``: this Bambuddy takes the page's navigate requests (see
  53. * useNavigateMessages), so the page can tell it apart from older builds.
  54. */
  55. function useThemeMessenger(iframeRef: RefObject<HTMLIFrameElement | null>) {
  56. const { resolvedMode, darkStyle, darkBackground, darkAccent, lightStyle, lightBackground, lightAccent } = useTheme();
  57. const dark = resolvedMode === 'dark';
  58. const style = dark ? darkStyle : lightStyle;
  59. const background = dark ? darkBackground : lightBackground;
  60. const accent = dark ? darkAccent : lightAccent;
  61. const sendTheme = useCallback(() => {
  62. const frame = iframeRef.current;
  63. if (!frame?.contentWindow) return;
  64. let origin: string;
  65. try {
  66. origin = new URL(frame.src).origin;
  67. } catch {
  68. return;
  69. }
  70. frame.contentWindow.postMessage(
  71. { type: 'bambuddy:theme', mode: resolvedMode, style, background, accent, canNavigate: true },
  72. origin,
  73. );
  74. }, [iframeRef, resolvedMode, style, background, accent]);
  75. useEffect(() => sendTheme(), [sendTheme]);
  76. useEffect(() => {
  77. const onMessage = (e: MessageEvent) => {
  78. if (e.source === iframeRef.current?.contentWindow && e.data?.type === 'bambuddy:theme-request') sendTheme();
  79. };
  80. window.addEventListener('message', onMessage);
  81. return () => window.removeEventListener('message', onMessage);
  82. }, [iframeRef, sendTheme]);
  83. return sendTheme;
  84. }
  85. /** Longest in-app path a framed page may ask for; real ones are far shorter. */
  86. const MAX_NAVIGATE_PATH = 512;
  87. /**
  88. * Let the framed page open a Bambuddy page in place, e.g. Bambuddy Orders'
  89. * "In Bambuddy" link to a batch. The sandbox rightly doesn't let the frame
  90. * navigate the top window itself, so it asks:
  91. * ``{type: 'bambuddy:navigate', path: '/queue?batch=12'}``.
  92. *
  93. * Accepted only from the frame, only from the link's own origin, and only for
  94. * a path inside Bambuddy: anything else is ignored, so the request can never
  95. * lead anywhere a typed address bar couldn't. The theme message's
  96. * ``canNavigate`` tells the page it can ask; without it the page opens a new
  97. * tab instead.
  98. */
  99. function useNavigateMessages(iframeRef: RefObject<HTMLIFrameElement | null>) {
  100. const navigate = useNavigate();
  101. useEffect(() => {
  102. const onMessage = (e: MessageEvent) => {
  103. const frame = iframeRef.current;
  104. if (!frame?.contentWindow || e.source !== frame.contentWindow) return;
  105. if (e.data?.type !== 'bambuddy:navigate') return;
  106. let origin: string;
  107. try {
  108. origin = new URL(frame.src).origin;
  109. } catch {
  110. return;
  111. }
  112. if (e.origin !== origin) return;
  113. const target = inAppPath(e.data.path);
  114. if (target === null) return;
  115. navigate(target);
  116. };
  117. window.addEventListener('message', onMessage);
  118. return () => window.removeEventListener('message', onMessage);
  119. }, [iframeRef, navigate]);
  120. }
  121. /** The path, query and hash of a same-origin path, or null for anything else. */
  122. function inAppPath(path: unknown): string | null {
  123. if (typeof path !== 'string' || path.length > MAX_NAVIGATE_PATH) return null;
  124. // "//host" and "/\\host" are other sites to a browser; only a plain absolute path is in-app.
  125. if (!path.startsWith('/') || path.startsWith('//') || path.includes('\\')) return null;
  126. let url: URL;
  127. try {
  128. url = new URL(path, window.location.origin);
  129. } catch {
  130. return null;
  131. }
  132. if (url.origin !== window.location.origin) return null;
  133. return url.pathname + url.search + url.hash;
  134. }