forked from coracle/caravel
Add env struct
This commit is contained in:
+8
-12
@@ -1,10 +1,8 @@
|
|||||||
# Server
|
# Server
|
||||||
HOST=127.0.0.1
|
SERVER_HOST=127.0.0.1
|
||||||
PORT=2892
|
SERVER_PORT=2892
|
||||||
ALLOW_ORIGINS= # Optional comma-separated allowed CORS origins; empty = permissive
|
SERVER_ALLOW_ORIGINS= # Optional comma-separated allowed CORS origins; empty = permissive
|
||||||
|
SERVER_ADMIN_PUBKEYS= # Comma-separated hex pubkeys with admin access
|
||||||
# Auth
|
|
||||||
ADMINS= # Comma-separated hex pubkeys with admin access
|
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
DATABASE_URL=sqlite://data/caravel.db
|
DATABASE_URL=sqlite://data/caravel.db
|
||||||
@@ -14,13 +12,13 @@ ROBOT_SECRET= # Nostr private key (hex)
|
|||||||
ROBOT_NAME=
|
ROBOT_NAME=
|
||||||
ROBOT_DESCRIPTION=
|
ROBOT_DESCRIPTION=
|
||||||
ROBOT_PICTURE=
|
ROBOT_PICTURE=
|
||||||
ROBOT_OUTBOX_RELAYS=relay.damus.io,relay.primal.net,nos.lol
|
ROBOT_WALLET= # Nostr Wallet Connect URL for generating Lightning invoices
|
||||||
ROBOT_INDEXER_RELAYS=purplepag.es,relay.damus.io,indexer.coracle.social
|
ROBOT_OUTBOX_RELAYS=wss://relay.damus.io,wss://relay.primal.net,wss://nos.lol
|
||||||
ROBOT_MESSAGING_RELAYS=auth.nostr1.com,relay.keychat.io,relay.ditto.pub
|
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
|
||||||
ZOOID_API_URL=http://127.0.0.1:3334
|
ZOOID_API_URL=http://127.0.0.1:3334
|
||||||
ZOOID_API_SECRET=
|
|
||||||
RELAY_DOMAIN=spaces.coracle.social
|
RELAY_DOMAIN=spaces.coracle.social
|
||||||
LIVEKIT_URL=
|
LIVEKIT_URL=
|
||||||
LIVEKIT_API_KEY=
|
LIVEKIT_API_KEY=
|
||||||
@@ -34,8 +32,6 @@ BLOSSOM_S3_ACCESS_KEY=
|
|||||||
BLOSSOM_S3_SECRET_KEY=
|
BLOSSOM_S3_SECRET_KEY=
|
||||||
|
|
||||||
# Billing
|
# 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_SECRET_KEY= # Required Stripe API secret key (sk_...)
|
||||||
STRIPE_WEBHOOK_SECRET=whsec_test_00000000000000000000000000 # Webhook signing secret (use real value in production)
|
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
|
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_ |
|
| `ADMINS` | Comma-separated admin pubkeys (hex) | _optional_ |
|
||||||
| `ALLOW_ORIGINS` | Comma-separated CORS origins. If empty, CORS is permissive. | _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_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 |
|
| `RELAY_DOMAIN` | Base domain appended to relay subdomains | empty |
|
||||||
| `LIVEKIT_URL` | LiveKit URL sent to zooid when relay livekit is enabled | _optional_ |
|
| `LIVEKIT_URL` | LiveKit URL sent to zooid when relay livekit is enabled | _optional_ |
|
||||||
| `LIVEKIT_API_KEY` | LiveKit API key sent to zooid | _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::billing::Billing;
|
||||||
use crate::command::Command;
|
use crate::command::Command;
|
||||||
|
use crate::env::Env;
|
||||||
use crate::infra::Infra;
|
use crate::infra::Infra;
|
||||||
use crate::models::{
|
use crate::models::{
|
||||||
RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay, Tenant,
|
RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay, Tenant,
|
||||||
@@ -24,8 +25,7 @@ use axum::body::Bytes;
|
|||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct Api {
|
pub struct Api {
|
||||||
host: String,
|
env: Env,
|
||||||
admins: Vec<String>,
|
|
||||||
query: Query,
|
query: Query,
|
||||||
command: Command,
|
command: Command,
|
||||||
billing: Billing,
|
billing: Billing,
|
||||||
@@ -120,17 +120,15 @@ fn map_invoice_lookup_error(error: InvoiceLookupError) -> ApiError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Api {
|
impl Api {
|
||||||
pub fn new(query: Query, command: Command, billing: Billing, infra: Infra) -> Self {
|
pub fn new(
|
||||||
let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
|
query: Query,
|
||||||
let admins = std::env::var("ADMINS")
|
command: Command,
|
||||||
.unwrap_or_default()
|
billing: Billing,
|
||||||
.split(',')
|
infra: Infra,
|
||||||
.map(|v| v.trim().to_lowercase())
|
env: &Env,
|
||||||
.filter(|v| !v.is_empty())
|
) -> Self {
|
||||||
.collect();
|
|
||||||
Self {
|
Self {
|
||||||
host,
|
env: env.clone(),
|
||||||
admins,
|
|
||||||
query,
|
query,
|
||||||
command,
|
command,
|
||||||
billing,
|
billing,
|
||||||
@@ -218,7 +216,7 @@ impl Api {
|
|||||||
// Intentional session-style variant of NIP-98 for Caravel API auth.
|
// Intentional session-style variant of NIP-98 for Caravel API auth.
|
||||||
// We validate signer identity plus host affinity, and do not bind to exact
|
// We validate signer identity plus host affinity, and do not bind to exact
|
||||||
// request URL/method or maintain replay state here.
|
// 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!(
|
return Err(ApiError::Unauthorized(anyhow!(
|
||||||
"authorization host mismatch"
|
"authorization host mismatch"
|
||||||
)));
|
)));
|
||||||
@@ -228,7 +226,12 @@ impl Api {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn require_admin(&self, authorized_pubkey: &str) -> std::result::Result<(), ApiError> {
|
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(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
Err(ApiError::Forbidden("admin required"))
|
Err(ApiError::Forbidden("admin required"))
|
||||||
@@ -240,7 +243,12 @@ impl Api {
|
|||||||
authorized_pubkey: &str,
|
authorized_pubkey: &str,
|
||||||
tenant_pubkey: &str,
|
tenant_pubkey: &str,
|
||||||
) -> std::result::Result<(), ApiError> {
|
) -> 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(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
@@ -267,7 +275,10 @@ impl Api {
|
|||||||
fn prepare_relay(&self, mut relay: Relay) -> std::result::Result<Relay, RelayValidationError> {
|
fn prepare_relay(&self, mut relay: Relay) -> std::result::Result<Relay, RelayValidationError> {
|
||||||
validate_subdomain_label(&relay.subdomain)?;
|
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 {
|
if !plan.blossom && relay.blossom_enabled == 1 {
|
||||||
return Err(RelayValidationError::PremiumFeature);
|
return Err(RelayValidationError::PremiumFeature);
|
||||||
@@ -524,8 +535,8 @@ async fn list_tenants(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_plans() -> Response {
|
async fn list_plans(State(state): State<AppState>) -> Response {
|
||||||
ok(StatusCode::OK, Query::list_plans())
|
ok(StatusCode::OK, state.api.query.list_plans())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_identity(
|
async fn get_identity(
|
||||||
@@ -533,7 +544,12 @@ async fn get_identity(
|
|||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
) -> std::result::Result<Response, ApiError> {
|
) -> std::result::Result<Response, ApiError> {
|
||||||
let pubkey = state.api.extract_auth_pubkey(&headers)?;
|
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 }))
|
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 {
|
async fn get_plan(State(state): State<AppState>, Path(id): Path<String>) -> Response {
|
||||||
match Query::get_plan(&id) {
|
match state.api.query.get_plan(&id) {
|
||||||
Some(plan) => ok(StatusCode::OK, plan),
|
Some(plan) => ok(StatusCode::OK, plan),
|
||||||
None => err(StatusCode::NOT_FOUND, "not-found", "plan not found"),
|
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);
|
.is_some_and(|requested| requested != current_plan);
|
||||||
|
|
||||||
if plan_changed {
|
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 {
|
if let Some(limit) = selected_plan.members {
|
||||||
let current_members = match state.api.fetch_relay_members(&relay).await {
|
let current_members = match state.api.fetch_relay_members(&relay).await {
|
||||||
Ok(members) => members.len() as i64,
|
Ok(members) => members.len() as i64,
|
||||||
@@ -1147,8 +1167,11 @@ async fn update_tenant(
|
|||||||
if nwc_url.is_empty() {
|
if nwc_url.is_empty() {
|
||||||
tenant.nwc_url = String::new();
|
tenant.nwc_url = String::new();
|
||||||
} else {
|
} else {
|
||||||
tenant.nwc_url =
|
tenant.nwc_url = state
|
||||||
crate::cipher::encrypt(&nwc_url).map_err(|e| ApiError::Internal(e.to_string()))?;
|
.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::bitcoin;
|
||||||
use crate::command::Command;
|
use crate::command::Command;
|
||||||
|
use crate::env::Env;
|
||||||
use crate::models::{Activity, RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, Relay};
|
use crate::models::{Activity, RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, Relay};
|
||||||
use crate::query::Query;
|
use crate::query::Query;
|
||||||
use crate::robot::Robot;
|
use crate::robot::Robot;
|
||||||
@@ -24,16 +25,21 @@ enum NwcInvoicePaymentOutcome {
|
|||||||
pub struct Billing {
|
pub struct Billing {
|
||||||
stripe: Stripe,
|
stripe: Stripe,
|
||||||
wallet: Wallet,
|
wallet: Wallet,
|
||||||
|
env: Env,
|
||||||
query: Query,
|
query: Query,
|
||||||
command: Command,
|
command: Command,
|
||||||
robot: Robot,
|
robot: Robot,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Billing {
|
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 {
|
Self {
|
||||||
stripe: Stripe::from_env(),
|
stripe: Stripe::new(
|
||||||
wallet: Wallet::from_url(&std::env::var("NWC_URL").unwrap_or_default()).expect("invalid NWC_URL"),
|
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,
|
query,
|
||||||
command,
|
command,
|
||||||
robot,
|
robot,
|
||||||
@@ -136,7 +142,7 @@ impl Billing {
|
|||||||
if relay.status != RELAY_STATUS_ACTIVE {
|
if relay.status != RELAY_STATUS_ACTIVE {
|
||||||
continue;
|
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");
|
tracing::warn!(relay = %relay.id, plan = %relay.plan, "active relay on unknown plan; not billed");
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
@@ -370,7 +376,7 @@ impl Billing {
|
|||||||
|
|
||||||
// 1. NWC auto-pay: if the tenant has a nwc_url
|
// 1. NWC auto-pay: if the tenant has a nwc_url
|
||||||
if !tenant.nwc_url.is_empty() {
|
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
|
match self
|
||||||
.nwc_pay_invoice(
|
.nwc_pay_invoice(
|
||||||
invoice_id,
|
invoice_id,
|
||||||
@@ -725,7 +731,7 @@ impl Billing {
|
|||||||
return Ok(());
|
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
|
let invoices = self
|
||||||
.stripe
|
.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 std::time::Duration;
|
||||||
|
|
||||||
use crate::command::Command;
|
use crate::command::Command;
|
||||||
|
use crate::env::Env;
|
||||||
use crate::models::{Activity, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay};
|
use crate::models::{Activity, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay};
|
||||||
use crate::query::Query;
|
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_DELAY_SECS: u64 = 15 * 60;
|
||||||
const RELAY_SYNC_RETRY_MAX_ATTEMPTS: usize = 6;
|
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)]
|
#[derive(Clone)]
|
||||||
pub struct Infra {
|
pub struct Infra {
|
||||||
api_url: String,
|
env: Env,
|
||||||
relay_domain: String,
|
|
||||||
livekit_url: String,
|
|
||||||
livekit_api_key: String,
|
|
||||||
livekit_api_secret: String,
|
|
||||||
api_secret: String,
|
|
||||||
blossom_s3: Option<BlossomS3Sync>,
|
|
||||||
query: Query,
|
query: Query,
|
||||||
command: Command,
|
command: Command,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Infra {
|
impl Infra {
|
||||||
pub fn new(query: Query, command: Command) -> Result<Self> {
|
pub fn new(query: Query, command: Command, env: &Env) -> Self {
|
||||||
let api_url = std::env::var("ZOOID_API_URL").unwrap_or_default();
|
Self {
|
||||||
let relay_domain = std::env::var("RELAY_DOMAIN").unwrap_or_default();
|
env: env.clone(),
|
||||||
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,
|
|
||||||
query,
|
query,
|
||||||
command,
|
command,
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn start(self) {
|
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>> {
|
pub async fn list_relay_members(&self, relay_id: &str) -> Result<Vec<String>> {
|
||||||
let client = reqwest::Client::new();
|
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 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
|
let response = client
|
||||||
.get(&url)
|
.get(&url)
|
||||||
@@ -274,20 +198,20 @@ impl Infra {
|
|||||||
|
|
||||||
async fn sync_relay(&self, relay: &Relay, is_new: bool) -> Result<()> {
|
async fn sync_relay(&self, relay: &Relay, is_new: bool) -> Result<()> {
|
||||||
let client = reqwest::Client::new();
|
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()
|
relay.subdomain.clone()
|
||||||
} else {
|
} else {
|
||||||
format!("{}.{}", relay.subdomain, self.relay_domain)
|
format!("{}.{}", relay.subdomain, self.env.relay_domain)
|
||||||
};
|
};
|
||||||
|
|
||||||
let livekit = if relay.livekit_enabled == 1 {
|
let livekit = if relay.livekit_enabled == 1 {
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"server_url": self.livekit_url,
|
"server_url": self.env.livekit_url,
|
||||||
"api_key": self.livekit_api_key,
|
"api_key": self.env.livekit_api_key,
|
||||||
"api_secret": self.livekit_api_secret,
|
"api_secret": self.env.livekit_api_secret,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
serde_json::json!({ "enabled": false })
|
serde_json::json!({ "enabled": false })
|
||||||
@@ -298,12 +222,13 @@ impl Infra {
|
|||||||
host,
|
host,
|
||||||
livekit,
|
livekit,
|
||||||
is_new.then(|| Keys::generate().secret_key().to_secret_hex()),
|
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 url = format!("{}/relay/{}", base, relay.id);
|
||||||
let auth = self
|
let auth = self
|
||||||
.nip98_auth(&url, zooid_sync_http_method(is_new))
|
.env
|
||||||
|
.make_auth(&url, zooid_sync_http_method(is_new))
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let request = if is_new {
|
let request = if is_new {
|
||||||
@@ -368,9 +293,9 @@ fn relay_sync_body(
|
|||||||
host: String,
|
host: String,
|
||||||
livekit: serde_json::Value,
|
livekit: serde_json::Value,
|
||||||
secret: Option<String>,
|
secret: Option<String>,
|
||||||
blossom_s3: Option<&BlossomS3Sync>,
|
env: &Env,
|
||||||
) -> serde_json::Value {
|
) -> serde_json::Value {
|
||||||
let blossom = blossom_sync_json(relay, blossom_s3);
|
let blossom = blossom_sync_json(relay, env);
|
||||||
|
|
||||||
let mut body = serde_json::json!({
|
let mut body = serde_json::json!({
|
||||||
"host": host,
|
"host": host,
|
||||||
@@ -405,38 +330,35 @@ fn relay_sync_body(
|
|||||||
body
|
body
|
||||||
}
|
}
|
||||||
|
|
||||||
fn blossom_sync_json(relay: &Relay, blossom_s3: Option<&BlossomS3Sync>) -> serde_json::Value {
|
/// Relay sync sets `key_prefix` to the relay schema so each relay gets its own
|
||||||
let enabled = relay.blossom_enabled == 1;
|
/// blob namespace within the shared bucket.
|
||||||
if !enabled {
|
fn blossom_sync_json(relay: &Relay, env: &Env) -> serde_json::Value {
|
||||||
|
if relay.blossom_enabled != 1 {
|
||||||
return serde_json::json!({ "enabled": false });
|
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();
|
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(
|
s3_obj.insert(
|
||||||
"endpoint".to_string(),
|
"endpoint".to_string(),
|
||||||
serde_json::Value::String(s3.endpoint.clone()),
|
serde_json::Value::String(env.blossom_s3_endpoint.clone()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
s3_obj.insert(
|
s3_obj.insert(
|
||||||
"region".to_string(),
|
"region".to_string(),
|
||||||
serde_json::Value::String(s3.region.clone()),
|
serde_json::Value::String(env.blossom_s3_region.clone()),
|
||||||
);
|
);
|
||||||
s3_obj.insert(
|
s3_obj.insert(
|
||||||
"bucket".to_string(),
|
"bucket".to_string(),
|
||||||
serde_json::Value::String(s3.bucket.clone()),
|
serde_json::Value::String(env.blossom_s3_bucket.clone()),
|
||||||
);
|
);
|
||||||
s3_obj.insert(
|
s3_obj.insert(
|
||||||
"access_key".to_string(),
|
"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(
|
s3_obj.insert(
|
||||||
"secret_key".to_string(),
|
"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(
|
s3_obj.insert(
|
||||||
"key_prefix".to_string(),
|
"key_prefix".to_string(),
|
||||||
|
|||||||
+1
-1
@@ -1,8 +1,8 @@
|
|||||||
pub mod api;
|
pub mod api;
|
||||||
pub mod billing;
|
pub mod billing;
|
||||||
pub mod bitcoin;
|
pub mod bitcoin;
|
||||||
pub mod cipher;
|
|
||||||
pub mod command;
|
pub mod command;
|
||||||
|
pub mod env;
|
||||||
pub mod infra;
|
pub mod infra;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
pub mod pool;
|
pub mod pool;
|
||||||
|
|||||||
+15
-22
@@ -1,8 +1,8 @@
|
|||||||
mod api;
|
mod api;
|
||||||
mod billing;
|
mod billing;
|
||||||
mod bitcoin;
|
mod bitcoin;
|
||||||
mod cipher;
|
|
||||||
mod command;
|
mod command;
|
||||||
|
mod env;
|
||||||
mod infra;
|
mod infra;
|
||||||
mod models;
|
mod models;
|
||||||
mod pool;
|
mod pool;
|
||||||
@@ -19,6 +19,7 @@ use tower_http::cors::{AllowOrigin, CorsLayer};
|
|||||||
use crate::api::Api;
|
use crate::api::Api;
|
||||||
use crate::billing::Billing;
|
use crate::billing::Billing;
|
||||||
use crate::command::Command;
|
use crate::command::Command;
|
||||||
|
use crate::env::Env;
|
||||||
use crate::infra::Infra;
|
use crate::infra::Infra;
|
||||||
use crate::query::Query;
|
use crate::query::Query;
|
||||||
use crate::robot::Robot;
|
use crate::robot::Robot;
|
||||||
@@ -32,30 +33,21 @@ async fn main() -> Result<()> {
|
|||||||
.with(tracing_subscriber::fmt::layer())
|
.with(tracing_subscriber::fmt::layer())
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
let pool = pool::create_pool().await?;
|
let env = Env::load();
|
||||||
let robot = Robot::new().await?;
|
|
||||||
let query = Query::new(pool.clone());
|
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 command = Command::new(pool);
|
||||||
let billing = Billing::new(query.clone(), command.clone(), robot.clone());
|
let billing = Billing::new(query.clone(), command.clone(), robot.clone(), &env);
|
||||||
let infra = Infra::new(query.clone(), command.clone())?;
|
let infra = Infra::new(query.clone(), command.clone(), &env);
|
||||||
let api = Api::new(query, command, billing.clone(), infra.clone());
|
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 cors = if env.server_allow_origins.is_empty() {
|
||||||
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() {
|
|
||||||
CorsLayer::permissive()
|
CorsLayer::permissive()
|
||||||
} else {
|
} else {
|
||||||
let parsed = origins
|
let parsed = env
|
||||||
|
.server_allow_origins
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|o| o.parse::<axum::http::HeaderValue>().ok())
|
.filter_map(|o| o.parse::<axum::http::HeaderValue>().ok())
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
@@ -72,7 +64,8 @@ async fn main() -> Result<()> {
|
|||||||
billing.start().await;
|
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?;
|
axum::serve(listener, app).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-4
@@ -7,10 +7,8 @@ use sqlx::{
|
|||||||
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
|
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub async fn create_pool() -> Result<SqlitePool> {
|
pub async fn create_pool(database_url: &str) -> Result<SqlitePool> {
|
||||||
let raw_database_url = std::env::var("DATABASE_URL")
|
let database_url = normalize_sqlite_url(database_url);
|
||||||
.unwrap_or_else(|_| format!("sqlite://{}/data/caravel.db", env!("CARGO_MANIFEST_DIR")));
|
|
||||||
let database_url = normalize_sqlite_url(&raw_database_url);
|
|
||||||
|
|
||||||
if let Some(path) = database_url.strip_prefix("sqlite://")
|
if let Some(path) = database_url.strip_prefix("sqlite://")
|
||||||
&& !path.is_empty()
|
&& !path.is_empty()
|
||||||
|
|||||||
+15
-10
@@ -1,16 +1,21 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
use crate::env::Env;
|
||||||
use crate::models::{Activity, Plan, Relay, Tenant};
|
use crate::models::{Activity, Plan, Relay, Tenant};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct Query {
|
pub struct Query {
|
||||||
pool: SqlitePool,
|
pool: SqlitePool,
|
||||||
|
env: Env,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Query {
|
impl Query {
|
||||||
pub fn new(pool: SqlitePool) -> Self {
|
pub fn new(pool: SqlitePool, env: &Env) -> Self {
|
||||||
Self { pool }
|
Self {
|
||||||
|
pool,
|
||||||
|
env: env.clone(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list_tenants(&self) -> Result<Vec<Tenant>> {
|
pub async fn list_tenants(&self) -> Result<Vec<Tenant>> {
|
||||||
@@ -36,7 +41,7 @@ impl Query {
|
|||||||
Ok(row)
|
Ok(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn list_plans() -> Vec<Plan> {
|
pub fn list_plans(&self) -> Vec<Plan> {
|
||||||
vec![
|
vec![
|
||||||
Plan {
|
Plan {
|
||||||
id: "free".to_string(),
|
id: "free".to_string(),
|
||||||
@@ -54,7 +59,7 @@ impl Query {
|
|||||||
members: Some(100),
|
members: Some(100),
|
||||||
blossom: true,
|
blossom: true,
|
||||||
livekit: 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 {
|
Plan {
|
||||||
id: "growth".to_string(),
|
id: "growth".to_string(),
|
||||||
@@ -63,19 +68,19 @@ impl Query {
|
|||||||
members: None,
|
members: None,
|
||||||
blossom: true,
|
blossom: true,
|
||||||
livekit: 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> {
|
pub fn get_plan(&self, plan_id: &str) -> Option<Plan> {
|
||||||
Self::list_plans().into_iter().find(|p| p.id == plan_id)
|
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 {
|
pub fn is_paid_plan(plan_id: &str) -> bool {
|
||||||
Self::get_plan(plan_id)
|
matches!(plan_id, "basic" | "growth")
|
||||||
.map(|p| p.id != "free")
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list_relays(&self) -> Result<Vec<Relay>> {
|
pub async fn list_relays(&self) -> Result<Vec<Relay>> {
|
||||||
|
|||||||
+37
-132
@@ -5,15 +5,11 @@ use anyhow::{Result, anyhow};
|
|||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
use crate::env::Env;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct Robot {
|
pub struct Robot {
|
||||||
secret: String,
|
env: Env,
|
||||||
name: String,
|
|
||||||
description: String,
|
|
||||||
picture: String,
|
|
||||||
outbox_client: Client,
|
|
||||||
indexer_client: Client,
|
|
||||||
messaging_client: Client,
|
|
||||||
outbox_cache: std::sync::Arc<Mutex<HashMap<String, CacheEntry>>>,
|
outbox_cache: std::sync::Arc<Mutex<HashMap<String, CacheEntry>>>,
|
||||||
dm_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 {
|
impl Robot {
|
||||||
pub async fn new() -> Result<Self> {
|
pub async fn new(env: &Env) -> 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?;
|
|
||||||
|
|
||||||
let robot = Self {
|
let robot = Self {
|
||||||
secret,
|
env: env.clone(),
|
||||||
name,
|
|
||||||
description,
|
|
||||||
picture,
|
|
||||||
outbox_client,
|
|
||||||
indexer_client,
|
|
||||||
messaging_client,
|
|
||||||
outbox_cache: std::sync::Arc::new(Mutex::new(HashMap::new())),
|
outbox_cache: std::sync::Arc::new(Mutex::new(HashMap::new())),
|
||||||
dm_cache: std::sync::Arc::new(Mutex::new(HashMap::new())),
|
dm_cache: std::sync::Arc::new(Mutex::new(HashMap::new())),
|
||||||
};
|
};
|
||||||
|
|
||||||
robot
|
robot.publish_identity().await?;
|
||||||
.publish_identity(&outbox_relays, &messaging_relays)
|
|
||||||
.await?;
|
|
||||||
Ok(robot)
|
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(
|
async fn publish_identity(
|
||||||
&self,
|
&self,
|
||||||
outbox_relays: &[String],
|
|
||||||
messaging_relays: &[String],
|
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let mut metadata = Metadata::new();
|
let mut metadata = Metadata::new();
|
||||||
if !self.name.is_empty() {
|
if !self.env.robot_name.is_empty() {
|
||||||
metadata = metadata.name(&self.name);
|
metadata = metadata.name(&self.env.robot_name);
|
||||||
}
|
}
|
||||||
if !self.description.is_empty() {
|
if !self.env.robot_description.is_empty() {
|
||||||
metadata = metadata.about(&self.description);
|
metadata = metadata.about(&self.env.robot_description);
|
||||||
}
|
}
|
||||||
if !self.picture.is_empty() {
|
if !self.env.robot_picture.is_empty() {
|
||||||
metadata = metadata.picture(Url::parse(&self.picture)?);
|
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))
|
.send_event_builder(EventBuilder::metadata(&metadata))
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let outbox_tags = outbox_relays
|
let outbox_tags = self.env.robot_outbox_relays
|
||||||
.iter()
|
.iter()
|
||||||
.map(|r| Tag::parse(["r", r.as_str()]))
|
.map(|r| Tag::parse(["r", r.as_str()]))
|
||||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||||
self.outbox_client
|
outbox_client
|
||||||
.send_event_builder(EventBuilder::new(Kind::Custom(10002), "").tags(outbox_tags))
|
.send_event_builder(EventBuilder::new(Kind::Custom(10002), "").tags(outbox_tags))
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let messaging_tags = messaging_relays
|
let messaging_tags = self.env.robot_messaging_relays
|
||||||
.iter()
|
.iter()
|
||||||
.map(|r| Tag::parse(["relay", r.as_str()]))
|
.map(|r| Tag::parse(["relay", r.as_str()]))
|
||||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||||
self.indexer_client
|
indexer_client
|
||||||
.send_event_builder(EventBuilder::new(Kind::Custom(10050), "").tags(messaging_tags))
|
.send_event_builder(EventBuilder::new(Kind::Custom(10050), "").tags(messaging_tags))
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -123,14 +96,8 @@ impl Robot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let recipient_pubkey = PublicKey::parse(recipient)?;
|
let recipient_pubkey = PublicKey::parse(recipient)?;
|
||||||
let client = self.messaging_client.clone();
|
let client = self.make_client(&dm_relays).await?;
|
||||||
for relay in dm_relays {
|
client.send_private_msg(recipient_pubkey, message, []).await?;
|
||||||
let _ = client.add_relay(relay).await;
|
|
||||||
}
|
|
||||||
client.connect().await;
|
|
||||||
client
|
|
||||||
.send_private_msg(recipient_pubkey, message, [])
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,10 +108,8 @@ impl Robot {
|
|||||||
|
|
||||||
let pubkey = PublicKey::parse(recipient)?;
|
let pubkey = PublicKey::parse(recipient)?;
|
||||||
let filter = Filter::new().author(pubkey).kind(Kind::Custom(10002));
|
let filter = Filter::new().author(pubkey).kind(Kind::Custom(10002));
|
||||||
let events = self
|
let client = self.make_client(&self.env.robot_indexer_relays).await?;
|
||||||
.indexer_client
|
let events = client.fetch_events(filter, Duration::from_secs(5)).await?;
|
||||||
.fetch_events(filter, Duration::from_secs(5))
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let mut relays = Vec::new();
|
let mut relays = Vec::new();
|
||||||
if let Some(event) = events.into_iter().max_by_key(|e| e.created_at) {
|
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> {
|
pub async fn fetch_nostr_name(&self, pubkey: &str) -> Option<String> {
|
||||||
let pubkey = PublicKey::parse(pubkey).ok()?;
|
let pubkey = PublicKey::parse(pubkey).ok()?;
|
||||||
let filter = Filter::new().author(pubkey).kind(Kind::Metadata).limit(1);
|
let filter = Filter::new().author(pubkey).kind(Kind::Metadata).limit(1);
|
||||||
let events = self
|
let client = self.make_client(&self.env.robot_indexer_relays).await.ok()?;
|
||||||
.indexer_client
|
let events = client.fetch_events(filter, Duration::from_secs(5)).await.ok()?;
|
||||||
.fetch_events(filter, Duration::from_secs(5))
|
|
||||||
.await
|
|
||||||
.ok()?;
|
|
||||||
let event = events.into_iter().max_by_key(|e| e.created_at)?;
|
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 content: serde_json::Value = serde_json::from_str(&event.content).ok()?;
|
||||||
let name = content
|
let name = content
|
||||||
@@ -189,13 +151,7 @@ impl Robot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let pubkey = PublicKey::parse(recipient)?;
|
let pubkey = PublicKey::parse(recipient)?;
|
||||||
let keys = Keys::parse(&self.secret)?;
|
let client = self.make_client(outbox_relays).await?;
|
||||||
let client = Client::new(keys);
|
|
||||||
for relay in outbox_relays {
|
|
||||||
client.add_relay(relay).await?;
|
|
||||||
}
|
|
||||||
client.connect().await;
|
|
||||||
|
|
||||||
let filter = Filter::new().author(pubkey).kind(Kind::Custom(10050));
|
let filter = Filter::new().author(pubkey).kind(Kind::Custom(10050));
|
||||||
let events = client.fetch_events(filter, Duration::from_secs(5)).await?;
|
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(
|
async fn get_cached(
|
||||||
cache: &std::sync::Arc<Mutex<HashMap<String, CacheEntry>>>,
|
cache: &std::sync::Arc<Mutex<HashMap<String, CacheEntry>>>,
|
||||||
key: &str,
|
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 {
|
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 {
|
pub fn new(secret_key: String, webhook_secret: String) -> Self {
|
||||||
Self {
|
Self {
|
||||||
secret_key,
|
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.
|
- [ ] 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
|
- [ ] Send a payment link instead of an invoice so we can generate/pay on the fly
|
||||||
|
|||||||
Reference in New Issue
Block a user