forked from jmug/stoatchat
feat(services/autumn): work towards upload API
This commit is contained in:
@@ -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"
|
||||
|
||||
1
crates/services/autumn/Dockerfile
Normal file
1
crates/services/autumn/Dockerfile
Normal file
@@ -0,0 +1 @@
|
||||
# TODO: https://github.com/wader/static-ffmpeg
|
||||
@@ -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));
|
||||
|
||||
@@ -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> {
|
||||
|
||||
104
crates/services/autumn/src/metadata.rs
Normal file
104
crates/services/autumn/src/metadata.rs
Normal 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
|
||||
}
|
||||
36
crates/services/autumn/src/mime_type.rs
Normal file
36
crates/services/autumn/src/mime_type.rs
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user