Skip to content

Commit ba0a75b

Browse files
authored
Merge pull request #265 from Rustmail/241-external-api-endpoints
feat(api): add external api endpoints for interact with rustmail via external system
2 parents dc982b6 + 74680d0 commit ba0a75b

33 files changed

Lines changed: 1611 additions & 451 deletions

File tree

Cargo.lock

Lines changed: 400 additions & 447 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
-- Add migration script here
2+
CREATE TABLE IF NOT EXISTS api_keys (
3+
id INTEGER PRIMARY KEY AUTOINCREMENT,
4+
key_hash TEXT NOT NULL UNIQUE,
5+
name TEXT NOT NULL,
6+
permissions TEXT NOT NULL,
7+
created_at INTEGER NOT NULL,
8+
expires_at INTEGER,
9+
last_used_at INTEGER,
10+
is_active INTEGER NOT NULL DEFAULT 1
11+
);
12+
13+
-- Index for faster lookups by hash (used on every API request)
14+
CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash);
15+
16+
-- Index for active keys only
17+
CREATE INDEX IF NOT EXISTS idx_api_keys_active ON api_keys(is_active);

rustmail/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ serde_json = "1.0.145"
2525
rand = "0.9.2"
2626
base64 = "0.22.1"
2727
subtle = "2.6.1"
28+
prefixed-api-key = "0.3.0"
29+
sha2 = "0.10.8"
30+
hex = "0.4.3"
2831
moka = { version = "0.12", features = ["future"] }
2932

