ci / docker (push) Successful in 9s
- expiry editor commits on blur/Enter with explicit clear button — no more per-keystroke PATCHes transiently expiring live links - shift-range walks the filtered view, anchors reset on clear, updaters kept pure (StrictMode-safe) - bulk delete surfaces errors, 404s on zero deletions, and shares one delete+cleanup path with single and album delete (s3::photo_prefix is the only prefix source) - expiry end-of-day convention extracted; share rows update from the PATCH response instead of reloading the list - Gallery container always renders, restoring the simple observer effect
272 lines
9.3 KiB
Rust
272 lines
9.3 KiB
Rust
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 sha2::Digest;
|
|
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, hashing as it flows: sha256 for duplicate detection, crc32 for
|
|
// spool-free zip downloads.
|
|
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 sha = sha2::Sha256::new();
|
|
let mut crc = crc32fast::Hasher::new();
|
|
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;
|
|
sha.update(&chunk);
|
|
crc.update(&chunk);
|
|
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"));
|
|
}
|
|
let sha256 = hex::encode(sha.finalize());
|
|
let crc32 = i64::from(crc.finalize());
|
|
|
|
// Same content already in this album? Return it — re-dragging a folder
|
|
// after a partial upload just fills the gaps instead of duplicating.
|
|
let existing: Option<Photo> =
|
|
sqlx::query_as("select * from photos where album_id = $1 and sha256 = $2")
|
|
.bind(album_id)
|
|
.bind(&sha256)
|
|
.fetch_optional(&state.db)
|
|
.await?;
|
|
if let Some(existing) = existing {
|
|
return Ok(Json(existing));
|
|
}
|
|
|
|
// 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: Result<(sqlx::Transaction<'static, sqlx::Postgres>, Photo), sqlx::Error> =
|
|
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, sha256, crc32)
|
|
values ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
returning *",
|
|
)
|
|
.bind(photo_id)
|
|
.bind(album_id)
|
|
.bind(&filename)
|
|
.bind(&content_type)
|
|
.bind(size)
|
|
.bind(PhotoStatus::Uploaded.as_str())
|
|
.bind(&sha256)
|
|
.bind(crc32)
|
|
.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:#}");
|
|
}
|
|
// Concurrent identical upload beat us to the unique index — hand
|
|
// back the winner instead of an error.
|
|
let unique_violation = matches!(
|
|
&e,
|
|
sqlx::Error::Database(db) if db.code().as_deref() == Some("23505")
|
|
);
|
|
if unique_violation {
|
|
let winner: Option<Photo> =
|
|
sqlx::query_as("select * from photos where album_id = $1 and sha256 = $2")
|
|
.bind(album_id)
|
|
.bind(&sha256)
|
|
.fetch_optional(&state.db)
|
|
.await?;
|
|
if let Some(winner) = winner {
|
|
return Ok(Json(winner));
|
|
}
|
|
}
|
|
return Err(e.into());
|
|
}
|
|
};
|
|
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))
|
|
}
|
|
|
|
/// Client-side dedup support: lets the uploader skip transferring files whose
|
|
/// content already exists in the album.
|
|
pub async fn by_hash(
|
|
State(state): State<AppState>,
|
|
Path((album_id, sha256)): Path<(Uuid, String)>,
|
|
) -> ApiResult<Json<Photo>> {
|
|
let photo: Option<Photo> =
|
|
sqlx::query_as("select * from photos where album_id = $1 and sha256 = $2")
|
|
.bind(album_id)
|
|
.bind(&sha256)
|
|
.fetch_optional(&state.db)
|
|
.await?;
|
|
photo.map(Json).ok_or_else(ApiError::not_found)
|
|
}
|
|
|
|
/// The one deletion path: delete the given photos and enqueue their S3
|
|
/// cleanup jobs in the same transaction. Prefixes come from s3::photo_prefix
|
|
/// so the key layout has a single source of truth. Returns how many photos
|
|
/// were actually deleted.
|
|
pub(super) async fn delete_with_cleanup(
|
|
tx: &mut sqlx::PgConnection,
|
|
ids: &[Uuid],
|
|
) -> Result<u64, sqlx::Error> {
|
|
let deleted: Vec<Uuid> = sqlx::query_scalar("delete from photos where id = any($1) returning id")
|
|
.bind(ids)
|
|
.fetch_all(&mut *tx)
|
|
.await?;
|
|
if deleted.is_empty() {
|
|
return Ok(0);
|
|
}
|
|
let prefixes: Vec<String> = deleted.iter().map(|id| s3::photo_prefix(*id)).collect();
|
|
sqlx::query(
|
|
"insert into jobs (kind, payload)
|
|
select $1, jsonb_build_object('prefix', p)
|
|
from unnest($2::text[]) as p",
|
|
)
|
|
.bind(JobKind::DeleteS3Prefix.as_str())
|
|
.bind(&prefixes)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
Ok(deleted.len() as u64)
|
|
}
|
|
|
|
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?;
|
|
if delete_with_cleanup(&mut tx, &[photo_id]).await? == 0 {
|
|
return Err(ApiError::not_found());
|
|
}
|
|
tx.commit().await?;
|
|
Ok(Json(serde_json::json!({ "ok": true })))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct DeleteManyBody {
|
|
ids: Vec<Uuid>,
|
|
}
|
|
|
|
pub async fn delete_many(
|
|
State(state): State<AppState>,
|
|
Json(body): Json<DeleteManyBody>,
|
|
) -> ApiResult<Json<serde_json::Value>> {
|
|
if body.ids.is_empty() {
|
|
return Err(ApiError::bad_request("ids must not be empty"));
|
|
}
|
|
let mut tx = state.db.begin().await?;
|
|
let deleted = delete_with_cleanup(&mut tx, &body.ids).await?;
|
|
// Consistent with the single-photo route: deleting nothing is an error,
|
|
// not a silent success (e.g. a stale tab re-deleting already-gone photos).
|
|
if deleted == 0 {
|
|
return Err(ApiError::not_found());
|
|
}
|
|
tx.commit().await?;
|
|
Ok(Json(serde_json::json!({ "ok": true, "deleted": deleted })))
|
|
}
|
|
|
|
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 })))
|
|
}
|