feat: initialize NORA artifact registry

Cloud-native multi-protocol artifact registry in Rust.

- Docker Registry v2
- Maven (+ proxy)
- npm (+ proxy)
- Cargo, PyPI
- Web UI, Swagger, Prometheus
- Local & S3 storage
- 32MB Docker image

Created by DevITWay
https://getnora.io
This commit is contained in:
2026-01-25 17:03:18 +00:00
commit 586420a476
36 changed files with 7613 additions and 0 deletions

315
nora-registry/src/auth.rs Normal file
View File

@@ -0,0 +1,315 @@
use axum::{
body::Body,
extract::State,
http::{header, Request, StatusCode},
middleware::Next,
response::{IntoResponse, Response},
};
use base64::{engine::general_purpose::STANDARD, Engine};
use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use crate::AppState;
/// Htpasswd-based authentication
#[derive(Clone)]
pub struct HtpasswdAuth {
users: HashMap<String, String>, // username -> bcrypt hash
}
impl HtpasswdAuth {
/// Load users from htpasswd file
pub fn from_file(path: &Path) -> Option<Self> {
let content = std::fs::read_to_string(path).ok()?;
let mut users = HashMap::new();
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((username, hash)) = line.split_once(':') {
users.insert(username.to_string(), hash.to_string());
}
}
if users.is_empty() {
None
} else {
Some(Self { users })
}
}
/// Verify username and password
pub fn authenticate(&self, username: &str, password: &str) -> bool {
if let Some(hash) = self.users.get(username) {
bcrypt::verify(password, hash).unwrap_or(false)
} else {
false
}
}
/// Get list of usernames
pub fn list_users(&self) -> Vec<&str> {
self.users.keys().map(|s| s.as_str()).collect()
}
}
/// Check if path is public (no auth required)
fn is_public_path(path: &str) -> bool {
matches!(
path,
"/" | "/health" | "/ready" | "/metrics" | "/v2/" | "/v2"
) || path.starts_with("/ui")
|| path.starts_with("/api-docs")
|| path.starts_with("/api/ui")
|| path.starts_with("/api/tokens")
}
/// Auth middleware - supports Basic auth and Bearer tokens
pub async fn auth_middleware(
State(state): State<Arc<AppState>>,
request: Request<Body>,
next: Next,
) -> Response {
// Skip auth if disabled
let auth = match &state.auth {
Some(auth) => auth,
None => return next.run(request).await,
};
// Skip auth for public endpoints
if is_public_path(request.uri().path()) {
return next.run(request).await;
}
// Extract Authorization header
let auth_header = request
.headers()
.get(header::AUTHORIZATION)
.and_then(|h| h.to_str().ok());
let auth_header = match auth_header {
Some(h) => h,
None => return unauthorized_response("Authentication required"),
};
// Try Bearer token first
if let Some(token) = auth_header.strip_prefix("Bearer ") {
if let Some(ref token_store) = state.tokens {
match token_store.verify_token(token) {
Ok(_user) => return next.run(request).await,
Err(_) => return unauthorized_response("Invalid or expired token"),
}
} else {
return unauthorized_response("Token authentication not configured");
}
}
// Parse Basic auth
if !auth_header.starts_with("Basic ") {
return unauthorized_response("Basic or Bearer authentication required");
}
let encoded = &auth_header[6..];
let decoded = match STANDARD.decode(encoded) {
Ok(d) => d,
Err(_) => return unauthorized_response("Invalid credentials encoding"),
};
let credentials = match String::from_utf8(decoded) {
Ok(c) => c,
Err(_) => return unauthorized_response("Invalid credentials encoding"),
};
let (username, password) = match credentials.split_once(':') {
Some((u, p)) => (u, p),
None => return unauthorized_response("Invalid credentials format"),
};
// Verify credentials
if !auth.authenticate(username, password) {
return unauthorized_response("Invalid username or password");
}
// Auth successful
next.run(request).await
}
fn unauthorized_response(message: &str) -> Response {
(
StatusCode::UNAUTHORIZED,
[
(header::WWW_AUTHENTICATE, "Basic realm=\"Nora\""),
(header::CONTENT_TYPE, "application/json"),
],
format!(r#"{{"error":"{}"}}"#, message),
)
.into_response()
}
/// Generate bcrypt hash for password (for CLI user management)
#[allow(dead_code)]
pub fn hash_password(password: &str) -> Result<String, bcrypt::BcryptError> {
bcrypt::hash(password, bcrypt::DEFAULT_COST)
}
// Token management API routes
use axum::{routing::post, Json, Router};
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
pub struct CreateTokenRequest {
pub username: String,
pub password: String,
#[serde(default = "default_ttl")]
pub ttl_days: u64,
pub description: Option<String>,
}
fn default_ttl() -> u64 {
30
}
#[derive(Serialize)]
pub struct CreateTokenResponse {
pub token: String,
pub expires_in_days: u64,
}
#[derive(Serialize)]
pub struct TokenListItem {
pub hash_prefix: String,
pub created_at: u64,
pub expires_at: u64,
pub last_used: Option<u64>,
pub description: Option<String>,
}
#[derive(Serialize)]
pub struct TokenListResponse {
pub tokens: Vec<TokenListItem>,
}
/// Create a new API token (requires Basic auth)
async fn create_token(
State(state): State<Arc<AppState>>,
Json(req): Json<CreateTokenRequest>,
) -> Response {
// Verify user credentials first
let auth = match &state.auth {
Some(auth) => auth,
None => return (StatusCode::SERVICE_UNAVAILABLE, "Auth not configured").into_response(),
};
if !auth.authenticate(&req.username, &req.password) {
return (StatusCode::UNAUTHORIZED, "Invalid credentials").into_response();
}
let token_store = match &state.tokens {
Some(ts) => ts,
None => {
return (
StatusCode::SERVICE_UNAVAILABLE,
"Token storage not configured",
)
.into_response()
}
};
match token_store.create_token(&req.username, req.ttl_days, req.description) {
Ok(token) => Json(CreateTokenResponse {
token,
expires_in_days: req.ttl_days,
})
.into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
/// List tokens for authenticated user
async fn list_tokens(
State(state): State<Arc<AppState>>,
Json(req): Json<CreateTokenRequest>,
) -> Response {
let auth = match &state.auth {
Some(auth) => auth,
None => return (StatusCode::SERVICE_UNAVAILABLE, "Auth not configured").into_response(),
};
if !auth.authenticate(&req.username, &req.password) {
return (StatusCode::UNAUTHORIZED, "Invalid credentials").into_response();
}
let token_store = match &state.tokens {
Some(ts) => ts,
None => {
return (
StatusCode::SERVICE_UNAVAILABLE,
"Token storage not configured",
)
.into_response()
}
};
let tokens: Vec<TokenListItem> = token_store
.list_tokens(&req.username)
.into_iter()
.map(|t| TokenListItem {
hash_prefix: t.token_hash[..16].to_string(),
created_at: t.created_at,
expires_at: t.expires_at,
last_used: t.last_used,
description: t.description,
})
.collect();
Json(TokenListResponse { tokens }).into_response()
}
#[derive(Deserialize)]
pub struct RevokeRequest {
pub username: String,
pub password: String,
pub hash_prefix: String,
}
/// Revoke a token
async fn revoke_token(
State(state): State<Arc<AppState>>,
Json(req): Json<RevokeRequest>,
) -> Response {
let auth = match &state.auth {
Some(auth) => auth,
None => return (StatusCode::SERVICE_UNAVAILABLE, "Auth not configured").into_response(),
};
if !auth.authenticate(&req.username, &req.password) {
return (StatusCode::UNAUTHORIZED, "Invalid credentials").into_response();
}
let token_store = match &state.tokens {
Some(ts) => ts,
None => {
return (
StatusCode::SERVICE_UNAVAILABLE,
"Token storage not configured",
)
.into_response()
}
};
match token_store.revoke_token(&req.hash_prefix) {
Ok(()) => (StatusCode::OK, "Token revoked").into_response(),
Err(e) => (StatusCode::NOT_FOUND, e.to_string()).into_response(),
}
}
/// Token management routes
pub fn token_routes() -> Router<Arc<AppState>> {
Router::new()
.route("/api/tokens", post(create_token))
.route("/api/tokens/list", post(list_tokens))
.route("/api/tokens/revoke", post(revoke_token))
}

299
nora-registry/src/backup.rs Normal file
View File

@@ -0,0 +1,299 @@
//! Backup and restore functionality for Nora
//!
//! Exports all artifacts to a tar.gz file and restores from backups.
use crate::storage::Storage;
use chrono::{DateTime, Utc};
use flate2::read::GzDecoder;
use flate2::write::GzEncoder;
use flate2::Compression;
use indicatif::{ProgressBar, ProgressStyle};
use serde::{Deserialize, Serialize};
use std::fs::File;
use std::io::Read;
use std::path::Path;
use tar::{Archive, Builder, Header};
/// Backup metadata stored in metadata.json
#[derive(Debug, Serialize, Deserialize)]
pub struct BackupMetadata {
pub version: String,
pub created_at: DateTime<Utc>,
pub artifact_count: usize,
pub total_bytes: u64,
pub storage_backend: String,
}
/// Statistics returned after backup
#[derive(Debug)]
pub struct BackupStats {
pub artifact_count: usize,
pub total_bytes: u64,
pub output_size: u64,
}
/// Statistics returned after restore
#[derive(Debug)]
pub struct RestoreStats {
pub artifact_count: usize,
pub total_bytes: u64,
}
/// Create a backup of all artifacts to a tar.gz file
pub async fn create_backup(storage: &Storage, output: &Path) -> Result<BackupStats, String> {
println!("Creating backup to: {}", output.display());
println!("Storage backend: {}", storage.backend_name());
// List all keys
println!("Scanning storage...");
let keys = storage.list("").await;
if keys.is_empty() {
println!("No artifacts found in storage. Creating empty backup.");
} else {
println!("Found {} artifacts", keys.len());
}
// Create output file
let file = File::create(output).map_err(|e| format!("Failed to create output file: {}", e))?;
let encoder = GzEncoder::new(file, Compression::default());
let mut archive = Builder::new(encoder);
// Progress bar
let pb = ProgressBar::new(keys.len() as u64);
pb.set_style(
ProgressStyle::default_bar()
.template(
"{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} ({eta})",
)
.expect("Invalid progress template")
.progress_chars("#>-"),
);
let mut total_bytes: u64 = 0;
let mut artifact_count = 0;
for key in &keys {
// Get file data
let data = match storage.get(key).await {
Ok(data) => data,
Err(e) => {
pb.println(format!("Warning: Failed to read {}: {}", key, e));
continue;
}
};
// Create tar header
let mut header = Header::new_gnu();
header.set_size(data.len() as u64);
header.set_mode(0o644);
header.set_mtime(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
);
header.set_cksum();
// Add to archive
archive
.append_data(&mut header, key, &*data)
.map_err(|e| format!("Failed to add {} to archive: {}", key, e))?;
total_bytes += data.len() as u64;
artifact_count += 1;
pb.inc(1);
}
// Add metadata.json
let metadata = BackupMetadata {
version: env!("CARGO_PKG_VERSION").to_string(),
created_at: Utc::now(),
artifact_count,
total_bytes,
storage_backend: storage.backend_name().to_string(),
};
let metadata_json = serde_json::to_vec_pretty(&metadata)
.map_err(|e| format!("Failed to serialize metadata: {}", e))?;
let mut header = Header::new_gnu();
header.set_size(metadata_json.len() as u64);
header.set_mode(0o644);
header.set_mtime(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
);
header.set_cksum();
archive
.append_data(&mut header, "metadata.json", metadata_json.as_slice())
.map_err(|e| format!("Failed to add metadata.json: {}", e))?;
// Finish archive
let encoder = archive
.into_inner()
.map_err(|e| format!("Failed to finish archive: {}", e))?;
encoder
.finish()
.map_err(|e| format!("Failed to finish compression: {}", e))?;
pb.finish_with_message("Backup complete");
// Get output file size
let output_size = std::fs::metadata(output).map(|m| m.len()).unwrap_or(0);
let stats = BackupStats {
artifact_count,
total_bytes,
output_size,
};
println!();
println!("Backup complete:");
println!(" Artifacts: {}", stats.artifact_count);
println!(" Total data: {} bytes", stats.total_bytes);
println!(" Backup file: {} bytes", stats.output_size);
println!(
" Compression ratio: {:.1}%",
if stats.total_bytes > 0 {
(stats.output_size as f64 / stats.total_bytes as f64) * 100.0
} else {
100.0
}
);
Ok(stats)
}
/// Restore artifacts from a backup file
pub async fn restore_backup(storage: &Storage, input: &Path) -> Result<RestoreStats, String> {
println!("Restoring from: {}", input.display());
println!("Storage backend: {}", storage.backend_name());
// Open backup file
let file = File::open(input).map_err(|e| format!("Failed to open backup file: {}", e))?;
let decoder = GzDecoder::new(file);
let mut archive = Archive::new(decoder);
// First pass: count entries and read metadata
let file = File::open(input).map_err(|e| format!("Failed to open backup file: {}", e))?;
let decoder = GzDecoder::new(file);
let mut archive_count = Archive::new(decoder);
let mut entry_count = 0;
let mut metadata: Option<BackupMetadata> = None;
for entry in archive_count
.entries()
.map_err(|e| format!("Failed to read archive: {}", e))?
{
let mut entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?;
let path = entry
.path()
.map_err(|e| format!("Failed to read path: {}", e))?
.to_string_lossy()
.to_string();
if path == "metadata.json" {
let mut data = Vec::new();
entry
.read_to_end(&mut data)
.map_err(|e| format!("Failed to read metadata: {}", e))?;
metadata = serde_json::from_slice(&data).ok();
} else {
entry_count += 1;
}
}
if let Some(ref meta) = metadata {
println!("Backup info:");
println!(" Version: {}", meta.version);
println!(" Created: {}", meta.created_at);
println!(" Artifacts: {}", meta.artifact_count);
println!(" Original size: {} bytes", meta.total_bytes);
println!();
}
// Progress bar
let pb = ProgressBar::new(entry_count as u64);
pb.set_style(
ProgressStyle::default_bar()
.template(
"{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} ({eta})",
)
.expect("Invalid progress template")
.progress_chars("#>-"),
);
let mut total_bytes: u64 = 0;
let mut artifact_count = 0;
// Second pass: restore files
for entry in archive
.entries()
.map_err(|e| format!("Failed to read archive: {}", e))?
{
let mut entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?;
let path = entry
.path()
.map_err(|e| format!("Failed to read path: {}", e))?
.to_string_lossy()
.to_string();
// Skip metadata file
if path == "metadata.json" {
continue;
}
// Read data
let mut data = Vec::new();
entry
.read_to_end(&mut data)
.map_err(|e| format!("Failed to read {}: {}", path, e))?;
// Put to storage
storage
.put(&path, &data)
.await
.map_err(|e| format!("Failed to store {}: {}", path, e))?;
total_bytes += data.len() as u64;
artifact_count += 1;
pb.inc(1);
}
pb.finish_with_message("Restore complete");
let stats = RestoreStats {
artifact_count,
total_bytes,
};
println!();
println!("Restore complete:");
println!(" Artifacts: {}", stats.artifact_count);
println!(" Total data: {} bytes", stats.total_bytes);
Ok(stats)
}
/// Format bytes for human-readable display
#[allow(dead_code)]
fn format_bytes(bytes: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = KB * 1024;
const GB: u64 = MB * 1024;
if bytes >= GB {
format!("{:.2} GB", bytes as f64 / GB as f64)
} else if bytes >= MB {
format!("{:.2} MB", bytes as f64 / MB as f64)
} else if bytes >= KB {
format!("{:.2} KB", bytes as f64 / KB as f64)
} else {
format!("{} B", bytes)
}
}

218
nora-registry/src/config.rs Normal file
View File

@@ -0,0 +1,218 @@
use serde::{Deserialize, Serialize};
use std::env;
use std::fs;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
pub server: ServerConfig,
pub storage: StorageConfig,
#[serde(default)]
pub maven: MavenConfig,
#[serde(default)]
pub npm: NpmConfig,
#[serde(default)]
pub auth: AuthConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerConfig {
pub host: String,
pub port: u16,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum StorageMode {
#[default]
Local,
S3,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StorageConfig {
#[serde(default)]
pub mode: StorageMode,
#[serde(default = "default_storage_path")]
pub path: String,
#[serde(default = "default_s3_url")]
pub s3_url: String,
#[serde(default = "default_bucket")]
pub bucket: String,
}
fn default_storage_path() -> String {
"data/storage".to_string()
}
fn default_s3_url() -> String {
"http://127.0.0.1:3000".to_string()
}
fn default_bucket() -> String {
"registry".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MavenConfig {
#[serde(default)]
pub proxies: Vec<String>,
#[serde(default = "default_timeout")]
pub proxy_timeout: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NpmConfig {
#[serde(default)]
pub proxy: Option<String>,
#[serde(default = "default_timeout")]
pub proxy_timeout: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_htpasswd_file")]
pub htpasswd_file: String,
#[serde(default = "default_token_storage")]
pub token_storage: String,
}
fn default_htpasswd_file() -> String {
"users.htpasswd".to_string()
}
fn default_token_storage() -> String {
"data/tokens".to_string()
}
fn default_timeout() -> u64 {
30
}
impl Default for MavenConfig {
fn default() -> Self {
Self {
proxies: vec!["https://repo1.maven.org/maven2".to_string()],
proxy_timeout: 30,
}
}
}
impl Default for NpmConfig {
fn default() -> Self {
Self {
proxy: Some("https://registry.npmjs.org".to_string()),
proxy_timeout: 30,
}
}
}
impl Default for AuthConfig {
fn default() -> Self {
Self {
enabled: false,
htpasswd_file: "users.htpasswd".to_string(),
token_storage: "data/tokens".to_string(),
}
}
}
impl Config {
/// Load configuration with priority: ENV > config.toml > defaults
pub fn load() -> Self {
// 1. Start with defaults
// 2. Override with config.toml if exists
let mut config: Config = fs::read_to_string("config.toml")
.ok()
.and_then(|content| toml::from_str(&content).ok())
.unwrap_or_default();
// 3. Override with ENV vars (highest priority)
config.apply_env_overrides();
config
}
/// Apply environment variable overrides
fn apply_env_overrides(&mut self) {
// Server config
if let Ok(val) = env::var("NORA_HOST") {
self.server.host = val;
}
if let Ok(val) = env::var("NORA_PORT") {
if let Ok(port) = val.parse() {
self.server.port = port;
}
}
// Storage config
if let Ok(val) = env::var("NORA_STORAGE_MODE") {
self.storage.mode = match val.to_lowercase().as_str() {
"s3" => StorageMode::S3,
_ => StorageMode::Local,
};
}
if let Ok(val) = env::var("NORA_STORAGE_PATH") {
self.storage.path = val;
}
if let Ok(val) = env::var("NORA_STORAGE_S3_URL") {
self.storage.s3_url = val;
}
if let Ok(val) = env::var("NORA_STORAGE_BUCKET") {
self.storage.bucket = val;
}
// Auth config
if let Ok(val) = env::var("NORA_AUTH_ENABLED") {
self.auth.enabled = val.to_lowercase() == "true" || val == "1";
}
if let Ok(val) = env::var("NORA_AUTH_HTPASSWD_FILE") {
self.auth.htpasswd_file = val;
}
// Maven config
if let Ok(val) = env::var("NORA_MAVEN_PROXIES") {
self.maven.proxies = val.split(',').map(|s| s.trim().to_string()).collect();
}
if let Ok(val) = env::var("NORA_MAVEN_PROXY_TIMEOUT") {
if let Ok(timeout) = val.parse() {
self.maven.proxy_timeout = timeout;
}
}
// npm config
if let Ok(val) = env::var("NORA_NPM_PROXY") {
self.npm.proxy = if val.is_empty() { None } else { Some(val) };
}
if let Ok(val) = env::var("NORA_NPM_PROXY_TIMEOUT") {
if let Ok(timeout) = val.parse() {
self.npm.proxy_timeout = timeout;
}
}
// Token storage
if let Ok(val) = env::var("NORA_AUTH_TOKEN_STORAGE") {
self.auth.token_storage = val;
}
}
}
impl Default for Config {
fn default() -> Self {
Self {
server: ServerConfig {
host: String::from("127.0.0.1"),
port: 4000,
},
storage: StorageConfig {
mode: StorageMode::Local,
path: String::from("data/storage"),
s3_url: String::from("http://127.0.0.1:3000"),
bucket: String::from("registry"),
},
maven: MavenConfig::default(),
npm: NpmConfig::default(),
auth: AuthConfig::default(),
}
}
}

View File

@@ -0,0 +1,89 @@
use axum::{extract::State, http::StatusCode, response::Json, routing::get, Router};
use serde::Serialize;
use std::sync::Arc;
use crate::AppState;
#[derive(Serialize)]
pub struct HealthStatus {
pub status: String,
pub version: String,
pub uptime_seconds: u64,
pub storage: StorageHealth,
pub registries: RegistriesHealth,
}
#[derive(Serialize)]
pub struct StorageHealth {
pub backend: String,
pub reachable: bool,
pub endpoint: String,
}
#[derive(Serialize)]
pub struct RegistriesHealth {
pub docker: String,
pub maven: String,
pub npm: String,
pub cargo: String,
pub pypi: String,
}
pub fn routes() -> Router<Arc<AppState>> {
Router::new()
.route("/health", get(health_check))
.route("/ready", get(readiness_check))
}
async fn health_check(State(state): State<Arc<AppState>>) -> (StatusCode, Json<HealthStatus>) {
let storage_reachable = check_storage_reachable(&state).await;
let status = if storage_reachable {
"healthy"
} else {
"unhealthy"
};
let uptime = state.start_time.elapsed().as_secs();
let health = HealthStatus {
status: status.to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
uptime_seconds: uptime,
storage: StorageHealth {
backend: state.storage.backend_name().to_string(),
reachable: storage_reachable,
endpoint: match state.storage.backend_name() {
"s3" => state.config.storage.s3_url.clone(),
_ => state.config.storage.path.clone(),
},
},
registries: RegistriesHealth {
docker: "ok".to_string(),
maven: "ok".to_string(),
npm: "ok".to_string(),
cargo: "ok".to_string(),
pypi: "ok".to_string(),
},
};
let status_code = if storage_reachable {
StatusCode::OK
} else {
StatusCode::SERVICE_UNAVAILABLE
};
(status_code, Json(health))
}
async fn readiness_check(State(state): State<Arc<AppState>>) -> StatusCode {
if check_storage_reachable(&state).await {
StatusCode::OK
} else {
StatusCode::SERVICE_UNAVAILABLE
}
}
async fn check_storage_reachable(state: &AppState) -> bool {
state.storage.health_check().await
}

267
nora-registry/src/main.rs Normal file
View File

@@ -0,0 +1,267 @@
mod auth;
mod backup;
mod config;
mod health;
mod metrics;
mod openapi;
mod registry;
mod storage;
mod tokens;
mod ui;
use axum::{middleware, Router};
use clap::{Parser, Subcommand};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Instant;
use tokio::signal;
use tracing::{error, info, warn};
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
use auth::HtpasswdAuth;
use config::{Config, StorageMode};
pub use storage::Storage;
use tokens::TokenStore;
#[derive(Parser)]
#[command(
name = "nora",
version,
about = "Multi-protocol artifact registry"
)]
struct Cli {
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(Subcommand)]
enum Commands {
/// Start the registry server (default)
Serve,
/// Backup all artifacts to a tar.gz file
Backup {
/// Output file path (e.g., backup.tar.gz)
#[arg(short, long)]
output: PathBuf,
},
/// Restore artifacts from a backup file
Restore {
/// Input backup file path
#[arg(short, long)]
input: PathBuf,
},
/// Migrate artifacts between storage backends
Migrate {
/// Source storage: local or s3
#[arg(long)]
from: String,
/// Destination storage: local or s3
#[arg(long)]
to: String,
/// Dry run - show what would be migrated without copying
#[arg(long, default_value = "false")]
dry_run: bool,
},
}
pub struct AppState {
pub storage: Storage,
pub config: Config,
pub start_time: Instant,
pub auth: Option<HtpasswdAuth>,
pub tokens: Option<TokenStore>,
}
#[tokio::main]
async fn main() {
let cli = Cli::parse();
// Initialize logging (JSON for server, plain for CLI commands)
let is_server = matches!(cli.command, None | Some(Commands::Serve));
init_logging(is_server);
let config = Config::load();
// Initialize storage based on mode
let storage = match config.storage.mode {
StorageMode::Local => {
if is_server {
info!(path = %config.storage.path, "Using local storage");
}
Storage::new_local(&config.storage.path)
}
StorageMode::S3 => {
if is_server {
info!(
s3_url = %config.storage.s3_url,
bucket = %config.storage.bucket,
"Using S3 storage"
);
}
Storage::new_s3(&config.storage.s3_url, &config.storage.bucket)
}
};
// Dispatch to command
match cli.command {
None | Some(Commands::Serve) => {
run_server(config, storage).await;
}
Some(Commands::Backup { output }) => {
if let Err(e) = backup::create_backup(&storage, &output).await {
error!("Backup failed: {}", e);
std::process::exit(1);
}
}
Some(Commands::Restore { input }) => {
if let Err(e) = backup::restore_backup(&storage, &input).await {
error!("Restore failed: {}", e);
std::process::exit(1);
}
}
Some(Commands::Migrate { from, to, dry_run }) => {
eprintln!("Migration from '{}' to '{}' (dry_run: {})", from, to, dry_run);
eprintln!("TODO: Migration not yet implemented");
std::process::exit(1);
}
}
}
fn init_logging(json_format: bool) {
let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
if json_format {
tracing_subscriber::registry()
.with(env_filter)
.with(fmt::layer().json().with_target(true))
.init();
} else {
tracing_subscriber::registry()
.with(env_filter)
.with(fmt::layer().with_target(false))
.init();
}
}
async fn run_server(config: Config, storage: Storage) {
let start_time = Instant::now();
// Load auth if enabled
let auth = if config.auth.enabled {
let path = Path::new(&config.auth.htpasswd_file);
match HtpasswdAuth::from_file(path) {
Some(auth) => {
info!(users = auth.list_users().len(), "Auth enabled");
Some(auth)
}
None => {
warn!(file = %config.auth.htpasswd_file, "Auth enabled but htpasswd file not found or empty");
None
}
}
} else {
None
};
// Initialize token store if auth is enabled
let tokens = if config.auth.enabled {
let token_path = Path::new(&config.auth.token_storage);
info!(path = %config.auth.token_storage, "Token storage initialized");
Some(TokenStore::new(token_path))
} else {
None
};
let state = Arc::new(AppState {
storage,
config,
start_time,
auth,
tokens,
});
let app = Router::new()
.merge(health::routes())
.merge(metrics::routes())
.merge(ui::routes())
.merge(openapi::routes())
.merge(auth::token_routes())
.merge(registry::docker_routes())
.merge(registry::maven_routes())
.merge(registry::npm_routes())
.merge(registry::cargo_routes())
.merge(registry::pypi_routes())
.layer(middleware::from_fn(metrics::metrics_middleware))
.layer(middleware::from_fn_with_state(
state.clone(),
auth::auth_middleware,
))
.with_state(state.clone());
let addr = format!("{}:{}", state.config.server.host, state.config.server.port);
let listener = tokio::net::TcpListener::bind(&addr)
.await
.expect("Failed to bind");
info!(
address = %addr,
version = env!("CARGO_PKG_VERSION"),
storage = state.storage.backend_name(),
auth_enabled = state.auth.is_some(),
"Nora started"
);
info!(
health = "/health",
ready = "/ready",
metrics = "/metrics",
ui = "/ui/",
api_docs = "/api-docs",
docker = "/v2/",
maven = "/maven2/",
npm = "/npm/",
cargo = "/cargo/",
pypi = "/simple/",
"Available endpoints"
);
// Graceful shutdown on SIGTERM/SIGINT
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await
.expect("Server error");
info!(
uptime_seconds = state.start_time.elapsed().as_secs(),
"Nora shutdown complete"
);
}
/// Wait for shutdown signal (SIGTERM or SIGINT)
async fn shutdown_signal() {
let ctrl_c = async {
signal::ctrl_c()
.await
.expect("Failed to install Ctrl+C handler");
};
#[cfg(unix)]
let terminate = async {
signal::unix::signal(signal::unix::SignalKind::terminate())
.expect("Failed to install SIGTERM handler")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {
info!("Received SIGINT, starting graceful shutdown...");
}
_ = terminate => {
info!("Received SIGTERM, starting graceful shutdown...");
}
}
}

View File

@@ -0,0 +1,147 @@
use axum::{
body::Body,
extract::MatchedPath,
http::Request,
middleware::Next,
response::{IntoResponse, Response},
routing::get,
Router,
};
use lazy_static::lazy_static;
use prometheus::{
register_histogram_vec, register_int_counter_vec, Encoder, HistogramVec, IntCounterVec,
TextEncoder,
};
use std::sync::Arc;
use std::time::Instant;
use crate::AppState;
lazy_static! {
/// Total HTTP requests counter
pub static ref HTTP_REQUESTS_TOTAL: IntCounterVec = register_int_counter_vec!(
"nora_http_requests_total",
"Total number of HTTP requests",
&["registry", "method", "status"]
).expect("metric can be created");
/// HTTP request duration histogram
pub static ref HTTP_REQUEST_DURATION: HistogramVec = register_histogram_vec!(
"nora_http_request_duration_seconds",
"HTTP request latency in seconds",
&["registry", "method"],
vec![0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0]
).expect("metric can be created");
/// Cache requests counter (hit/miss)
pub static ref CACHE_REQUESTS: IntCounterVec = register_int_counter_vec!(
"nora_cache_requests_total",
"Total cache requests",
&["registry", "result"]
).expect("metric can be created");
/// Storage operations counter
pub static ref STORAGE_OPERATIONS: IntCounterVec = register_int_counter_vec!(
"nora_storage_operations_total",
"Total storage operations",
&["operation", "status"]
).expect("metric can be created");
/// Artifacts count by registry
pub static ref ARTIFACTS_TOTAL: IntCounterVec = register_int_counter_vec!(
"nora_artifacts_total",
"Total artifacts stored",
&["registry"]
).expect("metric can be created");
}
/// Routes for metrics endpoint
pub fn routes() -> Router<Arc<AppState>> {
Router::new().route("/metrics", get(metrics_handler))
}
/// Handler for /metrics endpoint
async fn metrics_handler() -> impl IntoResponse {
let encoder = TextEncoder::new();
let metric_families = prometheus::gather();
let mut buffer = Vec::new();
encoder
.encode(&metric_families, &mut buffer)
.unwrap_or_default();
([("content-type", "text/plain; charset=utf-8")], buffer)
}
/// Middleware to record request metrics
pub async fn metrics_middleware(
matched_path: Option<MatchedPath>,
request: Request<Body>,
next: Next,
) -> Response {
let start = Instant::now();
let method = request.method().to_string();
let path = matched_path
.map(|p| p.as_str().to_string())
.unwrap_or_else(|| request.uri().path().to_string());
// Determine registry from path
let registry = detect_registry(&path);
// Process request
let response = next.run(request).await;
let duration = start.elapsed().as_secs_f64();
let status = response.status().as_u16().to_string();
// Record metrics
HTTP_REQUESTS_TOTAL
.with_label_values(&[&registry, &method, &status])
.inc();
HTTP_REQUEST_DURATION
.with_label_values(&[&registry, &method])
.observe(duration);
response
}
/// Detect registry type from path
fn detect_registry(path: &str) -> String {
if path.starts_with("/v2") {
"docker".to_string()
} else if path.starts_with("/maven2") {
"maven".to_string()
} else if path.starts_with("/npm") {
"npm".to_string()
} else if path.starts_with("/cargo") {
"cargo".to_string()
} else if path.starts_with("/simple") || path.starts_with("/packages") {
"pypi".to_string()
} else if path.starts_with("/ui") {
"ui".to_string()
} else {
"other".to_string()
}
}
/// Record cache hit
#[allow(dead_code)]
pub fn record_cache_hit(registry: &str) {
CACHE_REQUESTS.with_label_values(&[registry, "hit"]).inc();
}
/// Record cache miss
#[allow(dead_code)]
pub fn record_cache_miss(registry: &str) {
CACHE_REQUESTS.with_label_values(&[registry, "miss"]).inc();
}
/// Record storage operation
#[allow(dead_code)]
pub fn record_storage_op(operation: &str, success: bool) {
let status = if success { "success" } else { "error" };
STORAGE_OPERATIONS
.with_label_values(&[operation, status])
.inc();
}

View File

@@ -0,0 +1,382 @@
//! OpenAPI documentation and Swagger UI
//!
//! Functions in this module are stubs used only for generating OpenAPI documentation.
#![allow(dead_code)]
use axum::Router;
use std::sync::Arc;
use utoipa::OpenApi;
use utoipa_swagger_ui::SwaggerUi;
use crate::AppState;
#[derive(OpenApi)]
#[openapi(
info(
title = "Nora",
version = "0.1.0",
description = "Multi-protocol package registry supporting Docker, Maven, npm, Cargo, and PyPI",
license(name = "MIT"),
contact(name = "DevITWay", url = "https://github.com/getnora-io/nora")
),
servers(
(url = "/", description = "Current server")
),
tags(
(name = "health", description = "Health check endpoints"),
(name = "docker", description = "Docker Registry v2 API"),
(name = "maven", description = "Maven Repository API"),
(name = "npm", description = "npm Registry API"),
(name = "cargo", description = "Cargo Registry API"),
(name = "pypi", description = "PyPI Simple API"),
(name = "auth", description = "Authentication & API Tokens")
),
paths(
// Health
crate::openapi::health_check,
crate::openapi::readiness_check,
// Docker
crate::openapi::docker_version,
crate::openapi::docker_catalog,
crate::openapi::docker_tags,
crate::openapi::docker_manifest,
crate::openapi::docker_blob,
// Maven
crate::openapi::maven_artifact,
// npm
crate::openapi::npm_package,
// PyPI
crate::openapi::pypi_simple,
crate::openapi::pypi_package,
// Tokens
crate::openapi::create_token,
crate::openapi::list_tokens,
crate::openapi::revoke_token,
),
components(
schemas(
HealthResponse,
StorageHealth,
RegistriesHealth,
DockerVersion,
DockerCatalog,
DockerTags,
TokenRequest,
TokenResponse,
TokenListResponse,
TokenInfo,
ErrorResponse
)
)
)]
pub struct ApiDoc;
// ============ Schemas ============
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Serialize, ToSchema)]
pub struct HealthResponse {
/// Current health status
pub status: String,
/// Application version
pub version: String,
/// Uptime in seconds
pub uptime_seconds: u64,
/// Storage backend health
pub storage: StorageHealth,
/// Registry health status
pub registries: RegistriesHealth,
}
#[derive(Serialize, ToSchema)]
pub struct StorageHealth {
/// Backend type (local, s3)
pub backend: String,
/// Whether storage is reachable
pub reachable: bool,
/// Storage endpoint/path
pub endpoint: String,
}
#[derive(Serialize, ToSchema)]
pub struct RegistriesHealth {
pub docker: String,
pub maven: String,
pub npm: String,
pub cargo: String,
pub pypi: String,
}
#[derive(Serialize, ToSchema)]
pub struct DockerVersion {
/// API version
#[serde(rename = "Docker-Distribution-API-Version")]
pub version: String,
}
#[derive(Serialize, ToSchema)]
pub struct DockerCatalog {
/// List of repository names
pub repositories: Vec<String>,
}
#[derive(Serialize, ToSchema)]
pub struct DockerTags {
/// Repository name
pub name: String,
/// List of tags
pub tags: Vec<String>,
}
#[derive(Deserialize, ToSchema)]
pub struct TokenRequest {
/// Username for authentication
pub username: String,
/// Password for authentication
pub password: String,
/// Token TTL in days (default: 30)
#[serde(default = "default_ttl")]
pub ttl_days: u32,
/// Optional description
pub description: Option<String>,
}
fn default_ttl() -> u32 {
30
}
#[derive(Serialize, ToSchema)]
pub struct TokenResponse {
/// Generated API token (starts with nra_)
pub token: String,
/// Token expiration in days
pub expires_in_days: u32,
}
#[derive(Serialize, ToSchema)]
pub struct TokenListResponse {
/// List of tokens
pub tokens: Vec<TokenInfo>,
}
#[derive(Serialize, ToSchema)]
pub struct TokenInfo {
/// Token hash prefix (for identification)
pub hash_prefix: String,
/// Creation timestamp
pub created_at: u64,
/// Expiration timestamp
pub expires_at: u64,
/// Last used timestamp
pub last_used: Option<u64>,
/// Description
pub description: Option<String>,
}
#[derive(Serialize, ToSchema)]
pub struct ErrorResponse {
/// Error message
pub error: String,
}
// ============ Path Operations (documentation only) ============
/// Health check endpoint
#[utoipa::path(
get,
path = "/health",
tag = "health",
responses(
(status = 200, description = "Service is healthy", body = HealthResponse),
(status = 503, description = "Service is unhealthy", body = HealthResponse)
)
)]
pub async fn health_check() {}
/// Readiness probe
#[utoipa::path(
get,
path = "/ready",
tag = "health",
responses(
(status = 200, description = "Service is ready"),
(status = 503, description = "Service is not ready")
)
)]
pub async fn readiness_check() {}
/// Docker Registry version check
#[utoipa::path(
get,
path = "/v2/",
tag = "docker",
responses(
(status = 200, description = "Registry is available", body = DockerVersion),
(status = 401, description = "Authentication required")
)
)]
pub async fn docker_version() {}
/// List all repositories
#[utoipa::path(
get,
path = "/v2/_catalog",
tag = "docker",
responses(
(status = 200, description = "Repository list", body = DockerCatalog)
)
)]
pub async fn docker_catalog() {}
/// List tags for a repository
#[utoipa::path(
get,
path = "/v2/{name}/tags/list",
tag = "docker",
params(
("name" = String, Path, description = "Repository name")
),
responses(
(status = 200, description = "Tag list", body = DockerTags),
(status = 404, description = "Repository not found")
)
)]
pub async fn docker_tags() {}
/// Get manifest
#[utoipa::path(
get,
path = "/v2/{name}/manifests/{reference}",
tag = "docker",
params(
("name" = String, Path, description = "Repository name"),
("reference" = String, Path, description = "Tag or digest")
),
responses(
(status = 200, description = "Manifest content"),
(status = 404, description = "Manifest not found")
)
)]
pub async fn docker_manifest() {}
/// Get blob
#[utoipa::path(
get,
path = "/v2/{name}/blobs/{digest}",
tag = "docker",
params(
("name" = String, Path, description = "Repository name"),
("digest" = String, Path, description = "Blob digest (sha256:...)")
),
responses(
(status = 200, description = "Blob content"),
(status = 404, description = "Blob not found")
)
)]
pub async fn docker_blob() {}
/// Get Maven artifact
#[utoipa::path(
get,
path = "/maven2/{path}",
tag = "maven",
params(
("path" = String, Path, description = "Artifact path (e.g., org/apache/commons/commons-lang3/3.12.0/commons-lang3-3.12.0.jar)")
),
responses(
(status = 200, description = "Artifact content"),
(status = 404, description = "Artifact not found, trying upstream proxies")
)
)]
pub async fn maven_artifact() {}
/// Get npm package metadata
#[utoipa::path(
get,
path = "/npm/{name}",
tag = "npm",
params(
("name" = String, Path, description = "Package name")
),
responses(
(status = 200, description = "Package metadata (JSON)"),
(status = 404, description = "Package not found")
)
)]
pub async fn npm_package() {}
/// PyPI Simple index
#[utoipa::path(
get,
path = "/simple/",
tag = "pypi",
responses(
(status = 200, description = "HTML list of packages")
)
)]
pub async fn pypi_simple() {}
/// PyPI package page
#[utoipa::path(
get,
path = "/simple/{name}/",
tag = "pypi",
params(
("name" = String, Path, description = "Package name")
),
responses(
(status = 200, description = "HTML list of package files"),
(status = 404, description = "Package not found")
)
)]
pub async fn pypi_package() {}
/// Create API token
#[utoipa::path(
post,
path = "/api/tokens",
tag = "auth",
request_body = TokenRequest,
responses(
(status = 200, description = "Token created", body = TokenResponse),
(status = 401, description = "Invalid credentials", body = ErrorResponse),
(status = 400, description = "Auth not configured", body = ErrorResponse)
)
)]
pub async fn create_token() {}
/// List user's tokens
#[utoipa::path(
post,
path = "/api/tokens/list",
tag = "auth",
request_body = TokenRequest,
responses(
(status = 200, description = "Token list", body = TokenListResponse),
(status = 401, description = "Invalid credentials", body = ErrorResponse)
)
)]
pub async fn list_tokens() {}
/// Revoke a token
#[utoipa::path(
post,
path = "/api/tokens/revoke",
tag = "auth",
responses(
(status = 200, description = "Token revoked"),
(status = 401, description = "Invalid credentials", body = ErrorResponse),
(status = 404, description = "Token not found", body = ErrorResponse)
)
)]
pub async fn revoke_token() {}
// ============ Routes ============
pub fn routes() -> Router<Arc<AppState>> {
Router::new()
.merge(SwaggerUi::new("/api-docs").url("/api-docs/openapi.json", ApiDoc::openapi()))
}

