3 Commits

Author SHA1 Message Date
MechaCat02
a43ad6194a feat: unique display names (case-insensitive) + inline recover on join
Backend:
- Migration 007 deduplicates existing users by name (case-insensitive,
  keeping oldest) and adds a unique index on (event_id, LOWER(display_name))
- find_by_event_and_name is now case-insensitive (LOWER comparison)
- join endpoint checks name availability before creating; returns 409
  Conflict with code "conflict" when the name is taken

Frontend (join page):
- On 409, switches to a name-taken view showing an amber warning with
  name-choice tips, a PIN input that runs the recover flow inline, and
  an "Anderen Namen wählen" button that returns to the name input

Test guide: added Steps 8 (rate-limit auto-retry) and 9 (name uniqueness)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 18:36:52 +02:00
MechaCat02
7515983a63 Merge branch 'feat/upload-rate-limit-autoretry' into feat/unique-display-names 2026-04-03 18:34:04 +02:00
MechaCat02
6efd2fd3b5 feat: auto-retry uploads when rate limited
Backend: extend rate limiter with check_with_retry() that returns the
seconds until the next slot opens. Upload 429 responses now include
retry_after_secs in the JSON body and a Retry-After header.

Frontend: the upload queue catches 429s as RateLimitError, resets the
affected item to pending, schedules processQueue() to resume after the
server-reported wait, and shows a live countdown banner in UploadQueue.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 18:34:01 +02:00
13 changed files with 283 additions and 48 deletions

View File

@@ -40,3 +40,18 @@ Please test each step in order and report any errors (console errors, wrong text
23. Open a new **private/incognito** window at **http://localhost:5173/recover**
24. Enter the same name (`Max`) and the PIN you copied
25. ✅ Expected: You're redirected to the feed with the same account
### Step 8 — Upload rate-limit auto-retry
26. Upload more than 20 photos in one hour to trigger the rate limit
27. ✅ Expected: When the limit is hit, remaining items stay **Wartend** (not error)
28. ✅ Expected: An amber banner appears in the queue: "Upload-Limit erreicht. Wird in Xs automatisch fortgesetzt."
29. ✅ Expected: The countdown ticks down and uploads resume automatically when it reaches 0
### Step 9 — Name uniqueness (case-insensitive)
30. In a private/incognito window go to **http://localhost:5173/join**
31. Enter `max` or `MAX` — the same name already taken in Step 1 (different case)
32. ✅ Expected: Instead of creating a new account, an amber warning appears: „Max ist bereits vergeben." with name tips
33. ✅ Expected: A PIN input and **Anmelden** button appear, plus an **Anderen Namen wählen** button
34. Enter your PIN from Step 1 and click **Anmelden**
35. ✅ Expected: You're signed in to the existing `Max` account and redirected to the feed
36. Alternatively, click **Anderen Namen wählen** — ✅ Expected: the name input reappears with `max` pre-filled so you can edit it

View File

@@ -0,0 +1,4 @@
DROP INDEX IF EXISTS idx_user_event_name_ci;
CREATE INDEX idx_user_event_name
ON "user"(event_id, display_name);

View File

@@ -0,0 +1,15 @@
-- Deduplicate users with the same name (case-insensitive) per event,
-- keeping the oldest account so no real data is lost.
DELETE FROM "user"
WHERE id NOT IN (
SELECT DISTINCT ON (event_id, LOWER(display_name)) id
FROM "user"
ORDER BY event_id, LOWER(display_name), created_at ASC
);
-- Drop the old non-unique index (replaced below)
DROP INDEX IF EXISTS idx_user_event_name;
-- Unique index enforces one account per name per event (case-insensitive)
CREATE UNIQUE INDEX idx_user_event_name_ci
ON "user" (event_id, LOWER(display_name));

View File

@@ -39,6 +39,7 @@ pub async fn join(
if !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,
));
}
@@ -56,6 +57,14 @@ pub async fn join(
)
.await?;
// Reject if a user with this name (case-insensitive) already exists
if User::name_taken(&state.pool, event.id, display_name).await? {
return Err(AppError::Conflict(format!(
"Der Name \"{}\" ist bereits vergeben.",
display_name
)));
}
// Generate a 4-digit PIN
let pin: String = format!("{:04}", rand::rng().random_range(0..10000u32));
let pin_hash =
@@ -124,6 +133,7 @@ pub async fn recover(
if Utc::now() < locked_until {
return Err(AppError::TooManyRequests(
"Zu viele Versuche. Bitte warte 15 Minuten.".into(),
None,
));
}
}

