use axum::{ extract::{Path, Query, State, Multipart, Json}, http::HeaderMap, }; use serde::{Deserialize, Serialize}; use crate::{ error::AppError, handlers::{extract_api_key, validate_api_key, check_user_access}, models::{AvailableLanguage, LanguageUpdateRequest, UserLanguageResponse, AvailableLanguagesResponse}, AppState, }; use sqlx::{Row, ValueRef}; // Request struct for set_theme #[derive(Deserialize)] pub struct SetThemeRequest { pub user_id: i32, pub new_theme: String, } // Request struct for set_playback_speed - matches Python SetPlaybackSpeedUser model exactly #[derive(Deserialize)] pub struct SetPlaybackSpeedUser { pub user_id: i32, pub playback_speed: f64, } // Set user theme - matches Python api_set_theme function exactly pub async fn set_theme( State(state): State, headers: HeaderMap, Json(request): Json, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; // Check authorization - web key or user can only set their own theme let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; let is_web_key = state.db_pool.is_web_key(&api_key).await?; if key_id != request.user_id && !is_web_key { return Err(AppError::forbidden("You can only set your own theme!")); } state.db_pool.set_theme(request.user_id, &request.new_theme).await?; Ok(Json(serde_json::json!({ "message": "Theme updated successfully" }))) } // Set user playback speed - matches Python api_set_playback_speed_user function exactly pub async fn set_playback_speed_user( State(state): State, headers: HeaderMap, Json(request): Json, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; // Check authorization - web key or user can only set their own playback speed let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; let is_web_key = state.db_pool.is_web_key(&api_key).await?; if key_id != request.user_id && !is_web_key { return Err(AppError::forbidden("You can only modify your own settings.")); } state.db_pool.set_playback_speed_user(request.user_id, request.playback_speed).await?; Ok(Json(serde_json::json!({ "detail": "Default playback speed updated." }))) } // User info response struct #[derive(Serialize)] pub struct UserInfo { pub userid: i32, pub fullname: String, pub username: String, pub email: String, #[serde(serialize_with = "bool_to_int")] pub isadmin: bool, } // Helper function to serialize boolean as integer for Python compatibility fn bool_to_int(value: &bool, serializer: S) -> Result where S: serde::Serializer, { serializer.serialize_i32(if *value { 1 } else { 0 }) } // Get all users info - matches Python api_get_user_info function exactly (admin only) pub async fn get_user_info( State(state): State, headers: HeaderMap, ) -> Result>, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; // Check if user is admin (matches Python check_if_admin dependency) let user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; let is_admin = state.db_pool.user_admin_check(user_id).await?; if !is_admin { return Err(AppError::forbidden("Admin access required")); } let user_info = state.db_pool.get_user_info().await?; Ok(Json(user_info)) } // Get specific user info - matches Python api_get_my_user_info function exactly pub async fn get_my_user_info( State(state): State, Path(user_id): Path, headers: HeaderMap, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; // Check authorization - web key or user can only get their own info let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; let is_web_key = state.db_pool.is_web_key(&api_key).await?; if key_id != user_id && !is_web_key { return Err(AppError::forbidden("You can only retrieve your own user information!")); } let user_info = state.db_pool.get_my_user_info(user_id).await?; match user_info { Some(info) => Ok(Json(info)), None => Err(AppError::not_found("User not found")), } } // Request struct for add_user #[derive(Deserialize)] pub struct AddUserRequest { pub fullname: String, pub username: String, pub email: String, pub hash_pw: String, } // Add user - matches Python api_add_user function exactly (admin only) pub async fn add_user( State(state): State, headers: HeaderMap, Json(user_values): Json, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; // Check if user is admin (matches Python check_if_admin dependency) let requesting_user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; let is_admin = state.db_pool.user_admin_check(requesting_user_id).await?; if !is_admin { return Err(AppError::forbidden("Admin access required")); } match state.db_pool.add_user(&user_values.fullname, &user_values.username.to_lowercase(), &user_values.email, &user_values.hash_pw).await { Ok(user_id) => Ok(Json(serde_json::json!({ "detail": "Success", "user_id": user_id }))), Err(e) => { let error_msg = format!("{}", e); if error_msg.contains("username") && error_msg.contains("duplicate") { Err(AppError::Conflict("This username is already taken. Please choose a different username.".to_string())) } else if error_msg.contains("email") && error_msg.contains("duplicate") { Err(AppError::Conflict("This email is already in use. Please use a different email address.".to_string())) } else { Err(AppError::internal("Failed to create user")) } } } } // Add login user - matches Python api_add_user (add_login_user endpoint) function exactly (self-service) pub async fn add_login_user( State(state): State, Json(user_values): Json, ) -> Result, AppError> { // Check if self-service user registration is enabled (matches Python check_self_service) let self_service_status = state.db_pool.self_service_status().await?; if !self_service_status.status { return Err(AppError::forbidden("Your API key is either invalid or does not have correct permission")); } match state.db_pool.add_user(&user_values.fullname, &user_values.username.to_lowercase(), &user_values.email, &user_values.hash_pw).await { Ok(user_id) => Ok(Json(serde_json::json!({ "detail": "User added successfully", "user_id": user_id }))), Err(e) => { let error_msg = format!("{}", e); if error_msg.contains("username") && error_msg.contains("duplicate") { Err(AppError::Conflict("This username is already taken. Please choose a different username.".to_string())) } else if error_msg.contains("email") && error_msg.contains("duplicate") { Err(AppError::Conflict("This email address is already registered. Please use a different email.".to_string())) } else { Err(AppError::internal("An unexpected error occurred while creating the user")) } } } } // Set fullname - matches Python api_set_fullname function exactly pub async fn set_fullname( State(state): State, Path(user_id): Path, Query(params): Query>, headers: HeaderMap, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; let new_name = params.get("new_name") .ok_or_else(|| AppError::bad_request("Missing new_name parameter"))?; // Check authorization - admins can edit other users, users can edit themselves let user_id_from_api_key = state.db_pool.get_user_id_from_api_key(&api_key).await?; let is_admin = state.db_pool.user_admin_check(user_id_from_api_key).await?; if user_id != user_id_from_api_key && !is_admin { return Err(AppError::forbidden("You can only update your own full name")); } state.db_pool.set_fullname(user_id, new_name).await?; Ok(Json(serde_json::json!({ "detail": "Fullname updated." }))) } // Request struct for set_password #[derive(Deserialize)] pub struct PasswordUpdateRequest { pub hash_pw: String, } // Set password - matches Python api_set_password function exactly pub async fn set_password( State(state): State, Path(user_id): Path, headers: HeaderMap, Json(request): Json, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; // Check authorization - admins can edit other users, users can edit themselves let user_id_from_api_key = state.db_pool.get_user_id_from_api_key(&api_key).await?; let is_admin = state.db_pool.user_admin_check(user_id_from_api_key).await?; if user_id != user_id_from_api_key && !is_admin { return Err(AppError::forbidden("You can only update your own password")); } state.db_pool.set_password(user_id, &request.hash_pw).await?; Ok(Json(serde_json::json!({ "detail": "Password updated." }))) } // Delete user - matches Python api_delete_user function exactly (admin only) pub async fn delete_user( State(state): State, Path(user_id): Path, headers: HeaderMap, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; // Check if user is admin (matches Python check_if_admin dependency) let requesting_user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; let is_admin = state.db_pool.user_admin_check(requesting_user_id).await?; if !is_admin { return Err(AppError::forbidden("Admin access required")); } state.db_pool.delete_user(user_id).await?; Ok(Json(serde_json::json!({ "status": "User deleted" }))) } // Request struct for set_email #[derive(Deserialize)] pub struct SetEmailRequest { pub user_id: i32, pub new_email: String, } // Set email - matches Python api_set_email function exactly pub async fn set_email( State(state): State, headers: HeaderMap, Json(request): Json, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; // Check authorization - admins can edit other users, users can edit themselves let user_id_from_api_key = state.db_pool.get_user_id_from_api_key(&api_key).await?; let is_admin = state.db_pool.user_admin_check(user_id_from_api_key).await?; if request.user_id != user_id_from_api_key && !is_admin { return Err(AppError::forbidden("You can only update your own email")); } state.db_pool.set_email(request.user_id, &request.new_email).await?; Ok(Json(serde_json::json!({ "detail": "Email updated." }))) } // Request struct for set_username #[derive(Deserialize)] pub struct SetUsernameRequest { pub user_id: i32, pub new_username: String, } // Set username - matches Python api_set_username function exactly pub async fn set_username( State(state): State, headers: HeaderMap, Json(request): Json, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; // Check authorization - admins can edit other users, users can edit themselves let user_id_from_api_key = state.db_pool.get_user_id_from_api_key(&api_key).await?; let is_admin = state.db_pool.user_admin_check(user_id_from_api_key).await?; if request.user_id != user_id_from_api_key && !is_admin { return Err(AppError::forbidden("You can only update your own username")); } state.db_pool.set_username(request.user_id, &request.new_username.to_lowercase()).await?; Ok(Json(serde_json::json!({ "detail": "Username updated." }))) } // Request struct for set_isadmin #[derive(Deserialize)] pub struct SetIsAdminRequest { pub user_id: i32, pub isadmin: bool, } // Set isadmin - matches Python api_set_isadmin function exactly (admin only) pub async fn set_isadmin( State(state): State, headers: HeaderMap, Json(request): Json, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; // Check if user is admin (matches Python check_if_admin dependency) let requesting_user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; let is_admin = state.db_pool.user_admin_check(requesting_user_id).await?; if !is_admin { return Err(AppError::forbidden("Admin access required")); } state.db_pool.set_isadmin(request.user_id, request.isadmin).await?; Ok(Json(serde_json::json!({ "detail": "IsAdmin status updated." }))) } // Final admin check - matches Python api_final_admin function exactly (admin only) pub async fn final_admin( State(state): State, Path(user_id): Path, headers: HeaderMap, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; // Check if user is admin (matches Python check_if_admin dependency) let requesting_user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; let is_admin = state.db_pool.user_admin_check(requesting_user_id).await?; if !is_admin { return Err(AppError::forbidden("Admin access required")); } let is_final_admin = state.db_pool.final_admin(user_id).await?; Ok(Json(serde_json::json!({ "final_admin": is_final_admin }))) } // Enable/disable guest - matches Python api_enable_disable_guest function exactly (admin only) pub async fn enable_disable_guest( State(state): State, headers: HeaderMap, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; // Check if user is admin (matches Python check_if_admin dependency) let requesting_user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; let is_admin = state.db_pool.user_admin_check(requesting_user_id).await?; if !is_admin { return Err(AppError::forbidden("Admin access required")); } state.db_pool.enable_disable_guest().await?; Ok(Json(serde_json::json!({ "success": true }))) } // Enable/disable downloads - matches Python api_enable_disable_downloads function exactly (admin only) pub async fn enable_disable_downloads( State(state): State, headers: HeaderMap, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; // Check if user is admin (matches Python check_if_admin dependency) let requesting_user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; let is_admin = state.db_pool.user_admin_check(requesting_user_id).await?; if !is_admin { return Err(AppError::forbidden("Admin access required")); } state.db_pool.enable_disable_downloads().await?; Ok(Json(serde_json::json!({ "success": true }))) } // Enable/disable self service - matches Python api_enable_disable_self_service function exactly (admin only) pub async fn enable_disable_self_service( State(state): State, headers: HeaderMap, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; // Check if user is admin (matches Python check_if_admin dependency) let requesting_user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; let is_admin = state.db_pool.user_admin_check(requesting_user_id).await?; if !is_admin { return Err(AppError::forbidden("Admin access required")); } state.db_pool.enable_disable_self_service().await?; Ok(Json(serde_json::json!({ "success": true }))) } // Get guest status - matches Python api_guest_status function exactly pub async fn guest_status( State(state): State, headers: HeaderMap, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; let result = state.db_pool.guest_status().await?; Ok(Json(result)) } // Get RSS feed status - matches Python get_rss_feed_status function exactly pub async fn rss_feed_status( State(state): State, headers: HeaderMap, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; let user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; let result = state.db_pool.get_rss_feed_status(user_id).await?; Ok(Json(result)) } // Toggle RSS feeds - matches Python toggle_rss_feeds function exactly pub async fn toggle_rss_feeds( State(state): State, headers: HeaderMap, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; let user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; let new_status = state.db_pool.toggle_rss_feeds(user_id).await?; Ok(Json(serde_json::json!({ "success": true, "enabled": new_status }))) } // Get download status - matches Python api_download_status function exactly pub async fn download_status( State(state): State, headers: HeaderMap, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; let result = state.db_pool.download_status().await?; Ok(Json(result)) } // Get self service status - matches Python api_self_service_status function exactly pub async fn self_service_status( State(state): State, headers: HeaderMap, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; let result = state.db_pool.self_service_status().await?; Ok(Json(serde_json::json!({ "status": result.status, "first_admin_created": result.admin_exists }))) } // Request struct for save_email_settings #[derive(Deserialize)] pub struct SaveEmailSettingsRequest { pub email_settings: EmailSettings, } #[derive(Deserialize)] pub struct EmailSettings { pub server_name: String, #[serde(deserialize_with = "deserialize_string_to_i32")] pub server_port: i32, pub from_email: String, pub send_mode: String, pub encryption: String, #[serde(deserialize_with = "deserialize_bool_to_i32")] pub auth_required: i32, pub email_username: String, pub email_password: String, } // Helper function to deserialize string to i32 fn deserialize_string_to_i32<'de, D>(deserializer: D) -> Result where D: serde::Deserializer<'de> { use serde::de::Error; let s = String::deserialize(deserializer)?; s.parse::().map_err(D::Error::custom) } // Helper function to deserialize bool to i32 fn deserialize_bool_to_i32<'de, D>(deserializer: D) -> Result where D: serde::Deserializer<'de> { let b = bool::deserialize(deserializer)?; Ok(if b { 1 } else { 0 }) } // Save email settings - matches Python api_save_email_settings function exactly (admin only) pub async fn save_email_settings( State(state): State, headers: HeaderMap, Json(request): Json, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; // Check if user is admin (matches Python check_if_admin dependency) let requesting_user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; let is_admin = state.db_pool.user_admin_check(requesting_user_id).await?; if !is_admin { return Err(AppError::forbidden("Admin access required")); } state.db_pool.save_email_settings(&request.email_settings).await?; Ok(Json(serde_json::json!({ "detail": "Email settings saved." }))) } // Email settings response struct #[derive(Serialize)] pub struct EmailSettingsResponse { #[serde(rename = "Emailsettingsid")] pub emailsettingsid: i32, #[serde(rename = "ServerName")] pub server_name: String, #[serde(rename = "ServerPort")] pub server_port: i32, #[serde(rename = "FromEmail")] pub from_email: String, #[serde(rename = "SendMode")] pub send_mode: String, #[serde(rename = "Encryption")] pub encryption: String, #[serde(rename = "AuthRequired")] pub auth_required: i32, #[serde(rename = "Username")] pub username: String, #[serde(rename = "Password")] pub password: String, } // Get email settings - matches Python api_get_email_settings function exactly (admin only) pub async fn get_email_settings( State(state): State, headers: HeaderMap, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; // Check if user is admin (matches Python check_if_admin dependency) let requesting_user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; let is_admin = state.db_pool.user_admin_check(requesting_user_id).await?; if !is_admin { return Err(AppError::forbidden("Admin access required")); } let settings = state.db_pool.get_email_settings().await?; match settings { Some(settings) => Ok(Json(settings)), None => Err(AppError::not_found("Email settings not found")), } } // Request struct for send_test_email #[derive(Deserialize)] pub struct SendTestEmailRequest { pub server_name: String, pub server_port: String, pub from_email: String, pub send_mode: String, pub encryption: String, pub auth_required: bool, pub email_username: String, pub email_password: String, pub to_email: String, pub message: String, } // Send test email - matches Python api_send_email function exactly (admin only) pub async fn send_test_email( State(state): State, headers: HeaderMap, Json(request): Json, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; // Check if user is admin (matches Python check_if_admin dependency) let requesting_user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; let is_admin = state.db_pool.user_admin_check(requesting_user_id).await?; if !is_admin { return Err(AppError::forbidden("Admin access required")); } let email_status = send_email_internal(&request).await?; Ok(Json(serde_json::json!({ "email_status": email_status }))) } // HTML email template functions async fn read_logo_as_base64() -> Result { use std::path::Path; use tokio::fs; let logo_path = Path::new("/var/www/html/static/assets/favicon.png"); if !logo_path.exists() { return Err(AppError::internal("Logo file not found")); } let logo_bytes = fs::read(logo_path).await .map_err(|e| AppError::internal(&format!("Failed to read logo file: {}", e)))?; let base64_logo = base64::encode(&logo_bytes); Ok(base64_logo) } fn create_html_email_template(subject: &str, content: &str, logo_base64: &str) -> String { format!(r#" {} "#, subject, logo_base64, content) } // Internal email sending function using lettre async fn send_email_internal(request: &SendTestEmailRequest) -> Result { use lettre::{ message::{header::ContentType, Message}, transport::smtp::{authentication::Credentials, client::Tls, client::TlsParameters}, AsyncSmtpTransport, AsyncTransport, Tokio1Executor, }; use tokio::time::{timeout, Duration}; // Parse server port let port: u16 = request.server_port.parse() .map_err(|_| AppError::bad_request("Invalid server port"))?; // Read logo and create HTML content let logo_base64 = read_logo_as_base64().await.unwrap_or_default(); let html_content = format!(r#"

