5 Commits

Author SHA1 Message Date
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
MechaCat02
87b5aff478 feat: implement onboarding guide and HTML export guide
Add 4-step dismissible onboarding overlay shown on first feed visit
(welcome, upload, hashtags, PIN importance). Dismissed state persisted
in localStorage under eventsnap_guide_seen. Step indicator dots and
skip/continue buttons included.

Update HTML export guide modal to persist the eventsnap_html_guide_seen
flag: first download shows the instructions modal; subsequent clicks go
straight to download without interruption.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 21:13:14 +02:00
MechaCat02
75d186fad3 feat: implement My Account page
Add /account route showing display name (from localStorage), role badge,
session expiry decoded from JWT, and recovery PIN display with copy button.
Join and recover flows now persist display_name to localStorage via setAuth().
Feed header logout button replaced with person-icon link to /account;
logout is available from the account page.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 21:11:11 +02:00
MechaCat02
989d88022a feat: implement rate limiting across all API endpoints
Add sliding-window in-memory RateLimiter service (Arc<Mutex<HashMap>>)
with per-IP and per-user-id limits on all public endpoint classes:
- POST /api/v1/join: 5/min per IP
- GET /api/v1/feed: configurable per IP (feed_rate_per_min, default 60)
- POST /api/v1/upload: configurable per user (upload_rate_per_hour, default 10)
- GET /api/v1/export/zip|html: configurable per IP (export_rate_per_day, default 3)
Limits are hot-reloadable via the config table. All 429 responses use
German error messages. Client IP is read from X-Forwarded-For (Caddy).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 21:03:59 +02:00
MechaCat02
258e2bd84d feat: implement export engine
Add async ZIP and HTML offline viewer export workers, download endpoints,
and a guest-facing /export page.

Backend — export workers (tokio::spawn, run after gallery release):
- ZIP worker: streams all non-deleted originals into Gallery.zip via
  async_zip (Stored compression), organised into Photos/ and Videos/
  with {date}_{uploader}_{id}.{ext} filenames; updates progress_pct in DB
- HTML worker: renders Memories.html via minijinja template (self-contained:
  inlined CSS + JS, relative media paths); packs it with README.txt and
  all media into Memories.zip (Deflate for text, Stored for media)
- Both workers mark export_job status (running → done/failed), update
  export_zip_ready / export_html_ready on the event, and broadcast SSE
  export-progress + export-available when both complete

Backend — new endpoints (AuthUser):
- GET /export/zip   → streams Gallery.zip if export_zip_ready
- GET /export/html  → streams Memories.zip if export_html_ready
- GET /export/status → released flag + per-type status/progress (moved from admin)

Memories.html features: warm keepsake aesthetic, responsive grid, fullscreen
lightbox with captions/comments/likes, client-side hashtag filter chips,
XSS-safe JS, fully offline (no external deps)

Frontend — /export page:
- Locked state: padlock illustration + message
- Released state: ZIP and HTML cards with progress bars (SSE-driven),
  download buttons enabled only when ready
- HTML guide modal (unzip instructions + Wi-Fi tip) before download begins

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 20:56:21 +02:00
21 changed files with 1264 additions and 23 deletions

1
backend/Cargo.lock generated
View File

@@ -907,6 +907,7 @@ dependencies = [
"sysinfo",
"tokio",
"tokio-stream",
"tokio-util",
"tower",
"tower-http",
"tower_governor",

View File

@@ -17,6 +17,7 @@ bcrypt = "0.15"
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
tokio-stream = { version = "0.1", features = ["sync"] }
tokio-util = { version = "0.7", features = ["io", "compat"] }
futures = "0.3"
sha2 = "0.10"
rand = "0.9"

View File

@@ -1,7 +1,9 @@
use std::time::Duration;
use axum::extract::State;
use axum::http::StatusCode;
use axum::http::{HeaderMap, StatusCode};
use axum::Json;
use chrono::{Duration, Utc};
use chrono::Utc;
use rand::Rng;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
@@ -12,6 +14,7 @@ use crate::error::AppError;
use crate::models::event::Event;
use crate::models::session::Session;
use crate::models::user::{User, UserRole};
use crate::services::rate_limiter::client_ip;
use crate::state::AppState;
#[derive(Deserialize)]
@@ -29,8 +32,16 @@ pub struct JoinResponse {
pub async fn join(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<JoinRequest>,
) -> Result<(StatusCode, Json<JoinResponse>), AppError> {
let ip = client_ip(&headers, "unknown");
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(),
));
}
let display_name = body.display_name.trim();
if display_name.is_empty() || display_name.len() > 50 {
return Err(AppError::BadRequest(
@@ -62,7 +73,7 @@ pub async fn join(
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
let token_hash = jwt::hash_token(&token);
let expires_at = Utc::now() + Duration::days(state.config.session_expiry_days);
let expires_at = Utc::now() + chrono::Duration::days(state.config.session_expiry_days);
Session::create(&state.pool, user.id, &token_hash, expires_at).await?;
Ok((
@@ -134,7 +145,7 @@ pub async fn recover(
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
let token_hash = jwt::hash_token(&token);
let expires_at = Utc::now() + Duration::days(state.config.session_expiry_days);
let expires_at = Utc::now() + chrono::Duration::days(state.config.session_expiry_days);
Session::create(&state.pool, user.id, &token_hash, expires_at).await?;
return Ok(Json(RecoverResponse {
@@ -146,7 +157,7 @@ pub async fn recover(
// Wrong PIN — increment failure count
let attempts = User::increment_failed_pin(&state.pool, user.id).await?;
if attempts >= 3 {
let lockout = Utc::now() + Duration::minutes(15);
let lockout = Utc::now() + chrono::Duration::minutes(15);
User::lock_pin(&state.pool, user.id, lockout).await?;
}
}
@@ -217,7 +228,7 @@ pub async fn admin_login(
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
let token_hash = jwt::hash_token(&token);
let expires_at = Utc::now() + Duration::days(1);
let expires_at = Utc::now() + chrono::Duration::days(1);
Session::create(&state.pool, admin_user.id, &token_hash, expires_at).await?;
Ok(Json(AdminLoginResponse { jwt: token }))

View File

@@ -1,12 +1,15 @@
use std::collections::HashMap;
use std::time::Duration;
use axum::extract::State;
use axum::http::StatusCode;
use axum::http::{HeaderMap, StatusCode};
use axum::Json;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use sysinfo::System;
use crate::auth::middleware::RequireAdmin;
use crate::error::AppError;
use crate::services::rate_limiter::client_ip;
use crate::state::AppState;
// ── DTOs ─────────────────────────────────────────────────────────────────────
@@ -167,6 +170,101 @@ pub async fn get_export_jobs(
Ok(Json(jobs))
}
// ── Export download endpoints (authenticated guests) ─────────────────────────
pub async fn download_zip(
State(state): State<AppState>,
_auth: crate::auth::middleware::AuthUser,
headers: HeaderMap,
) -> Result<axum::response::Response, AppError> {
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(),
));
}
let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)
.await?
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
if !event.export_zip_ready {
return Err(AppError::NotFound(
"Der ZIP-Export ist noch nicht verfügbar.".into(),
));
}
let path = state.config.media_path.join("exports").join("Gallery.zip");
if !path.exists() {
return Err(AppError::NotFound("Exportdatei nicht gefunden.".into()));
}
serve_file(path, "Gallery.zip", "application/zip").await
}
pub async fn download_html(
State(state): State<AppState>,
_auth: crate::auth::middleware::AuthUser,
headers: HeaderMap,
) -> Result<axum::response::Response, AppError> {
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(),
));
}
let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)
.await?
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
if !event.export_html_ready {
return Err(AppError::NotFound(
"Der HTML-Export ist noch nicht verfügbar.".into(),
));
}
let path = state.config.media_path.join("exports").join("Memories.zip");
if !path.exists() {
return Err(AppError::NotFound("Exportdatei nicht gefunden.".into()));
}
serve_file(path, "Memories.zip", "application/zip").await
}
async fn serve_file(
path: std::path::PathBuf,
filename: &str,
content_type: &str,
) -> Result<axum::response::Response, AppError> {
use axum::body::Body;
use axum::http::{header, Response, StatusCode};
use tokio_util::io::ReaderStream;
let file = tokio::fs::File::open(&path)
.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);
let disposition = format!("attachment; filename=\"{filename}\"");
let response = Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, content_type)
.header(header::CONTENT_DISPOSITION, disposition)
.header(header::CONTENT_LENGTH, metadata.len())
.body(Body::from_stream(stream))
.map_err(|e| AppError::Internal(e.into()))?;
Ok(response)
}
/// Also expose export status to all authenticated users (guests need it for the export page)
pub async fn export_status(
State(state): State<AppState>,
@@ -200,3 +298,13 @@ pub async fn export_status(
"html": job_status("html"),
})))
}
async fn get_config_usize(pool: &sqlx::PgPool, key: &str, default: usize) -> usize {
let row: Option<(String,)> =
sqlx::query_as("SELECT value FROM config WHERE key = $1")
.bind(key)
.fetch_optional(pool)
.await
.unwrap_or(None);
row.and_then(|r| r.0.parse().ok()).unwrap_or(default)
}

