Compare commits

..

51 Commits

Author SHA1 Message Date
IAmTomahawkx
7cebf6e35b chore: add some routes and descriptions
Signed-off-by: IAmTomahawkx <iamtomahawkx@gmail.com>
2026-06-28 19:38:16 -07:00
IAmTomahawkx
db6cd1a014 Merge branch 'feat/admin-api' of github.com:stoatchat/stoatchat into feat/admin-api 2026-06-28 18:08:18 -07:00
İspik
35a11f7916 feat: add admin account enable route
Signed-off-by: İspik <ispik@ispik.dev>
2026-06-28 18:16:35 +03:00
İspik
feb936a278 feat: add admin account email verify route
Signed-off-by: İspik <ispik@ispik.dev>
2026-06-28 18:02:59 +03:00
İspik
f10aaa5bbe feat: add admin account email change route
Signed-off-by: İspik <ispik@ispik.dev>
2026-06-28 17:49:45 +03:00
İspik
0e8f220dfb fix: add admin routes back
Signed-off-by: İspik <ispik@ispik.dev>
2026-06-28 17:49:12 +03:00
İspik
cf4b9ba2ca fix: fix the issues caused by authifier migration
Signed-off-by: İspik <ispik@ispik.dev>
2026-06-28 16:47:16 +03:00
İspik
08fb35c3f9 Merge branch 'main' into feat/admin-api
# Conflicts:
#	crates/core/database/src/drivers/mod.rs
#	crates/core/database/src/drivers/reference.rs
#	crates/core/database/src/models/admin_migrations/ops/mongodb/init.rs
#	crates/core/database/src/models/channels/ops.rs
#	crates/core/database/src/models/server_members/ops.rs
#	crates/core/database/src/models/server_members/ops/mongodb.rs
#	crates/core/database/src/models/server_members/ops/reference.rs
#	crates/core/database/src/util/bridge/v0.rs
#	crates/core/result/src/axum.rs
#	crates/core/result/src/lib.rs
#	crates/core/result/src/rocket.rs
#	crates/delta/src/routes/mod.rs
#	crates/delta/src/routes/servers/server_edit.rs
2026-06-28 16:08:48 +03:00
Zomatree
a22378c35c fix(docs): update react version (#842)
Signed-off-by: Zomatree <me@zomatree.live>
2026-06-27 04:24:00 +01:00
Zomatree
164be6a2da Revert "chore(deps): update dependency gh to v2.95.0" (#840)
Signed-off-by: Zomatree <me@zomatree.live>
2026-06-25 18:57:12 +01:00
Zomatree
9217c6e49b ci: fix concurrency group (#837)
Signed-off-by: Zomatree <me@zomatree.live>
2026-06-25 13:38:39 +01:00
stoat-release[bot]
23ba7d8fd8 chore(deps): update mwader/static-ffmpeg docker tag to v7.1.1 (#827)
Co-authored-by: stoat-release[bot] <245062572+stoat-release[bot]@users.noreply.github.com>
2026-06-24 10:54:14 -07:00
stoat-release[bot]
6ece5cde3b chore(deps): update dependency gh to v2.95.0 (#822)
Co-authored-by: stoat-release[bot] <245062572+stoat-release[bot]@users.noreply.github.com>
2026-06-24 10:47:21 -07:00
stoat-release[bot]
7c2098c611 chore(deps): update dependency github:git-town/git-town to v22.7.1 (#823)
Co-authored-by: stoat-release[bot] <245062572+stoat-release[bot]@users.noreply.github.com>
2026-06-24 10:46:38 -07:00
Zomatree
01c7d925b0 chore: replace async-std with tokio (#813)
Signed-off-by: Zomatree <me@zomatree.live>
2026-06-24 13:38:20 +01:00
stoat-release[bot]
a15a542f43 chore(deps): update react monorepo to v19.2.7 (#787)
Co-authored-by: stoat-release[bot] <245062572+stoat-release[bot]@users.noreply.github.com>
2026-06-22 12:26:53 -07:00
stoat-release[bot]
34dffa9425 chore(deps): update rust crate regex to v1.12.4 (#816)
Co-authored-by: stoat-release[bot] <245062572+stoat-release[bot]@users.noreply.github.com>
2026-06-22 12:24:35 -07:00
İspik
9fb344a433 fix: remove unnecessary mut 2026-06-13 12:00:38 +03:00
İspik
948fb97e89 fix: channel edit endpoint would fail due to expecting user 2026-06-13 11:58:03 +03:00
İspik
05bceb6d4a feat: add admin channel wipe endpoint 2026-06-13 11:33:23 +03:00
İspik
e48d92c635 feat: add admin channel edit endpoint 2026-06-12 23:48:26 +03:00
İspik
3f4df765eb fix: add missing parameters 2026-06-12 23:28:18 +03:00
İspik
a3d390c085 Merge branch 'main' into feat/admin-api
# Conflicts:
#	crates/delta/src/routes/mod.rs
2026-06-12 23:00:48 +03:00
IAmTomahawkx
db4b26251f Merge branch 'main' into feat/admin-api 2026-06-03 11:10:48 -07:00
ispik
93a00ffbf8 Merge branch 'main' into feat/admin-api
# Conflicts:
#	crates/core/database/src/util/mod.rs
#	crates/core/result/src/axum.rs
2026-05-23 13:26:21 +03:00
ispik
2d8b7481bc fix: use the correct permissions
Signed-off-by: ispik <ispik@ispik.dev>
2026-04-27 10:40:07 +03:00
ispik
8bd5542e71 Merge branch 'main' into feat/admin-api
# Conflicts:
#	crates/core/database/src/models/users/model.rs
2026-04-27 10:29:19 +03:00
ispik
533cc5835a feat: add admin channel deletion endpoint 2026-04-18 19:31:38 +03:00
ispik
80b749d6fc Merge branch 'main' into feat/admin-api 2026-04-18 18:54:02 +03:00
ispik
74ce10162c Merge branch 'main' into feat/admin-api 2026-04-12 14:50:04 +03:00
ispik
9a67cb139c feat: implement admin account deletion endpoint 2026-04-12 14:49:41 +03:00
ispik
5e98a01624 refactor: disable account before creating audit action 2026-04-12 14:41:02 +03:00
ispik
9fe7de709a fix: refactor account disabling logic to use disable method 2026-04-12 14:38:29 +03:00
ispik
5b9a909923 fix: update audit action for accurate account actions 2026-04-12 14:18:22 +03:00
ispik
913946cfa1 feat: expose account modules in mod.rs and add it to routes 2026-04-12 14:15:40 +03:00
ispik
01e0c30596 fix: update permissions for accounts routes to ManageAccounts 2026-04-12 14:12:15 +03:00
ispik
1bb12b416a fix: update account disable endpoint to return EmptyResponse 2026-04-12 13:53:30 +03:00
ispik
5e577358ac feat: add optional case parameter to admin account disable endpoint 2026-04-12 13:49:34 +03:00
ispik
6e78f0b9c0 feat: implement admin account disable functionality 2026-04-11 14:00:50 +03:00
ispik
26faa18d6e chore: add files to mod.rs 2026-04-11 12:19:22 +03:00
ispik
a152d825a2 fix: update Reference type to be lifetime parameterized in admin API functions 2026-04-11 01:01:57 +03:00
ispik
85ad261cc3 Merge branch 'main' into feat/admin-api
# Conflicts:
#	crates/core/database/src/util/bridge/v0.rs
#	crates/core/result/src/axum.rs
#	crates/core/result/src/lib.rs
#	crates/core/result/src/rocket.rs
2026-04-11 00:49:36 +03:00
IAmTomahawkx
f857ca6926 Merge branch 'feat/admin-api' of github.com:stoatchat/stoatchat into feat/admin-api 2026-02-17 16:58:53 -08:00
IAmTomahawkx
73ce052e1a feat: Working server/comment routes 2026-02-17 16:43:05 -08:00
IAmTomahawkx
4fd71c98d6 feat: Implement models and meta routes. Add empty files for routes still to be implemented. 2026-02-17 16:37:18 -08:00
IAmTomahawkx
96f046bc76 create models 2026-02-17 16:37:18 -08:00
IAmTomahawkx
6ecfd14b07 Addd admin models to database 2026-02-17 16:36:57 -08:00
IAmTomahawkx
bf4530563a feat: Working server/comment routes 2025-07-22 03:27:05 -07:00
IAmTomahawkx
ff36d71ad5 feat: Implement models and meta routes. Add empty files for routes still to be implemented. 2025-07-02 18:43:23 -07:00
IAmTomahawkx
f8eb324543 create models 2025-06-20 16:40:01 -07:00
IAmTomahawkx
03db5b1ddf Addd admin models to database 2025-06-08 01:18:07 -07:00
215 changed files with 6003 additions and 1473 deletions

View File

@@ -12,7 +12,7 @@ permissions:
packages: write
concurrency:
group: ${{ github.head_ref || github.ref }}
group: docker-test-${{ github.head_ref || github.ref }}
cancel-in-progress: true
jobs:

View File

@@ -6,7 +6,7 @@ on:
pull_request:
concurrency:
group: ${{ github.head_ref || github.ref }}
group: rust-build-test-${{ github.head_ref || github.ref }}
cancel-in-progress: true
env:

View File

@@ -7,7 +7,7 @@ gh = "2.25.0"
rust = "1.92.0"
"cargo:cargo-nextest" = "0.9.122"
"github:git-town/git-town" = "22.4.0"
"github:git-town/git-town" = "22.7.1"
[settings]
experimental = true

1734
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -23,11 +23,12 @@ async-trait = "0.1.89"
tokio = "1.49.0"
async-channel = "2.3.1"
futures = "0.3.32"
async-std = "1.8.0"
async-tungstenite = "0.17.0"
futures-locks = "0.7.1"
async-lock = "2.8.0"
async-recursion = "1.0.4"
tokio-util = { version = "0.7.18" }
tokio-stream = "0.1.18"
# Error Handling
anyhow = "1.0.100"
@@ -89,7 +90,7 @@ log = "0.4.29"
pretty_env_logger = "0.4.0"
# Redis
redis-kiss = "0.1.4"
redis-kiss = { version = "0.1.4", default-features = false }
fred = "8.0.1"
# Serialisation
@@ -160,8 +161,8 @@ opentelemetry-appender-tracing = "0.31.1"
lapin = "4.7.1"
# Voice
livekit-api = "0.4.4"
livekit-protocol = "0.7.4"
livekit-api = "=0.4.23"
livekit-protocol = "=0.7.7"
livekit-runtime = "0.4.0"
# Other Utilities

View File

@@ -14,7 +14,7 @@ sentry = { workspace = true }
lru = { workspace = true }
ulid = { workspace = true }
once_cell = { workspace = true }
redis-kiss = { workspace = true }
redis-kiss = { workspace = true, default-features = false, features = ["tokio-runtime"] }
lru_time_cache = { workspace = true }
async-channel = { workspace = true }
@@ -30,8 +30,9 @@ serde = { workspace = true }
# async
futures = { workspace = true }
async-tungstenite = { workspace = true, features = ["async-std-runtime"] }
async-std = { workspace = true }
async-tungstenite = { workspace = true, features = ["tokio-runtime"] }
tokio = { workspace = true }
tokio-util = { workspace = true, features = ["compat"] }
# core
revolt-result = { workspace = true }

View File

@@ -2,7 +2,7 @@ use std::{
collections::{HashMap, HashSet}, num::NonZeroUsize, sync::Arc, time::Duration
};
use async_std::sync::{Mutex, RwLock};
use tokio::sync::{Mutex, RwLock};
use lru::LruCache;
use lru_time_cache::{LruCache as LruTimeCache, TimedEntry};
use revolt_database::{Channel, Member, Server, User};

View File

@@ -1,6 +1,6 @@
use std::env;
use async_std::net::TcpListener;
use tokio::net::TcpListener;
use revolt_presence::clear_region;
#[macro_use]
@@ -12,7 +12,7 @@ pub mod events;
mod database;
mod websocket;
#[async_std::main]
#[tokio::main]
async fn main() {
// Configure requirements for Bonfire.
revolt_config::configure!(events);
@@ -33,7 +33,7 @@ async fn main() {
// Start accepting new connections and spawn a client for each connection.
while let Ok((stream, addr)) = listener.accept().await {
async_std::task::spawn(async move {
tokio::task::spawn(async move {
info!("User connected from {addr:?}");
websocket::client(database::get_db(), stream, addr).await;
info!("User disconnected from {addr:?}");

View File

@@ -21,11 +21,12 @@ use revolt_database::{
};
use revolt_presence::{create_session, delete_session};
use async_std::{
use tokio::{
net::TcpStream,
sync::{Mutex, RwLock},
task::spawn,
};
use tokio_util::compat::{TokioAsyncReadCompatExt, Compat};
use revolt_result::create_error;
use sentry::Level;
@@ -33,8 +34,8 @@ use crate::config::{ProtocolConfiguration, WebsocketHandshakeCallback};
use crate::events::state::{State, SubscriptionStateChange};
use revolt_models::v0;
type WsReader = SplitStream<WebSocketStream<TcpStream>>;
type WsWriter = SplitSink<WebSocketStream<TcpStream>, async_tungstenite::tungstenite::Message>;
type WsReader = SplitStream<WebSocketStream<Compat<TcpStream>>>;
type WsWriter = SplitSink<WebSocketStream<Compat<TcpStream>>, async_tungstenite::tungstenite::Message>;
/// Start a new WebSocket client worker given access to the database,
/// the relevant TCP stream and the remote address of the client.
@@ -44,7 +45,7 @@ pub async fn client(db: &'static Database, stream: TcpStream, addr: SocketAddr)
// e.g. wss://example.com?format=json&version=1
let (sender, receiver) = oneshot::channel();
let Ok(ws) = async_tungstenite::accept_hdr_async_with_config(
stream,
stream.compat(),
WebsocketHandshakeCallback::from(sender),
None,
)

View File

@@ -13,7 +13,7 @@ repository = "https://github.com/stoatchat/stoatchat"
anyhow = ["dep:sentry-anyhow"]
report-macros = ["revolt-result"]
sentry = ["dep:sentry"]
test = ["async-std"]
test = ["tokio"]
default = ["sentry"]
[dependencies]
@@ -26,7 +26,7 @@ serde = { workspace = true }
# Async
futures-locks = { workspace = true }
async-std = { workspace = true, features = ["attributes"], optional = true }
tokio = { workspace = true, optional = true }
# Logging
log = { workspace = true }

View File

@@ -70,6 +70,9 @@ trust_cloudflare = false
easypwned = ""
# Tenor API Key
tenor_key = ""
# admin api machine keys
# admin_keys = ["key_1", "key_2"]
admin_keys = []
[api.security.captcha]
# hCaptcha configuration
@@ -219,11 +222,14 @@ default_bucket = "revolt-uploads"
[features]
# Feature gate options
webhooks_enabled = false
admin_api_enabled = false
# Enable push notifications for mass pings (everyone, online, roles)
# When false this will still ping in-client but will not send notifications from pushd
mass_mentions_send_notifications = true
# Can role/everyone pings be used at all
mass_mentions_enabled = true
# Show the admin api in the OpenAPI spec
admin_api_show_spec = false
[features.limits]

View File

@@ -228,6 +228,7 @@ pub struct ApiSecurity {
pub trust_cloudflare: bool,
pub easypwned: String,
pub tenor_key: String,
pub admin_keys: Vec<String>,
}
#[derive(Deserialize, Debug, Clone)]
@@ -439,6 +440,8 @@ pub struct Features {
pub webhooks_enabled: bool,
pub mass_mentions_send_notifications: bool,
pub mass_mentions_enabled: bool,
pub admin_api_enabled: bool,
pub admin_api_show_spec: bool,
#[serde(default)]
pub advanced: FeaturesAdvanced,
@@ -586,7 +589,7 @@ macro_rules! configure {
mod tests {
use crate::init;
#[async_std::test]
#[tokio::test]
async fn it_works() {
init().await;
}

View File

@@ -15,7 +15,7 @@ mongodb = ["dep:mongodb", "bson"]
# ... Other
tasks = ["isahc", "linkify", "url-escape"]
async-std-runtime = ["async-std"]
tokio-runtime = ["tokio"]
rocket-impl = [
"rocket",
"schemars",
@@ -27,7 +27,7 @@ redis-is-patched = ["revolt-presence/redis-is-patched"]
voice = ["livekit-api", "livekit-protocol", "livekit-runtime"]
# Default Features
default = ["mongodb", "async-std-runtime", "tasks"]
default = ["mongodb", "tokio-runtime", "tasks"]
[dependencies]
# Core
@@ -64,7 +64,7 @@ serde = { workspace = true }
iso8601-timestamp = { workspace = true, features = ["serde", "bson"] }
# Events
redis-kiss = { workspace = true }
redis-kiss = { workspace = true, default-features = false, features = ["tokio-runtime"] }
# Database
bson = { workspace = true, optional = true }
@@ -81,7 +81,8 @@ async-trait = { workspace = true }
async-recursion = { workspace = true }
# Async
async-std = { workspace = true, features = ["attributes"], optional = true }
tokio = { workspace = true, optional = true }
tokio-stream = { workspace = true }
# Axum Impl
axum = { workspace = true, optional = true }

View File

@@ -2,9 +2,9 @@
mod mongodb;
mod reference;
use rand::Rng;
use revolt_config::config;
use revolt_result::Result;
#[cfg(feature = "mongodb")]
pub use self::mongodb::*;

View File

@@ -1,17 +1,27 @@
use std::{collections::HashMap, sync::Arc};
use std::{
collections::{BTreeMap, HashMap},
sync::Arc,
};
use futures::lock::Mutex;
use crate::{
Bot, Channel, ChannelCompositeKey, ChannelUnread, Emoji, File, FileHash, Invite, Member,
MemberCompositeKey, Message, PolicyChange, RatelimitEvent, Report, Server, ServerBan, Snapshot,
User, UserSettings, Webhook, Account, AccountInvite, Session, MFATicket
AdminAuditItem, AdminCase, AdminComment, AdminStrike, AdminToken, AdminUser, Bot, Channel,
ChannelCompositeKey, ChannelUnread, Emoji, File, FileHash, Invite, Member, MemberCompositeKey,
Message, PolicyChange, RatelimitEvent, Report, Server, ServerBan, Snapshot, User, UserSettings,
Webhook, Account, AccountInvite, Session, MFATicket
};
database_derived!(
/// Reference implementation
#[derive(Default, Debug)]
pub struct ReferenceDb {
pub admin_audits: Arc<Mutex<BTreeMap<String, AdminAuditItem>>>,
pub admin_cases: Arc<Mutex<HashMap<String, AdminCase>>>,
pub admin_comments: Arc<Mutex<HashMap<String, AdminComment>>>,
pub admin_strikes: Arc<Mutex<HashMap<String, AdminStrike>>>,
pub admin_tokens: Arc<Mutex<HashMap<String, AdminToken>>>,
pub admin_users: Arc<Mutex<HashMap<String, AdminUser>>>,
pub bots: Arc<Mutex<HashMap<String, Bot>>>,
pub channels: Arc<Mutex<HashMap<String, Channel>>>,
pub channel_invites: Arc<Mutex<HashMap<String, Invite>>>,

View File

@@ -281,6 +281,16 @@ pub enum EventV1 {
id: String,
},
/// Channel wiped
///
/// Clients should remove all associated data:
/// - Messages
/// - Unreads
/// - Voice States
ChannelWipe {
id: String,
},
/// User joins a group
ChannelGroupJoin {
id: String,

View File

@@ -25,8 +25,8 @@ pub use mongodb;
#[macro_use]
extern crate bson;
#[cfg(not(feature = "async-std-runtime"))]
compile_error!("async-std-runtime feature must be enabled.");
#[cfg(not(feature = "tokio-runtime"))]
compile_error!("tokio-runtime feature must be enabled.");
#[macro_export]
#[cfg(debug_assertions)]

View File

@@ -0,0 +1,5 @@
mod models;
mod ops;
pub use models::*;
pub use ops::*;

View File

@@ -0,0 +1,45 @@
use iso8601_timestamp::Timestamp;
use revolt_models::v0::AdminAuditItemActions;
use crate::util::basic::transform_optional_string;
auto_derived! {
pub struct AdminAuditItem {
/// The audit item ID
#[serde(rename = "_id")]
pub id: String,
/// The moderator who performed the action
pub mod_id: String,
/// The action performed (previously 'permission'). Should be one of v0::AdminAuditActionAction
pub action: String,
/// The relevant case ID, if applicable
pub case_id: Option<String>,
/// The object the action was taken against, if applicable
pub target_id: Option<String>,
/// The context of the action, if applicable (eg. search phrases)
pub context: Option<String>,
/// When the action occurred, in iso8601.
pub timestamp: String
}
}
impl AdminAuditItem {
pub fn new(
mod_id: &str,
action: AdminAuditItemActions,
case_id: Option<&str>,
target_id: Option<&str>,
context: Option<&str>,
) -> AdminAuditItem {
let id = ulid::Ulid::new().to_string();
AdminAuditItem {
id,
mod_id: mod_id.to_string(),
action: action.to_string(),
case_id: transform_optional_string(case_id),
target_id: transform_optional_string(target_id),
context: transform_optional_string(context),
timestamp: Timestamp::now_utc().format_short().to_string(),
}
}
}

View File

@@ -0,0 +1,17 @@
mod mongodb;
mod reference;
use revolt_result::Result;
use crate::models::admin_audits::AdminAuditItem;
#[async_trait]
pub trait AbstractAdminAudits: Sync + Send {
async fn admin_audit_insert(&self, audit: AdminAuditItem) -> Result<()>;
async fn admin_audit_fetch(
&self,
before_id: Option<&str>,
limit: i64,
) -> Result<Vec<AdminAuditItem>>;
}

View File

@@ -0,0 +1,48 @@
use tokio_stream::StreamExt;
use revolt_result::Result;
use crate::AdminAuditItem;
use crate::MongoDb;
use super::AbstractAdminAudits;
static COL: &str = "admin_audits";
#[async_trait]
impl AbstractAdminAudits for MongoDb {
async fn admin_audit_insert(&self, audit: AdminAuditItem) -> Result<()> {
query!(self, insert_one, COL, audit).map(|_| ())
}
async fn admin_audit_fetch(
&self,
before_id: Option<&str>,
limit: i64,
) -> Result<Vec<AdminAuditItem>> {
if let Some(before) = before_id {
Ok(self
.col::<AdminAuditItem>(COL)
.find(doc! {
"_id": { "$lt": before}
})
.sort(doc! {"_id": -1})
.limit(limit)
.await
.map_err(|_| create_database_error!("find", COL))?
.filter_map(|f| f.ok())
.collect()
.await)
} else {
Ok(self
.col::<AdminAuditItem>(COL)
.find(doc! {})
.sort(doc! {"_id": -1})
.limit(limit)
.await
.map_err(|_| create_database_error!("find", COL))?
.filter_map(|f| f.ok())
.collect()
.await)
}
}
}

View File

@@ -0,0 +1,45 @@
use revolt_result::Result;
use crate::AdminAuditItem;
use crate::ReferenceDb;
use super::AbstractAdminAudits;
#[async_trait]
impl AbstractAdminAudits for ReferenceDb {
async fn admin_audit_insert(&self, audit: AdminAuditItem) -> Result<()> {
let mut admin_audits = self.admin_audits.lock().await;
if admin_audits.contains_key(&audit.id) {
Err(create_database_error!("insert", "admin_audits"))
} else {
admin_audits.insert(audit.id.to_string(), audit.clone());
Ok(())
}
}
async fn admin_audit_fetch(
&self,
before_id: Option<&str>,
limit: i64,
) -> Result<Vec<AdminAuditItem>> {
let admin_audits = self.admin_audits.lock().await;
if let Some(before) = before_id {
Ok(admin_audits
.iter()
.rev()
.skip_while(|(id, _)| id.as_str() <= before)
.by_ref()
.take(limit as usize)
.map(|(_, item)| item.clone())
.collect())
} else {
Ok(admin_audits
.iter()
.rev()
.by_ref()
.take(limit as usize)
.map(|(_, item)| item.clone())
.collect())
}
}
}

View File

@@ -0,0 +1,5 @@
mod models;
mod ops;
pub use models::*;
pub use ops::*;

View File

@@ -0,0 +1,53 @@
auto_derived_partial! {
pub struct AdminCase {
/// The case ID
#[serde(rename = "_id")]
pub id: String,
/// The case Short ID
pub short_id: String,
/// The owner of the case
pub owner_id: String,
/// The title of the case
pub title: String,
/// The status of the case (open/closed)
pub status: String,
/// When the case was closed, in iso8601
pub closed_at: Option<String>,
/// The tags for the case
pub tags: Vec<String>,
},
"PartialAdminCase"
}
impl AdminCase {
pub fn new(owner_id: &str, title: &str) -> AdminCase {
let id = ulid::Ulid::new().to_string();
let short_id = id.clone().split_off(id.len() - 7);
AdminCase {
id,
short_id,
owner_id: owner_id.to_string(),
title: title.to_string(),
status: "Open".to_string(),
closed_at: None,
tags: vec![],
}
}
pub fn merge_tags(&self, other: &[String]) -> Vec<String> {
let mut resp: Vec<String> = vec![];
// Shitty combining chain; itll only ever be like 5 items
resp.extend(self.tags.clone());
resp.extend(other.iter().filter_map(|p| {
if !self.tags.contains(p) {
Some(p.clone())
} else {
None
}
}));
resp
}
}

View File

@@ -0,0 +1,30 @@
mod mongodb;
mod reference;
use revolt_result::Result;
use crate::models::admin_cases::{AdminCase, PartialAdminCase};
#[async_trait]
pub trait AbstractAdminCases: Sync + Send {
async fn admin_case_create(&self, case: AdminCase) -> Result<()>;
async fn admin_case_assign_report(&self, case_id: &str, report_id: &str) -> Result<()>;
async fn admin_case_edit(&self, case_id: &str, partial: &PartialAdminCase) -> Result<()>;
async fn admin_case_fetch(&self, case_id: &str) -> Result<AdminCase>;
async fn admin_case_fetch_from_shorthand(&self, short_id: &str) -> Result<AdminCase>;
/// title is fuzzy, the rest of the arguments are direct matches
/// before_id and limit are for paginating
async fn admin_case_search(
&self,
title: Option<&str>,
status: Option<&str>,
owner_id: Option<&str>,
tags: Option<Vec<String>>,
before_id: Option<&str>,
limit: i64,
) -> Result<Vec<AdminCase>>;
}

View File

@@ -0,0 +1,85 @@
use tokio_stream::StreamExt;
use bson::Document;
use revolt_result::Result;
use crate::MongoDb;
use crate::{AdminCase, PartialAdminCase};
use super::AbstractAdminCases;
static COL: &str = "admin_cases";
#[async_trait]
impl AbstractAdminCases for MongoDb {
async fn admin_case_create(&self, case: AdminCase) -> Result<()> {
query!(self, insert_one, COL, case).map(|_| ())
}
async fn admin_case_assign_report(&self, case_id: &str, report_id: &str) -> Result<()> {
self.col::<Document>("safety_reports")
.update_one(
doc! {"_id": { "$regex": format!("{}$", report_id)}},
doc! {"case_id": case_id},
)
.await
.map(|_| ())
.map_err(|_| create_database_error!("safety_reports", "update_one"))
}
async fn admin_case_edit(&self, case_id: &str, partial: &PartialAdminCase) -> Result<()> {
query!(self, update_one_by_id, COL, case_id, partial, vec![], None).map(|_| ())
}
async fn admin_case_fetch(&self, case_id: &str) -> Result<AdminCase> {
query!(self, find_one_by_id, COL, case_id)?
.ok_or_else(|| create_database_error!("find_one", COL))
}
async fn admin_case_fetch_from_shorthand(&self, short_id: &str) -> Result<AdminCase> {
query!(self, find_one, COL, doc! {"short_id": short_id})?
.ok_or_else(|| create_database_error!("find_one", COL))
}
/// title is fuzzy, the rest of the arguments are direct matches
async fn admin_case_search(
&self,
title: Option<&str>,
status: Option<&str>,
owner_id: Option<&str>,
tags: Option<Vec<String>>,
before_id: Option<&str>,
limit: i64,
) -> Result<Vec<AdminCase>> {
let mut query = Document::new();
if let Some(title) = title {
query.insert("$text", doc! {"$search": title});
}
if let Some(status) = status {
query.insert("status", status);
}
if let Some(owner) = owner_id {
query.insert("owner_id", owner);
}
if let Some(tags) = tags {
query.insert("tags", doc! {"$elemMatch": {"$in": tags}});
}
if let Some(before) = before_id {
query.insert("_id", doc! {"$lt": before});
}
Ok(self
.col::<AdminCase>(COL)
.find(query)
.limit(limit)
.await
.map_err(|_| create_database_error!("find", COL))?
.filter_map(|f| f.ok())
.collect()
.await)
}
}

View File

@@ -0,0 +1,112 @@
use revolt_result::Result;
use crate::ReferenceDb;
use crate::{AdminCase, PartialAdminCase};
use super::AbstractAdminCases;
#[async_trait]
impl AbstractAdminCases for ReferenceDb {
async fn admin_case_create(&self, case: AdminCase) -> Result<()> {
let mut admin_cases = self.admin_cases.lock().await;
if admin_cases.contains_key(&case.id) {
Err(create_database_error!("insert", "admin_cases"))
} else {
admin_cases.insert(case.id.to_string(), case.clone());
Ok(())
}
}
async fn admin_case_assign_report(&self, case_id: &str, report_id: &str) -> Result<()> {
let mut reports = self.safety_reports.lock().await;
if let Some(report) = reports.get_mut(report_id) {
report.case_id = Some(case_id.to_string());
Ok(())
} else {
Err(create_error!(NotFound))
}
}
async fn admin_case_edit(&self, case_id: &str, partial: &PartialAdminCase) -> Result<()> {
let mut admin_cases = self.admin_cases.lock().await;
if let Some(case) = admin_cases.get_mut(case_id) {
case.apply_options(partial.clone());
Ok(())
} else {
Err(create_error!(NotFound))
}
}
async fn admin_case_fetch(&self, case_id: &str) -> Result<AdminCase> {
let admin_cases = self.admin_cases.lock().await;
if let Some(case) = admin_cases.get(case_id) {
Ok(case.clone())
} else {
Err(create_error!(NotFound))
}
}
async fn admin_case_fetch_from_shorthand(&self, short_id: &str) -> Result<AdminCase> {
let admin_cases = self.admin_cases.lock().await;
if let Some((_, case)) = admin_cases.iter().find(|(_, c)| c.short_id == short_id) {
Ok(case.clone())
} else {
Err(create_error!(NotFound))
}
}
/// title is fuzzy, the rest of the arguments are direct matches
/// before_id and limit are for paginating
async fn admin_case_search(
&self,
title: Option<&str>,
status: Option<&str>,
owner_id: Option<&str>,
tags: Option<Vec<String>>,
before_id: Option<&str>,
limit: i64,
) -> Result<Vec<AdminCase>> {
let admin_cases = self.admin_cases.lock().await;
Ok(admin_cases
.iter()
.filter(|(_, case)| {
if let Some(title) = title {
case.title.to_lowercase().contains(title)
} else {
true
}
})
.filter(|(_, case)| {
if let Some(status) = status {
case.status == status
} else {
true
}
})
.filter(|(_, case)| {
if let Some(owner) = owner_id {
case.owner_id == owner
} else {
true
}
})
.filter(|(_, case)| {
if let Some(tags) = &tags {
tags.iter().filter(|p| case.tags.contains(p)).count() > 0
} else {
true
}
})
.skip_while(|(id, _)| {
if let Some(before) = before_id {
id.as_str() <= before
} else {
false
}
})
.by_ref()
.take(limit as usize)
.map(|(_, case)| case.clone())
.collect())
}
}

View File

@@ -0,0 +1,5 @@
mod models;
mod ops;
pub use models::*;
pub use ops::*;

View File

@@ -0,0 +1,95 @@
use revolt_models::v0;
auto_derived_partial! {
pub struct AdminComment {
/// The comment ID
#[serde(rename = "_id")]
pub id: String,
/// The Object this comment is attached to
pub object_id: String,
/// The ID of the case this comment is attached to, if applicable
pub case_id: Option<String>,
/// The author ID
pub user_id: String,
/// When the comment was edited, if applicable, in iso8601
pub edited_at: Option<String>,
/// The content, if not a system event
pub content: Option<String>,
/// The system event that occurred, if applicable
pub system_message: Option<String>,
/// The system message target
pub system_message_target: Option<String>,
/// The system message raw context
pub system_message_context: Option<String>,
},
"PartialAdminComment"
}
impl AdminComment {
pub fn new(
object_id: &str,
user_id: &str,
content: &str,
case_id: Option<&str>,
) -> AdminComment {
let id = ulid::Ulid::new().to_string();
AdminComment {
id,
object_id: object_id.to_string(),
case_id: case_id.map(|c| c.to_string()),
user_id: user_id.to_string(),
edited_at: None,
content: Some(content.to_string()),
system_message: None,
system_message_context: None,
system_message_target: None,
}
}
pub fn new_system_message(
object_id: &str,
user_id: &str,
case_id: &str,
system_message_kind: v0::AdminAuditItemActions,
context: Option<&str>,
target: &str,
) -> AdminComment {
let id = ulid::Ulid::new().to_string();
AdminComment {
id,
object_id: object_id.to_string(),
case_id: Some(case_id.to_string()),
user_id: user_id.to_string(),
edited_at: None,
content: None,
system_message: Some(serde_json::to_string(&system_message_kind).unwrap()), // if this explodes i'll eat my hat.
system_message_context: context.map(|c| c.to_string()),
system_message_target: Some(target.to_string()),
}
}
/// Edit the comment, updating the edited_at time as well
pub fn edit(&mut self, content: &str) {
self.content = Some(content.to_string());
self.edited_at = Some(
iso8601_timestamp::Timestamp::now_utc()
.format_short()
.to_string(),
);
}
}
impl PartialAdminComment {
pub fn new() -> PartialAdminComment {
PartialAdminComment::default()
}
/// Edit the comment, updating the edited_at time as well
pub fn edit(&mut self, content: &str) {
self.content = Some(content.to_string());
self.edited_at = Some(
iso8601_timestamp::Timestamp::now_utc()
.format_short()
.to_string(),
);
}
}

View File

@@ -0,0 +1,28 @@
mod mongodb;
mod reference;
use revolt_result::Result;
use crate::{models::admin_comments::AdminComment, PartialAdminComment};
#[async_trait]
pub trait AbstractAdminComments: Sync + Send {
async fn admin_comment_insert(&self, comment: AdminComment) -> Result<()>;
async fn admin_comment_update(
&self,
comment_id: &str,
partial: &PartialAdminComment,
) -> Result<()>;
async fn admin_comment_fetch(&self, id: &str) -> Result<AdminComment>;
/// Fetch all comments related to the case. This includes comments made on other objects.
async fn admin_comment_fetch_related_case(&self, case_id: &str) -> Result<Vec<AdminComment>>;
/// Fetch all comments on an object
async fn admin_comment_fetch_object_comments(
&self,
object_id: &str,
) -> Result<Vec<AdminComment>>;
}

View File

@@ -0,0 +1,47 @@
use revolt_result::Result;
use crate::MongoDb;
use crate::{AdminComment, PartialAdminComment};
use super::AbstractAdminComments;
static COL: &str = "admin_comments";
#[async_trait]
impl AbstractAdminComments for MongoDb {
async fn admin_comment_insert(&self, comment: AdminComment) -> Result<()> {
query!(self, insert_one, COL, comment).map(|_| ())
}
async fn admin_comment_update(
&self,
comment_id: &str,
partial: &PartialAdminComment,
) -> Result<()> {
query!(
self,
update_one_by_id,
COL,
comment_id,
partial,
vec![],
None
)
.map(|_| ())
}
async fn admin_comment_fetch(&self, id: &str) -> Result<AdminComment> {
query!(self, find_one, COL, doc! {"_id": id})?.ok_or_else(|| create_error!(NotFound))
}
async fn admin_comment_fetch_related_case(&self, case_id: &str) -> Result<Vec<AdminComment>> {
query!(self, find, COL, doc! {"case_id": case_id})
}
async fn admin_comment_fetch_object_comments(
&self,
object_id: &str,
) -> Result<Vec<AdminComment>> {
query!(self, find, COL, doc! {"object_id": object_id})
}
}

View File

@@ -0,0 +1,73 @@
use iso8601_timestamp::Timestamp;
use revolt_result::Result;
use crate::ReferenceDb;
use crate::{AdminComment, PartialAdminComment};
use super::AbstractAdminComments;
#[async_trait]
impl AbstractAdminComments for ReferenceDb {
async fn admin_comment_insert(&self, comment: AdminComment) -> Result<()> {
let mut admin_comments = self.admin_comments.lock().await;
if admin_comments.contains_key(&comment.id) {
Err(create_database_error!("insert", "admin_comments"))
} else {
admin_comments.insert(comment.id.to_string(), comment.clone());
Ok(())
}
}
async fn admin_comment_update(
&self,
comment_id: &str,
partial: &PartialAdminComment,
) -> Result<()> {
let mut admin_comments = self.admin_comments.lock().await;
if let Some(comment) = admin_comments.get_mut(comment_id) {
comment.apply_options(partial.clone());
comment.edited_at = Some(Timestamp::now_utc().format().to_string());
Ok(())
} else {
Err(create_error!(NotFound))
}
}
async fn admin_comment_fetch(&self, id: &str) -> Result<AdminComment> {
let admin_comments = self.admin_comments.lock().await;
admin_comments
.get(id)
.map_or(Err(create_error!(NotFound)), |ac| Ok(ac.clone()))
}
async fn admin_comment_fetch_related_case(&self, case_id: &str) -> Result<Vec<AdminComment>> {
let admin_comments = self.admin_comments.lock().await;
Ok(admin_comments
.iter()
.filter_map(|(_, c)| {
if c.case_id.as_ref().is_some_and(|c| c == case_id) {
Some(c.clone())
} else {
None
}
})
.collect())
}
async fn admin_comment_fetch_object_comments(
&self,
object_id: &str,
) -> Result<Vec<AdminComment>> {
let admin_comments = self.admin_comments.lock().await;
Ok(admin_comments
.iter()
.filter_map(|(_, c)| {
if c.object_id == object_id {
Some(c.clone())
} else {
None
}
})
.collect())
}
}

View File

@@ -11,7 +11,7 @@ auto_derived!(
#[cfg(test)]
mod tests {
#[async_std::test]
#[tokio::test]
async fn migrate() {
database_test!(|db| async move {
// Initialise the database

View File

@@ -28,6 +28,20 @@ impl AbstractMigrations for MongoDb {
init::create_database(self).await;
}
let config = revolt_config::config().await;
if config.features.admin_api_enabled {
let db = self.db();
let colls = db
.list_collection_names()
.await
.expect("Failed to fetch collection names.");
if !colls.iter().any(|x| x == "admin_audits") {
info!("You've enabled the admin api for the first time. Setting up database...");
init::create_admin_database(&db).await;
}
}
Ok(())
}
}

View File

@@ -8,6 +8,8 @@ pub async fn create_database(db: &MongoDb) {
info!("Creating database.");
let db = db.db();
let config = revolt_config::config().await;
db.create_collection("accounts")
.await
.expect("Failed to create accounts collection.");
@@ -275,6 +277,11 @@ pub async fn create_database(db: &MongoDb) {
.await
.expect("Failed to create ratelimit_events index.");
// Only create these tables if the admin api is enabled
if config.features.admin_api_enabled {
create_admin_database(&db).await;
}
db.run_command(doc! {
"createIndexes": "accounts",
"indexes": [
@@ -361,3 +368,128 @@ pub async fn create_database(db: &MongoDb) {
info!("Created database.");
}
pub async fn create_admin_database(db: &mongodb::Database) {
db.create_collection("admin_audits")
.await
.expect("Failed to create admin_audits collection.");
db.create_collection("admin_comments")
.await
.expect("Failed to create admin_comments collection.");
db.create_collection("admin_cases")
.await
.expect("Failed to create admin_cases collection.");
db.create_collection("admin_strikes")
.await
.expect("Failed to create admin_strikes collection.");
db.create_collection("admin_tokens")
.await
.expect("Failed to create admin_tokens collection.");
db.create_collection("admin_users")
.await
.expect("Failed to create admin_users collection.");
db.run_command(doc! {
"createIndexes": "admin_comments",
"indexes": [
{
"key": {
"case_id": 1_i32,
},
"unique": false,
"name": "case_id"
},
{
"key": {
"object_id": 1_i32,
},
"unique": false,
"name": "object_id"
}
]
})
.await
.expect("Failed to create admin_comments index.");
db.run_command(doc! {
"createIndexes": "admin_cases",
"indexes": [
{
"key": {
"id": 1_i32,
},
"unique": true,
"name": "id"
},
{
"key": {
"short_id": 1_i32,
},
"unique": true,
"name": "short_id"
}
]
})
.await
.expect("Failed to create admin_cases index.");
db.run_command(doc! {
"createIndexes": "admin_users",
"indexes": [
{
"key": {
"email": 1_i32,
},
"unique": true,
"name": "case_id"
},
]
})
.await
.expect("Failed to create admin_comments index.");
db.run_command(doc! {
"createIndexes": "admin_audits",
"indexes": [
{
"key": {
"mod_id": 1_i32,
},
"unique": false,
"name": "mod_id"
},
{
"key": {
"target_id": 1_i32,
},
"unique": false,
"name": "target_id"
}
]
})
.await
.expect("Failed to create admin_audits index.");
// indexes on regular data that are only needed for admin queries
db.run_command(doc! {
"createIndexes": "server_members",
"indexes": [
{
"key": {
"joined_at": 1_i32,
},
"unique": false,
"name": "joined_at"
},
]
})
.await
.expect("Failed to create server_members::joined_at index.");
info!("Created admin database.");
}

View File

@@ -452,7 +452,7 @@ pub async fn run_migrations(db: &MongoDb, revision: i32) -> i32 {
warn!("This is a destructive operation and will wipe existing permission data (excl. defaults for SendMessage).");
warn!("Taking a backup is advised.");
warn!("Continuing in 10 seconds...");
async_std::task::sleep(Duration::from_secs(10)).await;
tokio::time::sleep(Duration::from_secs(10)).await;
let servers = db.col::<Document>("servers");
let mut cursor = servers.find(doc! {}).await.unwrap();

View File

@@ -0,0 +1,5 @@
mod models;
mod ops;
pub use models::*;
pub use ops::*;

View File

@@ -0,0 +1,47 @@
use crate::util::basic::transform_optional_string;
auto_derived_partial! {
pub struct AdminStrike {
/// The strike ID
#[serde(rename = "_id")]
pub id: String,
/// The object receiving the strike (user/server)
pub target_id: String,
/// The moderator who gave the strike
pub mod_id: String,
/// The case the strike was made under
pub case_id: Option<String>,
/// Action associated with the strike (eg. suspension/ban)
pub associated_action: Option<String>,
/// Has the strike been removed
pub overruled: bool,
/// The user-facing reason for the strike
pub reason: String,
/// Internal context for the strike
pub mod_context: Option<String>,
},
"PartialAdminStrike"
}
impl AdminStrike {
pub fn new(
target_id: &str,
mod_id: &str,
case_id: Option<&str>,
associated_action: Option<&str>,
reason: &str,
mod_context: Option<&str>,
) -> AdminStrike {
let id = ulid::Ulid::new().to_string();
AdminStrike {
id,
target_id: target_id.to_string(),
mod_id: mod_id.to_string(),
case_id: transform_optional_string(case_id),
associated_action: transform_optional_string(associated_action),
overruled: false,
reason: reason.to_string(),
mod_context: transform_optional_string(mod_context),
}
}
}

View File

@@ -0,0 +1,17 @@
mod mongodb;
mod reference;
use revolt_result::Result;
use crate::models::admin_strikes::models::{AdminStrike, PartialAdminStrike};
#[async_trait]
pub trait AbstractAdminStrikes: Sync + Send {
async fn admin_strike_insert(&self, strike: AdminStrike) -> Result<()>;
async fn admin_strike_update(&self, strike_id: &str, partial: PartialAdminStrike)
-> Result<()>;
async fn admin_strike_get(&self, strike_id: &str) -> Result<AdminStrike>;
async fn admin_strike_get_user(&self, user_id: &str) -> Result<Vec<AdminStrike>>;
}

View File

@@ -0,0 +1,41 @@
use revolt_result::Result;
use crate::MongoDb;
use crate::{AdminStrike, PartialAdminStrike};
use super::AbstractAdminStrikes;
static COL: &str = "admin_strikes";
#[async_trait]
impl AbstractAdminStrikes for MongoDb {
async fn admin_strike_insert(&self, strike: AdminStrike) -> Result<()> {
query!(self, insert_one, COL, strike).map(|_| ())
}
async fn admin_strike_update(
&self,
strike_id: &str,
partial: PartialAdminStrike,
) -> Result<()> {
query!(
self,
update_one_by_id,
COL,
strike_id,
partial,
vec![],
None
)
.map(|_| ())
}
async fn admin_strike_get(&self, strike_id: &str) -> Result<AdminStrike> {
query!(self, find_one_by_id, COL, strike_id)?
.ok_or_else(|| create_database_error!("find_one", COL))?
}
async fn admin_strike_get_user(&self, user_id: &str) -> Result<Vec<AdminStrike>> {
query!(self, find, COL, doc! {"target_id": user_id})
}
}

View File

@@ -0,0 +1,51 @@
use revolt_result::Result;
use crate::ReferenceDb;
use crate::{AdminStrike, PartialAdminStrike};
use super::AbstractAdminStrikes;
#[async_trait]
impl AbstractAdminStrikes for ReferenceDb {
async fn admin_strike_insert(&self, strike: AdminStrike) -> Result<()> {
let mut admin_strikes = self.admin_strikes.lock().await;
if admin_strikes.contains_key(&strike.id) {
Err(create_database_error!("insert", "admin_strikes"))
} else {
admin_strikes.insert(strike.id.to_string(), strike.clone());
Ok(())
}
}
async fn admin_strike_update(
&self,
strike_id: &str,
partial: PartialAdminStrike,
) -> Result<()> {
let mut admin_strikes = self.admin_strikes.lock().await;
if let Some(strike) = admin_strikes.get_mut(strike_id) {
strike.apply_options(partial);
Ok(())
} else {
Err(create_error!(NotFound))
}
}
async fn admin_strike_get(&self, strike_id: &str) -> Result<AdminStrike> {
let admin_strikes = self.admin_strikes.lock().await;
if let Some(strike) = admin_strikes.get(strike_id) {
Ok(strike.clone())
} else {
Err(create_error!(NotFound))
}
}
async fn admin_strike_get_user(&self, user_id: &str) -> Result<Vec<AdminStrike>> {
let admin_strikes = self.admin_strikes.lock().await;
Ok(admin_strikes
.iter()
.filter(|(_, strike)| strike.target_id == user_id)
.map(|(_, strike)| strike.clone())
.collect())
}
}

View File

@@ -0,0 +1,12 @@
mod models;
mod ops;
#[cfg(feature = "rocket-impl")]
mod rocket;
mod schema;
#[cfg(feature = "rocket-impl")]
pub use self::rocket::*;
#[cfg(feature = "rocket-impl")]
pub use self::schema::*;
pub use models::*;
pub use ops::*;

View File

@@ -0,0 +1,71 @@
use iso8601_timestamp::Timestamp;
use revolt_result::Result;
use crate::{AdminUser, Database};
auto_derived! {
pub struct AdminToken {
/// The token ID
#[serde(rename = "_id")]
pub id: String,
/// The user this token is attached to
pub user_id: String,
/// The token itself
pub token: String,
/// The expiry timestamp for this token, in iso6801
pub expiry: String
}
/// This struct is used to validate machine tokens when doing machine to machine communication.
pub struct AdminMachineToken {
/// Placeholder field.
pub on_behalf_of: AdminUser
}
pub enum AdminAuthorization {
AdminUser(AdminUser),
AdminMachine(AdminMachineToken),
}
}
impl AdminToken {
pub fn new(user_id: &str, expiry: Timestamp) -> AdminToken {
let id = ulid::Ulid::new().to_string();
let token = nanoid::nanoid!(64);
AdminToken {
id,
user_id: user_id.to_string(),
token,
expiry: expiry.format_short().to_string(),
}
}
}
impl AdminMachineToken {
pub async fn new_from_email(email: &str, db: &Database) -> Result<AdminMachineToken> {
println!("{}", email);
if email == "example@example.com" {
// This is basically just a workaround to make the first user.
let user_count = db.admin_user_count().await?;
println!("{}", user_count);
if user_count == 0 {
return Ok(AdminMachineToken {
on_behalf_of: AdminUser {
id: "00".to_string(),
platform_user_id: "".to_string(),
email: "example@example.com".to_string(),
active: true,
permissions: u64::MAX,
},
});
}
}
let user = db.admin_user_fetch_email(email).await?;
Ok(AdminMachineToken { on_behalf_of: user })
}
pub async fn new_from_id(user_id: &str, db: &Database) -> Result<AdminMachineToken> {
let user = db.admin_user_fetch(user_id).await?;
Ok(AdminMachineToken { on_behalf_of: user })
}
}

View File

@@ -0,0 +1,16 @@
mod mongodb;
mod reference;
use revolt_result::Result;
use crate::models::admin_tokens::AdminToken;
#[async_trait]
pub trait AbstractAdminTokens: Sync + Send {
async fn admin_token_create(&self, token: AdminToken) -> Result<()>;
async fn admin_token_revoke(&self, token_id: &str) -> Result<()>;
async fn admin_token_authenticate(&self, token: &str) -> Result<AdminToken>;
async fn admin_token_fetch(&self, id: &str) -> Result<AdminToken>;
}

View File

@@ -0,0 +1,34 @@
use revolt_result::Result;
use crate::AdminToken;
use crate::MongoDb;
use super::AbstractAdminTokens;
static COL: &str = "admin_tokens";
#[async_trait]
impl AbstractAdminTokens for MongoDb {
async fn admin_token_create(&self, token: AdminToken) -> Result<()> {
query!(self, insert_one, COL, token).map(|_| ())
}
async fn admin_token_revoke(&self, token_id: &str) -> Result<()> {
query!(self, delete_one_by_id, COL, token_id).map(|k| {
if k.deleted_count > 0 {
Ok(())
} else {
Err(create_error!(NotFound))
}
})?
}
async fn admin_token_authenticate(&self, token: &str) -> Result<AdminToken> {
query!(self, find_one, COL, doc! {"token": token})?
.ok_or_else(|| create_error!(InvalidCredentials))
}
async fn admin_token_fetch(&self, id: &str) -> Result<AdminToken> {
query!(self, find_one_by_id, COL, id)?.ok_or_else(|| create_error!(NotFound))
}
}

View File

@@ -0,0 +1,53 @@
use revolt_result::Result;
use crate::AdminToken;
use crate::ReferenceDb;
use super::AbstractAdminTokens;
#[async_trait]
impl AbstractAdminTokens for ReferenceDb {
async fn admin_token_create(&self, token: AdminToken) -> Result<()> {
let mut admin_tokens = self.admin_tokens.lock().await;
if admin_tokens.contains_key(&token.id) {
Err(create_database_error!("insert", "admin_tokens"))
} else {
admin_tokens.insert(token.id.to_string(), token.clone());
Ok(())
}
}
async fn admin_token_revoke(&self, token_id: &str) -> Result<()> {
let mut admin_tokens = self.admin_tokens.lock().await;
if admin_tokens.remove(token_id).is_some() {
Ok(())
} else {
Err(create_error!(NotFound))
}
}
async fn admin_token_authenticate(&self, token: &str) -> Result<AdminToken> {
let admin_tokens = self.admin_tokens.lock().await;
let result = admin_tokens
.iter()
.filter_map(|(_, t)| {
if t.token == token {
Some(t.clone())
} else {
None
}
})
.next();
Ok(result.ok_or_else(|| create_error!(NotFound))?)
}
async fn admin_token_fetch(&self, id: &str) -> Result<AdminToken> {
let admin_tokens = self.admin_tokens.lock().await;
let result = admin_tokens.iter().find(|tok| tok.0 == id);
Ok(result
.map(|t| t.1.clone())
.ok_or_else(|| create_error!(NotFound))?)
}
}

View File

@@ -0,0 +1,146 @@
use iso8601_timestamp::Timestamp;
use revolt_config::{capture_internal_error, report_error};
use revolt_result::{Error, Result};
use rocket::http::Status;
use rocket::request::{self, FromRequest, Outcome, Request};
use crate::{AdminAuthorization, AdminMachineToken, AdminUser, Database};
#[rocket::async_trait]
impl<'r> FromRequest<'r> for AdminMachineToken {
type Error = Error;
async fn from_request(request: &'r Request<'_>) -> request::Outcome<Self, Self::Error> {
let user: &Option<AdminMachineToken> = request
.local_cache_async(async {
let db = request.rocket().state::<Database>().expect("`Database`");
if let Some(on_behalf_of) = request
.headers()
.get("x-admin-on-behalf-of")
.next()
.map(|x| x.to_string())
{
if let Some(token) = request
.headers()
.get("x-admin-machine")
.next()
.map(|x| x.to_string())
{
let config = revolt_config::config().await;
let token = token.to_string();
if config.api.security.admin_keys.contains(&token) {
let resp: Result<AdminMachineToken> = if on_behalf_of.contains("@") {
AdminMachineToken::new_from_email(&on_behalf_of, db).await
} else {
AdminMachineToken::new_from_id(&on_behalf_of, db).await
};
if let Ok(resp) = resp {
return Some(resp);
}
}
}
}
None
})
.await;
if let Some(user) = user {
Outcome::Success(user.clone())
} else {
Outcome::Error((Status::Unauthorized, create_error!(InvalidSession)))
}
}
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for AdminAuthorization {
type Error = Error;
async fn from_request(request: &'r Request<'_>) -> request::Outcome<Self, Self::Error> {
let machine: &Option<AdminMachineToken> = request
.local_cache_async(async {
let db = request.rocket().state::<Database>().expect("`Database`");
if let Some(token) = request
.headers()
.get("x-admin-machine")
.next()
.map(|x| x.to_string())
{
if let Some(on_behalf_of) = request
.headers()
.get("x-admin-on-behalf-of")
.next()
.map(|x| x.to_string())
{
let config = revolt_config::config().await;
let token = token.to_string();
if config.api.security.admin_keys.contains(&token) {
let resp: Result<AdminMachineToken> = if on_behalf_of.contains("@") {
AdminMachineToken::new_from_email(&on_behalf_of, db).await
} else {
AdminMachineToken::new_from_id(&on_behalf_of, db).await
};
if let Ok(resp) = resp {
return Some(resp);
}
}
}
}
None
})
.await;
if let Some(machine) = machine {
if !machine.on_behalf_of.active {
Outcome::Error((Status::Unauthorized, create_error!(LockedOut)))
} else {
Outcome::Success(AdminAuthorization::AdminMachine(machine.clone()))
}
} else {
let user: &Option<AdminUser> = request
.local_cache_async(async {
let db = request.rocket().state::<Database>().expect("`Database`");
let token = request
.headers()
.get("x-admin-user")
.next()
.map(|x| x.to_string());
if let Some(token) = token {
if let Ok(admin_token) = db.admin_token_authenticate(&token).await {
if let Some(expiry) = Timestamp::parse(&admin_token.expiry) {
if expiry < Timestamp::now_utc() {
if let Err(e) = db.admin_token_revoke(&admin_token.id).await {
capture_internal_error!(e);
}
return None;
} else if let Ok(user) =
db.admin_user_fetch(&admin_token.user_id).await
{
if !user.active {
return None;
}
return Some(user);
}
}
}
}
None
})
.await;
if let Some(user) = user {
if !user.active {
Outcome::Error((Status::Unauthorized, create_error!(LockedOut)))
} else {
Outcome::Success(AdminAuthorization::AdminUser(user.clone()))
}
} else {
Outcome::Error((Status::Unauthorized, create_error!(InvalidSession)))
}
}
}
}

View File

@@ -0,0 +1,59 @@
use revolt_okapi::openapi3::{SecurityScheme, SecuritySchemeData};
use revolt_rocket_okapi::{
gen::OpenApiGenerator,
request::{OpenApiFromRequest, RequestHeaderInput},
};
use crate::{AdminAuthorization, AdminMachineToken};
impl OpenApiFromRequest<'_> for AdminAuthorization {
fn from_request_input(
_gen: &mut OpenApiGenerator,
_name: String,
_required: bool,
) -> revolt_rocket_okapi::Result<RequestHeaderInput> {
let mut requirements = schemars::Map::new();
requirements.insert("Admin Token".to_owned(), vec![]);
Ok(RequestHeaderInput::Security(
"Admin Token".to_owned(),
SecurityScheme {
data: SecuritySchemeData::ApiKey {
name: "x-admin-user".to_owned(),
location: "header".to_owned(),
},
description: Some("Used to authenticate as an admin user.
Can instead use an x-admin-machine token with an x-on-behalf-of containing the userid or email of
the user the machine is performing the action for.".to_owned()),
extensions: schemars::Map::new(),
},
requirements,
))
}
}
impl OpenApiFromRequest<'_> for AdminMachineToken {
fn from_request_input(
_gen: &mut OpenApiGenerator,
_name: String,
_required: bool,
) -> revolt_rocket_okapi::Result<RequestHeaderInput> {
let mut requirements = schemars::Map::new();
requirements.insert("Admin Machine Token".to_owned(), vec![]);
Ok(RequestHeaderInput::Security(
"Admin Machine Token".to_owned(),
SecurityScheme {
data: SecuritySchemeData::ApiKey {
name: "x-admin-machine".to_owned(),
location: "header".to_owned(),
},
description: Some("Used by machines to authenticate on behalf of a user.
Machines are trusted devices that authenticate as other users via providing the x-on-behalf-of header
with an admin user's ID or email.".to_owned()),
extensions: schemars::Map::new(),
},
requirements,
))
}
}

View File

@@ -0,0 +1,9 @@
mod models;
mod ops;
#[cfg(feature = "rocket-impl")]
mod rocket;
#[cfg(feature = "rocket-impl")]
pub use self::rocket::*;
pub use models::*;
pub use ops::*;

View File

@@ -0,0 +1,69 @@
use revolt_result::Result;
use crate::Database;
auto_derived_partial! {
pub struct AdminUser {
/// The ID of the user
#[serde(rename = "_id")]
pub id: String,
/// The user's revolt ID.
pub platform_user_id: String,
/// The user's email
pub email: String,
/// Whether the user is active or not (ie. can they use the api)
pub active: bool,
/// The permissions of the user
pub permissions: u64,
},
"PartialAdminUser"
}
impl AdminUser {
pub fn new(email: &str, platform_user_id: &str, permissions: u64) -> AdminUser {
let id = ulid::Ulid::new().to_string();
AdminUser {
id,
platform_user_id: platform_user_id.to_string(),
email: email.to_string(),
active: true,
permissions,
}
}
pub async fn find_by_id(id: &str, db: &Database) -> Result<AdminUser> {
return db.admin_user_fetch(id).await;
}
pub async fn find_by_email(email: &str, db: &Database) -> Result<AdminUser> {
return db.admin_user_fetch_email(email).await;
}
}
pub struct AdminUserPermissionFlagsValue(pub u64);
impl AdminUserPermissionFlagsValue {
pub fn has(&self, flag: revolt_models::v0::AdminUserPermissionFlags) -> bool {
self.has_value(flag as u64)
}
pub fn has_value(&self, bit: u64) -> bool {
let mask = 1 << bit;
self.0 & mask == mask
}
pub fn set(
&mut self,
flag: revolt_models::v0::AdminUserPermissionFlags,
toggle: bool,
) -> &mut Self {
self.set_value(flag as u64, toggle)
}
pub fn set_value(&mut self, bit: u64, toggle: bool) -> &mut Self {
if toggle {
self.0 |= 1 << bit;
} else {
self.0 &= !(1 << bit);
}
self
}
}

View File

@@ -0,0 +1,20 @@
mod mongodb;
mod reference;
use revolt_result::Result;
use crate::models::admin_users::models::{AdminUser, PartialAdminUser};
#[async_trait]
pub trait AbstractAdminUsers: Sync + Send {
async fn admin_user_insert(&self, user: AdminUser) -> Result<()>;
async fn admin_user_update(&self, user_id: &str, partial: PartialAdminUser) -> Result<()>;
async fn admin_user_fetch(&self, user_id: &str) -> Result<AdminUser>;
async fn admin_user_fetch_email(&self, email: &str) -> Result<AdminUser>;
async fn admin_user_list(&self) -> Result<Vec<AdminUser>>;
async fn admin_user_count(&self) -> Result<u16>;
}

View File

@@ -0,0 +1,35 @@
use revolt_result::Result;
use crate::MongoDb;
use crate::{AdminUser, PartialAdminUser};
use super::AbstractAdminUsers;
static COL: &str = "admin_users";
#[async_trait]
impl AbstractAdminUsers for MongoDb {
async fn admin_user_insert(&self, user: AdminUser) -> Result<()> {
query!(self, insert_one, COL, user).map(|_| ())
}
async fn admin_user_update(&self, user_id: &str, partial: PartialAdminUser) -> Result<()> {
query!(self, update_one_by_id, COL, user_id, partial, vec![], None).map(|_| ())
}
async fn admin_user_fetch(&self, user_id: &str) -> Result<AdminUser> {
query!(self, find_one_by_id, COL, user_id)?.ok_or_else(|| create_error!(NotFound))
}
async fn admin_user_fetch_email(&self, email: &str) -> Result<AdminUser> {
query!(self, find_one, COL, doc! {"email": email})?.ok_or_else(|| create_error!(NotFound))
}
async fn admin_user_list(&self) -> Result<Vec<AdminUser>> {
query!(self, find, COL, doc! {})
}
async fn admin_user_count(&self) -> Result<u16> {
query!(self, count_documents, COL, doc! {}).map(|resp| resp as u16)
}
}

View File

@@ -0,0 +1,56 @@
use revolt_result::Result;
use crate::ReferenceDb;
use crate::{AdminUser, PartialAdminUser};
use super::AbstractAdminUsers;
#[async_trait]
impl AbstractAdminUsers for ReferenceDb {
async fn admin_user_insert(&self, user: AdminUser) -> Result<()> {
let mut admin_users = self.admin_users.lock().await;
if admin_users.contains_key(&user.id) {
Err(create_database_error!("insert", "admin_users"))
} else {
admin_users.insert(user.id.to_string(), user.clone());
Ok(())
}
}
async fn admin_user_update(&self, user_id: &str, partial: PartialAdminUser) -> Result<()> {
let mut admin_users = self.admin_users.lock().await;
if let Some(existing_user) = admin_users.get_mut(user_id) {
existing_user.apply_options(partial);
Ok(())
} else {
Err(create_error!(NotFound))
}
}
async fn admin_user_fetch(&self, user_id: &str) -> Result<AdminUser> {
let admin_users = self.admin_users.lock().await;
if let Some(user) = admin_users.get(user_id) {
Ok(user.clone())
} else {
Err(create_error!(NotFound))
}
}
async fn admin_user_fetch_email(&self, email: &str) -> Result<AdminUser> {
let admin_users = self.admin_users.lock().await;
if let Some((_, user)) = admin_users.iter().filter(|(_, p)| p.email == email).next() {
Ok(user.clone())
} else {
Err(create_error!(NotFound))
}
}
async fn admin_user_list(&self) -> Result<Vec<AdminUser>> {
let admin_users = self.admin_users.lock().await;
Ok(admin_users.iter().map(|(_, u)| u).cloned().collect())
}
async fn admin_user_count(&self) -> Result<u16> {
Ok(self.admin_users.lock().await.len() as u16)
}
}

View File

@@ -0,0 +1,43 @@
use rocket::http::Status;
use rocket::request::{self, FromRequest, Outcome, Request};
use revolt_result::Error;
use crate::{AdminUser, Database};
#[rocket::async_trait]
impl<'r> FromRequest<'r> for AdminUser {
type Error = Error;
async fn from_request(request: &'r Request<'_>) -> request::Outcome<Self, Self::Error> {
let user: &Option<AdminUser> = request
.local_cache_async(async {
let db = request.rocket().state::<Database>().expect("`Database`");
let token = request
.headers()
.get("x-admin-user")
.next()
.map(|x| x.to_string());
if let Some(token) = token {
if let Ok(admin_token) = db.admin_token_authenticate(&token).await {
if let Ok(user) = db.admin_user_fetch(&admin_token.user_id).await {
return Some(user);
}
}
}
None
})
.await;
if let Some(user) = user {
if !user.active {
Outcome::Error((Status::Unauthorized, create_error!(LockedOut)))
} else {
Outcome::Success(user.clone())
}
} else {
Outcome::Error((Status::Unauthorized, create_error!(InvalidCredentials)))
}
}
}

View File

@@ -164,7 +164,7 @@ impl Bot {
mod tests {
use crate::{Bot, FieldsBot, PartialBot, User};
#[async_std::test]
#[tokio::test]
async fn crud() {
database_test!(|db| async move {
let owner = User::create(&db, "Owner".to_string(), None, None)

View File

@@ -12,7 +12,14 @@ static COL: &str = "channel_invites";
impl AbstractChannelInvites for MongoDb {
/// Insert a new invite into the database
async fn insert_invite(&self, invite: &Invite) -> Result<()> {
query!(self, insert_one, COL, &invite).map(|_| ())
self.insert_one(COL, &invite).await.or_else(|e| {
if e.to_string().contains("duplicate") {
return Err(create_error!(InviteExists));
} else {
return Err(create_database_error!("insert_one", COL));
}
})?;
Ok(())
}
/// Fetch an invite by the code

View File

@@ -128,7 +128,7 @@ impl Webhook {
mod tests {
use crate::{FieldsWebhook, PartialWebhook, Webhook};
#[async_std::test]
#[tokio::test]
async fn crud() {
database_test!(|db| async move {
let webhook_id = "webhook";

View File

@@ -767,6 +767,13 @@ impl Channel {
// - channels list / categories list on server
db.delete_channel(self).await
}
/// Wipe a channel's messages
pub async fn wipe(&self, db: &Database) -> Result<()> {
let id = self.id().to_string();
EventV1::ChannelWipe { id: id.clone() }.p(id).await;
db.wipe_channel(self).await
}
}
#[cfg(feature = "mongodb")]
@@ -787,7 +794,7 @@ mod tests {
use crate::{fixture, util::permissions::DatabasePermissionQuery};
#[async_std::test]
#[tokio::test]
async fn permissions_group_channel() {
database_test!(|db| async move {
fixture!(db, "group_with_members",
@@ -813,7 +820,7 @@ mod tests {
});
}
#[async_std::test]
#[tokio::test]
async fn permissions_text_channel() {
database_test!(|db| async move {
fixture!(db, "server_with_roles",

View File

@@ -54,5 +54,6 @@ pub trait AbstractChannels: Sync + Send {
async fn remove_user_from_groups(&self, channel_ids: Vec<String>, user_id: &str) -> Result<()>;
// Delete a channel
async fn delete_channel(&self, channel_id: &Channel) -> Result<()>;
async fn delete_channel(&self, channel_id: &Channel) -> Result<()>; // Wipe a channel's messages
async fn wipe_channel(&self, channel: &Channel) -> Result<()>;
}

View File

@@ -304,6 +304,23 @@ impl AbstractChannels for MongoDb {
// Delete the channel itself
query!(self, delete_one_by_id, COL, channel.id()).map(|_| ())
}
// Wipe a channel's messages
async fn wipe_channel(&self, channel: &Channel) -> Result<()> {
let id = channel.id().to_string();
// Delete invites and unreads.
self.delete_associated_channel_objects(Bson::String(id.to_string()))
.await?;
// Delete messages.
self.delete_bulk_messages(doc! {
"channel": &id
})
.await?;
Ok(())
}
}
impl MongoDb {

View File

@@ -182,4 +182,10 @@ impl AbstractChannels for ReferenceDb {
Err(create_error!(NotFound))
}
}
async fn wipe_channel(&self, channel: &Channel) -> Result<()> {
let mut messages = self.messages.lock().await;
messages.retain(|_, message| message.channel != channel.id());
Ok(())
}
}

View File

@@ -51,7 +51,7 @@ impl<'r> FromRequest<'r> for ValidatedTicket {
Outcome::Error((Status::Forbidden, create_error!(InvalidToken)))
}
} else {
Outcome::Forward(Status::Unauthorized)
Outcome::Error((Status::Forbidden, create_error!(InvalidToken)))
}
}
Outcome::Forward(f) => Outcome::Forward(f),

View File

@@ -1,4 +1,10 @@
mod admin_audits;
mod admin_cases;
mod admin_comments;
mod admin_migrations;
mod admin_strikes;
mod admin_tokens;
mod admin_users;
mod bots;
mod channel_invites;
mod channel_unreads;
@@ -22,7 +28,13 @@ mod account_invites;
mod sessions;
mod mfa_tickets;
pub use admin_audits::*;
pub use admin_cases::*;
pub use admin_comments::*;
pub use admin_migrations::*;
pub use admin_strikes::*;
pub use admin_tokens::*;
pub use admin_users::*;
pub use bots::*;
pub use channel_invites::*;
pub use channel_unreads::*;
@@ -54,6 +66,12 @@ use crate::MongoDb;
pub trait AbstractDatabase:
Sync
+ Send
+ admin_audits::AbstractAdminAudits
+ admin_cases::AbstractAdminCases
+ admin_comments::AbstractAdminComments
+ admin_strikes::AbstractAdminStrikes
+ admin_users::AbstractAdminUsers
+ admin_tokens::AbstractAdminTokens
+ admin_migrations::AbstractMigrations
+ bots::AbstractBots
+ channels::AbstractChannels

View File

@@ -18,5 +18,7 @@ auto_derived!(
/// Additional notes included on the report
#[serde(default)]
pub notes: String,
/// The case this report is assigned to
pub case_id: Option<String>,
}
);

View File

@@ -110,6 +110,7 @@ impl Member {
server: &Server,
user: &User,
channels: Option<Vec<Channel>>,
create_alert: bool,
) -> Result<(Member, Vec<Channel>)> {
if db.fetch_ban(&server.id, &user.id).await.is_ok() {
return Err(create_error!(Banned));
@@ -188,18 +189,20 @@ impl Member {
.private(user.id.clone())
.await;
if let Some(id) = server
.system_messages
.as_ref()
.and_then(|x| x.user_joined.as_ref())
{
SystemMessage::UserJoined {
id: user.id.clone(),
if create_alert {
if let Some(id) = server
.system_messages
.as_ref()
.and_then(|x| x.user_joined.as_ref())
{
SystemMessage::UserJoined {
id: user.id.clone(),
}
.into_message(id.to_string())
.send_without_notifications(db, None, None, false, false, false)
.await
.ok();
}
.into_message(id.to_string())
.send_without_notifications(db, None, None, false, false, false)
.await
.ok();
}
Ok((member, channels))
@@ -320,7 +323,7 @@ mod tests {
use crate::{Member, PartialMember, RemovalIntention, Server, User};
#[async_std::test]
#[tokio::test]
async fn muted_member_rejoin() {
database_test!(|db| async move {
match db {
@@ -349,8 +352,10 @@ mod tests {
.unwrap()
.0;
Member::create(&db, &server, &owner, None).await.unwrap();
let mut kickable_member = Member::create(&db, &server, &kickable_user, None)
Member::create(&db, &server, &owner, None, false)
.await
.unwrap();
let mut kickable_member = Member::create(&db, &server, &kickable_user, None, false)
.await
.unwrap()
.0;
@@ -374,7 +379,7 @@ mod tests {
.await
.unwrap();
let kickable_member = Member::create(&db, &server, &kickable_user, None)
let kickable_member = Member::create(&db, &server, &kickable_user, None, false)
.await
.unwrap()
.0;

View File

@@ -132,4 +132,18 @@ pub trait AbstractServerMembers: Sync + Send {
/// Removes a user from every server they are in
async fn clear_memberships(&self, user_id: &str) -> Result<()>;
/// Fetch all participants of a server.
async fn fetch_server_participants(
&self,
server_id: &str,
) -> Result<Vec<(crate::User, Option<Member>)>>;
/// Fetch all members of a server
async fn fetch_server_members(
&self,
server_id: &str,
page_size: u8,
after: Option<usize>,
) -> Result<Vec<(crate::User, Member)>>;
}

View File

@@ -1,10 +1,12 @@
use bson::Document;
use std::collections::HashMap;
use futures::StreamExt;
use iso8601_timestamp::Timestamp;
use mongodb::options::ReadConcern;
use revolt_result::Result;
use crate::{FieldsMember, Member, MemberCompositeKey, PartialMember};
use crate::{AbstractUsers, FieldsMember, Member, MemberCompositeKey, PartialMember};
use crate::{IntoDocumentPath, MongoDb};
use super::{AbstractServerMembers, ChunkedServerMembersGenerator};
@@ -337,6 +339,88 @@ impl AbstractServerMembers for MongoDb {
.map(|_| ())
.map_err(|_| create_database_error!("delete_many", COL))
}
async fn fetch_server_participants(
&self,
server_id: &str,
) -> Result<Vec<(crate::User, Option<Member>)>> {
let server: crate::Server = query!(self, find_one, "servers", doc! {"_id": server_id})?
.ok_or_else(|| create_database_error!("find", "servers"))?;
let ids: Vec<String> = self
.db()
.collection::<crate::Message>("messages")
.distinct("author", doc! {"channel": {"$in": server.channels}})
.await
.map_err(|_| create_database_error!("distinct", "messages"))?
.iter()
.map(|b| bson::from_bson::<String>(b.clone()).unwrap()) // json encoded string for some logic-defying reason
.collect();
println!("{:?}", &ids);
let users = self.fetch_users(&ids).await?;
let members = self
.fetch_members(server_id, &ids)
.await?
.iter()
.map(|f| (f.id.user.clone(), f.clone()))
.collect::<HashMap<String, Member>>();
Ok(users
.iter()
.map(|u| (u.clone(), members.get(&u.id).cloned()))
.collect())
}
async fn fetch_server_members(
&self,
server_id: &str,
page_size: u8,
after: Option<usize>,
) -> Result<Vec<(crate::User, Member)>> {
let mut members_stream = if let Some(after) = after {
self.col::<crate::Member>(COL)
.find(doc! {"_id.server": server_id, "joined_at": {"$gt": after as i64}})
.sort(doc! {"joined_at": -1})
.limit(page_size as i64)
.await
.map_err(|_| create_database_error!("find", COL))?
} else {
self.col::<crate::Member>(COL)
.find(doc! {"_id.server": server_id})
.sort(doc! {"joined_at": -1})
.limit(page_size as i64)
.await
.map_err(|_| create_database_error!("find", COL))?
};
let mut members = vec![];
if members_stream.advance().await.is_ok_and(|f| f) {
loop {
if let Ok(x) = members_stream.deserialize_current() {
members.push(x);
if let Ok(r) = members_stream.advance().await {
if !r {
break;
}
} else {
break;
}
}
}
}
let user_ids: Vec<String> = members.iter().map(|m| m.id.user.clone()).collect();
let mut set = HashMap::new();
self.fetch_users(&user_ids).await?.iter().for_each(|f| {
set.insert(f.id.clone(), f.clone());
});
return Ok(members
.iter()
.map(|f| (set.remove(&f.id.user).unwrap(), f.clone()))
.collect());
}
}
impl IntoDocumentPath for FieldsMember {

View File

@@ -1,3 +1,6 @@
use std::collections::HashSet;
use iso8601_timestamp::Timestamp;
use revolt_result::Result;
use crate::ReferenceDb;
@@ -209,4 +212,91 @@ impl AbstractServerMembers for ReferenceDb {
Ok(())
}
async fn fetch_server_participants(
&self,
server_id: &str,
) -> Result<Vec<(crate::User, Option<Member>)>> {
let servers = self.servers.lock().await;
let server = servers.get(server_id).ok_or(create_error!(NotFound))?;
let mut hash = HashSet::new();
server.channels.iter().for_each(|c| {
hash.insert(c.clone());
});
drop(servers);
let messages = self.messages.lock().await;
let userids = messages
.iter()
.filter(|(_, message)| hash.contains(&message.channel))
.map(|(_, message)| message.author.clone())
.collect::<HashSet<String>>();
drop(messages);
let users = self.users.lock().await;
let members = self.server_members.lock().await;
let resp_users: Vec<(crate::User, Option<crate::Member>)> = users
.iter()
.filter(|(uid, _)| userids.contains(*uid))
.map(|(uid, user)| {
let key = MemberCompositeKey {
server: server_id.to_string(),
user: uid.clone(),
};
// fuck it we ball
(user.clone(), members.get(&key).map(|f| f.clone()))
})
.collect();
Ok(resp_users)
}
/// Fetch all members of a server
async fn fetch_server_members(
&self,
server_id: &str,
page_size: u8,
after: Option<usize>,
) -> Result<Vec<(crate::User, Member)>> {
let members = self.server_members.lock().await;
let users = self.users.lock().await;
let mut server_members: Vec<&Member> = members
.iter()
.filter(|(_, f)| f.id.server == server_id)
.map(|(_, m)| m)
.collect();
server_members.sort_by(|a, b| a.joined_at.cmp(&b.joined_at));
let iterator = server_members.iter();
let mut resp: Vec<&&Member> = if let Some(after) = after {
iterator
.skip_while(|f| {
f.joined_at
.duration_since(Timestamp::UNIX_EPOCH)
.whole_milliseconds()
<= after as i128
})
.collect()
} else {
iterator.collect()
};
resp.truncate(page_size as usize);
Ok(resp
.iter()
.map(|f| {
(
users.get(&f.id.user).unwrap().clone(),
f.clone().clone().clone(),
)
})
.collect())
}
}

View File

@@ -420,7 +420,7 @@ mod tests {
use crate::{fixture, util::permissions::DatabasePermissionQuery};
#[async_std::test]
#[tokio::test]
async fn permissions() {
database_test!(|db| async move {
fixture!(db, "server_with_roles",

View File

@@ -886,6 +886,30 @@ impl User {
}
}
pub struct UserFlagsValue(pub u32);
impl UserFlagsValue {
pub fn has(&self, flag: UserFlags) -> bool {
self.has_value(flag as u32)
}
pub fn has_value(&self, bit: u32) -> bool {
let mask = 1 << bit;
self.0 & mask == mask
}
pub fn set(&mut self, flag: UserFlags, toggle: bool) -> &mut Self {
self.set_value(flag as u32, toggle)
}
pub fn set_value(&mut self, bit: u32, toggle: bool) -> &mut Self {
if toggle {
self.0 |= 1 << bit;
} else {
self.0 &= !(1 << bit);
}
self
}
}
#[cfg(test)]
mod tests {
use crate::User;
@@ -926,7 +950,7 @@ mod tests {
assert!(User::validate_username(username_https).is_err());
}
#[async_std::test]
#[tokio::test]
async fn username_sanitisation_clean() {
let username_clean = "Test";
@@ -936,7 +960,7 @@ mod tests {
assert_eq!(username_clean, username_clean_sanitised.unwrap());
}
#[async_std::test]
#[tokio::test]
async fn username_sanitisation_homoglyphs() {
let username_homoglyphs = "𝔽𝕌Ňℕy";
@@ -947,7 +971,7 @@ mod tests {
assert_eq!("funny", username_homoglyphs_sanitised);
}
#[async_std::test]
#[tokio::test]
async fn username_sanitisation_padding() {
let username_padding = "a";
@@ -956,7 +980,7 @@ mod tests {
assert_eq!("a_", username);
}
#[async_std::test]
#[tokio::test]
async fn create_user() {
use revolt_result::Result;

View File

@@ -305,6 +305,6 @@ pub async fn worker(db: Database, amqp: AMQP) {
}
// Sleep for an arbitrary amount of time.
async_std::task::sleep(Duration::from_secs(1)).await;
tokio::time::sleep(Duration::from_secs(1)).await;
}
}

View File

@@ -82,6 +82,6 @@ pub async fn worker(db: Database) {
}
// Sleep for an arbitrary amount of time.
async_std::task::sleep(Duration::from_secs(1)).await;
tokio::time::sleep(Duration::from_secs(1)).await;
}
}

View File

@@ -2,7 +2,7 @@
use crate::{Database, AMQP};
use async_std::task;
use tokio::task;
use std::time::Instant;
const WORKER_COUNT: usize = 5;

View File

@@ -7,11 +7,11 @@ use revolt_config::config;
use revolt_result::Result;
use async_lock::Semaphore;
use async_std::task::spawn;
use deadqueue::limited::Queue;
use once_cell::sync::Lazy;
use revolt_models::v0::Embed;
use std::{collections::HashSet, sync::Arc};
use tokio::task::spawn;
use isahc::prelude::*;
@@ -159,6 +159,7 @@ pub async fn generate(
.await
.into_iter()
.flatten()
.flatten()
.collect::<Vec<Embed>>();
// Prevent database update when no embeds are found.

View File

@@ -0,0 +1,3 @@
pub fn transform_optional_string(s: Option<&str>) -> Option<String> {
s.map(|f| f.to_string())
}

View File

@@ -612,6 +612,7 @@ impl From<crate::Report> for Report {
additional_context: value.additional_context,
status: value.status,
notes: value.notes,
case_id: value.case_id,
}
}
}
@@ -1525,3 +1526,98 @@ impl From<WebPushSubscription> for crate::WebPushSubscription {
}
}
}
impl From<crate::AdminUser> for AdminUser {
fn from(value: crate::AdminUser) -> Self {
AdminUser {
id: value.id,
platform_user_id: value.platform_user_id,
email: value.email,
active: value.active,
permissions: value.permissions,
revolt_user: None,
}
}
}
impl From<AdminUser> for crate::AdminUser {
fn from(value: AdminUser) -> Self {
crate::AdminUser {
id: value.id,
platform_user_id: value.platform_user_id,
email: value.email,
active: value.active,
permissions: value.permissions,
}
}
}
impl From<AdminUserEdit> for crate::PartialAdminUser {
fn from(value: AdminUserEdit) -> Self {
crate::PartialAdminUser {
id: None,
platform_user_id: value.platform_user_id,
email: value.email,
active: value.active,
permissions: value.permissions,
}
}
}
impl From<AdminToken> for crate::AdminToken {
fn from(value: AdminToken) -> Self {
crate::AdminToken {
id: value.id,
user_id: value.user_id,
token: value.token,
expiry: value.expiry.format_short().to_string(),
}
}
}
impl From<crate::AdminToken> for AdminToken {
fn from(value: crate::AdminToken) -> Self {
AdminToken {
id: value.id,
user_id: value.user_id,
token: value.token,
expiry: Timestamp::parse(&value.expiry).unwrap(), // if it's invalid it shouldn't have made it to this point.
}
}
}
impl From<crate::AdminComment> for AdminComment {
fn from(value: crate::AdminComment) -> Self {
AdminComment {
id: value.id,
case_id: value.case_id,
object_id: value.object_id,
user_id: value.user_id,
edited_at: value.edited_at.map(|f| Timestamp::parse(&f).unwrap()),
content: value.content,
system_message: value
.system_message
.map(|f| serde_json::from_str(&f).unwrap()),
system_message_target: value.system_message_target,
system_message_context: value.system_message_context,
}
}
}
impl From<AdminComment> for crate::AdminComment {
fn from(value: AdminComment) -> Self {
crate::AdminComment {
id: value.id,
object_id: value.object_id,
case_id: value.case_id,
user_id: value.user_id,
edited_at: value.edited_at.map(|f| f.format_short().to_string()),
content: value.content,
system_message: value
.system_message
.map(|f| serde_json::to_string(&f).unwrap()), // i'll eat my hat if this fails
system_message_target: value.system_message_target,
system_message_context: value.system_message_context,
}
}
}

View File

@@ -5,7 +5,7 @@ use revolt_result::{create_error, Result};
#[cfg(feature = "rocket-impl")]
use revolt_result::Error;
use async_std::sync::Mutex;
use tokio::sync::Mutex;
use once_cell::sync::Lazy;
use serde::{Deserialize, Serialize};

View File

@@ -1,4 +1,5 @@
pub mod acker;
pub mod basic;
pub mod bridge;
pub mod bulk_permissions;
pub mod captcha;

View File

@@ -2,7 +2,7 @@ use std::str::FromStr;
use revolt_result::Result;
#[cfg(feature = "rocket-impl")]
use rocket::request::FromParam;
use rocket::{form::FromFormField, request::FromParam};
#[cfg(feature = "rocket-impl")]
use schemars::{
schema::{InstanceType, Schema, SchemaObject, SingleOrVec},
@@ -10,7 +10,8 @@ use schemars::{
};
use crate::{
Bot, Channel, Database, Emoji, Invite, Member, Message, Server, ServerBan, User, Webhook,
AdminToken, Bot, Channel, Database, Emoji, Invite, Member, Message, Server, ServerBan, User,
Webhook,
};
/// Reference to some object in the database
@@ -25,6 +26,11 @@ impl<'a> Reference<'a> {
Reference { id }
}
/// Fetch Admin Token from Ref
pub async fn as_admin_token(&self, db: &Database) -> Result<AdminToken> {
db.admin_token_fetch(&self.id).await
}
/// Fetch ban from Ref
pub async fn as_ban(&self, db: &Database, server: &str) -> Result<ServerBan> {
db.fetch_ban(server, self.id).await
@@ -126,3 +132,10 @@ impl<'a> JsonSchema for Reference<'a> {
})
}
}
#[cfg(feature = "rocket-impl")]
impl<'r> FromFormField<'r> for Reference<'r> {
fn from_value(field: rocket::form::ValueField<'r>) -> rocket::form::Result<'r, Self> {
Ok(Reference::from_unchecked(field.value.into()))
}
}

View File

@@ -0,0 +1,399 @@
use iso8601_timestamp::Timestamp;
use crate::v0::{self, Report, User};
auto_derived! {
#[derive(Default)]
#[cfg_attr(feature = "validator", derive(validator::Validate))]
pub struct AdminAuditItemCreate {
/// The ID of the mod who performed the action
#[cfg_attr(feature="serde", serde(rename="mod"))]
pub mod_id: String,
/// The action performed (previously 'permission')
pub action: String,
/// The relevant case ID, if applicable
#[cfg_attr(feature="serde", serde(rename="case"))]
pub case_id: String,
/// The id of the target (any object) the action was taken against, if applicable
#[cfg_attr(feature="serde", serde(rename="target"))]
pub target_id: Option<String>,
/// The context attached to the action, if applicable (eg. search phrases)
pub context: Option<String>,
}
pub struct AdminAuditItem {
/// The audit item ID
pub id: String,
/// The ID of the mod who performed the action
#[cfg_attr(feature="serde", serde(rename="mod"))]
pub mod_id: String,
/// The action performed (previously 'permission')
pub action: AdminAuditItemActions,
/// The relevant case ID, if applicable
#[cfg_attr(feature="serde", serde(rename="case"))]
pub case_id: String,
/// The id of the target (any object) the action was taken against, if applicable
#[cfg_attr(feature="serde", serde(rename="target"))]
pub target_id: Option<String>,
/// The context attached to the action, if applicable (eg. search phrases)
pub context: Option<String>,
/// The time the action occurred at.
pub timestamp: Timestamp,
}
#[derive(Default)]
#[cfg_attr(feature = "validator", derive(validator::Validate))]
pub struct AdminCommentCreate {
/// The ID of the case this comment is attached to, if applicable. This should be the 7 character short code.
#[cfg_attr(feature="serde", serde(rename="case"))]
pub case_id: Option<String>,
/// The ID of the object this comment is attached to. Use full case ULID when creating a case comment.
#[cfg_attr(feature="serde", serde(rename="object"))]
pub object_id: String,
/// The content
#[cfg_attr(feature = "validator", validate(length(min = 1, max = 2000)))]
pub content: String
}
#[derive(Default)]
#[cfg_attr(feature = "validator", derive(validator::Validate))]
pub struct AdminCommentEdit {
/// The new content
#[cfg_attr(feature = "validator", validate(length(min = 1, max = 2000)))]
pub content: String
}
pub struct AdminComment {
/// The comment ID
pub id: String,
/// The ID of the case this comment is attached to, if applicable. This should be the 7 character short code
#[cfg_attr(feature="serde", serde(rename="case"))]
#[cfg_attr(feature="serde", serde(skip_serializing_if="Option::is_none"))]
pub case_id: Option<String>,
/// The object this comment is attached to. If this is referencing a case this is the full case ID
#[cfg_attr(feature="serde", serde(rename="object"))]
pub object_id: String,
/// The user who posted the comment/action
#[cfg_attr(feature="serde", serde(rename="user"))]
pub user_id: String,
/// When the comment was edited, if applicable, in iso8601
pub edited_at: Option<Timestamp>,
/// The content, if this is not a system event
#[cfg_attr(feature="serde", serde(skip_serializing_if="Option::is_none"))]
pub content: Option<String>,
/// The system event that happened, if this is not a comment (only applicable for cases)
#[cfg_attr(feature="serde", serde(skip_serializing_if="Option::is_none"))]
pub system_message: Option<AdminAuditItemActions>,
/// The system message target
#[cfg_attr(feature="serde", serde(skip_serializing_if="Option::is_none"))]
pub system_message_target: Option<String>,
/// The system message raw context
#[cfg_attr(feature="serde", serde(skip_serializing_if="Option::is_none"))]
pub system_message_context: Option<String>,
}
#[derive(Default)]
#[cfg_attr(feature = "validator", derive(validator::Validate))]
pub struct AdminCaseCreate {
/// The owner of the case. Defaults to the creator
#[cfg_attr(feature="serde", serde(rename="owner"))]
pub owner: Option<String>,
/// The title of the case. If not provided, a default will be generated from the report(s) assigned
pub title: Option<String>,
/// The report IDs to initially attach to this case (and generate a default title from)
#[cfg_attr(feature = "validator", validate(length(min = 1)))]
pub initial_reports: Vec<String>
}
#[derive(Default)]
pub struct AdminCaseEdit {
/// The new owner of the case.
#[cfg_attr(feature="serde", serde(rename="owner"))]
pub owner_id: Option<String>,
/// The new title of the case.
pub title: Option<String>,
/// Report IDs to add to the case.
pub add_reports: Option<Vec<String>>,
/// Report IDs to remove from the case.
pub remove_reports: Option<Vec<String>>
}
pub struct AdminCase {
/// The case ID
pub id: String,
/// The case Short ID
pub short_id: String,
/// The owner of the case
#[cfg_attr(feature="serde", serde(rename="owner"))]
pub owner_id: String,
/// The title of the case
pub title: String,
/// The status of the case (open/closed)
pub status: String,
/// When the case was closed
pub closed_at: Option<Timestamp>,
/// The tags for the case
pub tags: Vec<String>,
/// The reports assigned to this case
pub reports: Vec<Report>
}
pub struct AdminStrike {
/// The strike ID
pub id: String,
/// The object receiving the strike (user/server)
#[cfg_attr(feature="serde", serde(rename="target"))]
pub target_id: String,
/// The moderator who gave the strike
#[cfg_attr(feature="serde", serde(rename="mod"))]
pub mod_id: String,
/// The case the strike was made under
#[cfg_attr(feature="serde", serde(rename="case"))]
#[cfg_attr(feature="serde", serde(skip_serializing_if="Option::is_none"))]
pub case_id: Option<String>,
/// Action associated with the strike (eg. suspension/ban)
#[cfg_attr(feature="serde", serde(skip_serializing_if="Option::is_none"))]
pub associated_action: Option<String>,
/// Has the strike been removed
#[cfg_attr(feature="serde", serde(skip_serializing_if="crate::if_false"))]
pub overruled: bool,
/// The user-facing reason for the strike
pub reason: String,
/// Internal context for the strike
#[cfg_attr(feature="serde", serde(skip_serializing_if="Option::is_none"))]
pub mod_context: Option<String>,
}
#[cfg_attr(feature = "validator", derive(validator::Validate))]
pub struct AdminStrikeCreate {
/// The object receiving the strike (user/server)
#[cfg_attr(feature="serde", serde(rename="target"))]
pub target_id: String,
/// The case the strike was made under
#[cfg_attr(feature="serde", serde(rename="case"))]
pub case_id: Option<String>,
/// Action associated with the strike (eg. suspension/ban)
#[cfg_attr(feature = "validator", validate(length(min = 1, max = 25)))]
pub associated_action: Option<String>,
/// The user-facing reason for the strike
#[cfg_attr(feature = "validator", validate(length(max = 2000)))]
pub reason: String,
/// Internal context for the strike
#[cfg_attr(feature = "validator", validate(length(max = 2000)))]
pub mod_context: Option<String>,
}
#[cfg_attr(feature = "validator", derive(validator::Validate))]
pub struct AdminStrikeEdit {
/// The case the strike was made under
#[cfg_attr(feature="serde", serde(rename="case"))]
pub case_id: Option<String>,
/// Action associated with the strike (eg. suspension/ban)
#[cfg_attr(feature = "validator", validate(length(min = 1, max = 25)))]
pub associated_action: Option<String>,
/// The user-facing reason for the strike
#[cfg_attr(feature = "validator", validate(length(max = 2000)))]
pub reason: Option<String>,
/// Internal context for the strike
#[cfg_attr(feature = "validator", validate(length(max = 2000)))]
pub mod_context: Option<String>,
}
pub struct AdminToken {
/// The token ID
pub id: String,
/// The user this token is attached to
#[cfg_attr(feature = "serde", serde(rename="user"))]
pub user_id: String,
/// The token itself
pub token: String,
/// The expiry timestamp for this token, in iso6801
pub expiry: Timestamp
}
#[cfg_attr(feature = "validator", derive(validator::Validate))]
pub struct AdminTokenCreate {
/// The expiry timestamp for this token, in iso6801. Max 30 days from current time.
pub expiry: Timestamp,
}
pub struct AdminUser {
/// The ID of the user
pub id: String,
/// The user's revolt ID.
pub platform_user_id: String,
/// The user's email
pub email: String,
/// Whether the user is active or not (ie. can they use the api)
pub active: bool,
/// The permissions of the user
pub permissions: u64,
/// The Revolt user attached to this user. Use this for data like names and avatars.
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
pub revolt_user: Option<User>
}
#[cfg_attr(feature = "validator", derive(validator::Validate))]
pub struct AdminUserCreate {
/// The user's revolt ID.
#[cfg_attr(feature = "validator", validate(length(min = 10, max = 20)))]
pub platform_user_id: String,
/// The user's email
#[cfg_attr(feature = "validator", validate(email))]
pub email: String,
/// Whether the user is active or not (ie. can they use the api)
pub active: bool,
/// The permissions of the user
pub permissions: u64,
}
#[cfg_attr(feature = "validator", derive(validator::Validate))]
pub struct AdminUserEdit {
/// The user's revolt ID.
#[cfg_attr(feature = "validator", validate(length(min = 10, max = 20)))]
pub platform_user_id: Option<String>,
/// The user's email
#[cfg_attr(feature = "validator", validate(email))]
pub email: Option<String>,
/// Whether the user is active or not (ie. can they use the api)
pub active: Option<bool>,
/// The permissions of the user
pub permissions: Option<u64>,
}
// Flags
#[repr(u32)]
pub enum AdminUserPermissionFlags {
Comments = 0,
ManageAdminUsers = 1,
CreateTokens = 2,
ViewUsers = 3,
ManageUsers = 4,
ManageUsersSenstiveInfo = 5,
ViewServers = 6,
ManageServers = 7,
ViewDMChannels = 8,
ManageDMChannels = 9,
ViewCases = 10,
ManageOwnCases = 11,
ManageOtherCases = 12,
ViewReports = 13,
Search = 14,
ManageNotes = 15,
Discover = 16,
ManageAccounts = 17,
ManageChannels = 18,
}
pub enum AdminAuditItemActions {
// Meta
CreateAdminUser,
EditAdminUser,
CreateToken,
RevokeToken,
// Comments
CommentCreate,
CommentEdit,
CommentFetchForObject,
// Servers
ServerFetch,
ServerFetchParticipants,
ServerFetchMembers,
ServerAddMember,
ServerChangeOwner,
ServerCreateInvite,
ServerDeleteInvite,
ServerDeleteAllInvites,
ServerDelete,
ServerEdit,
ServerRemoveMember,
ServerSetFlags,
ServerInstanceBanAllMembers,
ServerBanMember,
ServerUnbanMember,
// Accounts
DeleteAccount,
DisableAccount,
EmailChange,
EmailVerify,
EnableAccount,
// Channels
DeleteChannel,
EditChannel,
WipeChannel
}
// Joiner payloads
pub struct AdminServerResponse {
pub server: v0::Server,
pub owner: v0::User,
pub comments: Vec<v0::AdminComment>,
}
pub struct AdminServerParticipantsResponse {
pub users: Vec<v0::User>,
pub members: Vec<v0::Member>,
pub sort_strategy: String
}
pub struct AdminMemberWithUserAndOffsetResponse {
pub after: Option<usize>,
pub users: Vec<v0::MemberWithUserResponse>
}
}
impl AdminAuditItemActions {
/// Does this action create a case comment?
pub fn makes_comment(&self) -> bool {
match self {
AdminAuditItemActions::CreateAdminUser => false,
AdminAuditItemActions::EditAdminUser => false,
AdminAuditItemActions::CreateToken => false,
AdminAuditItemActions::RevokeToken => false,
AdminAuditItemActions::CommentCreate => false,
AdminAuditItemActions::CommentEdit => false,
AdminAuditItemActions::CommentFetchForObject => false,
AdminAuditItemActions::ServerFetch => false,
AdminAuditItemActions::ServerFetchParticipants => false,
AdminAuditItemActions::ServerAddMember => true,
AdminAuditItemActions::ServerChangeOwner => true,
AdminAuditItemActions::ServerCreateInvite => true,
AdminAuditItemActions::ServerDeleteInvite => true,
AdminAuditItemActions::ServerDeleteAllInvites => true,
AdminAuditItemActions::ServerDelete => true,
AdminAuditItemActions::ServerEdit => true,
AdminAuditItemActions::ServerRemoveMember => true,
AdminAuditItemActions::ServerSetFlags => true,
AdminAuditItemActions::ServerInstanceBanAllMembers => true,
AdminAuditItemActions::ServerBanMember => true,
AdminAuditItemActions::ServerUnbanMember => true,
AdminAuditItemActions::ServerFetchMembers => false,
AdminAuditItemActions::DeleteAccount => true,
AdminAuditItemActions::DisableAccount => true,
AdminAuditItemActions::EmailChange => true,
AdminAuditItemActions::EmailVerify => true,
AdminAuditItemActions::EnableAccount => true,
AdminAuditItemActions::DeleteChannel => true,
AdminAuditItemActions::EditChannel => true,
AdminAuditItemActions::WipeChannel => true,
}
}
}
impl std::fmt::Display for AdminAuditItemActions {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:?}", self)
}
}

View File

@@ -1,3 +1,4 @@
mod admin;
mod bots;
mod channel_invites;
mod channel_unreads;
@@ -18,6 +19,7 @@ mod accounts;
mod mfa_tickets;
mod sessions;
pub use admin::*;
pub use bots::*;
pub use channel_invites::*;
pub use channel_unreads::*;

View File

@@ -8,6 +8,8 @@ auto_derived!(
pub id: String,
/// Id of the user creating this report
pub author_id: String,
/// The case this report is attached to
pub case_id: Option<String>,
/// Reported content
pub content: ReportedContent,
/// Additional report context

View File

@@ -159,4 +159,9 @@ auto_derived!(
#[cfg_attr(feature = "serde", serde(default))]
pub remove: Vec<FieldsMember>,
}
pub struct MemberWithUserResponse {
pub user: crate::v0::User,
pub member: Member,
}
);

View File

@@ -205,6 +205,8 @@ auto_derived!(
Banned = 4,
/// User was marked as spam and removed from platform
Spam = 8,
/// User is a global moderator
GlobalModerator = 16,
}
/// New user profile data

View File

@@ -18,7 +18,7 @@ try-from-primitive = ["dep:num_enum"]
[dev-dependencies]
# Async
async-std = { workspace = true, features = ["attributes"] }
tokio = { workspace = true }
[dependencies]
# Core

View File

@@ -4,7 +4,7 @@ use crate::{
DEFAULT_PERMISSION_SERVER, DEFAULT_PERMISSION_VIEW_ONLY,
};
#[async_std::test]
#[tokio::test]
async fn validate_user_permissions() {
/// Scenario in which we are friends with a user
/// and we have a DM channel open with them
@@ -102,7 +102,7 @@ async fn validate_user_permissions() {
}
}
#[async_std::test]
#[tokio::test]
async fn validate_group_permissions() {
/// Scenario in which we are in a group channel with only talking permission
struct Scenario {}
@@ -202,7 +202,7 @@ async fn validate_group_permissions() {
}
}
#[async_std::test]
#[tokio::test]
async fn validate_server_permissions() {
/// Scenario in which we are in a server channel where:
/// - the server grants reading history and sending messages by default
@@ -314,7 +314,7 @@ async fn validate_server_permissions() {
}
}
#[async_std::test]
#[tokio::test]
async fn validate_timed_out_member() {
/// Scenario in which we are in a server that we have been timed out from
struct Scenario {}

View File

@@ -14,7 +14,7 @@ redis-is-patched = []
[dev-dependencies]
# Async
async-std = { workspace = true, features = ["attributes"] }
tokio = { workspace = true }
# Config for loading Redis URI
revolt-config = { workspace = true }
@@ -26,4 +26,4 @@ rand = { workspace = true }
once_cell = { workspace = true }
# Redis
redis-kiss = { workspace = true }
redis-kiss = { workspace = true, default-features = false, features = ["tokio-runtime"] }

View File

@@ -195,7 +195,7 @@ mod tests {
use crate::{clear_region, create_session, delete_session, filter_online, is_online};
use rand::Rng;
#[async_std::test]
#[tokio::test]
async fn it_works() {
revolt_config::config().await;

View File

@@ -39,9 +39,11 @@ impl IntoResponse for Error {
ErrorType::GroupTooLarge { .. } => StatusCode::FORBIDDEN,
ErrorType::AlreadyInGroup => StatusCode::CONFLICT,
ErrorType::NotInGroup => StatusCode::NOT_FOUND,
ErrorType::NotAMember => StatusCode::BAD_REQUEST,
ErrorType::AlreadyPinned => StatusCode::BAD_REQUEST,
ErrorType::NotPinned => StatusCode::BAD_REQUEST,
ErrorType::InSlowmode { retry_after: _ } => StatusCode::TOO_MANY_REQUESTS,
ErrorType::InviteExists => StatusCode::BAD_REQUEST,
ErrorType::CantCreateServers => StatusCode::FORBIDDEN,
ErrorType::UnknownServer => StatusCode::NOT_FOUND,
@@ -120,6 +122,10 @@ impl IntoResponse for Error {
ErrorType::DisallowedMFAMethod => StatusCode::BAD_REQUEST,
ErrorType::OperationFailed => StatusCode::INTERNAL_SERVER_ERROR,
ErrorType::IncorrectData { .. } => StatusCode::BAD_REQUEST,
ErrorType::ImATeaPot => StatusCode::IM_A_TEAPOT,
ErrorType::PrivilegedAccount => StatusCode::FORBIDDEN,
};
(status, Json(&self)).into_response()

View File

@@ -106,12 +106,14 @@ pub enum ErrorType {
InSlowmode {
retry_after: u64,
},
InviteExists,
// ? Server related errors
CantCreateServers,
UnknownServer,
InvalidRole,
Banned,
NotAMember,
TooManyServers {
max: usize,
},
@@ -211,6 +213,12 @@ pub enum ErrorType {
TotpAlreadyEnabled,
DisallowedMFAMethod,
// ? Admin API relate errors
PrivilegedAccount,
// :)
ImATeaPot,
}
#[macro_export]

View File

@@ -41,10 +41,12 @@ impl<'r> Responder<'r, 'static> for Error {
ErrorType::GroupTooLarge { .. } => Status::Forbidden,
ErrorType::AlreadyInGroup => Status::Conflict,
ErrorType::NotInGroup => Status::NotFound,
ErrorType::NotAMember => Status::BadRequest,
ErrorType::AlreadyPinned => Status::BadRequest,
ErrorType::NotPinned => Status::BadRequest,
ErrorType::InSlowmode { retry_after: _ } => Status::TooManyRequests,
ErrorType::InvalidFlagValue => Status::BadRequest,
ErrorType::InviteExists => Status::BadRequest,
ErrorType::CantCreateServers => Status::Forbidden,
ErrorType::UnknownServer => Status::NotFound,
@@ -125,6 +127,10 @@ impl<'r> Responder<'r, 'static> for Error {
ErrorType::DisallowedMFAMethod => Status::BadRequest,
ErrorType::OperationFailed => Status::InternalServerError,
ErrorType::IncorrectData { .. } => Status::BadRequest,
ErrorType::ImATeaPot => Status::ImATeapot,
ErrorType::PrivilegedAccount => Status::Forbidden,
};
// Serialize the error data structure into JSON.

View File

@@ -18,7 +18,7 @@ tokio = { workspace = true }
futures = { workspace = true }
# Redis
redis-kiss = { workspace = true }
redis-kiss = { workspace = true, default-features = false, features = ["tokio-runtime"] }
# RabbitMQ
lapin = { workspace = true }

View File

@@ -20,7 +20,7 @@ fcm_v1 = { workspace = true }
web-push = { workspace = true }
isahc = { workspace = true, features = ["json"], optional = true }
revolt_a2 = { workspace = true, features = ["ring"] }
redis-kiss = { workspace = true }
redis-kiss = { workspace = true, default-features = false, features = ["tokio-runtime"] }
tokio = { workspace = true }
async-trait = { workspace = true }
ulid = { workspace = true }

View File

@@ -13,7 +13,7 @@ log = { workspace = true }
sentry = { workspace = true }
lru = { workspace = true }
ulid = { workspace = true }
redis-kiss = { workspace = true }
redis-kiss = { workspace = true, default-features = false, features = ["tokio-runtime"] }
chrono = { workspace = true }
# Serde
@@ -27,11 +27,6 @@ rocket_empty = { workspace = true }
# Async
futures = { workspace = true }
async-std = { workspace = true, features = [
"tokio1",
"tokio02",
"attributes",
] }
# Core
revolt-result = { workspace = true, features = ["rocket"] }

View File

@@ -11,7 +11,7 @@ publish = false
[dependencies]
# Test
rand = { workspace = true }
redis-kiss = { workspace = true }
redis-kiss = { workspace = true, default-features = false, features = ["tokio-runtime"] }
# Utility
lru = { workspace = true }
@@ -42,11 +42,7 @@ futures = { workspace = true }
chrono = { workspace = true }
async-channel = { workspace = true }
reqwest = { workspace = true, features = ["json"] }
async-std = { workspace = true, features = [
"tokio1",
"tokio02",
"attributes",
] }
tokio = { workspace = true }
# internal util
lettre = { workspace = true }

View File

@@ -2,7 +2,7 @@
//! POST /account/create
use std::time::Duration;
use async_std::task::sleep;
use tokio::time::sleep;
use revolt_config::config;
use revolt_database::{
util::{

View File

@@ -2,7 +2,7 @@
//! POST /account/reverify
use std::time::Duration;
use async_std::task::sleep;
use tokio::time::sleep;
use rocket::{serde::json::Json, State};
use rocket_empty::EmptyResponse;
use revolt_result::Result;

View File

@@ -2,7 +2,7 @@
//! POST /account/reset_password
use std::time::Duration;
use async_std::task::sleep;
use tokio::time::sleep;
use rocket::serde::json::Json;
use rocket::State;
use rocket_empty::EmptyResponse;

View File

@@ -0,0 +1,56 @@
use crate::routes::admin::util::{
create_audit_action, flatten_authorized_user, user_has_permission,
};
use revolt_database::{util::reference::Reference, AdminAuthorization, Database};
use revolt_models::v0;
use revolt_result::{create_error, Result};
use rocket::State;
use rocket_empty::EmptyResponse;
/// Delete an account. Requires ManageAccounts permissions
#[openapi(tag = "Admin")]
#[delete("/accounts/<id>?<case>")]
pub async fn admin_account_delete(
db: &State<Database>,
auth: AdminAuthorization,
id: Reference<'_>,
case: Option<&str>,
) -> Result<EmptyResponse> {
let user = flatten_authorized_user(&auth);
if !user_has_permission(user, v0::AdminUserPermissionFlags::ManageAccounts) {
return Err(create_error!(MissingPermission {
permission: "ManageAccounts".to_string()
}));
}
let target = id.as_user(db).await?;
if target.privileged {
return Err(create_error!(PrivilegedAccount));
}
let admin = db.admin_user_fetch(&target.id).await.ok();
if let Some(admin) = admin {
if user_has_permission(&admin, v0::AdminUserPermissionFlags::ManageAccounts) {
return Err(create_error!(PrivilegedAccount));
}
}
db.fetch_account(&target.id)
.await?
.mark_deleted(db)
.await?;
create_audit_action(
db,
&user.id,
v0::AdminAuditItemActions::DeleteAccount,
case,
Some(id.id),
None,
)
.await?;
Ok(EmptyResponse)
}

Some files were not shown because too many files have changed in this diff Show More