Files
fofos-admin/web/thumbnail.ts
T
pastilhasandClaude Opus 5 6f8dc83984 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>
2026-08-31 02:01:18 +01:00

66 lines
3.2 KiB
TypeScript

import { getHeaders } from 'hooks/useClient';
/**
* A thumbnail URL for something under `originals/`, authenticated by `?token=`.
*
* ── Why the token is in the query ──
*
* An `<img>` cannot send an Authorization header. `user-middleware` and the permission gate both accept
* the token in the query string for exactly this, and the platform's own file browser does the same.
* Built without it first, and every picture 401s while the layout looks perfectly correct.
*
* ── Why this is a module ──
*
* It was copied into GalleriesPanel and again into RecordsPanel, and a third copy was about to go into
* MediaField. Three copies of a URL shape is three places to be wrong about the rung when the ladder
* changes — which it did, four times in a day.
*/
export const thumbUrl = (path: string, width: 256 | 320 | 480 | 640 | 960 | 1280 = 320): string => {
const token = getHeaders()['Authorization']?.replace('Bearer ', '') ?? '';
const encoded = path.split('/').map(encodeURIComponent).join('/');
return `/api/fofos-admin/derived/${encoded}/${width}.webp?token=${encodeURIComponent(token)}`;
};
/**
* A video's poster, for the same reason.
*
* A video has no width rungs — `poster-448.webp` and friends are cut from its still — so a slot showing
* one asks for the poster rather than a width that does not exist.
*/
export const posterUrl = (path: string): string => {
const token = getHeaders()['Authorization']?.replace('Bearer ', '') ?? '';
const encoded = path.split('/').map(encodeURIComponent).join('/');
return `/api/fofos-admin/derived/${encoded}/poster-512.webp?token=${encodeURIComponent(token)}`;
};
/**
* An ORIGINAL, rendered at a width, for something drawn at size.
*
* The derived ladder is right for a row thumbnail and wrong for a framing slot: that is most of a panel
* wide and can be zoomed on top of it, so a rung chosen in advance is a rung that is wrong. This asks
* for the width the slot actually draws, from the file that has every pixel.
*
* `width` is a hint the server clamps to 256..4096. Ask for what will be drawn — twice it, on a screen
* that is worth it — rather than a round number.
*/
export const originalUrl = (path: string, width: number): string => {
const token = getHeaders()['Authorization']?.replace('Bearer ', '') ?? '';
const encoded = path.split('/').map(encodeURIComponent).join('/');
return `/api/fofos-admin/original/${encoded}?width=${Math.round(width)}&token=${encodeURIComponent(token)}`;
};
/**
* The site's own page, proxied through Officer, for the layout preview's iframe.
*
* Here rather than in the panel for the reason this module exists at all: it is a URL shape carrying a
* token, and a second copy of one is a second place to be wrong about it.
*
* `/api/fofos-admin/...` and NOT `/fofos-admin/...`. The second is the single-page app's own route, so
* anything under it that no router claims is answered with Officer's index.html — an iframe pointed
* there renders Officer inside Officer, which is precisely what it did.
*/
export const previewUrl = (path: string): string => {
const token = getHeaders()['Authorization']?.replace('Bearer ', '') ?? '';
return `/api/fofos-admin/preview/${path.replace(/^\/+/, '')}?token=${encodeURIComponent(token)}`;
};