diff --git a/.github/assets/dashboard.gif b/.github/assets/dashboard.gif index 92e107b..4c9f532 100644 Binary files a/.github/assets/dashboard.gif and b/.github/assets/dashboard.gif differ diff --git a/Cargo.lock b/Cargo.lock index e28c720..f648079 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1304,7 +1304,7 @@ dependencies = [ [[package]] name = "nora-registry" -version = "0.2.35" +version = "0.3.0" dependencies = [ "argon2", "async-trait", diff --git a/README.md b/README.md index 7554b97..286359f 100644 --- a/README.md +++ b/README.md @@ -15,10 +15,15 @@ Open [http://localhost:4000/ui/](http://localhost:4000/ui/) — your registry is ## Why NORA - **Zero-config** — single 32 MB binary, no database, no dependencies. `docker run` and it works. -- **Production-tested** — Docker, Maven, npm, PyPI, Cargo, Raw. Used in real CI/CD with ArgoCD, Buildx cache, and air-gapped environments. +- **Production-tested** — Docker, Maven, npm, PyPI, Cargo, Go, Raw. Used in real CI/CD with ArgoCD, Buildx cache, and air-gapped environments. - **Secure by default** — [OpenSSF Scorecard](https://scorecard.dev/viewer/?uri=github.com/getnora-io/nora), signed releases, SBOM, fuzz testing, 200+ unit tests. -**32 MB** binary | **< 100 MB** RAM | **3s** startup | **6** registries +[![Release](https://img.shields.io/github/v/release/getnora-io/nora)](https://github.com/getnora-io/nora/releases) +[![Image Size](https://ghcr-badge.egpl.dev/getnora-io/nora/size?label=image)](https://github.com/getnora-io/nora/pkgs/container/nora) +[![Downloads](https://img.shields.io/github/downloads/getnora-io/nora/total?label=downloads)](https://github.com/getnora-io/nora/releases) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) + +**32 MB** binary | **< 100 MB** RAM | **3s** startup | **7** registries > Used in production at [DevIT Academy](https://github.com/devitway) since January 2026 for Docker images, Maven artifacts, and npm packages. @@ -31,6 +36,7 @@ Open [http://localhost:4000/ui/](http://localhost:4000/ui/) — your registry is | npm | `/npm/` | npmjs.org, custom | ✓ | | Cargo | `/cargo/` | — | ✓ | | PyPI | `/simple/` | pypi.org, custom | ✓ | +| Go Modules | `/go/` | proxy.golang.org, custom | ✓ | | Raw files | `/raw/` | — | ✓ | ## Quick Start @@ -82,6 +88,12 @@ npm config set registry http://localhost:4000/npm/ npm publish ``` +### Go Modules + +```bash +GOPROXY=http://localhost:4000/go go get golang.org/x/text@latest +``` + ## Features - **Web UI** — dashboard with search, browse, i18n (EN/RU) @@ -154,6 +166,9 @@ proxy_timeout = 60 [[docker.upstreams]] url = "https://registry-1.docker.io" + +[go] +proxy = "https://proxy.golang.org" ``` ## CLI Commands @@ -181,6 +196,7 @@ nora mirror # Sync packages for offline use | `/npm/` | npm | | `/cargo/` | Cargo | | `/simple/` | PyPI | +| `/go/` | Go Modules | ## TLS / HTTPS @@ -220,7 +236,6 @@ See [CHANGELOG.md](CHANGELOG.md) for release history. [![CII Best Practices](https://www.bestpractices.dev/projects/12207/badge)](https://www.bestpractices.dev/projects/12207) [![Coverage](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/devitway/0f0538f1ed16d5d9951e4f2d3f79b699/raw/nora-coverage.json)](https://github.com/getnora-io/nora/actions/workflows/ci.yml) [![CI](https://img.shields.io/github/actions/workflow/status/getnora-io/nora/ci.yml?label=CI)](https://github.com/getnora-io/nora/actions) -[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) - **Signed releases** — every release is signed with [cosign](https://github.com/sigstore/cosign) - **SBOM** — SPDX + CycloneDX in every release diff --git a/nora-registry/src/repo_index.rs b/nora-registry/src/repo_index.rs index df9d5b2..e1c94db 100644 --- a/nora-registry/src/repo_index.rs +++ b/nora-registry/src/repo_index.rs @@ -81,6 +81,7 @@ pub struct RepoIndex { pub cargo: RegistryIndex, pub pypi: RegistryIndex, pub go: RegistryIndex, + pub raw: RegistryIndex, } impl RepoIndex { @@ -92,6 +93,7 @@ impl RepoIndex { cargo: RegistryIndex::new(), pypi: RegistryIndex::new(), go: RegistryIndex::new(), + raw: RegistryIndex::new(), } } @@ -104,6 +106,7 @@ impl RepoIndex { "cargo" => self.cargo.invalidate(), "pypi" => self.pypi.invalidate(), "go" => self.go.invalidate(), + "raw" => self.raw.invalidate(), _ => {} } } @@ -117,6 +120,7 @@ impl RepoIndex { "cargo" => &self.cargo, "pypi" => &self.pypi, "go" => &self.go, + "raw" => &self.raw, _ => return Arc::new(Vec::new()), }; @@ -137,6 +141,7 @@ impl RepoIndex { "cargo" => build_cargo_index(storage).await, "pypi" => build_pypi_index(storage).await, "go" => build_go_index(storage).await, + "raw" => build_raw_index(storage).await, _ => Vec::new(), }; info!(registry = registry, count = data.len(), "Index rebuilt"); @@ -147,7 +152,7 @@ impl RepoIndex { } /// Get counts for stats (no rebuild, just current state) - pub fn counts(&self) -> (usize, usize, usize, usize, usize, usize) { + pub fn counts(&self) -> (usize, usize, usize, usize, usize, usize, usize) { ( self.docker.count(), self.maven.count(), @@ -155,6 +160,7 @@ impl RepoIndex { self.cargo.count(), self.pypi.count(), self.go.count(), + self.raw.count(), ) } } @@ -364,6 +370,28 @@ async fn build_go_index(storage: &Storage) -> Vec { to_sorted_vec(modules) } +async fn build_raw_index(storage: &Storage) -> Vec { + let keys = storage.list("raw/").await; + let mut files: HashMap = HashMap::new(); + + for key in &keys { + if let Some(rest) = key.strip_prefix("raw/") { + // Group by top-level directory + let group = rest.split('/').next().unwrap_or(rest).to_string(); + let entry = files.entry(group).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(files) +} + /// Convert HashMap to sorted Vec fn to_sorted_vec(map: HashMap) -> Vec { let mut result: Vec<_> = map @@ -517,8 +545,8 @@ mod tests { #[test] fn test_repo_index_new() { let idx = RepoIndex::new(); - let (d, m, n, c, p, g) = idx.counts(); - assert_eq!((d, m, n, c, p, g), (0, 0, 0, 0, 0, 0)); + let (d, m, n, c, p, g, r) = idx.counts(); + assert_eq!((d, m, n, c, p, g, r), (0, 0, 0, 0, 0, 0, 0)); } #[test] @@ -530,14 +558,15 @@ mod tests { idx.invalidate("npm"); idx.invalidate("cargo"); idx.invalidate("pypi"); + idx.invalidate("raw"); idx.invalidate("unknown"); // should be a no-op } #[test] fn test_repo_index_default() { let idx = RepoIndex::default(); - let (d, m, n, c, p, g) = idx.counts(); - assert_eq!((d, m, n, c, p, g), (0, 0, 0, 0, 0, 0)); + let (d, m, n, c, p, g, r) = idx.counts(); + assert_eq!((d, m, n, c, p, g, r), (0, 0, 0, 0, 0, 0, 0)); } #[test] diff --git a/nora-registry/src/ui/api.rs b/nora-registry/src/ui/api.rs index 2621c26..28a6404 100644 --- a/nora-registry/src/ui/api.rs +++ b/nora-registry/src/ui/api.rs @@ -24,6 +24,7 @@ pub struct RegistryStats { pub cargo: usize, pub pypi: usize, pub go: usize, + pub raw: usize, } #[derive(Serialize)] @@ -116,8 +117,9 @@ pub async fn api_stats(State(state): State>) -> Json>) -> Json>) -> Json>) -> Json>) -> Json>) -> Json>) -> Json RegistryStats { .collect::>() .len(); + let raw = all_keys + .iter() + .filter(|k| k.starts_with("raw/")) + .filter_map(|k| k.strip_prefix("raw/")?.split('/').next()) + .collect::>() + .len(); + RegistryStats { docker, maven, @@ -413,6 +440,7 @@ pub async fn get_registry_stats(storage: &Storage) -> RegistryStats { cargo, pypi, go, + raw, } } @@ -957,3 +985,26 @@ fn extract_pypi_version(name: &str, filename: &str) -> Option { None } } + +pub async fn get_raw_detail(storage: &Storage, group: &str) -> PackageDetail { + let prefix = format!("raw/{}/", group); + let keys = storage.list(&prefix).await; + + let mut versions = Vec::new(); + for key in &keys { + if let Some(filename) = key.strip_prefix(&prefix) { + 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: filename.to_string(), + size, + published, + }); + } + } + + PackageDetail { versions } +} diff --git a/nora-registry/src/ui/components.rs b/nora-registry/src/ui/components.rs index 9582aa0..3616d3f 100644 --- a/nora-registry/src/ui/components.rs +++ b/nora-registry/src/ui/components.rs @@ -629,6 +629,7 @@ pub mod icons { pub const NPM: &str = r#""#; pub const CARGO: &str = r#""#; pub const GO: &str = r#""#; + pub const RAW: &str = r#""#; pub const PYPI: &str = r#""#; } diff --git a/nora-registry/src/ui/mod.rs b/nora-registry/src/ui/mod.rs index 5caeb59..2ce02a0 100644 --- a/nora-registry/src/ui/mod.rs +++ b/nora-registry/src/ui/mod.rs @@ -89,6 +89,8 @@ pub fn routes() -> Router> { .route("/ui/pypi/{name}", get(pypi_detail)) .route("/ui/go", get(go_list)) .route("/ui/go/{*name}", get(go_detail)) + .route("/ui/raw", get(raw_list)) + .route("/ui/raw/{*name}", get(raw_detail)) // API endpoints for HTMX .route("/api/ui/stats", get(api_stats)) .route("/api/ui/dashboard", get(api_dashboard)) @@ -338,3 +340,41 @@ async fn go_detail( let detail = get_go_detail(&state.storage, &name).await; Html(render_package_detail("go", &name, &detail, lang)) } + +// Raw pages +async fn raw_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_files = state.repo_index.get("raw", &state.storage).await; + let (files, total) = paginate(&all_files, page, limit); + + Html(render_registry_list_paginated( + "raw", + "Raw Storage", + &files, + page, + limit, + total, + lang, + )) +} + +async fn raw_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_raw_detail(&state.storage, &name).await; + Html(render_package_detail("raw", &name, &detail, lang)) +} diff --git a/nora-registry/src/ui/templates.rs b/nora-registry/src/ui/templates.rs index f5d4772..be7769d 100644 --- a/nora-registry/src/ui/templates.rs +++ b/nora-registry/src/ui/templates.rs @@ -141,7 +141,7 @@ pub fn render_dashboard(data: &DashboardResponse, lang: Lang) -> String { {} -
+
{}
@@ -656,6 +656,7 @@ pub fn render_package_detail( name ), "go" => format!("GOPROXY=http://127.0.0.1:4000/go go get {}", name), + "raw" => format!("curl -O http://127.0.0.1:4000/raw/{}/", name), _ => String::new(), }; @@ -823,6 +824,7 @@ fn get_registry_icon(registry_type: &str) -> &'static str { "cargo" => icons::CARGO, "pypi" => icons::PYPI, "go" => icons::GO, + "raw" => icons::RAW, _ => { r#""# } @@ -837,6 +839,7 @@ fn get_registry_title(registry_type: &str) -> &'static str { "cargo" => "Cargo Registry", "pypi" => "PyPI Repository", "go" => "Go Modules", + "raw" => "Raw Storage", _ => "Registry", } }