use the platform's dev proxy instead of a worse copy of it

The preview went through a proxy written into this plugin. The platform already had one —
/api/dev-proxy/:port/* — and it is better in exactly the ways the copy was worst.

Authentication. An iframe navigation cannot carry an Authorization header, and the copy answered that
by putting a token on the URL and on every asset the page then requested. The platform's answer is a
short-lived cookie obtained by an ordinary authenticated request, and its own comments say why: a
token on an iframe URL is a guess about which key the browser stored it under. It had been tried
there and rejected before this plugin repeated it.

Coverage. The copy rewrote URLs with a regex over the markup, which catches only what is written into
the document. The platform injects a script that patches fetch, XMLHttpRequest and WebSocket, so URLs
the page BUILDS at runtime are moved as well — which is most of them in a modern dev server. It also
normalises `..`, forwards HMR, and has nineteen tests behind it.

So the plugin's proxy is gone, previewUrl with it, and the panel does what the platform's Preview
panel does: ask for the session, then mount the frame at the dev port.

No sandbox attribute, for the reason that panel gives. Without allow-same-origin a frame has an opaque
origin and no storage; with allow-same-origin and allow-scripts it can reach up and remove its own
sandbox attribute. Claiming a protection that does not hold is worse than not claiming it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-31 02:07:18 +01:00
co-authored by Claude Opus 5
parent 6f8dc83984
commit 82862ed340
3 changed files with 82 additions and 154 deletions
+82 -18
View File
@@ -6,7 +6,6 @@ import { useClient } from 'hooks/useClient';
import { readableError } from '../errors';
import { useFlash } from '../useFlash';
import { useSaveTarget } from '../SaveTarget';
import { previewUrl } from '../thumbnail';
/**
* The shell every page renders inside: the menu, the footer, the colours and the typefaces.
@@ -71,6 +70,13 @@ export const LayoutPanel = () => {
const [stored, setStored] = useState<Layout | null>(null);
const [draft, setDraft] = useState<Layout | null>(null);
const [busy, setBusy] = useState(false);
/*
Which port the site's development server is on — the plugin's own setting, not the site's.
Read here rather than passed in because the preview is the only thing that needs it, and the
servers endpoint is where the plugin already keeps it.
*/
const [devPort, setDevPort] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [saved, flashSaved] = useFlash();
@@ -91,6 +97,13 @@ export const LayoutPanel = () => {
void load();
}, [load]);
useEffect(() => {
void clientRef.current
.get<{ name: string; port: number | null }[]>('/fofos-admin/_config/servers')
.then((servers) => setDevPort(servers.find((server) => server.name === 'development')?.port ?? null))
.catch(() => setDevPort(null));
}, []);
const patch = (values: Partial<Layout>) => setDraft((was) => (was ? { ...was, ...values } : was));
/*
@@ -158,24 +171,8 @@ export const LayoutPanel = () => {
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 bg-white">
<iframe
src={previewUrl('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>
<LayoutPreview port={devPort} />
{/* ── Menu ─────────────────────────────────────────────────────────────────────────────── */}
<section className="flex flex-col gap-3 rounded-lg border border-border p-4">
@@ -472,3 +469,70 @@ const LinkRow = ({
</div>
);
};
/**
* The site's own `/layout` page, through the PLATFORM's dev proxy.
*
* ── Not a proxy of our own ──
*
* This plugin had one for an afternoon, and it was a worse copy of something that already existed.
* `/api/dev-proxy/:port/*` reaches any loopback port, rewrites the HTML, and — the part a hand-rolled
* version does not get right — injects a script that patches `fetch`, `XMLHttpRequest` and `WebSocket`,
* so URLs the page BUILDS at runtime are moved too. A regex over the markup only ever catches the ones
* written into it.
*
* ── The session call, and why it comes first ──
*
* An iframe navigation cannot carry an Authorization header. The proxy's answer is a short-lived cookie
* obtained by an ordinary authenticated request — so the panel asks for one with the API client, which
* already knows how to authenticate, and only then mounts the frame. Putting a token on the iframe URL
* was the other option and it is a guess about where the browser kept it; the platform tried that and
* says so in its own comments.
*/
const LayoutPreview = ({ port }: { port: number | null }) => {
const { get } = useClient();
const [ready, setReady] = useState(false);
const [failed, setFailed] = useState<string | null>(null);
useEffect(() => {
if (!port) return;
let alive = true;
setReady(false);
setFailed(null);
void get<{ ok: boolean }>('/dev-proxy/session')
.then(() => alive && setReady(true))
.catch((failure: unknown) => alive && setFailed(readableError(failure)));
return () => {
alive = false;
};
}, [port, get]);
const message =
!port ? 'No development server is configured — set its port under Administration.'
: failed ? `Could not authorise the preview: ${failed}`
: !ready ? 'Connecting…'
: null;
return (
<section className="flex flex-col gap-2">
<div className="overflow-hidden rounded-lg border border-border bg-white">
{message ? (
<p className="p-6 text-center text-xs text-muted-foreground">{message}</p>
) : (
/*
No `sandbox`, for the reason the platform's own Preview gives: a sandbox WITHOUT
allow-same-origin has an opaque origin and no storage, and one WITH allow-same-origin and
allow-scripts is worth nothing, because the frame can reach up and remove the attribute.
This is the owner's own development server on this machine.
*/
<iframe
src={`/api/dev-proxy/${port}/layout`}
title="The site's layout"
className="h-[420px] w-full border-0"
/>
)}
</div>
<p className="text-[10px] text-muted-foreground">The development server, live. Save to see a change here.</p>
</section>
);
};
-15
View File
@@ -48,18 +48,3 @@ export const originalUrl = (path: string, width: number): string => {
const encoded = path.split('/').map(encodeURIComponent).join('/');
return `/api/fofos-admin/original/${encoded}?width=${Math.round(width)}&token=${encodeURIComponent(token)}`;
};
/**
* The site's own page, proxied through Officer, for the layout preview's iframe.
*
* Here rather than in the panel for the reason this module exists at all: it is a URL shape carrying a
* token, and a second copy of one is a second place to be wrong about it.
*
* `/api/fofos-admin/...` and NOT `/fofos-admin/...`. The second is the single-page app's own route, so
* anything under it that no router claims is answered with Officer's index.html — an iframe pointed
* there renders Officer inside Officer, which is precisely what it did.
*/
export const previewUrl = (path: string): string => {
const token = getHeaders()['Authorization']?.replace('Bearer ', '') ?? '';
return `/api/fofos-admin/preview/${path.replace(/^\/+/, '')}?token=${encodeURIComponent(token)}`;
};