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:
| M | src/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)
}