mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 21:49:42 +02:00
Codeman can now be mounted under a sub-path behind a reverse proxy that forwards the prefix unchanged (e.g. https://host/codeman/). Default is `/` (root), which is byte-identical to the historical behavior. Design — few choke points, mirrored ingress/egress: - src/config/base-path.ts: pure single-source normalize/validate/join/strip. - Server ingress: stripBasePath() inside Fastify rewriteUrl, so routes stay declared prefix-agnostic; un-prefixed requests (hooks, health, docker bridge hitting the raw port) pass through unchanged. - Server egress: one onSend hook prepends the base to root-absolute Location headers (covers all redirects). - HTML: renderIndexHtml points <base href> at the mount and injects window.__CODEMAN_BASE__ — ONLY when a base is set (inert at root). - Frontend runtime URLs: CodemanBase.url() route builder in constants.js, applied transparently by a fetch wrapper and explicitly at the EventSource/WebSocket/window.open/<img|iframe|a>-src sites. - sw.js derives its base from self.location; manifest uses relative start_url/scope. - Web-tab proxy: proxyPrefixFor(cap, basePath) is the single base-aware root that cascades to the injected <base>, HTML/attr rewrites, runtimeUrlShim, Set-Cookie Path and Location; capabilityFromReferer strips the base off the browser Referer, while the ingress parsers stay base-agnostic (rewriteUrl already stripped it). --base-url rides the daemon relaunch (buildWebArgs) and the service unit (resolveServicePlan). constants.js is guarded against a missing `window` for isolated unit-test contexts. Tests: test/base-path.test.ts (pure helpers), base-path coverage in webview-proxy/render-index-html/daemon-control; CodemanBase stubbed in the vm-isolated panels-ui test contexts. Docs: Remote-Access.md (sub-path section + nginx example), security-architecture.md env table, CLAUDE.md pattern. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XUkPBxbumnct6qSrx4JDju
102 lines
4.3 KiB
TypeScript
102 lines
4.3 KiB
TypeScript
/**
|
|
* @fileoverview Reverse-proxy base-path support — the single source of truth for
|
|
* the URL prefix Codeman is mounted under.
|
|
*
|
|
* When Codeman runs behind a reverse proxy at a sub-path (e.g. `/codeman/`), the
|
|
* proxy forwards the FULL request path INCLUDING that prefix (it does not strip
|
|
* it). Every URL the server emits to the browser (the HTML shell, redirects,
|
|
* the manifest/service-worker) and every URL the browser builds (fetch/SSE/WS)
|
|
* must therefore carry the prefix too.
|
|
*
|
|
* This module normalizes the operator-supplied value (`--base-url` / the
|
|
* `CODEMAN_BASE_URL` env var) into ONE canonical form used everywhere:
|
|
* - `''` — mounted at the origin root (the default, `/`)
|
|
* - `/foo` — mounted at a sub-path (leading slash, NO trailing slash)
|
|
*
|
|
* Keeping the normalized form free of a trailing slash means `basePath + '/api/x'`
|
|
* and `basePath + '/'` both compose cleanly, and `''` degrades to the historical
|
|
* root behavior with no special-casing at the call sites.
|
|
*
|
|
* @module config/base-path
|
|
*/
|
|
|
|
/**
|
|
* A normalized base path is either empty (root) or one-or-more `/segment`
|
|
* groups, where a segment is a conservative, proxy-safe subset of path
|
|
* characters. This deliberately excludes anything that could change routing
|
|
* meaning (`?`, `#`, `:`, whitespace, `%`) so the prefix is a plain path.
|
|
*/
|
|
const VALID_BASE_PATH = /^(?:\/[A-Za-z0-9._~-]+)+$/;
|
|
|
|
/**
|
|
* Normalize an operator-supplied base path into the canonical form.
|
|
*
|
|
* Accepts loose input (`codeman`, `/codeman`, `/codeman/`, `//codeman//`) and
|
|
* returns `''` for root or `/codeman` otherwise. Does NOT validate the character
|
|
* set — call {@link assertValidBasePath} (or {@link isValidBasePath}) for that.
|
|
*/
|
|
export function normalizeBasePath(input: string | undefined | null): string {
|
|
if (input === undefined || input === null) return '';
|
|
let p = String(input).trim();
|
|
if (p === '' || p === '/') return '';
|
|
if (!p.startsWith('/')) p = '/' + p;
|
|
p = p.replace(/\/{2,}/g, '/'); // collapse duplicate slashes
|
|
p = p.replace(/\/+$/, ''); // drop trailing slash(es)
|
|
return p;
|
|
}
|
|
|
|
/** True if `normalized` is a legal canonical base path (`''` or `/seg[/seg...]`). */
|
|
export function isValidBasePath(normalized: string): boolean {
|
|
return normalized === '' || VALID_BASE_PATH.test(normalized);
|
|
}
|
|
|
|
/**
|
|
* Normalize AND validate, throwing a human-readable error on bad input. Used by
|
|
* the CLI so a typo (`--base-url /a b`, `--base-url ?x`) fails loudly at startup
|
|
* instead of silently producing broken URLs.
|
|
*/
|
|
export function assertValidBasePath(input: string | undefined | null): string {
|
|
const normalized = normalizeBasePath(input);
|
|
if (!isValidBasePath(normalized)) {
|
|
throw new Error(
|
|
`Invalid --base-url ${JSON.stringify(input)}: use a plain path like "/codeman" ` +
|
|
`(letters, digits, and ._~- in each segment).`
|
|
);
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
/**
|
|
* Join the base path onto a root-absolute application path (`/api/x` → `/base/api/x`).
|
|
*
|
|
* Leaves alone anything that is not a root-absolute app path: empty strings,
|
|
* protocol-relative (`//host`) and absolute URLs (`http://`, `ws://`, `data:`),
|
|
* fragments/queries, and paths already carrying the prefix. This is the one
|
|
* function the whole codebase routes URL construction through.
|
|
*/
|
|
export function joinBasePath(basePath: string, path: string): string {
|
|
if (!basePath) return path;
|
|
if (typeof path !== 'string' || path.length === 0) return path;
|
|
if (!path.startsWith('/')) return path; // relative / fragment / query — resolved against <base>
|
|
if (path.startsWith('//')) return path; // protocol-relative
|
|
if (path === basePath || path.startsWith(basePath + '/') || path.startsWith(basePath + '?')) {
|
|
return path; // already prefixed
|
|
}
|
|
return basePath + path;
|
|
}
|
|
|
|
/**
|
|
* Strip the base path off an INCOMING request URL so internal routing stays
|
|
* prefix-agnostic. Requests that arrive WITHOUT the prefix (health checks,
|
|
* hooks, the docker bridge — all of which hit the raw port, bypassing the proxy)
|
|
* are returned unchanged, so the server answers at both `/api/x` and
|
|
* `/base/api/x`.
|
|
*/
|
|
export function stripBasePath(basePath: string, url: string): string {
|
|
if (!basePath) return url;
|
|
if (url === basePath) return '/';
|
|
if (url.startsWith(basePath + '/')) return url.slice(basePath.length);
|
|
if (url.startsWith(basePath + '?')) return '/' + url.slice(basePath.length);
|
|
return url;
|
|
}
|