this repo has no description

Generic cosmetic per-server config

lewis 41feec8d f9172a90

+22
.sqlx/query-6131bb5b39ca81bdbb193c0a9867bead8d9f3d793ad4eca97a79d166467a5052.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT storage_key FROM blobs WHERE cid = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "storage_key", 9 + "type_info": "Text" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text" 15 + ] 16 + }, 17 + "nullable": [ 18 + false 19 + ] 20 + }, 21 + "hash": "6131bb5b39ca81bdbb193c0a9867bead8d9f3d793ad4eca97a79d166467a5052" 22 + }
+14
.sqlx/query-d2990ce7f233d2489bb36a63920571c9f454a0605cc463829693d581bc0dce12.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "DELETE FROM blobs WHERE cid = $1", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text" 9 + ] 10 + }, 11 + "nullable": [] 12 + }, 13 + "hash": "d2990ce7f233d2489bb36a63920571c9f454a0605cc463829693d581bc0dce12" 14 + }
+2
frontend/src/App.svelte
··· 1 1 <script lang="ts"> 2 2 import { getCurrentPath, navigate } from './lib/router.svelte' 3 3 import { initAuth, getAuthState } from './lib/auth.svelte' 4 + import { initServerConfig } from './lib/serverConfig.svelte' 4 5 import { initI18n, _ } from './lib/i18n' 5 6 import { isLoading as i18nLoading } from 'svelte-i18n' 6 7 import Login from './routes/Login.svelte' ··· 41 42 } 42 43 43 44 $effect(() => { 45 + initServerConfig() 44 46 initAuth().then(({ oauthLoginCompleted }) => { 45 47 if (oauthLoginCompleted) { 46 48 navigate('/dashboard')
+55
frontend/src/lib/api.ts
··· 265 265 availableUserDomains: string[] 266 266 inviteCodeRequired: boolean 267 267 links?: { privacyPolicy?: string; termsOfService?: string } 268 + version?: string 268 269 }> { 269 270 return xrpc('com.atproto.server.describeServer') 271 + }, 272 + 273 + async listRepos(limit?: number): Promise<{ 274 + repos: Array<{ did: string; head: string; rev: string }> 275 + cursor?: string 276 + }> { 277 + const params: Record<string, string> = {} 278 + if (limit) params.limit = String(limit) 279 + return xrpc('com.atproto.sync.listRepos', { params }) 270 280 }, 271 281 272 282 async getNotificationPrefs(token: string): Promise<{ ··· 323 333 blobStorageBytes: number 324 334 }> { 325 335 return xrpc('com.tranquil.admin.getServerStats', { token }) 336 + }, 337 + 338 + async getServerConfig(): Promise<{ 339 + serverName: string 340 + primaryColor: string | null 341 + primaryColorDark: string | null 342 + secondaryColor: string | null 343 + secondaryColorDark: string | null 344 + logoCid: string | null 345 + }> { 346 + return xrpc('com.tranquil.server.getConfig') 347 + }, 348 + 349 + async updateServerConfig( 350 + token: string, 351 + config: { 352 + serverName?: string 353 + primaryColor?: string 354 + primaryColorDark?: string 355 + secondaryColor?: string 356 + secondaryColorDark?: string 357 + logoCid?: string 358 + } 359 + ): Promise<{ success: boolean }> { 360 + return xrpc('com.tranquil.admin.updateServerConfig', { 361 + method: 'POST', 362 + token, 363 + body: config, 364 + }) 365 + }, 366 + 367 + async uploadBlob(token: string, file: File): Promise<{ blob: { $type: string; ref: { $link: string }; mimeType: string; size: number } }> { 368 + const res = await fetch('/xrpc/com.atproto.repo.uploadBlob', { 369 + method: 'POST', 370 + headers: { 371 + 'Authorization': `Bearer ${token}`, 372 + 'Content-Type': file.type, 373 + }, 374 + body: file, 375 + }) 376 + if (!res.ok) { 377 + const err = await res.json().catch(() => ({ error: 'Unknown', message: res.statusText })) 378 + throw new ApiError(res.status, err.error, err.message) 379 + } 380 + return res.json() 326 381 }, 327 382 328 383 async changePassword(token: string, currentPassword: string, newPassword: string): Promise<void> {
+7 -1
frontend/src/lib/oauth.ts
··· 75 75 client_id: clientId, 76 76 redirect_uri: redirectUri, 77 77 response_type: 'code', 78 - scope: 'atproto transition:generic', 78 + scope: [ 79 + 'atproto', 80 + 'repo:*?action=create', 81 + 'repo:*?action=update', 82 + 'repo:*?action=delete', 83 + 'blob:*/*', 84 + ].join(' '), 79 85 state: state, 80 86 code_challenge: codeChallenge, 81 87 code_challenge_method: 'S256',
+123
frontend/src/lib/serverConfig.svelte.ts
··· 1 + import { api } from './api' 2 + 3 + interface ServerConfigState { 4 + serverName: string | null 5 + primaryColor: string | null 6 + primaryColorDark: string | null 7 + secondaryColor: string | null 8 + secondaryColorDark: string | null 9 + hasLogo: boolean 10 + loading: boolean 11 + } 12 + 13 + let state = $state<ServerConfigState>({ 14 + serverName: null, 15 + primaryColor: null, 16 + primaryColorDark: null, 17 + secondaryColor: null, 18 + secondaryColorDark: null, 19 + hasLogo: false, 20 + loading: true, 21 + }) 22 + 23 + let initialized = false 24 + let darkModeQuery: MediaQueryList | null = null 25 + 26 + function isDarkMode(): boolean { 27 + return darkModeQuery?.matches ?? false 28 + } 29 + 30 + function applyColors() { 31 + const root = document.documentElement 32 + const dark = isDarkMode() 33 + 34 + if (dark) { 35 + if (state.primaryColorDark) { 36 + root.style.setProperty('--accent', state.primaryColorDark) 37 + } else { 38 + root.style.removeProperty('--accent') 39 + } 40 + if (state.secondaryColorDark) { 41 + root.style.setProperty('--secondary', state.secondaryColorDark) 42 + } else { 43 + root.style.removeProperty('--secondary') 44 + } 45 + } else { 46 + if (state.primaryColor) { 47 + root.style.setProperty('--accent', state.primaryColor) 48 + } else { 49 + root.style.removeProperty('--accent') 50 + } 51 + if (state.secondaryColor) { 52 + root.style.setProperty('--secondary', state.secondaryColor) 53 + } else { 54 + root.style.removeProperty('--secondary') 55 + } 56 + } 57 + } 58 + 59 + function setFavicon(hasLogo: boolean) { 60 + let link = document.querySelector<HTMLLinkElement>("link[rel~='icon']") 61 + if (hasLogo) { 62 + if (!link) { 63 + link = document.createElement('link') 64 + link.rel = 'icon' 65 + document.head.appendChild(link) 66 + } 67 + link.href = '/logo' 68 + } else if (link) { 69 + link.remove() 70 + } 71 + } 72 + 73 + export async function initServerConfig(): Promise<void> { 74 + if (initialized) return 75 + initialized = true 76 + 77 + darkModeQuery = window.matchMedia('(prefers-color-scheme: dark)') 78 + darkModeQuery.addEventListener('change', applyColors) 79 + 80 + try { 81 + const config = await api.getServerConfig() 82 + state.serverName = config.serverName 83 + state.primaryColor = config.primaryColor 84 + state.primaryColorDark = config.primaryColorDark 85 + state.secondaryColor = config.secondaryColor 86 + state.secondaryColorDark = config.secondaryColorDark 87 + state.hasLogo = !!config.logoCid 88 + document.title = config.serverName 89 + applyColors() 90 + setFavicon(state.hasLogo) 91 + } catch { 92 + state.serverName = null 93 + } finally { 94 + state.loading = false 95 + } 96 + } 97 + 98 + export function getServerConfigState() { 99 + return state 100 + } 101 + 102 + export function setServerName(name: string) { 103 + state.serverName = name 104 + document.title = name 105 + } 106 + 107 + export function setColors(colors: { 108 + primaryColor?: string | null 109 + primaryColorDark?: string | null 110 + secondaryColor?: string | null 111 + secondaryColorDark?: string | null 112 + }) { 113 + if (colors.primaryColor !== undefined) state.primaryColor = colors.primaryColor 114 + if (colors.primaryColorDark !== undefined) state.primaryColorDark = colors.primaryColorDark 115 + if (colors.secondaryColor !== undefined) state.secondaryColor = colors.secondaryColor 116 + if (colors.secondaryColorDark !== undefined) state.secondaryColorDark = colors.secondaryColorDark 117 + applyColors() 118 + } 119 + 120 + export function setHasLogo(hasLogo: boolean) { 121 + state.hasLogo = hasLogo 122 + setFavicon(hasLogo) 123 + }
+10 -2
frontend/src/locales/en.json
··· 14 14 "expires": "Expires", 15 15 "name": "Name", 16 16 "dashboard": "Dashboard", 17 - "backToDashboard": "← Dashboard" 17 + "backToDashboard": "← Dashboard", 18 + "copied": "Copied!", 19 + "copyToClipboard": "Copy to Clipboard" 18 20 }, 19 21 "login": { 20 22 "title": "Sign In", ··· 45 47 "register": { 46 48 "title": "Create Account", 47 49 "subtitle": "Create a new account on this PDS", 50 + "migrateTitle": "Already have a Bluesky account?", 51 + "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.", 52 + "migrateLink": "Migrate with PDS Moover", 48 53 "handle": "Handle", 49 54 "handlePlaceholder": "yourname", 50 55 "handleHint": "Your full handle will be: @{handle}", ··· 226 231 "revoke": "Revoke", 227 232 "revoking": "Revoking...", 228 233 "creating": "Creating...", 229 - "revokeConfirm": "Revoke app password \"{name}\"? Apps using this password will no longer be able to access your account." 234 + "revokeConfirm": "Revoke app password \"{name}\"? Apps using this password will no longer be able to access your account.", 235 + "saveWarningTitle": "Important: Save this app password!", 236 + "saveWarningMessage": "This password is required to sign into apps that don't support passkeys or OAuth. You will only see it once.", 237 + "acknowledgeLabel": "I have saved my app password in a secure location" 230 238 }, 231 239 "sessions": { 232 240 "title": "Active Sessions",
+10 -2
frontend/src/locales/fi.json
··· 14 14 "expires": "Vanhenee", 15 15 "name": "Nimi", 16 16 "dashboard": "Hallintapaneeli", 17 - "backToDashboard": "← Hallintapaneeli" 17 + "backToDashboard": "← Hallintapaneeli", 18 + "copied": "Kopioitu!", 19 + "copyToClipboard": "Kopioi" 18 20 }, 19 21 "login": { 20 22 "title": "Kirjaudu sisään", ··· 45 47 "register": { 46 48 "title": "Luo tili", 47 49 "subtitle": "Luo uusi tili tälle PDS:lle", 50 + "migrateTitle": "Onko sinulla jo Bluesky-tili?", 51 + "migrateDescription": "Voit siirtää olemassa olevan tilisi tälle PDS:lle uuden luomisen sijaan. Seuraajasi, julkaisusi ja identiteettisi siirtyvät mukana.", 52 + "migrateLink": "Siirrä PDS Mooverilla", 48 53 "handle": "Käyttäjänimi", 49 54 "handlePlaceholder": "nimesi", 50 55 "handleHint": "Täydellinen käyttäjänimesi on: @{handle}", ··· 226 231 "revoke": "Peruuta", 227 232 "revoking": "Peruutetaan...", 228 233 "creating": "Luodaan...", 229 - "revokeConfirm": "Peruuta sovelluksen salasana \"{name}\"? Sovellukset, jotka käyttävät tätä salasanaa, eivät enää pääse tilillesi." 234 + "revokeConfirm": "Peruuta sovelluksen salasana \"{name}\"? Sovellukset, jotka käyttävät tätä salasanaa, eivät enää pääse tilillesi.", 235 + "saveWarningTitle": "Tärkeää: Tallenna tämä sovelluksen salasana!", 236 + "saveWarningMessage": "Tämä salasana tarvitaan kirjautumiseen sovelluksiin, jotka eivät tue pääsyavaimia tai OAuthia. Näet sen vain kerran.", 237 + "acknowledgeLabel": "Olen tallentanut sovelluksen salasanani turvalliseen paikkaan" 230 238 }, 231 239 "sessions": { 232 240 "title": "Aktiiviset istunnot",
+10 -2
frontend/src/locales/ja.json
··· 14 14 "expires": "有効期限", 15 15 "name": "名前", 16 16 "dashboard": "ダッシュボード", 17 - "backToDashboard": "← ダッシュボード" 17 + "backToDashboard": "← ダッシュボード", 18 + "copied": "コピーしました!", 19 + "copyToClipboard": "クリップボードにコピー" 18 20 }, 19 21 "login": { 20 22 "title": "サインイン", ··· 45 47 "register": { 46 48 "title": "アカウント作成", 47 49 "subtitle": "この PDS で新規アカウントを作成", 50 + "migrateTitle": "すでにBlueskyアカウントをお持ちですか?", 51 + "migrateDescription": "新しいアカウントを作成する代わりに、既存のアカウントをこのPDSに移行できます。フォロワー、投稿、IDも一緒に移行されます。", 52 + "migrateLink": "PDS Mooverで移行する", 48 53 "handle": "ハンドル", 49 54 "handlePlaceholder": "あなたの名前", 50 55 "handleHint": "完全なハンドル: @{handle}", ··· 226 231 "revoke": "取り消す", 227 232 "revoking": "取り消し中...", 228 233 "creating": "作成中...", 229 - "revokeConfirm": "アプリパスワード「{name}」を取り消しますか?このパスワードを使用しているアプリはアカウントにアクセスできなくなります。" 234 + "revokeConfirm": "アプリパスワード「{name}」を取り消しますか?このパスワードを使用しているアプリはアカウントにアクセスできなくなります。", 235 + "saveWarningTitle": "重要: このアプリパスワードを保存してください!", 236 + "saveWarningMessage": "このパスワードはパスキーや OAuth をサポートしていないアプリにサインインするために必要です。一度しか表示されません。", 237 + "acknowledgeLabel": "アプリパスワードを安全な場所に保存しました" 230 238 }, 231 239 "sessions": { 232 240 "title": "アクティブセッション",
+10 -2
frontend/src/locales/ko.json
··· 14 14 "expires": "만료일", 15 15 "name": "이름", 16 16 "dashboard": "대시보드", 17 - "backToDashboard": "← 대시보드" 17 + "backToDashboard": "← 대시보드", 18 + "copied": "복사됨!", 19 + "copyToClipboard": "클립보드에 복사" 18 20 }, 19 21 "login": { 20 22 "title": "로그인", ··· 45 47 "register": { 46 48 "title": "계정 만들기", 47 49 "subtitle": "이 PDS에 새 계정을 만듭니다", 50 + "migrateTitle": "이미 Bluesky 계정이 있으신가요?", 51 + "migrateDescription": "새 계정을 만드는 대신 기존 계정을 이 PDS로 마이그레이션할 수 있습니다. 팔로워, 게시물, ID가 함께 이전됩니다.", 52 + "migrateLink": "PDS Moover로 마이그레이션", 48 53 "handle": "핸들", 49 54 "handlePlaceholder": "사용자 이름", 50 55 "handleHint": "전체 핸들: @{handle}", ··· 226 231 "revoke": "취소", 227 232 "revoking": "취소 중...", 228 233 "creating": "생성 중...", 229 - "revokeConfirm": "앱 비밀번호 \"{name}\"을(를) 취소하시겠습니까? 이 비밀번호를 사용하는 앱은 더 이상 계정에 액세스할 수 없습니다." 234 + "revokeConfirm": "앱 비밀번호 \"{name}\"을(를) 취소하시겠습니까? 이 비밀번호를 사용하는 앱은 더 이상 계정에 액세스할 수 없습니다.", 235 + "saveWarningTitle": "중요: 이 앱 비밀번호를 저장하세요!", 236 + "saveWarningMessage": "이 비밀번호는 패스키 또는 OAuth를 지원하지 않는 앱에 로그인하는 데 필요합니다. 한 번만 볼 수 있습니다.", 237 + "acknowledgeLabel": "앱 비밀번호를 안전한 곳에 저장했습니다" 230 238 }, 231 239 "sessions": { 232 240 "title": "활성 세션",
+10 -2
frontend/src/locales/sv.json
··· 14 14 "expires": "Upphör", 15 15 "name": "Namn", 16 16 "dashboard": "Kontrollpanel", 17 - "backToDashboard": "← Kontrollpanel" 17 + "backToDashboard": "← Kontrollpanel", 18 + "copied": "Kopierat!", 19 + "copyToClipboard": "Kopiera" 18 20 }, 19 21 "login": { 20 22 "title": "Logga in", ··· 45 47 "register": { 46 48 "title": "Skapa konto", 47 49 "subtitle": "Skapa ett nytt konto på denna PDS", 50 + "migrateTitle": "Har du redan ett Bluesky-konto?", 51 + "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.", 52 + "migrateLink": "Flytta med PDS Moover", 48 53 "handle": "Användarnamn", 49 54 "handlePlaceholder": "dittnamn", 50 55 "handleHint": "Ditt fullständiga användarnamn blir: @{handle}", ··· 226 231 "revoke": "Återkalla", 227 232 "revoking": "Återkallar...", 228 233 "creating": "Skapar...", 229 - "revokeConfirm": "Återkalla applösenord \"{name}\"? Appar som använder detta lösenord kommer inte längre att kunna komma åt ditt konto." 234 + "revokeConfirm": "Återkalla applösenord \"{name}\"? Appar som använder detta lösenord kommer inte längre att kunna komma åt ditt konto.", 235 + "saveWarningTitle": "Viktigt: Spara detta applösenord!", 236 + "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.", 237 + "acknowledgeLabel": "Jag har sparat mitt applösenord på en säker plats" 230 238 }, 231 239 "sessions": { 232 240 "title": "Aktiva sessioner",
+10 -2
frontend/src/locales/zh.json
··· 14 14 "expires": "过期时间", 15 15 "name": "名称", 16 16 "dashboard": "控制台", 17 - "backToDashboard": "← 返回控制台" 17 + "backToDashboard": "← 返回控制台", 18 + "copied": "已复制!", 19 + "copyToClipboard": "复制" 18 20 }, 19 21 "login": { 20 22 "title": "登录", ··· 45 47 "register": { 46 48 "title": "创建账户", 47 49 "subtitle": "在此 PDS 上创建新账户", 50 + "migrateTitle": "已有 Bluesky 账户?", 51 + "migrateDescription": "您可以将现有账户迁移到此 PDS,而无需创建新账户。您的关注者、帖子和身份都会一起迁移。", 52 + "migrateLink": "使用 PDS Moover 迁移", 48 53 "handle": "用户名", 49 54 "handlePlaceholder": "您的用户名", 50 55 "handleHint": "您的完整用户名将是:@{handle}", ··· 226 231 "revoke": "撤销", 227 232 "revoking": "撤销中...", 228 233 "creating": "创建中...", 229 - "revokeConfirm": "撤销「{name}」的密码?使用此密码的应用将无法再访问您的账户。" 234 + "revokeConfirm": "撤销「{name}」的密码?使用此密码的应用将无法再访问您的账户。", 235 + "saveWarningTitle": "重要:请保存此应用专用密码!", 236 + "saveWarningMessage": "此密码用于登录不支持通行密钥或 OAuth 的应用。您只能看到一次。", 237 + "acknowledgeLabel": "我已将应用专用密码保存在安全的地方" 230 238 }, 231 239 "sessions": { 232 240 "title": "登录会话",
+365
frontend/src/routes/Admin.svelte
··· 1 1 <script lang="ts"> 2 2 import { getAuthState } from '../lib/auth.svelte' 3 + import { setServerName as setGlobalServerName, setColors as setGlobalColors, setHasLogo as setGlobalHasLogo } from '../lib/serverConfig.svelte' 3 4 import { navigate } from '../lib/router.svelte' 4 5 import { api, ApiError } from '../lib/api' 5 6 import { _ } from '../lib/i18n' ··· 50 51 } | null>(null) 51 52 let userDetailLoading = $state(false) 52 53 let userActionLoading = $state(false) 54 + let serverName = $state('') 55 + let serverNameInput = $state('') 56 + let primaryColor = $state('') 57 + let primaryColorInput = $state('') 58 + let primaryColorDark = $state('') 59 + let primaryColorDarkInput = $state('') 60 + let secondaryColor = $state('') 61 + let secondaryColorInput = $state('') 62 + let secondaryColorDark = $state('') 63 + let secondaryColorDarkInput = $state('') 64 + let logoCid = $state<string | null>(null) 65 + let originalLogoCid = $state<string | null>(null) 66 + let logoFile = $state<File | null>(null) 67 + let logoPreview = $state<string | null>(null) 68 + let serverConfigLoading = $state(false) 69 + let serverConfigError = $state<string | null>(null) 70 + let serverConfigSuccess = $state(false) 53 71 $effect(() => { 54 72 if (!auth.loading && !auth.session) { 55 73 navigate('/login') ··· 60 78 $effect(() => { 61 79 if (auth.session?.isAdmin) { 62 80 loadStats() 81 + loadServerConfig() 63 82 } 64 83 }) 84 + async function loadServerConfig() { 85 + try { 86 + const config = await api.getServerConfig() 87 + serverName = config.serverName 88 + serverNameInput = config.serverName 89 + primaryColor = config.primaryColor || '' 90 + primaryColorInput = config.primaryColor || '' 91 + primaryColorDark = config.primaryColorDark || '' 92 + primaryColorDarkInput = config.primaryColorDark || '' 93 + secondaryColor = config.secondaryColor || '' 94 + secondaryColorInput = config.secondaryColor || '' 95 + secondaryColorDark = config.secondaryColorDark || '' 96 + secondaryColorDarkInput = config.secondaryColorDark || '' 97 + logoCid = config.logoCid 98 + originalLogoCid = config.logoCid 99 + if (config.logoCid) { 100 + logoPreview = '/logo' 101 + } 102 + } catch (e) { 103 + serverConfigError = e instanceof ApiError ? e.message : 'Failed to load server config' 104 + } 105 + } 106 + async function saveServerConfig(e: Event) { 107 + e.preventDefault() 108 + if (!auth.session) return 109 + serverConfigLoading = true 110 + serverConfigError = null 111 + serverConfigSuccess = false 112 + try { 113 + let newLogoCid = logoCid 114 + if (logoFile) { 115 + const result = await api.uploadBlob(auth.session.accessJwt, logoFile) 116 + newLogoCid = result.blob.ref.$link 117 + } 118 + await api.updateServerConfig(auth.session.accessJwt, { 119 + serverName: serverNameInput, 120 + primaryColor: primaryColorInput, 121 + primaryColorDark: primaryColorDarkInput, 122 + secondaryColor: secondaryColorInput, 123 + secondaryColorDark: secondaryColorDarkInput, 124 + logoCid: newLogoCid ?? '', 125 + }) 126 + serverName = serverNameInput 127 + primaryColor = primaryColorInput 128 + primaryColorDark = primaryColorDarkInput 129 + secondaryColor = secondaryColorInput 130 + secondaryColorDark = secondaryColorDarkInput 131 + logoCid = newLogoCid 132 + originalLogoCid = newLogoCid 133 + logoFile = null 134 + setGlobalServerName(serverNameInput) 135 + setGlobalColors({ 136 + primaryColor: primaryColorInput || null, 137 + primaryColorDark: primaryColorDarkInput || null, 138 + secondaryColor: secondaryColorInput || null, 139 + secondaryColorDark: secondaryColorDarkInput || null, 140 + }) 141 + setGlobalHasLogo(!!newLogoCid) 142 + serverConfigSuccess = true 143 + setTimeout(() => { serverConfigSuccess = false }, 3000) 144 + } catch (e) { 145 + serverConfigError = e instanceof ApiError ? e.message : 'Failed to save server config' 146 + } finally { 147 + serverConfigLoading = false 148 + } 149 + } 150 + 151 + function handleLogoChange(e: Event) { 152 + const input = e.target as HTMLInputElement 153 + const file = input.files?.[0] 154 + if (file) { 155 + logoFile = file 156 + logoPreview = URL.createObjectURL(file) 157 + } 158 + } 159 + 160 + function removeLogo() { 161 + logoFile = null 162 + logoCid = null 163 + logoPreview = null 164 + } 165 + 166 + function hasConfigChanges(): boolean { 167 + const logoChanged = logoFile !== null || logoCid !== originalLogoCid 168 + return serverNameInput !== serverName || 169 + primaryColorInput !== primaryColor || 170 + primaryColorDarkInput !== primaryColorDark || 171 + secondaryColorInput !== secondaryColor || 172 + secondaryColorDarkInput !== secondaryColorDark || 173 + logoChanged 174 + } 65 175 async function loadStats() { 66 176 if (!auth.session) return 67 177 loading = true ··· 201 311 {#if error} 202 312 <div class="message error">{error}</div> 203 313 {/if} 314 + <section> 315 + <h2>Server Configuration</h2> 316 + <form class="config-form" onsubmit={saveServerConfig}> 317 + <div class="form-group"> 318 + <label for="serverName">Server Name</label> 319 + <input 320 + type="text" 321 + id="serverName" 322 + bind:value={serverNameInput} 323 + placeholder="My PDS" 324 + maxlength="100" 325 + disabled={serverConfigLoading} 326 + /> 327 + <span class="help-text">Displayed in the browser tab and other places</span> 328 + </div> 329 + 330 + <div class="form-group"> 331 + <label for="serverLogo">Server Logo</label> 332 + <div class="logo-upload"> 333 + {#if logoPreview} 334 + <div class="logo-preview"> 335 + <img src={logoPreview} alt="Logo preview" /> 336 + <button type="button" class="remove-logo" onclick={removeLogo} disabled={serverConfigLoading}>Remove</button> 337 + </div> 338 + {:else} 339 + <input 340 + type="file" 341 + id="serverLogo" 342 + accept="image/*" 343 + onchange={handleLogoChange} 344 + disabled={serverConfigLoading} 345 + /> 346 + {/if} 347 + </div> 348 + <span class="help-text">Used as favicon and shown in the navbar</span> 349 + </div> 350 + 351 + <h3 class="subsection-title">Theme Colors</h3> 352 + <p class="theme-hint">Leave blank to use default colors.</p> 353 + 354 + <div class="color-grid"> 355 + <div class="color-group"> 356 + <label for="primaryColor">Primary (Light Mode)</label> 357 + <div class="color-input-row"> 358 + <input 359 + type="color" 360 + bind:value={primaryColorInput} 361 + disabled={serverConfigLoading} 362 + /> 363 + <input 364 + type="text" 365 + id="primaryColor" 366 + bind:value={primaryColorInput} 367 + placeholder="#2c00ff (default)" 368 + disabled={serverConfigLoading} 369 + /> 370 + </div> 371 + </div> 372 + <div class="color-group"> 373 + <label for="primaryColorDark">Primary (Dark Mode)</label> 374 + <div class="color-input-row"> 375 + <input 376 + type="color" 377 + bind:value={primaryColorDarkInput} 378 + disabled={serverConfigLoading} 379 + /> 380 + <input 381 + type="text" 382 + id="primaryColorDark" 383 + bind:value={primaryColorDarkInput} 384 + placeholder="#7b6bff (default)" 385 + disabled={serverConfigLoading} 386 + /> 387 + </div> 388 + </div> 389 + <div class="color-group"> 390 + <label for="secondaryColor">Secondary (Light Mode)</label> 391 + <div class="color-input-row"> 392 + <input 393 + type="color" 394 + bind:value={secondaryColorInput} 395 + disabled={serverConfigLoading} 396 + /> 397 + <input 398 + type="text" 399 + id="secondaryColor" 400 + bind:value={secondaryColorInput} 401 + placeholder="#ff2400 (default)" 402 + disabled={serverConfigLoading} 403 + /> 404 + </div> 405 + </div> 406 + <div class="color-group"> 407 + <label for="secondaryColorDark">Secondary (Dark Mode)</label> 408 + <div class="color-input-row"> 409 + <input 410 + type="color" 411 + bind:value={secondaryColorDarkInput} 412 + disabled={serverConfigLoading} 413 + /> 414 + <input 415 + type="text" 416 + id="secondaryColorDark" 417 + bind:value={secondaryColorDarkInput} 418 + placeholder="#ff6b5b (default)" 419 + disabled={serverConfigLoading} 420 + /> 421 + </div> 422 + </div> 423 + </div> 424 + 425 + {#if serverConfigError} 426 + <div class="message error">{serverConfigError}</div> 427 + {/if} 428 + {#if serverConfigSuccess} 429 + <div class="message success">Server configuration saved</div> 430 + {/if} 431 + <button type="submit" disabled={serverConfigLoading || !hasConfigChanges()}> 432 + {serverConfigLoading ? 'Saving...' : 'Save Configuration'} 433 + </button> 434 + </form> 435 + </section> 204 436 {#if stats} 205 437 <section> 206 438 <h2>Server Statistics</h2> ··· 453 685 background: var(--error-bg); 454 686 border: 1px solid var(--error-border); 455 687 color: var(--error-text); 688 + } 689 + 690 + .message.success { 691 + background: var(--success-bg); 692 + border: 1px solid var(--success-border); 693 + color: var(--success-text); 694 + } 695 + 696 + .config-form { 697 + max-width: 500px; 698 + } 699 + 700 + .form-group { 701 + margin-bottom: var(--space-4); 702 + } 703 + 704 + .form-group label { 705 + display: block; 706 + font-weight: var(--font-medium); 707 + margin-bottom: var(--space-2); 708 + font-size: var(--text-sm); 709 + } 710 + 711 + .form-group input { 712 + width: 100%; 713 + padding: var(--space-2) var(--space-3); 714 + border: 1px solid var(--border-color); 715 + border-radius: var(--radius-md); 716 + font-size: var(--text-sm); 717 + background: var(--bg-input); 718 + color: var(--text-primary); 719 + } 720 + 721 + .form-group input:focus { 722 + outline: none; 723 + border-color: var(--accent); 724 + } 725 + 726 + .help-text { 727 + display: block; 728 + font-size: var(--text-xs); 729 + color: var(--text-secondary); 730 + margin-top: var(--space-1); 731 + } 732 + 733 + .config-form button { 734 + padding: var(--space-2) var(--space-4); 735 + background: var(--accent); 736 + color: var(--text-inverse); 737 + border: none; 738 + border-radius: var(--radius-md); 739 + cursor: pointer; 740 + font-size: var(--text-sm); 741 + } 742 + 743 + .config-form button:hover:not(:disabled) { 744 + background: var(--accent-hover); 745 + } 746 + 747 + .config-form button:disabled { 748 + opacity: 0.6; 749 + cursor: not-allowed; 750 + } 751 + 752 + .subsection-title { 753 + font-size: var(--text-sm); 754 + font-weight: var(--font-semibold); 755 + color: var(--text-primary); 756 + margin: var(--space-5) 0 var(--space-2) 0; 757 + padding-top: var(--space-4); 758 + border-top: 1px solid var(--border-color); 759 + } 760 + 761 + .theme-hint { 762 + font-size: var(--text-xs); 763 + color: var(--text-secondary); 764 + margin-bottom: var(--space-4); 765 + } 766 + 767 + .color-grid { 768 + display: grid; 769 + grid-template-columns: 1fr 1fr; 770 + gap: var(--space-4); 771 + margin-bottom: var(--space-4); 772 + } 773 + 774 + @media (max-width: 500px) { 775 + .color-grid { 776 + grid-template-columns: 1fr; 777 + } 778 + } 779 + 780 + .color-group label { 781 + display: block; 782 + font-size: var(--text-xs); 783 + font-weight: var(--font-medium); 784 + color: var(--text-secondary); 785 + margin-bottom: var(--space-1); 786 + } 787 + 788 + .color-group input[type="text"] { 789 + width: 100%; 790 + } 791 + 792 + .logo-upload { 793 + margin-top: var(--space-2); 794 + } 795 + 796 + .logo-preview { 797 + display: flex; 798 + align-items: center; 799 + gap: var(--space-3); 800 + } 801 + 802 + .logo-preview img { 803 + width: 48px; 804 + height: 48px; 805 + object-fit: contain; 806 + border-radius: var(--radius-md); 807 + border: 1px solid var(--border-color); 808 + background: var(--bg-input); 809 + } 810 + 811 + .remove-logo { 812 + background: transparent; 813 + color: var(--error-text); 814 + border: 1px solid var(--error-border); 815 + padding: var(--space-1) var(--space-2); 816 + font-size: var(--text-xs); 817 + } 818 + 819 + .remove-logo:hover:not(:disabled) { 820 + background: var(--error-bg); 456 821 } 457 822 458 823 section {
+79 -17
frontend/src/routes/AppPasswords.svelte
··· 11 11 let newPasswordName = $state('') 12 12 let creating = $state(false) 13 13 let createdPassword = $state<{ name: string; password: string } | null>(null) 14 + let passwordCopied = $state(false) 15 + let passwordAcknowledged = $state(false) 14 16 let revoking = $state<string | null>(null) 15 17 $effect(() => { 16 18 if (!auth.loading && !auth.session) { ··· 67 69 revoking = null 68 70 } 69 71 } 72 + function copyPassword() { 73 + if (createdPassword) { 74 + navigator.clipboard.writeText(createdPassword.password) 75 + passwordCopied = true 76 + } 77 + } 70 78 function dismissCreated() { 71 79 createdPassword = null 80 + passwordCopied = false 81 + passwordAcknowledged = false 72 82 } 73 83 </script> 74 84 <div class="page"> ··· 84 94 {/if} 85 95 {#if createdPassword} 86 96 <div class="created-password"> 87 - <h3>{$_('appPasswords.created')}</h3> 88 - <p>{$_('appPasswords.createdMessage')}</p> 97 + <div class="warning-box"> 98 + <strong>{$_('appPasswords.saveWarningTitle')}</strong> 99 + <p>{$_('appPasswords.saveWarningMessage')}</p> 100 + </div> 89 101 <div class="password-display"> 90 - <code>{createdPassword.password}</code> 102 + <div class="password-label">{$_('common.name')}: <strong>{createdPassword.name}</strong></div> 103 + <code class="password-code">{createdPassword.password}</code> 104 + <button type="button" class="copy-btn" onclick={copyPassword}> 105 + {passwordCopied ? $_('common.copied') : $_('common.copyToClipboard')} 106 + </button> 91 107 </div> 92 - <p class="password-name">{$_('common.name')}: {createdPassword.name}</p> 93 - <button onclick={dismissCreated}>{$_('common.done')}</button> 108 + <label class="checkbox-label"> 109 + <input type="checkbox" bind:checked={passwordAcknowledged} /> 110 + <span>{$_('appPasswords.acknowledgeLabel')}</span> 111 + </label> 112 + <button onclick={dismissCreated} disabled={!passwordAcknowledged}>{$_('common.done')}</button> 94 113 </div> 95 114 {/if} 96 115 <section class="create-section"> ··· 175 194 } 176 195 177 196 .created-password { 197 + display: flex; 198 + flex-direction: column; 199 + gap: var(--space-4); 178 200 padding: var(--space-6); 179 - background: var(--success-bg); 180 - border: 1px solid var(--success-border); 201 + background: var(--bg-secondary); 202 + border: 1px solid var(--border-color); 181 203 border-radius: var(--radius-xl); 182 204 margin-bottom: var(--space-7); 183 205 } 184 206 185 - .created-password h3 { 186 - margin: 0 0 var(--space-2) 0; 187 - color: var(--success-text); 207 + .warning-box { 208 + padding: var(--space-5); 209 + background: var(--warning-bg); 210 + border: 1px solid var(--warning-border); 211 + border-radius: var(--radius-lg); 212 + font-size: var(--text-sm); 213 + } 214 + 215 + .warning-box strong { 216 + display: block; 217 + margin-bottom: var(--space-2); 218 + color: var(--warning-text); 219 + } 220 + 221 + .warning-box p { 222 + margin: 0; 223 + color: var(--warning-text); 188 224 } 189 225 190 226 .password-display { 191 227 background: var(--bg-card); 192 - padding: var(--space-4); 193 - border-radius: var(--radius-md); 194 - margin: var(--space-4) 0; 228 + border: 2px solid var(--accent); 229 + border-radius: var(--radius-xl); 230 + padding: var(--space-6); 231 + text-align: center; 195 232 } 196 233 197 - .password-display code { 234 + .password-label { 235 + font-size: var(--text-sm); 236 + color: var(--text-secondary); 237 + margin-bottom: var(--space-4); 238 + } 239 + 240 + .password-code { 241 + display: block; 198 242 font-size: var(--text-xl); 199 243 font-family: ui-monospace, monospace; 244 + letter-spacing: 0.1em; 245 + padding: var(--space-5); 246 + background: var(--bg-input); 247 + border-radius: var(--radius-md); 248 + margin-bottom: var(--space-4); 249 + user-select: all; 200 250 word-break: break-all; 201 251 } 202 252 203 - .password-name { 204 - color: var(--text-secondary); 253 + .copy-btn { 254 + padding: var(--space-3) var(--space-5); 205 255 font-size: var(--text-sm); 206 - margin-bottom: var(--space-4); 256 + } 257 + 258 + .checkbox-label { 259 + display: flex; 260 + align-items: center; 261 + gap: var(--space-3); 262 + cursor: pointer; 263 + font-weight: var(--font-normal); 264 + } 265 + 266 + .checkbox-label input[type="checkbox"] { 267 + width: auto; 268 + padding: 0; 207 269 } 208 270 209 271 section {
+67 -6
frontend/src/routes/Home.svelte
··· 2 2 import { onMount } from 'svelte' 3 3 import { _ } from '../lib/i18n' 4 4 import { getAuthState } from '../lib/auth.svelte' 5 + import { getServerConfigState } from '../lib/serverConfig.svelte' 6 + import { api } from '../lib/api' 5 7 6 8 const auth = getAuthState() 9 + const serverConfig = getServerConfigState() 7 10 const sourceUrl = 'https://tangled.org/lewis.moe/bspds-sandbox' 8 11 12 + let pdsHostname = $state<string | null>(null) 13 + let pdsVersion = $state<string | null>(null) 14 + let userCount = $state<number | null>(null) 15 + 9 16 onMount(() => { 17 + api.describeServer().then(info => { 18 + if (info.availableUserDomains?.length) { 19 + pdsHostname = info.availableUserDomains[0] 20 + } 21 + if (info.version) { 22 + pdsVersion = info.version 23 + } 24 + }).catch(() => {}) 25 + 26 + api.listRepos(1000).then(data => { 27 + userCount = data.repos.length 28 + }).catch(() => {}) 29 + 10 30 const pattern = document.getElementById('dotPattern') 11 31 if (!pattern) return 12 32 ··· 65 85 <div class="pattern-fade"></div> 66 86 67 87 <nav> 68 - <span class="brand">Tranquil PDS</span> 69 - <span class="nav-meta">0.1.0</span> 88 + <div class="nav-left"> 89 + {#if serverConfig.hasLogo} 90 + <img src="/logo" alt="Logo" class="nav-logo" /> 91 + {/if} 92 + {#if pdsHostname} 93 + <span class="hostname">{pdsHostname}</span> 94 + {#if userCount !== null} 95 + <span class="user-count">{userCount} {userCount === 1 ? 'user' : 'users'}</span> 96 + {/if} 97 + {:else} 98 + <span class="hostname placeholder">loading...</span> 99 + {/if} 100 + </div> 101 + <span class="nav-meta">{pdsVersion || ''}</span> 70 102 </nav> 71 103 72 104 <div class="home"> ··· 139 171 140 172 <footer class="site-footer"> 141 173 <span>Open Source</span> 142 - <span>Made with care</span> 174 + <span>Made with patience</span> 143 175 </footer> 144 176 </div> 145 177 ··· 209 241 align-items: center; 210 242 } 211 243 212 - .brand { 244 + .nav-left { 245 + display: flex; 246 + align-items: center; 247 + gap: var(--space-3); 248 + } 249 + 250 + .nav-logo { 251 + height: 28px; 252 + width: auto; 253 + object-fit: contain; 254 + border-radius: var(--radius-sm); 255 + } 256 + 257 + .hostname { 213 258 font-weight: var(--font-semibold); 214 259 font-size: var(--text-base); 215 260 letter-spacing: 0.08em; ··· 217 262 text-transform: uppercase; 218 263 } 219 264 265 + .hostname.placeholder { 266 + opacity: 0.4; 267 + } 268 + 269 + .user-count { 270 + font-size: var(--text-sm); 271 + color: rgba(255, 255, 255, 0.85); 272 + padding: 4px 10px; 273 + background: rgba(255, 255, 255, 0.15); 274 + border-radius: var(--radius-md); 275 + } 276 + 220 277 .nav-meta { 221 278 font-size: var(--text-sm); 222 279 color: rgba(255, 255, 255, 0.7); ··· 319 376 320 377 .content h2 { 321 378 font-size: var(--text-sm); 322 - font-weight: var(--font-semibold); 379 + font-weight: var(--font-bold); 323 380 text-transform: uppercase; 324 381 letter-spacing: 0.1em; 325 - color: var(--accent); 382 + color: var(--accent-light); 326 383 margin: var(--space-8) 0 var(--space-5); 327 384 } 328 385 ··· 380 437 381 438 .btn { 382 439 text-align: center; 440 + } 441 + 442 + .nav-meta { 443 + display: none; 383 444 } 384 445 } 385 446
+55
frontend/src/routes/Register.svelte
··· 132 132 </script> 133 133 134 134 <div class="register-page"> 135 + <div class="migrate-callout"> 136 + <div class="migrate-icon">↗</div> 137 + <div class="migrate-content"> 138 + <strong>{$_('register.migrateTitle')}</strong> 139 + <p>{$_('register.migrateDescription')}</p> 140 + <a href="https://pdsmoover.com/moover" target="_blank" rel="noopener" class="migrate-link"> 141 + {$_('register.migrateLink')} → 142 + </a> 143 + </div> 144 + </div> 145 + 135 146 {#if error} 136 147 <div class="message error">{error}</div> 137 148 {/if} ··· 343 354 max-width: var(--width-sm); 344 355 margin: var(--space-9) auto; 345 356 padding: var(--space-7); 357 + } 358 + 359 + .migrate-callout { 360 + display: flex; 361 + gap: var(--space-4); 362 + padding: var(--space-5); 363 + background: var(--accent-muted); 364 + border: 1px solid var(--accent); 365 + border-radius: var(--radius-xl); 366 + margin-bottom: var(--space-6); 367 + } 368 + 369 + .migrate-icon { 370 + font-size: var(--text-2xl); 371 + line-height: 1; 372 + color: var(--accent); 373 + } 374 + 375 + .migrate-content { 376 + flex: 1; 377 + } 378 + 379 + .migrate-content strong { 380 + display: block; 381 + color: var(--text-primary); 382 + margin-bottom: var(--space-2); 383 + } 384 + 385 + .migrate-content p { 386 + margin: 0 0 var(--space-3) 0; 387 + font-size: var(--text-sm); 388 + color: var(--text-secondary); 389 + line-height: var(--leading-relaxed); 390 + } 391 + 392 + .migrate-link { 393 + font-size: var(--text-sm); 394 + font-weight: var(--font-medium); 395 + color: var(--accent); 396 + text-decoration: none; 397 + } 398 + 399 + .migrate-link:hover { 400 + text-decoration: underline; 346 401 } 347 402 348 403 h1 {
+57
frontend/src/routes/RegisterPasskey.svelte
··· 303 303 </script> 304 304 305 305 <div class="register-page"> 306 + {#if step === 'info'} 307 + <div class="migrate-callout"> 308 + <div class="migrate-icon">↗</div> 309 + <div class="migrate-content"> 310 + <strong>{$_('register.migrateTitle')}</strong> 311 + <p>{$_('register.migrateDescription')}</p> 312 + <a href="https://pdsmoover.com/moover" target="_blank" rel="noopener" class="migrate-link"> 313 + {$_('register.migrateLink')} → 314 + </a> 315 + </div> 316 + </div> 317 + {/if} 318 + 306 319 <h1>Create Passkey Account</h1> 307 320 <p class="subtitle"> 308 321 {#if step === 'info'} ··· 539 552 max-width: var(--width-sm); 540 553 margin: var(--space-9) auto; 541 554 padding: var(--space-7); 555 + } 556 + 557 + .migrate-callout { 558 + display: flex; 559 + gap: var(--space-4); 560 + padding: var(--space-5); 561 + background: var(--accent-muted); 562 + border: 1px solid var(--accent); 563 + border-radius: var(--radius-xl); 564 + margin-bottom: var(--space-6); 565 + } 566 + 567 + .migrate-icon { 568 + font-size: var(--text-2xl); 569 + line-height: 1; 570 + color: var(--accent); 571 + } 572 + 573 + .migrate-content { 574 + flex: 1; 575 + } 576 + 577 + .migrate-content strong { 578 + display: block; 579 + color: var(--text-primary); 580 + margin-bottom: var(--space-2); 581 + } 582 + 583 + .migrate-content p { 584 + margin: 0 0 var(--space-3) 0; 585 + font-size: var(--text-sm); 586 + color: var(--text-secondary); 587 + line-height: var(--leading-relaxed); 588 + } 589 + 590 + .migrate-link { 591 + font-size: var(--text-sm); 592 + font-weight: var(--font-medium); 593 + color: var(--accent); 594 + text-decoration: none; 595 + } 596 + 597 + .migrate-link:hover { 598 + text-decoration: underline; 542 599 } 543 600 544 601 h1, h2 {
+14
frontend/src/styles/base.css
··· 1 1 @import './tokens.css'; 2 2 3 + @property --accent { 4 + syntax: '<color>'; 5 + inherits: true; 6 + initial-value: #2c00ff; 7 + } 8 + 9 + @property --secondary { 10 + syntax: '<color>'; 11 + inherits: true; 12 + initial-value: #ff2400; 13 + } 14 + 3 15 *, 4 16 *::before, 5 17 *::after { ··· 15 27 background: var(--bg-primary); 16 28 -webkit-font-smoothing: antialiased; 17 29 -moz-osx-font-smoothing: grayscale; 30 + transition: background-color 0.3s ease; 18 31 } 19 32 20 33 h1, h2, h3, h4, h5, h6 { ··· 34 47 a { 35 48 color: var(--secondary); 36 49 text-decoration: none; 50 + transition: color 0.3s ease; 37 51 } 38 52 39 53 a:hover {
+7 -7
frontend/src/styles/tokens.css
··· 106 106 --border-light: #222222; 107 107 --border-dark: #333333; 108 108 109 - --accent: #2c00ff; 110 - --accent-hover: #4d33ff; 111 - --accent-muted: rgba(44, 0, 255, 0.15); 112 - --accent-light: #4d33ff; 109 + --accent: #7b6bff; 110 + --accent-hover: #9588ff; 111 + --accent-muted: rgba(123, 107, 255, 0.2); 112 + --accent-light: #9588ff; 113 113 114 - --secondary: #ff2400; 115 - --secondary-hover: #ff5533; 116 - --secondary-muted: rgba(255, 36, 0, 0.15); 114 + --secondary: #ff6b5b; 115 + --secondary-hover: #ff8577; 116 + --secondary-muted: rgba(255, 107, 91, 0.2); 117 117 118 118 --success-bg: #1a3d1a; 119 119 --success-border: #2d5a2d;
+7
migrations/20251231_server_config.sql
··· 1 + CREATE TABLE IF NOT EXISTS server_config ( 2 + key TEXT PRIMARY KEY, 3 + value TEXT NOT NULL, 4 + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 5 + ); 6 + 7 + INSERT INTO server_config (key, value) VALUES ('server_name', 'Tranquil PDS') ON CONFLICT DO NOTHING;
+194
src/api/admin/config.rs
··· 1 + use crate::api::error::ApiError; 2 + use crate::auth::BearerAuthAdmin; 3 + use crate::state::AppState; 4 + use axum::{extract::State, Json}; 5 + use serde::{Deserialize, Serialize}; 6 + use tracing::error; 7 + 8 + #[derive(Serialize)] 9 + #[serde(rename_all = "camelCase")] 10 + pub 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")] 21 + pub 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)] 31 + pub struct UpdateServerConfigResponse { 32 + pub success: bool, 33 + } 34 + 35 + fn 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 + 42 + pub 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 + 80 + async 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 + 92 + async 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 + 100 + pub 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("Server name must be 1-100 characters".into())); 109 + } 110 + upsert_config(&state.db, "server_name", trimmed).await?; 111 + } 112 + 113 + if let Some(ref color) = req.primary_color { 114 + if color.is_empty() { 115 + delete_config(&state.db, "primary_color").await?; 116 + } else if is_valid_hex_color(color) { 117 + upsert_config(&state.db, "primary_color", color).await?; 118 + } else { 119 + return Err(ApiError::InvalidRequest("Invalid primary color format (expected #RRGGBB)".into())); 120 + } 121 + } 122 + 123 + if let Some(ref color) = req.primary_color_dark { 124 + if color.is_empty() { 125 + delete_config(&state.db, "primary_color_dark").await?; 126 + } else if is_valid_hex_color(color) { 127 + upsert_config(&state.db, "primary_color_dark", color).await?; 128 + } else { 129 + return Err(ApiError::InvalidRequest("Invalid primary dark color format (expected #RRGGBB)".into())); 130 + } 131 + } 132 + 133 + if let Some(ref color) = req.secondary_color { 134 + if color.is_empty() { 135 + delete_config(&state.db, "secondary_color").await?; 136 + } else if is_valid_hex_color(color) { 137 + upsert_config(&state.db, "secondary_color", color).await?; 138 + } else { 139 + return Err(ApiError::InvalidRequest("Invalid secondary color format (expected #RRGGBB)".into())); 140 + } 141 + } 142 + 143 + if let Some(ref color) = req.secondary_color_dark { 144 + if color.is_empty() { 145 + delete_config(&state.db, "secondary_color_dark").await?; 146 + } else if is_valid_hex_color(color) { 147 + upsert_config(&state.db, "secondary_color_dark", color).await?; 148 + } else { 149 + return Err(ApiError::InvalidRequest("Invalid secondary dark color format (expected #RRGGBB)".into())); 150 + } 151 + } 152 + 153 + if let Some(ref logo_cid) = req.logo_cid { 154 + let old_logo_cid: Option<String> = sqlx::query_scalar( 155 + "SELECT value FROM server_config WHERE key = 'logo_cid'" 156 + ) 157 + .fetch_optional(&state.db) 158 + .await?; 159 + 160 + let should_delete_old = match (&old_logo_cid, logo_cid.is_empty()) { 161 + (Some(old), true) => Some(old.clone()), 162 + (Some(old), false) if old != logo_cid => Some(old.clone()), 163 + _ => None, 164 + }; 165 + 166 + if let Some(old_cid) = should_delete_old { 167 + if let Ok(Some(blob)) = sqlx::query!( 168 + "SELECT storage_key FROM blobs WHERE cid = $1", 169 + old_cid 170 + ) 171 + .fetch_optional(&state.db) 172 + .await 173 + { 174 + if let Err(e) = state.blob_store.delete(&blob.storage_key).await { 175 + error!("Failed to delete old logo blob from storage: {:?}", e); 176 + } 177 + if let Err(e) = sqlx::query!("DELETE FROM blobs WHERE cid = $1", old_cid) 178 + .execute(&state.db) 179 + .await 180 + { 181 + error!("Failed to delete old logo blob record: {:?}", e); 182 + } 183 + } 184 + } 185 + 186 + if logo_cid.is_empty() { 187 + delete_config(&state.db, "logo_cid").await?; 188 + } else { 189 + upsert_config(&state.db, "logo_cid", logo_cid).await?; 190 + } 191 + } 192 + 193 + Ok(Json(UpdateServerConfigResponse { success: true })) 194 + }
+2
src/api/admin/mod.rs
··· 1 1 pub mod account; 2 + pub mod config; 2 3 pub mod invite; 3 4 pub mod server_stats; 4 5 pub mod status; ··· 7 8 delete_account, get_account_info, get_account_infos, search_accounts, send_email, 8 9 update_account_email, update_account_handle, update_account_password, 9 10 }; 11 + pub use config::{get_server_config, update_server_config}; 10 12 pub use invite::{ 11 13 disable_account_invites, disable_invite_codes, enable_account_invites, get_invite_codes, 12 14 };
+57
src/api/server/logo.rs
··· 1 + use crate::state::AppState; 2 + use axum::{ 3 + body::Body, 4 + extract::State, 5 + http::StatusCode, 6 + http::header, 7 + response::{IntoResponse, Response}, 8 + }; 9 + use tracing::error; 10 + 11 + pub async fn get_logo(State(state): State<AppState>) -> Response { 12 + let logo_cid: Option<String> = match sqlx::query_scalar( 13 + "SELECT value FROM server_config WHERE key = 'logo_cid'" 14 + ) 15 + .fetch_optional(&state.db) 16 + .await 17 + { 18 + Ok(cid) => cid, 19 + Err(e) => { 20 + error!("DB error fetching logo_cid: {:?}", e); 21 + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); 22 + } 23 + }; 24 + 25 + let cid = match logo_cid { 26 + Some(c) if !c.is_empty() => c, 27 + _ => return StatusCode::NOT_FOUND.into_response(), 28 + }; 29 + 30 + let blob = match sqlx::query!( 31 + "SELECT storage_key, mime_type FROM blobs WHERE cid = $1", 32 + cid 33 + ) 34 + .fetch_optional(&state.db) 35 + .await 36 + { 37 + Ok(Some(row)) => row, 38 + Ok(None) => return StatusCode::NOT_FOUND.into_response(), 39 + Err(e) => { 40 + error!("DB error fetching blob: {:?}", e); 41 + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); 42 + } 43 + }; 44 + 45 + match state.blob_store.get(&blob.storage_key).await { 46 + Ok(data) => Response::builder() 47 + .status(StatusCode::OK) 48 + .header(header::CONTENT_TYPE, &blob.mime_type) 49 + .header(header::CACHE_CONTROL, "public, max-age=3600") 50 + .body(Body::from(data)) 51 + .unwrap(), 52 + Err(e) => { 53 + error!("Failed to fetch logo from storage: {:?}", e); 54 + StatusCode::NOT_FOUND.into_response() 55 + } 56 + } 57 + }
+2 -1
src/api/server/meta.rs
··· 20 20 Json(json!({ 21 21 "availableUserDomains": domains, 22 22 "inviteCodeRequired": invite_code_required, 23 - "did": format!("did:web:{}", pds_hostname) 23 + "did": format!("did:web:{}", pds_hostname), 24 + "version": env!("CARGO_PKG_VERSION") 24 25 })) 25 26 } 26 27 pub async fn health(State(state): State<AppState>) -> impl IntoResponse {
+2
src/api/server/mod.rs
··· 2 2 pub mod app_password; 3 3 pub mod email; 4 4 pub mod invite; 5 + pub mod logo; 5 6 pub mod meta; 6 7 pub mod passkey_account; 7 8 pub mod passkeys; ··· 20 21 pub use app_password::{create_app_password, list_app_passwords, revoke_app_password}; 21 22 pub use email::{confirm_email, request_email_update, update_email}; 22 23 pub use invite::{create_invite_code, create_invite_codes, get_account_invite_codes}; 24 + pub use logo::get_logo; 23 25 pub use meta::{describe_server, health, robots_txt}; 24 26 pub use passkey_account::{ 25 27 complete_passkey_setup, create_passkey_account, recover_passkey_account,
+9
src/lib.rs
··· 35 35 .route("/health", get(api::server::health)) 36 36 .route("/xrpc/_health", get(api::server::health)) 37 37 .route("/robots.txt", get(api::server::robots_txt)) 38 + .route("/logo", get(api::server::get_logo)) 38 39 .route( 39 40 "/xrpc/com.atproto.server.describeServer", 40 41 get(api::server::describe_server), ··· 401 402 .route( 402 403 "/xrpc/com.tranquil.admin.getServerStats", 403 404 get(api::admin::get_server_stats), 405 + ) 406 + .route( 407 + "/xrpc/com.tranquil.server.getConfig", 408 + get(api::admin::get_server_config), 409 + ) 410 + .route( 411 + "/xrpc/com.tranquil.admin.updateServerConfig", 412 + post(api::admin::update_server_config), 404 413 ) 405 414 .route( 406 415 "/xrpc/com.atproto.admin.disableAccountInvites",
+1 -1
src/oauth/endpoints/metadata.rs
··· 172 172 "refresh_token".to_string(), 173 173 ], 174 174 response_types: vec!["code".to_string()], 175 - scope: "atproto transition:generic".to_string(), 175 + scope: "atproto repo:*?action=create repo:*?action=update repo:*?action=delete blob:*/*".to_string(), 176 176 token_endpoint_auth_method: "none".to_string(), 177 177 application_type: "web".to_string(), 178 178 dpop_bound_access_tokens: true,