Skip to content

Commit 7bc2dbe

Browse files
committed
feat(panel): add api section to create and manage api keys
1 parent 9026ee5 commit 7bc2dbe

9 files changed

Lines changed: 741 additions & 1 deletion

File tree

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ config.toml
33
config.toml.backup
44
node_modules
55
.env
6-
db/
6+
/db/
77
.idea
88
.vscode/
99
package-lock.json
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
use crate::db::repr::{ApiKey, Permission};
2+
use chrono::Utc;
3+
use hex;
4+
use rand::Rng;
5+
use sha2::{Digest, Sha256};
6+
use sqlx::{Row, SqlitePool};
7+
8+
const API_KEY_PREFIX: &str = "rustmail";
9+
const API_KEY_LENGTH: usize = 32;
10+
11+
pub fn generate_api_key() -> Result<(String, String), String> {
12+
let mut rng = rand::rng();
13+
let random_bytes: Vec<u8> = (0..API_KEY_LENGTH).map(|_| rng.random::<u8>()).collect();
14+
let random_part = hex::encode(random_bytes);
15+
16+
let plain_key = format!("{}_{}", API_KEY_PREFIX, random_part);
17+
let key_hash = hash_api_key(&plain_key);
18+
19+
Ok((plain_key, key_hash))
20+
}
21+
22+
pub fn hash_api_key(key: &str) -> String {
23+
let mut hasher = Sha256::new();
24+
hasher.update(key.as_bytes());
25+
let result = hasher.finalize();
26+
hex::encode(result)
27+
}
28+
29+
pub fn _validate_api_key(key: &str, stored_hash: &str) -> bool {
30+
let key_hash = hash_api_key(key);
31+
use subtle::ConstantTimeEq;
32+
key_hash
33+
.as_bytes()
34+
.ct_eq(stored_hash.as_bytes())
35+
.unwrap_u8()
36+
== 1
37+
}
38+
39+
pub async fn create_api_key(
40+
pool: &SqlitePool,
41+
key_hash: String,
42+
name: String,
43+
permissions: Vec<Permission>,
44+
expires_at: Option<i64>,
45+
) -> Result<ApiKey, String> {
46+
let created_at = Utc::now().timestamp();
47+
let permissions_json = serde_json::to_string(&permissions)
48+
.map_err(|e| format!("Failed to serialize permissions: {}", e))?;
49+
50+
let result = sqlx::query(
51+
"INSERT INTO api_keys (key_hash, name, permissions, created_at, expires_at, is_active)
52+
VALUES (?, ?, ?, ?, ?, 1)",
53+
)
54+
.bind(&key_hash)
55+
.bind(&name)
56+
.bind(&permissions_json)
57+
.bind(created_at)
58+
.bind(expires_at)
59+
.execute(pool)
60+
.await
61+
.map_err(|e| format!("Failed to insert API key: {}", e))?;
62+
63+
let id = result.last_insert_rowid();
64+
65+
Ok(ApiKey {
66+
id,
67+
key_hash,
68+
name,
69+
permissions,
70+
created_at,
71+
expires_at,
72+
last_used_at: None,
73+
is_active: true,
74+
})
75+
}
76+
77+
pub async fn get_api_key_by_hash(
78+
pool: &SqlitePool,
79+
key_hash: &str,
80+
) -> Result<Option<ApiKey>, String> {
81+
let row = sqlx::query(
82+
"SELECT id, key_hash, name, permissions, created_at, expires_at, last_used_at, is_active
83+
FROM api_keys WHERE key_hash = ? AND is_active = 1",
84+
)
85+
.bind(key_hash)
86+
.fetch_optional(pool)
87+
.await
88+
.map_err(|e| format!("Failed to fetch API key: {}", e))?;
89+
90+
match row {
91+
Some(row) => {
92+
let permissions_json: String = row.get("permissions");
93+
let permissions: Vec<Permission> = serde_json::from_str(&permissions_json)
94+
.map_err(|e| format!("Failed to deserialize permissions: {}", e))?;
95+
96+
Ok(Some(ApiKey {
97+
id: row.get("id"),
98+
key_hash: row.get("key_hash"),
99+
name: row.get("name"),
100+
permissions,
101+
created_at: row.get("created_at"),
102+
expires_at: row.get("expires_at"),
103+
last_used_at: row.get("last_used_at"),
104+
is_active: row.get::<i64, _>("is_active") == 1,
105+
}))
106+
}
107+
None => Ok(None),
108+
}
109+
}
110+
111+
pub async fn list_api_keys(pool: &SqlitePool) -> Result<Vec<ApiKey>, String> {
112+
let rows = sqlx::query(
113+
"SELECT id, key_hash, name, permissions, created_at, expires_at, last_used_at, is_active
114+
FROM api_keys ORDER BY created_at DESC",
115+
)
116+
.fetch_all(pool)
117+
.await
118+
.map_err(|e| format!("Failed to fetch API keys: {}", e))?;
119+
120+
let mut keys = Vec::new();
121+
for row in rows {
122+
let permissions_json: String = row.get("permissions");
123+
let permissions: Vec<Permission> = serde_json::from_str(&permissions_json)
124+
.map_err(|e| format!("Failed to deserialize permissions: {}", e))?;
125+
126+
keys.push(ApiKey {
127+
id: row.get("id"),
128+
key_hash: row.get("key_hash"),
129+
name: row.get("name"),
130+
permissions,
131+
created_at: row.get("created_at"),
132+
expires_at: row.get("expires_at"),
133+
last_used_at: row.get("last_used_at"),
134+
is_active: row.get::<i64, _>("is_active") == 1,
135+
});
136+
}
137+
138+
Ok(keys)
139+
}
140+
141+
pub async fn revoke_api_key(pool: &SqlitePool, id: i64) -> Result<(), String> {
142+
sqlx::query("UPDATE api_keys SET is_active = 0 WHERE id = ?")
143+
.bind(id)
144+
.execute(pool)
145+
.await
146+
.map_err(|e| format!("Failed to revoke API key: {}", e))?;
147+
148+
Ok(())
149+
}
150+
151+
pub async fn delete_api_key(pool: &SqlitePool, id: i64) -> Result<(), String> {
152+
sqlx::query("DELETE FROM api_keys WHERE id = ?")
153+
.bind(id)
154+
.execute(pool)
155+
.await
156+
.map_err(|e| format!("Failed to delete API key: {}", e))?;
157+
158+
Ok(())
159+
}
160+
161+
pub async fn update_last_used(pool: &SqlitePool, id: i64) -> Result<(), String> {
162+
let now = Utc::now().timestamp();
163+
164+
sqlx::query("UPDATE api_keys SET last_used_at = ? WHERE id = ?")
165+
.bind(now)
166+
.bind(id)
167+
.execute(pool)
168+
.await
169+
.map_err(|e| format!("Failed to update last_used_at: {}", e))?;
170+
171+
Ok(())
172+
}
173+
174+
impl ApiKey {
175+
pub fn has_permission(&self, permission: Permission) -> bool {
176+
self.permissions.contains(&permission)
177+
}
178+
179+
pub fn is_expired(&self) -> bool {
180+
if let Some(expires_at) = self.expires_at {
181+
let now = Utc::now().timestamp();
182+
now > expires_at
183+
} else {
184+
false
185+
}
186+
}
187+
188+
pub fn is_valid(&self) -> bool {
189+
self.is_active && !self.is_expired()
190+
}
191+
}

rustmail_panel/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,6 @@ wasm-bindgen = "0.2.100"
2020
urlencoding = "2.1.3"
2121
ammonia = "4.1.2"
2222
pulldown-cmark = "0.13.0"
23+
chrono = { version = "0.4", features = ["serde", "wasmbind"] }
2324
chrono-tz = { version = "0.10", features = ["serde"] }
2425
[build-dependencies]

0 commit comments

Comments
 (0)