diff --git a/Cargo.lock b/Cargo.lock --- a/Cargo.lock +++ b/Cargo.lock @@ -248,6 +248,7 @@ "itoa", "matchit", "memchr", "mime", + "multer", "percent-encoding", "pin-project-lite", "serde_core", @@ -1987,6 +1988,23 @@ "portable-atomic", "smallvec", "tagptr", "uuid", +] + +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ atrium-identity = "0.1" atrium-common = "0.1" atrium-api = { version = "0.25", features = ["agent"] } atrium-xrpc = "0.12" -axum = "0.8" +axum = { version = "0.8", features = ["multipart"] } axum-extra = { version = "0.10", features = ["cookie", "cookie-signed", "cookie-key-expansion", "query"] } base64 = "0.22" dashmap = "6" diff --git a/docs/getting-started/configuration.md b/docs/getting-started/configuration.md --- a/docs/getting-started/configuration.md +++ b/docs/getting-started/configuration.md @@ -18,6 +18,10 @@ | `RELAY_URL` | no | `https://bsky.network` | Relay URL for [backfill](../guides/backfill.md) repo discovery | | `PLC_URL` | no | `https://plc.directory` | [PLC directory](https://github.com/did-method-plc/did-method-plc) URL for DID resolution | | `EVENT_LOG_RETENTION_DAYS` | no | `30` | Number of days to keep event logs before automatic cleanup. Set to `0` to disable cleanup | | `RUST_LOG` | no | `happyview=debug,tower_http=debug` | Log filter (uses `tracing_subscriber::EnvFilter`) | +| `APP_NAME` | no | --- | Application name shown on OAuth authorization screens. Overridden by database setting if set via admin API | +| `LOGO_URI` | no | --- | URL to application logo for OAuth screens. Overridden by database setting or logo upload | +| `TOS_URI` | no | --- | URL to terms of service. Overridden by database setting if set via admin API | +| `POLICY_URI` | no | --- | URL to privacy policy. Overridden by database setting if set via admin API | ## Example `.env` @@ -39,4 +43,8 @@ # RELAY_URL=https://bsky.network # PLC_URL=https://plc.directory # EVENT_LOG_RETENTION_DAYS=30 # RUST_LOG=happyview=debug,tower_http=debug +# APP_NAME=My App +# LOGO_URI=https://example.com/logo.png +# TOS_URI=https://example.com/tos +# POLICY_URI=https://example.com/privacy ``` diff --git a/docs/reference/architecture.md b/docs/reference/architecture.md --- a/docs/reference/architecture.md +++ b/docs/reference/architecture.md @@ -56,6 +56,7 @@ users.rs User CRUD handlers (create, list, get, delete, update permissions, transfer super) permissions.rs Permission enum (20 permissions), templates (Viewer, Operator, Manager, FullAccess) api_keys.rs API key CRUD handlers (create, list, revoke) with scoped permissions events.rs Event log query handler + settings.rs Instance settings CRUD handlers (list, upsert, delete, logo upload/serve) script_variables.rs Script variable CRUD handlers (list, upsert, delete) lexicons.rs Lexicon CRUD handlers network_lexicons.rs Network lexicon tracking (add, list, remove) @@ -224,6 +225,14 @@ | ------------ | ----------- | -------------------------------------------- | | `state_key` | text (PK) | OAuth state parameter | | `state_data` | text | Serialized state (managed by atrium) | | `created_at` | timestamptz | | + +### `instance_settings` + +| Column | Type | Description | +| ------------ | ----------- | -------------------------------------------- | +| `key` | text (PK) | Setting name (e.g. `app_name`) | +| `value` | text | Setting value | +| `updated_at` | timestamptz | Last modified | ### `event_logs` diff --git a/docs/reference/changelog.md b/docs/reference/changelog.md --- a/docs/reference/changelog.md +++ b/docs/reference/changelog.md @@ -1,5 +1,15 @@ # Changelog +## v2.1.0 — Native OAuth & Instance Settings + +- **Built-in OAuth** — replaced external AIP OAuth dependency with native `atrium-oauth` integration; HappyView manages the full OAuth flow internally +- **Instance settings** — new `instance_settings` key/value table for configurable instance metadata (app name, logo, ToS, privacy policy) with env var fallback +- **OAuth branding** — authorization screens now show configurable app name, logo, terms of service, and privacy policy links via the `/oauth/client-metadata.json` endpoint +- **Logo upload** — upload a logo image via `PUT /admin/settings/logo` (stored in DB, served at `GET /settings/logo`) +- **`settings:manage` permission** — new permission for managing instance settings, included in Manager and Full Access templates +- **Redirect URI support** — `/auth/login` accepts optional `redirect_uri` parameter for post-login navigation +- **CORS improvements** — origin-mirroring CORS with credentials support for cross-origin auth flows + ## v2.0.0 — User Permissions & Settings Restructure - **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 diff --git a/migrations/postgres/20260320000000_create_instance_settings.sql b/migrations/postgres/20260320000000_create_instance_settings.sql new file mode 100644 --- /dev/null +++ b/migrations/postgres/20260320000000_create_instance_settings.sql @@ -0,0 +1,5 @@ +CREATE TABLE instance_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/migrations/sqlite/20260320000000_create_instance_settings.sql b/migrations/sqlite/20260320000000_create_instance_settings.sql new file mode 100644 --- /dev/null +++ b/migrations/sqlite/20260320000000_create_instance_settings.sql @@ -0,0 +1,5 @@ +CREATE TABLE instance_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); diff --git a/src/admin/mod.rs b/src/admin/mod.rs --- a/src/admin/mod.rs +++ b/src/admin/mod.rs @@ -9,6 +9,7 @@ pub(crate) mod permissions; mod rate_limits; mod records; mod script_variables; +pub(crate) mod settings; mod stats; mod tap_stats; mod types; @@ -78,5 +79,14 @@ .route("/rate-limits/allowlist", post(rate_limits::add_allowlist)) .route( "/rate-limits/allowlist/{id}", delete(rate_limits::remove_allowlist), + ) + .route("/settings", get(settings::list)) + .route( + "/settings/logo", + put(settings::upload_logo).delete(settings::delete_logo), + ) + .route( + "/settings/{key}", + put(settings::upsert).delete(settings::delete), ) } diff --git a/src/admin/permissions.rs b/src/admin/permissions.rs --- a/src/admin/permissions.rs +++ b/src/admin/permissions.rs @@ -2,7 +2,7 @@ use std::collections::HashSet; use serde::{Deserialize, Serialize}; -/// All 23 permissions in the system. +/// All 27 permissions in the system. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum Permission { #[serde(rename = "lexicons:create")] @@ -66,6 +66,9 @@ #[serde(rename = "rate-limits:create")] RateLimitsCreate, #[serde(rename = "rate-limits:delete")] RateLimitsDelete, + + #[serde(rename = "settings:manage")] + SettingsManage, } impl Permission { @@ -98,10 +101,11 @@ Self::LabelersDelete => "labelers:delete", Self::RateLimitsRead => "rate-limits:read", Self::RateLimitsCreate => "rate-limits:create", Self::RateLimitsDelete => "rate-limits:delete", + Self::SettingsManage => "settings:manage", } } - /// All 26 permissions. + /// All 27 permissions. pub fn all() -> HashSet { HashSet::from([ Self::LexiconsCreate, @@ -130,6 +134,7 @@ Self::LabelersDelete, Self::RateLimitsRead, Self::RateLimitsCreate, Self::RateLimitsDelete, + Self::SettingsManage, ]) } } @@ -177,6 +182,7 @@ perms.insert(Permission::LabelersDelete); perms.insert(Permission::RateLimitsRead); perms.insert(Permission::RateLimitsCreate); perms.insert(Permission::RateLimitsDelete); + perms.insert(Permission::SettingsManage); perms } Self::FullAccess => Permission::all(), diff --git a/src/admin/settings.rs b/src/admin/settings.rs new file mode 100644 --- /dev/null +++ b/src/admin/settings.rs @@ -0,0 +1,315 @@ +use axum::Json; +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use base64::Engine; +use sqlx::AnyPool; +use std::env; + +use crate::AppState; +use crate::db::{DatabaseBackend, adapt_sql, now_rfc3339}; +use crate::error::AppError; +use crate::event_log::{EventLog, Severity, log_event}; + +use super::auth::UserAuth; +use super::permissions::Permission; +use super::types::{SettingEntry, UpsertSettingBody}; + +const ENV_FALLBACKS: &[(&str, &str)] = &[ + ("app_name", "APP_NAME"), + ("logo_uri", "LOGO_URI"), + ("tos_uri", "TOS_URI"), + ("policy_uri", "POLICY_URI"), +]; + +/// Resolve a setting value: check the DB first, then fall back to env var. +pub(crate) async fn get_setting( + pool: &AnyPool, + key: &str, + backend: DatabaseBackend, +) -> Option { + let sql = adapt_sql("SELECT value FROM instance_settings WHERE key = ?", backend); + let row: Option<(String,)> = sqlx::query_as(&sql) + .bind(key) + .fetch_optional(pool) + .await + .ok() + .flatten(); + + if let Some((value,)) = row { + return Some(value); + } + + // Fall back to env var if one is mapped for this key. + for (setting_key, env_var) in ENV_FALLBACKS { + if *setting_key == key { + return env::var(env_var).ok(); + } + } + + None +} + +/// GET /admin/settings — list all settings with their source. +pub(super) async fn list( + State(state): State, + auth: UserAuth, +) -> Result>, AppError> { + auth.require(Permission::SettingsManage).await?; + + let backend = state.db_backend; + let sql = adapt_sql( + "SELECT key, value FROM instance_settings ORDER BY key", + backend, + ); + let rows: Vec<(String, String)> = sqlx::query_as(&sql) + .fetch_all(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to list settings: {e}")))?; + + let db_keys: std::collections::HashSet = rows.iter().map(|(k, _)| k.clone()).collect(); + + let mut entries: Vec = rows + .into_iter() + .map(|(key, value)| SettingEntry { + key, + value, + source: "database".to_string(), + }) + .collect(); + + // Add env-var fallback entries for keys not already present in DB. + for (setting_key, env_var) in ENV_FALLBACKS { + if !db_keys.contains(*setting_key) + && let Ok(value) = env::var(env_var) + { + entries.push(SettingEntry { + key: setting_key.to_string(), + value, + source: "env".to_string(), + }); + } + } + + Ok(Json(entries)) +} + +/// PUT /admin/settings/{key} — create or update a setting. +pub(super) async fn upsert( + State(state): State, + auth: UserAuth, + Path(key): Path, + Json(body): Json, +) -> Result { + auth.require(Permission::SettingsManage).await?; + + let backend = state.db_backend; + let now = now_rfc3339(); + let sql = adapt_sql( + r#" + INSERT INTO instance_settings (key, value, updated_at) + VALUES (?, ?, ?) + ON CONFLICT (key) DO UPDATE SET value = ?, updated_at = ? + "#, + backend, + ); + sqlx::query(&sql) + .bind(&key) + .bind(&body.value) + .bind(&now) + .bind(&body.value) + .bind(&now) + .execute(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to upsert setting: {e}")))?; + + log_event( + &state.db, + EventLog { + event_type: "setting.updated".to_string(), + severity: Severity::Info, + actor_did: Some(auth.did.clone()), + subject: Some(key.clone()), + detail: serde_json::json!({ "value": body.value }), + }, + state.db_backend, + ) + .await; + + Ok(StatusCode::NO_CONTENT) +} + +/// DELETE /admin/settings/{key} — delete a setting. +pub(super) async fn delete( + State(state): State, + auth: UserAuth, + Path(key): Path, +) -> Result { + auth.require(Permission::SettingsManage).await?; + + let backend = state.db_backend; + let sql = adapt_sql("DELETE FROM instance_settings WHERE key = ?", backend); + let result = sqlx::query(&sql) + .bind(&key) + .execute(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to delete setting: {e}")))?; + + if result.rows_affected() == 0 { + return Err(AppError::NotFound(format!("setting '{key}' not found"))); + } + + log_event( + &state.db, + EventLog { + event_type: "setting.deleted".to_string(), + severity: Severity::Info, + actor_did: Some(auth.did.clone()), + subject: Some(key), + detail: serde_json::json!({}), + }, + state.db_backend, + ) + .await; + + Ok(StatusCode::NO_CONTENT) +} + +/// PUT /admin/settings/logo — upload a logo image (max 5MB). +pub(super) async fn upload_logo( + State(state): State, + auth: UserAuth, + mut multipart: axum::extract::Multipart, +) -> Result { + auth.require(Permission::SettingsManage).await?; + + let field = multipart + .next_field() + .await + .map_err(|e| AppError::BadRequest(format!("invalid multipart: {e}")))? + .ok_or_else(|| AppError::BadRequest("no file uploaded".into()))?; + + let content_type = field + .content_type() + .unwrap_or("application/octet-stream") + .to_string(); + + if !content_type.starts_with("image/") { + return Err(AppError::BadRequest("file must be an image".into())); + } + + let data = field + .bytes() + .await + .map_err(|e| AppError::BadRequest(format!("failed to read upload: {e}")))?; + + if data.len() > 5 * 1024 * 1024 { + return Err(AppError::BadRequest("logo must be 5MB or smaller".into())); + } + + let encoded = base64::engine::general_purpose::STANDARD.encode(&data); + + let backend = state.db_backend; + let now = now_rfc3339(); + let sql = adapt_sql( + "INSERT INTO instance_settings (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT (key) DO UPDATE SET value = ?, updated_at = ?", + backend, + ); + for (key, value) in [ + ("logo_data", encoded.as_str()), + ("logo_content_type", content_type.as_str()), + ] { + sqlx::query(&sql) + .bind(key) + .bind(value) + .bind(&now) + .bind(value) + .bind(&now) + .execute(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to store logo: {e}")))?; + } + + log_event( + &state.db, + EventLog { + event_type: "setting.updated".to_string(), + severity: Severity::Info, + actor_did: Some(auth.did.clone()), + subject: Some("logo".to_string()), + detail: serde_json::json!({ "content_type": content_type, "size_bytes": data.len() }), + }, + state.db_backend, + ) + .await; + + Ok(StatusCode::NO_CONTENT) +} + +/// DELETE /admin/settings/logo — remove uploaded logo. +pub(super) async fn delete_logo( + State(state): State, + auth: UserAuth, +) -> Result { + auth.require(Permission::SettingsManage).await?; + + let backend = state.db_backend; + let sql = adapt_sql("DELETE FROM instance_settings WHERE key IN (?, ?)", backend); + sqlx::query(&sql) + .bind("logo_data") + .bind("logo_content_type") + .execute(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to delete logo: {e}")))?; + + log_event( + &state.db, + EventLog { + event_type: "setting.deleted".to_string(), + severity: Severity::Info, + actor_did: Some(auth.did.clone()), + subject: Some("logo".to_string()), + detail: serde_json::json!({}), + }, + state.db_backend, + ) + .await; + + Ok(StatusCode::NO_CONTENT) +} + +/// GET /settings/logo — serve the uploaded logo (public, no auth). +pub(crate) async fn serve_logo( + State(state): State, +) -> Result { + let backend = state.db_backend; + let sql = adapt_sql( + "SELECT key, value FROM instance_settings WHERE key IN (?, ?)", + backend, + ); + let rows: Vec<(String, String)> = sqlx::query_as(&sql) + .bind("logo_data") + .bind("logo_content_type") + .fetch_all(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to load logo: {e}")))?; + + let data = rows.iter().find(|(k, _)| k == "logo_data").map(|(_, v)| v); + let ct = rows + .iter() + .find(|(k, _)| k == "logo_content_type") + .map(|(_, v)| v.as_str()); + + match (data, ct) { + (Some(encoded), Some(content_type)) => { + let bytes = base64::engine::general_purpose::STANDARD + .decode(encoded) + .map_err(|e| AppError::Internal(format!("failed to decode logo: {e}")))?; + Ok(axum::response::Response::builder() + .header("content-type", content_type) + .header("cache-control", "public, max-age=3600") + .body(axum::body::Body::from(bytes)) + .unwrap()) + } + _ => Err(AppError::NotFound("no logo uploaded".into())), + } +} diff --git a/src/admin/types.rs b/src/admin/types.rs --- a/src/admin/types.rs +++ b/src/admin/types.rs @@ -195,6 +195,22 @@ pub(super) status: String, } // --------------------------------------------------------------------------- +// Settings types +// --------------------------------------------------------------------------- + +#[derive(Serialize)] +pub(super) struct SettingEntry { + pub(super) key: String, + pub(super) value: String, + pub(super) source: String, +} + +#[derive(Deserialize)] +pub(super) struct UpsertSettingBody { + pub(super) value: String, +} + +// --------------------------------------------------------------------------- // User permission / transfer types // --------------------------------------------------------------------------- diff --git a/src/config.rs b/src/config.rs --- a/src/config.rs +++ b/src/config.rs @@ -17,6 +17,10 @@ pub relay_url: String, pub plc_url: String, pub static_dir: String, pub event_log_retention_days: u32, + pub app_name: Option, + pub logo_uri: Option, + pub tos_uri: Option, + pub policy_uri: Option, } impl Config { @@ -47,6 +51,10 @@ event_log_retention_days: std::env::var("EVENT_LOG_RETENTION_DAYS") .ok() .and_then(|v| v.parse().ok()) .unwrap_or(30), + app_name: env::var("APP_NAME").ok(), + logo_uri: env::var("LOGO_URI").ok(), + tos_uri: env::var("TOS_URI").ok(), + policy_uri: env::var("POLICY_URI").ok(), } } @@ -75,6 +83,10 @@ "TAP_ADMIN_PASSWORD", "RELAY_URL", "PLC_URL", "EVENT_LOG_RETENTION_DAYS", + "APP_NAME", + "LOGO_URI", + "TOS_URI", + "POLICY_URI", ] { unsafe { env::remove_var(key); @@ -104,6 +116,10 @@ relay_url: String::new(), plc_url: String::new(), static_dir: String::new(), event_log_retention_days: 30, + app_name: None, + logo_uri: None, + tos_uri: None, + policy_uri: None, }; assert_eq!( config.listen_addr(), diff --git a/src/lua/atproto_api.rs b/src/lua/atproto_api.rs --- a/src/lua/atproto_api.rs +++ b/src/lua/atproto_api.rs @@ -218,6 +218,10 @@ relay_url: String::new(), plc_url: plc_url.to_string(), static_dir: String::new(), event_log_retention_days: 30, + app_name: None, + logo_uri: None, + tos_uri: None, + policy_uri: None, }; let (tx, _) = watch::channel(vec![]); let (labeler_tx, _) = watch::channel(()); diff --git a/src/lua/db_api.rs b/src/lua/db_api.rs --- a/src/lua/db_api.rs +++ b/src/lua/db_api.rs @@ -626,6 +626,10 @@ relay_url: String::new(), plc_url: String::new(), static_dir: String::new(), event_log_retention_days: 30, + app_name: None, + logo_uri: None, + tos_uri: None, + policy_uri: None, }; let (tx, _) = watch::channel(vec![]); let (labeler_tx, _) = watch::channel(()); diff --git a/src/lua/execute.rs b/src/lua/execute.rs --- a/src/lua/execute.rs +++ b/src/lua/execute.rs @@ -960,6 +960,10 @@ relay_url: String::new(), plc_url: String::new(), static_dir: String::new(), event_log_retention_days: 30, + app_name: None, + logo_uri: None, + tos_uri: None, + policy_uri: None, }; let (tx, _) = watch::channel(vec![]); let (labeler_tx, _) = watch::channel(()); diff --git a/src/lua/http_api.rs b/src/lua/http_api.rs --- a/src/lua/http_api.rs +++ b/src/lua/http_api.rs @@ -100,6 +100,10 @@ relay_url: String::new(), plc_url: String::new(), static_dir: String::new(), event_log_retention_days: 30, + app_name: None, + logo_uri: None, + tos_uri: None, + policy_uri: None, }; let (tx, _) = watch::channel(vec![]); let (labeler_tx, _) = watch::channel(()); diff --git a/src/server.rs b/src/server.rs --- a/src/server.rs +++ b/src/server.rs @@ -64,6 +64,7 @@ let serve_dir = ServeDir::new(&static_dir).not_found_service(spa_fallback); Router::new() .route("/health", get(health)) + .route("/settings/logo", get(crate::admin::settings::serve_logo)) .nest("/admin", admin::admin_routes(state.clone())) .nest("/auth", crate::auth::routes::routes()) .route("/oauth/client-metadata.json", get(client_metadata)) @@ -96,8 +97,37 @@ Json(serde_json::json!({ "public_url": state.config.public_url })) } async fn client_metadata(State(state): State) -> Json { - let client_metadata = &state.oauth.client_metadata; - Json(serde_json::to_value(client_metadata).unwrap_or_default()) + let mut metadata = serde_json::to_value(&state.oauth.client_metadata).unwrap_or_default(); + + let pool = &state.db; + let backend = state.db_backend; + + if let Some(name) = crate::admin::settings::get_setting(pool, "app_name", backend).await { + metadata["client_name"] = serde_json::Value::String(name); + } + + // Logo: prefer uploaded logo_data (served at /settings/logo), fall back to logo_uri setting + let has_logo_data = crate::admin::settings::get_setting(pool, "logo_data", backend) + .await + .is_some(); + if has_logo_data { + metadata["logo_uri"] = serde_json::Value::String(format!( + "{}/settings/logo", + state.config.public_url.trim_end_matches('/') + )); + } else if let Some(uri) = crate::admin::settings::get_setting(pool, "logo_uri", backend).await { + metadata["logo_uri"] = serde_json::Value::String(uri); + } + + if let Some(uri) = crate::admin::settings::get_setting(pool, "tos_uri", backend).await { + metadata["tos_uri"] = serde_json::Value::String(uri); + } + + if let Some(uri) = crate::admin::settings::get_setting(pool, "policy_uri", backend).await { + metadata["policy_uri"] = serde_json::Value::String(uri); + } + + Json(metadata) } fn ip_from_forwarded_for(value: Option<&str>) -> Option { diff --git a/tests/common/app.rs b/tests/common/app.rs --- a/tests/common/app.rs +++ b/tests/common/app.rs @@ -47,6 +47,10 @@ relay_url: mock_url.clone(), plc_url: mock_url.clone(), static_dir: "./web/out".into(), event_log_retention_days: 30, + app_name: None, + logo_uri: None, + tos_uri: None, + policy_uri: None, }; let sql = adapt_sql( @@ -122,7 +126,9 @@ }, vec![], ), oauth: std::sync::Arc::new(oauth), - cookie_key: axum_extra::extract::cookie::Key::derive_from(b"test-secret"), + cookie_key: axum_extra::extract::cookie::Key::derive_from( + b"test-secret-that-is-at-least-32-bytes-long", + ), }; let router = server::router(state.clone()); diff --git a/tests/common/db.rs b/tests/common/db.rs --- a/tests/common/db.rs +++ b/tests/common/db.rs @@ -20,7 +20,7 @@ let backend = test_backend(); match backend { DatabaseBackend::Postgres => { sqlx::query( - "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", + "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", ) .execute(pool) .await @@ -40,6 +40,7 @@ "dead_letter_hooks", "record_refs", "labeler_subscriptions", "labels", + "instance_settings", ]; for table in tables { sqlx::query(&format!("DELETE FROM {table}")) diff --git a/tests/e2e_settings.rs b/tests/e2e_settings.rs new file mode 100644 --- /dev/null +++ b/tests/e2e_settings.rs @@ -0,0 +1,305 @@ +mod common; + +use axum::body::Body; +use axum::http::{Method, Request, StatusCode}; +use http_body_util::BodyExt; +use serde_json::{Value, json}; +use serial_test::serial; +use tower::ServiceExt; + +use common::app::TestApp; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +async fn json_body(resp: axum::response::Response) -> Value { + let body = resp.into_body().collect().await.unwrap().to_bytes(); + serde_json::from_slice(&body).unwrap() +} + +fn admin_get( + uri: &str, + cookie: (axum::http::HeaderName, axum::http::HeaderValue), +) -> Request { + Request::builder() + .uri(uri) + .header(cookie.0, cookie.1) + .body(Body::empty()) + .unwrap() +} + +fn admin_put( + uri: &str, + cookie: (axum::http::HeaderName, axum::http::HeaderValue), + body: &Value, +) -> Request { + Request::builder() + .method(Method::PUT) + .uri(uri) + .header(cookie.0, cookie.1) + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(body).unwrap())) + .unwrap() +} + +fn admin_delete( + uri: &str, + cookie: (axum::http::HeaderName, axum::http::HeaderValue), +) -> Request { + Request::builder() + .method(Method::DELETE) + .uri(uri) + .header(cookie.0, cookie.1) + .body(Body::empty()) + .unwrap() +} + +// --------------------------------------------------------------------------- +// Settings tests +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +#[ignore] +async fn settings_crud() { + let app = TestApp::new().await; + + // PUT a setting + let resp = app + .router + .clone() + .oneshot(admin_put( + "/admin/settings/app_name", + app.admin_cookie(), + &json!({ "value": "Test App" }), + )) + .await + .unwrap(); + + assert!( + resp.status().is_success(), + "expected success on PUT, got {}", + resp.status() + ); + + // GET all settings and verify the entry appears with source: "database" + let resp = app + .router + .clone() + .oneshot(admin_get("/admin/settings", app.admin_cookie())) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let json = json_body(resp).await; + let settings = json.as_array().unwrap(); + let app_name_entry = settings + .iter() + .find(|s| s["key"] == "app_name") + .expect("app_name entry not found in settings"); + assert_eq!(app_name_entry["source"], "database"); + + // DELETE the setting + let resp = app + .router + .clone() + .oneshot(admin_delete("/admin/settings/app_name", app.admin_cookie())) + .await + .unwrap(); + + assert!( + resp.status().is_success(), + "expected success on DELETE, got {}", + resp.status() + ); + + // GET again and verify it's removed + let resp = app + .router + .clone() + .oneshot(admin_get("/admin/settings", app.admin_cookie())) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let json = json_body(resp).await; + let settings = json.as_array().unwrap(); + let app_name_entry = settings.iter().find(|s| s["key"] == "app_name"); + assert!( + app_name_entry.is_none(), + "app_name entry should have been deleted" + ); +} + +#[tokio::test] +#[serial] +#[ignore] +async fn settings_requires_auth() { + let app = TestApp::new().await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/admin/settings") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +#[serial] +#[ignore] +async fn logo_upload_and_serve() { + let app = TestApp::new().await; + + let boundary = "----testboundary"; + // Minimal valid 1x1 PNG + let png_bytes: Vec = vec![ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature + 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR chunk + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, // 1x1 + 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xDE, // 8-bit RGB + 0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41, 0x54, // IDAT chunk + 0x08, 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0xE2, 0x21, 0xBC, + 0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, // IEND chunk + 0xAE, 0x42, 0x60, 0x82, + ]; + let body = format!( + "--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"logo.png\"\r\nContent-Type: image/png\r\n\r\n" + ); + let mut body_bytes = body.into_bytes(); + body_bytes.extend_from_slice(&png_bytes); + body_bytes.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes()); + + let cookie = app.admin_cookie(); + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method(Method::PUT) + .uri("/admin/settings/logo") + .header(cookie.0, cookie.1) + .header( + "content-type", + format!("multipart/form-data; boundary={boundary}"), + ) + .body(Body::from(body_bytes)) + .unwrap(), + ) + .await + .unwrap(); + + assert!( + resp.status().is_success(), + "expected success on logo upload, got {}", + resp.status() + ); + + // GET /settings/logo (public route) and verify 200 with content-type: image/png + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/settings/logo") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let content_type = resp + .headers() + .get("content-type") + .expect("expected content-type header") + .to_str() + .unwrap(); + assert!( + content_type.contains("image/png"), + "expected image/png content-type, got {content_type}" + ); + + // DELETE /admin/settings/logo + let resp = app + .router + .clone() + .oneshot(admin_delete("/admin/settings/logo", app.admin_cookie())) + .await + .unwrap(); + + assert!( + resp.status().is_success(), + "expected success on DELETE logo, got {}", + resp.status() + ); + + // GET /settings/logo should now return 404 + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/settings/logo") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +#[serial] +#[ignore] +async fn client_metadata_includes_settings() { + let app = TestApp::new().await; + + // PUT app_name setting + let resp = app + .router + .clone() + .oneshot(admin_put( + "/admin/settings/app_name", + app.admin_cookie(), + &json!({ "value": "Test App" }), + )) + .await + .unwrap(); + + assert!( + resp.status().is_success(), + "expected success on PUT app_name, got {}", + resp.status() + ); + + // GET /oauth/client-metadata.json (no auth) and verify client_name + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/oauth/client-metadata.json") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let json = json_body(resp).await; + assert_eq!( + json["client_name"], "Test App", + "expected client_name to be 'Test App', got {:?}", + json["client_name"] + ); +} diff --git a/tests/lua_atproto_api.rs b/tests/lua_atproto_api.rs --- a/tests/lua_atproto_api.rs +++ b/tests/lua_atproto_api.rs @@ -29,6 +29,10 @@ relay_url: String::new(), plc_url: String::new(), static_dir: String::new(), event_log_retention_days: 30, + app_name: None, + logo_uri: None, + tos_uri: None, + policy_uri: None, }; let (tx, _) = watch::channel(vec![]); let (labeler_tx, _) = watch::channel(()); diff --git a/tests/lua_db_api.rs b/tests/lua_db_api.rs --- a/tests/lua_db_api.rs +++ b/tests/lua_db_api.rs @@ -32,6 +32,10 @@ relay_url: String::new(), plc_url: String::new(), static_dir: String::new(), event_log_retention_days: 30, + app_name: None, + logo_uri: None, + tos_uri: None, + policy_uri: None, }; let (tx, _) = watch::channel(vec![]); let (labeler_tx, _) = watch::channel(());