From 595dc5844a03f83869831caf68888b2802a64587 Mon Sep 17 00:00:00 2001 From: maziggy Date: Sat, 8 Aug 2026 10:53:54 +0200 Subject: [PATCH] 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 " 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. --- CHANGELOG.md | 1 + .../__tests__/pages/GCodeViewerPage.test.tsx | 120 ++++++++++++++++++ frontend/src/i18n/locales/de.ts | 6 + frontend/src/i18n/locales/en.ts | 6 + frontend/src/i18n/locales/es.ts | 6 + frontend/src/i18n/locales/fr.ts | 6 + frontend/src/i18n/locales/it.ts | 6 + frontend/src/i18n/locales/ja.ts | 6 + frontend/src/i18n/locales/ko.ts | 6 + frontend/src/i18n/locales/pt-BR.ts | 6 + frontend/src/i18n/locales/ru.ts | 6 + frontend/src/i18n/locales/tr.ts | 6 + frontend/src/i18n/locales/uk.ts | 6 + frontend/src/i18n/locales/zh-CN.ts | 6 + frontend/src/i18n/locales/zh-TW.ts | 6 + frontend/src/pages/GCodeViewerPage.tsx | 111 +++++++++++++--- frontend/src/utils/framing.ts | 65 ++++++++++ .../{index-JwlPvVqj.js => index-CDkM7wuh.js} | 32 ++--- static/index.html | 2 +- 19 files changed, 373 insertions(+), 36 deletions(-) create mode 100644 frontend/src/__tests__/pages/GCodeViewerPage.test.tsx create mode 100644 frontend/src/utils/framing.ts rename static/assets/{index-JwlPvVqj.js => index-CDkM7wuh.js} (75%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d5ba3c12..c92b22778 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ All notable changes to Bambuddy will be documented in this file. - **Error and warning toasts now stay up twice as long** — Every pop-up notification disappeared after three seconds regardless of what it said. That is about right for "Settings saved", which confirms something you just did and is skimmed rather than read, but errors and warnings are a different kind of message: they carry a reason, often one relayed from the printer or the backend, and they run to a couple of lines. Three seconds was not long enough to finish reading one, and a missed error message is gone for good — there is no notification history to go back to. Errors and warnings now hold for six seconds. Success and informational toasts keep the three-second default, so the common case of clicking something and seeing it confirmed is unchanged, and the close button and the manual dismiss work exactly as before on all of them. The background print-dispatch toast is unaffected: it stays up while it has work in progress and clears itself shortly after the last job settles. Covered by frontend tests. ### Fixed +- **A 3D preview that a proxy refuses to embed now says so, instead of leaving you with the browser's error page (#2787, reported by @trickfilm)** — A reporter uploaded an STL, sliced it in Bambuddy, and found that the sliced file's **3D Preview** showed a frowny icon and "*hostname* refused to connect" — while the STL's own preview worked. The split is exactly where the two previews part company: an STL or a source 3MF is drawn in the page itself, but a sliced file opens the embedded G-code viewer, which lives in an iframe. Bambuddy's own headers allow that frame — it is same-origin, and both the policy and the legacy header say so — which means a refusal comes from something between the browser and Bambuddy, typically a reverse proxy or security add-on sending its own framing header. None of that was visible: the browser drew its error page inside Bambuddy's layout, and nothing said what had been refused, by whom, or that the viewer opens perfectly well in a tab of its own. The page now asks for the viewer directly, reads the framing headers off the reply, and when they refuse the frame it replaces it with an explanation naming the exact header — so an operator can go and find the rule in their proxy configuration — plus a link that opens the viewer in its own tab, which no framing header applies to. A viewer that is missing from the installation is reported the same way rather than as raw JSON inside the frame. When the check cannot reach a verdict the frame is left exactly as it was, because a guess at a cause we cannot see would be worse than the browser's own page. - **"We need you to confirm you are not a robot" on Bambu Cloud sign-in is now explained instead of just repeated (#2790)** — A reporter tried to connect to Bambu Cloud and got that sentence as an error toast, with no CAPTCHA anywhere to answer and nothing to click. It is Bambu's sentence, not Bambuddy's: their anti-abuse layer had flagged the network and was answering the sign-in with `HTTP 418` and a challenge body. Bambuddy had no idea what that was — the reply is well-formed JSON, so the existing Cloudflare-interstitial detector never fired on it, and the generic error path simply lifted Bambu's text out and showed it. The user was left to conclude their password was wrong, or that Bambuddy was broken; four sign-in attempts inside eighteen seconds appear in their log, each one more evidence for the thing that had flagged them. Bambuddy now recognises the challenge by its shape rather than by its wording, and the login form says what is actually happening: your email and password are not the problem, the block is tied to your public IP address rather than to your account, it normally clears by itself within a few hours, and retrying repeatedly extends it. The panel stays on screen — a toast is the wrong shape for a problem you cannot act on — and carries a one-click route to **Use access token instead**, which is the one way to connect while it lasts, since the token path does not go through the challenged endpoint. Sign-in requests are held back for five minutes after a challenge so Bambuddy stops making it worse, tracked per region and per host so a challenge on the API host cannot strand somebody halfway through a two-factor sign-in on the web one. MakerWorld imports, which meet the same challenge from the same edge, now share the detection instead of requiring the literal word "robot" in the error text. The System Health scanner has a matching signature, so the next support bundle from an affected install names the problem instead of coming back empty. The scanner's advice for a failed FTPS handshake was corrected at the same time: it still blamed firewalls and firmware, which last release's investigation (#2780) ruled out — it is the printer's own file service wedging, and the fix is to restart the printer. - **Buttons show a pointer cursor again, and the AMS slot menu stops reshuffling itself (#2791, reported by @AnthonyGrondin)** — Hovering most of Bambuddy gave you an arrow, not the little hand that says "this does something". Not everywhere, though, which is what made it read as sloppiness rather than a bug: the update pill was inert while the buttons beside it were fine, a bed or nozzle tile responded but the history-graph button tucked into its corner did not, and dropdowns went either way with no pattern behind it. The pattern was there. Tailwind v3 gave every button a pointer cursor as part of its baseline styling; Tailwind v4, which Bambuddy has used since the interface was built, deliberately dropped that rule to match what browsers do on their own — and browsers give a button the ordinary arrow. From then on a button only looked clickable if whoever wrote it had said so by hand. Fifteen of about nine hundred and thirty had. None of the hundred and forty-nine dropdowns had, and of the checkboxes and radio buttons, nineteen out of a hundred and thirty. The rule is now restored once, centrally, rather than pinned onto individual buttons for the rest of the project's life: buttons, dropdowns, checkboxes, radio buttons, disclosure arrows and anything explicitly marked up as a button all point again. It sits at the bottom of the styling order, so the places that deliberately show a "not allowed" cursor on a disabled control still win, and a control that is genuinely disabled is left alone. Modal backgrounds are deliberately untouched: clicking one closes the dialog, but a full-screen sheet that claims to be a button is worse than one that says nothing. Separately, and behind the same report: the menu on an AMS slot listed **Configure** above **Assign Spool** on an empty slot and the other way round on a filled one, because the two are drawn by different code that had quietly drifted apart — both now lead with the spool action, and a test pins each side so they cannot drift again. The buttons in that menu centred their own text, which left their icons in a ragged column; they are aligned to the left edge now. Their hover shading was a ten-percent step that was very hard to see, and is now twice that. And the star on **Add to favourites** turns yellow as you hover it, so it previews what clicking will do. - **A job queued to "Any {model}" now switches a printer on, like a job queued to one printer always has (#2786, reported by @TheUltimateC0der)** — Queue a print against a printer class -- **Any X1C**, or a Slicer Pipeline whose target type is **Printer class** -- with every printer of that class switched off at the wall, and nothing happened. The job sat pending, no smart plug was touched, and the only way out was to edit the item onto a specific printer, at which point Bambuddy powered that printer on immediately. The reporter's log holds that comparison exactly: thirteen minutes of the job being polled and passed over, then the edit, then a power-on on the very next check -- same job, same plug, same Auto Power On setting. Powering a printer on had only ever been written into the branch that handles a job pinned to one printer; the branch that picks a printer by model listed an offline one as a reason to keep waiting and never looked at its plugs. It does now. It also picks with a little more care than the older branch: a printer waiting for a plate-clear acknowledgment is passed over, because switching it on only leaves it idling behind that gate -- which is what the reporter's own log shows happening for the eighty minutes after their manual edit -- and a printer whose class the file cannot legally run on is never switched on at all. One printer comes up per queue check rather than a whole shelf at once, so several queued jobs wake several printers over the following minutes. Finally, a printer that is off and has no enabled Auto Power On plug now says that in the job's waiting reason instead of hiding behind the same "Offline" as the printers Bambuddy can bring back itself -- that distinction was the first question the reporter had to be asked. diff --git a/frontend/src/__tests__/pages/GCodeViewerPage.test.tsx b/frontend/src/__tests__/pages/GCodeViewerPage.test.tsx new file mode 100644 index 000000000..ede612a0f --- /dev/null +++ b/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 = {}) { + 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(); + + 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(); + + 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(); + + 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(); + + expect(await screen.findByTitle('GCode Viewer')).toBeInTheDocument(); + await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument()); + expect(screen.getByTitle('GCode Viewer')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index 7d31daa71..c57294ad9 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/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', diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 82fdac771..a8177cf65 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/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', diff --git a/frontend/src/i18n/locales/es.ts b/frontend/src/i18n/locales/es.ts index e9c659adc..adad81d1b 100644 --- a/frontend/src/i18n/locales/es.ts +++ b/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', diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index 668d70317..9f513fed7 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/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', diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index 0f959e0ad..8d17e397d 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/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', diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index 19f3cb8b1..b8eece6aa 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/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: 'ファイル管理に戻る', diff --git a/frontend/src/i18n/locales/ko.ts b/frontend/src/i18n/locales/ko.ts index 31a6f95b3..de484a4f6 100644 --- a/frontend/src/i18n/locales/ko.ts +++ b/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: '파일 관리자로 돌아가기' diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts index 01cb7b6ed..1e291de03 100644 --- a/frontend/src/i18n/locales/pt-BR.ts +++ b/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', diff --git a/frontend/src/i18n/locales/ru.ts b/frontend/src/i18n/locales/ru.ts index 76ab8288e..4f1d4aafe 100644 --- a/frontend/src/i18n/locales/ru.ts +++ b/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: "Вернуться в файловый менеджер", diff --git a/frontend/src/i18n/locales/tr.ts b/frontend/src/i18n/locales/tr.ts index fa4809dfd..95407427b 100644 --- a/frontend/src/i18n/locales/tr.ts +++ b/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', diff --git a/frontend/src/i18n/locales/uk.ts b/frontend/src/i18n/locales/uk.ts index 416b6f232..2bbaae06a 100644 --- a/frontend/src/i18n/locales/uk.ts +++ b/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: "Назад до файлового менеджера", diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 87b7fab52..08fe77fc2 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/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: '返回文件管理器', diff --git a/frontend/src/i18n/locales/zh-TW.ts b/frontend/src/i18n/locales/zh-TW.ts index a5b5d7d21..cd2135920 100644 --- a/frontend/src/i18n/locales/zh-TW.ts +++ b/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: '返回檔案管理器', diff --git a/frontend/src/pages/GCodeViewerPage.tsx b/frontend/src/pages/GCodeViewerPage.tsx index bc3294acb..2739479fd 100644 --- a/frontend/src/pages/GCodeViewerPage.tsx +++ b/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(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 (
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}
-