Skip to main content

pebble_cms/services/
api_token.rs

1use crate::models::ApiToken;
2use crate::Database;
3use anyhow::Result;
4use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
5use rand::Rng;
6use sha2::{Digest, Sha256};
7
8const TOKEN_PREFIX: &str = "pb_";
9const TOKEN_BYTE_LENGTH: usize = 32;
10
11/// Generate a raw random token string with the `pb_` prefix.
12fn generate_raw_token() -> String {
13    let mut bytes = [0u8; TOKEN_BYTE_LENGTH];
14    rand::thread_rng().fill(&mut bytes);
15    format!("{}{}", TOKEN_PREFIX, URL_SAFE_NO_PAD.encode(bytes))
16}
17
18/// SHA-256 hash a raw token for storage.
19fn hash_token(raw: &str) -> String {
20    let digest = Sha256::digest(raw.as_bytes());
21    hex::encode(digest)
22}
23
24/// Extract the short prefix (first 8 chars after `pb_`) for display.
25fn extract_prefix(raw: &str) -> String {
26    let without_prefix = raw.strip_prefix(TOKEN_PREFIX).unwrap_or(raw);
27    let end = without_prefix.len().min(8);
28    format!("{}{}...", TOKEN_PREFIX, &without_prefix[..end])
29}
30
31/// Create a new API token. Returns the raw token string (shown once) and the stored record.
32pub fn create_token(
33    db: &Database,
34    name: &str,
35    permissions: &str,
36    created_by: Option<i64>,
37    expires_at: Option<&str>,
38) -> Result<(String, ApiToken)> {
39    let raw_token = generate_raw_token();
40    let token_hash = hash_token(&raw_token);
41    let prefix = extract_prefix(&raw_token);
42
43    let conn = db.get()?;
44    conn.execute(
45        "INSERT INTO api_tokens (name, token_hash, prefix, permissions, created_by, expires_at)
46         VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
47        rusqlite::params![name, token_hash, prefix, permissions, created_by, expires_at],
48    )?;
49
50    let id = conn.last_insert_rowid();
51    let token = conn.query_row(
52        "SELECT id, name, prefix, permissions, created_by, last_used_at, expires_at, created_at
53         FROM api_tokens WHERE id = ?",
54        [id],
55        row_to_token,
56    )?;
57
58    Ok((raw_token, token))
59}
60
61/// Validate a raw token string. Returns the token record if valid and not expired.
62pub fn validate_token(db: &Database, raw_token: &str) -> Result<Option<ApiToken>> {
63    if !raw_token.starts_with(TOKEN_PREFIX) {
64        return Ok(None);
65    }
66
67    let token_hash = hash_token(raw_token);
68    let conn = db.get()?;
69
70    let token = conn
71        .query_row(
72            "SELECT id, name, prefix, permissions, created_by, last_used_at, expires_at, created_at
73             FROM api_tokens WHERE token_hash = ?",
74            [&token_hash],
75            row_to_token,
76        )
77        .ok();
78
79    let token = match token {
80        Some(t) => t,
81        None => return Ok(None),
82    };
83
84    // Check expiry
85    if let Some(ref expires) = token.expires_at {
86        let now = chrono::Utc::now().to_rfc3339();
87        if *expires < now {
88            return Ok(None);
89        }
90    }
91
92    // Update last_used_at
93    conn.execute(
94        "UPDATE api_tokens SET last_used_at = CURRENT_TIMESTAMP WHERE id = ?",
95        [token.id],
96    )?;
97
98    Ok(Some(token))
99}
100
101/// List all API tokens (without exposing hashes).
102pub fn list_tokens(db: &Database) -> Result<Vec<ApiToken>> {
103    let conn = db.get()?;
104    let mut stmt = conn.prepare(
105        "SELECT id, name, prefix, permissions, created_by, last_used_at, expires_at, created_at
106         FROM api_tokens ORDER BY created_at DESC",
107    )?;
108
109    let tokens = stmt
110        .query_map([], row_to_token)?
111        .filter_map(|r| r.ok())
112        .collect();
113
114    Ok(tokens)
115}
116
117/// Revoke (delete) an API token by ID.
118pub fn revoke_token(db: &Database, id: i64) -> Result<()> {
119    let conn = db.get()?;
120    conn.execute("DELETE FROM api_tokens WHERE id = ?", [id])?;
121    Ok(())
122}
123
124fn row_to_token(row: &rusqlite::Row<'_>) -> rusqlite::Result<ApiToken> {
125    Ok(ApiToken {
126        id: row.get(0)?,
127        name: row.get(1)?,
128        prefix: row.get(2)?,
129        permissions: row.get(3)?,
130        created_by: row.get(4)?,
131        last_used_at: row.get(5)?,
132        expires_at: row.get(6)?,
133        created_at: row.get(7)?,
134    })
135}