feat(services/autumn): work towards upload API

This commit is contained in:
Paul Makles
2024-09-01 16:42:28 +01:00
parent ebbbb5e174
commit 24dc96f80f
12 changed files with 453 additions and 97 deletions

View File

@@ -6,7 +6,13 @@ edition = "2021"
[dependencies]
# Media processing
webp = "0.3.0"
image = "0.25.2"
jxl-oxide = "0.8.1"
image = { version = "0.25.2" } # requires dav1d system library: features = ["avif-native"]
# Content type processing
infer = "0.16.0"
ffprobe = "0.4.0"
imagesize = "0.13.0"
# Utility
lazy_static = "1.5.0"

View File

@@ -0,0 +1 @@
# TODO: https://github.com/wader/static-ffmpeg

View File

@@ -11,13 +11,18 @@ use axum_typed_multipart::{FieldData, TryFromMultipart, TypedMultipart};
use image::ImageReader;
use lazy_static::lazy_static;
use revolt_config::config;
use revolt_database::{Database, FileHash};
use revolt_database::{Database, FileHash, Metadata};
use revolt_files::fetch_from_s3;
use revolt_result::{create_error, Result};
use serde::{Deserialize, Serialize};
use tempfile::NamedTempFile;
use utoipa::ToSchema;
use crate::{
metadata::{generate_metadata, temp_file_size},
mime_type::determine_mime_type,
};
/// Build the API router
pub async fn router() -> Router<Database> {
let config = config().await;
@@ -132,8 +137,36 @@ pub struct UploadResponse {
)]
async fn upload_file(
Path(tag): Path<Tag>,
TypedMultipart(UploadPayload { file }): TypedMultipart<UploadPayload>,
TypedMultipart(UploadPayload { mut file }): TypedMultipart<UploadPayload>,
) -> axum::response::Result<Json<UploadResponse>> {
// Extract the filename, or give it a generic name.
let file_name = file.metadata.file_name.unwrap_or("unnamed-file".to_owned());
// 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);
// 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?)
// TODO: Virus scan Metadata::Text/File
// TODO: encrypt the file and generate FileHash
// note: file_size is processed file's size (incl. authTag in encrypted buffer)
// TODO: insert FileHash
// TODO: upload to S3
// TODO: create attachment
Ok(Json(UploadResponse { id: "aaa" }))
}
@@ -180,10 +213,15 @@ async fn fetch_preview(
return Err(create_error!(NotFound));
}
// Ignore files that haven't been attached
if file.used_for.is_none() {
return Err(create_error!(NotFound));
}
let hash = file.as_hash(&db).await?;
// Only process image files
if !hash.content_type.starts_with("image/") {
if !matches!(hash.metadata, Metadata::Image { .. }) {
return Ok(
Redirect::permanent(&format!("/{tag}/{file_id}/{}", file.filename)).into_response(),
);
@@ -197,15 +235,16 @@ async fn fetch_preview(
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 = ImageReader::new(Cursor::new(data))
.with_guessed_format()
.map_err(|_| create_error!(InternalError))?
.decode()
.map_err(|_| create_error!(InternalError))?
//.resize(width as u32, height as u32, image::imageops::FilterType::Gaussian)
// resize is about 2.5x slower,
// thumb approximation doesn't have terrible quality so it's fine to stick with
// .resize(width as u32, height as u32, image::imageops::FilterType::Gaussian)
// aspect ratio is preserved when scaling
// thumbnail doesn't have terrible quality
// so we use thumbnail
.thumbnail(*w as u32, *h as u32);
// Encode it into WEBP
@@ -253,6 +292,11 @@ async fn fetch_file(
return Err(create_error!(NotFound));
}
// Ignore files that haven't been attached
if file.used_for.is_none() {
return Err(create_error!(NotFound));
}
// Ensure filename is correct
if file_name != file.filename {
return Err(create_error!(NotFound));

View File

@@ -11,6 +11,8 @@ use utoipa::{
use utoipa_scalar::{Scalar, Servable as ScalarServable};
mod api;
pub mod metadata;
pub mod mime_type;
#[tokio::main]
async fn main() -> Result<(), std::io::Error> {

View File

@@ -0,0 +1,104 @@
use std::{io::Cursor, os::unix::fs::MetadataExt};
use revolt_database::Metadata;
use revolt_result::{create_error, Result};
use tempfile::NamedTempFile;
/// Intersection of what infer can detect and what image-rs supports
///
/// Note: imagesize crate also supports all of these, so we use that for quick size probing.
static SUPPORTED_IMAGE_MIME: [&str; 9] = [
"image/avif",
"image/bmp",
"image/gif",
"image/vnd.microsoft.icon",
"image/jpeg",
"image/jxl", // not supported by image-rs but we shim it
"image/png",
"image/tiff",
"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) {
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
} 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
}
} else if mime_type.starts_with("audio/") {
Metadata::Audio
} else if mime_type == "plain/text" {
Metadata::Text
} else {
Metadata::File
}
}
/// Subroutine to ensure data isn't corrupted
pub fn validate_from_metadata(
reader: Cursor<Vec<u8>>,
metadata: Metadata,
mime_type: &str,
) -> Metadata {
if let Metadata::Image { .. } = &metadata {
if mime_type == "image/jxl" {
// Check if we can read using jxl-oxide crate
if jxl_oxide::JxlImage::builder()
.read(reader)
.inspect_err(|err| tracing::error!("Failed to read JXL! {err:?}"))
.is_err()
{
return Metadata::File;
}
} else if matches!(
// Check if we can read using image-rs crate
image::ImageReader::new(reader)
.with_guessed_format()
.inspect_err(|err| tracing::error!("Failed to read image! {err:?}"))
.map(|f| f.decode()),
Err(_) | Ok(Err(_))
) {
return Metadata::File;
}
}
metadata
}

View File

@@ -0,0 +1,36 @@
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 {
// 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 {
kind.mime_type()
} else {
"application/octet-stream"
};
// Map any known conflicts where appropriate
let mime_type = if mime_type == "application/zip" && file_name.to_lowercase().ends_with(".apk")
{
"application/vnd.android.package-archive"
} else {
mime_type
};
// 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";
}
}
}
mime_type
}