the preview iframe was rendering Officer inside Officer

Two mistakes, and the second would have bitten immediately after the first was fixed.

The iframe pointed at /fofos-admin/preview/layout. But /fofos-admin is a FRONTEND route — the
single-page app owns it, and anything under it no router claims is answered with the app's own
index.html. So the iframe was served Officer. The plugin's API is mounted at /api/fofos-admin, which
every other call already knew: useClient defaults its base to /api, and thumbnail.ts spells the full
path out for <img> tags that cannot use it.

And an iframe cannot send an Authorization header. Neither can the stylesheet, the fonts or the images
the page goes on to request — those are all Officer requests now, so all of them need ?token=, the
same reason every <img> in this plugin carries one. The token comes off the iframe's own query string
and is propagated into every URL the rewrite touches, with the separator decided per URL because many
already carry the site's ?v= stamp.

It is stripped before the request reaches the site. The token is Officer's and the site has never
heard of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-31 02:01:18 +01:00
co-authored by Claude Opus 5
parent b56c8375c3
commit 6f8dc83984
3 changed files with 77 additions and 30 deletions
+60 -29
View File
@@ -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
* `<base>` 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 `<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();
@@ -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 `<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): 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) => {