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:
-121
@@ -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') ?? '';
|
||||
|
||||
@@ -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