From 53f63dada520596765b401d726ba22f11b06b8ea Mon Sep 17 00:00:00 2001 From: Trezy Date: Wed, 29 Apr 2026 14:54:11 +0000 Subject: [PATCH] feat(xrpc-proxy): add settings to control the XRPC proxy --- src/admin/mod.rs | 5 +++++ src/admin/proxy_config.rs | 85 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 2 ++ src/lua/atproto_api.rs | 3 +++ src/lua/db_api.rs | 3 +++ src/lua/execute.rs | 3 +++ src/lua/http_api.rs | 3 +++ src/lua/xrpc_api.rs | 3 +++ src/main.rs | 12 ++++++++++++ src/proxy_config.rs | 191 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/xrpc/mod.rs | 10 ++++++++++ tests/common/app.rs | 3 +++ tests/e2e_proxy_config.rs | 198 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ tests/lua_atproto_api.rs | 3 +++ tests/lua_db_api.rs | 3 +++ web/src/app/dashboard/settings/xrpc-proxy/page.tsx | 214 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ web/src/components/app-sidebar.tsx | 7 +++++++ web/src/lib/api.ts | 17 +++++++++++++++++ 18 file(s) changed, 765 insertion(s)(+), 0 deletion(s)(-) diff --git a/src/admin/mod.rs b/src/admin/mod.rs --- a/src/admin/mod.rs +++ b/src/admin/mod.rs @@ -10,6 +10,7 @@ mod lexicons; mod network_lexicons; pub(crate) mod permissions; mod plugins; +mod proxy_config; mod records; mod script_variables; pub mod settings; @@ -75,6 +76,10 @@ .route("/settings", get(settings::list)) .route( "/settings/logo", put(settings::upload_logo).delete(settings::delete_logo), + ) + .route( + "/settings/xrpc-proxy", + get(proxy_config::get).put(proxy_config::put), ) .route( "/settings/{key}", diff --git a/src/admin/proxy_config.rs b/src/admin/proxy_config.rs new file mode 100644 --- /dev/null +++ b/src/admin/proxy_config.rs @@ -0,0 +1,85 @@ +use axum::Json; +use axum::extract::State; +use axum::http::StatusCode; + +use crate::AppState; +use crate::db::{adapt_sql, now_rfc3339}; +use crate::error::AppError; +use crate::event_log::{EventLog, Severity, log_event}; +use crate::proxy_config::{ProxyConfig, ProxyMode, validate_nsid_pattern}; + +use super::auth::UserAuth; +use super::permissions::Permission; + +const SETTING_KEY: &str = "xrpc_proxy_config"; + +/// GET /admin/settings/xrpc-proxy +pub(super) async fn get( + State(state): State, + auth: UserAuth, +) -> Result, AppError> { + auth.require(Permission::SettingsManage).await?; + + let config = (**state.proxy_config.load()).clone(); + Ok(Json(config)) +} + +/// PUT /admin/settings/xrpc-proxy +pub(super) async fn put( + State(state): State, + auth: UserAuth, + Json(mut config): Json, +) -> Result { + auth.require(Permission::SettingsManage).await?; + + // Clear nsids for modes that don't use them + if matches!(config.mode, ProxyMode::Disabled | ProxyMode::Open) { + config.nsids.clear(); + } + + // Validate NSID patterns + for pattern in &config.nsids { + validate_nsid_pattern(pattern).map_err(AppError::BadRequest)?; + } + + let json = serde_json::to_string(&config) + .map_err(|e| AppError::Internal(format!("failed to serialize proxy config: {e}")))?; + + 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(SETTING_KEY) + .bind(&json) + .bind(&now) + .bind(&json) + .bind(&now) + .execute(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to save proxy config: {e}")))?; + + // Update in-memory cache + state.proxy_config.store(std::sync::Arc::new(config)); + + log_event( + &state.db, + EventLog { + event_type: "setting.updated".to_string(), + severity: Severity::Info, + actor_did: Some(auth.did.clone()), + subject: Some(SETTING_KEY.to_string()), + detail: serde_json::json!({ "value": json }), + }, + state.db_backend, + ) + .await; + + Ok(StatusCode::NO_CONTENT) +} diff --git a/src/lib.rs b/src/lib.rs --- a/src/lib.rs +++ b/src/lib.rs @@ -16,6 +16,7 @@ pub mod lua; pub mod oauth; pub mod plugin; pub mod profile; +pub mod proxy_config; pub mod rate_limit; pub mod record_handler; pub mod record_refs; @@ -71,6 +72,7 @@ pub wasm_runtime: Arc, pub attestation_signer: Option>, pub official_registry: SharedRegistry, pub official_registry_config: RegistryConfig, + pub proxy_config: Arc>, } impl axum::extract::FromRef for axum_extra::extract::cookie::Key { 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 @@ -367,6 +367,9 @@ crate::plugin::official_registry::OfficialRegistryState::default(), )), official_registry_config: crate::plugin::official_registry::RegistryConfig::production( ), + proxy_config: std::sync::Arc::new(arc_swap::ArcSwap::new(std::sync::Arc::new( + crate::proxy_config::ProxyConfig::default(), + ))), } } 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 @@ -718,6 +718,9 @@ crate::plugin::official_registry::OfficialRegistryState::default(), )), official_registry_config: crate::plugin::official_registry::RegistryConfig::production( ), + proxy_config: std::sync::Arc::new(arc_swap::ArcSwap::new(std::sync::Arc::new( + crate::proxy_config::ProxyConfig::default(), + ))), } } diff --git a/src/lua/execute.rs b/src/lua/execute.rs --- a/src/lua/execute.rs +++ b/src/lua/execute.rs @@ -1137,6 +1137,9 @@ crate::plugin::official_registry::OfficialRegistryState::default(), )), official_registry_config: crate::plugin::official_registry::RegistryConfig::production( ), + proxy_config: std::sync::Arc::new(arc_swap::ArcSwap::new(std::sync::Arc::new( + crate::proxy_config::ProxyConfig::default(), + ))), } } 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 @@ -184,6 +184,9 @@ crate::plugin::official_registry::OfficialRegistryState::default(), )), official_registry_config: crate::plugin::official_registry::RegistryConfig::production( ), + proxy_config: std::sync::Arc::new(arc_swap::ArcSwap::new(std::sync::Arc::new( + crate::proxy_config::ProxyConfig::default(), + ))), } } diff --git a/src/lua/xrpc_api.rs b/src/lua/xrpc_api.rs --- a/src/lua/xrpc_api.rs +++ b/src/lua/xrpc_api.rs @@ -288,6 +288,9 @@ crate::plugin::official_registry::OfficialRegistryState::default(), )), official_registry_config: crate::plugin::official_registry::RegistryConfig::production( ), + proxy_config: std::sync::Arc::new(arc_swap::ArcSwap::new(std::sync::Arc::new( + crate::proxy_config::ProxyConfig::default(), + ))), } } diff --git a/src/main.rs b/src/main.rs --- a/src/main.rs +++ b/src/main.rs @@ -584,6 +584,17 @@ official_registry_config.clone(), official_registry.clone(), ); + let proxy_config = { + let json_str = + happyview::admin::settings::get_setting(&db_pool, "xrpc_proxy_config", db_backend) + .await; + let config = json_str + .and_then(|s| serde_json::from_str::(&s).ok()) + .unwrap_or_default(); + info!(mode = ?config.mode, nsid_count = config.nsids.len(), "Loaded XRPC proxy config"); + std::sync::Arc::new(arc_swap::ArcSwap::new(std::sync::Arc::new(config))) + }; + let state = AppState { config: config.clone(), http, @@ -602,6 +613,7 @@ wasm_runtime, attestation_signer, official_registry, official_registry_config, + proxy_config, }; jetstream::spawn(state.clone(), collections_rx); diff --git a/src/proxy_config.rs b/src/proxy_config.rs new file mode 100644 --- /dev/null +++ b/src/proxy_config.rs @@ -0,0 +1,191 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ProxyMode { + Disabled, + Open, + Allowlist, + Blocklist, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProxyConfig { + pub mode: ProxyMode, + pub nsids: Vec, +} + +impl Default for ProxyConfig { + fn default() -> Self { + Self { + mode: ProxyMode::Open, + nsids: vec![], + } + } +} + +impl ProxyConfig { + pub fn allows(&self, nsid: &str) -> bool { + match self.mode { + ProxyMode::Disabled => false, + ProxyMode::Open => true, + ProxyMode::Allowlist => self.nsids.iter().any(|pattern| nsid_matches(pattern, nsid)), + ProxyMode::Blocklist => !self.nsids.iter().any(|pattern| nsid_matches(pattern, nsid)), + } + } +} + +fn nsid_matches(pattern: &str, nsid: &str) -> bool { + if let Some(prefix) = pattern.strip_suffix(".*") { + nsid.starts_with(prefix) + && nsid.len() > prefix.len() + && nsid.as_bytes()[prefix.len()] == b'.' + } else { + pattern == nsid + } +} + +pub fn validate_nsid_pattern(pattern: &str) -> Result<(), String> { + if pattern.is_empty() { + return Err("NSID pattern must not be empty".into()); + } + + let (base, is_wildcard) = if let Some(prefix) = pattern.strip_suffix(".*") { + (prefix, true) + } else { + (pattern, false) + }; + + let segments: Vec<&str> = base.split('.').collect(); + if segments.len() < 2 { + return Err(format!( + "NSID pattern must have at least two segments: {pattern}" + )); + } + + for segment in &segments { + if segment.is_empty() { + return Err(format!("NSID pattern has empty segment: {pattern}")); + } + if !segment + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-') + { + return Err(format!( + "NSID segment contains invalid characters: {pattern}" + )); + } + } + + if !is_wildcard && segments.len() < 3 { + return Err(format!( + "Exact NSID must have at least three segments: {pattern}" + )); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_is_open_with_empty_nsids() { + let config = ProxyConfig::default(); + assert_eq!(config.mode, ProxyMode::Open); + assert!(config.nsids.is_empty()); + } + + #[test] + fn disabled_blocks_everything() { + let config = ProxyConfig { + mode: ProxyMode::Disabled, + nsids: vec![], + }; + assert!(!config.allows("com.example.feed.getHot")); + assert!(!config.allows("anything.at.all")); + } + + #[test] + fn open_allows_everything() { + let config = ProxyConfig { + mode: ProxyMode::Open, + nsids: vec![], + }; + assert!(config.allows("com.example.feed.getHot")); + assert!(config.allows("anything.at.all")); + } + + #[test] + fn allowlist_exact_match() { + let config = ProxyConfig { + mode: ProxyMode::Allowlist, + nsids: vec!["com.example.feed.getHot".into()], + }; + assert!(config.allows("com.example.feed.getHot")); + assert!(!config.allows("com.example.feed.getCold")); + } + + #[test] + fn allowlist_wildcard() { + let config = ProxyConfig { + mode: ProxyMode::Allowlist, + nsids: vec!["com.example.*".into()], + }; + assert!(config.allows("com.example.feed.getHot")); + assert!(config.allows("com.example.anything")); + assert!(!config.allows("com.other.feed.getHot")); + } + + #[test] + fn blocklist_exact_match() { + let config = ProxyConfig { + mode: ProxyMode::Blocklist, + nsids: vec!["com.example.feed.getHot".into()], + }; + assert!(!config.allows("com.example.feed.getHot")); + assert!(config.allows("com.example.feed.getCold")); + } + + #[test] + fn blocklist_wildcard() { + let config = ProxyConfig { + mode: ProxyMode::Blocklist, + nsids: vec!["com.example.*".into()], + }; + assert!(!config.allows("com.example.feed.getHot")); + assert!(config.allows("com.other.feed.getHot")); + } + + #[test] + fn validate_valid_nsids() { + assert!(validate_nsid_pattern("com.example.feed.getHot").is_ok()); + assert!(validate_nsid_pattern("com.example.*").is_ok()); + assert!(validate_nsid_pattern("games.gamesgamesgamesgames.*").is_ok()); + assert!(validate_nsid_pattern("a.b.c").is_ok()); + } + + #[test] + fn validate_invalid_nsids() { + assert!(validate_nsid_pattern("").is_err()); + assert!(validate_nsid_pattern("*").is_err()); + assert!(validate_nsid_pattern("com").is_err()); + assert!(validate_nsid_pattern("com.example.*.foo").is_err()); + assert!(validate_nsid_pattern("com..example").is_err()); + assert!(validate_nsid_pattern(".com.example").is_err()); + assert!(validate_nsid_pattern("com.example.").is_err()); + } + + #[test] + fn roundtrip_json() { + let config = ProxyConfig { + mode: ProxyMode::Allowlist, + nsids: vec!["com.example.*".into()], + }; + let json = serde_json::to_string(&config).unwrap(); + let parsed: ProxyConfig = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.mode, ProxyMode::Allowlist); + assert_eq!(parsed.nsids, vec!["com.example.*"]); + } +} diff --git a/src/xrpc/mod.rs b/src/xrpc/mod.rs --- a/src/xrpc/mod.rs +++ b/src/xrpc/mod.rs @@ -296,6 +296,11 @@ let lexicon = match lexicon { Some(l) => l, None => { + if !state.proxy_config.load().allows(&method) { + return Err(AppError::Forbidden( + "NSID not allowed by proxy policy".into(), + )); + } let mut response = proxy_to_authority(&state, &method, &raw_query, None).await?; if let CheckResult::Allowed { remaining, @@ -383,6 +388,11 @@ let lexicon = match lexicon { Some(l) => l, None => { + if !state.proxy_config.load().allows(&method) { + return Err(AppError::Forbidden( + "NSID not allowed by proxy policy".into(), + )); + } let mut response = proxy_to_authority(&state, &method, &raw_query, Some(&body)).await?; if let CheckResult::Allowed { remaining, diff --git a/tests/common/app.rs b/tests/common/app.rs --- a/tests/common/app.rs +++ b/tests/common/app.rs @@ -152,6 +152,9 @@ official_registry: std::sync::Arc::new(tokio::sync::RwLock::new( happyview::plugin::official_registry::OfficialRegistryState::default(), )), official_registry_config: registry_config, + proxy_config: std::sync::Arc::new(arc_swap::ArcSwap::new(std::sync::Arc::new( + happyview::proxy_config::ProxyConfig::default(), + ))), }; let router = server::router(state.clone()); diff --git a/tests/e2e_proxy_config.rs b/tests/e2e_proxy_config.rs new file mode 100644 --- /dev/null +++ b/tests/e2e_proxy_config.rs @@ -0,0 +1,198 @@ +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; + +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() +} + +#[tokio::test] +#[serial] +#[ignore] +async fn get_proxy_config_returns_default() { + let app = TestApp::new().await; + + let resp = app + .router + .clone() + .oneshot(admin_get("/admin/settings/xrpc-proxy", app.admin_cookie())) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let json = json_body(resp).await; + assert_eq!(json["mode"], "open"); + assert_eq!(json["nsids"], json!([])); +} + +#[tokio::test] +#[serial] +#[ignore] +async fn put_and_get_allowlist() { + let app = TestApp::new().await; + + let resp = app + .router + .clone() + .oneshot(admin_put( + "/admin/settings/xrpc-proxy", + app.admin_cookie(), + &json!({ + "mode": "allowlist", + "nsids": ["com.example.feed.*", "com.other.thing.getStuff"] + }), + )) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + + let resp = app + .router + .clone() + .oneshot(admin_get("/admin/settings/xrpc-proxy", app.admin_cookie())) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let json = json_body(resp).await; + assert_eq!(json["mode"], "allowlist"); + assert_eq!( + json["nsids"], + json!(["com.example.feed.*", "com.other.thing.getStuff"]) + ); +} + +#[tokio::test] +#[serial] +#[ignore] +async fn disabled_mode_clears_nsids() { + let app = TestApp::new().await; + + let resp = app + .router + .clone() + .oneshot(admin_put( + "/admin/settings/xrpc-proxy", + app.admin_cookie(), + &json!({ + "mode": "disabled", + "nsids": ["com.example.*"] + }), + )) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + + let resp = app + .router + .clone() + .oneshot(admin_get("/admin/settings/xrpc-proxy", app.admin_cookie())) + .await + .unwrap(); + + let json = json_body(resp).await; + assert_eq!(json["mode"], "disabled"); + assert_eq!(json["nsids"], json!([])); +} + +#[tokio::test] +#[serial] +#[ignore] +async fn invalid_mode_rejected() { + let app = TestApp::new().await; + + let resp = app + .router + .clone() + .oneshot(admin_put( + "/admin/settings/xrpc-proxy", + app.admin_cookie(), + &json!({ + "mode": "yolo", + "nsids": [] + }), + )) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); +} + +#[tokio::test] +#[serial] +#[ignore] +async fn invalid_nsid_rejected() { + let app = TestApp::new().await; + + let resp = app + .router + .clone() + .oneshot(admin_put( + "/admin/settings/xrpc-proxy", + app.admin_cookie(), + &json!({ + "mode": "allowlist", + "nsids": ["*"] + }), + )) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +#[serial] +#[ignore] +async fn requires_auth() { + let app = TestApp::new().await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/admin/settings/xrpc-proxy") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} 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 @@ -95,6 +95,9 @@ )), official_registry_config: happyview::plugin::official_registry::RegistryConfig::production( ), domain_cache: happyview::domain::DomainCache::new(), + proxy_config: std::sync::Arc::new(arc_swap::ArcSwap::new(std::sync::Arc::new( + happyview::proxy_config::ProxyConfig::default(), + ))), } } 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 @@ -98,6 +98,9 @@ )), official_registry_config: happyview::plugin::official_registry::RegistryConfig::production( ), domain_cache: happyview::domain::DomainCache::new(), + proxy_config: std::sync::Arc::new(arc_swap::ArcSwap::new(std::sync::Arc::new( + happyview::proxy_config::ProxyConfig::default(), + ))), } } diff --git a/web/src/app/dashboard/settings/xrpc-proxy/page.tsx b/web/src/app/dashboard/settings/xrpc-proxy/page.tsx new file mode 100644 --- /dev/null +++ b/web/src/app/dashboard/settings/xrpc-proxy/page.tsx @@ -0,0 +1,214 @@ +"use client" + +import { useCallback, useEffect, useState } from "react" +import { X } from "lucide-react" + +import { useCurrentUser } from "@/hooks/use-current-user" +import { + getProxyConfig, + updateProxyConfig, + type ProxyConfig, +} from "@/lib/api" +import { SiteHeader } from "@/components/site-header" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" + +const MODES = [ + { + value: "disabled" as const, + label: "Disabled", + description: "Block all proxy requests. Only locally registered lexicons are served.", + }, + { + value: "open" as const, + label: "Open", + description: + "Proxy all unrecognized NSIDs to their resolved authority. This is the default.", + }, + { + value: "allowlist" as const, + label: "Allowlist", + description: + "Only proxy NSIDs that match a pattern below. Everything else returns 403.", + }, + { + value: "blocklist" as const, + label: "Blocklist", + description: + "Proxy all NSIDs except those that match a pattern below.", + }, +] + +export default function XrpcProxySettingsPage() { + const { hasPermission } = useCurrentUser() + const canManage = hasPermission("settings:manage") + + const [mode, setMode] = useState("open") + const [nsids, setNsids] = useState([""]) + const [error, setError] = useState(null) + const [saving, setSaving] = useState(false) + const [notice, setNotice] = useState(null) + + const load = useCallback(async () => { + try { + const config = await getProxyConfig() + setMode(config.mode) + setNsids(config.nsids.length > 0 ? [...config.nsids, ""] : [""]) + } catch (e: unknown) { + setError(e instanceof Error ? e.message : String(e)) + } + }, []) + + useEffect(() => { + load() + }, [load]) + + const showNsids = mode === "allowlist" || mode === "blocklist" + + async function handleSave() { + setError(null) + setNotice(null) + setSaving(true) + try { + const filteredNsids = nsids.map((s) => s.trim()).filter(Boolean) + await updateProxyConfig({ mode, nsids: filteredNsids }) + setNotice("Proxy settings saved.") + await load() + } catch (e: unknown) { + setError(e instanceof Error ? e.message : String(e)) + } finally { + setSaving(false) + } + } + + function handleNsidChange(index: number, value: string) { + const next = [...nsids] + next[index] = value + if (index === nsids.length - 1 && value.trim() !== "") { + next.push("") + } + setNsids(next) + } + + function handleNsidRemove(index: number) { + const next = nsids.filter((_, i) => i !== index) + if (next.length === 0 || next[next.length - 1].trim() !== "") { + next.push("") + } + setNsids(next) + } + + function handleNsidPaste( + index: number, + e: React.ClipboardEvent, + ) { + const text = e.clipboardData.getData("text") + const parts = text.split(/[,;\s\n]+/).map((s) => s.trim()).filter(Boolean) + if (parts.length <= 1) return + e.preventDefault() + const before = nsids.slice(0, index) + const after = nsids.slice(index + 1).filter((s) => s.trim() !== "") + const next = [...before, ...parts, ...after, ""] + setNsids(next) + } + + function handleNsidKeyDown( + index: number, + e: React.KeyboardEvent, + ) { + if (e.key === "Backspace" && nsids[index] === "" && nsids.length > 1) { + e.preventDefault() + handleNsidRemove(index) + } + } + + return ( + <> + +
+ {error &&

{error}

} + {notice && ( +

+ {notice} +

+ )} + +
+

Proxy Mode

+

+ Control which unrecognized XRPC methods are forwarded to their + resolved authority. Locally registered lexicons are always served + regardless of this setting. +

+
+ +
+ {MODES.map((m) => ( + + ))} +
+ + {showNsids && ( +
+ +

+ Enter NSID patterns. Use com.example.* to + match all NSIDs under a namespace. +

+
+ {nsids.map((val, index) => ( +
+ handleNsidChange(index, e.target.value)} + onKeyDown={(e) => handleNsidKeyDown(index, e)} + onPaste={(e) => handleNsidPaste(index, e)} + placeholder="com.example.feed.*" + className="font-mono text-sm" + disabled={!canManage} + /> + {nsids.length > 1 && val !== "" && ( + + )} +
+ ))} +
+
+ )} + +
+ +
+
+ + ) +} diff --git a/web/src/components/app-sidebar.tsx b/web/src/components/app-sidebar.tsx --- a/web/src/components/app-sidebar.tsx +++ b/web/src/components/app-sidebar.tsx @@ -17,6 +17,7 @@ IconSettings, IconInfoCircle, IconApps, IconArrowUpCircle, + IconArrowsShuffle, IconSkull, } from "@tabler/icons-react"; import Image from "next/image"; @@ -102,6 +103,12 @@ { title: "General", url: "/dashboard/settings/general", icon: IconSettings, + requiredPermissions: ["settings:manage"], + }, + { + title: "XRPC Proxy", + url: "/dashboard/settings/xrpc-proxy", + icon: IconArrowsShuffle, requiredPermissions: ["settings:manage"], }, { diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -338,6 +338,23 @@ export function deleteLogo() { return apiFetch("/admin/settings/logo", { method: "DELETE" }) } +// Proxy config +export type ProxyConfig = { + mode: "disabled" | "open" | "allowlist" | "blocklist" + nsids: string[] +} + +export function getProxyConfig() { + return apiFetch("/admin/settings/xrpc-proxy") +} + +export function updateProxyConfig(config: ProxyConfig) { + return apiFetch("/admin/settings/xrpc-proxy", { + method: "PUT", + body: JSON.stringify(config), + }) +} + // Labelers export function getLabelers() { return apiFetch("/admin/labelers") -- tangled.sh