feat(core/database): implement webhook model

This commit is contained in:
Paul Makles
2023-06-03 13:00:05 +01:00
parent 410acb5b5c
commit 1dbbc3ed8d
14 changed files with 841 additions and 1 deletions

View File

@@ -15,6 +15,7 @@ mongodb = [ "dep:mongodb", "bson" ]
# ... Other
async-std-runtime = [ "async-std" ]
rocket-impl = [ "rocket", "schemars" ]
redis-is-patched = [ "revolt-presence/redis-is-patched" ]
# Default Features
default = [ "mongodb", "async-std-runtime" ]
@@ -22,6 +23,8 @@ default = [ "mongodb", "async-std-runtime" ]
[dependencies]
# Core
revolt-result = { version = "0.0.2", path = "../result" }
revolt-models = { version = "0.0.2", path = "../models" }
revolt-presence = { version = "0.0.2", path = "../presence" }
revolt-permissions = { version = "0.0.2", path = "../permissions", features = [ "serde" ] }
# Utility
@@ -36,6 +39,9 @@ revolt_optional_struct = "0.2.0"
serde = { version = "1", features = ["derive"] }
iso8601-timestamp = { version = "0.2.10", features = ["serde", "bson"] }
# Events
redis-kiss = { version = "0.1.4" }
# Database
bson = { optional = true, version = "2.1.0" }
mongodb = { optional = true, version = "2.1.0", default-features = false }

View File

@@ -2,7 +2,9 @@ use std::{collections::HashMap, sync::Arc};
use futures::lock::Mutex;
use crate::{AccountStrike, Bot, File, Member, MemberCompositeKey, Server, User, UserSettings};
use crate::{
AccountStrike, Bot, File, Member, MemberCompositeKey, Server, User, UserSettings, Webhook,
};
database_derived!(
/// Reference implementation
@@ -10,6 +12,7 @@ database_derived!(
pub struct ReferenceDb {
pub account_strikes: Arc<Mutex<HashMap<String, AccountStrike>>>,
pub bots: Arc<Mutex<HashMap<String, Bot>>>,
pub channel_webhooks: Arc<Mutex<HashMap<String, Webhook>>>,
pub user_settings: Arc<Mutex<HashMap<String, UserSettings>>>,
pub users: Arc<Mutex<HashMap<String, User>>>,
pub server_members: Arc<Mutex<HashMap<MemberCompositeKey, Member>>>,

View File

@@ -0,0 +1,267 @@
use authifier::AuthifierEvent;
use serde::{Deserialize, Serialize};
use revolt_models::v0::{FieldsWebhook, PartialWebhook, Webhook};
use revolt_result::Error;
use crate::Database;
/// WebSocket Client Errors
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(tag = "error")]
pub enum WebSocketError {
LabelMe,
InternalError { at: String },
InvalidSession,
OnboardingNotFinished,
AlreadyAuthenticated,
MalformedData { msg: String },
}
/// Ping Packet
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(untagged)]
pub enum Ping {
Binary(Vec<u8>),
Number(usize),
}
/// Untagged Error
#[derive(Serialize)]
#[serde(untagged)]
pub enum ErrorEvent {
Error(WebSocketError),
APIError(Error),
}
/// Protocol Events
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(tag = "type")]
pub enum EventV1 {
/// Multiple events
Bulk { v: Vec<EventV1> },
/// Successfully authenticated
Authenticated,
/* /// Basic data to cache
Ready {
users: Vec<User>,
servers: Vec<Server>,
channels: Vec<Channel>,
members: Vec<Member>,
emojis: Option<Vec<Emoji>>,
},
/// Ping response
Pong { data: Ping },
/// New message
Message(Message),
/// Update existing message
MessageUpdate {
id: String,
channel: String,
data: PartialMessage,
},
/// Append information to existing message
MessageAppend {
id: String,
channel: String,
append: AppendMessage,
},
/// 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> },
/// New channel
ChannelCreate(Channel),
/// Update existing channel
ChannelUpdate {
id: String,
data: PartialChannel,
clear: Vec<FieldsChannel>,
},
/// Delete channel
ChannelDelete { id: String },
/// User joins a group
ChannelGroupJoin { id: String, user: String },
/// User leaves a group
ChannelGroupLeave { id: String, user: String },
/// User started typing in a channel
ChannelStartTyping { id: String, user: String },
/// User stopped typing in a channel
ChannelStopTyping { id: String, user: String },
/// User acknowledged message in channel
ChannelAck {
id: String,
user: String,
message_id: String,
},
/// New server
ServerCreate {
id: String,
server: Server,
channels: Vec<Channel>,
},
/// Update existing server
ServerUpdate {
id: String,
data: PartialServer,
clear: Vec<FieldsServer>,
},
/// Delete server
ServerDelete { id: String },
/// Update existing server member
ServerMemberUpdate {
id: MemberCompositeKey,
data: PartialMember,
clear: Vec<FieldsMember>,
},
/// User joins server
ServerMemberJoin { id: String, user: String },
/// User left server
ServerMemberLeave { id: String, user: String },
/// Server role created or updated
ServerRoleUpdate {
id: String,
role_id: String,
data: PartialRole,
clear: Vec<FieldsRole>,
},
/// Server role deleted
ServerRoleDelete { id: String, role_id: String },
/// Update existing user
UserUpdate {
id: String,
data: PartialUser,
clear: Vec<FieldsUser>,
event_id: Option<String>,
},
/// Relationship with another user changed
UserRelationship {
id: String,
user: User,
// ! this field can be deprecated
status: RelationshipStatus,
},
/// Settings updated remotely
UserSettingsUpdate { id: String, update: UserSettings },
/// User has been platform banned or deleted their account
///
/// Clients should remove the following associated data:
/// - Messages
/// - DM Channels
/// - Relationships
/// - Server Memberships
///
/// User flags are specified to explain why a wipe is occurring though not all reasons will necessarily ever appear.
UserPlatformWipe { user_id: String, flags: i32 },
/// New emoji
EmojiCreate(Emoji),
/// Delete emoji
EmojiDelete { id: String },
/// New report
ReportCreate(Report), */
/// New webhook
WebhookCreate(Webhook),
/// Update existing webhook
WebhookUpdate {
id: String,
data: PartialWebhook,
remove: Vec<FieldsWebhook>,
},
/// Delete webhook
WebhookDelete { id: String },
/// Auth events
Auth(AuthifierEvent),
}
impl EventV1 {
/// Publish helper wrapper
pub async fn p(self, channel: String) {
#[cfg(not(debug_assertions))]
redis_kiss::p(channel, self).await;
#[cfg(debug_assertions)]
info!("Publishing event to {channel}: {self:?}");
#[cfg(debug_assertions)]
redis_kiss::publish(channel, self).await.unwrap();
}
/// Publish user event
pub async fn p_user(self, id: String, db: &Database) {
self.clone().p(id.clone()).await;
// ! FIXME: this should be captured by member list in the future
// ! and not immediately fanned out to users
if let Ok(members) = db.fetch_all_memberships(&id).await {
for member in members {
self.clone().p(member.id.server).await;
}
}
}
/// Publish private event
pub async fn private(self, id: String) {
self.p(format!("{id}!")).await;
}
/// Publish internal global event
pub async fn global(self) {
self.p("global".to_string()).await;
}
}

