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>
333 lines
10 KiB
Rust
333 lines
10 KiB
Rust
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<Uuid>,
|
|
pub limit: Option<i64>,
|
|
pub hashtag: Option<String>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct FeedUpload {
|
|
pub id: Uuid,
|
|
pub user_id: Uuid,
|
|
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,
|
|
pub comment_count: i64,
|
|
pub liked_by_me: bool,
|
|
pub created_at: DateTime<Utc>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct FeedResponse {
|
|
pub uploads: Vec<FeedUpload>,
|
|
pub next_cursor: Option<Uuid>,
|
|
}
|
|
|
|
#[derive(sqlx::FromRow)]
|
|
struct FeedRow {
|
|
id: Uuid,
|
|
user_id: Uuid,
|
|
uploader_name: String,
|
|
preview_path: Option<String>,
|
|
thumbnail_path: Option<String>,
|
|
mime_type: String,
|
|
caption: Option<String>,
|
|
like_count: i64,
|
|
comment_count: i64,
|
|
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,
|
|
headers: HeaderMap,
|
|
Query(q): Query<FeedQuery>,
|
|
) -> Result<Json<FeedResponse>, 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<FeedRow> = 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<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 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<Utc>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
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
|
|
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<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(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| {
|
|
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<AppState>,
|
|
auth: AuthUser,
|
|
) -> Result<Json<Vec<HashtagCount>>, 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<DateTime<Utc>> {
|
|
let row: Option<(DateTime<Utc>,)> =
|
|
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<Uuid> {
|
|
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()
|
|
}
|