diff --git a/api/.env.example b/api/.env.example new file mode 100644 index 0000000..447bc89 --- /dev/null +++ b/api/.env.example @@ -0,0 +1,36 @@ +# Server +HOST=0.0.0.0 +PORT=4000 +RUST_LOG=nixpanel_website_api=info,tower_http=info + +# Database +DATABASE_URL=mysql://nixpanel_web:password@127.0.0.1:3306/nixpanel_website + +# JWT — generate: openssl rand -hex 64 +JWT_SECRET=changeme_use_openssl_rand_hex_64 +JWT_EXPIRES_IN_SECS=604800 + +# App +APP_URL=https://nixpanel.io +CORS_ORIGIN=https://nixpanel.io + +# Stripe +STRIPE_SECRET_KEY=sk_live_... +STRIPE_WEBHOOK_SECRET=whsec_... +STRIPE_PRICE_SOLO_MONTHLY=price_... +STRIPE_PRICE_SOLO_ANNUAL=price_... +STRIPE_PRICE_HOST_MONTHLY=price_... +STRIPE_PRICE_HOST_ANNUAL=price_... +STRIPE_PRICE_AGENCY_MONTHLY=price_... +STRIPE_PRICE_AGENCY_ANNUAL=price_... + +# Internal licensing server +LICENSING_SERVER_URL=http://127.0.0.1:3000 +LICENSING_SERVER_API_KEY=your_internal_api_key_here + +# SMTP +MAIL_HOST=smtp.mailgun.org +MAIL_PORT=587 +MAIL_USER=postmaster@nixpanel.io +MAIL_PASSWORD=your_smtp_password +MAIL_FROM=noreply@nixpanel.io diff --git a/api/.gitignore b/api/.gitignore new file mode 100644 index 0000000..57e7ee2 --- /dev/null +++ b/api/.gitignore @@ -0,0 +1,3 @@ +target/ +.env +*.log diff --git a/api/Cargo.toml b/api/Cargo.toml new file mode 100644 index 0000000..f3851d3 --- /dev/null +++ b/api/Cargo.toml @@ -0,0 +1,68 @@ +[package] +name = "nixpanel-website-api" +version = "0.1.0" +edition = "2021" + +[dependencies] +# Async runtime +tokio = { version = "1", features = ["full"] } + +# Web framework +axum = { version = "0.7", features = ["macros", "multipart"] } +tower = { version = "0.4" } +tower-http = { version = "0.5", features = ["cors", "trace", "limit"] } + +# Database +sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "mysql", "uuid", "chrono", "migrate"] } + +# Serialization +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +# Auth +jsonwebtoken = "9" +argon2 = "0.5" + +# IDs / time +uuid = { version = "1", features = ["v4", "serde"] } +chrono = { version = "0.4", features = ["serde"] } + +# Config / env +dotenvy = "0.15" + +# HTTP client (for calling licensing server + Stripe) +reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } + +# Stripe +async-stripe = { version = "0.37", default-features = false, features = [ + "runtime-tokio-hyper-rustls", + "checkout", + "billing", + "webhook", +] } + +# Email +lettre = { version = "0.11", default-features = false, features = [ + "smtp-transport", + "tokio1-rustls-tls", + "builder", +] } + +# Error handling +thiserror = "1" +anyhow = "1" + +# Crypto +sha2 = "0.10" +hex = "0.4" +rand = "0.8" + +# Logging +tracing = "1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } + +[profile.release] +opt-level = 3 +lto = true +codegen-units = 1 +strip = true diff --git a/api/migrations/20240101000000_customers.sql b/api/migrations/20240101000000_customers.sql new file mode 100644 index 0000000..e1b76c1 --- /dev/null +++ b/api/migrations/20240101000000_customers.sql @@ -0,0 +1,52 @@ +CREATE TABLE IF NOT EXISTS customers ( + id VARCHAR(36) NOT NULL PRIMARY KEY, + email VARCHAR(255) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + full_name VARCHAR(255) NOT NULL, + is_verified TINYINT(1) NOT NULL DEFAULT 0, + verify_token VARCHAR(128) NULL, + reset_token VARCHAR(128) NULL, + reset_token_expires DATETIME NULL, + stripe_customer_id VARCHAR(100) NULL UNIQUE, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_email (email), + INDEX idx_stripe_customer (stripe_customer_id) +); + +CREATE TABLE IF NOT EXISTS subscriptions ( + id VARCHAR(36) NOT NULL PRIMARY KEY, + customer_id VARCHAR(36) NOT NULL, + license_id VARCHAR(36) NOT NULL, + stripe_subscription_id VARCHAR(100) NOT NULL UNIQUE, + stripe_price_id VARCHAR(100) NOT NULL, + plan_key VARCHAR(20) NOT NULL DEFAULT 'free', + billing_interval VARCHAR(10) NOT NULL DEFAULT 'monthly', + status VARCHAR(20) NOT NULL DEFAULT 'active', + server_ip VARCHAR(45) NOT NULL, + current_period_start DATETIME NOT NULL, + current_period_end DATETIME NOT NULL, + cancel_at_period_end TINYINT(1) NOT NULL DEFAULT 0, + canceled_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (customer_id) REFERENCES customers(id) ON DELETE CASCADE, + INDEX idx_customer_id (customer_id), + INDEX idx_license_id (license_id), + INDEX idx_stripe_sub (stripe_subscription_id), + INDEX idx_server_ip (server_ip) +); + +CREATE TABLE IF NOT EXISTS ip_change_requests ( + id VARCHAR(36) NOT NULL PRIMARY KEY, + customer_id VARCHAR(36) NOT NULL, + subscription_id VARCHAR(36) NOT NULL, + old_ip VARCHAR(45) NOT NULL, + new_ip VARCHAR(45) NOT NULL, + status VARCHAR(10) NOT NULL DEFAULT 'pending', + requested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + processed_at DATETIME NULL, + FOREIGN KEY (customer_id) REFERENCES customers(id) ON DELETE CASCADE, + FOREIGN KEY (subscription_id) REFERENCES subscriptions(id) ON DELETE CASCADE, + INDEX idx_customer_id (customer_id) +); diff --git a/api/src/config.rs b/api/src/config.rs new file mode 100644 index 0000000..86de99b --- /dev/null +++ b/api/src/config.rs @@ -0,0 +1,96 @@ +use anyhow::{Context, Result}; +use std::env; + +#[derive(Clone, Debug)] +pub struct Config { + pub host: String, + pub port: u16, + pub database_url: String, + pub jwt_secret: String, + pub jwt_expires_in_secs: u64, + pub app_url: String, + pub cors_origin: String, + pub stripe: StripeConfig, + pub licensing: LicensingConfig, + pub mail: MailConfig, +} + +#[derive(Clone, Debug)] +pub struct StripeConfig { + pub secret_key: String, + pub webhook_secret: String, + pub price_solo_monthly: String, + pub price_solo_annual: String, + pub price_host_monthly: String, + pub price_host_annual: String, + pub price_agency_monthly: String, + pub price_agency_annual: String, +} + +#[derive(Clone, Debug)] +pub struct LicensingConfig { + pub url: String, + pub api_key: String, +} + +#[derive(Clone, Debug)] +pub struct MailConfig { + pub host: String, + pub port: u16, + pub username: String, + pub password: String, + pub from: String, +} + +impl Config { + pub fn from_env() -> Result { + dotenvy::dotenv().ok(); + Ok(Self { + host: env::var("HOST").unwrap_or_else(|_| "0.0.0.0".into()), + port: env::var("PORT").unwrap_or_else(|_| "4000".into()).parse() + .context("PORT must be a number")?, + database_url: env::var("DATABASE_URL").context("DATABASE_URL required")?, + jwt_secret: env::var("JWT_SECRET").context("JWT_SECRET required")?, + jwt_expires_in_secs: env::var("JWT_EXPIRES_IN_SECS") + .unwrap_or_else(|_| "604800".into()).parse() + .context("JWT_EXPIRES_IN_SECS must be a number")?, + app_url: env::var("APP_URL").unwrap_or_else(|_| "https://nixpanel.io".into()), + cors_origin: env::var("CORS_ORIGIN").unwrap_or_else(|_| "https://nixpanel.io".into()), + stripe: StripeConfig { + secret_key: env::var("STRIPE_SECRET_KEY").context("STRIPE_SECRET_KEY required")?, + webhook_secret: env::var("STRIPE_WEBHOOK_SECRET").context("STRIPE_WEBHOOK_SECRET required")?, + price_solo_monthly: env::var("STRIPE_PRICE_SOLO_MONTHLY").unwrap_or_default(), + price_solo_annual: env::var("STRIPE_PRICE_SOLO_ANNUAL").unwrap_or_default(), + price_host_monthly: env::var("STRIPE_PRICE_HOST_MONTHLY").unwrap_or_default(), + price_host_annual: env::var("STRIPE_PRICE_HOST_ANNUAL").unwrap_or_default(), + price_agency_monthly: env::var("STRIPE_PRICE_AGENCY_MONTHLY").unwrap_or_default(), + price_agency_annual: env::var("STRIPE_PRICE_AGENCY_ANNUAL").unwrap_or_default(), + }, + licensing: LicensingConfig { + url: env::var("LICENSING_SERVER_URL").context("LICENSING_SERVER_URL required")?, + api_key: env::var("LICENSING_SERVER_API_KEY").context("LICENSING_SERVER_API_KEY required")?, + }, + mail: MailConfig { + host: env::var("MAIL_HOST").unwrap_or_else(|_| "smtp.mailgun.org".into()), + port: env::var("MAIL_PORT").unwrap_or_else(|_| "587".into()).parse().unwrap_or(587), + username: env::var("MAIL_USER").unwrap_or_default(), + password: env::var("MAIL_PASSWORD").unwrap_or_default(), + from: env::var("MAIL_FROM").unwrap_or_else(|_| "noreply@nixpanel.io".into()), + }, + }) + } + + /// Returns the Stripe price ID for a given plan key + interval + pub fn stripe_price_id(&self, plan_key: &str, interval: &str) -> Option<&str> { + match (plan_key, interval) { + ("solo", "monthly") => Some(&self.stripe.price_solo_monthly), + ("solo", "annual") => Some(&self.stripe.price_solo_annual), + ("host", "monthly") => Some(&self.stripe.price_host_monthly), + ("host", "annual") => Some(&self.stripe.price_host_annual), + ("agency", "monthly") => Some(&self.stripe.price_agency_monthly), + ("agency", "annual") => Some(&self.stripe.price_agency_annual), + _ => None, + } + .filter(|s| !s.is_empty()) + } +} diff --git a/api/src/db.rs b/api/src/db.rs new file mode 100644 index 0000000..3bfcb54 --- /dev/null +++ b/api/src/db.rs @@ -0,0 +1,16 @@ +use anyhow::Result; +use sqlx::mysql::MySqlPoolOptions; +use sqlx::MySqlPool; + +use crate::config::Config; + +pub async fn create_pool(cfg: &Config) -> Result { + let pool = MySqlPoolOptions::new() + .max_connections(20) + .connect(&cfg.database_url) + .await?; + + sqlx::migrate!("./migrations").run(&pool).await?; + + Ok(pool) +} diff --git a/api/src/error.rs b/api/src/error.rs new file mode 100644 index 0000000..935313b --- /dev/null +++ b/api/src/error.rs @@ -0,0 +1,44 @@ +use axum::{http::StatusCode, response::{IntoResponse, Response}, Json}; +use serde_json::json; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum AppError { + #[error("Not found")] + NotFound, + #[error("Unauthorized: {0}")] + Unauthorized(String), + #[error("Forbidden")] + Forbidden, + #[error("Bad request: {0}")] + BadRequest(String), + #[error("Conflict: {0}")] + Conflict(String), + #[error("Database error: {0}")] + Database(#[from] sqlx::Error), + #[error("Internal error: {0}")] + Internal(#[from] anyhow::Error), +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let (status, message) = match &self { + AppError::NotFound => (StatusCode::NOT_FOUND, self.to_string()), + AppError::Unauthorized(m) => (StatusCode::UNAUTHORIZED, m.clone()), + AppError::Forbidden => (StatusCode::FORBIDDEN, "Forbidden".into()), + AppError::BadRequest(m) => (StatusCode::BAD_REQUEST, m.clone()), + AppError::Conflict(m) => (StatusCode::CONFLICT, m.clone()), + AppError::Database(e) => { + tracing::error!("DB: {e}"); + (StatusCode::INTERNAL_SERVER_ERROR, "Database error".into()) + } + AppError::Internal(e) => { + tracing::error!("Internal: {e}"); + (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error".into()) + } + }; + (status, Json(json!({ "error": message }))).into_response() + } +} + +pub type AppResult = Result; diff --git a/api/src/main.rs b/api/src/main.rs new file mode 100644 index 0000000..201ac60 --- /dev/null +++ b/api/src/main.rs @@ -0,0 +1,40 @@ +mod config; +mod db; +mod error; +mod middleware; +mod models; +mod routes; +mod services; +mod state; + +use anyhow::Result; +use tower_http::{cors::CorsLayer, trace::TraceLayer}; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::registry() + .with(tracing_subscriber::EnvFilter::new( + std::env::var("RUST_LOG") + .unwrap_or_else(|_| "nixpanel_website_api=info,tower_http=info".into()), + )) + .with(tracing_subscriber::fmt::layer()) + .init(); + + let cfg = config::Config::from_env()?; + let pool = db::create_pool(&cfg).await?; + + tracing::info!("Database connected and migrations applied"); + + let state = state::AppState::new(pool, cfg.clone()); + let app = routes::create_router(state) + .layer(TraceLayer::new_for_http()) + .layer(CorsLayer::permissive()); + + let addr = format!("{}:{}", cfg.host, cfg.port); + let listener = tokio::net::TcpListener::bind(&addr).await?; + tracing::info!("NixPanel Website API listening on {addr}"); + + axum::serve(listener, app).await?; + Ok(()) +} diff --git a/api/src/middleware/auth.rs b/api/src/middleware/auth.rs new file mode 100644 index 0000000..36ae4b9 --- /dev/null +++ b/api/src/middleware/auth.rs @@ -0,0 +1,34 @@ +use axum::{ + extract::FromRequestParts, + http::{request::Parts, StatusCode}, + response::{IntoResponse, Response}, + Json, +}; +use serde_json::json; + +use crate::{models::CustomerClaims, services::auth::verify_jwt, state::AppState}; + +pub struct AuthCustomer(pub CustomerClaims); + +impl FromRequestParts for AuthCustomer { + type Rejection = Response; + + async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result { + let token = parts + .headers + .get("Authorization") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + .ok_or_else(|| { + (StatusCode::UNAUTHORIZED, Json(json!({"error": "Authentication required"}))) + .into_response() + })?; + + let claims = verify_jwt(&state.config, token).map_err(|_| { + (StatusCode::UNAUTHORIZED, Json(json!({"error": "Invalid or expired session"}))) + .into_response() + })?; + + Ok(AuthCustomer(claims)) + } +} diff --git a/api/src/middleware/mod.rs b/api/src/middleware/mod.rs new file mode 100644 index 0000000..0e4a05d --- /dev/null +++ b/api/src/middleware/mod.rs @@ -0,0 +1 @@ +pub mod auth; diff --git a/api/src/models.rs b/api/src/models.rs new file mode 100644 index 0000000..b1934a0 --- /dev/null +++ b/api/src/models.rs @@ -0,0 +1,46 @@ +use chrono::NaiveDateTime; +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; + +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct Customer { + pub id: String, + pub email: String, + #[serde(skip_serializing)] + pub password_hash: String, + pub full_name: String, + pub is_verified: bool, + #[serde(skip_serializing)] + pub verify_token: Option, + #[serde(skip_serializing)] + pub reset_token: Option, + #[serde(skip_serializing)] + pub reset_token_expires: Option, + pub stripe_customer_id: Option, + pub created_at: NaiveDateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct Subscription { + pub id: String, + pub customer_id: String, + pub license_id: String, + pub stripe_subscription_id: String, + pub stripe_price_id: String, + pub plan_key: String, + pub billing_interval: String, + pub status: String, + pub server_ip: String, + pub current_period_start: NaiveDateTime, + pub current_period_end: NaiveDateTime, + pub cancel_at_period_end: bool, + pub canceled_at: Option, + pub created_at: NaiveDateTime, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CustomerClaims { + pub sub: String, + pub email: String, + pub exp: usize, +} diff --git a/api/src/routes/auth.rs b/api/src/routes/auth.rs new file mode 100644 index 0000000..e4d7a06 --- /dev/null +++ b/api/src/routes/auth.rs @@ -0,0 +1,111 @@ +use axum::{extract::State, http::StatusCode, Json}; +use serde::Deserialize; + +use crate::{ + error::AppResult, + middleware::auth::AuthCustomer, + services::{auth, mail}, + state::AppState, +}; + +#[derive(Deserialize)] +pub struct RegisterRequest { + pub email: String, + pub password: String, + #[serde(rename = "fullName")] + pub full_name: String, +} + +#[derive(Deserialize)] +pub struct LoginRequest { + pub email: String, + pub password: String, +} + +// POST /api/v1/auth/register +pub async fn register( + State(state): State, + Json(body): Json, +) -> AppResult<(StatusCode, Json)> { + if body.password.len() < 8 { + return Err(crate::error::AppError::BadRequest("Password must be at least 8 characters".into())); + } + + let (customer, token) = auth::register(&state.pool, &body.email, &body.password, &body.full_name).await?; + + // Non-fatal — log if email fails + if let Err(e) = mail::send_verification(&state.config.mail, &state.config.app_url, &customer.email, &token).await { + tracing::warn!("Failed to send verification email: {e}"); + } + + Ok((StatusCode::CREATED, Json(serde_json::json!({ + "message": "Account created. Check your email to verify your account.", + "id": customer.id, + })))) +} + +// POST /api/v1/auth/login +pub async fn login( + State(state): State, + Json(body): Json, +) -> AppResult> { + let token = auth::login(&state.pool, &state.config, &body.email, &body.password).await?; + Ok(Json(serde_json::json!({ "token": token }))) +} + +// GET /api/v1/auth/me +pub async fn me( + State(state): State, + AuthCustomer(claims): AuthCustomer, +) -> AppResult> { + let customer = auth::get_by_id(&state.pool, &claims.sub) + .await? + .ok_or(crate::error::AppError::NotFound)?; + Ok(Json(serde_json::to_value(&customer).unwrap())) +} + +// GET /api/v1/auth/verify-email?token=... +pub async fn verify_email( + State(state): State, + axum::extract::Query(params): axum::extract::Query>, +) -> AppResult> { + let token = params.get("token").ok_or_else(|| crate::error::AppError::BadRequest("Missing token".into()))?; + let ok = auth::verify_email(&state.pool, token).await?; + if !ok { + return Err(crate::error::AppError::BadRequest("Invalid or expired verification link".into())); + } + Ok(Json(serde_json::json!({ "message": "Email verified. You can now log in." }))) +} + +// POST /api/v1/auth/forgot-password +pub async fn forgot_password( + State(state): State, + Json(body): Json, +) -> AppResult> { + if let Some(email) = body["email"].as_str() { + if let Ok(Some(token)) = auth::initiate_reset(&state.pool, email).await { + let _ = mail::send_password_reset(&state.config.mail, &state.config.app_url, email, &token).await; + } + } + // Always return success to prevent email enumeration + Ok(Json(serde_json::json!({ "message": "If that email exists, a reset link has been sent." }))) +} + +// POST /api/v1/auth/reset-password +pub async fn reset_password( + State(state): State, + Json(body): Json, +) -> AppResult> { + let token = body["token"].as_str().ok_or_else(|| crate::error::AppError::BadRequest("Missing token".into()))?; + let password = body["password"].as_str().ok_or_else(|| crate::error::AppError::BadRequest("Missing password".into()))?; + + if password.len() < 8 { + return Err(crate::error::AppError::BadRequest("Password must be at least 8 characters".into())); + } + + let ok = auth::reset_password(&state.pool, token, password).await?; + if !ok { + return Err(crate::error::AppError::BadRequest("Invalid or expired reset token".into())); + } + Ok(Json(serde_json::json!({ "message": "Password updated. You can now log in." }))) +} diff --git a/api/src/routes/billing.rs b/api/src/routes/billing.rs new file mode 100644 index 0000000..9801d16 --- /dev/null +++ b/api/src/routes/billing.rs @@ -0,0 +1,298 @@ +use axum::{body::Bytes, extract::State, http::{HeaderMap, StatusCode}, Json}; +use serde::Deserialize; +use uuid::Uuid; + +use crate::{ + error::{AppError, AppResult}, + middleware::auth::AuthCustomer, + services::{auth::get_by_id, licensing, mail, stripe as stripe_svc}, + state::AppState, +}; + +const PLAN_ID_FREE: &str = "00000000-0000-0000-0000-000000000001"; +const PLAN_ID_SOLO: &str = "00000000-0000-0000-0000-000000000002"; +const PLAN_ID_HOST: &str = "00000000-0000-0000-0000-000000000003"; +const PLAN_ID_AGENCY: &str = "00000000-0000-0000-0000-000000000004"; + +fn plan_id(key: &str) -> Option<&'static str> { + match key { + "free" => Some(PLAN_ID_FREE), + "solo" => Some(PLAN_ID_SOLO), + "host" => Some(PLAN_ID_HOST), + "agency" => Some(PLAN_ID_AGENCY), + _ => None, + } +} + +#[derive(Deserialize)] +pub struct CheckoutRequest { + #[serde(rename = "planKey")] pub plan_key: String, + #[serde(rename = "interval")] pub interval: String, + #[serde(rename = "serverIp")] pub server_ip: String, +} + +#[derive(Deserialize)] +pub struct ChangePlanRequest { + #[serde(rename = "planKey")] pub plan_key: String, + #[serde(rename = "interval")] pub interval: String, +} + +// GET /api/v1/billing/plans — public +pub async fn plans() -> Json { + Json(serde_json::json!({ + "free": { "monthly": 0.00, "annual": 0.00, "label": "Free" }, + "solo": { "monthly": 9.99, "annual": 95.90, "label": "Solo" }, + "host": { "monthly": 29.99, "annual": 287.90, "label": "Host" }, + "agency": { "monthly": 79.99, "annual": 767.90, "label": "Agency" }, + })) +} + +// POST /api/v1/billing/checkout +pub async fn checkout( + State(state): State, + AuthCustomer(claims): AuthCustomer, + Json(body): Json, +) -> AppResult> { + let customer = get_by_id(&state.pool, &claims.sub).await?.ok_or(AppError::NotFound)?; + + let price_id = state.config.stripe_price_id(&body.plan_key, &body.interval) + .ok_or_else(|| AppError::BadRequest("Invalid plan or interval".into()))? + .to_string(); + + // Ensure Stripe customer exists + let stripe_cid = ensure_stripe_customer(&state, &customer).await?; + + let url = stripe_svc::create_checkout_session( + &state.config.stripe, + &stripe_cid, + &price_id, + &body.plan_key, + &body.interval, + &body.server_ip, + &claims.sub, + &format!("{}/dashboard?success=1", state.config.app_url), + &format!("{}/pricing", state.config.app_url), + ).await.map_err(|e| AppError::Internal(e))?; + + Ok(Json(serde_json::json!({ "url": url }))) +} + +// POST /api/v1/billing/portal +pub async fn portal( + State(state): State, + AuthCustomer(claims): AuthCustomer, +) -> AppResult> { + let customer = get_by_id(&state.pool, &claims.sub).await?.ok_or(AppError::NotFound)?; + let stripe_cid = customer.stripe_customer_id + .ok_or_else(|| AppError::BadRequest("No billing account found".into()))?; + + let url = stripe_svc::create_portal_session( + &state.config.stripe, + &stripe_cid, + &format!("{}/dashboard", state.config.app_url), + ).await.map_err(|e| AppError::Internal(e))?; + + Ok(Json(serde_json::json!({ "url": url }))) +} + +// POST /api/v1/billing/cancel +pub async fn cancel( + State(state): State, + AuthCustomer(claims): AuthCustomer, +) -> AppResult> { + let sub = active_subscription(&state, &claims.sub).await? + .ok_or_else(|| AppError::NotFound)?; + + stripe_svc::cancel_at_period_end(&state.config.stripe, &sub.stripe_subscription_id) + .await.map_err(|e| AppError::Internal(e))?; + + sqlx::query!( + "UPDATE subscriptions SET cancel_at_period_end = 1 WHERE id = ?", sub.id + ).execute(&state.pool).await?; + + Ok(Json(serde_json::json!({ "message": "Subscription will cancel at end of billing period." }))) +} + +// POST /api/v1/billing/change-plan +pub async fn change_plan( + State(state): State, + AuthCustomer(claims): AuthCustomer, + Json(body): Json, +) -> AppResult> { + let sub = active_subscription(&state, &claims.sub).await? + .ok_or_else(|| AppError::NotFound)?; + + let price_id = state.config.stripe_price_id(&body.plan_key, &body.interval) + .ok_or_else(|| AppError::BadRequest("Invalid plan or interval".into()))? + .to_string(); + + stripe_svc::change_plan(&state.config.stripe, &sub.stripe_subscription_id, &price_id) + .await.map_err(|e| AppError::Internal(e))?; + + sqlx::query!( + "UPDATE subscriptions SET plan_key = ?, billing_interval = ? WHERE id = ?", + body.plan_key, body.interval, sub.id + ).execute(&state.pool).await?; + + Ok(Json(serde_json::json!({ "message": "Plan updated." }))) +} + +// GET /api/v1/billing/subscription +pub async fn subscription( + State(state): State, + AuthCustomer(claims): AuthCustomer, +) -> AppResult> { + let sub = active_subscription(&state, &claims.sub).await?; + Ok(Json(serde_json::to_value(&sub).unwrap())) +} + +// POST /api/v1/billing/webhook (raw body — no auth, verified by Stripe signature) +pub async fn webhook( + State(state): State, + headers: HeaderMap, + body: Bytes, +) -> AppResult> { + let sig = headers.get("stripe-signature") + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| AppError::BadRequest("Missing Stripe signature".into()))?; + + let event = stripe_svc::construct_webhook_event(&body, sig, &state.config.stripe.webhook_secret) + .map_err(|_| AppError::BadRequest("Invalid webhook signature".into()))?; + + match event.type_ { + stripe::EventType::CheckoutSessionCompleted => { + if let stripe::EventObject::CheckoutSession(session) = event.data.object { + handle_checkout_completed(&state, session).await?; + } + } + stripe::EventType::CustomerSubscriptionUpdated => { + if let stripe::EventObject::Subscription(sub) = event.data.object { + handle_subscription_updated(&state, sub).await?; + } + } + stripe::EventType::CustomerSubscriptionDeleted => { + if let stripe::EventObject::Subscription(sub) = event.data.object { + handle_subscription_deleted(&state, sub).await?; + } + } + _ => {} + } + + Ok(Json(serde_json::json!({ "received": true }))) +} + +// ── Webhook handlers ────────────────────────────────────────────────────────── + +async fn handle_checkout_completed( + state: &AppState, + session: stripe::CheckoutSession, +) -> AppResult<()> { + let meta = session.metadata.unwrap_or_default(); + let customer_id = meta.get("nixpanel_customer_id").cloned().unwrap_or_default(); + let plan_key = meta.get("plan_key").cloned().unwrap_or_default(); + let interval = meta.get("billing_interval").cloned().unwrap_or_default(); + let server_ip = meta.get("server_ip").cloned().unwrap_or_default(); + let stripe_sub = session.subscription.map(|s| s.id().to_string()).unwrap_or_default(); + + let customer = get_by_id(&state.pool, &customer_id).await?.ok_or(AppError::NotFound)?; + let pid = plan_id(&plan_key).ok_or_else(|| AppError::BadRequest("Unknown plan".into()))?; + + let license = licensing::create_license( + &state.config.licensing, + pid, + &customer.email, + &customer.full_name, + &server_ip, + ).await.map_err(|e| AppError::Internal(e))?; + + let license_id = license["id"].as_str().unwrap_or("").to_string(); + let sub_id = Uuid::new_v4().to_string(); + + sqlx::query!( + r#"INSERT INTO subscriptions + (id, customer_id, license_id, stripe_subscription_id, stripe_price_id, + plan_key, billing_interval, status, server_ip, + current_period_start, current_period_end) + VALUES (?, ?, ?, ?, '', ?, ?, 'active', ?, NOW(), DATE_ADD(NOW(), INTERVAL 1 MONTH))"#, + sub_id, customer_id, license_id, stripe_sub, + plan_key, interval, server_ip + ).execute(&state.pool).await?; + + let _ = mail::send_license_confirmation( + &state.config.mail, + &state.config.app_url, + &customer.email, + &plan_key, + &server_ip, + &interval, + ).await; + + Ok(()) +} + +async fn handle_subscription_updated(state: &AppState, sub: stripe::Subscription) -> AppResult<()> { + sqlx::query!( + r#"UPDATE subscriptions + SET status = ?, + current_period_start = ?, + current_period_end = ?, + cancel_at_period_end = ? + WHERE stripe_subscription_id = ?"#, + sub.status.to_string(), + chrono::DateTime::from_timestamp(sub.current_period_start, 0).unwrap().naive_utc(), + chrono::DateTime::from_timestamp(sub.current_period_end, 0).unwrap().naive_utc(), + sub.cancel_at_period_end, + sub.id.to_string(), + ).execute(&state.pool).await?; + Ok(()) +} + +async fn handle_subscription_deleted(state: &AppState, sub: stripe::Subscription) -> AppResult<()> { + let row = sqlx::query!( + "SELECT license_id FROM subscriptions WHERE stripe_subscription_id = ?", + sub.id.to_string() + ).fetch_optional(&state.pool).await?; + + if let Some(r) = row { + sqlx::query!( + "UPDATE subscriptions SET status = 'canceled', canceled_at = NOW() WHERE stripe_subscription_id = ?", + sub.id.to_string() + ).execute(&state.pool).await?; + let _ = licensing::revoke_license(&state.config.licensing, &r.license_id).await; + } + Ok(()) +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +async fn active_subscription(state: &AppState, customer_id: &str) -> AppResult> { + Ok(sqlx::query_as!( + crate::models::Subscription, + "SELECT * FROM subscriptions WHERE customer_id = ? AND status = 'active' ORDER BY created_at DESC LIMIT 1", + customer_id + ).fetch_optional(&state.pool).await?) +} + +async fn ensure_stripe_customer( + state: &AppState, + customer: &crate::models::Customer, +) -> AppResult { + if let Some(ref id) = customer.stripe_customer_id { + return Ok(id.clone()); + } + + use stripe::{Client, CreateCustomer, Customer as StripeCustomer}; + let stripe_client = stripe::Client::new(&state.config.stripe.secret_key); + let mut params = CreateCustomer::new(); + params.email = Some(&customer.email); + params.name = Some(&customer.full_name); + + let sc = StripeCustomer::create(&stripe_client, params) + .await.map_err(|e| AppError::Internal(anyhow::anyhow!("{e}")))?; + let sid = sc.id.to_string(); + + sqlx::query!("UPDATE customers SET stripe_customer_id = ? WHERE id = ?", sid, customer.id) + .execute(&state.pool).await?; + + Ok(sid) +} diff --git a/api/src/routes/license.rs b/api/src/routes/license.rs new file mode 100644 index 0000000..ca0d5df --- /dev/null +++ b/api/src/routes/license.rs @@ -0,0 +1,126 @@ +use axum::{extract::State, Json}; +use serde::Deserialize; + +use crate::{ + error::{AppError, AppResult}, + middleware::auth::AuthCustomer, + services::licensing, + state::AppState, +}; + +#[derive(Deserialize)] +pub struct ChangeIpRequest { + #[serde(rename = "newIp")] + pub new_ip: String, +} + +// GET /api/v1/license +pub async fn get( + State(state): State, + AuthCustomer(claims): AuthCustomer, +) -> AppResult> { + let sub = sqlx::query!( + "SELECT * FROM subscriptions WHERE customer_id = ? AND status = 'active' ORDER BY created_at DESC LIMIT 1", + claims.sub + ).fetch_optional(&state.pool).await?; + + let Some(sub) = sub else { return Ok(Json(serde_json::Value::Null)) }; + + let license = licensing::get_license(&state.config.licensing, &sub.license_id) + .await.map_err(|e| AppError::Internal(e))?; + + Ok(Json(serde_json::json!({ + "license": license, + "subscription": { + "id": sub.id, + "plan_key": sub.plan_key, + "billing_interval": sub.billing_interval, + "status": sub.status, + "server_ip": sub.server_ip, + "current_period_end": sub.current_period_end.to_string(), + "cancel_at_period_end": sub.cancel_at_period_end, + } + }))) +} + +// POST /api/v1/license/change-ip +pub async fn change_ip( + State(state): State, + AuthCustomer(claims): AuthCustomer, + Json(body): Json, +) -> AppResult> { + // Validate IPv4 format + if !is_valid_ipv4(&body.new_ip) { + return Err(AppError::BadRequest("Invalid IPv4 address".into())); + } + + let sub = sqlx::query!( + "SELECT * FROM subscriptions WHERE customer_id = ? AND status = 'active' ORDER BY created_at DESC LIMIT 1", + claims.sub + ).fetch_optional(&state.pool).await? + .ok_or(AppError::NotFound)?; + + if sub.server_ip == body.new_ip { + return Err(AppError::BadRequest("New IP is the same as current IP".into())); + } + + // Check if new IP is already bound to another active subscription + let conflict = sqlx::query!( + "SELECT id FROM subscriptions WHERE server_ip = ? AND status = 'active' AND id != ?", + body.new_ip, sub.id + ).fetch_optional(&state.pool).await?; + + if conflict.is_some() { + return Err(AppError::Conflict("That IP address is already bound to another license".into())); + } + + // Log IP change + let req_id = uuid::Uuid::new_v4().to_string(); + sqlx::query!( + r#"INSERT INTO ip_change_requests + (id, customer_id, subscription_id, old_ip, new_ip, status, processed_at) + VALUES (?, ?, ?, ?, ?, 'approved', NOW())"#, + req_id, claims.sub, sub.id, sub.server_ip, body.new_ip + ).execute(&state.pool).await?; + + // Push to licensing server + licensing::update_server_ip(&state.config.licensing, &sub.license_id, &body.new_ip) + .await.map_err(|e| AppError::Internal(e))?; + + // Update local record + sqlx::query!( + "UPDATE subscriptions SET server_ip = ? WHERE id = ?", + body.new_ip, sub.id + ).execute(&state.pool).await?; + + Ok(Json(serde_json::json!({ + "message": "IP address updated successfully.", + "newIp": body.new_ip, + }))) +} + +// GET /api/v1/license/ip-history +pub async fn ip_history( + State(state): State, + AuthCustomer(claims): AuthCustomer, +) -> AppResult> { + let rows = sqlx::query!( + "SELECT * FROM ip_change_requests WHERE customer_id = ? ORDER BY requested_at DESC", + claims.sub + ).fetch_all(&state.pool).await?; + + let result: Vec = rows.into_iter().map(|r| serde_json::json!({ + "id": r.id, + "old_ip": r.old_ip, + "new_ip": r.new_ip, + "status": r.status, + "requested_at": r.requested_at.to_string(), + "processed_at": r.processed_at.map(|d| d.to_string()), + })).collect(); + + Ok(Json(serde_json::json!(result))) +} + +fn is_valid_ipv4(ip: &str) -> bool { + ip.parse::().is_ok() +} diff --git a/api/src/routes/mod.rs b/api/src/routes/mod.rs new file mode 100644 index 0000000..a14a8e9 --- /dev/null +++ b/api/src/routes/mod.rs @@ -0,0 +1,52 @@ +use axum::{routing::{get, post}, Router}; +use crate::state::AppState; + +mod auth; +mod billing; +mod license; + +pub fn create_router(state: AppState) -> Router { + Router::new() + .route("/health", get(health)) + .nest("/api/v1", api_router()) + .with_state(state) +} + +fn api_router() -> Router { + Router::new() + .nest("/auth", auth_router()) + .nest("/billing", billing_router()) + .nest("/license", license_router()) +} + +fn auth_router() -> Router { + Router::new() + .route("/register", post(auth::register)) + .route("/login", post(auth::login)) + .route("/me", get(auth::me)) + .route("/verify-email", get(auth::verify_email)) + .route("/forgot-password", post(auth::forgot_password)) + .route("/reset-password", post(auth::reset_password)) +} + +fn billing_router() -> Router { + Router::new() + .route("/plans", get(billing::plans)) + .route("/checkout", post(billing::checkout)) + .route("/portal", post(billing::portal)) + .route("/cancel", post(billing::cancel)) + .route("/change-plan", post(billing::change_plan)) + .route("/subscription",get(billing::subscription)) + .route("/webhook", post(billing::webhook)) +} + +fn license_router() -> Router { + Router::new() + .route("/", get(license::get)) + .route("/change-ip", post(license::change_ip)) + .route("/ip-history",get(license::ip_history)) +} + +async fn health() -> axum::Json { + axum::Json(serde_json::json!({ "status": "ok", "ts": chrono::Utc::now().to_rfc3339() })) +} diff --git a/api/src/services/auth.rs b/api/src/services/auth.rs new file mode 100644 index 0000000..154e1cc --- /dev/null +++ b/api/src/services/auth.rs @@ -0,0 +1,154 @@ +use argon2::{ + password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, + Argon2, +}; +use chrono::Utc; +use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; +use sqlx::MySqlPool; +use uuid::Uuid; + +use crate::{ + config::Config, + error::{AppError, AppResult}, + models::{Customer, CustomerClaims}, +}; + +pub fn new_id() -> String { Uuid::new_v4().to_string() } + +pub fn random_token() -> String { + use rand::Rng; + let bytes: [u8; 32] = rand::thread_rng().gen(); + hex::encode(bytes) +} + +fn hash_password(password: &str) -> AppResult { + let salt = SaltString::generate(&mut OsRng); + Argon2::default() + .hash_password(password.as_bytes(), &salt) + .map(|h| h.to_string()) + .map_err(|e| AppError::Internal(anyhow::anyhow!("Hash error: {e}"))) +} + +fn verify_password(hash: &str, password: &str) -> bool { + let Ok(parsed) = PasswordHash::new(hash) else { return false }; + Argon2::default().verify_password(password.as_bytes(), &parsed).is_ok() +} + +pub fn issue_jwt(cfg: &Config, id: &str, email: &str) -> AppResult { + let exp = (Utc::now().timestamp() as u64 + cfg.jwt_expires_in_secs) as usize; + let claims = CustomerClaims { sub: id.to_string(), email: email.to_string(), exp }; + encode(&Header::default(), &claims, &EncodingKey::from_secret(cfg.jwt_secret.as_bytes())) + .map_err(|e| AppError::Internal(anyhow::anyhow!("JWT sign error: {e}"))) +} + +pub fn verify_jwt(cfg: &Config, token: &str) -> AppResult { + decode::( + token, + &DecodingKey::from_secret(cfg.jwt_secret.as_bytes()), + &Validation::default(), + ) + .map(|d| d.claims) + .map_err(|_| AppError::Unauthorized("Invalid or expired session".into())) +} + +// ── DB helpers ──────────────────────────────────────────────────────────────── + +pub async fn get_by_email(pool: &MySqlPool, email: &str) -> AppResult> { + Ok(sqlx::query_as!(Customer, "SELECT * FROM customers WHERE email = ?", email) + .fetch_optional(pool).await?) +} + +pub async fn get_by_id(pool: &MySqlPool, id: &str) -> AppResult> { + Ok(sqlx::query_as!(Customer, "SELECT * FROM customers WHERE id = ?", id) + .fetch_optional(pool).await?) +} + +// ── Business logic ──────────────────────────────────────────────────────────── + +pub async fn register( + pool: &MySqlPool, + email: &str, + password: &str, + full_name: &str, +) -> AppResult<(Customer, String)> { + let email = email.to_lowercase().trim().to_string(); + + if get_by_email(pool, &email).await?.is_some() { + return Err(AppError::Conflict("Email already registered".into())); + } + + let id = new_id(); + let hash = hash_password(password)?; + let verify_token = random_token(); + + sqlx::query!( + "INSERT INTO customers (id, email, password_hash, full_name, verify_token) VALUES (?, ?, ?, ?, ?)", + id, email, hash, full_name.trim(), verify_token + ) + .execute(pool).await?; + + let customer = get_by_id(pool, &id).await?.unwrap(); + Ok((customer, verify_token)) +} + +pub async fn verify_email(pool: &MySqlPool, token: &str) -> AppResult { + let row = sqlx::query!("SELECT id FROM customers WHERE verify_token = ?", token) + .fetch_optional(pool).await?; + let Some(r) = row else { return Ok(false) }; + sqlx::query!("UPDATE customers SET is_verified = 1, verify_token = NULL WHERE id = ?", r.id) + .execute(pool).await?; + Ok(true) +} + +pub async fn login( + pool: &MySqlPool, + cfg: &Config, + email: &str, + password: &str, +) -> AppResult { + let email = email.to_lowercase().trim().to_string(); + let customer = get_by_email(pool, &email) + .await? + .ok_or_else(|| AppError::Unauthorized("Invalid credentials".into()))?; + + if !customer.is_verified { + return Err(AppError::Unauthorized("Please verify your email before logging in".into())); + } + if !verify_password(&customer.password_hash, password) { + return Err(AppError::Unauthorized("Invalid credentials".into())); + } + + issue_jwt(cfg, &customer.id, &customer.email) +} + +pub async fn initiate_reset(pool: &MySqlPool, email: &str) -> AppResult> { + let email = email.to_lowercase().trim().to_string(); + let Some(c) = get_by_email(pool, &email).await? else { return Ok(None) }; + + let token = random_token(); + let expires = Utc::now().naive_utc() + chrono::Duration::hours(1); + + sqlx::query!( + "UPDATE customers SET reset_token = ?, reset_token_expires = ? WHERE id = ?", + token, expires, c.id + ).execute(pool).await?; + + Ok(Some(token)) +} + +pub async fn reset_password(pool: &MySqlPool, token: &str, new_password: &str) -> AppResult { + let row = sqlx::query!( + "SELECT id FROM customers WHERE reset_token = ? AND reset_token_expires > NOW()", + token + ).fetch_optional(pool).await?; + + let Some(r) = row else { return Ok(false) }; + let hash = hash_password(new_password)?; + + sqlx::query!( + "UPDATE customers SET password_hash = ?, reset_token = NULL, reset_token_expires = NULL WHERE id = ?", + hash, r.id + ).execute(pool).await?; + + Ok(true) +} diff --git a/api/src/services/licensing.rs b/api/src/services/licensing.rs new file mode 100644 index 0000000..e63881c --- /dev/null +++ b/api/src/services/licensing.rs @@ -0,0 +1,65 @@ +/// HTTP bridge to the nixpanel-licensing Rust server. +/// The website API calls this internally — clients never talk to it directly. +use anyhow::{Context, Result}; +use reqwest::Client; +use serde_json::{json, Value}; +use std::sync::OnceLock; + +use crate::config::LicensingConfig; + +static CLIENT: OnceLock = OnceLock::new(); + +fn client() -> &'static Client { + CLIENT.get_or_init(|| Client::builder().timeout(std::time::Duration::from_secs(10)).build().unwrap()) +} + +async fn request(cfg: &LicensingConfig, method: reqwest::Method, path: &str, body: Option) -> Result { + let mut req = client() + .request(method, format!("{}{path}", cfg.url)) + .header("Authorization", format!("Bearer {}", cfg.api_key)) + .header("Content-Type", "application/json"); + + if let Some(b) = body { + req = req.json(&b); + } + + let res = req.send().await.context("Licensing server request failed")?; + + if !res.status().is_success() { + let err: Value = res.json().await.unwrap_or(json!({"error": "unknown"})); + anyhow::bail!("{}", err["error"].as_str().unwrap_or("Licensing server error")); + } + + Ok(res.json().await.context("Failed to parse licensing server response")?) +} + +pub async fn create_license( + cfg: &LicensingConfig, + plan_id: &str, + owner_email: &str, + owner_name: &str, + server_ip: &str, +) -> Result { + request(cfg, reqwest::Method::POST, "/api/v1/licenses", Some(json!({ + "planId": plan_id, + "ownerEmail": owner_email, + "ownerName": owner_name, + "serverIp": server_ip, + }))).await +} + +pub async fn get_license(cfg: &LicensingConfig, license_id: &str) -> Result { + request(cfg, reqwest::Method::GET, &format!("/api/v1/licenses/{license_id}"), None).await +} + +pub async fn revoke_license(cfg: &LicensingConfig, license_id: &str) -> Result<()> { + request(cfg, reqwest::Method::POST, &format!("/api/v1/licenses/{license_id}/revoke"), None).await?; + Ok(()) +} + +pub async fn update_server_ip(cfg: &LicensingConfig, license_id: &str, server_ip: &str) -> Result<()> { + request(cfg, reqwest::Method::POST, &format!("/api/v1/licenses/{license_id}/rebind"), Some(json!({ + "serverIp": server_ip + }))).await?; + Ok(()) +} diff --git a/api/src/services/mail.rs b/api/src/services/mail.rs new file mode 100644 index 0000000..fdddbf4 --- /dev/null +++ b/api/src/services/mail.rs @@ -0,0 +1,75 @@ +use anyhow::Result; +use lettre::{ + message::header::ContentType, + transport::smtp::authentication::Credentials, + AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor, +}; +use std::sync::OnceLock; + +use crate::config::MailConfig; + +static MAILER: OnceLock> = OnceLock::new(); + +fn mailer(cfg: &MailConfig) -> &'static AsyncSmtpTransport { + MAILER.get_or_init(|| { + let creds = Credentials::new(cfg.username.clone(), cfg.password.clone()); + AsyncSmtpTransport::::relay(&cfg.host) + .unwrap() + .port(cfg.port) + .credentials(creds) + .build() + }) +} + +async fn send(cfg: &MailConfig, to: &str, subject: &str, html: String) -> Result<()> { + let email = Message::builder() + .from(cfg.from.parse()?) + .to(to.parse()?) + .subject(subject) + .header(ContentType::TEXT_HTML) + .body(html)?; + + mailer(cfg).send(email).await?; + Ok(()) +} + +pub async fn send_verification(cfg: &MailConfig, app_url: &str, to: &str, token: &str) -> Result<()> { + let url = format!("{app_url}/verify-email?token={token}"); + send(cfg, to, "Verify your NixPanel account", format!(r#" +

