Files
photos/src/routes/albums.rs
T
nilsandClaude 16d2a56a78
ci / docker (push) Successful in 13s
Initial release: self-hosted client photo gallery
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>
2026-07-17 13:12:42 +02:00

217 lines
6.0 KiB
Rust

use std::collections::HashMap;
use axum::extract::{Path, State};
use axum::Json;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::error::{ApiError, ApiResult};
use crate::models::{Album, JobKind, Photo, PhotoStatus};
use crate::state::AppState;
#[derive(Serialize, sqlx::FromRow)]
pub struct AlbumListItem {
pub id: Uuid,
pub name: String,
pub description: String,
pub created_at: DateTime<Utc>,
pub photo_count: i64,
pub cover_photo_id: Option<Uuid>,
pub cover_processed_at: Option<DateTime<Utc>>,
}
pub async fn list(
State(state): State<AppState>,
) -> ApiResult<Json<Vec<AlbumListItem>>> {
let albums: Vec<AlbumListItem> = sqlx::query_as(
"select a.id, a.name, a.description, a.created_at,
(select count(*) from photos p where p.album_id = a.id) as photo_count,
c.id as cover_photo_id, c.processed_at as cover_processed_at
from albums a
left join lateral (
select p.id, p.processed_at from photos p
where p.album_id = a.id and p.status = $1
order by coalesce(p.taken_at, p.created_at), p.filename
limit 1
) c on true
order by a.created_at desc",
)
.bind(PhotoStatus::Ready.as_str())
.fetch_all(&state.db)
.await?;
Ok(Json(albums))
}
#[derive(Deserialize)]
pub struct CreateAlbum {
name: String,
#[serde(default)]
description: String,
}
pub async fn create(
State(state): State<AppState>,
Json(body): Json<CreateAlbum>,
) -> ApiResult<Json<Album>> {
let name = body.name.trim();
if name.is_empty() {
return Err(ApiError::bad_request("album name is required"));
}
let album: Album =
sqlx::query_as("insert into albums (name, description) values ($1, $2) returning *")
.bind(name)
.bind(body.description.trim())
.fetch_one(&state.db)
.await?;
Ok(Json(album))
}
#[derive(Serialize)]
pub struct ShareRating {
pub share_label: String,
pub rating: i32,
}
#[derive(Serialize)]
pub struct ShareTag {
pub share_label: String,
pub tag: String,
}
#[derive(Serialize, Default)]
pub struct PhotoFeedback {
pub ratings: Vec<ShareRating>,
pub tags: Vec<ShareTag>,
}
#[derive(Serialize)]
pub struct AlbumDetail {
pub album: Album,
pub photos: Vec<Photo>,
pub feedback: HashMap<Uuid, PhotoFeedback>,
}
pub async fn get_one(
State(state): State<AppState>,
Path(album_id): Path<Uuid>,
) -> ApiResult<Json<AlbumDetail>> {
let album: Album = sqlx::query_as("select * from albums where id = $1")
.bind(album_id)
.fetch_one(&state.db)
.await?;
let photos: Vec<Photo> = sqlx::query_as(
"select * from photos where album_id = $1
order by coalesce(taken_at, created_at), filename",
)
.bind(album_id)
.fetch_all(&state.db)
.await?;
let mut feedback: HashMap<Uuid, PhotoFeedback> = HashMap::new();
let ratings: Vec<(Uuid, String, i32)> = sqlx::query_as(
"select r.photo_id, s.label, r.rating
from ratings r join shares s on s.id = r.share_id
where s.album_id = $1",
)
.bind(album_id)
.fetch_all(&state.db)
.await?;
for (photo_id, share_label, rating) in ratings {
feedback
.entry(photo_id)
.or_default()
.ratings
.push(ShareRating {
share_label,
rating,
});
}
let tags: Vec<(Uuid, String, String)> = sqlx::query_as(
"select t.photo_id, s.label, t.tag
from tags t join shares s on s.id = t.share_id
where s.album_id = $1
order by t.created_at",
)
.bind(album_id)
.fetch_all(&state.db)
.await?;
for (photo_id, share_label, tag) in tags {
feedback
.entry(photo_id)
.or_default()
.tags
.push(ShareTag { share_label, tag });
}
Ok(Json(AlbumDetail {
album,
photos,
feedback,
}))
}
#[derive(Deserialize)]
pub struct UpdateAlbum {
name: Option<String>,
description: Option<String>,
}
pub async fn update(
State(state): State<AppState>,
Path(album_id): Path<Uuid>,
Json(body): Json<UpdateAlbum>,
) -> ApiResult<Json<Album>> {
if let Some(name) = &body.name {
if name.trim().is_empty() {
return Err(ApiError::bad_request("album name cannot be empty"));
}
}
let album: Album = sqlx::query_as(
"update albums
set name = coalesce($2, name), description = coalesce($3, description)
where id = $1
returning *",
)
.bind(album_id)
.bind(body.name.as_deref().map(str::trim))
.bind(body.description.as_deref().map(str::trim))
.fetch_one(&state.db)
.await?;
Ok(Json(album))
}
pub async fn delete(
State(state): State<AppState>,
Path(album_id): Path<Uuid>,
) -> ApiResult<Json<serde_json::Value>> {
let mut tx = state.db.begin().await?;
// Lock the album row: concurrent uploads block on their FK check against
// it, then fail once it's gone and clean up their own S3 objects — so no
// photo can slip in between the cleanup enqueue and the cascade delete.
let locked: Option<(Uuid,)> = sqlx::query_as("select id from albums where id = $1 for update")
.bind(album_id)
.fetch_optional(&mut *tx)
.await?;
if locked.is_none() {
return Err(ApiError::not_found());
}
// Delete photos and enqueue their S3 cleanup atomically, in one statement.
sqlx::query(
"with deleted as (delete from photos where album_id = $1 returning id)
insert into jobs (kind, payload)
select $2, jsonb_build_object('prefix', 'photos/' || id || '/')
from deleted",
)
.bind(album_id)
.bind(JobKind::DeleteS3Prefix.as_str())
.execute(&mut *tx)
.await?;
sqlx::query("delete from albums where id = $1")
.bind(album_id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(Json(serde_json::json!({ "ok": true })))
}