// 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 `/owner/repo`, and prefixing that a second time produces
* `/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 `/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}`;
}