diff --git a/Cargo.lock b/Cargo.lock index 8a4c1e9..53e9237 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1185,7 +1185,7 @@ checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" [[package]] name = "nora-cli" -version = "0.2.9" +version = "0.2.11" dependencies = [ "clap", "flate2", @@ -1199,7 +1199,7 @@ dependencies = [ [[package]] name = "nora-registry" -version = "0.2.9" +version = "0.2.11" dependencies = [ "async-trait", "axum", @@ -1234,7 +1234,7 @@ dependencies = [ [[package]] name = "nora-storage" -version = "0.2.9" +version = "0.2.11" dependencies = [ "axum", "base64", diff --git a/Cargo.toml b/Cargo.toml index 2df1930..6187fde 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = [ ] [workspace.package] -version = "0.2.10" +version = "0.2.11" edition = "2021" license = "MIT" authors = ["DevITWay "] diff --git a/nora-registry/src/config.rs b/nora-registry/src/config.rs index bc54d62..6ed9ac0 100644 --- a/nora-registry/src/config.rs +++ b/nora-registry/src/config.rs @@ -11,6 +11,8 @@ pub struct Config { #[serde(default)] pub npm: NpmConfig, #[serde(default)] + pub pypi: PypiConfig, + #[serde(default)] pub auth: AuthConfig, } @@ -68,6 +70,14 @@ pub struct NpmConfig { pub proxy_timeout: u64, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PypiConfig { + #[serde(default)] + pub proxy: Option, + #[serde(default = "default_timeout")] + pub proxy_timeout: u64, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AuthConfig { #[serde(default)] @@ -108,6 +118,15 @@ impl Default for NpmConfig { } } +impl Default for PypiConfig { + fn default() -> Self { + Self { + proxy: Some("https://pypi.org/simple/".to_string()), + proxy_timeout: 30, + } + } +} + impl Default for AuthConfig { fn default() -> Self { Self { @@ -190,6 +209,16 @@ impl Config { } } + // PyPI config + if let Ok(val) = env::var("NORA_PYPI_PROXY") { + self.pypi.proxy = if val.is_empty() { None } else { Some(val) }; + } + if let Ok(val) = env::var("NORA_PYPI_PROXY_TIMEOUT") { + if let Ok(timeout) = val.parse() { + self.pypi.proxy_timeout = timeout; + } + } + // Token storage if let Ok(val) = env::var("NORA_AUTH_TOKEN_STORAGE") { self.auth.token_storage = val; @@ -212,6 +241,7 @@ impl Default for Config { }, maven: MavenConfig::default(), npm: NpmConfig::default(), + pypi: PypiConfig::default(), auth: AuthConfig::default(), } } diff --git a/nora-registry/src/registry/pypi.rs b/nora-registry/src/registry/pypi.rs index 2d0db15..cdac149 100644 --- a/nora-registry/src/registry/pypi.rs +++ b/nora-registry/src/registry/pypi.rs @@ -1,35 +1,295 @@ +use crate::activity_log::{ActionType, ActivityEntry}; use crate::AppState; use axum::{ - extract::State, - http::StatusCode, - response::{Html, IntoResponse}, + extract::{Path, State}, + http::{header, StatusCode}, + response::{Html, IntoResponse, Response}, routing::get, Router, }; use std::sync::Arc; +use std::time::Duration; pub fn routes() -> Router> { - Router::new().route("/simple/", get(list_packages)) + Router::new() + .route("/simple/", get(list_packages)) + .route("/simple/{name}/", get(package_versions)) + .route("/simple/{name}/{filename}", get(download_file)) } +/// List all packages (Simple API index) async fn list_packages(State(state): State>) -> 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()); + if !pkg.is_empty() { + packages.insert(pkg.to_string()); + } } } - let mut html = String::from("

Simple Index

"); + let mut html = String::from( + "\nSimple Index

Simple Index

\n", + ); let mut pkg_list: Vec<_> = packages.into_iter().collect(); pkg_list.sort(); for pkg in pkg_list { - html.push_str(&format!("{}
", pkg, pkg)); + html.push_str(&format!("{}
\n", pkg, pkg)); } html.push_str(""); (StatusCode::OK, Html(html)) } + +/// List versions/files for a specific package +async fn package_versions( + State(state): State>, + Path(name): Path, +) -> Response { + // Normalize package name (PEP 503) + let normalized = normalize_name(&name); + + // Try to get local files first + let prefix = format!("pypi/{}/", normalized); + let keys = state.storage.list(&prefix).await; + + if !keys.is_empty() { + // We have local files + let mut html = format!( + "\nLinks for {}

Links for {}

