diff --git a/api/router.ts b/api/router.ts index 35413ea..51f7f24 100644 --- a/api/router.ts +++ b/api/router.ts @@ -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: 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 ``. 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 `` 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') ?? ''; diff --git a/web/sections/LayoutPanel.tsx b/web/sections/LayoutPanel.tsx index 40d1a61..15b33c1 100644 --- a/web/sections/LayoutPanel.tsx +++ b/web/sections/LayoutPanel.tsx @@ -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(null); const [draft, setDraft] = useState(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(null); const [error, setError] = useState(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) => 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. */} -
-
-