gobby-wiki 0.7.0

Gobby wiki CLI shell
// A 500 MB ceiling keeps video/audio/PDF inbox imports usable while preventing
// accidental multi-GB reads from exhausting memory before media-specific
// ingestion can stream or degrade the file.
const DEFAULT_MAX_INBOX_ITEM_BYTES: u64 = 500_000_000;

use crate::error::WikiError;
use anyhow::{Context, anyhow, bail};
use gobby_core::provisioning::{StandaloneConfig, gcore_config_path};
use std::net::{SocketAddr, ToSocketAddrs};
use std::path::Path;
use std::time::Duration;

const GWIKI_DATABASE_URL_ENV: &str = "GWIKI_DATABASE_URL";
const GOBBY_POSTGRES_DSN_ENV: &str = "GOBBY_POSTGRES_DSN";
const GWIKI_BROKER_TIMEOUT_MS_ENV: &str = "GWIKI_BROKER_TIMEOUT_MS";
const LOCAL_CLI_TOKEN_FILENAME: &str = "local_cli_token";
const DEFAULT_BROKER_TIMEOUT: Duration = Duration::from_millis(7000);

#[derive(Debug)]
struct ValidatedDaemonUrl {
    request_base_url: String,
    host_header: String,
}

pub(crate) fn database_url() -> anyhow::Result<Option<String>> {
    if let Some(database_url) = database_url_from_env() {
        return Ok(Some(database_url));
    }

    let home = gobby_core::gobby_home()?;
    let bootstrap_path = home.join("bootstrap.yaml");
    match resolve_brokered_database_url_at(&home, &bootstrap_path) {
        Ok(database_url) => return Ok(Some(database_url)),
        Err(error) => {
            log::debug!("failed to resolve brokered gwiki database URL: {error}");
        }
    }
    match gobby_core::bootstrap::postgres_database_url_from_bootstrap_file(&bootstrap_path) {
        Ok(Some(database_url)) => return Ok(Some(database_url)),
        Ok(None) => {}
        Err(error) => {
            log::debug!(
                "failed to resolve gwiki database URL from bootstrap file {}: {error}",
                bootstrap_path.display()
            );
        }
    }
    resolve_database_url_from_gcore_config(&home)
}

pub(crate) fn database_url_for(command: &str) -> Result<Option<String>, WikiError> {
    database_url().map_err(|error| WikiError::Config {
        detail: format!("failed to resolve PostgreSQL hub for {command}: {error}"),
    })
}

pub(crate) fn database_url_from_env() -> Option<String> {
    [GWIKI_DATABASE_URL_ENV, GOBBY_POSTGRES_DSN_ENV]
        .into_iter()
        .find_map(|name| {
            std::env::var(name)
                .ok()
                .map(|value| value.trim().to_string())
                .filter(|value| !value.is_empty())
        })
}

fn resolve_database_url_from_gcore_config(home: &Path) -> anyhow::Result<Option<String>> {
    let Some(config) = StandaloneConfig::read_at(&gcore_config_path(home))? else {
        return Ok(None);
    };
    Ok(config
        .get("databases.postgres.dsn")
        .and_then(|value| non_empty_trimmed(Some(value.to_string()))))
}

fn resolve_brokered_database_url_at(
    gobby_home: &Path,
    bootstrap_path: &Path,
) -> anyhow::Result<String> {
    let token = read_local_cli_token_at(gobby_home)?;
    let daemon_url = gobby_core::daemon_url::daemon_url_at(bootstrap_path);
    request_broker_database_url(&daemon_url, &token)
}

fn read_local_cli_token_at(gobby_home: &Path) -> anyhow::Result<String> {
    let path = gobby_home.join(LOCAL_CLI_TOKEN_FILENAME);
    let token = std::fs::read_to_string(&path)
        .with_context(|| format!("missing local CLI token at {}", path.display()))?;
    let token = token.trim().to_string();
    if token.is_empty() {
        bail!("local CLI token at {} is empty", path.display());
    }
    Ok(token)
}

