diff --git a/.sqlx/query-6131bb5b39ca81bdbb193c0a9867bead8d9f3d793ad4eca97a79d166467a5052.json b/.sqlx/query-6131bb5b39ca81bdbb193c0a9867bead8d9f3d793ad4eca97a79d166467a5052.json new file mode 100644 index 0000000..5c07cbf --- /dev/null +++ b/.sqlx/query-6131bb5b39ca81bdbb193c0a9867bead8d9f3d793ad4eca97a79d166467a5052.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT storage_key FROM blobs WHERE cid = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "storage_key", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "6131bb5b39ca81bdbb193c0a9867bead8d9f3d793ad4eca97a79d166467a5052" +} diff --git a/.sqlx/query-d2990ce7f233d2489bb36a63920571c9f454a0605cc463829693d581bc0dce12.json b/.sqlx/query-d2990ce7f233d2489bb36a63920571c9f454a0605cc463829693d581bc0dce12.json new file mode 100644 index 0000000..dcf8493 --- /dev/null +++ b/.sqlx/query-d2990ce7f233d2489bb36a63920571c9f454a0605cc463829693d581bc0dce12.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM blobs WHERE cid = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "d2990ce7f233d2489bb36a63920571c9f454a0605cc463829693d581bc0dce12" +} diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 3ec362f..a083594 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -1,6 +1,7 @@
@@ -84,13 +94,22 @@ {/if} {#if createdPassword}
-

{$_('appPasswords.created')}

-

{$_('appPasswords.createdMessage')}

-
- {createdPassword.password} +
+ {$_('appPasswords.saveWarningTitle')} +

{$_('appPasswords.saveWarningMessage')}

-

{$_('common.name')}: {createdPassword.name}

- +
+
{$_('common.name')}: {createdPassword.name}
+ {createdPassword.password} + +
+ +
{/if}
@@ -175,35 +194,78 @@ } .created-password { + display: flex; + flex-direction: column; + gap: var(--space-4); padding: var(--space-6); - background: var(--success-bg); - border: 1px solid var(--success-border); + background: var(--bg-secondary); + border: 1px solid var(--border-color); border-radius: var(--radius-xl); margin-bottom: var(--space-7); } - .created-password h3 { - margin: 0 0 var(--space-2) 0; - color: var(--success-text); + .warning-box { + padding: var(--space-5); + background: var(--warning-bg); + border: 1px solid var(--warning-border); + border-radius: var(--radius-lg); + font-size: var(--text-sm); + } + + .warning-box strong { + display: block; + margin-bottom: var(--space-2); + color: var(--warning-text); + } + + .warning-box p { + margin: 0; + color: var(--warning-text); } .password-display { background: var(--bg-card); - padding: var(--space-4); - border-radius: var(--radius-md); - margin: var(--space-4) 0; + border: 2px solid var(--accent); + border-radius: var(--radius-xl); + padding: var(--space-6); + text-align: center; } - .password-display code { + .password-label { + font-size: var(--text-sm); + color: var(--text-secondary); + margin-bottom: var(--space-4); + } + + .password-code { + display: block; font-size: var(--text-xl); font-family: ui-monospace, monospace; + letter-spacing: 0.1em; + padding: var(--space-5); + background: var(--bg-input); + border-radius: var(--radius-md); + margin-bottom: var(--space-4); + user-select: all; word-break: break-all; } - .password-name { - color: var(--text-secondary); + .copy-btn { + padding: var(--space-3) var(--space-5); font-size: var(--text-sm); - margin-bottom: var(--space-4); + } + + .checkbox-label { + display: flex; + align-items: center; + gap: var(--space-3); + cursor: pointer; + font-weight: var(--font-normal); + } + + .checkbox-label input[type="checkbox"] { + width: auto; + padding: 0; } section { diff --git a/frontend/src/routes/Home.svelte b/frontend/src/routes/Home.svelte index 2e7ceef..f714908 100644 --- a/frontend/src/routes/Home.svelte +++ b/frontend/src/routes/Home.svelte @@ -2,11 +2,31 @@ import { onMount } from 'svelte' import { _ } from '../lib/i18n' import { getAuthState } from '../lib/auth.svelte' + import { getServerConfigState } from '../lib/serverConfig.svelte' + import { api } from '../lib/api' const auth = getAuthState() + const serverConfig = getServerConfigState() const sourceUrl = 'https://tangled.org/lewis.moe/bspds-sandbox' + let pdsHostname = $state(null) + let pdsVersion = $state(null) + let userCount = $state(null) + onMount(() => { + api.describeServer().then(info => { + if (info.availableUserDomains?.length) { + pdsHostname = info.availableUserDomains[0] + } + if (info.version) { + pdsVersion = info.version + } + }).catch(() => {}) + + api.listRepos(1000).then(data => { + userCount = data.repos.length + }).catch(() => {}) + const pattern = document.getElementById('dotPattern') if (!pattern) return @@ -65,8 +85,20 @@
@@ -139,7 +171,7 @@
Open Source - Made with care + Made with patience
@@ -209,7 +241,20 @@ align-items: center; } - .brand { + .nav-left { + display: flex; + align-items: center; + gap: var(--space-3); + } + + .nav-logo { + height: 28px; + width: auto; + object-fit: contain; + border-radius: var(--radius-sm); + } + + .hostname { font-weight: var(--font-semibold); font-size: var(--text-base); letter-spacing: 0.08em; @@ -217,6 +262,18 @@ text-transform: uppercase; } + .hostname.placeholder { + opacity: 0.4; + } + + .user-count { + font-size: var(--text-sm); + color: rgba(255, 255, 255, 0.85); + padding: 4px 10px; + background: rgba(255, 255, 255, 0.15); + border-radius: var(--radius-md); + } + .nav-meta { font-size: var(--text-sm); color: rgba(255, 255, 255, 0.7); @@ -319,10 +376,10 @@ .content h2 { font-size: var(--text-sm); - font-weight: var(--font-semibold); + font-weight: var(--font-bold); text-transform: uppercase; letter-spacing: 0.1em; - color: var(--accent); + color: var(--accent-light); margin: var(--space-8) 0 var(--space-5); } @@ -381,6 +438,10 @@ .btn { text-align: center; } + + .nav-meta { + display: none; + } } .site-footer { diff --git a/frontend/src/routes/Register.svelte b/frontend/src/routes/Register.svelte index 0d72cee..06055e8 100644 --- a/frontend/src/routes/Register.svelte +++ b/frontend/src/routes/Register.svelte @@ -132,6 +132,17 @@
+
+
+
+ {$_('register.migrateTitle')} +

{$_('register.migrateDescription')}

+ + {$_('register.migrateLink')} → + +
+
+ {#if error}
{error}
{/if} @@ -345,6 +356,50 @@ padding: var(--space-7); } + .migrate-callout { + display: flex; + gap: var(--space-4); + padding: var(--space-5); + background: var(--accent-muted); + border: 1px solid var(--accent); + border-radius: var(--radius-xl); + margin-bottom: var(--space-6); + } + + .migrate-icon { + font-size: var(--text-2xl); + line-height: 1; + color: var(--accent); + } + + .migrate-content { + flex: 1; + } + + .migrate-content strong { + display: block; + color: var(--text-primary); + margin-bottom: var(--space-2); + } + + .migrate-content p { + margin: 0 0 var(--space-3) 0; + font-size: var(--text-sm); + color: var(--text-secondary); + line-height: var(--leading-relaxed); + } + + .migrate-link { + font-size: var(--text-sm); + font-weight: var(--font-medium); + color: var(--accent); + text-decoration: none; + } + + .migrate-link:hover { + text-decoration: underline; + } + h1 { margin: 0 0 var(--space-3) 0; } diff --git a/frontend/src/routes/RegisterPasskey.svelte b/frontend/src/routes/RegisterPasskey.svelte index b317782..7fc4d46 100644 --- a/frontend/src/routes/RegisterPasskey.svelte +++ b/frontend/src/routes/RegisterPasskey.svelte @@ -303,6 +303,19 @@
+ {#if step === 'info'} +
+
+
+ {$_('register.migrateTitle')} +

{$_('register.migrateDescription')}

+ + {$_('register.migrateLink')} → + +
+
+ {/if} +

Create Passkey Account

{#if step === 'info'} @@ -541,6 +554,50 @@ padding: var(--space-7); } + .migrate-callout { + display: flex; + gap: var(--space-4); + padding: var(--space-5); + background: var(--accent-muted); + border: 1px solid var(--accent); + border-radius: var(--radius-xl); + margin-bottom: var(--space-6); + } + + .migrate-icon { + font-size: var(--text-2xl); + line-height: 1; + color: var(--accent); + } + + .migrate-content { + flex: 1; + } + + .migrate-content strong { + display: block; + color: var(--text-primary); + margin-bottom: var(--space-2); + } + + .migrate-content p { + margin: 0 0 var(--space-3) 0; + font-size: var(--text-sm); + color: var(--text-secondary); + line-height: var(--leading-relaxed); + } + + .migrate-link { + font-size: var(--text-sm); + font-weight: var(--font-medium); + color: var(--accent); + text-decoration: none; + } + + .migrate-link:hover { + text-decoration: underline; + } + h1, h2 { margin: 0 0 var(--space-3) 0; } diff --git a/frontend/src/styles/base.css b/frontend/src/styles/base.css index e3de2cd..c967f59 100644 --- a/frontend/src/styles/base.css +++ b/frontend/src/styles/base.css @@ -1,5 +1,17 @@ @import './tokens.css'; +@property --accent { + syntax: ''; + inherits: true; + initial-value: #2c00ff; +} + +@property --secondary { + syntax: ''; + inherits: true; + initial-value: #ff2400; +} + *, *::before, *::after { @@ -15,6 +27,7 @@ body { background: var(--bg-primary); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; + transition: background-color 0.3s ease; } h1, h2, h3, h4, h5, h6 { @@ -34,6 +47,7 @@ p { a { color: var(--secondary); text-decoration: none; + transition: color 0.3s ease; } a:hover { diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css index 142e650..c5526a1 100644 --- a/frontend/src/styles/tokens.css +++ b/frontend/src/styles/tokens.css @@ -106,14 +106,14 @@ --border-light: #222222; --border-dark: #333333; - --accent: #2c00ff; - --accent-hover: #4d33ff; - --accent-muted: rgba(44, 0, 255, 0.15); - --accent-light: #4d33ff; + --accent: #7b6bff; + --accent-hover: #9588ff; + --accent-muted: rgba(123, 107, 255, 0.2); + --accent-light: #9588ff; - --secondary: #ff2400; - --secondary-hover: #ff5533; - --secondary-muted: rgba(255, 36, 0, 0.15); + --secondary: #ff6b5b; + --secondary-hover: #ff8577; + --secondary-muted: rgba(255, 107, 91, 0.2); --success-bg: #1a3d1a; --success-border: #2d5a2d; diff --git a/migrations/20251231_server_config.sql b/migrations/20251231_server_config.sql new file mode 100644 index 0000000..49a8fd4 --- /dev/null +++ b/migrations/20251231_server_config.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS server_config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +INSERT INTO server_config (key, value) VALUES ('server_name', 'Tranquil PDS') ON CONFLICT DO NOTHING; diff --git a/src/api/admin/config.rs b/src/api/admin/config.rs new file mode 100644 index 0000000..506da8a --- /dev/null +++ b/src/api/admin/config.rs @@ -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, + pub primary_color_dark: Option, + pub secondary_color: Option, + pub secondary_color_dark: Option, + pub logo_cid: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateServerConfigRequest { + pub server_name: Option, + pub primary_color: Option, + pub primary_color_dark: Option, + pub secondary_color: Option, + pub secondary_color_dark: Option, + pub logo_cid: Option, +} + +#[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, +) -> Result, 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, + _admin: BearerAuthAdmin, + Json(req): Json, +) -> Result, 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 = 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 })) +} diff --git a/src/api/admin/mod.rs b/src/api/admin/mod.rs index 89c1c26..b1998ce 100644 --- a/src/api/admin/mod.rs +++ b/src/api/admin/mod.rs @@ -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, }; diff --git a/src/api/server/logo.rs b/src/api/server/logo.rs new file mode 100644 index 0000000..c8c42b9 --- /dev/null +++ b/src/api/server/logo.rs @@ -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) -> Response { + let logo_cid: Option = 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() + } + } +} diff --git a/src/api/server/meta.rs b/src/api/server/meta.rs index 3fa3d84..da299bd 100644 --- a/src/api/server/meta.rs +++ b/src/api/server/meta.rs @@ -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) -> impl IntoResponse { diff --git a/src/api/server/mod.rs b/src/api/server/mod.rs index ca5ceaa..aceb14d 100644 --- a/src/api/server/mod.rs +++ b/src/api/server/mod.rs @@ -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, diff --git a/src/lib.rs b/src/lib.rs index 4cf90ed..021a6f9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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), diff --git a/src/oauth/endpoints/metadata.rs b/src/oauth/endpoints/metadata.rs index abb0ac7..6d35ca6 100644 --- a/src/oauth/endpoints/metadata.rs +++ b/src/oauth/endpoints/metadata.rs @@ -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,