View File

@@ -0,0 +1,43 @@
use crate::AppState;
use axum::{
extract::{Path, State},
http::StatusCode,
response::{IntoResponse, Response},
routing::get,
Router,
};
use std::sync::Arc;
pub fn routes() -> Router<Arc<AppState>> {
Router::new()
.route("/cargo/api/v1/crates/{crate_name}", get(get_metadata))
.route(
"/cargo/api/v1/crates/{crate_name}/{version}/download",
get(download),
)
}
async fn get_metadata(
State(state): State<Arc<AppState>>,
Path(crate_name): Path<String>,
) -> Response {
let key = format!("cargo/{}/metadata.json", crate_name);
match state.storage.get(&key).await {
Ok(data) => (StatusCode::OK, data).into_response(),
Err(_) => StatusCode::NOT_FOUND.into_response(),
}
}
async fn download(
State(state): State<Arc<AppState>>,
Path((crate_name, version)): Path<(String, String)>,
) -> Response {
let key = format!(
"cargo/{}/{}/{}-{}.crate",
crate_name, version, crate_name, version
);
match state.storage.get(&key).await {
Ok(data) => (StatusCode::OK, data).into_response(),
Err(_) => StatusCode::NOT_FOUND.into_response(),
}
}

View File

@@ -0,0 +1,154 @@
use crate::AppState;
use axum::{
body::Bytes,
extract::{Path, State},
http::{header, StatusCode},
response::{IntoResponse, Response},
routing::{get, head, put},
Json, Router,
};
use serde_json::{json, Value};
use std::sync::Arc;
pub fn routes() -> Router<Arc<AppState>> {
Router::new()
.route("/v2/", get(check))
.route("/v2/{name}/blobs/{digest}", head(check_blob))
.route("/v2/{name}/blobs/{digest}", get(download_blob))
.route(
"/v2/{name}/blobs/uploads/",
axum::routing::post(start_upload),
)
.route("/v2/{name}/blobs/uploads/{uuid}", put(upload_blob))
.route("/v2/{name}/manifests/{reference}", get(get_manifest))
.route("/v2/{name}/manifests/{reference}", put(put_manifest))
.route("/v2/{name}/tags/list", get(list_tags))
}
async fn check() -> (StatusCode, Json<Value>) {
(StatusCode::OK, Json(json!({})))
}
async fn check_blob(
State(state): State<Arc<AppState>>,
Path((name, digest)): Path<(String, String)>,
) -> Response {
let key = format!("docker/{}/blobs/{}", name, digest);
match state.storage.get(&key).await {
Ok(data) => (
StatusCode::OK,
[(header::CONTENT_LENGTH, data.len().to_string())],
)
.into_response(),
Err(_) => StatusCode::NOT_FOUND.into_response(),
}
}
async fn download_blob(
State(state): State<Arc<AppState>>,
Path((name, digest)): Path<(String, String)>,
) -> Response {
let key = format!("docker/{}/blobs/{}", name, digest);
match state.storage.get(&key).await {
Ok(data) => (
StatusCode::OK,
[(header::CONTENT_TYPE, "application/octet-stream")],
data,
)
.into_response(),
Err(_) => StatusCode::NOT_FOUND.into_response(),
}
}
async fn start_upload(Path(name): Path<String>) -> Response {
let uuid = uuid::Uuid::new_v4().to_string();
let location = format!("/v2/{}/blobs/uploads/{}", name, uuid);
(
StatusCode::ACCEPTED,
[
(header::LOCATION, location.clone()),
("Docker-Upload-UUID".parse().unwrap(), uuid),
],
)
.into_response()
}
async fn upload_blob(
State(state): State<Arc<AppState>>,
Path((name, _uuid)): Path<(String, String)>,
axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
body: Bytes,
) -> Response {
let digest = match params.get("digest") {
Some(d) => d,
None => return StatusCode::BAD_REQUEST.into_response(),
};
let key = format!("docker/{}/blobs/{}", name, digest);
match state.storage.put(&key, &body).await {
Ok(()) => {
let location = format!("/v2/{}/blobs/{}", name, digest);
(StatusCode::CREATED, [(header::LOCATION, location)]).into_response()
}
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
}
}
async fn get_manifest(
State(state): State<Arc<AppState>>,
Path((name, reference)): Path<(String, String)>,
) -> Response {
let key = format!("docker/{}/manifests/{}.json", name, reference);
match state.storage.get(&key).await {
Ok(data) => (
StatusCode::OK,
[(
header::CONTENT_TYPE,
"application/vnd.docker.distribution.manifest.v2+json",
)],
data,
)
.into_response(),
Err(_) => StatusCode::NOT_FOUND.into_response(),
}
}
async fn put_manifest(
State(state): State<Arc<AppState>>,
Path((name, reference)): Path<(String, String)>,
body: Bytes,
) -> Response {
let key = format!("docker/{}/manifests/{}.json", name, reference);
match state.storage.put(&key, &body).await {
Ok(()) => {
use sha2::Digest;
let digest = format!("sha256:{:x}", sha2::Sha256::digest(&body));
let location = format!("/v2/{}/manifests/{}", name, reference);
(
StatusCode::CREATED,
[
(header::LOCATION, location),
("Docker-Content-Digest".parse().unwrap(), digest),
],
)
.into_response()
}
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
}
}
async fn list_tags(
State(state): State<Arc<AppState>>,
Path(name): Path<String>,
) -> (StatusCode, Json<Value>) {
let prefix = format!("docker/{}/manifests/", name);
let keys = state.storage.list(&prefix).await;
let tags: Vec<String> = keys
.iter()
.filter_map(|k| {
k.strip_prefix(&prefix)
.and_then(|t| t.strip_suffix(".json"))
.map(String::from)
})
.collect();
(StatusCode::OK, Json(json!({"name": name, "tags": tags})))
}

