1use std::path::{Path, PathBuf};
2
3use actix_files as fs;
4use actix_web::{
5 dev::{fn_service, ServiceRequest, ServiceResponse},
6 web, App, HttpResponse, HttpServer,
7};
8use tracing::{error, info};
9
10use super::listeners::{build_bind_listeners, build_desktop_listeners, resolve_worker_count};
11use super::tls::build_rustls_config;
12use crate::app_state::AppState;
13use crate::config::{
14 build_cors, build_rate_limiter, build_security_headers, is_loopback_bind,
15 require_limiter_for_nonloopback, wrap_governor_and_cors,
16};
17use crate::routes::{configure_routes, configure_routes_with_rate_limiting};
18use crate::services::frontend_package::{
19 ensure_current_frontend_dir_in, has_embedded_frontend_package, resolve_frontend_package_path,
20};
21use bamboo_config::TlsConfig;
22
23fn is_api_path(path: &str) -> bool {
32 path.starts_with("/api/")
33 || path.starts_with("/v1/")
34 || path.starts_with("/v2/")
35 || path.starts_with("/openai/")
36 || path.starts_with("/anthropic/")
37 || path.starts_with("/gemini/")
38}
39
40fn canonicalize_static_dir(path: &Path) -> Result<PathBuf, String> {
41 let canonicalized = path
42 .canonicalize()
43 .map_err(|e| format!("Static directory not found: {:?}: {}", path, e))?;
44 if !canonicalized.is_dir() {
45 return Err(format!(
46 "Static path is not a directory: {}",
47 canonicalized.display()
48 ));
49 }
50 Ok(canonicalized)
51}
52
53fn resolve_runtime_static_dir(
54 bamboo_home_dir: &Path,
55 configured_static_dir: Option<PathBuf>,
56) -> Result<Option<PathBuf>, String> {
57 if let Some(path) = configured_static_dir {
58 let canonicalized = canonicalize_static_dir(&path)?;
59 info!(
60 "Serving static files from configured directory: {:?}",
61 canonicalized
62 );
63 return Ok(Some(canonicalized));
64 }
65
66 if !has_embedded_frontend_package() && resolve_frontend_package_path(None).is_none() {
67 info!("No embedded or sidecar Bamboo frontend package found; starting API-only server");
68 return Ok(None);
69 }
70
71 let status = ensure_current_frontend_dir_in(bamboo_home_dir, None)
72 .map_err(|e| format!("Failed to prepare Bamboo frontend assets: {e}"))?;
73 let frontend_dir = canonicalize_static_dir(&status.frontend_dir)?;
74
75 if status.refreshed {
76 info!(
77 "Refreshed Bamboo frontend assets at {} (version {}, hash {})",
78 frontend_dir.display(),
79 status.bundled_manifest.frontend_version,
80 status.bundled_manifest.bundle_hash
81 );
82 } else {
83 info!(
84 "Using existing Bamboo frontend assets at {} (version {}, hash {})",
85 frontend_dir.display(),
86 status.bundled_manifest.frontend_version,
87 status.bundled_manifest.bundle_hash
88 );
89 }
90
91 Ok(Some(frontend_dir))
92}
93
94pub async fn run(bamboo_home_dir: PathBuf, port: u16) -> Result<(), String> {
106 run_with_tls(bamboo_home_dir, port, None).await
107}
108
109pub async fn run_with_tls(
113 bamboo_home_dir: PathBuf,
114 port: u16,
115 tls: Option<TlsConfig>,
116) -> Result<(), String> {
117 info!("Starting unified server in desktop mode...");
118
119 let static_dir = resolve_runtime_static_dir(&bamboo_home_dir, None)?;
120
121 let app_state = web::Data::new(
122 AppState::new(bamboo_home_dir.clone())
123 .await
124 .map_err(|e| format!("Failed to initialize app state: {e}"))?,
125 );
126 let app_state_for_shutdown = app_state.clone();
129 let workers = resolve_worker_count();
130
131 let app_factory = move || {
132 let mut app = super::web_service::with_body_limits(App::new())
137 .app_data(app_state.clone())
138 .wrap(build_cors("127.0.0.1", port))
139 .wrap(actix_web::middleware::from_fn(
143 crate::config::add_asset_cache_headers,
144 ))
145 .configure(configure_routes); if let Some(static_path) = &static_dir {
148 let index_file = static_path.join("index.html");
149 info!("Serving static files from: {:?}", static_path);
150 app = app.service(
151 fs::Files::new("/", static_path)
152 .index_file("index.html")
153 .prefer_utf8(true)
154 .disable_content_disposition()
155 .default_handler(fn_service(move |req: ServiceRequest| {
156 let index_file = index_file.clone();
157 async move {
158 let path = req.path().to_string();
159 if is_api_path(&path) {
160 let response = HttpResponse::NotFound().finish();
161 return Ok(ServiceResponse::new(req.into_parts().0, response));
162 }
163
164 let (http_req, _) = req.into_parts();
165 match actix_files::NamedFile::open_async(index_file).await {
166 Ok(file) => Ok(ServiceResponse::new(
167 http_req.clone(),
168 file.into_response(&http_req),
169 )),
170 Err(_) => Ok(ServiceResponse::new(
171 http_req,
172 HttpResponse::NotFound().finish(),
173 )),
174 }
175 }
176 })),
177 );
178 }
179
180 app
181 };
182
183 let rustls_cfg = match &tls {
187 Some(tls) => Some(build_rustls_config(tls)?),
188 None => None,
189 };
190
191 let listeners = build_desktop_listeners(port)?;
192
193 let mut http = HttpServer::new(app_factory).workers(workers);
194 for (idx, listener) in listeners.into_iter().enumerate() {
195 http = match &rustls_cfg {
196 Some(cfg) => http
197 .listen_rustls_0_23(listener, cfg.clone())
198 .map_err(|e| format!("Failed to attach TLS listener #{idx}: {e}"))?,
199 None => http
200 .listen(listener)
201 .map_err(|e| format!("Failed to attach listener #{idx}: {e}"))?,
202 };
203 }
204
205 let server = http.run();
206
207 let scheme = if rustls_cfg.is_some() {
208 "https"
209 } else {
210 "http"
211 };
212 info!("Unified server running on {scheme}://127.0.0.1:{port}");
213
214 let result = server.await;
215
216 app_state_for_shutdown.shutdown().await;
221
222 if let Err(e) = result {
223 error!("Server error: {}", e);
224 return Err(format!("Server error: {e}"));
225 }
226
227 Ok(())
228}
229
230pub async fn run_with_bind(bamboo_home_dir: PathBuf, port: u16, bind: &str) -> Result<(), String> {
244 run_with_bind_and_static(bamboo_home_dir, port, bind, None).await
245}
246
247pub async fn run_with_bind_tls(
249 bamboo_home_dir: PathBuf,
250 port: u16,
251 bind: &str,
252 tls: Option<TlsConfig>,
253) -> Result<(), String> {
254 run_with_bind_and_static_tls(bamboo_home_dir, port, bind, None, tls).await
255}
256
257pub async fn run_with_bind_and_static(
279 bamboo_home_dir: PathBuf,
280 port: u16,
281 bind: &str,
282 static_dir: Option<PathBuf>,
283) -> Result<(), String> {
284 run_with_bind_and_static_tls(bamboo_home_dir, port, bind, static_dir, None).await
285}
286
287pub async fn run_with_bind_and_static_tls(
290 bamboo_home_dir: PathBuf,
291 port: u16,
292 bind: &str,
293 static_dir: Option<PathBuf>,
294 tls: Option<TlsConfig>,
295) -> Result<(), String> {
296 info!("Starting unified server on {}:{}...", bind, port);
297
298 let apply_rate_limit = !is_loopback_bind(bind);
301 require_limiter_for_nonloopback(bind, apply_rate_limit)?;
306
307 let static_dir = resolve_runtime_static_dir(&bamboo_home_dir, static_dir)?;
308
309 let app_state = web::Data::new(
310 AppState::new(bamboo_home_dir.clone())
311 .await
312 .map_err(|e| format!("Failed to initialize app state: {e}"))?,
313 );
314 let app_state_for_shutdown = app_state.clone();
317 let workers = resolve_worker_count();
318
319 let rate_limiter = build_rate_limiter();
323 let bind_for_cors = bind.to_string();
324 let app_factory = move || {
325 let mut app = wrap_governor_and_cors(
335 super::web_service::with_body_limits(App::new()).app_data(app_state.clone()),
336 &rate_limiter,
337 apply_rate_limit,
338 &bind_for_cors,
339 port,
340 )
341 .wrap(build_security_headers())
342 .wrap(actix_web::middleware::from_fn(
345 crate::config::add_asset_cache_headers,
346 ))
347 .configure(configure_routes_with_rate_limiting);
348
349 if let Some(static_path) = &static_dir {
350 let index_file = static_path.join("index.html");
351 info!("Serving static files from: {:?}", static_path);
352 app = app.service(
353 fs::Files::new("/", static_path)
354 .index_file("index.html")
355 .prefer_utf8(true)
356 .disable_content_disposition()
357 .default_handler(fn_service(move |req: ServiceRequest| {
358 let index_file = index_file.clone();
359 async move {
360 let path = req.path().to_string();
361 if is_api_path(&path) {
362 let response = HttpResponse::NotFound().finish();
363 return Ok(ServiceResponse::new(req.into_parts().0, response));
364 }
365
366 let (http_req, _) = req.into_parts();
367 match actix_files::NamedFile::open_async(index_file).await {
368 Ok(file) => Ok(ServiceResponse::new(
369 http_req.clone(),
370 file.into_response(&http_req),
371 )),
372 Err(_) => Ok(ServiceResponse::new(
373 http_req,
374 HttpResponse::NotFound().finish(),
375 )),
376 }
377 }
378 })),
379 );
380 }
381
382 app
383 };
384
385 let rustls_cfg = match &tls {
389 Some(tls) => Some(build_rustls_config(tls)?),
390 None => None,
391 };
392
393 let listeners = build_bind_listeners(bind, port)?;
394
395 let mut http = HttpServer::new(app_factory).workers(workers);
396 for (idx, listener) in listeners.into_iter().enumerate() {
397 http = match &rustls_cfg {
398 Some(cfg) => http
399 .listen_rustls_0_23(listener, cfg.clone())
400 .map_err(|e| format!("Failed to attach TLS listener #{idx}: {e}"))?,
401 None => http
402 .listen(listener)
403 .map_err(|e| format!("Failed to attach listener #{idx}: {e}"))?,
404 };
405 }
406
407 let server = http.run();
408
409 let scheme = if rustls_cfg.is_some() {
410 "https"
411 } else {
412 "http"
413 };
414 info!("Unified server running on {scheme}://{}:{}", bind, port);
415
416 let result = server.await;
417
418 app_state_for_shutdown.shutdown().await;
422
423 if let Err(e) = result {
424 error!("Server error: {}", e);
425 return Err(format!("Server error: {e}"));
426 }
427
428 Ok(())
429}
430
431#[cfg(test)]
432mod tests {
433 use super::*;
434 use tempfile::tempdir;
435
436 #[test]
437 fn resolve_runtime_static_dir_uses_configured_dir_when_present() {
438 let bamboo_home = tempdir().unwrap();
439 let static_dir = tempdir().unwrap();
440 std::fs::write(static_dir.path().join("index.html"), "ok").unwrap();
441
442 let resolved =
443 resolve_runtime_static_dir(bamboo_home.path(), Some(static_dir.path().to_path_buf()))
444 .expect("configured static dir should resolve")
445 .expect("configured static dir should be returned");
446
447 assert_eq!(resolved, static_dir.path().canonicalize().unwrap());
448 }
449
450 #[test]
451 fn is_api_path_covers_every_registered_version_prefix() {
452 for api_path in [
456 "/api/v1/sessions",
457 "/v1/bamboo/workflows",
458 "/v2/unknown",
459 "/openai/v1/models",
460 "/anthropic/v1/messages",
461 "/gemini/v1beta/models",
462 ] {
463 assert!(is_api_path(api_path), "{api_path} must be an API path");
464 }
465
466 for frontend_path in ["/", "/index.html", "/assets/app.js", "/settings"] {
467 assert!(
468 !is_api_path(frontend_path),
469 "{frontend_path} must NOT be treated as an API path"
470 );
471 }
472 }
473
474 #[actix_web::test]
489 async fn full_app_assembly_forwards_every_api_prefix_and_still_serves_spa_fallback() {
490 use actix_web::dev::{fn_service, ServiceRequest, ServiceResponse};
491 use actix_web::http::StatusCode;
492 use actix_web::{test, App};
493
494 let static_dir = tempdir().unwrap();
495 let index_file = static_dir.path().join("index.html");
496 std::fs::write(&index_file, "<html>spa-fallback-marker</html>").unwrap();
497
498 let app = test::init_service(
499 App::new()
500 .configure(crate::routes::configure_routes)
501 .service(
502 fs::Files::new("/", static_dir.path())
503 .index_file("index.html")
504 .default_handler(fn_service(move |req: ServiceRequest| {
505 let index_file = index_file.clone();
506 async move {
507 let path = req.path().to_string();
508 if is_api_path(&path) {
509 let response = HttpResponse::NotFound().finish();
510 return Ok(ServiceResponse::new(req.into_parts().0, response));
511 }
512 let (http_req, _) = req.into_parts();
513 match actix_files::NamedFile::open_async(index_file).await {
514 Ok(file) => Ok(ServiceResponse::new(
515 http_req.clone(),
516 file.into_response(&http_req),
517 )),
518 Err(_) => Ok(ServiceResponse::new(
519 http_req,
520 HttpResponse::NotFound().finish(),
521 )),
522 }
523 }
524 })),
525 ),
526 )
527 .await;
528
529 for (method, uri) in [
534 ("GET", "/v1/bamboo/workflows"),
535 ("GET", "/api/v1/bamboo/workflows"),
536 ("GET", "/api/v1/sessions"),
537 ("GET", "/api/v1/sessions/does-not-exist/history"),
538 ("GET", "/api/v1/history/does-not-exist"),
539 ("GET", "/v2/stream"),
540 ("GET", "/openai/v1/models"),
541 ("GET", "/anthropic/v1/models"),
542 ("GET", "/gemini/v1beta/models"),
543 ] {
544 let req = test::TestRequest::with_uri(uri)
545 .method(method.parse().unwrap())
546 .to_request();
547 let resp = test::call_service(&app, req).await;
548 assert_ne!(
549 resp.status(),
550 StatusCode::NOT_FOUND,
551 "{method} {uri} must be routed to its real handler, not 404 via the SPA fallback"
552 );
553 }
554
555 let flat = test::TestRequest::get()
558 .uri("/api/v1/history/does-not-exist")
559 .to_request();
560 let flat_status = test::call_service(&app, flat).await.status();
561 let nested = test::TestRequest::get()
562 .uri("/api/v1/sessions/does-not-exist/history")
563 .to_request();
564 let nested_status = test::call_service(&app, nested).await.status();
565 assert_eq!(
566 flat_status, nested_status,
567 "flat and nested history aliases must behave identically"
568 );
569
570 let bogus_api_req = test::TestRequest::get()
573 .uri("/api/v1/totally-not-a-real-route")
574 .to_request();
575 let bogus_api_resp = test::call_service(&app, bogus_api_req).await;
576 assert_eq!(
577 bogus_api_resp.status(),
578 StatusCode::NOT_FOUND,
579 "an unmatched /api/v1/* path must 404, not silently serve the SPA"
580 );
581
582 let spa_req = test::TestRequest::get()
586 .uri("/chat/some-deep-route")
587 .to_request();
588 let spa_resp = test::call_service(&app, spa_req).await;
589 assert_eq!(spa_resp.status(), StatusCode::OK);
590 let body = actix_web::body::to_bytes(spa_resp.into_body())
591 .await
592 .unwrap();
593 assert!(
594 String::from_utf8_lossy(&body).contains("spa-fallback-marker"),
595 "non-API deep link must serve the SPA index.html"
596 );
597 }
598}