simple-web-app

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README

commit e09ad3174c4e02ee4d203556cf4b90fc4821acb6
parent deca07de1c8456a7d84102db279ec4c07ba4945b
Author: Silas Brack <silasbrack@gmail.com>
Date:   Tue,  9 Jun 2026 20:07:38 +0200

fix: prevent vote race condition with try-then-catch pattern

Instead of check-then-act (read votes, then create/delete), try the
insert first and handle the unique constraint violation as "already
voted, unvote instead". Also deduplicates story/comment vote handlers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Diffstat:
Msrc/handlers/vote.rs | 113++++++++++++++++++++++++++++++++++---------------------------------------------
1 file changed, 48 insertions(+), 65 deletions(-)

diff --git a/src/handlers/vote.rs b/src/handlers/vote.rs @@ -13,45 +13,38 @@ use crate::state::AppState; use crate::templates::VoteButtonTemplate; use crate::trailbase::{self, TARGET_TYPE_COMMENT, TARGET_TYPE_STORY}; -/// Vote on a story (Datastar endpoint) -pub async fn vote_story( - State(state): State<AppState>, - jar: CookieJar, - Path(story_id): Path<i64>, -) -> Result<Response, AppError> { - let tokens = get_tokens_from_cookies(&jar) - .ok_or_else(|| AppError::Unauthorized("Login required to vote".to_string()))?; - - let client = trailbase::client_with_tokens(&state.trailbase_url, &tokens)?; - - // Check if already voted - let voted = trailbase::get_user_votes(&client, TARGET_TYPE_STORY, &[story_id]) - .await - .map(|v| v.contains(&story_id)) - .unwrap_or(false); - - if voted { - // Unvote - trailbase::unvote(&client, TARGET_TYPE_STORY, story_id).await?; - } else { - // Vote - trailbase::vote(&client, TARGET_TYPE_STORY, story_id).await?; +/// Toggle a vote: try to create, if it fails (unique constraint), delete instead. +/// Returns whether the user now has an active vote. +async fn toggle_vote( + client: &trailbase_client::Client, + target_type: i64, + target_id: i64, +) -> Result<bool, AppError> { + match trailbase::vote(client, target_type, target_id).await { + Ok(()) => Ok(true), + Err(_) => { + // Vote already exists (unique constraint violation) — remove it + trailbase::unvote(client, target_type, target_id).await?; + Ok(false) + } } +} - // Get updated story score - let story = trailbase::get_story(&client, story_id).await?; - let new_score = story.map(|s| s.score).unwrap_or(0); - let new_voted = !voted; - +fn render_vote_button( + target_type: &str, + target_id: i64, + score: i64, + voted: bool, +) -> Result<Response, AppError> { let template = VoteButtonTemplate { - target_type: "story", - target_id: story_id, - score: new_score, - voted: new_voted, + target_type, + target_id, + score, + voted, }; let html = template.render()?; - let selector = format!("#vote-story-{}", story_id); + let selector = format!("#vote-{}-{}", target_type, target_id); Ok(Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, "text/html") @@ -61,6 +54,26 @@ pub async fn vote_story( .unwrap()) } +/// Vote on a story (Datastar endpoint) +pub async fn vote_story( + State(state): State<AppState>, + jar: CookieJar, + Path(story_id): Path<i64>, +) -> Result<Response, AppError> { + let tokens = get_tokens_from_cookies(&jar) + .ok_or_else(|| AppError::Unauthorized("Login required to vote".to_string()))?; + + let client = trailbase::client_with_tokens(&state.trailbase_url, &tokens)?; + let voted = toggle_vote(&client, TARGET_TYPE_STORY, story_id).await?; + + let new_score = trailbase::get_story(&client, story_id) + .await? + .map(|s| s.score) + .unwrap_or(0); + + render_vote_button("story", story_id, new_score, voted) +} + /// Vote on a comment (Datastar endpoint) pub async fn vote_comment( State(state): State<AppState>, @@ -71,22 +84,8 @@ pub async fn vote_comment( .ok_or_else(|| AppError::Unauthorized("Login required to vote".to_string()))?; let client = trailbase::client_with_tokens(&state.trailbase_url, &tokens)?; + let voted = toggle_vote(&client, TARGET_TYPE_COMMENT, comment_id).await?; - // Check if already voted - let voted = trailbase::get_user_votes(&client, TARGET_TYPE_COMMENT, &[comment_id]) - .await - .map(|v| v.contains(&comment_id)) - .unwrap_or(false); - - if voted { - // Unvote - trailbase::unvote(&client, TARGET_TYPE_COMMENT, comment_id).await?; - } else { - // Vote - trailbase::vote(&client, TARGET_TYPE_COMMENT, comment_id).await?; - } - - // Get updated comment score let args = trailbase_client::ListArguments::new() .with_filters(trailbase_client::Filter::new( "id", @@ -99,22 +98,6 @@ pub async fn vote_comment( .await .map_err(trailbase::ClientError::from)?; let new_score = response.records.first().map(|c| c.score).unwrap_or(0); - let new_voted = !voted; - let template = VoteButtonTemplate { - target_type: "comment", - target_id: comment_id, - score: new_score, - voted: new_voted, - }; - - let html = template.render()?; - let selector = format!("#vote-comment-{}", comment_id); - Ok(Response::builder() - .status(StatusCode::OK) - .header(header::CONTENT_TYPE, "text/html") - .header("datastar-selector", &selector) - .header("datastar-mode", "inner") - .body(Body::from(html)) - .unwrap()) + render_vote_button("comment", comment_id, new_score, voted) }