···1414 "expires": "Expires",
1515 "name": "Name",
1616 "dashboard": "Dashboard",
1717- "backToDashboard": "← Dashboard"
1717+ "backToDashboard": "← Dashboard",
1818+ "copied": "Copied!",
1919+ "copyToClipboard": "Copy to Clipboard"
1820 },
1921 "login": {
2022 "title": "Sign In",
···4547 "register": {
4648 "title": "Create Account",
4749 "subtitle": "Create a new account on this PDS",
5050+ "migrateTitle": "Already have a Bluesky account?",
5151+ "migrateDescription": "You can migrate your existing account to this PDS instead of creating a new one. Your followers, posts, and identity will come with you.",
5252+ "migrateLink": "Migrate with PDS Moover",
4853 "handle": "Handle",
4954 "handlePlaceholder": "yourname",
5055 "handleHint": "Your full handle will be: @{handle}",
···226231 "revoke": "Revoke",
227232 "revoking": "Revoking...",
228233 "creating": "Creating...",
229229- "revokeConfirm": "Revoke app password \"{name}\"? Apps using this password will no longer be able to access your account."
234234+ "revokeConfirm": "Revoke app password \"{name}\"? Apps using this password will no longer be able to access your account.",
235235+ "saveWarningTitle": "Important: Save this app password!",
236236+ "saveWarningMessage": "This password is required to sign into apps that don't support passkeys or OAuth. You will only see it once.",
237237+ "acknowledgeLabel": "I have saved my app password in a secure location"
230238 },
231239 "sessions": {
232240 "title": "Active Sessions",
+10-2
frontend/src/locales/fi.json
···1414 "expires": "Vanhenee",
1515 "name": "Nimi",
1616 "dashboard": "Hallintapaneeli",
1717- "backToDashboard": "← Hallintapaneeli"
1717+ "backToDashboard": "← Hallintapaneeli",
1818+ "copied": "Kopioitu!",
1919+ "copyToClipboard": "Kopioi"
1820 },
1921 "login": {
2022 "title": "Kirjaudu sisään",
···4547 "register": {
4648 "title": "Luo tili",
4749 "subtitle": "Luo uusi tili tälle PDS:lle",
5050+ "migrateTitle": "Onko sinulla jo Bluesky-tili?",
5151+ "migrateDescription": "Voit siirtää olemassa olevan tilisi tälle PDS:lle uuden luomisen sijaan. Seuraajasi, julkaisusi ja identiteettisi siirtyvät mukana.",
5252+ "migrateLink": "Siirrä PDS Mooverilla",
4853 "handle": "Käyttäjänimi",
4954 "handlePlaceholder": "nimesi",
5055 "handleHint": "Täydellinen käyttäjänimesi on: @{handle}",
···226231 "revoke": "Peruuta",
227232 "revoking": "Peruutetaan...",
228233 "creating": "Luodaan...",
229229- "revokeConfirm": "Peruuta sovelluksen salasana \"{name}\"? Sovellukset, jotka käyttävät tätä salasanaa, eivät enää pääse tilillesi."
234234+ "revokeConfirm": "Peruuta sovelluksen salasana \"{name}\"? Sovellukset, jotka käyttävät tätä salasanaa, eivät enää pääse tilillesi.",
235235+ "saveWarningTitle": "Tärkeää: Tallenna tämä sovelluksen salasana!",
236236+ "saveWarningMessage": "Tämä salasana tarvitaan kirjautumiseen sovelluksiin, jotka eivät tue pääsyavaimia tai OAuthia. Näet sen vain kerran.",
237237+ "acknowledgeLabel": "Olen tallentanut sovelluksen salasanani turvalliseen paikkaan"
230238 },
231239 "sessions": {
232240 "title": "Aktiiviset istunnot",
···1414 "expires": "만료일",
1515 "name": "이름",
1616 "dashboard": "대시보드",
1717- "backToDashboard": "← 대시보드"
1717+ "backToDashboard": "← 대시보드",
1818+ "copied": "복사됨!",
1919+ "copyToClipboard": "클립보드에 복사"
1820 },
1921 "login": {
2022 "title": "로그인",
···4547 "register": {
4648 "title": "계정 만들기",
4749 "subtitle": "이 PDS에 새 계정을 만듭니다",
5050+ "migrateTitle": "이미 Bluesky 계정이 있으신가요?",
5151+ "migrateDescription": "새 계정을 만드는 대신 기존 계정을 이 PDS로 마이그레이션할 수 있습니다. 팔로워, 게시물, ID가 함께 이전됩니다.",
5252+ "migrateLink": "PDS Moover로 마이그레이션",
4853 "handle": "핸들",
4954 "handlePlaceholder": "사용자 이름",
5055 "handleHint": "전체 핸들: @{handle}",
···226231 "revoke": "취소",
227232 "revoking": "취소 중...",
228233 "creating": "생성 중...",
229229- "revokeConfirm": "앱 비밀번호 \"{name}\"을(를) 취소하시겠습니까? 이 비밀번호를 사용하는 앱은 더 이상 계정에 액세스할 수 없습니다."
234234+ "revokeConfirm": "앱 비밀번호 \"{name}\"을(를) 취소하시겠습니까? 이 비밀번호를 사용하는 앱은 더 이상 계정에 액세스할 수 없습니다.",
235235+ "saveWarningTitle": "중요: 이 앱 비밀번호를 저장하세요!",
236236+ "saveWarningMessage": "이 비밀번호는 패스키 또는 OAuth를 지원하지 않는 앱에 로그인하는 데 필요합니다. 한 번만 볼 수 있습니다.",
237237+ "acknowledgeLabel": "앱 비밀번호를 안전한 곳에 저장했습니다"
230238 },
231239 "sessions": {
232240 "title": "활성 세션",
+10-2
frontend/src/locales/sv.json
···1414 "expires": "Upphör",
1515 "name": "Namn",
1616 "dashboard": "Kontrollpanel",
1717- "backToDashboard": "← Kontrollpanel"
1717+ "backToDashboard": "← Kontrollpanel",
1818+ "copied": "Kopierat!",
1919+ "copyToClipboard": "Kopiera"
1820 },
1921 "login": {
2022 "title": "Logga in",
···4547 "register": {
4648 "title": "Skapa konto",
4749 "subtitle": "Skapa ett nytt konto på denna PDS",
5050+ "migrateTitle": "Har du redan ett Bluesky-konto?",
5151+ "migrateDescription": "Du kan flytta ditt befintliga konto till denna PDS istället för att skapa ett nytt. Dina följare, inlägg och identitet följer med.",
5252+ "migrateLink": "Flytta med PDS Moover",
4853 "handle": "Användarnamn",
4954 "handlePlaceholder": "dittnamn",
5055 "handleHint": "Ditt fullständiga användarnamn blir: @{handle}",
···226231 "revoke": "Återkalla",
227232 "revoking": "Återkallar...",
228233 "creating": "Skapar...",
229229- "revokeConfirm": "Återkalla applösenord \"{name}\"? Appar som använder detta lösenord kommer inte längre att kunna komma åt ditt konto."
234234+ "revokeConfirm": "Återkalla applösenord \"{name}\"? Appar som använder detta lösenord kommer inte längre att kunna komma åt ditt konto.",
235235+ "saveWarningTitle": "Viktigt: Spara detta applösenord!",
236236+ "saveWarningMessage": "Detta lösenord krävs för att logga in i appar som inte stöder passkeys eller OAuth. Du ser det bara en gång.",
237237+ "acknowledgeLabel": "Jag har sparat mitt applösenord på en säker plats"
230238 },
231239 "sessions": {
232240 "title": "Aktiva sessioner",
···11+CREATE TABLE IF NOT EXISTS server_config (
22+ key TEXT PRIMARY KEY,
33+ value TEXT NOT NULL,
44+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
55+);
66+77+INSERT INTO server_config (key, value) VALUES ('server_name', 'Tranquil PDS') ON CONFLICT DO NOTHING;
+194
src/api/admin/config.rs
···11+use crate::api::error::ApiError;
22+use crate::auth::BearerAuthAdmin;
33+use crate::state::AppState;
44+use axum::{extract::State, Json};
55+use serde::{Deserialize, Serialize};
66+use tracing::error;
77+88+#[derive(Serialize)]
99+#[serde(rename_all = "camelCase")]
1010+pub struct ServerConfigResponse {
1111+ pub server_name: String,
1212+ pub primary_color: Option<String>,
1313+ pub primary_color_dark: Option<String>,
1414+ pub secondary_color: Option<String>,
1515+ pub secondary_color_dark: Option<String>,
1616+ pub logo_cid: Option<String>,
1717+}
1818+1919+#[derive(Deserialize)]
2020+#[serde(rename_all = "camelCase")]
2121+pub struct UpdateServerConfigRequest {
2222+ pub server_name: Option<String>,
2323+ pub primary_color: Option<String>,
2424+ pub primary_color_dark: Option<String>,
2525+ pub secondary_color: Option<String>,
2626+ pub secondary_color_dark: Option<String>,
2727+ pub logo_cid: Option<String>,
2828+}
2929+3030+#[derive(Serialize)]
3131+pub struct UpdateServerConfigResponse {
3232+ pub success: bool,
3333+}
3434+3535+fn is_valid_hex_color(s: &str) -> bool {
3636+ if s.len() != 7 || !s.starts_with('#') {
3737+ return false;
3838+ }
3939+ s[1..].chars().all(|c| c.is_ascii_hexdigit())
4040+}
4141+4242+pub async fn get_server_config(
4343+ State(state): State<AppState>,
4444+) -> Result<Json<ServerConfigResponse>, ApiError> {
4545+ let rows: Vec<(String, String)> = sqlx::query_as(
4646+ "SELECT key, value FROM server_config WHERE key IN ('server_name', 'primary_color', 'primary_color_dark', 'secondary_color', 'secondary_color_dark', 'logo_cid')"
4747+ )
4848+ .fetch_all(&state.db)
4949+ .await?;
5050+5151+ let mut server_name = "Tranquil PDS".to_string();
5252+ let mut primary_color = None;
5353+ let mut primary_color_dark = None;
5454+ let mut secondary_color = None;
5555+ let mut secondary_color_dark = None;
5656+ let mut logo_cid = None;
5757+5858+ for (key, value) in rows {
5959+ match key.as_str() {
6060+ "server_name" => server_name = value,
6161+ "primary_color" => primary_color = Some(value),
6262+ "primary_color_dark" => primary_color_dark = Some(value),
6363+ "secondary_color" => secondary_color = Some(value),
6464+ "secondary_color_dark" => secondary_color_dark = Some(value),
6565+ "logo_cid" => logo_cid = Some(value),
6666+ _ => {}
6767+ }
6868+ }
6969+7070+ Ok(Json(ServerConfigResponse {
7171+ server_name,
7272+ primary_color,
7373+ primary_color_dark,
7474+ secondary_color,
7575+ secondary_color_dark,
7676+ logo_cid,
7777+ }))
7878+}
7979+8080+async fn upsert_config(db: &sqlx::PgPool, key: &str, value: &str) -> Result<(), sqlx::Error> {
8181+ sqlx::query(
8282+ "INSERT INTO server_config (key, value, updated_at) VALUES ($1, $2, NOW())
8383+ ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()"
8484+ )
8585+ .bind(key)
8686+ .bind(value)
8787+ .execute(db)
8888+ .await?;
8989+ Ok(())
9090+}
9191+9292+async fn delete_config(db: &sqlx::PgPool, key: &str) -> Result<(), sqlx::Error> {
9393+ sqlx::query("DELETE FROM server_config WHERE key = $1")
9494+ .bind(key)
9595+ .execute(db)
9696+ .await?;
9797+ Ok(())
9898+}
9999+100100+pub async fn update_server_config(
101101+ State(state): State<AppState>,
102102+ _admin: BearerAuthAdmin,
103103+ Json(req): Json<UpdateServerConfigRequest>,
104104+) -> Result<Json<UpdateServerConfigResponse>, ApiError> {
105105+ if let Some(server_name) = req.server_name {
106106+ let trimmed = server_name.trim();
107107+ if trimmed.is_empty() || trimmed.len() > 100 {
108108+ return Err(ApiError::InvalidRequest("Server name must be 1-100 characters".into()));
109109+ }
110110+ upsert_config(&state.db, "server_name", trimmed).await?;
111111+ }
112112+113113+ if let Some(ref color) = req.primary_color {
114114+ if color.is_empty() {
115115+ delete_config(&state.db, "primary_color").await?;
116116+ } else if is_valid_hex_color(color) {
117117+ upsert_config(&state.db, "primary_color", color).await?;
118118+ } else {
119119+ return Err(ApiError::InvalidRequest("Invalid primary color format (expected #RRGGBB)".into()));
120120+ }
121121+ }
122122+123123+ if let Some(ref color) = req.primary_color_dark {
124124+ if color.is_empty() {
125125+ delete_config(&state.db, "primary_color_dark").await?;
126126+ } else if is_valid_hex_color(color) {
127127+ upsert_config(&state.db, "primary_color_dark", color).await?;
128128+ } else {
129129+ return Err(ApiError::InvalidRequest("Invalid primary dark color format (expected #RRGGBB)".into()));
130130+ }
131131+ }
132132+133133+ if let Some(ref color) = req.secondary_color {
134134+ if color.is_empty() {
135135+ delete_config(&state.db, "secondary_color").await?;
136136+ } else if is_valid_hex_color(color) {
137137+ upsert_config(&state.db, "secondary_color", color).await?;
138138+ } else {
139139+ return Err(ApiError::InvalidRequest("Invalid secondary color format (expected #RRGGBB)".into()));
140140+ }
141141+ }
142142+143143+ if let Some(ref color) = req.secondary_color_dark {
144144+ if color.is_empty() {
145145+ delete_config(&state.db, "secondary_color_dark").await?;
146146+ } else if is_valid_hex_color(color) {
147147+ upsert_config(&state.db, "secondary_color_dark", color).await?;
148148+ } else {
149149+ return Err(ApiError::InvalidRequest("Invalid secondary dark color format (expected #RRGGBB)".into()));
150150+ }
151151+ }
152152+153153+ if let Some(ref logo_cid) = req.logo_cid {
154154+ let old_logo_cid: Option<String> = sqlx::query_scalar(
155155+ "SELECT value FROM server_config WHERE key = 'logo_cid'"
156156+ )
157157+ .fetch_optional(&state.db)
158158+ .await?;
159159+160160+ let should_delete_old = match (&old_logo_cid, logo_cid.is_empty()) {
161161+ (Some(old), true) => Some(old.clone()),
162162+ (Some(old), false) if old != logo_cid => Some(old.clone()),
163163+ _ => None,
164164+ };
165165+166166+ if let Some(old_cid) = should_delete_old {
167167+ if let Ok(Some(blob)) = sqlx::query!(
168168+ "SELECT storage_key FROM blobs WHERE cid = $1",
169169+ old_cid
170170+ )
171171+ .fetch_optional(&state.db)
172172+ .await
173173+ {
174174+ if let Err(e) = state.blob_store.delete(&blob.storage_key).await {
175175+ error!("Failed to delete old logo blob from storage: {:?}", e);
176176+ }
177177+ if let Err(e) = sqlx::query!("DELETE FROM blobs WHERE cid = $1", old_cid)
178178+ .execute(&state.db)
179179+ .await
180180+ {
181181+ error!("Failed to delete old logo blob record: {:?}", e);
182182+ }
183183+ }
184184+ }
185185+186186+ if logo_cid.is_empty() {
187187+ delete_config(&state.db, "logo_cid").await?;
188188+ } else {
189189+ upsert_config(&state.db, "logo_cid", logo_cid).await?;
190190+ }
191191+ }
192192+193193+ Ok(Json(UpdateServerConfigResponse { success: true }))
194194+}
+2
src/api/admin/mod.rs
···11pub mod account;
22+pub mod config;
23pub mod invite;
34pub mod server_stats;
45pub mod status;
···78 delete_account, get_account_info, get_account_infos, search_accounts, send_email,
89 update_account_email, update_account_handle, update_account_password,
910};
1111+pub use config::{get_server_config, update_server_config};
1012pub use invite::{
1113 disable_account_invites, disable_invite_codes, enable_account_invites, get_invite_codes,
1214};
···22pub mod app_password;
33pub mod email;
44pub mod invite;
55+pub mod logo;
56pub mod meta;
67pub mod passkey_account;
78pub mod passkeys;
···2021pub use app_password::{create_app_password, list_app_passwords, revoke_app_password};
2122pub use email::{confirm_email, request_email_update, update_email};
2223pub use invite::{create_invite_code, create_invite_codes, get_account_invite_codes};
2424+pub use logo::get_logo;
2325pub use meta::{describe_server, health, robots_txt};
2426pub use passkey_account::{
2527 complete_passkey_setup, create_passkey_account, recover_passkey_account,