Rust things
This commit is contained in:
parent
fc10381692
commit
3debacab4f
30 changed files with 3257 additions and 647 deletions
|
|
@ -1,78 +1,40 @@
|
|||
use std::collections::VecDeque;
|
||||
use std::sync::{Arc, LazyLock};
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::body::Bytes;
|
||||
use axum::extract::State;
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use hmac::{Hmac, Mac};
|
||||
use parking_lot::Mutex;
|
||||
use rustc_hash::FxHashSet;
|
||||
use hmac::{Hmac, KeyInit, Mac};
|
||||
use sha2::Sha256;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::pocketbase::get_superuser_token;
|
||||
use crate::checkout_sessions::{
|
||||
grant_license, mark_checkout_completed, mark_referral_invite_used, verify_checkout_completion,
|
||||
CheckoutCompletion,
|
||||
};
|
||||
use crate::state::SharedState;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
/// Process-local LRU of recently processed Stripe event IDs.
|
||||
/// Stripe retries deliver the same event ID; we drop duplicates so we don't
|
||||
/// re-run side effects (subscription writes, token cache invalidation, logs).
|
||||
/// Capacity is intentionally generous: at typical webhook volumes this covers
|
||||
/// far more than Stripe's retry window.
|
||||
struct EventDedup {
|
||||
seen: FxHashSet<String>,
|
||||
queue: VecDeque<String>,
|
||||
capacity: usize,
|
||||
}
|
||||
|
||||
impl EventDedup {
|
||||
fn new(capacity: usize) -> Self {
|
||||
Self {
|
||||
seen: FxHashSet::default(),
|
||||
queue: VecDeque::with_capacity(capacity),
|
||||
capacity,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if this event ID is new (and records it),
|
||||
/// `false` if it was already seen recently.
|
||||
fn check_and_insert(&mut self, id: &str) -> bool {
|
||||
if self.seen.contains(id) {
|
||||
return false;
|
||||
}
|
||||
self.seen.insert(id.to_string());
|
||||
self.queue.push_back(id.to_string());
|
||||
if self.queue.len() > self.capacity {
|
||||
if let Some(old) = self.queue.pop_front() {
|
||||
self.seen.remove(&old);
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
static EVENT_DEDUP: LazyLock<Mutex<EventDedup>> =
|
||||
LazyLock::new(|| Mutex::new(EventDedup::new(1024)));
|
||||
|
||||
/// Verify Stripe webhook signature (v1 scheme).
|
||||
fn verify_signature(payload: &[u8], sig_header: &str, secret: &str) -> bool {
|
||||
// Parse timestamp and signature from header: "t=TIMESTAMP,v1=SIGNATURE"
|
||||
let mut timestamp = None;
|
||||
let mut signature = None;
|
||||
let mut signatures = Vec::new();
|
||||
for part in sig_header.split(',') {
|
||||
if let Some(ts) = part.strip_prefix("t=") {
|
||||
timestamp = Some(ts);
|
||||
} else if let Some(sig) = part.strip_prefix("v1=") {
|
||||
signature = Some(sig);
|
||||
signatures.push(sig);
|
||||
}
|
||||
}
|
||||
|
||||
let (ts, sig_hex) = match (timestamp, signature) {
|
||||
(Some(t), Some(s)) => (t, s),
|
||||
_ => return false,
|
||||
let Some(ts) = timestamp else {
|
||||
return false;
|
||||
};
|
||||
if signatures.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Reject webhooks older than 5 minutes to prevent replay attacks
|
||||
if let Ok(ts_secs) = ts.parse::<i64>() {
|
||||
|
|
@ -87,20 +49,21 @@ fn verify_signature(payload: &[u8], sig_header: &str, secret: &str) -> bool {
|
|||
return false;
|
||||
}
|
||||
|
||||
// Compute expected signature: HMAC-SHA256(secret, "TIMESTAMP.PAYLOAD")
|
||||
let signed_payload = format!("{ts}.{}", String::from_utf8_lossy(payload));
|
||||
let mut mac = match HmacSha256::new_from_slice(secret.as_bytes()) {
|
||||
Ok(m) => m,
|
||||
Err(_) => return false,
|
||||
};
|
||||
mac.update(signed_payload.as_bytes());
|
||||
let mut signed_payload = Vec::with_capacity(ts.len() + 1 + payload.len());
|
||||
signed_payload.extend_from_slice(ts.as_bytes());
|
||||
signed_payload.push(b'.');
|
||||
signed_payload.extend_from_slice(payload);
|
||||
|
||||
// Decode the provided hex signature and verify with constant-time comparison
|
||||
let sig_bytes = match hex::decode(sig_hex) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(_) => return false,
|
||||
};
|
||||
mac.verify_slice(&sig_bytes).is_ok()
|
||||
signatures.into_iter().any(|sig_hex| {
|
||||
let Ok(sig_bytes) = hex::decode(sig_hex) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(mut mac) = HmacSha256::new_from_slice(secret.as_bytes()) else {
|
||||
return false;
|
||||
};
|
||||
mac.update(&signed_payload);
|
||||
mac.verify_slice(&sig_bytes).is_ok()
|
||||
})
|
||||
}
|
||||
|
||||
/// Handle Stripe webhook events.
|
||||
|
|
@ -140,65 +103,64 @@ pub async fn post_stripe_webhook(
|
|||
let event_type = event["type"].as_str().unwrap_or("");
|
||||
let event_id = event["id"].as_str().unwrap_or("");
|
||||
|
||||
// Idempotency: drop replays/retries of an already-processed event.
|
||||
// We always answer 200 so Stripe stops retrying.
|
||||
if !event_id.is_empty() && !EVENT_DEDUP.lock().check_and_insert(event_id) {
|
||||
info!(event_id, event_type, "Dropping duplicate Stripe webhook");
|
||||
return StatusCode::OK.into_response();
|
||||
}
|
||||
|
||||
info!(event_id, event_type, "Received Stripe webhook");
|
||||
|
||||
if event_type == "checkout.session.completed" {
|
||||
let user_id = event["data"]["object"]["client_reference_id"]
|
||||
.as_str()
|
||||
.unwrap_or("");
|
||||
if user_id.is_empty() {
|
||||
warn!("checkout.session.completed missing client_reference_id");
|
||||
return StatusCode::OK.into_response();
|
||||
}
|
||||
if !user_id.bytes().all(|b| b.is_ascii_alphanumeric()) || user_id.len() > 20 {
|
||||
warn!(user_id, "Invalid client_reference_id format in webhook");
|
||||
return StatusCode::BAD_REQUEST.into_response();
|
||||
}
|
||||
|
||||
// Update user subscription to "licensed" via PocketBase superuser auth
|
||||
let token = match get_superuser_token(&state).await {
|
||||
Ok(t) => t,
|
||||
Err(err) => {
|
||||
warn!("Failed to auth as PocketBase superuser in webhook: {err}");
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
||||
let url = format!("{pb_url}/api/collections/users/records/{user_id}");
|
||||
let res = state
|
||||
.http_client
|
||||
.patch(&url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.json(&serde_json::json!({ "subscription": "licensed" }))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match res {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
state.token_cache.invalidate_by_user_id(user_id);
|
||||
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
|
||||
{
|
||||
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:?}"
|
||||
);
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
}
|
||||
info!(
|
||||
user_id,
|
||||
"User subscription updated to licensed via Stripe webhook"
|
||||
user_id = %checkout.user_id,
|
||||
reservation_id = %checkout.reservation_id,
|
||||
"User subscription updated to licensed via verified Stripe checkout"
|
||||
);
|
||||
}
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
warn!(
|
||||
user_id,
|
||||
"Failed to update user subscription ({status}): {text}"
|
||||
);
|
||||
Ok(CheckoutCompletion::AlreadyHandled) => {
|
||||
info!("Stripe checkout session was already handled");
|
||||
}
|
||||
Ok(CheckoutCompletion::Rejected(reason)) => {
|
||||
warn!("Rejecting Stripe checkout completion: {reason}");
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(user_id, "PocketBase request error in webhook: {err}");
|
||||
warn!("Failed to verify Stripe checkout completion: {err:?}");
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue