use crate::{ auth::{parse_authorization_header, verify_password, AuthRequest}, config::Config, database::{Database, Device}, jellyfin::{power_on_server, BrandingConfig, JellyfinClient, SystemInfo}, proxy::proxy_to_jellyfin_with_retry, sftp::{calculate_local_file_hash, SftpClient}, websocket::proxy_websocket, }; use anyhow::{anyhow, Result}; use axum::{ body::Body, extract::{ws::WebSocketUpgrade, State}, http::{HeaderMap, Method, StatusCode, Uri}, response::{IntoResponse, Response}, routing::any, Router, }; use std::{ collections::HashMap, path::Path as StdPath, sync::{Arc, RwLock}, time::{Duration, Instant}, }; use tokio::time::sleep; use tracing::{debug, error, info, warn}; const LOCAL_DB_PATH: &str = "./jellyfin.db"; const SYSTEM_INFO_PATH: &str = "./system_info.json"; // Embedded login HTML content at build time const LOGIN_HTML: &str = include_str!("../login.html"); pub struct AppState { config: Config, jellyfin_client: JellyfinClient, sftp_client: SftpClient, cached_system_info: RwLock>, is_jellyfin_online: RwLock, last_db_hash: RwLock>, last_activity: RwLock>, is_powering_on: RwLock, power_on_start_time: RwLock>, } impl AppState { pub async fn new(config: Config) -> Result> { let jellyfin_client = JellyfinClient::new(config.jellyfin_url.clone(), config.jellyfin_api_key.clone()); let sftp_client = SftpClient::new( config.sftp_host.clone(), config.sftp_port, config.sftp_user.clone(), config.sftp_password.clone(), ); // Try to load cached system info let cached_system_info = if StdPath::new(SYSTEM_INFO_PATH).exists() { match std::fs::read_to_string(SYSTEM_INFO_PATH) { Ok(content) => serde_json::from_str(&content).ok(), Err(_) => None, } } else { None }; // Initial database download if let Err(e) = sftp_client.download_file(&config.sftp_path, LOCAL_DB_PATH).await { warn!("Failed to download initial database: {}", e); } let app_state = Self { config, jellyfin_client, sftp_client, cached_system_info: RwLock::new(cached_system_info), is_jellyfin_online: RwLock::new(false), last_db_hash: RwLock::new(None), last_activity: RwLock::new(None), is_powering_on: RwLock::new(false), power_on_start_time: RwLock::new(None), }; // Initial status check app_state.update_jellyfin_status().await?; // Start background database update checker let state_clone = Arc::new(app_state); let checker_state = state_clone.clone(); tokio::spawn(async move { let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(30)); // Check every 30 seconds loop { interval.tick().await; if let Err(e) = checker_state.check_database_updates().await { warn!("Failed to check database updates: {}", e); } } }); Ok(state_clone) } pub async fn update_jellyfin_status(&self) -> Result<()> { let is_online = self.jellyfin_client.is_online().await; *self.is_jellyfin_online.write().unwrap() = is_online; if is_online { // Update system info cache if let Ok(system_info) = self.jellyfin_client.get_system_info().await { *self.cached_system_info.write().unwrap() = Some(system_info.clone()); // Save to file if let Ok(json_str) = serde_json::to_string_pretty(&system_info) { let _ = std::fs::write(SYSTEM_INFO_PATH, json_str); } } } Ok(()) } pub async fn check_database_updates(&self) -> Result<()> { match self.sftp_client.get_file_hash(&self.config.sftp_path).await { Ok(remote_hash) => { let current_hash = if StdPath::new(LOCAL_DB_PATH).exists() { calculate_local_file_hash(LOCAL_DB_PATH).ok() } else { None }; let last_hash = self.last_db_hash.read().unwrap().clone(); if Some(&remote_hash) != last_hash.as_ref() || current_hash.as_ref() != Some(&remote_hash) { info!("Database hash changed, downloading new version"); if let Err(e) = self.sftp_client.download_file(&self.config.sftp_path, LOCAL_DB_PATH).await { error!("Failed to download updated database: {}", e); } else { *self.last_db_hash.write().unwrap() = Some(remote_hash); info!("Database updated successfully"); } } } Err(e) => { warn!("Failed to check remote database hash: {}", e); } } Ok(()) } pub fn is_online(&self) -> bool { *self.is_jellyfin_online.read().unwrap() } pub fn is_powering_on(&self) -> bool { *self.is_powering_on.read().unwrap() } pub fn is_power_on_timeout(&self) -> bool { if let Some(start_time) = *self.power_on_start_time.read().unwrap() { start_time.elapsed() > Duration::from_secs(300) // 5 minutes } else { false } } pub async fn update_and_check_status(&self) -> bool { let is_online = self.jellyfin_client.is_online().await; *self.is_jellyfin_online.write().unwrap() = is_online; if is_online { // Reset power-on state if server came online *self.is_powering_on.write().unwrap() = false; *self.power_on_start_time.write().unwrap() = None; } is_online } pub fn update_activity(&self) { *self.last_activity.write().unwrap() = Some(Instant::now()); } // Check for database updates on every authentication-related operation async fn get_database_with_update_check(&self) -> Result { // Check for updates first if let Err(e) = self.check_database_updates().await { warn!("Failed to check database updates during access: {}", e); } if !StdPath::new(LOCAL_DB_PATH).exists() { return Err(anyhow!("Local database not found")); } Database::new(LOCAL_DB_PATH) } async fn authenticate_user(&self, username: &str, password: &str) -> Result> { let db = self.get_database_with_update_check().await?; if let Some(user) = db.get_user_by_username(username)? { if verify_password(password, &user.password)? { return Ok(Some(user)); } } Ok(None) } async fn validate_token(&self, token: &str) -> Result> { let db = self.get_database_with_update_check().await?; db.get_device_by_access_token(token) } async fn validate_device_id(&self, device_id: &str) -> Result> { let db = self.get_database_with_update_check().await?; db.get_device_by_device_id(device_id) } } pub fn create_app(state: Arc) -> Router { Router::new() .route("/", any(handle_root_request)) .route("/web", any(handle_web_request)) .route("/web/", any(handle_web_request)) .route("/web/*path", any(handle_web_request)) .route("/Users/AuthenticateByName", any(handle_auth_request)) .route("/System/Info/Public", any(handle_system_info_request)) .route("/Branding/Configuration", any(handle_branding_request)) .fallback(handle_fallback_request) .with_state(state) } async fn handle_root_request( State(state): State>, method: Method, uri: Uri, headers: HeaderMap, body: Body, ) -> Result, StatusCode> { state.update_activity(); // Check authentication first let is_authenticated = check_authentication(&state, &headers, &uri.query().map(|q| q.to_string())).await; // If server is offline but user is authenticated, power it on and wait if !state.is_online() && is_authenticated { ensure_server_online_for_authenticated_request(&state).await?; } // If server is online (either was online or just came online), proxy the request to Jellyfin if state.is_online() { let path = uri.path(); let query = uri.query().map(|q| q.to_string()); let body_bytes = match axum::body::to_bytes(body, usize::MAX).await { Ok(bytes) => bytes.to_vec(), Err(e) => { error!("Failed to read request body: {}", e); return Err(StatusCode::BAD_REQUEST); } }; return proxy_to_jellyfin_with_retry( method, path, query, headers, body_bytes, &state.jellyfin_client, { let state_clone = state.clone(); move || { let state_clone = state_clone.clone(); Box::pin(async move { state_clone.update_and_check_status().await }) } } ).await; } // If we reach here, server is offline and user is not authenticated // Serve login page only for GET requests if method == Method::GET { Ok(Response::builder() .status(StatusCode::OK) .header("content-type", "text/html") .body(Body::from(LOGIN_HTML)) .unwrap()) } else { Err(StatusCode::UNAUTHORIZED) } } async fn handle_web_request( State(state): State>, method: Method, uri: Uri, headers: HeaderMap, body: Body, ) -> Result, StatusCode> { state.update_activity(); // Check authentication first let is_authenticated = check_authentication(&state, &headers, &uri.query().map(|q| q.to_string())).await; // If server is offline but user is authenticated, power it on and wait if !state.is_online() && is_authenticated { ensure_server_online_for_authenticated_request(&state).await?; } // If server is online (either was online or just came online), proxy the request to Jellyfin if state.is_online() { let path = uri.path(); let query = uri.query().map(|q| q.to_string()); let body_bytes = match axum::body::to_bytes(body, usize::MAX).await { Ok(bytes) => bytes.to_vec(), Err(e) => { error!("Failed to read request body: {}", e); return Err(StatusCode::BAD_REQUEST); } }; return proxy_to_jellyfin_with_retry( method, path, query, headers, body_bytes, &state.jellyfin_client, { let state_clone = state.clone(); move || { let state_clone = state_clone.clone(); Box::pin(async move { state_clone.update_and_check_status().await }) } } ).await; } // If we reach here, server is offline and user is not authenticated // Serve login page only for GET requests if method == Method::GET { Ok(Response::builder() .status(StatusCode::OK) .header("content-type", "text/html") .body(Body::from(LOGIN_HTML)) .unwrap()) } else { Err(StatusCode::UNAUTHORIZED) } } async fn handle_auth_request( State(state): State>, method: Method, uri: Uri, headers: HeaderMap, body: Body, ) -> Result, StatusCode> { state.update_activity(); // Check authentication first (for existing session tokens) let is_authenticated = check_authentication(&state, &headers, &uri.query().map(|q| q.to_string())).await; // If server is offline but user is authenticated, power it on and wait if !state.is_online() && is_authenticated { ensure_server_online_for_authenticated_request(&state).await?; } // If server is online, proxy the request to Jellyfin if state.is_online() { let path = uri.path(); let query = uri.query().map(|q| q.to_string()); let body_bytes = match axum::body::to_bytes(body, usize::MAX).await { Ok(bytes) => bytes.to_vec(), Err(e) => { error!("Failed to read request body: {}", e); return Err(StatusCode::BAD_REQUEST); } }; return proxy_to_jellyfin_with_retry( method, path, query, headers, body_bytes, &state.jellyfin_client, { let state_clone = state.clone(); move || { let state_clone = state_clone.clone(); Box::pin(async move { state_clone.update_and_check_status().await }) } } ).await; } // If server is offline, handle authentication locally if method == Method::POST { let body_bytes = match axum::body::to_bytes(body, usize::MAX).await { Ok(bytes) => bytes.to_vec(), Err(e) => { error!("Failed to read request body: {}", e); return Err(StatusCode::BAD_REQUEST); } }; let auth_request: AuthRequest = match serde_json::from_slice(&body_bytes) { Ok(req) => req, Err(e) => { error!("Failed to parse auth request: {}", e); return Err(StatusCode::BAD_REQUEST); } }; // Validate credentials locally first - don't start server if invalid match state.authenticate_user(&auth_request.username, &auth_request.pw).await { Ok(Some(_user)) => { info!("Credentials validated locally, starting server and proxying to Jellyfin"); // User is valid, power on server and wait for it to come online ensure_server_online_for_authenticated_request(&state).await?; // Once server is online, proxy the original request to Jellyfin for real auth response if state.is_online() { let path = uri.path(); let query = uri.query().map(|q| q.to_string()); return proxy_to_jellyfin_with_retry( method, path, query, headers, body_bytes, &state.jellyfin_client, { let state_clone = state.clone(); move || { let state_clone = state_clone.clone(); Box::pin(async move { state_clone.update_and_check_status().await }) } } ).await; } else { error!("Server failed to come online after authentication"); Err(StatusCode::SERVICE_UNAVAILABLE) } } Ok(None) => { warn!("Authentication failed for user: {} - not starting server", auth_request.username); Err(StatusCode::UNAUTHORIZED) } Err(e) => { error!("Database error during authentication: {}", e); Err(StatusCode::INTERNAL_SERVER_ERROR) } } } else { Err(StatusCode::METHOD_NOT_ALLOWED) } } async fn handle_system_info_request( State(state): State>, method: Method, uri: Uri, headers: HeaderMap, body: Body, ) -> Result, StatusCode> { state.update_activity(); // Check authentication first let is_authenticated = check_authentication(&state, &headers, &uri.query().map(|q| q.to_string())).await; // If server is offline but user is authenticated, power it on if !state.is_online() && is_authenticated { ensure_server_online_for_authenticated_request(&state).await?; } // If server is online, proxy the request to Jellyfin if state.is_online() { let path = uri.path(); let query = uri.query().map(|q| q.to_string()); let body_bytes = match axum::body::to_bytes(body, usize::MAX).await { Ok(bytes) => bytes.to_vec(), Err(e) => { error!("Failed to read request body: {}", e); return Err(StatusCode::BAD_REQUEST); } }; return proxy_to_jellyfin_with_retry( method, path, query, headers, body_bytes, &state.jellyfin_client, { let state_clone = state.clone(); move || { let state_clone = state_clone.clone(); Box::pin(async move { state_clone.update_and_check_status().await }) } } ).await; } // If server is offline, return cached system info (this endpoint is usually public) let system_info = get_system_info_impl(state).await; let json_body = serde_json::to_string(&system_info).unwrap(); Ok(Response::builder() .status(StatusCode::OK) .header("content-type", "application/json") .body(Body::from(json_body)) .unwrap()) } async fn handle_branding_request( State(state): State>, method: Method, uri: Uri, headers: HeaderMap, body: Body, ) -> Result, StatusCode> { state.update_activity(); // Check authentication first let is_authenticated = check_authentication(&state, &headers, &uri.query().map(|q| q.to_string())).await; // If server is offline but user is authenticated, power it on if !state.is_online() && is_authenticated { ensure_server_online_for_authenticated_request(&state).await?; } // If server is online, proxy the request to Jellyfin if state.is_online() { let path = uri.path(); let query = uri.query().map(|q| q.to_string()); let body_bytes = match axum::body::to_bytes(body, usize::MAX).await { Ok(bytes) => bytes.to_vec(), Err(e) => { error!("Failed to read request body: {}", e); return Err(StatusCode::BAD_REQUEST); } }; return proxy_to_jellyfin_with_retry( method, path, query, headers, body_bytes, &state.jellyfin_client, { let state_clone = state.clone(); move || { let state_clone = state_clone.clone(); Box::pin(async move { state_clone.update_and_check_status().await }) } } ).await; } // If server is offline, return offline branding config (this endpoint is usually public) let branding_config = get_branding_config_impl(state).await; let json_body = serde_json::to_string(&branding_config).unwrap(); Ok(Response::builder() .status(StatusCode::OK) .header("content-type", "application/json") .body(Body::from(json_body)) .unwrap()) } async fn get_system_info_impl(state: Arc) -> SystemInfo { if state.is_online() { // Try to get fresh info from Jellyfin if let Ok(info) = state.jellyfin_client.get_system_info().await { return info; } } // Return cached info or default let cached_info = state.cached_system_info.read().unwrap(); if let Some(mut info) = cached_info.clone() { if !state.is_online() { info.server_name = format!("{} (Offline)", info.server_name); } info } else { SystemInfo { local_address: "http://localhost:8096".to_string(), server_name: "Jellyfin Server (Offline)".to_string(), version: "10.10.6".to_string(), product_name: "Jellyfin Server".to_string(), operating_system: "".to_string(), id: "unknown".to_string(), startup_wizard_completed: true, } } } async fn get_branding_config_impl(state: Arc) -> BrandingConfig { if state.is_online() { if let Ok(config) = state.jellyfin_client.get_branding_config().await { return config; } } BrandingConfig { login_disclaimer: "This server is currently offline. Log-in to start the server.".to_string(), custom_css: "".to_string(), splashscreen_enabled: true, } } async fn handle_fallback_request( ws: Option, State(state): State>, method: Method, uri: Uri, headers: HeaderMap, body: Body, ) -> Result, StatusCode> { // Check if this is a WebSocket upgrade request if let Some(ws_upgrade) = ws { return handle_websocket_request(ws_upgrade, state, uri, headers).await; } // Handle as regular HTTP request handle_proxy_request(None, State(state), method, uri, headers, body).await } async fn handle_websocket_request( ws_upgrade: WebSocketUpgrade, state: Arc, uri: Uri, headers: HeaderMap, ) -> Result, StatusCode> { state.update_activity(); let path = uri.path().to_string(); let query = uri.query().map(|q| q.to_string()); // Check authentication for WebSocket connections let is_authenticated = check_authentication(&state, &headers, &query).await; // If server is offline but user is authenticated, power it on if !state.is_online() && is_authenticated { ensure_server_online_for_authenticated_request(&state).await?; } // Check if server is online for WebSocket connections if !state.is_online() { // For WebSocket connections when offline, we need authentication if !is_authenticated { return Err(StatusCode::UNAUTHORIZED); } } // Handle WebSocket upgrade let jellyfin_url = state.jellyfin_client.get_base_url().to_string(); let query_str = query.clone(); let headers_clone = headers.clone(); Ok(ws_upgrade.on_upgrade(move |socket| async move { proxy_websocket(socket, &jellyfin_url, &path, query_str.as_deref(), &headers_clone).await; }).into_response()) } async fn handle_proxy_request( _ws: Option, State(state): State>, method: Method, uri: Uri, headers: HeaderMap, body: Body, ) -> Result, StatusCode> { state.update_activity(); let path = uri.path(); let query = uri.query().map(|q| q.to_string()); // Check authentication for all requests let is_authenticated = check_authentication(&state, &headers, &query).await; // If server is offline but user is authenticated, power it on if !state.is_online() && is_authenticated { ensure_server_online_for_authenticated_request(&state).await?; } // Handle regular HTTP requests let body_bytes = match axum::body::to_bytes(body, usize::MAX).await { Ok(bytes) => bytes.to_vec(), Err(e) => { error!("Failed to read request body: {}", e); return Err(StatusCode::BAD_REQUEST); } }; if !state.is_online() && !is_authenticated { return Err(StatusCode::UNAUTHORIZED); } // Proxy to Jellyfin proxy_to_jellyfin_with_retry( method, path, query, headers, body_bytes, &state.jellyfin_client, { let state_clone = state.clone(); move || { let state_clone = state_clone.clone(); Box::pin(async move { state_clone.update_and_check_status().await }) } } ).await } async fn check_authentication( state: &Arc, headers: &HeaderMap, query: &Option, ) -> bool { // Check for API key in query parameters if let Some(query_str) = query { let params: HashMap<_, _> = url::form_urlencoded::parse(query_str.as_bytes()).collect(); if let Some(api_key) = params.get("api_key") { if let Ok(Some(_)) = state.validate_token(api_key).await { debug!("Valid API key found in query parameters"); return true; } } if let Some(device_id) = params.get("deviceId").or_else(|| params.get("DeviceId")) { if let Ok(Some(_)) = state.validate_device_id(device_id).await { debug!("Valid device ID found in query parameters"); return true; } } } // Check authorization header if let Some(auth_header) = headers.get("authorization") { if let Ok(header_str) = auth_header.to_str() { debug!("Checking authorization header: {}", header_str); // First, try to extract token from MediaBrowser header if header_str.starts_with("MediaBrowser ") && header_str.contains("Token=") { if let Some(token_start) = header_str.find("Token=\"") { let token_start = token_start + 7; // Skip 'Token="' if let Some(token_end) = header_str[token_start..].find('"') { let token = &header_str[token_start..token_start + token_end]; debug!("Extracted token from header: {}", token); if let Ok(Some(_)) = state.validate_token(token).await { debug!("Valid token found in authorization header"); return true; } else { debug!("Token validation failed for: {}", token); } } } else if let Some(token_start) = header_str.find("Token=") { // Handle case without quotes around token value let token_start = token_start + 6; // Skip 'Token=' let token_end = header_str[token_start..].find(',').or_else(|| header_str[token_start..].find(' ')).unwrap_or(header_str.len() - token_start); let token = &header_str[token_start..token_start + token_end].trim_matches('"'); debug!("Extracted token from header (no quotes): {}", token); if let Ok(Some(_)) = state.validate_token(token).await { debug!("Valid token found in authorization header (no quotes)"); return true; } else { debug!("Token validation failed for: {}", token); } } } // Also try to parse device ID from header and validate it if let Some((_, _, device_id, _)) = parse_authorization_header(header_str) { // URL decode the device ID since it might be encoded if let Ok(decoded_device_id) = urlencoding::decode(&device_id) { debug!("Checking device ID: {}", decoded_device_id); if let Ok(Some(_)) = state.validate_device_id(&decoded_device_id).await { debug!("Valid device ID found in authorization header"); return true; } } // Also try the non-decoded version if let Ok(Some(_)) = state.validate_device_id(&device_id).await { debug!("Valid device ID found in authorization header (non-decoded)"); return true; } } } } debug!("No valid authentication found"); false } async fn ensure_server_online_for_authenticated_request(state: &Arc) -> Result<(), StatusCode> { // If server is already online, nothing to do if state.is_online() { return Ok(()); } // Check if we're already powering on if state.is_powering_on() { // Check if power-on has timed out if state.is_power_on_timeout() { error!("Server power-on timed out, resetting power-on state"); *state.is_powering_on.write().unwrap() = false; *state.power_on_start_time.write().unwrap() = None; return Err(StatusCode::SERVICE_UNAVAILABLE); } // Wait for server to come online or timeout let max_wait = Duration::from_secs(300); // 5 minutes let start_check = Instant::now(); info!("Server is being powered on, waiting for it to come online..."); while start_check.elapsed() < max_wait { // Check if server came online if state.jellyfin_client.is_online().await { *state.is_jellyfin_online.write().unwrap() = true; *state.is_powering_on.write().unwrap() = false; *state.power_on_start_time.write().unwrap() = None; info!("Server came online successfully"); return Ok(()); } // Check if power-on process timed out if state.is_power_on_timeout() { error!("Server power-on timed out while waiting"); *state.is_powering_on.write().unwrap() = false; *state.power_on_start_time.write().unwrap() = None; return Err(StatusCode::SERVICE_UNAVAILABLE); } sleep(tokio::time::Duration::from_secs(5)).await; } error!("Timed out waiting for server to come online"); return Err(StatusCode::SERVICE_UNAVAILABLE); } // Start power-on process info!("Server is offline but user is authenticated, powering on"); *state.is_powering_on.write().unwrap() = true; *state.power_on_start_time.write().unwrap() = Some(Instant::now()); if let Err(e) = power_on_server(&state.config.jellyfin_power_on_command).await { error!("Failed to power on server: {}", e); *state.is_powering_on.write().unwrap() = false; *state.power_on_start_time.write().unwrap() = None; return Err(StatusCode::SERVICE_UNAVAILABLE); } // Wait for server to come online let max_wait = Duration::from_secs(300); // 5 minutes let start_time = Instant::now(); while start_time.elapsed() < max_wait { if state.jellyfin_client.is_online().await { *state.is_jellyfin_online.write().unwrap() = true; *state.is_powering_on.write().unwrap() = false; *state.power_on_start_time.write().unwrap() = None; info!("Server came online successfully"); return Ok(()); } sleep(tokio::time::Duration::from_secs(5)).await; } error!("Server failed to come online within timeout"); *state.is_powering_on.write().unwrap() = false; *state.power_on_start_time.write().unwrap() = None; Err(StatusCode::SERVICE_UNAVAILABLE) }