diff --git a/Cargo.lock b/Cargo.lock index 9d72aa7f..4444a443 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -141,6 +141,18 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + [[package]] name = "async-compression" version = "0.4.42" @@ -240,12 +252,27 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bitflags" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -519,6 +546,35 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "log", + "publicsuffix", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -678,6 +734,15 @@ dependencies = [ "syn", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dunce" version = "1.0.5" @@ -1357,6 +1422,12 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -1584,6 +1655,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "pbkdf2" version = "0.12.2" @@ -1691,6 +1773,22 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + +[[package]] +name = "publicsuffix" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf" +dependencies = [ + "idna", + "psl-types", +] + [[package]] name = "pulldown-cmark" version = "0.12.2" @@ -1890,6 +1988,8 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64", "bytes", + "cookie", + "cookie_store", "futures-channel", "futures-core", "futures-util", @@ -2164,6 +2264,7 @@ version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ + "indexmap", "itoa", "memchr", "serde", @@ -2288,6 +2389,7 @@ dependencies = [ name = "silverbullet-server" version = "0.0.0" dependencies = [ + "argon2", "axum", "base64", "chrono", @@ -2309,6 +2411,7 @@ dependencies = [ "tower", "tower-http", "tracing", + "uuid", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 1a238ed4..97981cfe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,8 +18,9 @@ all = "warn" [workspace.dependencies] serde = { version = "1", features = ["derive"] } -serde_json = "1" +serde_json = { version = "1", features = ["preserve_order"] } thiserror = "2" +argon2 = { version = "0.5", features = ["std"] } walkdir = "2" ignore = "0.4" mime_guess = "2" diff --git a/bin/silverbullet/Cargo.toml b/bin/silverbullet/Cargo.toml index 5bd58f7f..dd63a423 100644 --- a/bin/silverbullet/Cargo.toml +++ b/bin/silverbullet/Cargo.toml @@ -34,6 +34,6 @@ serde_json = { workspace = true } serde_json = { workspace = true } [dev-dependencies] -reqwest = { workspace = true } +reqwest = { workspace = true, features = ["cookies"] } tempfile = "3" silverbullet-server-runtime-chrome = { path = "../../server-runtime-chrome" } diff --git a/bin/silverbullet/src/lib.rs b/bin/silverbullet/src/lib.rs index 397c3dad..128c3b6d 100644 --- a/bin/silverbullet/src/lib.rs +++ b/bin/silverbullet/src/lib.rs @@ -7,6 +7,7 @@ pub mod config; pub mod embed; +pub mod multi; pub mod server; /// The product version, injected at build time from `version.ts`. diff --git a/bin/silverbullet/src/main.rs b/bin/silverbullet/src/main.rs index ea017d31..6e54fe18 100644 --- a/bin/silverbullet/src/main.rs +++ b/bin/silverbullet/src/main.rs @@ -77,7 +77,11 @@ fn main() -> std::process::ExitCode { match server::run(cli.hostname, cli.port, cli.folder).await { Ok(()) => std::process::ExitCode::SUCCESS, Err(e) => { + // Log the fatal error and also write it to stderr — the + // conventional stream for fatal CLI errors (the tracing fmt + // subscriber writes to stdout). tracing::error!("{e}"); + eprintln!("Error: {e}"); std::process::ExitCode::FAILURE } } diff --git a/bin/silverbullet/src/multi.rs b/bin/silverbullet/src/multi.rs new file mode 100644 index 00000000..40b36f4f --- /dev/null +++ b/bin/silverbullet/src/multi.rs @@ -0,0 +1,171 @@ +//! Multi-space mode wiring: env validation, embedded-asset and Chrome-runtime +//! factories, admin stack construction, and the serve loop. + +use std::path::PathBuf; +use std::sync::Arc; + +use silverbullet_server::auth::{AuthConfig, Authenticator}; +use silverbullet_server::metrics::Metrics; +use silverbullet_server::multi::admin_api::{build_admin_router, AdminState}; +use silverbullet_server::multi::dispatch::build_main_router; +use silverbullet_server::multi::instance::{AssetFactories, InstanceDeps, RuntimeRequest}; +use silverbullet_server::multi::listeners::run_listener_manager; +use silverbullet_server::multi::manager::MultiManager; + +use crate::config::Config; +use crate::embed::{BaseFsAssets, ClientAssets, EmbeddedSpace}; + +/// Env vars that are per-space JSON config in multi-space mode; warned about if +/// set (they are configured per space in spaces.json, not process-globally). +const IGNORED_IN_MULTI: &[&str] = &[ + "SB_READ_ONLY", + "SB_NAME", + "SB_INDEX_PAGE", + "SB_DESCRIPTION", + "SB_THEME_COLOR", + "SB_HEAD_HTML", + "SB_SPACE_IGNORE", + "SB_LOG_PUSH", + "SB_URL_PREFIX", + "SB_SHELL_WHITELIST", +]; + +pub async fn run_multi(config: Config) -> Result<(), String> { + if config.unix_socket.is_some() { + return Err("SB_UNIX_SOCKET is not supported in multi-space mode".into()); + } + let admin_auth = AuthConfig::from_env().map_err(|e| e.0)?.ok_or_else(|| { + "multi-space mode requires SB_USER (admin credentials, user:pass format)".to_string() + })?; + for var in IGNORED_IN_MULTI { + if std::env::var(var).map(|v| !v.is_empty()).unwrap_or(false) { + tracing::warn!( + "{var} is ignored in multi-space mode — configure this per space in spaces.json" + ); + } + } + + let root = PathBuf::from(&config.space_folder); + std::fs::create_dir_all(&root).map_err(|e| format!("could not create server root: {e}"))?; + warn_if_world_readable(&root.join("spaces.json")); + + let admin_authenticator = Arc::new( + Authenticator::load_or_init(&root, &admin_auth) + .map_err(|e| format!("could not initialize admin authentication: {e}"))?, + ); + tracing::info!( + "multi-space admin authentication enabled for user {:?}", + admin_auth.user + ); + + let metrics = config.metrics_port.map(|_| Arc::new(Metrics::new())); + let deps = InstanceDeps { + root: root.clone(), + assets: AssetFactories { + client_bundle: Box::new(|| Box::new(EmbeddedSpace::::new())), + base_fs: Box::new(|| Box::new(EmbeddedSpace::::new())), + }, + runtime: Box::new(build_space_runtime), + metrics: metrics.clone(), + admin_auth: admin_auth.clone(), + version: crate::VERSION.to_string(), + main_port: config.port, + disable_service_worker: config.disable_service_worker, + }; + + let manager = MultiManager::boot(root, deps, config.port, config.metrics_port)?; + tracing::info!( + "SilverBullet multi-space mode: {} space(s) configured", + manager.registry().current().instances.len() + ); + + // Metrics on a dedicated port (server-global, aggregated across spaces). + if let (Some(mport), Some(metrics)) = (config.metrics_port, metrics.clone()) { + let maddr = format!("{}:{}", config.bind_host, mport); + let listener = tokio::net::TcpListener::bind(&maddr) + .await + .map_err(|e| format!("failed to bind metrics on {maddr}: {e}"))?; + let mrouter = axum::Router::new().route( + "/metrics", + axum::routing::get(move || { + let metrics = metrics.clone(); + async move { + ( + [( + axum::http::header::CONTENT_TYPE, + "text/plain; version=0.0.4", + )], + metrics.gather(), + ) + } + }), + ); + tracing::info!("metrics on http://{maddr}/metrics"); + tokio::spawn(async move { + let _ = axum::serve(listener, mrouter).await; + }); + } + + // Per-port space listeners. + tokio::spawn(run_listener_manager( + manager.clone(), + config.bind_host.clone(), + )); + + // Main listener: admin + prefix/host spaces. + let admin_state = Arc::new(AdminState::new( + manager.clone(), + admin_authenticator, + admin_auth, + Box::new(EmbeddedSpace::::new()), + )); + let router = build_main_router(manager, build_admin_router(admin_state)); + let addr = format!("{}:{}", config.bind_host, config.port); + let listener = tokio::net::TcpListener::bind(&addr) + .await + .map_err(|e| format!("failed to listen on {addr}: {e}"))?; + tracing::info!("SilverBullet multi-space server running: http://{addr} (admin at /.admin/)"); + axum::serve(listener, router) + .with_graceful_shutdown(crate::server::shutdown_signal()) + .await + .map_err(|e| format!("server error: {e}")) +} + +/// Per-space headless-Chrome runtime factory (same construction as the +/// single-space `build_runtime`). +fn build_space_runtime( + req: &RuntimeRequest, +) -> Option> { + let chrome_cfg = silverbullet_server_runtime_chrome::ChromeConfig::from_env( + req.server_url.clone(), + req.headless_token.to_string(), + req.space_folder, + req.read_only, + )?; + let logs = silverbullet_server::runtime::LogBuffer::new(); + match silverbullet_server_runtime_chrome::ChromeTransport::launch(chrome_cfg, logs.clone()) { + Ok(transport) => Some(Box::new(silverbullet_server::runtime::ClientRuntime::new( + transport, logs, + ))), + Err(e) => { + tracing::warn!("runtime disabled for space: could not launch Chrome: {e}"); + None + } + } +} + +#[cfg(unix)] +fn warn_if_world_readable(path: &std::path::Path) { + use std::os::unix::fs::PermissionsExt; + if let Ok(meta) = std::fs::metadata(path) { + if meta.permissions().mode() & 0o077 != 0 { + tracing::warn!( + "{} is group/world-readable — it contains auth config; chmod 600 recommended", + path.display() + ); + } + } +} + +#[cfg(not(unix))] +fn warn_if_world_readable(_path: &std::path::Path) {} diff --git a/bin/silverbullet/src/server.rs b/bin/silverbullet/src/server.rs index fcccfec2..5ebd8cb3 100644 --- a/bin/silverbullet/src/server.rs +++ b/bin/silverbullet/src/server.rs @@ -87,9 +87,10 @@ fn build_auth(config: &Config, headless_token: &str) -> Result, Str "user authentication enabled for user {:?}", auth_config.user ); - let inner: Box = Box::new(JwtAuthorizer::new( + let inner: Box = Box::new(JwtAuthorizer::with_prefix( authenticator.clone(), auth_config.auth_token.clone(), + config.host_url_prefix.clone(), )); let authorizer: Arc = Arc::new(HeadlessTokenAuthorizer::new( inner, @@ -235,6 +236,12 @@ pub async fn run( ) -> Result<(), String> { let config = Config::from_env(hostname, port, folder)?; tracing::info!("SilverBullet {}", crate::VERSION); + if std::env::var("SB_MULTI_SPACE") + .map(|v| !v.is_empty()) + .unwrap_or(false) + { + return crate::multi::run_multi(config).await; + } let state = Arc::new(build_state(&config)?); // Optional Prometheus metrics on a separate port. @@ -311,7 +318,7 @@ async fn serve_unix(_path: &str, _router: axum::Router) -> Result<(), String> { } /// Resolve when the process receives SIGINT (Ctrl-C) or SIGTERM. -async fn shutdown_signal() { +pub(crate) async fn shutdown_signal() { let ctrl_c = async { let _ = tokio::signal::ctrl_c().await; }; diff --git a/bin/silverbullet/tests/multi_space.rs b/bin/silverbullet/tests/multi_space.rs new file mode 100644 index 00000000..8488760e --- /dev/null +++ b/bin/silverbullet/tests/multi_space.rs @@ -0,0 +1,204 @@ +//! Black-box tests for multi-space mode (SB_MULTI_SPACE=1): admin login, space +//! CRUD over the admin API, per-space routing and auth isolation. +//! +//! Each test spawns the compiled `silverbullet` binary as a subprocess against a +//! fresh temp root and drives its HTTP surface with `reqwest::blocking`. The +//! runtime API is disabled (`SB_RUNTIME_API=0`) so no headless Chrome launches. +//! +//! Note: readiness is probed via `/.admin/api/spaces` returning 401 (server up + +//! admin router mounted + gating active) rather than `/.admin/` returning 200 — +//! the admin SPA shell (`.client/admin.html`) is a later deliverable and is not +//! yet embedded in the client bundle. Every assertion below is independent of +//! that shell. + +use std::net::TcpListener; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; + +/// Wraps the spawned server process and kills + reaps it on drop. +struct Server(Child); +impl Drop for Server { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +fn free_port() -> u16 { + TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port() +} + +/// Spawn the binary in multi-space mode against a fresh temp root and wait for +/// the admin API to answer (401 on the gated spaces list). +fn start_multi(extra_env: &[(&str, &str)]) -> (Server, tempfile::TempDir, String) { + let root = tempfile::tempdir().unwrap(); + let port = free_port(); + let mut cmd = Command::new(env!("CARGO_BIN_EXE_silverbullet")); + cmd.arg(root.path()) + .arg("-p") + .arg(port.to_string()) + .arg("-L") + .arg("127.0.0.1") + .env("SB_MULTI_SPACE", "1") + .env("SB_USER", "admin:adminpw") + .env("SB_RUNTIME_API", "0") + .env("SB_DISABLE_SERVICE_WORKER", "1") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + for (k, v) in extra_env { + cmd.env(k, v); + } + let child = cmd.spawn().expect("spawn silverbullet"); + let server = Server(child); + let base = format!("http://127.0.0.1:{port}"); + + let client = reqwest::blocking::Client::new(); + let deadline = Instant::now() + Duration::from_secs(20); + loop { + if let Ok(resp) = client.get(format!("{base}/.admin/api/spaces")).send() { + if resp.status().as_u16() == 401 { + break; + } + } + assert!(Instant::now() < deadline, "server did not boot in time"); + std::thread::sleep(Duration::from_millis(200)); + } + (server, root, base) +} + +fn admin_client(base: &str) -> reqwest::blocking::Client { + let client = reqwest::blocking::Client::builder() + .cookie_store(true) + .build() + .unwrap(); + let resp = client + .post(format!("{base}/.admin/api/login")) + .json(&serde_json::json!({ "username": "admin", "password": "adminpw" })) + .send() + .unwrap(); + assert!(resp.status().is_success()); + assert_eq!(resp.json::().unwrap()["status"], "ok"); + client +} + +#[test] +fn boots_empty_root_redirects_to_admin_and_api_is_gated() { + let (_srv, _root, base) = start_multi(&[]); + let client = reqwest::blocking::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .unwrap(); + let resp = client.get(format!("{base}/")).send().unwrap(); + assert_eq!(resp.status().as_u16(), 307); + assert_eq!(resp.headers()["location"], "/.admin/"); + let resp = client + .get(format!("{base}/.admin/api/spaces")) + .send() + .unwrap(); + assert_eq!(resp.status().as_u16(), 401); +} + +#[test] +fn refuses_to_boot_without_sb_user() { + let root = tempfile::tempdir().unwrap(); + let out = Command::new(env!("CARGO_BIN_EXE_silverbullet")) + .arg(root.path()) + .arg("-p") + .arg(free_port().to_string()) + .env("SB_MULTI_SPACE", "1") + .env_remove("SB_USER") + .output() + .unwrap(); + assert!(!out.status.success()); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("SB_USER"), "{stderr}"); +} + +#[test] +fn create_spaces_and_verify_routing_and_auth_isolation() { + let (_srv, root, base) = start_multi(&[]); + let admin = admin_client(&base); + + // Open space at /open. + let resp = admin + .post(format!("{base}/.admin/api/spaces")) + .json(&serde_json::json!({ "name": "Open", "binding": { "prefix": "/open" }, "auth": { "mode": "none" } })) + .send() + .unwrap(); + assert!(resp.status().is_success(), "{}", resp.text().unwrap()); + + // Inherit-auth space at /locked. + admin + .post(format!("{base}/.admin/api/spaces")) + .json(&serde_json::json!({ "name": "Locked", "binding": { "prefix": "/locked" }, "auth": { "mode": "inherit" } })) + .send() + .unwrap(); + + // Port-bound space. + let space_port = free_port(); + admin + .post(format!("{base}/.admin/api/spaces")) + .json(&serde_json::json!({ "name": "Ported", "binding": { "port": space_port }, "auth": { "mode": "none" } })) + .send() + .unwrap(); + + let anon = reqwest::blocking::Client::new(); + // Open space serves reads and writes. + assert!(anon + .get(format!("{base}/open/.ping")) + .send() + .unwrap() + .status() + .is_success()); + assert!(anon + .put(format!("{base}/open/.fs/note.md")) + .body("hello") + .send() + .unwrap() + .status() + .is_success()); + assert!(anon + .get(format!("{base}/open/.fs/note.md")) + .send() + .unwrap() + .text() + .unwrap() + .contains("hello")); + + // Locked space 401s anonymously. + assert_eq!( + anon.get(format!("{base}/locked/.fs")) + .send() + .unwrap() + .status() + .as_u16(), + 401 + ); + + // Port space answers on its own port (listener may take a moment). + let deadline = Instant::now() + Duration::from_secs(10); + loop { + if let Ok(r) = anon + .get(format!("http://127.0.0.1:{space_port}/.ping")) + .send() + { + if r.status().is_success() { + break; + } + } + assert!(Instant::now() < deadline, "port space never came up"); + std::thread::sleep(Duration::from_millis(200)); + } + + // Config persisted under the root. + assert!(root.path().join("spaces.json").exists()); + // Index seeded in the default folder. + let spaces_dir = std::fs::read_dir(root.path().join("spaces")) + .unwrap() + .count(); + assert_eq!(spaces_dir, 3); +} diff --git a/build/build_client.ts b/build/build_client.ts index 90aafb70..d1410c2e 100644 --- a/build/build_client.ts +++ b/build/build_client.ts @@ -50,6 +50,16 @@ export async function buildClient(): Promise { }, ], splitting: false + }], + ["admin ui", { + ...baseBuildConfig, + entryPoints: [ + { + in: "client/admin_ui/admin.tsx", + out: ".client/admin", + }, + ], + splitting: false }] ] @@ -115,6 +125,8 @@ async function patchServiceWorker() { !f.endsWith(".map") && f !== "auth.html" && f !== "index.html" && + f !== "admin.html" && + f !== "admin.js" && f !== "LICENSE.md", ) .map((f) => `/.client/${f}`), diff --git a/client/admin_ui/admin.tsx b/client/admin_ui/admin.tsx new file mode 100644 index 00000000..e0bc62e1 --- /dev/null +++ b/client/admin_ui/admin.tsx @@ -0,0 +1,603 @@ +import { Fragment, render } from "preact"; +import { useEffect, useState } from "preact/hooks"; + +type Binding = { prefix?: string; host?: string; port?: number }; +type SpaceInfo = { + name: string; + folder: string; + binding: Binding; + auth: { mode: "inherit" | "custom" | "none"; user?: string }; + readOnly: boolean; + shell: { enabled: boolean; whitelist: string[] }; + runtimeApi: boolean; + indexPage: string; + hasPassword: boolean; + status: { state: "running" | "errored"; reason?: string }; +}; +type FieldError = { field: string; message: string }; + +async function api(method: string, path: string, body?: unknown): Promise { + const resp = await fetch(path, { + method, + headers: body ? { "Content-Type": "application/json" } : {}, + body: body ? JSON.stringify(body) : undefined, + }); + if (resp.status === 401) throw { unauthorized: true }; + const json = await resp.json().catch(() => ({})); + if (!resp.ok) + throw json.errors ?? [{ field: "", message: `HTTP ${resp.status}` }]; + return json; +} + +function formatApiError(e: unknown): string { + if (Array.isArray(e)) { + return e + .map((fe: FieldError) => (fe.field ? `${fe.field}: ${fe.message}` : fe.message)) + .join(", "); + } + return "Request failed"; +} + +/** Lower-cased, filesystem-safe version of a space name for its default folder. */ +function slugify(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +/** URL-ish display text for a binding; doubles as the link text in the list. */ +function bindingLabel(b: Binding): string { + const port = location.port ? `:${location.port}` : ""; + if (b.host) return `${b.host}${port}`; + if (b.port) return `${location.hostname}:${b.port}`; + return b.prefix || "/"; +} + +function spaceUrl(b: Binding): string { + // Host-bound spaces are served by the same listener as this admin page, so + // they live on the same port (e.g. http://test.localhost:3000/ in dev). + if (b.host) { + return `//${b.host}${location.port ? `:${location.port}` : ""}/`; + } + if (b.port) return `//${location.hostname}:${b.port}/`; + return `${b.prefix || ""}/`; +} + +function Login({ onDone }: { onDone: () => void }) { + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + return ( +
{ + e.preventDefault(); + const r = await api("POST", "api/login", { username, password }); + if (r.status === "ok") onDone(); + else setError(r.error ?? "Login failed"); + }} + > +

SilverBullet Admin

+ {error &&

{error}

} + + setUsername(e.currentTarget.value)} + /> + + setPassword(e.currentTarget.value)} + /> +
+ +
+
+ ); +} + +function FolderField({ + value, + onChange, +}: { + value: string; + onChange: (v: string) => void; +}) { + const [status, setStatus] = useState(""); + useEffect(() => { + const t = setTimeout(async () => { + if (!value) { + setStatus(""); + return; + } + try { + const r = await api( + "GET", + `api/fs/dirs?path=${encodeURIComponent(value)}`, + ); + setStatus(r.status); + } catch { + /* transient */ + } + }, 250); + return () => clearTimeout(t); + }, [value]); + return ( + + + onChange(e.currentTarget.value)} + /> + {value && status === "exists" && ( + ✓ directory exists + )} + {value && status === "missing" && will be created} + {value && status === "notADirectory" && ( + not a directory + )} + + ); +} + +function SpaceForm({ + id, + initial, + onSaved, + onCancel, +}: { + id?: string; + initial?: SpaceInfo; + onSaved: () => void; + onCancel: () => void; +}) { + const [name, setName] = useState(initial?.name ?? ""); + const [folder, setFolder] = useState(initial?.folder ?? ""); + // The folder and (prefix-type) binding value track a sanitized version of + // the name until the user edits them by hand (or the space already exists — + // then the stored values are authoritative). + const [folderTouched, setFolderTouched] = useState(!!initial); + const [bindValueTouched, setBindValueTouched] = useState(!!initial); + const [bindType, setBindType] = useState<"prefix" | "host" | "port">( + initial?.binding.host ? "host" : initial?.binding.port ? "port" : "prefix", + ); + const [bindValue, setBindValue] = useState( + initial?.binding.host ?? + initial?.binding.prefix ?? + String(initial?.binding.port ?? ""), + ); + const [authMode, setAuthMode] = useState(initial?.auth.mode ?? "inherit"); + const [authUser, setAuthUser] = useState(initial?.auth.user ?? ""); + const [password, setPassword] = useState(""); + const [readOnly, setReadOnly] = useState(initial?.readOnly ?? false); + const [shellEnabled, setShellEnabled] = useState( + initial?.shell.enabled ?? true, + ); + const [runtimeApi, setRuntimeApi] = useState(initial?.runtimeApi ?? false); + const [indexPage, setIndexPage] = useState(initial?.indexPage ?? "index"); + const [errors, setErrors] = useState([]); + const [portStatus, setPortStatus] = useState<{ + status: string; + reason: string; + } | null>(null); + const [hostStatus, setHostStatus] = useState< + "verified" | "mismatch" | "unreachable" | null + >(null); + + // Live port-availability check while a port binding is being edited. + useEffect(() => { + if (bindType !== "port" || !bindValue) { + setPortStatus(null); + return; + } + const t = setTimeout(async () => { + try { + const self = id ? `&self=${encodeURIComponent(id)}` : ""; + const r = await api( + "GET", + `api/net/port?port=${encodeURIComponent(bindValue)}${self}`, + ); + setPortStatus({ status: r.status, reason: r.reason ?? "" }); + } catch { + /* transient */ + } + }, 300); + return () => clearTimeout(t); + }, [bindType, bindValue, id]); + + // Live hostname check: probe the candidate hostname from the browser and + // compare the answering server's per-boot instance id with our own. Proves + // DNS + routing + proxy forwarding end to end (from this browser's vantage + // point). `/.instance` answers on any Host, so this works before the + // binding exists. + useEffect(() => { + if ( + bindType !== "host" || + !bindValue || + bindValue.includes("/") || + bindValue.includes(":") + ) { + setHostStatus(null); + return; + } + const t = setTimeout(async () => { + try { + const own = await (await fetch("/.instance")).json(); + const port = location.port ? `:${location.port}` : ""; + const probe = await fetch( + `${location.protocol}//${bindValue}${port}/.instance`, + { signal: AbortSignal.timeout(4000) }, + ); + const remote = await probe.json(); + setHostStatus(remote.instance === own.instance ? "verified" : "mismatch"); + } catch { + setHostStatus("unreachable"); + } + }, 400); + return () => clearTimeout(t); + }, [bindType, bindValue]); + + return ( +
{ + e.preventDefault(); + const binding: Binding = + bindType === "host" + ? { host: bindValue } + : bindType === "port" + ? { port: parseInt(bindValue, 10) } + : { prefix: bindValue }; + const auth = + authMode === "custom" + ? { mode: authMode, user: authUser } + : { mode: authMode }; + const payload = { + name, + folder, + binding, + auth, + readOnly, + shell: { + enabled: shellEnabled, + whitelist: initial?.shell.whitelist ?? [], + }, + runtimeApi, + indexPage, + }; + try { + let spaceId = id; + if (id) await api("PUT", `api/spaces/${id}`, payload); + else spaceId = (await api("POST", "api/spaces", payload)).id; + if (authMode === "custom" && password) { + await api("POST", `api/spaces/${spaceId}/password`, { password }); + } + onSaved(); + } catch (errs) { + setErrors( + Array.isArray(errs) + ? errs + : [{ field: "", message: "Request failed" }], + ); + } + }} + > +