📧 Test Email

This is a test email from your PinePods server to verify your email configuration is working correctly.

Your message:

{}

If you received this email, your email settings are configured properly! 🎉

"#, request.message); let html_body = create_html_email_template("Test Email", &html_content, &logo_base64); // Create email message with HTML let email = Message::builder() .from(request.from_email.parse() .map_err(|_| AppError::bad_request("Invalid from email"))?) .to(request.to_email.parse() .map_err(|_| AppError::bad_request("Invalid to email"))?) .subject("PinePods - Test Email") .header(ContentType::TEXT_HTML) .body(html_body) .map_err(|e| AppError::internal(&format!("Failed to build email: {}", e)))?; // Configure SMTP transport based on encryption let mailer = match request.encryption.as_str() { "SSL/TLS" => { let tls = TlsParameters::new(request.server_name.clone()) .map_err(|e| AppError::internal(&format!("TLS configuration failed: {}", e)))?; if request.auth_required { let creds = Credentials::new(request.email_username.clone(), request.email_password.clone()); AsyncSmtpTransport::::relay(&request.server_name) .map_err(|e| AppError::internal(&format!("SMTP relay configuration failed: {}", e)))? .port(port) .tls(Tls::Wrapper(tls)) .credentials(creds) .build() } else { AsyncSmtpTransport::::relay(&request.server_name) .map_err(|e| AppError::internal(&format!("SMTP relay configuration failed: {}", e)))? .port(port) .tls(Tls::Wrapper(tls)) .build() } } "StartTLS" => { let tls = TlsParameters::new(request.server_name.clone()) .map_err(|e| AppError::internal(&format!("TLS configuration failed: {}", e)))?; if request.auth_required { let creds = Credentials::new(request.email_username.clone(), request.email_password.clone()); AsyncSmtpTransport::::relay(&request.server_name) .map_err(|e| AppError::internal(&format!("SMTP relay configuration failed: {}", e)))? .port(port) .tls(Tls::Required(tls)) .credentials(creds) .build() } else { AsyncSmtpTransport::::relay(&request.server_name) .map_err(|e| AppError::internal(&format!("SMTP relay configuration failed: {}", e)))? .port(port) .tls(Tls::Required(tls)) .build() } } _ => { // No encryption - use builder_dangerous for unencrypted connections if request.auth_required { let creds = Credentials::new(request.email_username.clone(), request.email_password.clone()); AsyncSmtpTransport::::builder_dangerous(&request.server_name) .port(port) .credentials(creds) .build() } else { AsyncSmtpTransport::::builder_dangerous(&request.server_name) .port(port) .build() } } }; // Send the email with timeout let email_future = mailer.send(email); match timeout(Duration::from_secs(30), email_future).await { Ok(Ok(_)) => Ok("Email sent successfully".to_string()), Ok(Err(e)) => { let error_msg = format!("{}", e); // Provide more helpful error messages for common issues if error_msg.contains("InvalidContentType") || error_msg.contains("corrupt message") { let suggestion = if port == 587 { "Port 587 typically requires StartTLS encryption, not SSL/TLS. Try changing encryption to 'StartTLS'." } else if port == 465 { "Port 465 typically requires SSL/TLS encryption." } else { "This may be a TLS/SSL configuration issue. Verify your encryption settings match your SMTP server requirements." }; Err(AppError::internal(&format!("SMTP connection failed: {}. {}. Original error: {}", "TLS/SSL handshake error", suggestion, error_msg))) } else if error_msg.contains("authentication") || error_msg.contains("auth") { Err(AppError::internal(&format!("SMTP authentication failed: {}. Please verify your username and password.", error_msg))) } else if error_msg.contains("connection") || error_msg.contains("timeout") { Err(AppError::internal(&format!("SMTP connection failed: {}. Please verify server name and port.", error_msg))) } else { Err(AppError::internal(&format!("Failed to send email: {}", error_msg))) } }, Err(_) => Err(AppError::internal("Email sending timed out after 30 seconds. Please check your SMTP server settings and network connectivity.".to_string())), } } // Request struct for send_email (using database settings) #[derive(Deserialize)] pub struct SendEmailRequest { pub to_email: String, pub subject: String, pub message: String, } // Send email using database settings - matches Python api_send_email function exactly pub async fn send_email( State(state): State, headers: HeaderMap, Json(request): Json, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; // Get email settings from database let email_settings = state.db_pool.get_email_settings().await?; let settings = match email_settings { Some(settings) => settings, None => return Err(AppError::not_found("Email settings not found")), }; let email_status = send_email_with_settings(&settings, &request).await?; Ok(Json(serde_json::json!({ "email_status": email_status }))) } // Send email using database settings pub async fn send_email_with_settings( settings: &EmailSettingsResponse, request: &SendEmailRequest, ) -> Result { use lettre::{ message::{header::ContentType, Message}, transport::smtp::{authentication::Credentials, client::Tls, client::TlsParameters}, AsyncSmtpTransport, AsyncTransport, Tokio1Executor, }; use tokio::time::{timeout, Duration}; // Read logo and create HTML content let logo_base64 = read_logo_as_base64().await.unwrap_or_default(); // Check if this is a password reset email and format accordingly let (html_content, final_subject) = if request.subject.contains("Password Reset") { // Extract the reset code from the message let reset_code = request.message.trim_start_matches("Your password reset code is "); let content = format!(r#"

🔐 Password Reset Request

You have requested a password reset for your PinePods account.

Please use the following code to reset your password:

{}

Important:

  • This code will expire in 10 minutes
  • Only use this code if you requested a password reset
  • If you didn't request this, you can safely ignore this email

For security reasons, never share this code with anyone.

"#, reset_code); (content, "PinePods - Password Reset Code".to_string()) } else { // For other emails, wrap the message content let content = format!(r#"

📧 {}

