899 lines
31 KiB
Rust
899 lines
31 KiB
Rust
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)
|
|
}
|