fix(review-2): high — read-only ban model, failed-compression cleanup, live SSE eviction
H1: corrected a round-1 over-reach. Bans are read-only per USER_JOURNEYS §10:
the base AuthUser extractor no longer rejects banned users (they keep read
access); Require{Host,Admin} and write handlers enforce the ban via
auth.is_banned → 403 (incl. self-unban). Demoted hosts still lose powers
immediately (live DB role) and are bounced off the dashboard. Also fixed the
login/recover redirect regression on unauthenticated 401.
H2: failed compression now self-heals instead of leaving a permanent broken
card — refund the quota, delete the orphaned original, soft-delete the row;
the frontend toasts the uploader and evicts the card on upload-error.
H3: self-delete, ban-with-hide (user-hidden), and comment-deletes now broadcast
SSE; feed, diashow, and lightbox evict the affected content live instead of
waiting for a reload. sse-eviction.spec.ts covers it.
Riders: feed/+page.svelte also carries the medium truncated-delta pill; host.rs
also carries the low atomic release_gallery claim; admin/+page.svelte also drops
the dead compression_concurrency control (medium).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,10 @@ pub struct AuthUser {
|
|||||||
pub user_id: Uuid,
|
pub user_id: Uuid,
|
||||||
pub event_id: Uuid,
|
pub event_id: Uuid,
|
||||||
pub role: UserRole,
|
pub role: UserRole,
|
||||||
|
/// Live ban flag. Banned users keep *read* access (per USER_JOURNEYS §10), so
|
||||||
|
/// the base extractor does NOT reject them — write handlers and the
|
||||||
|
/// Require{Host,Admin} extractors enforce the ban instead.
|
||||||
|
pub is_banned: bool,
|
||||||
pub token_hash: String,
|
pub token_hash: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,15 +49,13 @@ impl FromRequestParts<AppState> for AuthUser {
|
|||||||
|
|
||||||
// Trust *live* DB state, not the JWT: a role/ban stored in the token would
|
// Trust *live* DB state, not the JWT: a role/ban stored in the token would
|
||||||
// survive a demote/ban for the full session lifetime (up to 30d). Re-read
|
// survive a demote/ban for the full session lifetime (up to 30d). Re-read
|
||||||
// the user row so a demoted host loses host powers and a banned user is
|
// the user row so a demoted host loses host powers immediately. We do NOT
|
||||||
// locked out immediately on their next request.
|
// reject banned users here — they retain read access by design; writes and
|
||||||
|
// host/admin actions enforce the ban downstream.
|
||||||
let user = crate::models::user::User::find_by_id(&state.pool, claims.sub)
|
let user = crate::models::user::User::find_by_id(&state.pool, claims.sub)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::Internal(e.into()))?
|
.map_err(|e| AppError::Internal(e.into()))?
|
||||||
.ok_or_else(|| AppError::Unauthorized("Benutzer nicht gefunden.".into()))?;
|
.ok_or_else(|| AppError::Unauthorized("Benutzer nicht gefunden.".into()))?;
|
||||||
if user.is_banned {
|
|
||||||
return Err(AppError::Forbidden("Dein Zugang wurde gesperrt.".into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update last_seen_at in the background (fire-and-forget). Failures are
|
// Update last_seen_at in the background (fire-and-forget). Failures are
|
||||||
// non-fatal but worth surfacing — silent swallowing hides DB connection
|
// non-fatal but worth surfacing — silent swallowing hides DB connection
|
||||||
@@ -70,6 +72,7 @@ impl FromRequestParts<AppState> for AuthUser {
|
|||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
event_id: user.event_id,
|
event_id: user.event_id,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
|
is_banned: user.is_banned,
|
||||||
token_hash,
|
token_hash,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -86,6 +89,9 @@ impl FromRequestParts<AppState> for RequireHost {
|
|||||||
state: &AppState,
|
state: &AppState,
|
||||||
) -> Result<Self, Self::Rejection> {
|
) -> Result<Self, Self::Rejection> {
|
||||||
let auth = AuthUser::from_request_parts(parts, state).await?;
|
let auth = AuthUser::from_request_parts(parts, state).await?;
|
||||||
|
if auth.is_banned {
|
||||||
|
return Err(AppError::Forbidden("Du bist gesperrt.".into()));
|
||||||
|
}
|
||||||
match auth.role {
|
match auth.role {
|
||||||
UserRole::Host | UserRole::Admin => Ok(Self(auth)),
|
UserRole::Host | UserRole::Admin => Ok(Self(auth)),
|
||||||
_ => Err(AppError::Forbidden("Nur für Hosts und Admins.".into())),
|
_ => Err(AppError::Forbidden("Nur für Hosts und Admins.".into())),
|
||||||
@@ -104,6 +110,9 @@ impl FromRequestParts<AppState> for RequireAdmin {
|
|||||||
state: &AppState,
|
state: &AppState,
|
||||||
) -> Result<Self, Self::Rejection> {
|
) -> Result<Self, Self::Rejection> {
|
||||||
let auth = AuthUser::from_request_parts(parts, state).await?;
|
let auth = AuthUser::from_request_parts(parts, state).await?;
|
||||||
|
if auth.is_banned {
|
||||||
|
return Err(AppError::Forbidden("Du bist gesperrt.".into()));
|
||||||
|
}
|
||||||
match auth.role {
|
match auth.role {
|
||||||
UserRole::Admin => Ok(Self(auth)),
|
UserRole::Admin => Ok(Self(auth)),
|
||||||
_ => Err(AppError::Forbidden("Nur für Admins.".into())),
|
_ => Err(AppError::Forbidden("Nur für Admins.".into())),
|
||||||
|
|||||||
@@ -121,6 +121,15 @@ pub async fn ban_user(
|
|||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
// If we hid their uploads, evict them live from every feed + the diashow so a
|
||||||
|
// banned guest's content disappears without each viewer having to reload.
|
||||||
|
if body.hide_uploads {
|
||||||
|
let _ = state.sse_tx.send(SseEvent::new(
|
||||||
|
"user-hidden",
|
||||||
|
serde_json::json!({ "user_id": user_id }).to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
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,
|
||||||
@@ -329,6 +338,10 @@ pub async fn host_delete_comment(
|
|||||||
if !deleted {
|
if !deleted {
|
||||||
return Err(AppError::NotFound("Kommentar nicht gefunden.".into()));
|
return Err(AppError::NotFound("Kommentar nicht gefunden.".into()));
|
||||||
}
|
}
|
||||||
|
let _ = state.sse_tx.send(SseEvent::new(
|
||||||
|
"comment-deleted",
|
||||||
|
serde_json::json!({ "comment_id": comment_id }).to_string(),
|
||||||
|
));
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
actor_user_id = %auth.user_id,
|
actor_user_id = %auth.user_id,
|
||||||
event_id = %auth.event_id,
|
event_id = %auth.event_id,
|
||||||
@@ -380,19 +393,33 @@ pub async fn release_gallery(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
RequireHost(_auth): RequireHost,
|
RequireHost(_auth): RequireHost,
|
||||||
) -> Result<StatusCode, AppError> {
|
) -> Result<StatusCode, AppError> {
|
||||||
|
// Atomic claim: the conditional UPDATE is the sole gate, so two concurrent
|
||||||
|
// release calls can't both pass a check-then-set and double-enqueue exports.
|
||||||
|
// rows_affected == 0 means someone already released.
|
||||||
|
let result = sqlx::query(
|
||||||
|
"UPDATE event SET export_released_at = NOW()
|
||||||
|
WHERE slug = $1 AND export_released_at IS NULL",
|
||||||
|
)
|
||||||
|
.bind(&state.config.event_slug)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await?;
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
// Distinguish "no such event" from "already released" for a clean error.
|
||||||
|
let exists = Event::find_by_slug(&state.pool, &state.config.event_slug)
|
||||||
|
.await?
|
||||||
|
.is_some();
|
||||||
|
return Err(if exists {
|
||||||
|
AppError::BadRequest("Galerie wurde bereits freigegeben.".into())
|
||||||
|
} else {
|
||||||
|
AppError::NotFound("Event nicht gefunden.".into())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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()))?;
|
||||||
|
|
||||||
if event.export_released_at.is_some() {
|
|
||||||
return Err(AppError::BadRequest("Galerie wurde bereits freigegeben.".into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
sqlx::query("UPDATE event SET export_released_at = NOW() WHERE slug = $1")
|
|
||||||
.bind(&state.config.event_slug)
|
|
||||||
.execute(&state.pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// Enqueue export jobs
|
// Enqueue export jobs
|
||||||
for export_type in ["zip", "html"] {
|
for export_type in ["zip", "html"] {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
@@ -411,6 +438,7 @@ pub async fn release_gallery(
|
|||||||
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.sse_tx.clone(),
|
state.sse_tx.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -213,5 +213,9 @@ pub async fn delete_comment(
|
|||||||
if !deleted {
|
if !deleted {
|
||||||
return Err(AppError::NotFound("Kommentar nicht gefunden.".into()));
|
return Err(AppError::NotFound("Kommentar nicht gefunden.".into()));
|
||||||
}
|
}
|
||||||
|
let _ = state.sse_tx.send(crate::state::SseEvent::new(
|
||||||
|
"comment-deleted",
|
||||||
|
serde_json::json!({ "comment_id": comment_id, "upload_id": comment.upload_id }).to_string(),
|
||||||
|
));
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -313,6 +313,14 @@ pub async fn delete_upload(
|
|||||||
|
|
||||||
Upload::soft_delete_in_event(&state.pool, upload_id, auth.event_id).await?;
|
Upload::soft_delete_in_event(&state.pool, upload_id, auth.event_id).await?;
|
||||||
|
|
||||||
|
// Evict the card live on every other feed + the projector diashow — otherwise
|
||||||
|
// a self-deleted post lingers until each viewer manually reloads. Same event
|
||||||
|
// the host-delete path already emits and the frontend already handles.
|
||||||
|
let _ = state.sse_tx.send(crate::state::SseEvent::new(
|
||||||
|
"upload-deleted",
|
||||||
|
serde_json::json!({ "upload_id": upload_id }).to_string(),
|
||||||
|
));
|
||||||
|
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,11 +42,28 @@ impl CompressionWorker {
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("compression failed for upload {upload_id}: {e:#}");
|
tracing::error!("compression failed for upload {upload_id}: {e:#}");
|
||||||
|
// Auto-cleanup: a failed transcode would otherwise leave a
|
||||||
|
// permanently broken feed card, silently charge the uploader's
|
||||||
|
// quota, and orphan the original on disk. Refund + soft-delete
|
||||||
|
// (one tx, so v_feed excludes it), remove the orphan file, then
|
||||||
|
// tell the uploader (upload-error toast) and evict the card
|
||||||
|
// everywhere (upload-deleted, already handled by the feed).
|
||||||
|
let _ = Upload::set_compression_status(&worker.pool, upload_id, "failed").await;
|
||||||
|
if let Err(del) = Upload::soft_delete(&worker.pool, upload_id).await {
|
||||||
|
tracing::warn!(error = ?del, %upload_id, "failed to soft-delete after compression failure");
|
||||||
|
}
|
||||||
|
let orphan = worker.media_path.join(&original_path);
|
||||||
|
if let Err(rm) = tokio::fs::remove_file(&orphan).await {
|
||||||
|
tracing::warn!(error = ?rm, path = %orphan.display(), "failed to remove orphaned original");
|
||||||
|
}
|
||||||
let _ = worker.sse_tx.send(SseEvent {
|
let _ = worker.sse_tx.send(SseEvent {
|
||||||
event_type: "upload-error".to_string(),
|
event_type: "upload-error".to_string(),
|
||||||
data: serde_json::json!({ "upload_id": upload_id, "error": e.to_string() }).to_string(),
|
data: serde_json::json!({ "upload_id": upload_id, "error": e.to_string() }).to_string(),
|
||||||
});
|
});
|
||||||
let _ = Upload::set_compression_status(&worker.pool, upload_id, "failed").await;
|
let _ = worker.sse_tx.send(SseEvent {
|
||||||
|
event_type: "upload-deleted".to_string(),
|
||||||
|
data: serde_json::json!({ "upload_id": upload_id }).to_string(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -97,4 +97,27 @@ test.describe('Host — live role/ban revocation (H1)', () => {
|
|||||||
api.unbanUser(host.jwt, host.userId, { expectedStatus: [204] })
|
api.unbanUser(host.jwt, host.userId, { expectedStatus: [204] })
|
||||||
).rejects.toThrow(/→ 403/);
|
).rejects.toThrow(/→ 403/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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},
|
||||||
|
// 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 }) => {
|
||||||
|
const base = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||||
|
const target = await guest('BannedRW');
|
||||||
|
await api.banUser(host.jwt, target.userId, false);
|
||||||
|
|
||||||
|
// Reads still succeed.
|
||||||
|
const read = await fetch(base + '/api/v1/me/context', {
|
||||||
|
headers: { Authorization: `Bearer ${target.jwt}` },
|
||||||
|
});
|
||||||
|
expect(read.status).toBe(200);
|
||||||
|
|
||||||
|
// Writes are rejected.
|
||||||
|
const write = await fetch(base + '/api/v1/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${target.jwt}` },
|
||||||
|
body: new FormData(),
|
||||||
|
});
|
||||||
|
expect(write.status).toBe(403);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
51
e2e/specs/04-host/sse-eviction.spec.ts
Normal file
51
e2e/specs/04-host/sse-eviction.spec.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* Regression for the review's H3: self-deleting an upload and banning a user with
|
||||||
|
* hide_uploads mutated visibility server-side but broadcast nothing, so the
|
||||||
|
* content lingered on every other viewer's feed (and the projector diashow) until
|
||||||
|
* a manual reload. Both now emit SSE so clients evict live.
|
||||||
|
*/
|
||||||
|
import { test, expect } from '../../fixtures/test';
|
||||||
|
import { seedUpload } from '../../helpers/seed';
|
||||||
|
import { SseListener } from '../../helpers/sse-listener';
|
||||||
|
|
||||||
|
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||||
|
|
||||||
|
test.describe('Host — live SSE eviction (H3)', () => {
|
||||||
|
test('self-deleting an upload broadcasts upload-deleted', async ({ guest }) => {
|
||||||
|
const g = await guest('SelfDeleter');
|
||||||
|
const uploadId = await seedUpload(g.jwt, { caption: 'delete me' });
|
||||||
|
|
||||||
|
const sse = new SseListener();
|
||||||
|
await sse.start(g.jwt);
|
||||||
|
|
||||||
|
const res = await fetch(`${BASE}/api/v1/upload/${uploadId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { Authorization: `Bearer ${g.jwt}` },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(204);
|
||||||
|
|
||||||
|
await sse.waitForEvent(
|
||||||
|
'upload-deleted',
|
||||||
|
(e) => e.data.upload_id === uploadId
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('banning a user with hide_uploads broadcasts user-hidden', async ({
|
||||||
|
api,
|
||||||
|
host,
|
||||||
|
guest,
|
||||||
|
}) => {
|
||||||
|
const target = await guest('HideTarget');
|
||||||
|
await seedUpload(target.jwt, { caption: 'should vanish' });
|
||||||
|
|
||||||
|
const sse = new SseListener();
|
||||||
|
await sse.start(host.jwt);
|
||||||
|
|
||||||
|
await api.banUser(host.jwt, target.userId, true);
|
||||||
|
|
||||||
|
await sse.waitForEvent(
|
||||||
|
'user-hidden',
|
||||||
|
(e) => e.data.user_id === target.userId
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -68,6 +68,9 @@ async function request<T>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
// An expired/invalid token (401) clears the dead session. Banned users are
|
||||||
|
// NOT logged out — they keep read access by design (USER_JOURNEYS §10) and
|
||||||
|
// simply get a 403 "gesperrt" toast on writes.
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
clearAuth();
|
clearAuth();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,23 @@ export class SlideQueue {
|
|||||||
return { wasCurrent: currentId === id };
|
return { wasCurrent: currentId === id };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove every slide belonging to a user (banned with hide_uploads). Returns
|
||||||
|
* true if the current head was one of them so the caller advances immediately.
|
||||||
|
*/
|
||||||
|
removeByUser(userId: string, currentId: string | null): { wasCurrent: boolean } {
|
||||||
|
let wasCurrent = false;
|
||||||
|
for (const [id, slide] of this.allKnown) {
|
||||||
|
if (slide.user_id === userId) {
|
||||||
|
if (id === currentId) wasCurrent = true;
|
||||||
|
this.allKnown.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.liveQueue = this.liveQueue.filter((s) => s.user_id !== userId);
|
||||||
|
this.shuffleQueue = this.shuffleQueue.filter((s) => s.user_id !== userId);
|
||||||
|
return { wasCurrent };
|
||||||
|
}
|
||||||
|
|
||||||
/** Look up a slide by id — for the diashow page to render the current slide. */
|
/** Look up a slide by id — for the diashow page to render the current slide. */
|
||||||
get(id: string): FeedUpload | undefined {
|
get(id: string): FeedUpload | undefined {
|
||||||
return this.allKnown.get(id);
|
return this.allKnown.get(id);
|
||||||
|
|||||||
@@ -57,8 +57,9 @@
|
|||||||
title: 'Limits & Größen',
|
title: 'Limits & Größen',
|
||||||
fields: [
|
fields: [
|
||||||
{ key: 'max_image_size_mb', label: 'Max. Bildgröße (MB)', kind: 'number' },
|
{ key: 'max_image_size_mb', label: 'Max. Bildgröße (MB)', kind: 'number' },
|
||||||
{ key: 'max_video_size_mb', label: 'Max. Videogröße (MB)', kind: 'number' },
|
{ key: 'max_video_size_mb', label: 'Max. Videogröße (MB)', kind: 'number' }
|
||||||
{ key: 'compression_concurrency', label: 'Kompressions-Worker', kind: 'number' }
|
// compression_concurrency is set via COMPRESSION_WORKER_CONCURRENCY at
|
||||||
|
// boot, not live — omitted so it isn't a dead no-op control.
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -99,6 +99,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleUserHidden(data: string) {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(data) as { user_id: string };
|
||||||
|
const result = queue.removeByUser(payload.user_id, current?.id ?? null);
|
||||||
|
if (result.wasCurrent) advance();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function revealOverlay() {
|
function revealOverlay() {
|
||||||
showOverlay = true;
|
showOverlay = true;
|
||||||
if (overlayHideTimer) clearTimeout(overlayHideTimer);
|
if (overlayHideTimer) clearTimeout(overlayHideTimer);
|
||||||
@@ -145,6 +155,7 @@
|
|||||||
void acquireWakeLock();
|
void acquireWakeLock();
|
||||||
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));
|
||||||
void loadInitial();
|
void loadInitial();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,9 @@
|
|||||||
let refreshing = $state(false);
|
let refreshing = $state(false);
|
||||||
let pullProgress = $state(0); // 0–1+ during the drag, 0 when idle
|
let pullProgress = $state(0); // 0–1+ during the drag, 0 when idle
|
||||||
let selectedUpload = $state<FeedUpload | null>(null);
|
let selectedUpload = $state<FeedUpload | null>(null);
|
||||||
|
// Set when a truncated feed-delta means we missed too much to merge — shows a
|
||||||
|
// tap-to-refresh pill instead of yanking the user's scroll to page 1.
|
||||||
|
let feedStale = $state(false);
|
||||||
let sentinel: HTMLDivElement;
|
let sentinel: HTMLDivElement;
|
||||||
let feedObserver: IntersectionObserver | null = null;
|
let feedObserver: IntersectionObserver | null = null;
|
||||||
let inPlaceRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
let inPlaceRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
@@ -196,6 +199,28 @@
|
|||||||
if (selectedUpload?.id === payload.upload_id) selectedUpload = null;
|
if (selectedUpload?.id === payload.upload_id) selectedUpload = null;
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}),
|
}),
|
||||||
|
// A background transcode failed: the backend already cleaned up (refunded
|
||||||
|
// quota, removed the row) and an upload-deleted evicts the card. Only the
|
||||||
|
// uploader gets a toast — registered before upload-deleted so the card is
|
||||||
|
// still present to check ownership.
|
||||||
|
onSseEvent('upload-error', (data) => {
|
||||||
|
try {
|
||||||
|
const { upload_id } = JSON.parse(data) as { upload_id: string };
|
||||||
|
const mine = uploads.find((u) => u.id === upload_id && u.user_id === myUserId);
|
||||||
|
if (mine) {
|
||||||
|
toast('Ein Upload konnte nicht verarbeitet werden.', 'error');
|
||||||
|
void refreshQuota();
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}),
|
||||||
|
// A banned user's uploads were hidden — drop all their cards live.
|
||||||
|
onSseEvent('user-hidden', (data) => {
|
||||||
|
try {
|
||||||
|
const { user_id } = JSON.parse(data) as { user_id: string };
|
||||||
|
uploads = uploads.filter((u) => u.user_id !== user_id);
|
||||||
|
if (selectedUpload && selectedUpload.user_id === user_id) selectedUpload = null;
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}),
|
||||||
// Patch the single affected card in place from the SSE payload instead of
|
// Patch the single affected card in place from the SSE payload instead of
|
||||||
// refetching page 1 — a busy event fires these constantly and a full reload
|
// refetching page 1 — a busy event fires these constantly and a full reload
|
||||||
// would yank every scrolled-down user back to the top on each reaction.
|
// would yank every scrolled-down user back to the top on each reaction.
|
||||||
@@ -210,7 +235,9 @@
|
|||||||
// Missed more than the backend delta cap while backgrounded — the
|
// Missed more than the backend delta cap while backgrounded — the
|
||||||
// delta is only the newest slice, so merging would leave a silent gap
|
// delta is only the newest slice, so merging would leave a silent gap
|
||||||
// of older-but-still-new uploads. Resync from page 1 instead.
|
// of older-but-still-new uploads. Resync from page 1 instead.
|
||||||
void loadFeed(true);
|
// Rather than involuntarily yank the user to page 1, surface a
|
||||||
|
// tap-to-refresh pill so they keep scroll until they resync.
|
||||||
|
feedStale = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (delta.uploads.length) {
|
if (delta.uploads.length) {
|
||||||
@@ -440,6 +467,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if feedStale}
|
||||||
|
<div class="pointer-events-none fixed left-0 right-0 top-[calc(env(safe-area-inset-top)+0.5rem)] z-40 flex justify-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pointer-events-auto rounded-full bg-blue-600 px-4 py-1.5 text-xs font-semibold text-white shadow-lg hover:bg-blue-700"
|
||||||
|
onclick={() => { feedStale = false; void loadFeed(true); }}
|
||||||
|
>
|
||||||
|
Neue Beiträge – tippen zum Aktualisieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<!-- Sticky header — opaque fallback for browsers without backdrop-filter. -->
|
<!-- Sticky header — opaque fallback for browsers without backdrop-filter. -->
|
||||||
<div class="sticky top-0 z-30 border-b border-gray-200 bg-white/95 pt-[env(safe-area-inset-top)] backdrop-blur supports-[not(backdrop-filter:blur(0))]:bg-white dark:border-gray-800 dark:bg-gray-900/95 dark:supports-[not(backdrop-filter:blur(0))]:bg-gray-900">
|
<div class="sticky top-0 z-30 border-b border-gray-200 bg-white/95 pt-[env(safe-area-inset-top)] backdrop-blur supports-[not(backdrop-filter:blur(0))]:bg-white dark:border-gray-800 dark:bg-gray-900/95 dark:supports-[not(backdrop-filter:blur(0))]:bg-gray-900">
|
||||||
<div class="mx-auto flex max-w-2xl items-center justify-between px-4 py-3">
|
<div class="mx-auto flex max-w-2xl items-center justify-between px-4 py-3">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { getToken, getRole } from '$lib/auth';
|
import { getToken, getRole } from '$lib/auth';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
|
import type { MeContextDto } from '$lib/types';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { toast, toastError } from '$lib/toast-store';
|
import { toast, toastError } from '$lib/toast-store';
|
||||||
import ConfirmSheet from '$lib/components/ConfirmSheet.svelte';
|
import ConfirmSheet from '$lib/components/ConfirmSheet.svelte';
|
||||||
@@ -86,8 +87,22 @@
|
|||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
const role = getRole();
|
if (!token) {
|
||||||
if (!token || (role !== 'host' && role !== 'admin')) {
|
goto('/join');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Trust the *live* role, not the JWT claim: a mid-session demote leaves a
|
||||||
|
// stale 'host' in the token, but the backend now 403s every host call. Fetch
|
||||||
|
// the current role and bounce a demoted host to the feed instead of leaving
|
||||||
|
// them on a dashboard that errors on load.
|
||||||
|
try {
|
||||||
|
const ctx = await api.get<MeContextDto>('/me/context');
|
||||||
|
if (ctx.role !== 'host' && ctx.role !== 'admin') {
|
||||||
|
goto('/feed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Expired/invalid session (api.ts cleared it) — send them to re-auth.
|
||||||
goto('/join');
|
goto('/join');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user