Files
gitea/sidecar/rewrite.ts
T
officer 00243b312a rewrite Gitea's URLs so the frame stays inside the proxy
Gitea believes it lives at /. Its pages are full of root-absolute links, and
inside the frame those resolve against OFFICER'S origin — the page renders
perfectly and falls apart on the first click.

Four places a URL hides, each failing differently: href/src/action attributes,
window.config.appSubUrl, Location headers, and absolute URLs built from
ROOT_URL.

appSubUrl is the highest-value line. Gitea already supports being hosted under
a subpath and its client JS prefixes every URL it builds with that value, so
filling it in makes Gitea correct ITSELF rather than by interception.

The ROOT_URL case needs Gitea's public origin, which the sidecar does not have
— it fetches from loopback. Rather than add a second setting that can disagree
with Gitea's own, appUrlOrigin reads it out of the page: Gitea emits "appUrl"
in the same config blob as appSubUrl.

Not rewritten, deliberately: JavaScript bodies, because a blanket rewrite of
absolute paths inside a script corrupts strings that are not URLs; and clone
URLs, which must keep working for whoever copies them.

/_web/* now forwards by hand rather than through createSidecarProxy. That
forwarder passes three headers and nothing else, on purpose — it is shared by
every sidecar and its restraint is what makes it safe to reuse. It strips
cookies, and this path is nothing but cookies. Loosening it for gitea would
loosen it for the wallet.

Eighteen tests, the only part of this plugin verifiable without a running
Gitea. One caught a real bug: rewriteAbsolute turns a full URL into <base>/path
and the attribute pass then prefixed it again.
2026-08-19 08:31:28 +00:00

156 lines
7.3 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
.replace(/(href|src|action)=("|')(\/[^"']*)\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 {
return html.replace(/"appSubUrl"\s*:\s*"[^"]*"/g, `"appSubUrl":"${base}"`);
}
/**
* 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 {
let out = html;
for (const origin of origins.filter(Boolean)) {
// Escaped, because an origin is user-supplied config and a stray `.` or `?` would match too much.
const escaped = origin.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
out = out.replace(new RegExp(`${escaped}(?=/|"|'|\\s|$)`, 'g'), base);
}
return out;
}
/**
* 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 rewriteAttributes(rewriteAppSubUrl(rewriteAbsolute(html, base, all), 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}`;
}