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:
2026-08-31 02:07:18 +01:00
co-authored by Claude Opus 5
parent 6f8dc83984
commit 82862ed340
3 changed files with 82 additions and 154 deletions
-121
View File
@@ -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') ?? '';