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:
+312
@@ -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 }))
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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://")
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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>,
|
||||
}
|
||||
@@ -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 })))
|
||||
}
|
||||
@@ -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 })))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 })))
|
||||
}
|
||||
@@ -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 })))
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user