View File

@@ -0,0 +1,94 @@
use crate::AppState;
use axum::{
body::Bytes,
extract::{Path, State},
http::{header, StatusCode},
response::{IntoResponse, Response},
routing::{get, put},
Router,
};
use std::sync::Arc;
use std::time::Duration;
pub fn routes() -> Router<Arc<AppState>> {
Router::new()
.route("/maven2/{*path}", get(download))
.route("/maven2/{*path}", put(upload))
}
async fn download(State(state): State<Arc<AppState>>, Path(path): Path<String>) -> Response {
let key = format!("maven/{}", path);
// Try local storage first
if let Ok(data) = state.storage.get(&key).await {
return with_content_type(&path, data).into_response();
}
// Try proxy servers
for proxy_url in &state.config.maven.proxies {
let url = format!("{}/{}", proxy_url.trim_end_matches('/'), path);
match fetch_from_proxy(&url, state.config.maven.proxy_timeout).await {
Ok(data) => {
// Cache in local storage (fire and forget)
let storage = state.storage.clone();
let key_clone = key.clone();
let data_clone = data.clone();
tokio::spawn(async move {
let _ = storage.put(&key_clone, &data_clone).await;
});
return with_content_type(&path, data.into()).into_response();
}
Err(_) => continue,
}
}
StatusCode::NOT_FOUND.into_response()
}
async fn upload(
State(state): State<Arc<AppState>>,
Path(path): Path<String>,
body: Bytes,
) -> StatusCode {
let key = format!("maven/{}", path);
match state.storage.put(&key, &body).await {
Ok(()) => StatusCode::CREATED,
Err(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
async fn fetch_from_proxy(url: &str, timeout_secs: u64) -> Result<Vec<u8>, ()> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(timeout_secs))
.build()
.map_err(|_| ())?;
let response = client.get(url).send().await.map_err(|_| ())?;
if !response.status().is_success() {
return Err(());
}
response.bytes().await.map(|b| b.to_vec()).map_err(|_| ())
}
fn with_content_type(
path: &str,
data: Bytes,
) -> (StatusCode, [(header::HeaderName, &'static str); 1], Bytes) {
let content_type = if path.ends_with(".pom") {
"application/xml"
} else if path.ends_with(".jar") {
"application/java-archive"
} else if path.ends_with(".xml") {
"application/xml"
} else if path.ends_with(".sha1") || path.ends_with(".md5") {
"text/plain"
} else {
"application/octet-stream"
};
(StatusCode::OK, [(header::CONTENT_TYPE, content_type)], data)
}

View File

@@ -0,0 +1,11 @@
mod cargo_registry;
mod docker;
mod maven;
mod npm;
mod pypi;
pub use cargo_registry::routes as cargo_routes;
pub use docker::routes as docker_routes;
pub use maven::routes as maven_routes;
pub use npm::routes as npm_routes;
pub use pypi::routes as pypi_routes;

View File

@@ -0,0 +1,89 @@
use crate::AppState;
use axum::{
body::Bytes,
extract::{Path, State},
http::{header, StatusCode},
response::{IntoResponse, Response},
routing::get,
Router,
};
use std::sync::Arc;
use std::time::Duration;
pub fn routes() -> Router<Arc<AppState>> {
Router::new().route("/npm/{*path}", get(handle_request))
}
async fn handle_request(State(state): State<Arc<AppState>>, Path(path): Path<String>) -> Response {
// Determine if this is a tarball request or metadata request
let is_tarball = path.contains("/-/");
let key = if is_tarball {
let parts: Vec<&str> = path.split("/-/").collect();
if parts.len() == 2 {
format!("npm/{}/tarballs/{}", parts[0], parts[1])
} else {
format!("npm/{}", path)
}
} else {
format!("npm/{}/metadata.json", path)
};
// Try local storage first
if let Ok(data) = state.storage.get(&key).await {
return with_content_type(is_tarball, data).into_response();
}
// Try proxy if configured
if let Some(proxy_url) = &state.config.npm.proxy {
let url = if is_tarball {
// Tarball URL: https://registry.npmjs.org/package/-/package-version.tgz
format!("{}/{}", proxy_url.trim_end_matches('/'), path)
} else {
// Metadata URL: https://registry.npmjs.org/package
format!("{}/{}", proxy_url.trim_end_matches('/'), path)
};
if let Ok(data) = fetch_from_proxy(&url, state.config.npm.proxy_timeout).await {
// Cache in local storage (fire and forget)
let storage = state.storage.clone();
let key_clone = key.clone();
let data_clone = data.clone();
tokio::spawn(async move {
let _ = storage.put(&key_clone, &data_clone).await;
});
return with_content_type(is_tarball, data.into()).into_response();
}
}
StatusCode::NOT_FOUND.into_response()
}
async fn fetch_from_proxy(url: &str, timeout_secs: u64) -> Result<Vec<u8>, ()> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(timeout_secs))
.build()
.map_err(|_| ())?;
let response = client.get(url).send().await.map_err(|_| ())?;
if !response.status().is_success() {
return Err(());
}
response.bytes().await.map(|b| b.to_vec()).map_err(|_| ())
}
fn with_content_type(
is_tarball: bool,
data: Bytes,
) -> (StatusCode, [(header::HeaderName, &'static str); 1], Bytes) {
let content_type = if is_tarball {
"application/octet-stream"
} else {
"application/json"
};
(StatusCode::OK, [(header::CONTENT_TYPE, content_type)], data)
}

View File

@@ -0,0 +1,35 @@
use crate::AppState;
use axum::{
extract::State,
http::StatusCode,
response::{Html, IntoResponse},
routing::get,
Router,
};
use std::sync::Arc;
pub fn routes() -> Router<Arc<AppState>> {
Router::new().route("/simple/", get(list_packages))
}
async fn list_packages(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let keys = state.storage.list("pypi/").await;
let mut packages = std::collections::HashSet::new();
for key in keys {
if let Some(pkg) = key.strip_prefix("pypi/").and_then(|k| k.split('/').next()) {
packages.insert(pkg.to_string());
}
}
let mut html = String::from("<html><body><h1>Simple Index</h1>");
let mut pkg_list: Vec<_> = packages.into_iter().collect();
pkg_list.sort();
for pkg in pkg_list {
html.push_str(&format!("<a href=\"/simple/{}/\">{}</a><br>", pkg, pkg));
}
html.push_str("</body></html>");
(StatusCode::OK, Html(html))
}

View File

@@ -0,0 +1,131 @@
use async_trait::async_trait;
use axum::body::Bytes;
use std::path::PathBuf;
use tokio::fs;
use tokio::io::AsyncReadExt;
use super::{FileMeta, Result, StorageBackend, StorageError};
/// Local filesystem storage backend (zero-config default)
pub struct LocalStorage {
base_path: PathBuf,
}
impl LocalStorage {
pub fn new(path: &str) -> Self {
Self {
base_path: PathBuf::from(path),
}
}
fn key_to_path(&self, key: &str) -> PathBuf {
self.base_path.join(key)
}
/// Recursively list all files under a directory (sync helper)
fn list_files_sync(dir: &PathBuf, base: &PathBuf, prefix: &str, results: &mut Vec<String>) {
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() {
if let Ok(rel_path) = path.strip_prefix(base) {
let key = rel_path.to_string_lossy().replace('\\', "/");
if key.starts_with(prefix) || prefix.is_empty() {
results.push(key);
}
}
} else if path.is_dir() {
Self::list_files_sync(&path, base, prefix, results);
}
}
}
}
}
#[async_trait]
impl StorageBackend for LocalStorage {
async fn put(&self, key: &str, data: &[u8]) -> Result<()> {
let path = self.key_to_path(key);
// Create parent directories
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.await
.map_err(|e| StorageError::Io(e.to_string()))?;
}
// Write file
fs::write(&path, data)
.await
.map_err(|e| StorageError::Io(e.to_string()))?;
Ok(())
}
async fn get(&self, key: &str) -> Result<Bytes> {
let path = self.key_to_path(key);
if !path.exists() {
return Err(StorageError::NotFound);
}
let mut file = fs::File::open(&path).await.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
StorageError::NotFound
} else {
StorageError::Io(e.to_string())
}
})?;
let mut buffer = Vec::new();
file.read_to_end(&mut buffer)
.await
.map_err(|e| StorageError::Io(e.to_string()))?;
Ok(Bytes::from(buffer))
}
async fn list(&self, prefix: &str) -> Vec<String> {
let base = self.base_path.clone();
let prefix = prefix.to_string();
// Use blocking task for filesystem traversal
tokio::task::spawn_blocking(move || {
let mut results = Vec::new();
if base.exists() {
Self::list_files_sync(&base, &base, &prefix, &mut results);
}
results.sort();
results
})
.await
.unwrap_or_default()
}
async fn stat(&self, key: &str) -> Option<FileMeta> {
let path = self.key_to_path(key);
let metadata = fs::metadata(&path).await.ok()?;
let modified = metadata
.modified()
.ok()?
.duration_since(std::time::UNIX_EPOCH)
.ok()?
.as_secs();
Some(FileMeta {
size: metadata.len(),
modified,
})
}
async fn health_check(&self) -> bool {
// For local storage, just check if base directory exists or can be created
if self.base_path.exists() {
return true;
}
fs::create_dir_all(&self.base_path).await.is_ok()
}
fn backend_name(&self) -> &'static str {
"local"
}
}

