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:
25
backend/migrations/011_ban_hides_and_pin_reset.down.sql
Normal file
25
backend/migrations/011_ban_hides_and_pin_reset.down.sql
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
DROP TABLE IF EXISTS pin_reset_request;
|
||||||
|
|
||||||
|
-- Restore v_feed without the is_banned filter.
|
||||||
|
CREATE OR REPLACE VIEW v_feed AS
|
||||||
|
SELECT
|
||||||
|
u.id,
|
||||||
|
u.event_id,
|
||||||
|
u.user_id,
|
||||||
|
usr.display_name AS uploader_name,
|
||||||
|
usr.is_banned,
|
||||||
|
usr.uploads_hidden,
|
||||||
|
u.preview_path,
|
||||||
|
u.thumbnail_path,
|
||||||
|
u.mime_type,
|
||||||
|
u.caption,
|
||||||
|
u.created_at,
|
||||||
|
COUNT(DISTINCT l.user_id) AS like_count,
|
||||||
|
COUNT(DISTINCT c.id) AS comment_count
|
||||||
|
FROM upload u
|
||||||
|
JOIN "user" usr ON u.user_id = usr.id
|
||||||
|
LEFT JOIN "like" l ON l.upload_id = u.id
|
||||||
|
LEFT JOIN comment c ON c.upload_id = u.id AND c.deleted_at IS NULL
|
||||||
|
WHERE u.deleted_at IS NULL
|
||||||
|
AND usr.uploads_hidden = FALSE
|
||||||
|
GROUP BY u.id, usr.display_name, usr.is_banned, usr.uploads_hidden;
|
||||||
39
backend/migrations/011_ban_hides_and_pin_reset.up.sql
Normal file
39
backend/migrations/011_ban_hides_and_pin_reset.up.sql
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
-- Ban now ALWAYS hides: exclude banned uploaders from the feed view. Previously ban and
|
||||||
|
-- hide were decoupled (a host could ban without hiding), leaving a banned user's photos on
|
||||||
|
-- the feed and baked into the export. `find_visible_media` and the export query get the
|
||||||
|
-- same `is_banned = FALSE` filter in code.
|
||||||
|
CREATE OR REPLACE VIEW v_feed AS
|
||||||
|
SELECT
|
||||||
|
u.id,
|
||||||
|
u.event_id,
|
||||||
|
u.user_id,
|
||||||
|
usr.display_name AS uploader_name,
|
||||||
|
usr.is_banned,
|
||||||
|
usr.uploads_hidden,
|
||||||
|
u.preview_path,
|
||||||
|
u.thumbnail_path,
|
||||||
|
u.mime_type,
|
||||||
|
u.caption,
|
||||||
|
u.created_at,
|
||||||
|
COUNT(DISTINCT l.user_id) AS like_count,
|
||||||
|
COUNT(DISTINCT c.id) AS comment_count
|
||||||
|
FROM upload u
|
||||||
|
JOIN "user" usr ON u.user_id = usr.id
|
||||||
|
LEFT JOIN "like" l ON l.upload_id = u.id
|
||||||
|
LEFT JOIN comment c ON c.upload_id = u.id AND c.deleted_at IS NULL
|
||||||
|
WHERE u.deleted_at IS NULL
|
||||||
|
AND usr.uploads_hidden = FALSE
|
||||||
|
AND usr.is_banned = FALSE
|
||||||
|
GROUP BY u.id, usr.display_name, usr.is_banned, usr.uploads_hidden;
|
||||||
|
|
||||||
|
-- pin_reset_request: a guest who forgot their PIN (localStorage was the only copy) can ask
|
||||||
|
-- a host to reset it in-app, instead of being permanently orphaned. One pending request per
|
||||||
|
-- user; cleared when the host resets the PIN or dismisses it, and cascades if the user/event
|
||||||
|
-- is removed.
|
||||||
|
CREATE TABLE pin_reset_request (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
event_id UUID NOT NULL REFERENCES event(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE (user_id)
|
||||||
|
);
|
||||||
@@ -83,7 +83,19 @@ pub async fn join(
|
|||||||
let pin_hash =
|
let pin_hash =
|
||||||
bcrypt::hash(&pin, 12).map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
|
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(
|
let token = jwt::create_token(
|
||||||
user.id,
|
user.id,
|
||||||
@@ -359,3 +371,77 @@ pub async fn logout(
|
|||||||
Session::delete_by_token_hash(&state.pool, &auth.token_hash).await?;
|
Session::delete_by_token_hash(&state.pool, &auth.token_hash).await?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
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> {
|
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>(
|
let data = jsonwebtoken::decode::<Claims>(
|
||||||
token,
|
token,
|
||||||
&DecodingKey::from_secret(secret.as_bytes()),
|
&DecodingKey::from_secret(secret.as_bytes()),
|
||||||
&Validation::default(),
|
&validation,
|
||||||
)?;
|
)?;
|
||||||
Ok(data.claims)
|
Ok(data.claims)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,9 +37,10 @@ impl FromRequestParts<AppState> for AuthUser {
|
|||||||
.strip_prefix("Bearer ")
|
.strip_prefix("Bearer ")
|
||||||
.ok_or_else(|| AppError::Unauthorized("Ungültiges Token-Format.".into()))?;
|
.ok_or_else(|| AppError::Unauthorized("Ungültiges Token-Format.".into()))?;
|
||||||
|
|
||||||
// Verify the JWT (signature + expiry). We deliberately do NOT trust its role/ban
|
// Verify the JWT's signature. Expiry is deliberately NOT enforced here (see
|
||||||
// claims — the live user row below is authoritative — so the decoded claims
|
// `jwt::verify_token`) — the authoritative, sliding session lifetime lives in the
|
||||||
// themselves aren't needed beyond this check.
|
// `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)
|
jwt::verify_token(token, &state.config.jwt_secret)
|
||||||
.map_err(|_| AppError::Unauthorized("Token ungültig oder abgelaufen.".into()))?;
|
.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()))?
|
.map_err(|e| AppError::Internal(e.into()))?
|
||||||
.ok_or_else(|| AppError::Unauthorized("Sitzung nicht gefunden oder abgelaufen.".into()))?;
|
.ok_or_else(|| AppError::Unauthorized("Sitzung nicht gefunden oder abgelaufen.".into()))?;
|
||||||
|
|
||||||
// Update last_seen_at in the background (fire-and-forget). Failures are
|
// Touch last_seen_at AND slide the session's expiry forward (fire-and-forget), so
|
||||||
// non-fatal but worth surfacing — silent swallowing hides DB connection
|
// an active client's session renews instead of hitting the fixed 30-day cliff.
|
||||||
// pressure that would otherwise be the first symptom of a real problem.
|
// 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 pool = state.pool.clone();
|
||||||
let touch_hash = token_hash.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 {
|
tokio::spawn(async move {
|
||||||
if let Err(e) = Session::touch_by_token_hash(&pool, &touch_hash).await {
|
if let Err(e) = Session::touch_and_renew(&pool, &touch_hash, expiry_days).await {
|
||||||
tracing::warn!(error = ?e, "session touch failed");
|
tracing::warn!(error = ?e, "session touch/renew failed");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ pub enum AppError {
|
|||||||
Conflict(String),
|
Conflict(String),
|
||||||
/// Second field: optional retry-after seconds to include in the response.
|
/// Second field: optional retry-after seconds to include in the response.
|
||||||
TooManyRequests(String, Option<u64>),
|
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),
|
Internal(anyhow::Error),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,6 +26,7 @@ impl AppError {
|
|||||||
Self::NotFound(_) => (StatusCode::NOT_FOUND, "not_found"),
|
Self::NotFound(_) => (StatusCode::NOT_FOUND, "not_found"),
|
||||||
Self::Conflict(_) => (StatusCode::CONFLICT, "conflict"),
|
Self::Conflict(_) => (StatusCode::CONFLICT, "conflict"),
|
||||||
Self::TooManyRequests(..) => (StatusCode::TOO_MANY_REQUESTS, "too_many_requests"),
|
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"),
|
Self::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "internal_error"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -34,6 +39,7 @@ impl AppError {
|
|||||||
| Self::NotFound(msg)
|
| Self::NotFound(msg)
|
||||||
| Self::Conflict(msg) => msg.clone(),
|
| Self::Conflict(msg) => msg.clone(),
|
||||||
Self::TooManyRequests(msg, _) => msg.clone(),
|
Self::TooManyRequests(msg, _) => msg.clone(),
|
||||||
|
Self::QuotaExceeded(msg) => msg.clone(),
|
||||||
Self::Internal(err) => {
|
Self::Internal(err) => {
|
||||||
tracing::error!("internal error: {err:#}");
|
tracing::error!("internal error: {err:#}");
|
||||||
"Ein interner Fehler ist aufgetreten.".to_string()
|
"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
|
/// rather than merging (the older missed uploads are absent and unrecoverable
|
||||||
/// via a later delta, which advances `since` past them).
|
/// via a later delta, which advances `since` past them).
|
||||||
pub truncated: bool,
|
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(
|
pub async fn feed_delta(
|
||||||
@@ -193,6 +198,33 @@ pub async fn feed_delta(
|
|||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
Query(q): Query<DeltaQuery>,
|
Query(q): Query<DeltaQuery>,
|
||||||
) -> Result<Json<DeltaResponse>, AppError> {
|
) -> 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
|
// 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
|
// 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.
|
// fall back to a full feed refresh rather than another delta.
|
||||||
@@ -254,6 +286,7 @@ pub async fn feed_delta(
|
|||||||
uploads,
|
uploads,
|
||||||
deleted_ids: deleted_ids.into_iter().map(|r| r.0).collect(),
|
deleted_ids: deleted_ids.into_iter().map(|r| r.0).collect(),
|
||||||
truncated,
|
truncated,
|
||||||
|
server_time,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use crate::auth::middleware::RequireHost;
|
|||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::models::comment::Comment;
|
use crate::models::comment::Comment;
|
||||||
use crate::models::event::Event;
|
use crate::models::event::Event;
|
||||||
|
use crate::models::session::Session;
|
||||||
use crate::models::upload::Upload;
|
use crate::models::upload::Upload;
|
||||||
use crate::models::user::UserRole;
|
use crate::models::user::UserRole;
|
||||||
use crate::state::{AppState, SseEvent};
|
use crate::state::{AppState, SseEvent};
|
||||||
@@ -35,11 +36,27 @@ pub struct EventStatus {
|
|||||||
pub export_released: bool,
|
pub export_released: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
/// Count non-banned hosts/admins in the event OTHER than `excluding` — the operators
|
||||||
pub struct BanRequest {
|
/// who would remain if `excluding` were demoted or banned. Used to enforce the "an event
|
||||||
pub hide_uploads: bool,
|
/// 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)]
|
#[derive(Deserialize)]
|
||||||
pub struct SetRoleRequest {
|
pub struct SetRoleRequest {
|
||||||
pub role: String,
|
pub role: String,
|
||||||
@@ -93,8 +110,8 @@ pub async fn ban_user(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
RequireHost(auth): RequireHost,
|
RequireHost(auth): RequireHost,
|
||||||
Path(user_id): Path<Uuid>,
|
Path(user_id): Path<Uuid>,
|
||||||
Json(body): Json<BanRequest>,
|
|
||||||
) -> Result<StatusCode, AppError> {
|
) -> Result<StatusCode, AppError> {
|
||||||
|
// The ban request carries no body — ban always hides (no per-request options).
|
||||||
// Cannot ban yourself or another host/admin
|
// Cannot ban yourself or another host/admin
|
||||||
if user_id == auth.user_id {
|
if user_id == auth.user_id {
|
||||||
return Err(AppError::BadRequest("Du kannst dich nicht selbst sperren.".into()));
|
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()));
|
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(
|
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(user_id)
|
||||||
.bind(body.hide_uploads)
|
|
||||||
.bind(auth.event_id)
|
.bind(auth.event_id)
|
||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// If we hid their uploads, evict them live from every feed + the diashow so a
|
// Evict their content live from every feed + the diashow so it disappears without
|
||||||
// banned guest's content disappears without each viewer having to reload.
|
// each viewer having to reload. (Their own SSE stream is separately dropped by the
|
||||||
if body.hide_uploads {
|
// is_banned revalidation in `sse::stream`, so they stop receiving live pushes while
|
||||||
let _ = state.sse_tx.send(SseEvent::new(
|
// retaining plain read access.)
|
||||||
"user-hidden",
|
let _ = state.sse_tx.send(SseEvent::new(
|
||||||
serde_json::json!({ "user_id": user_id }).to_string(),
|
"user-hidden",
|
||||||
));
|
serde_json::json!({ "user_id": user_id }).to_string(),
|
||||||
}
|
));
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
actor_user_id = %auth.user_id,
|
actor_user_id = %auth.user_id,
|
||||||
target_user_id = %user_id,
|
target_user_id = %user_id,
|
||||||
event_id = %auth.event_id,
|
event_id = %auth.event_id,
|
||||||
hide_uploads = body.hide_uploads,
|
|
||||||
"host: ban_user"
|
"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(
|
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(user_id)
|
||||||
.bind(auth.event_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")
|
sqlx::query("UPDATE \"user\" SET role = $2::user_role WHERE id = $1 AND event_id = $3")
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.bind(new_role)
|
.bind(new_role)
|
||||||
@@ -308,6 +355,16 @@ pub async fn reset_user_pin(
|
|||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
.await?;
|
.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
|
// 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.
|
// their cached local PIN. They'll save the new one on the next /recover.
|
||||||
let _ = state.sse_tx.send(SseEvent::new(
|
let _ = state.sse_tx.send(SseEvent::new(
|
||||||
@@ -325,6 +382,48 @@ pub async fn reset_user_pin(
|
|||||||
Ok(Json(PinResetResponse { 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(
|
pub async fn host_delete_upload(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
RequireHost(auth): RequireHost,
|
RequireHost(auth): RequireHost,
|
||||||
@@ -401,8 +500,17 @@ pub async fn open_event(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
RequireHost(_auth): RequireHost,
|
RequireHost(_auth): RequireHost,
|
||||||
) -> Result<StatusCode, AppError> {
|
) -> 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(
|
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)
|
.bind(&state.config.event_slug)
|
||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
@@ -422,8 +530,15 @@ pub async fn release_gallery(
|
|||||||
// Atomic claim: the conditional UPDATE is the sole gate, so two concurrent
|
// 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.
|
// release calls can't both pass a check-then-set and double-enqueue exports.
|
||||||
// rows_affected == 0 means someone already released.
|
// 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(
|
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",
|
WHERE slug = $1 AND export_released_at IS NULL",
|
||||||
)
|
)
|
||||||
.bind(&state.config.event_slug)
|
.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.
|
// 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)
|
let event = Event::find_by_slug(&state.pool, &state.config.event_slug)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
|
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
|
||||||
|
|
||||||
// Enqueue export jobs
|
// Enqueue + spawn via the shared path so a re-release (after reopen) regenerates
|
||||||
for export_type in ["zip", "html"] {
|
// cleanly and startup recovery uses identical logic.
|
||||||
sqlx::query(
|
crate::services::export::enqueue_and_spawn_exports(
|
||||||
"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(
|
|
||||||
event.id,
|
event.id,
|
||||||
event.name,
|
event.name,
|
||||||
state.pool.clone(),
|
state.pool.clone(),
|
||||||
state.config.media_path.clone(),
|
state.config.media_path.clone(),
|
||||||
state.config.export_path.clone(),
|
state.config.export_path.clone(),
|
||||||
state.sse_tx.clone(),
|
state.sse_tx.clone(),
|
||||||
);
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,12 @@ pub struct MeContextDto {
|
|||||||
pub privacy_note: String,
|
pub privacy_note: String,
|
||||||
pub quota_enabled: bool,
|
pub quota_enabled: bool,
|
||||||
pub storage_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(
|
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 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 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 {
|
Ok(Json(MeContextDto {
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
display_name: user.display_name,
|
display_name: user.display_name,
|
||||||
@@ -76,5 +87,7 @@ pub async fn get_context(
|
|||||||
privacy_note,
|
privacy_note,
|
||||||
quota_enabled,
|
quota_enabled,
|
||||||
storage_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::http::StatusCode;
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::Deserialize;
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::auth::middleware::AuthUser;
|
use crate::auth::middleware::AuthUser;
|
||||||
@@ -12,11 +12,22 @@ use crate::models::hashtag::{self, Hashtag};
|
|||||||
use crate::models::upload::Upload;
|
use crate::models::upload::Upload;
|
||||||
use crate::state::AppState;
|
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(
|
pub async fn toggle_like(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
Path(upload_id): Path<Uuid>,
|
Path(upload_id): Path<Uuid>,
|
||||||
) -> Result<StatusCode, AppError> {
|
) -> Result<Json<LikeResponse>, AppError> {
|
||||||
// Check if user is banned
|
// Check if user is banned
|
||||||
let user = crate::models::user::User::find_by_id(&state.pool, auth.user_id)
|
let user = crate::models::user::User::find_by_id(&state.pool, auth.user_id)
|
||||||
.await?
|
.await?
|
||||||
@@ -35,7 +46,7 @@ pub async fn toggle_like(
|
|||||||
// ("Event schließen") freezes *new uploads* only — likes, comments and
|
// ("Event schließen") freezes *new uploads* only — likes, comments and
|
||||||
// browsing stay open (USER_JOURNEYS §9.3, FEATURES capability matrix).
|
// 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(
|
let result = sqlx::query(
|
||||||
"INSERT INTO \"like\" (upload_id, user_id) VALUES ($1, $2)
|
"INSERT INTO \"like\" (upload_id, user_id) VALUES ($1, $2)
|
||||||
ON CONFLICT (upload_id, user_id) DO NOTHING",
|
ON CONFLICT (upload_id, user_id) DO NOTHING",
|
||||||
@@ -45,7 +56,8 @@ pub async fn toggle_like(
|
|||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if result.rows_affected() == 0 {
|
let liked = result.rows_affected() > 0;
|
||||||
|
if !liked {
|
||||||
// Already liked — remove
|
// Already liked — remove
|
||||||
sqlx::query("DELETE FROM \"like\" WHERE upload_id = $1 AND user_id = $2")
|
sqlx::query("DELETE FROM \"like\" WHERE upload_id = $1 AND user_id = $2")
|
||||||
.bind(upload_id)
|
.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
|
// 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
|
// refetching page 1 (mirrors v_feed.like_count = COUNT(DISTINCT user_id)). The like
|
||||||
// count + broadcast are a UI optimisation — the like itself is already committed,
|
// itself is already committed, so a failed count must not fail the request — but we
|
||||||
// so a failure here must not fail the request. Swallow the error and skip the
|
// also must NOT broadcast/return a bogus 0 (that would push like_count: 0 to every
|
||||||
// broadcast; the next event or a pull-to-refresh reconciles the count.
|
// client until the next event). On error we skip the broadcast and return null.
|
||||||
if let Ok(like_count) = sqlx::query_scalar::<_, i64>(
|
let like_count = sqlx::query_scalar::<_, i64>(
|
||||||
"SELECT COUNT(DISTINCT user_id) FROM \"like\" WHERE upload_id = $1",
|
"SELECT COUNT(DISTINCT user_id) FROM \"like\" WHERE upload_id = $1",
|
||||||
)
|
)
|
||||||
.bind(upload_id)
|
.bind(upload_id)
|
||||||
.fetch_one(&state.pool)
|
.fetch_one(&state.pool)
|
||||||
.await
|
.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 {
|
let _ = state.sse_tx.send(crate::state::SseEvent {
|
||||||
event_type: "like-update".to_string(),
|
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)]
|
#[derive(Deserialize, Default)]
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ pub struct SseQuery {
|
|||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct StreamTicketResponse {
|
pub struct StreamTicketResponse {
|
||||||
pub ticket: String,
|
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
|
/// 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(
|
pub async fn issue_ticket(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
) -> Json<StreamTicketResponse> {
|
) -> Result<Json<StreamTicketResponse>, AppError> {
|
||||||
let ticket = state.sse_tickets.issue(auth.token_hash);
|
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
|
/// 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
|
// 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
|
// logged-out, expired, OR banned session's stream is closed rather than kept alive
|
||||||
// client happens to disconnect. Bounds a stale stream to ~60s. Only a *definitively*
|
// until the client happens to disconnect. Bounds a stale stream to ~60s. Banning
|
||||||
// gone/expired session ends the stream; a transient DB error just retries next tick.
|
// 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 pool = state.pool.clone();
|
||||||
let session_hash = token_hash.clone();
|
let session_hash = token_hash.clone();
|
||||||
let session_gone = async move {
|
let session_gone = async move {
|
||||||
@@ -84,9 +94,10 @@ pub async fn stream(
|
|||||||
ticker.tick().await; // consume the immediate first tick
|
ticker.tick().await; // consume the immediate first tick
|
||||||
loop {
|
loop {
|
||||||
ticker.tick().await;
|
ticker.tick().await;
|
||||||
match Session::find_by_token_hash(&pool, &session_hash).await {
|
match Session::find_user_by_token_hash(&pool, &session_hash).await {
|
||||||
Ok(Some(_)) => {} // still valid — keep streaming
|
Ok(Some(user)) if !user.is_banned => {} // still valid — keep streaming
|
||||||
Ok(None) => break, // logged out or expired — stop
|
Ok(Some(_)) => break, // banned — cut the stream
|
||||||
|
Ok(None) => break, // logged out or expired — stop
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(error = ?e, "SSE session revalidation query failed; retrying");
|
tracing::warn!(error = ?e, "SSE session revalidation query failed; retrying");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,6 +77,13 @@ pub async fn upload(
|
|||||||
drain_multipart(multipart).await;
|
drain_multipart(multipart).await;
|
||||||
return Err(AppError::Forbidden("Uploads sind gesperrt.".into()));
|
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
|
// Read config limits from DB
|
||||||
let max_image_mb: i64 = config::get_i64(&state.config_cache, "max_image_size_mb", 20).await;
|
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.
|
// disable it on trusted instances.
|
||||||
let quota_on = config::get_bool(&state.config_cache, "quota_enabled", true).await;
|
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;
|
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 {
|
if quota_on && storage_quota_on {
|
||||||
let estimate = compute_storage_quota(&state).await;
|
let estimate = compute_storage_quota(&state).await;
|
||||||
if let Some(limit) = estimate.limit_bytes {
|
if let Some(limit) = estimate.limit_bytes {
|
||||||
|
quota_limit = Some(limit);
|
||||||
let prospective_total = user.total_upload_bytes.saturating_add(size);
|
let prospective_total = user.total_upload_bytes.saturating_add(size);
|
||||||
if prospective_total > limit {
|
if prospective_total > limit {
|
||||||
let _ = tokio::fs::remove_file(&temp_abs).await;
|
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(),
|
"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).
|
// bytes with no row to reclaim them (silent quota erosion / spurious lockout).
|
||||||
let tx_result: Result<Upload, AppError> = async {
|
let tx_result: Result<Upload, AppError> = async {
|
||||||
let mut tx = state.pool.begin().await?;
|
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(auth.user_id)
|
||||||
.bind(size)
|
.bind(size)
|
||||||
|
.bind(limit)
|
||||||
.execute(&mut *tx)
|
.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(
|
let upload = Upload::create(
|
||||||
&mut *tx,
|
&mut *tx,
|
||||||
auth.event_id,
|
auth.event_id,
|
||||||
|
|||||||
@@ -44,6 +44,18 @@ async fn main() -> Result<()> {
|
|||||||
|
|
||||||
let state = AppState::new(pool.clone(), config.clone());
|
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
|
// Hourly background hygiene: prune expired sessions, evict cold rate-limiter
|
||||||
// keys. Keeps the DB and process from growing unboundedly over multi-day events.
|
// keys. Keeps the DB and process from growing unboundedly over multi-day events.
|
||||||
services::maintenance::spawn_periodic_tasks(
|
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/event", get(handlers::public::get_public_event))
|
||||||
.route("/api/v1/join", post(auth::handlers::join))
|
.route("/api/v1/join", post(auth::handlers::join))
|
||||||
.route("/api/v1/recover", post(auth::handlers::recover))
|
.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/admin/login", post(auth::handlers::admin_login))
|
||||||
.route("/api/v1/session", delete(auth::handlers::logout))
|
.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
|
// 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
|
// 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
|
// 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",
|
"/api/v1/host/users/{id}/pin-reset",
|
||||||
post(handlers::host::reset_user_pin),
|
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/upload/{id}", delete(handlers::host::host_delete_upload))
|
||||||
.route("/api/v1/host/comment/{id}", delete(handlers::host::host_delete_comment))
|
.route("/api/v1/host/comment/{id}", delete(handlers::host::host_delete_comment))
|
||||||
// Export (all authenticated users)
|
// Export (all authenticated users)
|
||||||
|
|||||||
@@ -65,13 +65,25 @@ impl Session {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update `last_seen_at`, keyed by the token hash so the auth extractor can touch a
|
/// Touch `last_seen_at` AND slide `expires_at` forward by `expiry_days` from now, so
|
||||||
/// session without a separate query to fetch its id first.
|
/// an actively-used session never hits the fixed 30-day cliff (it renews on every
|
||||||
pub async fn touch_by_token_hash(pool: &PgPool, token_hash: &str) -> Result<(), sqlx::Error> {
|
/// authenticated request). An idle session still expires `expiry_days` after its last
|
||||||
sqlx::query("UPDATE session SET last_seen_at = NOW() WHERE token_hash = $1")
|
/// activity. Keyed by token hash so the auth extractor needs no prior id lookup.
|
||||||
.bind(token_hash)
|
pub async fn touch_and_renew(
|
||||||
.execute(pool)
|
pool: &PgPool,
|
||||||
.await?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,4 +97,14 @@ impl Session {
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(())
|
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`/
|
/// Lean lookup for the public media aliases (`get_original`/`get_preview`/
|
||||||
/// `get_thumbnail`): returns ONLY the file paths + mime for a visible upload —
|
/// `get_thumbnail`): returns ONLY the file paths + mime for a visible upload —
|
||||||
/// excluding soft-deleted rows and ban-hidden owners (`user.uploads_hidden`), the
|
/// excluding soft-deleted rows, hidden owners (`uploads_hidden`), and banned owners
|
||||||
/// same filter `v_feed` applies. So moderation that removes a post from the feed
|
/// (`is_banned`) — the same filter `v_feed` applies. So moderation that removes a post
|
||||||
/// also stops its original/preview/thumbnail from being pulled by UUID.
|
/// 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
|
/// 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
|
/// 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
|
"SELECT up.original_path, up.preview_path, up.thumbnail_path, up.mime_type
|
||||||
FROM upload up
|
FROM upload up
|
||||||
JOIN \"user\" u ON u.id = up.user_id
|
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)
|
.bind(id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
|
|||||||
@@ -82,6 +82,91 @@ struct ViewerMedia {
|
|||||||
|
|
||||||
// ── Entry point ──────────────────────────────────────────────────────────────
|
// ── 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(
|
pub fn spawn_export_jobs(
|
||||||
event_id: Uuid,
|
event_id: Uuid,
|
||||||
event_name: String,
|
event_name: String,
|
||||||
@@ -124,7 +209,10 @@ async fn run_zip_export(
|
|||||||
export_path: &Path,
|
export_path: &Path,
|
||||||
sse_tx: &broadcast::Sender<SseEvent>,
|
sse_tx: &broadcast::Sender<SseEvent>,
|
||||||
) -> Result<()> {
|
) -> 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 uploads = query_uploads(pool, event_id).await?;
|
||||||
let total = uploads.len().max(1) as f32;
|
let total = uploads.len().max(1) as f32;
|
||||||
@@ -217,7 +305,10 @@ async fn run_html_export(
|
|||||||
export_path: &Path,
|
export_path: &Path,
|
||||||
sse_tx: &broadcast::Sender<SseEvent>,
|
sse_tx: &broadcast::Sender<SseEvent>,
|
||||||
) -> Result<()> {
|
) -> 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
|
// 1. Query data
|
||||||
let uploads = query_uploads(pool, event_id).await?;
|
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
|
FROM upload u
|
||||||
JOIN \"user\" usr ON usr.id = u.user_id
|
JOIN \"user\" usr ON usr.id = u.user_id
|
||||||
LEFT JOIN \"like\" l ON l.upload_id = u.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
|
GROUP BY u.id, usr.display_name
|
||||||
ORDER BY u.created_at ASC",
|
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)
|
Ok(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn mark_running(pool: &PgPool, event_id: Uuid, export_type: &str) {
|
/// Atomically claim a pending export job for this worker. Returns `true` only if we won
|
||||||
let _ = sqlx::query(
|
/// (status flipped `pending`→`running` in one statement). Returns `false` when another
|
||||||
"UPDATE export_job SET status = 'running' WHERE event_id = $1 AND type = $2::export_type",
|
/// 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(event_id)
|
||||||
.bind(export_type)
|
.bind(export_type)
|
||||||
.execute(pool)
|
.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) {
|
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:#}"),
|
Err(e) => tracing::error!("startup recovery: failed to sweep uploads: {e:#}"),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export jobs interrupted mid-run. Mark 'failed' so the host can re-trigger.
|
// Export jobs interrupted mid-run are marked 'failed' here so they aren't left
|
||||||
// The `UNIQUE(event_id, type)` constraint would otherwise block re-release.
|
// '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(
|
match sqlx::query(
|
||||||
"UPDATE export_job
|
"UPDATE export_job
|
||||||
SET status = 'failed',
|
SET status = 'failed',
|
||||||
|
|||||||
@@ -42,7 +42,11 @@ pub struct AppState {
|
|||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
pub fn new(pool: PgPool, config: AppConfig) -> Self {
|
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(
|
let compression = CompressionWorker::new(
|
||||||
pool.clone(),
|
pool.clone(),
|
||||||
config.media_path.clone(),
|
config.media_path.clone(),
|
||||||
|
|||||||
@@ -129,9 +129,9 @@ the Host can clean up later).
|
|||||||
viewer). Progress is visible in the Admin dashboard's Export tab; SSE
|
viewer). Progress is visible in the Admin dashboard's Export tab; SSE
|
||||||
`export-progress` keeps it live; `export-available` notifies all guests when ready.
|
`export-progress` keeps it live; `export-available` notifies all guests when ready.
|
||||||
5. **Nutzerverwaltung** — search users; per-user controls:
|
5. **Nutzerverwaltung** — search users; per-user controls:
|
||||||
- **Sperren** opens a confirmation modal with a checkbox "Uploads aus der Galerie
|
- **Sperren** opens a confirmation modal. Banning **always hides** the user's existing
|
||||||
ausblenden" — Host chooses whether to hide the user's existing uploads or leave them
|
uploads (a banned user's content is "gone" everywhere) — there is no opt-out. Submitting
|
||||||
visible. Submitting calls `POST /host/users/{id}/ban` with `hide_uploads`.
|
calls `POST /host/users/{id}/ban` (no body).
|
||||||
- **Entsperren** lifts the ban.
|
- **Entsperren** lifts the ban.
|
||||||
- **Host** promotes a guest to host.
|
- **Host** promotes a guest to host.
|
||||||
- **Degradieren** — visible on Host rows. A Host can demote *other* Hosts back to
|
- **Degradieren** — visible on Host rows. A Host can demote *other* Hosts back to
|
||||||
@@ -154,11 +154,15 @@ the Host can clean up later).
|
|||||||
("Du bist gesperrt."). Ban enforcement lives on the write handlers and the
|
("Du bist gesperrt."). Ban enforcement lives on the write handlers and the
|
||||||
host/admin extractors, not the base auth extractor.
|
host/admin extractors, not the base auth extractor.
|
||||||
3. They can still browse the read-only feed (and download the export once it's released).
|
3. They can still browse the read-only feed (and download the export once it's released).
|
||||||
|
Their sessions are **not** revoked — this is a read-only ban, not a logout. Their own
|
||||||
|
live SSE stream is dropped by the `is_banned` revalidation (no live pushes), but plain
|
||||||
|
feed reads still work.
|
||||||
4. They cannot upload, like, or comment; a banned host also loses all host/admin
|
4. They cannot upload, like, or comment; a banned host also loses all host/admin
|
||||||
actions (including unbanning themselves).
|
actions (including unbanning themselves).
|
||||||
5. If `hide_uploads` was set on the ban, their existing uploads are filtered out of the
|
5. Banning **always hides**: the user's existing uploads are filtered out of the feed for
|
||||||
feed for everyone (`v_feed` enforces this), and a live `user-hidden` SSE event evicts
|
everyone (`v_feed`, `find_visible_media`, and the export query all enforce
|
||||||
their cards from every open feed + the diashow without a reload.
|
`is_banned = FALSE`), and a live `user-hidden` SSE event evicts their cards from every
|
||||||
|
open feed + the diashow without a reload.
|
||||||
|
|
||||||
## 11. Admin — instance configuration
|
## 11. Admin — instance configuration
|
||||||
|
|
||||||
|
|||||||
@@ -31,21 +31,21 @@ test.describe('Feed — like + comment', () => {
|
|||||||
expect(row.liked_by_me).toBe(false);
|
expect(row.liked_by_me).toBe(false);
|
||||||
|
|
||||||
// First like → counted exactly once.
|
// First like → counted exactly once.
|
||||||
expect(await like(liker.jwt, uploadId)).toBe(204);
|
expect(await like(liker.jwt, uploadId)).toBe(200);
|
||||||
row = findFeedRow(await api.getFeed(liker.jwt), uploadId);
|
row = findFeedRow(await api.getFeed(liker.jwt), uploadId);
|
||||||
expect(row.like_count).toBe(1);
|
expect(row.like_count).toBe(1);
|
||||||
expect(row.liked_by_me).toBe(true);
|
expect(row.liked_by_me).toBe(true);
|
||||||
|
|
||||||
// Liking again is a toggle → back to zero (guards against a regression that
|
// Liking again is a toggle → back to zero (guards against a regression that
|
||||||
// double-counts a repeated like instead of removing it).
|
// double-counts a repeated like instead of removing it).
|
||||||
expect(await like(liker.jwt, uploadId)).toBe(204);
|
expect(await like(liker.jwt, uploadId)).toBe(200);
|
||||||
row = findFeedRow(await api.getFeed(liker.jwt), uploadId);
|
row = findFeedRow(await api.getFeed(liker.jwt), uploadId);
|
||||||
expect(row.like_count).toBe(0);
|
expect(row.like_count).toBe(0);
|
||||||
expect(row.liked_by_me).toBe(false);
|
expect(row.liked_by_me).toBe(false);
|
||||||
|
|
||||||
// A second distinct user's like is counted independently (per-user semantics).
|
// A second distinct user's like is counted independently (per-user semantics).
|
||||||
expect(await like(liker.jwt, uploadId)).toBe(204); // liker likes again → 1
|
expect(await like(liker.jwt, uploadId)).toBe(200); // liker likes again → 1
|
||||||
expect(await like(author.jwt, uploadId)).toBe(204); // author likes too → 2
|
expect(await like(author.jwt, uploadId)).toBe(200); // author likes too → 2
|
||||||
row = findFeedRow(await api.getFeed(liker.jwt), uploadId);
|
row = findFeedRow(await api.getFeed(liker.jwt), uploadId);
|
||||||
expect(row.like_count).toBe(2);
|
expect(row.like_count).toBe(2);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ test.describe('Host — event lock', () => {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${g.jwt}` },
|
headers: { Authorization: `Bearer ${g.jwt}` },
|
||||||
});
|
});
|
||||||
expect(likeRes.status).toBe(204);
|
expect(likeRes.status).toBe(200);
|
||||||
|
|
||||||
// Comments stay open on a locked event.
|
// Comments stay open on a locked event.
|
||||||
const commentRes = await fetch(`${BASE}/api/v1/upload/${id}/comments`, {
|
const commentRes = await fetch(`${BASE}/api/v1/upload/${id}/comments`, {
|
||||||
|
|||||||
@@ -16,13 +16,15 @@ test.describe('Host — moderation API', () => {
|
|||||||
expect(row?.uploads_hidden).toBe(true);
|
expect(row?.uploads_hidden).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('ban without hiding leaves uploads_hidden=false', async ({ api, host, guest }) => {
|
test('ban always hides — the hide_uploads flag is ignored (USER_JOURNEYS §10.5)', async ({ api, host, guest }) => {
|
||||||
|
// Ban is now unconditionally a hide: even asking NOT to hide still hides, because a
|
||||||
|
// banned user's content is "gone" everywhere. The legacy hide_uploads arg is ignored.
|
||||||
const target = await guest('Banned2');
|
const target = await guest('Banned2');
|
||||||
await api.banUser(host.jwt, target.userId, false);
|
await api.banUser(host.jwt, target.userId, false);
|
||||||
const users = await api.listUsers(host.jwt);
|
const users = await api.listUsers(host.jwt);
|
||||||
const row = users.find((u: any) => u.id === target.userId);
|
const row = users.find((u: any) => u.id === target.userId);
|
||||||
expect(row?.is_banned).toBe(true);
|
expect(row?.is_banned).toBe(true);
|
||||||
expect(row?.uploads_hidden).toBe(false);
|
expect(row?.uploads_hidden).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('banned user cannot call /upload', async ({ api, host, guest }) => {
|
test('banned user cannot call /upload', async ({ api, host, guest }) => {
|
||||||
@@ -102,7 +104,7 @@ test.describe('Host — live role/ban revocation (H1)', () => {
|
|||||||
// H1 / USER_JOURNEYS §10: a banned user keeps *read* access but every write is
|
// H1 / USER_JOURNEYS §10: a banned user keeps *read* access but every write is
|
||||||
// rejected with 403. The ban is enforced on write handlers + Require{Host,Admin},
|
// rejected with 403. The ban is enforced on write handlers + Require{Host,Admin},
|
||||||
// NOT in the base extractor (which would wrongly block reads too).
|
// NOT in the base extractor (which would wrongly block reads too).
|
||||||
test('a banned user keeps read access but is blocked from writes', async ({ api, host, guest }) => {
|
test('a banned user keeps read access but is blocked from writes', async ({ api, host, guest, db }) => {
|
||||||
const base = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
const base = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||||
const target = await guest('BannedRW');
|
const target = await guest('BannedRW');
|
||||||
const auth = (jwt: string) => ({ Authorization: `Bearer ${jwt}` });
|
const auth = (jwt: string) => ({ Authorization: `Bearer ${jwt}` });
|
||||||
@@ -147,10 +149,11 @@ test.describe('Host — live role/ban revocation (H1)', () => {
|
|||||||
});
|
});
|
||||||
expect(delComment.status).toBe(403);
|
expect(delComment.status).toBe(403);
|
||||||
|
|
||||||
// The upload survived every blocked write (still fetchable via the feed).
|
// The upload survived every blocked write — the delete was rejected, so the row still
|
||||||
const feed = await fetch(base + '/api/v1/feed', { headers: auth(host.jwt) });
|
// exists (deleted_at IS NULL). We check the DB directly rather than the feed: ban now
|
||||||
const body = await feed.json();
|
// ALWAYS hides, so a banned user's own upload is (correctly) filtered out of every feed
|
||||||
const rows = body.uploads ?? body.items ?? body;
|
// even though the row is intact. Read access to *other* content is proven by the 200 on
|
||||||
expect(Array.isArray(rows) && rows.some((u: any) => u.id === ownUpload)).toBe(true);
|
// /me/context above.
|
||||||
|
expect(await db.countUploadsForUser(target.userId)).toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
198
e2e/specs/10-flow-review/export-reopen-rerelease.spec.ts
Normal file
198
e2e/specs/10-flow-review/export-reopen-rerelease.spec.ts
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
/**
|
||||||
|
* Re-review regression guard — Chain #2 ("export corruption on reopen→re-release").
|
||||||
|
*
|
||||||
|
* Bug that was fixed: `spawn_export_jobs` ran the worker unconditionally and the
|
||||||
|
* worker wrote a FIXED temp path (`Gallery.zip.tmp` / `viewer_tmp_{event}`).
|
||||||
|
* The new reopen→re-release flow could therefore spawn a second worker while the
|
||||||
|
* first was still running — two workers stomping the same temp file → a corrupt
|
||||||
|
* keepsake ZIP served to guests.
|
||||||
|
*
|
||||||
|
* The fix: `claim_job` is an atomic `UPDATE ... WHERE status='pending'`; a worker
|
||||||
|
* that doesn't win the claim bails without touching the temp file. The re-enqueue
|
||||||
|
* `ON CONFLICT DO UPDATE ... WHERE status <> 'running'` leaves an in-flight job
|
||||||
|
* alone. Net: exactly one worker per (event,type).
|
||||||
|
*
|
||||||
|
* This test seeds real uploads, releases, then churns reopen→re-release (stressing
|
||||||
|
* the claim guard), waits for the export to settle, and asserts the produced ZIP is
|
||||||
|
* INTACT (passes `unzip -t`) with exactly one entry per upload — plus that no
|
||||||
|
* export_job row is left stuck `running`. A temp-file race would corrupt/truncate
|
||||||
|
* the archive or drop entries, failing these assertions.
|
||||||
|
*
|
||||||
|
* Note on scope: a Dockerised export over small fixtures completes in well under a
|
||||||
|
* second, so this cannot *deterministically* force two workers to overlap in time.
|
||||||
|
* It stresses the claim/ON-CONFLICT guard under rapid churn and hard-checks archive
|
||||||
|
* integrity; the atomic single-worker guarantee itself is additionally proven by
|
||||||
|
* code review + SQL PREPARE. Together they cover the regression.
|
||||||
|
*/
|
||||||
|
import { test, expect } from '../../fixtures/test';
|
||||||
|
import { Client } from 'pg';
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { writeFileSync, mkdtempSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { seedUpload } from '../../helpers/seed';
|
||||||
|
|
||||||
|
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||||
|
const SLUG = 'e2e-test-event';
|
||||||
|
const N_UPLOADS = 6;
|
||||||
|
|
||||||
|
const PG = {
|
||||||
|
host: process.env.E2E_DB_HOST ?? 'localhost',
|
||||||
|
port: Number(process.env.E2E_DB_PORT ?? '55432'),
|
||||||
|
user: process.env.E2E_DB_USER ?? 'eventsnap_test',
|
||||||
|
password: process.env.E2E_DB_PASSWORD ?? 'eventsnap_test',
|
||||||
|
database: process.env.E2E_DB_NAME ?? 'eventsnap_test',
|
||||||
|
};
|
||||||
|
|
||||||
|
async function pgQuery<T = any>(sql: string): Promise<T[]> {
|
||||||
|
const c = new Client(PG);
|
||||||
|
await c.connect();
|
||||||
|
try {
|
||||||
|
return (await c.query(sql)).rows as T[];
|
||||||
|
} finally {
|
||||||
|
await c.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function post(path: string, jwt: string) {
|
||||||
|
return fetch(BASE + path, { method: 'POST', headers: { Authorization: `Bearer ${jwt}` } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportStatus(jwt: string): Promise<any> {
|
||||||
|
const res = await fetch(BASE + '/api/v1/export/status', { headers: { Authorization: `Bearer ${jwt}` } });
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mintTicket(jwt: string): Promise<string> {
|
||||||
|
const res = await post('/api/v1/export/ticket', jwt);
|
||||||
|
return (await res.json()).ticket;
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Flow re-review — reopen→re-release export integrity (#2)', () => {
|
||||||
|
// The real export job runs image processing; give it head-room over the tiny fixtures.
|
||||||
|
test.setTimeout(90_000);
|
||||||
|
|
||||||
|
test('rapid release/reopen/re-release yields exactly one INTACT ZIP and no stuck jobs', async ({
|
||||||
|
host,
|
||||||
|
}) => {
|
||||||
|
// Seed real, decodable uploads while the event is open.
|
||||||
|
for (let i = 0; i < N_UPLOADS; i++) {
|
||||||
|
await seedUpload(host.jwt, { caption: `pic ${i}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// First release → export starts and uploads lock (release ⇒ lock).
|
||||||
|
expect((await post('/api/v1/host/gallery/release', host.jwt)).status).toBe(204);
|
||||||
|
|
||||||
|
// Post-release uploads must be rejected (belt-and-suspenders on top of the lock).
|
||||||
|
const { uploadRaw } = await import('../../helpers/upload-client');
|
||||||
|
const { readFileSync } = await import('node:fs');
|
||||||
|
const rejected = await uploadRaw(host.jwt, readFileSync(join(process.cwd(), 'fixtures', 'media', 'sample.jpg')), {
|
||||||
|
filename: 'late.jpg',
|
||||||
|
contentType: 'image/jpeg',
|
||||||
|
});
|
||||||
|
expect(rejected.status).toBe(403);
|
||||||
|
|
||||||
|
// Churn: reopen (clears release + ready flags) then re-release, several times in
|
||||||
|
// quick succession. This is the path that used to be able to double-spawn workers
|
||||||
|
// over the shared temp file. End on a release so the gallery is left released.
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
expect((await post('/api/v1/host/event/open', host.jwt)).status).toBe(204);
|
||||||
|
const rel = await post('/api/v1/host/gallery/release', host.jwt);
|
||||||
|
expect(rel.status).toBe(204);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for the export to settle: both jobs done and the ZIP marked ready.
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const s = await exportStatus(host.jwt);
|
||||||
|
return s.released === true && s.zip?.status === 'done' && s.html?.status === 'done';
|
||||||
|
}, { timeout: 60_000, intervals: [500] })
|
||||||
|
.toBe(true);
|
||||||
|
|
||||||
|
// No export_job may be left stuck 'running' (would mean a worker that never
|
||||||
|
// completed — the failure mode the claim guard exists to avoid).
|
||||||
|
const jobs = await pgQuery<{ type: string; status: string }>(
|
||||||
|
`SELECT ej.type::text AS type, ej.status::text AS status
|
||||||
|
FROM export_job ej JOIN event e ON e.id = ej.event_id
|
||||||
|
WHERE e.slug = '${SLUG}'`
|
||||||
|
);
|
||||||
|
expect(jobs.length).toBe(2);
|
||||||
|
for (const j of jobs) expect(j.status, `${j.type} job status`).toBe('done');
|
||||||
|
|
||||||
|
// Download the ZIP and prove it is INTACT — a temp-file race would corrupt or
|
||||||
|
// truncate it. `unzip -t` verifies every entry's CRC; a non-zero exit throws.
|
||||||
|
const ticket = await mintTicket(host.jwt);
|
||||||
|
const res = await fetch(BASE + '/api/v1/export/zip?ticket=' + encodeURIComponent(ticket));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const bytes = Buffer.from(await res.arrayBuffer());
|
||||||
|
// ZIP local-file-header magic — a stomped/half-written file fails this immediately.
|
||||||
|
expect(bytes.subarray(0, 2).toString('latin1')).toBe('PK');
|
||||||
|
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'es-zip-'));
|
||||||
|
const zipPath = join(dir, 'Gallery.zip');
|
||||||
|
writeFileSync(zipPath, bytes);
|
||||||
|
|
||||||
|
// Integrity: `unzip -t` exits 0 only if all CRCs check out and the archive is whole.
|
||||||
|
execFileSync('unzip', ['-t', zipPath], { stdio: 'pipe' });
|
||||||
|
|
||||||
|
// Completeness: exactly one entry per seeded upload — no entry lost to a stomped
|
||||||
|
// temp file, none duplicated by a second worker.
|
||||||
|
const listing = execFileSync('unzip', ['-Z1', zipPath], { encoding: 'utf8' })
|
||||||
|
.split('\n')
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter((l) => l.length > 0 && !l.endsWith('/'));
|
||||||
|
expect(listing.length, `zip entries:\n${listing.join('\n')}`).toBe(N_UPLOADS);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('re-release does NOT disturb an in-flight (running) export job — the anti-race guard', async ({
|
||||||
|
host,
|
||||||
|
}) => {
|
||||||
|
// Deterministic proof of the fix, independent of export timing. We simulate a worker
|
||||||
|
// that is still mid-export by pinning the zip job to `running` with a sentinel
|
||||||
|
// progress value, then drive reopen→re-release. The fix's
|
||||||
|
// ON CONFLICT ... DO UPDATE ... WHERE export_job.status <> 'running'
|
||||||
|
// must leave that row untouched (and the freshly-spawned worker's
|
||||||
|
// claim_job: UPDATE ... WHERE status = 'pending'
|
||||||
|
// finds nothing to claim, so it bails without racing the temp file).
|
||||||
|
//
|
||||||
|
// On the OLD code (unconditional ON CONFLICT DO UPDATE) the row would be reset to
|
||||||
|
// pending/progress=0 and a second worker would claim and run concurrently — so the
|
||||||
|
// sentinel below would be wiped. This assertion therefore fails on the bug and passes
|
||||||
|
// on the fix.
|
||||||
|
await seedUpload(host.jwt, { caption: 'a' });
|
||||||
|
await seedUpload(host.jwt, { caption: 'b' });
|
||||||
|
|
||||||
|
// Release and let the first export fully finish so no real worker is active.
|
||||||
|
expect((await post('/api/v1/host/gallery/release', host.jwt)).status).toBe(204);
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const s = await exportStatus(host.jwt);
|
||||||
|
return s.zip?.status === 'done' && s.html?.status === 'done';
|
||||||
|
}, { timeout: 60_000, intervals: [500] })
|
||||||
|
.toBe(true);
|
||||||
|
|
||||||
|
// Simulate an in-flight worker owning the zip job, with a sentinel we can check.
|
||||||
|
await pgQuery(
|
||||||
|
`UPDATE export_job ej SET status = 'running', progress_pct = 77
|
||||||
|
FROM event e
|
||||||
|
WHERE e.id = ej.event_id AND e.slug = '${SLUG}' AND ej.type = 'zip'`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reopen (clears release + ready flags, does NOT touch job rows) then re-release.
|
||||||
|
expect((await post('/api/v1/host/event/open', host.jwt)).status).toBe(204);
|
||||||
|
expect((await post('/api/v1/host/gallery/release', host.jwt)).status).toBe(204);
|
||||||
|
|
||||||
|
// Give any spawned worker a beat to attempt (and, correctly, bail) its claim.
|
||||||
|
await new Promise((r) => setTimeout(r, 750));
|
||||||
|
|
||||||
|
// The guard must have preserved the 'running' sentinel — untouched by both the
|
||||||
|
// re-enqueue and the bailing worker.
|
||||||
|
const [row] = await pgQuery<{ status: string; progress_pct: number }>(
|
||||||
|
`SELECT ej.status::text AS status, ej.progress_pct
|
||||||
|
FROM export_job ej JOIN event e ON e.id = ej.event_id
|
||||||
|
WHERE e.slug = '${SLUG}' AND ej.type = 'zip'`
|
||||||
|
);
|
||||||
|
expect(row.status, 'zip job status').toBe('running');
|
||||||
|
expect(Number(row.progress_pct), 'zip job sentinel progress').toBe(77);
|
||||||
|
});
|
||||||
|
});
|
||||||
100
e2e/specs/10-flow-review/offline-resume.spec.ts
Normal file
100
e2e/specs/10-flow-review/offline-resume.spec.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* Re-review regression guard — Chain B1 ("offline queue must auto-resume").
|
||||||
|
*
|
||||||
|
* Bug that was fixed: a file staged while OFFLINE was attempted immediately;
|
||||||
|
* the XHR network error marked the queue item `error`, and every resume path
|
||||||
|
* (the `online` listener, `processQueue`, `loadQueue`) only picks up `pending`
|
||||||
|
* items — so the item was stranded until a manual "Erneut" tap.
|
||||||
|
*
|
||||||
|
* The fix: network failures are a distinct `NetworkError` that keeps the item
|
||||||
|
* `pending`; `processQueue` bails while `navigator.onLine === false` (so an
|
||||||
|
* offline-staged item is never burned to `error`); and `requeueRetriable()`
|
||||||
|
* flips transient `error`→`pending` on the `online` event and on mount.
|
||||||
|
*
|
||||||
|
* This test drives a REAL browser going offline → stage+submit → reconnect and
|
||||||
|
* asserts the upload lands with NO manual interaction. It fails on the old code
|
||||||
|
* (item errors, never resumes) and passes on the fix.
|
||||||
|
*/
|
||||||
|
import { test, expect } from '../../fixtures/test';
|
||||||
|
import { FeedPage, UploadSheet } from '../../page-objects';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
const SAMPLE_JPG = join(process.cwd(), 'fixtures', 'media', 'sample.jpg');
|
||||||
|
|
||||||
|
// White-box: read the upload queue straight out of IndexedDB so we can assert
|
||||||
|
// the item's status without depending on the (currently unrendered) queue widget.
|
||||||
|
async function queueStatuses(page: import('@playwright/test').Page): Promise<string[]> {
|
||||||
|
return page.evaluate(async () => {
|
||||||
|
const statuses = await new Promise<string[]>((resolve, reject) => {
|
||||||
|
const req = indexedDB.open('eventsnap-uploads', 3);
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
req.onsuccess = () => {
|
||||||
|
const dbh = req.result;
|
||||||
|
const tx = dbh.transaction('queue', 'readonly');
|
||||||
|
const all = tx.objectStore('queue').getAll();
|
||||||
|
all.onsuccess = () => resolve(all.result.map((r: any) => r.status));
|
||||||
|
all.onerror = () => reject(all.error);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return statuses;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Flow re-review — offline upload auto-resume (B1)', () => {
|
||||||
|
test('offline-staged upload stays pending, then flushes on reconnect with no manual tap', async ({
|
||||||
|
page,
|
||||||
|
guest,
|
||||||
|
signIn,
|
||||||
|
db,
|
||||||
|
}) => {
|
||||||
|
const g = await guest('OfflineResume');
|
||||||
|
await signIn(page, g);
|
||||||
|
await page.goto('/feed');
|
||||||
|
expect(await db.countUploadsForUser(g.userId)).toBe(0);
|
||||||
|
|
||||||
|
// Warm the code-split `/upload` route chunk while still ONLINE. Without this, the
|
||||||
|
// client-side navigation the UploadSheet triggers would try to fetch the chunk with
|
||||||
|
// no connectivity and hit SvelteKit's error boundary — a test artifact unrelated to
|
||||||
|
// the upload-queue fix under test. (A production PWA service worker would cache it.)
|
||||||
|
await page.goto('/upload');
|
||||||
|
await page.goto('/feed');
|
||||||
|
|
||||||
|
// Go offline BEFORE staging so the first attempt happens with no connectivity.
|
||||||
|
await page.context().setOffline(true);
|
||||||
|
|
||||||
|
const feed = new FeedPage(page);
|
||||||
|
const sheet = new UploadSheet(page);
|
||||||
|
await feed.openUploadSheet();
|
||||||
|
await sheet.stageFiles([SAMPLE_JPG]);
|
||||||
|
await sheet.captionInput.waitFor({ state: 'visible', timeout: 10_000 });
|
||||||
|
await sheet.fillCaption('shot with no signal');
|
||||||
|
await sheet.submit();
|
||||||
|
// handleSubmit navigates to /feed after enqueuing even while offline.
|
||||||
|
await page.waitForURL('**/feed', { timeout: 15_000 });
|
||||||
|
|
||||||
|
// While offline: nothing may have reached the server, and — the crux of the
|
||||||
|
// fix — the item must be parked as `pending`, NOT burned to `error`/`blocked`
|
||||||
|
// (which on the old code would require a manual retry tap).
|
||||||
|
await expect
|
||||||
|
.poll(() => queueStatuses(page), { timeout: 8_000 })
|
||||||
|
.toEqual(['pending']);
|
||||||
|
expect(await db.countUploadsForUser(g.userId)).toBe(0);
|
||||||
|
|
||||||
|
// Reconnect. The `online` listener must resume the queue automatically — we do
|
||||||
|
// NOT navigate, tap retry, or otherwise touch the page.
|
||||||
|
await page.context().setOffline(false);
|
||||||
|
|
||||||
|
// The queued upload is now created server-side without any manual interaction.
|
||||||
|
await expect
|
||||||
|
.poll(() => db.countUploadsForUser(g.userId), { timeout: 20_000 })
|
||||||
|
.toBe(1);
|
||||||
|
|
||||||
|
// And the queue item ends terminal-good (done) or is drained — never stuck in error.
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const s = await queueStatuses(page);
|
||||||
|
return s.every((x) => x === 'done') || s.length === 0;
|
||||||
|
}, { timeout: 10_000 })
|
||||||
|
.toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -58,9 +58,21 @@
|
|||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Pull in a comment posted from another device on the CURRENTLY-open upload, so the
|
||||||
|
// open list matches the count. The `new-comment` broadcast carries only the count (not
|
||||||
|
// the body), so refetch — skipped when it's for a different upload, and the dedupe
|
||||||
|
// below makes our own just-posted optimistic comment a no-op.
|
||||||
|
const unsubNewComment = onSseEvent('new-comment', (data) => {
|
||||||
|
try {
|
||||||
|
const { upload_id } = JSON.parse(data) as { upload_id: string };
|
||||||
|
if (upload_id === upload.id) void loadComments(upload.id);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
if (burstTimer) clearTimeout(burstTimer);
|
if (burstTimer) clearTimeout(burstTimer);
|
||||||
unsubCommentDeleted();
|
unsubCommentDeleted();
|
||||||
|
unsubNewComment();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Only refetch when a *different* upload is shown. The feed reassigns the
|
// Only refetch when a *different* upload is shown. The feed reassigns the
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
case 'uploading': return 'Wird hochgeladen';
|
case 'uploading': return 'Wird hochgeladen';
|
||||||
case 'done': return 'Fertig';
|
case 'done': return 'Fertig';
|
||||||
case 'error': return 'Fehler';
|
case 'error': return 'Fehler';
|
||||||
|
case 'blocked': return 'Gesperrt';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
case 'uploading': return 'text-blue-600 dark:text-blue-400';
|
case 'uploading': return 'text-blue-600 dark:text-blue-400';
|
||||||
case 'done': return 'text-green-600 dark:text-green-400';
|
case 'done': return 'text-green-600 dark:text-green-400';
|
||||||
case 'error': return 'text-red-600 dark:text-red-400';
|
case 'error': return 'text-red-600 dark:text-red-400';
|
||||||
|
case 'blocked': return 'text-red-600 dark:text-red-400';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +96,7 @@
|
|||||||
Erneut
|
Erneut
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if item.status === 'done' || item.status === 'error'}
|
{#if item.status === 'done' || item.status === 'error' || item.status === 'blocked'}
|
||||||
<button
|
<button
|
||||||
onclick={() => removeItem(item.id)}
|
onclick={() => removeItem(item.id)}
|
||||||
class="inline-flex h-9 w-9 items-center justify-center text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
|
class="inline-flex h-9 w-9 items-center justify-center text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
|
||||||
|
|||||||
@@ -5,6 +5,11 @@
|
|||||||
import { scrollLock } from '$lib/actions/scroll-lock';
|
import { scrollLock } from '$lib/actions/scroll-lock';
|
||||||
import CameraCapture from '$lib/components/CameraCapture.svelte';
|
import CameraCapture from '$lib/components/CameraCapture.svelte';
|
||||||
import type { PendingFile } from '$lib/pending-upload-store';
|
import type { PendingFile } from '$lib/pending-upload-store';
|
||||||
|
import { eventState, uploadsClosed } from '$lib/event-state-store';
|
||||||
|
|
||||||
|
// Uploads closed (event locked or gallery released) — show a lock notice instead of
|
||||||
|
// the capture options, so a guest can't stage a photo that would just be rejected.
|
||||||
|
let closed = $derived(uploadsClosed($eventState));
|
||||||
|
|
||||||
let showCamera = $state(false);
|
let showCamera = $state(false);
|
||||||
let fileInput: HTMLInputElement;
|
let fileInput: HTMLInputElement;
|
||||||
@@ -149,6 +154,22 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-3 px-4 pb-4 pt-2">
|
<div class="space-y-3 px-4 pb-4 pt-2">
|
||||||
|
{#if closed}
|
||||||
|
<!-- Uploads closed: no capture options, just an explanation + dismiss. -->
|
||||||
|
<div class="rounded-xl bg-amber-50 px-5 py-4 text-center dark:bg-amber-950/30">
|
||||||
|
<p class="font-semibold text-amber-800 dark:text-amber-300">Uploads geschlossen</p>
|
||||||
|
<p class="mt-1 text-sm text-amber-700 dark:text-amber-400">
|
||||||
|
Der Host hat die Uploads für dieses Event beendet. Du kannst weiterhin Fotos ansehen,
|
||||||
|
liken und kommentieren.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onclick={close}
|
||||||
|
class="w-full rounded-xl border border-gray-200 py-3 text-sm font-medium text-gray-600 transition hover:bg-gray-50 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800"
|
||||||
|
>
|
||||||
|
Schließen
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
<!-- Gallery option -->
|
<!-- Gallery option -->
|
||||||
<button
|
<button
|
||||||
onclick={openGallery}
|
onclick={openGallery}
|
||||||
@@ -189,5 +210,6 @@
|
|||||||
>
|
>
|
||||||
Abbrechen
|
Abbrechen
|
||||||
</button>
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
44
frontend/src/lib/event-state-store.ts
Normal file
44
frontend/src/lib/event-state-store.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { writable } from 'svelte/store';
|
||||||
|
import { api } from './api';
|
||||||
|
import type { MeContextDto } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live event lock/release state, so the composer can reflect a close/reopen the *instant*
|
||||||
|
* it happens instead of a guest discovering the lock via a rejected upload. Seeded from
|
||||||
|
* `/me/context` on boot and kept current by the `event-closed`/`event-opened` SSE events
|
||||||
|
* (see the root layout, which subscribes once).
|
||||||
|
*/
|
||||||
|
export interface EventState {
|
||||||
|
uploadsLocked: boolean;
|
||||||
|
galleryReleased: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const eventState = writable<EventState>({ uploadsLocked: false, galleryReleased: false });
|
||||||
|
|
||||||
|
/** True when uploads are closed for any reason (event locked or gallery released). */
|
||||||
|
export function uploadsClosed(s: EventState): boolean {
|
||||||
|
return s.uploadsLocked || s.galleryReleased;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Refresh from the server. Non-fatal on failure — the next SSE event reconciles. */
|
||||||
|
export async function refreshEventState(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const ctx = await api.get<MeContextDto>('/me/context');
|
||||||
|
eventState.set({
|
||||||
|
uploadsLocked: ctx.uploads_locked,
|
||||||
|
galleryReleased: ctx.gallery_released
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// non-fatal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply an `event-closed` SSE event (uploads just locked). */
|
||||||
|
export function markClosed(): void {
|
||||||
|
eventState.update((s) => ({ ...s, uploadsLocked: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply an `event-opened` SSE event (uploads reopened → release also cleared). */
|
||||||
|
export function markOpened(): void {
|
||||||
|
eventState.set({ uploadsLocked: false, galleryReleased: false });
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ import { getToken } from './auth';
|
|||||||
import { api } from './api';
|
import { api } from './api';
|
||||||
import type { DeltaResponse } from './types';
|
import type { DeltaResponse } from './types';
|
||||||
|
|
||||||
type StreamTicketResponse = { ticket: string };
|
type StreamTicketResponse = { ticket: string; server_time: string };
|
||||||
|
|
||||||
type EventHandler = (data: string) => void;
|
type EventHandler = (data: string) => void;
|
||||||
|
|
||||||
@@ -78,9 +78,11 @@ export function connectSse(): void {
|
|||||||
// pass that on the URL. The JWT itself never appears in URLs / access logs.
|
// pass that on the URL. The JWT itself never appears in URLs / access logs.
|
||||||
void (async () => {
|
void (async () => {
|
||||||
let ticket: string;
|
let ticket: string;
|
||||||
|
let serverTime: string;
|
||||||
try {
|
try {
|
||||||
const res = await api.post<StreamTicketResponse>('/stream/ticket', {});
|
const res = await api.post<StreamTicketResponse>('/stream/ticket', {});
|
||||||
ticket = res.ticket;
|
ticket = res.ticket;
|
||||||
|
serverTime = res.server_time;
|
||||||
} catch {
|
} catch {
|
||||||
// Failed to mint a ticket (auth lapse, network blip). Back off and retry
|
// Failed to mint a ticket (auth lapse, network blip). Back off and retry
|
||||||
// via the existing error path.
|
// via the existing error path.
|
||||||
@@ -95,12 +97,17 @@ export function connectSse(): void {
|
|||||||
eventSource.onopen = () => {
|
eventSource.onopen = () => {
|
||||||
// Successful connection — reset the backoff counter.
|
// Successful connection — reset the backoff counter.
|
||||||
reconnectAttempt = 0;
|
reconnectAttempt = 0;
|
||||||
// If we have a previous timestamp this is a reconnect — fetch the gap.
|
// If we have a previous timestamp this is a reconnect — fetch the gap. The
|
||||||
|
// delta advances `lastEventTime` from the SERVER clock it returns.
|
||||||
const since = lastEventTime;
|
const since = lastEventTime;
|
||||||
if (since) {
|
if (since) {
|
||||||
void deltaFetchAndFan(since);
|
void deltaFetchAndFan(since);
|
||||||
|
} else {
|
||||||
|
// First connect: seed the cursor from the server clock at ticket-mint time,
|
||||||
|
// never `new Date()` — a skewed browser clock would otherwise shift the very
|
||||||
|
// first reconnect window and could drop uploads.
|
||||||
|
lastEventTime = serverTime;
|
||||||
}
|
}
|
||||||
lastEventTime = new Date().toISOString();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const eventName of KNOWN_EVENTS) {
|
for (const eventName of KNOWN_EVENTS) {
|
||||||
@@ -159,7 +166,12 @@ export function setLastEventTime(time: string): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function dispatch(eventType: string, data: string): void {
|
function dispatch(eventType: string, data: string): void {
|
||||||
lastEventTime = new Date().toISOString();
|
// Advance the reconnect cursor from the SERVER timestamp carried in the payload (when
|
||||||
|
// present — e.g. a new upload's `created_at`), never the browser clock. Events without
|
||||||
|
// a timestamp (likes, lock toggles) leave the cursor where it is; the next delta
|
||||||
|
// re-fetches from the last content timestamp we saw, which merges idempotently.
|
||||||
|
const ts = extractCreatedAt(data);
|
||||||
|
if (ts) lastEventTime = ts;
|
||||||
const list = handlers.get(eventType);
|
const list = handlers.get(eventType);
|
||||||
if (list) {
|
if (list) {
|
||||||
for (const handler of list) {
|
for (const handler of list) {
|
||||||
@@ -168,6 +180,17 @@ function dispatch(eventType: string, data: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Pull an ISO `created_at` out of an event payload if it has one, else undefined. */
|
||||||
|
function extractCreatedAt(data: string): string | undefined {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data);
|
||||||
|
if (parsed && typeof parsed.created_at === 'string') return parsed.created_at;
|
||||||
|
} catch {
|
||||||
|
// non-JSON payload (e.g. a plain count) — no timestamp to extract
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch all feed activity since `since` and fan it out as a synthetic `feed-delta`
|
* Fetch all feed activity since `since` and fan it out as a synthetic `feed-delta`
|
||||||
* event. Subscribers (typically the feed page) merge the result into their
|
* event. Subscribers (typically the feed page) merge the result into their
|
||||||
@@ -179,6 +202,9 @@ async function deltaFetchAndFan(since: string): Promise<void> {
|
|||||||
const response = await api.get<DeltaResponse>(
|
const response = await api.get<DeltaResponse>(
|
||||||
`/feed/delta?since=${encodeURIComponent(since)}`
|
`/feed/delta?since=${encodeURIComponent(since)}`
|
||||||
);
|
);
|
||||||
|
// Advance the cursor to the server clock this delta was computed at, so the next
|
||||||
|
// reconnect resumes exactly where the server left off (no browser-clock skew).
|
||||||
|
lastEventTime = response.server_time;
|
||||||
dispatch('feed-delta', JSON.stringify(response));
|
dispatch('feed-delta', JSON.stringify(response));
|
||||||
} catch {
|
} catch {
|
||||||
// non-fatal
|
// non-fatal
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ export interface DeltaResponse {
|
|||||||
// True when the delta hit the backend cap and is only the newest slice of the
|
// True when the delta hit the backend cap and is only the newest slice of the
|
||||||
// gap — the client must full-refresh rather than merge (see feed-delta handler).
|
// gap — the client must full-refresh rather than merge (see feed-delta handler).
|
||||||
truncated: boolean;
|
truncated: boolean;
|
||||||
|
// Server clock when this delta was computed — the next reconnect cursor advances
|
||||||
|
// from THIS, never the browser clock, so clock skew can't silently drop uploads.
|
||||||
|
server_time: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// mirrors backend/src/handlers/feed.rs::HashtagCount
|
// mirrors backend/src/handlers/feed.rs::HashtagCount
|
||||||
@@ -56,6 +59,8 @@ export interface MeContextDto {
|
|||||||
privacy_note: string;
|
privacy_note: string;
|
||||||
quota_enabled: boolean;
|
quota_enabled: boolean;
|
||||||
storage_quota_enabled: boolean;
|
storage_quota_enabled: boolean;
|
||||||
|
uploads_locked: boolean;
|
||||||
|
gallery_released: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// mirrors backend/src/handlers/host.rs::PinResetResponse
|
// mirrors backend/src/handlers/host.rs::PinResetResponse
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ export interface QueueItem {
|
|||||||
mimeType: string;
|
mimeType: string;
|
||||||
caption: string;
|
caption: string;
|
||||||
hashtags: string;
|
hashtags: string;
|
||||||
status: 'pending' | 'uploading' | 'done' | 'error';
|
// 'error' is retryable (network / 5xx); 'blocked' is TERMINAL (a 4xx the server will
|
||||||
|
// keep rejecting — locked event, banned user, released gallery, quota full). Blocked
|
||||||
|
// items have had their blob purged from IndexedDB and offer no retry.
|
||||||
|
status: 'pending' | 'uploading' | 'done' | 'error' | 'blocked';
|
||||||
progress: number;
|
progress: number;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
@@ -26,8 +29,50 @@ export const rateLimitRetryAt = writable<number | null>(null);
|
|||||||
const DB_NAME = 'eventsnap-uploads';
|
const DB_NAME = 'eventsnap-uploads';
|
||||||
const STORE_NAME = 'queue';
|
const STORE_NAME = 'queue';
|
||||||
|
|
||||||
|
/** Hard cap on queued items per device — bounds IndexedDB growth from stuck blobs. */
|
||||||
|
const MAX_QUEUE_ITEMS = 100;
|
||||||
|
|
||||||
let db: IDBPDatabase | null = null;
|
let db: IDBPDatabase | null = null;
|
||||||
|
|
||||||
|
// Resume the queue as soon as connectivity returns. Registered once, guarded for SSR.
|
||||||
|
// This is the other half of the "flushes when you're back online" promise — without it
|
||||||
|
// a reconnect only resumes if the user manually re-stages a file.
|
||||||
|
let onlineBound = false;
|
||||||
|
function bindOnline(): void {
|
||||||
|
if (onlineBound || typeof window === 'undefined') return;
|
||||||
|
window.addEventListener('online', () => {
|
||||||
|
void (async () => {
|
||||||
|
await requeueRetriable();
|
||||||
|
await processQueue();
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
onlineBound = true;
|
||||||
|
}
|
||||||
|
bindOnline();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flip transient `error` items (5xx / a network drop that got marked before we could
|
||||||
|
* reclassify it) back to `pending` so a resume actually retries them. Terminal `blocked`
|
||||||
|
* items (403/413) are left alone — retrying those never succeeds.
|
||||||
|
*/
|
||||||
|
async function requeueRetriable(): Promise<void> {
|
||||||
|
const database = await getDb();
|
||||||
|
const myUserId = getUserId();
|
||||||
|
const all = await database.getAll(STORE_NAME);
|
||||||
|
for (const entry of all) {
|
||||||
|
if (entry.userId === myUserId && entry.status === 'error' && entry.blob) {
|
||||||
|
entry.status = 'pending';
|
||||||
|
entry.error = undefined;
|
||||||
|
await database.put(STORE_NAME, entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
queueItems.update((items) =>
|
||||||
|
items.map((item) =>
|
||||||
|
item.status === 'error' ? { ...item, status: 'pending' as const, progress: 0, error: undefined } : item
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function getDb(): Promise<IDBPDatabase> {
|
async function getDb(): Promise<IDBPDatabase> {
|
||||||
if (db) return db;
|
if (db) return db;
|
||||||
// v1 → v2: add `userId` index so each guest's queue is isolated on shared devices.
|
// v1 → v2: add `userId` index so each guest's queue is isolated on shared devices.
|
||||||
@@ -76,6 +121,26 @@ class RateLimitError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A permanent, non-retryable failure — the server returned a 4xx (other than 429) that
|
||||||
|
* will never succeed on retry: uploads locked, user banned, gallery already released, or
|
||||||
|
* per-user quota full. The item is moved to the terminal `blocked` state and its blob is
|
||||||
|
* dropped from IndexedDB (no point keeping bytes we'll never send).
|
||||||
|
*/
|
||||||
|
class TerminalError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A connectivity failure — the request never reached the server (offline, DNS, dropped
|
||||||
|
* connection). The item stays `pending` (not `error`) so the `online` listener and the
|
||||||
|
* next `processQueue` pick it up automatically. This is what makes "the queue flushes
|
||||||
|
* when you're back online" actually true for a file staged with no signal.
|
||||||
|
*/
|
||||||
|
class NetworkError extends Error {}
|
||||||
|
|
||||||
export async function loadQueue(): Promise<void> {
|
export async function loadQueue(): Promise<void> {
|
||||||
const database = await getDb();
|
const database = await getDb();
|
||||||
const myUserId = getUserId();
|
const myUserId = getUserId();
|
||||||
@@ -98,6 +163,14 @@ export async function loadQueue(): Promise<void> {
|
|||||||
error: entry.error
|
error: entry.error
|
||||||
}));
|
}));
|
||||||
queueItems.set(items);
|
queueItems.set(items);
|
||||||
|
// Staged-but-unsent items from a prior session (queued offline, tab closed before
|
||||||
|
// reconnect) must resume now — otherwise the "queue flushes when you're back online"
|
||||||
|
// promise only holds if the user manually re-stages a file. Reclaim transient errors
|
||||||
|
// (a network drop from a prior session) so they retry instead of stalling.
|
||||||
|
void (async () => {
|
||||||
|
await requeueRetriable();
|
||||||
|
await processQueue();
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addToQueue(
|
export async function addToQueue(
|
||||||
@@ -108,6 +181,33 @@ export async function addToQueue(
|
|||||||
const database = await getDb();
|
const database = await getDb();
|
||||||
const userId = getUserId();
|
const userId = getUserId();
|
||||||
if (!userId) return; // not authenticated — nothing to do
|
if (!userId) return; // not authenticated — nothing to do
|
||||||
|
|
||||||
|
// Dedup: don't queue the same file twice while an identical one is still unsent
|
||||||
|
// (double-tap, re-added after a flaky reconnect). Matches on name+size+user.
|
||||||
|
const mine = get(queueItems).filter((i) => i.userId === userId);
|
||||||
|
const dup = mine.some(
|
||||||
|
(i) =>
|
||||||
|
i.fileName === file.name &&
|
||||||
|
i.fileSize === file.size &&
|
||||||
|
(i.status === 'pending' || i.status === 'uploading')
|
||||||
|
);
|
||||||
|
if (dup) return;
|
||||||
|
|
||||||
|
// Cap the queue so stuck/blocked blobs can't grow IndexedDB without bound. When full,
|
||||||
|
// evict the oldest terminal item (done/error/blocked) to make room; if none exist,
|
||||||
|
// refuse silently rather than pile on.
|
||||||
|
if (mine.length >= MAX_QUEUE_ITEMS) {
|
||||||
|
const evictable = get(queueItems).find(
|
||||||
|
(i) => i.userId === userId && (i.status === 'done' || i.status === 'error' || i.status === 'blocked')
|
||||||
|
);
|
||||||
|
if (evictable) {
|
||||||
|
await database.delete(STORE_NAME, evictable.id);
|
||||||
|
queueItems.update((items) => items.filter((it) => it.id !== evictable.id));
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
const entry = {
|
const entry = {
|
||||||
id,
|
id,
|
||||||
@@ -184,6 +284,10 @@ async function processQueue(): Promise<void> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
while (true) {
|
while (true) {
|
||||||
|
// Offline: leave items 'pending' rather than burning through them into 'error'.
|
||||||
|
// The `online` listener re-enters here the moment connectivity returns.
|
||||||
|
if (typeof navigator !== 'undefined' && navigator.onLine === false) break;
|
||||||
|
|
||||||
const items = get(queueItems);
|
const items = get(queueItems);
|
||||||
const next = items.find((item) => item.status === 'pending');
|
const next = items.find((item) => item.status === 'pending');
|
||||||
if (!next) break;
|
if (!next) break;
|
||||||
@@ -201,7 +305,12 @@ async function processQueue(): Promise<void> {
|
|||||||
}, e.retryAfterSecs * 1000);
|
}, e.retryAfterSecs * 1000);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// Other errors are already handled inside uploadItem (marked as 'error')
|
if (e instanceof NetworkError) {
|
||||||
|
// Connectivity dropped mid-flight — the item is back to 'pending'. Stop the
|
||||||
|
// loop; the `online` listener resumes it. No error state, no manual tap.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Other errors are already handled inside uploadItem (marked 'error'/'blocked')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -258,6 +367,8 @@ async function uploadItem(id: string): Promise<void> {
|
|||||||
if (xhr.status >= 200 && xhr.status < 300) {
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
resolve();
|
resolve();
|
||||||
} else if (xhr.status === 429) {
|
} else if (xhr.status === 429) {
|
||||||
|
// Rate limit only (quota-full is now a distinct 413, handled below as
|
||||||
|
// terminal) — back off and auto-resume when the window lifts.
|
||||||
try {
|
try {
|
||||||
const body = JSON.parse(xhr.responseText);
|
const body = JSON.parse(xhr.responseText);
|
||||||
const secs = typeof body.retry_after_secs === 'number' ? body.retry_after_secs : 60;
|
const secs = typeof body.retry_after_secs === 'number' ? body.retry_after_secs : 60;
|
||||||
@@ -265,7 +376,20 @@ async function uploadItem(id: string): Promise<void> {
|
|||||||
} catch {
|
} catch {
|
||||||
reject(new RateLimitError(60));
|
reject(new RateLimitError(60));
|
||||||
}
|
}
|
||||||
|
} else if (xhr.status >= 400 && xhr.status < 500) {
|
||||||
|
// Any other 4xx is permanent for this blob (locked/banned/released/quota).
|
||||||
|
// Retrying just re-hits the same rejection forever, so mark it terminal.
|
||||||
|
let msg = 'Upload nicht möglich.';
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(xhr.responseText);
|
||||||
|
if (body.message) msg = body.message;
|
||||||
|
else if (xhr.status === 413) msg = 'Speicher-Limit erreicht.';
|
||||||
|
} catch {
|
||||||
|
if (xhr.status === 413) msg = 'Speicher-Limit erreicht.';
|
||||||
|
}
|
||||||
|
reject(new TerminalError(msg));
|
||||||
} else {
|
} else {
|
||||||
|
// 5xx / unexpected — transient, keep it retryable.
|
||||||
try {
|
try {
|
||||||
const body = JSON.parse(xhr.responseText);
|
const body = JSON.parse(xhr.responseText);
|
||||||
reject(new Error(body.message || `HTTP ${xhr.status}`));
|
reject(new Error(body.message || `HTTP ${xhr.status}`));
|
||||||
@@ -275,8 +399,8 @@ async function uploadItem(id: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
xhr.addEventListener('error', () => reject(new Error('Netzwerkfehler')));
|
xhr.addEventListener('error', () => reject(new NetworkError('Netzwerkfehler')));
|
||||||
xhr.addEventListener('abort', () => reject(new Error('Abgebrochen')));
|
xhr.addEventListener('abort', () => reject(new NetworkError('Abgebrochen')));
|
||||||
xhr.send(formData);
|
xhr.send(formData);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -296,6 +420,24 @@ async function uploadItem(id: string): Promise<void> {
|
|||||||
updateItemStatus(id, 'pending');
|
updateItemStatus(id, 'pending');
|
||||||
throw e; // Propagate to processQueue for scheduling
|
throw e; // Propagate to processQueue for scheduling
|
||||||
}
|
}
|
||||||
|
if (e instanceof NetworkError) {
|
||||||
|
// No connectivity — keep the item pending and propagate so processQueue stops
|
||||||
|
// the loop (no point hammering while offline). The `online` listener resumes it.
|
||||||
|
entry.status = 'pending';
|
||||||
|
await database.put(STORE_NAME, entry);
|
||||||
|
updateItemStatus(id, 'pending');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
if (e instanceof TerminalError) {
|
||||||
|
// Permanent rejection — drop the blob (we'll never resend it) and mark blocked
|
||||||
|
// so the UI shows a clear reason and offers no retry.
|
||||||
|
delete entry.blob;
|
||||||
|
entry.status = 'blocked';
|
||||||
|
entry.error = e.message;
|
||||||
|
await database.put(STORE_NAME, entry);
|
||||||
|
updateItemStatus(id, 'blocked', e.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const msg = e instanceof Error ? e.message : 'Upload fehlgeschlagen.';
|
const msg = e instanceof Error ? e.message : 'Upload fehlgeschlagen.';
|
||||||
entry.status = 'error';
|
entry.status = 'error';
|
||||||
entry.error = msg;
|
entry.error = msg;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
import { onSseEvent } from '$lib/sse';
|
import { onSseEvent } from '$lib/sse';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import type { MeContextDto } from '$lib/types';
|
import type { MeContextDto } from '$lib/types';
|
||||||
|
import { eventState, markClosed, markOpened } from '$lib/event-state-store';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
@@ -39,6 +40,10 @@
|
|||||||
try {
|
try {
|
||||||
const ctx = await api.get<MeContextDto>('/me/context');
|
const ctx = await api.get<MeContextDto>('/me/context');
|
||||||
privacyNote.set(ctx.privacy_note);
|
privacyNote.set(ctx.privacy_note);
|
||||||
|
eventState.set({
|
||||||
|
uploadsLocked: ctx.uploads_locked,
|
||||||
|
galleryReleased: ctx.gallery_released
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Cross-cutting hydration on boot — failure is non-fatal; users without
|
// Cross-cutting hydration on boot — failure is non-fatal; users without
|
||||||
// a session land on /join anyway, and the per-page mount will retry.
|
// a session land on /join anyway, and the per-page mount will retry.
|
||||||
@@ -61,7 +66,11 @@
|
|||||||
} catch {
|
} catch {
|
||||||
// Malformed payload — discard; nothing actionable for the user.
|
// Malformed payload — discard; nothing actionable for the user.
|
||||||
}
|
}
|
||||||
})
|
}),
|
||||||
|
// Reflect a host closing/reopening uploads live, so the composer switches to a
|
||||||
|
// locked state immediately instead of a guest finding out via a rejected upload.
|
||||||
|
onSseEvent('event-closed', () => markClosed()),
|
||||||
|
onSseEvent('event-opened', () => markOpened())
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
let expiry = $state<Date | null>(null);
|
let expiry = $state<Date | null>(null);
|
||||||
let pinCopied = $state(false);
|
let pinCopied = $state(false);
|
||||||
let leaveConfirmOpen = $state(false);
|
let leaveConfirmOpen = $state(false);
|
||||||
|
let leaveEverywhere = $state(false);
|
||||||
let dataModeWarningOpen = $state(false);
|
let dataModeWarningOpen = $state(false);
|
||||||
|
|
||||||
// `pin` is sourced from the shared `currentPin` store so a global pin-reset SSE
|
// `pin` is sourced from the shared `currentPin` store so a global pin-reset SSE
|
||||||
@@ -99,10 +100,14 @@
|
|||||||
setTimeout(() => (pinCopied = false), 2000);
|
setTimeout(() => (pinCopied = false), 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout(everywhere = false) {
|
||||||
// Session-delete is best-effort: the JWT is going away on this device either way,
|
// Session-delete is best-effort: the JWT is going away on this device either way,
|
||||||
// so a network failure shouldn't block the user from leaving the event.
|
// so a network failure shouldn't block the user from leaving the event.
|
||||||
try { await api.delete('/session'); } catch { /* best-effort logout */ }
|
// `everywhere` revokes ALL of this user's sessions (other phones/laptops recovered
|
||||||
|
// via PIN), not just this device.
|
||||||
|
try {
|
||||||
|
await api.delete(everywhere ? '/sessions' : '/session');
|
||||||
|
} catch { /* best-effort logout */ }
|
||||||
// Wipe the IndexedDB upload queue so a second guest using the same device can't
|
// Wipe the IndexedDB upload queue so a second guest using the same device can't
|
||||||
// inherit (or be blamed for) this guest's pending uploads. Not done on a 401
|
// inherit (or be blamed for) this guest's pending uploads. Not done on a 401
|
||||||
// auto-clear — that path preserves the queue in case the user re-authenticates.
|
// auto-clear — that path preserves the queue in case the user re-authenticates.
|
||||||
@@ -379,9 +384,9 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<!-- Leave / logout -->
|
<!-- Leave / logout (this device) -->
|
||||||
<button
|
<button
|
||||||
onclick={() => (leaveConfirmOpen = true)}
|
onclick={() => { leaveEverywhere = false; leaveConfirmOpen = true; }}
|
||||||
class="flex w-full items-center gap-3 border-t border-gray-100 px-5 py-4 text-left transition hover:bg-red-50 dark:border-gray-700 dark:hover:bg-red-950/30"
|
class="flex w-full items-center gap-3 border-t border-gray-100 px-5 py-4 text-left transition hover:bg-red-50 dark:border-gray-700 dark:hover:bg-red-950/30"
|
||||||
>
|
>
|
||||||
<svg class="h-5 w-5 text-red-500 dark:text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
<svg class="h-5 w-5 text-red-500 dark:text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||||
@@ -389,6 +394,17 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span class="flex-1 text-sm font-medium text-red-600 dark:text-red-400">Event verlassen</span>
|
<span class="flex-1 text-sm font-medium text-red-600 dark:text-red-400">Event verlassen</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- Sign out everywhere (all devices) -->
|
||||||
|
<button
|
||||||
|
onclick={() => { leaveEverywhere = true; leaveConfirmOpen = true; }}
|
||||||
|
class="flex w-full items-center gap-3 border-t border-gray-100 px-5 py-4 text-left transition hover:bg-red-50 dark:border-gray-700 dark:hover:bg-red-950/30"
|
||||||
|
>
|
||||||
|
<svg class="h-5 w-5 text-red-500 dark:text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
|
||||||
|
</svg>
|
||||||
|
<span class="flex-1 text-sm font-medium text-red-600 dark:text-red-400">Auf allen Geräten abmelden</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -435,10 +451,12 @@
|
|||||||
<!-- Leave-confirm bottom sheet -->
|
<!-- Leave-confirm bottom sheet -->
|
||||||
<ConfirmSheet
|
<ConfirmSheet
|
||||||
open={leaveConfirmOpen}
|
open={leaveConfirmOpen}
|
||||||
title="Event verlassen?"
|
title={leaveEverywhere ? 'Auf allen Geräten abmelden?' : 'Event verlassen?'}
|
||||||
message="Du wirst abgemeldet. Mit deinem PIN kannst du jederzeit zurückkehren."
|
message={leaveEverywhere
|
||||||
|
? 'Alle deine Sitzungen (auch auf anderen Geräten) werden beendet. Mit deinem PIN kannst du jederzeit zurückkehren.'
|
||||||
|
: 'Du wirst abgemeldet. Mit deinem PIN kannst du jederzeit zurückkehren.'}
|
||||||
confirmLabel="Abmelden"
|
confirmLabel="Abmelden"
|
||||||
tone="danger"
|
tone="danger"
|
||||||
onConfirm={handleLogout}
|
onConfirm={() => handleLogout(leaveEverywhere)}
|
||||||
onCancel={() => (leaveConfirmOpen = false)}
|
onCancel={() => (leaveConfirmOpen = false)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -127,7 +127,6 @@
|
|||||||
|
|
||||||
// Ban modal state
|
// Ban modal state
|
||||||
let banTarget = $state<UserSummary | null>(null);
|
let banTarget = $state<UserSummary | null>(null);
|
||||||
let banHideUploads = $state(false);
|
|
||||||
let banSubmitting = $state(false);
|
let banSubmitting = $state(false);
|
||||||
|
|
||||||
// PIN reset state — `pinModal` holds the freshly-issued plaintext PIN. We forget it
|
// PIN reset state — `pinModal` holds the freshly-issued plaintext PIN. We forget it
|
||||||
@@ -254,14 +253,13 @@
|
|||||||
|
|
||||||
function openBanModal(user: UserSummary) {
|
function openBanModal(user: UserSummary) {
|
||||||
banTarget = user;
|
banTarget = user;
|
||||||
banHideUploads = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirmBan() {
|
async function confirmBan() {
|
||||||
if (!banTarget) return;
|
if (!banTarget) return;
|
||||||
banSubmitting = true;
|
banSubmitting = true;
|
||||||
try {
|
try {
|
||||||
await api.post(`/host/users/${banTarget.id}/ban`, { hide_uploads: banHideUploads });
|
await api.post(`/host/users/${banTarget.id}/ban`, {});
|
||||||
toast(`${banTarget.display_name} wurde gesperrt.`, 'success');
|
toast(`${banTarget.display_name} wurde gesperrt.`, 'success');
|
||||||
banTarget = null;
|
banTarget = null;
|
||||||
users = await api.get<UserSummary[]>('/host/users');
|
users = await api.get<UserSummary[]>('/host/users');
|
||||||
@@ -416,17 +414,14 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<!-- Ban modal — checkbox-bearing, so uses the Modal shell instead of ConfirmSheet. -->
|
<!-- Ban modal — ban always hides now, so this is a plain confirm (no checkbox). -->
|
||||||
<Modal open={banTarget !== null} titleId="admin-ban-modal-title" onClose={() => (banTarget = null)}>
|
<Modal open={banTarget !== null} titleId="admin-ban-modal-title" onClose={() => (banTarget = null)}>
|
||||||
{#if banTarget}
|
{#if banTarget}
|
||||||
<h2 id="admin-ban-modal-title" class="mb-1 text-lg font-bold text-gray-900 dark:text-gray-100">Benutzer sperren</h2>
|
<h2 id="admin-ban-modal-title" class="mb-1 text-lg font-bold text-gray-900 dark:text-gray-100">Benutzer sperren</h2>
|
||||||
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
||||||
Was soll mit den Uploads von <strong>{banTarget.display_name}</strong> passieren?
|
<strong>{banTarget.display_name}</strong> wird gesperrt: alle Uploads verschwinden aus
|
||||||
|
Galerie, Diashow und Export, und die Sitzung wird beendet. Rückgängig machbar über „Entsperren“.
|
||||||
</p>
|
</p>
|
||||||
<label class="mb-4 flex cursor-pointer items-center gap-3 rounded-lg border border-gray-200 p-3 dark:border-gray-700">
|
|
||||||
<input type="checkbox" bind:checked={banHideUploads} class="h-4 w-4 rounded border-gray-300 text-red-600 focus:ring-red-500 dark:border-gray-600" />
|
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">Uploads aus der Galerie ausblenden</span>
|
|
||||||
</label>
|
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button onclick={() => (banTarget = null)} class="flex-1 rounded-lg border border-gray-300 py-2 text-sm text-gray-700 hover:bg-gray-50 active:bg-gray-100 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800 dark:active:bg-gray-800">Abbrechen</button>
|
<button onclick={() => (banTarget = null)} class="flex-1 rounded-lg border border-gray-300 py-2 text-sm text-gray-700 hover:bg-gray-50 active:bg-gray-100 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800 dark:active:bg-gray-800">Abbrechen</button>
|
||||||
<button onclick={confirmBan} disabled={banSubmitting} class="flex-1 rounded-lg bg-red-600 py-2 text-sm font-medium text-white hover:bg-red-700 active:bg-red-700 disabled:opacity-50 dark:bg-red-500 dark:hover:bg-red-400 dark:active:bg-red-400">
|
<button onclick={confirmBan} disabled={banSubmitting} class="flex-1 rounded-lg bg-red-600 py-2 text-sm font-medium text-white hover:bg-red-700 active:bg-red-700 disabled:opacity-50 dark:bg-red-500 dark:hover:bg-red-400 dark:active:bg-red-400">
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
import { SlideQueue } from '$lib/diashow/queue';
|
import { SlideQueue } from '$lib/diashow/queue';
|
||||||
import { transitions, findTransition } from '$lib/diashow/transitions';
|
import { transitions, findTransition } from '$lib/diashow/transitions';
|
||||||
import { acquireWakeLock, releaseWakeLock } from '$lib/diashow/wakelock';
|
import { acquireWakeLock, releaseWakeLock } from '$lib/diashow/wakelock';
|
||||||
import type { FeedUpload, FeedResponse } from '$lib/types';
|
import type { FeedUpload, FeedResponse, DeltaResponse } from '$lib/types';
|
||||||
|
|
||||||
const DWELL_OPTIONS = [3000, 6000, 10000];
|
const DWELL_OPTIONS = [3000, 6000, 10000];
|
||||||
|
|
||||||
@@ -100,6 +100,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// After an all-night projector reconnects (SSE drop, network blip), the live events
|
||||||
|
// it missed are gone. The SSE client fans out a `feed-delta` of everything since the
|
||||||
|
// last seen timestamp — merge it so the show backfills the gap instead of silently
|
||||||
|
// stalling on a stale set.
|
||||||
|
function handleFeedDelta(data: string) {
|
||||||
|
try {
|
||||||
|
const delta = JSON.parse(data) as DeltaResponse;
|
||||||
|
for (const id of delta.deleted_ids) {
|
||||||
|
const result = queue.remove(id, current?.id ?? null);
|
||||||
|
if (result.wasCurrent) advance();
|
||||||
|
}
|
||||||
|
for (const upload of delta.uploads) {
|
||||||
|
// Only enqueue displayable (processed) items; pushLive dedupes by id, so a
|
||||||
|
// later `upload-processed` still refines anything that arrives raw.
|
||||||
|
if (upload.preview_url || upload.thumbnail_url) queue.pushLive(upload);
|
||||||
|
}
|
||||||
|
if (!current) advance();
|
||||||
|
} catch {
|
||||||
|
// ignore — non-fatal; the next live event keeps the show moving
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleUserHidden(data: string) {
|
function handleUserHidden(data: string) {
|
||||||
try {
|
try {
|
||||||
const payload = JSON.parse(data) as { user_id: string };
|
const payload = JSON.parse(data) as { user_id: string };
|
||||||
@@ -164,6 +186,7 @@
|
|||||||
unsubs.push(onSseEvent('upload-processed', handleUploadProcessed));
|
unsubs.push(onSseEvent('upload-processed', handleUploadProcessed));
|
||||||
unsubs.push(onSseEvent('upload-deleted', handleUploadDeleted));
|
unsubs.push(onSseEvent('upload-deleted', handleUploadDeleted));
|
||||||
unsubs.push(onSseEvent('user-hidden', handleUserHidden));
|
unsubs.push(onSseEvent('user-hidden', handleUserHidden));
|
||||||
|
unsubs.push(onSseEvent('feed-delta', handleFeedDelta));
|
||||||
void loadInitial();
|
void loadInitial();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -389,18 +389,24 @@
|
|||||||
|
|
||||||
async function handleLike(id: string) {
|
async function handleLike(id: string) {
|
||||||
try {
|
try {
|
||||||
await api.post(`/upload/${id}/like`);
|
// Set state from the server's authoritative response rather than blind-inverting
|
||||||
|
// local state. On a second device (same recovered user), the `like-update`
|
||||||
|
// broadcast only carries `like_count` — so a blind invert would drift
|
||||||
|
// `liked_by_me` until refresh. The response gives both, exactly.
|
||||||
|
const res = await api.post<{ liked: boolean; like_count: number | null }>(`/upload/${id}/like`);
|
||||||
vibrate(10);
|
vibrate(10);
|
||||||
|
// like_count is null when the server's count query hiccuped — keep the current
|
||||||
|
// count in that case rather than adopting a wrong number.
|
||||||
uploads = uploads.map((u) =>
|
uploads = uploads.map((u) =>
|
||||||
u.id === id
|
u.id === id
|
||||||
? { ...u, liked_by_me: !u.liked_by_me, like_count: u.liked_by_me ? u.like_count - 1 : u.like_count + 1 }
|
? { ...u, liked_by_me: res.liked, like_count: res.like_count ?? u.like_count }
|
||||||
: u
|
: u
|
||||||
);
|
);
|
||||||
if (selectedUpload?.id === id) {
|
if (selectedUpload?.id === id) {
|
||||||
selectedUpload = {
|
selectedUpload = {
|
||||||
...selectedUpload,
|
...selectedUpload,
|
||||||
liked_by_me: !selectedUpload.liked_by_me,
|
liked_by_me: res.liked,
|
||||||
like_count: selectedUpload.liked_by_me ? selectedUpload.like_count - 1 : selectedUpload.like_count + 1,
|
like_count: res.like_count ?? selectedUpload.like_count,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -27,8 +27,16 @@
|
|||||||
export_released: boolean;
|
export_released: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PinResetRequest {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
display_name: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
let event = $state<EventStatus | null>(null);
|
let event = $state<EventStatus | null>(null);
|
||||||
let users = $state<UserSummary[]>([]);
|
let users = $state<UserSummary[]>([]);
|
||||||
|
let pinResetRequests = $state<PinResetRequest[]>([]);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
|
|
||||||
@@ -47,7 +55,6 @@
|
|||||||
|
|
||||||
// Ban modal state
|
// Ban modal state
|
||||||
let banTarget = $state<UserSummary | null>(null);
|
let banTarget = $state<UserSummary | null>(null);
|
||||||
let banHideUploads = $state(false);
|
|
||||||
let banSubmitting = $state(false);
|
let banSubmitting = $state(false);
|
||||||
|
|
||||||
// PIN reset modal state. `pinModal` holds the freshly-issued plaintext PIN; it is
|
// PIN reset modal state. `pinModal` holds the freshly-issued plaintext PIN; it is
|
||||||
@@ -114,9 +121,10 @@
|
|||||||
loading = true;
|
loading = true;
|
||||||
error = null;
|
error = null;
|
||||||
try {
|
try {
|
||||||
[event, users] = await Promise.all([
|
[event, users, pinResetRequests] = await Promise.all([
|
||||||
api.get<EventStatus>('/host/event'),
|
api.get<EventStatus>('/host/event'),
|
||||||
api.get<UserSummary[]>('/host/users')
|
api.get<UserSummary[]>('/host/users'),
|
||||||
|
api.get<PinResetRequest[]>('/host/pin-reset-requests')
|
||||||
]);
|
]);
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
error = e instanceof Error ? e.message : 'Fehler beim Laden.';
|
error = e instanceof Error ? e.message : 'Fehler beim Laden.';
|
||||||
@@ -125,6 +133,25 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resetPinForRequest(req: PinResetRequest) {
|
||||||
|
try {
|
||||||
|
const res = await api.post<{ pin: string }>(`/host/users/${req.user_id}/pin-reset`);
|
||||||
|
pinModal = { name: req.display_name, pin: res.pin };
|
||||||
|
await reload();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
toastError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dismissPinRequest(req: PinResetRequest) {
|
||||||
|
try {
|
||||||
|
await api.delete(`/host/pin-reset-requests/${req.id}`);
|
||||||
|
pinResetRequests = pinResetRequests.filter((r) => r.id !== req.id);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
toastError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function toggleEventLock() {
|
async function toggleEventLock() {
|
||||||
if (!event) return;
|
if (!event) return;
|
||||||
try {
|
try {
|
||||||
@@ -153,14 +180,13 @@
|
|||||||
|
|
||||||
function openBanModal(user: UserSummary) {
|
function openBanModal(user: UserSummary) {
|
||||||
banTarget = user;
|
banTarget = user;
|
||||||
banHideUploads = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirmBan() {
|
async function confirmBan() {
|
||||||
if (!banTarget) return;
|
if (!banTarget) return;
|
||||||
banSubmitting = true;
|
banSubmitting = true;
|
||||||
try {
|
try {
|
||||||
await api.post(`/host/users/${banTarget.id}/ban`, { hide_uploads: banHideUploads });
|
await api.post(`/host/users/${banTarget.id}/ban`, {});
|
||||||
toast(`${banTarget.display_name} wurde gesperrt.`, 'success');
|
toast(`${banTarget.display_name} wurde gesperrt.`, 'success');
|
||||||
banTarget = null;
|
banTarget = null;
|
||||||
await reload();
|
await reload();
|
||||||
@@ -278,21 +304,14 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<!-- Ban modal — needs a checkbox so it's not a pure ConfirmSheet, but still gets the same a11y shell. -->
|
<!-- Ban modal — ban always hides now, so this is a plain confirm (no checkbox). -->
|
||||||
<Modal open={banTarget !== null} titleId="host-ban-modal-title" onClose={() => (banTarget = null)}>
|
<Modal open={banTarget !== null} titleId="host-ban-modal-title" onClose={() => (banTarget = null)}>
|
||||||
{#if banTarget}
|
{#if banTarget}
|
||||||
<h2 id="host-ban-modal-title" class="mb-1 text-lg font-bold text-gray-900 dark:text-gray-100">Benutzer sperren</h2>
|
<h2 id="host-ban-modal-title" class="mb-1 text-lg font-bold text-gray-900 dark:text-gray-100">Benutzer sperren</h2>
|
||||||
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
||||||
Was soll mit den Uploads von <strong>{banTarget.display_name}</strong> passieren?
|
<strong>{banTarget.display_name}</strong> wird gesperrt: alle Uploads verschwinden aus
|
||||||
|
Galerie, Diashow und Export, und die Sitzung wird beendet. Rückgängig machbar über „Entsperren“.
|
||||||
</p>
|
</p>
|
||||||
<label class="mb-4 flex cursor-pointer items-center gap-3 rounded-lg border border-gray-200 p-3 dark:border-gray-700">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
bind:checked={banHideUploads}
|
|
||||||
class="h-4 w-4 rounded border-gray-300 text-red-600 focus:ring-red-500 dark:border-gray-600"
|
|
||||||
/>
|
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">Uploads aus der Galerie ausblenden</span>
|
|
||||||
</label>
|
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onclick={() => (banTarget = null)}
|
onclick={() => (banTarget = null)}
|
||||||
@@ -336,6 +355,41 @@
|
|||||||
<div class="rounded-lg bg-red-50 p-4 text-sm text-red-700 dark:bg-red-950/30 dark:text-red-300">{error}</div>
|
<div class="rounded-lg bg-red-50 p-4 text-sm text-red-700 dark:bg-red-950/30 dark:text-red-300">{error}</div>
|
||||||
{:else if event}
|
{:else if event}
|
||||||
|
|
||||||
|
<!-- ── PIN-Reset-Anfragen ──────────────────────────────────────── -->
|
||||||
|
{#if pinResetRequests.length > 0}
|
||||||
|
<div class="overflow-hidden rounded-xl border border-amber-300 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/30">
|
||||||
|
<div class="border-b border-amber-200 px-5 py-3 dark:border-amber-900">
|
||||||
|
<h2 class="font-semibold text-amber-900 dark:text-amber-200">
|
||||||
|
PIN vergessen — {pinResetRequests.length} Anfrage{pinResetRequests.length === 1 ? '' : 'n'}
|
||||||
|
</h2>
|
||||||
|
<p class="mt-0.5 text-xs text-amber-700 dark:text-amber-400">
|
||||||
|
Prüfe die Identität, bevor du eine PIN zurücksetzt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ul class="divide-y divide-amber-200 dark:divide-amber-900">
|
||||||
|
{#each pinResetRequests as req (req.id)}
|
||||||
|
<li class="flex items-center justify-between gap-3 px-5 py-3">
|
||||||
|
<span class="min-w-0 truncate font-medium text-amber-900 dark:text-amber-100">{req.display_name}</span>
|
||||||
|
<div class="flex shrink-0 gap-2">
|
||||||
|
<button
|
||||||
|
onclick={() => dismissPinRequest(req)}
|
||||||
|
class="rounded-lg border border-amber-300 px-3 py-1.5 text-sm text-amber-800 hover:bg-amber-100 dark:border-amber-700 dark:text-amber-200 dark:hover:bg-amber-900/40"
|
||||||
|
>
|
||||||
|
Ablehnen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => resetPinForRequest(req)}
|
||||||
|
class="rounded-lg bg-amber-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-amber-700 dark:bg-amber-500 dark:hover:bg-amber-400"
|
||||||
|
>
|
||||||
|
PIN zurücksetzen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- ── Statistiken ─────────────────────────────────────────────── -->
|
<!-- ── Statistiken ─────────────────────────────────────────────── -->
|
||||||
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
|
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -29,6 +29,9 @@
|
|||||||
let recoveryPin = $state('');
|
let recoveryPin = $state('');
|
||||||
let recoveryError = $state('');
|
let recoveryError = $state('');
|
||||||
let recoveryLoading = $state(false);
|
let recoveryLoading = $state(false);
|
||||||
|
// Forgot-PIN request state (asks a host to reset it).
|
||||||
|
let pinRequestSent = $state(false);
|
||||||
|
let pinRequestLoading = $state(false);
|
||||||
|
|
||||||
async function handleJoin() {
|
async function handleJoin() {
|
||||||
if (!displayName.trim()) return;
|
if (!displayName.trim()) return;
|
||||||
@@ -85,9 +88,24 @@
|
|||||||
nameTaken = false;
|
nameTaken = false;
|
||||||
recoveryPin = '';
|
recoveryPin = '';
|
||||||
recoveryError = '';
|
recoveryError = '';
|
||||||
|
pinRequestSent = false;
|
||||||
// Keep displayName so the user can edit it slightly
|
// Keep displayName so the user can edit it slightly
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Forgot the PIN entirely: ask a host to reset it. The endpoint always 204s (no name
|
||||||
|
// enumeration), so we optimistically show a confirmation regardless.
|
||||||
|
async function requestPinReset() {
|
||||||
|
pinRequestLoading = true;
|
||||||
|
try {
|
||||||
|
await api.post('/recover/request', { display_name: takenName });
|
||||||
|
} catch {
|
||||||
|
// Non-fatal (rate limit etc.) — still show the confirmation so the user isn't stuck.
|
||||||
|
} finally {
|
||||||
|
pinRequestLoading = false;
|
||||||
|
pinRequestSent = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function copyPin() {
|
function copyPin() {
|
||||||
navigator.clipboard.writeText(pin);
|
navigator.clipboard.writeText(pin);
|
||||||
copied = true;
|
copied = true;
|
||||||
@@ -173,6 +191,23 @@
|
|||||||
Anderen Namen wählen
|
Anderen Namen wählen
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- Forgot the PIN entirely — ask a host to reset it in-app. -->
|
||||||
|
{#if pinRequestSent}
|
||||||
|
<p class="mt-3 rounded-lg bg-green-50 px-4 py-3 text-center text-sm text-green-700 dark:bg-green-950/30 dark:text-green-300">
|
||||||
|
Anfrage gesendet. Bitte einen Host, deine PIN zurückzusetzen — danach kannst du dich
|
||||||
|
mit der neuen PIN anmelden.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
onclick={requestPinReset}
|
||||||
|
disabled={pinRequestLoading}
|
||||||
|
data-testid="request-pin-reset"
|
||||||
|
class="mt-3 w-full text-center text-sm text-blue-600 underline decoration-dotted underline-offset-2 hover:text-blue-700 disabled:opacity-50 dark:text-blue-400"
|
||||||
|
>
|
||||||
|
{pinRequestLoading ? 'Wird gesendet…' : 'PIN vergessen? Host um Zurücksetzen bitten'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Normal join form -->
|
<!-- Normal join form -->
|
||||||
<h1 class="mb-2 text-center text-2xl font-bold text-gray-900 dark:text-gray-100">Willkommen!</h1>
|
<h1 class="mb-2 text-center text-2xl font-bold text-gray-900 dark:text-gray-100">Willkommen!</h1>
|
||||||
|
|||||||
Reference in New Issue
Block a user