Files
photos/src/routes/client.rs
T
nils 30d0b3064e
ci / docker (push) Successful in 13s
Client accept/reject votes with keyboard-driven culling
- verdicts table (per link, like ratings), PUT verdict endpoint, typed Verdict enum
- share page: thumbs up/down, verdict filter with counts, view-scoped selection bar
- lightbox: per-page shortcut table (P/X/U, 1-5/0, S), ? help overlay, action
  toast when a keyboard vote auto-advances
- shared useLightbox hook; single keydown subscription reading live state via ref
- album view: per-link thumbs and vote counts; share list shows accept/reject totals
- feedback queries deduped and run concurrently; verdict counts in one scan
2026-07-17 15:55:46 +02:00

437 lines
13 KiB
Rust

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<Share, ApiError> {
let share: Option<Share> = 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<Uuid> {
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::<Vec<_>>()
.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<Share> =
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<Share, ApiError> {
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<i32>,
height: Option<i32>,
taken_at: Option<DateTime<Utc>>,
processed_at: Option<DateTime<Utc>>,
my_rating: Option<i32>,
my_verdict: Option<String>,
}
#[derive(Serialize)]
struct ClientPhoto {
#[serde(flatten)]
row: ClientPhotoRow,
my_tags: Vec<String>,
}
#[derive(Serialize)]
pub struct ShareView {
label: String,
album_name: String,
album_description: String,
locked: bool,
allow_download: bool,
photos: Vec<ClientPhoto>,
}
pub async fn get_share(
State(state): State<AppState>,
Path(token): Path<String>,
jar: SignedCookieJar,
) -> ApiResult<(SignedCookieJar, Json<ShareView>)> {
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<ClientPhotoRow> = 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<Uuid, Vec<String>> = 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<AppState>,
Path(token): Path<String>,
jar: SignedCookieJar,
Json(body): Json<UnlockBody>,
) -> 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<Share, ApiError> {
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<AppState>,
Path((token, photo_id)): Path<(String, Uuid)>,
jar: SignedCookieJar,
Json(body): Json<RatingBody>,
) -> ApiResult<Json<serde_json::Value>> {
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<Verdict>,
}
pub async fn set_verdict(
State(state): State<AppState>,
Path((token, photo_id)): Path<(String, Uuid)>,
jar: SignedCookieJar,
Json(body): Json<VerdictBody>,
) -> ApiResult<Json<serde_json::Value>> {
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<String>,
}
pub async fn set_tags(
State(state): State<AppState>,
Path((token, photo_id)): Path<(String, Uuid)>,
jar: SignedCookieJar,
Json(body): Json<TagsBody>,
) -> ApiResult<Json<serde_json::Value>> {
let share = share_photo(&state, &jar, &token, photo_id).await?;
let mut tags: Vec<String> = 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 })))
}