diff --git a/backend/.env.template b/backend/.env.template index ea45eef..ed19339 100644 --- a/backend/.env.template +++ b/backend/.env.template @@ -1,10 +1,8 @@ # Server -HOST=127.0.0.1 -PORT=2892 -ALLOW_ORIGINS= # Optional comma-separated allowed CORS origins; empty = permissive - -# Auth -ADMINS= # Comma-separated hex pubkeys with admin access +SERVER_HOST=127.0.0.1 +SERVER_PORT=2892 +SERVER_ALLOW_ORIGINS= # Optional comma-separated allowed CORS origins; empty = permissive +SERVER_ADMIN_PUBKEYS= # Comma-separated hex pubkeys with admin access # Database DATABASE_URL=sqlite://data/caravel.db @@ -14,13 +12,13 @@ ROBOT_SECRET= # Nostr private key (hex) ROBOT_NAME= ROBOT_DESCRIPTION= ROBOT_PICTURE= -ROBOT_OUTBOX_RELAYS=relay.damus.io,relay.primal.net,nos.lol -ROBOT_INDEXER_RELAYS=purplepag.es,relay.damus.io,indexer.coracle.social -ROBOT_MESSAGING_RELAYS=auth.nostr1.com,relay.keychat.io,relay.ditto.pub +ROBOT_WALLET= # Nostr Wallet Connect URL for generating Lightning invoices +ROBOT_OUTBOX_RELAYS=wss://relay.damus.io,wss://relay.primal.net,wss://nos.lol +ROBOT_INDEXER_RELAYS=wss://purplepag.es,wss://relay.damus.io,wss://indexer.coracle.social +ROBOT_MESSAGING_RELAYS=wss://auth.nostr1.com,wss://relay.keychat.io,wss://relay.ditto.pub # Zooid ZOOID_API_URL=http://127.0.0.1:3334 -ZOOID_API_SECRET= RELAY_DOMAIN=spaces.coracle.social LIVEKIT_URL= LIVEKIT_API_KEY= @@ -34,8 +32,6 @@ BLOSSOM_S3_ACCESS_KEY= BLOSSOM_S3_SECRET_KEY= # Billing -NWC_URL= # Nostr Wallet Connect URL for generating Lightning invoices -ENCRYPTION_SECRET= # Nostr secret key (hex or nsec) used to encrypt tenant NWC URLs at rest STRIPE_SECRET_KEY= # Required Stripe API secret key (sk_...) STRIPE_WEBHOOK_SECRET=whsec_test_00000000000000000000000000 # Webhook signing secret (use real value in production) STRIPE_PRICE_BASIC= # Stripe price ID (price_...) for the Basic plan; required for paid plans diff --git a/backend/README.md b/backend/README.md index 7d3236f..eb9d634 100644 --- a/backend/README.md +++ b/backend/README.md @@ -38,7 +38,6 @@ Environment variables: | `ADMINS` | Comma-separated admin pubkeys (hex) | _optional_ | | `ALLOW_ORIGINS` | Comma-separated CORS origins. If empty, CORS is permissive. | _optional_ | | `ZOOID_API_URL` | Zooid API base URL used by infra worker | _required for infra sync_ | -| `ZOOID_API_SECRET` | Nostr secret key used for authentication of requests to the zooid API | _required_ | | `RELAY_DOMAIN` | Base domain appended to relay subdomains | empty | | `LIVEKIT_URL` | LiveKit URL sent to zooid when relay livekit is enabled | _optional_ | | `LIVEKIT_API_KEY` | LiveKit API key sent to zooid | _optional_ | diff --git a/backend/src/api.rs b/backend/src/api.rs index 37f06f0..a38adaf 100644 --- a/backend/src/api.rs +++ b/backend/src/api.rs @@ -14,6 +14,7 @@ use serde::{Deserialize, Serialize}; use crate::billing::Billing; use crate::command::Command; +use crate::env::Env; use crate::infra::Infra; use crate::models::{ RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay, Tenant, @@ -24,8 +25,7 @@ use axum::body::Bytes; #[derive(Clone)] pub struct Api { - host: String, - admins: Vec, + env: Env, query: Query, command: Command, billing: Billing, @@ -120,17 +120,15 @@ fn map_invoice_lookup_error(error: InvoiceLookupError) -> ApiError { } impl Api { - pub fn new(query: Query, command: Command, billing: Billing, infra: Infra) -> Self { - let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); - let admins = std::env::var("ADMINS") - .unwrap_or_default() - .split(',') - .map(|v| v.trim().to_lowercase()) - .filter(|v| !v.is_empty()) - .collect(); + pub fn new( + query: Query, + command: Command, + billing: Billing, + infra: Infra, + env: &Env, + ) -> Self { Self { - host, - admins, + env: env.clone(), query, command, billing, @@ -218,7 +216,7 @@ impl Api { // Intentional session-style variant of NIP-98 for Caravel API auth. // We validate signer identity plus host affinity, and do not bind to exact // request URL/method or maintain replay state here. - if !self.host.is_empty() && !got_u.contains(&self.host) { + if !self.env.server_host.is_empty() && !got_u.contains(&self.env.server_host) { return Err(ApiError::Unauthorized(anyhow!( "authorization host mismatch" ))); @@ -228,7 +226,12 @@ impl Api { } fn require_admin(&self, authorized_pubkey: &str) -> std::result::Result<(), ApiError> { - if self.admins.iter().any(|a| a == authorized_pubkey) { + if self + .env + .server_admin_pubkeys + .iter() + .any(|a| a == authorized_pubkey) + { Ok(()) } else { Err(ApiError::Forbidden("admin required")) @@ -240,7 +243,12 @@ impl Api { authorized_pubkey: &str, tenant_pubkey: &str, ) -> std::result::Result<(), ApiError> { - if self.admins.iter().any(|a| a == authorized_pubkey) || authorized_pubkey == tenant_pubkey + if self + .env + .server_admin_pubkeys + .iter() + .any(|a| a == authorized_pubkey) + || authorized_pubkey == tenant_pubkey { Ok(()) } else { @@ -267,7 +275,10 @@ impl Api { fn prepare_relay(&self, mut relay: Relay) -> std::result::Result { validate_subdomain_label(&relay.subdomain)?; - let plan = Query::get_plan(&relay.plan).ok_or(RelayValidationError::InvalidPlan)?; + let plan = self + .query + .get_plan(&relay.plan) + .ok_or(RelayValidationError::InvalidPlan)?; if !plan.blossom && relay.blossom_enabled == 1 { return Err(RelayValidationError::PremiumFeature); @@ -524,8 +535,8 @@ async fn list_tenants( } } -async fn list_plans() -> Response { - ok(StatusCode::OK, Query::list_plans()) +async fn list_plans(State(state): State) -> Response { + ok(StatusCode::OK, state.api.query.list_plans()) } async fn get_identity( @@ -533,7 +544,12 @@ async fn get_identity( headers: HeaderMap, ) -> std::result::Result { let pubkey = state.api.extract_auth_pubkey(&headers)?; - let is_admin = state.api.admins.iter().any(|a| a == &pubkey); + let is_admin = state + .api + .env + .server_admin_pubkeys + .iter() + .any(|a| a == &pubkey); Ok(ok(StatusCode::OK, IdentityResponse { pubkey, is_admin })) } @@ -599,8 +615,8 @@ async fn create_tenant( } } -async fn get_plan(Path(id): Path) -> Response { - match Query::get_plan(&id) { +async fn get_plan(State(state): State, Path(id): Path) -> Response { + match state.api.query.get_plan(&id) { Some(plan) => ok(StatusCode::OK, plan), None => err(StatusCode::NOT_FOUND, "not-found", "plan not found"), } @@ -881,7 +897,11 @@ async fn update_relay( .is_some_and(|requested| requested != current_plan); if plan_changed { - let selected_plan = Query::get_plan(&relay.plan).expect("validated plan must exist"); + let selected_plan = state + .api + .query + .get_plan(&relay.plan) + .expect("validated plan must exist"); if let Some(limit) = selected_plan.members { let current_members = match state.api.fetch_relay_members(&relay).await { Ok(members) => members.len() as i64, @@ -1147,8 +1167,11 @@ async fn update_tenant( if nwc_url.is_empty() { tenant.nwc_url = String::new(); } else { - tenant.nwc_url = - crate::cipher::encrypt(&nwc_url).map_err(|e| ApiError::Internal(e.to_string()))?; + tenant.nwc_url = state + .api + .env + .encrypt(&nwc_url) + .map_err(|e| ApiError::Internal(e.to_string()))?; } } diff --git a/backend/src/billing.rs b/backend/src/billing.rs index 25eb1de..2f677f6 100644 --- a/backend/src/billing.rs +++ b/backend/src/billing.rs @@ -3,6 +3,7 @@ use std::collections::BTreeMap; use crate::bitcoin; use crate::command::Command; +use crate::env::Env; use crate::models::{Activity, RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, Relay}; use crate::query::Query; use crate::robot::Robot; @@ -24,16 +25,21 @@ enum NwcInvoicePaymentOutcome { pub struct Billing { stripe: Stripe, wallet: Wallet, + env: Env, query: Query, command: Command, robot: Robot, } impl Billing { - pub fn new(query: Query, command: Command, robot: Robot) -> Self { + pub fn new(query: Query, command: Command, robot: Robot, env: &Env) -> Self { Self { - stripe: Stripe::from_env(), - wallet: Wallet::from_url(&std::env::var("NWC_URL").unwrap_or_default()).expect("invalid NWC_URL"), + stripe: Stripe::new( + env.stripe_secret_key.clone(), + env.stripe_webhook_secret.clone(), + ), + wallet: Wallet::from_url(&env.robot_wallet).expect("invalid ROBOT_WALLET"), + env: env.clone(), query, command, robot, @@ -136,7 +142,7 @@ impl Billing { if relay.status != RELAY_STATUS_ACTIVE { continue; } - let Some(plan) = Query::get_plan(&relay.plan) else { + let Some(plan) = self.query.get_plan(&relay.plan) else { tracing::warn!(relay = %relay.id, plan = %relay.plan, "active relay on unknown plan; not billed"); continue; }; @@ -370,7 +376,7 @@ impl Billing { // 1. NWC auto-pay: if the tenant has a nwc_url if !tenant.nwc_url.is_empty() { - let plain_nwc_url = crate::cipher::decrypt(&tenant.nwc_url)?; + let plain_nwc_url = self.env.decrypt(&tenant.nwc_url)?; match self .nwc_pay_invoice( invoice_id, @@ -725,7 +731,7 @@ impl Billing { return Ok(()); } - let plain_nwc_url = crate::cipher::decrypt(&tenant.nwc_url)?; + let plain_nwc_url = self.env.decrypt(&tenant.nwc_url)?; let invoices = self .stripe @@ -1040,236 +1046,4 @@ mod tests { )); } - use super::*; - use sqlx::SqlitePool; - use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; - use std::str::FromStr; - use std::sync::OnceLock; - use tokio::sync::Mutex; - - fn env_lock() -> &'static Mutex<()> { - static LOCK: OnceLock> = OnceLock::new(); - LOCK.get_or_init(|| Mutex::new(())) - } - - #[allow(unused_unsafe)] - fn set_stripe_secret_key(value: Option<&str>) { - match value { - Some(v) => unsafe { std::env::set_var("STRIPE_SECRET_KEY", v) }, - None => unsafe { std::env::remove_var("STRIPE_SECRET_KEY") }, - } - } - - #[allow(unused_unsafe)] - fn set_stripe_webhook_secret(value: Option<&str>) { - match value { - Some(v) => unsafe { std::env::set_var("STRIPE_WEBHOOK_SECRET", v) }, - None => unsafe { std::env::remove_var("STRIPE_WEBHOOK_SECRET") }, - } - } - - #[allow(unused_unsafe)] - fn set_nwc_url(value: Option<&str>) { - match value { - Some(v) => unsafe { std::env::set_var("NWC_URL", v) }, - None => unsafe { std::env::remove_var("NWC_URL") }, - } - } - - /// A syntactically valid NWC URI usable for `Wallet::from_env()` in tests. - /// The keys are random fixtures — the wallet is never actually contacted. - const TEST_NWC_URL: &str = "nostr+walletconnect://ef9824790df75f1f71d3fb9ffe9d8350f169df5cdd56a7a38592b407c61f4be7?relay=wss://relay.example.com&secret=baee312da88dcc52e9315e3962c2ea1bc8fdb5682a7fd6e6559084a41387e797"; - - struct StripeSecretKeyGuard { - previous: Option, - } - - impl StripeSecretKeyGuard { - fn set(value: Option<&str>) -> Self { - let previous = std::env::var("STRIPE_SECRET_KEY").ok(); - set_stripe_secret_key(value); - Self { previous } - } - } - - impl Drop for StripeSecretKeyGuard { - fn drop(&mut self) { - set_stripe_secret_key(self.previous.as_deref()); - } - } - - struct StripeWebhookSecretGuard { - previous: Option, - } - - impl StripeWebhookSecretGuard { - fn set(value: Option<&str>) -> Self { - let previous = std::env::var("STRIPE_WEBHOOK_SECRET").ok(); - set_stripe_webhook_secret(value); - Self { previous } - } - } - - impl Drop for StripeWebhookSecretGuard { - fn drop(&mut self) { - set_stripe_webhook_secret(self.previous.as_deref()); - } - } - - struct NwcUrlGuard { - previous: Option, - } - - impl NwcUrlGuard { - fn set(value: Option<&str>) -> Self { - let previous = std::env::var("NWC_URL").ok(); - set_nwc_url(value); - Self { previous } - } - } - - impl Drop for NwcUrlGuard { - fn drop(&mut self) { - set_nwc_url(self.previous.as_deref()); - } - } - - async fn test_pool() -> SqlitePool { - let connect_options = SqliteConnectOptions::from_str("sqlite::memory:") - .expect("valid sqlite memory url") - .create_if_missing(true); - - let pool = SqlitePoolOptions::new() - .max_connections(1) - .connect_with(connect_options) - .await - .expect("connect sqlite memory db"); - - sqlx::migrate!("./migrations") - .run(&pool) - .await - .expect("run migrations"); - - pool - } - - #[tokio::test] - async fn billing_new_panics_without_stripe_secret_key() { - let _lock = env_lock().lock().await; - let _secret_env = StripeSecretKeyGuard::set(None); - let _webhook_env = StripeWebhookSecretGuard::set(Some("whsec_test_dummy")); - let _nwc_env = NwcUrlGuard::set(None); - - let pool = test_pool().await; - let query = Query::new(pool.clone()); - let command = Command::new(pool); - let robot = Robot::test_stub(); - - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - Billing::new(query, command, robot) - })); - - let panic_payload = match result { - Ok(_) => panic!("constructor should panic when STRIPE_SECRET_KEY is missing"), - Err(payload) => payload, - }; - let panic_msg = if let Some(msg) = panic_payload.downcast_ref::<&str>() { - (*msg).to_string() - } else if let Some(msg) = panic_payload.downcast_ref::() { - msg.clone() - } else { - String::new() - }; - - assert!( - panic_msg.contains("missing STRIPE_SECRET_KEY environment variable"), - "unexpected panic: {panic_msg}" - ); - } - - #[tokio::test] - async fn billing_new_panics_without_stripe_webhook_secret() { - let _lock = env_lock().lock().await; - let _secret_env = StripeSecretKeyGuard::set(Some("sk_test_dummy")); - let _webhook_env = StripeWebhookSecretGuard::set(None); - let _nwc_env = NwcUrlGuard::set(None); - - let pool = test_pool().await; - let query = Query::new(pool.clone()); - let command = Command::new(pool); - let robot = Robot::test_stub(); - - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - Billing::new(query, command, robot) - })); - - let panic_payload = match result { - Ok(_) => panic!("constructor should panic when STRIPE_WEBHOOK_SECRET is missing"), - Err(payload) => payload, - }; - let panic_msg = if let Some(msg) = panic_payload.downcast_ref::<&str>() { - (*msg).to_string() - } else if let Some(msg) = panic_payload.downcast_ref::() { - msg.clone() - } else { - String::new() - }; - - assert!( - panic_msg.contains("missing STRIPE_WEBHOOK_SECRET environment variable"), - "unexpected panic: {panic_msg}" - ); - } - - #[tokio::test] - async fn billing_new_panics_with_blank_stripe_webhook_secret() { - let _lock = env_lock().lock().await; - let _secret_env = StripeSecretKeyGuard::set(Some("sk_test_dummy")); - let _webhook_env = StripeWebhookSecretGuard::set(Some(" ")); - let _nwc_env = NwcUrlGuard::set(None); - - let pool = test_pool().await; - let query = Query::new(pool.clone()); - let command = Command::new(pool); - let robot = Robot::test_stub(); - - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - Billing::new(query, command, robot) - })); - - let panic_payload = match result { - Ok(_) => panic!("constructor should panic when STRIPE_WEBHOOK_SECRET is blank"), - Err(payload) => payload, - }; - let panic_msg = if let Some(msg) = panic_payload.downcast_ref::<&str>() { - (*msg).to_string() - } else if let Some(msg) = panic_payload.downcast_ref::() { - msg.clone() - } else { - String::new() - }; - - assert!( - panic_msg.contains("missing STRIPE_WEBHOOK_SECRET environment variable"), - "unexpected panic: {panic_msg}" - ); - } - - #[tokio::test] - async fn billing_new_accepts_non_empty_stripe_secrets() { - let _lock = env_lock().lock().await; - let _secret_env = StripeSecretKeyGuard::set(Some("sk_test_dummy")); - let _webhook_env = StripeWebhookSecretGuard::set(Some("whsec_test_dummy")); - let _nwc_env = NwcUrlGuard::set(Some(TEST_NWC_URL)); - - let pool = test_pool().await; - let billing = Billing::new( - Query::new(pool.clone()), - Command::new(pool), - Robot::test_stub(), - ); - - assert_eq!(billing.stripe.secret_key, "sk_test_dummy"); - assert_eq!(billing.stripe.webhook_secret, "whsec_test_dummy"); - } } diff --git a/backend/src/cipher.rs b/backend/src/cipher.rs deleted file mode 100644 index bcf1e35..0000000 --- a/backend/src/cipher.rs +++ /dev/null @@ -1,28 +0,0 @@ -use anyhow::{Result, anyhow}; -use nostr_sdk::prelude::*; - -pub fn encrypt(plaintext: &str) -> Result { - let keys = load_key()?; - nip44::encrypt( - keys.secret_key(), - &keys.public_key(), - plaintext, - nip44::Version::V2, - ) - .map_err(|e| anyhow!("encryption failed: {e}")) -} - -pub fn decrypt(ciphertext: &str) -> Result { - let keys = load_key()?; - nip44::decrypt(keys.secret_key(), &keys.public_key(), ciphertext) - .map_err(|e| anyhow!("decryption failed: {e}")) -} - -fn load_key() -> Result { - let secret = std::env::var("ENCRYPTION_SECRET") - .map_err(|_| anyhow!("missing ENCRYPTION_SECRET environment variable"))?; - if secret.trim().is_empty() { - return Err(anyhow!("ENCRYPTION_SECRET is empty")); - } - Keys::parse(&secret).map_err(|e| anyhow!("invalid ENCRYPTION_SECRET: {e}")) -} diff --git a/backend/src/env.rs b/backend/src/env.rs new file mode 100644 index 0000000..614a332 --- /dev/null +++ b/backend/src/env.rs @@ -0,0 +1,126 @@ +use anyhow::{Result, anyhow}; +use nostr_sdk::prelude::*; + +#[derive(Clone)] +pub struct Env { + pub server_host: String, + pub server_port: u16, + pub server_admin_pubkeys: Vec, + pub server_allow_origins: Vec, + pub database_url: String, + pub robot_name: String, + pub robot_wallet: String, + pub robot_picture: String, + pub robot_description: String, + pub robot_outbox_relays: Vec, + pub robot_indexer_relays: Vec, + pub robot_messaging_relays: Vec, + pub blossom_s3_region: String, + pub blossom_s3_bucket: String, + pub blossom_s3_endpoint: String, + pub blossom_s3_access_key: String, + pub blossom_s3_secret_key: String, + pub zooid_api_url: String, + pub relay_domain: String, + pub livekit_url: String, + pub livekit_api_key: String, + pub livekit_api_secret: String, + pub stripe_secret_key: String, + pub stripe_webhook_secret: String, + pub stripe_price_basic: String, + pub stripe_price_growth: String, + /// Parsed from `robot_secret`; used for nostr signing and nip44 encryption. + pub keys: Keys, +} + +impl Env { + pub fn load() -> Self { + let keys = Keys::parse(&require_str("ROBOT_SECRET")) + .expect("ROBOT_SECRET is not a valid nostr secret key"); + + Self { + server_host: require_str("SERVER_HOST"), + server_port: require_u16("SERVER_PORT"), + server_admin_pubkeys: require_csv("SERVER_ADMIN_PUBKEYS"), + server_allow_origins: require_csv("SERVER_ALLOW_ORIGINS"), + database_url: require_str("DATABASE_URL"), + robot_name: require_str("ROBOT_NAME"), + robot_wallet: require_str("ROBOT_WALLET"), + robot_picture: require_str("ROBOT_PICTURE"), + robot_description: require_str("ROBOT_DESCRIPTION"), + robot_outbox_relays: require_csv("ROBOT_OUTBOX_RELAYS"), + robot_indexer_relays: require_csv("ROBOT_INDEXER_RELAYS"), + robot_messaging_relays: require_csv("ROBOT_MESSAGING_RELAYS"), + blossom_s3_region: require_str("BLOSSOM_S3_REGION"), + blossom_s3_bucket: require_str("BLOSSOM_S3_BUCKET"), + blossom_s3_endpoint: require_str("BLOSSOM_S3_ENDPOINT"), + blossom_s3_access_key: require_str("BLOSSOM_S3_ACCESS_KEY"), + blossom_s3_secret_key: require_str("BLOSSOM_S3_SECRET_KEY"), + zooid_api_url: require_str("ZOOID_API_URL"), + relay_domain: require_str("RELAY_DOMAIN"), + livekit_url: require_str("LIVEKIT_URL"), + livekit_api_key: require_str("LIVEKIT_API_KEY"), + livekit_api_secret: require_str("LIVEKIT_API_SECRET"), + stripe_secret_key: require_str("STRIPE_SECRET_KEY"), + stripe_webhook_secret: require_str("STRIPE_WEBHOOK_SECRET"), + stripe_price_basic: require_str("STRIPE_PRICE_BASIC"), + stripe_price_growth: require_str("STRIPE_PRICE_GROWTH"), + keys, + } + } + + pub fn encrypt(&self, plaintext: &str) -> Result { + nip44::encrypt( + self.keys.secret_key(), + &self.keys.public_key(), + plaintext, + nip44::Version::V2, + ) + .map_err(|e| anyhow!("encryption failed: {e}")) + } + + pub fn decrypt(&self, ciphertext: &str) -> Result { + nip44::decrypt(self.keys.secret_key(), &self.keys.public_key(), ciphertext) + .map_err(|e| anyhow!("decryption failed: {e}")) + } + + pub async fn make_auth(&self, url: &str, method: HttpMethod) -> Result { + let server_url = Url::parse(url)?; + let auth = HttpData::new(server_url, method) + .to_authorization(&self.keys) + .await?; + Ok(auth) + } +} + +fn require_str(key: &str) -> String { + let v = std::env::var(key) + .unwrap_or_else(|_| panic!("{key} is required")) + .trim() + .to_string(); + if v.is_empty() { + panic!("{key} is required") + } + v +} + +fn require_u16(key: &str) -> u16 { + require_str(key) + .parse() + .unwrap_or_else(|_| panic!("{key} is invalid")) +} + +fn require_csv(key: &str) -> Vec { + let v: Vec = std::env::var(key) + .unwrap_or_default() + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + + if v.is_empty() { + panic!("{key} is required"); + } + + v +} diff --git a/backend/src/infra.rs b/backend/src/infra.rs index 557a48b..597ae8b 100644 --- a/backend/src/infra.rs +++ b/backend/src/infra.rs @@ -3,6 +3,7 @@ use nostr_sdk::prelude::*; use std::time::Duration; use crate::command::Command; +use crate::env::Env; use crate::models::{Activity, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay}; use crate::query::Query; @@ -10,88 +11,20 @@ const RELAY_SYNC_RETRY_BASE_DELAY_SECS: u64 = 30; const RELAY_SYNC_RETRY_MAX_DELAY_SECS: u64 = 15 * 60; const RELAY_SYNC_RETRY_MAX_ATTEMPTS: usize = 6; -/// Blossom S3 settings from env; relay sync sets `key_prefix` to the relay schema. -#[derive(Clone)] -struct BlossomS3Sync { - endpoint: String, - region: String, - bucket: String, - access_key: String, - secret_key: String, -} - -impl BlossomS3Sync { - fn from_env() -> Option { - let region = std::env::var("BLOSSOM_S3_REGION").unwrap_or_default(); - let bucket = std::env::var("BLOSSOM_S3_BUCKET").unwrap_or_default(); - let access_key = std::env::var("BLOSSOM_S3_ACCESS_KEY").unwrap_or_default(); - let secret_key = std::env::var("BLOSSOM_S3_SECRET_KEY").unwrap_or_default(); - - let region = region.trim().to_string(); - let bucket = bucket.trim().to_string(); - let access_key = access_key.trim().to_string(); - let secret_key = secret_key.trim().to_string(); - - if region.is_empty() || bucket.is_empty() || access_key.is_empty() || secret_key.is_empty() { - return None; - } - - let endpoint = std::env::var("BLOSSOM_S3_ENDPOINT") - .unwrap_or_default() - .trim() - .to_string(); - - Some(Self { - endpoint, - region, - bucket, - access_key, - secret_key, - }) - } -} - #[derive(Clone)] pub struct Infra { - api_url: String, - relay_domain: String, - livekit_url: String, - livekit_api_key: String, - livekit_api_secret: String, - api_secret: String, - blossom_s3: Option, + env: Env, query: Query, command: Command, } impl Infra { - pub fn new(query: Query, command: Command) -> Result { - let api_url = std::env::var("ZOOID_API_URL").unwrap_or_default(); - let relay_domain = std::env::var("RELAY_DOMAIN").unwrap_or_default(); - let livekit_url = std::env::var("LIVEKIT_URL").unwrap_or_default(); - let livekit_api_key = std::env::var("LIVEKIT_API_KEY").unwrap_or_default(); - let livekit_api_secret = std::env::var("LIVEKIT_API_SECRET").unwrap_or_default(); - let api_secret = std::env::var("ZOOID_API_SECRET").unwrap_or_default(); - let blossom_s3 = BlossomS3Sync::from_env(); - - if api_url.trim().is_empty() { - anyhow::bail!("missing ZOOID_API_URL"); - } - if api_secret.trim().is_empty() { - anyhow::bail!("missing ZOOID_API_SECRET"); - } - - Ok(Self { - api_url, - relay_domain, - livekit_url, - livekit_api_key, - livekit_api_secret, - api_secret, - blossom_s3, + pub fn new(query: Query, command: Command, env: &Env) -> Self { + Self { + env: env.clone(), query, command, - }) + } } pub async fn start(self) { @@ -241,20 +174,11 @@ impl Infra { } } - async fn nip98_auth(&self, url: &str, method: HttpMethod) -> Result { - let keys = Keys::parse(&self.api_secret)?; - let server_url = Url::parse(url)?; - let auth = HttpData::new(server_url, method) - .to_authorization(&keys) - .await?; - Ok(auth) - } - pub async fn list_relay_members(&self, relay_id: &str) -> Result> { let client = reqwest::Client::new(); - let base = self.api_url.trim_end_matches('/'); + let base = self.env.zooid_api_url.trim_end_matches('/'); let url = format!("{base}/relay/{relay_id}/members"); - let auth = self.nip98_auth(&url, HttpMethod::GET).await?; + let auth = self.env.make_auth(&url, HttpMethod::GET).await?; let response = client .get(&url) @@ -274,20 +198,20 @@ impl Infra { async fn sync_relay(&self, relay: &Relay, is_new: bool) -> Result<()> { let client = reqwest::Client::new(); - let base = self.api_url.trim_end_matches('/'); + let base = self.env.zooid_api_url.trim_end_matches('/'); - let host = if self.relay_domain.is_empty() { + let host = if self.env.relay_domain.is_empty() { relay.subdomain.clone() } else { - format!("{}.{}", relay.subdomain, self.relay_domain) + format!("{}.{}", relay.subdomain, self.env.relay_domain) }; let livekit = if relay.livekit_enabled == 1 { serde_json::json!({ "enabled": true, - "server_url": self.livekit_url, - "api_key": self.livekit_api_key, - "api_secret": self.livekit_api_secret, + "server_url": self.env.livekit_url, + "api_key": self.env.livekit_api_key, + "api_secret": self.env.livekit_api_secret, }) } else { serde_json::json!({ "enabled": false }) @@ -298,12 +222,13 @@ impl Infra { host, livekit, is_new.then(|| Keys::generate().secret_key().to_secret_hex()), - self.blossom_s3.as_ref(), + &self.env, ); let url = format!("{}/relay/{}", base, relay.id); let auth = self - .nip98_auth(&url, zooid_sync_http_method(is_new)) + .env + .make_auth(&url, zooid_sync_http_method(is_new)) .await?; let request = if is_new { @@ -368,9 +293,9 @@ fn relay_sync_body( host: String, livekit: serde_json::Value, secret: Option, - blossom_s3: Option<&BlossomS3Sync>, + env: &Env, ) -> serde_json::Value { - let blossom = blossom_sync_json(relay, blossom_s3); + let blossom = blossom_sync_json(relay, env); let mut body = serde_json::json!({ "host": host, @@ -405,38 +330,35 @@ fn relay_sync_body( body } -fn blossom_sync_json(relay: &Relay, blossom_s3: Option<&BlossomS3Sync>) -> serde_json::Value { - let enabled = relay.blossom_enabled == 1; - if !enabled { +/// Relay sync sets `key_prefix` to the relay schema so each relay gets its own +/// blob namespace within the shared bucket. +fn blossom_sync_json(relay: &Relay, env: &Env) -> serde_json::Value { + if relay.blossom_enabled != 1 { return serde_json::json!({ "enabled": false }); } - let Some(s3) = blossom_s3 else { - return serde_json::json!({ "enabled": true }); - }; - let mut s3_obj = serde_json::Map::new(); - if !s3.endpoint.trim().is_empty() { + if !env.blossom_s3_endpoint.trim().is_empty() { s3_obj.insert( "endpoint".to_string(), - serde_json::Value::String(s3.endpoint.clone()), + serde_json::Value::String(env.blossom_s3_endpoint.clone()), ); } s3_obj.insert( "region".to_string(), - serde_json::Value::String(s3.region.clone()), + serde_json::Value::String(env.blossom_s3_region.clone()), ); s3_obj.insert( "bucket".to_string(), - serde_json::Value::String(s3.bucket.clone()), + serde_json::Value::String(env.blossom_s3_bucket.clone()), ); s3_obj.insert( "access_key".to_string(), - serde_json::Value::String(s3.access_key.clone()), + serde_json::Value::String(env.blossom_s3_access_key.clone()), ); s3_obj.insert( "secret_key".to_string(), - serde_json::Value::String(s3.secret_key.clone()), + serde_json::Value::String(env.blossom_s3_secret_key.clone()), ); s3_obj.insert( "key_prefix".to_string(), diff --git a/backend/src/lib.rs b/backend/src/lib.rs index f94ba6d..ce99830 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -1,8 +1,8 @@ pub mod api; pub mod billing; pub mod bitcoin; -pub mod cipher; pub mod command; +pub mod env; pub mod infra; pub mod models; pub mod pool; diff --git a/backend/src/main.rs b/backend/src/main.rs index db1bb4d..0324d8e 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -1,8 +1,8 @@ mod api; mod billing; mod bitcoin; -mod cipher; mod command; +mod env; mod infra; mod models; mod pool; @@ -19,6 +19,7 @@ use tower_http::cors::{AllowOrigin, CorsLayer}; use crate::api::Api; use crate::billing::Billing; use crate::command::Command; +use crate::env::Env; use crate::infra::Infra; use crate::query::Query; use crate::robot::Robot; @@ -32,30 +33,21 @@ async fn main() -> Result<()> { .with(tracing_subscriber::fmt::layer()) .init(); - let pool = pool::create_pool().await?; - let robot = Robot::new().await?; - let query = Query::new(pool.clone()); + let env = Env::load(); + + let pool = pool::create_pool(&env.database_url).await?; + let robot = Robot::new(&env).await?; + let query = Query::new(pool.clone(), &env); let command = Command::new(pool); - let billing = Billing::new(query.clone(), command.clone(), robot.clone()); - let infra = Infra::new(query.clone(), command.clone())?; - let api = Api::new(query, command, billing.clone(), infra.clone()); + let billing = Billing::new(query.clone(), command.clone(), robot.clone(), &env); + let infra = Infra::new(query.clone(), command.clone(), &env); + let api = Api::new(query, command, billing.clone(), infra.clone(), &env); - let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); - let port: u16 = std::env::var("PORT") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(2892); - let origins: Vec = std::env::var("ALLOW_ORIGINS") - .unwrap_or_default() - .split(',') - .map(|v| v.trim().to_string()) - .filter(|v| !v.is_empty()) - .collect(); - - let cors = if origins.is_empty() { + let cors = if env.server_allow_origins.is_empty() { CorsLayer::permissive() } else { - let parsed = origins + let parsed = env + .server_allow_origins .iter() .filter_map(|o| o.parse::().ok()) .collect::>(); @@ -72,7 +64,8 @@ async fn main() -> Result<()> { billing.start().await; }); - let listener = tokio::net::TcpListener::bind(format!("{host}:{port}")).await?; + let listener = + tokio::net::TcpListener::bind(format!("{}:{}", env.server_host, env.server_port)).await?; axum::serve(listener, app).await?; Ok(()) } diff --git a/backend/src/pool.rs b/backend/src/pool.rs index 165bc9e..a42b3da 100644 --- a/backend/src/pool.rs +++ b/backend/src/pool.rs @@ -7,10 +7,8 @@ use sqlx::{ sqlite::{SqliteConnectOptions, SqlitePoolOptions}, }; -pub async fn create_pool() -> Result { - let raw_database_url = std::env::var("DATABASE_URL") - .unwrap_or_else(|_| format!("sqlite://{}/data/caravel.db", env!("CARGO_MANIFEST_DIR"))); - let database_url = normalize_sqlite_url(&raw_database_url); +pub async fn create_pool(database_url: &str) -> Result { + let database_url = normalize_sqlite_url(database_url); if let Some(path) = database_url.strip_prefix("sqlite://") && !path.is_empty() diff --git a/backend/src/query.rs b/backend/src/query.rs index 8ff7ed8..dea4351 100644 --- a/backend/src/query.rs +++ b/backend/src/query.rs @@ -1,16 +1,21 @@ use anyhow::Result; use sqlx::SqlitePool; +use crate::env::Env; use crate::models::{Activity, Plan, Relay, Tenant}; #[derive(Clone)] pub struct Query { pool: SqlitePool, + env: Env, } impl Query { - pub fn new(pool: SqlitePool) -> Self { - Self { pool } + pub fn new(pool: SqlitePool, env: &Env) -> Self { + Self { + pool, + env: env.clone(), + } } pub async fn list_tenants(&self) -> Result> { @@ -36,7 +41,7 @@ impl Query { Ok(row) } - pub fn list_plans() -> Vec { + pub fn list_plans(&self) -> Vec { vec![ Plan { id: "free".to_string(), @@ -54,7 +59,7 @@ impl Query { members: Some(100), blossom: true, livekit: true, - stripe_price_id: Some(std::env::var("STRIPE_PRICE_BASIC").unwrap_or_default()), + stripe_price_id: Some(self.env.stripe_price_basic.clone()), }, Plan { id: "growth".to_string(), @@ -63,19 +68,19 @@ impl Query { members: None, blossom: true, livekit: true, - stripe_price_id: Some(std::env::var("STRIPE_PRICE_GROWTH").unwrap_or_default()), + stripe_price_id: Some(self.env.stripe_price_growth.clone()), }, ] } - pub fn get_plan(plan_id: &str) -> Option { - Self::list_plans().into_iter().find(|p| p.id == plan_id) + pub fn get_plan(&self, plan_id: &str) -> Option { + self.list_plans().into_iter().find(|p| p.id == plan_id) } + /// True for any plan that costs money. Doesn't require an instance because + /// the answer doesn't depend on Stripe price ids — only the canonical plan id. pub fn is_paid_plan(plan_id: &str) -> bool { - Self::get_plan(plan_id) - .map(|p| p.id != "free") - .unwrap_or(false) + matches!(plan_id, "basic" | "growth") } pub async fn list_relays(&self) -> Result> { diff --git a/backend/src/robot.rs b/backend/src/robot.rs index 7873c3e..51493bb 100644 --- a/backend/src/robot.rs +++ b/backend/src/robot.rs @@ -5,15 +5,11 @@ use anyhow::{Result, anyhow}; use nostr_sdk::prelude::*; use tokio::sync::Mutex; +use crate::env::Env; + #[derive(Clone)] pub struct Robot { - secret: String, - name: String, - description: String, - picture: String, - outbox_client: Client, - indexer_client: Client, - messaging_client: Client, + env: Env, outbox_cache: std::sync::Arc>>, dm_cache: std::sync::Arc>>, } @@ -25,84 +21,61 @@ struct CacheEntry { } impl Robot { - pub async fn new() -> Result { - let secret = std::env::var("ROBOT_SECRET").unwrap_or_default(); - if secret.trim().is_empty() { - return Err(anyhow!("ROBOT_SECRET is required")); - } - - let name = std::env::var("ROBOT_NAME").unwrap_or_default(); - let description = std::env::var("ROBOT_DESCRIPTION").unwrap_or_default(); - let picture = std::env::var("ROBOT_PICTURE").unwrap_or_default(); - let outbox_relays = split_relays("ROBOT_OUTBOX_RELAYS"); - let indexer_relays = split_relays("ROBOT_INDEXER_RELAYS"); - let messaging_relays = split_relays("ROBOT_MESSAGING_RELAYS"); - - if outbox_relays.is_empty() { - return Err(anyhow!("ROBOT_OUTBOX_RELAYS is required")); - } - if indexer_relays.is_empty() { - return Err(anyhow!("ROBOT_INDEXER_RELAYS is required")); - } - if messaging_relays.is_empty() { - return Err(anyhow!("ROBOT_MESSAGING_RELAYS is required")); - } - - let outbox_client = client_with_relays(&secret, &outbox_relays).await?; - let indexer_client = client_with_relays(&secret, &indexer_relays).await?; - let messaging_client = client_with_relays(&secret, &messaging_relays).await?; - + pub async fn new(env: &Env) -> Result { let robot = Self { - secret, - name, - description, - picture, - outbox_client, - indexer_client, - messaging_client, + env: env.clone(), outbox_cache: std::sync::Arc::new(Mutex::new(HashMap::new())), dm_cache: std::sync::Arc::new(Mutex::new(HashMap::new())), }; - robot - .publish_identity(&outbox_relays, &messaging_relays) - .await?; + robot.publish_identity().await?; Ok(robot) } + async fn make_client(&self, relays: &[String]) -> Result { + let client = Client::new(self.env.keys.clone()); + for relay in relays { + client.add_relay(relay).await?; + } + client.connect().await; + Ok(client) + } + + async fn publish_identity( &self, - outbox_relays: &[String], - messaging_relays: &[String], ) -> Result<()> { let mut metadata = Metadata::new(); - if !self.name.is_empty() { - metadata = metadata.name(&self.name); + if !self.env.robot_name.is_empty() { + metadata = metadata.name(&self.env.robot_name); } - if !self.description.is_empty() { - metadata = metadata.about(&self.description); + if !self.env.robot_description.is_empty() { + metadata = metadata.about(&self.env.robot_description); } - if !self.picture.is_empty() { - metadata = metadata.picture(Url::parse(&self.picture)?); + if !self.env.robot_picture.is_empty() { + metadata = metadata.picture(Url::parse(&self.env.robot_picture)?); } - self.outbox_client + let outbox_client = self.make_client(&self.env.robot_outbox_relays).await?; + let indexer_client = self.make_client(&self.env.robot_indexer_relays).await?; + + outbox_client .send_event_builder(EventBuilder::metadata(&metadata)) .await?; - let outbox_tags = outbox_relays + let outbox_tags = self.env.robot_outbox_relays .iter() .map(|r| Tag::parse(["r", r.as_str()])) .collect::, _>>()?; - self.outbox_client + outbox_client .send_event_builder(EventBuilder::new(Kind::Custom(10002), "").tags(outbox_tags)) .await?; - let messaging_tags = messaging_relays + let messaging_tags = self.env.robot_messaging_relays .iter() .map(|r| Tag::parse(["relay", r.as_str()])) .collect::, _>>()?; - self.indexer_client + indexer_client .send_event_builder(EventBuilder::new(Kind::Custom(10050), "").tags(messaging_tags)) .await?; @@ -123,14 +96,8 @@ impl Robot { } let recipient_pubkey = PublicKey::parse(recipient)?; - let client = self.messaging_client.clone(); - for relay in dm_relays { - let _ = client.add_relay(relay).await; - } - client.connect().await; - client - .send_private_msg(recipient_pubkey, message, []) - .await?; + let client = self.make_client(&dm_relays).await?; + client.send_private_msg(recipient_pubkey, message, []).await?; Ok(()) } @@ -141,10 +108,8 @@ impl Robot { let pubkey = PublicKey::parse(recipient)?; let filter = Filter::new().author(pubkey).kind(Kind::Custom(10002)); - let events = self - .indexer_client - .fetch_events(filter, Duration::from_secs(5)) - .await?; + let client = self.make_client(&self.env.robot_indexer_relays).await?; + let events = client.fetch_events(filter, Duration::from_secs(5)).await?; let mut relays = Vec::new(); if let Some(event) = events.into_iter().max_by_key(|e| e.created_at) { @@ -163,11 +128,8 @@ impl Robot { pub async fn fetch_nostr_name(&self, pubkey: &str) -> Option { let pubkey = PublicKey::parse(pubkey).ok()?; let filter = Filter::new().author(pubkey).kind(Kind::Metadata).limit(1); - let events = self - .indexer_client - .fetch_events(filter, Duration::from_secs(5)) - .await - .ok()?; + let client = self.make_client(&self.env.robot_indexer_relays).await.ok()?; + let events = client.fetch_events(filter, Duration::from_secs(5)).await.ok()?; let event = events.into_iter().max_by_key(|e| e.created_at)?; let content: serde_json::Value = serde_json::from_str(&event.content).ok()?; let name = content @@ -189,13 +151,7 @@ impl Robot { } let pubkey = PublicKey::parse(recipient)?; - let keys = Keys::parse(&self.secret)?; - let client = Client::new(keys); - for relay in outbox_relays { - client.add_relay(relay).await?; - } - client.connect().await; - + let client = self.make_client(outbox_relays).await?; let filter = Filter::new().author(pubkey).kind(Kind::Custom(10050)); let events = client.fetch_events(filter, Duration::from_secs(5)).await?; @@ -214,37 +170,6 @@ impl Robot { } } -fn split_relays(key: &str) -> Vec { - std::env::var(key) - .unwrap_or_default() - .split(',') - .map(|v| normalize_relay_url(v.trim())) - .filter(|v| !v.is_empty()) - .collect() -} - -fn normalize_relay_url(url: &str) -> String { - if url.is_empty() { - return String::new(); - } - - if url.starts_with("ws://") || url.starts_with("wss://") { - url.to_string() - } else { - format!("wss://{url}") - } -} - -async fn client_with_relays(secret: &str, relays: &[String]) -> Result { - let keys = Keys::parse(secret)?; - let client = Client::new(keys); - for relay in relays { - client.add_relay(relay).await?; - } - client.connect().await; - Ok(client) -} - async fn get_cached( cache: &std::sync::Arc>>, key: &str, @@ -273,23 +198,3 @@ async fn set_cached( }, ); } - -#[cfg(test)] -impl Robot { - pub fn test_stub() -> Self { - let keys = Keys::generate(); - let client = Client::new(keys); - - Self { - secret: String::new(), - name: String::new(), - description: String::new(), - picture: String::new(), - outbox_client: client.clone(), - indexer_client: client.clone(), - messaging_client: client, - outbox_cache: std::sync::Arc::new(Mutex::new(HashMap::new())), - dm_cache: std::sync::Arc::new(Mutex::new(HashMap::new())), - } - } -} diff --git a/backend/src/stripe.rs b/backend/src/stripe.rs index b4facab..a50109c 100644 --- a/backend/src/stripe.rs +++ b/backend/src/stripe.rs @@ -73,20 +73,6 @@ pub struct Stripe { } impl Stripe { - /// Builds the client from the environment: `STRIPE_SECRET_KEY` and - /// `STRIPE_WEBHOOK_SECRET`, both required. Panics if either is missing or blank. - pub fn from_env() -> Self { - let secret_key = std::env::var("STRIPE_SECRET_KEY").unwrap_or_default(); - if secret_key.trim().is_empty() { - panic!("missing STRIPE_SECRET_KEY environment variable"); - } - let webhook_secret = std::env::var("STRIPE_WEBHOOK_SECRET").unwrap_or_default(); - if webhook_secret.trim().is_empty() { - panic!("missing STRIPE_WEBHOOK_SECRET environment variable"); - } - Self::new(secret_key, webhook_secret) - } - pub fn new(secret_key: String, webhook_secret: String) -> Self { Self { secret_key, diff --git a/todo.md b/todo.md index 7715444..8803008 100644 --- a/todo.md +++ b/todo.md @@ -1,2 +1,3 @@ +- [ ] Split web utilities and controllers. Use decorators for implementing auth - [ ] Fix billing by using stripe as a backend to do proration, then mark invoices paid manually when using bitcoin. - [ ] Send a payment link instead of an invoice so we can generate/pay on the fly