inital webhook support

This commit is contained in:
Zomatree
2022-11-26 23:07:22 +00:00
parent ee1c4cc2d5
commit 5cb2320760
31 changed files with 653 additions and 38 deletions

View File

@@ -11,7 +11,7 @@ pub async fn req(db: &Db, user: User, target: Ref, msg: Ref) -> Result<EmptyResp
return Err(Error::NotFound);
}
if message.author != user.id {
if message.author.as_deref().unwrap_or_default() != user.id {
perms(&user)
.channel(&target.as_channel(db).await?)
.throw_permission(db, Permission::ManageMessages)

View File

@@ -43,7 +43,7 @@ pub async fn req(
return Err(Error::NotFound);
}
if message.author != user.id {
if message.author.as_deref().unwrap_or_default() != user.id {
return Err(Error::CannotEditMessage);
}

View File

@@ -7,7 +7,7 @@ use revolt_quark::{
},
perms,
web::idempotency::IdempotencyKey,
Db, Error, Permission, Ref, Result,
Db, Error, Permission, Ref, Result, types::push::MessageAuthor,
};
use regex::Regex;
@@ -22,31 +22,31 @@ pub struct DataMessageSend {
///
/// **This is deprecated and replaced by `Idempotency-Key`!**
#[validate(length(min = 1, max = 64))]
nonce: Option<String>,
pub nonce: Option<String>,
/// Message content to send
#[validate(length(min = 0, max = 2000))]
content: Option<String>,
pub content: Option<String>,
/// Attachments to include in message
#[validate(length(min = 1, max = 128))]
attachments: Option<Vec<String>>,
pub attachments: Option<Vec<String>>,
/// Messages to reply to
replies: Option<Vec<Reply>>,
pub replies: Option<Vec<Reply>>,
/// Embeds to include in message
///
/// Text embed content contributes to the content length cap
#[validate(length(min = 1, max = 10))]
embeds: Option<Vec<SendableEmbed>>,
pub embeds: Option<Vec<SendableEmbed>>,
/// Masquerade to apply to this message
#[validate]
masquerade: Option<Masquerade>,
pub masquerade: Option<Masquerade>,
/// Information about how this message should be interacted with
interactions: Option<Interactions>,
pub interactions: Option<Interactions>,
}
lazy_static! {
// ignoring I L O and U is intentional
static ref RE_MENTION: Regex = Regex::new(r"<@([0-9A-HJKMNP-TV-Z]{26})>").unwrap();
pub static ref RE_MENTION: Regex = Regex::new(r"<@([0-9A-HJKMNP-TV-Z]{26})>").unwrap();
}
/// # Send Message
@@ -86,7 +86,7 @@ pub async fn message_send(
let mut message = Message {
id: message_id.clone(),
channel: channel.id().to_string(),
author: user.id.clone(),
author: Some(user.id.clone()),
masquerade: data.masquerade,
interactions: data.interactions.unwrap_or_default(),
..Default::default()
@@ -128,11 +128,11 @@ pub async fn message_send(
for Reply { id, mention } in entries {
let message = Ref::from_unchecked(id).as_message(db).await?;
replies.insert(message.id);
if mention {
mentions.insert(message.author);
mentions.insert(message.author_id().to_owned());
}
replies.insert(message.id);
}
}
@@ -195,7 +195,7 @@ pub async fn message_send(
// 8. Pass-through nonce value for clients
message.nonce = Some(idempotency.into_key());
message.create(db, &channel, Some(&user)).await?;
message.create(db, &channel, Some(MessageAuthor::User(&user))).await?;
// Queue up a task for processing embeds
if let Some(content) = &message.content {

View File

@@ -19,11 +19,13 @@ mod message_query;
mod message_query_stale;
mod message_react;
mod message_search;
mod message_send;
pub mod message_send;
mod message_unreact;
mod permissions_set;
mod permissions_set_default;
mod voice_join;
mod webhook_create;
mod webhook_fetch_all;
pub fn routes() -> (Vec<Route>, OpenApi) {
openapi_get_routes_spec![
@@ -49,6 +51,8 @@ pub fn routes() -> (Vec<Route>, OpenApi) {
permissions_set_default::req,
message_react::react_message,
message_unreact::unreact_message,
message_clear_reactions::clear_reactions
message_clear_reactions::clear_reactions,
webhook_create::req,
webhook_fetch_all::req,
]
}

View File

@@ -0,0 +1,50 @@
use revolt_quark::{models::{User, Webhook, File}, perms, Db, Error, Permission, Ref, Result};
use rocket::serde::json::Json;
use serde::{Deserialize, Serialize};
use ulid::Ulid;
use validator::Validate;
#[derive(Validate, Serialize, Deserialize, JsonSchema)]
pub struct CreateWebhookBody {
#[validate(length(min = 1, max = 32))]
name: String,
#[validate(length(min = 1, max = 128))]
avatar: Option<String>
}
/// # Creates a webhook
///
/// creates a webhook which 3rd party platforms can use to send messages
#[openapi(tag = "Webhooks")]
#[post("/<target>/webhooks", data = "<data>")]
pub async fn req(db: &Db, user: User, target: Ref, data: Json<CreateWebhookBody>) -> Result<Json<Webhook>> {
let data = data.into_inner();
data.validate()
.map_err(|error| Error::FailedValidation { error })?;
let channel = target.as_channel(db).await?;
let mut permissions = perms(&user).channel(&channel);
permissions
.has_permission(db, Permission::ManageWebhooks)
.await?;
let webhook_id = Ulid::new().to_string();
let avatar = match &data.avatar {
Some(id) => Some(File::use_avatar(db, id, &webhook_id).await?),
None => None
};
let webhook = Webhook {
id: webhook_id,
name: data.name,
avatar,
channel: channel.id().to_string(),
token: nanoid::nanoid!(64)
};
webhook.create(db).await?;
Ok(Json(webhook))
}

View File

@@ -0,0 +1,19 @@
use revolt_quark::{models::{User, Webhook}, perms, Db, Permission, Ref, Result};
use rocket::serde::json::Json;
/// # Gets all webhooks
///
/// gets all webhooks inside the channel
#[openapi(tag = "Webhooks")]
#[get("/<target>/webhooks")]
pub async fn req(db: &Db, user: User, target: Ref) -> Result<Json<Vec<Webhook>>> {
let channel = target.as_channel(db).await?;
let mut permissions = perms(&user).channel(&channel);
permissions
.has_permission(db, Permission::ManageWebhooks)
.await?;
let webhooks = db.fetch_webhooks_for_channel(channel.id()).await?;
Ok(Json(webhooks))
}

View File

@@ -13,6 +13,7 @@ mod root;
mod servers;
mod sync;
mod users;
mod webhooks;
pub fn mount(mut rocket: Rocket<Build>) -> Rocket<Build> {
let settings = OpenApiSettings::default();
@@ -33,6 +34,7 @@ pub fn mount(mut rocket: Rocket<Build>) -> Rocket<Build> {
"/onboard" => onboard::routes(),
"/push" => push::routes(),
"/sync" => sync::routes(),
"/webhooks" => webhooks::routes()
};
rocket
@@ -82,7 +84,8 @@ fn custom_openapi_spec() -> OpenApi {
"Messaging",
"Interactions",
"Groups",
"Voice"
"Voice",
"Webhooks",
]
},
{
@@ -277,6 +280,13 @@ fn custom_openapi_spec() -> OpenApi {
),
..Default::default()
},
Tag {
name: "Webhooks".to_owned(),
description: Some(
"Send messages from 3rd party services".to_owned(),
),
..Default::default()
}
],
..Default::default()
}

View File

@@ -0,0 +1,16 @@
use rocket::Route;
use rocket_okapi::okapi::openapi3::OpenApi;
mod webhook_delete;
mod webhook_edit;
mod webhook_execute;
mod webhook_fetch;
pub fn routes() -> (Vec<Route>, OpenApi) {
openapi_get_routes_spec![
webhook_delete::req,
webhook_edit::req,
webhook_execute::req,
webhook_fetch::req,
]
}

View File

@@ -0,0 +1,18 @@
use revolt_quark::{Db, Ref, Result, EmptyResponse, Error};
/// # Deletes a webhook
///
/// deletes a webhook
#[openapi(tag = "Webhooks")]
#[delete("/<target>/<token>")]
pub async fn req(db: &Db, target: Ref, token: String) -> Result<EmptyResponse> {
let webhook = target.as_webhook(db).await?;
(webhook.token == token)
.then_some(())
.ok_or(Error::InvalidCredentials)?;
webhook.delete(db).await?;
Ok(EmptyResponse)
}

View File

@@ -0,0 +1,57 @@
use revolt_quark::{Db, Ref, Result, Error, models::{webhook::{FieldsWebhook, Webhook, PartialWebhook}, File}};
use serde::{Serialize, Deserialize};
use validator::Validate;
use rocket::serde::json::Json;
#[derive(Serialize, Deserialize, Validate, JsonSchema)]
pub struct WebhookEditBody {
#[validate(length(min = 1, max = 32))]
name: Option<String>,
#[validate(length(min = 1, max = 128))]
avatar: Option<String>,
#[serde(default)]
remove: Vec<FieldsWebhook>
}
/// # Edits a webhook
///
/// edits a webhook
#[openapi(tag = "Webhooks")]
#[patch("/<target>/<token>", data="<data>")]
pub async fn req(db: &Db, target: Ref, token: String, data: Json<WebhookEditBody>) -> Result<Json<Webhook>> {
let data = data.into_inner();
data.validate()
.map_err(|error| Error::FailedValidation { error })?;
let mut webhook = target.as_webhook(db).await?;
(webhook.token == token)
.then_some(())
.ok_or(Error::InvalidCredentials)?;
if data.name.is_none()
&& data.avatar.is_none()
&& data.remove.is_empty()
{
return Ok(Json(webhook))
};
let mut partial = PartialWebhook::default();
let WebhookEditBody { name, avatar, remove } = data;
if let Some(name) = name {
partial.name = Some(name)
}
if let Some(avatar) = avatar {
let file = File::use_avatar(db, &avatar, &webhook.id).await?;
partial.avatar = Some(file)
}
webhook.update(db, partial, remove).await?;
Ok(Json(webhook))
}

View File

@@ -0,0 +1,138 @@
use std::collections::HashSet;
use revolt_quark::{Db, Ref, Result, Error, models::{Message, message::Reply}, web::idempotency::IdempotencyKey, types::push::MessageAuthor};
use rocket::serde::json::Json;
use ulid::Ulid;
use crate::routes::channels::message_send::{DataMessageSend, RE_MENTION};
use validator::Validate;
/// # Executes a webhook
///
/// executes a webhook and sends a message
#[openapi(tag = "Webhooks")]
#[post("/<target>/<token>", data="<data>")]
pub async fn req(db: &Db, target: Ref, token: String, data: Json<DataMessageSend>, mut idempotency: IdempotencyKey) -> Result<Json<Message>> {
let data = data.into_inner();
data.validate()
.map_err(|error| Error::FailedValidation { error })?;
let webhook = target.as_webhook(db).await?;
(webhook.token == token)
.then_some(())
.ok_or(Error::InvalidCredentials)?;
Message::validate_sum(&data.content, &data.embeds)?;
idempotency.consume_nonce(data.nonce).await?;
let channel = Ref::from_unchecked(webhook.channel.clone()).as_channel(db).await?;
if (data.content.as_ref().map_or(true, |v| v.is_empty()))
&& (data.attachments.as_ref().map_or(true, |v| v.is_empty()))
&& (data.embeds.as_ref().map_or(true, |v| v.is_empty()))
{
return Err(Error::EmptyMessage);
}
let message_id = Ulid::new().to_string();
let mut message = Message {
id: message_id.clone(),
channel: channel.id().to_string(),
webhook: Some(webhook.id.clone()),
masquerade: data.masquerade,
interactions: data.interactions.unwrap_or_default(),
..Default::default()
};
// 1. Parse mentions in message.
let mut mentions = HashSet::new();
if let Some(content) = &data.content {
for capture in RE_MENTION.captures_iter(content) {
if let Some(mention) = capture.get(1) {
mentions.insert(mention.as_str().to_string());
}
}
}
// 4. Verify replies are valid.
let mut replies = HashSet::new();
if let Some(entries) = data.replies {
if entries.len() > 5 {
return Err(Error::TooManyReplies);
}
for Reply { id, mention } in entries {
let message = Ref::from_unchecked(id).as_message(db).await?;
if mention {
mentions.insert(message.author_id().to_owned());
}
replies.insert(message.id);
}
}
if !mentions.is_empty() {
message.mentions.replace(mentions.into_iter().collect());
}
if !replies.is_empty() {
message
.replies
.replace(replies.into_iter().collect::<Vec<String>>());
}
// 5. Process included embeds.
let mut embeds = vec![];
if let Some(sendable_embeds) = data.embeds {
for sendable_embed in sendable_embeds {
embeds.push(sendable_embed.into_embed(db, message_id.clone()).await?)
}
}
if !embeds.is_empty() {
message.embeds.replace(embeds);
}
// 6. Add attachments to message.
let mut attachments = vec![];
if let Some(ids) = &data.attachments {
// ! FIXME: move this to app config
if ids.len() > 5 {
return Err(Error::TooManyAttachments);
}
for attachment_id in ids {
attachments.push(
db.find_and_use_attachment(attachment_id, "attachments", "message", &message_id)
.await?,
);
}
}
if !attachments.is_empty() {
message.attachments.replace(attachments);
}
// 7. Set content
message.content = data.content;
// 8. Pass-through nonce value for clients
message.nonce = Some(idempotency.into_key());
message.create(db, &channel, Some(MessageAuthor::Webhook(&webhook))).await?;
// Queue up a task for processing embeds
if let Some(content) = &message.content {
revolt_quark::tasks::process_embeds::queue(
channel.id().to_string(),
message.id.to_string(),
content.clone(),
)
.await;
}
Ok(Json(message))
}

View File

@@ -0,0 +1,17 @@
use revolt_quark::{Db, Ref, Result, Error, models::Webhook};
use rocket::serde::json::Json;
/// # gets a webhook
///
/// gets a webhook
#[openapi(tag = "Webhooks")]
#[get("/<target>/<token>")]
pub async fn req(db: &Db, target: Ref, token: String) -> Result<Json<Webhook>> {
let webhook = target.as_webhook(db).await?;
(webhook.token == token)
.then_some(())
.ok_or(Error::InvalidCredentials)?;
Ok(Json(webhook))
}