{id ? "Edit space" : "Create space"}

+ {errors.map((e) => ( +

+ {e.field}: {e.message} +

+ ))} + + { + const newName = e.currentTarget.value; + setName(newName); + const slug = slugify(newName); + if (!folderTouched) { + setFolder(slug ? `spaces/${slug}` : ""); + } + if (!bindValueTouched && bindType === "prefix") { + setBindValue(slug ? `/${slug}` : ""); + } + }} + /> + + + + {/* Emulate the resulting URL: fixed scheme/host/port parts render as + affixes around the editable segment. */} +
+ + {bindType === "prefix" + ? location.origin + : bindType === "host" + ? `${location.protocol}//` + : `${location.protocol}//${location.hostname}:`} + + { + setBindValue(e.currentTarget.value); + setBindValueTouched(true); + }} + /> + {bindType === "host" && ( + + {location.port ? `:${location.port}/` : "/"} + + )} + {bindType === "port" && /} +
+ {bindType === "host" && hostStatus && ( + + {hostStatus === "verified" && ( + ✓ hostname reaches this server + )} + {hostStatus === "mismatch" && ( + + hostname reaches a different server + + )} + {hostStatus === "unreachable" && ( + + could not verify: hostname does not reach this server from your + browser (DNS or proxy not set up yet?) + + )} + + )} + {bindType === "port" && portStatus && ( + + {portStatus.status === "available" && ( + + ✓ port available + {portStatus.reason ? ` (${portStatus.reason})` : ""} + + )} + {portStatus.status === "inUse" && ( + port in use: {portStatus.reason} + )} + {portStatus.status === "invalid" && ( + {portStatus.reason} + )} + + )} + { + setFolder(v); + setFolderTouched(true); + }} + /> + + + {authMode === "custom" && ( + + + setAuthUser(e.currentTarget.value)} + /> + + setPassword(e.currentTarget.value)} + /> + + )} +
+ Advanced + + + + + setIndexPage(e.currentTarget.value)} + /> +
+
+ + +
+ + ); +} + +function App() { + const [phase, setPhase] = useState<"loading" | "login" | "list" | "form">( + "loading", + ); + const [spaces, setSpaces] = useState>({}); + const [editing, setEditing] = useState(undefined); + const [appError, setAppError] = useState(""); + + async function reload() { + try { + const s = await api("GET", "api/spaces"); + setSpaces(s); + setPhase("list"); + setAppError(""); + } catch (e: any) { + if (e.unauthorized) setPhase("login"); + else setAppError(formatApiError(e)); + } + } + useEffect(() => { + void reload(); + }, []); + + if (phase === "loading") return

Loading…

; + if (phase === "login") return ; + if (phase === "form") { + return ( + setPhase("list")} + /> + ); + } + const entries = Object.entries(spaces).sort((a, b) => + a[1].name.localeCompare(b[1].name), + ); + return ( + +

Spaces

+ {appError &&

{appError}

} + {entries.length === 0 &&

No spaces yet — create your first space.

} +
    + {entries.map(([id, s]) => ( +
  • + {s.name} + + {bindingLabel(s.binding)} + + + {s.status.state} + + + + + +
  • + ))} +
+
+ + +
+
+ ); +} + +render(, document.getElementById("root")!); diff --git a/client/boot.ts b/client/boot.ts index 00958bf4..db86f356 100644 --- a/client/boot.ts +++ b/client/boot.ts @@ -59,6 +59,10 @@ safeRun(async () => { // Not recoverable return; } + if (e.message === notAuthenticatedError.message) { + // A redirect to the login page is already under way; stop booting. + return; + } } // Concatenate and evaluate try { @@ -307,6 +311,14 @@ if (!globalThis.indexedDB) { ); } +/** + * Set once a boot fetch discovers it must navigate away (auth redirect or + * service-worker-reset reload). The navigation aborts the sibling in-flight + * boot fetches; this flag lets their error handlers report the redirect + * instead of misclassifying the abort as being offline. + */ +let redirectingAway = false; + async function cachedFetch(path: string): Promise { const cacheKey = `silverbullet.${document.baseURI}.${path}`; try { @@ -346,6 +358,7 @@ async function cachedFetch(path: string): Promise { ); await unregisterServiceWorkers(); console.log("Ok, now going to reload, let's hope for the best"); + redirectingAway = true; location.reload(); throw notAuthenticatedError; } @@ -355,14 +368,27 @@ async function cachedFetch(path: string): Promise { "Received an (authentication) redirect, redirecting to URL: " + redirectHeader, ); - location.href = redirectHeader; + // Only the first fetch to see the redirect navigates; its siblings just + // report the authentication failure. + if (!redirectingAway) { + redirectingAway = true; + location.href = redirectHeader; + } throw notAuthenticatedError; } const text = await response.text(); // Persist to localStorage localStorage.setItem(cacheKey, text); return text; - } catch { + } catch (e: any) { + if (e.message === notAuthenticatedError.message) { + throw e; + } + if (redirectingAway) { + // Our own redirect-to-login (or reload) navigation aborted this fetch — + // that's an authentication situation, not an offline one. + throw notAuthenticatedError; + } console.info("Falling back to cache for", path); // We may be offline, let's see if we have a cached copy const text = localStorage.getItem(cacheKey); diff --git a/client/html/admin.html b/client/html/admin.html new file mode 100644 index 00000000..1cfd614f --- /dev/null +++ b/client/html/admin.html @@ -0,0 +1,159 @@ + + + + + + + + SilverBullet Admin + + + +
+ + + diff --git a/docs/.silverbullet.auth.json b/docs/.silverbullet.auth.json new file mode 100644 index 00000000..154e711f --- /dev/null +++ b/docs/.silverbullet.auth.json @@ -0,0 +1,5 @@ +{ + "secret": "zWPkhQnSdDTB5QDORfQGmHDVuSduNwLKroXctIU4+3Y=", + "config_hash": "0c6f24ddc8da4c2f2e55d496cd30a64179b20f6ebe09621e7a0df9b35aa55cd0", + "salt": "YXyeDbwXZpVBkK4XJ0NgHQ==" +} \ No newline at end of file diff --git a/docs/Authentication.md b/docs/Authentication.md index ac946f87..a1f74a4b 100644 --- a/docs/Authentication.md +++ b/docs/Authentication.md @@ -15,6 +15,8 @@ SilverBullet’s built-in auth is a single set of credentials, set via the `SB_U If you need multi-user style access control (different people, SSO, MFA, …), put SilverBullet behind an [[Authentication Proxy]] (Authelia, Authentik, Cloudflare Access, etc.) and let that handle identity. +For running many separately-authenticated spaces from one server, see [[Multi-Space Mode]]. + # Enabling authentication Set `SB_USER` when starting the server. For the [[Install/Binary]]: diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 671f8262..d66fce3d 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -3,45 +3,27 @@ An attempt at documenting the changes/new features introduced in each release. ## Edge Whenever a commit is pushed to the `main` branch, within ~5 minutes, it will be released as a docker image with the `:v2` tag, and a binary in the [edge release](https://github.com/silverbulletmd/silverbullet/releases/tag/edge). If you want to live on the bleeding edge of SilverBullet goodness (or regression) this is where to do it. -* Fix: major typing/navigation slowdown on pages with many internal links in large spaces. -* New `index.describeSchema()` and `index.tagSchema(tag)` Space Lua APIs that expose indexed object-type / tag schemas as raw JSON Schema to scripts, widgets, and the `sb describe` CLI: `describeSchema()` returns a map of tag name → JSON Schema (only tags that declare a schema), and `tagSchema(tag)` returns a tag's JSON Schema or `nil` if undefined or schema-less. -* New `system.reboot()` Space Lua syscall: makes edited-on-disk changes live. -*-* **Baked sections**: bake `${...}` Lua expressions and widgets into - HTML-comment-delimited markdown (`` … ``) so - power-feature pages render in GitHub and other markdown tools. A block `${…}` - widget's **Bake** button (next to Copy) turns it into a baked section in - place; **"Baked Sections: Update"** (`Ctrl-Shift-b` / `Cmd-Shift-b`) - re-evaluates every baked section on the page and rewrites its body; and - **"Baked Sections: Unbake Section At Cursor"** restores the section under the - cursor to a live `${…}` directive for editing. See [[Baked Sections]]. The - **Bake** button and these commands are hidden on read-only pages and in - read-only spaces. -* Fix: frontmatter link live preview now follows the editor's regular markdown - preview behavior: raw YAML syntax stays visible when markdown syntax - rendering is enabled, and only the link currently being edited is revealed in - clean mode. -* Fix: tags shown in folded frontmatter now navigate to their tag pages instead - of unfolding the frontmatter block. -* Frontmatter in the editor now has configurable folding: by default long - frontmatter blocks fold automatically, and `frontmatterFolding` options let - you disable auto-folding, always fold frontmatter, or change the line - threshold. A subtle right-side marker folds or unfolds the whole block, and - folded frontmatter previews any `tags` value as tag chips. -* Fix: write-mode commands (those requiring read-write, e.g. the baking - commands) are now hidden in the command palette and their keybindings disabled - on **per-page** read-only pages (`perm: ro`), not just in fully read-only - spaces. -* HTML comments (both inline `` and block comments, including the - baked-section `` / `` markers) now render in a - subtle gray and slightly smaller font in the editor, like code comments. -* Backend and CLI (`silverbullet` and `sb` binaries) are now ported to Rust, both should be behavior preserving (that is: you shouldn’t really notice): +* **Multi-space mode**: set `SB_MULTI_SPACE=1` (plus `SB_USER` for admin credentials) to serve any number of spaces from one process. Spaces + are bound to a URL prefix, hostname, or dedicated port. See [[Multi-Space Mode]]. +* **Baked sections**: bake `${...}` Lua expressions and widgets into + HTML-comment-delimited markdown (`` … ``). See [[Baked Sections]]. +* Backend and CLI have been ported to Rust ([see background on this](https://no.silverbullet.plus/tech-stacks)), both should be behavior preserving (that is: you shouldn’t really notice): * The server backend (previously written in Go) has now been replaced by an adapted version of SilverBullet+‘s backend written in Rust, more unifying those code bases. * CLI client also reimplemented/backported to Rust as well. * This means the project is now all TypeScript + Rust. * The goal is to make do this without regressions, but watch for any issues. -* New [[API/codeWidget]] Lua API: register a renderer for a fenced code block language from Lua (e.g. ` ```mermaid `), no compiled plug required. A `render(body)` function receives the code block contents and returns a widget (or markdown/HTML). -* Fix: Space Lua now correctly truncates a parenthesized expression to a single value (Lua 5.4 semantics). Previously `(string.gsub(...))` and other parenthesized multi-return calls leaked their extra return values into `return`, call-argument, and assignment positions (e.g. `table.insert(t, (string.gsub(...)))` inserted two elements). Parentheses now yield exactly one value. +* Fix: major typing/navigation slowdown on pages with many internal links in large spaces. +* New `index.describeSchema()` and `index.tagSchema(tag)` Space Lua APIs that expose indexed object-type / tag schemas as raw JSON Schema to scripts, widgets, and the `sb describe` CLI: `describeSchema()` returns a map of tag name → JSON Schema (only tags that declare a schema), and `tagSchema(tag)` returns a tag's JSON Schema or `nil` if undefined or schema-less. +* New `system.reboot()` Space Lua syscall: makes edited-on-disk changes live. +* Fix: first-ever load of an authenticated space no longer shows a spurious "Could not process config and no cached copy, please connect to the Internet" alert before redirecting to the login page (the login redirect aborted the remaining boot requests, which were misread as being offline). +* Fix: frontmatter link live preview now follows the editor's regular markdown preview behavior: raw YAML syntax stays visible when markdown syntax rendering is enabled, and only the link currently being edited is revealed in clean mode. +* Fix: tags shown in folded frontmatter now navigate to their tag pages instead of unfolding the frontmatter block. +* Frontmatter in the editor now has configurable folding: by default long frontmatter blocks fold automatically, and `frontmatterFolding` options let you disable auto-folding, always fold frontmatter, or change the line threshold. A subtle right-side marker folds or unfolds the whole block, and folded frontmatter previews any `tags` value as tag chips. +* Fix: write-mode commands (those requiring read-write, e.g. the baking commands) are now hidden in the command palette and their keybindings disabled on **per-page** read-only pages (`perm: ro`), not just in fully read-only spaces. * Runtime API: the embedded headless-Chrome runtime now logs its lifecycle (when it launches on first use, when it becomes ready, and on crash/restart), and forwards the headless page‘s `console.*` output to the server log by default (disable with `SB_CHROME_LOG_CONSOLE=0` see [[Install/Configuration]]). +* HTML comments (both inline `` and block comments, including the baked-section `` / `` markers) now render in a subtle gray and slightly smaller font in the editor, like code comments. +* New [[API/codeWidget]] Lua API: register a renderer for a fenced code block language from Lua (e.g. ` ```mermaid `), previously only possible with plugs. A `render(body)` function receives the code block contents and returns a widget (or markdown/HTML). +* Fix: Space Lua now correctly truncates a parenthesized expression to a single value (Lua 5.4 semantics). Previously `(string.gsub(...))` and other parenthesized multi-return calls leaked their extra return values into `return`, call-argument, and assignment positions (e.g. `table.insert(t, (string.gsub(...)))` inserted two elements). Parentheses now yield exactly one value. * Fix: on Safari/WebKit, the first keystroke right after a paste could be inserted at the wrong position (e.g. pasting a URL inside `[text]()` and then pressing `)` produced `[text]()url)` instead of typing over the closing bracket). WebKit left the typing caret at the pre-paste position; the editor now re-syncs it after a paste. * Navigating to a page via a link now always opens it fresh (at the top, or at an explicit `#header`/`@pos` pointer in the link) instead of restoring your previous cursor and scroll position. Returning to a page via browser Back/Forward or the [[Page Picker]] still restores where you were. Plugs/Lua can opt into restoring with the new `editor.open` syscall (see [[API/editor]]). diff --git a/docs/Install.md b/docs/Install.md index 6c6c074e..651fc9dc 100644 --- a/docs/Install.md +++ b/docs/Install.md @@ -29,6 +29,8 @@ There are three things to take care of, in this order (follow the links in each 3. Deploy a [[TLS]] layer front of SilverBullet: browsers require `https://` (or `localhost`) for SilverBullet’s service worker, crypto, and clipboard APIs to work, so _you cannot_ reach a remote SilverBullet server over plain `http://`. 5. Once that’s all set up, follow [[Getting Started]] to learn the basics of using SilverBullet itself. +Hosting more than one space? Rather than running a separate server process per space, consider [[Multi-Space Mode]]: one process serves any number of spaces, each with its own URL, authentication, and admin UI. + # Cloud While [[Self Hosted]] is the intended path, if this is too much hassle for you. There is a simpler option by using [PikaPods](https://www.pikapods.com/pods?run=silverbullet). For a small fee (about $1.50 per month), you can run your instance there. PikaPods handles deployment, upgrades and backups and exposes SilverBullet securely via TLS. diff --git a/docs/Install/Configuration.md b/docs/Install/Configuration.md index 2d55e741..aae5c149 100644 --- a/docs/Install/Configuration.md +++ b/docs/Install/Configuration.md @@ -40,6 +40,9 @@ This is the default and simplest backend to use: a folder on disk. It is configu # Run mode * `SB_READ_ONLY` (==Experimental==): If you want to run the SilverBullet client and server in read-only mode (you get the full SilverBullet client, but all edit functionality and commands are disabled), you can do this by setting this environment variable to a non-empty value. Upon the server start a full space index will happen, after which all write operations will be disabled. +# Multi-space mode +* `SB_MULTI_SPACE`: Set to `1` to run the server in [[Multi-Space Mode]], serving any number of spaces (each with its own binding, auth, and settings in `spaces.json`) instead of a single space. Requires `SB_USER` for admin credentials; most other per-space `SB_*` variables (e.g. `SB_READ_ONLY`, `SB_NAME`) are ignored since those become per-space settings. + # Runtime API * `SB_RUNTIME_API`: The [[Runtime API]] is enabled automatically when Chrome/Chromium is detected on the system. Set to `0` to explicitly disable. Not available in read-only mode. * `SB_CHROME_PATH`: Optional explicit path to the Chrome/Chromium binary. Falls back to the `CHROMIUM_PATH` environment variable (pre-set in the `-runtime-api` Docker image), then auto-detection. diff --git a/docs/Multi-Space Mode.md b/docs/Multi-Space Mode.md new file mode 100644 index 00000000..6ce20757 --- /dev/null +++ b/docs/Multi-Space Mode.md @@ -0,0 +1,65 @@ +--- +tags: maturity/beta +references: +- bin/silverbullet/src/multi.rs +- server/src/multi/config.rs +- server/src/multi/validate.rs +- server/src/multi/dispatch.rs +- server/src/multi/admin_api.rs +- server/src/multi/listeners.rs +- server/src/auth/cookie.rs +--- + +Multi-space mode lets a single SilverBullet server host any number of [[Space|spaces]], each with its own URL, authentication, and configuration, replacing a fleet of separate server processes with one process, one config file, and one admin UI. + +# Enabling +Multi-Space is a server wide mode that is disabled by default it. By setting the `SB_MULTI_SPACE=1` you enable it. In this mode setting up SB_USER-bsed authentication (used for the admin account) is mandatory. + +For instance: + +```shell +SB_MULTI_SPACE=1 SB_USER=admin:s3cret silverbullet /var/lib/silverbullet +``` + +Notes: +* `SB_USER` is **required**: it becomes the admin credential for the management UI (and the default credential for spaces using `inherit` auth). +* The folder passed in holds `spaces.json` (all space configs), the admin auth state, and, potentially the space folders themselves under `spaces/` (although folders outside this folder can be configured as well). +* `SB_HOSTNAME`/`SB_PORT` configure the main listener. Space-level variables like `SB_READ_ONLY` or `SB_NAME` are ignored in this mode: those settings live per space. + +On first start no spaces exist: visiting `/` redirects to the admin UI at `/.admin/`, where you log in with the `SB_USER` credentials and create your first space. + +# Bindings +Each space is reachable one of three ways: +* **URL prefix**: e.g. `/work` on the main listener. The prefix must contain at least one path segment (a bare `/` is not allowed) and prefixes must not overlap (`/work` and `/work/sub` cannot coexist); prefixes starting with `/.` are reserved. +* **Hostname**: e.g. `notes.example.com`, matched on the `Host` header of the main listener. Point wildcard DNS or per-host reverse-proxy rules at the server. +* **Port**: a dedicated port, bound and released live as you add/remove spaces. + +# Per-space authentication +Every space has an auth mode: +* **inherit** (default): the space accepts the admin credentials. Because it inherits the full admin credential set, it also accepts the admin `SB_AUTH_TOKEN` as a bearer token for API access. +* **custom**: a space-specific username and password. Passwords are stored as hashes in `spaces.json`. There is no password recovery, just set a new password from the admin UI if lost. +* **none**: an open space, e.g. a public read-only wiki (be sure to enabled “read only” mode for these). + +Session cookies are scoped per space, so logging into one space never affects another, even on the same hostname. + +# The spaces.json format +`spaces.json` maps a generated ID to each space's configuration. It is managed by the admin UI, but hand-editing is fine while the server is stopped (changes on disk are read at startup). All single-space server settings have per-space equivalents: `readOnly`, `shell`, `runtimeApi` (off by default here), `indexPage`, `description`, `themeColor`, `headHtml`, `spaceIgnore`, `logPush`. + +```json +{ + "8b1c9e4e-…": { + "name": "Work notes", + "folder": "spaces/8b1c9e4e-…", + "binding": { "prefix": "/work" }, + "auth": { "mode": "inherit" }, + "readOnly": false + } +} +``` + +Deleting a space from the admin UI removes it from this file only, files on disk are never deleted. + +# Notes and limitations +* Multi-space mode always requires admin authentication, there is no open variant. +* Spaces share one OS process and user: this mode is built for a household/team of trusted spaces, not hostile multi-tenancy. +* The runtime API (`runtimeApi`) launches one headless Chrome per enabled space, lazily, it is off by default. diff --git a/e2e/auth-first-load.test.ts b/e2e/auth-first-load.test.ts new file mode 100644 index 00000000..b9b2f9b4 --- /dev/null +++ b/e2e/auth-first-load.test.ts @@ -0,0 +1,70 @@ +import { expect, test } from "@playwright/test"; +import { type ChildProcess, spawn } from "node:child_process"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { getFreePort, waitForServer } from "./fixtures"; + +// Regression test: on the FIRST-ever visit to an authenticated space (empty +// localStorage, no session), the boot fetches all 401 and the client redirects +// to the login page. The redirect used to abort the sibling in-flight boot +// fetches, which were then misclassified as "offline" and surfaced a spurious +// "Could not process config and no cached copy" alert right before the login +// page appeared. + +let proc: ChildProcess; +let spaceDir: string; +let base: string; + +test.beforeAll(async () => { + spaceDir = await mkdtemp(join(tmpdir(), "sb-auth-e2e-")); + const port = await getFreePort(); + proc = spawn( + "./target/debug/silverbullet", + [spaceDir, "-p", String(port), "-L", "127.0.0.1"], + { + cwd: join(import.meta.dirname, ".."), + stdio: ["ignore", "pipe", "pipe"], + env: { + ...process.env, + SB_USER: "alice:s3cret", + SB_RUNTIME_API: "0", + SB_DISABLE_SERVICE_WORKER: "1", + }, + }, + ); + base = `http://127.0.0.1:${port}`; + // The SPA shell is served openly, so /.ping answers even without a session. + await waitForServer(`${base}/.ping`); +}); + +test.afterAll(async () => { + proc?.kill(); + await rm(spaceDir, { recursive: true, force: true }); +}); + +test("first load of an authenticated space redirects to login without alerts", async ({ page }) => { + const dialogs: string[] = []; + page.on("dialog", async (dialog) => { + dialogs.push(dialog.message()); + await dialog.dismiss(); + }); + + await page.goto(`${base}/`); + // The boot code discovers it is unauthenticated and redirects to the login + // page. + await expect(page.locator("#username")).toBeVisible({ timeout: 30_000 }); + // Give any straggling (aborted) boot fetch time to surface a dialog. + await page.waitForTimeout(1500); + expect(dialogs, `unexpected dialogs: ${dialogs.join(" | ")}`).toEqual([]); + + // And logging in still works end to end. + await page.locator("#username").fill("alice"); + await page.locator("#password").fill("s3cret"); + await page.getByRole("button", { name: "Login" }).click(); + await page.waitForURL(`${base}/`); + await expect(page.locator("#sb-editor .cm-editor")).toBeVisible({ + timeout: 30_000, + }); + expect(dialogs, `unexpected dialogs: ${dialogs.join(" | ")}`).toEqual([]); +}); diff --git a/e2e/multi-space-admin.test.ts b/e2e/multi-space-admin.test.ts new file mode 100644 index 00000000..4b14cd86 --- /dev/null +++ b/e2e/multi-space-admin.test.ts @@ -0,0 +1,81 @@ +import { expect, test } from "@playwright/test"; +import { type ChildProcess, spawn } from "node:child_process"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { getFreePort, waitForEditorReady, waitForServer } from "./fixtures"; + +let proc: ChildProcess; +let rootDir: string; +let base: string; + +test.beforeAll(async () => { + rootDir = await mkdtemp(join(tmpdir(), "sb-multi-e2e-")); + const port = await getFreePort(); + proc = spawn( + "./target/debug/silverbullet", + [rootDir, "-p", String(port), "-L", "127.0.0.1"], + { + cwd: join(import.meta.dirname, ".."), + stdio: ["ignore", "pipe", "pipe"], + env: { + ...process.env, + SB_MULTI_SPACE: "1", + SB_USER: "admin:adminpw", + // See fixtures.ts: with the service worker disabled and + // `?headless=1` on navigation, the client uses its own in-page + // runtime, so the server never needs to spawn a headless + // Chrome for the runtime API. + SB_RUNTIME_API: "0", + SB_DISABLE_SERVICE_WORKER: "1", + }, + }, + ); + base = `http://127.0.0.1:${port}`; + await waitForServer(`${base}/.admin/`); +}); + +test.afterAll(async () => { + proc?.kill(); + await rm(rootDir, { recursive: true, force: true }); +}); + +test("first run: login, create a space, open it, edit a page", async ({ page }) => { + // Root redirects to the admin UI on a fresh server. + await page.goto(`${base}/`); + await expect(page).toHaveURL(/\/\.admin\//); + + // Log in. + await page.getByLabel("Username").fill("admin"); + await page.getByLabel("Password").fill("adminpw"); + await page.getByRole("button", { name: "Log in" }).click(); + + // Empty list -> create a space. + await expect(page.getByText("No spaces yet")).toBeVisible(); + await page.getByRole("button", { name: "Create space" }).click(); + await page.getByLabel("Name").fill("Playground"); + // The binding-value input's label is dynamic ("Prefix"/"Hostname"/"Port") + // and defaults to "Prefix" for the default "URL prefix" binding type. + await page.getByLabel("Prefix").fill("/play"); + await page.getByLabel("Authentication").selectOption("none"); + await page.getByRole("button", { name: "Create" }).click(); + + // It shows as running. + await expect(page.getByText("Playground")).toBeVisible(); + await expect(page.locator(".sb-badge.running")).toBeVisible(); + + // Open the space and verify the editor loads. Mirror the boot conventions + // from e2e/first-load.test.ts / fixtures.ts: navigate with `?headless=1` + // (so the server doesn't need a headless-Chrome runtime API), wait for + // `#sb-editor .cm-editor` to be visible, then wait for the client runtime + // to report ready before interacting with the page. + await page.goto(`${base}/play/?headless=1`); + await page.locator("#sb-editor .cm-editor").waitFor({ state: "visible", timeout: 30_000 }); + await waitForEditorReady(page); + + // Type into the page and confirm content sticks. + const editor = page.locator("#sb-editor .cm-content"); + await editor.click(); + await page.keyboard.type("Hello from multi-space"); + await expect(editor).toContainText("Hello from multi-space"); +}); diff --git a/server/Cargo.toml b/server/Cargo.toml index 77cb8d3f..25bfa834 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -27,9 +27,11 @@ pulldown-cmark = { workspace = true } minijinja = { workspace = true } prometheus = { workspace = true } thiserror = { workspace = true } +argon2 = { workspace = true } +uuid = { workspace = true } +tower = { version = "0.5", features = ["util"] } [dev-dependencies] silverbullet-server-common = { path = "../server-common", features = ["testing"] } tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync"] } -tower = { version = "0.5", features = ["util"] } tempfile = "3" diff --git a/server/src/auth/config.rs b/server/src/auth/config.rs index 2a51684c..01802689 100644 --- a/server/src/auth/config.rs +++ b/server/src/auth/config.rs @@ -4,7 +4,7 @@ pub struct AuthConfig { pub user: String, pub pass: String, - /// Optional bearer token for programmatic access (empty = none). + pub pass_hash: Option, pub auth_token: String, pub lockout_limit: u32, pub lockout_time_secs: u64, @@ -54,6 +54,7 @@ impl AuthConfig { Ok(Some(Self { user: u.to_string(), pass: p.to_string(), + pass_hash: None, auth_token: token.unwrap_or("").to_string(), lockout_limit: lockout_limit.and_then(|v| v.parse().ok()).unwrap_or(10), lockout_time_secs: lockout_time.and_then(|v| v.parse().ok()).unwrap_or(60), @@ -89,6 +90,15 @@ impl AuthConfig { h.update(field.as_bytes()); h.update([0u8]); // domain separator between fields } + // Only fold `pass_hash` in when it is set, so existing single-space + // servers (which always have `pass_hash: None`) keep the legacy digest + // and don't have their persisted sessions invalidated on upgrade. A + // domain tag distinguishes `None` from `Some("")`. + if let Some(pass_hash) = &self.pass_hash { + h.update(b"pass_hash:"); + h.update(pass_hash.as_bytes()); + h.update([0u8]); + } let digest = h.finalize(); let mut s = String::with_capacity(digest.len() * 2); for b in digest { @@ -99,9 +109,12 @@ impl AuthConfig { /// Whether the supplied credentials match. pub fn authorize(&self, user: &str, pass: &str) -> bool { - // Compare both fields in constant time to avoid leaking which differs. - constant_time_eq(user.as_bytes(), self.user.as_bytes()) - & constant_time_eq(pass.as_bytes(), self.pass.as_bytes()) + let user_ok = constant_time_eq(user.as_bytes(), self.user.as_bytes()); + let pass_ok = match &self.pass_hash { + Some(hash) => crate::auth::password::verify_password(pass, hash), + None => constant_time_eq(pass.as_bytes(), self.pass.as_bytes()), + }; + user_ok & pass_ok } } @@ -158,4 +171,74 @@ mod tests { fn invalid_user_format_is_error() { assert!(AuthConfig::try_parse(Some("nocolon"), None, None, None, None).is_err()); } + + #[test] + fn pass_hash_takes_precedence_over_plaintext() { + let phc = crate::auth::password::hash_password("hunter2").unwrap(); + let mut c = AuthConfig::parse(Some("alice:ignored"), None, None, None, None).unwrap(); + c.pass_hash = Some(phc); + assert!(c.authorize("alice", "hunter2")); + assert!( + !c.authorize("alice", "ignored"), + "plaintext field must be ignored when hash set" + ); + assert!(!c.authorize("bob", "hunter2")); + } + + #[test] + fn security_hash_changes_when_pass_hash_changes() { + let mut a = AuthConfig::parse(Some("u:p"), None, None, None, None).unwrap(); + let b = a.clone(); + a.pass_hash = Some(crate::auth::password::hash_password("x").unwrap()); + assert_ne!(a.security_hash(), b.security_hash()); + } + + /// Compute the pre-multi-space (legacy) digest inline: SHA-256 over + /// user/pass/auth_token/lockout_limit/lockout_time/remember_me, each + /// followed by a 0u8 separator. Must equal `security_hash()` when + /// `pass_hash` is `None`, so existing servers keep their session secret. + fn legacy_digest(c: &AuthConfig) -> String { + use sha2::{Digest, Sha256}; + let mut h = Sha256::new(); + for field in [ + c.user.as_str(), + c.pass.as_str(), + c.auth_token.as_str(), + &c.lockout_limit.to_string(), + &c.lockout_time_secs.to_string(), + &c.remember_me_hours.to_string(), + ] { + h.update(field.as_bytes()); + h.update([0u8]); + } + let digest = h.finalize(); + let mut s = String::with_capacity(digest.len() * 2); + for b in digest { + s.push_str(&format!("{b:02x}")); + } + s + } + + #[test] + fn security_hash_is_bit_compatible_when_pass_hash_none() { + let c = AuthConfig::parse( + Some("alice:s3cret"), + Some("tok"), + Some("5"), + Some("30"), + Some("48"), + ) + .unwrap(); + assert!(c.pass_hash.is_none()); + assert_eq!(c.security_hash(), legacy_digest(&c)); + } + + #[test] + fn security_hash_some_differs_from_none() { + let none = AuthConfig::parse(Some("u:p"), None, None, None, None).unwrap(); + let mut some = none.clone(); + // Even an empty `Some` must differ from `None` (domain tag). + some.pass_hash = Some(String::new()); + assert_ne!(some.security_hash(), none.security_hash()); + } } diff --git a/server/src/auth/cookie.rs b/server/src/auth/cookie.rs index d7cb4e8e..2e61308c 100644 --- a/server/src/auth/cookie.rs +++ b/server/src/auth/cookie.rs @@ -1,16 +1,29 @@ //! Cookie-name derivation and `Set-Cookie` construction for the standalone //! server's session cookies. The cookie name is derived from the request -//! `Host` so a browser keeps one session per host:port. +//! `Host` and the space's URL prefix (`auth_`), so a browser +//! keeps one session per host:port and per prefix-bound space sharing a host. +//! An empty prefix yields the legacy host-only `auth_` name. use axum::http::HeaderMap; use regex::Regex; use std::sync::OnceLock; -/// `auth_` with every non-word (`\W`) char in `host` replaced by `_`. -pub fn auth_cookie_name(host: &str) -> String { +/// `auth_` with every non-word (`\W`) char replaced by `_`. +/// The prefix slug keeps sessions separate when multiple spaces share a host +/// under different URL prefixes. Empty prefix ⇒ the legacy `auth_` name. +pub fn scoped_auth_cookie_name(host: &str, url_prefix: &str) -> String { static RE: OnceLock = OnceLock::new(); let re = RE.get_or_init(|| Regex::new(r"\W").unwrap()); - format!("auth_{}", re.replace_all(host, "_")) + format!( + "auth_{}{}", + re.replace_all(host, "_"), + re.replace_all(url_prefix, "_") + ) +} + +/// Legacy unscoped name (single-space servers without a URL prefix). +pub fn auth_cookie_name(host: &str) -> String { + scoped_auth_cookie_name(host, "") } /// The request `Host` header, or `""` when absent. @@ -122,4 +135,29 @@ mod tests { let v = set_cookie_value("auth_h", "", &opts); assert!(v.contains("; Max-Age=0")); } + + #[test] + fn scoped_name_with_empty_prefix_matches_legacy() { + assert_eq!( + scoped_auth_cookie_name("localhost:3000", ""), + auth_cookie_name("localhost:3000") + ); + assert_eq!(scoped_auth_cookie_name("localhost", ""), "auth_localhost"); + } + + #[test] + fn scoped_name_slugifies_prefix() { + assert_eq!( + scoped_auth_cookie_name("localhost", "/work"), + "auth_localhost_work" + ); + assert_eq!( + scoped_auth_cookie_name("h.example.com", "/a/b"), + "auth_h_example_com_a_b" + ); + assert_eq!( + scoped_auth_cookie_name("localhost", "/.admin"), + "auth_localhost__admin" + ); + } } diff --git a/server/src/auth/jwt_authorizer.rs b/server/src/auth/jwt_authorizer.rs index 87362452..efbc5f76 100644 --- a/server/src/auth/jwt_authorizer.rs +++ b/server/src/auth/jwt_authorizer.rs @@ -5,22 +5,36 @@ use axum::http::HeaderMap; use crate::auth::authenticator::Authenticator; use crate::auth::authorizer::{AuthContext, RequestAuthorizer}; use crate::auth::config::constant_time_eq; -use crate::auth::cookie::{auth_cookie_name, request_host}; +use crate::auth::cookie::{request_host, scoped_auth_cookie_name}; /// The standalone server's authorizer: a request is authorized if it carries the /// configured bearer token (constant-time compared) or a valid session JWT in -/// the host-derived auth cookie (`auth_`). +/// the scoped auth cookie. The cookie name is derived from the request `Host` +/// and this authorizer's URL prefix (`auth_`), so +/// sessions stay separate when multiple spaces share a host under different +/// prefixes; an empty prefix yields the legacy `auth_` name. pub struct JwtAuthorizer { authenticator: Arc, /// Optional bearer token (empty disables bearer auth). auth_token: String, + /// URL prefix this authorizer's space is mounted under (cookie scoping). + url_prefix: String, } impl JwtAuthorizer { pub fn new(authenticator: Arc, auth_token: String) -> Self { + Self::with_prefix(authenticator, auth_token, String::new()) + } + + pub fn with_prefix( + authenticator: Arc, + auth_token: String, + url_prefix: String, + ) -> Self { Self { authenticator, auth_token, + url_prefix, } } } @@ -34,7 +48,7 @@ impl RequestAuthorizer for JwtAuthorizer { } } } - let name = auth_cookie_name(&request_host(ctx.headers)); + let name = scoped_auth_cookie_name(&request_host(ctx.headers), &self.url_prefix); if let Some(cookie) = cookie_value(ctx.headers, &name) { if self.authenticator.verify_jwt(&cookie).is_ok() { return true; @@ -148,4 +162,27 @@ mod tests { let h = HeaderMap::new(); assert!(!authz().is_authorized(&ctx(&h))); } + + #[test] + fn prefixed_authorizer_reads_scoped_cookie_only() { + let auth = std::sync::Arc::new(Authenticator::from_secret_bytes(vec![3u8; 32], "h".into())); + let token = auth.issue_jwt("alice", 3600).unwrap(); + let a = JwtAuthorizer::with_prefix(auth, String::new(), "/work".into()); + // Scoped cookie: accepted. + let mut h = HeaderMap::new(); + h.insert("host", HeaderValue::from_static("localhost")); + h.insert( + "cookie", + HeaderValue::from_str(&format!("auth_localhost_work={token}")).unwrap(), + ); + assert!(a.is_authorized(&ctx(&h))); + // Unscoped cookie: rejected by the prefixed authorizer. + let mut h2 = HeaderMap::new(); + h2.insert("host", HeaderValue::from_static("localhost")); + h2.insert( + "cookie", + HeaderValue::from_str(&format!("auth_localhost={token}")).unwrap(), + ); + assert!(!a.is_authorized(&ctx(&h2))); + } } diff --git a/server/src/auth/mod.rs b/server/src/auth/mod.rs index 88679eb2..7e635300 100644 --- a/server/src/auth/mod.rs +++ b/server/src/auth/mod.rs @@ -11,11 +11,14 @@ pub mod headless_token; pub mod jwt_authorizer; pub mod lockout; pub mod login; +pub mod password; pub use authenticator::Authenticator; pub use authorizer::{AuthContext, RequestAuthorizer}; pub use config::AuthConfig; -pub use cookie::{auth_cookie_name, is_secure_request, request_host, CookieOptions}; +pub use cookie::{ + auth_cookie_name, is_secure_request, request_host, scoped_auth_cookie_name, CookieOptions, +}; pub use headless_token::HeadlessTokenAuthorizer; pub use jwt_authorizer::JwtAuthorizer; pub use lockout::LockoutTimer; diff --git a/server/src/auth/password.rs b/server/src/auth/password.rs new file mode 100644 index 00000000..1a94e3d9 --- /dev/null +++ b/server/src/auth/password.rs @@ -0,0 +1,63 @@ +//! Argon2id password hashing for multi-space per-space credentials. PHC-format +//! strings (`$argon2id$v=19$…`) are stored in `spaces.json`; plaintext never +//! lands on disk in multi-space mode. + +use argon2::password_hash::rand_core::OsRng; +use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}; +use argon2::Argon2; + +/// Hash a plaintext password to an argon2id PHC string (default params). +pub fn hash_password(plain: &str) -> Result { + let salt = SaltString::generate(&mut OsRng); + Argon2::default() + .hash_password(plain.as_bytes(), &salt) + .map(|h| h.to_string()) + .map_err(|e| format!("password hashing failed: {e}")) +} + +/// Verify a plaintext password against a PHC string. Any parse/verify error is +/// a mismatch (never panics on malformed input). +pub fn verify_password(plain: &str, phc: &str) -> bool { + PasswordHash::new(phc) + .map(|h| { + Argon2::default() + .verify_password(plain.as_bytes(), &h) + .is_ok() + }) + .unwrap_or(false) +} + +/// Whether `phc` parses as a valid PHC hash string (config validation). +pub fn is_valid_phc(phc: &str) -> bool { + PasswordHash::new(phc).is_ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hash_verifies_and_wrong_password_fails() { + let phc = hash_password("s3cret").unwrap(); + assert!(phc.starts_with("$argon2id$"), "{phc}"); + assert!(verify_password("s3cret", &phc)); + assert!(!verify_password("wrong", &phc)); + } + + #[test] + fn hashes_are_salted_uniquely() { + assert_ne!(hash_password("x").unwrap(), hash_password("x").unwrap()); + } + + #[test] + fn phc_validity_check() { + assert!(is_valid_phc(&hash_password("x").unwrap())); + assert!(!is_valid_phc("not-a-hash")); + assert!(!is_valid_phc("")); + } + + #[test] + fn garbage_phc_never_verifies() { + assert!(!verify_password("x", "garbage")); + } +} diff --git a/server/src/handlers/auth.rs b/server/src/handlers/auth.rs index 6a3ece84..be30367f 100644 --- a/server/src/handlers/auth.rs +++ b/server/src/handlers/auth.rs @@ -7,7 +7,7 @@ use axum::{Form, Json}; use serde::Deserialize; use serde_json::json; -use crate::auth::{auth_cookie_name, is_secure_request, request_host, CookieOptions}; +use crate::auth::{is_secure_request, request_host, CookieOptions}; use crate::router::run_blocking; use crate::state::ServerState; @@ -126,7 +126,7 @@ pub async fn handle_auth_post( }; let mut resp = Json(json!({ "status": "ok", "redirect": redirect })).into_response(); - let cookie_name = auth_cookie_name(&host); + let cookie_name = crate::auth::scoped_auth_cookie_name(&host, login.host_url_prefix()); append_cookie(&mut resp, &cookie_name, &jwt, &opts); if remember { append_cookie(&mut resp, "refreshLogin", "true", &opts); @@ -169,7 +169,12 @@ pub async fn handle_logout(State(state): State>, headers: Heade .header(axum::http::header::LOCATION, location) .body(axum::body::Body::empty()) .unwrap(); - append_cookie(&mut resp, &auth_cookie_name(&host), "", &del); + append_cookie( + &mut resp, + &crate::auth::scoped_auth_cookie_name(&host, &prefix), + "", + &del, + ); append_cookie(&mut resp, "refreshLogin", "", &del); resp } diff --git a/server/src/lib.rs b/server/src/lib.rs index 0338a89e..812208ad 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -7,6 +7,7 @@ pub mod auth; pub mod handlers; pub mod metrics; +pub mod multi; pub mod router; pub mod runtime; pub mod shell; diff --git a/server/src/multi/admin_api.rs b/server/src/multi/admin_api.rs new file mode 100644 index 00000000..799822bf --- /dev/null +++ b/server/src/multi/admin_api.rs @@ -0,0 +1,852 @@ +//! The `/.admin` surface: login (admin credentials from SB_USER), the admin +//! SPA shell + assets, and the space-management REST API. Sessions use the +//! `auth___admin` cookie scoped to Path=/.admin. + +use std::sync::Arc; + +use axum::extract::{Path as AxumPath, Request, State}; +use axum::http::{header, StatusCode}; +use axum::middleware::{self, Next}; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use serde::Deserialize; +use serde_json::json; +use silverbullet_server_common::SpacePrimitives; + +use crate::auth::cookie::set_cookie_value; +use crate::auth::{ + is_secure_request, request_host, scoped_auth_cookie_name, AuthConfig, Authenticator, + CookieOptions, JwtAuthorizer, LockoutTimer, LoginManager, RequestAuthorizer, +}; +use crate::multi::config::SpaceConfig; +use crate::multi::manager::{ApiError, MultiManager}; +use crate::router::run_blocking; + +pub const ADMIN_PREFIX: &str = "/.admin"; + +pub struct AdminState { + pub manager: Arc, + pub login: Arc, + pub authorizer: Arc, + pub client_bundle: Box, +} + +impl AdminState { + pub fn new( + manager: Arc, + authenticator: Arc, + auth_config: AuthConfig, + client_bundle: Box, + ) -> Self { + let authorizer: Arc = Arc::new(JwtAuthorizer::with_prefix( + authenticator.clone(), + auth_config.auth_token.clone(), + ADMIN_PREFIX.to_string(), + )); + let lockout = + LockoutTimer::from_config(auth_config.lockout_time_secs, auth_config.lockout_limit); + let login = Arc::new(LoginManager::new( + authenticator, + auth_config, + lockout, + ADMIN_PREFIX.to_string(), + )); + Self { + manager, + login, + authorizer, + client_bundle, + } + } +} + +/// 401 for API routes without a valid admin session. +async fn require_admin(State(state): State>, req: Request, next: Next) -> Response { + let authorized = { + let ctx = crate::auth::AuthContext { + method: req.method(), + path: req.uri().path(), + query: req.uri().query(), + headers: req.headers(), + }; + state.authorizer.is_authorized(&ctx) + }; + if authorized { + next.run(req).await + } else { + (StatusCode::UNAUTHORIZED, "Unauthorized").into_response() + } +} + +#[derive(Deserialize)] +struct LoginBody { + username: String, + password: String, +} + +async fn handle_login( + State(state): State>, + headers: axum::http::HeaderMap, + Json(body): Json, +) -> Response { + if state.login.is_locked() { + return Json( + json!({ "status": "error", "error": "Too many failed attempts — please wait" }), + ) + .into_response(); + } + if !state.login.authorize(&body.username, &body.password) { + state.login.record_failure(); + return Json(json!({ "status": "error", "error": "Invalid username and/or password" })) + .into_response(); + } + let (jwt, secs) = match state.login.issue_session(&body.username, false) { + Ok(v) => v, + Err(e) => { + tracing::error!("failed to mint admin session JWT: {e}"); + return (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response(); + } + }; + let host = request_host(&headers); + let opts = CookieOptions { + path: ADMIN_PREFIX.to_string(), + max_age_secs: Some(secs as i64), + http_only: true, + secure: is_secure_request(&headers), + same_site: "Lax", + }; + let mut resp = Json(json!({ "status": "ok" })).into_response(); + let name = scoped_auth_cookie_name(&host, ADMIN_PREFIX); + if let Ok(v) = set_cookie_value(&name, &jwt, &opts).parse() { + resp.headers_mut().append(header::SET_COOKIE, v); + } + resp +} + +async fn handle_logout( + State(_state): State>, + headers: axum::http::HeaderMap, +) -> Response { + let host = request_host(&headers); + let opts = CookieOptions { + path: ADMIN_PREFIX.to_string(), + max_age_secs: Some(0), + http_only: true, + secure: is_secure_request(&headers), + same_site: "Lax", + }; + let mut resp = Json(json!({ "status": "ok" })).into_response(); + let name = scoped_auth_cookie_name(&host, ADMIN_PREFIX); + if let Ok(v) = set_cookie_value(&name, "", &opts).parse() { + resp.headers_mut().append(header::SET_COOKIE, v); + } + resp +} + +async fn handle_shell(State(state): State>) -> Response { + let s = state.clone(); + match run_blocking(move || s.client_bundle.read_file(".client/admin.html")).await { + Ok((data, _)) => ([(header::CONTENT_TYPE, "text/html")], data).into_response(), + Err(_) => (StatusCode::NOT_FOUND, "Admin UI not found in client bundle").into_response(), + } +} + +async fn handle_asset( + State(state): State>, + AxumPath(file): AxumPath, +) -> Response { + if file.contains("..") || file.contains('/') { + return (StatusCode::BAD_REQUEST, "Invalid asset path").into_response(); + } + let s = state.clone(); + let path = format!(".client/{file}"); + match run_blocking(move || s.client_bundle.read_file(&path)).await { + Ok((data, _)) => { + let ctype = match file.rsplit('.').next() { + Some("js") => "text/javascript", + Some("css") => "text/css", + Some("map") | Some("json") => "application/json", + Some("svg") => "image/svg+xml", + Some("png") => "image/png", + _ => "application/octet-stream", + }; + ([(header::CONTENT_TYPE, ctype)], data).into_response() + } + Err(_) => (StatusCode::NOT_FOUND, "Not found").into_response(), + } +} + +async fn handle_list(State(state): State>) -> Response { + Json(state.manager.list()).into_response() +} + +fn api_error(e: ApiError) -> Response { + match e { + ApiError::Validation(errors) => { + (StatusCode::BAD_REQUEST, Json(json!({ "errors": errors }))).into_response() + } + ApiError::NotFound => ( + StatusCode::NOT_FOUND, + Json(json!({ "errors": [{ "field": "id", "message": "no such space" }] })), + ) + .into_response(), + ApiError::Internal(msg) => { + tracing::error!("admin API internal error: {msg}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "errors": [{ "field": "", "message": msg }] })), + ) + .into_response() + } + } +} + +async fn handle_create( + State(state): State>, + Json(cfg): Json, +) -> Response { + let manager = state.manager.clone(); + match run_blocking(move || Ok(manager.create(cfg))).await { + Ok(Ok(id)) => Json(json!({ "id": id })).into_response(), + Ok(Err(e)) => api_error(e), + Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "task failed").into_response(), + } +} + +async fn handle_update( + State(state): State>, + AxumPath(id): AxumPath, + Json(cfg): Json, +) -> Response { + let manager = state.manager.clone(); + match run_blocking(move || Ok(manager.update(&id, cfg))).await { + Ok(Ok(())) => Json(json!({ "status": "ok" })).into_response(), + Ok(Err(e)) => api_error(e), + Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "task failed").into_response(), + } +} + +async fn handle_delete( + State(state): State>, + AxumPath(id): AxumPath, +) -> Response { + let manager = state.manager.clone(); + match run_blocking(move || Ok(manager.delete(&id))).await { + Ok(Ok(())) => Json(json!({ "status": "ok" })).into_response(), + Ok(Err(e)) => api_error(e), + Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "task failed").into_response(), + } +} + +#[derive(Deserialize)] +struct PasswordBody { + password: String, +} + +async fn handle_set_password( + State(state): State>, + AxumPath(id): AxumPath, + Json(body): Json, +) -> Response { + let manager = state.manager.clone(); + match run_blocking(move || Ok(manager.set_password(&id, &body.password))).await { + Ok(Ok(())) => Json(json!({ "status": "ok" })).into_response(), + Ok(Err(e)) => api_error(e), + Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "task failed").into_response(), + } +} + +#[derive(Deserialize)] +struct DirsQuery { + #[serde(default)] + path: String, +} + +async fn handle_fs_dirs( + State(state): State>, + axum::extract::Query(q): axum::extract::Query, +) -> Response { + let root = state.manager.root().to_path_buf(); + let result = run_blocking(move || Ok(dir_completion(&root, &q.path))).await; + match result { + Ok(v) => Json(v).into_response(), + Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "task failed").into_response(), + } +} + +#[derive(Deserialize)] +struct PortQuery { + #[serde(default)] + port: String, + /// Space id whose own current port binding counts as available (editing). + #[serde(default, rename = "self")] + self_id: Option, +} + +/// `GET /api/net/port?port=…[&self=]` — live availability check for the +/// form's port field: `{"status": "available"|"inUse"|"invalid", "reason"}`. +async fn handle_port_check( + State(state): State>, + axum::extract::Query(q): axum::extract::Query, +) -> Response { + let port = match q.port.trim().parse::() { + Ok(p) if p != 0 => p, + _ => { + return Json(json!({ "status": "invalid", "reason": "not a valid port number" })) + .into_response(); + } + }; + let manager = state.manager.clone(); + match run_blocking(move || Ok(manager.port_check(port, q.self_id.as_deref()))).await { + Ok((true, reason)) => { + Json(json!({ "status": "available", "reason": reason })).into_response() + } + Ok((false, reason)) => Json(json!({ "status": "inUse", "reason": reason })).into_response(), + Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "task failed").into_response(), + } +} + +/// Path status + subdirectory suggestions for the admin form's folder field. +/// Relative input resolves against the server root. Directory names only. +fn dir_completion(root: &std::path::Path, input: &str) -> serde_json::Value { + let input_is_absolute = std::path::Path::new(input).is_absolute(); + let resolved = { + let p = std::path::Path::new(input); + if input_is_absolute { + p.to_path_buf() + } else { + root.join(p) + } + }; + let status = match std::fs::metadata(&resolved) { + Ok(m) if m.is_dir() => "exists", + Ok(_) => "notADirectory", + Err(_) => "missing", + }; + let writable = status == "exists" + && std::fs::metadata(&resolved) + .map(|m| !m.permissions().readonly()) + .unwrap_or(false); + + // Complete the last path component against its parent directory. + let (parent, partial) = if status == "exists" || input.ends_with('/') { + (resolved.clone(), String::new()) + } else { + ( + resolved + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| root.to_path_buf()), + resolved + .file_name() + .map(|f| f.to_string_lossy().to_string()) + .unwrap_or_default(), + ) + }; + let mut suggestions: Vec = std::fs::read_dir(&parent) + .map(|rd| { + rd.flatten() + .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false)) + .filter(|e| { + let name = e.file_name().to_string_lossy().to_string(); + !name.starts_with('.') && name.starts_with(&partial) + }) + .map(|e| { + let path = e.path(); + // For relative input, keep suggestions relative to the + // server root; absolute input keeps absolute suggestions. + let shown = if input_is_absolute { + path.as_path() + } else { + path.strip_prefix(root).unwrap_or(path.as_path()) + }; + shown.to_string_lossy().to_string() + }) + .collect() + }) + .unwrap_or_default(); + suggestions.sort(); + suggestions.truncate(50); + + json!({ "status": status, "writable": writable, "suggestions": suggestions }) +} + +pub fn build_admin_router(state: Arc) -> Router { + let api = Router::new() + .route("/api/spaces", get(handle_list).post(handle_create)) + .route( + "/api/spaces/{id}", + axum::routing::put(handle_update).delete(handle_delete), + ) + .route("/api/spaces/{id}/password", post(handle_set_password)) + .route("/api/fs/dirs", get(handle_fs_dirs)) + .route("/api/net/port", get(handle_port_check)) + .route_layer(middleware::from_fn_with_state(state.clone(), require_admin)); + Router::new() + .route("/", get(handle_shell)) + .route("/index.html", get(handle_shell)) + .route("/assets/{file}", get(handle_asset)) + .route("/api/login", post(handle_login)) + .route("/api/logout", get(handle_logout)) + .merge(api) + .layer(axum::extract::DefaultBodyLimit::max(1024 * 1024)) + .with_state(state) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::multi::instance::{AssetFactories, InstanceDeps}; + use crate::multi::manager::MultiManager; + use axum::body::Body; + use axum::http::{Request, StatusCode}; + use silverbullet_server_common::space::MemorySpacePrimitives; + use silverbullet_server_common::SpacePrimitives; + use std::sync::Arc; + use tower::ServiceExt; + + fn deps(root: &std::path::Path) -> InstanceDeps { + InstanceDeps { + root: root.to_path_buf(), + assets: AssetFactories { + client_bundle: Box::new(|| Box::new(MemorySpacePrimitives::new())), + base_fs: Box::new(|| Box::new(MemorySpacePrimitives::new())), + }, + runtime: Box::new(|_| None), + metrics: None, + admin_auth: crate::auth::AuthConfig::try_parse( + Some("admin:pw"), + None, + None, + None, + None, + ) + .unwrap() + .unwrap(), + version: "test".into(), + main_port: 3000, + disable_service_worker: true, + } + } + + pub(crate) fn admin_router(dir: &tempfile::TempDir) -> (axum::Router, Arc) { + let manager = + MultiManager::boot(dir.path().to_path_buf(), deps(dir.path()), 3000, None).unwrap(); + let authenticator = Arc::new(crate::auth::Authenticator::from_parts( + vec![7u8; 32], + "c2FsdA==".into(), + "h".into(), + )); + let auth_config = + crate::auth::AuthConfig::try_parse(Some("admin:pw"), None, None, None, None) + .unwrap() + .unwrap(); + let bundle = MemorySpacePrimitives::new(); + bundle + .write_file(".client/admin.html", b"ADMIN-SHELL", None) + .unwrap(); + bundle + .write_file(".client/admin.js", b"//js", None) + .unwrap(); + let state = Arc::new(AdminState::new( + manager.clone(), + authenticator, + auth_config, + Box::new(bundle), + )); + (build_admin_router(state), manager) + } + + async fn send(router: &axum::Router, req: Request) -> axum::response::Response { + router.clone().oneshot(req).await.unwrap() + } + + fn get(uri: &str) -> Request { + Request::builder() + .uri(uri) + .header("host", "localhost") + .body(Body::empty()) + .unwrap() + } + + async fn login_cookie(router: &axum::Router) -> String { + let resp = send( + router, + Request::builder() + .method("POST") + .uri("/api/login") + .header("host", "localhost") + .header("content-type", "application/json") + .body(Body::from(r#"{"username":"admin","password":"pw"}"#)) + .unwrap(), + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + resp.headers()[axum::http::header::SET_COOKIE] + .to_str() + .unwrap() + .split(';') + .next() + .unwrap() + .to_string() + } + + #[tokio::test] + async fn shell_and_assets_are_open() { + let dir = tempfile::tempdir().unwrap(); + let (r, _) = admin_router(&dir); + assert_eq!(send(&r, get("/")).await.status(), StatusCode::OK); + assert_eq!( + send(&r, get("/assets/admin.js")).await.status(), + StatusCode::OK + ); + } + + #[tokio::test] + async fn api_is_gated_and_login_cookie_unlocks_it() { + let dir = tempfile::tempdir().unwrap(); + let (r, _) = admin_router(&dir); + assert_eq!( + send(&r, get("/api/spaces")).await.status(), + StatusCode::UNAUTHORIZED + ); + + let cookie = login_cookie(&r).await; + assert!(cookie.starts_with("auth_localhost__admin="), "{cookie}"); + let resp = send( + &r, + Request::builder() + .uri("/api/spaces") + .header("host", "localhost") + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + } + + #[tokio::test] + async fn login_cookie_is_scoped_to_admin_path() { + let dir = tempfile::tempdir().unwrap(); + let (r, _) = admin_router(&dir); + let resp = send( + &r, + Request::builder() + .method("POST") + .uri("/api/login") + .header("host", "localhost") + .header("content-type", "application/json") + .body(Body::from(r#"{"username":"admin","password":"pw"}"#)) + .unwrap(), + ) + .await; + let raw = resp.headers()[axum::http::header::SET_COOKIE] + .to_str() + .unwrap(); + assert!(raw.contains("Path=/.admin"), "{raw}"); + assert!(raw.contains("HttpOnly"), "{raw}"); + } + + #[tokio::test] + async fn bad_credentials_rejected_json() { + let dir = tempfile::tempdir().unwrap(); + let (r, _) = admin_router(&dir); + let resp = send( + &r, + Request::builder() + .method("POST") + .uri("/api/login") + .header("host", "localhost") + .header("content-type", "application/json") + .body(Body::from(r#"{"username":"admin","password":"nope"}"#)) + .unwrap(), + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + assert!(resp.headers().get(axum::http::header::SET_COOKIE).is_none()); + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(v["status"], "error"); + } + + async fn authed( + router: &axum::Router, + method: &str, + uri: &str, + body: &str, + cookie: &str, + ) -> axum::response::Response { + send( + router, + Request::builder() + .method(method) + .uri(uri) + .header("host", "localhost") + .header("cookie", cookie) + .header("content-type", "application/json") + .body(if body.is_empty() { + Body::empty() + } else { + Body::from(body.to_string()) + }) + .unwrap(), + ) + .await + } + + async fn body_json(resp: axum::response::Response) -> serde_json::Value { + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + serde_json::from_slice(&bytes).unwrap() + } + + #[tokio::test] + async fn crud_lifecycle_over_http() { + let dir = tempfile::tempdir().unwrap(); + let (r, _m) = admin_router(&dir); + let cookie = login_cookie(&r).await; + + // Create. + let resp = authed( + &r, + "POST", + "/api/spaces", + r#"{"name":"Work","binding":{"prefix":"/work"},"auth":{"mode":"none"}}"#, + &cookie, + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + let v = body_json(resp).await; + let id = v["id"].as_str().unwrap().to_string(); + + // List shows it running. + let v = body_json(authed(&r, "GET", "/api/spaces", "", &cookie).await).await; + assert_eq!(v[&id]["status"]["state"], "running"); + + // Update to a new prefix. + let resp = authed(&r, "PUT", &format!("/api/spaces/{id}"), + &format!(r#"{{"name":"Work","folder":"spaces/{id}","binding":{{"prefix":"/w2"}},"auth":{{"mode":"none"}}}}"#), + &cookie).await; + assert_eq!(resp.status(), StatusCode::OK); + + // Validation error shape. + let resp = authed( + &r, + "POST", + "/api/spaces", + r#"{"name":"Dup","binding":{"prefix":"/w2"},"auth":{"mode":"none"}}"#, + &cookie, + ) + .await; + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + let v = body_json(resp).await; + assert!(!v["errors"].as_array().unwrap().is_empty(), "{v}"); + + // Delete. + let resp = authed(&r, "DELETE", &format!("/api/spaces/{id}"), "", &cookie).await; + assert_eq!(resp.status(), StatusCode::OK); + let resp = authed(&r, "DELETE", &format!("/api/spaces/{id}"), "", &cookie).await; + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn password_endpoint_hashes_and_redacts() { + let dir = tempfile::tempdir().unwrap(); + let (r, _m) = admin_router(&dir); + let cookie = login_cookie(&r).await; + let v = body_json( + authed( + &r, + "POST", + "/api/spaces", + r#"{"name":"C","binding":{"prefix":"/c"},"auth":{"mode":"custom","user":"u"}}"#, + &cookie, + ) + .await, + ) + .await; + let id = v["id"].as_str().unwrap().to_string(); + let resp = authed( + &r, + "POST", + &format!("/api/spaces/{id}/password"), + r#"{"password":"hunter2"}"#, + &cookie, + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + let v = body_json(authed(&r, "GET", "/api/spaces", "", &cookie).await).await; + assert_eq!(v[&id]["hasPassword"], true); + assert!(v[&id]["auth"].get("passHash").is_none()); + // Persisted hash on disk is argon2id, not plaintext. + let raw = std::fs::read_to_string(dir.path().join("spaces.json")).unwrap(); + assert!(!raw.contains("hunter2")); + assert!(raw.contains("$argon2id$")); + } + + #[tokio::test] + async fn fs_dirs_completion_and_status() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("alpha")).unwrap(); + std::fs::create_dir_all(dir.path().join("alps")).unwrap(); + std::fs::create_dir_all(dir.path().join("beta")).unwrap(); + std::fs::write(dir.path().join("afile"), "x").unwrap(); + let (r, _m) = admin_router(&dir); + let cookie = login_cookie(&r).await; + + // Unauthenticated: gated. + assert_eq!( + send(&r, get("/api/fs/dirs?path=al")).await.status(), + StatusCode::UNAUTHORIZED + ); + + // Partial relative path completes against the server root, and the + // suggestions stay relative to it (not absolute). + let v = body_json(authed(&r, "GET", "/api/fs/dirs?path=al", "", &cookie).await).await; + assert_eq!(v["status"], "missing"); + let sugg: Vec = v["suggestions"] + .as_array() + .unwrap() + .iter() + .map(|s| s.as_str().unwrap().to_string()) + .collect(); + assert!(sugg.iter().any(|s| s == "alpha"), "{sugg:?}"); + assert!(sugg.iter().any(|s| s == "alps"), "{sugg:?}"); + assert!(!sugg.iter().any(|s| s == "beta"), "{sugg:?}"); + + // Absolute input keeps absolute suggestions. + let abs = format!("{}/al", dir.path().display()); + let v = + body_json(authed(&r, "GET", &format!("/api/fs/dirs?path={abs}"), "", &cookie).await) + .await; + let abs_sugg: Vec = v["suggestions"] + .as_array() + .unwrap() + .iter() + .map(|s| s.as_str().unwrap().to_string()) + .collect(); + assert!( + abs_sugg + .iter() + .any(|s| s.ends_with("/alpha") && s.starts_with('/')), + "{abs_sugg:?}" + ); + + // Existing dir. + let v = body_json(authed(&r, "GET", "/api/fs/dirs?path=alpha", "", &cookie).await).await; + assert_eq!(v["status"], "exists"); + + // A file is notADirectory. + let v = body_json(authed(&r, "GET", "/api/fs/dirs?path=afile", "", &cookie).await).await; + assert_eq!(v["status"], "notADirectory"); + } + + #[tokio::test] + async fn port_check_statuses() { + let dir = tempfile::tempdir().unwrap(); + let (r, _m) = admin_router(&dir); + let cookie = login_cookie(&r).await; + + // Gated. + assert_eq!( + send(&r, get("/api/net/port?port=4000")).await.status(), + StatusCode::UNAUTHORIZED + ); + + // Invalid input. + let v = body_json(authed(&r, "GET", "/api/net/port?port=nope", "", &cookie).await).await; + assert_eq!(v["status"], "invalid"); + let v = body_json(authed(&r, "GET", "/api/net/port?port=0", "", &cookie).await).await; + assert_eq!(v["status"], "invalid"); + + // A free port is available. + let free = std::net::TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port(); + let v = body_json( + authed( + &r, + "GET", + &format!("/api/net/port?port={free}"), + "", + &cookie, + ) + .await, + ) + .await; + assert_eq!(v["status"], "available"); + + // The admin router's manager was booted with main_port 3000. + let v = body_json(authed(&r, "GET", "/api/net/port?port=3000", "", &cookie).await).await; + assert_eq!(v["status"], "inUse"); + + // A port held by another process is not bindable. + let blocker = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let held = blocker.local_addr().unwrap().port(); + let v = body_json( + authed( + &r, + "GET", + &format!("/api/net/port?port={held}"), + "", + &cookie, + ) + .await, + ) + .await; + assert_eq!(v["status"], "inUse"); + + // A port claimed by an existing space is inUse — unless it's the space + // being edited (self exclusion). + let space_port = std::net::TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port(); + let v = body_json( + authed( + &r, + "POST", + "/api/spaces", + &format!( + r#"{{"name":"P","binding":{{"port":{space_port}}},"auth":{{"mode":"none"}}}}"# + ), + &cookie, + ) + .await, + ) + .await; + let id = v["id"].as_str().unwrap(); + let v = body_json( + authed( + &r, + "GET", + &format!("/api/net/port?port={space_port}"), + "", + &cookie, + ) + .await, + ) + .await; + assert_eq!(v["status"], "inUse"); + let v = body_json( + authed( + &r, + "GET", + &format!("/api/net/port?port={space_port}&self={id}"), + "", + &cookie, + ) + .await, + ) + .await; + assert_eq!(v["status"], "available"); + } +} diff --git a/server/src/multi/config.rs b/server/src/multi/config.rs new file mode 100644 index 00000000..97b3a2b2 --- /dev/null +++ b/server/src/multi/config.rs @@ -0,0 +1,308 @@ +//! The `spaces.json` model: a flat map of generated GUID -> space config. +//! Serialized with keys in stable name order; unknown fields are preserved +//! verbatim so older servers don't destroy newer config. + +use std::collections::HashMap; +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +/// One space's binding to the outside world. Exactly one variant; the +/// `untagged` representation matches the spec's `{"prefix": "/x"}` / +/// `{"host": "..."}` / `{"port": 3001}` shapes. +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(untagged)] +pub enum Binding { + Prefix { prefix: String }, + Host { host: String }, + Port { port: u16 }, +} + +// A hand-written `Deserialize` (rather than `#[serde(untagged)]` + derive) so we +// can reject *composite* objects like `{"prefix": "/a", "port": 3001}` and +// unknown keys outright. A derived untagged enum would silently pick the first +// matching variant and drop the extra fields. +impl<'de> Deserialize<'de> for Binding { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct Repr { + #[serde(default)] + prefix: Option, + #[serde(default)] + host: Option, + #[serde(default)] + port: Option, + } + let repr = Repr::deserialize(deserializer)?; + match (repr.prefix, repr.host, repr.port) { + (Some(prefix), None, None) => Ok(Binding::Prefix { prefix }), + (None, Some(host), None) => Ok(Binding::Host { host }), + (None, None, Some(port)) => Ok(Binding::Port { port }), + _ => Err(serde::de::Error::custom( + "binding must have exactly one of `prefix`, `host`, or `port`", + )), + } + } +} + +/// Per-space authentication. `Inherit` (default) accepts the admin credentials. +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +#[serde(tag = "mode")] +pub enum SpaceAuth { + #[default] + #[serde(rename = "inherit")] + Inherit, + #[serde(rename = "custom", rename_all = "camelCase")] + Custom { + user: String, + /// argon2id PHC string; empty until the first password is set. + #[serde(default)] + pass_hash: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + auth_token: String, + #[serde(default = "default_lockout_limit")] + lockout_limit: u32, + #[serde(default = "default_lockout_time")] + lockout_time: u64, + #[serde(default = "default_remember_me_hours")] + remember_me_hours: u64, + }, + #[serde(rename = "none")] + None, +} + +fn default_lockout_limit() -> u32 { + 10 +} +fn default_lockout_time() -> u64 { + 60 +} +fn default_remember_me_hours() -> u64 { + 168 +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ShellSettings { + pub enabled: bool, + #[serde(default)] + pub whitelist: Vec, +} + +impl Default for ShellSettings { + fn default() -> Self { + Self { + enabled: true, + whitelist: vec![], + } + } +} + +fn default_index_page() -> String { + "index".into() +} +fn default_theme_color() -> String { + "#e1e1e1".into() +} +fn default_description() -> String { + "Powerful and programmable note taking app".into() +} + +/// A single space's full configuration — parity with the single-space `SB_*` +/// env surface. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SpaceConfig { + pub name: String, + /// Relative paths resolve against the server root. Empty = default + /// (`spaces/` under the root), filled in on create. + #[serde(default)] + pub folder: String, + pub binding: Binding, + #[serde(default)] + pub auth: SpaceAuth, + #[serde(default)] + pub read_only: bool, + #[serde(default)] + pub shell: ShellSettings, + #[serde(default)] + pub runtime_api: bool, + #[serde(default = "default_index_page")] + pub index_page: String, + #[serde(default = "default_description")] + pub description: String, + #[serde(default = "default_theme_color")] + pub theme_color: String, + #[serde(default)] + pub head_html: String, + #[serde(default)] + pub space_ignore: String, + #[serde(default)] + pub log_push: bool, + /// Fields written by newer versions, preserved verbatim. + #[serde(flatten)] + pub extra: serde_json::Map, +} + +/// The whole `spaces.json`: GUID -> config. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct MultiConfig { + pub spaces: HashMap, +} + +impl MultiConfig { + pub fn from_json(src: &str) -> Result { + let spaces: HashMap = + serde_json::from_str(src).map_err(|e| format!("invalid spaces.json: {e}"))?; + Ok(Self { spaces }) + } + + /// Pretty JSON with keys ordered by space name (then id, for stability + /// among duplicates). Relies on serde_json's `preserve_order` feature. + pub fn to_json_string(&self) -> Result { + let mut entries: Vec<(&String, &SpaceConfig)> = self.spaces.iter().collect(); + entries.sort_by(|a, b| (&a.1.name, a.0).cmp(&(&b.1.name, b.0))); + let mut map = serde_json::Map::new(); + for (id, cfg) in entries { + let v = serde_json::to_value(cfg).map_err(|e| e.to_string())?; + map.insert(id.clone(), v); + } + serde_json::to_string_pretty(&serde_json::Value::Object(map)).map_err(|e| e.to_string()) + } + + /// Read from `path`. A missing file is an empty config; a malformed file + /// is a hard error (config is never silently ignored). + pub fn load(path: &Path) -> Result { + match std::fs::read_to_string(path) { + Ok(src) => Self::from_json(&src), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()), + Err(e) => Err(format!("could not read {}: {e}", path.display())), + } + } + + /// Atomically persist: write `.tmp` (0600 on unix), then rename over + /// `path`. + pub fn save(&self, path: &Path) -> Result<(), String> { + let json = self.to_json_string()?; + let tmp = path.with_extension("json.tmp"); + std::fs::write(&tmp, &json) + .map_err(|e| format!("could not write {}: {e}", tmp.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Err(e) = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600)) { + // The file holds secrets; a failure to tighten permissions is + // worth surfacing, but the save still proceeds. + tracing::warn!("could not set 0600 on {}: {e}", tmp.display()); + } + } + std::fs::rename(&tmp, path) + .map_err(|e| format!("could not persist {}: {e}", path.display())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_json() -> &'static str { + r#"{ + "id-b": { "name": "Beta", "binding": { "prefix": "/b" } }, + "id-a": { + "name": "Alpha", + "folder": "/abs/path", + "binding": { "host": "a.example.com" }, + "auth": { "mode": "custom", "user": "u", "passHash": "$argon2id$x" }, + "readOnly": true, + "shell": { "enabled": false, "whitelist": ["git"] }, + "runtimeApi": true, + "indexPage": "home", + "futureField": { "nested": 1 } + } + }"# + } + + #[test] + fn parses_bindings_auth_and_defaults() { + let c: MultiConfig = MultiConfig::from_json(sample_json()).unwrap(); + let a = &c.spaces["id-a"]; + assert_eq!(a.name, "Alpha"); + assert!(matches!(&a.binding, Binding::Host { host } if host == "a.example.com")); + assert!( + matches!(&a.auth, SpaceAuth::Custom { user, pass_hash, .. } if user == "u" && pass_hash == "$argon2id$x") + ); + assert!(a.read_only); + assert!(!a.shell.enabled); + assert_eq!(a.index_page, "home"); + assert_eq!(a.extra["futureField"]["nested"], 1); + + let b = &c.spaces["id-b"]; + assert!(matches!(&b.binding, Binding::Prefix { prefix } if prefix == "/b")); + assert!(matches!(b.auth, SpaceAuth::Inherit)); // default + assert!(!b.read_only); + assert!(b.shell.enabled); // default on + assert_eq!(b.index_page, "index"); + assert_eq!(b.folder, ""); // empty = resolved elsewhere + } + + #[test] + fn round_trip_preserves_unknown_fields_and_orders_by_name() { + let c = MultiConfig::from_json(sample_json()).unwrap(); + let out = c.to_json_string().unwrap(); + // Alpha sorts before Beta even though the input had Beta first. + let ia = out.find("\"id-a\"").unwrap(); + let ib = out.find("\"id-b\"").unwrap(); + assert!(ia < ib, "name-sorted: {out}"); + assert!( + out.contains("futureField"), + "unknown field preserved: {out}" + ); + // And it re-parses identically. + let again = MultiConfig::from_json(&out).unwrap(); + assert_eq!(again.spaces["id-a"].name, "Alpha"); + } + + #[test] + fn load_missing_file_is_empty_and_save_round_trips() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("spaces.json"); + let empty = MultiConfig::load(&path).unwrap(); + assert!(empty.spaces.is_empty()); + + let c = MultiConfig::from_json(sample_json()).unwrap(); + c.save(&path).unwrap(); + let loaded = MultiConfig::load(&path).unwrap(); + assert_eq!(loaded.spaces.len(), 2); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&path).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600, "spaces.json must be 0600"); + } + } + + #[test] + fn composite_binding_is_rejected() { + // A `{"prefix": ..., "port": ...}` object is ambiguous: without + // `deny_unknown_fields` serde's untagged enum would silently pick + // Prefix and drop the port. It must fail to parse instead. + let src = r#"{ + "id": { "name": "X", "binding": { "prefix": "/a", "port": 3001 } } + }"#; + assert!( + MultiConfig::from_json(src).is_err(), + "composite binding must not deserialize" + ); + } + + #[test] + fn load_malformed_file_is_an_error() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("spaces.json"); + std::fs::write(&path, "{ not json").unwrap(); + assert!(MultiConfig::load(&path).is_err()); + } +} diff --git a/server/src/multi/dispatch.rs b/server/src/multi/dispatch.rs new file mode 100644 index 00000000..ccf6c8c1 --- /dev/null +++ b/server/src/multi/dispatch.rs @@ -0,0 +1,365 @@ +//! The main listener's request dispatcher: `/.admin` is reserved, then Host +//! header, then longest prefix. Matched requests are forwarded to the space's +//! own (unchanged) Core router with the prefix stripped. + +use std::sync::Arc; + +use axum::body::Body; +use axum::extract::{Request, State}; +use axum::http::{StatusCode, Uri}; +use axum::response::{IntoResponse, Redirect, Response}; +use axum::Router; +use tower::ServiceExt; + +use crate::multi::instance::InstanceStatus; +use crate::multi::manager::MultiManager; + +pub fn build_main_router(manager: Arc, admin_router: Router) -> Router { + // Per-boot instance identity, served on ANY hostname (it's routed before + // host/prefix resolution) with permissive CORS. The admin UI probes + // `http(s)://:/.instance` from the browser and + // compares the id against its own origin's to verify that a hostname + // binding actually reaches this very server. Discloses nothing but a + // random UUID. + let instance_id = uuid::Uuid::new_v4().to_string(); + let instance_handler = move || { + let body = format!("{{\"instance\":\"{instance_id}\"}}"); + async move { + ( + [ + (axum::http::header::CONTENT_TYPE, "application/json"), + (axum::http::header::ACCESS_CONTROL_ALLOW_ORIGIN, "*"), + ], + body, + ) + } + }; + Router::new() + .nest_service("/.admin", admin_router) + .route("/.instance", axum::routing::get(instance_handler)) + .fallback(dispatch) + .with_state(manager) +} + +async fn dispatch(State(manager): State>, req: Request) -> Response { + let table = manager.registry().current(); + let host = crate::auth::request_host(req.headers()); + let path = req.uri().path().to_string(); + + let Some((inst, prefix)) = table.resolve_main(&host, &path) else { + if path == "/" { + return Redirect::temporary("/.admin/").into_response(); + } + return ( + StatusCode::NOT_FOUND, + [(axum::http::header::CONTENT_TYPE, "text/html")], + "

