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 { 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, mut request: Request, next: Next, ) -> Result { 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 { // 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/delete", post(photos::delete_many)) .route("/api/photos/{id}", delete(photos::delete)) .route("/api/photos/{id}/reprocess", post(photos::reprocess)) .route( "/api/shares/{id}", delete(shares::delete).patch(shares::update), ) .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}/verdict", put(client::set_verdict), ) .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) }