diff options
| author | Nikolay Govorov <me@govorov.online> | 2026-01-31 07:33:13 +0000 |
|---|---|---|
| committer | Nikolay Govorov <me@govorov.online> | 2026-01-31 07:33:28 +0000 |
| commit | ea030e0dcdc9f282b3e3c96623e5e323b2a8b106 (patch) | |
| tree | 3f8a012bdeecc706142a65fb023536119432e2b4 | |
| parent | 9ac2a1efd7cc7c5201a9be4a387d415b06f3bc4a (diff) | |
| download | tar tar.gz tar.bz2 tar.lz tar.xz tar.zst zip | |
Unifies backends
Diffstat
| -rw-r--r-- | src/backends/go.rs | 128 | +42 −86 |
| -rw-r--r-- | src/backends/mod.rs | 12 | +12 −0 |
| -rw-r--r-- | src/backends/zig.rs | 128 | +42 −86 |
| -rw-r--r-- | src/config.rs | 16 | +16 −0 |
| -rw-r--r-- | src/controller_backend.rs | 95 | +95 −0 |
| -rw-r--r-- | src/controller_web.rs (renamed from src/web.rs) | 0 | +0 −0 |
| -rw-r--r-- | src/main.rs | 49 | +31 −18 |
7 files changed, 238 insertions, 190 deletions
diff --git a/src/backends/go.rs b/src/backends/go.rs index 9a241c5..df94df8 100644 --- a/src/backends/go.rs +++ b/src/backends/go.rs @@ -1,15 +1,46 @@ // SPDX-FileCopyrightText: 2026 Nikolay Govorov <me@govorov.online> // SPDX-License-Identifier: AGPL-3.0-or-later -use std::sync::Arc; - -use axum::{Router, body, extract, http, response, routing}; +use serde::Deserialize; use thiserror::Error; -use tracing::error; -use crate::config; -use crate::proxy; -use crate::storage; +use super::Backend; + +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct GoConfig { + pub enabled: bool, + pub upstream: String, +} + +impl Default for GoConfig { + fn default() -> Self { + Self { + enabled: true, + upstream: String::from("https://dl.google.com/go"), + } + } +} + +pub struct GoBackend { + config: GoConfig, + source: String, +} + +impl GoBackend { + pub fn new(config: GoConfig, source: String) -> Self { + Self { config, source } + } +} + +impl Backend for GoBackend { + const ID: &'static str = "go"; + + fn upstream_url(&self, filename: &str) -> Result<String, ()> { + let tarball = Tarball::parse(filename).map_err(|_| ())?; + Ok(tarball.upstream_url(&self.config.upstream, &self.source)) + } +} #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Archive { @@ -140,12 +171,8 @@ impl<'a> Tarball<'a> { } /// Builds the upstream URL for this tarball. - pub fn upstream_url(&self, source: &str) -> String { - // Use direct URL (go.dev/dl/ redirects to dl.google.com/go/) - format!( - "https://dl.google.com/go/{}?source={}", - self.filename, source - ) + pub fn upstream_url(&self, upstream: &str, source: &str) -> String { + format!("{}/{}?source={}", upstream, self.filename, source) } } @@ -168,77 +195,6 @@ fn parse_minor_with_release(s: &str) -> Result<(u32, ReleaseType), ParseError> { Ok((minor, ReleaseType::Stable)) } -pub struct GoController { - config: Arc<config::ConfigService>, - storage: Arc<storage::StorageService>, - upstream: Arc<proxy::ProxyService>, -} - -impl GoController { - pub fn new( - config: Arc<config::ConfigService>, - storage: Arc<storage::StorageService>, - upstream: Arc<proxy::ProxyService>, - ) -> Self { - Self { - config, - storage, - upstream, - } - } - - pub fn router(self: Arc<Self>) -> Router { - Router::new() - .route("/go/{filename}", routing::get(Self::handle)) - .with_state(self) - } - - async fn handle( - extract::State(controller): extract::State<Arc<Self>>, - extract::Path(filename): extract::Path<String>, - ) -> Result<response::Response, http::StatusCode> { - let tarball = Tarball::parse(&filename).map_err(|_| http::StatusCode::NOT_FOUND)?; - let url = tarball.upstream_url(controller.config.appname()); - - match controller.storage.get("go", &filename).await { - Ok(Some(entry)) => { - return Ok(Self::build_response( - http::StatusCode::OK, - entry.file_bytes.0, - )); - } - Ok(None) => {} - Err(err) => { - error!("failed get file from storage: {err}"); - return Err(http::StatusCode::INTERNAL_SERVER_ERROR); - } - } - - let entry = controller - .upstream - .fetch(proxy::DownloadRequest { url }) - .await?; - - match controller.storage.put("go", &filename, &entry.bytes).await { - Ok(()) => {} - Err(_) => { - return Err(http::StatusCode::INTERNAL_SERVER_ERROR); - } - } - - Ok(Self::build_response(http::StatusCode::OK, entry.bytes)) - } - - fn build_response(status: http::StatusCode, bytes: bytes::Bytes) -> response::Response { - response::Response::builder() - .status(status) - .header(http::header::CONTENT_TYPE, "application/octet-stream") - .header(http::header::CONTENT_LENGTH, bytes.len()) - .body(body::Body::from(bytes)) - .unwrap() - } -} - #[cfg(test)] mod tests { use super::*; @@ -333,10 +289,10 @@ mod tests { #[test] fn test_upstream_url() { let t = Tarball::parse("go1.25.6.linux-amd64.tar.gz").unwrap(); - let url = t.upstream_url("zorian"); + let url = t.upstream_url("https://dl.google.com/go", "zorian:test"); assert_eq!( url, - "https://dl.google.com/go/go1.25.6.linux-amd64.tar.gz?source=zorian" + "https://dl.google.com/go/go1.25.6.linux-amd64.tar.gz?source=zorian:test" ); } diff --git a/src/backends/mod.rs b/src/backends/mod.rs index 910005c..97ac397 100644 --- a/src/backends/mod.rs +++ b/src/backends/mod.rs @@ -3,3 +3,15 @@ pub mod go; pub mod zig; + +pub use go::{GoBackend, GoConfig}; +pub use zig::{ZigBackend, ZigConfig}; + +/// Trait for backend-specific logic (parsing, URL building). +pub trait Backend: Send + Sync + 'static { + /// Fixed unique identifier for storage + const ID: &'static str; + + /// Validates filename and returns the upstream URL. + fn upstream_url(&self, filename: &str) -> Result<String, ()>; +} diff --git a/src/backends/zig.rs b/src/backends/zig.rs index 9131511..424fd34 100644 --- a/src/backends/zig.rs +++ b/src/backends/zig.rs @@ -1,16 +1,47 @@ // SPDX-FileCopyrightText: 2026 Nikolay Govorov <me@govorov.online> // SPDX-License-Identifier: AGPL-3.0-or-later -use std::sync::Arc; - -use axum::{Router, body, extract, http, response, routing}; use semver::Version; +use serde::Deserialize; use thiserror::Error; -use tracing::error; -use crate::config; -use crate::proxy; -use crate::storage; +use super::Backend; + +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct ZigConfig { + pub enabled: bool, + pub upstream: String, +} + +impl Default for ZigConfig { + fn default() -> Self { + Self { + enabled: true, + upstream: String::from("https://ziglang.org"), + } + } +} + +pub struct ZigBackend { + config: ZigConfig, + source: String, +} + +impl ZigBackend { + pub fn new(config: ZigConfig, source: String) -> Self { + Self { config, source } + } +} + +impl Backend for ZigBackend { + const ID: &'static str = "zig"; + + fn upstream_url(&self, filename: &str) -> Result<String, ()> { + let tarball = Tarball::parse(filename).map_err(|_| ())?; + Ok(tarball.upstream_url(&self.config.upstream, &self.source)) + } +} #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Archive { @@ -132,89 +163,14 @@ impl<'a> Tarball<'a> { } /// Builds the upstream URL for this tarball. - pub fn upstream_url(&self, source: &str) -> String { + pub fn upstream_url(&self, upstream: &str, source: &str) -> String { if self.development { - format!( - "https://ziglang.org/builds/{}?source={}", - self.filename, source - ) + format!("{}/builds/{}?source={}", upstream, self.filename, source) } else { format!( - "https://ziglang.org/download/{}/{}?source={}", - self.version, self.filename, source, + "{}/download/{}/{}?source={}", + upstream, self.version, self.filename, source, ) } } } - -pub struct ZigController { - config: Arc<config::ConfigService>, - storage: Arc<storage::StorageService>, - upstream: Arc<proxy::ProxyService>, -} - -impl ZigController { - pub fn new( - config: Arc<config::ConfigService>, - storage: Arc<storage::StorageService>, - upstream: Arc<proxy::ProxyService>, - ) -> Self { - Self { - config, - storage, - upstream, - } - } - - pub fn router(self: Arc<Self>) -> Router { - Router::new() - .route("/zig/{filename}", routing::get(Self::handle)) - .with_state(self) - } - - async fn handle( - extract::State(controller): extract::State<Arc<Self>>, - extract::Path(filename): extract::Path<String>, - ) -> Result<response::Response, http::StatusCode> { - let tarball = Tarball::parse(&filename).map_err(|_| http::StatusCode::NOT_FOUND)?; - let url = tarball.upstream_url(controller.config.appname()); - - match controller.storage.get("zig", &filename).await { - Ok(Some(entry)) => { - return Ok(Self::build_response( - http::StatusCode::OK, - entry.file_bytes.0, - )); - } - Ok(None) => {} - Err(err) => { - error!("failed get file from storage: {err}"); - return Err(http::StatusCode::INTERNAL_SERVER_ERROR); - } - } - - let entry = controller - .upstream - .fetch(proxy::DownloadRequest { url }) - .await?; - - match controller.storage.put("zig", &filename, &entry.bytes).await { - Ok(()) => {} - Err(_) => { - return Err(http::StatusCode::INTERNAL_SERVER_ERROR); - } - } - - Ok(Self::build_response(http::StatusCode::OK, entry.bytes)) - } - - fn build_response(status: http::StatusCode, bytes: bytes::Bytes) -> response::Response { - // TODO: add etag - response::Response::builder() - .status(status) - .header(http::header::CONTENT_TYPE, "application/octet-stream") - .header(http::header::CONTENT_LENGTH, bytes.len()) - .body(body::Body::from(bytes)) - .unwrap() - } -} diff --git a/src/config.rs b/src/config.rs index 1e684bb..0fce8be 100644 --- a/src/config.rs +++ b/src/config.rs @@ -11,6 +11,8 @@ use bytesize::ByteSize; use serde::Deserialize; use thiserror::Error; +use crate::backends::{GoConfig, ZigConfig}; + fn deserialize_duration_secs<'de, D>(deserializer: D) -> Result<Duration, D::Error> where D: serde::Deserializer<'de>, @@ -241,6 +243,13 @@ pub struct TelemetryConfig { pub otelcol: Option<OtelcolConfig>, } +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(default)] +pub struct BackendsConfig { + pub go: GoConfig, + pub zig: ZigConfig, +} + #[derive(Debug, Deserialize)] #[serde(default)] pub struct ConfigService { @@ -249,6 +258,7 @@ pub struct ConfigService { listen: Vec<ListenerConfig>, server: ServerConfig, telemetry: TelemetryConfig, + backends: BackendsConfig, } impl Default for ConfigService { fn default() -> Self { @@ -258,6 +268,7 @@ impl Default for ConfigService { server: ServerConfig::default(), listen: vec![ListenerConfig::default()], telemetry: TelemetryConfig::default(), + backends: BackendsConfig::default(), } } } @@ -334,6 +345,10 @@ impl ConfigService { pub fn telemetry(&self) -> &TelemetryConfig { &self.telemetry } + + pub fn backends(&self) -> &BackendsConfig { + &self.backends + } } #[cfg(test)] @@ -350,6 +365,7 @@ impl ConfigService { tls_key: None, }], telemetry: TelemetryConfig::default(), + backends: BackendsConfig::default(), } } } diff --git a/src/controller_backend.rs b/src/controller_backend.rs new file mode 100644 --- /dev/null +++ b/src/controller_backend.rs @@ -0,0 +1,95 @@ +// SPDX-FileCopyrightText: 2026 Nikolay Govorov <me@govorov.online> +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::sync::Arc; + +use axum::{Router, body, extract, http, response, routing}; +use tracing::error; + +use crate::backends::Backend; +use crate::proxy; +use crate::storage; + +/// Generic controller for backend HTTP handling. +pub struct BackendController<B: Backend> { + backend: B, + storage: Arc<storage::StorageService>, + upstream: Arc<proxy::ProxyService>, +} + +impl<B: Backend> BackendController<B> { + pub fn new( + backend: B, + storage: Arc<storage::StorageService>, + upstream: Arc<proxy::ProxyService>, + ) -> Self { + Self { + backend, + storage, + upstream, + } + } + + pub fn router(self: Arc<Self>) -> Router { + Router::new() + .route("/{filename}", routing::get(Self::handle)) + .with_state(self) + } + + async fn handle( + extract::State(controller): extract::State<Arc<Self>>, + extract::Path(filename): extract::Path<String>, + ) -> Result<response::Response, http::StatusCode> { + let url = match controller.backend.upstream_url(&filename) { + Ok(url) => url, + Err(()) => { + error!(backend = B::ID, filename, "invalid filename"); + return Err(http::StatusCode::NOT_FOUND); + } + }; + + match controller.storage.get(B::ID, &filename).await { + Ok(Some(entry)) => { + return Ok(Self::build_response( + http::StatusCode::OK, + entry.file_bytes.0, + )); + } + Ok(None) => {} + Err(err) => { + error!( + backend = B::ID, + filename, "failed to get file from storage: {err}" + ); + return Err(http::StatusCode::INTERNAL_SERVER_ERROR); + } + } + + let entry = controller + .upstream + .fetch(proxy::DownloadRequest { url }) + .await?; + + match controller.storage.put(B::ID, &filename, &entry.bytes).await { + Ok(()) => {} + Err(err) => { + error!( + backend = B::ID, + filename, "failed to put file to storage: {err}" + ); + return Err(http::StatusCode::INTERNAL_SERVER_ERROR); + } + } + + Ok(Self::build_response(http::StatusCode::OK, entry.bytes)) + } + + fn build_response(status: http::StatusCode, bytes: bytes::Bytes) -> response::Response { + response::Response::builder() + .status(status) + .header(http::header::CONTENT_TYPE, "application/octet-stream") + .header(http::header::CONTENT_LENGTH, bytes.len()) + .body(body::Body::from(bytes)) + .unwrap() + } +} diff --git a/src/web.rs b/src/controller_web.rs index 4866c15..4866c15 100644 --- a/src/web.rs +++ b/src/controller_web.rs diff --git a/src/main.rs b/src/main.rs index 54b7246..955c4f4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,7 +6,9 @@ mod config; mod proxy; mod storage; mod telemetry; -mod web; + +mod controller_backend; +mod controller_web; use std::future::Future; use std::net::{SocketAddr, TcpListener}; @@ -28,9 +30,9 @@ use tokio::signal; use tracing::{error, info, trace}; use tracing_subscriber::registry::LookupSpan; -use crate::backends::go::GoController; -use crate::backends::zig::ZigController; -use crate::web::WebController; +use crate::backends::{GoBackend, ZigBackend}; +use crate::controller_backend::BackendController; +use crate::controller_web::WebController; const VERSION: &str = env!("CARGO_PKG_VERSION"); const HELP: &str = "\ @@ -120,16 +122,8 @@ async fn main() { let upstream = Arc::new(proxy::ProxyService::new()); let web_controller = Arc::new(WebController::default()); - let zig_controller = Arc::new(ZigController::new( - config.clone(), - storage.clone(), - upstream.clone(), - )); - let go_controller = Arc::new(GoController::new( - config.clone(), - storage.clone(), - upstream.clone(), - )); + let source = format!("zorian:{}", config.appname()); + let backends = config.backends(); const REQUEST_ID_HEADER: http::HeaderName = http::HeaderName::from_static("x-request-id"); @@ -225,10 +219,29 @@ async fn main() { .finish() .unwrap(); - let app = axum::Router::new() - .merge(web_controller.router()) - .merge(zig_controller.router()) - .merge(go_controller.router()) + let mut app = axum::Router::new().merge(web_controller.router()); + + if backends.zig.enabled { + let backend = ZigBackend::new(backends.zig.clone(), source.clone()); + let ctrl = Arc::new(BackendController::new( + backend, + storage.clone(), + upstream.clone(), + )); + app = app.nest("/zig", ctrl.router()); + } + + if backends.go.enabled { + let backend = GoBackend::new(backends.go.clone(), source.clone()); + let ctrl = Arc::new(BackendController::new( + backend, + storage.clone(), + upstream.clone(), + )); + app = app.nest("/go", ctrl.router()); + } + + let app = app // Opt-in layers .layer(tower_http::compression::CompressionLayer::new()) // request limits |
