Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
847 changes: 400 additions & 447 deletions Cargo.lock

Large diffs are not rendered by default.

17 changes: 17 additions & 0 deletions migrations/20251120120000_create_api_keys_table.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
-- Add migration script here
CREATE TABLE IF NOT EXISTS api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key_hash TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
permissions TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER,
last_used_at INTEGER,
is_active INTEGER NOT NULL DEFAULT 1
);

-- Index for faster lookups by hash (used on every API request)
CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash);

-- Index for active keys only
CREATE INDEX IF NOT EXISTS idx_api_keys_active ON api_keys(is_active);
3 changes: 3 additions & 0 deletions rustmail/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ serde_json = "1.0.145"
rand = "0.9.2"
base64 = "0.22.1"
subtle = "2.6.1"
prefixed-api-key = "0.3.0"
sha2 = "0.10.8"
hex = "0.4.3"
moka = { version = "0.12", features = ["future"] }

[dependencies.uuid]
Expand Down
82 changes: 82 additions & 0 deletions rustmail/src/api/handler/apikeys/create.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
use crate::db::operations::{create_api_key, generate_api_key};
use crate::db::repr::Permission;
use crate::prelude::types::*;
use axum::Json;
use axum::extract::State;
use axum::http::StatusCode;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::sync::Mutex;

#[derive(Deserialize)]
pub struct CreateApiKeyRequest {
pub name: String,
pub permissions: Vec<Permission>,
pub expires_at: Option<i64>,
}

#[derive(Serialize)]
pub struct CreateApiKeyResponse {
pub api_key: String,
pub id: i64,
pub name: String,
pub permissions: Vec<Permission>,
pub created_at: i64,
pub expires_at: Option<i64>,
}

pub async fn create_api_key_handler(
State(bot_state): State<Arc<Mutex<BotState>>>,
Json(req): Json<CreateApiKeyRequest>,
) -> Result<Json<CreateApiKeyResponse>, (StatusCode, String)> {
if req.name.trim().is_empty() {
return Err((StatusCode::BAD_REQUEST, "Name cannot be empty".to_string()));
}

if req.permissions.is_empty() {
return Err((
StatusCode::BAD_REQUEST,
"At least one permission is required".to_string(),
));
}

let db_pool = {
let state_lock = bot_state.lock().await;
match &state_lock.db_pool {
Some(pool) => pool.clone(),
None => {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
"Database not initialized".to_string(),
));
}
}
};

let (plain_key, key_hash) = match generate_api_key() {
Ok(keys) => keys,
Err(e) => return Err((StatusCode::INTERNAL_SERVER_ERROR, e)),
};

let api_key = match create_api_key(
&db_pool,
key_hash,
req.name,
req.permissions,
req.expires_at,
)
.await
{
Ok(key) => key,
Err(e) => return Err((StatusCode::INTERNAL_SERVER_ERROR, e)),
};

Ok(Json(CreateApiKeyResponse {
api_key: plain_key,
id: api_key.id,
name: api_key.name,
permissions: api_key.permissions,
created_at: api_key.created_at,
expires_at: api_key.expires_at,
}))
}
29 changes: 29 additions & 0 deletions rustmail/src/api/handler/apikeys/delete.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
use crate::db::operations::delete_api_key;
use crate::prelude::types::*;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use std::sync::Arc;
use tokio::sync::Mutex;

pub async fn delete_api_key_handler(
State(bot_state): State<Arc<Mutex<BotState>>>,
Path(id): Path<i64>,
) -> Result<StatusCode, (StatusCode, String)> {
let db_pool = {
let state_lock = bot_state.lock().await;
match &state_lock.db_pool {
Some(pool) => pool.clone(),
None => {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
"Database not initialized".to_string(),
))
}
}
};

match delete_api_key(&db_pool, id).await {
Ok(_) => Ok(StatusCode::NO_CONTENT),
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e)),
}
}
68 changes: 68 additions & 0 deletions rustmail/src/api/handler/apikeys/list.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
use crate::db::operations::list_api_keys;
use crate::db::repr::{ApiKey, Permission};
use crate::prelude::types::*;
use axum::Json;
use axum::extract::State;
use axum::http::StatusCode;
use serde::Serialize;
use std::sync::Arc;
use tokio::sync::Mutex;

#[derive(Serialize)]
pub struct ApiKeyListItem {
pub id: i64,
pub name: String,
pub permissions: Vec<Permission>,
pub created_at: i64,
pub expires_at: Option<i64>,
pub last_used_at: Option<i64>,
pub is_active: bool,
pub key_preview: String,
}

impl From<ApiKey> for ApiKeyListItem {
fn from(key: ApiKey) -> Self {
let key_preview = if key.key_hash.len() > 12 {
format!("{}...", &key.key_hash[..12])
} else {
key.key_hash.clone()
};

ApiKeyListItem {
id: key.id,
name: key.name,
permissions: key.permissions,
created_at: key.created_at,
expires_at: key.expires_at,
last_used_at: key.last_used_at,
is_active: key.is_active,
key_preview,
}
}
}

pub async fn list_api_keys_handler(
State(bot_state): State<Arc<Mutex<BotState>>>,
) -> Result<Json<Vec<ApiKeyListItem>>, (StatusCode, String)> {
let db_pool = {
let state_lock = bot_state.lock().await;
match &state_lock.db_pool {
Some(pool) => pool.clone(),
None => {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
"Database not initialized".to_string(),
));
}
}
};

