Merge branch 'chore/rustfmt-2026-07-15'

This commit is contained in:
fabi
2026-07-15 19:52:17 +02:00
29 changed files with 589 additions and 306 deletions

View File

@@ -66,9 +66,9 @@ jobs:
working-directory: ./backend working-directory: ./backend
run: cargo clippy --all-targets -- -D warnings run: cargo clippy --all-targets -- -D warnings
# NOTE: `cargo fmt --check` is deliberately NOT gated. The tree has never been rustfmt'd, so - name: Format
# turning it on means a 112-file mechanical reformat that would bury every real diff under it. working-directory: ./backend
# Formatting is not a correctness gate; do that cleanup on its own, then add the check here. run: cargo fmt --check
frontend: frontend:
name: Frontend — vitest + svelte-check name: Frontend — vitest + svelte-check

View File

@@ -1,8 +1,8 @@
use std::time::Duration; use std::time::Duration;
use axum::Json;
use axum::extract::State; use axum::extract::State;
use axum::http::{HeaderMap, StatusCode}; use axum::http::{HeaderMap, StatusCode};
use axum::Json;
use chrono::Utc; use chrono::Utc;
use rand::Rng; use rand::Rng;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -39,8 +39,11 @@ pub async fn join(
let ip = client_ip(&headers, "unknown"); let ip = client_ip(&headers, "unknown");
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await; let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
let join_rate_on = config::get_bool(&state.config_cache, "join_rate_enabled", true).await; let join_rate_on = config::get_bool(&state.config_cache, "join_rate_enabled", true).await;
if rate_limits_on && join_rate_on if rate_limits_on
&& !state.rate_limiter.check(format!("join:{ip}"), 5, Duration::from_secs(60)) && join_rate_on
&& !state
.rate_limiter
.check(format!("join:{ip}"), 5, Duration::from_secs(60))
{ {
return Err(AppError::TooManyRequests( return Err(AppError::TooManyRequests(
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(), "Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
@@ -80,8 +83,7 @@ pub async fn join(
// Generate a 4-digit PIN // Generate a 4-digit PIN
let pin: String = format!("{:04}", rand::rng().random_range(0..10000u32)); let pin: String = format!("{:04}", rand::rng().random_range(0..10000u32));
let pin_hash = let pin_hash = bcrypt::hash(&pin, 12).map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
bcrypt::hash(&pin, 12).map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
// The pre-check above is racy: two simultaneous joins with the same name can both // The pre-check above is racy: two simultaneous joins with the same name can both
// pass it, and the DB's unique index then rejects the loser. Map that unique // pass it, and the DB's unique index then rejects the loser. Map that unique
@@ -178,8 +180,7 @@ pub async fn recover(
.await? .await?
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?; .ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
let users = let users = User::find_by_event_and_name(&state.pool, event.id, display_name).await?;
User::find_by_event_and_name(&state.pool, event.id, display_name).await?;
if users.is_empty() { if users.is_empty() {
// No user with this name. Run a throwaway bcrypt verify so this branch takes // No user with this name. Run a throwaway bcrypt verify so this branch takes
@@ -208,8 +209,7 @@ pub async fn recover(
User::reset_pin_attempts(&state.pool, user.id).await?; User::reset_pin_attempts(&state.pool, user.id).await?;
} }
let pin_matches = bcrypt::verify(&body.pin, &user.recovery_pin_hash) let pin_matches = bcrypt::verify(&body.pin, &user.recovery_pin_hash).unwrap_or(false);
.unwrap_or(false);
if pin_matches { if pin_matches {
// Reset failed attempts on success // Reset failed attempts on success
@@ -289,13 +289,13 @@ pub async fn admin_login(
// honest typos. // honest typos.
let ip = client_ip(&headers, "unknown"); let ip = client_ip(&headers, "unknown");
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await; let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
let admin_rate_on = config::get_bool(&state.config_cache, "admin_login_rate_enabled", true).await; let admin_rate_on =
if rate_limits_on && admin_rate_on config::get_bool(&state.config_cache, "admin_login_rate_enabled", true).await;
&& !state.rate_limiter.check( if rate_limits_on
format!("admin_login:{ip}"), && admin_rate_on
5, && !state
Duration::from_secs(60), .rate_limiter
) .check(format!("admin_login:{ip}"), 5, Duration::from_secs(60))
{ {
return Err(AppError::TooManyRequests( return Err(AppError::TooManyRequests(
"Zu viele Anmeldeversuche. Bitte warte kurz und versuche es erneut.".into(), "Zu viele Anmeldeversuche. Bitte warte kurz und versuche es erneut.".into(),
@@ -303,8 +303,7 @@ pub async fn admin_login(
)); ));
} }
let valid = bcrypt::verify(&body.password, &state.config.admin_password_hash) let valid = bcrypt::verify(&body.password, &state.config.admin_password_hash).unwrap_or(false);
.unwrap_or(false);
if !valid { if !valid {
tracing::warn!(ip = %ip, "admin_login: wrong password"); tracing::warn!(ip = %ip, "admin_login: wrong password");
@@ -330,8 +329,8 @@ pub async fn admin_login(
let dummy_pin: String = (0..32) let dummy_pin: String = (0..32)
.map(|_| rand::rng().random_range(b'a'..=b'z') as char) .map(|_| rand::rng().random_range(b'a'..=b'z') as char)
.collect(); .collect();
let dummy_hash = bcrypt::hash(&dummy_pin, 4) let dummy_hash =
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?; bcrypt::hash(&dummy_pin, 4).map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
let user = User::create(&state.pool, event.id, admin_name, &dummy_hash).await?; let user = User::create(&state.pool, event.id, admin_name, &dummy_hash).await?;
sqlx::query("UPDATE \"user\" SET role = 'admin' WHERE id = $1") sqlx::query("UPDATE \"user\" SET role = 'admin' WHERE id = $1")
.bind(user.id) .bind(user.id)
@@ -364,10 +363,7 @@ pub async fn admin_login(
})) }))
} }
pub async fn logout( pub async fn logout(State(state): State<AppState>, auth: AuthUser) -> Result<StatusCode, AppError> {
State(state): State<AppState>,
auth: AuthUser,
) -> Result<StatusCode, AppError> {
Session::delete_by_token_hash(&state.pool, &auth.token_hash).await?; Session::delete_by_token_hash(&state.pool, &auth.token_hash).await?;
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }

View File

@@ -54,7 +54,9 @@ impl FromRequestParts<AppState> for AuthUser {
let user = Session::find_user_by_token_hash(&state.pool, &token_hash) let user = Session::find_user_by_token_hash(&state.pool, &token_hash)
.await .await
.map_err(|e| AppError::Internal(e.into()))? .map_err(|e| AppError::Internal(e.into()))?
.ok_or_else(|| AppError::Unauthorized("Sitzung nicht gefunden oder abgelaufen.".into()))?; .ok_or_else(|| {
AppError::Unauthorized("Sitzung nicht gefunden oder abgelaufen.".into())
})?;
// Touch last_seen_at AND slide the session's expiry forward (fire-and-forget), so // Touch last_seen_at AND slide the session's expiry forward (fire-and-forget), so
// an active client's session renews instead of hitting the fixed 30-day cliff. // an active client's session renews instead of hitting the fixed 30-day cliff.

View File

@@ -1,6 +1,6 @@
use std::path::PathBuf; use std::path::PathBuf;
use anyhow::{anyhow, Context, Result}; use anyhow::{Context, Result, anyhow};
/// Well-known dev JWT secret shipped in `.env.example`. If APP_ENV=production /// Well-known dev JWT secret shipped in `.env.example`. If APP_ENV=production
/// we refuse to start with this value; otherwise we warn loudly. /// we refuse to start with this value; otherwise we warn loudly.
@@ -67,8 +67,7 @@ pub struct AppConfig {
impl AppConfig { impl AppConfig {
pub fn from_env() -> Result<Self> { pub fn from_env() -> Result<Self> {
let app_env = let app_env = std::env::var("APP_ENV").unwrap_or_else(|_| "development".to_string());
std::env::var("APP_ENV").unwrap_or_else(|_| "development".to_string());
let is_prod = app_env.eq_ignore_ascii_case("production"); let is_prod = app_env.eq_ignore_ascii_case("production");
let jwt_secret = std::env::var("JWT_SECRET").context("JWT_SECRET must be set")?; let jwt_secret = std::env::var("JWT_SECRET").context("JWT_SECRET must be set")?;
@@ -77,18 +76,15 @@ impl AppConfig {
validate_secrets(is_prod, &jwt_secret, &admin_password_hash)?; validate_secrets(is_prod, &jwt_secret, &admin_password_hash)?;
Ok(Self { Ok(Self {
database_url: std::env::var("DATABASE_URL") database_url: std::env::var("DATABASE_URL").context("DATABASE_URL must be set")?,
.context("DATABASE_URL must be set")?,
jwt_secret, jwt_secret,
session_expiry_days: std::env::var("SESSION_EXPIRY_DAYS") session_expiry_days: std::env::var("SESSION_EXPIRY_DAYS")
.unwrap_or_else(|_| "30".to_string()) .unwrap_or_else(|_| "30".to_string())
.parse() .parse()
.context("SESSION_EXPIRY_DAYS must be a number")?, .context("SESSION_EXPIRY_DAYS must be a number")?,
admin_password_hash, admin_password_hash,
event_name: std::env::var("EVENT_NAME") event_name: std::env::var("EVENT_NAME").unwrap_or_else(|_| "EventSnap".to_string()),
.unwrap_or_else(|_| "EventSnap".to_string()), event_slug: std::env::var("EVENT_SLUG").context("EVENT_SLUG must be set")?,
event_slug: std::env::var("EVENT_SLUG")
.context("EVENT_SLUG must be set")?,
media_path: PathBuf::from( media_path: PathBuf::from(
std::env::var("MEDIA_PATH").unwrap_or_else(|_| "/media".to_string()), std::env::var("MEDIA_PATH").unwrap_or_else(|_| "/media".to_string()),
), ),
@@ -120,7 +116,10 @@ mod tests {
// The exact string shipped in `.env` — >32 chars, so it must be caught by // The exact string shipped in `.env` — >32 chars, so it must be caught by
// the substring guard, not the length check. // the substring guard, not the length check.
let err = validate_secrets(true, "change_me_to_a_random_64_byte_hex_string", REAL_HASH); let err = validate_secrets(true, "change_me_to_a_random_64_byte_hex_string", REAL_HASH);
assert!(err.is_err(), "placeholder JWT_SECRET must be rejected in prod"); assert!(
err.is_err(),
"placeholder JWT_SECRET must be rejected in prod"
);
} }
#[test] #[test]
@@ -154,7 +153,9 @@ mod tests {
fn placeholder_detection_is_case_insensitive() { fn placeholder_detection_is_case_insensitive() {
// looks_placeholder lowercases before matching — an upper/mixed-case // looks_placeholder lowercases before matching — an upper/mixed-case
// placeholder must still be rejected in prod. // placeholder must still be rejected in prod.
assert!(validate_secrets(true, "CHANGE_ME_TO_A_RANDOM_64_BYTE_HEX_STRING", REAL_HASH).is_err()); assert!(
validate_secrets(true, "CHANGE_ME_TO_A_RANDOM_64_BYTE_HEX_STRING", REAL_HASH).is_err()
);
assert!(validate_secrets(true, REAL_SECRET, "$2Y$12$PLACEHOLDER_replace_me").is_err()); assert!(validate_secrets(true, REAL_SECRET, "$2Y$12$PLACEHOLDER_replace_me").is_err());
} }

View File

@@ -1,6 +1,6 @@
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool; use sqlx::PgPool;
use sqlx::postgres::PgPoolOptions;
const DEFAULT_MAX_CONNECTIONS: u32 = 10; const DEFAULT_MAX_CONNECTIONS: u32 = 10;

View File

@@ -78,7 +78,8 @@ impl IntoResponse for AppError {
if let Some(secs) = retry_after_secs if let Some(secs) = retry_after_secs
&& let Ok(val) = axum::http::HeaderValue::from_str(&secs.to_string()) && let Ok(val) = axum::http::HeaderValue::from_str(&secs.to_string())
{ {
resp.headers_mut().insert(axum::http::header::RETRY_AFTER, val); resp.headers_mut()
.insert(axum::http::header::RETRY_AFTER, val);
} }
resp resp
} }

View File

@@ -1,9 +1,9 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::time::Duration; use std::time::Duration;
use axum::Json;
use axum::extract::{Query, State}; use axum::extract::{Query, State};
use axum::http::{HeaderMap, StatusCode}; use axum::http::{HeaderMap, StatusCode};
use axum::Json;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::auth::middleware::RequireAdmin; use crate::auth::middleware::RequireAdmin;
@@ -45,19 +45,17 @@ pub async fn get_stats(
.await? .await?
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?; .ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
let (user_count,): (i64,) = let (user_count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM \"user\" WHERE event_id = $1")
sqlx::query_as("SELECT COUNT(*) FROM \"user\" WHERE event_id = $1") .bind(event.id)
.fetch_one(&state.pool)
.await?;
let (upload_count,): (i64,) =
sqlx::query_as("SELECT COUNT(*) FROM upload WHERE event_id = $1 AND deleted_at IS NULL")
.bind(event.id) .bind(event.id)
.fetch_one(&state.pool) .fetch_one(&state.pool)
.await?; .await?;
let (upload_count,): (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM upload WHERE event_id = $1 AND deleted_at IS NULL",
)
.bind(event.id)
.fetch_one(&state.pool)
.await?;
let (comment_count,): (i64,) = sqlx::query_as( let (comment_count,): (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM comment c "SELECT COUNT(*) FROM comment c
JOIN upload u ON u.id = c.upload_id JOIN upload u ON u.id = c.upload_id
@@ -90,10 +88,9 @@ pub async fn get_config(
State(state): State<AppState>, State(state): State<AppState>,
RequireAdmin(_auth): RequireAdmin, RequireAdmin(_auth): RequireAdmin,
) -> Result<Json<HashMap<String, String>>, AppError> { ) -> Result<Json<HashMap<String, String>>, AppError> {
let rows: Vec<(String, String)> = let rows: Vec<(String, String)> = sqlx::query_as("SELECT key, value FROM config ORDER BY key")
sqlx::query_as("SELECT key, value FROM config ORDER BY key") .fetch_all(&state.pool)
.fetch_all(&state.pool) .await?;
.await?;
Ok(Json(rows.into_iter().collect())) Ok(Json(rows.into_iter().collect()))
} }
@@ -159,7 +156,7 @@ pub async fn patch_config(
None => { None => {
return Err(AppError::BadRequest(format!( return Err(AppError::BadRequest(format!(
"Ungültiger Wert für {key}: muss eine Zahl sein." "Ungültiger Wert für {key}: muss eine Zahl sein."
))) )));
} }
}; };
if integer_only && n.fract() != 0.0 { if integer_only && n.fract() != 0.0 {
@@ -297,7 +294,8 @@ pub async fn download_zip(
authenticate_download_ticket(&state, &q.ticket).await?; authenticate_download_ticket(&state, &q.ticket).await?;
enforce_export_rate(&state, &headers).await?; enforce_export_rate(&state, &headers).await?;
let path = resolve_export_file(&state, "zip", "Der ZIP-Export ist noch nicht verfügbar.").await?; let path =
resolve_export_file(&state, "zip", "Der ZIP-Export ist noch nicht verfügbar.").await?;
serve_file(path, "Gallery.zip", "application/zip").await serve_file(path, "Gallery.zip", "application/zip").await
} }
@@ -361,7 +359,7 @@ async fn serve_file(
content_type: &str, content_type: &str,
) -> Result<axum::response::Response, AppError> { ) -> Result<axum::response::Response, AppError> {
use axum::body::Body; use axum::body::Body;
use axum::http::{header, Response, StatusCode}; use axum::http::{Response, StatusCode, header};
use tokio_util::io::ReaderStream; use tokio_util::io::ReaderStream;
let file = tokio::fs::File::open(&path) let file = tokio::fs::File::open(&path)
@@ -418,9 +416,7 @@ pub async fn export_status(
let job_status = |type_name: &str| { let job_status = |type_name: &str| {
jobs.iter() jobs.iter()
.find(|(t, _, _)| t == type_name) .find(|(t, _, _)| t == type_name)
.map(|(_, status, pct)| { .map(|(_, status, pct)| serde_json::json!({ "status": status, "progress_pct": pct }))
serde_json::json!({ "status": status, "progress_pct": pct })
})
.unwrap_or_else(|| serde_json::json!({ "status": "locked", "progress_pct": 0 })) .unwrap_or_else(|| serde_json::json!({ "status": "locked", "progress_pct": 0 }))
}; };

View File

@@ -1,8 +1,8 @@
use std::time::Duration; use std::time::Duration;
use axum::Json;
use axum::extract::{Query, State}; use axum::extract::{Query, State};
use axum::http::HeaderMap; use axum::http::HeaderMap;
use axum::Json;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
@@ -130,7 +130,11 @@ pub async fn feed(
let has_more = rows.len() as i64 > limit; let has_more = rows.len() as i64 > limit;
let rows: Vec<FeedRow> = rows.into_iter().take(limit as usize).collect(); 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 }; let next_cursor = if has_more {
rows.last().map(|r| r.id)
} else {
None
};
// Batch check which uploads the current user has liked // Batch check which uploads the current user has liked
let upload_ids: Vec<Uuid> = rows.iter().map(|r| r.id).collect(); let upload_ids: Vec<Uuid> = rows.iter().map(|r| r.id).collect();
@@ -327,12 +331,11 @@ pub async fn hashtags(
State(state): State<AppState>, State(state): State<AppState>,
auth: AuthUser, auth: AuthUser,
) -> Result<Json<Vec<HashtagCount>>, AppError> { ) -> Result<Json<Vec<HashtagCount>>, AppError> {
let rows: Vec<(String, i64)> = sqlx::query_as( let rows: Vec<(String, i64)> =
"SELECT tag, upload_count FROM v_hashtag_counts WHERE event_id = $1", sqlx::query_as("SELECT tag, upload_count FROM v_hashtag_counts WHERE event_id = $1")
) .bind(auth.event_id)
.bind(auth.event_id) .fetch_all(&state.pool)
.fetch_all(&state.pool) .await?;
.await?;
Ok(Json( Ok(Json(
rows.into_iter() rows.into_iter()
@@ -362,14 +365,13 @@ async fn get_liked_set(
if upload_ids.is_empty() { if upload_ids.is_empty() {
return std::collections::HashSet::new(); return std::collections::HashSet::new();
} }
let rows: Vec<(Uuid,)> = sqlx::query_as( let rows: Vec<(Uuid,)> =
"SELECT upload_id FROM \"like\" WHERE user_id = $1 AND upload_id = ANY($2)", sqlx::query_as("SELECT upload_id FROM \"like\" WHERE user_id = $1 AND upload_id = ANY($2)")
) .bind(user_id)
.bind(user_id) .bind(upload_ids)
.bind(upload_ids) .fetch_all(pool)
.fetch_all(pool) .await
.await .unwrap_or_default();
.unwrap_or_default();
rows.into_iter().map(|r| r.0).collect() rows.into_iter().map(|r| r.0).collect()
} }

View File

@@ -1,6 +1,6 @@
use axum::Json;
use axum::extract::{Path, State}; use axum::extract::{Path, State};
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::Json;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
@@ -57,7 +57,6 @@ async fn remaining_operators(
Ok(count) Ok(count)
} }
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct SetRoleRequest { pub struct SetRoleRequest {
pub role: String, pub role: String,
@@ -115,7 +114,9 @@ pub async fn ban_user(
// The ban request carries no body — ban always hides (no per-request options). // The ban request carries no body — ban always hides (no per-request options).
// Cannot ban yourself or another host/admin // Cannot ban yourself or another host/admin
if user_id == auth.user_id { if user_id == auth.user_id {
return Err(AppError::BadRequest("Du kannst dich nicht selbst sperren.".into())); return Err(AppError::BadRequest(
"Du kannst dich nicht selbst sperren.".into(),
));
} }
let target = sqlx::query_as::<_, (String,)>( let target = sqlx::query_as::<_, (String,)>(
"SELECT role::text FROM \"user\" WHERE id = $1 AND event_id = $2", "SELECT role::text FROM \"user\" WHERE id = $1 AND event_id = $2",
@@ -126,8 +127,12 @@ pub async fn ban_user(
.await? .await?
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?; .ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
if target.0 == "admin" || (target.0 == "host" && auth.role != crate::models::user::UserRole::Admin) { if target.0 == "admin"
return Err(AppError::Forbidden("Du kannst diesen Benutzer nicht sperren.".into())); || (target.0 == "host" && auth.role != crate::models::user::UserRole::Admin)
{
return Err(AppError::Forbidden(
"Du kannst diesen Benutzer nicht sperren.".into(),
));
} }
// Floor: never leave the event with zero operators. Banning removes the target from // Floor: never leave the event with zero operators. Banning removes the target from
@@ -166,9 +171,12 @@ pub async fn ban_user(
// because it is the copy people keep. The export already filters `is_banned = FALSE`, so a // because it is the copy people keep. The export already filters `is_banned = FALSE`, so a
// FUTURE export excludes them; without this, an ALREADY-RELEASED archive would keep serving a // FUTURE export excludes them; without this, an ALREADY-RELEASED archive would keep serving a
// banned user's photos forever. Same class as a takedown, so same treatment. // banned user's photos forever. Same class as a takedown, so same treatment.
let regen = let regen = crate::services::export::invalidate_and_arm(
crate::services::export::invalidate_and_arm(&mut tx, &state.config.event_slug, Affects::Both) &mut tx,
.await?; &state.config.event_slug,
Affects::Both,
)
.await?;
tx.commit().await?; tx.commit().await?;
if let Some(r) = regen { if let Some(r) = regen {
start_regen(&state, r); start_regen(&state, r);
@@ -237,9 +245,12 @@ pub async fn unban_user(
// The mirror of the ban case: an unban RESTORES their uploads to the export query, so an // The mirror of the ban case: an unban RESTORES their uploads to the export query, so an
// already-released keepsake is now missing content it should contain. Rebuild it. // already-released keepsake is now missing content it should contain. Rebuild it.
let regen = let regen = crate::services::export::invalidate_and_arm(
crate::services::export::invalidate_and_arm(&mut tx, &state.config.event_slug, Affects::Both) &mut tx,
.await?; &state.config.event_slug,
Affects::Both,
)
.await?;
tx.commit().await?; tx.commit().await?;
if let Some(r) = regen { if let Some(r) = regen {
start_regen(&state, r); start_regen(&state, r);
@@ -266,9 +277,12 @@ pub async fn rebuild_export(
RequireHost(auth): RequireHost, RequireHost(auth): RequireHost,
) -> Result<StatusCode, AppError> { ) -> Result<StatusCode, AppError> {
let mut tx = state.pool.begin().await?; let mut tx = state.pool.begin().await?;
let regen = let regen = crate::services::export::invalidate_and_arm(
crate::services::export::invalidate_and_arm(&mut tx, &state.config.event_slug, Affects::Both) &mut tx,
.await?; &state.config.event_slug,
Affects::Both,
)
.await?;
tx.commit().await?; tx.commit().await?;
let Some(r) = regen else { let Some(r) = regen else {
@@ -316,7 +330,7 @@ pub async fn set_role(
_ => { _ => {
return Err(AppError::BadRequest( return Err(AppError::BadRequest(
"Ungültige Rolle. Erlaubt: guest, host.".into(), "Ungültige Rolle. Erlaubt: guest, host.".into(),
)) ));
} }
}; };
@@ -413,13 +427,12 @@ pub async fn reset_user_pin(
_ => { _ => {
return Err(AppError::Forbidden( return Err(AppError::Forbidden(
"Du darfst die PIN dieses Benutzers nicht zurücksetzen.".into(), "Du darfst die PIN dieses Benutzers nicht zurücksetzen.".into(),
)) ));
} }
} }
let pin: String = format!("{:04}", rand::rng().random_range(0..10000u32)); let pin: String = format!("{:04}", rand::rng().random_range(0..10000u32));
let pin_hash = let pin_hash = bcrypt::hash(&pin, 12).map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
bcrypt::hash(&pin, 12).map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
sqlx::query( sqlx::query(
"UPDATE \"user\" "UPDATE \"user\"
@@ -564,9 +577,12 @@ pub async fn host_delete_upload(
if !deleted { if !deleted {
return Err(AppError::NotFound("Upload nicht gefunden.".into())); return Err(AppError::NotFound("Upload nicht gefunden.".into()));
} }
let regen = let regen = crate::services::export::invalidate_and_arm(
crate::services::export::invalidate_and_arm(&mut tx, &state.config.event_slug, Affects::Both) &mut tx,
.await?; &state.config.event_slug,
Affects::Both,
)
.await?;
tx.commit().await?; tx.commit().await?;
let _ = state.sse_tx.send(SseEvent::new( let _ = state.sse_tx.send(SseEvent::new(

View File

@@ -7,8 +7,8 @@
//! account page loads this once on mount instead of issuing several round trips. //! account page loads this once on mount instead of issuing several round trips.
//! - `GET /api/v1/me/quota` — live per-user storage quota estimate. //! - `GET /api/v1/me/quota` — live per-user storage quota estimate.
use axum::extract::State;
use axum::Json; use axum::Json;
use axum::extract::State;
use serde::Serialize; use serde::Serialize;
use crate::auth::middleware::AuthUser; use crate::auth::middleware::AuthUser;
@@ -73,12 +73,19 @@ pub async fn get_context(
let privacy_note = config::get_str(&state.config_cache, "privacy_note", "").await; 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 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 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) let event =
.await?; 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 uploads_locked = event
let gallery_released = event.as_ref().map(|e| e.export_released_at.is_some()).unwrap_or(false); .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 { Ok(Json(MeContextDto {
user_id: user.id, user_id: user.id,

View File

@@ -1,7 +1,7 @@
//! Unauthenticated, read-only endpoints safe to expose before a user has joined. //! Unauthenticated, read-only endpoints safe to expose before a user has joined.
use axum::extract::State;
use axum::Json; use axum::Json;
use axum::extract::State;
use serde::Serialize; use serde::Serialize;
use crate::state::AppState; use crate::state::AppState;

View File

@@ -1,6 +1,6 @@
use axum::Json;
use axum::extract::{Path, Query, State}; use axum::extract::{Path, Query, State};
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::Json;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;

View File

@@ -1,14 +1,14 @@
use std::convert::Infallible; use std::convert::Infallible;
use std::time::Duration; use std::time::Duration;
use axum::Json;
use axum::extract::{Query, State}; use axum::extract::{Query, State};
use axum::response::sse::{Event, KeepAlive, Sse}; use axum::response::sse::{Event, KeepAlive, Sse};
use axum::Json;
use futures::stream::Stream; use futures::stream::Stream;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tokio_stream::wrappers::errors::BroadcastStreamRecvError;
use tokio_stream::wrappers::BroadcastStream;
use tokio_stream::StreamExt; use tokio_stream::StreamExt;
use tokio_stream::wrappers::BroadcastStream;
use tokio_stream::wrappers::errors::BroadcastStreamRecvError;
use crate::auth::middleware::AuthUser; use crate::auth::middleware::AuthUser;
use crate::error::AppError; use crate::error::AppError;
@@ -42,7 +42,10 @@ pub async fn issue_ticket(
let server_time = sqlx::query_scalar("SELECT NOW()") let server_time = sqlx::query_scalar("SELECT NOW()")
.fetch_one(&state.pool) .fetch_one(&state.pool)
.await?; .await?;
Ok(Json(StreamTicketResponse { ticket, server_time })) Ok(Json(StreamTicketResponse {
ticket,
server_time,
}))
} }
/// SSE stream endpoint. Authenticates via a single-use ticket (see /// SSE stream endpoint. Authenticates via a single-use ticket (see

View File

@@ -1,8 +1,8 @@
use std::time::Duration; use std::time::Duration;
use axum::Json;
use axum::extract::{Multipart, Path, State}; use axum::extract::{Multipart, Path, State};
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::Json;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::Deserialize; use serde::Deserialize;
use uuid::Uuid; use uuid::Uuid;
@@ -47,7 +47,8 @@ pub async fn upload(
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await; let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
let upload_rate_on = config::get_bool(&state.config_cache, "upload_rate_enabled", true).await; let upload_rate_on = config::get_bool(&state.config_cache, "upload_rate_enabled", true).await;
if rate_limits_on && upload_rate_on { if rate_limits_on && upload_rate_on {
let upload_rate = config::get_i64(&state.config_cache, "upload_rate_per_hour", 10).await as usize; let upload_rate =
config::get_i64(&state.config_cache, "upload_rate_per_hour", 10).await as usize;
if let Err(retry_after_secs) = state.rate_limiter.check_with_retry( if let Err(retry_after_secs) = state.rate_limiter.check_with_retry(
format!("upload:{}", auth.user_id), format!("upload:{}", auth.user_id),
upload_rate, upload_rate,
@@ -101,7 +102,10 @@ pub async fn upload(
// On success the temp file is renamed into place under its detected extension. // On success the temp file is renamed into place under its detected extension.
let upload_id = Uuid::new_v4(); let upload_id = Uuid::new_v4();
let event_slug = &state.config.event_slug; let event_slug = &state.config.event_slug;
let originals_dir = state.config.media_path.join(format!("originals/{event_slug}")); let originals_dir = state
.config
.media_path
.join(format!("originals/{event_slug}"));
let temp_abs = originals_dir.join(format!("{upload_id}.tmp")); let temp_abs = originals_dir.join(format!("{upload_id}.tmp"));
let mut streamed: Option<(i64, Vec<u8>)> = None; // (size, head bytes for sniffing) let mut streamed: Option<(i64, Vec<u8>)> = None; // (size, head bytes for sniffing)
@@ -139,12 +143,20 @@ pub async fn upload(
streamed = Some(stream_field_to_file(field, &temp_abs, cap_bytes).await?); streamed = Some(stream_field_to_file(field, &temp_abs, cap_bytes).await?);
} }
"caption" => { "caption" => {
caption = caption = Some(
Some(field.text().await.map_err(|e| AppError::BadRequest(e.to_string()))?); field
.text()
.await
.map_err(|e| AppError::BadRequest(e.to_string()))?,
);
} }
"hashtags" => { "hashtags" => {
hashtags_csv = hashtags_csv = Some(
Some(field.text().await.map_err(|e| AppError::BadRequest(e.to_string()))?); field
.text()
.await
.map_err(|e| AppError::BadRequest(e.to_string()))?,
);
} }
_ => {} _ => {}
} }
@@ -225,7 +237,8 @@ pub async fn upload(
// number of active uploaders. Gated by master + per-area toggles so the admin can // number of active uploaders. Gated by master + per-area toggles so the admin can
// disable it on trusted instances. // disable it on trusted instances.
let quota_on = config::get_bool(&state.config_cache, "quota_enabled", true).await; let quota_on = config::get_bool(&state.config_cache, "quota_enabled", true).await;
let storage_quota_on = config::get_bool(&state.config_cache, "storage_quota_enabled", true).await; let storage_quota_on =
config::get_bool(&state.config_cache, "storage_quota_enabled", true).await;
// When quota is enforced, this holds the byte ceiling so the increment UPDATE below can // When quota is enforced, this holds the byte ceiling so the increment UPDATE below can
// enforce it atomically (`WHERE total + size <= limit`). Without that guard, two // enforce it atomically (`WHERE total + size <= limit`). Without that guard, two
// concurrent uploads from the same user (e.g. phone + laptop) both pass this stale // concurrent uploads from the same user (e.g. phone + laptop) both pass this stale
@@ -321,11 +334,13 @@ pub async fn upload(
.execute(&mut *tx) .execute(&mut *tx)
.await? .await?
} else { } else {
sqlx::query("UPDATE \"user\" SET total_upload_bytes = total_upload_bytes + $2 WHERE id = $1") sqlx::query(
.bind(auth.user_id) "UPDATE \"user\" SET total_upload_bytes = total_upload_bytes + $2 WHERE id = $1",
.bind(size) )
.execute(&mut *tx) .bind(auth.user_id)
.await? .bind(size)
.execute(&mut *tx)
.await?
}; };
if inc.rows_affected() == 0 { if inc.rows_affected() == 0 {
return Err(AppError::QuotaExceeded( return Err(AppError::QuotaExceeded(
@@ -592,15 +607,15 @@ fn quota_limit_bytes(free_disk: i64, tolerance: f64, active_uploaders: i64) -> i
/// check (upload handler) or hide the UI (quota endpoint). /// check (upload handler) or hide the UI (quota endpoint).
pub async fn compute_storage_quota(state: &AppState) -> QuotaEstimate { pub async fn compute_storage_quota(state: &AppState) -> QuotaEstimate {
let quota_on = config::get_bool(&state.config_cache, "quota_enabled", true).await; let quota_on = config::get_bool(&state.config_cache, "quota_enabled", true).await;
let storage_quota_on = config::get_bool(&state.config_cache, "storage_quota_enabled", true).await; let storage_quota_on =
config::get_bool(&state.config_cache, "storage_quota_enabled", true).await;
let tolerance = config::get_f64(&state.config_cache, "quota_tolerance", 0.75).await; let tolerance = config::get_f64(&state.config_cache, "quota_tolerance", 0.75).await;
let (active_count,): (i64,) = sqlx::query_as( let (active_count,): (i64,) =
"SELECT COUNT(DISTINCT user_id) FROM upload WHERE deleted_at IS NULL", sqlx::query_as("SELECT COUNT(DISTINCT user_id) FROM upload WHERE deleted_at IS NULL")
) .fetch_one(&state.pool)
.fetch_one(&state.pool) .await
.await .unwrap_or((0,));
.unwrap_or((0,));
let active = active_count.max(1); let active = active_count.max(1);
// Cached disk reading. `None` means we couldn't resolve the media filesystem. // Cached disk reading. `None` means we couldn't resolve the media filesystem.
@@ -644,7 +659,7 @@ async fn stream_media_file(
cache_control: &str, cache_control: &str,
) -> Result<axum::response::Response, AppError> { ) -> Result<axum::response::Response, AppError> {
use axum::body::Body; use axum::body::Body;
use axum::http::{header, Response, StatusCode}; use axum::http::{Response, StatusCode, header};
use tokio_util::io::ReaderStream; use tokio_util::io::ReaderStream;
if !absolute.exists() { if !absolute.exists() {
@@ -723,7 +738,13 @@ pub async fn get_preview(
.preview_path .preview_path
.ok_or_else(|| AppError::NotFound("Vorschau nicht verfügbar.".into()))?; .ok_or_else(|| AppError::NotFound("Vorschau nicht verfügbar.".into()))?;
let absolute = state.config.media_path.join(&rel); let absolute = state.config.media_path.join(&rel);
stream_media_file(&absolute, "image/jpeg".to_string(), "inline", "private, max-age=300").await stream_media_file(
&absolute,
"image/jpeg".to_string(),
"inline",
"private, max-age=300",
)
.await
} }
/// Streaming access to an upload's **thumbnail** (video poster). Gated identically to /// Streaming access to an upload's **thumbnail** (video poster). Gated identically to
@@ -739,7 +760,13 @@ pub async fn get_thumbnail(
.thumbnail_path .thumbnail_path
.ok_or_else(|| AppError::NotFound("Thumbnail nicht verfügbar.".into()))?; .ok_or_else(|| AppError::NotFound("Thumbnail nicht verfügbar.".into()))?;
let absolute = state.config.media_path.join(&rel); let absolute = state.config.media_path.join(&rel);
stream_media_file(&absolute, "image/jpeg".to_string(), "inline", "private, max-age=300").await stream_media_file(
&absolute,
"image/jpeg".to_string(),
"inline",
"private, max-age=300",
)
.await
} }
#[cfg(test)] #[cfg(test)]

View File

@@ -1,7 +1,7 @@
use anyhow::Result; use anyhow::Result;
use axum::Router;
use axum::extract::DefaultBodyLimit; use axum::extract::DefaultBodyLimit;
use axum::routing::{delete, get, patch, post}; use axum::routing::{delete, get, patch, post};
use axum::Router;
use tower_http::services::ServeDir; use tower_http::services::ServeDir;
use tower_http::trace::TraceLayer; use tower_http::trace::TraceLayer;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
@@ -27,9 +27,10 @@ async fn main() -> Result<()> {
dotenvy::dotenv().ok(); dotenvy::dotenv().ok();
tracing_subscriber::registry() tracing_subscriber::registry()
.with(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| { .with(
"eventsnap_backend=debug,tower_http=debug".into() tracing_subscriber::EnvFilter::try_from_default_env()
})) .unwrap_or_else(|_| "eventsnap_backend=debug,tower_http=debug".into()),
)
.with(tracing_subscriber::fmt::layer()) .with(tracing_subscriber::fmt::layer())
.init(); .init();
@@ -73,7 +74,10 @@ async fn main() -> Result<()> {
.route("/api/v1/join", post(auth::handlers::join)) .route("/api/v1/join", post(auth::handlers::join))
.route("/api/v1/recover", post(auth::handlers::recover)) .route("/api/v1/recover", post(auth::handlers::recover))
// Forgotten-PIN escape hatch: ask a host to reset it (unauthenticated, throttled). // Forgotten-PIN escape hatch: ask a host to reset it (unauthenticated, throttled).
.route("/api/v1/recover/request", post(auth::handlers::request_pin_reset)) .route(
"/api/v1/recover/request",
post(auth::handlers::request_pin_reset),
)
.route("/api/v1/admin/login", post(auth::handlers::admin_login)) .route("/api/v1/admin/login", post(auth::handlers::admin_login))
.route("/api/v1/session", delete(auth::handlers::logout)) .route("/api/v1/session", delete(auth::handlers::logout))
// "Sign out everywhere" — revoke all of the caller's sessions. // "Sign out everywhere" — revoke all of the caller's sessions.
@@ -83,8 +87,10 @@ async fn main() -> Result<()> {
// layer just stops a multi-GB body from being buffered into memory before that // layer just stops a multi-GB body from being buffered into memory before that
// check runs. Sized generously above the default 500 MB video limit + multipart // check runs. Sized generously above the default 500 MB video limit + multipart
// overhead — if an admin raises max_video_size_mb above this, bump MAX_UPLOAD_BYTES. // overhead — if an admin raises max_video_size_mb above this, bump MAX_UPLOAD_BYTES.
.route("/api/v1/upload", post(handlers::upload::upload) .route(
.route_layer(DefaultBodyLimit::max(MAX_UPLOAD_BYTES))) "/api/v1/upload",
post(handlers::upload::upload).route_layer(DefaultBodyLimit::max(MAX_UPLOAD_BYTES)),
)
.route( .route(
"/api/v1/upload/{id}", "/api/v1/upload/{id}",
patch(handlers::upload::edit_upload).delete(handlers::upload::delete_upload), patch(handlers::upload::edit_upload).delete(handlers::upload::delete_upload),
@@ -112,27 +118,51 @@ async fn main() -> Result<()> {
.route("/api/v1/feed/delta", get(handlers::feed::feed_delta)) .route("/api/v1/feed/delta", get(handlers::feed::feed_delta))
.route("/api/v1/hashtags", get(handlers::feed::hashtags)) .route("/api/v1/hashtags", get(handlers::feed::hashtags))
// Social // Social
.route("/api/v1/upload/{id}/like", post(handlers::social::toggle_like)) .route(
"/api/v1/upload/{id}/like",
post(handlers::social::toggle_like),
)
.route( .route(
"/api/v1/upload/{id}/comments", "/api/v1/upload/{id}/comments",
get(handlers::social::list_comments).post(handlers::social::add_comment), get(handlers::social::list_comments).post(handlers::social::add_comment),
) )
.route("/api/v1/comment/{id}", delete(handlers::social::delete_comment)) .route(
"/api/v1/comment/{id}",
delete(handlers::social::delete_comment),
)
// SSE // SSE
.route("/api/v1/stream", get(handlers::sse::stream)) .route("/api/v1/stream", get(handlers::sse::stream))
.route("/api/v1/stream/ticket", post(handlers::sse::issue_ticket)) .route("/api/v1/stream/ticket", post(handlers::sse::issue_ticket))
// Host Dashboard // Host Dashboard
.route("/api/v1/host/event", get(handlers::host::get_event_status)) .route("/api/v1/host/event", get(handlers::host::get_event_status))
.route("/api/v1/host/event/close", post(handlers::host::close_event)) .route(
"/api/v1/host/event/close",
post(handlers::host::close_event),
)
.route("/api/v1/host/event/open", post(handlers::host::open_event)) .route("/api/v1/host/event/open", post(handlers::host::open_event))
.route("/api/v1/host/gallery/release", post(handlers::host::release_gallery)) .route(
"/api/v1/host/gallery/release",
post(handlers::host::release_gallery),
)
// Escape hatch: force a keepsake rebuild. Without it a failed export is terminal at runtime // Escape hatch: force a keepsake rebuild. Without it a failed export is terminal at runtime
// (release_gallery refuses an already-released event; recovery only runs at boot). // (release_gallery refuses an already-released event; recovery only runs at boot).
.route("/api/v1/host/export/rebuild", post(handlers::host::rebuild_export)) .route(
"/api/v1/host/export/rebuild",
post(handlers::host::rebuild_export),
)
.route("/api/v1/host/users", get(handlers::host::list_users)) .route("/api/v1/host/users", get(handlers::host::list_users))
.route("/api/v1/host/users/{id}/ban", post(handlers::host::ban_user)) .route(
.route("/api/v1/host/users/{id}/unban", post(handlers::host::unban_user)) "/api/v1/host/users/{id}/ban",
.route("/api/v1/host/users/{id}/role", patch(handlers::host::set_role)) post(handlers::host::ban_user),
)
.route(
"/api/v1/host/users/{id}/unban",
post(handlers::host::unban_user),
)
.route(
"/api/v1/host/users/{id}/role",
patch(handlers::host::set_role),
)
.route( .route(
"/api/v1/host/users/{id}/pin-reset", "/api/v1/host/users/{id}/pin-reset",
post(handlers::host::reset_user_pin), post(handlers::host::reset_user_pin),
@@ -145,11 +175,20 @@ async fn main() -> Result<()> {
"/api/v1/host/pin-reset-requests/{id}", "/api/v1/host/pin-reset-requests/{id}",
delete(handlers::host::dismiss_pin_reset_request), delete(handlers::host::dismiss_pin_reset_request),
) )
.route("/api/v1/host/upload/{id}", delete(handlers::host::host_delete_upload)) .route(
.route("/api/v1/host/comment/{id}", delete(handlers::host::host_delete_comment)) "/api/v1/host/upload/{id}",
delete(handlers::host::host_delete_upload),
)
.route(
"/api/v1/host/comment/{id}",
delete(handlers::host::host_delete_comment),
)
// Export (all authenticated users) // Export (all authenticated users)
.route("/api/v1/export/status", get(handlers::admin::export_status)) .route("/api/v1/export/status", get(handlers::admin::export_status))
.route("/api/v1/export/ticket", post(handlers::admin::export_ticket)) .route(
"/api/v1/export/ticket",
post(handlers::admin::export_ticket),
)
.route("/api/v1/export/zip", get(handlers::admin::download_zip)) .route("/api/v1/export/zip", get(handlers::admin::download_zip))
.route("/api/v1/export/html", get(handlers::admin::download_html)) .route("/api/v1/export/html", get(handlers::admin::download_html))
// Admin Dashboard // Admin Dashboard
@@ -158,7 +197,10 @@ async fn main() -> Result<()> {
"/api/v1/admin/config", "/api/v1/admin/config",
get(handlers::admin::get_config).patch(handlers::admin::patch_config), get(handlers::admin::get_config).patch(handlers::admin::patch_config),
) )
.route("/api/v1/admin/export/jobs", get(handlers::admin::get_export_jobs)); .route(
"/api/v1/admin/export/jobs",
get(handlers::admin::get_export_jobs),
);
// Test-only route: a hard reset for the Playwright E2E harness. The handler // Test-only route: a hard reset for the Playwright E2E harness. The handler
// is compiled in always, but the route is only attached when // is compiled in always, but the route is only attached when

View File

@@ -83,12 +83,10 @@ impl Comment {
} }
pub async fn find_by_id(pool: &PgPool, id: Uuid) -> Result<Option<Self>, sqlx::Error> { pub async fn find_by_id(pool: &PgPool, id: Uuid) -> Result<Option<Self>, sqlx::Error> {
sqlx::query_as::<_, Self>( sqlx::query_as::<_, Self>("SELECT * FROM comment WHERE id = $1 AND deleted_at IS NULL")
"SELECT * FROM comment WHERE id = $1 AND deleted_at IS NULL", .bind(id)
) .fetch_optional(pool)
.bind(id) .await
.fetch_optional(pool)
.await
} }
/// Event-scoped soft delete. Returns `false` if the comment doesn't exist or belongs to a /// Event-scoped soft delete. Returns `false` if the comment doesn't exist or belongs to a

View File

@@ -33,13 +33,11 @@ impl Event {
} }
pub async fn create(pool: &PgPool, slug: &str, name: &str) -> Result<Self, sqlx::Error> { pub async fn create(pool: &PgPool, slug: &str, name: &str) -> Result<Self, sqlx::Error> {
sqlx::query_as::<_, Self>( sqlx::query_as::<_, Self>("INSERT INTO event (slug, name) VALUES ($1, $2) RETURNING *")
"INSERT INTO event (slug, name) VALUES ($1, $2) RETURNING *", .bind(slug)
) .bind(name)
.bind(slug) .fetch_one(pool)
.bind(name) .await
.fetch_one(pool)
.await
} }
pub async fn find_or_create( pub async fn find_or_create(

View File

@@ -122,14 +122,20 @@ mod tests {
assert_eq!(extract_hashtags(&format!("#{ok}")), vec![ok.clone()]); assert_eq!(extract_hashtags(&format!("#{ok}")), vec![ok.clone()]);
// 41+ chars → dropped entirely (not truncated). // 41+ chars → dropped entirely (not truncated).
let too_long = "a".repeat(41); let too_long = "a".repeat(41);
assert_eq!(extract_hashtags(&format!("#{too_long}")), Vec::<String>::new()); assert_eq!(
extract_hashtags(&format!("#{too_long}")),
Vec::<String>::new()
);
} }
#[test] #[test]
fn duplicate_tags_are_returned_verbatim_not_deduplicated() { fn duplicate_tags_are_returned_verbatim_not_deduplicated() {
// Dedup is the DB's job (Hashtag::upsert ON CONFLICT); extraction returns each // Dedup is the DB's job (Hashtag::upsert ON CONFLICT); extraction returns each
// occurrence so callers can count/link them independently. Case folds to lower. // occurrence so callers can count/link them independently. Case folds to lower.
assert_eq!(extract_hashtags("#fun #Fun #fun!"), vec!["fun", "fun", "fun"]); assert_eq!(
extract_hashtags("#fun #Fun #fun!"),
vec!["fun", "fun", "fun"]
);
} }
#[test] #[test]

View File

@@ -90,10 +90,7 @@ impl Session {
Ok(()) Ok(())
} }
pub async fn delete_by_token_hash( pub async fn delete_by_token_hash(pool: &PgPool, token_hash: &str) -> Result<(), sqlx::Error> {
pool: &PgPool,
token_hash: &str,
) -> Result<(), sqlx::Error> {
sqlx::query("DELETE FROM session WHERE token_hash = $1") sqlx::query("DELETE FROM session WHERE token_hash = $1")
.bind(token_hash) .bind(token_hash)
.execute(pool) .execute(pool)

View File

@@ -109,14 +109,16 @@ impl User {
Ok(row.0) Ok(row.0)
} }
pub async fn lock_pin(pool: &PgPool, id: Uuid, until: DateTime<Utc>) -> Result<(), sqlx::Error> { pub async fn lock_pin(
sqlx::query( pool: &PgPool,
"UPDATE \"user\" SET pin_locked_until = $2 WHERE id = $1", id: Uuid,
) until: DateTime<Utc>,
.bind(id) ) -> Result<(), sqlx::Error> {
.bind(until) sqlx::query("UPDATE \"user\" SET pin_locked_until = $2 WHERE id = $1")
.execute(pool) .bind(id)
.await?; .bind(until)
.execute(pool)
.await?;
Ok(()) Ok(())
} }

View File

@@ -1,10 +1,10 @@
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use sqlx::PgPool; use sqlx::PgPool;
use tokio::sync::{broadcast, Semaphore}; use tokio::sync::{Semaphore, broadcast};
use uuid::Uuid; use uuid::Uuid;
use crate::models::upload::Upload; use crate::models::upload::Upload;
@@ -23,7 +23,12 @@ pub struct CompressionWorker {
} }
impl CompressionWorker { impl CompressionWorker {
pub fn new(pool: PgPool, media_path: PathBuf, concurrency: usize, sse_tx: broadcast::Sender<SseEvent>) -> Self { pub fn new(
pool: PgPool,
media_path: PathBuf,
concurrency: usize,
sse_tx: broadcast::Sender<SseEvent>,
) -> Self {
Self { Self {
semaphore: Arc::new(Semaphore::new(concurrency)), semaphore: Arc::new(Semaphore::new(concurrency)),
pool, pool,
@@ -55,7 +60,10 @@ impl CompressionWorker {
if worker.generation.load(Ordering::SeqCst) != born_at { if worker.generation.load(Ordering::SeqCst) != born_at {
return; return;
} }
match worker.do_process(upload_id, &original_path, &mime_type).await { match worker
.do_process(upload_id, &original_path, &mime_type)
.await
{
Ok(_) => { Ok(_) => {
tracing::info!("compression completed for upload {upload_id}"); tracing::info!("compression completed for upload {upload_id}");
let _ = worker.sse_tx.send(SseEvent { let _ = worker.sse_tx.send(SseEvent {
@@ -81,7 +89,8 @@ impl CompressionWorker {
} }
let _ = worker.sse_tx.send(SseEvent { let _ = worker.sse_tx.send(SseEvent {
event_type: "upload-error".to_string(), event_type: "upload-error".to_string(),
data: serde_json::json!({ "upload_id": upload_id, "error": e.to_string() }).to_string(), data: serde_json::json!({ "upload_id": upload_id, "error": e.to_string() })
.to_string(),
}); });
let _ = worker.sse_tx.send(SseEvent { let _ = worker.sse_tx.send(SseEvent {
event_type: "upload-deleted".to_string(), event_type: "upload-deleted".to_string(),
@@ -103,7 +112,9 @@ impl CompressionWorker {
let original = self.media_path.join(original_path); let original = self.media_path.join(original_path);
if mime_type.starts_with("image/") { if mime_type.starts_with("image/") {
let preview_rel = self.generate_image_preview(upload_id, &original, mime_type).await?; let preview_rel = self
.generate_image_preview(upload_id, &original, mime_type)
.await?;
Upload::set_preview_path(&self.pool, upload_id, &preview_rel).await?; Upload::set_preview_path(&self.pool, upload_id, &preview_rel).await?;
tracing::info!("preview generated for upload {upload_id}"); tracing::info!("preview generated for upload {upload_id}");
} else if mime_type.starts_with("video/") { } else if mime_type.starts_with("video/") {
@@ -151,7 +162,8 @@ impl CompressionWorker {
// Resize to max 800px wide, preserving aspect ratio // Resize to max 800px wide, preserving aspect ratio
let preview = img.resize(800, 800, image::imageops::FilterType::Lanczos3); let preview = img.resize(800, 800, image::imageops::FilterType::Lanczos3);
preview.save_with_format(&preview_path_clone, image::ImageFormat::Jpeg) preview
.save_with_format(&preview_path_clone, image::ImageFormat::Jpeg)
.context("failed to save preview")?; .context("failed to save preview")?;
// If the original is PNG, try lossless compression in-place // If the original is PNG, try lossless compression in-place
@@ -174,11 +186,7 @@ impl CompressionWorker {
Ok(format!("previews/{preview_filename}")) Ok(format!("previews/{preview_filename}"))
} }
async fn generate_video_thumbnail( async fn generate_video_thumbnail(&self, upload_id: Uuid, original: &Path) -> Result<String> {
&self,
upload_id: Uuid,
original: &Path,
) -> Result<String> {
let thumbs_dir = self.media_path.join("thumbnails"); let thumbs_dir = self.media_path.join("thumbnails");
tokio::fs::create_dir_all(&thumbs_dir).await?; tokio::fs::create_dir_all(&thumbs_dir).await?;
@@ -208,18 +216,14 @@ impl CompressionWorker {
.spawn() .spawn()
.context("failed to spawn ffmpeg")?; .context("failed to spawn ffmpeg")?;
let status = match tokio::time::timeout( let status =
std::time::Duration::from_secs(120), match tokio::time::timeout(std::time::Duration::from_secs(120), child.wait()).await {
child.wait(), Ok(res) => res.context("ffmpeg wait failed")?,
) Err(_) => {
.await let _ = child.kill().await;
{ anyhow::bail!("ffmpeg timeout after 120s");
Ok(res) => res.context("ffmpeg wait failed")?, }
Err(_) => { };
let _ = child.kill().await;
anyhow::bail!("ffmpeg timeout after 120s");
}
};
if !status.success() { if !status.success() {
// Best-effort: drain stderr for the log. // Best-effort: drain stderr for the log.
@@ -228,10 +232,7 @@ impl CompressionWorker {
use tokio::io::AsyncReadExt; use tokio::io::AsyncReadExt;
let _ = handle.read_to_end(&mut stderr).await; let _ = handle.read_to_end(&mut stderr).await;
} }
anyhow::bail!( anyhow::bail!("ffmpeg failed: {}", String::from_utf8_lossy(&stderr));
"ffmpeg failed: {}",
String::from_utf8_lossy(&stderr)
);
} }
Ok(format!("thumbnails/{thumb_filename}")) Ok(format!("thumbnails/{thumb_filename}"))

View File

@@ -81,17 +81,18 @@ impl ConfigCache {
} }
// Cache miss or stale — reload the entire table in one query. // Cache miss or stale — reload the entire table in one query.
let rows: Vec<(String, String)> = let rows: Vec<(String, String)> = match sqlx::query_as::<_, (String, String)>(
match sqlx::query_as::<_, (String, String)>("SELECT key, value FROM config") "SELECT key, value FROM config",
.fetch_all(&self.pool) )
.await .fetch_all(&self.pool)
{ .await
Ok(rows) => rows, {
Err(e) => { Ok(rows) => rows,
tracing::warn!(error = ?e, "config reload failed; using defaults for this read"); Err(e) => {
return None; tracing::warn!(error = ?e, "config reload failed; using defaults for this read");
} return None;
}; }
};
let values: HashMap<String, String> = rows.into_iter().collect(); let values: HashMap<String, String> = rows.into_iter().collect();
let result = values.get(key).cloned(); let result = values.get(key).cloned();
@@ -104,26 +105,43 @@ impl ConfigCache {
} }
pub async fn get_str(cache: &ConfigCache, key: &str, default: &str) -> String { pub async fn get_str(cache: &ConfigCache, key: &str, default: &str) -> String {
cache.get_raw(key).await.unwrap_or_else(|| default.to_string()) cache
.get_raw(key)
.await
.unwrap_or_else(|| default.to_string())
} }
pub async fn get_i64(cache: &ConfigCache, key: &str, default: i64) -> i64 { pub async fn get_i64(cache: &ConfigCache, key: &str, default: i64) -> i64 {
cache.get_raw(key).await.and_then(|v| v.parse().ok()).unwrap_or(default) cache
.get_raw(key)
.await
.and_then(|v| v.parse().ok())
.unwrap_or(default)
} }
pub async fn get_usize(cache: &ConfigCache, key: &str, default: usize) -> usize { pub async fn get_usize(cache: &ConfigCache, key: &str, default: usize) -> usize {
cache.get_raw(key).await.and_then(|v| v.parse().ok()).unwrap_or(default) cache
.get_raw(key)
.await
.and_then(|v| v.parse().ok())
.unwrap_or(default)
} }
pub async fn get_f64(cache: &ConfigCache, key: &str, default: f64) -> f64 { pub async fn get_f64(cache: &ConfigCache, key: &str, default: f64) -> f64 {
cache.get_raw(key).await.and_then(|v| v.parse().ok()).unwrap_or(default) cache
.get_raw(key)
.await
.and_then(|v| v.parse().ok())
.unwrap_or(default)
} }
/// Parses common truthy spellings used by both the migration seeds and the admin form. /// Parses common truthy spellings used by both the migration seeds and the admin form.
/// Accepts `true/false`, `1/0`, `yes/no`, `on/off` — case-insensitive. Anything else /// Accepts `true/false`, `1/0`, `yes/no`, `on/off` — case-insensitive. Anything else
/// returns `default`. /// returns `default`.
pub async fn get_bool(cache: &ConfigCache, key: &str, default: bool) -> bool { pub async fn get_bool(cache: &ConfigCache, key: &str, default: bool) -> bool {
let Some(raw) = cache.get_raw(key).await else { return default }; let Some(raw) = cache.get_raw(key).await else {
return default;
};
match raw.trim().to_ascii_lowercase().as_str() { match raw.trim().to_ascii_lowercase().as_str() {
"true" | "1" | "yes" | "on" => true, "true" | "1" | "yes" | "on" => true,
"false" | "0" | "no" | "off" => false, "false" | "0" | "no" | "off" => false,

View File

@@ -123,20 +123,14 @@ mod tests {
#[test] #[test]
fn picks_longest_matching_mount() { fn picks_longest_matching_mount() {
// Both "/" and "/media" prefix the path; the dedicated volume must win. // Both "/" and "/media" prefix the path; the dedicated volume must win.
let mounts = vec![ let mounts = vec![("/".to_string(), 100, 40), ("/media".to_string(), 200, 150)];
("/".to_string(), 100, 40),
("/media".to_string(), 200, 150),
];
let d = select_disk(&mounts, "/media/originals/x.jpg").unwrap(); let d = select_disk(&mounts, "/media/originals/x.jpg").unwrap();
assert_eq!((d.total, d.free), (200, 150)); assert_eq!((d.total, d.free), (200, 150));
} }
#[test] #[test]
fn falls_back_to_root_when_no_specific_mount_matches() { fn falls_back_to_root_when_no_specific_mount_matches() {
let mounts = vec![ let mounts = vec![("/".to_string(), 100, 40), ("/media".to_string(), 200, 150)];
("/".to_string(), 100, 40),
("/media".to_string(), 200, 150),
];
// "/var/lib" is only prefixed by "/". // "/var/lib" is only prefixed by "/".
let d = select_disk(&mounts, "/var/lib/data").unwrap(); let d = select_disk(&mounts, "/var/lib/data").unwrap();
assert_eq!((d.total, d.free), (100, 40)); assert_eq!((d.total, d.free), (100, 40));

View File

@@ -5,12 +5,12 @@ use anyhow::{Context, Result};
use async_zip::tokio::write::ZipFileWriter; use async_zip::tokio::write::ZipFileWriter;
use async_zip::{Compression, ZipEntryBuilder}; use async_zip::{Compression, ZipEntryBuilder};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use futures::io::{copy as fcopy, AllowStdIo}; use futures::io::{AllowStdIo, copy as fcopy};
use include_dir::{include_dir, Dir}; use include_dir::{Dir, include_dir};
use serde::Serialize; use serde::Serialize;
use sqlx::PgPool; use sqlx::PgPool;
use tokio::sync::broadcast;
use tokio::io::AsyncWriteExt; use tokio::io::AsyncWriteExt;
use tokio::sync::broadcast;
use tokio_util::compat::TokioAsyncReadCompatExt; use tokio_util::compat::TokioAsyncReadCompatExt;
use uuid::Uuid; use uuid::Uuid;
@@ -218,7 +218,11 @@ pub async fn invalidate_and_arm(
let types: &[&str] = if carried { &["html"] } else { &["zip", "html"] }; let types: &[&str] = if carried { &["html"] } else { &["zip", "html"] };
enqueue_types_at_epoch(&mut *conn, event_id, epoch, types).await?; enqueue_types_at_epoch(&mut *conn, event_id, epoch, types).await?;
Ok(Some(PendingRegen { event_id, event_name, epoch })) Ok(Some(PendingRegen {
event_id,
event_name,
epoch,
}))
} }
/// Startup export recovery: re-arm exports for any released event whose keepsake isn't fully /// Startup export recovery: re-arm exports for any released event whose keepsake isn't fully
@@ -275,7 +279,9 @@ pub async fn recover_exports(
continue; continue;
} }
tracing::warn!("export recovery: re-arming export jobs for event {event_id} @ epoch {epoch}"); tracing::warn!(
"export recovery: re-arming export jobs for event {event_id} @ epoch {epoch}"
);
let mut conn = match pool.acquire().await { let mut conn = match pool.acquire().await {
Ok(c) => c, Ok(c) => c,
Err(e) => { Err(e) => {
@@ -416,7 +422,9 @@ pub fn spawn_export_jobs(
if !delay.is_zero() { if !delay.is_zero() {
tokio::time::sleep(delay).await; tokio::time::sleep(delay).await;
} }
if let Err(e) = run_zip_export(event_id, epoch, &pool, &media_path, &export_path, &sse_tx).await { if let Err(e) =
run_zip_export(event_id, epoch, &pool, &media_path, &export_path, &sse_tx).await
{
tracing::error!("ZIP export failed for event {event_id} @ epoch {epoch}: {e:#}"); tracing::error!("ZIP export failed for event {event_id} @ epoch {epoch}: {e:#}");
mark_failed(&pool, event_id, "zip", epoch, &e.to_string()).await; mark_failed(&pool, event_id, "zip", epoch, &e.to_string()).await;
} }
@@ -427,9 +435,16 @@ pub fn spawn_export_jobs(
if !delay.is_zero() { if !delay.is_zero() {
tokio::time::sleep(delay).await; tokio::time::sleep(delay).await;
} }
if let Err(e) = if let Err(e) = run_html_export(
run_html_export(event_id, epoch, &event_name2, &pool2, &media_path2, &export_path2, &sse_tx2) event_id,
.await epoch,
&event_name2,
&pool2,
&media_path2,
&export_path2,
&sse_tx2,
)
.await
{ {
tracing::error!("HTML export failed for event {event_id} @ epoch {epoch}: {e:#}"); tracing::error!("HTML export failed for event {event_id} @ epoch {epoch}: {e:#}");
mark_failed(&pool2, event_id, "html", epoch, &e.to_string()).await; mark_failed(&pool2, event_id, "html", epoch, &e.to_string()).await;
@@ -459,8 +474,9 @@ async fn run_zip_export(
// here so a failing export can't leak them (which is what fills the disk in the first place). // here so a failing export can't leak them (which is what fills the disk in the first place).
let res = run_zip_export_inner(epoch, event_id, pool, media_path, export_path, sse_tx).await; let res = run_zip_export_inner(epoch, event_id, pool, media_path, export_path, sse_tx).await;
if res.is_err() { if res.is_err() {
let _ = tokio::fs::remove_file(export_path.join(gen_name(event_id, "Gallery", epoch, ".tmp"))) let _ =
.await; tokio::fs::remove_file(export_path.join(gen_name(event_id, "Gallery", epoch, ".tmp")))
.await;
} }
abandon_if_superseded("ZIP", event_id, epoch, res) abandon_if_superseded("ZIP", event_id, epoch, res)
} }
@@ -518,7 +534,11 @@ async fn run_zip_export_inner(
let ext = ext_from_path(&row.original_path); let ext = ext_from_path(&row.original_path);
let date = row.created_at.format("%Y-%m-%d_%H-%M").to_string(); let date = row.created_at.format("%Y-%m-%d_%H-%M").to_string();
let name_safe = sanitize_name(&row.uploader_name); let name_safe = sanitize_name(&row.uploader_name);
let folder = if row.mime_type.starts_with("video/") { "Videos" } else { "Photos" }; let folder = if row.mime_type.starts_with("video/") {
"Videos"
} else {
"Photos"
};
let entry_name = format!("{folder}/{date}_{name_safe}_{}.{ext}", row.id); let entry_name = format!("{folder}/{date}_{name_safe}_{}.{ext}", row.id);
let builder = ZipEntryBuilder::new(entry_name.into(), Compression::Stored); let builder = ZipEntryBuilder::new(entry_name.into(), Compression::Stored);
@@ -579,7 +599,9 @@ async fn run_zip_export_inner(
// IS the publish, atomically. A worker at a dead epoch simply writes a row nobody can see. // IS the publish, atomically. A worker at a dead epoch simply writes a row nobody can see.
if !finalize_job(pool, event_id, "zip", epoch, &format!("exports/{out_name}")).await { if !finalize_job(pool, event_id, "zip", epoch, &format!("exports/{out_name}")).await {
let _ = tokio::fs::remove_file(&out_path).await; let _ = tokio::fs::remove_file(&out_path).await;
tracing::info!("ZIP export for event {event_id} superseded (epoch {epoch} retired); discarded"); tracing::info!(
"ZIP export for event {event_id} superseded (epoch {epoch} retired); discarded"
);
return Ok(()); return Ok(());
} }
@@ -627,15 +649,25 @@ async fn run_html_export(
return Ok(()); return Ok(());
} }
let res = let res = run_html_export_inner(
run_html_export_inner(epoch, event_id, event_name, pool, media_path, export_path, sse_tx).await; epoch,
event_id,
event_name,
pool,
media_path,
export_path,
sse_tx,
)
.await;
if res.is_err() { if res.is_err() {
// Clean up this generation's temp artifacts so a failing (or abandoned) export can't leak // Clean up this generation's temp artifacts so a failing (or abandoned) export can't leak
// them — the leak is what fills the disk, which is what corrupts the next archive. // them — the leak is what fills the disk, which is what corrupts the next archive.
let _ = let _ =
tokio::fs::remove_file(export_path.join(gen_name(event_id, "Memories", epoch, ".tmp"))).await; tokio::fs::remove_file(export_path.join(gen_name(event_id, "Memories", epoch, ".tmp")))
let _ = tokio::fs::remove_dir_all(export_path.join(format!("viewer_tmp_{event_id}_{epoch}"))) .await;
.await; let _ =
tokio::fs::remove_dir_all(export_path.join(format!("viewer_tmp_{event_id}_{epoch}")))
.await;
} }
abandon_if_superseded("HTML", event_id, epoch, res) abandon_if_superseded("HTML", event_id, epoch, res)
} }
@@ -724,7 +756,10 @@ async fn run_html_export_inner(
match ffmpeg_result { match ffmpeg_result {
Ok(output) if output.status.success() => {} Ok(output) if output.status.success() => {}
_ => { _ => {
tracing::warn!("ffmpeg thumbnail failed for upload {}, skipping thumb", row.id); tracing::warn!(
"ffmpeg thumbnail failed for upload {}, skipping thumb",
row.id
);
// Missing thumb entry — viewer handles missing thumbs gracefully. // Missing thumb entry — viewer handles missing thumbs gracefully.
} }
} }
@@ -904,7 +939,10 @@ async fn run_html_export_inner(
let src_file = match tokio::fs::File::open(path).await { let src_file = match tokio::fs::File::open(path).await {
Ok(f) => f, Ok(f) => f,
Err(e) => { Err(e) => {
tracing::warn!("HTML export: skipping media {name} — cannot read {}: {e}", path.display()); tracing::warn!(
"HTML export: skipping media {name} — cannot read {}: {e}",
path.display()
);
continue; continue;
} }
}; };
@@ -939,9 +977,19 @@ async fn run_html_export_inner(
// Epoch-guarded finalize — writing `done` at a live epoch IS the publish (readiness is derived // Epoch-guarded finalize — writing `done` at a live epoch IS the publish (readiness is derived
// from it), so there is no separate ready flag to flip. If our epoch was retired, we lost: // from it), so there is no separate ready flag to flip. If our epoch was retired, we lost:
// discard the stale archive. // discard the stale archive.
if !finalize_job(pool, event_id, "html", epoch, &format!("exports/{out_name}")).await { if !finalize_job(
pool,
event_id,
"html",
epoch,
&format!("exports/{out_name}"),
)
.await
{
let _ = tokio::fs::remove_file(&out_path).await; let _ = tokio::fs::remove_file(&out_path).await;
tracing::info!("HTML export for event {event_id} superseded (epoch {epoch} retired); discarded"); tracing::info!(
"HTML export for event {event_id} superseded (epoch {epoch} retired); discarded"
);
return Ok(()); return Ok(());
} }
@@ -1077,7 +1125,10 @@ async fn finalize_job(
/// `Gallery.<n>.zip`, `Gallery.zip.<n>.tmp`, `viewer_tmp_<event>_<n>`). Returns None if the /// `Gallery.<n>.zip`, `Gallery.zip.<n>.tmp`, `viewer_tmp_<event>_<n>`). Returns None if the
/// name doesn't fit the shape, so unrelated files are left untouched. /// name doesn't fit the shape, so unrelated files are left untouched.
fn parse_gen_seq(name: &str, prefix: &str, suffix: &str) -> Option<i64> { fn parse_gen_seq(name: &str, prefix: &str, suffix: &str) -> Option<i64> {
name.strip_prefix(prefix)?.strip_suffix(suffix)?.parse::<i64>().ok() name.strip_prefix(prefix)?
.strip_suffix(suffix)?
.parse::<i64>()
.ok()
} }
/// Best-effort removal of stale per-generation export artifacts for one export type. Deletes /// Best-effort removal of stale per-generation export artifacts for one export type. Deletes
@@ -1276,7 +1327,13 @@ fn ext_from_path(path: &str) -> &str {
fn sanitize_name(name: &str) -> String { fn sanitize_name(name: &str) -> String {
name.chars() name.chars()
.map(|c| if c.is_alphanumeric() || c == '-' { c } else { '_' }) .map(|c| {
if c.is_alphanumeric() || c == '-' {
c
} else {
'_'
}
})
.collect() .collect()
} }

View File

@@ -87,11 +87,7 @@ pub async fn startup_recovery(pool: &PgPool) {
/// - drops expired SSE tickets (30s TTL but the map keeps the slot until pruned) /// - drops expired SSE tickets (30s TTL but the map keeps the slot until pruned)
/// ///
/// Cadence is 1h — fine for both jobs at our scale. /// Cadence is 1h — fine for both jobs at our scale.
pub fn spawn_periodic_tasks( pub fn spawn_periodic_tasks(pool: PgPool, rate_limiter: RateLimiter, sse_tickets: SseTicketStore) {
pool: PgPool,
rate_limiter: RateLimiter,
sse_tickets: SseTicketStore,
) {
tokio::spawn(async move { tokio::spawn(async move {
let mut tick = tokio::time::interval(Duration::from_secs(3600)); let mut tick = tokio::time::interval(Duration::from_secs(3600));
// Fire the first tick immediately, then hourly. // Fire the first tick immediately, then hourly.

View File

@@ -24,7 +24,12 @@ impl RateLimiter {
/// Returns `Ok(())` if allowed, `Err(retry_after_secs)` if rate-limited. /// Returns `Ok(())` if allowed, `Err(retry_after_secs)` if rate-limited.
/// `retry_after_secs` is how long until the oldest slot in the window expires. /// `retry_after_secs` is how long until the oldest slot in the window expires.
pub fn check_with_retry(&self, key: impl Into<String>, max: usize, window: Duration) -> Result<(), u64> { pub fn check_with_retry(
&self,
key: impl Into<String>,
max: usize,
window: Duration,
) -> Result<(), u64> {
let now = Instant::now(); let now = Instant::now();
let key = key.into(); let key = key.into();
let mut map = self.windows.lock().unwrap(); let mut map = self.windows.lock().unwrap();
@@ -120,7 +125,10 @@ mod tests {
assert!(rl.check("k", 1, w)); assert!(rl.check("k", 1, w));
assert!(!rl.check("k", 1, w)); assert!(!rl.check("k", 1, w));
std::thread::sleep(Duration::from_millis(55)); std::thread::sleep(Duration::from_millis(55));
assert!(rl.check("k", 1, w), "the slot should expire once the window passes"); assert!(
rl.check("k", 1, w),
"the slot should expire once the window passes"
);
} }
/// `retry_after` is not a "some number in range" — it is the time until the oldest slot /// `retry_after` is not a "some number in range" — it is the time until the oldest slot
@@ -174,7 +182,10 @@ mod tests {
let retry = rl.check_with_retry("k", 1, w).unwrap_err(); let retry = rl.check_with_retry("k", 1, w).unwrap_err();
// The sub-second remainder truncates to 0; clients must never be told "retry in 0s" // The sub-second remainder truncates to 0; clients must never be told "retry in 0s"
// (that's a busy-loop). The `.max(1)` floor is what prevents it. // (that's a busy-loop). The `.max(1)` floor is what prevents it.
assert_eq!(retry, 1, "a sub-second remainder must floor to 1, got {retry}"); assert_eq!(
retry, 1,
"a sub-second remainder must floor to 1, got {retry}"
);
} }
#[test] #[test]
@@ -251,7 +262,10 @@ mod tests {
fn client_ip_ignores_spoofed_leftmost_entry() { fn client_ip_ignores_spoofed_leftmost_entry() {
// A client prepending a fake IP to dodge throttles must not win. // A client prepending a fake IP to dodge throttles must not win.
let mut h = HeaderMap::new(); let mut h = HeaderMap::new();
h.insert("x-forwarded-for", "1.2.3.4, 9.9.9.9, 203.0.113.7".parse().unwrap()); h.insert(
"x-forwarded-for",
"1.2.3.4, 9.9.9.9, 203.0.113.7".parse().unwrap(),
);
assert_eq!(client_ip(&h, "fallback"), "203.0.113.7"); assert_eq!(client_ip(&h, "fallback"), "203.0.113.7");
} }

View File

@@ -90,7 +90,11 @@ mod tests {
let ticket = store.issue("hash-1".into()); let ticket = store.issue("hash-1".into());
assert_eq!(store.consume(&ticket).as_deref(), Some("hash-1")); assert_eq!(store.consume(&ticket).as_deref(), Some("hash-1"));
// Single-use: a replay of the same ticket is rejected. // Single-use: a replay of the same ticket is rejected.
assert_eq!(store.consume(&ticket), None, "a consumed ticket must not be reusable"); assert_eq!(
store.consume(&ticket),
None,
"a consumed ticket must not be reusable"
);
} }
#[test] #[test]
@@ -131,6 +135,10 @@ mod tests {
.expect("host uptime should exceed the ticket TTL"), .expect("host uptime should exceed the ticket TTL"),
}, },
); );
assert_eq!(store.consume(&stale), None, "an expired ticket must not authenticate"); assert_eq!(
store.consume(&stale),
None,
"an expired ticket must not authenticate"
);
} }
} }

View File

@@ -33,18 +33,34 @@ use sqlx::PgPool;
#[sqlx::test] #[sqlx::test]
async fn release_returns_post_increment_epoch(pool: PgPool) { async fn release_returns_post_increment_epoch(pool: PgPool) {
let event_id = seed_event(&pool, "wedding").await; let event_id = seed_event(&pool, "wedding").await;
assert_eq!(event_epoch(&pool, event_id).await, 0, "a fresh event starts at epoch 0"); assert_eq!(
event_epoch(&pool, event_id).await,
0,
"a fresh event starts at epoch 0"
);
let released = release_gallery(&pool, "wedding").await.expect("release claims the event"); let released = release_gallery(&pool, "wedding")
assert_eq!(released, 1, "RETURNING must give the epoch AFTER the +1, not before"); .await
assert_eq!(event_epoch(&pool, event_id).await, released, "worker's epoch == event's epoch"); .expect("release claims the event");
assert_eq!(
released, 1,
"RETURNING must give the epoch AFTER the +1, not before"
);
assert_eq!(
event_epoch(&pool, event_id).await,
released,
"worker's epoch == event's epoch"
);
// The jobs armed by the release carry exactly that epoch — this is what makes the worker's // The jobs armed by the release carry exactly that epoch — this is what makes the worker's
// guarded writes match. // guarded writes match.
for t in ["zip", "html"] { for t in ["zip", "html"] {
let (status, epoch, _) = job_row(&pool, event_id, t).await.expect("job armed"); let (status, epoch, _) = job_row(&pool, event_id, t).await.expect("job armed");
assert_eq!(status, "pending"); assert_eq!(status, "pending");
assert_eq!(epoch, released, "{t} job must be armed at the epoch the worker was born with"); assert_eq!(
epoch, released,
"{t} job must be armed at the epoch the worker was born with"
);
} }
} }
@@ -61,15 +77,27 @@ async fn epoch_is_strictly_monotonic_across_reopen(pool: PgPool) {
assert_eq!(release_gallery(&pool, "wedding").await, Some(1)); assert_eq!(release_gallery(&pool, "wedding").await, Some(1));
// A duplicate release is a no-op (`WHERE export_released_at IS NULL`) and must NOT bump. // A duplicate release is a no-op (`WHERE export_released_at IS NULL`) and must NOT bump.
assert_eq!(release_gallery(&pool, "wedding").await, None, "already released"); assert_eq!(
assert_eq!(event_epoch(&pool, event_id).await, 1, "a rejected release must not move the epoch"); release_gallery(&pool, "wedding").await,
None,
"already released"
);
assert_eq!(
event_epoch(&pool, event_id).await,
1,
"a rejected release must not move the epoch"
);
// Reopen retires the generation with ONE write. // Reopen retires the generation with ONE write.
assert_eq!(open_event(&pool, "wedding").await, 1); assert_eq!(open_event(&pool, "wedding").await, 1);
assert_eq!(event_epoch(&pool, event_id).await, 2, "reopen bumps"); assert_eq!(event_epoch(&pool, event_id).await, 2, "reopen bumps");
// And re-releasing bumps again — never back to 1. // And re-releasing bumps again — never back to 1.
assert_eq!(release_gallery(&pool, "wedding").await, Some(3), "re-release bumps again"); assert_eq!(
release_gallery(&pool, "wedding").await,
Some(3),
"re-release bumps again"
);
assert_eq!(event_epoch(&pool, event_id).await, 3); assert_eq!(event_epoch(&pool, event_id).await, 3);
} }
@@ -91,11 +119,19 @@ async fn retired_epoch_worker_writes_are_no_ops(pool: PgPool) {
let old_epoch = release_gallery(&pool, "wedding").await.unwrap(); let old_epoch = release_gallery(&pool, "wedding").await.unwrap();
// Worker A is born at epoch 1 and claims the ZIP. // Worker A is born at epoch 1 and claims the ZIP.
assert!(claim_job(&pool, event_id, "zip", old_epoch).await, "worker A wins its claim"); assert!(
assert!(update_progress(&pool, event_id, "zip", old_epoch, 40).await, "still live at 40%"); claim_job(&pool, event_id, "zip", old_epoch).await,
"worker A wins its claim"
);
assert!(
update_progress(&pool, event_id, "zip", old_epoch, 40).await,
"still live at 40%"
);
// ── A takedown lands mid-export: `invalidate_and_arm(Affects::Both)` bumps and re-arms. ── // ── A takedown lands mid-export: `invalidate_and_arm(Affects::Both)` bumps and re-arms. ──
let (_, _, new_epoch) = bump_epoch(&pool, "wedding").await.expect("bump on a released event"); let (_, _, new_epoch) = bump_epoch(&pool, "wedding")
.await
.expect("bump on a released event");
assert_eq!(new_epoch, old_epoch + 1); assert_eq!(new_epoch, old_epoch + 1);
let mut conn = pool.acquire().await.unwrap(); let mut conn = pool.acquire().await.unwrap();
enqueue_types_at_epoch(&mut conn, event_id, new_epoch, &["zip", "html"]).await; enqueue_types_at_epoch(&mut conn, event_id, new_epoch, &["zip", "html"]).await;
@@ -116,7 +152,10 @@ async fn retired_epoch_worker_writes_are_no_ops(pool: PgPool) {
// fresh worker. (If worker A had won, this row would read `done` at epoch 1.) // fresh worker. (If worker A had won, this row would read `done` at epoch 1.)
let (status, epoch, file_path) = job_row(&pool, event_id, "zip").await.unwrap(); let (status, epoch, file_path) = job_row(&pool, event_id, "zip").await.unwrap();
assert_eq!((status.as_str(), epoch), ("pending", new_epoch)); assert_eq!((status.as_str(), epoch), ("pending", new_epoch));
assert_eq!(file_path, None, "the loser's file_path must never be recorded"); assert_eq!(
file_path, None,
"the loser's file_path must never be recorded"
);
// And nothing is downloadable — not the stale archive, not anything. // And nothing is downloadable — not the stale archive, not anything.
assert_eq!(downloadable(&pool, event_id, "zip").await, None); assert_eq!(downloadable(&pool, event_id, "zip").await, None);
@@ -177,13 +216,25 @@ async fn export_current_is_exactly_the_invariant(pool: PgPool) {
// (name, released?, status, job epoch offset from the event epoch, expected visible) // (name, released?, status, job epoch offset from the event epoch, expected visible)
let cases: &[(&str, bool, &str, i64, bool)] = &[ let cases: &[(&str, bool, &str, i64, bool)] = &[
("released + done + current epoch", true, "done", 0, true), ("released + done + current epoch", true, "done", 0, true),
("NOT released (done, epoch matches)", false, "done", 0, false), (
"NOT released (done, epoch matches)",
false,
"done",
0,
false,
),
("NOT done: pending", true, "pending", 0, false), ("NOT done: pending", true, "pending", 0, false),
("NOT done: running", true, "running", 0, false), ("NOT done: running", true, "running", 0, false),
("NOT done: failed", true, "failed", 0, false), ("NOT done: failed", true, "failed", 0, false),
("stale epoch (done, released)", true, "done", -1, false), ("stale epoch (done, released)", true, "done", -1, false),
("future epoch (done, released)", true, "done", 1, false), ("future epoch (done, released)", true, "done", 1, false),
("migration-014 retired sentinel epoch -1", true, "done", -2, false), (
"migration-014 retired sentinel epoch -1",
true,
"done",
-2,
false,
),
]; ];
for (i, (name, released, status, offset, expect_visible)) in cases.iter().enumerate() { for (i, (name, released, status, offset, expect_visible)) in cases.iter().enumerate() {
@@ -209,7 +260,11 @@ async fn export_current_is_exactly_the_invariant(pool: PgPool) {
.await .await
.expect("clear armed jobs"); .expect("clear armed jobs");
let job_epoch = if *offset == -2 { -1 } else { event_epoch_now + offset }; let job_epoch = if *offset == -2 {
-1
} else {
event_epoch_now + offset
};
sqlx::query( sqlx::query(
"INSERT INTO export_job (event_id, type, status, progress_pct, epoch, file_path) "INSERT INTO export_job (event_id, type, status, progress_pct, epoch, file_path)
VALUES ($1, 'zip', $2::export_status, 100, $3, 'exports/Gallery.zip')", VALUES ($1, 'zip', $2::export_status, 100, $3, 'exports/Gallery.zip')",
@@ -251,12 +306,17 @@ async fn viewer_only_carries_a_done_zip_forward(pool: PgPool) {
assert!(claim_job(&pool, event_id, t, e1).await); assert!(claim_job(&pool, event_id, t, e1).await);
assert!(finalize_job(&pool, event_id, t, e1, &format!("exports/{t}.{e1}.zip")).await); assert!(finalize_job(&pool, event_id, t, e1, &format!("exports/{t}.{e1}.zip")).await);
} }
let zip_file = downloadable(&pool, event_id, "zip").await.expect("zip is live"); let zip_file = downloadable(&pool, event_id, "zip")
.await
.expect("zip is live");
// ── A comment is moderated: invalidate_and_arm(Affects::ViewerOnly). ── // ── A comment is moderated: invalidate_and_arm(Affects::ViewerOnly). ──
let (_, _, e2) = bump_epoch(&pool, "wedding").await.unwrap(); let (_, _, e2) = bump_epoch(&pool, "wedding").await.unwrap();
let carried = carry_zip_forward(&pool, event_id, e2).await; let carried = carry_zip_forward(&pool, event_id, e2).await;
assert!(carried, "a `done` ZIP at epoch-1 MUST be carried forward (rows_affected == 1)"); assert!(
carried,
"a `done` ZIP at epoch-1 MUST be carried forward (rows_affected == 1)"
);
// Only the viewer is re-armed… // Only the viewer is re-armed…
let mut conn = pool.acquire().await.unwrap(); let mut conn = pool.acquire().await.unwrap();
@@ -265,7 +325,11 @@ async fn viewer_only_carries_a_done_zip_forward(pool: PgPool) {
// …and the ZIP is STILL DOWNLOADABLE, at the new epoch, pointing at the same, unrenamed file. // …and the ZIP is STILL DOWNLOADABLE, at the new epoch, pointing at the same, unrenamed file.
let (status, epoch, _) = job_row(&pool, event_id, "zip").await.unwrap(); let (status, epoch, _) = job_row(&pool, event_id, "zip").await.unwrap();
assert_eq!((status.as_str(), epoch), ("done", e2), "the ZIP row rode the epoch bump"); assert_eq!(
(status.as_str(), epoch),
("done", e2),
"the ZIP row rode the epoch bump"
);
assert_eq!( assert_eq!(
downloadable(&pool, event_id, "zip").await, downloadable(&pool, event_id, "zip").await,
Some(zip_file), Some(zip_file),
@@ -354,8 +418,16 @@ async fn enqueue_preserves_a_done_half_at_the_current_epoch(pool: PgPool) {
// The good half survives untouched… // The good half survives untouched…
let (status, epoch, file_path) = job_row(&pool, event_id, "zip").await.unwrap(); let (status, epoch, file_path) = job_row(&pool, event_id, "zip").await.unwrap();
assert_eq!((status.as_str(), epoch), ("done", e1), "a done half at the live epoch is preserved"); assert_eq!(
assert_eq!(file_path.as_deref(), Some("exports/Gallery.1.zip"), "file_path not nulled"); (status.as_str(), epoch),
("done", e1),
"a done half at the live epoch is preserved"
);
assert_eq!(
file_path.as_deref(),
Some("exports/Gallery.1.zip"),
"file_path not nulled"
);
assert!(downloadable(&pool, event_id, "zip").await.is_some()); assert!(downloadable(&pool, event_id, "zip").await.is_some());
// …and only the missing half is re-armed. // …and only the missing half is re-armed.

View File

@@ -17,12 +17,7 @@ use uuid::Uuid;
/// SRC: `handlers/upload.rs:313-322` — the guarded quota increment, verbatim. /// SRC: `handlers/upload.rs:313-322` — the guarded quota increment, verbatim.
/// Returns `rows_affected()`; the handler aborts the whole upload tx when this is 0. /// Returns `rows_affected()`; the handler aborts the whole upload tx when this is 0.
async fn quota_inc( async fn quota_inc(exec: impl sqlx::PgExecutor<'_>, user_id: Uuid, size: i64, limit: i64) -> u64 {
exec: impl sqlx::PgExecutor<'_>,
user_id: Uuid,
size: i64,
limit: i64,
) -> u64 {
sqlx::query( sqlx::query(
"UPDATE \"user\" SET total_upload_bytes = total_upload_bytes + $2 "UPDATE \"user\" SET total_upload_bytes = total_upload_bytes + $2
WHERE id = $1 AND total_upload_bytes + $2 <= $3", WHERE id = $1 AND total_upload_bytes + $2 <= $3",
@@ -69,7 +64,11 @@ async fn quota_two_attempts_from_one_stale_snapshot_cannot_both_commit(pool: PgP
// Each upload, judged against that snapshot alone, fits: 0 + 60 <= 100. Twice. // Each upload, judged against that snapshot alone, fits: 0 + 60 <= 100. Twice.
assert!(snapshot + SIZE <= LIMIT); assert!(snapshot + SIZE <= LIMIT);
assert_eq!(quota_inc(&pool, user_id, SIZE, LIMIT).await, 1, "the first upload commits"); assert_eq!(
quota_inc(&pool, user_id, SIZE, LIMIT).await,
1,
"the first upload commits"
);
assert_eq!( assert_eq!(
quota_inc(&pool, user_id, SIZE, LIMIT).await, quota_inc(&pool, user_id, SIZE, LIMIT).await,
0, 0,
@@ -77,7 +76,11 @@ async fn quota_two_attempts_from_one_stale_snapshot_cannot_both_commit(pool: PgP
(60 + 60 = 120 > 100). rows_affected() == 0 is what makes the handler abort." (60 + 60 = 120 > 100). rows_affected() == 0 is what makes the handler abort."
); );
assert_eq!(total_bytes(&pool, user_id).await, SIZE, "never 120 — the quota held"); assert_eq!(
total_bytes(&pool, user_id).await,
SIZE,
"never 120 — the quota held"
);
} }
/// The same, but genuinely CONCURRENT: two transactions that both read `total = 0`, then both try to /// The same, but genuinely CONCURRENT: two transactions that both read `total = 0`, then both try to
@@ -126,9 +129,17 @@ async fn quota_guard_is_atomic_under_concurrent_transactions(pool: PgPool) {
// And an upload that legitimately fits in what's left still succeeds — the guard rejects // And an upload that legitimately fits in what's left still succeeds — the guard rejects
// overruns, not everything. // overruns, not everything.
assert_eq!(quota_inc(&pool, user_id, 40, LIMIT).await, 1, "0 + 60 + 40 == 100, exactly at the limit"); assert_eq!(
quota_inc(&pool, user_id, 40, LIMIT).await,
1,
"0 + 60 + 40 == 100, exactly at the limit"
);
assert_eq!(total_bytes(&pool, user_id).await, LIMIT); assert_eq!(total_bytes(&pool, user_id).await, LIMIT);
assert_eq!(quota_inc(&pool, user_id, 1, LIMIT).await, 0, "and one byte more is refused"); assert_eq!(
quota_inc(&pool, user_id, 1, LIMIT).await,
0,
"and one byte more is refused"
);
} }
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@@ -140,11 +151,13 @@ async fn lock_and_read_event(
tx: &mut sqlx::PgConnection, tx: &mut sqlx::PgConnection,
event_id: Uuid, event_id: Uuid,
) -> (Option<DateTime<Utc>>, Option<DateTime<Utc>>) { ) -> (Option<DateTime<Utc>>, Option<DateTime<Utc>>) {
sqlx::query_as("SELECT uploads_locked_at, export_released_at FROM event WHERE id = $1 FOR SHARE") sqlx::query_as(
.bind(event_id) "SELECT uploads_locked_at, export_released_at FROM event WHERE id = $1 FOR SHARE",
.fetch_one(tx) )
.await .bind(event_id)
.expect("FOR SHARE re-check") .fetch_one(tx)
.await
.expect("FOR SHARE re-check")
} }
/// THE GUARD AGAINST SILENT, PERMANENT PHOTO LOSS. /// THE GUARD AGAINST SILENT, PERMANENT PHOTO LOSS.
@@ -167,7 +180,10 @@ async fn for_share_upload_lock_serializes_against_release(pool: PgPool) {
// ── The guest's upload transaction takes the share lock. ── // ── The guest's upload transaction takes the share lock. ──
let mut upload_tx = pool.begin().await.unwrap(); let mut upload_tx = pool.begin().await.unwrap();
let (locked, released) = lock_and_read_event(&mut upload_tx, event_id).await; let (locked, released) = lock_and_read_event(&mut upload_tx, event_id).await;
assert!(locked.is_none() && released.is_none(), "uploads are open, so we proceed to commit"); assert!(
locked.is_none() && released.is_none(),
"uploads are open, so we proceed to commit"
);
// ── Concurrently, the host hits "Galerie freigeben". ── // ── Concurrently, the host hits "Galerie freigeben". ──
let release_done = Arc::new(AtomicBool::new(false)); let release_done = Arc::new(AtomicBool::new(false));
@@ -230,7 +246,11 @@ async fn for_share_upload_lock_serializes_against_release(pool: PgPool) {
.fetch_all(&pool) .fetch_all(&pool)
.await .await
.unwrap(); .unwrap();
assert_eq!(snapshot, vec![upload_id], "the released keepsake CONTAINS the in-flight photo"); assert_eq!(
snapshot,
vec![upload_id],
"the released keepsake CONTAINS the in-flight photo"
);
} }
/// The other side of the same lock: once the release has COMMITTED, the next upload's `FOR SHARE` /// The other side of the same lock: once the release has COMMITTED, the next upload's `FOR SHARE`
@@ -255,14 +275,23 @@ async fn upload_after_release_commits_sees_the_lock_and_is_rejected(pool: PgPool
// After it, the identical re-check sees the release and the handler bails out. // After it, the identical re-check sees the release and the handler bails out.
let mut tx = pool.begin().await.unwrap(); let mut tx = pool.begin().await.unwrap();
let (locked, released) = lock_and_read_event(&mut tx, event_id).await; let (locked, released) = lock_and_read_event(&mut tx, event_id).await;
assert!(released.is_some(), "the FOR SHARE re-read MUST observe the committed release"); assert!(
assert!(locked.is_some(), "release locks uploads in the same statement (release ⇒ lock)"); released.is_some(),
"the FOR SHARE re-read MUST observe the committed release"
);
assert!(
locked.is_some(),
"release locks uploads in the same statement (release ⇒ lock)"
);
tx.rollback().await.unwrap(); tx.rollback().await.unwrap();
// And a reopen makes it uploadable again — the rejection was reversible, not terminal. // And a reopen makes it uploadable again — the rejection was reversible, not terminal.
assert_eq!(open_event(&pool, "wedding").await, 1); assert_eq!(open_event(&pool, "wedding").await, 1);
let mut tx = pool.begin().await.unwrap(); let mut tx = pool.begin().await.unwrap();
let (locked, released) = lock_and_read_event(&mut tx, event_id).await; let (locked, released) = lock_and_read_event(&mut tx, event_id).await;
assert!(locked.is_none() && released.is_none(), "the guest can resume their upload"); assert!(
locked.is_none() && released.is_none(),
"the guest can resume their upload"
);
tx.rollback().await.unwrap(); tx.rollback().await.unwrap();
} }