AI-generate first working version of the app
This commit is contained in:
898
src/server.rs
Normal file
898
src/server.rs
Normal file
@@ -0,0 +1,898 @@
|
||||
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<Option<SystemInfo>>,
|
||||
is_jellyfin_online: RwLock<bool>,
|
||||
last_db_hash: RwLock<Option<String>>,
|
||||
last_activity: RwLock<Option<Instant>>,
|
||||
is_powering_on: RwLock<bool>,
|
||||
power_on_start_time: RwLock<Option<Instant>>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub async fn new(config: Config) -> Result<Arc<Self>> {
|
||||
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<Database> {
|
||||
// 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<Option<crate::database::User>> {
|
||||
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<Option<Device>> {
|
||||
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<Option<Device>> {
|
||||
let db = self.get_database_with_update_check().await?;
|
||||
db.get_device_by_device_id(device_id)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_app(state: Arc<AppState>) -> 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<Arc<AppState>>,
|
||||
method: Method,
|
||||
uri: Uri,
|
||||
headers: HeaderMap,
|
||||
body: Body,
|
||||
) -> Result<Response<Body>, 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<Arc<AppState>>,
|
||||
method: Method,
|
||||
uri: Uri,
|
||||
headers: HeaderMap,
|
||||
body: Body,
|
||||
) -> Result<Response<Body>, 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<Arc<AppState>>,
|
||||
method: Method,
|
||||
uri: Uri,
|
||||
headers: HeaderMap,
|
||||
body: Body,
|
||||
) -> Result<Response<Body>, 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<Arc<AppState>>,
|
||||
method: Method,
|
||||
uri: Uri,
|
||||
headers: HeaderMap,
|
||||
body: Body,
|
||||
) -> Result<Response<Body>, 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<Arc<AppState>>,
|
||||
method: Method,
|
||||
uri: Uri,
|
||||
headers: HeaderMap,
|
||||
body: Body,
|
||||
) -> Result<Response<Body>, 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<AppState>) -> 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<AppState>) -> 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<WebSocketUpgrade>,
|
||||
State(state): State<Arc<AppState>>,
|
||||
method: Method,
|
||||
uri: Uri,
|
||||
headers: HeaderMap,
|
||||
body: Body,
|
||||
) -> Result<Response<Body>, 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<AppState>,
|
||||
uri: Uri,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Response<Body>, 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<WebSocketUpgrade>,
|
||||
State(state): State<Arc<AppState>>,
|
||||
method: Method,
|
||||
uri: Uri,
|
||||
headers: HeaderMap,
|
||||
body: Body,
|
||||
) -> Result<Response<Body>, 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<AppState>,
|
||||
headers: &HeaderMap,
|
||||
query: &Option<String>,
|
||||
) -> 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<AppState>) -> 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)
|
||||
}
|
Reference in New Issue
Block a user