Compare commits

...
5 Commits
Author SHA1 Message Date
Paul Makles 4c8ea31d98 feat(admin): add global message query route 2023-03-11 17:26:48 +00:00
Paul Makles 92ac86a6bd chore: bump version
fix: separate privileged check when editing server
2023-03-11 17:04:30 +00:00
Paul Makles 13ed69c82a feat: privileged user editing 2023-03-11 16:53:25 +00:00
Paul Makles b83f6da648 feat: allow editing server flags through edit route 2023-03-11 16:40:23 +00:00
Paul Makles 89f1167239 refactor: unify message query into one method 2023-03-11 16:37:48 +00:00
20 changed files with 364 additions and 234 deletions
Generated
+3 -3
View File
@@ -2792,7 +2792,7 @@ dependencies = [
[[package]]
name = "revolt-bonfire"
version = "0.5.15"
version = "0.5.16"
dependencies = [
"async-std",
"async-tungstenite",
@@ -2808,7 +2808,7 @@ dependencies = [
[[package]]
name = "revolt-delta"
version = "0.5.15"
version = "0.5.16"
dependencies = [
"async-channel",
"async-std",
@@ -2848,7 +2848,7 @@ dependencies = [
[[package]]
name = "revolt-quark"
version = "0.5.15"
version = "0.5.16"
dependencies = [
"async-lock",
"async-recursion",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "revolt-bonfire"
version = "0.5.15"
version = "0.5.16"
license = "AGPL-3.0-or-later"
edition = "2021"
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "revolt-delta"
version = "0.5.15"
version = "0.5.16"
license = "AGPL-3.0-or-later"
authors = ["Paul Makles <paulmakles@gmail.com>"]
edition = "2018"
@@ -0,0 +1,31 @@
use revolt_quark::{
models::{
message::{BulkMessageResponse, MessageQuery},
User,
},
Db, Error, Result,
};
use rocket::serde::json::Json;
/// # Globally Fetch Messages
///
/// This is a privileged route to globally fetch messages.
#[openapi(tag = "Admin")]
#[post("/messages", data = "<data>")]
pub async fn message_query(
db: &Db,
user: User,
data: Json<MessageQuery>,
) -> Result<Json<BulkMessageResponse>> {
// Must be privileged for this route
if !user.privileged {
return Err(Error::NotPrivileged);
}
// Fetch data using query
let data = data.into_inner();
let messages = db.fetch_messages(data).await?;
BulkMessageResponse::transform(db, None, messages, Some(true))
.await
.map(Json)
}
+9
View File
@@ -0,0 +1,9 @@
use revolt_rocket_okapi::revolt_okapi::openapi3::OpenApi;
use rocket::Route;
mod message_query;
mod stats;
pub fn routes() -> (Vec<Route>, OpenApi) {
openapi_get_routes_spec![stats::stats, message_query::message_query]
}
@@ -6,7 +6,7 @@ use rocket::serde::json::Json;
/// # Query Stats
///
/// Fetch various technical statistics.
#[openapi(tag = "Core")]
#[openapi(tag = "Admin")]
#[get("/stats")]
pub async fn stats(db: &Db) -> Result<Json<Stats>> {
Ok(Json(db.generate_stats().await?))
@@ -1,6 +1,8 @@
use revolt_quark::{
models::{
message::{BulkMessageResponse, MessageSort},
message::{
BulkMessageResponse, MessageFilter, MessageQuery, MessageSort, MessageTimePeriod,
},
User,
},
perms, Db, Error, Permission, Ref, Result,
@@ -68,14 +70,28 @@ pub async fn req(
sort,
nearby,
include_users,
..
} = options;
let messages = db
.fetch_messages(channel.id(), limit, before, after, sort, nearby)
.fetch_messages(MessageQuery {
filter: MessageFilter {
channel: Some(channel.id().to_string()),
..Default::default()
},
time_period: if let Some(nearby) = nearby {
MessageTimePeriod::Relative { nearby }
} else {
MessageTimePeriod::Absolute {
before,
after,
sort,
}
},
limit,
})
.await?;
BulkMessageResponse::transform(db, &channel, messages, include_users)
BulkMessageResponse::transform(db, Some(&channel), messages, include_users)
.await
.map(Json)
}
@@ -1,6 +1,8 @@
use revolt_quark::{
models::{
message::{BulkMessageResponse, MessageSort},
message::{
BulkMessageResponse, MessageFilter, MessageQuery, MessageSort, MessageTimePeriod,
},
User,
},
perms, Db, Error, Permission, Ref, Result,
@@ -30,7 +32,7 @@ pub struct OptionsMessageSearch {
after: Option<String>,
/// Message sort direction
///
/// By default, it will be sorted by relevance.
/// By default, it will be sorted by latest.
#[serde(default = "MessageSort::default")]
sort: MessageSort,
/// Whether to include user (and member, if server channel) objects
@@ -73,10 +75,22 @@ pub async fn req(
} = options;
let messages = db
.search_messages(channel.id(), &query, limit, before, after, sort)
.fetch_messages(MessageQuery {
filter: MessageFilter {
channel: Some(channel.id().to_string()),
query: Some(query),
..Default::default()
},
time_period: MessageTimePeriod::Absolute {
before,
after,
sort: Some(sort),
},
limit,
})
.await?;
BulkMessageResponse::transform(db, &channel, messages, include_users)
BulkMessageResponse::transform(db, Some(&channel), messages, include_users)
.await
.map(Json)
}
+5 -3
View File
@@ -3,6 +3,7 @@ pub use rocket::http::Status;
pub use rocket::response::Redirect;
use rocket::{Build, Rocket};
mod admin;
mod bots;
mod channels;
mod customisation;
@@ -12,7 +13,6 @@ mod push;
mod root;
mod safety;
mod servers;
mod stats;
mod sync;
mod users;
@@ -22,7 +22,8 @@ pub fn mount(mut rocket: Rocket<Build>) -> Rocket<Build> {
mount_endpoints_and_merged_docs! {
rocket, "/".to_owned(), settings,
"/" => (vec![], custom_openapi_spec()),
"" => openapi_get_routes_spec![root::root, stats::stats, root::ping],
"" => openapi_get_routes_spec![root::root, root::ping],
"/admin" => admin::routes(),
"/users" => users::routes(),
"/bots" => bots::routes(),
"/channels" => channels::routes(),
@@ -109,8 +110,9 @@ fn custom_openapi_spec() -> OpenApi {
]
},
{
"name": "Platform Moderation",
"name": "Platform Administration",
"tags": [
"Admin",
"User Safety"
]
},
@@ -1,4 +1,5 @@
use revolt_quark::events::client::EventV1;
use revolt_quark::models::message::{MessageFilter, MessageQuery, MessageSort, MessageTimePeriod};
use revolt_quark::models::report::{ReportStatus, ReportedContent};
use revolt_quark::models::snapshot::{Snapshot, SnapshotContent};
use revolt_quark::models::{Report, User};
@@ -55,26 +56,34 @@ pub async fn report_content(db: &Db, user: User, data: Json<DataReportContent>)
// Collect prior context
let prior_context = db
.fetch_messages(
&message.channel,
Some(15),
Some(message.id.to_string()),
None,
None,
None,
)
.fetch_messages(MessageQuery {
filter: MessageFilter {
channel: Some(message.channel.to_string()),
..Default::default()
},
limit: Some(15),
time_period: MessageTimePeriod::Absolute {
before: Some(message.id.to_string()),
after: None,
sort: Some(MessageSort::Latest),
},
})
.await?;
// Collect leading context
let leading_context = db
.fetch_messages(
&message.channel,
Some(15),
None,
Some(message.id.to_string()),
None,
None,
)
.fetch_messages(MessageQuery {
filter: MessageFilter {
channel: Some(message.channel.to_string()),
..Default::default()
},
limit: Some(15),
time_period: MessageTimePeriod::Absolute {
before: None,
after: Some(message.id.to_string()),
sort: Some(MessageSort::Oldest),
},
})
.await?;
(
@@ -33,6 +33,10 @@ pub struct DataEditServer {
/// System message configuration
system_messages: Option<SystemMessageChannels>,
/// Bitfield of server flags
#[serde(skip_serializing_if = "Option::is_none")]
pub flags: Option<i32>,
// Whether this server is age-restricted
// nsfw: Option<bool>,
/// Whether this server is public and should show up on [Revolt Discover](https://rvlt.gg)
@@ -92,6 +96,11 @@ pub async fn req(
.await?;
}
// Check we are privileged if changing sensitive fields
if data.flags.is_some() && !user.privileged {
return Err(Error::NotPrivileged);
}
if data.categories.is_some() {
permissions
.throw_permission(db, Permission::ManageChannel)
@@ -105,6 +114,7 @@ pub async fn req(
banner,
categories,
system_messages,
flags,
// nsfw,
discoverable,
analytics,
@@ -116,6 +126,7 @@ pub async fn req(
description,
categories,
system_messages,
flags,
// nsfw,
discoverable,
analytics,
+36 -6
View File
@@ -1,6 +1,6 @@
use revolt_quark::models::user::{FieldsUser, PartialUser, User};
use revolt_quark::models::File;
use revolt_quark::{Database, Error, Result};
use revolt_quark::{Database, Error, Ref, Result};
use revolt_quark::models::user::UserStatus;
use rocket::serde::json::Json;
@@ -24,6 +24,10 @@ pub struct UserProfileData {
/// # User Data
#[derive(Validate, Serialize, Deserialize, JsonSchema)]
pub struct DataEditUser {
/// Attachment Id for avatar
#[validate(length(min = 1, max = 128))]
avatar: Option<String>,
/// New user status
#[validate]
status: Option<UserStatus>,
@@ -32,9 +36,14 @@ pub struct DataEditUser {
/// This is applied as a partial.
#[validate]
profile: Option<UserProfileData>,
/// Attachment Id for avatar
#[validate(length(min = 1, max = 128))]
avatar: Option<String>,
/// Bitfield of user badges
#[serde(skip_serializing_if = "Option::is_none")]
badges: Option<i32>,
/// Enum of user flags
#[serde(skip_serializing_if = "Option::is_none")]
flags: Option<i32>,
/// Fields to remove from user object
#[validate(length(min = 1))]
remove: Option<Vec<FieldsUser>>,
@@ -44,19 +53,36 @@ pub struct DataEditUser {
///
/// Edit currently authenticated user.
#[openapi(tag = "User Information")]
#[patch("/@me", data = "<data>")]
#[patch("/<target>", data = "<data>")]
pub async fn req(
db: &State<Database>,
mut user: User,
target: Ref,
data: Json<DataEditUser>,
) -> Result<Json<User>> {
let data = data.into_inner();
data.validate()
.map_err(|error| Error::FailedValidation { error })?;
// If we want to edit a different user than self, ensure we have
// permissions and subsequently replace the user in question
if target.id != "@me" {
if !user.privileged {
return Err(Error::NotPrivileged);
}
user = target.as_user(db).await?;
// Otherwise, filter out invalid edit fields
} else if data.badges.is_some() || data.flags.is_some() {
return Err(Error::NotPrivileged);
}
// Exit out early if nothing is changed
if data.status.is_none()
&& data.profile.is_none()
&& data.avatar.is_none()
&& data.badges.is_none()
&& data.flags.is_none()
&& data.remove.is_none()
{
return Ok(Json(user));
@@ -83,7 +109,11 @@ pub async fn req(
}
}
let mut partial: PartialUser = Default::default();
let mut partial: PartialUser = PartialUser {
badges: data.badges,
flags: data.flags,
..Default::default()
};
// 2. Apply new avatar
if let Some(avatar) = data.avatar {
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "revolt-quark"
version = "0.5.15"
version = "0.5.16"
license = "AGPL-3.0-or-later"
edition = "2021"
@@ -1,4 +1,4 @@
use crate::models::message::{AppendMessage, Message, MessageSort, PartialMessage};
use crate::models::message::{AppendMessage, Message, MessageQuery, PartialMessage};
use crate::{AbstractMessage, Result};
use super::super::DummyDb;
@@ -41,28 +41,8 @@ impl AbstractMessage for DummyDb {
Ok(())
}
async fn fetch_messages(
&self,
channel: &str,
_limit: Option<i64>,
_before: Option<String>,
_after: Option<String>,
_sort: Option<MessageSort>,
_nearby: Option<String>,
) -> Result<Vec<Message>> {
Ok(vec![self.fetch_message(channel).await.unwrap()])
}
async fn search_messages(
&self,
channel: &str,
_query: &str,
_limit: Option<i64>,
_before: Option<String>,
_after: Option<String>,
_sort: MessageSort,
) -> Result<Vec<Message>> {
Ok(vec![self.fetch_message(channel).await.unwrap()])
async fn fetch_messages(&self, _query: MessageQuery) -> Result<Vec<Message>> {
Ok(vec![])
}
/// Add a new reaction to a message
@@ -377,7 +377,7 @@ impl SendableEmbed {
impl BulkMessageResponse {
pub async fn transform(
db: &Database,
channel: &Channel,
channel: Option<&Channel>,
messages: Vec<Message>,
include_users: Option<bool>,
) -> Result<BulkMessageResponse> {
@@ -386,7 +386,8 @@ impl BulkMessageResponse {
let users = User::fetch_foreign_users(db, &user_ids).await?;
Ok(match channel {
Channel::TextChannel { server, .. } | Channel::VoiceChannel { server, .. } => {
Some(Channel::TextChannel { server, .. })
| Some(Channel::VoiceChannel { server, .. }) => {
BulkMessageResponse::MessagesAndUsers {
messages,
users,
+125 -130
View File
@@ -2,7 +2,9 @@ use bson::{to_bson, Document};
use futures::try_join;
use mongodb::options::FindOptions;
use crate::models::message::{AppendMessage, Message, MessageSort, PartialMessage};
use crate::models::message::{
AppendMessage, Message, MessageQuery, MessageSort, MessageTimePeriod, PartialMessage,
};
use crate::r#impl::mongo::DocumentId;
use crate::{AbstractMessage, Error, Result};
@@ -139,145 +141,138 @@ impl AbstractMessage for MongoDb {
.await
}
async fn fetch_messages(
&self,
channel: &str,
limit: Option<i64>,
before: Option<String>,
after: Option<String>,
sort: Option<MessageSort>,
nearby: Option<String>,
) -> Result<Vec<Message>> {
let limit = limit.unwrap_or(50);
Ok(if let Some(nearby) = nearby {
let (a, b) = try_join!(
self.find_with_options::<_, Message>(
COL,
async fn fetch_messages(&self, query: MessageQuery) -> Result<Vec<Message>> {
let mut filter = doc! {};
// 1. Apply message filters
if let Some(channel) = query.filter.channel {
filter.insert("channel", channel);
}
if let Some(author) = query.filter.author {
filter.insert("author", author);
}
let is_search_query = if let Some(query) = query.filter.query {
filter.insert(
"$text",
doc! {
"$search": query
},
);
true
} else {
false
};
// 2. Find query limit
let limit = query.limit.unwrap_or(50);
// 3. Apply message time period
match query.time_period {
MessageTimePeriod::Relative { nearby } => {
// 3.1. Prepare filters
let mut older_message_filter = filter.clone();
let mut newer_message_filter = filter;
older_message_filter.insert(
"_id",
doc! {
"channel": channel,
"_id": {
"$gte": &nearby
}
"$lt": &nearby
},
FindOptions::builder()
.limit(limit / 2 + 1)
.sort(doc! {
"_id": 1_i32
})
.build(),
),
self.find_with_options::<_, Message>(
COL,
);
newer_message_filter.insert(
"_id",
doc! {
"channel": channel,
"_id": {
"$lt": &nearby
}
"$gte": &nearby
},
);
// 3.2. Execute in both directions
let (a, b) = try_join!(
self.find_with_options::<_, Message>(
COL,
newer_message_filter,
FindOptions::builder()
.limit(limit / 2 + 1)
.sort(doc! {
"_id": 1_i32
})
.build(),
),
self.find_with_options::<_, Message>(
COL,
older_message_filter,
FindOptions::builder()
.limit(limit / 2)
.sort(doc! {
"_id": -1_i32
})
.build(),
)
)?;
Ok([a, b].concat())
}
MessageTimePeriod::Absolute {
before,
after,
sort,
} => {
// 3.1. Apply message ID filter
if let Some(doc) = match (before, after) {
(Some(before), Some(after)) => Some(doc! {
"$lt": before,
"$gt": after
}),
(Some(before), _) => Some(doc! {
"$lt": before
}),
(_, Some(after)) => Some(doc! {
"$gt": after
}),
_ => None,
} {
filter.insert("_id", doc);
}
// 3.2. Execute with given message sort
self.find_with_options(
COL,
filter,
FindOptions::builder()
.limit(limit / 2)
.sort(doc! {
"_id": -1_i32
.limit(limit)
.sort(match sort.unwrap_or(MessageSort::Latest) {
// Sort by relevance, fallback to latest
MessageSort::Relevance => {
if is_search_query {
doc! {
"score": {
"$meta": "textScore"
}
}
} else {
doc! {
"_id": -1_i32
}
}
}
// Sort by latest first
MessageSort::Latest => doc! {
"_id": -1_i32
},
// Sort by oldest first
MessageSort::Oldest => doc! {
"_id": 1_i32
},
})
.build(),
)
)?;
[a, b].concat()
} else {
let mut query = doc! { "channel": channel };
if let Some(before) = before {
query.insert("_id", doc! { "$lt": before });
.await
}
if let Some(after) = after {
query.insert("_id", doc! { "$gt": after });
}
let sort: i32 = if let MessageSort::Latest = sort.unwrap_or(MessageSort::Latest) {
-1
} else {
1
};
self.find_with_options::<_, Message>(
COL,
query,
FindOptions::builder()
.limit(limit)
.sort(doc! {
"_id": sort
})
.build(),
)
.await?
})
}
async fn search_messages(
&self,
channel: &str,
query: &str,
limit: Option<i64>,
before: Option<String>,
after: Option<String>,
sort: MessageSort,
) -> Result<Vec<Message>> {
let limit = limit.unwrap_or(50);
let mut filter = doc! {
"channel": channel,
"$text": {
"$search": query
}
};
if let Some(doc) = match (before, after) {
(Some(before), Some(after)) => Some(doc! {
"lt": before,
"gt": after
}),
(Some(before), _) => Some(doc! {
"lt": before
}),
(_, Some(after)) => Some(doc! {
"gt": after
}),
_ => None,
} {
filter.insert("_id", doc);
}
self.find_with_options(
COL,
filter,
FindOptions::builder()
.projection(if let MessageSort::Relevance = &sort {
doc! {
"score": {
"$meta": "textScore"
}
}
} else {
doc! {}
})
.limit(limit)
.sort(match &sort {
MessageSort::Relevance => doc! {
"score": {
"$meta": "textScore"
}
},
MessageSort::Latest => doc! {
"_id": -1_i32
},
MessageSort::Oldest => doc! {
"_id": 1_i32
},
})
.build(),
)
.await
}
/// Add a new reaction to a message
+16 -15
View File
@@ -90,24 +90,25 @@ impl MongoDb {
where
O: Into<Option<FindOptions>>,
{
Ok(self
.col::<T>(collection)
.find(projection, options)
.await
.map_err(|_| Error::DatabaseError {
let result = self.col::<T>(collection).find(projection, options).await;
Ok(if cfg!(debug_assertions) {
result.unwrap()
} else {
result.map_err(|_| Error::DatabaseError {
operation: "find",
with: collection,
})?
.filter_map(|s| async {
if cfg!(debug_assertions) {
// Hard fail on invalid documents
Some(s.unwrap())
} else {
s.ok()
}
})
.collect::<Vec<T>>()
.await)
}
.filter_map(|s| async {
if cfg!(debug_assertions) {
// Hard fail on invalid documents
Some(s.unwrap())
} else {
s.ok()
}
})
.collect::<Vec<T>>()
.await)
}
async fn find<T: DeserializeOwned + Unpin + Send + Sync>(
+51 -1
View File
@@ -157,7 +157,7 @@ pub struct Message {
/// # Message Sort
///
/// Sort used for retrieving messages
#[derive(Serialize, Deserialize, JsonSchema)]
#[derive(Serialize, Deserialize, JsonSchema, Debug)]
#[cfg_attr(feature = "rocket_impl", derive(FromFormField))]
pub enum MessageSort {
/// Sort by the most relevant messages
@@ -174,6 +174,56 @@ impl Default for MessageSort {
}
}
/// # Message Time Period
///
/// Filter and sort messages by time
#[derive(Serialize, Deserialize, JsonSchema)]
#[serde(untagged)]
pub enum MessageTimePeriod {
Relative {
/// Message id to search around
///
/// Specifying 'nearby' ignores 'before', 'after' and 'sort'.
/// It will also take half of limit rounded as the limits to each side.
/// It also fetches the message ID specified.
nearby: String,
},
Absolute {
/// Message id before which messages should be fetched
before: Option<String>,
/// Message id after which messages should be fetched
after: Option<String>,
/// Message sort direction
sort: Option<MessageSort>,
},
}
/// # Message Filter
#[derive(Serialize, Deserialize, JsonSchema, Default)]
pub struct MessageFilter {
/// Parent channel ID
pub channel: Option<String>,
/// Message author ID
pub author: Option<String>,
/// Search query
pub query: Option<String>,
}
/// # Message Query
#[derive(Serialize, Deserialize, JsonSchema)]
pub struct MessageQuery {
/// Maximum number of messages to fetch
///
/// For fetching nearby messages, this is \`(limit + 1)\`.
pub limit: Option<i64>,
/// Filter to apply
#[serde(flatten)]
pub filter: MessageFilter,
/// Time period to fetch
#[serde(flatten)]
pub time_period: MessageTimePeriod,
}
/// # Bulk Message Response
///
/// Response used when multiple messages are fetched
+1 -1
View File
@@ -118,7 +118,7 @@ pub struct Server {
#[serde(skip_serializing_if = "Option::is_none")]
pub banner: Option<File>,
/// Enum of server flags
/// Bitfield of server flags
#[serde(skip_serializing_if = "Option::is_none")]
pub flags: Option<i32>,
+3 -22
View File
@@ -1,4 +1,4 @@
use crate::models::message::{AppendMessage, Message, MessageSort, PartialMessage};
use crate::models::message::{AppendMessage, Message, MessageQuery, PartialMessage};
use crate::Result;
#[async_trait]
@@ -21,27 +21,8 @@ pub trait AbstractMessage: Sync + Send {
/// Delete messages from a channel by their ids and corresponding channel id
async fn delete_messages(&self, channel: &str, ids: Vec<String>) -> Result<()>;
/// Fetch multiple messages
async fn fetch_messages(
&self,
channel: &str,
limit: Option<i64>,
before: Option<String>,
after: Option<String>,
sort: Option<MessageSort>,
nearby: Option<String>,
) -> Result<Vec<Message>>;
/// Search for messages
async fn search_messages(
&self,
channel: &str,
query: &str,
limit: Option<i64>,
before: Option<String>,
after: Option<String>,
sort: MessageSort,
) -> Result<Vec<Message>>;
/// Fetch multiple messages by given query
async fn fetch_messages(&self, query: MessageQuery) -> Result<Vec<Message>>;
/// Add a new reaction to a message
async fn add_reaction(&self, id: &str, emoji: &str, user: &str) -> Result<()>;