this repo has no description
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 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( 109 "Server name must be 1-100 characters".into(), 110 )); 111 } 112 upsert_config(&state.db, "server_name", trimmed).await?; 113 } 114 115 if let Some(ref color) = req.primary_color { 116 if color.is_empty() { 117 delete_config(&state.db, "primary_color").await?; 118 } else if is_valid_hex_color(color) { 119 upsert_config(&state.db, "primary_color", color).await?; 120 } else { 121 return Err(ApiError::InvalidRequest( 122 "Invalid primary color format (expected #RRGGBB)".into(), 123 )); 124 } 125 } 126 127 if let Some(ref color) = req.primary_color_dark { 128 if color.is_empty() { 129 delete_config(&state.db, "primary_color_dark").await?; 130 } else if is_valid_hex_color(color) { 131 upsert_config(&state.db, "primary_color_dark", color).await?; 132 } else { 133 return Err(ApiError::InvalidRequest( 134 "Invalid primary dark color format (expected #RRGGBB)".into(), 135 )); 136 } 137 } 138 139 if let Some(ref color) = req.secondary_color { 140 if color.is_empty() { 141 delete_config(&state.db, "secondary_color").await?; 142 } else if is_valid_hex_color(color) { 143 upsert_config(&state.db, "secondary_color", color).await?; 144 } else { 145 return Err(ApiError::InvalidRequest( 146 "Invalid secondary color format (expected #RRGGBB)".into(), 147 )); 148 } 149 } 150 151 if let Some(ref color) = req.secondary_color_dark { 152 if color.is_empty() { 153 delete_config(&state.db, "secondary_color_dark").await?; 154 } else if is_valid_hex_color(color) { 155 upsert_config(&state.db, "secondary_color_dark", color).await?; 156 } else { 157 return Err(ApiError::InvalidRequest( 158 "Invalid secondary dark color format (expected #RRGGBB)".into(), 159 )); 160 } 161 } 162 163 if let Some(ref logo_cid) = req.logo_cid { 164 let old_logo_cid: Option<String> = 165 sqlx::query_scalar("SELECT value FROM server_config WHERE key = 'logo_cid'") 166 .fetch_optional(&state.db) 167 .await?; 168 169 let should_delete_old = match (&old_logo_cid, logo_cid.is_empty()) { 170 (Some(old), true) => Some(old.clone()), 171 (Some(old), false) if old != logo_cid => Some(old.clone()), 172 _ => None, 173 }; 174 175 if let Some(old_cid) = should_delete_old 176 && let Ok(Some(blob)) = 177 sqlx::query!("SELECT storage_key FROM blobs WHERE cid = $1", old_cid) 178 .fetch_optional(&state.db) 179 .await 180 { 181 if let Err(e) = state.blob_store.delete(&blob.storage_key).await { 182 error!("Failed to delete old logo blob from storage: {:?}", e); 183 } 184 if let Err(e) = sqlx::query!("DELETE FROM blobs WHERE cid = $1", old_cid) 185 .execute(&state.db) 186 .await 187 { 188 error!("Failed to delete old logo blob record: {:?}", e); 189 } 190 } 191 192 if logo_cid.is_empty() { 193 delete_config(&state.db, "logo_cid").await?; 194 } else { 195 upsert_config(&state.db, "logo_cid", logo_cid).await?; 196 } 197 } 198 199 Ok(Json(UpdateServerConfigResponse { success: true })) 200}