diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index eca88bf..7a80d51 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -68,6 +68,17 @@ jobs: backend-rs/target key: cargo-${{ hashFiles('backend-rs/Cargo.lock') }} + - name: Fetch common-passwords list + run: | + mkdir -p assets + curl -fsSL -o assets/common-passwords.txt.gz \ + "https://raw.githubusercontent.com/django/django/${DJANGO_PASSWORDS_TAG}/django/contrib/auth/common-passwords.txt.gz" + echo "${DJANGO_PASSWORDS_SHA256} assets/common-passwords.txt.gz" | sha256sum -c - + working-directory: backend-rs + env: + DJANGO_PASSWORDS_TAG: "5.2" + DJANGO_PASSWORDS_SHA256: "3c1baed62596de36860824eb3f436d5932d37ca8b06e59df78f5a44ec175afe4" + - name: Format Check run: cargo fmt --check working-directory: backend-rs diff --git a/backend-rs/src/error.rs b/backend-rs/src/error.rs index a4fe064..046a39e 100644 --- a/backend-rs/src/error.rs +++ b/backend-rs/src/error.rs @@ -89,3 +89,10 @@ impl IntoResponse for ApiError { } pub type ApiResult = Result; + +/// True if a `DbErr` is a SQLite UNIQUE-constraint violation. SeaORM surfaces the +/// underlying sqlx message verbatim; matching on it lets handlers turn a racing +/// duplicate insert into a clean 400 instead of a generic 500. +pub fn is_unique_violation(e: &sea_orm::DbErr) -> bool { + e.to_string().contains("UNIQUE constraint failed") +} diff --git a/backend-rs/src/mentions.rs b/backend-rs/src/mentions.rs index 83c019a..ef21c40 100644 --- a/backend-rs/src/mentions.rs +++ b/backend-rs/src/mentions.rs @@ -5,7 +5,7 @@ use sea_orm::sea_query::{Expr, Func}; use sea_orm::{ ActiveValue::{NotSet, Set}, - ColumnTrait, Condition, DatabaseConnection, EntityTrait, QueryFilter, + ColumnTrait, Condition, ConnectionTrait, EntityTrait, QueryFilter, }; use crate::entities::prelude::*; @@ -47,8 +47,8 @@ fn eq_or_null(col: notification::Column, value: Option) -> Condition { } } -pub async fn parse_mentions( - db: &DatabaseConnection, +pub async fn parse_mentions( + db: &C, text: &str, actor_id: i32, rating_id: Option, diff --git a/backend-rs/src/routes/ratings.rs b/backend-rs/src/routes/ratings.rs index 3dd9132..a82b99c 100644 --- a/backend-rs/src/routes/ratings.rs +++ b/backend-rs/src/routes/ratings.rs @@ -196,7 +196,13 @@ async fn create( } let now = crate::datetime::now_micros(); - let model = rating::ActiveModel { + // Insert + mention fan-out in one transaction so a parse_mentions failure + // can't leave a rating with no notifications. The DB's UNIQUE(user_id, + // flavor_id) index is the real guard against a racing double-rate (the + // pre-check above is best-effort and not atomic): map that violation to the + // same clean validation error instead of a 500. + let txn = state.db.begin().await?; + let model = match (rating::ActiveModel { id: NotSet, user_id: Set(uid), flavor_id: Set(flavor_id), @@ -204,12 +210,22 @@ async fn create( comment: Set(comment.clone()), created_at: Set(now), } - .insert(&state.db) - .await?; + .insert(&txn) + .await) + { + Ok(m) => m, + Err(e) if crate::error::is_unique_violation(&e) => { + return Err(ApiError::Validation(json!([ + "You have already rated this flavor." + ]))); + } + Err(e) => return Err(e.into()), + }; if let Some(text) = comment.as_deref().filter(|s| !s.is_empty()) { - parse_mentions(&state.db, text, uid, Some(model.id), None).await?; + parse_mentions(&txn, text, uid, Some(model.id), None).await?; } + txn.commit().await?; let mut dtos = build_ratings(&state, &ctx, vec![model]).await?; let out = dtos.pop().ok_or(ApiError::Internal)?; @@ -430,6 +446,9 @@ async fn reply( } let now = crate::datetime::now_micros(); + // Reply + owner-notification + mention fan-out are one unit: a failure in any + // step must not persist a partial reply (orphaned reply, missing notification). + let txn = state.db.begin().await?; let reply_model = reply::ActiveModel { id: NotSet, user_id: Set(uid), @@ -437,7 +456,7 @@ async fn reply( text: Set(text.to_string()), created_at: Set(now), } - .insert(&state.db) + .insert(&txn) .await?; if rating.user_id != uid { @@ -453,11 +472,12 @@ async fn reply( notification_type: Set("reply".to_string()), profile_comment_id: Set(None), } - .insert(&state.db) + .insert(&txn) .await?; } - parse_mentions(&state.db, text, uid, Some(rating.id), Some(reply_model.id)).await?; + parse_mentions(&txn, text, uid, Some(rating.id), Some(reply_model.id)).await?; + txn.commit().await?; let mut dtos = build_replies(&state, vec![reply_model]).await?; let out = dtos.pop().ok_or(ApiError::Internal)?;