This commit is contained in:
Andras Schmelczer 2026-07-03 18:39:34 +01:00
parent 1ee796b282
commit ab688243d7
36 changed files with 307 additions and 135 deletions

View file

@ -125,12 +125,7 @@ async fn static_cache_headers(
response
}
/// Add baseline security headers to every response. These are deliberately the
/// low-risk, broadly-compatible set: a Content-Security-Policy and
/// Permissions-Policy are intentionally left out because this app loads many
/// cross-origin resources (maplibre/deck.gl, Stripe, Google Street View,
/// analytics, the error sink) and a mis-scoped policy would break the map or
/// checkout. They should be added later as report-only first.
async fn security_headers(
request: axum::extract::Request,
next: middleware::Next,

View file

@ -1,6 +1,7 @@
use std::collections::HashSet;
use std::sync::{Arc, LazyLock, Mutex};
use axum::extract::rejection::JsonRejection;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
@ -153,7 +154,7 @@ fn current_unix_secs_string() -> 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.
/// or returns an unexpected payload. The caller should propagate that.
async fn verify_is_admin(
state: &AppState,
pb_url: &str,
@ -295,7 +296,7 @@ async fn mark_invite_used(
// 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
// 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}");
@ -328,7 +329,7 @@ async fn mark_invite_used(
invite_id,
expected = user_id,
actual = actual_user,
"Invite redemption race lost — invite already claimed by another user"
"Invite redemption race lost. Invite already claimed by another user"
);
return Err((StatusCode::CONFLICT, "Invite was already redeemed").into_response());
}
@ -472,7 +473,7 @@ pub async fn post_invites(
/// Only recognized when `--dist` is not set (i.e., dev mode).
const DEV_INVITE_CODE: &str = "devdevdevdev";
/// Validate an invite code. Public endpoint codes are 12-char random alphanumeric
/// Validate an invite code. Public endpoint: codes are 12-char random alphanumeric
/// so enumeration is impractical, and the response only reveals valid/invalid + type.
pub async fn get_invite(
State(shared): State<Arc<SharedState>>,
@ -542,7 +543,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) sanitized before returning.
// 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
@ -594,7 +595,7 @@ pub async fn get_invite(
pub async fn post_redeem_invite(
State(shared): State<Arc<SharedState>>,
Extension(user): Extension<OptionalUser>,
Json(req): Json<RedeemRequest>,
body: Result<Json<RedeemRequest>, JsonRejection>,
) -> Response {
let state = shared.load_state();
let user = match user.0 {
@ -602,11 +603,20 @@ pub async fn post_redeem_invite(
None => return StatusCode::UNAUTHORIZED.into_response(),
};
// Parse the body only after authenticating, and return a clean message
// instead of leaking the raw serde rejection (e.g. "missing field `code`").
let req = match body {
Ok(Json(req)) => req,
Err(_) => {
return (StatusCode::BAD_REQUEST, "A valid invite code is required.").into_response()
}
};
if let Err(msg) = validate_invite_code(&req.code) {
return (StatusCode::BAD_REQUEST, msg).into_response();
}
// Dev-only: fake redeem — just return "licensed" without touching PocketBase
// Dev-only: fake redeem, just return "licensed" without touching PocketBase
if state.is_dev && req.code == DEV_INVITE_CODE {
info!(user_id = %user.id, "Dev invite redeemed (no-op)");
return Json(RedeemResponse {