seems alright

This commit is contained in:
Andras Schmelczer 2026-05-17 13:52:11 +01:00
parent ebe7bbb51d
commit eac1bd0d13
58 changed files with 23125 additions and 153505 deletions

View file

@ -292,6 +292,47 @@ async fn mark_invite_used(
return Err(StatusCode::BAD_GATEWAY.into_response());
}
// Defense in depth: PocketBase has no atomic compare-and-swap for record
// updates, and our local + distributed locks could in principle fail (lock
// server timeout, server restart mid-redemption). Re-read the record and
// confirm WE actually own it — if a concurrent redemption beat us to the
// PATCH, both writes succeeded but the loser's user_id is overwritten and
// we must NOT grant a license.
let verify_url = format!("{pb_url}/api/collections/invites/records/{invite_id}");
let verify_resp = match state
.http_client
.get(&verify_url)
.header("Authorization", format!("Bearer {token}"))
.send()
.await
{
Ok(r) => r,
Err(err) => {
warn!("Failed to verify invite redemption: {err}");
return Err(StatusCode::BAD_GATEWAY.into_response());
}
};
if !verify_resp.status().is_success() {
return Err(StatusCode::BAD_GATEWAY.into_response());
}
let body: serde_json::Value = match verify_resp.json().await {
Ok(v) => v,
Err(err) => {
warn!("Failed to parse invite verify response: {err}");
return Err(StatusCode::BAD_GATEWAY.into_response());
}
};
let actual_user = body["used_by_id"].as_str().unwrap_or("");
if actual_user != user_id {
warn!(
invite_id,
expected = user_id,
actual = actual_user,
"Invite redemption race lost — invite already claimed by another user"
);
return Err((StatusCode::CONFLICT, "Invite was already redeemed").into_response());
}
Ok(())
}
@ -512,11 +553,16 @@ pub async fn get_invite(
.await
{
Ok(resp) if resp.status().is_success() => {
let user_body: serde_json::Value = resp.json().await.unwrap_or_default();
user_body["email"]
.as_str()
.and_then(|e| e.split('@').next())
.and_then(sanitize_invited_by)
match resp.json::<serde_json::Value>().await {
Ok(user_body) => user_body["email"]
.as_str()
.and_then(|e| e.split('@').next())
.and_then(sanitize_invited_by),
Err(err) => {
tracing::error!("Failed to parse inviter user record JSON: {err}");
return StatusCode::BAD_GATEWAY.into_response();
}
}
}
_ => None,
}
@ -689,26 +735,6 @@ pub async fn post_redeem_invite(
.into_response()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn redeemable_invite_filter_allows_unused_or_same_user_invite() {
let filter = redeemable_invite_filter("abc123", "user123").unwrap();
assert_eq!(
filter,
"code=\"abc123\" && (used_by_id=\"\" || used_by_id=\"user123\")"
);
}
#[test]
fn redeemable_invite_filter_rejects_unsafe_values() {
assert!(redeemable_invite_filter("bad-code", "user123").is_err());
assert!(redeemable_invite_filter("abc123", "bad-user").is_err());
}
}
/// List invites. Users only see invites they created, including admins.
pub async fn get_invites(
State(shared): State<Arc<SharedState>>,
@ -787,3 +813,23 @@ pub async fn get_invites(
Json(InviteListResponse { invites }).into_response()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn redeemable_invite_filter_allows_unused_or_same_user_invite() {
let filter = redeemable_invite_filter("abc123", "user123").unwrap();
assert_eq!(
filter,
"code=\"abc123\" && (used_by_id=\"\" || used_by_id=\"user123\")"
);
}
#[test]
fn redeemable_invite_filter_rejects_unsafe_values() {
assert!(redeemable_invite_filter("bad-code", "user123").is_err());
assert!(redeemable_invite_filter("abc123", "bad-user").is_err());
}
}