Skip to content

Commit 8f30187

Browse files
authored
Merge pull request #266 from Rustmail/263-permissions-system-for-panel
feat(panel): permissions system for panel
2 parents ba0a75b + 83348bf commit 8f30187

37 files changed

Lines changed: 2203 additions & 181 deletions

config.example.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ enable_logs = true
99
enable_features = true
1010
logs_channel_id = 14043597305699
1111
features_channel_id = 14069404548593076
12+
panel_super_admin_users = []
13+
panel_super_admin_roles = []
1214

1315
[bot.mode]
1416
type = "dual"
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
CREATE TABLE IF NOT EXISTS panel_permissions (
2+
id INTEGER PRIMARY KEY AUTOINCREMENT,
3+
subject_type TEXT NOT NULL,
4+
subject_id TEXT NOT NULL,
5+
permission TEXT NOT NULL,
6+
granted_by TEXT NOT NULL,
7+
granted_at INTEGER NOT NULL,
8+
UNIQUE(subject_type, subject_id, permission)
9+
);
10+
11+
CREATE INDEX idx_panel_perms_subject ON panel_permissions(subject_type, subject_id);
12+
CREATE INDEX idx_panel_perms_permission ON panel_permissions(permission);
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
use crate::prelude::types::*;
2+
use axum::extract::State;
3+
use axum::http::StatusCode;
4+
use axum::response::IntoResponse;
5+
use axum::Json;
6+
use serde::{Deserialize, Serialize};
7+
use serenity::all::GuildId;
8+
use std::sync::Arc;
9+
use tokio::sync::Mutex;
10+
11+
#[derive(Debug, Serialize, Deserialize)]
12+
pub struct MemberInfo {
13+
pub user_id: String,
14+
pub username: String,
15+
pub discriminator: String,
16+
pub avatar: Option<String>,
17+
pub roles: Vec<String>,
18+
}
19+
20+
pub async fn handle_list_members(
21+
State(bot_state): State<Arc<Mutex<BotState>>>,
22+
) -> impl IntoResponse {
23+
let (guild_id, bot_http) = {
24+
let state_lock = bot_state.lock().await;
25+
let config = match &state_lock.config {
26+
Some(c) => c,
27+
None => return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "Config not loaded"}))).into_response(),
28+
};
29+
let http = match &state_lock.bot_http {
30+
Some(h) => h.clone(),
31+
None => return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "Bot not initialized"}))).into_response(),
32+
};
33+
(config.bot.get_staff_guild_id(), http)
34+
};
35+
36+
let guild_id_obj = GuildId::new(guild_id);
37+
38+
let members = match guild_id_obj.members(bot_http, None, None).await {
39+
Ok(m) => m,
40+
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": format!("Failed to fetch members: {}", e)}))).into_response(),
41+
};
42+
43+
let member_infos: Vec<MemberInfo> = members.iter().map(|m| MemberInfo {
44+
user_id: m.user.id.to_string(),
45+
username: m.user.name.clone(),
46+
discriminator: m.user.discriminator.map(|d| d.to_string()).unwrap_or_else(|| "0".to_string()),
47+
avatar: m.user.avatar.as_ref().map(|a| a.to_string()),
48+
roles: m.roles.iter().map(|r| r.to_string()).collect(),
49+
}).collect();
50+
51+
(StatusCode::OK, Json(member_infos)).into_response()
52+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
mod members;
2+
mod permissions;
3+
mod roles;
4+
5+
pub use members::*;
6+
pub use permissions::*;
7+
pub use roles::*;
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
use crate::prelude::api::*;
2+
use crate::prelude::types::*;
3+
use axum::extract::{Path, State};
4+
use axum::http::StatusCode;
5+
use axum::response::IntoResponse;
6+
use axum::Json;
7+
use axum_extra::extract::CookieJar;
8+
use chrono::Utc;
9+
use rustmail_types::api::panel_permissions::*;
10+
use sqlx::{Row, query};
11+
use std::sync::Arc;
12+
use tokio::sync::Mutex;
13+
14+
pub async fn handle_list_permissions(
15+
State(bot_state): State<Arc<Mutex<BotState>>>,
16+
) -> impl IntoResponse {
17+
let db_pool = {
18+
let state_lock = bot_state.lock().await;
19+
match &state_lock.db_pool {
20+
Some(pool) => pool.clone(),
21+
None => return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "Database not initialized"}))).into_response(),
22+
}
23+
};
24+
25+
let rows = match query("SELECT * FROM panel_permissions ORDER BY granted_at DESC")
26+
.fetch_all(&db_pool)
27+
.await
28+
{
29+
Ok(r) => r,
30+
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": format!("Database error: {}", e)}))).into_response(),
31+
};
32+
33+
let mut permissions = Vec::new();
34+
for row in rows {
35+
if let (Ok(id), Ok(subject_type), Ok(subject_id), Ok(permission), Ok(granted_by), Ok(granted_at)) = (
36+
row.try_get::<i64, _>("id"),
37+
row.try_get::<String, _>("subject_type"),
38+
row.try_get::<String, _>("subject_id"),
39+
row.try_get::<String, _>("permission"),
40+
row.try_get::<String, _>("granted_by"),
41+
row.try_get::<i64, _>("granted_at"),
42+
) {
43+
if let (Some(st), Some(perm)) = (
44+
SubjectType::from_str(&subject_type),
45+
PanelPermission::from_str(&permission),
46+
) {
47+
permissions.push(PanelPermissionEntry {
48+
id,
49+
subject_type: st,
50+
subject_id,
51+
permission: perm,
52+
granted_by,
53+
granted_at,
54+
});
55+
}
56+
}
57+
}
58+
59+
(StatusCode::OK, Json(permissions)).into_response()
60+
}
61+
62+
pub async fn handle_grant_permission(
63+
State(bot_state): State<Arc<Mutex<BotState>>>,
64+
jar: CookieJar,
65+
Json(request): Json<GrantPermissionRequest>,
66+
) -> impl IntoResponse {
67+
let (db_pool, user_id) = {
68+
let state_lock = bot_state.lock().await;
69+
let pool = match &state_lock.db_pool {
70+
Some(p) => p.clone(),
71+
None => return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "Database not initialized"}))).into_response(),
72+
};
73+
74+
let session_cookie = jar.get("session_id");
75+
if session_cookie.is_none() {
76+
return (StatusCode::UNAUTHORIZED, Json(serde_json::json!({"error": "Unauthorized"}))).into_response();
77+
}
78+
79+
let session_id = session_cookie.unwrap().value().to_string();
80+
let uid = get_user_id_from_session(&session_id, &pool).await;
81+
(pool, uid)
82+
};
83+
84+
let subject_type_str = request.subject_type.as_str();
85+
let permission_str = request.permission.as_str();
86+
let now = Utc::now().timestamp();
87+
88+
let result = query(
89+
"INSERT INTO panel_permissions (subject_type, subject_id, permission, granted_by, granted_at)
90+
VALUES (?, ?, ?, ?, ?)
91+
ON CONFLICT(subject_type, subject_id, permission) DO UPDATE SET granted_by = ?, granted_at = ?"
92+
)
93+
.bind(subject_type_str)
94+
.bind(&request.subject_id)
95+
.bind(permission_str)
96+
.bind(&user_id)
97+
.bind(now)
98+
.bind(&user_id)
99+
.bind(now)
100+
.execute(&db_pool)
101+
.await;
102+
103+
match result {
104+
Ok(_) => (StatusCode::OK, Json(serde_json::json!({"success": true}))).into_response(),
105+
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": format!("Database error: {}", e)}))).into_response(),
106+
}
107+
}
108+
109+
pub async fn handle_revoke_permission(
110+
State(bot_state): State<Arc<Mutex<BotState>>>,
111+
Path(permission_id): Path<i64>,
112+
) -> impl IntoResponse {
113+
let db_pool = {
114+
let state_lock = bot_state.lock().await;
115+
match &state_lock.db_pool {
116+
Some(pool) => pool.clone(),
117+
None => return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "Database not initialized"}))).into_response(),
118+
}
119+
};
120+
121+
let result = query("DELETE FROM panel_permissions WHERE id = ?")
122+
.bind(permission_id)
123+
.execute(&db_pool)
124+
.await;
125+
126+
match result {
127+
Ok(_) => (StatusCode::OK, Json(serde_json::json!({"success": true}))).into_response(),
128+
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": format!("Database error: {}", e)}))).into_response(),
129+
}
130+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
use crate::prelude::types::*;
2+
use axum::extract::State;
3+
use axum::http::StatusCode;
4+
use axum::response::IntoResponse;
5+
use axum::Json;
6+
use serde::{Deserialize, Serialize};
7+
use serenity::all::GuildId;
8+
use std::sync::Arc;
9+
use tokio::sync::Mutex;
10+
11+
#[derive(Debug, Serialize, Deserialize)]
12+
pub struct RoleInfo {
13+
pub role_id: String,
14+
pub name: String,
15+
pub color: u32,
16+
pub position: u16,
17+
}
18+
19+
pub async fn handle_list_roles(
20+
State(bot_state): State<Arc<Mutex<BotState>>>,
21+
) -> impl IntoResponse {
22+
let (guild_id, bot_http) = {
23+
let state_lock = bot_state.lock().await;
24+
let config = match &state_lock.config {
25+
Some(c) => c,
26+
None => return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "Config not loaded"}))).into_response(),
27+
};
28+
let http = match &state_lock.bot_http {
29+
Some(h) => h.clone(),
30+
None => return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "Bot not initialized"}))).into_response(),
31+
};
32+
(config.bot.get_staff_guild_id(), http)
33+
};
34+
35+
let guild_id_obj = GuildId::new(guild_id);
36+
37+
let roles = match guild_id_obj.roles(bot_http).await {
38+
Ok(r) => r,
39+
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": format!("Failed to fetch roles: {}", e)}))).into_response(),
40+
};
41+
42+
let mut role_infos: Vec<RoleInfo> = roles.iter().map(|(id, role)| RoleInfo {
43+
role_id: id.to_string(),
44+
name: role.name.clone(),
45+
color: role.colour.0,
46+
position: role.position,
47+
}).collect();
48+
49+
role_infos.sort_by(|a, b| b.position.cmp(&a.position));
50+
51+
(StatusCode::OK, Json(role_infos)).into_response()
52+
}

