ci / docker (push) Successful in 16s
- 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
116 lines
4.2 KiB
Rust
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)
|
|
}
|