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,105 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user