View File

@@ -0,0 +1,93 @@
mod local;
mod s3;
pub use local::LocalStorage;
pub use s3::S3Storage;
use async_trait::async_trait;
use axum::body::Bytes;
use std::fmt;
use std::sync::Arc;
/// File metadata
#[derive(Debug, Clone)]
pub struct FileMeta {
pub size: u64,
pub modified: u64, // Unix timestamp
}
#[derive(Debug)]
pub enum StorageError {
Network(String),
NotFound,
Io(String),
}
impl fmt::Display for StorageError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Network(msg) => write!(f, "Network error: {}", msg),
Self::NotFound => write!(f, "Object not found"),
Self::Io(msg) => write!(f, "IO error: {}", msg),
}
}
}
impl std::error::Error for StorageError {}
pub type Result<T> = std::result::Result<T, StorageError>;
/// Storage backend trait
#[async_trait]
pub trait StorageBackend: Send + Sync {
async fn put(&self, key: &str, data: &[u8]) -> Result<()>;
async fn get(&self, key: &str) -> Result<Bytes>;
async fn list(&self, prefix: &str) -> Vec<String>;
async fn stat(&self, key: &str) -> Option<FileMeta>;
async fn health_check(&self) -> bool;
fn backend_name(&self) -> &'static str;
}
/// Storage wrapper for dynamic dispatch
#[derive(Clone)]
pub struct Storage {
inner: Arc<dyn StorageBackend>,
}
impl Storage {
pub fn new_local(path: &str) -> Self {
Self {
inner: Arc::new(LocalStorage::new(path)),
}
}
pub fn new_s3(s3_url: &str, bucket: &str) -> Self {
Self {
inner: Arc::new(S3Storage::new(s3_url, bucket)),
}
}
pub async fn put(&self, key: &str, data: &[u8]) -> Result<()> {
self.inner.put(key, data).await
}
pub async fn get(&self, key: &str) -> Result<Bytes> {
self.inner.get(key).await
}
pub async fn list(&self, prefix: &str) -> Vec<String> {
self.inner.list(prefix).await
}
pub async fn stat(&self, key: &str) -> Option<FileMeta> {
self.inner.stat(key).await
}
pub async fn health_check(&self) -> bool {
self.inner.health_check().await
}
pub fn backend_name(&self) -> &'static str {
self.inner.backend_name()
}
}

