use std::sync::Arc; use anyhow::Result; use axum::{ Json, extract::{Path, State}, http::StatusCode, response::Response, }; use serde::Deserialize; use crate::api::{Api, AuthedPubkey}; use crate::models::{ RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay, }; use crate::web::{ApiError, err, map_unique_error, ok, parse_bool_default}; #[derive(Deserialize)] pub struct CreateRelayRequest { pub tenant: String, pub subdomain: String, pub plan: String, pub info_name: Option, pub info_icon: Option, pub info_description: Option, pub policy_public_join: Option, pub policy_strip_signatures: Option, pub groups_enabled: Option, pub management_enabled: Option, pub blossom_enabled: Option, pub livekit_enabled: Option, pub push_enabled: Option, } #[derive(Deserialize)] pub struct UpdateRelayRequest { pub subdomain: Option, pub plan: Option, pub info_name: Option, pub info_icon: Option, pub info_description: Option, pub policy_public_join: Option, pub policy_strip_signatures: Option, pub groups_enabled: Option, pub management_enabled: Option, pub blossom_enabled: Option, pub livekit_enabled: Option, pub push_enabled: Option, } pub async fn list_relays( State(api): State>, AuthedPubkey(auth): AuthedPubkey, ) -> Result { api.require_admin(&auth)?; match api.query.list_relays().await { Ok(relays) => Ok(ok(StatusCode::OK, relays)), Err(e) => Err(ApiError::Internal(e.to_string())), } } pub async fn get_relay( State(api): State>, AuthedPubkey(auth): AuthedPubkey, Path(id): Path, ) -> Result { let relay = match api.query.get_relay(&id).await { Ok(Some(r)) => r, Ok(None) => return Ok(err(StatusCode::NOT_FOUND, "not-found", "relay not found")), Err(e) => { return Ok(err( StatusCode::INTERNAL_SERVER_ERROR, "internal", &e.to_string(), )); } }; api.require_admin_or_tenant(&auth, &relay.tenant)?; Ok(ok(StatusCode::OK, relay)) } pub async fn list_relay_activity( State(api): State>, AuthedPubkey(auth): AuthedPubkey, Path(id): Path, ) -> Result { let relay = match api.query.get_relay(&id).await { Ok(Some(r)) => r, Ok(None) => return Ok(err(StatusCode::NOT_FOUND, "not-found", "relay not found")), Err(e) => { return Ok(err( StatusCode::INTERNAL_SERVER_ERROR, "internal", &e.to_string(), )); } }; api.require_admin_or_tenant(&auth, &relay.tenant)?; match api.query.list_activity_for_relay(&id).await { Ok(activity) => Ok(ok( StatusCode::OK, serde_json::json!({ "activity": activity }), )), Err(e) => Ok(err( StatusCode::INTERNAL_SERVER_ERROR, "internal", &e.to_string(), )), } } pub async fn list_relay_members( State(api): State>, AuthedPubkey(auth): AuthedPubkey, Path(id): Path, ) -> Result { let relay = match api.query.get_relay(&id).await { Ok(Some(r)) => r, Ok(None) => return Ok(err(StatusCode::NOT_FOUND, "not-found", "relay not found")), Err(e) => { return Ok(err( StatusCode::INTERNAL_SERVER_ERROR, "internal", &e.to_string(), )); } }; api.require_admin_or_tenant(&auth, &relay.tenant)?; match fetch_relay_members(&api, &relay).await { Ok(members) => Ok(ok( StatusCode::OK, serde_json::json!({ "members": members }), )), Err(e) => Ok(err( StatusCode::INTERNAL_SERVER_ERROR, "internal", &e.to_string(), )), } } pub async fn create_relay( State(api): State>, AuthedPubkey(auth): AuthedPubkey, Json(payload): Json, ) -> Result { api.require_admin_or_tenant(&auth, &payload.tenant)?; let relay_id = format!( "{}_{}", payload.subdomain.replace('-', "_"), &uuid::Uuid::new_v4().simple().to_string()[..8] ); let mut relay = Relay { id: relay_id.clone(), tenant: payload.tenant, schema: relay_id.clone(), subdomain: payload.subdomain, plan: payload.plan, stripe_subscription_item_id: None, status: RELAY_STATUS_ACTIVE.to_string(), sync_error: String::new(), info_name: payload.info_name.unwrap_or_default(), info_icon: payload.info_icon.unwrap_or_default(), info_description: payload.info_description.unwrap_or_default(), policy_public_join: payload.policy_public_join.unwrap_or(0), policy_strip_signatures: payload.policy_strip_signatures.unwrap_or(0), groups_enabled: payload.groups_enabled.unwrap_or(1), management_enabled: payload.management_enabled.unwrap_or(1), blossom_enabled: payload.blossom_enabled.unwrap_or(0), livekit_enabled: payload.livekit_enabled.unwrap_or(0), push_enabled: payload.push_enabled.unwrap_or(1), synced: 0, }; relay = match prepare_relay(&api, relay) { Ok(r) => r, Err(e) => { return Ok(relay_validation_error_response(e)); } }; match api.command.create_relay(&relay).await { Ok(()) => Ok(ok(StatusCode::CREATED, relay)), Err(e) => { if matches!(map_unique_error(&e), Some("subdomain-exists")) { Ok(err( StatusCode::UNPROCESSABLE_ENTITY, "subdomain-exists", "subdomain already exists", )) } else { Ok(err( StatusCode::INTERNAL_SERVER_ERROR, "internal", &e.to_string(), )) } } } } pub async fn update_relay( State(api): State>, AuthedPubkey(auth): AuthedPubkey, Path(id): Path, Json(payload): Json, ) -> Result { let mut relay = match api.query.get_relay(&id).await { Ok(Some(r)) => r, Ok(None) => return Ok(err(StatusCode::NOT_FOUND, "not-found", "relay not found")), Err(e) => { return Ok(err( StatusCode::INTERNAL_SERVER_ERROR, "internal", &e.to_string(), )); } }; api.require_admin_or_tenant(&auth, &relay.tenant)?; let current_plan = relay.plan.clone(); let requested_plan = payload.plan.clone(); if let Some(v) = payload.subdomain { relay.subdomain = v; } if let Some(v) = requested_plan.clone() { relay.plan = v; } if let Some(v) = payload.info_name { relay.info_name = v; } if let Some(v) = payload.info_icon { relay.info_icon = v; } if let Some(v) = payload.info_description { relay.info_description = v; } if let Some(v) = payload.policy_public_join { relay.policy_public_join = v; } if let Some(v) = payload.policy_strip_signatures { relay.policy_strip_signatures = v; } if let Some(v) = payload.groups_enabled { relay.groups_enabled = v; } if let Some(v) = payload.management_enabled { relay.management_enabled = v; } if let Some(v) = payload.blossom_enabled { relay.blossom_enabled = v; } if let Some(v) = payload.livekit_enabled { relay.livekit_enabled = v; } if let Some(v) = payload.push_enabled { relay.push_enabled = v; } relay = match prepare_relay(&api, relay) { Ok(r) => r, Err(e) => { return Ok(relay_validation_error_response(e)); } }; let plan_changed = requested_plan .as_deref() .is_some_and(|requested| requested != current_plan); if plan_changed { let selected_plan = api .query .get_plan(&relay.plan) .expect("validated plan must exist"); if let Some(limit) = selected_plan.members { let current_members = match fetch_relay_members(&api, &relay).await { Ok(members) => members.len() as i64, Err(e) => { return Ok(err( StatusCode::INTERNAL_SERVER_ERROR, "internal", &e.to_string(), )); } }; if current_members > limit { let message = format!( "relay has {current_members} members, which exceeds the {} plan limit of {limit}", selected_plan.name.to_lowercase() ); return Ok(err( StatusCode::UNPROCESSABLE_ENTITY, "member-limit-exceeded", &message, )); } } } match api.command.update_relay(&relay).await { Ok(()) => Ok(ok(StatusCode::OK, relay)), Err(e) => { if matches!(map_unique_error(&e), Some("subdomain-exists")) { Ok(err( StatusCode::UNPROCESSABLE_ENTITY, "subdomain-exists", "subdomain already exists", )) } else { Ok(err( StatusCode::INTERNAL_SERVER_ERROR, "internal", &e.to_string(), )) } } } } pub async fn deactivate_relay( State(api): State>, AuthedPubkey(auth): AuthedPubkey, Path(id): Path, ) -> Result { let relay = match api.query.get_relay(&id).await { Ok(Some(r)) => r, Ok(None) => return Ok(err(StatusCode::NOT_FOUND, "not-found", "relay not found")), Err(e) => { return Ok(err( StatusCode::INTERNAL_SERVER_ERROR, "internal", &e.to_string(), )); } }; api.require_admin_or_tenant(&auth, &relay.tenant)?; if relay.status == RELAY_STATUS_INACTIVE || relay.status == RELAY_STATUS_DELINQUENT { return Ok(err( StatusCode::BAD_REQUEST, "relay-is-inactive", "relay is already inactive", )); } match api.command.deactivate_relay(&relay).await { Ok(()) => Ok(ok(StatusCode::OK, ())), Err(e) => Ok(err( StatusCode::INTERNAL_SERVER_ERROR, "internal", &e.to_string(), )), } } pub async fn reactivate_relay( State(api): State>, AuthedPubkey(auth): AuthedPubkey, Path(id): Path, ) -> Result { let relay = match api.query.get_relay(&id).await { Ok(Some(r)) => r, Ok(None) => return Ok(err(StatusCode::NOT_FOUND, "not-found", "relay not found")), Err(e) => { return Ok(err( StatusCode::INTERNAL_SERVER_ERROR, "internal", &e.to_string(), )); } }; api.require_admin_or_tenant(&auth, &relay.tenant)?; if relay.status == RELAY_STATUS_ACTIVE { return Ok(err( StatusCode::BAD_REQUEST, "relay-is-active", "relay is already active", )); } match api.command.activate_relay(&relay).await { Ok(()) => Ok(ok(StatusCode::OK, ())), Err(e) => Ok(err( StatusCode::INTERNAL_SERVER_ERROR, "internal", &e.to_string(), )), } } // --- helpers ---------------------------------------------------------------- async fn fetch_relay_members(api: &Api, relay: &Relay) -> Result> { if relay.synced == 0 { return Ok(Vec::new()); } api.infra.list_relay_members(&relay.id).await } /// Validate user-supplied fields and fill defaults for `policy_*` / feature /// flags. Premium feature flags are clamped against the plan's entitlements. fn prepare_relay(api: &Api, mut relay: Relay) -> Result { validate_subdomain_label(&relay.subdomain)?; let plan = api .query .get_plan(&relay.plan) .ok_or(RelayValidationError::InvalidPlan)?; if !plan.blossom && relay.blossom_enabled == 1 { return Err(RelayValidationError::PremiumFeature); } if !plan.livekit && relay.livekit_enabled == 1 { return Err(RelayValidationError::PremiumFeature); } if relay.status.is_empty() { relay.status = RELAY_STATUS_ACTIVE.to_string(); } relay.policy_public_join = parse_bool_default(relay.policy_public_join, 0); relay.policy_strip_signatures = parse_bool_default(relay.policy_strip_signatures, 0); relay.groups_enabled = parse_bool_default(relay.groups_enabled, 1); relay.management_enabled = parse_bool_default(relay.management_enabled, 1); relay.blossom_enabled = parse_bool_default(relay.blossom_enabled, if plan.blossom { 1 } else { 0 }); relay.livekit_enabled = parse_bool_default(relay.livekit_enabled, if plan.livekit { 1 } else { 0 }); relay.push_enabled = parse_bool_default(relay.push_enabled, 1); Ok(relay) } const SUBDOMAIN_LABEL_MAX_LEN: usize = 63; const RESERVED_SUBDOMAIN_LABELS: [&str; 2] = ["api", "admin"]; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum SubdomainValidationError { Empty, TooLong, Reserved, EdgeHyphen, InvalidCharacters, } impl SubdomainValidationError { fn code(self) -> &'static str { match self { Self::Empty => "subdomain-empty", Self::TooLong => "subdomain-too-long", Self::Reserved => "subdomain-reserved", Self::EdgeHyphen => "subdomain-invalid-hyphen", Self::InvalidCharacters => "subdomain-invalid-characters", } } fn message(self) -> &'static str { match self { Self::Empty => "subdomain is required", Self::TooLong => "subdomain must be 63 characters or fewer", Self::Reserved => "subdomain is reserved", Self::EdgeHyphen => "subdomain cannot start or end with a hyphen", Self::InvalidCharacters => { "subdomain may only contain lowercase letters, numbers, and hyphens" } } } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum RelayValidationError { InvalidPlan, PremiumFeature, Subdomain(SubdomainValidationError), } impl RelayValidationError { fn code(self) -> &'static str { match self { Self::InvalidPlan => "invalid-plan", Self::PremiumFeature => "premium-feature", Self::Subdomain(reason) => reason.code(), } } fn message(self) -> &'static str { match self { Self::InvalidPlan => "plan not found", Self::PremiumFeature => "feature requires a paid plan", Self::Subdomain(reason) => reason.message(), } } } impl From for RelayValidationError { fn from(value: SubdomainValidationError) -> Self { Self::Subdomain(value) } } fn validate_subdomain_label(subdomain: &str) -> Result<(), SubdomainValidationError> { if subdomain.is_empty() { return Err(SubdomainValidationError::Empty); } if subdomain.len() > SUBDOMAIN_LABEL_MAX_LEN { return Err(SubdomainValidationError::TooLong); } if subdomain.starts_with('-') || subdomain.ends_with('-') { return Err(SubdomainValidationError::EdgeHyphen); } if RESERVED_SUBDOMAIN_LABELS.contains(&subdomain) { return Err(SubdomainValidationError::Reserved); } if !subdomain .chars() .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') { return Err(SubdomainValidationError::InvalidCharacters); } Ok(()) } fn relay_validation_error_response(error: RelayValidationError) -> Response { err( StatusCode::UNPROCESSABLE_ENTITY, error.code(), error.message(), ) }