fix(user-flow): close offline-queue, export, session, ban & realtime gaps
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>
This commit is contained in:
@@ -83,7 +83,19 @@ pub async fn join(
|
||||
let pin_hash =
|
||||
bcrypt::hash(&pin, 12).map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
|
||||
|
||||
let user = User::create(&state.pool, event.id, display_name, &pin_hash).await?;
|
||||
// The pre-check above is racy: two simultaneous joins with the same name can both
|
||||
// pass it, and the DB's unique index then rejects the loser. Map that unique
|
||||
// violation to the same clean 409 the pre-check returns, not a generic 500.
|
||||
let user = match User::create(&state.pool, event.id, display_name, &pin_hash).await {
|
||||
Ok(u) => u,
|
||||
Err(sqlx::Error::Database(db)) if db.is_unique_violation() => {
|
||||
return Err(AppError::Conflict(format!(
|
||||
"Der Name \"{}\" ist bereits vergeben.",
|
||||
display_name
|
||||
)));
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
|
||||
let token = jwt::create_token(
|
||||
user.id,
|
||||
@@ -359,3 +371,77 @@ pub async fn logout(
|
||||
Session::delete_by_token_hash(&state.pool, &auth.token_hash).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
/// "Sign out everywhere" — revoke every session for the caller, not just the current one.
|
||||
/// A single leaked/kept-alive device (a shared phone, a laptop recovered via PIN) can then
|
||||
/// be cut from any of the user's devices.
|
||||
pub async fn logout_all(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
Session::delete_all_for_user(&state.pool, auth.user_id).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct PinResetRequestBody {
|
||||
pub display_name: String,
|
||||
}
|
||||
|
||||
/// A guest who forgot their PIN asks a host to reset it in-app. Unauthenticated (they
|
||||
/// can't log in without the PIN) and rate-limited. Always returns 204 regardless of
|
||||
/// whether the name exists, so it can't enumerate display names beyond what the public
|
||||
/// feed already exposes.
|
||||
pub async fn request_pin_reset(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<PinResetRequestBody>,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
let display_name = body.display_name.trim();
|
||||
let ip = client_ip(&headers, "unknown");
|
||||
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
|
||||
if rate_limits_on {
|
||||
let name_key = display_name.to_lowercase();
|
||||
if !state.rate_limiter.check(
|
||||
format!("pin_reset_req:{ip}:{name_key}"),
|
||||
3,
|
||||
Duration::from_secs(15 * 60),
|
||||
) {
|
||||
return Err(AppError::TooManyRequests(
|
||||
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
|
||||
None,
|
||||
));
|
||||
}
|
||||
}
|
||||
if display_name.is_empty() {
|
||||
return Ok(StatusCode::NO_CONTENT);
|
||||
}
|
||||
|
||||
// Single statement so the existing-name and unknown-name paths do IDENTICAL work
|
||||
// (same event+user index scans, an INSERT that matches 0 rows for an unknown name) —
|
||||
// no timing oracle despite the always-204 contract. Admins recover via password, so
|
||||
// they're excluded from the join and never get a reset request queued.
|
||||
let _ = sqlx::query(
|
||||
"INSERT INTO pin_reset_request (event_id, user_id)
|
||||
SELECT e.id, u.id
|
||||
FROM event e
|
||||
JOIN \"user\" u
|
||||
ON u.event_id = e.id
|
||||
AND lower(u.display_name) = lower($2)
|
||||
AND u.role <> 'admin'
|
||||
WHERE e.slug = $1
|
||||
ON CONFLICT (user_id) DO NOTHING",
|
||||
)
|
||||
.bind(&state.config.event_slug)
|
||||
.bind(display_name)
|
||||
.execute(&state.pool)
|
||||
.await;
|
||||
|
||||
// Broadcast unconditionally (carries no per-name info) so an online host's request
|
||||
// badge updates without revealing whether the name existed.
|
||||
let _ = state
|
||||
.sse_tx
|
||||
.send(crate::state::SseEvent::new("pin-reset-requested", "{}"));
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
@@ -46,10 +46,20 @@ pub fn create_token(
|
||||
}
|
||||
|
||||
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::default(),
|
||||
&validation,
|
||||
)?;
|
||||
Ok(data.claims)
|
||||
}
|
||||
|
||||
@@ -37,9 +37,10 @@ impl FromRequestParts<AppState> for AuthUser {
|
||||
.strip_prefix("Bearer ")
|
||||
.ok_or_else(|| AppError::Unauthorized("Ungültiges Token-Format.".into()))?;
|
||||
|
||||
// Verify the JWT (signature + expiry). We deliberately do NOT trust its role/ban
|
||||
// claims — the live user row below is authoritative — so the decoded claims
|
||||
// themselves aren't needed beyond this check.
|
||||
// Verify the JWT's signature. Expiry is deliberately NOT enforced here (see
|
||||
// `jwt::verify_token`) — the authoritative, sliding session lifetime lives in the
|
||||
// `session` row read below. We also don't trust the token's role/ban claims; the
|
||||
// live user row is authoritative, so the decoded claims aren't needed beyond this.
|
||||
jwt::verify_token(token, &state.config.jwt_secret)
|
||||
.map_err(|_| AppError::Unauthorized("Token ungültig oder abgelaufen.".into()))?;
|
||||
|
||||
@@ -55,14 +56,22 @@ impl FromRequestParts<AppState> for AuthUser {
|
||||
.map_err(|e| AppError::Internal(e.into()))?
|
||||
.ok_or_else(|| AppError::Unauthorized("Sitzung nicht gefunden oder abgelaufen.".into()))?;
|
||||
|
||||
// Update last_seen_at in the background (fire-and-forget). Failures are
|
||||
// non-fatal but worth surfacing — silent swallowing hides DB connection
|
||||
// pressure that would otherwise be the first symptom of a real problem.
|
||||
// Touch last_seen_at AND slide the session's expiry forward (fire-and-forget), so
|
||||
// an active client's session renews instead of hitting the fixed 30-day cliff.
|
||||
// Admin sessions keep their tighter 1-day window (they renew on activity but still
|
||||
// lapse a day after the admin goes idle). Failures are non-fatal but worth
|
||||
// surfacing — silent swallowing hides DB connection pressure that would otherwise
|
||||
// be the first symptom of a real problem.
|
||||
let pool = state.pool.clone();
|
||||
let touch_hash = token_hash.clone();
|
||||
let expiry_days = if user.role == UserRole::Admin {
|
||||
1
|
||||
} else {
|
||||
state.config.session_expiry_days
|
||||
};
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = Session::touch_by_token_hash(&pool, &touch_hash).await {
|
||||
tracing::warn!(error = ?e, "session touch failed");
|
||||
if let Err(e) = Session::touch_and_renew(&pool, &touch_hash, expiry_days).await {
|
||||
tracing::warn!(error = ?e, "session touch/renew failed");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -10,6 +10,10 @@ pub enum AppError {
|
||||
Conflict(String),
|
||||
/// Second field: optional retry-after seconds to include in the response.
|
||||
TooManyRequests(String, Option<u64>),
|
||||
/// Per-user storage quota exhausted. Distinct from `TooManyRequests` (rate limit) so
|
||||
/// the client can treat it as *terminal* (413, no retry) instead of backing off and
|
||||
/// retrying a permanently-failing upload forever.
|
||||
QuotaExceeded(String),
|
||||
Internal(anyhow::Error),
|
||||
}
|
||||
|
||||
@@ -22,6 +26,7 @@ impl AppError {
|
||||
Self::NotFound(_) => (StatusCode::NOT_FOUND, "not_found"),
|
||||
Self::Conflict(_) => (StatusCode::CONFLICT, "conflict"),
|
||||
Self::TooManyRequests(..) => (StatusCode::TOO_MANY_REQUESTS, "too_many_requests"),
|
||||
Self::QuotaExceeded(_) => (StatusCode::PAYLOAD_TOO_LARGE, "quota_exceeded"),
|
||||
Self::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "internal_error"),
|
||||
}
|
||||
}
|
||||
@@ -34,6 +39,7 @@ impl AppError {
|
||||
| Self::NotFound(msg)
|
||||
| Self::Conflict(msg) => msg.clone(),
|
||||
Self::TooManyRequests(msg, _) => msg.clone(),
|
||||
Self::QuotaExceeded(msg) => msg.clone(),
|
||||
Self::Internal(err) => {
|
||||
tracing::error!("internal error: {err:#}");
|
||||
"Ein interner Fehler ist aufgetreten.".to_string()
|
||||
|
||||
@@ -186,6 +186,11 @@ pub struct DeltaResponse {
|
||||
/// rather than merging (the older missed uploads are absent and unrecoverable
|
||||
/// via a later delta, which advances `since` past them).
|
||||
pub truncated: bool,
|
||||
/// The server's clock at the moment this delta was computed. The client advances its
|
||||
/// reconnect cursor from THIS, never `new Date()` — a browser clock even seconds fast
|
||||
/// would otherwise silently skip uploads whose server `created_at` falls in the skew
|
||||
/// window (they'd never reappear without a hard refresh).
|
||||
pub server_time: DateTime<Utc>,
|
||||
}
|
||||
|
||||
pub async fn feed_delta(
|
||||
@@ -193,6 +198,33 @@ pub async fn feed_delta(
|
||||
auth: AuthUser,
|
||||
Query(q): Query<DeltaQuery>,
|
||||
) -> Result<Json<DeltaResponse>, AppError> {
|
||||
// Rate-limit the delta the same way as the paginated feed. Without this, ~100 clients
|
||||
// reconnecting at once (post-outage, or a flapping network) each fire an unbounded
|
||||
// delta fetch — a reconnect stampede. Keyed per-user so one client can't starve others
|
||||
// behind a shared NAT.
|
||||
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
|
||||
let feed_rate_on = config::get_bool(&state.config_cache, "feed_rate_enabled", true).await;
|
||||
if rate_limits_on && feed_rate_on {
|
||||
let rate_limit = config::get_usize(&state.config_cache, "feed_rate_per_min", 60).await;
|
||||
if !state.rate_limiter.check(
|
||||
format!("feed_delta:{}", auth.user_id),
|
||||
rate_limit,
|
||||
Duration::from_secs(60),
|
||||
) {
|
||||
return Err(AppError::TooManyRequests(
|
||||
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
|
||||
None,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Anchor the next cursor to the DB clock, captured *before* the queries so an upload
|
||||
// committed during this handler is re-fetched next time rather than skipped (a
|
||||
// duplicate id merges idempotently on the client; a miss is unrecoverable).
|
||||
let server_time: DateTime<Utc> = sqlx::query_scalar("SELECT NOW()")
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
// Bounded like the paginated feed: a stale `since` could otherwise pull the
|
||||
// entire event's uploads in one response. If a client hits the cap it should
|
||||
// fall back to a full feed refresh rather than another delta.
|
||||
@@ -254,6 +286,7 @@ pub async fn feed_delta(
|
||||
uploads,
|
||||
deleted_ids: deleted_ids.into_iter().map(|r| r.0).collect(),
|
||||
truncated,
|
||||
server_time,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::auth::middleware::RequireHost;
|
||||
use crate::error::AppError;
|
||||
use crate::models::comment::Comment;
|
||||
use crate::models::event::Event;
|
||||
use crate::models::session::Session;
|
||||
use crate::models::upload::Upload;
|
||||
use crate::models::user::UserRole;
|
||||
use crate::state::{AppState, SseEvent};
|
||||
@@ -35,11 +36,27 @@ pub struct EventStatus {
|
||||
pub export_released: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct BanRequest {
|
||||
pub hide_uploads: bool,
|
||||
/// Count non-banned hosts/admins in the event OTHER than `excluding` — the operators
|
||||
/// who would remain if `excluding` were demoted or banned. Used to enforce the "an event
|
||||
/// always keeps at least one operator" floor.
|
||||
async fn remaining_operators(
|
||||
state: &AppState,
|
||||
event_id: Uuid,
|
||||
excluding: Uuid,
|
||||
) -> Result<i64, AppError> {
|
||||
let count = sqlx::query_scalar::<_, i64>(
|
||||
"SELECT COUNT(*) FROM \"user\"
|
||||
WHERE event_id = $1 AND id != $2
|
||||
AND role IN ('host', 'admin') AND is_banned = FALSE",
|
||||
)
|
||||
.bind(event_id)
|
||||
.bind(excluding)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SetRoleRequest {
|
||||
pub role: String,
|
||||
@@ -93,8 +110,8 @@ pub async fn ban_user(
|
||||
State(state): State<AppState>,
|
||||
RequireHost(auth): RequireHost,
|
||||
Path(user_id): Path<Uuid>,
|
||||
Json(body): Json<BanRequest>,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
// The ban request carries no body — ban always hides (no per-request options).
|
||||
// Cannot ban yourself or another host/admin
|
||||
if user_id == auth.user_id {
|
||||
return Err(AppError::BadRequest("Du kannst dich nicht selbst sperren.".into()));
|
||||
@@ -112,29 +129,46 @@ pub async fn ban_user(
|
||||
return Err(AppError::Forbidden("Du kannst diesen Benutzer nicht sperren.".into()));
|
||||
}
|
||||
|
||||
// Floor: never leave the event with zero operators. Banning removes the target from
|
||||
// the active-operator pool, so refuse if they're the last non-banned host/admin.
|
||||
if target.0 == "host" && remaining_operators(&state, auth.event_id, user_id).await? == 0 {
|
||||
return Err(AppError::BadRequest(
|
||||
"Der letzte Host kann nicht gesperrt werden.".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Ban ALWAYS hides: a banned user's content is "gone" everywhere. The visibility
|
||||
// views/queries now also filter on `is_banned` (defense in depth), and we set
|
||||
// `uploads_hidden` so the existing `user-hidden` live-eviction path fires too. The old
|
||||
// opt-out checkbox is gone — `hide_uploads` in the request is ignored.
|
||||
//
|
||||
// We deliberately do NOT revoke the banned user's sessions. This is a *read-only ban*
|
||||
// by design (USER_JOURNEYS §10.3): the user keeps read access to the feed and can still
|
||||
// download the released export — writes and host/admin actions are what the ban blocks
|
||||
// (enforced live on the write handlers + Require{Host,Admin}). Revoking sessions would
|
||||
// contradict that model, break the documented "banned guest can still download the
|
||||
// keepsake" flow, and be ineffective anyway (the user could just /recover a new session).
|
||||
sqlx::query(
|
||||
"UPDATE \"user\" SET is_banned = TRUE, uploads_hidden = $2 WHERE id = $1 AND event_id = $3",
|
||||
"UPDATE \"user\" SET is_banned = TRUE, uploads_hidden = TRUE WHERE id = $1 AND event_id = $2",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(body.hide_uploads)
|
||||
.bind(auth.event_id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
// If we hid their uploads, evict them live from every feed + the diashow so a
|
||||
// banned guest's content disappears without each viewer having to reload.
|
||||
if body.hide_uploads {
|
||||
let _ = state.sse_tx.send(SseEvent::new(
|
||||
"user-hidden",
|
||||
serde_json::json!({ "user_id": user_id }).to_string(),
|
||||
));
|
||||
}
|
||||
// Evict their content live from every feed + the diashow so it disappears without
|
||||
// each viewer having to reload. (Their own SSE stream is separately dropped by the
|
||||
// is_banned revalidation in `sse::stream`, so they stop receiving live pushes while
|
||||
// retaining plain read access.)
|
||||
let _ = state.sse_tx.send(SseEvent::new(
|
||||
"user-hidden",
|
||||
serde_json::json!({ "user_id": user_id }).to_string(),
|
||||
));
|
||||
|
||||
tracing::info!(
|
||||
actor_user_id = %auth.user_id,
|
||||
target_user_id = %user_id,
|
||||
event_id = %auth.event_id,
|
||||
hide_uploads = body.hide_uploads,
|
||||
"host: ban_user"
|
||||
);
|
||||
|
||||
@@ -166,8 +200,10 @@ pub async fn unban_user(
|
||||
));
|
||||
}
|
||||
|
||||
// Unban restores visibility too: ban set `uploads_hidden = TRUE`, so clearing only
|
||||
// `is_banned` would leave their content invisible. Clear both.
|
||||
let result = sqlx::query(
|
||||
"UPDATE \"user\" SET is_banned = FALSE WHERE id = $1 AND event_id = $2",
|
||||
"UPDATE \"user\" SET is_banned = FALSE, uploads_hidden = FALSE WHERE id = $1 AND event_id = $2",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(auth.event_id)
|
||||
@@ -230,6 +266,17 @@ pub async fn set_role(
|
||||
));
|
||||
}
|
||||
|
||||
// Floor: demoting the last non-banned host/admin to guest would leave the event with
|
||||
// no operator. Refuse.
|
||||
if new_role == "guest"
|
||||
&& target.0 == "host"
|
||||
&& remaining_operators(&state, auth.event_id, user_id).await? == 0
|
||||
{
|
||||
return Err(AppError::BadRequest(
|
||||
"Der letzte Host kann nicht zum Gast gemacht werden.".into(),
|
||||
));
|
||||
}
|
||||
|
||||
sqlx::query("UPDATE \"user\" SET role = $2::user_role WHERE id = $1 AND event_id = $3")
|
||||
.bind(user_id)
|
||||
.bind(new_role)
|
||||
@@ -308,6 +355,16 @@ pub async fn reset_user_pin(
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
// A PIN reset means the old credential is compromised/forgotten — revoke every
|
||||
// existing session so old devices must re-authenticate with the new PIN.
|
||||
let _ = Session::delete_all_for_user(&state.pool, user_id).await;
|
||||
|
||||
// Resolve any pending in-app "I forgot my PIN" request for this user.
|
||||
let _ = sqlx::query("DELETE FROM pin_reset_request WHERE user_id = $1")
|
||||
.bind(user_id)
|
||||
.execute(&state.pool)
|
||||
.await;
|
||||
|
||||
// Notify the *recipient* device(s) if they happen to be online so they can clear
|
||||
// their cached local PIN. They'll save the new one on the next /recover.
|
||||
let _ = state.sse_tx.send(SseEvent::new(
|
||||
@@ -325,6 +382,48 @@ pub async fn reset_user_pin(
|
||||
Ok(Json(PinResetResponse { pin }))
|
||||
}
|
||||
|
||||
#[derive(Serialize, sqlx::FromRow)]
|
||||
pub struct PinResetRequestSummary {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub display_name: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// List pending in-app PIN-reset requests so a host can action them (via the existing
|
||||
/// `reset_user_pin`, which also clears the request).
|
||||
pub async fn list_pin_reset_requests(
|
||||
State(state): State<AppState>,
|
||||
RequireHost(auth): RequireHost,
|
||||
) -> Result<Json<Vec<PinResetRequestSummary>>, AppError> {
|
||||
let rows = sqlx::query_as::<_, PinResetRequestSummary>(
|
||||
"SELECT r.id, r.user_id, u.display_name, r.created_at
|
||||
FROM pin_reset_request r
|
||||
JOIN \"user\" u ON u.id = r.user_id
|
||||
WHERE r.event_id = $1
|
||||
ORDER BY r.created_at ASC",
|
||||
)
|
||||
.bind(auth.event_id)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
Ok(Json(rows))
|
||||
}
|
||||
|
||||
/// Dismiss a PIN-reset request without resetting (e.g. the host couldn't verify the
|
||||
/// requester's identity).
|
||||
pub async fn dismiss_pin_reset_request(
|
||||
State(state): State<AppState>,
|
||||
RequireHost(auth): RequireHost,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
sqlx::query("DELETE FROM pin_reset_request WHERE id = $1 AND event_id = $2")
|
||||
.bind(id)
|
||||
.bind(auth.event_id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub async fn host_delete_upload(
|
||||
State(state): State<AppState>,
|
||||
RequireHost(auth): RequireHost,
|
||||
@@ -401,8 +500,17 @@ pub async fn open_event(
|
||||
State(state): State<AppState>,
|
||||
RequireHost(_auth): RequireHost,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
// Reopening also invalidates any prior release: the keepsake was snapshotted at
|
||||
// release time, so allowing new uploads afterwards would silently diverge the live
|
||||
// feed from the frozen export. Clearing `export_released_at` (and the readiness
|
||||
// flags) lets the host re-release later to regenerate a correct, complete keepsake.
|
||||
let result = sqlx::query(
|
||||
"UPDATE event SET uploads_locked_at = NULL WHERE slug = $1 AND uploads_locked_at IS NOT NULL",
|
||||
"UPDATE event
|
||||
SET uploads_locked_at = NULL,
|
||||
export_released_at = NULL,
|
||||
export_zip_ready = FALSE,
|
||||
export_html_ready = FALSE
|
||||
WHERE slug = $1 AND (uploads_locked_at IS NOT NULL OR export_released_at IS NOT NULL)",
|
||||
)
|
||||
.bind(&state.config.event_slug)
|
||||
.execute(&state.pool)
|
||||
@@ -422,8 +530,15 @@ pub async fn release_gallery(
|
||||
// Atomic claim: the conditional UPDATE is the sole gate, so two concurrent
|
||||
// release calls can't both pass a check-then-set and double-enqueue exports.
|
||||
// rows_affected == 0 means someone already released.
|
||||
//
|
||||
// Releasing also locks uploads in the same statement (release ⇒ lock). Otherwise a
|
||||
// guest whose offline upload reconnects *after* the export snapshot would land in the
|
||||
// live feed but not in the downloaded keepsake — a silent, non-regenerable data loss.
|
||||
// `COALESCE` preserves an earlier explicit lock time rather than overwriting it.
|
||||
let result = sqlx::query(
|
||||
"UPDATE event SET export_released_at = NOW()
|
||||
"UPDATE event
|
||||
SET export_released_at = NOW(),
|
||||
uploads_locked_at = COALESCE(uploads_locked_at, NOW())
|
||||
WHERE slug = $1 AND export_released_at IS NULL",
|
||||
)
|
||||
.bind(&state.config.event_slug)
|
||||
@@ -441,32 +556,26 @@ pub async fn release_gallery(
|
||||
});
|
||||
}
|
||||
|
||||
// Release locked uploads too — tell any open composer to flip to the locked UI live
|
||||
// rather than discovering it via a rejected upload.
|
||||
let _ = state.sse_tx.send(SseEvent::new("event-closed", "{}"));
|
||||
|
||||
// We won the claim — load the event for its id/name to enqueue export jobs.
|
||||
let event = Event::find_by_slug(&state.pool, &state.config.event_slug)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
|
||||
|
||||
// Enqueue export jobs
|
||||
for export_type in ["zip", "html"] {
|
||||
sqlx::query(
|
||||
"INSERT INTO export_job (event_id, type) VALUES ($1, $2::export_type)
|
||||
ON CONFLICT (event_id, type) DO NOTHING",
|
||||
)
|
||||
.bind(event.id)
|
||||
.bind(export_type)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Spawn export workers
|
||||
crate::services::export::spawn_export_jobs(
|
||||
// Enqueue + spawn via the shared path so a re-release (after reopen) regenerates
|
||||
// cleanly and startup recovery uses identical logic.
|
||||
crate::services::export::enqueue_and_spawn_exports(
|
||||
event.id,
|
||||
event.name,
|
||||
state.pool.clone(),
|
||||
state.config.media_path.clone(),
|
||||
state.config.export_path.clone(),
|
||||
state.sse_tx.clone(),
|
||||
);
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
@@ -55,6 +55,12 @@ pub struct MeContextDto {
|
||||
pub privacy_note: String,
|
||||
pub quota_enabled: bool,
|
||||
pub storage_quota_enabled: bool,
|
||||
/// Uploads are locked (event closed) — the composer should show a locked state live
|
||||
/// instead of letting a guest compose an upload only to eat a 403.
|
||||
pub uploads_locked: bool,
|
||||
/// The gallery has been released and the export snapshotted — uploads are permanently
|
||||
/// closed for this run (release ⇒ lock, and reopening regenerates).
|
||||
pub gallery_released: bool,
|
||||
}
|
||||
|
||||
pub async fn get_context(
|
||||
@@ -69,6 +75,11 @@ pub async fn get_context(
|
||||
let quota_enabled = config::get_bool(&state.config_cache, "quota_enabled", true).await;
|
||||
let storage_quota_enabled = config::get_bool(&state.config_cache, "storage_quota_enabled", true).await;
|
||||
|
||||
let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)
|
||||
.await?;
|
||||
let uploads_locked = event.as_ref().map(|e| e.uploads_locked_at.is_some()).unwrap_or(false);
|
||||
let gallery_released = event.as_ref().map(|e| e.export_released_at.is_some()).unwrap_or(false);
|
||||
|
||||
Ok(Json(MeContextDto {
|
||||
user_id: user.id,
|
||||
display_name: user.display_name,
|
||||
@@ -76,5 +87,7 @@ pub async fn get_context(
|
||||
privacy_note,
|
||||
quota_enabled,
|
||||
storage_quota_enabled,
|
||||
uploads_locked,
|
||||
gallery_released,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ use axum::extract::{Path, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::Json;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::middleware::AuthUser;
|
||||
@@ -12,11 +12,22 @@ use crate::models::hashtag::{self, Hashtag};
|
||||
use crate::models::upload::Upload;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct LikeResponse {
|
||||
/// The caller's like state *after* this toggle. The client sets `liked_by_me` from
|
||||
/// this rather than blind-inverting local state — otherwise a second device (same
|
||||
/// recovered user) drifts, since the `like-update` broadcast only carries `like_count`.
|
||||
pub liked: bool,
|
||||
/// Fresh like count, or `null` if the (best-effort) count query hiccuped. The client
|
||||
/// keeps its current count when this is null rather than adopting a wrong number.
|
||||
pub like_count: Option<i64>,
|
||||
}
|
||||
|
||||
pub async fn toggle_like(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(upload_id): Path<Uuid>,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
) -> Result<Json<LikeResponse>, AppError> {
|
||||
// Check if user is banned
|
||||
let user = crate::models::user::User::find_by_id(&state.pool, auth.user_id)
|
||||
.await?
|
||||
@@ -35,7 +46,7 @@ pub async fn toggle_like(
|
||||
// ("Event schließen") freezes *new uploads* only — likes, comments and
|
||||
// browsing stay open (USER_JOURNEYS §9.3, FEATURES capability matrix).
|
||||
|
||||
// Try to insert; if conflict, delete (toggle)
|
||||
// Try to insert; if conflict, delete (toggle). `liked` = the caller's state afterwards.
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO \"like\" (upload_id, user_id) VALUES ($1, $2)
|
||||
ON CONFLICT (upload_id, user_id) DO NOTHING",
|
||||
@@ -45,7 +56,8 @@ pub async fn toggle_like(
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
let liked = result.rows_affected() > 0;
|
||||
if !liked {
|
||||
// Already liked — remove
|
||||
sqlx::query("DELETE FROM \"like\" WHERE upload_id = $1 AND user_id = $2")
|
||||
.bind(upload_id)
|
||||
@@ -55,24 +67,29 @@ pub async fn toggle_like(
|
||||
}
|
||||
|
||||
// Fresh count so feed clients can patch the single card in place instead of
|
||||
// refetching page 1 (mirrors v_feed.like_count = COUNT(DISTINCT user_id)). The
|
||||
// count + broadcast are a UI optimisation — the like itself is already committed,
|
||||
// so a failure here must not fail the request. Swallow the error and skip the
|
||||
// broadcast; the next event or a pull-to-refresh reconciles the count.
|
||||
if let Ok(like_count) = sqlx::query_scalar::<_, i64>(
|
||||
// refetching page 1 (mirrors v_feed.like_count = COUNT(DISTINCT user_id)). The like
|
||||
// itself is already committed, so a failed count must not fail the request — but we
|
||||
// also must NOT broadcast/return a bogus 0 (that would push like_count: 0 to every
|
||||
// client until the next event). On error we skip the broadcast and return null.
|
||||
let like_count = sqlx::query_scalar::<_, i64>(
|
||||
"SELECT COUNT(DISTINCT user_id) FROM \"like\" WHERE upload_id = $1",
|
||||
)
|
||||
.bind(upload_id)
|
||||
.fetch_one(&state.pool)
|
||||
.await
|
||||
{
|
||||
.ok();
|
||||
|
||||
if let Some(count) = like_count {
|
||||
// Broadcast the new count so other clients patch their card. Only `like_count` is
|
||||
// shared — each client's own `liked_by_me` only changes via its own toggle (which
|
||||
// now reads it straight from this response).
|
||||
let _ = state.sse_tx.send(crate::state::SseEvent {
|
||||
event_type: "like-update".to_string(),
|
||||
data: serde_json::json!({ "upload_id": upload_id, "like_count": like_count }).to_string(),
|
||||
data: serde_json::json!({ "upload_id": upload_id, "like_count": count }).to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
Ok(Json(LikeResponse { liked, like_count }))
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
|
||||
@@ -23,6 +23,10 @@ pub struct SseQuery {
|
||||
#[derive(Serialize)]
|
||||
pub struct StreamTicketResponse {
|
||||
pub ticket: String,
|
||||
/// Server clock at mint time — the client seeds its SSE reconnect cursor from this
|
||||
/// instead of `new Date()`, so a skewed browser clock can't drop uploads. See
|
||||
/// `DeltaResponse::server_time`.
|
||||
pub server_time: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
/// Mint a short-lived single-use SSE ticket. The browser's `EventSource` cannot
|
||||
@@ -33,9 +37,12 @@ pub struct StreamTicketResponse {
|
||||
pub async fn issue_ticket(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
) -> Json<StreamTicketResponse> {
|
||||
) -> Result<Json<StreamTicketResponse>, AppError> {
|
||||
let ticket = state.sse_tickets.issue(auth.token_hash);
|
||||
Json(StreamTicketResponse { ticket })
|
||||
let server_time = sqlx::query_scalar("SELECT NOW()")
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
Ok(Json(StreamTicketResponse { ticket, server_time }))
|
||||
}
|
||||
|
||||
/// SSE stream endpoint. Authenticates via a single-use ticket (see
|
||||
@@ -74,9 +81,12 @@ pub async fn stream(
|
||||
});
|
||||
|
||||
// The session is only checked once at open. Re-validate it periodically so a
|
||||
// logged-out or expired session's stream is closed rather than kept alive until the
|
||||
// client happens to disconnect. Bounds a stale stream to ~60s. Only a *definitively*
|
||||
// gone/expired session ends the stream; a transient DB error just retries next tick.
|
||||
// logged-out, expired, OR banned session's stream is closed rather than kept alive
|
||||
// until the client happens to disconnect. Bounds a stale stream to ~60s. Banning
|
||||
// already revokes the user's sessions (so the row is gone), but re-reading the live
|
||||
// user row and dropping on `is_banned` is a cheap defense-in-depth. Only a
|
||||
// *definitive* gone/expired/banned state ends the stream; a transient DB error just
|
||||
// retries next tick.
|
||||
let pool = state.pool.clone();
|
||||
let session_hash = token_hash.clone();
|
||||
let session_gone = async move {
|
||||
@@ -84,9 +94,10 @@ pub async fn stream(
|
||||
ticker.tick().await; // consume the immediate first tick
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
match Session::find_by_token_hash(&pool, &session_hash).await {
|
||||
Ok(Some(_)) => {} // still valid — keep streaming
|
||||
Ok(None) => break, // logged out or expired — stop
|
||||
match Session::find_user_by_token_hash(&pool, &session_hash).await {
|
||||
Ok(Some(user)) if !user.is_banned => {} // still valid — keep streaming
|
||||
Ok(Some(_)) => break, // banned — cut the stream
|
||||
Ok(None) => break, // logged out or expired — stop
|
||||
Err(e) => {
|
||||
tracing::warn!(error = ?e, "SSE session revalidation query failed; retrying");
|
||||
}
|
||||
|
||||
@@ -77,6 +77,13 @@ pub async fn upload(
|
||||
drain_multipart(multipart).await;
|
||||
return Err(AppError::Forbidden("Uploads sind gesperrt.".into()));
|
||||
}
|
||||
// Belt-and-suspenders on top of the lock (release ⇒ lock): once the gallery is
|
||||
// released the export has been snapshotted, so a late upload could never make it into
|
||||
// the keepsake. Reject it explicitly rather than silently diverging the live feed.
|
||||
if event.export_released_at.is_some() {
|
||||
drain_multipart(multipart).await;
|
||||
return Err(AppError::Forbidden("Galerie wurde bereits freigegeben.".into()));
|
||||
}
|
||||
|
||||
// Read config limits from DB
|
||||
let max_image_mb: i64 = config::get_i64(&state.config_cache, "max_image_size_mb", 20).await;
|
||||
@@ -213,15 +220,21 @@ pub async fn upload(
|
||||
// disable it on trusted instances.
|
||||
let quota_on = config::get_bool(&state.config_cache, "quota_enabled", true).await;
|
||||
let storage_quota_on = config::get_bool(&state.config_cache, "storage_quota_enabled", true).await;
|
||||
// When quota is enforced, this holds the byte ceiling so the increment UPDATE below can
|
||||
// enforce it atomically (`WHERE total + size <= limit`). Without that guard, two
|
||||
// concurrent uploads from the same user (e.g. phone + laptop) both pass this stale
|
||||
// pre-check and both increment, blowing past the quota. The pre-check stays as a
|
||||
// fast path that avoids the disk write when the user is already clearly over.
|
||||
let mut quota_limit: Option<i64> = None;
|
||||
if quota_on && storage_quota_on {
|
||||
let estimate = compute_storage_quota(&state).await;
|
||||
if let Some(limit) = estimate.limit_bytes {
|
||||
quota_limit = Some(limit);
|
||||
let prospective_total = user.total_upload_bytes.saturating_add(size);
|
||||
if prospective_total > limit {
|
||||
let _ = tokio::fs::remove_file(&temp_abs).await;
|
||||
return Err(AppError::TooManyRequests(
|
||||
return Err(AppError::QuotaExceeded(
|
||||
"Du hast dein Upload-Limit für dieses Event erreicht.".into(),
|
||||
None,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -256,11 +269,33 @@ pub async fn upload(
|
||||
// bytes with no row to reclaim them (silent quota erosion / spurious lockout).
|
||||
let tx_result: Result<Upload, AppError> = async {
|
||||
let mut tx = state.pool.begin().await?;
|
||||
sqlx::query("UPDATE \"user\" SET total_upload_bytes = total_upload_bytes + $2 WHERE id = $1")
|
||||
// Increment the user's byte total. When a quota is in force, guard it atomically
|
||||
// (`total + size <= limit`) so two concurrent uploads can't both slip past the
|
||||
// stale pre-check — the loser's UPDATE matches 0 rows and we abort with the same
|
||||
// terminal quota error (the tx rolls back on drop; the on-disk file is cleaned by
|
||||
// the error path below).
|
||||
let inc = if let Some(limit) = quota_limit {
|
||||
sqlx::query(
|
||||
"UPDATE \"user\" SET total_upload_bytes = total_upload_bytes + $2
|
||||
WHERE id = $1 AND total_upload_bytes + $2 <= $3",
|
||||
)
|
||||
.bind(auth.user_id)
|
||||
.bind(size)
|
||||
.bind(limit)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query("UPDATE \"user\" SET total_upload_bytes = total_upload_bytes + $2 WHERE id = $1")
|
||||
.bind(auth.user_id)
|
||||
.bind(size)
|
||||
.execute(&mut *tx)
|
||||
.await?
|
||||
};
|
||||
if inc.rows_affected() == 0 {
|
||||
return Err(AppError::QuotaExceeded(
|
||||
"Du hast dein Upload-Limit für dieses Event erreicht.".into(),
|
||||
));
|
||||
}
|
||||
let upload = Upload::create(
|
||||
&mut *tx,
|
||||
auth.event_id,
|
||||
|
||||
@@ -44,6 +44,18 @@ async fn main() -> Result<()> {
|
||||
|
||||
let state = AppState::new(pool.clone(), config.clone());
|
||||
|
||||
// Re-spawn exports for events that were released but whose keepsake never finished
|
||||
// (crash mid-export). Needs the media/export paths + SSE sender, so it runs here
|
||||
// rather than inside `startup_recovery`. Fire-and-forget: the workers run in the
|
||||
// background; the HTTP server can start accepting requests meanwhile.
|
||||
services::export::recover_exports(
|
||||
pool.clone(),
|
||||
config.media_path.clone(),
|
||||
config.export_path.clone(),
|
||||
state.sse_tx.clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Hourly background hygiene: prune expired sessions, evict cold rate-limiter
|
||||
// keys. Keeps the DB and process from growing unboundedly over multi-day events.
|
||||
services::maintenance::spawn_periodic_tasks(
|
||||
@@ -60,8 +72,12 @@ async fn main() -> Result<()> {
|
||||
.route("/api/v1/event", get(handlers::public::get_public_event))
|
||||
.route("/api/v1/join", post(auth::handlers::join))
|
||||
.route("/api/v1/recover", post(auth::handlers::recover))
|
||||
// Forgotten-PIN escape hatch: ask a host to reset it (unauthenticated, throttled).
|
||||
.route("/api/v1/recover/request", post(auth::handlers::request_pin_reset))
|
||||
.route("/api/v1/admin/login", post(auth::handlers::admin_login))
|
||||
.route("/api/v1/session", delete(auth::handlers::logout))
|
||||
// "Sign out everywhere" — revoke all of the caller's sessions.
|
||||
.route("/api/v1/sessions", delete(auth::handlers::logout_all))
|
||||
// Upload — HTTP-level body cap as an OOM backstop. The handler still enforces
|
||||
// the precise per-class limits from DB config (max_image/video_size_mb); this
|
||||
// layer just stops a multi-GB body from being buffered into memory before that
|
||||
@@ -118,6 +134,14 @@ async fn main() -> Result<()> {
|
||||
"/api/v1/host/users/{id}/pin-reset",
|
||||
post(handlers::host::reset_user_pin),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/host/pin-reset-requests",
|
||||
get(handlers::host::list_pin_reset_requests),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/host/pin-reset-requests/{id}",
|
||||
delete(handlers::host::dismiss_pin_reset_request),
|
||||
)
|
||||
.route("/api/v1/host/upload/{id}", delete(handlers::host::host_delete_upload))
|
||||
.route("/api/v1/host/comment/{id}", delete(handlers::host::host_delete_comment))
|
||||
// Export (all authenticated users)
|
||||
|
||||
@@ -65,13 +65,25 @@ impl Session {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Update `last_seen_at`, keyed by the token hash so the auth extractor can touch a
|
||||
/// session without a separate query to fetch its id first.
|
||||
pub async fn touch_by_token_hash(pool: &PgPool, token_hash: &str) -> Result<(), sqlx::Error> {
|
||||
sqlx::query("UPDATE session SET last_seen_at = NOW() WHERE token_hash = $1")
|
||||
.bind(token_hash)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
/// Touch `last_seen_at` AND slide `expires_at` forward by `expiry_days` from now, so
|
||||
/// an actively-used session never hits the fixed 30-day cliff (it renews on every
|
||||
/// authenticated request). An idle session still expires `expiry_days` after its last
|
||||
/// activity. Keyed by token hash so the auth extractor needs no prior id lookup.
|
||||
pub async fn touch_and_renew(
|
||||
pool: &PgPool,
|
||||
token_hash: &str,
|
||||
expiry_days: i64,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
"UPDATE session
|
||||
SET last_seen_at = NOW(),
|
||||
expires_at = NOW() + ($2 || ' days')::interval
|
||||
WHERE token_hash = $1",
|
||||
)
|
||||
.bind(token_hash)
|
||||
.bind(expiry_days.to_string())
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -85,4 +97,14 @@ impl Session {
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Revoke every session for a user. Backs "sign out everywhere" and the forced
|
||||
/// re-auth after a host PIN-reset or a ban. Returns the number of sessions cleared.
|
||||
pub async fn delete_all_for_user(pool: &PgPool, user_id: Uuid) -> Result<u64, sqlx::Error> {
|
||||
let r = sqlx::query("DELETE FROM session WHERE user_id = $1")
|
||||
.bind(user_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(r.rows_affected())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,9 +86,9 @@ impl Upload {
|
||||
|
||||
/// Lean lookup for the public media aliases (`get_original`/`get_preview`/
|
||||
/// `get_thumbnail`): returns ONLY the file paths + mime for a visible upload —
|
||||
/// excluding soft-deleted rows and ban-hidden owners (`user.uploads_hidden`), the
|
||||
/// same filter `v_feed` applies. So moderation that removes a post from the feed
|
||||
/// also stops its original/preview/thumbnail from being pulled by UUID.
|
||||
/// excluding soft-deleted rows, hidden owners (`uploads_hidden`), and banned owners
|
||||
/// (`is_banned`) — the same filter `v_feed` applies. So moderation that removes a post
|
||||
/// from the feed also stops its original/preview/thumbnail from being pulled by UUID.
|
||||
///
|
||||
/// Selects four columns instead of the whole `Upload` row: this runs once per image
|
||||
/// per cache-miss on the media hot path, so we avoid hydrating fields the response
|
||||
@@ -101,7 +101,8 @@ impl Upload {
|
||||
"SELECT up.original_path, up.preview_path, up.thumbnail_path, up.mime_type
|
||||
FROM upload up
|
||||
JOIN \"user\" u ON u.id = up.user_id
|
||||
WHERE up.id = $1 AND up.deleted_at IS NULL AND u.uploads_hidden = false",
|
||||
WHERE up.id = $1 AND up.deleted_at IS NULL
|
||||
AND u.uploads_hidden = false AND u.is_banned = false",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
|
||||
@@ -82,6 +82,91 @@ struct ViewerMedia {
|
||||
|
||||
// ── Entry point ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// (Re)enqueue the export jobs for an event and spawn the workers. Safe to call on a
|
||||
/// fresh release *and* on a re-release: the `export_job` rows are reset to a clean
|
||||
/// `pending` state (clearing any prior `failed`/`done` from an earlier run) and the
|
||||
/// event's `export_*_ready` flags are cleared so downloads reflect the regenerating
|
||||
/// export rather than the stale one. This is the single path used by `release_gallery`
|
||||
/// and by startup export recovery, so the two can't drift.
|
||||
pub async fn enqueue_and_spawn_exports(
|
||||
event_id: Uuid,
|
||||
event_name: String,
|
||||
pool: PgPool,
|
||||
media_path: PathBuf,
|
||||
export_path: PathBuf,
|
||||
sse_tx: broadcast::Sender<SseEvent>,
|
||||
) -> Result<()> {
|
||||
for export_type in ["zip", "html"] {
|
||||
// Reset a prior 'done'/'failed' row to 'pending' so this (re)release regenerates —
|
||||
// but DO NOT touch a row that's still 'running'. If a worker from an earlier
|
||||
// release is mid-export, leaving it 'running' makes the worker we spawn below bail
|
||||
// its claim (WHERE status='pending' → 0 rows), so the two never race the temp file.
|
||||
sqlx::query(
|
||||
"INSERT INTO export_job (event_id, type, status, progress_pct)
|
||||
VALUES ($1, $2::export_type, 'pending', 0)
|
||||
ON CONFLICT (event_id, type) DO UPDATE
|
||||
SET status = 'pending', progress_pct = 0, file_path = NULL,
|
||||
error_message = NULL, completed_at = NULL
|
||||
WHERE export_job.status <> 'running'",
|
||||
)
|
||||
.bind(event_id)
|
||||
.bind(export_type)
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
}
|
||||
// Clear readiness so /export downloads 404 until the fresh run completes, instead of
|
||||
// serving a stale keepsake that predates a reopen→re-release.
|
||||
sqlx::query("UPDATE event SET export_zip_ready = FALSE, export_html_ready = FALSE WHERE id = $1")
|
||||
.bind(event_id)
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
|
||||
spawn_export_jobs(event_id, event_name, pool, media_path, export_path, sse_tx);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Startup export recovery: re-spawn exports for any released event whose keepsake is
|
||||
/// not fully ready (a crash mid-export left `export_released_at` set but the ZIP/HTML
|
||||
/// jobs `failed`). Without this, `release_gallery` would reject a retry with "bereits
|
||||
/// freigegeben" and downloads would 404 forever. Runs once at boot, after `AppState`
|
||||
/// exists (it needs the media/export paths + SSE sender).
|
||||
pub async fn recover_exports(
|
||||
pool: PgPool,
|
||||
media_path: PathBuf,
|
||||
export_path: PathBuf,
|
||||
sse_tx: broadcast::Sender<SseEvent>,
|
||||
) {
|
||||
let rows = match sqlx::query_as::<_, (Uuid, String)>(
|
||||
"SELECT id, name FROM event
|
||||
WHERE export_released_at IS NOT NULL
|
||||
AND NOT (export_zip_ready AND export_html_ready)",
|
||||
)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
tracing::error!("export recovery: failed to query released events: {e:#}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
for (event_id, event_name) in rows {
|
||||
tracing::warn!("export recovery: re-spawning export jobs for event {event_id}");
|
||||
if let Err(e) = enqueue_and_spawn_exports(
|
||||
event_id,
|
||||
event_name,
|
||||
pool.clone(),
|
||||
media_path.clone(),
|
||||
export_path.clone(),
|
||||
sse_tx.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!("export recovery: failed to re-spawn for event {event_id}: {e:#}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn spawn_export_jobs(
|
||||
event_id: Uuid,
|
||||
event_name: String,
|
||||
@@ -124,7 +209,10 @@ async fn run_zip_export(
|
||||
export_path: &Path,
|
||||
sse_tx: &broadcast::Sender<SseEvent>,
|
||||
) -> Result<()> {
|
||||
mark_running(pool, event_id, "zip").await;
|
||||
if !claim_job(pool, event_id, "zip").await {
|
||||
// Another worker already owns this ZIP export — bail rather than race the temp file.
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let uploads = query_uploads(pool, event_id).await?;
|
||||
let total = uploads.len().max(1) as f32;
|
||||
@@ -217,7 +305,10 @@ async fn run_html_export(
|
||||
export_path: &Path,
|
||||
sse_tx: &broadcast::Sender<SseEvent>,
|
||||
) -> Result<()> {
|
||||
mark_running(pool, event_id, "html").await;
|
||||
if !claim_job(pool, event_id, "html").await {
|
||||
// Another worker already owns this HTML export — bail rather than race the temp dir.
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 1. Query data
|
||||
let uploads = query_uploads(pool, event_id).await?;
|
||||
@@ -511,7 +602,8 @@ async fn query_uploads(pool: &PgPool, event_id: Uuid) -> Result<Vec<ExportUpload
|
||||
FROM upload u
|
||||
JOIN \"user\" usr ON usr.id = u.user_id
|
||||
LEFT JOIN \"like\" l ON l.upload_id = u.id
|
||||
WHERE u.event_id = $1 AND u.deleted_at IS NULL AND usr.uploads_hidden = FALSE
|
||||
WHERE u.event_id = $1 AND u.deleted_at IS NULL
|
||||
AND usr.uploads_hidden = FALSE AND usr.is_banned = FALSE
|
||||
GROUP BY u.id, usr.display_name
|
||||
ORDER BY u.created_at ASC",
|
||||
)
|
||||
@@ -548,14 +640,24 @@ async fn query_hashtags(pool: &PgPool, event_id: Uuid) -> Result<Vec<(Uuid, Stri
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
async fn mark_running(pool: &PgPool, event_id: Uuid, export_type: &str) {
|
||||
let _ = sqlx::query(
|
||||
"UPDATE export_job SET status = 'running' WHERE event_id = $1 AND type = $2::export_type",
|
||||
/// Atomically claim a pending export job for this worker. Returns `true` only if we won
|
||||
/// (status flipped `pending`→`running` in one statement). Returns `false` when another
|
||||
/// worker already owns it — e.g. a reopen→re-release spawned a second worker while a run
|
||||
/// from the first release is still in flight. Both write the SAME fixed temp path
|
||||
/// (`Gallery.zip.tmp` / `viewer_tmp_*`), so a loser MUST NOT run or it would interleave
|
||||
/// bytes and corrupt the archive. The row-level lock serializes the two UPDATEs, so
|
||||
/// exactly one sees `status = 'pending'`.
|
||||
async fn claim_job(pool: &PgPool, event_id: Uuid, export_type: &str) -> bool {
|
||||
sqlx::query(
|
||||
"UPDATE export_job SET status = 'running'
|
||||
WHERE event_id = $1 AND type = $2::export_type AND status = 'pending'",
|
||||
)
|
||||
.bind(event_id)
|
||||
.bind(export_type)
|
||||
.execute(pool)
|
||||
.await;
|
||||
.await
|
||||
.map(|r| r.rows_affected() > 0)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
async fn mark_failed(pool: &PgPool, event_id: Uuid, export_type: &str, msg: &str) {
|
||||
|
||||
@@ -44,8 +44,11 @@ pub async fn startup_recovery(pool: &PgPool) {
|
||||
Err(e) => tracing::error!("startup recovery: failed to sweep uploads: {e:#}"),
|
||||
}
|
||||
|
||||
// Export jobs interrupted mid-run. Mark 'failed' so the host can re-trigger.
|
||||
// The `UNIQUE(event_id, type)` constraint would otherwise block re-release.
|
||||
// Export jobs interrupted mid-run are marked 'failed' here so they aren't left
|
||||
// 'running' forever. The host CANNOT re-trigger a released export (release_gallery
|
||||
// rejects an already-released event), so `export::recover_exports` re-spawns these
|
||||
// failed-but-released jobs from `main` once `AppState` exists (it needs the media
|
||||
// paths + SSE sender this fn doesn't have).
|
||||
match sqlx::query(
|
||||
"UPDATE export_job
|
||||
SET status = 'failed',
|
||||
|
||||
@@ -42,7 +42,11 @@ pub struct AppState {
|
||||
|
||||
impl AppState {
|
||||
pub fn new(pool: PgPool, config: AppConfig) -> Self {
|
||||
let (sse_tx, _) = broadcast::channel(256);
|
||||
// Broadcast buffer for live SSE fan-out. Sized to absorb a burst (e.g. many
|
||||
// uploads landing at once during a busy moment) before a slow consumer lags and
|
||||
// has to `resync`. The resync path is a correctness backstop, not the happy path —
|
||||
// a roomier buffer keeps ~1000 concurrent clients from all resyncing at once.
|
||||
let (sse_tx, _) = broadcast::channel(1024);
|
||||
let compression = CompressionWorker::new(
|
||||
pool.clone(),
|
||||
config.media_path.clone(),
|
||||
|
||||
Reference in New Issue
Block a user