\n", + name, name + ); + + for key in &keys { + if let Some(filename) = key.strip_prefix(&prefix) { + if !filename.is_empty() { + html.push_str(&format!( + "{}
\n", + normalized, filename, filename + )); + } + } + } + html.push_str(""); + + return (StatusCode::OK, Html(html)).into_response(); + } + + // Try proxy if configured + if let Some(proxy_url) = &state.config.pypi.proxy { + let url = format!("{}{}/", proxy_url.trim_end_matches('/'), normalized); + + if let Ok(html) = fetch_package_page(&url, state.config.pypi.proxy_timeout).await { + // Rewrite URLs in the HTML to point to our registry + let rewritten = rewrite_pypi_links(&html, &normalized); + return (StatusCode::OK, Html(rewritten)).into_response(); + } + } + + StatusCode::NOT_FOUND.into_response() +} + +/// Download a specific file +async fn download_file( + State(state): State>, + Path((name, filename)): Path<(String, String)>, +) -> Response { + let normalized = normalize_name(&name); + let key = format!("pypi/{}/{}", normalized, filename); + + // Try local storage first + if let Ok(data) = state.storage.get(&key).await { + state.metrics.record_download("pypi"); + state.metrics.record_cache_hit(); + state.activity.push(ActivityEntry::new( + ActionType::CacheHit, + format!("{}/{}", name, filename), + "pypi", + "CACHE", + )); + + let content_type = if filename.ends_with(".whl") { + "application/zip" + } else if filename.ends_with(".tar.gz") || filename.ends_with(".tgz") { + "application/gzip" + } else { + "application/octet-stream" + }; + + return ( + StatusCode::OK, + [(header::CONTENT_TYPE, content_type)], + data, + ) + .into_response(); + } + + // Try proxy if configured + if let Some(proxy_url) = &state.config.pypi.proxy { + // First, fetch the package page to find the actual download URL + let page_url = format!("{}{}/", proxy_url.trim_end_matches('/'), normalized); + + if let Ok(html) = fetch_package_page(&page_url, state.config.pypi.proxy_timeout).await { + // Find the URL for this specific file + if let Some(file_url) = find_file_url(&html, &filename) { + if let Ok(data) = fetch_file(&file_url, state.config.pypi.proxy_timeout).await { + state.metrics.record_download("pypi"); + state.metrics.record_cache_miss(); + state.activity.push(ActivityEntry::new( + ActionType::ProxyFetch, + format!("{}/{}", name, filename), + "pypi", + "PROXY", + )); + + // Cache in local storage + 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; + }); + + let content_type = if filename.ends_with(".whl") { + "application/zip" + } else if filename.ends_with(".tar.gz") || filename.ends_with(".tgz") { + "application/gzip" + } else { + "application/octet-stream" + }; + + return ( + StatusCode::OK, + [(header::CONTENT_TYPE, content_type)], + data, + ) + .into_response(); + } + } + } + } + + StatusCode::NOT_FOUND.into_response() +} + +/// Normalize package name according to PEP 503 +fn normalize_name(name: &str) -> String { + name.to_lowercase().replace(['-', '_', '.'], "-") +} + +/// Fetch package page from upstream +async fn fetch_package_page(url: &str, timeout_secs: u64) -> Result { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(timeout_secs)) + .build() + .map_err(|_| ())?; + + let response = client + .get(url) + .header("Accept", "text/html") + .send() + .await + .map_err(|_| ())?; + + if !response.status().is_success() { + return Err(()); + } + + response.text().await.map_err(|_| ()) +} + +/// Fetch file from upstream +async fn fetch_file(url: &str, timeout_secs: u64) -> Result, ()> { + 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(|_| ()) +} + +/// Rewrite PyPI links to point to our registry +fn rewrite_pypi_links(html: &str, package_name: &str) -> String { + // Simple regex-free approach: find href="..." and rewrite + let mut result = String::with_capacity(html.len()); + let mut remaining = html; + + while let Some(href_start) = remaining.find("href=\"") { + result.push_str(&remaining[..href_start + 6]); + remaining = &remaining[href_start + 6..]; + + if let Some(href_end) = remaining.find('"') { + let url = &remaining[..href_end]; + + // Extract filename from URL + if let Some(filename) = extract_filename(url) { + // Rewrite to our local URL + result.push_str(&format!("/simple/{}/{}", package_name, filename)); + } else { + result.push_str(url); + } + + remaining = &remaining[href_end..]; + } + } + result.push_str(remaining); + result +} + +/// Extract filename from PyPI download URL +fn extract_filename(url: &str) -> Option<&str> { + // PyPI URLs look like: + // https://files.pythonhosted.org/packages/.../package-1.0.0.tar.gz#sha256=... + // or just the filename directly + + // Remove hash fragment + let url = url.split('#').next()?; + + // Get the last path component + let filename = url.rsplit('/').next()?; + + // Must be a valid package file + if filename.ends_with(".tar.gz") + || filename.ends_with(".tgz") + || filename.ends_with(".whl") + || filename.ends_with(".zip") + || filename.ends_with(".egg") + { + Some(filename) + } else { + None + } +} + +/// Find the download URL for a specific file in the HTML +fn find_file_url(html: &str, target_filename: &str) -> Option { + let mut remaining = html; + + while let Some(href_start) = remaining.find("href=\"") { + remaining = &remaining[href_start + 6..]; + + if let Some(href_end) = remaining.find('"') { + let url = &remaining[..href_end]; + + if let Some(filename) = extract_filename(url) { + if filename == target_filename { + // Remove hash fragment for actual download + return Some(url.split('#').next().unwrap_or(url).to_string()); + } + } + + remaining = &remaining[href_end..]; + } + } + + None +} diff --git a/nora-registry/src/ui/api.rs b/nora-registry/src/ui/api.rs index 187f300..7b13b01 100644 --- a/nora-registry/src/ui/api.rs +++ b/nora-registry/src/ui/api.rs @@ -215,7 +215,7 @@ pub async fn api_dashboard(State(state): State>) -> Json, extra_scripts: &str, + lang: Lang, ) -> String { + let t = get_translations(lang); format!( r##" - + @@ -58,33 +62,42 @@ pub fn layout_dark( document.body.classList.add('sidebar-open'); }} }} + + function setLang(lang) {{ + document.cookie = 'nora_lang=' + lang + ';path=/;max-age=31536000'; + window.location.reload(); + }} {} "##, + lang.code(), html_escape(title), - sidebar_dark(active_page), - header_dark(), + sidebar_dark(active_page, &t), + header_dark(lang), content, extra_scripts ) } /// Dark theme sidebar -fn sidebar_dark(active_page: Option<&str>) -> String { +fn sidebar_dark(active_page: Option<&str>, t: &Translations) -> String { let active = active_page.unwrap_or(""); let docker_icon = r#""#; let maven_icon = r#""#; let npm_icon = r#""#; - let cargo_icon = r#""#; + let cargo_icon = r#""#; let pypi_icon = r#""#; + // Dashboard label is translated, registry names stay as-is + let dashboard_label = t.nav_dashboard; + let nav_items = [ ( "dashboard", "/ui/", - "Dashboard", + dashboard_label, r#""#, true, ), @@ -124,7 +137,7 @@ fn sidebar_dark(active_page: Option<&str>) -> String { "#, - super::logo::LOGO_BASE64, nav_html, + t.nav_registries, VERSION ) } -/// Dark theme header -fn header_dark() -> String { - r##" +/// Dark theme header with language switcher +fn header_dark(lang: Lang) -> String { + let (en_class, ru_class) = match lang { + Lang::En => ("text-white font-semibold", "text-slate-400 hover:text-slate-200"), + Lang::Ru => ("text-slate-400 hover:text-slate-200", "text-white font-semibold"), + }; + + format!(r##"
- "##.to_string() + "##, en_class, ru_class) } /// Render global stats row (5-column grid) @@ -191,37 +212,39 @@ pub fn render_global_stats( artifacts: u64, cache_hit_percent: f64, storage_bytes: u64, + lang: Lang, ) -> String { + let t = get_translations(lang); format!( r##"
-
Downloads
+
{}
{}
-
Uploads
+
{}
{}
-
Artifacts
+
{}
{}
-
Cache Hit
+
{}
{:.1}%
-
Storage
+
{}
{}
"##, - downloads, - uploads, - artifacts, - cache_hit_percent, - format_size(storage_bytes) + t.stat_downloads, downloads, + t.stat_uploads, uploads, + t.stat_artifacts, artifacts, + t.stat_cache_hit, cache_hit_percent, + t.stat_storage, format_size(storage_bytes) ) } @@ -234,6 +257,7 @@ pub fn render_registry_card( uploads: u64, size_bytes: u64, href: &str, + t: &Translations, ) -> String { format!( r##" @@ -242,24 +266,24 @@ pub fn render_registry_card( {} - ACTIVE + {}
{}
- Artifacts + {}
{}
- Size + {}
{}
- Downloads + {}
{}
- Uploads + {}
{}
@@ -268,16 +292,17 @@ pub fn render_registry_card( href, name.to_lowercase(), icon_path, + t.active, name, - artifact_count, - format_size(size_bytes), - downloads, - uploads + t.artifacts, artifact_count, + t.size, format_size(size_bytes), + t.downloads, downloads, + t.uploads, uploads ) } /// Render mount points table -pub fn render_mount_points_table(mount_points: &[(String, String, Option)]) -> String { +pub fn render_mount_points_table(mount_points: &[(String, String, Option)], t: &Translations) -> String { let rows: String = mount_points .iter() .map(|(registry, mount_path, proxy)| { @@ -299,22 +324,28 @@ pub fn render_mount_points_table(mount_points: &[(String, String, Option r##"
-

Mount Points

+

{}

+
+
+ + + + + + + + + + {} + +
{}{}{}
- - - - - - - - - - {} - -
RegistryMount PathProxy Upstream
"##, + t.mount_points, + t.registry, + t.mount_path, + t.proxy_upstream, rows ) } @@ -355,22 +386,23 @@ pub fn render_activity_row( } /// Render the activity log container -pub fn render_activity_log(rows: &str) -> String { +pub fn render_activity_log(rows: &str, t: &Translations) -> String { format!( r##"
-
-

Recent Activity

+
+

{}

+ {}
-
+
- + - - - - - + + + + + @@ -380,6 +412,13 @@ pub fn render_activity_log(rows: &str) -> String { "##, + t.recent_activity, + t.last_n_events, + t.time, + t.action, + t.artifact, + t.registry, + t.source, rows ) } @@ -432,7 +471,7 @@ fn sidebar(active_page: Option<&str>) -> String { let docker_icon = r#""#; let maven_icon = r#""#; let npm_icon = r#""#; - let cargo_icon = r#""#; + let cargo_icon = r#""#; let pypi_icon = r#""#; let nav_items = [ @@ -555,7 +594,7 @@ pub mod icons { pub const DOCKER: &str = r#""#; pub const MAVEN: &str = r#""#; pub const NPM: &str = r#""#; - pub const CARGO: &str = r#""#; + pub const CARGO: &str = r#""#; pub const PYPI: &str = r#""#; } @@ -606,6 +645,56 @@ pub fn html_escape(s: &str) -> String { .replace('\'', "'") } +/// Render the "bragging" footer with NORA stats +pub fn render_bragging_footer(lang: Lang) -> String { + let t = get_translations(lang); + format!(r##" +
+
+ {} +
+
+
+
34 MB
+
{}
+
+
+
<1s
+
{}
+
+
+
~30 MB
+
{}
+
+
+
5
+
{}
+
+
+
{}
+
amd64 / arm64
+
+
+
{}
+
Config
+
+
+
+ {} +
+
+ "##, + t.built_for_speed, + t.docker_image, + t.cold_start, + t.memory, + t.registries_count, + t.multi_arch, + t.zero_config, + t.tagline + ) +} + /// Format Unix timestamp as relative time pub fn format_timestamp(ts: u64) -> String { if ts == 0 { diff --git a/nora-registry/src/ui/i18n.rs b/nora-registry/src/ui/i18n.rs new file mode 100644 index 0000000..41d6145 --- /dev/null +++ b/nora-registry/src/ui/i18n.rs @@ -0,0 +1,272 @@ +/// Internationalization support for the UI +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Lang { + #[default] + En, + Ru, +} + +impl Lang { + pub fn from_str(s: &str) -> Self { + match s.to_lowercase().as_str() { + "ru" | "rus" | "russian" => Lang::Ru, + _ => Lang::En, + } + } + + pub fn code(&self) -> &'static str { + match self { + Lang::En => "en", + Lang::Ru => "ru", + } + } +} + +/// All translatable strings +#[allow(dead_code)] +pub struct Translations { + // Navigation + pub nav_dashboard: &'static str, + pub nav_registries: &'static str, + + // Dashboard + pub dashboard_title: &'static str, + pub dashboard_subtitle: &'static str, + pub uptime: &'static str, + + // Stats + pub stat_downloads: &'static str, + pub stat_uploads: &'static str, + pub stat_artifacts: &'static str, + pub stat_cache_hit: &'static str, + pub stat_storage: &'static str, + + // Registry cards + pub active: &'static str, + pub artifacts: &'static str, + pub size: &'static str, + pub downloads: &'static str, + pub uploads: &'static str, + + // Mount points + pub mount_points: &'static str, + pub registry: &'static str, + pub mount_path: &'static str, + pub proxy_upstream: &'static str, + + // Activity + pub recent_activity: &'static str, + pub last_n_events: &'static str, + pub time: &'static str, + pub action: &'static str, + pub artifact: &'static str, + pub source: &'static str, + pub no_activity: &'static str, + + // Relative time + pub just_now: &'static str, + pub min_ago: &'static str, + pub mins_ago: &'static str, + pub hour_ago: &'static str, + pub hours_ago: &'static str, + pub day_ago: &'static str, + pub days_ago: &'static str, + + // Registry pages + pub repositories: &'static str, + pub search_placeholder: &'static str, + pub no_repos_found: &'static str, + pub push_first_artifact: &'static str, + pub name: &'static str, + pub tags: &'static str, + pub versions: &'static str, + pub updated: &'static str, + + // Detail pages + pub pull_command: &'static str, + pub install_command: &'static str, + pub maven_dependency: &'static str, + pub total: &'static str, + pub created: &'static str, + pub published: &'static str, + pub filename: &'static str, + pub files: &'static str, + + // Bragging footer + pub built_for_speed: &'static str, + pub docker_image: &'static str, + pub cold_start: &'static str, + pub memory: &'static str, + pub registries_count: &'static str, + pub multi_arch: &'static str, + pub zero_config: &'static str, + pub tagline: &'static str, +} + +pub fn get_translations(lang: Lang) -> &'static Translations { + match lang { + Lang::En => &TRANSLATIONS_EN, + Lang::Ru => &TRANSLATIONS_RU, + } +} + +pub static TRANSLATIONS_EN: Translations = Translations { + // Navigation + nav_dashboard: "Dashboard", + nav_registries: "Registries", + + // Dashboard + dashboard_title: "Dashboard", + dashboard_subtitle: "Overview of all registries", + uptime: "Uptime", + + // Stats + stat_downloads: "Downloads", + stat_uploads: "Uploads", + stat_artifacts: "Artifacts", + stat_cache_hit: "Cache Hit", + stat_storage: "Storage", + + // Registry cards + active: "ACTIVE", + artifacts: "Artifacts", + size: "Size", + downloads: "Downloads", + uploads: "Uploads", + + // Mount points + mount_points: "Mount Points", + registry: "Registry", + mount_path: "Mount Path", + proxy_upstream: "Proxy Upstream", + + // Activity + recent_activity: "Recent Activity", + last_n_events: "Last 20 events", + time: "Time", + action: "Action", + artifact: "Artifact", + source: "Source", + no_activity: "No recent activity", + + // Relative time + just_now: "just now", + min_ago: "min ago", + mins_ago: "mins ago", + hour_ago: "hour ago", + hours_ago: "hours ago", + day_ago: "day ago", + days_ago: "days ago", + + // Registry pages + repositories: "repositories", + search_placeholder: "Search repositories...", + no_repos_found: "No repositories found", + push_first_artifact: "Push your first artifact to see it here", + name: "Name", + tags: "Tags", + versions: "Versions", + updated: "Updated", + + // Detail pages + pull_command: "Pull Command", + install_command: "Install Command", + maven_dependency: "Maven Dependency", + total: "total", + created: "Created", + published: "Published", + filename: "Filename", + files: "files", + + // Bragging footer + built_for_speed: "Built for speed", + docker_image: "Docker Image", + cold_start: "Cold Start", + memory: "Memory", + registries_count: "Registries", + multi_arch: "Multi-arch", + zero_config: "Zero", + tagline: "Pure Rust. Single binary. OCI compatible.", +}; + +pub static TRANSLATIONS_RU: Translations = Translations { + // Navigation + nav_dashboard: "Панель", + nav_registries: "Реестры", + + // Dashboard + dashboard_title: "Панель управления", + dashboard_subtitle: "Обзор всех реестров", + uptime: "Аптайм", + + // Stats + stat_downloads: "Загрузки", + stat_uploads: "Публикации", + stat_artifacts: "Артефакты", + stat_cache_hit: "Кэш", + stat_storage: "Хранилище", + + // Registry cards + active: "АКТИВЕН", + artifacts: "Артефакты", + size: "Размер", + downloads: "Загрузки", + uploads: "Публикации", + + // Mount points + mount_points: "Точки монтирования", + registry: "Реестр", + mount_path: "Путь", + proxy_upstream: "Прокси", + + // Activity + recent_activity: "Последняя активность", + last_n_events: "Последние 20 событий", + time: "Время", + action: "Действие", + artifact: "Артефакт", + source: "Источник", + no_activity: "Нет активности", + + // Relative time + just_now: "только что", + min_ago: "мин назад", + mins_ago: "мин назад", + hour_ago: "час назад", + hours_ago: "ч назад", + day_ago: "день назад", + days_ago: "дн назад", + + // Registry pages + repositories: "репозиториев", + search_placeholder: "Поиск репозиториев...", + no_repos_found: "Репозитории не найдены", + push_first_artifact: "Загрузите первый артефакт, чтобы увидеть его здесь", + name: "Название", + tags: "Теги", + versions: "Версии", + updated: "Обновлено", + + // Detail pages + pull_command: "Команда загрузки", + install_command: "Команда установки", + maven_dependency: "Maven зависимость", + total: "всего", + created: "Создан", + published: "Опубликован", + filename: "Файл", + files: "файлов", + + // Bragging footer + built_for_speed: "Создан для скорости", + docker_image: "Docker образ", + cold_start: "Холодный старт", + memory: "Память", + registries_count: "Реестров", + multi_arch: "Мульти-арх", + zero_config: "Без", + tagline: "Чистый Rust. Один бинарник. OCI совместимый.", +}; diff --git a/nora-registry/src/ui/mod.rs b/nora-registry/src/ui/mod.rs index fa007f8..3954ac7 100644 --- a/nora-registry/src/ui/mod.rs +++ b/nora-registry/src/ui/mod.rs @@ -1,11 +1,12 @@ mod api; mod components; +pub mod i18n; mod logo; mod templates; use crate::AppState; use axum::{ - extract::{Path, State}, + extract::{Path, Query, State}, response::{Html, IntoResponse, Redirect}, routing::get, Router, @@ -13,8 +14,33 @@ use axum::{ use std::sync::Arc; use api::*; +use i18n::Lang; use templates::*; +#[derive(Debug, serde::Deserialize)] +struct LangQuery { + lang: Option, +} + +fn extract_lang(query: &Query, cookie_header: Option<&str>) -> Lang { + // Priority: query param > cookie > default + if let Some(ref lang) = query.lang { + return Lang::from_str(lang); + } + + // Try cookie + if let Some(cookies) = cookie_header { + for part in cookies.split(';') { + let part = part.trim(); + if let Some(value) = part.strip_prefix("nora_lang=") { + return Lang::from_str(value); + } + } + } + + Lang::default() +} + pub fn routes() -> Router> { Router::new() // UI Pages @@ -40,77 +66,122 @@ pub fn routes() -> Router> { } // Dashboard page -async fn dashboard(State(state): State>) -> impl IntoResponse { +async fn dashboard( + State(state): State>, + Query(query): Query, + headers: axum::http::HeaderMap, +) -> impl IntoResponse { + let lang = extract_lang(&Query(query), headers.get("cookie").and_then(|v| v.to_str().ok())); let response = api_dashboard(State(state)).await.0; - Html(render_dashboard(&response)) + Html(render_dashboard(&response, lang)) } // Docker pages -async fn docker_list(State(state): State>) -> impl IntoResponse { +async fn docker_list( + State(state): State>, + Query(query): Query, + headers: axum::http::HeaderMap, +) -> impl IntoResponse { + let lang = extract_lang(&Query(query), headers.get("cookie").and_then(|v| v.to_str().ok())); let repos = get_docker_repos(&state.storage).await; - Html(render_registry_list("docker", "Docker Registry", &repos)) + Html(render_registry_list("docker", "Docker Registry", &repos, lang)) } async fn docker_detail( State(state): State>, Path(name): Path, + Query(query): Query, + headers: axum::http::HeaderMap, ) -> impl IntoResponse { + let lang = extract_lang(&Query(query), headers.get("cookie").and_then(|v| v.to_str().ok())); let detail = get_docker_detail(&state.storage, &name).await; - Html(render_docker_detail(&name, &detail)) + Html(render_docker_detail(&name, &detail, lang)) } // Maven pages -async fn maven_list(State(state): State>) -> impl IntoResponse { +async fn maven_list( + State(state): State>, + Query(query): Query, + headers: axum::http::HeaderMap, +) -> impl IntoResponse { + let lang = extract_lang(&Query(query), headers.get("cookie").and_then(|v| v.to_str().ok())); let repos = get_maven_repos(&state.storage).await; - Html(render_registry_list("maven", "Maven Repository", &repos)) + Html(render_registry_list("maven", "Maven Repository", &repos, lang)) } async fn maven_detail( State(state): State>, Path(path): Path, + Query(query): Query, + headers: axum::http::HeaderMap, ) -> impl IntoResponse { + let lang = extract_lang(&Query(query), headers.get("cookie").and_then(|v| v.to_str().ok())); let detail = get_maven_detail(&state.storage, &path).await; - Html(render_maven_detail(&path, &detail)) + Html(render_maven_detail(&path, &detail, lang)) } // npm pages -async fn npm_list(State(state): State>) -> impl IntoResponse { +async fn npm_list( + State(state): State>, + Query(query): Query, + headers: axum::http::HeaderMap, +) -> impl IntoResponse { + let lang = extract_lang(&Query(query), headers.get("cookie").and_then(|v| v.to_str().ok())); let packages = get_npm_packages(&state.storage).await; - Html(render_registry_list("npm", "npm Registry", &packages)) + Html(render_registry_list("npm", "npm Registry", &packages, lang)) } async fn npm_detail( State(state): State>, Path(name): Path, + Query(query): Query, + headers: axum::http::HeaderMap, ) -> impl IntoResponse { + let lang = extract_lang(&Query(query), headers.get("cookie").and_then(|v| v.to_str().ok())); let detail = get_npm_detail(&state.storage, &name).await; - Html(render_package_detail("npm", &name, &detail)) + Html(render_package_detail("npm", &name, &detail, lang)) } // Cargo pages -async fn cargo_list(State(state): State>) -> impl IntoResponse { +async fn cargo_list( + State(state): State>, + Query(query): Query, + headers: axum::http::HeaderMap, +) -> impl IntoResponse { + let lang = extract_lang(&Query(query), headers.get("cookie").and_then(|v| v.to_str().ok())); let crates = get_cargo_crates(&state.storage).await; - Html(render_registry_list("cargo", "Cargo Registry", &crates)) + Html(render_registry_list("cargo", "Cargo Registry", &crates, lang)) } async fn cargo_detail( State(state): State>, Path(name): Path, + Query(query): Query, + headers: axum::http::HeaderMap, ) -> impl IntoResponse { + let lang = extract_lang(&Query(query), headers.get("cookie").and_then(|v| v.to_str().ok())); let detail = get_cargo_detail(&state.storage, &name).await; - Html(render_package_detail("cargo", &name, &detail)) + Html(render_package_detail("cargo", &name, &detail, lang)) } // PyPI pages -async fn pypi_list(State(state): State>) -> impl IntoResponse { +async fn pypi_list( + State(state): State>, + Query(query): Query, + headers: axum::http::HeaderMap, +) -> impl IntoResponse { + let lang = extract_lang(&Query(query), headers.get("cookie").and_then(|v| v.to_str().ok())); let packages = get_pypi_packages(&state.storage).await; - Html(render_registry_list("pypi", "PyPI Repository", &packages)) + Html(render_registry_list("pypi", "PyPI Repository", &packages, lang)) } async fn pypi_detail( State(state): State>, Path(name): Path, + Query(query): Query, + headers: axum::http::HeaderMap, ) -> impl IntoResponse { + let lang = extract_lang(&Query(query), headers.get("cookie").and_then(|v| v.to_str().ok())); let detail = get_pypi_detail(&state.storage, &name).await; - Html(render_package_detail("pypi", &name, &detail)) + Html(render_package_detail("pypi", &name, &detail, lang)) } diff --git a/nora-registry/src/ui/templates.rs b/nora-registry/src/ui/templates.rs index 334d78b..7360ffc 100644 --- a/nora-registry/src/ui/templates.rs +++ b/nora-registry/src/ui/templates.rs @@ -1,8 +1,10 @@ use super::api::{DashboardResponse, DockerDetail, MavenDetail, PackageDetail, RepoInfo}; use super::components::*; +use super::i18n::{get_translations, Lang}; /// Renders the main dashboard page with dark theme -pub fn render_dashboard(data: &DashboardResponse) -> String { +pub fn render_dashboard(data: &DashboardResponse, lang: Lang) -> String { + let t = get_translations(lang); // Render global stats let global_stats = render_global_stats( data.global_stats.downloads, @@ -10,6 +12,7 @@ pub fn render_dashboard(data: &DashboardResponse) -> String { data.global_stats.artifacts, data.global_stats.cache_hit_percent, data.global_stats.storage_bytes, + lang, ); // Render registry cards @@ -41,6 +44,7 @@ pub fn render_dashboard(data: &DashboardResponse) -> String { r.uploads, r.size_bytes, &format!("/ui/{}", r.name), + &t, ) }) .collect(); @@ -57,11 +61,11 @@ pub fn render_dashboard(data: &DashboardResponse) -> String { ) }) .collect(); - let mount_points = render_mount_points_table(&mount_data); + let mount_points = render_mount_points_table(&mount_data, &t); // Render activity log let activity_rows: String = if data.activity.is_empty() { - r##"
"##.to_string() + format!(r##""##, t.no_activity) } else { data.activity .iter() @@ -77,23 +81,26 @@ pub fn render_dashboard(data: &DashboardResponse) -> String { }) .collect() }; - let activity_log = render_activity_log(&activity_rows); + let activity_log = render_activity_log(&activity_rows, &t); // Format uptime let hours = data.uptime_seconds / 3600; let mins = (data.uptime_seconds % 3600) / 60; let uptime_str = format!("{}h {}m", hours, mins); + // Render bragging footer + let bragging_footer = render_bragging_footer(lang); + let content = format!( r##"
-

Dashboard

-

Overview of all registries

+

{}

+

{}

-
Uptime
+
{}
{}
@@ -105,16 +112,26 @@ pub fn render_dashboard(data: &DashboardResponse) -> String { {}
-
+
{} {}
+ + {} "##, - uptime_str, global_stats, registry_cards, mount_points, activity_log, + t.dashboard_title, + t.dashboard_subtitle, + t.uptime, + uptime_str, + global_stats, + registry_cards, + mount_points, + activity_log, + bragging_footer, ); let polling_script = render_polling_script(); - layout_dark("Dashboard", &content, Some("dashboard"), &polling_script) + layout_dark(t.dashboard_title, &content, Some("dashboard"), &polling_script, lang) } /// Format timestamp as relative time (e.g., "2 min ago") @@ -137,16 +154,16 @@ fn format_relative_time(timestamp: &chrono::DateTime) -> String { } /// Renders a registry list page (docker, maven, npm, cargo, pypi) -pub fn render_registry_list(registry_type: &str, title: &str, repos: &[RepoInfo]) -> String { +pub fn render_registry_list(registry_type: &str, title: &str, repos: &[RepoInfo], lang: Lang) -> String { + let t = get_translations(lang); let icon = get_registry_icon(registry_type); let table_rows = if repos.is_empty() { - r##"
"## - .to_string() +
{}
+
{}
+ "##, t.no_repos_found, t.push_first_artifact) } else { repos .iter() @@ -177,9 +194,8 @@ pub fn render_registry_list(registry_type: &str, title: &str, repos: &[RepoInfo] }; let version_label = match registry_type { - "docker" => "Tags", - "maven" => "Versions", - _ => "Versions", + "docker" => t.tags, + _ => t.versions, }; let content = format!( @@ -189,13 +205,13 @@ pub fn render_registry_list(registry_type: &str, title: &str, repos: &[RepoInfo] {}

{}

-

{} repositories

+

{} {}

- - - + + + @@ -227,16 +243,22 @@ pub fn render_registry_list(registry_type: &str, title: &str, repos: &[RepoInfo] icon, title, repos.len(), + t.repositories, + t.search_placeholder, registry_type, + t.name, version_label, + t.size, + t.updated, table_rows ); - layout_dark(title, &content, Some(registry_type), "") + layout_dark(title, &content, Some(registry_type), "", lang) } /// Renders Docker image detail page -pub fn render_docker_detail(name: &str, detail: &DockerDetail) -> String { +pub fn render_docker_detail(name: &str, detail: &DockerDetail, lang: Lang) -> String { + let _t = get_translations(lang); let tags_rows = if detail.tags.is_empty() { r##""##.to_string() } else { @@ -318,11 +340,12 @@ pub fn render_docker_detail(name: &str, detail: &DockerDetail) -> String { tags_rows ); - layout_dark(&format!("{} - Docker", name), &content, Some("docker"), "") + layout_dark(&format!("{} - Docker", name), &content, Some("docker"), "", lang) } /// Renders package detail page (npm, cargo, pypi) -pub fn render_package_detail(registry_type: &str, name: &str, detail: &PackageDetail) -> String { +pub fn render_package_detail(registry_type: &str, name: &str, detail: &PackageDetail, lang: Lang) -> String { + let _t = get_translations(lang); let icon = get_registry_icon(registry_type); let registry_title = get_registry_title(registry_type); @@ -422,11 +445,13 @@ pub fn render_package_detail(registry_type: &str, name: &str, detail: &PackageDe &content, Some(registry_type), "", + lang, ) } /// Renders Maven artifact detail page -pub fn render_maven_detail(path: &str, detail: &MavenDetail) -> String { +pub fn render_maven_detail(path: &str, detail: &MavenDetail, lang: Lang) -> String { + let _t = get_translations(lang); let artifact_rows = if detail.artifacts.is_empty() { r##""##.to_string() } else { @@ -506,7 +531,7 @@ pub fn render_maven_detail(path: &str, detail: &MavenDetail) -> String { artifact_rows ); - layout_dark(&format!("{} - Maven", path), &content, Some("maven"), "") + layout_dark(&format!("{} - Maven", path), &content, Some("maven"), "", lang) } /// Returns SVG icon path for the registry type
TimeActionArtifactRegistrySource{}{}{}{}{}
No recent activity
{}
+ format!(r##"
📭
-
No repositories found
-
Push your first artifact to see it here
-
Name {}SizeUpdated{}{}{}
No tags found
No artifacts found