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 = () => {