ci / docker (push) Successful in 13s
- 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
437 lines
13 KiB
Rust
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 })))
|
|
}
|