Clean up relay validation
This commit is contained in:
Generated
+13
@@ -210,6 +210,7 @@ dependencies = [
|
|||||||
"nostr-sdk",
|
"nostr-sdk",
|
||||||
"nwc",
|
"nwc",
|
||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
|
"regex",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -1894,6 +1895,18 @@ dependencies = [
|
|||||||
"bitflags",
|
"bitflags",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "regex"
|
||||||
|
version = "1.12.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
|
||||||
|
dependencies = [
|
||||||
|
"aho-corasick",
|
||||||
|
"memchr",
|
||||||
|
"regex-automata",
|
||||||
|
"regex-syntax",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "regex-automata"
|
name = "regex-automata"
|
||||||
version = "0.4.14"
|
version = "0.4.14"
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ hmac = "0.12"
|
|||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
dotenvy = "0.15.7"
|
dotenvy = "0.15.7"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
|
regex = "1"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tower = { version = "0.5", features = ["util"] }
|
tower = { version = "0.5", features = ["util"] }
|
||||||
|
|||||||
+28
-112
@@ -1,10 +1,11 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::{Arc, LazyLock};
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use axum::{
|
use axum::{
|
||||||
Json,
|
Json,
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
};
|
};
|
||||||
|
use regex::Regex;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
use crate::api::{Api, AuthedPubkey};
|
use crate::api::{Api, AuthedPubkey};
|
||||||
@@ -129,7 +130,7 @@ pub async fn create_relay(
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
let relay = prepare_relay(&api, relay).map_err(validation_error)?;
|
let relay = prepare_relay(&api, relay)?;
|
||||||
|
|
||||||
api.command
|
api.command
|
||||||
.create_relay(&relay)
|
.create_relay(&relay)
|
||||||
@@ -187,7 +188,7 @@ pub async fn update_relay(
|
|||||||
relay.push_enabled = v;
|
relay.push_enabled = v;
|
||||||
}
|
}
|
||||||
|
|
||||||
let relay = prepare_relay(&api, relay).map_err(validation_error)?;
|
let relay = prepare_relay(&api, relay)?;
|
||||||
|
|
||||||
let plan_changed = requested_plan
|
let plan_changed = requested_plan
|
||||||
.as_deref()
|
.as_deref()
|
||||||
@@ -229,7 +230,11 @@ pub async fn deactivate_relay(
|
|||||||
let relay = api.get_relay_or_404(&id).await?;
|
let relay = api.get_relay_or_404(&id).await?;
|
||||||
api.require_admin_or_tenant(&auth, &relay.tenant)?;
|
api.require_admin_or_tenant(&auth, &relay.tenant)?;
|
||||||
|
|
||||||
if relay.status == RELAY_STATUS_INACTIVE || relay.status == RELAY_STATUS_DELINQUENT {
|
if relay.status == RELAY_STATUS_DELINQUENT {
|
||||||
|
return Err(bad_request("relay-is-delinquent", "relay is delinquent"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if relay.status == RELAY_STATUS_INACTIVE {
|
||||||
return Err(bad_request("relay-is-inactive", "relay is already inactive"));
|
return Err(bad_request("relay-is-inactive", "relay is already inactive"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,6 +253,10 @@ pub async fn reactivate_relay(
|
|||||||
let relay = api.get_relay_or_404(&id).await?;
|
let relay = api.get_relay_or_404(&id).await?;
|
||||||
api.require_admin_or_tenant(&auth, &relay.tenant)?;
|
api.require_admin_or_tenant(&auth, &relay.tenant)?;
|
||||||
|
|
||||||
|
if relay.status == RELAY_STATUS_DELINQUENT {
|
||||||
|
return Err(bad_request("relay-is-delinquent", "relay is delinquent"));
|
||||||
|
}
|
||||||
|
|
||||||
if relay.status == RELAY_STATUS_ACTIVE {
|
if relay.status == RELAY_STATUS_ACTIVE {
|
||||||
return Err(bad_request("relay-is-active", "relay is already active"));
|
return Err(bad_request("relay-is-active", "relay is already active"));
|
||||||
}
|
}
|
||||||
@@ -266,40 +275,37 @@ async fn fetch_relay_members(api: &Api, relay: &Relay) -> Result<Vec<String>> {
|
|||||||
api.infra.list_relay_members(&relay.id).await
|
api.infra.list_relay_members(&relay.id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validate user-supplied fields and fill defaults for `policy_*` / feature
|
const RESERVED_SUBDOMAINS: [&str; 3] = ["api", "admin", "internal"];
|
||||||
/// flags. Premium feature flags are clamped against the plan's entitlements.
|
|
||||||
fn prepare_relay(api: &Api, mut relay: Relay) -> Result<Relay, RelayValidationError> {
|
static SUBDOMAIN_RE: LazyLock<Regex> =
|
||||||
validate_subdomain_label(&relay.subdomain)?;
|
LazyLock::new(|| Regex::new(r"^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$").unwrap());
|
||||||
|
|
||||||
|
fn prepare_relay(api: &Api, mut relay: Relay) -> Result<Relay, ApiError> {
|
||||||
|
if !SUBDOMAIN_RE.is_match(&relay.subdomain)
|
||||||
|
|| RESERVED_SUBDOMAINS.contains(&relay.subdomain.as_str()) {
|
||||||
|
return Err(unprocessable("invalid-subdomain", "subdomain is invalid"));
|
||||||
|
}
|
||||||
|
|
||||||
let plan = api
|
let plan = api
|
||||||
.query
|
.query
|
||||||
.get_plan(&relay.plan)
|
.get_plan(&relay.plan)
|
||||||
.ok_or(RelayValidationError::InvalidPlan)?;
|
.ok_or_else(|| unprocessable("invalid-plan", "plan not found"))?;
|
||||||
|
|
||||||
if !plan.blossom && relay.blossom_enabled == 1 {
|
if (!plan.blossom && relay.blossom_enabled == 1) || (!plan.livekit && relay.livekit_enabled == 1) {
|
||||||
return Err(RelayValidationError::PremiumFeature);
|
return Err(unprocessable("premium-feature", "feature requires a paid plan"));
|
||||||
}
|
|
||||||
if !plan.livekit && relay.livekit_enabled == 1 {
|
|
||||||
return Err(RelayValidationError::PremiumFeature);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
relay.policy_public_join = parse_bool_default(relay.policy_public_join, 0);
|
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.policy_strip_signatures = parse_bool_default(relay.policy_strip_signatures, 0);
|
||||||
relay.groups_enabled = parse_bool_default(relay.groups_enabled, 1);
|
relay.groups_enabled = parse_bool_default(relay.groups_enabled, 1);
|
||||||
relay.management_enabled = parse_bool_default(relay.management_enabled, 1);
|
relay.management_enabled = parse_bool_default(relay.management_enabled, 1);
|
||||||
relay.blossom_enabled =
|
relay.blossom_enabled = parse_bool_default(relay.blossom_enabled, 0);
|
||||||
parse_bool_default(relay.blossom_enabled, if plan.blossom { 1 } else { 0 });
|
relay.livekit_enabled = parse_bool_default(relay.livekit_enabled, 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);
|
relay.push_enabled = parse_bool_default(relay.push_enabled, 1);
|
||||||
|
|
||||||
Ok(relay)
|
Ok(relay)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validation_error(error: RelayValidationError) -> ApiError {
|
|
||||||
unprocessable(error.code(), error.message())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn map_relay_write_error(e: anyhow::Error) -> ApiError {
|
fn map_relay_write_error(e: anyhow::Error) -> ApiError {
|
||||||
if matches!(map_unique_error(&e), Some("subdomain-exists")) {
|
if matches!(map_unique_error(&e), Some("subdomain-exists")) {
|
||||||
unprocessable("subdomain-exists", "subdomain already exists")
|
unprocessable("subdomain-exists", "subdomain already exists")
|
||||||
@@ -307,93 +313,3 @@ fn map_relay_write_error(e: anyhow::Error) -> ApiError {
|
|||||||
internal(e)
|
internal(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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<SubdomainValidationError> 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(())
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user