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:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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)}`;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user