diff --git a/api/router.ts b/api/router.ts index e0606b7..35413ea 100644 --- a/api/router.ts +++ b/api/router.ts @@ -381,19 +381,18 @@ router.get('/original/*', async (ctx) => { * 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 rewrite, and its exact limits ── + * ── The prefix is `/api/fofos-admin/preview`, and that is load-bearing ── * - * The site writes root-relative URLs: `/styles.css`, `/derived/…`, `/images/…`. Inside an iframe at - * `/fofos-admin/preview/layout` those resolve against OFFICER's root, where they do not exist. A - * `` tag does not help — it changes relative URLs and leaves root-relative ones alone, which is - * precisely the set that is wrong. + * `/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. * - * So they are rewritten to sit under this prefix. Narrowly: only `href`, `src`, `srcset` and CSS - * `url()`, only when the value starts with a single `/`, and never `//` — that is protocol-relative - * and means another host entirely. Anything else is passed through untouched. + * ── Every rewritten URL carries the token ── * - * This is a preview of a development server, not a general-purpose proxy. It is GET-only, it refuses - * anything but the site's own origin, and it exists so one panel can show one truth. + * 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(); @@ -401,14 +400,22 @@ router.get('/preview/*', async (ctx) => { const url = new URL(ctx.req.url); const after = url.pathname.slice(url.pathname.indexOf('/preview/') + '/preview/'.length); - const target = `${credentials.url}/${after}${url.search}`; + + // 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" in an iframe is indistinguishable from the panel being broken. + // fetch" inside an iframe is indistinguishable from the panel being broken. return ctx.text('The development server is not running.', 502); } @@ -423,9 +430,7 @@ router.get('/preview/*', async (ctx) => { }); } - const html = await response.text(); - - return new Response(rebase(html, `${PREVIEW_PREFIX}/`), { + return new Response(rebase(await response.text(), `${PREVIEW_PREFIX}/`, token), { status: response.status, headers: { 'Content-Type': 'text/html; charset=utf-8', @@ -435,25 +440,51 @@ router.get('/preview/*', async (ctx) => { }); }); -/** Where the proxied site is mounted, as the browser sees it. */ -const PREVIEW_PREFIX = '/fofos-admin/preview'; +/** + * 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 a prefix. + * Root-relative URLs, moved under the prefix and given the token. * - * `//` is left alone deliberately — a protocol-relative URL names another host, and prefixing one - * would turn `//fonts.example.com/x` into a path on this server. That is the single case where a - * naive `="/` match is actively wrong, which is why the pattern excludes it rather than trusting that - * the site does not write any. + * 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): string { +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, `$1="${prefix}`) - // srcset is a comma-separated list, so each candidate is rewritten rather than the attribute. - .replace(/\bsrcset="([^"]*)"/g, (_whole, list: string) => - `srcset="${list.replace(/(^|,\s*)\/(?!\/)/g, `$1${prefix}`)}"`, - ) - .replace(/url\(\/(?!\/)/g, `url(${prefix}`); + .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) => { diff --git a/web/sections/LayoutPanel.tsx b/web/sections/LayoutPanel.tsx index c0a4dab..40d1a61 100644 --- a/web/sections/LayoutPanel.tsx +++ b/web/sections/LayoutPanel.tsx @@ -6,6 +6,7 @@ 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. @@ -164,7 +165,7 @@ export const LayoutPanel = () => {