forked from jmug/stoatchat
feat(services/autumn): strip exif data for images/videos
feat(services/autumn): hash files and log information
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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<Database> {
|
||||
@@ -139,27 +141,59 @@ async fn upload_file(
|
||||
Path(tag): Path<Tag>,
|
||||
TypedMultipart(UploadPayload { mut file }): TypedMultipart<UploadPayload>,
|
||||
) -> axum::response::Result<Json<UploadResponse>> {
|
||||
// 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::<u8>::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)
|
||||
|
||||
165
crates/services/autumn/src/exif.rs
Normal file
165
crates/services/autumn/src/exif.rs
Normal file
@@ -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<u8>,
|
||||
metadata: Metadata,
|
||||
mime: &str,
|
||||
) -> 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());
|
||||
} */
|
||||
// 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<u8> = 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::<u8>::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)),
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<u64> {
|
||||
// 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) {
|
||||
|
||||
@@ -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<u8>, 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
|
||||
|
||||
Reference in New Issue
Block a user