View File

@@ -0,0 +1,129 @@
use async_trait::async_trait;
use axum::body::Bytes;
use super::{FileMeta, Result, StorageBackend, StorageError};
/// S3-compatible storage backend (MinIO, AWS S3)
pub struct S3Storage {
s3_url: String,
bucket: String,
client: reqwest::Client,
}
impl S3Storage {
pub fn new(s3_url: &str, bucket: &str) -> Self {
Self {
s3_url: s3_url.to_string(),
bucket: bucket.to_string(),
client: reqwest::Client::new(),
}
}
fn parse_s3_keys(xml: &str, prefix: &str) -> Vec<String> {
xml.split("<Key>")
.filter_map(|part| part.split("</Key>").next())
.filter(|key| key.starts_with(prefix))
.map(String::from)
.collect()
}
}
#[async_trait]
impl StorageBackend for S3Storage {
async fn put(&self, key: &str, data: &[u8]) -> Result<()> {
let url = format!("{}/{}/{}", self.s3_url, self.bucket, key);
let response = self
.client
.put(&url)
.body(data.to_vec())
.send()
.await
.map_err(|e| StorageError::Network(e.to_string()))?;
if response.status().is_success() {
Ok(())
} else {
Err(StorageError::Network(format!(
"PUT failed: {}",
response.status()
)))
}
}
async fn get(&self, key: &str) -> Result<Bytes> {
let url = format!("{}/{}/{}", self.s3_url, self.bucket, key);
let response = self
.client
.get(&url)
.send()
.await
.map_err(|e| StorageError::Network(e.to_string()))?;
if response.status().is_success() {
response
.bytes()
.await
.map_err(|e| StorageError::Network(e.to_string()))
} else if response.status().as_u16() == 404 {
Err(StorageError::NotFound)
} else {
Err(StorageError::Network(format!(
"GET failed: {}",
response.status()
)))
}
}
async fn list(&self, prefix: &str) -> Vec<String> {
let url = format!("{}/{}", self.s3_url, self.bucket);
match self.client.get(&url).send().await {
Ok(response) if response.status().is_success() => {
if let Ok(xml) = response.text().await {
Self::parse_s3_keys(&xml, prefix)
} else {
Vec::new()
}
}
_ => Vec::new(),
}
}
async fn stat(&self, key: &str) -> Option<FileMeta> {
let url = format!("{}/{}/{}", self.s3_url, self.bucket, key);
let response = self.client.head(&url).send().await.ok()?;
if !response.status().is_success() {
return None;
}
let size = response
.headers()
.get("content-length")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse().ok())
.unwrap_or(0);
// S3 uses Last-Modified header, but for simplicity use current time if unavailable
let modified = response
.headers()
.get("last-modified")
.and_then(|v| v.to_str().ok())
.and_then(|v| httpdate::parse_http_date(v).ok())
.map(|t| {
t.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
})
.unwrap_or(0);
Some(FileMeta { size, modified })
}
async fn health_check(&self) -> bool {
let url = format!("{}/{}", self.s3_url, self.bucket);
match self.client.head(&url).send().await {
Ok(response) => response.status().is_success() || response.status().as_u16() == 404,
Err(_) => false,
}
}
fn backend_name(&self) -> &'static str {
"s3"
}
}

202
nora-registry/src/tokens.rs Normal file
View File

@@ -0,0 +1,202 @@
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use uuid::Uuid;
const TOKEN_PREFIX: &str = "nra_";
/// API Token metadata stored on disk
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TokenInfo {
pub token_hash: String,
pub user: String,
pub created_at: u64,
pub expires_at: u64,
pub last_used: Option<u64>,
pub description: Option<String>,
}
/// Token store for managing API tokens
#[derive(Clone)]
pub struct TokenStore {
storage_path: PathBuf,
}
impl TokenStore {
/// Create a new token store
pub fn new(storage_path: &Path) -> Self {
// Ensure directory exists
let _ = fs::create_dir_all(storage_path);
Self {
storage_path: storage_path.to_path_buf(),
}
}
/// Generate a new API token for a user
pub fn create_token(
&self,
user: &str,
ttl_days: u64,
description: Option<String>,
) -> Result<String, TokenError> {
// Generate random token
let raw_token = format!(
"{}{}",
TOKEN_PREFIX,
Uuid::new_v4().to_string().replace("-", "")
);
let token_hash = hash_token(&raw_token);
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let expires_at = now + (ttl_days * 24 * 60 * 60);
let info = TokenInfo {
token_hash: token_hash.clone(),
user: user.to_string(),
created_at: now,
expires_at,
last_used: None,
description,
};
// Save to file
let file_path = self
.storage_path
.join(format!("{}.json", &token_hash[..16]));
let json =
serde_json::to_string_pretty(&info).map_err(|e| TokenError::Storage(e.to_string()))?;
fs::write(&file_path, json).map_err(|e| TokenError::Storage(e.to_string()))?;
Ok(raw_token)
}
/// Verify a token and return user info if valid
pub fn verify_token(&self, token: &str) -> Result<String, TokenError> {
if !token.starts_with(TOKEN_PREFIX) {
return Err(TokenError::InvalidFormat);
}
let token_hash = hash_token(token);
let file_path = self
.storage_path
.join(format!("{}.json", &token_hash[..16]));
if !file_path.exists() {
return Err(TokenError::NotFound);
}
let content =
fs::read_to_string(&file_path).map_err(|e| TokenError::Storage(e.to_string()))?;
let mut info: TokenInfo =
serde_json::from_str(&content).map_err(|e| TokenError::Storage(e.to_string()))?;
// Verify hash matches
if info.token_hash != token_hash {
return Err(TokenError::NotFound);
}
// Check expiration
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
if now > info.expires_at {
return Err(TokenError::Expired);
}
// Update last_used
info.last_used = Some(now);
if let Ok(json) = serde_json::to_string_pretty(&info) {
let _ = fs::write(&file_path, json);
}
Ok(info.user)
}
/// List all tokens for a user
pub fn list_tokens(&self, user: &str) -> Vec<TokenInfo> {
let mut tokens = Vec::new();
if let Ok(entries) = fs::read_dir(&self.storage_path) {
for entry in entries.flatten() {
if let Ok(content) = fs::read_to_string(entry.path()) {
if let Ok(info) = serde_json::from_str::<TokenInfo>(&content) {
if info.user == user {
tokens.push(info);
}
}
}
}
}
tokens.sort_by(|a, b| b.created_at.cmp(&a.created_at));
tokens
}
/// Revoke a token by its hash prefix
pub fn revoke_token(&self, hash_prefix: &str) -> Result<(), TokenError> {
let file_path = self.storage_path.join(format!("{}.json", hash_prefix));
if !file_path.exists() {
return Err(TokenError::NotFound);
}
fs::remove_file(&file_path).map_err(|e| TokenError::Storage(e.to_string()))?;
Ok(())
}
/// Revoke all tokens for a user
pub fn revoke_all_for_user(&self, user: &str) -> usize {
let mut count = 0;
if let Ok(entries) = fs::read_dir(&self.storage_path) {
for entry in entries.flatten() {
if let Ok(content) = fs::read_to_string(entry.path()) {
if let Ok(info) = serde_json::from_str::<TokenInfo>(&content) {
if info.user == user && fs::remove_file(entry.path()).is_ok() {
count += 1;
}
}
}
}
}
count
}
}
/// Hash a token using SHA256
fn hash_token(token: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
format!("{:x}", hasher.finalize())
}
#[derive(Debug)]
pub enum TokenError {
InvalidFormat,
NotFound,
Expired,
Storage(String),
}
impl std::fmt::Display for TokenError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidFormat => write!(f, "Invalid token format"),
Self::NotFound => write!(f, "Token not found"),
Self::Expired => write!(f, "Token expired"),
Self::Storage(msg) => write!(f, "Storage error: {}", msg),
}
}
}
impl std::error::Error for TokenError {}