let api_keys = match list_api_keys(&db_pool).await {
Ok(keys) => keys,
Err(e) => return Err((StatusCode::INTERNAL_SERVER_ERROR, e)),
};

let response: Vec<ApiKeyListItem> = api_keys.into_iter().map(|k| k.into()).collect();

Ok(Json(response))
}
9 changes: 9 additions & 0 deletions rustmail/src/api/handler/apikeys/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
mod create;
mod delete;
mod list;
mod revoke;

pub use create::*;
pub use delete::*;
pub use list::*;
pub use revoke::*;
29 changes: 29 additions & 0 deletions rustmail/src/api/handler/apikeys/revoke.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
use crate::db::operations::revoke_api_key;
use crate::prelude::types::*;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use std::sync::Arc;
use tokio::sync::Mutex;

pub async fn revoke_api_key_handler(
State(bot_state): State<Arc<Mutex<BotState>>>,
Path(id): Path<i64>,
) -> Result<StatusCode, (StatusCode, String)> {
let db_pool = {
let state_lock = bot_state.lock().await;
match &state_lock.db_pool {
Some(pool) => pool.clone(),
None => {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
"Database not initialized".to_string(),
))
}
}
};

match revoke_api_key(&db_pool, id).await {
Ok(_) => Ok(StatusCode::NO_CONTENT),
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e)),
}
}
3 changes: 3 additions & 0 deletions rustmail/src/api/handler/externals/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
pub mod tickets;

pub use tickets::*;
41 changes: 41 additions & 0 deletions rustmail/src/api/handler/externals/tickets/create.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
use crate::db::repr::{ApiKey, Permission};
use crate::prelude::api::*;
use crate::types::BotState;
use axum::Json;
use axum::extract::{Extension, State};
use axum::http::StatusCode;
use rustmail_types::CreateTicket;
use std::sync::Arc;
use tokio::sync::Mutex;

pub async fn handle_external_ticket_create(
Extension(api_key): Extension<ApiKey>,
State(bot_state): State<Arc<Mutex<BotState>>>,
Json(update): Json<CreateTicket>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
check_permission(&api_key, Permission::CreateTicket)
.map_err(|e| (StatusCode::FORBIDDEN, format!("{:?}", e)))?;

let _current_config = {
let state = bot_state.lock().await;
match &state.config {
Some(c) => c.clone(),
None => {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
"Configuration not loaded".to_string(),
));
}
}
};

println!(
"API Key #{} creating ticket for Discord ID: {:?}",
api_key.id, update.discord_id
);

Ok(Json(serde_json::json!({
"status": "ticket created",
"message": "Ticket creation endpoint - implementation pending"
})))
}
3 changes: 3 additions & 0 deletions rustmail/src/api/handler/externals/tickets/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
pub mod create;

pub use create::*;
4 changes: 4 additions & 0 deletions rustmail/src/api/handler/mod.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
pub mod apikeys;
pub mod auth;
pub mod bot;
pub mod externals;
pub mod panel;
pub mod user;

pub use apikeys::*;
pub use auth::*;
pub use bot::*;
pub use externals::*;
pub use panel::*;
pub use user::*;
41 changes: 40 additions & 1 deletion rustmail/src/api/middleware/auth.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use crate::prelude::api::*;
use crate::prelude::types::*;
use crate::prelude::db::*;
use axum::extract::State;
use axum::extract::Request;
use axum::middleware::Next;
Expand Down Expand Up @@ -83,7 +84,7 @@ async fn verify_user(user_id: &str, guild_id: u64, bot_state: Arc<Mutex<BotState
pub async fn auth_middleware(
State(bot_state): State<Arc<Mutex<BotState>>>,
jar: CookieJar,
req: Request,
mut req: Request,
next: Next,
) -> Response {
let session_cookie = jar.get("session_id");
Expand All @@ -106,6 +107,44 @@ pub async fn auth_middleware(
}
};

if let Some(api_key_header) = req.headers().get("x-api-key") {
if let Ok(api_key_str) = api_key_header.to_str() {
let key_hash = hash_api_key(api_key_str);

match get_api_key_by_hash(&db_pool, &key_hash).await {
Ok(Some(api_key)) => {
if api_key.is_valid() {
let pool_clone = db_pool.clone();
let key_id = api_key.id;
tokio::spawn(async move {
let _ = update_last_used(&pool_clone, key_id).await;
});

req.extensions_mut().insert(api_key);
return next.run(req).await;
} else {
return (StatusCode::UNAUTHORIZED, "API key expired or inactive")
.into_response();
}
}
Ok(None) => {
return (StatusCode::UNAUTHORIZED, "Invalid API key").into_response();
}
Err(e) => {
eprintln!("Error fetching API key: {}", e);
return (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error")
.into_response();
}
}
}
}

let session_cookie = jar.get("session_id");

if session_cookie.is_none() {
return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response();
}

let session_id = session_cookie.unwrap().value().to_string();
let user_id = get_user_id_from_session(&session_id, &db_pool).await;

Expand Down
2 changes: 2 additions & 0 deletions rustmail/src/api/middleware/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
pub mod auth;
pub mod permissions;

pub use auth::*;
pub use permissions::*;
Loading