feat(db): idempotency keys, ban-aware counts, and a host audit trail

Four migrations, all additive against a database that already has 001-025 applied.

026 narrows the client-upload idempotency index with `AND deleted_at IS NULL`. The old
index made a soft-deleted row keep its key forever, so a guest who deleted a photo and
re-sent the same one had the retry silently swallowed. The new indexed set is a strict
subset of the old, so it cannot fail on existing rows.

027 adds `client_join_id`, which lets a join retry after a lost response resume the same
account instead of 409ing on a name the caller itself owns. Every existing row gets NULL
and the partial index excludes NULLs, so it indexes nothing at creation.

028 brings the feed view's like/comment counts in line with what the feed actually renders:
a banned guest's rows were still counted, so a card showed "3 comments" above two.
`comment.rs` gets the matching `NOT u.is_banned` on the live read path — the export and
hashtag queries already filtered it, so the two views of one moderation action disagreed.

029 records host moderation actions, which were previously invisible after the fact.

Verified by applying 001-029 to a real Postgres against seeded data, including a
soft-deleted row holding a key and a banned user's like and comment. 026's down-migration
legitimately fails where a deleted and a live row share a key — that is inherent to the
direction, documented in the file, and sqlx never runs downs at boot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
fabi
2026-08-11 22:43:11 +02:00
parent ef6d3a077a
commit 963f6449a1
15 changed files with 365 additions and 10 deletions

View File

