Initial release: self-hosted client photo gallery
ci / docker (push) Successful in 13m10s

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:
2026-07-17 13:12:42 +02:00
co-authored by Claude
commit f0238fc625
55 changed files with 11962 additions and 0 deletions
+312
View File
@@ -0,0 +1,312 @@
use axum::extract::{FromRequestParts, Query, State};
use axum::http::request::Parts;
use axum::response::Redirect;
use axum::Json;
use axum_extra::extract::cookie::{Cookie, SameSite, SignedCookieJar};
use chrono::Utc;
use serde::Deserialize;
use uuid::Uuid;
use crate::error::{ApiError, ApiResult};
use crate::state::AppState;
pub const SESSION_COOKIE: &str = "photos_session";
const STATE_COOKIE: &str = "photos_oauth_state";
const SESSION_DAYS: i64 = 30;
#[derive(Debug, Clone, Deserialize)]
pub struct OidcDiscovery {
pub authorization_endpoint: String,
pub token_endpoint: String,
pub userinfo_endpoint: String,
}
pub async fn discovery(state: &AppState) -> anyhow::Result<OidcDiscovery> {
let discovered = state
.oidc
.get_or_try_init(|| async {
let url = format!(
"{}/.well-known/openid-configuration",
state.config.oidc_issuer
);
let resp = state.http.get(&url).send().await?.error_for_status()?;
Ok::<_, anyhow::Error>(resp.json::<OidcDiscovery>().await?)
})
.await?;
Ok(discovered.clone())
}
#[derive(Debug, Clone)]
pub struct AuthUser {
pub id: Uuid,
pub email: String,
}
fn parse_session(value: &str) -> Option<(Uuid, i64, String)> {
let mut parts = value.splitn(3, '|');
let id = Uuid::parse_str(parts.next()?).ok()?;
let exp: i64 = parts.next()?.parse().ok()?;
let email = parts.next()?.to_string();
Some((id, exp, email))
}
/// Sessions are only honored while the email is still in ALLOWED_EMAILS, so
/// removing an address from the allowlist revokes access immediately.
pub fn user_from_jar(state: &AppState, jar: &SignedCookieJar) -> Option<AuthUser> {
let cookie = jar.get(SESSION_COOKIE)?;
let (id, exp, email) = parse_session(cookie.value())?;
if exp < Utc::now().timestamp() {
return None;
}
if !state.config.allowed_emails.contains(&email) {
return None;
}
Some(AuthUser { id, email })
}
/// A fresh session cookie when the current one has used up more than half its
/// lifetime — appended to responses by the admin middleware so an active
/// photographer's session slides instead of hard-expiring 30 days after login.
pub fn refreshed_session(state: &AppState, jar: &SignedCookieJar) -> Option<Cookie<'static>> {
let cookie = jar.get(SESSION_COOKIE)?;
let (id, exp, email) = parse_session(cookie.value())?;
let remaining = exp - Utc::now().timestamp();
if remaining > SESSION_DAYS * 86400 / 2 {
return None;
}
Some(session_cookie(state, id, &email))
}
impl FromRequestParts<AppState> for AuthUser {
type Rejection = ApiError;
async fn from_request_parts(
parts: &mut Parts,
_state: &AppState,
) -> Result<Self, Self::Rejection> {
// Only valid behind the require_photographer layer, which validates
// the session and stashes the user. Routes outside the admin router
// must not use this extractor — session validation lives in the
// middleware (and user_from_jar for the dual-auth image routes).
parts
.extensions
.get::<AuthUser>()
.cloned()
.ok_or_else(ApiError::unauthorized)
}
}
pub fn random_token(len: usize) -> String {
use rand::Rng;
rand::thread_rng()
.sample_iter(&rand::distributions::Alphanumeric)
.take(len)
.map(char::from)
.collect()
}
pub(crate) fn base_cookie(name: &'static str, value: String, secure: bool) -> Cookie<'static> {
Cookie::build((name, value))
.path("/")
.http_only(true)
.same_site(SameSite::Lax)
.secure(secure)
.build()
}
fn session_cookie(state: &AppState, user_id: Uuid, email: &str) -> Cookie<'static> {
let exp = Utc::now().timestamp() + SESSION_DAYS * 86400;
let mut cookie = base_cookie(
SESSION_COOKIE,
format!("{user_id}|{exp}|{email}"),
state.config.cookie_secure(),
);
cookie.set_max_age(time::Duration::days(SESSION_DAYS));
cookie
}
async fn upsert_user(
state: &AppState,
oidc_subject: &str,
email: &str,
display_name: &str,
) -> Result<Uuid, sqlx::Error> {
let (user_id,): (Uuid,) = sqlx::query_as(
"insert into users (oidc_subject, email, display_name) values ($1, $2, $3)
on conflict (oidc_subject) do update
set email = excluded.email, display_name = excluded.display_name
returning id",
)
.bind(oidc_subject)
.bind(email)
.bind(display_name)
.fetch_one(&state.db)
.await?;
Ok(user_id)
}
/// Login/callback are top-level browser navigations — errors must land the
/// user back on the SPA login card, never on a raw JSON body.
fn error_redirect(error: &ApiError) -> Redirect {
let message = if error.0.is_server_error() {
"sign-in failed — please try again"
} else {
error.1.as_str()
};
Redirect::to(&format!("/?auth_error={}", urlencoding::encode(message)))
}
pub async fn login(
State(state): State<AppState>,
jar: SignedCookieJar,
) -> (SignedCookieJar, Redirect) {
match login_inner(&state, jar.clone()).await {
Ok(ok) => ok,
Err(e) => {
tracing::warn!("login failed: {} {}", e.0, e.1);
(jar, error_redirect(&e))
}
}
}
async fn login_inner(
state: &AppState,
jar: SignedCookieJar,
) -> ApiResult<(SignedCookieJar, Redirect)> {
if let Some(email) = state.config.dev_autologin_email.clone() {
tracing::warn!("DEV_AUTOLOGIN_EMAIL is set — signing in {email} without OIDC");
let email = email.to_lowercase();
if !state.config.allowed_emails.contains(&email) {
return Err(ApiError::forbidden(
"DEV_AUTOLOGIN_EMAIL must also be in ALLOWED_EMAILS",
));
}
let user_id = upsert_user(state, &format!("dev:{email}"), &email, &email).await?;
let cookie = session_cookie(state, user_id, &email);
return Ok((jar.add(cookie), Redirect::to("/")));
}
let discovered = discovery(state).await?;
let oauth_state = random_token(24);
let redirect_uri = format!("{}/api/auth/callback", state.config.public_url);
let separator = if discovered.authorization_endpoint.contains('?') {
'&'
} else {
'?'
};
let url = format!(
"{}{}response_type=code&client_id={}&redirect_uri={}&scope=openid%20email%20profile&state={}",
discovered.authorization_endpoint,
separator,
urlencoding::encode(&state.config.oidc_client_id),
urlencoding::encode(&redirect_uri),
oauth_state
);
let mut cookie = base_cookie(STATE_COOKIE, oauth_state, state.config.cookie_secure());
cookie.set_max_age(time::Duration::minutes(10));
Ok((jar.add(cookie), Redirect::to(&url)))
}
#[derive(Deserialize)]
pub struct CallbackQuery {
code: Option<String>,
state: Option<String>,
error: Option<String>,
error_description: Option<String>,
}
#[derive(Deserialize)]
struct TokenResponse {
access_token: String,
}
#[derive(Deserialize)]
struct UserInfo {
sub: String,
email: Option<String>,
name: Option<String>,
preferred_username: Option<String>,
}
pub async fn callback(
State(state): State<AppState>,
jar: SignedCookieJar,
Query(query): Query<CallbackQuery>,
) -> (SignedCookieJar, Redirect) {
match callback_inner(&state, jar.clone(), query).await {
Ok(ok) => ok,
Err(e) => {
tracing::warn!("oidc callback failed: {} {}", e.0, e.1);
(jar, error_redirect(&e))
}
}
}
async fn callback_inner(
state: &AppState,
jar: SignedCookieJar,
query: CallbackQuery,
) -> ApiResult<(SignedCookieJar, Redirect)> {
if let Some(err) = query.error {
let detail = query.error_description.unwrap_or_default();
return Err(ApiError::bad_request(format!("oidc error: {err} {detail}")));
}
let code = query
.code
.ok_or_else(|| ApiError::bad_request("missing code"))?;
let returned_state = query.state.unwrap_or_default();
let cookie_state = jar.get(STATE_COOKIE).map(|c| c.value().to_string());
if returned_state.is_empty() || cookie_state.as_deref() != Some(returned_state.as_str()) {
return Err(ApiError::bad_request("oauth state mismatch"));
}
let jar = jar.remove(Cookie::build((STATE_COOKIE, "")).path("/").build());
let discovered = discovery(state).await?;
let redirect_uri = format!("{}/api/auth/callback", state.config.public_url);
let token: TokenResponse = state
.http
.post(&discovered.token_endpoint)
.form(&[
("grant_type", "authorization_code"),
("code", code.as_str()),
("redirect_uri", redirect_uri.as_str()),
("client_id", state.config.oidc_client_id.as_str()),
("client_secret", state.config.oidc_client_secret.as_str()),
])
.send()
.await?
.error_for_status()?
.json()
.await?;
let info: UserInfo = state
.http
.get(&discovered.userinfo_endpoint)
.bearer_auth(&token.access_token)
.send()
.await?
.error_for_status()?
.json()
.await?;
let email = info.email.clone().unwrap_or_default().to_lowercase();
if email.is_empty() || !state.config.allowed_emails.contains(&email) {
return Err(ApiError::forbidden("this account is not allowed to sign in"));
}
let display_name = info
.name
.or(info.preferred_username)
.unwrap_or_else(|| email.clone());
let user_id = upsert_user(state, &info.sub, &email, &display_name).await?;
let cookie = session_cookie(state, user_id, &email);
Ok((jar.add(cookie), Redirect::to("/")))
}
pub async fn logout(jar: SignedCookieJar) -> (SignedCookieJar, Json<serde_json::Value>) {
let jar = jar.remove(Cookie::build((SESSION_COOKIE, "")).path("/").build());
(jar, Json(serde_json::json!({ "ok": true })))
}
pub async fn me(user: AuthUser) -> Json<serde_json::Value> {
Json(serde_json::json!({ "email": user.email }))
}
+33
View File
@@ -0,0 +1,33 @@
use tower_http::services::{ServeDir, ServeFile};
use tower_http::trace::TraceLayer;
use tracing_subscriber::EnvFilter;
use photos::config::Config;
use photos::state::AppState;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| "info,sqlx=warn".into()),
)
.init();
let config = Config::from_env()?;
let state = AppState::new(config).await?;
let static_dir = state.config.static_dir.clone();
let index = std::path::Path::new(&static_dir).join("index.html");
// .fallback (not .not_found_service) so SPA routes get index.html with a 200
let spa = ServeDir::new(&static_dir).fallback(ServeFile::new(index));
let app = photos::routes::router(&state)
.fallback_service(spa)
.layer(TraceLayer::new_for_http())
.with_state(state.clone());
let listener = tokio::net::TcpListener::bind(&state.config.bind_addr).await?;
tracing::info!("listening on http://{}", state.config.bind_addr);
axum::serve(listener, app).await?;
Ok(())
}
+19
View File
@@ -0,0 +1,19 @@
use tracing_subscriber::EnvFilter;
use photos::config::Config;
use photos::jobs;
use photos::state::AppState;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| "info,sqlx=warn".into()),
)
.init();
let config = Config::from_env()?;
let state = AppState::new(config).await?;
jobs::run_worker(state).await;
Ok(())
}
+81
View File
@@ -0,0 +1,81 @@
use anyhow::Context;
#[derive(Clone, Debug)]
pub struct Config {
pub database_url: String,
pub bind_addr: String,
/// External base URL of the app, e.g. https://photos.example.com (no trailing slash).
pub public_url: String,
pub session_secret: String,
pub s3_bucket: String,
pub s3_endpoint: Option<String>,
pub s3_region: String,
pub s3_access_key: String,
pub s3_secret_key: String,
pub s3_force_path_style: bool,
pub oidc_issuer: String,
pub oidc_client_id: String,
pub oidc_client_secret: String,
/// Lowercased email addresses allowed to sign in as photographer.
pub allowed_emails: Vec<String>,
pub static_dir: String,
pub worker_concurrency: usize,
/// DEV ONLY: if set, /api/auth/login skips OIDC entirely and signs in as
/// this email. Never set in production.
pub dev_autologin_email: Option<String>,
}
fn required(name: &str) -> anyhow::Result<String> {
std::env::var(name).with_context(|| format!("missing required env var {name}"))
}
fn optional(name: &str) -> Option<String> {
std::env::var(name).ok().filter(|v| !v.is_empty())
}
impl Config {
pub fn from_env() -> anyhow::Result<Self> {
let session_secret = required("SESSION_SECRET")?;
anyhow::ensure!(
session_secret.len() >= 32,
"SESSION_SECRET must be at least 32 characters"
);
let public_url = required("PUBLIC_URL")?.trim_end_matches('/').to_string();
let dev_autologin_email = optional("DEV_AUTOLOGIN_EMAIL");
anyhow::ensure!(
dev_autologin_email.is_none() || !public_url.starts_with("https://"),
"DEV_AUTOLOGIN_EMAIL must not be set when PUBLIC_URL is https:// — it disables login"
);
Ok(Self {
database_url: required("DATABASE_URL")?,
bind_addr: optional("BIND_ADDR").unwrap_or_else(|| "0.0.0.0:8080".into()),
public_url,
session_secret,
s3_bucket: required("S3_BUCKET")?,
s3_endpoint: optional("S3_ENDPOINT"),
s3_region: optional("S3_REGION").unwrap_or_else(|| "us-east-1".into()),
s3_access_key: required("S3_ACCESS_KEY")?,
s3_secret_key: required("S3_SECRET_KEY")?,
s3_force_path_style: optional("S3_FORCE_PATH_STYLE")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false),
oidc_issuer: required("OIDC_ISSUER")?.trim_end_matches('/').to_string(),
oidc_client_id: required("OIDC_CLIENT_ID")?,
oidc_client_secret: required("OIDC_CLIENT_SECRET")?,
allowed_emails: required("ALLOWED_EMAILS")?
.split(',')
.map(|s| s.trim().to_lowercase())
.filter(|s| !s.is_empty())
.collect(),
static_dir: optional("STATIC_DIR").unwrap_or_else(|| "frontend/dist".into()),
worker_concurrency: optional("WORKER_CONCURRENCY")
.and_then(|v| v.parse().ok())
.unwrap_or(2),
dev_autologin_email,
})
}
pub fn cookie_secure(&self) -> bool {
self.public_url.starts_with("https://")
}
}
+55
View File
@@ -0,0 +1,55 @@
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
pub struct ApiError(pub StatusCode, pub String);
pub type ApiResult<T> = Result<T, ApiError>;
impl ApiError {
pub fn bad_request(msg: impl Into<String>) -> Self {
Self(StatusCode::BAD_REQUEST, msg.into())
}
pub fn unauthorized() -> Self {
Self(StatusCode::UNAUTHORIZED, "authentication required".into())
}
pub fn forbidden(msg: impl Into<String>) -> Self {
Self(StatusCode::FORBIDDEN, msg.into())
}
pub fn not_found() -> Self {
Self(StatusCode::NOT_FOUND, "not found".into())
}
pub fn gone(msg: impl Into<String>) -> Self {
Self(StatusCode::GONE, msg.into())
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
(self.0, Json(serde_json::json!({ "error": self.1 }))).into_response()
}
}
impl From<sqlx::Error> for ApiError {
fn from(e: sqlx::Error) -> Self {
if matches!(e, sqlx::Error::RowNotFound) {
return Self::not_found();
}
tracing::error!("database error: {e}");
Self(StatusCode::INTERNAL_SERVER_ERROR, "internal error".into())
}
}
impl From<anyhow::Error> for ApiError {
fn from(e: anyhow::Error) -> Self {
tracing::error!("internal error: {e:#}");
Self(StatusCode::INTERNAL_SERVER_ERROR, "internal error".into())
}
}
impl From<reqwest::Error> for ApiError {
fn from(e: reqwest::Error) -> Self {
tracing::error!("upstream http error: {e}");
Self(StatusCode::BAD_GATEWAY, "upstream error".into())
}
}
+253
View File
@@ -0,0 +1,253 @@
use std::io::Cursor;
use std::path::Path;
use anyhow::Context;
use chrono::{DateTime, NaiveDateTime, Utc};
use image::codecs::jpeg::JpegEncoder;
use image::imageops::FilterType;
use image::DynamicImage;
use serde::Deserialize;
use uuid::Uuid;
use crate::models::{Photo, PhotoStatus};
use crate::s3;
use crate::state::AppState;
const PREVIEW_EDGE: u32 = 2048;
const THUMB_EDGE: u32 = 512;
pub const RAW_EXTENSIONS: &[&str] = &[
"3fr", "arw", "cr2", "cr3", "dng", "erf", "iiq", "kdc", "mef", "mos", "nef", "nrw", "orf",
"pef", "raf", "raw", "rw2", "rwl", "srw", "x3f",
];
pub fn is_raw_filename(filename: &str) -> bool {
Path::new(filename)
.extension()
.and_then(|e| e.to_str())
.map(|e| RAW_EXTENSIONS.contains(&e.to_lowercase().as_str()))
.unwrap_or(false)
}
#[derive(Deserialize)]
struct ProcessPayload {
photo_id: Uuid,
}
pub async fn process_photo_job(state: &AppState, payload: &serde_json::Value) -> anyhow::Result<()> {
let payload: ProcessPayload = serde_json::from_value(payload.clone())?;
process_photo(state, payload.photo_id).await
}
pub async fn process_photo(state: &AppState, photo_id: Uuid) -> anyhow::Result<()> {
let photo: Option<Photo> = sqlx::query_as("select * from photos where id = $1")
.bind(photo_id)
.fetch_optional(&state.db)
.await?;
let Some(photo) = photo else {
tracing::warn!("photo {photo_id} no longer exists, skipping");
return Ok(());
};
sqlx::query("update photos set status = $2, error = null where id = $1")
.bind(photo_id)
.bind(PhotoStatus::Processing.as_str())
.execute(&state.db)
.await?;
let dir = tempfile::tempdir().context("creating temp dir")?;
let extension = Path::new(&photo.filename)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("bin")
.to_lowercase();
let src_path = dir.path().join(format!("original.{extension}"));
download(state, &s3::original_key(photo_id, &photo.filename), &src_path).await?;
let meta = exif_metadata(&src_path).await?;
let render_input = if RAW_EXTENSIONS.contains(&extension.as_str()) {
extract_embedded_jpeg(&src_path).await?
} else {
tokio::fs::read(&src_path).await.context("reading original")?
};
let orientation = meta.orientation;
let (preview, thumb, width, height) =
tokio::task::spawn_blocking(move || render(&render_input, orientation))
.await
.context("render task panicked")??;
s3::put_bytes(state, &s3::preview_key(photo_id), preview, "image/jpeg").await?;
s3::put_bytes(state, &s3::thumb_key(photo_id), thumb, "image/jpeg").await?;
let updated = sqlx::query(
"update photos
set status = $5, error = null, width = $2, height = $3,
taken_at = coalesce($4, taken_at), processed_at = now()
where id = $1",
)
.bind(photo_id)
.bind(width as i32)
.bind(height as i32)
.bind(meta.taken_at)
.bind(PhotoStatus::Ready.as_str())
.execute(&state.db)
.await?;
if updated.rows_affected() == 0 {
// Photo was deleted while we were processing; its delete_s3_prefix job
// may already have run, so remove the derivatives we just re-created.
tracing::warn!("photo {photo_id} deleted during processing; cleaning up derivatives");
s3::delete_prefix(state, &s3::photo_prefix(photo_id)).await?;
}
Ok(())
}
async fn download(state: &AppState, key: &str, path: &Path) -> anyhow::Result<()> {
let object = state
.s3
.get_object()
.bucket(&state.config.s3_bucket)
.key(key)
.send()
.await
.with_context(|| format!("fetching s3://{}/{key}", state.config.s3_bucket))?;
let mut reader = object.body.into_async_read();
let mut file = tokio::fs::File::create(path).await?;
tokio::io::copy(&mut reader, &mut file).await?;
Ok(())
}
#[derive(Default)]
struct ExifMeta {
orientation: u32,
taken_at: Option<DateTime<Utc>>,
}
async fn exif_metadata(path: &Path) -> anyhow::Result<ExifMeta> {
let output = tokio::process::Command::new("exiftool")
.args([
"-j",
"-d",
"%Y-%m-%dT%H:%M:%S",
"-Orientation#",
"-DateTimeOriginal",
"-CreateDate",
"-OffsetTimeOriginal",
"-OffsetTime",
])
.arg(path)
.output()
.await;
let output = match output {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
anyhow::bail!("exiftool is not installed or not on PATH")
}
other => other.context("running exiftool")?,
};
if !output.status.success() {
tracing::warn!(
"exiftool metadata read failed: {}",
String::from_utf8_lossy(&output.stderr)
);
return Ok(ExifMeta::default());
}
let parsed: Vec<serde_json::Value> =
serde_json::from_slice(&output.stdout).context("parsing exiftool json")?;
let entry = parsed.first().cloned().unwrap_or_default();
let orientation = entry
.get("Orientation")
.and_then(|v| v.as_u64())
.map(|v| v as u32)
.unwrap_or(1);
let offset = ["OffsetTimeOriginal", "OffsetTime"]
.iter()
.find_map(|field| entry.get(*field).and_then(|v| v.as_str()));
let taken_at = ["DateTimeOriginal", "CreateDate"]
.iter()
.filter_map(|field| entry.get(*field).and_then(|v| v.as_str()))
.find_map(|s| parse_exif_datetime(s, offset));
Ok(ExifMeta {
orientation,
taken_at,
})
}
/// EXIF datetimes are camera-local wall-clock time; apply the EXIF offset tag
/// when the camera recorded one, otherwise fall back to treating it as UTC.
fn parse_exif_datetime(s: &str, offset: Option<&str>) -> Option<DateTime<Utc>> {
if let Some(offset) = offset {
if let Ok(dt) = DateTime::parse_from_str(&format!("{s}{offset}"), "%Y-%m-%dT%H:%M:%S%:z") {
return Some(dt.with_timezone(&Utc));
}
}
NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S")
.ok()
.map(|naive| naive.and_utc())
}
/// Extract the largest embedded JPEG preview from a raw file using exiftool.
async fn extract_embedded_jpeg(path: &Path) -> anyhow::Result<Vec<u8>> {
let mut best: Vec<u8> = Vec::new();
for tag in ["-JpgFromRaw", "-PreviewImage", "-OtherImage", "-ThumbnailImage"] {
let output = tokio::process::Command::new("exiftool")
.args(["-b", tag])
.arg(path)
.output()
.await
.context("running exiftool")?;
if output.status.success() && output.stdout.len() > best.len() {
best = output.stdout;
}
// A full-size embedded preview is comfortably above this; stop early.
if best.len() > 200_000 {
break;
}
}
anyhow::ensure!(
best.len() > 1_000,
"no usable embedded preview found in raw file"
);
Ok(best)
}
fn render(bytes: &[u8], orientation: u32) -> anyhow::Result<(Vec<u8>, Vec<u8>, u32, u32)> {
let img = image::load_from_memory(bytes).context("decoding image")?;
let img = apply_orientation(img, orientation);
let (width, height) = (img.width(), img.height());
let preview = if width.max(height) > PREVIEW_EDGE {
img.resize(PREVIEW_EDGE, PREVIEW_EDGE, FilterType::Triangle)
} else {
img
};
let thumb = preview.resize(THUMB_EDGE, THUMB_EDGE, FilterType::Lanczos3);
Ok((
encode_jpeg(&preview, 86)?,
encode_jpeg(&thumb, 82)?,
width,
height,
))
}
fn encode_jpeg(img: &DynamicImage, quality: u8) -> anyhow::Result<Vec<u8>> {
let rgb = img.to_rgb8();
let mut buf = Cursor::new(Vec::new());
let encoder = JpegEncoder::new_with_quality(&mut buf, quality);
rgb.write_with_encoder(encoder).context("encoding jpeg")?;
Ok(buf.into_inner())
}
fn apply_orientation(img: DynamicImage, orientation: u32) -> DynamicImage {
match orientation {
2 => img.fliph(),
3 => img.rotate180(),
4 => img.flipv(),
5 => img.rotate90().fliph(),
6 => img.rotate90(),
7 => img.rotate270().fliph(),
8 => img.rotate270(),
_ => img,
}
}
+329
View File
@@ -0,0 +1,329 @@
use std::time::Duration;
use uuid::Uuid;
use crate::models::{JobKind, JobStatus, PhotoStatus};
use crate::state::AppState;
/// Hard ceiling on a single job run; the sole bound for a live-but-hung worker
/// (a hung S3 read or exiftool child), since the heartbeat keeps the reaper away.
const JOB_TIMEOUT: Duration = Duration::from_secs(30 * 60);
/// How often a running job refreshes its lock. Must stay well below the
/// reaper's staleness threshold.
const HEARTBEAT_EVERY: Duration = Duration::from_secs(300);
/// A 'running' job whose lock is older than this had its worker die.
const STALE_AFTER: &str = "15 minutes";
#[derive(Debug, sqlx::FromRow)]
pub struct Job {
pub id: Uuid,
pub kind: String,
pub payload: serde_json::Value,
pub attempts: i32,
pub max_attempts: i32,
}
pub async fn enqueue<'e, E>(
executor: E,
kind: JobKind,
payload: serde_json::Value,
) -> Result<(), sqlx::Error>
where
E: sqlx::PgExecutor<'e>,
{
sqlx::query("insert into jobs (kind, payload) values ($1, $2)")
.bind(kind.as_str())
.bind(payload)
.execute(executor)
.await?;
Ok(())
}
/// Make sure a process_photo job will (re)run for this photo: bump a queued
/// one to run now with fresh attempts; leave a running one alone (resetting
/// its attempts wouldn't reach the in-flight worker, which decides exhaustion
/// from its claim-time copy — it will finish or fail on its own and the photo
/// can be retried again); enqueue fresh otherwise.
pub async fn ensure_process_photo(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
photo_id: Uuid,
) -> Result<(), sqlx::Error> {
let requeued = sqlx::query(
"update jobs set run_at = now(), attempts = 0
where kind = $2 and status = $3 and payload->>'photo_id' = $1",
)
.bind(photo_id.to_string())
.bind(JobKind::ProcessPhoto.as_str())
.bind(JobStatus::Queued.as_str())
.execute(&mut **tx)
.await?;
if requeued.rows_affected() > 0 {
return Ok(());
}
let running: Option<(Uuid,)> = sqlx::query_as(
"select id from jobs
where kind = $2 and status = $3 and payload->>'photo_id' = $1",
)
.bind(photo_id.to_string())
.bind(JobKind::ProcessPhoto.as_str())
.bind(JobStatus::Running.as_str())
.fetch_optional(&mut **tx)
.await?;
if running.is_some() {
return Ok(());
}
enqueue(
&mut **tx,
JobKind::ProcessPhoto,
serde_json::json!({ "photo_id": photo_id }),
)
.await
}
pub async fn run_worker(state: AppState) {
let concurrency = state.config.worker_concurrency.max(1);
// Unique per process so locked_by distinguishes workers across replicas.
let instance = crate::auth::random_token(6);
tracing::info!("starting worker {instance} with concurrency {concurrency}");
let mut handles = Vec::new();
handles.push(tokio::spawn(reaper_loop(state.clone())));
for i in 0..concurrency {
let state = state.clone();
handles.push(tokio::spawn(worker_loop(state, format!("worker-{instance}-{i}"))));
}
for handle in handles {
let _ = handle.await;
}
}
/// Requeue stale jobs whose worker died mid-run — but only while they have
/// attempts left; exhausted stale jobs are failed outright so a job that
/// crashes its worker (e.g. OOM during decode) cannot crash-loop forever.
async fn reaper_loop(state: AppState) {
loop {
let failed: Result<Vec<(String, serde_json::Value)>, sqlx::Error> = sqlx::query_as(&format!(
"update jobs set status = $1, locked_by = null,
last_error = coalesce(last_error, 'worker lost repeatedly (crash loop?)')
where (status = $2 and locked_at < now() - interval '{STALE_AFTER}'
or status = $3)
and attempts >= max_attempts
returning kind, payload"
))
.bind(JobStatus::Failed.as_str())
.bind(JobStatus::Running.as_str())
.bind(JobStatus::Queued.as_str())
.fetch_all(&state.db)
.await;
match failed {
Ok(jobs) => {
for (kind, payload) in jobs {
tracing::error!(kind, "reaper failed exhausted job");
mark_photo_error(&state, &kind, &payload, "processing failed repeatedly").await;
}
}
Err(e) => tracing::error!("job reaper (fail pass) errored: {e}"),
}
let requeued = sqlx::query(&format!(
"update jobs set status = $1, locked_by = null, locked_at = null
where status = $2 and locked_at < now() - interval '{STALE_AFTER}'
and attempts < max_attempts"
))
.bind(JobStatus::Queued.as_str())
.bind(JobStatus::Running.as_str())
.execute(&state.db)
.await;
match requeued {
Ok(r) if r.rows_affected() > 0 => {
tracing::warn!("requeued {} stale running job(s)", r.rows_affected())
}
Ok(_) => {}
Err(e) => tracing::error!("job reaper (requeue pass) errored: {e}"),
}
tokio::time::sleep(Duration::from_secs(60)).await;
}
}
/// Terminal-failure side effect for process_photo jobs: surface the error on
/// the photo, but never overwrite a photo a newer job already finished.
async fn mark_photo_error(state: &AppState, kind: &str, payload: &serde_json::Value, message: &str) {
if kind != JobKind::ProcessPhoto.as_str() {
return;
}
let Some(photo_id) = payload
.get("photo_id")
.and_then(|v| v.as_str())
.and_then(|s| Uuid::parse_str(s).ok())
else {
return;
};
// A job can die before its first status write, so rescue photos stuck in
// 'uploaded' as well as 'processing' — but never overwrite 'ready'.
let _ = sqlx::query(
"update photos set status = $3, error = $2
where id = $1 and status in ($4, $5)",
)
.bind(photo_id)
.bind(message)
.bind(PhotoStatus::Error.as_str())
.bind(PhotoStatus::Processing.as_str())
.bind(PhotoStatus::Uploaded.as_str())
.execute(&state.db)
.await;
}
async fn worker_loop(state: AppState, name: String) {
loop {
match claim(&state, &name).await {
Ok(Some(job)) => execute(&state, job, &name).await,
Ok(None) => tokio::time::sleep(Duration::from_secs(2)).await,
Err(e) => {
tracing::error!("failed to claim job: {e}");
tokio::time::sleep(Duration::from_secs(5)).await;
}
}
}
}
async fn claim(state: &AppState, name: &str) -> Result<Option<Job>, sqlx::Error> {
sqlx::query_as(
"update jobs
set status = $2, locked_by = $1, locked_at = now(), attempts = attempts + 1
where id = (
select id from jobs
where status = $3 and run_at <= now() and attempts < max_attempts
order by created_at
limit 1
for update skip locked
)
returning id, kind, payload, attempts, max_attempts",
)
.bind(name)
.bind(JobStatus::Running.as_str())
.bind(JobStatus::Queued.as_str())
.fetch_optional(&state.db)
.await
}
/// Keep locked_at fresh while a job runs so the reaper never requeues a job
/// whose worker is alive. Never completes; raced against the job in select!.
async fn heartbeat(state: &AppState, job_id: Uuid, name: &str) {
loop {
tokio::time::sleep(HEARTBEAT_EVERY).await;
let _ = sqlx::query(
"update jobs set locked_at = now()
where id = $1 and locked_by = $2 and status = $3",
)
.bind(job_id)
.bind(name)
.bind(JobStatus::Running.as_str())
.execute(&state.db)
.await;
}
}
async fn execute(state: &AppState, job: Job, name: &str) {
tracing::info!(job_id = %job.id, kind = %job.kind, attempt = job.attempts, "job started");
// Parse the kind here (not at claim decode) so an unknown kind — e.g.
// enqueued by a newer deploy — fails THIS job normally instead of
// poisoning the claim loop.
let run = async {
match job.kind.parse::<JobKind>() {
Ok(JobKind::ProcessPhoto) => {
crate::imaging::process_photo_job(state, &job.payload).await
}
Ok(JobKind::DeleteS3Prefix) => delete_s3_prefix_job(state, &job.payload).await,
Err(e) => Err(anyhow::anyhow!(e)),
}
};
let result = tokio::select! {
result = tokio::time::timeout(JOB_TIMEOUT, run) => match result {
Ok(result) => result,
Err(_) => Err(anyhow::anyhow!(
"job timed out after {}s",
JOB_TIMEOUT.as_secs()
)),
},
_ = heartbeat(state, job.id, name) => unreachable!("heartbeat never completes"),
};
// Finalization is guarded on locked_by so a worker whose job was reclaimed
// (reaper) cannot overwrite the state written by the new owner.
match result {
Ok(()) => {
let updated = sqlx::query(
"update jobs set status = $3, locked_by = null, last_error = null
where id = $1 and locked_by = $2 and status = $4",
)
.bind(job.id)
.bind(name)
.bind(JobStatus::Done.as_str())
.bind(JobStatus::Running.as_str())
.execute(&state.db)
.await;
match updated {
Ok(r) if r.rows_affected() == 0 => {
tracing::warn!(job_id = %job.id, "job was reclaimed by another worker; result discarded")
}
Ok(_) => tracing::info!(job_id = %job.id, kind = %job.kind, "job done"),
Err(e) => tracing::error!(job_id = %job.id, "failed to finalize job: {e}"),
}
}
Err(e) => {
let message = format!("{e:#}");
let exhausted = job.attempts >= job.max_attempts;
tracing::error!(job_id = %job.id, kind = %job.kind, exhausted, "job failed: {message}");
// Two self-contained query+bind branches — the placeholder lists
// and bind chains must never be shared across branches.
let finalize = if exhausted {
sqlx::query(
"update jobs set status = $4, locked_by = null, last_error = $2
where id = $1 and locked_by = $3 and status = $5",
)
.bind(job.id)
.bind(&message)
.bind(name)
.bind(JobStatus::Failed.as_str())
.bind(JobStatus::Running.as_str())
} else {
let backoff = 30.0 * f64::from(job.attempts * job.attempts);
sqlx::query(
"update jobs
set status = $4, locked_by = null, last_error = $2,
run_at = now() + make_interval(secs => $6)
where id = $1 and locked_by = $3 and status = $5",
)
.bind(job.id)
.bind(&message)
.bind(name)
.bind(JobStatus::Queued.as_str())
.bind(JobStatus::Running.as_str())
.bind(backoff)
};
let owned = match finalize.execute(&state.db).await {
Ok(r) => r.rows_affected() > 0,
Err(e) => {
tracing::error!(job_id = %job.id, "failed to finalize job: {e}");
false
}
};
if owned && exhausted {
mark_photo_error(state, &job.kind, &job.payload, &message).await;
}
}
}
}
async fn delete_s3_prefix_job(
state: &AppState,
payload: &serde_json::Value,
) -> anyhow::Result<()> {
let prefix = payload
.get("prefix")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("missing prefix in payload"))?;
anyhow::ensure!(
prefix.starts_with("photos/") && prefix.ends_with('/'),
"refusing to delete suspicious prefix {prefix:?}"
);
crate::s3::delete_prefix(state, prefix).await
}
+9
View File
@@ -0,0 +1,9 @@
pub mod auth;
pub mod config;
pub mod error;
pub mod imaging;
pub mod jobs;
pub mod models;
pub mod routes;
pub mod s3;
pub mod state;
+136
View File
@@ -0,0 +1,136 @@
use chrono::{DateTime, Utc};
use serde::Serialize;
use uuid::Uuid;
/// Stored as text in Postgres; decoded via TryFrom so an unknown value is a
/// loud decode error instead of a silently misbehaving string.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum PhotoStatus {
Uploaded,
Processing,
Ready,
Error,
}
impl PhotoStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::Uploaded => "uploaded",
Self::Processing => "processing",
Self::Ready => "ready",
Self::Error => "error",
}
}
}
impl std::str::FromStr for PhotoStatus {
type Err = String;
fn from_str(value: &str) -> Result<Self, String> {
match value {
"uploaded" => Ok(Self::Uploaded),
"processing" => Ok(Self::Processing),
"ready" => Ok(Self::Ready),
"error" => Ok(Self::Error),
other => Err(format!("unknown photo status: {other}")),
}
}
}
// #[sqlx(try_from = "String")] needs TryFrom; delegate to FromStr.
impl TryFrom<String> for PhotoStatus {
type Error = String;
fn try_from(value: String) -> Result<Self, String> {
value.parse()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JobKind {
ProcessPhoto,
DeleteS3Prefix,
}
impl JobKind {
pub fn as_str(self) -> &'static str {
match self {
Self::ProcessPhoto => "process_photo",
Self::DeleteS3Prefix => "delete_s3_prefix",
}
}
}
impl std::str::FromStr for JobKind {
type Err = String;
fn from_str(value: &str) -> Result<Self, String> {
match value {
"process_photo" => Ok(Self::ProcessPhoto),
"delete_s3_prefix" => Ok(Self::DeleteS3Prefix),
other => Err(format!("unknown job kind: {other}")),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JobStatus {
Queued,
Running,
Done,
Failed,
}
impl JobStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::Queued => "queued",
Self::Running => "running",
Self::Done => "done",
Self::Failed => "failed",
}
}
}
#[derive(Debug, Clone, sqlx::FromRow, Serialize)]
pub struct Album {
pub id: Uuid,
pub name: String,
pub description: String,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, sqlx::FromRow, Serialize)]
pub struct Photo {
pub id: Uuid,
pub album_id: Uuid,
pub filename: String,
pub content_type: String,
pub size_bytes: i64,
#[sqlx(try_from = "String")]
pub status: PhotoStatus,
pub error: Option<String>,
pub width: Option<i32>,
pub height: Option<i32>,
pub taken_at: Option<DateTime<Utc>>,
pub processed_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, sqlx::FromRow, Serialize)]
pub struct Share {
pub id: Uuid,
pub album_id: Uuid,
pub token: String,
pub label: String,
#[serde(skip_serializing)]
pub password_hash: Option<String>,
pub allow_download: bool,
pub expires_at: Option<DateTime<Utc>>,
#[serde(skip_serializing)]
pub failed_attempts: i32,
#[serde(skip_serializing)]
pub locked_until: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
}
+216
View File
@@ -0,0 +1,216 @@
use std::collections::HashMap;
use axum::extract::{Path, State};
use axum::Json;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::error::{ApiError, ApiResult};
use crate::models::{Album, JobKind, Photo, PhotoStatus};
use crate::state::AppState;
#[derive(Serialize, sqlx::FromRow)]
pub struct AlbumListItem {
pub id: Uuid,
pub name: String,
pub description: String,
pub created_at: DateTime<Utc>,
pub photo_count: i64,
pub cover_photo_id: Option<Uuid>,
pub cover_processed_at: Option<DateTime<Utc>>,
}
pub async fn list(
State(state): State<AppState>,
) -> ApiResult<Json<Vec<AlbumListItem>>> {
let albums: Vec<AlbumListItem> = sqlx::query_as(
"select a.id, a.name, a.description, a.created_at,
(select count(*) from photos p where p.album_id = a.id) as photo_count,
c.id as cover_photo_id, c.processed_at as cover_processed_at
from albums a
left join lateral (
select p.id, p.processed_at from photos p
where p.album_id = a.id and p.status = $1
order by coalesce(p.taken_at, p.created_at), p.filename
limit 1
) c on true
order by a.created_at desc",
)
.bind(PhotoStatus::Ready.as_str())
.fetch_all(&state.db)
.await?;
Ok(Json(albums))
}
#[derive(Deserialize)]
pub struct CreateAlbum {
name: String,
#[serde(default)]
description: String,
}
pub async fn create(
State(state): State<AppState>,
Json(body): Json<CreateAlbum>,
) -> ApiResult<Json<Album>> {
let name = body.name.trim();
if name.is_empty() {
return Err(ApiError::bad_request("album name is required"));
}
let album: Album =
sqlx::query_as("insert into albums (name, description) values ($1, $2) returning *")
.bind(name)
.bind(body.description.trim())
.fetch_one(&state.db)
.await?;
Ok(Json(album))
}
#[derive(Serialize)]
pub struct ShareRating {
pub share_label: String,
pub rating: i32,
}
#[derive(Serialize)]
pub struct ShareTag {
pub share_label: String,
pub tag: String,
}
#[derive(Serialize, Default)]
pub struct PhotoFeedback {
pub ratings: Vec<ShareRating>,
pub tags: Vec<ShareTag>,
}
#[derive(Serialize)]
pub struct AlbumDetail {
pub album: Album,
pub photos: Vec<Photo>,
pub feedback: HashMap<Uuid, PhotoFeedback>,
}
pub async fn get_one(
State(state): State<AppState>,
Path(album_id): Path<Uuid>,
) -> ApiResult<Json<AlbumDetail>> {
let album: Album = sqlx::query_as("select * from albums where id = $1")
.bind(album_id)
.fetch_one(&state.db)
.await?;
let photos: Vec<Photo> = sqlx::query_as(
"select * from photos where album_id = $1
order by coalesce(taken_at, created_at), filename",
)
.bind(album_id)
.fetch_all(&state.db)
.await?;
let mut feedback: HashMap<Uuid, PhotoFeedback> = HashMap::new();
let ratings: Vec<(Uuid, String, i32)> = sqlx::query_as(
"select r.photo_id, s.label, r.rating
from ratings r join shares s on s.id = r.share_id
where s.album_id = $1",
)
.bind(album_id)
.fetch_all(&state.db)
.await?;
for (photo_id, share_label, rating) in ratings {
feedback
.entry(photo_id)
.or_default()
.ratings
.push(ShareRating {
share_label,
rating,
});
}
let tags: Vec<(Uuid, String, String)> = sqlx::query_as(
"select t.photo_id, s.label, t.tag
from tags t join shares s on s.id = t.share_id
where s.album_id = $1
order by t.created_at",
)
.bind(album_id)
.fetch_all(&state.db)
.await?;
for (photo_id, share_label, tag) in tags {
feedback
.entry(photo_id)
.or_default()
.tags
.push(ShareTag { share_label, tag });
}
Ok(Json(AlbumDetail {
album,
photos,
feedback,
}))
}
#[derive(Deserialize)]
pub struct UpdateAlbum {
name: Option<String>,
description: Option<String>,
}
pub async fn update(
State(state): State<AppState>,
Path(album_id): Path<Uuid>,
Json(body): Json<UpdateAlbum>,
) -> ApiResult<Json<Album>> {
if let Some(name) = &body.name {
if name.trim().is_empty() {
return Err(ApiError::bad_request("album name cannot be empty"));
}
}
let album: Album = sqlx::query_as(
"update albums
set name = coalesce($2, name), description = coalesce($3, description)
where id = $1
returning *",
)
.bind(album_id)
.bind(body.name.as_deref().map(str::trim))
.bind(body.description.as_deref().map(str::trim))
.fetch_one(&state.db)
.await?;
Ok(Json(album))
}
pub async fn delete(
State(state): State<AppState>,
Path(album_id): Path<Uuid>,
) -> ApiResult<Json<serde_json::Value>> {
let mut tx = state.db.begin().await?;
// Lock the album row: concurrent uploads block on their FK check against
// it, then fail once it's gone and clean up their own S3 objects — so no
// photo can slip in between the cleanup enqueue and the cascade delete.
let locked: Option<(Uuid,)> = sqlx::query_as("select id from albums where id = $1 for update")
.bind(album_id)
.fetch_optional(&mut *tx)
.await?;
if locked.is_none() {
return Err(ApiError::not_found());
}
// Delete photos and enqueue their S3 cleanup atomically, in one statement.
sqlx::query(
"with deleted as (delete from photos where album_id = $1 returning id)
insert into jobs (kind, payload)
select $2, jsonb_build_object('prefix', 'photos/' || id || '/')
from deleted",
)
.bind(album_id)
.bind(JobKind::DeleteS3Prefix.as_str())
.execute(&mut *tx)
.await?;
sqlx::query("delete from albums where id = $1")
.bind(album_id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(Json(serde_json::json!({ "ok": true })))
}
+398
View File
@@ -0,0 +1,398 @@
use std::collections::HashMap;
use argon2::{Argon2, PasswordHash, PasswordVerifier};
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::Json;
use axum_extra::extract::cookie::SignedCookieJar;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::error::{ApiError, ApiResult};
use crate::models::{PhotoStatus, Share};
use crate::state::AppState;
pub async fn load_share(state: &AppState, token: &str) -> Result<Share, ApiError> {
let share: Option<Share> = sqlx::query_as("select * from shares where token = $1")
.bind(token)
.fetch_optional(&state.db)
.await?;
let share = share.ok_or_else(ApiError::not_found)?;
if share.expires_at.map(|e| e < Utc::now()).unwrap_or(false) {
return Err(ApiError::gone("this link has expired"));
}
Ok(share)
}
/// One signed cookie lists every share id this browser has been granted
/// (by viewing a passwordless share or unlocking a protected one). Image and
/// download requests are authorized from it, so URLs carry no token.
const SHARE_ACCESS_COOKIE: &str = "photos_shares";
const MAX_REMEMBERED_SHARES: usize = 20;
pub fn share_ids_from_jar(jar: &SignedCookieJar) -> Vec<Uuid> {
jar.get(SHARE_ACCESS_COOKIE)
.map(|c| {
c.value()
.split(',')
.filter_map(|s| Uuid::parse_str(s).ok())
.collect()
})
.unwrap_or_default()
}
/// Add a share to the browser's access cookie (most recent first). Always
/// re-issues the cookie so the 30-day expiry slides on every visit instead of
/// being fixed at the first one.
fn grant_access(state: &AppState, jar: SignedCookieJar, share_id: Uuid) -> SignedCookieJar {
let mut ids = share_ids_from_jar(&jar);
ids.retain(|id| *id != share_id);
ids.insert(0, share_id);
ids.truncate(MAX_REMEMBERED_SHARES);
let value = ids
.iter()
.map(Uuid::to_string)
.collect::<Vec<_>>()
.join(",");
let mut cookie =
crate::auth::base_cookie(SHARE_ACCESS_COOKIE, value, state.config.cookie_secure());
cookie.set_max_age(time::Duration::days(30));
jar.add(cookie)
}
pub fn is_unlocked(jar: &SignedCookieJar, share: &Share) -> bool {
share.password_hash.is_none() || share_ids_from_jar(jar).contains(&share.id)
}
/// Cookie-based authorization for image/download requests, which carry no
/// share token: any remembered, still-valid share covering the album grants
/// access (and must allow downloads when `need_download`).
pub async fn authorize_album_via_cookie(
state: &AppState,
jar: &SignedCookieJar,
album_id: Uuid,
need_download: bool,
) -> Result<(), ApiError> {
let ids = share_ids_from_jar(jar);
if ids.is_empty() {
return Err(ApiError::unauthorized());
}
let shares: Vec<Share> =
sqlx::query_as("select * from shares where album_id = $1 and id = any($2)")
.bind(album_id)
.bind(&ids)
.fetch_all(&state.db)
.await?;
let now = Utc::now();
let valid: Vec<&Share> = shares
.iter()
.filter(|s| s.expires_at.map(|e| e > now).unwrap_or(true))
.collect();
if valid.is_empty() {
return Err(ApiError::unauthorized());
}
if need_download && !valid.iter().any(|s| s.allow_download) {
return Err(ApiError::forbidden("downloads are disabled for this link"));
}
Ok(())
}
/// The one place client-share access policy lives: token valid + not expired,
/// unlocked, and (for download endpoints) downloads enabled.
pub async fn authorize_share(
state: &AppState,
jar: &SignedCookieJar,
token: &str,
need_download: bool,
) -> Result<Share, ApiError> {
let share = load_share(state, token).await?;
if !is_unlocked(jar, &share) {
return Err(ApiError::unauthorized());
}
if need_download && !share.allow_download {
return Err(ApiError::forbidden("downloads are disabled for this link"));
}
Ok(share)
}
fn require_unlocked(jar: &SignedCookieJar, share: &Share) -> Result<(), ApiError> {
if is_unlocked(jar, share) {
Ok(())
} else {
Err(ApiError(
StatusCode::UNAUTHORIZED,
"password required".into(),
))
}
}
#[derive(Serialize, sqlx::FromRow)]
struct ClientPhotoRow {
id: Uuid,
filename: String,
size_bytes: i64,
width: Option<i32>,
height: Option<i32>,
taken_at: Option<DateTime<Utc>>,
processed_at: Option<DateTime<Utc>>,
my_rating: Option<i32>,
}
#[derive(Serialize)]
struct ClientPhoto {
#[serde(flatten)]
row: ClientPhotoRow,
my_tags: Vec<String>,
}
#[derive(Serialize)]
pub struct ShareView {
label: String,
album_name: String,
album_description: String,
locked: bool,
allow_download: bool,
photos: Vec<ClientPhoto>,
}
pub async fn get_share(
State(state): State<AppState>,
Path(token): Path<String>,
jar: SignedCookieJar,
) -> ApiResult<(SignedCookieJar, Json<ShareView>)> {
let share = load_share(&state, &token).await?;
let (album_name, album_description): (String, String) =
sqlx::query_as("select name, description from albums where id = $1")
.bind(share.album_id)
.fetch_one(&state.db)
.await?;
if !is_unlocked(&jar, &share) {
return Ok((
jar,
Json(ShareView {
label: share.label,
album_name,
album_description,
locked: true,
allow_download: share.allow_download,
photos: vec![],
}),
));
}
// Grant this browser image/download access for the share's album.
let jar = grant_access(&state, jar, share.id);
let rows: Vec<ClientPhotoRow> = sqlx::query_as(
"select p.id, p.filename, p.size_bytes, p.width, p.height, p.taken_at, p.processed_at, r.rating as my_rating
from photos p
left join ratings r on r.photo_id = p.id and r.share_id = $2
where p.album_id = $1 and p.status = $3
order by coalesce(p.taken_at, p.created_at), p.filename",
)
.bind(share.album_id)
.bind(share.id)
.bind(PhotoStatus::Ready.as_str())
.fetch_all(&state.db)
.await?;
let tag_rows: Vec<(Uuid, String)> =
sqlx::query_as("select photo_id, tag from tags where share_id = $1 order by created_at")
.bind(share.id)
.fetch_all(&state.db)
.await?;
let mut tag_map: HashMap<Uuid, Vec<String>> = HashMap::new();
for (photo_id, tag) in tag_rows {
tag_map.entry(photo_id).or_default().push(tag);
}
let photos = rows
.into_iter()
.map(|row| {
let my_tags = tag_map.remove(&row.id).unwrap_or_default();
ClientPhoto { row, my_tags }
})
.collect();
Ok((
jar,
Json(ShareView {
label: share.label,
album_name,
album_description,
locked: false,
allow_download: share.allow_download,
photos,
}),
))
}
#[derive(Deserialize)]
pub struct UnlockBody {
password: String,
}
const MAX_UNLOCK_ATTEMPTS: i32 = 10;
pub async fn unlock(
State(state): State<AppState>,
Path(token): Path<String>,
jar: SignedCookieJar,
Json(body): Json<UnlockBody>,
) -> ApiResult<(SignedCookieJar, StatusCode)> {
let share = load_share(&state, &token).await?;
let Some(hash) = share.password_hash.clone() else {
return Ok((jar, StatusCode::NO_CONTENT));
};
if let Some(locked_until) = share.locked_until {
if locked_until > Utc::now() {
return Err(ApiError(
StatusCode::TOO_MANY_REQUESTS,
"too many attempts — try again in a few minutes".into(),
));
}
// The lock window has passed: grant a fresh set of attempts, so a
// legitimate client isn't re-locked by their next single typo.
sqlx::query("update shares set failed_attempts = 0, locked_until = null where id = $1")
.bind(share.id)
.execute(&state.db)
.await?;
}
// Argon2 is deliberately slow; keep it off the async runtime threads.
let password = body.password;
let verified = tokio::task::spawn_blocking(move || {
let parsed = PasswordHash::new(&hash).map_err(|e| anyhow::anyhow!("bad hash: {e}"))?;
Ok::<_, anyhow::Error>(
Argon2::default()
.verify_password(password.as_bytes(), &parsed)
.is_ok(),
)
})
.await
.map_err(|e| anyhow::anyhow!("verify task failed: {e}"))??;
if !verified {
sqlx::query(
"update shares
set failed_attempts = failed_attempts + 1,
locked_until = case when failed_attempts + 1 >= $2
then now() + interval '15 minutes'
else locked_until end
where id = $1",
)
.bind(share.id)
.bind(MAX_UNLOCK_ATTEMPTS)
.execute(&state.db)
.await?;
return Err(ApiError(StatusCode::UNAUTHORIZED, "wrong password".into()));
}
sqlx::query("update shares set failed_attempts = 0, locked_until = null where id = $1")
.bind(share.id)
.execute(&state.db)
.await?;
Ok((grant_access(&state, jar, share.id), StatusCode::NO_CONTENT))
}
async fn share_photo(
state: &AppState,
jar: &SignedCookieJar,
token: &str,
photo_id: Uuid,
) -> Result<Share, ApiError> {
let share = load_share(state, token).await?;
require_unlocked(jar, &share)?;
let exists: Option<(Uuid,)> = sqlx::query_as(
"select id from photos where id = $1 and album_id = $2 and status = $3",
)
.bind(photo_id)
.bind(share.album_id)
.bind(PhotoStatus::Ready.as_str())
.fetch_optional(&state.db)
.await?;
if exists.is_none() {
return Err(ApiError::not_found());
}
Ok(share)
}
#[derive(Deserialize)]
pub struct RatingBody {
rating: i32,
}
pub async fn set_rating(
State(state): State<AppState>,
Path((token, photo_id)): Path<(String, Uuid)>,
jar: SignedCookieJar,
Json(body): Json<RatingBody>,
) -> ApiResult<Json<serde_json::Value>> {
if !(0..=5).contains(&body.rating) {
return Err(ApiError::bad_request("rating must be between 0 and 5"));
}
let share = share_photo(&state, &jar, &token, photo_id).await?;
if body.rating == 0 {
sqlx::query("delete from ratings where share_id = $1 and photo_id = $2")
.bind(share.id)
.bind(photo_id)
.execute(&state.db)
.await?;
} else {
sqlx::query(
"insert into ratings (share_id, photo_id, rating) values ($1, $2, $3)
on conflict (share_id, photo_id)
do update set rating = excluded.rating, updated_at = now()",
)
.bind(share.id)
.bind(photo_id)
.bind(body.rating)
.execute(&state.db)
.await?;
}
Ok(Json(serde_json::json!({ "ok": true })))
}
#[derive(Deserialize)]
pub struct TagsBody {
tags: Vec<String>,
}
pub async fn set_tags(
State(state): State<AppState>,
Path((token, photo_id)): Path<(String, Uuid)>,
jar: SignedCookieJar,
Json(body): Json<TagsBody>,
) -> ApiResult<Json<serde_json::Value>> {
let share = share_photo(&state, &jar, &token, photo_id).await?;
let mut tags: Vec<String> = Vec::new();
for tag in body.tags {
let tag = tag.trim().to_lowercase();
if tag.is_empty() || tag.chars().count() > 40 {
continue;
}
if !tags.contains(&tag) {
tags.push(tag);
}
if tags.len() >= 20 {
break;
}
}
let mut tx = state.db.begin().await?;
sqlx::query("delete from tags where share_id = $1 and photo_id = $2")
.bind(share.id)
.bind(photo_id)
.execute(&mut *tx)
.await?;
if !tags.is_empty() {
sqlx::query("insert into tags (share_id, photo_id, tag) select $1, $2, unnest($3::text[])")
.bind(share.id)
.bind(photo_id)
.bind(&tags)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(Json(serde_json::json!({ "ok": true, "tags": tags })))
}
+105
View File
@@ -0,0 +1,105 @@
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
}
+114
View File
@@ -0,0 +1,114 @@
pub mod albums;
pub mod client;
pub mod images;
pub mod photos;
pub mod shares;
pub mod zip;
use axum::extract::{DefaultBodyLimit, Request, State};
use axum::middleware::{self, Next};
use axum::response::{IntoResponse, Response};
use axum::routing::{any, delete, get, post, put};
use axum::{Json, Router};
use axum_extra::extract::cookie::SignedCookieJar;
use crate::auth;
use crate::error::ApiError;
use crate::state::AppState;
const MAX_UPLOAD_BYTES: usize = 4 * 1024 * 1024 * 1024;
async fn health() -> Json<serde_json::Value> {
Json(serde_json::json!({ "ok": true }))
}
/// Unknown /api paths must 404 as JSON, not fall through to the SPA's index.html.
async fn api_not_found() -> ApiError {
ApiError::not_found()
}
/// Wrong method on a known route gets a JSON 405 (not an empty body).
async fn method_not_allowed() -> ApiError {
ApiError(
axum::http::StatusCode::METHOD_NOT_ALLOWED,
"method not allowed".into(),
)
}
/// Every route in the admin router passes through this layer, so photographer
/// auth is structural — a new admin endpoint cannot be forgotten open. The
/// authenticated user is stored in request extensions for handlers that need
/// the identity.
async fn require_photographer(
State(state): State<AppState>,
mut request: Request,
next: Next,
) -> Result<Response, ApiError> {
let jar = SignedCookieJar::from_headers(request.headers(), state.cookie_key.clone());
let user = auth::user_from_jar(&state, &jar).ok_or_else(ApiError::unauthorized)?;
request.extensions_mut().insert(user);
// Slide the session: past half-life, responses carry a fresh cookie.
let refreshed = auth::refreshed_session(&state, &jar);
let response = next.run(request).await;
Ok(match refreshed {
Some(cookie) => (jar.add(cookie), response).into_response(),
None => response,
})
}
pub fn router(state: &AppState) -> Router<AppState> {
// Photographer-only surface. Add new admin endpoints HERE — the auth
// layer covers them automatically.
let admin = Router::new()
.route("/api/me", get(auth::me))
.route("/api/albums", get(albums::list).post(albums::create))
.route(
"/api/albums/{id}",
get(albums::get_one)
.patch(albums::update)
.delete(albums::delete),
)
.route(
"/api/albums/{id}/photos",
post(photos::upload).layer(DefaultBodyLimit::max(MAX_UPLOAD_BYTES)),
)
.route(
"/api/albums/{id}/shares",
get(shares::list).post(shares::create),
)
.route("/api/albums/{id}/zip", post(zip::album_zip))
.route("/api/photos/{id}", delete(photos::delete))
.route("/api/photos/{id}/reprocess", post(photos::reprocess))
.route("/api/shares/{id}", delete(shares::delete))
.route("/api/shares/{id}/reset-lock", post(shares::reset_lock))
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_photographer,
));
// Public surface: health, the auth flow itself, and client-share
// endpoints (self-authorizing via token or share-access cookie).
Router::new()
.route("/api/health", get(health))
.route("/api/auth/login", get(auth::login))
.route("/api/auth/callback", get(auth::callback))
.route("/api/auth/logout", post(auth::logout))
.route("/api/share/{token}", get(client::get_share))
.route("/api/share/{token}/unlock", post(client::unlock))
.route(
"/api/share/{token}/photos/{photo_id}/rating",
put(client::set_rating),
)
.route(
"/api/share/{token}/photos/{photo_id}/tags",
put(client::set_tags),
)
.route("/api/share/{token}/zip", post(zip::share_zip))
// Dual-auth (photographer session OR share cookie), checked in-handler:
.route("/api/img/{id}/{size}", get(images::serve))
.route("/api/photos/{id}/original", get(images::original))
.merge(admin)
.route("/api", any(api_not_found))
.route("/api/{*path}", any(api_not_found))
.method_not_allowed_fallback(method_not_allowed)
}
+174
View File
@@ -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 })))
}
+159
View File
@@ -0,0 +1,159 @@
use argon2::password_hash::rand_core::OsRng;
use argon2::password_hash::SaltString;
use argon2::{Argon2, PasswordHasher};
use axum::extract::{Path, State};
use axum::Json;
use chrono::{DateTime, Utc};
use serde::Deserialize;
use uuid::Uuid;
use crate::auth::random_token;
use crate::error::{ApiError, ApiResult};
use crate::state::AppState;
#[derive(sqlx::FromRow)]
struct ShareAdminRow {
id: Uuid,
token: String,
label: String,
password_hash: Option<String>,
allow_download: bool,
expires_at: Option<DateTime<Utc>>,
locked_until: Option<DateTime<Utc>>,
created_at: DateTime<Utc>,
rating_count: i64,
tag_count: i64,
}
fn share_json(state: &AppState, row: &ShareAdminRow) -> serde_json::Value {
serde_json::json!({
"id": row.id,
"token": row.token,
"url": format!("{}/s/{}", state.config.public_url, row.token),
"label": row.label,
"has_password": row.password_hash.is_some(),
"allow_download": row.allow_download,
"expires_at": row.expires_at,
"locked": row.locked_until.map(|t| t > Utc::now()).unwrap_or(false),
"created_at": row.created_at,
"rating_count": row.rating_count,
"tag_count": row.tag_count,
})
}
const SHARE_COLUMNS: &str = "s.id, s.token, s.label, s.password_hash, s.allow_download,
s.expires_at, s.locked_until, s.created_at,
(select count(*) from ratings r where r.share_id = s.id) as rating_count,
(select count(*) from tags t where t.share_id = s.id) as tag_count";
pub async fn list(
State(state): State<AppState>,
Path(album_id): Path<Uuid>,
) -> ApiResult<Json<Vec<serde_json::Value>>> {
let rows: Vec<ShareAdminRow> = sqlx::query_as(&format!(
"select {SHARE_COLUMNS} from shares s where s.album_id = $1 order by s.created_at desc"
))
.bind(album_id)
.fetch_all(&state.db)
.await?;
Ok(Json(rows.iter().map(|r| share_json(&state, r)).collect()))
}
#[derive(Deserialize)]
pub struct CreateShare {
#[serde(default)]
label: String,
password: Option<String>,
#[serde(default = "default_true")]
allow_download: bool,
expires_at: Option<DateTime<Utc>>,
}
fn default_true() -> bool {
true
}
pub async fn create(
State(state): State<AppState>,
Path(album_id): Path<Uuid>,
Json(body): Json<CreateShare>,
) -> ApiResult<Json<serde_json::Value>> {
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 password_hash = match body.password.as_deref().map(str::trim) {
Some(pw) if !pw.is_empty() => {
// Argon2 is deliberately slow; keep it off the async runtime threads.
let pw = pw.to_string();
let hash = tokio::task::spawn_blocking(move || {
let salt = SaltString::generate(&mut OsRng);
Argon2::default()
.hash_password(pw.as_bytes(), &salt)
.map(|h| h.to_string())
.map_err(|e| anyhow::anyhow!("hashing password: {e}"))
})
.await
.map_err(|e| anyhow::anyhow!("hash task failed: {e}"))??;
Some(hash)
}
_ => None,
};
let token = random_token(24);
let (share_id,): (Uuid,) = sqlx::query_as(
"insert into shares (album_id, token, label, password_hash, allow_download, expires_at)
values ($1, $2, $3, $4, $5, $6)
returning id",
)
.bind(album_id)
.bind(&token)
.bind(body.label.trim())
.bind(&password_hash)
.bind(body.allow_download)
.bind(body.expires_at)
.fetch_one(&state.db)
.await?;
let row: ShareAdminRow =
sqlx::query_as(&format!("select {SHARE_COLUMNS} from shares s where s.id = $1"))
.bind(share_id)
.fetch_one(&state.db)
.await?;
Ok(Json(share_json(&state, &row)))
}
/// Clear a share's password-lockout state (e.g. after a client fat-fingered
/// their way into the 15-minute lock).
pub async fn reset_lock(
State(state): State<AppState>,
Path(share_id): Path<Uuid>,
) -> ApiResult<Json<serde_json::Value>> {
let updated =
sqlx::query("update shares set failed_attempts = 0, locked_until = null where id = $1")
.bind(share_id)
.execute(&state.db)
.await?;
if updated.rows_affected() == 0 {
return Err(ApiError::not_found());
}
Ok(Json(serde_json::json!({ "ok": true })))
}
pub async fn delete(
State(state): State<AppState>,
Path(share_id): Path<Uuid>,
) -> ApiResult<Json<serde_json::Value>> {
let deleted = sqlx::query("delete from shares where id = $1")
.bind(share_id)
.execute(&state.db)
.await?;
if deleted.rows_affected() == 0 {
return Err(ApiError::not_found());
}
Ok(Json(serde_json::json!({ "ok": true })))
}
+423
View File
@@ -0,0 +1,423 @@
//! 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,
}
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,
});
}
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())
}
/// Download an original into an anonymous temp file, computing its CRC-32 and
/// verifying the byte count matches what the zip plan promised.
fn spool(state: &AppState, key: String, expected_size: u64) -> JoinHandle<anyhow::Result<(tokio::fs::File, u32)>> {
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}"))?;
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((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: spool the next object from S3 while streaming the current one.
let mut pending: Option<JoinHandle<anyhow::Result<(tokio::fs::File, u32)>>> = None;
for (i, entry) in plan.entries.iter().enumerate() {
let current = match pending.take() {
Some(handle) => handle,
None => spool(state, entry.s3_key.clone(), entry.size),
};
if let Some(next) = plan.entries.get(i + 1) {
pending = Some(spool(state, next.s3_key.clone(), next.size));
}
let (mut file, crc) = current
.await
.map_err(|e| anyhow::anyhow!("spool task failed: {e}"))?
.map_err(|e| anyhow::anyhow!("photo {}: {e:#}", entry.photo_id))?;
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?;
tokio::io::copy(&mut file, &mut out).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(())
}
+113
View File
@@ -0,0 +1,113 @@
use std::path::Path;
use aws_sdk_s3::config::{BehaviorVersion, Credentials, Region};
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{Delete, ObjectIdentifier};
use aws_sdk_s3::Client;
use uuid::Uuid;
use crate::config::Config;
use crate::state::AppState;
pub fn client(config: &Config) -> Client {
let credentials = Credentials::new(
&config.s3_access_key,
&config.s3_secret_key,
None,
None,
"photos-config",
);
let mut builder = aws_sdk_s3::Config::builder()
.behavior_version(BehaviorVersion::latest())
.credentials_provider(credentials)
.region(Region::new(config.s3_region.clone()))
.force_path_style(config.s3_force_path_style);
if let Some(endpoint) = &config.s3_endpoint {
builder = builder.endpoint_url(endpoint);
}
Client::from_conf(builder.build())
}
pub fn original_key(photo_id: Uuid, filename: &str) -> String {
format!("photos/{photo_id}/original/{filename}")
}
pub fn preview_key(photo_id: Uuid) -> String {
format!("photos/{photo_id}/preview.jpg")
}
pub fn thumb_key(photo_id: Uuid) -> String {
format!("photos/{photo_id}/thumb.jpg")
}
pub fn photo_prefix(photo_id: Uuid) -> String {
format!("photos/{photo_id}/")
}
pub async fn put_file(
state: &AppState,
key: &str,
path: &Path,
content_type: &str,
) -> anyhow::Result<()> {
let body = ByteStream::from_path(path).await?;
state
.s3
.put_object()
.bucket(&state.config.s3_bucket)
.key(key)
.content_type(content_type)
.body(body)
.send()
.await?;
Ok(())
}
pub async fn put_bytes(
state: &AppState,
key: &str,
bytes: Vec<u8>,
content_type: &str,
) -> anyhow::Result<()> {
state
.s3
.put_object()
.bucket(&state.config.s3_bucket)
.key(key)
.content_type(content_type)
.body(ByteStream::from(bytes))
.send()
.await?;
Ok(())
}
pub async fn delete_prefix(state: &AppState, prefix: &str) -> anyhow::Result<()> {
loop {
let list = state
.s3
.list_objects_v2()
.bucket(&state.config.s3_bucket)
.prefix(prefix)
.send()
.await?;
let objects: Vec<ObjectIdentifier> = list
.contents()
.iter()
.filter_map(|o| o.key())
.map(|k| ObjectIdentifier::builder().key(k).build())
.collect::<Result<_, _>>()?;
if objects.is_empty() {
return Ok(());
}
state
.s3
.delete_objects()
.bucket(&state.config.s3_bucket)
.delete(Delete::builder().set_objects(Some(objects)).build()?)
.send()
.await?;
if !list.is_truncated().unwrap_or(false) {
return Ok(());
}
}
}
+51
View File
@@ -0,0 +1,51 @@
use std::sync::Arc;
use axum::extract::FromRef;
use axum_extra::extract::cookie::Key;
use sha2::{Digest, Sha512};
use sqlx::PgPool;
use tokio::sync::OnceCell;
use crate::auth::OidcDiscovery;
use crate::config::Config;
#[derive(Clone)]
pub struct AppState {
pub db: PgPool,
pub s3: aws_sdk_s3::Client,
pub http: reqwest::Client,
pub config: Arc<Config>,
pub cookie_key: Key,
pub oidc: Arc<OnceCell<OidcDiscovery>>,
/// Caps concurrent zip streams — each holds S3 connections for its
/// duration, and slow readers would otherwise pin them indefinitely.
pub zip_permits: Arc<tokio::sync::Semaphore>,
}
impl FromRef<AppState> for Key {
fn from_ref(state: &AppState) -> Key {
state.cookie_key.clone()
}
}
impl AppState {
pub async fn new(config: Config) -> anyhow::Result<Self> {
let db = sqlx::postgres::PgPoolOptions::new()
.max_connections(10)
.connect(&config.database_url)
.await?;
sqlx::migrate!("./migrations").run(&db).await?;
let s3 = crate::s3::client(&config);
// SHA-512 digest is exactly the 64 bytes Key::from requires.
let cookie_key = Key::from(&Sha512::digest(config.session_secret.as_bytes()));
Ok(Self {
db,
s3,
http: reqwest::Client::new(),
config: Arc::new(config),
cookie_key,
oidc: Arc::new(OnceCell::new()),
zip_permits: Arc::new(tokio::sync::Semaphore::new(4)),
})
}
}