diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 9cad17a..d2d75d5 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.4" @@ -32,6 +38,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "argon2" version = "0.5.3" @@ -205,6 +220,8 @@ dependencies = [ "tower_governor", "tracing", "tracing-subscriber", + "utoipa", + "utoipa-swagger-ui", ] [[package]] @@ -424,6 +441,15 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-queue" version = "0.3.12" @@ -499,6 +525,17 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "diff" version = "0.1.13" @@ -672,6 +709,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "miniz_oxide", + "zlib-rs", +] + [[package]] name = "flume" version = "0.11.1" @@ -1538,6 +1585,16 @@ dependencies = [ "unicase", ] +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.0" @@ -2333,6 +2390,40 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rust-embed" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa" +dependencies = [ + "proc-macro2", + "quote", + "rust-embed-utils", + "syn", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" +dependencies = [ + "sha2", + "walkdir", +] + [[package]] name = "rust-multipart-rfc7578_2" version = "0.9.0" @@ -2681,6 +2772,12 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "simd_cesu8" version = "1.1.1" @@ -3424,6 +3521,48 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utoipa" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bde15df68e80b16c7d16b9616e80770ad158988daa56a27dccd1e55558b0160" +dependencies = [ + "indexmap", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba0b99ee52df3028635d93840c797102da61f8a7bb3cf751032455895b52ef8" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn", +] + +[[package]] +name = "utoipa-swagger-ui" +version = "9.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d047458f1b5b65237c2f6dc6db136945667f40a7668627b3490b9513a3d43a55" +dependencies = [ + "axum", + "base64", + "mime_guess", + "regex", + "rust-embed", + "serde", + "serde_json", + "url", + "utoipa", + "zip", +] + [[package]] name = "uuid" version = "1.23.1" @@ -4113,8 +4252,40 @@ dependencies = [ "syn", ] +[[package]] +name = "zip" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12598812502ed0105f607f941c386f43d441e00148fce9dec3ca5ffb0bde9308" +dependencies = [ + "arbitrary", + "crc32fast", + "flate2", + "indexmap", + "memchr", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/backend/Cargo.toml b/backend/Cargo.toml index dbc8b62..3ef5e20 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -23,6 +23,8 @@ rand = "0.10.1" futures-util = "0.3.32" mime_guess = "2.0.5" tower_governor = { version = "0.8.0", default-features = false, features = ["axum", "tracing"] } +utoipa = { version = "5.5.0", features = ["axum_extras", "chrono"] } +utoipa-swagger-ui = { version = "9.0.2", features = ["axum"] } [dev-dependencies] axum-test = "20.0.0" diff --git a/backend/src/main.rs b/backend/src/main.rs index 294226d..e2c64bc 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -24,14 +24,16 @@ use tower_http::{ }; use tracing::info; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; +use utoipa::OpenApi; +use utoipa_swagger_ui::SwaggerUi; use crate::{ model::{FileRepository, UserRepository}, web::{ mw_auth::{mw_ctx_resolver, mw_require_auth}, - routes_file::routes_file, - routes_health::routes_health, - routes_login::routes_auth, + routes_file::{FileApi, routes_file}, + routes_health::{HealthApi, routes_health}, + routes_login::{AuthApi, routes_auth}, }, }; @@ -84,10 +86,15 @@ async fn main() -> Result<()> { routes_auth = routes_auth.layer(GovernorLayer::new(governor_conf_auth)); } + let mut openapi = AuthApi::openapi(); + openapi.merge(FileApi::openapi()); + openapi.merge(HealthApi::openapi()); + let app = Router::new() .nest("/api", routes_file) .nest("/api/auth", routes_auth) .merge(routes_health()) + .merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", openapi)) .layer(TraceLayer::new_for_http()) .layer(middleware::from_fn_with_state( user_repository, diff --git a/backend/src/model.rs b/backend/src/model.rs index 16fbba3..375108e 100644 --- a/backend/src/model.rs +++ b/backend/src/model.rs @@ -6,7 +6,7 @@ use sqlx::{PgPool, prelude::FromRow}; use crate::error::{LoftError, Result}; -#[derive(Clone, Debug, Serialize, FromRow)] +#[derive(Clone, Debug, Serialize, FromRow, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct FileRecord { pub id: i64, diff --git a/backend/src/web/routes_file.rs b/backend/src/web/routes_file.rs index 1d4bb87..994af5a 100644 --- a/backend/src/web/routes_file.rs +++ b/backend/src/web/routes_file.rs @@ -7,6 +7,7 @@ use axum::{ routing::get, }; use sqlx::types::uuid; +use utoipa::OpenApi; use crate::{ ctx::Ctx, @@ -14,6 +15,20 @@ use crate::{ model::{FileRecord, FileRepository}, }; +#[derive(OpenApi)] +#[openapi( + paths( + list_files, + upload_file, + get_file, + delete_file, + download_file, + stream_part + ), + components(schemas(FileRecord)) +)] +pub struct FileApi; + pub fn routes_file(file_repository: FileRepository) -> Router { Router::new() .route("/files", get(list_files).post(upload_file)) @@ -23,11 +38,25 @@ pub fn routes_file(file_repository: FileRepository) -> Router { .with_state(file_repository) } +/// Upload a file +#[utoipa::path( + post, + path = "/api/files", + tag = "files", + security(("cookie_auth" = [])), + request_body(content_type = "multipart/form-data", description = "Multipart form with a `file` field"), + responses( + (status = 201, description = "Uploaded file metadata", body = FileRecord), + (status = 400, description = "No file provided or malformed upload"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error") + ) +)] async fn upload_file( State(file_repository): State, ctx: Ctx, mut multipart: Multipart, -) -> Result> { +) -> Result<(StatusCode, Json)> { let mut uploaded: Option<(String, String, usize)> = None; while let Some(field) = multipart.next_field().await? { @@ -45,9 +74,23 @@ async fn upload_file( .create_file_record(ctx.user_id(), &name, size, &key) .await?; - Ok(Json(file_record)) + Ok((StatusCode::CREATED, Json(file_record))) } +/// Get a file's metadata +#[utoipa::path( + get, + path = "/api/files/{id}", + tag = "files", + security(("cookie_auth" = [])), + params(("id" = u64, Path, description = "File id")), + responses( + (status = 200, description = "File metadata", body = FileRecord), + (status = 404, description = "File not found"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error") + ) +)] async fn get_file( State(file_repository): State, ctx: Ctx, @@ -59,6 +102,20 @@ async fn get_file( Ok(Json(record)) } +/// Download a file +#[utoipa::path( + get, + path = "/api/files/{id}/download", + tag = "files", + security(("cookie_auth" = [])), + params(("id" = u64, Path, description = "File id")), + responses( + (status = 200, description = "File bytes", content_type = "application/octet-stream"), + (status = 404, description = "File not found"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error") + ) +)] async fn download_file( State(file_repository): State, ctx: Ctx, @@ -70,6 +127,24 @@ async fn download_file( Ok(Body::from_stream(stream)) } +/// Stream a byte range of a file (HTTP 206) +#[utoipa::path( + get, + path = "/api/files/{id}/stream_part", + tag = "files", + security(("cookie_auth" = [])), + params( + ("id" = u64, Path, description = "File id"), + ("Range" = Option, Header, description = "Byte range, e.g. bytes=0-1023") + ), + responses( + (status = 206, description = "Partial content"), + (status = 400, description = "Invalid range"), + (status = 404, description = "File not found"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error") + ) +)] async fn stream_part( State(file_repository): State, ctx: Ctx, @@ -117,6 +192,20 @@ fn parse_range(headers: &HeaderMap, file_size: u64) -> Result<(u64, u64)> { Ok((start, end)) } +/// Delete a file +#[utoipa::path( + delete, + path = "/api/files/{id}", + tag = "files", + security(("cookie_auth" = [])), + params(("id" = u64, Path, description = "File id")), + responses( + (status = 200, description = "Deleted file metadata", body = FileRecord), + (status = 404, description = "File not found"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error") + ) +)] async fn delete_file( State(file_repository): State, ctx: Ctx, @@ -128,6 +217,18 @@ async fn delete_file( Ok(Json(file)) } +/// List the current user's files +#[utoipa::path( + get, + path = "/api/files", + tag = "files", + security(("cookie_auth" = [])), + responses( + (status = 200, description = "List user's files", body = Vec), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error") + ) +)] async fn list_files( State(file_repository): State, ctx: Ctx, @@ -138,7 +239,11 @@ async fn list_files( #[cfg(test)] mod tests { - use axum::{Router, http::header, middleware}; + use axum::{ + Router, + http::{StatusCode, header}, + middleware, + }; use axum_test::{ TestServer, multipart::{MultipartForm, Part}, @@ -284,7 +389,7 @@ mod tests { )) .await; - res.assert_status_ok(); + res.assert_status(StatusCode::CREATED); let file = res.json::(); assert_eq!(file["name"], "a.jpg"); diff --git a/backend/src/web/routes_health.rs b/backend/src/web/routes_health.rs index 6175b50..1e2c633 100644 --- a/backend/src/web/routes_health.rs +++ b/backend/src/web/routes_health.rs @@ -1,9 +1,21 @@ use axum::{Router, routing::get}; +use utoipa::OpenApi; + +#[derive(OpenApi)] +#[openapi(paths(health))] +pub struct HealthApi; pub fn routes_health() -> Router { Router::new().route("/health", get(health)) } +/// Health check +#[utoipa::path( + get, + path = "/health", + tag = "health", + responses((status = 200, description = "Service is healthy")) +)] async fn health() -> &'static str { "up" } diff --git a/backend/src/web/routes_login.rs b/backend/src/web/routes_login.rs index dfdb843..b3193f2 100644 --- a/backend/src/web/routes_login.rs +++ b/backend/src/web/routes_login.rs @@ -4,9 +4,13 @@ use argon2::{ }; use axum::{Json, Router, extract::State, http::StatusCode, routing::post}; use rand::RngExt; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use tower_cookies::{Cookie, Cookies}; use tracing::warn; +use utoipa::{ + Modify, OpenApi, + openapi::security::{ApiKey, ApiKeyValue, SecurityScheme}, +}; use crate::{ error::{LoftError, Result}, @@ -14,6 +18,32 @@ use crate::{ web::AUTH_TOKEN, }; +#[derive(OpenApi)] +#[openapi( + info( + title = "loft API", + description = "Self-hosted media storage (Axum + SvelteKit)" + ), + modifiers(&SecurityAddon), + paths(login, logout, register), + components(schemas(LoginPayload)) +)] +pub struct AuthApi; + +/// Registers the session-cookie auth scheme so protected endpoints show as secured. +struct SecurityAddon; + +impl Modify for SecurityAddon { + fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { + if let Some(components) = openapi.components.as_mut() { + components.add_security_scheme( + "cookie_auth", + SecurityScheme::ApiKey(ApiKey::Cookie(ApiKeyValue::new(AUTH_TOKEN))), + ); + } + } +} + pub fn routes_auth(user_repository: UserRepository) -> Router { Router::new() .route("/login", post(login)) @@ -22,6 +52,18 @@ pub fn routes_auth(user_repository: UserRepository) -> Router { .with_state(user_repository) } +/// Log in and start a session +#[utoipa::path( + post, + path = "/api/auth/login", + tag = "auth", + request_body = LoginPayload, + responses( + (status = 200, description = "Login successful"), + (status = 401, description = "Login failed"), + (status = 500, description = "Internal server error") + ) +)] async fn login( State(user_repository): State, cookies: Cookies, @@ -50,6 +92,17 @@ async fn login( Ok(StatusCode::OK) } +/// Log out and clear the session +#[utoipa::path( + post, + path = "/api/auth/logout", + tag = "auth", + // request_body = LoginPayload, + responses( + (status = 200, description = "Logout successful"), + (status = 500, description = "Internal server error") + ) +)] async fn logout( State(user_repository): State, cookies: Cookies, @@ -64,6 +117,18 @@ async fn logout( Ok(StatusCode::OK) } +/// Register a new account +#[utoipa::path( + post, + path = "/api/auth/register", + tag = "auth", + // request_body = LoginPayload, + responses( + (status = 201, description = "Registration successful"), + (status = 401, description = "Registration failed"), + (status = 500, description = "Internal server error") + ) +)] async fn register( State(user_repository): State, Json(payload): Json, @@ -76,7 +141,7 @@ async fn register( warn!( "Register fail, username {} already exists", &payload.username - ); // also fix "Login fail" typo + ); return Err(LoftError::RegisterFail); } @@ -108,7 +173,7 @@ fn create_cookie() -> Cookie<'static> { .build() } -#[derive(Debug, Deserialize)] +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] struct LoginPayload { username: String, password: String,