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
-121
View File
@@ -366,127 +366,6 @@ 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 prefix is `/api/fofos-admin/preview`, and that is load-bearing ──
*
* `/fofos-admin` is a FRONTEND route — Officer's single-page app owns it, and anything under it that
* the router does not claim is answered with the app's own index.html. An iframe pointed there is
* served Officer, inside Officer, which is exactly what it did the first time.
*
* ── Every rewritten URL carries the token ──
*
* An iframe cannot send an Authorization header, and neither can the stylesheet, the fonts or the
* images it goes on to request. They are all Officer requests now, so all of them need `?token=` —
* the same reason thumbnail.ts puts one on every `<img>`. The token comes from the iframe's own query
* string and is propagated into everything the page asks for next.
*/
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);
// The token travels on to the page's own requests, and must not be forwarded to the site — it is
// Officer's, and the site has never heard of it.
const search = new URLSearchParams(url.search);
const token = search.get('token') ?? '';
search.delete('token');
const query = search.toString();
const target = `${credentials.url}/${after}${query ? `?${query}` : ''}`;
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" inside 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' },
});
}
return new Response(rebase(await response.text(), `${PREVIEW_PREFIX}/`, token), {
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.
*
* `/api/...` and not `/fofos-admin/...`, because the second is the single-page app's own route and
* anything under it that no router claims is answered with index.html. See the note above — this was
* wrong once and the symptom was Officer rendering inside Officer.
*/
const PREVIEW_PREFIX = '/api/fofos-admin/preview';
/**
* Root-relative URLs, moved under the prefix and given the token.
*
* A `<base>` tag would have been simpler and does not work: it moves RELATIVE urls and leaves
* root-relative ones exactly where they are, which is precisely the set that is wrong here.
*
* `//` is excluded deliberately. That is protocol-relative and names another host, so prefixing one
* would turn `//fonts.example.com/x` into a path on this server. It is the single case where a naive
* match is actively wrong rather than merely useless.
*/
function rebase(html: string, prefix: string, token: string): string {
const move = (value: string): string => {
if (!value.startsWith('/') || value.startsWith('//')) return value;
const moved = `${prefix}${value.slice(1)}`;
if (!token) return moved;
// Many of these already carry the site's own `?v=` cache stamp, so the separator is decided per
// URL rather than assumed.
return `${moved}${moved.includes('?') ? '&' : '?'}token=${encodeURIComponent(token)}`;
};
return html
.replace(/\b(href|src)="([^"]*)"/g, (_whole, attribute: string, value: string) => `${attribute}="${move(value)}"`)
// A srcset is a comma-separated list of "url descriptor" pairs, so each candidate is moved rather
// than the attribute as a whole.
.replace(/\bsrcset="([^"]*)"/g, (_whole, list: string) => {
const moved = list
.split(',')
.map((candidate) => {
const parts = candidate.trim().split(/\s+/);
if (parts.length) parts[0] = move(parts[0] ?? '');
return parts.join(' ');
})
.join(', ');
return `srcset="${moved}"`;
})
.replace(/url\((\/[^)"']*)\)/g, (_whole, value: string) => `url(${move(value)})`);
}
router.get('/_config/media/files', async (ctx) => {
const user = ctx.get('user');
const scope = ctx.req.query('path') ?? '';
+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)}`;
};