View File

@@ -1,6 +1,5 @@
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use serde_json::json;
#[derive(Debug)]
pub enum AppError {
@@ -8,7 +7,9 @@ pub enum AppError {
Unauthorized(String),
Forbidden(String),
NotFound(String),
TooManyRequests(String),
Conflict(String),
/// Second field: optional retry-after seconds to include in the response.
TooManyRequests(String, Option<u64>),
Internal(anyhow::Error),
}
@@ -19,7 +20,8 @@ impl AppError {
Self::Unauthorized(_) => (StatusCode::UNAUTHORIZED, "unauthorized"),
Self::Forbidden(_) => (StatusCode::FORBIDDEN, "forbidden"),
Self::NotFound(_) => (StatusCode::NOT_FOUND, "not_found"),
Self::TooManyRequests(_) => (StatusCode::TOO_MANY_REQUESTS, "too_many_requests"),
Self::Conflict(_) => (StatusCode::CONFLICT, "conflict"),
Self::TooManyRequests(..) => (StatusCode::TOO_MANY_REQUESTS, "too_many_requests"),
Self::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "internal_error"),
}
}
@@ -30,7 +32,8 @@ impl AppError {
| Self::Unauthorized(msg)
| Self::Forbidden(msg)
| Self::NotFound(msg)
| Self::TooManyRequests(msg) => msg.clone(),
| Self::Conflict(msg) => msg.clone(),
Self::TooManyRequests(msg, _) => msg.clone(),
Self::Internal(err) => {
tracing::error!("internal error: {err:#}");
"Ein interner Fehler ist aufgetreten.".to_string()
@@ -42,13 +45,29 @@ impl AppError {
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, code) = self.status_and_code();
let retry_after_secs = if let Self::TooManyRequests(_, Some(secs)) = &self {
Some(*secs)
} else {
None
};
let message = self.message();
let body = json!({
let mut body = serde_json::json!({
"error": code,
"message": message,
"status": status.as_u16(),
});
(status, axum::Json(body)).into_response()
if let Some(secs) = retry_after_secs {
body["retry_after_secs"] = secs.into();
}
let mut resp = (status, axum::Json(body)).into_response();
if let Some(secs) = retry_after_secs {
if let Ok(val) = axum::http::HeaderValue::from_str(&secs.to_string()) {
resp.headers_mut().insert(axum::http::header::RETRY_AFTER, val);
}
}
resp
}
}

View File

@@ -180,9 +180,7 @@ pub async fn download_zip(
let ip = client_ip(&headers, "unknown");
let limit = get_config_usize(&state.pool, "export_rate_per_day", 3).await;
if !state.rate_limiter.check(format!("export:{ip}"), limit, Duration::from_secs(86400)) {
return Err(AppError::TooManyRequests(
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
));
return Err(AppError::TooManyRequests("Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(), None));
}
let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)
@@ -211,9 +209,7 @@ pub async fn download_html(
let ip = client_ip(&headers, "unknown");
let limit = get_config_usize(&state.pool, "export_rate_per_day", 3).await;
if !state.rate_limiter.check(format!("export:{ip}"), limit, Duration::from_secs(86400)) {
return Err(AppError::TooManyRequests(
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
));
return Err(AppError::TooManyRequests("Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(), None));
}
let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)

View File

@@ -63,9 +63,7 @@ pub async fn feed(
let ip = client_ip(&headers, "unknown");
let rate_limit = get_config_usize(&state.pool, "feed_rate_per_min", 60).await;
if !state.rate_limiter.check(format!("feed:{ip}"), rate_limit, Duration::from_secs(60)) {
return Err(AppError::TooManyRequests(
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
));
return Err(AppError::TooManyRequests("Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(), None));
}
let limit = q.limit.unwrap_or(20).min(100);

View File

@@ -20,13 +20,14 @@ pub async fn upload(
) -> Result<(StatusCode, Json<UploadDto>), AppError> {
// Rate limit: N uploads per hour per user
let upload_rate = get_config_i64(&state.pool, "upload_rate_per_hour", 10).await as usize;
if !state
if let Err(retry_after_secs) = state
.rate_limiter
.check(format!("upload:{}", auth.user_id), upload_rate, Duration::from_secs(3600))
.check_with_retry(format!("upload:{}", auth.user_id), upload_rate, Duration::from_secs(3600))
{
drain_multipart(multipart).await;
return Err(AppError::TooManyRequests(
"Du hast dein Upload-Limit für diese Stunde erreicht.".into(),
Some(retry_after_secs),
));
}

View File

@@ -59,7 +59,7 @@ impl User {
display_name: &str,
) -> Result<Vec<Self>, sqlx::Error> {
sqlx::query_as::<_, Self>(
"SELECT * FROM \"user\" WHERE event_id = $1 AND display_name = $2",
"SELECT * FROM \"user\" WHERE event_id = $1 AND LOWER(display_name) = LOWER($2)",
)
.bind(event_id)
.bind(display_name)
@@ -67,6 +67,21 @@ impl User {
.await
}
pub async fn name_taken(
pool: &PgPool,
event_id: Uuid,
display_name: &str,
) -> Result<bool, sqlx::Error> {
let row: (bool,) = sqlx::query_as(
"SELECT EXISTS(SELECT 1 FROM \"user\" WHERE event_id = $1 AND LOWER(display_name) = LOWER($2))",
)
.bind(event_id)
.bind(display_name)
.fetch_one(pool)
.await?;
Ok(row.0)
}
pub async fn increment_failed_pin(pool: &PgPool, id: Uuid) -> Result<i16, sqlx::Error> {
let row: (i16,) = sqlx::query_as(
"UPDATE \"user\"

View File

@@ -19,17 +19,26 @@ impl RateLimiter {
/// Returns `true` if the request is allowed, `false` if rate-limited.
pub fn check(&self, key: impl Into<String>, max: usize, window: Duration) -> bool {
self.check_with_retry(key, max, window).is_ok()
}
/// Returns `Ok(())` if allowed, `Err(retry_after_secs)` if rate-limited.
/// `retry_after_secs` is how long until the oldest slot in the window expires.
pub fn check_with_retry(&self, key: impl Into<String>, max: usize, window: Duration) -> Result<(), u64> {
let now = Instant::now();
let key = key.into();
let mut map = self.windows.lock().unwrap();
let timestamps = map.entry(key).or_default();
// Drop entries outside the window
timestamps.retain(|&t| now.duration_since(t) < window);
if timestamps.len() < max {
timestamps.push(now);
true
Ok(())
} else {
false
// The oldest timestamp expires at oldest + window; compute remaining seconds
let oldest = timestamps[0];
let elapsed = now.duration_since(oldest);
let remaining = window.saturating_sub(elapsed);
Err(remaining.as_secs().max(1))
}
}
}

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { queueItems, isProcessing, retryItem, removeItem, clearCompleted } from '$lib/upload-queue';
import { queueItems, isProcessing, retryItem, removeItem, clearCompleted, rateLimitRetryAt } from '$lib/upload-queue';
import type { QueueItem } from '$lib/upload-queue';
function formatSize(bytes: number): string {
@@ -28,6 +28,25 @@
let items = $derived($queueItems);
let hasCompleted = $derived(items.some((i) => i.status === 'done'));
// Countdown for rate-limit banner
let countdown = $state(0);
$effect(() => {
const retryAt = $rateLimitRetryAt;
if (!retryAt) {
countdown = 0;
return;
}
countdown = Math.ceil((retryAt - Date.now()) / 1000);
const interval = setInterval(() => {
countdown = Math.ceil((retryAt - Date.now()) / 1000);
if (countdown <= 0) clearInterval(interval);
}, 1000);
return () => clearInterval(interval);
});
</script>
{#if items.length > 0}
@@ -49,6 +68,12 @@
{/if}
</div>
{#if $rateLimitRetryAt && countdown > 0}
<div class="border-b border-amber-100 bg-amber-50 px-4 py-2 text-sm text-amber-800">
Upload-Limit erreicht. Wird in {countdown} Sek. automatisch fortgesetzt.
</div>
{/if}
<ul class="divide-y divide-gray-100">
{#each items as item (item.id)}
<li class="px-4 py-3">

View File

@@ -18,6 +18,9 @@ export interface QueueItem {
export const queueItems = writable<QueueItem[]>([]);
export const isProcessing = writable(false);
/** Set to the timestamp (ms) at which the rate-limit lifts, or null when clear. */
export const rateLimitRetryAt = writable<number | null>(null);
const DB_NAME = 'eventsnap-uploads';
const STORE_NAME = 'queue';
@@ -35,6 +38,14 @@ async function getDb(): Promise<IDBPDatabase> {
return db;
}
class RateLimitError extends Error {
retryAfterSecs: number;
constructor(secs: number) {
super('rate_limited');
this.retryAfterSecs = secs;
}
}
export async function loadQueue(): Promise<void> {
const database = await getDb();
const all = await database.getAll(STORE_NAME);
@@ -136,7 +147,21 @@ async function processQueue(): Promise<void> {
const next = items.find((item) => item.status === 'pending');
if (!next) break;
await uploadItem(next.id);
try {
await uploadItem(next.id);
} catch (e) {
if (e instanceof RateLimitError) {
// Keep all pending items as-is; schedule queue resume when limit lifts
const retryAt = Date.now() + e.retryAfterSecs * 1000;
rateLimitRetryAt.set(retryAt);
setTimeout(() => {
rateLimitRetryAt.set(null);
processQueue();
}, e.retryAfterSecs * 1000);
break;
}
// Other errors are already handled inside uploadItem (marked as 'error')
}
}
} finally {
processing = false;
@@ -148,7 +173,6 @@ async function uploadItem(id: string): Promise<void> {
const database = await getDb();
const entry = await database.get(STORE_NAME, id);
if (!entry || !entry.blob) {
// No blob — mark as error
updateItemStatus(id, 'error', 'Datei nicht gefunden.');
return;
}
@@ -184,6 +208,14 @@ async function uploadItem(id: string): Promise<void> {
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve();
} else if (xhr.status === 429) {
try {
const body = JSON.parse(xhr.responseText);
const secs = typeof body.retry_after_secs === 'number' ? body.retry_after_secs : 60;
reject(new RateLimitError(secs));
} catch {
reject(new RateLimitError(60));
}
} else {
try {
const body = JSON.parse(xhr.responseText);
@@ -205,6 +237,13 @@ async function uploadItem(id: string): Promise<void> {
await database.put(STORE_NAME, entry);
updateItemStatus(id, 'done');
} catch (e) {
if (e instanceof RateLimitError) {
// Reset to pending so it will be retried when the queue resumes
entry.status = 'pending';
await database.put(STORE_NAME, entry);
updateItemStatus(id, 'pending');
throw e; // Propagate to processQueue for scheduling
}
const msg = e instanceof Error ? e.message : 'Upload fehlgeschlagen.';
entry.status = 'error';
entry.error = msg;
@@ -224,7 +263,7 @@ function updateItemStatus(
? {
...item,
status,
progress: status === 'done' ? 100 : status === 'error' ? item.progress : item.progress,
progress: status === 'done' ? 100 : status === 'pending' ? 0 : item.progress,
error
}
: item

View File

@@ -10,6 +10,13 @@
let pin = $state('');
let copied = $state(false);
// Name-taken state — shown instead of the normal form
let nameTaken = $state(false);
let takenName = $state('');
let recoveryPin = $state('');
let recoveryError = $state('');
let recoveryLoading = $state(false);
async function handleJoin() {
if (!displayName.trim()) return;
loading = true;
@@ -26,7 +33,10 @@
pin = res.pin;
showPinModal = true;
} catch (e) {
if (e instanceof ApiError) {
if (e instanceof ApiError && e.code === 'conflict') {
takenName = displayName.trim();
nameTaken = true;
} else if (e instanceof ApiError) {
error = e.message;
} else {
error = 'Ein Fehler ist aufgetreten.';
@@ -36,6 +46,35 @@
}
}
async function handleInlineRecover() {
if (recoveryPin.length < 4) return;
recoveryLoading = true;
recoveryError = '';
try {
const res = await api.post<{ jwt: string; user_id: string }>(
'/recover',
{ display_name: takenName, pin: recoveryPin.trim() }
);
setAuth(res.jwt, recoveryPin.trim(), res.user_id, takenName);
goto('/feed');
} catch (e) {
if (e instanceof ApiError) {
recoveryError = e.message;
} else {
recoveryError = 'Ein Fehler ist aufgetreten.';
}
} finally {
recoveryLoading = false;
}
}
function tryDifferentName() {
nameTaken = false;
recoveryPin = '';
recoveryError = '';
// Keep displayName so the user can edit it slightly
}
function copyPin() {
navigator.clipboard.writeText(pin);
copied = true;
@@ -49,35 +88,85 @@
<div class="flex min-h-screen items-center justify-center bg-gray-50 px-4">
<div class="w-full max-w-sm">
<h1 class="mb-2 text-center text-2xl font-bold text-gray-900">Willkommen!</h1>
<p class="mb-6 text-center text-gray-600">Gib deinen Namen ein, um dem Event beizutreten.</p>
<form onsubmit={(e) => { e.preventDefault(); handleJoin(); }}>
<input
type="text"
bind:value={displayName}
placeholder="Dein Name"
maxlength={50}
class="mb-3 w-full rounded-lg border border-gray-300 px-4 py-3 text-lg focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200"
/>
{#if nameTaken}
<!-- Name-taken state: sign in with PIN or choose a different name -->
<div class="mb-5 rounded-lg border border-amber-200 bg-amber-50 p-4">
<p class="font-semibold text-amber-900">{takenName}" ist bereits vergeben.</p>
<p class="mt-1 text-sm text-amber-800">
Wähle einen anderen Namen, z.&nbsp;B. einen Spitznamen oder füge deinen Nachnamen hinzu
(„{takenName} M." oder „{takenName} aus Berlin").
</p>
</div>
{#if error}
<p class="mb-3 text-sm text-red-600">{error}</p>
{/if}
<p class="mb-3 text-sm font-medium text-gray-700">
Falls du das bist, melde dich mit deinem PIN an:
</p>
<form onsubmit={(e) => { e.preventDefault(); handleInlineRecover(); }}>
<input
type="text"
bind:value={recoveryPin}
placeholder="4-stelliger PIN"
maxlength={4}
inputmode="numeric"
pattern="[0-9]*"
class="mb-3 w-full rounded-lg border border-gray-300 px-4 py-3 text-center text-2xl font-mono tracking-widest focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200"
/>
{#if recoveryError}
<p class="mb-3 text-sm text-red-600">{recoveryError}</p>
{/if}
<button
type="submit"
disabled={recoveryLoading || recoveryPin.length < 4}
class="mb-3 w-full rounded-lg bg-blue-600 px-4 py-3 font-medium text-white transition hover:bg-blue-700 disabled:opacity-50"
>
{recoveryLoading ? 'Wird angemeldet...' : 'Anmelden'}
</button>
</form>
<button
type="submit"
disabled={loading || !displayName.trim()}
class="w-full rounded-lg bg-blue-600 px-4 py-3 text-lg font-medium text-white transition hover:bg-blue-700 disabled:opacity-50"
onclick={tryDifferentName}
class="w-full rounded-lg border border-gray-300 px-4 py-3 font-medium text-gray-700 transition hover:bg-gray-50"
>
{loading ? 'Wird geladen...' : 'Beitreten'}
Anderen Namen wählen
</button>
</form>
<p class="mt-4 text-center text-sm text-gray-500">
Schon dabei?
<a href="/recover" class="text-blue-600 hover:underline">Mit PIN wiederherstellen</a>
</p>
{:else}
<!-- Normal join form -->
<h1 class="mb-2 text-center text-2xl font-bold text-gray-900">Willkommen!</h1>
<p class="mb-6 text-center text-gray-600">Gib deinen Namen ein, um dem Event beizutreten.</p>
<form onsubmit={(e) => { e.preventDefault(); handleJoin(); }}>
<input
type="text"
bind:value={displayName}
placeholder="Dein Name"
maxlength={50}
class="mb-3 w-full rounded-lg border border-gray-300 px-4 py-3 text-lg focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200"
/>
{#if error}
<p class="mb-3 text-sm text-red-600">{error}</p>
{/if}
<button
type="submit"
disabled={loading || !displayName.trim()}
class="w-full rounded-lg bg-blue-600 px-4 py-3 text-lg font-medium text-white transition hover:bg-blue-700 disabled:opacity-50"
>
{loading ? 'Wird geladen...' : 'Beitreten'}
</button>
</form>
<p class="mt-4 text-center text-sm text-gray-500">
Schon dabei?
<a href="/recover" class="text-blue-600 hover:underline">Mit PIN wiederherstellen</a>
</p>
{/if}
</div>
</div>