//! `/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 tracing_opentelemetry::OpenTelemetrySpanExt; 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::Status; 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::perceptual_hash; use crate::state::AppState; #[get("/image")] #[tracing::instrument(name = "get_image", skip_all)] pub async fn get_image( _claims: Claims, request: HttpRequest, req: web::Query, app_state: Data, exif_dao: Data>>, ) -> impl Responder { let span = tracing::Span::current(); // 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_state(&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); // `size=large|xlarge` is only meaningful for stills — there's no // useful "resized video preview" tier. Videos fall back to the // existing thumb pipeline (which already handles gif/static // selection). `mut` so preview branches can downgrade to `Full` // after a generation failure. let mut image_size = if (image_size == PhotoSize::Large || image_size == PhotoSize::XLarge) && file_types::is_video_file(&path) { PhotoSize::Thumb } else { image_size }; if image_size == PhotoSize::Large || image_size == PhotoSize::XLarge { let tier = if image_size == PhotoSize::Large { PreviewTier::Large } else { PreviewTier::XLarge }; if let Some(resp) = serve_preview( tier, library, &path, Path::new(&app_state.thumbnail_path), &exif_dao, &request, ) .await { span.set_status(Status::Ok); return resp; } // Fall through to the Full branch below so the caller gets // *something* useful (the original bytes — or the RAW // embedded preview, which is what the Full branch returns for // unrenderable RAW containers) instead of a 404. image_size = 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(&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 Some(file) = open_derivative(&thumb_path) { span.set_status(Status::Ok); return file .use_etag(true) .use_last_modified(true) .prefer_utf8(true) .into_response(&request); } // No thumb on disk (never generated, or an `.unsupported` // sentinel from a failed scan). Never stream the original into // a thumb slot: a 12432×3504 panorama decodes to 174MB, past // Android's 100MB draw limit, and crashes the app natively. // The large tier is capped at 2048px; if even that can't be // made, the client gets a 404 and shows a blank tile. if !file_types::is_video_file(&path) { if let Some(resp) = serve_preview( PreviewTier::Large, library, &path, Path::new(&app_state.thumbnail_path), &exif_dao, &request, ) .await { span.set_status(Status::Ok); return resp; } span.set_status(Status::error("Thumbnail unavailable")); return HttpResponse::NotFound().finish(); } } // 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) && 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() } } /// On-demand resized tiers served by [`serve_preview`]. #[derive(Clone, Copy)] enum PreviewTier { Large, XLarge, } impl PreviewTier { fn name(self) -> &'static str { match self { PreviewTier::Large => "Large", PreviewTier::XLarge => "XLarge", } } fn legacy_dir(self) -> &'static str { match self { PreviewTier::Large => "_large", PreviewTier::XLarge => "_xlarge", } } fn hash_path(self, thumbs: &Path, hash: &str) -> PathBuf { match self { PreviewTier::Large => content_hash::large_preview_path(thumbs, hash), PreviewTier::XLarge => content_hash::xlarge_preview_path(thumbs, hash), } } fn generate(self, src: &Path, dest: &Path) -> std::io::Result<()> { match self { PreviewTier::Large => crate::thumbnails::generate_large_preview(src, dest), PreviewTier::XLarge => crate::thumbnails::generate_xlarge_preview(src, dest), } } } /// Serve the cached `tier` preview of `path`, generating it on a miss. /// `None` when it can't be produced; the caller picks the fallback. async fn serve_preview( tier: PreviewTier, library: &libraries::Library, path: &Path, thumbs: &Path, exif_dao: &Mutex>, request: &HttpRequest, ) -> Option { let relative_path = path .strip_prefix(&library.root_path) .expect("Error stripping library root prefix from preview"); let relative_path_str = relative_path.to_string_lossy().replace('\\', "/"); // Lookup chain mirrors the Thumb branch — hash-keyed first so // multi-library deployments share derivative bytes across // libraries, then library-scoped legacy as the fallback for // rows that aren't hashed yet. let hash_path: Option = { let mut dao = exif_dao.lock().expect("Unable to lock ExifDao"); match dao.get_exif(&relative_path_str) { Ok(Some(row)) => row .content_hash .as_deref() .map(|h| tier.hash_path(thumbs, h)), _ => None, } }; let scoped_legacy_path = content_hash::library_scoped_legacy_path( &thumbs.join(tier.legacy_dir()), library.id, relative_path, ); let serve = |p: &Path| { open_derivative(p).map(|file| { file.use_etag(true) .use_last_modified(true) .prefer_utf8(true) .into_response(request) }) }; let existing = hash_path .as_ref() .filter(|p| p.exists()) .or_else(|| Some(&scoped_legacy_path).filter(|p| p.exists())); if let Some(resp) = existing.and_then(|p| serve(p)) { return Some(resp); } // Cache miss — generate. Resize + JPEG-encode can take 100–500ms // for a 24MP source (longer for RAW), so run on the blocking pool // to keep the actix worker free. Prefer the hash-keyed // destination when a hash is known so the result is reusable // across libraries that hold the same bytes. let dest = hash_path.unwrap_or(scoped_legacy_path); let src = path.to_path_buf(); let dest_for_block = dest.clone(); let generated = web::block(move || generate_preview(tier, &src, &dest_for_block)).await; match generated { Ok(Ok(())) => {} Ok(Err(e)) => warn!( "{} preview generation failed for {:?}: {}", tier.name(), path, e ), Err(e) => warn!( "{} preview blocking-pool error for {:?}: {}", tier.name(), path, e ), } serve(&dest) } /// Generate `tier` for `src` into a per-call tempfile, then atomically /// rename it to `dest`, so a concurrent reader never sees a half-written /// JPEG. The tempfile name must be unique: clients fire the same request /// twice at once (tile remounts), and a shared `.tmp` let the first /// rename steal the second's file — "cannot find the file specified /// (os error 2)". fn generate_preview(tier: PreviewTier, src: &Path, dest: &Path) -> std::io::Result<()> { let parent = dest .parent() .ok_or_else(|| std::io::Error::other("preview path has no parent"))?; std::fs::create_dir_all(parent)?; let tmp = tempfile::Builder::new() .suffix(".jpg.tmp") .tempfile_in(parent)? .into_temp_path(); tier.generate(src, &tmp)?; tmp.persist(dest)?; Ok(()) } 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. Sniff the format: a legacy thumb keeps // its source's name (`clip.mp4`, `shot.arw`) but holds JPEG bytes. let img = image::ImageReader::open(thumb_path)? .with_guessed_format()? .decode()?; 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")] #[tracing::instrument(name = "get_file_metadata", skip_all)] pub async fn get_file_metadata( _: Claims, path: web::Query, app_state: Data, exif_dao: Data>>, ) -> impl Responder { let span = tracing::Span::current(); let library = libraries::resolve_library_param_state(&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(memories::FilenameDate::legacy_timestamp); // Query EXIF data if available let row = exif_dao .lock() .ok() .and_then(|mut dao| dao.get_exif(&path.path).ok().flatten()); set_capture_dates( &mut response, row.as_ref(), &path.path, crate::date_view::owner_display_zone(&app_state.user_ai_prefs_dao), ); response.exif = row.map(Into::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")] #[tracing::instrument(name = "set_image_gps", skip_all)] pub async fn set_image_gps( _: Claims, body: web::Json, app_state: Data, exif_dao: Data>>, ) -> impl Responder { let span = tracing::Span::current(); let library = libraries::resolve_library_param_state(&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, date_resolver::exif_prior_from(&extracted).as_ref(), ); 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()), date_utc: resolved_date.and_then(|r| r.date_utc), camera_offset_minutes: resolved_date.and_then(|r| r.camera_offset_minutes), date_taken_tz_source: resolved_date .and_then(|r| r.tz_source) .map(|t| t.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(&normalized_path) { Ok(Some(_)) => dao.update_exif(insert_exif), Ok(None) => dao.store_exif(insert_exif), Err(_) => dao.update_exif(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, wall_clock: None, true_instant: None, }, }; set_capture_dates( &mut response, Some(&row), &normalized_path, crate::date_view::owner_display_zone(&app_state.user_ai_prefs_dao), ); 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(memories::FilenameDate::legacy_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")] #[tracing::instrument(name = "get_full_exif", skip_all)] pub async fn get_full_exif( _: Claims, path: web::Query, app_state: Data, ) -> impl Responder { let span = tracing::Span::current(); let library = libraries::resolve_library_param_state(&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 override. /// `date_taken` is unix seconds: the wall the user picked rendered in their /// display zone, encoded naive-as-UTC (the Apollo client passes through the /// same value the photo carousel rendered before edit). The legacy /// `date_taken` column stores it verbatim; the new-contract triple /// (`date_utc` / `camera_offset_minutes` / `date_taken_tz_source = /// 'manual'`, ADR-0001 ticket #115) is derived from it as a wall clock in /// the user's display zone. #[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, display_zone: chrono_tz::Tz, ) -> 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, wall_clock: None, true_instant: None, }, }; set_capture_dates(&mut response, Some(&exif), rel_path, display_zone); 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(memories::FilenameDate::legacy_timestamp); response } /// Fill the capture-date fields every metadata response carries (ADR-0001): /// `wall_clock` to display and `true_instant` to match against. Needs the /// fs times already on the response (the unindexed-file fallback). fn set_capture_dates( response: &mut MetadataResponse, row: Option<&ImageExif>, rel_path: &str, display_zone: chrono_tz::Tz, ) { let server_zone = crate::camera_tz::server_zone(); let fs_earliest = earliest_fs_time(response); response.wall_clock = crate::date_view::file_wall_clock(row, rel_path, fs_earliest, display_zone, server_zone); response.true_instant = crate::date_view::file_true_instant(row, rel_path, fs_earliest, display_zone, server_zone); } /// Earlier of the file's created / modified times — the ingest waterfall's /// `fs_time` rule — for the wall-clock fallback of unindexed files. fn earliest_fs_time(response: &MetadataResponse) -> Option { match (response.created, response.modified) { (Some(c), Some(m)) => Some(c.min(m)), (c, m) => c.or(m), } } #[post("/image/exif/date")] #[tracing::instrument(name = "set_image_date", skip_all)] pub async fn set_image_date( _: Claims, body: web::Json, app_state: Data, exif_dao: Data>>, ) -> impl Responder { let span = tracing::Span::current(); let library = match libraries::resolve_library_param_state(&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('\\', "/"); // The user's display zone — the wall the UI sent is rendered in this // zone (ADR-0001, ticket #115). NULL / unparseable prefs fall back to // the app default inside display_zone_for. let display_tz = { let mut prefs_dao = app_state .user_ai_prefs_dao .lock() .expect("Unable to lock UserAiPrefsDao"); match prefs_dao.get_prefs() { Ok(Some(prefs)) => crate::camera_tz::display_zone_for(prefs.display_tz.as_deref()), _ => crate::camera_tz::display_zone_for(None), } }; let updated = { let mut dao = exif_dao.lock().expect("Unable to lock ExifDao"); dao.set_manual_date_taken(library.id, &normalized_path, body.date_taken, &display_tz) }; match updated { Ok(row) => { span.set_status(Status::Ok); HttpResponse::Ok().json(build_metadata_response_for_date_mutation( library, &normalized_path, row, crate::date_view::owner_display_zone(&app_state.user_ai_prefs_dao), )) } 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")] #[tracing::instrument(name = "clear_image_date", skip_all)] pub async fn clear_image_date( _: Claims, body: web::Json, app_state: Data, exif_dao: Data>>, ) -> impl Responder { let span = tracing::Span::current(); let library = match libraries::resolve_library_param_state(&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(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, crate::date_view::owner_display_zone(&app_state.user_ai_prefs_dao), )) } 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")] #[tracing::instrument(name = "upload_image", skip_all)] pub async fn upload_image( _: Claims, query: web::Query, mut payload: mp::Multipart, app_state: Data, exif_dao: Data>>, ) -> impl Responder { let span = tracing::Span::current(); // 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_state(&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(&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 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, date_resolver::exif_prior_from(&exif_data).as_ref(), ); 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()), date_utc: resolved_date.and_then(|r| r.date_utc), camera_offset_minutes: resolved_date .and_then(|r| r.camera_offset_minutes), date_taken_tz_source: resolved_date .and_then(|r| r.tz_source) .map(|t| t.as_str().to_string()), }; if let Ok(mut dao) = exif_dao.lock() { if let Err(e) = dao.store_exif(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() } /// Open a generated derivative (thumbnail / preview) with a content type /// taken from its bytes rather than its name. Legacy derivative paths /// mirror the source's relative path, so a video's JPEG thumbnail lives /// at `…/clip.mp4` and a RAW's at `…/shot.arw`; `NamedFile` would label /// those `video/mp4` / `image/x-sony-arw`, and clients that trust the /// header hand a JPEG to the wrong decoder. fn open_derivative(path: &Path) -> Option { let file = NamedFile::open(path).ok()?; let mut magic = [0u8; 8]; let sniffed = File::open(path) .and_then(|mut f| f.read(&mut magic)) .ok() .and_then(|n| sniff_image_mime(&magic[..n])); Some(match sniffed { Some(mime) => file.set_content_type(mime), None => file, }) } /// The image formats derivatives are written in (JPEG, or PNG for a PNG /// source thumbnailed by the `image` crate), identified by magic bytes. fn sniff_image_mime(head: &[u8]) -> Option { if head.starts_with(&[0xFF, 0xD8, 0xFF]) { Some(actix_web::mime::IMAGE_JPEG) } else if head.starts_with(b"\x89PNG\r\n\x1a\n") { Some(actix_web::mime::IMAGE_PNG) } else { None } } #[cfg(test)] mod tests { use super::*; use crate::database::SqliteExifDao; use crate::database::test::in_memory_db_connection; use actix_web::test::TestRequest; use image::GenericImageView; #[test] fn derivative_content_type_comes_from_bytes_not_name() { let dir = tempfile::tempdir().unwrap(); let req = TestRequest::default().to_http_request(); let content_type = |name: &str, bytes: &[u8]| { let p = dir.path().join(name); std::fs::write(&p, bytes).unwrap(); let resp = open_derivative(&p).unwrap().into_response(&req); resp.headers() .get("content-type") .unwrap() .to_str() .unwrap() .to_string() }; // A video's legacy thumb: JPEG bytes under the source's name. assert_eq!( content_type("clip.mp4", &[0xFF, 0xD8, 0xFF, 0xE0, 0]), "image/jpeg" ); assert_eq!( content_type("shot.arw", &[0xFF, 0xD8, 0xFF, 0xDB]), "image/jpeg" ); assert_eq!( content_type("shot.png", b"\x89PNG\r\n\x1a\n...."), "image/png" ); // Unrecognised bytes keep the extension-based guess. assert_eq!(content_type("anim.gif", b"GIF89a"), "image/gif"); } fn library(root: &Path) -> libraries::Library { libraries::Library { id: libraries::PRIMARY_LIBRARY_ID, name: "main".to_string(), root_path: root.to_string_lossy().to_string(), enabled: true, excluded_dirs: Vec::new(), } } fn exif_dao() -> Mutex> { Mutex::new(Box::new(SqliteExifDao::from_connection( in_memory_db_connection(), ))) } /// Write a solid-color JPEG of the given size under `root`. fn write_jpeg(root: &Path, name: &str, w: u32, h: u32) -> PathBuf { let path = root.join(name); image::RgbImage::from_pixel(w, h, image::Rgb([90, 140, 200])) .save(&path) .unwrap(); path } #[test] fn concurrent_generation_of_one_preview_all_succeed() { let root = tempfile::tempdir().unwrap(); let thumbs = tempfile::tempdir().unwrap(); let src = write_jpeg(root.path(), "pano.jpg", 3000, 800); let dest = thumbs.path().join("_large").join("ab").join("abcd.jpg"); // Clients fire the same preview twice at once; with a shared // tempfile name the loser's rename failed with os error 2. let barrier = std::sync::Barrier::new(8); std::thread::scope(|s| { let handles: Vec<_> = (0..8) .map(|_| { s.spawn(|| { barrier.wait(); generate_preview(PreviewTier::Large, &src, &dest) }) }) .collect(); for h in handles { h.join().unwrap().expect("concurrent generation failed"); } }); assert!(dest.exists()); let leftovers: Vec<_> = std::fs::read_dir(dest.parent().unwrap()) .unwrap() .filter_map(Result::ok) .filter(|e| e.file_name() != "abcd.jpg") .collect(); assert!(leftovers.is_empty(), "stray tempfiles: {:?}", leftovers); } #[actix_rt::test] async fn large_preview_caps_a_panorama_at_2048() { let root = tempfile::tempdir().unwrap(); let thumbs = tempfile::tempdir().unwrap(); let lib = library(root.path()); let src = write_jpeg(root.path(), "pano.jpg", 6000, 1600); let req = TestRequest::default().to_http_request(); serve_preview( PreviewTier::Large, &lib, &src, thumbs.path(), &exif_dao(), &req, ) .await .expect("preview served"); // Unhashed row → library-scoped legacy path under `_large/`. let dest = content_hash::library_scoped_legacy_path( &thumbs.path().join("_large"), lib.id, Path::new("pano.jpg"), ); let (w, h) = image::open(dest).unwrap().dimensions(); assert_eq!((w, h), (2048, 546)); } }