@@ -2,7 +2,11 @@ use anyhow::{Context, Result};
use sqlx::PgPool;
use sqlx::postgres::PgPoolOptions;
const DEFAULT_MAX_CONNECTIONS: u32 = 10;
/// Keep in step with `.env.example` and the `db` sizing comment in `docker-compose.yml`.
/// These three drifted apart once (code 10 / `.env.example` 15 / runbook 30) and the runbook
/// presented its number as authoritative, so the contradiction was invisible at deploy time.
/// 15 is sized to 2 vCPU and the 1G `db` memory limit — raise it only alongside both.
const DEFAULT_MAX_CONNECTIONS: u32 = 15;
/// SQLSTATE for `invalid_password`.
const PG_INVALID_PASSWORD: &str = "28P01";
@@ -47,10 +51,23 @@ fn explain_auth_failure(err: &sqlx::Error) {
}
pub async fn create_pool(database_url: &str) -> Result<PgPool> {
let max_connections = std::env::var("DATABASE_MAX_CONNECTIONS")
.ok()
.and_then(|s| s.parse::<u32>().ok())
.unwrap_or(DEFAULT_MAX_CONNECTIONS);
// A malformed value must not silently become the default: an operator who typed
// `DATABASE_MAX_CONNECTIONS=3O` (letter O) would otherwise get 15 with no indication,
// and would keep tuning a knob that never took effect.
let max_connections = match std::env::var("DATABASE_MAX_CONNECTIONS") {
Err(_) => DEFAULT_MAX_CONNECTIONS,
Ok(raw) => match raw.trim().parse::<u32>() {
Ok(0) => {
anyhow::bail!("DATABASE_MAX_CONNECTIONS must be at least 1 (got 0)");
}
Ok(n) => n,
Err(e) => {
anyhow::bail!(
"DATABASE_MAX_CONNECTIONS must be a positive integer (got {raw:?}): {e}"
);
}
},
};
let pool = match PgPoolOptions::new()
.max_connections(max_connections)

View File

@@ -62,13 +62,27 @@ impl Comment {
) -> Result<Vec<CommentDto>, sqlx::Error> {
// Two-step: pick the newest `limit` rows older than `before`, then flip
// them back into ascending order so the caller can render top-to-bottom.
// `AND NOT u.is_banned` — the filter that was missing (H11).
//
// Only `deleted_at` was checked, so a banned guest's comments stayed on the live feed
// forever: the host bans somebody for an abusive comment, watches every photo of theirs
// vanish, and the comment is still sitting there on the most-viewed photo of the evening.
// Nothing on the client evicted them either.
//
// The tell that this was an oversight rather than a decision: the EXPORT query already
// filters `is_banned`, so the comment disappeared from the keepsake but not from the app —
// the two views of the same moderation action disagreed. Migration 021 did the same for
// hashtag counts. This brings the live read path in line with both.
//
// A ban is reversible and this is derived at read time, so `unban_user` restores the
// comments with no extra work.
sqlx::query_as::<_, CommentDto>(
"SELECT * FROM (
SELECT c.id, c.upload_id, c.user_id, u.display_name AS uploader_name,
c.body, c.created_at
FROM comment c
JOIN \"user\" u ON u.id = c.user_id
WHERE c.upload_id = $1 AND c.deleted_at IS NULL
WHERE c.upload_id = $1 AND c.deleted_at IS NULL AND NOT u.is_banned
AND ($2::timestamptz IS NULL OR c.created_at < $2)
ORDER BY c.created_at DESC
LIMIT $3

View File

@@ -77,10 +77,19 @@ impl Upload {
//
// The conflict target repeats the index's `WHERE` clause because it is a partial index;
// without it Postgres cannot prove which index to use and rejects the statement.
//
// KEEP THIS IN LOCKSTEP WITH `upload_client_upload_id_key` (migration 026). The predicate
// here must match the index's, or the arbiter cannot be inferred and every upload that
// carries a `client_upload_id` fails as a runtime 500 — queries in this codebase are not
// compile-time checked, so nothing catches a drift between the two at build time.
//
// `AND deleted_at IS NULL` is what makes a retry-after-delete work instead of 409ing
// forever: the key is claimed only while a LIVE row holds it, which is what
// `find_by_client_upload_id` below has always assumed.
sqlx::query_as::<_, Self>(
"INSERT INTO upload (event_id, user_id, original_path, mime_type, original_size_bytes, caption, client_upload_id)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (client_upload_id) WHERE client_upload_id IS NOT NULL DO NOTHING
ON CONFLICT (client_upload_id) WHERE client_upload_id IS NOT NULL AND deleted_at IS NULL DO NOTHING
RETURNING *",
)
.bind(event_id)
@@ -229,6 +238,18 @@ impl Upload {
.await
}
/// Read the lifetime derivative-attempt counter WITHOUT charging it.
///
/// Used by the in-request retries after the first: those re-enter `do_process` but must not
/// spend the lifetime budget again (see `charge_lifetime_attempt`). `None` still means the
/// row vanished, so the caller's "nothing to do" branch keeps working unchanged.
pub async fn derivative_attempts(pool: &PgPool, id: Uuid) -> Result<Option<i16>, sqlx::Error> {
sqlx::query_scalar("SELECT derivative_attempts FROM upload WHERE id = $1")
.bind(id)
.fetch_optional(pool)
.await
}
/// Store why the last derivative attempt failed. Diagnostics only — nothing branches on it.
pub async fn record_derivative_failure(
pool: &PgPool,

View File

@@ -47,19 +47,41 @@ impl User {
event_id: Uuid,
display_name: &str,
pin_hash: &str,
client_join_id: Option<Uuid>,
) -> Result<Self, sqlx::Error> {
sqlx::query_as::<_, Self>(
"INSERT INTO \"user\" (event_id, display_name, recovery_pin_hash)
VALUES ($1, $2, $3)
"INSERT INTO \"user\" (event_id, display_name, recovery_pin_hash, client_join_id)
VALUES ($1, $2, $3, $4)
RETURNING *",
)
.bind(event_id)
.bind(display_name)
.bind(pin_hash)
.bind(client_join_id)
.fetch_one(pool)
.await
}
/// Look up a join that already succeeded, by the idempotency key its client sent.
///
/// The retry path for H16: the account was created but the response never arrived, so the
/// client re-sends the same `client_join_id`. Finding a row here means "this join already
/// happened" — the caller rotates the PIN and answers with a usable one rather than 409ing
/// on a name the caller itself owns.
pub async fn find_by_client_join_id(
pool: &PgPool,
event_id: Uuid,
client_join_id: Uuid,
) -> Result<Option<Self>, sqlx::Error> {
sqlx::query_as::<_, Self>(
"SELECT * FROM \"user\" WHERE event_id = $1 AND client_join_id = $2",
)
.bind(event_id)
.bind(client_join_id)
.fetch_optional(pool)
.await
}
/// Create a user with an explicit role, in ONE statement.
///
/// `create` + a separate `UPDATE ... SET role` is not equivalent: a crash or a pool error

View File

@@ -0,0 +1,62 @@
//! Append-only record of privileged actions. See migration 029 for why it exists.
//!
//! Design constraints, both learned from the rest of this codebase:
//!
//! * **Never fail the action.** An audit write that can turn a successful ban into a 500 makes
//! moderation less reliable than no audit at all. Every failure here is logged and swallowed.
//! * **Never store a credential.** `reset_pin` is the action most worth recording and the one
//! whose payload must never be in `detail` — a table that could hand back a guest's PIN would
//! be a worse privacy problem than the gap it closes.
use serde_json::Value;
use sqlx::PgPool;
use uuid::Uuid;
use crate::models::user::UserRole;
/// Record one privileged action.
///
/// Takes `&PgPool` rather than a transaction on purpose: the audit row is not part of the action's
/// atomicity. If the action commits and the audit write fails we want the action to stand (and a
/// loud log line); if the action rolls back, an orphan audit row saying "someone tried" is more
/// useful than silence.
#[allow(clippy::too_many_arguments)]
pub async fn record(
pool: &PgPool,
event_id: Uuid,
actor_id: Uuid,
actor_name: Option<&str>,
actor_role: UserRole,
action: &str,
target_id: Option<Uuid>,
target_name: Option<&str>,
detail: Option<Value>,
) {
let result = sqlx::query(
"INSERT INTO host_action_audit
(event_id, actor_id, actor_name, actor_role, action, target_id, target_name, detail)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
)
.bind(event_id)
.bind(actor_id)
.bind(actor_name)
.bind(format!("{actor_role:?}").to_lowercase())
.bind(action)
.bind(target_id)
.bind(target_name)
.bind(detail)
.execute(pool)
.await;
match result {
Ok(_) => {}
Err(e) => {
// `error`, not `warn`: losing an audit row is the kind of thing that should show up in
// whatever is watching the logs, even though it must not fail the request.
tracing::error!(
error = ?e, action, %actor_id, ?target_id,
"failed to write host action audit row"
);
}
}
}

View File

@@ -1,3 +1,4 @@
pub mod audit;
pub mod compression;
pub mod config;
pub mod disk;