the preview is the real page, proxied

The panel drew its own header and footer in React. It looked plausible and it was wrong — the site's
header has a logo hanging past a rule and a nav in the content column — and worse, it was a second
implementation of the layout, so it would have gone on being wrong differently every time either side
moved.

So the admin embeds the site's own `/layout` route instead: the real shell with no page in it. A
designer changing the header in code changes what the admin shows, with no admin change at all. That
is the bridge.

Proxied through Officer rather than linked, because everything this plugin knows about where the site
lives is loopback — 127.0.0.1:<devPort> and nothing else. There is no public hostname stored anywhere
and adding one would be a second fact about the same thing, kept in step by hand. The request goes the
way every other request here goes, server-side, and the browser talks only to Officer.

Same origin, which is also what keeps the door open: the day the page builder wants to draw handles on
top of the real page, it can. A cross-origin iframe never could.

The rewrite is narrow and its limits are the interesting part. The site writes root-relative URLs and
those resolve against Officer's root inside the iframe; a <base> tag does not help, because it moves
relative URLs and leaves root-relative ones exactly where they were wrong. So href, src, srcset and
CSS url() are moved under the prefix — and `//` is excluded deliberately, because that names another
host and prefixing one would turn it into a path on this server.

Only HTML is rewritten. Stylesheets, fonts and images are returned as they came: rewriting bytes we do
not understand is how a proxy corrupts a font.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-31 01:55:45 +01:00
co-authored by Claude Opus 5
parent 09b914c430
commit b56c8375c3
2 changed files with 112 additions and 53 deletions
+90
View File
@@ -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:<devPort> 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
* `<base>` 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') ?? '';
+22 -53
View File
@@ -150,61 +150,30 @@ export const LayoutPanel = () => {
{saved && <p className="text-xs text-success">Saved</p>}
{error && <p className="text-xs text-destructive">{error}</p>}
{/* ── 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.
*/}
<section className="flex flex-col gap-2">
<div
className="overflow-hidden rounded-lg border border-border"
style={{ backgroundColor: bg, color: fg }}
>
{/* The menu, in the order it will appear, in the face chosen for it. */}
<div
className="flex flex-wrap items-center justify-center gap-x-5 gap-y-1 border-b px-4 py-3 text-[11px]"
style={{ borderColor: fg, fontFamily: draft.font_sub ?? undefined }}
>
{/* 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) => (
<span key={index}>{item.label}</span>
),
)}
{!menu.length && !draft.pages.length && <span className="opacity-40">No menu yet</span>}
</div>
{/* 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. */}
<div className="px-4 py-10 text-center" style={{ fontFamily: draft.font_title ?? undefined }}>
<p className="text-lg">The page</p>
<p className="mt-1 text-[11px] opacity-50" style={{ fontFamily: draft.font_body ?? undefined }}>
Whatever is built for it goes here
</p>
</div>
{/* The footer, with the columns as they will fall. */}
<div className="border-t px-4 py-4" style={{ borderColor: fg }}>
{columns.length ? (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
{columns.map((column, index) => (
<div key={index}>
{column.title && (
<p className="mb-1 text-[11px]" style={{ fontFamily: draft.font_sub ?? undefined }}>
{column.title}
</p>
)}
{column.links.map((link, i) => (
<p key={i} className="text-[11px] opacity-70">
{link.label}
</p>
))}
</div>
))}
</div>
) : (
<p className="text-[11px] opacity-40">No footer columns yet</p>
)}
{draft.footer_note && <p className="mt-3 text-[10px] opacity-60">{draft.footer_note}</p>}
</div>
<div className="overflow-hidden rounded-lg border border-border bg-white">
<iframe
src="/fofos-admin/preview/layout"
title="The site's layout"
className="h-[420px] w-full border-0"
/* Nothing is typed into it and nothing is submitted from it. It is a window. */
sandbox="allow-same-origin allow-scripts"
/>
</div>
<p className="text-[10px] text-muted-foreground">
The development server, live. Save to see a change here.
</p>
</section>
{/* ── Menu ─────────────────────────────────────────────────────────────────────────────── */}