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 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}