feat: rewrite website API in Rust — axum, sqlx, Stripe, lettre, argon2, MariaDB
This commit is contained in:
@@ -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:[email protected]: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=[email protected]
|
||||
MAIL_PASSWORD=your_smtp_password
|
||||
MAIL_FROM=[email protected]
|
||||
@@ -0,0 +1,3 @@
|
||||
target/
|
||||
.env
|
||||
*.log
|
||||
@@ -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
|
||||
@@ -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)
|
||||
);
|
||||
@@ -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<Self> {
|
||||
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(|_| "[email protected]".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())
|
||||
}
|
||||
}
|
||||
@@ -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<MySqlPool> {
|
||||
let pool = MySqlPoolOptions::new()
|
||||
.max_connections(20)
|
||||
.connect(&cfg.database_url)
|
||||
.await?;
|
||||
|
||||
sqlx::migrate!("./migrations").run(&pool).await?;
|
||||
|
||||
Ok(pool)
|
||||
}
|
||||
@@ -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<T> = Result<T, AppError>;
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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<AppState> for AuthCustomer {
|
||||
type Rejection = Response;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod auth;
|
||||
@@ -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<String>,
|
||||
#[serde(skip_serializing)]
|
||||
pub reset_token: Option<String>,
|
||||
#[serde(skip_serializing)]
|
||||
pub reset_token_expires: Option<NaiveDateTime>,
|
||||
pub stripe_customer_id: Option<String>,
|
||||
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<NaiveDateTime>,
|
||||
pub created_at: NaiveDateTime,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct CustomerClaims {
|
||||
pub sub: String,
|
||||
pub email: String,
|
||||
pub exp: usize,
|
||||
}
|
||||
@@ -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<AppState>,
|
||||
Json(body): Json<RegisterRequest>,
|
||||
) -> AppResult<(StatusCode, Json<serde_json::Value>)> {
|
||||
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<AppState>,
|
||||
Json(body): Json<LoginRequest>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
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<AppState>,
|
||||
AuthCustomer(claims): AuthCustomer,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
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<AppState>,
|
||||
axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
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<AppState>,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
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<AppState>,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
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." })))
|
||||
}
|
||||
@@ -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<serde_json::Value> {
|
||||
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<AppState>,
|
||||
AuthCustomer(claims): AuthCustomer,
|
||||
Json(body): Json<CheckoutRequest>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
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<AppState>,
|
||||
AuthCustomer(claims): AuthCustomer,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
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<AppState>,
|
||||
AuthCustomer(claims): AuthCustomer,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
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<AppState>,
|
||||
AuthCustomer(claims): AuthCustomer,
|
||||
Json(body): Json<ChangePlanRequest>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
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<AppState>,
|
||||
AuthCustomer(claims): AuthCustomer,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
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<AppState>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
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<Option<crate::models::Subscription>> {
|
||||
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<String> {
|
||||
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)
|
||||
}
|
||||
@@ -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<AppState>,
|
||||
AuthCustomer(claims): AuthCustomer,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
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<AppState>,
|
||||
AuthCustomer(claims): AuthCustomer,
|
||||
Json(body): Json<ChangeIpRequest>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
// 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<AppState>,
|
||||
AuthCustomer(claims): AuthCustomer,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
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<serde_json::Value> = 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::<std::net::Ipv4Addr>().is_ok()
|
||||
}
|
||||
@@ -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<AppState> {
|
||||
Router::new()
|
||||
.nest("/auth", auth_router())
|
||||
.nest("/billing", billing_router())
|
||||
.nest("/license", license_router())
|
||||
}
|
||||
|
||||
fn auth_router() -> Router<AppState> {
|
||||
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<AppState> {
|
||||
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<AppState> {
|
||||
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<serde_json::Value> {
|
||||
axum::Json(serde_json::json!({ "status": "ok", "ts": chrono::Utc::now().to_rfc3339() }))
|
||||
}
|
||||
@@ -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<String> {
|
||||
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<String> {
|
||||
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<CustomerClaims> {
|
||||
decode::<CustomerClaims>(
|
||||
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<Option<Customer>> {
|
||||
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<Option<Customer>> {
|
||||
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<bool> {
|
||||
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<String> {
|
||||
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<Option<String>> {
|
||||
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<bool> {
|
||||
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)
|
||||
}
|
||||
@@ -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<Client> = 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<Value>) -> Result<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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(())
|
||||
}
|
||||
@@ -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<AsyncSmtpTransport<Tokio1Executor>> = OnceLock::new();
|
||||
|
||||
fn mailer(cfg: &MailConfig) -> &'static AsyncSmtpTransport<Tokio1Executor> {
|
||||
MAILER.get_or_init(|| {
|
||||
let creds = Credentials::new(cfg.username.clone(), cfg.password.clone());
|
||||
AsyncSmtpTransport::<Tokio1Executor>::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#"
|
||||
<h2>Welcome to NixPanel</h2>
|
||||
<p>Click below to verify your email address:</p>
|
||||
<a href="{url}" style="background:#2563eb;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;">
|
||||
Verify Email
|
||||
</a>
|
||||
<p>This link expires in 24 hours.</p>
|
||||
"#)).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#"
|
||||
<h2>Password Reset</h2>
|
||||
<p>Click below to reset your password. This link expires in 1 hour.</p>
|
||||
<a href="{url}" style="background:#2563eb;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;">
|
||||
Reset Password
|
||||
</a>
|
||||
<p>If you didn't request this, ignore this email.</p>
|
||||
"#)).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#"
|
||||
<h2>License Activated</h2>
|
||||
<p>Your <strong>{plan}</strong> ({interval}) plan is now active for <strong>{server_ip}</strong>.</p>
|
||||
<a href="{app_url}/dashboard" style="background:#2563eb;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;">
|
||||
Go to Dashboard
|
||||
</a>
|
||||
"#)).await
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod auth;
|
||||
pub mod licensing;
|
||||
pub mod mail;
|
||||
pub mod stripe;
|
||||
@@ -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<Client> = 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<String> {
|
||||
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<String> {
|
||||
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::<SubscriptionId>().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::<SubscriptionId>().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<stripe::Event> {
|
||||
Webhook::construct_event(
|
||||
std::str::from_utf8(payload).context("Invalid payload")?,
|
||||
signature,
|
||||
secret,
|
||||
)
|
||||
.context("Invalid webhook signature")
|
||||
}
|
||||
@@ -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<Config>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(pool: MySqlPool, config: Config) -> Self {
|
||||
Self { pool, config: Arc::new(config) }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user