Compare commits

...
34 Commits
Author SHA1 Message Date
Paul Makles 4c00a7dfb7 chore: strip dotenv (unmaintained) from project
It is no longer needed as configuration is loaded via. TOML or direct env if appropriate.
2024-11-27 17:05:11 +00:00
Paul Makles b9ae333b02 fix(bonfire): make error handling consistent 2024-11-11 12:31:34 +00:00
Paul Makles bf0fc504a9 chore(vscode): select nix env 2024-11-11 12:31:19 +00:00
Paul Makles 705e517871 fix: db migration for webhooks does not consider missing channels
fixes #378
2024-10-31 20:31:09 +00:00
Paul Makles 6f99ac2160 chore: bump little_exif debug code 2024-10-28 15:38:07 +00:00
Paul Makles 2fcc714546 chore: add helper script for running everything at once 2024-10-28 14:15:48 +00:00
Paul Makles 1e72f7bc77 chore: copy createbuckets config from self-hosted 2024-10-28 13:45:37 +00:00
Paul Makles af0d24c7c6 fix: don't use failed file hashes
fixes #377
2024-10-28 13:40:40 +00:00
Paul Makles ab58177dfa feat: add support for path style buckets 2024-10-27 23:28:28 +00:00
Paul Makles df07426019 chore: bump version to 0.7.19 2024-10-24 17:48:11 +01:00
Paul Makles c30d9a2620 chore(bonfire): fire error event with more information 2024-10-24 17:47:17 +01:00
Paul Makles 7b44317705 docs: add new features page 2024-10-24 17:45:10 +01:00
Paul Makles 876068a37e fix: populate empty vector if clear field is missing
fixes #367
2024-10-24 17:14:30 +01:00
IAmTomahawkx 397b9878e1 fix: temp fix for spam attack via notification bug abuse, but git actually commits the changes this time 2024-10-06 13:22:13 -07:00
Paul Makles d9deadc65a fix: temp fix for spam attack via notification bug abuse 2024-10-06 02:34:47 -07:00
Paul Makles 58d3c5cc2e fix(services/january): remove image if video present and hence fix logic error
refactor(services/january): throw an error if embed fails to generate
2024-10-02 16:08:24 +01:00
Paul Makles 520fb02fb6 fix(services/january): support svg for embed generation 2024-10-02 15:20:35 +01:00
Paul Makles 2cb12a3d59 fix: do not handle error early 2024-10-02 15:05:57 +01:00
Paul Makles 18888eae5f fix: mangled symbol 2024-10-02 14:13:32 +01:00
Paul Makles f31020fb6e refactor(core): add ImageProcessingFailed error 2024-10-02 14:12:26 +01:00
Paul Makles bb202079e0 fix(services/january): put html parsing into block to prevent issues with futures 2024-10-02 14:09:41 +01:00
Paul Makles bb6bcda8bd fix(services/january): reddit embeds by spoofing as discord / mapping to old reddit
closes #360
2024-10-02 12:56:32 +01:00
Paul Makles 68099bd2b7 chore: bump version & close issues
closes #366
closes #364
closes #359
closes #356
closes #354
closes #351
closes #348
closes #345
2024-10-02 12:39:32 +01:00
Paul Makles a8db1cb40d feat(core/files): SVG rendering for thumbnails 2024-10-02 12:36:56 +01:00
Paul Makles efa7ba78ed feat(core/files): support for jixel decoding
closes #342
2024-10-02 12:20:14 +01:00
Paul Makles 25fc692dc1 chore(core/models): truncate the rest of the embed fields 2024-10-02 11:35:37 +01:00
Paul Makles 530d68fe89 feat(services/autumn): add accept-language header
closes #358
2024-10-02 11:32:55 +01:00
Paul Makles afd8c906ba fix(services/january): YouTube fallback 2024-10-02 11:31:55 +01:00
Paul Makles c596dd5458 chore: bump all versions 2024-10-01 21:18:55 +01:00
Paul Makles ed78b253ff feat(services/january): website embed generation 2024-10-01 21:11:08 +01:00
Paul Makles 66c84e0ad9 feat(services/january): image/video embeds 2024-10-01 19:13:48 +01:00
Paul Makles 21335b3297 feat(services/january): proxy images/videos 2024-10-01 19:02:39 +01:00
Paul Makles 8fc791f81a ci: remove .env file entry 2024-09-29 17:18:41 +01:00
Paul Makles 1689ee5ddc ci: use compose.yml file instead of deleted file 2024-09-29 17:08:42 +01:00
41 changed files with 1699 additions and 303 deletions
+1 -5
View File
@@ -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:
+3 -2
View File
@@ -1,5 +1,6 @@
{
"editor.formatOnSave": true,
"rust-analyzer.checkOnSave.command": "clippy",
"nixEnvSelector.suggestion": false
}
"nixEnvSelector.suggestion": false,
"nixEnvSelector.nixFile": "${workspaceFolder}/default.nix"
}
Generated
+610 -59
View File
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -95,17 +95,21 @@ Then continue:
# start other necessary services
docker compose up -d
# run everything together
./scripts/start.sh
# .. or individually
# run the API server
cargo run --bin revolt-delta
# run the events server
cargo run --bin revolt-bonfire
# run the file server
cargo run --bin revolt-autumn
# run th proxy server
# run the proxy server
cargo run --bin revolt-january
# hint:
# mold -run <cargo build, cargo run, etc...>
# mold -run ./scripts/start.sh
```
You can start a web client by doing the following:
+4 -5
View File
@@ -32,13 +32,12 @@ services:
image: minio/mc
depends_on:
- minio
environment:
MINIO_ROOT_USER: minioautumn
MINIO_ROOT_PASSWORD: minioautumn
entrypoint: >
/bin/sh -c "
/usr/bin/mc config host add minio http://minio:9000 $MINIO_ROOT_USER $MINIO_ROOT_PASSWORD;
while ! /usr/bin/mc ready minio; do echo 'Waiting minio...' && sleep 1; done;
while ! /usr/bin/mc ready minio; do
/usr/bin/mc config host add minio http://minio:9000 minioautumn minioautumn;
echo 'Waiting minio...' && sleep 1;
done;
/usr/bin/mc mb minio/revolt-uploads;
exit 0;
"
+4 -4
View File
@@ -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" }
+2 -2
View File
@@ -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
+11 -8
View File
@@ -79,7 +79,9 @@ pub async fn client(db: &'static Database, stream: TcpStream, addr: SocketAddr)
// Try to authenticate the user.
let Some(token) = config.get_session_token().as_ref() else {
write
.send(config.encode(&create_error!(InvalidSession)))
.send(config.encode(&EventV1::Error {
data: create_error!(InvalidSession),
}))
.await
.ok();
return;
@@ -88,7 +90,10 @@ pub async fn client(db: &'static Database, stream: TcpStream, addr: SocketAddr)
let (user, session_id) = match User::from_token(db, token, UserHint::Any).await {
Ok(user) => user,
Err(err) => {
write.send(config.encode(&err)).await.ok();
write
.send(config.encode(&EventV1::Error { data: err }))
.await
.ok();
return;
}
};
@@ -301,25 +306,23 @@ async fn listener(
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);
+2 -3
View File
@@ -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>"]
@@ -15,7 +15,6 @@ default = ["test"]
[dependencies]
# Utility
dotenv = "0.15.0"
config = "0.13.3"
cached = "0.44.0"
once_cell = "1.18.0"
@@ -35,4 +34,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 }
+4
View File
@@ -132,6 +132,7 @@ emojis = [128, 128]
#
# Backblaze B2:
# - endpoint is listed on the "Buckets" page
# - path_style_buckets is set to true
# - region is `eu-central-003` string from endpoint URL
# - access_key_id is keyID generated on the "Application Keys" page
# - secret_access_key is token generated on the "Application Keys" page
@@ -139,6 +140,9 @@ emojis = [128, 128]
# S3 protocol endpoint
endpoint = "http://minio:9000"
# Whether to use path-style buckets
# Generally true, except for MinIO
path_style_buckets = false
# S3 region name
region = "minio"
# S3 protocol key ID
+1 -2
View File
@@ -175,6 +175,7 @@ pub struct FilesLimit {
#[derive(Deserialize, Debug, Clone)]
pub struct FilesS3 {
pub endpoint: String,
pub path_style_buckets: bool,
pub region: String,
pub access_key_id: String,
pub secret_access_key: String,
@@ -286,8 +287,6 @@ pub async fn config() -> Settings {
/// Configure logging and common Rust variables
pub async fn setup_logging(release: &'static str, dsn: String) -> Option<sentry::ClientInitGuard> {
dotenv::dotenv().ok();
if std::env::var("RUST_LOG").is_err() {
std::env::set_var("RUST_LOG", "info");
}
+6 -6
View File
@@ -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,15 +24,15 @@ default = ["mongodb", "async-std-runtime", "tasks"]
[dependencies]
# Core
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-models = { version = "0.7.16", path = "../models", features = [
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",
] }
+13 -22
View File
@@ -1,25 +1,16 @@
use authifier::AuthifierEvent;
use revolt_result::Error;
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;
use crate::Database;
/// WebSocket Client Errors
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(tag = "error")]
pub enum WebSocketError {
LabelMe,
InternalError { at: String },
InvalidSession,
OnboardingNotFinished,
AlreadyAuthenticated,
MalformedData { msg: String },
}
/// Ping Packet
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(untagged)]
@@ -28,14 +19,6 @@ pub enum Ping {
Number(usize),
}
/// Untagged Error
#[derive(Serialize)]
#[serde(untagged)]
pub enum ErrorEvent {
Error(WebSocketError),
APIError(Error),
}
/// Fields provided in Ready payload
#[derive(PartialEq)]
pub enum ReadyPayloadFields {
@@ -55,6 +38,8 @@ pub enum ReadyPayloadFields {
pub enum EventV1 {
/// Multiple events
Bulk { v: Vec<EventV1> },
/// Error event
Error { data: Error },
/// Successfully authenticated
Authenticated,
@@ -89,6 +74,7 @@ pub enum EventV1 {
id: String,
channel: String,
data: PartialMessage,
#[serde(default)]
clear: Vec<FieldsMessage>,
},
@@ -140,6 +126,7 @@ pub enum EventV1 {
ServerUpdate {
id: String,
data: PartialServer,
#[serde(default)]
clear: Vec<FieldsServer>,
},
@@ -150,6 +137,7 @@ pub enum EventV1 {
ServerMemberUpdate {
id: MemberCompositeKey,
data: PartialMember,
#[serde(default)]
clear: Vec<FieldsMember>,
},
@@ -168,6 +156,7 @@ pub enum EventV1 {
id: String,
role_id: String,
data: PartialRole,
#[serde(default)]
clear: Vec<FieldsRole>,
},
@@ -178,6 +167,7 @@ pub enum EventV1 {
UserUpdate {
id: String,
data: PartialUser,
#[serde(default)]
clear: Vec<FieldsUser>,
event_id: Option<String>,
},
@@ -212,6 +202,7 @@ pub enum EventV1 {
ChannelUpdate {
id: String,
data: PartialChannel,
#[serde(default)]
clear: Vec<FieldsChannel>,
},
@@ -11,6 +11,7 @@ use bson::oid::ObjectId;
use futures::StreamExt;
use rand::seq::SliceRandom;
use revolt_permissions::DEFAULT_WEBHOOK_PERMISSIONS;
use revolt_result::{Error, ErrorType};
use serde::{Deserialize, Serialize};
use unicode_segmentation::UnicodeSegmentation;
@@ -20,7 +21,7 @@ struct MigrationInfo {
revision: i32,
}
pub const LATEST_REVISION: i32 = 30;
pub const LATEST_REVISION: i32 = 31;
pub async fn migrate_database(db: &MongoDb) {
let migrations = db.col::<Document>("migrations");
@@ -1139,53 +1140,7 @@ 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");
}
}
// Revision 29 omitted due to bug.
if revision <= 30 {
info!("Running migration [revision 30 / 29-09-2024]: Add index for used_for.id to attachments.");
@@ -1209,7 +1164,68 @@ pub async fn run_migrations(db: &MongoDb, revision: i32) -> i32 {
.expect("Failed to create attachments index.");
}
// Need to migrate fields on attachments, change `user_id`, `object_id`, etc to `parent`.
if revision <= 31 {
info!("Running migration [revision 31 / 31-10-2024]: Add creator_id to webhooks and delete those whose channels don't exist.");
#[derive(serde::Serialize, serde::Deserialize)]
struct WebhookShell {
_id: String,
channel_id: String,
}
let webhooks = db
.db()
.collection::<WebhookShell>("channel_webhooks")
.find(doc! {}, None)
.await
.expect("webhooks")
.filter_map(|s| async { s.ok() })
.collect::<Vec<WebhookShell>>()
.await;
for webhook in webhooks {
match db.fetch_channel(&webhook.channel_id).await {
Ok(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": webhook._id,
},
doc! {
"$set" : {
"creator_id": creator_id
}
},
None,
)
.await
.expect("update webhook");
}
Err(Error {
error_type: ErrorType::NotFound,
..
}) => {
db.db()
.collection::<WebhookShell>("channel_webhooks")
.delete_one(doc! { "_id": webhook._id }, None)
.await
.expect("failed to delete invalid webhook");
}
Err(err) => panic!("{err:?}"),
}
}
}
// Reminder to update LATEST_REVISION when adding new migrations.
LATEST_REVISION.max(revision)
@@ -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() {
+21 -3
View File
@@ -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"
+164 -1
View File
@@ -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;
@@ -31,6 +34,7 @@ pub fn create_client(s3_config: FilesS3) -> Client {
let config = Config::builder()
.region(Region::new(s3_config.region))
.endpoint_url(s3_config.endpoint)
.force_path_style(s3_config.path_style_buckets)
.credentials_provider(creds)
.build();
@@ -108,3 +112,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()
}
}
+3 -3
View File
@@ -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"
+50 -7
View File
@@ -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()
}
}
+2 -2
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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>"]
+3
View File
@@ -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()
+2
View File
@@ -162,6 +162,8 @@ pub enum ErrorType {
max: usize,
},
FileTypeNotAllowed,
ImageProcessingFailed,
NoEmbedData,
// ? Legacy errors
VosoUnavailable,
+2
View File
@@ -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 -2
View File
@@ -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"
@@ -16,7 +16,6 @@ redis-kiss = "0.1.4"
lru = "0.7.0"
url = "2.2.2"
log = "0.4.11"
dotenv = "0.15.0"
dashmap = "5.2.0"
linkify = "0.6.0"
once_cell = "1.17.1"
-2
View File
@@ -19,8 +19,6 @@ pub struct TestHarness {
impl TestHarness {
pub async fn new() -> TestHarness {
dotenv::dotenv().ok();
let client = Client::tracked(crate::web().await)
.await
.expect("valid rocket instance");
+6 -6
View File
@@ -1,6 +1,6 @@
[package]
name = "revolt-autumn"
version = "0.7.14"
version = "0.7.19"
edition = "2021"
[dependencies]
@@ -13,7 +13,7 @@ webp = "0.3.0"
sha2 = "0.10.8"
jxl-oxide = "0.8.1"
kamadak-exif = "0.5.4"
# revolt_little_exif = "0.4.0"
# revolt_little_exif = "0.5.1"
image = { version = "0.25.2" } # avif encode requires dav1d system library: features = ["avif-native"]
# File processing
@@ -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",
] }
+29 -32
View File
@@ -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;
@@ -206,16 +207,27 @@ async fn upload_file(
};
// Find an existing hash and use that if possible
if let Ok(file_hash) = db
let file_hash_exists = if let Ok(file_hash) = db
.fetch_attachment_hash(&format!("{original_hash:02x}"))
.await
{
let tag: &'static str = tag.into();
db.insert_attachment(&file_hash.into_file(id.clone(), tag.to_owned(), filename, user.id))
if !file_hash.iv.is_empty() {
let tag: &'static str = tag.into();
db.insert_attachment(&file_hash.into_file(
id.clone(),
tag.to_owned(),
filename,
user.id,
))
.await?;
return Ok(Json(UploadResponse { id }));
}
return Ok(Json(UploadResponse { id }));
}
true
} else {
false
};
// Determine the mime type for the file
let mime_type = determine_mime_type(&mut file.contents, &buf, &filename);
@@ -273,7 +285,10 @@ async fn upload_file(
size: new_file_size as isize,
};
db.insert_attachment_hash(&file_hash).await?;
// Add attachment hash if it doesn't exist
if !file_hash_exists {
db.insert_attachment_hash(&file_hash).await?;
}
// Upload the file to S3 and commit nonce to database
let upload_start = Instant::now();
@@ -352,30 +367,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((
[
+18 -17
View File
@@ -17,23 +17,24 @@ pub async fn strip_metadata(
) -> Result<(Vec<u8>, Metadata)> {
match &metadata {
Metadata::Image { width, height } => match mime {
// little_exif does not appear to parse JPEGs correctly? had 2/2 files fail
/* "image/jpeg" | "image/png" => {
// use little_exif to strip metadata except for orientation and colour profile
// PNGs must also be re-encoded to mitigate CVE-2023-21036
let metadata = revolt_little_exif::metadata::Metadata::new_from_path_with_filetype(
file.path(),
match mime {
"image/jpeg" => revolt_little_exif::filetype::FileExtension::JPEG,
"image/png" => revolt_little_exif::filetype::FileExtension::PNG {
as_zTXt_chunk: true,
},
_ => unreachable!(),
},
)
.unwrap();
dbg!(metadata.data());
} */
// // little_exif does not appear to parse JPEGs correctly? had 2/2 files fail
// "image/jpeg" | "image/png" => {
// // use little_exif to strip metadata except for orientation and colour profile
// // PNGs must also be re-encoded to mitigate CVE-2023-21036
// let metadata = revolt_little_exif::metadata::Metadata::new_from_path_with_filetype(
// file.path(),
// match mime {
// "image/jpeg" => revolt_little_exif::filetype::FileExtension::JPEG,
// "image/png" => revolt_little_exif::filetype::FileExtension::PNG {
// as_zTXt_chunk: true,
// },
// _ => unreachable!(),
// },
// )
// .unwrap();
// dbg!(metadata.data());
// todo!()
// }
// Apply orientation manually & strip all other EXIF data
"image/jpeg" | "image/png" | "image/avif" | "image/tiff" => {
// Create a reader
+13 -25
View File
@@ -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" {
+5 -1
View File
@@ -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
+12 -4
View File
@@ -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" }
+23 -12
View File
@@ -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)
}
+3
View File
@@ -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
+214 -14
View File
@@ -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()),
_ => {}
}
}
}
+1
View File
@@ -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]()
+15
View File
@@ -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
+2
View File
@@ -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
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -e
cargo build \
--bin revolt-delta \
--bin revolt-bonfire \
--bin revolt-autumn \
--bin revolt-january
trap 'pkill -f revolt-' SIGINT
cargo run --bin revolt-delta &
cargo run --bin revolt-bonfire &
cargo run --bin revolt-autumn &
cargo run --bin revolt-january