diff --git a/.fdignore b/.fdignore new file mode 100644 index 0000000..6415016 --- /dev/null +++ b/.fdignore @@ -0,0 +1 @@ +ref diff --git a/backend/Cargo.toml b/backend/Cargo.toml new file mode 100644 index 0000000..f8b8bea --- /dev/null +++ b/backend/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "backend" +version = "0.1.0" +edition = "2024" + +[dependencies] +anyhow = "1.0" +axum = "0.7" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "sqlite", "macros"] } +tokio = { version = "1.36", features = ["full"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +nostr-sdk = "0.39" +uuid = { version = "1.7", features = ["v4"] } +chrono = { version = "0.4", features = ["serde"] } +tower-http = { version = "0.5", features = ["cors"] } diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..55a23ca --- /dev/null +++ b/backend/README.md @@ -0,0 +1,96 @@ +# Backend + +Rust backend for the Nostr relay hosting platform. This service manages tenants, relays, invoices, and billing, and provisions relays by calling the zooid HTTP API. It authenticates requests using NIP-98. + +## Tech Stack + +- Rust (Edition 2024) +- Axum (HTTP server) +- SQLx + SQLite (persistence) +- Nostr SDK (NIP-98 verification) + +## Layout + +``` +backend/ + migrations/ # SQL migrations + src/ + auth.rs # NIP-98 verification helper + config.rs # Env-based configuration + db.rs # SQLite pool + migrations + main.rs # Axum server entrypoint + models.rs # Data models + repo.rs # Data access layer +``` + +## Configuration + +Environment variables: + +| Variable | Description | Default | +|---|---|---| +| `DATABASE_URL` | SQLite database URL | `sqlite://data/hosting.db` | +| `HOST` | Bind host | `127.0.0.1` | +| `PORT` | Bind port | `3000` | + +The database directory is created automatically if it doesn’t exist. + +## Database Schema + +Created via `migrations/0001_init.sql`: + +- `tenants` +- `relays` +- `invoices` +- `invoice_items` + +## Running + +```bash +cd backend +cargo run +``` + +Health check: + +``` +GET /healthz +``` + +## NIP-98 Authentication + +NIP-98 verification is implemented in `auth.rs` using the Rust Nostr SDK. It verifies: + +- Authorization header format +- Event signature and kind +- URL + HTTP method tags +- Timestamp validity + +This is ready to be used by API routes. + +## API Routes + +Tenant routes (all require NIP-98 auth; pubkey is inferred from the token): + +- `GET /tenant` — fetch (or create) tenant +- `GET /tenant/relays` — list tenant relays +- `POST /tenant/relays` — create relay +- `GET /tenant/relays/:id` — get relay +- `PUT /tenant/relays/:id` — update relay +- `DELETE /tenant/relays/:id` — deactivate relay +- `GET /tenant/invoices` — list invoices + +Admin routes (all require NIP-98 auth; pubkey must be in `HOSTING_ADMIN_PUBKEYS`): + +- `GET /admin/tenants` — list tenants +- `GET /admin/tenants/:pubkey` — tenant detail (includes relays) +- `PUT /admin/tenants/:pubkey` — update tenant status +- `GET /admin/relays` — list relays +- `GET /admin/relays/:id` — get relay +- `PUT /admin/relays/:id` — update relay +- `DELETE /admin/relays/:id` — deactivate relay + +## Next Steps + +- Add relay provisioning worker (zooid API calls) +- Add invoice generation and billing jobs diff --git a/backend/migrations/0001_init.sql b/backend/migrations/0001_init.sql new file mode 100644 index 0000000..a287e33 --- /dev/null +++ b/backend/migrations/0001_init.sql @@ -0,0 +1,37 @@ +CREATE TABLE IF NOT EXISTS tenants ( + pubkey TEXT PRIMARY KEY, + status TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS relays ( + id TEXT PRIMARY KEY, + tenant TEXT NOT NULL, + name TEXT NOT NULL, + subdomain TEXT NOT NULL, + schema TEXT NOT NULL, + description TEXT NOT NULL, + plan TEXT NOT NULL, + status TEXT NOT NULL, + FOREIGN KEY (tenant) REFERENCES tenants(pubkey) +); + +CREATE TABLE IF NOT EXISTS invoices ( + id TEXT PRIMARY KEY, + tenant TEXT NOT NULL, + amount INTEGER NOT NULL, + status TEXT NOT NULL, + created_at TEXT NOT NULL, + invoice TEXT NOT NULL, + FOREIGN KEY (tenant) REFERENCES tenants(pubkey) +); + +CREATE TABLE IF NOT EXISTS invoice_items ( + id TEXT PRIMARY KEY, + invoice TEXT NOT NULL, + relay TEXT NOT NULL, + amount INTEGER NOT NULL, + period_start TEXT NOT NULL, + period_end TEXT NOT NULL, + FOREIGN KEY (invoice) REFERENCES invoices(id), + FOREIGN KEY (relay) REFERENCES relays(id) +); diff --git a/backend/src/api.rs b/backend/src/api.rs new file mode 100644 index 0000000..0662c1e --- /dev/null +++ b/backend/src/api.rs @@ -0,0 +1,547 @@ +use std::sync::Arc; + +use anyhow::{anyhow, Result}; +use axum::{ + extract::{Path, State}, + http::{HeaderMap, Method, StatusCode, Uri}, + response::{IntoResponse, Response}, + routing::{delete, get, post, put}, + Json, Router, +}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::auth::verify_nip98; +use crate::models::{NewRelay, NewTenant, Relay, UpdateRelay}; +use crate::repo::Repo; + +#[derive(Clone)] +pub struct AppState { + pub repo: Repo, + pub admin_pubkeys: Arc>, +} + +pub fn router(state: AppState) -> Router { + let tenant_routes = Router::new() + .route("/tenant", get(get_tenant)) + .route("/tenant/relays", get(list_tenant_relays).post(create_tenant_relay)) + .route( + "/tenant/relays/:id", + get(get_tenant_relay).put(update_tenant_relay).delete(deactivate_tenant_relay), + ) + .route("/tenant/invoices", get(list_tenant_invoices)); + + let admin_routes = Router::new() + .route("/admin/tenants", get(admin_list_tenants)) + .route( + "/admin/tenants/:pubkey", + get(admin_get_tenant).put(admin_update_tenant_status), + ) + .route("/admin/relays", get(admin_list_relays)) + .route( + "/admin/relays/:id", + get(admin_get_relay).put(admin_update_relay).delete(admin_deactivate_relay), + ); + + Router::new() + .merge(tenant_routes) + .merge(admin_routes) + .with_state(state) +} + +#[derive(Debug, Serialize)] +struct ApiError { + error: String, +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + (StatusCode::BAD_REQUEST, Json(self)).into_response() + } +} + +fn unauthorized() -> Response { + (StatusCode::UNAUTHORIZED, Json(ApiError { error: "unauthorized".into() })) + .into_response() +} + +fn forbidden() -> Response { + (StatusCode::FORBIDDEN, Json(ApiError { error: "forbidden".into() })).into_response() +} + +fn not_found() -> Response { + (StatusCode::NOT_FOUND, Json(ApiError { error: "not found".into() })).into_response() +} + +fn extract_auth_pubkey(headers: &HeaderMap, method: &Method, uri: &Uri) -> Result { + let auth_header = headers + .get(axum::http::header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| anyhow!("missing authorization header"))?; + + let host = headers + .get(axum::http::header::HOST) + .and_then(|v| v.to_str().ok()) + .unwrap_or_default(); + + let scheme = headers + .get("x-forwarded-proto") + .and_then(|v| v.to_str().ok()) + .unwrap_or("http"); + + let path = uri.path_and_query().map(|v| v.as_str()).unwrap_or(uri.path()); + let url = format!("{}://{}{}", scheme, host, path); + let pubkey = verify_nip98(auth_header, &url, method.as_str())?; + Ok(pubkey.to_string()) +} + +async fn get_tenant( + State(state): State, + headers: HeaderMap, + method: Method, + uri: Uri, +) -> Response { + let pubkey = match extract_auth_pubkey(&headers, &method, &uri) { + Ok(pubkey) => pubkey, + Err(_) => return unauthorized(), + }; + + match state.repo.get_tenant(&pubkey).await { + Ok(Some(tenant)) => (StatusCode::OK, Json(tenant)).into_response(), + Ok(None) => { + let tenant = NewTenant { + pubkey: pubkey.clone(), + status: "active".to_string(), + }; + if state.repo.create_tenant(&tenant).await.is_ok() { + (StatusCode::OK, Json(tenant)).into_response() + } else { + (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: "failed to create tenant".into() })) + .into_response() + } + } + Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: "failed to load tenant".into() })).into_response(), + } +} + +async fn list_tenant_relays( + State(state): State, + headers: HeaderMap, + method: Method, + uri: Uri, +) -> Response { + let pubkey = match extract_auth_pubkey(&headers, &method, &uri) { + Ok(pubkey) => pubkey, + Err(_) => return unauthorized(), + }; + + match state.repo.list_relays_by_tenant(&pubkey).await { + Ok(relays) => (StatusCode::OK, Json(relays)).into_response(), + Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: "failed to load relays".into() })).into_response(), + } +} + +#[derive(Debug, Deserialize)] +struct CreateRelayRequest { + name: String, + subdomain: String, + description: String, + plan: String, +} + +async fn create_tenant_relay( + State(state): State, + headers: HeaderMap, + method: Method, + uri: Uri, + Json(payload): Json, +) -> Response { + let pubkey = match extract_auth_pubkey(&headers, &method, &uri) { + Ok(pubkey) => pubkey, + Err(_) => return unauthorized(), + }; + + let tenant = NewTenant { + pubkey: pubkey.clone(), + status: "active".to_string(), + }; + + if let Err(_) = state.repo.create_tenant_if_missing(&tenant).await { + return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: "failed to ensure tenant".into() })) + .into_response(); + } + + let relay = NewRelay { + id: Uuid::new_v4().to_string(), + tenant: pubkey.clone(), + name: payload.name, + subdomain: payload.subdomain.clone(), + schema: payload.subdomain.replace('-', "_"), + description: payload.description, + plan: payload.plan, + status: "pending".to_string(), + }; + + if let Err(_) = state.repo.create_relay(&relay).await { + return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: "failed to create relay".into() })).into_response(); + } + + spawn_provisioning_worker(relay.clone()); + + (StatusCode::CREATED, Json(relay)).into_response() +} + +async fn get_tenant_relay( + State(state): State, + headers: HeaderMap, + method: Method, + uri: Uri, + Path(id): Path, +) -> Response { + let pubkey = match extract_auth_pubkey(&headers, &method, &uri) { + Ok(pubkey) => pubkey, + Err(_) => return unauthorized(), + }; + + match state.repo.get_relay(&id).await { + Ok(Some(relay)) if relay.tenant == pubkey => (StatusCode::OK, Json(relay)).into_response(), + Ok(Some(_)) => forbidden(), + Ok(None) => not_found(), + Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: "failed to load relay".into() })).into_response(), + } +} + +#[derive(Debug, Deserialize)] +struct UpdateRelayRequest { + name: String, + subdomain: String, + description: String, + plan: String, +} + +async fn update_tenant_relay( + State(state): State, + headers: HeaderMap, + method: Method, + uri: Uri, + Path(id): Path, + Json(payload): Json, +) -> Response { + let pubkey = match extract_auth_pubkey(&headers, &method, &uri) { + Ok(pubkey) => pubkey, + Err(_) => return unauthorized(), + }; + + let existing = match state.repo.get_relay(&id).await { + Ok(Some(relay)) => relay, + Ok(None) => return not_found(), + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: "failed to load relay".into() })).into_response(), + }; + + if existing.tenant != pubkey { + return forbidden(); + } + + let updated = UpdateRelay { + id: existing.id, + name: payload.name, + subdomain: payload.subdomain.clone(), + schema: payload.subdomain.replace('-', "_"), + description: payload.description, + plan: payload.plan, + status: existing.status, + }; + + if let Err(_) = state.repo.update_relay(&updated).await { + return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: "failed to update relay".into() })).into_response(); + } + + (StatusCode::OK, Json(updated)).into_response() +} + +async fn deactivate_tenant_relay( + State(state): State, + headers: HeaderMap, + method: Method, + uri: Uri, + Path(id): Path, +) -> Response { + let pubkey = match extract_auth_pubkey(&headers, &method, &uri) { + Ok(pubkey) => pubkey, + Err(_) => return unauthorized(), + }; + + let existing = match state.repo.get_relay(&id).await { + Ok(Some(relay)) => relay, + Ok(None) => return not_found(), + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: "failed to load relay".into() })).into_response(), + }; + + if existing.tenant != pubkey { + return forbidden(); + } + + let updated = UpdateRelay { + id: existing.id, + name: existing.name, + subdomain: existing.subdomain.clone(), + schema: existing.subdomain.replace('-', "_"), + description: existing.description, + plan: existing.plan, + status: "deactivated".to_string(), + }; + + if let Err(_) = state.repo.update_relay(&updated).await { + return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: "failed to deactivate relay".into() })).into_response(); + } + + (StatusCode::OK, Json(updated)).into_response() +} + +async fn list_tenant_invoices( + State(state): State, + headers: HeaderMap, + method: Method, + uri: Uri, +) -> Response { + let pubkey = match extract_auth_pubkey(&headers, &method, &uri) { + Ok(pubkey) => pubkey, + Err(_) => return unauthorized(), + }; + + match state.repo.list_invoices_by_tenant(&pubkey).await { + Ok(invoices) => (StatusCode::OK, Json(invoices)).into_response(), + Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: "failed to load invoices".into() })).into_response(), + } +} + +async fn admin_list_tenants( + State(state): State, + headers: HeaderMap, + method: Method, + uri: Uri, +) -> Response { + let pubkey = match extract_auth_pubkey(&headers, &method, &uri) { + Ok(pubkey) => pubkey, + Err(_) => return unauthorized(), + }; + + if !state.admin_pubkeys.contains(&pubkey) { + return forbidden(); + } + + match state.repo.list_tenants().await { + Ok(tenants) => (StatusCode::OK, Json(tenants)).into_response(), + Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: "failed to load tenants".into() })).into_response(), + } +} + +async fn admin_get_tenant( + State(state): State, + headers: HeaderMap, + method: Method, + uri: Uri, + Path(pubkey): Path, +) -> Response { + let admin = match extract_auth_pubkey(&headers, &method, &uri) { + Ok(pubkey) => pubkey, + Err(_) => return unauthorized(), + }; + + if !state.admin_pubkeys.contains(&admin) { + return forbidden(); + } + + let tenant = match state.repo.get_tenant(&pubkey).await { + Ok(Some(tenant)) => tenant, + Ok(None) => return not_found(), + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: "failed to load tenant".into() })).into_response(), + }; + + let relays = match state.repo.list_relays_by_tenant(&pubkey).await { + Ok(relays) => relays, + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: "failed to load relays".into() })).into_response(), + }; + + #[derive(Serialize)] + struct TenantDetail { + tenant: crate::models::Tenant, + relays: Vec, + } + + (StatusCode::OK, Json(TenantDetail { tenant, relays })).into_response() +} + +#[derive(Debug, Deserialize)] +struct UpdateTenantStatusRequest { + status: String, +} + +async fn admin_update_tenant_status( + State(state): State, + headers: HeaderMap, + method: Method, + uri: Uri, + Path(pubkey): Path, + Json(payload): Json, +) -> Response { + let admin = match extract_auth_pubkey(&headers, &method, &uri) { + Ok(pubkey) => pubkey, + Err(_) => return unauthorized(), + }; + + if !state.admin_pubkeys.contains(&admin) { + return forbidden(); + } + + let tenant = match state.repo.get_tenant(&pubkey).await { + Ok(Some(tenant)) => tenant, + Ok(None) => return not_found(), + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: "failed to load tenant".into() })).into_response(), + }; + + if let Err(_) = state + .repo + .update_tenant_status(&tenant.pubkey, &payload.status) + .await + { + return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: "failed to update tenant".into() })) + .into_response(); + } + + let updated = NewTenant { + pubkey: tenant.pubkey, + status: payload.status, + }; + + (StatusCode::OK, Json(updated)).into_response() +} + +async fn admin_list_relays( + State(state): State, + headers: HeaderMap, + method: Method, + uri: Uri, +) -> Response { + let admin = match extract_auth_pubkey(&headers, &method, &uri) { + Ok(pubkey) => pubkey, + Err(_) => return unauthorized(), + }; + + if !state.admin_pubkeys.contains(&admin) { + return forbidden(); + } + + match state.repo.list_relays().await { + Ok(relays) => (StatusCode::OK, Json(relays)).into_response(), + Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: "failed to load relays".into() })).into_response(), + } +} + +async fn admin_get_relay( + State(state): State, + headers: HeaderMap, + method: Method, + uri: Uri, + Path(id): Path, +) -> Response { + let admin = match extract_auth_pubkey(&headers, &method, &uri) { + Ok(pubkey) => pubkey, + Err(_) => return unauthorized(), + }; + + if !state.admin_pubkeys.contains(&admin) { + return forbidden(); + } + + match state.repo.get_relay(&id).await { + Ok(Some(relay)) => (StatusCode::OK, Json(relay)).into_response(), + Ok(None) => not_found(), + Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: "failed to load relay".into() })).into_response(), + } +} + +async fn admin_update_relay( + State(state): State, + headers: HeaderMap, + method: Method, + uri: Uri, + Path(id): Path, + Json(payload): Json, +) -> Response { + let admin = match extract_auth_pubkey(&headers, &method, &uri) { + Ok(pubkey) => pubkey, + Err(_) => return unauthorized(), + }; + + if !state.admin_pubkeys.contains(&admin) { + return forbidden(); + } + + let existing = match state.repo.get_relay(&id).await { + Ok(Some(relay)) => relay, + Ok(None) => return not_found(), + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: "failed to load relay".into() })).into_response(), + }; + + let updated = UpdateRelay { + id: existing.id, + name: payload.name, + subdomain: payload.subdomain.clone(), + schema: payload.subdomain.replace('-', "_"), + description: payload.description, + plan: payload.plan, + status: existing.status, + }; + + if let Err(_) = state.repo.update_relay(&updated).await { + return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: "failed to update relay".into() })).into_response(); + } + + (StatusCode::OK, Json(updated)).into_response() +} + +async fn admin_deactivate_relay( + State(state): State, + headers: HeaderMap, + method: Method, + uri: Uri, + Path(id): Path, +) -> Response { + let admin = match extract_auth_pubkey(&headers, &method, &uri) { + Ok(pubkey) => pubkey, + Err(_) => return unauthorized(), + }; + + if !state.admin_pubkeys.contains(&admin) { + return forbidden(); + } + + let existing = match state.repo.get_relay(&id).await { + Ok(Some(relay)) => relay, + Ok(None) => return not_found(), + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: "failed to load relay".into() })).into_response(), + }; + + let updated = UpdateRelay { + id: existing.id, + name: existing.name, + subdomain: existing.subdomain.clone(), + schema: existing.subdomain.replace('-', "_"), + description: existing.description, + plan: existing.plan, + status: "deactivated".to_string(), + }; + + if let Err(_) = state.repo.update_relay(&updated).await { + return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: "failed to deactivate relay".into() })).into_response(); + } + + (StatusCode::OK, Json(updated)).into_response() +} + +fn spawn_provisioning_worker(relay: NewRelay) { + tokio::spawn(async move { + tracing::info!(relay_id = relay.id, "provisioning worker started"); + }); +} diff --git a/backend/src/auth.rs b/backend/src/auth.rs new file mode 100644 index 0000000..34fe303 --- /dev/null +++ b/backend/src/auth.rs @@ -0,0 +1,15 @@ +use anyhow::{anyhow, Result}; +use std::str::FromStr; + +use nostr_sdk::nostr::key::PublicKey; +use nostr_sdk::nostr::nips::nip98::{self, HttpMethod}; +use nostr_sdk::nostr::types::time::Timestamp; +use nostr_sdk::nostr::types::url::Url; + +pub fn verify_nip98(auth_header: &str, url: &str, method: &str) -> Result { + let url = Url::parse(url).map_err(|e| anyhow!(e))?; + let method = HttpMethod::from_str(&method.to_uppercase()).map_err(|e| anyhow!(e))?; + let now = Timestamp::now(); + + nip98::verify_auth_header(auth_header, &url, method, now).map_err(|e| anyhow!(e)) +} diff --git a/backend/src/config.rs b/backend/src/config.rs new file mode 100644 index 0000000..751d3b2 --- /dev/null +++ b/backend/src/config.rs @@ -0,0 +1,34 @@ +use std::env; + +#[derive(Debug, Clone)] +pub struct Config { + pub database_url: String, + pub host: String, + pub port: u16, + pub admin_pubkeys: Vec, +} + +impl Config { + pub fn from_env() -> Self { + let database_url = + env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite://data/hosting.db".to_string()); + let host = env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); + let port = env::var("PORT") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(3000); + let admin_pubkeys = env::var("HOSTING_ADMIN_PUBKEYS") + .unwrap_or_default() + .split(',') + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + .collect::>(); + + Self { + database_url, + host, + port, + admin_pubkeys, + } + } +} diff --git a/backend/src/db.rs b/backend/src/db.rs new file mode 100644 index 0000000..674f398 --- /dev/null +++ b/backend/src/db.rs @@ -0,0 +1,15 @@ +use anyhow::Result; +use sqlx::{migrate::Migrator, sqlite::SqlitePoolOptions, SqlitePool}; + +static MIGRATOR: Migrator = sqlx::migrate!("./migrations"); + +pub async fn init_pool(database_url: &str) -> Result { + let pool = SqlitePoolOptions::new() + .max_connections(5) + .connect(database_url) + .await?; + + MIGRATOR.run(&pool).await?; + + Ok(pool) +} diff --git a/backend/src/main.rs b/backend/src/main.rs new file mode 100644 index 0000000..74d92fb --- /dev/null +++ b/backend/src/main.rs @@ -0,0 +1,63 @@ +mod api; +mod auth; +mod config; +mod db; +mod models; +mod repo; + +use std::net::SocketAddr; + +use anyhow::Result; +use axum::{routing::get, Router}; +use tokio::net::TcpListener; +use tower_http::cors::CorsLayer; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +use crate::config::Config; +use crate::db::init_pool; +use crate::repo::Repo; + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::registry() + .with(tracing_subscriber::EnvFilter::from_default_env()) + .with(tracing_subscriber::fmt::layer()) + .init(); + + let config = Config::from_env(); + ensure_sqlite_dir(&config.database_url)?; + + let pool = init_pool(&config.database_url).await?; + let repo = Repo::new(pool); + let state = api::AppState { + repo, + admin_pubkeys: std::sync::Arc::new(config.admin_pubkeys.clone()), + }; + + let app = Router::new() + .merge(api::router(state)) + .route("/healthz", get(healthz)) + .layer(CorsLayer::permissive()); + + let addr: SocketAddr = format!("{}:{}", config.host, config.port).parse()?; + let listener = TcpListener::bind(addr).await?; + tracing::info!("listening on {}", addr); + + axum::serve(listener, app).await?; + Ok(()) +} + +fn ensure_sqlite_dir(database_url: &str) -> Result<()> { + if let Some(path) = database_url.strip_prefix("sqlite://") { + if let Some(dir) = std::path::Path::new(path).parent() { + if !dir.as_os_str().is_empty() { + std::fs::create_dir_all(dir)?; + } + } + } + Ok(()) +} + +async fn healthz() -> &'static str { + "ok" +} diff --git a/backend/src/models.rs b/backend/src/models.rs new file mode 100644 index 0000000..5b2323a --- /dev/null +++ b/backend/src/models.rs @@ -0,0 +1,88 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct Tenant { + pub pubkey: String, + pub status: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NewTenant { + pub pubkey: String, + pub status: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct Relay { + pub id: String, + pub tenant: String, + pub name: String, + pub subdomain: String, + pub schema: String, + pub description: String, + pub plan: String, + pub status: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NewRelay { + pub id: String, + pub tenant: String, + pub name: String, + pub subdomain: String, + pub schema: String, + pub description: String, + pub plan: String, + pub status: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateRelay { + pub id: String, + pub name: String, + pub subdomain: String, + pub schema: String, + pub description: String, + pub plan: String, + pub status: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct Invoice { + pub id: String, + pub tenant: String, + pub amount: i64, + pub status: String, + pub created_at: String, + pub invoice: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NewInvoice { + pub id: String, + pub tenant: String, + pub amount: i64, + pub status: String, + pub created_at: String, + pub invoice: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct InvoiceItem { + pub id: String, + pub invoice: String, + pub relay: String, + pub amount: i64, + pub period_start: String, + pub period_end: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NewInvoiceItem { + pub id: String, + pub invoice: String, + pub relay: String, + pub amount: i64, + pub period_start: String, + pub period_end: String, +} diff --git a/backend/src/repo.rs b/backend/src/repo.rs new file mode 100644 index 0000000..0ba12c9 --- /dev/null +++ b/backend/src/repo.rs @@ -0,0 +1,190 @@ +use anyhow::Result; +use sqlx::{Sqlite, SqlitePool, Transaction}; + +use crate::models::{ + Invoice, InvoiceItem, NewInvoice, NewInvoiceItem, NewRelay, NewTenant, Relay, Tenant, UpdateRelay, +}; + +#[derive(Clone)] +pub struct Repo { + pool: SqlitePool, +} + +impl Repo { + pub fn new(pool: SqlitePool) -> Self { + Self { pool } + } + + pub async fn update_tenant_status(&self, pubkey: &str, status: &str) -> Result<()> { + sqlx::query("UPDATE tenants SET status = ? WHERE pubkey = ?") + .bind(status) + .bind(pubkey) + .execute(&self.pool) + .await?; + Ok(()) + } + + pub async fn create_tenant(&self, tenant: &NewTenant) -> Result<()> { + sqlx::query( + "INSERT INTO tenants (pubkey, status) VALUES (?, ?)", + ) + .bind(&tenant.pubkey) + .bind(&tenant.status) + .execute(&self.pool) + .await?; + Ok(()) + } + + pub async fn create_tenant_if_missing(&self, tenant: &NewTenant) -> Result<()> { + sqlx::query( + "INSERT OR IGNORE INTO tenants (pubkey, status) VALUES (?, ?)", + ) + .bind(&tenant.pubkey) + .bind(&tenant.status) + .execute(&self.pool) + .await?; + Ok(()) + } + + pub async fn get_tenant(&self, pubkey: &str) -> Result> { + let tenant = sqlx::query_as::<_, Tenant>( + "SELECT pubkey, status FROM tenants WHERE pubkey = ?", + ) + .bind(pubkey) + .fetch_optional(&self.pool) + .await?; + Ok(tenant) + } + + pub async fn list_tenants(&self) -> Result> { + let tenants = sqlx::query_as::<_, Tenant>( + "SELECT pubkey, status FROM tenants ORDER BY pubkey", + ) + .fetch_all(&self.pool) + .await?; + Ok(tenants) + } + + pub async fn create_relay(&self, relay: &NewRelay) -> Result<()> { + sqlx::query( + "INSERT INTO relays (id, tenant, name, subdomain, schema, description, plan, status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(&relay.id) + .bind(&relay.tenant) + .bind(&relay.name) + .bind(&relay.subdomain) + .bind(&relay.schema) + .bind(&relay.description) + .bind(&relay.plan) + .bind(&relay.status) + .execute(&self.pool) + .await?; + Ok(()) + } + + pub async fn update_relay(&self, relay: &UpdateRelay) -> Result<()> { + sqlx::query( + "UPDATE relays SET name = ?, subdomain = ?, schema = ?, description = ?, plan = ?, status = ? + WHERE id = ?", + ) + .bind(&relay.name) + .bind(&relay.subdomain) + .bind(&relay.schema) + .bind(&relay.description) + .bind(&relay.plan) + .bind(&relay.status) + .bind(&relay.id) + .execute(&self.pool) + .await?; + Ok(()) + } + + pub async fn get_relay(&self, id: &str) -> Result> { + let relay = sqlx::query_as::<_, Relay>( + "SELECT id, tenant, name, subdomain, schema, description, plan, status FROM relays WHERE id = ?", + ) + .bind(id) + .fetch_optional(&self.pool) + .await?; + Ok(relay) + } + + pub async fn list_relays_by_tenant(&self, tenant: &str) -> Result> { + let relays = sqlx::query_as::<_, Relay>( + "SELECT id, tenant, name, subdomain, schema, description, plan, status FROM relays WHERE tenant = ? ORDER BY name", + ) + .bind(tenant) + .fetch_all(&self.pool) + .await?; + Ok(relays) + } + + pub async fn list_relays(&self) -> Result> { + let relays = sqlx::query_as::<_, Relay>( + "SELECT id, tenant, name, subdomain, schema, description, plan, status FROM relays ORDER BY name", + ) + .fetch_all(&self.pool) + .await?; + Ok(relays) + } + + pub async fn create_invoice_with_items( + &self, + invoice: &NewInvoice, + items: &[NewInvoiceItem], + ) -> Result<()> { + let mut tx: Transaction<'_, Sqlite> = self.pool.begin().await?; + + sqlx::query( + "INSERT INTO invoices (id, tenant, amount, status, created_at, invoice) + VALUES (?, ?, ?, ?, ?, ?)", + ) + .bind(&invoice.id) + .bind(&invoice.tenant) + .bind(invoice.amount) + .bind(&invoice.status) + .bind(&invoice.created_at) + .bind(&invoice.invoice) + .execute(&mut *tx) + .await?; + + for item in items { + sqlx::query( + "INSERT INTO invoice_items (id, invoice, relay, amount, period_start, period_end) + VALUES (?, ?, ?, ?, ?, ?)", + ) + .bind(&item.id) + .bind(&item.invoice) + .bind(&item.relay) + .bind(item.amount) + .bind(&item.period_start) + .bind(&item.period_end) + .execute(&mut *tx) + .await?; + } + + tx.commit().await?; + Ok(()) + } + + pub async fn list_invoices_by_tenant(&self, tenant: &str) -> Result> { + let invoices = sqlx::query_as::<_, Invoice>( + "SELECT id, tenant, amount, status, created_at, invoice FROM invoices WHERE tenant = ? ORDER BY created_at DESC", + ) + .bind(tenant) + .fetch_all(&self.pool) + .await?; + Ok(invoices) + } + + pub async fn list_invoice_items(&self, invoice_id: &str) -> Result> { + let items = sqlx::query_as::<_, InvoiceItem>( + "SELECT id, invoice, relay, amount, period_start, period_end FROM invoice_items WHERE invoice = ?", + ) + .bind(invoice_id) + .fetch_all(&self.pool) + .await?; + Ok(items) + } +}