Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
173a3effda | ||
|
|
34f05f4b2f | ||
|
|
d76a71141f | ||
|
|
6f3441cf4a | ||
|
|
23ad135983 | ||
|
|
d46c7f7f3c | ||
|
|
0719985ac5 | ||
|
|
ab5bd47a39 | ||
|
|
9fd7128f80 | ||
|
|
df276ac40b |
Generated
+679
-15
File diff suppressed because it is too large
Load Diff
@@ -134,6 +134,7 @@ kamadak-exif = "0.5.4"
|
||||
webp = "0.3.0"
|
||||
image = "0.25.2" # avif encode requires dav1d system library: features = ["avif-native"]
|
||||
thumbhash = "0.1.0"
|
||||
lcms2 = "6.1.1" # for color profile processing
|
||||
|
||||
# File processing
|
||||
revolt_clamav-client = "0.1.5"
|
||||
@@ -159,6 +160,7 @@ authifier = "1.0.16"
|
||||
|
||||
# RabbitMQ
|
||||
amqprs = "1.7.0"
|
||||
lapin = "4.7.1"
|
||||
|
||||
# Voice
|
||||
livekit-api = "0.4.4"
|
||||
@@ -184,6 +186,7 @@ url = "2.2.2"
|
||||
impl_ops = "0.1.1"
|
||||
lazy_static = "1.5.0"
|
||||
mime = "0.3.17"
|
||||
futures-lite = "2.6.1"
|
||||
|
||||
# Build Dependencies
|
||||
vergen = "7.5.0"
|
||||
|
||||
+1
-1
@@ -61,7 +61,7 @@ secret = "ZjCofRlfm6GGtjlifmNpCDkcQbEIIVC0"
|
||||
# S3 protocol endpoint
|
||||
endpoint = "http://127.0.0.1:14009"
|
||||
# S3 region name
|
||||
region = "minio"
|
||||
region = "us-east-1"
|
||||
# S3 protocol key ID
|
||||
access_key_id = "minioautumn"
|
||||
# S3 protocol access key
|
||||
|
||||
+12
-2
@@ -8,10 +8,20 @@ services:
|
||||
# MongoDB
|
||||
database:
|
||||
image: mongo
|
||||
command: mongod --replSet rs0
|
||||
ports:
|
||||
- "27017:27017"
|
||||
volumes:
|
||||
- ./.data/db:/data/db
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
healthcheck:
|
||||
test: echo "try { rs.status() } catch (err) { rs.initiate({_id:'rs0',members:[{_id:0,host:'127.0.0.1:27017'}]}) }" | mongosh --port 27017 --quiet
|
||||
interval: 5s
|
||||
timeout: 30s
|
||||
start_period: 0s
|
||||
start_interval: 1s
|
||||
retries: 30
|
||||
ulimits:
|
||||
nofile:
|
||||
soft: 65536
|
||||
@@ -19,8 +29,8 @@ services:
|
||||
|
||||
# MinIO
|
||||
minio:
|
||||
image: minio/minio
|
||||
command: server /data
|
||||
image: firstfinger/minio:latest
|
||||
#command: server /data
|
||||
environment:
|
||||
MINIO_ROOT_USER: minioautumn
|
||||
MINIO_ROOT_PASSWORD: minioautumn
|
||||
|
||||
@@ -30,6 +30,10 @@ host = "rabbit"
|
||||
port = 5672
|
||||
username = "rabbituser"
|
||||
password = "rabbitpass"
|
||||
default_exchange = "revolt"
|
||||
|
||||
[rabbit.queues]
|
||||
acks = "internal.ack"
|
||||
|
||||
[api]
|
||||
|
||||
|
||||
@@ -122,12 +122,19 @@ pub struct Database {
|
||||
pub redis_pubsub: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
pub struct RabbitQueues {
|
||||
pub acks: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
pub struct Rabbit {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub default_exchange: String,
|
||||
pub queues: RabbitQueues,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
|
||||
@@ -38,6 +38,7 @@ revolt-models = { workspace = true, features = ["validator"] }
|
||||
revolt-presence = { workspace = true }
|
||||
revolt-permissions = { workspace = true, features = ["serde", "bson"] }
|
||||
revolt-parser = { workspace = true }
|
||||
revolt-coalesced = { workspace = true }
|
||||
|
||||
# Utility
|
||||
log = { workspace = true }
|
||||
|
||||
@@ -2,7 +2,10 @@ use std::collections::HashSet;
|
||||
|
||||
use crate::events::rabbit::*;
|
||||
use crate::User;
|
||||
use amqprs::channel::{BasicPublishArguments, ExchangeDeclareArguments};
|
||||
use amqprs::channel::{
|
||||
BasicPublishArguments, ExchangeDeclareArguments, ExchangeType, QueueBindArguments,
|
||||
QueueDeclareArguments,
|
||||
};
|
||||
use amqprs::connection::OpenConnectionArguments;
|
||||
use amqprs::{channel::Channel, connection::Connection, error::Error as AMQPError};
|
||||
use amqprs::{BasicProperties, FieldTable};
|
||||
@@ -55,6 +58,43 @@ impl AMQP {
|
||||
AMQP::new(connection, channel)
|
||||
}
|
||||
|
||||
pub async fn configure_channels(&self) -> revolt_result::Result<()> {
|
||||
let config = revolt_config::config().await;
|
||||
|
||||
self.channel
|
||||
.exchange_declare(
|
||||
ExchangeDeclareArguments::new(
|
||||
&config.rabbit.default_exchange,
|
||||
&ExchangeType::Topic.to_string(),
|
||||
)
|
||||
.durable(true)
|
||||
.finish(),
|
||||
)
|
||||
.await
|
||||
.expect("Failed to declare exchange");
|
||||
|
||||
// Configure acks channel & routing
|
||||
self.channel
|
||||
.queue_declare(
|
||||
QueueDeclareArguments::new(&config.rabbit.queues.acks)
|
||||
.durable(true)
|
||||
.no_wait(true)
|
||||
.finish(),
|
||||
)
|
||||
.await
|
||||
.expect("Failed to bind queue");
|
||||
|
||||
self.channel
|
||||
.queue_bind(QueueBindArguments::new(
|
||||
&config.rabbit.queues.acks,
|
||||
&config.rabbit.default_exchange,
|
||||
&config.rabbit.queues.acks,
|
||||
))
|
||||
.await
|
||||
.expect("Failed to bind channel");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn friend_request_accepted(
|
||||
&self,
|
||||
accepted_request_user: &User,
|
||||
@@ -232,7 +272,9 @@ impl AMQP {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn ack_message(
|
||||
/// # Sends an ack to pushd to update badges on iPhones.
|
||||
/// Not to be confused with the process_ack function, which handles sending all acks to crond for processing.
|
||||
pub async fn ack_notification_message(
|
||||
&self,
|
||||
user_id: String,
|
||||
channel_id: String,
|
||||
@@ -316,4 +358,41 @@ impl AMQP {
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// # Send an ack to crond for processing
|
||||
pub async fn process_ack(
|
||||
&self,
|
||||
user_id: &str,
|
||||
channel_id: Option<&str>,
|
||||
server_id: Option<&str>,
|
||||
) -> Result<(), AMQPError> {
|
||||
let config = revolt_config::config().await;
|
||||
|
||||
let payload = AckEventPayload {
|
||||
user_id: user_id.to_string(),
|
||||
channel_id: channel_id.map(|value| value.to_string()),
|
||||
server_id: server_id.map(|value| value.to_string()),
|
||||
};
|
||||
let payload = to_string(&payload).unwrap();
|
||||
|
||||
info!(
|
||||
"Sending ack processor event on exchange {}, channel {}: {}",
|
||||
config.rabbit.default_exchange, config.rabbit.queues.acks, payload
|
||||
);
|
||||
|
||||
self.channel
|
||||
.basic_publish(
|
||||
BasicProperties::default()
|
||||
.with_content_type("application/json")
|
||||
.with_persistence(true)
|
||||
//.with_headers(headers)
|
||||
.finish(),
|
||||
payload.into(),
|
||||
BasicPublishArguments::new(
|
||||
&config.rabbit.default_exchange,
|
||||
&config.rabbit.queues.acks,
|
||||
),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ use revolt_result::Error;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use revolt_models::v0::{
|
||||
AppendMessage, Channel, ChannelUnread, ChannelVoiceState, Emoji, FieldsChannel, FieldsMember, FieldsMessage, FieldsRole, FieldsServer, FieldsUser, FieldsWebhook, Member, MemberCompositeKey, Message, PartialChannel, PartialMember, PartialMessage, PartialRole, PartialServer, PartialUser, PartialUserVoiceState, PartialWebhook, PolicyChange, RemovalIntention, Report, Server, User, UserSettings, UserVoiceState, Webhook
|
||||
AppendMessage, Channel, ChannelUnread, ChannelVoiceState, Emoji, FieldsChannel, FieldsMember, FieldsMessage, FieldsRole, FieldsServer, FieldsUser, FieldsWebhook, Member, MemberCompositeKey, Message, PartialChannel, PartialEmoji, PartialMember, PartialMessage, PartialRole, PartialServer, PartialUser, PartialUserVoiceState, PartialWebhook, PolicyChange, RemovalIntention, Report, Server, User, UserSettings, UserVoiceState, Webhook
|
||||
};
|
||||
|
||||
use crate::Database;
|
||||
@@ -219,6 +219,12 @@ pub enum EventV1 {
|
||||
/// New emoji
|
||||
EmojiCreate(Emoji),
|
||||
|
||||
/// Update existing emoji
|
||||
EmojiUpdate {
|
||||
id: String,
|
||||
data: PartialEmoji,
|
||||
},
|
||||
|
||||
/// Delete emoji
|
||||
EmojiDelete { id: String },
|
||||
|
||||
|
||||
@@ -78,3 +78,11 @@ pub struct AckPayload {
|
||||
pub channel_id: String,
|
||||
pub message_id: String,
|
||||
}
|
||||
|
||||
/// This is not the same as the AckPayload above, as the state for this event is stored in redis to allow for state updates while the event is queued.
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct AckEventPayload {
|
||||
pub user_id: String,
|
||||
pub channel_id: Option<String>,
|
||||
pub server_id: Option<String>,
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ macro_rules! database_test {
|
||||
db.drop_database().await;
|
||||
|
||||
#[allow(clippy::redundant_closure_call)]
|
||||
(|$db: $crate::Database| $test)(db.clone()).await;
|
||||
std::boxed::Box::pin((|$db: $crate::Database| $test)(db.clone())).await;
|
||||
|
||||
db.drop_database().await
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#![allow(deprecated)]
|
||||
use std::{borrow::Cow, collections::HashMap};
|
||||
|
||||
use redis_kiss::get_connection;
|
||||
use revolt_config::config;
|
||||
use revolt_models::v0::{self, MessageAuthor};
|
||||
use revolt_permissions::OverrideField;
|
||||
@@ -212,7 +213,7 @@ impl Channel {
|
||||
role_permissions: HashMap::new(),
|
||||
nsfw: data.nsfw.unwrap_or(false),
|
||||
voice: data.voice.map(|voice| voice.into()),
|
||||
slowmode: None
|
||||
slowmode: None,
|
||||
},
|
||||
v0::LegacyServerChannelType::Voice => Channel::TextChannel {
|
||||
id: id.clone(),
|
||||
@@ -225,7 +226,7 @@ impl Channel {
|
||||
role_permissions: HashMap::new(),
|
||||
nsfw: data.nsfw.unwrap_or(false),
|
||||
voice: Some(data.voice.unwrap_or_default().into()),
|
||||
slowmode: None
|
||||
slowmode: None,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -643,7 +644,7 @@ impl Channel {
|
||||
}
|
||||
|
||||
/// Acknowledge a message
|
||||
pub async fn ack(&self, user: &str, message: &str) -> Result<()> {
|
||||
pub async fn ack(&self, user: &str, message: &str, amqp: &AMQP) -> Result<()> {
|
||||
EventV1::ChannelAck {
|
||||
id: self.id().to_string(),
|
||||
user: user.to_string(),
|
||||
@@ -652,17 +653,7 @@ impl Channel {
|
||||
.private(user.to_string())
|
||||
.await;
|
||||
|
||||
#[cfg(feature = "tasks")]
|
||||
crate::tasks::ack::queue_ack(
|
||||
self.id().to_string(),
|
||||
user.to_string(),
|
||||
crate::tasks::ack::AckEvent::AckMessage {
|
||||
id: message.to_string(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
crate::util::acker::ack_channel(user, self.id(), message, amqp).await
|
||||
}
|
||||
|
||||
/// Remove user from a group
|
||||
|
||||
@@ -2,6 +2,7 @@ use std::collections::HashSet;
|
||||
use std::str::FromStr;
|
||||
|
||||
use once_cell::sync::Lazy;
|
||||
use revolt_models::v0;
|
||||
use revolt_result::Result;
|
||||
use ulid::Ulid;
|
||||
|
||||
@@ -41,6 +42,12 @@ auto_derived!(
|
||||
Server { id: String },
|
||||
Detached,
|
||||
}
|
||||
|
||||
/// Partial representation of an emoji
|
||||
pub struct PartialEmoji {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
}
|
||||
);
|
||||
|
||||
#[allow(clippy::disallowed_methods)]
|
||||
@@ -75,6 +82,26 @@ impl Emoji {
|
||||
db.detach_emoji(&self).await
|
||||
}
|
||||
|
||||
/// Update an emoji
|
||||
pub async fn update(&mut self, db: &Database, partial: PartialEmoji) -> Result<()> {
|
||||
if let Some(name) = partial.name.clone() {
|
||||
self.name = name;
|
||||
}
|
||||
|
||||
db.update_emoji(&self.id, &partial).await?;
|
||||
|
||||
EventV1::EmojiUpdate {
|
||||
id: self.id.clone(),
|
||||
data: v0::PartialEmoji {
|
||||
name: partial.name.clone(),
|
||||
},
|
||||
}
|
||||
.p(self.parent().to_string())
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use revolt_result::Result;
|
||||
|
||||
use crate::Emoji;
|
||||
use crate::{Emoji, PartialEmoji};
|
||||
|
||||
#[cfg(feature = "mongodb")]
|
||||
mod mongodb;
|
||||
@@ -20,6 +20,9 @@ pub trait AbstractEmojis: Sync + Send {
|
||||
/// Fetch emoji by their parent ids
|
||||
async fn fetch_emoji_by_parent_ids(&self, parent_ids: &[String]) -> Result<Vec<Emoji>>;
|
||||
|
||||
/// Update emoji with new information
|
||||
async fn update_emoji(&self, emoji_id: &str, partial: &PartialEmoji) -> Result<()>;
|
||||
|
||||
/// Detach an emoji by its id
|
||||
async fn detach_emoji(&self, emoji: &Emoji) -> Result<()>;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use bson::Document;
|
||||
use revolt_result::Result;
|
||||
|
||||
use crate::Emoji;
|
||||
use crate::{Emoji, PartialEmoji};
|
||||
use crate::MongoDb;
|
||||
|
||||
use super::AbstractEmojis;
|
||||
@@ -46,6 +46,11 @@ impl AbstractEmojis for MongoDb {
|
||||
)
|
||||
}
|
||||
|
||||
/// Update emoji with new information
|
||||
async fn update_emoji(&self, emoji_id: &str, partial: &PartialEmoji) -> Result<()> {
|
||||
query!(self, update_one_by_id, COL, emoji_id, partial, vec![], None).map(|_| ())
|
||||
}
|
||||
|
||||
/// Detach an emoji by its id
|
||||
async fn detach_emoji(&self, emoji: &Emoji) -> Result<()> {
|
||||
self.col::<Document>(COL)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use revolt_result::Result;
|
||||
|
||||
use crate::Emoji;
|
||||
use crate::{Emoji, PartialEmoji};
|
||||
use crate::EmojiParent;
|
||||
use crate::ReferenceDb;
|
||||
|
||||
@@ -54,6 +54,19 @@ impl AbstractEmojis for ReferenceDb {
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Update emoji with new information
|
||||
async fn update_emoji(&self, emoji_id: &str, partial: &PartialEmoji) -> Result<()> {
|
||||
let mut emojis = self.emojis.lock().await;
|
||||
if let Some(emoji) = emojis.get_mut(emoji_id) {
|
||||
if let Some(name) = partial.name.clone() {
|
||||
emoji.name = name;
|
||||
}
|
||||
Ok(())
|
||||
} else {
|
||||
Err(create_error!(NotFound))
|
||||
}
|
||||
}
|
||||
|
||||
/// Detach an emoji by its id
|
||||
async fn detach_emoji(&self, emoji: &Emoji) -> Result<()> {
|
||||
let mut emojis = self.emojis.lock().await;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -105,7 +105,11 @@ pub async fn handle_ack_event(
|
||||
|
||||
if mentions_acked > 0 {
|
||||
if let Err(err) = amqp
|
||||
.ack_message(user.to_string(), channel.to_string(), id.to_owned())
|
||||
.ack_notification_message(
|
||||
user.to_string(),
|
||||
channel.to_string(),
|
||||
id.to_owned(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
revolt_config::capture_error(&err);
|
||||
@@ -192,9 +196,7 @@ pub async fn handle_ack_event(
|
||||
.expect("Failed to fetch channel from db");
|
||||
|
||||
if let TextChannel { server, .. } = channel {
|
||||
if let Err(err) =
|
||||
amqp.mass_mention_message_sent(server, mass_mentions).await
|
||||
{
|
||||
if let Err(err) = amqp.mass_mention_message_sent(server, mass_mentions).await {
|
||||
revolt_config::capture_error(&err);
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
use redis_kiss::{get_connection, AsyncCommands};
|
||||
use revolt_permissions::{calculate_channel_permissions, ChannelPermission};
|
||||
use revolt_result::{Result, ToRevoltError};
|
||||
|
||||
use crate::{events::client::EventV1, Channel, Database, Server, User, AMQP};
|
||||
|
||||
pub async fn ack_channel(user: &str, channel: &str, message: &str, amqp: &AMQP) -> Result<()> {
|
||||
let mut redis = get_connection()
|
||||
.await
|
||||
.map_err(|_| create_error!(InternalError))?;
|
||||
|
||||
let old: Option<String> = redis
|
||||
.getset(format!("acker:{user}+{channel}"), message)
|
||||
.await
|
||||
.to_internal_error()?;
|
||||
|
||||
if old.is_none() || old.unwrap() == message {
|
||||
amqp.process_ack(user, Some(channel), None)
|
||||
.await
|
||||
.to_internal_error()?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn ack_server(user: &User, server: &Server, db: &Database, amqp: &AMQP) -> Result<()> {
|
||||
let mut redis = get_connection()
|
||||
.await
|
||||
.map_err(|_| create_error!(InternalError))?;
|
||||
|
||||
let channels = db.fetch_channels(&server.channels).await?;
|
||||
let query = crate::util::permissions::DatabasePermissionQuery::new(db, user).server(server);
|
||||
|
||||
for channel in channels {
|
||||
let channel_id = channel.id();
|
||||
let mut q = query.clone().channel(&channel);
|
||||
|
||||
if calculate_channel_permissions(&mut q)
|
||||
.await
|
||||
.has_channel_permission(ChannelPermission::ViewChannel)
|
||||
{
|
||||
let channel_last_msg = match &channel {
|
||||
Channel::TextChannel {
|
||||
last_message_id, ..
|
||||
} => last_message_id,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
.clone();
|
||||
|
||||
if let Some(channel_last_msg) = channel_last_msg {
|
||||
let old: Option<String> = redis
|
||||
.getset(
|
||||
format!("acker:{}+{}", user.id, channel_id),
|
||||
&channel_last_msg,
|
||||
)
|
||||
.await
|
||||
.to_internal_error()?;
|
||||
|
||||
if old.is_none() || old.unwrap() == channel_last_msg {
|
||||
amqp.process_ack(&user.id, Some(channel_id), Some(&server.id))
|
||||
.await
|
||||
.to_internal_error()?;
|
||||
|
||||
EventV1::ChannelAck {
|
||||
id: channel_id.to_string(),
|
||||
user: user.id.clone(),
|
||||
message_id: channel_last_msg,
|
||||
}
|
||||
.private(user.id.clone())
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod acker;
|
||||
pub mod bridge;
|
||||
pub mod bulk_permissions;
|
||||
mod funcs;
|
||||
|
||||
@@ -54,4 +54,22 @@ auto_derived!(
|
||||
#[serde(default)]
|
||||
pub nsfw: bool,
|
||||
}
|
||||
|
||||
/// Partial emoji representation
|
||||
#[derive(Default)]
|
||||
pub struct PartialEmoji {
|
||||
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
/// Edit emoji information
|
||||
#[cfg_attr(feature = "validator", derive(Validate))]
|
||||
pub struct DataEditEmoji {
|
||||
/// Emoji name
|
||||
#[cfg_attr(
|
||||
feature = "validator",
|
||||
validate(length(min = 1, max = 32), regex = "RE_EMOJI")
|
||||
)]
|
||||
pub name: Option<String>,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -24,6 +24,7 @@ impl IntoResponse for Error {
|
||||
ErrorType::UnknownChannel => StatusCode::NOT_FOUND,
|
||||
ErrorType::UnknownMessage => StatusCode::NOT_FOUND,
|
||||
ErrorType::UnknownAttachment => StatusCode::BAD_REQUEST,
|
||||
ErrorType::CannotDeleteMessage => StatusCode::FORBIDDEN,
|
||||
ErrorType::CannotEditMessage => StatusCode::FORBIDDEN,
|
||||
ErrorType::CannotJoinCall => StatusCode::BAD_REQUEST,
|
||||
ErrorType::TooManyAttachments { .. } => StatusCode::BAD_REQUEST,
|
||||
|
||||
@@ -78,6 +78,7 @@ pub enum ErrorType {
|
||||
UnknownChannel,
|
||||
UnknownAttachment,
|
||||
UnknownMessage,
|
||||
CannotDeleteMessage,
|
||||
CannotEditMessage,
|
||||
CannotJoinCall,
|
||||
TooManyAttachments {
|
||||
|
||||
@@ -30,6 +30,7 @@ impl<'r> Responder<'r, 'static> for Error {
|
||||
ErrorType::UnknownChannel => Status::NotFound,
|
||||
ErrorType::UnknownMessage => Status::NotFound,
|
||||
ErrorType::UnknownAttachment => Status::BadRequest,
|
||||
ErrorType::CannotDeleteMessage => Status::Forbidden,
|
||||
ErrorType::CannotEditMessage => Status::Forbidden,
|
||||
ErrorType::CannotJoinCall => Status::BadRequest,
|
||||
ErrorType::TooManyAttachments { .. } => Status::BadRequest,
|
||||
|
||||
@@ -16,8 +16,22 @@ log = { workspace = true }
|
||||
# Async
|
||||
tokio = { workspace = true }
|
||||
|
||||
# Redis
|
||||
redis-kiss = { workspace = true }
|
||||
|
||||
# RabbitMQ
|
||||
lapin = { workspace = true }
|
||||
futures-lite = { workspace = true }
|
||||
|
||||
# Processing
|
||||
serde_json = { workspace = true }
|
||||
revolt_optional_struct = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
iso8601-timestamp = { workspace = true, features = ["serde", "bson"] }
|
||||
|
||||
# Core
|
||||
revolt-database = { workspace = true }
|
||||
revolt-result = { workspace = true }
|
||||
revolt-config = { workspace = true }
|
||||
revolt-files = { workspace = true }
|
||||
revolt-permissions = { workspace = true }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use revolt_config::configure;
|
||||
use revolt_database::DatabaseInfo;
|
||||
use revolt_result::Result;
|
||||
use tasks::{file_deletion, prune_dangling_files, prune_members};
|
||||
use tasks::{acks, file_deletion, prune_dangling_files, prune_members};
|
||||
use tokio::try_join;
|
||||
|
||||
pub mod tasks;
|
||||
@@ -14,7 +14,8 @@ async fn main() -> Result<()> {
|
||||
try_join!(
|
||||
file_deletion::task(db.clone()),
|
||||
prune_dangling_files::task(db.clone()),
|
||||
prune_members::task(db.clone())
|
||||
prune_members::task(db.clone()),
|
||||
acks::task(db.clone())
|
||||
)
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
use futures_lite::stream::StreamExt;
|
||||
use lapin::{
|
||||
options::*,
|
||||
types::FieldTable,
|
||||
uri::{AMQPAuthority, AMQPQueryString, AMQPUri, AMQPUserInfo},
|
||||
ConnectionBuilder, ConnectionProperties,
|
||||
};
|
||||
use log::info;
|
||||
use redis_kiss::{get_connection, AsyncCommands, Conn as RedisConnection};
|
||||
use revolt_config::config;
|
||||
use revolt_database::{events::rabbit::AckEventPayload, Database};
|
||||
use revolt_result::{Result, ToRevoltError};
|
||||
use serde_json;
|
||||
|
||||
pub async fn task(db: Database) -> Result<()> {
|
||||
let config = config().await;
|
||||
|
||||
let mut redis = get_connection()
|
||||
.await
|
||||
.expect("Failed to get redis connection");
|
||||
|
||||
let uri = AMQPUri {
|
||||
scheme: lapin::uri::AMQPScheme::AMQP,
|
||||
authority: AMQPAuthority {
|
||||
userinfo: AMQPUserInfo {
|
||||
username: config.rabbit.username,
|
||||
password: config.rabbit.password,
|
||||
},
|
||||
host: config.rabbit.host,
|
||||
port: config.rabbit.port,
|
||||
},
|
||||
vhost: "/".to_string(),
|
||||
query: AMQPQueryString::default(),
|
||||
};
|
||||
|
||||
let connection = ConnectionBuilder::new()
|
||||
.expect("Builder")
|
||||
.with_uri(uri)
|
||||
.with_properties(ConnectionProperties::default())
|
||||
.connect()
|
||||
.await
|
||||
.expect("Failed to connect to rabbitmq");
|
||||
|
||||
let reader_channel = connection
|
||||
.create_channel()
|
||||
.await
|
||||
.expect("Failed to create channel");
|
||||
|
||||
let mut consumer = reader_channel
|
||||
.basic_consume(
|
||||
config.rabbit.queues.acks.into(),
|
||||
"crond-ack-consumer".into(),
|
||||
BasicConsumeOptions::default(),
|
||||
FieldTable::default(),
|
||||
)
|
||||
.await
|
||||
.expect("Failed to create consumer");
|
||||
|
||||
while let Some(delivery) = consumer.next().await {
|
||||
if let Ok(delivery) = delivery {
|
||||
let payload: std::result::Result<AckEventPayload, _> =
|
||||
serde_json::from_slice(&delivery.data);
|
||||
if let Ok(payload) = payload {
|
||||
info!("{:?}", payload);
|
||||
if let Err(e) = process_channel_ack(
|
||||
&db,
|
||||
payload.user_id,
|
||||
payload.channel_id.unwrap(),
|
||||
&mut redis,
|
||||
)
|
||||
.await
|
||||
{
|
||||
revolt_config::capture_error(&e);
|
||||
_ = delivery.reject(BasicRejectOptions { requeue: false }).await;
|
||||
} else {
|
||||
_ = delivery.ack(BasicAckOptions { multiple: false }).await;
|
||||
}
|
||||
} else {
|
||||
revolt_config::capture_message(
|
||||
format!("Failed to decode ack data: {:?}", delivery.data).as_str(),
|
||||
revolt_config::Level::Error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::disallowed_methods)]
|
||||
async fn process_channel_ack(
|
||||
db: &Database,
|
||||
user: String,
|
||||
channel: String,
|
||||
redis: &mut RedisConnection,
|
||||
) -> Result<()> {
|
||||
let message_id: Option<String> = redis
|
||||
.get_del(format!("acker:{user}+{channel}"))
|
||||
.await
|
||||
.to_internal_error()?;
|
||||
|
||||
if let Some(message_id) = message_id {
|
||||
// This will be uncommented eventually, but we need to sort out the transition to lapin first. For now we'll simply disable the badge update logic.
|
||||
// We also drop a db request as a bonus.
|
||||
|
||||
//let unread = db.fetch_unread(&user, &channel).await?;
|
||||
let _updated = db.acknowledge_message(&channel, &user, &message_id).await?;
|
||||
info!("Set new state for ack: {}:{}:{}", channel, user, message_id);
|
||||
|
||||
// if let (Some(before), Some(after)) = (unread, updated) {
|
||||
// let before_mentions = before.mentions.unwrap_or_default().len();
|
||||
// let after_mentions = after.mentions.unwrap_or_default().len();
|
||||
|
||||
// let mentions_acked = before_mentions - after_mentions;
|
||||
|
||||
// if mentions_acked > 0 {
|
||||
// if let Err(err) = amqp
|
||||
// .ack_message(user.to_string(), channel.to_string(), payload.message_id)
|
||||
// .await
|
||||
// {
|
||||
// revolt_config::capture_error(&err);
|
||||
// }
|
||||
// };
|
||||
// }
|
||||
|
||||
Ok(())
|
||||
} else {
|
||||
Err(message_id.to_internal_error().expect_err("no err"))
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod acks;
|
||||
pub mod file_deletion;
|
||||
pub mod prune_dangling_files;
|
||||
pub mod prune_members;
|
||||
|
||||
@@ -24,8 +24,8 @@ use amqprs::{
|
||||
};
|
||||
use async_std::channel::unbounded;
|
||||
use authifier::AuthifierEvent;
|
||||
use rocket::data::ToByteUnit;
|
||||
use revolt_database::voice::VoiceClient;
|
||||
use rocket::data::ToByteUnit;
|
||||
|
||||
pub async fn web() -> Rocket<Build> {
|
||||
// Get settings
|
||||
@@ -93,22 +93,6 @@ pub async fn web() -> Rocket<Build> {
|
||||
)
|
||||
.into();
|
||||
|
||||
let swagger_0_8 = revolt_rocket_okapi::swagger_ui::make_swagger_ui(
|
||||
&revolt_rocket_okapi::swagger_ui::SwaggerUIConfig {
|
||||
url: "/0.8/openapi.json".to_owned(),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.into();
|
||||
|
||||
let swagger_0_8 = revolt_rocket_okapi::swagger_ui::make_swagger_ui(
|
||||
&revolt_rocket_okapi::swagger_ui::SwaggerUIConfig {
|
||||
url: "/0.8/openapi.json".to_owned(),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.into();
|
||||
|
||||
// Voice handler
|
||||
let voice_client = VoiceClient::new(config.api.livekit.nodes.clone());
|
||||
// Configure Rabbit
|
||||
@@ -136,6 +120,9 @@ pub async fn web() -> Rocket<Build> {
|
||||
.expect("Failed to declare exchange");
|
||||
|
||||
let amqp = AMQP::new(connection, channel);
|
||||
amqp.configure_channels()
|
||||
.await
|
||||
.expect("Failed to configure channels");
|
||||
|
||||
// Launch background task workers
|
||||
revolt_database::tasks::start_workers(db.clone(), amqp.clone());
|
||||
@@ -153,7 +140,6 @@ pub async fn web() -> Rocket<Build> {
|
||||
.mount("/", rocket_cors::catch_all_options_routes())
|
||||
.mount("/", ratelimiter::routes())
|
||||
.mount("/swagger/", swagger)
|
||||
.mount("/0.8/swagger/", swagger_0_8)
|
||||
.manage(authifier)
|
||||
.manage(db)
|
||||
.manage(amqp)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use revolt_database::{
|
||||
util::{permissions::DatabasePermissionQuery, reference::Reference},
|
||||
Database, User,
|
||||
Database, User, AMQP,
|
||||
};
|
||||
use revolt_permissions::{calculate_channel_permissions, ChannelPermission};
|
||||
use revolt_result::{create_error, Result};
|
||||
@@ -14,6 +14,7 @@ use rocket_empty::EmptyResponse;
|
||||
#[put("/<target>/ack/<message>")]
|
||||
pub async fn ack(
|
||||
db: &State<Database>,
|
||||
amqp: &State<AMQP>,
|
||||
user: User,
|
||||
target: Reference<'_>,
|
||||
message: Reference<'_>,
|
||||
@@ -29,7 +30,7 @@ pub async fn ack(
|
||||
.throw_if_lacking_channel_permission(ChannelPermission::ViewChannel)?;
|
||||
|
||||
channel
|
||||
.ack(&user.id, message.id)
|
||||
.ack(&user.id, message.id, amqp)
|
||||
.await
|
||||
.map(|_| EmptyResponse)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
use revolt_database::{
|
||||
util::{permissions::DatabasePermissionQuery, reference::Reference},
|
||||
Database, EmojiParent, PartialEmoji, User,
|
||||
};
|
||||
use revolt_models::v0;
|
||||
use revolt_permissions::{calculate_server_permissions, ChannelPermission};
|
||||
use revolt_result::{create_error, Result};
|
||||
use rocket::{serde::json::Json, State};
|
||||
use validator::Validate;
|
||||
|
||||
/// # Edit Emoji
|
||||
///
|
||||
/// Edit an emoji by its id.
|
||||
#[openapi(tag = "Emojis")]
|
||||
#[patch("/emoji/<emoji_id>", data = "<data>")]
|
||||
pub async fn edit_emoji(
|
||||
db: &State<Database>,
|
||||
user: User,
|
||||
emoji_id: Reference<'_>,
|
||||
data: Json<v0::DataEditEmoji>,
|
||||
) -> Result<Json<v0::Emoji>> {
|
||||
let data = data.into_inner();
|
||||
data.validate().map_err(|error| {
|
||||
create_error!(FailedValidation {
|
||||
error: error.to_string()
|
||||
})
|
||||
})?;
|
||||
|
||||
let mut emoji = emoji_id.as_emoji(db).await?;
|
||||
|
||||
match &emoji.parent {
|
||||
EmojiParent::Server { id } => {
|
||||
let server = db.fetch_server(id.as_str()).await?;
|
||||
|
||||
let mut query = DatabasePermissionQuery::new(db, &user).server(&server);
|
||||
calculate_server_permissions(&mut query)
|
||||
.await
|
||||
.throw_if_lacking_channel_permission(ChannelPermission::ManageCustomisation)?;
|
||||
}
|
||||
EmojiParent::Detached => return Err(create_error!(NotAuthenticated)),
|
||||
}
|
||||
|
||||
if data.name.is_none() {
|
||||
return Ok(Json(emoji.into()));
|
||||
}
|
||||
|
||||
let partial = PartialEmoji { name: data.name };
|
||||
emoji.update(db, partial).await?;
|
||||
|
||||
Ok(Json(emoji.into()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use crate::util::test::TestHarness;
|
||||
use revolt_database::{Emoji, EmojiParent, Member};
|
||||
use revolt_models::v0;
|
||||
use rocket::http::{ContentType, Header, Status};
|
||||
use ulid::Ulid;
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn edit_emoji_name_as_creator() {
|
||||
let harness = TestHarness::new().await;
|
||||
let (_, session, user) = harness.new_user().await;
|
||||
let (server, _) = harness.new_server(&user).await;
|
||||
|
||||
let emoji_id = Ulid::new().to_string();
|
||||
let emoji = Emoji {
|
||||
id: emoji_id.clone(),
|
||||
parent: EmojiParent::Server {
|
||||
id: server.id.clone(),
|
||||
},
|
||||
creator_id: user.id.clone(),
|
||||
name: "initial_name".to_string(),
|
||||
animated: false,
|
||||
nsfw: false,
|
||||
};
|
||||
emoji.create(&harness.db).await.expect("`Emoji` created");
|
||||
|
||||
let response = harness
|
||||
.client
|
||||
.patch(format!("/custom/emoji/{emoji_id}"))
|
||||
.header(Header::new("x-session-token", session.token.to_string()))
|
||||
.header(ContentType::JSON)
|
||||
.body(
|
||||
json!(v0::DataEditEmoji {
|
||||
name: Some("renamed_emoji".to_string()),
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(response.status(), Status::Ok);
|
||||
|
||||
let edited: v0::Emoji = response.into_json().await.expect("`Emoji`");
|
||||
assert_eq!(edited.name, "renamed_emoji");
|
||||
}
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn reject_invalid_emoji_name() {
|
||||
let harness = TestHarness::new().await;
|
||||
let (_, session, user) = harness.new_user().await;
|
||||
let (server, _) = harness.new_server(&user).await;
|
||||
|
||||
let emoji_id = Ulid::new().to_string();
|
||||
let emoji = Emoji {
|
||||
id: emoji_id.clone(),
|
||||
parent: EmojiParent::Server {
|
||||
id: server.id.clone(),
|
||||
},
|
||||
creator_id: user.id.clone(),
|
||||
name: "valid_name".to_string(),
|
||||
animated: false,
|
||||
nsfw: false,
|
||||
};
|
||||
emoji.create(&harness.db).await.expect("`Emoji` created");
|
||||
|
||||
let response = harness
|
||||
.client
|
||||
.patch(format!("/custom/emoji/{emoji_id}"))
|
||||
.header(Header::new("x-session-token", session.token.to_string()))
|
||||
.header(ContentType::JSON)
|
||||
.body(
|
||||
json!(v0::DataEditEmoji {
|
||||
name: Some("Invalid Name".to_string()),
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(response.status(), Status::BadRequest);
|
||||
}
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn reject_edit_for_detached_emoji() {
|
||||
let harness = TestHarness::new().await;
|
||||
let (_, session, user) = harness.new_user().await;
|
||||
|
||||
let emoji_id = Ulid::new().to_string();
|
||||
let emoji = Emoji {
|
||||
id: emoji_id.clone(),
|
||||
parent: EmojiParent::Detached,
|
||||
creator_id: user.id.clone(),
|
||||
name: "detached_name".to_string(),
|
||||
animated: false,
|
||||
nsfw: false,
|
||||
};
|
||||
emoji.create(&harness.db).await.expect("`Emoji` created");
|
||||
|
||||
let response = harness
|
||||
.client
|
||||
.patch(format!("/custom/emoji/{emoji_id}"))
|
||||
.header(Header::new("x-session-token", session.token.to_string()))
|
||||
.header(ContentType::JSON)
|
||||
.body(
|
||||
json!(v0::DataEditEmoji {
|
||||
name: Some("should_not_apply".to_string()),
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(response.status(), Status::Unauthorized);
|
||||
}
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn reject_edit_for_creator_without_manage_customisation() {
|
||||
let harness = TestHarness::new().await;
|
||||
let (_, _, owner) = harness.new_user().await;
|
||||
let (_, creator_session, creator) = harness.new_user().await;
|
||||
let (server, _) = harness.new_server(&owner).await;
|
||||
|
||||
Member::create(&harness.db, &server, &creator, None)
|
||||
.await
|
||||
.expect("`Member` created");
|
||||
|
||||
let emoji_id = Ulid::new().to_string();
|
||||
let emoji = Emoji {
|
||||
id: emoji_id.clone(),
|
||||
parent: EmojiParent::Server {
|
||||
id: server.id.clone(),
|
||||
},
|
||||
creator_id: creator.id.clone(),
|
||||
name: "member_uploaded_name".to_string(),
|
||||
animated: false,
|
||||
nsfw: false,
|
||||
};
|
||||
emoji.create(&harness.db).await.expect("`Emoji` created");
|
||||
|
||||
let response = harness
|
||||
.client
|
||||
.patch(format!("/custom/emoji/{emoji_id}"))
|
||||
.header(Header::new(
|
||||
"x-session-token",
|
||||
creator_session.token.to_string(),
|
||||
))
|
||||
.header(ContentType::JSON)
|
||||
.body(
|
||||
json!(v0::DataEditEmoji {
|
||||
name: Some("renamed_without_permission".to_string()),
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(response.status(), Status::Forbidden);
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,14 @@ use rocket::Route;
|
||||
|
||||
mod emoji_create;
|
||||
mod emoji_delete;
|
||||
mod emoji_edit;
|
||||
mod emoji_fetch;
|
||||
|
||||
pub fn routes() -> (Vec<Route>, OpenApi) {
|
||||
openapi_get_routes_spec![
|
||||
emoji_create::create_emoji,
|
||||
emoji_delete::delete_emoji,
|
||||
emoji_edit::edit_emoji,
|
||||
emoji_fetch::fetch_emoji
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use revolt_database::{
|
||||
util::{permissions::DatabasePermissionQuery, reference::Reference},
|
||||
Database, User,
|
||||
util::{acker, permissions::DatabasePermissionQuery, reference::Reference},
|
||||
Database, User, AMQP,
|
||||
};
|
||||
use revolt_permissions::PermissionQuery;
|
||||
use revolt_result::{create_error, Result};
|
||||
@@ -12,7 +12,12 @@ use rocket_empty::EmptyResponse;
|
||||
/// Mark all channels in a server as read.
|
||||
#[openapi(tag = "Server Information")]
|
||||
#[put("/<target>/ack")]
|
||||
pub async fn ack(db: &State<Database>, user: User, target: Reference<'_>) -> Result<EmptyResponse> {
|
||||
pub async fn ack(
|
||||
db: &State<Database>,
|
||||
amqp: &State<AMQP>,
|
||||
user: User,
|
||||
target: Reference<'_>,
|
||||
) -> Result<EmptyResponse> {
|
||||
if user.bot.is_some() {
|
||||
return Err(create_error!(IsBot));
|
||||
}
|
||||
@@ -23,7 +28,6 @@ pub async fn ack(db: &State<Database>, user: User, target: Reference<'_>) -> Res
|
||||
return Err(create_error!(NotFound));
|
||||
}
|
||||
|
||||
db.acknowledge_channels(&user.id, &server.channels)
|
||||
.await
|
||||
.map(|_| EmptyResponse)
|
||||
acker::ack_server(&user, &server, db, amqp).await?;
|
||||
Ok(EmptyResponse)
|
||||
}
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
use rocket::Route;
|
||||
use revolt_rocket_okapi::revolt_okapi::openapi3::OpenApi;
|
||||
use rocket::Route;
|
||||
|
||||
mod webhook_delete;
|
||||
mod webhook_delete_message;
|
||||
mod webhook_delete_token;
|
||||
mod webhook_edit;
|
||||
mod webhook_edit_message;
|
||||
mod webhook_edit_token;
|
||||
mod webhook_execute;
|
||||
mod webhook_fetch_token;
|
||||
mod webhook_fetch;
|
||||
mod webhook_execute_github;
|
||||
mod webhook_fetch;
|
||||
mod webhook_fetch_token;
|
||||
|
||||
pub fn routes() -> (Vec<Route>, OpenApi) {
|
||||
openapi_get_routes_spec![
|
||||
webhook_delete_message::webhook_delete_message,
|
||||
webhook_delete_token::webhook_delete_token,
|
||||
webhook_delete::webhook_delete,
|
||||
webhook_edit_message::webhook_edit_message,
|
||||
webhook_edit_token::webhook_edit_token,
|
||||
webhook_edit::webhook_edit,
|
||||
webhook_execute_github::webhook_execute_github,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
use revolt_database::{util::reference::Reference, Database};
|
||||
use revolt_result::{create_error, Result};
|
||||
use rocket::State;
|
||||
use rocket_empty::EmptyResponse;
|
||||
|
||||
/// # Deletes a webhook message
|
||||
///
|
||||
/// Deletes a message sent by a webhook
|
||||
#[openapi(tag = "Webhooks")]
|
||||
#[delete("/<webhook_id>/<token>/<message_id>")]
|
||||
pub async fn webhook_delete_message(
|
||||
db: &State<Database>,
|
||||
webhook_id: Reference<'_>,
|
||||
token: String,
|
||||
message_id: Reference<'_>,
|
||||
) -> Result<EmptyResponse> {
|
||||
let webhook = webhook_id.as_webhook(db).await?;
|
||||
webhook.assert_token(&token)?;
|
||||
|
||||
let message = message_id.as_message(db).await?;
|
||||
|
||||
if message.author != webhook.id {
|
||||
return Err(create_error!(CannotDeleteMessage));
|
||||
}
|
||||
|
||||
message.delete(db).await.map(|_| EmptyResponse)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
use iso8601_timestamp::Timestamp;
|
||||
use revolt_config::config;
|
||||
use revolt_database::{
|
||||
tasks::process_embeds::queue, util::reference::Reference, Database, Message, PartialMessage,
|
||||
};
|
||||
use revolt_models::v0::{self, DataEditMessage, Embed};
|
||||
use revolt_models::validator::Validate;
|
||||
use revolt_result::{create_error, Result};
|
||||
use rocket::{serde::json::Json, State};
|
||||
|
||||
/// # Edits a webhook message
|
||||
///
|
||||
/// Edits a message sent by a webhook
|
||||
#[openapi(tag = "Webhooks")]
|
||||
#[patch("/<webhook_id>/<token>/<message_id>", data = "<data>")]
|
||||
pub async fn webhook_edit_message(
|
||||
db: &State<Database>,
|
||||
webhook_id: Reference<'_>,
|
||||
token: String,
|
||||
message_id: Reference<'_>,
|
||||
data: Json<DataEditMessage>,
|
||||
) -> Result<Json<v0::Message>> {
|
||||
let edit = data.into_inner();
|
||||
edit.validate().map_err(|error| {
|
||||
create_error!(FailedValidation {
|
||||
error: error.to_string()
|
||||
})
|
||||
})?;
|
||||
|
||||
Message::validate_sum(
|
||||
&edit.content,
|
||||
edit.embeds.as_deref().unwrap_or_default(),
|
||||
config().await.features.limits.default.message_length,
|
||||
)?;
|
||||
|
||||
let webhook = webhook_id.as_webhook(db).await?;
|
||||
webhook.assert_token(&token)?;
|
||||
|
||||
let mut message = message_id.as_message(db).await?;
|
||||
if message.author != webhook.id {
|
||||
return Err(create_error!(CannotEditMessage));
|
||||
}
|
||||
|
||||
message.edited = Some(Timestamp::now_utc());
|
||||
let mut partial = PartialMessage {
|
||||
edited: message.edited,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// 1. Handle content update
|
||||
if let Some(content) = &edit.content {
|
||||
partial.content = Some(content.clone());
|
||||
}
|
||||
|
||||
// 2. Clear any auto generated embeds
|
||||
let mut new_embeds = vec![];
|
||||
if let Some(embeds) = &message.embeds {
|
||||
for embed in embeds {
|
||||
if let Embed::Text(embed) = embed {
|
||||
new_embeds.push(Embed::Text(embed.clone()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Replace if we are given new embeds
|
||||
if let Some(embeds) = edit.embeds {
|
||||
new_embeds.clear();
|
||||
|
||||
for embed in embeds {
|
||||
new_embeds.push(message.create_embed(db, embed).await?);
|
||||
}
|
||||
}
|
||||
|
||||
partial.embeds = Some(new_embeds);
|
||||
|
||||
message.update(db, partial, vec![]).await?;
|
||||
|
||||
// Queue up a task for processing embeds
|
||||
if let Some(content) = edit.content {
|
||||
queue(message.channel.to_string(), message.id.to_string(), content).await;
|
||||
}
|
||||
|
||||
Ok(Json(message.into_model(None, None)))
|
||||
}
|
||||
@@ -18,6 +18,7 @@ kamadak-exif = { workspace = true }
|
||||
# revolt_little_exif = "0.5.1"
|
||||
image = { workspace = true }
|
||||
thumbhash = { workspace = true }
|
||||
lcms2 = { workspace = true }
|
||||
|
||||
# File processing
|
||||
revolt_clamav-client = { workspace = true }
|
||||
@@ -31,7 +32,7 @@ imagesize = { workspace = true }
|
||||
# Utility
|
||||
lazy_static = { workspace = true }
|
||||
moka = { workspace = true, features = ["future"] }
|
||||
|
||||
url-escape = { workspace = true }
|
||||
# Serialisation
|
||||
strum_macros = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -24,6 +24,7 @@ use sha2::Digest;
|
||||
use tempfile::NamedTempFile;
|
||||
use tokio::time::Instant;
|
||||
use tower_http::cors::{AllowHeaders, Any, CorsLayer};
|
||||
use url_escape::encode_component;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::{
|
||||
@@ -479,8 +480,10 @@ async fn fetch_file(
|
||||
// Ensure filename is correct
|
||||
if file_name != file.filename {
|
||||
if file_name == "original" {
|
||||
let safe_filename = encode_component(&file.filename);
|
||||
|
||||
return Ok(
|
||||
Redirect::permanent(&format!("/{tag}/{file_id}/{}", file.filename)).into_response(),
|
||||
Redirect::permanent(&format!("/{tag}/{file_id}/{}", safe_filename)).into_response(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
use std::io::{Cursor, Read};
|
||||
|
||||
use crate::utils::apply_icc_profile;
|
||||
use exif::Reader;
|
||||
use image::{ImageFormat, ImageReader};
|
||||
use image::{ImageEncoder, ImageReader};
|
||||
use revolt_config::report_internal_error;
|
||||
use revolt_database::Metadata;
|
||||
use revolt_result::{create_error, Result};
|
||||
use tempfile::NamedTempFile;
|
||||
use tokio::process::Command;
|
||||
|
||||
macro_rules! encode_with_icc {
|
||||
($encoder:expr, $icc:expr, $image:expr, $width:expr, $height:expr, $color:expr) => {{
|
||||
let mut encoder = $encoder;
|
||||
if let Some(icc) = $icc {
|
||||
let _ = encoder.set_icc_profile(icc.clone());
|
||||
}
|
||||
encoder.write_image($image, $width, $height, $color)
|
||||
}};
|
||||
}
|
||||
|
||||
/// Strip EXIF data from given file and produce new file and metadata
|
||||
pub async fn strip_metadata(
|
||||
file: NamedTempFile,
|
||||
@@ -17,8 +28,8 @@ pub async fn strip_metadata(
|
||||
) -> Result<(Vec<u8>, Metadata)> {
|
||||
match &metadata {
|
||||
Metadata::Image {
|
||||
width,
|
||||
height,
|
||||
width: _,
|
||||
height: _,
|
||||
thumbhash,
|
||||
animated,
|
||||
} => match mime {
|
||||
@@ -46,11 +57,12 @@ pub async fn strip_metadata(
|
||||
let mut cursor = Cursor::new(buf);
|
||||
|
||||
// Decode the image
|
||||
let image = report_internal_error!(report_internal_error!(ImageReader::new(
|
||||
&mut cursor
|
||||
)
|
||||
.with_guessed_format())?
|
||||
.decode());
|
||||
let reader =
|
||||
report_internal_error!(ImageReader::new(&mut cursor).with_guessed_format())?;
|
||||
let mut decoder = report_internal_error!(reader.into_decoder())?;
|
||||
let mut icc_profile =
|
||||
report_internal_error!(image::ImageDecoder::icc_profile(&mut decoder))?;
|
||||
let mut image = report_internal_error!(image::DynamicImage::from_decoder(decoder))?;
|
||||
|
||||
// Reset read position
|
||||
cursor.set_position(0);
|
||||
@@ -71,38 +83,68 @@ pub async fn strip_metadata(
|
||||
|
||||
// Apply the EXIF rotation
|
||||
// See https://jdhao.github.io/2019/07/31/image_rotation_exif_info/
|
||||
report_internal_error!(match &rotation {
|
||||
2 => image?.fliph(),
|
||||
3 => image?.rotate180(),
|
||||
4 => image?.rotate180().fliph(),
|
||||
5 => image?.rotate90().fliph(),
|
||||
6 => image?.rotate90(),
|
||||
7 => image?.rotate270().fliph(),
|
||||
8 => image?.rotate270(),
|
||||
_ => image?,
|
||||
}
|
||||
.write_to(
|
||||
&mut writer,
|
||||
match mime {
|
||||
"image/jpeg" => ImageFormat::Jpeg,
|
||||
"image/png" => ImageFormat::Png,
|
||||
"image/avif" => ImageFormat::Avif,
|
||||
"image/tiff" => ImageFormat::Tiff,
|
||||
_ => todo!(),
|
||||
},
|
||||
))?;
|
||||
|
||||
// Calculate dimensions after rotation.
|
||||
let (width, height) = match &rotation {
|
||||
2 | 4 | 5 | 7 => (*height, *width),
|
||||
_ => (*width, *height),
|
||||
image = match &rotation {
|
||||
2 => image.fliph(),
|
||||
3 => image.rotate180(),
|
||||
4 => image.rotate180().fliph(),
|
||||
5 => image.rotate90().fliph(),
|
||||
6 => image.rotate90(),
|
||||
7 => image.rotate270().fliph(),
|
||||
8 => image.rotate270(),
|
||||
_ => image,
|
||||
};
|
||||
|
||||
if let Some(icc) = &icc_profile {
|
||||
image = apply_icc_profile(image, icc);
|
||||
icc_profile = None;
|
||||
}
|
||||
|
||||
let color_type = image.color();
|
||||
let width = image.width();
|
||||
let height = image.height();
|
||||
|
||||
report_internal_error!(match mime {
|
||||
"image/jpeg" => encode_with_icc!(
|
||||
image::codecs::jpeg::JpegEncoder::new(&mut writer),
|
||||
&icc_profile,
|
||||
image.as_bytes(),
|
||||
width,
|
||||
height,
|
||||
color_type.into()
|
||||
),
|
||||
"image/png" => encode_with_icc!(
|
||||
image::codecs::png::PngEncoder::new(&mut writer),
|
||||
&icc_profile,
|
||||
image.as_bytes(),
|
||||
width,
|
||||
height,
|
||||
color_type.into()
|
||||
),
|
||||
"image/avif" => {
|
||||
// avif encoder doesn't implement set_icc_profile currently
|
||||
image::codecs::avif::AvifEncoder::new(&mut writer).write_image(
|
||||
image.as_bytes(),
|
||||
width,
|
||||
height,
|
||||
color_type.into(),
|
||||
)
|
||||
}
|
||||
"image/tiff" => encode_with_icc!(
|
||||
image::codecs::tiff::TiffEncoder::new(&mut writer),
|
||||
&icc_profile,
|
||||
image.as_bytes(),
|
||||
width,
|
||||
height,
|
||||
color_type.into()
|
||||
),
|
||||
_ => unreachable!(),
|
||||
})?;
|
||||
|
||||
Ok((
|
||||
bytes,
|
||||
Metadata::Image {
|
||||
width,
|
||||
height,
|
||||
width: width as isize,
|
||||
height: height as isize,
|
||||
thumbhash: thumbhash.clone(),
|
||||
animated: *animated,
|
||||
},
|
||||
|
||||
@@ -18,6 +18,7 @@ pub mod exif;
|
||||
pub mod metadata;
|
||||
pub mod mime_type;
|
||||
mod ratelimits;
|
||||
mod utils;
|
||||
|
||||
#[derive(FromRef, Clone)]
|
||||
struct AppState {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::io::Cursor;
|
||||
|
||||
use crate::utils::apply_icc_profile;
|
||||
use image::{GenericImageView, ImageError, ImageReader};
|
||||
use revolt_database::Metadata;
|
||||
use revolt_files::{image_size, is_animated, video_size};
|
||||
@@ -27,16 +28,26 @@ pub fn generate_metadata(f: &NamedTempFile, mime_type: &str) -> Metadata {
|
||||
.map(|(width, height)| Metadata::Image {
|
||||
width: width as isize,
|
||||
height: height as isize,
|
||||
thumbhash: ImageReader::open(f)
|
||||
.and_then(|r| r.with_guessed_format())
|
||||
.map_err(ImageError::from)
|
||||
.and_then(|r| r.decode())
|
||||
.map(|img| img.thumbnail(100, 100))
|
||||
.map(|img| (img.dimensions(), img.to_rgba8().into_raw()))
|
||||
.map(|((width, height), rgba)| {
|
||||
thumbhash::rgba_to_thumb_hash(width as usize, height as usize, &rgba)
|
||||
})
|
||||
.ok(),
|
||||
thumbhash: (|| {
|
||||
let reader = ImageReader::open(f).ok()?.with_guessed_format().ok()?;
|
||||
let mut decoder = reader.into_decoder().ok()?;
|
||||
let icc_profile = image::ImageDecoder::icc_profile(&mut decoder)
|
||||
.ok()
|
||||
.flatten();
|
||||
let mut img = image::DynamicImage::from_decoder(decoder).ok()?;
|
||||
|
||||
if let Some(icc) = icc_profile {
|
||||
img = apply_icc_profile(img, &icc);
|
||||
}
|
||||
|
||||
let img = img.thumbnail(100, 100);
|
||||
let (width, height) = img.dimensions();
|
||||
Some(thumbhash::rgba_to_thumb_hash(
|
||||
width as usize,
|
||||
height as usize,
|
||||
&img.into_rgba8().into_raw(),
|
||||
))
|
||||
})(),
|
||||
animated: is_animated(f, mime_type).or(Some(false)),
|
||||
})
|
||||
.unwrap_or_default()
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/// Convert image to sRGB using the provided ICC profile.
|
||||
/// Returns the converted image, or the original if conversion fails.
|
||||
pub fn apply_icc_profile(image: image::DynamicImage, icc: &[u8]) -> image::DynamicImage {
|
||||
let Ok(src_profile) = lcms2::Profile::new_icc(icc) else {
|
||||
return image;
|
||||
};
|
||||
let dst_profile = lcms2::Profile::new_srgb();
|
||||
let format = if image.color().has_alpha() {
|
||||
lcms2::PixelFormat::RGBA_8
|
||||
} else {
|
||||
lcms2::PixelFormat::RGB_8
|
||||
};
|
||||
let Ok(t) = lcms2::Transform::new(
|
||||
&src_profile,
|
||||
format,
|
||||
&dst_profile,
|
||||
format,
|
||||
lcms2::Intent::Perceptual,
|
||||
) else {
|
||||
return image;
|
||||
};
|
||||
if image.color().has_alpha() {
|
||||
let mut rgba_image = image.into_rgba8();
|
||||
t.transform_in_place(rgba_image.as_mut());
|
||||
image::DynamicImage::ImageRgba8(rgba_image)
|
||||
} else {
|
||||
let mut rgb_image = image.into_rgb8();
|
||||
t.transform_in_place(rgb_image.as_mut());
|
||||
image::DynamicImage::ImageRgb8(rgb_image)
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,9 @@ lazy_static! {
|
||||
/// Regex for matching new Reddit URLs
|
||||
static ref RE_URL_NEW_REDDIT: Regex = Regex::new("^(?:(?:new\\.|www\\.)?reddit).com").expect("valid regex");
|
||||
|
||||
/// Regex for matching YouTube Shorts URLs
|
||||
static ref RE_URL_YOUTUBE_SHORTS: Regex = Regex::new("^(?:(?:https?:)?//)?(?:(?:www\\.)?youtube\\.com)/shorts/([a-zA-Z0-9_-]+)").expect("valid regex");
|
||||
|
||||
/// Cache for proxy results
|
||||
static ref PROXY_CACHE: moka::future::Cache<String, Result<(String, Vec<u8>)>> = moka::future::Cache::builder()
|
||||
.weigher(|_key, value: &Result<(String, Vec<u8>)>| -> u32 {
|
||||
@@ -214,6 +217,13 @@ impl Request {
|
||||
.to_string();
|
||||
}
|
||||
|
||||
// Re-map Youtube Shorts to regular Youtube links
|
||||
if let Some(captures) = RE_URL_YOUTUBE_SHORTS.captures(&url) {
|
||||
if let Some(video_id) = captures.get(1) {
|
||||
url = format!("https://youtube.com/watch?v={}", video_id.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
// Generate the actual embed
|
||||
if let Some(hit) = EMBED_CACHE.get(&url).await {
|
||||
Ok(hit)
|
||||
|
||||
@@ -190,7 +190,7 @@ pub async fn create_website_embed(original_url: &str, document: &str) -> Option<
|
||||
|
||||
pub async fn populate_special(original_url: String, metadata: &mut WebsiteMetadata) {
|
||||
lazy_static! {
|
||||
static ref RE_YOUTUBE: Regex = Regex::new("^(?:(?:https?:)?//)?(?:(?:www|m)\\.)?(?:(?:youtube\\.com|youtu.be))(?:/(?:[\\w\\-]+\\?v=|embed/|v/)?)([\\w\\-]+)(?:\\S+)?$").unwrap();
|
||||
static ref RE_YOUTUBE: Regex = Regex::new("^(?:(?:https?:)?//)?(?:(?:www|m)\\.)?(?:(?:youtube\\.com|youtu.be))(?:/(?:[\\w\\-]+\\?v=|embed/|v/|shorts/)?)([\\w\\-]+)(?:\\S+)?$").unwrap();
|
||||
|
||||
static ref RE_LIGHTSPEED: Regex = Regex::new("^(?:https?://)?(?:[\\w]+\\.)?lightspeed\\.tv/([a-z0-9_]{4,25})").unwrap();
|
||||
|
||||
|
||||
@@ -539,6 +539,22 @@ Emoji created, the event object has the same schema as the Emoji object in the A
|
||||
}
|
||||
```
|
||||
|
||||
### EmojiUpdate
|
||||
|
||||
Emoji has been updated.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "EmojiUpdate",
|
||||
"id": "{emoji_id}",
|
||||
"data": {
|
||||
"name"?: "{emoji_name}"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `data` field contains a partial Emoji object.
|
||||
|
||||
### EmojiDelete
|
||||
|
||||
Emoji has been deleted.
|
||||
|
||||
Reference in New Issue
Block a user