feat(web): serve the HTTP surface under a configurable base path

Adds --base-path / AI_MEMORY_BASE_PATH (default empty = host root,
byte-identical to today) so ai-memory can be hosted under a URL subpath
behind a reverse proxy that preserves the prefix — completing the
reverse-proxy deployment story in docs/https-via-proxy.md, whose recipes
currently all assume the host root. When set (e.g. /wiki) the whole
surface nests under it: /wiki/mcp, /wiki/api/v1, /wiki/hook and the web UI.

--web-slug / AI_MEMORY_WEB_SLUG (default /web) chooses where the UI mounts
within the base; "/" serves it at the base root. The server injects a
normalized <base href> into the served index so a custom --web-ui-dir SPA
resolves its relative asset/router URLs under the prefix without a rebuild.

Safety: empty base path => identical routing (tested). The prefix is pure
routing — require_bearer/CORS/cookie semantics unchanged. Path normalization
(normalize_prefix/web_base_href) collapses edge/duplicate slashes, restricts
to a safe charset, HTML-escapes the injected href, and never emits a
protocol-relative "//" — covered by unit tests (""/"/"/"//"/"wiki"/"/wiki/"
/unsafe-chars). The /api/v1, /mcp, /hook contract paths are unchanged; they
only inherit the base prefix.

