feat: add otel logging, integrate with tracing
This commit is contained in:
@@ -2,4 +2,4 @@
|
|||||||
#MISE description="Start Docker containers"
|
#MISE description="Start Docker containers"
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
docker compose up -d
|
docker compose up -d "$@"
|
||||||
|
|||||||
@@ -2,4 +2,4 @@
|
|||||||
#MISE description="Stop Docker containers"
|
#MISE description="Stop Docker containers"
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
docker compose down
|
docker compose down "$@"
|
||||||
|
|||||||
12
README.md
12
README.md
@@ -109,6 +109,18 @@ If you'd like to change anything, create a `Revolt.overrides.toml` file and spec
|
|||||||
> ports: !override
|
> ports: !override
|
||||||
> - "14072:5672"
|
> - "14072:5672"
|
||||||
> - "14672:15672"
|
> - "14672:15672"
|
||||||
|
>
|
||||||
|
> victoria-metrics:
|
||||||
|
> ports: !override
|
||||||
|
> - "18428:8428"
|
||||||
|
>
|
||||||
|
> victoria-logs:
|
||||||
|
> ports: !override
|
||||||
|
> - "19428:9428"
|
||||||
|
>
|
||||||
|
> victoria-traces:
|
||||||
|
> ports: !override
|
||||||
|
> - "14428:10428"
|
||||||
> ```
|
> ```
|
||||||
>
|
>
|
||||||
> And corresponding Revolt configuration:
|
> And corresponding Revolt configuration:
|
||||||
|
|||||||
22
compose.yml
22
compose.yml
@@ -1,3 +1,5 @@
|
|||||||
|
name: stoat-backend
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# Redis
|
# Redis
|
||||||
redis:
|
redis:
|
||||||
@@ -69,9 +71,29 @@ services:
|
|||||||
MAILDEV_INCOMING_USER: smtp
|
MAILDEV_INCOMING_USER: smtp
|
||||||
MAILDEV_INCOMING_PASS: smtp
|
MAILDEV_INCOMING_PASS: smtp
|
||||||
|
|
||||||
|
# LiveKit
|
||||||
livekit:
|
livekit:
|
||||||
image: ghcr.io/stoatchat/livekit-server:v1.9.9
|
image: ghcr.io/stoatchat/livekit-server:v1.9.9
|
||||||
command: --config /etc/livekit.yml
|
command: --config /etc/livekit.yml
|
||||||
network_mode: "host"
|
network_mode: "host"
|
||||||
volumes:
|
volumes:
|
||||||
- ./livekit.yml:/etc/livekit.yml
|
- ./livekit.yml:/etc/livekit.yml
|
||||||
|
|
||||||
|
# VM
|
||||||
|
victoria-metrics:
|
||||||
|
image: victoriametrics/victoria-metrics:latest
|
||||||
|
ports:
|
||||||
|
- 8428:8428
|
||||||
|
|
||||||
|
# VL
|
||||||
|
victoria-logs:
|
||||||
|
image: docker.io/victoriametrics/victoria-logs:v1.43.1
|
||||||
|
ports:
|
||||||
|
- 9428:9428
|
||||||
|
command: -storageDataPath=victoria-logs-data
|
||||||
|
|
||||||
|
# VT
|
||||||
|
victoria-traces:
|
||||||
|
image: docker.io/victoriametrics/victoria-traces:latest
|
||||||
|
ports:
|
||||||
|
- 10428:10428
|
||||||
|
|||||||
@@ -471,7 +471,6 @@ pub async fn setup_logging(release: &'static str, dsn: String) -> Option<sentry:
|
|||||||
std::env::set_var("ROCKET_ADDRESS", "0.0.0.0");
|
std::env::set_var("ROCKET_ADDRESS", "0.0.0.0");
|
||||||
}
|
}
|
||||||
|
|
||||||
pretty_env_logger::init();
|
|
||||||
log::info!("Starting {release}");
|
log::info!("Starting {release}");
|
||||||
|
|
||||||
if dsn.is_empty() {
|
if dsn.is_empty() {
|
||||||
|
|||||||
@@ -39,10 +39,6 @@ serde = { version = "1.0", features = ["derive"] }
|
|||||||
# Async runtime
|
# Async runtime
|
||||||
tokio = { version = "1.0", features = ["full"] }
|
tokio = { version = "1.0", features = ["full"] }
|
||||||
|
|
||||||
# Logging
|
|
||||||
tracing = "0.1"
|
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
|
||||||
|
|
||||||
# Core crates
|
# Core crates
|
||||||
revolt-files = { version = "0.9.4", path = "../../core/files" }
|
revolt-files = { version = "0.9.4", path = "../../core/files" }
|
||||||
revolt-config = { version = "0.9.4", path = "../../core/config" }
|
revolt-config = { version = "0.9.4", path = "../../core/config" }
|
||||||
@@ -53,15 +49,25 @@ revolt-result = { version = "0.9.4", path = "../../core/result", features = [
|
|||||||
"utoipa",
|
"utoipa",
|
||||||
"axum",
|
"axum",
|
||||||
] }
|
] }
|
||||||
revolt-ratelimits = { version = "0.9.4", path = "../../core/ratelimits", features = ["axum"] }
|
revolt-ratelimits = { version = "0.9.4", path = "../../core/ratelimits", features = [
|
||||||
|
"axum",
|
||||||
|
] }
|
||||||
|
|
||||||
# Axum / web server
|
# Axum / web server
|
||||||
tempfile = "3.12.0"
|
tempfile = "3.12.0"
|
||||||
axum-macros = "0.4.1"
|
axum-macros = { workspace = true }
|
||||||
axum_typed_multipart = "0.12.1"
|
axum_typed_multipart = { workspace = true }
|
||||||
axum = { version = "0.7.5", features = ["multipart"] }
|
axum = { workspace = true }
|
||||||
tower-http = { version = "0.5.2", features = ["cors"] }
|
tower-http = { workspace = true }
|
||||||
|
|
||||||
# OpenAPI & documentation generation
|
# OpenAPI & documentation generation
|
||||||
utoipa-scalar = { version = "0.1.0", features = ["axum"] }
|
utoipa-scalar = { version = "0.1.0", features = ["axum"] }
|
||||||
utoipa = { version = "4.2.3", features = ["axum_extras", "ulid"] }
|
utoipa = { version = "4.2.3", features = ["axum_extras", "ulid"] }
|
||||||
|
|
||||||
|
# OpenTelemetry
|
||||||
|
tracing = { workspace = true }
|
||||||
|
tracing-subscriber = { workspace = true }
|
||||||
|
opentelemetry = { workspace = true }
|
||||||
|
opentelemetry_sdk = { workspace = true }
|
||||||
|
opentelemetry-otlp = { workspace = true }
|
||||||
|
opentelemetry-appender-tracing = { workspace = true }
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ use utoipa::ToSchema;
|
|||||||
|
|
||||||
use crate::{exif::strip_metadata, metadata::generate_metadata, mime_type::determine_mime_type, AppState};
|
use crate::{exif::strip_metadata, metadata::generate_metadata, mime_type::determine_mime_type, AppState};
|
||||||
|
|
||||||
/// Build the API router
|
|
||||||
pub async fn router() -> Router<AppState> {
|
pub async fn router() -> Router<AppState> {
|
||||||
let config = config().await;
|
let config = config().await;
|
||||||
|
|
||||||
@@ -52,8 +51,6 @@ pub async fn router() -> Router<AppState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
lazy_static! {
|
lazy_static! {
|
||||||
/// Short-lived file cache to allow us to populate different CDN regions without increasing bandwidth to S3 provider
|
|
||||||
/// Uploads will also be stored here to prevent immediately queued downloads from doing the entire round-trip
|
|
||||||
static ref S3_CACHE: moka::future::Cache<String, Result<Vec<u8>>> = moka::future::Cache::builder()
|
static ref S3_CACHE: moka::future::Cache<String, Result<Vec<u8>>> = moka::future::Cache::builder()
|
||||||
.weigher(|_key, value: &Result<Vec<u8>>| -> u32 {
|
.weigher(|_key, value: &Result<Vec<u8>>| -> u32 {
|
||||||
std::mem::size_of::<Result<Vec<u8>>>() as u32 + if let Ok(vec) = value {
|
std::mem::size_of::<Result<Vec<u8>>>() as u32 + if let Ok(vec) = value {
|
||||||
@@ -70,7 +67,6 @@ lazy_static! {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Retrieve hash information and file data by given hash
|
|
||||||
async fn retrieve_file_by_hash(hash: &FileHash) -> Result<Vec<u8>> {
|
async fn retrieve_file_by_hash(hash: &FileHash) -> Result<Vec<u8>> {
|
||||||
if let Some(data) = S3_CACHE.get(&hash.id).await {
|
if let Some(data) = S3_CACHE.get(&hash.id).await {
|
||||||
data
|
data
|
||||||
@@ -81,17 +77,15 @@ async fn retrieve_file_by_hash(hash: &FileHash) -> Result<Vec<u8>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Successful root response
|
|
||||||
#[derive(Serialize, Debug, ToSchema)]
|
#[derive(Serialize, Debug, ToSchema)]
|
||||||
pub struct RootResponse {
|
pub struct RootResponse {
|
||||||
autumn: &'static str,
|
autumn: &'static str,
|
||||||
version: &'static str,
|
version: &'static str,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Capture crate version from Cargo
|
|
||||||
static CRATE_VERSION: &str = env!("CARGO_PKG_VERSION");
|
static CRATE_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
|
|
||||||
/// Root response from service
|
/// Get information about the service
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
path = "/",
|
path = "/",
|
||||||
@@ -106,7 +100,6 @@ async fn root() -> Json<RootResponse> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Empty handler for OPTIONS routes
|
|
||||||
async fn options() {}
|
async fn options() {}
|
||||||
|
|
||||||
/// Available tags to upload to
|
/// Available tags to upload to
|
||||||
@@ -170,28 +163,19 @@ async fn upload_file(
|
|||||||
Path(tag): Path<Tag>,
|
Path(tag): Path<Tag>,
|
||||||
TypedMultipart(UploadPayload { mut file }): TypedMultipart<UploadPayload>,
|
TypedMultipart(UploadPayload { mut file }): TypedMultipart<UploadPayload>,
|
||||||
) -> Result<Json<UploadResponse>> {
|
) -> Result<Json<UploadResponse>> {
|
||||||
// Fetch configuration
|
let now = Instant::now();
|
||||||
let config = config().await;
|
let config = config().await;
|
||||||
|
|
||||||
// Keep track of processing time
|
|
||||||
let now = Instant::now();
|
|
||||||
|
|
||||||
// Extract the filename, or give it a generic name
|
|
||||||
let filename = file.metadata.file_name.unwrap_or("unnamed-file".to_owned());
|
let filename = file.metadata.file_name.unwrap_or("unnamed-file".to_owned());
|
||||||
|
|
||||||
// Load file to memory
|
|
||||||
let mut buf = Vec::<u8>::new();
|
let mut buf = Vec::<u8>::new();
|
||||||
report_internal_error!(file.contents.read_to_end(&mut buf))?;
|
report_internal_error!(file.contents.read_to_end(&mut buf))?;
|
||||||
|
|
||||||
// Take note of original file size
|
|
||||||
let original_file_size = buf.len();
|
let original_file_size = buf.len();
|
||||||
|
|
||||||
// Ensure the file is not empty
|
|
||||||
if original_file_size < config.files.limit.min_file_size {
|
if original_file_size < config.files.limit.min_file_size {
|
||||||
return Err(create_error!(FileTooSmall));
|
return Err(create_error!(FileTooSmall));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get user's file upload limits
|
|
||||||
let limits = user.limits().await;
|
let limits = user.limits().await;
|
||||||
let size_limit = *limits
|
let size_limit = *limits
|
||||||
.file_upload_size_limit
|
.file_upload_size_limit
|
||||||
@@ -202,24 +186,19 @@ async fn upload_file(
|
|||||||
return Err(create_error!(FileTooLarge { max: size_limit }));
|
return Err(create_error!(FileTooLarge { max: size_limit }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate sha256 hash
|
|
||||||
let original_hash = {
|
let original_hash = {
|
||||||
let mut hasher = sha2::Sha256::new();
|
let mut hasher = sha2::Sha256::new();
|
||||||
hasher.update(&buf);
|
hasher.update(&buf);
|
||||||
hasher.finalize()
|
hasher.finalize()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Generate an ID for this file
|
|
||||||
let id = if matches!(tag, Tag::emojis) {
|
let id = if matches!(tag, Tag::emojis) {
|
||||||
ulid::Ulid::new().to_string()
|
ulid::Ulid::new().to_string()
|
||||||
} else {
|
} else {
|
||||||
nanoid::nanoid!(42)
|
nanoid::nanoid!(42)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Determine the mime type for the file
|
|
||||||
let mime_type = determine_mime_type(&mut file.contents, &buf, &filename);
|
let mime_type = determine_mime_type(&mut file.contents, &buf, &filename);
|
||||||
|
|
||||||
// Check blocklist for mime type
|
|
||||||
if config
|
if config
|
||||||
.files
|
.files
|
||||||
.blocked_mime_types
|
.blocked_mime_types
|
||||||
@@ -229,15 +208,11 @@ async fn upload_file(
|
|||||||
return Err(create_error!(FileTypeNotAllowed));
|
return Err(create_error!(FileTypeNotAllowed));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine metadata for the file
|
|
||||||
let metadata = generate_metadata(&file.contents, mime_type);
|
let metadata = generate_metadata(&file.contents, mime_type);
|
||||||
|
|
||||||
// Block non-images for non-attachment uploads
|
|
||||||
if !matches!(tag, Tag::attachments) && !matches!(metadata, Metadata::Image { .. }) {
|
if !matches!(tag, Tag::attachments) && !matches!(metadata, Metadata::Image { .. }) {
|
||||||
return Err(create_error!(FileTypeNotAllowed));
|
return Err(create_error!(FileTypeNotAllowed));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find an existing hash and use that if possible
|
|
||||||
let file_hash_exists = if let Ok(file_hash) = db
|
let file_hash_exists = if let Ok(file_hash) = db
|
||||||
.fetch_attachment_hash(&format!("{original_hash:02x}"))
|
.fetch_attachment_hash(&format!("{original_hash:02x}"))
|
||||||
.await
|
.await
|
||||||
@@ -260,10 +235,8 @@ async fn upload_file(
|
|||||||
false
|
false
|
||||||
};
|
};
|
||||||
|
|
||||||
// Strip metadata
|
|
||||||
let (buf, metadata) = strip_metadata(file.contents, buf, metadata, mime_type).await?;
|
let (buf, metadata) = strip_metadata(file.contents, buf, metadata, mime_type).await?;
|
||||||
|
|
||||||
// Virus scan files if ClamAV is configured
|
|
||||||
if matches!(metadata, Metadata::File)
|
if matches!(metadata, Metadata::File)
|
||||||
&& (config.files.scan_mime_types.is_empty()
|
&& (config.files.scan_mime_types.is_empty()
|
||||||
|| config.files.scan_mime_types.iter().any(|v| v == mime_type))
|
|| config.files.scan_mime_types.iter().any(|v| v == mime_type))
|
||||||
@@ -272,7 +245,6 @@ async fn upload_file(
|
|||||||
return Err(create_error!(InternalError));
|
return Err(create_error!(InternalError));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Print file information for debug purposes
|
|
||||||
let new_file_size = buf.len() + AUTHENTICATION_TAG_SIZE_BYTES;
|
let new_file_size = buf.len() + AUTHENTICATION_TAG_SIZE_BYTES;
|
||||||
let processed_hash = {
|
let processed_hash = {
|
||||||
let mut hasher = sha2::Sha256::new();
|
let mut hasher = sha2::Sha256::new();
|
||||||
@@ -284,7 +256,6 @@ async fn upload_file(
|
|||||||
|
|
||||||
tracing::info!("Received file {filename}\nOriginal hash: {original_hash:02x}\nOriginal size: {original_file_size} bytes\nMime type: {mime_type}\nMetadata: {metadata:?}\nProcessed file size: {new_file_size} bytes ({:.2}%).\nProcessed hash: {processed_hash:02x}\nProcessing took {time_to_process:?}", process_ratio * 100.0);
|
tracing::info!("Received file {filename}\nOriginal hash: {original_hash:02x}\nOriginal size: {original_file_size} bytes\nMime type: {mime_type}\nMetadata: {metadata:?}\nProcessed file size: {new_file_size} bytes ({:.2}%).\nProcessed hash: {processed_hash:02x}\nProcessing took {time_to_process:?}", process_ratio * 100.0);
|
||||||
|
|
||||||
// Create hash entry in database
|
|
||||||
let file_hash = FileHash {
|
let file_hash = FileHash {
|
||||||
id: format!("{original_hash:02x}"),
|
id: format!("{original_hash:02x}"),
|
||||||
processed_hash: format!("{processed_hash:02x}"),
|
processed_hash: format!("{processed_hash:02x}"),
|
||||||
@@ -300,21 +271,17 @@ async fn upload_file(
|
|||||||
size: new_file_size as isize,
|
size: new_file_size as isize,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add attachment hash if it doesn't exist
|
|
||||||
if !file_hash_exists {
|
if !file_hash_exists {
|
||||||
db.insert_attachment_hash(&file_hash).await?;
|
db.insert_attachment_hash(&file_hash).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload the file to S3 and commit nonce to database
|
|
||||||
let upload_start = Instant::now();
|
let upload_start = Instant::now();
|
||||||
let nonce = upload_to_s3(&file_hash.bucket_id, &file_hash.id, &buf).await?;
|
let nonce = upload_to_s3(&file_hash.bucket_id, &file_hash.id, &buf).await?;
|
||||||
db.set_attachment_hash_nonce(&file_hash.id, &nonce).await?;
|
db.set_attachment_hash_nonce(&file_hash.id, &nonce).await?;
|
||||||
|
|
||||||
// Debug information
|
|
||||||
let time_to_upload = Instant::now() - upload_start;
|
let time_to_upload = Instant::now() - upload_start;
|
||||||
tracing::info!("Took {time_to_upload:?} to upload {new_file_size} bytes to S3.");
|
tracing::info!("Took {time_to_upload:?} to upload {new_file_size} bytes to S3.");
|
||||||
|
|
||||||
// Finally, create the file and return its ID
|
|
||||||
let tag: &'static str = tag.into();
|
let tag: &'static str = tag.into();
|
||||||
db.insert_attachment(&file_hash.into_file(id.clone(), tag.to_owned(), filename, user.id))
|
db.insert_attachment(&file_hash.into_file(id.clone(), tag.to_owned(), filename, user.id))
|
||||||
.await?;
|
.await?;
|
||||||
@@ -362,12 +329,10 @@ async fn fetch_preview(
|
|||||||
let tag_str: &'static str = tag.clone().into();
|
let tag_str: &'static str = tag.clone().into();
|
||||||
let file = db.fetch_attachment(tag_str, &file_id).await?;
|
let file = db.fetch_attachment(tag_str, &file_id).await?;
|
||||||
|
|
||||||
// Ignore deleted files
|
|
||||||
if file.deleted.is_some_and(|v| v) {
|
if file.deleted.is_some_and(|v| v) {
|
||||||
return Err(create_error!(NotFound));
|
return Err(create_error!(NotFound));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ignore files that haven't been attached
|
|
||||||
if file.used_for.is_none() {
|
if file.used_for.is_none() {
|
||||||
return Err(create_error!(NotFound));
|
return Err(create_error!(NotFound));
|
||||||
}
|
}
|
||||||
@@ -376,7 +341,7 @@ async fn fetch_preview(
|
|||||||
|
|
||||||
let is_animated = hash.content_type == "image/gif"; // TODO: extract this data from files
|
let is_animated = hash.content_type == "image/gif"; // TODO: extract this data from files
|
||||||
|
|
||||||
// Only process image files and don't process GIFs if not avatar or icon
|
// Process GIFs if avatar or icon
|
||||||
if !matches!(hash.metadata, Metadata::Image { .. })
|
if !matches!(hash.metadata, Metadata::Image { .. })
|
||||||
|| (is_animated && !matches!(tag, Tag::avatars | Tag::icons))
|
|| (is_animated && !matches!(tag, Tag::avatars | Tag::icons))
|
||||||
{
|
{
|
||||||
@@ -385,10 +350,8 @@ async fn fetch_preview(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Original image data
|
|
||||||
let data = retrieve_file_by_hash(&hash).await?;
|
let data = retrieve_file_by_hash(&hash).await?;
|
||||||
|
|
||||||
// Read image and create thumbnail
|
|
||||||
let data = create_thumbnail(
|
let data = create_thumbnail(
|
||||||
decode_image(&mut Cursor::new(data), &file.content_type)?,
|
decode_image(&mut Cursor::new(data), &file.content_type)?,
|
||||||
tag_str,
|
tag_str,
|
||||||
@@ -430,17 +393,14 @@ async fn fetch_file(
|
|||||||
let tag: &'static str = tag.clone().into();
|
let tag: &'static str = tag.clone().into();
|
||||||
let file = db.fetch_attachment(tag, &file_id).await?;
|
let file = db.fetch_attachment(tag, &file_id).await?;
|
||||||
|
|
||||||
// Ignore deleted files
|
|
||||||
if file.deleted.is_some_and(|v| v) {
|
if file.deleted.is_some_and(|v| v) {
|
||||||
return Err(create_error!(NotFound));
|
return Err(create_error!(NotFound));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ignore files that haven't been attached
|
|
||||||
if file.used_for.is_none() {
|
if file.used_for.is_none() {
|
||||||
return Err(create_error!(NotFound));
|
return Err(create_error!(NotFound));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure filename is correct
|
|
||||||
if file_name != file.filename {
|
if file_name != file.filename {
|
||||||
if file_name == "original" {
|
if file_name == "original" {
|
||||||
return Ok(
|
return Ok(
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ use std::time::Duration;
|
|||||||
use revolt_config::{config, report_internal_error};
|
use revolt_config::{config, report_internal_error};
|
||||||
use revolt_result::Result;
|
use revolt_result::Result;
|
||||||
|
|
||||||
/// Initialise ClamAV
|
|
||||||
pub async fn init() {
|
pub async fn init() {
|
||||||
let config = config().await;
|
let config = config().await;
|
||||||
|
|
||||||
@@ -32,7 +31,6 @@ pub async fn init() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Scan for malware
|
|
||||||
pub async fn is_malware(buf: &[u8]) -> Result<bool> {
|
pub async fn is_malware(buf: &[u8]) -> Result<bool> {
|
||||||
let config = config().await;
|
let config = config().await;
|
||||||
if config.files.clamd_host.is_empty() {
|
if config.files.clamd_host.is_empty() {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ use revolt_result::{create_error, Result};
|
|||||||
use tempfile::NamedTempFile;
|
use tempfile::NamedTempFile;
|
||||||
use tokio::process::Command;
|
use tokio::process::Command;
|
||||||
|
|
||||||
/// Strip EXIF data from given file and produce new file and metadata
|
|
||||||
pub async fn strip_metadata(
|
pub async fn strip_metadata(
|
||||||
file: NamedTempFile,
|
file: NamedTempFile,
|
||||||
buf: Vec<u8>,
|
buf: Vec<u8>,
|
||||||
@@ -37,20 +36,16 @@ pub async fn strip_metadata(
|
|||||||
// }
|
// }
|
||||||
// Apply orientation manually & strip all other EXIF data
|
// Apply orientation manually & strip all other EXIF data
|
||||||
"image/jpeg" | "image/png" | "image/avif" | "image/tiff" => {
|
"image/jpeg" | "image/png" | "image/avif" | "image/tiff" => {
|
||||||
// Create a reader
|
|
||||||
let mut cursor = Cursor::new(buf);
|
let mut cursor = Cursor::new(buf);
|
||||||
|
|
||||||
// Decode the image
|
|
||||||
let image = report_internal_error!(report_internal_error!(ImageReader::new(
|
let image = report_internal_error!(report_internal_error!(ImageReader::new(
|
||||||
&mut cursor
|
&mut cursor
|
||||||
)
|
)
|
||||||
.with_guessed_format())?
|
.with_guessed_format())?
|
||||||
.decode());
|
.decode());
|
||||||
|
|
||||||
// Reset read position
|
|
||||||
cursor.set_position(0);
|
cursor.set_position(0);
|
||||||
|
|
||||||
// Extract orientation data
|
|
||||||
let exif_reader = Reader::new();
|
let exif_reader = Reader::new();
|
||||||
let rotation = match exif_reader.read_from_container(&mut cursor) {
|
let rotation = match exif_reader.read_from_container(&mut cursor) {
|
||||||
Ok(exif) => match exif.get_field(exif::Tag::Orientation, exif::In::PRIMARY) {
|
Ok(exif) => match exif.get_field(exif::Tag::Orientation, exif::In::PRIMARY) {
|
||||||
@@ -60,12 +55,10 @@ pub async fn strip_metadata(
|
|||||||
_ => 0,
|
_ => 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create a buffer to write to
|
|
||||||
let mut bytes: Vec<u8> = Vec::new();
|
let mut bytes: Vec<u8> = Vec::new();
|
||||||
let mut writer = Cursor::new(&mut bytes);
|
let mut writer = Cursor::new(&mut bytes);
|
||||||
|
|
||||||
// Apply the EXIF rotation
|
// https://jdhao.github.io/2019/07/31/image_rotation_exif_info/
|
||||||
// See https://jdhao.github.io/2019/07/31/image_rotation_exif_info/
|
|
||||||
report_internal_error!(match &rotation {
|
report_internal_error!(match &rotation {
|
||||||
2 => image?.fliph(),
|
2 => image?.fliph(),
|
||||||
3 => image?.rotate180(),
|
3 => image?.rotate180(),
|
||||||
@@ -87,7 +80,6 @@ pub async fn strip_metadata(
|
|||||||
},
|
},
|
||||||
))?;
|
))?;
|
||||||
|
|
||||||
// Calculate dimensions after rotation.
|
|
||||||
let (width, height) = match &rotation {
|
let (width, height) = match &rotation {
|
||||||
2 | 4 | 5 | 7 => (*height, *width),
|
2 | 4 | 5 | 7 => (*height, *width),
|
||||||
_ => (*width, *height),
|
_ => (*width, *height),
|
||||||
@@ -95,16 +87,13 @@ pub async fn strip_metadata(
|
|||||||
|
|
||||||
Ok((bytes, Metadata::Image { width, height }))
|
Ok((bytes, Metadata::Image { width, height }))
|
||||||
}
|
}
|
||||||
// JXLs store EXIF data but we don't have the ability to write them
|
// TODO: JXLs store EXIF data but we don't have the ability to write them
|
||||||
"image/jxl" => Ok((buf, metadata)),
|
"image/jxl" => Ok((buf, metadata)),
|
||||||
// All other images that cannot store EXIF data
|
// assume all other images that cannot store EXIF data
|
||||||
_ => Ok((buf, metadata)),
|
_ => Ok((buf, metadata)),
|
||||||
},
|
},
|
||||||
// Use ffmpeg to copy video stream and probe new metadata
|
|
||||||
Metadata::Video { .. } => match mime {
|
Metadata::Video { .. } => match mime {
|
||||||
// Strip EXIF data by copying video stream
|
|
||||||
"video/mp4" | "video/webm" | "video/quicktime" => {
|
"video/mp4" | "video/webm" | "video/quicktime" => {
|
||||||
// Pick the correct file format for ffmpeg
|
|
||||||
let ext = match mime {
|
let ext = match mime {
|
||||||
"video/mp4" => "mp4",
|
"video/mp4" => "mp4",
|
||||||
"video/webm" => "webm",
|
"video/webm" => "webm",
|
||||||
@@ -112,30 +101,22 @@ pub async fn strip_metadata(
|
|||||||
_ => unreachable!(),
|
_ => unreachable!(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Temporary output file
|
|
||||||
let mut out_file = report_internal_error!(NamedTempFile::new())?;
|
let mut out_file = report_internal_error!(NamedTempFile::new())?;
|
||||||
|
|
||||||
// Process the file with ffmpeg
|
|
||||||
report_internal_error!(
|
report_internal_error!(
|
||||||
Command::new("ffmpeg")
|
Command::new("ffmpeg")
|
||||||
.args([
|
.args([
|
||||||
// Overwrite the temporary file
|
|
||||||
"-y",
|
"-y",
|
||||||
// Read original uploaded file
|
|
||||||
"-i",
|
"-i",
|
||||||
file.path().to_str().ok_or(create_error!(InternalError))?,
|
file.path().to_str().ok_or(create_error!(InternalError))?,
|
||||||
// Strip any metadata
|
"-map_metadata", // strip metadata
|
||||||
"-map_metadata",
|
|
||||||
"-1",
|
"-1",
|
||||||
// Copy video / audio data to new file
|
"-c:v", // just copy the streams
|
||||||
"-c:v",
|
|
||||||
"copy",
|
"copy",
|
||||||
"-c:a",
|
"-c:a",
|
||||||
"copy",
|
"copy",
|
||||||
// Select correct file format
|
|
||||||
"-f",
|
"-f",
|
||||||
ext,
|
ext,
|
||||||
// Save to new temporary file
|
|
||||||
out_file
|
out_file
|
||||||
.path()
|
.path()
|
||||||
.to_str()
|
.to_str()
|
||||||
@@ -145,19 +126,17 @@ pub async fn strip_metadata(
|
|||||||
.await
|
.await
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
// Probe the file again
|
|
||||||
let metadata = crate::metadata::generate_metadata(&out_file, mime);
|
let metadata = crate::metadata::generate_metadata(&out_file, mime);
|
||||||
|
|
||||||
// Read the file from disk
|
|
||||||
let mut buf = Vec::<u8>::new();
|
let mut buf = Vec::<u8>::new();
|
||||||
report_internal_error!(out_file.read_to_end(&mut buf))?;
|
report_internal_error!(out_file.read_to_end(&mut buf))?;
|
||||||
|
|
||||||
Ok((buf, metadata))
|
Ok((buf, metadata))
|
||||||
}
|
}
|
||||||
// Assume all other video formats cannot store EXIF data
|
// assume all other video formats cannot store EXIF data
|
||||||
_ => Ok((buf, metadata)),
|
_ => Ok((buf, metadata)),
|
||||||
},
|
},
|
||||||
// all other file types don't store EXIF data
|
// assume all other file types don't store EXIF data
|
||||||
_ => Ok((buf, metadata)),
|
_ => Ok((buf, metadata)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use axum_macros::FromRef;
|
|||||||
use revolt_database::{Database, DatabaseInfo};
|
use revolt_database::{Database, DatabaseInfo};
|
||||||
use revolt_ratelimits::axum as ratelimiter;
|
use revolt_ratelimits::axum as ratelimiter;
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
|
use tower_http::trace::TraceLayer;
|
||||||
use utoipa::{
|
use utoipa::{
|
||||||
openapi::security::{ApiKey, ApiKeyValue, SecurityScheme},
|
openapi::security::{ApiKey, ApiKeyValue, SecurityScheme},
|
||||||
Modify, OpenApi,
|
Modify, OpenApi,
|
||||||
@@ -27,13 +28,32 @@ struct AppState {
|
|||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), std::io::Error> {
|
async fn main() -> Result<(), std::io::Error> {
|
||||||
// Configure logging and environment
|
let logger_provider = init_logs();
|
||||||
revolt_config::configure!(files);
|
|
||||||
|
|
||||||
// Wait for ClamAV
|
let otel_layer = OpenTelemetryTracingBridge::new(&logger_provider);
|
||||||
|
|
||||||
|
let filter_otel = EnvFilter::new("info")
|
||||||
|
.add_directive("hyper=off".parse().unwrap())
|
||||||
|
.add_directive("tonic=off".parse().unwrap())
|
||||||
|
.add_directive("h2=off".parse().unwrap())
|
||||||
|
.add_directive("reqwest=off".parse().unwrap());
|
||||||
|
|
||||||
|
let otel_layer = otel_layer.with_filter(filter_otel);
|
||||||
|
|
||||||
|
let filter_fmt = EnvFilter::new("info");
|
||||||
|
|
||||||
|
let fmt_layer = tracing_subscriber::fmt::layer()
|
||||||
|
.with_thread_names(true)
|
||||||
|
.with_filter(filter_fmt);
|
||||||
|
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(otel_layer)
|
||||||
|
.with(fmt_layer)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
revolt_config::configure!(files);
|
||||||
clamav::init().await;
|
clamav::init().await;
|
||||||
|
|
||||||
// Configure API schema
|
|
||||||
#[derive(OpenApi)]
|
#[derive(OpenApi)]
|
||||||
#[openapi(
|
#[openapi(
|
||||||
modifiers(&SecurityAddon),
|
modifiers(&SecurityAddon),
|
||||||
@@ -76,7 +96,6 @@ async fn main() -> Result<(), std::io::Error> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connect to the database
|
|
||||||
let db = DatabaseInfo::Auto.connect().await.unwrap();
|
let db = DatabaseInfo::Auto.connect().await.unwrap();
|
||||||
let ratelimits = ratelimiter::RatelimitStorage::new(ratelimits::AutumnRatelimits);
|
let ratelimits = ratelimiter::RatelimitStorage::new(ratelimits::AutumnRatelimits);
|
||||||
|
|
||||||
@@ -85,7 +104,6 @@ async fn main() -> Result<(), std::io::Error> {
|
|||||||
ratelimit_storage: ratelimits,
|
ratelimit_storage: ratelimits,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Configure Axum and router
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.merge(Scalar::with_url("/scalar", ApiDoc::openapi()))
|
.merge(Scalar::with_url("/scalar", ApiDoc::openapi()))
|
||||||
.nest("/", api::router().await)
|
.nest("/", api::router().await)
|
||||||
@@ -94,10 +112,56 @@ async fn main() -> Result<(), std::io::Error> {
|
|||||||
state.clone(),
|
state.clone(),
|
||||||
ratelimiter::ratelimit_middleware,
|
ratelimiter::ratelimit_middleware,
|
||||||
))
|
))
|
||||||
|
.layer(TraceLayer::new_for_http())
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
|
|
||||||
// Configure TCP listener and bind
|
|
||||||
let address = SocketAddr::from((Ipv4Addr::UNSPECIFIED, 14704));
|
let address = SocketAddr::from((Ipv4Addr::UNSPECIFIED, 14704));
|
||||||
let listener = TcpListener::bind(&address).await?;
|
let listener = TcpListener::bind(&address).await?;
|
||||||
axum::serve(listener, app.into_make_service()).await
|
axum::serve(listener, app.into_make_service()).await?;
|
||||||
|
|
||||||
|
if let Err(e) = logger_provider.shutdown() {
|
||||||
|
panic!("logger provider failed to shut down");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
use opentelemetry::trace::{TraceContextExt, Tracer};
|
||||||
|
use opentelemetry::KeyValue;
|
||||||
|
use opentelemetry::{global, InstrumentationScope};
|
||||||
|
use opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge;
|
||||||
|
use opentelemetry_otlp::{LogExporter, MetricExporter, Protocol, SpanExporter, WithExportConfig};
|
||||||
|
use opentelemetry_sdk::logs::SdkLoggerProvider;
|
||||||
|
use opentelemetry_sdk::metrics::SdkMeterProvider;
|
||||||
|
use opentelemetry_sdk::trace::SdkTracerProvider;
|
||||||
|
use opentelemetry_sdk::Resource;
|
||||||
|
use std::error::Error;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
use tracing::info;
|
||||||
|
use tracing_subscriber::prelude::*;
|
||||||
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
|
fn get_resource() -> Resource {
|
||||||
|
static RESOURCE: OnceLock<Resource> = OnceLock::new();
|
||||||
|
RESOURCE
|
||||||
|
.get_or_init(|| {
|
||||||
|
Resource::builder()
|
||||||
|
.with_service_name("basic-otlp-example-grpc")
|
||||||
|
.build()
|
||||||
|
})
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init_logs() -> SdkLoggerProvider {
|
||||||
|
let exporter = LogExporter::builder()
|
||||||
|
.with_http()
|
||||||
|
.with_endpoint("http://localhost:19428/insert/opentelemetry/v1/logs")
|
||||||
|
.with_protocol(Protocol::HttpBinary)
|
||||||
|
.build()
|
||||||
|
.expect("Failed to create log exporter");
|
||||||
|
|
||||||
|
SdkLoggerProvider::builder()
|
||||||
|
.with_resource(get_resource())
|
||||||
|
.with_batch_exporter(exporter)
|
||||||
|
.build()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ static SUPPORTED_IMAGE_MIME: [&str; 9] = [
|
|||||||
"image/webp",
|
"image/webp",
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Generate metadata from file, using mime type as a hint
|
|
||||||
pub fn generate_metadata(f: &NamedTempFile, mime_type: &str) -> Metadata {
|
pub fn generate_metadata(f: &NamedTempFile, mime_type: &str) -> Metadata {
|
||||||
if SUPPORTED_IMAGE_MIME.contains(&mime_type) {
|
if SUPPORTED_IMAGE_MIME.contains(&mime_type) {
|
||||||
image_size(f)
|
image_size(f)
|
||||||
@@ -44,7 +43,6 @@ pub fn generate_metadata(f: &NamedTempFile, mime_type: &str) -> Metadata {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Subroutine to ensure data isn't corrupted
|
|
||||||
pub fn validate_from_metadata(
|
pub fn validate_from_metadata(
|
||||||
reader: Cursor<Vec<u8>>,
|
reader: Cursor<Vec<u8>>,
|
||||||
metadata: Metadata,
|
metadata: Metadata,
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
use tempfile::NamedTempFile;
|
use tempfile::NamedTempFile;
|
||||||
|
|
||||||
/// Determine the mime type of the given temporary file and filename
|
|
||||||
pub fn determine_mime_type(f: &mut NamedTempFile, buf: &[u8], file_name: &str) -> &'static str {
|
pub fn determine_mime_type(f: &mut NamedTempFile, buf: &[u8], file_name: &str) -> &'static str {
|
||||||
// Force certain extensions into particular mime types
|
|
||||||
if file_name.to_lowercase().ends_with(".apk") {
|
if file_name.to_lowercase().ends_with(".apk") {
|
||||||
return "application/vnd.android.package-archive";
|
return "application/vnd.android.package-archive";
|
||||||
} else if file_name.to_lowercase().ends_with(".exe") {
|
} else if file_name.to_lowercase().ends_with(".exe") {
|
||||||
return "application/vnd.microsoft.portable-executable";
|
return "application/vnd.microsoft.portable-executable";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use magic signatures to determine mime type
|
|
||||||
let kind = infer::get_from_path(f.path()).expect("file read successfully");
|
let kind = infer::get_from_path(f.path()).expect("file read successfully");
|
||||||
let mime_type = if let Some(kind) = kind {
|
let mime_type = if let Some(kind) = kind {
|
||||||
kind.mime_type()
|
kind.mime_type()
|
||||||
@@ -17,7 +14,6 @@ pub fn determine_mime_type(f: &mut NamedTempFile, buf: &[u8], file_name: &str) -
|
|||||||
"application/octet-stream"
|
"application/octet-stream"
|
||||||
};
|
};
|
||||||
|
|
||||||
// See if the file is actually just plain Unicode/ASCII text
|
|
||||||
if mime_type == "application/octet-stream" && simdutf8::basic::from_utf8(buf).is_ok() {
|
if mime_type == "application/octet-stream" && simdutf8::basic::from_utf8(buf).is_ok() {
|
||||||
if file_name.to_lowercase().ends_with(".svg") {
|
if file_name.to_lowercase().ends_with(".svg") {
|
||||||
return "image/svg+xml";
|
return "image/svg+xml";
|
||||||
|
|||||||
Reference in New Issue
Block a user