diff --git a/Cargo.lock b/Cargo.lock index 45d652c0..8d55ada1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -342,7 +342,7 @@ dependencies = [ "chrono", "futures", "handlebars", - "iso8601-timestamp", + "iso8601-timestamp 0.1.10", "lazy_static", "lettre", "log", @@ -1605,6 +1605,17 @@ dependencies = [ "time 0.3.17", ] +[[package]] +name = "iso8601-timestamp" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368d05885f276773b79b4578869a60ab91e880415d17fb2a8d193aea74188a29" +dependencies = [ + "generic-array 0.14.5", + "serde", + "time 0.3.17", +] + [[package]] name = "itoa" version = "0.4.8" @@ -2826,7 +2837,7 @@ dependencies = [ [[package]] name = "revolt-bonfire" -version = "0.5.19" +version = "0.5.21" dependencies = [ "async-std", "async-tungstenite", @@ -2851,9 +2862,11 @@ dependencies = [ "authifier", "bson", "futures", + "iso8601-timestamp 0.2.10", "log", "mongodb", "nanoid", + "once_cell", "revolt-permissions", "revolt-result", "revolt_optional_struct", @@ -2861,11 +2874,12 @@ dependencies = [ "schemars", "serde", "serde_json", + "ulid 1.0.0", ] [[package]] name = "revolt-delta" -version = "0.5.19" +version = "0.5.21" dependencies = [ "async-channel", "async-std", @@ -2937,7 +2951,7 @@ dependencies = [ [[package]] name = "revolt-quark" -version = "0.5.19" +version = "0.5.21" dependencies = [ "async-lock", "async-recursion", @@ -2954,7 +2968,7 @@ dependencies = [ "futures", "impl_ops", "indexmap", - "iso8601-timestamp", + "iso8601-timestamp 0.1.10", "linkify 0.8.1", "log", "lru", @@ -3127,7 +3141,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4554a81937341d8c3cd3e43431ac2d23624444dc3a88e7ffd3ff66593779293" dependencies = [ "authifier", - "iso8601-timestamp", + "iso8601-timestamp 0.1.10", "revolt_okapi", "revolt_rocket_okapi", "rocket", @@ -4233,6 +4247,15 @@ dependencies = [ "rand 0.8.5", ] +[[package]] +name = "ulid" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13a3aaa69b04e5b66cc27309710a569ea23593612387d67daaf102e73aa974fd" +dependencies = [ + "rand 0.8.5", +] + [[package]] name = "uname" version = "0.1.1" diff --git a/Dockerfile b/Dockerfile index 6bac93d0..eb5a66ec 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Build Stage -FROM --platform="${BUILDPLATFORM}" rustlang/rust:nightly-slim +FROM --platform="${BUILDPLATFORM}" rust:slim USER 0:0 WORKDIR /home/rust/src diff --git a/STYLE_GUIDE.md b/STYLE_GUIDE.md new file mode 100644 index 00000000..4e955e5d --- /dev/null +++ b/STYLE_GUIDE.md @@ -0,0 +1,67 @@ +# Code Style Guide + +Beyond using Cargo format and Clippy, there are some specific code style guidelines laid out in this document for different parts of the project. + +## `core/database` crate + +w.r.t. `model.rs` files + +- All struct definitions must be commented. + ```rust + /// Server + pub struct Server { + /// Name of the server + pub name: String, + ``` +- Struct definitions should not include derives unless necessary (if additional traits such as Hash are required) and instead use `auto_derived!` and `auto_derived_partial!`. + ```rust + auto_derived_partial!( + /// Server + pub struct Server { .. }, + "PartialServer" + ); + ``` +- `auto_derived!` macro accepts multiple entries and should be used as such: + ```rust + auto_derived!( + /// Optional fields on server object + pub enum FieldsServer { .. } + + /// Optional fields on server object + pub enum FieldsRole { .. } + ); + ``` +- If special serialisation conditions are required, such as checking if a boolean is false, use the existing definitions for these functions from the crate root: + ```rust + #[serde(skip_serializing_if = "crate::if_false", default)] + ``` +- `impl` blocks may be defined below the struct definitions and should be ordered in the same order of definition. Methods in the block must follow the same guidelines as traits where-in: methods are ordered in terms of CRUD, there are empty line breaks, and methods are commented. + +w.r.t. `ops` module for models + +- All traits must use a the name format `AbstractPlural` where Plural is the plural form of the collection. e.g. Servers +- Traits defined must follow these guidelines: + + - Methods are ordered in terms of CRUD, create-read-update-delete ordering. + + ```rust + #[async_trait] + pub trait AbstractServerMembers: Sync + Send { + /// Insert a new server member into the database + async fn insert_member(&self, member: &Member) -> Result<()>; + + /// Fetch a server member by their id + async fn fetch_member(&self, server_id: &str, user_id: &str) -> Result; + + /// Update information for a server member + async fn update_member(&self, .. ) -> Result<()>; + + /// Delete a server member by their id + async fn delete_member(&self, id: &MemberCompositeKey) -> Result<()>; + } + ``` + + - There should be an empty line break between each method declaration. + - All methods must have an appropriate comment. + +- When implementing the trait defined in `ops.rs` with each driver, the method declaration style should be the same for ease of searching: same ordering, same comments, same line breaks. diff --git a/clippy.toml b/clippy.toml index bb3ea02e..a1c69535 100644 --- a/clippy.toml +++ b/clippy.toml @@ -2,6 +2,14 @@ disallowed-methods = [ # Shouldn't need to access these directly "revolt_database::models::bots::model::Bot::remove_field", - # Prefer to use Object::delete() + # Prefer to use Object::create() + "revolt_database::models::safety_strikes::ops::AbstractAccountStrikes::insert_account_strike", + + # Prefer to use Object::update(&self) "revolt_database::models::bots::ops::AbstractBots::update_bot", + "revolt_database::models::safety_strikes::ops::AbstractAccountStrikes::update_account_strike", + + # Prefer to use Object::delete(&self) + "revolt_database::models::bots::ops::AbstractBots::delete_bot", + "revolt_database::models::safety_strikes::ops::AbstractAccountStrikes::delete_account_strike", ] diff --git a/crates/bonfire/Cargo.toml b/crates/bonfire/Cargo.toml index 6659d0e4..9828f03d 100644 --- a/crates/bonfire/Cargo.toml +++ b/crates/bonfire/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-bonfire" -version = "0.5.19" +version = "0.5.21" license = "AGPL-3.0-or-later" edition = "2021" diff --git a/crates/core/database/Cargo.toml b/crates/core/database/Cargo.toml index 562718ca..b92bb4fa 100644 --- a/crates/core/database/Cargo.toml +++ b/crates/core/database/Cargo.toml @@ -22,16 +22,19 @@ default = [ "mongodb", "async-std-runtime" ] [dependencies] # Core revolt-result = { version = "0.0.2", path = "../result" } -revolt-permissions = { version = "0.0.2", path = "../permissions" } +revolt-permissions = { version = "0.0.2", path = "../permissions", features = [ "serde" ] } # Utility log = "0.4" +ulid = "1.0.0" nanoid = "0.4.0" +once_cell = "1.17" # Serialisation serde_json = "1" revolt_optional_struct = "0.2.0" serde = { version = "1", features = ["derive"] } +iso8601-timestamp = { version = "0.2.10", features = ["serde", "bson"] } # Database bson = { optional = true, version = "2.1.0" } diff --git a/crates/core/database/src/drivers/reference.rs b/crates/core/database/src/drivers/reference.rs index cd4bc1a1..9f562b36 100644 --- a/crates/core/database/src/drivers/reference.rs +++ b/crates/core/database/src/drivers/reference.rs @@ -2,13 +2,27 @@ use std::{collections::HashMap, sync::Arc}; use futures::lock::Mutex; -use crate::{Bot, User}; +use crate::{AccountStrike, Bot, File, Member, MemberCompositeKey, Server, User, UserSettings}; database_derived!( /// Reference implementation #[derive(Default)] pub struct ReferenceDb { + pub account_strikes: Arc>>, pub bots: Arc>>, + pub user_settings: Arc>>, pub users: Arc>>, + pub server_members: Arc>>, + pub servers: Arc>>, + pub files: Arc>>, + + pub server_bans: Arc>>, + pub safety_reports: Arc>>, + pub safety_snapshots: Arc>>, + pub emoji: Arc>>, + pub messages: Arc>>, + pub channels: Arc>>, + pub channel_invites: Arc>>, + pub channel_unreads: Arc>>, } ); diff --git a/crates/core/database/src/models/admin_migrations/ops/mongodb/init.rs b/crates/core/database/src/models/admin_migrations/ops/mongodb/init.rs index 2649635d..352d04ad 100644 --- a/crates/core/database/src/models/admin_migrations/ops/mongodb/init.rs +++ b/crates/core/database/src/models/admin_migrations/ops/mongodb/init.rs @@ -64,6 +64,10 @@ pub async fn create_database(db: &MongoDb) { .await .expect("Failed to create safety_snapshots collection."); + db.create_collection("safety_strikes", None) + .await + .expect("Failed to create safety_strikes collection."); + db.create_collection("bots", None) .await .expect("Failed to create bots collection."); diff --git a/crates/core/database/src/models/admin_migrations/ops/mongodb/scripts.rs b/crates/core/database/src/models/admin_migrations/ops/mongodb/scripts.rs index 8c5aa874..7a9a8d38 100644 --- a/crates/core/database/src/models/admin_migrations/ops/mongodb/scripts.rs +++ b/crates/core/database/src/models/admin_migrations/ops/mongodb/scripts.rs @@ -16,7 +16,7 @@ struct MigrationInfo { revision: i32, } -pub const LATEST_REVISION: i32 = 21; +pub const LATEST_REVISION: i32 = 23; pub async fn migrate_database(db: &MongoDb) { let migrations = db.col::("migrations"); @@ -696,26 +696,15 @@ pub async fn run_migrations(db: &MongoDb, revision: i32) -> i32 { if revision <= 19 { info!("Running migration [revision 19 / 27-02-2023]: Create report / snapshot collections, migrate to new model if applicable."); - // TODO: make these fail once production is migrated - if db - .db() + db.db() .create_collection("safety_reports", None) .await - .is_err() - { - info!("Failed to create safety_reports collection but this is expected in production."); - } + .unwrap(); - if db - .db() + db.db() .create_collection("safety_snapshots", None) .await - .is_err() - { - info!( - "Failed to create safety_snapshots collection but this is expected in production." - ); - } + .unwrap(); db.col::("safety_reports") .update_many( @@ -753,6 +742,32 @@ pub async fn run_migrations(db: &MongoDb, revision: i32) -> i32 { .expect("Failed to create safety snapshot index."); } + if revision <= 21 { + info!("Running migration [revision 21 / 31-05-2023]: Add collection `safety_strikes`."); + + db.db() + .create_collection("safety_strikes", None) + .await + .unwrap(); + } + + if revision <= 22 { + info!("Running migration [revision 22 / 31-05-2023]: Add moderator_id to account strikes."); + + db.col::("safety_strikes") + .update_many( + doc! {}, + doc! { + "$set": { + "moderator_id": "01EX2NCWQ0CHS3QJF0FEQS1GR4" + } + }, + None, + ) + .await + .expect("Failed to update server members."); + } + // Need to migrate fields on attachments, change `user_id`, `object_id`, etc to `parent`. // Reminder to update LATEST_REVISION when adding new migrations. diff --git a/crates/core/database/src/models/bots/model.rs b/crates/core/database/src/models/bots/model.rs index 1a9fa17e..23cd9861 100644 --- a/crates/core/database/src/models/bots/model.rs +++ b/crates/core/database/src/models/bots/model.rs @@ -84,14 +84,14 @@ impl Bot { /// Delete this bot pub async fn delete(&self, db: &Database) -> Result<()> { - // db.fetch_user(&self.id).await?.mark_deleted(db).await?; + db.fetch_user(&self.id).await?.mark_deleted(db).await?; db.delete_bot(&self.id).await } } #[cfg(test)] mod tests { - use crate::{Bot, FieldsBot, PartialBot}; + use crate::{Bot, FieldsBot, PartialBot, User}; #[async_std::test] async fn crud() { @@ -100,6 +100,14 @@ mod tests { let user_id = "user"; let token = "my_token"; + let user = User { + id: bot_id.to_string(), + username: "Bot Name".to_string(), + ..Default::default() + }; + + db.insert_user(&user).await.unwrap(); + let bot = Bot { id: bot_id.to_string(), owner: user_id.to_string(), @@ -139,7 +147,8 @@ mod tests { bot.delete(&db).await.unwrap(); assert!(db.fetch_bot(bot_id).await.is_err()); - assert_eq!(0, db.get_number_of_bots_by_user(user_id).await.unwrap()) + assert_eq!(0, db.get_number_of_bots_by_user(user_id).await.unwrap()); + assert_eq!(db.fetch_user(bot_id).await.unwrap().flags, Some(2)) }); } } diff --git a/crates/core/database/src/models/bots/ops.rs b/crates/core/database/src/models/bots/ops.rs index 3a618253..8d77673f 100644 --- a/crates/core/database/src/models/bots/ops.rs +++ b/crates/core/database/src/models/bots/ops.rs @@ -7,14 +7,20 @@ mod reference; #[async_trait] pub trait AbstractBots: Sync + Send { + /// Insert new bot into the database + async fn insert_bot(&self, bot: &Bot) -> Result<()>; + /// Fetch a bot by its id async fn fetch_bot(&self, id: &str) -> Result; /// Fetch a bot by its token async fn fetch_bot_by_token(&self, token: &str) -> Result; - /// Insert new bot into the database - async fn insert_bot(&self, bot: &Bot) -> Result<()>; + /// Fetch bots owned by a user + async fn fetch_bots_by_user(&self, user_id: &str) -> Result>; + + /// Get the number of bots owned by a user + async fn get_number_of_bots_by_user(&self, user_id: &str) -> Result; /// Update bot with new information async fn update_bot( @@ -26,10 +32,4 @@ pub trait AbstractBots: Sync + Send { /// Delete a bot from the database async fn delete_bot(&self, id: &str) -> Result<()>; - - /// Fetch bots owned by a user - async fn fetch_bots_by_user(&self, user_id: &str) -> Result>; - - /// Get the number of bots owned by a user - async fn get_number_of_bots_by_user(&self, user_id: &str) -> Result; } diff --git a/crates/core/database/src/models/bots/ops/mongodb.rs b/crates/core/database/src/models/bots/ops/mongodb.rs index 69da814f..b141e8c5 100644 --- a/crates/core/database/src/models/bots/ops/mongodb.rs +++ b/crates/core/database/src/models/bots/ops/mongodb.rs @@ -9,6 +9,11 @@ static COL: &str = "bots"; #[async_trait] impl AbstractBots for MongoDb { + /// Insert new bot into the database + async fn insert_bot(&self, bot: &Bot) -> Result<()> { + query!(self, insert_one, COL, &bot).map(|_| ()) + } + /// Fetch a bot by its id async fn fetch_bot(&self, id: &str) -> Result { query!(self, find_one_by_id, COL, id)?.ok_or_else(|| create_error!(NotFound)) @@ -27,35 +32,6 @@ impl AbstractBots for MongoDb { .ok_or_else(|| create_error!(NotFound)) } - /// Insert new bot into the database - async fn insert_bot(&self, bot: &Bot) -> Result<()> { - query!(self, insert_one, COL, &bot).map(|_| ()) - } - - /// Update bot with new information - async fn update_bot( - &self, - id: &str, - partial: &PartialBot, - remove: Vec, - ) -> Result<()> { - query!( - self, - update_one_by_id, - COL, - id, - partial, - remove.iter().map(|x| x as &dyn IntoDocumentPath).collect(), - None - ) - .map(|_| ()) - } - - /// Delete a bot from the database - async fn delete_bot(&self, id: &str) -> Result<()> { - query!(self, delete_one_by_id, COL, id).map(|_| ()) - } - /// Fetch bots owned by a user async fn fetch_bots_by_user(&self, user_id: &str) -> Result> { query!( @@ -80,6 +56,30 @@ impl AbstractBots for MongoDb { ) .map(|v| v as usize) } + + /// Update bot with new information + async fn update_bot( + &self, + id: &str, + partial: &PartialBot, + remove: Vec, + ) -> Result<()> { + query!( + self, + update_one_by_id, + COL, + id, + partial, + remove.iter().map(|x| x as &dyn IntoDocumentPath).collect(), + None + ) + .map(|_| ()) + } + + /// Delete a bot from the database + async fn delete_bot(&self, id: &str) -> Result<()> { + query!(self, delete_one_by_id, COL, id).map(|_| ()) + } } impl IntoDocumentPath for FieldsBot { diff --git a/crates/core/database/src/models/bots/ops/reference.rs b/crates/core/database/src/models/bots/ops/reference.rs index 3a8caff2..b516dde8 100644 --- a/crates/core/database/src/models/bots/ops/reference.rs +++ b/crates/core/database/src/models/bots/ops/reference.rs @@ -7,6 +7,17 @@ use super::AbstractBots; #[async_trait] impl AbstractBots for ReferenceDb { + /// Insert new bot into the database + async fn insert_bot(&self, bot: &Bot) -> Result<()> { + let mut bots = self.bots.lock().await; + if bots.contains_key(&bot.id) { + Err(create_database_error!("insert", "bot")) + } else { + bots.insert(bot.id.to_string(), bot.clone()); + Ok(()) + } + } + /// Fetch a bot by its id async fn fetch_bot(&self, id: &str) -> Result { let bots = self.bots.lock().await; @@ -22,15 +33,20 @@ impl AbstractBots for ReferenceDb { .ok_or_else(|| create_error!(NotFound)) } - /// Insert new bot into the database - async fn insert_bot(&self, bot: &Bot) -> Result<()> { - let mut bots = self.bots.lock().await; - if bots.contains_key(&bot.id) { - Err(create_database_error!("insert", "bot")) - } else { - bots.insert(bot.id.to_string(), bot.clone()); - Ok(()) - } + /// Fetch bots owned by a user + async fn fetch_bots_by_user(&self, user_id: &str) -> Result> { + let bots = self.bots.lock().await; + Ok(bots + .values() + .filter(|bot| bot.owner == user_id) + .cloned() + .collect()) + } + + /// Get the number of bots owned by a user + async fn get_number_of_bots_by_user(&self, user_id: &str) -> Result { + let bots = self.bots.lock().await; + Ok(bots.values().filter(|bot| bot.owner == user_id).count()) } /// Update bot with new information @@ -63,20 +79,4 @@ impl AbstractBots for ReferenceDb { Err(create_error!(NotFound)) } } - - /// Fetch bots owned by a user - async fn fetch_bots_by_user(&self, user_id: &str) -> Result> { - let bots = self.bots.lock().await; - Ok(bots - .values() - .filter(|bot| bot.owner == user_id) - .cloned() - .collect()) - } - - /// Get the number of bots owned by a user - async fn get_number_of_bots_by_user(&self, user_id: &str) -> Result { - let bots = self.bots.lock().await; - Ok(bots.values().filter(|bot| bot.owner == user_id).count()) - } } diff --git a/crates/core/database/src/models/files/mod.rs b/crates/core/database/src/models/files/mod.rs index 4a7ebf60..4d801b73 100644 --- a/crates/core/database/src/models/files/mod.rs +++ b/crates/core/database/src/models/files/mod.rs @@ -1,3 +1,5 @@ mod model; +mod ops; pub use model::*; +pub use ops::*; diff --git a/crates/core/database/src/models/files/model.rs b/crates/core/database/src/models/files/model.rs index d39e1eb3..85edcaea 100644 --- a/crates/core/database/src/models/files/model.rs +++ b/crates/core/database/src/models/files/model.rs @@ -1,3 +1,7 @@ +use crate::Database; + +use revolt_result::Result; + auto_derived_partial!( /// File pub struct File { @@ -57,3 +61,47 @@ auto_derived!( Audio, } ); + +impl File { + /// Use a file for a message attachment + pub async fn use_attachment(db: &Database, id: &str, parent: &str) -> Result { + db.find_and_use_attachment(id, "attachments", "message", parent) + .await + } + + /// Use a file for a user profile background + pub async fn use_background(db: &Database, id: &str, parent: &str) -> Result { + db.find_and_use_attachment(id, "backgrounds", "user", parent) + .await + } + + /// Use a file for a user avatar + pub async fn use_avatar(db: &Database, id: &str, parent: &str) -> Result { + db.find_and_use_attachment(id, "avatars", "user", parent) + .await + } + + /// Use a file for an icon + pub async fn use_icon(db: &Database, id: &str, parent: &str) -> Result { + db.find_and_use_attachment(id, "icons", "object", parent) + .await + } + + /// Use a file for a server icon + pub async fn use_server_icon(db: &Database, id: &str, parent: &str) -> Result { + db.find_and_use_attachment(id, "icons", "object", parent) + .await + } + + /// Use a file for a server banner + pub async fn use_banner(db: &Database, id: &str, parent: &str) -> Result { + db.find_and_use_attachment(id, "banners", "server", parent) + .await + } + + /// Use a file for an emoji + pub async fn use_emoji(db: &Database, id: &str, parent: &str) -> Result { + db.find_and_use_attachment(id, "emojis", "object", parent) + .await + } +} diff --git a/crates/core/database/src/models/files/ops.rs b/crates/core/database/src/models/files/ops.rs new file mode 100644 index 00000000..55d139fc --- /dev/null +++ b/crates/core/database/src/models/files/ops.rs @@ -0,0 +1,30 @@ +use revolt_result::Result; + +use crate::File; + +mod mongodb; +mod reference; + +#[async_trait] +pub trait AbstractAttachments: Sync + Send { + /// Insert attachment into database. + async fn insert_attachment(&self, attachment: &File) -> Result<()>; + + /// Find an attachment by its details and mark it as used by a given parent. + async fn find_and_use_attachment( + &self, + id: &str, + tag: &str, + parent_type: &str, + parent_id: &str, + ) -> Result; + + /// Mark an attachment as having been reported. + async fn mark_attachment_as_reported(&self, id: &str) -> Result<()>; + + /// Mark an attachment as having been deleted. + async fn mark_attachment_as_deleted(&self, id: &str) -> Result<()>; + + /// Mark multiple attachments as having been deleted. + async fn mark_attachments_as_deleted(&self, ids: &[String]) -> Result<()>; +} diff --git a/crates/core/database/src/models/files/ops/mongodb.rs b/crates/core/database/src/models/files/ops/mongodb.rs new file mode 100644 index 00000000..a752bf0a --- /dev/null +++ b/crates/core/database/src/models/files/ops/mongodb.rs @@ -0,0 +1,117 @@ +use bson::Document; +use revolt_result::Result; + +use crate::File; +use crate::MongoDb; + +use super::AbstractAttachments; + +static COL: &str = "bots"; + +#[async_trait] +impl AbstractAttachments for MongoDb { + /// Insert attachment into database. + async fn insert_attachment(&self, attachment: &File) -> Result<()> { + query!(self, insert_one, COL, &attachment).map(|_| ()) + } + + /// Find an attachment by its details and mark it as used by a given parent. + async fn find_and_use_attachment( + &self, + id: &str, + tag: &str, + parent_type: &str, + parent_id: &str, + ) -> Result { + let key = format!("{parent_type}_id"); + let file = query!( + self, + find_one, + COL, + doc! { + "_id": id, + "tag": tag, + &key: { + "$exists": false + } + } + )? + .ok_or_else(|| create_error!(NotFound))?; + + self.col::(COL) + .update_one( + doc! { + "_id": id + }, + doc! { + "$set": { + key: parent_id + } + }, + None, + ) + .await + .map_err(|_| create_database_error!("update_one", COL))?; + + Ok(file) + } + + /// Mark an attachment as having been reported. + async fn mark_attachment_as_reported(&self, id: &str) -> Result<()> { + self.col::(COL) + .update_one( + doc! { + "_id": id + }, + doc! { + "$set": { + "reported": true + } + }, + None, + ) + .await + .map(|_| ()) + .map_err(|_| create_database_error!("update_one", COL)) + } + + /// Mark an attachment as having been deleted. + async fn mark_attachment_as_deleted(&self, id: &str) -> Result<()> { + self.col::(COL) + .update_one( + doc! { + "_id": id + }, + doc! { + "$set": { + "deleted": true + } + }, + None, + ) + .await + .map(|_| ()) + .map_err(|_| create_database_error!("update_one", COL)) + } + + /// Mark multiple attachments as having been deleted. + async fn mark_attachments_as_deleted(&self, ids: &[String]) -> Result<()> { + self.col::(COL) + .update_one( + doc! { + "_id": { + "$in": ids + } + }, + doc! { + "$set": { + "deleted": true + } + }, + None, + ) + .await + .map(|_| ()) + .map_err(|_| create_database_error!("update_one", COL)) + } +} diff --git a/crates/core/database/src/models/files/ops/reference.rs b/crates/core/database/src/models/files/ops/reference.rs new file mode 100644 index 00000000..455e6476 --- /dev/null +++ b/crates/core/database/src/models/files/ops/reference.rs @@ -0,0 +1,79 @@ +use revolt_result::Result; + +use crate::File; +use crate::ReferenceDb; + +use super::AbstractAttachments; + +#[async_trait] +impl AbstractAttachments for ReferenceDb { + /// Insert attachment into database. + async fn insert_attachment(&self, attachment: &File) -> Result<()> { + let mut attachments = self.files.lock().await; + if attachments.contains_key(&attachment.id) { + Err(create_database_error!("insert", "attachment")) + } else { + attachments.insert(attachment.id.to_string(), attachment.clone()); + Ok(()) + } + } + + /// Find an attachment by its details and mark it as used by a given parent. + async fn find_and_use_attachment( + &self, + id: &str, + _tag: &str, + _parent_type: &str, + _parent_id: &str, + ) -> Result { + let mut files = self.files.lock().await; + if let Some(file) = files.get_mut(id) { + // TODO: check tag + // TODO: set parent ID + Ok(file.clone()) + } else { + Err(create_error!(NotFound)) + } + } + + /// Mark an attachment as having been reported. + async fn mark_attachment_as_reported(&self, id: &str) -> Result<()> { + let mut files = self.files.lock().await; + if let Some(file) = files.get_mut(id) { + file.reported = Some(true); + Ok(()) + } else { + Err(create_error!(NotFound)) + } + } + + /// Mark an attachment as having been deleted. + async fn mark_attachment_as_deleted(&self, id: &str) -> Result<()> { + let mut files = self.files.lock().await; + if let Some(file) = files.get_mut(id) { + file.deleted = Some(true); + Ok(()) + } else { + Err(create_error!(NotFound)) + } + } + + /// Mark multiple attachments as having been deleted. + async fn mark_attachments_as_deleted(&self, ids: &[String]) -> Result<()> { + let mut files = self.files.lock().await; + + for id in ids { + if !files.contains_key(id) { + return Err(create_error!(NotFound)); + } + } + + for id in ids { + if let Some(file) = files.get_mut(id) { + file.reported = Some(true); + } + } + + Ok(()) + } +} diff --git a/crates/core/database/src/models/mod.rs b/crates/core/database/src/models/mod.rs index 9fb3ad3d..badc2c24 100644 --- a/crates/core/database/src/models/mod.rs +++ b/crates/core/database/src/models/mod.rs @@ -1,17 +1,34 @@ mod admin_migrations; mod bots; mod files; +mod safety_strikes; +mod server_members; +mod servers; +mod user_settings; mod users; pub use admin_migrations::*; pub use bots::*; pub use files::*; +pub use safety_strikes::*; +pub use server_members::*; +pub use servers::*; +pub use user_settings::*; pub use users::*; use crate::{Database, MongoDb, ReferenceDb}; pub trait AbstractDatabase: - Sync + Send + admin_migrations::AbstractMigrations + bots::AbstractBots + users::AbstractUsers + Sync + + Send + + admin_migrations::AbstractMigrations + + bots::AbstractBots + + files::AbstractAttachments + + safety_strikes::AbstractAccountStrikes + + server_members::AbstractServerMembers + + servers::AbstractServers + + user_settings::AbstractUserSettings + + users::AbstractUsers { } diff --git a/crates/quark/src/models/relations.drawio b/crates/core/database/src/models/relations.drawio similarity index 91% rename from crates/quark/src/models/relations.drawio rename to crates/core/database/src/models/relations.drawio index d9ff7751..d84abfd1 100644 --- a/crates/quark/src/models/relations.drawio +++ b/crates/core/database/src/models/relations.drawio @@ -1,6 +1,6 @@ - + @@ -35,18 +35,12 @@ - - - - - - - - + + @@ -54,11 +48,19 @@ + + + + + + + + - + - + @@ -66,6 +68,14 @@ + + + + + + + + @@ -161,8 +171,8 @@ - - + + @@ -199,6 +209,9 @@ + + + diff --git a/crates/core/database/src/models/safety_strikes/mod.rs b/crates/core/database/src/models/safety_strikes/mod.rs new file mode 100644 index 00000000..4d801b73 --- /dev/null +++ b/crates/core/database/src/models/safety_strikes/mod.rs @@ -0,0 +1,5 @@ +mod model; +mod ops; + +pub use model::*; +pub use ops::*; diff --git a/crates/core/database/src/models/safety_strikes/model.rs b/crates/core/database/src/models/safety_strikes/model.rs new file mode 100644 index 00000000..28d699c9 --- /dev/null +++ b/crates/core/database/src/models/safety_strikes/model.rs @@ -0,0 +1,124 @@ +use revolt_result::Result; + +use crate::Database; + +auto_derived_partial!( + /// Account Strike + pub struct AccountStrike { + /// Strike Id + #[serde(rename = "_id")] + pub id: String, + /// Id of reported user + pub user_id: String, + /// Id of moderator + pub moderator_id: String, + + /// Attached reason + pub reason: String, + }, + "PartialAccountStrike" +); + +#[allow(clippy::disallowed_methods)] +impl AccountStrike { + pub async fn create( + db: &Database, + user_id: String, + reason: String, + moderator_id: String, + ) -> Result { + let strike = AccountStrike { + id: ulid::Ulid::new().to_string(), + user_id, + moderator_id, + reason, + }; + + db.insert_account_strike(&strike).await?; + Ok(strike) + } + + /// Update this strike + pub async fn update(&mut self, db: &Database, partial: PartialAccountStrike) -> Result<()> { + db.update_account_strike(&self.id, &partial).await?; + self.apply_options(partial); + Ok(()) + } + + /// Delete this strike + pub async fn delete(&self, db: &Database) -> Result<()> { + db.delete_account_strike(&self.id).await + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use crate::{AccountStrike, PartialAccountStrike}; + + #[async_std::test] + async fn crud() { + database_test!(|db| async move { + let user_id = "user"; + + let strike = AccountStrike::create( + &db, + user_id.to_string(), + "reason 1".to_string(), + "moderator_id".to_string(), + ) + .await + .unwrap(); + + let mut updated_strike = strike.clone(); + updated_strike + .update( + &db, + PartialAccountStrike { + reason: Some("new reason".to_string()), + ..Default::default() + }, + ) + .await + .unwrap(); + + let strike2 = AccountStrike::create( + &db, + user_id.to_string(), + "reason 2".to_string(), + "moderator_id".to_string(), + ) + .await + .unwrap(); + + let strikes = db.fetch_account_strikes_by_user(user_id).await.unwrap(); + + let ids = strikes + .iter() + .cloned() + .map(|strike| strike.id) + .collect::>(); + + assert!(ids.contains(&strike.id)); + assert!(ids.contains(&strike2.id)); + + let fetched_strike = strikes + .into_iter() + .find(|strike| strike.id == strike.id) + .unwrap(); + + assert_eq!(fetched_strike, updated_strike); + assert_ne!(fetched_strike, strike); + + strike.delete(&db).await.unwrap(); + assert_eq!( + 1, + db.fetch_account_strikes_by_user(user_id) + .await + .unwrap() + .len() + ) + }); + } +} diff --git a/crates/core/database/src/models/safety_strikes/ops.rs b/crates/core/database/src/models/safety_strikes/ops.rs new file mode 100644 index 00000000..a2cdcec5 --- /dev/null +++ b/crates/core/database/src/models/safety_strikes/ops.rs @@ -0,0 +1,24 @@ +use revolt_result::Result; + +use crate::{AccountStrike, PartialAccountStrike}; + +mod mongodb; +mod reference; + +#[async_trait] +pub trait AbstractAccountStrikes: Sync + Send { + /// Insert new strike into the database + async fn insert_account_strike(&self, strike: &AccountStrike) -> Result<()>; + + /// Fetch strike by id + async fn fetch_account_strike(&self, id: &str) -> Result; + + /// Fetch strikes by user id + async fn fetch_account_strikes_by_user(&self, user_id: &str) -> Result>; + + /// Update strike with new information + async fn update_account_strike(&self, id: &str, partial: &PartialAccountStrike) -> Result<()>; + + /// Delete a strike from the database + async fn delete_account_strike(&self, id: &str) -> Result<()>; +} diff --git a/crates/core/database/src/models/safety_strikes/ops/mongodb.rs b/crates/core/database/src/models/safety_strikes/ops/mongodb.rs new file mode 100644 index 00000000..e6903b3f --- /dev/null +++ b/crates/core/database/src/models/safety_strikes/ops/mongodb.rs @@ -0,0 +1,55 @@ +use futures::StreamExt; +use revolt_result::Result; + +use crate::MongoDb; +use crate::{AccountStrike, PartialAccountStrike}; + +use super::AbstractAccountStrikes; + +static COL: &str = "safety_strikes"; + +#[async_trait] +impl AbstractAccountStrikes for MongoDb { + /// Insert new strike into the database + async fn insert_account_strike(&self, strike: &AccountStrike) -> Result<()> { + query!(self, insert_one, COL, &strike).map(|_| ()) + } + + /// Fetch strike by id + async fn fetch_account_strike(&self, id: &str) -> Result { + query!(self, find_one_by_id, COL, id)?.ok_or_else(|| create_error!(NotFound)) + } + + /// Fetch strikes by user id + async fn fetch_account_strikes_by_user(&self, user_id: &str) -> Result> { + Ok(self + .col::(COL) + .find( + doc! { + "user_id": user_id, + }, + None, + ) + .await + .map_err(|_| create_database_error!("find", COL))? + .filter_map(|s| async { + if cfg!(debug_assertions) { + Some(s.unwrap()) + } else { + s.ok() + } + }) + .collect() + .await) + } + + /// Update strike with new information + async fn update_account_strike(&self, id: &str, partial: &PartialAccountStrike) -> Result<()> { + query!(self, update_one_by_id, COL, id, partial, vec![], None).map(|_| ()) + } + + /// Delete a strike from the database + async fn delete_account_strike(&self, id: &str) -> Result<()> { + query!(self, delete_one_by_id, COL, id).map(|_| ()) + } +} diff --git a/crates/core/database/src/models/safety_strikes/ops/reference.rs b/crates/core/database/src/models/safety_strikes/ops/reference.rs new file mode 100644 index 00000000..aaf8d3d0 --- /dev/null +++ b/crates/core/database/src/models/safety_strikes/ops/reference.rs @@ -0,0 +1,60 @@ +use revolt_result::Result; + +use crate::ReferenceDb; +use crate::{AccountStrike, PartialAccountStrike}; + +use super::AbstractAccountStrikes; + +#[async_trait] +impl AbstractAccountStrikes for ReferenceDb { + /// Insert new strike into the database + async fn insert_account_strike(&self, strike: &AccountStrike) -> Result<()> { + let mut strikes = self.account_strikes.lock().await; + if strikes.contains_key(&strike.id) { + Err(create_database_error!("insert", "strike")) + } else { + strikes.insert(strike.id.to_string(), strike.clone()); + Ok(()) + } + } + + /// Fetch strike by id + async fn fetch_account_strike(&self, id: &str) -> Result { + let strikes = self.account_strikes.lock().await; + strikes + .get(id) + .cloned() + .ok_or_else(|| create_error!(NotFound)) + } + + /// Fetch strikes by user id + async fn fetch_account_strikes_by_user(&self, user_id: &str) -> Result> { + let strikes = self.account_strikes.lock().await; + Ok(strikes + .values() + .filter(|strike| strike.user_id == user_id) + .cloned() + .collect()) + } + + /// Update strike with new information + async fn update_account_strike(&self, id: &str, partial: &PartialAccountStrike) -> Result<()> { + let mut strikes = self.account_strikes.lock().await; + if let Some(strike) = strikes.get_mut(id) { + strike.apply_options(partial.clone()); + Ok(()) + } else { + Err(create_error!(NotFound)) + } + } + + /// Delete a strike from the database + async fn delete_account_strike(&self, id: &str) -> Result<()> { + let mut strikes = self.account_strikes.lock().await; + if strikes.remove(id).is_some() { + Ok(()) + } else { + Err(create_error!(NotFound)) + } + } +} diff --git a/crates/core/database/src/models/server_members/mod.rs b/crates/core/database/src/models/server_members/mod.rs new file mode 100644 index 00000000..4d801b73 --- /dev/null +++ b/crates/core/database/src/models/server_members/mod.rs @@ -0,0 +1,5 @@ +mod model; +mod ops; + +pub use model::*; +pub use ops::*; diff --git a/crates/core/database/src/models/server_members/model.rs b/crates/core/database/src/models/server_members/model.rs new file mode 100644 index 00000000..629ffac4 --- /dev/null +++ b/crates/core/database/src/models/server_members/model.rs @@ -0,0 +1,118 @@ +use iso8601_timestamp::Timestamp; +use revolt_result::Result; + +use crate::{Database, File, Server}; + +auto_derived_partial!( + /// Server Member + pub struct Member { + /// Unique member id + #[serde(rename = "_id")] + pub id: MemberCompositeKey, + + /// Time at which this user joined the server + #[serde(skip_serializing_if = "Option::is_none")] + pub joined_at: Option, + + /// Member's nickname + #[serde(skip_serializing_if = "Option::is_none")] + pub nickname: Option, + /// Avatar attachment + #[serde(skip_serializing_if = "Option::is_none")] + pub avatar: Option, + + /// Member's roles + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub roles: Vec, + /// Timestamp this member is timed out until + #[serde(skip_serializing_if = "Option::is_none")] + pub timeout: Option, + }, + "PartialMember" +); + +auto_derived!( + /// Composite primary key consisting of server and user id + #[derive(Hash, Default)] + pub struct MemberCompositeKey { + /// Server Id + pub server: String, + /// User Id + pub user: String, + } + + /// Optional fields on server member object + pub enum FieldsMember { + Nickname, + Avatar, + Roles, + Timeout, + } + + /// Member removal intention + pub enum RemovalIntention { + Leave, + Kick, + Ban, + } +); + +impl Member { + /// Update member data + pub async fn update<'a>( + &mut self, + db: &Database, + partial: PartialMember, + remove: Vec, + ) -> Result<()> { + for field in &remove { + self.remove_field(field); + } + + self.apply_options(partial.clone()); + + db.update_member(&self.id, &partial, remove.clone()).await?; + + /* // TODO: EventV1::ServerMemberUpdate { + id: self.id.clone(), + data: partial, + clear: remove, + } + .p(self.id.server.clone()) + .await; */ + + Ok(()) + } + + pub fn remove_field(&mut self, field: &FieldsMember) { + match field { + FieldsMember::Avatar => self.avatar = None, + FieldsMember::Nickname => self.nickname = None, + FieldsMember::Roles => self.roles.clear(), + FieldsMember::Timeout => self.timeout = None, + } + } + + /// Get this user's current ranking + pub fn get_ranking(&self, server: &Server) -> i64 { + let mut value = i64::MAX; + for role in &self.roles { + if let Some(role) = server.roles.get(role) { + if role.rank < value { + value = role.rank; + } + } + } + + value + } + + /// Check whether this member is in timeout + pub fn in_timeout(&self) -> bool { + if let Some(timeout) = self.timeout { + *timeout > *Timestamp::now_utc() + } else { + false + } + } +} diff --git a/crates/core/database/src/models/server_members/ops.rs b/crates/core/database/src/models/server_members/ops.rs new file mode 100644 index 00000000..369f43e2 --- /dev/null +++ b/crates/core/database/src/models/server_members/ops.rs @@ -0,0 +1,41 @@ +use revolt_result::Result; + +use crate::{FieldsMember, Member, MemberCompositeKey, PartialMember}; + +mod mongodb; +mod reference; + +#[async_trait] +pub trait AbstractServerMembers: Sync + Send { + /// Insert a new server member into the database + async fn insert_member(&self, member: &Member) -> Result<()>; + + /// Fetch a server member by their id + async fn fetch_member(&self, server_id: &str, user_id: &str) -> Result; + + /// Fetch all members in a server + async fn fetch_all_members<'a>(&self, server_id: &str) -> Result>; + + /// Fetch all memberships for a user + async fn fetch_all_memberships<'a>(&self, user_id: &str) -> Result>; + + /// Fetch multiple members by their ids + async fn fetch_members<'a>(&self, server_id: &str, ids: &'a [String]) -> Result>; + + /// Fetch member count of a server + async fn fetch_member_count(&self, server_id: &str) -> Result; + + /// Fetch server count of a user + async fn fetch_server_count(&self, user_id: &str) -> Result; + + /// Update information for a server member + async fn update_member( + &self, + id: &MemberCompositeKey, + partial: &PartialMember, + remove: Vec, + ) -> Result<()>; + + /// Delete a server member by their id + async fn delete_member(&self, id: &MemberCompositeKey) -> Result<()>; +} diff --git a/crates/core/database/src/models/server_members/ops/mongodb.rs b/crates/core/database/src/models/server_members/ops/mongodb.rs new file mode 100644 index 00000000..8ab52679 --- /dev/null +++ b/crates/core/database/src/models/server_members/ops/mongodb.rs @@ -0,0 +1,178 @@ +use futures::StreamExt; +use revolt_result::Result; + +use crate::{FieldsMember, Member, MemberCompositeKey, PartialMember}; +use crate::{IntoDocumentPath, MongoDb}; + +use super::AbstractServerMembers; + +static COL: &str = "server_members"; + +#[async_trait] +impl AbstractServerMembers for MongoDb { + /// Insert a new server member into the database + async fn insert_member(&self, member: &Member) -> Result<()> { + query!(self, insert_one, COL, &member).map(|_| ()) + } + + /// Fetch a server member by their id + async fn fetch_member(&self, server_id: &str, user_id: &str) -> Result { + query!( + self, + find_one, + COL, + doc! { + "_id.server": server_id, + "_id.user": user_id + } + )? + .ok_or_else(|| create_error!(NotFound)) + } + + /// Fetch all members in a server + async fn fetch_all_members<'a>(&self, server_id: &str) -> Result> { + Ok(self + .col::(COL) + .find( + doc! { + "_id.server": server_id + }, + None, + ) + .await + .map_err(|_| create_database_error!("find", COL))? + .filter_map(|s| async { + if cfg!(debug_assertions) { + Some(s.unwrap()) + } else { + s.ok() + } + }) + .collect() + .await) + } + + /// Fetch all memberships for a user + async fn fetch_all_memberships<'a>(&self, user_id: &str) -> Result> { + Ok(self + .col::(COL) + .find( + doc! { + "_id.user": user_id + }, + None, + ) + .await + .map_err(|_| create_database_error!("find", COL))? + .filter_map(|s| async { + if cfg!(debug_assertions) { + Some(s.unwrap()) + } else { + s.ok() + } + }) + .collect() + .await) + } + + /// Fetch multiple members by their ids + async fn fetch_members<'a>(&self, server_id: &str, ids: &'a [String]) -> Result> { + Ok(self + .col::(COL) + .find( + doc! { + "_id.server": server_id, + "_id.user": { + "$in": ids + } + }, + None, + ) + .await + .map_err(|_| create_database_error!("find", COL))? + .filter_map(|s| async { + if cfg!(debug_assertions) { + Some(s.unwrap()) + } else { + s.ok() + } + }) + .collect() + .await) + } + + /// Fetch member count of a server + async fn fetch_member_count(&self, server_id: &str) -> Result { + self.col::(COL) + .count_documents( + doc! { + "_id.server": server_id + }, + None, + ) + .await + .map(|c| c as usize) + .map_err(|_| create_database_error!("count_documents", COL)) + } + + /// Fetch server count of a user + async fn fetch_server_count(&self, user_id: &str) -> Result { + self.col::(COL) + .count_documents( + doc! { + "_id.user": user_id + }, + None, + ) + .await + .map(|c| c as usize) + .map_err(|_| create_database_error!("count_documents", COL)) + } + + /// Update information for a server member + async fn update_member( + &self, + id: &MemberCompositeKey, + partial: &PartialMember, + remove: Vec, + ) -> Result<()> { + query!( + self, + update_one, + COL, + doc! { + "_id.server": &id.server, + "_id.user": &id.user + }, + partial, + remove.iter().map(|x| x as &dyn IntoDocumentPath).collect(), + None + ) + .map(|_| ()) + } + + /// Delete a server member by their id + async fn delete_member(&self, id: &MemberCompositeKey) -> Result<()> { + query!( + self, + delete_one, + COL, + doc! { + "_id.server": &id.server, + "_id.user": &id.user + } + ) + .map(|_| ()) + } +} + +impl IntoDocumentPath for FieldsMember { + fn as_path(&self) -> Option<&'static str> { + Some(match self { + FieldsMember::Avatar => "avatar", + FieldsMember::Nickname => "nickname", + FieldsMember::Roles => "roles", + FieldsMember::Timeout => "timeout", + }) + } +} diff --git a/crates/core/database/src/models/server_members/ops/reference.rs b/crates/core/database/src/models/server_members/ops/reference.rs new file mode 100644 index 00000000..88c9c7b2 --- /dev/null +++ b/crates/core/database/src/models/server_members/ops/reference.rs @@ -0,0 +1,117 @@ +use revolt_result::Result; + +use crate::ReferenceDb; +use crate::{FieldsMember, Member, MemberCompositeKey, PartialMember}; + +use super::AbstractServerMembers; + +#[async_trait] +impl AbstractServerMembers for ReferenceDb { + /// Insert a new server member into the database + async fn insert_member(&self, member: &Member) -> Result<()> { + let mut server_members = self.server_members.lock().await; + if server_members.contains_key(&member.id) { + Err(create_database_error!("insert", "member")) + } else { + server_members.insert(member.id.clone(), member.clone()); + Ok(()) + } + } + + /// Fetch a server member by their id + async fn fetch_member(&self, server_id: &str, user_id: &str) -> Result { + let server_members = self.server_members.lock().await; + server_members + .get(&MemberCompositeKey { + server: server_id.to_string(), + user: user_id.to_string(), + }) + .cloned() + .ok_or_else(|| create_error!(NotFound)) + } + + /// Fetch all members in a server + async fn fetch_all_members<'a>(&self, server_id: &str) -> Result> { + let server_members = self.server_members.lock().await; + Ok(server_members + .values() + .filter(|member| member.id.server == server_id) + .cloned() + .collect()) + } + + /// Fetch all memberships for a user + async fn fetch_all_memberships<'a>(&self, user_id: &str) -> Result> { + let server_members = self.server_members.lock().await; + Ok(server_members + .values() + .filter(|member| member.id.user == user_id) + .cloned() + .collect()) + } + + /// Fetch multiple members by their ids + async fn fetch_members<'a>(&self, server_id: &str, ids: &'a [String]) -> Result> { + let server_members = self.server_members.lock().await; + ids.iter() + .map(|id| { + server_members + .get(&MemberCompositeKey { + server: server_id.to_string(), + user: id.to_string(), + }) + .cloned() + .ok_or_else(|| create_error!(NotFound)) + }) + .collect() + } + + /// Fetch member count of a server + async fn fetch_member_count(&self, server_id: &str) -> Result { + let server_members = self.server_members.lock().await; + Ok(server_members + .values() + .filter(|member| member.id.server == server_id) + .count()) + } + + /// Fetch server count of a user + async fn fetch_server_count(&self, user_id: &str) -> Result { + let server_members = self.server_members.lock().await; + Ok(server_members + .values() + .filter(|member| member.id.user == user_id) + .count()) + } + + /// Update information for a server member + async fn update_member( + &self, + id: &MemberCompositeKey, + partial: &PartialMember, + remove: Vec, + ) -> Result<()> { + let mut server_members = self.server_members.lock().await; + if let Some(member) = server_members.get_mut(id) { + for field in remove { + #[allow(clippy::disallowed_methods)] + member.remove_field(&field); + } + + member.apply_options(partial.clone()); + Ok(()) + } else { + Err(create_error!(NotFound)) + } + } + + /// Delete a server member by their id + async fn delete_member(&self, id: &MemberCompositeKey) -> Result<()> { + let mut server_members = self.server_members.lock().await; + if server_members.remove(id).is_some() { + Ok(()) + } else { + Err(create_error!(NotFound)) + } + } +} diff --git a/crates/core/database/src/models/servers/mod.rs b/crates/core/database/src/models/servers/mod.rs new file mode 100644 index 00000000..4d801b73 --- /dev/null +++ b/crates/core/database/src/models/servers/mod.rs @@ -0,0 +1,5 @@ +mod model; +mod ops; + +pub use model::*; +pub use ops::*; diff --git a/crates/core/database/src/models/servers/model.rs b/crates/core/database/src/models/servers/model.rs new file mode 100644 index 00000000..a650509d --- /dev/null +++ b/crates/core/database/src/models/servers/model.rs @@ -0,0 +1,451 @@ +use std::collections::{HashMap, HashSet}; + +use revolt_permissions::OverrideField; +use revolt_result::Result; +use ulid::Ulid; + +use crate::{Database, File}; + +auto_derived_partial!( + /// Server + pub struct Server { + /// Unique Id + #[serde(rename = "_id")] + pub id: String, + /// User id of the owner + pub owner: String, + + /// Name of the server + pub name: String, + /// Description for the server + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + /// Channels within this server + // ! FIXME: this may be redundant + pub channels: Vec, + /// Categories for this server + #[serde(skip_serializing_if = "Option::is_none")] + pub categories: Option>, + /// Configuration for sending system event messages + #[serde(skip_serializing_if = "Option::is_none")] + pub system_messages: Option, + + /// Roles for this server + #[serde( + default = "HashMap::::new", + skip_serializing_if = "HashMap::::is_empty" + )] + pub roles: HashMap, + /// Default set of server and channel permissions + pub default_permissions: i64, + + /// Icon attachment + #[serde(skip_serializing_if = "Option::is_none")] + pub icon: Option, + /// Banner attachment + #[serde(skip_serializing_if = "Option::is_none")] + pub banner: Option, + + /// Bitfield of server flags + #[serde(skip_serializing_if = "Option::is_none")] + pub flags: Option, + + /// Whether this server is flagged as not safe for work + #[serde(skip_serializing_if = "crate::if_false", default)] + pub nsfw: bool, + /// Whether to enable analytics + #[serde(skip_serializing_if = "crate::if_false", default)] + pub analytics: bool, + /// Whether this server should be publicly discoverable + #[serde(skip_serializing_if = "crate::if_false", default)] + pub discoverable: bool, + }, + "PartialServer" +); + +auto_derived_partial!( + /// Role + pub struct Role { + /// Role name + pub name: String, + /// Permissions available to this role + pub permissions: OverrideField, + /// Colour used for this role + /// + /// This can be any valid CSS colour + #[serde(skip_serializing_if = "Option::is_none")] + pub colour: Option, + /// Whether this role should be shown separately on the member sidebar + #[serde(skip_serializing_if = "crate::if_false", default)] + pub hoist: bool, + /// Ranking of this role + #[serde(default)] + pub rank: i64, + }, + "PartialRole" +); + +auto_derived!( + /// Channel category + pub struct Category { + /// Unique ID for this category + pub id: String, + /// Title for this category + pub title: String, + /// Channels in this category + pub channels: Vec, + } + + /// System message channel assignments + pub struct SystemMessageChannels { + /// ID of channel to send user join messages in + #[serde(skip_serializing_if = "Option::is_none")] + pub user_joined: Option, + /// ID of channel to send user left messages in + #[serde(skip_serializing_if = "Option::is_none")] + pub user_left: Option, + /// ID of channel to send user kicked messages in + #[serde(skip_serializing_if = "Option::is_none")] + pub user_kicked: Option, + /// ID of channel to send user banned messages in + #[serde(skip_serializing_if = "Option::is_none")] + pub user_banned: Option, + } + + /// Optional fields on server object + pub enum FieldsServer { + Description, + Categories, + SystemMessages, + Icon, + Banner, + } + + /// Optional fields on server object + pub enum FieldsRole { + Colour, + } +); + +impl Server { + /// Create a server + pub async fn create(&self, db: &Database) -> Result<()> { + db.insert_server(self).await + } + + /// Update server data + pub async fn update<'a>( + &mut self, + db: &Database, + partial: PartialServer, + remove: Vec, + ) -> Result<()> { + for field in &remove { + self.remove_field(field); + } + + self.apply_options(partial.clone()); + + db.update_server(&self.id, &partial, remove.clone()).await?; + + /* // TODO: EventV1::ServerUpdate { + id: self.id.clone(), + data: partial, + clear: remove, + } + .p(self.id.clone()) + .await; */ + + Ok(()) + } + + /// Delete a server + pub async fn delete(self, db: &Database) -> Result<()> { + /* // TODO: EventV1::ServerDelete { + id: self.id.clone(), + } + .p(self.id.clone()) + .await; */ + + db.delete_server(&self.id).await + } + + /// Remove a field from Server + pub fn remove_field(&mut self, field: &FieldsServer) { + match field { + FieldsServer::Description => self.description = None, + FieldsServer::Categories => self.categories = None, + FieldsServer::SystemMessages => self.system_messages = None, + FieldsServer::Icon => self.icon = None, + FieldsServer::Banner => self.banner = None, + } + } + + /// Set role permission on a server + pub async fn set_role_permission( + &mut self, + db: &Database, + role_id: &str, + permissions: OverrideField, + ) -> Result<()> { + if let Some(role) = self.roles.get_mut(role_id) { + role.update( + db, + &self.id, + role_id, + PartialRole { + permissions: Some(permissions), + ..Default::default() + }, + vec![], + ) + .await?; + + Ok(()) + } else { + Err(create_error!(NotFound)) + } + } + + /* /// Create a new member in a server + pub async fn create_member( + &self, + db: &Database, + user: User, + channels: Option>, + ) -> Result> { + if db.fetch_ban(&self.id, &user.id).await.is_ok() { + return Err(Error::Banned); + } + + let member = Member { + id: MemberCompositeKey { + server: self.id.clone(), + user: user.id.clone(), + }, + joined_at: Timestamp::now_utc(), + nickname: None, + avatar: None, + roles: vec![], + timeout: None, + }; + + db.insert_member(&member).await?; + + let should_fetch = channels.is_none(); + let mut channels = channels.unwrap_or_default(); + + if should_fetch { + let perm = perms(&user).server(self).member(&member); + let existing_channels = db.fetch_channels(&self.channels).await?; + for channel in existing_channels { + if perm + .clone() + .channel(&channel) + .has_permission(db, Permission::ViewChannel) + .await? + { + channels.push(channel); + } + } + } + + /* // TODO: EventV1::ServerMemberJoin { + id: self.id.clone(), + user: user.id.clone(), + } + .p(self.id.clone()) + .await; + + EventV1::ServerCreate { + id: self.id.clone(), + server: self.clone(), + channels: channels.clone(), + } + .private(user.id.clone()) + .await; */ + + if let Some(id) = self + .system_messages + .as_ref() + .and_then(|x| x.user_joined.as_ref()) + { + SystemMessage::UserJoined { + id: user.id.clone(), + } + .into_message(id.to_string()) + .create_no_web_push(db, id, false) + .await + .ok(); + } + + Ok(channels) + } + + /// Remove a member from a server + pub async fn remove_member( + &self, + db: &Database, + member: Member, + intention: RemovalIntention, + silent: bool, + ) -> Result<()> { + db.delete_member(&member.id).await?; + + /* // TODO: EventV1::ServerMemberLeave { + id: self.id.to_string(), + user: member.id.user.clone(), + } + .p(member.id.server) + .await; */ + + if !silent { + if let Some(id) = self.system_messages.as_ref().and_then(|x| match intention { + RemovalIntention::Leave => x.user_left.as_ref(), + RemovalIntention::Kick => x.user_kicked.as_ref(), + RemovalIntention::Ban => x.user_banned.as_ref(), + }) { + match intention { + RemovalIntention::Leave => SystemMessage::UserLeft { id: member.id.user }, + RemovalIntention::Kick => SystemMessage::UserKicked { id: member.id.user }, + RemovalIntention::Ban => SystemMessage::UserBanned { id: member.id.user }, + } + .into_message(id.to_string()) + .create_no_web_push(db, id, false) + .await + .ok(); + } + } + + Ok(()) + } + + /// Create ban + pub async fn ban_user( + self, + db: &Database, + id: MemberCompositeKey, + reason: Option, + ) -> Result { + let ban = ServerBan { id, reason }; + db.insert_ban(&ban).await?; + Ok(ban) + } + + /// Ban a member from a server + pub async fn ban_member( + self, + db: &Database, + member: Member, + reason: Option, + ) -> Result { + self.remove_member(db, member.clone(), RemovalIntention::Ban, false) + .await?; + + self.ban_user(db, member.id, reason).await + } */ +} + +impl Role { + /// Into optional struct + pub fn into_optional(self) -> PartialRole { + PartialRole { + name: Some(self.name), + permissions: Some(self.permissions), + colour: self.colour, + hoist: Some(self.hoist), + rank: Some(self.rank), + } + } + + /// Create a role + pub async fn create(&self, db: &Database, server_id: &str) -> Result { + let role_id = Ulid::new().to_string(); + db.insert_role(server_id, &role_id, self).await?; + + /* // TODO: EventV1::ServerRoleUpdate { + id: server_id.to_string(), + role_id: role_id.to_string(), + data: self.clone().into_optional(), + clear: vec![], + } + .p(server_id.to_string()) + .await; */ + + Ok(role_id) + } + + /// Update server data + pub async fn update<'a>( + &mut self, + db: &Database, + server_id: &str, + role_id: &str, + partial: PartialRole, + remove: Vec, + ) -> Result<()> { + for field in &remove { + self.remove_field(field); + } + + self.apply_options(partial.clone()); + + db.update_role(server_id, role_id, &partial, remove.clone()) + .await?; + + /* // TODO: EventV1::ServerRoleUpdate { + id: server_id.to_string(), + role_id: role_id.to_string(), + data: partial, + clear: vec![], + } + .p(server_id.to_string()) + .await; */ + + Ok(()) + } + + /// Remove field from Role + pub fn remove_field(&mut self, field: &FieldsRole) { + match field { + FieldsRole::Colour => self.colour = None, + } + } + + /// Delete a role + pub async fn delete(self, db: &Database, server_id: &str, role_id: &str) -> Result<()> { + /* // TODO: EventV1::ServerRoleDelete { + id: server_id.to_string(), + role_id: role_id.to_string(), + } + .p(server_id.to_string()) + .await; */ + + db.delete_role(server_id, role_id).await + } +} + +impl SystemMessageChannels { + pub fn into_channel_ids(self) -> HashSet { + let mut ids = HashSet::new(); + + if let Some(id) = self.user_joined { + ids.insert(id); + } + + if let Some(id) = self.user_left { + ids.insert(id); + } + + if let Some(id) = self.user_kicked { + ids.insert(id); + } + + if let Some(id) = self.user_banned { + ids.insert(id); + } + + ids + } +} diff --git a/crates/core/database/src/models/servers/ops.rs b/crates/core/database/src/models/servers/ops.rs new file mode 100644 index 00000000..6c58773a --- /dev/null +++ b/crates/core/database/src/models/servers/ops.rs @@ -0,0 +1,46 @@ +use revolt_result::Result; + +use crate::{FieldsRole, FieldsServer, PartialRole, PartialServer, Role, Server}; + +mod mongodb; +mod reference; + +#[async_trait] +pub trait AbstractServers: Sync + Send { + /// Insert a new server into database + async fn insert_server(&self, server: &Server) -> Result<()>; + + /// Fetch a server by its id + async fn fetch_server(&self, id: &str) -> Result; + + /// Fetch a servers by their ids + async fn fetch_servers<'a>(&self, ids: &'a [String]) -> Result>; + + /// Update a server with new information + async fn update_server( + &self, + id: &str, + partial: &PartialServer, + remove: Vec, + ) -> Result<()>; + + /// Delete a server by its id + async fn delete_server(&self, id: &str) -> Result<()>; + + /// Insert a new role into server object + async fn insert_role(&self, server_id: &str, role_id: &str, role: &Role) -> Result<()>; + + /// Update an existing role on a server + async fn update_role( + &self, + server_id: &str, + role_id: &str, + partial: &PartialRole, + remove: Vec, + ) -> Result<()>; + + /// Delete a role from a server + /// + /// Also updates channels and members. + async fn delete_role(&self, server_id: &str, role_id: &str) -> Result<()>; +} diff --git a/crates/core/database/src/models/servers/ops/mongodb.rs b/crates/core/database/src/models/servers/ops/mongodb.rs new file mode 100644 index 00000000..0c679e5f --- /dev/null +++ b/crates/core/database/src/models/servers/ops/mongodb.rs @@ -0,0 +1,183 @@ +use bson::{to_document, Document}; +use futures::StreamExt; +use revolt_result::Result; + +use crate::{FieldsRole, FieldsServer, PartialRole, PartialServer, Role, Server}; +use crate::{IntoDocumentPath, MongoDb}; + +use super::AbstractServers; + +static COL: &str = "servers"; + +#[async_trait] +impl AbstractServers for MongoDb { + /// Insert a new server into database + async fn insert_server(&self, server: &Server) -> Result<()> { + query!(self, insert_one, COL, &server).map(|_| ()) + } + + /// Fetch a server by its id + async fn fetch_server(&self, id: &str) -> Result { + query!(self, find_one_by_id, COL, id)?.ok_or_else(|| create_error!(NotFound)) + } + + /// Fetch a servers by their ids + async fn fetch_servers<'a>(&self, ids: &'a [String]) -> Result> { + Ok(self + .col::(COL) + .find( + doc! { + "_id": { + "$in": ids + } + }, + None, + ) + .await + .map_err(|_| create_database_error!("find", "servers"))? + .filter_map(|s| async { + if cfg!(debug_assertions) { + Some(s.unwrap()) + } else { + s.ok() + } + }) + .collect() + .await) + } + + /// Update a server with new information + async fn update_server( + &self, + id: &str, + partial: &PartialServer, + remove: Vec, + ) -> Result<()> { + query!( + self, + update_one_by_id, + COL, + id, + partial, + remove.iter().map(|x| x as &dyn IntoDocumentPath).collect(), + None + ) + .map(|_| ()) + } + + /// Delete a server by its id + async fn delete_server(&self, id: &str) -> Result<()> { + query!(self, delete_one_by_id, COL, id).map(|_| ()) + } + + /// Insert a new role into server object + async fn insert_role(&self, server_id: &str, role_id: &str, role: &Role) -> Result<()> { + self.col::(COL) + .update_one( + doc! { + "_id": server_id + }, + doc! { + "$set": { + "roles.".to_owned() + role_id: to_document(role) + .map_err(|_| create_database_error!("to_document", "role"))? + } + }, + None, + ) + .await + .map(|_| ()) + .map_err(|_| create_database_error!("update_one", "server")) + } + + /// Update an existing role on a server + async fn update_role( + &self, + server_id: &str, + role_id: &str, + partial: &PartialRole, + remove: Vec, + ) -> Result<()> { + query!( + self, + update_one_by_id, + COL, + server_id, + partial, + remove.iter().map(|x| x as &dyn IntoDocumentPath).collect(), + "roles.".to_owned() + role_id + "." + ) + .map(|_| ()) + } + + /// Delete a role from a server + /// + /// Also updates channels and members. + async fn delete_role(&self, server_id: &str, role_id: &str) -> Result<()> { + self.col::("server_members") + .update_many( + doc! { + "_id.server": server_id + }, + doc! { + "$pull": { + "roles": &role_id + } + }, + None, + ) + .await + .map_err(|_| create_database_error!("update_many", "server_members"))?; + + self.col::("channels") + .update_one( + doc! { + "server": server_id + }, + doc! { + "$unset": { + "role_permissions.".to_owned() + role_id: 1_i32 + } + }, + None, + ) + .await + .map_err(|_| create_database_error!("update_one", "channels"))?; + + self.col::("servers") + .update_one( + doc! { + "_id": server_id + }, + doc! { + "$unset": { + "roles.".to_owned() + role_id: 1_i32 + } + }, + None, + ) + .await + .map(|_| ()) + .map_err(|_| create_database_error!("update_one", "servers")) + } +} + +impl IntoDocumentPath for FieldsServer { + fn as_path(&self) -> Option<&'static str> { + Some(match self { + FieldsServer::Banner => "banner", + FieldsServer::Categories => "categories", + FieldsServer::Description => "description", + FieldsServer::Icon => "icon", + FieldsServer::SystemMessages => "system_messages", + }) + } +} + +impl IntoDocumentPath for FieldsRole { + fn as_path(&self) -> Option<&'static str> { + Some(match self { + FieldsRole::Colour => "colour", + }) + } +} diff --git a/crates/core/database/src/models/servers/ops/reference.rs b/crates/core/database/src/models/servers/ops/reference.rs new file mode 100644 index 00000000..18ee1b38 --- /dev/null +++ b/crates/core/database/src/models/servers/ops/reference.rs @@ -0,0 +1,126 @@ +use revolt_result::Result; + +use crate::ReferenceDb; +use crate::{FieldsRole, FieldsServer, PartialRole, PartialServer, Role, Server}; + +use super::AbstractServers; + +#[async_trait] +impl AbstractServers for ReferenceDb { + /// Insert a new server into database + async fn insert_server(&self, server: &Server) -> Result<()> { + let mut servers = self.servers.lock().await; + if servers.contains_key(&server.id) { + Err(create_database_error!("insert", "server")) + } else { + servers.insert(server.id.to_string(), server.clone()); + Ok(()) + } + } + + /// Fetch a server by its id + async fn fetch_server(&self, id: &str) -> Result { + let servers = self.servers.lock().await; + servers + .get(id) + .cloned() + .ok_or_else(|| create_error!(NotFound)) + } + + /// Fetch a servers by their ids + async fn fetch_servers<'a>(&self, ids: &'a [String]) -> Result> { + let servers = self.servers.lock().await; + ids.iter() + .map(|id| { + servers + .get(id) + .cloned() + .ok_or_else(|| create_error!(NotFound)) + }) + .collect() + } + + /// Update a server with new information + async fn update_server( + &self, + id: &str, + partial: &PartialServer, + remove: Vec, + ) -> Result<()> { + let mut servers = self.servers.lock().await; + if let Some(server) = servers.get_mut(id) { + for field in remove { + #[allow(clippy::disallowed_methods)] + server.remove_field(&field); + } + + server.apply_options(partial.clone()); + Ok(()) + } else { + Err(create_error!(NotFound)) + } + } + + /// Delete a server by its id + async fn delete_server(&self, id: &str) -> Result<()> { + let mut servers = self.servers.lock().await; + if servers.remove(id).is_some() { + Ok(()) + } else { + Err(create_error!(NotFound)) + } + } + + /// Insert a new role into server object + async fn insert_role(&self, server_id: &str, role_id: &str, role: &Role) -> Result<()> { + let mut servers = self.servers.lock().await; + if let Some(server) = servers.get_mut(server_id) { + server.roles.insert(role_id.to_string(), role.clone()); + Ok(()) + } else { + Err(create_error!(NotFound)) + } + } + + /// Update an existing role on a server + async fn update_role( + &self, + server_id: &str, + role_id: &str, + partial: &PartialRole, + remove: Vec, + ) -> Result<()> { + let mut servers = self.servers.lock().await; + if let Some(server) = servers.get_mut(server_id) { + if let Some(role) = server.roles.get_mut(role_id) { + for field in remove { + #[allow(clippy::disallowed_methods)] + role.remove_field(&field); + } + + role.apply_options(partial.clone()); + Ok(()) + } else { + Err(create_error!(NotFound)) + } + } else { + Err(create_error!(NotFound)) + } + } + + /// Delete a role from a server + /// + /// Also updates channels and members. + async fn delete_role(&self, server_id: &str, role_id: &str) -> Result<()> { + let mut servers = self.servers.lock().await; + if let Some(server) = servers.get_mut(server_id) { + if server.roles.remove(role_id).is_some() { + Ok(()) + } else { + Err(create_error!(NotFound)) + } + } else { + Err(create_error!(NotFound)) + } + } +} diff --git a/crates/core/database/src/models/user_settings/mod.rs b/crates/core/database/src/models/user_settings/mod.rs new file mode 100644 index 00000000..4d801b73 --- /dev/null +++ b/crates/core/database/src/models/user_settings/mod.rs @@ -0,0 +1,5 @@ +mod model; +mod ops; + +pub use model::*; +pub use ops::*; diff --git a/crates/core/database/src/models/user_settings/model.rs b/crates/core/database/src/models/user_settings/model.rs new file mode 100644 index 00000000..1780f6a2 --- /dev/null +++ b/crates/core/database/src/models/user_settings/model.rs @@ -0,0 +1,28 @@ +use std::collections::HashMap; + +use crate::Database; + +use revolt_result::Result; + +pub type UserSettings = HashMap; + +#[async_trait] +pub trait UserSettingsImpl { + async fn set(self, db: &Database, user: &str) -> Result<()>; +} + +#[async_trait] +impl UserSettingsImpl for UserSettings { + async fn set(self, db: &Database, user: &str) -> Result<()> { + db.set_user_settings(user, &self).await?; + + /* // TODO: EventV1::UserSettingsUpdate { + id: user.to_string(), + update: self, + } + .private(user.to_string()) + .await; */ + + Ok(()) + } +} diff --git a/crates/core/database/src/models/user_settings/ops.rs b/crates/core/database/src/models/user_settings/ops.rs new file mode 100644 index 00000000..2dd6bb69 --- /dev/null +++ b/crates/core/database/src/models/user_settings/ops.rs @@ -0,0 +1,18 @@ +use revolt_result::Result; + +use crate::UserSettings; + +mod mongodb; +mod reference; + +#[async_trait] +pub trait AbstractUserSettings: Sync + Send { + /// Fetch a subset of user settings + async fn fetch_user_settings(&'_ self, id: &str, filter: &'_ [String]) -> Result; + + /// Update a subset of user settings + async fn set_user_settings(&self, id: &str, settings: &UserSettings) -> Result<()>; + + /// Delete all user settings + async fn delete_user_settings(&self, id: &str) -> Result<()>; +} diff --git a/crates/core/database/src/models/user_settings/ops/mongodb.rs b/crates/core/database/src/models/user_settings/ops/mongodb.rs new file mode 100644 index 00000000..5de98832 --- /dev/null +++ b/crates/core/database/src/models/user_settings/ops/mongodb.rs @@ -0,0 +1,67 @@ +use ::mongodb::options::FindOneOptions; +use bson::to_bson; +use bson::Document; +use mongodb::options::UpdateOptions; +use revolt_result::Result; + +use crate::MongoDb; +use crate::UserSettings; + +use super::AbstractUserSettings; + +static COL: &str = "user_settings"; + +#[async_trait] +impl AbstractUserSettings for MongoDb { + /// Fetch a subset of user settings + async fn fetch_user_settings(&'_ self, id: &str, filter: &'_ [String]) -> Result { + let mut projection = doc! { + "_id": 0, + }; + + for key in filter { + projection.insert(key, 1); + } + + Ok(query!( + self, + find_one_with_options, + COL, + doc! { + "_id": id + }, + FindOneOptions::builder().projection(projection).build() + )? + .unwrap_or_default()) + } + + /// Update a subset of user settings + async fn set_user_settings(&self, id: &str, settings: &UserSettings) -> Result<()> { + let mut set = doc! {}; + for (key, data) in settings { + set.insert( + key, + vec![to_bson(&data.0).unwrap(), to_bson(&data.1).unwrap()], + ); + } + + self.col::(COL) + .update_one( + doc! { + "_id": id + }, + doc! { + "$set": set + }, + UpdateOptions::builder().upsert(true).build(), + ) + .await + .map(|_| ()) + .map_err(|_| create_database_error!("update_one", "user_settings")) + } + + /// Delete all user settings + async fn delete_user_settings(&self, id: &str) -> Result<()> { + query!(self, delete_one_by_id, COL, id).map(|_| ()) + } +} diff --git a/crates/core/database/src/models/user_settings/ops/reference.rs b/crates/core/database/src/models/user_settings/ops/reference.rs new file mode 100644 index 00000000..17d8445b --- /dev/null +++ b/crates/core/database/src/models/user_settings/ops/reference.rs @@ -0,0 +1,46 @@ +use revolt_result::Result; + +use crate::ReferenceDb; +use crate::UserSettings; + +use super::AbstractUserSettings; + +#[async_trait] +impl AbstractUserSettings for ReferenceDb { + /// Fetch a subset of user settings + async fn fetch_user_settings(&'_ self, id: &str, filter: &'_ [String]) -> Result { + let user_settings = self.user_settings.lock().await; + user_settings + .get(id) + .cloned() + .map(|settings| { + settings + .into_iter() + .filter(|(key, _)| filter.contains(key)) + .collect() + }) + .ok_or_else(|| create_error!(NotFound)) + } + + /// Update a subset of user settings + async fn set_user_settings(&self, id: &str, settings: &UserSettings) -> Result<()> { + let mut user_settings = self.user_settings.lock().await; + if let Some(settings) = user_settings.get_mut(id) { + settings.extend(settings.clone()); + } else { + user_settings.insert(id.to_string(), settings.clone()); + } + + Ok(()) + } + + /// Delete all user settings + async fn delete_user_settings(&self, id: &str) -> Result<()> { + let mut user_settings = self.user_settings.lock().await; + if user_settings.remove(id).is_some() { + Ok(()) + } else { + Err(create_error!(NotFound)) + } + } +} diff --git a/crates/core/database/src/models/users/model.rs b/crates/core/database/src/models/users/model.rs index ebdc2ecb..c70e0839 100644 --- a/crates/core/database/src/models/users/model.rs +++ b/crates/core/database/src/models/users/model.rs @@ -1,4 +1,6 @@ -use crate::File; +use crate::{Database, File}; + +use revolt_result::{Error, ErrorType, Result}; auto_derived_partial!( /// # User @@ -74,7 +76,7 @@ auto_derived!( /// User's active status pub struct UserStatus { /// Custom status text - #[serde(skip_serializing_if = "String::is_empty")] + #[serde(skip_serializing_if = "String::is_empty", default)] pub text: String, /// Current presence option #[serde(skip_serializing_if = "Option::is_none")] @@ -84,7 +86,7 @@ auto_derived!( /// User's profile pub struct UserProfile { /// Text content on user's profile - #[serde(skip_serializing_if = "String::is_empty")] + #[serde(skip_serializing_if = "String::is_empty", default)] pub content: String, /// Background visible on user's profile #[serde(skip_serializing_if = "Option::is_none")] @@ -96,4 +98,100 @@ auto_derived!( /// Id of the owner of this bot pub owner: String, } + + /// Optional fields on user object + pub enum FieldsUser { + Avatar, + StatusText, + StatusPresence, + ProfileContent, + ProfileBackground, + } ); + +impl User { + /// Check whether a username is already in use by another user + #[allow(dead_code)] + async fn is_username_taken(db: &Database, username: &str) -> Result { + match db.fetch_user_by_username(username).await { + Ok(_) => Ok(true), + Err(Error { + error_type: ErrorType::NotFound, + .. + }) => Ok(false), + Err(error) => Err(error), + } + } + + /// Update user data + pub async fn update<'a>( + &mut self, + db: &Database, + partial: PartialUser, + remove: Vec, + ) -> Result<()> { + for field in &remove { + self.remove_field(field); + } + + self.apply_options(partial.clone()); + db.update_user(&self.id, &partial, remove.clone()).await?; + + /* // TODO: EventV1::UserUpdate { + id: self.id.clone(), + data: partial, + clear: remove, + } + .p_user(self.id.clone(), db) + .await; */ + + Ok(()) + } + + /// Remove a field from User object + pub fn remove_field(&mut self, field: &FieldsUser) { + match field { + FieldsUser::Avatar => self.avatar = None, + FieldsUser::StatusText => { + if let Some(x) = self.status.as_mut() { + x.text = String::new(); + } + } + FieldsUser::StatusPresence => { + if let Some(x) = self.status.as_mut() { + x.presence = None; + } + } + FieldsUser::ProfileContent => { + if let Some(x) = self.profile.as_mut() { + x.content = String::new(); + } + } + FieldsUser::ProfileBackground => { + if let Some(x) = self.profile.as_mut() { + x.background = None; + } + } + } + } + + /// Mark as deleted + pub async fn mark_deleted(&mut self, db: &Database) -> Result<()> { + self.update( + db, + PartialUser { + username: Some(format!("Deleted User {}", self.id)), + flags: Some(2), + ..Default::default() + }, + vec![ + FieldsUser::Avatar, + FieldsUser::StatusText, + FieldsUser::StatusPresence, + FieldsUser::ProfileContent, + FieldsUser::ProfileBackground, + ], + ) + .await + } +} diff --git a/crates/core/database/src/models/users/ops.rs b/crates/core/database/src/models/users/ops.rs index ca2662ed..28421ec0 100644 --- a/crates/core/database/src/models/users/ops.rs +++ b/crates/core/database/src/models/users/ops.rs @@ -1,12 +1,57 @@ use revolt_result::Result; -use crate::User; +use crate::{FieldsUser, PartialUser, RelationshipStatus, User}; mod mongodb; mod reference; #[async_trait] pub trait AbstractUsers: Sync + Send { + /// Insert a new user into the database + async fn insert_user(&self, user: &User) -> Result<()>; + /// Fetch a user from the database async fn fetch_user(&self, id: &str) -> Result; + + /// Fetch a user from the database by their username + async fn fetch_user_by_username(&self, username: &str) -> Result; + + /// Fetch a user from the database by their session token + async fn fetch_user_by_token(&self, token: &str) -> Result; + + /// Fetch multiple users by their ids + async fn fetch_users<'a>(&self, ids: &'a [String]) -> Result>; + + /// Fetch ids of users that both users are friends with + async fn fetch_mutual_user_ids(&self, user_a: &str, user_b: &str) -> Result>; + + /// Fetch ids of channels that both users are in + async fn fetch_mutual_channel_ids(&self, user_a: &str, user_b: &str) -> Result>; + + /// Fetch ids of servers that both users share + async fn fetch_mutual_server_ids(&self, user_a: &str, user_b: &str) -> Result>; + + /// Update a user by their id given some data + async fn update_user( + &self, + id: &str, + user: &PartialUser, + remove: Vec, + ) -> Result<()>; + + /// Set relationship with another user + /// + /// This should use pull_relationship if relationship is None. + async fn set_relationship( + &self, + user_id: &str, + target_id: &str, + relationship: &RelationshipStatus, + ) -> Result<()>; + + /// Remove relationship with another user + async fn pull_relationship(&self, user_id: &str, target_id: &str) -> Result<()>; + + /// Delete a user by their id + async fn delete_user(&self, id: &str) -> Result<()>; } diff --git a/crates/core/database/src/models/users/ops/mongodb.rs b/crates/core/database/src/models/users/ops/mongodb.rs index c6e43c98..6bea5077 100644 --- a/crates/core/database/src/models/users/ops/mongodb.rs +++ b/crates/core/database/src/models/users/ops/mongodb.rs @@ -1,16 +1,300 @@ +use ::mongodb::options::{Collation, CollationStrength, FindOneOptions, FindOptions}; +use authifier::models::Session; +use futures::StreamExt; use revolt_result::Result; +use crate::DocumentId; +use crate::IntoDocumentPath; use crate::MongoDb; -use crate::User; +use crate::{FieldsUser, PartialUser, RelationshipStatus, User}; use super::AbstractUsers; -static COL: &str = "bots"; +static COL: &str = "users"; #[async_trait] impl AbstractUsers for MongoDb { + /// Insert a new user into the database + async fn insert_user(&self, user: &User) -> Result<()> { + query!(self, insert_one, COL, &user).map(|_| ()) + } + /// Fetch a user from the database async fn fetch_user(&self, id: &str) -> Result { query!(self, find_one_by_id, COL, id)?.ok_or_else(|| create_error!(NotFound)) } + + /// Fetch a user from the database by their username + async fn fetch_user_by_username(&self, username: &str) -> Result { + query!( + self, + find_one_with_options, + COL, + doc! { + "username": username + }, + FindOneOptions::builder() + .collation( + Collation::builder() + .locale("en") + .strength(CollationStrength::Secondary) + .build(), + ) + .build() + )? + .ok_or_else(|| create_error!(NotFound)) + } + + /// Fetch a user from the database by their session token + async fn fetch_user_by_token(&self, token: &str) -> Result { + let session = self + .col::("sessions") + .find_one( + doc! { + "token": token + }, + None, + ) + .await + .map_err(|_| create_database_error!("find_one", "sessions"))? + .ok_or_else(|| create_error!(InvalidSession))?; + + self.fetch_user(&session.id).await + } + + /// Fetch multiple users by their ids + async fn fetch_users<'a>(&self, ids: &'a [String]) -> Result> { + Ok(self + .col::(COL) + .find( + doc! { + "_id": { + "$in": ids + } + }, + None, + ) + .await + .map_err(|_| create_database_error!("find", COL))? + .filter_map(|s| async { + if cfg!(debug_assertions) { + Some(s.unwrap()) + } else { + s.ok() + } + }) + .collect() + .await) + } + + /// Fetch ids of users that both users are friends with + async fn fetch_mutual_user_ids(&self, user_a: &str, user_b: &str) -> Result> { + Ok(self + .col::(COL) + .find( + doc! { + "$and": [ + { "relations": { "$elemMatch": { "_id": &user_a, "status": "Friend" } } }, + { "relations": { "$elemMatch": { "_id": &user_b, "status": "Friend" } } } + ] + }, + FindOptions::builder().projection(doc! { "_id": 1 }).build(), + ) + .await + .map_err(|_| create_database_error!("find", COL))? + .filter_map(|s| async { s.ok() }) + .map(|user| user.id) + .collect() + .await) + } + + /// Fetch ids of channels that both users are in + async fn fetch_mutual_channel_ids(&self, user_a: &str, user_b: &str) -> Result> { + Ok(self + .col::("channels") + .find( + doc! { + "channel_type": { + "$in": ["Group", "DirectMessage"] + }, + "recipients": { + "$all": [ user_a, user_b ] + } + }, + FindOptions::builder().projection(doc! { "_id": 1 }).build(), + ) + .await + .map_err(|_| create_database_error!("find", "channels"))? + .filter_map(|s| async { s.ok() }) + .map(|user| user.id) + .collect() + .await) + } + + /// Fetch ids of servers that both users share + async fn fetch_mutual_server_ids(&self, user_a: &str, user_b: &str) -> Result> { + Ok(self + .col::("server_members") + .aggregate( + vec![ + doc! { + "$match": { + "_id.user": user_a + } + }, + doc! { + "$lookup": { + "from": "server_members", + "as": "members", + "let": { + "server": "$_id.server" + }, + "pipeline": [ + { + "$match": { + "$expr": { + "$and": [ + { "$eq": [ "$_id.user", user_b ] }, + { "$eq": [ "$_id.server", "$$server" ] } + ] + } + } + } + ] + } + }, + doc! { + "$match": { + "members": { + "$size": 1_i32 + } + } + }, + doc! { + "$project": { + "_id": "$_id.server" + } + }, + ], + None, + ) + .await + .map_err(|_| create_database_error!("aggregate", "server_members"))? + .filter_map(|s| async { s.ok() }) + .filter_map(|doc| async move { doc.get_str("_id").map(|id| id.to_string()).ok() }) + .collect() + .await) + } + + /// Update a user by their id given some data + async fn update_user( + &self, + id: &str, + partial: &PartialUser, + remove: Vec, + ) -> Result<()> { + query!( + self, + update_one_by_id, + COL, + id, + partial, + remove.iter().map(|x| x as &dyn IntoDocumentPath).collect(), + None + ) + .map(|_| ()) + } + + /// Set relationship with another user + /// + /// This should use pull_relationship if relationship is None. + async fn set_relationship( + &self, + user_id: &str, + target_id: &str, + relationship: &RelationshipStatus, + ) -> Result<()> { + if let RelationshipStatus::None = relationship { + return self.pull_relationship(user_id, target_id).await; + } + + self.col::(COL) + .update_one( + doc! { + "_id": user_id + }, + vec![doc! { + "$set": { + "relations": { + "$concatArrays": [ + { + "$ifNull": [ + { + "$filter": { + "input": "$relations", + "cond": { + "$ne": [ + "$$this._id", + target_id + ] + } + } + }, + [] + ] + }, + [ + { + "_id": target_id, + "status": format!("{relationship:?}") + } + ] + ] + } + } + }], + None, + ) + .await + .map(|_| ()) + .map_err(|_| create_database_error!("update_one", "user")) + } + + /// Remove relationship with another user + async fn pull_relationship(&self, user_id: &str, target_id: &str) -> Result<()> { + self.col::(COL) + .update_one( + doc! { + "_id": user_id + }, + doc! { + "$pull": { + "relations": { + "_id": target_id + } + } + }, + None, + ) + .await + .map(|_| ()) + .map_err(|_| create_database_error!("update_one", COL)) + } + + /// Delete a user by their id + async fn delete_user(&self, id: &str) -> Result<()> { + query!(self, delete_one_by_id, COL, id).map(|_| ()) + } +} + +impl IntoDocumentPath for FieldsUser { + fn as_path(&self) -> Option<&'static str> { + Some(match self { + FieldsUser::Avatar => "avatar", + FieldsUser::ProfileBackground => "profile.background", + FieldsUser::ProfileContent => "profile.content", + FieldsUser::StatusPresence => "status.presence", + FieldsUser::StatusText => "status.text", + }) + } } diff --git a/crates/core/database/src/models/users/ops/reference.rs b/crates/core/database/src/models/users/ops/reference.rs index 034f4867..d75af7a6 100644 --- a/crates/core/database/src/models/users/ops/reference.rs +++ b/crates/core/database/src/models/users/ops/reference.rs @@ -1,12 +1,23 @@ use revolt_result::Result; use crate::ReferenceDb; -use crate::User; +use crate::{FieldsUser, PartialUser, RelationshipStatus, User}; use super::AbstractUsers; #[async_trait] impl AbstractUsers for ReferenceDb { + /// Insert a new user into the database + async fn insert_user(&self, user: &User) -> Result<()> { + let mut users = self.users.lock().await; + if users.contains_key(&user.id) { + Err(create_database_error!("insert", "user")) + } else { + users.insert(user.id.to_string(), user.clone()); + Ok(()) + } + } + /// Fetch a user from the database async fn fetch_user(&self, id: &str) -> Result { let users = self.users.lock().await; @@ -15,4 +26,96 @@ impl AbstractUsers for ReferenceDb { .cloned() .ok_or_else(|| create_error!(NotFound)) } + + /// Fetch a user from the database by their username + async fn fetch_user_by_username(&self, username: &str) -> Result { + let users = self.users.lock().await; + let lowercase = username.to_lowercase(); + users + .values() + .find(|user| user.username.to_lowercase() == lowercase) + .cloned() + .ok_or_else(|| create_error!(NotFound)) + } + + /// Fetch a user from the database by their session token + async fn fetch_user_by_token(&self, _token: &str) -> Result { + todo!() + } + + /// Fetch multiple users by their ids + async fn fetch_users<'a>(&self, ids: &'a [String]) -> Result> { + let users = self.users.lock().await; + ids.iter() + .map(|id| { + users + .get(id) + .cloned() + .ok_or_else(|| create_error!(NotFound)) + }) + .collect() + } + + /// Fetch ids of users that both users are friends with + async fn fetch_mutual_user_ids(&self, _user_a: &str, _user_b: &str) -> Result> { + todo!() + } + + /// Fetch ids of channels that both users are in + async fn fetch_mutual_channel_ids(&self, _user_a: &str, _user_b: &str) -> Result> { + todo!() + } + + /// Fetch ids of servers that both users share + async fn fetch_mutual_server_ids(&self, _user_a: &str, _user_b: &str) -> Result> { + todo!() + } + + /// Update a user by their id given some data + async fn update_user( + &self, + id: &str, + partial: &PartialUser, + remove: Vec, + ) -> Result<()> { + let mut users = self.users.lock().await; + if let Some(user) = users.get_mut(id) { + for field in remove { + #[allow(clippy::disallowed_methods)] + user.remove_field(&field); + } + + user.apply_options(partial.clone()); + Ok(()) + } else { + Err(create_error!(NotFound)) + } + } + + /// Set relationship with another user + /// + /// This should use pull_relationship if relationship is None. + async fn set_relationship( + &self, + _user_id: &str, + _target_id: &str, + _relationship: &RelationshipStatus, + ) -> Result<()> { + todo!() + } + + /// Remove relationship with another user + async fn pull_relationship(&self, _user_id: &str, _target_id: &str) -> Result<()> { + todo!() + } + + /// Delete a user by their id + async fn delete_user(&self, id: &str) -> Result<()> { + let mut users = self.users.lock().await; + if users.remove(id).is_some() { + Ok(()) + } else { + Err(create_error!(NotFound)) + } + } } diff --git a/crates/core/database/src/util/permissions.rs b/crates/core/database/src/util/permissions.rs index 80b02e03..c4e0d695 100644 --- a/crates/core/database/src/util/permissions.rs +++ b/crates/core/database/src/util/permissions.rs @@ -8,6 +8,7 @@ use crate::{Database, User}; /// Permissions calculator pub struct PermissionCalculator<'a> { + #[allow(dead_code)] database: &'a Database, perspective: &'a User, diff --git a/crates/core/models/src/v0/account_strikes.rs b/crates/core/models/src/v0/account_strikes.rs new file mode 100644 index 00000000..d402d2f1 --- /dev/null +++ b/crates/core/models/src/v0/account_strikes.rs @@ -0,0 +1,39 @@ +auto_derived!( + /// Account Strike + pub struct AccountStrike { + /// Strike Id + #[serde(rename = "_id")] + pub id: String, + /// Id of reported user + pub user_id: String, + + /// Attached reason + pub reason: String, + } + + /// New strike information + pub struct DataCreateStrike { + /// Id of reported user + pub user_id: String, + + /// Attached reason + pub reason: String, + } + + /// New strike information + pub struct DataEditAccountStrike { + /// New attached reason + pub reason: String, + } +); + +#[cfg(feature = "from_database")] +impl From for AccountStrike { + fn from(value: revolt_database::AccountStrike) -> Self { + AccountStrike { + id: value.id, + user_id: value.user_id, + reason: value.reason, + } + } +} diff --git a/crates/core/models/src/v0/mod.rs b/crates/core/models/src/v0/mod.rs index 927b1c92..9654fb85 100644 --- a/crates/core/models/src/v0/mod.rs +++ b/crates/core/models/src/v0/mod.rs @@ -1,7 +1,9 @@ +mod account_strikes; mod bots; mod files; mod users; +pub use account_strikes::*; pub use bots::*; pub use files::*; pub use users::*; diff --git a/crates/core/permissions/src/models/server.rs b/crates/core/permissions/src/models/server.rs index 08ae8443..7c11e98d 100644 --- a/crates/core/permissions/src/models/server.rs +++ b/crates/core/permissions/src/models/server.rs @@ -1,5 +1,5 @@ /// Representation of a single permission override -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, Eq, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct Override { /// Allow bit flags @@ -10,7 +10,7 @@ pub struct Override { /// Representation of a single permission override /// as it appears on models and in the database -#[derive(/*JsonSchema, */ Debug, Clone, Copy, Default)] +#[derive(/*JsonSchema, */ Debug, Clone, Copy, Default, Eq, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct OverrideField { /// Allow bit flags diff --git a/crates/delta/Cargo.toml b/crates/delta/Cargo.toml index 0c44fef4..1445cb60 100644 --- a/crates/delta/Cargo.toml +++ b/crates/delta/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-delta" -version = "0.5.19" +version = "0.5.21" license = "AGPL-3.0-or-later" authors = ["Paul Makles "] edition = "2018" diff --git a/crates/delta/src/routes/channels/members_fetch.rs b/crates/delta/src/routes/channels/members_fetch.rs index 2580d69b..51510bd2 100644 --- a/crates/delta/src/routes/channels/members_fetch.rs +++ b/crates/delta/src/routes/channels/members_fetch.rs @@ -11,10 +11,6 @@ use rocket::serde::json::Json; #[openapi(tag = "Groups")] #[get("//members")] pub async fn req(db: &Db, user: User, target: Ref) -> Result>> { - if user.bot.is_some() { - return Err(Error::IsBot); - } - let channel = target.as_channel(db).await?; perms(&user) .channel(&channel) diff --git a/crates/delta/src/routes/mod.rs b/crates/delta/src/routes/mod.rs index 98f7aee7..4ae56abf 100644 --- a/crates/delta/src/routes/mod.rs +++ b/crates/delta/src/routes/mod.rs @@ -161,7 +161,12 @@ fn custom_openapi_spec() -> OpenApi { servers: vec![ Server { url: "https://api.revolt.chat".to_owned(), - description: Some("Revolt API".to_owned()), + description: Some("Revolt Production".to_owned()), + ..Default::default() + }, + Server { + url: "https://revolt.chat/api".to_owned(), + description: Some("Revolt Staging".to_owned()), ..Default::default() }, Server { diff --git a/crates/delta/src/routes/safety/create_strike.rs b/crates/delta/src/routes/safety/create_strike.rs new file mode 100644 index 00000000..43fc078c --- /dev/null +++ b/crates/delta/src/routes/safety/create_strike.rs @@ -0,0 +1,34 @@ +use revolt_database::{AccountStrike, Database}; +use revolt_models::v0::{AccountStrike as AccountStrikeModel, DataCreateStrike}; +use revolt_quark::models::User; +use revolt_quark::{Error, Result}; +use rocket::serde::json::Json; +use rocket::State; + +/// # Create Strike +/// +/// Create a new account strike +#[openapi(tag = "User Safety")] +#[post("/strikes", data = "")] +pub async fn create_strike( + db: &State, + user: User, + data: Json, +) -> Result> { + // Must be privileged for this route + if !user.privileged { + return Err(Error::NotPrivileged); + } + + let data = data.into_inner(); + let target = db + .fetch_user(&data.user_id) + .await + .map_err(Error::from_core)?; + + AccountStrike::create(db, target.id, data.reason, user.id) + .await + .map(|strike| strike.into()) + .map(Json) + .map_err(Error::from_core) +} diff --git a/crates/delta/src/routes/safety/delete_strike.rs b/crates/delta/src/routes/safety/delete_strike.rs new file mode 100644 index 00000000..d11a80e7 --- /dev/null +++ b/crates/delta/src/routes/safety/delete_strike.rs @@ -0,0 +1,23 @@ +use revolt_database::Database; +use revolt_quark::models::User; +use revolt_quark::{Error, Result}; +use rocket::State; + +/// # Delete Strike +/// +/// Delete a strike by its ID +#[openapi(tag = "User Safety")] +#[delete("/strikes/")] +pub async fn delete_strike(db: &State, user: User, strike_id: String) -> Result<()> { + // Must be privileged for this route + if !user.privileged { + return Err(Error::NotPrivileged); + } + + let strike = db + .fetch_account_strike(&strike_id) + .await + .map_err(Error::from_core)?; + + strike.delete(db).await.map_err(Error::from_core) +} diff --git a/crates/delta/src/routes/safety/edit_strike.rs b/crates/delta/src/routes/safety/edit_strike.rs new file mode 100644 index 00000000..81744d11 --- /dev/null +++ b/crates/delta/src/routes/safety/edit_strike.rs @@ -0,0 +1,39 @@ +use revolt_database::{Database, PartialAccountStrike}; +use revolt_models::v0::DataEditAccountStrike; +use revolt_quark::models::User; +use revolt_quark::{Error, Result}; +use rocket::serde::json::Json; +use rocket::State; + +/// # Edit Strike +/// +/// Edit a strike by its ID +#[openapi(tag = "User Safety")] +#[post("/strikes/", data = "")] +pub async fn edit_strike( + db: &State, + user: User, + strike_id: String, + data: Json, +) -> Result<()> { + // Must be privileged for this route + if !user.privileged { + return Err(Error::NotPrivileged); + } + + let mut strike = db + .fetch_account_strike(&strike_id) + .await + .map_err(Error::from_core)?; + + strike + .update( + db, + PartialAccountStrike { + reason: Some(data.0.reason), + ..Default::default() + }, + ) + .await + .map_err(Error::from_core) +} diff --git a/crates/delta/src/routes/safety/fetch_reports.rs b/crates/delta/src/routes/safety/fetch_reports.rs index 5b418472..0832e157 100644 --- a/crates/delta/src/routes/safety/fetch_reports.rs +++ b/crates/delta/src/routes/safety/fetch_reports.rs @@ -1,17 +1,61 @@ +use revolt_quark::models::report::{ReportStatus, ReportStatusString, ReportedContent}; use revolt_quark::models::{Report, User}; use revolt_quark::{Db, Error, Result}; use rocket::serde::json::Json; +use serde::Deserialize; + +/// # Query Parameters +#[derive(Deserialize, JsonSchema, FromForm)] +pub struct OptionsFetchReports { + /// Find reports against messages, servers, or users + content_id: Option, + + /// Find reports created by user + author_id: Option, + + /// Report status to include in search + status: Option, +} /// # Fetch Reports /// /// Fetch all available reports #[openapi(tag = "User Safety")] -#[get("/reports")] -pub async fn fetch_reports(db: &Db, user: User) -> Result>> { +#[get("/reports?")] +pub async fn fetch_reports( + db: &Db, + user: User, + options: OptionsFetchReports, +) -> Result>> { // Must be privileged for this route if !user.privileged { return Err(Error::NotPrivileged); } - db.fetch_reports().await.map(Json) + let mut reports = db.fetch_reports().await?; + + if let Some(content_id) = options.content_id { + reports.retain(|report| match &report.content { + ReportedContent::Message { id, .. } + | ReportedContent::Server { id, .. } + | ReportedContent::User { id, .. } => id == &content_id, + }); + } + + if let Some(author_id) = options.author_id { + reports.retain(|report| report.author_id == author_id); + } + + if let Some(status) = options.status { + reports.retain(|report| { + matches!( + (&status, &report.status), + (ReportStatusString::Created, ReportStatus::Created { .. }) + | (ReportStatusString::Rejected, ReportStatus::Rejected { .. }) + | (ReportStatusString::Resolved, ReportStatus::Resolved { .. }) + ) + }); + } + + Ok(Json(reports)) } diff --git a/crates/delta/src/routes/safety/fetch_snapshot.rs b/crates/delta/src/routes/safety/fetch_snapshot.rs deleted file mode 100644 index 7d32bcf2..00000000 --- a/crates/delta/src/routes/safety/fetch_snapshot.rs +++ /dev/null @@ -1,82 +0,0 @@ -use std::collections::HashSet; - -use revolt_quark::models::snapshot::{SnapshotContent, SnapshotWithContext}; -use revolt_quark::models::{Channel, User}; -use revolt_quark::{Db, Error, Result}; -use rocket::serde::json::Json; - -/// # Fetch Snapshot -/// -/// Fetch a snapshot for a given report -#[openapi(tag = "User Safety")] -#[get("/snapshot/")] -pub async fn fetch_snapshot( - db: &Db, - user: User, - report_id: String, -) -> Result> { - // Must be privileged for this route - if !user.privileged { - return Err(Error::NotPrivileged); - } - - // Fetch snapshot - let snapshot = db.fetch_snapshot(&report_id).await?; - - // Resolve and fetch IDs of associated content - let mut user_ids: HashSet<&str> = HashSet::new(); - let mut channel_ids: HashSet<&str> = HashSet::new(); - - match &snapshot.content { - SnapshotContent::Message { - prior_context, - leading_context, - message, - } => { - for msg in prior_context { - user_ids.insert(&msg.author); - } - - for msg in leading_context { - user_ids.insert(&msg.author); - } - - user_ids.insert(&message.author); - channel_ids.insert(&message.channel); - } - SnapshotContent::User(user) => { - user_ids.insert(&user.id); - } - SnapshotContent::Server(server) => { - for channel in &server.channels { - channel_ids.insert(channel); - } - } - } - - // Collect user and channel IDs - let user_ids: Vec = user_ids.into_iter().map(|s| s.to_owned()).collect(); - let channel_ids: Vec = channel_ids.into_iter().map(|s| s.to_owned()).collect(); - - // Fetch users and channels - let users = db.fetch_users(&user_ids).await?; - let channels = db.fetch_channels(&channel_ids).await?; - - // Pull out first server from channels if possible - let server = if let Some(server_id) = channels.iter().find_map(|channel| match channel { - Channel::TextChannel { server, .. } => Some(server), - _ => None, - }) { - Some(db.fetch_server(server_id).await?) - } else { - None - }; - - // Return snapshot with context - Ok(Json(SnapshotWithContext { - snapshot, - users, - channels, - server, - })) -} diff --git a/crates/delta/src/routes/safety/fetch_snapshots.rs b/crates/delta/src/routes/safety/fetch_snapshots.rs new file mode 100644 index 00000000..8283b48f --- /dev/null +++ b/crates/delta/src/routes/safety/fetch_snapshots.rs @@ -0,0 +1,87 @@ +use std::collections::HashSet; + +use revolt_quark::models::snapshot::{SnapshotContent, SnapshotWithContext}; +use revolt_quark::models::{Channel, User}; +use revolt_quark::{Db, Error, Result}; +use rocket::serde::json::Json; + +/// # Fetch Snapshots +/// +/// Fetch a snapshots for a given report +#[openapi(tag = "User Safety")] +#[get("/snapshot/")] +pub async fn fetch_snapshots( + db: &Db, + user: User, + report_id: String, +) -> Result>> { + // Must be privileged for this route + if !user.privileged { + return Err(Error::NotPrivileged); + } + + // Fetch snapshots + let snapshots = db.fetch_snapshots(&report_id).await?; + let mut result = vec![]; + + for snapshot in snapshots { + // Resolve and fetch IDs of associated content + let mut user_ids: HashSet<&str> = HashSet::new(); + let mut channel_ids: HashSet<&str> = HashSet::new(); + + match &snapshot.content { + SnapshotContent::Message { + prior_context, + leading_context, + message, + } => { + for msg in prior_context { + user_ids.insert(&msg.author); + } + + for msg in leading_context { + user_ids.insert(&msg.author); + } + + user_ids.insert(&message.author); + channel_ids.insert(&message.channel); + } + SnapshotContent::User(user) => { + user_ids.insert(&user.id); + } + SnapshotContent::Server(server) => { + for channel in &server.channels { + channel_ids.insert(channel); + } + } + } + + // Collect user and channel IDs + let user_ids: Vec = user_ids.into_iter().map(|s| s.to_owned()).collect(); + let channel_ids: Vec = channel_ids.into_iter().map(|s| s.to_owned()).collect(); + + // Fetch users and channels + let users = db.fetch_users(&user_ids).await?; + let channels = db.fetch_channels(&channel_ids).await?; + + // Pull out first server from channels if possible + let server = if let Some(server_id) = channels.iter().find_map(|channel| match channel { + Channel::TextChannel { server, .. } => Some(server), + _ => None, + }) { + Some(db.fetch_server(server_id).await?) + } else { + None + }; + + // Return snapshot with context + result.push(SnapshotWithContext { + snapshot, + users, + channels, + server, + }); + } + + Ok(Json(result)) +} diff --git a/crates/delta/src/routes/safety/fetch_strikes.rs b/crates/delta/src/routes/safety/fetch_strikes.rs new file mode 100644 index 00000000..2ae0b581 --- /dev/null +++ b/crates/delta/src/routes/safety/fetch_strikes.rs @@ -0,0 +1,28 @@ +use revolt_database::Database; +use revolt_models::v0::AccountStrike; +use revolt_quark::models::User; +use revolt_quark::{Error, Result}; +use rocket::serde::json::Json; +use rocket::State; + +/// # Fetch Strikes +/// +/// Fetch strikes for a user by their ID +#[openapi(tag = "User Safety")] +#[get("/strikes/")] +pub async fn fetch_strikes( + db: &State, + user: User, + user_id: String, +) -> Result>> { + // Must be privileged for this route + if !user.privileged { + return Err(Error::NotPrivileged); + } + + db.fetch_account_strikes_by_user(&user_id) + .await + .map(|v| v.into_iter().map(|e| e.into()).collect()) + .map(Json) + .map_err(Error::from_core) +} diff --git a/crates/delta/src/routes/safety/mod.rs b/crates/delta/src/routes/safety/mod.rs index 7aee1e8a..f9027811 100644 --- a/crates/delta/src/routes/safety/mod.rs +++ b/crates/delta/src/routes/safety/mod.rs @@ -6,7 +6,12 @@ mod fetch_report; mod fetch_reports; mod report_content; -mod fetch_snapshot; +mod fetch_snapshots; + +mod create_strike; +mod delete_strike; +mod edit_strike; +mod fetch_strikes; pub fn routes() -> (Vec, OpenApi) { openapi_get_routes_spec![ @@ -16,6 +21,11 @@ pub fn routes() -> (Vec, OpenApi) { fetch_reports::fetch_reports, report_content::report_content, // Snapshots - fetch_snapshot::fetch_snapshot + fetch_snapshots::fetch_snapshots, + // Strikes + create_strike::create_strike, + fetch_strikes::fetch_strikes, + edit_strike::edit_strike, + delete_strike::delete_strike ] } diff --git a/crates/delta/src/routes/safety/report_content.rs b/crates/delta/src/routes/safety/report_content.rs index 5a7060b7..2856d225 100644 --- a/crates/delta/src/routes/safety/report_content.rs +++ b/crates/delta/src/routes/safety/report_content.rs @@ -1,5 +1,4 @@ use revolt_quark::events::client::EventV1; -use revolt_quark::models::message::{MessageFilter, MessageQuery, MessageSort, MessageTimePeriod}; use revolt_quark::models::report::{ReportStatus, ReportedContent}; use revolt_quark::models::snapshot::{Snapshot, SnapshotContent}; use revolt_quark::models::{Report, User}; @@ -38,7 +37,7 @@ pub async fn report_content(db: &Db, user: User, data: Json) // Find the content and create a snapshot of it // Also retrieve any references to Files - let (content, files): (SnapshotContent, Vec) = match &data.content { + let (snapshots, files): (Vec, Vec) = match &data.content { ReportedContent::Message { id, .. } => { let message = db.fetch_message(id).await?; @@ -47,53 +46,8 @@ pub async fn report_content(db: &Db, user: User, data: Json) return Err(Error::CannotReportYourself); } - // Collect message attachments - let files = message - .attachments - .as_ref() - .map(|attachments| attachments.iter().map(|x| x.id.to_string()).collect()) - .unwrap_or_default(); - - // Collect prior context - let prior_context = db - .fetch_messages(MessageQuery { - filter: MessageFilter { - channel: Some(message.channel.to_string()), - ..Default::default() - }, - limit: Some(15), - time_period: MessageTimePeriod::Absolute { - before: Some(message.id.to_string()), - after: None, - sort: Some(MessageSort::Latest), - }, - }) - .await?; - - // Collect leading context - let leading_context = db - .fetch_messages(MessageQuery { - filter: MessageFilter { - channel: Some(message.channel.to_string()), - ..Default::default() - }, - limit: Some(15), - time_period: MessageTimePeriod::Absolute { - before: None, - after: Some(message.id.to_string()), - sort: Some(MessageSort::Oldest), - }, - }) - .await?; - - ( - SnapshotContent::Message { - message, - prior_context, - leading_context, - }, - files, - ) + let (snapshot, files) = SnapshotContent::generate_from_message(db, message).await?; + (vec![snapshot], files) } ReportedContent::Server { id, .. } => { let server = db.fetch_server(id).await?; @@ -103,15 +57,10 @@ pub async fn report_content(db: &Db, user: User, data: Json) return Err(Error::CannotReportYourself); } - // Collect server's icon and banner - let files = [&server.icon, &server.banner] - .iter() - .filter_map(|x| x.as_ref().map(|x| x.id.to_string())) - .collect(); - - (SnapshotContent::Server(server), files) + let (snapshot, files) = SnapshotContent::generate_from_server(server)?; + (vec![snapshot], files) } - ReportedContent::User { id, .. } => { + ReportedContent::User { id, message_id, .. } => { let reported_user = db.fetch_user(id).await?; // Users cannot report themselves @@ -119,19 +68,25 @@ pub async fn report_content(db: &Db, user: User, data: Json) return Err(Error::CannotReportYourself); } - // Collect user's avatar and profile background - let files = [ - reported_user.avatar.as_ref(), - reported_user - .profile - .as_ref() - .and_then(|profile| profile.background.as_ref()), - ] - .iter() - .filter_map(|x| x.as_ref().map(|x| x.id.to_string())) - .collect(); + // Determine if there is a message provided as context + let message = if let Some(id) = message_id { + db.fetch_message(id).await.ok() + } else { + None + }; - (SnapshotContent::User(reported_user), files) + let (snapshot, files) = SnapshotContent::generate_from_user(reported_user)?; + + if let Some(message) = message { + let (message_snapshot, message_files) = + SnapshotContent::generate_from_message(db, message).await?; + ( + vec![snapshot, message_snapshot], + [files, message_files].concat(), + ) + } else { + (vec![snapshot], files) + } } }; @@ -143,14 +98,17 @@ pub async fn report_content(db: &Db, user: User, data: Json) // Generate an id for the report let id = Ulid::new().to_string(); - // Save a snapshot of the content - let snapshot = Snapshot { - id: Ulid::new().to_string(), - report_id: id.to_string(), - content, - }; + // Insert all new generated snapshots + for content in snapshots { + // Save a snapshot of the content + let snapshot = Snapshot { + id: Ulid::new().to_string(), + report_id: id.to_string(), + content, + }; - db.insert_snapshot(&snapshot).await?; + db.insert_snapshot(&snapshot).await?; + } // Save the report let report = Report { diff --git a/crates/delta/src/routes/servers/member_experimental_query.rs b/crates/delta/src/routes/servers/member_experimental_query.rs new file mode 100644 index 00000000..44040ebe --- /dev/null +++ b/crates/delta/src/routes/servers/member_experimental_query.rs @@ -0,0 +1,82 @@ +use revolt_quark::{ + models::{Member, User}, + perms, Db, Error, Ref, Result, +}; + +use rocket::serde::json::Json; +use serde::{Deserialize, Serialize}; + +/// # Query Parameters +#[derive(Deserialize, JsonSchema, FromForm)] +pub struct OptionsQueryMembers { + /// String to search for + query: String, + + /// Discourage use of this API + experimental_api: bool, +} + +/// # Query members by name +#[derive(Serialize, JsonSchema)] +pub struct MemberQueryResponse { + /// List of members + members: Vec, + /// List of users + users: Vec, +} + +/// # Query members by name +/// +/// Query members by a given name, this API is not stable and will be removed in the future. +#[openapi(tag = "Server Members")] +#[get("//members_experimental_query?")] +pub async fn member_experimental_query( + db: &Db, + user: User, + target: Ref, + options: OptionsQueryMembers, +) -> Result> { + if !options.experimental_api { + return Err(Error::InternalError); + } + + let server = target.as_server(db).await?; + perms(&user).server(&server).calc(db).await?; + + let mut members = db.fetch_all_members(&server.id).await?; + + let mut user_ids = vec![]; + for member in &members { + user_ids.push(member.id.user.clone()); + } + + let mut users = User::fetch_foreign_users(db, &user_ids).await?; + + // Ensure the lists match up exactly + members.sort_by(|a, b| a.id.user.cmp(&b.id.user)); + users.sort_by(|a, b| a.id.cmp(&b.id)); + + // Filter all matches + let mut zipped_vec: Vec<(Member, User)> = members + .into_iter() + .zip(users) + .filter(|(member, user)| { + if let Some(nickname) = &member.nickname { + nickname.contains(&options.query) + } else { + user.username.contains(&options.query) + } + }) + .collect(); + + // Sort remaining matches by length + zipped_vec.sort_by(|(member_a, user_a), (member_b, user_b)| { + let left = member_a.nickname.as_ref().unwrap_or(&user_a.username); + let right = member_b.nickname.as_ref().unwrap_or(&user_b.username); + left.len().cmp(&right.len()) + }); + + // Take the first five and return them + let (members, users) = zipped_vec.into_iter().take(10).unzip(); + Ok(Json(MemberQueryResponse { members, users })) +} diff --git a/crates/delta/src/routes/servers/mod.rs b/crates/delta/src/routes/servers/mod.rs index 15cdd735..a3ebeb75 100644 --- a/crates/delta/src/routes/servers/mod.rs +++ b/crates/delta/src/routes/servers/mod.rs @@ -8,6 +8,7 @@ mod channel_create; mod emoji_list; mod invites_fetch; mod member_edit; +mod member_experimental_query; mod member_fetch; mod member_fetch_all; mod member_remove; @@ -34,6 +35,7 @@ pub fn routes() -> (Vec, OpenApi) { member_remove::req, member_fetch::req, member_edit::req, + member_experimental_query::member_experimental_query, ban_create::req, ban_remove::req, ban_list::req, diff --git a/crates/delta/src/routes/servers/server_edit.rs b/crates/delta/src/routes/servers/server_edit.rs index 765c311a..b18cbc9f 100644 --- a/crates/delta/src/routes/servers/server_edit.rs +++ b/crates/delta/src/routes/servers/server_edit.rs @@ -78,7 +78,9 @@ pub async fn req( && data.system_messages.is_none() && data.categories.is_none() // && data.nsfw.is_none() + && data.flags.is_none() && data.analytics.is_none() + && data.discoverable.is_none() && data.remove.is_none() { return Ok(Json(server)); @@ -87,7 +89,6 @@ pub async fn req( || data.icon.is_some() || data.banner.is_some() || data.system_messages.is_some() - // || data.nsfw.is_some() || data.analytics.is_some() || data.remove.is_some() { @@ -97,10 +98,13 @@ pub async fn req( } // Check we are privileged if changing sensitive fields - if data.flags.is_some() && !user.privileged { + if (data.flags.is_some() /*|| data.nsfw.is_some()*/ || data.discoverable.is_some()) + && !user.privileged + { return Err(Error::NotPrivileged); } + // Changing categories requires manage channel if data.categories.is_some() { permissions .throw_permission(db, Permission::ManageChannel) diff --git a/crates/delta/src/routes/servers/server_fetch.rs b/crates/delta/src/routes/servers/server_fetch.rs index db17fd05..ded1b756 100644 --- a/crates/delta/src/routes/servers/server_fetch.rs +++ b/crates/delta/src/routes/servers/server_fetch.rs @@ -1,17 +1,65 @@ use revolt_quark::{ - models::{Server, User}, + models::{Channel, Server, User}, perms, Db, Ref, Result, }; use rocket::serde::json::Json; +use serde::{Deserialize, Serialize}; + +/// # Query Parameters +#[derive(Deserialize, JsonSchema, FromForm)] +pub struct OptionsFetchServer { + /// Whether to include channels + include_channels: Option, +} + +/// # Fetch server route response +#[derive(Serialize, JsonSchema)] +#[serde(untagged)] +pub enum FetchServerResponse { + JustServer(Server), + ServerWithChannels { + #[serde(flatten)] + server: Server, + channels: Vec, + }, +} /// # Fetch Server /// /// Fetch a server by its id. #[openapi(tag = "Server Information")] -#[get("/")] -pub async fn req(db: &Db, user: User, target: Ref) -> Result> { +#[get("/?")] +pub async fn req( + db: &Db, + user: User, + target: Ref, + options: OptionsFetchServer, +) -> Result> { let server = target.as_server(db).await?; - perms(&user).server(&server).calc(db).await?; + let mut perms = perms(&user).server(&server); + perms.calc(db).await?; - Ok(Json(server)) + if let Some(true) = options.include_channels { + let all_channels = db.fetch_channels(&server.channels).await?; + let mut visible_channels = vec![]; + + for channel in all_channels { + if perms + .clone() + .channel(&channel) + .calc(db) + .await? + .can_view_channel() + { + visible_channels.push(channel); + } + } + + Ok(Json(FetchServerResponse::ServerWithChannels { + server, + channels: visible_channels, + })) + } else { + Ok(Json(FetchServerResponse::JustServer(server))) + } } diff --git a/crates/delta/src/routes/users/edit_user.rs b/crates/delta/src/routes/users/edit_user.rs index c7c2b30c..e91d8919 100644 --- a/crates/delta/src/routes/users/edit_user.rs +++ b/crates/delta/src/routes/users/edit_user.rs @@ -66,14 +66,20 @@ pub async fn req( // If we want to edit a different user than self, ensure we have // permissions and subsequently replace the user in question - if target.id != "@me" { - if !user.privileged { + if target.id != "@me" && target.id != user.id { + let target_user = target.as_user(db).await?; + let is_bot_owner = target_user + .bot + .map(|bot| bot.owner == user.id) + .unwrap_or_default(); + + if !is_bot_owner && !user.privileged { return Err(Error::NotPrivileged); } + } - user = target.as_user(db).await?; // Otherwise, filter out invalid edit fields - } else if data.badges.is_some() || data.flags.is_some() { + if !user.privileged && (data.badges.is_some() || data.flags.is_some()) { return Err(Error::NotPrivileged); } diff --git a/crates/quark/Cargo.toml b/crates/quark/Cargo.toml index 8ebe7bb2..36204037 100644 --- a/crates/quark/Cargo.toml +++ b/crates/quark/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-quark" -version = "0.5.19" +version = "0.5.21" edition = "2021" license = "AGPL-3.0-or-later" diff --git a/crates/quark/src/events/client.rs b/crates/quark/src/events/client.rs index b4706535..25c4d585 100644 --- a/crates/quark/src/events/client.rs +++ b/crates/quark/src/events/client.rs @@ -184,6 +184,7 @@ pub enum EventV1 { id: String, data: PartialUser, clear: Vec, + event_id: Option, }, /// Relationship with another user changed diff --git a/crates/quark/src/events/impl.rs b/crates/quark/src/events/impl.rs index 0ea6e05b..1c7e3e4a 100644 --- a/crates/quark/src/events/impl.rs +++ b/crates/quark/src/events/impl.rs @@ -302,6 +302,7 @@ impl State { ..Default::default() }, clear: vec![], + event_id: Some(ulid::Ulid::new().to_string()), }; for server in self.cache.servers.keys() { @@ -499,6 +500,17 @@ impl State { } } + EventV1::UserUpdate { event_id, .. } => { + if let Some(id) = event_id { + if self.cache.seen_events.contains(id) { + return false; + } + + self.cache.seen_events.put(id.to_string(), ()); + } + + *event_id = None; + } EventV1::UserRelationship { id, user, .. } => { self.cache.users.insert(id.clone(), user.clone()); @@ -508,6 +520,7 @@ impl State { self.remove_subscription(id); } } + _ => {} } diff --git a/crates/quark/src/events/state.rs b/crates/quark/src/events/state.rs index 8b502d22..278a18ac 100644 --- a/crates/quark/src/events/state.rs +++ b/crates/quark/src/events/state.rs @@ -1,5 +1,7 @@ use std::collections::{HashMap, HashSet}; +use lru::LruCache; + use crate::models::{Channel, Member, Server, User}; /// Enumeration representing some change in subscriptions @@ -26,7 +28,7 @@ pub enum SubscriptionStateChange { /// ------------------------------------------------ /// We can strip these objects to core information!! /// ------------------------------------------------ -#[derive(Debug, Default)] +#[derive(Debug)] pub struct Cache { pub user_id: String, @@ -34,6 +36,23 @@ pub struct Cache { pub channels: HashMap, pub members: HashMap, pub servers: HashMap, + + pub seen_events: LruCache, +} + +impl Default for Cache { + fn default() -> Self { + Cache { + user_id: Default::default(), + + users: Default::default(), + channels: Default::default(), + members: Default::default(), + servers: Default::default(), + + seen_events: LruCache::new(20), + } + } } /// Client state diff --git a/crates/quark/src/impl/dummy/safety/snapshot.rs b/crates/quark/src/impl/dummy/safety/snapshot.rs index a99447dd..691f28d4 100644 --- a/crates/quark/src/impl/dummy/safety/snapshot.rs +++ b/crates/quark/src/impl/dummy/safety/snapshot.rs @@ -10,7 +10,7 @@ impl AbstractSnapshot for DummyDb { Ok(()) } - async fn fetch_snapshot(&self, _report_id: &str) -> Result { + async fn fetch_snapshots(&self, _report_id: &str) -> Result> { todo!() } } diff --git a/crates/quark/src/impl/generic/mod.rs b/crates/quark/src/impl/generic/mod.rs index ff385ca7..17bde1bc 100644 --- a/crates/quark/src/impl/generic/mod.rs +++ b/crates/quark/src/impl/generic/mod.rs @@ -27,4 +27,5 @@ pub mod users { pub mod safety { pub mod report; + pub mod snapshot; } diff --git a/crates/quark/src/impl/generic/safety/report.rs b/crates/quark/src/impl/generic/safety/report.rs index 259d7701..daac34d7 100644 --- a/crates/quark/src/impl/generic/safety/report.rs +++ b/crates/quark/src/impl/generic/safety/report.rs @@ -1,9 +1,30 @@ -use crate::{models::report::PartialReport, models::Report, Database, Result}; +use iso8601_timestamp::Timestamp; + +use crate::{ + models::report::PartialReport, + models::{report::ReportStatus, Report}, + Database, Result, +}; impl Report { /// Update report data pub async fn update(&mut self, db: &Database, partial: PartialReport) -> Result<()> { self.apply_options(partial.clone()); + + match &mut self.status { + ReportStatus::Created {} => {} + ReportStatus::Rejected { closed_at, .. } => { + if closed_at.is_none() { + closed_at.replace(Timestamp::now_utc()); + } + } + ReportStatus::Resolved { closed_at } => { + if closed_at.is_none() { + closed_at.replace(Timestamp::now_utc()); + } + } + } + db.update_report(&self.id, &partial).await } } diff --git a/crates/quark/src/impl/generic/safety/snapshot.rs b/crates/quark/src/impl/generic/safety/snapshot.rs new file mode 100644 index 00000000..b5e372a9 --- /dev/null +++ b/crates/quark/src/impl/generic/safety/snapshot.rs @@ -0,0 +1,88 @@ +use crate::{ + models::{ + message::{MessageFilter, MessageQuery, MessageSort, MessageTimePeriod}, + snapshot::SnapshotContent, + Message, Server, User, + }, + Database, Result, +}; + +impl SnapshotContent { + pub async fn generate_from_message( + db: &Database, + message: Message, + ) -> Result<(SnapshotContent, Vec)> { + // Collect message attachments + let files = message + .attachments + .as_ref() + .map(|attachments| attachments.iter().map(|x| x.id.to_string()).collect()) + .unwrap_or_default(); + + // Collect prior context + let prior_context = db + .fetch_messages(MessageQuery { + filter: MessageFilter { + channel: Some(message.channel.to_string()), + ..Default::default() + }, + limit: Some(15), + time_period: MessageTimePeriod::Absolute { + before: Some(message.id.to_string()), + after: None, + sort: Some(MessageSort::Latest), + }, + }) + .await?; + + // Collect leading context + let leading_context = db + .fetch_messages(MessageQuery { + filter: MessageFilter { + channel: Some(message.channel.to_string()), + ..Default::default() + }, + limit: Some(15), + time_period: MessageTimePeriod::Absolute { + before: None, + after: Some(message.id.to_string()), + sort: Some(MessageSort::Oldest), + }, + }) + .await?; + + Ok(( + SnapshotContent::Message { + message, + prior_context, + leading_context, + }, + files, + )) + } + + pub fn generate_from_server(server: Server) -> Result<(SnapshotContent, Vec)> { + // Collect server's icon and banner + let files = [&server.icon, &server.banner] + .iter() + .filter_map(|x| x.as_ref().map(|x| x.id.to_string())) + .collect(); + + Ok((SnapshotContent::Server(server), files)) + } + + pub fn generate_from_user(user: User) -> Result<(SnapshotContent, Vec)> { + // Collect user's avatar and profile background + let files = [ + user.avatar.as_ref(), + user.profile + .as_ref() + .and_then(|profile| profile.background.as_ref()), + ] + .iter() + .filter_map(|x| x.as_ref().map(|x| x.id.to_string())) + .collect(); + + Ok((SnapshotContent::User(user), files)) + } +} diff --git a/crates/quark/src/impl/generic/users/user.rs b/crates/quark/src/impl/generic/users/user.rs index 2707723b..3355a5c7 100644 --- a/crates/quark/src/impl/generic/users/user.rs +++ b/crates/quark/src/impl/generic/users/user.rs @@ -33,6 +33,7 @@ impl User { id: self.id.clone(), data: partial, clear: remove, + event_id: Some(ulid::Ulid::new().to_string()), } .p_user(self.id.clone(), db) .await; diff --git a/crates/quark/src/impl/mongo/safety/snapshot.rs b/crates/quark/src/impl/mongo/safety/snapshot.rs index 6b31de9f..4b1a934c 100644 --- a/crates/quark/src/impl/mongo/safety/snapshot.rs +++ b/crates/quark/src/impl/mongo/safety/snapshot.rs @@ -11,8 +11,8 @@ impl AbstractSnapshot for MongoDb { self.insert_one(COL, snapshot).await.map(|_| ()) } - async fn fetch_snapshot(&self, report_id: &str) -> Result { - self.find_one( + async fn fetch_snapshots(&self, report_id: &str) -> Result> { + self.find( COL, doc! { "report_id": report_id diff --git a/crates/quark/src/models/safety/report.rs b/crates/quark/src/models/safety/report.rs index f9ee2a02..186fa77a 100644 --- a/crates/quark/src/models/safety/report.rs +++ b/crates/quark/src/models/safety/report.rs @@ -1,3 +1,5 @@ +use iso8601_timestamp::Timestamp; +use rocket::FromFormField; use serde::{Deserialize, Serialize}; /// Reason for reporting content (message or server) @@ -6,16 +8,41 @@ pub enum ContentReportReason { /// No reason has been specified NoneSpecified, - /// Blatantly illegal content + /// Illegal content catch-all reason Illegal, + /// Selling or facilitating use of drugs or other illegal goods + IllegalGoods, + + /// Extortion or blackmail + IllegalExtortion, + + /// Revenge or child pornography + IllegalPornography, + + /// Illegal hacking activity + IllegalHacking, + + /// Extreme violence, gore, or animal cruelty + /// With exception to violence potrayed in media / creative arts + ExtremeViolence, + /// Content that promotes harm to others / self PromotesHarm, + /// Unsolicited advertisements + UnsolicitedSpam, + + /// This is a raid + Raid, + /// Spam or platform abuse SpamAbuse, - /// Distribution of malware + /// Scams or fraud + ScamsFraud, + + /// Distribution of malware or malicious links Malware, /// Harassment or abuse targeted at another user @@ -28,6 +55,9 @@ pub enum UserReportReason { /// No reason has been specified NoneSpecified, + /// Unsolicited advertisements + UnsolicitedSpam, + /// User is sending spam or otherwise abusing the platform SpamAbuse, @@ -68,6 +98,8 @@ pub enum ReportedContent { id: String, /// Reason for reporting a user report_reason: UserReportReason, + /// Message context + message_id: Option, }, } @@ -79,10 +111,26 @@ pub enum ReportStatus { Created {}, /// Report was rejected - Rejected { rejection_reason: String }, + Rejected { + rejection_reason: String, + closed_at: Option, + }, /// Report was actioned and resolved - Resolved {}, + Resolved { closed_at: Option }, +} + +/// Just the status of the report +#[derive(Serialize, Deserialize, JsonSchema, Debug, Clone, FromFormField)] +pub enum ReportStatusString { + /// Report is waiting for triage / action + Created, + + /// Report was rejected + Rejected, + + /// Report was actioned and resolved + Resolved, } /// User-generated platform moderation report. diff --git a/crates/quark/src/tasks/ack.rs b/crates/quark/src/tasks/ack.rs index 25cf6e2b..6bca0886 100644 --- a/crates/quark/src/tasks/ack.rs +++ b/crates/quark/src/tasks/ack.rs @@ -2,8 +2,8 @@ use crate::Database; use deadqueue::limited::Queue; -use std::{collections::HashMap, time::Duration}; use once_cell::sync::Lazy; +use std::{collections::HashMap, time::Duration}; use super::DelayedTask; @@ -95,7 +95,7 @@ pub async fn worker(db: Database) { }) = Q.try_pop() { let key = (user, channel); - if let Some(mut task) = tasks.get_mut(&key) { + if let Some(task) = tasks.get_mut(&key) { task.delay(); match &mut event { diff --git a/crates/quark/src/tasks/last_message_id.rs b/crates/quark/src/tasks/last_message_id.rs index 976a725d..acc3cbc4 100644 --- a/crates/quark/src/tasks/last_message_id.rs +++ b/crates/quark/src/tasks/last_message_id.rs @@ -2,8 +2,8 @@ use crate::{models::channel::PartialChannel, Database}; use deadqueue::limited::Queue; -use std::{collections::HashMap, time::Duration}; use once_cell::sync::Lazy; +use std::{collections::HashMap, time::Duration}; use super::DelayedTask; @@ -73,7 +73,7 @@ pub async fn worker(db: Database) { // Queue incoming tasks. while let Some(Data { channel, id, is_dm }) = Q.try_pop() { - if let Some(mut task) = tasks.get_mut(&channel) { + if let Some(task) = tasks.get_mut(&channel) { task.data.id = id; task.delay(); } else { diff --git a/crates/quark/src/traits/safety/snapshot.rs b/crates/quark/src/traits/safety/snapshot.rs index 2c1e4d9c..516ef87d 100644 --- a/crates/quark/src/traits/safety/snapshot.rs +++ b/crates/quark/src/traits/safety/snapshot.rs @@ -6,6 +6,6 @@ pub trait AbstractSnapshot: Sync + Send { /// Insert a new snapshot into the database async fn insert_snapshot(&self, snapshot: &Snapshot) -> Result<()>; - /// Fetch a snapshot by a report's id - async fn fetch_snapshot(&self, report_id: &str) -> Result; + /// Fetch a snapshots by a report's id + async fn fetch_snapshots(&self, report_id: &str) -> Result>; } diff --git a/crates/quark/src/util/variables/delta.rs b/crates/quark/src/util/variables/delta.rs index 3733a12d..1ae35fc2 100644 --- a/crates/quark/src/util/variables/delta.rs +++ b/crates/quark/src/util/variables/delta.rs @@ -41,7 +41,7 @@ pub static SMTP_FROM: Lazy = Lazy::new(|| env::var("REVOLT_SMTP_FROM").u // Application Logic Settings pub static MAX_GROUP_SIZE: Lazy = Lazy::new(|| env::var("REVOLT_MAX_GROUP_SIZE").unwrap_or_else(|_| "50".to_string()).parse().unwrap()); -pub static MAX_BOT_COUNT: Lazy = Lazy::new(|| env::var("REVOLT_MAX_BOT_COUNT").unwrap_or_else(|_| "5".to_string()).parse().unwrap()); +pub static MAX_BOT_COUNT: Lazy = Lazy::new(|| env::var("REVOLT_MAX_BOT_COUNT").unwrap_or_else(|_| "10".to_string()).parse().unwrap()); pub static MAX_EMBED_COUNT: Lazy = Lazy::new(|| env::var("REVOLT_MAX_EMBED_COUNT").unwrap_or_else(|_| "5".to_string()).parse().unwrap()); pub static MAX_SERVER_COUNT: Lazy = Lazy::new(|| env::var("REVOLT_MAX_SERVER_COUNT").unwrap_or_else(|_| "100".to_string()).parse().unwrap()); pub static MAX_CHANNEL_COUNT: Lazy = Lazy::new(|| env::var("REVOLT_MAX_CHANNEL_COUNT").unwrap_or_else(|_| "200".to_string()).parse().unwrap());