3033
[dependencies.uuid]
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
use crate::db::operations::{create_api_key, generate_api_key};
2+
use crate::db::repr::Permission;
3+
use crate::prelude::types::*;
4+
use axum::Json;
5+
use axum::extract::State;
6+
use axum::http::StatusCode;
7+
use serde::{Deserialize, Serialize};
8+
use std::sync::Arc;
9+
use tokio::sync::Mutex;
10+
11+
#[derive(Deserialize)]
12+
pub struct CreateApiKeyRequest {
13+
pub name: String,
14+
pub permissions: Vec<Permission>,
15+
pub expires_at: Option<i64>,
16+
}
17+
18+
#[derive(Serialize)]
19+
pub struct CreateApiKeyResponse {
20+
pub api_key: String,
21+
pub id: i64,
22+
pub name: String,
23+
pub permissions: Vec<Permission>,
24+
pub created_at: i64,
25+
pub expires_at: Option<i64>,
26+
}
27+
28+
pub async fn create_api_key_handler(
29+
State(bot_state): State<Arc<Mutex<BotState>>>,
30+
Json(req): Json<CreateApiKeyRequest>,
31+
) -> Result<Json<CreateApiKeyResponse>, (StatusCode, String)> {
32+
if req.name.trim().is_empty() {
33+
return Err((StatusCode::BAD_REQUEST, "Name cannot be empty".to_string()));
34+
}
35+
36+
if req.permissions.is_empty() {
37+
return Err((
38+
StatusCode::BAD_REQUEST,
39+
"At least one permission is required".to_string(),
40+
));
41+
}
42+
43+
let db_pool = {
44+
let state_lock = bot_state.lock().await;
45+
match &state_lock.db_pool {
46+
Some(pool) => pool.clone(),
47+
None => {
48+
return Err((
49+
StatusCode::INTERNAL_SERVER_ERROR,
50+
"Database not initialized".to_string(),
51+
));
52+
}
53+
}
54+
};
55+
56+
let (plain_key, key_hash) = match generate_api_key() {
57+
Ok(keys) => keys,
58+
Err(e) => return Err((StatusCode::INTERNAL_SERVER_ERROR, e)),
59+
};
60+
61+
let api_key = match create_api_key(
62+
&db_pool,
63+
key_hash,
64+
req.name,
65+
req.permissions,
66+
req.expires_at,
67+
)
68+
.await
69+
{
70+
Ok(key) => key,
71+
Err(e) => return Err((StatusCode::INTERNAL_SERVER_ERROR, e)),
72+
};
73+
74+
Ok(Json(CreateApiKeyResponse {
75+
api_key: plain_key,
76+
id: api_key.id,
77+
name: api_key.name,
78+
permissions: api_key.permissions,
79+
created_at: api_key.created_at,
80+
expires_at: api_key.expires_at,
81+
}))
82+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
use crate::db::operations::delete_api_key;
2+
use crate::prelude::types::*;
3+
use axum::extract::{Path, State};
4+
use axum::http::StatusCode;
5+
use std::sync::Arc;
6+
use tokio::sync::Mutex;
7+
8+
pub async fn delete_api_key_handler(
9+
State(bot_state): State<Arc<Mutex<BotState>>>,
10+
Path(id): Path<i64>,
11+
) -> Result<StatusCode, (StatusCode, String)> {
12+
let db_pool = {
13+
let state_lock = bot_state.lock().await;
14+
match &state_lock.db_pool {
15+
Some(pool) => pool.clone(),
16+
None => {
17+
return Err((
18+
StatusCode::INTERNAL_SERVER_ERROR,
19+
"Database not initialized".to_string(),
20+
))
21+
}
22+
}
23+
};
24+
25+
match delete_api_key(&db_pool, id).await {
26+
Ok(_) => Ok(StatusCode::NO_CONTENT),
27+
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e)),
28+
}
29+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
use crate::db::operations::list_api_keys;
2+
use crate::db::repr::{ApiKey, Permission};
3+
use crate::prelude::types::*;
4+
use axum::Json;
5+
use axum::extract::State;
6+
use axum::http::StatusCode;
7+
use serde::Serialize;
8+
use std::sync::Arc;
9+
use tokio::sync::Mutex;
10+
11+
#[derive(Serialize)]
12+
pub struct ApiKeyListItem {
13+
pub id: i64,
14+
pub name: String,
15+
pub permissions: Vec<Permission>,
16+
pub created_at: i64,
17+
pub expires_at: Option<i64>,
18+
pub last_used_at: Option<i64>,
19+
pub is_active: bool,
20+
pub key_preview: String,
21+
}
22+
23+
impl From<ApiKey> for ApiKeyListItem {
24+
fn from(key: ApiKey) -> Self {
25+
let key_preview = if key.key_hash.len() > 12 {
26+
format!("{}...", &key.key_hash[..12])
27+
} else {
28+
key.key_hash.clone()
29+
};
30+
31+
ApiKeyListItem {
32+
id: key.id,
33+
name: key.name,
34+
permissions: key.permissions,
35+
created_at: key.created_at,
36+
expires_at: key.expires_at,
37+
last_used_at: key.last_used_at,
38+
is_active: key.is_active,
39+
key_preview,
40+
}
41+
}
42+
}
43+
44+
pub async fn list_api_keys_handler(
45+
State(bot_state): State<Arc<Mutex<BotState>>>,
46+
) -> Result<Json<Vec<ApiKeyListItem>>, (StatusCode, String)> {
47+
let db_pool = {
48+
let state_lock = bot_state.lock().await;
49+
match &state_lock.db_pool {
50+
Some(pool) => pool.clone(),
51+
None => {
52+
return Err((
53+
StatusCode::INTERNAL_SERVER_ERROR,
54+
"Database not initialized".to_string(),
55+
));
56+
}
57+
}
58+
};
59+
60+
let api_keys = match list_api_keys(&db_pool).await {
61+
Ok(keys) => keys,
62+
Err(e) => return Err((StatusCode::INTERNAL_SERVER_ERROR, e)),
63+
};
64+
65+
let response: Vec<ApiKeyListItem> = api_keys.into_iter().map(|k| k.into()).collect();
66+
67+
Ok(Json(response))
68+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
mod create;
2+
mod delete;
3+
mod list;
4+
mod revoke;
5+
6+
pub use create::*;
7+
pub use delete::*;
8+
pub use list::*;
9+
pub use revoke::*;
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
use crate::db::operations::revoke_api_key;
2+
use crate::prelude::types::*;
3+
use axum::extract::{Path, State};
4+
use axum::http::StatusCode;
5+
use std::sync::Arc;
6+
use tokio::sync::Mutex;
7+
8+
pub async fn revoke_api_key_handler(
9+
State(bot_state): State<Arc<Mutex<BotState>>>,
10+
Path(id): Path<i64>,
11+
) -> Result<StatusCode, (StatusCode, String)> {
12+
let db_pool = {
13+
let state_lock = bot_state.lock().await;
14+
match &state_lock.db_pool {
15+
Some(pool) => pool.clone(),
16+
None => {
17+
return Err((
18+
StatusCode::INTERNAL_SERVER_ERROR,
19+
"Database not initialized".to_string(),
20+
))
21+
}
22+
}
23+
};
24+
25+
match revoke_api_key(&db_pool, id).await {
26+
Ok(_) => Ok(StatusCode::NO_CONTENT),
27+
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e)),
28+
}
29+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
pub mod tickets;
2+
3+
pub use tickets::*;
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
use crate::db::repr::{ApiKey, Permission};
2+
use crate::prelude::api::*;
3+
use crate::types::BotState;
4+
use axum::Json;
5+
use axum::extract::{Extension, State};
6+
use axum::http::StatusCode;
7+
use rustmail_types::CreateTicket;
8+
use std::sync::Arc;
9+
use tokio::sync::Mutex;
10+
11+
pub async fn handle_external_ticket_create(
12+
Extension(api_key): Extension<ApiKey>,
13+
State(bot_state): State<Arc<Mutex<BotState>>>,
14+
Json(update): Json<CreateTicket>,
15+
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
16+
check_permission(&api_key, Permission::CreateTicket)
17+
.map_err(|e| (StatusCode::FORBIDDEN, format!("{:?}", e)))?;
18+
19+
let _current_config = {
20+
let state = bot_state.lock().await;
21+
match &state.config {
22+
Some(c) => c.clone(),
23+
None => {
24+
return Err((
25+
StatusCode::INTERNAL_SERVER_ERROR,
26+
"Configuration not loaded".to_string(),
27+
));
28+
}
29+
}
30+
};
31+
32+
println!(
33+
"API Key #{} creating ticket for Discord ID: {:?}",
34+
api_key.id, update.discord_id
35+
);
36+
37+
Ok(Json(serde_json::json!({
38+
"status": "ticket created",
39+
"message": "Ticket creation endpoint - implementation pending"
40+
})))
41+
}

0 commit comments

Comments
 (0)