lmao
This commit is contained in:
parent
03445188ea
commit
524580eb25
102 changed files with 36625 additions and 1295 deletions
374
server-rs/src/routes/invites.rs
Normal file
374
server-rs/src/routes/invites.rs
Normal file
|
|
@ -0,0 +1,374 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::Path;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::{Extension, Json};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::auth::OptionalUser;
|
||||
use crate::pocketbase::auth_superuser;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct InviteResponse {
|
||||
code: String,
|
||||
url: String,
|
||||
invite_type: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct InviteValidation {
|
||||
valid: bool,
|
||||
invite_type: String,
|
||||
used: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RedeemRequest {
|
||||
code: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct RedeemResponse {
|
||||
/// "licensed" if admin invite was redeemed directly, or a checkout URL for referral
|
||||
result: String,
|
||||
/// For referral invites: the Stripe checkout URL with coupon
|
||||
checkout_url: Option<String>,
|
||||
}
|
||||
|
||||
/// Validate that an invite code contains only safe characters (alphanumeric, lowercase).
|
||||
/// Rejects any code that could be used for PocketBase filter injection.
|
||||
fn validate_invite_code(code: &str) -> Result<(), &'static str> {
|
||||
if code.is_empty() || code.len() > 20 {
|
||||
return Err("Invalid invite code length");
|
||||
}
|
||||
if !code.bytes().all(|b| b.is_ascii_alphanumeric()) {
|
||||
return Err("Invalid invite code characters");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn generate_invite_code() -> String {
|
||||
use rand::Rng;
|
||||
let mut rng = rand::rng();
|
||||
let chars: Vec<char> = (0..12)
|
||||
.map(|_| {
|
||||
let idx: u8 = rng.random_range(0..36);
|
||||
if idx < 10 {
|
||||
(b'0' + idx) as char
|
||||
} else {
|
||||
(b'a' + idx - 10) as char
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
chars.into_iter().collect()
|
||||
}
|
||||
|
||||
/// Create an invite. Admins create "admin" invites (free license).
|
||||
/// Licensed non-admin users create "referral" invites (30% off).
|
||||
pub async fn post_invites(
|
||||
state: Arc<AppState>,
|
||||
Extension(user): Extension<OptionalUser>,
|
||||
_body: Json<serde_json::Value>,
|
||||
) -> Response {
|
||||
let user = match user.0 {
|
||||
Some(u) => u,
|
||||
None => return StatusCode::UNAUTHORIZED.into_response(),
|
||||
};
|
||||
|
||||
let invite_type = if user.is_admin {
|
||||
"admin"
|
||||
} else if user.subscription == "licensed" {
|
||||
"referral"
|
||||
} else {
|
||||
return (StatusCode::FORBIDDEN, "Only licensed users can create invites").into_response();
|
||||
};
|
||||
|
||||
let code = generate_invite_code();
|
||||
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
||||
|
||||
let token = match auth_superuser(&state.http_client, pb_url, &state.pocketbase_admin_email, &state.pocketbase_admin_password).await
|
||||
{
|
||||
Ok(t) => t,
|
||||
Err(err) => {
|
||||
warn!("Failed to auth as PocketBase superuser: {err}");
|
||||
return StatusCode::BAD_GATEWAY.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let create_url = format!("{pb_url}/api/collections/invites/records");
|
||||
let res = state
|
||||
.http_client
|
||||
.post(&create_url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.json(&serde_json::json!({
|
||||
"code": code,
|
||||
"created_by": user.id,
|
||||
"invite_type": invite_type,
|
||||
"used_by_id": "",
|
||||
"used_at": "",
|
||||
}))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match res {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
let public_url = &state.public_url;
|
||||
let url = format!("{public_url}/invite/{code}");
|
||||
info!(code = %code, invite_type, user_id = %user.id, "Created invite");
|
||||
Json(InviteResponse {
|
||||
code,
|
||||
url,
|
||||
invite_type: invite_type.to_string(),
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
warn!("Failed to create invite ({status}): {text}");
|
||||
StatusCode::BAD_GATEWAY.into_response()
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("PocketBase request error: {err}");
|
||||
StatusCode::BAD_GATEWAY.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate an invite code. Requires authentication to prevent enumeration.
|
||||
pub async fn get_invite(
|
||||
state: Arc<AppState>,
|
||||
Extension(user): Extension<OptionalUser>,
|
||||
Path(code): Path<String>,
|
||||
) -> Response {
|
||||
if user.0.is_none() {
|
||||
return StatusCode::UNAUTHORIZED.into_response();
|
||||
}
|
||||
|
||||
if let Err(msg) = validate_invite_code(&code) {
|
||||
return (StatusCode::BAD_REQUEST, msg).into_response();
|
||||
}
|
||||
|
||||
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
||||
let filter = format!("code=\"{}\"", code);
|
||||
let url = format!(
|
||||
"{pb_url}/api/collections/invites/records?filter={}&perPage=1",
|
||||
urlencoding::encode(&filter)
|
||||
);
|
||||
|
||||
let res = match state.http_client.get(&url).send().await {
|
||||
Ok(r) => r,
|
||||
Err(err) => {
|
||||
warn!("Failed to look up invite: {err}");
|
||||
return StatusCode::BAD_GATEWAY.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if !res.status().is_success() {
|
||||
return StatusCode::BAD_GATEWAY.into_response();
|
||||
}
|
||||
|
||||
let body: serde_json::Value = match res.json().await {
|
||||
Ok(v) => v,
|
||||
Err(_) => return StatusCode::BAD_GATEWAY.into_response(),
|
||||
};
|
||||
|
||||
let items = body["items"].as_array();
|
||||
match items.and_then(|arr| arr.first()) {
|
||||
Some(invite) => {
|
||||
let invite_type = invite["invite_type"].as_str().unwrap_or("").to_string();
|
||||
let used_by = invite["used_by_id"].as_str().unwrap_or("");
|
||||
let used = !used_by.is_empty();
|
||||
Json(InviteValidation {
|
||||
valid: true,
|
||||
invite_type,
|
||||
used,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
None => Json(InviteValidation {
|
||||
valid: false,
|
||||
invite_type: String::new(),
|
||||
used: false,
|
||||
})
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Redeem an invite code. Requires authentication.
|
||||
/// Admin invite: sets subscription to "licensed" directly.
|
||||
/// Referral invite: returns a discounted Stripe checkout URL.
|
||||
pub async fn post_redeem_invite(
|
||||
state: Arc<AppState>,
|
||||
Extension(user): Extension<OptionalUser>,
|
||||
Json(req): Json<RedeemRequest>,
|
||||
) -> Response {
|
||||
let user = match user.0 {
|
||||
Some(u) => u,
|
||||
None => return StatusCode::UNAUTHORIZED.into_response(),
|
||||
};
|
||||
|
||||
if let Err(msg) = validate_invite_code(&req.code) {
|
||||
return (StatusCode::BAD_REQUEST, msg).into_response();
|
||||
}
|
||||
|
||||
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
||||
|
||||
let token = match auth_superuser(&state.http_client, pb_url, &state.pocketbase_admin_email, &state.pocketbase_admin_password).await
|
||||
{
|
||||
Ok(t) => t,
|
||||
Err(err) => {
|
||||
warn!("Failed to auth as PocketBase superuser: {err}");
|
||||
return StatusCode::BAD_GATEWAY.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// Look up invite
|
||||
let filter = format!(
|
||||
"code=\"{}\" && used_by_id=\"\"",
|
||||
req.code
|
||||
);
|
||||
let lookup_url = format!(
|
||||
"{pb_url}/api/collections/invites/records?filter={}&perPage=1",
|
||||
urlencoding::encode(&filter)
|
||||
);
|
||||
|
||||
let res = match state.http_client.get(&lookup_url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.send().await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(err) => {
|
||||
warn!("Failed to look up invite: {err}");
|
||||
return StatusCode::BAD_GATEWAY.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let body: serde_json::Value = match res.json().await {
|
||||
Ok(v) => v,
|
||||
Err(_) => return StatusCode::BAD_GATEWAY.into_response(),
|
||||
};
|
||||
|
||||
let invite = match body["items"].as_array().and_then(|arr| arr.first()) {
|
||||
Some(inv) => inv.clone(),
|
||||
None => {
|
||||
return (StatusCode::NOT_FOUND, "Invalid or already used invite code").into_response()
|
||||
}
|
||||
};
|
||||
|
||||
let invite_id = invite["id"].as_str().unwrap_or("");
|
||||
let invite_type = invite["invite_type"].as_str().unwrap_or("");
|
||||
|
||||
// Mark invite as used
|
||||
let now = {
|
||||
let dur = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default();
|
||||
dur.as_secs().to_string()
|
||||
};
|
||||
let _ = state
|
||||
.http_client
|
||||
.patch(&format!(
|
||||
"{pb_url}/api/collections/invites/records/{invite_id}"
|
||||
))
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.json(&serde_json::json!({
|
||||
"used_by_id": user.id,
|
||||
"used_at": now,
|
||||
}))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
if invite_type == "admin" {
|
||||
// Grant license directly
|
||||
let update_url = format!("{pb_url}/api/collections/users/records/{}", user.id);
|
||||
let res = state
|
||||
.http_client
|
||||
.patch(&update_url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.json(&serde_json::json!({ "subscription": "licensed" }))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match res {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
state.token_cache.invalidate_by_user_id(&user.id);
|
||||
info!(user_id = %user.id, code = %req.code, "Admin invite redeemed — user licensed");
|
||||
Json(RedeemResponse {
|
||||
result: "licensed".to_string(),
|
||||
checkout_url: None,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
_ => {
|
||||
warn!("Failed to update user subscription for admin invite");
|
||||
StatusCode::BAD_GATEWAY.into_response()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Referral invite — create discounted checkout with dynamic pricing
|
||||
let count = match super::pricing::count_licensed_users(&state).await {
|
||||
Ok(c) => c,
|
||||
Err(err) => {
|
||||
warn!("Failed to count licensed users for invite checkout: {err}");
|
||||
return StatusCode::SERVICE_UNAVAILABLE.into_response();
|
||||
}
|
||||
};
|
||||
let price_pence = super::pricing::price_for_count(count);
|
||||
|
||||
let secret_key = &state.stripe_secret_key;
|
||||
let public_url = &state.public_url;
|
||||
let success_url = format!("{public_url}/pricing?license_success=1");
|
||||
let cancel_url = format!("{public_url}/pricing");
|
||||
|
||||
let form_params = vec![
|
||||
("mode", "payment".to_string()),
|
||||
(
|
||||
"line_items[0][price_data][unit_amount]",
|
||||
price_pence.to_string(),
|
||||
),
|
||||
("line_items[0][price_data][currency]", "gbp".to_string()),
|
||||
(
|
||||
"line_items[0][price_data][product_data][name]",
|
||||
"Perfect Postcodes Lifetime License".to_string(),
|
||||
),
|
||||
("line_items[0][quantity]", "1".to_string()),
|
||||
("success_url", success_url),
|
||||
("cancel_url", cancel_url),
|
||||
("client_reference_id", user.id.clone()),
|
||||
("customer_email", user.email.clone()),
|
||||
("discounts[0][coupon]", state.stripe_referral_coupon_id.clone()),
|
||||
];
|
||||
|
||||
let stripe_res = state
|
||||
.http_client
|
||||
.post("https://api.stripe.com/v1/checkout/sessions")
|
||||
.basic_auth(secret_key, None::<&str>)
|
||||
.form(&form_params)
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match stripe_res {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
let stripe_body: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||
let checkout_url = stripe_body["url"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
info!(user_id = %user.id, code = %req.code, "Referral invite redeemed — checkout created");
|
||||
Json(RedeemResponse {
|
||||
result: "checkout".to_string(),
|
||||
checkout_url: Some(checkout_url),
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
_ => {
|
||||
warn!("Failed to create Stripe checkout for referral invite");
|
||||
StatusCode::BAD_GATEWAY.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue