//! HTTP server module: routes, state, middleware, and API handlers. pub mod auth; pub mod jobs; pub mod middleware; pub mod scan; pub mod settings; pub mod sse; pub mod stats; pub mod system; pub mod wizard; #[cfg(test)] mod tests; use crate::Agent; use crate::Transcoder; use crate::config::Config; use crate::db::{AlchemistEvent, Db, EventChannels}; use crate::error::{AlchemistError, Result}; use crate::system::hardware::{HardwareInfo, HardwareProbeLog, HardwareState}; use axum::{ Router, http::{StatusCode, Uri, header}, middleware as axum_middleware, response::{IntoResponse, Response}, routing::{delete, get, post}, }; #[cfg(feature = "embed-web")] use rust_embed::RustEmbed; use std::collections::HashMap; use std::fs; use std::net::{IpAddr, SocketAddr}; use std::path::{Path as FsPath, PathBuf}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Instant; use tokio::net::lookup_host; use tokio::sync::{Mutex, RwLock, broadcast}; use tokio::time::Duration; #[cfg(not(feature = "embed-web"))] use tracing::warn; use tracing::{error, info}; use uuid::Uuid; use middleware::RateLimitEntry; #[cfg(feature = "embed-web")] #[derive(RustEmbed)] #[folder = "web/dist/"] struct Assets; fn load_static_asset(path: &str) -> Option> { sanitize_asset_path(path)?; #[cfg(feature = "embed-web")] if let Some(content) = Assets::get(path) { return Some(content.data.into_owned()); } let full_path = PathBuf::from("web/dist").join(path); fs::read(full_path).ok() } pub struct AppState { pub db: Arc, pub config: Arc>, pub agent: Arc, pub transcoder: Arc, pub scheduler: crate::scheduler::SchedulerHandle, pub event_channels: Arc, pub tx: broadcast::Sender, // Legacy channel for transition pub setup_required: Arc, pub start_time: Instant, pub telemetry_runtime_id: String, pub notification_manager: Arc, pub sys: Mutex, pub file_watcher: Arc, pub library_scanner: Arc, pub config_path: PathBuf, pub config_mutable: bool, pub hardware_state: HardwareState, pub hardware_probe_log: Arc>, pub resources_cache: Arc>>, pub(crate) login_rate_limiter: Mutex>, pub(crate) global_rate_limiter: Mutex>, pub(crate) sse_connections: Arc, } pub struct RunServerArgs { pub db: Arc, pub config: Arc>, pub agent: Arc, pub transcoder: Arc, pub scheduler: crate::scheduler::SchedulerHandle, pub event_channels: Arc, pub tx: broadcast::Sender, // Legacy channel for transition pub setup_required: bool, pub config_path: PathBuf, pub config_mutable: bool, pub hardware_state: HardwareState, pub hardware_probe_log: Arc>, pub notification_manager: Arc, pub file_watcher: Arc, pub library_scanner: Arc, } pub async fn run_server(args: RunServerArgs) -> Result<()> { let RunServerArgs { db, config, agent, transcoder, scheduler, event_channels, tx, setup_required, config_path, config_mutable, hardware_state, hardware_probe_log, notification_manager, file_watcher, library_scanner, } = args; #[cfg(not(feature = "embed-web"))] { let web_dist = PathBuf::from("web/dist"); if !web_dist.exists() { let cwd = std::env::current_dir() .map(|p| format!("{}/", p.display())) .unwrap_or_default(); warn!( "web/dist not found at {}web/dist — frontend will not be served. \ Build it first with `just web-build` or run from the repo root.", cwd ); } } // Initialize sysinfo let mut sys = sysinfo::System::new(); sys.refresh_cpu_usage(); sys.refresh_memory(); let state = Arc::new(AppState { db, config, agent, transcoder, scheduler, event_channels, tx, setup_required: Arc::new(AtomicBool::new(setup_required)), start_time: std::time::Instant::now(), telemetry_runtime_id: Uuid::new_v4().to_string(), notification_manager, sys: Mutex::new(sys), file_watcher, library_scanner, config_path, config_mutable, hardware_state, hardware_probe_log, resources_cache: Arc::new(tokio::sync::Mutex::new(None)), login_rate_limiter: Mutex::new(HashMap::new()), global_rate_limiter: Mutex::new(HashMap::new()), sse_connections: Arc::new(std::sync::atomic::AtomicUsize::new(0)), }); // Clone agent for shutdown handler before moving state into router let shutdown_agent = state.agent.clone(); let app = app_router(state); let port = std::env::var("ALCHEMIST_SERVER_PORT") .ok() .filter(|value| !value.trim().is_empty()) .map(|value| { value.trim().parse::().map_err(|_| { AlchemistError::Config("ALCHEMIST_SERVER_PORT must be a valid u16".to_string()) }) }) .transpose()? .unwrap_or(3000); let user_specified_port = std::env::var("ALCHEMIST_SERVER_PORT") .ok() .filter(|v| !v.trim().is_empty()) .is_some(); let max_attempts: u16 = if user_specified_port { 1 } else { 10 }; let mut listener = None; let mut bound_port = port; for attempt in 0..max_attempts { let try_port = port.saturating_add(attempt); let addr = format!("0.0.0.0:{try_port}"); match tokio::net::TcpListener::bind(&addr).await { Ok(l) => { bound_port = try_port; listener = Some(l); break; } Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => { if user_specified_port { return Err(AlchemistError::Config(format!( "Port {try_port} is already in use. Set ALCHEMIST_SERVER_PORT to a different port." ))); } let next = try_port.saturating_add(1); if attempt + 1 < max_attempts { tracing::warn!("Port {try_port} is in use, trying {next}"); } else { tracing::warn!("Port {try_port} is in use, no more ports to try"); } } Err(e) => return Err(AlchemistError::Io(e)), } } let listener = listener.ok_or_else(|| { AlchemistError::Config(format!( "Could not bind to any port in range {port}–{}. Set ALCHEMIST_SERVER_PORT to use a specific port.", port.saturating_add(max_attempts - 1) )) })?; if bound_port != port { tracing::warn!( "Port {} was in use — Alchemist is listening on http://0.0.0.0:{bound_port} instead", port ); info!("listening on http://0.0.0.0:{bound_port}"); } else { info!("listening on http://0.0.0.0:{bound_port}"); } // Run server with graceful shutdown on Ctrl+C axum::serve( listener, app.into_make_service_with_connect_info::(), ) .with_graceful_shutdown(async move { // Wait for shutdown signal let ctrl_c = async { tokio::signal::ctrl_c() .await .expect("failed to install Ctrl+C handler"); }; #[cfg(unix)] let terminate = async { tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) .expect("failed to install signal handler") .recv() .await; }; #[cfg(not(unix))] let terminate = std::future::pending::<()>(); tokio::select! { _ = ctrl_c => { info!("Received Ctrl+C, initiating graceful shutdown..."); } _ = terminate => { info!("Received SIGTERM, initiating graceful shutdown..."); } } // Forceful immediate shutdown of active jobs shutdown_agent .graceful_shutdown() .await; }) .await .map_err(|e| AlchemistError::Unknown(format!("Server error: {}", e)))?; Ok(()) } fn app_router(state: Arc) -> Router { use auth::*; use jobs::*; use scan::*; use settings::*; use sse::*; use stats::*; use system::*; use wizard::*; Router::new() // API Routes .route("/api/scan/start", post(start_scan_handler)) .route("/api/scan/status", get(get_scan_status_handler)) .route("/api/scan", post(scan_handler)) .route("/api/stats", get(stats_handler)) .route("/api/stats/aggregated", get(aggregated_stats_handler)) .route("/api/stats/daily", get(daily_stats_handler)) .route("/api/stats/detailed", get(detailed_stats_handler)) .route("/api/stats/savings", get(savings_summary_handler)) // Canonical job list endpoint. .route("/api/jobs", get(jobs_table_handler)) .route("/api/jobs/table", get(jobs_table_handler)) .route("/api/jobs/batch", post(batch_jobs_handler)) .route("/api/logs/history", get(logs_history_handler)) .route("/api/logs", delete(clear_logs_handler)) .route("/api/jobs/restart-failed", post(restart_failed_handler)) .route("/api/jobs/clear-completed", post(clear_completed_handler)) .route("/api/jobs/:id/cancel", post(cancel_job_handler)) .route("/api/jobs/:id/priority", post(update_job_priority_handler)) .route("/api/jobs/:id/restart", post(restart_job_handler)) .route("/api/jobs/:id/delete", post(delete_job_handler)) .route("/api/jobs/:id/details", get(get_job_detail_handler)) .route("/api/events", get(sse_handler)) .route("/api/engine/pause", post(pause_engine_handler)) .route("/api/engine/resume", post(resume_engine_handler)) .route("/api/engine/drain", post(drain_engine_handler)) .route("/api/engine/stop-drain", post(stop_drain_handler)) .route( "/api/engine/mode", get(get_engine_mode_handler).post(set_engine_mode_handler), ) .route("/api/engine/status", get(engine_status_handler)) .route( "/api/settings/transcode", get(get_transcode_settings_handler).post(update_transcode_settings_handler), ) .route( "/api/settings/system", get(get_system_settings_handler).post(update_system_settings_handler), ) .route( "/api/settings/bundle", get(get_settings_bundle_handler).put(update_settings_bundle_handler), ) .route( "/api/settings/preferences", post(set_setting_preference_handler), ) .route( "/api/settings/preferences/:key", get(get_setting_preference_handler), ) .route( "/api/settings/config", get(get_settings_config_handler).put(update_settings_config_handler), ) .route( "/api/settings/watch-dirs", get(get_watch_dirs_handler).post(add_watch_dir_handler), ) .route("/api/settings/folders", post(sync_watch_dirs_handler)) .route( "/api/settings/watch-dirs/:id", delete(remove_watch_dir_handler), ) .route( "/api/watch-dirs/:id/profile", axum::routing::patch(assign_watch_dir_profile_handler), ) .route("/api/profiles/presets", get(get_profile_presets_handler)) .route( "/api/profiles", get(list_profiles_handler).post(create_profile_handler), ) .route( "/api/profiles/:id", axum::routing::put(update_profile_handler).delete(delete_profile_handler), ) .route( "/api/settings/notifications", get(get_notifications_handler).post(add_notification_handler), ) .route( "/api/settings/notifications/:id", delete(delete_notification_handler), ) .route( "/api/settings/notifications/test", post(test_notification_handler), ) .route( "/api/settings/files", get(get_file_settings_handler).post(update_file_settings_handler), ) .route( "/api/settings/schedule", get(get_schedule_handler).post(add_schedule_handler), ) .route( "/api/settings/hardware", get(get_hardware_settings_handler).post(update_hardware_settings_handler), ) .route( "/api/settings/schedule/:id", delete(delete_schedule_handler), ) // Health Check Routes .route("/api/health", get(health_handler)) .route("/api/ready", get(ready_handler)) // System Routes .route("/api/system/resources", get(system_resources_handler)) .route("/api/system/info", get(get_system_info_handler)) .route("/api/system/hardware", get(get_hardware_info_handler)) .route( "/api/system/hardware/probe-log", get(get_hardware_probe_log_handler), ) .route( "/api/library/intelligence", get(library_intelligence_handler), ) .route("/api/library/health", get(library_health_handler)) .route( "/api/library/health/scan", post(start_library_health_scan_handler), ) .route( "/api/library/health/scan/:id", post(rescan_library_health_issue_handler), ) .route( "/api/library/health/issues", get(get_library_health_issues_handler), ) .route("/api/fs/browse", get(fs_browse_handler)) .route("/api/fs/recommendations", get(fs_recommendations_handler)) .route("/api/fs/preview", post(fs_preview_handler)) .route("/api/telemetry/payload", get(telemetry_payload_handler)) // Setup Routes .route("/api/setup/status", get(setup_status_handler)) .route("/api/setup/complete", post(setup_complete_handler)) .route("/api/auth/login", post(login_handler)) .route("/api/auth/logout", post(logout_handler)) .route( "/api/ui/preferences", get(get_preferences_handler).post(update_preferences_handler), ) // Static Asset Routes .route("/", get(index_handler)) .route("/*file", get(static_handler)) .layer(axum_middleware::from_fn( middleware::security_headers_middleware, )) .layer(axum_middleware::from_fn_with_state( state.clone(), middleware::auth_middleware, )) .layer(axum_middleware::from_fn_with_state( state.clone(), middleware::rate_limit_middleware, )) .with_state(state) } // Helper functions used by multiple modules pub(crate) async fn refresh_file_watcher(state: &AppState) { let config = state.config.read().await.clone(); if let Err(e) = crate::system::watcher::refresh_from_sources( state.file_watcher.as_ref(), state.db.as_ref(), &config, state.setup_required.load(Ordering::Relaxed), ) .await { error!("Failed to update file watcher: {}", e); } } pub(crate) async fn replace_runtime_hardware( state: &AppState, hardware_info: HardwareInfo, probe_log: HardwareProbeLog, ) { state.hardware_state.replace(Some(hardware_info)).await; *state.hardware_probe_log.write().await = probe_log; } pub(crate) fn config_write_blocked_response(config_path: &FsPath) -> Response { ( StatusCode::CONFLICT, format!( "Configuration updates are disabled (ALCHEMIST_CONFIG_MUTABLE=false). \ Set ALCHEMIST_CONFIG_MUTABLE=true and ensure {:?} is writable.", config_path ), ) .into_response() } pub(crate) fn config_save_error_to_response(config_path: &FsPath, err: &anyhow::Error) -> Response { if let Some(io_err) = err.downcast_ref::() { let read_only = io_err .to_string() .to_ascii_lowercase() .contains("read-only"); if io_err.kind() == std::io::ErrorKind::PermissionDenied || read_only { return ( StatusCode::CONFLICT, format!( "Configuration file {:?} is not writable: {}", config_path, io_err ), ) .into_response(); } } ( StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to save config at {:?}: {}", config_path, err), ) .into_response() } pub(crate) async fn save_config_or_response( state: &AppState, config: &Config, ) -> std::result::Result<(), Box> { if !state.config_mutable { return Err(Box::new(config_write_blocked_response(&state.config_path))); } if let Some(parent) = state.config_path.parent() { if !parent.as_os_str().is_empty() && !parent.exists() { if let Err(err) = std::fs::create_dir_all(parent) { return Err(config_save_error_to_response( &state.config_path, &anyhow::Error::new(err), ) .into()); } } } if let Err(err) = crate::settings::save_config_and_project( state.db.as_ref(), state.config_path.as_path(), config, ) .await { return Err(config_save_error_to_response( &state.config_path, &anyhow::Error::msg(err.to_string()), ) .into()); } Ok(()) } pub(crate) fn config_read_error_response(context: &str, err: &AlchemistError) -> Response { ( StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to {context}: {err}"), ) .into_response() } pub(crate) fn hardware_error_response(err: &AlchemistError) -> Response { let status = match err { AlchemistError::Config(_) | AlchemistError::Hardware(_) => StatusCode::BAD_REQUEST, _ => StatusCode::INTERNAL_SERVER_ERROR, }; (status, err.to_string()).into_response() } pub(crate) fn validate_transcode_payload( payload: &settings::TranscodeSettingsPayload, ) -> std::result::Result<(), &'static str> { if payload.concurrent_jobs == 0 { return Err("concurrent_jobs must be > 0"); } if !(0.0..=1.0).contains(&payload.size_reduction_threshold) { return Err("size_reduction_threshold must be 0.0-1.0"); } if payload.min_bpp_threshold < 0.0 { return Err("min_bpp_threshold must be >= 0.0"); } if payload.threads > 512 { return Err("threads must be <= 512"); } if !(50.0..=1000.0).contains(&payload.tonemap_peak) { return Err("tonemap_peak must be between 50 and 1000"); } if !(0.0..=1.0).contains(&payload.tonemap_desat) { return Err("tonemap_desat must be between 0.0 and 1.0"); } Ok(()) } pub(crate) fn canonicalize_directory_path( value: &str, field_name: &str, ) -> std::result::Result { let trimmed = value.trim(); if trimmed.is_empty() { return Err(format!("{field_name} must not be empty")); } if trimmed.contains('\0') { return Err(format!("{field_name} must not contain null bytes")); } let path = PathBuf::from(trimmed); if !path.is_dir() { return Err(format!("{field_name} must be an existing directory")); } fs::canonicalize(&path).map_err(|_| format!("{field_name} must be canonicalizable")) } pub(crate) fn normalize_optional_directory( value: Option<&str>, field_name: &str, ) -> std::result::Result, String> { let Some(value) = value else { return Ok(None); }; let trimmed = value.trim(); if trimmed.is_empty() { return Ok(None); } canonicalize_directory_path(trimmed, field_name) .map(|path| Some(path.to_string_lossy().to_string())) } pub(crate) fn normalize_optional_path( value: Option<&str>, field_name: &str, ) -> std::result::Result, String> { let Some(value) = value else { return Ok(None); }; let trimmed = value.trim(); if trimmed.is_empty() { return Ok(None); } if trimmed.contains('\0') { return Err(format!("{field_name} must not contain null bytes")); } if cfg!(target_os = "linux") { let path = PathBuf::from(trimmed); if !path.exists() { return Err(format!("{field_name} must exist")); } return fs::canonicalize(path) .map(|path| Some(path.to_string_lossy().to_string())) .map_err(|_| format!("{field_name} must be canonicalizable")); } Ok(Some(trimmed.to_string())) } pub(crate) fn is_row_not_found(err: &AlchemistError) -> bool { matches!(err, AlchemistError::Database(sqlx::Error::RowNotFound)) } pub(crate) fn has_path_separator(value: &str) -> bool { value.chars().any(|c| c == '/' || c == '\\') } pub(crate) fn normalize_schedule_time(value: &str) -> Option { let trimmed = value.trim(); let parts: Vec<&str> = trimmed.split(':').collect(); if parts.len() != 2 { return None; } let hour: u32 = parts[0].parse().ok()?; let minute: u32 = parts[1].parse().ok()?; if hour > 23 || minute > 59 { return None; } Some(format!("{:02}:{:02}", hour, minute)) } pub(crate) async fn validate_notification_url( raw: &str, allow_local: bool, ) -> std::result::Result<(), String> { let url = reqwest::Url::parse(raw).map_err(|_| "endpoint_url must be a valid URL".to_string())?; match url.scheme() { "http" | "https" => {} _ => return Err("endpoint_url must use http or https".to_string()), } if !url.username().is_empty() || url.password().is_some() { return Err("endpoint_url must not contain embedded credentials".to_string()); } if url.fragment().is_some() { return Err("endpoint_url must not include a URL fragment".to_string()); } let host = url .host_str() .ok_or_else(|| "endpoint_url must include a host".to_string())?; if !allow_local && host.eq_ignore_ascii_case("localhost") { return Err("endpoint_url host is not allowed".to_string()); } if let Ok(ip) = host.parse::() { if !allow_local && is_private_ip(ip) { return Err("endpoint_url host is not allowed".to_string()); } } else { let port = url .port_or_known_default() .ok_or_else(|| "endpoint_url must include a port".to_string())?; let host_port = format!("{}:{}", host, port); let mut resolved = false; let addrs = tokio::time::timeout(Duration::from_secs(3), lookup_host(host_port)) .await .map_err(|_| "endpoint_url host resolution timed out".to_string())? .map_err(|_| "endpoint_url host could not be resolved".to_string())?; for addr in addrs { resolved = true; if !allow_local && is_private_ip(addr.ip()) { return Err("endpoint_url host is not allowed".to_string()); } } if !resolved { return Err("endpoint_url host could not be resolved".to_string()); } } Ok(()) } fn is_private_ip(ip: IpAddr) -> bool { match ip { IpAddr::V4(v4) => { v4.is_private() || v4.is_loopback() || v4.is_link_local() || v4.is_multicast() || v4.is_unspecified() || v4.is_broadcast() } IpAddr::V6(v6) => { v6.is_loopback() || v6.is_unique_local() || v6.is_unicast_link_local() || v6.is_multicast() || v6.is_unspecified() } } } fn sanitize_asset_path(raw: &str) -> Option { let normalized = raw.replace('\\', "/"); let mut segments = Vec::new(); for segment in normalized.split('/') { if segment.is_empty() || segment == "." { continue; } if segment == ".." { return None; } segments.push(segment); } if segments.is_empty() { Some("index.html".to_string()) } else { Some(segments.join("/")) } } // Static asset handlers async fn index_handler() -> impl IntoResponse { static_handler(Uri::from_static("/index.html")).await } async fn static_handler(uri: Uri) -> impl IntoResponse { let raw_path = uri.path().trim_start_matches('/'); let path = match sanitize_asset_path(raw_path) { Some(path) => path, None => return StatusCode::NOT_FOUND.into_response(), }; if let Some(content) = load_static_asset(&path) { let mime = mime_guess::from_path(&path).first_or_octet_stream(); return ([(header::CONTENT_TYPE, mime.as_ref())], content).into_response(); } // Attempt to serve index.html for directory paths (e.g. /jobs -> jobs/index.html) if !path.contains('.') { let index_path = format!("{}/index.html", path); if let Some(content) = load_static_asset(&index_path) { let mime = mime_guess::from_path("index.html").first_or_octet_stream(); return ([(header::CONTENT_TYPE, mime.as_ref())], content).into_response(); } } if path == "index.html" { const MISSING_WEB_BUILD_PAGE: &str = r#" Alchemist UI Not Built

Alchemist UI is not built

The backend is running, but frontend assets are missing.

Run cd web && bun install && bun run build, then restart Alchemist.

"#; return ( StatusCode::SERVICE_UNAVAILABLE, [(header::CONTENT_TYPE, "text/html; charset=utf-8")], MISSING_WEB_BUILD_PAGE, ) .into_response(); } if !path.contains('.') { if let Some(content) = load_static_asset("404.html") { let mime = mime_guess::from_path("404.html").first_or_octet_stream(); return ( StatusCode::NOT_FOUND, [(header::CONTENT_TYPE, mime.as_ref())], content, ) .into_response(); } } // Default fallback to 404 for missing files. StatusCode::NOT_FOUND.into_response() }