Skip to main content

lean_ctx/dashboard/
base_path.rs

1//! Reverse-proxy subpath support for `lean-ctx dashboard --base-path` (#355).
2//!
3//! When the dashboard is mounted under a subpath (e.g. `/dashboard/`) by an
4//! nginx-style reverse proxy, two things must happen:
5//!
6//! 1. Every root-absolute URL in the served HTML/CSS/JS (`/static/…`, `/api/…`,
7//!    `/favicon…`) must be prefixed with the base path, otherwise the browser
8//!    resolves them against the origin root and bypasses the subpath.
9//! 2. The server must accept incoming requests **both with and without** the
10//!    prefix, so it works whether or not the reverse proxy strips it.
11//!
12//! All functions are pure and `base`-gated (empty base → exact no-op), so the
13//! default behaviour is byte-for-byte identical to a dashboard without a subpath.
14
15/// Normalizes a user-supplied base path into a canonical form: an empty string
16/// for "no prefix", otherwise a single leading slash and no trailing slash.
17///
18/// `""`/`"/"` → `""`, `"dashboard"`/`"/dashboard/"`/`"//dashboard//"` →
19/// `"/dashboard"`.
20pub fn normalize(input: &str) -> String {
21    let trimmed = input.trim().trim_matches('/');
22    if trimmed.is_empty() {
23        String::new()
24    } else {
25        format!("/{trimmed}")
26    }
27}
28
29/// Strips the base-path prefix from an incoming request path so downstream
30/// routing always sees a root-relative path. Requests that already arrive
31/// root-relative (proxy stripped the prefix, or direct local access) pass
32/// through unchanged.
33pub fn strip<'a>(path: &'a str, base: &str) -> &'a str {
34    if base.is_empty() {
35        return path;
36    }
37    if path == base {
38        return "/";
39    }
40    match path.strip_prefix(base) {
41        Some(rest) if rest.starts_with('/') => rest,
42        _ => path,
43    }
44}
45
46/// Prefixes every root-absolute asset/API/favicon URL in a served text body with
47/// the base path. Only the quote/paren-delimited forms that actually occur in the
48/// dashboard assets (`"/static/`, `'/api/`, `` `/api/ ``, `url(/static/`, …) are
49/// rewritten, so ordinary text is never touched. No-op when `base` is empty.
50pub fn rewrite_asset_urls(body: &str, base: &str) -> String {
51    if base.is_empty() {
52        return body.to_string();
53    }
54    // Root-absolute prefixes used by the dashboard. `/favicon` has no trailing
55    // slash on purpose (covers both `/favicon.svg` and `/favicon.ico`).
56    const PREFIXES: &[&str] = &["/static/", "/api/", "/favicon"];
57    // Delimiters that introduce a URL literal in HTML attributes, JS string and
58    // template literals, and CSS `url(...)`.
59    const DELIMS: &[char] = &['"', '\'', '`', '('];
60
61    let mut out = body.to_string();
62    for prefix in PREFIXES {
63        for &d in DELIMS {
64            let from = format!("{d}{prefix}");
65            if out.contains(&from) {
66                let to = format!("{d}{base}{prefix}");
67                out = out.replace(&from, &to);
68            }
69        }
70    }
71    out
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77
78    #[test]
79    fn normalize_canonicalizes() {
80        assert_eq!(normalize(""), "");
81        assert_eq!(normalize("/"), "");
82        assert_eq!(normalize("   "), "");
83        assert_eq!(normalize("dashboard"), "/dashboard");
84        assert_eq!(normalize("/dashboard"), "/dashboard");
85        assert_eq!(normalize("/dashboard/"), "/dashboard");
86        assert_eq!(normalize("//dashboard//"), "/dashboard");
87        assert_eq!(normalize("/lean/ctx"), "/lean/ctx");
88    }
89
90    #[test]
91    fn strip_empty_base_is_noop() {
92        assert_eq!(strip("/api/stats", ""), "/api/stats");
93        assert_eq!(strip("/", ""), "/");
94    }
95
96    #[test]
97    fn strip_removes_prefix() {
98        assert_eq!(strip("/dashboard", "/dashboard"), "/");
99        assert_eq!(strip("/dashboard/", "/dashboard"), "/");
100        assert_eq!(strip("/dashboard/api/stats", "/dashboard"), "/api/stats");
101        assert_eq!(
102            strip("/dashboard/static/style.css", "/dashboard"),
103            "/static/style.css"
104        );
105    }
106
107    #[test]
108    fn strip_accepts_already_rootrelative() {
109        // Reverse proxy already stripped the prefix (or direct local access).
110        assert_eq!(strip("/api/stats", "/dashboard"), "/api/stats");
111        assert_eq!(strip("/", "/dashboard"), "/");
112    }
113
114    #[test]
115    fn strip_does_not_match_partial_segment() {
116        // `/dashboardx` must NOT be treated as `/dashboard` + `x`.
117        assert_eq!(strip("/dashboardx/api", "/dashboard"), "/dashboardx/api");
118    }
119
120    #[test]
121    fn rewrite_empty_base_is_noop() {
122        let html = r#"<script src="/static/lib/api.js"></script>"#;
123        assert_eq!(rewrite_asset_urls(html, ""), html);
124    }
125
126    #[test]
127    fn rewrite_html_attributes() {
128        let html = r#"<link href="/static/style.css"><script src="/static/lib/api.js"></script><link rel="icon" href="/favicon.svg">"#;
129        let out = rewrite_asset_urls(html, "/dashboard");
130        assert!(out.contains(r#"href="/dashboard/static/style.css""#));
131        assert!(out.contains(r#"src="/dashboard/static/lib/api.js""#));
132        assert!(out.contains(r#"href="/dashboard/favicon.svg""#));
133        assert!(!out.contains(r#"href="/static/"#));
134    }
135
136    #[test]
137    fn rewrite_js_string_and_template_literals() {
138        let js = "fetch('/api/stats'); const u = `/api/search?q=${q}`; api('/api/pulse');";
139        let out = rewrite_asset_urls(js, "/dashboard");
140        assert!(out.contains("fetch('/dashboard/api/stats')"));
141        assert!(out.contains("`/dashboard/api/search?q=${q}`"));
142        assert!(out.contains("api('/dashboard/api/pulse')"));
143    }
144
145    #[test]
146    fn rewrite_css_url() {
147        let css = "src: url('/static/fonts/inter-variable.woff2');";
148        let out = rewrite_asset_urls(css, "/dashboard");
149        assert!(out.contains("url('/dashboard/static/fonts/inter-variable.woff2')"));
150    }
151
152    #[test]
153    fn rewrite_fetch_interceptor_guard() {
154        // The HTML interceptor checks `url.startsWith('/api/')`; after rewrite it
155        // must check the prefixed form so Bearer auth still attaches.
156        let js = "if (url.startsWith('/api/')) attachToken();";
157        let out = rewrite_asset_urls(js, "/dashboard");
158        assert!(out.contains("url.startsWith('/dashboard/api/')"));
159    }
160
161    #[test]
162    fn rewrite_does_not_double_prefix() {
163        let html = r#"<script src="/static/x.js"></script>"#;
164        let once = rewrite_asset_urls(html, "/dashboard");
165        // Re-running would double-prefix only if source already had the base; the
166        // canonical source never does, so a single pass is correct and stable.
167        assert_eq!(once.matches("/dashboard/static/x.js").count(), 1);
168        assert!(!once.contains("/dashboard/dashboard/"));
169    }
170}