Generic cosmetic per-server config

This commit is contained in:
lewis
2025-12-23 20:35:17 +02:00
parent ac792fdd75
commit 11fc081971
27 changed files with 1202 additions and 46 deletions
+194
View File
@@ -0,0 +1,194 @@
use crate::api::error::ApiError;
use crate::auth::BearerAuthAdmin;
use crate::state::AppState;
use axum::{extract::State, Json};
use serde::{Deserialize, Serialize};
use tracing::error;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ServerConfigResponse {
pub server_name: String,
pub primary_color: Option<String>,
pub primary_color_dark: Option<String>,
pub secondary_color: Option<String>,
pub secondary_color_dark: Option<String>,
pub logo_cid: Option<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateServerConfigRequest {
pub server_name: Option<String>,
pub primary_color: Option<String>,
pub primary_color_dark: Option<String>,
pub secondary_color: Option<String>,
pub secondary_color_dark: Option<String>,
pub logo_cid: Option<String>,
}
#[derive(Serialize)]
pub struct UpdateServerConfigResponse {
pub success: bool,
}
fn is_valid_hex_color(s: &str) -> bool {
if s.len() != 7 || !s.starts_with('#') {
return false;
}
s[1..].chars().all(|c| c.is_ascii_hexdigit())
}
pub async fn get_server_config(
State(state): State<AppState>,
) -> Result<Json<ServerConfigResponse>, ApiError> {
let rows: Vec<(String, String)> = sqlx::query_as(
"SELECT key, value FROM server_config WHERE key IN ('server_name', 'primary_color', 'primary_color_dark', 'secondary_color', 'secondary_color_dark', 'logo_cid')"
)
.fetch_all(&state.db)
.await?;
let mut server_name = "Tranquil PDS".to_string();
let mut primary_color = None;
let mut primary_color_dark = None;
let mut secondary_color = None;
let mut secondary_color_dark = None;
let mut logo_cid = None;
for (key, value) in rows {
match key.as_str() {
"server_name" => server_name = value,
"primary_color" => primary_color = Some(value),
"primary_color_dark" => primary_color_dark = Some(value),
"secondary_color" => secondary_color = Some(value),
"secondary_color_dark" => secondary_color_dark = Some(value),
"logo_cid" => logo_cid = Some(value),
_ => {}
}
}
Ok(Json(ServerConfigResponse {
server_name,
primary_color,
primary_color_dark,
secondary_color,
secondary_color_dark,
logo_cid,
}))
}
async fn upsert_config(db: &sqlx::PgPool, key: &str, value: &str) -> Result<(), sqlx::Error> {
sqlx::query(
"INSERT INTO server_config (key, value, updated_at) VALUES ($1, $2, NOW())
ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()"
)
.bind(key)
.bind(value)
.execute(db)
.await?;
Ok(())
}
async fn delete_config(db: &sqlx::PgPool, key: &str) -> Result<(), sqlx::Error> {
sqlx::query("DELETE FROM server_config WHERE key = $1")
.bind(key)
.execute(db)
.await?;
Ok(())
}
pub async fn update_server_config(
State(state): State<AppState>,
_admin: BearerAuthAdmin,
Json(req): Json<UpdateServerConfigRequest>,
) -> Result<Json<UpdateServerConfigResponse>, ApiError> {
if let Some(server_name) = req.server_name {
let trimmed = server_name.trim();
if trimmed.is_empty() || trimmed.len() > 100 {
return Err(ApiError::InvalidRequest("Server name must be 1-100 characters".into()));
}
upsert_config(&state.db, "server_name", trimmed).await?;
}
if let Some(ref color) = req.primary_color {
if color.is_empty() {
delete_config(&state.db, "primary_color").await?;
} else if is_valid_hex_color(color) {
upsert_config(&state.db, "primary_color", color).await?;
} else {
return Err(ApiError::InvalidRequest("Invalid primary color format (expected #RRGGBB)".into()));
}
}
if let Some(ref color) = req.primary_color_dark {
if color.is_empty() {
delete_config(&state.db, "primary_color_dark").await?;
} else if is_valid_hex_color(color) {
upsert_config(&state.db, "primary_color_dark", color).await?;
} else {
return Err(ApiError::InvalidRequest("Invalid primary dark color format (expected #RRGGBB)".into()));
}
}
if let Some(ref color) = req.secondary_color {
if color.is_empty() {
delete_config(&state.db, "secondary_color").await?;
} else if is_valid_hex_color(color) {
upsert_config(&state.db, "secondary_color", color).await?;
} else {
return Err(ApiError::InvalidRequest("Invalid secondary color format (expected #RRGGBB)".into()));
}
}
if let Some(ref color) = req.secondary_color_dark {
if color.is_empty() {
delete_config(&state.db, "secondary_color_dark").await?;
} else if is_valid_hex_color(color) {
upsert_config(&state.db, "secondary_color_dark", color).await?;
} else {
return Err(ApiError::InvalidRequest("Invalid secondary dark color format (expected #RRGGBB)".into()));
}
}
if let Some(ref logo_cid) = req.logo_cid {
let old_logo_cid: Option<String> = sqlx::query_scalar(
"SELECT value FROM server_config WHERE key = 'logo_cid'"
)
.fetch_optional(&state.db)
.await?;
let should_delete_old = match (&old_logo_cid, logo_cid.is_empty()) {
(Some(old), true) => Some(old.clone()),
(Some(old), false) if old != logo_cid => Some(old.clone()),
_ => None,
};
if let Some(old_cid) = should_delete_old {
if let Ok(Some(blob)) = sqlx::query!(
"SELECT storage_key FROM blobs WHERE cid = $1",
old_cid
)
.fetch_optional(&state.db)
.await
{
if let Err(e) = state.blob_store.delete(&blob.storage_key).await {
error!("Failed to delete old logo blob from storage: {:?}", e);
}
if let Err(e) = sqlx::query!("DELETE FROM blobs WHERE cid = $1", old_cid)
.execute(&state.db)
.await
{
error!("Failed to delete old logo blob record: {:?}", e);
}
}
}
if logo_cid.is_empty() {
delete_config(&state.db, "logo_cid").await?;
} else {
upsert_config(&state.db, "logo_cid", logo_cid).await?;
}
}
Ok(Json(UpdateServerConfigResponse { success: true }))
}
+2
View File
@@ -1,4 +1,5 @@
pub mod account;
pub mod config;
pub mod invite;
pub mod server_stats;
pub mod status;
@@ -7,6 +8,7 @@ pub use account::{
delete_account, get_account_info, get_account_infos, search_accounts, send_email,
update_account_email, update_account_handle, update_account_password,
};
pub use config::{get_server_config, update_server_config};
pub use invite::{
disable_account_invites, disable_invite_codes, enable_account_invites, get_invite_codes,
};
+57
View File
@@ -0,0 +1,57 @@
use crate::state::AppState;
use axum::{
body::Body,
extract::State,
http::StatusCode,
http::header,
response::{IntoResponse, Response},
};
use tracing::error;
pub async fn get_logo(State(state): State<AppState>) -> Response {
let logo_cid: Option<String> = match sqlx::query_scalar(
"SELECT value FROM server_config WHERE key = 'logo_cid'"
)
.fetch_optional(&state.db)
.await
{
Ok(cid) => cid,
Err(e) => {
error!("DB error fetching logo_cid: {:?}", e);
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
};
let cid = match logo_cid {
Some(c) if !c.is_empty() => c,
_ => return StatusCode::NOT_FOUND.into_response(),
};
let blob = match sqlx::query!(
"SELECT storage_key, mime_type FROM blobs WHERE cid = $1",
cid
)
.fetch_optional(&state.db)
.await
{
Ok(Some(row)) => row,
Ok(None) => return StatusCode::NOT_FOUND.into_response(),
Err(e) => {
error!("DB error fetching blob: {:?}", e);
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
};
match state.blob_store.get(&blob.storage_key).await {
Ok(data) => Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, &blob.mime_type)
.header(header::CACHE_CONTROL, "public, max-age=3600")
.body(Body::from(data))
.unwrap(),
Err(e) => {
error!("Failed to fetch logo from storage: {:?}", e);
StatusCode::NOT_FOUND.into_response()
}
}
}
+2 -1
View File
@@ -20,7 +20,8 @@ pub async fn describe_server() -> impl IntoResponse {
Json(json!({
"availableUserDomains": domains,
"inviteCodeRequired": invite_code_required,
"did": format!("did:web:{}", pds_hostname)
"did": format!("did:web:{}", pds_hostname),
"version": env!("CARGO_PKG_VERSION")
}))
}
pub async fn health(State(state): State<AppState>) -> impl IntoResponse {
+2
View File
@@ -2,6 +2,7 @@ pub mod account_status;
pub mod app_password;
pub mod email;
pub mod invite;
pub mod logo;
pub mod meta;
pub mod passkey_account;
pub mod passkeys;
@@ -20,6 +21,7 @@ pub use account_status::{
pub use app_password::{create_app_password, list_app_passwords, revoke_app_password};
pub use email::{confirm_email, request_email_update, update_email};
pub use invite::{create_invite_code, create_invite_codes, get_account_invite_codes};
pub use logo::get_logo;
pub use meta::{describe_server, health, robots_txt};
pub use passkey_account::{
complete_passkey_setup, create_passkey_account, recover_passkey_account,
+9
View File
@@ -35,6 +35,7 @@ pub fn app(state: AppState) -> Router {
.route("/health", get(api::server::health))
.route("/xrpc/_health", get(api::server::health))
.route("/robots.txt", get(api::server::robots_txt))
.route("/logo", get(api::server::get_logo))
.route(
"/xrpc/com.atproto.server.describeServer",
get(api::server::describe_server),
@@ -402,6 +403,14 @@ pub fn app(state: AppState) -> Router {
"/xrpc/com.tranquil.admin.getServerStats",
get(api::admin::get_server_stats),
)
.route(
"/xrpc/com.tranquil.server.getConfig",
get(api::admin::get_server_config),
)
.route(
"/xrpc/com.tranquil.admin.updateServerConfig",
post(api::admin::update_server_config),
)
.route(
"/xrpc/com.atproto.admin.disableAccountInvites",
post(api::admin::disable_account_invites),
+1 -1
View File
@@ -172,7 +172,7 @@ pub async fn frontend_client_metadata(
"refresh_token".to_string(),
],
response_types: vec!["code".to_string()],
scope: "atproto transition:generic".to_string(),
scope: "atproto repo:*?action=create repo:*?action=update repo:*?action=delete blob:*/*".to_string(),
token_endpoint_auth_method: "none".to_string(),
application_type: "web".to_string(),
dpop_bound_access_tokens: true,