Files
PinePods-nix/PinePods-0.8.2/rust-api/src/handlers/settings.rs
2026-03-03 10:57:43 -05:00

4094 lines
156 KiB
Rust

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<AppState>,
headers: HeaderMap,
Json(request): Json<SetThemeRequest>,
) -> Result<Json<serde_json::Value>, 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<AppState>,
headers: HeaderMap,
Json(request): Json<SetPlaybackSpeedUser>,
) -> Result<Json<serde_json::Value>, 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<S>(value: &bool, serializer: S) -> Result<S::Ok, S::Error>
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<AppState>,
headers: HeaderMap,
) -> Result<Json<Vec<UserInfo>>, 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<AppState>,
Path(user_id): Path<i32>,
headers: HeaderMap,
) -> Result<Json<serde_json::Value>, 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<AppState>,
headers: HeaderMap,
Json(user_values): Json<AddUserRequest>,
) -> Result<Json<serde_json::Value>, 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<AppState>,
Json(user_values): Json<AddUserRequest>,
) -> Result<Json<serde_json::Value>, 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<AppState>,
Path(user_id): Path<i32>,
Query(params): Query<std::collections::HashMap<String, String>>,
headers: HeaderMap,
) -> Result<Json<serde_json::Value>, 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<AppState>,
Path(user_id): Path<i32>,
headers: HeaderMap,
Json(request): Json<PasswordUpdateRequest>,
) -> Result<Json<serde_json::Value>, 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<AppState>,
Path(user_id): Path<i32>,
headers: HeaderMap,
) -> Result<Json<serde_json::Value>, 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<AppState>,
headers: HeaderMap,
Json(request): Json<SetEmailRequest>,
) -> Result<Json<serde_json::Value>, 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<AppState>,
headers: HeaderMap,
Json(request): Json<SetUsernameRequest>,
) -> Result<Json<serde_json::Value>, 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<AppState>,
headers: HeaderMap,
Json(request): Json<SetIsAdminRequest>,
) -> Result<Json<serde_json::Value>, 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<AppState>,
Path(user_id): Path<i32>,
headers: HeaderMap,
) -> Result<Json<serde_json::Value>, 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<AppState>,
headers: HeaderMap,
) -> Result<Json<serde_json::Value>, 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<AppState>,
headers: HeaderMap,
) -> Result<Json<serde_json::Value>, 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<AppState>,
headers: HeaderMap,
) -> Result<Json<serde_json::Value>, 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<AppState>,
headers: HeaderMap,
) -> Result<Json<bool>, 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<AppState>,
headers: HeaderMap,
) -> Result<Json<bool>, 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<AppState>,
headers: HeaderMap,
) -> Result<Json<serde_json::Value>, 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<AppState>,
headers: HeaderMap,
) -> Result<Json<bool>, 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<AppState>,
headers: HeaderMap,
) -> Result<Json<serde_json::Value>, 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<i32, D::Error>
where
D: serde::Deserializer<'de>
{
use serde::de::Error;
let s = String::deserialize(deserializer)?;
s.parse::<i32>().map_err(D::Error::custom)
}
// Helper function to deserialize bool to i32
fn deserialize_bool_to_i32<'de, D>(deserializer: D) -> Result<i32, D::Error>
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<AppState>,
headers: HeaderMap,
Json(request): Json<SaveEmailSettingsRequest>,
) -> Result<Json<serde_json::Value>, 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<AppState>,
headers: HeaderMap,
) -> Result<Json<EmailSettingsResponse>, 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<AppState>,
headers: HeaderMap,
Json(request): Json<SendTestEmailRequest>,
) -> Result<Json<serde_json::Value>, 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<String, AppError> {
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#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{}</title>
<style>
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
margin: 0;
padding: 0;
background-color: #f8f9fa;
color: #333333;
}}
.email-container {{
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
overflow: hidden;
}}
.header {{
background: linear-gradient(135deg, #539e8a 0%, #4a8b7a 100%);
padding: 32px 24px;
text-align: center;
}}
.logo {{
width: 64px;
height: 64px;
margin: 0 auto 16px;
display: block;
border-radius: 12px;
background-color: rgba(255, 255, 255, 0.1);
padding: 8px;
}}
.header h1 {{
color: #ffffff;
margin: 0;
font-size: 28px;
font-weight: 600;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}}
.content {{
padding: 32px 24px;
line-height: 1.6;
}}
.content h2 {{
color: #539e8a;
margin: 0 0 16px 0;
font-size: 22px;
font-weight: 600;
}}
.content p {{
margin: 0 0 16px 0;
font-size: 16px;
}}
.code-block {{
background-color: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 6px;
padding: 16px;
font-family: 'Courier New', Consolas, monospace;
font-size: 18px;
font-weight: 600;
color: #539e8a;
text-align: center;
margin: 24px 0;
letter-spacing: 2px;
}}
.footer {{
background-color: #f8f9fa;
padding: 24px;
text-align: center;
border-top: 1px solid #e9ecef;
}}
.footer p {{
margin: 0;
font-size: 14px;
color: #6c757d;
}}
.footer a {{
color: #539e8a;
text-decoration: none;
}}
.footer a:hover {{
text-decoration: underline;
}}
</style>
</head>
<body>
<div class="email-container">
<div class="header">
<img src="data:image/png;base64,{}" alt="PinePods Logo" class="logo">
<h1>PinePods</h1>
</div>
<div class="content">
{}
</div>
<div class="footer">
<p>This email was sent from your PinePods server.</p>
<p>Visit <a href="https://github.com/madeofpendletonwool/PinePods">PinePods on GitHub</a> for more information.</p>
</div>
</div>
</body>
</html>"#, subject, logo_base64, content)
}
// Internal email sending function using lettre
async fn send_email_internal(request: &SendTestEmailRequest) -> Result<String, AppError> {
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#"
<h2>📧 Test Email</h2>
<p>This is a test email from your PinePods server to verify your email configuration is working correctly.</p>
<p><strong>Your message:</strong></p>
<p style="background-color: #f8f9fa; padding: 16px; border-radius: 6px; border-left: 4px solid #539e8a;">{}</p>
<p>If you received this email, your email settings are configured properly! 🎉</p>
"#, 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::<Tokio1Executor>::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::<Tokio1Executor>::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::<Tokio1Executor>::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::<Tokio1Executor>::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::<Tokio1Executor>::builder_dangerous(&request.server_name)
.port(port)
.credentials(creds)
.build()
} else {
AsyncSmtpTransport::<Tokio1Executor>::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<AppState>,
headers: HeaderMap,
Json(request): Json<SendEmailRequest>,
) -> Result<Json<serde_json::Value>, 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<String, AppError> {
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#"
<h2>🔐 Password Reset Request</h2>
<p>You have requested a password reset for your PinePods account.</p>
<p>Please use the following code to reset your password:</p>
<div class="code-block">{}</div>
<p><strong>Important:</strong></p>
<ul style="margin: 16px 0; padding-left: 20px;">
<li>This code will expire in <strong>10 minutes</strong></li>
<li>Only use this code if you requested a password reset</li>
<li>If you didn't request this, you can safely ignore this email</li>
</ul>
<p>For security reasons, never share this code with anyone.</p>
"#, reset_code);
(content, "PinePods - Password Reset Code".to_string())
} else {
// For other emails, wrap the message content
let content = format!(r#"
<h2>📧 {}</h2>
<div style="background-color: #f8f9fa; padding: 16px; border-radius: 6px; border-left: 4px solid #539e8a;">
{}
</div>
"#, request.subject, request.message.replace("\n", "<br>"));
(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::<Tokio1Executor>::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::<Tokio1Executor>::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::<Tokio1Executor>::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::<Tokio1Executor>::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::<Tokio1Executor>::builder_dangerous(&settings.server_name)
.port(settings.server_port as u16)
.credentials(creds)
.build()
} else {
AsyncSmtpTransport::<Tokio1Executor>::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<i32>,
}
// Get API info - matches Python api_get_api_info function exactly
pub async fn get_api_info(
State(state): State<AppState>,
Path(user_id): Path<i32>,
headers: HeaderMap,
) -> Result<Json<serde_json::Value>, 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<Vec<i32>>,
}
// Create API key - matches Python api_create_api_key function exactly
pub async fn create_api_key(
State(state): State<AppState>,
headers: HeaderMap,
Json(request): Json<CreateApiKeyRequest>,
) -> Result<Json<serde_json::Value>, 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<AppState>,
headers: HeaderMap,
Json(request): Json<DeleteApiKeyRequest>,
) -> Result<Json<serde_json::Value>, 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<AppState>,
headers: HeaderMap,
Json(request): Json<BackupUserRequest>,
) -> Result<String, 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 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<AppState>,
headers: HeaderMap,
Json(request): Json<BackupServerRequest>,
) -> Result<axum::response::Response, 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"));
}
// 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<axum::response::Response, String> {
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<Option<String>, 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<String, String> {
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<String, String> {
// 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<String, String> {
// 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<String, String> {
// 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::<String, _>(i) {
// Properly escape strings for PostgreSQL
let escaped = val.replace('\'', "''").replace('\\', "\\\\");
output.push_str(&format!("'{}'", escaped));
} else if let Ok(val) = row.try_get::<i32, _>(i) {
output.push_str(&val.to_string());
} else if let Ok(val) = row.try_get::<i64, _>(i) {
output.push_str(&val.to_string());
} else if let Ok(val) = row.try_get::<bool, _>(i) {
output.push_str(if val { "true" } else { "false" });
} else if let Ok(val) = row.try_get::<f64, _>(i) {
output.push_str(&val.to_string());
} else if let Ok(val) = row.try_get::<chrono::DateTime<chrono::Utc>, _>(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::<String, _>(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<String, String> {
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::<String, _>(i) {
// Properly escape strings for MySQL
let escaped = val.replace('\'', "''").replace('\\', "\\\\");
output.push_str(&format!("'{}'", escaped));
} else if let Ok(val) = row.try_get::<i32, _>(i) {
output.push_str(&val.to_string());
} else if let Ok(val) = row.try_get::<i64, _>(i) {
output.push_str(&val.to_string());
} else if let Ok(val) = row.try_get::<bool, _>(i) {
output.push_str(&val.to_string());
} else if let Ok(val) = row.try_get::<f64, _>(i) {
output.push_str(&val.to_string());
} else if let Ok(val) = row.try_get::<chrono::DateTime<chrono::Utc>, _>(i) {
output.push_str(&format!("'{}'", val.format("%Y-%m-%d %H:%M:%S")));
} else {
// Fallback: try to get as text
match row.try_get::<String, _>(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<AppState>,
headers: HeaderMap,
mut multipart: Multipart,
) -> Result<Json<serde_json::Value>, 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<AppState>,
Path(user_id): Path<i32>,
headers: HeaderMap,
) -> Result<Json<serde_json::Value>, 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<AppState>,
headers: HeaderMap,
Json(request): Json<VerifyTempMfaRequest>,
) -> Result<Json<serde_json::Value>, 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<AppState>,
Path(user_id): Path<i32>,
headers: HeaderMap,
) -> Result<Json<serde_json::Value>, 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<AppState>,
headers: HeaderMap,
Json(request): Json<SaveMfaSecretRequest>,
) -> Result<Json<serde_json::Value>, 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<AppState>,
headers: HeaderMap,
) -> Result<Json<serde_json::Value>, 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<AppState>,
headers: HeaderMap,
Json(request): Json<InitiateNextcloudLoginRequest>,
) -> Result<Json<serde_json::Value>, 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<AppState>,
headers: HeaderMap,
Json(request): Json<AddNextcloudServerRequest>,
) -> Result<Json<serde_json::Value>, 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<serde_json::Value, Box<dyn std::error::Error + Send + Sync>> {
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::<serde_json::Value>().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<AppState>,
headers: HeaderMap,
Json(request): Json<VerifyGpodderAuthRequest>,
) -> Result<Json<serde_json::Value>, 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<AppState>,
headers: HeaderMap,
Json(request): Json<AddGpodderServerRequest>,
) -> Result<Json<serde_json::Value>, 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<AppState>,
Path(user_id): Path<i32>,
headers: HeaderMap,
) -> Result<Json<serde_json::Value>, 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<AppState>,
Path(user_id): Path<i32>,
headers: HeaderMap,
) -> Result<Json<serde_json::Value>, 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<AppState>,
headers: HeaderMap,
Json(request): Json<RemoveSyncRequest>,
) -> Result<Json<serde_json::Value>, 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<String>,
pub password: Option<String>,
pub youtube_channel: Option<bool>,
pub feed_cutoff: Option<i32>,
}
// Request struct for import_opml
#[derive(Deserialize)]
pub struct OpmlImportRequest {
pub podcasts: Vec<String>,
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<String>,
pub ntfy_server_url: Option<String>,
pub ntfy_username: Option<String>,
pub ntfy_password: Option<String>,
pub ntfy_access_token: Option<String>,
pub gotify_url: Option<String>,
pub gotify_token: Option<String>,
pub http_url: Option<String>,
pub http_token: Option<String>,
pub http_method: Option<String>,
}
// 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<String>,
pub name_claim: Option<String>,
pub email_claim: Option<String>,
pub username_claim: Option<String>,
pub roles_claim: Option<String>,
pub user_role: Option<String>,
pub admin_role: Option<String>,
}
// 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<String>,
}
// Add custom podcast - matches Python add_custom_podcast function exactly
pub async fn add_custom_podcast(
State(state): State<AppState>,
headers: HeaderMap,
Json(request): Json<CustomPodcastRequest>,
) -> Result<Json<serde_json::Value>, 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<String, AppError> {
// 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<AppState>,
headers: HeaderMap,
Json(request): Json<OpmlImportRequest>,
) -> Result<Json<serde_json::Value>, 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<AppState>,
Path(user_id): Path<i32>,
headers: HeaderMap,
) -> Result<Json<ImportProgressResponse>, 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<AppState>,
Query(query): Query<UserIdQuery>,
headers: HeaderMap,
) -> Result<Json<serde_json::Value>, 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<AppState>,
headers: HeaderMap,
Json(request): Json<NotificationSettingsRequest>,
) -> Result<Json<serde_json::Value>, 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<AppState>,
headers: HeaderMap,
Json(request): Json<NotificationTestRequest>,
) -> Result<Json<serde_json::Value>, 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<AppState>,
headers: HeaderMap,
Json(request): Json<OidcProviderRequest>,
) -> Result<Json<serde_json::Value>, 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<AppState>,
headers: HeaderMap,
Path(provider_id): Path<i32>,
Json(request): Json<OidcProviderRequest>,
) -> Result<Json<serde_json::Value>, 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<AppState>,
headers: HeaderMap,
) -> Result<Json<serde_json::Value>, 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<AppState>,
headers: HeaderMap,
Json(provider_id): Json<i32>,
) -> Result<Json<serde_json::Value>, 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<AppState>,
Query(query): Query<UserIdQuery>,
headers: HeaderMap,
) -> Result<Json<serde_json::Value>, 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<AppState>,
Query(query): Query<StartpageQuery>,
headers: HeaderMap,
) -> Result<Json<serde_json::Value>, 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<AppState>,
Path((user_id, person_id)): Path<(i32, i32)>,
headers: HeaderMap,
Json(request): Json<PersonSubscribeRequest>,
) -> Result<Json<serde_json::Value>, 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<AppState>,
Path((user_id, person_id)): Path<(i32, i32)>,
headers: HeaderMap,
Json(request): Json<PersonUnsubscribeRequest>,
) -> Result<Json<serde_json::Value>, 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<AppState>,
Path(user_id): Path<i32>,
headers: HeaderMap,
) -> Result<Json<serde_json::Value>, 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<AppState>,
Path((user_id, person_id)): Path<(i32, i32)>,
headers: HeaderMap,
) -> Result<Json<serde_json::Value>, 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<AppState>,
headers: HeaderMap,
Json(request): Json<SetPlaybackSpeedPodcast>,
) -> Result<Json<serde_json::Value>, 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<AppState>,
headers: HeaderMap,
Json(request): Json<AutoDownloadRequest>,
) -> Result<Json<serde_json::Value>, 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<AppState>,
headers: HeaderMap,
Json(request): Json<TogglePodcastNotificationData>,
) -> Result<Json<serde_json::Value>, 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<AppState>,
headers: HeaderMap,
Json(request): Json<SkipTimesRequest>,
) -> Result<Json<serde_json::Value>, 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<AppState>,
headers: HeaderMap,
Json(request): Json<RemoveCategoryData>,
) -> Result<Json<serde_json::Value>, 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<AppState>,
headers: HeaderMap,
Json(request): Json<AddCategoryData>,
) -> Result<Json<serde_json::Value>, 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<AppState>,
headers: HeaderMap,
) -> Result<Json<serde_json::Value>, 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<AppState>,
headers: HeaderMap,
Json(request): Json<VerifyMfaRequest>,
) -> Result<Json<serde_json::Value>, 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<AppState>,
headers: HeaderMap,
Json(request): Json<ScheduleBackupRequest>,
) -> Result<Json<serde_json::Value>, 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<AppState>,
headers: HeaderMap,
Json(request): Json<GetScheduledBackupRequest>,
) -> Result<Json<serde_json::Value>, 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<AppState>,
headers: HeaderMap,
Json(request): Json<ListBackupFilesRequest>,
) -> Result<Json<serde_json::Value>, 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<AppState>,
headers: HeaderMap,
Json(request): Json<RestoreBackupFileRequest>,
) -> Result<Json<serde_json::Value>, 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<AppState>,
headers: HeaderMap,
Json(request): Json<ManualBackupRequest>,
) -> Result<Json<serde_json::Value>, 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<AppState>,
Json(request): Json<GetUnmatchedPodcastsRequest>,
) -> Result<Json<serde_json::Value>, 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<AppState>,
Json(request): Json<UpdatePodcastIndexIdRequest>,
) -> Result<Json<serde_json::Value>, 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<AppState>,
Json(request): Json<IgnorePodcastIndexIdRequest>,
) -> Result<Json<serde_json::Value>, 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<AppState>,
Json(request): Json<GetIgnoredPodcastsRequest>,
) -> Result<Json<serde_json::Value>, 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<AppState>,
headers: HeaderMap,
Query(params): Query<std::collections::HashMap<String, String>>,
) -> Result<Json<UserLanguageResponse>, 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<AppState>,
headers: HeaderMap,
Json(request): Json<LanguageUpdateRequest>,
) -> Result<Json<serde_json::Value>, 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<Json<AvailableLanguagesResponse>, 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::<serde_json::Value>(&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<Json<serde_json::Value>, 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<i32>,
}
// 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<AppState>,
headers: HeaderMap,
Json(request): Json<SetGlobalPodcastCoverPreference>,
) -> Result<Json<serde_json::Value>, 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<AppState>,
headers: HeaderMap,
Json(request): Json<SetPodcastCoverPreference>,
) -> Result<Json<serde_json::Value>, 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<AppState>,
headers: HeaderMap,
Json(request): Json<ClearPodcastCoverPreference>,
) -> Result<Json<serde_json::Value>, 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<AppState>,
headers: HeaderMap,
Query(params): Query<std::collections::HashMap<String, String>>,
) -> Result<Json<serde_json::Value>, 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
})))
}