From 5995aaf31c82cbeb0bc2d959e465cd33b7fbec4f Mon Sep 17 00:00:00 2001 From: trisua Date: Fri, 11 Apr 2025 22:12:43 -0400 Subject: [PATCH] add: user account warnings --- crates/app/src/assets.rs | 2 + crates/app/src/langs/en-US.toml | 2 + crates/app/src/public/html/mod/profile.html | 8 + crates/app/src/public/html/mod/warnings.html | 132 +++++++++++++++ crates/app/src/routes/api/v1/auth/ipbans.rs | 2 +- crates/app/src/routes/api/v1/auth/mod.rs | 1 + .../src/routes/api/v1/auth/user_warnings.rs | 65 ++++++++ crates/app/src/routes/api/v1/mod.rs | 11 ++ crates/app/src/routes/pages/mod.rs | 4 + crates/app/src/routes/pages/mod_panel.rs | 48 ++++++ crates/core/src/database/common.rs | 1 + crates/core/src/database/drivers/common.rs | 1 + .../drivers/sql/create_user_warnings.sql | 7 + crates/core/src/database/mod.rs | 1 + crates/core/src/database/user_warnings.rs | 150 ++++++++++++++++++ crates/core/src/model/auth.rs | 25 +++ 16 files changed, 459 insertions(+), 1 deletion(-) create mode 100644 crates/app/src/public/html/mod/warnings.html create mode 100644 crates/app/src/routes/api/v1/auth/user_warnings.rs create mode 100644 crates/core/src/database/drivers/sql/create_user_warnings.sql create mode 100644 crates/core/src/database/user_warnings.rs diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs index fb7ccf8..35c7398 100644 --- a/crates/app/src/assets.rs +++ b/crates/app/src/assets.rs @@ -66,6 +66,7 @@ pub const MOD_REPORTS: &str = include_str!("./public/html/mod/reports.html"); pub const MOD_FILE_REPORT: &str = include_str!("./public/html/mod/file_report.html"); pub const MOD_IP_BANS: &str = include_str!("./public/html/mod/ip_bans.html"); pub const MOD_PROFILE: &str = include_str!("./public/html/mod/profile.html"); +pub const MOD_WARNINGS: &str = include_str!("./public/html/mod/warnings.html"); // langs pub const LANG_EN_US: &str = include_str!("./langs/en-US.toml"); @@ -197,6 +198,7 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD { write_template!(html_path->"mod/file_report.html"(crate::assets::MOD_FILE_REPORT) --config=config); write_template!(html_path->"mod/ip_bans.html"(crate::assets::MOD_IP_BANS) --config=config); write_template!(html_path->"mod/profile.html"(crate::assets::MOD_PROFILE) --config=config); + write_template!(html_path->"mod/warnings.html"(crate::assets::MOD_WARNINGS) --config=config); html_path } diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index 2cec734..871f5d5 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -112,3 +112,5 @@ version = "1.0.0" "mod_panel:label.open_reported_content" = "Open reported content" "mod_panel:label.manage_profile" = "Manage profile" "mod_panel:label.permissions_level_builder" = "Permission level builder" +"mod_panel:label.warnings" = "Warnings" +"mod_panel:label.create_warning" = "Create warning" diff --git a/crates/app/src/public/html/mod/profile.html b/crates/app/src/public/html/mod/profile.html index 01edda8..d848394 100644 --- a/crates/app/src/public/html/mod/profile.html +++ b/crates/app/src/public/html/mod/profile.html @@ -22,6 +22,14 @@ View settings + + {{ icon "shield-alert" }} + View warnings + + + + + +
+
+ + {{ icon "message-circle-warning" }} + {{ text "mod_panel:label.warnings" }} + +
+ +
+ {% for item in items %} +
+ + +
+ {{ item.content|markdown|safe }} +
+
+ {% endfor %} + + + {{ components::pagination(page=page, items=items|length) }} +
+
+ + + +{% endblock %} diff --git a/crates/app/src/routes/api/v1/auth/ipbans.rs b/crates/app/src/routes/api/v1/auth/ipbans.rs index ef75d34..eefe767 100644 --- a/crates/app/src/routes/api/v1/auth/ipbans.rs +++ b/crates/app/src/routes/api/v1/auth/ipbans.rs @@ -27,7 +27,7 @@ pub async fn create_request( match data.create_ipban(IpBan::new(ip, user.id, req.reason)).await { Ok(_) => Json(ApiReturn { ok: true, - message: "IP ban deleted".to_string(), + message: "IP ban created".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 3ab87ab..95a2e9a 100644 --- a/crates/app/src/routes/api/v1/auth/mod.rs +++ b/crates/app/src/routes/api/v1/auth/mod.rs @@ -2,6 +2,7 @@ pub mod images; pub mod ipbans; pub mod profile; pub mod social; +pub mod user_warnings; use super::{LoginProps, RegisterProps}; use crate::{ diff --git a/crates/app/src/routes/api/v1/auth/user_warnings.rs b/crates/app/src/routes/api/v1/auth/user_warnings.rs new file mode 100644 index 0000000..49df570 --- /dev/null +++ b/crates/app/src/routes/api/v1/auth/user_warnings.rs @@ -0,0 +1,65 @@ +use crate::{ + get_user_from_token, + model::{ApiReturn, Error}, + routes::api::v1::CreateUserWarning, + State, +}; +use axum::{Extension, Json, extract::Path, response::IntoResponse}; +use axum_extra::extract::CookieJar; +use tetratto_core::model::{auth::UserWarning, permissions::FinePermission}; + +/// Create a new user warning. +pub async fn create_request( + jar: CookieJar, + Path(uid): 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_user_warning(UserWarning::new(uid, user.id, req.content)) + .await + { + Ok(_) => Json(ApiReturn { + ok: true, + message: "User warning created".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +/// Delete the given user warning. +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_WARNINGS) { + return Json(Error::NotAllowed.into()); + } + + match data.delete_user_warning(id, user).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "User warning deleted".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index da6903c..7f745f4 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -162,6 +162,12 @@ pub fn routes() -> Router { "/auth/user/find_by_ip/{ip}", get(auth::profile::redirect_from_ip), ) + // warnings + .route("/warnings/{id}", post(auth::user_warnings::create_request)) + .route( + "/warnings/{id}", + delete(auth::user_warnings::delete_request), + ) // notifications .route( "/notifications/my", @@ -326,3 +332,8 @@ pub struct CreateIpBan { pub struct DisableTotp { pub totp: String, } + +#[derive(Deserialize)] +pub struct CreateUserWarning { + pub content: String, +} diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs index 3a41b3f..2379944 100644 --- a/crates/app/src/routes/pages/mod.rs +++ b/crates/app/src/routes/pages/mod.rs @@ -35,6 +35,10 @@ pub fn routes() -> Router { "/mod_panel/profile/{id}", get(mod_panel::manage_profile_request), ) + .route( + "/mod_panel/profile/{id}/warnings", + get(mod_panel::manage_profile_warnings_request), + ) // auth .route("/auth/register", get(auth::register_request)) .route("/auth/login", get(auth::login_request)) diff --git a/crates/app/src/routes/pages/mod_panel.rs b/crates/app/src/routes/pages/mod_panel.rs index a815003..f9d21c1 100644 --- a/crates/app/src/routes/pages/mod_panel.rs +++ b/crates/app/src/routes/pages/mod_panel.rs @@ -184,3 +184,51 @@ pub async fn manage_profile_request( // return Ok(Html(data.1.render("mod/profile.html", &context).unwrap())) } + +/// `/mod_panel/profile/{id}/warnings` +pub async fn manage_profile_warnings_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, + Query(req): Query, +) -> impl IntoResponse { + let data = data.read().await; + let user = match get_user_from_token!(jar, data.0) { + Some(ua) => ua, + None => { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &None).await, + )); + } + }; + + if !user.permissions.check(FinePermission::MANAGE_USERS) { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &None).await, + )); + } + + let profile = match data.0.get_user_by_id(id).await { + Ok(p) => p, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }; + + let list = match data + .0 + .get_user_warnings_by_user(profile.id, 12, req.page) + .await + { + Ok(p) => p, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }; + + let lang = get_lang!(jar, data.0); + let mut context = initial_context(&data.0.0, lang, &Some(user)).await; + + context.insert("profile", &profile); + context.insert("items", &list); + context.insert("page", &req.page); + + // return + Ok(Html(data.1.render("mod/warnings.html", &context).unwrap())) +} diff --git a/crates/core/src/database/common.rs b/crates/core/src/database/common.rs index 0841bd3..06e2061 100644 --- a/crates/core/src/database/common.rs +++ b/crates/core/src/database/common.rs @@ -24,6 +24,7 @@ impl DataManager { execute!(&conn, common::CREATE_TABLE_IPBANS).unwrap(); execute!(&conn, common::CREATE_TABLE_AUDIT_LOG).unwrap(); execute!(&conn, common::CREATE_TABLE_REPORTS).unwrap(); + execute!(&conn, common::CREATE_TABLE_USER_WARNINGS).unwrap(); Ok(()) } diff --git a/crates/core/src/database/drivers/common.rs b/crates/core/src/database/drivers/common.rs index a5f7523..f6d8558 100644 --- a/crates/core/src/database/drivers/common.rs +++ b/crates/core/src/database/drivers/common.rs @@ -9,3 +9,4 @@ pub const CREATE_TABLE_USERBLOCKS: &str = include_str!("./sql/create_userblocks. pub const CREATE_TABLE_IPBANS: &str = include_str!("./sql/create_ipbans.sql"); pub const CREATE_TABLE_AUDIT_LOG: &str = include_str!("./sql/create_audit_log.sql"); pub const CREATE_TABLE_REPORTS: &str = include_str!("./sql/create_reports.sql"); +pub const CREATE_TABLE_USER_WARNINGS: &str = include_str!("./sql/create_user_warnings.sql"); diff --git a/crates/core/src/database/drivers/sql/create_user_warnings.sql b/crates/core/src/database/drivers/sql/create_user_warnings.sql new file mode 100644 index 0000000..9dcb838 --- /dev/null +++ b/crates/core/src/database/drivers/sql/create_user_warnings.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS user_warnings ( + id BIGINT NOT NULL PRIMARY KEY, + created BIGINT NOT NULL, + receiver BIGINT NOT NULL, + moderator BIGINT NOT NULL, + content TEXT NOT NULL +) diff --git a/crates/core/src/database/mod.rs b/crates/core/src/database/mod.rs index 5e87c54..9222af6 100644 --- a/crates/core/src/database/mod.rs +++ b/crates/core/src/database/mod.rs @@ -9,6 +9,7 @@ mod notifications; mod posts; mod reactions; mod reports; +mod user_warnings; mod userblocks; mod userfollows; diff --git a/crates/core/src/database/user_warnings.rs b/crates/core/src/database/user_warnings.rs new file mode 100644 index 0000000..4401d3d --- /dev/null +++ b/crates/core/src/database/user_warnings.rs @@ -0,0 +1,150 @@ +use super::*; +use crate::cache::Cache; +use crate::model::auth::{Notification, UserWarning}; +use crate::model::moderation::AuditLogEntry; +use crate::model::{Error, Result, auth::User, permissions::FinePermission}; +use crate::{auto_method, execute, get, query_row, query_rows, params}; + +#[cfg(feature = "sqlite")] +use rusqlite::Row; + +#[cfg(feature = "postgres")] +use tokio_postgres::Row; + +impl DataManager { + /// Get a [`UserWarning`] from an SQL row. + pub(crate) fn get_user_warning_from_row( + #[cfg(feature = "sqlite")] x: &Row<'_>, + #[cfg(feature = "postgres")] x: &Row, + ) -> UserWarning { + UserWarning { + id: get!(x->0(i64)) as usize, + created: get!(x->1(i64)) as usize, + receiver: get!(x->2(i64)) as usize, + moderator: get!(x->3(i64)) as usize, + content: get!(x->4(String)), + } + } + + auto_method!(get_user_warning_by_ip(&str)@get_user_warning_from_row -> "SELECT * FROM user_warning WHERE ip = $1" --name="user warning" --returns=UserWarning --cache-key-tmpl="atto.user_warning:{}"); + + /// Get all user warnings by user (paginated). + /// + /// # Arguments + /// * `user` - the ID of the user to fetch warnings for + /// * `batch` - the limit of items in each page + /// * `page` - the page number + pub async fn get_user_warnings_by_user( + &self, + user: usize, + batch: usize, + page: usize, + ) -> Result> { + let conn = match self.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = query_rows!( + &conn, + "SELECT * FROM user_warnings WHERE receiver = $1 ORDER BY created DESC LIMIT $2 OFFSET $3", + &[&(user as i64), &(batch as i64), &((page * batch) as i64)], + |x| { Self::get_user_warning_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("user warning".to_string())); + } + + Ok(res.unwrap()) + } + + /// Create a new user warning in the database. + /// + /// # Arguments + /// * `data` - a mock [`UserWarning`] object to insert + pub async fn create_user_warning(&self, data: UserWarning) -> Result<()> { + let user = self.get_user_by_id(data.moderator).await?; + + // ONLY moderators can create warnings + if !user.permissions.check(FinePermission::MANAGE_WARNINGS) { + 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, + "INSERT INTO user_warnings VALUES ($1, $2, $3, $4, $5)", + params![ + &(data.id as i64), + &(data.created as i64), + &(data.receiver as i64), + &(data.moderator as i64), + &data.content + ] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // create audit log entry + self.create_audit_log_entry(AuditLogEntry::new( + user.id, + format!( + "invoked `create_user_warning` with x value `{}`", + data.receiver + ), + )) + .await?; + + // send notification + self.create_notification(Notification::new( + "You have received a new account warning.".to_string(), + data.content, + data.receiver, + )) + .await?; + + // return + Ok(()) + } + + pub async fn delete_user_warning(&self, id: usize, user: User) -> Result<()> { + // ONLY moderators can manage warnings + if !user.permissions.check(FinePermission::MANAGE_WARNINGS) { + 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 user_warnings WHERE id = $1", + &[&(id as i64)] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + self.2.remove(format!("atto.user_warning:{}", id)).await; + + // create audit log entry + self.create_audit_log_entry(AuditLogEntry::new( + user.id, + format!("invoked `delete_user_warning` with x value `{id}`"), + )) + .await?; + + // return + Ok(()) + } +} diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index 9608f06..f1a5245 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -351,3 +351,28 @@ impl IpBan { } } } + +#[derive(Serialize, Deserialize)] +pub struct UserWarning { + pub id: usize, + pub created: usize, + pub receiver: usize, + pub moderator: usize, + pub content: String, +} + +impl UserWarning { + /// Create a new [`UserWarning`]. + pub fn new(user: usize, moderator: usize, content: String) -> Self { + Self { + id: AlmostSnowflake::new(1234567890) + .to_string() + .parse::() + .unwrap(), + created: unix_epoch_timestamp() as usize, + receiver: user, + moderator, + content, + } + } +}