fn request_broker_database_url(daemon_url: &str, token: &str) -> anyhow::Result<String> {
    let daemon = validate_loopback_daemon_url(daemon_url)?;
    let url = format!(
        "{}/api/local/runtime/database-url",
        daemon.request_base_url.trim_end_matches('/')
    );
    let timeout = broker_timeout();
    let agent = ureq::AgentBuilder::new().timeout(timeout).build();
    let response = agent
        .post(&url)
        .set("Host", &daemon.host_header)
        .set("X-Gobby-Local-Token", token)
        .call()
        .map_err(|err| {
            anyhow!(
                "database_url broker request failed after {}ms: {err}",
                timeout.as_millis()
            )
        })?;
    let body = response
        .into_string()
        .context("database_url broker response body was not valid UTF-8")?;
    let body: serde_json::Value =
        serde_json::from_str(&body).context("database_url broker response was not valid JSON")?;
    let database_url = body
        .get("database_url")
        .and_then(|value| value.as_str())
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .ok_or_else(|| anyhow!("database_url broker response omitted database_url"))?;
    validate_database_url(database_url)
}

fn broker_timeout() -> Duration {
    let Some(raw) = std::env::var(GWIKI_BROKER_TIMEOUT_MS_ENV).ok() else {
        return DEFAULT_BROKER_TIMEOUT;
    };
    raw.trim()
        .parse::<u64>()
        .ok()
        .filter(|millis| *millis > 0)
        .map(Duration::from_millis)
        .unwrap_or(DEFAULT_BROKER_TIMEOUT)
}

fn validate_loopback_daemon_url(daemon_url: &str) -> anyhow::Result<ValidatedDaemonUrl> {
    let url = url::Url::parse(daemon_url)
        .with_context(|| format!("database_url broker daemon URL is invalid: {daemon_url}"))?;
    let host = url
        .host_str()
        .ok_or_else(|| anyhow!("database_url broker daemon URL must include a host"))?;
    let port = url.port_or_known_default().ok_or_else(|| {
        anyhow!("database_url broker daemon URL must include a port or known scheme")
    })?;
    let resolved = (host, port)
        .to_socket_addrs()
        .with_context(|| format!("resolve database_url broker daemon host `{host}`"))?
        .collect::<Vec<_>>();
    if resolved.is_empty() {
        bail!("database_url broker daemon host `{host}` resolved no addresses");
    }
    if resolved.iter().any(|addr| !addr.ip().is_loopback()) {
        bail!("database_url broker daemon host `{host}` must resolve only to loopback addresses");
    }
    let target_addr = resolved[0];
    Ok(ValidatedDaemonUrl {
        request_base_url: request_base_url(&url, target_addr)?,
        host_header: host_header(host, url.port()),
    })
}

fn request_base_url(url: &url::Url, target_addr: SocketAddr) -> anyhow::Result<String> {
    let mut request_url = url::Url::parse(&format!("{}://{}", url.scheme(), target_addr))
        .context("construct database_url broker request URL")?;
    request_url.set_path(url.path());
    request_url.set_query(url.query());
    Ok(request_url.to_string())
}

fn host_header(host: &str, port: Option<u16>) -> String {
    let host = if host.contains(':') && !host.starts_with('[') {
        format!("[{host}]")
    } else {
        host.to_string()
    };
    match port {
        Some(port) => format!("{host}:{port}"),
        None => host,
    }
}

fn validate_database_url(database_url: &str) -> anyhow::Result<String> {
    let parsed = url::Url::parse(database_url)
        .with_context(|| "database_url broker returned an invalid PostgreSQL URL")?;
    if !matches!(parsed.scheme(), "postgres" | "postgresql") {
        bail!(
            "database_url broker returned unsupported scheme `{}`",
            parsed.scheme()
        );
    }
    if parsed.host_str().is_none() {
        bail!("database_url broker returned a PostgreSQL URL without a host");
    }
    if parsed.path().trim_matches('/').is_empty() {
        bail!("database_url broker returned a PostgreSQL URL without a database name");
    }
    Ok(database_url.trim().to_string())
}

fn non_empty_trimmed(value: Option<String>) -> Option<String> {
    value
        .map(|value| value.trim().to_string())
        .filter(|value| !value.is_empty())
}

pub(crate) fn max_inbox_item_bytes_from_env() -> u64 {
    match std::env::var("GWIKI_MAX_INBOX_ITEM_BYTES") {
        Ok(raw) => parse_positive_u64(&raw).unwrap_or_else(|| {
            eprintln!("warning: ignoring invalid GWIKI_MAX_INBOX_ITEM_BYTES={raw}");
            DEFAULT_MAX_INBOX_ITEM_BYTES
        }),
        Err(_) => DEFAULT_MAX_INBOX_ITEM_BYTES,
    }
}

