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:
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