simple-web-app

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

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:
Msrc/trailbase.rs | 84+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mtraildepot/config.textproto | 4----
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" }]