use axum::{Json, extract::State}; use serde::{Deserialize, Serialize}; use tracing::{error, warn}; use tranquil_pds::api::error::{ApiError, DbResultExt}; use tranquil_pds::auth::{Admin, Auth}; use tranquil_pds::state::AppState; use tranquil_types::CidLink; #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct ServerConfigOutput { pub server_name: String, pub primary_color: Option, pub primary_color_dark: Option, pub secondary_color: Option, pub secondary_color_dark: Option, pub logo_cid: Option, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct UpdateServerConfigRequest { pub server_name: Option, pub primary_color: Option, pub primary_color_dark: Option, pub secondary_color: Option, pub secondary_color_dark: Option, pub logo_cid: Option, } #[derive(Serialize)] pub struct UpdateServerConfigOutput { pub success: bool, } fn is_valid_hex_color(s: &str) -> bool { if s.len() != 7 || !s.starts_with('#') { return false; } s[1..].chars().all(|c| c.is_ascii_hexdigit()) } pub async fn get_server_config( State(state): State, ) -> Result, ApiError> { let keys = &[ "server_name", "primary_color", "primary_color_dark", "secondary_color", "secondary_color_dark", "logo_cid", ]; let rows = state .repos .infra .get_server_configs(keys) .await .log_db_err("fetching server config")?; let config_map: std::collections::HashMap = rows.into_iter().collect(); Ok(Json(ServerConfigOutput { server_name: config_map .get("server_name") .cloned() .unwrap_or_else(|| "Tranquil PDS".to_string()), primary_color: config_map.get("primary_color").cloned(), primary_color_dark: config_map.get("primary_color_dark").cloned(), secondary_color: config_map.get("secondary_color").cloned(), secondary_color_dark: config_map.get("secondary_color_dark").cloned(), logo_cid: config_map.get("logo_cid").cloned(), })) } pub async fn update_server_config( State(state): State, _auth: Auth, Json(req): Json, ) -> Result, ApiError> { if let Some(server_name) = req.server_name { let trimmed = server_name.trim(); if trimmed.is_empty() || trimmed.len() > 100 { return Err(ApiError::InvalidRequest( "Server name must be 1-100 characters".into(), )); } state .repos .infra .upsert_server_config("server_name", trimmed) .await .log_db_err("upserting server_name")?; } if let Some(ref color) = req.primary_color { if color.is_empty() { state .repos .infra .delete_server_config("primary_color") .await .log_db_err("deleting primary_color")?; } else if is_valid_hex_color(color) { state .repos .infra .upsert_server_config("primary_color", color) .await .log_db_err("upserting primary_color")?; } else { return Err(ApiError::InvalidRequest( "Invalid primary color format (expected #RRGGBB)".into(), )); } } if let Some(ref color) = req.primary_color_dark { if color.is_empty() { state .repos .infra .delete_server_config("primary_color_dark") .await .log_db_err("deleting primary_color_dark")?; } else if is_valid_hex_color(color) { state .repos .infra .upsert_server_config("primary_color_dark", color) .await .log_db_err("upserting primary_color_dark")?; } else { return Err(ApiError::InvalidRequest( "Invalid primary dark color format (expected #RRGGBB)".into(), )); } } if let Some(ref color) = req.secondary_color { if color.is_empty() { state .repos .infra .delete_server_config("secondary_color") .await .log_db_err("deleting secondary_color")?; } else if is_valid_hex_color(color) { state .repos .infra .upsert_server_config("secondary_color", color) .await .log_db_err("upserting secondary_color")?; } else { return Err(ApiError::InvalidRequest( "Invalid secondary color format (expected #RRGGBB)".into(), )); } } if let Some(ref color) = req.secondary_color_dark { if color.is_empty() { state .repos .infra .delete_server_config("secondary_color_dark") .await .log_db_err("deleting secondary_color_dark")?; } else if is_valid_hex_color(color) { state .repos .infra .upsert_server_config("secondary_color_dark", color) .await .log_db_err("upserting secondary_color_dark")?; } else { return Err(ApiError::InvalidRequest( "Invalid secondary dark color format (expected #RRGGBB)".into(), )); } } if let Some(ref logo_cid) = req.logo_cid { let old_logo_cid = state .repos .infra .get_server_config("logo_cid") .await .ok() .flatten(); let should_delete_old = match (&old_logo_cid, logo_cid.is_empty()) { (Some(old), true) => Some(old.clone()), (Some(old), false) if old != logo_cid => Some(old.clone()), _ => None, }; if let Some(old_cid_str) = should_delete_old { match CidLink::new(old_cid_str) { Ok(old_cid) => { if let Ok(Some(storage_key)) = state .repos .infra .get_blob_storage_key_by_cid(&old_cid) .await { if let Err(e) = state.blob_store.delete(&storage_key).await { error!("Failed to delete old logo blob from storage: {:?}", e); } if let Err(e) = state.repos.infra.delete_blob_by_cid(&old_cid).await { error!("Failed to delete old logo blob record: {:?}", e); } } } Err(e) => { warn!( "Old logo CID in database is invalid, skipping cleanup: {:?}", e ); } } } if logo_cid.is_empty() { state .repos .infra .delete_server_config("logo_cid") .await .log_db_err("deleting logo_cid")?; } else { state .repos .infra .upsert_server_config("logo_cid", logo_cid) .await .log_db_err("upserting logo_cid")?; } } Ok(Json(UpdateServerConfigOutput { success: true })) }