Compare commits
31
Commits
20240929-1
...
20241024-1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df07426019 | ||
|
|
c30d9a2620 | ||
|
|
7b44317705 | ||
|
|
876068a37e | ||
|
|
397b9878e1 | ||
|
|
d9deadc65a | ||
|
|
58d3c5cc2e | ||
|
|
520fb02fb6 | ||
|
|
2cb12a3d59 | ||
|
|
18888eae5f | ||
|
|
f31020fb6e | ||
|
|
bb202079e0 | ||
|
|
bb6bcda8bd | ||
|
|
68099bd2b7 | ||
|
|
a8db1cb40d | ||
|
|
efa7ba78ed | ||
|
|
25fc692dc1 | ||
|
|
530d68fe89 | ||
|
|
afd8c906ba | ||
|
|
c596dd5458 | ||
|
|
ed78b253ff | ||
|
|
66c84e0ad9 | ||
|
|
21335b3297 | ||
|
|
8fc791f81a | ||
|
|
1689ee5ddc | ||
|
|
7a061bb3c6 | ||
|
|
6c3b8eaa92 | ||
|
|
5e1b2e165f | ||
|
|
e270b5df6a | ||
|
|
cbf9e81256 | ||
|
|
3080ec1f5a |
@@ -61,7 +61,7 @@ jobs:
|
||||
if: github.event_name != 'pull_request'
|
||||
strategy:
|
||||
matrix:
|
||||
project: [delta, bonfire]
|
||||
project: [delta, bonfire, autumn, january]
|
||||
name: Build ${{ matrix.project }} image
|
||||
steps:
|
||||
# Configure build environment
|
||||
@@ -98,6 +98,14 @@ jobs:
|
||||
"bonfire": {
|
||||
"path": "crates/bonfire",
|
||||
"tag": "${{ github.repository_owner }}/bonfire"
|
||||
},
|
||||
"autumn": {
|
||||
"path": "crates/services/autumn",
|
||||
"tag": "${{ github.repository_owner }}/autumn"
|
||||
},
|
||||
"january": {
|
||||
"path": "crates/services/january",
|
||||
"tag": "${{ github.repository_owner }}/january"
|
||||
}
|
||||
}
|
||||
export_to: output
|
||||
|
||||
@@ -41,11 +41,7 @@ jobs:
|
||||
|
||||
- name: Run services in background
|
||||
run: |
|
||||
docker compose -f docker-compose.db.yml up -d
|
||||
|
||||
- name: Copy .env.example
|
||||
run: |
|
||||
cp .env.example .env
|
||||
docker compose -f compose.yml up -d
|
||||
|
||||
- name: Run cargo test
|
||||
env:
|
||||
|
||||
Generated
+613
-54
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "revolt-nodejs-bindings"
|
||||
version = "0.7.16"
|
||||
version = "0.7.19"
|
||||
description = "Node.js bindings for the Revolt software"
|
||||
authors = ["Paul Makles <me@insrt.uk>"]
|
||||
license = "MIT"
|
||||
@@ -20,6 +20,6 @@ serde = { version = "1", features = ["derive"] }
|
||||
|
||||
async-std = "1.12.0"
|
||||
|
||||
revolt-config = { version = "0.7.16", path = "../../core/config" }
|
||||
revolt-result = { version = "0.7.16", path = "../../core/result" }
|
||||
revolt-database = { version = "0.7.16", path = "../../core/database" }
|
||||
revolt-config = { version = "0.7.19", path = "../../core/config" }
|
||||
revolt-result = { version = "0.7.19", path = "../../core/result" }
|
||||
revolt-database = { version = "0.7.19", path = "../../core/database" }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "revolt-bonfire"
|
||||
version = "0.7.16"
|
||||
version = "0.7.19"
|
||||
license = "AGPL-3.0-or-later"
|
||||
edition = "2021"
|
||||
|
||||
@@ -41,7 +41,7 @@ revolt-result = { path = "../core/result" }
|
||||
revolt-models = { path = "../core/models" }
|
||||
revolt-config = { path = "../core/config" }
|
||||
revolt-database = { path = "../core/database" }
|
||||
revolt-permissions = { version = "0.7.16", path = "../core/permissions" }
|
||||
revolt-permissions = { version = "0.7.19", path = "../core/permissions" }
|
||||
revolt-presence = { path = "../core/presence", features = ["redis-is-patched"] }
|
||||
|
||||
# redis
|
||||
|
||||
@@ -5,7 +5,6 @@ FROM ghcr.io/revoltchat/base:latest AS builder
|
||||
FROM gcr.io/distroless/cc-debian12:nonroot
|
||||
COPY --from=builder /home/rust/src/target/release/revolt-bonfire ./
|
||||
|
||||
EXPOSE 9000
|
||||
EXPOSE 14703
|
||||
USER nonroot
|
||||
ENV HOST=0.0.0.0:9000
|
||||
CMD ["./revolt-bonfire"]
|
||||
|
||||
@@ -14,6 +14,7 @@ use futures::{
|
||||
FutureExt, SinkExt, StreamExt, TryStreamExt,
|
||||
};
|
||||
use redis_kiss::{PayloadType, REDIS_PAYLOAD_TYPE, REDIS_URI};
|
||||
use revolt_config::report_internal_error;
|
||||
use revolt_database::{
|
||||
events::{client::EventV1, server::ClientMessage},
|
||||
Database, User, UserHint,
|
||||
@@ -99,27 +100,21 @@ pub async fn client(db: &'static Database, stream: TcpStream, addr: SocketAddr)
|
||||
let user_id = state.cache.user_id.clone();
|
||||
|
||||
// Notify socket we have authenticated.
|
||||
if let Err(err) = write.send(config.encode(&EventV1::Authenticated)).await {
|
||||
error!("Failed to write: {err:?}");
|
||||
sentry::capture_error(&err);
|
||||
if report_internal_error!(write.send(config.encode(&EventV1::Authenticated)).await).is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Download required data to local cache and send Ready payload.
|
||||
let ready_payload = match state
|
||||
.generate_ready_payload(db, config.get_ready_payload_fields())
|
||||
.await
|
||||
{
|
||||
let ready_payload = match report_internal_error!(
|
||||
state
|
||||
.generate_ready_payload(db, config.get_ready_payload_fields())
|
||||
.await
|
||||
) {
|
||||
Ok(ready_payload) => ready_payload,
|
||||
Err(err) => {
|
||||
sentry::capture_error(&err);
|
||||
return;
|
||||
}
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
if let Err(err) = write.send(config.encode(&ready_payload)).await {
|
||||
error!("Failed to write: {err:?}");
|
||||
sentry::capture_error(&err);
|
||||
if report_internal_error!(write.send(config.encode(&ready_payload)).await).is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -214,21 +209,16 @@ async fn listener(
|
||||
write: &Mutex<WsWriter>,
|
||||
) {
|
||||
let redis_config = RedisConfig::from_url(&REDIS_URI).unwrap();
|
||||
let subscriber = match fred::types::Builder::from_config(redis_config).build_subscriber_client()
|
||||
{
|
||||
let subscriber = match report_internal_error!(
|
||||
fred::types::Builder::from_config(redis_config).build_subscriber_client()
|
||||
) {
|
||||
Ok(subscriber) => subscriber,
|
||||
Err(err) => {
|
||||
error!("Failed to build a subscriber: {err:?}");
|
||||
sentry::capture_error(&err);
|
||||
return;
|
||||
}
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
if let Err(err) = subscriber.init().await {
|
||||
error!("Failed to init subscriber: {err:?}");
|
||||
sentry::capture_error(&err);
|
||||
if report_internal_error!(subscriber.init().await).is_err() {
|
||||
return;
|
||||
};
|
||||
}
|
||||
|
||||
// Handle Redis connection dropping
|
||||
let (clean_up_s, clean_up_r) = async_channel::bounded(1);
|
||||
@@ -249,17 +239,13 @@ async fn listener(
|
||||
// Check for state changes for subscriptions.
|
||||
match state.apply_state().await {
|
||||
SubscriptionStateChange::Reset => {
|
||||
if let Err(err) = subscriber.unsubscribe_all().await {
|
||||
error!("Unsubscribe all failed: {err:?}");
|
||||
sentry::capture_error(&err);
|
||||
if report_internal_error!(subscriber.unsubscribe_all().await).is_err() {
|
||||
break 'out;
|
||||
}
|
||||
|
||||
let subscribed = state.subscribed.read().await;
|
||||
for id in subscribed.iter() {
|
||||
if let Err(err) = subscriber.subscribe(id).await {
|
||||
error!("Subscribe failed: {err:?}");
|
||||
sentry::capture_error(&err);
|
||||
if report_internal_error!(subscriber.subscribe(id).await).is_err() {
|
||||
break 'out;
|
||||
}
|
||||
}
|
||||
@@ -272,9 +258,7 @@ async fn listener(
|
||||
#[cfg(debug_assertions)]
|
||||
info!("{addr:?} unsubscribing from {id}");
|
||||
|
||||
if let Err(err) = subscriber.unsubscribe(id).await {
|
||||
error!("Unsubscribe failed: {err:?}");
|
||||
sentry::capture_error(&err);
|
||||
if report_internal_error!(subscriber.unsubscribe(id).await).is_err() {
|
||||
break 'out;
|
||||
}
|
||||
}
|
||||
@@ -283,9 +267,7 @@ async fn listener(
|
||||
#[cfg(debug_assertions)]
|
||||
info!("{addr:?} subscribing to {id}");
|
||||
|
||||
if let Err(err) = subscriber.subscribe(id).await {
|
||||
error!("Subscribe failed: {err:?}");
|
||||
sentry::capture_error(&err);
|
||||
if report_internal_error!(subscriber.subscribe(id).await).is_err() {
|
||||
break 'out;
|
||||
}
|
||||
}
|
||||
@@ -310,38 +292,32 @@ async fn listener(
|
||||
_ = t2 => {},
|
||||
message = t1 => {
|
||||
// Handle incoming events.
|
||||
let message = match message {
|
||||
let message = match report_internal_error!(message) {
|
||||
Ok(message) => message,
|
||||
Err(e) => {
|
||||
error!("Error while consuming pub/sub messages: {e:?}");
|
||||
sentry::capture_error(&e);
|
||||
break 'out;
|
||||
}
|
||||
Err(_) => break 'out
|
||||
};
|
||||
|
||||
let event = match *REDIS_PAYLOAD_TYPE {
|
||||
PayloadType::Json => message
|
||||
.value
|
||||
.as_str()
|
||||
.and_then(|s| serde_json::from_str::<EventV1>(s.as_ref()).ok()),
|
||||
.and_then(|s| report_internal_error!(serde_json::from_str::<EventV1>(s.as_ref())).ok()),
|
||||
PayloadType::Msgpack => message
|
||||
.value
|
||||
.as_bytes()
|
||||
.and_then(|b| rmp_serde::from_slice::<EventV1>(b).ok()),
|
||||
.and_then(|b| report_internal_error!(rmp_serde::from_slice::<EventV1>(b)).ok()),
|
||||
PayloadType::Bincode => message
|
||||
.value
|
||||
.as_bytes()
|
||||
.and_then(|b| bincode::deserialize::<EventV1>(b).ok()),
|
||||
.and_then(|b| report_internal_error!(bincode::deserialize::<EventV1>(b)).ok()),
|
||||
};
|
||||
|
||||
let Some(mut event) = event else {
|
||||
let err = format!(
|
||||
"Failed to deserialise an event for {}! Introspection: `{:?}`",
|
||||
"Failed to deserialise event for {}: `{:?}`",
|
||||
message.channel,
|
||||
message
|
||||
.value
|
||||
.as_string()
|
||||
.map(|x| x.chars().take(32).collect::<String>())
|
||||
);
|
||||
|
||||
error!("{}", err);
|
||||
@@ -393,10 +369,7 @@ async fn listener(
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(err) = subscriber.quit().await {
|
||||
error!("{}", err);
|
||||
sentry::capture_error(&err);
|
||||
}
|
||||
report_internal_error!(subscriber.quit().await).ok();
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "revolt-config"
|
||||
version = "0.7.16"
|
||||
version = "0.7.19"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
authors = ["Paul Makles <me@insrt.uk>"]
|
||||
@@ -35,4 +35,4 @@ pretty_env_logger = "0.4.0"
|
||||
sentry = "0.31.5"
|
||||
|
||||
# Core
|
||||
revolt-result = { version = "0.7.16", path = "../result", optional = true }
|
||||
revolt-result = { version = "0.7.19", path = "../result", optional = true }
|
||||
|
||||
@@ -6,7 +6,7 @@ use futures_locks::RwLock;
|
||||
use once_cell::sync::Lazy;
|
||||
use serde::Deserialize;
|
||||
|
||||
pub use sentry::capture_error;
|
||||
pub use sentry::{capture_error, capture_message, Level};
|
||||
|
||||
#[cfg(feature = "report-macros")]
|
||||
#[macro_export]
|
||||
@@ -14,7 +14,10 @@ macro_rules! report_error {
|
||||
( $expr: expr, $error: ident $( $tt:tt )? ) => {
|
||||
$expr
|
||||
.inspect_err(|err| {
|
||||
$crate::capture_error(err);
|
||||
$crate::capture_message(
|
||||
&format!("{err:?} ({}:{}:{})", file!(), line!(), column!()),
|
||||
$crate::Level::Error,
|
||||
);
|
||||
})
|
||||
.map_err(|_| ::revolt_result::create_error!($error))
|
||||
};
|
||||
@@ -26,7 +29,10 @@ macro_rules! report_internal_error {
|
||||
( $expr: expr ) => {
|
||||
$expr
|
||||
.inspect_err(|err| {
|
||||
$crate::capture_error(err);
|
||||
$crate::capture_message(
|
||||
&format!("{err:?} ({}:{}:{})", file!(), line!(), column!()),
|
||||
$crate::Level::Error,
|
||||
);
|
||||
})
|
||||
.map_err(|_| ::revolt_result::create_error!(InternalError))
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "revolt-database"
|
||||
version = "0.7.16"
|
||||
version = "0.7.19"
|
||||
edition = "2021"
|
||||
license = "AGPL-3.0-or-later"
|
||||
authors = ["Paul Makles <me@insrt.uk>"]
|
||||
@@ -24,13 +24,15 @@ default = ["mongodb", "async-std-runtime", "tasks"]
|
||||
|
||||
[dependencies]
|
||||
# Core
|
||||
revolt-config = { version = "0.7.16", path = "../config" }
|
||||
revolt-result = { version = "0.7.16", path = "../result" }
|
||||
revolt-models = { version = "0.7.16", path = "../models", features = [
|
||||
revolt-config = { version = "0.7.19", path = "../config", features = [
|
||||
"report-macros",
|
||||
] }
|
||||
revolt-result = { version = "0.7.19", path = "../result" }
|
||||
revolt-models = { version = "0.7.19", path = "../models", features = [
|
||||
"validator",
|
||||
] }
|
||||
revolt-presence = { version = "0.7.16", path = "../presence" }
|
||||
revolt-permissions = { version = "0.7.16", path = "../permissions", features = [
|
||||
revolt-presence = { version = "0.7.19", path = "../presence" }
|
||||
revolt-permissions = { version = "0.7.19", path = "../permissions", features = [
|
||||
"serde",
|
||||
"bson",
|
||||
] }
|
||||
|
||||
@@ -2,7 +2,10 @@ use authifier::AuthifierEvent;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use revolt_models::v0::{
|
||||
AppendMessage, Channel, ChannelUnread, Emoji, FieldsChannel, FieldsMember, FieldsMessage, FieldsRole, FieldsServer, FieldsUser, FieldsWebhook, Member, MemberCompositeKey, Message, PartialChannel, PartialMember, PartialMessage, PartialRole, PartialServer, PartialUser, PartialWebhook, RemovalIntention, Report, Server, User, UserSettings, Webhook
|
||||
AppendMessage, Channel, ChannelUnread, Emoji, FieldsChannel, FieldsMember, FieldsMessage,
|
||||
FieldsRole, FieldsServer, FieldsUser, FieldsWebhook, Member, MemberCompositeKey, Message,
|
||||
PartialChannel, PartialMember, PartialMessage, PartialRole, PartialServer, PartialUser,
|
||||
PartialWebhook, RemovalIntention, Report, Server, User, UserSettings, Webhook,
|
||||
};
|
||||
use revolt_result::Error;
|
||||
|
||||
@@ -89,6 +92,7 @@ pub enum EventV1 {
|
||||
id: String,
|
||||
channel: String,
|
||||
data: PartialMessage,
|
||||
#[serde(default)]
|
||||
clear: Vec<FieldsMessage>,
|
||||
},
|
||||
|
||||
@@ -140,6 +144,7 @@ pub enum EventV1 {
|
||||
ServerUpdate {
|
||||
id: String,
|
||||
data: PartialServer,
|
||||
#[serde(default)]
|
||||
clear: Vec<FieldsServer>,
|
||||
},
|
||||
|
||||
@@ -150,6 +155,7 @@ pub enum EventV1 {
|
||||
ServerMemberUpdate {
|
||||
id: MemberCompositeKey,
|
||||
data: PartialMember,
|
||||
#[serde(default)]
|
||||
clear: Vec<FieldsMember>,
|
||||
},
|
||||
|
||||
@@ -168,6 +174,7 @@ pub enum EventV1 {
|
||||
id: String,
|
||||
role_id: String,
|
||||
data: PartialRole,
|
||||
#[serde(default)]
|
||||
clear: Vec<FieldsRole>,
|
||||
},
|
||||
|
||||
@@ -178,6 +185,7 @@ pub enum EventV1 {
|
||||
UserUpdate {
|
||||
id: String,
|
||||
data: PartialUser,
|
||||
#[serde(default)]
|
||||
clear: Vec<FieldsUser>,
|
||||
event_id: Option<String>,
|
||||
},
|
||||
@@ -212,6 +220,7 @@ pub enum EventV1 {
|
||||
ChannelUpdate {
|
||||
id: String,
|
||||
data: PartialChannel,
|
||||
#[serde(default)]
|
||||
clear: Vec<FieldsChannel>,
|
||||
},
|
||||
|
||||
|
||||
@@ -222,6 +222,12 @@ pub async fn create_database(db: &MongoDb) {
|
||||
"hash": 1_i32
|
||||
},
|
||||
"name": "hash"
|
||||
},
|
||||
{
|
||||
"key": {
|
||||
"used_for.id": 1_i32
|
||||
},
|
||||
"name": "used_for_id"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@ use crate::{
|
||||
bson::{doc, from_bson, from_document, to_document, Bson, DateTime, Document},
|
||||
options::FindOptions,
|
||||
},
|
||||
Invite, MongoDb, DISCRIMINATOR_SEARCH_SPACE,
|
||||
AbstractChannels, AbstractServers, Channel, Invite, MongoDb, DISCRIMINATOR_SEARCH_SPACE,
|
||||
};
|
||||
use bson::oid::ObjectId;
|
||||
use futures::StreamExt;
|
||||
@@ -20,7 +20,7 @@ struct MigrationInfo {
|
||||
revision: i32,
|
||||
}
|
||||
|
||||
pub const LATEST_REVISION: i32 = 29;
|
||||
pub const LATEST_REVISION: i32 = 30;
|
||||
|
||||
pub async fn migrate_database(db: &MongoDb) {
|
||||
let migrations = db.col::<Document>("migrations");
|
||||
@@ -1139,6 +1139,76 @@ pub async fn run_migrations(db: &MongoDb, revision: i32) -> i32 {
|
||||
.expect("Failed to create attachment_hashes index.");
|
||||
}
|
||||
|
||||
if revision <= 29 {
|
||||
info!("Running migration [revision 29 / 29-09-2024]: Add creator_id to webhooks.");
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
struct WebhookShell {
|
||||
_id: String,
|
||||
channel_id: String,
|
||||
}
|
||||
|
||||
let invites = db
|
||||
.db()
|
||||
.collection::<WebhookShell>("channel_webhooks")
|
||||
.find(doc! {}, None)
|
||||
.await
|
||||
.expect("webhooks")
|
||||
.filter_map(|s| async { s.ok() })
|
||||
.collect::<Vec<WebhookShell>>()
|
||||
.await;
|
||||
|
||||
for invite in invites {
|
||||
let channel = db.fetch_channel(&invite.channel_id).await.expect("channel");
|
||||
let creator_id = match channel {
|
||||
Channel::Group { owner, .. } => owner,
|
||||
Channel::TextChannel { server, .. } | Channel::VoiceChannel { server, .. } => {
|
||||
let server = db.fetch_server(&server).await.expect("server");
|
||||
server.owner
|
||||
}
|
||||
_ => unreachable!("not server or group channel!"),
|
||||
};
|
||||
|
||||
db.db()
|
||||
.collection::<Document>("channel_webhooks")
|
||||
.update_one(
|
||||
doc! {
|
||||
"_id": invite._id,
|
||||
},
|
||||
doc! {
|
||||
"$set" : {
|
||||
"creator_id": creator_id
|
||||
}
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("update webhook");
|
||||
}
|
||||
}
|
||||
|
||||
if revision <= 30 {
|
||||
info!("Running migration [revision 30 / 29-09-2024]: Add index for used_for.id to attachments.");
|
||||
|
||||
db.db()
|
||||
.run_command(
|
||||
doc! {
|
||||
"createIndexes": "attachments",
|
||||
"indexes": [
|
||||
{
|
||||
"key": {
|
||||
"used_for.id": 1_i32
|
||||
},
|
||||
"name": "used_for_id"
|
||||
}
|
||||
]
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("Failed to create attachments index.");
|
||||
}
|
||||
|
||||
// Need to migrate fields on attachments, change `user_id`, `object_id`, etc to `parent`.
|
||||
|
||||
// Reminder to update LATEST_REVISION when adding new migrations.
|
||||
|
||||
@@ -17,6 +17,9 @@ auto_derived_partial!(
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub avatar: Option<File>,
|
||||
|
||||
/// User that created this webhook
|
||||
pub creator_id: String,
|
||||
|
||||
/// The channel this webhook belongs to
|
||||
pub channel_id: String,
|
||||
|
||||
@@ -43,6 +46,7 @@ impl Default for Webhook {
|
||||
id: Default::default(),
|
||||
name: Default::default(),
|
||||
avatar: None,
|
||||
creator_id: Default::default(),
|
||||
channel_id: Default::default(),
|
||||
permissions: Default::default(),
|
||||
token: Default::default(),
|
||||
|
||||
@@ -261,7 +261,7 @@ impl AbstractChannels for MongoDb {
|
||||
|
||||
// Delete associated attachments
|
||||
self.delete_many_attachments(doc! {
|
||||
"object_id": &id
|
||||
"used_for.id": &id
|
||||
})
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -19,9 +19,11 @@ auto_derived_partial!(
|
||||
/// When this file was uploaded
|
||||
pub uploaded_at: Option<Timestamp>, // these are Option<>s to not break file uploads on legacy Autumn
|
||||
/// ID of user who uploaded this file
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub uploader_id: Option<String>, // these are Option<>s to not break file uploads on legacy Autumn
|
||||
|
||||
/// What the file was used for
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub used_for: Option<FileUsedFor>,
|
||||
|
||||
/// Whether this file was deleted
|
||||
@@ -63,6 +65,7 @@ auto_derived!(
|
||||
ServerBanner,
|
||||
Emoji,
|
||||
UserAvatar,
|
||||
WebhookAvatar,
|
||||
UserProfileBackground,
|
||||
LegacyGroupIcon,
|
||||
ChannelIcon,
|
||||
@@ -86,44 +89,154 @@ impl File {
|
||||
}
|
||||
|
||||
/// Use a file for a message attachment
|
||||
pub async fn use_attachment(db: &Database, id: &str, parent: &str) -> Result<File> {
|
||||
db.find_and_use_attachment(id, "attachments", "message", parent)
|
||||
.await
|
||||
pub async fn use_attachment(
|
||||
db: &Database,
|
||||
id: &str,
|
||||
parent: &str,
|
||||
uploader_id: &str,
|
||||
) -> Result<File> {
|
||||
db.find_and_use_attachment(
|
||||
id,
|
||||
"attachments",
|
||||
FileUsedFor {
|
||||
id: parent.to_owned(),
|
||||
object_type: FileUsedForType::Message,
|
||||
},
|
||||
uploader_id.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Use a file for a user profile background
|
||||
pub async fn use_background(db: &Database, id: &str, parent: &str) -> Result<File> {
|
||||
db.find_and_use_attachment(id, "backgrounds", "user", parent)
|
||||
.await
|
||||
pub async fn use_background(
|
||||
db: &Database,
|
||||
id: &str,
|
||||
parent: &str,
|
||||
uploader_id: &str,
|
||||
) -> Result<File> {
|
||||
db.find_and_use_attachment(
|
||||
id,
|
||||
"backgrounds",
|
||||
FileUsedFor {
|
||||
id: parent.to_owned(),
|
||||
object_type: FileUsedForType::UserProfileBackground,
|
||||
},
|
||||
uploader_id.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Use a file for a user avatar
|
||||
pub async fn use_avatar(db: &Database, id: &str, parent: &str) -> Result<File> {
|
||||
db.find_and_use_attachment(id, "avatars", "user", parent)
|
||||
.await
|
||||
pub async fn use_user_avatar(
|
||||
db: &Database,
|
||||
id: &str,
|
||||
parent: &str,
|
||||
uploader_id: &str,
|
||||
) -> Result<File> {
|
||||
db.find_and_use_attachment(
|
||||
id,
|
||||
"avatars",
|
||||
FileUsedFor {
|
||||
id: parent.to_owned(),
|
||||
object_type: FileUsedForType::UserAvatar,
|
||||
},
|
||||
uploader_id.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Use a file for an icon
|
||||
pub async fn use_icon(db: &Database, id: &str, parent: &str) -> Result<File> {
|
||||
db.find_and_use_attachment(id, "icons", "object", parent)
|
||||
.await
|
||||
/// Use a file for a webhook avatar
|
||||
pub async fn use_webhook_avatar(
|
||||
db: &Database,
|
||||
id: &str,
|
||||
parent: &str,
|
||||
uploader_id: &str,
|
||||
) -> Result<File> {
|
||||
db.find_and_use_attachment(
|
||||
id,
|
||||
"avatars",
|
||||
FileUsedFor {
|
||||
id: parent.to_owned(),
|
||||
object_type: FileUsedForType::WebhookAvatar,
|
||||
},
|
||||
uploader_id.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Use a file for a server icon
|
||||
pub async fn use_server_icon(db: &Database, id: &str, parent: &str) -> Result<File> {
|
||||
db.find_and_use_attachment(id, "icons", "object", parent)
|
||||
.await
|
||||
pub async fn use_server_icon(
|
||||
db: &Database,
|
||||
id: &str,
|
||||
parent: &str,
|
||||
uploader_id: &str,
|
||||
) -> Result<File> {
|
||||
db.find_and_use_attachment(
|
||||
id,
|
||||
"icons",
|
||||
FileUsedFor {
|
||||
id: parent.to_owned(),
|
||||
object_type: FileUsedForType::ServerIcon,
|
||||
},
|
||||
uploader_id.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Use a file for a channel icon
|
||||
pub async fn use_channel_icon(
|
||||
db: &Database,
|
||||
id: &str,
|
||||
parent: &str,
|
||||
uploader_id: &str,
|
||||
) -> Result<File> {
|
||||
db.find_and_use_attachment(
|
||||
id,
|
||||
"icons",
|
||||
FileUsedFor {
|
||||
id: parent.to_owned(),
|
||||
object_type: FileUsedForType::ChannelIcon,
|
||||
},
|
||||
uploader_id.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Use a file for a server banner
|
||||
pub async fn use_banner(db: &Database, id: &str, parent: &str) -> Result<File> {
|
||||
db.find_and_use_attachment(id, "banners", "server", parent)
|
||||
.await
|
||||
pub async fn use_server_banner(
|
||||
db: &Database,
|
||||
id: &str,
|
||||
parent: &str,
|
||||
uploader_id: &str,
|
||||
) -> Result<File> {
|
||||
db.find_and_use_attachment(
|
||||
id,
|
||||
"banners",
|
||||
FileUsedFor {
|
||||
id: parent.to_owned(),
|
||||
object_type: FileUsedForType::ServerBanner,
|
||||
},
|
||||
uploader_id.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Use a file for an emoji
|
||||
pub async fn use_emoji(db: &Database, id: &str, parent: &str) -> Result<File> {
|
||||
db.find_and_use_attachment(id, "emojis", "object", parent)
|
||||
.await
|
||||
pub async fn use_emoji(
|
||||
db: &Database,
|
||||
id: &str,
|
||||
parent: &str,
|
||||
uploader_id: &str,
|
||||
) -> Result<File> {
|
||||
db.find_and_use_attachment(
|
||||
id,
|
||||
"emojis",
|
||||
FileUsedFor {
|
||||
id: parent.to_owned(),
|
||||
object_type: FileUsedForType::Emoji,
|
||||
},
|
||||
uploader_id.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ use revolt_result::Result;
|
||||
|
||||
use crate::File;
|
||||
|
||||
use super::FileUsedFor;
|
||||
|
||||
mod mongodb;
|
||||
mod reference;
|
||||
|
||||
@@ -18,8 +20,8 @@ pub trait AbstractAttachments: Sync + Send {
|
||||
&self,
|
||||
id: &str,
|
||||
tag: &str,
|
||||
parent_type: &str,
|
||||
parent_id: &str,
|
||||
used_for: FileUsedFor,
|
||||
uploader_id: String,
|
||||
) -> Result<File>;
|
||||
|
||||
/// Mark an attachment as having been reported.
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
use bson::to_document;
|
||||
use bson::Document;
|
||||
use revolt_config::report_internal_error;
|
||||
use revolt_result::Result;
|
||||
|
||||
use crate::File;
|
||||
use crate::FileUsedFor;
|
||||
use crate::MongoDb;
|
||||
|
||||
use super::AbstractAttachments;
|
||||
@@ -34,10 +37,9 @@ impl AbstractAttachments for MongoDb {
|
||||
&self,
|
||||
id: &str,
|
||||
tag: &str,
|
||||
parent_type: &str,
|
||||
parent_id: &str,
|
||||
used_for: FileUsedFor,
|
||||
uploader_id: String,
|
||||
) -> Result<File> {
|
||||
let key = format!("{parent_type}_id");
|
||||
let file = query!(
|
||||
self,
|
||||
find_one,
|
||||
@@ -45,7 +47,7 @@ impl AbstractAttachments for MongoDb {
|
||||
doc! {
|
||||
"_id": id,
|
||||
"tag": tag,
|
||||
&key: {
|
||||
"used_for": {
|
||||
"$exists": false
|
||||
}
|
||||
}
|
||||
@@ -59,7 +61,8 @@ impl AbstractAttachments for MongoDb {
|
||||
},
|
||||
doc! {
|
||||
"$set": {
|
||||
key: parent_id
|
||||
"used_for": report_internal_error!(to_document(&used_for))?,
|
||||
"uploader_id": uploader_id
|
||||
}
|
||||
},
|
||||
None,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use revolt_result::Result;
|
||||
|
||||
use crate::File;
|
||||
use crate::FileUsedFor;
|
||||
use crate::ReferenceDb;
|
||||
|
||||
use super::AbstractAttachments;
|
||||
@@ -37,19 +38,14 @@ impl AbstractAttachments for ReferenceDb {
|
||||
&self,
|
||||
id: &str,
|
||||
tag: &str,
|
||||
parent_type: &str,
|
||||
parent_id: &str,
|
||||
used_for: FileUsedFor,
|
||||
uploader_id: String,
|
||||
) -> Result<File> {
|
||||
let mut files = self.files.lock().await;
|
||||
if let Some(file) = files.get_mut(id) {
|
||||
if file.tag == tag {
|
||||
match parent_type {
|
||||
"message" => file.message_id = Some(parent_id.to_owned()),
|
||||
"user" => file.user_id = Some(parent_id.to_owned()),
|
||||
"object" => file.object_id = Some(parent_id.to_owned()),
|
||||
"server" => file.server_id = Some(parent_id.to_owned()),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
file.uploader_id = Some(uploader_id);
|
||||
file.used_for = Some(used_for);
|
||||
|
||||
Ok(file.clone())
|
||||
} else {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::collections::HashSet;
|
||||
use std::{collections::HashSet, hash::RandomState};
|
||||
|
||||
use indexmap::{IndexMap, IndexSet};
|
||||
use iso8601_timestamp::Timestamp;
|
||||
@@ -338,7 +338,34 @@ impl Message {
|
||||
}
|
||||
|
||||
if !mentions.is_empty() {
|
||||
message.mentions.replace(mentions.into_iter().collect());
|
||||
// FIXME: temp fix to stop spam attacks
|
||||
match channel {
|
||||
Channel::DirectMessage { ref recipients, .. }
|
||||
| Channel::Group { ref recipients, .. } => {
|
||||
let recipients_hash: HashSet<&String, RandomState> =
|
||||
HashSet::from_iter(recipients.iter());
|
||||
|
||||
mentions.retain(|m| recipients_hash.contains(m));
|
||||
}
|
||||
Channel::TextChannel { ref server, .. }
|
||||
| Channel::VoiceChannel { ref server, .. } => {
|
||||
let mentions_vec = Vec::from_iter(mentions.iter().cloned());
|
||||
let valid_members = db.fetch_members(server.as_str(), &mentions_vec[..]).await;
|
||||
if let Ok(valid_members) = valid_members {
|
||||
let valid_ids: HashSet<String, RandomState> = HashSet::from_iter(
|
||||
valid_members.iter().map(|member| member.id.user.clone()),
|
||||
);
|
||||
mentions.retain(|m| valid_ids.contains(m));
|
||||
} else {
|
||||
revolt_config::capture_error(&valid_members.unwrap_err());
|
||||
}
|
||||
}
|
||||
Channel::SavedMessages { .. } => mentions.clear(),
|
||||
}
|
||||
|
||||
if !mentions.is_empty() {
|
||||
message.mentions.replace(mentions.into_iter().collect());
|
||||
}
|
||||
}
|
||||
|
||||
if !replies.is_empty() {
|
||||
@@ -370,10 +397,8 @@ impl Message {
|
||||
}
|
||||
|
||||
for attachment_id in data.attachments.as_deref().unwrap_or_default() {
|
||||
attachments.push(
|
||||
db.find_and_use_attachment(attachment_id, "attachments", "message", &message_id)
|
||||
.await?,
|
||||
);
|
||||
attachments
|
||||
.push(File::use_attachment(db, attachment_id, &message_id, author.id()).await?);
|
||||
}
|
||||
|
||||
if !attachments.is_empty() {
|
||||
@@ -499,10 +524,7 @@ impl Message {
|
||||
})?;
|
||||
|
||||
let media = if let Some(id) = embed.media {
|
||||
Some(
|
||||
db.find_and_use_attachment(&id, "attachments", "message", &self.id)
|
||||
.await?,
|
||||
)
|
||||
Some(File::use_attachment(db, &id, &self.id, &self.author).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -661,7 +683,7 @@ impl Message {
|
||||
) -> Result<()> {
|
||||
let media: Option<v0::File> = if let Some(id) = embed.media {
|
||||
Some(
|
||||
db.find_and_use_attachment(&id, "attachments", "message", &self.id)
|
||||
File::use_attachment(db, &id, &self.id, &self.author)
|
||||
.await?
|
||||
.into(),
|
||||
)
|
||||
|
||||
@@ -260,7 +260,7 @@ impl MongoDb {
|
||||
|
||||
// Update many attachments with parent id.
|
||||
self.delete_many_attachments(doc! {
|
||||
"object_id": &server_id
|
||||
"used_for.id": &server_id
|
||||
})
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ use crate::Database;
|
||||
|
||||
/// Payload information, before assembly
|
||||
#[derive(Debug)]
|
||||
#[allow(non_snake_case)]
|
||||
pub struct ApnPayload {
|
||||
message: Message,
|
||||
url: String,
|
||||
@@ -29,6 +30,7 @@ pub struct ApnPayload {
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
#[allow(non_snake_case)]
|
||||
struct Payload<'a> {
|
||||
aps: APS<'a>,
|
||||
#[serde(skip_serializing)]
|
||||
|
||||
@@ -11,7 +11,7 @@ use base64::{
|
||||
use deadqueue::limited::Queue;
|
||||
use fcm_v1::auth::{Authenticator, ServiceAccountKey};
|
||||
use once_cell::sync::Lazy;
|
||||
use revolt_config::config;
|
||||
use revolt_config::{config, report_internal_error};
|
||||
use revolt_models::v0::PushNotification;
|
||||
use revolt_presence::filter_online;
|
||||
use serde_json::json;
|
||||
@@ -116,12 +116,11 @@ pub async fn worker(db: Database, authifier_db: AuthifierDatabase) {
|
||||
if fcm_error.contains("404 (Not Found)") {
|
||||
println!("Unregistering {:?}", session.id);
|
||||
|
||||
if let Err(err) = db
|
||||
.remove_push_subscription_by_session_id(&session.id)
|
||||
.await
|
||||
{
|
||||
revolt_config::capture_error(&err);
|
||||
}
|
||||
report_internal_error!(
|
||||
db.remove_push_subscription_by_session_id(&session.id)
|
||||
.await
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -108,6 +108,7 @@ impl From<crate::Webhook> for Webhook {
|
||||
id: value.id,
|
||||
name: value.name,
|
||||
avatar: value.avatar.map(|file| file.into()),
|
||||
creator_id: value.creator_id,
|
||||
channel_id: value.channel_id,
|
||||
token: value.token,
|
||||
permissions: value.permissions,
|
||||
@@ -121,6 +122,7 @@ impl From<crate::PartialWebhook> for PartialWebhook {
|
||||
id: value.id,
|
||||
name: value.name,
|
||||
avatar: value.avatar.map(|file| file.into()),
|
||||
creator_id: value.creator_id,
|
||||
channel_id: value.channel_id,
|
||||
token: value.token,
|
||||
permissions: value.permissions,
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
[package]
|
||||
name = "revolt-files"
|
||||
version = "0.7.16"
|
||||
version = "0.7.19"
|
||||
edition = "2021"
|
||||
license = "AGPL-3.0-or-later"
|
||||
authors = ["Paul Makles <me@insrt.uk>"]
|
||||
description = "Revolt Backend: S3 and encryption subroutines"
|
||||
|
||||
[dependencies]
|
||||
tracing = "0.1"
|
||||
|
||||
ffprobe = "0.4.0"
|
||||
imagesize = "0.13.0"
|
||||
tempfile = "3.12.0"
|
||||
|
||||
base64 = "0.22.1"
|
||||
aes-gcm = "0.10.3"
|
||||
typenum = "1.17.0"
|
||||
@@ -14,7 +20,19 @@ typenum = "1.17.0"
|
||||
aws-config = "1.5.5"
|
||||
aws-sdk-s3 = { version = "1.46.0", features = ["behavior-version-latest"] }
|
||||
|
||||
revolt-config = { version = "0.7.16", path = "../config", features = [
|
||||
revolt-config = { version = "0.7.19", path = "../config", features = [
|
||||
"report-macros",
|
||||
] }
|
||||
revolt-result = { version = "0.7.16", path = "../result" }
|
||||
revolt-result = { version = "0.7.19", path = "../result" }
|
||||
|
||||
# image processing
|
||||
jxl-oxide = "0.8.1"
|
||||
image = { version = "0.25.2" }
|
||||
|
||||
# svg rendering
|
||||
usvg = "0.44.0"
|
||||
resvg = "0.44.0"
|
||||
tiny-skia = "0.11.4"
|
||||
|
||||
# encoding
|
||||
webp = "0.3.0"
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
use std::io::Write;
|
||||
use std::io::{BufRead, Read, Seek, Write};
|
||||
|
||||
use aes_gcm::{
|
||||
aead::{AeadCore, AeadMutInPlace, OsRng},
|
||||
Aes256Gcm, Key, KeyInit, Nonce,
|
||||
};
|
||||
use image::{DynamicImage, ImageBuffer};
|
||||
use revolt_config::{config, report_internal_error, FilesS3};
|
||||
use revolt_result::{create_error, Result};
|
||||
|
||||
@@ -13,6 +14,8 @@ use aws_sdk_s3::{
|
||||
};
|
||||
|
||||
use base64::prelude::*;
|
||||
use tempfile::NamedTempFile;
|
||||
use tiny_skia::Pixmap;
|
||||
|
||||
/// Size of the authentication tag in the buffer
|
||||
pub const AUTHENTICATION_TAG_SIZE_BYTES: usize = 16;
|
||||
@@ -62,6 +65,11 @@ pub async fn fetch_from_s3(bucket_id: &str, path: &str, nonce: &str) -> Result<V
|
||||
// we just want the Vec<u8>
|
||||
}
|
||||
|
||||
// File is not encrypted
|
||||
if nonce.is_empty() {
|
||||
return Ok(buf);
|
||||
}
|
||||
|
||||
// Recover nonce as bytes
|
||||
let nonce = &BASE64_STANDARD.decode(nonce).unwrap()[..];
|
||||
let nonce: &Nonce<typenum::consts::U12> = nonce.into();
|
||||
@@ -103,3 +111,162 @@ pub async fn upload_to_s3(bucket_id: &str, path: &str, buf: &[u8]) -> Result<Str
|
||||
|
||||
Ok(BASE64_STANDARD.encode(nonce))
|
||||
}
|
||||
|
||||
/// Determine size of image at temp file
|
||||
pub fn image_size(f: &NamedTempFile) -> Option<(usize, usize)> {
|
||||
if let Ok(size) = imagesize::size(f.path())
|
||||
.inspect_err(|err| tracing::error!("Failed to generate image size! {err:?}"))
|
||||
{
|
||||
Some((size.width, size.height))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Determine size of image with buffer
|
||||
pub fn image_size_vec(v: &[u8], mime: &str) -> Option<(usize, usize)> {
|
||||
match mime {
|
||||
"image/svg+xml" => {
|
||||
let tree =
|
||||
report_internal_error!(usvg::Tree::from_data(v, &Default::default())).ok()?;
|
||||
|
||||
let size = tree.size();
|
||||
Some((size.width() as usize, size.height() as usize))
|
||||
}
|
||||
_ => {
|
||||
if let Ok(size) = imagesize::blob_size(v)
|
||||
.inspect_err(|err| tracing::error!("Failed to generate image size! {err:?}"))
|
||||
{
|
||||
Some((size.width, size.height))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Determine size of video at temp file
|
||||
pub fn video_size(f: &NamedTempFile) -> Option<(i64, i64)> {
|
||||
if let Ok(data) = ffprobe::ffprobe(f.path())
|
||||
.inspect_err(|err| tracing::error!("Failed to ffprobe file! {err:?}"))
|
||||
{
|
||||
// Use first valid stream
|
||||
for stream in data.streams {
|
||||
if let (Some(w), Some(h)) = (stream.width, stream.height) {
|
||||
return Some((w, h));
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode image from reader
|
||||
pub fn decode_image<R: Read + BufRead + Seek>(reader: &mut R, mime: &str) -> Result<DynamicImage> {
|
||||
match mime {
|
||||
// Read image using jxl-oxide crate
|
||||
"image/jxl" => {
|
||||
let jxl_image = report_internal_error!(jxl_oxide::JxlImage::builder().read(reader))?;
|
||||
if let Ok(frame) = jxl_image.render_frame(0) {
|
||||
match frame.color_channels().len() {
|
||||
3 => Ok(DynamicImage::ImageRgb8(
|
||||
DynamicImage::ImageRgb32F(
|
||||
ImageBuffer::from_vec(
|
||||
jxl_image.width(),
|
||||
jxl_image.height(),
|
||||
frame.image().buf().to_vec(),
|
||||
)
|
||||
.ok_or_else(|| create_error!(ImageProcessingFailed))?,
|
||||
)
|
||||
.to_rgb8(),
|
||||
)),
|
||||
4 => Ok(DynamicImage::ImageRgba8(
|
||||
DynamicImage::ImageRgba32F(
|
||||
ImageBuffer::from_vec(
|
||||
jxl_image.width(),
|
||||
jxl_image.height(),
|
||||
frame.image().buf().to_vec(),
|
||||
)
|
||||
.ok_or_else(|| create_error!(ImageProcessingFailed))?,
|
||||
)
|
||||
.to_rgba8(),
|
||||
)),
|
||||
_ => Err(create_error!(ImageProcessingFailed)),
|
||||
}
|
||||
} else {
|
||||
Err(create_error!(ImageProcessingFailed))
|
||||
}
|
||||
}
|
||||
// Read image using resvg
|
||||
"image/svg+xml" => {
|
||||
// usvg doesn't support Read trait so copy to buffer
|
||||
let mut buf = Vec::new();
|
||||
report_internal_error!(reader.read_to_end(&mut buf))?;
|
||||
|
||||
let tree = report_internal_error!(usvg::Tree::from_data(&buf, &Default::default()))?;
|
||||
let size = tree.size();
|
||||
let mut pixmap = Pixmap::new(size.width() as u32, size.height() as u32)
|
||||
.ok_or_else(|| create_error!(ImageProcessingFailed))?;
|
||||
|
||||
let mut pixmap_mut = pixmap.as_mut();
|
||||
resvg::render(&tree, Default::default(), &mut pixmap_mut);
|
||||
|
||||
Ok(DynamicImage::ImageRgba8(
|
||||
ImageBuffer::from_vec(
|
||||
size.width() as u32,
|
||||
size.height() as u32,
|
||||
pixmap.data().to_vec(),
|
||||
)
|
||||
.ok_or_else(|| create_error!(ImageProcessingFailed))?,
|
||||
))
|
||||
}
|
||||
// Check if we can read using image-rs crate
|
||||
_ => report_internal_error!(report_internal_error!(
|
||||
image::ImageReader::new(reader).with_guessed_format()
|
||||
)?
|
||||
.decode()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether given reader has a valid image
|
||||
pub fn is_valid_image<R: Read + BufRead + Seek>(reader: &mut R, mime: &str) -> bool {
|
||||
match mime {
|
||||
// Check if we can read using jxl-oxide crate
|
||||
"image/jxl" => jxl_oxide::JxlImage::builder()
|
||||
.read(reader)
|
||||
.inspect_err(|err| tracing::error!("Failed to read JXL! {err:?}"))
|
||||
.is_ok(),
|
||||
// Check if we can read using image-rs crate
|
||||
_ => !matches!(
|
||||
image::ImageReader::new(reader)
|
||||
.with_guessed_format()
|
||||
.inspect_err(|err| tracing::error!("Failed to read image! {err:?}"))
|
||||
.map(|f| f.decode()),
|
||||
Err(_) | Ok(Err(_))
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create thumbnail from given image
|
||||
pub async fn create_thumbnail(image: DynamicImage, tag: &str) -> Vec<u8> {
|
||||
// Load configuration
|
||||
let config = config().await;
|
||||
let [w, h] = config.files.preview.get(tag).unwrap();
|
||||
|
||||
// Create thumbnail
|
||||
//.resize(width as u32, height as u32, image::imageops::FilterType::Gaussian)
|
||||
// resize is about 2.5x slower,
|
||||
// thumbnail doesn't have terrible quality
|
||||
// so we use thumbnail
|
||||
let image = image.thumbnail(image.width().min(*w as u32), image.height().min(*h as u32));
|
||||
|
||||
// Encode it into WEBP
|
||||
let encoder = webp::Encoder::from_image(&image).expect("Could not create encoder.");
|
||||
if config.files.webp_quality != 100.0 {
|
||||
encoder.encode(config.files.webp_quality).to_vec()
|
||||
} else {
|
||||
encoder.encode_lossless().to_vec()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "revolt-models"
|
||||
version = "0.7.16"
|
||||
version = "0.7.19"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
authors = ["Paul Makles <me@insrt.uk>"]
|
||||
@@ -20,8 +20,8 @@ default = ["serde", "partials", "rocket"]
|
||||
|
||||
[dependencies]
|
||||
# Core
|
||||
revolt-config = { version = "0.7.16", path = "../config" }
|
||||
revolt-permissions = { version = "0.7.16", path = "../permissions" }
|
||||
revolt-config = { version = "0.7.19", path = "../config" }
|
||||
revolt-permissions = { version = "0.7.19", path = "../permissions" }
|
||||
|
||||
# Utility
|
||||
regex = "1"
|
||||
|
||||
@@ -16,6 +16,9 @@ auto_derived_partial!(
|
||||
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
|
||||
pub avatar: Option<File>,
|
||||
|
||||
/// User that created this webhook
|
||||
pub creator_id: String,
|
||||
|
||||
/// The channel this webhook belongs to
|
||||
pub channel_id: String,
|
||||
|
||||
|
||||
@@ -14,9 +14,9 @@ auto_derived!(
|
||||
/// URL to the original image
|
||||
pub url: String,
|
||||
/// Width of the image
|
||||
pub width: isize,
|
||||
pub width: usize,
|
||||
/// Height of the image
|
||||
pub height: isize,
|
||||
pub height: usize,
|
||||
/// Positioning and size
|
||||
pub size: ImageSize,
|
||||
}
|
||||
@@ -26,9 +26,9 @@ auto_derived!(
|
||||
/// URL to the original video
|
||||
pub url: String,
|
||||
/// Width of the video
|
||||
pub width: isize,
|
||||
pub width: usize,
|
||||
/// Height of the video
|
||||
pub height: isize,
|
||||
pub height: usize,
|
||||
}
|
||||
|
||||
/// Type of remote Twitch content
|
||||
@@ -86,7 +86,7 @@ auto_derived!(
|
||||
},
|
||||
AppleMusic {
|
||||
album_id: String,
|
||||
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
track_id: Option<String>,
|
||||
},
|
||||
@@ -119,8 +119,6 @@ auto_derived!(
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub video: Option<Video>,
|
||||
|
||||
// #[serde(skip_serializing_if = "Option::is_none")]
|
||||
// opengraph_type: Option<String>,
|
||||
/// Site name
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub site_name: Option<String>,
|
||||
@@ -156,11 +154,56 @@ auto_derived!(
|
||||
|
||||
/// Embed
|
||||
#[serde(tag = "type")]
|
||||
#[derive(Default)]
|
||||
pub enum Embed {
|
||||
Website(WebsiteMetadata),
|
||||
Image(Image),
|
||||
Video(Video),
|
||||
Text(Text),
|
||||
#[default]
|
||||
None,
|
||||
}
|
||||
);
|
||||
|
||||
impl WebsiteMetadata {
|
||||
/// Truncate strings in metadata
|
||||
pub fn truncate(&mut self) {
|
||||
if let Some(s) = self.url.as_mut() {
|
||||
s.truncate(256);
|
||||
}
|
||||
|
||||
if let Some(s) = self.original_url.as_mut() {
|
||||
s.truncate(256);
|
||||
}
|
||||
|
||||
if let Some(s) = self.title.as_mut() {
|
||||
s.truncate(100);
|
||||
}
|
||||
|
||||
if let Some(s) = self.description.as_mut() {
|
||||
s.truncate(1000);
|
||||
}
|
||||
|
||||
if let Some(s) = self.site_name.as_mut() {
|
||||
s.truncate(32);
|
||||
}
|
||||
|
||||
if let Some(s) = self.icon_url.as_mut() {
|
||||
s.truncate(256);
|
||||
}
|
||||
|
||||
if let Some(s) = self.colour.as_mut() {
|
||||
s.truncate(32);
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this is considered "empty"
|
||||
pub fn is_empty(&self) -> bool {
|
||||
(self.title.is_none() || self.title.as_ref().is_some_and(|f| f.is_empty()))
|
||||
&& (self.description.is_none()
|
||||
|| self.description.as_ref().is_some_and(|f| f.is_empty()))
|
||||
&& self.special.is_none()
|
||||
&& self.video.is_none()
|
||||
&& self.image.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "revolt-permissions"
|
||||
version = "0.7.16"
|
||||
version = "0.7.19"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
authors = ["Paul Makles <me@insrt.uk>"]
|
||||
@@ -21,7 +21,7 @@ async-std = { version = "1.8.0", features = ["attributes"] }
|
||||
|
||||
[dependencies]
|
||||
# Core
|
||||
revolt-result = { version = "0.7.16", path = "../result" }
|
||||
revolt-result = { version = "0.7.19", path = "../result" }
|
||||
|
||||
# Utility
|
||||
auto_ops = "0.3.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "revolt-presence"
|
||||
version = "0.7.16"
|
||||
version = "0.7.19"
|
||||
edition = "2021"
|
||||
license = "AGPL-3.0-or-later"
|
||||
authors = ["Paul Makles <me@insrt.uk>"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "revolt-result"
|
||||
version = "0.7.16"
|
||||
version = "0.7.19"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
authors = ["Paul Makles <me@insrt.uk>"]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use axum::{http::StatusCode, response::IntoResponse, Json};
|
||||
use rocket::http::Status;
|
||||
|
||||
use crate::{Error, ErrorType};
|
||||
|
||||
@@ -78,6 +79,8 @@ impl IntoResponse for Error {
|
||||
ErrorType::FileTooSmall => StatusCode::UNPROCESSABLE_ENTITY,
|
||||
ErrorType::FileTooLarge { .. } => StatusCode::UNPROCESSABLE_ENTITY,
|
||||
ErrorType::FileTypeNotAllowed => StatusCode::BAD_REQUEST,
|
||||
ErrorType::ImageProcessingFailed => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ErrorType::NoEmbedData => StatusCode::BAD_REQUEST,
|
||||
};
|
||||
|
||||
(status, Json(&self)).into_response()
|
||||
|
||||
@@ -162,6 +162,8 @@ pub enum ErrorType {
|
||||
max: usize,
|
||||
},
|
||||
FileTypeNotAllowed,
|
||||
ImageProcessingFailed,
|
||||
NoEmbedData,
|
||||
|
||||
// ? Legacy errors
|
||||
VosoUnavailable,
|
||||
|
||||
@@ -84,6 +84,8 @@ impl<'r> Responder<'r, 'static> for Error {
|
||||
ErrorType::FileTooSmall => Status::UnprocessableEntity,
|
||||
ErrorType::FileTooLarge { .. } => Status::UnprocessableEntity,
|
||||
ErrorType::FileTypeNotAllowed => Status::BadRequest,
|
||||
ErrorType::ImageProcessingFailed => Status::InternalServerError,
|
||||
ErrorType::NoEmbedData => Status::BadRequest,
|
||||
};
|
||||
|
||||
// Serialize the error data structure into JSON.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "revolt-delta"
|
||||
version = "0.7.16"
|
||||
version = "0.7.19"
|
||||
license = "AGPL-3.0-or-later"
|
||||
authors = ["Paul Makles <paulmakles@gmail.com>"]
|
||||
edition = "2018"
|
||||
|
||||
@@ -7,6 +7,5 @@ COPY --from=builder /home/rust/src/target/release/revolt-delta ./
|
||||
|
||||
EXPOSE 8000
|
||||
ENV ROCKET_ADDRESS 0.0.0.0
|
||||
ENV ROCKET_PORT 8000
|
||||
USER nonroot
|
||||
CMD ["./revolt-delta"]
|
||||
|
||||
@@ -124,7 +124,7 @@ pub async fn edit(
|
||||
}
|
||||
|
||||
if let Some(icon_id) = data.icon {
|
||||
partial.icon = Some(File::use_icon(db, &icon_id, id).await?);
|
||||
partial.icon = Some(File::use_channel_icon(db, &icon_id, id, &user.id).await?);
|
||||
*icon = partial.icon.clone();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use revolt_database::{
|
||||
util::{permissions::DatabasePermissionQuery, reference::Reference},
|
||||
Channel, Database, User, Webhook,
|
||||
Channel, Database, File, User, Webhook,
|
||||
};
|
||||
use revolt_models::v0;
|
||||
use revolt_permissions::{
|
||||
@@ -43,10 +43,7 @@ pub async fn create_webhook(
|
||||
let webhook_id = Ulid::new().to_string();
|
||||
|
||||
let avatar = match &data.avatar {
|
||||
Some(id) => Some(
|
||||
db.find_and_use_attachment(id, "avatars", "user", &webhook_id)
|
||||
.await?,
|
||||
),
|
||||
Some(id) => Some(File::use_webhook_avatar(db, id, &webhook_id, &user.id).await?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
@@ -54,6 +51,7 @@ pub async fn create_webhook(
|
||||
id: webhook_id,
|
||||
name: data.name,
|
||||
avatar,
|
||||
creator_id: user.id,
|
||||
channel_id: channel.id().to_string(),
|
||||
permissions: *DEFAULT_WEBHOOK_PERMISSIONS,
|
||||
token: Some(nanoid::nanoid!(64)),
|
||||
|
||||
@@ -55,7 +55,7 @@ pub async fn create_emoji(
|
||||
};
|
||||
|
||||
// Find the relevant attachment
|
||||
let attachment = File::use_emoji(db, &id, &id).await?;
|
||||
let attachment = File::use_emoji(db, &id, &id, &user.id).await?;
|
||||
|
||||
// Create the emoji object
|
||||
let emoji = Emoji {
|
||||
|
||||
@@ -142,7 +142,7 @@ pub async fn edit(
|
||||
|
||||
// 2. Apply new avatar
|
||||
if let Some(avatar) = avatar {
|
||||
partial.avatar = Some(File::use_avatar(db, &avatar, &user.id).await?);
|
||||
partial.avatar = Some(File::use_user_avatar(db, &avatar, &user.id, &user.id).await?);
|
||||
}
|
||||
|
||||
member
|
||||
|
||||
@@ -138,13 +138,13 @@ pub async fn edit(
|
||||
|
||||
// 3. Apply new icon
|
||||
if let Some(icon) = icon {
|
||||
partial.icon = Some(File::use_server_icon(db, &icon, &server.id).await?);
|
||||
partial.icon = Some(File::use_server_icon(db, &icon, &server.id, &user.id).await?);
|
||||
server.icon = partial.icon.clone();
|
||||
}
|
||||
|
||||
// 4. Apply new banner
|
||||
if let Some(banner) = banner {
|
||||
partial.banner = Some(File::use_banner(db, &banner, &server.id).await?);
|
||||
partial.banner = Some(File::use_server_banner(db, &banner, &server.id, &user.id).await?);
|
||||
server.banner = partial.banner.clone();
|
||||
}
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ pub async fn edit(
|
||||
|
||||
// 2. Apply new avatar
|
||||
if let Some(avatar) = data.avatar {
|
||||
partial.avatar = Some(File::use_avatar(db, &avatar, &user.id).await?);
|
||||
partial.avatar = Some(File::use_user_avatar(db, &avatar, &user.id, &user.id).await?);
|
||||
}
|
||||
|
||||
// 3. Apply new status
|
||||
@@ -114,7 +114,8 @@ pub async fn edit(
|
||||
}
|
||||
|
||||
if let Some(background) = profile.background {
|
||||
new_profile.background = Some(File::use_background(db, &background, &user.id).await?);
|
||||
new_profile.background =
|
||||
Some(File::use_background(db, &background, &user.id, &user.id).await?);
|
||||
}
|
||||
|
||||
partial.profile = Some(new_profile);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use revolt_database::{
|
||||
util::{permissions::DatabasePermissionQuery, reference::Reference},
|
||||
Database, PartialWebhook, User,
|
||||
Database, File, PartialWebhook, User,
|
||||
};
|
||||
use revolt_models::v0::{DataEditWebhook, Webhook};
|
||||
use revolt_permissions::{calculate_channel_permissions, ChannelPermission};
|
||||
@@ -52,10 +52,7 @@ pub async fn webhook_edit(
|
||||
};
|
||||
|
||||
if let Some(avatar) = avatar {
|
||||
let file = db
|
||||
.find_and_use_attachment(&avatar, "avatars", "user", &webhook.id)
|
||||
.await?;
|
||||
|
||||
let file = File::use_webhook_avatar(db, &avatar, &webhook.id, &webhook.creator_id).await?;
|
||||
partial.avatar = Some(file)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use revolt_database::util::reference::Reference;
|
||||
use revolt_database::{Database, PartialWebhook};
|
||||
use revolt_database::{Database, File, PartialWebhook};
|
||||
use revolt_models::v0::{DataEditWebhook, Webhook};
|
||||
use revolt_models::validator::Validate;
|
||||
use revolt_result::{create_error, Result};
|
||||
@@ -34,7 +34,7 @@ pub async fn webhook_edit_token(
|
||||
name,
|
||||
avatar,
|
||||
permissions,
|
||||
remove
|
||||
remove,
|
||||
} = data;
|
||||
|
||||
let mut partial = PartialWebhook {
|
||||
@@ -44,10 +44,7 @@ pub async fn webhook_edit_token(
|
||||
};
|
||||
|
||||
if let Some(avatar) = avatar {
|
||||
let file = db
|
||||
.find_and_use_attachment(&avatar, "avatars", "user", &webhook.id)
|
||||
.await?;
|
||||
|
||||
let file = File::use_webhook_avatar(db, &avatar, &webhook.id, &webhook.creator_id).await?;
|
||||
partial.avatar = Some(file)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "revolt-autumn"
|
||||
version = "0.7.14"
|
||||
version = "0.7.19"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
@@ -42,12 +42,12 @@ tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
# Core crates
|
||||
revolt-files = { version = "0.7.16", path = "../../core/files" }
|
||||
revolt-config = { version = "0.7.16", path = "../../core/config" }
|
||||
revolt-database = { version = "0.7.16", path = "../../core/database", features = [
|
||||
revolt-files = { version = "0.7.19", path = "../../core/files" }
|
||||
revolt-config = { version = "0.7.19", path = "../../core/config" }
|
||||
revolt-database = { version = "0.7.19", path = "../../core/database", features = [
|
||||
"axum-impl",
|
||||
] }
|
||||
revolt-result = { version = "0.7.16", path = "../../core/result", features = [
|
||||
revolt-result = { version = "0.7.19", path = "../../core/result", features = [
|
||||
"utoipa",
|
||||
"axum",
|
||||
] }
|
||||
|
||||
@@ -11,11 +11,12 @@ use axum::{
|
||||
Json, Router,
|
||||
};
|
||||
use axum_typed_multipart::{FieldData, TryFromMultipart, TypedMultipart};
|
||||
use image::ImageReader;
|
||||
use lazy_static::lazy_static;
|
||||
use revolt_config::{config, report_internal_error};
|
||||
use revolt_database::{iso8601_timestamp::Timestamp, Database, FileHash, Metadata, User};
|
||||
use revolt_files::{fetch_from_s3, upload_to_s3, AUTHENTICATION_TAG_SIZE_BYTES};
|
||||
use revolt_files::{
|
||||
create_thumbnail, decode_image, fetch_from_s3, upload_to_s3, AUTHENTICATION_TAG_SIZE_BYTES,
|
||||
};
|
||||
use revolt_result::{create_error, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::Digest;
|
||||
@@ -352,30 +353,12 @@ async fn fetch_preview(
|
||||
// Original image data
|
||||
let data = retrieve_file_by_hash(&hash).await?;
|
||||
|
||||
// Dimensions we need to resize to
|
||||
let config = config().await;
|
||||
let [w, h] = config.files.preview.get(tag).unwrap();
|
||||
|
||||
// Read the image and resize it
|
||||
// TODO: use jxl_oxide to process image/jxl files
|
||||
let image = report_internal_error!(report_internal_error!(ImageReader::new(Cursor::new(
|
||||
data
|
||||
))
|
||||
.with_guessed_format())?
|
||||
.decode())?
|
||||
//.resize(width as u32, height as u32, image::imageops::FilterType::Gaussian)
|
||||
// resize is about 2.5x slower,
|
||||
// thumbnail doesn't have terrible quality
|
||||
// so we use thumbnail
|
||||
.thumbnail(*w as u32, *h as u32);
|
||||
|
||||
// Encode it into WEBP
|
||||
let encoder = webp::Encoder::from_image(&image).expect("Could not create encoder.");
|
||||
let data = if config.files.webp_quality != 100.0 {
|
||||
encoder.encode(config.files.webp_quality).to_vec()
|
||||
} else {
|
||||
encoder.encode_lossless().to_vec()
|
||||
};
|
||||
// Read image and create thumbnail
|
||||
let data = create_thumbnail(
|
||||
decode_image(&mut Cursor::new(data), &file.content_type)?,
|
||||
tag,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok((
|
||||
[
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use std::io::Cursor;
|
||||
|
||||
use revolt_database::Metadata;
|
||||
use revolt_files::{image_size, video_size};
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
/// Intersection of what infer can detect and what image-rs supports
|
||||
@@ -21,32 +22,19 @@ static SUPPORTED_IMAGE_MIME: [&str; 9] = [
|
||||
/// Generate metadata from file, using mime type as a hint
|
||||
pub fn generate_metadata(f: &NamedTempFile, mime_type: &str) -> Metadata {
|
||||
if SUPPORTED_IMAGE_MIME.contains(&mime_type) {
|
||||
if let Ok(size) = imagesize::size(f.path())
|
||||
.inspect_err(|err| tracing::error!("Failed to generate image size! {err:?}"))
|
||||
{
|
||||
if let (Ok(width), Ok(height)) = (size.width.try_into(), size.height.try_into()) {
|
||||
return Metadata::Image { width, height };
|
||||
}
|
||||
}
|
||||
|
||||
Metadata::File
|
||||
image_size(f)
|
||||
.map(|(width, height)| Metadata::Image {
|
||||
width: width as isize,
|
||||
height: height as isize,
|
||||
})
|
||||
.unwrap_or_default()
|
||||
} else if mime_type.starts_with("video/") {
|
||||
if let Ok(data) = ffprobe::ffprobe(f.path())
|
||||
.inspect_err(|err| tracing::error!("Failed to ffprobe file! {err:?}"))
|
||||
{
|
||||
// Use first valid stream
|
||||
for stream in data.streams {
|
||||
if let (Some(w), Some(h)) = (stream.width, stream.height) {
|
||||
if let (Ok(width), Ok(height)) = (w.try_into(), h.try_into()) {
|
||||
return Metadata::Video { width, height };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Metadata::File
|
||||
} else {
|
||||
Metadata::File
|
||||
}
|
||||
video_size(f)
|
||||
.map(|(width, height)| Metadata::Video {
|
||||
width: width as isize,
|
||||
height: height as isize,
|
||||
})
|
||||
.unwrap_or_default()
|
||||
} else if mime_type.starts_with("audio/") {
|
||||
Metadata::Audio
|
||||
} else if mime_type == "plain/text" {
|
||||
|
||||
@@ -19,7 +19,11 @@ pub fn determine_mime_type(f: &mut NamedTempFile, buf: &[u8], file_name: &str) -
|
||||
|
||||
// See if the file is actually just plain Unicode/ASCII text
|
||||
if mime_type == "application/octet-stream" && simdutf8::basic::from_utf8(buf).is_ok() {
|
||||
return "plain/text";
|
||||
if file_name.to_lowercase().ends_with(".svg") {
|
||||
return "image/svg+xml";
|
||||
} else {
|
||||
return "plain/text";
|
||||
}
|
||||
}
|
||||
|
||||
mime_type
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
[package]
|
||||
name = "revolt-january"
|
||||
version = "0.7.14"
|
||||
version = "0.7.19"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
# Utility
|
||||
mime = "0.3.17"
|
||||
regex = "1.11.0"
|
||||
tempfile = "3.13.0"
|
||||
lazy_static = "1.5.0"
|
||||
moka = { version = "0.12.8", features = ["future"] }
|
||||
|
||||
# Web scraping
|
||||
scraper = "0.20.0"
|
||||
encoding_rs = "0.8.34"
|
||||
|
||||
# Serialisation
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0.68"
|
||||
|
||||
# Async runtime
|
||||
async-recursion = "1.1.1"
|
||||
tokio = { version = "1.0", features = ["full"] }
|
||||
|
||||
# Web requests
|
||||
@@ -24,12 +31,13 @@ tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
# Core crates
|
||||
revolt-config = { version = "0.7.16", path = "../../core/config" }
|
||||
revolt-models = { version = "0.7.16", path = "../../core/models" }
|
||||
revolt-result = { version = "0.7.16", path = "../../core/result", features = [
|
||||
revolt-config = { version = "0.7.19", path = "../../core/config" }
|
||||
revolt-models = { version = "0.7.19", path = "../../core/models" }
|
||||
revolt-result = { version = "0.7.19", path = "../../core/result", features = [
|
||||
"utoipa",
|
||||
"axum",
|
||||
] }
|
||||
revolt-files = { version = "0.7.19", path = "../../core/files" }
|
||||
|
||||
# Axum / web server
|
||||
axum = { version = "0.7.5" }
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# Build Stage
|
||||
FROM ghcr.io/revoltchat/base:latest AS builder
|
||||
|
||||
# Bundle Stage
|
||||
FROM gcr.io/distroless/cc-debian12:nonroot
|
||||
COPY --from=builder /home/rust/src/target/release/revolt-january ./
|
||||
COPY --from=mwader/static-ffmpeg:7.0.2 /ffmpeg /usr/local/bin/
|
||||
COPY --from=mwader/static-ffmpeg:7.0.2 /ffprobe /usr/local/bin/
|
||||
|
||||
EXPOSE 14705
|
||||
USER nonroot
|
||||
CMD ["./revolt-january"]
|
||||
@@ -1,16 +1,14 @@
|
||||
use axum::{body::Bytes, extract::Query, routing::get, Json, Router};
|
||||
use axum::{extract::Query, response::IntoResponse, routing::get, Json, Router};
|
||||
use reqwest::header;
|
||||
use revolt_models::v0::Embed;
|
||||
use revolt_result::Result;
|
||||
use revolt_result::{create_error, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use axum_extra::{
|
||||
headers::{authorization::Bearer, Authorization},
|
||||
TypedHeader,
|
||||
};
|
||||
|
||||
use crate::requests::Request;
|
||||
|
||||
pub static CACHE_CONTROL: &str = "public, max-age=600, immutable";
|
||||
|
||||
pub async fn router() -> Router {
|
||||
Router::new()
|
||||
.route("/", get(root))
|
||||
@@ -59,8 +57,17 @@ struct UrlQuery {
|
||||
("url" = String, Query, description = "URL to fetch")
|
||||
),
|
||||
)]
|
||||
async fn proxy(Query(UrlQuery { url }): Query<UrlQuery>) -> Result<Bytes> {
|
||||
Request::proxy_file(&url).await
|
||||
async fn proxy(Query(UrlQuery { url }): Query<UrlQuery>) -> Result<impl IntoResponse> {
|
||||
Request::proxy_file(&url).await.map(|(content_type, data)| {
|
||||
(
|
||||
[
|
||||
(header::CONTENT_TYPE, content_type),
|
||||
(header::CONTENT_DISPOSITION, "inline".to_owned()),
|
||||
(header::CACHE_CONTROL, CACHE_CONTROL.to_owned()),
|
||||
],
|
||||
data,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Generate embed for a given URL
|
||||
@@ -79,7 +86,11 @@ async fn proxy(Query(UrlQuery { url }): Query<UrlQuery>) -> Result<Bytes> {
|
||||
)]
|
||||
async fn embed(
|
||||
Query(UrlQuery { url }): Query<UrlQuery>,
|
||||
TypedHeader(Authorization(_bearer)): TypedHeader<Authorization<Bearer>>,
|
||||
) -> Result<Json<Embed>> {
|
||||
Request::generate_embed(&url).await.map(Json)
|
||||
// TypedHeader(Authorization(_bearer)): TypedHeader<Authorization<Bearer>>,
|
||||
) -> Result<impl IntoResponse> {
|
||||
match Request::generate_embed(url).await {
|
||||
Ok(Embed::None) => Err(create_error!(NoEmbedData)),
|
||||
result => result,
|
||||
}
|
||||
.map(Json)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ use utoipa_scalar::{Scalar, Servable as ScalarServable};
|
||||
|
||||
mod api;
|
||||
pub mod requests;
|
||||
pub mod website_embed;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), std::io::Error> {
|
||||
@@ -65,6 +66,8 @@ async fn main() -> Result<(), std::io::Error> {
|
||||
.nest("/", api::router().await);
|
||||
|
||||
// Configure TCP listener and bind
|
||||
tracing::info!("Listening on 0.0.0.0:14705");
|
||||
tracing::info!("Play around with the API: http://localhost:14705/scalar");
|
||||
let address = SocketAddr::from((Ipv4Addr::UNSPECIFIED, 14705));
|
||||
let listener = TcpListener::bind(&address).await?;
|
||||
axum::serve(listener, app.into_make_service()).await
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::body::Bytes;
|
||||
use encoding_rs::{Encoding, UTF_8_INIT};
|
||||
use lazy_static::lazy_static;
|
||||
use mime::Mime;
|
||||
use reqwest::{header::CONTENT_TYPE, redirect, Client, Response};
|
||||
use revolt_models::v0::Embed;
|
||||
use regex::Regex;
|
||||
use reqwest::{
|
||||
header::{self, CONTENT_TYPE},
|
||||
redirect, Client, Response,
|
||||
};
|
||||
use revolt_config::report_internal_error;
|
||||
use revolt_files::{create_thumbnail, decode_image, image_size_vec, is_valid_image, video_size};
|
||||
use revolt_models::v0::{Embed, Image, ImageSize, Video};
|
||||
use revolt_result::{create_error, Result};
|
||||
use std::{
|
||||
io::{Cursor, Write},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
lazy_static! {
|
||||
/// Request client
|
||||
static ref CLIENT: Client = reqwest::Client::builder()
|
||||
.user_agent("Mozilla/5.0 (compatible; January/2.0; +https://github.com/revoltchat/backend)")
|
||||
.timeout(Duration::from_secs(10)) // TODO config
|
||||
.connect_timeout(Duration::from_secs(5)) // TODO config
|
||||
.redirect(redirect::Policy::custom(|attempt| {
|
||||
@@ -24,14 +32,20 @@ lazy_static! {
|
||||
.build()
|
||||
.expect("reqwest Client");
|
||||
|
||||
/// Spoof User Agent as Discord
|
||||
static ref RE_USER_AGENT_SPOOFING_AS_DISCORD: Regex = Regex::new("^(?:(?:https?:)?//)?(?:(?:vx|fx)?twitter|(?:fixv|fixup)?x|(?:old\\.|new\\.|www\\.)reddit).com").expect("valid regex");
|
||||
|
||||
/// Regex for matching new Reddit URLs
|
||||
static ref RE_URL_NEW_REDDIT: Regex = Regex::new("^(?:(?:https?:)?//)?(?:(?:new\\.|www\\.)?reddit).com").expect("valid regex");
|
||||
|
||||
/// Cache for proxy results
|
||||
static ref PROXY_CACHE: moka::future::Cache<String, Result<Bytes>> = moka::future::Cache::builder()
|
||||
static ref PROXY_CACHE: moka::future::Cache<String, Result<(String, Vec<u8>)>> = moka::future::Cache::builder()
|
||||
.max_capacity(10_000) // TODO config
|
||||
.time_to_live(Duration::from_secs(60)) // TODO config
|
||||
.build();
|
||||
|
||||
/// Cache for embed results
|
||||
static ref EMBED_CACHE: moka::future::Cache<String, Result<Embed>> = moka::future::Cache::builder()
|
||||
static ref EMBED_CACHE: moka::future::Cache<String, Embed> = moka::future::Cache::builder()
|
||||
.max_capacity(1_000) // TODO config
|
||||
.time_to_live(Duration::from_secs(60)) // TODO config
|
||||
.build();
|
||||
@@ -45,20 +59,188 @@ pub struct Request {
|
||||
|
||||
impl Request {
|
||||
/// Proxy a given URL
|
||||
pub async fn proxy_file(url: &str) -> Result<Bytes> {
|
||||
pub async fn proxy_file(url: &str) -> Result<(String, Vec<u8>)> {
|
||||
if let Some(hit) = PROXY_CACHE.get(url).await {
|
||||
hit
|
||||
} else {
|
||||
todo!()
|
||||
let Request { response, mime } = Request::new(url).await?;
|
||||
|
||||
if matches!(mime.type_(), mime::IMAGE | mime::VIDEO) {
|
||||
let bytes = report_internal_error!(response.bytes().await);
|
||||
|
||||
let result = match bytes {
|
||||
Ok(bytes) => {
|
||||
if matches!(mime.type_(), mime::IMAGE) {
|
||||
let reader = &mut Cursor::new(&bytes);
|
||||
|
||||
if matches!(mime.subtype(), mime::GIF) {
|
||||
if is_valid_image(reader, "image/gif") {
|
||||
Ok(("image/gif".to_owned(), bytes.to_vec()))
|
||||
} else {
|
||||
Err(create_error!(FileTypeNotAllowed))
|
||||
}
|
||||
} else {
|
||||
Ok((
|
||||
"image/webp".to_owned(),
|
||||
create_thumbnail(
|
||||
decode_image(reader, mime.as_ref())?,
|
||||
"attachments",
|
||||
)
|
||||
.await,
|
||||
))
|
||||
}
|
||||
} else {
|
||||
let mut file = report_internal_error!(tempfile::NamedTempFile::new())?;
|
||||
report_internal_error!(file.write_all(&bytes))?;
|
||||
|
||||
if video_size(&file).is_some() {
|
||||
Ok((mime.to_string(), bytes.to_vec()))
|
||||
} else {
|
||||
Err(create_error!(FileTypeNotAllowed))
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => Err(err),
|
||||
};
|
||||
|
||||
PROXY_CACHE.insert(url.to_owned(), result.clone()).await;
|
||||
result
|
||||
} else {
|
||||
Err(create_error!(FileTypeNotAllowed))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch metadata for an image
|
||||
pub async fn fetch_image_metadata(
|
||||
url: &str,
|
||||
request: Option<Request>,
|
||||
) -> Result<Option<Image>> {
|
||||
if let Some(hit) = EMBED_CACHE.get(url).await {
|
||||
match hit {
|
||||
Embed::Image(img) => Ok(Some(img)),
|
||||
_ => Ok(None),
|
||||
}
|
||||
} else {
|
||||
let request = if let Some(request) = request {
|
||||
request
|
||||
} else {
|
||||
let request = Request::new(url).await?;
|
||||
if matches!(request.mime.type_(), mime::IMAGE) {
|
||||
request
|
||||
} else {
|
||||
return Err(create_error!(FileTypeNotAllowed));
|
||||
}
|
||||
};
|
||||
|
||||
if let Some((width, height)) = image_size_vec(
|
||||
&report_internal_error!(request.response.bytes().await)?,
|
||||
request.mime.as_ref(),
|
||||
) {
|
||||
Ok(Some(Image {
|
||||
url: url.to_owned(),
|
||||
width,
|
||||
height,
|
||||
size: ImageSize::Large,
|
||||
}))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch metadata for an video
|
||||
pub async fn fetch_video_metadata(
|
||||
url: &str,
|
||||
request: Option<Request>,
|
||||
) -> Result<Option<Video>> {
|
||||
if let Some(hit) = EMBED_CACHE.get(url).await {
|
||||
match hit {
|
||||
Embed::Video(vid) => Ok(Some(vid)),
|
||||
_ => Ok(None),
|
||||
}
|
||||
} else {
|
||||
let response = if let Some(Request { response, .. }) = request {
|
||||
response
|
||||
} else {
|
||||
let Request { response, mime } = Request::new(url).await?;
|
||||
if matches!(mime.type_(), mime::VIDEO) {
|
||||
response
|
||||
} else {
|
||||
return Err(create_error!(FileTypeNotAllowed));
|
||||
}
|
||||
};
|
||||
|
||||
let mut file = report_internal_error!(tempfile::NamedTempFile::new())?;
|
||||
report_internal_error!(
|
||||
file.write_all(&report_internal_error!(response.bytes().await)?)
|
||||
)?;
|
||||
|
||||
if let Some((width, height)) = video_size(&file) {
|
||||
Ok(Some(Video {
|
||||
url: url.to_owned(),
|
||||
width: width as usize,
|
||||
height: height as usize,
|
||||
}))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate embed for a given URL
|
||||
pub async fn generate_embed(url: &str) -> Result<Embed> {
|
||||
if let Some(hit) = EMBED_CACHE.get(url).await {
|
||||
hit
|
||||
pub async fn generate_embed(mut url: String) -> Result<Embed> {
|
||||
// Re-map certain links for better metadata generation
|
||||
if RE_URL_NEW_REDDIT.is_match(&url) {
|
||||
url = RE_URL_NEW_REDDIT
|
||||
// Reddit has a bunch of clickbait-y marketing on the new URLs, so we use the old site instead
|
||||
.replace(&url, "https://old.reddit.com")
|
||||
.to_string();
|
||||
}
|
||||
|
||||
// Generate the actual embed
|
||||
if let Some(hit) = EMBED_CACHE.get(&url).await {
|
||||
Ok(hit)
|
||||
} else {
|
||||
todo!()
|
||||
let request = Request::new(&url).await?;
|
||||
let embed = match (request.mime.type_(), request.mime.subtype()) {
|
||||
(_, mime::HTML) => {
|
||||
let content_type = request
|
||||
.response
|
||||
.headers()
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.parse::<Mime>().ok());
|
||||
|
||||
let encoding_name = content_type
|
||||
.as_ref()
|
||||
.and_then(|mime| mime.get_param("charset").map(|charset| charset.as_str()))
|
||||
.unwrap_or("utf-8");
|
||||
|
||||
let encoding =
|
||||
Encoding::for_label(encoding_name.as_bytes()).unwrap_or(&UTF_8_INIT);
|
||||
|
||||
let bytes = report_internal_error!(request.response.bytes().await)?;
|
||||
let (text, _, _) = encoding.decode(&bytes);
|
||||
|
||||
crate::website_embed::create_website_embed(&url, &text)
|
||||
.await
|
||||
.map(Embed::Website)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
(mime::IMAGE, _) => Request::fetch_image_metadata(&url, Some(request))
|
||||
.await
|
||||
.map(|res| res.map(Embed::Image).unwrap_or_default())
|
||||
.unwrap_or_default(),
|
||||
(mime::VIDEO, _) => Request::fetch_video_metadata(&url, Some(request))
|
||||
.await
|
||||
.map(|res| res.map(Embed::Video).unwrap_or_default())
|
||||
.unwrap_or_default(),
|
||||
_ => Embed::None,
|
||||
};
|
||||
|
||||
EMBED_CACHE.insert(url.to_owned(), embed.clone()).await;
|
||||
Ok(embed)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +248,15 @@ impl Request {
|
||||
pub async fn new(url: &str) -> Result<Request> {
|
||||
let response = CLIENT
|
||||
.get(url)
|
||||
.header(
|
||||
"User-Agent",
|
||||
if RE_USER_AGENT_SPOOFING_AS_DISCORD.is_match(url) {
|
||||
"Mozilla/5.0 (compatible; Discordbot/2.0; +https://discordapp.com)"
|
||||
} else {
|
||||
"Mozilla/5.0 (compatible; January/2.0; +https://github.com/revoltchat/backend)"
|
||||
},
|
||||
)
|
||||
.header("Accept-Language", "en-US,en;q=0.5")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| create_error!(ProxyError))?;
|
||||
@@ -88,4 +279,13 @@ impl Request {
|
||||
|
||||
Ok(Request { response, mime })
|
||||
}
|
||||
|
||||
/// Check if something exists
|
||||
pub async fn exists(url: &str) -> bool {
|
||||
if let Ok(response) = CLIENT.head(url).send().await {
|
||||
response.status().is_success()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use lazy_static::lazy_static;
|
||||
use regex::Regex;
|
||||
use revolt_models::v0::{
|
||||
BandcampType, Image, ImageSize, LightspeedType, Special, TwitchType, Video, WebsiteMetadata,
|
||||
};
|
||||
use scraper::{Html, Selector};
|
||||
|
||||
/// Create website metadata from URL and document
|
||||
pub async fn create_website_embed(original_url: &str, document: &str) -> Option<WebsiteMetadata> {
|
||||
let (mut meta, mut link) = {
|
||||
let document = Html::parse_document(document);
|
||||
|
||||
// create selectors
|
||||
let meta_selector = Selector::parse("meta").ok()?;
|
||||
let link_selector = Selector::parse("link").ok()?;
|
||||
|
||||
// extract meta tags
|
||||
let mut meta = HashMap::new();
|
||||
for el in document.select(&meta_selector) {
|
||||
let node = el.value();
|
||||
|
||||
if let (Some(property), Some(content)) = (
|
||||
node.attr("property").or_else(|| node.attr("name")),
|
||||
node.attr("content"),
|
||||
) {
|
||||
meta.insert(property.to_string(), content.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// extract rel links
|
||||
let mut link = HashMap::new();
|
||||
for el in document.select(&link_selector) {
|
||||
let node = el.value();
|
||||
|
||||
if let (Some(property), Some(content)) = (node.attr("rel"), node.attr("href")) {
|
||||
link.insert(property.to_string(), content.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
(meta, link)
|
||||
};
|
||||
|
||||
// build metadata
|
||||
let mut metadata = WebsiteMetadata {
|
||||
title: meta
|
||||
.remove("og:title")
|
||||
.or_else(|| meta.remove("twitter:title"))
|
||||
.or_else(|| meta.remove("title"))
|
||||
.map(|s| s.trim().to_owned()),
|
||||
description: meta
|
||||
.remove("og:description")
|
||||
.or_else(|| meta.remove("twitter:description"))
|
||||
.or_else(|| meta.remove("description"))
|
||||
.map(|s| s.trim().to_owned()),
|
||||
image: meta
|
||||
.remove("og:image")
|
||||
.or_else(|| meta.remove("og:image:secure_url"))
|
||||
.or_else(|| meta.remove("twitter:image"))
|
||||
.or_else(|| meta.remove("twitter:image:src"))
|
||||
.map(|s| s.trim().to_owned())
|
||||
.map(|mut url| {
|
||||
// If relative URL, prepend root URL. Also if root URL ends with a slash, remove it.
|
||||
if let Some(ch) = url.chars().next() {
|
||||
if ch == '/' {
|
||||
url = format!("{}{}", &original_url.trim_end_matches('/'), url);
|
||||
}
|
||||
}
|
||||
|
||||
let mut size = ImageSize::Preview;
|
||||
if let Some(card) = meta.remove("twitter:card") {
|
||||
if &card == "summary_large_image" {
|
||||
size = ImageSize::Large;
|
||||
}
|
||||
}
|
||||
|
||||
Image {
|
||||
url: url.to_owned(),
|
||||
width: meta
|
||||
.remove("og:image:width")
|
||||
.unwrap_or_default()
|
||||
.parse()
|
||||
.unwrap_or(0),
|
||||
height: meta
|
||||
.remove("og:image:height")
|
||||
.unwrap_or_default()
|
||||
.parse()
|
||||
.unwrap_or(0),
|
||||
size,
|
||||
}
|
||||
}),
|
||||
video: meta
|
||||
.remove("og:video")
|
||||
.or_else(|| meta.remove("og:video:url"))
|
||||
.or_else(|| meta.remove("og:video:secure_url"))
|
||||
.map(|s| s.trim().to_owned())
|
||||
.map(|mut url| {
|
||||
// If relative URL, prepend root URL. Also if root URL ends with a slash, remove it.
|
||||
if let Some(ch) = url.chars().next() {
|
||||
if ch == '/' {
|
||||
url = format!("{}{}", &original_url.trim_end_matches('/'), url);
|
||||
}
|
||||
}
|
||||
|
||||
Video {
|
||||
url: url.to_owned(),
|
||||
width: meta
|
||||
.remove("og:video:width")
|
||||
.unwrap_or_default()
|
||||
.parse()
|
||||
.unwrap_or(0),
|
||||
height: meta
|
||||
.remove("og:video:height")
|
||||
.unwrap_or_default()
|
||||
.parse()
|
||||
.unwrap_or(0),
|
||||
}
|
||||
}),
|
||||
icon_url: link
|
||||
.remove("apple-touch-icon")
|
||||
.or_else(|| link.remove("icon"))
|
||||
.map(|s| s.trim().to_owned())
|
||||
.map(|mut v| {
|
||||
// If relative URL, prepend root URL.
|
||||
if let Some(ch) = v.chars().next() {
|
||||
if ch == '/' {
|
||||
v = format!("{}{}", &original_url.trim_end_matches('/'), v);
|
||||
}
|
||||
}
|
||||
|
||||
v
|
||||
}),
|
||||
colour: meta.remove("theme-color").map(|s| s.trim().to_owned()),
|
||||
site_name: meta.remove("og:site_name").map(|s| s.trim().to_owned()),
|
||||
url: meta
|
||||
.remove("og:url")
|
||||
.or_else(|| Some(original_url.to_owned())),
|
||||
original_url: Some(original_url.to_owned()),
|
||||
special: None,
|
||||
};
|
||||
|
||||
// populate extra metadata for popular websites
|
||||
populate_special(original_url.to_owned(), &mut metadata).await;
|
||||
|
||||
// fetch video size if missing
|
||||
if metadata.special.is_none() {
|
||||
if let Some(Video { width, height, url }) = &metadata.video {
|
||||
if width == &0 || height == &0 {
|
||||
metadata.video =
|
||||
match crate::requests::Request::fetch_video_metadata(url, None).await {
|
||||
Ok(Some(video)) => Some(video),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// remove image if video exists
|
||||
if metadata.video.is_some() {
|
||||
metadata.image.take();
|
||||
}
|
||||
|
||||
// fetch image size if missing
|
||||
if metadata.special.is_none() {
|
||||
if let Some(Image {
|
||||
width, height, url, ..
|
||||
}) = &metadata.image
|
||||
{
|
||||
if width == &0 || height == &0 {
|
||||
metadata.image =
|
||||
match crate::requests::Request::fetch_image_metadata(url, None).await {
|
||||
Ok(Some(image)) => Some(image),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// truncate data
|
||||
metadata.truncate();
|
||||
|
||||
// if it's empty, don't return anything
|
||||
if metadata.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(metadata)
|
||||
}
|
||||
}
|
||||
|
||||
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_LIGHTSPEED: Regex = Regex::new("^(?:https?://)?(?:[\\w]+\\.)?lightspeed\\.tv/([a-z0-9_]{4,25})").unwrap();
|
||||
|
||||
static ref RE_TWITCH: Regex = Regex::new("^(?:https?://)?(?:www\\.|go\\.)?twitch\\.tv/([a-z0-9_]+)($|\\?)").unwrap();
|
||||
static ref RE_TWITCH_VOD: Regex = Regex::new("^(?:https?://)?(?:www\\.|go\\.)?twitch\\.tv/videos/([0-9]+)($|\\?)").unwrap();
|
||||
static ref RE_TWITCH_CLIP: Regex = Regex::new("^(?:https?://)?(?:www\\.|go\\.)?twitch\\.tv/(?:[a-z0-9_]+)/clip/([A-z0-9_-]+)($|\\?)").unwrap();
|
||||
|
||||
static ref RE_SPOTIFY: Regex = Regex::new("^(?:https?://)?open.spotify.com/(track|user|artist|album|playlist)/([A-z0-9]+)").unwrap();
|
||||
static ref RE_SOUNDCLOUD: Regex = Regex::new("^(?:https?://)?soundcloud.com/([a-zA-Z0-9-]+)/([A-z0-9-]+)").unwrap();
|
||||
static ref RE_BANDCAMP: Regex = Regex::new("^(?:https?://)?(?:[A-z0-9_-]+).bandcamp.com/(track|album)/([A-z0-9_-]+)").unwrap();
|
||||
static ref RE_APPLE_MUSIC: Regex = Regex::new("^(?:https?://)?music\\.apple\\.com/(?:[a-z]{2}/)?album/(?:[a-zA-Z0-9-]+)/(\\d+)(?:\\?i=(\\d+))?").unwrap();
|
||||
|
||||
static ref RE_STREAMABLE: Regex = Regex::new("^(?:https?://)?(?:www\\.)?streamable\\.com/([\\w\\d-]+)").unwrap();
|
||||
|
||||
static ref RE_GIF: Regex = Regex::new("^(?:https?://)?(www\\.)?(gifbox\\.me/view|yiffbox\\.me/view|tenor\\.com/view|giphy\\.com/gifs|gfycat\\.com|redgifs\\.com/watch)/[\\w\\d-]+").unwrap();
|
||||
}
|
||||
|
||||
let url = metadata
|
||||
.url
|
||||
.as_ref()
|
||||
.or(metadata.original_url.as_ref())
|
||||
.unwrap_or(&original_url)
|
||||
.as_str();
|
||||
|
||||
metadata.special = if let Some(captures) = RE_STREAMABLE.captures_iter(url).next() {
|
||||
Some(Special::Streamable {
|
||||
id: captures[1].to_string(),
|
||||
})
|
||||
} else if let Some(captures) = RE_YOUTUBE.captures_iter(url).next() {
|
||||
let id = captures[1].to_string();
|
||||
|
||||
lazy_static! {
|
||||
static ref RE_TIMESTAMP: Regex = Regex::new("(?:\\?|&)(?:t|start)=([\\w]+)").unwrap();
|
||||
}
|
||||
|
||||
// YouTube now blocks datacentre IPs from fetching information
|
||||
// This is a fallback to prevent the embed from looking weird
|
||||
if metadata.video.is_none() {
|
||||
metadata.title.replace("YouTube".to_owned());
|
||||
metadata.description.take();
|
||||
metadata.colour.take();
|
||||
metadata.icon_url.take();
|
||||
metadata.site_name.take();
|
||||
|
||||
// Verify the video exists
|
||||
if !crate::requests::Request::exists(&format!(
|
||||
"http://img.youtube.com/vi/{}/sddefault.jpg",
|
||||
id
|
||||
))
|
||||
.await
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(timestamp_captures) = RE_TIMESTAMP.captures_iter(url).next() {
|
||||
Some(Special::YouTube {
|
||||
id,
|
||||
timestamp: Some(timestamp_captures[1].to_string()),
|
||||
})
|
||||
} else {
|
||||
Some(Special::YouTube {
|
||||
id,
|
||||
timestamp: None,
|
||||
})
|
||||
}
|
||||
} else if let Some(captures) = RE_LIGHTSPEED.captures_iter(url).next() {
|
||||
Some(Special::Lightspeed {
|
||||
id: captures[1].to_string(),
|
||||
content_type: LightspeedType::Channel,
|
||||
})
|
||||
} else if let Some(captures) = RE_TWITCH.captures_iter(url).next() {
|
||||
Some(Special::Twitch {
|
||||
id: captures[1].to_string(),
|
||||
content_type: TwitchType::Channel,
|
||||
})
|
||||
} else if let Some(captures) = RE_TWITCH_VOD.captures_iter(url).next() {
|
||||
Some(Special::Twitch {
|
||||
id: captures[1].to_string(),
|
||||
content_type: TwitchType::Video,
|
||||
})
|
||||
} else if let Some(captures) = RE_TWITCH_CLIP.captures_iter(url).next() {
|
||||
Some(Special::Twitch {
|
||||
id: captures[1].to_string(),
|
||||
content_type: TwitchType::Clip,
|
||||
})
|
||||
} else if let Some(captures) = RE_SPOTIFY.captures_iter(url).next() {
|
||||
Some(Special::Spotify {
|
||||
content_type: captures[1].to_string(),
|
||||
id: captures[2].to_string(),
|
||||
})
|
||||
} else if RE_SOUNDCLOUD.is_match(url) {
|
||||
Some(Special::Soundcloud)
|
||||
} else if RE_BANDCAMP.is_match(url) {
|
||||
lazy_static! {
|
||||
static ref RE_TRACK: Regex = Regex::new("track=(\\d+)").unwrap();
|
||||
static ref RE_ALBUM: Regex = Regex::new("album=(\\d+)").unwrap();
|
||||
}
|
||||
|
||||
if let Some(video) = &metadata.video {
|
||||
if let Some(captures) = RE_TRACK.captures_iter(&video.url).next() {
|
||||
Some(Special::Bandcamp {
|
||||
content_type: BandcampType::Track,
|
||||
id: captures[1].to_string(),
|
||||
})
|
||||
} else {
|
||||
RE_ALBUM
|
||||
.captures_iter(&video.url)
|
||||
.next()
|
||||
.map(|captures| Special::Bandcamp {
|
||||
content_type: BandcampType::Album,
|
||||
id: captures[1].to_string(),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else if RE_GIF.is_match(url) {
|
||||
Some(Special::GIF)
|
||||
} else {
|
||||
RE_APPLE_MUSIC
|
||||
.captures_iter(url)
|
||||
.next()
|
||||
.map(|captures| Special::AppleMusic {
|
||||
album_id: captures[1].to_string(),
|
||||
track_id: captures.get(2).map(|m| m.as_str().to_string()),
|
||||
})
|
||||
};
|
||||
|
||||
// add colours for popular websites
|
||||
if let Some(special) = &metadata.special {
|
||||
match special {
|
||||
Special::YouTube { .. } => metadata.colour = Some("#FF424F".to_string()),
|
||||
Special::Twitch { .. } => metadata.colour = Some("#7B68EE".to_string()),
|
||||
Special::Lightspeed { .. } => metadata.colour = Some("#7445D9".to_string()),
|
||||
Special::Spotify { .. } => metadata.colour = Some("#1ABC9C".to_string()),
|
||||
Special::Soundcloud { .. } => metadata.colour = Some("#FF7F50".to_string()),
|
||||
Special::AppleMusic { .. } => metadata.colour = Some("#FA233B".to_string()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
- [Introduction](./hello.md)
|
||||
- [Project Structure]()
|
||||
- [Creating new API features](./new_features.md)
|
||||
- [Testing]()
|
||||
- [Writing a new database test]()
|
||||
- [Writing a new API test]()
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# New API features
|
||||
|
||||
New API features must be documented where appropriate, this document aims to cover everywhere you need to update for new features.
|
||||
|
||||
Before writing new API features, generally a good idea to:
|
||||
|
||||
- Consult with other developers in the [Revolt Developers space](https://rvlt.gg/API)
|
||||
- If it's a relatively big feature, also [write an RFC](https://github.com/revoltchat/rfcs)
|
||||
|
||||
When your feature is ready to release, ensure to:
|
||||
|
||||
- Update backend documentation (what you're reading now!) if applicable
|
||||
- Update the [developers documentation](https://github.com/revoltchat/wiki) if applicable
|
||||
- Update the Feature Matrix (or ask someone that can to do so)
|
||||
- Ensure it is properly listed in the backend release changelog
|
||||
@@ -24,6 +24,7 @@ docker build -t ghcr.io/revoltchat/base:latest -f Dockerfile.useCurrentArch .
|
||||
docker build -t ghcr.io/revoltchat/server:$TAG - < crates/delta/Dockerfile
|
||||
docker build -t ghcr.io/revoltchat/bonfire:$TAG - < crates/bonfire/Dockerfile
|
||||
docker build -t ghcr.io/revoltchat/autumn:$TAG - < crates/services/autumn/Dockerfile
|
||||
docker build -t ghcr.io/revoltchat/january:$TAG - < crates/services/january/Dockerfile
|
||||
|
||||
if [ "$DEBUG" = "true" ]; then
|
||||
git restore Cargo.toml
|
||||
@@ -32,3 +33,4 @@ fi
|
||||
docker push ghcr.io/revoltchat/server:$TAG
|
||||
docker push ghcr.io/revoltchat/bonfire:$TAG
|
||||
docker push ghcr.io/revoltchat/autumn:$TAG
|
||||
docker push ghcr.io/revoltchat/january:$TAG
|
||||
|
||||
Reference in New Issue
Block a user