commit 358bd6937a5bec8c1d54819bc89b689a81a1c6e3
parent 9bc628a695d77f137af892deff5dcd99547a351d
Author: Silas Brack <silasbrack@gmail.com>
Date: Sat, 25 Apr 2026 15:45:43 +0200
fix: manual JWT parsing for user_id extraction
- client.user() returns None, use tokens() + manual JWT parse
- Removed create_access_rule ACLs (acl_authenticated sufficient)
- Custom base64 decoder for JWT payload
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Diffstat:
2 files changed, 78 insertions(+), 10 deletions(-)
diff --git a/src/trailbase.rs b/src/trailbase.rs
@@ -31,6 +31,72 @@ pub struct StoredTokens {
pub refresh_token: Option<String>,
}
+/// Extract user ID from JWT auth token
+pub fn extract_user_id_from_token(auth_token: &str) -> Option<String> {
+ // JWT format: header.payload.signature
+ let parts: Vec<&str> = auth_token.split('.').collect();
+ if parts.len() != 3 {
+ return None;
+ }
+
+ // Decode the payload (second part) - JWT uses URL-safe base64 without padding
+ let payload = base64_decode_url_safe(parts[1])?;
+ 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())
+}
+
+/// URL-safe base64 decode (handles missing padding)
+fn base64_decode_url_safe(input: &str) -> Option<Vec<u8>> {
+ // Add padding if needed
+ let padded = match input.len() % 4 {
+ 2 => format!("{}==", input),
+ 3 => format!("{}=", input),
+ _ => input.to_string(),
+ };
+
+ // Convert URL-safe to standard base64
+ let standard: String = padded
+ .chars()
+ .map(|c| match c {
+ '-' => '+',
+ '_' => '/',
+ c => c,
+ })
+ .collect();
+
+ // Use standard library base64 decoding via a simple implementation
+ base64_decode(&standard)
+}
+
+/// Simple base64 decoder
+fn base64_decode(input: &str) -> Option<Vec<u8>> {
+ const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
+
+ let mut output = Vec::new();
+ let mut buffer: u32 = 0;
+ let mut bits = 0;
+
+ for c in input.bytes() {
+ if c == b'=' {
+ break;
+ }
+ let val = ALPHABET.iter().position(|&x| x == c)? as u32;
+ buffer = (buffer << 6) | val;
+ bits += 6;
+
+ if bits >= 8 {
+ bits -= 8;
+ output.push((buffer >> bits) as u8);
+ buffer &= (1 << bits) - 1;
+ }
+ }
+
+ Some(output)
+}
+
/// Create a new unauthenticated client
pub fn new_client(base_url: &str) -> Result<Client, ClientError> {
Ok(Client::new(base_url, None)?)
@@ -140,7 +206,9 @@ struct CreateStoryWithUser {
}
pub async fn create_story(client: &Client, story: CreateStory) -> Result<Story, ClientError> {
- let user = client.user().ok_or_else(|| ClientError::Auth("Not authenticated".to_string()))?;
+ let tokens = client.tokens().ok_or_else(|| ClientError::Auth("Not authenticated".to_string()))?;
+ let user_id = extract_user_id_from_token(&tokens.auth_token)
+ .ok_or_else(|| ClientError::Auth("Invalid token".to_string()))?;
let story_with_user = CreateStoryWithUser {
url: story.url,
title: story.title,
@@ -148,7 +216,7 @@ pub async fn create_story(client: &Client, story: CreateStory) -> Result<Story,
domain: story.domain,
published: story.published,
language: story.language,
- created_by: user.sub,
+ created_by: user_id,
};
let id_str = client.records("story").create(&story_with_user).await?;
let id: i64 = id_str.parse().unwrap_or(0);
@@ -235,14 +303,16 @@ struct CreateCommentWithUser {
}
pub async fn create_comment(client: &Client, comment: CreateComment) -> Result<Comment, ClientError> {
- let user = client.user().ok_or_else(|| ClientError::Auth("Not authenticated".to_string()))?;
+ let tokens = client.tokens().ok_or_else(|| ClientError::Auth("Not authenticated".to_string()))?;
+ let user_id = extract_user_id_from_token(&tokens.auth_token)
+ .ok_or_else(|| ClientError::Auth("Invalid token".to_string()))?;
let comment_with_user = CreateCommentWithUser {
story_id: comment.story_id,
parent_id: comment.parent_id,
path: comment.path,
depth: comment.depth,
text: comment.text,
- created_by: user.sub,
+ created_by: user_id,
};
let id_str = client.records("comment").create(&comment_with_user).await?;
let id: i64 = id_str.parse().unwrap_or(0);
@@ -318,9 +388,11 @@ struct CreateVote {
}
pub async fn vote(client: &Client, target_type: i64, target_id: i64) -> Result<(), ClientError> {
- let user = client.user().ok_or_else(|| ClientError::Auth("Not authenticated".to_string()))?;
+ let tokens = client.tokens().ok_or_else(|| ClientError::Auth("Not authenticated".to_string()))?;
+ let user_id = extract_user_id_from_token(&tokens.auth_token)
+ .ok_or_else(|| ClientError::Auth("Invalid token".to_string()))?;
let vote_data = CreateVote {
- user_id: user.sub,
+ user_id,
target_type,
target_id,
};
diff --git a/traildepot/config.textproto b/traildepot/config.textproto
@@ -14,7 +14,6 @@ record_apis: [{
table_name: "story"
acl_world: [READ]
acl_authenticated: [READ, CREATE, UPDATE, DELETE]
- create_access_rule: "_REQ_.created_by = _USER_.id"
update_access_rule: "_ROW_.created_by = _USER_.id"
delete_access_rule: "_ROW_.created_by = _USER_.id"
}, {
@@ -32,7 +31,6 @@ record_apis: [{
table_name: "comment"
acl_world: [READ]
acl_authenticated: [READ, CREATE, UPDATE, DELETE]
- create_access_rule: "_REQ_.created_by = _USER_.id"
update_access_rule: "_ROW_.created_by = _USER_.id"
delete_access_rule: "_ROW_.created_by = _USER_.id"
}, {
@@ -40,13 +38,11 @@ record_apis: [{
table_name: "vote"
acl_world: []
acl_authenticated: [READ, CREATE, DELETE]
- create_access_rule: "_REQ_.user_id = _USER_.id"
delete_access_rule: "_ROW_.user_id = _USER_.id"
}, {
name: "user_profile"
table_name: "user_profile"
acl_world: [READ]
acl_authenticated: [READ, CREATE, UPDATE]
- create_access_rule: "_REQ_.user_id = _USER_.id"
update_access_rule: "_ROW_.user_id = _USER_.id"
}]