Follow-up: the built-in server-rendered browser emits root-absolute /web
links; making those prefix-relative is a separate change. The documented
reverse-proxy + custom-UI path is fully supported here.
This commit is contained in:
Djalma Júnior
2026-06-02 13:37:23 -03:00
parent 0772a3839c
commit 31ea1f0d6d
2 changed files with 206 additions and 14 deletions
+17
View File
@@ -902,6 +902,23 @@ pub struct ServeArgs {
/// --enable-web is set.
#[arg(long)]
pub web_ui_dir: Option<PathBuf>,
/// Base path the whole HTTP surface is served under. Empty (default)
/// keeps every route at the host root — byte-identical to previous
/// behaviour. Set e.g. `/wiki` to host ai-memory under a URL subpath
/// behind a reverse proxy that preserves the prefix; then `/mcp`,
/// `/api/v1`, `/hook` and the web UI all live under it (`/wiki/mcp`,
/// `/wiki/api/v1`, …). The value is normalised to `/<core>` (leading
/// slash, no trailing); `/` and `` both mean root.
#[arg(long, env = "AI_MEMORY_BASE_PATH", default_value = "")]
pub base_path: String,
/// Slug the web UI is mounted at, WITHIN `--base-path`. Default `/web`
/// (the read-only `/api/v1` API always stays at `<base>/api/v1`). Set
/// `/` to serve the UI at the base root itself (e.g. `/wiki` instead of
/// `/wiki/web`). The server injects a normalised `<base href>` into the
/// served HTML so the built-in UI and a custom `--web-ui-dir` SPA both
/// resolve their assets under the prefix without a rebuild.
#[arg(long, env = "AI_MEMORY_WEB_SLUG", default_value = "/web")]
pub web_slug: String,
/// Run the HTTP transport in stateful (session) mode: the server
/// issues an `Mcp-Session-Id` on `initialize` and requires it on
/// every later request, with SSE-framed responses. Off by default —
+189 -14
View File
@@ -25,7 +25,7 @@ use rmcp::transport::streamable_http_server::{
};
use tokio_util::sync::CancellationToken;
use tower_http::cors::CorsLayer;
use tower_http::services::{ServeDir, ServeFile};
use tower_http::services::ServeDir;
use tracing::info;
use crate::auth::{AuthState, require_bearer};
@@ -279,6 +279,14 @@ pub async fn run(config: &Config, args: ServeArgs) -> Result<()> {
.merge(hooks)
.layer(DefaultBodyLimit::max(MAX_BODY_BYTES))
.merge(admin.layer(DefaultBodyLimit::max(BOOTSTRAP_MAX_BODY_BYTES)));
let base_path = normalize_prefix(&args.base_path);
if base_path.is_empty() && !args.base_path.trim_matches('/').trim().is_empty() {
tracing::warn!(
raw = %args.base_path,
"AI_MEMORY_BASE_PATH is not a safe path prefix; serving at root instead",
);
}
let base_href = web_base_href(&args.base_path, &args.web_slug);
let router = mount_web_router(
router,
args.enable_web,
@@ -286,8 +294,18 @@ pub async fn run(config: &Config, args: ServeArgs) -> Result<()> {
wiki.clone(),
args.web_ui_dir.as_deref(),
&cors_origins,
&args.web_slug,
&base_href,
);
let router = apply_http_layers(router, auth_state, config.allowed_hosts.clone());
// Host the entire surface under the configured base path. Empty
// base = root (unchanged). The auth/host layers are already
// attached to `router`, so they run for every nested route.
let router = if base_path.is_empty() {
router
} else {
axum::Router::new().nest(&base_path, router)
};
let listener = tokio::net::TcpListener::bind(&bind)
.await
.with_context(|| format!("binding {bind}"))?;
@@ -712,6 +730,123 @@ fn llm_retry_hint(provider: &str, model: &str, base_url: Option<&str>) -> String
command
}
/// Normalise an operator-supplied path prefix into either `""` (root) or
/// `/<core>` — exactly one leading slash, no trailing slash, internal
/// empty/`//` segments collapsed. Segments are restricted to a safe path
/// charset; anything outside it falls back to root so a malformed env var
/// can never inject markup or a protocol-relative `//` into served HTML.
pub(crate) fn normalize_prefix(raw: &str) -> String {
let segs: Vec<&str> = raw.trim().split('/').filter(|s| !s.is_empty()).collect();
if segs.is_empty() {
return String::new();
}
let safe = segs.iter().all(|s| {
s.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '~'))
});
if !safe {
return String::new();
}
format!("/{}", segs.join("/"))
}
/// Build the `<base href>` value (always trailing-slash-terminated, never
/// the protocol-relative `//`) for the web UI mounted at `base_path` +
/// `web_slug`.
pub(crate) fn web_base_href(base_path: &str, web_slug: &str) -> String {
let combined = format!(
"{}{}",
normalize_prefix(base_path),
normalize_prefix(web_slug)
);
if combined.is_empty() {
"/".to_string()
} else {
format!("{combined}/")
}
}
/// Escape a string for safe inclusion inside a double-quoted HTML attribute.
fn escape_attr(s: &str) -> String {
s.replace('&', "&amp;")
.replace('"', "&quot;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
/// Inject `<base href="{href}">` immediately after the first `<head…>` tag
/// (or prepend it when there is no head) so the served SPA's relative
/// asset/router URLs resolve under the configured prefix.
pub(crate) fn inject_base_href(html: &str, href: &str) -> String {
let tag = format!("<base href=\"{}\">", escape_attr(href));
if let Some(start) = html.find("<head")
&& let Some(gt) = html[start..].find('>')
{
let pos = start + gt + 1;
let mut out = String::with_capacity(html.len() + tag.len());
out.push_str(&html[..pos]);
out.push_str(&tag);
out.push_str(&html[pos..]);
return out;
}
format!("{tag}{html}")
}
#[cfg(test)]
mod web_base_tests {
use super::{inject_base_href, normalize_prefix, web_base_href};
#[test]
fn normalize_prefix_edge_cases() {
assert_eq!(normalize_prefix(""), "");
assert_eq!(normalize_prefix("/"), "");
assert_eq!(normalize_prefix("//"), "");
assert_eq!(normalize_prefix(" / "), "");
assert_eq!(normalize_prefix("wiki"), "/wiki");
assert_eq!(normalize_prefix("/wiki"), "/wiki");
assert_eq!(normalize_prefix("/wiki/"), "/wiki");
assert_eq!(normalize_prefix("//wiki//"), "/wiki");
assert_eq!(normalize_prefix("/wiki/sub"), "/wiki/sub");
// Unsafe chars fall back to root — never inject markup or `//`.
assert_eq!(normalize_prefix("/wi\"ki"), "");
assert_eq!(normalize_prefix("/wiki space"), "");
assert_eq!(normalize_prefix("/<script>"), "");
}
#[test]
fn web_base_href_never_protocol_relative() {
assert_eq!(web_base_href("", "/web"), "/web/");
assert_eq!(web_base_href("/wiki", "/web"), "/wiki/web/");
assert_eq!(web_base_href("/wiki", "/"), "/wiki/");
assert_eq!(web_base_href("", "/"), "/");
assert_eq!(web_base_href("/", "/"), "/");
assert_eq!(web_base_href("/wiki/", "web"), "/wiki/web/");
for (b, s) in [("", "/"), ("/", "/"), ("//", "//")] {
assert!(!web_base_href(b, s).starts_with("//"));
}
}
#[test]
fn inject_base_href_after_head() {
let html = "<!doctype html><html><head><meta charset=\"utf-8\"></head><body></body></html>";
let out = inject_base_href(html, "/wiki/web/");
assert!(out.contains("<head><base href=\"/wiki/web/\"><meta"));
}
#[test]
fn inject_base_href_no_head_prepends() {
let out = inject_base_href("<html></html>", "/x/");
assert!(out.starts_with("<base href=\"/x/\"><html>"));
}
#[test]
fn inject_base_href_escapes_attr() {
let out = inject_base_href("<head></head>", "/a\"b/");
assert!(out.contains("<base href=\"/a&quot;b/\">"));
}
}
#[allow(clippy::too_many_arguments)]
fn mount_web_router(
router: axum::Router,
enable_web: bool,
@@ -719,6 +854,8 @@ fn mount_web_router(
wiki: Wiki,
web_ui_dir: Option<&Path>,
cors_origins: &[String],
web_slug: &str,
base_href: &str,
) -> axum::Router {
if !enable_web {
return router;
@@ -751,25 +888,55 @@ fn mount_web_router(
};
let router = router.nest("/api/v1", api);
// Where the UI is mounted WITHIN the (already-applied) base path.
// Empty slug => the UI is the root of the base path itself.
let slug = normalize_prefix(web_slug);
let mount = if slug.is_empty() { "/" } else { slug.as_str() };
// Custom SPA via --web-ui-dir (SPA fallback to index.html), otherwise
// the built-in server-side wiki browser.
// the built-in server-side wiki browser. In both cases the served
// index carries an injected `<base href>` so relative asset/router
// URLs resolve under `{base_path}{web_slug}`.
if let Some(dir) = web_ui_dir {
let dir = dir.to_path_buf();
let index = dir.join("index.html");
info!(dir = %dir.display(), "custom web UI mounted at /web");
// ServeDir already answers /web and /web/ (SPA fallback to index.html);
// an explicit /web/ route here would conflict with the nest_service.
return router.nest_service("/web", ServeDir::new(dir).fallback(ServeFile::new(index)));
let injected = inject_base_href(
&std::fs::read_to_string(dir.join("index.html")).unwrap_or_default(),
base_href,
);
info!(mount, base_href, "custom web UI mounted");
// Assets are served as files; any unmatched path (SPA client route)
// falls back to the injected index. `append_index_html_on_directories`
// is off so the directory index also flows through the injecting
// fallback rather than serving the raw on-disk index.html.
let spa = ServeDir::new(dir)
.append_index_html_on_directories(false)
.fallback(axum::routing::get(move || {
let body = injected.clone();
async move { axum::response::Html(body) }
}));
return if slug.is_empty() {
router.fallback_service(spa)
} else {
router.nest_service(&slug, spa)
};
}
let web_router = ai_memory_web::router(reader, wiki);
info!("read-only wiki browser mounted at /web");
router
.route(
"/web/",
axum::routing::get(|| async { axum::response::Redirect::permanent("/web") }),
)
.nest("/web", web_router)
info!(mount, "read-only wiki browser mounted");
if slug.is_empty() {
router.merge(web_router)
} else {
let redirect_to = slug.clone();
router
.route(
&format!("{slug}/"),
axum::routing::get(move || {
let to = redirect_to.clone();
async move { axum::response::Redirect::permanent(&to) }
}),
)
.nest(&slug, web_router)
}
}
fn apply_http_layers(
@@ -913,6 +1080,8 @@ mod tests {
wiki,
None,
&[],
"/web",
"/web/",
);
let router = apply_http_layers(
router,
@@ -1074,6 +1243,8 @@ mod tests {
wiki,
None,
&cors_origins,
"/web",
"/web/",
);
// No auth layer so we can reach /api/v1 directly.
let resp = router
@@ -1123,6 +1294,8 @@ mod tests {
wiki,
None,
&cors_origins,
"/web",
"/web/",
);
let resp = router
.oneshot(
@@ -1161,6 +1334,8 @@ mod tests {
wiki,
None,
&cors_origins,
"/web",
"/web/",
);
// /web is a non-api route; sending an Origin header must not trigger CORS.
let resp = router