This commit is contained in:
Andras Schmelczer 2026-05-14 20:42:48 +01:00
parent 273d7a83ee
commit 084117cea8
48 changed files with 2283 additions and 890 deletions

View file

@ -10,7 +10,8 @@ use tracing::{info, warn};
use crate::auth::{OptionalUser, PocketBaseUser};
use crate::checkout_sessions::{
active_referral_checkout_user, start_license_checkout, CheckoutStart,
active_referral_checkout_user, grant_license_with_pricing_lock, start_license_checkout,
CheckoutStart,
};
use crate::pocketbase::get_superuser_token;
use crate::pocketbase_locks::acquire_pocketbase_lock;
@ -107,6 +108,25 @@ fn validate_invite_code(code: &str) -> Result<(), &'static str> {
Ok(())
}
/// Sanitize the inviter's display name returned to anonymous clients.
/// The value comes from the inviter's email local-part stored in PocketBase;
/// we don't trust it, so strip control chars and HTML-meaningful characters
/// and cap the length. Returns None if nothing usable remains.
fn sanitize_invited_by(raw: &str) -> Option<String> {
const MAX_LEN: usize = 40;
let cleaned: String = raw
.chars()
.filter(|c| !c.is_control() && !matches!(*c, '<' | '>' | '"' | '\'' | '&' | '\\'))
.take(MAX_LEN)
.collect();
let trimmed = cleaned.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
fn generate_invite_code() -> String {
use rand::RngExt;
let mut rng = rand::rng();
@ -131,6 +151,48 @@ fn current_unix_secs_string() -> String {
.to_string()
}
/// Fetch the live `is_admin` flag for a user, bypassing any cached token
/// claims. Returns Err with an HTTP response if PocketBase is unreachable
/// or returns an unexpected payload — the caller should propagate that.
async fn verify_is_admin(
state: &AppState,
pb_url: &str,
token: &str,
user_id: &str,
) -> Result<bool, Response> {
if user_id.is_empty()
|| user_id.len() > 32
|| !user_id.bytes().all(|b| b.is_ascii_alphanumeric())
{
return Err(StatusCode::FORBIDDEN.into_response());
}
let url = format!("{pb_url}/api/collections/users/records/{user_id}");
let resp = match state
.http_client
.get(&url)
.header("Authorization", format!("Bearer {token}"))
.send()
.await
{
Ok(r) => r,
Err(err) => {
warn!("Failed to verify is_admin: {err}");
return Err(StatusCode::BAD_GATEWAY.into_response());
}
};
if !resp.status().is_success() {
return Err(StatusCode::BAD_GATEWAY.into_response());
}
let body: serde_json::Value = match resp.json().await {
Ok(v) => v,
Err(err) => {
warn!("Failed to parse user record for is_admin verify: {err}");
return Err(StatusCode::BAD_GATEWAY.into_response());
}
};
Ok(body["is_admin"].as_bool().unwrap_or(false))
}
async fn lookup_unused_invite(
state: &AppState,
pb_url: &str,
@ -217,35 +279,16 @@ async fn mark_invite_used(
async fn grant_license_for_invite(
state: &AppState,
pb_url: &str,
token: &str,
_pb_url: &str,
_token: &str,
user_id: &str,
) -> Result<(), Response> {
let update_url = format!("{pb_url}/api/collections/users/records/{user_id}");
let resp = match state
.http_client
.patch(&update_url)
.header("Authorization", format!("Bearer {token}"))
.json(&serde_json::json!({ "subscription": "licensed" }))
.send()
grant_license_with_pricing_lock(state, user_id)
.await
{
Ok(resp) => resp,
Err(err) => {
.map_err(|err| {
warn!("Failed to update user subscription for admin invite: {err}");
return Err(StatusCode::BAD_GATEWAY.into_response());
}
};
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
warn!("PocketBase user subscription update failed ({status}): {text}");
return Err(StatusCode::BAD_GATEWAY.into_response());
}
state.token_cache.invalidate_by_user_id(user_id);
Ok(())
StatusCode::BAD_GATEWAY.into_response()
})
}
async fn create_referral_checkout(
@ -289,12 +332,32 @@ pub async fn post_invites(
None => return StatusCode::UNAUTHORIZED.into_response(),
};
let invite_type = if user.is_admin {
match body.invite_type.as_deref() {
Some("referral") => "referral",
_ => "admin",
// Cached token claims could be stale or, in the worst case, tampered with
// upstream of us. For admin-only actions, re-fetch the live record from
// PocketBase and trust only that.
let wants_admin_invite =
user.is_admin && !matches!(body.invite_type.as_deref(), Some("referral"));
let pb_url = state.pocketbase_url.trim_end_matches('/');
let token = match get_superuser_token(&state).await {
Ok(t) => t,
Err(err) => {
warn!("Failed to auth as PocketBase superuser: {err}");
return StatusCode::BAD_GATEWAY.into_response();
}
} else if user.subscription == "licensed" {
};
let invite_type = if wants_admin_invite {
match verify_is_admin(&state, pb_url, &token, &user.id).await {
Ok(true) => "admin",
Ok(false) => {
warn!(user_id = %user.id, "is_admin claim rejected by live PB lookup");
return (StatusCode::FORBIDDEN, "Not authorised").into_response();
}
Err(response) => return response,
}
} else if user.is_admin || user.subscription == "licensed" {
"referral"
} else {
return (
@ -305,15 +368,6 @@ pub async fn post_invites(
};
let code = generate_invite_code();
let pb_url = state.pocketbase_url.trim_end_matches('/');
let token = match get_superuser_token(&state).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
@ -429,7 +483,7 @@ pub async fn get_invite(
let used = !used_by.is_empty();
let created_by = invite["created_by"].as_str().unwrap_or("");
// Look up inviter's name (email local part)
// Look up inviter's name (email local part) — sanitized before returning.
let invited_by = if !created_by.is_empty() {
let user_url = format!("{pb_url}/api/collections/users/records/{created_by}");
match state
@ -444,7 +498,7 @@ pub async fn get_invite(
user_body["email"]
.as_str()
.and_then(|e| e.split('@').next())
.map(String::from)
.and_then(sanitize_invited_by)
}
_ => None,
}
@ -565,11 +619,11 @@ pub async fn post_redeem_invite(
};
if invite_type == "admin" {
if let Err(response) = mark_invite_used(&state, pb_url, &token, invite_id, &user.id).await {
if let Err(response) = grant_license_for_invite(&state, pb_url, &token, &user.id).await {
return response;
}
if let Err(response) = grant_license_for_invite(&state, pb_url, &token, &user.id).await {
if let Err(response) = mark_invite_used(&state, pb_url, &token, invite_id, &user.id).await {
return response;
}