Przeglądaj źródła

Say which header blocked the 3D preview, instead of leaving the browser's page (#2787)

A reporter uploaded an STL, sliced it in Bambuddy, and got a frowny icon and
"<hostname> refused to connect" when previewing the sliced file -- while the
STL's own preview worked. That is Chrome's ERR_BLOCKED_BY_RESPONSE page, drawn
inside our layout shell, and the split between the two previews is where the
cause is: an STL or source 3MF renders in the page, a sliced file opens the
embedded G-code viewer, which is the only thing in Bambuddy that frames a
Bambuddy page (FileManagerPage.tsx:2472, GCodeViewerPage.tsx:47).

Our headers permit that frame -- frame-ancestors 'self' plus SAMEORIGIN on
everything under /gcode-viewer (main.py:7709) -- and the frame is same-origin,
so a refusal means a stricter header was added after we replied: a reverse
proxy, a security add-on, an auth gateway. None of which the user could see.
The browser drew its own page and nothing said what was refused, by whom, or
that the viewer opens perfectly well in a tab.

The frame cannot report this itself. A frame blocked by X-Frame-Options or
frame-ancestors still fires onLoad -- the browser commits an error document --
so there is no failure to catch. The page now asks for the same URL directly:
same-origin, so every response header is readable, and it goes through whatever
proxy the browser reaches Bambuddy by.

findFramingRefusal reads the verdict the way a browser does. frame-ancestors
wins outright when present, because CSP requires X-Frame-Options to be ignored
in that case -- reading both would blame a proxy-added DENY the browser never
consulted. Multiple CSP headers are intersected and fetch joins them into one
comma-separated string, so every frame-ancestors occurrence has to permit us,
not just the first; that is the shape a proxy appending its own policy to ours
actually takes. Failing that, a legacy header that is anything other than a
single SAMEORIGIN refuses us, including the conflicting "SAMEORIGIN, DENY" that
appears when a second copy is appended.

On refusal the frame is replaced with the header named verbatim, so an operator
can go and find the rule in their proxy config, and a link that opens the viewer
in its own tab -- a top-level page, which no framing header applies to. A
non-200 is reported the same way rather than as raw {"detail":"Not Found"}
inside the frame, which the startup-time warning at main.py:8120 already calls
out as easy to miss. A probe that cannot reach a verdict changes nothing: the
iframe stays, because guessing at a cause we cannot see is worse than the
browser's own page.

The working case is unaffected -- the iframe renders immediately as before and
the probe only ever replaces it.
maziggy 4 tygodni temu
rodzic
commit
595dc5844a

Plik diff jest za duży
+ 1 - 0
CHANGELOG.md


+ 120 - 0
frontend/src/__tests__/pages/GCodeViewerPage.test.tsx

