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}