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

116 lines
4.2 KiB
Rust

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/albums/{id}/photos/by-hash/{sha256}", get(photos::by_hash))
.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)
}