From 54af71589ac92dcc12fa676e65940b49e3446a4b Mon Sep 17 00:00:00 2001 From: loki5512344 Date: Tue, 29 Sep 2026 18:39:39 +0200 Subject: [PATCH] feat(gateway): serve the mod jar under GET /downloads/* --- backend/.env.example | 3 ++ backend/Cargo.lock | 25 ++++++++++ backend/docker-compose.prod.yml | 4 ++ backend/gateway/Cargo.toml | 2 +- backend/gateway/src/config.rs | 5 ++ backend/gateway/src/lib.rs | 10 +++- backend/gateway/tests/common/mod.rs | 4 ++ backend/gateway/tests/downloads.rs | 76 +++++++++++++++++++++++++++++ 8 files changed, 127 insertions(+), 2 deletions(-) create mode 100644 backend/gateway/tests/downloads.rs diff --git a/backend/.env.example b/backend/.env.example index d7732e1..3680a1d 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -21,6 +21,9 @@ ACCOUNTS_GRPC_URL=http://127.0.0.1:50051 CONFIGS_HTTP_URL=http://127.0.0.1:8082 SITE_ORIGIN=http://localhost:5173 TRUST_PROXY=false +# Directory the gateway serves under GET /downloads/* (lovisual.jar lives here; +# docker-compose.prod.yml mounts it as /srv/downloads and sets the variable itself) +DOWNLOADS_DIR=downloads # configs-service CONFIGS_DATABASE_URL=postgres://lovisual:lovisual@localhost:5432/configs_db CONFIGS_PORT=8082 diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 4d37549..f81faa7 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1869,6 +1869,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + [[package]] name = "httparse" version = "1.10.1" @@ -2335,6 +2341,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 = "miniz_oxide" version = "0.8.9" @@ -4061,10 +4077,19 @@ checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c" dependencies = [ "bitflags", "bytes", + "futures-core", + "futures-util", "http 1.5.0", "http-body 1.1.0", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", "percent-encoding", "pin-project-lite", + "tokio", + "tokio-util", "tower-layer", "tower-service", "tracing", diff --git a/backend/docker-compose.prod.yml b/backend/docker-compose.prod.yml index f955079..ca1be57 100644 --- a/backend/docker-compose.prod.yml +++ b/backend/docker-compose.prod.yml @@ -113,6 +113,10 @@ services: ACCOUNTS_HTTP_URL: http://accounts-service:8081 ACCOUNTS_GRPC_URL: http://accounts-service:50051 CONFIGS_HTTP_URL: http://configs-service:8082 + # Read-only static files served under GET /downloads/* (lovisual.jar). + DOWNLOADS_DIR: /srv/downloads + volumes: + - ./downloads:/srv/downloads:ro ports: - "127.0.0.1:8080:8080" networks: [lovisual-internal] diff --git a/backend/gateway/Cargo.toml b/backend/gateway/Cargo.toml index 1d47895..f6f0d09 100644 --- a/backend/gateway/Cargo.toml +++ b/backend/gateway/Cargo.toml @@ -12,7 +12,7 @@ common = { path = "../common" } axum = "0.8" http-body-util = "0.1" tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time"] } -tower-http = { version = "0.7", features = ["cors", "trace"] } +tower-http = { version = "0.7", features = ["cors", "trace", "fs"] } tracing = "0.1" tracing-subscriber = "0.3" serde_json = "1" diff --git a/backend/gateway/src/config.rs b/backend/gateway/src/config.rs index b44cb23..8b52f0b 100644 --- a/backend/gateway/src/config.rs +++ b/backend/gateway/src/config.rs @@ -9,6 +9,9 @@ pub struct Config { pub accounts_grpc_url: String, pub configs_http_url: String, pub site_origin: String, + /// Directory served read-only under `GET /downloads/*` — today just + /// `lovisual.jar`, the mod's direct download (see TODO.md «Скачивание»). + pub downloads_dir: String, /// Behind a reverse proxy (nginx/caddy) that appends the client IP to /// X-Forwarded-For. Never enable when the gateway is exposed directly. pub trust_proxy: bool, @@ -31,6 +34,7 @@ impl Config { accounts_grpc_url: var("ACCOUNTS_GRPC_URL")?, configs_http_url: var("CONFIGS_HTTP_URL")?, site_origin: var("SITE_ORIGIN")?, + downloads_dir: std::env::var("DOWNLOADS_DIR").unwrap_or_else(|_| "downloads".into()), trust_proxy: std::env::var("TRUST_PROXY").is_ok_and(|v| v == "true"), }) } @@ -68,6 +72,7 @@ mod tests { accounts_grpc_url: String::new(), configs_http_url: String::new(), site_origin: "http://localhost:5173".into(), + downloads_dir: "downloads".into(), trust_proxy: false, } } diff --git a/backend/gateway/src/lib.rs b/backend/gateway/src/lib.rs index f525abd..d9800eb 100644 --- a/backend/gateway/src/lib.rs +++ b/backend/gateway/src/lib.rs @@ -15,7 +15,7 @@ use axum::{ use config::Config; use identity::device::DeviceAuthenticator; use std::sync::Arc; -use tower_http::cors::CorsLayer; +use tower_http::{cors::CorsLayer, services::ServeDir}; pub fn build_app(cfg: &Config, devices: Arc) -> Router { let upstreams = Arc::new(proxy::forward::Upstreams::new(cfg).expect("valid upstream config")); @@ -45,6 +45,14 @@ pub fn build_app(cfg: &Config, devices: Arc) -> Router // limit before it gets rejected, and CORS preflights skip all of it. Router::new() .route("/health", get(|| async { "ok" })) + // The mod jar: one flat file from DOWNLOADS_DIR, streamed (no buffering + // of a ~60 MB body in memory). ServeDir resolves paths inside that + // directory only and 404s anything else; the dot-segment guard above + // already refuses `..`. + .nest_service( + "/downloads", + ServeDir::new(&cfg.downloads_dir).append_index_html_on_directories(false), + ) .fallback(proxy::forward::proxy) .with_state(upstreams) .layer(axum::middleware::from_fn(identity::authorize)) diff --git a/backend/gateway/tests/common/mod.rs b/backend/gateway/tests/common/mod.rs index 2d42024..9b80286 100644 --- a/backend/gateway/tests/common/mod.rs +++ b/backend/gateway/tests/common/mod.rs @@ -21,6 +21,10 @@ pub fn config(accounts: &str, configs: &str) -> Config { accounts_grpc_url: String::new(), configs_http_url: configs.into(), site_origin: "http://localhost:5173".into(), + downloads_dir: std::env::temp_dir() + .join("lovisual-downloads-test") + .display() + .to_string(), trust_proxy: true, } } diff --git a/backend/gateway/tests/downloads.rs b/backend/gateway/tests/downloads.rs new file mode 100644 index 0000000..ac32d51 --- /dev/null +++ b/backend/gateway/tests/downloads.rs @@ -0,0 +1,76 @@ +mod common; + +use axum::http::StatusCode; + +/// Fresh per-test downloads directory so parallel tests never share a file. +async fn server_with(downloads_dir: &std::path::Path) -> axum_test::TestServer { + let accounts = common::spawn_echo().await; + let configs = common::spawn_echo().await; + let mut cfg = common::config(&accounts, &configs); + cfg.downloads_dir = downloads_dir.display().to_string(); + let app = gateway::build_app(&cfg, common::no_devices()); + axum_test::TestServer::new(app) +} + +/// A clean per-test directory inside the gateway's downloads root. +fn dir(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("lv-downloads-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("create downloads dir"); + dir +} + +#[tokio::test] +async fn serves_the_mod_jar_as_a_download() { + let dir = dir("serve"); + std::fs::write(dir.join("lovisual.jar"), b"fake-jar-bytes").expect("write jar"); + let server = server_with(&dir).await; + + let res = server.get("/downloads/lovisual.jar").await; + res.assert_status(StatusCode::OK); + assert_eq!( + res.headers()["content-type"], + "application/java-archive", + "the jar keeps its own media type" + ); + assert_eq!(res.headers()["content-length"], "14"); + assert_eq!(res.text(), "fake-jar-bytes"); + std::fs::remove_dir_all(&dir).ok(); +} + +#[tokio::test] +async fn missing_file_and_directory_index_are_404() { + let dir = dir("missing"); + let server = server_with(&dir).await; + + server + .get("/downloads/other.jar") + .await + .assert_status(StatusCode::NOT_FOUND); + // No index/SPA fallback under /downloads: a directory is never a download. + let res = server.get("/downloads/").await; + assert_ne!( + res.status_code(), + StatusCode::OK, + "directory must not be served" + ); + std::fs::remove_dir_all(&dir).ok(); +} + +#[tokio::test] +async fn dot_segment_traversal_is_rejected_before_the_file_is_touched() { + let dir = dir("traversal"); + std::fs::write(dir.join("lovisual.jar"), b"fake-jar-bytes").expect("write jar"); + let accounts = common::spawn_echo().await; + let configs = common::spawn_echo().await; + let mut cfg = common::config(&accounts, &configs); + cfg.downloads_dir = dir.display().to_string(); + let app = gateway::build_app(&cfg, common::no_devices()); + + // Raw request: an HTTP client would normalize `..` away before sending. + let (status, _headers, body) = + common::raw(&app, "GET", "/downloads/../../etc/passwd", "1.2.3.4").await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert!(!body.contains("fake-jar-bytes")); + std::fs::remove_dir_all(&dir).ok(); +}