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