580
nora-registry/src/ui/api.rs Normal file
View File

@@ -0,0 +1,580 @@
use super::components::{format_size, format_timestamp, html_escape};
use super::templates::encode_uri_component;
use crate::AppState;
use crate::Storage;
use axum::{
extract::{Path, Query, State},
response::Json,
};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
#[derive(Serialize)]
pub struct RegistryStats {
pub docker: usize,
pub maven: usize,
pub npm: usize,
pub cargo: usize,
pub pypi: usize,
}
#[derive(Serialize, Clone)]
pub struct RepoInfo {
pub name: String,
pub versions: usize,
pub size: u64,
pub updated: String,
}
#[derive(Serialize)]
pub struct TagInfo {
pub name: String,
pub size: u64,
pub created: String,
}
#[derive(Serialize)]
pub struct DockerDetail {
pub tags: Vec<TagInfo>,
}
#[derive(Serialize)]
pub struct VersionInfo {
pub version: String,
pub size: u64,
pub published: String,
}
#[derive(Serialize)]
pub struct PackageDetail {
pub versions: Vec<VersionInfo>,
}
#[derive(Serialize)]
pub struct MavenArtifact {
pub filename: String,
pub size: u64,
}
#[derive(Serialize)]
pub struct MavenDetail {
pub artifacts: Vec<MavenArtifact>,
}
#[derive(Deserialize)]
pub struct SearchQuery {
pub q: Option<String>,
}
// ============ API Handlers ============
pub async fn api_stats(State(state): State<Arc<AppState>>) -> Json<RegistryStats> {
let stats = get_registry_stats(&state.storage).await;
Json(stats)
}
pub async fn api_list(
State(state): State<Arc<AppState>>,
Path(registry_type): Path<String>,
) -> Json<Vec<RepoInfo>> {
let repos = match registry_type.as_str() {
"docker" => get_docker_repos(&state.storage).await,
"maven" => get_maven_repos(&state.storage).await,
"npm" => get_npm_packages(&state.storage).await,
"cargo" => get_cargo_crates(&state.storage).await,
"pypi" => get_pypi_packages(&state.storage).await,
_ => vec![],
};
Json(repos)
}
pub async fn api_detail(
State(state): State<Arc<AppState>>,
Path((registry_type, name)): Path<(String, String)>,
) -> Json<serde_json::Value> {
match registry_type.as_str() {
"docker" => {
let detail = get_docker_detail(&state.storage, &name).await;
Json(serde_json::to_value(detail).unwrap_or_default())
}
"npm" => {
let detail = get_npm_detail(&state.storage, &name).await;
Json(serde_json::to_value(detail).unwrap_or_default())
}
"cargo" => {
let detail = get_cargo_detail(&state.storage, &name).await;
Json(serde_json::to_value(detail).unwrap_or_default())
}
_ => Json(serde_json::json!({})),
}
}
pub async fn api_search(
State(state): State<Arc<AppState>>,
Path(registry_type): Path<String>,
Query(params): Query<SearchQuery>,
) -> axum::response::Html<String> {
let query = params.q.unwrap_or_default().to_lowercase();
let repos = match registry_type.as_str() {
"docker" => get_docker_repos(&state.storage).await,
"maven" => get_maven_repos(&state.storage).await,
"npm" => get_npm_packages(&state.storage).await,
"cargo" => get_cargo_crates(&state.storage).await,
"pypi" => get_pypi_packages(&state.storage).await,
_ => vec![],
};
let filtered: Vec<_> = if query.is_empty() {
repos
} else {
repos
.into_iter()
.filter(|r| r.name.to_lowercase().contains(&query))
.collect()
};
// Return HTML fragment for HTMX
let html = if filtered.is_empty() {
r#"<tr><td colspan="4" class="px-6 py-12 text-center text-slate-500">
<div class="text-4xl mb-2">🔍</div>
<div>No matching repositories found</div>
</td></tr>"#
.to_string()
} else {
filtered
.iter()
.map(|repo| {
let detail_url =
format!("/ui/{}/{}", registry_type, encode_uri_component(&repo.name));
format!(
r#"
<tr class="hover:bg-slate-50 cursor-pointer" onclick="window.location='{}'">
<td class="px-6 py-4">
<a href="{}" class="text-blue-600 hover:text-blue-800 font-medium">{}</a>
</td>
<td class="px-6 py-4 text-slate-600">{}</td>
<td class="px-6 py-4 text-slate-600">{}</td>
<td class="px-6 py-4 text-slate-500 text-sm">{}</td>
</tr>
"#,
detail_url,
detail_url,
html_escape(&repo.name),
repo.versions,
format_size(repo.size),
&repo.updated
)
})
.collect::<Vec<_>>()
.join("")
};
axum::response::Html(html)
}
// ============ Data Fetching Functions ============
pub async fn get_registry_stats(storage: &Storage) -> RegistryStats {
let all_keys = storage.list("").await;
let docker = all_keys
.iter()
.filter(|k| k.starts_with("docker/") && k.contains("/manifests/"))
.filter_map(|k| k.split('/').nth(1))
.collect::<HashSet<_>>()
.len();
let maven = all_keys
.iter()
.filter(|k| k.starts_with("maven/"))
.filter_map(|k| {
// Extract groupId/artifactId from maven path
let parts: Vec<_> = k.strip_prefix("maven/")?.split('/').collect();
if parts.len() >= 2 {
Some(parts[..parts.len() - 1].join("/"))
} else {
None
}
})
.collect::<HashSet<_>>()
.len();
let npm = all_keys
.iter()
.filter(|k| k.starts_with("npm/") && k.ends_with("/metadata.json"))
.count();
let cargo = all_keys
.iter()
.filter(|k| k.starts_with("cargo/") && k.ends_with("/metadata.json"))
.count();
let pypi = all_keys
.iter()
.filter(|k| k.starts_with("pypi/"))
.filter_map(|k| k.strip_prefix("pypi/")?.split('/').next())
.collect::<HashSet<_>>()
.len();
RegistryStats {
docker,
maven,
npm,
cargo,
pypi,
}
}
pub async fn get_docker_repos(storage: &Storage) -> Vec<RepoInfo> {
let keys = storage.list("docker/").await;
let mut repos: HashMap<String, (RepoInfo, u64)> = HashMap::new(); // (info, latest_modified)
for key in &keys {
if let Some(rest) = key.strip_prefix("docker/") {
let parts: Vec<_> = rest.split('/').collect();
if parts.len() >= 3 {
let name = parts[0].to_string();
let entry = repos.entry(name.clone()).or_insert_with(|| {
(
RepoInfo {
name,
versions: 0,
size: 0,
updated: "N/A".to_string(),
},
0,
)
});
if parts[1] == "manifests" {
entry.0.versions += 1;
if let Some(meta) = storage.stat(key).await {
entry.0.size += meta.size;
if meta.modified > entry.1 {
entry.1 = meta.modified;
entry.0.updated = format_timestamp(meta.modified);
}
}
}
}
}
}
let mut result: Vec<_> = repos.into_values().map(|(r, _)| r).collect();
result.sort_by(|a, b| a.name.cmp(&b.name));
result
}
pub async fn get_docker_detail(storage: &Storage, name: &str) -> DockerDetail {
let prefix = format!("docker/{}/manifests/", name);
let keys = storage.list(&prefix).await;
let mut tags = Vec::new();
for key in &keys {
if let Some(tag_name) = key
.strip_prefix(&prefix)
.and_then(|s| s.strip_suffix(".json"))
{
let (size, created) = if let Some(meta) = storage.stat(key).await {
(meta.size, format_timestamp(meta.modified))
} else {
(0, "N/A".to_string())
};
tags.push(TagInfo {
name: tag_name.to_string(),
size,
created,
});
}
}
DockerDetail { tags }
}
pub async fn get_maven_repos(storage: &Storage) -> Vec<RepoInfo> {
let keys = storage.list("maven/").await;
let mut repos: HashMap<String, (RepoInfo, u64)> = HashMap::new();
for key in &keys {
if let Some(rest) = key.strip_prefix("maven/") {
let parts: Vec<_> = rest.split('/').collect();
if parts.len() >= 2 {
let artifact_path = parts[..parts.len() - 1].join("/");
let entry = repos.entry(artifact_path.clone()).or_insert_with(|| {
(
RepoInfo {
name: artifact_path,
versions: 0,
size: 0,
updated: "N/A".to_string(),
},
0,
)
});
entry.0.versions += 1;
if let Some(meta) = storage.stat(key).await {
entry.0.size += meta.size;
if meta.modified > entry.1 {
entry.1 = meta.modified;
entry.0.updated = format_timestamp(meta.modified);
}
}
}
}
}
let mut result: Vec<_> = repos.into_values().map(|(r, _)| r).collect();
result.sort_by(|a, b| a.name.cmp(&b.name));
result
}
pub async fn get_maven_detail(storage: &Storage, path: &str) -> MavenDetail {
let prefix = format!("maven/{}/", path);
let keys = storage.list(&prefix).await;
let mut artifacts = Vec::new();
for key in &keys {
if let Some(filename) = key.strip_prefix(&prefix) {
if filename.contains('/') {
continue;
}
let size = storage.stat(key).await.map(|m| m.size).unwrap_or(0);
artifacts.push(MavenArtifact {
filename: filename.to_string(),
size,
});
}
}
MavenDetail { artifacts }
}
pub async fn get_npm_packages(storage: &Storage) -> Vec<RepoInfo> {
let keys = storage.list("npm/").await;
let mut packages: HashMap<String, (RepoInfo, u64)> = HashMap::new();
for key in &keys {
if let Some(rest) = key.strip_prefix("npm/") {
let parts: Vec<_> = rest.split('/').collect();
if !parts.is_empty() {
let name = parts[0].to_string();
let entry = packages.entry(name.clone()).or_insert_with(|| {
(
RepoInfo {
name,
versions: 0,
size: 0,
updated: "N/A".to_string(),
},
0,
)
});
if parts.len() >= 3 && parts[1] == "tarballs" {
entry.0.versions += 1;
if let Some(meta) = storage.stat(key).await {
entry.0.size += meta.size;
if meta.modified > entry.1 {
entry.1 = meta.modified;
entry.0.updated = format_timestamp(meta.modified);
}
}
}
}
}
}
let mut result: Vec<_> = packages.into_values().map(|(r, _)| r).collect();
result.sort_by(|a, b| a.name.cmp(&b.name));
result
}
pub async fn get_npm_detail(storage: &Storage, name: &str) -> PackageDetail {
let prefix = format!("npm/{}/tarballs/", name);
let keys = storage.list(&prefix).await;
let mut versions = Vec::new();
for key in &keys {
if let Some(tarball) = key.strip_prefix(&prefix) {
if let Some(version) = tarball
.strip_prefix(&format!("{}-", name))
.and_then(|s| s.strip_suffix(".tgz"))
{
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,
});
}
}
}
PackageDetail { versions }
}
pub async fn get_cargo_crates(storage: &Storage) -> Vec<RepoInfo> {
let keys = storage.list("cargo/").await;
let mut crates: HashMap<String, (RepoInfo, u64)> = HashMap::new();
for key in &keys {
if let Some(rest) = key.strip_prefix("cargo/") {
let parts: Vec<_> = rest.split('/').collect();
if !parts.is_empty() {
let name = parts[0].to_string();
let entry = crates.entry(name.clone()).or_insert_with(|| {
(
RepoInfo {
name,
versions: 0,
size: 0,
updated: "N/A".to_string(),
},
0,
)
});
if parts.len() >= 3 && key.ends_with(".crate") {
entry.0.versions += 1;
if let Some(meta) = storage.stat(key).await {
entry.0.size += meta.size;
if meta.modified > entry.1 {
entry.1 = meta.modified;
entry.0.updated = format_timestamp(meta.modified);
}
}
}
}
}
}
let mut result: Vec<_> = crates.into_values().map(|(r, _)| r).collect();
result.sort_by(|a, b| a.name.cmp(&b.name));
result
}
pub async fn get_cargo_detail(storage: &Storage, name: &str) -> PackageDetail {
let prefix = format!("cargo/{}/", name);
let keys = storage.list(&prefix).await;
let mut versions = Vec::new();
for key in keys.iter().filter(|k| k.ends_with(".crate")) {
if let Some(rest) = key.strip_prefix(&prefix) {
let parts: Vec<_> = rest.split('/').collect();
if !parts.is_empty() {
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: parts[0].to_string(),
size,
published,
});
}
}
}
PackageDetail { versions }
}
pub async fn get_pypi_packages(storage: &Storage) -> Vec<RepoInfo> {
let keys = storage.list("pypi/").await;
let mut packages: HashMap<String, (RepoInfo, u64)> = HashMap::new();
for key in &keys {
if let Some(rest) = key.strip_prefix("pypi/") {
let parts: Vec<_> = rest.split('/').collect();
if !parts.is_empty() {
let name = parts[0].to_string();
let entry = packages.entry(name.clone()).or_insert_with(|| {
(
RepoInfo {
name,
versions: 0,
size: 0,
updated: "N/A".to_string(),
},
0,
)
});
if parts.len() >= 2 {
entry.0.versions += 1;
if let Some(meta) = storage.stat(key).await {
entry.0.size += meta.size;
if meta.modified > entry.1 {
entry.1 = meta.modified;
entry.0.updated = format_timestamp(meta.modified);
}
}
}
}
}
}
let mut result: Vec<_> = packages.into_values().map(|(r, _)| r).collect();
result.sort_by(|a, b| a.name.cmp(&b.name));
result
}
pub async fn get_pypi_detail(storage: &Storage, name: &str) -> PackageDetail {
let prefix = format!("pypi/{}/", name);
let keys = storage.list(&prefix).await;
let mut versions = Vec::new();
for key in &keys {
if let Some(filename) = key.strip_prefix(&prefix) {
if let Some(version) = extract_pypi_version(name, filename) {
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,
size,
published,
});
}
}
}
PackageDetail { versions }
}
fn extract_pypi_version(name: &str, filename: &str) -> Option<String> {
// Handle both .tar.gz and .whl files
let clean_name = name.replace('-', "_");
if filename.ends_with(".tar.gz") {
// package-1.0.0.tar.gz
let base = filename.strip_suffix(".tar.gz")?;
let version = base
.strip_prefix(&format!("{}-", name))
.or_else(|| base.strip_prefix(&format!("{}-", clean_name)))?;
Some(version.to_string())
} else if filename.ends_with(".whl") {
// package-1.0.0-py3-none-any.whl
let parts: Vec<_> = filename.split('-').collect();
if parts.len() >= 2 {
Some(parts[1].to_string())
} else {
None
}
} else {
None
}
}

