Files
ImageApi/src/data/mod.rs
2026-01-26 11:58:24 -05:00

413 lines
11 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.strip_prefix("Bearer ").ok_or_else(|| {
jsonwebtoken::errors::Error::from(jsonwebtoken::errors::ErrorKind::InvalidToken)
})?;
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>,
// Pagination metadata (only present when limit is set)
#[serde(skip_serializing_if = "Option::is_none")]
pub total_count: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub has_more: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub next_offset: Option<i64>,
}
#[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>,
// Pagination parameters (optional - backward compatible)
pub limit: Option<i64>,
pub offset: Option<i64>,
}
#[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,
#[allow(dead_code)] // Part of API contract, may be used in future
pub(crate) size: Option<PhotoSize>,
#[serde(default)]
#[allow(dead_code)] // Part of API contract, may be used in future
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>,
pub filename_date: Option<i64>, // Date extracted from filename
}
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,
filename_date: None, // Will be set in endpoint handler
}
}
}
#[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.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,
}
}
}
#[derive(Debug, Deserialize)]
pub struct AddTagRequest {
pub file_name: String,
pub tag_name: String,
}
#[derive(Deserialize)]
pub struct GetTagsRequest {
pub path: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct GpsPhotoSummary {
pub path: String,
pub lat: f64,
pub lon: f64,
pub date_taken: Option<i64>,
}
#[derive(Debug, Serialize)]
pub struct GpsPhotosResponse {
pub photos: Vec<GpsPhotoSummary>,
pub total: usize,
}
#[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 => 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)
}
}
}
}