25 Commits

Author SHA1 Message Date
833b3e376d docs: add bilingual onboarding (EN/RU) 2026-01-30 16:19:48 +00:00
783464acac fix: clippy let_and_return warning 2026-01-30 16:15:21 +00:00
8ac1e2c0c3 style: fix formatting 2026-01-30 16:06:40 +00:00
8c525bb5c2 feat: add Docker image metadata support
- Store metadata (.meta.json) alongside manifests with:
  - push_timestamp, last_pulled, downloads counter
  - size_bytes, os, arch, variant
  - layers list with digest and size
- Update metadata on manifest pull (increment downloads, update last_pulled)
- Extract OS/arch from config blob on push
- Extend UI API TagInfo with metadata fields
- Add public_url config option for pull commands
- Add Docker upstream proxy with auth support
- Add raw repository support
- Bump version to 0.2.12
2026-01-30 15:52:29 +00:00
5c53611cfd feat: add secrets provider architecture
Trait-based secrets management for secure credential handling:
- SecretsProvider trait for pluggable backends
- EnvProvider as default (12-Factor App pattern)
- ProtectedString with zeroize (memory zeroed on drop)
- Redacted Debug impl prevents secret leakage in logs
- S3Credentials struct for future AWS S3 integration
- Config: [secrets] section with provider and clear_env options