View File

@@ -0,0 +1,222 @@
/// Main layout wrapper with header and sidebar
pub fn layout(title: &str, content: &str, active_page: Option<&str>) -> String {
format!(
r##"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{} - Nora</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<style>
[x-cloak] {{ display: none !important; }}
</style>
</head>
<body class="bg-slate-100 min-h-screen">
<div class="flex h-screen overflow-hidden">
<!-- Sidebar -->
{}
<!-- Main content -->
<div class="flex-1 flex flex-col overflow-hidden">
<!-- Header -->
{}
<!-- Content -->
<main class="flex-1 overflow-y-auto p-6">
{}
</main>
</div>
</div>
</body>
</html>"##,
html_escape(title),
sidebar(active_page),
header(),
content
)
}
/// Sidebar navigation component
fn sidebar(active_page: Option<&str>) -> String {
let active = active_page.unwrap_or("");
let nav_items = [
(
"dashboard",
"/ui/",
"Dashboard",
r#"<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/>"#,
),
("docker", "/ui/docker", "🐳 Docker", ""),
("maven", "/ui/maven", "☕ Maven", ""),
("npm", "/ui/npm", "📦 npm", ""),
("cargo", "/ui/cargo", "🦀 Cargo", ""),
("pypi", "/ui/pypi", "🐍 PyPI", ""),
];
let nav_html: String = nav_items.iter().map(|(id, href, label, icon_path)| {
let is_active = active == *id;
let active_class = if is_active {
"bg-slate-700 text-white"
} else {
"text-slate-300 hover:bg-slate-700 hover:text-white"
};
if icon_path.is_empty() {
// Emoji-based item
format!(r#"
<a href="{}" class="flex items-center px-4 py-3 text-sm font-medium rounded-lg transition-colors {}">
<span class="mr-3 text-lg">{}</span>
</a>
"#, href, active_class, label)
} else {
// SVG icon item
format!(r##"
<a href="{}" class="flex items-center px-4 py-3 text-sm font-medium rounded-lg transition-colors {}">
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{}
</svg>
{}
</a>
"##, href, active_class, icon_path, label)
}
}).collect();
format!(
r#"
<div class="w-64 bg-slate-800 text-white flex flex-col">
<!-- Logo -->
<div class="h-16 flex items-center px-6 border-b border-slate-700">
<span class="text-2xl mr-2">⚓</span>
<span class="text-xl font-bold">Nora</span>
</div>
<!-- Navigation -->
<nav class="flex-1 px-4 py-6 space-y-1">
<div class="text-xs font-semibold text-slate-400 uppercase tracking-wider px-4 mb-3">
Navigation
</div>
{}
<div class="text-xs font-semibold text-slate-400 uppercase tracking-wider px-4 mt-8 mb-3">
Registries
</div>
</nav>
<!-- Footer -->
<div class="px-4 py-4 border-t border-slate-700">
<div class="text-xs text-slate-400">
Nora v0.1.0
</div>
</div>
</div>
"#,
nav_html
)
}
/// Header component
fn header() -> String {
r##"
<header class="h-16 bg-white border-b border-slate-200 flex items-center justify-between px-6">
<div class="flex-1">
<!-- Search removed for simplicity, HTMX search is on list pages -->
</div>
<div class="flex items-center space-x-4">
<a href="https://github.com" target="_blank" class="text-slate-500 hover:text-slate-700">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path fill-rule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clip-rule="evenodd"/>
</svg>
</a>
<button class="text-slate-500 hover:text-slate-700">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</button>
</div>
</header>
"##.to_string()
}
/// Stat card for dashboard
pub fn stat_card(name: &str, icon: &str, count: usize, href: &str, unit: &str) -> String {
format!(
r##"
<a href="{}" class="bg-white rounded-lg shadow-sm border border-slate-200 p-6 hover:shadow-md hover:border-blue-300 transition-all">
<div class="flex items-center justify-between mb-4">
<span class="text-3xl">{}</span>
<span class="text-xs font-medium text-green-600 bg-green-100 px-2 py-1 rounded-full">ACTIVE</span>
</div>
<div class="text-lg font-semibold text-slate-800 mb-1">{}</div>
<div class="text-2xl font-bold text-slate-800">{}</div>
<div class="text-sm text-slate-500">{}</div>
</a>
"##,
href, icon, name, count, unit
)
}
/// Format file size in human-readable format
pub fn format_size(bytes: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = KB * 1024;
const GB: u64 = MB * 1024;
if bytes >= GB {
format!("{:.1} GB", bytes as f64 / GB as f64)
} else if bytes >= MB {
format!("{:.1} MB", bytes as f64 / MB as f64)
} else if bytes >= KB {
format!("{:.1} KB", bytes as f64 / KB as f64)
} else {
format!("{} B", bytes)
}
}
/// Escape HTML special characters
pub fn html_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
}
/// Format Unix timestamp as relative time
pub fn format_timestamp(ts: u64) -> String {
if ts == 0 {
return "N/A".to_string();
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
if now < ts {
return "just now".to_string();
}
let diff = now - ts;
if diff < 60 {
"just now".to_string()
} else if diff < 3600 {
let mins = diff / 60;
format!("{} min{} ago", mins, if mins == 1 { "" } else { "s" })
} else if diff < 86400 {
let hours = diff / 3600;
format!("{} hour{} ago", hours, if hours == 1 { "" } else { "s" })
} else if diff < 604800 {
let days = diff / 86400;
format!("{} day{} ago", days, if days == 1 { "" } else { "s" })
} else if diff < 2592000 {
let weeks = diff / 604800;
format!("{} week{} ago", weeks, if weeks == 1 { "" } else { "s" })
} else {
let months = diff / 2592000;
format!("{} month{} ago", months, if months == 1 { "" } else { "s" })
}
}

114
nora-registry/src/ui/mod.rs Normal file
View File

@@ -0,0 +1,114 @@
mod api;
mod components;
mod templates;
use crate::AppState;
use axum::{
extract::{Path, State},
response::{Html, IntoResponse, Redirect},
routing::get,
Router,
};
use std::sync::Arc;
use api::*;
use templates::*;
pub fn routes() -> Router<Arc<AppState>> {
Router::new()
// UI Pages
.route("/", get(|| async { Redirect::to("/ui/") }))
.route("/ui", get(|| async { Redirect::to("/ui/") }))
.route("/ui/", get(dashboard))
.route("/ui/docker", get(docker_list))
.route("/ui/docker/{name}", get(docker_detail))
.route("/ui/maven", get(maven_list))
.route("/ui/maven/{*path}", get(maven_detail))
.route("/ui/npm", get(npm_list))
.route("/ui/npm/{name}", get(npm_detail))
.route("/ui/cargo", get(cargo_list))
.route("/ui/cargo/{name}", get(cargo_detail))
.route("/ui/pypi", get(pypi_list))
.route("/ui/pypi/{name}", get(pypi_detail))
// API endpoints for HTMX
.route("/api/ui/stats", get(api_stats))
.route("/api/ui/{registry_type}/list", get(api_list))
.route("/api/ui/{registry_type}/{name}", get(api_detail))
.route("/api/ui/{registry_type}/search", get(api_search))
}
// Dashboard page
async fn dashboard(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let stats = get_registry_stats(&state.storage).await;
Html(render_dashboard(&stats))
}
// Docker pages
async fn docker_list(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let repos = get_docker_repos(&state.storage).await;
Html(render_registry_list("docker", "Docker Registry", &repos))
}
async fn docker_detail(
State(state): State<Arc<AppState>>,
Path(name): Path<String>,
) -> impl IntoResponse {
let detail = get_docker_detail(&state.storage, &name).await;
Html(render_docker_detail(&name, &detail))
}
// Maven pages
async fn maven_list(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let repos = get_maven_repos(&state.storage).await;
Html(render_registry_list("maven", "Maven Repository", &repos))
}
async fn maven_detail(
State(state): State<Arc<AppState>>,
Path(path): Path<String>,
) -> impl IntoResponse {
let detail = get_maven_detail(&state.storage, &path).await;
Html(render_maven_detail(&path, &detail))
}
// npm pages
async fn npm_list(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let packages = get_npm_packages(&state.storage).await;
Html(render_registry_list("npm", "npm Registry", &packages))
}
async fn npm_detail(
State(state): State<Arc<AppState>>,
Path(name): Path<String>,
) -> impl IntoResponse {
let detail = get_npm_detail(&state.storage, &name).await;
Html(render_package_detail("npm", &name, &detail))
}
// Cargo pages
async fn cargo_list(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let crates = get_cargo_crates(&state.storage).await;
Html(render_registry_list("cargo", "Cargo Registry", &crates))
}
async fn cargo_detail(
State(state): State<Arc<AppState>>,
Path(name): Path<String>,
) -> impl IntoResponse {
let detail = get_cargo_detail(&state.storage, &name).await;
Html(render_package_detail("cargo", &name, &detail))
}
// PyPI pages
async fn pypi_list(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let packages = get_pypi_packages(&state.storage).await;
Html(render_registry_list("pypi", "PyPI Repository", &packages))
}
async fn pypi_detail(
State(state): State<Arc<AppState>>,
Path(name): Path<String>,
) -> impl IntoResponse {
let detail = get_pypi_detail(&state.storage, &name).await;
Html(render_package_detail("pypi", &name, &detail))
}

View File

@@ -0,0 +1,478 @@
use super::api::{DockerDetail, MavenDetail, PackageDetail, RegistryStats, RepoInfo};
use super::components::*;
/// Renders the main dashboard page
pub fn render_dashboard(stats: &RegistryStats) -> String {
let content = format!(
r##"
<div class="mb-8">
<h1 class="text-2xl font-bold text-slate-800 mb-2">Dashboard</h1>
<p class="text-slate-500">Overview of all registries</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-6 mb-8">
{}
{}
{}
{}
{}
</div>
<div class="bg-white rounded-lg shadow-sm border border-slate-200 p-6">
<h2 class="text-lg font-semibold text-slate-800 mb-4">Quick Links</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<a href="/ui/docker" class="flex items-center p-3 rounded-lg border border-slate-200 hover:border-blue-300 hover:bg-blue-50 transition-colors">
<span class="text-2xl mr-3">🐳</span>
<div>
<div class="font-medium text-slate-700">Docker Registry</div>
<div class="text-sm text-slate-500">API: /v2/</div>
</div>
</a>
<a href="/ui/maven" class="flex items-center p-3 rounded-lg border border-slate-200 hover:border-blue-300 hover:bg-blue-50 transition-colors">
<span class="text-2xl mr-3">☕</span>
<div>
<div class="font-medium text-slate-700">Maven Repository</div>
<div class="text-sm text-slate-500">API: /maven2/</div>
</div>
</a>
<a href="/ui/npm" class="flex items-center p-3 rounded-lg border border-slate-200 hover:border-blue-300 hover:bg-blue-50 transition-colors">
<span class="text-2xl mr-3">📦</span>
<div>
<div class="font-medium text-slate-700">npm Registry</div>
<div class="text-sm text-slate-500">API: /npm/</div>
</div>
</a>
<a href="/ui/cargo" class="flex items-center p-3 rounded-lg border border-slate-200 hover:border-blue-300 hover:bg-blue-50 transition-colors">
<span class="text-2xl mr-3">🦀</span>
<div>
<div class="font-medium text-slate-700">Cargo Registry</div>
<div class="text-sm text-slate-500">API: /cargo/</div>
</div>
</a>
<a href="/ui/pypi" class="flex items-center p-3 rounded-lg border border-slate-200 hover:border-blue-300 hover:bg-blue-50 transition-colors">
<span class="text-2xl mr-3">🐍</span>
<div>
<div class="font-medium text-slate-700">PyPI Repository</div>
<div class="text-sm text-slate-500">API: /simple/</div>
</div>
</a>
</div>
</div>
"##,
stat_card("Docker", "🐳", stats.docker, "/ui/docker", "images"),
stat_card("Maven", "", stats.maven, "/ui/maven", "artifacts"),
stat_card("npm", "📦", stats.npm, "/ui/npm", "packages"),
stat_card("Cargo", "🦀", stats.cargo, "/ui/cargo", "crates"),
stat_card("PyPI", "🐍", stats.pypi, "/ui/pypi", "packages"),
);
layout("Dashboard", &content, Some("dashboard"))
}
/// Renders a registry list page (docker, maven, npm, cargo, pypi)
pub fn render_registry_list(registry_type: &str, title: &str, repos: &[RepoInfo]) -> String {
let icon = get_registry_icon(registry_type);
let table_rows = if repos.is_empty() {
r##"<tr><td colspan="4" class="px-6 py-12 text-center text-slate-500">
<div class="text-4xl mb-2">📭</div>
<div>No repositories found</div>
<div class="text-sm mt-1">Push your first artifact to see it here</div>
</td></tr>"##
.to_string()
} else {
repos
.iter()
.map(|repo| {
let detail_url =
format!("/ui/{}/{}", registry_type, encode_uri_component(&repo.name));
format!(
r##"
<tr class="hover:bg-slate-50 cursor-pointer" onclick="window.location='{}'">
<td class="px-6 py-4">
<a href="{}" class="text-blue-600 hover:text-blue-800 font-medium">{}</a>
</td>
<td class="px-6 py-4 text-slate-600">{}</td>
<td class="px-6 py-4 text-slate-600">{}</td>
<td class="px-6 py-4 text-slate-500 text-sm">{}</td>
</tr>
"##,
detail_url,
detail_url,
html_escape(&repo.name),
repo.versions,
format_size(repo.size),
&repo.updated
)
})
.collect::<Vec<_>>()
.join("")
};
let version_label = match registry_type {
"docker" => "Tags",
"maven" => "Versions",
_ => "Versions",
};
let content = format!(
r##"
<div class="mb-6 flex items-center justify-between">
<div class="flex items-center">
<span class="text-3xl mr-3">{}</span>
<div>
<h1 class="text-2xl font-bold text-slate-800">{}</h1>
<p class="text-slate-500">{} repositories</p>
</div>
</div>
<div class="flex items-center gap-4">
<div class="relative">
<input type="text"
placeholder="Search repositories..."
class="pl-10 pr-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
hx-get="/api/ui/{}/search"
hx-trigger="keyup changed delay:300ms"
hx-target="#repo-table-body"
name="q">
<svg class="absolute left-3 top-2.5 h-5 w-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow-sm border border-slate-200 overflow-hidden">
<table class="w-full">
<thead class="bg-slate-50 border-b border-slate-200">
<tr>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider">Name</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider">{}</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider">Size</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider">Updated</th>
</tr>
</thead>
<tbody id="repo-table-body" class="divide-y divide-slate-200">
{}
</tbody>
</table>
</div>
"##,
icon,
title,
repos.len(),
registry_type,
version_label,
table_rows
);
layout(title, &content, Some(registry_type))
}
/// Renders Docker image detail page
pub fn render_docker_detail(name: &str, detail: &DockerDetail) -> String {
let tags_rows = if detail.tags.is_empty() {
r##"<tr><td colspan="3" class="px-6 py-8 text-center text-slate-500">No tags found</td></tr>"##.to_string()
} else {
detail
.tags
.iter()
.map(|tag| {
format!(
r##"
<tr class="hover:bg-slate-50">
<td class="px-6 py-4">
<span class="font-mono text-sm bg-slate-100 px-2 py-1 rounded">{}</span>
</td>
<td class="px-6 py-4 text-slate-600">{}</td>
<td class="px-6 py-4 text-slate-500 text-sm">{}</td>
</tr>
"##,
html_escape(&tag.name),
format_size(tag.size),
&tag.created
)
})
.collect::<Vec<_>>()
.join("")
};
let pull_cmd = format!("docker pull 127.0.0.1:4000/{}", name);
let content = format!(
r##"
<div class="mb-6">
<div class="flex items-center mb-2">
<a href="/ui/docker" class="text-blue-600 hover:text-blue-800">Docker Registry</a>
<span class="mx-2 text-slate-400">/</span>
<span class="text-slate-800 font-medium">{}</span>
</div>
<div class="flex items-center">
<span class="text-3xl mr-3">🐳</span>
<h1 class="text-2xl font-bold text-slate-800">{}</h1>
</div>
</div>
<div class="bg-white rounded-lg shadow-sm border border-slate-200 p-6 mb-6">
<h2 class="text-lg font-semibold text-slate-800 mb-3">Pull Command</h2>
<div class="flex items-center bg-slate-900 text-green-400 rounded-lg p-4 font-mono text-sm">
<code class="flex-1">{}</code>
<button onclick="navigator.clipboard.writeText('{}')" class="ml-4 text-slate-400 hover:text-white transition-colors" title="Copy to clipboard">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
</button>
</div>
</div>
<div class="bg-white rounded-lg shadow-sm border border-slate-200 overflow-hidden">
<div class="px-6 py-4 border-b border-slate-200">
<h2 class="text-lg font-semibold text-slate-800">Tags ({} total)</h2>
</div>
<table class="w-full">
<thead class="bg-slate-50 border-b border-slate-200">
<tr>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider">Tag</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider">Size</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider">Created</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-200">
{}
</tbody>
</table>
</div>
"##,
html_escape(name),
html_escape(name),
pull_cmd,
pull_cmd,
detail.tags.len(),
tags_rows
);
layout(&format!("{} - Docker", name), &content, Some("docker"))
}
/// Renders package detail page (npm, cargo, pypi)
pub fn render_package_detail(registry_type: &str, name: &str, detail: &PackageDetail) -> String {
let icon = get_registry_icon(registry_type);
let registry_title = get_registry_title(registry_type);
let versions_rows = if detail.versions.is_empty() {
r##"<tr><td colspan="3" class="px-6 py-8 text-center text-slate-500">No versions found</td></tr>"##.to_string()
} else {
detail
.versions
.iter()
.map(|v| {
format!(
r##"
<tr class="hover:bg-slate-50">
<td class="px-6 py-4">
<span class="font-mono text-sm bg-slate-100 px-2 py-1 rounded">{}</span>
</td>
<td class="px-6 py-4 text-slate-600">{}</td>
<td class="px-6 py-4 text-slate-500 text-sm">{}</td>
</tr>
"##,
html_escape(&v.version),
format_size(v.size),
&v.published
)
})
.collect::<Vec<_>>()
.join("")
};
let install_cmd = match registry_type {
"npm" => format!("npm install {} --registry http://127.0.0.1:4000/npm", name),
"cargo" => format!("cargo add {}", name),
"pypi" => format!(
"pip install {} --index-url http://127.0.0.1:4000/simple",
name
),
_ => String::new(),
};
let content = format!(
r##"
<div class="mb-6">
<div class="flex items-center mb-2">
<a href="/ui/{}" class="text-blue-600 hover:text-blue-800">{}</a>
<span class="mx-2 text-slate-400">/</span>
<span class="text-slate-800 font-medium">{}</span>
</div>
<div class="flex items-center">
<span class="text-3xl mr-3">{}</span>
<h1 class="text-2xl font-bold text-slate-800">{}</h1>
</div>
</div>
<div class="bg-white rounded-lg shadow-sm border border-slate-200 p-6 mb-6">
<h2 class="text-lg font-semibold text-slate-800 mb-3">Install Command</h2>
<div class="flex items-center bg-slate-900 text-green-400 rounded-lg p-4 font-mono text-sm">
<code class="flex-1">{}</code>
<button onclick="navigator.clipboard.writeText('{}')" class="ml-4 text-slate-400 hover:text-white transition-colors" title="Copy to clipboard">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
</button>
</div>
</div>
<div class="bg-white rounded-lg shadow-sm border border-slate-200 overflow-hidden">
<div class="px-6 py-4 border-b border-slate-200">
<h2 class="text-lg font-semibold text-slate-800">Versions ({} total)</h2>
</div>
<table class="w-full">
<thead class="bg-slate-50 border-b border-slate-200">
<tr>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider">Version</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider">Size</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider">Published</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-200">
{}
</tbody>
</table>
</div>
"##,
registry_type,
registry_title,
html_escape(name),
icon,
html_escape(name),
install_cmd,
install_cmd,
detail.versions.len(),
versions_rows
);
layout(
&format!("{} - {}", name, registry_title),
&content,
Some(registry_type),
)
}
/// Renders Maven artifact detail page
pub fn render_maven_detail(path: &str, detail: &MavenDetail) -> String {
let artifact_rows = if detail.artifacts.is_empty() {
r##"<tr><td colspan="2" class="px-6 py-8 text-center text-slate-500">No artifacts found</td></tr>"##.to_string()
} else {
detail.artifacts.iter().map(|a| {
let download_url = format!("/maven2/{}/{}", path, a.filename);
format!(r##"
<tr class="hover:bg-slate-50">
<td class="px-6 py-4">
<a href="{}" class="text-blue-600 hover:text-blue-800 font-mono text-sm">{}</a>
</td>
<td class="px-6 py-4 text-slate-600">{}</td>
</tr>
"##, download_url, html_escape(&a.filename), format_size(a.size))
}).collect::<Vec<_>>().join("")
};
// Extract artifact name from path (last component before version)
let parts: Vec<&str> = path.split('/').collect();
let artifact_name = if parts.len() >= 2 {
parts[parts.len() - 2]
} else {
path
};
let dep_cmd = format!(
r#"<dependency>
<groupId>{}</groupId>
<artifactId>{}</artifactId>
<version>{}</version>
</dependency>"#,
parts[..parts.len().saturating_sub(2)].join("."),
artifact_name,
parts.last().unwrap_or(&"")
);
let content = format!(
r##"
<div class="mb-6">
<div class="flex items-center mb-2">
<a href="/ui/maven" class="text-blue-600 hover:text-blue-800">Maven Repository</a>
<span class="mx-2 text-slate-400">/</span>
<span class="text-slate-800 font-medium">{}</span>
</div>
<div class="flex items-center">
<span class="text-3xl mr-3">☕</span>
<h1 class="text-2xl font-bold text-slate-800">{}</h1>
</div>
</div>
<div class="bg-white rounded-lg shadow-sm border border-slate-200 p-6 mb-6">
<h2 class="text-lg font-semibold text-slate-800 mb-3">Maven Dependency</h2>
<pre class="bg-slate-900 text-green-400 rounded-lg p-4 font-mono text-sm overflow-x-auto">{}</pre>
</div>
<div class="bg-white rounded-lg shadow-sm border border-slate-200 overflow-hidden">
<div class="px-6 py-4 border-b border-slate-200">
<h2 class="text-lg font-semibold text-slate-800">Artifacts ({} files)</h2>
</div>
<table class="w-full">
<thead class="bg-slate-50 border-b border-slate-200">
<tr>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider">Filename</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider">Size</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-200">
{}
</tbody>
</table>
</div>
"##,
html_escape(path),
html_escape(path),
html_escape(&dep_cmd),
detail.artifacts.len(),
artifact_rows
);
layout(&format!("{} - Maven", path), &content, Some("maven"))
}
fn get_registry_icon(registry_type: &str) -> &'static str {
match registry_type {
"docker" => "🐳",
"maven" => "",
"npm" => "📦",
"cargo" => "🦀",
"pypi" => "🐍",
_ => "📁",
}
}
fn get_registry_title(registry_type: &str) -> &'static str {
match registry_type {
"docker" => "Docker Registry",
"maven" => "Maven Repository",
"npm" => "npm Registry",
"cargo" => "Cargo Registry",
"pypi" => "PyPI Repository",
_ => "Registry",
}
}
/// Simple URL encoding for path components
pub fn encode_uri_component(s: &str) -> String {
let mut result = String::new();
for c in s.chars() {
match c {
'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' | '~' => result.push(c),
_ => {
for byte in c.to_string().as_bytes() {
result.push_str(&format!("%{:02X}", byte));
}
}
}
}
result
}