use ::anyhow; use actix::{Handler, Message}; use anyhow::Context; use std::collections::HashSet; use std::fmt::Debug; use std::fs::read_dir; use std::io; use std::io::ErrorKind; use std::path::{Path, PathBuf}; use std::sync::Mutex; use std::time::SystemTime; use crate::data::{ Claims, ExifBatchRequest, ExifBatchResponse, ExifSummary, FilesRequest, FilterMode, MediaType, PhotosResponse, SortType, }; use crate::database::ExifDao; use crate::file_types; use crate::geo::{gps_bounding_box, haversine_distance}; use crate::memories::extract_date_from_filename; use crate::utils::earliest_fs_time; use crate::{AppState, create_thumbnails}; use actix_web::web::Data; use actix_web::{ HttpRequest, HttpResponse, web::{self, Query}, }; use chrono::{DateTime, Utc}; use log::{debug, error, info, trace, warn}; use opentelemetry::KeyValue; use opentelemetry::trace::{Span, Status, TraceContextExt, Tracer}; use crate::data::SortType::NameAsc; use crate::error::IntoHttpError; use crate::otel::{extract_context_from_request, global_tracer}; use crate::tags::{FileWithTagCount, TagDao}; use crate::video::actors::StreamActor; use path_absolutize::*; use rand::prelude::SliceRandom; use rand::thread_rng; /// File metadata for sorting and filtering /// Includes tag count and optional date for date-based sorting pub struct FileWithMetadata { pub file_name: String, pub tag_count: i64, pub date_taken: Option, // Unix timestamp from EXIF or filename extraction pub library_id: i32, } use serde::Deserialize; /// Apply sorting to files with EXIF data support for date-based sorting /// Handles both date sorting (with EXIF/filename fallback) and regular sorting /// Returns (sorted_file_paths, sorted_library_ids, total_count) fn apply_sorting_with_exif( files: Vec, file_libraries: Vec, sort_type: SortType, exif_dao: &mut Box, span_context: &opentelemetry::Context, libraries: &[crate::libraries::Library], limit: Option, offset: i64, ) -> (Vec, Vec, i64) { let total_count = files.len() as i64; match sort_type { SortType::DateTakenAsc | SortType::DateTakenDesc => { info!("Date sorting requested, using in-memory sort with EXIF/filename fallback"); let (sorted, sorted_libs, _) = in_memory_date_sort( files, file_libraries, sort_type, exif_dao, span_context, libraries, limit, offset, ); (sorted, sorted_libs, total_count) } _ => { let (sorted, sorted_libs) = sort(files, file_libraries, sort_type); let (result, result_libs) = if let Some(limit_val) = limit { let skip = offset as usize; let take = limit_val as usize; ( sorted.iter().skip(skip).take(take).cloned().collect(), sorted_libs.iter().skip(skip).take(take).copied().collect(), ) } else { (sorted, sorted_libs) }; (result, result_libs, total_count) } } } /// Fallback in-memory date sorting with EXIF/filename extraction fn in_memory_date_sort( files: Vec, file_libraries: Vec, sort_type: SortType, exif_dao: &mut Box, span_context: &opentelemetry::Context, libraries: &[crate::libraries::Library], limit: Option, offset: i64, ) -> (Vec, Vec, i64) { let total_count = files.len() as i64; let file_paths: Vec = files.iter().map(|f| f.file_name.clone()).collect(); // Batch fetch EXIF data (keyed by rel_path; in union mode a rel_path may // correspond to rows in multiple libraries — pick the date from the one // matching the requesting row's library_id when possible). let exif_rows = exif_dao .get_exif_batch(span_context, &file_paths) .unwrap_or_default(); let exif_map: std::collections::HashMap<(String, i32), i64> = exif_rows .into_iter() .filter_map(|exif| { exif.date_taken .map(|dt| ((exif.file_path, exif.library_id), dt)) }) .collect(); let lib_roots: std::collections::HashMap = libraries .iter() .map(|l| (l.id, l.root_path.as_str())) .collect(); // Convert to FileWithMetadata with date fallback logic let files_with_metadata: Vec = files .into_iter() .zip(file_libraries.iter().copied()) .map(|(f, lib_id)| { let date_taken = exif_map .get(&(f.file_name.clone(), lib_id)) .copied() .or_else(|| extract_date_from_filename(&f.file_name).map(|dt| dt.timestamp())) .or_else(|| { lib_roots.get(&lib_id).and_then(|root| { let full_path = Path::new(root).join(&f.file_name); std::fs::metadata(full_path) .ok() .and_then(|md| earliest_fs_time(&md)) .map(|system_time| { >>::into(system_time).timestamp() }) }) }); FileWithMetadata { file_name: f.file_name, tag_count: f.tag_count, date_taken, library_id: lib_id, } }) .collect(); let (sorted, sorted_libs) = sort_with_metadata(files_with_metadata, sort_type); let (result, result_libs) = if let Some(limit_val) = limit { let skip = offset as usize; let take = limit_val as usize; ( sorted .iter() .skip(skip) .take(take) .cloned() .collect::>(), sorted_libs .iter() .skip(skip) .take(take) .copied() .collect::>(), ) } else { (sorted, sorted_libs) }; (result, result_libs, total_count) } pub async fn list_photos( _: Claims, request: HttpRequest, req: Query, app_state: Data, file_system: Data, tag_dao: Data>, exif_dao: Data>>, ) -> HttpResponse { let search_path = &req.path; let tracer = global_tracer(); let context = extract_context_from_request(&request); let mut span = tracer.start_with_context("list_photos", &context); span.set_attributes(vec![ KeyValue::new("path", search_path.to_string()), KeyValue::new("recursive", req.recursive.unwrap_or(false).to_string()), KeyValue::new( "tag_ids", req.tag_ids.clone().unwrap_or_default().to_string(), ), KeyValue::new( "tag_filter_mode", format!("{:?}", req.tag_filter_mode.unwrap_or(FilterMode::Any)), ), KeyValue::new( "exclude_tag_ids", req.exclude_tag_ids.clone().unwrap_or_default().to_string(), ), KeyValue::new("sort", format!("{:?}", &req.sort.unwrap_or(NameAsc))), // EXIF search parameters KeyValue::new("camera_make", req.camera_make.clone().unwrap_or_default()), KeyValue::new("camera_model", req.camera_model.clone().unwrap_or_default()), KeyValue::new("lens_model", req.lens_model.clone().unwrap_or_default()), KeyValue::new( "gps_lat", req.gps_lat.map(|v| v.to_string()).unwrap_or_default(), ), KeyValue::new( "gps_lon", req.gps_lon.map(|v| v.to_string()).unwrap_or_default(), ), KeyValue::new( "gps_radius_km", req.gps_radius_km.map(|v| v.to_string()).unwrap_or_default(), ), KeyValue::new( "date_from", req.date_from.map(|v| v.to_string()).unwrap_or_default(), ), KeyValue::new( "date_to", req.date_to.map(|v| v.to_string()).unwrap_or_default(), ), KeyValue::new( "media_type", req.media_type .as_ref() .map(|mt| format!("{:?}", mt)) .unwrap_or_default(), ), // Pagination parameters KeyValue::new("pagination.enabled", req.limit.is_some().to_string()), KeyValue::new( "pagination.limit", req.limit.map(|l| l.to_string()).unwrap_or_default(), ), KeyValue::new("pagination.offset", req.offset.unwrap_or(0).to_string()), // Optimization flags KeyValue::new("optimization.batch_tags", "true"), KeyValue::new( "optimization.db_sort", matches!( req.sort, Some(SortType::DateTakenAsc | SortType::DateTakenDesc) ) .to_string(), ), KeyValue::new("library", req.library.clone().unwrap_or_default()), ]); // Resolve the optional library filter. Unknown values return 400. A // `None` result means "union across all libraries" and downstream // walks iterate every configured library root. let library = match crate::libraries::resolve_library_param(&app_state, req.library.as_deref()) { Ok(lib) => lib, Err(msg) => { log::warn!("Rejecting /photos request: {}", msg); return HttpResponse::BadRequest().body(msg); } }; let span_context = opentelemetry::Context::current_with_span(span); // Check if EXIF filtering is requested let has_exif_filters = req.camera_make.is_some() || req.camera_model.is_some() || req.lens_model.is_some() || req.gps_lat.is_some() || req.date_from.is_some() || req.date_to.is_some(); // Apply EXIF-based filtering if requested let exif_matched_files: Option> = if has_exif_filters { // Validate GPS parameters (all 3 must be present together) if (req.gps_lat.is_some() || req.gps_lon.is_some() || req.gps_radius_km.is_some()) && !(req.gps_lat.is_some() && req.gps_lon.is_some() && req.gps_radius_km.is_some()) { warn!("GPS search requires lat, lon, and radius_km to all be specified"); span_context .span() .set_status(Status::error("Invalid GPS parameters")); return HttpResponse::BadRequest().body("GPS search requires lat, lon, and radius_km"); } // Calculate GPS bounding box if GPS search is requested let gps_bounds = if let (Some(lat), Some(lon), Some(radius_km)) = (req.gps_lat, req.gps_lon, req.gps_radius_km) { let (min_lat, max_lat, min_lon, max_lon) = gps_bounding_box(lat, lon, radius_km); Some((min_lat, max_lat, min_lon, max_lon)) } else { None }; // Query EXIF database let mut exif_dao_guard = exif_dao.lock().expect("Unable to get ExifDao"); let exif_results = exif_dao_guard .query_by_exif( &span_context, req.camera_make.as_deref(), req.camera_model.as_deref(), req.lens_model.as_deref(), gps_bounds, req.date_from, req.date_to, ) .unwrap_or_else(|e| { warn!("EXIF query failed: {:?}", e); Vec::new() }); // Apply precise GPS distance filtering if GPS search was requested let filtered_results = if let (Some(lat), Some(lon), Some(radius_km)) = (req.gps_lat, req.gps_lon, req.gps_radius_km) { exif_results .into_iter() .filter(|exif| { if let (Some(photo_lat), Some(photo_lon)) = (exif.gps_latitude, exif.gps_longitude) { let distance = haversine_distance(lat, lon, photo_lat as f64, photo_lon as f64); distance <= radius_km } else { false } }) .map(|exif| exif.file_path) .collect::>() } else { exif_results .into_iter() .map(|exif| exif.file_path) .collect::>() }; info!("EXIF filtering matched {} files", filtered_results.len()); Some(filtered_results) } else { None }; // In scoped mode (`library` is Some) we gate tag-based results (which // key on rel_path only) by "does this rel_path actually exist on disk // in the selected library's root". In union mode we assign each // returned file to the first library it resolves in, and drop files // that exist in no configured library. let libraries_to_scan: Vec<&crate::libraries::Library> = match library { Some(lib) => vec![lib], None => app_state.libraries.iter().collect(), }; let search_recursively = req.recursive.unwrap_or(false); if let Some(tag_ids) = &req.tag_ids && search_recursively { let filter_mode = &req.tag_filter_mode.unwrap_or(FilterMode::Any); info!( "Searching for tags: {}. With path: '{}' and filter mode: {:?}", tag_ids, search_path, filter_mode ); let mut dao = tag_dao.lock().expect("Unable to get TagDao"); let tag_ids = tag_ids .split(',') .filter_map(|t| t.parse().ok()) .collect::>(); let exclude_tag_ids = req .exclude_tag_ids .clone() .unwrap_or_default() .split(',') .filter_map(|t| t.parse().ok()) .collect::>(); return match filter_mode { FilterMode::Any => { dao.get_files_with_any_tag_ids(tag_ids.clone(), exclude_tag_ids, &span_context) } FilterMode::All => { dao.get_files_with_all_tag_ids(tag_ids.clone(), exclude_tag_ids, &span_context) } } .context(format!( "Failed to get files with tag_ids: {:?} with filter_mode: {:?}", tag_ids, filter_mode )) .inspect(|files| { info!( "Found {:?} tagged files, filtering down by search path {:?}", files.len(), search_path ) }) .map(|tagged_files| { tagged_files .into_iter() .filter(|f| { // When searching at the root, everything matches recursively if search_path.trim() == "" { return true; } f.file_name.starts_with(&format!( "{}/", search_path.strip_suffix('/').unwrap_or_else(|| search_path) )) }) .filter(|f| { // Apply EXIF filtering if present if let Some(ref exif_files) = exif_matched_files { exif_files.contains(&f.file_name) } else { true } }) .filter_map(|f| { // Apply media type filter first (cheap check before disk I/O). if let Some(ref media_type) = req.media_type { let path = PathBuf::from(&f.file_name); if !matches_media_type(&path, media_type) { return None; } } // Resolve the file's library by checking each // candidate library's root on disk. Falls back to // content-hash siblings if the rel_path was // registered under a different path but same content. for lib in &libraries_to_scan { if PathBuf::from(&lib.root_path).join(&f.file_name).exists() { return Some((f, lib.id)); } } let siblings = { let mut dao = exif_dao.lock().expect("Unable to get ExifDao"); match dao .find_content_hash_anywhere(&span_context, &f.file_name) .unwrap_or(None) { Some(hash) => dao .get_rel_paths_by_hash(&span_context, &hash) .unwrap_or_default(), None => Vec::new(), } }; for lib in &libraries_to_scan { if siblings .iter() .any(|p| PathBuf::from(&lib.root_path).join(p).exists()) { return Some((f, lib.id)); } } // Tags are library-agnostic. If we can't confirm which // library currently holds the file on disk (e.g. the // tagged rel_path is stale or the caller is testing // without real files), keep the tagged row and // attribute it to the primary library so the client // still sees the tag hit. if library.is_none() { Some((f, app_state.primary_library().id)) } else { None } }) .collect::>() }) .map(|paired| { // Handle sorting - use helper function that supports EXIF date sorting and pagination let sort_type = req.sort.unwrap_or(NameAsc); let limit = req.limit; let offset = req.offset.unwrap_or(0); let (files, file_libs): (Vec, Vec) = paired.into_iter().unzip(); let mut exif_dao_guard = exif_dao.lock().expect("Unable to get ExifDao"); let result = apply_sorting_with_exif( files, file_libs, sort_type, &mut exif_dao_guard, &span_context, &app_state.libraries, limit, offset, ); drop(exif_dao_guard); result }) .inspect(|(files, _libs, total)| debug!("Found {:?} files (total: {})", files.len(), total)) .map(|(tagged_files, photo_libraries, total_count)| { info!( "Found {:?} tagged files: {:?}", tagged_files.len(), tagged_files ); let returned_count = tagged_files.len() as i64; let offset = req.offset.unwrap_or(0); let pagination_metadata = if req.limit.is_some() { ( Some(total_count), Some(offset + returned_count < total_count), if offset + returned_count < total_count { Some(offset + returned_count) } else { None }, ) } else { (None, None, None) }; span_context .span() .set_attribute(KeyValue::new("file_count", tagged_files.len().to_string())); span_context .span() .set_attribute(KeyValue::new("total_count", total_count.to_string())); span_context.span().set_status(Status::Ok); HttpResponse::Ok().json(PhotosResponse { photos: tagged_files, dirs: vec![], photo_libraries, total_count: pagination_metadata.0, has_more: pagination_metadata.1, next_offset: pagination_metadata.2, }) }) .into_http_internal_err() .unwrap_or_else(|e| e.error_response()); } // In scoped mode `libraries_to_scan` has one entry (the selected library); // in union mode we enumerate every configured library and intermix results. // // Recursive mode pulls rel_paths from image_exif (kept in parity with disk // by the watcher's full-scan reconciliation) instead of walking — a ~10k // file library drops from multi-second to ~10ms for the listing itself. // Non-recursive mode still walks because we need directory metadata for // the `dirs` response and listing a single directory is cheap. let mut file_names: Vec = Vec::new(); let mut file_libraries: Vec = Vec::new(); let mut dirs_set: std::collections::HashSet = std::collections::HashSet::new(); let mut any_library_resolved = false; if search_recursively { let start_db_list = std::time::Instant::now(); let lib_ids: Vec = libraries_to_scan.iter().map(|l| l.id).collect(); let trimmed = search_path.trim(); let prefix = if trimmed.is_empty() || trimmed == "/" { None } else { Some(trimmed) }; let rows = { let mut dao = exif_dao.lock().expect("Unable to get ExifDao"); dao.list_rel_paths_for_libraries(&span_context, &lib_ids, prefix) .unwrap_or_else(|e| { warn!("list_rel_paths_for_libraries failed: {:?}", e); Vec::new() }) }; info!( "DB-backed recursive listing: {} files across {} libraries in {:?}", rows.len(), lib_ids.len(), start_db_list.elapsed() ); any_library_resolved = true; for (lib_id, path) in rows { file_libraries.push(lib_id); file_names.push(path); } } else { for lib in &libraries_to_scan { let files_result = if lib.id == app_state.primary_library().id { file_system.get_files_for_path(search_path) } else { is_valid_full_path( &PathBuf::from(&lib.root_path), &PathBuf::from(search_path), false, ) .map(|path| { debug!("Valid path for non-recursive search: {:?}", path); list_files(&path).unwrap_or_default() }) .context("Invalid path") }; let files = match files_result { Ok(f) => { any_library_resolved = true; f } Err(e) => { debug!( "Skipping library '{}' for path '{}': {:?}", lib.name, search_path, e ); continue; } }; info!( "Found {:?} files in library '{}' path: {:?} (recursive: {})", files.len(), lib.name, search_path, search_recursively ); for path in &files { match path.metadata() { Ok(md) => { let relative = path.strip_prefix(&lib.root_path).unwrap_or_else(|_| { panic!( "Unable to strip library root {} from file path {}", &lib.root_path, path.display() ) }); // Normalize separators to '/' so downstream lookups // (tags, EXIF, insights) that store rel_paths with // forward slashes still match on Windows. let relative_str = relative.to_str().unwrap().replace('\\', "/"); if md.is_file() { file_names.push(relative_str); file_libraries.push(lib.id); } else if md.is_dir() { dirs_set.insert(relative_str); } } Err(e) => { error!("Failed getting file metadata: {:?}", e); // Include files without metadata if they have extensions if path.extension().is_some() { let relative = path.strip_prefix(&lib.root_path).unwrap_or_else(|_| { panic!( "Unable to strip library root {} from file path {}", &lib.root_path, path.display() ) }); file_names.push(relative.to_str().unwrap().replace('\\', "/")); file_libraries.push(lib.id); } } } } } } if !any_library_resolved { error!("Bad photos request: {}", req.path); span_context .span() .set_status(Status::error("Invalid path")); return HttpResponse::BadRequest().finish(); } let dirs: Vec = dirs_set.into_iter().collect(); info!( "Starting to filter {} files from filesystem", file_names.len() ); let start_filter = std::time::Instant::now(); info!( "File filtering took {:?}, now fetching tag counts for {} files", start_filter.elapsed(), file_names.len() ); let start_tags = std::time::Instant::now(); // Batch query for tag counts (tags are library-agnostic / keyed by rel_path). let tag_counts = { let mut tag_dao_guard = tag_dao.lock().expect("Unable to get TagDao"); tag_dao_guard .get_tag_counts_batch(&span_context, &file_names) .unwrap_or_default() }; info!("Batch tag count query took {:?}", start_tags.elapsed()); let start_tag_filter = std::time::Instant::now(); let file_tags_map: std::collections::HashMap> = if req.tag_ids.is_some() || req.exclude_tag_ids.is_some() { info!( "Tag filtering requested, fetching full tag lists for {} files", file_names.len() ); let mut tag_dao_guard = tag_dao.lock().expect("Unable to get TagDao"); file_names .iter() .filter_map(|file_name| { tag_dao_guard .get_tags_for_path(&span_context, file_name) .ok() .map(|tags| (file_name.clone(), tags)) }) .collect() } else { std::collections::HashMap::new() }; if req.tag_ids.is_some() || req.exclude_tag_ids.is_some() { info!("Full tag list fetch took {:?}", start_tag_filter.elapsed()); } // Filter + pair with the parallel library_id while preserving ordering // so the downstream sort can return both arrays in lockstep. let photos_with_libs: Vec<(FileWithTagCount, i32)> = file_names .into_iter() .zip(file_libraries.into_iter()) .filter_map(|(file_name, lib_id)| { let file_tags = file_tags_map.get(&file_name).cloned().unwrap_or_default(); if let Some(tag_ids_csv) = &req.tag_ids { let tag_ids = tag_ids_csv .split(',') .filter_map(|t| t.parse().ok()) .collect::>(); let excluded_tag_ids = req .exclude_tag_ids .clone() .unwrap_or_default() .split(',') .filter_map(|t| t.parse().ok()) .collect::>(); let filter_mode = req.tag_filter_mode.unwrap_or(FilterMode::Any); let excluded = file_tags.iter().any(|t| excluded_tag_ids.contains(&t.id)); let keep = !excluded && match filter_mode { FilterMode::Any => file_tags.iter().any(|t| tag_ids.contains(&t.id)), FilterMode::All => tag_ids .iter() .all(|id| file_tags.iter().any(|tag| &tag.id == id)), }; if !keep { return None; } } if let Some(ref exif_files) = exif_matched_files && !exif_files.contains(&file_name) { return None; } if let Some(ref media_type) = req.media_type { let path = PathBuf::from(&file_name); if !matches_media_type(&path, media_type) { return None; } } let tag_count = *tag_counts.get(&file_name).unwrap_or(&0); Some(( FileWithTagCount { file_name, tag_count, }, lib_id, )) }) .collect(); info!( "After all filters, {} files remain (filtering took {:?})", photos_with_libs.len(), start_filter.elapsed() ); // Extract pagination parameters let limit = req.limit; let offset = req.offset.unwrap_or(0); let start_sort = std::time::Instant::now(); let (photos, file_libs_sorted_input): (Vec, Vec) = photos_with_libs.into_iter().unzip(); let (response_files, response_libraries, total_count) = if let Some(sort_type) = req.sort { info!("Sorting {} files by {:?}", photos.len(), sort_type); let mut exif_dao_guard = exif_dao.lock().expect("Unable to get ExifDao"); let result = apply_sorting_with_exif( photos, file_libs_sorted_input, sort_type, &mut exif_dao_guard, &span_context, &app_state.libraries, limit, offset, ); drop(exif_dao_guard); result } else { // No sorting requested - apply pagination if requested let total = photos.len() as i64; let (paged_files, paged_libs): (Vec, Vec) = if let Some(limit_val) = limit { photos .into_iter() .zip(file_libs_sorted_input) .skip(offset as usize) .take(limit_val as usize) .map(|(f, lib)| (f.file_name, lib)) .unzip() } else { photos .into_iter() .zip(file_libs_sorted_input) .map(|(f, lib)| (f.file_name, lib)) .unzip() }; (paged_files, paged_libs, total) }; info!( "Sorting took {:?}, returned {} files (total: {})", start_sort.elapsed(), response_files.len(), total_count ); let returned_count = response_files.len() as i64; let pagination_metadata = if limit.is_some() { ( Some(total_count), Some(offset + returned_count < total_count), if offset + returned_count < total_count { Some(offset + returned_count) } else { None }, ) } else { (None, None, None) }; span_context.span().set_attribute(KeyValue::new( "file_count", response_files.len().to_string(), )); span_context .span() .set_attribute(KeyValue::new("returned_count", returned_count.to_string())); span_context .span() .set_attribute(KeyValue::new("total_count", total_count.to_string())); span_context.span().set_status(Status::Ok); HttpResponse::Ok().json(PhotosResponse { photos: response_files, dirs, photo_libraries: response_libraries, total_count: pagination_metadata.0, has_more: pagination_metadata.1, next_offset: pagination_metadata.2, }) } fn sort( files: Vec, file_libraries: Vec, sort_type: SortType, ) -> (Vec, Vec) { let mut paired: Vec<(FileWithTagCount, i32)> = files.into_iter().zip(file_libraries).collect(); match sort_type { SortType::Shuffle => paired.shuffle(&mut thread_rng()), NameAsc => paired.sort_by(|l, r| l.0.file_name.cmp(&r.0.file_name)), SortType::NameDesc => paired.sort_by(|l, r| r.0.file_name.cmp(&l.0.file_name)), SortType::TagCountAsc => paired.sort_by(|l, r| l.0.tag_count.cmp(&r.0.tag_count)), SortType::TagCountDesc => paired.sort_by(|l, r| r.0.tag_count.cmp(&l.0.tag_count)), SortType::DateTakenAsc | SortType::DateTakenDesc => { warn!("Date sorting not implemented for FileWithTagCount"); paired.sort_by(|l, r| l.0.file_name.cmp(&r.0.file_name)); } } paired .into_iter() .map(|(f, lib)| (f.file_name, lib)) .unzip() } /// Sort files with metadata support (including date sorting) fn sort_with_metadata( mut files: Vec, sort_type: SortType, ) -> (Vec, Vec) { match sort_type { SortType::Shuffle => files.shuffle(&mut thread_rng()), NameAsc => { files.sort_by(|l, r| l.file_name.cmp(&r.file_name)); } SortType::NameDesc => { files.sort_by(|l, r| r.file_name.cmp(&l.file_name)); } SortType::TagCountAsc => { files.sort_by(|l, r| l.tag_count.cmp(&r.tag_count)); } SortType::TagCountDesc => { files.sort_by(|l, r| r.tag_count.cmp(&l.tag_count)); } SortType::DateTakenAsc | SortType::DateTakenDesc => { files.sort_by(|l, r| { match (l.date_taken, r.date_taken) { (Some(a), Some(b)) => { if sort_type == SortType::DateTakenAsc { a.cmp(&b) } else { b.cmp(&a) } } (Some(_), None) => std::cmp::Ordering::Less, // Dated photos first (None, Some(_)) => std::cmp::Ordering::Greater, (None, None) => l.file_name.cmp(&r.file_name), // Fallback to name } }); } } files .into_iter() .map(|f| (f.file_name, f.library_id)) .unzip() } pub fn list_files(dir: &Path) -> io::Result> { let tracer = global_tracer(); let mut span = tracer.start("list_files"); let dir_name_string = dir.to_str().unwrap_or_default().to_string(); span.set_attribute(KeyValue::new("dir", dir_name_string)); info!("Listing files in: {:?}", dir); let files = read_dir(dir)? .filter_map(|res| res.ok()) .filter(|entry| is_image_or_video(&entry.path()) || entry.file_type().unwrap().is_dir()) .map(|entry| entry.path()) .collect::>(); span.set_attribute(KeyValue::new("file_count", files.len().to_string())); span.set_status(Status::Ok); info!("Found {:?} files in directory: {:?}", files.len(), dir); Ok(files) } pub fn is_image_or_video(path: &Path) -> bool { file_types::is_media_file(path) } /// Check if a file matches the media type filter fn matches_media_type(path: &Path, media_type: &MediaType) -> bool { let result = match media_type { MediaType::All => file_types::is_image_file(path) || file_types::is_video_file(path), MediaType::Photo => file_types::is_image_file(path), MediaType::Video => file_types::is_video_file(path), }; let extension = path .extension() .and_then(|p| p.to_str()) .map_or(String::from(""), |p| p.to_lowercase()); debug!( "Media type check: path={:?}, extension='{}', type={:?}, match={}", path, extension, media_type, result ); result } pub fn is_valid_full_path + Debug + AsRef>( base: &P, path: &P, new_file: bool, ) -> Option { trace!("is_valid_full_path => Base: {:?}. Path: {:?}", base, path); let path = PathBuf::from(&path); let mut path = if path.is_relative() { let mut full_path = PathBuf::new(); full_path.push(base); full_path.push(&path); full_path } else { path }; match is_path_above_base_dir(base, &mut path, new_file) { Ok(path) => Some(path), Err(PathValidationError::DoesNotExist(p)) => { debug!("Path does not exist under base {:?}: {:?}", base, p); None } Err(PathValidationError::AboveBase(p)) => { error!("Path above base directory {:?}: {:?}", base, p); None } Err(PathValidationError::Other(e)) => { error!("{}", e); None } } } #[derive(Debug)] enum PathValidationError { DoesNotExist(PathBuf), AboveBase(PathBuf), Other(anyhow::Error), } impl std::fmt::Display for PathValidationError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { PathValidationError::DoesNotExist(p) => write!(f, "Path does not exist: {:?}", p), PathValidationError::AboveBase(p) => write!(f, "Path above base directory: {:?}", p), PathValidationError::Other(e) => write!(f, "{}", e), } } } fn is_path_above_base_dir + Debug>( base: P, full_path: &mut PathBuf, new_file: bool, ) -> Result { match full_path.absolutize() { Err(e) => Err(PathValidationError::Other( anyhow::Error::new(e) .context(format!("Unable to resolve absolute path: {:?}", full_path)), )), Ok(p) => { if p.starts_with(base) && (new_file || p.exists()) { Ok(p.into_owned()) } else if !p.exists() { Err(PathValidationError::DoesNotExist(p.into_owned())) } else { Err(PathValidationError::AboveBase(p.into_owned())) } } } } /// Handler for GPS summary endpoint /// Returns lightweight GPS data for all photos with coordinates pub async fn get_gps_summary( _: Claims, request: HttpRequest, req: Query, exif_dao: Data>>, app_state: Data, ) -> Result { use crate::data::{GpsPhotoSummary, GpsPhotosResponse}; let parent_cx = extract_context_from_request(&request); let tracer = global_tracer(); let mut span = tracer .span_builder("get_gps_summary") .start_with_context(&tracer, &parent_cx); span.set_attribute(KeyValue::new("path", req.path.clone())); span.set_attribute(KeyValue::new( "recursive", req.recursive.unwrap_or(false).to_string(), )); let cx = opentelemetry::Context::current_with_span(span); // The database stores relative paths, so we use the path as-is. // Normalize empty path or "/" to return all GPS photos. Validation // is purely a traversal guard — the path need not exist on disk // under any particular library, because the DAO just does a prefix // match against image_exif.rel_path (which is library-agnostic for // this summary query). let requested_path = if req.path.is_empty() || req.path == "/" { String::new() } else { let req_path = PathBuf::from(&req.path); let validated = app_state.libraries.iter().any(|lib| { is_valid_full_path(&PathBuf::from(&lib.root_path), &req_path, true).is_some() }); if !validated { warn!("Invalid path for GPS summary: {}", req.path); cx.span().set_status(Status::error("Invalid path")); return Ok(HttpResponse::BadRequest().json(serde_json::json!({ "error": "Invalid path" }))); } req.path.clone() }; let recursive = req.recursive.unwrap_or(false); info!( "Fetching GPS photos for path='{}' recursive={}", requested_path, recursive ); // Query database for all photos with GPS let mut exif_dao_guard = exif_dao.lock().expect("Unable to get ExifDao"); match exif_dao_guard.get_all_with_gps(&cx, &requested_path, recursive) { Ok(gps_data) => { let mut photos: Vec = gps_data .into_iter() .map(|(path, lat, lon, date_taken)| GpsPhotoSummary { path, lat, lon, date_taken, }) .collect(); // Sort by date_taken based on request, defaulting to ascending (oldest to newest) use crate::data::SortType; let sort_type = req.sort.unwrap_or(SortType::DateTakenAsc); match sort_type { SortType::DateTakenDesc => { photos.sort_by(|a, b| match (a.date_taken, b.date_taken) { (Some(date_a), Some(date_b)) => date_b.cmp(&date_a), (Some(_), None) => std::cmp::Ordering::Less, (None, Some(_)) => std::cmp::Ordering::Greater, (None, None) => a.path.cmp(&b.path), }); } NameAsc => { photos.sort_by(|a, b| a.path.cmp(&b.path)); } SortType::NameDesc => { photos.sort_by(|a, b| b.path.cmp(&a.path)); } _ => { // Default: DateTakenAsc photos.sort_by(|a, b| match (a.date_taken, b.date_taken) { (Some(date_a), Some(date_b)) => date_a.cmp(&date_b), (Some(_), None) => std::cmp::Ordering::Less, (None, Some(_)) => std::cmp::Ordering::Greater, (None, None) => a.path.cmp(&b.path), }); } } let total = photos.len(); cx.span() .set_attribute(KeyValue::new("result_count", total as i64)); cx.span().set_status(Status::Ok); Ok(HttpResponse::Ok().json(GpsPhotosResponse { photos, total })) } Err(e) => { error!("Error querying GPS data: {:?}", e); cx.span() .set_status(Status::error(format!("Database error: {:?}", e))); Ok(HttpResponse::InternalServerError().json(serde_json::json!({ "error": "Failed to query GPS data" }))) } } } /// Handler for the batch EXIF endpoint at `GET /photos/exif`. /// /// Returns a single-row projection of `image_exif` for every photo whose /// `date_taken` falls in `[date_from, date_to]`, across all libraries. /// Designed to replace the N+1 pattern of `/photos` + per-file /// `/image/metadata` for window-scoped consumers like Apollo's photo-to- /// track matcher: one DB query, one HTTP round-trip, one mutex acquisition. /// /// Photos with no `date_taken` are excluded by construction (the underlying /// `query_by_exif` filter requires a non-null timestamp once a range is /// supplied). Filename-extracted dates are not synthesized here; if a /// caller needs that fallback, fetch the row separately via /// `/image/metadata` (rare path). pub async fn list_exif_summary( _: Claims, request: HttpRequest, req: Query, exif_dao: Data>>, app_state: Data, ) -> Result { let parent_cx = extract_context_from_request(&request); let tracer = global_tracer(); let mut span = tracer .span_builder("list_exif_summary") .start_with_context(&tracer, &parent_cx); span.set_attribute(KeyValue::new( "date_from", req.date_from.map(|v| v.to_string()).unwrap_or_default(), )); span.set_attribute(KeyValue::new( "date_to", req.date_to.map(|v| v.to_string()).unwrap_or_default(), )); span.set_attribute(KeyValue::new( "library", req.library.clone().unwrap_or_default(), )); // Resolve the library filter up front so a bad id/name 400s before we // ever take the DAO mutex. None == union across all libraries. let library_filter = match crate::libraries::resolve_library_param(&app_state, req.library.as_deref()) { Ok(lib) => lib.map(|l| l.id), Err(msg) => { span.set_status(Status::error(msg.clone())); return Ok(HttpResponse::BadRequest().json(serde_json::json!({ "error": msg }))); } }; let cx = opentelemetry::Context::current_with_span(span); // Pre-build an id → name map so we don't linear-scan libraries per row. let library_names: std::collections::HashMap = app_state .libraries .iter() .map(|lib| (lib.id, lib.name.clone())) .collect(); let mut exif_dao_guard = exif_dao.lock().expect("Unable to get ExifDao"); match exif_dao_guard.query_by_exif(&cx, None, None, None, None, req.date_from, req.date_to) { Ok(rows) => { let photos: Vec = rows .into_iter() // Library filter post-query: keeps the DAO trait (and its // mocks) unchanged. For typical 2–3 library setups the in- // memory pass over a date-bounded result set is negligible; // can be pushed into SQL later if it ever isn't. .filter(|r| library_filter.is_none_or(|id| r.library_id == id)) .map(|r| ExifSummary { library_name: library_names.get(&r.library_id).cloned(), file_path: r.file_path, library_id: r.library_id, camera_model: r.camera_model, width: r.width, height: r.height, // image_exif stores GPS as f32 to keep row size small; // widen for the JSON shape so clients don't need to // know about the on-disk precision. gps_latitude: r.gps_latitude.map(f64::from), gps_longitude: r.gps_longitude.map(f64::from), date_taken: r.date_taken, }) .collect(); let total = photos.len(); cx.span() .set_attribute(KeyValue::new("result_count", total as i64)); cx.span().set_status(Status::Ok); Ok(HttpResponse::Ok().json(ExifBatchResponse { photos, total })) } Err(e) => { error!("Error querying EXIF batch: {:?}", e); cx.span() .set_status(Status::error(format!("Database error: {:?}", e))); Ok(HttpResponse::InternalServerError().json(serde_json::json!({ "error": "Failed to query EXIF data" }))) } } } pub async fn move_file( _: Claims, file_system: Data, app_state: Data, request: web::Json, ) -> HttpResponse { info!("Moving file: {:?}", request); match is_valid_full_path(&app_state.base_path, &request.source, false) .ok_or(ErrorKind::InvalidData) .and_then(|source| { is_valid_full_path(&app_state.base_path, &request.destination, true) .ok_or(ErrorKind::InvalidData) .and_then(|dest| { if dest.exists() { error!("Destination already exists, not moving file: {:?}", source); Err(ErrorKind::AlreadyExists) } else { Ok(dest) } }) .map(|dest| (source, dest)) }) .map(|(source, dest)| file_system.move_file(source, dest)) { Ok(_) => { info!("Moved file: {} -> {}", request.source, request.destination,); app_state.stream_manager.do_send(RefreshThumbnailsMessage); HttpResponse::Ok().finish() } Err(e) => { error!( "Error moving file: {} to: {}. {}", request.source, request.destination, e ); if e == ErrorKind::InvalidData { HttpResponse::BadRequest().finish() } else { HttpResponse::InternalServerError().finish() } } } } #[derive(Deserialize, Debug)] pub struct MoveFileRequest { source: String, destination: String, } pub trait FileSystemAccess { fn get_files_for_path(&self, path: &str) -> anyhow::Result>; fn move_file>(&self, from: P, destination: P) -> anyhow::Result<()>; } pub struct RealFileSystem { base_path: String, } impl RealFileSystem { #[allow(dead_code)] // Used in main.rs binary and tests pub(crate) fn new(base_path: String) -> RealFileSystem { RealFileSystem { base_path } } } impl FileSystemAccess for RealFileSystem { fn get_files_for_path(&self, path: &str) -> anyhow::Result> { is_valid_full_path(&PathBuf::from(&self.base_path), &PathBuf::from(path), false) .map(|path| { debug!("Valid path: {:?}", path); list_files(&path).unwrap_or_default() }) .context("Invalid path") } fn move_file>(&self, from: P, destination: P) -> anyhow::Result<()> { info!( "Moving file: '{:?}' -> '{:?}'", from.as_ref(), destination.as_ref() ); let name = from .as_ref() .file_name() .map(|n| n.to_str().unwrap_or_default().to_string()) .unwrap_or_default(); std::fs::rename(from, destination) .with_context(|| format!("Failed to move file: {:?}", name)) } } pub struct RefreshThumbnailsMessage; impl Message for RefreshThumbnailsMessage { type Result = (); } impl Handler for StreamActor { type Result = (); fn handle(&mut self, _msg: RefreshThumbnailsMessage, _ctx: &mut Self::Context) -> Self::Result { let tracer = global_tracer(); let _ = tracer.start("RefreshThumbnailsMessage"); info!("Refreshing thumbnails after upload"); // The stub in lib.rs is a no-op; the real generation is driven by // the file watcher tick in main.rs, which has access to the // configured libraries. create_thumbnails(&[]) } } #[cfg(test)] mod tests { use super::*; use crate::database::DbError; use ::anyhow::anyhow; use std::collections::HashMap; use std::env; use std::fs::File; struct FakeFileSystem { files: HashMap>, base_path: String, err: bool, } impl FakeFileSystem { #[allow(dead_code)] fn with_error() -> FakeFileSystem { FakeFileSystem { files: HashMap::new(), base_path: String::new(), err: true, } } fn new(files: HashMap>) -> FakeFileSystem { // Use temp dir as base path for consistency let base_path = env::temp_dir(); FakeFileSystem { files, base_path: base_path.to_str().unwrap().to_string(), err: false, } } } impl FileSystemAccess for FakeFileSystem { fn get_files_for_path(&self, path: &str) -> anyhow::Result> { if self.err { Err(anyhow!("Error for test")) } else if let Some(files) = self.files.get(path) { // Prepend base_path to all returned files Ok(files .iter() .map(|f| PathBuf::from(&self.base_path).join(f)) .collect::>()) } else { Ok(Vec::new()) } } fn move_file>(&self, _from: P, _destination: P) -> anyhow::Result<()> { todo!() } } struct MockExifDao; impl ExifDao for MockExifDao { fn store_exif( &mut self, _context: &opentelemetry::Context, data: crate::database::models::InsertImageExif, ) -> Result { // Return a dummy ImageExif for tests Ok(crate::database::models::ImageExif { id: 1, library_id: data.library_id, file_path: data.file_path.to_string(), camera_make: data.camera_make.map(|s| s.to_string()), camera_model: data.camera_model.map(|s| s.to_string()), lens_model: data.lens_model.map(|s| s.to_string()), width: data.width, height: data.height, orientation: data.orientation, gps_latitude: data.gps_latitude, gps_longitude: data.gps_longitude, gps_altitude: data.gps_altitude, focal_length: data.focal_length, aperture: data.aperture, shutter_speed: data.shutter_speed, iso: data.iso, date_taken: data.date_taken, created_time: data.created_time, last_modified: data.last_modified, content_hash: data.content_hash.clone(), size_bytes: data.size_bytes, }) } fn get_exif( &mut self, _context: &opentelemetry::Context, _: &str, ) -> Result, DbError> { Ok(None) } fn update_exif( &mut self, _context: &opentelemetry::Context, data: crate::database::models::InsertImageExif, ) -> Result { // Return a dummy ImageExif for tests Ok(crate::database::models::ImageExif { id: 1, library_id: data.library_id, file_path: data.file_path.to_string(), camera_make: data.camera_make.map(|s| s.to_string()), camera_model: data.camera_model.map(|s| s.to_string()), lens_model: data.lens_model.map(|s| s.to_string()), width: data.width, height: data.height, orientation: data.orientation, gps_latitude: data.gps_latitude, gps_longitude: data.gps_longitude, gps_altitude: data.gps_altitude, focal_length: data.focal_length, aperture: data.aperture, shutter_speed: data.shutter_speed, iso: data.iso, date_taken: data.date_taken, created_time: data.created_time, last_modified: data.last_modified, content_hash: data.content_hash.clone(), size_bytes: data.size_bytes, }) } fn delete_exif( &mut self, _context: &opentelemetry::Context, _: &str, ) -> Result<(), DbError> { Ok(()) } fn get_all_with_date_taken( &mut self, _context: &opentelemetry::Context, _library_id: Option, ) -> Result, DbError> { Ok(Vec::new()) } fn get_exif_batch( &mut self, _context: &opentelemetry::Context, _: &[String], ) -> Result, DbError> { Ok(Vec::new()) } fn query_by_exif( &mut self, _context: &opentelemetry::Context, _: Option<&str>, _: Option<&str>, _: Option<&str>, _: Option<(f64, f64, f64, f64)>, _: Option, _: Option, ) -> Result, DbError> { Ok(Vec::new()) } fn get_camera_makes( &mut self, _context: &opentelemetry::Context, ) -> Result, DbError> { Ok(Vec::new()) } fn update_file_path( &mut self, _context: &opentelemetry::Context, _old_path: &str, _new_path: &str, ) -> Result<(), DbError> { Ok(()) } fn get_all_file_paths( &mut self, _context: &opentelemetry::Context, ) -> Result, DbError> { Ok(Vec::new()) } fn get_all_with_gps( &mut self, _context: &opentelemetry::Context, _base_path: &str, _recursive: bool, ) -> Result)>, DbError> { todo!() } fn get_rows_missing_hash( &mut self, _context: &opentelemetry::Context, _limit: i64, ) -> Result, DbError> { Ok(Vec::new()) } fn backfill_content_hash( &mut self, _context: &opentelemetry::Context, _library_id: i32, _rel_path: &str, _hash: &str, _size_bytes: i64, ) -> Result<(), DbError> { Ok(()) } fn find_by_content_hash( &mut self, _context: &opentelemetry::Context, _hash: &str, ) -> Result, DbError> { Ok(None) } fn get_rel_paths_sharing_content( &mut self, _context: &opentelemetry::Context, _library_id: i32, rel_path: &str, ) -> Result, DbError> { Ok(vec![rel_path.to_string()]) } fn get_rel_paths_for_library( &mut self, _context: &opentelemetry::Context, _library_id: i32, ) -> Result, DbError> { Ok(vec![]) } fn find_content_hash_anywhere( &mut self, _context: &opentelemetry::Context, _rel_path: &str, ) -> Result, DbError> { Ok(None) } fn get_rel_paths_by_hash( &mut self, _context: &opentelemetry::Context, _hash: &str, ) -> Result, DbError> { Ok(vec![]) } fn get_rel_paths_for_hashes( &mut self, _context: &opentelemetry::Context, _hashes: &[String], ) -> Result>, DbError> { Ok(std::collections::HashMap::new()) } fn list_rel_paths_for_libraries( &mut self, _context: &opentelemetry::Context, _library_ids: &[i32], _path_prefix: Option<&str>, ) -> Result, DbError> { Ok(vec![]) } fn delete_exif_by_library( &mut self, _context: &opentelemetry::Context, _library_id: i32, _rel_path: &str, ) -> Result<(), DbError> { Ok(()) } } mod api { use super::*; use actix_web::{HttpResponse, web::Query}; use crate::{ AppState, data::{Claims, PhotosResponse}, testhelpers::BodyReader, }; use crate::database::test::in_memory_db_connection; use crate::tags::SqliteTagDao; use actix_web::test::TestRequest; use actix_web::web::Data; use std::fs; fn setup() { let _ = env_logger::builder().is_test(true).try_init(); } #[actix_rt::test] async fn test_list_photos() { setup(); let claims = Claims { sub: String::from("1"), exp: 12345, }; let request: Query = Query::from_query("path=").unwrap(); // Create AppState with the same base_path as RealFileSystem let test_state = AppState::test_state(); // Create a dedicated test directory to avoid interference from other files in system temp let test_base = PathBuf::from(test_state.base_path.clone()); let mut test_dir = test_base.clone(); test_dir.push("test-dir"); fs::create_dir_all(&test_dir).unwrap(); let mut photo_path = test_base.clone(); photo_path.push("photo.jpg"); File::create(&photo_path).unwrap(); let response: HttpResponse = list_photos( claims, TestRequest::default().to_http_request(), request, Data::new(test_state), Data::new(RealFileSystem::new(test_base.to_str().unwrap().to_string())), Data::new(Mutex::new(SqliteTagDao::default())), Data::new(Mutex::new(Box::new(MockExifDao) as Box)), ) .await; let status = response.status(); assert_eq!(status, 200); let body: PhotosResponse = serde_json::from_str(&response.read_to_str()).unwrap(); debug!("{:?}", body); assert!(body.photos.contains(&String::from("photo.jpg"))); assert!(body.dirs.contains(&String::from("test-dir"))); assert!( body.photos .iter() .filter(|filename| !filename.ends_with(".png") && !filename.ends_with(".jpg") && !filename.ends_with(".jpeg")) .collect::>() .is_empty() ); // Cleanup let _ = fs::remove_dir_all(test_base); } #[actix_rt::test] async fn test_list_below_base_fails_400() { setup(); let claims = Claims { sub: String::from("1"), exp: 12345, }; let request: Query = Query::from_query("path=..").unwrap(); let temp_dir = env::temp_dir(); let response = list_photos( claims, TestRequest::default().to_http_request(), request, Data::new(AppState::test_state()), Data::new(RealFileSystem::new(temp_dir.to_str().unwrap().to_string())), Data::new(Mutex::new(SqliteTagDao::default())), Data::new(Mutex::new(Box::new(MockExifDao) as Box)), ) .await; assert_eq!(response.status(), 400); } #[actix_rt::test] async fn get_files_with_tag_any_filter() { setup(); let claims = Claims { sub: String::from("1"), exp: 12345, }; let request: Query = Query::from_query("path=&tag_ids=1,3&recursive=true").unwrap(); let mut tag_dao = SqliteTagDao::new(std::sync::Arc::new(Mutex::new(in_memory_db_connection()))); let tag1 = tag_dao .create_tag(&opentelemetry::Context::current(), "tag1") .unwrap(); let _tag2 = tag_dao .create_tag(&opentelemetry::Context::current(), "tag2") .unwrap(); let tag3 = tag_dao .create_tag(&opentelemetry::Context::current(), "tag3") .unwrap(); let _ = &tag_dao .tag_file(&opentelemetry::Context::current(), "test.jpg", tag1.id) .unwrap(); let _ = &tag_dao .tag_file(&opentelemetry::Context::current(), "test.jpg", tag3.id) .unwrap(); let response: HttpResponse = list_photos( claims, TestRequest::default().to_http_request(), request, Data::new(AppState::test_state()), Data::new(FakeFileSystem::new(HashMap::new())), Data::new(Mutex::new(tag_dao)), Data::new(Mutex::new(Box::new(MockExifDao) as Box)), ) .await; assert_eq!(200, response.status()); let body: PhotosResponse = serde_json::from_str(&response.read_to_str()).unwrap(); assert_eq!(1, body.photos.len()); assert!(body.photos.contains(&String::from("test.jpg"))); } #[actix_rt::test] async fn get_files_with_tag_all_filter() { setup(); let claims = Claims { sub: String::from("1"), exp: 12345, }; let mut tag_dao = SqliteTagDao::new(std::sync::Arc::new(Mutex::new(in_memory_db_connection()))); let tag1 = tag_dao .create_tag(&opentelemetry::Context::current(), "tag1") .unwrap(); let _tag2 = tag_dao .create_tag(&opentelemetry::Context::current(), "tag2") .unwrap(); let tag3 = tag_dao .create_tag(&opentelemetry::Context::current(), "tag3") .unwrap(); let _ = &tag_dao .tag_file(&opentelemetry::Context::current(), "test.jpg", tag1.id) .unwrap(); let _ = &tag_dao .tag_file(&opentelemetry::Context::current(), "test.jpg", tag3.id) .unwrap(); // Should get filtered since it doesn't have tag3 tag_dao .tag_file( &opentelemetry::Context::current(), "some-other.jpg", tag1.id, ) .unwrap(); let request: Query = Query::from_query(&format!( "path=&tag_ids={},{}&tag_filter_mode=All&recursive=true", tag1.id, tag3.id )) .unwrap(); let response: HttpResponse = list_photos( claims, TestRequest::default().to_http_request(), request, Data::new(AppState::test_state()), Data::new(FakeFileSystem::new(HashMap::new())), Data::new(Mutex::new(tag_dao)), Data::new(Mutex::new(Box::new(MockExifDao) as Box)), ) .await; assert_eq!(200, response.status()); let body: PhotosResponse = serde_json::from_str(&response.read_to_str()).unwrap(); assert_eq!(1, body.photos.len()); assert!(body.photos.contains(&String::from("test.jpg"))); } } #[test] fn directory_traversal_test() { let base = env::temp_dir(); assert_eq!( None, is_valid_full_path(&base, &PathBuf::from("../"), false) ); assert_eq!(None, is_valid_full_path(&base, &PathBuf::from(".."), false)); assert_eq!( None, is_valid_full_path(&base, &PathBuf::from("fake/../../../"), false) ); assert_eq!( None, is_valid_full_path(&base, &PathBuf::from("../../../etc/passwd"), false) ); assert_eq!( None, is_valid_full_path(&base, &PathBuf::from("..//etc/passwd"), false) ); assert_eq!( None, is_valid_full_path(&base, &PathBuf::from("../../etc/passwd"), false) ); } #[test] fn build_from_path_relative_to_base_test() { let base = env::temp_dir(); let mut test_file = PathBuf::from(&base); test_file.push("test.png"); File::create(test_file).unwrap(); assert!(is_valid_full_path(&base, &PathBuf::from("test.png"), false).is_some()); } #[test] fn build_from_relative_returns_none_if_directory_does_not_exist_test() { let base = env::temp_dir(); let path = "relative/path/test.png"; let mut test_file = PathBuf::from(&base); test_file.push(path); assert_eq!(None, is_valid_full_path(&base, &test_file, false)); } #[test] fn build_from_absolute_path_test() { let base = env::temp_dir(); let mut test_file = PathBuf::from(&base); test_file.push("test.png"); File::create(&test_file).unwrap(); assert!(is_valid_full_path(&base, &test_file, false).is_some()); assert_eq!( Some(test_file.clone()), is_valid_full_path(&base, &test_file, false) ); } macro_rules! extension_test { ($name:ident, $filename:literal) => { #[test] fn $name() { assert!(is_image_or_video(Path::new($filename))); } }; } extension_test!(valid_png, "image.png"); extension_test!(valid_png_mixed_case, "image.pNg"); extension_test!(valid_png_upper_case, "image.PNG"); extension_test!(valid_jpeg, "image.jpeg"); extension_test!(valid_jpeg_upper_case, "image.JPEG"); extension_test!(valid_jpg, "image.jpg"); extension_test!(valid_jpg_upper_case, "image.JPG"); extension_test!(valid_mp4, "image.mp4"); extension_test!(valid_mp4_mixed_case, "image.mP4"); extension_test!(valid_mp4_upper_case, "image.MP4"); extension_test!(valid_mov, "image.mov"); extension_test!(valid_mov_mixed_case, "image.mOV"); extension_test!(valid_mov_upper_case, "image.MOV"); extension_test!(valid_nef, "image.nef"); extension_test!(valid_nef_mixed_case, "image.nEF"); extension_test!(valid_nef_upper_case, "image.NEF"); #[test] fn hidden_file_not_valid_test() { assert!(!is_image_or_video(Path::new(".DS_store"))); } }