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}