Merge branch 'fix/audit-2026-07-07'
This commit is contained in:
@@ -238,6 +238,10 @@ pub struct AdminLoginRequest {
|
|||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct AdminLoginResponse {
|
pub struct AdminLoginResponse {
|
||||||
pub jwt: String,
|
pub jwt: String,
|
||||||
|
/// The admin's user id + display name, so the client can populate a real identity
|
||||||
|
/// (own-post affordances, a name on the Account page) instead of a blank session.
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub display_name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn admin_login(
|
pub async fn admin_login(
|
||||||
@@ -325,7 +329,11 @@ pub async fn admin_login(
|
|||||||
let expires_at = Utc::now() + chrono::Duration::days(1);
|
let expires_at = Utc::now() + chrono::Duration::days(1);
|
||||||
Session::create(&state.pool, admin_user.id, &token_hash, expires_at).await?;
|
Session::create(&state.pool, admin_user.id, &token_hash, expires_at).await?;
|
||||||
|
|
||||||
Ok(Json(AdminLoginResponse { jwt: token }))
|
Ok(Json(AdminLoginResponse {
|
||||||
|
jwt: token,
|
||||||
|
user_id: admin_user.id,
|
||||||
|
display_name: admin_user.display_name,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn logout(
|
pub async fn logout(
|
||||||
|
|||||||
@@ -266,6 +266,10 @@ pub async fn export_ticket(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
auth: crate::auth::middleware::AuthUser,
|
auth: crate::auth::middleware::AuthUser,
|
||||||
) -> Json<serde_json::Value> {
|
) -> Json<serde_json::Value> {
|
||||||
|
// NOTE: intentionally NOT gated on `is_banned`. A banned user keeps *read* access
|
||||||
|
// by design (USER_JOURNEYS §10.3, FEATURES: "Can still download the export once
|
||||||
|
// released — Spec design choice"). The export is read-only, so it stays available
|
||||||
|
// to them, consistent with the read-only-ban model.
|
||||||
let ticket = state.sse_tickets.issue(auth.token_hash);
|
let ticket = state.sse_tickets.issue(auth.token_hash);
|
||||||
Json(serde_json::json!({ "ticket": ticket }))
|
Json(serde_json::json!({ "ticket": ticket }))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,6 +146,26 @@ pub async fn unban_user(
|
|||||||
RequireHost(auth): RequireHost,
|
RequireHost(auth): RequireHost,
|
||||||
Path(user_id): Path<Uuid>,
|
Path(user_id): Path<Uuid>,
|
||||||
) -> Result<StatusCode, AppError> {
|
) -> Result<StatusCode, AppError> {
|
||||||
|
// Mirror the ban guard: a host may only lift bans on guests, never on hosts or
|
||||||
|
// admins. Without this a host could override an admin's ban of another host,
|
||||||
|
// which is asymmetric with `ban_user` and lets a host escalate a peer back in.
|
||||||
|
let target = sqlx::query_as::<_, (String,)>(
|
||||||
|
"SELECT role::text FROM \"user\" WHERE id = $1 AND event_id = $2",
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(auth.event_id)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
|
||||||
|
|
||||||
|
if target.0 == "admin"
|
||||||
|
|| (target.0 == "host" && auth.role != crate::models::user::UserRole::Admin)
|
||||||
|
{
|
||||||
|
return Err(AppError::Forbidden(
|
||||||
|
"Du kannst diesen Benutzer nicht entsperren.".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
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 WHERE id = $1 AND event_id = $2",
|
||||||
)
|
)
|
||||||
@@ -275,10 +295,11 @@ pub async fn reset_user_pin(
|
|||||||
SET recovery_pin_hash = $1,
|
SET recovery_pin_hash = $1,
|
||||||
failed_pin_attempts = 0,
|
failed_pin_attempts = 0,
|
||||||
pin_locked_until = NULL
|
pin_locked_until = NULL
|
||||||
WHERE id = $2",
|
WHERE id = $2 AND event_id = $3",
|
||||||
)
|
)
|
||||||
.bind(&pin_hash)
|
.bind(&pin_hash)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
|
.bind(auth.event_id)
|
||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ pub mod admin;
|
|||||||
pub mod feed;
|
pub mod feed;
|
||||||
pub mod host;
|
pub mod host;
|
||||||
pub mod me;
|
pub mod me;
|
||||||
|
pub mod public;
|
||||||
pub mod social;
|
pub mod social;
|
||||||
pub mod sse;
|
pub mod sse;
|
||||||
pub mod test_admin;
|
pub mod test_admin;
|
||||||
|
|||||||
24
backend/src/handlers/public.rs
Normal file
24
backend/src/handlers/public.rs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
//! Unauthenticated, read-only endpoints safe to expose before a user has joined.
|
||||||
|
|
||||||
|
use axum::extract::State;
|
||||||
|
use axum::Json;
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct PublicEventDto {
|
||||||
|
pub name: String,
|
||||||
|
pub slug: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Public event identity, used by the pre-auth join/recover screens so a guest can
|
||||||
|
/// see *which* event they're joining. Only the display name and slug are exposed —
|
||||||
|
/// nothing user-scoped — so this is safe without a token. Served straight from the
|
||||||
|
/// instance config (no DB round-trip needed).
|
||||||
|
pub async fn get_public_event(State(state): State<AppState>) -> Json<PublicEventDto> {
|
||||||
|
Json(PublicEventDto {
|
||||||
|
name: state.config.event_name.clone(),
|
||||||
|
slug: state.config.event_slug.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -12,18 +12,6 @@ use crate::models::hashtag::{self, Hashtag};
|
|||||||
use crate::models::upload::Upload;
|
use crate::models::upload::Upload;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
/// Reject the request when the event's uploads (and, by extension, social
|
|
||||||
/// interaction) are locked. Mirrors the guard in the upload handler.
|
|
||||||
async fn require_event_open(state: &AppState) -> Result<(), AppError> {
|
|
||||||
let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
|
|
||||||
if event.uploads_locked_at.is_some() {
|
|
||||||
return Err(AppError::Forbidden("Das Event ist geschlossen.".into()));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn toggle_like(
|
pub async fn toggle_like(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
@@ -43,8 +31,9 @@ pub async fn toggle_like(
|
|||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
|
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
|
||||||
|
|
||||||
// A closed event freezes social interaction too, matching the upload handler.
|
// NOTE: liking is intentionally allowed while the event is locked. Locking
|
||||||
require_event_open(&state).await?;
|
// ("Event schließen") freezes *new uploads* only — likes, comments and
|
||||||
|
// browsing stay open (USER_JOURNEYS §9.3, FEATURES capability matrix).
|
||||||
|
|
||||||
// Try to insert; if conflict, delete (toggle)
|
// Try to insert; if conflict, delete (toggle)
|
||||||
let result = sqlx::query(
|
let result = sqlx::query(
|
||||||
@@ -135,8 +124,9 @@ pub async fn add_comment(
|
|||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
|
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
|
||||||
|
|
||||||
// A closed event freezes social interaction too, matching the upload handler.
|
// NOTE: commenting is intentionally allowed while the event is locked. Locking
|
||||||
require_event_open(&state).await?;
|
// freezes *new uploads* only — likes, comments and browsing stay open
|
||||||
|
// (USER_JOURNEYS §9.3, FEATURES capability matrix).
|
||||||
|
|
||||||
let text = body.body.trim();
|
let text = body.body.trim();
|
||||||
let text_chars = text.chars().count();
|
let text_chars = text.chars().count();
|
||||||
|
|||||||
@@ -90,14 +90,21 @@ pub async fn upload(
|
|||||||
let name = field.name().unwrap_or_default().to_string();
|
let name = field.name().unwrap_or_default().to_string();
|
||||||
match name.as_str() {
|
match name.as_str() {
|
||||||
"file" => {
|
"file" => {
|
||||||
// Note: the client-declared filename and Content-Type are intentionally
|
// Note: the client-declared filename and Content-Type do NOT determine
|
||||||
// ignored — the stored MIME and extension are derived from the file's
|
// the stored MIME/extension — those come from the file's magic bytes
|
||||||
// magic bytes below, so a mislabelled payload can't influence them.
|
// below. The declared type is used only to pick a memory cap so an
|
||||||
file_data = Some(
|
// oversized body can't be fully buffered before the size check. A
|
||||||
field.bytes().await
|
// mislabelled type only makes the cap *stricter* (safe); the
|
||||||
.map_err(|e| AppError::BadRequest(format!("Datei konnte nicht gelesen werden: {e}")))?
|
// authoritative per-class check still runs on the detected type.
|
||||||
.to_vec(),
|
let declared = field.content_type().unwrap_or("").to_string();
|
||||||
);
|
let cap_bytes = if declared.starts_with("video/") {
|
||||||
|
(max_video_mb * 1024 * 1024) as usize
|
||||||
|
} else if declared.starts_with("image/") {
|
||||||
|
(max_image_mb * 1024 * 1024) as usize
|
||||||
|
} else {
|
||||||
|
(max_image_mb.max(max_video_mb) * 1024 * 1024) as usize
|
||||||
|
};
|
||||||
|
file_data = Some(read_field_capped(field, cap_bytes).await?);
|
||||||
}
|
}
|
||||||
"caption" => {
|
"caption" => {
|
||||||
caption = Some(
|
caption = Some(
|
||||||
@@ -332,6 +339,32 @@ pub async fn delete_upload(
|
|||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read a multipart field into memory, aborting with a 400 the moment it exceeds
|
||||||
|
/// `max_bytes`. Without this the whole field is buffered (up to the HTTP body cap)
|
||||||
|
/// before the post-read size check runs, so a request claiming to be a tiny image
|
||||||
|
/// could still force hundreds of MB of allocation. Streaming with an early abort
|
||||||
|
/// bounds peak memory to roughly the applicable per-class limit.
|
||||||
|
async fn read_field_capped(
|
||||||
|
mut field: axum::extract::multipart::Field<'_>,
|
||||||
|
max_bytes: usize,
|
||||||
|
) -> Result<Vec<u8>, AppError> {
|
||||||
|
let mut buf: Vec<u8> = Vec::new();
|
||||||
|
while let Some(chunk) = field
|
||||||
|
.chunk()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::BadRequest(format!("Datei konnte nicht gelesen werden: {e}")))?
|
||||||
|
{
|
||||||
|
if buf.len().saturating_add(chunk.len()) > max_bytes {
|
||||||
|
return Err(AppError::BadRequest(format!(
|
||||||
|
"Datei ist zu groß. Maximum: {} MB.",
|
||||||
|
max_bytes / (1024 * 1024)
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
buf.extend_from_slice(&chunk);
|
||||||
|
}
|
||||||
|
Ok(buf)
|
||||||
|
}
|
||||||
|
|
||||||
/// Drain a multipart body so the HTTP connection stays clean when returning an early error.
|
/// Drain a multipart body so the HTTP connection stays clean when returning an early error.
|
||||||
/// Without draining, the client may still be sending the body after we've sent our response,
|
/// Without draining, the client may still be sending the body after we've sent our response,
|
||||||
/// which can corrupt the keep-alive connection for subsequent requests.
|
/// which can corrupt the keep-alive connection for subsequent requests.
|
||||||
@@ -407,16 +440,17 @@ pub async fn compute_storage_quota(state: &AppState) -> QuotaEstimate {
|
|||||||
/// - `<img src>` / `<video src>` in the feed, lightbox, and diashow when the user is in
|
/// - `<img src>` / `<video src>` in the feed, lightbox, and diashow when the user is in
|
||||||
/// Data Mode = Original
|
/// Data Mode = Original
|
||||||
///
|
///
|
||||||
/// **Auth model:** the route is intentionally unauthenticated, matching how the rest of
|
/// **Auth model:** the route is intentionally unauthenticated so it works from
|
||||||
/// `/media/*` is served (preview + thumbnail variants). The URL contains the upload's
|
/// `<img src>` / `window.open`, matching how preview + thumbnail variants are served.
|
||||||
/// UUID, which is unguessable — same security posture as `/media/originals/{slug}/{id}`.
|
/// The URL contains the upload's unguessable UUID. Unlike raw `/media` files, this
|
||||||
/// Adding `Authorization: Bearer` here would make the endpoint unusable from `<img src>`
|
/// alias is the *only* way to fetch an original: direct `/media/originals/**` access is
|
||||||
/// and `window.open`, defeating the purpose of having the alias.
|
/// blocked in the router, and this handler filters out soft-deleted and ban-hidden
|
||||||
|
/// uploads (via `find_by_id_visible`) so moderation actually removes access to content.
|
||||||
pub async fn get_original(
|
pub async fn get_original(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(upload_id): Path<Uuid>,
|
Path(upload_id): Path<Uuid>,
|
||||||
) -> Result<axum::response::Response, AppError> {
|
) -> Result<axum::response::Response, AppError> {
|
||||||
let upload = Upload::find_by_id(&state.pool, upload_id)
|
let upload = Upload::find_by_id_visible(&state.pool, upload_id)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
|
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ async fn main() -> Result<()> {
|
|||||||
|
|
||||||
let api = Router::new()
|
let api = Router::new()
|
||||||
// Auth
|
// Auth
|
||||||
|
.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))
|
||||||
.route("/api/v1/admin/login", post(auth::handlers::admin_login))
|
.route("/api/v1/admin/login", post(auth::handlers::admin_login))
|
||||||
@@ -144,6 +145,16 @@ async fn main() -> Result<()> {
|
|||||||
let router = Router::new()
|
let router = Router::new()
|
||||||
.route("/health", get(|| async { "ok" }))
|
.route("/health", get(|| async { "ok" }))
|
||||||
.merge(api)
|
.merge(api)
|
||||||
|
// Block direct HTTP access to originals. They live under `media_path` (so the
|
||||||
|
// compression worker and export can read them off disk) but must NOT be pullable
|
||||||
|
// straight from `/media/originals/**` — that would bypass the visibility checks in
|
||||||
|
// `get_original` (soft-delete + ban-hide). Every legitimate original fetch goes
|
||||||
|
// through `/api/v1/upload/{id}/original`; previews/thumbnails under `/media` stay
|
||||||
|
// public. The more specific nest takes precedence over `/media` below.
|
||||||
|
.nest_service(
|
||||||
|
"/media/originals",
|
||||||
|
get(|| async { axum::http::StatusCode::NOT_FOUND }),
|
||||||
|
)
|
||||||
.nest_service("/media", media_service)
|
.nest_service("/media", media_service)
|
||||||
.layer(TraceLayer::new_for_http())
|
.layer(TraceLayer::new_for_http())
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
|
|||||||
@@ -74,6 +74,22 @@ impl Upload {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Like [`Self::find_by_id`] but also excludes uploads whose owner has been
|
||||||
|
/// ban-hidden (`user.uploads_hidden`). Used by the public original-file alias
|
||||||
|
/// so that moderation which hides a user's content from the feed/export also
|
||||||
|
/// stops their full-resolution originals from being pulled by UUID. Mirrors the
|
||||||
|
/// `uploads_hidden` filter that `v_feed` already applies to the feed.
|
||||||
|
pub async fn find_by_id_visible(pool: &PgPool, id: Uuid) -> Result<Option<Self>, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, Self>(
|
||||||
|
"SELECT up.* FROM upload up
|
||||||
|
JOIN \"user\" u ON u.id = up.user_id
|
||||||
|
WHERE up.id = $1 AND up.deleted_at IS NULL AND u.uploads_hidden = false",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
/// Event-scoped lookup used by host endpoints so a host of event A cannot
|
/// Event-scoped lookup used by host endpoints so a host of event A cannot
|
||||||
/// reach uploads belonging to event B.
|
/// reach uploads belonging to event B.
|
||||||
pub async fn find_by_id_and_event(
|
pub async fn find_by_id_and_event(
|
||||||
|
|||||||
@@ -38,15 +38,14 @@ test.describe('Upload — gallery path', () => {
|
|||||||
await expect.poll(() => db.countUploadsForUser(h.userId), { timeout: 10_000 }).toBe(2);
|
await expect.poll(() => db.countUploadsForUser(h.userId), { timeout: 10_000 }).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test.fixme('UI flow: FAB → UploadSheet → /upload → submit drives a real XHR upload', async ({ page, guest, signIn, db }) => {
|
test('UI flow: FAB → UploadSheet → /upload → submit drives a real XHR upload', async ({ page, guest, signIn, db }) => {
|
||||||
// The full UI flow (BottomNav FAB → UploadSheet → /upload page → handleSubmit →
|
// Previously fixme'd: the UI queue never fired a POST. Root cause was NOT a
|
||||||
// upload-queue.ts XHR) does not currently complete within the test window in
|
// navigation/blob timing quirk but an IndexedDB upgrade bug — the v1→v2
|
||||||
// Playwright. The XHR doesn't appear in backend logs. Suspected cause: the
|
// `upgrade` callback opened a *new* transaction, which throws during a
|
||||||
// queue worker fires after the page navigates from /upload to /feed via
|
// version-change transaction and aborted the whole upgrade, so the `queue`
|
||||||
// SvelteKit's goto(), but the blob/IDB chain may not survive the unmount/
|
// object store was never created and the worker could never persist an item.
|
||||||
// remount cycle in Playwright's headless Chromium. Needs deeper
|
// Fixed in upload-queue.ts by reusing the callback's version-change
|
||||||
// investigation; tracked as a fixme for now. API-driven tests above cover
|
// transaction. This test guards against regressing that.
|
||||||
// the data contract.
|
|
||||||
const h = await guest('UploaderUI');
|
const h = await guest('UploaderUI');
|
||||||
await signIn(page, h);
|
await signIn(page, h);
|
||||||
const feed = new FeedPage(page);
|
const feed = new FeedPage(page);
|
||||||
|
|||||||
@@ -34,10 +34,11 @@ test.describe('Host — event lock', () => {
|
|||||||
// and flip fixme to test once it lands.
|
// and flip fixme to test once it lands.
|
||||||
});
|
});
|
||||||
|
|
||||||
// Regression for the review: likes/comments used to ignore uploads_locked_at,
|
// Locking is uploads-only: likes, comments and browsing stay open on a closed
|
||||||
// so social writes still landed on a closed event. They now share the upload
|
// event (USER_JOURNEYS §9.3, FEATURES capability matrix). Only new uploads are
|
||||||
// handler's lock guard.
|
// rejected. (An earlier revision froze social interaction too; that contradicted
|
||||||
test('a closed event rejects likes and comments', async ({ api, host, guest }) => {
|
// the documented behavior and was reverted.)
|
||||||
|
test('a closed event still allows likes and comments, but blocks new uploads', async ({ api, host, guest }) => {
|
||||||
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
|
||||||
const g = await guest('SocialLocked');
|
const g = await guest('SocialLocked');
|
||||||
|
|
||||||
@@ -55,17 +56,26 @@ test.describe('Host — event lock', () => {
|
|||||||
|
|
||||||
await api.closeEvent(host.jwt);
|
await api.closeEvent(host.jwt);
|
||||||
|
|
||||||
|
// Likes stay open on a locked event.
|
||||||
const likeRes = await fetch(`${BASE}/api/v1/upload/${id}/like`, {
|
const likeRes = await fetch(`${BASE}/api/v1/upload/${id}/like`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${g.jwt}` },
|
headers: { Authorization: `Bearer ${g.jwt}` },
|
||||||
});
|
});
|
||||||
expect(likeRes.status).toBe(403);
|
expect(likeRes.status).toBe(204);
|
||||||
|
|
||||||
|
// 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`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${g.jwt}`, 'Content-Type': 'application/json' },
|
headers: { Authorization: `Bearer ${g.jwt}`, 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ body: 'sollte blockiert sein' }),
|
body: JSON.stringify({ body: 'darf durchgehen' }),
|
||||||
});
|
});
|
||||||
expect(commentRes.status).toBe(403);
|
expect(commentRes.status).toBe(201);
|
||||||
|
|
||||||
|
// New uploads, however, are rejected while locked.
|
||||||
|
const blockedUpload = await uploadRaw(g.jwt, readFileSync(sample), {
|
||||||
|
filename: 'y.jpg',
|
||||||
|
contentType: 'image/jpeg',
|
||||||
|
});
|
||||||
|
expect(blockedUpload.status).toBe(403);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,16 +33,23 @@ async function getDb(): Promise<IDBPDatabase> {
|
|||||||
// 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.
|
||||||
// Pre-existing entries (no userId) are dropped on upgrade; nothing useful was ever
|
// Pre-existing entries (no userId) are dropped on upgrade; nothing useful was ever
|
||||||
// persisted across logouts before this version.
|
// persisted across logouts before this version.
|
||||||
db = await openDB(DB_NAME, 2, {
|
// Version 3 self-heals installs corrupted by a shipped v1→v2 bug: that upgrade
|
||||||
upgrade(database, oldVersion) {
|
// opened a *new* transaction inside the callback, which throws InvalidStateError
|
||||||
if (oldVersion < 1) {
|
// ("A version change transaction is running") and aborts the whole upgrade —
|
||||||
|
// leaving some browsers at version 2 with NO 'queue' object store (so every queue
|
||||||
|
// write failed and no upload ever fired). Bumping to 3 re-runs this upgrade for
|
||||||
|
// those installs; the contains() guard recreates the missing store instead of
|
||||||
|
// assuming createObjectStore only ever runs on a brand-new DB.
|
||||||
|
db = await openDB(DB_NAME, 3, {
|
||||||
|
upgrade(database, oldVersion, _newVersion, transaction) {
|
||||||
|
if (!database.objectStoreNames.contains(STORE_NAME)) {
|
||||||
database.createObjectStore(STORE_NAME, { keyPath: 'id' });
|
database.createObjectStore(STORE_NAME, { keyPath: 'id' });
|
||||||
}
|
} else if (oldVersion < 2) {
|
||||||
if (oldVersion < 2) {
|
// Existing v1 store: its entries predate the `userId` field, so drop them
|
||||||
// Wipe any pre-v2 entries — they have no userId field and would belong
|
// rather than misattribute them to whoever is signed in now. Reuse the
|
||||||
// to a now-indeterminate user. Safer to drop than to misattribute.
|
// active version-change transaction (never open a new one here — see above).
|
||||||
const tx = database.transaction(STORE_NAME, 'readwrite');
|
// Skipped when we just created the store, which is already empty.
|
||||||
tx.objectStore(STORE_NAME).clear();
|
transaction.objectStore(STORE_NAME).clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,9 +18,14 @@
|
|||||||
loading = true;
|
loading = true;
|
||||||
error = '';
|
error = '';
|
||||||
try {
|
try {
|
||||||
const res = await api.post<{ jwt: string }>('/admin/login', { password });
|
const res = await api.post<{ jwt: string; user_id: string; display_name: string }>(
|
||||||
// Admin sessions have no PIN; pass null so setAuth doesn't overwrite a guest PIN
|
'/admin/login',
|
||||||
setAuth(res.jwt, null, '');
|
{ password }
|
||||||
|
);
|
||||||
|
// Admin sessions have no PIN; pass null so setAuth doesn't overwrite a guest PIN.
|
||||||
|
// Persist the real user id + name so the admin has an identity (own-post
|
||||||
|
// affordances on the feed, a name on the Account page rather than "Unbekannt").
|
||||||
|
setAuth(res.jwt, null, res.user_id, res.display_name);
|
||||||
goto('/admin');
|
goto('/admin');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof ApiError) {
|
if (e instanceof ApiError) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
|
import { getToken } from '$lib/auth';
|
||||||
import { showBottomNav } from '$lib/ui-store';
|
import { showBottomNav } from '$lib/ui-store';
|
||||||
import { dataMode, pickMediaUrl } from '$lib/data-mode-store';
|
import { dataMode, pickMediaUrl } from '$lib/data-mode-store';
|
||||||
import { onSseEvent } from '$lib/sse';
|
import { onSseEvent } from '$lib/sse';
|
||||||
@@ -151,6 +152,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
// Auth guard — mirror the other protected routes. Without this an
|
||||||
|
// unauthenticated visitor lands on a permanently-loading empty slideshow
|
||||||
|
// instead of being sent to /join.
|
||||||
|
if (!getToken()) {
|
||||||
|
goto('/join');
|
||||||
|
return;
|
||||||
|
}
|
||||||
showBottomNav.set(false);
|
showBottomNav.set(false);
|
||||||
void acquireWakeLock();
|
void acquireWakeLock();
|
||||||
unsubs.push(onSseEvent('upload-processed', handleUploadProcessed));
|
unsubs.push(onSseEvent('upload-processed', handleUploadProcessed));
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { getToken, getRole } from '$lib/auth';
|
import { getToken, getRole, getUserId } from '$lib/auth';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import type { MeContextDto } from '$lib/types';
|
import type { MeContextDto } from '$lib/types';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
@@ -57,6 +57,7 @@
|
|||||||
let pinModal = $state<{ name: string; pin: string } | null>(null);
|
let pinModal = $state<{ name: string; pin: string } | null>(null);
|
||||||
|
|
||||||
const myRole = getRole();
|
const myRole = getRole();
|
||||||
|
const myUserId = getUserId();
|
||||||
|
|
||||||
// Generic confirm-then-run for the irreversible / privilege-changing actions
|
// Generic confirm-then-run for the irreversible / privilege-changing actions
|
||||||
// (promote, demote, unban, release gallery) that previously fired on one tap.
|
// (promote, demote, unban, release gallery) that previously fired on one tap.
|
||||||
@@ -486,7 +487,11 @@
|
|||||||
>
|
>
|
||||||
Entsperren
|
Entsperren
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
{:else if user.id !== myUserId}
|
||||||
|
<!-- Never render target-actions (promote/demote/PIN/ban) on the
|
||||||
|
caller's own row: the backend rejects every self-action
|
||||||
|
(self-ban / self-demote / self-PIN) with a 400, so the button
|
||||||
|
would only ever fail. -->
|
||||||
{#if user.role === 'guest' && (myRole === 'host' || myRole === 'admin')}
|
{#if user.role === 'guest' && (myRole === 'host' || myRole === 'admin')}
|
||||||
<button
|
<button
|
||||||
onclick={() => (confirmAction = {
|
onclick={() => (confirmAction = {
|
||||||
|
|||||||
@@ -1,9 +1,21 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { api, ApiError } from '$lib/api';
|
import { api, ApiError } from '$lib/api';
|
||||||
import { setAuth } from '$lib/auth';
|
import { setAuth } from '$lib/auth';
|
||||||
import { focusTrap } from '$lib/actions/focus-trap';
|
import { focusTrap } from '$lib/actions/focus-trap';
|
||||||
|
|
||||||
|
// Show which event the guest is joining (USER_JOURNEYS §1). Public, pre-auth.
|
||||||
|
let eventName = $state('');
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
const ev = await api.get<{ name: string; slug: string }>('/event');
|
||||||
|
eventName = ev.name;
|
||||||
|
} catch {
|
||||||
|
// Non-fatal — fall back to the generic heading if the lookup fails.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let displayName = $state('');
|
let displayName = $state('');
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
@@ -164,6 +176,9 @@
|
|||||||
{: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>
|
||||||
|
{#if eventName}
|
||||||
|
<p class="mb-1 text-center text-lg font-semibold text-blue-600 dark:text-blue-400" data-testid="join-event-name">{eventName}</p>
|
||||||
|
{/if}
|
||||||
<p class="mb-6 text-center text-gray-600 dark:text-gray-400">Gib deinen Namen ein, um dem Event beizutreten.</p>
|
<p class="mb-6 text-center text-gray-600 dark:text-gray-400">Gib deinen Namen ein, um dem Event beizutreten.</p>
|
||||||
|
|
||||||
<form onsubmit={(e) => { e.preventDefault(); handleJoin(); }}>
|
<form onsubmit={(e) => { e.preventDefault(); handleJoin(); }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user