15 Commits

Author SHA1 Message Date
MechaCat02
77d55e941c harden(deploy): add app/frontend healthchecks + readiness-gate Caddy
Caddy depended on app/frontend with no readiness gate, so it could proxy to a
not-yet-listening upstream and surface brief 502s on boot/restart. Add
wget-based healthchecks (busybox wget ships in both alpine runtime images):
app hits the unauthenticated /health, frontend hits / (SSR returns 200; the
redirect is client-side). app uses a 30s start_period to cover boot-time
migrations. Caddy now depends_on both with condition: service_healthy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 17:38:51 +02:00
MechaCat02
a895bebdc2 harden(deploy): close public DB exposure + make JWT prod guard effective
Two HIGH deployment fixes found in the go-live review, plus two cheap backstops.

HIGH 1 — Postgres was published to the public internet. The committed,
auto-merged docker-compose.override.yml mapped 5432:5432 on 0.0.0.0 (and a
Docker publish bypasses ufw), so a `docker compose up` on the prod host exposed
the DB with the .env password. Bind the dev port to 127.0.0.1 so it's reachable
only from the host's loopback even when the override lands in prod. .env.example
also stops shipping `secret` as the DB password (now CHANGE_ME_*).

HIGH 2 — the JWT-secret production guard was inert. APP_ENV was never set to
production (defaulted to development), so the guard never ran; and it only
matched one stale dev sentinel, not the `.env.example` placeholder that an
operator is most likely to leave in place. Now: docker-compose.yml forces
APP_ENV=production on the app service, and config.rs refuses to boot in
production on any placeholder (change/example/placeholder/replace) or any secret
< 64 chars. Verified: placeholder and short secrets are rejected; a 128-char
secret passes. This turns "looks protected" into actually protected.

