seems alright
This commit is contained in:
parent
ebe7bbb51d
commit
eac1bd0d13
58 changed files with 23125 additions and 153505 deletions
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue