diff --git a/Cargo.lock b/Cargo.lock index 27897a6..e28c720 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1321,6 +1321,7 @@ dependencies = [ "indicatif", "lazy_static", "parking_lot", + "percent-encoding", "prometheus", "reqwest", "serde", diff --git a/nora-registry/Cargo.toml b/nora-registry/Cargo.toml index c12fee6..f9baeb9 100644 --- a/nora-registry/Cargo.toml +++ b/nora-registry/Cargo.toml @@ -51,6 +51,7 @@ parking_lot = "0.12" zeroize = { version = "1.8", features = ["derive"] } argon2 = { version = "0.5", features = ["std", "rand"] } tower-http = { version = "0.6", features = ["set-header"] } +percent-encoding = "2" [dev-dependencies] tempfile = "3" diff --git a/nora-registry/src/config.rs b/nora-registry/src/config.rs index fb9a7a4..964f7cb 100644 --- a/nora-registry/src/config.rs +++ b/nora-registry/src/config.rs @@ -26,6 +26,8 @@ pub struct Config { #[serde(default)] pub docker: DockerConfig, #[serde(default)] + pub go: GoConfig, + #[serde(default)] pub raw: RawConfig, #[serde(default)] pub auth: AuthConfig, @@ -127,6 +129,48 @@ pub struct PypiConfig { pub proxy_timeout: u64, } +/// Go module proxy configuration (GOPROXY protocol) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GoConfig { + /// Upstream Go module proxy URL (default: https://proxy.golang.org) + #[serde(default = "default_go_proxy")] + pub proxy: Option, + #[serde(default)] + pub proxy_auth: Option, // "user:pass" for basic auth + #[serde(default = "default_timeout")] + pub proxy_timeout: u64, + /// Separate timeout for .zip downloads (default: 120s, zips can be large) + #[serde(default = "default_go_zip_timeout")] + pub proxy_timeout_zip: u64, + /// Maximum module zip size in bytes (default: 100MB) + #[serde(default = "default_go_max_zip_size")] + pub max_zip_size: u64, +} + +fn default_go_proxy() -> Option { + Some("https://proxy.golang.org".to_string()) +} + +fn default_go_zip_timeout() -> u64 { + 120 +} + +fn default_go_max_zip_size() -> u64 { + 104_857_600 // 100MB +} + +impl Default for GoConfig { + fn default() -> Self { + Self { + proxy: default_go_proxy(), + proxy_auth: None, + proxy_timeout: 30, + proxy_timeout_zip: 120, + max_zip_size: 104_857_600, + } + } +} + /// Docker registry configuration with upstream proxy support #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DockerConfig { @@ -387,6 +431,10 @@ impl Config { ); } } + // Go + if self.go.proxy_auth.is_some() && std::env::var("NORA_GO_PROXY_AUTH").is_err() { + tracing::warn!("Go proxy credentials in config.toml are plaintext — consider NORA_GO_PROXY_AUTH env var"); + } // npm if self.npm.proxy_auth.is_some() && std::env::var("NORA_NPM_PROXY_AUTH").is_err() { tracing::warn!("npm proxy credentials in config.toml are plaintext — consider NORA_NPM_PROXY_AUTH env var"); @@ -629,6 +677,7 @@ impl Default for Config { maven: MavenConfig::default(), npm: NpmConfig::default(), pypi: PypiConfig::default(), + go: GoConfig::default(), docker: DockerConfig::default(), raw: RawConfig::default(), auth: AuthConfig::default(), diff --git a/nora-registry/src/main.rs b/nora-registry/src/main.rs index f84ca71..8b4dcf8 100644 --- a/nora-registry/src/main.rs +++ b/nora-registry/src/main.rs @@ -326,7 +326,8 @@ async fn run_server(config: Config, storage: Storage) { .merge(registry::npm_routes()) .merge(registry::cargo_routes()) .merge(registry::pypi_routes()) - .merge(registry::raw_routes()); + .merge(registry::raw_routes()) + .merge(registry::go_routes()); // Routes WITHOUT rate limiting (health, metrics, UI) let public_routes = Router::new() diff --git a/nora-registry/src/registry/go.rs b/nora-registry/src/registry/go.rs new file mode 100644 index 0000000..a2aecc6 --- /dev/null +++ b/nora-registry/src/registry/go.rs @@ -0,0 +1,522 @@ +// Copyright (c) 2026 Volkov Pavel | DevITWay +// SPDX-License-Identifier: MIT + +//! Go module proxy (GOPROXY protocol). +//! +//! Implements the 5 required endpoints: +//! GET /go/{module}/@v/list — list known versions +//! GET /go/{module}/@v/{ver}.info — version metadata (JSON) +//! GET /go/{module}/@v/{ver}.mod — go.mod file +//! GET /go/{module}/@v/{ver}.zip — module zip archive +//! GET /go/{module}/@latest — latest version info + +use crate::activity_log::{ActionType, ActivityEntry}; +use crate::audit::AuditEntry; +use crate::registry::{proxy_fetch, proxy_fetch_text, ProxyError}; +use crate::AppState; +use axum::{ + extract::{Path, State}, + http::{header, HeaderValue, StatusCode}, + response::{IntoResponse, Response}, + routing::get, + Router, +}; +use percent_encoding::percent_decode; +use std::sync::Arc; + +pub fn routes() -> Router> { + Router::new().route("/go/{*path}", get(handle)) +} + +/// Main handler — parses the wildcard path and dispatches to the right logic. +async fn handle(State(state): State>, Path(path): Path) -> Response { + // URL-decode the path: Go client sends %21 for !, Axum wildcard may not decode it + let path = percent_decode(path.as_bytes()) + .decode_utf8() + .map(|s| s.into_owned()) + .unwrap_or(path); + + tracing::debug!(path = %path, "Go proxy request"); + + // Validate path: no traversal, no null bytes + if !is_safe_path(&path) { + tracing::debug!(path = %path, "Go proxy: unsafe path"); + return StatusCode::BAD_REQUEST.into_response(); + } + + // Split: "github.com/!azure/sdk/@v/v1.0.0.info" → module + file + let (module_encoded, file) = match split_go_path(&path) { + Some(parts) => parts, + None => { + tracing::debug!(path = %path, "Go proxy: cannot split path"); + return StatusCode::NOT_FOUND.into_response(); + } + }; + + let storage_key = format!("go/{}", path); + let content_type = content_type_for(&file); + + // Mutable endpoints: @v/list and @latest can be refreshed from upstream + let is_mutable = file == "@v/list" || file == "@latest"; + // Immutable: .info, .mod, .zip — once cached, never overwrite + let is_immutable = !is_mutable; + + // 1. Try local cache (for immutable files, this is authoritative) + if let Ok(data) = state.storage.get(&storage_key).await { + state.metrics.record_download("go"); + state.metrics.record_cache_hit(); + state.activity.push(ActivityEntry::new( + ActionType::CacheHit, + format_artifact(&module_encoded, &file), + "go", + "CACHE", + )); + return with_content_type(data.to_vec(), content_type); + } + + // 2. Try upstream proxy + let proxy_url = match &state.config.go.proxy { + Some(url) => url.clone(), + None => return StatusCode::NOT_FOUND.into_response(), + }; + + // Validate module path encoding (but keep encoded for upstream — proxy.golang.org expects ! encoding) + if decode_module_path(&module_encoded).is_err() { + return StatusCode::BAD_REQUEST.into_response(); + } + + let upstream_url = format!( + "{}/{}", + proxy_url.trim_end_matches('/'), + format_upstream_path(&module_encoded, &file) + ); + + // Use longer timeout for .zip files + let timeout = if file.ends_with(".zip") { + state.config.go.proxy_timeout_zip + } else { + state.config.go.proxy_timeout + }; + + // Fetch: binary for .zip, text for everything else + let data = if file.ends_with(".zip") { + proxy_fetch( + &state.http_client, + &upstream_url, + timeout, + state.config.go.proxy_auth.as_deref(), + ) + .await + } else { + proxy_fetch_text( + &state.http_client, + &upstream_url, + timeout, + state.config.go.proxy_auth.as_deref(), + None, + ) + .await + .map(|s| s.into_bytes()) + }; + + match data { + Ok(bytes) => { + // Enforce size limit for .zip + if file.ends_with(".zip") && bytes.len() as u64 > state.config.go.max_zip_size { + tracing::warn!( + module = module_encoded, + size = bytes.len(), + limit = state.config.go.max_zip_size, + "Go module zip exceeds size limit" + ); + return StatusCode::PAYLOAD_TOO_LARGE.into_response(); + } + + state.metrics.record_download("go"); + state.metrics.record_cache_miss(); + state.activity.push(ActivityEntry::new( + ActionType::ProxyFetch, + format_artifact(&module_encoded, &file), + "go", + "PROXY", + )); + state + .audit + .log(AuditEntry::new("proxy_fetch", "api", "", "go", "")); + + // Background cache: immutable = put_if_absent, mutable = always overwrite + let storage = state.storage.clone(); + let key = storage_key.clone(); + let data_clone = bytes.clone(); + tokio::spawn(async move { + if is_immutable { + // Only write if not already cached (immutability guarantee) + if storage.stat(&key).await.is_none() { + let _ = storage.put(&key, &data_clone).await; + } + } else { + let _ = storage.put(&key, &data_clone).await; + } + }); + + state.repo_index.invalidate("go"); + with_content_type(bytes, content_type) + } + Err(ProxyError::NotFound) => StatusCode::NOT_FOUND.into_response(), + Err(e) => { + tracing::debug!( + module = module_encoded, + file = file, + error = ?e, + "Go upstream proxy error" + ); + StatusCode::BAD_GATEWAY.into_response() + } + } +} + +// ============================================================================ +// Module path encoding/decoding +// ============================================================================ + +/// Decode Go module path: `!x` → `X` +/// +/// Go module proxy spec requires uppercase letters to be encoded as `!` +/// followed by the lowercase letter. Raw uppercase in encoded path is invalid. +fn decode_module_path(encoded: &str) -> Result { + let mut result = String::with_capacity(encoded.len()); + let mut chars = encoded.chars(); + while let Some(c) = chars.next() { + if c == '!' { + match chars.next() { + Some(next) if next.is_ascii_lowercase() => { + result.push(next.to_ascii_uppercase()); + } + _ => return Err(()), + } + } else if c.is_ascii_uppercase() { + // Raw uppercase in encoded path is invalid per spec + return Err(()); + } else { + result.push(c); + } + } + Ok(result) +} + +/// Encode Go module path: `X` → `!x` +#[cfg(test)] +fn encode_module_path(path: &str) -> String { + let mut result = String::with_capacity(path.len() + 8); + for c in path.chars() { + if c.is_ascii_uppercase() { + result.push('!'); + result.push(c.to_ascii_lowercase()); + } else { + result.push(c); + } + } + result +} + +// ============================================================================ +// Path parsing helpers +// ============================================================================ + +/// Split Go path into (encoded_module, file). +/// +/// Examples: +/// "github.com/user/repo/@v/v1.0.0.info" → ("github.com/user/repo", "@v/v1.0.0.info") +/// "github.com/user/repo/v2/@v/list" → ("github.com/user/repo/v2", "@v/list") +/// "github.com/user/repo/@latest" → ("github.com/user/repo", "@latest") +fn split_go_path(path: &str) -> Option<(String, String)> { + // Try @latest first (it's simpler) + if let Some(pos) = path.rfind("/@latest") { + let module = &path[..pos]; + if !module.is_empty() { + return Some((module.to_string(), "@latest".to_string())); + } + } + + // Try @v/ — find the last occurrence (handles /v2/@v/ correctly) + if let Some(pos) = path.rfind("/@v/") { + let module = &path[..pos]; + let file = &path[pos + 1..]; // "@v/..." + if !module.is_empty() && !file.is_empty() { + return Some((module.to_string(), file.to_string())); + } + } + + None +} + +/// Path validation: no traversal attacks +fn is_safe_path(path: &str) -> bool { + !path.contains("..") + && !path.starts_with('/') + && !path.contains("//") + && !path.contains('\0') + && !path.is_empty() +} + +/// Content-Type for Go proxy responses +fn content_type_for(file: &str) -> &'static str { + if file.ends_with(".info") || file == "@latest" { + "application/json" + } else if file.ends_with(".zip") { + "application/zip" + } else { + // .mod, @v/list + "text/plain; charset=utf-8" + } +} + +/// Build upstream URL path (uses decoded module path) +fn format_upstream_path(module_decoded: &str, file: &str) -> String { + format!("{}/{}", module_decoded, file) +} + +/// Human-readable artifact name for activity log +fn format_artifact(module: &str, file: &str) -> String { + if file == "@v/list" || file == "@latest" { + format!("{} {}", module, file) + } else if let Some(version_file) = file.strip_prefix("@v/") { + // "v1.0.0.info" → "module@v1.0.0" + let version = version_file + .rsplit_once('.') + .map(|(v, _ext)| v) + .unwrap_or(version_file); + format!("{}@{}", module, version) + } else { + format!("{}/{}", module, file) + } +} + +/// Build response with Content-Type header +fn with_content_type(data: Vec, content_type: &'static str) -> Response { + ( + StatusCode::OK, + [(header::CONTENT_TYPE, HeaderValue::from_static(content_type))], + data, + ) + .into_response() +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + // ── Encoding/decoding ─────────────────────────────────────────────── + + #[test] + fn test_decode_azure() { + assert_eq!( + decode_module_path("github.com/!azure/sdk").unwrap(), + "github.com/Azure/sdk" + ); + } + + #[test] + fn test_decode_multiple_uppercase() { + assert_eq!( + decode_module_path("!google!cloud!platform/foo").unwrap(), + "GoogleCloudPlatform/foo" + ); + } + + #[test] + fn test_decode_no_uppercase() { + assert_eq!( + decode_module_path("github.com/user/repo").unwrap(), + "github.com/user/repo" + ); + } + + #[test] + fn test_decode_invalid_bang_at_end() { + assert!(decode_module_path("foo!").is_err()); + } + + #[test] + fn test_decode_invalid_bang_followed_by_uppercase() { + assert!(decode_module_path("foo!A").is_err()); + } + + #[test] + fn test_decode_raw_uppercase_is_invalid() { + assert!(decode_module_path("github.com/Azure/sdk").is_err()); + } + + #[test] + fn test_encode_roundtrip() { + let original = "github.com/Azure/azure-sdk-for-go"; + let encoded = encode_module_path(original); + assert_eq!(encoded, "github.com/!azure/azure-sdk-for-go"); + assert_eq!(decode_module_path(&encoded).unwrap(), original); + } + + #[test] + fn test_encode_no_change() { + assert_eq!( + encode_module_path("github.com/user/repo"), + "github.com/user/repo" + ); + } + + // ── Path splitting ────────────────────────────────────────────────── + + #[test] + fn test_split_version_info() { + let (module, file) = split_go_path("github.com/user/repo/@v/v1.0.0.info").unwrap(); + assert_eq!(module, "github.com/user/repo"); + assert_eq!(file, "@v/v1.0.0.info"); + } + + #[test] + fn test_split_version_list() { + let (module, file) = split_go_path("github.com/user/repo/@v/list").unwrap(); + assert_eq!(module, "github.com/user/repo"); + assert_eq!(file, "@v/list"); + } + + #[test] + fn test_split_latest() { + let (module, file) = split_go_path("github.com/user/repo/@latest").unwrap(); + assert_eq!(module, "github.com/user/repo"); + assert_eq!(file, "@latest"); + } + + #[test] + fn test_split_major_version_suffix() { + let (module, file) = split_go_path("github.com/user/repo/v2/@v/list").unwrap(); + assert_eq!(module, "github.com/user/repo/v2"); + assert_eq!(file, "@v/list"); + } + + #[test] + fn test_split_incompatible_version() { + let (module, file) = + split_go_path("github.com/user/repo/@v/v4.1.2+incompatible.info").unwrap(); + assert_eq!(module, "github.com/user/repo"); + assert_eq!(file, "@v/v4.1.2+incompatible.info"); + } + + #[test] + fn test_split_pseudo_version() { + let (module, file) = + split_go_path("github.com/user/repo/@v/v0.0.0-20210101000000-abcdef123456.info") + .unwrap(); + assert_eq!(module, "github.com/user/repo"); + assert_eq!(file, "@v/v0.0.0-20210101000000-abcdef123456.info"); + } + + #[test] + fn test_split_no_at() { + assert!(split_go_path("github.com/user/repo/v1.0.0").is_none()); + } + + #[test] + fn test_split_empty_module() { + assert!(split_go_path("/@v/list").is_none()); + } + + // ── Path safety ───────────────────────────────────────────────────── + + #[test] + fn test_safe_path_normal() { + assert!(is_safe_path("github.com/user/repo/@v/list")); + } + + #[test] + fn test_reject_traversal() { + assert!(!is_safe_path("../../etc/passwd")); + } + + #[test] + fn test_reject_absolute() { + assert!(!is_safe_path("/etc/passwd")); + } + + #[test] + fn test_reject_double_slash() { + assert!(!is_safe_path("github.com//evil/@v/list")); + } + + #[test] + fn test_reject_null() { + assert!(!is_safe_path("github.com/\0evil/@v/list")); + } + + #[test] + fn test_reject_empty() { + assert!(!is_safe_path("")); + } + + // ── Content-Type ──────────────────────────────────────────────────── + + #[test] + fn test_content_type_info() { + assert_eq!(content_type_for("@v/v1.0.0.info"), "application/json"); + } + + #[test] + fn test_content_type_latest() { + assert_eq!(content_type_for("@latest"), "application/json"); + } + + #[test] + fn test_content_type_zip() { + assert_eq!(content_type_for("@v/v1.0.0.zip"), "application/zip"); + } + + #[test] + fn test_content_type_mod() { + assert_eq!( + content_type_for("@v/v1.0.0.mod"), + "text/plain; charset=utf-8" + ); + } + + #[test] + fn test_content_type_list() { + assert_eq!(content_type_for("@v/list"), "text/plain; charset=utf-8"); + } + + // ── Artifact formatting ───────────────────────────────────────────── + + #[test] + fn test_format_artifact_version() { + assert_eq!( + format_artifact("github.com/user/repo", "@v/v1.0.0.info"), + "github.com/user/repo@v1.0.0" + ); + } + + #[test] + fn test_format_artifact_list() { + assert_eq!( + format_artifact("github.com/user/repo", "@v/list"), + "github.com/user/repo @v/list" + ); + } + + #[test] + fn test_format_artifact_latest() { + assert_eq!( + format_artifact("github.com/user/repo", "@latest"), + "github.com/user/repo @latest" + ); + } + + #[test] + fn test_format_artifact_zip() { + assert_eq!( + format_artifact("github.com/user/repo", "@v/v1.0.0.zip"), + "github.com/user/repo@v1.0.0" + ); + } +} diff --git a/nora-registry/src/registry/mod.rs b/nora-registry/src/registry/mod.rs index e5e257b..414f6f4 100644 --- a/nora-registry/src/registry/mod.rs +++ b/nora-registry/src/registry/mod.rs @@ -4,6 +4,7 @@ mod cargo_registry; pub mod docker; pub mod docker_auth; +mod go; mod maven; mod npm; mod pypi; @@ -12,6 +13,7 @@ mod raw; pub use cargo_registry::routes as cargo_routes; pub use docker::routes as docker_routes; pub use docker_auth::DockerAuth; +pub use go::routes as go_routes; pub use maven::routes as maven_routes; pub use npm::routes as npm_routes; pub use pypi::routes as pypi_routes; diff --git a/nora-registry/src/repo_index.rs b/nora-registry/src/repo_index.rs index aa8ce27..df9d5b2 100644 --- a/nora-registry/src/repo_index.rs +++ b/nora-registry/src/repo_index.rs @@ -80,6 +80,7 @@ pub struct RepoIndex { pub npm: RegistryIndex, pub cargo: RegistryIndex, pub pypi: RegistryIndex, + pub go: RegistryIndex, } impl RepoIndex { @@ -90,6 +91,7 @@ impl RepoIndex { npm: RegistryIndex::new(), cargo: RegistryIndex::new(), pypi: RegistryIndex::new(), + go: RegistryIndex::new(), } } @@ -101,6 +103,7 @@ impl RepoIndex { "npm" => self.npm.invalidate(), "cargo" => self.cargo.invalidate(), "pypi" => self.pypi.invalidate(), + "go" => self.go.invalidate(), _ => {} } } @@ -113,6 +116,7 @@ impl RepoIndex { "npm" => &self.npm, "cargo" => &self.cargo, "pypi" => &self.pypi, + "go" => &self.go, _ => return Arc::new(Vec::new()), }; @@ -132,6 +136,7 @@ impl RepoIndex { "npm" => build_npm_index(storage).await, "cargo" => build_cargo_index(storage).await, "pypi" => build_pypi_index(storage).await, + "go" => build_go_index(storage).await, _ => Vec::new(), }; info!(registry = registry, count = data.len(), "Index rebuilt"); @@ -142,13 +147,14 @@ impl RepoIndex { } /// Get counts for stats (no rebuild, just current state) - pub fn counts(&self) -> (usize, usize, usize, usize, usize) { + pub fn counts(&self) -> (usize, usize, usize, usize, usize, usize) { ( self.docker.count(), self.maven.count(), self.npm.count(), self.cargo.count(), self.pypi.count(), + self.go.count(), ) } } @@ -329,6 +335,35 @@ async fn build_pypi_index(storage: &Storage) -> Vec { to_sorted_vec(packages) } +async fn build_go_index(storage: &Storage) -> Vec { + let keys = storage.list("go/").await; + let mut modules: HashMap = HashMap::new(); + + for key in &keys { + if let Some(rest) = key.strip_prefix("go/") { + // Pattern: go/{module}/@v/{version}.zip + // Count .zip files as versions (authoritative artifacts) + if rest.contains("/@v/") && key.ends_with(".zip") { + // Extract module path: everything before /@v/ + if let Some(pos) = rest.rfind("/@v/") { + let module = &rest[..pos]; + let entry = modules.entry(module.to_string()).or_insert((0, 0, 0)); + entry.0 += 1; + + if let Some(meta) = storage.stat(key).await { + entry.1 += meta.size; + if meta.modified > entry.2 { + entry.2 = meta.modified; + } + } + } + } + } + } + + to_sorted_vec(modules) +} + /// Convert HashMap to sorted Vec fn to_sorted_vec(map: HashMap) -> Vec { let mut result: Vec<_> = map @@ -482,8 +517,8 @@ mod tests { #[test] fn test_repo_index_new() { let idx = RepoIndex::new(); - let (d, m, n, c, p) = idx.counts(); - assert_eq!((d, m, n, c, p), (0, 0, 0, 0, 0)); + let (d, m, n, c, p, g) = idx.counts(); + assert_eq!((d, m, n, c, p, g), (0, 0, 0, 0, 0, 0)); } #[test] @@ -501,8 +536,8 @@ mod tests { #[test] fn test_repo_index_default() { let idx = RepoIndex::default(); - let (d, m, n, c, p) = idx.counts(); - assert_eq!((d, m, n, c, p), (0, 0, 0, 0, 0)); + let (d, m, n, c, p, g) = idx.counts(); + assert_eq!((d, m, n, c, p, g), (0, 0, 0, 0, 0, 0)); } #[test] diff --git a/nora-registry/src/ui/api.rs b/nora-registry/src/ui/api.rs index 9f047f9..2621c26 100644 --- a/nora-registry/src/ui/api.rs +++ b/nora-registry/src/ui/api.rs @@ -23,6 +23,7 @@ pub struct RegistryStats { pub npm: usize, pub cargo: usize, pub pypi: usize, + pub go: usize, } #[derive(Serialize)] @@ -114,14 +115,16 @@ pub async fn api_stats(State(state): State>) -> Json>) -> Json>) -> Json>) -> Json>) -> Json>) -> Json RegistryStats { .collect::>() .len(); + let go = all_keys + .iter() + .filter(|k| k.starts_with("go/") && k.ends_with(".zip")) + .filter_map(|k| { + let rest = k.strip_prefix("go/")?; + let pos = rest.rfind("/@v/")?; + Some(rest[..pos].to_string()) + }) + .collect::>() + .len(); + RegistryStats { docker, maven, npm, cargo, pypi, + go, } } @@ -874,6 +908,32 @@ pub async fn get_pypi_detail(storage: &Storage, name: &str) -> PackageDetail { PackageDetail { versions } } +pub async fn get_go_detail(storage: &Storage, module: &str) -> PackageDetail { + let prefix = format!("go/{}/@v/", module); + let keys = storage.list(&prefix).await; + + let mut versions = Vec::new(); + for key in keys.iter().filter(|k| k.ends_with(".zip")) { + if let Some(rest) = key.strip_prefix(&prefix) { + if let Some(version) = rest.strip_suffix(".zip") { + let (size, published) = if let Some(meta) = storage.stat(key).await { + (meta.size, format_timestamp(meta.modified)) + } else { + (0, "N/A".to_string()) + }; + versions.push(VersionInfo { + version: version.to_string(), + size, + published, + }); + } + } + } + + versions.sort_by(|a, b| b.version.cmp(&a.version)); + PackageDetail { versions } +} + fn extract_pypi_version(name: &str, filename: &str) -> Option { // Handle both .tar.gz and .whl files let clean_name = name.replace('-', "_"); diff --git a/nora-registry/src/ui/components.rs b/nora-registry/src/ui/components.rs index c04c12a..9582aa0 100644 --- a/nora-registry/src/ui/components.rs +++ b/nora-registry/src/ui/components.rs @@ -90,7 +90,7 @@ fn sidebar_dark(active_page: Option<&str>, t: &Translations) -> String { let docker_icon = r#""#; let maven_icon = r#""#; let npm_icon = r#""#; - let cargo_icon = r#""#; + let cargo_icon = r#""#; let pypi_icon = r#""#; // Dashboard label is translated, registry names stay as-is @@ -109,6 +109,13 @@ fn sidebar_dark(active_page: Option<&str>, t: &Translations) -> String { ("npm", "/ui/npm", "npm", npm_icon, false), ("cargo", "/ui/cargo", "Cargo", cargo_icon, false), ("pypi", "/ui/pypi", "PyPI", pypi_icon, false), + ( + "go", + "/ui/go", + "Go", + r#""#, + false, + ), ]; let nav_html: String = nav_items.iter().map(|(id, href, label, icon_path, is_stroke)| { @@ -277,15 +284,15 @@ pub fn render_registry_card( ) -> String { format!( r##" - -
- + +
+ {} - {} + {}
-
{}
-
+
{}
+
{}
{}
@@ -490,7 +497,7 @@ fn sidebar(active_page: Option<&str>) -> String { let docker_icon = r#""#; let maven_icon = r#""#; let npm_icon = r#""#; - let cargo_icon = r#""#; + let cargo_icon = r#""#; let pypi_icon = r#""#; let nav_items = [ @@ -506,6 +513,13 @@ fn sidebar(active_page: Option<&str>) -> String { ("npm", "/ui/npm", "npm", npm_icon, false), ("cargo", "/ui/cargo", "Cargo", cargo_icon, false), ("pypi", "/ui/pypi", "PyPI", pypi_icon, false), + ( + "go", + "/ui/go", + "Go", + r#""#, + false, + ), ]; let nav_html: String = nav_items.iter().map(|(id, href, label, icon_path, is_stroke)| { @@ -613,7 +627,8 @@ pub mod icons { pub const DOCKER: &str = r#""#; pub const MAVEN: &str = r#""#; pub const NPM: &str = r#""#; - pub const CARGO: &str = r#""#; + pub const CARGO: &str = r#""#; + pub const GO: &str = r#""#; pub const PYPI: &str = r#""#; } diff --git a/nora-registry/src/ui/mod.rs b/nora-registry/src/ui/mod.rs index e5b08de..5caeb59 100644 --- a/nora-registry/src/ui/mod.rs +++ b/nora-registry/src/ui/mod.rs @@ -87,6 +87,8 @@ pub fn routes() -> Router> { .route("/ui/cargo/{name}", get(cargo_detail)) .route("/ui/pypi", get(pypi_list)) .route("/ui/pypi/{name}", get(pypi_detail)) + .route("/ui/go", get(go_list)) + .route("/ui/go/{*name}", get(go_detail)) // API endpoints for HTMX .route("/api/ui/stats", get(api_stats)) .route("/api/ui/dashboard", get(api_dashboard)) @@ -298,3 +300,41 @@ async fn pypi_detail( let detail = get_pypi_detail(&state.storage, &name).await; Html(render_package_detail("pypi", &name, &detail, lang)) } + +// Go pages +async fn go_list( + State(state): State>, + Query(query): Query, + headers: axum::http::HeaderMap, +) -> impl IntoResponse { + let lang = extract_lang_from_list(&query, headers.get("cookie").and_then(|v| v.to_str().ok())); + let page = query.page.unwrap_or(1).max(1); + let limit = query.limit.unwrap_or(DEFAULT_PAGE_SIZE).min(100); + + let all_modules = state.repo_index.get("go", &state.storage).await; + let (modules, total) = paginate(&all_modules, page, limit); + + Html(render_registry_list_paginated( + "go", + "Go Modules", + &modules, + page, + limit, + total, + lang, + )) +} + +async fn go_detail( + State(state): State>, + Path(name): Path, + Query(query): Query, + headers: axum::http::HeaderMap, +) -> impl IntoResponse { + let lang = extract_lang( + &Query(query), + headers.get("cookie").and_then(|v| v.to_str().ok()), + ); + let detail = get_go_detail(&state.storage, &name).await; + Html(render_package_detail("go", &name, &detail, lang)) +} diff --git a/nora-registry/src/ui/templates.rs b/nora-registry/src/ui/templates.rs index 9ceb182..f5d4772 100644 --- a/nora-registry/src/ui/templates.rs +++ b/nora-registry/src/ui/templates.rs @@ -24,22 +24,8 @@ pub fn render_dashboard(data: &DashboardResponse, lang: Lang) -> String { .registry_stats .iter() .map(|r| { - let icon = match r.name.as_str() { - "docker" => icons::DOCKER, - "maven" => icons::MAVEN, - "npm" => icons::NPM, - "cargo" => icons::CARGO, - "pypi" => icons::PYPI, - _ => icons::DOCKER, - }; - let display_name = match r.name.as_str() { - "docker" => "Docker", - "maven" => "Maven", - "npm" => "npm", - "cargo" => "Cargo", - "pypi" => "PyPI", - _ => &r.name, - }; + let icon = get_registry_icon(&r.name); + let display_name = get_registry_title(&r.name); render_registry_card( display_name, icon, @@ -155,7 +141,7 @@ pub fn render_dashboard(data: &DashboardResponse, lang: Lang) -> String { {} -
+
{}
@@ -669,6 +655,7 @@ pub fn render_package_detail( "pip install {} --index-url http://127.0.0.1:4000/simple", name ), + "go" => format!("GOPROXY=http://127.0.0.1:4000/go go get {}", name), _ => String::new(), }; @@ -835,6 +822,7 @@ fn get_registry_icon(registry_type: &str) -> &'static str { "npm" => icons::NPM, "cargo" => icons::CARGO, "pypi" => icons::PYPI, + "go" => icons::GO, _ => { r#""# } @@ -848,6 +836,7 @@ fn get_registry_title(registry_type: &str) -> &'static str { "npm" => "npm Registry", "cargo" => "Cargo Registry", "pypi" => "PyPI Repository", + "go" => "Go Modules", _ => "Registry", } }