Backstops:
- frontend container now runs as the non-root `node` user (the backend was
  already hardened; the frontend wasn't).
- app gets mem_limit 3g so a miss in the in-app upload RAM caps OOM-kills the
  container instead of the 8 GB host.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 17:35:57 +02:00
MechaCat02
2d7169e971 harden: low-severity bucket — unspoofable IP, admin floor, log/SSE hygiene
The cheap, real LOWs from the review (bucket 🅰); 🅱 items and won't-fix
decisions are recorded in docs/SECURITY-BACKLOG.md.

- XFF spoofing (highest-value): client_ip now prefers an unspoofable X-Real-IP
  (Caddy `header_up X-Real-IP {remote_host}`) and otherwise takes the *rightmost*
  X-Forwarded-For token, never the client-controlled leftmost. The join cap,
  recover throttle, and admin-login floor all keyed on this. Unit-tested.
  Caddyfile also drops the dead /media/previews|originals cache matchers (the
  gateway sets its own Cache-Control) and the false "only host can download"
  comment.
- admin_login: add a hard, non-disableable rate floor (30/5min/IP) so the DB
  rate-limit master toggle can't remove brute-force protection.
- SSE: the broadcast upload-error payload no longer includes the raw error
  (absolute filesystem paths) — generic message to clients, details logged
  server-side only.
- Access logs: the request span logs the path only, never the query string, so
  the replayable media `?sig=` capability isn't written to logs.
- Reaper TOCTOU: reap_deleted now deletes rows by the ids it actually unlinked
  (DELETE … WHERE id = ANY) instead of re-evaluating the time predicate, so a
  row crossing the grace line mid-sweep can't be row-deleted without its files
  unlinked.
- Dockerfile: run the backend as a non-root user; create /media owned by `app`
  so a fresh volume is writable.
- a11y: aria-labels on the upload caption and feed search inputs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 18:01:41 +02:00
MechaCat02
db1e5b8833 fix: post-review punch-list — upload concurrency cap, atomic release, ban SSE
Follow-up to the adversarial re-review of the audit-fix branch.

MUST-FIX:
- H3: bound aggregate upload RAM. The body limit alone didn't cap concurrency,
  so ~N parallel 550MB uploads could OOM the box. Add an Arc<Semaphore>
  (UPLOAD_MAX_CONCURRENCY=4) in AppState, acquired at the top of the upload
  handler before the multipart body is read, so waiting requests hold only a
  connection — peak buffered RAM ≈ 4×550MB. (Matches the CompressionWorker
  semaphore pattern; avoids tower's non-default `limit` feature.)
- M8: release_gallery now runs the claim UPDATE + both job INSERTs in one
  transaction, so the row lock is held until the jobs exist — closing the
  cross-table TOCTOU where two concurrent presses could both spawn workers
  racing the same output files. Export temp filenames are now per-run
  (Gallery.{uuid}.zip.tmp, viewer_tmp_{event}_{uuid}, Memories.{uuid}.zip.tmp)
  so overlapping runs can't truncate each other. Final served names unchanged.
- M11: 'user-banned' was missing from sse.ts KNOWN_EVENTS, so EventSource
  silently dropped the frame and the forced-logout handler never fired. Added.

SHOULD-FIX:
- M8-3: re-release is now allowed when nothing is in progress and at least one
  job failed (was: only when *every* job failed), so a one-sided failure (zip
  done, html failed) is no longer permanently unrecoverable.
- M18: two light-mode contrast spots the earlier sweep missed — the LightboxModal
  char-counter class: directives and the account PIN-missing notice.
- M15: the diashow auto-advance is now slowed to a ≥30s floor under
  prefers-reduced-motion (WCAG 2.2.2), making the app.css comment accurate.

Verified: cargo build + 6 tests, npm run check (0 errors), and a live smoke test
against Postgres — first release 204, second 400, one-sided-failure re-release
204, no SQL errors; a normal upload still returns 201 through the new permit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 17:40:54 +02:00
MechaCat02
d4181b1119 chore(git): ignore local audit/review reports
The audit + fix-verification reports (docs/AUDIT-2026-06-27.md,
docs/FIX-VERIFICATION-2026-06-27.md) are local working artifacts; one was
accidentally swept into the media-gateway commit. Dropped from history and
ignored so they stay untracked local files.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 17:22:20 +02:00
MechaCat02
23a7d89a89 fix(migrations): renumber feed-view migration 006 → 010
The new v_feed-rewrite migration collided with the existing 006_user_pin_lockout
(versions must be unique). Verified against a fresh Postgres: all migrations
001–010 now apply cleanly on startup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 16:47:27 +02:00
MechaCat02
8272197cea fix(auth): escalating recovery-PIN lockout backoff (M4)
The failed-attempt counter is no longer reset when the lockout window expires,
so each further wrong PIN past the 3-strike threshold doubles the lockout —
15min, 30, 60, … capped at 24h. Combined with the now 6-digit PIN (1M space),
this makes sustained guessing via /recover infeasible. A successful recovery or
a host PIN reset clears the counter; legitimate users (who don't fail) are
unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 16:28:31 +02:00
MechaCat02
0737288ed9 a11y: viewport-fit, reduced-motion, like aria-pressed, labels, contrast (M13-M18)
M13: add viewport-fit=cover so env(safe-area-inset-*) resolves on notched phones.
M14: like buttons (list card, grid overlay, lightbox) get aria-pressed + a
descriptive aria-label so AT announces self-state, not just the shared count.
M15: a prefers-reduced-motion media query neutralizes the decorative keyframes
(Ken Burns, crossfade, HeartBurst) and snaps transitions.
M16: join/recover name + PIN inputs and the lightbox comment input get aria-labels.
M17: FeedGrid overlay like/comment buttons get aria-labels and ≥44px hit areas.
M18: bump light-mode secondary text from gray-400 to gray-500 (keeping
dark:text-gray-400) across the app for WCAG AA contrast.

Also raises the PIN inputs to 6 digits (placeholder, maxlength, slice, and the
auto-submit threshold) to match the new 6-digit generated PINs; legacy 4-digit
PINs still submit via the button.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 16:26:21 +02:00
MechaCat02
5bd008591b fix(frontend): surface upload errors, push ban/lock, route guards (WS8)
H11: UploadSheet now honors the modal contract — role=dialog/aria-modal +
accessible name, a focus-trap/Escape/focus-restore $effect mirroring
ContextSheet, and inert + pointer-events-none when closed so its controls leave
the tab order and AT tree.

H12: a layout-level $effect toasts each upload-queue item the first time it
transitions to 'error', so a banned/locked/over-quota guest is actually told
why their photo didn't post instead of the composer silently closing.

M10: api.ts now redirects to /join after clearAuth() on a 401 (guarded against
loops on the auth screens), so a 401 no longer strands the user on a dead page.

M11: ban and event-lock are pushed to guests. A new uploadsLocked store is
seeded from /me/context (now exposes uploads_locked) and kept live by
event-closed/opened SSE; the feed shows a banner and the FAB is disabled when
locked. A user-banned SSE event force-logs-out the targeted user.

M19: feed and export pages track a distinct loadError and render an "Erneut
versuchen" retry card instead of collapsing a fetch failure into "empty" /
"not released". Light-mode empty-state text bumped to gray-500 for contrast.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 16:16:01 +02:00
MechaCat02
cf428725b9 perf(feed): rewrite v_feed, bound feed_delta, server-time SSE cursor (H6,H7,M9)
H6: migration 006 replaces v_feed's double LEFT JOIN + COUNT(DISTINCT) (which
materialized a likes×comments Cartesian per upload) with correlated scalar
subqueries that each use their own index. Output columns are unchanged, so
feed + hashtag-filtered paths both benefit.

H7: feed_delta now applies the feed rate limit (keyed per user), caps results
at 200 rows, and clamps how far back a client `since` may reach (7 days). When
clamped or capped it returns reload_required=true; the SSE client turns that
into a full feed reload instead of streaming the whole gallery through the view
on every tab refocus.

M9: the SSE reconnect cursor is now advanced only from server timestamps (an
upload's created_at, seeded from the feed and updated on new-upload events and
delta responses), never the client clock — so a skewed phone clock can't drop
events missed while backgrounded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 15:58:35 +02:00
MechaCat02
ae6c496f94 fix(authz): event-scope social handlers, atomic gallery release (M1, M3, M8)
M1: toggle_like / add_comment / list_comments now resolve the target via
Upload::find_by_id_and_event, returning 404 for uploads outside the caller's
event or already soft-deleted — closing the cross-event IDOR and the
write-to-deleted-post path.

M3: the redundant in-handler is_banned fetches (upload, like, comment) are
removed; the AuthUser extractor (WS1) already rejects banned users on every
authenticated route, which also covers the previously-ungated edit_upload /
delete_upload / delete_comment.

M8: release_gallery claims the release with a single conditional UPDATE and only
proceeds when it matches a row, so concurrent presses can't spawn duplicate
export workers racing on the same files. Re-release is now permitted when all
prior export jobs terminally failed (e.g. a crash mid-export), and the job
upsert resets failed rows so the workers re-run cleanly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 15:43:12 +02:00
MechaCat02
9364cb624a fix(dos): bound uploads, offload bcrypt, cap image decode, split queues
H3: re-enable a request-body limit (550MB, sized to the largest allowed video +
multipart overhead) instead of DefaultBodyLimit::disable(), and drop the
second full-file copy by keeping the upload as Bytes.

H4: image decode now goes through ImageReader with explicit dimension
(12000x12000) and allocation caps, rejecting decompression bombs before the
expensive resize, and the whole spawn_blocking is wrapped in a 120s timeout
mirroring the ffmpeg guard.

H8: every bcrypt hash/verify (join, recover, admin_login, host PIN reset) now
runs via spawn_blocking through a new services::password helper, so concurrent
auths can't pin the async workers.

M7: images and videos get independent compression permit pools, so slow videos
can no longer starve image-preview generation.

H5: add a generous per-IP daily account-creation cap (NAT-safe at ~100 guests)
on top of the burst cap, and wire up the previously-dead upload_count_quota_*
config as an event-wide file-count cap that a re-join cannot reset.

Also bumps generated recovery PINs from 4 to 6 digits (part of M4).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 15:38:59 +02:00
MechaCat02
8faf702208 feat(media): authenticated signed media gateway (C1, C2-sink, C3, H2, H10)
Replaces the DB-blind `/media` ServeDir with a signed, DB-aware gateway at
`GET /media/{kind}/{id}`. Every media byte now flows through an HMAC-SHA256
signature check (minted into feed/upload DTOs for authenticated members; <img>
can't carry a Bearer header) plus a DB lookup:

- C1: export ZIP/HTML have no upload row, so they are unreachable by path —
  download stays behind the authenticated /export endpoints.
- C2 (sink): responses carry X-Content-Type-Options: nosniff and a locked-down
  CSP (default-src 'none'; sandbox), neutralizing any active content.
- C3 / H2: find_by_id filters deleted_at and the handler rejects ban-hidden
  uploaders, so deleted and moderated artifacts 404 — and the unauthenticated
  get_original alias (the H2 hole) is removed entirely.
- H10: delete paths (owner + host) now unlink original/preview/thumbnail after
  commit; soft_delete returns the paths; an hourly reaper reclaims disk for
  rows soft-deleted past a 1-day grace and hard-deletes them (FKs cascade).

Signed URLs are bucketed to a 1h window so they stay stable across feed polls
(browser cache hits) while expiring within 24h. media_token sign/verify has a
unit test (roundtrip + tamper + expiry).

Frontend: FeedUpload/pickMediaUrl now use the backend-provided signed
original_url; no client constructs a media path anymore.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 15:30:30 +02:00
MechaCat02
ab9f1d89b2 fix(upload): server-side MIME/ext allowlist + atomic transactional write
C2: stored-XSS via the application/* MIME bypass is closed — the declared
Content-Type and client filename are no longer trusted. New classify() derives
both the canonical MIME and a safe extension purely from magic bytes against a
strict allowlist (jpeg/png/webp/heic/mp4/mov/webm); anything infer can't
positively identify (incl. HTML/SVG script payloads) is rejected. Unit tests
cover accept + reject paths.

M12: the stored extension can no longer carry '/' or arbitrary client text —
it's one of the allowlist's safe constants, ending directory-pollution and the
export DoS.

M5/M6: quota is now reserved with a single conditional UPDATE that row-locks
(no TOCTOU overshoot) and the reservation + row INSERT run in one transaction;
the file is written before commit and unlinked on any rollback, so a crash
mid-write can no longer leak quota bytes or orphan a row. Upload::create is now
executor-generic to run inside the tx.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 15:19:34 +02:00
MechaCat02
2068e8c1f3 fix(auth): reconcile role/ban from DB, revoke sessions, fix pin/comment bugs
H1: AuthUser now JOINs session→user and sources role/ban/event from the live
DB row instead of trusting JWT claims. Bans take effect on the next request;
demotion/promotion is immediate. Adds Session::find_auth_context and rejects
tokens whose sub/event_id disagree with the session row. This also closes M2
(banned export) and M3 (banned edit/delete) globally — banned users can no
longer pass the AuthUser extractor.

Adds Session::delete_by_user_id and revokes all of a user's sessions on
ban_user, set_role, and reset_user_pin so existing JWTs die immediately.
ban_user also emits a user-banned SSE for forced client logout.

H9: reset_user_pin used a non-existent column pin_failed_attempts (runtime
sqlx, so it 500'd in prod). Corrected to failed_pin_attempts — the only
account-recovery path now works.

C4: LightboxModal posted comments to /comment (singular); backend only
registers /comments. One-character route fix re-enables the comment feature.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 15:14:22 +02:00
54 changed files with 1572 additions and 358 deletions

View File

@@ -4,15 +4,21 @@ DOMAIN=my-event.example.com
# ── App server ────────────────────────────────────────────────────────────────
APP_PORT=3000
# docker-compose.yml already forces APP_ENV=production for the `app` service.
# Only set this for a non-compose (bare cargo) run; leave unset for local dev.
# APP_ENV=production
# ── Database ──────────────────────────────────────────────────────────────────
DATABASE_URL=postgres://eventsnap:secret@db:5432/eventsnap
# Set a strong password and keep it identical in DATABASE_URL and POSTGRES_PASSWORD.
DATABASE_URL=postgres://eventsnap:CHANGE_ME_strong_db_password@db:5432/eventsnap
POSTGRES_USER=eventsnap
POSTGRES_PASSWORD=secret
POSTGRES_PASSWORD=CHANGE_ME_strong_db_password
POSTGRES_DB=eventsnap
# ── Authentication ────────────────────────────────────────────────────────────
# Generate with: openssl rand -hex 64
# REQUIRED in production: generate with `openssl rand -hex 64` (128 hex chars).
# The backend refuses to start in production with this placeholder or any value
# that is short or contains change/example/placeholder/replace.
JWT_SECRET=change_me_to_a_random_64_byte_hex_string
SESSION_EXPIRY_DAYS=30

4
.gitignore vendored
View File

@@ -24,3 +24,7 @@ e2e/.env.test
# OS
.DS_Store
Thumbs.db
# Local audit/review reports — generated working artifacts, kept out of git
docs/AUDIT-2026-06-27.md
docs/FIX-VERIFICATION-2026-06-27.md

View File

@@ -5,21 +5,24 @@
@hashed_assets path_regexp hashed /_app/immutable/.*\.[a-f0-9]{8,}\.(js|css|woff2)$
header @hashed_assets Cache-Control "public, max-age=31536000, immutable"
# Media previews and thumbnails
@previews path /media/previews/* /media/thumbnails/*
header @previews Cache-Control "public, max-age=3600"
# Original media files (private — only host can download)
@originals path /media/originals/*
header @originals Cache-Control "private, max-age=86400"
# API — never cache
@api path /api/*
header @api Cache-Control "no-store"
# Route API and media requests to the Rust backend
reverse_proxy /api/* app:3000
reverse_proxy /media/* app:3000
# Media is served by the authenticated gateway at /media/{kind}/{id}, which
# sets its own Cache-Control (private) and security headers per response — no
# Caddy-side cache rules (the old /media/previews|originals matchers were for
# the retired static mount).
# Route API and media requests to the Rust backend. Set X-Real-IP from the
# real TCP peer and overwrite any client-supplied value so the backend's
# rate-limit keys can't be spoofed via a forged X-Forwarded-For.
reverse_proxy /api/* app:3000 {
header_up X-Real-IP {remote_host}
}
reverse_proxy /media/* app:3000 {
header_up X-Real-IP {remote_host}
}
# Everything else goes to SvelteKit frontend
reverse_proxy frontend:3001

View File

@@ -18,10 +18,18 @@ RUN touch src/main.rs && cargo build --release
# --- Runtime stage ---
FROM alpine:3.21
RUN apk add --no-cache ca-certificates ffmpeg
# Run as an unprivileged user (defense-in-depth). The media volume mounts at
# /media; creating it here as `app` means a fresh named volume inherits app
# ownership and is writable without running as root. (An existing root-owned
# volume from a prior deploy needs a one-time `chown -R app:app` — see deploy notes.)
RUN apk add --no-cache ca-certificates ffmpeg \
&& addgroup -S app && adduser -S -G app app \
&& mkdir -p /media && chown app:app /media
WORKDIR /app
COPY --from=builder /app/target/release/eventsnap-backend ./
RUN chown -R app:app /app
USER app
EXPOSE 3000
CMD ["./eventsnap-backend"]

View File

@@ -0,0 +1,23 @@
-- Restore the original join-based v_feed definition.
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;

View File

@@ -0,0 +1,26 @@
-- H6: replace v_feed's double LEFT JOIN + COUNT(DISTINCT) (which materializes a
-- likes×comments Cartesian per upload before de-duping) with correlated scalar
-- subqueries. Each count now uses its own index (idx_like_upload /
-- idx_comment_upload) and there is no GROUP BY. The output columns are
-- unchanged, so every consumer keeps working.
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,
(SELECT COUNT(*) FROM "like" l
WHERE l.upload_id = u.id) AS like_count,
(SELECT COUNT(*) FROM comment c
WHERE c.upload_id = u.id AND c.deleted_at IS NULL) AS comment_count
FROM upload u
JOIN "user" usr ON u.user_id = usr.id
WHERE u.deleted_at IS NULL
AND usr.uploads_hidden = FALSE;

View File

@@ -15,6 +15,7 @@ use crate::models::event::Event;
use crate::models::session::Session;
use crate::models::user::{User, UserRole};
use crate::services::config;
use crate::services::password;
use crate::services::rate_limiter::client_ip;
use crate::state::AppState;
@@ -39,13 +40,23 @@ pub async fn join(
let ip = client_ip(&headers, "unknown");
let rate_limits_on = config::get_bool(&state.pool, "rate_limits_enabled", true).await;
let join_rate_on = config::get_bool(&state.pool, "join_rate_enabled", true).await;
if rate_limits_on && join_rate_on
&& !state.rate_limiter.check(format!("join:{ip}"), 5, Duration::from_secs(60))
{
return Err(AppError::TooManyRequests(
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
None,
));
if rate_limits_on && join_rate_on {
// Short burst cap (typos / double-taps) AND a generous per-IP daily cap
// on account creation. The daily cap bounds mass account-minting (which
// otherwise resets the per-user upload budget, see the event-wide count
// quota in the upload handler) while staying well above a real ~100-guest
// venue sharing one NAT'd IP.
let burst_ok =
state.rate_limiter.check(format!("join:{ip}"), 5, Duration::from_secs(60));
let daily_ok = state
.rate_limiter
.check(format!("join_day:{ip}"), 200, Duration::from_secs(86_400));
if !burst_ok || !daily_ok {
return Err(AppError::TooManyRequests(
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
None,
));
}
}
let display_name = body.display_name.trim();
@@ -78,10 +89,10 @@ pub async fn join(
)));
}
// Generate a 4-digit PIN
let pin: String = format!("{:04}", rand::rng().random_range(0..10000u32));
let pin_hash =
bcrypt::hash(&pin, 12).map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
// Generate a 6-digit PIN (≈20 bits vs the old ~13.3, so brute-forcing it
// against the 3-strike lockout is far harder).
let pin: String = format!("{:06}", rand::rng().random_range(0..1_000_000u32));
let pin_hash = password::hash(pin.clone(), 12).await?;
let user = User::create(&state.pool, event.id, display_name, &pin_hash).await?;
@@ -164,24 +175,22 @@ pub async fn recover(
}
for user in &users {
// Check PIN lockout. If the lockout has expired, also reset the failed-attempt
// counter so the user gets a fresh 3-strike window — otherwise the counter
// stays at 3+ and every subsequent wrong PIN immediately re-locks them, even
// after waiting out the cooldown. Without this reset, a once-locked account
// is effectively permanently fragile.
// Check PIN lockout. The failed-attempt counter is deliberately NOT reset
// when the window expires — it drives the *escalating* backoff below, so a
// determined guesser faces an exponentially growing wait. A successful
// recovery (and a host PIN reset) is what clears it. The counter only ever
// grows on a *wrong* PIN, so a legitimate user is unaffected.
if let Some(locked_until) = user.pin_locked_until {
if Utc::now() < locked_until {
return Err(AppError::TooManyRequests(
"Zu viele Versuche. Bitte warte 15 Minuten.".into(),
"Zu viele Versuche. Bitte warte und versuche es später erneut.".into(),
None,
));
}
// Lockout window expired — wipe the counter and the timestamp.
User::reset_pin_attempts(&state.pool, user.id).await?;
}
let pin_matches = bcrypt::verify(&body.pin, &user.recovery_pin_hash)
.unwrap_or(false);
let pin_matches =
password::verify(body.pin.clone(), user.recovery_pin_hash.clone()).await;
if pin_matches {
// Reset failed attempts on success
@@ -216,13 +225,19 @@ pub async fn recover(
"recover: wrong PIN"
);
if attempts >= 3 {
let lockout = Utc::now() + chrono::Duration::minutes(15);
// Escalating backoff: 15min, 30, 60, … doubling per failure past the
// threshold, capped at 24h. Makes sustained guessing against the
// 6-digit space (1M combinations) astronomically slow.
let exp = (attempts as u32).saturating_sub(3).min(10);
let minutes = 15i64.saturating_mul(1i64 << exp).min(24 * 60);
let lockout = Utc::now() + chrono::Duration::minutes(minutes);
User::lock_pin(&state.pool, user.id, lockout).await?;
tracing::warn!(
user_id = %user.id,
event_id = %event.id,
ip = %ip,
"recover: account locked for 15 minutes"
minutes,
"recover: account locked (escalating backoff)"
);
}
}
@@ -256,6 +271,22 @@ pub async fn admin_login(
// a long-running guess campaign. 5 attempts / minute / IP is plenty for
// honest typos.
let ip = client_ip(&headers, "unknown");
// Hard, non-disableable floor: admin_login is the one credential endpoint
// whose brute-force protection must survive the DB rate-limit master toggle
// being turned off. 30 attempts / 5 min / IP is generous for typos but caps
// sustained guessing regardless of config.
if !state.rate_limiter.check(
format!("admin_login_floor:{ip}"),
30,
Duration::from_secs(300),
) {
return Err(AppError::TooManyRequests(
"Zu viele Anmeldeversuche. Bitte warte kurz und versuche es erneut.".into(),
None,
));
}
let rate_limits_on = config::get_bool(&state.pool, "rate_limits_enabled", true).await;
let admin_rate_on = config::get_bool(&state.pool, "admin_login_rate_enabled", true).await;
if rate_limits_on && admin_rate_on
@@ -271,8 +302,11 @@ pub async fn admin_login(
));
}
let valid = bcrypt::verify(&body.password, &state.config.admin_password_hash)
.unwrap_or(false);
let valid = password::verify(
body.password.clone(),
state.config.admin_password_hash.clone(),
)
.await;
if !valid {
tracing::warn!(ip = %ip, "admin_login: wrong password");
@@ -298,8 +332,7 @@ pub async fn admin_login(
let dummy_pin: String = (0..32)
.map(|_| rand::rng().random_range(b'a'..=b'z') as char)
.collect();
let dummy_hash = bcrypt::hash(&dummy_pin, 4)
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
let dummy_hash = password::hash(dummy_pin, 4).await?;
let user = User::create(&state.pool, event.id, admin_name, &dummy_hash).await?;
sqlx::query("UPDATE \"user\" SET role = 'admin' WHERE id = $1")
.bind(user.id)

View File

@@ -1,4 +1,4 @@
use axum::extract::{FromRequestParts, State};
use axum::extract::FromRequestParts;
use axum::http::request::Parts;
use uuid::Uuid;
@@ -38,16 +38,32 @@ impl FromRequestParts<AppState> for AuthUser {
let token_hash = jwt::hash_token(token);
let session = Session::find_by_token_hash(&state.pool, &token_hash)
// Reconcile the session against the live `user` row. We do NOT trust the
// JWT claims for role/ban/event — a token can outlive a ban or demotion
// (default lifetime 30 days). The single JOIN query is the same number
// of round-trips as the old existence check.
let ctx = Session::find_auth_context(&state.pool, &token_hash)
.await
.map_err(|e| AppError::Internal(e.into()))?
.ok_or_else(|| AppError::Unauthorized("Sitzung nicht gefunden oder abgelaufen.".into()))?;
// Ban takes effect immediately on the next request, regardless of the
// token's remaining lifetime.
if ctx.is_banned {
return Err(AppError::Forbidden("Du bist gesperrt.".into()));
}
// Defend against a JWT that was issued for a different user/event than
// the session's DB row points at (e.g. a swapped or replayed token).
if claims.sub != ctx.user_id || claims.event_id != ctx.event_id {
return Err(AppError::Unauthorized("Token passt nicht zur Sitzung.".into()));
}
// Update last_seen_at in the background (fire-and-forget). Failures are
// non-fatal but worth surfacing — silent swallowing hides DB connection
// pressure that would otherwise be the first symptom of a real problem.
let pool = state.pool.clone();
let session_id = session.id;
let session_id = ctx.session_id;
tokio::spawn(async move {
if let Err(e) = Session::touch(&pool, session_id).await {
tracing::warn!(error = ?e, session_id = %session_id, "session touch failed");
@@ -55,9 +71,11 @@ impl FromRequestParts<AppState> for AuthUser {
});
Ok(Self {
user_id: claims.sub,
event_id: claims.event_id,
role: claims.role,
user_id: ctx.user_id,
event_id: ctx.event_id,
// Live role from the DB, not the claim — demotion/promotion takes
// effect on the next request.
role: ctx.role,
token_hash,
})
}

View File

@@ -25,18 +25,30 @@ impl AppConfig {
let is_prod = app_env.eq_ignore_ascii_case("production");
let jwt_secret = std::env::var("JWT_SECRET").context("JWT_SECRET must be set")?;
if jwt_secret == DEV_JWT_SECRET_SENTINEL {
if is_prod {
// A weak/placeholder signing key lets anyone forge any token (including
// admin). Detect the known dev sentinel, the `.env.example` placeholder,
// and anything that smells like an unreplaced template value.
let lower = jwt_secret.to_ascii_lowercase();
let looks_placeholder = jwt_secret == DEV_JWT_SECRET_SENTINEL
|| lower.contains("change")
|| lower.contains("example")
|| lower.contains("placeholder")
|| lower.contains("replace");
if is_prod {
// Production must use a real, strong secret — no placeholders, ≥64
// chars (an `openssl rand -hex 64` is 128 hex chars).
if looks_placeholder || jwt_secret.len() < 64 {
return Err(anyhow!(
"Refusing to start in production with the well-known dev JWT_SECRET — \
rotate it (openssl rand -hex 64)."
"Refusing to start in production: JWT_SECRET is a placeholder or too short. \
Generate a real one (openssl rand -hex 64) and set it in the prod environment."
));
}
} else if looks_placeholder || jwt_secret.len() < 32 {
tracing::warn!(
"JWT_SECRET is the dev sentinel — fine for local development, NEVER ship this."
"JWT_SECRET looks like a dev/placeholder value — fine for local development, \
NEVER ship this to production."
);
} else if jwt_secret.len() < 32 {
return Err(anyhow!("JWT_SECRET must be at least 32 characters."));
}
Ok(Self {

View File

@@ -10,6 +10,7 @@ use uuid::Uuid;
use crate::auth::middleware::AuthUser;
use crate::error::AppError;
use crate::services::config;
use crate::services::media_token;
use crate::services::rate_limiter::client_ip;
use crate::state::AppState;
@@ -27,6 +28,8 @@ pub struct FeedUpload {
pub uploader_name: String,
pub preview_url: Option<String>,
pub thumbnail_url: Option<String>,
/// Signed gateway URL for the full-resolution original. Always present.
pub original_url: Option<String>,
pub mime_type: String,
pub caption: Option<String>,
pub like_count: i64,
@@ -55,6 +58,33 @@ struct FeedRow {
created_at: DateTime<Utc>,
}
/// Build a feed DTO, minting fresh signed gateway URLs for each artifact. The
/// preview/thumbnail URLs are present only when the derivative exists so the
/// client can show a skeleton while compression is still running; the original
/// URL is always present.
fn to_feed_upload(r: FeedRow, liked: bool, secret: &str, now: i64) -> FeedUpload {
FeedUpload {
liked_by_me: liked,
preview_url: r
.preview_path
.as_ref()
.map(|_| media_token::signed_url(secret, "preview", r.id, now)),
thumbnail_url: r
.thumbnail_path
.as_ref()
.map(|_| media_token::signed_url(secret, "thumbnail", r.id, now)),
original_url: Some(media_token::signed_url(secret, "original", r.id, now)),
id: r.id,
user_id: r.user_id,
uploader_name: r.uploader_name,
mime_type: r.mime_type,
caption: r.caption,
like_count: r.like_count,
comment_count: r.comment_count,
created_at: r.created_at,
}
}
pub async fn feed(
State(state): State<AppState>,
auth: AuthUser,
@@ -135,24 +165,13 @@ pub async fn feed(
let upload_ids: Vec<Uuid> = rows.iter().map(|r| r.id).collect();
let liked_set = get_liked_set(&state.pool, auth.user_id, &upload_ids).await;
let now = Utc::now().timestamp();
let secret = &state.config.jwt_secret;
let uploads = rows
.into_iter()
.map(|r| {
let preview_url = r.preview_path.map(|p| format!("/media/{p}"));
let thumbnail_url = r.thumbnail_path.map(|p| format!("/media/{p}"));
FeedUpload {
liked_by_me: liked_set.contains(&r.id),
id: r.id,
user_id: r.user_id,
uploader_name: r.uploader_name,
preview_url,
thumbnail_url,
mime_type: r.mime_type,
caption: r.caption,
like_count: r.like_count,
comment_count: r.comment_count,
created_at: r.created_at,
}
let liked = liked_set.contains(&r.id);
to_feed_upload(r, liked, secret, now)
})
.collect();
@@ -171,57 +190,90 @@ pub struct DeltaQuery {
pub struct DeltaResponse {
pub uploads: Vec<FeedUpload>,
pub deleted_ids: Vec<Uuid>,
/// Set when the delta was clamped (too-old cursor) or hit the row cap — the
/// client should do a full feed reload instead of trusting the partial set.
pub reload_required: bool,
}
/// Hard cap on how many uploads one delta returns. Beyond this the client is
/// told to reload rather than streaming the whole gallery through the view.
const DELTA_LIMIT: i64 = 200;
/// How far back a client-supplied `since` may reach. A tab backgrounded for days
/// must not pull the entire event on reconnect.
const DELTA_MAX_LOOKBACK_DAYS: i64 = 7;
pub async fn feed_delta(
State(state): State<AppState>,
auth: AuthUser,
Query(q): Query<DeltaQuery>,
) -> Result<Json<DeltaResponse>, AppError> {
// H7: feed_delta runs the (expensive) feed query and fires on every tab
// refocus, so it needs the same rate limit as feed(), keyed per user.
let rate_limits_on = config::get_bool(&state.pool, "rate_limits_enabled", true).await;
let feed_rate_on = config::get_bool(&state.pool, "feed_rate_enabled", true).await;
if rate_limits_on && feed_rate_on {
let rate_limit = config::get_usize(&state.pool, "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,
));
}
}
// Clamp the lookback server-side; signal a full reload if we had to.
let min_since = Utc::now() - chrono::Duration::days(DELTA_MAX_LOOKBACK_DAYS);
let clamped = q.since < min_since;
let since = if clamped { min_since } else { q.since };
let rows = sqlx::query_as::<_, FeedRow>(
"SELECT id, user_id, uploader_name, preview_path, thumbnail_path,
mime_type, caption, like_count, comment_count, created_at
FROM v_feed
WHERE event_id = $1 AND created_at > $2
ORDER BY created_at DESC",
ORDER BY created_at DESC
LIMIT $3",
)
.bind(auth.event_id)
.bind(q.since)
.bind(since)
.bind(DELTA_LIMIT + 1)
.fetch_all(&state.pool)
.await?;
let capped = rows.len() as i64 > DELTA_LIMIT;
let rows: Vec<FeedRow> = rows.into_iter().take(DELTA_LIMIT as usize).collect();
let reload_required = clamped || capped;
let deleted_ids: Vec<(Uuid,)> = sqlx::query_as(
"SELECT id FROM upload
WHERE event_id = $1 AND deleted_at IS NOT NULL AND deleted_at > $2",
)
.bind(auth.event_id)
.bind(q.since)
.bind(since)
.fetch_all(&state.pool)
.await?;
let upload_ids: Vec<Uuid> = rows.iter().map(|r| r.id).collect();
let liked_set = get_liked_set(&state.pool, auth.user_id, &upload_ids).await;
let now = Utc::now().timestamp();
let secret = &state.config.jwt_secret;
let uploads = rows
.into_iter()
.map(|r| FeedUpload {
liked_by_me: liked_set.contains(&r.id),
id: r.id,
user_id: r.user_id,
uploader_name: r.uploader_name,
preview_url: r.preview_path.map(|p| format!("/media/{p}")),
thumbnail_url: r.thumbnail_path.map(|p| format!("/media/{p}")),
mime_type: r.mime_type,
caption: r.caption,
like_count: r.like_count,
comment_count: r.comment_count,
created_at: r.created_at,
.map(|r| {
let liked = liked_set.contains(&r.id);
to_feed_upload(r, liked, secret, now)
})
.collect();
Ok(Json(DeltaResponse {
uploads,
deleted_ids: deleted_ids.into_iter().map(|r| r.0).collect(),
reload_required,
}))
}

View File

@@ -9,10 +9,21 @@ use crate::auth::middleware::RequireHost;
use crate::error::AppError;
use crate::models::comment::Comment;
use crate::models::event::Event;
use crate::models::session::Session;
use crate::models::upload::Upload;
use crate::models::user::UserRole;
use crate::state::{AppState, SseEvent};
/// Revoke all of a user's sessions (best-effort). A failure here is logged but
/// never fails the surrounding admin action — the live-role/ban reconciliation
/// in `AuthUser` is the authoritative gate; revocation is defense-in-depth.
async fn revoke_sessions(pool: &sqlx::PgPool, user_id: Uuid, action: &str) {
match Session::delete_by_user_id(pool, user_id).await {
Ok(n) => tracing::info!(target_user_id = %user_id, revoked = n, action, "sessions revoked"),
Err(e) => tracing::warn!(error = ?e, target_user_id = %user_id, action, "session revoke failed"),
}
}
// ── DTOs ─────────────────────────────────────────────────────────────────────
#[derive(Serialize, sqlx::FromRow)]
@@ -120,6 +131,14 @@ pub async fn ban_user(
.execute(&state.pool)
.await?;
revoke_sessions(&state.pool, user_id, "ban_user").await;
// Tell the banned user's online devices to log out immediately.
let _ = state.sse_tx.send(SseEvent::new(
"user-banned",
serde_json::json!({ "user_id": user_id }).to_string(),
));
tracing::info!(
actor_user_id = %auth.user_id,
target_user_id = %user_id,
@@ -201,6 +220,13 @@ pub async fn set_role(
.bind(auth.event_id)
.execute(&state.pool)
.await?;
// Force a clean re-auth so the new role can't be cached client-side and so
// any in-flight tokens carrying the old role are invalidated. The live-role
// reconciliation in AuthUser already prevents privilege escalation, but
// revoking is cheaper to reason about.
revoke_sessions(&state.pool, user_id, "set_role").await;
tracing::info!(
actor_user_id = %auth.user_id,
target_user_id = %user_id,
@@ -256,14 +282,13 @@ pub async fn reset_user_pin(
}
}
let pin: String = format!("{:04}", rand::rng().random_range(0..10000u32));
let pin_hash =
bcrypt::hash(&pin, 12).map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
let pin: String = format!("{:06}", rand::rng().random_range(0..1_000_000u32));
let pin_hash = crate::services::password::hash(pin.clone(), 12).await?;
sqlx::query(
"UPDATE \"user\"
SET recovery_pin_hash = $1,
pin_failed_attempts = 0,
failed_pin_attempts = 0,
pin_locked_until = NULL
WHERE id = $2",
)
@@ -272,6 +297,10 @@ pub async fn reset_user_pin(
.execute(&state.pool)
.await?;
// A PIN reset must invalidate existing sessions so a compromised/old token
// can't outlive the reset.
revoke_sessions(&state.pool, user_id, "reset_user_pin").await;
// Notify the *recipient* device(s) if they happen to be online so they can clear
// their cached local PIN. They'll save the new one on the next /recover.
let _ = state.sse_tx.send(SseEvent::new(
@@ -294,24 +323,21 @@ pub async fn host_delete_upload(
RequireHost(auth): RequireHost,
Path(upload_id): Path<Uuid>,
) -> Result<StatusCode, AppError> {
let upload = Upload::find_by_id_and_event(&state.pool, upload_id, auth.event_id)
let paths = Upload::soft_delete_in_event(&state.pool, upload_id, auth.event_id)
.await?
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
let deleted = Upload::soft_delete_in_event(&state.pool, upload_id, auth.event_id).await?;
if !deleted {
return Err(AppError::NotFound("Upload nicht gefunden.".into()));
}
crate::services::media_fs::unlink_media(&state.config.media_path, &paths).await;
let _ = state.sse_tx.send(SseEvent::new(
"upload-deleted",
serde_json::json!({ "upload_id": upload.id }).to_string(),
serde_json::json!({ "upload_id": upload_id }).to_string(),
));
tracing::info!(
actor_user_id = %auth.user_id,
event_id = %auth.event_id,
upload_id = %upload.id,
upload_id = %upload_id,
"host: host_delete_upload"
);
@@ -377,28 +403,65 @@ pub async fn release_gallery(
.await?
.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()));
// Atomically claim the release AND enqueue the jobs in one transaction (M8).
// The claim UPDATE row-locks the event row and the lock is held until commit
// — *after* the export_job rows exist — so a second concurrent press re-reads
// the now-present jobs and matches 0 rows (clean 400). Doing the UPDATE and
// INSERTs as separate autocommit statements left a cross-table TOCTOU window
// where both presses could win and race the same output files.
//
// Re-release guard (M8-3): allow when never released, OR nothing is currently
// in progress and at least one job terminally failed. The old "every job
// failed" guard left a one-sided failure (zip done, html failed) permanently
// unrecoverable.
let mut tx = state.pool.begin().await?;
let claim = sqlx::query(
"UPDATE event SET export_released_at = NOW()
WHERE slug = $1
AND (
export_released_at IS NULL
OR (
NOT EXISTS (
SELECT 1 FROM export_job j
WHERE j.event_id = event.id AND j.status IN ('running', 'pending')
)
AND EXISTS (
SELECT 1 FROM export_job j
WHERE j.event_id = event.id AND j.status = 'failed'
)
)
)",
)
.bind(&state.config.event_slug)
.execute(&mut *tx)
.await?;
if claim.rows_affected() == 0 {
tx.rollback().await.ok();
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, resetting any prior terminal rows so the workers
// re-run cleanly (including an already-`done` sibling on re-release).
for export_type in ["zip", "html"] {
sqlx::query(
"INSERT INTO export_job (event_id, type) VALUES ($1, $2::export_type)
ON CONFLICT (event_id, type) DO NOTHING",
ON CONFLICT (event_id, type)
DO UPDATE SET status = 'pending', progress_pct = 0,
error_message = NULL, completed_at = NULL",
)
.bind(event.id)
.bind(export_type)
.execute(&state.pool)
.execute(&mut *tx)
.await?;
}
// Spawn export workers
tx.commit().await?;
// Spawn export workers only after the claim+enqueue is durably committed.
crate::services::export::spawn_export_jobs(
event.id,
event.name,

View File

@@ -55,6 +55,9 @@ pub struct MeContextDto {
pub privacy_note: String,
pub quota_enabled: bool,
pub storage_quota_enabled: bool,
/// Whether uploads are currently locked for the event, so the client can show
/// a banner + disable the upload affordance on load (not just via SSE).
pub uploads_locked: bool,
}
pub async fn get_context(
@@ -68,6 +71,10 @@ pub async fn get_context(
let privacy_note = config::get_str(&state.pool, "privacy_note", "").await;
let quota_enabled = config::get_bool(&state.pool, "quota_enabled", true).await;
let storage_quota_enabled = config::get_bool(&state.pool, "storage_quota_enabled", true).await;
let uploads_locked = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)
.await?
.map(|e| e.uploads_locked_at.is_some())
.unwrap_or(false);
Ok(Json(MeContextDto {
user_id: user.id,
@@ -76,5 +83,6 @@ pub async fn get_context(
privacy_note,
quota_enabled,
storage_quota_enabled,
uploads_locked,
}))
}

View File

@@ -0,0 +1,117 @@
//! Authenticated media gateway.
//!
//! Replaces the raw `/media` `ServeDir`. Every media byte now flows through a
//! signature check plus a DB lookup, so:
//! - soft-deleted uploads 404 (`find_by_id` filters `deleted_at`) — closes C3,
//! - ban-hidden uploaders' artifacts 404 (mirrors the `v_feed` rule) — H2,
//! - the export archives (which have no `upload` row) are unreachable — C1,
//! - HTML/SVG can't be served as an active document — the response carries
//! `nosniff` + a locked-down CSP (defense-in-depth behind the upload-time
//! allowlist) — C2 sink.
//!
//! Access is authorized by the embedded HMAC signature (see `media_token`), not
//! a Bearer header, because `<img>`/`<video>` cannot send one.
use axum::extract::{Path, Query, State};
use axum::response::Response;
use chrono::Utc;
use serde::Deserialize;
use uuid::Uuid;
use crate::error::AppError;
use crate::models::upload::Upload;
use crate::models::user::User;
use crate::services::media_token;
use crate::state::AppState;
#[derive(Deserialize)]
pub struct MediaQuery {
pub exp: i64,
pub sig: String,
}
pub async fn serve(
State(state): State<AppState>,
Path((kind, id)): Path<(String, Uuid)>,
Query(q): Query<MediaQuery>,
) -> Result<Response, AppError> {
if !media_token::verify(
&state.config.jwt_secret,
&kind,
id,
q.exp,
&q.sig,
Utc::now().timestamp(),
) {
return Err(AppError::Unauthorized(
"Ungültige oder abgelaufene Medien-URL.".into(),
));
}
// `find_by_id` filters `deleted_at IS NULL`, so soft-deleted uploads 404.
let upload = Upload::find_by_id(&state.pool, id)
.await?
.ok_or_else(|| AppError::NotFound("Datei nicht gefunden.".into()))?;
// Hide artifacts of users whose uploads were hidden by a host (ban + hide),
// matching the `usr.uploads_hidden = FALSE` predicate in `v_feed`.
let uploader = User::find_by_id(&state.pool, upload.user_id)
.await?
.ok_or_else(|| AppError::NotFound("Datei nicht gefunden.".into()))?;
if uploader.uploads_hidden {
return Err(AppError::NotFound("Datei nicht gefunden.".into()));
}
let (rel_path, content_type) = match kind.as_str() {
"original" => (upload.original_path.clone(), upload.mime_type.clone()),
"preview" => (
upload
.preview_path
.clone()
.ok_or_else(|| AppError::NotFound("Vorschau nicht verfügbar.".into()))?,
"image/jpeg".to_string(),
),
"thumbnail" => (
upload
.thumbnail_path
.clone()
.ok_or_else(|| AppError::NotFound("Vorschau nicht verfügbar.".into()))?,
"image/jpeg".to_string(),
),
_ => return Err(AppError::NotFound("Unbekannter Medientyp.".into())),
};
stream_file(&state.config.media_path.join(&rel_path), &content_type).await
}
async fn stream_file(path: &std::path::Path, content_type: &str) -> Result<Response, AppError> {
use axum::body::Body;
use axum::http::{header, StatusCode};
use tokio_util::io::ReaderStream;
let file = tokio::fs::File::open(path)
.await
.map_err(|_| AppError::NotFound("Datei nicht gefunden.".into()))?;
let metadata = file
.metadata()
.await
.map_err(|e| AppError::Internal(e.into()))?;
let stream = ReaderStream::new(file);
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, content_type)
.header(header::CONTENT_LENGTH, metadata.len())
// URLs are signed + time-boxed; cache only privately, aligned to the
// 1h URL-stability bucket.
.header(header::CACHE_CONTROL, "private, max-age=3600")
// Defense-in-depth against any active content slipping past the
// upload-time allowlist: never sniff, never script.
.header(header::X_CONTENT_TYPE_OPTIONS, "nosniff")
.header(
header::CONTENT_SECURITY_POLICY,
"default-src 'none'; sandbox; frame-ancestors 'none'",
)
.body(Body::from_stream(stream))
.map_err(|e| AppError::Internal(e.into()))
}

View File

@@ -2,6 +2,7 @@ pub mod admin;
pub mod feed;
pub mod host;
pub mod me;
pub mod media;
pub mod social;
pub mod sse;
pub mod test_admin;

View File

@@ -9,6 +9,7 @@ use crate::auth::middleware::AuthUser;
use crate::error::AppError;
use crate::models::comment::{Comment, CommentDto};
use crate::models::hashtag::{self, Hashtag};
use crate::models::upload::Upload;
use crate::state::AppState;
pub async fn toggle_like(
@@ -16,13 +17,12 @@ pub async fn toggle_like(
auth: AuthUser,
Path(upload_id): Path<Uuid>,
) -> Result<StatusCode, AppError> {
// Check if user is banned
let user = crate::models::user::User::find_by_id(&state.pool, auth.user_id)
// Ban is already rejected by the AuthUser extractor. Scope the target to the
// caller's event and reject deleted uploads (M1: no cross-event IDOR, no
// likes on soft-deleted posts).
Upload::find_by_id_and_event(&state.pool, upload_id, auth.event_id)
.await?
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
if user.is_banned {
return Err(AppError::Forbidden("Du bist gesperrt.".into()));
}
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
// Try to insert; if conflict, delete (toggle)
let result = sqlx::query(
@@ -64,10 +64,16 @@ const COMMENT_PAGE_SIZE: i64 = 50;
pub async fn list_comments(
State(state): State<AppState>,
_auth: AuthUser,
auth: AuthUser,
Path(upload_id): Path<Uuid>,
Query(q): Query<ListCommentsQuery>,
) -> Result<Json<Vec<CommentDto>>, AppError> {
// M1: a pure read behind any valid token must still be scoped to the
// caller's event and must not leak comments on soft-deleted uploads.
Upload::find_by_id_and_event(&state.pool, upload_id, auth.event_id)
.await?
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
let comments =
Comment::list_for_upload(&state.pool, upload_id, q.before, COMMENT_PAGE_SIZE).await?;
Ok(Json(comments))
@@ -84,12 +90,15 @@ pub async fn add_comment(
Path(upload_id): Path<Uuid>,
Json(body): Json<AddCommentRequest>,
) -> Result<(StatusCode, Json<CommentDto>), AppError> {
// M1: scope the target upload to the caller's event and reject deleted
// posts. (Ban is already handled by the AuthUser extractor.)
Upload::find_by_id_and_event(&state.pool, upload_id, auth.event_id)
.await?
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
let user = crate::models::user::User::find_by_id(&state.pool, auth.user_id)
.await?
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
if user.is_banned {
return Err(AppError::Forbidden("Du bist gesperrt.".into()));
}
let text = body.body.trim();
let text_chars = text.chars().count();

View File

@@ -16,6 +16,35 @@ use crate::state::AppState;
const MAX_CAPTION_LENGTH: usize = 2000;
/// Derive the canonical MIME type and a *safe* file extension purely from the
/// file's magic bytes, against a strict allowlist. The client-declared
/// `Content-Type` and the client filename are never trusted: the old code
/// skipped validation entirely for `application/*` and copied the extension
/// verbatim from the filename, which allowed storing `{uuid}.html`/`.svg` with
/// a script payload (stored XSS) and `/`-bearing extensions (path pollution +
/// export DoS).
///
/// `infer` cannot fingerprint HTML/SVG, so the reject-by-default arm is exactly
/// what closes the XSS vector — anything not positively identified as an allowed
/// raster image or video is refused. Returns `(canonical_mime, safe_ext, is_video)`.
fn classify(data: &[u8]) -> Result<(&'static str, &'static str, bool), AppError> {
let kind = infer::get(data)
.ok_or_else(|| AppError::BadRequest("Dateityp konnte nicht erkannt werden.".into()))?;
match kind.mime_type() {
"image/jpeg" => Ok(("image/jpeg", "jpg", false)),
"image/png" => Ok(("image/png", "png", false)),
"image/webp" => Ok(("image/webp", "webp", false)),
// infer reports HEIC/HEIF as image/heif
"image/heif" | "image/heic" => Ok(("image/heic", "heic", false)),
"video/mp4" => Ok(("video/mp4", "mp4", true)),
"video/quicktime" => Ok(("video/quicktime", "mov", true)),
"video/webm" => Ok(("video/webm", "webm", true)),
other => Err(AppError::BadRequest(format!(
"Dateityp nicht erlaubt: {other}. Erlaubt sind JPEG, PNG, WebP, HEIC, MP4, MOV, WebM."
))),
}
}
pub async fn upload(
State(state): State<AppState>,
auth: AuthUser,
@@ -39,14 +68,11 @@ pub async fn upload(
}
}
// Check if user is banned
// Ban is already rejected by the AuthUser extractor. We still load the user
// for the uploader's display name in the response DTO.
let user = User::find_by_id(&state.pool, auth.user_id)
.await?
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
if user.is_banned {
drain_multipart(multipart).await;
return Err(AppError::Forbidden("Du bist gesperrt.".into()));
}
// Check if uploads are locked
let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)
@@ -61,9 +87,17 @@ pub async fn upload(
let max_image_mb: i64 = config::get_i64(&state.pool, "max_image_size_mb", 20).await;
let max_video_mb: i64 = config::get_i64(&state.pool, "max_video_size_mb", 500).await;
let mut file_data: Option<Vec<u8>> = None;
let mut file_name: Option<String> = None;
let mut content_type: Option<String> = None;
// Bound concurrent upload-body buffering to cap aggregate RAM (H3). Acquired
// *before* the multipart body is read, so requests waiting on a permit hold
// only a connection, not a buffered ~550 MB file. The permit is held for the
// rest of the handler (body read + write) and released on return.
let _upload_permit = state
.upload_limiter
.acquire()
.await
.map_err(|e| AppError::Internal(e.into()))?;
let mut file_data: Option<axum::body::Bytes> = None;
let mut caption: Option<String> = None;
let mut hashtags_csv: Option<String> = None;
@@ -71,12 +105,13 @@ pub async fn upload(
let name = field.name().unwrap_or_default().to_string();
match name.as_str() {
"file" => {
file_name = field.file_name().map(|s| s.to_string());
content_type = field.content_type().map(|s| s.to_string());
// The client-declared filename and Content-Type are deliberately
// ignored — the stored MIME and extension are derived from the
// file's magic bytes (see `classify`). Kept as `Bytes` (one copy
// off the wire) rather than an extra `.to_vec()`.
file_data = Some(
field.bytes().await
.map_err(|e| AppError::BadRequest(format!("Datei konnte nicht gelesen werden: {e}")))?
.to_vec(),
.map_err(|e| AppError::BadRequest(format!("Datei konnte nicht gelesen werden: {e}")))?,
);
}
"caption" => {
@@ -96,7 +131,6 @@ pub async fn upload(
}
let data = file_data.ok_or_else(|| AppError::BadRequest("Keine Datei hochgeladen.".into()))?;
let mime = content_type.unwrap_or_else(|| "application/octet-stream".to_string());
let size = data.len() as i64;
// Validate caption length. Counted in chars (code points) to match the
@@ -111,25 +145,13 @@ pub async fn upload(
}
}
// Validate file MIME type using magic bytes
let detected_mime = infer::get(&data);
if let Some(detected) = detected_mime {
let detected_type = detected.mime_type();
// Ensure detected type is compatible with declared MIME type
let declared_category = mime.split('/').next().unwrap_or("");
let detected_category = detected_type.split('/').next().unwrap_or("");
// Derive canonical MIME + safe extension from magic bytes against a strict
// allowlist. The client Content-Type and filename are never trusted.
let (canonical_mime, safe_ext, is_video) = classify(&data)?;
let canonical_mime = canonical_mime.to_string();
// Only reject if categories don't match (e.g., image vs video)
if declared_category != "application" && declared_category != detected_category {
return Err(AppError::BadRequest(format!(
"Dateiinhalt entspricht nicht dem deklarierten Typ. Erwartet: {}, erkannt: {}",
mime, detected_type
)));
}
}
// Validate file size
let max_bytes = if mime.starts_with("video/") {
// Validate file size against the per-category limit.
let max_bytes = if is_video {
max_video_mb * 1024 * 1024
} else {
max_image_mb * 1024 * 1024
@@ -141,59 +163,120 @@ pub async fn upload(
)));
}
let upload_id = Uuid::new_v4();
let event_slug = &state.config.event_slug;
let relative_path = format!("originals/{event_slug}/{upload_id}.{safe_ext}");
let absolute_path = state.config.media_path.join(&relative_path);
// Per-user storage quota — dynamic formula based on available disk space and the
// number of active uploaders. Gated by master + per-area toggles so the admin can
// disable it on trusted instances.
// disable it on trusted instances. `None` ⇒ enforcement off.
let quota_on = config::get_bool(&state.pool, "quota_enabled", true).await;
let storage_quota_on = config::get_bool(&state.pool, "storage_quota_enabled", true).await;
if quota_on && storage_quota_on {
let estimate = compute_storage_quota(&state).await;
if let Some(limit) = estimate.limit_bytes {
let prospective_total = user.total_upload_bytes.saturating_add(size);
if prospective_total > limit {
return Err(AppError::TooManyRequests(
"Du hast dein Upload-Limit für dieses Event erreicht.".into(),
None,
));
}
let quota_limit: Option<i64> = if quota_on && storage_quota_on {
compute_storage_quota(&state).await.limit_bytes
} else {
None
};
// Event-wide file-count cap. Unlike the per-user budget, this can't be reset
// by minting a fresh account via /join, so it bounds total uploads no matter
// how many accounts an abuser creates. Wires up the previously-dead
// `upload_count_quota_*` config (default off to preserve existing behavior).
if quota_on && config::get_bool(&state.pool, "upload_count_quota_enabled", false).await {
let max_count = config::get_i64(&state.pool, "upload_count_quota_max", 10_000).await;
let (count,): (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM upload WHERE event_id = $1 AND deleted_at IS NULL",
)
.bind(auth.event_id)
.fetch_one(&state.pool)
.await?;
if count >= max_count {
return Err(AppError::TooManyRequests(
"Das Upload-Limit für dieses Event ist erreicht.".into(),
None,
));
}
}
// Determine file extension
let ext = file_name
.as_deref()
.and_then(|n| n.rsplit('.').next())
.unwrap_or(if mime.starts_with("video/") { "mp4" } else { "jpg" });
// Reserve quota and insert the row in a single transaction so the byte
// counter and the upload row are atomic (M6: no quota leak if we crash
// between them) and the reservation is a conditional UPDATE that row-locks
// (M5: concurrent uploads can no longer all pass a stale snapshot and
// overshoot the limit). The file is written before commit and unlinked on
// any rollback so a failed transaction leaves nothing behind.
let mut tx = state.pool.begin().await?;
let upload_id = Uuid::new_v4();
let event_slug = &state.config.event_slug;
let relative_path = format!("originals/{event_slug}/{upload_id}.{ext}");
let absolute_path = state.config.media_path.join(&relative_path);
// Ensure directory exists and write file
if let Some(parent) = absolute_path.parent() {
tokio::fs::create_dir_all(parent).await.map_err(|e| AppError::Internal(e.into()))?;
}
tokio::fs::write(&absolute_path, &data).await.map_err(|e| AppError::Internal(e.into()))?;
// Update user's total upload bytes
sqlx::query("UPDATE \"user\" SET total_upload_bytes = total_upload_bytes + $2 WHERE id = $1")
let reserved: Option<(Uuid,)> = if let Some(limit) = quota_limit {
sqlx::query_as(
"UPDATE \"user\"
SET total_upload_bytes = total_upload_bytes + $2
WHERE id = $1 AND total_upload_bytes + $2 <= $3
RETURNING id",
)
.bind(auth.user_id)
.bind(size)
.execute(&state.pool)
.await?;
.bind(limit)
.fetch_optional(&mut *tx)
.await?
} else {
sqlx::query_as(
"UPDATE \"user\"
SET total_upload_bytes = total_upload_bytes + $2
WHERE id = $1
RETURNING id",
)
.bind(auth.user_id)
.bind(size)
.fetch_optional(&mut *tx)
.await?
};
if reserved.is_none() {
// The user exists (checked above), so a missing row here means the
// conditional limit guard rejected the reservation → over quota.
tx.rollback().await.ok();
return Err(AppError::TooManyRequests(
"Du hast dein Upload-Limit für dieses Event erreicht.".into(),
None,
));
}
// Insert upload record
let upload = Upload::create(
&state.pool,
// Write the file. On any failure, roll back (releasing the reservation) and
// remove a possibly-partial file.
if let Some(parent) = absolute_path.parent() {
if let Err(e) = tokio::fs::create_dir_all(parent).await {
tx.rollback().await.ok();
return Err(AppError::Internal(e.into()));
}
}
if let Err(e) = tokio::fs::write(&absolute_path, data.as_ref()).await {
tx.rollback().await.ok();
return Err(AppError::Internal(e.into()));
}
let upload = match Upload::create(
&mut *tx,
auth.event_id,
auth.user_id,
&relative_path,
&mime,
&canonical_mime,
size,
caption.as_deref(),
)
.await?;
.await
{
Ok(u) => u,
Err(e) => {
tx.rollback().await.ok();
let _ = tokio::fs::remove_file(&absolute_path).await;
return Err(e.into());
}
};
if let Err(e) = tx.commit().await {
let _ = tokio::fs::remove_file(&absolute_path).await;
return Err(e.into());
}
// Process hashtags from caption and explicit CSV
let mut tags: Vec<String> = Vec::new();
@@ -219,7 +302,7 @@ pub async fn upload(
// Spawn compression task
state
.compression
.process(upload.id, relative_path, mime.clone());
.process(upload.id, relative_path, canonical_mime.clone());
// Broadcast SSE event
let dto = UploadDto {
@@ -228,7 +311,13 @@ pub async fn upload(
uploader_name: user.display_name,
preview_url: None,
thumbnail_url: None,
mime_type: mime,
original_url: Some(crate::services::media_token::signed_url(
&state.config.jwt_secret,
"original",
upload.id,
chrono::Utc::now().timestamp(),
)),
mime_type: canonical_mime,
caption,
hashtags: tags,
like_count: 0,
@@ -293,7 +382,15 @@ pub async fn delete_upload(
return Err(AppError::Forbidden("Nur eigene Uploads löschen.".into()));
}
Upload::soft_delete(&state.pool, upload_id).await?;
if let Some(paths) = Upload::soft_delete(&state.pool, upload_id).await? {
crate::services::media_fs::unlink_media(&state.config.media_path, &paths).await;
}
// Tell other clients to drop the photo immediately (mirrors host delete).
let _ = state.sse_tx.send(crate::state::SseEvent::new(
"upload-deleted",
serde_json::json!({ "upload_id": upload_id }).to_string(),
));
Ok(StatusCode::NO_CONTENT)
}
@@ -361,53 +458,44 @@ pub async fn compute_storage_quota(state: &AppState) -> QuotaEstimate {
}
}
/// Streaming download of the original file behind an upload. Used by:
/// - the per-post "Original anzeigen" context action (`window.open`)
/// - `<img src>` / `<video src>` in the feed, lightbox, and diashow when the user is in
/// Data Mode = Original
///
/// **Auth model:** the route is intentionally unauthenticated, matching how the rest of
/// `/media/*` is served (preview + thumbnail variants). The URL contains the upload's
/// UUID, which is unguessable — same security posture as `/media/originals/{slug}/{id}`.
/// Adding `Authorization: Bearer` here would make the endpoint unusable from `<img src>`
/// and `window.open`, defeating the purpose of having the alias.
pub async fn get_original(
State(state): State<AppState>,
Path(upload_id): Path<Uuid>,
) -> Result<axum::response::Response, AppError> {
let upload = Upload::find_by_id(&state.pool, upload_id)
.await?
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
// The original-file download is now served by the authenticated, signed media
// gateway (`handlers::media::serve`). The old unauthenticated `get_original`
// alias was the H2 vulnerability and has been removed.
let absolute = state.config.media_path.join(&upload.original_path);
if !absolute.exists() {
return Err(AppError::NotFound("Datei nicht gefunden.".into()));
#[cfg(test)]
mod tests {
use super::classify;
#[test]
fn classify_accepts_allowed_image_types() {
// PNG signature
let png = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 0];
let (mime, ext, is_video) = classify(&png).expect("png allowed");
assert_eq!(mime, "image/png");
assert_eq!(ext, "png");
assert!(!is_video);
// JPEG signature
let jpeg = [0xFF, 0xD8, 0xFF, 0xE0, 0, 0, 0, 0, 0, 0, 0, 0];
let (mime, ext, _) = classify(&jpeg).expect("jpeg allowed");
assert_eq!(mime, "image/jpeg");
assert_eq!(ext, "jpg");
}
use axum::body::Body;
use axum::http::{header, Response, StatusCode};
use tokio_util::io::ReaderStream;
#[test]
fn classify_rejects_unrecognized_and_html_svg() {
// Plain HTML / script payload — infer cannot fingerprint it, so it must
// be rejected (this is the stored-XSS vector the old code let through
// via a declared `application/*` Content-Type).
let html = b"<html><script>alert(1)</script></html>";
assert!(classify(html).is_err());
let file = tokio::fs::File::open(&absolute)
.await
.map_err(|e| AppError::Internal(e.into()))?;
let metadata = file
.metadata()
.await
.map_err(|e| AppError::Internal(e.into()))?;
let stream = ReaderStream::new(file);
// SVG (text-based) is likewise unrecognized → rejected.
let svg = b"<svg xmlns=\"http://www.w3.org/2000/svg\"><script>x</script></svg>";
assert!(classify(svg).is_err());
let filename = absolute
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("original");
let disposition = format!("attachment; filename=\"{filename}\"");
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, upload.mime_type)
.header(header::CONTENT_DISPOSITION, disposition)
.header(header::CONTENT_LENGTH, metadata.len())
.body(Body::from_stream(stream))
.map_err(|e| AppError::Internal(e.into()))
// Empty / garbage.
assert!(classify(&[]).is_err());
assert!(classify(&[0x00, 0x01, 0x02, 0x03]).is_err());
}
}

View File

@@ -2,7 +2,6 @@ use anyhow::Result;
use axum::extract::DefaultBodyLimit;
use axum::routing::{delete, get, patch, post};
use axum::Router;
use tower_http::services::ServeDir;
use tower_http::trace::TraceLayer;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
@@ -46,6 +45,7 @@ async fn main() -> Result<()> {
pool,
state.rate_limiter.clone(),
state.sse_tickets.clone(),
config.media_path.clone(),
);
// Ensure media directories exist
@@ -57,17 +57,15 @@ async fn main() -> Result<()> {
.route("/api/v1/recover", post(auth::handlers::recover))
.route("/api/v1/admin/login", post(auth::handlers::admin_login))
.route("/api/v1/session", delete(auth::handlers::logout))
// Upload — body limit disabled; size validation is done inside the handler
// Upload — cap the request body so a single request can't buffer the box
// into OOM. Sized to the largest allowed video (500 MB) plus multipart
// overhead; the per-category limit is still enforced inside the handler.
.route("/api/v1/upload", post(handlers::upload::upload)
.route_layer(DefaultBodyLimit::disable()))
.route_layer(DefaultBodyLimit::max(550 * 1024 * 1024)))
.route(
"/api/v1/upload/{id}",
patch(handlers::upload::edit_upload).delete(handlers::upload::delete_upload),
)
.route(
"/api/v1/upload/{id}/original",
get(handlers::upload::get_original),
)
// Current-user endpoints (live quota estimate, profile + privacy note bundle)
.route("/api/v1/me/context", get(handlers::me::get_context))
.route("/api/v1/me/quota", get(handlers::me::get_quota))
@@ -129,14 +127,28 @@ async fn main() -> Result<()> {
api
};
// Serve media files from disk
let media_service = ServeDir::new(&config.media_path);
// Media is served exclusively through the authenticated, signed gateway —
// there is no raw static mount. The gateway verifies an HMAC signature and
// consults the DB (deleted / ban-hidden / type), so private artifacts and
// the export archives are never reachable by guessing a path.
// Trace spans log the request *path* only, never the query string — signed
// media URLs carry a replayable `?sig=` capability that must not land in
// access logs.
let trace_layer = TraceLayer::new_for_http().make_span_with(
|req: &axum::http::Request<axum::body::Body>| {
tracing::info_span!(
"request",
method = %req.method(),
path = %req.uri().path(),
)
},
);
let router = Router::new()
.route("/health", get(|| async { "ok" }))
.merge(api)
.nest_service("/media", media_service)
.layer(TraceLayer::new_for_http())
.route("/media/{kind}/{id}", get(handlers::media::serve))
.layer(trace_layer)
.with_state(state);
let listener = tokio::net::TcpListener::bind(("0.0.0.0", config.app_port)).await?;

View File

@@ -2,6 +2,8 @@ use chrono::{DateTime, Utc};
use sqlx::PgPool;
use uuid::Uuid;
use crate::models::user::UserRole;
#[derive(Debug, sqlx::FromRow)]
pub struct Session {
pub id: Uuid,
@@ -12,6 +14,18 @@ pub struct Session {
pub created_at: DateTime<Utc>,
}
/// Live identity reconciled from the DB for a valid, unexpired session. Used by
/// the `AuthUser` extractor so role/ban/event are sourced from the `user` row
/// rather than trusted from (potentially stale) JWT claims.
#[derive(Debug, sqlx::FromRow)]
pub struct AuthContext {
pub session_id: Uuid,
pub user_id: Uuid,
pub event_id: Uuid,
pub role: UserRole,
pub is_banned: bool,
}
impl Session {
pub async fn create(
pool: &PgPool,
@@ -43,6 +57,25 @@ impl Session {
.await
}
/// Reconcile a session against the live `user` row in a single round-trip.
/// Returns `None` when the session is missing/expired. The PK join to
/// `"user"` is cheap and lets the extractor read the *current* role/ban
/// state instead of the JWT claim.
pub async fn find_auth_context(
pool: &PgPool,
token_hash: &str,
) -> Result<Option<AuthContext>, sqlx::Error> {
sqlx::query_as::<_, AuthContext>(
"SELECT s.id AS session_id, u.id AS user_id, u.event_id, u.role, u.is_banned
FROM session s
JOIN \"user\" u ON u.id = s.user_id
WHERE s.token_hash = $1 AND s.expires_at > NOW()",
)
.bind(token_hash)
.fetch_optional(pool)
.await
}
pub async fn touch(pool: &PgPool, id: Uuid) -> Result<(), sqlx::Error> {
sqlx::query("UPDATE session SET last_seen_at = NOW() WHERE id = $1")
.bind(id)
@@ -61,4 +94,15 @@ impl Session {
.await?;
Ok(())
}
/// Revoke every session belonging to a user — used on ban, role change, and
/// PIN reset so existing JWTs stop working immediately. Returns the number
/// of sessions removed. Best-effort: callers log but do not fail on error.
pub async fn delete_by_user_id(pool: &PgPool, user_id: Uuid) -> Result<u64, sqlx::Error> {
let result = sqlx::query("DELETE FROM session WHERE user_id = $1")
.bind(user_id)
.execute(pool)
.await?;
Ok(result.rows_affected())
}
}

View File

@@ -19,6 +19,15 @@ pub struct Upload {
pub deleted_at: Option<DateTime<Utc>>,
}
/// On-disk artifact paths returned by the soft-delete methods so the caller can
/// unlink the files after the DB commit.
#[derive(Debug, sqlx::FromRow)]
pub struct DeletedPaths {
pub original: String,
pub preview: Option<String>,
pub thumbnail: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct UploadDto {
pub id: Uuid,
@@ -26,6 +35,8 @@ pub struct UploadDto {
pub uploader_name: String,
pub preview_url: Option<String>,
pub thumbnail_url: Option<String>,
/// Signed gateway URL for the full-resolution original. Always present.
pub original_url: Option<String>,
pub mime_type: String,
pub caption: Option<String>,
pub hashtags: Vec<String>,
@@ -36,15 +47,21 @@ pub struct UploadDto {
}
impl Upload {
pub async fn create(
pool: &PgPool,
/// Generic over the executor so it can run inside the upload transaction
/// (`&mut *tx`) alongside the quota-counter reservation, keeping the
/// byte-counter and the row atomic (no quota leak on a mid-write crash).
pub async fn create<'e, E>(
executor: E,
event_id: Uuid,
user_id: Uuid,
original_path: &str,
mime_type: &str,
original_size_bytes: i64,
caption: Option<&str>,
) -> Result<Self, sqlx::Error> {
) -> Result<Self, sqlx::Error>
where
E: sqlx::PgExecutor<'e>,
{
sqlx::query_as::<_, Self>(
"INSERT INTO upload (event_id, user_id, original_path, mime_type, original_size_bytes, caption)
VALUES ($1, $2, $3, $4, $5, $6)
@@ -56,7 +73,7 @@ impl Upload {
.bind(mime_type)
.bind(original_size_bytes)
.bind(caption)
.fetch_one(pool)
.fetch_one(executor)
.await
}
@@ -119,18 +136,21 @@ impl Upload {
///
/// No-op if the row is already deleted — protects against a double-tap on the
/// delete action double-decrementing the counter.
pub async fn soft_delete(pool: &PgPool, id: Uuid) -> Result<(), sqlx::Error> {
///
/// Returns the artifact paths of the row that was deleted (`None` if nothing
/// matched) so the caller can unlink the files after the commit.
pub async fn soft_delete(pool: &PgPool, id: Uuid) -> Result<Option<DeletedPaths>, sqlx::Error> {
let mut tx = pool.begin().await?;
let row: Option<(Uuid, i64)> = sqlx::query_as(
let row: Option<(Uuid, i64, String, Option<String>, Option<String>)> = sqlx::query_as(
"UPDATE upload
SET deleted_at = NOW()
WHERE id = $1 AND deleted_at IS NULL
RETURNING user_id, original_size_bytes",
RETURNING user_id, original_size_bytes, original_path, preview_path, thumbnail_path",
)
.bind(id)
.fetch_optional(&mut *tx)
.await?;
if let Some((user_id, bytes)) = row {
let paths = if let Some((user_id, bytes, original, preview, thumbnail)) = row {
sqlx::query(
"UPDATE \"user\"
SET total_upload_bytes = GREATEST(0, total_upload_bytes - $2)
@@ -140,31 +160,34 @@ impl Upload {
.bind(bytes)
.execute(&mut *tx)
.await?;
}
Some(DeletedPaths { original, preview, thumbnail })
} else {
None
};
tx.commit().await?;
Ok(())
Ok(paths)
}
/// Event-scoped variant of [`Self::soft_delete`]. Returns `false` if no row
/// Event-scoped variant of [`Self::soft_delete`]. Returns `None` if no row
/// matched (already deleted, wrong event, or unknown id) so host handlers
/// can return a clean 404 instead of silently no-op'ing.
/// can return a clean 404, and the artifact paths otherwise.
pub async fn soft_delete_in_event(
pool: &PgPool,
id: Uuid,
event_id: Uuid,
) -> Result<bool, sqlx::Error> {
) -> Result<Option<DeletedPaths>, sqlx::Error> {
let mut tx = pool.begin().await?;
let row: Option<(Uuid, i64)> = sqlx::query_as(
let row: Option<(Uuid, i64, String, Option<String>, Option<String>)> = sqlx::query_as(
"UPDATE upload
SET deleted_at = NOW()
WHERE id = $1 AND event_id = $2 AND deleted_at IS NULL
RETURNING user_id, original_size_bytes",
RETURNING user_id, original_size_bytes, original_path, preview_path, thumbnail_path",
)
.bind(id)
.bind(event_id)
.fetch_optional(&mut *tx)
.await?;
let deleted = if let Some((user_id, bytes)) = row {
let paths = if let Some((user_id, bytes, original, preview, thumbnail)) = row {
sqlx::query(
"UPDATE \"user\"
SET total_upload_bytes = GREATEST(0, total_upload_bytes - $2)
@@ -174,12 +197,12 @@ impl Upload {
.bind(bytes)
.execute(&mut *tx)
.await?;
true
Some(DeletedPaths { original, preview, thumbnail })
} else {
false
None
};
tx.commit().await?;
Ok(deleted)
Ok(paths)
}
pub async fn update_caption(

View File

@@ -11,16 +11,27 @@ use crate::state::SseEvent;
#[derive(Clone)]
pub struct CompressionWorker {
semaphore: Arc<Semaphore>,
/// Separate permit pools for images and videos so a couple of slow/large
/// videos (each holding a permit across the full ffmpeg wait) can never
/// starve image-preview generation, and vice versa.
image_sem: Arc<Semaphore>,
video_sem: Arc<Semaphore>,
pool: PgPool,
media_path: PathBuf,
sse_tx: broadcast::Sender<SseEvent>,
}
impl CompressionWorker {
pub fn new(pool: PgPool, media_path: PathBuf, concurrency: usize, sse_tx: broadcast::Sender<SseEvent>) -> Self {
pub fn new(
pool: PgPool,
media_path: PathBuf,
image_concurrency: usize,
video_concurrency: usize,
sse_tx: broadcast::Sender<SseEvent>,
) -> Self {
Self {
semaphore: Arc::new(Semaphore::new(concurrency)),
image_sem: Arc::new(Semaphore::new(image_concurrency)),
video_sem: Arc::new(Semaphore::new(video_concurrency)),
pool,
media_path,
sse_tx,
@@ -31,7 +42,9 @@ impl CompressionWorker {
pub fn process(&self, upload_id: Uuid, original_path: String, mime_type: String) {
let worker = self.clone();
tokio::spawn(async move {
let _permit = worker.semaphore.acquire().await;
let is_video = mime_type.starts_with("video/");
let sem = if is_video { &worker.video_sem } else { &worker.image_sem };
let _permit = sem.acquire().await;
match worker.do_process(upload_id, &original_path, &mime_type).await {
Ok(_) => {
tracing::info!("compression completed for upload {upload_id}");
@@ -41,10 +54,16 @@ impl CompressionWorker {
});
}
Err(e) => {
// Log the detailed error (incl. paths) server-side only; the
// SSE channel is broadcast to every client, so send a generic
// message — never leak absolute filesystem paths.
tracing::error!("compression failed for upload {upload_id}: {e:#}");
let _ = worker.sse_tx.send(SseEvent {
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": "Verarbeitung fehlgeschlagen."
}).to_string(),
});
let _ = Upload::set_compression_status(&worker.pool, upload_id, "failed").await;
}
@@ -91,10 +110,25 @@ impl CompressionWorker {
let preview_path_clone = preview_path.clone();
let mime_owned = mime_type.to_string();
// Run blocking image operations in a spawn_blocking task
tokio::task::spawn_blocking(move || -> Result<()> {
let img = image::open(&original)
.context("failed to open image")?;
// Run blocking image operations in a spawn_blocking task, bounded by a
// hard timeout (mirrors the ffmpeg guard) so a pathological decode can't
// hold the permit forever.
let handle = tokio::task::spawn_blocking(move || -> Result<()> {
use image::ImageReader;
// Reject decompression bombs *before* fully decoding: a small file can
// otherwise expand to enormous dimensions and a very expensive resize.
// 12000×12000 covers any real phone photo; max_alloc caps memory.
let mut reader = ImageReader::open(&original)
.context("failed to open image")?
.with_guessed_format()
.context("failed to read image header")?;
let mut limits = image::Limits::default();
limits.max_image_width = Some(12_000);
limits.max_image_height = Some(12_000);
limits.max_alloc = Some(256 * 1024 * 1024);
reader.limits(limits);
let img = reader.decode().context("failed to decode image")?;
// Resize to max 800px wide, preserving aspect ratio
let preview = img.resize(800, 800, image::imageops::FilterType::Lanczos3);
@@ -115,8 +149,12 @@ impl CompressionWorker {
}
Ok(())
})
.await??;
});
match tokio::time::timeout(std::time::Duration::from_secs(120), handle).await {
Ok(join) => join.context("image task panicked")??,
Err(_) => anyhow::bail!("image processing timeout after 120s"),
}
Ok(format!("previews/{preview_filename}"))
}

View File

@@ -94,8 +94,14 @@ pub fn spawn_export_jobs(
let sse_tx2 = sse_tx.clone();
let event_name2 = event_name.clone();
// Per-run id so two runs (e.g. a re-release after a crash mid-export, where
// startup_recovery marked the old run 'failed' but its task may still be
// winding down) write to distinct temp paths and can't truncate each other's
// output (M8). The final served filenames stay fixed.
let run_id = Uuid::new_v4();
tokio::spawn(async move {
if let Err(e) = run_zip_export(event_id, &pool, &media_path, &sse_tx).await {
if let Err(e) = run_zip_export(event_id, run_id, &pool, &media_path, &sse_tx).await {
tracing::error!("ZIP export failed for event {event_id}: {e:#}");
mark_failed(&pool, event_id, "zip", &e.to_string()).await;
}
@@ -104,7 +110,7 @@ pub fn spawn_export_jobs(
tokio::spawn(async move {
if let Err(e) =
run_html_export(event_id, &event_name2, &pool2, &media_path2, &sse_tx2).await
run_html_export(event_id, run_id, &event_name2, &pool2, &media_path2, &sse_tx2).await
{
tracing::error!("HTML export failed for event {event_id}: {e:#}");
mark_failed(&pool2, event_id, "html", &e.to_string()).await;
@@ -117,6 +123,7 @@ pub fn spawn_export_jobs(
async fn run_zip_export(
event_id: Uuid,
run_id: Uuid,
pool: &PgPool,
media_path: &Path,
sse_tx: &broadcast::Sender<SseEvent>,
@@ -129,7 +136,8 @@ async fn run_zip_export(
let exports_dir = media_path.join("exports");
tokio::fs::create_dir_all(&exports_dir).await?;
let tmp_path = exports_dir.join("Gallery.zip.tmp");
// Per-run temp name; final served name stays fixed.
let tmp_path = exports_dir.join(format!("Gallery.{run_id}.zip.tmp"));
let out_path = exports_dir.join("Gallery.zip");
{
@@ -190,6 +198,7 @@ async fn run_zip_export(
async fn run_html_export(
event_id: Uuid,
run_id: Uuid,
event_name: &str,
pool: &PgPool,
media_path: &Path,
@@ -208,8 +217,8 @@ async fn run_html_export(
let exports_dir = media_path.join("exports");
tokio::fs::create_dir_all(&exports_dir).await?;
// 2. Create temp directory for media processing
let tmp_dir = exports_dir.join(format!("viewer_tmp_{event_id}"));
// 2. Create temp directory for media processing (per-run, see run_id).
let tmp_dir = exports_dir.join(format!("viewer_tmp_{event_id}_{run_id}"));
let media_tmp = tmp_dir.join("media");
tokio::fs::create_dir_all(&media_tmp).await?;
@@ -370,8 +379,8 @@ async fn run_html_export(
update_progress(pool, event_id, "html", 72).await;
// 5. Create ZIP
let tmp_path = exports_dir.join("Memories.zip.tmp");
// 5. Create ZIP (per-run temp name; final served name stays fixed).
let tmp_path = exports_dir.join(format!("Memories.{run_id}.zip.tmp"));
let out_path = exports_dir.join("Memories.zip");
{

View File

@@ -13,6 +13,7 @@
//! rate-limiter's in-memory windows (so keys for IPs that left long ago don't
//! accumulate).
use std::path::PathBuf;
use std::time::Duration;
use sqlx::PgPool;
@@ -76,6 +77,7 @@ pub fn spawn_periodic_tasks(
pool: PgPool,
rate_limiter: RateLimiter,
sse_tickets: SseTicketStore,
media_path: PathBuf,
) {
tokio::spawn(async move {
let mut tick = tokio::time::interval(Duration::from_secs(3600));
@@ -86,6 +88,9 @@ pub fn spawn_periodic_tasks(
cleanup_sessions(&pool).await;
rate_limiter.prune();
sse_tickets.prune();
// Reclaim disk for uploads soft-deleted more than the grace period
// ago, and hard-delete those rows (FKs cascade).
crate::services::media_fs::reap_deleted(&pool, &media_path).await;
}
});
}

View File

@@ -0,0 +1,83 @@
//! Filesystem lifecycle for media artifacts.
//!
//! The DB-aware gateway hides deleted/hidden uploads, but the bytes still sit on
//! a fixed-size disk until something removes them. This module is that
//! something: best-effort unlinking on delete, plus a periodic reaper that
//! sweeps files belonging to soft-deleted rows (catching anything an in-process
//! unlink missed — e.g. a crash between the DB commit and the unlink).
use std::path::Path;
use sqlx::PgPool;
use crate::models::upload::DeletedPaths;
/// Best-effort removal of an upload's three on-disk artifacts. A missing file is
/// not an error (it may already be gone, or never existed for videos without a
/// preview); anything else is logged but never propagated — losing the bytes
/// must not fail the user's delete.
pub async fn unlink_media(media_path: &Path, paths: &DeletedPaths) {
let candidates = [
Some(&paths.original),
paths.preview.as_ref(),
paths.thumbnail.as_ref(),
];
for rel in candidates.into_iter().flatten() {
let abs = media_path.join(rel);
match tokio::fs::remove_file(&abs).await {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => tracing::warn!(path = %rel, error = ?e, "media unlink failed"),
}
}
}
/// Reaper: remove on-disk files for uploads that were soft-deleted more than
/// `grace` ago, then hard-delete those rows so they don't accumulate. Strictly
/// DB-row-driven — it never walks the filesystem, so it can never remove a file
/// belonging to a live upload.
pub async fn reap_deleted(pool: &PgPool, media_path: &Path) {
// Snapshot the exact rows (id + paths) we are about to unlink, and delete by
// those ids — NOT by re-evaluating the `deleted_at < now - 1 day` predicate.
// A row that crosses the 1-day line *during* the unlink loop would otherwise
// be hard-deleted by the second predicate without ever having its files
// unlinked → a permanent orphan.
let rows: Vec<(uuid::Uuid, String, Option<String>, Option<String>)> = match sqlx::query_as(
"SELECT id, original_path, preview_path, thumbnail_path
FROM upload
WHERE deleted_at IS NOT NULL AND deleted_at < NOW() - INTERVAL '1 day'",
)
.fetch_all(pool)
.await
{
Ok(rows) => rows,
Err(e) => {
tracing::warn!(error = ?e, "media reaper: query failed");
return;
}
};
if rows.is_empty() {
return;
}
let mut ids = Vec::with_capacity(rows.len());
for (id, original, preview, thumbnail) in &rows {
unlink_media(media_path, &DeletedPaths {
original: original.clone(),
preview: preview.clone(),
thumbnail: thumbnail.clone(),
})
.await;
ids.push(*id);
}
match sqlx::query("DELETE FROM upload WHERE id = ANY($1)")
.bind(&ids)
.execute(pool)
.await
{
Ok(r) => tracing::info!("media reaper: removed {} deleted upload(s)", r.rows_affected()),
Err(e) => tracing::warn!(error = ?e, "media reaper: row delete failed"),
}
}

View File

@@ -0,0 +1,122 @@
//! Stateless signed URLs for the authenticated media gateway.
//!
//! Media (`<img>`/`<video>` sources) cannot carry an `Authorization` header, so
//! access is granted by an HMAC-SHA256 signature embedded in the URL. The
//! feed/upload DTOs are serialized for an already-authenticated event member, so
//! that is where fresh signatures are minted; the gateway handler verifies them
//! without any DB/session state.
//!
//! HMAC is implemented over the in-tree `sha2` crate (no new dependency). The
//! key is the app's `jwt_secret`.
use sha2::{Digest, Sha256};
use uuid::Uuid;
/// Validity window of a signed URL.
const TTL_SECS: i64 = 24 * 3600;
/// Issue-time bucket. Expiry (and therefore the URL) is stable within this
/// window so the browser caches image bytes across feed polls instead of
/// re-downloading on every refresh.
const BUCKET_SECS: i64 = 3600;
fn hmac_sha256(key: &[u8], msg: &[u8]) -> [u8; 32] {
const BLOCK: usize = 64;
let mut key_block = [0u8; BLOCK];
if key.len() > BLOCK {
let digest = Sha256::digest(key);
key_block[..32].copy_from_slice(&digest);
} else {
key_block[..key.len()].copy_from_slice(key);
}
let mut ipad = [0x36u8; BLOCK];
let mut opad = [0x5cu8; BLOCK];
for i in 0..BLOCK {
ipad[i] ^= key_block[i];
opad[i] ^= key_block[i];
}
let mut inner = Sha256::new();
inner.update(ipad);
inner.update(msg);
let inner_digest = inner.finalize();
let mut outer = Sha256::new();
outer.update(opad);
outer.update(inner_digest);
outer.finalize().into()
}
fn sign(secret: &str, kind: &str, id: Uuid, exp: i64) -> String {
let msg = format!("{kind}:{id}:{exp}");
let mac = hmac_sha256(secret.as_bytes(), msg.as_bytes());
let mut hex = String::with_capacity(64);
for b in mac {
use std::fmt::Write;
let _ = write!(hex, "{b:02x}");
}
hex
}
/// Build a signed, time-boxed gateway URL for one of an upload's artifacts.
/// `kind` is `original` | `preview` | `thumbnail`.
pub fn signed_url(secret: &str, kind: &str, id: Uuid, now: i64) -> String {
let exp = ((now / BUCKET_SECS) * BUCKET_SECS) + TTL_SECS;
let sig = sign(secret, kind, id, exp);
format!("/media/{kind}/{id}?exp={exp}&sig={sig}")
}
/// Verify a signed URL: signature must match and the expiry must not have
/// passed. Signature comparison is constant-time.
pub fn verify(secret: &str, kind: &str, id: Uuid, exp: i64, sig: &str, now: i64) -> bool {
if exp < now {
return false;
}
let expected = sign(secret, kind, id, exp);
constant_time_eq(expected.as_bytes(), sig.as_bytes())
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff = 0u8;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
diff == 0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sign_verify_roundtrip() {
let secret = "test_secret_at_least_32_chars_long_xxxx";
let id = Uuid::new_v4();
let now = 1_700_000_000;
let url = signed_url(secret, "original", id, now);
// Extract exp + sig from the query string.
let (_, query) = url.split_once('?').unwrap();
let mut exp = 0i64;
let mut sig = String::new();
for pair in query.split('&') {
let (k, v) = pair.split_once('=').unwrap();
match k {
"exp" => exp = v.parse().unwrap(),
"sig" => sig = v.to_string(),
_ => {}
}
}
assert!(verify(secret, "original", id, exp, &sig, now));
// Tampered kind / id / sig must fail.
assert!(!verify(secret, "preview", id, exp, &sig, now));
assert!(!verify(secret, "original", Uuid::new_v4(), exp, &sig, now));
assert!(!verify(secret, "original", id, exp, "deadbeef", now));
// Wrong secret must fail.
assert!(!verify("other_secret_at_least_32_chars_long_yy", "original", id, exp, &sig, now));
// Expired must fail.
assert!(!verify(secret, "original", id, exp, &sig, exp + 1));
}
}

View File

@@ -3,5 +3,8 @@ pub mod config;
pub mod export;
pub mod jobs;
pub mod maintenance;
pub mod media_fs;
pub mod media_token;
pub mod password;
pub mod rate_limiter;
pub mod sse_tickets;

View File

@@ -0,0 +1,25 @@
//! bcrypt hashing/verification offloaded to the blocking pool.
//!
//! bcrypt cost-12 is ~250400ms of synchronous CPU. Called directly in an async
//! handler it pins a Tokio worker for that whole time, so a handful of
//! concurrent joins/recovers/admin-logins can starve every other request
//! (feed, SSE, upload). Routing the work through `spawn_blocking` keeps the
//! async reactor responsive.
use crate::error::AppError;
/// Hash a secret with the given bcrypt cost, off the async reactor.
pub async fn hash(plain: String, cost: u32) -> Result<String, AppError> {
tokio::task::spawn_blocking(move || bcrypt::hash(&plain, cost))
.await
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))
}
/// Verify a secret against a bcrypt hash, off the async reactor. Returns `false`
/// on any error (mirrors the previous `unwrap_or(false)` fail-closed behavior).
pub async fn verify(plain: String, hash: String) -> bool {
tokio::task::spawn_blocking(move || bcrypt::verify(&plain, &hash).unwrap_or(false))
.await
.unwrap_or(false)
}

View File

@@ -71,13 +71,55 @@ impl RateLimiter {
}
}
/// Extract the client IP from X-Forwarded-For (Caddy sets this) or fall back
/// to a provided socket address string.
/// Extract the client IP for rate-limit keys.
///
/// Prefers `X-Real-IP`, which our reverse proxy (Caddy) overwrites with the real
/// TCP peer (`header_up X-Real-IP {remote_host}`) — a client cannot spoof it.
/// Falls back to the **rightmost** `X-Forwarded-For` token (the entry the proxy
/// appended), never the leftmost: the leftmost is fully client-controlled, and
/// trusting it let an attacker rotate the key to bypass the join cap, the
/// recover throttle, and the admin-login floor.
pub fn client_ip(headers: &axum::http::HeaderMap, fallback: &str) -> String {
if let Some(ip) = headers
.get("x-real-ip")
.and_then(|v| v.to_str().ok())
.map(|s| s.trim())
.filter(|s| !s.is_empty())
{
return ip.to_owned();
}
headers
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.split(',').next())
.and_then(|s| s.split(',').next_back())
.map(|s| s.trim().to_owned())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| fallback.to_owned())
}
#[cfg(test)]
mod tests {
use super::client_ip;
use axum::http::HeaderMap;
#[test]
fn prefers_x_real_ip() {
let mut h = HeaderMap::new();
h.insert("x-real-ip", "9.9.9.9".parse().unwrap());
h.insert("x-forwarded-for", "1.1.1.1, 9.9.9.9".parse().unwrap());
assert_eq!(client_ip(&h, "fb"), "9.9.9.9");
}
#[test]
fn xff_uses_rightmost_not_spoofable_leftmost() {
// Attacker prepends a forged entry; the proxy-appended real peer is last.
let mut h = HeaderMap::new();
h.insert("x-forwarded-for", "1.2.3.4, 203.0.113.7".parse().unwrap());
assert_eq!(client_ip(&h, "fb"), "203.0.113.7");
}
#[test]
fn falls_back_when_no_headers() {
assert_eq!(client_ip(&HeaderMap::new(), "fb"), "fb");
}
}

View File

@@ -1,11 +1,20 @@
use std::sync::Arc;
use sqlx::PgPool;
use tokio::sync::broadcast;
use tokio::sync::{broadcast, Semaphore};
use crate::config::AppConfig;
use crate::services::compression::CompressionWorker;
use crate::services::rate_limiter::RateLimiter;
use crate::services::sse_tickets::SseTicketStore;
/// Max concurrent in-flight `/upload` requests. Each holds its whole file in
/// memory while reading the multipart body, so this bounds aggregate upload RAM
/// to ~N × the 550 MB body cap (≈2.2 GB here) — keeping headroom for Postgres +
/// the app on an 8 GB box. The per-user rate limiter is a *count* limiter
/// (10/hr), not a concurrency cap, so this is the actual OOM guard. Tunable.
pub const UPLOAD_MAX_CONCURRENCY: usize = 4;
#[derive(Clone, Debug)]
pub struct SseEvent {
pub event_type: String,
@@ -31,13 +40,21 @@ pub struct AppState {
pub compression: CompressionWorker,
pub rate_limiter: RateLimiter,
pub sse_tickets: SseTicketStore,
/// Caps concurrent upload-body buffering (see UPLOAD_MAX_CONCURRENCY).
pub upload_limiter: Arc<Semaphore>,
}
impl AppState {
pub fn new(pool: PgPool, config: AppConfig) -> Self {
let (sse_tx, _) = broadcast::channel(256);
let compression =
CompressionWorker::new(pool.clone(), config.media_path.clone(), 2, sse_tx.clone());
// Independent image (2) and video (2) permit pools — see CompressionWorker.
let compression = CompressionWorker::new(
pool.clone(),
config.media_path.clone(),
2,
2,
sse_tx.clone(),
);
Self {
pool,
config,
@@ -45,6 +62,7 @@ impl AppState {
compression,
rate_limiter: RateLimiter::new(),
sse_tickets: SseTicketStore::new(),
upload_limiter: Arc::new(Semaphore::new(UPLOAD_MAX_CONCURRENCY)),
}
}
}

View File

@@ -1,4 +1,8 @@
# Auto-merged by `docker compose up`. Exposes Postgres for LOCAL tooling only —
# bound to 127.0.0.1 so that even when this committed file lands on the prod host
# the DB is NOT reachable on the public IP (a `0.0.0.0` publish would also bypass
# ufw). Never widen this to 0.0.0.0.
services:
db:
ports:
- "5432:5432"
- "127.0.0.1:5432:5432"

View File

@@ -21,6 +21,13 @@ services:
dockerfile: Dockerfile
restart: unless-stopped
env_file: .env
# Force production mode here (not via .env, which is easy to forget) so the
# backend's JWT-secret guard actually fires on this deployment.
environment:
APP_ENV: production
# Backstop the in-app upload RAM caps: if anything slips the per-request
# bounds, the container is OOM-killed instead of the 8 GB host.
mem_limit: 3g
depends_on:
db:
condition: service_healthy
@@ -28,6 +35,13 @@ services:
- media_data:/media
expose:
- "3000"
# /health is unauthenticated; start_period covers the boot-time migrations.
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost:3000/health"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
frontend:
build:
@@ -36,9 +50,16 @@ services:
restart: unless-stopped
env_file: .env
depends_on:
- app
app:
condition: service_healthy
expose:
- "3001"
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost:3001/"]
interval: 10s
timeout: 5s
retries: 5
start_period: 15s
caddy:
image: caddy:2-alpine
@@ -49,9 +70,13 @@ services:
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
# Gate on readiness so Caddy doesn't proxy to a not-yet-listening upstream
# (which otherwise shows brief 502s on boot/restart).
depends_on:
- app
- frontend
app:
condition: service_healthy
frontend:
condition: service_healthy
volumes:
postgres_data:

51
docs/SECURITY-BACKLOG.md Normal file
View File

@@ -0,0 +1,51 @@
# Security & Hardening Backlog
Tracks the deliberately-deferred items from the 2026-06-27 audit and its review passes. The
Critical→Medium findings and the cheap "bucket 🅰" LOWs are fixed on
`fix/audit-2026-06-27-critical-medium`. What remains is recorded here so the decisions are
explicit, not forgotten.
## 🅱 Worth a tracked ticket (real, not one-liners)
- **Moderation UI gap** — the backend `DELETE /host/upload/{id}` and `DELETE /host/comment/{id}`
endpoints have **no frontend caller**, so a host cannot remove a guest's content from the UI.
This is a functional hole, not polish. Needs a host-facing "remove" action wired to those
endpoints (a `ContextSheet` action gated on host role).
- **Feed reactivity** — a single global SSE event (`like-update`/`new-comment`/`upload-processed`)
triggers `loadFeed(true)`, which full-replaces the list with the first 20, collapsing a
scrolled feed; and owner-deleted uploads aren't broadcast to other clients (only host deletes
are). Patch the affected item from the SSE payload instead of full-reloading; emit
`upload-deleted` from the owner `delete_upload` path too.
- **Media HMAC domain separation** — the signed-media tokens reuse `jwt_secret` as the HMAC key.
Correct today (different message structure), but a dedicated derived key
(`HKDF(jwt_secret, "media-url")`) would isolate the domains so a future change to one can't
weaken the other.
- **Quota mount-detection + low-disk guard** — `compute_storage_quota` picks the disk via
`starts_with` (root is a wildcard prefix → can match the wrong device) and there's no hard
min-free-space precheck when the quota is disabled. Use longest-prefix match; add an
unconditional 507/429 when free space is critically low.
## 🅲 Consciously won't-fix at ~100-guest single-box scale
Diminishing returns vs. the deployment's actual threat model. Revisit only if the scale or
tenancy model changes.
- Rate-limiter `HashMap` key LRU/cap (attacker-chosen `recover:{ip}:{name}` keys accumulate up to
the 24h prune ceiling) — bounded and pruned; not worth an LRU.
- "Last host" / host↔host role-churn guard — operational, low blast radius.
- Performance micro-indexes (`idx_like_user_upload`, comment pagination index) — current queries
are sub-ms at this row count.
- Optimistic-like in-flight guard, ownership-snapshot-at-mount, assorted copy tweaks — UX polish.
## By-design notes (documented, not bugs)
- **Banned user retains ≤24h media access via already-held signed URLs.** The media gateway
authorizes by *signature + uploader visibility*, not requester identity, and signed URLs are
time-boxed (24h, bucketed). A banned/revoked-session user cannot mint new URLs (every API call
401/403s) but can replay URLs they already hold until expiry — only for content they already
saw. Accepted: tightening this would require per-request identity on every `<img>` load, which
the `<img>`-can't-send-a-Bearer constraint precludes. Shorten the TTL in `media_token.rs` if a
stricter bound is ever needed.
- **Two DB queries per *cold* media fetch** (`upload` row + uploader row). Mitigated by browser
caching and the stable bucketed URL (so warm fetches don't hit the backend at all). Could be one
JOIN if it ever shows up in profiling.

View File

@@ -16,6 +16,10 @@ COPY --from=builder /app/build ./build
COPY --from=builder /app/package.json ./
RUN npm install --omit=dev
# Run as the image's built-in unprivileged `node` user (defense-in-depth). The
# adapter-node server only reads from /app, so no chown is needed.
USER node
EXPOSE 3001
ENV PORT=3001 HOST=0.0.0.0
CMD ["node", "build"]

View File

@@ -1 +1,18 @@
@import "./tailwind-theme.css";
/*
* Respect the OS "reduce motion" setting (WCAG 2.3.3 / 2.2.2). Neutralizes the
* decorative animations — Ken Burns zoom, slideshow crossfade, HeartBurst — and
* snaps transitions, which is a vestibular/migraine safeguard especially for the
* big-screen diashow. Auto-advance timing is additionally slowed in JS.
*/
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}

View File

@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="text-scale" content="scale" />
<meta name="theme-color" content="#ffffff" />
<!--

View File

@@ -1,3 +1,5 @@
import { goto } from '$app/navigation';
import { browser } from '$app/environment';
import { getToken, clearAuth } from './auth';
const BASE = '/api/v1';
@@ -42,6 +44,11 @@ async function request<T>(
if (!res.ok) {
if (res.status === 401) {
clearAuth();
// Don't strand the user on a now-unauthenticated page with no nav —
// send them to /join. Guard against loops on the auth screens.
if (browser && !/^\/(join|recover|admin\/login)/.test(window.location.pathname)) {
void goto('/join');
}
}
throw new ApiError(res.status, data.error ?? 'unknown', data.message ?? 'Fehler');
}

View File

@@ -3,6 +3,7 @@
import { page } from '$app/stores';
import { uploadSheetOpen, uploadBadgeCount } from '$lib/ui-store';
import { exportStatus, initExportStatus } from '$lib/export-status-store';
import { uploadsLocked } from '$lib/event-state-store';
function isActive(path: string): boolean {
return $page.url.pathname.startsWith(path);
@@ -43,8 +44,9 @@
<div class="relative -translate-y-3">
<button
onclick={() => ($uploadSheetOpen = true)}
class="relative flex h-14 w-14 items-center justify-center rounded-full bg-blue-600 text-white shadow-lg transition active:scale-95 hover:bg-blue-700"
aria-label="Hochladen"
disabled={$uploadsLocked}
class="relative flex h-14 w-14 items-center justify-center rounded-full bg-blue-600 text-white shadow-lg transition active:scale-95 hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-blue-600 disabled:active:scale-100"
aria-label={$uploadsLocked ? 'Uploads gesperrt' : 'Hochladen'}
>
<!-- Camera + plus icon -->
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">

View File

@@ -24,7 +24,7 @@
function tileUrl(upload: FeedUpload): string {
if (upload.thumbnail_url) return upload.thumbnail_url;
if (upload.preview_url) return upload.preview_url;
return $dataMode === 'original' ? `/api/v1/upload/${upload.id}/original` : '';
return $dataMode === 'original' ? (upload.original_url ?? '') : '';
}
</script>
@@ -67,7 +67,9 @@
<p class="truncate text-xs font-medium text-white">{upload.uploader_name}</p>
<div class="mt-0.5 flex items-center gap-3 text-xs text-white/80">
<button
class="pointer-events-auto flex items-center gap-0.5"
class="pointer-events-auto -m-2 flex min-h-11 min-w-11 items-center justify-center gap-0.5 p-2"
aria-pressed={upload.liked_by_me}
aria-label="Gefällt mir ({upload.like_count})"
onclick={(e) => { e.stopPropagation(); onlike(upload.id); }}
>
<svg class="h-3.5 w-3.5 {upload.liked_by_me ? 'fill-red-400 text-red-400' : ''}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -76,7 +78,8 @@
{upload.like_count}
</button>
<button
class="pointer-events-auto flex items-center gap-0.5"
class="pointer-events-auto -m-2 flex min-h-11 min-w-11 items-center justify-center gap-0.5 p-2"
aria-label="Kommentare ({upload.comment_count})"
onclick={(e) => { e.stopPropagation(); oncomment(upload.id); }}
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">

View File

@@ -100,7 +100,7 @@
</div>
<div class="min-w-0">
<p class="truncate text-sm font-semibold text-gray-900 dark:text-gray-100">{upload.uploader_name}</p>
<p class="text-xs text-gray-400 dark:text-gray-500">{relativeTime(upload.created_at)}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">{relativeTime(upload.created_at)}</p>
</div>
</div>
@@ -164,6 +164,8 @@
<div class="flex items-center gap-4 px-4 py-2">
<button
onclick={() => { vibrate(10); onlike(upload.id); }}
aria-pressed={upload.liked_by_me}
aria-label="Gefällt mir ({upload.like_count})"
class="flex items-center gap-1.5 text-sm font-medium transition-colors
{upload.liked_by_me ? 'text-red-500 dark:text-red-400' : 'text-gray-500 hover:text-red-400 active:text-red-400 dark:text-gray-400 dark:hover:text-red-400 dark:active:text-red-400'}"
>
@@ -188,7 +190,7 @@
{upload.comment_count}
</button>
{#if isOwn}
<span class="ml-auto text-xs text-gray-400 dark:text-gray-500">Eigener Beitrag</span>
<span class="ml-auto text-xs text-gray-500 dark:text-gray-400">Eigener Beitrag</span>
{/if}
</div>

View File

@@ -59,7 +59,7 @@
if (!newComment.trim()) return;
loading = true;
try {
const comment = await api.post<CommentDto>(`/upload/${upload.id}/comment`, {
const comment = await api.post<CommentDto>(`/upload/${upload.id}/comments`, {
body: newComment.trim()
});
comments = [...comments, comment];
@@ -141,10 +141,12 @@
<div class="flex items-center justify-between">
<div>
<span id="lightbox-title" class="font-medium text-gray-900 dark:text-gray-100">{upload.uploader_name}</span>
<span class="ml-2 text-xs text-gray-400 dark:text-gray-500">{formatTime(upload.created_at)}</span>
<span class="ml-2 text-xs text-gray-500 dark:text-gray-400">{formatTime(upload.created_at)}</span>
</div>
<button
onclick={() => onlike(upload.id)}
aria-pressed={upload.liked_by_me}
aria-label="Gefällt mir ({upload.like_count})"
class="flex items-center gap-1 rounded-full px-2.5 py-1 text-sm transition {
upload.liked_by_me
? 'bg-red-50 text-red-600 dark:bg-red-950/40 dark:text-red-300'
@@ -165,7 +167,7 @@
<!-- Comments list -->
<div class="flex-1 overflow-y-auto p-3">
{#if comments.length === 0}
<p class="text-center text-sm text-gray-400 dark:text-gray-500">Noch keine Kommentare.</p>
<p class="text-center text-sm text-gray-500 dark:text-gray-400">Noch keine Kommentare.</p>
{:else}
<div class="space-y-3">
{#each comments as comment (comment.id)}
@@ -173,7 +175,7 @@
<div class="flex-1">
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">{comment.uploader_name}</span>
<span class="ml-1 text-sm text-gray-700 dark:text-gray-300">{comment.body}</span>
<div class="mt-0.5 text-xs text-gray-400 dark:text-gray-500">{formatTime(comment.created_at)}</div>
<div class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">{formatTime(comment.created_at)}</div>
</div>
{#if comment.user_id === userId}
<button
@@ -201,6 +203,7 @@
<input
type="text"
bind:value={newComment}
aria-label="Kommentar schreiben"
placeholder="Kommentar schreiben..."
maxlength={COMMENT_MAX}
class="flex-1 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-blue-500 focus:outline-none dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-500"
@@ -215,8 +218,8 @@
</div>
<div
class="mt-1 text-right text-xs"
class:text-gray-400={newComment.length < 450}
class:dark:text-gray-500={newComment.length < 450}
class:text-gray-500={newComment.length < 450}
class:dark:text-gray-400={newComment.length < 450}
class:text-amber-600={newComment.length >= 450 && newComment.length < COMMENT_MAX}
class:dark:text-amber-400={newComment.length >= 450 && newComment.length < COMMENT_MAX}
class:text-red-600={newComment.length >= COMMENT_MAX}

View File

@@ -7,6 +7,8 @@
let showCamera = $state(false);
let fileInput: HTMLInputElement;
let sheet = $state<HTMLDivElement | null>(null);
let returnFocus: HTMLElement | null = null;
// Keep the sheet and backdrop always in the DOM for smooth CSS transitions.
let open = $derived($uploadSheetOpen);
@@ -15,6 +17,50 @@
uploadSheetOpen.set(false);
}
// Modal contract (mirrors ContextSheet): trap Tab within the sheet, close on
// Escape, and restore focus on close. The sheet is permanently mounted (CSS
// translate animation), so this is wired via $effect — and `inert`/`aria-hidden`
// below keep its controls out of the tab order + AT tree while closed.
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault();
close();
return;
}
if (e.key !== 'Tab' || !sheet) return;
const list = Array.from(sheet.querySelectorAll<HTMLElement>('button:not([disabled])'));
if (list.length === 0) return;
const first = list[0];
const last = list[list.length - 1];
const active = document.activeElement as HTMLElement | null;
if (e.shiftKey && (active === first || !sheet.contains(active))) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && active === last) {
e.preventDefault();
first.focus();
}
}
$effect(() => {
if (open) {
returnFocus = (document.activeElement as HTMLElement | null) ?? null;
requestAnimationFrame(() => {
const first = sheet?.querySelector<HTMLButtonElement>('button:not([disabled])');
first?.focus({ preventScroll: true });
});
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
} else if (returnFocus) {
try {
returnFocus.focus({ preventScroll: true });
} catch {
/* element gone */
}
returnFocus = null;
}
});
function openGallery() {
fileInput?.click();
}
@@ -80,10 +126,17 @@
<!-- Sheet -->
<div
bind:this={sheet}
class="fixed inset-x-0 bottom-0 z-50 rounded-t-2xl bg-white transition-transform duration-300 dark:bg-gray-900"
class:translate-y-full={!open}
class:translate-y-0={open}
class:pointer-events-none={!open}
style="padding-bottom: env(safe-area-inset-bottom)"
role="dialog"
aria-modal="true"
aria-label="Hochladen"
tabindex="-1"
inert={!open}
>
<!-- Drag handle -->
<div class="flex justify-center pt-3 pb-1">

View File

@@ -1,5 +1,5 @@
// Per-device "Datenmodus" — Saver loads compressed previews (default), Original loads
// the full file via the auth-gated `/api/v1/upload/{id}/original` endpoint.
// the full file via the signed `original_url` from the media gateway.
//
// Stored per-device in localStorage (not per-user) because data plans are a property
// of the device the guest is currently holding, not their identity.
@@ -40,17 +40,24 @@ if (browser) {
* Build the URL for a feed upload given the current data mode and the URL variants
* the backend returned. Centralised so every consumer (cards, lightbox, diashow)
* follows the same fallback rule:
* Original mode → original API route. Falls back to preview if no upload id is
* available (defensive — shouldn't happen in practice).
* Original mode → signed original URL, falling back to preview/thumbnail.
* Saver mode → preview URL (compressed), falling back to thumbnail and then
* original.
* the signed original.
*
* All URLs are minted (and signed) by the backend; the client never constructs
* a media path itself.
*/
export function pickMediaUrl(
mode: DataMode,
upload: { id: string; preview_url: string | null; thumbnail_url: string | null }
upload: {
id: string;
preview_url: string | null;
thumbnail_url: string | null;
original_url: string | null;
}
): string {
if (mode === 'original') {
return `/api/v1/upload/${upload.id}/original`;
return upload.original_url ?? upload.preview_url ?? upload.thumbnail_url ?? '';
}
return upload.preview_url ?? upload.thumbnail_url ?? `/api/v1/upload/${upload.id}/original`;
return upload.preview_url ?? upload.thumbnail_url ?? upload.original_url ?? '';
}

View File

@@ -0,0 +1,10 @@
// Shared, reactive event state pushed from the backend.
//
// `uploadsLocked` is seeded from `/me/context` on boot and kept live by the
// `event-closed` / `event-opened` SSE events (wired in the root layout). Pages
// read it to show an "Uploads gesperrt" banner and disable the upload FAB, so a
// locked event stops inviting uploads that would only fail server-side.
import { writable } from 'svelte/store';
export const uploadsLocked = writable<boolean>(false);

View File

@@ -38,6 +38,7 @@ const KNOWN_EVENTS = [
'upload-deleted',
'like-update',
'new-comment',
'user-banned',
'event-closed',
'event-opened',
'event-updated',
@@ -50,7 +51,18 @@ const KNOWN_EVENTS = [
* Synthetic event types — not emitted by the server, dispatched locally to fan out
* cross-cutting state changes (e.g. delta-fetch results after a reconnect).
*/
export type SyntheticEvent = 'feed-delta';
export type SyntheticEvent = 'feed-delta' | 'feed-reload';
/**
* Advance the reconnect cursor using a **server** timestamp (an upload's
* `created_at`), never the client clock. A phone clock skewed ahead would
* otherwise make the cursor jump past events that happened while the tab was
* backgrounded. ISO-8601 UTC strings compare correctly lexicographically.
*/
function noteServerTime(ts: string | null | undefined): void {
if (!ts) return;
if (!lastEventTime || ts > lastEventTime) lastEventTime = ts;
}
export function onSseEvent(eventType: string, handler: EventHandler): () => void {
if (!handlers.has(eventType)) {
@@ -93,12 +105,13 @@ export function connectSse(): void {
eventSource.onopen = () => {
// Successful connection — reset the backoff counter.
reconnectAttempt = 0;
// If we have a previous timestamp this is a reconnect — fetch the gap.
// If we have a previous (server-derived) timestamp this is a reconnect
// — fetch the gap. The cursor is only ever advanced from server
// timestamps (noteServerTime), so client clock skew can't drop events.
const since = lastEventTime;
if (since) {
void deltaFetchAndFan(since);
}
lastEventTime = new Date().toISOString();
};
for (const eventName of KNOWN_EVENTS) {
@@ -146,7 +159,15 @@ export function setLastEventTime(time: string): void {
}
function dispatch(eventType: string, data: string): void {
lastEventTime = new Date().toISOString();
// Advance the cursor from the server timestamp carried by a new upload.
// Other event types don't carry one and must not bump it off the client clock.
if (eventType === 'new-upload') {
try {
noteServerTime((JSON.parse(data) as { created_at?: string }).created_at);
} catch {
// payload not JSON — ignore
}
}
const list = handlers.get(eventType);
if (list) {
for (const handler of list) {
@@ -166,6 +187,14 @@ async function deltaFetchAndFan(since: string): Promise<void> {
const response = await api.get<DeltaResponse>(
`/feed/delta?since=${encodeURIComponent(since)}`
);
// Advance the cursor from the newest server timestamp in the delta.
for (const u of response.uploads) noteServerTime(u.created_at);
// The server clamped the window or hit the row cap — the partial delta
// can't be trusted, so ask the page to do a full reload instead.
if (response.reload_required) {
dispatch('feed-reload', '{}');
return;
}
dispatch('feed-delta', JSON.stringify(response));
} catch {
// non-fatal

View File

@@ -9,6 +9,7 @@ export interface FeedUpload {
uploader_name: string;
preview_url: string | null;
thumbnail_url: string | null;
original_url: string | null;
mime_type: string;
caption: string | null;
like_count: number;
@@ -27,6 +28,7 @@ export interface FeedResponse {
export interface DeltaResponse {
uploads: FeedUpload[];
deleted_ids: string[];
reload_required: boolean;
}
// mirrors backend/src/handlers/feed.rs::HashtagCount
@@ -53,6 +55,7 @@ export interface MeContextDto {
privacy_note: string;
quota_enabled: boolean;
storage_quota_enabled: boolean;
uploads_locked: boolean;
}
// mirrors backend/src/handlers/host.rs::PinResetResponse

View File

@@ -1,8 +1,9 @@
<script lang="ts">
import favicon from '$lib/assets/favicon.svg';
import '../app.css';
import { initAuth, getToken, getUserId, clearPin } from '$lib/auth';
import { initAuth, getToken, getUserId, clearPin, clearAuth } from '$lib/auth';
import { initTheme } from '$lib/theme-store';
import { goto } from '$app/navigation';
import { onMount, onDestroy } from 'svelte';
import BottomNav from '$lib/components/BottomNav.svelte';
import UploadSheet from '$lib/components/UploadSheet.svelte';
@@ -14,12 +15,28 @@
import { refreshQuota } from '$lib/quota-store';
import { onSseEvent } from '$lib/sse';
import { api } from '$lib/api';
import { toast } from '$lib/toast-store';
import { uploadsLocked } from '$lib/event-state-store';
import type { MeContextDto } from '$lib/types';
let { children } = $props();
let unsubs: Array<() => void> = [];
// H12: surface upload failures. The queue marks failed items 'error' in
// IndexedDB but nothing rendered them, so a banned/locked/over-quota guest saw
// the composer close to a normal feed and assumed success. Toast each item the
// first time it transitions to 'error'.
let toastedErrors = new Set<string>();
$effect(() => {
for (const item of $queueItems) {
if (item.status === 'error' && !toastedErrors.has(item.id)) {
toastedErrors.add(item.id);
toast(item.error ?? 'Upload fehlgeschlagen.', 'error');
}
}
});
// Slim progress bar: ratio of completed items to total, shown while processing.
let progressPct = $derived.by(() => {
const total = $queueItems.length;
@@ -39,6 +56,7 @@
try {
const ctx = await api.get<MeContextDto>('/me/context');
privacyNote.set(ctx.privacy_note);
uploadsLocked.set(ctx.uploads_locked);
} catch {
// Cross-cutting hydration on boot — failure is non-fatal; users without
// a session land on /join anyway, and the per-page mount will retry.
@@ -61,7 +79,26 @@
} catch {
// Malformed payload — discard; nothing actionable for the user.
}
})
}),
// M11: a host banned someone. If it's us, the session has already been
// revoked server-side — drop local auth and send us to /join so the UI
// doesn't keep pretending we're signed in.
onSseEvent('user-banned', (data) => {
try {
const payload = JSON.parse(data) as { user_id: string };
if (payload.user_id === getUserId()) {
clearAuth();
toast('Du wurdest vom Event entfernt.', 'error');
void goto('/join');
}
} catch {
/* malformed — ignore */
}
}),
// M11: reflect event lock state live so the feed/upload pages can toggle
// their "Uploads gesperrt" banner and disable the FAB.
onSseEvent('event-closed', () => uploadsLocked.set(true)),
onSseEvent('event-opened', () => uploadsLocked.set(false))
);
});

View File

@@ -161,7 +161,7 @@
</div>
</div>
{#if expiry}
<p class="mt-3 text-xs text-gray-400 dark:text-gray-500">Sitzung gültig bis {formatDate(expiry)}</p>
<p class="mt-3 text-xs text-gray-500 dark:text-gray-400">Sitzung gültig bis {formatDate(expiry)}</p>
{/if}
</div>
@@ -219,7 +219,7 @@
</button>
</div>
{:else}
<div class="rounded-lg bg-white px-4 py-3 text-sm text-gray-400 shadow-sm dark:bg-gray-900 dark:text-gray-500">
<div class="rounded-lg bg-white px-4 py-3 text-sm text-gray-500 shadow-sm dark:bg-gray-900 dark:text-gray-400">
PIN nicht gespeichert. Nutze die Wiederherstellungs-Seite, um dich mit deinem PIN anzumelden.
</div>
{/if}
@@ -334,7 +334,7 @@
style="width: {quotaPercent}%"
></div>
</div>
<p class="mt-2 text-xs text-gray-400 dark:text-gray-500">
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
Geschätzt für {$quotaStore.active_uploaders} aktive Beitragende.
</p>
</div>
@@ -361,7 +361,7 @@
href="/recover"
class="flex items-center gap-3 px-5 py-4 transition hover:bg-gray-50 dark:hover:bg-gray-700/50"
>
<svg class="h-5 w-5 text-gray-400 dark:text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<svg class="h-5 w-5 text-gray-500 dark:text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 8.25h3m-3 3h3m-3 3h3" />
</svg>
<span class="flex-1 text-sm font-medium text-gray-700 dark:text-gray-300">Gerät wechseln / PIN nutzen</span>

View File

@@ -412,7 +412,7 @@
<div class="mx-auto max-w-3xl p-4">
{#if loading}
<div class="py-16 text-center text-gray-400 dark:text-gray-500">Laden…</div>
<div class="py-16 text-center text-gray-500 dark:text-gray-400">Laden…</div>
{:else if error}
<div class="rounded-lg bg-red-50 p-4 text-sm text-red-700">{error}</div>
{:else}
@@ -451,7 +451,7 @@
style="width: {diskPct(stats)}%"
></div>
</div>
<p class="mt-1.5 text-xs text-gray-400 dark:text-gray-500">{formatBytes(stats.disk_free_bytes)} frei</p>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">{formatBytes(stats.disk_free_bytes)} frei</p>
</div>
{/if}
</div>
@@ -552,7 +552,7 @@
</button>
</div>
{#if exportJobs.length === 0}
<p class="text-sm text-gray-400 dark:text-gray-500">Noch keine Export-Jobs.</p>
<p class="text-sm text-gray-500 dark:text-gray-400">Noch keine Export-Jobs.</p>
{:else}
<div class="space-y-3">
{#each exportJobs as job}
@@ -589,7 +589,7 @@
<!-- Search -->
<div class="p-4">
<div class="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 dark:border-gray-700 dark:bg-gray-900">
<svg class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<svg class="h-4 w-4 shrink-0 text-gray-500 dark:text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
</svg>
<input
@@ -601,7 +601,7 @@
</div>
</div>
{#if filteredUsers.length === 0}
<p class="px-5 py-8 text-center text-sm text-gray-400 dark:text-gray-500">Keine Treffer.</p>
<p class="px-5 py-8 text-center text-sm text-gray-500 dark:text-gray-400">Keine Treffer.</p>
{:else}
<div class="divide-y divide-gray-100 dark:divide-gray-700">
{#each filteredUsers as user}
@@ -618,7 +618,7 @@
<span class="rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700 dark:bg-red-900/40 dark:text-red-200">Gesperrt</span>
{/if}
</div>
<p class="text-xs text-gray-400 dark:text-gray-500">
<p class="text-xs text-gray-500 dark:text-gray-400">
{user.upload_count} Upload{user.upload_count !== 1 ? 's' : ''} · {formatBytes(user.total_upload_bytes)}
</p>
</div>

View File

@@ -35,7 +35,15 @@
clearTimer();
if (paused) return;
// Videos: advance on `ended` or after `max(dwell, 12s)` — whichever first.
const ms = isVideo ? Math.max(dwellMs, 12000) : dwellMs;
let ms = isVideo ? Math.max(dwellMs, 12000) : dwellMs;
// Reduced-motion: slow auto-advance to a ≥30s floor so content doesn't
// auto-change rapidly (WCAG 2.2.2); the CSS already snaps the transitions.
if (
typeof window !== 'undefined' &&
window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
) {
ms = Math.max(ms, 30000);
}
advanceTimer = setTimeout(advance, ms);
}

View File

@@ -23,6 +23,7 @@
let status = $state<ExportStatus | null>(null);
let showHtmlGuide = $state(false);
let loading = $state(true);
let loadError = $state(false);
let unsubscribers: (() => void)[] = [];
@@ -53,9 +54,12 @@
async function loadStatus() {
try {
status = await api.get<ExportStatus>('/export/status');
loadError = false;
} catch {
// Background poll triggered by SSE — silent. The visible empty/loading state
// will reflect the failure; the next event will retry.
// M19: a fetch failure must not masquerade as "not yet released" — only
// flag an error when we have nothing to show; a background SSE poll that
// fails while we already have a status stays silent.
if (!status) loadError = true;
} finally {
loading = false;
}
@@ -173,7 +177,17 @@
<div class="mx-auto max-w-lg space-y-4 p-4">
{#if loading}
<div class="py-16 text-center text-gray-400 dark:text-gray-500">Laden…</div>
<div class="py-16 text-center text-gray-500 dark:text-gray-400">Laden…</div>
{:else if loadError}
<div class="rounded-xl border border-gray-200 bg-white p-6 text-center dark:border-gray-700 dark:bg-gray-800">
<p class="font-medium text-gray-700 dark:text-gray-300">Status konnte nicht geladen werden.</p>
<button
onclick={() => { loading = true; loadStatus(); }}
class="mt-3 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-blue-700"
>
Erneut versuchen
</button>
</div>
{:else if !status?.released}
<div class="rounded-xl border border-gray-200 bg-white p-6 text-center dark:border-gray-700 dark:bg-gray-800">
<svg class="mx-auto mb-3 h-12 w-12 text-gray-300 dark:text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -191,7 +205,7 @@
<div class="min-w-0">
<h2 class="font-semibold text-gray-900 dark:text-gray-100">ZIP-Archiv</h2>
<p class="mt-0.5 text-sm text-gray-500 dark:text-gray-400">Alle Original-Fotos und Videos in strukturierten Ordnern.</p>
<p class="mt-1 text-xs {status.zip.status === 'done' ? 'text-green-600 dark:text-green-400' : status.zip.status === 'failed' ? 'text-red-500 dark:text-red-400' : 'text-gray-400 dark:text-gray-500'}">
<p class="mt-1 text-xs {status.zip.status === 'done' ? 'text-green-600 dark:text-green-400' : status.zip.status === 'failed' ? 'text-red-500 dark:text-red-400' : 'text-gray-500 dark:text-gray-400'}">
{statusText(status.zip)}
</p>
</div>
@@ -218,7 +232,7 @@
<div class="min-w-0">
<h2 class="font-semibold text-gray-900 dark:text-gray-100">HTML-Viewer</h2>
<p class="mt-0.5 text-sm text-gray-500 dark:text-gray-400">Schöne Offline-Galerie mit Filterung, Kommentaren und Likes — kein Internet nötig.</p>
<p class="mt-1 text-xs {status.html.status === 'done' ? 'text-green-600 dark:text-green-400' : status.html.status === 'failed' ? 'text-red-500 dark:text-red-400' : 'text-gray-400 dark:text-gray-500'}">
<p class="mt-1 text-xs {status.html.status === 'done' ? 'text-green-600 dark:text-green-400' : status.html.status === 'failed' ? 'text-red-500 dark:text-red-400' : 'text-gray-500 dark:text-gray-400'}">
{statusText(status.html)}
</p>
</div>

View File

@@ -2,7 +2,7 @@
import { goto } from '$app/navigation';
import { getToken, getUserId } from '$lib/auth';
import { api } from '$lib/api';
import { connectSse, disconnectSse, onSseEvent } from '$lib/sse';
import { connectSse, disconnectSse, onSseEvent, setLastEventTime } from '$lib/sse';
import { onMount, onDestroy } from 'svelte';
import FeedGrid from '$lib/components/FeedGrid.svelte';
import FeedListCard from '$lib/components/FeedListCard.svelte';
@@ -16,6 +16,7 @@
import { toast, toastError } from '$lib/toast-store';
import { pullToRefresh } from '$lib/actions/pull-to-refresh';
import { vibrate } from '$lib/haptics';
import { uploadsLocked } from '$lib/event-state-store';
import type { FeedUpload, FeedResponse, HashtagCount, DeltaResponse } from '$lib/types';
let uploads = $state<FeedUpload[]>([]);
@@ -24,6 +25,7 @@
let nextCursor = $state<string | null>(null);
let loadingMore = $state(false);
let initialLoading = $state(true);
let loadError = $state(false);
let refreshing = $state(false);
let pullProgress = $state(0); // 01+ during the drag, 0 when idle
let selectedUpload = $state<FeedUpload | null>(null);
@@ -68,7 +70,7 @@
label: 'Original anzeigen',
icon: '⤓',
onClick: () => {
window.open(`/api/v1/upload/${target.id}/original`, '_blank');
if (target.original_url) window.open(target.original_url, '_blank');
}
}
];
@@ -200,6 +202,9 @@
}),
onSseEvent('like-update', () => loadFeed(true)),
onSseEvent('new-comment', () => loadFeed(true)),
// Delta was clamped/capped server-side (H7) — do a full reload instead
// of trusting a partial set.
onSseEvent('feed-reload', () => loadFeed(true)),
// Synthetic event from the SSE client after a foreground reconnect — merge
// any uploads + deletions we missed while the tab was hidden.
onSseEvent('feed-delta', (data) => {
@@ -243,9 +248,18 @@
const res = await api.get<FeedResponse>(`/feed?${params}`);
uploads = res.uploads;
nextCursor = res.next_cursor;
loadError = false;
// Seed the SSE reconnect cursor from the newest server timestamp so the
// delta on the next reconnect is based on server time, not the client
// clock (M9).
if (uploads.length) setLastEventTime(uploads[0].created_at);
} catch (e) {
// Initial / user-triggered refresh is worth surfacing — background SSE refetches are noisier and silenced below.
if (!refresh) toastError(e);
// M19: distinguish a load failure from a genuinely empty gallery so the
// template can offer a retry instead of showing "nobody posted yet".
if (!refresh) {
loadError = true;
toastError(e);
}
} finally {
initialLoading = false;
}
@@ -442,11 +456,12 @@
<div class="mx-auto max-w-2xl px-4 pb-3">
<div class="relative">
<div class="flex items-center gap-2 rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 focus-within:border-blue-400 focus-within:bg-white focus-within:ring-1 focus-within:ring-blue-200 dark:border-gray-700 dark:bg-gray-800 dark:focus-within:border-blue-500 dark:focus-within:bg-gray-800">
<svg class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<svg class="h-4 w-4 shrink-0 text-gray-500 dark:text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
</svg>
<input
type="search"
aria-label="Nutzer oder Hashtag suchen"
placeholder="Nutzer oder #Tag suchen…"
bind:value={searchQuery}
onfocus={(e) => {
@@ -479,7 +494,7 @@
onmousedown={() => selectSuggestion(item)}
>
{#if item.type === 'user'}
<svg class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<svg class="h-4 w-4 shrink-0 text-gray-500 dark:text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
</svg>
<span class="font-medium text-gray-900 dark:text-gray-100">{item.value}</span>
@@ -517,6 +532,16 @@
{/if}
</div>
<!-- Uploads-locked banner (M11) -->
{#if $uploadsLocked}
<div
class="mx-auto mb-2 max-w-2xl rounded-lg bg-amber-100 px-4 py-2 text-center text-sm font-medium text-amber-800 dark:bg-amber-900/40 dark:text-amber-200"
role="status"
>
Uploads sind aktuell gesperrt.
</div>
{/if}
<!-- Content -->
{#if initialLoading && uploads.length === 0}
<div class="mx-auto max-w-2xl" data-testid="feed-skeleton">
@@ -532,10 +557,20 @@
</div>
{/if}
</div>
{:else if uploads.length === 0 && loadError}
<div class="py-20 text-center">
<p class="text-lg text-gray-600 dark:text-gray-300">Galerie konnte nicht geladen werden.</p>
<button
onclick={() => loadFeed()}
class="mt-3 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-blue-700"
>
Erneut versuchen
</button>
</div>
{:else if uploads.length === 0}
<div class="py-20 text-center">
<p class="text-lg text-gray-400 dark:text-gray-500">Noch keine Fotos.</p>
<p class="mt-1 text-sm text-gray-400 dark:text-gray-500">Tippe auf den Plus-Button unten!</p>
<p class="text-lg text-gray-500 dark:text-gray-400">Noch keine Fotos.</p>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Tippe auf den Plus-Button unten!</p>
</div>
{:else if viewMode === 'list'}
<!-- List view: chronological full-width cards -->
@@ -556,7 +591,7 @@
<div class="mx-auto max-w-2xl">
{#if displayUploads.length === 0}
<div class="py-16 text-center">
<p class="text-sm text-gray-400 dark:text-gray-500">Keine Treffer für die gewählten Filter.</p>
<p class="text-sm text-gray-500 dark:text-gray-400">Keine Treffer für die gewählten Filter.</p>
<button onclick={clearFilters} class="mt-2 text-sm text-blue-600 hover:underline dark:text-blue-400">Filter zurücksetzen</button>
</div>
{:else}

View File

@@ -288,7 +288,7 @@
<div class="mx-auto max-w-3xl space-y-3 p-4">
{#if loading}
<div class="py-16 text-center text-gray-400 dark:text-gray-500">Laden…</div>
<div class="py-16 text-center text-gray-500 dark:text-gray-400">Laden…</div>
{:else if error}
<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}
@@ -301,7 +301,7 @@
>
<h2 class="font-semibold text-gray-900 dark:text-gray-100">Statistiken</h2>
<svg
class="h-5 w-5 text-gray-400 dark:text-gray-500 transition-transform duration-200 {statsOpen ? 'rotate-180' : ''}"
class="h-5 w-5 text-gray-500 dark:text-gray-400 transition-transform duration-200 {statsOpen ? 'rotate-180' : ''}"
fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
@@ -324,7 +324,7 @@
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">Uploads</p>
</div>
<div class="rounded-xl bg-gray-50 p-4 text-center dark:bg-gray-900/60">
<p class="text-2xl font-bold {event.export_released ? 'text-blue-600 dark:text-blue-400' : 'text-gray-400 dark:text-gray-500'}">
<p class="text-2xl font-bold {event.export_released ? 'text-blue-600 dark:text-blue-400' : 'text-gray-500 dark:text-gray-400'}">
{event.export_released ? 'Ja' : 'Nein'}
</p>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">Freigegeben</p>
@@ -341,7 +341,7 @@
>
<h2 class="font-semibold text-gray-900 dark:text-gray-100">Event-Einstellungen</h2>
<svg
class="h-5 w-5 text-gray-400 dark:text-gray-500 transition-transform duration-200 {settingsOpen ? 'rotate-180' : ''}"
class="h-5 w-5 text-gray-500 dark:text-gray-400 transition-transform duration-200 {settingsOpen ? 'rotate-180' : ''}"
fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
@@ -376,7 +376,7 @@
>
<h2 class="font-semibold text-gray-900 dark:text-gray-100">Nutzerverwaltung</h2>
<svg
class="h-5 w-5 text-gray-400 dark:text-gray-500 transition-transform duration-200 {usersOpen ? 'rotate-180' : ''}"
class="h-5 w-5 text-gray-500 dark:text-gray-400 transition-transform duration-200 {usersOpen ? 'rotate-180' : ''}"
fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
@@ -387,7 +387,7 @@
<!-- Search -->
<div class="px-4 py-3">
<div class="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 dark:border-gray-700 dark:bg-gray-900">
<svg class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<svg class="h-4 w-4 shrink-0 text-gray-500 dark:text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
</svg>
<input
@@ -399,7 +399,7 @@
</div>
</div>
{#if filteredUsers.length === 0}
<p class="px-5 py-8 text-center text-sm text-gray-400 dark:text-gray-500">Keine Treffer.</p>
<p class="px-5 py-8 text-center text-sm text-gray-500 dark:text-gray-400">Keine Treffer.</p>
{:else}
<div class="divide-y divide-gray-100 dark:divide-gray-700">
{#each filteredUsers as user}
@@ -416,7 +416,7 @@
<span class="rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700 dark:bg-red-900/40 dark:text-red-200">Gesperrt</span>
{/if}
</div>
<p class="text-xs text-gray-400 dark:text-gray-500">
<p class="text-xs text-gray-500 dark:text-gray-400">
{user.upload_count} Upload{user.upload_count !== 1 ? 's' : ''} · {formatBytes(user.total_upload_bytes)}
</p>
</div>

View File

@@ -100,10 +100,10 @@
// user doesn't have to chase the (now cosmetic) Anmelden button.
function onRecoveryPinInput(e: Event) {
const el = e.currentTarget as HTMLInputElement;
const cleaned = el.value.replace(/\D/g, '').slice(0, 4);
const cleaned = el.value.replace(/\D/g, '').slice(0, 6);
if (cleaned !== el.value) el.value = cleaned;
recoveryPin = cleaned;
if (recoveryPin.length === 4 && !recoveryLoading) {
if (recoveryPin.length === 6 && !recoveryLoading) {
handleInlineRecover();
}
}
@@ -131,8 +131,9 @@
type="text"
value={recoveryPin}
oninput={onRecoveryPinInput}
placeholder="4-stelliger PIN"
maxlength={4}
aria-label="Wiederherstellungs-PIN"
placeholder="6-stelliger PIN"
maxlength={6}
inputmode="numeric"
pattern="[0-9]*"
data-testid="recovery-pin-input"
@@ -170,6 +171,7 @@
<input
type="text"
bind:value={displayName}
aria-label="Dein Name"
placeholder="Dein Name"
maxlength={50}
data-testid="join-name-input"

View File

@@ -58,10 +58,10 @@
// invalid intermediate state.
function onPinInput(e: Event) {
const el = e.currentTarget as HTMLInputElement;
const cleaned = el.value.replace(/\D/g, '').slice(0, 4);
const cleaned = el.value.replace(/\D/g, '').slice(0, 6);
if (cleaned !== el.value) el.value = cleaned;
pin = cleaned;
if (pin.length === 4 && displayName.trim() && !loading) {
if (pin.length === 6 && displayName.trim() && !loading) {
handleRecover();
}
}
@@ -89,6 +89,7 @@
<input
type="text"
bind:value={displayName}
aria-label="Dein Name"
placeholder="Dein Name"
maxlength={50}
data-testid="recover-name-input"
@@ -98,8 +99,9 @@
type="text"
value={pin}
oninput={onPinInput}
placeholder="4-stelliger PIN"
maxlength={4}
aria-label="Wiederherstellungs-PIN"
placeholder="6-stelliger PIN"
maxlength={6}
inputmode="numeric"
pattern="[0-9]*"
data-testid="recover-pin-input"

View File

@@ -177,7 +177,7 @@
</svg>
<div>
<p class="font-medium text-gray-500 dark:text-gray-400">Keine Dateien ausgewählt</p>
<p class="mt-1 text-sm text-gray-400 dark:text-gray-500">Geh zurück und tippe auf den Plus-Button.</p>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Geh zurück und tippe auf den Plus-Button.</p>
</div>
<button
onclick={cancel}
@@ -195,6 +195,7 @@
bind:value={caption}
maxlength={MAX_CAPTION_LENGTH}
data-testid="upload-caption"
aria-label="Beschreibung"
placeholder="Beschreibung hinzufügen… (#hashtags möglich)"
rows="4"
class="w-full resize-none rounded-xl border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-900