feat: rewrite website API in Rust — axum, sqlx, Stripe, lettre, argon2, MariaDB
This commit is contained in:
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user