mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-09-11 04:36:05 +00:00
Generic cosmetic per-server config
This commit is contained in:
@@ -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 }))
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user