Welcome to NixPanel

+

Click below to verify your email address:

+ + Verify Email + +

This link expires in 24 hours.

+ "#)).await +} + +pub async fn send_password_reset(cfg: &MailConfig, app_url: &str, to: &str, token: &str) -> Result<()> { + let url = format!("{app_url}/reset-password?token={token}"); + send(cfg, to, "Reset your NixPanel password", format!(r#" +

Password Reset

+

Click below to reset your password. This link expires in 1 hour.

+ + Reset Password + +

If you didn't request this, ignore this email.

+ "#)).await +} + +pub async fn send_license_confirmation( + cfg: &MailConfig, + app_url: &str, + to: &str, + plan: &str, + server_ip: &str, + interval: &str, +) -> Result<()> { + send(cfg, to, &format!("Your NixPanel {plan} license is active"), format!(r#" +

License Activated

+

Your {plan} ({interval}) plan is now active for {server_ip}.

+ + Go to Dashboard + + "#)).await +} diff --git a/api/src/services/mod.rs b/api/src/services/mod.rs new file mode 100644 index 0000000..d121e94 --- /dev/null +++ b/api/src/services/mod.rs @@ -0,0 +1,4 @@ +pub mod auth; +pub mod licensing; +pub mod mail; +pub mod stripe; diff --git a/api/src/services/stripe.rs b/api/src/services/stripe.rs new file mode 100644 index 0000000..ad0311b --- /dev/null +++ b/api/src/services/stripe.rs @@ -0,0 +1,115 @@ +use anyhow::{Context, Result}; +use stripe::{ + BillingPortalSession, CheckoutSession, CheckoutSessionMode, Client, + CreateBillingPortalSession, CreateCheckoutSession, CreateCheckoutSessionLineItems, + CreateCheckoutSessionSubscriptionData, Currency, EventObject, EventType, + Subscription, SubscriptionId, Webhook, +}; +use std::sync::OnceLock; + +use crate::config::StripeConfig; + +static STRIPE: OnceLock = OnceLock::new(); + +pub fn client(cfg: &StripeConfig) -> &'static Client { + STRIPE.get_or_init(|| Client::new(&cfg.secret_key)) +} + +pub async fn create_checkout_session( + cfg: &StripeConfig, + stripe_customer: &str, + price_id: &str, + plan_key: &str, + interval: &str, + server_ip: &str, + customer_id: &str, + success_url: &str, + cancel_url: &str, +) -> Result { + use std::collections::HashMap; + + let mut metadata = HashMap::new(); + metadata.insert("nixpanel_customer_id".into(), customer_id.into()); + metadata.insert("plan_key".into(), plan_key.into()); + metadata.insert("billing_interval".into(), interval.into()); + metadata.insert("server_ip".into(), server_ip.into()); + + let mut params = CreateCheckoutSession::new(); + params.customer = Some(stripe_customer.parse().context("Invalid customer ID")?); + params.mode = Some(CheckoutSessionMode::Subscription); + params.success_url = Some(success_url); + params.cancel_url = Some(cancel_url); + params.metadata = Some(metadata.clone()); + params.line_items = Some(vec![CreateCheckoutSessionLineItems { + price: Some(price_id.to_string()), + quantity: Some(1), + ..Default::default() + }]); + params.subscription_data = Some(CreateCheckoutSessionSubscriptionData { + metadata: Some(metadata), + ..Default::default() + }); + + let session = CheckoutSession::create(client(cfg), params) + .await + .context("Failed to create Stripe checkout session")?; + + session.url.context("Stripe session missing URL") +} + +pub async fn create_portal_session( + cfg: &StripeConfig, + stripe_customer: &str, + return_url: &str, +) -> Result { + let mut params = CreateBillingPortalSession::new( + stripe_customer.parse().context("Invalid customer ID")? + ); + params.return_url = Some(return_url); + + let session = BillingPortalSession::create(client(cfg), params) + .await + .context("Failed to create billing portal session")?; + + Ok(session.url) +} + +pub async fn cancel_at_period_end(cfg: &StripeConfig, subscription_id: &str) -> Result<()> { + use stripe::UpdateSubscription; + let id = subscription_id.parse::().context("Invalid subscription ID")?; + let mut params = UpdateSubscription::new(); + params.cancel_at_period_end = Some(true); + Subscription::update(client(cfg), &id, params) + .await + .context("Failed to cancel subscription")?; + Ok(()) +} + +pub async fn change_plan(cfg: &StripeConfig, subscription_id: &str, new_price_id: &str) -> Result<()> { + use stripe::{UpdateSubscription, UpdateSubscriptionItems}; + let id = subscription_id.parse::().context("Invalid subscription ID")?; + let sub = Subscription::retrieve(client(cfg), &id, &[]).await?; + let item_id = sub.items.data.first().context("No subscription items")?.id.clone(); + + let mut params = UpdateSubscription::new(); + params.items = Some(vec![UpdateSubscriptionItems { + id: Some(item_id.to_string()), + price: Some(new_price_id.to_string()), + ..Default::default() + }]); + params.proration_behavior = Some(stripe::SubscriptionProrationBehavior::CreateProrations); + + Subscription::update(client(cfg), &id, params) + .await + .context("Failed to change plan")?; + Ok(()) +} + +pub fn construct_webhook_event(payload: &[u8], signature: &str, secret: &str) -> Result { + Webhook::construct_event( + std::str::from_utf8(payload).context("Invalid payload")?, + signature, + secret, + ) + .context("Invalid webhook signature") +} diff --git a/api/src/state.rs b/api/src/state.rs new file mode 100644 index 0000000..51f4813 --- /dev/null +++ b/api/src/state.rs @@ -0,0 +1,15 @@ +use sqlx::MySqlPool; +use std::sync::Arc; +use crate::config::Config; + +#[derive(Clone)] +pub struct AppState { + pub pool: MySqlPool, + pub config: Arc, +} + +impl AppState { + pub fn new(pool: MySqlPool, config: Config) -> Self { + Self { pool, config: Arc::new(config) } + } +}