From 69fe3a8b07bb5984ac89b4ef0627e3c1a029fa14 Mon Sep 17 00:00:00 2001 From: Jared Wolff Date: Thu, 5 Mar 2026 12:48:47 -0500 Subject: [PATCH] feat(photos): add EXIF metadata extraction and storage Extract EXIF orientation, GPS coordinates, camera info, and timestamps from uploaded images using kamadak-exif. Store metadata in a new file_metadata PG table. Apply EXIF orientation to thumbnail generation so images display correctly. Add /api/files/{id}/metadata endpoint. --- Cargo.lock | 16 ++ Cargo.toml | 1 + db/schema.sql | 22 +++ src/common/di.rs | 7 +- .../pg/file_metadata_repository.rs | 174 +++++++++++++++++ src/infrastructure/repositories/pg/mod.rs | 2 + src/infrastructure/services/exif_service.rs | 180 ++++++++++++++++++ src/infrastructure/services/mod.rs | 1 + .../services/thumbnail_service.rs | 18 ++ src/interfaces/api/handlers/file_handler.rs | 63 +++++- src/interfaces/api/routes.rs | 1 + 11 files changed, 483 insertions(+), 2 deletions(-) create mode 100644 src/infrastructure/repositories/pg/file_metadata_repository.rs create mode 100644 src/infrastructure/services/exif_service.rs diff --git a/Cargo.lock b/Cargo.lock index 4584f0e3..c2f03b67 100755 --- a/Cargo.lock +++ b/Cargo.lock @@ -1477,6 +1477,15 @@ dependencies = [ "simple_asn1", ] +[[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 = "lazy_static" version = "1.5.0" @@ -1718,6 +1727,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "mutate_once" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af" + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1820,6 +1835,7 @@ dependencies = [ "image", "infer", "jsonwebtoken", + "kamadak-exif", "md-5", "mimalloc", "mime_guess", diff --git a/Cargo.toml b/Cargo.toml index bd58bced..80c6cc5a 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,7 @@ dotenvy = "0.15.7" moka = { version = "0.12", features = ["future", "sync"] } http-range-header = "0.4" image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] } +kamadak-exif = "0.5" md-5 = "0.10" sha2 = "0.10.9" blake3 = { version = "1.8.3", features = ["rayon"] } diff --git a/db/schema.sql b/db/schema.sql index 85a1ecc7..9aaa5c4e 100755 --- a/db/schema.sql +++ b/db/schema.sql @@ -646,6 +646,28 @@ CREATE INDEX IF NOT EXISTS idx_shares_created_by ON storage.shares(created_by); COMMENT ON TABLE storage.shares IS 'Shared links for files and folders with token-based access'; +-- ── EXIF / media metadata for image and video files ───────────────────── +-- Separate table keeps storage.files lean (most files aren't images). +-- Populated at upload time by the ExifService. +CREATE TABLE IF NOT EXISTS storage.file_metadata ( + file_id UUID PRIMARY KEY REFERENCES storage.files(id) ON DELETE CASCADE, + captured_at TIMESTAMP WITH TIME ZONE, -- EXIF DateTimeOriginal + latitude DOUBLE PRECISION, -- GPS latitude (decimal degrees) + longitude DOUBLE PRECISION, -- GPS longitude (decimal degrees) + camera_make TEXT, -- EXIF Make + camera_model TEXT, -- EXIF Model + orientation SMALLINT, -- EXIF Orientation (1-8) + width INTEGER, -- Original pixel width + height INTEGER, -- Original pixel height + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- For the Photos timeline: ORDER BY captured_at DESC with cursor pagination +CREATE INDEX IF NOT EXISTS idx_file_metadata_captured + ON storage.file_metadata(captured_at DESC) WHERE captured_at IS NOT NULL; + +COMMENT ON TABLE storage.file_metadata IS 'EXIF and media metadata extracted at upload time'; + -- ── Atomic recursive folder copy (WebDAV COPY Depth: infinity) ────────── -- -- Copies the entire subtree rooted at `p_source_id` under `p_target_parent_id`. diff --git a/src/common/di.rs b/src/common/di.rs index 8e4f434b..99febd7e 100755 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -24,7 +24,7 @@ use crate::common::config::AppConfig; use crate::common::errors::DomainError; use crate::infrastructure::repositories::pg::SharePgRepository; use crate::infrastructure::repositories::pg::{ - FileBlobReadRepository, FileBlobWriteRepository, FolderDbRepository, TrashDbRepository, + FileBlobReadRepository, FileBlobWriteRepository, FileMetadataRepository, FolderDbRepository, TrashDbRepository, }; use crate::infrastructure::services::file_content_cache::{ FileContentCache, FileContentCacheConfig, @@ -214,6 +214,9 @@ impl AppServiceFactory { None }; + // File metadata repository — EXIF/media metadata for images + let file_metadata_repository = Arc::new(FileMetadataRepository::new(db_pool.clone())); + tracing::info!( "Repository services initialized with 100% blob storage model (PG metadata + DedupService blobs)" ); @@ -223,6 +226,7 @@ impl AppServiceFactory { folder_repo_concrete, file_read_repository, file_write_repository, + file_metadata_repository, i18n_repository, trash_repository, } @@ -815,6 +819,7 @@ pub struct RepositoryServices { pub folder_repo_concrete: Arc, pub file_read_repository: Arc, pub file_write_repository: Arc, + pub file_metadata_repository: Arc, pub i18n_repository: Arc, pub trash_repository: Option>, } diff --git a/src/infrastructure/repositories/pg/file_metadata_repository.rs b/src/infrastructure/repositories/pg/file_metadata_repository.rs new file mode 100644 index 00000000..3b7b76fc --- /dev/null +++ b/src/infrastructure/repositories/pg/file_metadata_repository.rs @@ -0,0 +1,174 @@ +//! PostgreSQL repository for image/video EXIF metadata. + +use chrono::{DateTime, Utc}; +use serde::Serialize; +use sqlx::PgPool; +use std::collections::HashMap; +use std::sync::Arc; +use tracing::error; + +use crate::common::errors::DomainError; +use crate::infrastructure::services::exif_service::ExifMetadata; + +/// Metadata as stored/retrieved from the database. +#[derive(Debug, Clone, Serialize)] +pub struct StoredMetadata { + pub file_id: String, + pub captured_at: Option>, + pub latitude: Option, + pub longitude: Option, + pub camera_make: Option, + pub camera_model: Option, + pub orientation: Option, + pub width: Option, + pub height: Option, +} + +/// Repository for `storage.file_metadata` table operations. +pub struct FileMetadataRepository { + pool: Arc, +} + +impl FileMetadataRepository { + pub fn new(pool: Arc) -> Self { + Self { pool } + } + + /// Insert or update EXIF metadata for a file. + pub async fn upsert(&self, file_id: &str, meta: &ExifMetadata) -> Result<(), DomainError> { + sqlx::query( + r#" + INSERT INTO storage.file_metadata + (file_id, captured_at, latitude, longitude, camera_make, camera_model, orientation, width, height) + VALUES ($1::uuid, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (file_id) DO UPDATE SET + captured_at = EXCLUDED.captured_at, + latitude = EXCLUDED.latitude, + longitude = EXCLUDED.longitude, + camera_make = EXCLUDED.camera_make, + camera_model = EXCLUDED.camera_model, + orientation = EXCLUDED.orientation, + width = EXCLUDED.width, + height = EXCLUDED.height + "#, + ) + .bind(file_id) + .bind(meta.captured_at) + .bind(meta.latitude) + .bind(meta.longitude) + .bind(&meta.camera_make) + .bind(&meta.camera_model) + .bind(meta.orientation.map(|o| o as i16)) + .bind(meta.width.map(|w| w as i32)) + .bind(meta.height.map(|h| h as i32)) + .execute(self.pool.as_ref()) + .await + .map_err(|e| { + error!("Failed to upsert file metadata: {}", e); + DomainError::internal_error("FileMetadata", format!("upsert: {e}")) + })?; + + Ok(()) + } + + /// Get metadata for a single file. + pub async fn get(&self, file_id: &str) -> Result, DomainError> { + let row: Option<( + String, + Option>, + Option, + Option, + Option, + Option, + Option, + Option, + Option, + )> = sqlx::query_as( + r#" + SELECT file_id::text, captured_at, latitude, longitude, + camera_make, camera_model, orientation, width, height + FROM storage.file_metadata + WHERE file_id = $1::uuid + "#, + ) + .bind(file_id) + .fetch_optional(self.pool.as_ref()) + .await + .map_err(|e| { + error!("Failed to get file metadata: {}", e); + DomainError::internal_error("FileMetadata", format!("get: {e}")) + })?; + + Ok(row.map( + |(file_id, captured_at, latitude, longitude, camera_make, camera_model, orientation, width, height)| { + StoredMetadata { + file_id, + captured_at, + latitude, + longitude, + camera_make, + camera_model, + orientation, + width, + height, + } + }, + )) + } + + /// Get metadata for multiple files in a single query. + pub async fn get_batch( + &self, + file_ids: &[String], + ) -> Result, DomainError> { + if file_ids.is_empty() { + return Ok(HashMap::new()); + } + + let rows: Vec<( + String, + Option>, + Option, + Option, + Option, + Option, + Option, + Option, + Option, + )> = sqlx::query_as( + r#" + SELECT file_id::text, captured_at, latitude, longitude, + camera_make, camera_model, orientation, width, height + FROM storage.file_metadata + WHERE file_id = ANY($1::uuid[]) + "#, + ) + .bind(file_ids) + .fetch_all(self.pool.as_ref()) + .await + .map_err(|e| { + error!("Failed to batch get file metadata: {}", e); + DomainError::internal_error("FileMetadata", format!("get_batch: {e}")) + })?; + + let mut map = HashMap::with_capacity(rows.len()); + for (file_id, captured_at, latitude, longitude, camera_make, camera_model, orientation, width, height) in rows { + map.insert( + file_id.clone(), + StoredMetadata { + file_id, + captured_at, + latitude, + longitude, + camera_make, + camera_model, + orientation, + width, + height, + }, + ); + } + + Ok(map) + } +} diff --git a/src/infrastructure/repositories/pg/mod.rs b/src/infrastructure/repositories/pg/mod.rs index 8a245867..0e24650c 100755 --- a/src/infrastructure/repositories/pg/mod.rs +++ b/src/infrastructure/repositories/pg/mod.rs @@ -7,6 +7,7 @@ mod contact_persistence_dto; mod contact_pg_repository; mod device_code_pg_repository; mod favorites_pg_repository; +pub mod file_metadata_repository; mod nextcloud_object_id_repository; mod recent_items_pg_repository; mod session_pg_repository; @@ -30,6 +31,7 @@ pub use contact_persistence_dto::*; pub use contact_pg_repository::ContactPgRepository; pub use device_code_pg_repository::DeviceCodePgRepository; pub use favorites_pg_repository::FavoritesPgRepository; +pub use file_metadata_repository::FileMetadataRepository; pub use file_blob_read_repository::FileBlobReadRepository; pub use file_blob_write_repository::FileBlobWriteRepository; pub use folder_db_repository::FolderDbRepository; diff --git a/src/infrastructure/services/exif_service.rs b/src/infrastructure/services/exif_service.rs new file mode 100644 index 00000000..ef912ed2 --- /dev/null +++ b/src/infrastructure/services/exif_service.rs @@ -0,0 +1,180 @@ +//! EXIF metadata extraction from image files. +//! +//! Uses `kamadak-exif` to parse EXIF headers from JPEG/TIFF/HEIF images. +//! Extraction is cheap — only the header bytes are read, not the full image. + +use chrono::{DateTime, NaiveDateTime, Utc}; +use exif::{In, Reader, Tag}; +use std::io::Cursor; + +/// Extracted EXIF metadata fields. +#[derive(Debug, Clone, Default)] +pub struct ExifMetadata { + /// Photo capture time (EXIF DateTimeOriginal) + pub captured_at: Option>, + /// GPS latitude in decimal degrees (positive = North) + pub latitude: Option, + /// GPS longitude in decimal degrees (positive = East) + pub longitude: Option, + /// Camera manufacturer (EXIF Make) + pub camera_make: Option, + /// Camera model (EXIF Model) + pub camera_model: Option, + /// EXIF Orientation tag (1-8) + pub orientation: Option, + /// Original image width in pixels + pub width: Option, + /// Original image height in pixels + pub height: Option, +} + +/// Stateless service for extracting EXIF metadata from image bytes. +pub struct ExifService; + +impl ExifService { + /// Extract EXIF metadata from raw image bytes. + /// + /// Returns `None` if the file has no EXIF data (e.g. PNG, GIF, WebP) + /// or if parsing fails entirely. Individual fields may be `None` even + /// when the EXIF block exists (not all cameras populate every tag). + pub fn extract(data: &[u8]) -> Option { + let exif = Reader::new() + .read_from_container(&mut Cursor::new(data)) + .ok()?; + + let mut meta = ExifMetadata::default(); + + // ── Capture date ── + if let Some(field) = exif.get_field(Tag::DateTimeOriginal, In::PRIMARY) { + meta.captured_at = parse_exif_datetime(&field.display_value().to_string()); + } + // Fallback to DateTimeDigitized if DateTimeOriginal is missing + if meta.captured_at.is_none() { + if let Some(field) = exif.get_field(Tag::DateTimeDigitized, In::PRIMARY) { + meta.captured_at = parse_exif_datetime(&field.display_value().to_string()); + } + } + + // ── GPS coordinates ── + meta.latitude = parse_gps_coord(&exif, Tag::GPSLatitude, Tag::GPSLatitudeRef); + meta.longitude = parse_gps_coord(&exif, Tag::GPSLongitude, Tag::GPSLongitudeRef); + + // ── Camera info ── + if let Some(field) = exif.get_field(Tag::Make, In::PRIMARY) { + let val = field.display_value().to_string().trim_matches('"').trim().to_string(); + if !val.is_empty() { + meta.camera_make = Some(val); + } + } + if let Some(field) = exif.get_field(Tag::Model, In::PRIMARY) { + let val = field.display_value().to_string().trim_matches('"').trim().to_string(); + if !val.is_empty() { + meta.camera_model = Some(val); + } + } + + // ── Orientation ── + if let Some(field) = exif.get_field(Tag::Orientation, In::PRIMARY) { + if let exif::Value::Short(ref v) = field.value { + if let Some(&o) = v.first() { + if (1..=8).contains(&o) { + meta.orientation = Some(o); + } + } + } + } + + // ── Dimensions ── + if let Some(field) = exif.get_field(Tag::PixelXDimension, In::PRIMARY) { + meta.width = parse_u32_value(&field.value); + } + if let Some(field) = exif.get_field(Tag::PixelYDimension, In::PRIMARY) { + meta.height = parse_u32_value(&field.value); + } + // Fallback to ImageWidth/ImageLength if PixelXDimension is missing + if meta.width.is_none() { + if let Some(field) = exif.get_field(Tag::ImageWidth, In::PRIMARY) { + meta.width = parse_u32_value(&field.value); + } + } + if meta.height.is_none() { + if let Some(field) = exif.get_field(Tag::ImageLength, In::PRIMARY) { + meta.height = parse_u32_value(&field.value); + } + } + + Some(meta) + } +} + +/// Parse EXIF datetime string "YYYY:MM:DD HH:MM:SS" into DateTime. +fn parse_exif_datetime(s: &str) -> Option> { + // EXIF dates use ":" as separator for date parts + let s = s.trim().trim_matches('"'); + NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") + .or_else(|_| NaiveDateTime::parse_from_str(s, "%Y:%m:%d %H:%M:%S")) + .ok() + .map(|ndt| ndt.and_utc()) +} + +/// Parse GPS coordinate from EXIF rational values + reference (N/S or E/W). +fn parse_gps_coord(exif: &exif::Exif, coord_tag: Tag, ref_tag: Tag) -> Option { + let field = exif.get_field(coord_tag, In::PRIMARY)?; + let ref_field = exif.get_field(ref_tag, In::PRIMARY)?; + + let rationals = match &field.value { + exif::Value::Rational(v) if v.len() >= 3 => v, + _ => return None, + }; + + let degrees = rationals[0].to_f64(); + let minutes = rationals[1].to_f64(); + let seconds = rationals[2].to_f64(); + + let mut decimal = degrees + minutes / 60.0 + seconds / 3600.0; + + // Apply hemisphere sign + let reference = ref_field.display_value().to_string(); + let reference = reference.trim().trim_matches('"'); + if reference == "S" || reference == "W" { + decimal = -decimal; + } + + Some(decimal) +} + +/// Extract a u32 from various EXIF value types (Short, Long). +fn parse_u32_value(value: &exif::Value) -> Option { + match value { + exif::Value::Short(v) => v.first().map(|&x| x as u32), + exif::Value::Long(v) => v.first().copied(), + _ => None, + } +} + +/// Apply EXIF orientation to a `DynamicImage`. +/// +/// EXIF orientation values 1-8 describe how the stored pixels relate to +/// the intended display orientation. This function transforms the image +/// to match the intended orientation. +pub fn apply_orientation(img: image::DynamicImage, orientation: u16) -> image::DynamicImage { + match orientation { + 1 => img, // Normal + 2 => image::DynamicImage::from(image::imageops::flip_horizontal(&img)), // Mirror horizontal + 3 => image::DynamicImage::from(image::imageops::rotate180(&img)), // Rotate 180° + 4 => image::DynamicImage::from(image::imageops::flip_vertical(&img)), // Mirror vertical + 5 => { + // Transpose: flip horizontal then rotate 270° (= rotate 90° CW then flip horizontal) + let flipped = image::imageops::flip_horizontal(&img); + image::DynamicImage::from(image::imageops::rotate270(&flipped)) + } + 6 => image::DynamicImage::from(image::imageops::rotate90(&img)), // Rotate 90° CW + 7 => { + // Transverse: flip horizontal then rotate 90° + let flipped = image::imageops::flip_horizontal(&img); + image::DynamicImage::from(image::imageops::rotate90(&flipped)) + } + 8 => image::DynamicImage::from(image::imageops::rotate270(&img)), // Rotate 270° CW + _ => img, + } +} diff --git a/src/infrastructure/services/mod.rs b/src/infrastructure/services/mod.rs index 0fe2886d..da2f6983 100755 --- a/src/infrastructure/services/mod.rs +++ b/src/infrastructure/services/mod.rs @@ -1,6 +1,7 @@ pub mod chunked_upload_service; pub mod compression_service; pub mod dedup_service; +pub mod exif_service; pub mod file_content_cache; pub mod file_system_i18n_service; pub mod image_transcode_service; diff --git a/src/infrastructure/services/thumbnail_service.rs b/src/infrastructure/services/thumbnail_service.rs index 1ca81b25..f75a7a95 100755 --- a/src/infrastructure/services/thumbnail_service.rs +++ b/src/infrastructure/services/thumbnail_service.rs @@ -269,6 +269,15 @@ impl ThumbnailService { let img = image::load_from_memory(&data) .map_err(|e| ThumbnailError::ImageError(e.to_string()))?; + // Apply EXIF orientation so thumbnails display correctly + let img = { + use crate::infrastructure::services::exif_service::{ExifService, apply_orientation}; + let orientation = ExifService::extract(&data) + .and_then(|m| m.orientation) + .unwrap_or(1); + apply_orientation(img, orientation) + }; + // Calculate new dimensions preserving aspect ratio let (orig_width, orig_height) = (img.width(), img.height()); let (new_width, new_height) = if orig_width > orig_height { @@ -349,6 +358,15 @@ impl ThumbnailService { let img = image::load_from_memory(&data) .map_err(|e| ThumbnailError::ImageError(e.to_string()))?; + // Apply EXIF orientation so thumbnails display correctly + let img = { + use crate::infrastructure::services::exif_service::{ExifService, apply_orientation}; + let orientation = ExifService::extract(&data) + .and_then(|m| m.orientation) + .unwrap_or(1); + apply_orientation(img, orientation) + }; + let (orig_w, orig_h) = (img.width(), img.height()); ThumbnailSize::all() diff --git a/src/interfaces/api/handlers/file_handler.rs b/src/interfaces/api/handlers/file_handler.rs index de5f0859..66a69e7a 100755 --- a/src/interfaces/api/handlers/file_handler.rs +++ b/src/interfaces/api/handlers/file_handler.rs @@ -605,7 +605,7 @@ impl FileHandler { Err(response) => return response.into_response(), }; - // Generate thumbnails for supported images in background + // Generate thumbnails and extract EXIF metadata for supported images in background if state .core .thumbnail_service @@ -615,6 +615,7 @@ impl FileHandler { let thumbnail_service = state.core.thumbnail_service.clone(); let dedup_service = state.core.dedup_service.clone(); let file_read = state.repositories.file_read_repository.clone(); + let metadata_repo = state.repositories.file_metadata_repository.clone(); tokio::spawn(async move { // Resolve the actual blob path on disk (not the logical file path, @@ -627,6 +628,25 @@ impl FileHandler { } }; let file_path = dedup_service.blob_path(&blob_hash); + + // Extract EXIF metadata (reads only header bytes, very fast). + // Runs before thumbnail generation so the OS page cache is primed. + { + use crate::infrastructure::services::exif_service::ExifService; + match tokio::fs::read(&file_path).await { + Ok(data) => { + if let Some(meta) = ExifService::extract(&data) { + if let Err(e) = metadata_repo.upsert(&file_id, &meta).await { + tracing::warn!("Failed to store EXIF for {}: {}", file_id, e); + } + } + } + Err(e) => { + tracing::warn!("Failed to read file for EXIF extraction {}: {}", file_id, e); + } + } + } + tracing::info!("🖼️ Generating thumbnails for: {}", file_id); thumbnail_service.generate_all_sizes_background(file_id, file_path); }); @@ -635,6 +655,47 @@ impl FileHandler { Self::created_json_response(&file).into_response() } + // ═══════════════════════════════════════════════════════════════════════ + // METADATA + // ═══════════════════════════════════════════════════════════════════════ + + /// Returns EXIF/media metadata for a file. + /// + /// Used by the Photos lightbox and for testing EXIF extraction. + pub async fn get_file_metadata( + State(state): State, + auth_user: AuthUser, + Path(file_id): Path, + ) -> impl IntoResponse { + // Verify ownership + let file_read = &state.repositories.file_read_repository; + if let Err(e) = file_read.verify_file_owner(&file_id, &auth_user.id).await { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": e.to_string() })), + ) + .into_response(); + } + + let metadata_repo = &state.repositories.file_metadata_repository; + match metadata_repo.get(&file_id).await { + Ok(Some(meta)) => (StatusCode::OK, Json(meta)).into_response(), + Ok(None) => ( + StatusCode::OK, + Json(serde_json::json!({ + "file_id": file_id, + "message": "No EXIF metadata available" + })), + ) + .into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e.to_string() })), + ) + .into_response(), + } + } + // ═══════════════════════════════════════════════════════════════════════ // DELETE // ═══════════════════════════════════════════════════════════════════════ diff --git a/src/interfaces/api/routes.rs b/src/interfaces/api/routes.rs index 2f9f4a32..266d0715 100755 --- a/src/interfaces/api/routes.rs +++ b/src/interfaces/api/routes.rs @@ -146,6 +146,7 @@ pub fn create_api_routes(app_state: &Arc) -> Router> { .route("/upload", post(FileHandler::upload_file_with_thumbnails)) .route("/{id}", get(FileHandler::download_file)) .route("/{id}/thumbnail/{size}", get(FileHandler::get_thumbnail)) + .route("/{id}/metadata", get(FileHandler::get_file_metadata)) .layer(DefaultBodyLimit::max(10 * 1024 * 1024 * 1024)) // 10 GB for file uploads .with_state(app_state.clone());