Add env struct
This commit is contained in:
+8
-12
@@ -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
|
||||
|
||||
@@ -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_ |
|
||||
|
||||
+47
-24
@@ -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<String>,
|
||||
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<Relay, RelayValidationError> {
|
||||
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<AppState>) -> 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<Response, ApiError> {
|
||||
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<String>) -> Response {
|
||||
match Query::get_plan(&id) {
|
||||
async fn get_plan(State(state): State<AppState>, Path(id): Path<String>) -> 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()))?;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+12
-238
@@ -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<Mutex<()>> = 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<String>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
}
|
||||
|
||||
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::<String>() {
|
||||
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::<String>() {
|
||||
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::<String>() {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
use anyhow::{Result, anyhow};
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
pub fn encrypt(plaintext: &str) -> Result<String> {
|
||||
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<String> {
|
||||
let keys = load_key()?;
|
||||
nip44::decrypt(keys.secret_key(), &keys.public_key(), ciphertext)
|
||||
.map_err(|e| anyhow!("decryption failed: {e}"))
|
||||
}
|
||||
|
||||
fn load_key() -> Result<Keys> {
|
||||
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}"))
|
||||
}
|
||||
@@ -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<String>,
|
||||
pub server_allow_origins: Vec<String>,
|
||||
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<String>,
|
||||
pub robot_indexer_relays: Vec<String>,
|
||||
pub robot_messaging_relays: Vec<String>,
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
let v: Vec<String> = 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
|
||||
}
|
||||
+29
-107
@@ -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<Self> {
|
||||
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<BlossomS3Sync>,
|
||||
env: Env,
|
||||
query: Query,
|
||||
command: Command,
|
||||
}
|
||||
|
||||
impl Infra {
|
||||
pub fn new(query: Query, command: Command) -> Result<Self> {
|
||||
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<String> {
|
||||
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<Vec<String>> {
|
||||
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<String>,
|
||||
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(),
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+15
-22
@@ -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<String> = 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::<axum::http::HeaderValue>().ok())
|
||||
.collect::<Vec<_>>();
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
+2
-4
@@ -7,10 +7,8 @@ use sqlx::{
|
||||
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
|
||||
};
|
||||
|
||||
pub async fn create_pool() -> Result<SqlitePool> {
|
||||
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<SqlitePool> {
|
||||
let database_url = normalize_sqlite_url(database_url);
|
||||
|
||||
if let Some(path) = database_url.strip_prefix("sqlite://")
|
||||
&& !path.is_empty()
|
||||
|
||||
+15
-10
@@ -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<Vec<Tenant>> {
|
||||
@@ -36,7 +41,7 @@ impl Query {
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
pub fn list_plans() -> Vec<Plan> {
|
||||
pub fn list_plans(&self) -> Vec<Plan> {
|
||||
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<Plan> {
|
||||
Self::list_plans().into_iter().find(|p| p.id == plan_id)
|
||||
pub fn get_plan(&self, plan_id: &str) -> Option<Plan> {
|
||||
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<Vec<Relay>> {
|
||||
|
||||
+37
-132
@@ -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<Mutex<HashMap<String, CacheEntry>>>,
|
||||
dm_cache: std::sync::Arc<Mutex<HashMap<String, CacheEntry>>>,
|
||||
}
|
||||
@@ -25,84 +21,61 @@ struct CacheEntry {
|
||||
}
|
||||
|
||||
impl Robot {
|
||||
pub async fn new() -> Result<Self> {
|
||||
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<Self> {
|
||||
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<Client> {
|
||||
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::<std::result::Result<Vec<_>, _>>()?;
|
||||
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::<std::result::Result<Vec<_>, _>>()?;
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<Client> {
|
||||
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<Mutex<HashMap<String, CacheEntry>>>,
|
||||
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())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user