View File

@@ -0,0 +1 @@
pub mod client;

View File

@@ -80,6 +80,8 @@ mod models;
pub mod util;
pub use models::*;
pub mod events;
/// Utility function to check if a boolean value is false
pub fn if_false(t: &bool) -> bool {
!t

View File

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

View File

@@ -0,0 +1,158 @@
use revolt_result::Result;
use crate::events::client::EventV1;
use crate::{Database, File};
auto_derived_partial!(
/// Webhook
pub struct Webhook {
/// Webhook Id
#[serde(rename = "_id")]
pub id: String,
/// The name of the webhook
pub name: String,
/// The avatar of the webhook
#[serde(skip_serializing_if = "Option::is_none")]
pub avatar: Option<File>,
/// The channel this webhook belongs to
pub channel_id: String,
/// The private token for the webhook
pub token: Option<String>,
},
"PartialWebhook"
);
auto_derived!(
/// Optional fields on webhook object
pub enum FieldsWebhook {
Avatar,
}
);
#[allow(clippy::disallowed_methods)]
impl Webhook {
pub async fn create(&self, db: &Database) -> Result<()> {
db.insert_webhook(self).await?;
// Avoid leaking the token to people who receive the event
let mut webhook = self.clone();
webhook.token = None;
EventV1::WebhookCreate(webhook.into())
.p(self.channel_id.clone())
.await;
Ok(())
}
pub fn assert_token(&self, token: &str) -> Result<()> {
if self.token.as_deref() == Some(token) {
Ok(())
} else {
Err(create_error!(InvalidCredentials))
}
}
pub async fn update(
&mut self,
db: &Database,
mut partial: PartialWebhook,
remove: Vec<FieldsWebhook>,
) -> Result<()> {
for field in &remove {
self.remove_field(field)
}
self.apply_options(partial.clone());
db.update_webhook(&self.id, &partial, &remove).await?;
partial.token = None; // Avoid leaking the token to people who receive the event
EventV1::WebhookUpdate {
id: self.id.clone(),
data: partial.into(),
remove: remove.into_iter().map(|v| v.into()).collect(),
}
.p(self.channel_id.clone())
.await;
Ok(())
}
pub fn remove_field(&mut self, field: &FieldsWebhook) {
match field {
FieldsWebhook::Avatar => self.avatar = None,
}
}
pub async fn delete(&self, db: &Database) -> Result<()> {
db.delete_webhook(&self.id).await?;
EventV1::WebhookDelete {
id: self.id.clone(),
}
.p(self.channel_id.clone())
.await;
Ok(())
}
}
#[cfg(test)]
mod tests {
use crate::{FieldsWebhook, PartialWebhook, Webhook};
#[async_std::test]
async fn crud() {
database_test!(|db| async move {
let webhook_id = "webhook";
let channel_id = "channel";
let webhook = Webhook {
id: webhook_id.to_string(),
name: "Webhook Name".to_string(),
channel_id: channel_id.to_string(),
avatar: Some(Default::default()),
..Default::default()
};
db.insert_webhook(&webhook).await.unwrap();
let mut updated_webhook = webhook.clone();
updated_webhook
.update(
&db,
PartialWebhook {
name: Some("New Name".to_string()),
..Default::default()
},
vec![FieldsWebhook::Avatar],
)
.await
.unwrap();
let fetched_webhook = db.fetch_webhook(webhook_id).await.unwrap();
let fetched_webhooks = db.fetch_webhooks_for_channel(channel_id).await.unwrap();
assert_eq!(updated_webhook, fetched_webhook);
assert_ne!(webhook, fetched_webhook);
assert_eq!(1, fetched_webhooks.len());
assert_eq!(fetched_webhook, fetched_webhooks[0]);
webhook.delete(&db).await.unwrap();
assert!(db.fetch_webhook(webhook_id).await.is_err());
assert_eq!(
0,
db.fetch_webhooks_for_channel(channel_id)
.await
.unwrap()
.len()
)
});
}
}

View File

@@ -0,0 +1,29 @@
use revolt_result::Result;
use crate::{FieldsWebhook, PartialWebhook, Webhook};
mod mongodb;
mod reference;
#[async_trait]
pub trait AbstractWebhooks: Sync + Send {
/// Insert new webhook into the database
async fn insert_webhook(&self, webhook: &Webhook) -> Result<()>;
/// Fetch webhook by id
async fn fetch_webhook(&self, webhook_id: &str) -> Result<Webhook>;
/// Fetch webhooks for channel
async fn fetch_webhooks_for_channel(&self, channel_id: &str) -> Result<Vec<Webhook>>;
/// Update webhook with new information
async fn update_webhook(
&self,
webhook_id: &str,
partial: &PartialWebhook,
remove: &[FieldsWebhook],
) -> Result<()>;
/// Delete webhook by id
async fn delete_webhook(&self, webhook_id: &str) -> Result<()>;
}

View File

@@ -0,0 +1,77 @@
use futures::StreamExt;
use revolt_result::Result;
use crate::{FieldsWebhook, PartialWebhook, Webhook};
use crate::{IntoDocumentPath, MongoDb};
use super::AbstractWebhooks;
static COL: &str = "channel_webhooks";
#[async_trait]
impl AbstractWebhooks for MongoDb {
/// Insert new webhook into the database
async fn insert_webhook(&self, webhook: &Webhook) -> Result<()> {
query!(self, insert_one, COL, &webhook).map(|_| ())
}
/// Fetch webhook by id
async fn fetch_webhook(&self, webhook_id: &str) -> Result<Webhook> {
query!(self, find_one_by_id, COL, webhook_id)?.ok_or_else(|| create_error!(NotFound))
}
/// Fetch webhooks for channel
async fn fetch_webhooks_for_channel(&self, channel_id: &str) -> Result<Vec<Webhook>> {
Ok(self
.col::<Webhook>(COL)
.find(
doc! {
"channel_id": channel_id,
},
None,
)
.await
.map_err(|_| create_database_error!("find", COL))?
.filter_map(|s| async {
if cfg!(debug_assertions) {
Some(s.unwrap())
} else {
s.ok()
}
})
.collect()
.await)
}
/// Update webhook with new information
async fn update_webhook(
&self,
webhook_id: &str,
partial: &PartialWebhook,
remove: &[FieldsWebhook],
) -> Result<()> {
query!(
self,
update_one_by_id,
COL,
webhook_id,
partial,
remove.iter().map(|x| x as &dyn IntoDocumentPath).collect(),
None
)
.map(|_| ())
}
/// Delete webhook by id
async fn delete_webhook(&self, webhook_id: &str) -> Result<()> {
query!(self, delete_one_by_id, COL, webhook_id).map(|_| ())
}
}
impl IntoDocumentPath for FieldsWebhook {
fn as_path(&self) -> Option<&'static str> {
Some(match self {
FieldsWebhook::Avatar => "avatar",
})
}
}

