deploy
This commit is contained in:
parent
273d7a83ee
commit
084117cea8
48 changed files with 2283 additions and 890 deletions
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue