Skip to main content

lean_ctx/dashboard/
mod.rs

1use std::sync::Arc;
2use subtle::ConstantTimeEq;
3use tokio::io::{AsyncReadExt, AsyncWriteExt};
4use tokio::net::TcpListener;
5
6const DEFAULT_PORT: u16 = 3333;
7const DEFAULT_HOST: &str = "127.0.0.1";
8const COCKPIT_INDEX_HTML: &str = include_str!("static/index.html");
9const COCKPIT_STYLE_CSS: &str = include_str!("static/style.css");
10const COCKPIT_LIB_API_JS: &str = include_str!("static/lib/api.js");
11const COCKPIT_LIB_FORMAT_JS: &str = include_str!("static/lib/format.js");
12const COCKPIT_LIB_ROUTER_JS: &str = include_str!("static/lib/router.js");
13const COCKPIT_LIB_CHARTS_JS: &str = include_str!("static/lib/charts.js");
14const COCKPIT_LIB_SHARED_JS: &str = include_str!("static/lib/shared.js");
15const COCKPIT_COMPONENT_NAV_JS: &str = include_str!("static/components/cockpit-nav.js");
16const COCKPIT_COMPONENT_CONTEXT_JS: &str = include_str!("static/components/cockpit-context.js");
17const COCKPIT_COMPONENT_OVERVIEW_JS: &str = include_str!("static/components/cockpit-overview.js");
18const COCKPIT_COMPONENT_LIVE_JS: &str = include_str!("static/components/cockpit-live.js");
19const COCKPIT_COMPONENT_KNOWLEDGE_JS: &str = include_str!("static/components/cockpit-knowledge.js");
20const COCKPIT_COMPONENT_AGENTS_JS: &str = include_str!("static/components/cockpit-agents.js");
21const COCKPIT_COMPONENT_MEMORY_JS: &str = include_str!("static/components/cockpit-memory.js");
22const COCKPIT_COMPONENT_SEARCH_JS: &str = include_str!("static/components/cockpit-search.js");
23const COCKPIT_COMPONENT_COMPRESSION_JS: &str =
24    include_str!("static/components/cockpit-compression.js");
25const COCKPIT_COMPONENT_TOUR_JS: &str = include_str!("static/components/cockpit-tour.js");
26const COCKPIT_COMPONENT_GRAPH_JS: &str = include_str!("static/components/cockpit-graph.js");
27const COCKPIT_COMPONENT_ARCHITECTURE_JS: &str =
28    include_str!("static/components/cockpit-architecture.js");
29const COCKPIT_COMPONENT_EXPLORER_JS: &str = include_str!("static/components/cockpit-explorer.js");
30const COCKPIT_COMPONENT_HEALTH_JS: &str = include_str!("static/components/cockpit-health.js");
31const COCKPIT_COMPONENT_REMAINING_JS: &str = include_str!("static/components/cockpit-remaining.js");
32const COCKPIT_COMPONENT_COMMANDER_JS: &str = include_str!("static/components/cockpit-commander.js");
33const COCKPIT_COMPONENT_PALETTE_JS: &str = include_str!("static/components/cockpit-palette.js");
34const COCKPIT_COMPONENT_ROI_JS: &str = include_str!("static/components/cockpit-roi.js");
35const COCKPIT_COMPONENT_AREA_TABS_JS: &str = include_str!("static/components/cockpit-area-tabs.js");
36const COCKPIT_COMPONENT_PROTECTION_JS: &str =
37    include_str!("static/components/cockpit-protection.js");
38
39// Vendored third-party libraries — embedded so the dashboard works fully offline
40// (no external CDN). Served as text via the standard route pipeline.
41const COCKPIT_VENDOR_CHART_JS: &str = include_str!("static/vendor/chart.umd.min.js");
42const COCKPIT_VENDOR_D3_JS: &str = include_str!("static/vendor/d3.min.js");
43const COCKPIT_FONTS_CSS: &str = include_str!("static/fonts/fonts.css");
44const COCKPIT_FAVICON_SVG: &str = include_str!("static/favicon.svg");
45
46// Self-hosted variable fonts (binary woff2). Served via a dedicated binary
47// branch in `handle_request` so the bytes are never corrupted by the
48// String-based route pipeline.
49const FONT_INTER_WOFF2: &[u8] = include_bytes!("static/fonts/inter-variable.woff2");
50const FONT_JETBRAINS_WOFF2: &[u8] = include_bytes!("static/fonts/jetbrains-mono-variable.woff2");
51const FONT_SPACE_GROTESK_WOFF2: &[u8] = include_bytes!("static/fonts/space-grotesk-variable.woff2");
52
53/// Maps a request path to an embedded binary font asset.
54fn match_font_asset(path: &str) -> Option<&'static [u8]> {
55    match path {
56        "/static/fonts/inter-variable.woff2" => Some(FONT_INTER_WOFF2),
57        "/static/fonts/jetbrains-mono-variable.woff2" => Some(FONT_JETBRAINS_WOFF2),
58        "/static/fonts/space-grotesk-variable.woff2" => Some(FONT_SPACE_GROTESK_WOFF2),
59        _ => None,
60    }
61}
62
63pub mod base_path;
64pub mod routes;
65
66pub async fn start(
67    port: Option<u16>,
68    host: Option<String>,
69    base_path: Option<String>,
70    auth_token: Option<String>,
71) {
72    let port = port.unwrap_or_else(|| {
73        std::env::var("LEAN_CTX_PORT")
74            .ok()
75            .and_then(|p| p.parse().ok())
76            .unwrap_or(DEFAULT_PORT)
77    });
78
79    let host = host.unwrap_or_else(|| {
80        std::env::var("LEAN_CTX_HOST")
81            .ok()
82            .unwrap_or_else(|| DEFAULT_HOST.to_string())
83    });
84
85    // Reverse-proxy subpath (e.g. `/dashboard`). Normalized to "" or "/prefix".
86    // Shared across connections behind an Arc; "" means "no subpath" (#355).
87    let base_path = Arc::new(
88        base_path
89            .or_else(|| std::env::var("LEAN_CTX_DASHBOARD_BASE_PATH").ok())
90            .map(|b| base_path::normalize(&b))
91            .unwrap_or_default(),
92    );
93
94    let addr = format!("{host}:{port}");
95    let is_local = host == "127.0.0.1" || host == "localhost" || host == "::1";
96
97    // Resolve any *requested* fixed token (flag > LEAN_CTX_HTTP_TOKEN) up-front;
98    // `None` means "generate a random one". Done before the already-running check
99    // so we can warn when the requested token won't match a live instance (#377).
100    let (requested_token, token_src) = resolve_requested_token(auth_token.as_deref());
101
102    // Avoid accidental multiple dashboard instances (common source of "it hangs").
103    // Only safe to auto-detect for local dashboards without auth.
104    if is_local && dashboard_responding(&host, port) {
105        println!("\n  lean-ctx dashboard already running → http://{host}:{port}{base_path}");
106        if let Some(req) = requested_token.as_deref() {
107            if load_saved_token().as_deref() != Some(req) {
108                eprintln!(
109                    "  \x1b[33m⚠\x1b[0m The running instance uses a different token — your {token_src} \
110                     will be rejected. Stop it (Ctrl+C) and restart to apply the new token."
111                );
112            }
113        }
114        println!("  Tip: use Ctrl+C in the existing terminal to stop it.\n");
115        if let Some(t) = load_saved_token() {
116            open_browser(&format!("http://localhost:{port}{base_path}/?token={t}"));
117        } else {
118            open_browser(&format!("http://localhost:{port}{base_path}/"));
119        }
120        return;
121    }
122
123    // Always enable auth (even on loopback) to prevent cross-origin reads of /api/*
124    // from a malicious website (CORS is not a reliable boundary for localhost services).
125    let t = requested_token.unwrap_or_else(generate_token);
126    let token = Some(Arc::new(t));
127
128    // Bind BEFORE persisting the token: two racing `lean-ctx dashboard` starts
129    // both used to write their fresh token, the bind loser exited — leaving a
130    // token on disk that the surviving server never accepted. Every later
131    // "already running" browser open (and any tool reading dashboard.token)
132    // then got 401s. Binding first makes the loser exit without touching the
133    // file, so dashboard.token always belongs to the live listener.
134    let listener = match TcpListener::bind(&addr).await {
135        Ok(l) => l,
136        Err(e) => {
137            eprintln!("Failed to bind to {addr}: {e}");
138            std::process::exit(1);
139        }
140    };
141
142    if let Some(t) = token.as_ref() {
143        save_token(t);
144        let masked = if t.len() > 12 {
145            format!(
146                "{}…{}",
147                &t[..t.floor_char_boundary(8)],
148                &t[t.ceil_char_boundary(t.len().saturating_sub(4))..]
149            )
150        } else {
151            t.to_string()
152        };
153        let src = if token_src.is_empty() {
154            String::new()
155        } else {
156            format!(" (from {token_src})")
157        };
158        if is_local {
159            println!("  Auth: enabled (local){src}");
160            println!("  Browser URL:  http://localhost:{port}{base_path}/?token={t}");
161        } else {
162            eprintln!(
163                "  \x1b[33m⚠\x1b[0m Binding to {host} — authentication enabled.\n  \
164                 Bearer token{src}: \x1b[1;32m{masked}\x1b[0m\n  \
165                 Browser URL:  http://<your-ip>:{port}{base_path}/?token={t}"
166            );
167        }
168    }
169
170    let stats_path = crate::core::data_dir::lean_ctx_data_dir().map_or_else(
171        |_| "~/.lean-ctx/stats.json".to_string(),
172        |d| d.join("stats.json").display().to_string(),
173    );
174
175    if host == "0.0.0.0" {
176        println!("\n  lean-ctx dashboard → http://0.0.0.0:{port} (all interfaces)");
177        println!("  Local access:  http://localhost:{port}");
178    } else {
179        println!("\n  lean-ctx dashboard → http://{host}:{port}");
180    }
181    println!("  Stats file: {stats_path}");
182    println!("  Press Ctrl+C to stop");
183    println!(
184        "  \x1b[2m💡 Join the public leaderboard at https://leanctx.com/metrics: lean-ctx gain --publish --leaderboard\x1b[0m\n"
185    );
186
187    if is_local {
188        if let Some(t) = token.as_ref() {
189            open_browser(&format!("http://localhost:{port}{base_path}/?token={t}"));
190        } else {
191            open_browser(&format!("http://localhost:{port}{base_path}/"));
192        }
193    }
194    if crate::shell::is_container() && is_local {
195        println!("  Tip (Docker): bind 0.0.0.0 + publish port:");
196        println!("    lean-ctx dashboard --host=0.0.0.0 --port={port}");
197        println!("    docker run ... -p {port}:{port} ...");
198        println!();
199    }
200
201    if crate::core::datadog_push::spawn_if_enabled() {
202        println!(
203            "  Datadog push: enabled (agentless, every LEAN_CTX_DATADOG_INTERVAL_SECS or 60s)"
204        );
205    }
206
207    loop {
208        if let Ok((stream, _)) = listener.accept().await {
209            let token_ref = token.clone();
210            let base_ref = base_path.clone();
211            tokio::spawn(handle_request(stream, token_ref, base_ref));
212        }
213    }
214}
215
216/// Name of the env var that pins the dashboard Bearer token (#377).
217const HTTP_TOKEN_ENV: &str = "LEAN_CTX_HTTP_TOKEN";
218/// Read-only token accepted **only** for `GET /metrics` (GL #401) so
219/// monitoring agents never hold the full dashboard credential.
220const SCRAPE_TOKEN_ENV: &str = "LEAN_CTX_SCRAPE_TOKEN";
221
222/// Resolve the dashboard Bearer token.
223///
224/// Honors `LEAN_CTX_HTTP_TOKEN` (#377): when set to a non-empty value it is used
225/// verbatim so reverse-proxy / container deployments keep a stable token across
226/// restarts and redeploys (nginx can inject a fixed `Authorization: Bearer …`).
227/// When unset or empty, a fresh random token is generated (no behavior change).
228///
229/// Resolve a *requested* fixed token with precedence `--auth-token` flag >
230/// `LEAN_CTX_HTTP_TOKEN` (#377). The flag wins so it survives container/service
231/// environments that strip or fail to inherit the env var. Returns the trimmed,
232/// non-empty token and a human label of its source; `None` means "no fixed token
233/// requested → caller generates a random one".
234fn resolve_requested_token(flag: Option<&str>) -> (Option<String>, &'static str) {
235    if let Some(t) = flag.map(str::trim).filter(|s| !s.is_empty()) {
236        return (Some(t.to_string()), "--auth-token");
237    }
238    if let Ok(raw) = std::env::var(HTTP_TOKEN_ENV) {
239        let trimmed = raw.trim();
240        if !trimmed.is_empty() {
241            return (Some(trimmed.to_string()), HTTP_TOKEN_ENV);
242        }
243    }
244    (None, "")
245}
246
247fn generate_token() -> String {
248    let mut bytes = [0u8; 32];
249    if getrandom::fill(&mut bytes).is_err() {
250        tracing::warn!("CSPRNG unavailable — falling back to time-based token");
251        let ts = std::time::SystemTime::now()
252            .duration_since(std::time::UNIX_EPOCH)
253            .unwrap_or_default()
254            .as_nanos();
255        for (i, b) in bytes.iter_mut().enumerate() {
256            *b = ((ts >> (i % 16 * 8)) & 0xFF) as u8;
257        }
258    }
259    format!("lctx_{}", hex_lower(&bytes))
260}
261
262fn save_token(token: &str) {
263    if let Ok(dir) = crate::core::paths::state_dir() {
264        let _ = std::fs::create_dir_all(&dir);
265        let path = dir.join("dashboard.token");
266        #[cfg(unix)]
267        {
268            use std::io::Write;
269            use std::os::unix::fs::OpenOptionsExt;
270            let Ok(mut f) = std::fs::OpenOptions::new()
271                .write(true)
272                .create(true)
273                .truncate(true)
274                .mode(0o600)
275                .open(&path)
276            else {
277                return;
278            };
279            let _ = f.write_all(token.as_bytes());
280        }
281        #[cfg(not(unix))]
282        {
283            let _ = std::fs::write(&path, token);
284        }
285    }
286}
287
288fn load_saved_token() -> Option<String> {
289    let dir = crate::core::paths::state_dir().ok()?;
290    let path = dir.join("dashboard.token");
291    std::fs::read_to_string(path)
292        .ok()
293        .map(|s| s.trim().to_string())
294}
295
296/// Adds `nonce="..."` to all inline `<script>` tags (those without a `src=` attribute).
297/// External scripts (`<script src="...">`) are left untouched.
298pub fn add_nonce_to_inline_scripts(html: &str, nonce: &str) -> String {
299    let mut result = String::with_capacity(html.len() + 128);
300    let mut remaining = html;
301    while let Some(pos) = remaining.find("<script") {
302        result.push_str(&remaining[..pos]);
303        let tag_start = &remaining[pos..];
304        let tag_end = tag_start.find('>').unwrap_or(tag_start.len());
305        let tag = &tag_start[..=tag_end];
306        if tag.contains("src=") || tag.contains("nonce=") {
307            result.push_str(tag);
308        } else {
309            result.push_str(&tag.replacen("<script", &format!("<script nonce=\"{nonce}\""), 1));
310        }
311        remaining = &tag_start[tag_end + 1..];
312    }
313    result.push_str(remaining);
314    result
315}
316
317fn hex_lower(bytes: &[u8]) -> String {
318    const HEX: &[u8; 16] = b"0123456789abcdef";
319    let mut out = String::with_capacity(bytes.len() * 2);
320    for &b in bytes {
321        out.push(HEX[(b >> 4) as usize] as char);
322        out.push(HEX[(b & 0x0f) as usize] as char);
323    }
324    out
325}
326
327fn open_browser(url: &str) {
328    #[cfg(target_os = "macos")]
329    {
330        let _ = std::process::Command::new("open").arg(url).spawn();
331    }
332
333    #[cfg(target_os = "linux")]
334    {
335        let _ = std::process::Command::new("xdg-open")
336            .arg(url)
337            .stderr(std::process::Stdio::null())
338            .spawn();
339    }
340
341    #[cfg(target_os = "windows")]
342    {
343        let _ = std::process::Command::new("cmd")
344            .args(["/C", "start", url])
345            .spawn();
346    }
347}
348
349fn dashboard_responding(host: &str, port: u16) -> bool {
350    use std::io::{Read, Write};
351    use std::net::TcpStream;
352    use std::time::Duration;
353
354    let addr = format!("{host}:{port}");
355    let Ok(mut s) = TcpStream::connect_timeout(
356        &addr
357            .parse()
358            .unwrap_or_else(|_| std::net::SocketAddr::from(([127, 0, 0, 1], port))),
359        Duration::from_millis(150),
360    ) else {
361        return false;
362    };
363    let _ = s.set_read_timeout(Some(Duration::from_millis(150)));
364    let _ = s.set_write_timeout(Some(Duration::from_millis(150)));
365
366    let auth_header = load_saved_token()
367        .map(|t| format!("Authorization: Bearer {t}\r\n"))
368        .unwrap_or_default();
369
370    let req = format!(
371        "GET /api/version HTTP/1.1\r\nHost: localhost\r\n{auth_header}Connection: close\r\n\r\n"
372    );
373    if s.write_all(req.as_bytes()).is_err() {
374        return false;
375    }
376    let mut buf = [0u8; 256];
377    let Ok(n) = s.read(&mut buf) else {
378        return false;
379    };
380    let head = String::from_utf8_lossy(&buf[..n]);
381    head.starts_with("HTTP/1.1 200") || head.starts_with("HTTP/1.0 200")
382}
383
384const MAX_HTTP_MESSAGE: usize = 2 * 1024 * 1024;
385
386fn header_line_value<'a>(header_section: &'a str, name: &str) -> Option<&'a str> {
387    for line in header_section.lines() {
388        let Some((k, v)) = line.split_once(':') else {
389            continue;
390        };
391        if k.trim().eq_ignore_ascii_case(name) {
392            return Some(v.trim());
393        }
394    }
395    None
396}
397
398/// Loopback dashboards often use `localhost` vs `127.0.0.1` interchangeably in `Origin`.
399fn host_loopback_aliases(host: &str) -> Vec<String> {
400    let mut v = vec![host.to_string()];
401    if let Some(port) = host.strip_prefix("127.0.0.1:") {
402        v.push(format!("localhost:{port}"));
403    }
404    if let Some(port) = host.strip_prefix("localhost:") {
405        v.push(format!("127.0.0.1:{port}"));
406    }
407    if let Some(port) = host.strip_prefix("[::1]:") {
408        v.push(format!("127.0.0.1:{port}"));
409        v.push(format!("localhost:{port}"));
410    }
411    v
412}
413
414fn origin_matches_dashboard_host(origin: &str, host: &str) -> bool {
415    let origin = origin.trim_end_matches('/');
416    for h in host_loopback_aliases(host) {
417        if origin.eq_ignore_ascii_case(&format!("http://{h}"))
418            || origin.eq_ignore_ascii_case(&format!("https://{h}"))
419        {
420            return true;
421        }
422    }
423    false
424}
425
426/// Defense-in-depth for browser POSTs: reject cross-site `Origin` on mutating `/api/*` calls.
427/// Non-browser clients (no `Origin`) remain allowed when Bearer auth succeeds.
428fn csrf_origin_ok(header_section: &str, method: &str, path: &str) -> bool {
429    let uc = method.to_ascii_uppercase();
430    if !matches!(uc.as_str(), "POST" | "PUT" | "PATCH" | "DELETE") {
431        return true;
432    }
433    if !path.starts_with("/api/") {
434        return true;
435    }
436    let Some(origin) = header_line_value(header_section, "Origin") else {
437        return true;
438    };
439    if origin.is_empty() || origin.eq_ignore_ascii_case("null") {
440        return true;
441    }
442    let Some(host) = header_line_value(header_section, "Host") else {
443        return false;
444    };
445    origin_matches_dashboard_host(origin, host)
446}
447
448fn find_headers_end(buf: &[u8]) -> Option<usize> {
449    buf.windows(4).position(|w| w == b"\r\n\r\n")
450}
451
452fn parse_content_length_header(header_section: &[u8]) -> Option<usize> {
453    let text = String::from_utf8_lossy(header_section);
454    for line in text.lines() {
455        let Some((k, v)) = line.split_once(':') else {
456            continue;
457        };
458        if k.trim().eq_ignore_ascii_case("content-length") {
459            return v.trim().parse::<usize>().ok();
460        }
461    }
462    Some(0)
463}
464
465async fn read_http_message(stream: &mut tokio::net::TcpStream) -> Option<Vec<u8>> {
466    let mut buf = Vec::new();
467    let mut tmp = [0u8; 8192];
468    loop {
469        if let Some(end) = find_headers_end(&buf) {
470            let cl = parse_content_length_header(&buf[..end])?;
471            let total = end + 4 + cl;
472            if total > MAX_HTTP_MESSAGE {
473                return None;
474            }
475            if buf.len() >= total {
476                buf.truncate(total);
477                return Some(buf);
478            }
479        } else if buf.len() > 65_536 {
480            return None;
481        }
482
483        let n = stream.read(&mut tmp).await.ok()?;
484        if n == 0 {
485            return None;
486        }
487        buf.extend_from_slice(&tmp[..n]);
488        if buf.len() > MAX_HTTP_MESSAGE {
489            return None;
490        }
491    }
492}
493
494async fn handle_request(
495    mut stream: tokio::net::TcpStream,
496    token: Option<Arc<String>>,
497    base_path: Arc<String>,
498) {
499    let is_loopback = stream.peer_addr().is_ok_and(|a| a.ip().is_loopback());
500
501    let Some(buf) = read_http_message(&mut stream).await else {
502        return;
503    };
504    let Some(header_end) = find_headers_end(&buf) else {
505        return;
506    };
507    let header_text = String::from_utf8_lossy(&buf[..header_end]).to_string();
508    let body_start = header_end + 4;
509    let Some(content_len) = parse_content_length_header(&buf[..header_end]) else {
510        return;
511    };
512    if buf.len() < body_start + content_len {
513        return;
514    }
515    let body_str = std::str::from_utf8(&buf[body_start..body_start + content_len])
516        .unwrap_or("")
517        .to_string();
518
519    let first = header_text.lines().next().unwrap_or("");
520    let mut parts = first.split_whitespace();
521    let method = parts.next().unwrap_or("GET").to_string();
522    let raw_path = parts.next().unwrap_or("/").to_string();
523
524    let (path, query_token) = if let Some(idx) = raw_path.find('?') {
525        let p = &raw_path[..idx];
526        let qs = &raw_path[idx + 1..];
527        let tok = qs
528            .split('&')
529            .find_map(|pair| pair.strip_prefix("token="))
530            .map(std::string::ToString::to_string);
531        (p.to_string(), tok)
532    } else {
533        (raw_path.clone(), None)
534    };
535
536    let query_str = raw_path
537        .find('?')
538        .map_or(String::new(), |i| raw_path[i + 1..].to_string());
539
540    // Strip the reverse-proxy subpath prefix (if any) so all downstream matching
541    // (fonts, auth, routing) works on root-relative paths whether or not the
542    // proxy already stripped it (#355).
543    let path = base_path::strip(&path, base_path.as_str()).to_string();
544
545    // Binary font assets are public (like CSS/JS) and bypass the String-based
546    // route pipeline so their bytes stay intact.
547    if let Some(bytes) = match_font_asset(&path) {
548        let header = format!(
549            "HTTP/1.1 200 OK\r\n\
550             Content-Type: font/woff2\r\n\
551             Content-Length: {}\r\n\
552             Cache-Control: public, max-age=31536000, immutable\r\n\
553             X-Content-Type-Options: nosniff\r\n\
554             Connection: close\r\n\
555             \r\n",
556            bytes.len()
557        );
558        let _ = stream.write_all(header.as_bytes()).await;
559        let _ = stream.write_all(bytes).await;
560        return;
561    }
562
563    let is_api = path.starts_with("/api/");
564    let requires_auth = is_api || path == "/metrics";
565
566    if let Some(ref expected) = token {
567        let mut has_header_auth = check_auth(&header_text, expected);
568
569        // Read-only scrape token (GL #401): lets a Prometheus/Datadog agent
570        // scrape `/metrics` without holding the full dashboard token. Valid
571        // for the metrics endpoint only — every other API stays gated on the
572        // dashboard token.
573        if !has_header_auth && path == "/metrics" {
574            if let Ok(scrape) = std::env::var(SCRAPE_TOKEN_ENV) {
575                let scrape = scrape.trim();
576                if !scrape.is_empty() && check_auth(&header_text, scrape) {
577                    has_header_auth = true;
578                }
579            }
580        }
581
582        if requires_auth && !has_header_auth {
583            let body = r#"{"error":"unauthorized"}"#;
584            let response = format!(
585                "HTTP/1.1 401 Unauthorized\r\n\
586                 Content-Type: application/json\r\n\
587                 Content-Length: {}\r\n\
588                 WWW-Authenticate: Bearer\r\n\
589                 Connection: close\r\n\
590                 \r\n\
591                 {body}",
592                body.len()
593            );
594            let _ = stream.write_all(response.as_bytes()).await;
595            return;
596        }
597
598        if !csrf_origin_ok(&header_text, method.as_str(), path.as_str()) {
599            let body = r#"{"error":"forbidden"}"#;
600            let response = format!(
601                "HTTP/1.1 403 Forbidden\r\n\
602                 Content-Type: application/json\r\n\
603                 Content-Length: {}\r\n\
604                 Connection: close\r\n\
605                 \r\n\
606                 {body}",
607                body.len()
608            );
609            let _ = stream.write_all(response.as_bytes()).await;
610            return;
611        }
612    }
613
614    let path = path.as_str();
615    let query_str = query_str.as_str();
616    let method = method.as_str();
617
618    let compute = std::panic::catch_unwind(|| {
619        routes::route_response(
620            path,
621            query_str,
622            query_token.as_ref(),
623            token.as_ref(),
624            is_loopback,
625            method,
626            &body_str,
627        )
628    });
629    let (status, content_type, mut body) = match compute {
630        Ok(v) => v,
631        Err(_) => (
632            "500 Internal Server Error",
633            "application/json",
634            r#"{"error":"dashboard route panicked"}"#.to_string(),
635        ),
636    };
637
638    // Under a reverse-proxy subpath, rewrite root-absolute asset/API URLs in the
639    // served HTML/CSS/JS so the browser requests them under the prefix (#355).
640    if !base_path.is_empty()
641        && (content_type.contains("text/html")
642            || content_type.contains("text/css")
643            || content_type.contains("javascript"))
644    {
645        body = base_path::rewrite_asset_urls(&body, base_path.as_str());
646    }
647
648    let cache_header = if content_type.starts_with("application/json") {
649        "Cache-Control: no-cache, no-store, must-revalidate\r\nPragma: no-cache\r\n"
650    } else if content_type.starts_with("application/javascript")
651        || content_type.starts_with("text/css")
652    {
653        "Cache-Control: no-cache, must-revalidate\r\n"
654    } else {
655        ""
656    };
657
658    let nonce = {
659        let mut nb = [0u8; 16];
660        if getrandom::fill(&mut nb).is_err() {
661            nb.iter_mut().enumerate().for_each(|(i, b)| {
662                *b = (std::time::SystemTime::now()
663                    .duration_since(std::time::UNIX_EPOCH)
664                    .unwrap_or_default()
665                    .subsec_nanos()
666                    .wrapping_add(i as u32)) as u8;
667            });
668        }
669        hex_lower(&nb)
670    };
671    if content_type.contains("text/html") {
672        body = add_nonce_to_inline_scripts(&body, &nonce);
673    }
674    let security_headers = format!(
675        "X-Content-Type-Options: nosniff\r\n\
676         X-Frame-Options: DENY\r\n\
677         Referrer-Policy: no-referrer\r\n\
678         Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{nonce}'; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' data:; connect-src 'self'\r\n"
679    );
680
681    let response = format!(
682        "HTTP/1.1 {status}\r\n\
683         Content-Type: {content_type}\r\n\
684         Content-Length: {}\r\n\
685         {cache_header}\
686         {security_headers}\
687         Connection: close\r\n\
688         \r\n\
689         {body}",
690        body.len()
691    );
692
693    let _ = stream.write_all(response.as_bytes()).await;
694}
695
696fn check_auth(request: &str, expected_token: &str) -> bool {
697    for line in request.lines() {
698        let lower = line.to_lowercase();
699        if lower.starts_with("authorization:") {
700            let value = line["authorization:".len()..].trim();
701            if let Some(token) = value
702                .strip_prefix("Bearer ")
703                .or_else(|| value.strip_prefix("bearer "))
704            {
705                return constant_time_eq(token.trim().as_bytes(), expected_token.as_bytes());
706            }
707        }
708    }
709    false
710}
711
712fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
713    if a.len() != b.len() {
714        return false;
715    }
716    bool::from(a.ct_eq(b))
717}
718
719#[cfg(test)]
720mod tests {
721    use super::routes::helpers::normalize_dashboard_demo_path;
722    use super::*;
723    use tempfile::tempdir;
724
725    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
726
727    #[test]
728    fn check_auth_with_valid_bearer() {
729        let req = "GET /api/stats HTTP/1.1\r\nAuthorization: Bearer lctx_abc123\r\n\r\n";
730        assert!(check_auth(req, "lctx_abc123"));
731    }
732
733    #[test]
734    fn check_auth_with_invalid_bearer() {
735        let req = "GET /api/stats HTTP/1.1\r\nAuthorization: Bearer wrong_token\r\n\r\n";
736        assert!(!check_auth(req, "lctx_abc123"));
737    }
738
739    #[test]
740    fn check_auth_missing_header() {
741        let req = "GET /api/stats HTTP/1.1\r\nHost: localhost\r\n\r\n";
742        assert!(!check_auth(req, "lctx_abc123"));
743    }
744
745    #[test]
746    fn check_auth_lowercase_bearer() {
747        let req = "GET /api/stats HTTP/1.1\r\nauthorization: bearer lctx_abc123\r\n\r\n";
748        assert!(check_auth(req, "lctx_abc123"));
749    }
750
751    #[test]
752    fn query_token_parsing() {
753        let raw_path = "/index.html?token=lctx_abc123&other=val";
754        let idx = raw_path.find('?').unwrap();
755        let qs = &raw_path[idx + 1..];
756        let tok = qs.split('&').find_map(|pair| pair.strip_prefix("token="));
757        assert_eq!(tok, Some("lctx_abc123"));
758    }
759
760    #[test]
761    fn api_path_detection() {
762        assert!("/api/stats".starts_with("/api/"));
763        assert!("/api/version".starts_with("/api/"));
764        assert!(!"/".starts_with("/api/"));
765        assert!(!"/index.html".starts_with("/api/"));
766        assert!(!"/favicon.ico".starts_with("/api/"));
767    }
768
769    #[test]
770    fn normalize_dashboard_demo_path_strips_rooted_relative_windows_path() {
771        let normalized = normalize_dashboard_demo_path(r"\backend\list_tables.js");
772        assert_eq!(
773            normalized,
774            format!("backend{}list_tables.js", std::path::MAIN_SEPARATOR)
775        );
776    }
777
778    #[test]
779    fn normalize_dashboard_demo_path_preserves_absolute_windows_path() {
780        let input = r"C:\repo\backend\list_tables.js";
781        assert_eq!(normalize_dashboard_demo_path(input), input);
782    }
783
784    #[test]
785    fn normalize_dashboard_demo_path_preserves_unc_path() {
786        let input = r"\\server\share\backend\list_tables.js";
787        assert_eq!(normalize_dashboard_demo_path(input), input);
788    }
789
790    #[test]
791    fn normalize_dashboard_demo_path_strips_dot_slash_prefix() {
792        assert_eq!(
793            normalize_dashboard_demo_path("./src/main.rs"),
794            "src/main.rs"
795        );
796        assert_eq!(
797            normalize_dashboard_demo_path(r".\src\main.rs"),
798            format!("src{}main.rs", std::path::MAIN_SEPARATOR)
799        );
800    }
801
802    #[test]
803    fn api_profile_returns_json() {
804        let (_status, _ct, body) =
805            routes::route_response("/api/profile", "", None, None, false, "GET", "");
806        let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
807        assert!(v.get("active_name").is_some(), "missing active_name");
808        assert!(
809            v.pointer("/profile/profile/name")
810                .and_then(|n| n.as_str())
811                .is_some(),
812            "missing profile.profile.name"
813        );
814        assert!(v.get("available").and_then(|a| a.as_array()).is_some());
815    }
816
817    #[test]
818    fn api_billing_badge_returns_cosmetic_shape() {
819        let (status, ct, body) =
820            routes::route_response("/api/billing-badge", "", None, None, false, "GET", "");
821        assert_eq!(status, "200 OK");
822        assert_eq!(ct, "application/json");
823        let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
824        assert!(v.get("plan").and_then(|p| p.as_str()).is_some());
825        assert!(v
826            .get("supporter")
827            .and_then(serde_json::Value::as_bool)
828            .is_some());
829        assert!(
830            matches!(
831                v.get("source").and_then(|s| s.as_str()),
832                Some("live" | "cached" | "expired" | "none")
833            ),
834            "unexpected source: {body}"
835        );
836    }
837
838    #[test]
839    fn api_episodes_returns_json() {
840        let (_status, _ct, body) =
841            routes::route_response("/api/episodes", "", None, None, false, "GET", "");
842        let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
843        assert!(v.get("project_hash").is_some());
844        assert!(v.get("stats").is_some());
845        assert!(v.get("recent").and_then(|a| a.as_array()).is_some());
846    }
847
848    #[test]
849    fn api_procedures_returns_json() {
850        let (_status, _ct, body) =
851            routes::route_response("/api/procedures", "", None, None, false, "GET", "");
852        let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
853        assert!(v.get("project_hash").is_some());
854        assert!(v.get("procedures").and_then(|a| a.as_array()).is_some());
855        assert!(v.get("suggestions").and_then(|a| a.as_array()).is_some());
856    }
857
858    #[test]
859    fn api_compression_demo_heals_moved_file_paths() {
860        let _g = ENV_LOCK.lock().expect("env lock");
861        let td = tempdir().expect("tempdir");
862        let root = td.path();
863        std::fs::create_dir_all(root.join("src").join("moved")).expect("mkdir");
864        std::fs::write(
865            root.join("src").join("moved").join("foo.rs"),
866            "pub fn foo() { println!(\"hi\"); }\n",
867        )
868        .expect("write foo.rs");
869
870        let root_s = root.to_string_lossy().to_string();
871        std::env::set_var("LEAN_CTX_DASHBOARD_PROJECT", &root_s);
872
873        let (_status, _ct, body) = routes::route_response(
874            "/api/compression-demo",
875            "path=src/foo.rs",
876            None,
877            None,
878            false,
879            "GET",
880            "",
881        );
882        let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
883        assert!(v.get("error").is_none(), "unexpected error: {body}");
884        assert_eq!(
885            v.get("resolved_from").and_then(|x| x.as_str()),
886            Some("src/moved/foo.rs")
887        );
888
889        std::env::remove_var("LEAN_CTX_DASHBOARD_PROJECT");
890        if let Some(dir) = crate::core::graph_index::ProjectIndex::index_dir(&root_s) {
891            let _ = std::fs::remove_dir_all(dir);
892        }
893    }
894
895    #[test]
896    fn resolve_token_uses_env_var_verbatim() {
897        let _g = ENV_LOCK.lock().expect("env lock");
898        std::env::set_var(HTTP_TOKEN_ENV, "lctx_mystatic");
899        let (token, src) = resolve_requested_token(None);
900        std::env::remove_var(HTTP_TOKEN_ENV);
901        assert_eq!(
902            src, HTTP_TOKEN_ENV,
903            "token should be reported as env-sourced"
904        );
905        assert_eq!(token.as_deref(), Some("lctx_mystatic"));
906    }
907
908    #[test]
909    fn resolve_token_trims_env_var() {
910        let _g = ENV_LOCK.lock().expect("env lock");
911        std::env::set_var(HTTP_TOKEN_ENV, "  lctx_padded  ");
912        let (token, src) = resolve_requested_token(None);
913        std::env::remove_var(HTTP_TOKEN_ENV);
914        assert_eq!(src, HTTP_TOKEN_ENV);
915        assert_eq!(token.as_deref(), Some("lctx_padded"));
916    }
917
918    #[test]
919    fn resolve_token_falls_back_to_random_when_unset() {
920        let _g = ENV_LOCK.lock().expect("env lock");
921        std::env::remove_var(HTTP_TOKEN_ENV);
922        let (token, src) = resolve_requested_token(None);
923        assert!(token.is_none(), "unset env requests no fixed token");
924        assert!(src.is_empty());
925        // The production fallback in `start()` generates a random token.
926        let generated = token.unwrap_or_else(generate_token);
927        assert!(
928            generated.starts_with("lctx_"),
929            "generated token prefix, got {generated}"
930        );
931        assert!(
932            generated.len() > 12,
933            "generated token should be 32-byte hex"
934        );
935    }
936
937    #[test]
938    fn resolve_token_ignores_empty_env() {
939        let _g = ENV_LOCK.lock().expect("env lock");
940        std::env::set_var(HTTP_TOKEN_ENV, "   ");
941        let (token, src) = resolve_requested_token(None);
942        std::env::remove_var(HTTP_TOKEN_ENV);
943        assert!(
944            token.is_none(),
945            "whitespace-only env requests no fixed token"
946        );
947        assert!(src.is_empty());
948    }
949
950    #[test]
951    fn resolve_token_flag_overrides_env() {
952        // #377: --auth-token must win over LEAN_CTX_HTTP_TOKEN so it survives
953        // environments that strip/fail to inherit the env var.
954        let _g = ENV_LOCK.lock().expect("env lock");
955        std::env::set_var(HTTP_TOKEN_ENV, "lctx_fromenv");
956        let (token, src) = resolve_requested_token(Some("lctx_fromflag"));
957        std::env::remove_var(HTTP_TOKEN_ENV);
958        assert_eq!(src, "--auth-token");
959        assert_eq!(token.as_deref(), Some("lctx_fromflag"));
960    }
961
962    #[test]
963    fn resolve_token_uses_flag_when_env_unset() {
964        let _g = ENV_LOCK.lock().expect("env lock");
965        std::env::remove_var(HTTP_TOKEN_ENV);
966        let (token, src) = resolve_requested_token(Some("  lctx_flag_padded  "));
967        assert_eq!(src, "--auth-token");
968        assert_eq!(token.as_deref(), Some("lctx_flag_padded"));
969    }
970
971    #[test]
972    fn resolve_token_empty_flag_falls_back_to_env() {
973        let _g = ENV_LOCK.lock().expect("env lock");
974        std::env::set_var(HTTP_TOKEN_ENV, "lctx_fromenv");
975        let (token, src) = resolve_requested_token(Some("   "));
976        std::env::remove_var(HTTP_TOKEN_ENV);
977        assert_eq!(src, HTTP_TOKEN_ENV);
978        assert_eq!(token.as_deref(), Some("lctx_fromenv"));
979    }
980}