View File

@@ -0,0 +1,70 @@
use revolt_result::Result;
use crate::ReferenceDb;
use crate::{FieldsWebhook, PartialWebhook, Webhook};
use super::AbstractWebhooks;
#[async_trait]
impl AbstractWebhooks for ReferenceDb {
/// Insert new webhook into the database
async fn insert_webhook(&self, webhook: &Webhook) -> Result<()> {
let mut webhooks = self.channel_webhooks.lock().await;
if webhooks.contains_key(&webhook.id) {
Err(create_database_error!("insert", "webhook"))
} else {
webhooks.insert(webhook.id.to_string(), webhook.clone());
Ok(())
}
}
/// Fetch webhook by id
async fn fetch_webhook(&self, webhook_id: &str) -> Result<Webhook> {
let webhooks = self.channel_webhooks.lock().await;
webhooks
.get(webhook_id)
.cloned()
.ok_or_else(|| create_error!(NotFound))
}
/// Fetch webhooks for channel
async fn fetch_webhooks_for_channel(&self, channel_id: &str) -> Result<Vec<Webhook>> {
let webhooks = self.channel_webhooks.lock().await;
Ok(webhooks
.values()
.filter(|webhook| webhook.channel_id == channel_id)
.cloned()
.collect())
}
/// Update webhook with new information
async fn update_webhook(
&self,
webhook_id: &str,
partial: &PartialWebhook,
remove: &[FieldsWebhook],
) -> Result<()> {
let mut webhooks = self.channel_webhooks.lock().await;
if let Some(webhook) = webhooks.get_mut(webhook_id) {
for field in remove {
#[allow(clippy::disallowed_methods)]
webhook.remove_field(field);
}
webhook.apply_options(partial.clone());
Ok(())
} else {
Err(create_error!(NotFound))
}
}
/// Delete webhook by id
async fn delete_webhook(&self, webhook_id: &str) -> Result<()> {
let mut webhooks = self.channel_webhooks.lock().await;
if webhooks.remove(webhook_id).is_some() {
Ok(())
} else {
Err(create_error!(NotFound))
}
}
}

