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>
106 lines
3.3 KiB
Rust
106 lines
3.3 KiB
Rust
use axum::body::Body;
|
|
use axum::extract::{Path, State};
|
|
use axum::http::{header, StatusCode};
|
|
use axum::response::Response;
|
|
use axum_extra::extract::cookie::SignedCookieJar;
|
|
use tokio_util::io::ReaderStream;
|
|
use uuid::Uuid;
|
|
|
|
use crate::auth::user_from_jar;
|
|
use crate::error::{ApiError, ApiResult};
|
|
use crate::models::{Photo, PhotoStatus};
|
|
use crate::routes::client::authorize_album_via_cookie;
|
|
use crate::s3;
|
|
use crate::state::AppState;
|
|
|
|
/// Allow access if the requester is the signed-in photographer, or holds a
|
|
/// share-access cookie (set when viewing/unlocking a share) covering the
|
|
/// photo's album.
|
|
async fn authorize_photo(
|
|
state: &AppState,
|
|
jar: &SignedCookieJar,
|
|
photo_id: Uuid,
|
|
need_download: bool,
|
|
) -> Result<Photo, ApiError> {
|
|
let photo: Option<Photo> = sqlx::query_as("select * from photos where id = $1")
|
|
.bind(photo_id)
|
|
.fetch_optional(&state.db)
|
|
.await?;
|
|
let photo = photo.ok_or_else(ApiError::not_found)?;
|
|
|
|
if user_from_jar(state, jar).is_some() {
|
|
return Ok(photo);
|
|
}
|
|
authorize_album_via_cookie(state, jar, photo.album_id, need_download).await?;
|
|
// Clients may only reach photos the share listing exposes.
|
|
if photo.status != PhotoStatus::Ready {
|
|
return Err(ApiError::not_found());
|
|
}
|
|
Ok(photo)
|
|
}
|
|
|
|
async fn stream_object(
|
|
state: &AppState,
|
|
key: &str,
|
|
content_type: &str,
|
|
attachment_name: Option<&str>,
|
|
) -> Result<Response, ApiError> {
|
|
let object = state
|
|
.s3
|
|
.get_object()
|
|
.bucket(&state.config.s3_bucket)
|
|
.key(key)
|
|
.send()
|
|
.await
|
|
.map_err(|e| {
|
|
tracing::warn!("s3 get {key} failed: {e}");
|
|
ApiError::not_found()
|
|
})?;
|
|
|
|
let mut builder = Response::builder()
|
|
.status(StatusCode::OK)
|
|
.header(header::CONTENT_TYPE, content_type)
|
|
.header(
|
|
header::CACHE_CONTROL,
|
|
"private, max-age=31536000, immutable",
|
|
);
|
|
if let Some(length) = object.content_length() {
|
|
builder = builder.header(header::CONTENT_LENGTH, length);
|
|
}
|
|
if let Some(name) = attachment_name {
|
|
let safe = name.replace(['"', '\\'], "_");
|
|
builder = builder.header(
|
|
header::CONTENT_DISPOSITION,
|
|
format!("attachment; filename=\"{safe}\""),
|
|
);
|
|
}
|
|
let stream = ReaderStream::new(object.body.into_async_read());
|
|
builder
|
|
.body(Body::from_stream(stream))
|
|
.map_err(|e| anyhow::anyhow!("building response: {e}").into())
|
|
}
|
|
|
|
pub async fn serve(
|
|
State(state): State<AppState>,
|
|
Path((photo_id, size)): Path<(Uuid, String)>,
|
|
jar: SignedCookieJar,
|
|
) -> ApiResult<Response> {
|
|
let key = match size.as_str() {
|
|
"thumb" => s3::thumb_key(photo_id),
|
|
"preview" => s3::preview_key(photo_id),
|
|
_ => return Err(ApiError::bad_request("size must be thumb or preview")),
|
|
};
|
|
authorize_photo(&state, &jar, photo_id, false).await?;
|
|
stream_object(&state, &key, "image/jpeg", None).await
|
|
}
|
|
|
|
pub async fn original(
|
|
State(state): State<AppState>,
|
|
Path(photo_id): Path<Uuid>,
|
|
jar: SignedCookieJar,
|
|
) -> ApiResult<Response> {
|
|
let photo = authorize_photo(&state, &jar, photo_id, true).await?;
|
|
let key = s3::original_key(photo_id, &photo.filename);
|
|
stream_object(&state, &key, &photo.content_type, Some(&photo.filename)).await
|
|
}
|