Files
photos/src/routes/zip.rs
T
nils 6259ca84d8
ci / docker (push) Successful in 12s
Content dedup on both sides, stored checksums, spool-free zips, upload stats
- sha256+crc32 hashed during upload streaming; unique index per album
- duplicate content returns the existing photo (race-safe via 23505)
- client hashes locally (WebCrypto) and skips the transfer entirely for
  content the album already has
- zip downloads stream S3->response directly using the stored crc32;
  pre-hash photos spool once and self-heal (crc via zip, sha via reprocess)
- upload UI: overall progress bar, bytes, live speed, ETA
2026-07-17 14:41:07 +02:00

475 lines
18 KiB
Rust

//! Streaming ZIP downloads, hand-written for spec compliance.
//!
//! Every entry is Stored (raws/jpegs don't compress) with its exact size and
//! CRC-32 in the local file header — no data descriptors — so the archives
//! work with strict *streaming* extractors (Java ZipInputStream, bsdtar from
//! a pipe), not just central-directory readers. Sizes are known up front,
//! which also makes the total byte length deterministic: the response carries
//! a real Content-Length, so browsers show progress and flag truncated
//! downloads as failed.
//!
//! Each file is spooled from S3 to an anonymous temp file to compute its CRC
//! before its header is written; the next file spools while the current one
//! streams out, so S3 latency doesn't stall the download.
use std::collections::HashSet;
use axum::body::{Body, Bytes};
use axum::extract::{Form, Path, State};
use axum::http::{header, StatusCode};
use axum::response::Response;
use axum_extra::extract::cookie::SignedCookieJar;
use chrono::{DateTime, Datelike, Timelike, Utc};
use serde::Deserialize;
use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt};
use tokio::task::JoinHandle;
use uuid::Uuid;
use crate::error::{ApiError, ApiResult};
use crate::models::{Photo, PhotoStatus};
use crate::routes::client::authorize_share;
use crate::s3;
use crate::state::AppState;
const U32_SENTINEL: u64 = 0xFFFF_FFFF;
/// Sent as a plain form POST (not fetch) so the browser streams the download
/// natively; `ids` is a comma-separated list, empty = every ready photo.
#[derive(Deserialize)]
pub struct ZipRequest {
#[serde(default)]
ids: String,
}
/// Strict: a present-but-malformed id is a 400, never silently reinterpreted
/// as the download-everything sentinel.
fn parse_ids(raw: &str) -> Result<Vec<Uuid>, ApiError> {
raw.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| {
Uuid::parse_str(s).map_err(|_| ApiError::bad_request(format!("invalid photo id: {s}")))
})
.collect()
}
async fn ready_photos(
state: &AppState,
album_id: Uuid,
ids: &[Uuid],
) -> Result<Vec<Photo>, sqlx::Error> {
sqlx::query_as(
"select * from photos
where album_id = $1 and status = $3
and (cardinality($2::uuid[]) = 0 or id = any($2))
order by coalesce(taken_at, created_at), filename",
)
.bind(album_id)
.bind(ids)
.bind(PhotoStatus::Ready.as_str())
.fetch_all(&state.db)
.await
}
async fn album_name(state: &AppState, album_id: Uuid) -> Result<String, ApiError> {
let name: Option<(String,)> = sqlx::query_as("select name from albums where id = $1")
.bind(album_id)
.fetch_optional(&state.db)
.await?;
Ok(name.ok_or_else(ApiError::not_found)?.0)
}
pub async fn album_zip(
State(state): State<AppState>,
Path(album_id): Path<Uuid>,
Form(request): Form<ZipRequest>,
) -> ApiResult<Response> {
let name = album_name(&state, album_id).await?;
let photos = ready_photos(&state, album_id, &parse_ids(&request.ids)?).await?;
stream_zip(state, photos, &name)
}
pub async fn share_zip(
State(state): State<AppState>,
Path(token): Path<String>,
jar: SignedCookieJar,
Form(request): Form<ZipRequest>,
) -> ApiResult<Response> {
let share = authorize_share(&state, &jar, &token, true).await?;
let name = album_name(&state, share.album_id).await?;
let photos = ready_photos(&state, share.album_id, &parse_ids(&request.ids)?).await?;
stream_zip(state, photos, &name)
}
/// Dedupe case-insensitively — archives are extracted onto case-insensitive
/// filesystems (macOS/Windows), where "DSC1.JPG" and "dsc1.jpg" would collide.
fn unique_entry_name(used: &mut HashSet<String>, filename: &str) -> String {
if used.insert(filename.to_lowercase()) {
return filename.to_string();
}
let (stem, ext) = match filename.rsplit_once('.') {
Some((stem, ext)) if !stem.is_empty() => (stem, format!(".{ext}")),
_ => (filename, String::new()),
};
for n in 2.. {
let candidate = format!("{stem} ({n}){ext}");
if used.insert(candidate.to_lowercase()) {
return candidate;
}
}
unreachable!()
}
/// MS-DOS timestamp (2-second resolution, no timezone; years 1980+ only —
/// callers clamp earlier dates).
fn dos_datetime(t: DateTime<Utc>) -> (u16, u16) {
let time = ((t.hour() as u16) << 11) | ((t.minute() as u16) << 5) | (t.second() as u16 / 2);
let date = (((t.year() - 1980) as u16) << 9) | ((t.month() as u16) << 5) | (t.day() as u16);
(time, date)
}
struct Entry {
photo_id: Uuid,
s3_key: String,
name: Vec<u8>,
size: u64,
offset: u64,
dos_time: u16,
dos_date: u16,
/// Stored at upload/processing time; photos from before hashes existed
/// have None and take the slower spool path (which backfills it).
crc: Option<u32>,
}
struct ZipPlan {
entries: Vec<Entry>,
cd_offset: u64,
cd_size: u64,
zip64_eocd: bool,
total_len: u64,
}
fn plan_zip(photos: &[Photo]) -> Result<ZipPlan, ApiError> {
let mut used_names = HashSet::new();
let mut entries = Vec::with_capacity(photos.len());
let mut offset: u64 = 0;
for photo in photos {
let size = photo.size_bytes as u64;
if size >= U32_SENTINEL {
return Err(ApiError::bad_request(format!(
"{} is too large for a zip download",
photo.filename
)));
}
let name = unique_entry_name(&mut used_names, &photo.filename).into_bytes();
// DOS timestamps can't represent pre-1980 dates (cameras with unset
// clocks); fall back to the upload time.
let timestamp = photo
.taken_at
.filter(|t| t.year() >= 1980)
.unwrap_or(photo.created_at);
let (dos_time, dos_date) = dos_datetime(timestamp);
let entry_offset = offset;
offset += 30 + name.len() as u64 + size;
entries.push(Entry {
photo_id: photo.id,
s3_key: s3::original_key(photo.id, &photo.filename),
name,
size,
offset: entry_offset,
dos_time,
dos_date,
crc: photo.crc32.map(|v| v as u32),
});
}
let cd_offset = offset;
let cd_size: u64 = entries
.iter()
.map(|e| 46 + e.name.len() as u64 + if e.offset >= U32_SENTINEL { 12 } else { 0 })
.sum();
let zip64_eocd = entries.len() >= 0xFFFF
|| cd_size >= U32_SENTINEL
|| cd_offset >= U32_SENTINEL;
let total_len = cd_offset + cd_size + 22 + if zip64_eocd { 56 + 20 } else { 0 };
Ok(ZipPlan {
entries,
cd_offset,
cd_size,
zip64_eocd,
total_len,
})
}
fn stream_zip(state: AppState, photos: Vec<Photo>, album_name: &str) -> ApiResult<Response> {
if photos.is_empty() {
return Err(ApiError::bad_request("no downloadable photos selected"));
}
let plan = plan_zip(&photos)?;
let permit = state.zip_permits.clone().try_acquire_owned().map_err(|_| {
ApiError(
StatusCode::SERVICE_UNAVAILABLE,
"too many downloads in progress — try again in a moment".into(),
)
})?;
let zip_name: String = album_name
.trim()
.chars()
.map(|c| if c.is_ascii_alphanumeric() || matches!(c, ' ' | '-' | '_') { c } else { '_' })
.take(80)
.collect();
let zip_name = if zip_name.is_empty() { "photos".to_string() } else { zip_name };
let total_len = plan.total_len;
let (writer, reader) = tokio::io::duplex(256 * 1024);
let write_task = tokio::spawn(async move {
let result = write_zip(&state, plan, writer).await;
drop(permit);
result
});
// Forward bytes to the response; when the writer finishes, surface any
// zip error as a stream error so hyper ABORTS the connection — combined
// with the exact Content-Length, browsers report truncation as a failed
// download instead of keeping a silently corrupt file.
struct Pump {
reader: tokio::io::DuplexStream,
task: Option<JoinHandle<anyhow::Result<()>>>,
}
let pump = Pump {
reader,
task: Some(write_task),
};
let stream = futures::stream::unfold(pump, |mut pump| async move {
pump.task.as_ref()?; // stream is over after a terminal item
let mut buf = vec![0u8; 64 * 1024];
match pump.reader.read(&mut buf).await {
Ok(0) => {
let task = pump.task.take()?;
match task.await {
Ok(Ok(())) => None,
Ok(Err(e)) => Some((
Err(std::io::Error::other(format!("zip stream failed: {e:#}"))),
pump,
)),
Err(e) => Some((
Err(std::io::Error::other(format!("zip task panicked: {e}"))),
pump,
)),
}
}
Ok(n) => {
buf.truncate(n);
Some((Ok(Bytes::from(buf)), pump))
}
Err(e) => {
pump.task = None;
Some((Err(e), pump))
}
}
});
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/zip")
.header(header::CONTENT_LENGTH, total_len)
.header(
header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{zip_name}.zip\""),
)
.body(Body::from_stream(stream))
.map_err(|e| anyhow::anyhow!("building response: {e}").into())
}
enum Fetched {
/// CRC already known — the body streams straight into the response.
Direct(Box<aws_sdk_s3::operation::get_object::GetObjectOutput>),
/// Pre-hash photo: spooled to a temp file to compute the CRC first.
Spooled(tokio::fs::File, u32),
}
/// Start fetching an original. With a known CRC this only opens the S3
/// response (the body is consumed later, straight into the zip stream);
/// otherwise the object is spooled to an anonymous temp file to compute the
/// CRC, verifying the byte count the zip plan promised.
fn fetch_entry(
state: &AppState,
key: String,
expected_size: u64,
crc_known: bool,
) -> JoinHandle<anyhow::Result<Fetched>> {
let state = state.clone();
tokio::spawn(async move {
let object = state
.s3
.get_object()
.bucket(&state.config.s3_bucket)
.key(&key)
.send()
.await
.map_err(|e| anyhow::anyhow!("fetching {key}: {e}"))?;
if crc_known {
return Ok(Fetched::Direct(Box::new(object)));
}
let mut file = tokio::fs::File::from_std(tempfile::tempfile()?);
let mut reader = object.body.into_async_read();
let mut hasher = crc32fast::Hasher::new();
let mut written: u64 = 0;
let mut buf = vec![0u8; 128 * 1024];
loop {
let n = reader.read(&mut buf).await?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
file.write_all(&buf[..n]).await?;
written += n as u64;
}
anyhow::ensure!(
written == expected_size,
"{key} is {written} bytes in s3 but {expected_size} in the database"
);
file.flush().await?;
file.seek(std::io::SeekFrom::Start(0)).await?;
Ok(Fetched::Spooled(file, hasher.finalize()))
})
}
async fn write_zip(
state: &AppState,
plan: ZipPlan,
mut out: tokio::io::DuplexStream,
) -> anyhow::Result<()> {
// UTF-8 filename flag; no data descriptors (bit 3 unset).
const FLAGS: u16 = 0x0800;
let mut crcs = Vec::with_capacity(plan.entries.len());
// Prefetch: start fetching the next object while streaming the current one.
let mut pending: Option<JoinHandle<anyhow::Result<Fetched>>> = None;
for (i, entry) in plan.entries.iter().enumerate() {
let current = match pending.take() {
Some(handle) => handle,
None => fetch_entry(state, entry.s3_key.clone(), entry.size, entry.crc.is_some()),
};
if let Some(next) = plan.entries.get(i + 1) {
pending = Some(fetch_entry(
state,
next.s3_key.clone(),
next.size,
next.crc.is_some(),
));
}
let fetched = current
.await
.map_err(|e| anyhow::anyhow!("fetch task failed: {e}"))?
.map_err(|e| anyhow::anyhow!("photo {}: {e:#}", entry.photo_id))?;
let crc = match &fetched {
Fetched::Direct(_) => entry.crc.expect("direct fetch implies known crc"),
Fetched::Spooled(_, crc) => *crc,
};
crcs.push(crc);
let mut lfh = Vec::with_capacity(30 + entry.name.len());
lfh.extend_from_slice(&0x04034b50u32.to_le_bytes());
lfh.extend_from_slice(&20u16.to_le_bytes()); // version needed
lfh.extend_from_slice(&FLAGS.to_le_bytes());
lfh.extend_from_slice(&0u16.to_le_bytes()); // method: stored
lfh.extend_from_slice(&entry.dos_time.to_le_bytes());
lfh.extend_from_slice(&entry.dos_date.to_le_bytes());
lfh.extend_from_slice(&crc.to_le_bytes());
lfh.extend_from_slice(&(entry.size as u32).to_le_bytes()); // compressed
lfh.extend_from_slice(&(entry.size as u32).to_le_bytes()); // uncompressed
lfh.extend_from_slice(&(entry.name.len() as u16).to_le_bytes());
lfh.extend_from_slice(&0u16.to_le_bytes()); // extra len
lfh.extend_from_slice(&entry.name);
out.write_all(&lfh).await?;
match fetched {
Fetched::Direct(object) => {
let mut reader = object.body.into_async_read();
let copied = tokio::io::copy(&mut reader, &mut out).await?;
anyhow::ensure!(
copied == entry.size,
"{} is {copied} bytes in s3 but {} in the database",
entry.s3_key,
entry.size
);
}
Fetched::Spooled(mut file, crc) => {
tokio::io::copy(&mut file, &mut out).await?;
// Self-heal: store the freshly computed crc so the next
// download of this photo streams directly.
let _ = sqlx::query("update photos set crc32 = coalesce(crc32, $2) where id = $1")
.bind(entry.photo_id)
.bind(i64::from(crc))
.execute(&state.db)
.await;
}
}
}
// Central directory.
for (entry, crc) in plan.entries.iter().zip(&crcs) {
let zip64_offset = entry.offset >= U32_SENTINEL;
let mut cdh = Vec::with_capacity(46 + entry.name.len() + 12);
cdh.extend_from_slice(&0x02014b50u32.to_le_bytes());
cdh.extend_from_slice(&0x031Eu16.to_le_bytes()); // made by: unix, 3.0
cdh.extend_from_slice(&(if zip64_offset { 45u16 } else { 20u16 }).to_le_bytes());
cdh.extend_from_slice(&FLAGS.to_le_bytes());
cdh.extend_from_slice(&0u16.to_le_bytes()); // method: stored
cdh.extend_from_slice(&entry.dos_time.to_le_bytes());
cdh.extend_from_slice(&entry.dos_date.to_le_bytes());
cdh.extend_from_slice(&crc.to_le_bytes());
cdh.extend_from_slice(&(entry.size as u32).to_le_bytes());
cdh.extend_from_slice(&(entry.size as u32).to_le_bytes());
cdh.extend_from_slice(&(entry.name.len() as u16).to_le_bytes());
cdh.extend_from_slice(&(if zip64_offset { 12u16 } else { 0u16 }).to_le_bytes()); // extra len
cdh.extend_from_slice(&0u16.to_le_bytes()); // comment len
cdh.extend_from_slice(&0u16.to_le_bytes()); // disk number
cdh.extend_from_slice(&0u16.to_le_bytes()); // internal attrs
cdh.extend_from_slice(&(0o100644u32 << 16).to_le_bytes()); // unix -rw-r--r--
let offset32 = if zip64_offset { U32_SENTINEL as u32 } else { entry.offset as u32 };
cdh.extend_from_slice(&offset32.to_le_bytes());
cdh.extend_from_slice(&entry.name);
if zip64_offset {
cdh.extend_from_slice(&0x0001u16.to_le_bytes()); // zip64 extra field
cdh.extend_from_slice(&8u16.to_le_bytes());
cdh.extend_from_slice(&entry.offset.to_le_bytes());
}
out.write_all(&cdh).await?;
}
// End of central directory (zip64 variants only when values overflow).
let mut tail = Vec::with_capacity(98);
if plan.zip64_eocd {
let entries = plan.entries.len() as u64;
tail.extend_from_slice(&0x06064b50u32.to_le_bytes());
tail.extend_from_slice(&44u64.to_le_bytes()); // record size
tail.extend_from_slice(&0x031Eu16.to_le_bytes()); // made by
tail.extend_from_slice(&45u16.to_le_bytes()); // version needed
tail.extend_from_slice(&0u32.to_le_bytes()); // this disk
tail.extend_from_slice(&0u32.to_le_bytes()); // cd disk
tail.extend_from_slice(&entries.to_le_bytes());
tail.extend_from_slice(&entries.to_le_bytes());
tail.extend_from_slice(&plan.cd_size.to_le_bytes());
tail.extend_from_slice(&plan.cd_offset.to_le_bytes());
// zip64 EOCD locator
tail.extend_from_slice(&0x07064b50u32.to_le_bytes());
tail.extend_from_slice(&0u32.to_le_bytes());
tail.extend_from_slice(&(plan.cd_offset + plan.cd_size).to_le_bytes());
tail.extend_from_slice(&1u32.to_le_bytes());
}
let clamp16 = |v: u64| -> u16 { v.min(0xFFFF) as u16 };
let clamp32 = |v: u64| -> u32 { v.min(U32_SENTINEL) as u32 };
tail.extend_from_slice(&0x06054b50u32.to_le_bytes());
tail.extend_from_slice(&0u16.to_le_bytes()); // this disk
tail.extend_from_slice(&0u16.to_le_bytes()); // cd disk
tail.extend_from_slice(&clamp16(plan.entries.len() as u64).to_le_bytes());
tail.extend_from_slice(&clamp16(plan.entries.len() as u64).to_le_bytes());
tail.extend_from_slice(&clamp32(plan.cd_size).to_le_bytes());
tail.extend_from_slice(&clamp32(plan.cd_offset).to_le_bytes());
tail.extend_from_slice(&0u16.to_le_bytes()); // comment len
out.write_all(&tail).await?;
out.shutdown().await?;
Ok(())
}