@@ -0,0 +1,120 @@
+/**
+ * The G-code viewer's frame, when something refuses to let it be embedded (#2787).
+ *
+ * Sliced files preview through a full-page route whose body is an iframe of
+ * /gcode-viewer/; STL and source 3MF use an in-page three.js modal instead. So a
+ * proxy that injects a framing header breaks exactly one of the two previews,
+ * and all the user sees is the browser's own "refused to connect" page inside
+ * our layout shell — no clue what happened, and no hint that the viewer works
+ * perfectly well in a tab of its own.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { screen, waitFor, within } from '@testing-library/react';
+import { http, HttpResponse } from 'msw';
+import { render } from '../utils';
+import { GCodeViewerPage } from '../../pages/GCodeViewerPage';
+import { findFramingRefusal } from '../../utils/framing';
+import { server } from '../mocks/server';
+
+const ORIGIN = 'https://printers.example.com';
+const OURS = "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'self';";
+
+function serveViewer(status: number, headers: Record<string, string> = {}) {
+  server.use(http.get('/gcode-viewer/', () => new HttpResponse(null, { status, headers })));
+}
+
+describe('findFramingRefusal', () => {
+  it('accepts the headers Bambuddy itself sends', () => {
+    expect(findFramingRefusal('SAMEORIGIN', OURS, ORIGIN)).toBeNull();
+  });
+
+  it('accepts an origin named explicitly instead of self', () => {
+    const csp = `frame-ancestors ${ORIGIN};`;
+    expect(findFramingRefusal(null, csp, ORIGIN)).toBeNull();
+  });
+
+  it('reports a proxy-added policy that intersects ours down to none', () => {
+    // Two Content-Security-Policy headers arrive as one comma-joined string.
+    // Both apply, so ours permitting us is not enough.
+    const refusal = findFramingRefusal('SAMEORIGIN', `${OURS}, frame-ancestors 'none'`, ORIGIN);
+    expect(refusal).toBe("Content-Security-Policy: frame-ancestors 'none'");
+  });
+
+  it('reports frame-ancestors listing only somebody else', () => {
+    const refusal = findFramingRefusal(null, "frame-ancestors https://ha.example.com;", ORIGIN);
+    expect(refusal).toContain('ha.example.com');
+  });
+
+  it('reports X-Frame-Options DENY when no frame-ancestors is present', () => {
+    expect(findFramingRefusal('DENY', null, ORIGIN)).toBe('X-Frame-Options: DENY');
+  });
+
+  it('reports a second X-Frame-Options appended to ours', () => {
+    expect(findFramingRefusal('SAMEORIGIN, DENY', null, ORIGIN)).toBe(
+      'X-Frame-Options: SAMEORIGIN, DENY',
+    );
+  });
+
+  it('ignores X-Frame-Options when frame-ancestors permits us, as browsers do', () => {
+    // CSP supersedes the legacy header outright — flagging this would blame a
+    // header the browser never consulted.
+    expect(findFramingRefusal('DENY', OURS, ORIGIN)).toBeNull();
+  });
+
+  it('accepts a response carrying no framing headers at all', () => {
+    expect(findFramingRefusal(null, null, ORIGIN)).toBeNull();
+  });
+});
+
+describe('GCodeViewerPage', () => {
+  it('embeds the viewer when nothing refuses the frame', async () => {
+    serveViewer(200, { 'X-Frame-Options': 'SAMEORIGIN', 'Content-Security-Policy': OURS });
+
+    render(<GCodeViewerPage />);
+
+    expect(await screen.findByTitle('GCode Viewer')).toBeInTheDocument();
+    // Give the probe a chance to land and prove it changes nothing.
+    await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument());
+    expect(screen.getByTitle('GCode Viewer')).toBeInTheDocument();
+  });
+
+  it('explains a refused frame and offers the viewer in its own tab', async () => {
+    serveViewer(200, { 'Content-Security-Policy': "frame-ancestors 'none';" });
+
+    render(<GCodeViewerPage />);
+
+    const panel = await screen.findByRole('alert');
+    expect(panel).toHaveTextContent(/could not be embedded/i);
+    // Name the header so the operator can go and find it in their proxy.
+    expect(panel).toHaveTextContent(/frame-ancestors 'none'/);
+    // A top-level navigation is not subject to frame-ancestors, so this works.
+    const link = within(panel).getByRole('link', { name: /new tab/i });
+    expect(link).toHaveAttribute('href', '/gcode-viewer/');
+    expect(link).toHaveAttribute('target', '_blank');
+    expect(screen.queryByTitle('GCode Viewer')).not.toBeInTheDocument();
+  });
+
+  it('reports missing viewer assets rather than showing raw JSON', async () => {
+    serveViewer(404);
+
+    render(<GCodeViewerPage />);
+
+    const panel = await screen.findByRole('alert');
+    expect(panel).toHaveTextContent(/unavailable/i);
+    expect(panel).toHaveTextContent(/HTTP 404/);
+    expect(screen.queryByTitle('GCode Viewer')).not.toBeInTheDocument();
+  });
+
+  it('keeps the frame when the probe itself fails', async () => {
+    // No evidence either way — the browser's own error page is better than a
+    // guess at a cause we cannot see.
+    server.use(http.get('/gcode-viewer/', () => HttpResponse.error()));
+
+    render(<GCodeViewerPage />);
+
+    expect(await screen.findByTitle('GCode Viewer')).toBeInTheDocument();
+    await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument());
+    expect(screen.getByTitle('GCode Viewer')).toBeInTheDocument();
+  });
+});

+ 6 - 0
frontend/src/i18n/locales/de.ts

@@ -6899,6 +6899,12 @@ export default {
     },
   },
   gcodeViewer: {
+    blockedTitle: '3D-Vorschau konnte nicht eingebettet werden',
+    blockedBody: 'Bambuddy erlaubt dieser Seite, den G-Code-Viewer eingebettet anzuzeigen, aber etwas zwischen Ihrem Browser und Bambuddy verweigert das — meist ein Reverse-Proxy oder eine Sicherheitserweiterung, die einen eigenen Frame-Header sendet. Das Öffnen des Viewers in einem eigenen Tab ist davon nicht betroffen.',
+    unavailableTitle: '3D-Vorschau nicht verfügbar',
+    unavailableBody: 'Bambuddy konnte die Dateien des G-Code-Viewers nicht ausliefern. Normalerweise fehlt dann das Verzeichnis gcode_viewer in der Installation; das Startprotokoll weist ebenfalls darauf hin.',
+    problemDetail: 'Meldung des Servers: {{detail}}',
+    openInNewTab: 'Viewer in neuem Tab öffnen',
     back: 'Zurück',
     backToArchives: 'Zurück zum Druckarchiv',
     backToFiles: 'Zurück zum Dateimanager',

+ 6 - 0
frontend/src/i18n/locales/en.ts

@@ -6948,6 +6948,12 @@ export default {
     },
   },
   gcodeViewer: {
+    blockedTitle: 'The 3D preview could not be embedded',
+    blockedBody: 'Bambuddy allows this page to show the G-code viewer inline, but something between your browser and Bambuddy is refusing it — usually a reverse proxy or a security add-on sending its own framing header. Opening the viewer in its own tab is not affected.',
+    unavailableTitle: 'The 3D preview is unavailable',
+    unavailableBody: 'Bambuddy could not serve the G-code viewer\'s files. This normally means the gcode_viewer directory is missing from the installation; the startup log says so too.',
+    problemDetail: 'Reported by the server: {{detail}}',
+    openInNewTab: 'Open the viewer in a new tab',
     back: 'Back',
     backToArchives: 'Back to Print Archives',
     backToFiles: 'Back to File Manager',

+ 6 - 0
frontend/src/i18n/locales/es.ts

@@ -6908,6 +6908,12 @@ export default {
     },
   },
   gcodeViewer: {
+    blockedTitle: 'No se pudo incrustar la vista previa 3D',
+    blockedBody: 'Bambuddy permite que esta página muestre el visor de G-code incrustado, pero algo entre su navegador y Bambuddy lo está rechazando — normalmente un proxy inverso o un complemento de seguridad que envía su propia cabecera de marco. Abrir el visor en su propia pestaña no se ve afectado.',
+    unavailableTitle: 'La vista previa 3D no está disponible',
+    unavailableBody: 'Bambuddy no pudo servir los archivos del visor de G-code. Esto suele significar que falta el directorio gcode_viewer en la instalación; el registro de inicio también lo indica.',
+    problemDetail: 'Informado por el servidor: {{detail}}',
+    openInNewTab: 'Abrir el visor en una pestaña nueva',
     back: 'Atrás',
     backToArchives: 'Volver a los archivos de impresión',
     backToFiles: 'Volver al gestor de archivos',

+ 6 - 0
frontend/src/i18n/locales/fr.ts

@@ -6888,6 +6888,12 @@ export default {
     },
   },
   gcodeViewer: {
+    blockedTitle: 'L\'aperçu 3D n\'a pas pu être intégré',
+    blockedBody: 'Bambuddy autorise cette page à afficher la visionneuse G-code en ligne, mais quelque chose entre votre navigateur et Bambuddy le refuse — généralement un reverse proxy ou une extension de sécurité qui envoie son propre en-tête de cadre. L\'ouverture de la visionneuse dans un onglet dédié n\'est pas concernée.',
+    unavailableTitle: 'L\'aperçu 3D est indisponible',
+    unavailableBody: 'Bambuddy n\'a pas pu servir les fichiers de la visionneuse G-code. Cela signifie généralement que le répertoire gcode_viewer est absent de l\'installation ; le journal de démarrage l\'indique également.',
+    problemDetail: 'Signalé par le serveur : {{detail}}',
+    openInNewTab: 'Ouvrir la visionneuse dans un nouvel onglet',
     back: 'Retour',
     backToArchives: 'Retour aux archives d\'impression',
     backToFiles: 'Retour au gestionnaire de fichiers',

+ 6 - 0
frontend/src/i18n/locales/it.ts

@@ -6887,6 +6887,12 @@ export default {
     },
   },
   gcodeViewer: {
+    blockedTitle: 'Impossibile incorporare l\'anteprima 3D',
+    blockedBody: 'Bambuddy consente a questa pagina di mostrare il visualizzatore G-code incorporato, ma qualcosa tra il browser e Bambuddy lo rifiuta — di solito un reverse proxy o un\'estensione di sicurezza che invia una propria intestazione di frame. L\'apertura del visualizzatore in una scheda dedicata non è interessata.',
+    unavailableTitle: 'Anteprima 3D non disponibile',
+    unavailableBody: 'Bambuddy non è riuscito a servire i file del visualizzatore G-code. Di solito significa che la cartella gcode_viewer manca nell\'installazione; anche il log di avvio lo segnala.',
+    problemDetail: 'Segnalato dal server: {{detail}}',
+    openInNewTab: 'Apri il visualizzatore in una nuova scheda',
     back: 'Indietro',
     backToArchives: 'Torna agli archivi di stampa',
     backToFiles: 'Torna al gestore file',

+ 6 - 0
frontend/src/i18n/locales/ja.ts

@@ -6899,6 +6899,12 @@ export default {
     },
   },
   gcodeViewer: {
+    blockedTitle: '3Dプレビューを埋め込めませんでした',
+    blockedBody: 'BambuddyはこのページにG-codeビューアーを埋め込んで表示することを許可していますが、ブラウザーとBambuddyの間にある何かがそれを拒否しています。多くの場合、独自のフレームヘッダーを送信するリバースプロキシやセキュリティ拡張が原因です。ビューアーを別のタブで開く場合は影響ありません。',
+    unavailableTitle: '3Dプレビューを利用できません',
+    unavailableBody: 'BambuddyがG-codeビューアーのファイルを配信できませんでした。通常はインストールに gcode_viewer ディレクトリが存在しないことを意味します。起動ログにも記録されています。',
+    problemDetail: 'サーバーからの報告: {{detail}}',
+    openInNewTab: 'ビューアーを新しいタブで開く',
     back: '戻る',
     backToArchives: '印刷アーカイブに戻る',
     backToFiles: 'ファイル管理に戻る',

+ 6 - 0
frontend/src/i18n/locales/ko.ts

@@ -6357,6 +6357,12 @@ export default {
     }
   },
   gcodeViewer: {
+    blockedTitle: '3D 미리보기를 삽입할 수 없습니다',
+    blockedBody: 'Bambuddy는 이 페이지에 G-code 뷰어를 삽입해 표시하도록 허용하지만, 브라우저와 Bambuddy 사이의 무언가가 이를 거부하고 있습니다. 대개 자체 프레임 헤더를 보내는 리버스 프록시나 보안 추가 기능이 원인입니다. 뷰어를 별도 탭에서 여는 것은 영향을 받지 않습니다.',
+    unavailableTitle: '3D 미리보기를 사용할 수 없습니다',
+    unavailableBody: 'Bambuddy가 G-code 뷰어 파일을 제공하지 못했습니다. 보통 설치본에 gcode_viewer 디렉터리가 없다는 뜻이며, 시작 로그에도 기록됩니다.',
+    problemDetail: '서버 보고: {{detail}}',
+    openInNewTab: '새 탭에서 뷰어 열기',
     back: '뒤로',
     backToArchives: '인쇄 아카이브로 돌아가기',
     backToFiles: '파일 관리자로 돌아가기'

+ 6 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -6887,6 +6887,12 @@ export default {
     },
   },
   gcodeViewer: {
+    blockedTitle: 'Não foi possível incorporar a pré-visualização 3D',
+    blockedBody: 'O Bambuddy permite que esta página mostre o visualizador de G-code incorporado, mas algo entre o seu navegador e o Bambuddy está recusando — normalmente um proxy reverso ou um complemento de segurança que envia o próprio cabeçalho de quadro. Abrir o visualizador em uma aba própria não é afetado.',
+    unavailableTitle: 'A pré-visualização 3D está indisponível',
+    unavailableBody: 'O Bambuddy não conseguiu servir os arquivos do visualizador de G-code. Isso normalmente significa que o diretório gcode_viewer está ausente na instalação; o log de inicialização também informa isso.',
+    problemDetail: 'Informado pelo servidor: {{detail}}',
+    openInNewTab: 'Abrir o visualizador em uma nova aba',
     back: 'Voltar',
     backToArchives: 'Voltar para os arquivos de impressão',
     backToFiles: 'Voltar para o gerenciador de arquivos',

+ 6 - 0
frontend/src/i18n/locales/ru.ts

@@ -6526,6 +6526,12 @@ export default {
     },
   },
   gcodeViewer: {
+    blockedTitle: "Не удалось встроить 3D-предпросмотр",
+    blockedBody: "Bambuddy разрешает этой странице показывать просмотрщик G-code встроенным, но что-то между браузером и Bambuddy это запрещает — обычно обратный прокси или расширение безопасности, отправляющее собственный заголовок фрейма. Открытие просмотрщика в отдельной вкладке не затрагивается.",
+    unavailableTitle: "3D-предпросмотр недоступен",
+    unavailableBody: "Bambuddy не смог отдать файлы просмотрщика G-code. Обычно это значит, что в установке отсутствует каталог gcode_viewer; об этом также сообщает журнал запуска.",
+    problemDetail: "Сообщение сервера: {{detail}}",
+    openInNewTab: "Открыть просмотрщик в новой вкладке",
     back: "Назад",
     backToArchives: "Вернуться в архив печати",
     backToFiles: "Вернуться в файловый менеджер",

+ 6 - 0
frontend/src/i18n/locales/tr.ts

@@ -6839,6 +6839,12 @@ export default {
     },
   },
   gcodeViewer: {
+    blockedTitle: '3D önizleme gömülemedi',
+    blockedBody: 'Bambuddy bu sayfanın G-code görüntüleyiciyi gömülü göstermesine izin veriyor, ancak tarayıcınızla Bambuddy arasındaki bir şey bunu reddediyor — genellikle kendi çerçeve başlığını gönderen bir ters proxy veya güvenlik eklentisi. Görüntüleyiciyi kendi sekmesinde açmak bundan etkilenmez.',
+    unavailableTitle: '3D önizleme kullanılamıyor',
+    unavailableBody: 'Bambuddy, G-code görüntüleyicinin dosyalarını sunamadı. Bu genellikle kurulumda gcode_viewer dizininin eksik olduğu anlamına gelir; başlangıç günlüğü de bunu belirtir.',
+    problemDetail: 'Sunucunun bildirdiği: {{detail}}',
+    openInNewTab: 'Görüntüleyiciyi yeni sekmede aç',
     back: 'Geri',
     backToArchives: 'Baskı Arşivlerine Dön',
     backToFiles: 'Dosya Yöneticisine Dön',

+ 6 - 0
frontend/src/i18n/locales/uk.ts

@@ -6943,6 +6943,12 @@ export default {
     },
   },
   gcodeViewer: {
+    blockedTitle: "Не вдалося вбудувати 3D-перегляд",
+    blockedBody: "Bambuddy дозволяє цій сторінці показувати переглядач G-code вбудованим, але щось між браузером і Bambuddy це відхиляє — зазвичай зворотний проксі або розширення безпеки, яке надсилає власний заголовок фрейму. Відкриття переглядача в окремій вкладці це не зачіпає.",
+    unavailableTitle: "3D-перегляд недоступний",
+    unavailableBody: "Bambuddy не зміг віддати файли переглядача G-code. Зазвичай це означає, що в установці бракує каталогу gcode_viewer; журнал запуску також про це повідомляє.",
+    problemDetail: "Повідомлення сервера: {{detail}}",
+    openInNewTab: "Відкрити переглядач у новій вкладці",
     back: "Назад",
     backToArchives: "Назад до друку архівів",
     backToFiles: "Назад до файлового менеджера",

+ 6 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -6886,6 +6886,12 @@ export default {
     },
   },
   gcodeViewer: {
+    blockedTitle: '无法嵌入 3D 预览',
+    blockedBody: 'Bambuddy 允许此页面内嵌显示 G-code 查看器,但浏览器与 Bambuddy 之间的某个环节拒绝了它 — 通常是发送自有框架标头的反向代理或安全插件。在独立标签页中打开查看器不受影响。',
+    unavailableTitle: '3D 预览不可用',
+    unavailableBody: 'Bambuddy 无法提供 G-code 查看器的文件。这通常表示安装中缺少 gcode_viewer 目录;启动日志中也会有相应记录。',
+    problemDetail: '服务器报告:{{detail}}',
+    openInNewTab: '在新标签页中打开查看器',
     back: '返回',
     backToArchives: '返回打印归档',
     backToFiles: '返回文件管理器',

+ 6 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -6886,6 +6886,12 @@ export default {
     },
   },
   gcodeViewer: {
+    blockedTitle: '無法嵌入 3D 預覽',
+    blockedBody: 'Bambuddy 允許此頁面內嵌顯示 G-code 檢視器,但瀏覽器與 Bambuddy 之間的某個環節拒絕了它 — 通常是傳送自有框架標頭的反向代理或安全外掛。在獨立分頁中開啟檢視器不受影響。',
+    unavailableTitle: '3D 預覽無法使用',
+    unavailableBody: 'Bambuddy 無法提供 G-code 檢視器的檔案。這通常表示安裝中缺少 gcode_viewer 目錄;啟動記錄中也會有相應紀錄。',
+    problemDetail: '伺服器回報:{{detail}}',
+    openInNewTab: '在新分頁中開啟檢視器',
     back: '返回',
     backToArchives: '返回列印歸檔',
     backToFiles: '返回檔案管理器',

+ 92 - 19
frontend/src/pages/GCodeViewerPage.tsx

@@ -1,16 +1,62 @@
+import { useEffect, useState } from 'react';
 import { useNavigate, useSearchParams } from 'react-router-dom';
-import { ArrowLeft } from 'lucide-react';
+import { ArrowLeft, ExternalLink, ShieldAlert } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
+import { findFramingRefusal, type FrameProblem } from '../utils/framing';
 
 export function GCodeViewerPage() {
   const navigate = useNavigate();
   const [searchParams] = useSearchParams();
   const { t } = useTranslation();
+  const [problem, setProblem] = useState<FrameProblem | null>(null);
+
+  // Forward the outer page's query string (e.g. ?archive=82) to the iframe so
+  // the adapter inside can pick up the archive to load. The iframe itself must
+  // keep the trailing slash on /gcode-viewer/ so it hits the raw-viewer route;
+  // the outer SPA URL uses no trailing slash so a reload falls through to the
+  // SPA catch-all and keeps the Bambuddy layout shell.
+  const iframeSrc = `/gcode-viewer/${window.location.search}`;
+  const embedded = window !== window.top;
+
+  // A frame refused by X-Frame-Options / frame-ancestors still fires `onLoad` —
+  // the browser commits its own "refused to connect" error page — so the iframe
+  // itself cannot tell us anything. Ask for the same URL directly instead: it is
+  // same-origin, so every response header is readable, and it travels through
+  // whatever proxy the browser reaches Bambuddy by. The iframe is rendered
+  // straight away regardless and only replaced if this comes back refusing,
+  // which keeps the working case exactly as fast as before.
+  useEffect(() => {
+    if (embedded) return;
+    const controller = new AbortController();
+    (async () => {
+      try {
+        const response = await fetch(iframeSrc, {
+          credentials: 'same-origin',
+          signal: controller.signal,
+        });
+        if (!response.ok) {
+          setProblem({ kind: 'unavailable', detail: `HTTP ${response.status}` });
+          return;
+        }
+        const refusal = findFramingRefusal(
+          response.headers.get('x-frame-options'),
+          response.headers.get('content-security-policy'),
+          window.location.origin,
+        );
+        if (refusal) setProblem({ kind: 'blocked', detail: refusal });
+      } catch {
+        // Aborted, offline, or the probe itself was blocked. The iframe stays;
+        // guessing at a cause we have no evidence for would be worse than the
+        // browser's own error page.
+      }
+    })();
+    return () => controller.abort();
+  }, [iframeSrc, embedded]);
 
   // Safety guard: if this React app is itself inside an iframe (e.g. the
   // StaticFiles mount isn't registered and serve_spa returned us here),
   // don't render another iframe — that would create an infinite loop.
-  if (window !== window.top) {
+  if (embedded) {
     return (
       <div style={{ padding: 32, color: '#f88' }}>
         GCode viewer static files not found. Check that the{' '}
@@ -39,13 +85,6 @@ export function GCodeViewerPage() {
     }
   };
 
-  // Forward the outer page's query string (e.g. ?archive=82) to the iframe so
-  // the adapter inside can pick up the archive to load. The iframe itself must
-  // keep the trailing slash on /gcode-viewer/ so it hits the raw-viewer route;
-  // the outer SPA URL uses no trailing slash so a reload falls through to the
-  // SPA catch-all and keeps the Bambuddy layout shell.
-  const iframeSrc = `/gcode-viewer/${window.location.search}`;
-
   return (
     // h-14 (3.5 rem) is the fixed header height defined in Layout.tsx.
     // Subtracting it prevents a double scrollbar inside the layout shell.
@@ -60,16 +99,50 @@ export function GCodeViewerPage() {
           {backLabel}
         </button>
       </div>
-      <iframe
-        src={iframeSrc}
-        title="GCode Viewer"
-        style={{
-          display: 'block',
-          width: '100%',
-          flex: 1,
-          border: 'none',
-        }}
-      />
+      {problem ? (
+        <div className="flex-1 overflow-y-auto p-6">
+          <div role="alert" className="max-w-2xl mx-auto p-4 rounded-lg border border-amber-500/40 bg-amber-500/10">
+            <div className="flex items-start gap-3">
+              <ShieldAlert className="w-5 h-5 text-amber-400 shrink-0 mt-0.5" />
+              <div className="min-w-0">
+                <p className="text-sm font-medium text-amber-300">
+                  {problem.kind === 'blocked'
+                    ? t('gcodeViewer.blockedTitle')
+                    : t('gcodeViewer.unavailableTitle')}
+                </p>
+                <p className="text-xs text-bambu-gray mt-1">
+                  {problem.kind === 'blocked'
+                    ? t('gcodeViewer.blockedBody')
+                    : t('gcodeViewer.unavailableBody')}
+                </p>
+                <p className="text-xs text-bambu-gray mt-2 font-mono break-all">
+                  {t('gcodeViewer.problemDetail', { detail: problem.detail })}
+                </p>
+                <a
+                  href={iframeSrc}
+                  target="_blank"
+                  rel="noreferrer"
+                  className="mt-3 inline-flex items-center gap-1 text-xs text-bambu-green hover:underline"
+                >
+                  <ExternalLink className="w-3 h-3" />
+                  {t('gcodeViewer.openInNewTab')}
+                </a>
+              </div>
+            </div>
+          </div>
+        </div>
+      ) : (
+        <iframe
+          src={iframeSrc}
+          title="GCode Viewer"
+          style={{
+            display: 'block',
+            width: '100%',
+            flex: 1,
+            border: 'none',
+          }}
+        />
+      )}
     </div>
   );
 }

+ 65 - 0
frontend/src/utils/framing.ts

@@ -0,0 +1,65 @@
+/**
+ * Reading a response's framing headers, for the embedded G-code viewer (#2787).
+ *
+ * The viewer is the only part of Bambuddy that embeds a Bambuddy page in a
+ * frame, so it is the only part a proxy-added framing header can break — and it
+ * breaks with the browser's own error page, which says nothing about what was
+ * refused or by whom.
+ */
+
+/** Why the viewer could not be shown inline, with the evidence that says so. */
+export type FrameProblem =
+  | { kind: 'blocked'; detail: string }
+  | { kind: 'unavailable'; detail: string };
+
+/**
+ * Decide whether a response's framing headers allow `origin` to embed it.
+ *
+ * Returns the offending header verbatim when embedding is refused, or null when
+ * it is allowed. Bambuddy's own headers always allow it (`frame-ancestors
+ * 'self'` plus `X-Frame-Options: SAMEORIGIN`, set in `main.py`), so a refusal
+ * means something between the browser and Bambuddy — a reverse proxy, a
+ * security add-on, an auth gateway — added a stricter one.
+ *
+ * `frame-ancestors` wins outright when present: per CSP the browser must ignore
+ * `X-Frame-Options` entirely in that case, so reading both would blame a
+ * proxy-added `X-Frame-Options: DENY` the browser never consulted. Multiple CSP
+ * headers are *intersected*, and `fetch` joins them into one comma-separated
+ * string, so every `frame-ancestors` occurrence has to permit us — not just the
+ * first one.
+ */
+export function findFramingRefusal(
+  xFrameOptions: string | null,
+  contentSecurityPolicy: string | null,
+  origin: string,
+): string | null {
+  const csp = contentSecurityPolicy ?? '';
+  const directives = [...csp.matchAll(/(?:^|[;,])\s*frame-ancestors\s+([^;,]*)/gi)];
+  if (directives.length > 0) {
+    const self = origin.toLowerCase();
+    for (const [, raw] of directives) {
+      const value = raw.trim();
+      const sources = value.toLowerCase().split(/\s+/).filter(Boolean);
+      const permitsUs = sources.some(
+        (source) =>
+          source === '*' ||
+          source === "'self'" ||
+          source === self ||
+          source === self.replace(/^https?:\/\//, ''),
+      );
+      if (!permitsUs) return `Content-Security-Policy: frame-ancestors ${value}`;
+    }
+    return null;
+  }
+
+  // No frame-ancestors anywhere: the legacy header governs. Anything other than
+  // a single SAMEORIGIN refuses us — DENY, ALLOW-FROM, or the conflicting
+  // "SAMEORIGIN, DENY" that appears when a proxy appends a second copy.
+  const legacy = (xFrameOptions ?? '')
+    .split(',')
+    .map((value) => value.trim().toLowerCase())
+    .filter(Boolean);
+  if (legacy.length === 0) return null;
+  if (legacy.length === 1 && legacy[0] === 'sameorigin') return null;
+  return `X-Frame-Options: ${xFrameOptions}`;
+}

Plik diff jest za duży
+ 0 - 0
static/assets/index-CDkM7wuh.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-JwlPvVqj.js"></script>
+    <script type="module" crossorigin src="/assets/index-CDkM7wuh.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-ud1tvgv1.css">
   </head>
   <body>

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików