deploy
This commit is contained in:
parent
273d7a83ee
commit
084117cea8
48 changed files with 2283 additions and 890 deletions
|
|
@ -9,7 +9,7 @@ use sha2::Sha256;
|
|||
use tracing::{info, warn};
|
||||
|
||||
use crate::checkout_sessions::{
|
||||
grant_license, mark_checkout_completed, mark_referral_invite_used, verify_checkout_completion,
|
||||
complete_verified_checkout, reverse_license_for_payment_intent, verify_checkout_completion,
|
||||
CheckoutCompletion,
|
||||
};
|
||||
use crate::state::SharedState;
|
||||
|
|
@ -54,16 +54,52 @@ fn verify_signature(payload: &[u8], sig_header: &str, secret: &str) -> bool {
|
|||
signed_payload.push(b'.');
|
||||
signed_payload.extend_from_slice(payload);
|
||||
|
||||
signatures.into_iter().any(|sig_hex| {
|
||||
// Verify every candidate signature without short-circuiting, so the total
|
||||
// time taken doesn't depend on which (if any) signature matched.
|
||||
let mut matched = false;
|
||||
for sig_hex in signatures {
|
||||
let Ok(sig_bytes) = hex::decode(sig_hex) else {
|
||||
return false;
|
||||
continue;
|
||||
};
|
||||
let Ok(mut mac) = HmacSha256::new_from_slice(secret.as_bytes()) else {
|
||||
return false;
|
||||
continue;
|
||||
};
|
||||
mac.update(&signed_payload);
|
||||
mac.verify_slice(&sig_bytes).is_ok()
|
||||
})
|
||||
// verify_slice itself is constant-time.
|
||||
if mac.verify_slice(&sig_bytes).is_ok() {
|
||||
matched = true;
|
||||
}
|
||||
}
|
||||
matched
|
||||
}
|
||||
|
||||
fn payment_intent_id_from_object(object: &serde_json::Value) -> Option<&str> {
|
||||
object["payment_intent"]
|
||||
.as_str()
|
||||
.filter(|id| is_safe_stripe_id(id))
|
||||
}
|
||||
|
||||
fn is_safe_stripe_id(id: &str) -> bool {
|
||||
!id.is_empty()
|
||||
&& id.len() <= 128
|
||||
&& id
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
|
||||
}
|
||||
|
||||
fn reversal_event_is_actionable(event_type: &str, object: &serde_json::Value) -> bool {
|
||||
match event_type {
|
||||
"charge.refunded" => {
|
||||
object["refunded"].as_bool().unwrap_or(false)
|
||||
|| object["amount_refunded"].as_u64().unwrap_or(0) > 0
|
||||
}
|
||||
"charge.refund.updated" | "refund.created" | "refund.updated" => {
|
||||
matches!(object["status"].as_str(), Some("succeeded"))
|
||||
}
|
||||
"charge.dispute.created" | "charge.dispute.funds_withdrawn" => true,
|
||||
"charge.dispute.closed" => matches!(object["status"].as_str(), Some("lost")),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle Stripe webhook events.
|
||||
|
|
@ -109,40 +145,11 @@ pub async fn post_stripe_webhook(
|
|||
let session = &event["data"]["object"];
|
||||
match verify_checkout_completion(&state, session).await {
|
||||
Ok(CheckoutCompletion::Grant(checkout)) => {
|
||||
if let Err(err) = mark_referral_invite_used(
|
||||
&state,
|
||||
&checkout.referral_invite_id,
|
||||
&checkout.user_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
if let Err(err) = complete_verified_checkout(&state, &checkout).await {
|
||||
warn!(
|
||||
user_id = %checkout.user_id,
|
||||
reservation_id = %checkout.reservation_id,
|
||||
referral_invite_id = %checkout.referral_invite_id,
|
||||
"Failed to mark referral invite used after Stripe checkout: {err:?}"
|
||||
);
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
}
|
||||
if let Err(err) = grant_license(&state, &checkout.user_id).await {
|
||||
warn!(
|
||||
user_id = %checkout.user_id,
|
||||
reservation_id = %checkout.reservation_id,
|
||||
"Failed to grant license after Stripe checkout: {err:?}"
|
||||
);
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
}
|
||||
if let Err(err) = mark_checkout_completed(
|
||||
&state,
|
||||
&checkout.reservation_id,
|
||||
checkout.paid_amount_pence,
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
user_id = %checkout.user_id,
|
||||
reservation_id = %checkout.reservation_id,
|
||||
"Failed to mark checkout completed after license grant: {err:?}"
|
||||
"Failed to complete verified Stripe checkout: {err:?}"
|
||||
);
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
}
|
||||
|
|
@ -163,6 +170,52 @@ pub async fn post_stripe_webhook(
|
|||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
}
|
||||
}
|
||||
} else if matches!(
|
||||
event_type,
|
||||
"charge.refunded"
|
||||
| "charge.refund.updated"
|
||||
| "refund.created"
|
||||
| "refund.updated"
|
||||
| "charge.dispute.created"
|
||||
| "charge.dispute.closed"
|
||||
| "charge.dispute.funds_withdrawn"
|
||||
) {
|
||||
let object = &event["data"]["object"];
|
||||
let Some(payment_intent_id) = payment_intent_id_from_object(object) else {
|
||||
warn!(
|
||||
event_id,
|
||||
event_type, "Stripe reversal event missing payment intent id"
|
||||
);
|
||||
return StatusCode::OK.into_response();
|
||||
};
|
||||
if !reversal_event_is_actionable(event_type, object) {
|
||||
info!(
|
||||
payment_intent_id,
|
||||
event_type, "Ignoring non-final Stripe reversal event"
|
||||
);
|
||||
return StatusCode::OK.into_response();
|
||||
}
|
||||
match reverse_license_for_payment_intent(&state, payment_intent_id, event_type).await {
|
||||
Ok(Some(user_id)) => {
|
||||
info!(
|
||||
user_id,
|
||||
payment_intent_id, event_type, "Processed Stripe payment reversal event"
|
||||
);
|
||||
}
|
||||
Ok(None) => {
|
||||
warn!(
|
||||
payment_intent_id,
|
||||
event_type, "Stripe reversal event had no matching checkout reservation"
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
payment_intent_id,
|
||||
event_type, "Failed to process Stripe payment reversal event: {err:?}"
|
||||
);
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StatusCode::OK.into_response()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue