diff --git a/crates/app/src/routes/api/v1/auth/ipbans.rs b/crates/app/src/routes/api/v1/auth/ipbans.rs new file mode 100644 index 0000000..6d1e97d --- /dev/null +++ b/crates/app/src/routes/api/v1/auth/ipbans.rs @@ -0,0 +1,61 @@ +use crate::{ + State, get_user_from_token, + model::{ApiReturn, Error}, + routes::api::v1::CreateIpBan, +}; +use axum::{Extension, Json, extract::Path, response::IntoResponse}; +use axum_extra::extract::CookieJar; +use tetratto_core::model::{auth::IpBan, permissions::FinePermission}; + +/// Create a new IP ban. +pub async fn create_request( + jar: CookieJar, + Path(ip): Path, + Extension(data): Extension, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + if !user.permissions.check(FinePermission::MANAGE_BANS) { + return Json(Error::NotAllowed.into()); + } + + match data.create_ipban(IpBan::new(ip, user.id, req.reason)).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "IP ban deleted".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +/// Delete the given IP ban. +pub async fn delete_request( + jar: CookieJar, + Path(id): Path, + Extension(data): Extension, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + if !user.permissions.check(FinePermission::MANAGE_BANS) { + return Json(Error::NotAllowed.into()); + } + + match data.delete_ipban(id, user).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "IP ban deleted".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} diff --git a/crates/app/src/routes/api/v1/auth/mod.rs b/crates/app/src/routes/api/v1/auth/mod.rs index b76caab..d0a92d4 100644 --- a/crates/app/src/routes/api/v1/auth/mod.rs +++ b/crates/app/src/routes/api/v1/auth/mod.rs @@ -1,4 +1,5 @@ pub mod images; +pub mod ipbans; pub mod profile; pub mod social; diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 14d9ab2..db5ab14 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -172,6 +172,9 @@ pub fn routes() -> Router { "/communities/{cid}/memberships/{uid}/role", post(communities::communities::update_membership_role), ) + // ipbans + .route("/bans/{ip}", post(auth::ipbans::create_request)) + .route("/bans/id/{id}", delete(auth::ipbans::delete_request)) } #[derive(Deserialize)] @@ -265,3 +268,8 @@ pub struct UpdateMembershipRole { pub struct DeleteUser { pub password: String, } + +#[derive(Deserialize)] +pub struct CreateIpBan { + pub reason: String, +} diff --git a/crates/core/src/database/audit_log.rs b/crates/core/src/database/audit_log.rs new file mode 100644 index 0000000..84a7650 --- /dev/null +++ b/crates/core/src/database/audit_log.rs @@ -0,0 +1,84 @@ +use super::*; +use crate::cache::Cache; +use crate::model::{ + Error, Result, auth::User, moderation::AuditLogEntry, permissions::FinePermission, +}; +use crate::{auto_method, execute, get, query_row}; + +#[cfg(feature = "sqlite")] +use rusqlite::Row; + +#[cfg(feature = "postgres")] +use tokio_postgres::Row; + +impl DataManager { + /// Get an [`AuditLogEntry`] from an SQL row. + pub(crate) fn get_auditlog_entry_from_row( + #[cfg(feature = "sqlite")] x: &Row<'_>, + #[cfg(feature = "postgres")] x: &Row, + ) -> AuditLogEntry { + AuditLogEntry { + id: get!(x->0(isize)) as usize, + created: get!(x->1(isize)) as usize, + moderator: get!(x->2(isize)) as usize, + content: get!(x->3(String)), + } + } + + auto_method!(get_auditlog_entry_by_id(usize)@get_auditlog_entry_from_row -> "SELECT * FROM auditlog WHERE id = $1" --name="audit log entry" --returns=AuditLogEntry --cache-key-tmpl="atto.auditlog:{}"); + + /// Create a new audit log entry in the database. + /// + /// # Arguments + /// * `data` - a mock [`AuditLogEntry`] object to insert + pub async fn create_auditlog_entry(&self, data: AuditLogEntry) -> Result<()> { + let conn = match self.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!( + &conn, + "INSERT INTO auditlog VALUES ($1, $2, $3, $4)", + &[ + &data.id.to_string().as_str(), + &data.created.to_string().as_str(), + &data.moderator.to_string().as_str(), + &data.content.as_str(), + ] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // return + Ok(()) + } + + pub async fn delete_auditlog_entry(&self, id: usize, user: User) -> Result<()> { + if !user.permissions.check(FinePermission::MANAGE_AUDITLOG) { + return Err(Error::NotAllowed); + } + + let conn = match self.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!( + &conn, + "DELETE FROM auditlog WHERE id = $1", + &[&id.to_string()] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + self.2.remove(format!("atto.auditlog:{}", id)).await; + + // return + Ok(()) + } +} diff --git a/crates/core/src/database/common.rs b/crates/core/src/database/common.rs index 85e6ff1..90a965c 100644 --- a/crates/core/src/database/common.rs +++ b/crates/core/src/database/common.rs @@ -22,6 +22,8 @@ impl DataManager { execute!(&conn, common::CREATE_TABLE_USERFOLLOWS).unwrap(); execute!(&conn, common::CREATE_TABLE_USERBLOCKS).unwrap(); execute!(&conn, common::CREATE_TABLE_IPBANS).unwrap(); + execute!(&conn, common::CREATE_TABLE_AUDITLOG).unwrap(); + execute!(&conn, common::CREATE_TABLE_REPORTS).unwrap(); Ok(()) } @@ -136,6 +138,11 @@ macro_rules! auto_method { if user.id != y.owner { if !user.permissions.check(FinePermission::$permission) { return Err(Error::NotAllowed); + } else { + self.create_auditlog_entry(crate::model::moderation::AuditLogEntry::new( + user.id, + format!("invoked `{}` with x value `{id}`", stringify!($name)), + )) } } @@ -161,6 +168,11 @@ macro_rules! auto_method { if user.id != y.owner { if !user.permissions.check(FinePermission::$permission) { return Err(Error::NotAllowed); + } else { + self.create_auditlog_entry(crate::model::moderation::AuditLogEntry::new( + user.id, + format!("invoked `{}` with x value `{id}`", stringify!($name)), + )) } } @@ -188,6 +200,12 @@ macro_rules! auto_method { if user.id != y.owner { if !user.permissions.check(FinePermission::$permission) { return Err(Error::NotAllowed); + } else { + self.create_auditlog_entry(crate::model::moderation::AuditLogEntry::new( + user.id, + format!("invoked `{}` with x value `{id}`", stringify!($name)), + )) + .await? } } @@ -213,6 +231,12 @@ macro_rules! auto_method { if user.id != y.owner { if !user.permissions.check(FinePermission::$permission) { return Err(Error::NotAllowed); + } else { + self.create_auditlog_entry(crate::model::moderation::AuditLogEntry::new( + user.id, + format!("invoked `{}` with x value `{x}`", stringify!($name)), + )) + .await? } } @@ -240,6 +264,12 @@ macro_rules! auto_method { if user.id != y.owner { if !user.permissions.check(FinePermission::$permission) { return Err(Error::NotAllowed); + } else { + self.create_auditlog_entry(crate::model::moderation::AuditLogEntry::new( + user.id, + format!("invoked `{}` with x value `{id}`", stringify!($name), id), + )) + .await? } } @@ -269,6 +299,12 @@ macro_rules! auto_method { if user.id != y.owner { if !user.permissions.check(FinePermission::$permission) { return Err(Error::NotAllowed); + } else { + self.create_auditlog_entry(crate::model::moderation::AuditLogEntry::new( + user.id, + format!("invoked `{}` with x value `{x:?}`", stringify!($name)), + )) + .await? } } @@ -418,6 +454,12 @@ macro_rules! auto_method { if user.id != y.owner { if !user.permissions.check(FinePermission::$permission) { return Err(Error::NotAllowed); + } else { + self.create_auditlog_entry(crate::model::moderation::AuditLogEntry::new( + user.id, + format!("invoked `{}` with x value `{id}`", stringify!($name)), + )) + .await? } } @@ -445,6 +487,12 @@ macro_rules! auto_method { if user.id != y.owner { if !user.permissions.check(FinePermission::$permission) { return Err(Error::NotAllowed); + } else { + self.create_auditlog_entry(crate::model::moderation::AuditLogEntry::new( + user.id, + format!("invoked `{}` with x value `{x}`", stringify!($name)), + )) + .await? } } @@ -497,6 +545,12 @@ macro_rules! auto_method { if user.id != y.owner { if !user.permissions.check(FinePermission::$permission) { return Err(Error::NotAllowed); + } else { + self.create_auditlog_entry(crate::model::moderation::AuditLogEntry::new( + user.id, + format!("invoked `{}` with x value `{x:?}`", stringify!($name)), + )) + .await? } } diff --git a/crates/core/src/database/communities.rs b/crates/core/src/database/communities.rs index fa43c48..cd03e94 100644 --- a/crates/core/src/database/communities.rs +++ b/crates/core/src/database/communities.rs @@ -223,6 +223,12 @@ impl DataManager { if user.id != y.owner { if !user.permissions.check(FinePermission::MANAGE_COMMUNITIES) { return Err(Error::NotAllowed); + } else { + self.create_auditlog_entry(crate::model::moderation::AuditLogEntry::new( + user.id, + format!("invoked `delete_community` with x value `{id}`"), + )) + .await? } } diff --git a/crates/core/src/database/drivers/common.rs b/crates/core/src/database/drivers/common.rs index 9f2f7fb..c1c6eec 100644 --- a/crates/core/src/database/drivers/common.rs +++ b/crates/core/src/database/drivers/common.rs @@ -7,3 +7,5 @@ pub const CREATE_TABLE_NOTIFICATIONS: &str = include_str!("./sql/create_notifica pub const CREATE_TABLE_USERFOLLOWS: &str = include_str!("./sql/create_userfollows.sql"); pub const CREATE_TABLE_USERBLOCKS: &str = include_str!("./sql/create_userblocks.sql"); pub const CREATE_TABLE_IPBANS: &str = include_str!("./sql/create_ipbans.sql"); +pub const CREATE_TABLE_AUDITLOG: &str = include_str!("./sql/create_auditlog.sql"); +pub const CREATE_TABLE_REPORTS: &str = include_str!("./sql/create_reports.sql"); diff --git a/crates/core/src/database/drivers/sql/create_auditlog.sql b/crates/core/src/database/drivers/sql/create_auditlog.sql new file mode 100644 index 0000000..596fce8 --- /dev/null +++ b/crates/core/src/database/drivers/sql/create_auditlog.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS auditlog ( + ip TEXT NOT NULL, + created INTEGER NOT NULL PRIMARY KEY, + moderator TEXT NOT NULL, + content TEXT NOT NULL +) diff --git a/crates/core/src/database/drivers/sql/create_reports.sql b/crates/core/src/database/drivers/sql/create_reports.sql new file mode 100644 index 0000000..d2fb1cc --- /dev/null +++ b/crates/core/src/database/drivers/sql/create_reports.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS reports ( + ip TEXT NOT NULL, + created INTEGER NOT NULL PRIMARY KEY, + owner TEXT NOT NULL, + content TEXT NOT NULL, + asset INTEGER NOT NULL, + asset_type TEXT NOT NULL +) diff --git a/crates/core/src/database/mod.rs b/crates/core/src/database/mod.rs index dff4296..5e87c54 100644 --- a/crates/core/src/database/mod.rs +++ b/crates/core/src/database/mod.rs @@ -1,3 +1,4 @@ +mod audit_log; mod auth; mod common; mod communities; @@ -7,6 +8,7 @@ mod memberships; mod notifications; mod posts; mod reactions; +mod reports; mod userblocks; mod userfollows; diff --git a/crates/core/src/database/posts.rs b/crates/core/src/database/posts.rs index ce6cb01..40cae44 100644 --- a/crates/core/src/database/posts.rs +++ b/crates/core/src/database/posts.rs @@ -395,6 +395,12 @@ impl DataManager { if user.id != y.owner { if !user.permissions.check(FinePermission::MANAGE_POSTS) { return Err(Error::NotAllowed); + } else { + self.create_auditlog_entry(crate::model::moderation::AuditLogEntry::new( + user.id, + format!("invoked `delete_post` with x value `{id}`"), + )) + .await? } } diff --git a/crates/core/src/database/reports.rs b/crates/core/src/database/reports.rs new file mode 100644 index 0000000..826cdbe --- /dev/null +++ b/crates/core/src/database/reports.rs @@ -0,0 +1,86 @@ +use super::*; +use crate::cache::Cache; +use crate::model::{Error, Result, auth::User, moderation::Report, permissions::FinePermission}; +use crate::{auto_method, execute, get, query_row}; + +#[cfg(feature = "sqlite")] +use rusqlite::Row; + +#[cfg(feature = "postgres")] +use tokio_postgres::Row; + +impl DataManager { + /// Get a [`Report`] from an SQL row. + pub(crate) fn get_report_from_row( + #[cfg(feature = "sqlite")] x: &Row<'_>, + #[cfg(feature = "postgres")] x: &Row, + ) -> Report { + Report { + id: get!(x->0(isize)) as usize, + created: get!(x->1(isize)) as usize, + owner: get!(x->2(isize)) as usize, + content: get!(x->3(String)), + asset: get!(x->4(isize)) as usize, + asset_type: serde_json::from_str(&get!(x->5(String))).unwrap(), + } + } + + auto_method!(get_report_by_id(usize)@get_report_from_row -> "SELECT * FROM reports WHERE id = $1" --name="report" --returns=Report --cache-key-tmpl="atto.reports:{}"); + + /// Create a new report in the database. + /// + /// # Arguments + /// * `data` - a mock [`Report`] object to insert + pub async fn create_report(&self, data: Report) -> Result<()> { + let conn = match self.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!( + &conn, + "INSERT INTO reports VALUES ($1, $2, $3, $4, $5, $6)", + &[ + &data.id.to_string().as_str(), + &data.created.to_string().as_str(), + &data.owner.to_string().as_str(), + &data.content.as_str(), + &data.asset.to_string().as_str(), + &serde_json::to_string(&data.asset_type).unwrap().as_str(), + ] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // return + Ok(()) + } + + pub async fn delete_report(&self, id: usize, user: User) -> Result<()> { + if !user.permissions.check(FinePermission::MANAGE_REPORTS) { + return Err(Error::NotAllowed); + } + + let conn = match self.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!( + &conn, + "DELETE FROM reports WHERE id = $1", + &[&id.to_string()] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + self.2.remove(format!("atto.report:{}", id)).await; + + // return + Ok(()) + } +} diff --git a/crates/core/src/model/communities.rs b/crates/core/src/model/communities.rs index 635675b..2d05006 100644 --- a/crates/core/src/model/communities.rs +++ b/crates/core/src/model/communities.rs @@ -70,7 +70,7 @@ impl Community { } } -#[derive(Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize)] pub struct CommunityContext { pub display_name: String, pub description: String, @@ -86,7 +86,7 @@ impl Default for CommunityContext { } /// Who can read a [`Community`]. -#[derive(Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] pub enum CommunityReadAccess { /// Everybody can view the community. Everybody, @@ -101,7 +101,7 @@ impl Default for CommunityReadAccess { } /// Who can write to a [`Community`]. -#[derive(Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] pub enum CommunityWriteAccess { /// Everybody. Everybody, @@ -120,7 +120,7 @@ impl Default for CommunityWriteAccess { } /// Who can join a [`Community`]. -#[derive(Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] pub enum CommunityJoinAccess { /// Joins are closed. Nobody can join the community. Nobody, @@ -136,7 +136,7 @@ impl Default for CommunityJoinAccess { } } -#[derive(Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct CommunityMembership { pub id: usize, pub created: usize, @@ -161,7 +161,7 @@ impl CommunityMembership { } } -#[derive(Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize)] pub struct PostContext { pub comments_enabled: bool, } diff --git a/crates/core/src/model/mod.rs b/crates/core/src/model/mod.rs index 06277b3..1ae1b18 100644 --- a/crates/core/src/model/mod.rs +++ b/crates/core/src/model/mod.rs @@ -1,6 +1,7 @@ pub mod auth; pub mod communities; pub mod communities_permissions; +pub mod moderation; pub mod permissions; pub mod reactions; diff --git a/crates/core/src/model/moderation.rs b/crates/core/src/model/moderation.rs new file mode 100644 index 0000000..9929fed --- /dev/null +++ b/crates/core/src/model/moderation.rs @@ -0,0 +1,54 @@ +use serde::{Deserialize, Serialize}; +use tetratto_shared::{snow::AlmostSnowflake, unix_epoch_timestamp}; + +use super::reactions::AssetType; + +#[derive(Serialize, Deserialize)] +pub struct AuditLogEntry { + pub id: usize, + pub created: usize, + pub moderator: usize, + pub content: String, +} + +impl AuditLogEntry { + /// Create a new [`AuditLogEntry`]. + pub fn new(moderator: usize, content: String) -> Self { + Self { + id: AlmostSnowflake::new(1234567890) + .to_string() + .parse::() + .unwrap(), + created: unix_epoch_timestamp() as usize, + moderator, + content, + } + } +} + +#[derive(Serialize, Deserialize)] +pub struct Report { + pub id: usize, + pub created: usize, + pub owner: usize, + pub content: String, + pub asset: usize, + pub asset_type: AssetType, +} + +impl Report { + /// Create a new [`Report`]. + pub fn new(owner: usize, content: String, asset: usize, asset_type: AssetType) -> Self { + Self { + id: AlmostSnowflake::new(1234567890) + .to_string() + .parse::() + .unwrap(), + created: unix_epoch_timestamp() as usize, + owner, + content, + asset, + asset_type, + } + } +} diff --git a/crates/core/src/model/permissions.rs b/crates/core/src/model/permissions.rs index e5a0ee1..bc1ef43 100644 --- a/crates/core/src/model/permissions.rs +++ b/crates/core/src/model/permissions.rs @@ -23,6 +23,8 @@ bitflags! { const MANAGE_REACTIONS = 1 << 12; const MANAGE_FOLLOWS = 1 << 13; const MANAGE_VERIFIED = 1 << 14; + const MANAGE_AUDITLOG = 1 << 15; + const MANAGE_REPORTS = 1 << 16; const _ = !0; }