Comprehensive user-flow review across guest/host/admin roles, then fixes with
e2e regression guards. Highlights:
- Offline upload queue auto-resumes on reconnect: network errors keep items
pending (not error), 4xx are terminal (no infinite retry), quota exhaustion
returns a distinct 413; queue cap + dedup.
- Export lifecycle: release atomically locks uploads; reopen invalidates and
re-release regenerates the keepsake; workers claim their job atomically so a
reopen->re-release can't corrupt the ZIP; startup re-spawns interrupted
exports.
- Sessions slide on activity (no 30-day cliff); JWT expiry deferred to the
revocable session row; sign-out-everywhere + revoke-on-PIN-reset.
- Ban always hides content (v_feed / find_visible_media / export filter
is_banned) but stays a read-only ban per USER_JOURNEYS §10 — sessions are
not revoked, read access + keepsake download preserved.
- Realtime: server-clock SSE delta cursor; event-closed/opened drive the UI
live; feed_delta rate-limited; like returns {liked, like_count} to fix
multi-device drift; lightbox live comments; diashow delta backfill.
- Forgotten-PIN in-app request flow; simultaneous same-name join returns 409;
quota increment is transactional; operator floor.
Adds e2e/specs/10-flow-review/ (offline resume, export integrity, deterministic
anti-race guard) and updates existing specs for the new contracts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
72 lines
2.4 KiB
Rust
72 lines
2.4 KiB
Rust
use chrono::{Duration, Utc};
|
|
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation};
|
|
use serde::{Deserialize, Serialize};
|
|
use sha2::{Digest, Sha256};
|
|
use uuid::Uuid;
|
|
|
|
use crate::models::user::UserRole;
|
|
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
pub struct Claims {
|
|
pub sub: Uuid,
|
|
pub event_id: Uuid,
|
|
pub role: UserRole,
|
|
pub exp: i64,
|
|
pub iat: i64,
|
|
/// Random per-token identifier. Without it, two `create_token` calls in the
|
|
/// same wall-clock second for the same (sub, role, event) produce identical
|
|
/// JWT bytes — and identical sha256(token) hashes — which then collide on
|
|
/// the `session.token_hash` UNIQUE constraint. The jti is ignored by the
|
|
/// verifier but breaks the collision.
|
|
#[serde(default)]
|
|
pub jti: Uuid,
|
|
}
|
|
|
|
pub fn create_token(
|
|
user_id: Uuid,
|
|
event_id: Uuid,
|
|
role: UserRole,
|
|
secret: &str,
|
|
expiry_days: i64,
|
|
) -> Result<String, jsonwebtoken::errors::Error> {
|
|
let now = Utc::now();
|
|
let claims = Claims {
|
|
sub: user_id,
|
|
event_id,
|
|
role,
|
|
iat: now.timestamp(),
|
|
exp: (now + Duration::days(expiry_days)).timestamp(),
|
|
jti: Uuid::new_v4(),
|
|
};
|
|
jsonwebtoken::encode(
|
|
&Header::default(),
|
|
&claims,
|
|
&EncodingKey::from_secret(secret.as_bytes()),
|
|
)
|
|
}
|
|
|
|
pub fn verify_token(token: &str, secret: &str) -> Result<Claims, jsonwebtoken::errors::Error> {
|
|
// We deliberately do NOT enforce the JWT's own `exp`. The authoritative session
|
|
// lifetime lives in the `session` row (`expires_at > NOW()`), which SLIDES forward on
|
|
// every authenticated request (see `Session::touch_and_renew`). Enforcing the JWT's
|
|
// fixed +Nd `exp` here would hard-log-out an actively-used client on day N+1 even
|
|
// though its session was renewed — the "30-day cliff" from the review. With server-
|
|
// side sliding sessions, the token is a signature-checked bearer credential and its
|
|
// lifetime is revocable (logout / expiry / ban all delete the row), which is strictly
|
|
// stronger than a stateless non-revocable exp.
|
|
let mut validation = Validation::default();
|
|
validation.validate_exp = false;
|
|
let data = jsonwebtoken::decode::<Claims>(
|
|
token,
|
|
&DecodingKey::from_secret(secret.as_bytes()),
|
|
&validation,
|
|
)?;
|
|
Ok(data.claims)
|
|
}
|
|
|
|
pub fn hash_token(token: &str) -> String {
|
|
let mut hasher = Sha256::new();
|
|
hasher.update(token.as_bytes());
|
|
format!("{:x}", hasher.finalize())
|
|
}
|