simple-web-app

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

commit 9caba3ce794ed4ac9b6d219ca7b7509da7eda9ff
parent 6c2ae8100cd5d380068ebf532a1e4efbb8fbd82f
Author: Silas Brack <silasbrack@gmail.com>
Date:   Sat, 25 Apr 2026 13:17:30 +0200

fix: search wildcards and vote authentication

- Add wildcards to search LIKE query for substring matching
- Extract user_id from JWT token for vote creation
- Pass user_id to TrailBase vote API to satisfy ACL rule

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Diffstat:
MCargo.lock | 1+
MCargo.toml | 1+
Msrc/handlers/vote.rs | 12++++++++++--
Msrc/trailbase.rs | 32+++++++++++++++++++++++++++++---
4 files changed, 41 insertions(+), 5 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -1911,6 +1911,7 @@ dependencies = [ "askama", "axum", "axum-extra", + "base64", "chrono", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml @@ -7,6 +7,7 @@ edition = "2021" axum = "0.8" axum-extra = { version = "0.10", features = ["cookie"] } askama = "0.12" +base64 = "0.22" chrono = { version = "0.4", features = ["serde"] } tokio = { version = "1", features = ["macros", "rt-multi-thread", "net"] } serde = { version = "1", features = ["derive"] } diff --git a/src/handlers/vote.rs b/src/handlers/vote.rs @@ -20,6 +20,10 @@ pub async fn vote_story( let tokens = get_tokens_from_cookies(&jar) .ok_or_else(|| AppError::Unauthorized("Login required to vote".to_string()))?; + // Extract user ID from token + let user_id = trailbase::extract_user_id_from_token(&tokens) + .ok_or_else(|| AppError::Unauthorized("Invalid auth token".to_string()))?; + let client = trailbase::client_with_tokens(&state.trailbase_url, &tokens)?; // Check if already voted @@ -33,7 +37,7 @@ pub async fn vote_story( trailbase::unvote(&client, TARGET_TYPE_STORY, story_id).await?; } else { // Vote - trailbase::vote(&client, TARGET_TYPE_STORY, story_id).await?; + trailbase::vote(&client, &user_id, TARGET_TYPE_STORY, story_id).await?; } // Get updated story score @@ -60,6 +64,10 @@ pub async fn vote_comment( let tokens = get_tokens_from_cookies(&jar) .ok_or_else(|| AppError::Unauthorized("Login required to vote".to_string()))?; + // Extract user ID from token + let user_id = trailbase::extract_user_id_from_token(&tokens) + .ok_or_else(|| AppError::Unauthorized("Invalid auth token".to_string()))?; + let client = trailbase::client_with_tokens(&state.trailbase_url, &tokens)?; // Check if already voted @@ -73,7 +81,7 @@ pub async fn vote_comment( trailbase::unvote(&client, TARGET_TYPE_COMMENT, comment_id).await?; } else { // Vote - trailbase::vote(&client, TARGET_TYPE_COMMENT, comment_id).await?; + trailbase::vote(&client, &user_id, TARGET_TYPE_COMMENT, comment_id).await?; } // Get updated comment score diff --git a/src/trailbase.rs b/src/trailbase.rs @@ -31,6 +31,25 @@ pub struct StoredTokens { pub refresh_token: Option<String>, } +/// Extract user ID from JWT auth token +pub fn extract_user_id_from_token(tokens: &StoredTokens) -> Option<String> { + use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; + + // JWT format: header.payload.signature + let parts: Vec<&str> = tokens.auth_token.split('.').collect(); + if parts.len() != 3 { + return None; + } + + // Decode the payload (second part) + let payload = URL_SAFE_NO_PAD.decode(parts[1]).ok()?; + let payload_str = String::from_utf8(payload).ok()?; + + // Parse as JSON and extract "sub" claim + let json: serde_json::Value = serde_json::from_str(&payload_str).ok()?; + json.get("sub").and_then(|v| v.as_str()).map(|s| s.to_string()) +} + /// Create a new unauthenticated client pub fn new_client(base_url: &str) -> Result<Client, ClientError> { Ok(Client::new(base_url, None)?) @@ -272,12 +291,17 @@ pub async fn calculate_comment_path( /// Create a vote #[derive(Debug, Serialize)] struct CreateVote { + user_id: String, target_type: i64, target_id: i64, } -pub async fn vote(client: &Client, target_type: i64, target_id: i64) -> Result<(), ClientError> { - let vote_data = CreateVote { target_type, target_id }; +pub async fn vote(client: &Client, user_id: &str, target_type: i64, target_id: i64) -> Result<(), ClientError> { + let vote_data = CreateVote { + user_id: user_id.to_string(), + target_type, + target_id, + }; client.records("vote").create(&vote_data).await?; Ok(()) } @@ -483,9 +507,11 @@ pub async fn search_stories( query: &str, limit: usize, ) -> Result<Vec<SearchResult>, ClientError> { + // Add wildcards for substring matching + let pattern = format!("%{}%", query); let args = ListArguments::new() .with_pagination(Pagination::new().with_limit(limit)) - .with_filters(Filter::new("title", CompareOp::Like, query)); + .with_filters(Filter::new("title", CompareOp::Like, &pattern)); let response = client.records("story").list::<Story>(args).await?; Ok(response