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,174 @@
|
||||
use axum::body::Body;
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::header::CONTENT_TYPE;
|
||||
use axum::http::HeaderMap;
|
||||
use axum::Json;
|
||||
use futures::StreamExt;
|
||||
use serde::Deserialize;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{ApiError, ApiResult};
|
||||
use crate::jobs;
|
||||
use crate::models::{JobKind, Photo, PhotoStatus};
|
||||
use crate::s3;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UploadQuery {
|
||||
filename: String,
|
||||
}
|
||||
|
||||
fn sanitize_filename(raw: &str) -> Result<String, ApiError> {
|
||||
let base = raw.rsplit(['/', '\\']).next().unwrap_or(raw);
|
||||
let cleaned: String = base
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | ' ' | '(' | ')') {
|
||||
c
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let cleaned = cleaned.trim().trim_start_matches('.').to_string();
|
||||
if cleaned.is_empty() {
|
||||
return Err(ApiError::bad_request("invalid filename"));
|
||||
}
|
||||
Ok(cleaned.chars().take(150).collect())
|
||||
}
|
||||
|
||||
pub async fn upload(
|
||||
State(state): State<AppState>,
|
||||
Path(album_id): Path<Uuid>,
|
||||
Query(query): Query<UploadQuery>,
|
||||
headers: HeaderMap,
|
||||
body: Body,
|
||||
) -> ApiResult<Json<Photo>> {
|
||||
let album_exists: Option<(Uuid,)> = sqlx::query_as("select id from albums where id = $1")
|
||||
.bind(album_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
if album_exists.is_none() {
|
||||
return Err(ApiError::not_found());
|
||||
}
|
||||
|
||||
let filename = sanitize_filename(&query.filename)?;
|
||||
let content_type = headers
|
||||
.get(CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("application/octet-stream")
|
||||
.to_string();
|
||||
|
||||
// Stream the request body to a temp file so large raws never sit in memory.
|
||||
let dir = tempfile::tempdir().map_err(anyhow::Error::from)?;
|
||||
let path = dir.path().join("upload.bin");
|
||||
let mut file = tokio::fs::File::create(&path)
|
||||
.await
|
||||
.map_err(anyhow::Error::from)?;
|
||||
let mut stream = body.into_data_stream();
|
||||
let mut size: i64 = 0;
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.map_err(|e| ApiError::bad_request(format!("upload aborted: {e}")))?;
|
||||
size += chunk.len() as i64;
|
||||
file.write_all(&chunk).await.map_err(anyhow::Error::from)?;
|
||||
}
|
||||
file.flush().await.map_err(anyhow::Error::from)?;
|
||||
drop(file);
|
||||
if size == 0 {
|
||||
return Err(ApiError::bad_request("empty upload"));
|
||||
}
|
||||
|
||||
// Upload to S3 first, then create the row and enqueue processing in one
|
||||
// transaction — a photo row can never exist without its job, and a failed
|
||||
// transaction (e.g. album deleted mid-upload) cleans up the S3 object.
|
||||
let photo_id = Uuid::new_v4();
|
||||
let key = s3::original_key(photo_id, &filename);
|
||||
s3::put_file(&state, &key, &path, &content_type).await?;
|
||||
|
||||
let result: ApiResult<(sqlx::Transaction<'static, sqlx::Postgres>, Photo)> = async {
|
||||
let mut tx = state.db.begin().await?;
|
||||
let photo: Photo = sqlx::query_as(
|
||||
"insert into photos (id, album_id, filename, content_type, size_bytes, status)
|
||||
values ($1, $2, $3, $4, $5, $6)
|
||||
returning *",
|
||||
)
|
||||
.bind(photo_id)
|
||||
.bind(album_id)
|
||||
.bind(&filename)
|
||||
.bind(&content_type)
|
||||
.bind(size)
|
||||
.bind(PhotoStatus::Uploaded.as_str())
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
jobs::enqueue(
|
||||
&mut *tx,
|
||||
JobKind::ProcessPhoto,
|
||||
serde_json::json!({ "photo_id": photo_id }),
|
||||
)
|
||||
.await?;
|
||||
Ok((tx, photo))
|
||||
}
|
||||
.await;
|
||||
|
||||
let (tx, photo) = match result {
|
||||
Ok(pair) => pair,
|
||||
Err(e) => {
|
||||
// Nothing committed — safe to remove the freshly stored original.
|
||||
if let Err(cleanup) = s3::delete_prefix(&state, &s3::photo_prefix(photo_id)).await {
|
||||
tracing::error!("failed to clean up s3 after aborted upload: {cleanup:#}");
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
if let Err(e) = tx.commit().await {
|
||||
// A failed COMMIT is ambiguous (it may have been applied); deleting
|
||||
// the S3 object here could destroy a committed photo's original, so
|
||||
// leave it — an orphaned object beats data loss.
|
||||
tracing::error!(
|
||||
"commit failed after upload of photo {photo_id}; leaving s3 object in place: {e}"
|
||||
);
|
||||
return Err(anyhow::Error::from(e).context("saving upload").into());
|
||||
}
|
||||
Ok(Json(photo))
|
||||
}
|
||||
|
||||
pub async fn delete(
|
||||
State(state): State<AppState>,
|
||||
Path(photo_id): Path<Uuid>,
|
||||
) -> ApiResult<Json<serde_json::Value>> {
|
||||
let mut tx = state.db.begin().await?;
|
||||
let deleted = sqlx::query("delete from photos where id = $1")
|
||||
.bind(photo_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if deleted.rows_affected() == 0 {
|
||||
return Err(ApiError::not_found());
|
||||
}
|
||||
jobs::enqueue(
|
||||
&mut *tx,
|
||||
JobKind::DeleteS3Prefix,
|
||||
serde_json::json!({ "prefix": s3::photo_prefix(photo_id) }),
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
|
||||
pub async fn reprocess(
|
||||
State(state): State<AppState>,
|
||||
Path(photo_id): Path<Uuid>,
|
||||
) -> ApiResult<Json<serde_json::Value>> {
|
||||
let mut tx = state.db.begin().await?;
|
||||
let updated = sqlx::query("update photos set status = $2, error = null where id = $1")
|
||||
.bind(photo_id)
|
||||
.bind(PhotoStatus::Uploaded.as_str())
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if updated.rows_affected() == 0 {
|
||||
return Err(ApiError::not_found());
|
||||
}
|
||||
jobs::ensure_process_photo(&mut tx, photo_id).await?;
|
||||
tx.commit().await?;
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
Reference in New Issue
Block a user