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