A lexicon-driven AppView for ATProto. happyview.dev
backfill firehose jetstream atproto appview oauth lexicon
8
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add config for oauth screen values

Trezy 62b5c3a6 46d98475

+791 -7
+18
Cargo.lock
··· 248 248 "matchit", 249 249 "memchr", 250 250 "mime", 251 + "multer", 251 252 "percent-encoding", 252 253 "pin-project-lite", 253 254 "serde_core", ··· 1987 1988 "smallvec", 1988 1989 "tagptr", 1989 1990 "uuid", 1991 + ] 1992 + 1993 + [[package]] 1994 + name = "multer" 1995 + version = "3.1.0" 1996 + source = "registry+https://github.com/rust-lang/crates.io-index" 1997 + checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" 1998 + dependencies = [ 1999 + "bytes", 2000 + "encoding_rs", 2001 + "futures-util", 2002 + "http", 2003 + "httparse", 2004 + "memchr", 2005 + "mime", 2006 + "spin", 2007 + "version_check", 1990 2008 ] 1991 2009 1992 2010 [[package]]
+1 -1
Cargo.toml
··· 11 11 atrium-common = "0.1" 12 12 atrium-api = { version = "0.25", features = ["agent"] } 13 13 atrium-xrpc = "0.12" 14 - axum = "0.8" 14 + axum = { version = "0.8", features = ["multipart"] } 15 15 axum-extra = { version = "0.10", features = ["cookie", "cookie-signed", "cookie-key-expansion", "query"] } 16 16 base64 = "0.22" 17 17 dashmap = "6"
+8
docs/getting-started/configuration.md
··· 18 18 | `PLC_URL` | no | `https://plc.directory` | [PLC directory](https://github.com/did-method-plc/did-method-plc) URL for DID resolution | 19 19 | `EVENT_LOG_RETENTION_DAYS` | no | `30` | Number of days to keep event logs before automatic cleanup. Set to `0` to disable cleanup | 20 20 | `RUST_LOG` | no | `happyview=debug,tower_http=debug` | Log filter (uses `tracing_subscriber::EnvFilter`) | 21 + | `APP_NAME` | no | --- | Application name shown on OAuth authorization screens. Overridden by database setting if set via admin API | 22 + | `LOGO_URI` | no | --- | URL to application logo for OAuth screens. Overridden by database setting or logo upload | 23 + | `TOS_URI` | no | --- | URL to terms of service. Overridden by database setting if set via admin API | 24 + | `POLICY_URI` | no | --- | URL to privacy policy. Overridden by database setting if set via admin API | 21 25 22 26 ## Example `.env` 23 27 ··· 39 43 # PLC_URL=https://plc.directory 40 44 # EVENT_LOG_RETENTION_DAYS=30 41 45 # RUST_LOG=happyview=debug,tower_http=debug 46 + # APP_NAME=My App 47 + # LOGO_URI=https://example.com/logo.png 48 + # TOS_URI=https://example.com/tos 49 + # POLICY_URI=https://example.com/privacy 42 50 ```
+9
docs/reference/architecture.md
··· 56 56 permissions.rs Permission enum (20 permissions), templates (Viewer, Operator, Manager, FullAccess) 57 57 api_keys.rs API key CRUD handlers (create, list, revoke) with scoped permissions 58 58 events.rs Event log query handler 59 + settings.rs Instance settings CRUD handlers (list, upsert, delete, logo upload/serve) 59 60 script_variables.rs Script variable CRUD handlers (list, upsert, delete) 60 61 lexicons.rs Lexicon CRUD handlers 61 62 network_lexicons.rs Network lexicon tracking (add, list, remove) ··· 224 225 | `state_key` | text (PK) | OAuth state parameter | 225 226 | `state_data` | text | Serialized state (managed by atrium) | 226 227 | `created_at` | timestamptz | | 228 + 229 + ### `instance_settings` 230 + 231 + | Column | Type | Description | 232 + | ------------ | ----------- | -------------------------------------------- | 233 + | `key` | text (PK) | Setting name (e.g. `app_name`) | 234 + | `value` | text | Setting value | 235 + | `updated_at` | timestamptz | Last modified | 227 236 228 237 ### `event_logs` 229 238
+10
docs/reference/changelog.md
··· 1 1 # Changelog 2 2 3 + ## v2.1.0 — Native OAuth & Instance Settings 4 + 5 + - **Built-in OAuth** — replaced external AIP OAuth dependency with native `atrium-oauth` integration; HappyView manages the full OAuth flow internally 6 + - **Instance settings** — new `instance_settings` key/value table for configurable instance metadata (app name, logo, ToS, privacy policy) with env var fallback 7 + - **OAuth branding** — authorization screens now show configurable app name, logo, terms of service, and privacy policy links via the `/oauth/client-metadata.json` endpoint 8 + - **Logo upload** — upload a logo image via `PUT /admin/settings/logo` (stored in DB, served at `GET /settings/logo`) 9 + - **`settings:manage` permission** — new permission for managing instance settings, included in Manager and Full Access templates 10 + - **Redirect URI support** — `/auth/login` accepts optional `redirect_uri` parameter for post-login navigation 11 + - **CORS improvements** — origin-mirroring CORS with credentials support for cross-origin auth flows 12 + 3 13 ## v2.0.0 — User Permissions & Settings Restructure 4 14 5 15 - **User permissions system** — replaced the `admins` table with a `users` table supporting 20 granular permissions, permission templates (Viewer, Operator, Manager, Full Access), and a super user concept with escalation and self-modification guards
+5
migrations/postgres/20260320000000_create_instance_settings.sql
··· 1 + CREATE TABLE instance_settings ( 2 + key TEXT PRIMARY KEY, 3 + value TEXT NOT NULL, 4 + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 5 + );
+5
migrations/sqlite/20260320000000_create_instance_settings.sql
··· 1 + CREATE TABLE instance_settings ( 2 + key TEXT PRIMARY KEY, 3 + value TEXT NOT NULL, 4 + updated_at TEXT NOT NULL DEFAULT (datetime('now')) 5 + );
+10
src/admin/mod.rs
··· 9 9 mod rate_limits; 10 10 mod records; 11 11 mod script_variables; 12 + pub(crate) mod settings; 12 13 mod stats; 13 14 mod tap_stats; 14 15 mod types; ··· 78 79 .route( 79 80 "/rate-limits/allowlist/{id}", 80 81 delete(rate_limits::remove_allowlist), 82 + ) 83 + .route("/settings", get(settings::list)) 84 + .route( 85 + "/settings/logo", 86 + put(settings::upload_logo).delete(settings::delete_logo), 87 + ) 88 + .route( 89 + "/settings/{key}", 90 + put(settings::upsert).delete(settings::delete), 81 91 ) 82 92 }
+8 -2
src/admin/permissions.rs
··· 2 2 3 3 use serde::{Deserialize, Serialize}; 4 4 5 - /// All 23 permissions in the system. 5 + /// All 27 permissions in the system. 6 6 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] 7 7 pub enum Permission { 8 8 #[serde(rename = "lexicons:create")] ··· 66 66 RateLimitsCreate, 67 67 #[serde(rename = "rate-limits:delete")] 68 68 RateLimitsDelete, 69 + 70 + #[serde(rename = "settings:manage")] 71 + SettingsManage, 69 72 } 70 73 71 74 impl Permission { ··· 98 101 Self::RateLimitsRead => "rate-limits:read", 99 102 Self::RateLimitsCreate => "rate-limits:create", 100 103 Self::RateLimitsDelete => "rate-limits:delete", 104 + Self::SettingsManage => "settings:manage", 101 105 } 102 106 } 103 107 104 - /// All 26 permissions. 108 + /// All 27 permissions. 105 109 pub fn all() -> HashSet<Permission> { 106 110 HashSet::from([ 107 111 Self::LexiconsCreate, ··· 130 134 Self::RateLimitsRead, 131 135 Self::RateLimitsCreate, 132 136 Self::RateLimitsDelete, 137 + Self::SettingsManage, 133 138 ]) 134 139 } 135 140 } ··· 177 182 perms.insert(Permission::RateLimitsRead); 178 183 perms.insert(Permission::RateLimitsCreate); 179 184 perms.insert(Permission::RateLimitsDelete); 185 + perms.insert(Permission::SettingsManage); 180 186 perms 181 187 } 182 188 Self::FullAccess => Permission::all(),
+315
src/admin/settings.rs
··· 1 + use axum::Json; 2 + use axum::extract::{Path, State}; 3 + use axum::http::StatusCode; 4 + use base64::Engine; 5 + use sqlx::AnyPool; 6 + use std::env; 7 + 8 + use crate::AppState; 9 + use crate::db::{DatabaseBackend, adapt_sql, now_rfc3339}; 10 + use crate::error::AppError; 11 + use crate::event_log::{EventLog, Severity, log_event}; 12 + 13 + use super::auth::UserAuth; 14 + use super::permissions::Permission; 15 + use super::types::{SettingEntry, UpsertSettingBody}; 16 + 17 + const ENV_FALLBACKS: &[(&str, &str)] = &[ 18 + ("app_name", "APP_NAME"), 19 + ("logo_uri", "LOGO_URI"), 20 + ("tos_uri", "TOS_URI"), 21 + ("policy_uri", "POLICY_URI"), 22 + ]; 23 + 24 + /// Resolve a setting value: check the DB first, then fall back to env var. 25 + pub(crate) async fn get_setting( 26 + pool: &AnyPool, 27 + key: &str, 28 + backend: DatabaseBackend, 29 + ) -> Option<String> { 30 + let sql = adapt_sql("SELECT value FROM instance_settings WHERE key = ?", backend); 31 + let row: Option<(String,)> = sqlx::query_as(&sql) 32 + .bind(key) 33 + .fetch_optional(pool) 34 + .await 35 + .ok() 36 + .flatten(); 37 + 38 + if let Some((value,)) = row { 39 + return Some(value); 40 + } 41 + 42 + // Fall back to env var if one is mapped for this key. 43 + for (setting_key, env_var) in ENV_FALLBACKS { 44 + if *setting_key == key { 45 + return env::var(env_var).ok(); 46 + } 47 + } 48 + 49 + None 50 + } 51 + 52 + /// GET /admin/settings — list all settings with their source. 53 + pub(super) async fn list( 54 + State(state): State<AppState>, 55 + auth: UserAuth, 56 + ) -> Result<Json<Vec<SettingEntry>>, AppError> { 57 + auth.require(Permission::SettingsManage).await?; 58 + 59 + let backend = state.db_backend; 60 + let sql = adapt_sql( 61 + "SELECT key, value FROM instance_settings ORDER BY key", 62 + backend, 63 + ); 64 + let rows: Vec<(String, String)> = sqlx::query_as(&sql) 65 + .fetch_all(&state.db) 66 + .await 67 + .map_err(|e| AppError::Internal(format!("failed to list settings: {e}")))?; 68 + 69 + let db_keys: std::collections::HashSet<String> = rows.iter().map(|(k, _)| k.clone()).collect(); 70 + 71 + let mut entries: Vec<SettingEntry> = rows 72 + .into_iter() 73 + .map(|(key, value)| SettingEntry { 74 + key, 75 + value, 76 + source: "database".to_string(), 77 + }) 78 + .collect(); 79 + 80 + // Add env-var fallback entries for keys not already present in DB. 81 + for (setting_key, env_var) in ENV_FALLBACKS { 82 + if !db_keys.contains(*setting_key) 83 + && let Ok(value) = env::var(env_var) 84 + { 85 + entries.push(SettingEntry { 86 + key: setting_key.to_string(), 87 + value, 88 + source: "env".to_string(), 89 + }); 90 + } 91 + } 92 + 93 + Ok(Json(entries)) 94 + } 95 + 96 + /// PUT /admin/settings/{key} — create or update a setting. 97 + pub(super) async fn upsert( 98 + State(state): State<AppState>, 99 + auth: UserAuth, 100 + Path(key): Path<String>, 101 + Json(body): Json<UpsertSettingBody>, 102 + ) -> Result<StatusCode, AppError> { 103 + auth.require(Permission::SettingsManage).await?; 104 + 105 + let backend = state.db_backend; 106 + let now = now_rfc3339(); 107 + let sql = adapt_sql( 108 + r#" 109 + INSERT INTO instance_settings (key, value, updated_at) 110 + VALUES (?, ?, ?) 111 + ON CONFLICT (key) DO UPDATE SET value = ?, updated_at = ? 112 + "#, 113 + backend, 114 + ); 115 + sqlx::query(&sql) 116 + .bind(&key) 117 + .bind(&body.value) 118 + .bind(&now) 119 + .bind(&body.value) 120 + .bind(&now) 121 + .execute(&state.db) 122 + .await 123 + .map_err(|e| AppError::Internal(format!("failed to upsert setting: {e}")))?; 124 + 125 + log_event( 126 + &state.db, 127 + EventLog { 128 + event_type: "setting.updated".to_string(), 129 + severity: Severity::Info, 130 + actor_did: Some(auth.did.clone()), 131 + subject: Some(key.clone()), 132 + detail: serde_json::json!({ "value": body.value }), 133 + }, 134 + state.db_backend, 135 + ) 136 + .await; 137 + 138 + Ok(StatusCode::NO_CONTENT) 139 + } 140 + 141 + /// DELETE /admin/settings/{key} — delete a setting. 142 + pub(super) async fn delete( 143 + State(state): State<AppState>, 144 + auth: UserAuth, 145 + Path(key): Path<String>, 146 + ) -> Result<StatusCode, AppError> { 147 + auth.require(Permission::SettingsManage).await?; 148 + 149 + let backend = state.db_backend; 150 + let sql = adapt_sql("DELETE FROM instance_settings WHERE key = ?", backend); 151 + let result = sqlx::query(&sql) 152 + .bind(&key) 153 + .execute(&state.db) 154 + .await 155 + .map_err(|e| AppError::Internal(format!("failed to delete setting: {e}")))?; 156 + 157 + if result.rows_affected() == 0 { 158 + return Err(AppError::NotFound(format!("setting '{key}' not found"))); 159 + } 160 + 161 + log_event( 162 + &state.db, 163 + EventLog { 164 + event_type: "setting.deleted".to_string(), 165 + severity: Severity::Info, 166 + actor_did: Some(auth.did.clone()), 167 + subject: Some(key), 168 + detail: serde_json::json!({}), 169 + }, 170 + state.db_backend, 171 + ) 172 + .await; 173 + 174 + Ok(StatusCode::NO_CONTENT) 175 + } 176 + 177 + /// PUT /admin/settings/logo — upload a logo image (max 5MB). 178 + pub(super) async fn upload_logo( 179 + State(state): State<AppState>, 180 + auth: UserAuth, 181 + mut multipart: axum::extract::Multipart, 182 + ) -> Result<StatusCode, AppError> { 183 + auth.require(Permission::SettingsManage).await?; 184 + 185 + let field = multipart 186 + .next_field() 187 + .await 188 + .map_err(|e| AppError::BadRequest(format!("invalid multipart: {e}")))? 189 + .ok_or_else(|| AppError::BadRequest("no file uploaded".into()))?; 190 + 191 + let content_type = field 192 + .content_type() 193 + .unwrap_or("application/octet-stream") 194 + .to_string(); 195 + 196 + if !content_type.starts_with("image/") { 197 + return Err(AppError::BadRequest("file must be an image".into())); 198 + } 199 + 200 + let data = field 201 + .bytes() 202 + .await 203 + .map_err(|e| AppError::BadRequest(format!("failed to read upload: {e}")))?; 204 + 205 + if data.len() > 5 * 1024 * 1024 { 206 + return Err(AppError::BadRequest("logo must be 5MB or smaller".into())); 207 + } 208 + 209 + let encoded = base64::engine::general_purpose::STANDARD.encode(&data); 210 + 211 + let backend = state.db_backend; 212 + let now = now_rfc3339(); 213 + let sql = adapt_sql( 214 + "INSERT INTO instance_settings (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT (key) DO UPDATE SET value = ?, updated_at = ?", 215 + backend, 216 + ); 217 + for (key, value) in [ 218 + ("logo_data", encoded.as_str()), 219 + ("logo_content_type", content_type.as_str()), 220 + ] { 221 + sqlx::query(&sql) 222 + .bind(key) 223 + .bind(value) 224 + .bind(&now) 225 + .bind(value) 226 + .bind(&now) 227 + .execute(&state.db) 228 + .await 229 + .map_err(|e| AppError::Internal(format!("failed to store logo: {e}")))?; 230 + } 231 + 232 + log_event( 233 + &state.db, 234 + EventLog { 235 + event_type: "setting.updated".to_string(), 236 + severity: Severity::Info, 237 + actor_did: Some(auth.did.clone()), 238 + subject: Some("logo".to_string()), 239 + detail: serde_json::json!({ "content_type": content_type, "size_bytes": data.len() }), 240 + }, 241 + state.db_backend, 242 + ) 243 + .await; 244 + 245 + Ok(StatusCode::NO_CONTENT) 246 + } 247 + 248 + /// DELETE /admin/settings/logo — remove uploaded logo. 249 + pub(super) async fn delete_logo( 250 + State(state): State<AppState>, 251 + auth: UserAuth, 252 + ) -> Result<StatusCode, AppError> { 253 + auth.require(Permission::SettingsManage).await?; 254 + 255 + let backend = state.db_backend; 256 + let sql = adapt_sql("DELETE FROM instance_settings WHERE key IN (?, ?)", backend); 257 + sqlx::query(&sql) 258 + .bind("logo_data") 259 + .bind("logo_content_type") 260 + .execute(&state.db) 261 + .await 262 + .map_err(|e| AppError::Internal(format!("failed to delete logo: {e}")))?; 263 + 264 + log_event( 265 + &state.db, 266 + EventLog { 267 + event_type: "setting.deleted".to_string(), 268 + severity: Severity::Info, 269 + actor_did: Some(auth.did.clone()), 270 + subject: Some("logo".to_string()), 271 + detail: serde_json::json!({}), 272 + }, 273 + state.db_backend, 274 + ) 275 + .await; 276 + 277 + Ok(StatusCode::NO_CONTENT) 278 + } 279 + 280 + /// GET /settings/logo — serve the uploaded logo (public, no auth). 281 + pub(crate) async fn serve_logo( 282 + State(state): State<AppState>, 283 + ) -> Result<axum::response::Response, AppError> { 284 + let backend = state.db_backend; 285 + let sql = adapt_sql( 286 + "SELECT key, value FROM instance_settings WHERE key IN (?, ?)", 287 + backend, 288 + ); 289 + let rows: Vec<(String, String)> = sqlx::query_as(&sql) 290 + .bind("logo_data") 291 + .bind("logo_content_type") 292 + .fetch_all(&state.db) 293 + .await 294 + .map_err(|e| AppError::Internal(format!("failed to load logo: {e}")))?; 295 + 296 + let data = rows.iter().find(|(k, _)| k == "logo_data").map(|(_, v)| v); 297 + let ct = rows 298 + .iter() 299 + .find(|(k, _)| k == "logo_content_type") 300 + .map(|(_, v)| v.as_str()); 301 + 302 + match (data, ct) { 303 + (Some(encoded), Some(content_type)) => { 304 + let bytes = base64::engine::general_purpose::STANDARD 305 + .decode(encoded) 306 + .map_err(|e| AppError::Internal(format!("failed to decode logo: {e}")))?; 307 + Ok(axum::response::Response::builder() 308 + .header("content-type", content_type) 309 + .header("cache-control", "public, max-age=3600") 310 + .body(axum::body::Body::from(bytes)) 311 + .unwrap()) 312 + } 313 + _ => Err(AppError::NotFound("no logo uploaded".into())), 314 + } 315 + }
+16
src/admin/types.rs
··· 195 195 } 196 196 197 197 // --------------------------------------------------------------------------- 198 + // Settings types 199 + // --------------------------------------------------------------------------- 200 + 201 + #[derive(Serialize)] 202 + pub(super) struct SettingEntry { 203 + pub(super) key: String, 204 + pub(super) value: String, 205 + pub(super) source: String, 206 + } 207 + 208 + #[derive(Deserialize)] 209 + pub(super) struct UpsertSettingBody { 210 + pub(super) value: String, 211 + } 212 + 213 + // --------------------------------------------------------------------------- 198 214 // User permission / transfer types 199 215 // --------------------------------------------------------------------------- 200 216
+16
src/config.rs
··· 17 17 pub plc_url: String, 18 18 pub static_dir: String, 19 19 pub event_log_retention_days: u32, 20 + pub app_name: Option<String>, 21 + pub logo_uri: Option<String>, 22 + pub tos_uri: Option<String>, 23 + pub policy_uri: Option<String>, 20 24 } 21 25 22 26 impl Config { ··· 47 51 .ok() 48 52 .and_then(|v| v.parse().ok()) 49 53 .unwrap_or(30), 54 + app_name: env::var("APP_NAME").ok(), 55 + logo_uri: env::var("LOGO_URI").ok(), 56 + tos_uri: env::var("TOS_URI").ok(), 57 + policy_uri: env::var("POLICY_URI").ok(), 50 58 } 51 59 } 52 60 ··· 75 83 "RELAY_URL", 76 84 "PLC_URL", 77 85 "EVENT_LOG_RETENTION_DAYS", 86 + "APP_NAME", 87 + "LOGO_URI", 88 + "TOS_URI", 89 + "POLICY_URI", 78 90 ] { 79 91 unsafe { 80 92 env::remove_var(key); ··· 104 116 plc_url: String::new(), 105 117 static_dir: String::new(), 106 118 event_log_retention_days: 30, 119 + app_name: None, 120 + logo_uri: None, 121 + tos_uri: None, 122 + policy_uri: None, 107 123 }; 108 124 assert_eq!( 109 125 config.listen_addr(),
+4
src/lua/atproto_api.rs
··· 218 218 plc_url: plc_url.to_string(), 219 219 static_dir: String::new(), 220 220 event_log_retention_days: 30, 221 + app_name: None, 222 + logo_uri: None, 223 + tos_uri: None, 224 + policy_uri: None, 221 225 }; 222 226 let (tx, _) = watch::channel(vec![]); 223 227 let (labeler_tx, _) = watch::channel(());
+4
src/lua/db_api.rs
··· 626 626 plc_url: String::new(), 627 627 static_dir: String::new(), 628 628 event_log_retention_days: 30, 629 + app_name: None, 630 + logo_uri: None, 631 + tos_uri: None, 632 + policy_uri: None, 629 633 }; 630 634 let (tx, _) = watch::channel(vec![]); 631 635 let (labeler_tx, _) = watch::channel(());
+4
src/lua/execute.rs
··· 960 960 plc_url: String::new(), 961 961 static_dir: String::new(), 962 962 event_log_retention_days: 30, 963 + app_name: None, 964 + logo_uri: None, 965 + tos_uri: None, 966 + policy_uri: None, 963 967 }; 964 968 let (tx, _) = watch::channel(vec![]); 965 969 let (labeler_tx, _) = watch::channel(());
+4
src/lua/http_api.rs
··· 100 100 plc_url: String::new(), 101 101 static_dir: String::new(), 102 102 event_log_retention_days: 30, 103 + app_name: None, 104 + logo_uri: None, 105 + tos_uri: None, 106 + policy_uri: None, 103 107 }; 104 108 let (tx, _) = watch::channel(vec![]); 105 109 let (labeler_tx, _) = watch::channel(());
+32 -2
src/server.rs
··· 64 64 65 65 Router::new() 66 66 .route("/health", get(health)) 67 + .route("/settings/logo", get(crate::admin::settings::serve_logo)) 67 68 .nest("/admin", admin::admin_routes(state.clone())) 68 69 .nest("/auth", crate::auth::routes::routes()) 69 70 .route("/oauth/client-metadata.json", get(client_metadata)) ··· 96 97 } 97 98 98 99 async fn client_metadata(State(state): State<AppState>) -> Json<serde_json::Value> { 99 - let client_metadata = &state.oauth.client_metadata; 100 - Json(serde_json::to_value(client_metadata).unwrap_or_default()) 100 + let mut metadata = serde_json::to_value(&state.oauth.client_metadata).unwrap_or_default(); 101 + 102 + let pool = &state.db; 103 + let backend = state.db_backend; 104 + 105 + if let Some(name) = crate::admin::settings::get_setting(pool, "app_name", backend).await { 106 + metadata["client_name"] = serde_json::Value::String(name); 107 + } 108 + 109 + // Logo: prefer uploaded logo_data (served at /settings/logo), fall back to logo_uri setting 110 + let has_logo_data = crate::admin::settings::get_setting(pool, "logo_data", backend) 111 + .await 112 + .is_some(); 113 + if has_logo_data { 114 + metadata["logo_uri"] = serde_json::Value::String(format!( 115 + "{}/settings/logo", 116 + state.config.public_url.trim_end_matches('/') 117 + )); 118 + } else if let Some(uri) = crate::admin::settings::get_setting(pool, "logo_uri", backend).await { 119 + metadata["logo_uri"] = serde_json::Value::String(uri); 120 + } 121 + 122 + if let Some(uri) = crate::admin::settings::get_setting(pool, "tos_uri", backend).await { 123 + metadata["tos_uri"] = serde_json::Value::String(uri); 124 + } 125 + 126 + if let Some(uri) = crate::admin::settings::get_setting(pool, "policy_uri", backend).await { 127 + metadata["policy_uri"] = serde_json::Value::String(uri); 128 + } 129 + 130 + Json(metadata) 101 131 } 102 132 103 133 fn ip_from_forwarded_for(value: Option<&str>) -> Option<IpAddr> {
+7 -1
tests/common/app.rs
··· 47 47 plc_url: mock_url.clone(), 48 48 static_dir: "./web/out".into(), 49 49 event_log_retention_days: 30, 50 + app_name: None, 51 + logo_uri: None, 52 + tos_uri: None, 53 + policy_uri: None, 50 54 }; 51 55 52 56 let sql = adapt_sql( ··· 122 126 vec![], 123 127 ), 124 128 oauth: std::sync::Arc::new(oauth), 125 - cookie_key: axum_extra::extract::cookie::Key::derive_from(b"test-secret"), 129 + cookie_key: axum_extra::extract::cookie::Key::derive_from( 130 + b"test-secret-that-is-at-least-32-bytes-long", 131 + ), 126 132 }; 127 133 128 134 let router = server::router(state.clone());
+2 -1
tests/common/db.rs
··· 20 20 match backend { 21 21 DatabaseBackend::Postgres => { 22 22 sqlx::query( 23 - "TRUNCATE records, lexicons, backfill_jobs, users, user_permissions, api_keys, event_logs, script_variables, dead_letter_hooks, record_refs, labeler_subscriptions, labels RESTART IDENTITY CASCADE", 23 + "TRUNCATE records, lexicons, backfill_jobs, users, user_permissions, api_keys, event_logs, script_variables, dead_letter_hooks, record_refs, labeler_subscriptions, labels, instance_settings RESTART IDENTITY CASCADE", 24 24 ) 25 25 .execute(pool) 26 26 .await ··· 40 40 "record_refs", 41 41 "labeler_subscriptions", 42 42 "labels", 43 + "instance_settings", 43 44 ]; 44 45 for table in tables { 45 46 sqlx::query(&format!("DELETE FROM {table}"))
+305
tests/e2e_settings.rs
··· 1 + mod common; 2 + 3 + use axum::body::Body; 4 + use axum::http::{Method, Request, StatusCode}; 5 + use http_body_util::BodyExt; 6 + use serde_json::{Value, json}; 7 + use serial_test::serial; 8 + use tower::ServiceExt; 9 + 10 + use common::app::TestApp; 11 + 12 + // --------------------------------------------------------------------------- 13 + // Helpers 14 + // --------------------------------------------------------------------------- 15 + 16 + async fn json_body(resp: axum::response::Response) -> Value { 17 + let body = resp.into_body().collect().await.unwrap().to_bytes(); 18 + serde_json::from_slice(&body).unwrap() 19 + } 20 + 21 + fn admin_get( 22 + uri: &str, 23 + cookie: (axum::http::HeaderName, axum::http::HeaderValue), 24 + ) -> Request<Body> { 25 + Request::builder() 26 + .uri(uri) 27 + .header(cookie.0, cookie.1) 28 + .body(Body::empty()) 29 + .unwrap() 30 + } 31 + 32 + fn admin_put( 33 + uri: &str, 34 + cookie: (axum::http::HeaderName, axum::http::HeaderValue), 35 + body: &Value, 36 + ) -> Request<Body> { 37 + Request::builder() 38 + .method(Method::PUT) 39 + .uri(uri) 40 + .header(cookie.0, cookie.1) 41 + .header("content-type", "application/json") 42 + .body(Body::from(serde_json::to_vec(body).unwrap())) 43 + .unwrap() 44 + } 45 + 46 + fn admin_delete( 47 + uri: &str, 48 + cookie: (axum::http::HeaderName, axum::http::HeaderValue), 49 + ) -> Request<Body> { 50 + Request::builder() 51 + .method(Method::DELETE) 52 + .uri(uri) 53 + .header(cookie.0, cookie.1) 54 + .body(Body::empty()) 55 + .unwrap() 56 + } 57 + 58 + // --------------------------------------------------------------------------- 59 + // Settings tests 60 + // --------------------------------------------------------------------------- 61 + 62 + #[tokio::test] 63 + #[serial] 64 + #[ignore] 65 + async fn settings_crud() { 66 + let app = TestApp::new().await; 67 + 68 + // PUT a setting 69 + let resp = app 70 + .router 71 + .clone() 72 + .oneshot(admin_put( 73 + "/admin/settings/app_name", 74 + app.admin_cookie(), 75 + &json!({ "value": "Test App" }), 76 + )) 77 + .await 78 + .unwrap(); 79 + 80 + assert!( 81 + resp.status().is_success(), 82 + "expected success on PUT, got {}", 83 + resp.status() 84 + ); 85 + 86 + // GET all settings and verify the entry appears with source: "database" 87 + let resp = app 88 + .router 89 + .clone() 90 + .oneshot(admin_get("/admin/settings", app.admin_cookie())) 91 + .await 92 + .unwrap(); 93 + 94 + assert_eq!(resp.status(), StatusCode::OK); 95 + let json = json_body(resp).await; 96 + let settings = json.as_array().unwrap(); 97 + let app_name_entry = settings 98 + .iter() 99 + .find(|s| s["key"] == "app_name") 100 + .expect("app_name entry not found in settings"); 101 + assert_eq!(app_name_entry["source"], "database"); 102 + 103 + // DELETE the setting 104 + let resp = app 105 + .router 106 + .clone() 107 + .oneshot(admin_delete("/admin/settings/app_name", app.admin_cookie())) 108 + .await 109 + .unwrap(); 110 + 111 + assert!( 112 + resp.status().is_success(), 113 + "expected success on DELETE, got {}", 114 + resp.status() 115 + ); 116 + 117 + // GET again and verify it's removed 118 + let resp = app 119 + .router 120 + .clone() 121 + .oneshot(admin_get("/admin/settings", app.admin_cookie())) 122 + .await 123 + .unwrap(); 124 + 125 + assert_eq!(resp.status(), StatusCode::OK); 126 + let json = json_body(resp).await; 127 + let settings = json.as_array().unwrap(); 128 + let app_name_entry = settings.iter().find(|s| s["key"] == "app_name"); 129 + assert!( 130 + app_name_entry.is_none(), 131 + "app_name entry should have been deleted" 132 + ); 133 + } 134 + 135 + #[tokio::test] 136 + #[serial] 137 + #[ignore] 138 + async fn settings_requires_auth() { 139 + let app = TestApp::new().await; 140 + 141 + let resp = app 142 + .router 143 + .clone() 144 + .oneshot( 145 + Request::builder() 146 + .uri("/admin/settings") 147 + .body(Body::empty()) 148 + .unwrap(), 149 + ) 150 + .await 151 + .unwrap(); 152 + 153 + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); 154 + } 155 + 156 + #[tokio::test] 157 + #[serial] 158 + #[ignore] 159 + async fn logo_upload_and_serve() { 160 + let app = TestApp::new().await; 161 + 162 + let boundary = "----testboundary"; 163 + // Minimal valid 1x1 PNG 164 + let png_bytes: Vec<u8> = vec![ 165 + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature 166 + 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR chunk 167 + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, // 1x1 168 + 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xDE, // 8-bit RGB 169 + 0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41, 0x54, // IDAT chunk 170 + 0x08, 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0xE2, 0x21, 0xBC, 171 + 0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, // IEND chunk 172 + 0xAE, 0x42, 0x60, 0x82, 173 + ]; 174 + let body = format!( 175 + "--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"logo.png\"\r\nContent-Type: image/png\r\n\r\n" 176 + ); 177 + let mut body_bytes = body.into_bytes(); 178 + body_bytes.extend_from_slice(&png_bytes); 179 + body_bytes.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes()); 180 + 181 + let cookie = app.admin_cookie(); 182 + let resp = app 183 + .router 184 + .clone() 185 + .oneshot( 186 + Request::builder() 187 + .method(Method::PUT) 188 + .uri("/admin/settings/logo") 189 + .header(cookie.0, cookie.1) 190 + .header( 191 + "content-type", 192 + format!("multipart/form-data; boundary={boundary}"), 193 + ) 194 + .body(Body::from(body_bytes)) 195 + .unwrap(), 196 + ) 197 + .await 198 + .unwrap(); 199 + 200 + assert!( 201 + resp.status().is_success(), 202 + "expected success on logo upload, got {}", 203 + resp.status() 204 + ); 205 + 206 + // GET /settings/logo (public route) and verify 200 with content-type: image/png 207 + let resp = app 208 + .router 209 + .clone() 210 + .oneshot( 211 + Request::builder() 212 + .uri("/settings/logo") 213 + .body(Body::empty()) 214 + .unwrap(), 215 + ) 216 + .await 217 + .unwrap(); 218 + 219 + assert_eq!(resp.status(), StatusCode::OK); 220 + let content_type = resp 221 + .headers() 222 + .get("content-type") 223 + .expect("expected content-type header") 224 + .to_str() 225 + .unwrap(); 226 + assert!( 227 + content_type.contains("image/png"), 228 + "expected image/png content-type, got {content_type}" 229 + ); 230 + 231 + // DELETE /admin/settings/logo 232 + let resp = app 233 + .router 234 + .clone() 235 + .oneshot(admin_delete("/admin/settings/logo", app.admin_cookie())) 236 + .await 237 + .unwrap(); 238 + 239 + assert!( 240 + resp.status().is_success(), 241 + "expected success on DELETE logo, got {}", 242 + resp.status() 243 + ); 244 + 245 + // GET /settings/logo should now return 404 246 + let resp = app 247 + .router 248 + .clone() 249 + .oneshot( 250 + Request::builder() 251 + .uri("/settings/logo") 252 + .body(Body::empty()) 253 + .unwrap(), 254 + ) 255 + .await 256 + .unwrap(); 257 + 258 + assert_eq!(resp.status(), StatusCode::NOT_FOUND); 259 + } 260 + 261 + #[tokio::test] 262 + #[serial] 263 + #[ignore] 264 + async fn client_metadata_includes_settings() { 265 + let app = TestApp::new().await; 266 + 267 + // PUT app_name setting 268 + let resp = app 269 + .router 270 + .clone() 271 + .oneshot(admin_put( 272 + "/admin/settings/app_name", 273 + app.admin_cookie(), 274 + &json!({ "value": "Test App" }), 275 + )) 276 + .await 277 + .unwrap(); 278 + 279 + assert!( 280 + resp.status().is_success(), 281 + "expected success on PUT app_name, got {}", 282 + resp.status() 283 + ); 284 + 285 + // GET /oauth/client-metadata.json (no auth) and verify client_name 286 + let resp = app 287 + .router 288 + .clone() 289 + .oneshot( 290 + Request::builder() 291 + .uri("/oauth/client-metadata.json") 292 + .body(Body::empty()) 293 + .unwrap(), 294 + ) 295 + .await 296 + .unwrap(); 297 + 298 + assert_eq!(resp.status(), StatusCode::OK); 299 + let json = json_body(resp).await; 300 + assert_eq!( 301 + json["client_name"], "Test App", 302 + "expected client_name to be 'Test App', got {:?}", 303 + json["client_name"] 304 + ); 305 + }
+4
tests/lua_atproto_api.rs
··· 29 29 plc_url: String::new(), 30 30 static_dir: String::new(), 31 31 event_log_retention_days: 30, 32 + app_name: None, 33 + logo_uri: None, 34 + tos_uri: None, 35 + policy_uri: None, 32 36 }; 33 37 let (tx, _) = watch::channel(vec![]); 34 38 let (labeler_tx, _) = watch::channel(());
+4
tests/lua_db_api.rs
··· 32 32 plc_url: String::new(), 33 33 static_dir: String::new(), 34 34 event_log_retention_days: 30, 35 + app_name: None, 36 + logo_uri: None, 37 + tos_uri: None, 38 + policy_uri: None, 35 39 }; 36 40 let (tx, _) = watch::channel(vec![]); 37 41 let (labeler_tx, _) = watch::channel(());