this repo has no description
at main 6.5 kB view raw
1use crate::api::error::ApiError; 2use crate::auth::BearerAuthAdmin; 3use crate::state::AppState; 4use axum::{Json, extract::State}; 5use serde::{Deserialize, Serialize}; 6use tracing::error; 7 8#[derive(Serialize)] 9#[serde(rename_all = "camelCase")] 10pub struct ServerConfigResponse { 11 pub server_name: String, 12 pub primary_color: Option<String>, 13 pub primary_color_dark: Option<String>, 14 pub secondary_color: Option<String>, 15 pub secondary_color_dark: Option<String>, 16 pub logo_cid: Option<String>, 17} 18 19#[derive(Deserialize)] 20#[serde(rename_all = "camelCase")] 21pub struct UpdateServerConfigRequest { 22 pub server_name: Option<String>, 23 pub primary_color: Option<String>, 24 pub primary_color_dark: Option<String>, 25 pub secondary_color: Option<String>, 26 pub secondary_color_dark: Option<String>, 27 pub logo_cid: Option<String>, 28} 29 30#[derive(Serialize)] 31pub struct UpdateServerConfigResponse { 32 pub success: bool, 33} 34 35fn is_valid_hex_color(s: &str) -> bool { 36 if s.len() != 7 || !s.starts_with('#') { 37 return false; 38 } 39 s[1..].chars().all(|c| c.is_ascii_hexdigit()) 40} 41 42pub async fn get_server_config( 43 State(state): State<AppState>, 44) -> Result<Json<ServerConfigResponse>, ApiError> { 45 let rows: Vec<(String, String)> = sqlx::query_as( 46 "SELECT key, value FROM server_config WHERE key IN ('server_name', 'primary_color', 'primary_color_dark', 'secondary_color', 'secondary_color_dark', 'logo_cid')" 47 ) 48 .fetch_all(&state.db) 49 .await?; 50 51 let config_map: std::collections::HashMap<String, String> = 52 rows.into_iter().collect(); 53 54 Ok(Json(ServerConfigResponse { 55 server_name: config_map 56 .get("server_name") 57 .cloned() 58 .unwrap_or_else(|| "Tranquil PDS".to_string()), 59 primary_color: config_map.get("primary_color").cloned(), 60 primary_color_dark: config_map.get("primary_color_dark").cloned(), 61 secondary_color: config_map.get("secondary_color").cloned(), 62 secondary_color_dark: config_map.get("secondary_color_dark").cloned(), 63 logo_cid: config_map.get("logo_cid").cloned(), 64 })) 65} 66 67async fn upsert_config(db: &sqlx::PgPool, key: &str, value: &str) -> Result<(), sqlx::Error> { 68 sqlx::query( 69 "INSERT INTO server_config (key, value, updated_at) VALUES ($1, $2, NOW()) 70 ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()", 71 ) 72 .bind(key) 73 .bind(value) 74 .execute(db) 75 .await?; 76 Ok(()) 77} 78 79async fn delete_config(db: &sqlx::PgPool, key: &str) -> Result<(), sqlx::Error> { 80 sqlx::query("DELETE FROM server_config WHERE key = $1") 81 .bind(key) 82 .execute(db) 83 .await?; 84 Ok(()) 85} 86 87pub async fn update_server_config( 88 State(state): State<AppState>, 89 _admin: BearerAuthAdmin, 90 Json(req): Json<UpdateServerConfigRequest>, 91) -> Result<Json<UpdateServerConfigResponse>, ApiError> { 92 if let Some(server_name) = req.server_name { 93 let trimmed = server_name.trim(); 94 if trimmed.is_empty() || trimmed.len() > 100 { 95 return Err(ApiError::InvalidRequest( 96 "Server name must be 1-100 characters".into(), 97 )); 98 } 99 upsert_config(&state.db, "server_name", trimmed).await?; 100 } 101 102 if let Some(ref color) = req.primary_color { 103 if color.is_empty() { 104 delete_config(&state.db, "primary_color").await?; 105 } else if is_valid_hex_color(color) { 106 upsert_config(&state.db, "primary_color", color).await?; 107 } else { 108 return Err(ApiError::InvalidRequest( 109 "Invalid primary color format (expected #RRGGBB)".into(), 110 )); 111 } 112 } 113 114 if let Some(ref color) = req.primary_color_dark { 115 if color.is_empty() { 116 delete_config(&state.db, "primary_color_dark").await?; 117 } else if is_valid_hex_color(color) { 118 upsert_config(&state.db, "primary_color_dark", color).await?; 119 } else { 120 return Err(ApiError::InvalidRequest( 121 "Invalid primary dark color format (expected #RRGGBB)".into(), 122 )); 123 } 124 } 125 126 if let Some(ref color) = req.secondary_color { 127 if color.is_empty() { 128 delete_config(&state.db, "secondary_color").await?; 129 } else if is_valid_hex_color(color) { 130 upsert_config(&state.db, "secondary_color", color).await?; 131 } else { 132 return Err(ApiError::InvalidRequest( 133 "Invalid secondary color format (expected #RRGGBB)".into(), 134 )); 135 } 136 } 137 138 if let Some(ref color) = req.secondary_color_dark { 139 if color.is_empty() { 140 delete_config(&state.db, "secondary_color_dark").await?; 141 } else if is_valid_hex_color(color) { 142 upsert_config(&state.db, "secondary_color_dark", color).await?; 143 } else { 144 return Err(ApiError::InvalidRequest( 145 "Invalid secondary dark color format (expected #RRGGBB)".into(), 146 )); 147 } 148 } 149 150 if let Some(ref logo_cid) = req.logo_cid { 151 let old_logo_cid: Option<String> = 152 sqlx::query_scalar("SELECT value FROM server_config WHERE key = 'logo_cid'") 153 .fetch_optional(&state.db) 154 .await?; 155 156 let should_delete_old = match (&old_logo_cid, logo_cid.is_empty()) { 157 (Some(old), true) => Some(old.clone()), 158 (Some(old), false) if old != logo_cid => Some(old.clone()), 159 _ => None, 160 }; 161 162 if let Some(old_cid) = should_delete_old 163 && let Ok(Some(blob)) = 164 sqlx::query!("SELECT storage_key FROM blobs WHERE cid = $1", old_cid) 165 .fetch_optional(&state.db) 166 .await 167 { 168 if let Err(e) = state.blob_store.delete(&blob.storage_key).await { 169 error!("Failed to delete old logo blob from storage: {:?}", e); 170 } 171 if let Err(e) = sqlx::query!("DELETE FROM blobs WHERE cid = $1", old_cid) 172 .execute(&state.db) 173 .await 174 { 175 error!("Failed to delete old logo blob record: {:?}", e); 176 } 177 } 178 179 if logo_cid.is_empty() { 180 delete_config(&state.db, "logo_cid").await?; 181 } else { 182 upsert_config(&state.db, "logo_cid", logo_cid).await?; 183 } 184 } 185 186 Ok(Json(UpdateServerConfigResponse { success: true })) 187}