4 Commits

Author SHA1 Message Date
MechaCat02
53a28d361b fix: add TraceLayer and Vite proxy config (supporting changes)
- Add tower-http TraceLayer to Axum router for request logging
- Configure Vite dev server proxy for /api and /media to localhost:3000
- Add TEST_GUIDE.md for manual frontend testing steps

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 18:14:06 +02:00
MechaCat02
c58216268f fix: drain multipart body before returning early upload errors
When the rate limit, ban, or event-lock check fired before the multipart
body was consumed, the HTTP keep-alive connection was left in a bad state.
The next request from the Vite proxy then received a connection reset,
causing cascading "Netzwerkfehler" / empty-body 500 responses.

Fix: call drain_multipart() before every early return in the upload
handler so the connection remains clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 18:07:27 +02:00
MechaCat02
ee7c703c13 fix: disable default 2MB body limit on upload route
Axum's DefaultBodyLimit defaults to 2MB. Uploaded photos/videos exceed
this and were rejected with 400 before reaching the handler. Disable the
body limit on POST /api/v1/upload — size validation is already enforced
inside the handler via max_image_size_mb / max_video_size_mb config.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 14:41:01 +02:00
MechaCat02
a33b2c4d41 fix: serialize UserRole as lowercase in JWT
Add #[serde(rename_all = "lowercase")] to UserRole enum so JWT payloads
contain lowercase role strings ("guest", "host", "admin") matching what
the frontend route guards and getRole() comparisons expect.

Without this, JWTs contained PascalCase ("Guest", "Host", "Admin")
causing hosts and admins to be redirected to /join instead of their
dashboards.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 14:11:44 +02:00
5 changed files with 68 additions and 3 deletions

42
TEST_GUIDE.md Normal file
View File

@@ -0,0 +1,42 @@
## Frontend Testing — Step by Step
Please test each step in order and report any errors (console errors, wrong text, broken UI, API errors).
### Step 1 — Join flow + PIN modal
1. Open **http://localhost:5173/** in your browser (or navigate there if already open)
2. You should land on the **join page** (`/join`) with a name input
3. Enter your name (e.g. `Max`) and click **Beitreten**
4. ✅ Expected: A modal appears showing your 4-digit PIN in large monospace font with a "Kopieren" button
5. Click **Weiter zur Galerie**
### Step 2 — Onboarding guide
6. You should land on the **feed page** (`/feed`)
7. ✅ Expected: A dark overlay appears at the bottom (or center on desktop) — the onboarding guide — showing step 1 of 4 with a step indicator and the Willkommen screen
8. Click **Weiter** through all 4 steps, then **Los geht's!**
9. ✅ Expected: Overlay disappears
### Step 3 — Feed & navigation
10. ✅ Expected: Feed shows "Noch keine Fotos." empty state with an upload button
11. ✅ Expected: Top-right has an **upload button** (blue) and a **person icon** link
### Step 4 — My Account page
12. Click the **person icon** in the top-right
13. ✅ Expected: `/account` page shows your name (`Max`), a blue "Gast" badge, session expiry date, and your PIN displayed large in an amber box
14. Click **Kopieren** — check clipboard contains your PIN
15. ✅ Expected: Button briefly shows "Kopiert!"
16. Click **Zur Galerie** to go back to the feed
### Step 5 — Upload
17. Click **Hochladen** — this takes you to `/upload`
18. Try uploading a photo from your device library
19. ✅ Expected: Photo appears in queue with a progress bar, then completes
20. Go back to `/feed` — ✅ Expected: your photo appears in the feed grid
### Step 6 — Onboarding guide not shown again
21. Reload the page at `/feed`
22. ✅ Expected: The onboarding overlay does **not** appear (already dismissed)
### Step 7 — Recover (open a private/incognito window)
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

View File

@@ -24,6 +24,7 @@ pub async fn upload(
.rate_limiter
.check(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(),
));
@@ -34,6 +35,7 @@ pub async fn upload(
.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()));
}
@@ -42,6 +44,7 @@ pub async fn upload(
.await?
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
if event.uploads_locked_at.is_some() {
drain_multipart(multipart).await;
return Err(AppError::Forbidden("Uploads sind gesperrt.".into()));
}
@@ -239,6 +242,15 @@ pub async fn delete_upload(
Ok(StatusCode::NO_CONTENT)
}
/// Drain a multipart body so the HTTP connection stays clean when returning an early error.
/// Without draining, the client may still be sending the body after we've sent our response,
/// which can corrupt the keep-alive connection for subsequent requests.
async fn drain_multipart(mut mp: Multipart) {
while let Ok(Some(mut field)) = mp.next_field().await {
while field.chunk().await.ok().flatten().is_some() {}
}
}
async fn get_config_i64(pool: &sqlx::PgPool, key: &str, default: i64) -> i64 {
let row: Option<(String,)> =
sqlx::query_as("SELECT value FROM config WHERE key = $1")

View File

@@ -1,7 +1,9 @@
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};
mod auth;
@@ -40,8 +42,9 @@ 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
.route("/api/v1/upload", post(handlers::upload::upload))
// Upload — body limit disabled; size validation is done inside the handler
.route("/api/v1/upload", post(handlers::upload::upload)
.route_layer(DefaultBodyLimit::disable()))
.route(
"/api/v1/upload/{id}",
patch(handlers::upload::edit_upload).delete(handlers::upload::delete_upload),
@@ -89,6 +92,7 @@ async fn main() -> Result<()> {
.route("/health", get(|| async { "ok" }))
.merge(api)
.nest_service("/media", media_service)
.layer(TraceLayer::new_for_http())
.with_state(state);
let listener = tokio::net::TcpListener::bind(("0.0.0.0", config.app_port)).await?;

View File

@@ -4,6 +4,7 @@ use sqlx::PgPool;
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
#[serde(rename_all = "lowercase")]
#[sqlx(type_name = "user_role", rename_all = "lowercase")]
pub enum UserRole {
Guest,

View File

@@ -3,5 +3,11 @@ import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [tailwindcss(), sveltekit()]
plugins: [tailwindcss(), sveltekit()],
server: {
proxy: {
'/api': 'http://localhost:3000',
'/media': 'http://localhost:3000'
}
}
});