Compare commits

...

17 Commits

Author SHA1 Message Date
Paul Makles
8491ced13d fix: ensure list matches 2022-07-18 12:46:33 +01:00
Paul Makles
229d4e2e1d fix: correct ordering for creating ownership change event 2022-07-15 21:46:41 +01:00
Paul Makles
7bdd2d69d6 feat: add message reactions + interactions object 2022-07-15 21:24:49 +01:00
Paul Makles
ab2af9b5e5 feat: add group ownership transfer 2022-07-15 17:58:08 +01:00
Paul Makles
5aebda2d16 chore: restrict emojis to lowercase 2022-07-15 17:57:53 +01:00
Paul Makles
a7273d0556 feat: allow servers to set discoverable value 2022-07-15 17:57:30 +01:00
Paul Makles
72aed64e5b chore: allow banning users not in server 2022-07-15 16:47:27 +01:00
Paul Makles
00ad4d54e7 feat: add nsfw flag to emotes 2022-07-15 16:34:08 +01:00
Paul Makles
cba8c4ef3b fix: add case-insensitive flag to colour regex 2022-07-15 16:13:04 +01:00
Paul Makles
b6e395fe0d fix: use default for roles key 2022-07-15 16:02:58 +01:00
Paul Makles
8d07457c58 chore: make joined_at migration shorter 2022-07-15 16:02:46 +01:00
Paul Makles
f1171e5358 feat: restrict role colours to regex (increase length limit) 2022-07-15 15:36:00 +01:00
Paul Makles
d96c9f62c4 chore: detach emojis on delete 2022-07-15 14:01:59 +01:00
Paul Makles
4f73e43a03 feat: restrict permissions for users in timeout 2022-07-15 13:55:55 +01:00
Paul Makles
741b8ee8fd feat: add role colours to masquerades
feat: add `joined_at` property to members
feat: add `timeout` property to members (non-functional)
2022-07-15 13:42:01 +01:00
Paul Makles
692081b7f0 chore: bump rauth for verification fix 2022-07-14 14:01:10 +01:00
Paul Makles
943b1f08f0 fix: prevent short-circuit on permissions 2022-07-10 18:27:22 +01:00
46 changed files with 2607 additions and 118 deletions

13
Cargo.lock generated
View File