No space here

Manage spaces in the admin UI.

", + ) + .into_response(); + }; + + // When the path exactly equals a non-empty prefix (no trailing slash), + // redirect to `/`. The session cookie's Path is `/`, so the + // bare URL wouldn't carry the cookie and a logged-in user would see a login + // page. Preserve the query string across the redirect. + if !prefix.is_empty() && path == prefix { + let target = match req.uri().query() { + Some(q) => format!("{prefix}/?{q}"), + None => format!("{prefix}/"), + }; + return Redirect::temporary(&target).into_response(); + } + + let Some(router) = inst.router.clone() else { + let reason = match &inst.status { + InstanceStatus::Errored(r) => r.clone(), + _ => "space unavailable".to_string(), + }; + return ( + StatusCode::SERVICE_UNAVAILABLE, + format!("Space unavailable: {reason}"), + ) + .into_response(); + }; + + let req = if prefix.is_empty() { + req + } else { + strip_prefix(req, &prefix) + }; + match router.oneshot(req).await { + Ok(resp) => resp, + Err(never) => match never {}, + } +} + +/// Rewrite the request URI with `prefix` removed from the path (query kept). +/// `/work` -> `/`, `/work/x?q=1` -> `/x?q=1`. +fn strip_prefix(mut req: Request, prefix: &str) -> Request { + let uri = req.uri(); + let pq = uri.path_and_query().map(|pq| pq.as_str()).unwrap_or("/"); + let rest = &pq[prefix.len()..]; + let new = if rest.is_empty() { + "/".to_string() + } else if rest.starts_with('?') { + format!("/{rest}") + } else { + rest.to_string() + }; + if let Ok(new_uri) = new.parse::() { + *req.uri_mut() = new_uri; + } + req +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::multi::config::{Binding, SpaceAuth, SpaceConfig}; + use crate::multi::instance::{AssetFactories, InstanceDeps}; + use crate::multi::manager::MultiManager; + use axum::body::Body; + use axum::http::{Request, StatusCode}; + use silverbullet_server_common::space::MemorySpacePrimitives; + use tower::ServiceExt; + + fn deps(root: &std::path::Path) -> InstanceDeps { + InstanceDeps { + root: root.to_path_buf(), + assets: AssetFactories { + client_bundle: Box::new(|| Box::new(MemorySpacePrimitives::new())), + base_fs: Box::new(|| Box::new(MemorySpacePrimitives::new())), + }, + runtime: Box::new(|_| None), + metrics: None, + admin_auth: crate::auth::AuthConfig::try_parse( + Some("admin:pw"), + None, + None, + None, + None, + ) + .unwrap() + .unwrap(), + version: "test".into(), + main_port: 3000, + disable_service_worker: true, + } + } + + fn payload(name: &str, binding: Binding) -> SpaceConfig { + SpaceConfig { + name: name.into(), + folder: String::new(), + binding, + auth: SpaceAuth::None, + read_only: false, + shell: Default::default(), + runtime_api: false, + index_page: "index".into(), + description: String::new(), + theme_color: "#e1e1e1".into(), + head_html: String::new(), + space_ignore: String::new(), + log_push: false, + extra: Default::default(), + } + } + + /// Manager with a /work space, a root space, and a host-bound space, plus a + /// dummy admin router answering 299 (a sentinel status). + fn setup(dir: &tempfile::TempDir) -> axum::Router { + let m = MultiManager::boot(dir.path().to_path_buf(), deps(dir.path()), 3000, None).unwrap(); + m.create(payload( + "Work", + Binding::Prefix { + prefix: "/work".into(), + }, + )) + .unwrap(); + m.create(payload( + "Hosted", + Binding::Host { + host: "notes.example.com".into(), + }, + )) + .unwrap(); + let admin = axum::Router::new() + .fallback(|| async { (StatusCode::from_u16(299).unwrap(), "admin") }); + build_main_router(m, admin) + } + + async fn get(router: &axum::Router, host: &str, uri: &str) -> axum::response::Response { + router + .clone() + .oneshot( + Request::builder() + .uri(uri) + .header("host", host) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap() + } + + #[tokio::test] + async fn prefix_space_gets_stripped_path() { + let dir = tempfile::tempdir().unwrap(); + let r = setup(&dir); + // /work/.config -> the work space's /.config (200, open auth). + assert_eq!( + get(&r, "localhost", "/work/.config").await.status(), + StatusCode::OK + ); + // Query strings survive the strip. + assert_eq!( + get(&r, "localhost", "/work/.config?x=1").await.status(), + StatusCode::OK + ); + } + + #[tokio::test] + async fn bare_prefix_url_redirects_to_trailing_slash() { + let dir = tempfile::tempdir().unwrap(); + let r = setup(&dir); + // Exact prefix, no trailing slash -> 307 to `/`. + let resp = get(&r, "localhost", "/work").await; + assert_eq!(resp.status(), StatusCode::TEMPORARY_REDIRECT); + assert_eq!(resp.headers()[axum::http::header::LOCATION], "/work/"); + // Query string is preserved. + let resp = get(&r, "localhost", "/work?a=1&b=2").await; + assert_eq!(resp.status(), StatusCode::TEMPORARY_REDIRECT); + assert_eq!( + resp.headers()[axum::http::header::LOCATION], + "/work/?a=1&b=2" + ); + // A deeper path under the prefix is forwarded, not redirected. + assert_eq!( + get(&r, "localhost", "/work/.config").await.status(), + StatusCode::OK + ); + } + + #[tokio::test] + async fn host_space_resolution_and_no_root_binding() { + let dir = tempfile::tempdir().unwrap(); + let r = setup(&dir); + assert_eq!( + get(&r, "notes.example.com", "/.config").await.status(), + StatusCode::OK + ); + // Root prefixes are rejected by validation, so unprefixed paths on an + // unmatched host never reach a space. + assert_eq!( + get(&r, "localhost", "/.config").await.status(), + StatusCode::NOT_FOUND + ); + } + + #[tokio::test] + async fn root_prefix_binding_is_rejected() { + let dir = tempfile::tempdir().unwrap(); + let m = MultiManager::boot(dir.path().to_path_buf(), deps(dir.path()), 3000, None).unwrap(); + assert!(m + .create(payload("Root", Binding::Prefix { prefix: "/".into() })) + .is_err()); + } + + #[tokio::test] + async fn admin_prefix_always_reserved() { + let dir = tempfile::tempdir().unwrap(); + let r = setup(&dir); + // /.admin always routes to the admin router, never to a space. + assert_eq!( + get(&r, "localhost", "/.admin/anything") + .await + .status() + .as_u16(), + 299 + ); + } + + #[tokio::test] + async fn instance_endpoint_answers_on_any_host_with_cors() { + let dir = tempfile::tempdir().unwrap(); + let r = setup(&dir); + + async fn instance_of(r: &axum::Router, host: &str) -> (String, axum::http::HeaderMap) { + let resp = get(r, host, "/.instance").await; + assert_eq!(resp.status(), StatusCode::OK); + let headers = resp.headers().clone(); + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + (v["instance"].as_str().unwrap().to_string(), headers) + } + + // Answers on the admin origin's host and on a space-bound host alike — + // host resolution must not shadow it (that's the whole point: probing + // a hostname BEFORE it is bound, and THROUGH one that is). + let (id1, headers) = instance_of(&r, "localhost").await; + let (id2, _) = instance_of(&r, "notes.example.com").await; + let (id3, _) = instance_of(&r, "unbound.example.org").await; + assert_eq!(id1, id2); + assert_eq!(id1, id3); + assert!(!id1.is_empty()); + assert_eq!( + headers[axum::http::header::ACCESS_CONTROL_ALLOW_ORIGIN], + "*" + ); + + // A different router (fresh boot) gets a different id. + let dir2 = tempfile::tempdir().unwrap(); + let r2 = setup(&dir2); + let (other, _) = instance_of(&r2, "localhost").await; + assert_ne!(id1, other); + } + + #[tokio::test] + async fn no_match_404_and_root_redirects_to_admin_when_unbound() { + let dir = tempfile::tempdir().unwrap(); + // Manager with NO root space. + let m = MultiManager::boot(dir.path().to_path_buf(), deps(dir.path()), 3000, None).unwrap(); + m.create(payload( + "Work", + Binding::Prefix { + prefix: "/work".into(), + }, + )) + .unwrap(); + let admin = axum::Router::new().fallback(|| async { "admin" }); + let r = build_main_router(m, admin); + let resp = get(&r, "localhost", "/").await; + assert_eq!(resp.status(), StatusCode::TEMPORARY_REDIRECT); + assert_eq!(resp.headers()[axum::http::header::LOCATION], "/.admin/"); + let resp = get(&r, "localhost", "/nothing/here").await; + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn errored_space_returns_503_with_reason() { + let dir = tempfile::tempdir().unwrap(); + let m = MultiManager::boot(dir.path().to_path_buf(), deps(dir.path()), 3000, None).unwrap(); + let mut p = payload( + "Broken", + Binding::Prefix { + prefix: "/b".into(), + }, + ); + p.auth = SpaceAuth::Custom { + user: "u".into(), + pass_hash: String::new(), + auth_token: String::new(), + lockout_limit: 10, + lockout_time: 60, + remember_me_hours: 168, + }; + m.create(p).unwrap(); + let admin = axum::Router::new().fallback(|| async { "admin" }); + let r = build_main_router(m, admin); + let resp = get(&r, "localhost", "/b/.config").await; + assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE); + } +} diff --git a/server/src/multi/instance.rs b/server/src/multi/instance.rs new file mode 100644 index 00000000..fc815f3e --- /dev/null +++ b/server/src/multi/instance.rs @@ -0,0 +1,472 @@ +//! Materialize a `SpaceConfig` into a running space: Core's regular +//! `ServerState` + router, exactly as the single-space binary builds it. A +//! build failure produces an `Errored` instance (never a crash) so one broken +//! space can't take the server down. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use silverbullet_server_common::space::{ + DiskSpacePrimitives, FallthroughSpacePrimitives, ReadOnlySpacePrimitives, +}; +use silverbullet_server_common::{BootConfig, SpacePrimitives}; + +use crate::auth::{ + AuthConfig, Authenticator, HeadlessTokenAuthorizer, JwtAuthorizer, LockoutTimer, LoginManager, + RequestAuthorizer, +}; +use crate::multi::config::{Binding, SpaceAuth, SpaceConfig}; +use crate::multi::validate::normalize_prefix; +use crate::shell::ShellConfig; +use crate::state::{ServerState, ServerVersion}; + +/// Factories that produce the read-only asset layers (client bundle + base_fs). +/// Injected because the embedded assets live in the bin crate, not here. +pub struct AssetFactories { + pub client_bundle: Box Box + Send + Sync>, + pub base_fs: Box Box + Send + Sync>, +} + +/// Everything the runtime factory needs to (maybe) build a backend for a space. +pub struct RuntimeRequest<'a> { + pub space_folder: &'a str, + pub server_url: String, + pub headless_token: &'a str, + pub read_only: bool, +} + +/// Builds a runtime backend for a space, or `None` when unavailable/disabled. +pub type RuntimeFactory = + Box Option> + Send + Sync>; + +/// Shared inputs for building every space instance. +pub struct InstanceDeps { + pub root: PathBuf, + pub assets: AssetFactories, + pub runtime: RuntimeFactory, + pub metrics: Option>, + /// Inherit-mode credentials (the admin's). + pub admin_auth: AuthConfig, + pub version: String, + pub main_port: u16, + pub disable_service_worker: bool, +} + +/// Whether a space built successfully. +#[derive(Debug)] +pub enum InstanceStatus { + Running, + Errored(String), +} + +/// A materialized space: its config plus a ready-to-mount router (or `None` +/// when the build errored). +pub struct SpaceInstance { + pub id: String, + pub config: SpaceConfig, + /// Normalized prefix; "" for host/port bindings. + pub prefix: String, + pub status: InstanceStatus, + /// `None` when errored. + pub router: Option, +} + +/// Resolve a space's folder: empty -> `/spaces/`, relative -> under +/// root, absolute -> as-is. +pub fn resolve_folder(root: &Path, id: &str, folder: &str) -> PathBuf { + if folder.is_empty() { + root.join("spaces").join(id) + } else { + let p = Path::new(folder); + if p.is_absolute() { + p.to_path_buf() + } else { + root.join(p) + } + } +} + +/// Create `.md` in `folder` when the space has no `.md` files yet. +/// Mirrors the single-space binary's `ensure_index`, at the fs level. +pub fn seed_index(folder: &Path, index_page: &str) { + let has_md = std::fs::read_dir(folder) + .map(|rd| { + rd.flatten() + .any(|e| e.path().extension().is_some_and(|x| x == "md")) + }) + .unwrap_or(true); // unreadable folder: do nothing + if has_md { + return; + } + let path = folder.join(format!("{index_page}.md")); + if let Err(e) = std::fs::write(&path, "# Welcome to your new space!\n") { + tracing::warn!("could not seed index page {}: {e}", path.display()); + } +} + +/// Random 256-bit hex token (headless-browser authorization). +fn generate_token() -> String { + let mut bytes = [0u8; 32]; + getrandom::getrandom(&mut bytes).expect("OS RNG must be available"); + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +pub fn build_instance(id: &str, config: &SpaceConfig, deps: &InstanceDeps) -> SpaceInstance { + let prefix = match &config.binding { + Binding::Prefix { prefix } => normalize_prefix(prefix), + _ => String::new(), + }; + match try_build_state(id, config, &prefix, deps) { + Ok(state) => SpaceInstance { + id: id.to_string(), + config: config.clone(), + prefix, + status: InstanceStatus::Running, + router: Some(crate::build_router(Arc::new(state))), + }, + Err(reason) => { + tracing::warn!("space {id} is errored: {reason}"); + SpaceInstance { + id: id.to_string(), + config: config.clone(), + prefix, + status: InstanceStatus::Errored(reason), + router: None, + } + } + } +} + +fn try_build_state( + id: &str, + config: &SpaceConfig, + prefix: &str, + deps: &InstanceDeps, +) -> Result { + let folder = resolve_folder(&deps.root, id, &config.folder); + if !folder.is_dir() { + return Err(format!("space folder does not exist: {}", folder.display())); + } + let folder_str = folder.to_string_lossy().to_string(); + + let disk = DiskSpacePrimitives::new(&folder_str, &config.space_ignore) + .map_err(|e| format!("failed to open space folder {folder_str}: {e}"))?; + let disk: Box = if config.read_only { + Box::new(ReadOnlySpacePrimitives::new(Box::new(disk))) + } else { + Box::new(disk) + }; + let space: Box = Box::new(FallthroughSpacePrimitives::new( + disk, + (deps.assets.base_fs)(), + )); + + // Per-space auth config (None = open space). + let auth_config: Option = match &config.auth { + SpaceAuth::Inherit => Some(deps.admin_auth.clone()), + SpaceAuth::None => None, + SpaceAuth::Custom { + user, + pass_hash, + auth_token, + lockout_limit, + lockout_time, + remember_me_hours, + } => { + if pass_hash.is_empty() { + return Err("custom auth: no password set yet".into()); + } + Some(AuthConfig { + user: user.clone(), + pass: String::new(), + pass_hash: Some(pass_hash.clone()), + auth_token: auth_token.clone(), + lockout_limit: *lockout_limit, + lockout_time_secs: *lockout_time, + remember_me_hours: *remember_me_hours, + }) + } + }; + + let headless_token = generate_token(); + let (authorizer, login) = match &auth_config { + None => (None, None), + Some(ac) => { + let authenticator = Arc::new( + Authenticator::load_or_init(&folder, ac) + .map_err(|e| format!("could not initialize authentication: {e}"))?, + ); + let inner: Box = Box::new(JwtAuthorizer::with_prefix( + authenticator.clone(), + ac.auth_token.clone(), + prefix.to_string(), + )); + let authorizer: Arc = + Arc::new(HeadlessTokenAuthorizer::new(inner, headless_token.clone())); + let lockout = LockoutTimer::from_config(ac.lockout_time_secs, ac.lockout_limit); + let login = Arc::new(LoginManager::new( + authenticator, + ac.clone(), + lockout, + prefix.to_string(), + )); + (Some(authorizer), Some(login)) + } + }; + + // Runtime: only when enabled, writable, and reachable via 127.0.0.1 (prefix + // or port bindings; host-bound spaces can't be addressed by IP — v1 skips). + let runtime = if config.runtime_api && !config.read_only { + let server_url = match &config.binding { + Binding::Prefix { .. } => format!("http://127.0.0.1:{}{prefix}", deps.main_port), + Binding::Port { port } => format!("http://127.0.0.1:{port}"), + Binding::Host { .. } => { + tracing::warn!("space {id}: runtimeApi unsupported for host bindings, disabled"); + String::new() + } + }; + if server_url.is_empty() { + None + } else { + (deps.runtime)(&RuntimeRequest { + space_folder: &folder_str, + server_url, + headless_token: &headless_token, + read_only: config.read_only, + }) + } + } else { + None + }; + + let shell_enabled = config.shell.enabled && !config.read_only; + Ok(ServerState { + space, + client_bundle: (deps.assets.client_bundle)(), + boot_config: BootConfig { + space_folder_path: folder_str.clone(), + space_name: config.name.clone(), + index_page: config.index_page.clone(), + read_only: config.read_only, + log_push: config.log_push, + enable_client_encryption: authorizer.is_some(), + shell_backend: if shell_enabled { + "local".into() + } else { + "noop".into() + }, + disable_service_worker: deps.disable_service_worker, + }, + space_folder_path: folder_str, + version: ServerVersion::Static(deps.version.clone()), + host_url_prefix: prefix.to_string(), + additional_head_html: config.head_html.clone(), + theme_color: config.theme_color.clone(), + space_description: config.description.clone(), + authorizer, + login, + shell: ShellConfig { + enabled: shell_enabled, + whitelist: config.shell.whitelist.clone(), + }, + metrics: deps.metrics.clone(), + runtime, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::multi::config::{Binding, SpaceAuth, SpaceConfig}; + use silverbullet_server_common::space::MemorySpacePrimitives; + + fn test_deps(root: &std::path::Path) -> InstanceDeps { + InstanceDeps { + root: root.to_path_buf(), + assets: AssetFactories { + client_bundle: Box::new(|| Box::new(MemorySpacePrimitives::new())), + base_fs: Box::new(|| Box::new(MemorySpacePrimitives::new())), + }, + runtime: Box::new(|_req| None), + metrics: None, + admin_auth: crate::auth::AuthConfig::try_parse( + Some("admin:pw"), + None, + None, + None, + None, + ) + .unwrap() + .unwrap(), + version: "test".into(), + main_port: 3000, + disable_service_worker: true, + } + } + + fn space(binding: Binding, auth: SpaceAuth, folder: &str) -> SpaceConfig { + SpaceConfig { + name: "S".into(), + folder: folder.into(), + binding, + auth, + read_only: false, + shell: Default::default(), + runtime_api: false, + index_page: "index".into(), + description: String::new(), + theme_color: "#e1e1e1".into(), + head_html: String::new(), + space_ignore: String::new(), + log_push: false, + extra: Default::default(), + } + } + + #[test] + fn resolves_folders_default_relative_absolute() { + let root = std::path::Path::new("/root"); + assert_eq!(resolve_folder(root, "id1", ""), root.join("spaces/id1")); + assert_eq!(resolve_folder(root, "id1", "sub/dir"), root.join("sub/dir")); + let abs = if cfg!(windows) { r"C:\abs" } else { "/abs" }; + assert_eq!( + resolve_folder(root, "id1", abs), + std::path::PathBuf::from(abs) + ); + } + + #[test] + fn builds_running_instance_with_inherit_auth_and_prefix() { + let dir = tempfile::tempdir().unwrap(); + let deps = test_deps(dir.path()); + let folder = dir.path().join("spaces/x"); + std::fs::create_dir_all(&folder).unwrap(); + let cfg = space( + Binding::Prefix { + prefix: "/work/".into(), + }, + SpaceAuth::Inherit, + folder.to_str().unwrap(), + ); + let inst = build_instance("x", &cfg, &deps); + assert!( + matches!(inst.status, InstanceStatus::Running), + "{:?}", + inst.status + ); + assert_eq!(inst.prefix, "/work"); + assert!(inst.router.is_some()); + } + + #[test] + fn missing_folder_yields_errored_instance() { + let dir = tempfile::tempdir().unwrap(); + let deps = test_deps(dir.path()); + let cfg = space( + Binding::Prefix { + prefix: "/x".into(), + }, + SpaceAuth::None, + dir.path().join("nope").to_str().unwrap(), + ); + let inst = build_instance("x", &cfg, &deps); + match &inst.status { + InstanceStatus::Errored(reason) => assert!(reason.contains("folder"), "{reason}"), + other => panic!("expected errored, got {other:?}"), + } + assert!(inst.router.is_none()); + } + + #[test] + fn custom_auth_without_password_is_errored() { + let dir = tempfile::tempdir().unwrap(); + let deps = test_deps(dir.path()); + let folder = dir.path().join("s"); + std::fs::create_dir_all(&folder).unwrap(); + let cfg = space( + Binding::Prefix { + prefix: "/x".into(), + }, + SpaceAuth::Custom { + user: "u".into(), + pass_hash: String::new(), + auth_token: String::new(), + lockout_limit: 10, + lockout_time: 60, + remember_me_hours: 168, + }, + folder.to_str().unwrap(), + ); + let inst = build_instance("x", &cfg, &deps); + assert!(matches!(inst.status, InstanceStatus::Errored(_))); + } + + #[tokio::test] + async fn auth_none_space_serves_config_openly() { + use tower::ServiceExt; + let dir = tempfile::tempdir().unwrap(); + let deps = test_deps(dir.path()); + let folder = dir.path().join("open"); + std::fs::create_dir_all(&folder).unwrap(); + let cfg = space( + Binding::Prefix { + prefix: "/o".into(), + }, + SpaceAuth::None, + folder.to_str().unwrap(), + ); + let inst = build_instance("o", &cfg, &deps); + let resp = inst + .router + .unwrap() + .oneshot( + axum::http::Request::builder() + .uri("/.config") + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), axum::http::StatusCode::OK); + } + + #[tokio::test] + async fn inherit_auth_space_401s_without_credentials() { + use tower::ServiceExt; + let dir = tempfile::tempdir().unwrap(); + let deps = test_deps(dir.path()); + let folder = dir.path().join("locked"); + std::fs::create_dir_all(&folder).unwrap(); + let cfg = space( + Binding::Prefix { + prefix: "/l".into(), + }, + SpaceAuth::Inherit, + folder.to_str().unwrap(), + ); + let inst = build_instance("l", &cfg, &deps); + let resp = inst + .router + .unwrap() + .oneshot( + axum::http::Request::builder() + .uri("/.config") + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), axum::http::StatusCode::UNAUTHORIZED); + } + + #[test] + fn seed_index_creates_page_only_in_empty_space() { + let dir = tempfile::tempdir().unwrap(); + seed_index(dir.path(), "index"); + assert!(dir.path().join("index.md").exists()); + // Non-empty: do not overwrite/add. + std::fs::write(dir.path().join("other.md"), "x").unwrap(); + seed_index(dir.path(), "home"); + assert!(!dir.path().join("home.md").exists()); + } +} diff --git a/server/src/multi/listeners.rs b/server/src/multi/listeners.rs new file mode 100644 index 00000000..e8e46c2e --- /dev/null +++ b/server/src/multi/listeners.rs @@ -0,0 +1,240 @@ +//! Dynamically managed TCP listeners for port-bound spaces. Reconciles the +//! bound-port set against the routing table on every config change. Each port +//! serves whatever instance the CURRENT table maps it to, so editing a space +//! swaps content without rebinding; only adding/removing ports touches sockets. + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use axum::extract::{Request, State}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::Router; +use tower::ServiceExt; + +use crate::multi::instance::InstanceStatus; +use crate::multi::manager::MultiManager; + +/// Reconcile-on-change loop: bind an initial set of listeners, then re-reconcile +/// on every config change until the manager is dropped (watch channel closes). +pub async fn run_listener_manager(manager: Arc, bind_host: String) { + let mut rx = manager.subscribe_changes(); + let mut tasks: HashMap> = HashMap::new(); + loop { + reconcile(&manager, &bind_host, &mut tasks).await; + if rx.changed().await.is_err() { + break; // manager dropped — shut down + } + } +} + +async fn reconcile( + manager: &Arc, + bind_host: &str, + tasks: &mut HashMap>, +) { + let wanted: HashSet = manager.registry().current().ports().into_iter().collect(); + + // Unbind removed ports. + let stale: Vec = tasks + .keys() + .filter(|p| !wanted.contains(p)) + .copied() + .collect(); + for port in stale { + if let Some(handle) = tasks.remove(&port) { + handle.abort(); + manager.set_bind_error(port, None); + tracing::info!("unbound space port {port}"); + } + } + + // Bind new ports. + for port in wanted { + if tasks.contains_key(&port) { + continue; + } + match tokio::net::TcpListener::bind((bind_host, port)).await { + Ok(listener) => { + manager.set_bind_error(port, None); + let router = port_router(manager.clone(), port); + tracing::info!("space listening on http://{bind_host}:{port}"); + let handle = tokio::spawn(async move { + let _ = axum::serve(listener, router).await; + }); + tasks.insert(port, handle); + } + Err(e) => { + tracing::warn!("could not bind space port {port}: {e}"); + manager.set_bind_error(port, Some(format!("could not bind port {port}: {e}"))); + } + } + } +} + +/// Router for one dedicated port: every request resolves the port's current +/// instance from the live registry, so editing a port-bound space swaps content +/// without rebinding. +fn port_router(manager: Arc, port: u16) -> Router { + Router::new() + .fallback(serve_port) + .with_state((manager, port)) +} + +async fn serve_port( + State((manager, port)): State<(Arc, u16)>, + req: Request, +) -> Response { + let Some(inst) = manager.registry().current().resolve_port(port) else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + "No space bound to this port", + ) + .into_response(); + }; + match inst.router.clone() { + Some(router) => match router.oneshot(req).await { + Ok(resp) => resp, + Err(never) => match never {}, + }, + None => { + let reason = match &inst.status { + InstanceStatus::Errored(r) => r.clone(), + _ => "space unavailable".into(), + }; + ( + StatusCode::SERVICE_UNAVAILABLE, + format!("Space unavailable: {reason}"), + ) + .into_response() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::multi::config::{Binding, SpaceAuth, SpaceConfig}; + use crate::multi::instance::{AssetFactories, InstanceDeps}; + use crate::multi::manager::MultiManager; + use silverbullet_server_common::space::MemorySpacePrimitives; + + fn deps(root: &std::path::Path) -> InstanceDeps { + InstanceDeps { + root: root.to_path_buf(), + assets: AssetFactories { + client_bundle: Box::new(|| Box::new(MemorySpacePrimitives::new())), + base_fs: Box::new(|| Box::new(MemorySpacePrimitives::new())), + }, + runtime: Box::new(|_| None), + metrics: None, + admin_auth: crate::auth::AuthConfig::try_parse( + Some("admin:pw"), + None, + None, + None, + None, + ) + .unwrap() + .unwrap(), + version: "test".into(), + main_port: 3000, + disable_service_worker: true, + } + } + + fn payload(name: &str, port: u16) -> SpaceConfig { + SpaceConfig { + name: name.into(), + folder: String::new(), + binding: Binding::Port { port }, + auth: SpaceAuth::None, + read_only: false, + shell: Default::default(), + runtime_api: false, + index_page: "index".into(), + description: String::new(), + theme_color: "#e1e1e1".into(), + head_html: String::new(), + space_ignore: String::new(), + log_push: false, + extra: Default::default(), + } + } + + fn free_port() -> u16 { + std::net::TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port() + } + + /// Minimal HTTP GET over a raw socket (no client dependency needed). + async fn http_get_status(port: u16, path: &str) -> Option { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let mut stream = tokio::net::TcpStream::connect(("127.0.0.1", port)) + .await + .ok()?; + let req = format!("GET {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n"); + stream.write_all(req.as_bytes()).await.ok()?; + let mut buf = String::new(); + stream.read_to_string(&mut buf).await.ok()?; + buf.split_whitespace().nth(1)?.parse().ok() + } + + async fn wait_for_status(port: u16, path: &str, want: u16) -> bool { + for _ in 0..100 { + if http_get_status(port, path).await == Some(want) { + return true; + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + false + } + + #[tokio::test(flavor = "multi_thread")] + async fn ports_bind_serve_and_unbind_with_config() { + let dir = tempfile::tempdir().unwrap(); + let m = MultiManager::boot(dir.path().to_path_buf(), deps(dir.path()), 3000, None).unwrap(); + let _task = tokio::spawn(run_listener_manager(m.clone(), "127.0.0.1".into())); + + let port = free_port(); + let id = m.create(payload("P", port)).unwrap(); + assert!( + wait_for_status(port, "/.ping", 200).await, + "port space must serve /.ping" + ); + + m.delete(&id).unwrap(); + // After removal the port stops answering (connection refused -> None). + for _ in 0..100 { + if http_get_status(port, "/.ping").await.is_none() { + return; + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + panic!("port should have been unbound"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn bind_conflict_marks_space_errored() { + let dir = tempfile::tempdir().unwrap(); + let m = MultiManager::boot(dir.path().to_path_buf(), deps(dir.path()), 3000, None).unwrap(); + let _task = tokio::spawn(run_listener_manager(m.clone(), "127.0.0.1".into())); + + // Occupy a port, then configure a space on it. + let blocker = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = blocker.local_addr().unwrap().port(); + let id = m.create(payload("Clash", port)).unwrap(); + + for _ in 0..100 { + let list = m.list(); + if list[&id]["status"]["state"] == "errored" { + return; + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + panic!("bind conflict should mark the space errored"); + } +} diff --git a/server/src/multi/manager.rs b/server/src/multi/manager.rs new file mode 100644 index 00000000..3173d10d --- /dev/null +++ b/server/src/multi/manager.rs @@ -0,0 +1,507 @@ +//! The multi-space orchestrator: owns the persisted `MultiConfig`, the built +//! instances, and the swap-on-change lifecycle. Every mutation follows one +//! path: validate -> persist (atomic) -> rebuild changed instances -> swap the +//! routing table -> notify the listener manager. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use crate::multi::config::{MultiConfig, SpaceAuth, SpaceConfig}; +use crate::multi::instance::{ + build_instance, resolve_folder, seed_index, InstanceDeps, InstanceStatus, SpaceInstance, +}; +use crate::multi::registry::{Registry, RoutingTable}; +use crate::multi::validate::{validate, FieldError}; + +#[derive(Debug)] +pub enum ApiError { + Validation(Vec), + NotFound, + Internal(String), +} + +pub struct MultiManager { + root: PathBuf, + config_path: PathBuf, + deps: InstanceDeps, + main_port: u16, + metrics_port: Option, + /// Current persisted config + built instances, mutated under one lock so + /// concurrent admin calls serialize. + state: Mutex, + registry: Registry, + bind_errors: Mutex>, + changes: tokio::sync::watch::Sender, +} + +struct Inner { + config: MultiConfig, + instances: HashMap>, +} + +impl MultiManager { + /// Load spaces.json (hard error when malformed), build all instances, and + /// return the manager. `changes` is a watch channel bumped on every apply. + pub fn boot( + root: PathBuf, + deps: InstanceDeps, + main_port: u16, + metrics_port: Option, + ) -> Result, String> { + let config_path = root.join("spaces.json"); + let config = MultiConfig::load(&config_path)?; + let errors = validate(&config, main_port, metrics_port); + if !errors.is_empty() { + let msgs: Vec = errors + .iter() + .map(|e| format!("{}: {}", e.field, e.message)) + .collect(); + return Err(format!("invalid spaces.json: {}", msgs.join("; "))); + } + let instances: HashMap> = config + .spaces + .iter() + .map(|(id, cfg)| (id.clone(), Arc::new(build_instance(id, cfg, &deps)))) + .collect(); + let table = RoutingTable::build(instances.clone()); + let (changes, _) = tokio::sync::watch::channel(0); + Ok(Arc::new(Self { + root, + config_path, + deps, + main_port, + metrics_port, + state: Mutex::new(Inner { config, instances }), + registry: Registry::new(table), + bind_errors: Mutex::new(HashMap::new()), + changes, + })) + } + + pub fn registry(&self) -> &Registry { + &self.registry + } + + pub fn root(&self) -> &Path { + &self.root + } + + pub fn subscribe_changes(&self) -> tokio::sync::watch::Receiver { + self.changes.subscribe() + } + + /// Record/clear a port bind failure (listener manager reports these). + /// Live availability check for a prospective port binding. Returns + /// `(available, reason)`. `exclude_id` names the space being edited, whose + /// own current binding counts as available. The OS-level probe binds on + /// the loopback interface only, so it is advisory — the authoritative + /// check remains the listener manager's real bind at apply time. + pub fn port_check(&self, port: u16, exclude_id: Option<&str>) -> (bool, String) { + if port == self.main_port { + return (false, "used by the main SilverBullet listener".into()); + } + if Some(port) == self.metrics_port { + return (false, "used by the metrics listener".into()); + } + { + let inner = self.state.lock().unwrap(); + for (id, cfg) in &inner.config.spaces { + if let crate::multi::config::Binding::Port { port: p } = &cfg.binding { + if *p == port { + if exclude_id == Some(id.as_str()) { + return (true, "current binding of this space".into()); + } + return (false, format!("used by space \"{}\"", cfg.name)); + } + } + } + } + match std::net::TcpListener::bind(("127.0.0.1", port)) { + Ok(_) => (true, String::new()), + Err(e) => (false, format!("not bindable: {e}")), + } + } + + pub fn set_bind_error(&self, port: u16, error: Option) { + let mut be = self.bind_errors.lock().unwrap(); + match error { + Some(e) => { + be.insert(port, e); + } + None => { + be.remove(&port); + } + } + } + + /// Validate + persist + rebuild + swap. Called with the state lock held by + /// the CRUD methods (single mutation path). On validation or persist + /// failure, `inner` is left completely untouched. + fn apply_locked(&self, inner: &mut Inner, new_config: MultiConfig) -> Result<(), ApiError> { + let errors = validate(&new_config, self.main_port, self.metrics_port); + if !errors.is_empty() { + return Err(ApiError::Validation(errors)); + } + new_config + .save(&self.config_path) + .map_err(ApiError::Internal)?; + // Rebuild changed/new instances; reuse Arcs for untouched ones. + let mut instances = HashMap::new(); + for (id, cfg) in &new_config.spaces { + match inner.instances.get(id) { + Some(existing) if &existing.config == cfg => { + instances.insert(id.clone(), existing.clone()); + } + _ => { + instances.insert(id.clone(), Arc::new(build_instance(id, cfg, &self.deps))); + } + } + } + self.registry.swap(RoutingTable::build(instances.clone())); + inner.config = new_config; + inner.instances = instances; + self.changes.send_modify(|v| *v += 1); + Ok(()) + } + + pub fn create(&self, mut cfg: SpaceConfig) -> Result { + let mut inner = self.state.lock().unwrap(); + let id = uuid::Uuid::new_v4().to_string(); + if cfg.folder.is_empty() { + cfg.folder = format!("spaces/{id}"); + } + let folder = resolve_folder(&self.root, &id, &cfg.folder); + std::fs::create_dir_all(&folder).map_err(|e| { + ApiError::Validation(vec![FieldError { + field: "folder".into(), + message: format!("could not create folder {}: {e}", folder.display()), + }]) + })?; + let mut new_config = inner.config.clone(); + new_config.spaces.insert(id.clone(), cfg.clone()); + self.apply_locked(&mut inner, new_config)?; + seed_index(&folder, &cfg.index_page); + Ok(id) + } + + pub fn update(&self, id: &str, cfg: SpaceConfig) -> Result<(), ApiError> { + let mut inner = self.state.lock().unwrap(); + if !inner.config.spaces.contains_key(id) { + return Err(ApiError::NotFound); + } + let mut new_config = inner.config.clone(); + new_config.spaces.insert(id.to_string(), cfg); + self.apply_locked(&mut inner, new_config) + } + + pub fn delete(&self, id: &str) -> Result<(), ApiError> { + let mut inner = self.state.lock().unwrap(); + if !inner.config.spaces.contains_key(id) { + return Err(ApiError::NotFound); + } + let mut new_config = inner.config.clone(); + new_config.spaces.remove(id); + self.apply_locked(&mut inner, new_config) + } + + pub fn set_password(&self, id: &str, plain: &str) -> Result<(), ApiError> { + let mut inner = self.state.lock().unwrap(); + let Some(space) = inner.config.spaces.get(id).cloned() else { + return Err(ApiError::NotFound); + }; + let SpaceAuth::Custom { + user, + auth_token, + lockout_limit, + lockout_time, + remember_me_hours, + .. + } = space.auth.clone() + else { + return Err(ApiError::Validation(vec![FieldError { + field: "auth.mode".into(), + message: "password can only be set for custom auth mode".into(), + }])); + }; + let pass_hash = crate::auth::password::hash_password(plain).map_err(ApiError::Internal)?; + let mut updated = space; + updated.auth = SpaceAuth::Custom { + user, + pass_hash, + auth_token, + lockout_limit, + lockout_time, + remember_me_hours, + }; + let mut new_config = inner.config.clone(); + new_config.spaces.insert(id.to_string(), updated); + self.apply_locked(&mut inner, new_config) + } + + /// JSON view for GET /spaces: id -> { config (passHash redacted to + /// hasPassword), status: { state: "running"|"errored", reason? } }. + pub fn list(&self) -> serde_json::Value { + let inner = self.state.lock().unwrap(); + let bind_errors = self.bind_errors.lock().unwrap(); + let mut out = serde_json::Map::new(); + for (id, inst) in &inner.instances { + let mut v = serde_json::to_value(&inst.config).unwrap_or_default(); + // Redact the hash; expose hasPassword. + let mut has_password = false; + if let Some(auth) = v.get_mut("auth").and_then(|a| a.as_object_mut()) { + if let Some(h) = auth.remove("passHash") { + has_password = h.as_str().map(|s| !s.is_empty()).unwrap_or(false); + } + } + v["hasPassword"] = serde_json::Value::Bool(has_password); + // Status: build errors, or a bind error reported by the listener + // manager for port-bound spaces. + let port_error = match &inst.config.binding { + crate::multi::config::Binding::Port { port } => bind_errors.get(port).cloned(), + _ => None, + }; + v["status"] = match (&inst.status, port_error) { + (InstanceStatus::Errored(reason), _) => { + serde_json::json!({ "state": "errored", "reason": reason }) + } + (_, Some(reason)) => serde_json::json!({ "state": "errored", "reason": reason }), + _ => serde_json::json!({ "state": "running" }), + }; + out.insert(id.clone(), v); + } + serde_json::Value::Object(out) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::multi::config::{Binding, SpaceAuth, SpaceConfig}; + use crate::multi::instance::{AssetFactories, InstanceDeps}; + use silverbullet_server_common::space::MemorySpacePrimitives; + + fn deps(root: &std::path::Path) -> InstanceDeps { + InstanceDeps { + root: root.to_path_buf(), + assets: AssetFactories { + client_bundle: Box::new(|| Box::new(MemorySpacePrimitives::new())), + base_fs: Box::new(|| Box::new(MemorySpacePrimitives::new())), + }, + runtime: Box::new(|_| None), + metrics: None, + admin_auth: crate::auth::AuthConfig::try_parse( + Some("admin:pw"), + None, + None, + None, + None, + ) + .unwrap() + .unwrap(), + version: "test".into(), + main_port: 3000, + disable_service_worker: true, + } + } + + fn payload(name: &str, binding: Binding) -> SpaceConfig { + SpaceConfig { + name: name.into(), + folder: String::new(), + binding, + auth: SpaceAuth::None, + read_only: false, + shell: Default::default(), + runtime_api: false, + index_page: "index".into(), + description: String::new(), + theme_color: "#e1e1e1".into(), + head_html: String::new(), + space_ignore: String::new(), + log_push: false, + extra: Default::default(), + } + } + + fn boot(root: &std::path::Path) -> std::sync::Arc { + MultiManager::boot(root.to_path_buf(), deps(root), 3000, None).unwrap() + } + + #[test] + fn boots_empty_without_config_file() { + let dir = tempfile::tempdir().unwrap(); + let m = boot(dir.path()); + assert!(m.registry().current().instances.is_empty()); + } + + #[test] + fn malformed_config_fails_boot() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("spaces.json"), "{ nope").unwrap(); + assert!( + MultiManager::boot(dir.path().to_path_buf(), deps(dir.path()), 3000, None).is_err() + ); + } + + #[test] + fn create_defaults_folder_seeds_index_persists_and_routes() { + let dir = tempfile::tempdir().unwrap(); + let m = boot(dir.path()); + let id = m + .create(payload( + "Work", + Binding::Prefix { + prefix: "/work".into(), + }, + )) + .unwrap(); + // Folder created + index seeded. + assert!(dir + .path() + .join("spaces") + .join(&id) + .join("index.md") + .exists()); + // Routes live immediately. + let (inst, prefix) = m + .registry() + .current() + .resolve_main("localhost", "/work/x") + .unwrap(); + assert_eq!(inst.id, id); + assert_eq!(prefix, "/work"); + // Persisted. + let cfg = crate::multi::config::MultiConfig::load(&dir.path().join("spaces.json")).unwrap(); + assert_eq!(cfg.spaces[&id].name, "Work"); + } + + #[test] + fn duplicate_prefix_is_rejected_and_not_persisted() { + let dir = tempfile::tempdir().unwrap(); + let m = boot(dir.path()); + m.create(payload( + "A", + Binding::Prefix { + prefix: "/x".into(), + }, + )) + .unwrap(); + let e = m + .create(payload( + "B", + Binding::Prefix { + prefix: "/x".into(), + }, + )) + .unwrap_err(); + assert!(matches!(e, ApiError::Validation(_))); + let cfg = crate::multi::config::MultiConfig::load(&dir.path().join("spaces.json")).unwrap(); + assert_eq!(cfg.spaces.len(), 1); + } + + #[test] + fn update_rebinds_and_delete_removes_but_keeps_files() { + let dir = tempfile::tempdir().unwrap(); + let m = boot(dir.path()); + let id = m + .create(payload( + "A", + Binding::Prefix { + prefix: "/a".into(), + }, + )) + .unwrap(); + let mut cfg = payload( + "A", + Binding::Prefix { + prefix: "/b".into(), + }, + ); + cfg.folder = format!("spaces/{id}"); // keep the same folder + m.update(&id, cfg).unwrap(); + assert!(m + .registry() + .current() + .resolve_main("localhost", "/a/x") + .is_none()); + assert!(m + .registry() + .current() + .resolve_main("localhost", "/b/x") + .is_some()); + + m.delete(&id).unwrap(); + assert!(m.registry().current().instances.is_empty()); + assert!( + dir.path() + .join("spaces") + .join(&id) + .join("index.md") + .exists(), + "files kept" + ); + assert!(matches!(m.delete(&id), Err(ApiError::NotFound))); + } + + #[test] + fn set_password_hashes_and_unerrors_custom_space() { + let dir = tempfile::tempdir().unwrap(); + let m = boot(dir.path()); + let mut p = payload( + "C", + Binding::Prefix { + prefix: "/c".into(), + }, + ); + p.auth = SpaceAuth::Custom { + user: "u".into(), + pass_hash: String::new(), + auth_token: String::new(), + lockout_limit: 10, + lockout_time: 60, + remember_me_hours: 168, + }; + let id = m.create(p).unwrap(); + // Errored until a password is set. + let list = m.list(); + assert_eq!(list[&id]["status"]["state"], "errored"); + m.set_password(&id, "hunter2").unwrap(); + let list = m.list(); + assert_eq!(list[&id]["status"]["state"], "running"); + assert_eq!(list[&id]["hasPassword"], true); + assert!( + list[&id]["auth"].get("passHash").is_none(), + "hash must be redacted" + ); + } + + #[test] + fn set_password_on_non_custom_space_is_a_validation_error() { + let dir = tempfile::tempdir().unwrap(); + let m = boot(dir.path()); + let id = m + .create(payload( + "A", + Binding::Prefix { + prefix: "/a".into(), + }, + )) + .unwrap(); + assert!(matches!( + m.set_password(&id, "x"), + Err(ApiError::Validation(_)) + )); + } + + #[test] + fn change_watch_bumps_on_apply() { + let dir = tempfile::tempdir().unwrap(); + let m = boot(dir.path()); + let rx = m.subscribe_changes(); + let before = *rx.borrow(); + m.create(payload("A", Binding::Port { port: 4321 })) + .unwrap(); + assert!(*m.subscribe_changes().borrow() > before); + } +} diff --git a/server/src/multi/mod.rs b/server/src/multi/mod.rs new file mode 100644 index 00000000..0a0ebf86 --- /dev/null +++ b/server/src/multi/mod.rs @@ -0,0 +1,12 @@ +//! Multi-space server mode: N spaces served from one process, each bound to a +//! URL prefix, hostname, or dedicated port, configured via `spaces.json` and +//! managed through the `/.admin` UI. See docs/Multi-Space Mode.md. + +pub mod admin_api; +pub mod config; +pub mod dispatch; +pub mod instance; +pub mod listeners; +pub mod manager; +pub mod registry; +pub mod validate; diff --git a/server/src/multi/registry.rs b/server/src/multi/registry.rs new file mode 100644 index 00000000..9237ff00 --- /dev/null +++ b/server/src/multi/registry.rs @@ -0,0 +1,240 @@ +//! The live routing state: which space answers which host/prefix/port. Built +//! immutably from the instance set and swapped wholesale behind a `RwLock` on +//! every config change (readers clone the `Arc`, never block on rebuilds). + +use std::collections::HashMap; +use std::sync::{Arc, RwLock}; + +use crate::multi::config::Binding; +use crate::multi::instance::SpaceInstance; + +pub struct RoutingTable { + pub instances: HashMap>, + hosts: HashMap>, + /// (normalized prefix, instance), sorted longest-first. + prefixes: Vec<(String, Arc)>, + ports: HashMap>, +} + +impl RoutingTable { + pub fn build(instances: HashMap>) -> Self { + let mut hosts = HashMap::new(); + let mut prefixes = Vec::new(); + let mut ports = HashMap::new(); + for inst in instances.values() { + match &inst.config.binding { + Binding::Prefix { .. } => prefixes.push((inst.prefix.clone(), inst.clone())), + Binding::Host { host } => { + // Host matching is case-insensitive (DNS is); store the key + // lowercased and lowercase the request host at resolve time. + hosts.insert(host.to_ascii_lowercase(), inst.clone()); + } + Binding::Port { port } => { + ports.insert(*port, inst.clone()); + } + } + } + prefixes.sort_by_key(|(prefix, _)| std::cmp::Reverse(prefix.len())); + Self { + instances, + hosts, + prefixes, + ports, + } + } + + /// Resolve a main-listener request. `host` is the raw Host header (may + /// include :port — stripped internally). Returns the instance and the + /// matched prefix ("" for host matches). + pub fn resolve_main(&self, host: &str, path: &str) -> Option<(Arc, String)> { + let bare_host = host.split(':').next().unwrap_or(host).to_ascii_lowercase(); + if let Some(inst) = self.hosts.get(&bare_host) { + return Some((inst.clone(), String::new())); + } + for (prefix, inst) in &self.prefixes { + let matches = + prefix.is_empty() || path == prefix || path.starts_with(&format!("{prefix}/")); + if matches { + return Some((inst.clone(), prefix.clone())); + } + } + None + } + + pub fn resolve_port(&self, port: u16) -> Option> { + self.ports.get(&port).cloned() + } + + pub fn ports(&self) -> Vec { + let mut v: Vec = self.ports.keys().copied().collect(); + v.sort_unstable(); + v + } +} + +/// Swappable handle to the current routing table. +pub struct Registry(RwLock>); + +impl Registry { + pub fn new(table: RoutingTable) -> Self { + Self(RwLock::new(Arc::new(table))) + } + pub fn current(&self) -> Arc { + self.0.read().expect("registry lock poisoned").clone() + } + pub fn swap(&self, table: RoutingTable) { + *self.0.write().expect("registry lock poisoned") = Arc::new(table); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::multi::config::{Binding, SpaceAuth, SpaceConfig}; + use crate::multi::instance::{InstanceStatus, SpaceInstance}; + use std::collections::HashMap; + use std::sync::Arc; + + fn inst(id: &str, binding: Binding) -> Arc { + let prefix = match &binding { + Binding::Prefix { prefix } => crate::multi::validate::normalize_prefix(prefix), + _ => String::new(), + }; + Arc::new(SpaceInstance { + id: id.into(), + config: SpaceConfig { + name: id.into(), + folder: String::new(), + binding, + auth: SpaceAuth::None, + read_only: false, + shell: Default::default(), + runtime_api: false, + index_page: "index".into(), + description: String::new(), + theme_color: String::new(), + head_html: String::new(), + space_ignore: String::new(), + log_push: false, + extra: Default::default(), + }, + prefix, + status: InstanceStatus::Running, + router: None, + }) + } + + fn table() -> RoutingTable { + let mut m = HashMap::new(); + m.insert( + "root".into(), + inst("root", Binding::Prefix { prefix: "/".into() }), + ); + m.insert( + "work".into(), + inst( + "work", + Binding::Prefix { + prefix: "/work".into(), + }, + ), + ); + m.insert( + "deep".into(), + inst( + "deep", + Binding::Prefix { + prefix: "/work/sub".into(), + }, + ), + ); + m.insert( + "hosted".into(), + inst( + "hosted", + Binding::Host { + host: "notes.example.com".into(), + }, + ), + ); + m.insert( + "ported".into(), + inst("ported", Binding::Port { port: 4001 }), + ); + RoutingTable::build(m) + } + + #[test] + fn host_match_beats_prefix_match() { + let t = table(); + let (i, p) = t + .resolve_main("notes.example.com:3000", "/work/page") + .unwrap(); + assert_eq!(i.id, "hosted"); + assert_eq!(p, ""); + } + + #[test] + fn host_match_is_case_insensitive() { + let t = table(); + // Mixed-case Host header still resolves to the lowercase-bound space. + let (i, p) = t.resolve_main("Notes.Example.COM:3000", "/x").unwrap(); + assert_eq!(i.id, "hosted"); + assert_eq!(p, ""); + } + + #[test] + fn longest_prefix_wins() { + let t = table(); + assert_eq!( + t.resolve_main("localhost", "/work/sub/x").unwrap().0.id, + "deep" + ); + assert_eq!( + t.resolve_main("localhost", "/work/other").unwrap().0.id, + "work" + ); + assert_eq!(t.resolve_main("localhost", "/work").unwrap().0.id, "work"); + // /workother must NOT match /work (boundary check). + assert_eq!( + t.resolve_main("localhost", "/workother").unwrap().0.id, + "root" + ); + assert_eq!( + t.resolve_main("localhost", "/anything").unwrap().0.id, + "root" + ); + } + + #[test] + fn no_root_space_means_no_match() { + let mut m = HashMap::new(); + m.insert( + "work".into(), + inst( + "work", + Binding::Prefix { + prefix: "/work".into(), + }, + ), + ); + let t = RoutingTable::build(m); + assert!(t.resolve_main("localhost", "/other").is_none()); + } + + #[test] + fn port_resolution_and_listing() { + let t = table(); + assert_eq!(t.resolve_port(4001).unwrap().id, "ported"); + assert!(t.resolve_port(4002).is_none()); + assert_eq!(t.ports(), vec![4001]); + } + + #[test] + fn registry_swaps_atomically() { + let r = Registry::new(table()); + assert_eq!(r.current().ports(), vec![4001]); + r.swap(RoutingTable::build(HashMap::new())); + assert!(r.current().ports().is_empty()); + } +} diff --git a/server/src/multi/validate.rs b/server/src/multi/validate.rs new file mode 100644 index 00000000..89dcd0bf --- /dev/null +++ b/server/src/multi/validate.rs @@ -0,0 +1,472 @@ +//! Whole-config validation for multi-space mode. Pure — filesystem checks +//! (folder accessibility) happen in the manager at apply time. + +use serde::Serialize; +use std::collections::HashMap; + +use crate::multi::config::{Binding, MultiConfig, SpaceAuth}; + +#[derive(Debug, Clone, Serialize)] +pub struct FieldError { + pub field: String, + pub message: String, +} + +fn err(errors: &mut Vec, field: impl Into, message: impl Into) { + errors.push(FieldError { + field: field.into(), + message: message.into(), + }); +} + +/// Normalize a URL prefix: single leading `/`, no trailing `/`; `/` -> "". +/// Mirrors the single-space `SB_URL_PREFIX` normalization. +pub fn normalize_prefix(raw: &str) -> String { + if raw.is_empty() { + return String::new(); + } + let with_lead = if raw.starts_with('/') { + raw.to_string() + } else { + format!("/{raw}") + }; + with_lead.trim_end_matches('/').to_string() +} + +/// Validate the whole config. Empty result = acceptable. Field paths are +/// `.`. +pub fn validate( + config: &MultiConfig, + main_port: u16, + metrics_port: Option, +) -> Vec { + let mut errors = Vec::new(); + let mut seen_prefixes: HashMap = HashMap::new(); // normalized -> id + let mut seen_hosts: HashMap = HashMap::new(); + let mut seen_ports: HashMap = HashMap::new(); + let mut seen_folders: HashMap = HashMap::new(); // folder -> id + + for (id, space) in &config.spaces { + if space.name.trim().is_empty() { + err(&mut errors, format!("{id}.name"), "name must not be empty"); + } + // Two spaces sharing a folder would silently mix their files; catch the + // literal-duplicate case (aliasing via symlinks/absolute-vs-relative is + // out of scope for this pure check). Empty folders default to a + // per-space GUID directory and can't collide. + if !space.folder.is_empty() { + let key = space.folder.trim_end_matches('/').to_string(); + if let Some(other) = seen_folders.insert(key, id.clone()) { + err( + &mut errors, + format!("{id}.folder"), + format!("folder {:?} already used by space {other}", space.folder), + ); + } + } + match &space.binding { + Binding::Prefix { prefix } => { + let norm = normalize_prefix(prefix); + if norm.is_empty() { + // A bare "/" (or empty) prefix would claim the whole host; + // spaces must live under a named path segment. + err( + &mut errors, + format!("{id}.binding"), + "prefix must contain at least one path segment (a bare / is not allowed)", + ); + } else if norm.starts_with("/.") { + err( + &mut errors, + format!("{id}.binding"), + "prefixes starting with /. are reserved", + ); + } else { + // Prefixes must be unique AND non-overlapping: a space at + // /work and another at /work/sub would fight over URL + // space (and their service-worker scopes would overlap). + let conflict = seen_prefixes.iter().find(|(other_norm, _)| { + *other_norm == &norm + || norm.starts_with(&format!("{other_norm}/")) + || other_norm.starts_with(&format!("{norm}/")) + }); + if let Some((other_norm, other_id)) = conflict { + let other_name = config + .spaces + .get(other_id) + .map(|s| s.name.clone()) + .unwrap_or_else(|| other_id.clone()); + err( + &mut errors, + format!("{id}.binding"), + format!( + "prefix {norm:?} overlaps prefix {other_norm:?} of space \"{other_name}\"" + ), + ); + } else { + seen_prefixes.insert(norm, id.clone()); + } + } + } + Binding::Host { host } => { + if host.is_empty() || host.contains('/') || host.contains(':') { + err( + &mut errors, + format!("{id}.binding"), + "host must be a bare hostname (no port, no slashes)", + ); + } else if let Some(other) = seen_hosts.insert(host.to_ascii_lowercase(), id.clone()) + { + // Host matching is case-insensitive, so dupe detection is too. + err( + &mut errors, + format!("{id}.binding"), + format!("host {host:?} already used by space {other}"), + ); + } + } + Binding::Port { port } => { + if *port == main_port { + err( + &mut errors, + format!("{id}.binding"), + "port collides with the main listener", + ); + } else if Some(*port) == metrics_port { + err( + &mut errors, + format!("{id}.binding"), + "port collides with the metrics listener", + ); + } else if let Some(other) = seen_ports.insert(*port, id.clone()) { + err( + &mut errors, + format!("{id}.binding"), + format!("port {port} already used by space {other}"), + ); + } + } + } + if let SpaceAuth::Custom { + user, pass_hash, .. + } = &space.auth + { + if user.trim().is_empty() { + err( + &mut errors, + format!("{id}.auth.user"), + "custom auth requires a username", + ); + } + if !pass_hash.is_empty() && !crate::auth::password::is_valid_phc(pass_hash) { + err( + &mut errors, + format!("{id}.auth.passHash"), + "not a valid argon2 PHC hash", + ); + } + } + } + errors +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::multi::config::{Binding, MultiConfig, SpaceAuth, SpaceConfig}; + + fn space(name: &str, binding: Binding) -> SpaceConfig { + SpaceConfig { + name: name.into(), + folder: String::new(), + binding, + auth: SpaceAuth::Inherit, + read_only: false, + shell: Default::default(), + runtime_api: false, + index_page: "index".into(), + description: String::new(), + theme_color: "#e1e1e1".into(), + head_html: String::new(), + space_ignore: String::new(), + log_push: false, + extra: Default::default(), + } + } + + fn cfg(entries: Vec<(&str, SpaceConfig)>) -> MultiConfig { + MultiConfig { + spaces: entries + .into_iter() + .map(|(k, v)| (k.to_string(), v)) + .collect(), + } + } + + #[test] + fn prefix_normalization_rules() { + assert_eq!(normalize_prefix("/"), ""); + assert_eq!(normalize_prefix("wiki"), "/wiki"); + assert_eq!(normalize_prefix("/wiki/"), "/wiki"); + } + + #[test] + fn valid_config_passes() { + let c = cfg(vec![ + ( + "a", + space( + "A", + Binding::Prefix { + prefix: "/a".into(), + }, + ), + ), + ( + "b", + space( + "B", + Binding::Host { + host: "b.example.com".into(), + }, + ), + ), + ("c", space("C", Binding::Port { port: 4000 })), + ]); + assert!(validate(&c, 3000, None).is_empty()); + } + + #[test] + fn root_and_empty_prefixes_rejected() { + for raw in ["/", "", "//"] { + let c = cfg(vec![( + "a", + space("A", Binding::Prefix { prefix: raw.into() }), + )]); + let errs = validate(&c, 3000, None); + assert!( + errs.iter().any(|e| e.field == "a.binding"), + "prefix {raw:?} must be rejected: {errs:?}" + ); + } + } + + #[test] + fn overlapping_prefixes_rejected() { + // Nested prefixes conflict regardless of declaration order. + for (p1, p2) in [("/work", "/work/sub"), ("/work/sub", "/work")] { + let c = cfg(vec![ + ("a", space("A", Binding::Prefix { prefix: p1.into() })), + ("b", space("B", Binding::Prefix { prefix: p2.into() })), + ]); + let errs = validate(&c, 3000, None); + assert!( + errs.iter().any(|e| e.field.ends_with(".binding")), + "{p1} + {p2} must conflict: {errs:?}" + ); + } + // A shared string prefix without a path-segment boundary is fine. + let c = cfg(vec![ + ( + "a", + space( + "A", + Binding::Prefix { + prefix: "/work".into(), + }, + ), + ), + ( + "b", + space( + "B", + Binding::Prefix { + prefix: "/workshop".into(), + }, + ), + ), + ]); + assert!(validate(&c, 3000, None).is_empty()); + } + + #[test] + fn duplicate_folders_rejected() { + let mut a = space( + "A", + Binding::Prefix { + prefix: "/a".into(), + }, + ); + a.folder = "spaces/notes".into(); + let mut b = space( + "B", + Binding::Prefix { + prefix: "/b".into(), + }, + ); + b.folder = "spaces/notes/".into(); // same after trailing-slash trim + let errs = validate(&cfg(vec![("a", a), ("b", b)]), 3000, None); + assert!( + errs.iter().any(|e| e.field.ends_with(".folder")), + "{errs:?}" + ); + // Two empty folders are fine (each defaults to its own GUID dir). + let c = cfg(vec![ + ( + "a", + space( + "A", + Binding::Prefix { + prefix: "/a".into(), + }, + ), + ), + ( + "b", + space( + "B", + Binding::Prefix { + prefix: "/b".into(), + }, + ), + ), + ]); + assert!(validate(&c, 3000, None).is_empty()); + } + + #[test] + fn duplicate_bindings_rejected() { + let c = cfg(vec![ + ( + "a", + space( + "A", + Binding::Prefix { + prefix: "/x".into(), + }, + ), + ), + ( + "b", + space( + "B", + Binding::Prefix { + prefix: "/x/".into(), + }, + ), + ), // same after normalization + ]); + let errs = validate(&c, 3000, None); + assert!( + errs.iter().any(|e| e.field.ends_with(".binding")), + "{errs:?}" + ); + } + + #[test] + fn duplicate_hosts_rejected_case_insensitively() { + let c = cfg(vec![ + ( + "a", + space( + "A", + Binding::Host { + host: "Notes.Example.com".into(), + }, + ), + ), + ( + "b", + space( + "B", + Binding::Host { + host: "notes.example.COM".into(), + }, + ), + ), + ]); + let errs = validate(&c, 3000, None); + assert!( + errs.iter().any(|e| e.field.ends_with(".binding")), + "{errs:?}" + ); + } + + #[test] + fn reserved_and_colliding_bindings_rejected() { + let c = cfg(vec![ + ( + "a", + space( + "A", + Binding::Prefix { + prefix: "/.admin".into(), + }, + ), + ), + ("b", space("B", Binding::Port { port: 3000 })), // main port + ("c", space("C", Binding::Port { port: 9090 })), // metrics port + ( + "d", + space( + "D", + Binding::Host { + host: "with/slash".into(), + }, + ), + ), + ]); + let errs = validate(&c, 3000, Some(9090)); + assert_eq!(errs.len(), 4, "{errs:?}"); + } + + #[test] + fn custom_auth_requires_user_and_valid_hash() { + let mut s = space( + "A", + Binding::Prefix { + prefix: "/a".into(), + }, + ); + s.auth = SpaceAuth::Custom { + user: String::new(), + pass_hash: "nonsense".into(), + auth_token: String::new(), + lockout_limit: 10, + lockout_time: 60, + remember_me_hours: 168, + }; + let errs = validate(&cfg(vec![("a", s)]), 3000, None); + assert!(errs.iter().any(|e| e.field == "a.auth.user"), "{errs:?}"); + assert!( + errs.iter().any(|e| e.field == "a.auth.passHash"), + "{errs:?}" + ); + } + + #[test] + fn empty_name_rejected_and_empty_custom_hash_allowed() { + // Empty passHash is legal (password not set yet — space stays errored + // until set), but empty name is not. + let mut s = space( + "", + Binding::Prefix { + prefix: "/a".into(), + }, + ); + s.auth = SpaceAuth::Custom { + user: "u".into(), + pass_hash: String::new(), + auth_token: String::new(), + lockout_limit: 10, + lockout_time: 60, + remember_me_hours: 168, + }; + let errs = validate(&cfg(vec![("a", s)]), 3000, None); + assert!(errs.iter().any(|e| e.field == "a.name"), "{errs:?}"); + assert!( + !errs.iter().any(|e| e.field == "a.auth.passHash"), + "{errs:?}" + ); + } +}