diff --git a/Cargo.lock b/Cargo.lock index f9f0ad6..ab8dd37 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1135,6 +1135,25 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "h2" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.12.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -1277,6 +1296,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -1488,6 +1508,25 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -1690,6 +1729,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "mio" version = "1.1.0" @@ -3696,6 +3745,12 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + [[package]] name = "unicode-bidi" version = "0.3.18" @@ -4275,7 +4330,9 @@ dependencies = [ "chrono", "config", "database", + "include_dir", "migration", + "mime_guess", "sea-orm", "serde", "serde_json", diff --git a/apps/api/Cargo.toml b/apps/api/Cargo.toml index d5c120c..c4c76d4 100644 --- a/apps/api/Cargo.toml +++ b/apps/api/Cargo.toml @@ -7,7 +7,7 @@ edition = "2024" database = { path = "../../public/database" } migration = { path = "../../public/migration" } -axum = { version = "0.8.7", features = ["form", "http1", "json", "matched-path", "original-uri", "query", "tokio", "tower-log", "tracing", "macros"]} +axum = { version = "0.8.7", features = ["form", "http1", "http2", "json", "matched-path", "original-uri", "query", "tokio", "tower-log", "tracing", "macros"]} async-trait = { version = "0.1.89" } chrono = { version = "0.4.42", features = ["clock", "std", "oldtime", "wasmbind", "serde"] } config = { version = "0.15.19", features = ["toml", "json", "yaml", "ini", "ron", "json5", "convert-case", "async"] } @@ -18,3 +18,5 @@ tracing-subscriber = { version = "0.3.20", features = ["smallvec", "fmt", "ansi" serde_json = { version = "1.0.145", features = ["std"] } serde = { version = "1.0.228", features = ["std", "derive"] } sea-orm = { workspace = true } +include_dir = { version = "0.7.4" } +mime_guess = { version = "2.0.5" } diff --git a/apps/api/src/routes.rs b/apps/api/src/routes.rs index c0d22db..7fb0e6b 100644 --- a/apps/api/src/routes.rs +++ b/apps/api/src/routes.rs @@ -1,3 +1,6 @@ +mod api; +mod view; + use std::sync::Arc; use axum::{Extension, Router}; @@ -25,6 +28,10 @@ pub struct AppService { pub fn get_root_router(state: impl Into>) -> Router { let mut router = Router::new(); + router = router + .nest("/api", api::get_api_router()) + .merge(view::get_view_router()); + router = middlewares::apply_root_middleware(router); router = router.layer(Extension(state.into())); diff --git a/apps/api/src/routes/api.rs b/apps/api/src/routes/api.rs new file mode 100644 index 0000000..2924fe9 --- /dev/null +++ b/apps/api/src/routes/api.rs @@ -0,0 +1,11 @@ +use axum::{Router, response::IntoResponse, routing::any}; + +pub fn get_api_router() -> Router { + Router::new() + // explicit fallback for unmatched API routes + .route("/{*wildcard}", any(api_fallback_handler)) +} + +async fn api_fallback_handler() -> impl IntoResponse { + (axum::http::StatusCode::NOT_FOUND, "API route not found").into_response() +} diff --git a/apps/api/src/routes/view.rs b/apps/api/src/routes/view.rs new file mode 100644 index 0000000..ef61da1 --- /dev/null +++ b/apps/api/src/routes/view.rs @@ -0,0 +1,71 @@ +use axum::{ + Router, + body::Bytes, + extract::Path, + http::{StatusCode, header}, + response::IntoResponse, + routing::{MethodRouter, get}, +}; +use include_dir::{Dir, include_dir}; +use mime_guess::from_path; + +static DIST_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/../frontend/build/client"); +const INDEX_HTML_PATH: &str = "index.html"; +const INDEX_FILE_NOT_FOUND_HTML: &str = r#" + + + + + + 404 Not Found + + +

404 Not Found

+

The requested resource was not found on this server. Possibly the frontend build is missing or corrupted.

+ + +"#; + +pub fn get_view_router() -> Router { + Router::new() + // Serve the root index.html + .route("/", get(root_view_handler)) + .route( + "/{*wildcard}", + MethodRouter::new() + .get(|Path(path): Path| async move { view_handler(Some(path)).await }), + ) +} + +async fn root_view_handler() -> impl IntoResponse { + view_handler(None).await +} + +async fn view_handler(path: Option) -> impl IntoResponse { + // If path is empty, serve index.html + let incoming_path = if let Some(p) = path { + p.trim_start_matches('/').to_string() + } else { + INDEX_HTML_PATH.to_string() + }; + + let path = match DIST_DIR.get_file(&incoming_path) { + Some(_) => incoming_path, + None => INDEX_HTML_PATH.to_string(), + }; + + match DIST_DIR.get_file(&path) { + Some(file) => { + let mime = from_path(&path).first_or_octet_stream(); + let body: Bytes = Bytes::copy_from_slice(file.contents()); + ([(header::CONTENT_TYPE, mime.as_ref())], body).into_response() + } + // This should never happen, but just in case... + None => ( + StatusCode::NOT_FOUND, + [(header::CONTENT_TYPE, "text/plain")], + Bytes::from(INDEX_FILE_NOT_FOUND_HTML), + ) + .into_response(), + } +}