fn parse_positive_u64(raw: &str) -> Option<u64> {
    raw.trim().parse::<u64>().ok().filter(|value| *value > 0)
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use std::io::{Read, Write};
    use std::net::TcpListener;
    use std::thread;

    use crate::support::test_env::EnvGuard;

    #[test]
    fn positive_u64_env_parser_rejects_invalid_values() {
        assert_eq!(parse_positive_u64("42"), Some(42));
        assert_eq!(parse_positive_u64(" 7 "), Some(7));
        assert_eq!(parse_positive_u64("0"), None);
        assert_eq!(parse_positive_u64("-1"), None);
        assert_eq!(parse_positive_u64("nope"), None);
    }

    #[test]
    #[serial_test::serial]
    fn database_url_uses_gobby_broker_when_env_missing() {
        let expected_database_url = "postgresql://brokered.example/gobby";
        let token = "local-token";
        let (port, handle) = spawn_database_url_broker(expected_database_url, token);
        let home = tempfile::tempdir().expect("create home");
        fs::write(
            home.path().join("bootstrap.yaml"),
            format!("daemon_port: {port}\nbind_host: 127.0.0.1\n"),
        )
        .expect("write bootstrap");
        fs::write(home.path().join("local_cli_token"), format!("{token}\n")).expect("write token");
        let _env = EnvGuard::set("GOBBY_HOME", home.path().as_os_str())
            .and_unset("GWIKI_DATABASE_URL")
            .and_unset("GOBBY_POSTGRES_DSN");

        let resolved = database_url()
            .expect("resolve database url")
            .expect("brokered database url");

        assert_eq!(resolved, expected_database_url);
        let request = handle.join().expect("broker thread");
        assert!(request.starts_with("POST /api/local/runtime/database-url "));
        assert!(request.contains(&format!("Host: 127.0.0.1:{port}")));
        assert!(request.contains("X-Gobby-Local-Token: local-token"));
    }

    #[test]
    fn database_url_broker_rejects_non_loopback_daemon_host() {
        let error = validate_loopback_daemon_url("http://192.0.2.1:60887")
            .expect_err("non-loopback daemon host is rejected");

        assert!(
            error
                .to_string()
                .contains("must resolve only to loopback addresses")
        );
    }

    #[test]
    #[serial_test::serial]
    fn database_url_logs_bad_bootstrap_and_falls_back_to_gcore_config() {
        let home = tempfile::tempdir().expect("create home");
        fs::write(home.path().join("bootstrap.yaml"), "hub_backend: [")
            .expect("write bad bootstrap");
        fs::write(
            home.path().join("gcore.yaml"),
            "databases:\n  postgres:\n    dsn: postgresql://gcore.example/gobby\n",
        )
        .expect("write gcore config");
        let _env = EnvGuard::set("GOBBY_HOME", home.path().as_os_str())
            .and_unset("GWIKI_DATABASE_URL")
            .and_unset("GOBBY_POSTGRES_DSN");

        let resolved = database_url()
            .expect("resolve database url")
            .expect("gcore database url");

        assert_eq!(resolved, "postgresql://gcore.example/gobby");
    }

    fn spawn_database_url_broker(
        database_url: &'static str,
        token: &'static str,
    ) -> (u16, thread::JoinHandle<String>) {
        let listener = TcpListener::bind("127.0.0.1:0").expect("bind broker");
        let port = listener.local_addr().expect("broker address").port();
        let handle = thread::spawn(move || {
            let (mut stream, _) = listener.accept().expect("accept broker request");
            let mut buffer = [0_u8; 4096];
            let bytes = stream.read(&mut buffer).expect("read broker request");
            let request = String::from_utf8_lossy(&buffer[..bytes]).into_owned();
            assert!(request.contains(&format!("X-Gobby-Local-Token: {token}")));
            let body = format!(r#"{{"database_url":"{database_url}"}}"#);
            write!(
                stream,
                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
                body.len(),
                body
            )
            .expect("write broker response");
            request
        });
        (port, handle)
    }
}