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(xrpc-proxy): add settings to control the XRPC proxy

Trezy 608e9e79 e6a9e157

+765
+5
src/admin/mod.rs
··· 10 10 mod network_lexicons; 11 11 pub(crate) mod permissions; 12 12 mod plugins; 13 + mod proxy_config; 13 14 mod records; 14 15 mod script_variables; 15 16 pub mod settings; ··· 75 76 .route( 76 77 "/settings/logo", 77 78 put(settings::upload_logo).delete(settings::delete_logo), 79 + ) 80 + .route( 81 + "/settings/xrpc-proxy", 82 + get(proxy_config::get).put(proxy_config::put), 78 83 ) 79 84 .route( 80 85 "/settings/{key}",
+85
src/admin/proxy_config.rs
··· 1 + use axum::Json; 2 + use axum::extract::State; 3 + use axum::http::StatusCode; 4 + 5 + use crate::AppState; 6 + use crate::db::{adapt_sql, now_rfc3339}; 7 + use crate::error::AppError; 8 + use crate::event_log::{EventLog, Severity, log_event}; 9 + use crate::proxy_config::{ProxyConfig, ProxyMode, validate_nsid_pattern}; 10 + 11 + use super::auth::UserAuth; 12 + use super::permissions::Permission; 13 + 14 + const SETTING_KEY: &str = "xrpc_proxy_config"; 15 + 16 + /// GET /admin/settings/xrpc-proxy 17 + pub(super) async fn get( 18 + State(state): State<AppState>, 19 + auth: UserAuth, 20 + ) -> Result<Json<ProxyConfig>, AppError> { 21 + auth.require(Permission::SettingsManage).await?; 22 + 23 + let config = (**state.proxy_config.load()).clone(); 24 + Ok(Json(config)) 25 + } 26 + 27 + /// PUT /admin/settings/xrpc-proxy 28 + pub(super) async fn put( 29 + State(state): State<AppState>, 30 + auth: UserAuth, 31 + Json(mut config): Json<ProxyConfig>, 32 + ) -> Result<StatusCode, AppError> { 33 + auth.require(Permission::SettingsManage).await?; 34 + 35 + // Clear nsids for modes that don't use them 36 + if matches!(config.mode, ProxyMode::Disabled | ProxyMode::Open) { 37 + config.nsids.clear(); 38 + } 39 + 40 + // Validate NSID patterns 41 + for pattern in &config.nsids { 42 + validate_nsid_pattern(pattern).map_err(AppError::BadRequest)?; 43 + } 44 + 45 + let json = serde_json::to_string(&config) 46 + .map_err(|e| AppError::Internal(format!("failed to serialize proxy config: {e}")))?; 47 + 48 + let backend = state.db_backend; 49 + let now = now_rfc3339(); 50 + let sql = adapt_sql( 51 + r#" 52 + INSERT INTO instance_settings (key, value, updated_at) 53 + VALUES (?, ?, ?) 54 + ON CONFLICT (key) DO UPDATE SET value = ?, updated_at = ? 55 + "#, 56 + backend, 57 + ); 58 + sqlx::query(&sql) 59 + .bind(SETTING_KEY) 60 + .bind(&json) 61 + .bind(&now) 62 + .bind(&json) 63 + .bind(&now) 64 + .execute(&state.db) 65 + .await 66 + .map_err(|e| AppError::Internal(format!("failed to save proxy config: {e}")))?; 67 + 68 + // Update in-memory cache 69 + state.proxy_config.store(std::sync::Arc::new(config)); 70 + 71 + log_event( 72 + &state.db, 73 + EventLog { 74 + event_type: "setting.updated".to_string(), 75 + severity: Severity::Info, 76 + actor_did: Some(auth.did.clone()), 77 + subject: Some(SETTING_KEY.to_string()), 78 + detail: serde_json::json!({ "value": json }), 79 + }, 80 + state.db_backend, 81 + ) 82 + .await; 83 + 84 + Ok(StatusCode::NO_CONTENT) 85 + }
+2
src/lib.rs
··· 16 16 pub mod oauth; 17 17 pub mod plugin; 18 18 pub mod profile; 19 + pub mod proxy_config; 19 20 pub mod rate_limit; 20 21 pub mod record_handler; 21 22 pub mod record_refs; ··· 71 72 pub attestation_signer: Option<Arc<plugin::attestation::AttestationSigner>>, 72 73 pub official_registry: SharedRegistry, 73 74 pub official_registry_config: RegistryConfig, 75 + pub proxy_config: Arc<arc_swap::ArcSwap<proxy_config::ProxyConfig>>, 74 76 } 75 77 76 78 impl axum::extract::FromRef<AppState> for axum_extra::extract::cookie::Key {
+3
src/lua/atproto_api.rs
··· 367 367 )), 368 368 official_registry_config: crate::plugin::official_registry::RegistryConfig::production( 369 369 ), 370 + proxy_config: std::sync::Arc::new(arc_swap::ArcSwap::new(std::sync::Arc::new( 371 + crate::proxy_config::ProxyConfig::default(), 372 + ))), 370 373 } 371 374 } 372 375
+3
src/lua/db_api.rs
··· 718 718 )), 719 719 official_registry_config: crate::plugin::official_registry::RegistryConfig::production( 720 720 ), 721 + proxy_config: std::sync::Arc::new(arc_swap::ArcSwap::new(std::sync::Arc::new( 722 + crate::proxy_config::ProxyConfig::default(), 723 + ))), 721 724 } 722 725 } 723 726
+3
src/lua/execute.rs
··· 1137 1137 )), 1138 1138 official_registry_config: crate::plugin::official_registry::RegistryConfig::production( 1139 1139 ), 1140 + proxy_config: std::sync::Arc::new(arc_swap::ArcSwap::new(std::sync::Arc::new( 1141 + crate::proxy_config::ProxyConfig::default(), 1142 + ))), 1140 1143 } 1141 1144 } 1142 1145
+3
src/lua/http_api.rs
··· 184 184 )), 185 185 official_registry_config: crate::plugin::official_registry::RegistryConfig::production( 186 186 ), 187 + proxy_config: std::sync::Arc::new(arc_swap::ArcSwap::new(std::sync::Arc::new( 188 + crate::proxy_config::ProxyConfig::default(), 189 + ))), 187 190 } 188 191 } 189 192
+3
src/lua/xrpc_api.rs
··· 288 288 )), 289 289 official_registry_config: crate::plugin::official_registry::RegistryConfig::production( 290 290 ), 291 + proxy_config: std::sync::Arc::new(arc_swap::ArcSwap::new(std::sync::Arc::new( 292 + crate::proxy_config::ProxyConfig::default(), 293 + ))), 291 294 } 292 295 } 293 296
+12
src/main.rs
··· 584 584 official_registry.clone(), 585 585 ); 586 586 587 + let proxy_config = { 588 + let json_str = 589 + happyview::admin::settings::get_setting(&db_pool, "xrpc_proxy_config", db_backend) 590 + .await; 591 + let config = json_str 592 + .and_then(|s| serde_json::from_str::<happyview::proxy_config::ProxyConfig>(&s).ok()) 593 + .unwrap_or_default(); 594 + info!(mode = ?config.mode, nsid_count = config.nsids.len(), "Loaded XRPC proxy config"); 595 + std::sync::Arc::new(arc_swap::ArcSwap::new(std::sync::Arc::new(config))) 596 + }; 597 + 587 598 let state = AppState { 588 599 config: config.clone(), 589 600 http, ··· 602 613 attestation_signer, 603 614 official_registry, 604 615 official_registry_config, 616 + proxy_config, 605 617 }; 606 618 607 619 jetstream::spawn(state.clone(), collections_rx);
+191
src/proxy_config.rs
··· 1 + use serde::{Deserialize, Serialize}; 2 + 3 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] 4 + #[serde(rename_all = "lowercase")] 5 + pub enum ProxyMode { 6 + Disabled, 7 + Open, 8 + Allowlist, 9 + Blocklist, 10 + } 11 + 12 + #[derive(Debug, Clone, Serialize, Deserialize)] 13 + pub struct ProxyConfig { 14 + pub mode: ProxyMode, 15 + pub nsids: Vec<String>, 16 + } 17 + 18 + impl Default for ProxyConfig { 19 + fn default() -> Self { 20 + Self { 21 + mode: ProxyMode::Open, 22 + nsids: vec![], 23 + } 24 + } 25 + } 26 + 27 + impl ProxyConfig { 28 + pub fn allows(&self, nsid: &str) -> bool { 29 + match self.mode { 30 + ProxyMode::Disabled => false, 31 + ProxyMode::Open => true, 32 + ProxyMode::Allowlist => self.nsids.iter().any(|pattern| nsid_matches(pattern, nsid)), 33 + ProxyMode::Blocklist => !self.nsids.iter().any(|pattern| nsid_matches(pattern, nsid)), 34 + } 35 + } 36 + } 37 + 38 + fn nsid_matches(pattern: &str, nsid: &str) -> bool { 39 + if let Some(prefix) = pattern.strip_suffix(".*") { 40 + nsid.starts_with(prefix) 41 + && nsid.len() > prefix.len() 42 + && nsid.as_bytes()[prefix.len()] == b'.' 43 + } else { 44 + pattern == nsid 45 + } 46 + } 47 + 48 + pub fn validate_nsid_pattern(pattern: &str) -> Result<(), String> { 49 + if pattern.is_empty() { 50 + return Err("NSID pattern must not be empty".into()); 51 + } 52 + 53 + let (base, is_wildcard) = if let Some(prefix) = pattern.strip_suffix(".*") { 54 + (prefix, true) 55 + } else { 56 + (pattern, false) 57 + }; 58 + 59 + let segments: Vec<&str> = base.split('.').collect(); 60 + if segments.len() < 2 { 61 + return Err(format!( 62 + "NSID pattern must have at least two segments: {pattern}" 63 + )); 64 + } 65 + 66 + for segment in &segments { 67 + if segment.is_empty() { 68 + return Err(format!("NSID pattern has empty segment: {pattern}")); 69 + } 70 + if !segment 71 + .chars() 72 + .all(|c| c.is_ascii_alphanumeric() || c == '-') 73 + { 74 + return Err(format!( 75 + "NSID segment contains invalid characters: {pattern}" 76 + )); 77 + } 78 + } 79 + 80 + if !is_wildcard && segments.len() < 3 { 81 + return Err(format!( 82 + "Exact NSID must have at least three segments: {pattern}" 83 + )); 84 + } 85 + 86 + Ok(()) 87 + } 88 + 89 + #[cfg(test)] 90 + mod tests { 91 + use super::*; 92 + 93 + #[test] 94 + fn default_is_open_with_empty_nsids() { 95 + let config = ProxyConfig::default(); 96 + assert_eq!(config.mode, ProxyMode::Open); 97 + assert!(config.nsids.is_empty()); 98 + } 99 + 100 + #[test] 101 + fn disabled_blocks_everything() { 102 + let config = ProxyConfig { 103 + mode: ProxyMode::Disabled, 104 + nsids: vec![], 105 + }; 106 + assert!(!config.allows("com.example.feed.getHot")); 107 + assert!(!config.allows("anything.at.all")); 108 + } 109 + 110 + #[test] 111 + fn open_allows_everything() { 112 + let config = ProxyConfig { 113 + mode: ProxyMode::Open, 114 + nsids: vec![], 115 + }; 116 + assert!(config.allows("com.example.feed.getHot")); 117 + assert!(config.allows("anything.at.all")); 118 + } 119 + 120 + #[test] 121 + fn allowlist_exact_match() { 122 + let config = ProxyConfig { 123 + mode: ProxyMode::Allowlist, 124 + nsids: vec!["com.example.feed.getHot".into()], 125 + }; 126 + assert!(config.allows("com.example.feed.getHot")); 127 + assert!(!config.allows("com.example.feed.getCold")); 128 + } 129 + 130 + #[test] 131 + fn allowlist_wildcard() { 132 + let config = ProxyConfig { 133 + mode: ProxyMode::Allowlist, 134 + nsids: vec!["com.example.*".into()], 135 + }; 136 + assert!(config.allows("com.example.feed.getHot")); 137 + assert!(config.allows("com.example.anything")); 138 + assert!(!config.allows("com.other.feed.getHot")); 139 + } 140 + 141 + #[test] 142 + fn blocklist_exact_match() { 143 + let config = ProxyConfig { 144 + mode: ProxyMode::Blocklist, 145 + nsids: vec!["com.example.feed.getHot".into()], 146 + }; 147 + assert!(!config.allows("com.example.feed.getHot")); 148 + assert!(config.allows("com.example.feed.getCold")); 149 + } 150 + 151 + #[test] 152 + fn blocklist_wildcard() { 153 + let config = ProxyConfig { 154 + mode: ProxyMode::Blocklist, 155 + nsids: vec!["com.example.*".into()], 156 + }; 157 + assert!(!config.allows("com.example.feed.getHot")); 158 + assert!(config.allows("com.other.feed.getHot")); 159 + } 160 + 161 + #[test] 162 + fn validate_valid_nsids() { 163 + assert!(validate_nsid_pattern("com.example.feed.getHot").is_ok()); 164 + assert!(validate_nsid_pattern("com.example.*").is_ok()); 165 + assert!(validate_nsid_pattern("games.gamesgamesgamesgames.*").is_ok()); 166 + assert!(validate_nsid_pattern("a.b.c").is_ok()); 167 + } 168 + 169 + #[test] 170 + fn validate_invalid_nsids() { 171 + assert!(validate_nsid_pattern("").is_err()); 172 + assert!(validate_nsid_pattern("*").is_err()); 173 + assert!(validate_nsid_pattern("com").is_err()); 174 + assert!(validate_nsid_pattern("com.example.*.foo").is_err()); 175 + assert!(validate_nsid_pattern("com..example").is_err()); 176 + assert!(validate_nsid_pattern(".com.example").is_err()); 177 + assert!(validate_nsid_pattern("com.example.").is_err()); 178 + } 179 + 180 + #[test] 181 + fn roundtrip_json() { 182 + let config = ProxyConfig { 183 + mode: ProxyMode::Allowlist, 184 + nsids: vec!["com.example.*".into()], 185 + }; 186 + let json = serde_json::to_string(&config).unwrap(); 187 + let parsed: ProxyConfig = serde_json::from_str(&json).unwrap(); 188 + assert_eq!(parsed.mode, ProxyMode::Allowlist); 189 + assert_eq!(parsed.nsids, vec!["com.example.*"]); 190 + } 191 + }
+10
src/xrpc/mod.rs
··· 296 296 let lexicon = match lexicon { 297 297 Some(l) => l, 298 298 None => { 299 + if !state.proxy_config.load().allows(&method) { 300 + return Err(AppError::Forbidden( 301 + "NSID not allowed by proxy policy".into(), 302 + )); 303 + } 299 304 let mut response = proxy_to_authority(&state, &method, &raw_query, None).await?; 300 305 if let CheckResult::Allowed { 301 306 remaining, ··· 383 388 let lexicon = match lexicon { 384 389 Some(l) => l, 385 390 None => { 391 + if !state.proxy_config.load().allows(&method) { 392 + return Err(AppError::Forbidden( 393 + "NSID not allowed by proxy policy".into(), 394 + )); 395 + } 386 396 let mut response = proxy_to_authority(&state, &method, &raw_query, Some(&body)).await?; 387 397 if let CheckResult::Allowed { 388 398 remaining,
+3
tests/common/app.rs
··· 152 152 happyview::plugin::official_registry::OfficialRegistryState::default(), 153 153 )), 154 154 official_registry_config: registry_config, 155 + proxy_config: std::sync::Arc::new(arc_swap::ArcSwap::new(std::sync::Arc::new( 156 + happyview::proxy_config::ProxyConfig::default(), 157 + ))), 155 158 }; 156 159 157 160 let router = server::router(state.clone());
+198
tests/e2e_proxy_config.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 + async fn json_body(resp: axum::response::Response) -> Value { 13 + let body = resp.into_body().collect().await.unwrap().to_bytes(); 14 + serde_json::from_slice(&body).unwrap() 15 + } 16 + 17 + fn admin_get( 18 + uri: &str, 19 + cookie: (axum::http::HeaderName, axum::http::HeaderValue), 20 + ) -> Request<Body> { 21 + Request::builder() 22 + .uri(uri) 23 + .header(cookie.0, cookie.1) 24 + .body(Body::empty()) 25 + .unwrap() 26 + } 27 + 28 + fn admin_put( 29 + uri: &str, 30 + cookie: (axum::http::HeaderName, axum::http::HeaderValue), 31 + body: &Value, 32 + ) -> Request<Body> { 33 + Request::builder() 34 + .method(Method::PUT) 35 + .uri(uri) 36 + .header(cookie.0, cookie.1) 37 + .header("content-type", "application/json") 38 + .body(Body::from(serde_json::to_vec(body).unwrap())) 39 + .unwrap() 40 + } 41 + 42 + #[tokio::test] 43 + #[serial] 44 + #[ignore] 45 + async fn get_proxy_config_returns_default() { 46 + let app = TestApp::new().await; 47 + 48 + let resp = app 49 + .router 50 + .clone() 51 + .oneshot(admin_get("/admin/settings/xrpc-proxy", app.admin_cookie())) 52 + .await 53 + .unwrap(); 54 + 55 + assert_eq!(resp.status(), StatusCode::OK); 56 + let json = json_body(resp).await; 57 + assert_eq!(json["mode"], "open"); 58 + assert_eq!(json["nsids"], json!([])); 59 + } 60 + 61 + #[tokio::test] 62 + #[serial] 63 + #[ignore] 64 + async fn put_and_get_allowlist() { 65 + let app = TestApp::new().await; 66 + 67 + let resp = app 68 + .router 69 + .clone() 70 + .oneshot(admin_put( 71 + "/admin/settings/xrpc-proxy", 72 + app.admin_cookie(), 73 + &json!({ 74 + "mode": "allowlist", 75 + "nsids": ["com.example.feed.*", "com.other.thing.getStuff"] 76 + }), 77 + )) 78 + .await 79 + .unwrap(); 80 + 81 + assert_eq!(resp.status(), StatusCode::NO_CONTENT); 82 + 83 + let resp = app 84 + .router 85 + .clone() 86 + .oneshot(admin_get("/admin/settings/xrpc-proxy", app.admin_cookie())) 87 + .await 88 + .unwrap(); 89 + 90 + assert_eq!(resp.status(), StatusCode::OK); 91 + let json = json_body(resp).await; 92 + assert_eq!(json["mode"], "allowlist"); 93 + assert_eq!( 94 + json["nsids"], 95 + json!(["com.example.feed.*", "com.other.thing.getStuff"]) 96 + ); 97 + } 98 + 99 + #[tokio::test] 100 + #[serial] 101 + #[ignore] 102 + async fn disabled_mode_clears_nsids() { 103 + let app = TestApp::new().await; 104 + 105 + let resp = app 106 + .router 107 + .clone() 108 + .oneshot(admin_put( 109 + "/admin/settings/xrpc-proxy", 110 + app.admin_cookie(), 111 + &json!({ 112 + "mode": "disabled", 113 + "nsids": ["com.example.*"] 114 + }), 115 + )) 116 + .await 117 + .unwrap(); 118 + 119 + assert_eq!(resp.status(), StatusCode::NO_CONTENT); 120 + 121 + let resp = app 122 + .router 123 + .clone() 124 + .oneshot(admin_get("/admin/settings/xrpc-proxy", app.admin_cookie())) 125 + .await 126 + .unwrap(); 127 + 128 + let json = json_body(resp).await; 129 + assert_eq!(json["mode"], "disabled"); 130 + assert_eq!(json["nsids"], json!([])); 131 + } 132 + 133 + #[tokio::test] 134 + #[serial] 135 + #[ignore] 136 + async fn invalid_mode_rejected() { 137 + let app = TestApp::new().await; 138 + 139 + let resp = app 140 + .router 141 + .clone() 142 + .oneshot(admin_put( 143 + "/admin/settings/xrpc-proxy", 144 + app.admin_cookie(), 145 + &json!({ 146 + "mode": "yolo", 147 + "nsids": [] 148 + }), 149 + )) 150 + .await 151 + .unwrap(); 152 + 153 + assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); 154 + } 155 + 156 + #[tokio::test] 157 + #[serial] 158 + #[ignore] 159 + async fn invalid_nsid_rejected() { 160 + let app = TestApp::new().await; 161 + 162 + let resp = app 163 + .router 164 + .clone() 165 + .oneshot(admin_put( 166 + "/admin/settings/xrpc-proxy", 167 + app.admin_cookie(), 168 + &json!({ 169 + "mode": "allowlist", 170 + "nsids": ["*"] 171 + }), 172 + )) 173 + .await 174 + .unwrap(); 175 + 176 + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 177 + } 178 + 179 + #[tokio::test] 180 + #[serial] 181 + #[ignore] 182 + async fn requires_auth() { 183 + let app = TestApp::new().await; 184 + 185 + let resp = app 186 + .router 187 + .clone() 188 + .oneshot( 189 + Request::builder() 190 + .uri("/admin/settings/xrpc-proxy") 191 + .body(Body::empty()) 192 + .unwrap(), 193 + ) 194 + .await 195 + .unwrap(); 196 + 197 + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); 198 + }
+3
tests/lua_atproto_api.rs
··· 95 95 official_registry_config: happyview::plugin::official_registry::RegistryConfig::production( 96 96 ), 97 97 domain_cache: happyview::domain::DomainCache::new(), 98 + proxy_config: std::sync::Arc::new(arc_swap::ArcSwap::new(std::sync::Arc::new( 99 + happyview::proxy_config::ProxyConfig::default(), 100 + ))), 98 101 } 99 102 } 100 103
+3
tests/lua_db_api.rs
··· 98 98 official_registry_config: happyview::plugin::official_registry::RegistryConfig::production( 99 99 ), 100 100 domain_cache: happyview::domain::DomainCache::new(), 101 + proxy_config: std::sync::Arc::new(arc_swap::ArcSwap::new(std::sync::Arc::new( 102 + happyview::proxy_config::ProxyConfig::default(), 103 + ))), 101 104 } 102 105 } 103 106
+214
web/src/app/dashboard/settings/xrpc-proxy/page.tsx
··· 1 + "use client" 2 + 3 + import { useCallback, useEffect, useState } from "react" 4 + import { X } from "lucide-react" 5 + 6 + import { useCurrentUser } from "@/hooks/use-current-user" 7 + import { 8 + getProxyConfig, 9 + updateProxyConfig, 10 + type ProxyConfig, 11 + } from "@/lib/api" 12 + import { SiteHeader } from "@/components/site-header" 13 + import { Button } from "@/components/ui/button" 14 + import { Input } from "@/components/ui/input" 15 + import { Label } from "@/components/ui/label" 16 + 17 + const MODES = [ 18 + { 19 + value: "disabled" as const, 20 + label: "Disabled", 21 + description: "Block all proxy requests. Only locally registered lexicons are served.", 22 + }, 23 + { 24 + value: "open" as const, 25 + label: "Open", 26 + description: 27 + "Proxy all unrecognized NSIDs to their resolved authority. This is the default.", 28 + }, 29 + { 30 + value: "allowlist" as const, 31 + label: "Allowlist", 32 + description: 33 + "Only proxy NSIDs that match a pattern below. Everything else returns 403.", 34 + }, 35 + { 36 + value: "blocklist" as const, 37 + label: "Blocklist", 38 + description: 39 + "Proxy all NSIDs except those that match a pattern below.", 40 + }, 41 + ] 42 + 43 + export default function XrpcProxySettingsPage() { 44 + const { hasPermission } = useCurrentUser() 45 + const canManage = hasPermission("settings:manage") 46 + 47 + const [mode, setMode] = useState<ProxyConfig["mode"]>("open") 48 + const [nsids, setNsids] = useState<string[]>([""]) 49 + const [error, setError] = useState<string | null>(null) 50 + const [saving, setSaving] = useState(false) 51 + const [notice, setNotice] = useState<string | null>(null) 52 + 53 + const load = useCallback(async () => { 54 + try { 55 + const config = await getProxyConfig() 56 + setMode(config.mode) 57 + setNsids(config.nsids.length > 0 ? [...config.nsids, ""] : [""]) 58 + } catch (e: unknown) { 59 + setError(e instanceof Error ? e.message : String(e)) 60 + } 61 + }, []) 62 + 63 + useEffect(() => { 64 + load() 65 + }, [load]) 66 + 67 + const showNsids = mode === "allowlist" || mode === "blocklist" 68 + 69 + async function handleSave() { 70 + setError(null) 71 + setNotice(null) 72 + setSaving(true) 73 + try { 74 + const filteredNsids = nsids.map((s) => s.trim()).filter(Boolean) 75 + await updateProxyConfig({ mode, nsids: filteredNsids }) 76 + setNotice("Proxy settings saved.") 77 + await load() 78 + } catch (e: unknown) { 79 + setError(e instanceof Error ? e.message : String(e)) 80 + } finally { 81 + setSaving(false) 82 + } 83 + } 84 + 85 + function handleNsidChange(index: number, value: string) { 86 + const next = [...nsids] 87 + next[index] = value 88 + if (index === nsids.length - 1 && value.trim() !== "") { 89 + next.push("") 90 + } 91 + setNsids(next) 92 + } 93 + 94 + function handleNsidRemove(index: number) { 95 + const next = nsids.filter((_, i) => i !== index) 96 + if (next.length === 0 || next[next.length - 1].trim() !== "") { 97 + next.push("") 98 + } 99 + setNsids(next) 100 + } 101 + 102 + function handleNsidPaste( 103 + index: number, 104 + e: React.ClipboardEvent<HTMLInputElement>, 105 + ) { 106 + const text = e.clipboardData.getData("text") 107 + const parts = text.split(/[,;\s\n]+/).map((s) => s.trim()).filter(Boolean) 108 + if (parts.length <= 1) return 109 + e.preventDefault() 110 + const before = nsids.slice(0, index) 111 + const after = nsids.slice(index + 1).filter((s) => s.trim() !== "") 112 + const next = [...before, ...parts, ...after, ""] 113 + setNsids(next) 114 + } 115 + 116 + function handleNsidKeyDown( 117 + index: number, 118 + e: React.KeyboardEvent<HTMLInputElement>, 119 + ) { 120 + if (e.key === "Backspace" && nsids[index] === "" && nsids.length > 1) { 121 + e.preventDefault() 122 + handleNsidRemove(index) 123 + } 124 + } 125 + 126 + return ( 127 + <> 128 + <SiteHeader title="XRPC Proxy" /> 129 + <div className="flex flex-1 flex-col gap-6 p-4 md:p-6 max-w-3xl"> 130 + {error && <p className="text-destructive text-sm">{error}</p>} 131 + {notice && ( 132 + <p className="text-sm text-green-600 dark:text-green-400"> 133 + {notice} 134 + </p> 135 + )} 136 + 137 + <div> 138 + <h2 className="text-lg font-semibold">Proxy Mode</h2> 139 + <p className="text-muted-foreground text-sm"> 140 + Control which unrecognized XRPC methods are forwarded to their 141 + resolved authority. Locally registered lexicons are always served 142 + regardless of this setting. 143 + </p> 144 + </div> 145 + 146 + <fieldset className="flex flex-col gap-3" disabled={!canManage}> 147 + {MODES.map((m) => ( 148 + <label 149 + key={m.value} 150 + className="flex items-start gap-3 rounded-lg border p-3 cursor-pointer has-[:checked]:border-primary has-[:checked]:bg-primary/5" 151 + > 152 + <input 153 + type="radio" 154 + name="proxy-mode" 155 + value={m.value} 156 + checked={mode === m.value} 157 + onChange={() => setMode(m.value)} 158 + className="mt-1" 159 + /> 160 + <div> 161 + <span className="font-medium text-sm">{m.label}</span> 162 + <p className="text-muted-foreground text-xs">{m.description}</p> 163 + </div> 164 + </label> 165 + ))} 166 + </fieldset> 167 + 168 + {showNsids && ( 169 + <div className="flex flex-col gap-2"> 170 + <Label> 171 + NSID Patterns 172 + </Label> 173 + <p className="text-muted-foreground text-xs"> 174 + Enter NSID patterns. Use <code className="text-[11px] bg-muted px-1 py-0.5 rounded">com.example.*</code> to 175 + match all NSIDs under a namespace. 176 + </p> 177 + <div className="flex flex-col gap-1.5"> 178 + {nsids.map((val, index) => ( 179 + <div key={index} className="flex gap-1.5"> 180 + <Input 181 + value={val} 182 + onChange={(e) => handleNsidChange(index, e.target.value)} 183 + onKeyDown={(e) => handleNsidKeyDown(index, e)} 184 + onPaste={(e) => handleNsidPaste(index, e)} 185 + placeholder="com.example.feed.*" 186 + className="font-mono text-sm" 187 + disabled={!canManage} 188 + /> 189 + {nsids.length > 1 && val !== "" && ( 190 + <Button 191 + type="button" 192 + variant="ghost" 193 + size="icon" 194 + onClick={() => handleNsidRemove(index)} 195 + disabled={!canManage} 196 + > 197 + <X className="size-4" /> 198 + </Button> 199 + )} 200 + </div> 201 + ))} 202 + </div> 203 + </div> 204 + )} 205 + 206 + <div className="flex justify-end pt-2"> 207 + <Button onClick={handleSave} disabled={!canManage || saving}> 208 + {saving ? "Saving..." : "Save changes"} 209 + </Button> 210 + </div> 211 + </div> 212 + </> 213 + ) 214 + }
+7
web/src/components/app-sidebar.tsx
··· 17 17 IconInfoCircle, 18 18 IconApps, 19 19 IconArrowUpCircle, 20 + IconArrowsShuffle, 20 21 IconSkull, 21 22 } from "@tabler/icons-react"; 22 23 import Image from "next/image"; ··· 102 103 title: "General", 103 104 url: "/dashboard/settings/general", 104 105 icon: IconSettings, 106 + requiredPermissions: ["settings:manage"], 107 + }, 108 + { 109 + title: "XRPC Proxy", 110 + url: "/dashboard/settings/xrpc-proxy", 111 + icon: IconArrowsShuffle, 105 112 requiredPermissions: ["settings:manage"], 106 113 }, 107 114 {
+17
web/src/lib/api.ts
··· 338 338 return apiFetch("/admin/settings/logo", { method: "DELETE" }) 339 339 } 340 340 341 + // Proxy config 342 + export type ProxyConfig = { 343 + mode: "disabled" | "open" | "allowlist" | "blocklist" 344 + nsids: string[] 345 + } 346 + 347 + export function getProxyConfig() { 348 + return apiFetch<ProxyConfig>("/admin/settings/xrpc-proxy") 349 + } 350 + 351 + export function updateProxyConfig(config: ProxyConfig) { 352 + return apiFetch("/admin/settings/xrpc-proxy", { 353 + method: "PUT", 354 + body: JSON.stringify(config), 355 + }) 356 + } 357 + 341 358 // Labelers 342 359 export function getLabelers() { 343 360 return apiFetch<LabelerSummary[]>("/admin/labelers")