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 { let photo: Option = 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 { 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, Path((photo_id, size)): Path<(Uuid, String)>, jar: SignedCookieJar, ) -> ApiResult { 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, Path(photo_id): Path, jar: SignedCookieJar, ) -> ApiResult { 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 }