feat(gateway): serve the mod jar under GET /downloads/*
This commit is contained in:
parent
192ef52eed
commit
54af71589a
8 changed files with 127 additions and 2 deletions
|
|
@ -21,6 +21,9 @@ ACCOUNTS_GRPC_URL=http://127.0.0.1:50051
|
||||||
CONFIGS_HTTP_URL=http://127.0.0.1:8082
|
CONFIGS_HTTP_URL=http://127.0.0.1:8082
|
||||||
SITE_ORIGIN=http://localhost:5173
|
SITE_ORIGIN=http://localhost:5173
|
||||||
TRUST_PROXY=false
|
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-service
|
||||||
CONFIGS_DATABASE_URL=postgres://lovisual:lovisual@localhost:5432/configs_db
|
CONFIGS_DATABASE_URL=postgres://lovisual:lovisual@localhost:5432/configs_db
|
||||||
CONFIGS_PORT=8082
|
CONFIGS_PORT=8082
|
||||||
|
|
|
||||||
25
backend/Cargo.lock
generated
25
backend/Cargo.lock
generated
|
|
@ -1869,6 +1869,12 @@ dependencies = [
|
||||||
"pin-project-lite",
|
"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]]
|
[[package]]
|
||||||
name = "httparse"
|
name = "httparse"
|
||||||
version = "1.10.1"
|
version = "1.10.1"
|
||||||
|
|
@ -2335,6 +2341,16 @@ version = "0.3.17"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
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]]
|
[[package]]
|
||||||
name = "miniz_oxide"
|
name = "miniz_oxide"
|
||||||
version = "0.8.9"
|
version = "0.8.9"
|
||||||
|
|
@ -4061,10 +4077,19 @@ checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
|
"futures-util",
|
||||||
"http 1.5.0",
|
"http 1.5.0",
|
||||||
"http-body 1.1.0",
|
"http-body 1.1.0",
|
||||||
|
"http-body-util",
|
||||||
|
"http-range-header",
|
||||||
|
"httpdate",
|
||||||
|
"mime",
|
||||||
|
"mime_guess",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,10 @@ services:
|
||||||
ACCOUNTS_HTTP_URL: http://accounts-service:8081
|
ACCOUNTS_HTTP_URL: http://accounts-service:8081
|
||||||
ACCOUNTS_GRPC_URL: http://accounts-service:50051
|
ACCOUNTS_GRPC_URL: http://accounts-service:50051
|
||||||
CONFIGS_HTTP_URL: http://configs-service:8082
|
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:
|
ports:
|
||||||
- "127.0.0.1:8080:8080"
|
- "127.0.0.1:8080:8080"
|
||||||
networks: [lovisual-internal]
|
networks: [lovisual-internal]
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ common = { path = "../common" }
|
||||||
axum = "0.8"
|
axum = "0.8"
|
||||||
http-body-util = "0.1"
|
http-body-util = "0.1"
|
||||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time"] }
|
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 = "0.1"
|
||||||
tracing-subscriber = "0.3"
|
tracing-subscriber = "0.3"
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,9 @@ pub struct Config {
|
||||||
pub accounts_grpc_url: String,
|
pub accounts_grpc_url: String,
|
||||||
pub configs_http_url: String,
|
pub configs_http_url: String,
|
||||||
pub site_origin: 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
|
/// Behind a reverse proxy (nginx/caddy) that appends the client IP to
|
||||||
/// X-Forwarded-For. Never enable when the gateway is exposed directly.
|
/// X-Forwarded-For. Never enable when the gateway is exposed directly.
|
||||||
pub trust_proxy: bool,
|
pub trust_proxy: bool,
|
||||||
|
|
@ -31,6 +34,7 @@ impl Config {
|
||||||
accounts_grpc_url: var("ACCOUNTS_GRPC_URL")?,
|
accounts_grpc_url: var("ACCOUNTS_GRPC_URL")?,
|
||||||
configs_http_url: var("CONFIGS_HTTP_URL")?,
|
configs_http_url: var("CONFIGS_HTTP_URL")?,
|
||||||
site_origin: var("SITE_ORIGIN")?,
|
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"),
|
trust_proxy: std::env::var("TRUST_PROXY").is_ok_and(|v| v == "true"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -68,6 +72,7 @@ mod tests {
|
||||||
accounts_grpc_url: String::new(),
|
accounts_grpc_url: String::new(),
|
||||||
configs_http_url: String::new(),
|
configs_http_url: String::new(),
|
||||||
site_origin: "http://localhost:5173".into(),
|
site_origin: "http://localhost:5173".into(),
|
||||||
|
downloads_dir: "downloads".into(),
|
||||||
trust_proxy: false,
|
trust_proxy: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ use axum::{
|
||||||
use config::Config;
|
use config::Config;
|
||||||
use identity::device::DeviceAuthenticator;
|
use identity::device::DeviceAuthenticator;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tower_http::cors::CorsLayer;
|
use tower_http::{cors::CorsLayer, services::ServeDir};
|
||||||
|
|
||||||
pub fn build_app(cfg: &Config, devices: Arc<dyn DeviceAuthenticator>) -> Router {
|
pub fn build_app(cfg: &Config, devices: Arc<dyn DeviceAuthenticator>) -> Router {
|
||||||
let upstreams = Arc::new(proxy::forward::Upstreams::new(cfg).expect("valid upstream config"));
|
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<dyn DeviceAuthenticator>) -> Router
|
||||||
// limit before it gets rejected, and CORS preflights skip all of it.
|
// limit before it gets rejected, and CORS preflights skip all of it.
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/health", get(|| async { "ok" }))
|
.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)
|
.fallback(proxy::forward::proxy)
|
||||||
.with_state(upstreams)
|
.with_state(upstreams)
|
||||||
.layer(axum::middleware::from_fn(identity::authorize))
|
.layer(axum::middleware::from_fn(identity::authorize))
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,10 @@ pub fn config(accounts: &str, configs: &str) -> Config {
|
||||||
accounts_grpc_url: String::new(),
|
accounts_grpc_url: String::new(),
|
||||||
configs_http_url: configs.into(),
|
configs_http_url: configs.into(),
|
||||||
site_origin: "http://localhost:5173".into(),
|
site_origin: "http://localhost:5173".into(),
|
||||||
|
downloads_dir: std::env::temp_dir()
|
||||||
|
.join("lovisual-downloads-test")
|
||||||
|
.display()
|
||||||
|
.to_string(),
|
||||||
trust_proxy: true,
|
trust_proxy: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
76
backend/gateway/tests/downloads.rs
Normal file
76
backend/gateway/tests/downloads.rs
Normal file
|
|
@ -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();
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue