..
This commit is contained in:
parent
1ee796b282
commit
ab688243d7
36 changed files with 307 additions and 135 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue