diff --git a/api/router.ts b/api/router.ts index 51f7f24..e0606b7 100644 --- a/api/router.ts +++ b/api/router.ts @@ -366,6 +366,96 @@ router.get('/original/*', async (ctx) => { }); }); +/** + * The site's own pages, proxied through Officer so the admin can embed them. + * + * ── Why proxy rather than link ── + * + * The admin has to show the menu and the footer as they ACTUALLY look, and the only honest way is to + * render the real page. That needs a URL the BROWSER can reach, and everything this plugin knows is + * loopback: the site answers on 127.0.0.1: and nothing else. There is no public hostname + * stored anywhere, and adding one would be a second fact about where the site lives, kept in step by + * hand with the one that already exists. + * + * So the request goes the way every other request in this plugin goes — server-side, over loopback — + * and the browser talks only to Officer. Same origin, which also means the day the page builder wants + * to draw handles on top of the real page, it can. A cross-origin iframe could never have. + * + * ── The rewrite, and its exact limits ── + * + * The site writes root-relative URLs: `/styles.css`, `/derived/…`, `/images/…`. Inside an iframe at + * `/fofos-admin/preview/layout` those resolve against OFFICER's root, where they do not exist. A + * `` tag does not help — it changes relative URLs and leaves root-relative ones alone, which is + * precisely the set that is wrong. + * + * So they are rewritten to sit under this prefix. Narrowly: only `href`, `src`, `srcset` and CSS + * `url()`, only when the value starts with a single `/`, and never `//` — that is protocol-relative + * and means another host entirely. Anything else is passed through untouched. + * + * This is a preview of a development server, not a general-purpose proxy. It is GET-only, it refuses + * anything but the site's own origin, and it exists so one panel can show one truth. + */ +router.get('/preview/*', async (ctx) => { + const credentials = await readCredentials(); + if (!credentials) return ctx.text('The development server is not configured.', 503); + + const url = new URL(ctx.req.url); + const after = url.pathname.slice(url.pathname.indexOf('/preview/') + '/preview/'.length); + const target = `${credentials.url}/${after}${url.search}`; + + let response: Response; + try { + response = await fetch(target, { headers: { accept: ctx.req.header('accept') ?? '*/*' } }); + } catch { + // The usual case by far: the development server is stopped. Said plainly, because "failed to + // fetch" in an iframe is indistinguishable from the panel being broken. + return ctx.text('The development server is not running.', 502); + } + + const type = response.headers.get('content-type') ?? 'application/octet-stream'; + + // Only HTML carries URLs that need moving. Everything else — stylesheets, fonts, images — is + // returned as it came, because rewriting bytes we do not understand is how a proxy corrupts a font. + if (!type.includes('text/html')) { + return new Response(await response.arrayBuffer(), { + status: response.status, + headers: { 'Content-Type': type, 'Cache-Control': 'private, max-age=60' }, + }); + } + + const html = await response.text(); + + return new Response(rebase(html, `${PREVIEW_PREFIX}/`), { + status: response.status, + headers: { + 'Content-Type': 'text/html; charset=utf-8', + // Never cached. The whole value of this is that it shows what the site looks like NOW. + 'Cache-Control': 'no-store', + }, + }); +}); + +/** Where the proxied site is mounted, as the browser sees it. */ +const PREVIEW_PREFIX = '/fofos-admin/preview'; + +/** + * Root-relative URLs, moved under a prefix. + * + * `//` is left alone deliberately — a protocol-relative URL names another host, and prefixing one + * would turn `//fonts.example.com/x` into a path on this server. That is the single case where a + * naive `="/` match is actively wrong, which is why the pattern excludes it rather than trusting that + * the site does not write any. + */ +function rebase(html: string, prefix: string): string { + return html + .replace(/\b(href|src)="\/(?!\/)/g, `$1="${prefix}`) + // srcset is a comma-separated list, so each candidate is rewritten rather than the attribute. + .replace(/\bsrcset="([^"]*)"/g, (_whole, list: string) => + `srcset="${list.replace(/(^|,\s*)\/(?!\/)/g, `$1${prefix}`)}"`, + ) + .replace(/url\(\/(?!\/)/g, `url(${prefix}`); +} + router.get('/_config/media/files', async (ctx) => { const user = ctx.get('user'); const scope = ctx.req.query('path') ?? ''; diff --git a/web/sections/LayoutPanel.tsx b/web/sections/LayoutPanel.tsx index 5a910e7..c0a4dab 100644 --- a/web/sections/LayoutPanel.tsx +++ b/web/sections/LayoutPanel.tsx @@ -150,61 +150,30 @@ export const LayoutPanel = () => { {saved &&

Saved

} {error &&

{error}

} - {/* ── The sketch ───────────────────────────────────────────────────────────────────────── */} + {/* + The REAL page, embedded — not a drawing of it. + + It was a React sketch: a centred row of labels, a body, a band at the bottom. It looked + plausible and it was wrong, because the site's header has a logo hanging past a rule and a nav + that sits in the content column. Worse, it was a second implementation of the layout, so it + would have gone on being wrong differently every time either side changed. + + This is `/layout` on the development server — the site's own shell with no page in it — proxied + through Officer so it is same origin. See the preview route in api/router.ts. + */}
-
- {/* The menu, in the order it will appear, in the face chosen for it. */} -
- {/* The menu when there is one, the site's own pages when there is not — the same fallback - the page itself applies, so the sketch never shows a menu the site would not draw. */} - {(menu.length ? menu : draft.pages.map((page) => ({ label: page.label, href: page.path }))).map( - (item, index) => ( - {item.label} - ), - )} - {!menu.length && !draft.pages.length && No menu yet} -
- - {/* The body. Deliberately empty: what goes here is the page, and the page is what this - section is NOT about. Its only job is to give the two bands something between them. */} -
-

The page

-

- Whatever is built for it goes here -

-
- - {/* The footer, with the columns as they will fall. */} -
- {columns.length ? ( -
- {columns.map((column, index) => ( -
- {column.title && ( -

- {column.title} -

- )} - {column.links.map((link, i) => ( -

- {link.label} -

- ))} -
- ))} -
- ) : ( -

No footer columns yet

- )} - {draft.footer_note &&

{draft.footer_note}

} -
+
+