Merge branch 'chore/rustfmt-2026-07-15'
This commit is contained in:
6
.github/workflows/checks.yml
vendored
6
.github/workflows/checks.yml
vendored
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,15 +45,13 @@ 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)
|
.bind(event.id)
|
||||||
.fetch_one(&state.pool)
|
.fetch_one(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let (upload_count,): (i64,) = sqlx::query_as(
|
let (upload_count,): (i64,) =
|
||||||
"SELECT COUNT(*) FROM upload WHERE event_id = $1 AND deleted_at IS NULL",
|
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?;
|
||||||
@@ -90,8 +88,7 @@ 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?;
|
||||||
|
|
||||||
@@ -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 }))
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,9 +331,8 @@ 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?;
|
||||||
@@ -362,9 +365,8 @@ 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)
|
||||||
|
|||||||
@@ -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,8 +171,11 @@ 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,
|
||||||
|
&state.config.event_slug,
|
||||||
|
Affects::Both,
|
||||||
|
)
|
||||||
.await?;
|
.await?;
|
||||||
tx.commit().await?;
|
tx.commit().await?;
|
||||||
if let Some(r) = regen {
|
if let Some(r) = regen {
|
||||||
@@ -237,8 +245,11 @@ 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,
|
||||||
|
&state.config.event_slug,
|
||||||
|
Affects::Both,
|
||||||
|
)
|
||||||
.await?;
|
.await?;
|
||||||
tx.commit().await?;
|
tx.commit().await?;
|
||||||
if let Some(r) = regen {
|
if let Some(r) = regen {
|
||||||
@@ -266,8 +277,11 @@ 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,
|
||||||
|
&state.config.event_slug,
|
||||||
|
Affects::Both,
|
||||||
|
)
|
||||||
.await?;
|
.await?;
|
||||||
tx.commit().await?;
|
tx.commit().await?;
|
||||||
|
|
||||||
@@ -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,8 +577,11 @@ 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,
|
||||||
|
&state.config.event_slug,
|
||||||
|
Affects::Both,
|
||||||
|
)
|
||||||
.await?;
|
.await?;
|
||||||
tx.commit().await?;
|
tx.commit().await?;
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,7 +334,9 @@ 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(
|
||||||
|
"UPDATE \"user\" SET total_upload_bytes = total_upload_bytes + $2 WHERE id = $1",
|
||||||
|
)
|
||||||
.bind(auth.user_id)
|
.bind(auth.user_id)
|
||||||
.bind(size)
|
.bind(size)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
@@ -592,12 +607,12 @@ 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,));
|
||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -83,9 +83,7 @@ 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)
|
.bind(id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -33,9 +33,7 @@ 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(slug)
|
||||||
.bind(name)
|
.bind(name)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -109,10 +109,12 @@ 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>,
|
||||||
|
) -> Result<(), sqlx::Error> {
|
||||||
|
sqlx::query("UPDATE \"user\" SET pin_locked_until = $2 WHERE id = $1")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.bind(until)
|
.bind(until)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
|
|||||||
@@ -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,12 +216,8 @@ 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(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(res) => res.context("ffmpeg wait failed")?,
|
Ok(res) => res.context("ffmpeg wait failed")?,
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
let _ = child.kill().await;
|
let _ = child.kill().await;
|
||||||
@@ -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}"))
|
||||||
|
|||||||
@@ -81,8 +81,9 @@ 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)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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,8 +435,15 @@ 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,
|
||||||
|
epoch,
|
||||||
|
&event_name2,
|
||||||
|
&pool2,
|
||||||
|
&media_path2,
|
||||||
|
&export_path2,
|
||||||
|
&sse_tx2,
|
||||||
|
)
|
||||||
.await
|
.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:#}");
|
||||||
@@ -459,7 +474,8 @@ 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 _ =
|
||||||
|
tokio::fs::remove_file(export_path.join(gen_name(event_id, "Gallery", epoch, ".tmp")))
|
||||||
.await;
|
.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,14 +649,24 @@ 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;
|
||||||
|
let _ =
|
||||||
|
tokio::fs::remove_dir_all(export_path.join(format!("viewer_tmp_{event_id}_{epoch}")))
|
||||||
.await;
|
.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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,7 +151,9 @@ 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(
|
||||||
|
"SELECT uploads_locked_at, export_released_at FROM event WHERE id = $1 FOR SHARE",
|
||||||
|
)
|
||||||
.bind(event_id)
|
.bind(event_id)
|
||||||
.fetch_one(tx)
|
.fetch_one(tx)
|
||||||
.await
|
.await
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user