Files
photos/src/routes/images.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

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
}