use std::{fs, str::FromStr}; use crate::database::models::ImageExif; use anyhow::{Context, anyhow}; use chrono::{DateTime, Utc}; use log::error; use actix_web::error::ErrorUnauthorized; use actix_web::{Error, FromRequest, HttpRequest, dev, http::header}; use futures::future::{Ready, err, ok}; use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode}; use serde::{Deserialize, Serialize}; #[derive(Serialize)] pub struct Token<'a> { pub token: &'a str, } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct Claims { pub sub: String, pub exp: i64, } #[cfg(test)] pub mod helper { use super::Claims; use chrono::{Duration, Utc}; impl Claims { pub fn valid_user(user_id: String) -> Self { Claims { sub: user_id, exp: (Utc::now() + Duration::minutes(1)).timestamp(), } } } } pub fn secret_key() -> String { if cfg!(test) { String::from("test_key") } else { dotenv::var("SECRET_KEY").expect("SECRET_KEY env not set!") } } impl FromStr for Claims { type Err = jsonwebtoken::errors::Error; fn from_str(s: &str) -> Result { let token = s.strip_prefix("Bearer ").ok_or_else(|| { jsonwebtoken::errors::Error::from(jsonwebtoken::errors::ErrorKind::InvalidToken) })?; match decode::( token, &DecodingKey::from_secret(secret_key().as_bytes()), &Validation::new(Algorithm::HS256), ) { Ok(data) => Ok(data.claims), Err(other) => { error!("DecodeError: {}", other); Err(other) } } } } impl FromRequest for Claims { type Error = Error; type Future = Ready>; fn from_request(req: &HttpRequest, _payload: &mut dev::Payload) -> Self::Future { req.headers() .get(header::AUTHORIZATION) .map_or_else( || Err(anyhow!("No authorization header")), |header| { header .to_str() .context("Unable to read Authorization header to string") }, ) .and_then(|header| { Claims::from_str(header) .with_context(|| "Unable to decode token from Authorization header") }) .map_or_else( |e| { error!("{}", e); err(ErrorUnauthorized("Bad token")) }, ok, ) } } #[derive(Serialize, Deserialize, Debug)] pub struct PhotosResponse { pub photos: Vec, pub dirs: Vec, /// Library id for each entry in `photos`, same length and ordering. /// Parallel array rather than an object per row to keep the payload /// small and backwards-compatible with older clients. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub photo_libraries: Vec, // Pagination metadata (only present when limit is set) #[serde(skip_serializing_if = "Option::is_none")] pub total_count: Option, #[serde(skip_serializing_if = "Option::is_none")] pub has_more: Option, #[serde(skip_serializing_if = "Option::is_none")] pub next_offset: Option, } #[derive(Copy, Clone, Deserialize, PartialEq, Debug)] #[serde(rename_all = "lowercase")] pub enum SortType { Shuffle, NameAsc, NameDesc, TagCountAsc, TagCountDesc, DateTakenAsc, DateTakenDesc, } #[derive(Deserialize)] pub struct FilesRequest { pub path: String, // comma separated numbers pub tag_ids: Option, pub exclude_tag_ids: Option, pub tag_filter_mode: Option, pub recursive: Option, pub sort: Option, // EXIF-based search parameters pub camera_make: Option, pub camera_model: Option, pub lens_model: Option, // GPS location search pub gps_lat: Option, pub gps_lon: Option, pub gps_radius_km: Option, // Date range filtering: inclusive true instants (unix seconds), compared // against each row's true instant (ADR-0001, #152). For calendar days, // send `date_from_day` / `date_to_day` (`YYYY-MM-DD`) instead: the server // takes their bounds in the owner's display zone, and each replaces its // instant counterpart. pub date_from: Option, pub date_to: Option, pub date_from_day: Option, pub date_to_day: Option, // Media type filtering pub media_type: Option, // Pagination parameters (optional - backward compatible) pub limit: Option, pub offset: Option, /// Optional library filter. Accepts a library id (e.g. "1") or name /// (e.g. "main"). When omitted, results span all libraries. pub library: Option, /// When true, include rows soft-marked as duplicates of another file /// (i.e. `image_exif.duplicate_of_hash IS NOT NULL`). Default false — /// the standard /photos listing hides demoted siblings so the grid /// silently shrinks after a resolve. The Apollo duplicates modal /// passes `true` so it can show both survivors and demoted members /// inside a group. #[serde(default)] pub include_duplicates: Option, } #[derive(Copy, Clone, Deserialize, PartialEq, Debug)] pub enum FilterMode { Any, All, } #[derive(Copy, Clone, Deserialize, PartialEq, Debug)] #[serde(rename_all = "lowercase")] pub enum MediaType { Photo, Video, All, } #[derive(Copy, Clone, Deserialize, PartialEq, Debug)] #[serde(rename_all = "lowercase")] pub enum PhotoSize { Full, XLarge, Large, Medium, Thumb, } #[derive(Debug, Deserialize)] pub struct ThumbnailRequest { pub(crate) path: String, #[allow(dead_code)] // Part of API contract, may be used in future pub(crate) size: Option, #[serde(default)] #[allow(dead_code)] // Part of API contract, may be used in future pub(crate) format: Option, #[serde(default)] #[allow(dead_code)] // Part of API contract, may be used in future pub(crate) shape: Option, /// Optional library filter. Accepts a library id (e.g. "1") or name /// (e.g. "main"). When omitted, defaults to the primary library. #[serde(default)] pub(crate) library: Option, } #[derive(Debug, Deserialize, PartialEq)] pub enum ThumbnailFormat { #[serde(rename = "gif")] Gif, #[serde(rename = "image")] Image, } #[derive(Debug, Deserialize, PartialEq)] pub enum ThumbnailShape { #[serde(rename = "circle")] Circle, #[serde(rename = "square")] Square, } #[derive(Deserialize)] pub struct LoginRequest { pub username: String, pub password: String, } #[derive(Deserialize)] pub struct CreateAccountRequest { pub username: String, pub password: String, pub confirmation: String, } #[derive(Deserialize)] pub struct AddFavoriteRequest { pub path: String, } #[derive(Debug, Serialize)] pub struct MetadataResponse { pub created: Option, pub modified: Option, pub size: u64, pub exif: Option, pub filename_date: Option, // Date extracted from filename pub library_id: Option, pub library_name: Option, /// The capture wall clock to display, naive ISO-8601 /// (`2018-09-29T14:00:00`) — `date_view::file_wall_clock`. Render it /// verbatim; never zone-convert it (ADR-0001). pub wall_clock: Option, /// True instant of capture (unix seconds) — `date_view::file_true_instant`. /// For instant-to-instant matching (SMS / location windows); never /// render it, render `wall_clock`. pub true_instant: Option, } impl From for MetadataResponse { fn from(metadata: fs::Metadata) -> Self { MetadataResponse { created: metadata.created().ok().map(|created| { let utc: DateTime = created.into(); utc.timestamp() }), modified: metadata.modified().ok().map(|modified| { let utc: DateTime = modified.into(); utc.timestamp() }), size: metadata.len(), exif: None, filename_date: None, // Will be set in endpoint handler library_id: None, library_name: None, wall_clock: None, // Set in the endpoint handler true_instant: None, // Set in the endpoint handler } } } #[derive(Debug, Serialize)] pub struct ExifMetadata { pub camera: Option, pub image_properties: Option, pub gps: Option, pub capture_settings: Option, pub date_taken: Option, /// Which step of the canonical-date waterfall populated `date_taken`: /// `"exif" | "exiftool" | "filename" | "fs_time" | "manual"`. NULL when /// `date_taken` itself is NULL. pub date_taken_source: Option, /// True UTC instant under the new date contract (ADR-0001). NULL while /// the row is still on the legacy convention (drain pending, or the /// unclassified filename branch). pub date_utc: Option, /// Camera offset (local − UTC, minutes) that produced `date_utc`. pub camera_offset_minutes: Option, /// Why the offset is what it is: `offset_tag` | `gps` | /// `assumed_server` | `utc_direct` | `manual`. pub date_taken_tz_source: Option, /// When `date_taken_source = "manual"`, the prior `date_taken` snapshot. /// Used by the UI's revert affordance and to label "manually overridden; /// originally X" in the details modal. pub original_date_taken: Option, /// When `date_taken_source = "manual"`, the prior source. pub original_date_taken_source: Option, /// When `date_taken_source = "manual"`, the prior `date_utc` snapshot /// (the new-contract triple the revert restores alongside the legacy /// pair above). pub original_date_utc: Option, /// When `date_taken_source = "manual"`, the prior camera offset. pub original_camera_offset_minutes: Option, /// When `date_taken_source = "manual"`, the prior tz source. pub original_date_taken_tz_source: Option, } #[derive(Debug, Serialize)] pub struct CameraInfo { pub make: Option, pub model: Option, pub lens: Option, } #[derive(Debug, Serialize)] pub struct ImageProperties { pub width: Option, pub height: Option, pub orientation: Option, } #[derive(Debug, Serialize)] pub struct GpsCoordinates { pub latitude: Option, pub longitude: Option, pub altitude: Option, } #[derive(Debug, Serialize)] pub struct CaptureSettings { pub focal_length: Option, pub aperture: Option, pub shutter_speed: Option, pub iso: Option, } impl From for ExifMetadata { fn from(exif: ImageExif) -> Self { let has_camera_info = exif.camera_make.is_some() || exif.camera_model.is_some() || exif.lens_model.is_some(); let has_image_properties = exif.width.is_some() || exif.height.is_some() || exif.orientation.is_some(); let has_gps = exif.gps_latitude.is_some() || exif.gps_longitude.is_some() || exif.gps_altitude.is_some(); let has_capture_settings = exif.focal_length.is_some() || exif.aperture.is_some() || exif.shutter_speed.is_some() || exif.iso.is_some(); ExifMetadata { camera: if has_camera_info { Some(CameraInfo { make: exif.camera_make, model: exif.camera_model, lens: exif.lens_model, }) } else { None }, image_properties: if has_image_properties { Some(ImageProperties { width: exif.width, height: exif.height, orientation: exif.orientation, }) } else { None }, gps: if has_gps { Some(GpsCoordinates { latitude: exif.gps_latitude.map(|v| v as f64), longitude: exif.gps_longitude.map(|v| v as f64), altitude: exif.gps_altitude.map(|v| v as f64), }) } else { None }, capture_settings: if has_capture_settings { Some(CaptureSettings { focal_length: exif.focal_length.map(|v| v as f64), aperture: exif.aperture.map(|v| v as f64), shutter_speed: exif.shutter_speed, iso: exif.iso, }) } else { None }, date_taken: exif.date_taken, date_taken_source: exif.date_taken_source, date_utc: exif.date_utc, camera_offset_minutes: exif.camera_offset_minutes, date_taken_tz_source: exif.date_taken_tz_source, original_date_taken: exif.original_date_taken, original_date_taken_source: exif.original_date_taken_source, original_date_utc: exif.original_date_utc, original_camera_offset_minutes: exif.original_camera_offset_minutes, original_date_taken_tz_source: exif.original_date_taken_tz_source, } } } #[derive(Debug, Deserialize)] pub struct AddTagRequest { pub file_name: String, pub tag_name: String, } #[derive(Deserialize)] pub struct GetTagsRequest { pub path: Option, } #[derive(Debug, Serialize)] pub struct GpsPhotoSummary { pub path: String, pub lat: f64, pub lon: f64, /// Legacy mixed-convention date (frozen for existing rows; ADR-0001). pub date_taken: Option, /// True instant (epoch seconds), when the row has been converted. pub date_utc: Option, /// Camera offset in minutes from UTC at capture, when known. pub camera_offset_minutes: Option, /// Which evidence pinned the instant (`offset_tag`, `gps`, /// `assumed_server`, `utc_direct`, `manual`). pub date_taken_tz_source: Option, /// Capture wall clock to display (naive ISO-8601; see /// `MetadataResponse::wall_clock`). pub wall_clock: Option, } #[derive(Debug, Serialize)] pub struct GpsPhotosResponse { pub photos: Vec, pub total: usize, } /// Single-row projection of `image_exif` rich enough to drive Apollo's /// photo-to-track matcher (and any similar window-scoped consumer) without /// a per-file `/image/metadata` round-trip. Returned by `/photos/exif`. #[derive(Debug, Serialize)] pub struct ExifSummary { pub file_path: String, pub library_id: i32, pub library_name: Option, pub camera_model: Option, pub width: Option, pub height: Option, pub gps_latitude: Option, pub gps_longitude: Option, pub date_taken: Option, /// True UTC instant under the new date contract (ADR-0001). NULL while /// the row is still on the legacy convention. pub date_utc: Option, /// Camera offset (local − UTC, minutes) that produced `date_utc`. pub camera_offset_minutes: Option, /// How the offset was determined. See `camera_tz::TzSource`. pub date_taken_tz_source: Option, /// True instant of capture (unix seconds) — `date_view::row_true_instant`: /// `date_utc` when converted, else the legacy value under its per-source /// convention, so unconverted and permanent-floor rows still match /// instant-to-instant. For matching (location track, SMS windows); never /// render it, render `wall_clock`. pub true_instant: Option, /// Capture wall clock to display (naive ISO-8601; see /// `MetadataResponse::wall_clock`). pub wall_clock: Option, } impl ExifSummary { /// Project an `image_exif` row onto the `/photos/exif` wire shape. /// `true_instant` / `wall_clock` go through the same `date_view` reads /// as `/image/metadata` and `/photos/gps-summary`, so all three agree. pub fn from_row( r: ImageExif, library_name: Option, display_zone: chrono_tz::Tz, server_zone: Option, ) -> Self { ExifSummary { true_instant: crate::date_view::row_true_instant(&r, display_zone, server_zone), wall_clock: crate::date_view::row_wall_clock(&r, display_zone, server_zone), library_name, 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, date_utc: r.date_utc, camera_offset_minutes: r.camera_offset_minutes, date_taken_tz_source: r.date_taken_tz_source, } } } #[derive(Debug, Serialize)] pub struct ExifBatchResponse { pub photos: Vec, pub total: usize, } #[derive(Debug, Deserialize)] pub struct ExifBatchRequest { /// Lower bound (inclusive) on the row's true instant (`true_instant`), /// unix seconds. Same semantics as `date_from` on `/photos`. pub date_from: Option, /// Upper bound (inclusive). Same semantics as `date_to` on `/photos`. pub date_to: Option, /// Calendar-day bounds (`YYYY-MM-DD`) in the owner's display zone; each /// replaces its instant counterpart. Same as on `/photos`. pub date_from_day: Option, pub date_to_day: Option, /// Restrict results to a single library by id. Omit (or "" / "all") for /// union mode — the default. Filtered post-query in the handler so the /// existing `query_by_exif` DAO trait stays untouched. pub library: Option, } #[derive(Deserialize)] pub struct PreviewClipRequest { pub path: String, } #[derive(Deserialize)] pub struct PreviewStatusRequest { pub paths: Vec, } #[derive(Serialize)] pub struct PreviewStatusResponse { pub previews: Vec, } #[derive(Serialize)] pub struct PreviewStatusItem { pub path: String, pub status: String, #[serde(skip_serializing_if = "Option::is_none")] pub preview_url: Option, } #[cfg(test)] mod tests { use super::Claims; use jsonwebtoken::errors::ErrorKind; use std::str::FromStr; #[test] fn test_token_from_claims() { let claims = Claims { exp: 16136164790, // 2481-ish sub: String::from("9"), }; let c = Claims::from_str( "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5IiwiZXhwIjoxNjEzNjE2NDc5MH0.9wwK4l8vhvq55YoueEljMbN_5uVTaAsGLLRPr0AuymE") .unwrap(); assert_eq!(claims.sub, c.sub); assert_eq!(claims.exp, c.exp); } #[test] fn test_expired_token() { let err = Claims::from_str( "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5IiwiZXhwIjoxNn0.eZnfaNfiD54VMbphIqeBICeG9SzAtwNXntLwtTBihjY", ); match err.unwrap_err().into_kind() { ErrorKind::ExpiredSignature => {} kind => panic!("Unexpected error: {:?}", kind), } } #[test] fn test_junk_token_is_invalid() { let err = Claims::from_str("uni-֍ՓՓՓՓՓՓՓՓՓՓՓՓՓՓՓ"); match err.unwrap_err().into_kind() { ErrorKind::InvalidToken => {} kind => panic!("Unexpected error: {:?}", kind), } } #[test] fn test_preview_clip_request_deserialize() { use super::PreviewClipRequest; let json = r#"{"path":"photos/2024/video.mp4"}"#; let req: PreviewClipRequest = serde_json::from_str(json).unwrap(); assert_eq!(req.path, "photos/2024/video.mp4"); } #[test] fn test_preview_status_request_deserialize() { use super::PreviewStatusRequest; let json = r#"{"paths":["a/one.mp4","b/two.mp4","c/three.mp4"]}"#; let req: PreviewStatusRequest = serde_json::from_str(json).unwrap(); assert_eq!(req.paths.len(), 3); assert_eq!(req.paths[0], "a/one.mp4"); assert_eq!(req.paths[2], "c/three.mp4"); } #[test] fn test_preview_status_response_serialize() { use super::{PreviewStatusItem, PreviewStatusResponse}; let response = PreviewStatusResponse { previews: vec![ PreviewStatusItem { path: "a/one.mp4".to_string(), status: "complete".to_string(), preview_url: Some("/video/preview?path=a%2Fone.mp4".to_string()), }, PreviewStatusItem { path: "b/two.mp4".to_string(), status: "pending".to_string(), preview_url: None, }, ], }; let json = serde_json::to_value(&response).unwrap(); let previews = json["previews"].as_array().unwrap(); assert_eq!(previews.len(), 2); // Complete item should have preview_url assert_eq!(previews[0]["status"], "complete"); assert!(previews[0]["preview_url"].is_string()); // Pending item should not have preview_url (skip_serializing_if) assert_eq!(previews[1]["status"], "pending"); assert!(previews[1].get("preview_url").is_none()); } } #[cfg(test)] mod date_contract_tests { use super::*; fn sample_row() -> crate::database::models::ImageExif { crate::database::models::ImageExif { id: 1, library_id: 1, file_path: "2024/vacation/IMG_0001.jpg".to_string(), camera_make: None, camera_model: Some("NIKON Z 8".to_string()), lens_model: None, width: None, height: None, orientation: None, gps_latitude: None, gps_longitude: None, gps_altitude: None, focal_length: None, aperture: None, shutter_speed: None, iso: None, // Wall 2024-07-04 12:00:00 stored legacy-style (as-UTC). date_taken: Some(1_720_094_400), created_time: 0, last_modified: 0, content_hash: None, size_bytes: None, phash_64: None, dhash_64: None, duplicate_of_hash: None, duplicate_decided_at: None, date_taken_source: Some("exif".to_string()), original_date_taken: None, original_date_taken_source: None, clip_embedding: None, clip_model_version: None, date_utc: Some(1_720_108_800), camera_offset_minutes: Some(-240), date_taken_tz_source: Some("offset_tag".to_string()), original_date_utc: None, original_camera_offset_minutes: None, original_date_taken_tz_source: None, } } /// /image/metadata contract: the new-contract date fields ride along on /// the EXIF metadata payload alongside the legacy `date_taken`. #[test] fn exif_metadata_response_includes_date_contract_fields() { let meta = ExifMetadata::from(sample_row()); assert_eq!(meta.date_taken, Some(1_720_094_400)); assert_eq!(meta.date_utc, Some(1_720_108_800)); assert_eq!(meta.camera_offset_minutes, Some(-240)); assert_eq!(meta.date_taken_tz_source.as_deref(), Some("offset_tag")); } /// /photos/exif contract: Apollo's per-photo summary carries the same /// fields so the viewer flip (spec #113) can read one shape, plus the /// server-computed `true_instant` / `wall_clock` it matches and renders. #[test] fn exif_summary_shape_includes_date_contract_fields() { let summary = ExifSummary::from_row( sample_row(), Some("main".to_string()), chrono_tz::America::New_York, Some(chrono_tz::America::New_York), ); // Verify the JSON wire names clients (Apollo, FVR) will consume. let json = serde_json::to_value(&summary).unwrap(); assert_eq!(json["date_taken"], 1_720_094_400); assert_eq!(json["date_utc"], 1_720_108_800); assert_eq!(json["camera_offset_minutes"], -240); assert_eq!(json["date_taken_tz_source"], "offset_tag"); assert_eq!(json["true_instant"], 1_720_108_800); assert_eq!(json["wall_clock"], "2024-07-04T12:00:00"); assert_eq!(json["library_name"], "main"); } /// A row the drain hasn't converted (or never will — the edited/exported /// floor) has no `date_utc`, but `/photos/exif` must still hand clients /// a true instant and wall clock via the per-source fallback, so they /// never re-derive the legacy convention themselves (ADR-0001). #[test] fn exif_summary_unconverted_row_falls_back_to_legacy_convention() { let mut row = sample_row(); row.date_utc = None; row.camera_offset_minutes = None; row.date_taken_tz_source = None; let summary = ExifSummary::from_row( row, None, chrono_tz::America::New_York, Some(chrono_tz::America::New_York), ); assert_eq!(summary.date_utc, None); // exif legacy = 12:00 wall as-UTC; read in the server zone (EDT) // that's 16:00 UTC — the same instant the converted row names. assert_eq!(summary.true_instant, Some(1_720_108_800)); assert_eq!(summary.wall_clock.as_deref(), Some("2024-07-04T12:00:00")); } }