Implements foundation for EXIF-based photo search capabilities: - Add geo.rs module with GPS distance calculations (Haversine + bounding box) - Extend FilesRequest with EXIF search parameters (camera, GPS, date, media type) - Add MediaType enum and DateTakenAsc/DateTakenDesc sort options - Create date_taken index migration for efficient date queries - Implement ExifDao methods: get_exif_batch, query_by_exif, get_camera_makes - Add FileWithMetadata struct for date-aware sorting - Implement date sorting with filename extraction fallback - Make extract_date_from_filename public for reuse Next: Integrate EXIF filtering into list_photos() and enhance get_all_tags()
379 lines
9.6 KiB
Rust
379 lines
9.6 KiB
Rust
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<Self, Self::Err> {
|
|
let token = *(s.split("Bearer ").collect::<Vec<_>>().last().unwrap_or(&""));
|
|
|
|
match decode::<Claims>(
|
|
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<Result<Self, Self::Error>>;
|
|
|
|
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(|| format!("Unable to decode token from: {}", header))
|
|
})
|
|
.map_or_else(
|
|
|e| {
|
|
error!("{}", e);
|
|
err(ErrorUnauthorized("Bad token"))
|
|
},
|
|
ok,
|
|
)
|
|
}
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Debug)]
|
|
pub struct PhotosResponse {
|
|
pub photos: Vec<String>,
|
|
pub dirs: Vec<String>,
|
|
}
|
|
|
|
#[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<String>,
|
|
pub exclude_tag_ids: Option<String>,
|
|
pub tag_filter_mode: Option<FilterMode>,
|
|
pub recursive: Option<bool>,
|
|
pub sort: Option<SortType>,
|
|
|
|
// EXIF-based search parameters
|
|
pub camera_make: Option<String>,
|
|
pub camera_model: Option<String>,
|
|
pub lens_model: Option<String>,
|
|
|
|
// GPS location search
|
|
pub gps_lat: Option<f64>,
|
|
pub gps_lon: Option<f64>,
|
|
pub gps_radius_km: Option<f64>,
|
|
|
|
// Date range filtering (Unix timestamps)
|
|
pub date_from: Option<i64>,
|
|
pub date_to: Option<i64>,
|
|
|
|
// Media type filtering
|
|
pub media_type: Option<MediaType>,
|
|
}
|
|
|
|
#[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,
|
|
Thumb,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct ThumbnailRequest {
|
|
pub(crate) path: String,
|
|
pub(crate) size: Option<PhotoSize>,
|
|
#[serde(default)]
|
|
pub(crate) format: Option<ThumbnailFormat>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, PartialEq)]
|
|
pub enum ThumbnailFormat {
|
|
#[serde(rename = "gif")]
|
|
Gif,
|
|
#[serde(rename = "image")]
|
|
Image,
|
|
}
|
|
|
|
#[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<i64>,
|
|
pub modified: Option<i64>,
|
|
pub size: u64,
|
|
pub exif: Option<ExifMetadata>,
|
|
}
|
|
|
|
impl From<fs::Metadata> for MetadataResponse {
|
|
fn from(metadata: fs::Metadata) -> Self {
|
|
MetadataResponse {
|
|
created: metadata.created().ok().map(|created| {
|
|
let utc: DateTime<Utc> = created.into();
|
|
utc.timestamp()
|
|
}),
|
|
modified: metadata.modified().ok().map(|modified| {
|
|
let utc: DateTime<Utc> = modified.into();
|
|
utc.timestamp()
|
|
}),
|
|
size: metadata.len(),
|
|
exif: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct ExifMetadata {
|
|
pub camera: Option<CameraInfo>,
|
|
pub image_properties: Option<ImageProperties>,
|
|
pub gps: Option<GpsCoordinates>,
|
|
pub capture_settings: Option<CaptureSettings>,
|
|
pub date_taken: Option<i64>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct CameraInfo {
|
|
pub make: Option<String>,
|
|
pub model: Option<String>,
|
|
pub lens: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct ImageProperties {
|
|
pub width: Option<i32>,
|
|
pub height: Option<i32>,
|
|
pub orientation: Option<i32>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct GpsCoordinates {
|
|
pub latitude: Option<f64>,
|
|
pub longitude: Option<f64>,
|
|
pub altitude: Option<f64>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct CaptureSettings {
|
|
pub focal_length: Option<f64>,
|
|
pub aperture: Option<f64>,
|
|
pub shutter_speed: Option<String>,
|
|
pub iso: Option<i32>,
|
|
}
|
|
|
|
impl From<ImageExif> 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,
|
|
longitude: exif.gps_longitude,
|
|
altitude: exif.gps_altitude,
|
|
})
|
|
} else {
|
|
None
|
|
},
|
|
capture_settings: if has_capture_settings {
|
|
Some(CaptureSettings {
|
|
focal_length: exif.focal_length,
|
|
aperture: exif.aperture,
|
|
shutter_speed: exif.shutter_speed,
|
|
iso: exif.iso,
|
|
})
|
|
} else {
|
|
None
|
|
},
|
|
date_taken: exif.date_taken,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct AddTagRequest {
|
|
pub file_name: String,
|
|
pub tag_name: String,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct GetTagsRequest {
|
|
pub path: Option<String>,
|
|
}
|
|
|
|
#[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(
|
|
"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(
|
|
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5IiwiZXhwIjoxNn0.eZnfaNfiD54VMbphIqeBICeG9SzAtwNXntLwtTBihjY",
|
|
);
|
|
|
|
match err.unwrap_err().into_kind() {
|
|
ErrorKind::ExpiredSignature => assert!(true),
|
|
kind => {
|
|
println!("Unexpected error: {:?}", kind);
|
|
assert!(false)
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_junk_token_is_invalid() {
|
|
let err = Claims::from_str("uni-֍ՓՓՓՓՓՓՓՓՓՓՓՓՓՓՓ");
|
|
|
|
match err.unwrap_err().into_kind() {
|
|
ErrorKind::InvalidToken => assert!(true),
|
|
kind => {
|
|
println!("Unexpected error: {:?}", kind);
|
|
assert!(false)
|
|
}
|
|
}
|
|
}
|
|
}
|