View File

@@ -1,4 +1,7 @@
use std::time::Duration;
use axum::extract::{Query, State};
use axum::http::HeaderMap;
use axum::Json;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
@@ -6,6 +9,7 @@ use uuid::Uuid;
use crate::auth::middleware::AuthUser;
use crate::error::AppError;
use crate::services::rate_limiter::client_ip;
use crate::state::AppState;
#[derive(Deserialize)]
@@ -53,8 +57,17 @@ struct FeedRow {
pub async fn feed(
State(state): State<AppState>,
auth: AuthUser,
headers: HeaderMap,
Query(q): Query<FeedQuery>,
) -> Result<Json<FeedResponse>, AppError> {
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(),
));
}
let limit = q.limit.unwrap_or(20).min(100);
let rows = if let Some(hashtag) = &q.hashtag {
@@ -227,6 +240,16 @@ pub async fn hashtags(
))
}
async fn get_config_usize(pool: &sqlx::PgPool, key: &str, default: usize) -> usize {
let row: Option<(String,)> =
sqlx::query_as("SELECT value FROM config WHERE key = $1")
.bind(key)
.fetch_optional(pool)
.await
.unwrap_or(None);
row.and_then(|r| r.0.parse().ok()).unwrap_or(default)
}
async fn get_cursor_time(pool: &sqlx::PgPool, cursor_id: Uuid) -> Option<DateTime<Utc>> {
let row: Option<(DateTime<Utc>,)> =
sqlx::query_as("SELECT created_at FROM upload WHERE id = $1")

View File

@@ -244,7 +244,7 @@ pub async fn release_gallery(
.execute(&state.pool)
.await?;
// Enqueue export jobs (processed by the export worker in a later step)
// Enqueue export jobs
for export_type in ["zip", "html"] {
sqlx::query(
"INSERT INTO export_job (event_id, type) VALUES ($1, $2::export_type)
@@ -256,5 +256,14 @@ pub async fn release_gallery(
.await?;
}
// Spawn export workers
crate::services::export::spawn_export_jobs(
event.id,
event.name,
state.pool.clone(),
state.config.media_path.clone(),
state.sse_tx.clone(),
);
Ok(StatusCode::NO_CONTENT)
}

View File

@@ -1,3 +1,5 @@
use std::time::Duration;
use axum::extract::{Multipart, Path, State};
use axum::http::StatusCode;
use axum::Json;
@@ -16,6 +18,17 @@ pub async fn upload(
auth: AuthUser,
mut multipart: Multipart,
) -> 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
.rate_limiter
.check(format!("upload:{}", auth.user_id), upload_rate, Duration::from_secs(3600))
{
return Err(AppError::TooManyRequests(
"Du hast dein Upload-Limit für diese Stunde erreicht.".into(),
));
}
// Check if user is banned
let user = User::find_by_id(&state.pool, auth.user_id)
.await?

View File

@@ -70,8 +70,10 @@ async fn main() -> Result<()> {
.route("/api/v1/host/users/{id}/role", patch(handlers::host::set_role))
.route("/api/v1/host/upload/{id}", delete(handlers::host::host_delete_upload))
.route("/api/v1/host/comment/{id}", delete(handlers::host::host_delete_comment))
// Export status (all authenticated users)
// Export (all authenticated users)
.route("/api/v1/export/status", get(handlers::admin::export_status))
.route("/api/v1/export/zip", get(handlers::admin::download_zip))
.route("/api/v1/export/html", get(handlers::admin::download_html))
// Admin Dashboard
.route("/api/v1/admin/stats", get(handlers::admin::get_stats))
.route(

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

@@ -0,0 +1,569 @@
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use async_zip::tokio::write::ZipFileWriter;
use async_zip::{Compression, ZipEntryBuilder};
use chrono::{DateTime, Utc};
use futures::io::{copy as fcopy, AllowStdIo};
use serde::Serialize;
use sqlx::PgPool;
use tokio::sync::broadcast;
use tokio_util::compat::TokioAsyncReadCompatExt;
use uuid::Uuid;
use crate::state::SseEvent;
// ── DB query rows ────────────────────────────────────────────────────────────
#[derive(sqlx::FromRow)]
struct ExportUploadRow {
id: Uuid,
original_path: String,
mime_type: String,
caption: Option<String>,
uploader_name: String,
like_count: i64,
created_at: DateTime<Utc>,
}
#[derive(sqlx::FromRow)]
struct ExportCommentRow {
upload_id: Uuid,
uploader_name: String,
body: String,
created_at: DateTime<Utc>,
}
// ── Template context structs ─────────────────────────────────────────────────
#[derive(Serialize)]
struct TmplComment {
uploader_name: String,
body: String,
created_at: String,
}
#[derive(Serialize)]
struct TmplUpload {
id: String,
path: String,
is_video: bool,
caption: String,
uploader_name: String,
like_count: i64,
created_at: String,
comments: Vec<TmplComment>,
hashtags: Vec<String>,
}
// ── Entry point ──────────────────────────────────────────────────────────────
pub fn spawn_export_jobs(
event_id: Uuid,
event_name: String,
pool: PgPool,
media_path: PathBuf,
sse_tx: broadcast::Sender<SseEvent>,
) {
let pool2 = pool.clone();
let media_path2 = media_path.clone();
let sse_tx2 = sse_tx.clone();
let event_name2 = event_name.clone();
tokio::spawn(async move {
if let Err(e) = run_zip_export(event_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;
}
maybe_broadcast_complete(&pool, event_id, &sse_tx).await;
});
tokio::spawn(async move {
if let Err(e) =
run_html_export(event_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;
}
maybe_broadcast_complete(&pool2, event_id, &sse_tx2).await;
});
}
// ── ZIP export ───────────────────────────────────────────────────────────────
async fn run_zip_export(
event_id: Uuid,
pool: &PgPool,
media_path: &Path,
sse_tx: &broadcast::Sender<SseEvent>,
) -> Result<()> {
mark_running(pool, event_id, "zip").await;
let uploads = query_uploads(pool, event_id).await?;
let total = uploads.len().max(1) as f32;
let exports_dir = media_path.join("exports");
tokio::fs::create_dir_all(&exports_dir).await?;
let tmp_path = exports_dir.join("Gallery.zip.tmp");
let out_path = exports_dir.join("Gallery.zip");
{
let file = tokio::fs::File::create(&tmp_path).await?;
let mut zip = ZipFileWriter::with_tokio(file);
for (i, row) in uploads.iter().enumerate() {
let src = media_path.join(&row.original_path);
if !src.exists() {
continue;
}
let ext = ext_from_path(&row.original_path);
let date = row.created_at.format("%Y-%m-%d_%H-%M").to_string();
let name_safe = sanitize_name(&row.uploader_name);
let folder = if row.mime_type.starts_with("video/") { "Videos" } else { "Photos" };
let entry_name = format!("{folder}/{date}_{name_safe}_{}.{ext}", row.id);
let builder = ZipEntryBuilder::new(entry_name.into(), Compression::Stored);
let mut entry = zip.write_entry_stream(builder).await?;
let mut f = tokio::fs::File::open(&src).await?.compat();
fcopy(&mut f, &mut entry).await?;
entry.close().await?;
let pct = ((i + 1) as f32 / total * 100.0) as i16;
update_progress(pool, event_id, "zip", pct.min(99)).await;
}
zip.close().await?;
}
tokio::fs::rename(&tmp_path, &out_path).await?;
sqlx::query(
"UPDATE export_job SET status = 'done', progress_pct = 100, file_path = $2, completed_at = NOW()
WHERE event_id = $1 AND type = 'zip'::export_type",
)
.bind(event_id)
.bind("exports/Gallery.zip")
.execute(pool)
.await?;
sqlx::query("UPDATE event SET export_zip_ready = TRUE WHERE id = $1")
.bind(event_id)
.execute(pool)
.await?;
let _ = sse_tx.send(SseEvent {
event_type: "export-progress".to_string(),
data: serde_json::json!({ "type": "zip", "progress_pct": 100 }).to_string(),
});
tracing::info!("ZIP export complete for event {event_id}");
Ok(())
}
// ── HTML export ──────────────────────────────────────────────────────────────
async fn run_html_export(
event_id: Uuid,
event_name: &str,
pool: &PgPool,
media_path: &Path,
sse_tx: &broadcast::Sender<SseEvent>,
) -> Result<()> {
mark_running(pool, event_id, "html").await;
let uploads = query_uploads(pool, event_id).await?;
let comments = query_comments(pool, event_id).await?;
let hashtags_per_upload = query_hashtags(pool, event_id).await?;
let total = uploads.len().max(1) as f32;
let exports_dir = media_path.join("exports");
tokio::fs::create_dir_all(&exports_dir).await?;
// Build template context
let mut tmpl_uploads: Vec<TmplUpload> = Vec::new();
for (i, row) in uploads.iter().enumerate() {
let ext = ext_from_path(&row.original_path);
let date_str = row.created_at.format("%Y-%m-%d_%H-%M").to_string();
let name_safe = sanitize_name(&row.uploader_name);
let folder = if row.mime_type.starts_with("video/") { "Videos" } else { "Photos" };
let filename = format!("{date_str}_{name_safe}_{}.{ext}", row.id);
let upload_comments: Vec<TmplComment> = comments
.iter()
.filter(|c| c.upload_id == row.id)
.map(|c| TmplComment {
uploader_name: c.uploader_name.clone(),
body: c.body.clone(),
created_at: c.created_at.format("%d.%m.%Y %H:%M").to_string(),
})
.collect();
let tags: Vec<String> = hashtags_per_upload
.iter()
.filter(|(uid, _)| *uid == row.id)
.map(|(_, tag)| tag.clone())
.collect();
tmpl_uploads.push(TmplUpload {
id: row.id.to_string(),
path: format!("{folder}/{filename}"),
is_video: row.mime_type.starts_with("video/"),
caption: row.caption.clone().unwrap_or_default(),
uploader_name: row.uploader_name.clone(),
like_count: row.like_count,
created_at: row.created_at.format("%d.%m.%Y %H:%M").to_string(),
comments: upload_comments,
hashtags: tags,
});
let pct = ((i + 1) as f32 / total * 50.0) as i16;
update_progress(pool, event_id, "html", pct.min(49)).await;
}
// Render HTML
let mut env = minijinja::Environment::new();
env.add_template("memories", MEMORIES_TEMPLATE)
.context("template compile error")?;
let tmpl = env.get_template("memories").unwrap();
let html = tmpl
.render(minijinja::context!(
event_name => event_name,
uploads => minijinja::Value::from_serialize(&tmpl_uploads),
generated_at => Utc::now().format("%d.%m.%Y").to_string(),
))
.context("template render error")?;
update_progress(pool, event_id, "html", 55).await;
let tmp_path = exports_dir.join("Memories.zip.tmp");
let out_path = exports_dir.join("Memories.zip");
{
let file = tokio::fs::File::create(&tmp_path).await?;
let mut zip = ZipFileWriter::with_tokio(file);
// Memories.html
{
let builder =
ZipEntryBuilder::new("Memories/Memories.html".into(), Compression::Deflate);
let mut entry = zip.write_entry_stream(builder).await?;
let mut cursor = AllowStdIo::new(std::io::Cursor::new(html.as_bytes()));
fcopy(&mut cursor, &mut entry).await?;
entry.close().await?;
}
update_progress(pool, event_id, "html", 60).await;
// README.txt
{
let builder =
ZipEntryBuilder::new("Memories/README.txt".into(), Compression::Deflate);
let mut entry = zip.write_entry_stream(builder).await?;
let mut cursor = AllowStdIo::new(std::io::Cursor::new(README_TEXT.as_bytes()));
fcopy(&mut cursor, &mut entry).await?;
entry.close().await?;
}
// Media files
for (i, row) in uploads.iter().enumerate() {
let src = media_path.join(&row.original_path);
if !src.exists() {
continue;
}
let ext = ext_from_path(&row.original_path);
let date_str = row.created_at.format("%Y-%m-%d_%H-%M").to_string();
let name_safe = sanitize_name(&row.uploader_name);
let folder = if row.mime_type.starts_with("video/") { "Videos" } else { "Photos" };
let filename = format!("{date_str}_{name_safe}_{}.{ext}", row.id);
let entry_name = format!("Memories/{folder}/{filename}");
let builder = ZipEntryBuilder::new(entry_name.into(), Compression::Stored);
let mut entry = zip.write_entry_stream(builder).await?;
let mut f = tokio::fs::File::open(&src).await?.compat();
fcopy(&mut f, &mut entry).await?;
entry.close().await?;
let pct = 60 + ((i + 1) as f32 / total * 39.0) as i16;
update_progress(pool, event_id, "html", pct.min(99)).await;
}
zip.close().await?;
}
tokio::fs::rename(&tmp_path, &out_path).await?;
sqlx::query(
"UPDATE export_job SET status = 'done', progress_pct = 100, file_path = $2, completed_at = NOW()
WHERE event_id = $1 AND type = 'html'::export_type",
)
.bind(event_id)
.bind("exports/Memories.zip")
.execute(pool)
.await?;
sqlx::query("UPDATE event SET export_html_ready = TRUE WHERE id = $1")
.bind(event_id)
.execute(pool)
.await?;
let _ = sse_tx.send(SseEvent {
event_type: "export-progress".to_string(),
data: serde_json::json!({ "type": "html", "progress_pct": 100 }).to_string(),
});
tracing::info!("HTML export complete for event {event_id}");
Ok(())
}
// ── DB helpers ───────────────────────────────────────────────────────────────
async fn query_uploads(pool: &PgPool, event_id: Uuid) -> Result<Vec<ExportUploadRow>> {
Ok(sqlx::query_as::<_, ExportUploadRow>(
"SELECT u.id, u.original_path, u.mime_type, u.caption,
usr.display_name AS uploader_name,
COUNT(DISTINCT l.user_id) AS like_count,
u.created_at
FROM upload u
JOIN \"user\" usr ON usr.id = u.user_id
LEFT JOIN \"like\" l ON l.upload_id = u.id
WHERE u.event_id = $1 AND u.deleted_at IS NULL AND usr.uploads_hidden = FALSE
GROUP BY u.id, usr.display_name
ORDER BY u.created_at ASC",
)
.bind(event_id)
.fetch_all(pool)
.await?)
}
async fn query_comments(pool: &PgPool, event_id: Uuid) -> Result<Vec<ExportCommentRow>> {
Ok(sqlx::query_as::<_, ExportCommentRow>(
"SELECT c.upload_id, usr.display_name AS uploader_name, c.body, c.created_at
FROM comment c
JOIN \"user\" usr ON usr.id = c.user_id
JOIN upload u ON u.id = c.upload_id
WHERE u.event_id = $1 AND c.deleted_at IS NULL AND u.deleted_at IS NULL
ORDER BY c.created_at ASC",
)
.bind(event_id)
.fetch_all(pool)
.await?)
}
async fn query_hashtags(pool: &PgPool, event_id: Uuid) -> Result<Vec<(Uuid, String)>> {
let rows: Vec<(Uuid, String)> = sqlx::query_as(
"SELECT uh.upload_id, h.tag
FROM upload_hashtag uh
JOIN hashtag h ON h.id = uh.hashtag_id
JOIN upload u ON u.id = uh.upload_id
WHERE h.event_id = $1 AND u.deleted_at IS NULL",
)
.bind(event_id)
.fetch_all(pool)
.await?;
Ok(rows)
}
async fn mark_running(pool: &PgPool, event_id: Uuid, export_type: &str) {
let _ = sqlx::query(
"UPDATE export_job SET status = 'running' WHERE event_id = $1 AND type = $2::export_type",
)
.bind(event_id)
.bind(export_type)
.execute(pool)
.await;
}
async fn mark_failed(pool: &PgPool, event_id: Uuid, export_type: &str, msg: &str) {
let _ = sqlx::query(
"UPDATE export_job SET status = 'failed', error_message = $3
WHERE event_id = $1 AND type = $2::export_type",
)
.bind(event_id)
.bind(export_type)
.bind(msg)
.execute(pool)
.await;
}
async fn update_progress(pool: &PgPool, event_id: Uuid, export_type: &str, pct: i16) {
let _ = sqlx::query(
"UPDATE export_job SET progress_pct = $3 WHERE event_id = $1 AND type = $2::export_type",
)
.bind(event_id)
.bind(export_type)
.bind(pct)
.execute(pool)
.await;
}
async fn maybe_broadcast_complete(
pool: &PgPool,
event_id: Uuid,
sse_tx: &broadcast::Sender<SseEvent>,
) {
let row: Option<(bool, bool)> = sqlx::query_as(
"SELECT export_zip_ready, export_html_ready FROM event WHERE id = $1",
)
.bind(event_id)
.fetch_optional(pool)
.await
.unwrap_or(None);
if let Some((zip_ready, html_ready)) = row {
if zip_ready && html_ready {
let _ = sse_tx.send(SseEvent {
event_type: "export-available".to_string(),
data: serde_json::json!({ "types": ["zip", "html"] }).to_string(),
});
}
}
}
fn ext_from_path(path: &str) -> &str {
path.rsplit('.').next().unwrap_or("bin")
}
fn sanitize_name(name: &str) -> String {
name.chars()
.map(|c| if c.is_alphanumeric() || c == '-' { c } else { '_' })
.collect()
}
// ── Static content ───────────────────────────────────────────────────────────
const README_TEXT: &str = "Willkommen in der Event-Galerie!\n\
\n\
So geht's:\n\
1. Entpacke diese ZIP-Datei\n\
(Windows: Rechtsklick > \"Alle extrahieren\"; Mac: Doppelklick;\n\
Handy: Dateimanager-App verwenden).\n\
2. Öffne die Datei \"Memories.html\" in deinem Browser\n\
(z. B. Chrome, Safari oder Firefox).\n\
3. Stöbere durch alle Fotos und Videos.\n\
Du kannst nach Hashtags filtern — klicke einfach auf einen Hashtag.\n\
4. Eine Internetverbindung ist nicht nötig.\n\
Alles ist lokal auf deinem Gerät gespeichert.\n\
\n\
Viel Freude mit den Erinnerungen!\n";
const MEMORIES_TEMPLATE: &str = r#"<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ event_name }} Erinnerungen</title>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{font-family:Georgia,serif;background:#faf7f2;color:#3d2b1f;min-height:100vh}
header{background:#fff8f0;border-bottom:1px solid #e8d9c8;padding:1.5rem 1rem;text-align:center}
header h1{font-size:1.75rem;font-weight:700;color:#5c3317;letter-spacing:.02em}
header p{font-size:.85rem;color:#9a7060;margin-top:.25rem}
.chips{display:flex;flex-wrap:wrap;gap:.5rem;padding:1rem;justify-content:center;border-bottom:1px solid #e8d9c8;background:#fff8f0}
.chip{cursor:pointer;padding:.3rem .8rem;border-radius:999px;border:1px solid #c8a98a;font-size:.8rem;color:#6b4c36;background:#fff;transition:all .15s}
.chip:hover,.chip.active{background:#c8a98a;color:#fff;border-color:#c8a98a}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:1rem;padding:1.25rem;max-width:1100px;margin:0 auto}
.card{background:#fff;border-radius:.75rem;overflow:hidden;box-shadow:0 1px 4px rgba(0,0,0,.08);cursor:pointer;transition:transform .15s,box-shadow .15s}
.card:hover{transform:translateY(-2px);box-shadow:0 4px 16px rgba(0,0,0,.12)}
.card.hidden{display:none}
.thumb-wrap{position:relative;width:100%;aspect-ratio:1;overflow:hidden;background:#e8d9c8}
.thumb{width:100%;height:100%;object-fit:cover;display:block}
.vid-icon{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:2.5rem;pointer-events:none}
.card-info{padding:.6rem .75rem}
.card-uploader{font-size:.75rem;color:#9a7060;margin-bottom:.2rem}
.card-caption{font-size:.85rem;color:#3d2b1f;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}
.card-meta{display:flex;align-items:center;gap:.5rem;margin-top:.4rem;font-size:.75rem;color:#b08060}
.lb{display:none;position:fixed;inset:0;z-index:100;background:rgba(0,0,0,.88);overflow-y:auto}
.lb.open{display:flex;flex-direction:column}
.lb-close{position:fixed;top:.75rem;right:1rem;font-size:1.75rem;color:#fff;cursor:pointer;z-index:101;line-height:1}
.lb-close:hover{color:#e8c89a}
.lb-media{max-width:900px;width:100%;margin:3rem auto 0;padding:0 .5rem}
.lb-media img,.lb-media video{width:100%;border-radius:.5rem;max-height:70vh;object-fit:contain;background:#1a1a1a;display:block}
.lb-details{max-width:900px;width:100%;margin:1rem auto 2rem;padding:0 1rem}
.lb-caption{font-size:1rem;color:#fff;margin-bottom:.5rem}
.lb-meta{font-size:.8rem;color:#b08060;margin-bottom:.75rem}
.lb-tags{display:flex;flex-wrap:wrap;gap:.4rem;margin-bottom:1rem}
.lb-tag{font-size:.75rem;color:#e8c89a;background:rgba(255,255,255,.1);padding:.2rem .6rem;border-radius:999px}
.lb-likes{font-size:.85rem;color:#d4a574;margin-bottom:.75rem}
.lb-comments h3{font-size:.9rem;color:#e8d9c8;margin-bottom:.5rem;font-weight:600}
.comment{border-top:1px solid rgba(255,255,255,.1);padding:.5rem 0}
.comment-name{font-size:.75rem;color:#b08060}
.comment-body{font-size:.85rem;color:#e8d9c8;margin-top:.15rem}
.empty{text-align:center;padding:3rem 1rem;color:#b08060;font-size:.95rem}
footer{text-align:center;padding:1.5rem;font-size:.75rem;color:#b08060;border-top:1px solid #e8d9c8;margin-top:2rem}
</style>
</head>
<body>
<header>
<h1>{{ event_name }}</h1>
<p>Erinnerungen · Erstellt am {{ generated_at }}</p>
</header>
{% set ns = namespace(all_tags=[]) %}
{% for u in uploads %}{% for t in u.hashtags %}{% if t not in ns.all_tags %}{% set ns.all_tags = ns.all_tags + [t] %}{% endif %}{% endfor %}{% endfor %}
{% if ns.all_tags %}
<div class="chips" id="chips">
<span class="chip active" data-tag="">Alle</span>
{% for tag in ns.all_tags %}<span class="chip" data-tag="{{ tag }}">#{{ tag }}</span>{% endfor %}
</div>
{% endif %}
{% if uploads %}
<div class="grid" id="grid">
{% for u in uploads %}
<div class="card" data-tags="{{ u.hashtags | join(',') }}" onclick="openLb({{ loop.index0 }})">
<div class="thumb-wrap">
{% if u.is_video %}
<video class="thumb" src="{{ u.path }}" preload="none"></video>
<div class="vid-icon">▶</div>
{% else %}
<img class="thumb" src="{{ u.path }}" alt="" loading="lazy">
{% endif %}
</div>
<div class="card-info">
<div class="card-uploader">{{ u.uploader_name }} · {{ u.created_at }}</div>
{% if u.caption %}<div class="card-caption">{{ u.caption }}</div>{% endif %}
<div class="card-meta">
<span>♡ {{ u.like_count }}</span>
{% if u.comments %}<span>💬 {{ u.comments | length }}</span>{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty">Noch keine Fotos vorhanden.</div>
{% endif %}
<div class="lb" id="lb">
<span class="lb-close" onclick="closeLb()">×</span>
<div class="lb-media" id="lb-media"></div>
<div class="lb-details" id="lb-details"></div>
</div>
<footer>{{ event_name }} · Offline-Galerie · EventSnap</footer>
<script>
const uploads = {{ uploads | tojson }};
let activeTag = '';
function filterCards(){document.querySelectorAll('#grid .card').forEach((card,i)=>{const tags=(card.dataset.tags||'').split(',').filter(Boolean);card.classList.toggle('hidden',activeTag!==''&&!tags.includes(activeTag));});}
document.querySelectorAll('#chips .chip').forEach(chip=>{chip.addEventListener('click',()=>{document.querySelectorAll('#chips .chip').forEach(c=>c.classList.remove('active'));chip.classList.add('active');activeTag=chip.dataset.tag;filterCards();});});
function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
function openLb(idx){
const u=uploads[idx];
const lb=document.getElementById('lb');
const media=document.getElementById('lb-media');
const details=document.getElementById('lb-details');
if(u.is_video){media.innerHTML=`<video src="${esc(u.path)}" controls autoplay playsinline></video>`;}
else{media.innerHTML=`<img src="${esc(u.path)}" alt="${esc(u.caption)}">`;}
const tags=u.hashtags.map(t=>`<span class="lb-tag">#${esc(t)}</span>`).join('');
const comments=u.comments.map(c=>`<div class="comment"><div class="comment-name">${esc(c.uploader_name)} · ${esc(c.created_at)}</div><div class="comment-body">${esc(c.body)}</div></div>`).join('');
details.innerHTML=(u.caption?`<div class="lb-caption">${esc(u.caption)}</div>`:'')+
`<div class="lb-meta">${esc(u.uploader_name)} · ${esc(u.created_at)}</div>`+
(tags?`<div class="lb-tags">${tags}</div>`:'')+
`<div class="lb-likes">♡ ${u.like_count} Likes</div>`+
(u.comments.length?`<div class="lb-comments"><h3>Kommentare (${u.comments.length})</h3>${comments}</div>`:'');
lb.classList.add('open');document.body.style.overflow='hidden';
}
function closeLb(){document.getElementById('lb').classList.remove('open');document.getElementById('lb-media').innerHTML='';document.body.style.overflow='';}
document.getElementById('lb').addEventListener('click',e=>{if(e.target===e.currentTarget)closeLb();});
document.addEventListener('keydown',e=>{if(e.key==='Escape')closeLb();});
</script>
</body>
</html>"#;

View File

@@ -1 +1,3 @@
pub mod compression;
pub mod export;
pub mod rate_limiter;

View File

@@ -0,0 +1,46 @@
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
/// Thread-safe sliding-window rate limiter backed by an in-memory HashMap.
/// Each key (e.g. `"join:{ip}"` or `"upload:{user_id}"`) tracks timestamps
/// of recent requests and rejects new ones once the window is full.
#[derive(Clone)]
pub struct RateLimiter {
windows: Arc<Mutex<HashMap<String, Vec<Instant>>>>,
}
impl RateLimiter {
pub fn new() -> Self {
Self {
windows: Arc::new(Mutex::new(HashMap::new())),
}
}
/// Returns `true` if the request is allowed, `false` if rate-limited.
pub fn check(&self, key: impl Into<String>, max: usize, window: Duration) -> bool {
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
} else {
false
}
}
}
/// Extract the client IP from X-Forwarded-For (Caddy sets this) or fall back
/// to a provided socket address string.
pub fn client_ip(headers: &axum::http::HeaderMap, fallback: &str) -> String {
headers
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.split(',').next())
.map(|s| s.trim().to_owned())
.unwrap_or_else(|| fallback.to_owned())
}

View File

@@ -3,6 +3,7 @@ use tokio::sync::broadcast;
use crate::config::AppConfig;
use crate::services::compression::CompressionWorker;
use crate::services::rate_limiter::RateLimiter;
#[derive(Clone, Debug)]
pub struct SseEvent {
@@ -16,6 +17,7 @@ pub struct AppState {
pub config: AppConfig,
pub sse_tx: broadcast::Sender<SseEvent>,
pub compression: CompressionWorker,
pub rate_limiter: RateLimiter,
}
impl AppState {
@@ -28,6 +30,7 @@ impl AppState {
config,
sse_tx,
compression,
rate_limiter: RateLimiter::new(),
}
}
}

View File

@@ -1029,6 +1029,7 @@
"integrity": "sha512-MdFRjevVxmAknf2NbaUkDF16jSIzXMWd4Nfah0Qp8TtQVoSp3bV4jKt8mX7z7qTUTWvgSaxtR0EG5WJf53gcuA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@sveltejs/acorn-typescript": "^1.0.5",
@@ -1071,6 +1072,7 @@
"integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@sveltejs/vite-plugin-svelte-inspector": "^5.0.0",
"deepmerge": "^4.3.1",
@@ -1444,6 +1446,7 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2381,6 +2384,7 @@
"integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -2516,6 +2520,7 @@
"integrity": "sha512-QjvU7EFemf6mRzdMGlAFttMWtAAVXrax61SZYHdkD6yoVGQ89VeyKfZD4H1JrV1WLmJBxWhFch9H6ig/87VGjw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@jridgewell/remapping": "^2.3.4",
"@jridgewell/sourcemap-codec": "^1.5.0",
@@ -2616,6 +2621,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -2637,6 +2643,7 @@
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",

View File

@@ -4,6 +4,7 @@ import { browser } from '$app/environment';
const TOKEN_KEY = 'eventsnap_jwt';
const PIN_KEY = 'eventsnap_pin';
const USER_ID_KEY = 'eventsnap_user_id';
const DISPLAY_NAME_KEY = 'eventsnap_display_name';
export const isAuthenticated = writable(false);
@@ -22,11 +23,28 @@ export function getUserId(): string | null {
return localStorage.getItem(USER_ID_KEY);
}
export function setAuth(jwt: string, pin: string | null, userId: string): void {
export function getDisplayName(): string | null {
if (!browser) return null;
return localStorage.getItem(DISPLAY_NAME_KEY);
}
export function getExpiry(): Date | null {
const token = getToken();
if (!token) return null;
try {
const payload = JSON.parse(atob(token.split('.')[1]));
return payload.exp ? new Date(payload.exp * 1000) : null;
} catch {
return null;
}
}
export function setAuth(jwt: string, pin: string | null, userId: string, displayName?: string): void {
if (!browser) return;
localStorage.setItem(TOKEN_KEY, jwt);
if (pin) localStorage.setItem(PIN_KEY, pin);
localStorage.setItem(USER_ID_KEY, userId);
if (displayName) localStorage.setItem(DISPLAY_NAME_KEY, displayName);
isAuthenticated.set(true);
}

View File

@@ -0,0 +1,85 @@
<script lang="ts">
import { browser } from '$app/environment';
const GUIDE_SEEN_KEY = 'eventsnap_guide_seen';
let visible = $state(false);
let step = $state(0);
const steps = [
{
icon: '📸',
title: 'Willkommen bei EventSnap!',
body: 'Hier kannst du Fotos und Videos mit allen Gästen teilen — in Echtzeit, ganz ohne App-Store.'
},
{
icon: '⬆️',
title: 'Fotos & Videos hochladen',
body: 'Tippe oben auf „Hochladen", um Fotos aus deiner Galerie oder direkt mit der Kamera aufzunehmen. Mehrere Dateien auf einmal sind kein Problem!'
},
{
icon: '#️⃣',
title: 'Hashtags nutzen',
body: 'Füge in deiner Bildunterschrift #hashtags ein, um Fotos zu gruppieren — z.B. #tanz, #buffet oder #reden. Du kannst danach filtern.'
},
{
icon: '🔑',
title: 'Deinen PIN merken!',
body: 'Du hast beim Registrieren einen 4-stelligen PIN erhalten. Speichere ihn — du brauchst ihn, um dein Konto auf einem anderen Gerät wiederherzustellen. Er ist immer unter „Mein Konto" zu finden.'
}
];
if (browser && !localStorage.getItem(GUIDE_SEEN_KEY)) {
visible = true;
}
function next() {
if (step < steps.length - 1) {
step++;
} else {
dismiss();
}
}
function dismiss() {
if (browser) localStorage.setItem(GUIDE_SEEN_KEY, '1');
visible = false;
}
</script>
{#if visible}
<!-- Backdrop -->
<div class="fixed inset-0 z-50 flex items-end justify-center bg-black/60 sm:items-center">
<div class="w-full max-w-sm rounded-t-3xl bg-white p-6 shadow-2xl sm:rounded-2xl">
<!-- Step indicator -->
<div class="mb-5 flex justify-center gap-1.5">
{#each steps as _, i}
<div class="h-1.5 rounded-full transition-all {i === step ? 'w-6 bg-blue-600' : 'w-1.5 bg-gray-200'}"></div>
{/each}
</div>
<!-- Content -->
<div class="mb-6 text-center">
<div class="mb-3 text-5xl">{steps[step].icon}</div>
<h2 class="mb-2 text-xl font-bold text-gray-900">{steps[step].title}</h2>
<p class="text-sm leading-relaxed text-gray-600">{steps[step].body}</p>
</div>
<!-- Buttons -->
<div class="flex gap-2">
<button
onclick={dismiss}
class="flex-1 rounded-xl border border-gray-200 py-3 text-sm text-gray-500 hover:bg-gray-50"
>
Überspringen
</button>
<button
onclick={next}
class="flex-1 rounded-xl bg-blue-600 py-3 text-sm font-semibold text-white hover:bg-blue-700"
>
{step < steps.length - 1 ? 'Weiter' : 'Los geht\'s!'}
</button>
</div>
</div>
</div>
{/if}

View File

@@ -0,0 +1,137 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { getToken, getPin, getDisplayName, getExpiry, getRole, clearAuth } from '$lib/auth';
import { api } from '$lib/api';
import { browser } from '$app/environment';
import { onMount } from 'svelte';
let pin = $state<string | null>(null);
let displayName = $state<string | null>(null);
let role = $state<'guest' | 'host' | 'admin' | null>(null);
let expiry = $state<Date | null>(null);
let copied = $state(false);
let pinCopied = $state(false);
onMount(() => {
if (!getToken()) {
goto('/join');
return;
}
pin = getPin();
displayName = getDisplayName();
role = getRole();
expiry = getExpiry();
});
function copyPin() {
if (!pin) return;
navigator.clipboard.writeText(pin);
pinCopied = true;
setTimeout(() => (pinCopied = false), 2000);
}
async function handleLogout() {
try { await api.delete('/session'); } catch { /* ignore */ }
clearAuth();
goto('/join');
}
function formatDate(d: Date): string {
return d.toLocaleDateString('de-DE', { day: '2-digit', month: 'long', year: 'numeric' });
}
function roleLabel(r: string | null): string {
switch (r) {
case 'admin': return 'Admin';
case 'host': return 'Gastgeber';
default: return 'Gast';
}
}
function roleColor(r: string | null): string {
switch (r) {
case 'admin': return 'bg-red-100 text-red-700';
case 'host': return 'bg-purple-100 text-purple-700';
default: return 'bg-blue-100 text-blue-700';
}
}
</script>
<div class="min-h-screen bg-gray-50">
<!-- Header -->
<div class="border-b border-gray-200 bg-white">
<div class="mx-auto flex max-w-lg items-center justify-between px-4 py-4">
<h1 class="text-xl font-bold text-gray-900">Mein Konto</h1>
<a href="/feed" class="text-sm text-blue-600 hover:underline">Zur Galerie</a>
</div>
</div>
<div class="mx-auto max-w-lg space-y-4 p-4">
<!-- Profile card -->
<div class="rounded-xl border border-gray-200 bg-white p-5">
<div class="flex items-center gap-3">
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-blue-100 text-xl font-bold text-blue-600">
{#if displayName}
{displayName[0].toUpperCase()}
{:else}
?
{/if}
</div>
<div>
<p class="font-semibold text-gray-900">{displayName ?? 'Unbekannt'}</p>
<span class="inline-block rounded-full px-2 py-0.5 text-xs font-medium {roleColor(role)}">
{roleLabel(role)}
</span>
</div>
</div>
{#if expiry}
<p class="mt-3 text-xs text-gray-400">Sitzung gültig bis {formatDate(expiry)}</p>
{/if}
</div>
<!-- PIN card -->
<div class="rounded-xl border border-amber-200 bg-amber-50 p-5">
<h2 class="mb-1 font-semibold text-amber-900">Wiederherstellungs-PIN</h2>
<p class="mb-3 text-sm text-amber-700">
Du brauchst diesen PIN, um dein Konto auf einem anderen Gerät wiederherzustellen. Schreib ihn auf!
</p>
{#if pin}
<div class="flex items-center justify-between rounded-lg bg-white px-4 py-3 shadow-sm">
<span class="font-mono text-4xl font-bold tracking-widest text-gray-900">{pin}</span>
<button
onclick={copyPin}
class="rounded-md bg-amber-100 px-3 py-1.5 text-sm font-medium text-amber-800 transition hover:bg-amber-200"
>
{pinCopied ? 'Kopiert!' : 'Kopieren'}
</button>
</div>
{:else}
<div class="rounded-lg bg-white px-4 py-3 text-sm text-gray-400 shadow-sm">
PIN nicht gespeichert. Nutze die Wiederherstellungs-Seite, um dich mit deinem PIN anzumelden.
</div>
{/if}
</div>
<!-- Recovery hint -->
<div class="rounded-xl border border-gray-200 bg-white p-5">
<h2 class="mb-1 font-semibold text-gray-900">Gerät wechseln?</h2>
<p class="text-sm text-gray-600">
Auf einem anderen Gerät kannst du dein Konto mit deinem Namen und PIN wiederherstellen.
</p>
<a
href="/recover"
class="mt-3 inline-block text-sm font-medium text-blue-600 hover:underline"
>
Zur Wiederherstellungs-Seite →
</a>
</div>
<!-- Logout -->
<button
onclick={handleLogout}
class="w-full rounded-xl border border-red-200 bg-white py-3 text-sm font-medium text-red-600 transition hover:bg-red-50"
>
Abmelden
</button>
</div>
</div>

View File

@@ -0,0 +1,202 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { getToken } from '$lib/auth';
import { api } from '$lib/api';
import { onMount, onDestroy } from 'svelte';
import { connectSse, disconnectSse, onSseEvent } from '$lib/sse';
interface JobStatus {
status: 'locked' | 'pending' | 'running' | 'done' | 'failed';
progress_pct: number;
}
interface ExportStatus {
released: boolean;
zip: JobStatus;
html: JobStatus;
}
const HTML_GUIDE_KEY = 'eventsnap_html_guide_seen';
let status = $state<ExportStatus | null>(null);
let showHtmlGuide = $state(false);
let loading = $state(true);
let unsubscribers: (() => void)[] = [];
onMount(async () => {
if (!getToken()) {
goto('/join');
return;
}
await loadStatus();
connectSse();
unsubscribers.push(
onSseEvent('export-progress', async () => {
await loadStatus();
}),
onSseEvent('export-available', async () => {
await loadStatus();
})
);
});
onDestroy(() => {
disconnectSse();
for (const unsub of unsubscribers) unsub();
});
async function loadStatus() {
try {
status = await api.get<ExportStatus>('/export/status');
} catch {
// ignore
} finally {
loading = false;
}
}
function jobLabel(type: 'zip' | 'html'): string {
return type === 'zip' ? 'ZIP-Archiv (Gallery.zip)' : 'HTML-Viewer (Memories.zip)';
}
function statusText(job: JobStatus): string {
switch (job.status) {
case 'locked': return 'Noch nicht freigegeben';
case 'pending': return 'Wird vorbereitet…';
case 'running': return `Wird erstellt (${job.progress_pct} %)`;
case 'done': return 'Bereit zum Download';
case 'failed': return 'Fehlgeschlagen';
}
}
function downloadZip() {
window.location.href = '/api/v1/export/zip';
}
function downloadHtml() {
if (localStorage.getItem(HTML_GUIDE_KEY)) {
window.location.href = '/api/v1/export/html';
} else {
showHtmlGuide = true;
}
}
function confirmHtmlDownload() {
localStorage.setItem(HTML_GUIDE_KEY, '1');
showHtmlGuide = false;
window.location.href = '/api/v1/export/html';
}
</script>
<!-- HTML guide modal -->
{#if showHtmlGuide}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div class="w-full max-w-sm rounded-2xl bg-white p-6 shadow-xl">
<h2 class="mb-3 text-lg font-bold text-gray-900">Hinweis zum HTML-Viewer</h2>
<ol class="mb-4 space-y-2 text-sm text-gray-700">
<li class="flex gap-2"><span class="font-bold text-blue-600">1.</span> ZIP-Datei entpacken (Windows: Rechtsklick → "Alle extrahieren"; Mac: Doppelklick).</li>
<li class="flex gap-2"><span class="font-bold text-blue-600">2.</span> <strong>Memories.html</strong> im Browser öffnen.</li>
<li class="flex gap-2"><span class="font-bold text-blue-600">3.</span> Kein Internet nötig — alles ist lokal gespeichert.</li>
</ol>
<p class="mb-4 rounded-lg bg-amber-50 px-3 py-2 text-xs text-amber-700">
Tipp: Am besten im WLAN herunterladen — die Datei kann mehrere GB groß sein.
</p>
<div class="flex gap-2">
<button
onclick={() => (showHtmlGuide = false)}
class="flex-1 rounded-lg border border-gray-300 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
Abbrechen
</button>
<button
onclick={confirmHtmlDownload}
class="flex-1 rounded-lg bg-blue-600 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Herunterladen
</button>
</div>
</div>
</div>
{/if}
<div class="min-h-screen bg-gray-50">
<div class="border-b border-gray-200 bg-white">
<div class="mx-auto flex max-w-lg items-center justify-between px-4 py-4">
<h1 class="text-xl font-bold text-gray-900">Export</h1>
<a href="/feed" class="text-sm text-blue-600 hover:underline">Zur Galerie</a>
</div>
</div>
<div class="mx-auto max-w-lg space-y-4 p-4">
{#if loading}
<div class="py-16 text-center text-gray-400">Laden…</div>
{:else if !status?.released}
<div class="rounded-xl border border-gray-200 bg-white p-6 text-center">
<svg class="mx-auto mb-3 h-12 w-12 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
<p class="font-medium text-gray-700">Export noch nicht verfügbar</p>
<p class="mt-1 text-sm text-gray-500">Schau nach der Veranstaltung noch einmal vorbei.</p>
</div>
{:else if status}
<p class="text-sm text-gray-500">Wähle dein bevorzugtes Format:</p>
<!-- ZIP card -->
<div class="rounded-xl border border-gray-200 bg-white p-5">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<h2 class="font-semibold text-gray-900">ZIP-Archiv</h2>
<p class="mt-0.5 text-sm text-gray-500">Alle Original-Fotos und Videos in strukturierten Ordnern.</p>
<p class="mt-1 text-xs {status.zip.status === 'done' ? 'text-green-600' : status.zip.status === 'failed' ? 'text-red-500' : 'text-gray-400'}">
{statusText(status.zip)}
</p>
</div>
<button
onclick={downloadZip}
disabled={status.zip.status !== 'done'}
class="shrink-0 rounded-lg px-4 py-2 text-sm font-medium {status.zip.status === 'done' ? 'bg-blue-600 text-white hover:bg-blue-700' : 'bg-gray-100 text-gray-400 cursor-not-allowed'}"
>
Download
</button>
</div>
{#if status.zip.status === 'running'}
<div class="mt-3">
<div class="h-1.5 overflow-hidden rounded-full bg-gray-200">
<div class="h-full rounded-full bg-blue-500 transition-all" style="width: {status.zip.progress_pct}%"></div>
</div>
</div>
{/if}
</div>
<!-- HTML card -->
<div class="rounded-xl border border-gray-200 bg-white p-5">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<h2 class="font-semibold text-gray-900">HTML-Viewer</h2>
<p class="mt-0.5 text-sm text-gray-500">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' : status.html.status === 'failed' ? 'text-red-500' : 'text-gray-400'}">
{statusText(status.html)}
</p>
</div>
<button
onclick={downloadHtml}
disabled={status.html.status !== 'done'}
class="shrink-0 rounded-lg px-4 py-2 text-sm font-medium {status.html.status === 'done' ? 'bg-blue-600 text-white hover:bg-blue-700' : 'bg-gray-100 text-gray-400 cursor-not-allowed'}"
>
Download
</button>
</div>
{#if status.html.status === 'running'}
<div class="mt-3">
<div class="h-1.5 overflow-hidden rounded-full bg-gray-200">
<div class="h-full rounded-full bg-blue-500 transition-all" style="width: {status.html.progress_pct}%"></div>
</div>
</div>
{/if}
</div>
{/if}
</div>
</div>

View File

@@ -1,12 +1,13 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { getToken, clearAuth } from '$lib/auth';
import { getToken } from '$lib/auth';
import { api } from '$lib/api';
import { connectSse, disconnectSse, onSseEvent } from '$lib/sse';
import { onMount, onDestroy } from 'svelte';
import FeedGrid from '$lib/components/FeedGrid.svelte';
import HashtagChips from '$lib/components/HashtagChips.svelte';
import LightboxModal from '$lib/components/LightboxModal.svelte';
import OnboardingGuide from '$lib/components/OnboardingGuide.svelte';
import type { FeedUpload, FeedResponse, HashtagCount } from '$lib/types';
let uploads = $state<FeedUpload[]>([]);
@@ -152,11 +153,7 @@
if (u) selectedUpload = u;
}
async function handleLogout() {
try { await api.delete('/session'); } catch { /* ignore */ }
clearAuth();
goto('/join');
}
</script>
<div class="min-h-screen bg-gray-50">
@@ -171,12 +168,15 @@
>
Hochladen
</a>
<button
onclick={handleLogout}
<a
href="/account"
class="text-sm text-gray-500 hover:text-gray-700"
aria-label="Mein Konto"
>
Abmelden
</button>
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" 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>
</a>
</div>
</div>
@@ -224,3 +224,6 @@
onlike={handleLike}
/>
{/if}
<!-- First-visit onboarding guide -->
<OnboardingGuide />

View File

@@ -22,7 +22,7 @@
is_new: boolean;
}>('/join', { display_name: displayName.trim() });
setAuth(res.jwt, res.pin, res.user_id);
setAuth(res.jwt, res.pin, res.user_id, displayName.trim());
pin = res.pin;
showPinModal = true;
} catch (e) {

View File

@@ -25,7 +25,7 @@
user_id: string;
}>('/recover', { display_name: displayName.trim(), pin: pin.trim() });
setAuth(res.jwt, pin.trim(), res.user_id);
setAuth(res.jwt, pin.trim(), res.user_id, displayName.trim());
goto('/feed');
} catch (e) {
if (e instanceof ApiError) {