Filtering was split across two independent client-side states and applied to whatever page 1 happened to hold, by caption SUBSTRING. So a tag chip selected in the list view was silently still applied in the grid without being shown; a filter matched photos whose caption merely contained the text; and anything past the first page was invisible to it. Verified against the seeded data: `hashtag=tanz` returned 6 photos by substring, 1 by tag. `FeedQuery` now carries `hashtag` (single, list view), `hashtags` (CSV, OR'd, grid chips) and `uploader` (exact, AND'd), normalised through one function that trims, strips `#`, lowercases and dedupes, and yields None when empty — so an empty filter means "no filter", never "match nothing". The two SQL branches collapse into one with `h.tag = ANY($4)`. Tag-OR plus tag+user-AND is a specified feature, not an accident: `e2e/specs/03-feed/filter-search.spec.ts` and USER_JOURNEYS §8 pin it, which is why the semantics moved to the server rather than being simplified away. Tags travel as CSV safely because the backend restricts them to ASCII alphanumerics and `_`; `uploader` stays a single exact parameter because a display name can contain a comma. New `GET /api/v1/uploaders` reads `v_feed`, so banned and hidden uploaders are excluded for free. Migration 021 gives `v_hashtag_counts` the same treatment. It counted every upload regardless of the uploader's ban state, so banning a guest left their tags in the chip list as ghost filters that lead to an empty feed. Verified: after banning the guest who owned all six `tanz*` photos, the chips went 6 -> 0. `?limit=-5` returned a 500 — only the upper bound was clamped, so Postgres was asked for `LIMIT -4`. Clamped at both ends. `is_banned` is added to `/me/context` so the client can show a read-only notice instead of letting a banned guest discover the ban one 403 toast at a time. `add_comment` sorts and dedupes hashtags on the normalised key, matching the upload path — the two disagreed, which is a lock-ordering deadlock between concurrent upserts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
124 lines
4.5 KiB
Rust
124 lines
4.5 KiB
Rust
//! Endpoints scoped to the *current user*. Kept separate from `auth::handlers` because
|
|
//! these aren't about acquiring / refreshing a session — they're about reading my own
|
|
//! state once I'm already signed in.
|
|
//!
|
|
//! Current routes:
|
|
//! - `GET /api/v1/me/context` — bundled profile + feature flags + privacy note. The
|
|
//! account page loads this once on mount instead of issuing several round trips.
|
|
//! - `GET /api/v1/me/quota` — live per-user storage quota estimate.
|
|
|
|
use axum::Json;
|
|
use axum::extract::State;
|
|
use serde::Serialize;
|
|
|
|
use crate::auth::middleware::AuthUser;
|
|
use crate::error::AppError;
|
|
use crate::handlers::upload::compute_storage_quota;
|
|
use crate::models::user::{User, UserRole};
|
|
use crate::services::config;
|
|
use crate::state::AppState;
|
|
|
|
#[derive(Serialize)]
|
|
pub struct QuotaDto {
|
|
pub enabled: bool,
|
|
pub used_bytes: i64,
|
|
pub limit_bytes: Option<i64>,
|
|
pub active_uploaders: i64,
|
|
pub free_disk_bytes: i64,
|
|
}
|
|
|
|
pub async fn get_quota(
|
|
State(state): State<AppState>,
|
|
auth: AuthUser,
|
|
) -> Result<Json<QuotaDto>, AppError> {
|
|
let user = User::find_by_id(&state.pool, auth.user_id)
|
|
.await?
|
|
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
|
|
|
|
let estimate = compute_storage_quota(&state).await;
|
|
|
|
// Raw server telemetry (free disk, concurrent uploader count) is staff-only — it
|
|
// must never reach a guest, even though the guest upload UI no longer renders it.
|
|
// A guest still gets their own `used`/`limit` so enforcement stays transparent to
|
|
// the code paths that consume it; only the server-wide fields are zeroed.
|
|
let is_staff = matches!(auth.role, UserRole::Host | UserRole::Admin);
|
|
|
|
Ok(Json(QuotaDto {
|
|
enabled: estimate.limit_bytes.is_some(),
|
|
used_bytes: user.total_upload_bytes,
|
|
limit_bytes: estimate.limit_bytes,
|
|
active_uploaders: if is_staff {
|
|
estimate.active_uploaders
|
|
} else {
|
|
0
|
|
},
|
|
free_disk_bytes: if is_staff {
|
|
estimate.free_disk_bytes
|
|
} else {
|
|
0
|
|
},
|
|
}))
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct MeContextDto {
|
|
pub user_id: uuid::Uuid,
|
|
pub display_name: String,
|
|
pub role: String,
|
|
/// Plain-text Datenschutzhinweis set by the admin. Empty string when not configured.
|
|
pub privacy_note: String,
|
|
pub quota_enabled: bool,
|
|
pub storage_quota_enabled: bool,
|
|
/// Uploads are locked (event closed) — the composer should show a locked state live
|
|
/// instead of letting a guest compose an upload only to eat a 403.
|
|
pub uploads_locked: bool,
|
|
/// The gallery has been released and the export snapshotted — uploads are permanently
|
|
/// closed for this run (release ⇒ lock, and reopening regenerates).
|
|
pub gallery_released: bool,
|
|
/// This guest is banned: a deliberately READ-ONLY ban (see `handlers/host.rs`) — they keep
|
|
/// the feed and the keepsake, but every write is refused.
|
|
///
|
|
/// Exposed so the UI can SAY so. Without it the client had no idea, so the upload button,
|
|
/// the like button and "Löschen" all rendered enabled and returned 403 "Du bist gesperrt."
|
|
/// on every tap — a guest tapping upload repeatedly with nobody to ask. The lock case
|
|
/// (`uploads_locked`) has always been surfaced for exactly this reason; a ban was not.
|
|
pub is_banned: bool,
|
|
}
|
|
|
|
pub async fn get_context(
|
|
State(state): State<AppState>,
|
|
auth: AuthUser,
|
|
) -> Result<Json<MeContextDto>, AppError> {
|
|
let user = User::find_by_id(&state.pool, auth.user_id)
|
|
.await?
|
|
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
|
|
|
|
let privacy_note = config::get_str(&state.config_cache, "privacy_note", "").await;
|
|
let quota_enabled = config::get_bool(&state.config_cache, "quota_enabled", true).await;
|
|
let storage_quota_enabled =
|
|
config::get_bool(&state.config_cache, "storage_quota_enabled", true).await;
|
|
|
|
let event =
|
|
crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug).await?;
|
|
let uploads_locked = event
|
|
.as_ref()
|
|
.map(|e| e.uploads_locked_at.is_some())
|
|
.unwrap_or(false);
|
|
let gallery_released = event
|
|
.as_ref()
|
|
.map(|e| e.export_released_at.is_some())
|
|
.unwrap_or(false);
|
|
|
|
Ok(Json(MeContextDto {
|
|
user_id: user.id,
|
|
display_name: user.display_name,
|
|
role: user.role.as_str().to_string(),
|
|
privacy_note,
|
|
quota_enabled,
|
|
storage_quota_enabled,
|
|
uploads_locked,
|
|
gallery_released,
|
|
is_banned: user.is_banned,
|
|
}))
|
|
}
|