rustmail/src/api/handler/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
pub mod admin;
12
pub mod apikeys;
23
pub mod auth;
34
pub mod bot;
45
pub mod externals;
56
pub mod panel;
67
pub mod user;
78

9+
pub use admin::*;
810
pub use apikeys::*;
911
pub use auth::*;
1012
pub use bot::*;
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
pub mod avatar;
2+
pub mod permissions;
23

34
pub use avatar::*;
5+
pub use permissions::*;
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
use crate::api::utils::panel_permissions::get_user_panel_permissions;
2+
use crate::prelude::api::*;
3+
use crate::prelude::types::*;
4+
use axum::extract::State;
5+
use axum::http::StatusCode;
6+
use axum::response::IntoResponse;
7+
use axum::Json;
8+
use axum_extra::extract::CookieJar;
9+
use std::sync::Arc;
10+
use tokio::sync::Mutex;
11+
12+
pub async fn handle_get_user_permissions(
13+
State(bot_state): State<Arc<Mutex<BotState>>>,
14+
jar: CookieJar,
15+
) -> impl IntoResponse {
16+
let (db_pool, config, guild_id, bot_http, user_id) = {
17+
let state_lock = bot_state.lock().await;
18+
19+
let pool = match &state_lock.db_pool {
20+
Some(p) => p.clone(),
21+
None => return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "Database not initialized"}))).into_response(),
22+
};
23+
24+
let cfg = match &state_lock.config {
25+
Some(c) => c.clone(),
26+
None => return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "Config not loaded"}))).into_response(),
27+
};
28+
29+
let http = match &state_lock.bot_http {
30+
Some(h) => h.clone(),
31+
None => return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "Bot not initialized"}))).into_response(),
32+
};
33+
34+
let session_cookie = jar.get("session_id");
35+
if session_cookie.is_none() {
36+
return (StatusCode::UNAUTHORIZED, Json(serde_json::json!({"error": "Unauthorized"}))).into_response();
37+
}
38+
39+
let session_id = session_cookie.unwrap().value().to_string();
40+
let uid = get_user_id_from_session(&session_id, &pool).await;
41+
let gid = cfg.bot.get_staff_guild_id();
42+
43+
(pool, cfg, gid, http, uid)
44+
};
45+
46+
let permissions = get_user_panel_permissions(&user_id, &config, guild_id, bot_http, &db_pool).await;
47+
48+
(StatusCode::OK, Json(permissions)).into_response()
49+
}

rustmail/src/api/middleware/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
pub mod auth;
2+
pub mod panel_permission;
23
pub mod permissions;
34

45
pub use auth::*;
6+
pub use panel_permission::*;
57
pub use permissions::*;

0 commit comments

Comments
 (0)