Skip to main content

bamboo_server/server/
entrypoints.rs

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 crate::app_state::AppState;
12use crate::config::{build_cors, build_security_headers};
13use crate::routes::{configure_routes, configure_routes_with_rate_limiting};
14use crate::services::frontend_package::{
15    ensure_current_frontend_dir_in, has_embedded_frontend_package, resolve_frontend_package_path,
16};
17
18fn canonicalize_static_dir(path: &Path) -> Result<PathBuf, String> {
19    let canonicalized = path
20        .canonicalize()
21        .map_err(|e| format!("Static directory not found: {:?}: {}", path, e))?;
22    if !canonicalized.is_dir() {
23        return Err(format!(
24            "Static path is not a directory: {}",
25            canonicalized.display()
26        ));
27    }
28    Ok(canonicalized)
29}
30
31fn resolve_runtime_static_dir(
32    bamboo_home_dir: &Path,
33    configured_static_dir: Option<PathBuf>,
34) -> Result<Option<PathBuf>, String> {
35    if let Some(path) = configured_static_dir {
36        let canonicalized = canonicalize_static_dir(&path)?;
37        info!(
38            "Serving static files from configured directory: {:?}",
39            canonicalized
40        );
41        return Ok(Some(canonicalized));
42    }
43
44    if !has_embedded_frontend_package() && resolve_frontend_package_path(None).is_none() {
45        info!("No embedded or sidecar Bamboo frontend package found; starting API-only server");
46        return Ok(None);
47    }
48
49    let status = ensure_current_frontend_dir_in(bamboo_home_dir, None)
50        .map_err(|e| format!("Failed to prepare Bamboo frontend assets: {e}"))?;
51    let frontend_dir = canonicalize_static_dir(&status.frontend_dir)?;
52
53    if status.refreshed {
54        info!(
55            "Refreshed Bamboo frontend assets at {} (version {}, hash {})",
56            frontend_dir.display(),
57            status.bundled_manifest.frontend_version,
58            status.bundled_manifest.bundle_hash
59        );
60    } else {
61        info!(
62            "Using existing Bamboo frontend assets at {} (version {}, hash {})",
63            frontend_dir.display(),
64            status.bundled_manifest.frontend_version,
65            status.bundled_manifest.bundle_hash
66        );
67    }
68
69    Ok(Some(frontend_dir))
70}
71
72/// Run the unified server in desktop mode (localhost only, no rate limiting)
73///
74/// This is the simplest mode for desktop applications:
75/// - Binds to 127.0.0.1 only (safe, localhost-only)
76/// - No rate limiting (assumes single user)
77/// - No security headers (development mode)
78///
79/// # Arguments
80/// * `bamboo_home_dir` - Bamboo home directory containing all app data (config, sessions, skills, etc.)
81///   Equivalent to `${HOME}/.bamboo` in standard installations.
82/// * `port` - Port to listen on
83pub async fn run(bamboo_home_dir: PathBuf, port: u16) -> Result<(), String> {
84    info!("Starting unified server in desktop mode...");
85
86    let static_dir = resolve_runtime_static_dir(&bamboo_home_dir, None)?;
87
88    let app_state = web::Data::new(
89        AppState::new(bamboo_home_dir.clone())
90            .await
91            .map_err(|e| format!("Failed to initialize app state: {e}"))?,
92    );
93    let workers = resolve_worker_count();
94
95    let app_factory = move || {
96        let mut app = App::new()
97            .app_data(app_state.clone())
98            .wrap(build_cors("127.0.0.1", port))
99            .configure(configure_routes); // No rate limiting for desktop mode
100
101        if let Some(static_path) = &static_dir {
102            let index_file = static_path.join("index.html");
103            info!("Serving static files from: {:?}", static_path);
104            app = app.service(
105                fs::Files::new("/", static_path)
106                    .index_file("index.html")
107                    .prefer_utf8(true)
108                    .disable_content_disposition()
109                    .default_handler(fn_service(move |req: ServiceRequest| {
110                        let index_file = index_file.clone();
111                        async move {
112                            let path = req.path().to_string();
113                            if path.starts_with("/api/")
114                                || path.starts_with("/v1/")
115                                || path.starts_with("/openai/")
116                                || path.starts_with("/anthropic/")
117                                || path.starts_with("/gemini/")
118                            {
119                                let response = HttpResponse::NotFound().finish();
120                                return Ok(ServiceResponse::new(req.into_parts().0, response));
121                            }
122
123                            let (http_req, _) = req.into_parts();
124                            match actix_files::NamedFile::open_async(index_file).await {
125                                Ok(file) => Ok(ServiceResponse::new(
126                                    http_req.clone(),
127                                    file.into_response(&http_req),
128                                )),
129                                Err(_) => Ok(ServiceResponse::new(
130                                    http_req,
131                                    HttpResponse::NotFound().finish(),
132                                )),
133                            }
134                        }
135                    })),
136            );
137        }
138
139        app
140    };
141
142    let listeners = build_desktop_listeners(port)?;
143
144    let mut http = HttpServer::new(app_factory).workers(workers);
145    for (idx, listener) in listeners.into_iter().enumerate() {
146        http = http
147            .listen(listener)
148            .map_err(|e| format!("Failed to attach listener #{idx}: {e}"))?;
149    }
150
151    let server = http.run();
152
153    info!("Unified server running on http://127.0.0.1:{port}");
154
155    if let Err(e) = server.await {
156        error!("Server error: {}", e);
157        return Err(format!("Server error: {e}"));
158    }
159
160    Ok(())
161}
162
163/// Run the unified server with custom bind address (Docker/production mode)
164///
165/// Production mode features:
166/// - Custom bind address (0.0.0.0 for Docker, custom for standalone)
167/// - Rate limiting enabled (10 req/sec, burst 20)
168/// - Security headers enabled
169/// - Request size limits (25MB JSON, 30MB payload)
170///
171/// # Arguments
172/// * `bamboo_home_dir` - Bamboo home directory containing all app data (config, sessions, skills, etc.)
173///   Equivalent to `${HOME}/.bamboo` in standard installations.
174/// * `port` - Port to listen on
175/// * `bind` - Bind address (127.0.0.1, 0.0.0.0, or custom)
176pub async fn run_with_bind(bamboo_home_dir: PathBuf, port: u16, bind: &str) -> Result<(), String> {
177    run_with_bind_and_static(bamboo_home_dir, port, bind, None).await
178}
179
180/// Run the unified server with custom bind address and static file serving
181///
182/// Production mode with frontend serving:
183/// - All features from run_with_bind()
184/// - Static file serving for frontend (index.html, assets, etc.)
185///
186/// # Arguments
187/// * `bamboo_home_dir` - Bamboo home directory containing all app data (config, sessions, skills, etc.)
188///   Equivalent to `${HOME}/.bamboo` in standard installations.
189/// * `port` - Port to listen on
190/// * `bind` - Bind address (127.0.0.1 for localhost, 0.0.0.0 for all interfaces)
191/// * `static_dir` - Optional directory containing built frontend files
192///
193/// # Example
194/// ```bash
195/// # Docker mode (serve frontend)
196/// bamboo serve --port 9562 --bind 0.0.0.0 --static-dir /app/static
197///
198/// # Standalone production mode (serve frontend)
199/// bamboo serve --port 9562 --static-dir ./dist
200/// ```
201pub async fn run_with_bind_and_static(
202    bamboo_home_dir: PathBuf,
203    port: u16,
204    bind: &str,
205    static_dir: Option<PathBuf>,
206) -> Result<(), String> {
207    info!("Starting unified server on {}:{}...", bind, port);
208
209    let static_dir = resolve_runtime_static_dir(&bamboo_home_dir, static_dir)?;
210
211    let app_state = web::Data::new(
212        AppState::new(bamboo_home_dir.clone())
213            .await
214            .map_err(|e| format!("Failed to initialize app state: {e}"))?,
215    );
216    let workers = resolve_worker_count();
217
218    let bind_for_cors = bind.to_string();
219    let app_factory = move || {
220        let mut app = App::new()
221            // Request size limits to prevent DoS
222            // Chat requests may include base64 images; keep limits high enough for local usage.
223            .app_data(web::JsonConfig::default().limit(25 * 1024 * 1024)) // 25MB JSON limit
224            .app_data(web::PayloadConfig::new(30 * 1024 * 1024)) // 30MB payload limit
225            .app_data(app_state.clone())
226            .wrap(build_cors(&bind_for_cors, port))
227            .wrap(build_security_headers())
228            .configure(configure_routes_with_rate_limiting); // Enable rate limiting
229
230        if let Some(static_path) = &static_dir {
231            let index_file = static_path.join("index.html");
232            info!("Serving static files from: {:?}", static_path);
233            app = app.service(
234                fs::Files::new("/", static_path)
235                    .index_file("index.html")
236                    .prefer_utf8(true)
237                    .disable_content_disposition()
238                    .default_handler(fn_service(move |req: ServiceRequest| {
239                        let index_file = index_file.clone();
240                        async move {
241                            let path = req.path().to_string();
242                            if path.starts_with("/api/")
243                                || path.starts_with("/v1/")
244                                || path.starts_with("/openai/")
245                                || path.starts_with("/anthropic/")
246                                || path.starts_with("/gemini/")
247                            {
248                                let response = HttpResponse::NotFound().finish();
249                                return Ok(ServiceResponse::new(req.into_parts().0, response));
250                            }
251
252                            let (http_req, _) = req.into_parts();
253                            match actix_files::NamedFile::open_async(index_file).await {
254                                Ok(file) => Ok(ServiceResponse::new(
255                                    http_req.clone(),
256                                    file.into_response(&http_req),
257                                )),
258                                Err(_) => Ok(ServiceResponse::new(
259                                    http_req,
260                                    HttpResponse::NotFound().finish(),
261                                )),
262                            }
263                        }
264                    })),
265            );
266        }
267
268        app
269    };
270
271    let listeners = build_bind_listeners(bind, port)?;
272
273    let mut http = HttpServer::new(app_factory).workers(workers);
274    for (idx, listener) in listeners.into_iter().enumerate() {
275        http = http
276            .listen(listener)
277            .map_err(|e| format!("Failed to attach listener #{idx}: {e}"))?;
278    }
279
280    let server = http.run();
281
282    info!("Unified server running on http://{}:{}", bind, port);
283
284    if let Err(e) = server.await {
285        error!("Server error: {}", e);
286        return Err(format!("Server error: {e}"));
287    }
288
289    Ok(())
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295    use tempfile::tempdir;
296
297    #[test]
298    fn resolve_runtime_static_dir_uses_configured_dir_when_present() {
299        let bamboo_home = tempdir().unwrap();
300        let static_dir = tempdir().unwrap();
301        std::fs::write(static_dir.path().join("index.html"), "ok").unwrap();
302
303        let resolved =
304            resolve_runtime_static_dir(bamboo_home.path(), Some(static_dir.path().to_path_buf()))
305                .expect("configured static dir should resolve")
306                .expect("configured static dir should be returned");
307
308        assert_eq!(resolved, static_dir.path().canonicalize().unwrap());
309    }
310}