View File

@@ -1,5 +1,6 @@
mod admin_migrations;
mod bots;
mod channel_webhooks;
mod files;
mod safety_strikes;
mod server_members;
@@ -9,6 +10,7 @@ mod users;
pub use admin_migrations::*;
pub use bots::*;
pub use channel_webhooks::*;
pub use files::*;
pub use safety_strikes::*;
pub use server_members::*;
@@ -29,6 +31,7 @@ pub trait AbstractDatabase:
+ servers::AbstractServers
+ user_settings::AbstractUserSettings
+ users::AbstractUsers
+ channel_webhooks::AbstractWebhooks
{
}

View File

@@ -0,0 +1 @@
pub mod v0;

View File

@@ -0,0 +1,217 @@
use revolt_models::v0::*;
impl From<crate::AccountStrike> for AccountStrike {
fn from(value: crate::AccountStrike) -> Self {
AccountStrike {
id: value.id,
user_id: value.user_id,
reason: value.reason,
}
}
}
impl crate::Bot {
pub fn into_public_bot(self, user: crate::User) -> PublicBot {
#[cfg(debug_assertions)]
assert_eq!(self.id, user.id);
PublicBot {
id: self.id,
username: user.username,
avatar: user.avatar.map(|x| x.id).unwrap_or_default(),
description: user
.profile
.map(|profile| profile.content)
.unwrap_or_default(),
}
}
}
impl From<crate::Bot> for Bot {
fn from(value: crate::Bot) -> Self {
Bot {
id: value.id,
owner_id: value.owner,
token: value.token,
public: value.public,
analytics: value.analytics,
discoverable: value.discoverable,
interactions_url: value.interactions_url,
terms_of_service_url: value.terms_of_service_url,
privacy_policy_url: value.privacy_policy_url,
flags: value.flags.unwrap_or_default() as u32,
}
}
}
impl From<crate::Webhook> for Webhook {
fn from(value: crate::Webhook) -> Self {
Webhook {
id: value.id,
name: value.name,
avatar: value.avatar.map(|file| file.into()),
channel_id: value.channel_id,
token: value.token,
}
}
}
impl From<crate::PartialWebhook> for PartialWebhook {
fn from(value: crate::PartialWebhook) -> Self {
PartialWebhook {
id: value.id,
name: value.name,
avatar: value.avatar.map(|file| file.into()),
channel_id: value.channel_id,
token: value.token,
}
}
}
impl From<FieldsWebhook> for crate::FieldsWebhook {
fn from(_value: FieldsWebhook) -> Self {
Self::Avatar
}
}
impl From<crate::FieldsWebhook> for FieldsWebhook {
fn from(_value: crate::FieldsWebhook) -> Self {
Self::Avatar
}
}
impl From<crate::File> for File {
fn from(value: crate::File) -> Self {
File {
id: value.id,
tag: value.tag,
filename: value.filename,
metadata: value.metadata.into(),
content_type: value.content_type,
size: value.size,
deleted: value.deleted,
reported: value.reported,
message_id: value.message_id,
user_id: value.user_id,
server_id: value.server_id,
object_id: value.object_id,
}
}
}
impl From<crate::Metadata> for Metadata {
fn from(value: crate::Metadata) -> Self {
match value {
crate::Metadata::File => Metadata::File,
crate::Metadata::Text => Metadata::Text,
crate::Metadata::Image { width, height } => Metadata::Image {
width: width as usize,
height: height as usize,
},
crate::Metadata::Video { width, height } => Metadata::Video {
width: width as usize,
height: height as usize,
},
crate::Metadata::Audio => Metadata::Audio,
}
}
}
impl crate::User {
pub async fn into<P>(self, perspective: P) -> User
where
P: Into<Option<crate::User>>,
{
let relationship = if let Some(perspective) = perspective.into() {
perspective
.relations
.unwrap_or_default()
.into_iter()
.find(|relationship| relationship.id == self.id)
.map(|relationship| relationship.status.into())
.unwrap_or_default()
} else {
RelationshipStatus::None
};
// do permission stuff here
// TODO: implement permissions =)
let can_see_profile = false;
User {
username: self.username,
avatar: self.avatar.map(|file| file.into()),
relations: vec![],
badges: self.badges.unwrap_or_default() as u32,
status: None,
profile: None,
flags: self.flags.unwrap_or_default() as u32,
privileged: self.privileged,
bot: self.bot.map(|bot| bot.into()),
relationship,
online: can_see_profile && revolt_presence::is_online(&self.id).await,
id: self.id,
}
}
}
impl From<crate::RelationshipStatus> for RelationshipStatus {
fn from(value: crate::RelationshipStatus) -> Self {
match value {
crate::RelationshipStatus::None => RelationshipStatus::None,
crate::RelationshipStatus::User => RelationshipStatus::User,
crate::RelationshipStatus::Friend => RelationshipStatus::Friend,
crate::RelationshipStatus::Outgoing => RelationshipStatus::Outgoing,
crate::RelationshipStatus::Incoming => RelationshipStatus::Incoming,
crate::RelationshipStatus::Blocked => RelationshipStatus::Blocked,
crate::RelationshipStatus::BlockedOther => RelationshipStatus::BlockedOther,
}
}
}
impl From<crate::Relationship> for Relationship {
fn from(value: crate::Relationship) -> Self {
Self {
user_id: value.id,
status: value.status.into(),
}
}
}
impl From<crate::Presence> for Presence {
fn from(value: crate::Presence) -> Self {
match value {
crate::Presence::Online => Presence::Online,
crate::Presence::Idle => Presence::Idle,
crate::Presence::Focus => Presence::Focus,
crate::Presence::Busy => Presence::Busy,
crate::Presence::Invisible => Presence::Invisible,
}
}
}
impl From<crate::UserStatus> for UserStatus {
fn from(value: crate::UserStatus) -> Self {
UserStatus {
text: value.text,
presence: value.presence.map(|presence| presence.into()),
}
}
}
impl From<crate::UserProfile> for UserProfile {
fn from(value: crate::UserProfile) -> Self {
UserProfile {
content: value.content,
background: value.background.map(|file| file.into()),
}
}
}
impl From<crate::BotInformation> for BotInformation {
fn from(value: crate::BotInformation) -> Self {
BotInformation {
owner_id: value.owner,
}
}
}

View File

@@ -1,2 +1,3 @@
pub mod bridge;
pub mod permissions;
pub mod reference;