Foundation for AWS Secrets Manager, Vault, K8s (v0.4.0+)
2026-01-30 10:02:58 +00:00
73d28ea80b feat: add configurable rate limiting
Rate limits now configurable via config.toml and ENV variables:
- New [rate_limit] config section with auth/upload/general settings
- ENV: NORA_RATE_LIMIT_{AUTH|UPLOAD|GENERAL}_{RPS|BURST}
- Rate limit configuration logged at startup
- Functions accept &RateLimitConfig instead of hardcoded values
2026-01-30 08:20:50 +00:00
278275978c Fix clippy warnings 2026-01-26 19:43:51 +00:00
c1019238cb Fix code formatting 2026-01-26 19:42:20 +00:00
73e7e525a3 Add i18n support, PyPI proxy, and UI improvements
- Add Russian/English language switcher with cookie persistence
- Add PyPI proxy support with caching (like npm)
- Add height limits to Activity Log and Mount Points tables
- Change Cargo icon to delivery truck
- Replace graphical logo with styled text "NORA"
- Bump version to 0.2.11
2026-01-26 19:31:28 +00:00
0a5f267374 Bump version to 0.2.10 2026-01-26 18:43:21 +00:00
5353faef88 Apply dark theme to all UI pages
- Convert registry list, docker detail, package detail, maven detail pages to dark theme
- Use layout_dark instead of layout for all pages
- Update colors: bg-[#1e293b] cards, slate-700 borders, slate-200/400 text
- Mark unused light theme functions with #[allow(dead_code)]
2026-01-26 18:43:11 +00:00
a1c51e1b6b Bump version to 0.2.9 2026-01-26 18:02:43 +00:00
9cea0673da Bump version to 0.2.8 2026-01-26 17:46:34 +00:00
24f198e172 Add dashboard endpoint to OpenAPI documentation
- Add /api/ui/dashboard endpoint with dashboard tag
- Add schemas: DashboardResponse, GlobalStats, RegistryCardStats, MountPoint, ActivityEntry
- Update API version to 0.2.7 in OpenAPI spec
2026-01-26 17:45:54 +00:00
5eca1817af Display version dynamically in UI sidebar
- Add VERSION constant using CARGO_PKG_VERSION
- Show version in both light and dark theme sidebars
- Update workspace version to 0.2.7
2026-01-26 17:31:39 +00:00
80a96527fa Fix clippy warnings 2026-01-26 16:44:01 +00:00
1abe0df25a Fix formatting 2026-01-26 16:39:48 +00:00
38c727491b Add dashboard metrics, activity log, and dark theme
- Add DashboardMetrics for tracking downloads/uploads/cache hits per registry
- Add ActivityLog for recent activity with bounded size (50 entries)
- Instrument Docker, npm, Maven, and Cargo handlers with metrics
- Add /api/ui/dashboard endpoint with global stats and activity
- Implement dark theme dashboard with real-time polling (5s interval)
- Add mount points table showing registry paths and proxy upstreams
2026-01-26 16:21:25 +00:00
d0a9459acd Fix Docker push/pull: add PATCH endpoint for chunked uploads
- Add PATCH handler for /v2/{name}/blobs/uploads/{uuid} to support
  chunked blob uploads (Docker sends data chunks via PATCH)
- Include Range header in PATCH response to indicate bytes received
- Add Docker-Content-Digest header to GET manifest responses
- Store manifests by both tag and digest for proper pull support
- Add parking_lot dependency for upload session state management
2026-01-26 12:01:05 +00:00
482a68637e Fix rate limiting: exempt health/metrics, increase upload limits
- Health, metrics, UI, and API docs are now exempt from rate limiting
- Increased upload rate limits to 200 req/s with burst of 500 for Docker compatibility
2026-01-26 11:04:14 +00:00
61f8a39279 Use self-hosted runner for release builds
16-core runner should be 3-4x faster than GitHub's 2-core runners
2026-01-26 10:39:04 +00:00
835a6f0b14 Speed up release workflow
- Remove duplicate tests (already run on push to main)
- Build only for amd64 (arm64 rarely needed for VPS)
2026-01-26 10:18:11 +00:00
340c49bf12 Fix formatting 2026-01-26 10:14:11 +00:00
c84d13c26e Increase upload rate limits for Docker parallel requests
Docker client sends many parallel requests when pushing layers.
Increased upload rate limiter from 10 req/s to 50 req/s and burst from 20 to 100.
2026-01-26 10:10:45 +00:00
7e8978533a fix: resolve clippy warnings and format code 2026-01-26 08:31:00 +00:00
63 changed files with 1379 additions and 3159 deletions

View File

@@ -1,142 +0,0 @@
#!/bin/bash
# Pre-commit hook to prevent accidental commits of sensitive files
# Enable: git config core.hooksPath .githooks
set -e
RED='\033[0;31m'
YELLOW='\033[1;33m'
GREEN='\033[0;32m'
NC='\033[0m'
# Allowed file extensions (whitelist)
ALLOWED_EXTENSIONS=(
'\.rs$'
'\.toml$'
'\.lock$'
'\.yml$'
'\.yaml$'
'\.json$'
'\.sh$'
'\.html$'
'\.css$'
'\.js$'
'\.gitignore$'
'\.dockerignore$'
'Dockerfile$'
'LICENSE$'
'Makefile$'
)
# Extensions that trigger a warning (not blocked)
WARN_EXTENSIONS=(
'\.md$'
)
# Always blocked patterns (regardless of extension)
BLOCKED_PATTERNS=(
'\.env$'
'\.env\.'
'\.key$'
'\.pem$'
'\.p12$'
'\.pfx$'
'\.htpasswd$'
'secret'
'credential'
'password'
'\.bak$'
'\.swp$'
'\.swo$'
'node_modules/'
'target/debug/'
'\.DS_Store'
)
# Get staged files (only NEW files, not already tracked)
STAGED_FILES=$(git diff --cached --name-only --diff-filter=A)
if [ -z "$STAGED_FILES" ]; then
# No new files, only modifications to existing - allow
exit 0
fi
# Build patterns
ALLOWED_PATTERN=$(IFS='|'; echo "${ALLOWED_EXTENSIONS[*]}")
WARN_PATTERN=$(IFS='|'; echo "${WARN_EXTENSIONS[*]}")
BLOCKED_PATTERN=$(IFS='|'; echo "${BLOCKED_PATTERNS[*]}")
# Check for blocked patterns first
BLOCKED_FILES=$(echo "$STAGED_FILES" | grep -iE "$BLOCKED_PATTERN" || true)
if [ -n "$BLOCKED_FILES" ]; then
echo -e "${RED}BLOCKED: Suspicious files detected in commit${NC}"
echo ""
echo -e "${YELLOW}Files:${NC}"
echo "$BLOCKED_FILES" | sed 's/^/ - /'
echo ""
echo "If intentional, use: git commit --no-verify"
exit 1
fi
# Check for files with unknown extensions
UNKNOWN_FILES=""
WARN_FILES=""
while IFS= read -r file; do
[ -z "$file" ] && continue
if echo "$file" | grep -qE "$BLOCKED_PATTERN"; then
continue # Already handled above
elif echo "$file" | grep -qE "$WARN_PATTERN"; then
WARN_FILES="$WARN_FILES$file"$'\n'
elif ! echo "$file" | grep -qE "$ALLOWED_PATTERN"; then
UNKNOWN_FILES="$UNKNOWN_FILES$file"$'\n'
fi
done <<< "$STAGED_FILES"
# Warn about .md files
if [ -n "$WARN_FILES" ]; then
echo -e "${YELLOW}WARNING: Markdown files in commit:${NC}"
echo "$WARN_FILES" | sed '/^$/d' | sed 's/^/ - /'
echo ""
fi
# Block unknown extensions
if [ -n "$UNKNOWN_FILES" ]; then
echo -e "${RED}BLOCKED: Files with unknown extensions:${NC}"
echo "$UNKNOWN_FILES" | sed '/^$/d' | sed 's/^/ - /'
echo ""
echo "Allowed extensions: rs, toml, lock, yml, yaml, json, sh, html, css, js, md"
echo "If intentional, use: git commit --no-verify"
exit 1
fi
# Check for large files (>5MB)
LARGE_FILES=$(echo "$STAGED_FILES" | while read f; do
if [ -f "$f" ]; then
size=$(stat -f%z "$f" 2>/dev/null || stat -c%s "$f" 2>/dev/null || echo 0)
if [ "$size" -gt 5242880 ]; then
echo "$f ($(numfmt --to=iec $size 2>/dev/null || echo "${size}B"))"
fi
fi
done)
if [ -n "$LARGE_FILES" ]; then
echo -e "${YELLOW}WARNING: Large files (>5MB) in commit:${NC}"
echo "$LARGE_FILES" | sed 's/^/ - /'
echo ""
fi
# Run cargo fmt check if Rust files changed
if git diff --cached --name-only | grep -q '\.rs$'; then
if command -v cargo &> /dev/null; then
if ! cargo fmt --check &> /dev/null; then
echo -e "${RED}BLOCKED: cargo fmt check failed${NC}"
echo "Run: cargo fmt"
exit 1
fi
fi
fi
exit 0

View File

@@ -1,16 +0,0 @@
version: 2
updates:
# GitHub Actions — обновляет версии actions в workflows
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
labels: [dependencies, ci]
# Cargo — только security-апдейты, без шума от minor/patch
- package-ecosystem: cargo
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 5
labels: [dependencies, rust]

View File

@@ -11,7 +11,7 @@ jobs:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
@@ -27,63 +27,3 @@ jobs:
- name: Run tests
run: cargo test --package nora-registry
security:
name: Security
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write # for uploading SARIF to GitHub Security tab
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0 # full history required for gitleaks
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Cache cargo
uses: Swatinem/rust-cache@v2
# ── Secrets ────────────────────────────────────────────────────────────
- name: Gitleaks — scan for hardcoded secrets
run: |
curl -sL https://github.com/gitleaks/gitleaks/releases/download/v8.21.2/gitleaks_8.21.2_linux_x64.tar.gz \
| tar xz -C /usr/local/bin gitleaks
gitleaks detect --source . --exit-code 1 --report-format sarif --report-path gitleaks.sarif || true
continue-on-error: true # findings are reported, do not block the pipeline
# ── CVE in Rust dependencies ────────────────────────────────────────────
- name: Install cargo-audit
run: cargo install cargo-audit --locked
- name: cargo audit — RustSec advisory database
run: cargo audit
continue-on-error: true # warn only; known CVEs should not block CI until triaged
# ── Licenses, banned crates, supply chain policy ────────────────────────
- name: cargo deny — licenses and banned crates
uses: EmbarkStudios/cargo-deny-action@v2
with:
command: check
arguments: --all-features
# ── CVE scan of source tree and Cargo.lock ──────────────────────────────
- name: Trivy — filesystem scan (Cargo.lock + source)
if: always()
uses: aquasecurity/trivy-action@0.34.2
with:
scan-type: fs
scan-ref: .
format: sarif
output: trivy-fs.sarif
severity: HIGH,CRITICAL
exit-code: 0 # warn only; change to 1 to block the pipeline
- name: Upload Trivy fs results to GitHub Security tab
uses: github/codeql-action/upload-sarif@v4
if: always()
with:
sarif_file: trivy-fs.sarif
category: trivy-fs

View File

@@ -6,253 +6,75 @@ on:
env:
REGISTRY: ghcr.io
NORA: localhost:5000
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
name: Build & Push
runs-on: [self-hosted, nora]
runs-on: self-hosted
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Set up Rust
run: |
echo "/home/github-runner/.cargo/bin" >> $GITHUB_PATH
echo "RUSTUP_HOME=/home/github-runner/.rustup" >> $GITHUB_ENV
echo "CARGO_HOME=/home/github-runner/.cargo" >> $GITHUB_ENV
- name: Build release binary (musl static)
run: |
cargo build --release --target x86_64-unknown-linux-musl --package nora-registry
cp target/x86_64-unknown-linux-musl/release/nora ./nora
- name: Upload binary artifact
uses: actions/upload-artifact@v7
with:
name: nora-binary-${{ github.run_id }}
path: ./nora
retention-days: 1
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver-opts: network=host
- name: Log in to GitHub Container Registry
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# ── Alpine ───────────────────────────────────────────────────────────────
- name: Extract metadata (alpine)
id: meta-alpine
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.NORA }}/${{ env.IMAGE_NAME }}
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest
- name: Build and push (alpine)
uses: docker/build-push-action@v6
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
file: Dockerfile
platforms: linux/amd64
push: true
tags: ${{ steps.meta-alpine.outputs.tags }}
labels: ${{ steps.meta-alpine.outputs.labels }}
cache-from: type=registry,ref=${{ env.NORA }}/${{ env.IMAGE_NAME }}-cache:alpine
cache-to: type=registry,ref=${{ env.NORA }}/${{ env.IMAGE_NAME }}-cache:alpine,mode=max
# ── RED OS ───────────────────────────────────────────────────────────────
- name: Extract metadata (redos)
id: meta-redos
uses: docker/metadata-action@v5
with:
images: |
${{ env.NORA }}/${{ env.IMAGE_NAME }}
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
flavor: suffix=-redos,onlatest=true
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=redos
- name: Build and push (redos)
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.redos
platforms: linux/amd64
push: true
tags: ${{ steps.meta-redos.outputs.tags }}
labels: ${{ steps.meta-redos.outputs.labels }}
cache-from: type=registry,ref=${{ env.NORA }}/${{ env.IMAGE_NAME }}-cache:redos
cache-to: type=registry,ref=${{ env.NORA }}/${{ env.IMAGE_NAME }}-cache:redos,mode=max
# ── Astra Linux SE ───────────────────────────────────────────────────────
- name: Extract metadata (astra)
id: meta-astra
uses: docker/metadata-action@v5
with:
images: |
${{ env.NORA }}/${{ env.IMAGE_NAME }}
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
flavor: suffix=-astra,onlatest=true
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=astra
- name: Build and push (astra)
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.astra
platforms: linux/amd64
push: true
tags: ${{ steps.meta-astra.outputs.tags }}
labels: ${{ steps.meta-astra.outputs.labels }}
cache-from: type=registry,ref=${{ env.NORA }}/${{ env.IMAGE_NAME }}-cache:astra
cache-to: type=registry,ref=${{ env.NORA }}/${{ env.IMAGE_NAME }}-cache:astra,mode=max
scan:
name: Scan (${{ matrix.name }})
runs-on: [self-hosted, nora]
needs: build
permissions:
contents: read
packages: read
security-events: write
strategy:
fail-fast: false
matrix:
include:
- name: alpine
suffix: ""
- name: redos
suffix: "-redos"
- name: astra
suffix: "-astra"
steps:
- name: Set version tag (strip leading v)
id: ver
run: echo "tag=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
- name: Trivy — image scan (${{ matrix.name }})
uses: aquasecurity/trivy-action@0.34.2
with:
scan-type: image
image-ref: ${{ env.NORA }}/${{ env.IMAGE_NAME }}:${{ steps.ver.outputs.tag }}${{ matrix.suffix }}
format: sarif
output: trivy-image-${{ matrix.name }}.sarif
severity: HIGH,CRITICAL
exit-code: 1
- name: Upload Trivy image results to GitHub Security tab
uses: github/codeql-action/upload-sarif@v4
if: always()
with:
sarif_file: trivy-image-${{ matrix.name }}.sarif
category: trivy-image-${{ matrix.name }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
release:
name: GitHub Release
runs-on: [self-hosted, nora]
needs: [build, scan]
runs-on: ubuntu-latest
needs: build
permissions:
contents: write
packages: read
steps:
- uses: actions/checkout@v6
- name: Set version tag (strip leading v)
id: ver
run: echo "tag=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
- name: Download binary artifact
uses: actions/download-artifact@v4
with:
name: nora-binary-${{ github.run_id }}
path: ./artifacts
- name: Prepare binary
run: |
cp ./artifacts/nora ./nora-linux-amd64
chmod +x ./nora-linux-amd64
sha256sum ./nora-linux-amd64 > nora-linux-amd64.sha256
echo "Binary size: $(du -sh nora-linux-amd64 | cut -f1)"
cat nora-linux-amd64.sha256
- name: Generate SBOM (SPDX)
uses: anchore/sbom-action@v0
with:
image: ${{ env.NORA }}/${{ env.IMAGE_NAME }}:${{ steps.ver.outputs.tag }}
format: spdx-json
output-file: nora-${{ github.ref_name }}.sbom.spdx.json
- name: Generate SBOM (CycloneDX)
uses: anchore/sbom-action@v0
with:
image: ${{ env.NORA }}/${{ env.IMAGE_NAME }}:${{ steps.ver.outputs.tag }}
format: cyclonedx-json
output-file: nora-${{ github.ref_name }}.sbom.cdx.json
- uses: actions/checkout@v4
- name: Create Release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v1
with:
generate_release_notes: true
files: |
nora-linux-amd64
nora-linux-amd64.sha256
nora-${{ github.ref_name }}.sbom.spdx.json
nora-${{ github.ref_name }}.sbom.cdx.json
body: |
## Install
```bash
curl -fsSL https://getnora.io/install.sh | sh
```
Or download the binary directly:
```bash
curl -LO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/nora-linux-amd64
chmod +x nora-linux-amd64
sudo mv nora-linux-amd64 /usr/local/bin/nora
```
## Docker
**Alpine (standard):**
```bash
docker pull ghcr.io/${{ github.repository }}:${{ github.ref_name }}
```
**RED OS:**
```bash
docker pull ghcr.io/${{ github.repository }}:${{ github.ref_name }}-redos
```
**Astra Linux SE:**
```bash
docker pull ghcr.io/${{ github.repository }}:${{ github.ref_name }}-astra
```
## Changelog
See [CHANGELOG.md](https://github.com/${{ github.repository }}/blob/main/CHANGELOG.md)

12
.gitignore vendored
View File

@@ -5,15 +5,3 @@ data/
.env.*
*.log
internal config
# Internal files
SESSION*.md
TODO.md
ROADMAP*.md
docs-site/
docs/
*.txt
## Internal files
.internal/
examples/

View File

@@ -4,159 +4,18 @@ All notable changes to NORA will be documented in this file.
---
## [0.2.24] - 2026-02-24
### Added / Добавлено
- `install.sh` installer script live at <https://getnora.io/install.sh> — `curl -fsSL https://getnora.io/install.sh | sh`
- Скрипт установки `install.sh` доступен на <https://getnora.io/install.sh>
### CI/CD
- Restore Astra Linux SE Docker image build, Trivy scan, and release artifact (`-astra` tag)
- Восстановлена сборка Docker-образа для Astra Linux SE, сканирование Trivy и артефакт релиза (тег `-astra`)
---
## [0.2.23] - 2026-02-24
### Added / Добавлено
- Binary (`nora`) + SHA-256 checksum attached to every GitHub Release
- Бинарник (`nora`) и SHA-256 контрольная сумма прикреплены к каждому релизу GitHub
### Fixed / Исправлено
- Security: bump `prometheus` 0.13 → 0.14 (CVE-2025-53605) and `bytes` 1.11.0 → 1.11.1 (CVE-2026-25541)
- Безопасность: обновлены `prometheus` 0.13 → 0.14 (CVE-2025-53605) и `bytes` 1.11.0 → 1.11.1 (CVE-2026-25541)
### CI/CD
- Add Dependabot for automated dependency updates / Добавлен Dependabot для автоматического обновления зависимостей
- Pin `aquasecurity/trivy-action` to `0.30.0`, bump to `0.34.1`; scan gate blocks release on HIGH/CRITICAL CVE
- Закреплён `trivy-action@0.30.0`, обновлён до `0.34.1`; сканирование блокирует релиз при HIGH/CRITICAL CVE
- Upgrade `codeql-action` v3 → v4 / Обновлён `codeql-action` v3 → v4
- Fix `deny.toml` deprecated keys (`copyleft`, `unlicensed` removed in `cargo-deny`) / Исправлены устаревшие ключи в `deny.toml`
- Fix binary path in Docker image (`/usr/local/bin/nora`) / Исправлен путь бинарника в Docker-образе
- Pin build job to `nora` runner label / Джоб сборки закреплён за runner'ом с меткой `nora`
- Allow `CDLA-Permissive-2.0` license (`webpki-roots`) / Разрешена лицензия `CDLA-Permissive-2.0`
- Ignore `RUSTSEC-2025-0119` (unmaintained transitive dep `number_prefix` via `indicatif`)
### Dependencies / Зависимости
- `chrono` 0.4.43 → 0.4.44
- `quick-xml` 0.31.0 → 0.39.2
- `toml` 0.8.23 → 1.0.3+spec-1.1.0
- `flate2` 1.1.8 → 1.1.9
- `softprops/action-gh-release` 1 → 2
- `actions/checkout` 4 → 6
- `docker/build-push-action` 5 → 6
### Documentation / Документация
- Replace text title with SVG logo; `O` styled in blue-600 / Заголовок заменён SVG-логотипом; буква `O` стилизована в blue-600
---
## [0.2.22] - 2026-02-24
### Changed / Изменено
- First stable release with Docker images published to container registry
- Первый стабильный релиз с Docker-образами, опубликованными в container registry
---
## [0.2.21] - 2026-02-24
### CI/CD
- Consolidate all Docker builds into a single job to fix runner network issues / Все Docker-сборки объединены в один job для устранения сетевых проблем runner'а
- Build musl static binary for maximum portability / Сборка musl-бинарника для максимальной переносимости
- Add security scanning (Trivy) + SBOM generation to release pipeline / Добавлено сканирование безопасности (Trivy) и генерация SBOM в pipeline релиза
- Add Cargo cache to speed up builds / Добавлен кэш Cargo для ускорения сборок
- Replace `gitleaks` GitHub Action with CLI (no license requirement) / `gitleaks` Action заменён CLI-вызовом (лицензия не требуется)
- Use GitHub-runner's own Rust toolchain (avoid path conflicts) / Используется Rust toolchain самого GitHub-runner'а
- Use shared runner filesystem instead of artifact API (avoids network upload latency) / Общая файловая система runner'а вместо artifact API
- Remove Astra Linux build temporarily / Сборка для Astra Linux временно удалена
---
## [0.2.20] - 2026-02-23
### Added / Добавлено
- Parallel CI builds for Astra Linux and RedOS / Параллельная сборка в CI для Astra Linux и RedOS
### Changed / Изменено
- Use `FROM scratch` base image for Astra Linux and RedOS Docker builds / Базовый образ `FROM scratch` для Docker-сборок Astra Linux и RedOS
- Shared `reqwest::Client` across all registry handlers / Общий `reqwest::Client` для всех registry-обработчиков
### Fixed / Исправлено
- Auth: replace `starts_with` with explicit `matches!` for token path checks / Аутентификация: `starts_with` заменён явной проверкой `matches!` для путей с токенами
- Remove unnecessary QEMU step for amd64-only builds / Удалён лишний шаг QEMU для amd64-сборок
---
## [0.2.19] - 2026-01-31
### Added / Добавлено
- Pre-commit hook to prevent accidental commits of sensitive files / Pre-commit хук для защиты от случайного коммита чувствительных файлов
- README badges: build status, version, license / Бейджи в README: статус сборки, версия, лицензия
### Performance / Производительность
- In-memory repository index with pagination for faster dashboard load / Индекс репозитория в памяти с пагинацией для ускорения загрузки дашборда
### Fixed / Исправлено
- Use `div_ceil` instead of manual ceiling division / Использован `div_ceil` вместо ручной реализации деления с округлением вверх
---
## [0.2.18] - 2026-01-31
### Changed
- Logo styling refinements
---
## [0.2.17] - 2026-01-31
### Added
- Copyright headers to all source files (Volkov Pavel | DevITWay)
- SPDX-License-Identifier: MIT in all .rs files
---
## [0.2.16] - 2026-01-31
### Changed
- N○RA branding: stylized O logo across dashboard
- Fixed O letter alignment in logo
---
## [0.2.15] - 2026-01-31
### Fixed
- Code formatting (cargo fmt)
---
## [0.2.14] - 2026-01-31
### Fixed
- Docker dashboard now shows actual image size from manifest layers (config + layers sum)
- Previously showed only manifest file size (~500 B instead of actual image size)
---
## [0.2.13] - 2026-01-31
### Fixed
- npm dashboard now shows correct version count and package sizes
- Parses metadata.json for versions, dist.unpackedSize, and time.modified
- Previously showed 0 versions / 0 B for all packages
---
## [0.2.12] - 2026-01-30
## [0.3.0] - 2026-01-30
### Added
#### Configurable Rate Limiting
- Rate limits now configurable via `config.toml` and environment variables
- New config section `[rate_limit]` with parameters: `auth_rps`, `auth_burst`, `upload_rps`, `upload_burst`, `general_rps`, `general_burst`
- New config section `[rate_limit]` with 6 parameters:
- `auth_rps` / `auth_burst` - Authentication endpoints (brute-force protection)
- `upload_rps` / `upload_burst` - Upload endpoints (Docker push, etc.)
- `general_rps` / `general_burst` - General API endpoints
- Environment variables: `NORA_RATE_LIMIT_{AUTH|UPLOAD|GENERAL}_{RPS|BURST}`
- Rate limit configuration logged at startup
#### Secrets Provider Architecture
- Trait-based secrets management (`SecretsProvider` trait)
@@ -164,78 +23,14 @@ All notable changes to NORA will be documented in this file.
- Protected secrets with `zeroize` (memory zeroed on drop)
- Redacted Debug impl prevents secret leakage in logs
- New config section `[secrets]` with `provider` and `clear_env` options
#### Docker Image Metadata
- Support for image metadata retrieval
#### Documentation
- Bilingual onboarding guide (EN/RU)
---
## [0.2.11] - 2026-01-26
### Added
- Internationalization (i18n) support
- PyPI registry proxy
- UI improvements
---
## [0.2.10] - 2026-01-26
- Foundation for future AWS Secrets Manager, Vault, K8s integration
### Changed
- Dark theme applied to all UI pages
---
## [0.2.9] - 2026-01-26
### Changed
- Version bump release
---
## [0.2.8] - 2026-01-26
### Added
- Dashboard endpoint added to OpenAPI documentation
---
## [0.2.7] - 2026-01-26
### Added
- Dynamic version display in UI sidebar
---
## [0.2.6] - 2026-01-26
### Added
#### Dashboard Metrics
- Global stats panel: downloads, uploads, artifacts, cache hit rate, storage
- Extended registry cards with artifact count, size, counters
- Activity log (last 20 events)
#### UI
- Dark theme (bg: #0f172a, cards: #1e293b)
---
## [0.2.5] - 2026-01-26
- Rate limiting functions now accept `&RateLimitConfig` parameter
- Improved error messages with `.expect()` instead of `.unwrap()`
### Fixed
- Docker push/pull: added PATCH endpoint for chunked uploads
---
## [0.2.4] - 2026-01-26
### Fixed
- Rate limiting: health/metrics endpoints now exempt
- Increased upload rate limits for Docker parallel requests
- Rate limiting was hardcoded in v0.2.0, now user-configurable
---
@@ -287,6 +82,7 @@ All notable changes to NORA will be documented in this file.
- JSON error responses with request_id support
### Changed
- `StorageError` now uses `thiserror` derive macro
- `TokenError` now uses `thiserror` derive macro
- Storage wrapper validates keys before delegating to backend
@@ -294,6 +90,7 @@ All notable changes to NORA will be documented in this file.
- Body size limit set to 100MB default via `DefaultBodyLimit`
### Dependencies Added
- `thiserror = "2"` - typed error handling
- `tower_governor = "0.8"` - rate limiting
- `governor = "0.10"` - rate limiting backend
@@ -301,6 +98,7 @@ All notable changes to NORA will be documented in this file.
- `wiremock = "0.6"` (dev) - HTTP mocking for S3 tests
### Files Added
- `src/validation.rs` - input validation module
- `src/migrate.rs` - storage migration module
- `src/error.rs` - application error types
@@ -312,6 +110,7 @@ All notable changes to NORA will be documented in this file.
## [0.1.0] - 2026-01-24
### Added
- Multi-protocol support: Docker Registry v2, Maven, npm, Cargo, PyPI
- Web UI dashboard
- Swagger UI (`/api-docs`)
@@ -326,6 +125,7 @@ All notable changes to NORA will be documented in this file.
- Graceful shutdown (SIGTERM/SIGINT)
- Backup/restore commands
---
---
# Журнал изменений (RU)
@@ -334,96 +134,6 @@ All notable changes to NORA will be documented in this file.
---
## [0.2.12] - 2026-01-30
### Добавлено
#### Настраиваемый Rate Limiting
- Rate limits настраиваются через `config.toml` и переменные окружения
- Новая секция `[rate_limit]` с параметрами: `auth_rps`, `auth_burst`, `upload_rps`, `upload_burst`, `general_rps`, `general_burst`
- Переменные окружения: `NORA_RATE_LIMIT_{AUTH|UPLOAD|GENERAL}_{RPS|BURST}`
#### Архитектура Secrets Provider
- Trait-based управление секретами (`SecretsProvider` trait)
- ENV provider по умолчанию (12-Factor App паттерн)
- Защищённые секреты с `zeroize` (память обнуляется при drop)
- Redacted Debug impl предотвращает утечку секретов в логи
- Новая секция `[secrets]` с опциями `provider` и `clear_env`
#### Docker Image Metadata
- Поддержка получения метаданных образов
#### Документация
- Двуязычный onboarding guide (EN/RU)
---
## [0.2.11] - 2026-01-26
### Добавлено
- Поддержка интернационализации (i18n)
- PyPI registry proxy
- Улучшения UI
---
## [0.2.10] - 2026-01-26
### Изменено
- Тёмная тема применена ко всем страницам UI
---
## [0.2.9] - 2026-01-26
### Изменено
- Релиз с обновлением версии
---
## [0.2.8] - 2026-01-26
### Добавлено
- Dashboard endpoint добавлен в OpenAPI документацию
---
## [0.2.7] - 2026-01-26
### Добавлено
- Динамическое отображение версии в сайдбаре UI
---
## [0.2.6] - 2026-01-26
### Добавлено
#### Dashboard Metrics
- Глобальная панель статистики: downloads, uploads, artifacts, cache hit rate, storage
- Расширенные карточки реестров с количеством артефактов, размером, счётчиками
- Лог активности (последние 20 событий)
#### UI
- Тёмная тема (bg: #0f172a, cards: #1e293b)
---
## [0.2.5] - 2026-01-26
### Исправлено
- Docker push/pull: добавлен PATCH endpoint для chunked uploads
---
## [0.2.4] - 2026-01-26
### Исправлено
- Rate limiting: health/metrics endpoints теперь исключены
- Увеличены лимиты upload для параллельных Docker запросов
---
## [0.2.0] - 2026-01-25
### Добавлено
@@ -472,6 +182,7 @@ All notable changes to NORA will be documented in this file.
- JSON-ответы об ошибках с поддержкой request_id
### Изменено
- `StorageError` теперь использует макрос `thiserror`
- `TokenError` теперь использует макрос `thiserror`
- Storage wrapper валидирует ключи перед делегированием backend
@@ -479,6 +190,7 @@ All notable changes to NORA will be documented in this file.
- Лимит размера body установлен в 100MB через `DefaultBodyLimit`
### Добавлены зависимости
- `thiserror = "2"` - типизированная обработка ошибок
- `tower_governor = "0.8"` - rate limiting
- `governor = "0.10"` - backend для rate limiting
@@ -486,6 +198,7 @@ All notable changes to NORA will be documented in this file.
- `wiremock = "0.6"` (dev) - HTTP-мокирование для S3 тестов
### Добавлены файлы
- `src/validation.rs` - модуль валидации ввода
- `src/migrate.rs` - модуль миграции хранилища
- `src/error.rs` - типы ошибок приложения
@@ -497,6 +210,7 @@ All notable changes to NORA will be documented in this file.
## [0.1.0] - 2026-01-24
### Добавлено
- Мульти-протокольная поддержка: Docker Registry v2, Maven, npm, Cargo, PyPI
- Web UI дашборд
- Swagger UI (`/api-docs`)

View File

@@ -1,71 +1,100 @@
# Contributing to NORA
Thank you for your interest in contributing to NORA!
Thanks for your interest in contributing to NORA!
## Getting Started
1. Fork the repository
2. Clone your fork: `git clone https://github.com/YOUR_USERNAME/nora.git`
3. Create a branch: `git checkout -b feature/your-feature`
1. **Fork** the repository
2. **Clone** your fork:
```bash
git clone https://github.com/your-username/nora.git
cd nora
```
3. **Create a branch**:
```bash
git checkout -b feature/your-feature-name
```
## Development Setup
### Prerequisites
- Rust 1.75+ (`rustup update`)
- Docker (for testing)
- Git
### Build
```bash
# Install Rust (if needed)
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# Enable pre-commit hooks (important!)
git config core.hooksPath .githooks
# Build
cargo build
# Run tests
cargo test
# Run locally
cargo run --bin nora -- serve
```
## Code Style
### Run
- Run `cargo fmt` before committing
- Run `cargo clippy` and fix warnings
- Follow Rust naming conventions
```bash
cargo run --bin nora
```
## Pull Request Process
### Test
1. Update documentation if needed
2. Add tests for new features
3. Ensure all tests pass: `cargo test`
4. Ensure code is formatted: `cargo fmt --check`
5. Ensure no clippy warnings: `cargo clippy`
```bash
cargo test
cargo clippy
cargo fmt --check
```
## Making Changes
1. **Write code** following Rust conventions
2. **Add tests** for new features
3. **Update docs** if needed
4. **Run checks**:
```bash
cargo fmt
cargo clippy -- -D warnings
cargo test
```
## Commit Messages
Use conventional commits:
Follow [Conventional Commits](https://www.conventionalcommits.org/):
- `feat:` - new feature
- `fix:` - bug fix
- `docs:` - documentation
- `style:` - formatting
- `refactor:` - code refactoring
- `test:` - adding tests
- `chore:` - maintenance
- `feat:` - New feature
- `fix:` - Bug fix
- `docs:` - Documentation
- `test:` - Tests
- `refactor:` - Code refactoring
- `chore:` - Maintenance
Example: `feat: add OAuth2 authentication`
Example:
```bash
git commit -m "feat: add S3 storage migration"
```
## Reporting Issues
## Pull Request Process
- Use GitHub Issues
- Include steps to reproduce
- Include NORA version and OS
1. **Push** to your fork:
```bash
git push origin feature/your-feature-name
```
## License
2. **Open a Pull Request** on GitHub
By contributing, you agree that your contributions will be licensed under the MIT License.
3. **Wait for review** - maintainers will review your PR
## Contact
## Code Style
- Telegram: [@DevITWay](https://t.me/DevITWay)
- GitHub Issues: [getnora-io/nora](https://github.com/getnora-io/nora/issues)
- Follow Rust conventions
- Use `cargo fmt` for formatting
- Pass `cargo clippy` with no warnings
- Write meaningful commit messages
## Questions?
- Open an [Issue](https://github.com/getnora-io/nora/issues)
- Ask in [Discussions](https://github.com/getnora-io/nora/discussions)
- Reach out on [Telegram](https://t.me/DevITWay)
---
Built with love by the NORA community

411
Cargo.lock generated
View File

@@ -82,12 +82,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "anyhow"
version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arbitrary"
version = "1.4.2"
@@ -190,9 +184,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bcrypt"
version = "0.18.0"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a0f5948f30df5f43ac29d310b7476793be97c50787e6ef4a63d960a0d0be827"
checksum = "abaf6da45c74385272ddf00e1ac074c7d8a6c1a1dda376902bd6a427522a8b2c"
dependencies = [
"base64",
"blowfish",
@@ -240,15 +234,15 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "bytes"
version = "1.11.1"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
[[package]]
name = "cc"
version = "1.2.55"
version = "1.2.54"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29"
checksum = "6354c81bbfd62d9cfa9cb3c773c2b7b2a3a482d569de977fd0e961f6e7c00583"
dependencies = [
"find-msvc-tools",
"shlex",
@@ -268,9 +262,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chrono"
version = "0.4.44"
version = "0.4.43"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118"
dependencies = [
"iana-time-zone",
"js-sys",
@@ -292,9 +286,9 @@ dependencies = [
[[package]]
name = "clap"
version = "4.5.60"
version = "4.5.54"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a"
checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394"
dependencies = [
"clap_builder",
"clap_derive",
@@ -302,9 +296,9 @@ dependencies = [
[[package]]
name = "clap_builder"
version = "4.5.60"
version = "4.5.54"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876"
checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00"
dependencies = [
"anstream",
"anstyle",
@@ -314,9 +308,9 @@ dependencies = [
[[package]]
name = "clap_derive"
version = "4.5.55"
version = "4.5.49"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671"
dependencies = [
"heck",
"proc-macro2",
@@ -326,9 +320,9 @@ dependencies = [
[[package]]
name = "clap_lex"
version = "1.0.0"
version = "0.7.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831"
checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32"
[[package]]
name = "colorchoice"
@@ -338,15 +332,15 @@ checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
[[package]]
name = "console"
version = "0.16.2"
version = "0.15.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03e45a4a8926227e4197636ba97a9fc9b00477e9f4bd711395687c5f0734bec4"
checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8"
dependencies = [
"encode_unicode",
"libc",
"once_cell",
"unicode-width",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -440,7 +434,6 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"subtle",
]
[[package]]
@@ -495,15 +488,15 @@ dependencies = [
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db"
[[package]]
name = "flate2"
version = "1.1.9"
version = "1.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369"
dependencies = [
"crc32fast",
"miniz_oxide",
@@ -516,12 +509,6 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foldhash"
version = "0.2.0"
@@ -679,19 +666,6 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "getrandom"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"wasip2",
"wasip3",
]
[[package]]
name = "governor"
version = "0.10.4"
@@ -740,15 +714,6 @@ version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash 0.1.5",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
@@ -757,7 +722,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash 0.2.0",
"foldhash",
]
[[package]]
@@ -772,21 +737,6 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
]
[[package]]
name = "http"
version = "1.4.0"
@@ -911,9 +861,9 @@ dependencies = [
[[package]]
name = "iana-time-zone"
version = "0.1.65"
version = "0.1.64"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb"
dependencies = [
"android_system_properties",
"core-foundation-sys",
@@ -1014,12 +964,6 @@ dependencies = [
"zerovec",
]
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "idna"
version = "1.1.0"
@@ -1055,14 +999,14 @@ dependencies = [
[[package]]
name = "indicatif"
version = "0.18.4"
version = "0.17.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb"
checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235"
dependencies = [
"console",
"number_prefix",
"portable-atomic",
"unicode-width",
"unit-prefix",
"web-time",
]
@@ -1119,17 +1063,11 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "libc"
version = "0.2.182"
version = "0.2.180"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
[[package]]
name = "libredox"
@@ -1144,9 +1082,9 @@ dependencies = [
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
[[package]]
name = "litemap"
@@ -1247,7 +1185,7 @@ checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21"
[[package]]
name = "nora-cli"
version = "0.2.25"
version = "0.2.12"
dependencies = [
"clap",
"flate2",
@@ -1261,7 +1199,7 @@ dependencies = [
[[package]]
name = "nora-registry"
version = "0.2.25"
version = "0.2.12"
dependencies = [
"async-trait",
"axum",
@@ -1271,8 +1209,6 @@ dependencies = [
"clap",
"flate2",
"governor",
"hex",
"hmac",
"httpdate",
"indicatif",
"lazy_static",
@@ -1299,7 +1235,7 @@ dependencies = [
[[package]]
name = "nora-storage"
version = "0.2.25"
version = "0.2.12"
dependencies = [
"axum",
"base64",
@@ -1344,6 +1280,12 @@ dependencies = [
"libc",
]
[[package]]
name = "number_prefix"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3"
[[package]]
name = "once_cell"
version = "1.21.3"
@@ -1441,16 +1383,6 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
@@ -1462,9 +1394,9 @@ dependencies = [
[[package]]
name = "prometheus"
version = "0.14.0"
version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ca5326d8d0b950a9acd87e6a3f94745394f62e4dae1b1ee22b2bc0c394af43a"
checksum = "3d33c28a30771f7f96db69893f78b857f7450d7e0237e9c8fc6427a81bae7ed1"
dependencies = [
"cfg-if",
"fnv",
@@ -1472,28 +1404,14 @@ dependencies = [
"memchr",
"parking_lot",
"protobuf",
"thiserror 2.0.18",
"thiserror 1.0.69",
]
[[package]]
name = "protobuf"
version = "3.7.2"
version = "2.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d65a1d4ddae7d8b5de68153b48f6aa3bba8cb002b243dbdbc55a5afbc98f99f4"
dependencies = [
"once_cell",
"protobuf-support",
"thiserror 1.0.69",
]
[[package]]
name = "protobuf-support"
version = "3.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e36c2f31e0a47f9280fb347ef5e461ffcd2c52dd520d8e216b52f93b0b0d7d6"
dependencies = [
"thiserror 1.0.69",
]
checksum = "106dd99e98437432fed6519dedecfade6a06a73bb7b2a1e019fdd2bee5778d94"
[[package]]
name = "quanta"
@@ -1512,9 +1430,9 @@ dependencies = [
[[package]]
name = "quick-xml"
version = "0.39.2"
version = "0.31.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d"
checksum = "1004a344b30a54e2ee58d66a71b32d2db2feb0a31f9a2d302bf0536f15de2a33"
dependencies = [
"memchr",
"serde",
@@ -1771,9 +1689,9 @@ checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
[[package]]
name = "rustix"
version = "1.1.4"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
dependencies = [
"bitflags",
"errno",
@@ -1844,12 +1762,6 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "semver"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
[[package]]
name = "serde"
version = "1.0.228"
@@ -1906,11 +1818,11 @@ dependencies = [
[[package]]
name = "serde_spanned"
version = "1.0.4"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776"
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
dependencies = [
"serde_core",
"serde",
]
[[package]]
@@ -2060,12 +1972,12 @@ dependencies = [
[[package]]
name = "tempfile"
version = "3.26.0"
version = "3.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0"
checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c"
dependencies = [
"fastrand",
"getrandom 0.4.1",
"getrandom 0.3.4",
"once_cell",
"rustix",
"windows-sys 0.61.2",
@@ -2209,48 +2121,50 @@ dependencies = [
[[package]]
name = "toml"
version = "1.0.3+spec-1.1.0"
version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7614eaf19ad818347db24addfa201729cf2a9b6fdfd9eb0ab870fcacc606c0c"
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
dependencies = [
"indexmap",
"serde_core",
"serde",
"serde_spanned",
"toml_datetime",
"toml_parser",
"toml_writer",
"winnow",
"toml_edit",
]
[[package]]
name = "toml_datetime"
version = "1.0.0+spec-1.1.0"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e"
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
dependencies = [
"serde_core",
"serde",
]
[[package]]
name = "toml_parser"
version = "1.0.9+spec-1.1.0"
name = "toml_edit"
version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4"
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
"indexmap",
"serde",
"serde_spanned",
"toml_datetime",
"toml_write",
"winnow",
]
[[package]]
name = "toml_writer"
version = "1.0.6+spec-1.1.0"
name = "toml_write"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
name = "tonic"
version = "0.14.3"
version = "0.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a286e33f82f8a1ee2df63f4fa35c0becf4a85a0cb03091a15fd7bf0b402dc94a"
checksum = "eb7613188ce9f7df5bfe185db26c5814347d110db17920415cf2fbcad85e7203"
dependencies = [
"async-trait",
"axum",
@@ -2446,18 +2360,6 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "unit-prefix"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3"
[[package]]
name = "untrusted"
version = "0.9.0"
@@ -2533,11 +2435,11 @@ dependencies = [
[[package]]
name = "uuid"
version = "1.21.0"
version = "1.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb"
checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f"
dependencies = [
"getrandom 0.4.1",
"getrandom 0.3.4",
"js-sys",
"wasm-bindgen",
]
@@ -2588,15 +2490,6 @@ dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasip3"
version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.108"
@@ -2656,40 +2549,6 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "wasm-encoder"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags",
"hashbrown 0.15.5",
"indexmap",
"semver",
]
[[package]]
name = "web-sys"
version = "0.3.85"
@@ -2818,6 +2677,15 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.60.2"
@@ -2970,6 +2838,9 @@ name = "winnow"
version = "0.7.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829"
dependencies = [
"memchr",
]
[[package]]
name = "wiremock"
@@ -2999,88 +2870,6 @@ name = "wit-bindgen"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
dependencies = [
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
dependencies = [
"anyhow",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
dependencies = [
"anyhow",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-xid",
"wasmparser",
]
[[package]]
name = "writeable"
@@ -3123,18 +2912,18 @@ dependencies = [
[[package]]
name = "zerocopy"
version = "0.8.37"
version = "0.8.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7456cf00f0685ad319c5b1693f291a650eaf345e941d082fc4e03df8a03996ac"
checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.37"
version = "0.8.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1328722bbf2115db7e19d69ebcc15e795719e2d66b60827c6a69a117365e37a0"
checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1"
dependencies = [
"proc-macro2",
"quote",
@@ -3231,15 +3020,15 @@ dependencies = [
[[package]]
name = "zlib-rs"
version = "0.6.2"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c745c48e1007337ed136dc99df34128b9faa6ed542d80a1c673cf55a6d7236c8"
checksum = "40990edd51aae2c2b6907af74ffb635029d5788228222c4bb811e9351c0caad3"
[[package]]
name = "zmij"
version = "1.0.17"
version = "1.0.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02aae0f83f69aafc94776e879363e9771d7ecbffe2c7fbb6c14c5e00dfe88439"
checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65"
[[package]]
name = "zopfli"

View File

@@ -7,7 +7,7 @@ members = [
]
[workspace.package]
version = "0.2.25"
version = "0.2.12"
edition = "2021"
license = "MIT"
authors = ["DevITWay <devitway@gmail.com>"]
@@ -24,5 +24,3 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
sha2 = "0.10"
async-trait = "0.1"
hmac = "0.12"
hex = "0.4"

View File

@@ -1,11 +1,58 @@
# syntax=docker/dockerfile:1.4
# Binary is pre-built by CI (cargo build --release) and passed via context
# Build stage
FROM rust:1.83-alpine AS builder
RUN apk add --no-cache musl-dev curl
WORKDIR /app
# Copy manifests
COPY Cargo.toml Cargo.lock ./
COPY nora-registry/Cargo.toml nora-registry/
COPY nora-storage/Cargo.toml nora-storage/
COPY nora-cli/Cargo.toml nora-cli/
# Create dummy sources for dependency caching
RUN mkdir -p nora-registry/src nora-storage/src nora-cli/src && \
echo "fn main() {}" > nora-registry/src/main.rs && \
echo "fn main() {}" > nora-storage/src/main.rs && \
echo "fn main() {}" > nora-cli/src/main.rs
# Build dependencies only (with cache)
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git \
--mount=type=cache,target=/app/target \
cargo build --release --package nora-registry && \
rm -rf nora-registry/src nora-storage/src nora-cli/src
# Copy real sources
COPY nora-registry/src nora-registry/src
COPY nora-storage/src nora-storage/src
COPY nora-cli/src nora-cli/src
# Build release binary (with cache)
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git \
--mount=type=cache,target=/app/target \
touch nora-registry/src/main.rs && \
cargo build --release --package nora-registry && \
cp /app/target/release/nora /usr/local/bin/nora
# Runtime stage
FROM alpine:3.20
RUN apk add --no-cache ca-certificates && mkdir -p /data
RUN apk add --no-cache ca-certificates
COPY nora /usr/local/bin/nora
WORKDIR /app
# Copy binary
COPY --from=builder /usr/local/bin/nora /usr/local/bin/nora
# Create data directory
RUN mkdir -p /data
# Default environment
ENV RUST_LOG=info
ENV NORA_HOST=0.0.0.0
ENV NORA_PORT=4000
@@ -17,5 +64,5 @@ EXPOSE 4000
VOLUME ["/data"]
ENTRYPOINT ["/usr/local/bin/nora"]
ENTRYPOINT ["nora"]
CMD ["serve"]

View File

@@ -1,28 +0,0 @@
# syntax=docker/dockerfile:1.4
# Binary is pre-built by CI (cargo build --release) and passed via context
# Runtime: scratch — compatible with Astra Linux SE (FSTEC certified)
# To switch to official base: replace FROM scratch with
# FROM registry.astralinux.ru/library/alse:latest
# RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/*
FROM alpine:3.20 AS certs
RUN apk add --no-cache ca-certificates
FROM scratch
COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY nora /usr/local/bin/nora
ENV RUST_LOG=info
ENV NORA_HOST=0.0.0.0
ENV NORA_PORT=4000
ENV NORA_STORAGE_MODE=local
ENV NORA_STORAGE_PATH=/data/storage
ENV NORA_AUTH_TOKEN_STORAGE=/data/tokens
EXPOSE 4000
VOLUME ["/data"]
ENTRYPOINT ["/usr/local/bin/nora"]
CMD ["serve"]

View File

@@ -1,28 +0,0 @@
# syntax=docker/dockerfile:1.4
# Binary is pre-built by CI (cargo build --release) and passed via context
# Runtime: scratch — compatible with RED OS (FSTEC certified)
# To switch to official base: replace FROM scratch with
# FROM registry.red-soft.ru/redos/redos:8
# RUN dnf install -y ca-certificates && dnf clean all
FROM alpine:3.20 AS certs
RUN apk add --no-cache ca-certificates
FROM scratch
COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY nora /usr/local/bin/nora
ENV RUST_LOG=info
ENV NORA_HOST=0.0.0.0
ENV NORA_PORT=4000
ENV NORA_STORAGE_MODE=local
ENV NORA_STORAGE_PATH=/data/storage
ENV NORA_AUTH_TOKEN_STORAGE=/data/tokens
EXPOSE 4000
VOLUME ["/data"]
ENTRYPOINT ["/usr/local/bin/nora"]
CMD ["serve"]

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2026 Volkov Pavel | DevITWay
Copyright (c) 2026 DevITWay
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@@ -1,10 +1,6 @@
<img src="logo.jpg" alt="NORA" height="120" />
# NORA
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
[![Release](https://img.shields.io/github/v/release/getnora-io/nora)](https://github.com/getnora-io/nora/releases)
[![CI](https://img.shields.io/github/actions/workflow/status/getnora-io/nora/ci.yml?label=CI)](https://github.com/getnora-io/nora/actions)
[![Rust](https://img.shields.io/badge/rust-%23000000.svg?logo=rust&logoColor=white)](https://www.rust-lang.org/)
[![Telegram](https://img.shields.io/badge/Telegram-DevITWay-blue?logo=telegram)](https://t.me/DevITWay)
> **Your Cloud-Native Artifact Registry**
@@ -44,7 +40,7 @@ Fast. Organized. Feel at Home.
### Docker (Recommended)
```bash
docker run -d -p 4000:4000 -v nora-data:/data ghcr.io/getnora-io/nora:latest
docker run -d -p 4000:4000 -v nora-data:/data getnora/nora
```
### From Source
@@ -195,4 +191,4 @@ Copyright (c) 2026 DevITWay
---
**🐿️ N○RA** - Organized like a chipmunk's stash | Built with Rust by [DevITWay](https://t.me/DevITWay)
**NORA** - Organized like a chipmunk's stash | Built with Rust by [DevITWay](https://t.me/DevITWay)

View File

@@ -1,53 +0,0 @@
# Security Policy
## Supported Versions
| Version | Supported |
| ------- | ------------------ |
| 0.2.x | :white_check_mark: |
| < 0.2 | :x: |
## Reporting a Vulnerability
**Please do not report security vulnerabilities through public GitHub issues.**
Instead, please report them via:
1. **Email:** devitway@gmail.com
2. **Telegram:** [@DevITWay](https://t.me/DevITWay) (private message)
### What to Include
- Type of vulnerability
- Steps to reproduce
- Potential impact
- Suggested fix (if any)
### Response Timeline
- **Initial response:** within 48 hours
- **Status update:** within 7 days
- **Fix timeline:** depends on severity
### Severity Levels
| Severity | Description | Response |
|----------|-------------|----------|
| Critical | Remote code execution, auth bypass | Immediate fix |
| High | Data exposure, privilege escalation | Fix within 7 days |
| Medium | Limited impact vulnerabilities | Fix in next release |
| Low | Minor issues | Scheduled fix |
## Security Best Practices
When deploying NORA:
1. **Enable authentication** - Set `NORA_AUTH_ENABLED=true`
2. **Use HTTPS** - Put NORA behind a reverse proxy with TLS
3. **Limit network access** - Use firewall rules
4. **Regular updates** - Keep NORA updated to latest version
5. **Secure credentials** - Use strong passwords, rotate tokens
## Acknowledgments
We appreciate responsible disclosure and will acknowledge security researchers who report valid vulnerabilities.

152
SESSION_NOTES.md Normal file
View File

@@ -0,0 +1,152 @@
# NORA Development Session Notes
---
## 2026-01-26 - Dashboard Expansion
### Iteration 1: Planning & Exploration
- Received detailed implementation plan for dashboard expansion
- Explored codebase structure using Task agent
- Identified key files to modify:
- `main.rs` - AppState
- `ui/api.rs`, `ui/mod.rs`, `ui/components.rs`, `ui/templates.rs`
- `registry/docker.rs`, `npm.rs`, `maven.rs`, `cargo_registry.rs`
### Iteration 2: Infrastructure (Phase 1)
- Created `src/dashboard_metrics.rs`:
- `DashboardMetrics` struct with AtomicU64 counters
- Per-registry tracking (docker, npm, maven, cargo, pypi)
- `record_download()`, `record_upload()`, `record_cache_hit/miss()`
- `cache_hit_rate()` calculation
- Created `src/activity_log.rs`:
- `ActionType` enum: Pull, Push, CacheHit, ProxyFetch
- `ActivityEntry` struct with timestamp, action, artifact, registry, source
- `ActivityLog` with RwLock<VecDeque> (bounded to 50 entries)
### Iteration 3: AppState Update (Phase 2)
- Updated `main.rs`:
- Added `mod activity_log` and `mod dashboard_metrics`
- Extended `AppState` with `metrics: DashboardMetrics` and `activity: ActivityLog`
- Initialized in `run_server()`
### Iteration 4: API Endpoint (Phase 3)
- Updated `ui/api.rs`:
- Added structs: `DashboardResponse`, `GlobalStats`, `RegistryCardStats`, `MountPoint`
- Implemented `api_dashboard()` - aggregates all metrics, storage stats, activity
- Updated `ui/mod.rs`:
- Added route `/api/ui/dashboard`
- Modified `dashboard()` handler to use new response
### Iteration 5: Dark Theme UI (Phase 4)
- Updated `ui/components.rs` with ~400 new lines:
- `layout_dark()` - dark theme wrapper (#0f172a background)
- `sidebar_dark()`, `header_dark()` - dark theme navigation
- `render_global_stats()` - 5-column stats grid
- `render_registry_card()` - extended card with metrics
- `render_mount_points_table()` - registry paths and proxies
- `render_activity_row()`, `render_activity_log()` - activity display
- `render_polling_script()` - 5-second auto-refresh JS
### Iteration 6: Dashboard Template (Phase 5)
- Updated `ui/templates.rs`:
- Refactored `render_dashboard()` to accept `DashboardResponse`
- Added uptime display, global stats, registry cards grid
- Added mount points table and activity log
- Added `format_relative_time()` helper
### Iteration 7: Registry Instrumentation (Phase 6)
- `registry/docker.rs`:
- `download_blob()` - record download + cache hit + activity
- `get_manifest()` - record download + cache hit + activity
- `upload_blob()` - record upload + activity
- `put_manifest()` - record upload + activity
- `registry/npm.rs`:
- Cache hit tracking for local storage
- Cache miss + proxy fetch tracking
- `registry/maven.rs`:
- `download()` - cache hit/miss + activity
- `upload()` - record upload + activity
- `registry/cargo_registry.rs`:
- `download()` - record download + activity
### Iteration 8: Build & Test
- `cargo build` - compiled successfully with minor warnings
- Fixed warnings:
- Removed unused `RegistryStats` import
- Added `#[allow(dead_code)]` to `stat_card()`
- `cargo test` - all 75 tests passed
### Iteration 9: Server Testing
- Started server: `cargo run --release --bin nora`
- Tested endpoints:
```
GET /health - OK
GET /api/ui/dashboard - returns full metrics JSON
GET /ui/ - dark theme dashboard HTML
GET /v2/test/manifests/v1 - triggered Docker metrics
GET /npm/lodash/-/lodash-4.17.21.tgz - triggered npm proxy metrics
```
- Verified metrics tracking:
- Downloads: 3 (2 Docker + 1 npm)
- Cache hit rate: 66.67%
- Activity log populated with Pull, ProxyFetch events
### Iteration 10: Git Commit & Push
- Staged 11 files (2 new, 9 modified)
- Commit: `93f9655 Add dashboard metrics, activity log, and dark theme`
- Pushed to `origin/main`
### Iteration 11: Documentation
- Updated `TODO.md` with v0.2.1 section
- Created this `SESSION_NOTES.md`
---
### Key Decisions Made
1. **In-memory metrics** - AtomicU64 for thread-safety, reset on restart
2. **Bounded activity log** - 50 entries max, oldest evicted
3. **Polling over WebSocket** - simpler, 5-second interval sufficient
4. **Dark theme only for dashboard** - registry list pages keep light theme
### Files Changed Summary
```
New:
nora-registry/src/activity_log.rs
nora-registry/src/dashboard_metrics.rs
Modified:
nora-registry/src/main.rs (+8 lines)
nora-registry/src/registry/cargo_registry.rs (+13 lines)
nora-registry/src/registry/docker.rs (+47 lines)
nora-registry/src/registry/maven.rs (+36 lines)
nora-registry/src/registry/npm.rs (+29 lines)
nora-registry/src/ui/api.rs (+154 lines)
nora-registry/src/ui/components.rs (+394 lines)
nora-registry/src/ui/mod.rs (+5 lines)
nora-registry/src/ui/templates.rs (+180/-79 lines)
Total: ~1004 insertions, 79 deletions
```
### Useful Commands
```bash
# Start server
cargo run --release --bin nora
# Test dashboard
curl http://127.0.0.1:4000/api/ui/dashboard
# View UI
open http://127.0.0.1:4000/ui/
# Trigger metrics
curl http://127.0.0.1:4000/v2/test/manifests/v1
curl http://127.0.0.1:4000/npm/lodash/-/lodash-4.17.21.tgz -o /dev/null
```
---

503
TODO.md Normal file
View File

@@ -0,0 +1,503 @@
# NORA Roadmap / TODO
## v0.2.0 - DONE
- [x] Unit tests (75 tests passing)
- [x] Input validation (path traversal protection)
- [x] Rate limiting (brute-force protection)
- [x] Request ID tracking
- [x] Migrate command (local <-> S3)
- [x] Error handling (thiserror)
- [x] SVG brand icons
---
## v0.2.1 - Dashboard Expansion (2026-01-26) - DONE
### Commit: 93f9655
### New Files
- `nora-registry/src/dashboard_metrics.rs` - AtomicU64 counters for metrics
- `nora-registry/src/activity_log.rs` - Bounded activity log (50 entries)
### Modified Files
- `nora-registry/src/main.rs` - Added modules, updated AppState
- `nora-registry/src/ui/api.rs` - Added DashboardResponse, api_dashboard()
- `nora-registry/src/ui/mod.rs` - Added /api/ui/dashboard route
- `nora-registry/src/ui/components.rs` - Dark theme components
- `nora-registry/src/ui/templates.rs` - New render_dashboard()
- `nora-registry/src/registry/docker.rs` - Instrumented handlers
- `nora-registry/src/registry/npm.rs` - Instrumented with cache tracking
- `nora-registry/src/registry/maven.rs` - Instrumented download/upload
- `nora-registry/src/registry/cargo_registry.rs` - Instrumented download
### Features Implemented
- [x] Global stats panel (downloads, uploads, artifacts, cache hit %, storage)
- [x] Per-registry metrics (Docker, Maven, npm, Cargo, PyPI)
- [x] Mount points table with proxy upstreams
- [x] Activity log (last 20 events)
- [x] Dark theme (#0f172a background, #1e293b cards)
- [x] Auto-refresh polling (5 seconds)
- [x] Cache hit/miss tracking
### API Endpoints
- `GET /api/ui/dashboard` - Full dashboard data as JSON
### Dark Theme Colors
```
Background: #0f172a (slate-950)
Cards: #1e293b (slate-800)
Borders: slate-700
Text primary: slate-200
Text secondary: slate-400
Accent: blue-400
```
### Testing Commands
```bash
# Test dashboard API
curl http://127.0.0.1:4000/api/ui/dashboard
# Test Docker pull (triggers metrics)
curl http://127.0.0.1:4000/v2/test/manifests/v1
# Test npm proxy (triggers cache miss)
curl http://127.0.0.1:4000/npm/lodash/-/lodash-4.17.21.tgz -o /dev/null
```
### Future Improvements (Dashboard)
- [ ] Add PyPI download instrumentation
- [ ] Persist metrics to disk (currently reset on restart)
- [ ] Add WebSocket for real-time updates (instead of polling)
- [ ] Add graphs/charts for metrics over time
- [ ] Add user/client tracking in activity log
- [ ] Dark/light theme toggle
---
## v0.3.0 - OIDC / Workload Identity Federation
### Killer Feature: OIDC for CI/CD
Zero-secret authentication for GitHub Actions, GitLab CI, etc.
**Goal:** Replace manual `ROBOT_TOKEN` rotation with federated identity.
```yaml
# GitHub Actions example
permissions:
id-token: write
steps:
- name: Login to NORA
uses: nora/login-action@v1
```
### Config Structure (draft)
```toml
[auth.oidc]
enabled = true
# GitHub Actions
[[auth.oidc.providers]]
name = "github-actions"
issuer = "https://token.actions.githubusercontent.com"
audience = "https://nora.example.com"
[[auth.oidc.providers.rules]]
# Claim matching (supports glob)
match = { repository = "my-org/*", ref = "refs/heads/main" }
# Granted permissions
permissions = ["push:my-org/*", "pull:*"]
[[auth.oidc.providers.rules]]
match = { repository = "my-org/*", ref = "refs/heads/*" }
permissions = ["pull:*"]
# GitLab CI
[[auth.oidc.providers]]
name = "gitlab-ci"
issuer = "https://gitlab.com"
audience = "https://nora.example.com"
[[auth.oidc.providers.rules]]
match = { project_path = "my-group/*" }
permissions = ["push:my-group/*", "pull:*"]
```
### Implementation Tasks
- [ ] JWT validation library (jsonwebtoken crate)
- [ ] OIDC discovery (/.well-known/openid-configuration)
- [ ] JWKS fetching and caching
- [ ] Claims extraction and glob matching
- [ ] Permission resolution from rules
- [ ] Token exchange endpoint (POST /auth/oidc/token)
- [ ] GitHub Action: `nora/login-action`
---
## v0.4.0 - Transparent Docker Hub Proxy
### Pain Point
Harbor forces tag changes: `docker pull my-harbor/proxy-cache/library/nginx`
This breaks Helm charts hardcoded to `nginx`.
### Goal
Transparent pull-through cache:
```bash
docker pull nora.example.com/nginx # -> proxies to Docker Hub
```
### Implementation Tasks
- [ ] Registry v2 API interception
- [ ] Upstream registry configuration
- [ ] Cache layer management
- [ ] Rate limit handling (Docker Hub limits)
---
## v0.5.0 - Repo-level RBAC
### Challenge
Per-repository permissions need fast lookup (100 layers per push).
### Solution
Glob patterns for 90% of cases:
```toml
[[auth.rules]]
subject = "team-frontend"
permissions = ["push:frontend/*", "pull:*"]
[[auth.rules]]
subject = "ci-bot"
permissions = ["push:*/release-*", "pull:*"]
```
### Implementation Tasks
- [ ] In-memory permission cache
- [ ] Glob pattern matcher (globset crate)
- [ ] Permission inheritance (org -> project -> repo)
---
## Target Audience
1. DevOps engineers tired of Java/Go monsters
2. Edge/IoT installations (Raspberry Pi, branch offices)
3. Educational platforms (student labs)
4. CI/CD pipelines (GitHub Actions, GitLab CI)
## Competitive Advantages
| Feature | NORA | Harbor | Nexus |
|---------|------|--------|-------|
| Memory | <100MB | 2GB+ | 4GB+ |
| OIDC for CI | v0.3.0 | No | No |
| Transparent proxy | v0.4.0 | No (tag rewrite) | Partial |
| Single binary | Yes | No (microservices) | No (Java) |
| Zero-config upgrade | Yes | Complex | Complex |
---
## v0.6.0 - Online Garbage Collection
### Pain Point
Harbor GC blocks registry for hours. Can't push during cleanup.
### Goal
Non-blocking garbage collection with zero downtime.
### Implementation Tasks
- [ ] Mark-and-sweep without locking
- [ ] Background blob cleanup
- [ ] Progress reporting via API/CLI
- [ ] `nora gc --dry-run` preview
---
## v0.7.0 - Retention Policies
### Pain Point
"Keep last 10 tags" sounds simple, works poorly everywhere.
### Goal
Declarative retention rules in config:
```toml
[[retention]]
match = "*/dev-*"
keep_last = 5
[[retention]]
match = "*/release-*"
keep_last = 20
older_than = "90d"
[[retention]]
match = "**/pr-*"
older_than = "7d"
```
### Implementation Tasks
- [ ] Glob pattern matching for repos/tags
- [ ] Age-based and count-based rules
- [ ] Dry-run mode
- [ ] Scheduled execution (cron-style)
---
## v0.8.0 - Multi-tenancy & Quotas
### Pain Point
Harbor projects have quotas but configuration is painful. Nexus has no real isolation.
### Goal
Simple namespaces with limits:
```toml
[[tenants]]
name = "team-frontend"
storage_quota = "50GB"
rate_limit = { push = 100, pull = 1000 } # per hour
[[tenants]]
name = "team-backend"
storage_quota = "100GB"
```
### Implementation Tasks
- [ ] Tenant isolation (namespace prefix)
- [ ] Storage quota tracking
- [ ] Per-tenant rate limiting
- [ ] Usage reporting API
---
## v0.9.0 - Smart Replication
### Pain Point
Harbor replication rules are complex, errors silently swallowed.
### Goal
Simple CLI-driven replication with clear feedback:
```bash
nora replicate --to remote-dc --filter "prod/*" --dry-run
nora replicate --from gcr.io/my-project/* --to local/imported/
```
### Implementation Tasks
- [ ] Push-based replication to remote NORA
- [ ] Pull-based import from external registries (Docker Hub, GCR, ECR, Quay)
- [ ] Filter by glob patterns
- [ ] Progress bar and detailed logs
- [ ] Retry logic with exponential backoff
---
## v1.0.0 - Production Ready
### Features to polish
- [ ] Full CLI (`nora images ls`, `nora tag`, `nora delete`)
- [ ] Webhooks with filters and retry logic
- [ ] Enhanced Prometheus metrics (per-repo stats, cache hit ratio, bandwidth per tenant)
- [ ] TUI dashboard (optional)
- [ ] Helm chart for Kubernetes deployment
- [ ] Official Docker image on ghcr.io
---
## Future Ideas (v1.x+)
### Cold Storage Tiering
Auto-move old tags to S3 Glacier:
```toml
[[storage.tiering]]
match = "*"
older_than = "180d"
move_to = "s3-glacier"
```
### Vulnerability Scanning Integration
Not built-in (use Trivy), but:
- [ ] Webhook on push -> trigger external scan
- [ ] Store scan results as OCI artifacts
- [ ] Block pull if critical CVEs (policy)
### Image Signing (Cosign/Notation)
- [ ] Signature storage (OCI artifacts)
- [ ] Policy enforcement (reject unsigned)
### P2P Distribution (Dragonfly/Kraken style)
For large clusters pulling same image simultaneously.
---
---
## Architecture / DDD
### Current State (v0.2.0)
Monolithic structure, all in `nora-registry/src/`:
```
src/
├── main.rs # CLI + server setup
├── auth.rs # htpasswd + basic auth
├── tokens.rs # API tokens
├── storage/ # Storage backends (local, s3)
├── registry/ # Protocol handlers (docker, maven, npm, cargo, pypi)
├── ui/ # Web dashboard
└── ...
```
### Target Architecture (v1.0+)
#### Domain-Driven Design Boundaries
```
nora/
├── nora-core/ # Domain layer (no dependencies)
│ ├── src/
│ │ ├── artifact.rs # Artifact, Digest, Tag, Manifest
│ │ ├── repository.rs # Repository, Namespace
│ │ ├── identity.rs # User, ServiceAccount, Token
│ │ ├── policy.rs # Permission, Rule, Quota
│ │ └── events.rs # DomainEvent (ArtifactPushed, etc.)
├── nora-auth/ # Authentication bounded context
│ ├── src/
│ │ ├── htpasswd.rs # Basic auth provider
│ │ ├── oidc.rs # OIDC/JWT provider
│ │ ├── token.rs # API token provider
│ │ └── rbac.rs # Permission resolver
├── nora-storage/ # Storage bounded context
│ ├── src/
│ │ ├── backend.rs # StorageBackend trait
│ │ ├── local.rs # Filesystem
│ │ ├── s3.rs # S3-compatible
│ │ ├── tiered.rs # Hot/cold tiering
│ │ └── gc.rs # Garbage collection
├── nora-registry/ # Application layer (HTTP API)
│ ├── src/
│ │ ├── api/
│ │ │ ├── oci.rs # OCI Distribution API (/v2/)
│ │ │ ├── maven.rs # Maven repository
│ │ │ ├── npm.rs # npm registry
│ │ │ ├── cargo.rs # Cargo registry
│ │ │ └── pypi.rs # PyPI (simple API)
│ │ ├── proxy/ # Upstream proxy/cache
│ │ ├── webhook/ # Event webhooks
│ │ └── ui/ # Web dashboard
├── nora-cli/ # CLI application
│ ├── src/
│ │ ├── commands/
│ │ │ ├── serve.rs
│ │ │ ├── images.rs # nora images ls/delete/tag
│ │ │ ├── gc.rs # nora gc
│ │ │ ├── backup.rs # nora backup/restore
│ │ │ ├── migrate.rs # nora migrate
│ │ │ └── replicate.rs
│ │ └── tui/ # Optional TUI dashboard
└── nora-sdk/ # Client SDK (for nora/login-action)
└── src/
├── client.rs # HTTP client
└── oidc.rs # Token exchange
```
#### Key Principles
1. **Hexagonal Architecture**
- Core domain has no external dependencies
- Ports (traits) define boundaries
- Adapters implement ports (S3, filesystem, OIDC providers)
2. **Event-Driven**
- Domain events: `ArtifactPushed`, `ArtifactDeleted`, `TagCreated`
- Webhooks subscribe to events
- Async processing for GC, replication
3. **CQRS-lite**
- Commands: Push, Delete, CreateToken
- Queries: List, Get, Search
- Separate read/write paths for hot endpoints
4. **Configuration as Code**
- All policies in `nora.toml`
- No database for config (file-based)
- GitOps friendly
#### Trait Boundaries (Ports)
```rust
// nora-core/src/ports.rs
#[async_trait]
pub trait ArtifactStore {
async fn push_blob(&self, digest: &Digest, data: Bytes) -> Result<()>;
async fn get_blob(&self, digest: &Digest) -> Result<Bytes>;
async fn push_manifest(&self, repo: &Repository, tag: &Tag, manifest: &Manifest) -> Result<()>;
async fn get_manifest(&self, repo: &Repository, reference: &Reference) -> Result<Manifest>;
async fn list_tags(&self, repo: &Repository) -> Result<Vec<Tag>>;
async fn delete(&self, repo: &Repository, reference: &Reference) -> Result<()>;
}
#[async_trait]
pub trait IdentityProvider {
async fn authenticate(&self, credentials: &Credentials) -> Result<Identity>;
async fn authorize(&self, identity: &Identity, action: &Action, resource: &Resource) -> Result<bool>;
}
#[async_trait]
pub trait EventPublisher {
async fn publish(&self, event: DomainEvent) -> Result<()>;
}
```
#### Migration Path
| Phase | Action |
|-------|--------|
| v0.3 | Extract `nora-auth` crate (OIDC work) |
| v0.4 | Extract `nora-core` domain types |
| v0.5 | Extract `nora-storage` with trait boundaries |
| v0.6+ | Refactor registry handlers to use ports |
| v1.0 | Full hexagonal architecture |
### Technical Debt to Address
- [ ] Remove `unwrap()` in non-test code (started in e9984cf)
- [ ] Add tracing spans to all handlers
- [ ] Consistent error types across modules
- [ ] Extract hardcoded limits to config
- [ ] Add OpenTelemetry support (traces, not just metrics)
### Performance Requirements
| Metric | Target |
|--------|--------|
| Memory (idle) | <50MB |
| Memory (under load) | <100MB |
| Startup time | <1s |
| Blob throughput | Wire speed (no processing overhead) |
| Manifest latency | <10ms p99 |
| Auth check | <1ms (cached) |
### Security Requirements
- [ ] No secrets in logs (already redacting)
- [ ] TLS termination (or trust reverse proxy)
- [ ] Content-addressable storage (immutable blobs)
- [ ] Audit log for all mutations
- [ ] SBOM generation for NORA itself
---
## Notes
- S3 storage: already implemented
- Web UI: minimalist read-only dashboard (done)
- TUI: consider for v1.0
- Vulnerability scanning: out of scope (use Trivy externally)
- Image signing: out of scope for now (use cosign externally)

View File

@@ -1,40 +0,0 @@
# cargo-deny configuration
# https://embarkstudios.github.io/cargo-deny/
[advisories]
# Vulnerability database (RustSec)
db-urls = ["https://github.com/rustsec/advisory-db"]
ignore = [
"RUSTSEC-2025-0119", # number_prefix unmaintained, transitive via indicatif; no fix available
]
[licenses]
# Allowed open-source licenses
allow = [
"MIT",
"Apache-2.0",
"Apache-2.0 WITH LLVM-exception",
"BSD-2-Clause",
"BSD-3-Clause",
"ISC",
"Unicode-DFS-2016",
"Unicode-3.0",
"CC0-1.0",
"OpenSSL",
"Zlib",
"CDLA-Permissive-2.0", # webpki-roots (CA certificates bundle)
"MPL-2.0",
]
[bans]
multiple-versions = "warn"
deny = [
{ name = "openssl-sys" },
{ name = "openssl" },
]
skip = []
[sources]
unknown-registry = "warn"
unknown-git = "warn"
allow-registry = ["https://github.com/rust-lang/crates.io-index"]

View File

@@ -1,98 +0,0 @@
#!/usr/bin/env sh
# NORA installer — https://getnora.io/install.sh
# Usage: curl -fsSL https://getnora.io/install.sh | sh
set -e
REPO="getnora-io/nora"
BINARY="nora"
INSTALL_DIR="/usr/local/bin"
# ── Detect OS and architecture ──────────────────────────────────────────────
OS="$(uname -s)"
ARCH="$(uname -m)"
case "$OS" in
Linux) os="linux" ;;
Darwin) os="darwin" ;;
*)
echo "Unsupported OS: $OS"
echo "Please download manually: https://github.com/$REPO/releases/latest"
exit 1
;;
esac
case "$ARCH" in
x86_64 | amd64) arch="amd64" ;;
aarch64 | arm64) arch="arm64" ;;
*)
echo "Unsupported architecture: $ARCH"
echo "Please download manually: https://github.com/$REPO/releases/latest"
exit 1
;;
esac
ASSET="${BINARY}-${os}-${arch}"
# ── Get latest release version ──────────────────────────────────────────────
VERSION="$(curl -fsSL "https://api.github.com/repos/$REPO/releases/latest" \
| grep '"tag_name"' \
| sed 's/.*"tag_name": *"\([^"]*\)".*/\1/')"
if [ -z "$VERSION" ]; then
echo "Failed to get latest version"
exit 1
fi
echo "Installing NORA $VERSION ($os/$arch)..."
# ── Download binary and checksum ────────────────────────────────────────────
BASE_URL="https://github.com/$REPO/releases/download/$VERSION"
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
echo "Downloading $ASSET..."
curl -fsSL "$BASE_URL/$ASSET" -o "$TMP_DIR/$BINARY"
curl -fsSL "$BASE_URL/$ASSET.sha256" -o "$TMP_DIR/$ASSET.sha256"
# ── Verify checksum ─────────────────────────────────────────────────────────
echo "Verifying checksum..."
EXPECTED="$(awk '{print $1}' "$TMP_DIR/$ASSET.sha256")"
ACTUAL="$(sha256sum "$TMP_DIR/$BINARY" | awk '{print $1}')"
if [ "$EXPECTED" != "$ACTUAL" ]; then
echo "Checksum mismatch!"
echo " Expected: $EXPECTED"
echo " Actual: $ACTUAL"
exit 1
fi
echo "Checksum OK"
# ── Install ─────────────────────────────────────────────────────────────────
chmod +x "$TMP_DIR/$BINARY"
if [ -w "$INSTALL_DIR" ]; then
mv "$TMP_DIR/$BINARY" "$INSTALL_DIR/$BINARY"
elif command -v sudo >/dev/null 2>&1; then
sudo mv "$TMP_DIR/$BINARY" "$INSTALL_DIR/$BINARY"
else
# Fallback to ~/.local/bin
INSTALL_DIR="$HOME/.local/bin"
mkdir -p "$INSTALL_DIR"
mv "$TMP_DIR/$BINARY" "$INSTALL_DIR/$BINARY"
echo "Installed to $INSTALL_DIR/$BINARY"
echo "Make sure $INSTALL_DIR is in your PATH"
fi
# ── Done ────────────────────────────────────────────────────────────────────
echo ""
echo "NORA $VERSION installed to $INSTALL_DIR/$BINARY"
echo ""
nora --version 2>/dev/null || true

BIN
logo.jpg

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

View File

@@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 72" width="300" height="72">
<text font-family="'SF Mono', 'Fira Code', 'Cascadia Code', monospace" font-weight="800" fill="#0f172a" letter-spacing="1">
<tspan x="8" y="58" font-size="52">N</tspan><tspan font-size="68" dy="-10" fill="#2563EB">O</tspan><tspan font-size="52" dy="10">RA</tspan>
</text>
</svg>

Before

Width:  |  Height:  |  Size: 373 B

View File

@@ -18,6 +18,6 @@ reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
clap = { version = "4", features = ["derive"] }
indicatif = "0.18"
indicatif = "0.17"
tar = "0.4"
flate2 = "1.1"
flate2 = "1.0"

View File

@@ -1,6 +1,3 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
use clap::{Parser, Subcommand};
#[derive(Parser)]

View File

@@ -24,21 +24,19 @@ tracing-subscriber.workspace = true
reqwest.workspace = true
sha2.workspace = true
async-trait.workspace = true
hmac.workspace = true
hex.workspace = true
toml = "1.0"
toml = "0.8"
uuid = { version = "1", features = ["v4"] }
bcrypt = "0.18"
bcrypt = "0.17"
base64 = "0.22"
prometheus = "0.14"
prometheus = "0.13"
lazy_static = "1.5"
httpdate = "1"
utoipa = { version = "5", features = ["axum_extras"] }
utoipa-swagger-ui = { version = "9", features = ["axum", "reqwest"] }
clap = { version = "4", features = ["derive"] }
tar = "0.4"
flate2 = "1.1"
indicatif = "0.18"
flate2 = "1.0"
indicatif = "0.17"
chrono = { version = "0.4", features = ["serde"] }
thiserror = "2"
tower_governor = "0.8"

View File

@@ -1,6 +1,3 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
use chrono::{DateTime, Utc};
use parking_lot::RwLock;
use serde::Serialize;

View File

@@ -1,6 +1,3 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
use axum::{
body::Body,
extract::State,
@@ -63,17 +60,11 @@ impl HtpasswdAuth {
fn is_public_path(path: &str) -> bool {
matches!(
path,
"/" | "/health"
| "/ready"
| "/metrics"
| "/v2/"
| "/v2"
| "/api/tokens"
| "/api/tokens/list"
| "/api/tokens/revoke"
"/" | "/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
@@ -410,12 +401,8 @@ mod tests {
assert!(is_public_path("/api/ui/stats"));
assert!(is_public_path("/api/tokens"));
assert!(is_public_path("/api/tokens/list"));
assert!(is_public_path("/api/tokens/revoke"));
// Protected paths
assert!(!is_public_path("/api/tokens/unknown"));
assert!(!is_public_path("/api/tokens/admin"));
assert!(!is_public_path("/api/tokens/extra/path"));
assert!(!is_public_path("/v2/myimage/blobs/sha256:abc"));
assert!(!is_public_path("/v2/library/nginx/manifests/latest"));
assert!(!is_public_path(

View File

@@ -1,6 +1,3 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
//! Backup and restore functionality for Nora
//!
//! Exports all artifacts to a tar.gz file and restores from backups.

View File

@@ -1,6 +1,3 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
use serde::{Deserialize, Serialize};
use std::env;
use std::fs;
@@ -56,19 +53,6 @@ pub struct StorageConfig {
pub s3_url: String,
#[serde(default = "default_bucket")]
pub bucket: String,
/// S3 access key (optional, uses anonymous access if not set)
#[serde(default)]
pub s3_access_key: Option<String>,
/// S3 secret key (optional, uses anonymous access if not set)
#[serde(default)]
pub s3_secret_key: Option<String>,
/// S3 region (default: us-east-1)
#[serde(default = "default_s3_region")]
pub s3_region: String,
}
fn default_s3_region() -> String {
"us-east-1".to_string()
}
fn default_storage_path() -> String {
@@ -249,8 +233,6 @@ impl Default for AuthConfig {
/// - `NORA_RATE_LIMIT_GENERAL_BURST` - General burst size
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RateLimitConfig {
#[serde(default = "default_rate_limit_enabled")]
pub enabled: bool,
#[serde(default = "default_auth_rps")]
pub auth_rps: u64,
#[serde(default = "default_auth_burst")]
@@ -265,9 +247,6 @@ pub struct RateLimitConfig {
pub general_burst: u32,
}
fn default_rate_limit_enabled() -> bool {
true
}
fn default_auth_rps() -> u64 {
1
}
@@ -290,7 +269,6 @@ fn default_general_burst() -> u32 {
impl Default for RateLimitConfig {
fn default() -> Self {
Self {
enabled: default_rate_limit_enabled(),
auth_rps: default_auth_rps(),
auth_burst: default_auth_burst(),
upload_rps: default_upload_rps(),
@@ -347,15 +325,6 @@ impl Config {
if let Ok(val) = env::var("NORA_STORAGE_BUCKET") {
self.storage.bucket = val;
}
if let Ok(val) = env::var("NORA_STORAGE_S3_ACCESS_KEY") {
self.storage.s3_access_key = if val.is_empty() { None } else { Some(val) };
}
if let Ok(val) = env::var("NORA_STORAGE_S3_SECRET_KEY") {
self.storage.s3_secret_key = if val.is_empty() { None } else { Some(val) };
}
if let Ok(val) = env::var("NORA_STORAGE_S3_REGION") {
self.storage.s3_region = val;
}
// Auth config
if let Ok(val) = env::var("NORA_AUTH_ENABLED") {
@@ -432,9 +401,6 @@ impl Config {
}
// Rate limit config
if let Ok(val) = env::var("NORA_RATE_LIMIT_ENABLED") {
self.rate_limit.enabled = val.to_lowercase() == "true" || val == "1";
}
if let Ok(val) = env::var("NORA_RATE_LIMIT_AUTH_RPS") {
if let Ok(v) = val.parse::<u64>() {
self.rate_limit.auth_rps = v;
@@ -489,9 +455,6 @@ impl Default for Config {
path: String::from("data/storage"),
s3_url: String::from("http://127.0.0.1:3000"),
bucket: String::from("registry"),
s3_access_key: None,
s3_secret_key: None,
s3_region: String::from("us-east-1"),
},
maven: MavenConfig::default(),
npm: NpmConfig::default(),

View File

@@ -1,6 +1,3 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Instant;

View File

@@ -1,6 +1,3 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
#![allow(dead_code)]
//! Application error handling with HTTP response conversion
//!

View File

@@ -1,6 +1,3 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
use axum::{extract::State, http::StatusCode, response::Json, routing::get, Router};
use serde::Serialize;
use std::sync::Arc;

View File

@@ -1,6 +1,3 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
mod activity_log;
mod auth;
mod backup;
@@ -13,7 +10,6 @@ mod migrate;
mod openapi;
mod rate_limit;
mod registry;
mod repo_index;
mod request_id;
mod secrets;
mod storage;
@@ -34,7 +30,6 @@ use activity_log::ActivityLog;
use auth::HtpasswdAuth;
use config::{Config, StorageMode};
use dashboard_metrics::DashboardMetrics;
use repo_index::RepoIndex;
pub use storage::Storage;
use tokens::TokenStore;
@@ -84,8 +79,6 @@ pub struct AppState {
pub metrics: DashboardMetrics,
pub activity: ActivityLog,
pub docker_auth: registry::DockerAuth,
pub repo_index: RepoIndex,
pub http_client: reqwest::Client,
}
#[tokio::main]
@@ -111,18 +104,10 @@ async fn main() {
info!(
s3_url = %config.storage.s3_url,
bucket = %config.storage.bucket,
region = %config.storage.s3_region,
has_credentials = config.storage.s3_access_key.is_some(),
"Using S3 storage"
);
}
Storage::new_s3(
&config.storage.s3_url,
&config.storage.bucket,
&config.storage.s3_region,
config.storage.s3_access_key.as_deref(),
config.storage.s3_secret_key.as_deref(),
)
Storage::new_s3(&config.storage.s3_url, &config.storage.bucket)
}
};
@@ -146,13 +131,7 @@ async fn main() {
Some(Commands::Migrate { from, to, dry_run }) => {
let source = match from.as_str() {
"local" => Storage::new_local(&config.storage.path),
"s3" => Storage::new_s3(
&config.storage.s3_url,
&config.storage.bucket,
&config.storage.s3_region,
config.storage.s3_access_key.as_deref(),
config.storage.s3_secret_key.as_deref(),
),
"s3" => Storage::new_s3(&config.storage.s3_url, &config.storage.bucket),
_ => {
error!("Invalid source: '{}'. Use 'local' or 's3'", from);
std::process::exit(1);
@@ -161,13 +140,7 @@ async fn main() {
let dest = match to.as_str() {
"local" => Storage::new_local(&config.storage.path),
"s3" => Storage::new_s3(
&config.storage.s3_url,
&config.storage.bucket,
&config.storage.s3_region,
config.storage.s3_access_key.as_deref(),
config.storage.s3_secret_key.as_deref(),
),
"s3" => Storage::new_s3(&config.storage.s3_url, &config.storage.bucket),
_ => {
error!("Invalid destination: '{}'. Use 'local' or 's3'", to);
std::process::exit(1);
@@ -210,7 +183,6 @@ async fn run_server(config: Config, storage: Storage) {
// Log rate limiting configuration
info!(
enabled = config.rate_limit.enabled,
auth_rps = config.rate_limit.auth_rps,
auth_burst = config.rate_limit.auth_burst,
upload_rps = config.rate_limit.upload_rps,
@@ -265,49 +237,14 @@ async fn run_server(config: Config, storage: Storage) {
None
};
let rate_limit_enabled = config.rate_limit.enabled;
// Create rate limiters before moving config to state
let auth_limiter = rate_limit::auth_rate_limiter(&config.rate_limit);
let upload_limiter = rate_limit::upload_rate_limiter(&config.rate_limit);
let general_limiter = rate_limit::general_rate_limiter(&config.rate_limit);
// Initialize Docker auth with proxy timeout
let docker_auth = registry::DockerAuth::new(config.docker.proxy_timeout);
let http_client = reqwest::Client::new();
// Registry routes (shared between rate-limited and non-limited paths)
let registry_routes = Router::new()
.merge(registry::docker_routes())
.merge(registry::maven_routes())
.merge(registry::npm_routes())
.merge(registry::cargo_routes())
.merge(registry::pypi_routes())
.merge(registry::raw_routes());
// Routes WITHOUT rate limiting (health, metrics, UI)
let public_routes = Router::new()
.merge(health::routes())
.merge(metrics::routes())
.merge(ui::routes())
.merge(openapi::routes());
let app_routes = if rate_limit_enabled {
// Create rate limiters before moving config to state
let auth_limiter = rate_limit::auth_rate_limiter(&config.rate_limit);
let upload_limiter = rate_limit::upload_rate_limiter(&config.rate_limit);
let general_limiter = rate_limit::general_rate_limiter(&config.rate_limit);
let auth_routes = auth::token_routes().layer(auth_limiter);
let limited_registry = registry_routes.layer(upload_limiter);
Router::new()
.merge(auth_routes)
.merge(limited_registry)
.layer(general_limiter)
} else {
info!("Rate limiting DISABLED");
Router::new()
.merge(auth::token_routes())
.merge(registry_routes)
};
let state = Arc::new(AppState {
storage,
config,
@@ -317,13 +254,37 @@ async fn run_server(config: Config, storage: Storage) {
metrics: DashboardMetrics::new(),
activity: ActivityLog::new(50),
docker_auth,
repo_index: RepoIndex::new(),
http_client,
});
// Token routes with strict rate limiting (brute-force protection)
let auth_routes = auth::token_routes().layer(auth_limiter);
// Registry routes with upload rate limiting
let registry_routes = Router::new()
.merge(registry::docker_routes())
.merge(registry::maven_routes())
.merge(registry::npm_routes())
.merge(registry::cargo_routes())
.merge(registry::pypi_routes())
.merge(registry::raw_routes())
.layer(upload_limiter);
// Routes WITHOUT rate limiting (health, metrics, UI)
let public_routes = Router::new()
.merge(health::routes())
.merge(metrics::routes())
.merge(ui::routes())
.merge(openapi::routes());
// Routes WITH rate limiting
let rate_limited_routes = Router::new()
.merge(auth_routes)
.merge(registry_routes)
.layer(general_limiter);
let app = Router::new()
.merge(public_routes)
.merge(app_routes)
.merge(rate_limited_routes)
.layer(DefaultBodyLimit::max(100 * 1024 * 1024)) // 100MB default body limit
.layer(middleware::from_fn(request_id::request_id_middleware))
.layer(middleware::from_fn(metrics::metrics_middleware))

View File

@@ -1,6 +1,3 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
use axum::{
body::Body,
extract::MatchedPath,

View File

@@ -1,6 +1,3 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
//! Migration between storage backends
//!
//! Supports migrating artifacts from one storage backend to another

View File

@@ -1,6 +1,3 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
//! OpenAPI documentation and Swagger UI
//!
//! Functions in this module are stubs used only for generating OpenAPI documentation.
@@ -18,7 +15,7 @@ use crate::AppState;
#[openapi(
info(
title = "Nora",
version = "0.2.12",
version = "0.2.10",
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")
@@ -28,7 +25,6 @@ use crate::AppState;
),
tags(
(name = "health", description = "Health check endpoints"),
(name = "metrics", description = "Prometheus metrics"),
(name = "dashboard", description = "Dashboard & Metrics API"),
(name = "docker", description = "Docker Registry v2 API"),
(name = "maven", description = "Maven Repository API"),
@@ -41,30 +37,18 @@ use crate::AppState;
// Health
crate::openapi::health_check,
crate::openapi::readiness_check,
// Metrics
crate::openapi::prometheus_metrics,
// Dashboard
crate::openapi::dashboard_metrics,
// Docker - Read
// Docker
crate::openapi::docker_version,
crate::openapi::docker_catalog,
crate::openapi::docker_tags,
crate::openapi::docker_manifest_get,
crate::openapi::docker_blob_head,
crate::openapi::docker_blob_get,
// Docker - Write
crate::openapi::docker_manifest_put,
crate::openapi::docker_blob_upload_start,
crate::openapi::docker_blob_upload_patch,
crate::openapi::docker_blob_upload_put,
crate::openapi::docker_manifest,
crate::openapi::docker_blob,
// Maven
crate::openapi::maven_artifact_get,
crate::openapi::maven_artifact_put,
crate::openapi::maven_artifact,
// npm
crate::openapi::npm_package,
// Cargo
crate::openapi::cargo_metadata,
crate::openapi::cargo_download,
// PyPI
crate::openapi::pypi_simple,
crate::openapi::pypi_package,
@@ -274,8 +258,6 @@ pub struct ActivityEntry {
// ============ Path Operations (documentation only) ============
// -------------------- Health --------------------
/// Health check endpoint
#[utoipa::path(
get,
@@ -300,23 +282,6 @@ pub async fn health_check() {}
)]
pub async fn readiness_check() {}
// -------------------- Metrics --------------------
/// Prometheus metrics endpoint
///
/// Returns metrics in Prometheus text format for scraping.
#[utoipa::path(
get,
path = "/metrics",
tag = "metrics",
responses(
(status = 200, description = "Prometheus metrics", content_type = "text/plain")
)
)]
pub async fn prometheus_metrics() {}
// -------------------- Dashboard --------------------
/// Dashboard metrics and activity
///
/// Returns comprehensive metrics including downloads, uploads, cache statistics,
@@ -331,8 +296,6 @@ pub async fn prometheus_metrics() {}
)]
pub async fn dashboard_metrics() {}
// -------------------- Docker Registry v2 - Read Operations --------------------
/// Docker Registry version check
#[utoipa::path(
get,
@@ -362,7 +325,7 @@ pub async fn docker_catalog() {}
path = "/v2/{name}/tags/list",
tag = "docker",
params(
("name" = String, Path, description = "Repository name (e.g., 'alpine' or 'library/nginx')")
("name" = String, Path, description = "Repository name")
),
responses(
(status = 200, description = "Tag list", body = DockerTags),
@@ -378,30 +341,14 @@ pub async fn docker_tags() {}
tag = "docker",
params(
("name" = String, Path, description = "Repository name"),
("reference" = String, Path, description = "Tag or digest (sha256:...)")
("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() {}
/// Check if blob exists
#[utoipa::path(
head,
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 exists, Content-Length header contains size"),
(status = 404, description = "Blob not found")
)
)]
pub async fn docker_blob_head() {}
pub async fn docker_manifest() {}
/// Get blob
#[utoipa::path(
@@ -417,79 +364,7 @@ pub async fn docker_blob_head() {}
(status = 404, description = "Blob not found")
)
)]
pub async fn docker_blob_get() {}
// -------------------- Docker Registry v2 - Write Operations --------------------
/// Push manifest
#[utoipa::path(
put,
path = "/v2/{name}/manifests/{reference}",
tag = "docker",
params(
("name" = String, Path, description = "Repository name"),
("reference" = String, Path, description = "Tag or digest")
),
responses(
(status = 201, description = "Manifest created, Docker-Content-Digest header contains digest"),
(status = 400, description = "Invalid manifest")
)
)]
pub async fn docker_manifest_put() {}
/// Start blob upload
///
/// Initiates a resumable blob upload. Returns a Location header with the upload URL.
#[utoipa::path(
post,
path = "/v2/{name}/blobs/uploads/",
tag = "docker",
params(
("name" = String, Path, description = "Repository name")
),
responses(
(status = 202, description = "Upload started, Location header contains upload URL")
)
)]
pub async fn docker_blob_upload_start() {}
/// Upload blob chunk (chunked upload)
///
/// Uploads a chunk of data to an in-progress upload session.
#[utoipa::path(
patch,
path = "/v2/{name}/blobs/uploads/{uuid}",
tag = "docker",
params(
("name" = String, Path, description = "Repository name"),
("uuid" = String, Path, description = "Upload session UUID")
),
responses(
(status = 202, description = "Chunk accepted, Range header indicates bytes received")
)
)]
pub async fn docker_blob_upload_patch() {}
/// Complete blob upload
///
/// Finalizes the blob upload. Can include final chunk data in the body.
#[utoipa::path(
put,
path = "/v2/{name}/blobs/uploads/{uuid}",
tag = "docker",
params(
("name" = String, Path, description = "Repository name"),
("uuid" = String, Path, description = "Upload session UUID"),
("digest" = String, Query, description = "Expected blob digest (sha256:...)")
),
responses(
(status = 201, description = "Blob created"),
(status = 400, description = "Digest mismatch or missing")
)
)]
pub async fn docker_blob_upload_put() {}
// -------------------- Maven --------------------
pub async fn docker_blob() {}
/// Get Maven artifact
#[utoipa::path(
@@ -504,24 +379,7 @@ pub async fn docker_blob_upload_put() {}
(status = 404, description = "Artifact not found, trying upstream proxies")
)
)]
pub async fn maven_artifact_get() {}
/// Upload Maven artifact
#[utoipa::path(
put,
path = "/maven2/{path}",
tag = "maven",
params(
("path" = String, Path, description = "Artifact path")
),
responses(
(status = 201, description = "Artifact uploaded"),
(status = 500, description = "Storage error")
)
)]
pub async fn maven_artifact_put() {}
// -------------------- npm --------------------
pub async fn maven_artifact() {}
/// Get npm package metadata
#[utoipa::path(
@@ -529,7 +387,7 @@ pub async fn maven_artifact_put() {}
path = "/npm/{name}",
tag = "npm",
params(
("name" = String, Path, description = "Package name (e.g., 'lodash' or '@scope/package')")
("name" = String, Path, description = "Package name")
),
responses(
(status = 200, description = "Package metadata (JSON)"),
@@ -538,41 +396,6 @@ pub async fn maven_artifact_put() {}
)]
pub async fn npm_package() {}
// -------------------- Cargo --------------------
/// Get Cargo crate metadata
#[utoipa::path(
get,
path = "/cargo/api/v1/crates/{crate_name}",
tag = "cargo",
params(
("crate_name" = String, Path, description = "Crate name")
),
responses(
(status = 200, description = "Crate metadata (JSON)"),
(status = 404, description = "Crate not found")
)
)]
pub async fn cargo_metadata() {}
/// Download Cargo crate
#[utoipa::path(
get,
path = "/cargo/api/v1/crates/{crate_name}/{version}/download",
tag = "cargo",
params(
("crate_name" = String, Path, description = "Crate name"),
("version" = String, Path, description = "Crate version")
),
responses(
(status = 200, description = "Crate file (.crate)"),
(status = 404, description = "Crate version not found")
)
)]
pub async fn cargo_download() {}
// -------------------- PyPI --------------------
/// PyPI Simple index
#[utoipa::path(
get,
@@ -599,8 +422,6 @@ pub async fn pypi_simple() {}
)]
pub async fn pypi_package() {}
// -------------------- Auth / Tokens --------------------
/// Create API token
#[utoipa::path(
post,

View File

@@ -1,6 +1,3 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
//! Rate limiting configuration and middleware
//!
//! Provides rate limiting to protect against:
@@ -10,7 +7,6 @@
use crate::config::RateLimitConfig;
use tower_governor::governor::GovernorConfigBuilder;
use tower_governor::key_extractor::SmartIpKeyExtractor;
/// Create rate limiter layer for auth endpoints (strict protection against brute-force)
pub fn auth_rate_limiter(
@@ -36,12 +32,11 @@ pub fn auth_rate_limiter(
pub fn upload_rate_limiter(
config: &RateLimitConfig,
) -> tower_governor::GovernorLayer<
SmartIpKeyExtractor,
tower_governor::key_extractor::PeerIpKeyExtractor,
governor::middleware::StateInformationMiddleware,
axum::body::Body,
> {
let gov_config = GovernorConfigBuilder::default()
.key_extractor(SmartIpKeyExtractor)
.per_second(config.upload_rps)
.burst_size(config.upload_burst)
.use_headers()
@@ -55,12 +50,11 @@ pub fn upload_rate_limiter(
pub fn general_rate_limiter(
config: &RateLimitConfig,
) -> tower_governor::GovernorLayer<
SmartIpKeyExtractor,
tower_governor::key_extractor::PeerIpKeyExtractor,
governor::middleware::StateInformationMiddleware,
axum::body::Body,
> {
let gov_config = GovernorConfigBuilder::default()
.key_extractor(SmartIpKeyExtractor)
.per_second(config.general_rps)
.burst_size(config.general_burst)
.use_headers()
@@ -105,7 +99,6 @@ mod tests {
#[test]
fn test_custom_config() {
let config = RateLimitConfig {
enabled: true,
auth_rps: 10,
auth_burst: 20,
upload_rps: 500,

View File

@@ -1,6 +1,3 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
use crate::activity_log::{ActionType, ActivityEntry};
use crate::AppState;
use axum::{

View File

@@ -1,6 +1,3 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
use crate::activity_log::{ActionType, ActivityEntry};
use crate::registry::docker_auth::DockerAuth;
use crate::storage::Storage;
@@ -50,8 +47,6 @@ static UPLOAD_SESSIONS: std::sync::LazyLock<RwLock<HashMap<String, Vec<u8>>>> =
pub fn routes() -> Router<Arc<AppState>> {
Router::new()
.route("/v2/", get(check))
.route("/v2/_catalog", get(catalog))
// Single-segment name routes (e.g., /v2/alpine/...)
.route("/v2/{name}/blobs/{digest}", head(check_blob))
.route("/v2/{name}/blobs/{digest}", get(download_blob))
.route(
@@ -65,52 +60,12 @@ pub fn routes() -> Router<Arc<AppState>> {
.route("/v2/{name}/manifests/{reference}", get(get_manifest))
.route("/v2/{name}/manifests/{reference}", put(put_manifest))
.route("/v2/{name}/tags/list", get(list_tags))
// Two-segment name routes (e.g., /v2/library/alpine/...)
.route("/v2/{ns}/{name}/blobs/{digest}", head(check_blob_ns))
.route("/v2/{ns}/{name}/blobs/{digest}", get(download_blob_ns))
.route(
"/v2/{ns}/{name}/blobs/uploads/",
axum::routing::post(start_upload_ns),
)
.route(
"/v2/{ns}/{name}/blobs/uploads/{uuid}",
patch(patch_blob_ns).put(upload_blob_ns),
)
.route(
"/v2/{ns}/{name}/manifests/{reference}",
get(get_manifest_ns),
)
.route(
"/v2/{ns}/{name}/manifests/{reference}",
put(put_manifest_ns),
)
.route("/v2/{ns}/{name}/tags/list", get(list_tags_ns))
}
async fn check() -> (StatusCode, Json<Value>) {
(StatusCode::OK, Json(json!({})))
}
/// List all repositories in the registry
async fn catalog(State(state): State<Arc<AppState>>) -> Json<Value> {
let keys = state.storage.list("docker/").await;
// Extract unique repository names from paths like "docker/{name}/manifests/..."
let mut repos: Vec<String> = keys
.iter()
.filter_map(|k| {
k.strip_prefix("docker/")
.and_then(|rest| rest.split('/').next())
.map(String::from)
})
.collect();
repos.sort();
repos.dedup();
Json(json!({ "repositories": repos }))
}
async fn check_blob(
State(state): State<Arc<AppState>>,
Path((name, digest)): Path<(String, String)>,
@@ -167,7 +122,6 @@ async fn download_blob(
// Try upstream proxies
for upstream in &state.config.docker.upstreams {
if let Ok(data) = fetch_blob_from_upstream(
&state.http_client,
&upstream.url,
&name,
&digest,
@@ -193,8 +147,6 @@ async fn download_blob(
let _ = storage.put(&key_clone, &data_clone).await;
});
state.repo_index.invalidate("docker");
return (
StatusCode::OK,
[(header::CONTENT_TYPE, "application/octet-stream")],
@@ -305,7 +257,6 @@ async fn upload_blob(
"docker",
"LOCAL",
));
state.repo_index.invalidate("docker");
let location = format!("/v2/{}/blobs/{}", name, digest);
(StatusCode::CREATED, [(header::LOCATION, location)]).into_response()
}
@@ -361,14 +312,8 @@ async fn get_manifest(
}
// Try upstream proxies
tracing::debug!(
upstreams_count = state.config.docker.upstreams.len(),
"Trying upstream proxies"
);
for upstream in &state.config.docker.upstreams {
tracing::debug!(upstream_url = %upstream.url, "Trying upstream");
if let Ok((data, content_type)) = fetch_manifest_from_upstream(
&state.http_client,
&upstream.url,
&name,
&reference,
@@ -418,8 +363,6 @@ async fn get_manifest(
}
});
state.repo_index.invalidate("docker");
return (
StatusCode::OK,
[
@@ -481,7 +424,6 @@ async fn put_manifest(
"docker",
"LOCAL",
));
state.repo_index.invalidate("docker");
let location = format!("/v2/{}/manifests/{}", name, reference);
(
@@ -512,78 +454,8 @@ async fn list_tags(State(state): State<Arc<AppState>>, Path(name): Path<String>)
(StatusCode::OK, Json(json!({"name": name, "tags": tags}))).into_response()
}
// ============================================================================
// Namespace handlers (for two-segment names like library/alpine)
// These combine ns/name into a single name and delegate to the main handlers
// ============================================================================
async fn check_blob_ns(
state: State<Arc<AppState>>,
Path((ns, name, digest)): Path<(String, String, String)>,
) -> Response {
let full_name = format!("{}/{}", ns, name);
check_blob(state, Path((full_name, digest))).await
}
async fn download_blob_ns(
state: State<Arc<AppState>>,
Path((ns, name, digest)): Path<(String, String, String)>,
) -> Response {
let full_name = format!("{}/{}", ns, name);
download_blob(state, Path((full_name, digest))).await
}
async fn start_upload_ns(Path((ns, name)): Path<(String, String)>) -> Response {
let full_name = format!("{}/{}", ns, name);
start_upload(Path(full_name)).await
}
async fn patch_blob_ns(
Path((ns, name, uuid)): Path<(String, String, String)>,
body: Bytes,
) -> Response {
let full_name = format!("{}/{}", ns, name);
patch_blob(Path((full_name, uuid)), body).await
}
async fn upload_blob_ns(
state: State<Arc<AppState>>,
Path((ns, name, uuid)): Path<(String, String, String)>,
query: axum::extract::Query<std::collections::HashMap<String, String>>,
body: Bytes,
) -> Response {
let full_name = format!("{}/{}", ns, name);
upload_blob(state, Path((full_name, uuid)), query, body).await
}
async fn get_manifest_ns(
state: State<Arc<AppState>>,
Path((ns, name, reference)): Path<(String, String, String)>,
) -> Response {
let full_name = format!("{}/{}", ns, name);
get_manifest(state, Path((full_name, reference))).await
}
async fn put_manifest_ns(
state: State<Arc<AppState>>,
Path((ns, name, reference)): Path<(String, String, String)>,
body: Bytes,
) -> Response {
let full_name = format!("{}/{}", ns, name);
put_manifest(state, Path((full_name, reference)), body).await
}
async fn list_tags_ns(
state: State<Arc<AppState>>,
Path((ns, name)): Path<(String, String)>,
) -> Response {
let full_name = format!("{}/{}", ns, name);
list_tags(state, Path(full_name)).await
}
/// Fetch a blob from an upstream Docker registry
async fn fetch_blob_from_upstream(
client: &reqwest::Client,
upstream_url: &str,
name: &str,
digest: &str,
@@ -597,14 +469,14 @@ async fn fetch_blob_from_upstream(
digest
);
// First try without auth
let response = client
.get(&url)
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(timeout))
.send()
.await
.build()
.map_err(|_| ())?;
// First try without auth
let response = client.get(&url).send().await.map_err(|_| ())?;
let response = if response.status() == reqwest::StatusCode::UNAUTHORIZED {
// Get Www-Authenticate header and fetch token
let www_auth = response
@@ -640,7 +512,6 @@ async fn fetch_blob_from_upstream(
/// Fetch a manifest from an upstream Docker registry
/// Returns (manifest_bytes, content_type)
async fn fetch_manifest_from_upstream(
client: &reqwest::Client,
upstream_url: &str,
name: &str,
reference: &str,
@@ -654,7 +525,10 @@ async fn fetch_manifest_from_upstream(
reference
);
tracing::debug!(url = %url, "Fetching manifest from upstream");
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(timeout))
.build()
.map_err(|_| ())?;
// Request with Accept header for manifest types
let accept_header = "application/vnd.docker.distribution.manifest.v2+json, \
@@ -665,15 +539,10 @@ async fn fetch_manifest_from_upstream(
// First try without auth
let response = client
.get(&url)
.timeout(Duration::from_secs(timeout))
.header("Accept", accept_header)
.send()
.await
.map_err(|e| {
tracing::error!(error = %e, url = %url, "Failed to send request to upstream");
})?;
tracing::debug!(status = %response.status(), "Initial upstream response");
.map_err(|_| ())?;
let response = if response.status() == reqwest::StatusCode::UNAUTHORIZED {
// Get Www-Authenticate header and fetch token
@@ -683,34 +552,25 @@ async fn fetch_manifest_from_upstream(
.and_then(|v| v.to_str().ok())
.map(String::from);
tracing::debug!(www_auth = ?www_auth, "Got 401, fetching token");
if let Some(token) = docker_auth
.get_token(upstream_url, name, www_auth.as_deref())
.await
{
tracing::debug!("Token acquired, retrying with auth");
client
.get(&url)
.header("Accept", accept_header)
.header("Authorization", format!("Bearer {}", token))
.send()
.await
.map_err(|e| {
tracing::error!(error = %e, "Failed to send authenticated request");
})?
.map_err(|_| ())?
} else {
tracing::error!("Failed to acquire token");
return Err(());
}
} else {
response
};
tracing::debug!(status = %response.status(), "Final upstream response");
if !response.status().is_success() {
tracing::warn!(status = %response.status(), "Upstream returned non-success status");
return Err(());
}

View File

@@ -1,6 +1,3 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
use parking_lot::RwLock;
use std::collections::HashMap;
use std::time::{Duration, Instant};
@@ -80,12 +77,9 @@ impl DockerAuth {
let scope = format!("repository:{}:pull", name);
let url = format!("{}?service={}&scope={}", realm, service, scope);
tracing::debug!(url = %url, "Fetching auth token");
let response = self.client.get(&url).send().await.ok()?;
if !response.status().is_success() {
tracing::warn!(status = %response.status(), "Token request failed");
return None;
}

View File

@@ -1,6 +1,3 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
use crate::activity_log::{ActionType, ActivityEntry};
use crate::AppState;
use axum::{
@@ -23,6 +20,7 @@ pub fn routes() -> Router<Arc<AppState>> {
async fn download(State(state): State<Arc<AppState>>, Path(path): Path<String>) -> Response {
let key = format!("maven/{}", path);
// Extract artifact name for logging (last 2-3 path components)
let artifact_name = path
.split('/')
.rev()
@@ -33,6 +31,7 @@ async fn download(State(state): State<Arc<AppState>>, Path(path): Path<String>)
.collect::<Vec<_>>()
.join("/");
// Try local storage first
if let Ok(data) = state.storage.get(&key).await {
state.metrics.record_download("maven");
state.metrics.record_cache_hit();
@@ -45,10 +44,11 @@ async fn download(State(state): State<Arc<AppState>>, Path(path): Path<String>)
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(&state.http_client, &url, state.config.maven.proxy_timeout).await {
match fetch_from_proxy(&url, state.config.maven.proxy_timeout).await {
Ok(data) => {
state.metrics.record_download("maven");
state.metrics.record_cache_miss();
@@ -59,6 +59,7 @@ async fn download(State(state): State<Arc<AppState>>, Path(path): Path<String>)
"PROXY",
));
// Cache in local storage (fire and forget)
let storage = state.storage.clone();
let key_clone = key.clone();
let data_clone = data.clone();
@@ -66,8 +67,6 @@ async fn download(State(state): State<Arc<AppState>>, Path(path): Path<String>)
let _ = storage.put(&key_clone, &data_clone).await;
});
state.repo_index.invalidate("maven");
return with_content_type(&path, data.into()).into_response();
}
Err(_) => continue,
@@ -84,6 +83,7 @@ async fn upload(
) -> StatusCode {
let key = format!("maven/{}", path);
// Extract artifact name for logging
let artifact_name = path
.split('/')
.rev()
@@ -103,25 +103,20 @@ async fn upload(
"maven",
"LOCAL",
));
state.repo_index.invalidate("maven");
StatusCode::CREATED
}
Err(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
async fn fetch_from_proxy(
client: &reqwest::Client,
url: &str,
timeout_secs: u64,
) -> Result<Vec<u8>, ()> {
let response = client
.get(url)
async fn fetch_from_proxy(url: &str, timeout_secs: u64) -> Result<Vec<u8>, ()> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(timeout_secs))
.send()
.await
.build()
.map_err(|_| ())?;
let response = client.get(url).send().await.map_err(|_| ())?;
if !response.status().is_success() {
return Err(());
}

View File

@@ -1,6 +1,3 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
mod cargo_registry;
pub mod docker;
pub mod docker_auth;

View File

@@ -1,6 +1,3 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
use crate::activity_log::{ActionType, ActivityEntry};
use crate::AppState;
use axum::{
@@ -19,6 +16,7 @@ pub fn routes() -> Router<Arc<AppState>> {
}
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 {
@@ -32,12 +30,14 @@ async fn handle_request(State(state): State<Arc<AppState>>, Path(path): Path<Str
format!("npm/{}/metadata.json", path)
};
// Extract package name for logging
let package_name = if is_tarball {
path.split("/-/").next().unwrap_or(&path).to_string()
} else {
path.clone()
};
// Try local storage first
if let Ok(data) = state.storage.get(&key).await {
if is_tarball {
state.metrics.record_download("npm");
@@ -52,12 +52,17 @@ async fn handle_request(State(state): State<Arc<AppState>>, Path(path): Path<Str
return with_content_type(is_tarball, data).into_response();
}
// Try proxy if configured
if let Some(proxy_url) = &state.config.npm.proxy {
let url = format!("{}/{}", proxy_url.trim_end_matches('/'), path);
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(&state.http_client, &url, state.config.npm.proxy_timeout).await
{
if let Ok(data) = fetch_from_proxy(&url, state.config.npm.proxy_timeout).await {
if is_tarball {
state.metrics.record_download("npm");
state.metrics.record_cache_miss();
@@ -69,6 +74,7 @@ async fn handle_request(State(state): State<Arc<AppState>>, Path(path): Path<Str
));
}
// Cache in local storage (fire and forget)
let storage = state.storage.clone();
let key_clone = key.clone();
let data_clone = data.clone();
@@ -76,10 +82,6 @@ async fn handle_request(State(state): State<Arc<AppState>>, Path(path): Path<Str
let _ = storage.put(&key_clone, &data_clone).await;
});
if is_tarball {
state.repo_index.invalidate("npm");
}
return with_content_type(is_tarball, data.into()).into_response();
}
}
@@ -87,18 +89,14 @@ async fn handle_request(State(state): State<Arc<AppState>>, Path(path): Path<Str
StatusCode::NOT_FOUND.into_response()
}
async fn fetch_from_proxy(
client: &reqwest::Client,
url: &str,
timeout_secs: u64,
) -> Result<Vec<u8>, ()> {
let response = client
.get(url)
async fn fetch_from_proxy(url: &str, timeout_secs: u64) -> Result<Vec<u8>, ()> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(timeout_secs))
.send()
.await
.build()
.map_err(|_| ())?;
let response = client.get(url).send().await.map_err(|_| ())?;
if !response.status().is_success() {
return Err(());
}

View File

@@ -1,6 +1,3 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
use crate::activity_log::{ActionType, ActivityEntry};
use crate::AppState;
use axum::{
@@ -85,9 +82,7 @@ async fn package_versions(
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(&state.http_client, &url, state.config.pypi.proxy_timeout).await
{
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();
@@ -132,22 +127,10 @@ async fn download_file(
// 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(
&state.http_client,
&page_url,
state.config.pypi.proxy_timeout,
)
.await
{
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(
&state.http_client,
&file_url,
state.config.pypi.proxy_timeout,
)
.await
{
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(
@@ -165,8 +148,6 @@ async fn download_file(
let _ = storage.put(&key_clone, &data_clone).await;
});
state.repo_index.invalidate("pypi");
let content_type = if filename.ends_with(".whl") {
"application/zip"
} else if filename.ends_with(".tar.gz") || filename.ends_with(".tgz") {
@@ -191,14 +172,14 @@ fn normalize_name(name: &str) -> String {
}
/// Fetch package page from upstream
async fn fetch_package_page(
client: &reqwest::Client,
url: &str,
timeout_secs: u64,
) -> Result<String, ()> {
async fn fetch_package_page(url: &str, timeout_secs: u64) -> Result<String, ()> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(timeout_secs))
.build()
.map_err(|_| ())?;
let response = client
.get(url)
.timeout(Duration::from_secs(timeout_secs))
.header("Accept", "text/html")
.send()
.await
@@ -212,14 +193,14 @@ async fn fetch_package_page(
}
/// Fetch file from upstream
async fn fetch_file(client: &reqwest::Client, url: &str, timeout_secs: u64) -> Result<Vec<u8>, ()> {
let response = client
.get(url)
async fn fetch_file(url: &str, timeout_secs: u64) -> Result<Vec<u8>, ()> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(timeout_secs))
.send()
.await
.build()
.map_err(|_| ())?;
let response = client.get(url).send().await.map_err(|_| ())?;
if !response.status().is_success() {
return Err(());
}

View File

@@ -1,6 +1,3 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
use crate::activity_log::{ActionType, ActivityEntry};
use crate::AppState;
use axum::{

View File

@@ -1,351 +0,0 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
//! In-memory repository index with lazy rebuild on invalidation.
//!
//! Design (designed for efficiency):
//! - Rebuild happens ONLY on write operations, not TTL
//! - Double-checked locking prevents duplicate rebuilds
//! - Arc<Vec> for zero-cost reads
//! - Single rebuild at a time per registry (rebuild_lock)
use crate::storage::Storage;
use crate::ui::components::format_timestamp;
use parking_lot::RwLock;
use serde::Serialize;
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tokio::sync::Mutex as AsyncMutex;
use tracing::info;
/// Repository info for UI display
#[derive(Debug, Clone, Serialize)]
pub struct RepoInfo {
pub name: String,
pub versions: usize,
pub size: u64,
pub updated: String,
}
/// Index for a single registry type
pub struct RegistryIndex {
data: RwLock<Arc<Vec<RepoInfo>>>,
dirty: AtomicBool,
rebuild_lock: AsyncMutex<()>,
}
impl RegistryIndex {
pub fn new() -> Self {
Self {
data: RwLock::new(Arc::new(Vec::new())),
dirty: AtomicBool::new(true),
rebuild_lock: AsyncMutex::new(()),
}
}
/// Mark index as needing rebuild
pub fn invalidate(&self) {
self.dirty.store(true, Ordering::Release);
}
fn is_dirty(&self) -> bool {
self.dirty.load(Ordering::Acquire)
}
fn get_cached(&self) -> Arc<Vec<RepoInfo>> {
Arc::clone(&self.data.read())
}
fn set(&self, data: Vec<RepoInfo>) {
*self.data.write() = Arc::new(data);
self.dirty.store(false, Ordering::Release);
}
pub fn count(&self) -> usize {
self.data.read().len()
}
}
impl Default for RegistryIndex {
fn default() -> Self {
Self::new()
}
}
/// Main repository index for all registries
pub struct RepoIndex {
pub docker: RegistryIndex,
pub maven: RegistryIndex,
pub npm: RegistryIndex,
pub cargo: RegistryIndex,
pub pypi: RegistryIndex,
}
impl RepoIndex {
pub fn new() -> Self {
Self {
docker: RegistryIndex::new(),
maven: RegistryIndex::new(),
npm: RegistryIndex::new(),
cargo: RegistryIndex::new(),
pypi: RegistryIndex::new(),
}
}
/// Invalidate a specific registry index
pub fn invalidate(&self, registry: &str) {
match registry {
"docker" => self.docker.invalidate(),
"maven" => self.maven.invalidate(),
"npm" => self.npm.invalidate(),
"cargo" => self.cargo.invalidate(),
"pypi" => self.pypi.invalidate(),
_ => {}
}
}
/// Get index with double-checked locking (prevents race condition)
pub async fn get(&self, registry: &str, storage: &Storage) -> Arc<Vec<RepoInfo>> {
let index = match registry {
"docker" => &self.docker,
"maven" => &self.maven,
"npm" => &self.npm,
"cargo" => &self.cargo,
"pypi" => &self.pypi,
_ => return Arc::new(Vec::new()),
};
// Fast path: not dirty, return cached
if !index.is_dirty() {
return index.get_cached();
}
// Slow path: acquire rebuild lock (only one thread rebuilds)
let _guard = index.rebuild_lock.lock().await;
// Double-check under lock (another thread may have rebuilt)
if index.is_dirty() {
let data = match registry {
"docker" => build_docker_index(storage).await,
"maven" => build_maven_index(storage).await,
"npm" => build_npm_index(storage).await,
"cargo" => build_cargo_index(storage).await,
"pypi" => build_pypi_index(storage).await,
_ => Vec::new(),
};
info!(registry = registry, count = data.len(), "Index rebuilt");
index.set(data);
}
index.get_cached()
}
/// Get counts for stats (no rebuild, just current state)
pub fn counts(&self) -> (usize, usize, usize, usize, usize) {
(
self.docker.count(),
self.maven.count(),
self.npm.count(),
self.cargo.count(),
self.pypi.count(),
)
}
}
impl Default for RepoIndex {
fn default() -> Self {
Self::new()
}
}
// ============================================================================
// Index builders
// ============================================================================
async fn build_docker_index(storage: &Storage) -> Vec<RepoInfo> {
let keys = storage.list("docker/").await;
let mut repos: HashMap<String, (usize, u64, u64)> = HashMap::new();
for key in &keys {
if key.ends_with(".meta.json") {
continue;
}
if let Some(rest) = key.strip_prefix("docker/") {
let parts: Vec<_> = rest.split('/').collect();
if parts.len() >= 3 && parts[1] == "manifests" && key.ends_with(".json") {
let name = parts[0].to_string();
let entry = repos.entry(name).or_insert((0, 0, 0));
entry.0 += 1;
if let Ok(data) = storage.get(key).await {
if let Ok(m) = serde_json::from_slice::<serde_json::Value>(&data) {
let cfg = m
.get("config")
.and_then(|c| c.get("size"))
.and_then(|s| s.as_u64())
.unwrap_or(0);
let layers: u64 = m
.get("layers")
.and_then(|l| l.as_array())
.map(|arr| {
arr.iter()
.filter_map(|l| l.get("size").and_then(|s| s.as_u64()))
.sum()
})
.unwrap_or(0);
entry.1 += cfg + layers;
}
}
if let Some(meta) = storage.stat(key).await {
if meta.modified > entry.2 {
entry.2 = meta.modified;
}
}
}
}
}
to_sorted_vec(repos)
}
async fn build_maven_index(storage: &Storage) -> Vec<RepoInfo> {
let keys = storage.list("maven/").await;
let mut repos: HashMap<String, (usize, u64, 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 path = parts[..parts.len() - 1].join("/");
let entry = repos.entry(path).or_insert((0, 0, 0));
entry.0 += 1;
if let Some(meta) = storage.stat(key).await {
entry.1 += meta.size;
if meta.modified > entry.2 {
entry.2 = meta.modified;
}
}
}
}
}
to_sorted_vec(repos)
}
async fn build_npm_index(storage: &Storage) -> Vec<RepoInfo> {
let keys = storage.list("npm/").await;
let mut packages: HashMap<String, (usize, u64, u64)> = HashMap::new();
// Count tarballs instead of parsing metadata.json (faster than parsing JSON)
for key in &keys {
if let Some(rest) = key.strip_prefix("npm/") {
// Pattern: npm/{package}/tarballs/{file}.tgz
if rest.contains("/tarballs/") && key.ends_with(".tgz") {
let parts: Vec<_> = rest.split('/').collect();
if !parts.is_empty() {
let name = parts[0].to_string();
let entry = packages.entry(name).or_insert((0, 0, 0));
entry.0 += 1;
if let Some(meta) = storage.stat(key).await {
entry.1 += meta.size;
if meta.modified > entry.2 {
entry.2 = meta.modified;
}
}
}
}
}
}
to_sorted_vec(packages)
}
async fn build_cargo_index(storage: &Storage) -> Vec<RepoInfo> {
let keys = storage.list("cargo/").await;
let mut crates: HashMap<String, (usize, u64, u64)> = HashMap::new();
for key in &keys {
if key.ends_with(".crate") {
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).or_insert((0, 0, 0));
entry.0 += 1;
if let Some(meta) = storage.stat(key).await {
entry.1 += meta.size;
if meta.modified > entry.2 {
entry.2 = meta.modified;
}
}
}
}
}
}
to_sorted_vec(crates)
}
async fn build_pypi_index(storage: &Storage) -> Vec<RepoInfo> {
let keys = storage.list("pypi/").await;
let mut packages: HashMap<String, (usize, u64, u64)> = HashMap::new();
for key in &keys {
if let Some(rest) = key.strip_prefix("pypi/") {
let parts: Vec<_> = rest.split('/').collect();
if parts.len() >= 2 {
let name = parts[0].to_string();
let entry = packages.entry(name).or_insert((0, 0, 0));
entry.0 += 1;
if let Some(meta) = storage.stat(key).await {
entry.1 += meta.size;
if meta.modified > entry.2 {
entry.2 = meta.modified;
}
}
}
}
}
to_sorted_vec(packages)
}
/// Convert HashMap to sorted Vec<RepoInfo>
fn to_sorted_vec(map: HashMap<String, (usize, u64, u64)>) -> Vec<RepoInfo> {
let mut result: Vec<_> = map
.into_iter()
.map(|(name, (versions, size, modified))| RepoInfo {
name,
versions,
size,
updated: if modified > 0 {
format_timestamp(modified)
} else {
"N/A".to_string()
},
})
.collect();
result.sort_by(|a, b| a.name.cmp(&b.name));
result
}
/// Pagination helper
pub fn paginate<T: Clone>(data: &[T], page: usize, limit: usize) -> (Vec<T>, usize) {
let total = data.len();
let start = page.saturating_sub(1) * limit;
if start >= total {
return (Vec::new(), total);
}
let end = (start + limit).min(total);
(data[start..end].to_vec(), total)
}

View File

@@ -1,6 +1,3 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
//! Request ID middleware for request tracking and correlation
//!
//! Generates a unique ID for each request that can be used for:

View File

@@ -1,6 +1,3 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
//! Environment variables secrets provider
//!
//! Reads secrets from environment variables. This is the default provider

View File

@@ -1,6 +1,3 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
#![allow(dead_code)] // Foundational code for future S3/Vault integration
//! Secrets management for NORA

View File

@@ -1,6 +1,3 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
//! Protected secret types with memory safety
//!
//! Secrets are automatically zeroed on drop and redacted in Debug output.

View File

@@ -1,6 +1,3 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
use async_trait::async_trait;
use axum::body::Bytes;
use std::path::PathBuf;

View File

@@ -1,6 +1,3 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
mod local;
mod s3;
@@ -62,17 +59,9 @@ impl Storage {
}
}
pub fn new_s3(
s3_url: &str,
bucket: &str,
region: &str,
access_key: Option<&str>,
secret_key: Option<&str>,
) -> Self {
pub fn new_s3(s3_url: &str, bucket: &str) -> Self {
Self {
inner: Arc::new(S3Storage::new(
s3_url, bucket, region, access_key, secret_key,
)),
inner: Arc::new(S3Storage::new(s3_url, bucket)),
}
}

View File

@@ -1,146 +1,24 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
use async_trait::async_trait;
use axum::body::Bytes;
use chrono::Utc;
use hmac::{Hmac, Mac};
use sha2::{Digest, Sha256};
use super::{FileMeta, Result, StorageBackend, StorageError};
type HmacSha256 = Hmac<Sha256>;
/// S3-compatible storage backend (MinIO, AWS S3)
pub struct S3Storage {
s3_url: String,
bucket: String,
region: String,
access_key: Option<String>,
secret_key: Option<String>,
client: reqwest::Client,
}
impl S3Storage {
/// Create new S3 storage with optional credentials
pub fn new(
s3_url: &str,
bucket: &str,
region: &str,
access_key: Option<&str>,
secret_key: Option<&str>,
) -> Self {
pub fn new(s3_url: &str, bucket: &str) -> Self {
Self {
s3_url: s3_url.trim_end_matches('/').to_string(),
s3_url: s3_url.to_string(),
bucket: bucket.to_string(),
region: region.to_string(),
access_key: access_key.map(String::from),
secret_key: secret_key.map(String::from),
client: reqwest::Client::new(),
}
}
/// Sign a request using AWS Signature v4
fn sign_request(
&self,
method: &str,
path: &str,
payload_hash: &str,
timestamp: &str,
date: &str,
) -> Option<String> {
let (access_key, secret_key) = match (&self.access_key, &self.secret_key) {
(Some(ak), Some(sk)) => (ak.as_str(), sk.as_str()),
_ => return None,
};
// Parse host from URL
let host = self
.s3_url
.trim_start_matches("http://")
.trim_start_matches("https://");
// Canonical request
// URI must be URL-encoded (except /)
let encoded_path = uri_encode(path);
let canonical_uri = format!("/{}/{}", self.bucket, encoded_path);
let canonical_query = "";
let canonical_headers = format!(
"host:{}\nx-amz-content-sha256:{}\nx-amz-date:{}\n",
host, payload_hash, timestamp
);
let signed_headers = "host;x-amz-content-sha256;x-amz-date";
// AWS Signature v4 canonical request format:
// HTTPMethod\nCanonicalURI\nCanonicalQueryString\nCanonicalHeaders\n\nSignedHeaders\nHashedPayload
// Note: CanonicalHeaders already ends with \n, plus blank line before SignedHeaders
let canonical_request = format!(
"{}\n{}\n{}\n{}\n{}\n{}",
method, canonical_uri, canonical_query, canonical_headers, signed_headers, payload_hash
);
let canonical_request_hash = hex::encode(Sha256::digest(canonical_request.as_bytes()));
// String to sign
let credential_scope = format!("{}/{}/s3/aws4_request", date, self.region);
let string_to_sign = format!(
"AWS4-HMAC-SHA256\n{}\n{}\n{}",
timestamp, credential_scope, canonical_request_hash
);
// Calculate signature
let k_date = hmac_sha256(format!("AWS4{}", secret_key).as_bytes(), date.as_bytes());
let k_region = hmac_sha256(&k_date, self.region.as_bytes());
let k_service = hmac_sha256(&k_region, b"s3");
let k_signing = hmac_sha256(&k_service, b"aws4_request");
let signature = hex::encode(hmac_sha256(&k_signing, string_to_sign.as_bytes()));
// Authorization header
Some(format!(
"AWS4-HMAC-SHA256 Credential={}/{}, SignedHeaders={}, Signature={}",
access_key, credential_scope, signed_headers, signature
))
}
/// Make a signed request
async fn signed_request(
&self,
method: reqwest::Method,
key: &str,
body: Option<&[u8]>,
) -> std::result::Result<reqwest::Response, StorageError> {
let url = format!("{}/{}/{}", self.s3_url, self.bucket, key);
let now = Utc::now();
let timestamp = now.format("%Y%m%dT%H%M%SZ").to_string();
let date = now.format("%Y%m%d").to_string();
let payload_hash = match body {
Some(data) => hex::encode(Sha256::digest(data)),
None => hex::encode(Sha256::digest(b"")),
};
let mut request = self
.client
.request(method.clone(), &url)
.header("x-amz-date", &timestamp)
.header("x-amz-content-sha256", &payload_hash);
if let Some(auth) =
self.sign_request(method.as_str(), key, &payload_hash, &timestamp, &date)
{
request = request.header("Authorization", auth);
}
if let Some(data) = body {
request = request.body(data.to_vec());
}
request
.send()
.await
.map_err(|e| StorageError::Network(e.to_string()))
}
fn parse_s3_keys(xml: &str, prefix: &str) -> Vec<String> {
xml.split("<Key>")
.filter_map(|part| part.split("</Key>").next())
@@ -150,34 +28,17 @@ impl S3Storage {
}
}
/// URL-encode a string for S3 canonical URI (encode all except A-Za-z0-9-_.~/)
fn uri_encode(s: &str) -> String {
let mut result = String::with_capacity(s.len() * 3);
for c in s.chars() {
match c {
'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' | '/' => result.push(c),
_ => {
for b in c.to_string().as_bytes() {
result.push_str(&format!("%{:02X}", b));
}
}
}
}
result
}
fn hmac_sha256(key: &[u8], data: &[u8]) -> Vec<u8> {
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC can take key of any size");
mac.update(data);
mac.finalize().into_bytes().to_vec()
}
#[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
.signed_request(reqwest::Method::PUT, key, Some(data))
.await?;
.client
.put(&url)
.body(data.to_vec())
.send()
.await
.map_err(|e| StorageError::Network(e.to_string()))?;
if response.status().is_success() {
Ok(())
@@ -190,7 +51,13 @@ impl StorageBackend for S3Storage {
}
async fn get(&self, key: &str) -> Result<Bytes> {
let response = self.signed_request(reqwest::Method::GET, key, None).await?;
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
@@ -208,9 +75,13 @@ impl StorageBackend for S3Storage {
}
async fn delete(&self, key: &str) -> Result<()> {
let url = format!("{}/{}/{}", self.s3_url, self.bucket, key);
let response = self
.signed_request(reqwest::Method::DELETE, key, None)
.await?;
.client
.delete(&url)
.send()
.await
.map_err(|e| StorageError::Network(e.to_string()))?;
if response.status().is_success() || response.status().as_u16() == 204 {
Ok(())
@@ -225,59 +96,8 @@ impl StorageBackend for S3Storage {
}
async fn list(&self, prefix: &str) -> Vec<String> {
// For listing, we need to make a request to the bucket
let url = format!("{}/{}", self.s3_url, self.bucket);
let now = Utc::now();
let timestamp = now.format("%Y%m%dT%H%M%SZ").to_string();
let date = now.format("%Y%m%d").to_string();
let payload_hash = hex::encode(Sha256::digest(b""));
let host = self
.s3_url
.trim_start_matches("http://")
.trim_start_matches("https://");
let mut request = self
.client
.get(&url)
.header("x-amz-date", &timestamp)
.header("x-amz-content-sha256", &payload_hash);
// Sign for bucket listing (different path)
if let (Some(access_key), Some(secret_key)) = (&self.access_key, &self.secret_key) {
let canonical_uri = format!("/{}", self.bucket);
let canonical_headers = format!(
"host:{}\nx-amz-content-sha256:{}\nx-amz-date:{}\n",
host, payload_hash, timestamp
);
let signed_headers = "host;x-amz-content-sha256;x-amz-date";
let canonical_request = format!(
"GET\n{}\n\n{}\n{}\n{}",
canonical_uri, canonical_headers, signed_headers, payload_hash
);
let canonical_request_hash = hex::encode(Sha256::digest(canonical_request.as_bytes()));
let credential_scope = format!("{}/{}/s3/aws4_request", date, self.region);
let string_to_sign = format!(
"AWS4-HMAC-SHA256\n{}\n{}\n{}",
timestamp, credential_scope, canonical_request_hash
);
let k_date = hmac_sha256(format!("AWS4{}", secret_key).as_bytes(), date.as_bytes());
let k_region = hmac_sha256(&k_date, self.region.as_bytes());
let k_service = hmac_sha256(&k_region, b"s3");
let k_signing = hmac_sha256(&k_service, b"aws4_request");
let signature = hex::encode(hmac_sha256(&k_signing, string_to_sign.as_bytes()));
let auth = format!(
"AWS4-HMAC-SHA256 Credential={}/{}, SignedHeaders={}, Signature={}",
access_key, credential_scope, signed_headers, signature
);
request = request.header("Authorization", auth);
}
match request.send().await {
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)
@@ -290,22 +110,18 @@ impl StorageBackend for S3Storage {
}
async fn stat(&self, key: &str) -> Option<FileMeta> {
let response = self
.signed_request(reqwest::Method::HEAD, key, None)
.await
.ok()?;
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")
@@ -317,63 +133,12 @@ impl StorageBackend for S3Storage {
.as_secs()
})
.unwrap_or(0);
Some(FileMeta { size, modified })
}
async fn health_check(&self) -> bool {
// Try HEAD on the bucket
let url = format!("{}/{}", self.s3_url, self.bucket);
let now = Utc::now();
let timestamp = now.format("%Y%m%dT%H%M%SZ").to_string();
let date = now.format("%Y%m%d").to_string();
let payload_hash = hex::encode(Sha256::digest(b""));
let host = self
.s3_url
.trim_start_matches("http://")
.trim_start_matches("https://");
let mut request = self
.client
.head(&url)
.header("x-amz-date", &timestamp)
.header("x-amz-content-sha256", &payload_hash);
if let (Some(access_key), Some(secret_key)) = (&self.access_key, &self.secret_key) {
let canonical_uri = format!("/{}", self.bucket);
let canonical_headers = format!(
"host:{}\nx-amz-content-sha256:{}\nx-amz-date:{}\n",
host, payload_hash, timestamp
);
let signed_headers = "host;x-amz-content-sha256;x-amz-date";
let canonical_request = format!(
"HEAD\n{}\n\n{}\n{}\n{}",
canonical_uri, canonical_headers, signed_headers, payload_hash
);
let canonical_request_hash = hex::encode(Sha256::digest(canonical_request.as_bytes()));
let credential_scope = format!("{}/{}/s3/aws4_request", date, self.region);
let string_to_sign = format!(
"AWS4-HMAC-SHA256\n{}\n{}\n{}",
timestamp, credential_scope, canonical_request_hash
);
let k_date = hmac_sha256(format!("AWS4{}", secret_key).as_bytes(), date.as_bytes());
let k_region = hmac_sha256(&k_date, self.region.as_bytes());
let k_service = hmac_sha256(&k_region, b"s3");
let k_signing = hmac_sha256(&k_service, b"aws4_request");
let signature = hex::encode(hmac_sha256(&k_signing, string_to_sign.as_bytes()));
let auth = format!(
"AWS4-HMAC-SHA256 Credential={}/{}, SignedHeaders={}, Signature={}",
access_key, credential_scope, signed_headers, signature
);
request = request.header("Authorization", auth);
}
match request.send().await {
match self.client.head(&url).send().await {
Ok(response) => response.status().is_success() || response.status().as_u16() == 404,
Err(_) => false,
}
@@ -387,28 +152,173 @@ impl StorageBackend for S3Storage {
#[cfg(test)]
mod tests {
use super::*;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[test]
fn test_backend_name() {
let storage = S3Storage::new(
"http://localhost:9000",
"test-bucket",
"us-east-1",
Some("access"),
Some("secret"),
);
assert_eq!(storage.backend_name(), "s3");
#[tokio::test]
async fn test_put_success() {
let mock_server = MockServer::start().await;
let storage = S3Storage::new(&mock_server.uri(), "test-bucket");
Mock::given(method("PUT"))
.and(path("/test-bucket/test-key"))
.respond_with(ResponseTemplate::new(200))
.mount(&mock_server)
.await;
let result = storage.put("test-key", b"data").await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_put_failure() {
let mock_server = MockServer::start().await;
let storage = S3Storage::new(&mock_server.uri(), "test-bucket");
Mock::given(method("PUT"))
.and(path("/test-bucket/test-key"))
.respond_with(ResponseTemplate::new(500))
.mount(&mock_server)
.await;
let result = storage.put("test-key", b"data").await;
assert!(matches!(result, Err(StorageError::Network(_))));
}
#[tokio::test]
async fn test_get_success() {
let mock_server = MockServer::start().await;
let storage = S3Storage::new(&mock_server.uri(), "test-bucket");
Mock::given(method("GET"))
.and(path("/test-bucket/test-key"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(b"test data".to_vec()))
.mount(&mock_server)
.await;
let data = storage.get("test-key").await.unwrap();
assert_eq!(&*data, b"test data");
}
#[tokio::test]
async fn test_get_not_found() {
let mock_server = MockServer::start().await;
let storage = S3Storage::new(&mock_server.uri(), "test-bucket");
Mock::given(method("GET"))
.and(path("/test-bucket/missing"))
.respond_with(ResponseTemplate::new(404))
.mount(&mock_server)
.await;
let result = storage.get("missing").await;
assert!(matches!(result, Err(StorageError::NotFound)));
}
#[tokio::test]
async fn test_list() {
let mock_server = MockServer::start().await;
let storage = S3Storage::new(&mock_server.uri(), "test-bucket");
let xml_response = r#"<?xml version="1.0"?>
<ListBucketResult>
<Key>docker/image1</Key>
<Key>docker/image2</Key>
<Key>maven/artifact</Key>
</ListBucketResult>"#;
Mock::given(method("GET"))
.and(path("/test-bucket"))
.respond_with(ResponseTemplate::new(200).set_body_string(xml_response))
.mount(&mock_server)
.await;
let keys = storage.list("docker/").await;
assert_eq!(keys.len(), 2);
assert!(keys.iter().all(|k| k.starts_with("docker/")));
}
#[tokio::test]
async fn test_stat_success() {
let mock_server = MockServer::start().await;
let storage = S3Storage::new(&mock_server.uri(), "test-bucket");
Mock::given(method("HEAD"))
.and(path("/test-bucket/test-key"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-length", "1234")
.insert_header("last-modified", "Sun, 06 Nov 1994 08:49:37 GMT"),
)
.mount(&mock_server)
.await;
let meta = storage.stat("test-key").await.unwrap();
assert_eq!(meta.size, 1234);
assert!(meta.modified > 0);
}
#[tokio::test]
async fn test_stat_not_found() {
let mock_server = MockServer::start().await;
let storage = S3Storage::new(&mock_server.uri(), "test-bucket");
Mock::given(method("HEAD"))
.and(path("/test-bucket/missing"))
.respond_with(ResponseTemplate::new(404))
.mount(&mock_server)
.await;
let meta = storage.stat("missing").await;
assert!(meta.is_none());
}
#[tokio::test]
async fn test_health_check_healthy() {
let mock_server = MockServer::start().await;
let storage = S3Storage::new(&mock_server.uri(), "test-bucket");
Mock::given(method("HEAD"))
.and(path("/test-bucket"))
.respond_with(ResponseTemplate::new(200))
.mount(&mock_server)
.await;
assert!(storage.health_check().await);
}
#[tokio::test]
async fn test_health_check_bucket_not_found_is_ok() {
let mock_server = MockServer::start().await;
let storage = S3Storage::new(&mock_server.uri(), "test-bucket");
Mock::given(method("HEAD"))
.and(path("/test-bucket"))
.respond_with(ResponseTemplate::new(404))
.mount(&mock_server)
.await;
// 404 is OK for health check (bucket may be empty)
assert!(storage.health_check().await);
}
#[tokio::test]
async fn test_health_check_server_error() {
let mock_server = MockServer::start().await;
let storage = S3Storage::new(&mock_server.uri(), "test-bucket");
Mock::given(method("HEAD"))
.and(path("/test-bucket"))
.respond_with(ResponseTemplate::new(500))
.mount(&mock_server)
.await;
assert!(!storage.health_check().await);
}
#[test]
fn test_s3_storage_creation_anonymous() {
let storage = S3Storage::new(
"http://localhost:9000",
"test-bucket",
"us-east-1",
None,
None,
);
fn test_backend_name() {
let storage = S3Storage::new("http://localhost:9000", "bucket");
assert_eq!(storage.backend_name(), "s3");
}
@@ -418,10 +328,4 @@ mod tests {
let keys = S3Storage::parse_s3_keys(xml, "docker/");
assert_eq!(keys, vec!["docker/a", "docker/b"]);
}
#[test]
fn test_hmac_sha256() {
let result = hmac_sha256(b"key", b"data");
assert!(!result.is_empty());
}
}

View File

@@ -1,6 +1,3 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::fs;

View File

@@ -1,10 +1,6 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
use super::components::{format_size, format_timestamp, html_escape};
use super::templates::encode_uri_component;
use crate::activity_log::ActivityEntry;
use crate::repo_index::RepoInfo;
use crate::AppState;
use crate::Storage;
use axum::{
@@ -25,6 +21,14 @@ pub struct RegistryStats {
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,
@@ -108,44 +112,44 @@ pub struct MountPoint {
// ============ API Handlers ============
pub async fn api_stats(State(state): State<Arc<AppState>>) -> Json<RegistryStats> {
// Trigger index rebuild if needed, then get counts
let _ = state.repo_index.get("docker", &state.storage).await;
let _ = state.repo_index.get("maven", &state.storage).await;
let _ = state.repo_index.get("npm", &state.storage).await;
let _ = state.repo_index.get("cargo", &state.storage).await;
let _ = state.repo_index.get("pypi", &state.storage).await;
let (docker, maven, npm, cargo, pypi) = state.repo_index.counts();
Json(RegistryStats {
docker,
maven,
npm,
cargo,
pypi,
})
let stats = get_registry_stats(&state.storage).await;
Json(stats)
}
pub async fn api_dashboard(State(state): State<Arc<AppState>>) -> Json<DashboardResponse> {
// Get indexes (will rebuild if dirty)
let docker_repos = state.repo_index.get("docker", &state.storage).await;
let maven_repos = state.repo_index.get("maven", &state.storage).await;
let npm_repos = state.repo_index.get("npm", &state.storage).await;
let cargo_repos = state.repo_index.get("cargo", &state.storage).await;
let pypi_repos = state.repo_index.get("pypi", &state.storage).await;
let registry_stats = get_registry_stats(&state.storage).await;
// Calculate sizes from cached index
let docker_size: u64 = docker_repos.iter().map(|r| r.size).sum();
let maven_size: u64 = maven_repos.iter().map(|r| r.size).sum();
let npm_size: u64 = npm_repos.iter().map(|r| r.size).sum();
let cargo_size: u64 = cargo_repos.iter().map(|r| r.size).sum();
let pypi_size: u64 = pypi_repos.iter().map(|r| r.size).sum();
let total_storage = docker_size + maven_size + npm_size + cargo_size + pypi_size;
// Calculate total storage size
let all_keys = state.storage.list("").await;
let mut total_storage: u64 = 0;
let mut docker_size: u64 = 0;
let mut maven_size: u64 = 0;
let mut npm_size: u64 = 0;
let mut cargo_size: u64 = 0;
let mut pypi_size: u64 = 0;
let total_artifacts = docker_repos.len()
+ maven_repos.len()
+ npm_repos.len()
+ cargo_repos.len()
+ pypi_repos.len();
for key in &all_keys {
if let Some(meta) = state.storage.stat(key).await {
total_storage += meta.size;
if key.starts_with("docker/") {
docker_size += meta.size;
} else if key.starts_with("maven/") {
maven_size += meta.size;
} else if key.starts_with("npm/") {
npm_size += meta.size;
} else if key.starts_with("cargo/") {
cargo_size += meta.size;
} else if key.starts_with("pypi/") {
pypi_size += meta.size;
}
}
}
let total_artifacts = registry_stats.docker
+ registry_stats.maven
+ registry_stats.npm
+ registry_stats.cargo
+ registry_stats.pypi;
let global_stats = GlobalStats {
downloads: state.metrics.downloads.load(Ordering::Relaxed),
@@ -158,35 +162,35 @@ pub async fn api_dashboard(State(state): State<Arc<AppState>>) -> Json<Dashboard
let registry_card_stats = vec![
RegistryCardStats {
name: "docker".to_string(),
artifact_count: docker_repos.len(),
artifact_count: registry_stats.docker,
downloads: state.metrics.get_registry_downloads("docker"),
uploads: state.metrics.get_registry_uploads("docker"),
size_bytes: docker_size,
},
RegistryCardStats {
name: "maven".to_string(),
artifact_count: maven_repos.len(),
artifact_count: registry_stats.maven,
downloads: state.metrics.get_registry_downloads("maven"),
uploads: state.metrics.get_registry_uploads("maven"),
size_bytes: maven_size,
},
RegistryCardStats {
name: "npm".to_string(),
artifact_count: npm_repos.len(),
artifact_count: registry_stats.npm,
downloads: state.metrics.get_registry_downloads("npm"),
uploads: 0,
size_bytes: npm_size,
},
RegistryCardStats {
name: "cargo".to_string(),
artifact_count: cargo_repos.len(),
artifact_count: registry_stats.cargo,
downloads: state.metrics.get_registry_downloads("cargo"),
uploads: 0,
size_bytes: cargo_size,
},
RegistryCardStats {
name: "pypi".to_string(),
artifact_count: pypi_repos.len(),
artifact_count: registry_stats.pypi,
downloads: state.metrics.get_registry_downloads("pypi"),
uploads: 0,
size_bytes: pypi_size,
@@ -237,8 +241,15 @@ pub async fn api_list(
State(state): State<Arc<AppState>>,
Path(registry_type): Path<String>,
) -> Json<Vec<RepoInfo>> {
let repos = state.repo_index.get(&registry_type, &state.storage).await;
Json((*repos).clone())
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(
@@ -269,13 +280,20 @@ pub async fn api_search(
) -> axum::response::Html<String> {
let query = params.q.unwrap_or_default().to_lowercase();
let repos = state.repo_index.get(&registry_type, &state.storage).await;
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<&RepoInfo> = if query.is_empty() {
repos.iter().collect()
let filtered: Vec<_> = if query.is_empty() {
repos
} else {
repos
.iter()
.into_iter()
.filter(|r| r.name.to_lowercase().contains(&query))
.collect()
};
@@ -320,9 +338,7 @@ pub async fn api_search(
}
// ============ Data Fetching Functions ============
// NOTE: Legacy functions below - kept for reference, will be removed in future cleanup
#[allow(dead_code)]
pub async fn get_registry_stats(storage: &Storage) -> RegistryStats {
let all_keys = storage.list("").await;
@@ -374,18 +390,12 @@ pub async fn get_registry_stats(storage: &Storage) -> RegistryStats {
}
}
#[allow(dead_code)]
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 {
// Skip .meta.json files
if key.ends_with(".meta.json") {
continue;
}
if let Some(rest) = key.strip_prefix("docker/") {
let parts: Vec<_> = rest.split('/').collect();
if parts.len() >= 3 {
@@ -402,35 +412,10 @@ pub async fn get_docker_repos(storage: &Storage) -> Vec<RepoInfo> {
)
});
if parts[1] == "manifests" && key.ends_with(".json") {
if parts[1] == "manifests" {
entry.0.versions += 1;
// Parse manifest to get actual image size (config + layers)
if let Ok(manifest_data) = storage.get(key).await {
if let Ok(manifest) =
serde_json::from_slice::<serde_json::Value>(&manifest_data)
{
let config_size = manifest
.get("config")
.and_then(|c| c.get("size"))
.and_then(|s| s.as_u64())
.unwrap_or(0);
let layers_size: u64 = manifest
.get("layers")
.and_then(|l| l.as_array())
.map(|layers| {
layers
.iter()
.filter_map(|l| l.get("size").and_then(|s| s.as_u64()))
.sum()
})
.unwrap_or(0);
entry.0.size += config_size + layers_size;
}
}
// Update timestamp
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);
@@ -485,37 +470,11 @@ pub async fn get_docker_detail(state: &AppState, name: &str) -> DockerDetail {
"N/A".to_string()
};
// Calculate size from manifest layers (config + layers)
// Use size from metadata if available, otherwise from file
let size = if metadata.size_bytes > 0 {
metadata.size_bytes
} else {
// Parse manifest to get actual image size
if let Ok(manifest_data) = state.storage.get(key).await {
if let Ok(manifest) =
serde_json::from_slice::<serde_json::Value>(&manifest_data)
{
let config_size = manifest
.get("config")
.and_then(|c| c.get("size"))
.and_then(|s| s.as_u64())
.unwrap_or(0);
let layers_size: u64 = manifest
.get("layers")
.and_then(|l| l.as_array())
.map(|layers| {
layers
.iter()
.filter_map(|l| l.get("size").and_then(|s| s.as_u64()))
.sum()
})
.unwrap_or(0);
config_size + layers_size
} else {
0
}
} else {
0
}
state.storage.stat(key).await.map(|m| m.size).unwrap_or(0)
};
// Format last_pulled
@@ -553,7 +512,6 @@ pub async fn get_docker_detail(state: &AppState, name: &str) -> DockerDetail {
DockerDetail { tags }
}
#[allow(dead_code)]
pub async fn get_maven_repos(storage: &Storage) -> Vec<RepoInfo> {
let keys = storage.list("maven/").await;
@@ -613,125 +571,75 @@ pub async fn get_maven_detail(storage: &Storage, path: &str) -> MavenDetail {
MavenDetail { artifacts }
}
#[allow(dead_code)]
pub async fn get_npm_packages(storage: &Storage) -> Vec<RepoInfo> {
let keys = storage.list("npm/").await;
let mut packages: HashMap<String, RepoInfo> = HashMap::new();
let mut packages: HashMap<String, (RepoInfo, u64)> = HashMap::new();
// Find all metadata.json files
for key in &keys {
if key.ends_with("/metadata.json") {
if let Some(name) = key
.strip_prefix("npm/")
.and_then(|s| s.strip_suffix("/metadata.json"))
{
// Parse metadata to get version count and info
if let Ok(data) = storage.get(key).await {
if let Ok(metadata) = serde_json::from_slice::<serde_json::Value>(&data) {
let versions_count = metadata
.get("versions")
.and_then(|v| v.as_object())
.map(|v| v.len())
.unwrap_or(0);
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,
)
});
// Calculate total size from dist.unpackedSize or estimate
let total_size: u64 = metadata
.get("versions")
.and_then(|v| v.as_object())
.map(|versions| {
versions
.values()
.filter_map(|v| {
v.get("dist")
.and_then(|d| d.get("unpackedSize"))
.and_then(|s| s.as_u64())
})
.sum()
})
.unwrap_or(0);
// Get latest version time for "updated"
let updated = metadata
.get("time")
.and_then(|t| t.get("modified"))
.and_then(|m| m.as_str())
.map(|s| s[..10].to_string()) // Take just date part
.unwrap_or_else(|| "N/A".to_string());
packages.insert(
name.to_string(),
RepoInfo {
name: name.to_string(),
versions: versions_count,
size: total_size,
updated,
},
);
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().collect();
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 metadata_key = format!("npm/{}/metadata.json", name);
let prefix = format!("npm/{}/tarballs/", name);
let keys = storage.list(&prefix).await;
let mut versions = Vec::new();
// Parse metadata.json for version info
if let Ok(data) = storage.get(&metadata_key).await {
if let Ok(metadata) = serde_json::from_slice::<serde_json::Value>(&data) {
if let Some(versions_obj) = metadata.get("versions").and_then(|v| v.as_object()) {
let time_obj = metadata.get("time").and_then(|t| t.as_object());
for (version, info) in versions_obj {
let size = info
.get("dist")
.and_then(|d| d.get("unpackedSize"))
.and_then(|s| s.as_u64())
.unwrap_or(0);
let published = time_obj
.and_then(|t| t.get(version))
.and_then(|p| p.as_str())
.map(|s| s[..10].to_string())
.unwrap_or_else(|| "N/A".to_string());
versions.push(VersionInfo {
version: version.clone(),
size,
published,
});
}
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,
});
}
}
}
// Sort by version (semver-like, newest first)
versions.sort_by(|a, b| {
let a_parts: Vec<u32> = a
.version
.split('.')
.filter_map(|s| s.parse().ok())
.collect();
let b_parts: Vec<u32> = b
.version
.split('.')
.filter_map(|s| s.parse().ok())
.collect();
b_parts.cmp(&a_parts)
});
PackageDetail { versions }
}
#[allow(dead_code)]
pub async fn get_cargo_crates(storage: &Storage) -> Vec<RepoInfo> {
let keys = storage.list("cargo/").await;
@@ -799,7 +707,6 @@ pub async fn get_cargo_detail(storage: &Storage, name: &str) -> PackageDetail {
PackageDetail { versions }
}
#[allow(dead_code)]
pub async fn get_pypi_packages(storage: &Storage) -> Vec<RepoInfo> {
let keys = storage.list("pypi/").await;

View File

@@ -1,6 +1,3 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
use super::i18n::{get_translations, Lang, Translations};
/// Application version from Cargo.toml
@@ -140,7 +137,7 @@ fn sidebar_dark(active_page: Option<&str>, t: &Translations) -> String {
<div id="sidebar" class="fixed md:static inset-y-0 left-0 z-50 w-64 bg-slate-800 text-white flex flex-col transform -translate-x-full md:translate-x-0 transition-transform duration-200 ease-in-out">
<div class="h-16 flex items-center justify-between px-6 border-b border-slate-700">
<div class="flex items-center">
<span class="text-xl font-bold tracking-tight">N<span class="inline-block w-4 h-4 rounded-full border-2 border-current align-middle mx-px"></span>RA</span>
<span class="text-2xl font-bold tracking-tight">N<span class="inline-block w-5 h-5 rounded-full border-2 border-current align-middle relative -top-0.5 mx-0.5"></span>RA</span>
</div>
<button onclick="toggleSidebar()" class="md:hidden p-1 rounded-lg hover:bg-slate-700">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">

View File

@@ -1,6 +1,3 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
/// Internationalization support for the UI
use serde::{Deserialize, Serialize};

File diff suppressed because one or more lines are too long

View File

@@ -1,13 +1,9 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
mod api;
pub mod components;
mod components;
pub mod i18n;
mod logo;
mod templates;
use crate::repo_index::paginate;
use crate::AppState;
use axum::{
extract::{Path, Query, State},
@@ -26,15 +22,6 @@ struct LangQuery {
lang: Option<String>,
}
#[derive(Debug, serde::Deserialize)]
struct ListQuery {
lang: Option<String>,
page: Option<usize>,
limit: Option<usize>,
}
const DEFAULT_PAGE_SIZE: usize = 50;
fn extract_lang(query: &Query<LangQuery>, cookie_header: Option<&str>) -> Lang {
// Priority: query param > cookie > default
if let Some(ref lang) = query.lang {
@@ -54,23 +41,6 @@ fn extract_lang(query: &Query<LangQuery>, cookie_header: Option<&str>) -> Lang {
Lang::default()
}
fn extract_lang_from_list(query: &ListQuery, cookie_header: Option<&str>) -> Lang {
if let Some(ref lang) = query.lang {
return Lang::from_str(lang);
}
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<Arc<AppState>> {
Router::new()
// UI Pages
@@ -112,23 +82,18 @@ async fn dashboard(
// Docker pages
async fn docker_list(
State(state): State<Arc<AppState>>,
Query(query): Query<ListQuery>,
Query(query): Query<LangQuery>,
headers: axum::http::HeaderMap,
) -> impl IntoResponse {
let lang = extract_lang_from_list(&query, headers.get("cookie").and_then(|v| v.to_str().ok()));
let page = query.page.unwrap_or(1).max(1);
let limit = query.limit.unwrap_or(DEFAULT_PAGE_SIZE).min(100);
let all_repos = state.repo_index.get("docker", &state.storage).await;
let (repos, total) = paginate(&all_repos, page, limit);
Html(render_registry_list_paginated(
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,
page,
limit,
total,
lang,
))
}
@@ -150,23 +115,18 @@ async fn docker_detail(
// Maven pages
async fn maven_list(
State(state): State<Arc<AppState>>,
Query(query): Query<ListQuery>,
Query(query): Query<LangQuery>,
headers: axum::http::HeaderMap,
) -> impl IntoResponse {
let lang = extract_lang_from_list(&query, headers.get("cookie").and_then(|v| v.to_str().ok()));
let page = query.page.unwrap_or(1).max(1);
let limit = query.limit.unwrap_or(DEFAULT_PAGE_SIZE).min(100);
let all_repos = state.repo_index.get("maven", &state.storage).await;
let (repos, total) = paginate(&all_repos, page, limit);
Html(render_registry_list_paginated(
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,
page,
limit,
total,
lang,
))
}
@@ -188,25 +148,15 @@ async fn maven_detail(
// npm pages
async fn npm_list(
State(state): State<Arc<AppState>>,
Query(query): Query<ListQuery>,
Query(query): Query<LangQuery>,
headers: axum::http::HeaderMap,
) -> impl IntoResponse {
let lang = extract_lang_from_list(&query, headers.get("cookie").and_then(|v| v.to_str().ok()));
let page = query.page.unwrap_or(1).max(1);
let limit = query.limit.unwrap_or(DEFAULT_PAGE_SIZE).min(100);
let all_packages = state.repo_index.get("npm", &state.storage).await;
let (packages, total) = paginate(&all_packages, page, limit);
Html(render_registry_list_paginated(
"npm",
"npm Registry",
&packages,
page,
limit,
total,
lang,
))
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, lang))
}
async fn npm_detail(
@@ -226,23 +176,18 @@ async fn npm_detail(
// Cargo pages
async fn cargo_list(
State(state): State<Arc<AppState>>,
Query(query): Query<ListQuery>,
Query(query): Query<LangQuery>,
headers: axum::http::HeaderMap,
) -> impl IntoResponse {
let lang = extract_lang_from_list(&query, headers.get("cookie").and_then(|v| v.to_str().ok()));
let page = query.page.unwrap_or(1).max(1);
let limit = query.limit.unwrap_or(DEFAULT_PAGE_SIZE).min(100);
let all_crates = state.repo_index.get("cargo", &state.storage).await;
let (crates, total) = paginate(&all_crates, page, limit);
Html(render_registry_list_paginated(
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,
page,
limit,
total,
lang,
))
}
@@ -264,23 +209,18 @@ async fn cargo_detail(
// PyPI pages
async fn pypi_list(
State(state): State<Arc<AppState>>,
Query(query): Query<ListQuery>,
Query(query): Query<LangQuery>,
headers: axum::http::HeaderMap,
) -> impl IntoResponse {
let lang = extract_lang_from_list(&query, headers.get("cookie").and_then(|v| v.to_str().ok()));
let page = query.page.unwrap_or(1).max(1);
let limit = query.limit.unwrap_or(DEFAULT_PAGE_SIZE).min(100);
let all_packages = state.repo_index.get("pypi", &state.storage).await;
let (packages, total) = paginate(&all_packages, page, limit);
Html(render_registry_list_paginated(
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,
page,
limit,
total,
lang,
))
}

View File

@@ -1,10 +1,6 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
use super::api::{DashboardResponse, DockerDetail, MavenDetail, PackageDetail};
use super::api::{DashboardResponse, DockerDetail, MavenDetail, PackageDetail, RepoInfo};
use super::components::*;
use super::i18n::{get_translations, Lang};
use crate::repo_index::RepoInfo;
/// Renders the main dashboard page with dark theme
pub fn render_dashboard(data: &DashboardResponse, lang: Lang) -> String {
@@ -167,7 +163,6 @@ fn format_relative_time(timestamp: &chrono::DateTime<chrono::Utc>) -> String {
}
/// Renders a registry list page (docker, maven, npm, cargo, pypi)
#[allow(dead_code)]
pub fn render_registry_list(
registry_type: &str,
title: &str,
@@ -278,220 +273,6 @@ pub fn render_registry_list(
layout_dark(title, &content, Some(registry_type), "", lang)
}
/// Renders a registry list page with pagination
pub fn render_registry_list_paginated(
registry_type: &str,
title: &str,
repos: &[RepoInfo],
page: usize,
limit: usize,
total: usize,
lang: Lang,
) -> String {
let t = get_translations(lang);
let icon = get_registry_icon(registry_type);
let table_rows = if repos.is_empty() && page == 1 {
format!(
r##"<tr><td colspan="4" class="px-6 py-12 text-center text-slate-500">
<div class="text-4xl mb-2">📭</div>
<div>{}</div>
<div class="text-sm mt-1">{}</div>
</td></tr>"##,
t.no_repos_found, t.push_first_artifact
)
} else 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 more items on this page</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-700 cursor-pointer" onclick="window.location='{}'">
<td class="px-6 py-4">
<a href="{}" class="text-blue-400 hover:text-blue-300 font-medium">{}</a>
</td>
<td class="px-6 py-4 text-slate-400">{}</td>
<td class="px-6 py-4 text-slate-400">{}</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" => t.tags,
_ => t.versions,
};
// Pagination
let total_pages = total.div_ceil(limit);
let start_item = if total == 0 {
0
} else {
(page - 1) * limit + 1
};
let end_item = (start_item + repos.len()).saturating_sub(1);
let pagination = if total_pages > 1 {
let mut pages_html = String::new();
// Previous button
if page > 1 {
pages_html.push_str(&format!(
r##"<a href="/ui/{}?page={}&limit={}" class="px-3 py-1 rounded bg-slate-700 hover:bg-slate-600 text-slate-300">←</a>"##,
registry_type, page - 1, limit
));
} else {
pages_html.push_str(r##"<span class="px-3 py-1 rounded bg-slate-800 text-slate-600 cursor-not-allowed">←</span>"##);
}
// Page numbers (show max 7 pages around current)
let start_page = if page <= 4 { 1 } else { page - 3 };
let end_page = (start_page + 6).min(total_pages);
if start_page > 1 {
pages_html.push_str(&format!(
r##"<a href="/ui/{}?page=1&limit={}" class="px-3 py-1 rounded hover:bg-slate-700 text-slate-400">1</a>"##,
registry_type, limit
));
if start_page > 2 {
pages_html.push_str(r##"<span class="px-2 text-slate-600">...</span>"##);
}
}
for p in start_page..=end_page {
if p == page {
pages_html.push_str(&format!(
r##"<span class="px-3 py-1 rounded bg-blue-600 text-white font-medium">{}</span>"##,
p
));
} else {
pages_html.push_str(&format!(
r##"<a href="/ui/{}?page={}&limit={}" class="px-3 py-1 rounded hover:bg-slate-700 text-slate-400">{}</a>"##,
registry_type, p, limit, p
));
}
}
if end_page < total_pages {
if end_page < total_pages - 1 {
pages_html.push_str(r##"<span class="px-2 text-slate-600">...</span>"##);
}
pages_html.push_str(&format!(
r##"<a href="/ui/{}?page={}&limit={}" class="px-3 py-1 rounded hover:bg-slate-700 text-slate-400">{}</a>"##,
registry_type, total_pages, limit, total_pages
));
}
// Next button
if page < total_pages {
pages_html.push_str(&format!(
r##"<a href="/ui/{}?page={}&limit={}" class="px-3 py-1 rounded bg-slate-700 hover:bg-slate-600 text-slate-300">→</a>"##,
registry_type, page + 1, limit
));
} else {
pages_html.push_str(r##"<span class="px-3 py-1 rounded bg-slate-800 text-slate-600 cursor-not-allowed">→</span>"##);
}
format!(
r##"
<div class="mt-4 flex items-center justify-between">
<div class="text-sm text-slate-500">
Showing {}-{} of {} items
</div>
<div class="flex items-center gap-1">
{}
</div>
</div>
"##,
start_item, end_item, total, pages_html
)
} else if total > 0 {
format!(
r##"<div class="mt-4 text-sm text-slate-500">Showing all {} items</div>"##,
total
)
} else {
String::new()
};
let content = format!(
r##"
<div class="mb-6 flex items-center justify-between">
<div class="flex items-center">
<svg class="w-10 h-10 mr-3 text-slate-400" fill="currentColor" viewBox="0 0 24 24">{}</svg>
<div>
<h1 class="text-2xl font-bold text-slate-200">{}</h1>
<p class="text-slate-500">{} {}</p>
</div>
</div>
<div class="flex items-center gap-4">
<div class="relative">
<input type="text"
placeholder="{}"
class="pl-10 pr-4 py-2 bg-slate-800 border border-slate-600 text-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent placeholder-slate-500"
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-500" 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-[#1e293b] rounded-lg shadow-sm border border-slate-700 overflow-hidden">
<table class="w-full">
<thead class="bg-slate-800 border-b border-slate-700">
<tr>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-400 uppercase tracking-wider">{}</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-400 uppercase tracking-wider">{}</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-400 uppercase tracking-wider">{}</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-400 uppercase tracking-wider">{}</th>
</tr>
</thead>
<tbody id="repo-table-body" class="divide-y divide-slate-700">
{}
</tbody>
</table>
</div>
{}
"##,
icon,
title,
total,
t.repositories,
t.search_placeholder,
registry_type,
t.name,
version_label,
t.size,
t.updated,
table_rows,
pagination
);
layout_dark(title, &content, Some(registry_type), "", lang)
}
/// Renders Docker image detail page
pub fn render_docker_detail(name: &str, detail: &DockerDetail, lang: Lang) -> String {
let _t = get_translations(lang);

View File

@@ -1,6 +1,3 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
#![allow(dead_code)]
//! Input validation for artifact registry paths and identifiers
//!

View File

@@ -19,10 +19,10 @@ serde.workspace = true
serde_json.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
toml = "1.0"
toml = "0.8"
uuid = { version = "1", features = ["v4"] }
sha2 = "0.10"
base64 = "0.22"
httpdate = "1"
chrono = { version = "0.4", features = ["serde"] }
quick-xml = { version = "0.39", features = ["serialize"] }
quick-xml = { version = "0.31", features = ["serialize"] }

View File

@@ -1,6 +1,3 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
use serde::{Deserialize, Serialize};
use std::fs;

View File

@@ -1,6 +1,3 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
mod config;
use axum::extract::DefaultBodyLimit;