{}
"#, request.subject, request.message.replace("\n", "
")); (content, request.subject.clone()) }; let html_body = create_html_email_template(&final_subject, &html_content, &logo_base64); // Create email message with HTML let email = Message::builder() .from(settings.from_email.parse() .map_err(|_| AppError::bad_request("Invalid from email in settings"))?) .to(request.to_email.parse() .map_err(|_| AppError::bad_request("Invalid to email"))?) .subject(&final_subject) .header(ContentType::TEXT_HTML) .body(html_body) .map_err(|e| AppError::internal(&format!("Failed to build email: {}", e)))?; // Configure SMTP transport based on encryption let mailer = match settings.encryption.as_str() { "SSL/TLS" => { let tls = TlsParameters::new(settings.server_name.clone()) .map_err(|e| AppError::internal(&format!("TLS configuration failed: {}", e)))?; if settings.auth_required == 1 { let creds = Credentials::new(settings.username.clone(), settings.password.clone()); AsyncSmtpTransport::::relay(&settings.server_name) .map_err(|e| AppError::internal(&format!("SMTP relay configuration failed: {}", e)))? .port(settings.server_port as u16) .tls(Tls::Wrapper(tls)) .credentials(creds) .build() } else { AsyncSmtpTransport::::relay(&settings.server_name) .map_err(|e| AppError::internal(&format!("SMTP relay configuration failed: {}", e)))? .port(settings.server_port as u16) .tls(Tls::Wrapper(tls)) .build() } } "StartTLS" => { let tls = TlsParameters::new(settings.server_name.clone()) .map_err(|e| AppError::internal(&format!("TLS configuration failed: {}", e)))?; if settings.auth_required == 1 { let creds = Credentials::new(settings.username.clone(), settings.password.clone()); AsyncSmtpTransport::::relay(&settings.server_name) .map_err(|e| AppError::internal(&format!("SMTP relay configuration failed: {}", e)))? .port(settings.server_port as u16) .tls(Tls::Required(tls)) .credentials(creds) .build() } else { AsyncSmtpTransport::::relay(&settings.server_name) .map_err(|e| AppError::internal(&format!("SMTP relay configuration failed: {}", e)))? .port(settings.server_port as u16) .tls(Tls::Required(tls)) .build() } } _ => { // No encryption - use builder_dangerous for unencrypted connections if settings.auth_required == 1 { let creds = Credentials::new(settings.username.clone(), settings.password.clone()); AsyncSmtpTransport::::builder_dangerous(&settings.server_name) .port(settings.server_port as u16) .credentials(creds) .build() } else { AsyncSmtpTransport::::builder_dangerous(&settings.server_name) .port(settings.server_port as u16) .build() } } }; // Send the email with timeout let email_future = mailer.send(email); match timeout(Duration::from_secs(30), email_future).await { Ok(Ok(_)) => Ok("Email sent successfully".to_string()), Ok(Err(e)) => { let error_msg = format!("{}", e); let port = settings.server_port as u16; // Provide more helpful error messages for common issues if error_msg.contains("InvalidContentType") || error_msg.contains("corrupt message") { let suggestion = if port == 587 { "Port 587 typically requires StartTLS encryption, not SSL/TLS. Try changing encryption to 'StartTLS'." } else if port == 465 { "Port 465 typically requires SSL/TLS encryption." } else { "This may be a TLS/SSL configuration issue. Verify your encryption settings match your SMTP server requirements." }; Err(AppError::internal(&format!("SMTP connection failed: {}. {}. Original error: {}", "TLS/SSL handshake error", suggestion, error_msg))) } else if error_msg.contains("authentication") || error_msg.contains("auth") { Err(AppError::internal(&format!("SMTP authentication failed: {}. Please verify your username and password.", error_msg))) } else if error_msg.contains("connection") || error_msg.contains("timeout") { Err(AppError::internal(&format!("SMTP connection failed: {}. Please verify server name and port.", error_msg))) } else { Err(AppError::internal(&format!("Failed to send email: {}", error_msg))) } }, Err(_) => Err(AppError::internal("Email sending timed out after 30 seconds. Please check your SMTP server settings and network connectivity.".to_string())), } } // API info response struct - matches Python get_api_info response exactly #[derive(Serialize)] pub struct ApiInfo { pub apikeyid: i32, pub userid: i32, pub username: String, pub lastfourdigits: String, pub created: String, pub podcastids: Vec, } // Get API info - matches Python api_get_api_info function exactly pub async fn get_api_info( State(state): State, Path(user_id): Path, headers: HeaderMap, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; // Check authorization (elevated access or own user) let user_id_from_api_key = state.db_pool.get_user_id_from_api_key(&api_key).await?; let is_web_key = state.db_pool.is_web_key(&api_key).await?; if !is_web_key && user_id != user_id_from_api_key { return Err(AppError::forbidden("You are not authorized to access these user details")); } let api_information = state.db_pool.get_api_info(user_id).await?; match api_information { Some(info) => Ok(Json(serde_json::json!({ "api_info": info }))), None => Err(AppError::not_found("User not found")), } } // Request struct for create_api_key #[derive(Deserialize)] pub struct CreateApiKeyRequest { pub user_id: i32, pub rssonly: bool, pub podcast_ids: Option>, } // Create API key - matches Python api_create_api_key function exactly pub async fn create_api_key( State(state): State, headers: HeaderMap, Json(request): Json, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; // Check authorization - web key or own user let user_id_from_api_key = state.db_pool.get_user_id_from_api_key(&api_key).await?; let is_web_key = state.db_pool.is_web_key(&api_key).await?; if request.user_id != user_id_from_api_key && !is_web_key { return Err(AppError::forbidden("Your API key is either invalid or does not have correct permission")); } if request.rssonly { let new_key = state.db_pool.create_rss_key(request.user_id, request.podcast_ids).await?; Ok(Json(serde_json::json!({ "rss_key": new_key }))) } else { let new_key = state.db_pool.create_api_key(request.user_id).await?; Ok(Json(serde_json::json!({ "api_key": new_key }))) } } // Request struct for delete_api_key #[derive(Deserialize)] pub struct DeleteApiKeyRequest { pub api_id: String, pub user_id: String, } // Delete API key - matches Python api_delete_api_key function exactly pub async fn delete_api_key( State(state): State, headers: HeaderMap, Json(request): Json, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; // Parse api_id from string (user_id not used for authorization) let api_id: i32 = request.api_id.parse() .map_err(|_| AppError::bad_request("Invalid api_id format"))?; // Check authorization - admins can delete any key (except user ID 1), users can only delete their own keys let requesting_user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; let is_requesting_user_admin = state.db_pool.user_admin_check(requesting_user_id).await?; // Get the owner of the API key being deleted let api_key_owner = state.db_pool.get_api_key_owner(api_id).await?; if api_key_owner.is_none() { return Err(AppError::not_found("API key not found")); } let api_key_owner = api_key_owner.unwrap(); // For debugging - log the values println!("🔐 delete_api_key: requesting_user={}, api_key_owner={}, is_admin={}, api_id={}", requesting_user_id, api_key_owner, is_requesting_user_admin, api_id); // Authorization logic: // - Admin users can delete any key EXCEPT keys belonging to user ID 1 (background tasks) // - Regular users can only delete their own keys if !is_requesting_user_admin && requesting_user_id != api_key_owner { return Err(AppError::forbidden("You are not authorized to access or remove other users api-keys.")); } // Check if the API key to be deleted is the same as the one used in the current request if state.db_pool.is_same_api_key(api_id, &api_key).await? { return Err(AppError::forbidden("You cannot delete the API key that is currently in use.")); } // Check if the API key belongs to the background task user (user_id 1) - no one can delete these if api_key_owner == 1 { return Err(AppError::forbidden("Cannot delete background task API key - would break refreshing.")); } // CRITICAL SAFETY CHECK: Ensure the API key owner has at least one other API key (would prevent logins) let remaining_keys_count = state.db_pool.count_user_api_keys_excluding(api_key_owner, api_id).await?; if remaining_keys_count == 0 { if requesting_user_id == api_key_owner { return Err(AppError::forbidden("Cannot delete your final API key - you must have at least one key to maintain access.")); } else { return Err(AppError::forbidden("Cannot delete the user's final API key - they must have at least one key to maintain access.")); } } // Proceed with deletion if the checks pass state.db_pool.delete_api_key(api_id).await?; Ok(Json(serde_json::json!({ "detail": "API key deleted." }))) } // Request struct for backup_user #[derive(Deserialize)] pub struct BackupUserRequest { pub user_id: i32, } // Backup user data - matches Python backup_user function exactly pub async fn backup_user( State(state): State, headers: HeaderMap, Json(request): Json, ) -> Result { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; // Check authorization - web key or own user let user_id_from_api_key = state.db_pool.get_user_id_from_api_key(&api_key).await?; let is_web_key = state.db_pool.is_web_key(&api_key).await?; if request.user_id != user_id_from_api_key && !is_web_key { return Err(AppError::forbidden("You can only make backups for yourself!")); } let opml_data = state.db_pool.backup_user(request.user_id).await?; Ok(opml_data) } // Request struct for backup_server #[derive(Deserialize)] pub struct BackupServerRequest { pub database_pass: String, } // Backup server data - improved streaming approach for large databases pub async fn backup_server( State(state): State, headers: HeaderMap, Json(request): Json, ) -> Result { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; // Check if user is admin (matches Python check_if_admin dependency) let requesting_user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; let is_admin = state.db_pool.user_admin_check(requesting_user_id).await?; if !is_admin { return Err(AppError::forbidden("Admin access required")); } // For large databases, we'll implement streaming export instead of subprocess // This avoids loading the entire database into memory at once match backup_server_streaming(&state, &request.database_pass).await { Ok(response) => Ok(response), Err(e) => Err(AppError::internal(&format!("Backup failed: {}", e))), } } // Use actual pg_dump/mysqldump for reliable backups async fn backup_server_streaming( state: &AppState, database_pass: &str, ) -> Result { use axum::response::Response; use axum::body::Body; use tokio::process::Command; use tokio_util::io::ReaderStream; // Get database connection info from config let mut cmd = match &state.db_pool { crate::database::DatabasePool::Postgres(_) => { // Extract connection details from DATABASE_URL or config let host = std::env::var("DB_HOST").unwrap_or_else(|_| "localhost".to_string()); let port = std::env::var("DB_PORT").unwrap_or_else(|_| "5432".to_string()); let database = std::env::var("DB_NAME").unwrap_or_else(|_| "pinepods".to_string()); let username = std::env::var("DB_USER").unwrap_or_else(|_| "postgres".to_string()); // Use pg_dump with data-only options (no schema) let mut cmd = Command::new("pg_dump"); cmd.arg("--host").arg(&host) .arg("--port").arg(&port) .arg("--username").arg(&username) .arg("--no-password") .arg("--verbose") .arg("--data-only") .arg("--disable-triggers") .arg("--format=plain") .arg(&database); // Set password via environment variable cmd.env("PGPASSWORD", database_pass); cmd } crate::database::DatabasePool::MySQL(_) => { let host = std::env::var("DB_HOST").unwrap_or_else(|_| "localhost".to_string()); let port = std::env::var("DB_PORT").unwrap_or_else(|_| "3306".to_string()); let database = std::env::var("DB_NAME").unwrap_or_else(|_| "pinepods".to_string()); let username = std::env::var("DB_USER").unwrap_or_else(|_| "root".to_string()); let mut cmd = Command::new("mysqldump"); cmd.arg("--host").arg(&host) .arg("--port").arg(&port) .arg("--user").arg(&username) .arg(format!("--password={}", database_pass)) .arg("--skip-ssl") .arg("--default-auth=mysql_native_password") .arg("--single-transaction") .arg("--routines") .arg("--triggers") .arg("--complete-insert") .arg(&database); cmd } }; let mut child = cmd.stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .spawn() .map_err(|e| format!("Failed to start backup process: {}", e))?; let stdout = child.stdout.take() .ok_or("Failed to get stdout from backup process")?; let stderr = child.stderr.take() .ok_or("Failed to get stderr from backup process")?; let stream = ReaderStream::new(stdout); let body = Body::from_stream(stream); // Spawn a task to wait for the process and handle errors tokio::spawn(async move { // Read stderr to capture error messages let mut stderr_reader = tokio::io::BufReader::new(stderr); let mut stderr_output = String::new(); use tokio::io::AsyncBufReadExt; // Read stderr line by line let mut lines = stderr_reader.lines(); while let Ok(Some(line)) = lines.next_line().await { stderr_output.push_str(&line); stderr_output.push('\n'); } match child.wait().await { Ok(status) if status.success() => { println!("Backup process completed successfully"); } Ok(status) => { println!("Backup process failed with status: {}", status); if !stderr_output.is_empty() { println!("Mysqldump stderr output: {}", stderr_output); } } Err(e) => { println!("Failed to wait for backup process: {}", e); } } }); Ok(Response::builder() .status(200) .header("content-type", "text/plain; charset=utf-8") .header("content-disposition", "attachment; filename=\"pinepods_backup.sql\"") .body(body) .map_err(|e| format!("Failed to build response: {}", e))?) } // Generate backup chunks to handle large databases efficiently async fn generate_backup_chunk(state: &AppState, chunk_id: usize) -> Result, String> { // Define tables in order of dependencies (foreign keys) - complete list from migrations let tables = match &state.db_pool { crate::database::DatabasePool::Postgres(_) => vec![ "Users", "OIDCProviders", "APIKeys", "RssKeys", "RssKeyMap", "AppSettings", "EmailSettings", "UserStats", "UserSettings", "Podcasts", "Episodes", "YouTubeVideos", "UserEpisodeHistory", "UserVideoHistory", "EpisodeQueue", "SavedEpisodes", "SavedVideos", "DownloadedEpisodes", "DownloadedVideos", "GpodderDevices", "GpodderSyncState", "People", "PeopleEpisodes", "SharedEpisodes", "Playlists", "PlaylistContents", "Sessions", "UserNotificationSettings" ], crate::database::DatabasePool::MySQL(_) => vec![ "Users", "OIDCProviders", "APIKeys", "RssKeys", "RssKeyMap", "AppSettings", "EmailSettings", "UserStats", "UserSettings", "Podcasts", "Episodes", "YouTubeVideos", "UserEpisodeHistory", "UserVideoHistory", "EpisodeQueue", "SavedEpisodes", "SavedVideos", "DownloadedEpisodes", "DownloadedVideos", "GpodderDevices", "GpodderSyncState", "People", "PeopleEpisodes", "SharedEpisodes", "Playlists", "PlaylistContents", "Sessions", "UserNotificationSettings" ], }; // Header chunk if chunk_id == 0 { return Ok(Some(generate_backup_header())); } // Table chunks (one table per chunk to keep memory usage low) let table_index = chunk_id - 1; if table_index < tables.len() { let table_name = tables[table_index]; match export_table_data(state, table_name).await { Ok(data) => Ok(Some(data)), Err(e) => Err(format!("Failed to export table {}: {}", table_name, e)), } } else { // End of stream Ok(None) } } // Generate SQL backup header fn generate_backup_header() -> String { format!( "-- PinePods Database Backup\n-- Generated: {}\n-- Rust API Backup System\n\n", chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC") ) } // Export individual table data efficiently async fn export_table_data(state: &AppState, table_name: &str) -> Result { const BATCH_SIZE: i64 = 1000; // Process 1000 rows at a time let mut sql_output = format!("\n-- Exporting table: {}\n", table_name); // First, export the CREATE TABLE statement let create_statement = match &state.db_pool { crate::database::DatabasePool::Postgres(pool) => { export_postgres_table_schema(pool, table_name).await? } crate::database::DatabasePool::MySQL(pool) => { export_mysql_table_schema(pool, table_name).await? } }; sql_output.push_str(&create_statement); sql_output.push('\n'); // Then export the data let mut offset = 0; loop { let batch_data = match &state.db_pool { crate::database::DatabasePool::Postgres(pool) => { export_postgres_table_batch(pool, table_name, offset, BATCH_SIZE).await? } crate::database::DatabasePool::MySQL(pool) => { export_mysql_table_batch(pool, table_name, offset, BATCH_SIZE).await? } }; if batch_data.is_empty() { break; // No more data } sql_output.push_str(&batch_data); offset += BATCH_SIZE; // Don't artificially limit chunk size - complete the entire table // Each table is processed as one complete chunk to ensure valid SQL } Ok(sql_output) } // Export PostgreSQL table schema using pg_dump-like approach async fn export_postgres_table_schema( pool: &sqlx::PgPool, table_name: &str, ) -> Result { // Get table definition from PostgreSQL system catalogs with proper ARRAY handling let query = r#" SELECT 'CREATE TABLE "' || schemaname || '"."' || tablename || '" (' AS create_start, string_agg( '"' || column_name || '" ' || CASE WHEN data_type = 'ARRAY' THEN CASE WHEN udt_name = '_int4' THEN 'INTEGER[]' WHEN udt_name = '_text' THEN 'TEXT[]' WHEN udt_name = '_varchar' THEN 'VARCHAR[]' WHEN udt_name = '_int8' THEN 'BIGINT[]' WHEN udt_name = '_bool' THEN 'BOOLEAN[]' ELSE udt_name || '[]' END WHEN data_type = 'character varying' THEN 'VARCHAR(' || COALESCE(character_maximum_length::text, '255') || ')' WHEN data_type = 'character' THEN 'CHAR(' || character_maximum_length || ')' WHEN data_type = 'numeric' THEN 'NUMERIC(' || numeric_precision || ',' || numeric_scale || ')' WHEN data_type = 'integer' THEN 'INTEGER' WHEN data_type = 'bigint' THEN 'BIGINT' WHEN data_type = 'boolean' THEN 'BOOLEAN' WHEN data_type = 'timestamp without time zone' THEN 'TIMESTAMP' WHEN data_type = 'timestamp with time zone' THEN 'TIMESTAMPTZ' WHEN data_type = 'date' THEN 'DATE' WHEN data_type = 'text' THEN 'TEXT' WHEN data_type = 'double precision' THEN 'DOUBLE PRECISION' WHEN data_type = 'real' THEN 'REAL' WHEN data_type = 'smallint' THEN 'SMALLINT' WHEN data_type = 'uuid' THEN 'UUID' WHEN data_type = 'json' THEN 'JSON' WHEN data_type = 'jsonb' THEN 'JSONB' ELSE UPPER(data_type) END || CASE WHEN is_nullable = 'NO' THEN ' NOT NULL' ELSE '' END, ', ' ORDER BY ordinal_position ) AS columns, ');' AS create_end FROM information_schema.columns c JOIN pg_tables t ON t.tablename = c.table_name WHERE c.table_name = $1 AND c.table_schema = 'public' GROUP BY schemaname, tablename "#; let row = sqlx::query(query) .bind(table_name) .fetch_optional(pool) .await .map_err(|e| format!("Schema query failed: {}", e))?; if let Some(row) = row { let create_start: String = row.try_get("create_start").map_err(|e| format!("Column error: {}", e))?; let columns: String = row.try_get("columns").map_err(|e| format!("Column error: {}", e))?; let create_end: String = row.try_get("create_end").map_err(|e| format!("Column error: {}", e))?; Ok(format!("{}\n {}\n{}\n", create_start, columns, create_end)) } else { Err(format!("Table {} not found", table_name)) } } // Export MySQL table schema async fn export_mysql_table_schema( pool: &sqlx::MySqlPool, table_name: &str, ) -> Result { // Use SHOW CREATE TABLE for MySQL let query = format!("SHOW CREATE TABLE {}", table_name); let row = sqlx::query(&query) .fetch_optional(pool) .await .map_err(|e| format!("Schema query failed: {}", e))?; if let Some(row) = row { let create_table: String = row.try_get(1).map_err(|e| format!("Column error: {}", e))?; Ok(format!("{};\n", create_table)) } else { Err(format!("Table {} not found", table_name)) } } // Export PostgreSQL table batch async fn export_postgres_table_batch( pool: &sqlx::PgPool, table_name: &str, offset: i64, limit: i64, ) -> Result { // Use quoted table names for PostgreSQL let query = format!( r#"SELECT * FROM "{}" ORDER BY 1 LIMIT {} OFFSET {}"#, table_name, limit, offset ); let rows = sqlx::query(&query) .fetch_all(pool) .await .map_err(|e| format!("Query failed: {}", e))?; if rows.is_empty() { return Ok(String::new()); } let mut output = format!("INSERT INTO \"{}\" VALUES\n", table_name); let mut first_row = true; for row in rows { if !first_row { output.push_str(",\n"); } first_row = false; output.push('('); let column_count = row.columns().len(); for i in 0..column_count { if i > 0 { output.push_str(", "); } // Handle different PostgreSQL data types safely match row.try_get_raw(i) { Ok(value) if value.is_null() => output.push_str("NULL"), Ok(_) => { // Try different data types in order of likelihood if let Ok(val) = row.try_get::(i) { // Properly escape strings for PostgreSQL let escaped = val.replace('\'', "''").replace('\\', "\\\\"); output.push_str(&format!("'{}'", escaped)); } else if let Ok(val) = row.try_get::(i) { output.push_str(&val.to_string()); } else if let Ok(val) = row.try_get::(i) { output.push_str(&val.to_string()); } else if let Ok(val) = row.try_get::(i) { output.push_str(if val { "true" } else { "false" }); } else if let Ok(val) = row.try_get::(i) { output.push_str(&val.to_string()); } else if let Ok(val) = row.try_get::, _>(i) { output.push_str(&format!("'{}'", val.format("%Y-%m-%d %H:%M:%S%.6f%z"))); } else { // Fallback: try to get as text match row.try_get::(i) { Ok(val) => { let escaped = val.replace('\'', "''").replace('\\', "\\\\"); output.push_str(&format!("'{}'", escaped)); }, Err(_) => output.push_str("NULL"), } } } Err(_) => output.push_str("NULL"), } } output.push(')'); } output.push_str(";\n"); Ok(output) } // Export MySQL table batch async fn export_mysql_table_batch( pool: &sqlx::MySqlPool, table_name: &str, offset: i64, limit: i64, ) -> Result { let query = format!( "SELECT * FROM {} ORDER BY 1 LIMIT {} OFFSET {}", table_name, limit, offset ); let rows = sqlx::query(&query) .fetch_all(pool) .await .map_err(|e| format!("Query failed: {}", e))?; if rows.is_empty() { return Ok(String::new()); } let mut output = format!("INSERT INTO {} VALUES\n", table_name); let mut first_row = true; for row in rows { if !first_row { output.push_str(",\n"); } first_row = false; output.push('('); let column_count = row.columns().len(); for i in 0..column_count { if i > 0 { output.push_str(", "); } // Handle different MySQL data types safely match row.try_get_raw(i) { Ok(value) if value.is_null() => output.push_str("NULL"), Ok(_) => { // Try different data types in order of likelihood if let Ok(val) = row.try_get::(i) { // Properly escape strings for MySQL let escaped = val.replace('\'', "''").replace('\\', "\\\\"); output.push_str(&format!("'{}'", escaped)); } else if let Ok(val) = row.try_get::(i) { output.push_str(&val.to_string()); } else if let Ok(val) = row.try_get::(i) { output.push_str(&val.to_string()); } else if let Ok(val) = row.try_get::(i) { output.push_str(&val.to_string()); } else if let Ok(val) = row.try_get::(i) { output.push_str(&val.to_string()); } else if let Ok(val) = row.try_get::, _>(i) { output.push_str(&format!("'{}'", val.format("%Y-%m-%d %H:%M:%S"))); } else { // Fallback: try to get as text match row.try_get::(i) { Ok(val) => { let escaped = val.replace('\'', "''").replace('\\', "\\\\"); output.push_str(&format!("'{}'", escaped)); }, Err(_) => output.push_str("NULL"), } } } Err(_) => output.push_str("NULL"), } } output.push(')'); } output.push_str(";\n"); Ok(output) } pub async fn restore_server( State(state): State, headers: HeaderMap, mut multipart: Multipart, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; // Check if user is admin let user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; let is_admin = state.db_pool.user_admin_check(user_id).await?; if !is_admin { return Err(AppError::forbidden("Admin access required")); } // Process the multipart form to get the uploaded file and database password let mut sql_content = None; let mut _database_password = None; while let Some(field) = multipart.next_field().await.map_err(|e| AppError::bad_request(&format!("Multipart error: {}", e)))? { let name = field.name().unwrap_or("").to_string(); if name == "backup_file" { let filename = field.file_name().unwrap_or("").to_string(); // Validate file extension if !filename.ends_with(".sql") { return Err(AppError::bad_request("Only SQL files are allowed")); } let data = field.bytes().await.map_err(|e| AppError::bad_request(&format!("Failed to read file: {}", e)))?; // Check file size (limit to 100MB) if data.len() > 100 * 1024 * 1024 { return Err(AppError::bad_request("File too large (max 100MB)")); } sql_content = Some(String::from_utf8(data.to_vec()).map_err(|_| AppError::bad_request("Invalid UTF-8 content"))?); } else if name == "database_pass" { let password_data = field.bytes().await.map_err(|e| AppError::bad_request(&format!("Failed to read password: {}", e)))?; _database_password = Some(String::from_utf8(password_data.to_vec()).map_err(|_| AppError::bad_request("Invalid UTF-8 password"))?); } } let sql_content = sql_content.ok_or_else(|| AppError::bad_request("No SQL file uploaded"))?; let _database_password = _database_password.ok_or_else(|| AppError::bad_request("Database password is required"))?; // Process the restore in the background to prevent timeouts let db_pool = state.db_pool.clone(); tokio::spawn(async move { if let Err(e) = db_pool.restore_server_data(&sql_content).await { tracing::error!("Restore failed: {}", e); } }); Ok(Json(serde_json::json!({ "message": "Server restore started successfully" }))) } // Generate MFA secret - matches Python generate_mfa_secret function exactly pub async fn generate_mfa_secret( State(state): State, Path(user_id): Path, headers: HeaderMap, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; // Check authorization - web key or own user let user_id_from_api_key = state.db_pool.get_user_id_from_api_key(&api_key).await?; let is_web_key = state.db_pool.is_web_key(&api_key).await?; if user_id != user_id_from_api_key && !is_web_key { return Err(AppError::forbidden("You can only generate MFA secrets for yourself!")); } let (secret, qr_code_svg) = state.db_pool.generate_mfa_secret(user_id).await?; Ok(Json(serde_json::json!({ "secret": secret, "qr_code_svg": qr_code_svg }))) } // Request struct for verify_temp_mfa #[derive(Deserialize)] pub struct VerifyTempMfaRequest { pub user_id: i32, pub mfa_code: String, } // Verify temporary MFA code - matches Python verify_temp_mfa function exactly pub async fn verify_temp_mfa( State(state): State, headers: HeaderMap, Json(request): Json, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; // Check authorization - web key or own user let user_id_from_api_key = state.db_pool.get_user_id_from_api_key(&api_key).await?; let is_web_key = state.db_pool.is_web_key(&api_key).await?; if request.user_id != user_id_from_api_key && !is_web_key { return Err(AppError::forbidden("You can only verify MFA codes for yourself!")); } let verified = state.db_pool.verify_temp_mfa(request.user_id, &request.mfa_code).await?; Ok(Json(serde_json::json!({ "verified": verified }))) } // Check MFA enabled - matches Python check_mfa_enabled function exactly pub async fn check_mfa_enabled( State(state): State, Path(user_id): Path, headers: HeaderMap, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; // Check for elevated access (admin/web key) let is_web_key = state.db_pool.is_web_key(&api_key).await?; let user_id_from_api_key = state.db_pool.get_user_id_from_api_key(&api_key).await?; // If not elevated access, user can only check their own MFA status if !is_web_key && user_id != user_id_from_api_key { return Err(AppError::forbidden("You are not authorized to check mfa status for other users.")); } let is_enabled = state.db_pool.check_mfa_enabled(user_id).await?; Ok(Json(serde_json::json!({"mfa_enabled": is_enabled}))) } // Request struct for save_mfa_secret #[derive(Deserialize)] pub struct SaveMfaSecretRequest { pub user_id: i32, pub mfa_secret: String, } // Save MFA secret - matches Python save_mfa_secret function exactly pub async fn save_mfa_secret( State(state): State, headers: HeaderMap, Json(request): Json, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; // Check authorization - web key or own user let user_id_from_api_key = state.db_pool.get_user_id_from_api_key(&api_key).await?; let is_web_key = state.db_pool.is_web_key(&api_key).await?; if request.user_id != user_id_from_api_key && !is_web_key { return Err(AppError::forbidden("You can only save MFA secrets for yourself!")); } let success = state.db_pool.save_mfa_secret(request.user_id, &request.mfa_secret).await?; Ok(Json(serde_json::json!({ "success": success }))) } // Delete MFA - matches Python delete_mfa function exactly pub async fn delete_mfa( State(state): State, headers: HeaderMap, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; let user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; let success = state.db_pool.delete_mfa_secret(user_id).await?; Ok(Json(serde_json::json!({ "success": success }))) } // Request struct for initiate_nextcloud_login #[derive(Deserialize)] pub struct InitiateNextcloudLoginRequest { pub user_id: i32, pub nextcloud_url: String, } // Initiate Nextcloud login - matches Python initiate_nextcloud_login function exactly pub async fn initiate_nextcloud_login( State(state): State, headers: HeaderMap, Json(request): Json, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; // Allow the action only if the API key belongs to the user if key_id != request.user_id { return Err(AppError::forbidden("You are not authorized to initiate this action.")); } let login_data = state.db_pool.initiate_nextcloud_login(request.user_id, &request.nextcloud_url).await?; Ok(Json(login_data.raw_response)) } // Request struct for add_nextcloud_server #[derive(Deserialize, Clone)] pub struct AddNextcloudServerRequest { pub user_id: i32, pub token: String, pub poll_endpoint: String, pub nextcloud_url: String, } // Add Nextcloud server - matches Python add_nextcloud_server function exactly pub async fn add_nextcloud_server( State(state): State, headers: HeaderMap, Json(request): Json, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; // Allow the action only if the API key belongs to the user if key_id != request.user_id { return Err(AppError::forbidden("You are not authorized to access these user details")); } // Reset gPodder settings to default like Python version state.db_pool.remove_podcast_sync(request.user_id).await?; // Create a task for the Nextcloud authentication polling let task_id = state.task_manager.create_task("nextcloud_auth".to_string(), request.user_id).await?; // Start background polling task using TaskManager let state_clone = state.clone(); let request_clone = request.clone(); let task_id_clone = task_id.clone(); tokio::spawn(async move { poll_for_auth_completion_background(state_clone, request_clone, task_id_clone).await; }); // Return 200 status code before starting to poll (like Python version) Ok(Json(serde_json::json!({ "status": "polling", "task_id": task_id }))) } // Background task for polling Nextcloud auth completion async fn poll_for_auth_completion_background(state: AppState, request: AddNextcloudServerRequest, task_id: String) { // Update task to indicate polling has started if let Err(e) = state.task_manager.update_task_progress(&task_id, 10.0, Some("Starting Nextcloud authentication polling...".to_string())).await { eprintln!("Failed to update task progress: {}", e); } match poll_for_auth_completion(&request.poll_endpoint, &request.token, &state.task_manager, &task_id).await { Ok(credentials) => { println!("Nextcloud authentication successful: {:?}", credentials); // Update task progress if let Err(e) = state.task_manager.update_task_progress(&task_id, 90.0, Some("Authentication successful, saving credentials...".to_string())).await { eprintln!("Failed to update task progress: {}", e); } // Extract credentials from the response if let (Some(app_password), Some(login_name)) = ( credentials.get("appPassword").and_then(|v| v.as_str()), credentials.get("loginName").and_then(|v| v.as_str()) ) { // Save the real credentials using the database method match state.db_pool.save_nextcloud_credentials(request.user_id, &request.nextcloud_url, app_password, login_name).await { Ok(_) => { println!("Successfully added Nextcloud settings for user {}", request.user_id); if let Err(e) = state.task_manager.complete_task(&task_id, Some(serde_json::json!({"status": "success", "message": "Nextcloud authentication completed"})), Some("Nextcloud authentication completed successfully".to_string())).await { eprintln!("Failed to complete task: {}", e); } } Err(e) => { eprintln!("Failed to add Nextcloud settings: {}", e); if let Err(e) = state.task_manager.fail_task(&task_id, format!("Failed to save Nextcloud settings: {}", e)).await { eprintln!("Failed to fail task: {}", e); } } } } else { eprintln!("Missing appPassword or loginName in credentials"); if let Err(e) = state.task_manager.fail_task(&task_id, "Missing credentials in Nextcloud response".to_string()).await { eprintln!("Failed to fail task: {}", e); } } } Err(e) => { eprintln!("Nextcloud authentication failed: {}", e); if let Err(e) = state.task_manager.fail_task(&task_id, format!("Authentication failed: {}", e)).await { eprintln!("Failed to fail task: {}", e); } } } } // Poll for auth completion - matches Python poll_for_auth_completion function async fn poll_for_auth_completion( endpoint: &str, token: &str, task_manager: &crate::services::task_manager::TaskManager, task_id: &str ) -> Result> { let client = reqwest::Client::new(); let payload = serde_json::json!({ "token": token }); let timeout = std::time::Duration::from_secs(20 * 60); // 20 minutes timeout let start_time = std::time::Instant::now(); let mut poll_count = 0; while start_time.elapsed() < timeout { poll_count += 1; // Update progress based on time elapsed (up to 80% during polling) let elapsed_secs = start_time.elapsed().as_secs(); let progress = 10.0 + ((elapsed_secs as f64 / (20.0 * 60.0)) * 70.0).min(70.0); let message = format!("Waiting for user to complete authentication... (attempt {})", poll_count); if let Err(e) = task_manager.update_task_progress(task_id, progress, Some(message)).await { eprintln!("Failed to update task progress during polling: {}", e); } match client .post(endpoint) .json(&payload) .header("Content-Type", "application/json") .send() .await { Ok(response) => { match response.status().as_u16() { 200 => { let credentials = response.json::().await?; println!("Authentication successful: {:?}", credentials); return Ok(credentials); } 404 => { // User hasn't completed auth yet, continue polling tokio::time::sleep(std::time::Duration::from_secs(5)).await; } status => { println!("Polling failed with status code {}", status); return Err(format!("Polling for Nextcloud authentication failed with status {}", status).into()); } } } Err(e) => { println!("Connection error, retrying: {}", e); tokio::time::sleep(std::time::Duration::from_secs(5)).await; } } } Err("Polling timeout reached".into()) } // Helper function to save Nextcloud credentials directly to database async fn save_nextcloud_credentials( db_pool: &crate::database::DatabasePool, user_id: i32, nextcloud_url: &str, app_password: &str, login_name: &str ) -> crate::error::AppResult<()> { // Encrypt the app password let encrypted_password = db_pool.encrypt_password(app_password).await?; // Store Nextcloud credentials match db_pool { crate::database::DatabasePool::Postgres(pool) => { sqlx::query(r#"UPDATE "Users" SET gpodderurl = $1, gpodderloginname = $2, gpoddertoken = $3, pod_sync_type = 'nextcloud' WHERE userid = $4"#) .bind(nextcloud_url) .bind(login_name) .bind(&encrypted_password) .bind(user_id) .execute(pool) .await?; } crate::database::DatabasePool::MySQL(pool) => { sqlx::query("UPDATE Users SET GpodderUrl = ?, GpodderLoginName = ?, GpodderToken = ?, Pod_Sync_Type = 'nextcloud' WHERE UserID = ?") .bind(nextcloud_url) .bind(login_name) .bind(&encrypted_password) .bind(user_id) .execute(pool) .await?; } } Ok(()) } // Request struct for verify_gpodder_auth #[derive(Deserialize)] pub struct VerifyGpodderAuthRequest { pub gpodder_url: String, pub gpodder_username: String, pub gpodder_password: String, } // Verify gPodder authentication - matches Python verify_gpodder_auth function exactly pub async fn verify_gpodder_auth( State(state): State, headers: HeaderMap, Json(request): Json, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; // Direct HTTP call to match Python implementation exactly let client = reqwest::Client::new(); let auth_url = format!("{}/api/2/auth/{}/login.json", request.gpodder_url.trim_end_matches('/'), request.gpodder_username); match client .post(&auth_url) .basic_auth(&request.gpodder_username, Some(&request.gpodder_password)) .send() .await { Ok(response) => { if response.status().is_success() { Ok(Json(serde_json::json!({"status": "success", "message": "Logged in!"}))) } else { Err(AppError::unauthorized("Authentication failed")) } } Err(_) => { Err(AppError::internal("Internal Server Error")) } } } // Request struct for add_gpodder_server #[derive(Deserialize)] pub struct AddGpodderServerRequest { pub gpodder_url: String, pub gpodder_username: String, pub gpodder_password: String, } // Add gPodder server - matches Python add_gpodder_server function exactly pub async fn add_gpodder_server( State(state): State, headers: HeaderMap, Json(request): Json, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; let user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; let success = state.db_pool.add_gpodder_server(user_id, &request.gpodder_url, &request.gpodder_username, &request.gpodder_password).await?; if success { Ok(Json(serde_json::json!({ "status": "success" }))) } else { Err(AppError::internal("Failed to add gPodder server")) } } // Get gPodder settings - matches Python get_gpodder_settings function exactly pub async fn get_gpodder_settings( State(state): State, Path(user_id): Path, headers: HeaderMap, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; // Check authorization - web key or own user let user_id_from_api_key = state.db_pool.get_user_id_from_api_key(&api_key).await?; let is_web_key = state.db_pool.is_web_key(&api_key).await?; if user_id != user_id_from_api_key && !is_web_key { return Err(AppError::forbidden("You can only view your own gPodder settings!")); } let settings = state.db_pool.get_gpodder_settings(user_id).await?; match settings { Some(settings) => Ok(Json(serde_json::json!({ "data": settings }))), None => Err(AppError::not_found("gPodder settings not found")), } } // Check gPodder settings - matches Python check_gpodder_settings function exactly pub async fn check_gpodder_settings( State(state): State, Path(user_id): Path, headers: HeaderMap, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; // Check authorization - web key or own user let user_id_from_api_key = state.db_pool.get_user_id_from_api_key(&api_key).await?; let is_web_key = state.db_pool.is_web_key(&api_key).await?; if user_id != user_id_from_api_key && !is_web_key { return Err(AppError::forbidden("You can only check your own gPodder settings!")); } let has_settings = state.db_pool.check_gpodder_settings(user_id).await?; Ok(Json(serde_json::json!({ "data": has_settings }))) } // Remove podcast sync - matches Python remove_podcast_sync function exactly #[derive(Debug, serde::Deserialize)] pub struct RemoveSyncRequest { pub user_id: i32, } pub async fn remove_podcast_sync( State(state): State, headers: HeaderMap, Json(request): Json, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; // Check if the user has permission to modify this user's data let user_id_from_api_key = state.db_pool.get_user_id_from_api_key(&api_key).await?; let is_web_key = state.db_pool.is_web_key(&api_key).await?; if request.user_id != user_id_from_api_key && !is_web_key { return Err(AppError::forbidden("You are not authorized to modify these user settings")); } // Remove the sync settings let success = state.db_pool.remove_gpodder_settings(request.user_id).await?; if success { Ok(Json(serde_json::json!({ "success": true, "message": "Podcast sync settings removed successfully" }))) } else { Err(AppError::internal("Failed to remove podcast sync settings")) } } // === NEW ENDPOINTS - REMAINING SETTINGS === // Request struct for add_custom_podcast #[derive(Deserialize)] pub struct CustomPodcastRequest { pub feed_url: String, pub user_id: i32, pub username: Option, pub password: Option, pub youtube_channel: Option, pub feed_cutoff: Option, } // Request struct for import_opml #[derive(Deserialize)] pub struct OpmlImportRequest { pub podcasts: Vec, pub user_id: i32, } // Response struct for import_progress #[derive(Serialize)] pub struct ImportProgressResponse { pub current: i32, pub total: i32, pub current_podcast: String, } // Request struct for notification_settings #[derive(Deserialize)] pub struct NotificationSettingsRequest { pub user_id: i32, pub platform: String, pub enabled: bool, pub ntfy_topic: Option, pub ntfy_server_url: Option, pub ntfy_username: Option, pub ntfy_password: Option, pub ntfy_access_token: Option, pub gotify_url: Option, pub gotify_token: Option, pub http_url: Option, pub http_token: Option, pub http_method: Option, } // Request struct for test_notification #[derive(Deserialize)] pub struct NotificationTestRequest { pub user_id: i32, pub platform: String, } // Request struct for add_oidc_provider #[derive(Deserialize)] pub struct OidcProviderRequest { pub provider_name: String, pub client_id: String, pub client_secret: String, pub authorization_url: String, pub token_url: String, pub user_info_url: String, pub button_text: String, pub scope: String, pub button_color: String, pub button_text_color: String, pub icon_svg: Option, pub name_claim: Option, pub email_claim: Option, pub username_claim: Option, pub roles_claim: Option, pub user_role: Option, pub admin_role: Option, } // Query structs for user_id parameters #[derive(Deserialize)] pub struct UserIdQuery { pub user_id: i32, } #[derive(Deserialize)] pub struct StartpageQuery { pub user_id: i32, pub startpage: Option, } // Add custom podcast - matches Python add_custom_podcast function exactly pub async fn add_custom_podcast( State(state): State, headers: HeaderMap, Json(request): Json, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; // Check authorization - user can only add podcasts for themselves let user_id_from_api_key = state.db_pool.get_user_id_from_api_key(&api_key).await?; let is_web_key = state.db_pool.is_web_key(&api_key).await?; if request.user_id != user_id_from_api_key && !is_web_key { return Err(AppError::forbidden("You can only add podcasts for yourself!")); } // Check if this is a YouTube channel request if request.youtube_channel.unwrap_or(false) { // Extract channel ID from YouTube URL let channel_id = extract_youtube_channel_id(&request.feed_url)?; // Check if channel already exists let existing_id = state.db_pool.check_existing_channel_subscription( &channel_id, request.user_id, ).await?; if let Some(podcast_id) = existing_id { // Channel already subscribed, return existing podcast details let podcast_details = state.db_pool.get_podcast_details(request.user_id, podcast_id).await?; return Ok(Json(serde_json::json!({ "data": podcast_details }))); } // Get channel info using yt-dlp (bypasses Google API limits) let channel_info = crate::handlers::youtube::get_youtube_channel_info(&channel_id).await?; let feed_cutoff = request.feed_cutoff.unwrap_or(30); // Add YouTube channel to database let podcast_id = state.db_pool.add_youtube_channel( &channel_info, request.user_id, feed_cutoff, ).await?; // Spawn background task to process YouTube videos let state_clone = state.clone(); let channel_id_clone = channel_id.clone(); tokio::spawn(async move { if let Err(e) = crate::handlers::youtube::process_youtube_channel( podcast_id, &channel_id_clone, feed_cutoff, &state_clone ).await { println!("Error processing YouTube channel {}: {}", channel_id_clone, e); } }); // Get complete podcast details for response let podcast_details = state.db_pool.get_podcast_details(request.user_id, podcast_id).await?; return Ok(Json(serde_json::json!({ "data": podcast_details }))); } // Regular podcast feed handling // Get podcast values from feed URL let podcast_values = state.db_pool.get_podcast_values( &request.feed_url, request.user_id, request.username.as_deref(), request.password.as_deref() ).await?; // Add podcast with 30 episode cutoff (matches Python default) let (podcast_id, _) = state.db_pool.add_podcast_from_values( &podcast_values, request.user_id, 30, request.username.as_deref(), request.password.as_deref() ).await?; // Get complete podcast details for response let podcast_details = state.db_pool.get_podcast_details(request.user_id, podcast_id).await?; Ok(Json(serde_json::json!({ "data": podcast_details }))) } // Helper function to extract YouTube channel ID from various URL formats fn extract_youtube_channel_id(url: &str) -> Result { // Support various YouTube URL formats: // - https://www.youtube.com/channel/UC... // - https://youtube.com/channel/UC... // - https://www.youtube.com/@channelname // - youtube.com/@channelname // - Just the channel ID itself: UC... let url_lower = url.to_lowercase(); // If it's already a channel ID (starts with UC) if url.starts_with("UC") && !url.contains('/') && !url.contains('.') { return Ok(url.to_string()); } // Extract from /channel/ URLs if url_lower.contains("/channel/") { if let Some(channel_part) = url.split("/channel/").nth(1) { let channel_id = channel_part.split(&['/', '?', '&'][..]).next().unwrap_or(""); if !channel_id.is_empty() { return Ok(channel_id.to_string()); } } } // For @handle URLs, we need to use yt-dlp to resolve the channel ID // This will be handled by get_youtube_channel_info, so we return the URL as-is if url_lower.contains("/@") || url.starts_with('@') { return Ok(url.to_string()); } Err(AppError::bad_request(&format!( "Invalid YouTube channel URL. Expected format: https://www.youtube.com/channel/UC... or https://www.youtube.com/@channelname or just the channel ID. Got: {}", url ))) } // Import OPML - matches Python import_opml function exactly with background processing pub async fn import_opml( State(state): State, headers: HeaderMap, Json(request): Json, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; // Check authorization let user_id_from_api_key = state.db_pool.get_user_id_from_api_key(&api_key).await?; let is_web_key = state.db_pool.is_web_key(&api_key).await?; if request.user_id != user_id_from_api_key && !is_web_key { return Err(AppError::forbidden("You can only import OPML for yourself!")); } let total_podcasts = request.podcasts.len(); // Initialize progress tracking in Redis/Valkey state.import_progress_manager.start_import(request.user_id, total_podcasts as i32).await?; // Spawn background task for OPML processing let state_clone = state.clone(); let podcasts = request.podcasts.clone(); let user_id = request.user_id; tokio::spawn(async move { for (index, feed_url) in podcasts.iter().enumerate() { // Update progress let _ = state_clone.import_progress_manager.update_progress( user_id, index as i32, feed_url ).await; // Process podcast (with error handling to continue on failures) match state_clone.db_pool.get_podcast_values(feed_url, user_id, None, None).await { Ok(podcast_values) => { let _ = state_clone.db_pool.add_podcast_from_values( &podcast_values, user_id, 30, // feed_cutoff None, // username None // password ).await; } Err(e) => { tracing::error!("Failed to import podcast {}: {}", feed_url, e); // Continue with next podcast } } // Small delay between imports (matches Python 0.1s delay) tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; } // Clear progress when complete let _ = state_clone.import_progress_manager.clear_progress(user_id).await; }); Ok(Json(serde_json::json!({ "message": "OPML import started", "total": total_podcasts }))) } // Import progress webhook - matches Python import_progress function exactly pub async fn import_progress( State(state): State, Path(user_id): Path, headers: HeaderMap, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; // Check authorization - user can only check their own progress let user_id_from_api_key = state.db_pool.get_user_id_from_api_key(&api_key).await?; let is_web_key = state.db_pool.is_web_key(&api_key).await?; if user_id != user_id_from_api_key && !is_web_key { return Err(AppError::forbidden("You can only check your own import progress!")); } let (current, total, current_podcast) = state.import_progress_manager.get_progress(user_id).await?; let progress = ImportProgressResponse { current, total, current_podcast, }; Ok(Json(progress)) } // Get notification settings - matches Python notification_settings GET function exactly pub async fn get_notification_settings( State(state): State, Query(query): Query, headers: HeaderMap, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; // Check authorization let user_id_from_api_key = state.db_pool.get_user_id_from_api_key(&api_key).await?; let is_web_key = state.db_pool.is_web_key(&api_key).await?; if query.user_id != user_id_from_api_key && !is_web_key { return Err(AppError::forbidden("You can only view your own notification settings!")); } let settings = state.db_pool.get_notification_settings(query.user_id).await?; Ok(Json(serde_json::json!({ "settings": settings }))) } // Update notification settings - matches Python notification_settings PUT function exactly pub async fn update_notification_settings( State(state): State, headers: HeaderMap, Json(request): Json, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; // Check authorization let user_id_from_api_key = state.db_pool.get_user_id_from_api_key(&api_key).await?; let is_web_key = state.db_pool.is_web_key(&api_key).await?; if request.user_id != user_id_from_api_key && !is_web_key { return Err(AppError::forbidden("You can only update your own notification settings!")); } state.db_pool.update_notification_settings( request.user_id, &request.platform, request.enabled, request.ntfy_topic.as_deref(), request.ntfy_server_url.as_deref(), request.ntfy_username.as_deref(), request.ntfy_password.as_deref(), request.ntfy_access_token.as_deref(), request.gotify_url.as_deref(), request.gotify_token.as_deref(), request.http_url.as_deref(), request.http_token.as_deref(), request.http_method.as_deref() ).await?; Ok(Json(serde_json::json!({ "detail": "Notification settings updated successfully" }))) } // Test notification - matches Python test_notification function exactly pub async fn test_notification( State(state): State, headers: HeaderMap, Json(request): Json, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; // Check authorization let user_id_from_api_key = state.db_pool.get_user_id_from_api_key(&api_key).await?; let is_web_key = state.db_pool.is_web_key(&api_key).await?; if request.user_id != user_id_from_api_key && !is_web_key { return Err(AppError::forbidden("You can only test your own notifications!")); } // Get notification settings and send test notification let settings = state.db_pool.get_notification_settings(request.user_id).await?; // Find settings for the specific platform let platform_settings = settings.iter() .find(|s| s.get("platform").and_then(|p| p.as_str()) == Some(&request.platform)) .ok_or_else(|| AppError::bad_request(&format!("No settings found for platform: {}", request.platform)))?; let success = state.notification_manager.send_test_notification(request.user_id, &request.platform, platform_settings).await?; if success { Ok(Json(serde_json::json!({ "detail": "Test notification sent successfully" }))) } else { Err(AppError::bad_request("Failed to send test notification - check your settings")) } } // Add OIDC provider - matches Python add_oidc_provider function exactly pub async fn add_oidc_provider( State(state): State, headers: HeaderMap, Json(request): Json, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; // Check if user is admin - OIDC provider management requires admin access let user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; let is_admin = state.db_pool.user_admin_check(user_id).await?; if !is_admin { return Err(AppError::forbidden("Admin access required to add OIDC providers")); } let provider_id = state.db_pool.add_oidc_provider( &request.provider_name, &request.client_id, &request.client_secret, &request.authorization_url, &request.token_url, &request.user_info_url, &request.button_text, &request.scope, &request.button_color, &request.button_text_color, request.icon_svg.as_deref().unwrap_or(""), request.name_claim.as_deref().unwrap_or("name"), request.email_claim.as_deref().unwrap_or("email"), request.username_claim.as_deref().unwrap_or("username"), request.roles_claim.as_deref().unwrap_or(""), request.user_role.as_deref().unwrap_or(""), request.admin_role.as_deref().unwrap_or(""), false // initialized_from_env = false (added via UI) ).await?; Ok(Json(serde_json::json!({ "provider_id": provider_id }))) } // Update OIDC provider - updates an existing provider pub async fn update_oidc_provider( State(state): State, headers: HeaderMap, Path(provider_id): Path, Json(request): Json, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; // Check if user is admin - OIDC provider management requires admin access let user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; let is_admin = state.db_pool.user_admin_check(user_id).await?; if !is_admin { return Err(AppError::forbidden("Admin access required to update OIDC providers")); } // Only update client_secret if it's not empty let client_secret_to_update = if request.client_secret.is_empty() { None } else { Some(request.client_secret.as_str()) }; let success = state.db_pool.update_oidc_provider( provider_id, &request.provider_name, &request.client_id, client_secret_to_update, &request.authorization_url, &request.token_url, &request.user_info_url, &request.button_text, &request.scope, &request.button_color, &request.button_text_color, request.icon_svg.as_deref().unwrap_or(""), request.name_claim.as_deref().unwrap_or("name"), request.email_claim.as_deref().unwrap_or("email"), request.username_claim.as_deref().unwrap_or("username"), request.roles_claim.as_deref().unwrap_or(""), request.user_role.as_deref().unwrap_or(""), request.admin_role.as_deref().unwrap_or("") ).await?; if success { Ok(Json(serde_json::json!({ "message": "OIDC provider updated successfully" }))) } else { Err(AppError::not_found("OIDC provider not found")) } } // List OIDC providers - matches Python list_oidc_providers function exactly pub async fn list_oidc_providers( State(state): State, headers: HeaderMap, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; let providers = state.db_pool.list_oidc_providers().await?; Ok(Json(serde_json::json!({ "providers": providers }))) } // Remove OIDC provider - matches Python remove_oidc_provider function exactly pub async fn remove_oidc_provider( State(state): State, headers: HeaderMap, Json(provider_id): Json, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; // Check if user is admin - OIDC provider management requires admin access let user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; let is_admin = state.db_pool.user_admin_check(user_id).await?; if !is_admin { return Err(AppError::forbidden("Admin access required to remove OIDC providers")); } // Check if provider was initialized from environment variables let is_env_initialized = state.db_pool.is_oidc_provider_env_initialized(provider_id).await?; if is_env_initialized { return Err(AppError::forbidden("Cannot remove OIDC provider that was initialized from environment variables. Providers created from docker-compose environment variables are protected from removal to prevent login issues.")); } let success = state.db_pool.remove_oidc_provider(provider_id).await?; if success { Ok(Json(serde_json::json!({ "message": "OIDC provider removed successfully" }))) } else { Err(AppError::not_found("OIDC provider not found")) } } // Get startpage - matches Python startpage GET function exactly pub async fn get_startpage( State(state): State, Query(query): Query, headers: HeaderMap, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; // Check authorization let user_id_from_api_key = state.db_pool.get_user_id_from_api_key(&api_key).await?; let is_web_key = state.db_pool.is_web_key(&api_key).await?; if query.user_id != user_id_from_api_key && !is_web_key { return Err(AppError::forbidden("You can only view your own startpage setting!")); } let startpage = state.db_pool.get_startpage(query.user_id).await?; Ok(Json(serde_json::json!({ "StartPage": startpage }))) } // Update startpage - matches Python startpage POST function exactly pub async fn update_startpage( State(state): State, Query(query): Query, headers: HeaderMap, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; // Check authorization let user_id_from_api_key = state.db_pool.get_user_id_from_api_key(&api_key).await?; let is_web_key = state.db_pool.is_web_key(&api_key).await?; if query.user_id != user_id_from_api_key && !is_web_key { return Err(AppError::forbidden("You can only update your own startpage setting!")); } let startpage = query.startpage.unwrap_or_else(|| "home".to_string()); state.db_pool.update_startpage(query.user_id, &startpage).await?; Ok(Json(serde_json::json!({ "success": true, "message": "StartPage updated successfully" }))) } // Request struct for person subscribe #[derive(Deserialize)] pub struct PersonSubscribeRequest { pub person_name: String, pub person_img: String, pub podcast_id: i32, } // Request struct for person unsubscribe #[derive(Deserialize)] pub struct PersonUnsubscribeRequest { pub person_name: String, } // Subscribe to person - matches Python api_subscribe_to_person function exactly pub async fn subscribe_to_person( State(state): State, Path((user_id, person_id)): Path<(i32, i32)>, headers: HeaderMap, Json(request): Json, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; // Check authorization - web key or user can only subscribe for themselves let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; let is_web_key = state.db_pool.is_web_key(&api_key).await?; if key_id != user_id && !is_web_key { return Err(AppError::forbidden("You can only subscribe for yourself!")); } let person_db_id = state.db_pool.subscribe_to_person( user_id, person_id, &request.person_name, &request.person_img, request.podcast_id, ).await?; // Trigger immediate background task to process person subscription and gather episodes let db_pool = state.db_pool.clone(); let person_name = request.person_name.clone(); tokio::spawn(async move { match db_pool.process_person_subscription(user_id, person_db_id, person_name.clone()).await { Ok(_) => { tracing::info!("Successfully processed immediate person subscription for {}", person_name); } Err(e) => { tracing::error!("Failed to process immediate person subscription for {}: {}", person_name, e); } } }); Ok(Json(serde_json::json!({ "success": true, "message": "Successfully subscribed to person", "person_id": person_db_id }))) } // Unsubscribe from person - matches Python api_unsubscribe_from_person function exactly pub async fn unsubscribe_from_person( State(state): State, Path((user_id, person_id)): Path<(i32, i32)>, headers: HeaderMap, Json(request): Json, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; // Check authorization - web key or user can only unsubscribe for themselves let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; let is_web_key = state.db_pool.is_web_key(&api_key).await?; if key_id != user_id && !is_web_key { return Err(AppError::forbidden("You can only unsubscribe for yourself!")); } let success = state.db_pool.unsubscribe_from_person( user_id, person_id, &request.person_name, ).await?; if success { Ok(Json(serde_json::json!({ "success": true, "message": "Successfully unsubscribed from person" }))) } else { Ok(Json(serde_json::json!({ "success": false, "message": "Person subscription not found" }))) } } // Get person subscriptions - matches Python api_get_person_subscriptions function exactly pub async fn get_person_subscriptions( State(state): State, Path(user_id): Path, headers: HeaderMap, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; // Check authorization - web key or user can only get their own subscriptions let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; let is_web_key = state.db_pool.is_web_key(&api_key).await?; if key_id != user_id && !is_web_key { return Err(AppError::forbidden("You can only retrieve your own subscriptions!")); } let subscriptions = state.db_pool.get_person_subscriptions(user_id).await?; Ok(Json(serde_json::json!({ "subscriptions": subscriptions }))) } // Get person episodes - matches Python api_return_person_episodes function exactly pub async fn get_person_episodes( State(state): State, Path((user_id, person_id)): Path<(i32, i32)>, headers: HeaderMap, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; // Check authorization - web key or user can only get their own subscriptions let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; let is_web_key = state.db_pool.is_web_key(&api_key).await?; if key_id != user_id && !is_web_key { return Err(AppError::forbidden("You can only retrieve your own person episodes!")); } let episodes = state.db_pool.get_person_episodes(user_id, person_id).await?; Ok(Json(serde_json::json!({ "episodes": episodes }))) } // Request struct for set_podcast_playback_speed - matches Python SetPlaybackSpeedPodcast model #[derive(Deserialize)] pub struct SetPlaybackSpeedPodcast { pub user_id: i32, pub podcast_id: i32, pub playback_speed: f64, } // Set podcast playback speed - matches Python api_set_podcast_playback_speed endpoint pub async fn set_podcast_playback_speed( State(state): State, headers: HeaderMap, Json(request): Json, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; // Check authorization - web key or user can only modify their own podcasts let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; let is_web_key = state.db_pool.is_web_key(&api_key).await?; if key_id != request.user_id && !is_web_key { return Err(AppError::forbidden("You can only modify your own podcasts.")); } state.db_pool.set_podcast_playback_speed(request.user_id, request.podcast_id, request.playback_speed).await?; Ok(Json(serde_json::json!({ "detail": "Default podcast playback speed updated." }))) } // Request struct for enable_auto_download - matches Python AutoDownloadRequest model #[derive(Deserialize)] pub struct AutoDownloadRequest { pub podcast_id: i32, pub auto_download: bool, pub user_id: i32, } // Enable/disable auto download for podcast - matches Python api_enable_auto_download endpoint pub async fn enable_auto_download( State(state): State, headers: HeaderMap, Json(request): Json, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; // Check authorization - user can only modify their own podcasts let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; if key_id != request.user_id { return Err(AppError::forbidden("You can only modify your own podcasts.")); } state.db_pool.enable_auto_download(request.podcast_id, request.auto_download, request.user_id).await?; Ok(Json(serde_json::json!({ "detail": "Auto-download status updated." }))) } // Request struct for toggle_podcast_notifications - matches Python TogglePodcastNotificationData model #[derive(Deserialize)] pub struct TogglePodcastNotificationData { pub user_id: i32, pub podcast_id: i32, pub enabled: bool, } // Toggle podcast notifications - matches Python api_toggle_podcast_notifications endpoint pub async fn toggle_podcast_notifications( State(state): State, headers: HeaderMap, Json(request): Json, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; // Check authorization - web key or user can only modify their own podcasts let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; let is_web_key = state.db_pool.is_web_key(&api_key).await?; if key_id != request.user_id && !is_web_key { return Err(AppError::forbidden("Invalid API key")); } let success = state.db_pool.toggle_podcast_notifications(request.user_id, request.podcast_id, request.enabled).await?; if success { Ok(Json(serde_json::json!({ "detail": "Notification settings updated successfully" }))) } else { Ok(Json(serde_json::json!({ "detail": "Failed to update notification settings" }))) } } // Request struct for adjust_skip_times - matches Python SkipTimesRequest model #[derive(Deserialize)] pub struct SkipTimesRequest { pub podcast_id: i32, #[serde(default)] pub start_skip: i32, #[serde(default)] pub end_skip: i32, pub user_id: i32, } // Adjust skip times for podcast - matches Python api_adjust_skip_times endpoint pub async fn adjust_skip_times( State(state): State, headers: HeaderMap, Json(request): Json, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; // Check authorization - web key or user can only modify their own podcasts let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; let is_web_key = state.db_pool.is_web_key(&api_key).await?; if key_id != request.user_id && !is_web_key { return Err(AppError::forbidden("You can only modify your own podcasts.")); } state.db_pool.adjust_skip_times(request.podcast_id, request.start_skip, request.end_skip, request.user_id).await?; Ok(Json(serde_json::json!({ "detail": "Skip times updated." }))) } // Request struct for remove_category - matches Python RemoveCategoryData model #[derive(Deserialize)] pub struct RemoveCategoryData { pub podcast_id: i32, pub user_id: i32, pub category: String, } // Remove category from podcast - matches Python api_remove_category endpoint pub async fn remove_category( State(state): State, headers: HeaderMap, Json(request): Json, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; // Check authorization - user can only modify their own podcasts let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; if key_id != request.user_id { return Err(AppError::forbidden("You can only modify categories of your own podcasts!")); } state.db_pool.remove_category(request.podcast_id, request.user_id, &request.category).await?; Ok(Json(serde_json::json!({ "detail": "Category removed." }))) } // Request struct for add_category - matches Python AddCategoryData model #[derive(Deserialize)] pub struct AddCategoryData { pub podcast_id: i32, pub user_id: i32, pub category: String, } // Add category to podcast - matches Python api_add_category endpoint pub async fn add_category( State(state): State, headers: HeaderMap, Json(request): Json, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; // Check authorization - web key or user can only modify their own podcasts let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; let is_web_key = state.db_pool.is_web_key(&api_key).await?; if key_id != request.user_id && !is_web_key { return Err(AppError::forbidden("You can only modify categories of your own podcasts!")); } let result = state.db_pool.add_category(request.podcast_id, request.user_id, &request.category).await?; Ok(Json(serde_json::json!({ "detail": result }))) } // Get user RSS key - matches Python get_user_rss_key endpoint pub async fn get_user_rss_key( State(state): State, headers: HeaderMap, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; if key_id == 0 { return Err(AppError::forbidden("Invalid API key")); } let rss_key = state.db_pool.get_user_rss_key(key_id).await?; if let Some(key) = rss_key { Ok(Json(serde_json::json!({ "rss_key": key }))) } else { Err(AppError::not_found("No RSS key found. Please enable RSS feeds first.")) } } #[derive(Deserialize)] pub struct VerifyMfaRequest { pub user_id: i32, pub mfa_code: String, } // Verify MFA code - matches Python verify_mfa endpoint pub async fn verify_mfa( State(state): State, headers: HeaderMap, Json(request): Json, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; // Check authorization if !check_user_access(&state, &api_key, request.user_id).await? { return Err(AppError::forbidden("You can only verify your own login code!")); } // Get the stored MFA secret let secret = state.db_pool.get_mfa_secret(request.user_id).await?; if let Some(secret_str) = secret { // Verify the TOTP code use totp_rs::{Algorithm, TOTP, Secret}; let totp = TOTP::new( Algorithm::SHA1, 6, 1, 30, Secret::Encoded(secret_str.clone()).to_bytes().unwrap(), Some("Pinepods".to_string()), "login".to_string(), ).map_err(|e| AppError::internal(&format!("Failed to create TOTP: {}", e)))?; let is_valid = totp.check_current(&request.mfa_code) .map_err(|e| AppError::internal(&format!("Failed to verify TOTP: {}", e)))?; Ok(Json(serde_json::json!({ "verified": is_valid }))) } else { Ok(Json(serde_json::json!({ "verified": false }))) } } // Scheduled backup management #[derive(Deserialize)] pub struct ScheduleBackupRequest { pub user_id: i32, pub cron_schedule: String, // e.g., "0 2 * * *" for daily at 2 AM pub enabled: bool, } #[derive(Deserialize)] pub struct GetScheduledBackupRequest { pub user_id: i32, } #[derive(Deserialize)] pub struct ListBackupFilesRequest { pub user_id: i32, } #[derive(Deserialize)] pub struct RestoreBackupFileRequest { pub user_id: i32, pub backup_filename: String, } // Schedule automatic backup - admin only pub async fn schedule_backup( State(state): State, headers: HeaderMap, Json(request): Json, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; // Check if user is admin let requesting_user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; let is_admin = state.db_pool.user_admin_check(requesting_user_id).await?; if !is_admin { return Err(AppError::forbidden("Admin access required")); } // Validate cron expression using tokio-cron-scheduler use tokio_cron_scheduler::Job; if let Err(_) = Job::new(&request.cron_schedule, |_uuid, _lock| {}) { return Err(AppError::bad_request("Invalid cron schedule format")); } // Store the schedule in database state.db_pool.set_scheduled_backup(request.user_id, &request.cron_schedule, request.enabled).await?; Ok(Json(serde_json::json!({ "detail": "Backup schedule updated successfully", "schedule": request.cron_schedule, "enabled": request.enabled }))) } // Get scheduled backup settings - admin only pub async fn get_scheduled_backup( State(state): State, headers: HeaderMap, Json(request): Json, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; // Check if user is admin let requesting_user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; let is_admin = state.db_pool.user_admin_check(requesting_user_id).await?; if !is_admin { return Err(AppError::forbidden("Admin access required")); } let schedule_info = state.db_pool.get_scheduled_backup(request.user_id).await?; Ok(Json(serde_json::json!(schedule_info))) } // List backup files in mounted backup directory - admin only pub async fn list_backup_files( State(state): State, headers: HeaderMap, Json(request): Json, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; // Check if user is admin let requesting_user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; let is_admin = state.db_pool.user_admin_check(requesting_user_id).await?; if !is_admin { return Err(AppError::forbidden("Admin access required")); } use std::fs; let backup_dir = "/opt/pinepods/backups"; let backup_files = match fs::read_dir(backup_dir) { Ok(entries) => { let mut files = Vec::new(); for entry in entries { if let Ok(entry) = entry { let path = entry.path(); if path.is_file() && path.extension().map_or(false, |ext| ext == "sql") { if let Some(filename) = path.file_name().and_then(|n| n.to_str()) { let metadata = entry.metadata().ok(); let size = metadata.as_ref().map(|m| m.len()).unwrap_or(0); let modified = metadata.as_ref() .and_then(|m| m.modified().ok()) .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) .map(|d| d.as_secs()) .unwrap_or(0); files.push(serde_json::json!({ "filename": filename, "size": size, "modified": modified })); } } } } files.sort_by(|a, b| { let a_modified = a["modified"].as_u64().unwrap_or(0); let b_modified = b["modified"].as_u64().unwrap_or(0); b_modified.cmp(&a_modified) // Sort by modified date desc (newest first) }); files } Err(_) => { return Err(AppError::internal("Failed to read backup directory")); } }; Ok(Json(serde_json::json!({ "backup_files": backup_files }))) } // Restore from backup file in mounted directory - admin only pub async fn restore_from_backup_file( State(state): State, headers: HeaderMap, Json(request): Json, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; // Check if user is admin let requesting_user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; let is_admin = state.db_pool.user_admin_check(requesting_user_id).await?; if !is_admin { return Err(AppError::forbidden("Admin access required")); } // Validate filename to prevent path traversal let backup_filename = request.backup_filename.clone(); if backup_filename.contains("..") || backup_filename.contains("/") || !backup_filename.ends_with(".sql") { return Err(AppError::bad_request("Invalid backup filename")); } let backup_path = format!("/opt/pinepods/backups/{}", backup_filename); // Check if file exists if !std::path::Path::new(&backup_path).exists() { return Err(AppError::not_found("Backup file not found")); } // Clone for the async closure let backup_filename_for_closure = backup_filename.clone(); // Spawn restoration task let task_id = state.task_spawner.spawn_progress_task( "restore_from_backup_file".to_string(), 0, // System user move |reporter| { let backup_path = backup_path.clone(); let backup_filename = backup_filename_for_closure; async move { reporter.update_progress(10.0, Some("Starting restoration from backup file...".to_string())).await?; // Get database password from environment let db_password = std::env::var("DB_PASSWORD") .map_err(|_| AppError::internal("Database password not found in environment"))?; reporter.update_progress(50.0, Some("Restoring database...".to_string())).await?; // Execute restoration based on database type use tokio::process::Command; let db_type = std::env::var("DB_TYPE").unwrap_or_else(|_| "postgresql".to_string()); let db_host = std::env::var("DB_HOST").unwrap_or_else(|_| "localhost".to_string()); let db_name = std::env::var("DB_NAME").unwrap_or_else(|_| "pinepods_database".to_string()); let output = if db_type.to_lowercase().contains("mysql") || db_type.to_lowercase().contains("mariadb") { let db_port = std::env::var("DB_PORT").unwrap_or_else(|_| "3306".to_string()); let db_user = std::env::var("DB_USER").unwrap_or_else(|_| "root".to_string()); let mut cmd = Command::new("mysql"); cmd.arg("-h").arg(&db_host) .arg("-P").arg(&db_port) .arg("-u").arg(&db_user) .arg(&format!("-p{}", db_password)) .arg("--ssl-verify-server-cert=0") .arg(&db_name); // For MySQL, we need to pipe the file content to stdin cmd.stdin(std::process::Stdio::piped()); let mut child = cmd.spawn() .map_err(|e| AppError::internal(&format!("Failed to execute mysql: {}", e)))?; // Read the backup file and send to mysql stdin let backup_content = tokio::fs::read_to_string(&backup_path).await .map_err(|e| AppError::internal(&format!("Failed to read backup file: {}", e)))?; if let Some(stdin) = child.stdin.as_mut() { use tokio::io::AsyncWriteExt; stdin.write_all(backup_content.as_bytes()).await .map_err(|e| AppError::internal(&format!("Failed to write to mysql stdin: {}", e)))?; } child.wait_with_output().await .map_err(|e| AppError::internal(&format!("Failed to wait for mysql: {}", e)))? } else { // PostgreSQL let db_port = std::env::var("DB_PORT").unwrap_or_else(|_| "5432".to_string()); let db_user = std::env::var("DB_USER").unwrap_or_else(|_| "postgres".to_string()); let mut cmd = Command::new("psql"); cmd.arg("-h").arg(&db_host) .arg("-p").arg(&db_port) .arg("-U").arg(&db_user) .arg("-d").arg(&db_name) .arg("-f").arg(&backup_path) .env("PGPASSWORD", &db_password); cmd.output().await .map_err(|e| AppError::internal(&format!("Failed to execute psql: {}", e)))? }; if !output.status.success() { let error_msg = String::from_utf8_lossy(&output.stderr); return Err(AppError::internal(&format!("Restore failed: {}", error_msg))); } reporter.update_progress(100.0, Some("Restoration completed successfully".to_string())).await?; Ok(serde_json::json!({ "status": "Restoration completed successfully", "backup_file": backup_filename })) } } ).await?; Ok(Json(serde_json::json!({ "detail": "Restoration started", "task_id": task_id }))) } // Request struct for manual backup to directory #[derive(Deserialize)] pub struct ManualBackupRequest { pub user_id: i32, } // Manual backup to directory - admin only pub async fn manual_backup_to_directory( State(state): State, headers: HeaderMap, Json(request): Json, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; // Check if user is admin let requesting_user_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; let is_admin = state.db_pool.user_admin_check(requesting_user_id).await?; if !is_admin { return Err(AppError::forbidden("Admin access required")); } // Generate filename with timestamp let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S"); let backup_filename = format!("manual_backup_{}.sql", timestamp); let backup_path = format!("/opt/pinepods/backups/{}", backup_filename); // Ensure backup directory exists if let Err(e) = std::fs::create_dir_all("/opt/pinepods/backups") { return Err(AppError::internal(&format!("Failed to create backup directory: {}", e))); } // Set ownership using PUID/PGID environment variables let puid: u32 = std::env::var("PUID").unwrap_or_else(|_| "1000".to_string()).parse().unwrap_or(1000); let pgid: u32 = std::env::var("PGID").unwrap_or_else(|_| "1000".to_string()).parse().unwrap_or(1000); // Set directory ownership (ignore errors for NFS mounts) let _ = std::process::Command::new("chown") .args(&[format!("{}:{}", puid, pgid), "/opt/pinepods/backups".to_string()]) .output(); // Clone for the async closure let backup_filename_for_closure = backup_filename.clone(); // Spawn backup task let task_id = state.task_spawner.spawn_progress_task( "manual_backup_to_directory".to_string(), 0, // System user move |reporter| { let backup_path = backup_path.clone(); let backup_filename = backup_filename_for_closure; async move { reporter.update_progress(10.0, Some("Starting manual backup...".to_string())).await?; // Get database credentials from environment let db_type = std::env::var("DB_TYPE").unwrap_or_else(|_| "postgresql".to_string()); let db_host = std::env::var("DB_HOST").unwrap_or_else(|_| "localhost".to_string()); let db_name = std::env::var("DB_NAME").unwrap_or_else(|_| "pinepods_database".to_string()); let db_password = std::env::var("DB_PASSWORD") .map_err(|_| AppError::internal("Database password not found in environment"))?; reporter.update_progress(30.0, Some("Creating database backup...".to_string())).await?; // Use appropriate backup command based on database type let output = if db_type.to_lowercase().contains("mysql") || db_type.to_lowercase().contains("mariadb") { let db_port = std::env::var("DB_PORT").unwrap_or_else(|_| "3306".to_string()); let db_user = std::env::var("DB_USER").unwrap_or_else(|_| "root".to_string()); tokio::process::Command::new("mysqldump") .args(&[ "-h", &db_host, "-P", &db_port, "-u", &db_user, &format!("-p{}", db_password), "--single-transaction", "--routines", "--triggers", "--ssl-verify-server-cert=0", "--result-file", &backup_path, &db_name ]) .output() .await .map_err(|e| AppError::internal(&format!("Failed to execute mysqldump: {}", e)))? } else { // PostgreSQL let db_port = std::env::var("DB_PORT").unwrap_or_else(|_| "5432".to_string()); let db_user = std::env::var("DB_USER").unwrap_or_else(|_| "postgres".to_string()); tokio::process::Command::new("pg_dump") .env("PGPASSWORD", db_password) .args(&[ "-h", &db_host, "-p", &db_port, "-U", &db_user, "-d", &db_name, "--clean", "--if-exists", "--no-owner", "--no-privileges", "-f", &backup_path ]) .output() .await .map_err(|e| AppError::internal(&format!("Failed to execute pg_dump: {}", e)))? }; if !output.status.success() { let error_msg = String::from_utf8_lossy(&output.stderr); return Err(AppError::internal(&format!("Backup failed: {}", error_msg))); } reporter.update_progress(90.0, Some("Finalizing backup...".to_string())).await?; // Set file ownership using PUID/PGID environment variables let puid: u32 = std::env::var("PUID").unwrap_or_else(|_| "1000".to_string()).parse().unwrap_or(1000); let pgid: u32 = std::env::var("PGID").unwrap_or_else(|_| "1000".to_string()).parse().unwrap_or(1000); // Set backup file ownership (ignore errors for NFS mounts) let _ = std::process::Command::new("chown") .args(&[format!("{}:{}", puid, pgid), backup_path.clone()]) .output(); // Check if backup file was created and get its size let backup_info = match std::fs::metadata(&backup_path) { Ok(metadata) => serde_json::json!({ "filename": backup_filename, "size": metadata.len(), "path": backup_path }), Err(_) => { return Err(AppError::internal("Backup file was not created")); } }; reporter.update_progress(100.0, Some("Manual backup completed successfully".to_string())).await?; Ok(serde_json::json!({ "status": "Manual backup completed successfully", "backup_info": backup_info })) } } ).await?; Ok(Json(serde_json::json!({ "detail": "Manual backup started", "task_id": task_id, "filename": backup_filename }))) } // Request for getting podcasts with podcast_index_id = 0 #[derive(Deserialize)] pub struct GetUnmatchedPodcastsRequest { pub user_id: i32, } // Get podcasts that have podcast_index_id = 0 (imported via OPML without podcast index match) pub async fn get_unmatched_podcasts( headers: HeaderMap, State(state): State, Json(request): Json, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; // Verify API key let is_valid = state.db_pool.verify_api_key(&api_key).await?; if !is_valid { return Err(AppError::unauthorized("Invalid API key")); } // Check if it's web key or user's own key let is_web_key = state.db_pool.is_web_key(&api_key).await?; let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; if key_id == request.user_id || is_web_key { let podcasts = state.db_pool.get_unmatched_podcasts(request.user_id).await?; Ok(Json(serde_json::json!({"podcasts": podcasts}))) } else { Err(AppError::forbidden("You can only access your own podcasts")) } } // Request for updating podcast index ID #[derive(Deserialize)] pub struct UpdatePodcastIndexIdRequest { pub user_id: i32, pub podcast_id: i32, pub podcast_index_id: i32, } // Update a podcast's podcast_index_id pub async fn update_podcast_index_id( headers: HeaderMap, State(state): State, Json(request): Json, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; // Verify API key let is_valid = state.db_pool.verify_api_key(&api_key).await?; if !is_valid { return Err(AppError::unauthorized("Invalid API key")); } // Check if it's web key or user's own key let is_web_key = state.db_pool.is_web_key(&api_key).await?; let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; if key_id == request.user_id || is_web_key { state.db_pool.update_podcast_index_id( request.user_id, request.podcast_id, request.podcast_index_id ).await?; Ok(Json(serde_json::json!({ "detail": "Podcast index ID updated successfully" }))) } else { Err(AppError::forbidden("You can only update your own podcasts")) } } // Request for ignoring a podcast index ID #[derive(Deserialize)] pub struct IgnorePodcastIndexIdRequest { pub user_id: i32, pub podcast_id: i32, pub ignore: bool, } #[derive(Deserialize)] pub struct GetIgnoredPodcastsRequest { pub user_id: i32, } // Ignore/unignore a podcast's index ID requirement pub async fn ignore_podcast_index_id( headers: HeaderMap, State(state): State, Json(request): Json, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; // Verify API key let is_valid = state.db_pool.verify_api_key(&api_key).await?; if !is_valid { return Err(AppError::unauthorized("Invalid API key")); } // Check if it's web key or user's own key let is_web_key = state.db_pool.is_web_key(&api_key).await?; let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; if key_id == request.user_id || is_web_key { state.db_pool.ignore_podcast_index_id( request.user_id, request.podcast_id, request.ignore ).await?; let action = if request.ignore { "ignored" } else { "unignored" }; Ok(Json(serde_json::json!({ "detail": format!("Podcast index ID requirement {}", action) }))) } else { Err(AppError::forbidden("You can only update your own podcasts")) } } // Get podcasts that are ignored from podcast index matching pub async fn get_ignored_podcasts( headers: HeaderMap, State(state): State, Json(request): Json, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; // Verify API key let is_valid = state.db_pool.verify_api_key(&api_key).await?; if !is_valid { return Err(AppError::unauthorized("Invalid API key")); } // Check if it's web key or user's own key let is_web_key = state.db_pool.is_web_key(&api_key).await?; let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; if key_id == request.user_id || is_web_key { let podcasts = state.db_pool.get_ignored_podcasts(request.user_id).await?; Ok(Json(serde_json::json!({ "podcasts": podcasts }))) } else { Err(AppError::forbidden("You can only view your own podcasts")) } } // Get user's language preference pub async fn get_user_language( State(state): State, headers: HeaderMap, Query(params): Query>, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; let user_id: i32 = params .get("user_id") .ok_or_else(|| AppError::bad_request("Missing user_id parameter"))? .parse() .map_err(|_| AppError::bad_request("Invalid user_id format"))?; check_user_access(&state, &api_key, user_id).await?; let language = state.db_pool.get_user_language(user_id).await?; Ok(Json(UserLanguageResponse { language })) } // Update user's language preference pub async fn update_user_language( State(state): State, headers: HeaderMap, Json(request): Json, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; check_user_access(&state, &api_key, request.user_id).await?; let success = state.db_pool.update_user_language(request.user_id, &request.language).await?; if success { Ok(Json(serde_json::json!({ "success": true, "language": request.language }))) } else { Err(AppError::not_found("User not found")) } } // Get available languages by scanning translation files pub async fn get_available_languages() -> Result, AppError> { let translations_dir = std::path::Path::new("/var/www/html/static/translations"); let mut languages = Vec::new(); if let Ok(entries) = std::fs::read_dir(translations_dir) { for entry in entries.flatten() { if let Some(file_name) = entry.file_name().to_str() { if file_name.ends_with(".json") { let lang_code = file_name.strip_suffix(".json").unwrap_or(""); // Map language codes to human-readable names let lang_name = match lang_code { "en" => "English", "ar" => "العربية", "be" => "Беларуская", "bg" => "Български", "bn" => "বাংলা", "ca" => "Català", "cs" => "Čeština", "da" => "Dansk", "de" => "Deutsch", "es" => "Español", "et" => "Eesti", "eu" => "Euskera", "fa" => "فارسی", "fi" => "Suomi", "fr" => "Français", "gu" => "ગુજરાતી", "he" => "עברית", "hi" => "हिन्दी", "hr" => "Hrvatski", "hu" => "Magyar", "it" => "Italiano", "ja" => "日本語", "ko" => "한국어", "lt" => "Lietuvių", "nb" => "Norsk Bokmål", "nl" => "Nederlands", "pl" => "Polski", "pt" => "Português", "pt-BR" => "Português (Brasil)", "ro" => "Română", "ru" => "Русский", "sk" => "Slovenčina", "sl" => "Slovenščina", "sv" => "Svenska", "tr" => "Türkçe", "uk" => "Українська", "vi" => "Tiếng Việt", "zh" => "中文", "zh-Hans" => "中文 (简体)", "zh-Hant" => "中文 (繁體)", "test" => "Test Language", _ => lang_code, // Fallback to code if name not mapped }; // Validate that the translation file contains valid JSON if let Ok(content) = std::fs::read_to_string(entry.path()) { if serde_json::from_str::(&content).is_ok() { languages.push(AvailableLanguage { code: lang_code.to_string(), name: lang_name.to_string(), }); } } } } } } // Sort by language code for consistent ordering languages.sort_by(|a, b| a.code.cmp(&b.code)); // Ensure English is always first if present if let Some(en_index) = languages.iter().position(|l| l.code == "en") { if en_index != 0 { let en_lang = languages.remove(en_index); languages.insert(0, en_lang); } } Ok(Json(AvailableLanguagesResponse { languages })) } // Get server default language (no authentication required) pub async fn get_server_default_language() -> Result, AppError> { // Get default language from environment variable, fallback to 'en' let default_language = std::env::var("DEFAULT_LANGUAGE").unwrap_or_else(|_| "en".to_string()); // Validate language code (basic validation) let default_language = if default_language.len() > 10 || default_language.is_empty() { "en" } else { &default_language }; Ok(Json(serde_json::json!({ "default_language": default_language }))) } // Request struct for set_global_podcast_cover_preference - matches playback speed pattern #[derive(Deserialize)] pub struct SetGlobalPodcastCoverPreference { pub user_id: i32, pub use_podcast_covers: bool, pub podcast_id: Option, } // Set global podcast cover preference - matches Python api_set_global_podcast_cover_preference function pub async fn set_global_podcast_cover_preference( State(state): State, headers: HeaderMap, Json(request): Json, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; // Check authorization - web key or user can only set their own preference let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; let is_web_key = state.db_pool.is_web_key(&api_key).await?; if key_id != request.user_id && !is_web_key { return Err(AppError::forbidden("You can only modify your own settings.")); } // If podcast_id is provided, set per-podcast preference; otherwise set global preference if let Some(podcast_id) = request.podcast_id { state.db_pool.set_podcast_cover_preference(request.user_id, podcast_id, request.use_podcast_covers).await?; Ok(Json(serde_json::json!({ "detail": "Podcast cover preference updated." }))) } else { state.db_pool.set_global_podcast_cover_preference(request.user_id, request.use_podcast_covers).await?; Ok(Json(serde_json::json!({ "detail": "Global podcast cover preference updated." }))) } } // Request struct for set_podcast_cover_preference - matches podcast playback speed pattern #[derive(Deserialize)] pub struct SetPodcastCoverPreference { pub user_id: i32, pub podcast_id: i32, pub use_podcast_covers: bool, } // Set podcast cover preference - matches Python api_set_podcast_cover_preference function pub async fn set_podcast_cover_preference( State(state): State, headers: HeaderMap, Json(request): Json, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; // Check authorization - web key or user can only modify their own podcasts let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; let is_web_key = state.db_pool.is_web_key(&api_key).await?; if key_id != request.user_id && !is_web_key { return Err(AppError::forbidden("You can only modify your own podcasts.")); } state.db_pool.set_podcast_cover_preference(request.user_id, request.podcast_id, request.use_podcast_covers).await?; Ok(Json(serde_json::json!({ "detail": "Podcast cover preference updated." }))) } // Request struct for clear_podcast_cover_preference - matches clear playback speed pattern #[derive(Deserialize)] pub struct ClearPodcastCoverPreference { pub user_id: i32, pub podcast_id: i32, } // Clear podcast cover preference - matches Python api_clear_podcast_cover_preference function pub async fn clear_podcast_cover_preference( State(state): State, headers: HeaderMap, Json(request): Json, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; // Check authorization - web key or user can only modify their own podcasts let key_id = state.db_pool.get_user_id_from_api_key(&api_key).await?; let is_web_key = state.db_pool.is_web_key(&api_key).await?; if key_id != request.user_id && !is_web_key { return Err(AppError::forbidden("You can only modify your own podcasts.")); } state.db_pool.clear_podcast_cover_preference(request.user_id, request.podcast_id).await?; Ok(Json(serde_json::json!({ "detail": "Podcast cover preference cleared." }))) } // Get global podcast cover preference pub async fn get_global_podcast_cover_preference( State(state): State, headers: HeaderMap, Query(params): Query>, ) -> Result, AppError> { let api_key = extract_api_key(&headers)?; validate_api_key(&state, &api_key).await?; let user_id: i32 = params .get("user_id") .ok_or_else(|| AppError::bad_request("Missing user_id parameter"))? .parse() .map_err(|_| AppError::bad_request("Invalid user_id format"))?; // Check authorization - users can only access their own settings let user_id_from_api_key = state.db_pool.get_user_id_from_api_key(&api_key).await?; if user_id_from_api_key != user_id { return Err(AppError::forbidden("You can only access your own settings.")); } // If podcast_id is provided, get per-podcast preference; otherwise get global preference let use_podcast_covers = if let Some(podcast_id_str) = params.get("podcast_id") { let podcast_id: i32 = podcast_id_str .parse() .map_err(|_| AppError::bad_request("Invalid podcast_id format"))?; let per_podcast_preference = state.db_pool.get_podcast_cover_preference(user_id, podcast_id).await?; // If no per-podcast preference is set, fall back to global preference match per_podcast_preference { Some(preference) => preference, None => state.db_pool.get_global_podcast_cover_preference(user_id).await?, } } else { state.db_pool.get_global_podcast_cover_preference(user_id).await? }; Ok(Json(serde_json::json!({ "use_podcast_covers": use_podcast_covers }))) }