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_LIB_DOCTOR_JS: &str = include_str!("static/lib/doctor.js");
16const COCKPIT_COMPONENT_NAV_JS: &str = include_str!("static/components/cockpit-nav.js");
17const COCKPIT_COMPONENT_CONTEXT_JS: &str = include_str!("static/components/cockpit-context.js");
18const COCKPIT_COMPONENT_OVERVIEW_JS: &str = include_str!("static/components/cockpit-overview.js");
19const COCKPIT_COMPONENT_LIVE_JS: &str = include_str!("static/components/cockpit-live.js");
20const COCKPIT_COMPONENT_KNOWLEDGE_JS: &str = include_str!("static/components/cockpit-knowledge.js");
21const COCKPIT_COMPONENT_AGENTS_JS: &str = include_str!("static/components/cockpit-agents.js");
22const COCKPIT_COMPONENT_MEMORY_JS: &str = include_str!("static/components/cockpit-memory.js");
23const COCKPIT_COMPONENT_SEARCH_JS: &str = include_str!("static/components/cockpit-search.js");
24const COCKPIT_COMPONENT_COMPRESSION_JS: &str =
25 include_str!("static/components/cockpit-compression.js");
26const COCKPIT_COMPONENT_TOUR_JS: &str = include_str!("static/components/cockpit-tour.js");
27const COCKPIT_COMPONENT_GRAPH_JS: &str = include_str!("static/components/cockpit-graph.js");
28const COCKPIT_COMPONENT_ARCHITECTURE_JS: &str =
29 include_str!("static/components/cockpit-architecture.js");
30const COCKPIT_COMPONENT_EXPLORER_JS: &str = include_str!("static/components/cockpit-explorer.js");
31const COCKPIT_COMPONENT_HEALTH_JS: &str = include_str!("static/components/cockpit-health.js");
32const COCKPIT_COMPONENT_REMAINING_JS: &str = include_str!("static/components/cockpit-remaining.js");
33const COCKPIT_COMPONENT_COMMANDER_JS: &str = include_str!("static/components/cockpit-commander.js");
34const COCKPIT_COMPONENT_PALETTE_JS: &str = include_str!("static/components/cockpit-palette.js");
35const COCKPIT_COMPONENT_ROI_JS: &str = include_str!("static/components/cockpit-roi.js");
36const COCKPIT_COMPONENT_LEADERBOARD_JS: &str =
37 include_str!("static/components/cockpit-leaderboard.js");
38const COCKPIT_COMPONENT_AREA_TABS_JS: &str = include_str!("static/components/cockpit-area-tabs.js");
39const COCKPIT_COMPONENT_PROTECTION_JS: &str =
40 include_str!("static/components/cockpit-protection.js");
41const COCKPIT_COMPONENT_SETTINGS_JS: &str = include_str!("static/components/cockpit-settings.js");
42
43const COCKPIT_VENDOR_CHART_JS: &str = include_str!("static/vendor/chart.umd.min.js");
46const COCKPIT_VENDOR_D3_JS: &str = include_str!("static/vendor/d3.min.js");
47const COCKPIT_FONTS_CSS: &str = include_str!("static/fonts/fonts.css");
48const COCKPIT_FAVICON_SVG: &str = include_str!("static/favicon.svg");
49
50const FONT_INTER_WOFF2: &[u8] = include_bytes!("static/fonts/inter-variable.woff2");
54const FONT_JETBRAINS_WOFF2: &[u8] = include_bytes!("static/fonts/jetbrains-mono-variable.woff2");
55const FONT_SPACE_GROTESK_WOFF2: &[u8] = include_bytes!("static/fonts/space-grotesk-variable.woff2");
56
57fn match_font_asset(path: &str) -> Option<&'static [u8]> {
59 match path {
60 "/static/fonts/inter-variable.woff2" => Some(FONT_INTER_WOFF2),
61 "/static/fonts/jetbrains-mono-variable.woff2" => Some(FONT_JETBRAINS_WOFF2),
62 "/static/fonts/space-grotesk-variable.woff2" => Some(FONT_SPACE_GROTESK_WOFF2),
63 _ => None,
64 }
65}
66
67pub mod base_path;
68pub mod routes;
69pub(crate) mod vscode_open;
70
71pub async fn start(
72 port: Option<u16>,
73 host: Option<String>,
74 base_path: Option<String>,
75 auth_token: Option<String>,
76 open_mode: Option<String>,
77) {
78 let open = resolve_open_mode(open_mode.as_deref());
80 let port = port.unwrap_or_else(|| {
81 std::env::var("LEAN_CTX_PORT")
82 .ok()
83 .and_then(|p| p.parse().ok())
84 .unwrap_or(DEFAULT_PORT)
85 });
86
87 let host = host.unwrap_or_else(|| {
88 std::env::var("LEAN_CTX_HOST")
89 .ok()
90 .unwrap_or_else(|| DEFAULT_HOST.to_string())
91 });
92
93 let base_path = Arc::new(
96 base_path
97 .or_else(|| std::env::var("LEAN_CTX_DASHBOARD_BASE_PATH").ok())
98 .map(|b| base_path::normalize(&b))
99 .unwrap_or_default(),
100 );
101
102 let addr = format!("{host}:{port}");
103 let is_local = host == "127.0.0.1" || host == "localhost" || host == "::1";
104
105 let (requested_token, token_src) = resolve_requested_token(auth_token.as_deref());
109
110 if is_local && dashboard_responding(&host, port) {
113 println!("\n lean-ctx dashboard already running → http://{host}:{port}{base_path}");
114 if let Some(req) = requested_token.as_deref()
115 && load_saved_token().as_deref() != Some(req)
116 {
117 eprintln!(
118 " \x1b[33m⚠\x1b[0m The running instance uses a different token — your {token_src} \
119 will be rejected. Stop it (Ctrl+C) and restart to apply the new token."
120 );
121 }
122 println!(" Tip: use Ctrl+C in the existing terminal to stop it.\n");
123 if let Some(t) = load_saved_token() {
124 open_dashboard_url(
125 &format!("http://localhost:{port}{base_path}/?token={t}"),
126 open,
127 );
128 } else {
129 open_dashboard_url(&format!("http://localhost:{port}{base_path}/"), open);
130 }
131 return;
132 }
133
134 let t = requested_token.unwrap_or_else(generate_token);
137 let token = Some(Arc::new(t));
138
139 let listener = match TcpListener::bind(&addr).await {
146 Ok(l) => l,
147 Err(e) => {
148 eprintln!("Failed to bind to {addr}: {e}");
149 std::process::exit(1);
150 }
151 };
152
153 if let Some(t) = token.as_ref() {
154 save_token(t);
155 let masked = if t.len() > 12 {
156 format!(
157 "{}…{}",
158 &t[..t.floor_char_boundary(8)],
159 &t[t.ceil_char_boundary(t.len().saturating_sub(4))..]
160 )
161 } else {
162 t.to_string()
163 };
164 let src = if token_src.is_empty() {
165 String::new()
166 } else {
167 format!(" (from {token_src})")
168 };
169 if is_local {
170 println!(" Auth: enabled (local){src}");
171 println!(" Browser URL: http://localhost:{port}{base_path}/?token={t}");
172 } else {
173 eprintln!(
174 " \x1b[33m⚠\x1b[0m Binding to {host} — authentication enabled.\n \
175 Bearer token{src}: \x1b[1;32m{masked}\x1b[0m\n \
176 Browser URL: http://<your-ip>:{port}{base_path}/?token={t}"
177 );
178 }
179 }
180
181 let stats_path = crate::core::data_dir::lean_ctx_data_dir().map_or_else(
182 |_| "~/.lean-ctx/stats.json".to_string(),
183 |d| d.join("stats.json").display().to_string(),
184 );
185
186 if host == "0.0.0.0" {
187 println!("\n lean-ctx dashboard → http://0.0.0.0:{port} (all interfaces)");
188 println!(" Local access: http://localhost:{port}");
189 } else {
190 println!("\n lean-ctx dashboard → http://{host}:{port}");
191 }
192 println!(" Stats file: {stats_path}");
193 println!(" Press Ctrl+C to stop");
194 println!(
195 " \x1b[2m💡 Join the public leaderboard at https://leanctx.com/metrics: lean-ctx gain --publish --leaderboard\x1b[0m\n"
196 );
197
198 if is_local {
199 if let Some(t) = token.as_ref() {
200 open_dashboard_url(
201 &format!("http://localhost:{port}{base_path}/?token={t}"),
202 open,
203 );
204 } else {
205 open_dashboard_url(&format!("http://localhost:{port}{base_path}/"), open);
206 }
207 }
208 if crate::shell::is_container() && is_local {
209 println!(" Tip (Docker): bind 0.0.0.0 + publish port:");
210 println!(" lean-ctx dashboard --host=0.0.0.0 --port={port}");
211 println!(" docker run ... -p {port}:{port} ...");
212 println!();
213 }
214
215 if crate::core::datadog_push::spawn_if_enabled() {
216 println!(
217 " Datadog push: enabled (agentless, every LEAN_CTX_DATADOG_INTERVAL_SECS or 60s)"
218 );
219 }
220
221 loop {
222 if let Ok((stream, _)) = listener.accept().await {
223 let token_ref = token.clone();
224 let base_ref = base_path.clone();
225 tokio::spawn(handle_request(stream, token_ref, base_ref));
226 }
227 }
228}
229
230const HTTP_TOKEN_ENV: &str = "LEAN_CTX_HTTP_TOKEN";
232const SCRAPE_TOKEN_ENV: &str = "LEAN_CTX_SCRAPE_TOKEN";
235
236fn resolve_requested_token(flag: Option<&str>) -> (Option<String>, &'static str) {
249 if let Some(t) = flag.map(str::trim).filter(|s| !s.is_empty()) {
250 return (Some(t.to_string()), "--auth-token");
251 }
252 if let Ok(raw) = std::env::var(HTTP_TOKEN_ENV) {
253 let trimmed = raw.trim();
254 if !trimmed.is_empty() {
255 return (Some(trimmed.to_string()), HTTP_TOKEN_ENV);
256 }
257 }
258 (None, "")
259}
260
261fn generate_token() -> String {
262 let mut bytes = [0u8; 32];
263 if getrandom::fill(&mut bytes).is_err() {
264 tracing::warn!("CSPRNG unavailable — falling back to time-based token");
265 let ts = std::time::SystemTime::now()
266 .duration_since(std::time::UNIX_EPOCH)
267 .unwrap_or_default()
268 .as_nanos();
269 for (i, b) in bytes.iter_mut().enumerate() {
270 *b = ((ts >> (i % 16 * 8)) & 0xFF) as u8;
271 }
272 }
273 format!("lctx_{}", hex_lower(&bytes))
274}
275
276fn save_token(token: &str) {
277 if let Ok(dir) = crate::core::paths::state_dir() {
278 let _ = std::fs::create_dir_all(&dir);
279 let path = dir.join("dashboard.token");
280 #[cfg(unix)]
281 {
282 use std::io::Write;
283 use std::os::unix::fs::OpenOptionsExt;
284 let Ok(mut f) = std::fs::OpenOptions::new()
285 .write(true)
286 .create(true)
287 .truncate(true)
288 .mode(0o600)
289 .open(&path)
290 else {
291 return;
292 };
293 let _ = f.write_all(token.as_bytes());
294 }
295 #[cfg(not(unix))]
296 {
297 let _ = std::fs::write(&path, token);
298 }
299 }
300}
301
302fn load_saved_token() -> Option<String> {
303 let dir = crate::core::paths::state_dir().ok()?;
304 let path = dir.join("dashboard.token");
305 std::fs::read_to_string(path)
306 .ok()
307 .map(|s| s.trim().to_string())
308}
309
310pub fn add_nonce_to_inline_scripts(html: &str, nonce: &str) -> String {
313 let mut result = String::with_capacity(html.len() + 128);
314 let mut remaining = html;
315 while let Some(pos) = remaining.find("<script") {
316 result.push_str(&remaining[..pos]);
317 let tag_start = &remaining[pos..];
318 let tag_end = tag_start.find('>').unwrap_or(tag_start.len());
319 let tag = &tag_start[..=tag_end];
320 if tag.contains("src=") || tag.contains("nonce=") {
321 result.push_str(tag);
322 } else {
323 result.push_str(&tag.replacen("<script", &format!("<script nonce=\"{nonce}\""), 1));
324 }
325 remaining = &tag_start[tag_end + 1..];
326 }
327 result.push_str(remaining);
328 result
329}
330
331fn hex_lower(bytes: &[u8]) -> String {
332 const HEX: &[u8; 16] = b"0123456789abcdef";
333 let mut out = String::with_capacity(bytes.len() * 2);
334 for &b in bytes {
335 out.push(HEX[(b >> 4) as usize] as char);
336 out.push(HEX[(b & 0x0f) as usize] as char);
337 }
338 out
339}
340
341#[derive(Clone, Copy, PartialEq, Eq, Debug)]
343enum DashboardOpen {
344 Browser,
346 None,
349 Vscode,
353}
354
355fn resolve_open_mode(flag: Option<&str>) -> DashboardOpen {
358 let raw = flag
359 .map(str::to_string)
360 .or_else(|| std::env::var("LEAN_CTX_DASHBOARD_OPEN").ok())
361 .unwrap_or_default();
362 match raw.trim().to_ascii_lowercase().as_str() {
363 "none" | "off" | "false" | "no" => DashboardOpen::None,
364 "vscode" | "code" | "editor" => DashboardOpen::Vscode,
365 _ => DashboardOpen::Browser,
366 }
367}
368
369fn open_dashboard_url(url: &str, mode: DashboardOpen) {
371 match mode {
372 DashboardOpen::Browser => open_browser(url),
373 DashboardOpen::None => {}
374 DashboardOpen::Vscode => {
375 println!(
380 " \x1b[2mNative tab: run ⇧⌘P → \"lean-ctx: Open Web Dashboard\" (needs the lean-ctx VS Code extension)\x1b[0m"
381 );
382 println!(
383 " \x1b[2mNo extension? ⇧⌘P → \"Simple Browser: Show\" → paste the URL above\x1b[0m"
384 );
385 }
386 }
387}
388
389fn open_browser(url: &str) {
390 #[cfg(target_os = "macos")]
391 {
392 let _ = std::process::Command::new("open").arg(url).spawn();
393 }
394
395 #[cfg(target_os = "linux")]
396 {
397 let _ = std::process::Command::new("xdg-open")
398 .arg(url)
399 .stderr(std::process::Stdio::null())
400 .spawn();
401 }
402
403 #[cfg(target_os = "windows")]
404 {
405 let _ = std::process::Command::new("cmd")
406 .args(["/C", "start", url])
407 .spawn();
408 }
409}
410
411fn dashboard_responding(host: &str, port: u16) -> bool {
412 use std::io::{Read, Write};
413 use std::net::TcpStream;
414 use std::time::Duration;
415
416 let addr = format!("{host}:{port}");
417 let Ok(mut s) = TcpStream::connect_timeout(
418 &addr
419 .parse()
420 .unwrap_or_else(|_| std::net::SocketAddr::from(([127, 0, 0, 1], port))),
421 Duration::from_millis(150),
422 ) else {
423 return false;
424 };
425 let _ = s.set_read_timeout(Some(Duration::from_millis(150)));
426 let _ = s.set_write_timeout(Some(Duration::from_millis(150)));
427
428 let auth_header = load_saved_token()
429 .map(|t| format!("Authorization: Bearer {t}\r\n"))
430 .unwrap_or_default();
431
432 let req = format!(
433 "GET /api/version HTTP/1.1\r\nHost: localhost\r\n{auth_header}Connection: close\r\n\r\n"
434 );
435 if s.write_all(req.as_bytes()).is_err() {
436 return false;
437 }
438 let mut buf = [0u8; 256];
439 let Ok(n) = s.read(&mut buf) else {
440 return false;
441 };
442 let head = String::from_utf8_lossy(&buf[..n]);
443 head.starts_with("HTTP/1.1 200") || head.starts_with("HTTP/1.0 200")
444}
445
446const MAX_HTTP_MESSAGE: usize = 2 * 1024 * 1024;
447
448fn header_line_value<'a>(header_section: &'a str, name: &str) -> Option<&'a str> {
449 for line in header_section.lines() {
450 let Some((k, v)) = line.split_once(':') else {
451 continue;
452 };
453 if k.trim().eq_ignore_ascii_case(name) {
454 return Some(v.trim());
455 }
456 }
457 None
458}
459
460fn host_loopback_aliases(host: &str) -> Vec<String> {
462 let mut v = vec![host.to_string()];
463 if let Some(port) = host.strip_prefix("127.0.0.1:") {
464 v.push(format!("localhost:{port}"));
465 }
466 if let Some(port) = host.strip_prefix("localhost:") {
467 v.push(format!("127.0.0.1:{port}"));
468 }
469 if let Some(port) = host.strip_prefix("[::1]:") {
470 v.push(format!("127.0.0.1:{port}"));
471 v.push(format!("localhost:{port}"));
472 }
473 v
474}
475
476fn origin_matches_dashboard_host(origin: &str, host: &str) -> bool {
477 let origin = origin.trim_end_matches('/');
478 for h in host_loopback_aliases(host) {
479 if origin.eq_ignore_ascii_case(&format!("http://{h}"))
480 || origin.eq_ignore_ascii_case(&format!("https://{h}"))
481 {
482 return true;
483 }
484 }
485 false
486}
487
488fn csrf_origin_ok(header_section: &str, method: &str, path: &str) -> bool {
491 let uc = method.to_ascii_uppercase();
492 if !matches!(uc.as_str(), "POST" | "PUT" | "PATCH" | "DELETE") {
493 return true;
494 }
495 if !path.starts_with("/api/") {
496 return true;
497 }
498 let Some(origin) = header_line_value(header_section, "Origin") else {
499 return true;
500 };
501 if origin.is_empty() || origin.eq_ignore_ascii_case("null") {
502 return true;
503 }
504 let Some(host) = header_line_value(header_section, "Host") else {
505 return false;
506 };
507 origin_matches_dashboard_host(origin, host)
508}
509
510fn find_headers_end(buf: &[u8]) -> Option<usize> {
511 buf.windows(4).position(|w| w == b"\r\n\r\n")
512}
513
514fn parse_content_length_header(header_section: &[u8]) -> Option<usize> {
515 let text = String::from_utf8_lossy(header_section);
516 for line in text.lines() {
517 let Some((k, v)) = line.split_once(':') else {
518 continue;
519 };
520 if k.trim().eq_ignore_ascii_case("content-length") {
521 return v.trim().parse::<usize>().ok();
522 }
523 }
524 Some(0)
525}
526
527async fn read_http_message(stream: &mut tokio::net::TcpStream) -> Option<Vec<u8>> {
528 let mut buf = Vec::new();
529 let mut tmp = [0u8; 8192];
530 loop {
531 if let Some(end) = find_headers_end(&buf) {
532 let cl = parse_content_length_header(&buf[..end])?;
533 let total = end + 4 + cl;
534 if total > MAX_HTTP_MESSAGE {
535 return None;
536 }
537 if buf.len() >= total {
538 buf.truncate(total);
539 return Some(buf);
540 }
541 } else if buf.len() > 65_536 {
542 return None;
543 }
544
545 let n = stream.read(&mut tmp).await.ok()?;
546 if n == 0 {
547 return None;
548 }
549 buf.extend_from_slice(&tmp[..n]);
550 if buf.len() > MAX_HTTP_MESSAGE {
551 return None;
552 }
553 }
554}
555
556async fn handle_request(
557 mut stream: tokio::net::TcpStream,
558 token: Option<Arc<String>>,
559 base_path: Arc<String>,
560) {
561 let is_loopback = stream.peer_addr().is_ok_and(|a| a.ip().is_loopback());
562
563 let Some(buf) = read_http_message(&mut stream).await else {
564 return;
565 };
566 let Some(header_end) = find_headers_end(&buf) else {
567 return;
568 };
569 let header_text = String::from_utf8_lossy(&buf[..header_end]).to_string();
570 let body_start = header_end + 4;
571 let Some(content_len) = parse_content_length_header(&buf[..header_end]) else {
572 return;
573 };
574 if buf.len() < body_start + content_len {
575 return;
576 }
577 let body_str = std::str::from_utf8(&buf[body_start..body_start + content_len])
578 .unwrap_or("")
579 .to_string();
580
581 let first = header_text.lines().next().unwrap_or("");
582 let mut parts = first.split_whitespace();
583 let method = parts.next().unwrap_or("GET").to_string();
584 let raw_path = parts.next().unwrap_or("/").to_string();
585
586 let (path, query_token) = if let Some(idx) = raw_path.find('?') {
587 let p = &raw_path[..idx];
588 let qs = &raw_path[idx + 1..];
589 let tok = qs
590 .split('&')
591 .find_map(|pair| pair.strip_prefix("token="))
592 .map(std::string::ToString::to_string);
593 (p.to_string(), tok)
594 } else {
595 (raw_path.clone(), None)
596 };
597
598 let query_str = raw_path
599 .find('?')
600 .map_or(String::new(), |i| raw_path[i + 1..].to_string());
601
602 let path = base_path::strip(&path, base_path.as_str()).to_string();
606
607 if let Some(bytes) = match_font_asset(&path) {
610 let header = format!(
611 "HTTP/1.1 200 OK\r\n\
612 Content-Type: font/woff2\r\n\
613 Content-Length: {}\r\n\
614 Cache-Control: public, max-age=31536000, immutable\r\n\
615 X-Content-Type-Options: nosniff\r\n\
616 Connection: close\r\n\
617 \r\n",
618 bytes.len()
619 );
620 let _ = stream.write_all(header.as_bytes()).await;
621 let _ = stream.write_all(bytes).await;
622 return;
623 }
624
625 let is_api = path.starts_with("/api/");
626 let requires_auth = is_api || path == "/metrics";
627
628 if let Some(ref expected) = token {
629 let mut has_header_auth = check_auth(&header_text, expected);
630
631 if !has_header_auth
636 && path == "/metrics"
637 && let Ok(scrape) = std::env::var(SCRAPE_TOKEN_ENV)
638 {
639 let scrape = scrape.trim();
640 if !scrape.is_empty() && check_auth(&header_text, scrape) {
641 has_header_auth = true;
642 }
643 }
644
645 if requires_auth && !has_header_auth {
646 let body = r#"{"error":"unauthorized"}"#;
647 let response = format!(
648 "HTTP/1.1 401 Unauthorized\r\n\
649 Content-Type: application/json\r\n\
650 Content-Length: {}\r\n\
651 WWW-Authenticate: Bearer\r\n\
652 Connection: close\r\n\
653 \r\n\
654 {body}",
655 body.len()
656 );
657 let _ = stream.write_all(response.as_bytes()).await;
658 return;
659 }
660
661 if !csrf_origin_ok(&header_text, method.as_str(), path.as_str()) {
662 let body = r#"{"error":"forbidden"}"#;
663 let response = format!(
664 "HTTP/1.1 403 Forbidden\r\n\
665 Content-Type: application/json\r\n\
666 Content-Length: {}\r\n\
667 Connection: close\r\n\
668 \r\n\
669 {body}",
670 body.len()
671 );
672 let _ = stream.write_all(response.as_bytes()).await;
673 return;
674 }
675 }
676
677 let route_started = std::time::Instant::now();
685 let route_label = path.clone();
686 let compute = tokio::task::spawn_blocking(move || {
687 routes::route_response(
688 &path,
689 &query_str,
690 query_token.as_ref(),
691 token.as_ref(),
692 is_loopback,
693 &method,
694 &body_str,
695 )
696 })
697 .await;
698 let (status, content_type, mut body) = match compute {
699 Ok(v) => v,
700 Err(_) => (
703 "500 Internal Server Error",
704 "application/json",
705 r#"{"error":"dashboard route panicked"}"#.to_string(),
706 ),
707 };
708 let route_elapsed = route_started.elapsed();
711 if route_elapsed >= std::time::Duration::from_secs(1) {
712 tracing::warn!(
713 target: "lean_ctx::dashboard",
714 "slow dashboard route {route_label} took {} ms",
715 route_elapsed.as_millis()
716 );
717 }
718
719 if !base_path.is_empty()
722 && (content_type.contains("text/html")
723 || content_type.contains("text/css")
724 || content_type.contains("javascript"))
725 {
726 body = base_path::rewrite_asset_urls(&body, base_path.as_str());
727 }
728
729 let cache_header = if content_type.starts_with("application/json") {
730 "Cache-Control: no-cache, no-store, must-revalidate\r\nPragma: no-cache\r\n"
731 } else if content_type.starts_with("application/javascript")
732 || content_type.starts_with("text/css")
733 {
734 "Cache-Control: no-cache, must-revalidate\r\n"
735 } else {
736 ""
737 };
738
739 let nonce = {
740 let mut nb = [0u8; 16];
741 if getrandom::fill(&mut nb).is_err() {
742 nb.iter_mut().enumerate().for_each(|(i, b)| {
743 *b = (std::time::SystemTime::now()
744 .duration_since(std::time::UNIX_EPOCH)
745 .unwrap_or_default()
746 .subsec_nanos()
747 .wrapping_add(i as u32)) as u8;
748 });
749 }
750 hex_lower(&nb)
751 };
752 if content_type.contains("text/html") {
753 body = add_nonce_to_inline_scripts(&body, &nonce);
754 }
755 let security_headers = format!(
756 "X-Content-Type-Options: nosniff\r\n\
757 X-Frame-Options: DENY\r\n\
758 Referrer-Policy: no-referrer\r\n\
759 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"
760 );
761
762 let response = format!(
763 "HTTP/1.1 {status}\r\n\
764 Content-Type: {content_type}\r\n\
765 Content-Length: {}\r\n\
766 {cache_header}\
767 {security_headers}\
768 Connection: close\r\n\
769 \r\n\
770 {body}",
771 body.len()
772 );
773
774 let _ = stream.write_all(response.as_bytes()).await;
775}
776
777fn check_auth(request: &str, expected_token: &str) -> bool {
778 for line in request.lines() {
779 let lower = line.to_lowercase();
780 if lower.starts_with("authorization:") {
781 let value = line["authorization:".len()..].trim();
782 if let Some(token) = value
783 .strip_prefix("Bearer ")
784 .or_else(|| value.strip_prefix("bearer "))
785 {
786 return constant_time_eq(token.trim().as_bytes(), expected_token.as_bytes());
787 }
788 }
789 }
790 false
791}
792
793fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
794 if a.len() != b.len() {
795 return false;
796 }
797 bool::from(a.ct_eq(b))
798}
799
800#[cfg(test)]
801mod tests {
802 use super::routes::helpers::normalize_dashboard_demo_path;
803 use super::*;
804 use tempfile::tempdir;
805
806 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
807
808 #[test]
809 fn check_auth_with_valid_bearer() {
810 let req = "GET /api/stats HTTP/1.1\r\nAuthorization: Bearer lctx_abc123\r\n\r\n";
811 assert!(check_auth(req, "lctx_abc123"));
812 }
813
814 #[test]
815 fn check_auth_with_invalid_bearer() {
816 let req = "GET /api/stats HTTP/1.1\r\nAuthorization: Bearer wrong_token\r\n\r\n";
817 assert!(!check_auth(req, "lctx_abc123"));
818 }
819
820 #[test]
821 fn open_mode_flag_parses_all_variants() {
822 assert_eq!(resolve_open_mode(Some("none")), DashboardOpen::None);
824 assert_eq!(resolve_open_mode(Some("off")), DashboardOpen::None);
825 assert_eq!(resolve_open_mode(Some("no")), DashboardOpen::None);
826 assert_eq!(resolve_open_mode(Some("vscode")), DashboardOpen::Vscode);
827 assert_eq!(resolve_open_mode(Some("code")), DashboardOpen::Vscode);
828 assert_eq!(resolve_open_mode(Some("editor")), DashboardOpen::Vscode);
829 assert_eq!(resolve_open_mode(Some("VSCode")), DashboardOpen::Vscode);
830 assert_eq!(resolve_open_mode(Some("browser")), DashboardOpen::Browser);
831 assert_eq!(resolve_open_mode(Some("wat")), DashboardOpen::Browser);
833 }
834
835 #[test]
836 fn open_mode_env_is_used_when_no_flag() {
837 let _guard = ENV_LOCK.lock().unwrap();
838 crate::test_env::set_var("LEAN_CTX_DASHBOARD_OPEN", "none");
839 assert_eq!(resolve_open_mode(None), DashboardOpen::None);
840 crate::test_env::set_var("LEAN_CTX_DASHBOARD_OPEN", "vscode");
841 assert_eq!(resolve_open_mode(None), DashboardOpen::Vscode);
842 assert_eq!(resolve_open_mode(Some("browser")), DashboardOpen::Browser);
844 crate::test_env::remove_var("LEAN_CTX_DASHBOARD_OPEN");
845 assert_eq!(resolve_open_mode(None), DashboardOpen::Browser);
846 }
847
848 #[test]
849 fn check_auth_missing_header() {
850 let req = "GET /api/stats HTTP/1.1\r\nHost: localhost\r\n\r\n";
851 assert!(!check_auth(req, "lctx_abc123"));
852 }
853
854 #[test]
855 fn check_auth_lowercase_bearer() {
856 let req = "GET /api/stats HTTP/1.1\r\nauthorization: bearer lctx_abc123\r\n\r\n";
857 assert!(check_auth(req, "lctx_abc123"));
858 }
859
860 #[test]
861 fn query_token_parsing() {
862 let raw_path = "/index.html?token=lctx_abc123&other=val";
863 let idx = raw_path.find('?').unwrap();
864 let qs = &raw_path[idx + 1..];
865 let tok = qs.split('&').find_map(|pair| pair.strip_prefix("token="));
866 assert_eq!(tok, Some("lctx_abc123"));
867 }
868
869 #[test]
870 fn api_path_detection() {
871 assert!("/api/stats".starts_with("/api/"));
872 assert!("/api/version".starts_with("/api/"));
873 assert!(!"/".starts_with("/api/"));
874 assert!(!"/index.html".starts_with("/api/"));
875 assert!(!"/favicon.ico".starts_with("/api/"));
876 }
877
878 #[test]
879 fn normalize_dashboard_demo_path_strips_rooted_relative_windows_path() {
880 let normalized = normalize_dashboard_demo_path(r"\backend\list_tables.js");
881 assert_eq!(
882 normalized,
883 format!("backend{}list_tables.js", std::path::MAIN_SEPARATOR)
884 );
885 }
886
887 #[test]
888 fn normalize_dashboard_demo_path_preserves_absolute_windows_path() {
889 let input = r"C:\repo\backend\list_tables.js";
890 assert_eq!(normalize_dashboard_demo_path(input), input);
891 }
892
893 #[test]
894 fn normalize_dashboard_demo_path_preserves_unc_path() {
895 let input = r"\\server\share\backend\list_tables.js";
896 assert_eq!(normalize_dashboard_demo_path(input), input);
897 }
898
899 #[test]
900 fn normalize_dashboard_demo_path_strips_dot_slash_prefix() {
901 assert_eq!(
902 normalize_dashboard_demo_path("./src/main.rs"),
903 "src/main.rs"
904 );
905 assert_eq!(
906 normalize_dashboard_demo_path(r".\src\main.rs"),
907 format!("src{}main.rs", std::path::MAIN_SEPARATOR)
908 );
909 }
910
911 #[test]
912 fn api_profile_returns_json() {
913 let (_status, _ct, body) =
914 routes::route_response("/api/profile", "", None, None, false, "GET", "");
915 let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
916 assert!(v.get("active_name").is_some(), "missing active_name");
917 assert!(
918 v.pointer("/profile/profile/name")
919 .and_then(|n| n.as_str())
920 .is_some(),
921 "missing profile.profile.name"
922 );
923 assert!(v.get("available").and_then(|a| a.as_array()).is_some());
924 }
925
926 #[test]
927 fn api_billing_badge_returns_cosmetic_shape() {
928 let (status, ct, body) =
929 routes::route_response("/api/billing-badge", "", None, None, false, "GET", "");
930 assert_eq!(status, "200 OK");
931 assert_eq!(ct, "application/json");
932 let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
933 assert!(v.get("plan").and_then(|p| p.as_str()).is_some());
934 assert!(
935 v.get("supporter")
936 .and_then(serde_json::Value::as_bool)
937 .is_some()
938 );
939 assert!(
940 matches!(
941 v.get("source").and_then(|s| s.as_str()),
942 Some("live" | "cached" | "expired" | "none")
943 ),
944 "unexpected source: {body}"
945 );
946 }
947
948 #[test]
949 fn api_episodes_returns_json() {
950 let (_status, _ct, body) =
951 routes::route_response("/api/episodes", "", None, None, false, "GET", "");
952 let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
953 assert!(v.get("project_hash").is_some());
954 assert!(v.get("stats").is_some());
955 assert!(v.get("recent").and_then(|a| a.as_array()).is_some());
956 }
957
958 #[test]
959 fn api_procedures_returns_json() {
960 let (_status, _ct, body) =
961 routes::route_response("/api/procedures", "", None, None, false, "GET", "");
962 let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
963 assert!(v.get("project_hash").is_some());
964 assert!(v.get("procedures").and_then(|a| a.as_array()).is_some());
965 assert!(v.get("suggestions").and_then(|a| a.as_array()).is_some());
966 }
967
968 #[test]
969 fn api_compression_demo_heals_moved_file_paths() {
970 let _g = ENV_LOCK.lock().expect("env lock");
971 let td = tempdir().expect("tempdir");
972 let root = td.path();
973 std::fs::create_dir_all(root.join("src").join("moved")).expect("mkdir");
974 std::fs::write(
975 root.join("src").join("moved").join("foo.rs"),
976 "pub fn foo() { println!(\"hi\"); }\n",
977 )
978 .expect("write foo.rs");
979
980 let root_s = root.to_string_lossy().to_string();
981 crate::test_env::set_var("LEAN_CTX_DASHBOARD_PROJECT", &root_s);
982
983 let (_status, _ct, body) = routes::route_response(
984 "/api/compression-demo",
985 "path=src/foo.rs",
986 None,
987 None,
988 false,
989 "GET",
990 "",
991 );
992 let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
993 assert!(v.get("error").is_none(), "unexpected error: {body}");
994 assert_eq!(
995 v.get("resolved_from").and_then(|x| x.as_str()),
996 Some("src/moved/foo.rs")
997 );
998
999 crate::test_env::remove_var("LEAN_CTX_DASHBOARD_PROJECT");
1000 if let Some(dir) = crate::core::graph_index::ProjectIndex::index_dir(&root_s) {
1001 let _ = std::fs::remove_dir_all(dir);
1002 }
1003 }
1004
1005 #[test]
1006 fn resolve_token_uses_env_var_verbatim() {
1007 let _g = ENV_LOCK.lock().expect("env lock");
1008 crate::test_env::set_var(HTTP_TOKEN_ENV, "lctx_mystatic");
1009 let (token, src) = resolve_requested_token(None);
1010 crate::test_env::remove_var(HTTP_TOKEN_ENV);
1011 assert_eq!(
1012 src, HTTP_TOKEN_ENV,
1013 "token should be reported as env-sourced"
1014 );
1015 assert_eq!(token.as_deref(), Some("lctx_mystatic"));
1016 }
1017
1018 #[test]
1019 fn resolve_token_trims_env_var() {
1020 let _g = ENV_LOCK.lock().expect("env lock");
1021 crate::test_env::set_var(HTTP_TOKEN_ENV, " lctx_padded ");
1022 let (token, src) = resolve_requested_token(None);
1023 crate::test_env::remove_var(HTTP_TOKEN_ENV);
1024 assert_eq!(src, HTTP_TOKEN_ENV);
1025 assert_eq!(token.as_deref(), Some("lctx_padded"));
1026 }
1027
1028 #[test]
1029 fn resolve_token_falls_back_to_random_when_unset() {
1030 let _g = ENV_LOCK.lock().expect("env lock");
1031 crate::test_env::remove_var(HTTP_TOKEN_ENV);
1032 let (token, src) = resolve_requested_token(None);
1033 assert!(token.is_none(), "unset env requests no fixed token");
1034 assert!(src.is_empty());
1035 let generated = token.unwrap_or_else(generate_token);
1037 assert!(
1038 generated.starts_with("lctx_"),
1039 "generated token prefix, got {generated}"
1040 );
1041 assert!(
1042 generated.len() > 12,
1043 "generated token should be 32-byte hex"
1044 );
1045 }
1046
1047 #[test]
1048 fn resolve_token_ignores_empty_env() {
1049 let _g = ENV_LOCK.lock().expect("env lock");
1050 crate::test_env::set_var(HTTP_TOKEN_ENV, " ");
1051 let (token, src) = resolve_requested_token(None);
1052 crate::test_env::remove_var(HTTP_TOKEN_ENV);
1053 assert!(
1054 token.is_none(),
1055 "whitespace-only env requests no fixed token"
1056 );
1057 assert!(src.is_empty());
1058 }
1059
1060 #[test]
1061 fn resolve_token_flag_overrides_env() {
1062 let _g = ENV_LOCK.lock().expect("env lock");
1065 crate::test_env::set_var(HTTP_TOKEN_ENV, "lctx_fromenv");
1066 let (token, src) = resolve_requested_token(Some("lctx_fromflag"));
1067 crate::test_env::remove_var(HTTP_TOKEN_ENV);
1068 assert_eq!(src, "--auth-token");
1069 assert_eq!(token.as_deref(), Some("lctx_fromflag"));
1070 }
1071
1072 #[test]
1073 fn resolve_token_uses_flag_when_env_unset() {
1074 let _g = ENV_LOCK.lock().expect("env lock");
1075 crate::test_env::remove_var(HTTP_TOKEN_ENV);
1076 let (token, src) = resolve_requested_token(Some(" lctx_flag_padded "));
1077 assert_eq!(src, "--auth-token");
1078 assert_eq!(token.as_deref(), Some("lctx_flag_padded"));
1079 }
1080
1081 #[test]
1082 fn resolve_token_empty_flag_falls_back_to_env() {
1083 let _g = ENV_LOCK.lock().expect("env lock");
1084 crate::test_env::set_var(HTTP_TOKEN_ENV, "lctx_fromenv");
1085 let (token, src) = resolve_requested_token(Some(" "));
1086 crate::test_env::remove_var(HTTP_TOKEN_ENV);
1087 assert_eq!(src, HTTP_TOKEN_ENV);
1088 assert_eq!(token.as_deref(), Some("lctx_fromenv"));
1089 }
1090}