this repo has no description
1use crate::api::error::ApiError; 2use crate::auth::BearerAuthAdmin; 3use crate::state::AppState; 4use axum::{extract::State, Json}; 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 mut server_name = "Tranquil PDS".to_string(); 52 let mut primary_color = None; 53 let mut primary_color_dark = None; 54 let mut secondary_color = None; 55 let mut secondary_color_dark = None; 56 let mut logo_cid = None; 57 58 for (key, value) in rows { 59 match key.as_str() { 60 "server_name" => server_name = value, 61 "primary_color" => primary_color = Some(value), 62 "primary_color_dark" => primary_color_dark = Some(value), 63 "secondary_color" => secondary_color = Some(value), 64 "secondary_color_dark" => secondary_color_dark = Some(value), 65 "logo_cid" => logo_cid = Some(value), 66 _ => {} 67 } 68 } 69 70 Ok(Json(ServerConfigResponse { 71 server_name, 72 primary_color, 73 primary_color_dark, 74 secondary_color, 75 secondary_color_dark, 76 logo_cid, 77 })) 78} 79 80async fn upsert_config(db: &sqlx::PgPool, key: &str, value: &str) -> Result<(), sqlx::Error> { 81 sqlx::query( 82 "INSERT INTO server_config (key, value, updated_at) VALUES ($1, $2, NOW()) 83 ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()" 84 ) 85 .bind(key) 86 .bind(value) 87 .execute(db) 88 .await?; 89 Ok(()) 90} 91 92async fn delete_config(db: &sqlx::PgPool, key: &str) -> Result<(), sqlx::Error> { 93 sqlx::query("DELETE FROM server_config WHERE key = $1") 94 .bind(key) 95 .execute(db) 96 .await?; 97 Ok(()) 98} 99 100pub async fn update_server_config( 101 State(state): State<AppState>, 102 _admin: BearerAuthAdmin, 103 Json(req): Json<UpdateServerConfigRequest>, 104) -> Result<Json<UpdateServerConfigResponse>, ApiError> { 105 if let Some(server_name) = req.server_name { 106 let trimmed = server_name.trim(); 107 if trimmed.is_empty() || trimmed.len() > 100 { 108 return Err(ApiError::InvalidRequest("Server name must be 1-100 characters".into())); 109 } 110 upsert_config(&state.db, "server_name", trimmed).await?; 111 } 112 113 if let Some(ref color) = req.primary_color { 114 if color.is_empty() { 115 delete_config(&state.db, "primary_color").await?; 116 } else if is_valid_hex_color(color) { 117 upsert_config(&state.db, "primary_color", color).await?; 118 } else { 119 return Err(ApiError::InvalidRequest("Invalid primary color format (expected #RRGGBB)".into())); 120 } 121 } 122 123 if let Some(ref color) = req.primary_color_dark { 124 if color.is_empty() { 125 delete_config(&state.db, "primary_color_dark").await?; 126 } else if is_valid_hex_color(color) { 127 upsert_config(&state.db, "primary_color_dark", color).await?; 128 } else { 129 return Err(ApiError::InvalidRequest("Invalid primary dark color format (expected #RRGGBB)".into())); 130 } 131 } 132 133 if let Some(ref color) = req.secondary_color { 134 if color.is_empty() { 135 delete_config(&state.db, "secondary_color").await?; 136 } else if is_valid_hex_color(color) { 137 upsert_config(&state.db, "secondary_color", color).await?; 138 } else { 139 return Err(ApiError::InvalidRequest("Invalid secondary color format (expected #RRGGBB)".into())); 140 } 141 } 142 143 if let Some(ref color) = req.secondary_color_dark { 144 if color.is_empty() { 145 delete_config(&state.db, "secondary_color_dark").await?; 146 } else if is_valid_hex_color(color) { 147 upsert_config(&state.db, "secondary_color_dark", color).await?; 148 } else { 149 return Err(ApiError::InvalidRequest("Invalid secondary dark color format (expected #RRGGBB)".into())); 150 } 151 } 152 153 if let Some(ref logo_cid) = req.logo_cid { 154 let old_logo_cid: Option<String> = sqlx::query_scalar( 155 "SELECT value FROM server_config WHERE key = 'logo_cid'" 156 ) 157 .fetch_optional(&state.db) 158 .await?; 159 160 let should_delete_old = match (&old_logo_cid, logo_cid.is_empty()) { 161 (Some(old), true) => Some(old.clone()), 162 (Some(old), false) if old != logo_cid => Some(old.clone()), 163 _ => None, 164 }; 165 166 if let Some(old_cid) = should_delete_old { 167 if let Ok(Some(blob)) = sqlx::query!( 168 "SELECT storage_key FROM blobs WHERE cid = $1", 169 old_cid 170 ) 171 .fetch_optional(&state.db) 172 .await 173 { 174 if let Err(e) = state.blob_store.delete(&blob.storage_key).await { 175 error!("Failed to delete old logo blob from storage: {:?}", e); 176 } 177 if let Err(e) = sqlx::query!("DELETE FROM blobs WHERE cid = $1", old_cid) 178 .execute(&state.db) 179 .await 180 { 181 error!("Failed to delete old logo blob record: {:?}", e); 182 } 183 } 184 } 185 186 if logo_cid.is_empty() { 187 delete_config(&state.db, "logo_cid").await?; 188 } else { 189 upsert_config(&state.db, "logo_cid", logo_cid).await?; 190 } 191 } 192 193 Ok(Json(UpdateServerConfigResponse { success: true })) 194}