@@ -1995,9 +1995,9 @@ dependencies = [
[[package]]
name = "once_cell"
version = "1.12.0"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7709cef83f0c1f58f666e746a08b21e0085f7440fa6a29cc194d68aac97a4225"
checksum = "18a6dbe30758c9f83eb00cbea4ac95966305f5a7772f3f42ebfc7fc7eddbd8e1"
[[package]]
name = "opaque-debug"
@@ -2505,7 +2505,7 @@ dependencies = [
[[package]]
name = "rauth"
version = "1.0.0"
source = "git+https://github.com/insertish/rauth?rev=c2acaf6b31213e3969f8b48f9deed3daa125d394#c2acaf6b31213e3969f8b48f9deed3daa125d394"
source = "git+https://github.com/insertish/rauth?rev=9c3f9bb58a549a51546bd748274130ac4a667cb4#9c3f9bb58a549a51546bd748274130ac4a667cb4"
dependencies = [
"async-std",
"async-trait",
@@ -2714,7 +2714,7 @@ dependencies = [
[[package]]
name = "revolt-bonfire"
version = "1.0.6-patch.2"
version = "0.5.5"
dependencies = [
"async-std",
"async-tungstenite",
@@ -2730,7 +2730,7 @@ dependencies = [
[[package]]
name = "revolt-delta"
version = "0.5.4"
version = "0.5.5"
dependencies = [
"async-channel",
"async-std",
@@ -2792,6 +2792,7 @@ dependencies = [
"nanoid",
"num_enum",
"okapi",
"once_cell",
"optional_struct",
"pretty_env_logger",
"rauth",
@@ -2986,7 +2987,7 @@ dependencies = [
[[package]]
name = "rocket_rauth"
version = "1.0.0"
source = "git+https://github.com/insertish/rauth?rev=c2acaf6b31213e3969f8b48f9deed3daa125d394#c2acaf6b31213e3969f8b48f9deed3daa125d394"
source = "git+https://github.com/insertish/rauth?rev=9c3f9bb58a549a51546bd748274130ac4a667cb4#9c3f9bb58a549a51546bd748274130ac4a667cb4"
dependencies = [
"okapi",
"rauth",

View File

@@ -1,6 +1,6 @@
[package]
name = "revolt-bonfire"
version = "1.0.6-patch.2"
version = "0.5.5"
license = "AGPL-3.0-or-later"
edition = "2021"

View File

@@ -1,6 +1,6 @@
[package]
name = "revolt-delta"
version = "0.5.4"
version = "0.5.5"
license = "AGPL-3.0-or-later"
authors = ["Paul Makles <paulmakles@gmail.com>"]
edition = "2018"
@@ -52,7 +52,7 @@ mobc-redis = { version = "0.7.0", default-features = false, features = ["async-s
# web
rocket = { version = "0.5.0-rc.2", default-features = false, features = ["json"] }
rocket_empty = { git = "https://github.com/insertish/rocket_empty", branch = "master" }
rocket_rauth = { git = "https://github.com/insertish/rauth", rev = "c2acaf6b31213e3969f8b48f9deed3daa125d394" }
rocket_rauth = { git = "https://github.com/insertish/rauth", rev = "9c3f9bb58a549a51546bd748274130ac4a667cb4" }
# spec generation
schemars = "0.8.8"

View File

@@ -16,19 +16,18 @@ use validator::Validate;
pub struct DataEditChannel {
/// Channel name
#[validate(length(min = 1, max = 32))]
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
/// Channel description
#[validate(length(min = 0, max = 1024))]
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
/// Group owner
owner: Option<String>,
/// Icon
///
/// Provide an Autumn attachment Id.
#[validate(length(min = 1, max = 128))]
icon: Option<String>,
/// Whether this channel is age-restricted
#[serde(skip_serializing_if = "Option::is_none")]
nsfw: Option<bool>,
#[validate(length(min = 1))]
remove: Option<Vec<FieldsChannel>>,
@@ -59,12 +58,47 @@ pub async fn req(
&& data.description.is_none()
&& data.icon.is_none()
&& data.nsfw.is_none()
&& data.owner.is_none()
&& data.remove.is_none()
{
return Ok(Json(channel));
}
let mut partial: PartialChannel = Default::default();
// Transfer group ownership
if let Some(new_owner) = data.owner {
if let Channel::Group {
owner, recipients, ..
} = &mut channel
{
// Make sure we are the owner of this group
if owner != &user.id {
return Err(Error::NotOwner);
}
// Ensure user is part of group
if !recipients.contains(&new_owner) {
return Err(Error::NotInGroup);
}
// Transfer ownership
let old_owner = std::mem::replace(owner, new_owner.to_string());
// Notify clients
SystemMessage::ChannelOwnershipChanged {
from: old_owner,
to: new_owner,
}
} else {
return Err(Error::InvalidOperation);
}
.into_message(channel.id().to_string())
.create(db, &channel, None)
.await
.ok();
}
match &mut channel {
Channel::Group {
id,

View File

@@ -0,0 +1,34 @@
use revolt_quark::{
models::{message::PartialMessage, User},
perms, Db, EmptyResponse, Permission, Ref, Result,
};
/// # Remove All Reactions from Message
///
/// Remove your own, someone else's or all of a given reaction.
///
/// Requires `ManageMessages` permission.
#[openapi(tag = "Interactions")]
#[delete("/<target>/messages/<msg>/reactions")]
pub async fn clear_reactions(db: &Db, user: User, target: Ref, msg: Ref) -> Result<EmptyResponse> {
let channel = target.as_channel(db).await?;
perms(&user)
.channel(&channel)
.throw_permission_and_view_channel(db, Permission::ManageMessages)
.await?;
// Fetch relevant message
let mut message = msg.as_message_in(db, channel.id()).await?;
// Clear reactions
message
.update(
db,
PartialMessage {
reactions: Some(Default::default()),
..Default::default()
},
)
.await
.map(|_| EmptyResponse)
}

View File

@@ -0,0 +1,29 @@
use revolt_quark::{models::User, perms, Db, EmptyResponse, Permission, Ref, Result};
/// # Add Reaction to Message
///
/// React to a given message.
#[openapi(tag = "Interactions")]
#[put("/<target>/messages/<msg>/reactions/<emoji>")]
pub async fn react_message(
db: &Db,
user: User,
target: Ref,
msg: Ref,
emoji: Ref,
) -> Result<EmptyResponse> {
let channel = target.as_channel(db).await?;
perms(&user)
.channel(&channel)
.throw_permission_and_view_channel(db, Permission::React)
.await?;
// Fetch relevant message
let message = msg.as_message_in(db, channel.id()).await?;
// Add the reaction
message
.add_reaction(db, &user, &emoji.id)
.await
.map(|_| EmptyResponse)
}

View File

@@ -2,7 +2,7 @@ use std::collections::HashSet;
use revolt_quark::{
models::{
message::{Masquerade, Reply, SendableEmbed},
message::{Interactions, Masquerade, Reply, SendableEmbed},
Message, User,
},
perms,
@@ -40,6 +40,8 @@ pub struct DataMessageSend {
/// Masquerade to apply to this message
#[validate]
masquerade: Option<Masquerade>,
/// Information about how this message should be interacted with
interactions: Option<Interactions>,
}
lazy_static! {
@@ -86,6 +88,7 @@ pub async fn message_send(
channel: channel.id().to_string(),
author: user.id.clone(),
masquerade: data.masquerade,
interactions: data.interactions.unwrap_or_default(),
..Default::default()
};
@@ -100,13 +103,22 @@ pub async fn message_send(
}
// 2. Verify permissions for masquerade.
if message.masquerade.is_some() {
if let Some(masq) = &message.masquerade {
permissions
.throw_permission(db, Permission::Masquerade)
.await?;
if masq.colour.is_some() {
permissions
.throw_permission(db, Permission::ManageRole)
.await?;
}
}
// 3. Verify replies are valid.
// 3. Ensure interactions information is correct
message.interactions.validate(db).await?;
// 4. Verify replies are valid.
let mut replies = HashSet::new();
if let Some(entries) = data.replies {
if entries.len() > 5 {
@@ -139,7 +151,7 @@ pub async fn message_send(
.replace(replies.into_iter().collect::<Vec<String>>());
}
// 4. Process included embeds.
// 5. Process included embeds.
let mut embeds = vec![];
if let Some(sendable_embeds) = data.embeds {
for sendable_embed in sendable_embeds {
@@ -151,7 +163,7 @@ pub async fn message_send(
message.embeds.replace(embeds);
}
// 5. Add attachments to message.
// 6. Add attachments to message.
let mut attachments = vec![];
if let Some(ids) = &data.attachments {
if !ids.is_empty() {
@@ -177,10 +189,10 @@ pub async fn message_send(
message.attachments.replace(attachments);
}
// 6. Set content
// 7. Set content
message.content = data.content;
// 7. Pass-through nonce value for clients
// 8. Pass-through nonce value for clients
message.nonce = Some(idempotency.into_key());
message.create(db, &channel, Some(&user)).await?;

View File

@@ -0,0 +1,58 @@
use revolt_quark::{models::User, perms, Db, EmptyResponse, Permission, Ref, Result};
use serde::{Deserialize, Serialize};
/// # Query Parameters
#[derive(Serialize, Deserialize, JsonSchema, FromForm)]
pub struct OptionsUnreact {
/// Remove a specific user's reaction
user_id: Option<String>,
/// Remove all reactions
remove_all: Option<bool>,
}
/// # Remove Reaction(s) to Message
///
/// Remove your own, someone else's or all of a given reaction.
///
/// Requires `ManageMessages` if changing others' reactions.
#[openapi(tag = "Interactions")]
#[delete("/<target>/messages/<msg>/reactions/<emoji>?<options..>")]
pub async fn unreact_message(
db: &Db,
user: User,
target: Ref,
msg: Ref,
emoji: Ref,
options: OptionsUnreact,
) -> Result<EmptyResponse> {
let channel = target.as_channel(db).await?;
let mut permissions = perms(&user).channel(&channel);
permissions
.throw_permission_and_view_channel(db, Permission::React)
.await?;
// Check if we need to escalate permissions
let remove_all = options.remove_all.unwrap_or_default();
if options.user_id.is_some() || remove_all {
permissions
.throw_permission(db, Permission::ManageMessages)
.await?;
}
// Fetch relevant message
let message = msg.as_message_in(db, channel.id()).await?;
// Check if we should wipe all of this reaction
if remove_all {
return message
.clear_reaction(db, &emoji.id)
.await
.map(|_| EmptyResponse);
}
// Remove the reaction
message
.remove_reaction(db, options.user_id.as_ref().unwrap_or(&user.id), &emoji.id)
.await
.map(|_| EmptyResponse)
}

View File

@@ -11,13 +11,16 @@ mod group_remove_member;
mod invite_create;
mod members_fetch;
mod message_bulk_delete;
mod message_clear_reactions;
mod message_delete;
mod message_edit;
mod message_fetch;
mod message_query;
mod message_query_stale;
mod message_react;
mod message_search;
mod message_send;
mod message_unreact;
mod permissions_set;
mod permissions_set_default;
mod voice_join;
@@ -44,5 +47,8 @@ pub fn routes() -> (Vec<Route>, OpenApi) {
voice_join::req,
permissions_set::req,
permissions_set_default::req,
message_react::react_message,
message_unreact::unreact_message,
message_clear_reactions::clear_reactions
]
}

View File

@@ -14,7 +14,11 @@ pub struct DataCreateEmoji {
/// Server name
#[validate(length(min = 1, max = 32), regex = "RE_EMOJI")]
name: String,
/// Parent information
parent: EmojiParent,
/// Whether the emoji is mature
#[serde(default)]
nsfw: bool,
}
/// # Create New Emoji
@@ -55,6 +59,7 @@ pub async fn create_emoji(
return Err(Error::TooManyEmoji);
}
}
EmojiParent::Detached => return Err(Error::InvalidOperation),
};
// Find the relevant attachment
@@ -67,6 +72,7 @@ pub async fn create_emoji(
creator_id: user.id,
name: data.name,
animated: "image/gif" == &attachment.content_type,
nsfw: data.nsfw,
};
// Save emoji

View File

@@ -28,6 +28,7 @@ pub async fn delete_emoji(db: &Db, user: User, id: Ref) -> Result<EmptyResponse>
.throw_permission(db, Permission::ManageCustomisation)
.await?;
}
EmojiParent::Detached => return Ok(EmptyResponse),
};
}

View File

@@ -80,6 +80,7 @@ fn custom_openapi_spec() -> OpenApi {
"Channel Invites",
"Channel Permissions",
"Messaging",
"Interactions",
"Groups",
"Voice"
]

View File

@@ -1,5 +1,5 @@
use revolt_quark::{
models::{ServerBan, User},
models::{server_member::MemberCompositeKey, ServerBan, User},
perms, Db, Error, Permission, Ref, Result,
};
@@ -47,13 +47,27 @@ pub async fn req(
.throw_permission(db, Permission::BanMembers)
.await?;
let member = target.as_member(db, &server.id).await?;
// If member exists, check privileges against them
if let Ok(member) = target.as_member(db, &server.id).await {
if member.get_ranking(permissions.server.get().unwrap())
<= permissions.get_member_rank().unwrap_or(i64::MIN)
{
return Err(Error::NotElevated);
}
if member.get_ranking(permissions.server.get().unwrap())
<= permissions.get_member_rank().unwrap_or(i64::MIN)
{
return Err(Error::NotElevated);
server.ban_member(db, member, data.reason).await.map(Json)
} else {
let server_id = server.id.to_string();
server
.ban_user(
db,
MemberCompositeKey {
server: server_id,
user: target.id,
},
data.reason,
)
.await
.map(Json)
}
server.ban_member(db, member, data.reason).await.map(Json)
}

View File

@@ -5,7 +5,7 @@ use revolt_quark::{
server_member::{FieldsMember, PartialMember},
File, Member, User,
},
perms, Db, Error, Permission, Ref, Result,
perms, Db, Error, Permission, Ref, Result, Timestamp,
};
use rocket::serde::json::Json;
@@ -22,6 +22,8 @@ pub struct DataMemberEdit {
avatar: Option<String>,
/// Array of role ids
roles: Option<Vec<String>>,
/// Timestamp this member is timed out until
timeout: Option<Timestamp>,
/// Fields to remove from channel object
#[validate(length(min = 1))]
remove: Option<Vec<FieldsMember>>,
@@ -105,13 +107,7 @@ pub async fn req(
// Check permissions against roles in diff
if let Some(roles) = &data.roles {
let fallback = vec![];
let current_roles = member
.roles
.as_ref()
.unwrap_or(&fallback)
.iter()
.collect::<HashSet<&String>>();
let current_roles = member.roles.iter().collect::<HashSet<&String>>();
let new_roles = roles.iter().collect::<HashSet<&String>>();
let added_roles: Vec<&&String> = new_roles.difference(&current_roles).collect();
@@ -132,12 +128,14 @@ pub async fn req(
nickname,
avatar,
roles,
timeout,
remove,
} = data;
let mut partial = PartialMember {
nickname,
roles,
timeout,
..Default::default()
};

View File

@@ -3,7 +3,9 @@ use revolt_quark::{
server::{FieldsRole, PartialRole, Role},
User,
},
perms, Db, Error, Permission, Ref, Result,
perms,
util::regex::RE_COLOUR,
Db, Error, Permission, Ref, Result,
};
use rocket::serde::json::Json;
@@ -17,7 +19,7 @@ pub struct DataEditRole {
#[validate(length(min = 1, max = 32))]
name: Option<String>,
/// Role colour
#[validate(length(min = 1, max = 32))]
#[validate(length(min = 1, max = 128), regex = "RE_COLOUR")]
colour: Option<String>,
/// Whether this role should be displayed separately
hoist: Option<bool>,

View File

@@ -33,6 +33,8 @@ pub struct DataEditServer {
// Whether this server is age-restricted
// nsfw: Option<bool>,
/// Whether this server is public and should show up on [Revolt Discover](https://rvlt.gg)
discoverable: Option<bool>,
/// Whether analytics should be collected for this server
///
/// Must be enabled in order to show up on [Revolt Discover](https://rvlt.gg).
@@ -102,6 +104,7 @@ pub async fn req(
categories,
system_messages,
// nsfw,
discoverable,
analytics,
remove,
} = data;
@@ -112,6 +115,7 @@ pub async fn req(
categories,
system_messages,
// nsfw,
discoverable,
analytics,
..Default::default()
};

View File

@@ -11,4 +11,4 @@ pub static RE_USERNAME: Lazy<Regex> =
/// Regex for valid emoji names
///
/// Alphanumeric and underscores
pub static RE_EMOJI: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[a-zA-Z0-9_]+$").unwrap());
pub static RE_EMOJI: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[a-z0-9_]+$").unwrap());

View File

@@ -68,6 +68,7 @@ impl_ops = "0.1.1"
num_enum = "0.5.6"
reqwest = "0.11.10"
bitfield = "0.13.2"
once_cell = "1.13.0"
lazy_static = "1.4.0"
lru = { version = "0.7.6", optional = true }
@@ -84,7 +85,7 @@ rocket_empty = { optional = true, git = "https://github.com/insertish/rocket_emp
rocket_cors = { optional = true, git = "https://github.com/lawliet89/rocket_cors", rev = "5843861a88958c16bfaa0b40f0d8910772bcd2f6" }
# rAuth
rauth = { git = "https://github.com/insertish/rauth", rev = "c2acaf6b31213e3969f8b48f9deed3daa125d394", features = [ "async-std-runtime" ] }
rauth = { git = "https://github.com/insertish/rauth", rev = "9c3f9bb58a549a51546bd748274130ac4a667cb4", features = [ "async-std-runtime" ] }
# Sentry
sentry = "0.25.0"

File diff suppressed because it is too large Load Diff

View File

@@ -78,6 +78,29 @@ pub enum EventV1 {
/// Delete message
MessageDelete { id: String, channel: String },
/// New reaction to a message
MessageReact {
id: String,
channel_id: String,
user_id: String,
emoji_id: String,
},
/// Remove user's reaction from message
MessageUnreact {
id: String,
channel_id: String,
user_id: String,
emoji_id: String,
},
/// Remove a reaction from message
MessageRemoveReaction {
id: String,
channel_id: String,
emoji_id: String,
},
/// Bulk delete messages
BulkMessageDelete { channel: String, ids: Vec<String> },

View File

@@ -111,6 +111,7 @@ impl State {
// Fetch all memberships with their corresponding servers.
let members: Vec<Member> = db.fetch_all_memberships(&user.id).await?;
let server_ids: Vec<String> = members.iter().map(|x| x.id.server.clone()).collect();
let servers = db.fetch_servers(&server_ids).await?;
@@ -484,10 +485,8 @@ impl State {
if data.rank.is_some() || data.permissions.is_some() {
if let Some(member) = self.cache.members.get(id) {
if let Some(roles) = &member.roles {
if roles.contains(role_id) {
queue_server = Some(id.clone());
}
if member.roles.contains(role_id) {
queue_server = Some(id.clone());
}
}
}
@@ -498,10 +497,8 @@ impl State {
}
if let Some(member) = self.cache.members.get(id) {
if let Some(roles) = &member.roles {
if roles.contains(role_id) {
queue_server = Some(id.clone());
}
if member.roles.contains(role_id) {
queue_server = Some(id.clone());
}
}
}

View File

@@ -64,4 +64,22 @@ impl AbstractMessage for DummyDb {
) -> Result<Vec<Message>> {
Ok(vec![self.fetch_message(channel).await.unwrap()])
}
/// Add a new reaction to a message
async fn add_reaction(&self, id: &str, emoji: &str, user: &str) -> Result<()> {
info!("Add to {id} with {emoji} and {user}");
Ok(())
}
/// Remove a reaction from a message
async fn remove_reaction(&self, id: &str, emoji: &str, user: &str) -> Result<()> {
info!("Remove {emoji} from {id} for {user}");
Ok(())
}
/// Remove reaction from a message
async fn clear_reaction(&self, id: &str, emoji: &str) -> Result<()> {
info!("Clear {emoji} on {id}");
Ok(())
}
}

View File

@@ -14,6 +14,7 @@ impl AbstractEmoji for DummyDb {
parent: EmojiParent::Server { id: id.into() },
creator_id: id.into(),
animated: false,
nsfw: false,
})
}
@@ -33,9 +34,9 @@ impl AbstractEmoji for DummyDb {
Ok(())
}
/// Delete an emoji by its id
async fn delete_emoji(&self, emoji: &Emoji) -> Result<()> {
info!("Delete {emoji:?}");
/// Detach an emoji by its id
async fn detach_emoji(&self, emoji: &Emoji) -> Result<()> {
info!("Detach {emoji:?}");
Ok(())
}
}

View File

@@ -3,6 +3,8 @@ use crate::{AbstractServerMember, Result};
use super::super::DummyDb;
use iso8601_timestamp::Timestamp;
#[async_trait]
impl AbstractServerMember for DummyDb {
async fn fetch_member(&self, server: &str, user: &str) -> Result<Member> {
@@ -11,9 +13,11 @@ impl AbstractServerMember for DummyDb {
server: server.into(),
user: user.into(),
},
joined_at: Timestamp::now_utc(),
nickname: None,
avatar: None,
roles: None,
roles: vec![],
timeout: None,
})
}

View File

@@ -305,6 +305,15 @@ impl Channel {
vec![],
)
.await?;
SystemMessage::ChannelOwnershipChanged {
from: owner.to_string(),
to: new_owner.into(),
}
.into_message(id.to_string())
.create(db, self, None)
.await
.ok();
} else {
db.delete_channel(self).await?;
return Ok(());

View File

@@ -8,9 +8,10 @@ use crate::{
events::client::EventV1,
models::{
message::{
AppendMessage, BulkMessageResponse, PartialMessage, SendableEmbed, SystemMessage,
AppendMessage, BulkMessageResponse, Interactions, PartialMessage, SendableEmbed,
SystemMessage,
},
Channel, Message, User,
Channel, Emoji, Message, User,
},
presence::presence_filter_online,
tasks::ack::AckEvent,
@@ -191,6 +192,79 @@ impl Message {
Err(Error::PayloadTooLarge)
}
}
/// Add a reaction to a message
pub async fn add_reaction(&self, db: &Database, user: &User, emoji: &str) -> Result<()> {
// Check if the emoji is whitelisted
if !self.interactions.can_use(emoji) {
return Err(Error::InvalidOperation);
}
// Check if the emoji is usable by us
if !Emoji::can_use(db, emoji).await? {
return Err(Error::InvalidOperation);
}
// Send reaction event
EventV1::MessageReact {
id: self.id.to_string(),
channel_id: self.channel.to_string(),
user_id: user.id.to_string(),
emoji_id: emoji.to_string(),
}
.p(self.channel.to_string())
.await;
// Add emoji
db.add_reaction(&self.id, emoji, &user.id).await
}
/// Remove a reaction from a message
pub async fn remove_reaction(&self, db: &Database, user: &str, emoji: &str) -> Result<()> {
// Check if it actually exists
let empty = if let Some(users) = self.reactions.get(emoji) {
if !users.contains(user) {
return Err(Error::NotFound);
}
users.len() == 1
} else {
return Err(Error::NotFound);
};
// Send reaction event
EventV1::MessageUnreact {
id: self.id.to_string(),
channel_id: self.channel.to_string(),
user_id: user.to_string(),
emoji_id: emoji.to_string(),
}
.p(self.channel.to_string())
.await;
if empty {
// If empty, remove the reaction entirely
db.clear_reaction(&self.id, emoji).await
} else {
// Otherwise only remove that one reaction
db.remove_reaction(&self.id, emoji, user).await
}
}
/// Remove a reaction from a message
pub async fn clear_reaction(&self, db: &Database, emoji: &str) -> Result<()> {
// Send reaction event
EventV1::MessageRemoveReaction {
id: self.id.to_string(),
channel_id: self.channel.to_string(),
emoji_id: emoji.to_string(),
}
.p(self.channel.to_string())
.await;
// Write to database
db.clear_reaction(&self.id, emoji).await
}
}
pub trait IntoUsers {
@@ -262,6 +336,9 @@ impl From<SystemMessage> for String {
"Channel description changed.".to_string()
}
SystemMessage::ChannelIconChanged { .. } => "Channel icon changed.".to_string(),
SystemMessage::ChannelOwnershipChanged { .. } => {
"Channel ownership changed.".to_string()
}
}
}
}
@@ -321,3 +398,40 @@ impl BulkMessageResponse {
}
}
}
impl Interactions {
/// Validate interactions info is correct
pub async fn validate(&self, db: &Database) -> Result<()> {
if let Some(reactions) = &self.reactions {
if reactions.len() > 20 {
return Err(Error::InvalidOperation);
}
for reaction in reactions {
if !Emoji::can_use(db, reaction).await? {
return Err(Error::InvalidOperation);
}
}
}
Ok(())
}
/// Check if we can use a given emoji to react
pub fn can_use(&self, emoji: &str) -> bool {
if self.restrict_reactions {
if let Some(reactions) = &self.reactions {
reactions.contains(emoji)
} else {
false
}
} else {
true
}
}
/// Check if default initialisation of fields
pub fn is_default(&self) -> bool {
!self.restrict_reactions && self.reactions.is_none()
}
}

View File

@@ -1,14 +1,27 @@
use std::{collections::HashSet, str::FromStr};
use ulid::Ulid;
use crate::{
events::client::EventV1,
models::{emoji::EmojiParent, Emoji},
Database, Result,
};
lazy_static! {
/// Permissible emojis
static ref PERMISSIBLE_EMOJIS: HashSet<String> = include_str!(crate::asset!("emojis.txt"))
.split('\n')
.map(|x| x.into())
.collect();
}
impl Emoji {
/// Get parent id
fn parent(&self) -> &str {
match &self.parent {
EmojiParent::Server { id } => id,
EmojiParent::Detached => "",
}
}
@@ -30,7 +43,16 @@ impl Emoji {
.p(self.parent().to_string())
.await;
db.mark_attachment_as_deleted(&self.id).await?;
db.delete_emoji(&self).await
db.detach_emoji(&self).await
}
/// Check whether we can use a given emoji
pub async fn can_use(db: &Database, emoji: &str) -> Result<bool> {
if Ulid::from_str(emoji).is_ok() {
db.fetch_emoji(emoji).await?;
Ok(true)
} else {
Ok(PERMISSIBLE_EMOJIS.contains(emoji))
}
}
}

View File

@@ -1,3 +1,4 @@
use iso8601_timestamp::Timestamp;
use ulid::Ulid;
use crate::{
@@ -188,7 +189,11 @@ impl Server {
server: self.id.clone(),
user: user.id.clone(),
},
..Default::default()
joined_at: Timestamp::now_utc(),
nickname: None,
avatar: None,
roles: vec![],
timeout: None,
};
db.insert_member(&member).await?;
@@ -278,6 +283,18 @@ impl Server {
Ok(())
}
/// Create ban
pub async fn ban_user(
self,
db: &Database,
id: MemberCompositeKey,
reason: Option<String>,
) -> Result<ServerBan> {
let ban = ServerBan { id, reason };
db.insert_ban(&ban).await?;
Ok(ban)
}
/// Ban a member from a server
pub async fn ban_member(
self,
@@ -285,16 +302,10 @@ impl Server {
member: Member,
reason: Option<String>,
) -> Result<ServerBan> {
let ban = ServerBan {
id: member.id.clone(),
reason,
};
self.remove_member(db, member, RemovalIntention::Ban)
self.remove_member(db, member.clone(), RemovalIntention::Ban)
.await?;
db.insert_ban(&ban).await?;
Ok(ban)
self.ban_user(db, member.id, reason).await
}
}

View File

@@ -1,3 +1,5 @@
use iso8601_timestamp::Timestamp;
use crate::{
events::client::EventV1,
models::{
@@ -36,19 +38,24 @@ impl Member {
/// Get this user's current ranking
pub fn get_ranking(&self, server: &Server) -> i64 {
if let Some(roles) = &self.roles {
let mut value = i64::MAX;
for role in roles {
if let Some(role) = server.roles.get(role) {
if role.rank < value {
value = role.rank;
}
let mut value = i64::MAX;
for role in &self.roles {
if let Some(role) = server.roles.get(role) {
if role.rank < value {
value = role.rank;
}
}
}
value
value
}
/// Check whether this member is in timeout
pub fn in_timeout(&self) -> bool {
if let Some(timeout) = self.timeout {
*timeout > *Timestamp::now_utc()
} else {
i64::MAX
false
}
}
@@ -56,7 +63,8 @@ impl Member {
match field {
FieldsMember::Avatar => self.avatar = None,
FieldsMember::Nickname => self.nickname = None,
FieldsMember::Roles => self.roles = None,
FieldsMember::Roles => self.roles.clear(),
FieldsMember::Timeout => self.timeout = None,
}
}
}

View File

@@ -1,6 +1,6 @@
use std::time::Duration;
use bson::Bson;
use bson::{Bson, DateTime};
use futures::StreamExt;
use mongodb::{
bson::{doc, from_bson, from_document, to_document, Document},
@@ -16,7 +16,7 @@ struct MigrationInfo {
revision: i32,
}
pub const LATEST_REVISION: i32 = 17;
pub const LATEST_REVISION: i32 = 18;
pub async fn migrate_database(db: &MongoDb) {
let migrations = db.col::<Document>("migrations");
@@ -644,6 +644,23 @@ pub async fn run_migrations(db: &MongoDb, revision: i32) -> i32 {
.expect("Failed to create emoji parent index.");
}
if revision <= 17 {
info!("Running migration [revision 17 / 15-07-2022]: Initialise `joined_at` property on server members.");
db.col::<Document>("server_members")
.update_many(
doc! {},
doc! {
"$set": {
"joined_at": DateTime::now().to_rfc3339_string()
}
},
None,
)
.await
.expect("Failed to update server members.");
}
// Need to migrate fields on attachments, change `user_id`, `object_id`, etc to `parent`.
// Reminder to update LATEST_REVISION when adding new migrations.

View File

@@ -279,4 +279,70 @@ impl AbstractMessage for MongoDb {
)
.await
}
/// Add a new reaction to a message
async fn add_reaction(&self, id: &str, emoji: &str, user: &str) -> Result<()> {
self.col::<Document>(COL)
.update_one(
doc! {
"_id": id
},
doc! {
"$addToSet": {
format!("reactions.{emoji}"): user
}
},
None,
)
.await
.map(|_| ())
.map_err(|_| Error::DatabaseError {
operation: "update_one",
with: "message",
})
}
/// Remove a reaction from a message
async fn remove_reaction(&self, id: &str, emoji: &str, user: &str) -> Result<()> {
self.col::<Document>(COL)
.update_one(
doc! {
"_id": id
},
doc! {
"$pull": {
format!("reactions.{emoji}"): user
}
},
None,
)
.await
.map(|_| ())
.map_err(|_| Error::DatabaseError {
operation: "update_one",
with: "message",
})
}
/// Remove reaction from a message
async fn clear_reaction(&self, id: &str, emoji: &str) -> Result<()> {
self.col::<Document>(COL)
.update_one(
doc! {
"_id": id
},
doc! {
"$unset": {
format!("reactions.{emoji}"): 1
}
},
None,
)
.await
.map(|_| ())
.map_err(|_| Error::DatabaseError {
operation: "update_one",
with: "message",
})
}
}

View File

@@ -1,5 +1,7 @@
use bson::Document;
use crate::models::Emoji;
use crate::{AbstractEmoji, Result};
use crate::{AbstractEmoji, Error, Result};
use super::super::MongoDb;
@@ -42,7 +44,26 @@ impl AbstractEmoji for MongoDb {
}
/// Delete an emoji by its id
async fn delete_emoji(&self, emoji: &Emoji) -> Result<()> {
self.delete_one_by_id(COL, &emoji.id).await.map(|_| ())
async fn detach_emoji(&self, emoji: &Emoji) -> Result<()> {
self.col::<Document>(COL)
.update_one(
doc! {
"_id": &emoji.id
},
doc! {
"$set": {
"parent": {
"type": "Detached"
}
}
},
None,
)
.await
.map(|_| ())
.map_err(|_| Error::DatabaseError {
operation: "update_one",
with: "emojis",
})
}
}

View File

@@ -129,6 +129,7 @@ impl IntoDocumentPath for FieldsMember {
FieldsMember::Avatar => "avatar",
FieldsMember::Nickname => "nickname",
FieldsMember::Roles => "roles",
FieldsMember::Timeout => "timeout",
})
}
}

View File

@@ -1,3 +1,6 @@
use crate::util::regex::RE_COLOUR;
use std::collections::{HashMap, HashSet};
use serde::{Deserialize, Serialize};
use validator::Validate;
@@ -11,6 +14,11 @@ use crate::{
types::january::Embed,
};
/// Utility function to check if a boolean value is false
pub fn if_false(t: &bool) -> bool {
!t
}
/// # Reply
///
/// Representation of a message reply before it is sent.
@@ -33,7 +41,7 @@ pub struct SendableEmbed {
#[validate(length(min = 1, max = 2000))]
pub description: Option<String>,
pub media: Option<String>,
#[validate(length(min = 1, max = 64))]
#[validate(length(min = 1, max = 128), regex = "RE_COLOUR")]
pub colour: Option<String>,
}
@@ -61,6 +69,8 @@ pub enum SystemMessage {
ChannelDescriptionChanged { by: String },
#[serde(rename = "channel_icon_changed")]
ChannelIconChanged { by: String },
#[serde(rename = "channel_ownership_changed")]
ChannelOwnershipChanged { from: String, to: String },
}
/// Name and / or avatar override information
@@ -74,6 +84,25 @@ pub struct Masquerade {
#[serde(skip_serializing_if = "Option::is_none")]
#[validate(length(min = 1, max = 128))]
pub avatar: Option<String>,
/// Replace the display role colour shown on this message
///
/// Must have `ManageRole` permission to use
///
/// This can be any valid CSS colour
#[serde(skip_serializing_if = "Option::is_none")]
#[validate(length(min = 1, max = 32))]
pub colour: Option<String>,
}
/// Information to guide interactions on this message
#[derive(Serialize, Deserialize, JsonSchema, Debug, Clone, Validate, Default)]
pub struct Interactions {
/// Reactions which should always appear and be distinct
#[serde(skip_serializing_if = "Option::is_none", default)]
pub reactions: Option<HashSet<String>>,
/// Whether reactions should be restricted to the given list
#[serde(skip_serializing_if = "if_false", default)]
pub restrict_reactions: bool,
}
/// Representation of a Message on Revolt
@@ -115,6 +144,12 @@ pub struct Message {
/// Array of message ids this message is replying to
#[serde(skip_serializing_if = "Option::is_none")]
pub replies: Option<Vec<String>>,
/// Hashmap of emoji IDs to array of user IDs
#[serde(skip_serializing_if = "HashMap::is_empty", default)]
pub reactions: HashMap<String, HashSet<String>>,
/// Information about how this message should be interacted with
#[serde(skip_serializing_if = "Interactions::is_default", default)]
pub interactions: Interactions,
/// Name and / or avatar overrides for this message
#[serde(skip_serializing_if = "Option::is_none")]
pub masquerade: Option<Masquerade>,

View File

@@ -1,10 +1,16 @@
use serde::{Deserialize, Serialize};
/// Utility function to check if a boolean value is false
pub fn if_false(t: &bool) -> bool {
!t
}
/// Information about what owns this emoji
#[derive(Serialize, Deserialize, JsonSchema, Debug, Clone)]
#[serde(tag = "type")]
pub enum EmojiParent {
Server { id: String },
Detached,
}
/// Representation of an Emoji on Revolt
@@ -20,5 +26,9 @@ pub struct Emoji {
/// Emoji name
pub name: String,
/// Whether the emoji is animated
#[serde(skip_serializing_if = "if_false", default)]
pub animated: bool,
/// Whether the emoji is marked as nsfw
#[serde(skip_serializing_if = "if_false", default)]
pub nsfw: bool,
}

View File

@@ -1,3 +1,4 @@
use iso8601_timestamp::Timestamp;
use serde::{Deserialize, Serialize};
use crate::models::attachment::File;
@@ -12,7 +13,7 @@ pub struct MemberCompositeKey {
}
/// Representation of a member of a server on Revolt
#[derive(Serialize, Deserialize, JsonSchema, Debug, Clone, OptionalStruct, Default)]
#[derive(Serialize, Deserialize, JsonSchema, Debug, Clone, OptionalStruct)]
#[optional_derive(Serialize, Deserialize, JsonSchema, Debug, Default, Clone)]
#[optional_name = "PartialMember"]
#[opt_skip_serializing_none]
@@ -22,6 +23,9 @@ pub struct Member {
#[serde(rename = "_id")]
pub id: MemberCompositeKey,
/// Time at which this user joined the server
pub joined_at: Timestamp,
/// Member's nickname
#[serde(skip_serializing_if = "Option::is_none")]
pub nickname: Option<String>,
@@ -30,8 +34,11 @@ pub struct Member {
pub avatar: Option<File>,
/// Member's roles
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub roles: Vec<String>,
/// Timestamp this member is timed out until
#[serde(skip_serializing_if = "Option::is_none")]
pub roles: Option<Vec<String>>,
pub timeout: Option<Timestamp>,
}
/// Optional fields on server member object
@@ -40,6 +47,7 @@ pub enum FieldsMember {
Nickname,
Avatar,
Roles,
Timeout,
}
/// Member removal intention

View File

@@ -45,8 +45,23 @@ impl Override {
impl PermissionValue {
/// Apply a given override to this value
pub fn apply(&mut self, v: Override) {
self.0 |= v.allow;
self.0 &= !v.deny;
self.allow(v.allow);
self.revoke(v.deny);
}
/// Allow given permissions
pub fn allow(&mut self, v: u64) {
self.0 |= v;
}
/// Revoke given permissions
pub fn revoke(&mut self, v: u64) {
self.0 &= !v;
}
/// Restrict to given permissions
pub fn restrict(&mut self, v: u64) {
self.0 &= v;
}
}

View File

@@ -65,8 +65,8 @@ pub enum Permission {
UploadFiles = 1 << 27,
/// Masquerade messages using custom nickname and avatar
Masquerade = 1 << 28,
// % 1 bits reserved
/// React to messages with emojis
React = 1 << 29,
// * Voice permissions
/// Connect to a voice channel
@@ -98,6 +98,7 @@ impl_op_ex!(+ |a: &Permission, b: &Permission| -> u64 { *a as u64 | *b as u64 })
impl_op_ex_commutative!(+ |a: &u64, b: &Permission| -> u64 { *a | *b as u64 });
lazy_static! {
pub static ref ALLOW_IN_TIMEOUT: u64 = Permission::ViewChannel + Permission::ReadMessageHistory;
pub static ref DEFAULT_PERMISSION_VIEW_ONLY: u64 =
Permission::ViewChannel + Permission::ReadMessageHistory;
pub static ref DEFAULT_PERMISSION: u64 = *DEFAULT_PERMISSION_VIEW_ONLY

View File

@@ -2,7 +2,7 @@ use std::collections::HashSet;
use crate::{
models::Channel, permissions::PermissionCalculator, Override, Permission, PermissionValue,
Permissions, Perms, Result, DEFAULT_PERMISSION_DIRECT_MESSAGE,
Permissions, Perms, Result, ALLOW_IN_TIMEOUT, DEFAULT_PERMISSION_DIRECT_MESSAGE,
DEFAULT_PERMISSION_SAVED_MESSAGES, DEFAULT_PERMISSION_VIEW_ONLY,
};
@@ -55,11 +55,7 @@ async fn calculate_server_permission(
let mut permissions: PermissionValue = server.default_permissions.into();
// 4. Resolve each role in order.
let member_roles: HashSet<&String> = if let Some(roles) = member.roles.as_ref() {
roles.iter().collect()
} else {
HashSet::new()
};
let member_roles: HashSet<&String> = member.roles.iter().collect();
if !member_roles.is_empty() {
let mut roles = server
@@ -80,6 +76,11 @@ async fn calculate_server_permission(
}
}
// 5. Revoke permissions if member is timed out.
if member.in_timeout() {
permissions.restrict(*ALLOW_IN_TIMEOUT);
}
Ok(permissions)
}
@@ -110,25 +111,36 @@ async fn calculate_channel_permission(
// 1. Check channel type.
let value: PermissionValue = match channel {
Channel::SavedMessages { .. } => (*DEFAULT_PERMISSION_SAVED_MESSAGES).into(),
Channel::DirectMessage { recipients, .. } => {
// 2. Fetch user.
let other_user = recipients
.iter()
.find(|x| x != &&data.perspective.id)
.unwrap();
let user = db.fetch_user(other_user).await?;
data.user.set(user);
// 3. Calculate user permissions.
let perms = data.calc_user(db).await;
// 4. Check if the user can send messages.
if perms.get_send_message() {
(*DEFAULT_PERMISSION_DIRECT_MESSAGE).into()
Channel::SavedMessages { user, .. } => {
if user == &data.perspective.id {
(*DEFAULT_PERMISSION_SAVED_MESSAGES).into()
} else {
(*DEFAULT_PERMISSION_VIEW_ONLY).into()
0_u64.into()
}
}
Channel::DirectMessage { recipients, .. } => {
// 2. Ensure we are a recipient.
if recipients.contains(&data.perspective.id) {
// 3. Fetch user.
let other_user = recipients
.iter()
.find(|x| x != &&data.perspective.id)
.unwrap();
let user = db.fetch_user(other_user).await?;
data.user.set(user);
// 4. Calculate user permissions.
let perms = data.calc_user(db).await;
// 5. Check if the user can send messages.
if perms.get_send_message() {
(*DEFAULT_PERMISSION_DIRECT_MESSAGE).into()
} else {
(*DEFAULT_PERMISSION_VIEW_ONLY).into()
}
} else {
0_u64.into()
}
}
Channel::Group {
@@ -182,11 +194,7 @@ async fn calculate_channel_permission(
}
// 4. Resolve each role in order.
let member_roles: HashSet<&String> = if let Some(roles) = member.roles.as_ref() {
roles.iter().collect()
} else {
HashSet::new()
};
let member_roles: HashSet<&String> = member.roles.iter().collect();
if !member_roles.is_empty() {
let mut roles = role_permissions
@@ -208,6 +216,11 @@ async fn calculate_channel_permission(
}
}
// 5. Revoke permissions if member is timed out.
if member.in_timeout() {
permissions.restrict(*ALLOW_IN_TIMEOUT);
}
permissions
} else {
(Permission::GrantAllSafe as u64).into()

View File

@@ -42,4 +42,13 @@ pub trait AbstractMessage: Sync + Send {
after: Option<String>,
sort: MessageSort,
) -> Result<Vec<Message>>;
/// Add a new reaction to a message
async fn add_reaction(&self, id: &str, emoji: &str, user: &str) -> Result<()>;
/// Remove a reaction from a message
async fn remove_reaction(&self, id: &str, emoji: &str, user: &str) -> Result<()>;
/// Remove reaction from a message
async fn clear_reaction(&self, id: &str, emoji: &str) -> Result<()>;
}

View File

@@ -15,6 +15,6 @@ pub trait AbstractEmoji: Sync + Send {
/// Insert emoji into database.
async fn insert_emoji(&self, emoji: &Emoji) -> Result<()>;
/// Delete an emoji by its id
async fn delete_emoji(&self, emoji: &Emoji) -> Result<()>;
/// Detach an emoji by its id
async fn detach_emoji(&self, emoji: &Emoji) -> Result<()>;
}

View File

@@ -3,6 +3,7 @@ pub mod manipulation;
pub mod pfp;
pub mod rauth;
pub mod r#ref;
pub mod regex;
pub mod result;
pub mod value;
pub mod variables;

View File

@@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize};
use crate::models::{Bot, Channel, Emoji, Invite, Member, Message, Server, ServerBan, User};
use crate::presence::presence_is_online;
use crate::{Database, Result};
use crate::{Database, Error, Result};
/// Reference to some object in the database
#[derive(Serialize, Deserialize)]
@@ -44,6 +44,16 @@ impl Ref {
db.fetch_message(&self.id).await
}
/// Fetch message in channel from Ref
pub async fn as_message_in(&self, db: &Database, channel: &str) -> Result<Message> {
let message = self.as_message(db).await?;
if message.channel != channel {
return Err(Error::NotFound);
}
Ok(message)
}
/// Fetch bot from Ref
pub async fn as_bot(&self, db: &Database) -> Result<Bot> {
db.fetch_bot(&self.id).await

View File

@@ -0,0 +1,21 @@
use once_cell::sync::Lazy;
use regex::Regex;
/// Regex for valid role colours
///
/// Allows the use of named colours, rgb(a), variables and all gradients.
///
/// Flags:
/// - Case-insensitive (`i`)
///
/// Source:
/// ```regex
/// VALUE = [a-z ]+|var\(--[a-z\d-]+\)|rgba?\([\d, ]+\)|#[a-f0-9]+
/// ADDITIONAL_VALUE = \d+deg
/// STOP = ([ ]+(\d{1,3}%|0))?
///
/// ^(?:VALUE|(repeating-)?(linear|conic|radial)-gradient\((VALUE|ADDITIONAL_VALUE)STOP(,[ ]*(VALUE)STOP)+\))$
/// ```
pub static RE_COLOUR: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(?i)^(?:[a-z ]+|var\(--[a-z\d-]+\)|rgba?\([\d, ]+\)|#[a-f0-9]+|(repeating-)?(linear|conic|radial)-gradient\(([a-z ]+|var\(--[a-z\d-]+\)|rgba?\([\d, ]+\)|#[a-f0-9]+|\d+deg)([ ]+(\d{1,3}%|0))?(,[ ]*([a-z ]+|var\(--[a-z\d-]+\)|rgba?\([\d, ]+\)|#[a-f0-9]+)([ ]+(\d{1,3}%|0))?)+\))$").unwrap()
});

View File

@@ -72,6 +72,7 @@ pub enum Error {
},
NotElevated,
CannotGiveMissingPermissions,
NotOwner,
// ? General errors.
DatabaseError {
@@ -167,6 +168,7 @@ impl<'r> Responder<'r, 'static> for Error {
Error::MissingUserPermission { .. } => Status::Forbidden,
Error::NotElevated => Status::Forbidden,
Error::CannotGiveMissingPermissions => Status::Forbidden,
Error::NotOwner => Status::Forbidden,
Error::DatabaseError { .. } => Status::InternalServerError,
Error::InternalError => Status::InternalServerError,

View File

@@ -50,6 +50,7 @@ services:
/usr/bin/mc mb minio/backgrounds;
/usr/bin/mc mb minio/icons;
/usr/bin/mc mb minio/banners;
/usr/bin/mc mb minio/emojis;
exit 0;
"
# File server (autumn)