The dashboard heatmap reads its endpoint from data-heatmap-url:
document.querySelector('#user-heatmap').getAttribute('data-heatmap-url').
Unprefixed it fetched from the PLATFORM's origin, got the SPA's index.html and
reported "Unexpected token '<'" while parsing JSON — the last error left after
everything visible was rendering correctly.
data-url carries the language switcher's links and had the same problem.
Matched on the `url` suffix rather than by listing attribute names, because the
list is Gitea's to change.
Found by reading Gitea's own bundle rather than by reasoning about what it
probably does — which is what the three previous attempts at this error were.
206 lines
10 KiB
TypeScript
206 lines
10 KiB
TypeScript
// Making Gitea, which believes it lives at `/`, work when it is served under `/api/gitea/_web`.
|
|
//
|
|
// ── Why this is needed at all ──
|
|
//
|
|
// Gitea is server-rendered. Its pages are full of ROOT-ABSOLUTE links — `/user/login`, `/owner/repo`,
|
|
// `/assets/index.js` — and inside the frame those resolve against OFFICER'S origin. The first click
|
|
// leaves the proxy and lands on Officer's SPA, which knows nothing about them. The page renders
|
|
// perfectly and then falls apart on contact.
|
|
//
|
|
// ── Four different places a URL hides ──
|
|
//
|
|
// 1. Attributes in the HTML — `href`, `src`, `action`. Ordinary rewriting.
|
|
// 2. `window.config.appSubUrl`, which Gitea's own client JS builds URLs from. Setting this is the single
|
|
// highest-value line here: Gitea already supports running under a subpath, so telling it the subpath
|
|
// makes its JavaScript correct by itself rather than by interception.
|
|
// 3. `Location` headers. Gitea redirects constantly — after login, after every form post — and an
|
|
// unrewritten one navigates the frame out of the proxy.
|
|
// 4. Absolute URLs built from ROOT_URL, which point at the real host. Those escape to a third-party
|
|
// origin where the session cookie is dropped, so they are the most damaging of the four.
|
|
//
|
|
// ── What is deliberately NOT rewritten ──
|
|
//
|
|
// JavaScript bodies. A blanket rewrite of every absolute path inside a script hits strings that are not
|
|
// URLs — a regex, a route table, an i18n key — and corrupts the file it is trying to fix. `appSubUrl`
|
|
// removes the need.
|
|
//
|
|
// Clone URLs. `git clone` must keep working against the real host, and a clone URL rewritten into the
|
|
// proxy path is a URL that fails for everyone who copies it. They are left alone deliberately, which is
|
|
// why `rewriteAbsolute` only touches the HTML around them and never `.git` endpoints.
|
|
|
|
/** Collapse `..` before prefixing, or the browser normalises the prefix straight back off. */
|
|
const normalise = (path: string): string => {
|
|
try {
|
|
const url = new URL(path, 'http://placeholder/');
|
|
return `${url.pathname}${url.search}${url.hash}`;
|
|
} catch {
|
|
return path;
|
|
}
|
|
};
|
|
|
|
const prefixed = (base: string, path: string): string => `${base}${normalise(path)}`;
|
|
|
|
/**
|
|
* Paths that must NOT be prefixed.
|
|
*
|
|
* `//host` is protocol-relative and belongs to someone else. Everything else here is a scheme, and a
|
|
* `mailto:` or `data:` that gained a path prefix would simply be broken.
|
|
*/
|
|
const isForeign = (path: string): boolean => path.startsWith('//') || /^[a-z][a-z0-9+.-]*:/i.test(path);
|
|
|
|
/**
|
|
* Rewrite root-absolute `href`/`src`/`action` attributes, and `url()` in inline CSS.
|
|
*
|
|
* Skips anything already under `base`. `rewriteAbsolute` runs first and has already turned
|
|
* `https://gitea…/owner/repo` into `<base>/owner/repo`, and prefixing that a second time produces
|
|
* `<base><base>/owner/repo` — a 404 that looks nothing like its cause.
|
|
*/
|
|
export function rewriteAttributes(html: string, base: string): string {
|
|
const skip = (path: string) => isForeign(path) || path === base || path.startsWith(`${base}/`);
|
|
return html
|
|
// `data-*url` alongside the obvious three. Gitea hands URLs to its own JavaScript through data
|
|
// attributes — `data-heatmap-url` is where the dashboard heatmap gets its endpoint, and `data-url`
|
|
// carries the language switcher's links. Missing them meant the heatmap fetched from the PLATFORM's
|
|
// origin, got the SPA's index.html, and reported `Unexpected token '<'` while parsing JSON.
|
|
//
|
|
// Matched on the `url` suffix rather than by listing names, because the list is Gitea's to change.
|
|
.replace(
|
|
/(href|src|action|data-[a-z-]*url)=("|')(\/[^"']*)\2/gi,
|
|
(match, attr: string, quote: string, path: string) =>
|
|
skip(path) ? match : `${attr}=${quote}${prefixed(base, path)}${quote}`,
|
|
)
|
|
.replace(/url\((\/[^)"']*)\)/g, (match, path: string) => (skip(path) ? match : `url(${prefixed(base, path)})`));
|
|
}
|
|
|
|
/**
|
|
* Point Gitea's own JavaScript at the subpath.
|
|
*
|
|
* Gitea emits `window.config = {…,"appSubUrl":"",…}` and its client code prefixes every URL it builds
|
|
* with that value — it is how Gitea supports being hosted under a subdirectory. Filling it in is
|
|
* therefore not a hack around Gitea, it is the mechanism Gitea provides.
|
|
*
|
|
* Matched narrowly on the key so a repository whose CONTENT contains the string is untouched.
|
|
*/
|
|
export function rewriteAppSubUrl(html: string, base: string): string {
|
|
// Both shapes, because Gitea emits the SECOND one and this originally only matched the first:
|
|
// "appSubUrl":"…" JSON, which is what an API response looks like
|
|
// appSubUrl: '…' a JavaScript object literal, unquoted key and single quotes — the real page
|
|
// Guessing the shape is why this silently did nothing while every attribute around it was rewritten
|
|
// correctly. The quote style is captured and reused so the emitted literal stays valid either way.
|
|
return html.replace(/(["']?appSubUrl["']?\s*:\s*)(["'])[^"']*\2/g, (_m, head: string, quote: string) =>
|
|
`${head}${quote}${base}${quote}`,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Rewrite absolute URLs that point at the upstream host.
|
|
*
|
|
* These come from `ROOT_URL`, and they are the worst kind: they navigate the frame to a third-party
|
|
* origin, where the session cookie is dropped and the user is silently signed out.
|
|
*
|
|
* `upstreamOrigin` is where the sidecar actually fetches from — normally `http://localhost:3000`. A Gitea
|
|
* configured with a public `ROOT_URL` emits THAT instead, so both are rewritten when they differ.
|
|
*/
|
|
export function rewriteAbsolute(html: string, base: string, origins: string[]): string {
|
|
const escapedBase = base.split('/').join('\\/');
|
|
let out = html;
|
|
for (const origin of origins.filter(Boolean)) {
|
|
// Regex-escaped, because an origin is user-supplied config and a stray `.` or `?` would match too much.
|
|
const rx = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
|
|
// The plain form, in attributes and text.
|
|
out = out.replace(new RegExp(`${rx(origin)}(?=/|"|'|\\s|$)`, 'g'), base);
|
|
|
|
// And the JSON-ESCAPED form, which is how it appears inside Gitea's inline script:
|
|
// appUrl: 'https:\/\/gitea.example.com\/'
|
|
// Missed at first, and the consequence was subtle rather than visible — the page rendered, but every
|
|
// URL Gitea's JS built from `appUrl` pointed at the real host, cross-origin. Those requests came back
|
|
// as Gitea's own login HTML, so the heatmap reported "Unexpected token '<'" while parsing JSON, and
|
|
// the event worker failed the same way.
|
|
const escapedOrigin = origin.split('/').join('\\/');
|
|
out = out.replace(new RegExp(rx(escapedOrigin), 'g'), escapedBase);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Asset paths embedded in JavaScript, including JSON-escaped ones.
|
|
*
|
|
* Gitea inlines a script that names its own worker: `'\\/assets\\/js\\/eventsource.sharedworker.…js'`.
|
|
* That is not an attribute and its slashes are backslash-escaped, so neither of the rewrites above sees
|
|
* it. Unprefixed, the browser resolves it against the PLATFORM's origin, gets the SPA's index.html, and
|
|
* reports `Uncaught SyntaxError: Unexpected token '<'` from a file that looks like it loaded.
|
|
*
|
|
* NARROW on purpose, like dev-proxy's `/_bun/` rule. A blanket rewrite of absolute paths inside
|
|
* JavaScript corrupts strings that are not URLs; `/assets/` is Gitea's own asset root and specific
|
|
* enough to match safely.
|
|
*
|
|
* `appSubUrl` covers everything Gitea's code BUILDS at runtime. This covers what is already baked into
|
|
* the page before that code runs.
|
|
*/
|
|
export function rewriteAssetPaths(html: string, base: string): string {
|
|
// `/api/gitea/_web` becomes `\/api\/gitea\/_web`, matching the escaping of the string it is going into.
|
|
const escapedBase = base.split('/').join('\\/');
|
|
return html
|
|
.replace(/\\\/assets\\\//g, `${escapedBase}\\/assets\\/`)
|
|
.replace(/(["'`])\/assets\//g, (_m, q: string) => `${q}${base}/assets/`);
|
|
}
|
|
|
|
/**
|
|
* The public URL Gitea believes it has, read out of its own page.
|
|
*
|
|
* Gitea emits `window.config = {"appUrl":"https://gitea.example/",…}`, built from `ROOT_URL`. That is the
|
|
* origin its absolute links use, and it is not otherwise known here: the sidecar only knows where it
|
|
* FETCHES from, which is usually loopback. Taking it from the page means no second setting to configure
|
|
* and no way for the two to disagree.
|
|
*/
|
|
export function appUrlOrigin(html: string): string | null {
|
|
const match = html.match(/"appUrl"\s*:\s*"([^"]+)"/);
|
|
if (!match) return null;
|
|
try {
|
|
return new URL(match[1]!).origin;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Everything, in the order that makes each step see what the last one left. */
|
|
export function rewriteHtml(html: string, base: string, origins: string[]): string {
|
|
// Gitea's own declared origin joins the list, so a ROOT_URL nobody told the platform about is still
|
|
// rewritten. Absolute first: it turns `https://gitea…/user/login` into `<base>/user/login`, which the
|
|
// attribute pass then skips as already-prefixed.
|
|
const declared = appUrlOrigin(html);
|
|
const all = declared ? [...origins, declared] : origins;
|
|
return rewriteAssetPaths(rewriteAttributes(rewriteAppSubUrl(rewriteAbsolute(html, base, all), base), base), base);
|
|
}
|
|
|
|
/**
|
|
* Rewrite a `Location` header so a redirect stays inside the frame.
|
|
*
|
|
* Handles both shapes Gitea emits: a root-absolute path, and a full URL built from ROOT_URL. A redirect
|
|
* that escapes is the single most visible failure of a proxy like this — the frame simply leaves.
|
|
*/
|
|
export function rewriteLocation(location: string, base: string, origins: string[]): string {
|
|
for (const origin of origins.filter(Boolean)) {
|
|
if (location.startsWith(origin)) return `${base}${normalise(location.slice(origin.length) || '/')}`;
|
|
}
|
|
if (location.startsWith('/') && !location.startsWith('//') && !location.startsWith(base)) {
|
|
return prefixed(base, location);
|
|
}
|
|
return location;
|
|
}
|
|
|
|
/**
|
|
* Scope Gitea's cookies to the frame.
|
|
*
|
|
* Gitea sets `Path=/`, which on this origin means its session cookie is attached to every request to the
|
|
* platform — including the API, where it is noise at best. Narrowing it keeps Gitea's session inside
|
|
* Gitea's mount.
|
|
*
|
|
* `Secure` is left exactly as Gitea set it. Adding it would break a plain-http install; removing it would
|
|
* weaken one that meant it.
|
|
*/
|
|
export function rewriteSetCookie(value: string, base: string): string {
|
|
return /;\s*path=/i.test(value) ? value.replace(/;\s*path=[^;]*/i, `; Path=${base}`) : `${value}; Path=${base}`;
|
|
}
|