//! `/image*` endpoints: image serving (with hash/library-scoped/bare //! legacy thumbnail lookup), upload, EXIF metadata read + GPS / date //! mutation, and the full exiftool dump used by Apollo's details modal. use std::error::Error; use std::fs::File; use std::io::ErrorKind; use std::io::prelude::*; use std::path::{Path, PathBuf}; use std::sync::Mutex; use actix_files::NamedFile; use actix_multipart as mp; use actix_web::{ HttpRequest, HttpResponse, Responder, get, post, web::{self, BufMut, BytesMut, Data}, }; use chrono::Utc; use futures::stream::StreamExt; use log::{debug, error, info, trace, warn}; use opentelemetry::KeyValue; use opentelemetry::trace::{Span, Status, TraceContextExt, Tracer}; use urlencoding::decode; use crate::content_hash; use crate::data::{ Claims, MetadataResponse, PhotoSize, ThumbnailFormat, ThumbnailRequest, ThumbnailShape, }; use crate::database::models::{ImageExif, InsertImageExif}; use crate::database::{DbErrorKind, ExifDao}; use crate::date_resolver; use crate::exif; use crate::file_types; use crate::files::{RefreshThumbnailsMessage, is_image_or_video, is_valid_full_path}; use crate::libraries; use crate::memories; use crate::otel::{extract_context_from_request, global_tracer}; use crate::perceptual_hash; use crate::state::AppState; #[get("/image")] pub async fn get_image( _claims: Claims, request: HttpRequest, req: web::Query, app_state: Data, exif_dao: Data>>, ) -> impl Responder { let tracer = global_tracer(); let context = extract_context_from_request(&request); let mut span = tracer.start_with_context("get_image", &context); // Resolve library from query param; default to primary so clients that // don't yet send `library=` continue to work. let library = match libraries::resolve_library_param(&app_state, req.library.as_deref()) { Ok(Some(lib)) => lib, Ok(None) => app_state.primary_library(), Err(msg) => { span.set_status(Status::error(msg.clone())); return HttpResponse::BadRequest().body(msg); } }; // Union-mode search returns flat rel_paths with no library attribution, // so clients may request a file under the wrong library. Try the // resolved library first; if the file isn't there, fall back to any // other library holding that rel_path on disk. let resolved = is_valid_full_path(&library.root_path, &req.path, false) .filter(|p| p.exists()) .map(|p| (library, p)) .or_else(|| { app_state.libraries.iter().find_map(|lib| { if lib.id == library.id { return None; } is_valid_full_path(&lib.root_path, &req.path, false) .filter(|p| p.exists()) .map(|p| (lib, p)) }) }); if let Some((library, path)) = resolved { let image_size = req.size.unwrap_or(PhotoSize::Full); if image_size == PhotoSize::Thumb { let relative_path = path .strip_prefix(&library.root_path) .expect("Error stripping library root prefix from thumbnail"); let relative_path_str = relative_path.to_string_lossy().replace('\\', "/"); let thumbs = &app_state.thumbnail_path; let bare_legacy_thumb_path = Path::new(&thumbs).join(relative_path); let scoped_legacy_thumb_path = content_hash::library_scoped_legacy_path( Path::new(&thumbs), library.id, relative_path, ); // Gif thumbnails are a separate lookup (video GIF previews). // Dual-lookup for gif is out of scope; preserve existing flow. if req.format == Some(ThumbnailFormat::Gif) && file_types::is_video_file(&path) { let mut gif_path = Path::new(&app_state.gif_path).join(relative_path); gif_path.set_extension("gif"); trace!("Gif thumbnail path: {:?}", gif_path); if let Ok(file) = NamedFile::open(&gif_path) { span.set_status(Status::Ok); return file .use_etag(true) .use_last_modified(true) .prefer_utf8(true) .into_response(&request); } } // Lookup chain (most-specific first, falling back as we miss): // 1. hash-keyed (`//.jpg`) — content // identity, shared across libraries; // 2. library-scoped legacy (`//`) — // written by current generation when hash isn't known; // 3. bare legacy (`/`) — pre-multi-library // thumbs from the days before library prefixing existed. // Stage (3) goes away once a one-time migration lifts every // bare-legacy file under a library prefix; until then it // prevents needless 404s for already-warmed deployments. let hash_thumb_path: Option = { let mut dao = exif_dao.lock().expect("Unable to lock ExifDao"); match dao.get_exif(&context, &relative_path_str) { Ok(Some(row)) => row .content_hash .as_deref() .map(|h| content_hash::thumbnail_path(Path::new(thumbs), h)), _ => None, } }; let thumb_path = hash_thumb_path .as_ref() .filter(|p| p.exists()) .cloned() .or_else(|| { if scoped_legacy_thumb_path.exists() { Some(scoped_legacy_thumb_path.clone()) } else { None } }) .unwrap_or_else(|| bare_legacy_thumb_path.clone()); // Handle circular thumbnail request if req.shape == Some(ThumbnailShape::Circle) { match create_circular_thumbnail(&thumb_path, thumbs).await { Ok(circular_path) => { if let Ok(file) = NamedFile::open(&circular_path) { span.set_status(Status::Ok); return file .use_etag(true) .use_last_modified(true) .prefer_utf8(true) .into_response(&request); } } Err(e) => { warn!("Failed to create circular thumbnail: {:?}", e); // Fall through to serve square thumbnail } } } trace!("Thumbnail path: {:?}", thumb_path); if let Ok(file) = NamedFile::open(&thumb_path) { span.set_status(Status::Ok); return file .use_etag(true) .use_last_modified(true) .prefer_utf8(true) .into_response(&request); } } // Full-size requests for RAW formats (NEF/CR2/ARW/etc.) can't just // NamedFile-stream the original bytes — browsers won't decode the // RAW container, so a `` lands as a broken image. Serve // the embedded JPEG preview instead (typically the camera's in-body // review JPEG, ~1–2 MP). Falls through to NamedFile if no preview is // available, which preserves the historical behavior for callers // that genuinely want the original bytes. if image_size == PhotoSize::Full && exif::is_tiff_raw(&path) { if let Some(preview) = exif::extract_embedded_jpeg_preview(&path) { span.set_status(Status::Ok); return HttpResponse::Ok() .content_type("image/jpeg") .insert_header(("Cache-Control", "public, max-age=3600")) .body(preview); } } if let Ok(file) = NamedFile::open(&path) { span.set_status(Status::Ok); // Enable ETag and set cache headers for full images (1 hour cache) return file .use_etag(true) .use_last_modified(true) .prefer_utf8(true) .into_response(&request); } span.set_status(Status::error("Not found")); HttpResponse::NotFound().finish() } else { span.set_status(Status::error("Not found")); error!("Path does not exist in any library: {}", req.path); HttpResponse::NotFound().finish() } } async fn create_circular_thumbnail( thumb_path: &Path, thumbs_dir: &str, ) -> Result> { use image::{GenericImageView, ImageBuffer, Rgba}; // Create circular thumbnails directory let circular_dir = Path::new(thumbs_dir).join("_circular"); // Get relative path from thumbs_dir to create same structure let relative_to_thumbs = thumb_path.strip_prefix(thumbs_dir)?; let circular_path = circular_dir.join(relative_to_thumbs).with_extension("png"); // Check if circular thumbnail already exists if circular_path.exists() { return Ok(circular_path); } // Create parent directory if needed if let Some(parent) = circular_path.parent() { std::fs::create_dir_all(parent)?; } // Load the square thumbnail let img = image::open(thumb_path)?; let (width, height) = img.dimensions(); // Fixed output size for consistency let output_size = 80u32; let radius = output_size as f32 / 2.0; // Calculate crop area to get square center of original image let crop_size = width.min(height); let crop_x = (width - crop_size) / 2; let crop_y = (height - crop_size) / 2; // Create a new RGBA image with transparency let output = ImageBuffer::from_fn(output_size, output_size, |x, y| { let dx = x as f32 - radius; let dy = y as f32 - radius; let distance = (dx * dx + dy * dy).sqrt(); if distance <= radius { // Inside circle - map to cropped source area // Scale from output coordinates to crop coordinates let scale = crop_size as f32 / output_size as f32; let src_x = crop_x + (x as f32 * scale) as u32; let src_y = crop_y + (y as f32 * scale) as u32; let pixel = img.get_pixel(src_x, src_y); Rgba([pixel[0], pixel[1], pixel[2], 255]) } else { // Outside circle - transparent Rgba([0, 0, 0, 0]) } }); // Save as PNG (supports transparency) output.save(&circular_path)?; Ok(circular_path) } #[get("/image/metadata")] pub async fn get_file_metadata( _: Claims, request: HttpRequest, path: web::Query, app_state: Data, exif_dao: Data>>, ) -> impl Responder { let tracer = global_tracer(); let context = extract_context_from_request(&request); let mut span = tracer.start_with_context("get_file_metadata", &context); let span_context = opentelemetry::Context::new().with_remote_span_context(span.span_context().clone()); let library = libraries::resolve_library_param(&app_state, path.library.as_deref()) .ok() .flatten() .unwrap_or_else(|| app_state.primary_library()); // Fall back to other libraries if the file isn't under the resolved one, // matching the `/image` handler so union-mode search results resolve. let resolved = is_valid_full_path(&library.root_path, &path.path, false) .filter(|p| p.exists()) .map(|p| (library, p)) .or_else(|| { app_state.libraries.iter().find_map(|lib| { if lib.id == library.id { return None; } is_valid_full_path(&lib.root_path, &path.path, false) .filter(|p| p.exists()) .map(|p| (lib, p)) }) }); match resolved .ok_or_else(|| ErrorKind::InvalidData.into()) .and_then(|(lib, full_path)| { File::open(&full_path) .and_then(|file| file.metadata()) .map(|metadata| (lib, metadata)) }) { Ok((resolved_library, metadata)) => { let mut response: MetadataResponse = metadata.into(); response.library_id = Some(resolved_library.id); response.library_name = Some(resolved_library.name.clone()); // Extract date from filename if possible response.filename_date = memories::extract_date_from_filename(&path.path).map(|dt| dt.timestamp()); // Query EXIF data if available if let Ok(mut dao) = exif_dao.lock() && let Ok(Some(exif)) = dao.get_exif(&span_context, &path.path) { response.exif = Some(exif.into()); } span.add_event( "Metadata fetched", vec![KeyValue::new("file", path.path.clone())], ); span.set_status(Status::Ok); HttpResponse::Ok().json(response) } Err(e) => { let message = format!("Error getting metadata for file '{}': {:?}", path.path, e); error!("{}", message); span.set_status(Status::error(message)); HttpResponse::InternalServerError().finish() } } } /// Body for `POST /image/exif/gps` — write GPS coordinates into a file's /// EXIF in place. Only `path` + `latitude` + `longitude` are required. /// `library` is optional (falls back to the primary library) and matches /// the convention of the other path-keyed routes. #[derive(serde::Deserialize)] struct SetGpsRequest { path: String, library: Option, latitude: f64, longitude: f64, } #[post("/image/exif/gps")] pub async fn set_image_gps( _: Claims, request: HttpRequest, body: web::Json, app_state: Data, exif_dao: Data>>, ) -> impl Responder { let tracer = global_tracer(); let context = extract_context_from_request(&request); let mut span = tracer.start_with_context("set_image_gps", &context); let span_context = opentelemetry::Context::new().with_remote_span_context(span.span_context().clone()); let library = libraries::resolve_library_param(&app_state, body.library.as_deref()) .ok() .flatten() .unwrap_or_else(|| app_state.primary_library()); // Same fallback as get_file_metadata: union-mode means a file may // resolve under a sibling library. let resolved = is_valid_full_path(&library.root_path, &body.path, false) .filter(|p| p.exists()) .map(|p| (library, p)) .or_else(|| { app_state.libraries.iter().find_map(|lib| { if lib.id == library.id { return None; } is_valid_full_path(&lib.root_path, &body.path, false) .filter(|p| p.exists()) .map(|p| (lib, p)) }) }); let (resolved_library, full_path) = match resolved { Some(v) => v, None => { span.set_status(Status::error("file not found")); return HttpResponse::NotFound().body("File not found"); } }; if !exif::supports_exif(&full_path) { return HttpResponse::BadRequest().body("File format does not support EXIF GPS write"); } if let Err(e) = exif::write_gps(&full_path, body.latitude, body.longitude) { let msg = format!("exiftool write failed: {}", e); error!("{}", msg); span.set_status(Status::error(msg.clone())); return HttpResponse::InternalServerError().body(msg); } // Re-read EXIF from disk (the write path doesn't tell us the rest of // the parsed fields back, and we want the DB row to match what // extract_exif_from_path would now produce). Update the existing row // rather than insert — this endpoint is invoked on already-indexed // files only. let extracted = match exif::extract_exif_from_path(&full_path) { Ok(d) => d, Err(e) => { // GPS was written successfully but re-extraction failed; surface // a 500 because the DB will now disagree with disk until the // next file scan rewrites it. let msg = format!("EXIF re-read failed after write: {}", e); error!("{}", msg); return HttpResponse::InternalServerError().body(msg); } }; let now = Utc::now().timestamp(); let normalized_path = body.path.replace('\\', "/"); // Re-run the canonical-date waterfall on every GPS write — exiftool // writing GPS doesn't change the capture date, but if the row was // previously sourced from `fs_time` the re-read may have given us a // real EXIF date this time, and we want to upgrade the source. let resolved_date = date_resolver::resolve_date_taken(&full_path, extracted.date_taken); let insert_exif = InsertImageExif { library_id: resolved_library.id, file_path: normalized_path.clone(), camera_make: extracted.camera_make, camera_model: extracted.camera_model, lens_model: extracted.lens_model, width: extracted.width, height: extracted.height, orientation: extracted.orientation, gps_latitude: extracted.gps_latitude.map(|v| v as f32), gps_longitude: extracted.gps_longitude.map(|v| v as f32), gps_altitude: extracted.gps_altitude.map(|v| v as f32), focal_length: extracted.focal_length.map(|v| v as f32), aperture: extracted.aperture.map(|v| v as f32), shutter_speed: extracted.shutter_speed, iso: extracted.iso, date_taken: resolved_date.map(|r| r.timestamp), // Created_time is preserved by update_exif (it doesn't touch the // column); pass any int — it's ignored in the UPDATE statement. created_time: now, last_modified: now, // Hash + size aren't touched in update_exif either, but the file // bytes did change — best-effort recompute so the new hash lands // on the next call to get_exif. Failure here just leaves the old // values in place. content_hash: content_hash::compute(&full_path) .ok() .map(|c| c.content_hash), size_bytes: content_hash::compute(&full_path).ok().map(|c| c.size_bytes), // GPS-update path doesn't touch perceptual hashes either; columns // ignored by update_exif. Compute best-effort so a new file lands // with a usable signal; failure just leaves prior values in place. phash_64: perceptual_hash::compute(&full_path).map(|h| h.phash_64), dhash_64: perceptual_hash::compute(&full_path).map(|h| h.dhash_64), date_taken_source: resolved_date.map(|r| r.source.as_str().to_string()), }; let updated = { let mut dao = exif_dao.lock().expect("Unable to lock ExifDao"); // If the row doesn't exist yet (file isn't indexed for some reason), // insert instead so the GPS write is at least visible the moment // the watcher catches up. match dao.get_exif(&span_context, &normalized_path) { Ok(Some(_)) => dao.update_exif(&span_context, insert_exif), Ok(None) => dao.store_exif(&span_context, insert_exif), Err(_) => dao.update_exif(&span_context, insert_exif), } }; match updated { Ok(row) => { // Mirror the file metadata so the client gets the new size / // mtime in the same response and can refresh its cached // metadata block in one round-trip. let fs_meta = std::fs::metadata(&full_path).ok(); let mut response: MetadataResponse = match fs_meta { Some(m) => m.into(), None => MetadataResponse { created: None, modified: None, size: 0, exif: None, filename_date: None, library_id: None, library_name: None, }, }; response.exif = Some(row.into()); response.library_id = Some(resolved_library.id); response.library_name = Some(resolved_library.name.clone()); response.filename_date = memories::extract_date_from_filename(&body.path).map(|dt| dt.timestamp()); span.set_status(Status::Ok); HttpResponse::Ok().json(response) } Err(e) => { let msg = format!("EXIF DB update failed: {:?}", e); error!("{}", msg); span.set_status(Status::error(msg.clone())); HttpResponse::InternalServerError().body(msg) } } } /// `GET /image/exif/full?path=&library=` — full per-file EXIF dump via /// exiftool, for the DETAILS modal's "FULL EXIF" pane. Strictly richer /// than `/image/metadata`'s curated subset (every group exiftool can /// see: EXIF, File, MakerNotes, Composite, ICC_Profile, IPTC, …). /// /// On-demand only — the watcher / indexer never calls this. Falls back /// to 503 when exiftool isn't installed (deployer guidance is the same /// as for the RAW preview pipeline: install exiftool for full coverage). #[get("/image/exif/full")] pub async fn get_full_exif( _: Claims, request: HttpRequest, path: web::Query, app_state: Data, ) -> impl Responder { let tracer = global_tracer(); let context = extract_context_from_request(&request); let mut span = tracer.start_with_context("get_full_exif", &context); let library = libraries::resolve_library_param(&app_state, path.library.as_deref()) .ok() .flatten() .unwrap_or_else(|| app_state.primary_library()); // Same union-mode fallback as get_file_metadata — the file may live // under a sibling library when the requested one's path resolves but // doesn't actually contain the bytes. let resolved = is_valid_full_path(&library.root_path, &path.path, false) .filter(|p| p.exists()) .map(|p| (library, p)) .or_else(|| { app_state.libraries.iter().find_map(|lib| { if lib.id == library.id { return None; } is_valid_full_path(&lib.root_path, &path.path, false) .filter(|p| p.exists()) .map(|p| (lib, p)) }) }); let (resolved_library, full_path) = match resolved { Some(v) => v, None => { span.set_status(Status::error("file not found")); return HttpResponse::NotFound().body("File not found"); } }; // exiftool spawn is blocking — keep it off the actix worker by // running on the blocking pool. ~50–200 ms typical for a JPEG; // longer for RAW with rich MakerNotes. let exif_result = web::block(move || crate::exif::read_full_exif_via_exiftool(&full_path)).await; match exif_result { Ok(Ok(Some(tags))) => { span.set_status(Status::Ok); HttpResponse::Ok().json(serde_json::json!({ "library_id": resolved_library.id, "library_name": resolved_library.name, "tags": tags, })) } Ok(Ok(None)) => { // exiftool ran but produced no output for this file — treat as // empty rather than an error so the modal renders "no tags" // gracefully. HttpResponse::Ok().json(serde_json::json!({ "library_id": resolved_library.id, "library_name": resolved_library.name, "tags": serde_json::Value::Object(Default::default()), })) } Ok(Err(e)) => { let msg = format!("exiftool failed: {}", e); error!("{}", msg); span.set_status(Status::error(msg.clone())); // 503 — typically "exiftool isn't on PATH" or a transient spawn // failure. Apollo surfaces a hint in the modal. HttpResponse::ServiceUnavailable().body(msg) } Err(e) => { let msg = format!("blocking-pool error: {}", e); error!("{}", msg); span.set_status(Status::error(msg.clone())); HttpResponse::InternalServerError().body(msg) } } } /// Body for `POST /image/exif/date` — operator-driven date_taken override. /// `date_taken` is unix seconds (matches `image_exif.date_taken`'s convention /// — naive local reinterpreted as UTC, not real UTC; the Apollo client passes /// through the same value the photo carousel rendered before edit). #[derive(serde::Deserialize)] struct SetDateRequest { path: String, library: Option, date_taken: i64, } /// Body for `POST /image/exif/date/clear` — revert a manual override and /// restore the resolver-derived `(date_taken, date_taken_source)` pair from /// the snapshot. #[derive(serde::Deserialize)] struct ClearDateRequest { path: String, library: Option, } /// Build a `MetadataResponse` for the date endpoints. Mirrors /// `get_file_metadata`'s shape so the client gets a single source of truth /// after every mutation. Filesystem metadata is best-effort: if the file is /// on a stale mount or moved, the DB-side override still succeeds and the /// response carries `created=None, modified=None, size=0`. The DB row's /// updated EXIF is what matters here. fn build_metadata_response_for_date_mutation( library: &libraries::Library, rel_path: &str, exif: ImageExif, ) -> MetadataResponse { let full_path = is_valid_full_path(&library.root_path, &rel_path.to_string(), false); let fs_meta = full_path .as_ref() .filter(|p| p.exists()) .and_then(|p| std::fs::metadata(p).ok()); let mut response: MetadataResponse = match fs_meta { Some(m) => m.into(), None => MetadataResponse { created: None, modified: None, size: 0, exif: None, filename_date: None, library_id: None, library_name: None, }, }; response.exif = Some(exif.into()); response.library_id = Some(library.id); response.library_name = Some(library.name.clone()); response.filename_date = memories::extract_date_from_filename(rel_path).map(|dt| dt.timestamp()); response } #[post("/image/exif/date")] pub async fn set_image_date( _: Claims, request: HttpRequest, body: web::Json, app_state: Data, exif_dao: Data>>, ) -> impl Responder { let tracer = global_tracer(); let context = extract_context_from_request(&request); let mut span = tracer.start_with_context("set_image_date", &context); let span_context = opentelemetry::Context::new().with_remote_span_context(span.span_context().clone()); let library = match libraries::resolve_library_param(&app_state, body.library.as_deref()) { Ok(Some(lib)) => lib, Ok(None) => app_state.primary_library(), Err(msg) => { span.set_status(Status::error(msg.clone())); return HttpResponse::BadRequest().body(msg); } }; // Path normalization matches set_image_gps so a Windows-import client // doesn't end up with a backslash variant that misses the row. let normalized_path = body.path.replace('\\', "/"); let updated = { let mut dao = exif_dao.lock().expect("Unable to lock ExifDao"); dao.set_manual_date_taken(&span_context, library.id, &normalized_path, body.date_taken) }; match updated { Ok(row) => { span.set_status(Status::Ok); HttpResponse::Ok().json(build_metadata_response_for_date_mutation( &library, &normalized_path, row, )) } Err(e) => { let msg = format!("set_manual_date_taken failed: {:?}", e); error!("{}", msg); span.set_status(Status::error(msg.clone())); match e.kind { DbErrorKind::NotFound => HttpResponse::NotFound().body(msg), _ => HttpResponse::InternalServerError().body(msg), } } } } #[post("/image/exif/date/clear")] pub async fn clear_image_date( _: Claims, request: HttpRequest, body: web::Json, app_state: Data, exif_dao: Data>>, ) -> impl Responder { let tracer = global_tracer(); let context = extract_context_from_request(&request); let mut span = tracer.start_with_context("clear_image_date", &context); let span_context = opentelemetry::Context::new().with_remote_span_context(span.span_context().clone()); let library = match libraries::resolve_library_param(&app_state, body.library.as_deref()) { Ok(Some(lib)) => lib, Ok(None) => app_state.primary_library(), Err(msg) => { span.set_status(Status::error(msg.clone())); return HttpResponse::BadRequest().body(msg); } }; let normalized_path = body.path.replace('\\', "/"); let updated = { let mut dao = exif_dao.lock().expect("Unable to lock ExifDao"); dao.clear_manual_date_taken(&span_context, library.id, &normalized_path) }; match updated { Ok(row) => { span.set_status(Status::Ok); HttpResponse::Ok().json(build_metadata_response_for_date_mutation( &library, &normalized_path, row, )) } Err(e) => { let msg = format!("clear_manual_date_taken failed: {:?}", e); error!("{}", msg); span.set_status(Status::error(msg.clone())); match e.kind { DbErrorKind::NotFound => HttpResponse::NotFound().body(msg), _ => HttpResponse::InternalServerError().body(msg), } } } } #[derive(serde::Deserialize)] struct UploadQuery { library: Option, } #[post("/image")] pub async fn upload_image( _: Claims, request: HttpRequest, query: web::Query, mut payload: mp::Multipart, app_state: Data, exif_dao: Data>>, ) -> impl Responder { let tracer = global_tracer(); let context = extract_context_from_request(&request); let mut span = tracer.start_with_context("upload_image", &context); let span_context = opentelemetry::Context::new().with_remote_span_context(span.span_context().clone()); // Resolve the optional library selector. Absent → primary library // (backwards-compatible with clients that don't yet send `library=`). let target_library = match libraries::resolve_library_param(&app_state, query.library.as_deref()) { Ok(Some(lib)) => lib, Ok(None) => app_state.primary_library(), Err(msg) => { span.set_status(Status::error(msg.clone())); return HttpResponse::BadRequest().body(msg); } }; let mut file_content: BytesMut = BytesMut::new(); let mut file_name: Option = None; let mut file_path: Option = None; while let Some(Ok(mut part)) = payload.next().await { if let Some(content_type) = part.content_disposition() { debug!("{:?}", content_type); if let Some(filename) = content_type.get_filename() { debug!("Name (raw): {:?}", filename); // Decode URL-encoded filename (e.g., "file%20name.jpg" -> "file name.jpg") let decoded_filename = decode(filename) .map(|s| s.to_string()) .unwrap_or_else(|_| filename.to_string()); debug!("Name (decoded): {:?}", decoded_filename); file_name = Some(decoded_filename); while let Some(Ok(data)) = part.next().await { file_content.put(data); } } else if content_type.get_name() == Some("path") { while let Some(Ok(data)) = part.next().await { if let Ok(path) = std::str::from_utf8(&data) { file_path = Some(path.to_string()) } } } } } let path = file_path.unwrap_or_else(|| target_library.root_path.clone()); if !file_content.is_empty() { if file_name.is_none() { span.set_status(Status::error("No filename provided")); return HttpResponse::BadRequest().body("No filename provided"); } let full_path = PathBuf::from(&path).join(file_name.unwrap()); if let Some(full_path) = is_valid_full_path( &target_library.root_path, &full_path.to_str().unwrap().to_string(), true, ) { // Pre-write content-hash check: if these exact bytes already // exist anywhere in any library (and aren't themselves // soft-marked as duplicates), don't write the file. Return // 409 with the canonical sibling so the mobile app can show // a friendly "already in your library" toast. let upload_hash = blake3::Hasher::new() .update(&file_content) .finalize() .to_hex() .to_string(); { let mut dao = exif_dao.lock().expect("Unable to lock ExifDao"); if let Ok(Some(existing)) = dao.find_by_content_hash(&span_context, &upload_hash) && existing.duplicate_of_hash.is_none() { let library_name = libraries::load_all(&mut crate::database::connect()) .into_iter() .find(|l| l.id == existing.library_id) .map(|l| l.name); span.set_status(Status::Ok); return HttpResponse::Conflict().json(serde_json::json!({ "duplicate_of": { "library_id": existing.library_id, "rel_path": existing.file_path, }, "content_hash": upload_hash, "library_name": library_name, })); } } let context = opentelemetry::Context::new().with_remote_span_context(span.span_context().clone()); tracer .span_builder("file write") .start_with_context(&tracer, &context); let uploaded_path = if !full_path.is_file() && is_image_or_video(&full_path) { let mut file = File::create(&full_path).unwrap(); file.write_all(&file_content).unwrap(); info!("Uploaded: {:?}", full_path); full_path } else { warn!("File already exists: {:?}", full_path); let new_path = format!( "{}/{}_{}.{}", full_path.parent().unwrap().to_str().unwrap(), full_path.file_stem().unwrap().to_str().unwrap(), Utc::now().timestamp(), full_path .extension() .expect("Uploaded file should have an extension") .to_str() .unwrap() ); info!("Uploaded: {}", new_path); let new_path_buf = PathBuf::from(&new_path); let mut file = File::create(&new_path_buf).unwrap(); file.write_all(&file_content).unwrap(); new_path_buf }; // Extract and store EXIF data if file supports it if exif::supports_exif(&uploaded_path) { let relative_path = uploaded_path .strip_prefix(&target_library.root_path) .expect("Error stripping library root prefix") .to_str() .unwrap() .replace('\\', "/"); match exif::extract_exif_from_path(&uploaded_path) { Ok(exif_data) => { let timestamp = Utc::now().timestamp(); let (content_hash, size_bytes) = match content_hash::compute(&uploaded_path) { Ok(id) => (Some(id.content_hash), Some(id.size_bytes)), Err(e) => { warn!( "Failed to hash uploaded {}: {:?}", uploaded_path.display(), e ); (None, None) } }; let perceptual = perceptual_hash::compute(&uploaded_path); let resolved_date = date_resolver::resolve_date_taken(&uploaded_path, exif_data.date_taken); let insert_exif = InsertImageExif { library_id: target_library.id, file_path: relative_path.clone(), camera_make: exif_data.camera_make, camera_model: exif_data.camera_model, lens_model: exif_data.lens_model, width: exif_data.width, height: exif_data.height, orientation: exif_data.orientation, gps_latitude: exif_data.gps_latitude.map(|v| v as f32), gps_longitude: exif_data.gps_longitude.map(|v| v as f32), gps_altitude: exif_data.gps_altitude.map(|v| v as f32), focal_length: exif_data.focal_length.map(|v| v as f32), aperture: exif_data.aperture.map(|v| v as f32), shutter_speed: exif_data.shutter_speed, iso: exif_data.iso, date_taken: resolved_date.map(|r| r.timestamp), created_time: timestamp, last_modified: timestamp, content_hash, size_bytes, phash_64: perceptual.map(|h| h.phash_64), dhash_64: perceptual.map(|h| h.dhash_64), date_taken_source: resolved_date.map(|r| r.source.as_str().to_string()), }; if let Ok(mut dao) = exif_dao.lock() { if let Err(e) = dao.store_exif(&span_context, insert_exif) { error!("Failed to store EXIF data for {}: {:?}", relative_path, e); } else { debug!("EXIF data stored for {}", relative_path); } } } Err(e) => { debug!( "No EXIF data or error extracting from {}: {:?}", uploaded_path.display(), e ); } } } } else { error!("Invalid path for upload: {:?}", full_path); span.set_status(Status::error("Invalid path for upload")); return HttpResponse::BadRequest().body("Path was not valid"); } } else { span.set_status(Status::error("No file body read")); return HttpResponse::BadRequest().body("No file body read"); } app_state.stream_manager.do_send(RefreshThumbnailsMessage); span.set_status(Status::Ok); HttpResponse::Ok().finish() }