feat: rewrite website API in Rust — axum, sqlx, Stripe, lettre, argon2, MariaDB

This commit is contained in:
NixReaper
2026-04-23 17:28:37 -05:00
parent faf0818dde
commit d0424577a9
21 changed files with 1451 additions and 0 deletions
+154
View File
@@ -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)
}
+65
View File
@@ -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(())
}
+75
View File
@@ -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
}
+4
View File
@@ -0,0 +1,4 @@
pub mod auth;
pub mod licensing;
pub mod mail;
pub mod stripe;
+115
View File
@@ -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")
}