use std::collections::HashMap; use argon2::{Argon2, PasswordHash, PasswordVerifier}; use axum::extract::{Path, State}; use axum::http::StatusCode; use axum::Json; use axum_extra::extract::cookie::SignedCookieJar; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::error::{ApiError, ApiResult}; use crate::models::{PhotoStatus, Share, Verdict}; use crate::state::AppState; pub async fn load_share(state: &AppState, token: &str) -> Result { let share: Option = sqlx::query_as("select * from shares where token = $1") .bind(token) .fetch_optional(&state.db) .await?; let share = share.ok_or_else(ApiError::not_found)?; if share.expires_at.map(|e| e < Utc::now()).unwrap_or(false) { return Err(ApiError::gone("this link has expired")); } Ok(share) } /// One signed cookie lists every share id this browser has been granted /// (by viewing a passwordless share or unlocking a protected one). Image and /// download requests are authorized from it, so URLs carry no token. const SHARE_ACCESS_COOKIE: &str = "photos_shares"; const MAX_REMEMBERED_SHARES: usize = 20; pub fn share_ids_from_jar(jar: &SignedCookieJar) -> Vec { jar.get(SHARE_ACCESS_COOKIE) .map(|c| { c.value() .split(',') .filter_map(|s| Uuid::parse_str(s).ok()) .collect() }) .unwrap_or_default() } /// Add a share to the browser's access cookie (most recent first). Always /// re-issues the cookie so the 30-day expiry slides on every visit instead of /// being fixed at the first one. fn grant_access(state: &AppState, jar: SignedCookieJar, share_id: Uuid) -> SignedCookieJar { let mut ids = share_ids_from_jar(&jar); ids.retain(|id| *id != share_id); ids.insert(0, share_id); ids.truncate(MAX_REMEMBERED_SHARES); let value = ids .iter() .map(Uuid::to_string) .collect::>() .join(","); let mut cookie = crate::auth::base_cookie(SHARE_ACCESS_COOKIE, value, state.config.cookie_secure()); cookie.set_max_age(time::Duration::days(30)); jar.add(cookie) } pub fn is_unlocked(jar: &SignedCookieJar, share: &Share) -> bool { share.password_hash.is_none() || share_ids_from_jar(jar).contains(&share.id) } /// Cookie-based authorization for image/download requests, which carry no /// share token: any remembered, still-valid share covering the album grants /// access (and must allow downloads when `need_download`). pub async fn authorize_album_via_cookie( state: &AppState, jar: &SignedCookieJar, album_id: Uuid, need_download: bool, ) -> Result<(), ApiError> { let ids = share_ids_from_jar(jar); if ids.is_empty() { return Err(ApiError::unauthorized()); } let shares: Vec = sqlx::query_as("select * from shares where album_id = $1 and id = any($2)") .bind(album_id) .bind(&ids) .fetch_all(&state.db) .await?; let now = Utc::now(); let valid: Vec<&Share> = shares .iter() .filter(|s| s.expires_at.map(|e| e > now).unwrap_or(true)) .collect(); if valid.is_empty() { return Err(ApiError::unauthorized()); } if need_download && !valid.iter().any(|s| s.allow_download) { return Err(ApiError::forbidden("downloads are disabled for this link")); } Ok(()) } /// The one place client-share access policy lives: token valid + not expired, /// unlocked, and (for download endpoints) downloads enabled. pub async fn authorize_share( state: &AppState, jar: &SignedCookieJar, token: &str, need_download: bool, ) -> Result { let share = load_share(state, token).await?; if !is_unlocked(jar, &share) { return Err(ApiError::unauthorized()); } if need_download && !share.allow_download { return Err(ApiError::forbidden("downloads are disabled for this link")); } Ok(share) } fn require_unlocked(jar: &SignedCookieJar, share: &Share) -> Result<(), ApiError> { if is_unlocked(jar, share) { Ok(()) } else { Err(ApiError( StatusCode::UNAUTHORIZED, "password required".into(), )) } } #[derive(Serialize, sqlx::FromRow)] struct ClientPhotoRow { id: Uuid, filename: String, size_bytes: i64, width: Option, height: Option, taken_at: Option>, processed_at: Option>, my_rating: Option, my_verdict: Option, } #[derive(Serialize)] struct ClientPhoto { #[serde(flatten)] row: ClientPhotoRow, my_tags: Vec, } #[derive(Serialize)] pub struct ShareView { label: String, album_name: String, album_description: String, locked: bool, allow_download: bool, photos: Vec, } pub async fn get_share( State(state): State, Path(token): Path, jar: SignedCookieJar, ) -> ApiResult<(SignedCookieJar, Json)> { let share = load_share(&state, &token).await?; let (album_name, album_description): (String, String) = sqlx::query_as("select name, description from albums where id = $1") .bind(share.album_id) .fetch_one(&state.db) .await?; if !is_unlocked(&jar, &share) { return Ok(( jar, Json(ShareView { label: share.label, album_name, album_description, locked: true, allow_download: share.allow_download, photos: vec![], }), )); } // Grant this browser image/download access for the share's album. let jar = grant_access(&state, jar, share.id); let rows: Vec = sqlx::query_as( "select p.id, p.filename, p.size_bytes, p.width, p.height, p.taken_at, p.processed_at, r.rating as my_rating, v.verdict as my_verdict from photos p left join ratings r on r.photo_id = p.id and r.share_id = $2 left join verdicts v on v.photo_id = p.id and v.share_id = $2 where p.album_id = $1 and p.status = $3 order by coalesce(p.taken_at, p.created_at), p.filename", ) .bind(share.album_id) .bind(share.id) .bind(PhotoStatus::Ready.as_str()) .fetch_all(&state.db) .await?; let tag_rows: Vec<(Uuid, String)> = sqlx::query_as("select photo_id, tag from tags where share_id = $1 order by created_at") .bind(share.id) .fetch_all(&state.db) .await?; let mut tag_map: HashMap> = HashMap::new(); for (photo_id, tag) in tag_rows { tag_map.entry(photo_id).or_default().push(tag); } let photos = rows .into_iter() .map(|row| { let my_tags = tag_map.remove(&row.id).unwrap_or_default(); ClientPhoto { row, my_tags } }) .collect(); Ok(( jar, Json(ShareView { label: share.label, album_name, album_description, locked: false, allow_download: share.allow_download, photos, }), )) } #[derive(Deserialize)] pub struct UnlockBody { password: String, } const MAX_UNLOCK_ATTEMPTS: i32 = 10; pub async fn unlock( State(state): State, Path(token): Path, jar: SignedCookieJar, Json(body): Json, ) -> ApiResult<(SignedCookieJar, StatusCode)> { let share = load_share(&state, &token).await?; let Some(hash) = share.password_hash.clone() else { return Ok((jar, StatusCode::NO_CONTENT)); }; if let Some(locked_until) = share.locked_until { if locked_until > Utc::now() { return Err(ApiError( StatusCode::TOO_MANY_REQUESTS, "too many attempts — try again in a few minutes".into(), )); } // The lock window has passed: grant a fresh set of attempts, so a // legitimate client isn't re-locked by their next single typo. sqlx::query("update shares set failed_attempts = 0, locked_until = null where id = $1") .bind(share.id) .execute(&state.db) .await?; } // Argon2 is deliberately slow; keep it off the async runtime threads. let password = body.password; let verified = tokio::task::spawn_blocking(move || { let parsed = PasswordHash::new(&hash).map_err(|e| anyhow::anyhow!("bad hash: {e}"))?; Ok::<_, anyhow::Error>( Argon2::default() .verify_password(password.as_bytes(), &parsed) .is_ok(), ) }) .await .map_err(|e| anyhow::anyhow!("verify task failed: {e}"))??; if !verified { sqlx::query( "update shares set failed_attempts = failed_attempts + 1, locked_until = case when failed_attempts + 1 >= $2 then now() + interval '15 minutes' else locked_until end where id = $1", ) .bind(share.id) .bind(MAX_UNLOCK_ATTEMPTS) .execute(&state.db) .await?; return Err(ApiError(StatusCode::UNAUTHORIZED, "wrong password".into())); } sqlx::query("update shares set failed_attempts = 0, locked_until = null where id = $1") .bind(share.id) .execute(&state.db) .await?; Ok((grant_access(&state, jar, share.id), StatusCode::NO_CONTENT)) } async fn share_photo( state: &AppState, jar: &SignedCookieJar, token: &str, photo_id: Uuid, ) -> Result { let share = load_share(state, token).await?; require_unlocked(jar, &share)?; let exists: Option<(Uuid,)> = sqlx::query_as( "select id from photos where id = $1 and album_id = $2 and status = $3", ) .bind(photo_id) .bind(share.album_id) .bind(PhotoStatus::Ready.as_str()) .fetch_optional(&state.db) .await?; if exists.is_none() { return Err(ApiError::not_found()); } Ok(share) } #[derive(Deserialize)] pub struct RatingBody { rating: i32, } pub async fn set_rating( State(state): State, Path((token, photo_id)): Path<(String, Uuid)>, jar: SignedCookieJar, Json(body): Json, ) -> ApiResult> { if !(0..=5).contains(&body.rating) { return Err(ApiError::bad_request("rating must be between 0 and 5")); } let share = share_photo(&state, &jar, &token, photo_id).await?; if body.rating == 0 { sqlx::query("delete from ratings where share_id = $1 and photo_id = $2") .bind(share.id) .bind(photo_id) .execute(&state.db) .await?; } else { sqlx::query( "insert into ratings (share_id, photo_id, rating) values ($1, $2, $3) on conflict (share_id, photo_id) do update set rating = excluded.rating, updated_at = now()", ) .bind(share.id) .bind(photo_id) .bind(body.rating) .execute(&state.db) .await?; } Ok(Json(serde_json::json!({ "ok": true }))) } #[derive(Deserialize)] pub struct VerdictBody { verdict: Option, } pub async fn set_verdict( State(state): State, Path((token, photo_id)): Path<(String, Uuid)>, jar: SignedCookieJar, Json(body): Json, ) -> ApiResult> { let share = share_photo(&state, &jar, &token, photo_id).await?; match body.verdict { None => { sqlx::query("delete from verdicts where share_id = $1 and photo_id = $2") .bind(share.id) .bind(photo_id) .execute(&state.db) .await?; } Some(verdict) => { sqlx::query( "insert into verdicts (share_id, photo_id, verdict) values ($1, $2, $3) on conflict (share_id, photo_id) do update set verdict = excluded.verdict, updated_at = now()", ) .bind(share.id) .bind(photo_id) .bind(verdict.as_str()) .execute(&state.db) .await?; } } Ok(Json(serde_json::json!({ "ok": true }))) } #[derive(Deserialize)] pub struct TagsBody { tags: Vec, } pub async fn set_tags( State(state): State, Path((token, photo_id)): Path<(String, Uuid)>, jar: SignedCookieJar, Json(body): Json, ) -> ApiResult> { let share = share_photo(&state, &jar, &token, photo_id).await?; let mut tags: Vec = Vec::new(); for tag in body.tags { let tag = tag.trim().to_lowercase(); if tag.is_empty() || tag.chars().count() > 40 { continue; } if !tags.contains(&tag) { tags.push(tag); } if tags.len() >= 20 { break; } } let mut tx = state.db.begin().await?; sqlx::query("delete from tags where share_id = $1 and photo_id = $2") .bind(share.id) .bind(photo_id) .execute(&mut *tx) .await?; if !tags.is_empty() { sqlx::query("insert into tags (share_id, photo_id, tag) select $1, $2, unnest($3::text[])") .bind(share.id) .bind(photo_id) .bind(&tags) .execute(&mut *tx) .await?; } tx.commit().await?; Ok(Json(serde_json::json!({ "ok": true, "tags": tags }))) }