use std::time::Duration; use axum::extract::{Query, State}; use axum::http::HeaderMap; use axum::Json; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; 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; #[derive(Deserialize)] pub struct FeedQuery { pub cursor: Option, pub limit: Option, pub hashtag: Option, } #[derive(Serialize)] pub struct FeedUpload { pub id: Uuid, pub user_id: Uuid, pub uploader_name: String, pub preview_url: Option, pub thumbnail_url: Option, /// Signed gateway URL for the full-resolution original. Always present. pub original_url: Option, pub mime_type: String, pub caption: Option, pub like_count: i64, pub comment_count: i64, pub liked_by_me: bool, pub created_at: DateTime, } #[derive(Serialize)] pub struct FeedResponse { pub uploads: Vec, pub next_cursor: Option, } #[derive(sqlx::FromRow)] struct FeedRow { id: Uuid, user_id: Uuid, uploader_name: String, preview_path: Option, thumbnail_path: Option, mime_type: String, caption: Option, like_count: i64, comment_count: i64, created_at: DateTime, } /// 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, auth: AuthUser, headers: HeaderMap, Query(q): Query, ) -> Result, AppError> { let ip = client_ip(&headers, "unknown"); 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:{ip}"), rate_limit, Duration::from_secs(60)) { 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); let rows = if let Some(hashtag) = &q.hashtag { let tag = hashtag.trim().trim_start_matches('#').to_lowercase(); sqlx::query_as::<_, FeedRow>( "SELECT v.id, v.user_id, v.uploader_name, v.preview_path, v.thumbnail_path, v.mime_type, v.caption, v.like_count, v.comment_count, v.created_at FROM v_feed v JOIN upload_hashtag uh ON uh.upload_id = v.id JOIN hashtag h ON h.id = uh.hashtag_id AND h.tag = $1 WHERE v.event_id = $2 AND ($3::timestamptz IS NULL OR v.created_at < $3) ORDER BY v.created_at DESC LIMIT $4", ) .bind(&tag) .bind(auth.event_id) .bind( if let Some(cursor) = q.cursor { get_cursor_time(&state.pool, cursor).await } else { None }, ) .bind(limit + 1) .fetch_all(&state.pool) .await? } else { 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 ($2::timestamptz IS NULL OR created_at < $2) ORDER BY created_at DESC LIMIT $3", ) .bind(auth.event_id) .bind( if let Some(cursor) = q.cursor { get_cursor_time(&state.pool, cursor).await } else { None }, ) .bind(limit + 1) .fetch_all(&state.pool) .await? }; let has_more = rows.len() as i64 > limit; let rows: Vec = rows.into_iter().take(limit as usize).collect(); let next_cursor = if has_more { rows.last().map(|r| r.id) } else { None }; // Batch check which uploads the current user has liked let upload_ids: Vec = 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 liked = liked_set.contains(&r.id); to_feed_upload(r, liked, secret, now) }) .collect(); Ok(Json(FeedResponse { uploads, next_cursor, })) } #[derive(Deserialize)] pub struct DeltaQuery { pub since: DateTime, } #[derive(Serialize)] pub struct DeltaResponse { pub uploads: Vec, pub deleted_ids: Vec, /// 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, auth: AuthUser, Query(q): Query, ) -> Result, 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 LIMIT $3", ) .bind(auth.event_id) .bind(since) .bind(DELTA_LIMIT + 1) .fetch_all(&state.pool) .await?; let capped = rows.len() as i64 > DELTA_LIMIT; let rows: Vec = 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(since) .fetch_all(&state.pool) .await?; let upload_ids: Vec = 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 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, })) } #[derive(Serialize)] pub struct HashtagCount { pub tag: String, pub count: i64, } pub async fn hashtags( State(state): State, auth: AuthUser, ) -> Result>, AppError> { let rows: Vec<(String, i64)> = sqlx::query_as( "SELECT tag, upload_count FROM v_hashtag_counts WHERE event_id = $1", ) .bind(auth.event_id) .fetch_all(&state.pool) .await?; Ok(Json( rows.into_iter() .map(|(tag, count)| HashtagCount { tag, count }) .collect(), )) } async fn get_cursor_time(pool: &sqlx::PgPool, cursor_id: Uuid) -> Option> { let row: Option<(DateTime,)> = sqlx::query_as("SELECT created_at FROM upload WHERE id = $1") .bind(cursor_id) .fetch_optional(pool) .await .ok()?; row.map(|r| r.0) } async fn get_liked_set( pool: &sqlx::PgPool, user_id: Uuid, upload_ids: &[Uuid], ) -> std::collections::HashSet { if upload_ids.is_empty() { return std::collections::HashSet::new(); } let rows: Vec<(Uuid,)> = sqlx::query_as( "SELECT upload_id FROM \"like\" WHERE user_id = $1 AND upload_id = ANY($2)", ) .bind(user_id) .bind(upload_ids) .fetch_all(pool) .await .unwrap_or_default(); rows.into_iter().map(|r| r.0).collect() }