diff --git a/Cargo.lock b/Cargo.lock index c754f4bd..8919f3ca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3485,6 +3485,15 @@ dependencies = [ "sha2", ] +[[package]] +name = "kamadak-exif" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef4fc70d0ab7e5b6bafa30216a6b48705ea964cdfc29c050f2412295eba58077" +dependencies = [ + "mutate_once", +] + [[package]] name = "kv-log-macro" version = "1.0.7" @@ -3990,6 +3999,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "mutate_once" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16cf681a23b4d0a43fc35024c176437f9dcd818db34e0f42ab456a0ee5ad497b" + [[package]] name = "nanoid" version = "0.4.0" @@ -4399,9 +4414,9 @@ dependencies = [ [[package]] name = "paste" -version = "1.0.7" +version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c520e05135d6e763148b6426a837e239041653ba7becd2e538c076c738025fc" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] name = "pathdiff" @@ -5286,6 +5301,7 @@ dependencies = [ "imagesize", "infer", "jxl-oxide", + "kamadak-exif", "lazy_static", "moka", "revolt-config", @@ -5294,6 +5310,8 @@ dependencies = [ "revolt-result", "serde", "serde_json", + "sha2", + "simdutf8", "strum_macros", "tempfile", "tokio 1.35.1", @@ -6443,9 +6461,9 @@ checksum = "ae1a47186c03a32177042e55dbc5fd5aee900b8e0069a8d70fba96a9375cd012" [[package]] name = "sha2" -version = "0.10.2" +version = "0.10.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55deaec60f81eefe3cce0dc50bda92d6d8e88f2a27df7c5033b42afeb1ed2676" +checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" dependencies = [ "cfg-if", "cpufeatures", @@ -6511,6 +6529,12 @@ dependencies = [ "quote 1.0.37", ] +[[package]] +name = "simdutf8" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27f6278552951f1f2b8cf9da965d10969b2efdea95a6ec47987ab46edfe263a" + [[package]] name = "slab" version = "0.4.6" diff --git a/crates/core/files/src/lib.rs b/crates/core/files/src/lib.rs index 2dec2eb8..fb83871a 100644 --- a/crates/core/files/src/lib.rs +++ b/crates/core/files/src/lib.rs @@ -11,6 +11,8 @@ use aws_sdk_s3::{ use base64::prelude::*; +pub static AUTHENTICATION_TAG_SIZE_BYTES: usize = 16; + /// Create an S3 client pub fn create_client(s3_config: FilesS3) -> Client { let provider_name = "my-creds"; diff --git a/crates/services/autumn/Cargo.toml b/crates/services/autumn/Cargo.toml index f9a1a4a1..d6739f69 100644 --- a/crates/services/autumn/Cargo.toml +++ b/crates/services/autumn/Cargo.toml @@ -6,8 +6,12 @@ edition = "2021" [dependencies] # Media processing webp = "0.3.0" +sha2 = "0.10.8" jxl-oxide = "0.8.1" -image = { version = "0.25.2" } # requires dav1d system library: features = ["avif-native"] +kamadak-exif = "0.5.4" +# revolt_little_exif = "0.4.0" +simdutf8 = { version = "0.1.4", features = ["aarch64_neon"] } +image = { version = "0.25.2" } # requires dav1d system library: features = ["avif-native"] # Content type processing infer = "0.16.0" diff --git a/crates/services/autumn/src/api.rs b/crates/services/autumn/src/api.rs index e7a0483a..a7063d27 100644 --- a/crates/services/autumn/src/api.rs +++ b/crates/services/autumn/src/api.rs @@ -1,4 +1,7 @@ -use std::{io::Cursor, time::Duration}; +use std::{ + io::{Cursor, Read}, + time::Duration, +}; use axum::{ extract::{DefaultBodyLimit, Path, State}, @@ -12,16 +15,15 @@ use image::ImageReader; use lazy_static::lazy_static; use revolt_config::config; use revolt_database::{Database, FileHash, Metadata}; -use revolt_files::fetch_from_s3; +use revolt_files::{fetch_from_s3, AUTHENTICATION_TAG_SIZE_BYTES}; use revolt_result::{create_error, Result}; use serde::{Deserialize, Serialize}; +use sha2::Digest; use tempfile::NamedTempFile; +use tokio::time::Instant; use utoipa::ToSchema; -use crate::{ - metadata::{generate_metadata, temp_file_size}, - mime_type::determine_mime_type, -}; +use crate::{exif::strip_metadata, metadata::generate_metadata, mime_type::determine_mime_type}; /// Build the API router pub async fn router() -> Router { @@ -139,27 +141,59 @@ async fn upload_file( Path(tag): Path, TypedMultipart(UploadPayload { mut file }): TypedMultipart, ) -> axum::response::Result> { - // Extract the filename, or give it a generic name. + // Keep track of processing time + let now = Instant::now(); + + // TODO: authenticate the user + + // Extract the filename, or give it a generic name let file_name = file.metadata.file_name.unwrap_or("unnamed-file".to_owned()); + // Load file to memory + let mut buf = Vec::::new(); + file.contents + .read_to_end(&mut buf) + .map_err(|_| create_error!(InternalError))?; + + // Take note of original file size + let original_file_size = buf.len(); + + // TODO: check against tag + + // Generate sha256 hash + let original_hash = { + let mut hasher = sha2::Sha256::new(); + hasher.update(&buf); + hasher.finalize() + }; + // TODO: find existing `hash` (or match `processed`) and use that if possible // then: create attachment and return UploadResponse { id } - // Determine file size - let original_file_size = temp_file_size(&file.contents)?; - // Determine the mime type for the file - let mime_type = determine_mime_type(&mut file.contents, &file_name, original_file_size); + let mime_type = determine_mime_type(&mut file.contents, &buf, &file_name); // TODO: block mime types here // Determine metadata for the file let metadata = generate_metadata(&file.contents, mime_type); - // TODO: Re-encode MIMES_WITH_EXIF to remove EXIF data (this needs orientation data: https://github.com/revoltchat/autumn/blob/master/src/routes/upload.rs#L96) - // TODO: Re-encode videos to remove EXIF data (this may need orientation data?) + // Strip metadata + let (buf, metadata) = strip_metadata(file.contents, buf, metadata, mime_type).await?; - // TODO: Virus scan Metadata::Text/File + // TODO: Virus scan Metadata::File + + // Print file information for debug purposes + let new_file_size = buf.len() + AUTHENTICATION_TAG_SIZE_BYTES; + let processed_hash = { + let mut hasher = sha2::Sha256::new(); + hasher.update(&buf); + hasher.finalize() + }; + let process_ratio = new_file_size as f32 / original_file_size as f32; + let time_to_process = Instant::now() - now; + + tracing::info!("Received file {file_name}\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); // TODO: encrypt the file and generate FileHash // note: file_size is processed file's size (incl. authTag in encrypted buffer) diff --git a/crates/services/autumn/src/exif.rs b/crates/services/autumn/src/exif.rs new file mode 100644 index 00000000..aa991675 --- /dev/null +++ b/crates/services/autumn/src/exif.rs @@ -0,0 +1,165 @@ +use std::io::{Cursor, Read}; + +use exif::Reader; +use image::{ImageFormat, ImageReader}; +use revolt_database::Metadata; +use revolt_result::{create_error, Result}; +use tempfile::NamedTempFile; +use tokio::process::Command; + +/// Strip EXIF data from given file and produce new file and metadata +pub async fn strip_metadata( + file: NamedTempFile, + buf: Vec, + metadata: Metadata, + mime: &str, +) -> Result<(Vec, 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()); + } */ + // Apply orientation manually & strip all other EXIF data + "image/jpeg" | "image/png" | "image/avif" | "image/tiff" => { + // Create a reader + let mut cursor = Cursor::new(buf); + + // Decode the image + let image = ImageReader::new(&mut cursor) + .with_guessed_format() + .map_err(|_| create_error!(InternalError))? + .decode() + .map_err(|_| create_error!(InternalError)); + + // Reset read position + cursor.set_position(0); + + // Extract orientation data + let exif_reader = Reader::new(); + let rotation = match exif_reader.read_from_container(&mut cursor) { + Ok(exif) => match exif.get_field(exif::Tag::Orientation, exif::In::PRIMARY) { + Some(orientation) => orientation.value.get_uint(0).unwrap_or_default(), + _ => 0, + }, + _ => 0, + }; + + // Create a buffer to write to + let mut bytes: Vec = Vec::new(); + let mut writer = Cursor::new(&mut bytes); + + // Apply the EXIF rotation + // See https://jdhao.github.io/2019/07/31/image_rotation_exif_info/ + match &rotation { + 2 => image?.fliph(), + 3 => image?.rotate180(), + 4 => image?.rotate180().fliph(), + 5 => image?.rotate90().fliph(), + 6 => image?.rotate90(), + 7 => image?.rotate270().fliph(), + 8 => image?.rotate270(), + _ => image?, + } + .write_to( + &mut writer, + match mime { + "image/jpeg" => ImageFormat::Jpeg, + "image/png" => ImageFormat::Png, + "image/avif" => ImageFormat::Avif, + "image/tiff" => ImageFormat::Tiff, + _ => todo!(), + }, + ) + .map_err(|_| create_error!(InternalError))?; + + // Calculate dimensions after rotation. + let (width, height) = match &rotation { + 2 | 4 | 5 | 7 => (*height, *width), + _ => (*width, *height), + }; + + Ok((bytes, Metadata::Image { width, height })) + } + // JXLs store EXIF data but we don't have the ability to write them + "image/jxl" => Ok((buf, metadata)), + // All other images that cannot store EXIF data + _ => Ok((buf, metadata)), + }, + // Use ffmpeg to copy video stream and probe new metadata + Metadata::Video { .. } => match mime { + // Strip EXIF data by copying video stream + "video/mp4" | "video/webm" | "video/quicktime" => { + // Pick the correct file format for ffmpeg + let ext = match mime { + "video/mp4" => "mp4", + "video/webm" => "webm", + "video/quicktime" => "mov", + _ => unreachable!(), + }; + + // Temporary output file + let mut out_file = + NamedTempFile::new().map_err(|_| create_error!(InternalError))?; + + // Process the file with ffmpeg + Command::new("ffmpeg") + .args([ + // Overwrite the temporary file + "-y", + // Read original uploaded file + "-i", + file.path().to_str().ok_or(create_error!(InternalError))?, + // Strip any metadata + "-map_metadata", + "-1", + // Copy video / audio data to new file + "-c:v", + "copy", + "-c:a", + "copy", + // Select correct file format + "-f", + ext, + // Save to new temporary file + out_file + .path() + .to_str() + .ok_or(create_error!(InternalError))?, + ]) + .output() + .await + .inspect_err(|err| tracing::error!("{err:?}")) + .map_err(|_| create_error!(InternalError))?; + + // Probe the file again + let metadata = crate::metadata::generate_metadata(&out_file, mime); + + // Read the file from disk + let mut buf = Vec::::new(); + out_file + .read_to_end(&mut buf) + .map_err(|_| create_error!(InternalError))?; + + Ok((buf, metadata)) + } + // Assume all other video formats cannot store EXIF data + _ => Ok((buf, metadata)), + }, + // all other file types don't store EXIF data + _ => Ok((buf, metadata)), + } +} diff --git a/crates/services/autumn/src/main.rs b/crates/services/autumn/src/main.rs index 76ead8df..add11d2e 100644 --- a/crates/services/autumn/src/main.rs +++ b/crates/services/autumn/src/main.rs @@ -11,6 +11,7 @@ use utoipa::{ use utoipa_scalar::{Scalar, Servable as ScalarServable}; mod api; +pub mod exif; pub mod metadata; pub mod mime_type; diff --git a/crates/services/autumn/src/metadata.rs b/crates/services/autumn/src/metadata.rs index a34831c0..5203a520 100644 --- a/crates/services/autumn/src/metadata.rs +++ b/crates/services/autumn/src/metadata.rs @@ -19,21 +19,6 @@ static SUPPORTED_IMAGE_MIME: [&str; 9] = [ "image/webp", ]; -/// Image mime types that have EXIF data -static IMAGE_MIMES_WITH_EXIF: [&str; 4] = ["image/avif", "image/jpeg", "image/jxl", "image/tiff"]; - -/// Get the size of a temp file -pub fn temp_file_size(f: &NamedTempFile) -> Result { - // Check the size of the file - if let Ok(file) = f.reopen() { - if let Ok(metadata) = file.metadata() { - return Ok(metadata.size()); - } - } - - Err(create_error!(InternalError)) -} - /// 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) { diff --git a/crates/services/autumn/src/mime_type.rs b/crates/services/autumn/src/mime_type.rs index ad54e78c..8bfd636a 100644 --- a/crates/services/autumn/src/mime_type.rs +++ b/crates/services/autumn/src/mime_type.rs @@ -1,9 +1,7 @@ -use std::io::Read; - use tempfile::NamedTempFile; /// Determine the mime type of the given temporary file and filename -pub fn determine_mime_type(f: &mut NamedTempFile, file_name: &str, file_size: u64) -> &'static str { +pub fn determine_mime_type(f: &mut NamedTempFile, buf: &Vec, file_name: &str) -> &'static str { // Use magic signatures to determine mime type let kind = infer::get_from_path(f.path()).expect("file read successfully"); let mime_type = if let Some(kind) = kind { @@ -21,15 +19,8 @@ pub fn determine_mime_type(f: &mut NamedTempFile, file_name: &str, file_size: u6 }; // See if the file is actually just plain Unicode/ASCII text - if mime_type == "application/octet-stream" { - // don't check files over >= 500 kB - if file_size <= 500_000 { - let mut buf = String::new(); - if f.read_to_string(&mut buf).is_ok() { - // successfully read the file as UTF-8 - return "plain/text"; - } - } + if mime_type == "application/octet-stream" && simdutf8::basic::from_utf8(buf).is_ok() { + return "plain/text"; } mime_type