diff --git a/migrations/2025-08-04-195527_add_audit_log/down.sql b/migrations/2025-08-04-195527_add_audit_log/down.sql new file mode 100644 index 0000000..f7c5a37 --- /dev/null +++ b/migrations/2025-08-04-195527_add_audit_log/down.sql @@ -0,0 +1,2 @@ +-- This file should undo anything in `up.sql` +DROP TABLE audit_logs; diff --git a/migrations/2025-08-04-195527_add_audit_log/up.sql b/migrations/2025-08-04-195527_add_audit_log/up.sql new file mode 100644 index 0000000..dbd923d --- /dev/null +++ b/migrations/2025-08-04-195527_add_audit_log/up.sql @@ -0,0 +1,14 @@ +-- Your SQL goes here +CREATE TABLE audit_logs ( + uuid UUID PRIMARY KEY NOT NULL, + guild_uuid UUID NOT NULL, + action_id INT2 NOT NULL, + by_uuid UUID NOT NULL REFERENCES guild_members(uuid), + channel_uuid UUID REFERENCES channels(uuid) DEFAULT NULL, + user_uuid UUID REFERENCES users(uuid) DEFAULT NULL, + message_uuid UUID REFERENCES messages(uuid) DEFAULT NULL, + role_uuid UUID REFERENCES roles(uuid) DEFAULT NULL, + audit_message VARCHAR(200) DEFAULT NULL, + changed_from VARCHAR(200) DEFAULT NULL, + changed_to VARCHAR(200) DEFAULT NULL +); diff --git a/src/api/v1/channels/uuid/mod.rs b/src/api/v1/channels/uuid/mod.rs index f5566b3..5b84de3 100644 --- a/src/api/v1/channels/uuid/mod.rs +++ b/src/api/v1/channels/uuid/mod.rs @@ -6,11 +6,7 @@ pub mod socket; use std::sync::Arc; use crate::{ - AppState, - api::v1::auth::CurrentUser, - error::Error, - objects::{Channel, Member, Permissions}, - utils::global_checks, + api::v1::auth::CurrentUser, error::Error, objects::{AuditLog, AuditLogId, Channel, Member, Permissions}, utils::global_checks, AppState }; use axum::{ Extension, Json, @@ -55,7 +51,9 @@ pub async fn delete( .check_permission(&mut conn, &app_state.cache_pool, Permissions::ManageChannel) .await?; + let log_entry = AuditLog::new(channel.guild_uuid, AuditLogId::ChannelDelete as i16, member.uuid, None, None, None, None, Some(channel.name.clone()), None, None).await; channel.delete(&mut conn, &app_state.cache_pool).await?; + log_entry.push(&mut conn).await?; Ok(StatusCode::OK) } @@ -117,12 +115,15 @@ pub async fn patch( .await?; if let Some(new_name) = &new_info.name { + let log_entry = AuditLog::new(channel.guild_uuid, AuditLogId::ChannelUpdateName as i16, member.uuid, Some(channel_uuid), None, None, None, None, Some(channel.name.clone()), Some(new_name.clone())).await; channel .set_name(&mut conn, &app_state.cache_pool, new_name.to_string()) .await?; + log_entry.push(&mut conn).await?; } if let Some(new_description) = &new_info.description { + let log_entry = AuditLog::new(channel.guild_uuid, AuditLogId::ChannelUpdateDescripiton as i16, member.uuid, Some(channel_uuid), None, None, None, None, Some(channel.description.clone().unwrap_or("".to_string())), Some(new_description.clone())).await; channel .set_description( &mut conn, @@ -130,6 +131,7 @@ pub async fn patch( new_description.to_string(), ) .await?; + log_entry.push(&mut conn).await?; } if let Some(new_is_above) = &new_info.is_above { diff --git a/src/api/v1/guilds/uuid/auditlogs.rs b/src/api/v1/guilds/uuid/auditlogs.rs new file mode 100644 index 0000000..329e0c5 --- /dev/null +++ b/src/api/v1/guilds/uuid/auditlogs.rs @@ -0,0 +1,37 @@ +use std::sync::Arc; + +use ::uuid::Uuid; +use axum::{ + Extension, Json, + extract::{Path, Query, State}, + http::StatusCode, + response::IntoResponse, +}; + +use crate::{ + AppState, + api::v1::auth::CurrentUser, + error::Error, + objects::{AuditLog, Member, PaginationRequest, Permissions}, + utils::global_checks, +}; + +pub async fn get( + State(app_state): State>, + Path(guild_uuid): Path, + Query(pagination): Query, + Extension(CurrentUser(uuid)): Extension>, +) -> Result { + let mut conn = app_state.pool.get().await?; + + global_checks(&mut conn, &app_state.config, uuid).await?; + + let caller = Member::check_membership(&mut conn, uuid, guild_uuid).await?; + caller + .check_permission(&mut conn, &app_state.cache_pool, Permissions::ManageGuild) + .await?; + + let logs = AuditLog::fetch_page(&mut conn, guild_uuid, pagination).await?; + + Ok((StatusCode::OK, Json(logs))) +} diff --git a/src/api/v1/guilds/uuid/bans.rs b/src/api/v1/guilds/uuid/bans.rs index 2e31a59..318d092 100644 --- a/src/api/v1/guilds/uuid/bans.rs +++ b/src/api/v1/guilds/uuid/bans.rs @@ -9,11 +9,7 @@ use axum::{ use uuid::Uuid; use crate::{ - AppState, - api::v1::auth::CurrentUser, - error::Error, - objects::{GuildBan, Member, Permissions}, - utils::global_checks, + api::v1::auth::CurrentUser, error::Error, objects::{AuditLog, AuditLogId, GuildBan, Member, Permissions}, utils::global_checks, AppState }; pub async fn get( @@ -51,7 +47,9 @@ pub async fn unban( let ban = GuildBan::fetch_one(&mut conn, guild_uuid, user_uuid).await?; + let log_entry = AuditLog::new(guild_uuid, AuditLogId::MemberUnban as i16, caller.uuid, None, Some(ban.user_uuid), None, None, None, None, None).await; ban.unban(&mut conn).await?; + log_entry.push(&mut conn).await?; Ok(StatusCode::OK) } diff --git a/src/api/v1/guilds/uuid/channels.rs b/src/api/v1/guilds/uuid/channels.rs index 1cd7f78..ffb8f7e 100644 --- a/src/api/v1/guilds/uuid/channels.rs +++ b/src/api/v1/guilds/uuid/channels.rs @@ -10,11 +10,7 @@ use axum::{ use serde::Deserialize; use crate::{ - AppState, - api::v1::auth::CurrentUser, - error::Error, - objects::{Channel, Member, Permissions}, - utils::{CacheFns, global_checks, order_by_is_above}, + api::v1::auth::CurrentUser, error::Error, objects::{AuditLog, AuditLogId, Channel, Member, Permissions}, utils::{global_checks, order_by_is_above, CacheFns}, AppState }; #[derive(Deserialize)] @@ -83,5 +79,7 @@ pub async fn create( ) .await?; + AuditLog::new(guild_uuid, AuditLogId::ChannelCreate as i16, member.uuid, Some(channel.uuid), None, None, None, Some(channel.name.clone()), None, None).await.push(&mut conn).await?; + Ok((StatusCode::OK, Json(channel))) } diff --git a/src/api/v1/guilds/uuid/invites/mod.rs b/src/api/v1/guilds/uuid/invites/mod.rs index fa06f44..d5dda8a 100644 --- a/src/api/v1/guilds/uuid/invites/mod.rs +++ b/src/api/v1/guilds/uuid/invites/mod.rs @@ -10,11 +10,7 @@ use serde::Deserialize; use uuid::Uuid; use crate::{ - AppState, - api::v1::auth::CurrentUser, - error::Error, - objects::{Guild, Member, Permissions}, - utils::global_checks, + api::v1::auth::CurrentUser, error::Error, objects::{AuditLog, AuditLogId, Guild, Member, Permissions}, utils::global_checks, AppState }; #[derive(Deserialize)] @@ -62,5 +58,7 @@ pub async fn create( .create_invite(&mut conn, uuid, invite_request.custom_id.clone()) .await?; + AuditLog::new(guild_uuid, AuditLogId::InviteCreate as i16, member.uuid, None, None, None, None, Some(invite.id.clone()), None, None).await.push(&mut conn).await?; + Ok((StatusCode::OK, Json(invite))) } diff --git a/src/api/v1/guilds/uuid/mod.rs b/src/api/v1/guilds/uuid/mod.rs index 53f469b..6e57704 100644 --- a/src/api/v1/guilds/uuid/mod.rs +++ b/src/api/v1/guilds/uuid/mod.rs @@ -12,6 +12,7 @@ use axum::{ use bytes::Bytes; use uuid::Uuid; +mod auditlogs; mod bans; mod channels; mod invites; @@ -46,6 +47,8 @@ pub fn router() -> Router> { // Bans .route("/bans", get(bans::get)) .route("/bans/{uuid}", delete(bans::unban)) + // Audit Logs + .route("/auditlogs", get(auditlogs::get)) } /// `GET /api/v1/guilds/{uuid}` DESCRIPTION diff --git a/src/api/v1/guilds/uuid/roles/mod.rs b/src/api/v1/guilds/uuid/roles/mod.rs index d3660ce..6b8ac58 100644 --- a/src/api/v1/guilds/uuid/roles/mod.rs +++ b/src/api/v1/guilds/uuid/roles/mod.rs @@ -10,11 +10,7 @@ use axum::{ use serde::Deserialize; use crate::{ - AppState, - api::v1::auth::CurrentUser, - error::Error, - objects::{Member, Permissions, Role}, - utils::{CacheFns, global_checks, order_by_is_above}, + api::v1::auth::CurrentUser, error::Error, objects::{AuditLog, AuditLogId, Member, Permissions, Role}, utils::{global_checks, order_by_is_above, CacheFns}, AppState }; pub mod uuid; @@ -71,7 +67,10 @@ pub async fn create( .check_permission(&mut conn, &app_state.cache_pool, Permissions::ManageRole) .await?; + // TODO: roles permission let role = Role::new(&mut conn, guild_uuid, role_info.name.clone()).await?; + AuditLog::new(guild_uuid, AuditLogId::RoleCreate as i16, member.uuid, None, None, None, Some(role.uuid), Some(role_info.name.clone()) , None, None).await.push(&mut conn).await?; + Ok((StatusCode::OK, Json(role)).into_response()) } diff --git a/src/api/v1/members/uuid/ban.rs b/src/api/v1/members/uuid/ban.rs index e828e69..589969d 100644 --- a/src/api/v1/members/uuid/ban.rs +++ b/src/api/v1/members/uuid/ban.rs @@ -9,11 +9,7 @@ use axum::{ use serde::Deserialize; use crate::{ - AppState, - api::v1::auth::CurrentUser, - error::Error, - objects::{Member, Permissions}, - utils::global_checks, + api::v1::auth::CurrentUser, error::Error, objects::{AuditLog, AuditLogId, Member, Permissions}, utils::global_checks, AppState }; use uuid::Uuid; @@ -42,7 +38,9 @@ pub async fn post( .check_permission(&mut conn, &app_state.cache_pool, Permissions::BanMember) .await?; + let log_entry = AuditLog::new(member.guild_uuid, AuditLogId::MemberBan as i16, caller.uuid, None, Some(member.user_uuid), None, None, Some(payload.reason.clone()), None, None).await; member.ban(&mut conn, &payload.reason).await?; + log_entry.push(&mut conn).await?; Ok(StatusCode::OK) } diff --git a/src/api/v1/members/uuid/mod.rs b/src/api/v1/members/uuid/mod.rs index 5bfd129..715e6e6 100644 --- a/src/api/v1/members/uuid/mod.rs +++ b/src/api/v1/members/uuid/mod.rs @@ -54,9 +54,9 @@ pub async fn delete( Member::fetch_one_with_uuid(&mut conn, &app_state.cache_pool, Some(&me), member_uuid) .await?; - let deleter = Member::check_membership(&mut conn, uuid, member.guild_uuid).await?; + let caller = Member::check_membership(&mut conn, uuid, member.guild_uuid).await?; - deleter + caller .check_permission(&mut conn, &app_state.cache_pool, Permissions::KickMember) .await?; diff --git a/src/objects/auditlog.rs b/src/objects/auditlog.rs new file mode 100644 index 0000000..d749a55 --- /dev/null +++ b/src/objects/auditlog.rs @@ -0,0 +1,138 @@ +use crate::{ + Conn, + error::Error, + objects::{Pagination, PaginationRequest, load_or_empty}, + schema::audit_logs, +}; +use diesel::{ + ExpressionMethods, Insertable, QueryDsl, Queryable, Selectable, SelectableHelper, insert_into, +}; +use diesel_async::RunQueryDsl; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +pub enum AuditLogId { + MessageDelete = 100, + MessageEdit = 101, + + ChannelCreate = 200, + ChannelDelete = 201, + ChannelUpdateName = 202, + ChannelUpdateDescripiton = 203, + + RoleCreate = 300, + RoleDelete = 301, + RoleUpdateName = 302, + RoleUpdatePermission = 303, + InviteCreate = 304, + InviteDelete = 305, + + MemberKick = 400, + MemberBan = 401, + MemberUnban = 402, + MemberUpdateNickname = 403, + MemberUpdateRoles = 404, +} + +#[derive(Insertable, Selectable, Queryable, Serialize, Deserialize, Clone)] +#[diesel(table_name = audit_logs)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct AuditLog { + pub uuid: Uuid, + pub guild_uuid: Uuid, + pub action_id: i16, + pub by_uuid: Uuid, + pub channel_uuid: Option, + pub user_uuid: Option, + pub message_uuid: Option, + pub role_uuid: Option, + pub audit_message: Option, + pub changed_from: Option, + pub changed_to: Option, +} + +impl AuditLog { + pub async fn count(conn: &mut Conn, guild_uuid: Uuid) -> Result { + use audit_logs::dsl; + let count: i64 = dsl::audit_logs + .filter(dsl::guild_uuid.eq(guild_uuid)) + .count() + .get_result(conn) + .await?; + + Ok(count) + } + pub async fn fetch_page( + conn: &mut Conn, + guild_uuid: Uuid, + pagination: PaginationRequest, + ) -> Result, Error> { + // TODO: Maybe add cache, but I do not know how + let per_page = pagination.per_page.unwrap_or(20); + let offset = (pagination.page - 1) * per_page; + + if !(10..=100).contains(&per_page) { + return Err(Error::BadRequest( + "Invalid amount per page requested".to_string(), + )); + } + + use audit_logs::dsl; + let logs: Vec = load_or_empty( + dsl::audit_logs + .filter(dsl::guild_uuid.eq(guild_uuid)) + .limit(per_page.into()) + .offset(offset as i64) + .select(AuditLog::as_select()) + .load(conn) + .await, + )?; + + let pages = (AuditLog::count(conn, guild_uuid).await? as f32 / per_page as f32).ceil(); + + let paginated_logs = Pagination:: { + objects: logs.clone(), + amount: logs.len() as i32, + pages: pages as i32, + page: pagination.page, + }; + + Ok(paginated_logs) + } + + pub async fn new( + guild_uuid: Uuid, + action_id: i16, + by_uuid: Uuid, + channel_uuid: Option, + user_uuid: Option, + message_uuid: Option, + role_uuid: Option, + audit_message: Option, + changed_from: Option, + changed_to: Option, + ) -> AuditLog{ + AuditLog { + uuid: Uuid::now_v7(), + guild_uuid, + action_id, + by_uuid, + channel_uuid, + user_uuid, + message_uuid, + role_uuid, + audit_message, + changed_from, + changed_to, + } + } + + pub async fn push(self, conn: &mut Conn) -> Result<(), Error>{ + insert_into(audit_logs::table) + .values(self.clone()) + .execute(conn) + .await?; + + Ok(()) + } +} diff --git a/src/objects/channel.rs b/src/objects/channel.rs index 03a2cf6..dacc562 100644 --- a/src/objects/channel.rs +++ b/src/objects/channel.rs @@ -52,8 +52,8 @@ impl ChannelBuilder { pub struct Channel { pub uuid: Uuid, pub guild_uuid: Uuid, - name: String, - description: Option, + pub name: String, + pub description: Option, pub is_above: Option, pub permissions: Vec, } diff --git a/src/objects/mod.rs b/src/objects/mod.rs index 5a013ca..29c96b3 100644 --- a/src/objects/mod.rs +++ b/src/objects/mod.rs @@ -7,6 +7,7 @@ use log::debug; use serde::{Deserialize, Serialize}; use uuid::Uuid; +mod auditlog; mod bans; mod channel; mod email_token; @@ -20,6 +21,8 @@ mod password_reset_token; mod role; mod user; +pub use auditlog::AuditLog; +pub use auditlog::AuditLogId; pub use bans::GuildBan; pub use channel::Channel; pub use email_token::EmailToken; diff --git a/src/objects/role.rs b/src/objects/role.rs index cc71fb2..88749a8 100644 --- a/src/objects/role.rs +++ b/src/objects/role.rs @@ -21,9 +21,9 @@ use super::{HasIsAbove, HasUuid, load_or_empty, member::MemberBuilder}; #[diesel(primary_key(uuid))] #[diesel(check_for_backend(diesel::pg::Pg))] pub struct Role { - uuid: Uuid, + pub uuid: Uuid, guild_uuid: Uuid, - name: String, + pub name: String, color: i32, is_above: Option, pub permissions: i64, diff --git a/src/schema.rs b/src/schema.rs index 88f6155..b0e27b8 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -11,6 +11,25 @@ diesel::table! { } } +diesel::table! { + audit_logs (uuid) { + uuid -> Uuid, + guild_uuid -> Uuid, + action_id -> Int2, + by_uuid -> Uuid, + channel_uuid -> Nullable, + user_uuid -> Nullable, + message_uuid -> Nullable, + role_uuid -> Nullable, + #[max_length = 200] + audit_message -> Nullable, + #[max_length = 200] + changed_from -> Nullable, + #[max_length = 200] + changed_to -> Nullable, + } +} + diesel::table! { channel_permissions (channel_uuid, role_uuid) { channel_uuid -> Uuid, @@ -163,6 +182,11 @@ diesel::table! { diesel::joinable!(access_tokens -> refresh_tokens (refresh_token)); diesel::joinable!(access_tokens -> users (uuid)); +diesel::joinable!(audit_logs -> channels (channel_uuid)); +diesel::joinable!(audit_logs -> guild_members (by_uuid)); +diesel::joinable!(audit_logs -> messages (message_uuid)); +diesel::joinable!(audit_logs -> roles (role_uuid)); +diesel::joinable!(audit_logs -> users (user_uuid)); diesel::joinable!(channel_permissions -> channels (channel_uuid)); diesel::joinable!(channel_permissions -> roles (role_uuid)); diesel::joinable!(channels -> guilds (guild_uuid)); @@ -182,6 +206,7 @@ diesel::joinable!(roles -> guilds (guild_uuid)); diesel::allow_tables_to_appear_in_same_query!( access_tokens, + audit_logs, channel_permissions, channels, friend_requests,