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,
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 actix_governor::Governor;
22use bamboo_config::TlsConfig;
23
24fn canonicalize_static_dir(path: &Path) -> Result<PathBuf, String> {
25 let canonicalized = path
26 .canonicalize()
27 .map_err(|e| format!("Static directory not found: {:?}: {}", path, e))?;
28 if !canonicalized.is_dir() {
29 return Err(format!(
30 "Static path is not a directory: {}",
31 canonicalized.display()
32 ));
33 }
34 Ok(canonicalized)
35}
36
37fn resolve_runtime_static_dir(
38 bamboo_home_dir: &Path,
39 configured_static_dir: Option<PathBuf>,
40) -> Result<Option<PathBuf>, String> {
41 if let Some(path) = configured_static_dir {
42 let canonicalized = canonicalize_static_dir(&path)?;
43 info!(
44 "Serving static files from configured directory: {:?}",
45 canonicalized
46 );
47 return Ok(Some(canonicalized));
48 }
49
50 if !has_embedded_frontend_package() && resolve_frontend_package_path(None).is_none() {
51 info!("No embedded or sidecar Bamboo frontend package found; starting API-only server");
52 return Ok(None);
53 }
54
55 let status = ensure_current_frontend_dir_in(bamboo_home_dir, None)
56 .map_err(|e| format!("Failed to prepare Bamboo frontend assets: {e}"))?;
57 let frontend_dir = canonicalize_static_dir(&status.frontend_dir)?;
58
59 if status.refreshed {
60 info!(
61 "Refreshed Bamboo frontend assets at {} (version {}, hash {})",
62 frontend_dir.display(),
63 status.bundled_manifest.frontend_version,
64 status.bundled_manifest.bundle_hash
65 );
66 } else {
67 info!(
68 "Using existing Bamboo frontend assets at {} (version {}, hash {})",
69 frontend_dir.display(),
70 status.bundled_manifest.frontend_version,
71 status.bundled_manifest.bundle_hash
72 );
73 }
74
75 Ok(Some(frontend_dir))
76}
77
78pub async fn run(bamboo_home_dir: PathBuf, port: u16) -> Result<(), String> {
90 run_with_tls(bamboo_home_dir, port, None).await
91}
92
93pub async fn run_with_tls(
97 bamboo_home_dir: PathBuf,
98 port: u16,
99 tls: Option<TlsConfig>,
100) -> Result<(), String> {
101 info!("Starting unified server in desktop mode...");
102
103 let static_dir = resolve_runtime_static_dir(&bamboo_home_dir, None)?;
104
105 let app_state = web::Data::new(
106 AppState::new(bamboo_home_dir.clone())
107 .await
108 .map_err(|e| format!("Failed to initialize app state: {e}"))?,
109 );
110 let app_state_for_shutdown = app_state.clone();
113 let workers = resolve_worker_count();
114
115 let app_factory = move || {
116 let mut app = super::web_service::with_body_limits(App::new())
121 .app_data(app_state.clone())
122 .wrap(build_cors("127.0.0.1", port))
123 .wrap(actix_web::middleware::from_fn(
127 crate::config::add_asset_cache_headers,
128 ))
129 .configure(configure_routes); if let Some(static_path) = &static_dir {
132 let index_file = static_path.join("index.html");
133 info!("Serving static files from: {:?}", static_path);
134 app = app.service(
135 fs::Files::new("/", static_path)
136 .index_file("index.html")
137 .prefer_utf8(true)
138 .disable_content_disposition()
139 .default_handler(fn_service(move |req: ServiceRequest| {
140 let index_file = index_file.clone();
141 async move {
142 let path = req.path().to_string();
143 if path.starts_with("/api/")
144 || path.starts_with("/v1/")
145 || path.starts_with("/openai/")
146 || path.starts_with("/anthropic/")
147 || path.starts_with("/gemini/")
148 {
149 let response = HttpResponse::NotFound().finish();
150 return Ok(ServiceResponse::new(req.into_parts().0, response));
151 }
152
153 let (http_req, _) = req.into_parts();
154 match actix_files::NamedFile::open_async(index_file).await {
155 Ok(file) => Ok(ServiceResponse::new(
156 http_req.clone(),
157 file.into_response(&http_req),
158 )),
159 Err(_) => Ok(ServiceResponse::new(
160 http_req,
161 HttpResponse::NotFound().finish(),
162 )),
163 }
164 }
165 })),
166 );
167 }
168
169 app
170 };
171
172 let rustls_cfg = match &tls {
176 Some(tls) => Some(build_rustls_config(tls)?),
177 None => None,
178 };
179
180 let listeners = build_desktop_listeners(port)?;
181
182 let mut http = HttpServer::new(app_factory).workers(workers);
183 for (idx, listener) in listeners.into_iter().enumerate() {
184 http = match &rustls_cfg {
185 Some(cfg) => http
186 .listen_rustls_0_23(listener, cfg.clone())
187 .map_err(|e| format!("Failed to attach TLS listener #{idx}: {e}"))?,
188 None => http
189 .listen(listener)
190 .map_err(|e| format!("Failed to attach listener #{idx}: {e}"))?,
191 };
192 }
193
194 let server = http.run();
195
196 let scheme = if rustls_cfg.is_some() {
197 "https"
198 } else {
199 "http"
200 };
201 info!("Unified server running on {scheme}://127.0.0.1:{port}");
202
203 let result = server.await;
204
205 app_state_for_shutdown.shutdown().await;
210
211 if let Err(e) = result {
212 error!("Server error: {}", e);
213 return Err(format!("Server error: {e}"));
214 }
215
216 Ok(())
217}
218
219pub async fn run_with_bind(bamboo_home_dir: PathBuf, port: u16, bind: &str) -> Result<(), String> {
233 run_with_bind_and_static(bamboo_home_dir, port, bind, None).await
234}
235
236pub async fn run_with_bind_tls(
238 bamboo_home_dir: PathBuf,
239 port: u16,
240 bind: &str,
241 tls: Option<TlsConfig>,
242) -> Result<(), String> {
243 run_with_bind_and_static_tls(bamboo_home_dir, port, bind, None, tls).await
244}
245
246pub async fn run_with_bind_and_static(
268 bamboo_home_dir: PathBuf,
269 port: u16,
270 bind: &str,
271 static_dir: Option<PathBuf>,
272) -> Result<(), String> {
273 run_with_bind_and_static_tls(bamboo_home_dir, port, bind, static_dir, None).await
274}
275
276pub async fn run_with_bind_and_static_tls(
279 bamboo_home_dir: PathBuf,
280 port: u16,
281 bind: &str,
282 static_dir: Option<PathBuf>,
283 tls: Option<TlsConfig>,
284) -> Result<(), String> {
285 info!("Starting unified server on {}:{}...", bind, port);
286
287 let static_dir = resolve_runtime_static_dir(&bamboo_home_dir, static_dir)?;
288
289 let app_state = web::Data::new(
290 AppState::new(bamboo_home_dir.clone())
291 .await
292 .map_err(|e| format!("Failed to initialize app state: {e}"))?,
293 );
294 let app_state_for_shutdown = app_state.clone();
297 let workers = resolve_worker_count();
298
299 let rate_limiter = build_rate_limiter();
303 let apply_rate_limit = !is_loopback_bind(bind);
306 require_limiter_for_nonloopback(bind, apply_rate_limit)?;
309 let bind_for_cors = bind.to_string();
310 let app_factory = move || {
311 let mut app = super::web_service::with_body_limits(App::new())
315 .app_data(app_state.clone())
316 .wrap(actix_web::middleware::Condition::new(
325 apply_rate_limit,
326 Governor::new(&rate_limiter),
327 ))
328 .wrap(build_cors(&bind_for_cors, port))
329 .wrap(build_security_headers())
330 .wrap(actix_web::middleware::from_fn(
333 crate::config::add_asset_cache_headers,
334 ))
335 .configure(configure_routes_with_rate_limiting);
336
337 if let Some(static_path) = &static_dir {
338 let index_file = static_path.join("index.html");
339 info!("Serving static files from: {:?}", static_path);
340 app = app.service(
341 fs::Files::new("/", static_path)
342 .index_file("index.html")
343 .prefer_utf8(true)
344 .disable_content_disposition()
345 .default_handler(fn_service(move |req: ServiceRequest| {
346 let index_file = index_file.clone();
347 async move {
348 let path = req.path().to_string();
349 if path.starts_with("/api/")
350 || path.starts_with("/v1/")
351 || path.starts_with("/openai/")
352 || path.starts_with("/anthropic/")
353 || path.starts_with("/gemini/")
354 {
355 let response = HttpResponse::NotFound().finish();
356 return Ok(ServiceResponse::new(req.into_parts().0, response));
357 }
358
359 let (http_req, _) = req.into_parts();
360 match actix_files::NamedFile::open_async(index_file).await {
361 Ok(file) => Ok(ServiceResponse::new(
362 http_req.clone(),
363 file.into_response(&http_req),
364 )),
365 Err(_) => Ok(ServiceResponse::new(
366 http_req,
367 HttpResponse::NotFound().finish(),
368 )),
369 }
370 }
371 })),
372 );
373 }
374
375 app
376 };
377
378 let rustls_cfg = match &tls {
382 Some(tls) => Some(build_rustls_config(tls)?),
383 None => None,
384 };
385
386 let listeners = build_bind_listeners(bind, port)?;
387
388 let mut http = HttpServer::new(app_factory).workers(workers);
389 for (idx, listener) in listeners.into_iter().enumerate() {
390 http = match &rustls_cfg {
391 Some(cfg) => http
392 .listen_rustls_0_23(listener, cfg.clone())
393 .map_err(|e| format!("Failed to attach TLS listener #{idx}: {e}"))?,
394 None => http
395 .listen(listener)
396 .map_err(|e| format!("Failed to attach listener #{idx}: {e}"))?,
397 };
398 }
399
400 let server = http.run();
401
402 let scheme = if rustls_cfg.is_some() {
403 "https"
404 } else {
405 "http"
406 };
407 info!("Unified server running on {scheme}://{}:{}", bind, port);
408
409 let result = server.await;
410
411 app_state_for_shutdown.shutdown().await;
415
416 if let Err(e) = result {
417 error!("Server error: {}", e);
418 return Err(format!("Server error: {e}"));
419 }
420
421 Ok(())
422}
423
424#[cfg(test)]
425mod tests {
426 use super::*;
427 use tempfile::tempdir;
428
429 #[test]
430 fn resolve_runtime_static_dir_uses_configured_dir_when_present() {
431 let bamboo_home = tempdir().unwrap();
432 let static_dir = tempdir().unwrap();
433 std::fs::write(static_dir.path().join("index.html"), "ok").unwrap();
434
435 let resolved =
436 resolve_runtime_static_dir(bamboo_home.path(), Some(static_dir.path().to_path_buf()))
437 .expect("configured static dir should resolve")
438 .expect("configured static dir should be returned");
439
440 assert_eq!(resolved, static_dir.path().canonicalize().unwrap());
441 }
442}