Rust (axum + sqlx) API and worker sharing a Postgres-backed job queue (SKIP LOCKED, heartbeat, reaper, typed statuses), S3 storage with derived keys and a fully private bucket, OIDC photographer login with per-request allowlist checks, client share links with argon2 passwords and lockout, cookie-based image authorization with sliding expiry, hand-rolled spec-compliant streaming ZIP downloads with exact Content-Length, React + Vite gallery frontend, single Docker image, Helm chart for external S3 + Postgres, and Gitea CI. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,398 @@
|
||||
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};
|
||||
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>,
|
||||
}
|
||||
|
||||
#[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
|
||||
from photos p
|
||||
left join ratings r on r.photo_id = p.id and r.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 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 })))
|
||||
}
|
||||
Reference in New Issue
Block a user