mirror of
https://github.com/getnora-io/nora.git
synced 2026-04-13 01:30:32 +00:00
Compare commits
159 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bbff337b4c | |||
| a73335c549 | |||
| ad6aba46b2 | |||
| 095270d113 | |||
| 769f5fb01d | |||
| 53884e143b | |||
| 0eb26f24f7 | |||
| fa962b2d6e | |||
| a1da4fff1e | |||
| 868c4feca7 | |||
| 5b4cba1392 | |||
| ad890be56a | |||
| 3b9ea37b0e | |||
| 233b83f902 | |||
| d886426957 | |||
| 52c2443543 | |||
| 26d30b622d | |||
| 272898f43c | |||
| 61de6c6ddd | |||
| b80c7c5160 | |||
| 68089b2bbf | |||
| af411a2bf4 | |||
| 96ccd16879 | |||
| 6582000789 | |||
| 070774ac94 | |||
| 058fc41f1c | |||
| 7f5a3c7c8a | |||
| 5b57cc5913 | |||
| aa844d851d | |||
| 8569de23d5 | |||
|
|
9349b93757 | ||
|
|
69080dfd90 | ||
|
|
ae799aed94 | ||
|
|
95c6e403a8 | ||
|
|
2c886040d7 | ||
|
|
9ab6ccc594 | ||
|
|
679b36b986 | ||
|
|
da8c473e02 | ||
|
|
3dc8b81261 | ||
| 7502c583d0 | |||
| a9455c35b9 | |||
| 8278297b4a | |||
| 8da4c4278a | |||
| 99c1f9b5ec | |||
| 07de85d4f8 | |||
| 4c3a9f6bd5 | |||
| 402d2321ef | |||
| f560e5f76b | |||
| e34032d08f | |||
| 03a3bf9197 | |||
| 6c5f0dda30 | |||
| fb058302c8 | |||
| 79565aec47 | |||
| 58a484d805 | |||
| 45c3e276dc | |||
|
|
f4e53b85dd | ||
| 05d89d5153 | |||
| b149f7ebd4 | |||
| 5254e2a54a | |||
| 8783d1dc4b | |||
|
|
4c05df2359 | ||
| 7f8e3cfe68 | |||
|
|
13f33e8919 | ||
|
|
7454ff2e03 | ||
|
|
5ffb5a9be3 | ||
|
|
c8793a4b60 | ||
|
|
fd4a7b0b0f | ||
|
|
7af1e7462c | ||
|
|
de1a188fa7 | ||
|
|
36d0749bb3 | ||
| fb0f80ac5a | |||
| 161d7f706a | |||
| e4e38e3aab | |||
| b153bc0c5b | |||
| d76383c701 | |||
| d161c2f645 | |||
| c7f9d5c036 | |||
| b41bfd9a88 | |||
| 3e3070a401 | |||
| 3868b16ea4 | |||
| 3a6d3eeb9a | |||
| dd29707395 | |||
| e7a6a652af | |||
| 4ad802ce2f | |||
|
|
04c806b659 | ||
|
|
50a5395a87 | ||
|
|
bcd172f23f | ||
|
|
a5a7c4f8be | ||
|
|
2c7c497c30 | ||
|
|
6b6f88ab9c | ||
|
|
1255e3227b | ||
|
|
aabd0b76fb | ||
| ac14405af3 | |||
| 5f385dce45 | |||
| 761e08f168 | |||
| eb4f82df07 | |||
| 9784ad1813 | |||
| fc1288820d | |||
| a17a75161b | |||
| 0b3ef3ab96 | |||
| 99e290d30c | |||
| f74b781d1f | |||
| 05c765627f | |||
| 1813546bee | |||
| 196c313f20 | |||
| aece2d739d | |||
| b7e11da2da | |||
| dd3813edff | |||
| adade10c67 | |||
| 6ad710ff32 | |||
| 037204a3eb | |||
| 1e01d4df56 | |||
| ab5ed3f488 | |||
| 8336166e0e | |||
| 42e71b9195 | |||
| ffac4f0286 | |||
| 078ef94153 | |||
| 94c92e5bc3 | |||
| 7326f9b0e2 | |||
| a2cb7c639c | |||
| eb77060114 | |||
| 8da3eab734 | |||
| f82e252e39 | |||
| 7763b85b94 | |||
| 47a3690384 | |||
| a9125e6287 | |||
| 3f0b84c831 | |||
| ce30c5b57d | |||
| f76c6d6075 | |||
| e6bd9b6ead | |||
| cf55a19acf | |||
| e33da13dc7 | |||
| bbdefff07c | |||
| b29a0309d4 | |||
| 38003db6f8 | |||
| dab3ee805e | |||
| ac4020d34f | |||
| 5fc4237ac5 | |||
| ee4e01467a | |||
| 3265e217e7 | |||
| cf9feee5b2 | |||
| 0a97b00278 | |||
| d162e96841 | |||
| 4aa7529aa4 | |||
| 411bc75e5e | |||
| d2fec9ad15 | |||
| 00910dd69e | |||
| 4332b74636 | |||
| 86130a80ce | |||
| 2f86b4852a | |||
| 08eea07cfe | |||
| a13d7b8cfc | |||
| f1cda800a2 | |||
| da219dc794 | |||
| 1152308f6c | |||
| 6c53b2da84 | |||
| c7098a4aed | |||
| 937266a4c7 | |||
| 00fbd20112 |
142
.githooks/pre-commit
Executable file
142
.githooks/pre-commit
Executable file
@@ -0,0 +1,142 @@
|
|||||||
|
#!/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
|
||||||
16
.github/dependabot.yml
vendored
Normal file
16
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
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]
|
||||||
150
.github/workflows/ci.yml
vendored
150
.github/workflows/ci.yml
vendored
@@ -11,7 +11,7 @@ jobs:
|
|||||||
name: Test
|
name: Test
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Install Rust
|
- name: Install Rust
|
||||||
uses: dtolnay/rust-toolchain@stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
@@ -27,3 +27,151 @@ jobs:
|
|||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: cargo test --package nora-registry
|
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
|
||||||
|
|
||||||
|
# ── CVE in Rust dependencies ────────────────────────────────────────────
|
||||||
|
- name: Install cargo-audit
|
||||||
|
run: cargo install cargo-audit --locked
|
||||||
|
|
||||||
|
- name: cargo audit — RustSec advisory database
|
||||||
|
run: cargo audit --ignore RUSTSEC-2025-0119 # known: number_prefix via indicatif
|
||||||
|
|
||||||
|
# ── 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.35.0
|
||||||
|
with:
|
||||||
|
scan-type: fs
|
||||||
|
scan-ref: .
|
||||||
|
format: sarif
|
||||||
|
output: trivy-fs.sarif
|
||||||
|
severity: HIGH,CRITICAL
|
||||||
|
exit-code: 1 # block pipeline on HIGH/CRITICAL vulnerabilities
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
|
integration:
|
||||||
|
name: Integration
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: test
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Install Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Cache cargo
|
||||||
|
uses: Swatinem/rust-cache@v2
|
||||||
|
|
||||||
|
- name: Build NORA
|
||||||
|
run: cargo build --release --package nora-registry
|
||||||
|
|
||||||
|
# -- Start NORA --
|
||||||
|
- name: Start NORA
|
||||||
|
run: |
|
||||||
|
NORA_STORAGE_PATH=/tmp/nora-data ./target/release/nora &
|
||||||
|
for i in $(seq 1 15); do
|
||||||
|
curl -sf http://localhost:4000/health && break || sleep 2
|
||||||
|
done
|
||||||
|
curl -sf http://localhost:4000/health | jq .
|
||||||
|
|
||||||
|
# -- Docker push/pull --
|
||||||
|
- name: Configure Docker for insecure registry
|
||||||
|
run: |
|
||||||
|
echo '{"insecure-registries": ["localhost:4000"]}' | sudo tee /etc/docker/daemon.json
|
||||||
|
sudo systemctl restart docker
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
- name: Docker — push and pull image
|
||||||
|
run: |
|
||||||
|
docker pull alpine:3.20
|
||||||
|
docker tag alpine:3.20 localhost:4000/test/alpine:integration
|
||||||
|
docker push localhost:4000/test/alpine:integration
|
||||||
|
docker rmi localhost:4000/test/alpine:integration
|
||||||
|
docker pull localhost:4000/test/alpine:integration
|
||||||
|
echo "Docker push/pull OK"
|
||||||
|
|
||||||
|
- name: Docker — verify catalog and tags
|
||||||
|
run: |
|
||||||
|
curl -sf http://localhost:4000/v2/_catalog | jq .
|
||||||
|
curl -sf http://localhost:4000/v2/test/alpine/tags/list | jq .
|
||||||
|
|
||||||
|
# -- npm (read-only proxy, no publish support yet) --
|
||||||
|
- name: npm — verify registry endpoint
|
||||||
|
run: |
|
||||||
|
STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:4000/npm/lodash)
|
||||||
|
echo "npm endpoint returned: $STATUS"
|
||||||
|
[ "$STATUS" != "000" ] && echo "npm endpoint OK" || (echo "npm endpoint unreachable" && exit 1)
|
||||||
|
|
||||||
|
# -- Maven deploy/download --
|
||||||
|
- name: Maven — deploy and download artifact
|
||||||
|
run: |
|
||||||
|
echo "test-artifact-content-$(date +%s)" > /tmp/test-artifact.jar
|
||||||
|
CHECKSUM=$(sha256sum /tmp/test-artifact.jar | cut -d' ' -f1)
|
||||||
|
curl -sf -X PUT --data-binary @/tmp/test-artifact.jar http://localhost:4000/maven2/com/example/test-lib/1.0.0/test-lib-1.0.0.jar
|
||||||
|
curl -sf -o /tmp/downloaded.jar http://localhost:4000/maven2/com/example/test-lib/1.0.0/test-lib-1.0.0.jar
|
||||||
|
DOWNLOAD_CHECKSUM=$(sha256sum /tmp/downloaded.jar | cut -d' ' -f1)
|
||||||
|
[ "$CHECKSUM" = "$DOWNLOAD_CHECKSUM" ] && echo "Maven deploy/download OK" || (echo "Checksum mismatch!" && exit 1)
|
||||||
|
|
||||||
|
# -- PyPI (read-only proxy, no upload support yet) --
|
||||||
|
- name: PyPI — verify simple index
|
||||||
|
run: |
|
||||||
|
STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:4000/simple/)
|
||||||
|
echo "PyPI simple index returned: $STATUS"
|
||||||
|
[ "$STATUS" = "200" ] && echo "PyPI endpoint OK" || (echo "Expected 200, got $STATUS" && exit 1)
|
||||||
|
|
||||||
|
# -- Cargo (read-only proxy, no publish support yet) --
|
||||||
|
- name: Cargo — verify registry API responds
|
||||||
|
run: |
|
||||||
|
STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:4000/cargo/api/v1/crates/serde)
|
||||||
|
echo "Cargo API returned: $STATUS"
|
||||||
|
[ "$STATUS" != "000" ] && echo "Cargo endpoint OK" || (echo "Cargo endpoint unreachable" && exit 1)
|
||||||
|
|
||||||
|
# -- API checks --
|
||||||
|
- name: API — health, ready, metrics
|
||||||
|
run: |
|
||||||
|
curl -sf http://localhost:4000/health | jq .status
|
||||||
|
curl -sf http://localhost:4000/ready
|
||||||
|
curl -sf http://localhost:4000/metrics | head -5
|
||||||
|
echo "API checks OK"
|
||||||
|
|
||||||
|
- name: Stop NORA
|
||||||
|
if: always()
|
||||||
|
run: pkill nora || true
|
||||||
|
|||||||
236
.github/workflows/release.yml
vendored
236
.github/workflows/release.yml
vendored
@@ -6,75 +6,265 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: ghcr.io
|
REGISTRY: ghcr.io
|
||||||
|
NORA: localhost:5000
|
||||||
IMAGE_NAME: ${{ github.repository }}
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
name: Build & Push
|
name: Build & Push
|
||||||
runs-on: self-hosted
|
runs-on: [self-hosted, nora]
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up Rust
|
||||||
uses: docker/setup-qemu-action@v3
|
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 Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
|
with:
|
||||||
|
driver-opts: network=host
|
||||||
|
|
||||||
- name: Log in to Container Registry
|
- name: Log in to GitHub Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Extract metadata
|
# ── Alpine ───────────────────────────────────────────────────────────────
|
||||||
id: meta
|
- name: Extract metadata (alpine)
|
||||||
uses: docker/metadata-action@v5
|
id: meta-alpine
|
||||||
|
uses: docker/metadata-action@v6
|
||||||
with:
|
with:
|
||||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
images: |
|
||||||
|
${{ env.NORA }}/${{ env.IMAGE_NAME }}
|
||||||
|
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
tags: |
|
tags: |
|
||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
type=semver,pattern={{major}}
|
|
||||||
type=raw,value=latest
|
type=raw,value=latest
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push (alpine)
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
|
file: Dockerfile
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta-alpine.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta-alpine.outputs.labels }}
|
||||||
cache-from: type=gha
|
cache-from: type=registry,ref=${{ env.NORA }}/${{ env.IMAGE_NAME }}-cache:alpine,ignore-error=true
|
||||||
cache-to: type=gha,mode=max
|
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@v6
|
||||||
|
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@v7
|
||||||
|
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,ignore-error=true
|
||||||
|
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@v6
|
||||||
|
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@v7
|
||||||
|
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,ignore-error=true
|
||||||
|
cache-to: type=registry,ref=${{ env.NORA }}/${{ env.IMAGE_NAME }}-cache:astra,mode=max
|
||||||
|
|
||||||
|
# ── Smoke test ──────────────────────────────────────────────────────────
|
||||||
|
- name: Smoke test — verify alpine image starts and responds
|
||||||
|
run: |
|
||||||
|
docker rm -f nora-smoke 2>/dev/null || true
|
||||||
|
docker run --rm -d --name nora-smoke -p 5555:4000 -e NORA_HOST=0.0.0.0 \
|
||||||
|
${{ env.NORA }}/${{ env.IMAGE_NAME }}:latest
|
||||||
|
for i in $(seq 1 10); do
|
||||||
|
curl -sf http://localhost:5555/health && break || sleep 2
|
||||||
|
done
|
||||||
|
curl -sf http://localhost:5555/health
|
||||||
|
docker stop nora-smoke
|
||||||
|
|
||||||
|
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.35.0
|
||||||
|
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 }}
|
||||||
|
|
||||||
release:
|
release:
|
||||||
name: GitHub Release
|
name: GitHub Release
|
||||||
runs-on: ubuntu-latest
|
runs-on: [self-hosted, nora]
|
||||||
needs: build
|
needs: [build, scan]
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
packages: read
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- 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
|
||||||
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
generate_release_notes: true
|
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: |
|
body: |
|
||||||
## Docker
|
## 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
|
```bash
|
||||||
docker pull ghcr.io/${{ github.repository }}:${{ github.ref_name }}
|
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
|
## Changelog
|
||||||
|
|
||||||
See [CHANGELOG.md](https://github.com/${{ github.repository }}/blob/main/CHANGELOG.md)
|
See [CHANGELOG.md](https://github.com/${{ github.repository }}/blob/main/CHANGELOG.md)
|
||||||
|
|||||||
15
.gitignore
vendored
15
.gitignore
vendored
@@ -5,3 +5,18 @@ data/
|
|||||||
.env.*
|
.env.*
|
||||||
*.log
|
*.log
|
||||||
internal config
|
internal config
|
||||||
|
|
||||||
|
# Backup files
|
||||||
|
*.bak
|
||||||
|
|
||||||
|
# Internal files
|
||||||
|
SESSION*.md
|
||||||
|
TODO.md
|
||||||
|
ROADMAP*.md
|
||||||
|
docs-site/
|
||||||
|
docs/
|
||||||
|
*.txt
|
||||||
|
|
||||||
|
## Internal files
|
||||||
|
.internal/
|
||||||
|
examples/
|
||||||
|
|||||||
8
.gitleaks.toml
Normal file
8
.gitleaks.toml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# Gitleaks configuration
|
||||||
|
# https://github.com/gitleaks/gitleaks
|
||||||
|
|
||||||
|
[allowlist]
|
||||||
|
description = "Allowlist for false positives"
|
||||||
|
|
||||||
|
# Documentation examples with placeholder credentials
|
||||||
|
commits = ["92155cf6574d89f93ee68503a7b68455ceaa19af"]
|
||||||
442
CHANGELOG.md
442
CHANGELOG.md
@@ -4,6 +4,349 @@ All notable changes to NORA will be documented in this file.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [0.2.29] - 2026-03-15
|
||||||
|
|
||||||
|
### Added / Добавлено
|
||||||
|
- **Upstream Authentication**: All registry proxies now support Basic Auth credentials for private upstream registries
|
||||||
|
- **Аутентификация upstream**: Все прокси реестров теперь поддерживают Basic Auth для приватных upstream-реестров
|
||||||
|
- Docker: `NORA_DOCKER_UPSTREAMS="https://registry.corp.com|user:pass"`
|
||||||
|
- Maven: `NORA_MAVEN_PROXIES="https://nexus.corp.com/maven2|user:pass"`
|
||||||
|
- npm: `NORA_NPM_PROXY_AUTH="user:pass"`
|
||||||
|
- PyPI: `NORA_PYPI_PROXY_AUTH="user:pass"`
|
||||||
|
- **Plaintext credential warning**: NORA logs a warning at startup if credentials are stored in config.toml instead of env vars
|
||||||
|
- **Предупреждение о plaintext credentials**: NORA логирует предупреждение при старте, если credentials хранятся в config.toml вместо переменных окружения
|
||||||
|
|
||||||
|
### Changed / Изменено
|
||||||
|
- Extracted `basic_auth_header()` helper for consistent auth across all protocols
|
||||||
|
- Вынесен хелпер `basic_auth_header()` для единообразной авторизации всех протоколов
|
||||||
|
|
||||||
|
### Removed / Удалено
|
||||||
|
- Removed unused `DockerAuth::fetch_with_auth()` method (dead code cleanup)
|
||||||
|
- Удалён неиспользуемый метод `DockerAuth::fetch_with_auth()` (очистка мёртвого кода)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.2.28] - 2026-03-13
|
||||||
|
|
||||||
|
### Fixed / Исправлено
|
||||||
|
- **docker-compose.yml**: Fixed image reference from `getnora/nora:latest` to `ghcr.io/getnora-io/nora:latest`
|
||||||
|
- **docker-compose.yml**: Исправлена ссылка на образ с `getnora/nora:latest` на `ghcr.io/getnora-io/nora:latest`
|
||||||
|
|
||||||
|
### Documentation / Документация
|
||||||
|
- **Authentication Guide**: Added complete auth setup guide in README — htpasswd, API tokens, RBAC roles, curl examples
|
||||||
|
- **Руководство по аутентификации**: Добавлено полное руководство по настройке auth в README — htpasswd, API-токены, RBAC-роли, примеры curl
|
||||||
|
- **FSTEC builds**: Documented `Dockerfile.astra` and `Dockerfile.redos` purpose in README
|
||||||
|
- **Сборки ФСТЭК**: Документировано назначение `Dockerfile.astra` и `Dockerfile.redos` в README
|
||||||
|
- **TLS / HTTPS**: Added reverse proxy setup guide (Caddy, Nginx) and `insecure-registries` Docker config for internal deployments
|
||||||
|
- **TLS / HTTPS**: Добавлено руководство по настройке reverse proxy (Caddy, Nginx) и конфигурация `insecure-registries` Docker для внутренних инсталляций
|
||||||
|
|
||||||
|
### Removed / Удалено
|
||||||
|
- Removed stale `CHANGELOG.md.bak` from repository
|
||||||
|
- Удалён устаревший `CHANGELOG.md.bak` из репозитория
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.2.27] - 2026-03-03
|
||||||
|
|
||||||
|
### Added / Добавлено
|
||||||
|
- **Configurable body limit**: `NORA_BODY_LIMIT_MB` env var (default: `2048` = 2GB) — replaces hardcoded 100MB limit that caused `413 Payload Too Large` on large Docker image push
|
||||||
|
- **Настраиваемый лимит тела запроса**: переменная `NORA_BODY_LIMIT_MB` (по умолчанию: `2048` = 2GB) — заменяет захардкоженный лимит 100MB, вызывавший `413 Payload Too Large` при push больших Docker-образов
|
||||||
|
- **Docker Delete API**: `DELETE /v2/{name}/manifests/{reference}` and `DELETE /v2/{name}/blobs/{digest}` per Docker Registry V2 spec (returns 202 Accepted)
|
||||||
|
- **Docker Delete API**: `DELETE /v2/{name}/manifests/{reference}` и `DELETE /v2/{name}/blobs/{digest}` по спецификации Docker Registry V2 (возвращает 202 Accepted)
|
||||||
|
- Namespace-qualified DELETE variants (`/v2/{ns}/{name}/...`)
|
||||||
|
- Audit log integration for delete operations
|
||||||
|
|
||||||
|
### Fixed / Исправлено
|
||||||
|
- Docker push of images >100MB no longer fails with 413 error
|
||||||
|
- Push Docker-образов >100MB больше не падает с ошибкой 413
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.2.26] - 2026-03-03
|
||||||
|
|
||||||
|
### Added / Добавлено
|
||||||
|
- **Helm OCI support**: `helm push` / `helm pull` now works out of the box via OCI protocol
|
||||||
|
- **Поддержка Helm OCI**: `helm push` / `helm pull` теперь работают из коробки через OCI протокол
|
||||||
|
- **RBAC**: Token-based role system with three roles — `read`, `write`, `admin` (default: `read`)
|
||||||
|
- **RBAC**: Ролевая система на основе токенов — `read`, `write`, `admin` (по умолчанию: `read`)
|
||||||
|
- **Audit log**: Persistent append-only JSONL audit trail for all registry operations (`{storage}/audit.jsonl`)
|
||||||
|
- **Аудит**: Персистентный append-only JSONL лог всех операций реестра (`{storage}/audit.jsonl`)
|
||||||
|
- **GC command**: `nora gc --dry-run` — garbage collection for orphaned blobs (mark-and-sweep)
|
||||||
|
- **Команда GC**: `nora gc --dry-run` — сборка мусора для осиротевших блобов (mark-and-sweep)
|
||||||
|
|
||||||
|
### Fixed / Исправлено
|
||||||
|
- **Helm OCI pull**: Fixed OCI manifest media type detection — manifests with non-Docker `config.mediaType` now correctly return `application/vnd.oci.image.manifest.v1+json`
|
||||||
|
- **Helm OCI pull**: Исправлено определение media type OCI манифестов — манифесты с не-Docker `config.mediaType` теперь корректно возвращают `application/vnd.oci.image.manifest.v1+json`
|
||||||
|
- **Docker-Content-Digest**: Added missing header in blob upload response (required by Helm OCI client)
|
||||||
|
- **Docker-Content-Digest**: Добавлен отсутствующий заголовок в ответе на загрузку blob (требуется клиентом Helm OCI)
|
||||||
|
|
||||||
|
### Security / Безопасность
|
||||||
|
- Read-only tokens (`role: read`) are now blocked from PUT/POST/DELETE/PATCH operations with HTTP 403
|
||||||
|
- Токены только для чтения (`role: read`) теперь блокируются при PUT/POST/DELETE/PATCH с HTTP 403
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.2.25] - 2026-03-03
|
||||||
|
|
||||||
|
### Fixed / Исправлено
|
||||||
|
- **Rate limiter fix**: Added `NORA_RATE_LIMIT_ENABLED` env var (default: `true`) to disable rate limiting on internal deployments
|
||||||
|
- **Исправление rate limiter**: Добавлена переменная `NORA_RATE_LIMIT_ENABLED` (по умолчанию: `true`) для отключения rate limiting на внутренних инсталляциях
|
||||||
|
- **SmartIpKeyExtractor**: Upload and general routes now use `SmartIpKeyExtractor` (reads `X-Forwarded-For`) instead of `PeerIpKeyExtractor` — fixes 429 errors behind reverse proxy / Docker bridge
|
||||||
|
- **SmartIpKeyExtractor**: Маршруты upload и general теперь используют `SmartIpKeyExtractor` (читает `X-Forwarded-For`) вместо `PeerIpKeyExtractor` — устраняет ошибки 429 за reverse proxy / Docker bridge
|
||||||
|
|
||||||
|
### Dependencies / Зависимости
|
||||||
|
- `clap` 4.5.56 → 4.5.60
|
||||||
|
- `uuid` 1.20.0 → 1.21.0
|
||||||
|
- `tempfile` 3.24.0 → 3.26.0
|
||||||
|
- `bcrypt` 0.17.1 → 0.18.0
|
||||||
|
- `indicatif` 0.17.11 → 0.18.4
|
||||||
|
|
||||||
|
### CI/CD
|
||||||
|
- `actions/checkout` 4 → 6
|
||||||
|
- `actions/upload-artifact` 4 → 7
|
||||||
|
- `softprops/action-gh-release` 1 → 2
|
||||||
|
- `aquasecurity/trivy-action` 0.30.0 → 0.34.2
|
||||||
|
- `docker/build-push-action` 5 → 6
|
||||||
|
- Move scan/release to self-hosted runner with NORA cache
|
||||||
|
- Сканирование/релиз перенесены на self-hosted runner с кэшем через NORA
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [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
|
||||||
|
|
||||||
|
### 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`
|
||||||
|
- Environment variables: `NORA_RATE_LIMIT_{AUTH|UPLOAD|GENERAL}_{RPS|BURST}`
|
||||||
|
|
||||||
|
#### Secrets Provider Architecture
|
||||||
|
- Trait-based secrets management (`SecretsProvider` trait)
|
||||||
|
- ENV provider as default (12-Factor App pattern)
|
||||||
|
- 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
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [0.2.0] - 2026-01-25
|
## [0.2.0] - 2026-01-25
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -52,7 +395,6 @@ All notable changes to NORA will be documented in this file.
|
|||||||
- JSON error responses with request_id support
|
- JSON error responses with request_id support
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- `StorageError` now uses `thiserror` derive macro
|
- `StorageError` now uses `thiserror` derive macro
|
||||||
- `TokenError` now uses `thiserror` derive macro
|
- `TokenError` now uses `thiserror` derive macro
|
||||||
- Storage wrapper validates keys before delegating to backend
|
- Storage wrapper validates keys before delegating to backend
|
||||||
@@ -60,7 +402,6 @@ All notable changes to NORA will be documented in this file.
|
|||||||
- Body size limit set to 100MB default via `DefaultBodyLimit`
|
- Body size limit set to 100MB default via `DefaultBodyLimit`
|
||||||
|
|
||||||
### Dependencies Added
|
### Dependencies Added
|
||||||
|
|
||||||
- `thiserror = "2"` - typed error handling
|
- `thiserror = "2"` - typed error handling
|
||||||
- `tower_governor = "0.8"` - rate limiting
|
- `tower_governor = "0.8"` - rate limiting
|
||||||
- `governor = "0.10"` - rate limiting backend
|
- `governor = "0.10"` - rate limiting backend
|
||||||
@@ -68,7 +409,6 @@ All notable changes to NORA will be documented in this file.
|
|||||||
- `wiremock = "0.6"` (dev) - HTTP mocking for S3 tests
|
- `wiremock = "0.6"` (dev) - HTTP mocking for S3 tests
|
||||||
|
|
||||||
### Files Added
|
### Files Added
|
||||||
|
|
||||||
- `src/validation.rs` - input validation module
|
- `src/validation.rs` - input validation module
|
||||||
- `src/migrate.rs` - storage migration module
|
- `src/migrate.rs` - storage migration module
|
||||||
- `src/error.rs` - application error types
|
- `src/error.rs` - application error types
|
||||||
@@ -80,7 +420,6 @@ All notable changes to NORA will be documented in this file.
|
|||||||
## [0.1.0] - 2026-01-24
|
## [0.1.0] - 2026-01-24
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Multi-protocol support: Docker Registry v2, Maven, npm, Cargo, PyPI
|
- Multi-protocol support: Docker Registry v2, Maven, npm, Cargo, PyPI
|
||||||
- Web UI dashboard
|
- Web UI dashboard
|
||||||
- Swagger UI (`/api-docs`)
|
- Swagger UI (`/api-docs`)
|
||||||
@@ -95,7 +434,6 @@ All notable changes to NORA will be documented in this file.
|
|||||||
- Graceful shutdown (SIGTERM/SIGINT)
|
- Graceful shutdown (SIGTERM/SIGINT)
|
||||||
- Backup/restore commands
|
- Backup/restore commands
|
||||||
|
|
||||||
---
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Журнал изменений (RU)
|
# Журнал изменений (RU)
|
||||||
@@ -104,6 +442,96 @@ 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
|
## [0.2.0] - 2026-01-25
|
||||||
|
|
||||||
### Добавлено
|
### Добавлено
|
||||||
@@ -152,7 +580,6 @@ All notable changes to NORA will be documented in this file.
|
|||||||
- JSON-ответы об ошибках с поддержкой request_id
|
- JSON-ответы об ошибках с поддержкой request_id
|
||||||
|
|
||||||
### Изменено
|
### Изменено
|
||||||
|
|
||||||
- `StorageError` теперь использует макрос `thiserror`
|
- `StorageError` теперь использует макрос `thiserror`
|
||||||
- `TokenError` теперь использует макрос `thiserror`
|
- `TokenError` теперь использует макрос `thiserror`
|
||||||
- Storage wrapper валидирует ключи перед делегированием backend
|
- Storage wrapper валидирует ключи перед делегированием backend
|
||||||
@@ -160,7 +587,6 @@ All notable changes to NORA will be documented in this file.
|
|||||||
- Лимит размера body установлен в 100MB через `DefaultBodyLimit`
|
- Лимит размера body установлен в 100MB через `DefaultBodyLimit`
|
||||||
|
|
||||||
### Добавлены зависимости
|
### Добавлены зависимости
|
||||||
|
|
||||||
- `thiserror = "2"` - типизированная обработка ошибок
|
- `thiserror = "2"` - типизированная обработка ошибок
|
||||||
- `tower_governor = "0.8"` - rate limiting
|
- `tower_governor = "0.8"` - rate limiting
|
||||||
- `governor = "0.10"` - backend для rate limiting
|
- `governor = "0.10"` - backend для rate limiting
|
||||||
@@ -168,7 +594,6 @@ All notable changes to NORA will be documented in this file.
|
|||||||
- `wiremock = "0.6"` (dev) - HTTP-мокирование для S3 тестов
|
- `wiremock = "0.6"` (dev) - HTTP-мокирование для S3 тестов
|
||||||
|
|
||||||
### Добавлены файлы
|
### Добавлены файлы
|
||||||
|
|
||||||
- `src/validation.rs` - модуль валидации ввода
|
- `src/validation.rs` - модуль валидации ввода
|
||||||
- `src/migrate.rs` - модуль миграции хранилища
|
- `src/migrate.rs` - модуль миграции хранилища
|
||||||
- `src/error.rs` - типы ошибок приложения
|
- `src/error.rs` - типы ошибок приложения
|
||||||
@@ -180,7 +605,6 @@ All notable changes to NORA will be documented in this file.
|
|||||||
## [0.1.0] - 2026-01-24
|
## [0.1.0] - 2026-01-24
|
||||||
|
|
||||||
### Добавлено
|
### Добавлено
|
||||||
|
|
||||||
- Мульти-протокольная поддержка: Docker Registry v2, Maven, npm, Cargo, PyPI
|
- Мульти-протокольная поддержка: Docker Registry v2, Maven, npm, Cargo, PyPI
|
||||||
- Web UI дашборд
|
- Web UI дашборд
|
||||||
- Swagger UI (`/api-docs`)
|
- Swagger UI (`/api-docs`)
|
||||||
|
|||||||
131
CONTRIBUTING.md
131
CONTRIBUTING.md
@@ -1,100 +1,71 @@
|
|||||||
# Contributing to NORA
|
# Contributing to NORA
|
||||||
|
|
||||||
Thanks for your interest in contributing to NORA!
|
Thank you for your interest in contributing to NORA!
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
1. **Fork** the repository
|
1. Fork the repository
|
||||||
2. **Clone** your fork:
|
2. Clone your fork: `git clone https://github.com/YOUR_USERNAME/nora.git`
|
||||||
```bash
|
3. Create a branch: `git checkout -b feature/your-feature`
|
||||||
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
|
## Development Setup
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
- Rust 1.75+ (`rustup update`)
|
|
||||||
- Docker (for testing)
|
|
||||||
- Git
|
|
||||||
|
|
||||||
### Build
|
|
||||||
|
|
||||||
```bash
|
```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
|
cargo build
|
||||||
```
|
|
||||||
|
|
||||||
### Run
|
# Run tests
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo run --bin nora
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo test
|
cargo test
|
||||||
cargo clippy
|
|
||||||
cargo fmt --check
|
# Run locally
|
||||||
|
cargo run --bin nora -- serve
|
||||||
```
|
```
|
||||||
|
|
||||||
## 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
|
|
||||||
|
|
||||||
Follow [Conventional Commits](https://www.conventionalcommits.org/):
|
|
||||||
|
|
||||||
- `feat:` - New feature
|
|
||||||
- `fix:` - Bug fix
|
|
||||||
- `docs:` - Documentation
|
|
||||||
- `test:` - Tests
|
|
||||||
- `refactor:` - Code refactoring
|
|
||||||
- `chore:` - Maintenance
|
|
||||||
|
|
||||||
Example:
|
|
||||||
```bash
|
|
||||||
git commit -m "feat: add S3 storage migration"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Pull Request Process
|
|
||||||
|
|
||||||
1. **Push** to your fork:
|
|
||||||
```bash
|
|
||||||
git push origin feature/your-feature-name
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Open a Pull Request** on GitHub
|
|
||||||
|
|
||||||
3. **Wait for review** - maintainers will review your PR
|
|
||||||
|
|
||||||
## Code Style
|
## Code Style
|
||||||
|
|
||||||
- Follow Rust conventions
|
- Run `cargo fmt` before committing
|
||||||
- Use `cargo fmt` for formatting
|
- Run `cargo clippy` and fix warnings
|
||||||
- Pass `cargo clippy` with no warnings
|
- Follow Rust naming conventions
|
||||||
- Write meaningful commit messages
|
|
||||||
|
|
||||||
## Questions?
|
## Pull Request Process
|
||||||
|
|
||||||
- Open an [Issue](https://github.com/getnora-io/nora/issues)
|
1. Update documentation if needed
|
||||||
- Ask in [Discussions](https://github.com/getnora-io/nora/discussions)
|
2. Add tests for new features
|
||||||
- Reach out on [Telegram](https://t.me/DevITWay)
|
3. Ensure all tests pass: `cargo test`
|
||||||
|
4. Ensure code is formatted: `cargo fmt --check`
|
||||||
|
5. Ensure no clippy warnings: `cargo clippy`
|
||||||
|
|
||||||
---
|
## Commit Messages
|
||||||
|
|
||||||
Built with love by the NORA community
|
Use conventional commits:
|
||||||
|
|
||||||
|
- `feat:` - new feature
|
||||||
|
- `fix:` - bug fix
|
||||||
|
- `docs:` - documentation
|
||||||
|
- `style:` - formatting
|
||||||
|
- `refactor:` - code refactoring
|
||||||
|
- `test:` - adding tests
|
||||||
|
- `chore:` - maintenance
|
||||||
|
|
||||||
|
Example: `feat: add OAuth2 authentication`
|
||||||
|
|
||||||
|
## Reporting Issues
|
||||||
|
|
||||||
|
- Use GitHub Issues
|
||||||
|
- Include steps to reproduce
|
||||||
|
- Include NORA version and OS
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
By contributing, you agree that your contributions will be licensed under the MIT License.
|
||||||
|
|
||||||
|
## Contact
|
||||||
|
|
||||||
|
- Telegram: [@DevITWay](https://t.me/DevITWay)
|
||||||
|
- GitHub Issues: [getnora-io/nora](https://github.com/getnora-io/nora/issues)
|
||||||
|
|||||||
467
Cargo.lock
generated
467
Cargo.lock
generated
@@ -82,6 +82,12 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "arbitrary"
|
name = "arbitrary"
|
||||||
version = "1.4.2"
|
version = "1.4.2"
|
||||||
@@ -184,13 +190,13 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bcrypt"
|
name = "bcrypt"
|
||||||
version = "0.17.1"
|
version = "0.19.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "abaf6da45c74385272ddf00e1ac074c7d8a6c1a1dda376902bd6a427522a8b2c"
|
checksum = "523ab528ce3a7ada6597f8ccf5bd8d85ebe26d5edf311cad4d1d3cfb2d357ac6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"blowfish",
|
"blowfish",
|
||||||
"getrandom 0.3.4",
|
"getrandom 0.4.1",
|
||||||
"subtle",
|
"subtle",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
@@ -234,15 +240,15 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytes"
|
name = "bytes"
|
||||||
version = "1.11.0"
|
version = "1.11.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
|
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.2.54"
|
version = "1.2.55"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6354c81bbfd62d9cfa9cb3c773c2b7b2a3a482d569de977fd0e961f6e7c00583"
|
checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"find-msvc-tools",
|
"find-msvc-tools",
|
||||||
"shlex",
|
"shlex",
|
||||||
@@ -262,9 +268,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chrono"
|
name = "chrono"
|
||||||
version = "0.4.43"
|
version = "0.4.44"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118"
|
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"iana-time-zone",
|
"iana-time-zone",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
@@ -286,9 +292,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap"
|
name = "clap"
|
||||||
version = "4.5.54"
|
version = "4.5.60"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394"
|
checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clap_builder",
|
"clap_builder",
|
||||||
"clap_derive",
|
"clap_derive",
|
||||||
@@ -296,9 +302,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap_builder"
|
name = "clap_builder"
|
||||||
version = "4.5.54"
|
version = "4.5.60"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00"
|
checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anstream",
|
"anstream",
|
||||||
"anstyle",
|
"anstyle",
|
||||||
@@ -308,9 +314,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap_derive"
|
name = "clap_derive"
|
||||||
version = "4.5.49"
|
version = "4.5.55"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671"
|
checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"heck",
|
"heck",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
@@ -320,9 +326,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap_lex"
|
name = "clap_lex"
|
||||||
version = "0.7.7"
|
version = "1.0.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32"
|
checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "colorchoice"
|
name = "colorchoice"
|
||||||
@@ -332,15 +338,15 @@ checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "console"
|
name = "console"
|
||||||
version = "0.15.11"
|
version = "0.16.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8"
|
checksum = "03e45a4a8926227e4197636ba97a9fc9b00477e9f4bd711395687c5f0734bec4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"encode_unicode",
|
"encode_unicode",
|
||||||
"libc",
|
"libc",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"unicode-width",
|
"unicode-width",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -434,6 +440,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"block-buffer",
|
"block-buffer",
|
||||||
"crypto-common",
|
"crypto-common",
|
||||||
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -466,7 +473,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -488,15 +495,15 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "find-msvc-tools"
|
name = "find-msvc-tools"
|
||||||
version = "0.1.8"
|
version = "0.1.9"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db"
|
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "flate2"
|
name = "flate2"
|
||||||
version = "1.1.8"
|
version = "1.1.9"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369"
|
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crc32fast",
|
"crc32fast",
|
||||||
"miniz_oxide",
|
"miniz_oxide",
|
||||||
@@ -509,6 +516,12 @@ version = "1.0.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "foldhash"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "foldhash"
|
name = "foldhash"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -666,6 +679,19 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"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]]
|
[[package]]
|
||||||
name = "governor"
|
name = "governor"
|
||||||
version = "0.10.4"
|
version = "0.10.4"
|
||||||
@@ -714,6 +740,15 @@ version = "0.14.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
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]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.16.1"
|
version = "0.16.1"
|
||||||
@@ -722,7 +757,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"allocator-api2",
|
"allocator-api2",
|
||||||
"equivalent",
|
"equivalent",
|
||||||
"foldhash",
|
"foldhash 0.2.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -737,6 +772,21 @@ version = "0.5.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
|
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]]
|
[[package]]
|
||||||
name = "http"
|
name = "http"
|
||||||
version = "1.4.0"
|
version = "1.4.0"
|
||||||
@@ -861,9 +911,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "iana-time-zone"
|
name = "iana-time-zone"
|
||||||
version = "0.1.64"
|
version = "0.1.65"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb"
|
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_system_properties",
|
"android_system_properties",
|
||||||
"core-foundation-sys",
|
"core-foundation-sys",
|
||||||
@@ -964,6 +1014,12 @@ dependencies = [
|
|||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "id-arena"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "idna"
|
name = "idna"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
@@ -999,14 +1055,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "indicatif"
|
name = "indicatif"
|
||||||
version = "0.17.11"
|
version = "0.18.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235"
|
checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"console",
|
"console",
|
||||||
"number_prefix",
|
|
||||||
"portable-atomic",
|
"portable-atomic",
|
||||||
"unicode-width",
|
"unicode-width",
|
||||||
|
"unit-prefix",
|
||||||
"web-time",
|
"web-time",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1064,10 +1120,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libc"
|
name = "leb128fmt"
|
||||||
version = "0.2.180"
|
version = "0.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
|
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libc"
|
||||||
|
version = "0.2.182"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libredox"
|
name = "libredox"
|
||||||
@@ -1082,9 +1144,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "linux-raw-sys"
|
name = "linux-raw-sys"
|
||||||
version = "0.11.0"
|
version = "0.12.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
|
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "litemap"
|
name = "litemap"
|
||||||
@@ -1185,7 +1247,7 @@ checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nora-cli"
|
name = "nora-cli"
|
||||||
version = "0.1.0"
|
version = "0.2.29"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clap",
|
"clap",
|
||||||
"flate2",
|
"flate2",
|
||||||
@@ -1199,7 +1261,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nora-registry"
|
name = "nora-registry"
|
||||||
version = "0.1.0"
|
version = "0.2.29"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -1209,9 +1271,12 @@ dependencies = [
|
|||||||
"clap",
|
"clap",
|
||||||
"flate2",
|
"flate2",
|
||||||
"governor",
|
"governor",
|
||||||
|
"hex",
|
||||||
|
"hmac",
|
||||||
"httpdate",
|
"httpdate",
|
||||||
"indicatif",
|
"indicatif",
|
||||||
"lazy_static",
|
"lazy_static",
|
||||||
|
"parking_lot",
|
||||||
"prometheus",
|
"prometheus",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -1229,11 +1294,12 @@ dependencies = [
|
|||||||
"utoipa-swagger-ui",
|
"utoipa-swagger-ui",
|
||||||
"uuid",
|
"uuid",
|
||||||
"wiremock",
|
"wiremock",
|
||||||
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nora-storage"
|
name = "nora-storage"
|
||||||
version = "0.1.0"
|
version = "0.2.29"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
"base64",
|
"base64",
|
||||||
@@ -1278,12 +1344,6 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "number_prefix"
|
|
||||||
version = "0.4.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
version = "1.21.3"
|
version = "1.21.3"
|
||||||
@@ -1381,6 +1441,16 @@ dependencies = [
|
|||||||
"zerocopy",
|
"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]]
|
[[package]]
|
||||||
name = "proc-macro2"
|
name = "proc-macro2"
|
||||||
version = "1.0.106"
|
version = "1.0.106"
|
||||||
@@ -1392,9 +1462,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "prometheus"
|
name = "prometheus"
|
||||||
version = "0.13.4"
|
version = "0.14.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3d33c28a30771f7f96db69893f78b857f7450d7e0237e9c8fc6427a81bae7ed1"
|
checksum = "3ca5326d8d0b950a9acd87e6a3f94745394f62e4dae1b1ee22b2bc0c394af43a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"fnv",
|
"fnv",
|
||||||
@@ -1402,14 +1472,28 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"protobuf",
|
"protobuf",
|
||||||
"thiserror 1.0.69",
|
"thiserror 2.0.18",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "protobuf"
|
name = "protobuf"
|
||||||
version = "2.28.0"
|
version = "3.7.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "106dd99e98437432fed6519dedecfade6a06a73bb7b2a1e019fdd2bee5778d94"
|
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",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quanta"
|
name = "quanta"
|
||||||
@@ -1428,9 +1512,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quick-xml"
|
name = "quick-xml"
|
||||||
version = "0.31.0"
|
version = "0.39.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1004a344b30a54e2ee58d66a71b32d2db2feb0a31f9a2d302bf0536f15de2a33"
|
checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"memchr",
|
"memchr",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -1458,9 +1542,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quinn-proto"
|
name = "quinn-proto"
|
||||||
version = "0.11.13"
|
version = "0.11.14"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
|
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"getrandom 0.3.4",
|
"getrandom 0.3.4",
|
||||||
@@ -1687,15 +1771,15 @@ checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustix"
|
name = "rustix"
|
||||||
version = "1.1.3"
|
version = "1.1.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
|
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags",
|
||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys",
|
"linux-raw-sys",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1760,6 +1844,12 @@ version = "1.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "semver"
|
||||||
|
version = "1.0.27"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde"
|
name = "serde"
|
||||||
version = "1.0.228"
|
version = "1.0.228"
|
||||||
@@ -1816,11 +1906,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_spanned"
|
name = "serde_spanned"
|
||||||
version = "0.6.9"
|
version = "1.0.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
|
checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1970,15 +2060,15 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tempfile"
|
name = "tempfile"
|
||||||
version = "3.24.0"
|
version = "3.26.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c"
|
checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastrand",
|
"fastrand",
|
||||||
"getrandom 0.3.4",
|
"getrandom 0.4.1",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix",
|
"rustix",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2057,9 +2147,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio"
|
name = "tokio"
|
||||||
version = "1.49.0"
|
version = "1.50.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86"
|
checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -2119,50 +2209,48 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml"
|
name = "toml"
|
||||||
version = "0.8.23"
|
version = "1.0.6+spec-1.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
|
checksum = "399b1124a3c9e16766831c6bba21e50192572cdd98706ea114f9502509686ffc"
|
||||||
dependencies = [
|
|
||||||
"serde",
|
|
||||||
"serde_spanned",
|
|
||||||
"toml_datetime",
|
|
||||||
"toml_edit",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "toml_datetime"
|
|
||||||
version = "0.6.11"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
|
|
||||||
dependencies = [
|
|
||||||
"serde",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "toml_edit"
|
|
||||||
version = "0.22.27"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"serde",
|
"serde_core",
|
||||||
"serde_spanned",
|
"serde_spanned",
|
||||||
"toml_datetime",
|
"toml_datetime",
|
||||||
"toml_write",
|
"toml_parser",
|
||||||
|
"toml_writer",
|
||||||
"winnow",
|
"winnow",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_write"
|
name = "toml_datetime"
|
||||||
version = "0.1.2"
|
version = "1.0.0+spec-1.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_parser"
|
||||||
|
version = "1.0.9+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4"
|
||||||
|
dependencies = [
|
||||||
|
"winnow",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_writer"
|
||||||
|
version = "1.0.6+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tonic"
|
name = "tonic"
|
||||||
version = "0.14.2"
|
version = "0.14.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "eb7613188ce9f7df5bfe185db26c5814347d110db17920415cf2fbcad85e7203"
|
checksum = "a286e33f82f8a1ee2df63f4fa35c0becf4a85a0cb03091a15fd7bf0b402dc94a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -2358,6 +2446,18 @@ version = "0.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
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]]
|
[[package]]
|
||||||
name = "untrusted"
|
name = "untrusted"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@@ -2433,11 +2533,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uuid"
|
name = "uuid"
|
||||||
version = "1.20.0"
|
version = "1.22.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f"
|
checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"getrandom 0.3.4",
|
"getrandom 0.4.1",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
@@ -2488,6 +2588,15 @@ dependencies = [
|
|||||||
"wit-bindgen",
|
"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]]
|
[[package]]
|
||||||
name = "wasm-bindgen"
|
name = "wasm-bindgen"
|
||||||
version = "0.2.108"
|
version = "0.2.108"
|
||||||
@@ -2547,6 +2656,40 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"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]]
|
[[package]]
|
||||||
name = "web-sys"
|
name = "web-sys"
|
||||||
version = "0.3.85"
|
version = "0.3.85"
|
||||||
@@ -2598,7 +2741,7 @@ version = "0.1.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2675,15 +2818,6 @@ dependencies = [
|
|||||||
"windows-targets 0.52.6",
|
"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]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.60.2"
|
version = "0.60.2"
|
||||||
@@ -2836,9 +2970,6 @@ name = "winnow"
|
|||||||
version = "0.7.14"
|
version = "0.7.14"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829"
|
checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829"
|
||||||
dependencies = [
|
|
||||||
"memchr",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wiremock"
|
name = "wiremock"
|
||||||
@@ -2868,6 +2999,88 @@ name = "wit-bindgen"
|
|||||||
version = "0.51.0"
|
version = "0.51.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
|
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]]
|
[[package]]
|
||||||
name = "writeable"
|
name = "writeable"
|
||||||
@@ -2910,18 +3123,18 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerocopy"
|
name = "zerocopy"
|
||||||
version = "0.8.33"
|
version = "0.8.37"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd"
|
checksum = "7456cf00f0685ad319c5b1693f291a650eaf345e941d082fc4e03df8a03996ac"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"zerocopy-derive",
|
"zerocopy-derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerocopy-derive"
|
name = "zerocopy-derive"
|
||||||
version = "0.8.33"
|
version = "0.8.37"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1"
|
checksum = "1328722bbf2115db7e19d69ebcc15e795719e2d66b60827c6a69a117365e37a0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -2954,6 +3167,20 @@ name = "zeroize"
|
|||||||
version = "1.8.2"
|
version = "1.8.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
||||||
|
dependencies = [
|
||||||
|
"zeroize_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zeroize_derive"
|
||||||
|
version = "1.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerotrie"
|
name = "zerotrie"
|
||||||
@@ -3004,15 +3231,15 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zlib-rs"
|
name = "zlib-rs"
|
||||||
version = "0.5.5"
|
version = "0.6.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "40990edd51aae2c2b6907af74ffb635029d5788228222c4bb811e9351c0caad3"
|
checksum = "c745c48e1007337ed136dc99df34128b9faa6ed542d80a1c673cf55a6d7236c8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zmij"
|
name = "zmij"
|
||||||
version = "1.0.16"
|
version = "1.0.17"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65"
|
checksum = "02aae0f83f69aafc94776e879363e9771d7ecbffe2c7fbb6c14c5e00dfe88439"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zopfli"
|
name = "zopfli"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.1.0"
|
version = "0.2.29"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
authors = ["DevITWay <devitway@gmail.com>"]
|
authors = ["DevITWay <devitway@gmail.com>"]
|
||||||
@@ -24,3 +24,5 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
|||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
|
hmac = "0.12"
|
||||||
|
hex = "0.4"
|
||||||
|
|||||||
55
Dockerfile
55
Dockerfile
@@ -1,58 +1,11 @@
|
|||||||
# syntax=docker/dockerfile:1.4
|
# 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
|
FROM alpine:3.20
|
||||||
|
|
||||||
RUN apk add --no-cache ca-certificates
|
RUN apk add --no-cache ca-certificates && mkdir -p /data
|
||||||
|
|
||||||
WORKDIR /app
|
COPY nora /usr/local/bin/nora
|
||||||
|
|
||||||
# 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 RUST_LOG=info
|
||||||
ENV NORA_HOST=0.0.0.0
|
ENV NORA_HOST=0.0.0.0
|
||||||
ENV NORA_PORT=4000
|
ENV NORA_PORT=4000
|
||||||
@@ -64,5 +17,5 @@ EXPOSE 4000
|
|||||||
|
|
||||||
VOLUME ["/data"]
|
VOLUME ["/data"]
|
||||||
|
|
||||||
ENTRYPOINT ["nora"]
|
ENTRYPOINT ["/usr/local/bin/nora"]
|
||||||
CMD ["serve"]
|
CMD ["serve"]
|
||||||
|
|||||||
28
Dockerfile.astra
Normal file
28
Dockerfile.astra
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# 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"]
|
||||||
28
Dockerfile.redos
Normal file
28
Dockerfile.redos
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# 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"]
|
||||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2026 DevITWay
|
Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
133
README.md
133
README.md
@@ -1,7 +1,11 @@
|
|||||||
# NORA
|
|
||||||
|
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://t.me/DevITWay)
|
[](https://github.com/getnora-io/nora/releases)
|
||||||
|
[](https://github.com/getnora-io/nora/actions)
|
||||||
|
[](https://github.com/getnora-io/nora/pkgs/container/nora)
|
||||||
|
[](https://github.com/getnora-io/nora/stargazers)
|
||||||
|
[](https://www.rust-lang.org/)
|
||||||
|
[](https://getnora.dev)
|
||||||
|
[](https://t.me/getnora)
|
||||||
|
|
||||||
> **Your Cloud-Native Artifact Registry**
|
> **Your Cloud-Native Artifact Registry**
|
||||||
|
|
||||||
@@ -32,15 +36,17 @@ Fast. Organized. Feel at Home.
|
|||||||
|
|
||||||
- **Security**
|
- **Security**
|
||||||
- Basic Auth (htpasswd + bcrypt)
|
- Basic Auth (htpasswd + bcrypt)
|
||||||
- Revocable API tokens
|
- Revocable API tokens with RBAC
|
||||||
- ENV-based configuration (12-Factor)
|
- ENV-based configuration (12-Factor)
|
||||||
|
- SBOM (SPDX + CycloneDX) in every release
|
||||||
|
- See [SECURITY.md](SECURITY.md) for vulnerability reporting
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
### Docker (Recommended)
|
### Docker (Recommended)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -d -p 4000:4000 -v nora-data:/data getnora/nora
|
docker run -d -p 4000:4000 -v nora-data:/data ghcr.io/getnora-io/nora:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
### From Source
|
### From Source
|
||||||
@@ -82,6 +88,39 @@ npm config set registry http://localhost:4000/npm/
|
|||||||
npm publish
|
npm publish
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
NORA supports Basic Auth (htpasswd) and revocable API tokens with RBAC.
|
||||||
|
|
||||||
|
### Quick Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Create htpasswd file with bcrypt
|
||||||
|
htpasswd -cbB users.htpasswd admin yourpassword
|
||||||
|
# Add more users:
|
||||||
|
htpasswd -bB users.htpasswd ci-user ci-secret
|
||||||
|
|
||||||
|
# 2. Start NORA with auth enabled
|
||||||
|
docker run -d -p 4000:4000 \
|
||||||
|
-v nora-data:/data \
|
||||||
|
-v ./users.htpasswd:/data/users.htpasswd \
|
||||||
|
-e NORA_AUTH_ENABLED=true \
|
||||||
|
ghcr.io/getnora-io/nora:latest
|
||||||
|
|
||||||
|
# 3. Verify
|
||||||
|
curl -u admin:yourpassword http://localhost:4000/v2/_catalog
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Tokens (RBAC)
|
||||||
|
|
||||||
|
| Role | Pull/Read | Push/Write | Delete/Admin |
|
||||||
|
|------|-----------|------------|--------------|
|
||||||
|
| `read` | Yes | No | No |
|
||||||
|
| `write` | Yes | Yes | No |
|
||||||
|
| `admin` | Yes | Yes | Yes |
|
||||||
|
|
||||||
|
See [Authentication guide](https://getnora.dev/configuration/authentication/) for token management, Docker login, and CI/CD integration.
|
||||||
|
|
||||||
## CLI Commands
|
## CLI Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -101,10 +140,10 @@ nora migrate --from local --to s3
|
|||||||
| `NORA_HOST` | 127.0.0.1 | Bind address |
|
| `NORA_HOST` | 127.0.0.1 | Bind address |
|
||||||
| `NORA_PORT` | 4000 | Port |
|
| `NORA_PORT` | 4000 | Port |
|
||||||
| `NORA_STORAGE_MODE` | local | `local` or `s3` |
|
| `NORA_STORAGE_MODE` | local | `local` or `s3` |
|
||||||
| `NORA_STORAGE_PATH` | data/storage | Local storage path |
|
|
||||||
| `NORA_STORAGE_S3_URL` | - | S3 endpoint URL |
|
|
||||||
| `NORA_STORAGE_BUCKET` | registry | S3 bucket name |
|
|
||||||
| `NORA_AUTH_ENABLED` | false | Enable authentication |
|
| `NORA_AUTH_ENABLED` | false | Enable authentication |
|
||||||
|
| `NORA_DOCKER_UPSTREAMS` | `https://registry-1.docker.io` | Docker upstreams (`url\|user:pass,...`) |
|
||||||
|
|
||||||
|
See [full configuration reference](https://getnora.dev/configuration/settings/) for all environment variables including storage, rate limiting, proxy auth, and secrets.
|
||||||
|
|
||||||
### config.toml
|
### config.toml
|
||||||
|
|
||||||
@@ -120,8 +159,11 @@ path = "data/storage"
|
|||||||
[auth]
|
[auth]
|
||||||
enabled = false
|
enabled = false
|
||||||
htpasswd_file = "users.htpasswd"
|
htpasswd_file = "users.htpasswd"
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
See [full config reference](https://getnora.dev/configuration/settings/) for rate limiting, secrets, and proxy settings.
|
||||||
|
|
||||||
## Endpoints
|
## Endpoints
|
||||||
|
|
||||||
| URL | Description |
|
| URL | Description |
|
||||||
@@ -137,6 +179,77 @@ htpasswd_file = "users.htpasswd"
|
|||||||
| `/cargo/` | Cargo |
|
| `/cargo/` | Cargo |
|
||||||
| `/simple/` | PyPI |
|
| `/simple/` | PyPI |
|
||||||
|
|
||||||
|
## TLS / HTTPS
|
||||||
|
|
||||||
|
NORA serves plain HTTP by design. **TLS is intentionally not built into the binary** — this is a deliberate architectural decision:
|
||||||
|
|
||||||
|
- **Single responsibility**: NORA manages artifacts, not certificates. Embedding TLS means bundling Let's Encrypt clients, certificate renewal logic, ACME challenges, and custom CA support — all of which already exist in battle-tested tools.
|
||||||
|
- **Operational simplicity**: One place for certificates (reverse proxy), not scattered across every service. When a cert expires, you fix it in one config — not in NORA, Grafana, GitLab, and every other service separately.
|
||||||
|
- **Industry standard**: Docker Hub, GitHub Container Registry, AWS ECR, Harbor, Nexus — none of them terminate TLS in the registry process. A reverse proxy in front is the universal pattern.
|
||||||
|
- **Zero-config internal use**: On trusted networks (lab, CI/CD), NORA works out of the box without generating self-signed certs or managing keystores.
|
||||||
|
|
||||||
|
### Production (recommended): reverse proxy with auto-TLS
|
||||||
|
|
||||||
|
```
|
||||||
|
Client → Caddy/Nginx (HTTPS, port 443) → NORA (HTTP, port 4000)
|
||||||
|
```
|
||||||
|
|
||||||
|
Caddy example:
|
||||||
|
|
||||||
|
```
|
||||||
|
registry.example.com {
|
||||||
|
reverse_proxy localhost:4000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Nginx example:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name registry.example.com;
|
||||||
|
ssl_certificate /etc/letsencrypt/live/registry.example.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/registry.example.com/privkey.pem;
|
||||||
|
client_max_body_size 0; # unlimited for large image pushes
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:4000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Internal / Lab: insecure registry
|
||||||
|
|
||||||
|
If you run NORA without TLS (e.g., on a private network), configure Docker to trust it:
|
||||||
|
|
||||||
|
```json
|
||||||
|
// /etc/docker/daemon.json
|
||||||
|
{
|
||||||
|
"insecure-registries": ["192.168.1.100:4000"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then restart Docker:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl restart docker
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note:** `insecure-registries` disables TLS verification for that host. Use only on trusted networks.
|
||||||
|
|
||||||
|
## FSTEC-Certified OS Builds
|
||||||
|
|
||||||
|
NORA provides dedicated Dockerfiles for Russian FSTEC-certified operating systems:
|
||||||
|
|
||||||
|
- `Dockerfile.astra` — Astra Linux SE (for government and defense sector)
|
||||||
|
- `Dockerfile.redos` — RED OS (for enterprise and public sector)
|
||||||
|
|
||||||
|
Both use `scratch` base with statically-linked binary for minimal attack surface. Comments in each file show how to switch to official distro base images if required by your security policy.
|
||||||
|
|
||||||
|
These builds are published as `-astra` and `-redos` tagged images in GitHub Releases.
|
||||||
|
|
||||||
## Performance
|
## Performance
|
||||||
|
|
||||||
| Metric | NORA | Nexus | JFrog |
|
| Metric | NORA | Nexus | JFrog |
|
||||||
@@ -149,7 +262,7 @@ htpasswd_file = "users.htpasswd"
|
|||||||
|
|
||||||
**Created and maintained by [DevITWay](https://github.com/devitway)**
|
**Created and maintained by [DevITWay](https://github.com/devitway)**
|
||||||
|
|
||||||
- Website: [devopsway.ru](https://devopsway.ru)
|
- Website: [getnora.dev](https://getnora.dev)
|
||||||
- Telegram: [@DevITWay](https://t.me/DevITWay)
|
- Telegram: [@DevITWay](https://t.me/DevITWay)
|
||||||
- GitHub: [@devitway](https://github.com/devitway)
|
- GitHub: [@devitway](https://github.com/devitway)
|
||||||
- Email: devitway@gmail.com
|
- Email: devitway@gmail.com
|
||||||
@@ -166,4 +279,4 @@ Copyright (c) 2026 DevITWay
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**NORA** - Organized like a chipmunk's stash | Built with Rust by [DevITWay](https://t.me/DevITWay)
|
**🐿️ N○RA** - Organized like a chipmunk's stash | Built with Rust by [DevITWay](https://t.me/DevITWay)
|
||||||
|
|||||||
53
SECURITY.md
Normal file
53
SECURITY.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# 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.
|
||||||
440
TODO.md
440
TODO.md
@@ -1,440 +0,0 @@
|
|||||||
# 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.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)
|
|
||||||
40
deny.toml
Normal file
40
deny.toml
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# 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 via indicatif; no fix available. Review by 2026-06-15
|
||||||
|
]
|
||||||
|
|
||||||
|
[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"]
|
||||||
190
deploy/README.md
190
deploy/README.md
@@ -1,57 +1,187 @@
|
|||||||
# NORA Demo Deployment
|
# NORA Demo Deployment
|
||||||
|
|
||||||
## DNS Setup
|
[English](#english) | [Русский](#russian)
|
||||||
|
|
||||||
Add A record:
|
---
|
||||||
```
|
|
||||||
demo.getnora.io → <VPS_IP>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Deploy
|
<a name="english"></a>
|
||||||
|
## English
|
||||||
|
|
||||||
|
### Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run NORA with Docker
|
||||||
|
docker run -d \
|
||||||
|
--name nora \
|
||||||
|
-p 4000:4000 \
|
||||||
|
-v nora-data:/data \
|
||||||
|
ghcr.io/getnora-io/nora:latest
|
||||||
|
|
||||||
|
# Check health
|
||||||
|
curl http://localhost:4000/health
|
||||||
|
```
|
||||||
|
|
||||||
|
### Push Docker Images
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Tag your image
|
||||||
|
docker tag myapp:v1 localhost:4000/myapp:v1
|
||||||
|
|
||||||
|
# Push to NORA
|
||||||
|
docker push localhost:4000/myapp:v1
|
||||||
|
|
||||||
|
# Pull from NORA
|
||||||
|
docker pull localhost:4000/myapp:v1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Use as Maven Repository
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<!-- pom.xml -->
|
||||||
|
<repositories>
|
||||||
|
<repository>
|
||||||
|
<id>nora</id>
|
||||||
|
<url>http://localhost:4000/maven2/</url>
|
||||||
|
</repository>
|
||||||
|
</repositories>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Use as npm Registry
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm config set registry http://localhost:4000/npm/
|
||||||
|
npm install lodash
|
||||||
|
```
|
||||||
|
|
||||||
|
### Use as PyPI Index
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install --index-url http://localhost:4000/simple/ requests
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production Deployment with HTTPS
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone repo
|
|
||||||
git clone https://github.com/getnora-io/nora.git
|
git clone https://github.com/getnora-io/nora.git
|
||||||
cd nora/deploy
|
cd nora/deploy
|
||||||
|
|
||||||
# Start
|
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
|
|
||||||
# Check logs
|
|
||||||
docker compose logs -f
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## URLs
|
### URLs
|
||||||
|
|
||||||
- **Web UI:** https://demo.getnora.io/ui/
|
| URL | Description |
|
||||||
- **API Docs:** https://demo.getnora.io/api-docs
|
|-----|-------------|
|
||||||
- **Health:** https://demo.getnora.io/health
|
| `/ui/` | Web UI |
|
||||||
|
| `/api-docs` | Swagger API Docs |
|
||||||
|
| `/health` | Health Check |
|
||||||
|
| `/metrics` | Prometheus Metrics |
|
||||||
|
|
||||||
## Docker Usage
|
### Environment Variables
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `NORA_HOST` | 127.0.0.1 | Bind address |
|
||||||
|
| `NORA_PORT` | 4000 | Port |
|
||||||
|
| `NORA_STORAGE_PATH` | data/storage | Storage path |
|
||||||
|
| `NORA_AUTH_ENABLED` | false | Enable auth |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a name="russian"></a>
|
||||||
|
## Русский
|
||||||
|
|
||||||
|
### Быстрый старт
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Tag and push
|
# Запуск NORA в Docker
|
||||||
docker tag myimage:latest demo.getnora.io/myimage:latest
|
docker run -d \
|
||||||
docker push demo.getnora.io/myimage:latest
|
--name nora \
|
||||||
|
-p 4000:4000 \
|
||||||
|
-v nora-data:/data \
|
||||||
|
ghcr.io/getnora-io/nora:latest
|
||||||
|
|
||||||
# Pull
|
# Проверка работоспособности
|
||||||
docker pull demo.getnora.io/myimage:latest
|
curl http://localhost:4000/health
|
||||||
```
|
```
|
||||||
|
|
||||||
## Management
|
### Загрузка Docker образов
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Stop
|
# Тегируем образ
|
||||||
|
docker tag myapp:v1 localhost:4000/myapp:v1
|
||||||
|
|
||||||
|
# Пушим в NORA
|
||||||
|
docker push localhost:4000/myapp:v1
|
||||||
|
|
||||||
|
# Скачиваем из NORA
|
||||||
|
docker pull localhost:4000/myapp:v1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Использование как Maven репозиторий
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<!-- pom.xml -->
|
||||||
|
<repositories>
|
||||||
|
<repository>
|
||||||
|
<id>nora</id>
|
||||||
|
<url>http://localhost:4000/maven2/</url>
|
||||||
|
</repository>
|
||||||
|
</repositories>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Использование как npm реестр
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm config set registry http://localhost:4000/npm/
|
||||||
|
npm install lodash
|
||||||
|
```
|
||||||
|
|
||||||
|
### Использование как PyPI индекс
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install --index-url http://localhost:4000/simple/ requests
|
||||||
|
```
|
||||||
|
|
||||||
|
### Продакшен с HTTPS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/getnora-io/nora.git
|
||||||
|
cd nora/deploy
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Эндпоинты
|
||||||
|
|
||||||
|
| URL | Описание |
|
||||||
|
|-----|----------|
|
||||||
|
| `/ui/` | Веб-интерфейс |
|
||||||
|
| `/api-docs` | Swagger документация |
|
||||||
|
| `/health` | Проверка здоровья |
|
||||||
|
| `/metrics` | Метрики Prometheus |
|
||||||
|
|
||||||
|
### Переменные окружения
|
||||||
|
|
||||||
|
| Переменная | По умолчанию | Описание |
|
||||||
|
|------------|--------------|----------|
|
||||||
|
| `NORA_HOST` | 127.0.0.1 | Адрес привязки |
|
||||||
|
| `NORA_PORT` | 4000 | Порт |
|
||||||
|
| `NORA_STORAGE_PATH` | data/storage | Путь хранилища |
|
||||||
|
| `NORA_AUTH_ENABLED` | false | Включить авторизацию |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Management / Управление
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Stop / Остановить
|
||||||
docker compose down
|
docker compose down
|
||||||
|
|
||||||
# Restart
|
# Restart / Перезапустить
|
||||||
docker compose restart
|
docker compose restart
|
||||||
|
|
||||||
# View logs
|
# Logs / Логи
|
||||||
docker compose logs -f nora
|
docker compose logs -f nora
|
||||||
docker compose logs -f caddy
|
|
||||||
|
|
||||||
# Update
|
# Update / Обновить
|
||||||
docker compose pull
|
docker compose pull && docker compose up -d
|
||||||
docker compose up -d
|
|
||||||
```
|
```
|
||||||
|
|||||||
83
deploy/demo-traffic.sh
Normal file
83
deploy/demo-traffic.sh
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Demo traffic simulator for NORA registry
|
||||||
|
# Generates random registry activity for dashboard demo
|
||||||
|
|
||||||
|
REGISTRY="http://localhost:4000"
|
||||||
|
LOG_FILE="/var/log/nora-demo-traffic.log"
|
||||||
|
|
||||||
|
# Sample packages to fetch
|
||||||
|
NPM_PACKAGES=("lodash" "express" "react" "axios" "moment" "underscore" "chalk" "debug")
|
||||||
|
MAVEN_ARTIFACTS=(
|
||||||
|
"org/apache/commons/commons-lang3/3.12.0/commons-lang3-3.12.0.pom"
|
||||||
|
"com/google/guava/guava/31.1-jre/guava-31.1-jre.pom"
|
||||||
|
"org/slf4j/slf4j-api/2.0.0/slf4j-api-2.0.0.pom"
|
||||||
|
)
|
||||||
|
DOCKER_IMAGES=("alpine" "busybox" "hello-world")
|
||||||
|
|
||||||
|
log() {
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "$LOG_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Random sleep between min and max seconds
|
||||||
|
random_sleep() {
|
||||||
|
local min=$1
|
||||||
|
local max=$2
|
||||||
|
local delay=$((RANDOM % (max - min + 1) + min))
|
||||||
|
sleep $delay
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fetch random npm package
|
||||||
|
fetch_npm() {
|
||||||
|
local pkg=${NPM_PACKAGES[$RANDOM % ${#NPM_PACKAGES[@]}]}
|
||||||
|
log "NPM: fetching $pkg"
|
||||||
|
curl -s "$REGISTRY/npm/$pkg" > /dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fetch random maven artifact
|
||||||
|
fetch_maven() {
|
||||||
|
local artifact=${MAVEN_ARTIFACTS[$RANDOM % ${#MAVEN_ARTIFACTS[@]}]}
|
||||||
|
log "MAVEN: fetching $artifact"
|
||||||
|
curl -s "$REGISTRY/maven2/$artifact" > /dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Docker push/pull cycle
|
||||||
|
docker_cycle() {
|
||||||
|
local img=${DOCKER_IMAGES[$RANDOM % ${#DOCKER_IMAGES[@]}]}
|
||||||
|
local tag="demo-$(date +%s)"
|
||||||
|
|
||||||
|
log "DOCKER: push/pull cycle for $img"
|
||||||
|
|
||||||
|
# Tag and push
|
||||||
|
docker tag "$img:latest" "localhost:4000/demo/$img:$tag" 2>/dev/null
|
||||||
|
docker push "localhost:4000/demo/$img:$tag" > /dev/null 2>&1
|
||||||
|
|
||||||
|
# Pull back
|
||||||
|
docker rmi "localhost:4000/demo/$img:$tag" > /dev/null 2>&1
|
||||||
|
docker pull "localhost:4000/demo/$img:$tag" > /dev/null 2>&1
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
docker rmi "localhost:4000/demo/$img:$tag" > /dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main loop
|
||||||
|
log "Starting demo traffic simulator"
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
# Random operation
|
||||||
|
op=$((RANDOM % 10))
|
||||||
|
|
||||||
|
case $op in
|
||||||
|
0|1|2|3) # 40% npm
|
||||||
|
fetch_npm
|
||||||
|
;;
|
||||||
|
4|5|6) # 30% maven
|
||||||
|
fetch_maven
|
||||||
|
;;
|
||||||
|
7|8|9) # 30% docker
|
||||||
|
docker_cycle
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Random delay: 30-120 seconds
|
||||||
|
random_sleep 30 120
|
||||||
|
done
|
||||||
15
deploy/nora-demo-traffic.service
Normal file
15
deploy/nora-demo-traffic.service
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=NORA Demo Traffic Simulator
|
||||||
|
After=docker.service
|
||||||
|
Requires=docker.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/opt/nora/demo-traffic.sh
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
services:
|
services:
|
||||||
nora:
|
nora:
|
||||||
build: .
|
build: .
|
||||||
image: getnora/nora:latest
|
image: ghcr.io/getnora-io/nora:latest
|
||||||
ports:
|
ports:
|
||||||
- "4000:4000"
|
- "4000:4000"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
98
install.sh
Normal file
98
install.sh
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
#!/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
|
||||||
5
logo.svg
Normal file
5
logo.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 373 B |
@@ -18,6 +18,6 @@ reqwest.workspace = true
|
|||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
indicatif = "0.17"
|
indicatif = "0.18"
|
||||||
tar = "0.4"
|
tar = "0.4"
|
||||||
flate2 = "1.0"
|
flate2 = "1.1"
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
|
|||||||
@@ -24,23 +24,27 @@ tracing-subscriber.workspace = true
|
|||||||
reqwest.workspace = true
|
reqwest.workspace = true
|
||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
async-trait.workspace = true
|
async-trait.workspace = true
|
||||||
toml = "0.8"
|
hmac.workspace = true
|
||||||
|
hex.workspace = true
|
||||||
|
toml = "1.0"
|
||||||
uuid = { version = "1", features = ["v4"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
bcrypt = "0.17"
|
bcrypt = "0.19"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
prometheus = "0.13"
|
prometheus = "0.14"
|
||||||
lazy_static = "1.5"
|
lazy_static = "1.5"
|
||||||
httpdate = "1"
|
httpdate = "1"
|
||||||
utoipa = { version = "5", features = ["axum_extras"] }
|
utoipa = { version = "5", features = ["axum_extras"] }
|
||||||
utoipa-swagger-ui = { version = "9", features = ["axum", "reqwest"] }
|
utoipa-swagger-ui = { version = "9", features = ["axum", "reqwest"] }
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
tar = "0.4"
|
tar = "0.4"
|
||||||
flate2 = "1.0"
|
flate2 = "1.1"
|
||||||
indicatif = "0.17"
|
indicatif = "0.18"
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
tower_governor = "0.8"
|
tower_governor = "0.8"
|
||||||
governor = "0.10"
|
governor = "0.10"
|
||||||
|
parking_lot = "0.12"
|
||||||
|
zeroize = { version = "1.8", features = ["derive"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|||||||
101
nora-registry/src/activity_log.rs
Normal file
101
nora-registry/src/activity_log.rs
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use parking_lot::RwLock;
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
|
/// Type of action that was performed
|
||||||
|
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||||
|
pub enum ActionType {
|
||||||
|
Pull,
|
||||||
|
Push,
|
||||||
|
CacheHit,
|
||||||
|
ProxyFetch,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for ActionType {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
ActionType::Pull => write!(f, "PULL"),
|
||||||
|
ActionType::Push => write!(f, "PUSH"),
|
||||||
|
ActionType::CacheHit => write!(f, "CACHE"),
|
||||||
|
ActionType::ProxyFetch => write!(f, "PROXY"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single activity log entry
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct ActivityEntry {
|
||||||
|
pub timestamp: DateTime<Utc>,
|
||||||
|
pub action: ActionType,
|
||||||
|
pub artifact: String,
|
||||||
|
pub registry: String,
|
||||||
|
pub source: String, // "LOCAL", "PROXY", "CACHE"
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActivityEntry {
|
||||||
|
pub fn new(action: ActionType, artifact: String, registry: &str, source: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
timestamp: Utc::now(),
|
||||||
|
action,
|
||||||
|
artifact,
|
||||||
|
registry: registry.to_string(),
|
||||||
|
source: source.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Thread-safe activity log with bounded size
|
||||||
|
pub struct ActivityLog {
|
||||||
|
entries: RwLock<VecDeque<ActivityEntry>>,
|
||||||
|
max_entries: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActivityLog {
|
||||||
|
pub fn new(max: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
entries: RwLock::new(VecDeque::with_capacity(max)),
|
||||||
|
max_entries: max,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a new entry to the log, removing oldest if at capacity
|
||||||
|
pub fn push(&self, entry: ActivityEntry) {
|
||||||
|
let mut entries = self.entries.write();
|
||||||
|
if entries.len() >= self.max_entries {
|
||||||
|
entries.pop_front();
|
||||||
|
}
|
||||||
|
entries.push_back(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the most recent N entries (newest first)
|
||||||
|
pub fn recent(&self, count: usize) -> Vec<ActivityEntry> {
|
||||||
|
let entries = self.entries.read();
|
||||||
|
entries.iter().rev().take(count).cloned().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all entries (newest first)
|
||||||
|
pub fn all(&self) -> Vec<ActivityEntry> {
|
||||||
|
let entries = self.entries.read();
|
||||||
|
entries.iter().rev().cloned().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the total number of entries
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.entries.read().len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if the log is empty
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.entries.read().is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ActivityLog {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new(50)
|
||||||
|
}
|
||||||
|
}
|
||||||
73
nora-registry/src/audit.rs
Normal file
73
nora-registry/src/audit.rs
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//! Persistent audit log — append-only JSONL file
|
||||||
|
//!
|
||||||
|
//! Records who/when/what for every registry operation.
|
||||||
|
//! File: {storage_path}/audit.jsonl
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use parking_lot::Mutex;
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::fs::{self, OpenOptions};
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct AuditEntry {
|
||||||
|
pub ts: DateTime<Utc>,
|
||||||
|
pub action: String,
|
||||||
|
pub actor: String,
|
||||||
|
pub artifact: String,
|
||||||
|
pub registry: String,
|
||||||
|
pub detail: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuditEntry {
|
||||||
|
pub fn new(action: &str, actor: &str, artifact: &str, registry: &str, detail: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
ts: Utc::now(),
|
||||||
|
action: action.to_string(),
|
||||||
|
actor: actor.to_string(),
|
||||||
|
artifact: artifact.to_string(),
|
||||||
|
registry: registry.to_string(),
|
||||||
|
detail: detail.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct AuditLog {
|
||||||
|
path: PathBuf,
|
||||||
|
writer: Mutex<Option<fs::File>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuditLog {
|
||||||
|
pub fn new(storage_path: &str) -> Self {
|
||||||
|
let path = PathBuf::from(storage_path).join("audit.jsonl");
|
||||||
|
let writer = match OpenOptions::new().create(true).append(true).open(&path) {
|
||||||
|
Ok(f) => {
|
||||||
|
info!(path = %path.display(), "Audit log initialized");
|
||||||
|
Mutex::new(Some(f))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!(path = %path.display(), error = %e, "Failed to open audit log, auditing disabled");
|
||||||
|
Mutex::new(None)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Self { path, writer }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn log(&self, entry: AuditEntry) {
|
||||||
|
if let Some(ref mut file) = *self.writer.lock() {
|
||||||
|
if let Ok(json) = serde_json::to_string(&entry) {
|
||||||
|
let _ = writeln!(file, "{}", json);
|
||||||
|
let _ = file.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn path(&self) -> &PathBuf {
|
||||||
|
&self.path
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
body::Body,
|
body::Body,
|
||||||
extract::State,
|
extract::State,
|
||||||
@@ -10,6 +13,7 @@ use std::collections::HashMap;
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::tokens::Role;
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
|
||||||
/// Htpasswd-based authentication
|
/// Htpasswd-based authentication
|
||||||
@@ -60,11 +64,17 @@ impl HtpasswdAuth {
|
|||||||
fn is_public_path(path: &str) -> bool {
|
fn is_public_path(path: &str) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
path,
|
path,
|
||||||
"/" | "/health" | "/ready" | "/metrics" | "/v2/" | "/v2"
|
"/" | "/health"
|
||||||
|
| "/ready"
|
||||||
|
| "/metrics"
|
||||||
|
| "/v2/"
|
||||||
|
| "/v2"
|
||||||
|
| "/api/tokens"
|
||||||
|
| "/api/tokens/list"
|
||||||
|
| "/api/tokens/revoke"
|
||||||
) || path.starts_with("/ui")
|
) || path.starts_with("/ui")
|
||||||
|| path.starts_with("/api-docs")
|
|| path.starts_with("/api-docs")
|
||||||
|| path.starts_with("/api/ui")
|
|| path.starts_with("/api/ui")
|
||||||
|| path.starts_with("/api/tokens")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Auth middleware - supports Basic auth and Bearer tokens
|
/// Auth middleware - supports Basic auth and Bearer tokens
|
||||||
@@ -99,7 +109,18 @@ pub async fn auth_middleware(
|
|||||||
if let Some(token) = auth_header.strip_prefix("Bearer ") {
|
if let Some(token) = auth_header.strip_prefix("Bearer ") {
|
||||||
if let Some(ref token_store) = state.tokens {
|
if let Some(ref token_store) = state.tokens {
|
||||||
match token_store.verify_token(token) {
|
match token_store.verify_token(token) {
|
||||||
Ok(_user) => return next.run(request).await,
|
Ok((_user, role)) => {
|
||||||
|
let method = request.method().clone();
|
||||||
|
if (method == axum::http::Method::PUT
|
||||||
|
|| method == axum::http::Method::POST
|
||||||
|
|| method == axum::http::Method::DELETE
|
||||||
|
|| method == axum::http::Method::PATCH)
|
||||||
|
&& !role.can_write()
|
||||||
|
{
|
||||||
|
return (StatusCode::FORBIDDEN, "Read-only token").into_response();
|
||||||
|
}
|
||||||
|
return next.run(request).await;
|
||||||
|
}
|
||||||
Err(_) => return unauthorized_response("Invalid or expired token"),
|
Err(_) => return unauthorized_response("Invalid or expired token"),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -166,6 +187,12 @@ pub struct CreateTokenRequest {
|
|||||||
#[serde(default = "default_ttl")]
|
#[serde(default = "default_ttl")]
|
||||||
pub ttl_days: u64,
|
pub ttl_days: u64,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
|
#[serde(default = "default_role_str")]
|
||||||
|
pub role: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_role_str() -> String {
|
||||||
|
"read".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_ttl() -> u64 {
|
fn default_ttl() -> u64 {
|
||||||
@@ -185,6 +212,7 @@ pub struct TokenListItem {
|
|||||||
pub expires_at: u64,
|
pub expires_at: u64,
|
||||||
pub last_used: Option<u64>,
|
pub last_used: Option<u64>,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
|
pub role: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -218,7 +246,19 @@ async fn create_token(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
match token_store.create_token(&req.username, req.ttl_days, req.description) {
|
let role = match req.role.as_str() {
|
||||||
|
"read" => Role::Read,
|
||||||
|
"write" => Role::Write,
|
||||||
|
"admin" => Role::Admin,
|
||||||
|
_ => {
|
||||||
|
return (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"Invalid role. Use: read, write, admin",
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match token_store.create_token(&req.username, req.ttl_days, req.description, role) {
|
||||||
Ok(token) => Json(CreateTokenResponse {
|
Ok(token) => Json(CreateTokenResponse {
|
||||||
token,
|
token,
|
||||||
expires_in_days: req.ttl_days,
|
expires_in_days: req.ttl_days,
|
||||||
@@ -262,6 +302,7 @@ async fn list_tokens(
|
|||||||
expires_at: t.expires_at,
|
expires_at: t.expires_at,
|
||||||
last_used: t.last_used,
|
last_used: t.last_used,
|
||||||
description: t.description,
|
description: t.description,
|
||||||
|
role: t.role.to_string(),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -401,8 +442,12 @@ mod tests {
|
|||||||
assert!(is_public_path("/api/ui/stats"));
|
assert!(is_public_path("/api/ui/stats"));
|
||||||
assert!(is_public_path("/api/tokens"));
|
assert!(is_public_path("/api/tokens"));
|
||||||
assert!(is_public_path("/api/tokens/list"));
|
assert!(is_public_path("/api/tokens/list"));
|
||||||
|
assert!(is_public_path("/api/tokens/revoke"));
|
||||||
|
|
||||||
// Protected paths
|
// 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/myimage/blobs/sha256:abc"));
|
||||||
assert!(!is_public_path("/v2/library/nginx/manifests/latest"));
|
assert!(!is_public_path("/v2/library/nginx/manifests/latest"));
|
||||||
assert!(!is_public_path(
|
assert!(!is_public_path(
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
//! Backup and restore functionality for Nora
|
//! Backup and restore functionality for Nora
|
||||||
//!
|
//!
|
||||||
//! Exports all artifacts to a tar.gz file and restores from backups.
|
//! Exports all artifacts to a tar.gz file and restores from backups.
|
||||||
|
|||||||
@@ -1,7 +1,18 @@
|
|||||||
|
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
|
||||||
|
pub use crate::secrets::SecretsConfig;
|
||||||
|
|
||||||
|
/// Encode "user:pass" into a Basic Auth header value, e.g. "Basic dXNlcjpwYXNz".
|
||||||
|
pub fn basic_auth_header(credentials: &str) -> String {
|
||||||
|
format!("Basic {}", STANDARD.encode(credentials))
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
pub server: ServerConfig,
|
pub server: ServerConfig,
|
||||||
@@ -11,13 +22,33 @@ pub struct Config {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub npm: NpmConfig,
|
pub npm: NpmConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub pypi: PypiConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
pub docker: DockerConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
pub raw: RawConfig,
|
||||||
|
#[serde(default)]
|
||||||
pub auth: AuthConfig,
|
pub auth: AuthConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
pub rate_limit: RateLimitConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
pub secrets: SecretsConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ServerConfig {
|
pub struct ServerConfig {
|
||||||
pub host: String,
|
pub host: String,
|
||||||
pub port: u16,
|
pub port: u16,
|
||||||
|
/// Public URL for generating pull commands (e.g., "registry.example.com")
|
||||||
|
#[serde(default)]
|
||||||
|
pub public_url: Option<String>,
|
||||||
|
/// Maximum request body size in MB (default: 2048 = 2GB)
|
||||||
|
#[serde(default = "default_body_limit_mb")]
|
||||||
|
pub body_limit_mb: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_body_limit_mb() -> usize {
|
||||||
|
2048 // 2GB - enough for any Docker image
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
|
||||||
@@ -38,6 +69,19 @@ pub struct StorageConfig {
|
|||||||
pub s3_url: String,
|
pub s3_url: String,
|
||||||
#[serde(default = "default_bucket")]
|
#[serde(default = "default_bucket")]
|
||||||
pub bucket: String,
|
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 {
|
fn default_storage_path() -> String {
|
||||||
@@ -55,7 +99,7 @@ fn default_bucket() -> String {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct MavenConfig {
|
pub struct MavenConfig {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub proxies: Vec<String>,
|
pub proxies: Vec<MavenProxyEntry>,
|
||||||
#[serde(default = "default_timeout")]
|
#[serde(default = "default_timeout")]
|
||||||
pub proxy_timeout: u64,
|
pub proxy_timeout: u64,
|
||||||
}
|
}
|
||||||
@@ -64,10 +108,91 @@ pub struct MavenConfig {
|
|||||||
pub struct NpmConfig {
|
pub struct NpmConfig {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub proxy: Option<String>,
|
pub proxy: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub proxy_auth: Option<String>, // "user:pass" for basic auth
|
||||||
#[serde(default = "default_timeout")]
|
#[serde(default = "default_timeout")]
|
||||||
pub proxy_timeout: u64,
|
pub proxy_timeout: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct PypiConfig {
|
||||||
|
#[serde(default)]
|
||||||
|
pub proxy: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub proxy_auth: Option<String>, // "user:pass" for basic auth
|
||||||
|
#[serde(default = "default_timeout")]
|
||||||
|
pub proxy_timeout: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Docker registry configuration with upstream proxy support
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DockerConfig {
|
||||||
|
#[serde(default = "default_docker_timeout")]
|
||||||
|
pub proxy_timeout: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub upstreams: Vec<DockerUpstream>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Docker upstream registry configuration
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DockerUpstream {
|
||||||
|
pub url: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub auth: Option<String>, // "user:pass" for basic auth
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maven upstream proxy configuration
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
pub enum MavenProxyEntry {
|
||||||
|
Simple(String),
|
||||||
|
Full(MavenProxy),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maven upstream proxy with optional auth
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct MavenProxy {
|
||||||
|
pub url: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub auth: Option<String>, // "user:pass" for basic auth
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MavenProxyEntry {
|
||||||
|
pub fn url(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
MavenProxyEntry::Simple(s) => s,
|
||||||
|
MavenProxyEntry::Full(p) => &p.url,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn auth(&self) -> Option<&str> {
|
||||||
|
match self {
|
||||||
|
MavenProxyEntry::Simple(_) => None,
|
||||||
|
MavenProxyEntry::Full(p) => p.auth.as_deref(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raw repository configuration for simple file storage
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct RawConfig {
|
||||||
|
#[serde(default = "default_raw_enabled")]
|
||||||
|
pub enabled: bool,
|
||||||
|
#[serde(default = "default_max_file_size")]
|
||||||
|
pub max_file_size: u64, // in bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_docker_timeout() -> u64 {
|
||||||
|
60
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_raw_enabled() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_max_file_size() -> u64 {
|
||||||
|
104_857_600 // 100MB
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct AuthConfig {
|
pub struct AuthConfig {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -93,7 +218,9 @@ fn default_timeout() -> u64 {
|
|||||||
impl Default for MavenConfig {
|
impl Default for MavenConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
proxies: vec!["https://repo1.maven.org/maven2".to_string()],
|
proxies: vec![MavenProxyEntry::Simple(
|
||||||
|
"https://repo1.maven.org/maven2".to_string(),
|
||||||
|
)],
|
||||||
proxy_timeout: 30,
|
proxy_timeout: 30,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -103,11 +230,43 @@ impl Default for NpmConfig {
|
|||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
proxy: Some("https://registry.npmjs.org".to_string()),
|
proxy: Some("https://registry.npmjs.org".to_string()),
|
||||||
|
proxy_auth: None,
|
||||||
proxy_timeout: 30,
|
proxy_timeout: 30,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Default for PypiConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
proxy: Some("https://pypi.org/simple/".to_string()),
|
||||||
|
proxy_auth: None,
|
||||||
|
proxy_timeout: 30,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for DockerConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
proxy_timeout: 60,
|
||||||
|
upstreams: vec![DockerUpstream {
|
||||||
|
url: "https://registry-1.docker.io".to_string(),
|
||||||
|
auth: None,
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for RawConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: true,
|
||||||
|
max_file_size: 104_857_600, // 100MB
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Default for AuthConfig {
|
impl Default for AuthConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -118,7 +277,114 @@ impl Default for AuthConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rate limiting configuration
|
||||||
|
///
|
||||||
|
/// Controls request rate limits for different endpoint types.
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
/// ```toml
|
||||||
|
/// [rate_limit]
|
||||||
|
/// auth_rps = 1
|
||||||
|
/// auth_burst = 5
|
||||||
|
/// upload_rps = 500
|
||||||
|
/// upload_burst = 1000
|
||||||
|
/// general_rps = 100
|
||||||
|
/// general_burst = 200
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// # Environment Variables
|
||||||
|
/// - `NORA_RATE_LIMIT_AUTH_RPS` - Auth requests per second
|
||||||
|
/// - `NORA_RATE_LIMIT_AUTH_BURST` - Auth burst size
|
||||||
|
/// - `NORA_RATE_LIMIT_UPLOAD_RPS` - Upload requests per second
|
||||||
|
/// - `NORA_RATE_LIMIT_UPLOAD_BURST` - Upload burst size
|
||||||
|
/// - `NORA_RATE_LIMIT_GENERAL_RPS` - General requests per second
|
||||||
|
/// - `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")]
|
||||||
|
pub auth_burst: u32,
|
||||||
|
#[serde(default = "default_upload_rps")]
|
||||||
|
pub upload_rps: u64,
|
||||||
|
#[serde(default = "default_upload_burst")]
|
||||||
|
pub upload_burst: u32,
|
||||||
|
#[serde(default = "default_general_rps")]
|
||||||
|
pub general_rps: u64,
|
||||||
|
#[serde(default = "default_general_burst")]
|
||||||
|
pub general_burst: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_rate_limit_enabled() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
fn default_auth_rps() -> u64 {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
fn default_auth_burst() -> u32 {
|
||||||
|
5
|
||||||
|
}
|
||||||
|
fn default_upload_rps() -> u64 {
|
||||||
|
200
|
||||||
|
}
|
||||||
|
fn default_upload_burst() -> u32 {
|
||||||
|
500
|
||||||
|
}
|
||||||
|
fn default_general_rps() -> u64 {
|
||||||
|
100
|
||||||
|
}
|
||||||
|
fn default_general_burst() -> u32 {
|
||||||
|
200
|
||||||
|
}
|
||||||
|
|
||||||
|
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(),
|
||||||
|
upload_burst: default_upload_burst(),
|
||||||
|
general_rps: default_general_rps(),
|
||||||
|
general_burst: default_general_burst(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
|
/// Warn if credentials are configured via config.toml (not env vars)
|
||||||
|
pub fn warn_plaintext_credentials(&self) {
|
||||||
|
// Docker upstreams
|
||||||
|
for (i, upstream) in self.docker.upstreams.iter().enumerate() {
|
||||||
|
if upstream.auth.is_some() && std::env::var("NORA_DOCKER_UPSTREAMS").is_err() {
|
||||||
|
tracing::warn!(
|
||||||
|
upstream_index = i,
|
||||||
|
url = %upstream.url,
|
||||||
|
"Docker upstream credentials in config.toml are plaintext — consider NORA_DOCKER_UPSTREAMS env var"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Maven proxies
|
||||||
|
for proxy in &self.maven.proxies {
|
||||||
|
if proxy.auth().is_some() && std::env::var("NORA_MAVEN_PROXIES").is_err() {
|
||||||
|
tracing::warn!(
|
||||||
|
url = %proxy.url(),
|
||||||
|
"Maven proxy credentials in config.toml are plaintext — consider NORA_MAVEN_PROXIES env var"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// npm
|
||||||
|
if self.npm.proxy_auth.is_some() && std::env::var("NORA_NPM_PROXY_AUTH").is_err() {
|
||||||
|
tracing::warn!("npm proxy credentials in config.toml are plaintext — consider NORA_NPM_PROXY_AUTH env var");
|
||||||
|
}
|
||||||
|
// PyPI
|
||||||
|
if self.pypi.proxy_auth.is_some() && std::env::var("NORA_PYPI_PROXY_AUTH").is_err() {
|
||||||
|
tracing::warn!("PyPI proxy credentials in config.toml are plaintext — consider NORA_PYPI_PROXY_AUTH env var");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Load configuration with priority: ENV > config.toml > defaults
|
/// Load configuration with priority: ENV > config.toml > defaults
|
||||||
pub fn load() -> Self {
|
pub fn load() -> Self {
|
||||||
// 1. Start with defaults
|
// 1. Start with defaults
|
||||||
@@ -144,6 +410,14 @@ impl Config {
|
|||||||
self.server.port = port;
|
self.server.port = port;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Ok(val) = env::var("NORA_PUBLIC_URL") {
|
||||||
|
self.server.public_url = if val.is_empty() { None } else { Some(val) };
|
||||||
|
}
|
||||||
|
if let Ok(val) = env::var("NORA_BODY_LIMIT_MB") {
|
||||||
|
if let Ok(mb) = val.parse() {
|
||||||
|
self.server.body_limit_mb = mb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Storage config
|
// Storage config
|
||||||
if let Ok(val) = env::var("NORA_STORAGE_MODE") {
|
if let Ok(val) = env::var("NORA_STORAGE_MODE") {
|
||||||
@@ -161,6 +435,15 @@ impl Config {
|
|||||||
if let Ok(val) = env::var("NORA_STORAGE_BUCKET") {
|
if let Ok(val) = env::var("NORA_STORAGE_BUCKET") {
|
||||||
self.storage.bucket = val;
|
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
|
// Auth config
|
||||||
if let Ok(val) = env::var("NORA_AUTH_ENABLED") {
|
if let Ok(val) = env::var("NORA_AUTH_ENABLED") {
|
||||||
@@ -170,9 +453,23 @@ impl Config {
|
|||||||
self.auth.htpasswd_file = val;
|
self.auth.htpasswd_file = val;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Maven config
|
// Maven config — supports "url1,url2" or "url1|auth1,url2|auth2"
|
||||||
if let Ok(val) = env::var("NORA_MAVEN_PROXIES") {
|
if let Ok(val) = env::var("NORA_MAVEN_PROXIES") {
|
||||||
self.maven.proxies = val.split(',').map(|s| s.trim().to_string()).collect();
|
self.maven.proxies = val
|
||||||
|
.split(',')
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(|s| {
|
||||||
|
let parts: Vec<&str> = s.trim().splitn(2, '|').collect();
|
||||||
|
if parts.len() > 1 {
|
||||||
|
MavenProxyEntry::Full(MavenProxy {
|
||||||
|
url: parts[0].to_string(),
|
||||||
|
auth: Some(parts[1].to_string()),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
MavenProxyEntry::Simple(parts[0].to_string())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
}
|
}
|
||||||
if let Ok(val) = env::var("NORA_MAVEN_PROXY_TIMEOUT") {
|
if let Ok(val) = env::var("NORA_MAVEN_PROXY_TIMEOUT") {
|
||||||
if let Ok(timeout) = val.parse() {
|
if let Ok(timeout) = val.parse() {
|
||||||
@@ -190,10 +487,104 @@ impl Config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// npm proxy auth
|
||||||
|
if let Ok(val) = env::var("NORA_NPM_PROXY_AUTH") {
|
||||||
|
self.npm.proxy_auth = if val.is_empty() { None } else { Some(val) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// PyPI config
|
||||||
|
if let Ok(val) = env::var("NORA_PYPI_PROXY") {
|
||||||
|
self.pypi.proxy = if val.is_empty() { None } else { Some(val) };
|
||||||
|
}
|
||||||
|
if let Ok(val) = env::var("NORA_PYPI_PROXY_TIMEOUT") {
|
||||||
|
if let Ok(timeout) = val.parse() {
|
||||||
|
self.pypi.proxy_timeout = timeout;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PyPI proxy auth
|
||||||
|
if let Ok(val) = env::var("NORA_PYPI_PROXY_AUTH") {
|
||||||
|
self.pypi.proxy_auth = if val.is_empty() { None } else { Some(val) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Docker config
|
||||||
|
if let Ok(val) = env::var("NORA_DOCKER_PROXY_TIMEOUT") {
|
||||||
|
if let Ok(timeout) = val.parse() {
|
||||||
|
self.docker.proxy_timeout = timeout;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// NORA_DOCKER_UPSTREAMS format: "url1,url2" or "url1|auth1,url2|auth2"
|
||||||
|
if let Ok(val) = env::var("NORA_DOCKER_UPSTREAMS") {
|
||||||
|
self.docker.upstreams = val
|
||||||
|
.split(',')
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(|s| {
|
||||||
|
let parts: Vec<&str> = s.trim().splitn(2, '|').collect();
|
||||||
|
DockerUpstream {
|
||||||
|
url: parts[0].to_string(),
|
||||||
|
auth: parts.get(1).map(|a| a.to_string()),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raw config
|
||||||
|
if let Ok(val) = env::var("NORA_RAW_ENABLED") {
|
||||||
|
self.raw.enabled = val.to_lowercase() == "true" || val == "1";
|
||||||
|
}
|
||||||
|
if let Ok(val) = env::var("NORA_RAW_MAX_FILE_SIZE") {
|
||||||
|
if let Ok(size) = val.parse() {
|
||||||
|
self.raw.max_file_size = size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Token storage
|
// Token storage
|
||||||
if let Ok(val) = env::var("NORA_AUTH_TOKEN_STORAGE") {
|
if let Ok(val) = env::var("NORA_AUTH_TOKEN_STORAGE") {
|
||||||
self.auth.token_storage = val;
|
self.auth.token_storage = val;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Ok(val) = env::var("NORA_RATE_LIMIT_AUTH_BURST") {
|
||||||
|
if let Ok(v) = val.parse::<u32>() {
|
||||||
|
self.rate_limit.auth_burst = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Ok(val) = env::var("NORA_RATE_LIMIT_UPLOAD_RPS") {
|
||||||
|
if let Ok(v) = val.parse::<u64>() {
|
||||||
|
self.rate_limit.upload_rps = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Ok(val) = env::var("NORA_RATE_LIMIT_UPLOAD_BURST") {
|
||||||
|
if let Ok(v) = val.parse::<u32>() {
|
||||||
|
self.rate_limit.upload_burst = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Ok(val) = env::var("NORA_RATE_LIMIT_GENERAL_RPS") {
|
||||||
|
if let Ok(v) = val.parse::<u64>() {
|
||||||
|
self.rate_limit.general_rps = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Ok(val) = env::var("NORA_RATE_LIMIT_GENERAL_BURST") {
|
||||||
|
if let Ok(v) = val.parse::<u32>() {
|
||||||
|
self.rate_limit.general_burst = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Secrets config
|
||||||
|
if let Ok(val) = env::var("NORA_SECRETS_PROVIDER") {
|
||||||
|
self.secrets.provider = val;
|
||||||
|
}
|
||||||
|
if let Ok(val) = env::var("NORA_SECRETS_CLEAR_ENV") {
|
||||||
|
self.secrets.clear_env = val.to_lowercase() == "true" || val == "1";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,16 +594,63 @@ impl Default for Config {
|
|||||||
server: ServerConfig {
|
server: ServerConfig {
|
||||||
host: String::from("127.0.0.1"),
|
host: String::from("127.0.0.1"),
|
||||||
port: 4000,
|
port: 4000,
|
||||||
|
public_url: None,
|
||||||
|
body_limit_mb: 2048,
|
||||||
},
|
},
|
||||||
storage: StorageConfig {
|
storage: StorageConfig {
|
||||||
mode: StorageMode::Local,
|
mode: StorageMode::Local,
|
||||||
path: String::from("data/storage"),
|
path: String::from("data/storage"),
|
||||||
s3_url: String::from("http://127.0.0.1:3000"),
|
s3_url: String::from("http://127.0.0.1:3000"),
|
||||||
bucket: String::from("registry"),
|
bucket: String::from("registry"),
|
||||||
|
s3_access_key: None,
|
||||||
|
s3_secret_key: None,
|
||||||
|
s3_region: String::from("us-east-1"),
|
||||||
},
|
},
|
||||||
maven: MavenConfig::default(),
|
maven: MavenConfig::default(),
|
||||||
npm: NpmConfig::default(),
|
npm: NpmConfig::default(),
|
||||||
|
pypi: PypiConfig::default(),
|
||||||
|
docker: DockerConfig::default(),
|
||||||
|
raw: RawConfig::default(),
|
||||||
auth: AuthConfig::default(),
|
auth: AuthConfig::default(),
|
||||||
|
rate_limit: RateLimitConfig::default(),
|
||||||
|
secrets: SecretsConfig::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rate_limit_default() {
|
||||||
|
let config = RateLimitConfig::default();
|
||||||
|
assert_eq!(config.auth_rps, 1);
|
||||||
|
assert_eq!(config.auth_burst, 5);
|
||||||
|
assert_eq!(config.upload_rps, 200);
|
||||||
|
assert_eq!(config.upload_burst, 500);
|
||||||
|
assert_eq!(config.general_rps, 100);
|
||||||
|
assert_eq!(config.general_burst, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rate_limit_from_toml() {
|
||||||
|
let toml = r#"
|
||||||
|
[server]
|
||||||
|
host = "127.0.0.1"
|
||||||
|
port = 4000
|
||||||
|
|
||||||
|
[storage]
|
||||||
|
mode = "local"
|
||||||
|
|
||||||
|
[rate_limit]
|
||||||
|
auth_rps = 10
|
||||||
|
upload_burst = 1000
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let config: Config = toml::from_str(toml).unwrap();
|
||||||
|
assert_eq!(config.rate_limit.auth_rps, 10);
|
||||||
|
assert_eq!(config.rate_limit.upload_burst, 1000);
|
||||||
|
assert_eq!(config.rate_limit.auth_burst, 5); // default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
218
nora-registry/src/dashboard_metrics.rs
Normal file
218
nora-registry/src/dashboard_metrics.rs
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::time::Instant;
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
/// Serializable snapshot of metrics for persistence
|
||||||
|
#[derive(Serialize, Deserialize, Default)]
|
||||||
|
struct MetricsSnapshot {
|
||||||
|
downloads: u64,
|
||||||
|
uploads: u64,
|
||||||
|
cache_hits: u64,
|
||||||
|
cache_misses: u64,
|
||||||
|
docker_downloads: u64,
|
||||||
|
docker_uploads: u64,
|
||||||
|
npm_downloads: u64,
|
||||||
|
maven_downloads: u64,
|
||||||
|
maven_uploads: u64,
|
||||||
|
cargo_downloads: u64,
|
||||||
|
pypi_downloads: u64,
|
||||||
|
raw_downloads: u64,
|
||||||
|
raw_uploads: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dashboard metrics for tracking registry activity
|
||||||
|
/// Uses atomic counters for thread-safe access without locks
|
||||||
|
pub struct DashboardMetrics {
|
||||||
|
// Global counters
|
||||||
|
pub downloads: AtomicU64,
|
||||||
|
pub uploads: AtomicU64,
|
||||||
|
pub cache_hits: AtomicU64,
|
||||||
|
pub cache_misses: AtomicU64,
|
||||||
|
|
||||||
|
// Per-registry download counters
|
||||||
|
pub docker_downloads: AtomicU64,
|
||||||
|
pub docker_uploads: AtomicU64,
|
||||||
|
pub npm_downloads: AtomicU64,
|
||||||
|
pub maven_downloads: AtomicU64,
|
||||||
|
pub maven_uploads: AtomicU64,
|
||||||
|
pub cargo_downloads: AtomicU64,
|
||||||
|
pub pypi_downloads: AtomicU64,
|
||||||
|
pub raw_downloads: AtomicU64,
|
||||||
|
pub raw_uploads: AtomicU64,
|
||||||
|
|
||||||
|
pub start_time: Instant,
|
||||||
|
|
||||||
|
/// Path to metrics.json for persistence
|
||||||
|
persist_path: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DashboardMetrics {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
downloads: AtomicU64::new(0),
|
||||||
|
uploads: AtomicU64::new(0),
|
||||||
|
cache_hits: AtomicU64::new(0),
|
||||||
|
cache_misses: AtomicU64::new(0),
|
||||||
|
docker_downloads: AtomicU64::new(0),
|
||||||
|
docker_uploads: AtomicU64::new(0),
|
||||||
|
npm_downloads: AtomicU64::new(0),
|
||||||
|
maven_downloads: AtomicU64::new(0),
|
||||||
|
maven_uploads: AtomicU64::new(0),
|
||||||
|
cargo_downloads: AtomicU64::new(0),
|
||||||
|
pypi_downloads: AtomicU64::new(0),
|
||||||
|
raw_downloads: AtomicU64::new(0),
|
||||||
|
raw_uploads: AtomicU64::new(0),
|
||||||
|
start_time: Instant::now(),
|
||||||
|
persist_path: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create metrics with persistence — loads existing data from metrics.json
|
||||||
|
pub fn with_persistence(storage_path: &str) -> Self {
|
||||||
|
let path = Path::new(storage_path).join("metrics.json");
|
||||||
|
let mut metrics = Self::new();
|
||||||
|
metrics.persist_path = Some(path.clone());
|
||||||
|
|
||||||
|
// Load existing metrics if file exists
|
||||||
|
if path.exists() {
|
||||||
|
match std::fs::read_to_string(&path) {
|
||||||
|
Ok(data) => match serde_json::from_str::<MetricsSnapshot>(&data) {
|
||||||
|
Ok(snap) => {
|
||||||
|
metrics.downloads = AtomicU64::new(snap.downloads);
|
||||||
|
metrics.uploads = AtomicU64::new(snap.uploads);
|
||||||
|
metrics.cache_hits = AtomicU64::new(snap.cache_hits);
|
||||||
|
metrics.cache_misses = AtomicU64::new(snap.cache_misses);
|
||||||
|
metrics.docker_downloads = AtomicU64::new(snap.docker_downloads);
|
||||||
|
metrics.docker_uploads = AtomicU64::new(snap.docker_uploads);
|
||||||
|
metrics.npm_downloads = AtomicU64::new(snap.npm_downloads);
|
||||||
|
metrics.maven_downloads = AtomicU64::new(snap.maven_downloads);
|
||||||
|
metrics.maven_uploads = AtomicU64::new(snap.maven_uploads);
|
||||||
|
metrics.cargo_downloads = AtomicU64::new(snap.cargo_downloads);
|
||||||
|
metrics.pypi_downloads = AtomicU64::new(snap.pypi_downloads);
|
||||||
|
metrics.raw_downloads = AtomicU64::new(snap.raw_downloads);
|
||||||
|
metrics.raw_uploads = AtomicU64::new(snap.raw_uploads);
|
||||||
|
info!(
|
||||||
|
downloads = snap.downloads,
|
||||||
|
uploads = snap.uploads,
|
||||||
|
"Loaded persisted metrics"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => warn!("Failed to parse metrics.json: {}", e),
|
||||||
|
},
|
||||||
|
Err(e) => warn!("Failed to read metrics.json: {}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
metrics
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save current metrics to disk
|
||||||
|
pub fn save(&self) {
|
||||||
|
let Some(path) = &self.persist_path else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let snap = MetricsSnapshot {
|
||||||
|
downloads: self.downloads.load(Ordering::Relaxed),
|
||||||
|
uploads: self.uploads.load(Ordering::Relaxed),
|
||||||
|
cache_hits: self.cache_hits.load(Ordering::Relaxed),
|
||||||
|
cache_misses: self.cache_misses.load(Ordering::Relaxed),
|
||||||
|
docker_downloads: self.docker_downloads.load(Ordering::Relaxed),
|
||||||
|
docker_uploads: self.docker_uploads.load(Ordering::Relaxed),
|
||||||
|
npm_downloads: self.npm_downloads.load(Ordering::Relaxed),
|
||||||
|
maven_downloads: self.maven_downloads.load(Ordering::Relaxed),
|
||||||
|
maven_uploads: self.maven_uploads.load(Ordering::Relaxed),
|
||||||
|
cargo_downloads: self.cargo_downloads.load(Ordering::Relaxed),
|
||||||
|
pypi_downloads: self.pypi_downloads.load(Ordering::Relaxed),
|
||||||
|
raw_downloads: self.raw_downloads.load(Ordering::Relaxed),
|
||||||
|
raw_uploads: self.raw_uploads.load(Ordering::Relaxed),
|
||||||
|
};
|
||||||
|
// Atomic write: write to tmp then rename
|
||||||
|
let tmp = path.with_extension("json.tmp");
|
||||||
|
if let Ok(data) = serde_json::to_string_pretty(&snap) {
|
||||||
|
if std::fs::write(&tmp, &data).is_ok() {
|
||||||
|
let _ = std::fs::rename(&tmp, path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a download event for the specified registry
|
||||||
|
pub fn record_download(&self, registry: &str) {
|
||||||
|
self.downloads.fetch_add(1, Ordering::Relaxed);
|
||||||
|
match registry {
|
||||||
|
"docker" => self.docker_downloads.fetch_add(1, Ordering::Relaxed),
|
||||||
|
"npm" => self.npm_downloads.fetch_add(1, Ordering::Relaxed),
|
||||||
|
"maven" => self.maven_downloads.fetch_add(1, Ordering::Relaxed),
|
||||||
|
"cargo" => self.cargo_downloads.fetch_add(1, Ordering::Relaxed),
|
||||||
|
"pypi" => self.pypi_downloads.fetch_add(1, Ordering::Relaxed),
|
||||||
|
"raw" => self.raw_downloads.fetch_add(1, Ordering::Relaxed),
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record an upload event for the specified registry
|
||||||
|
pub fn record_upload(&self, registry: &str) {
|
||||||
|
self.uploads.fetch_add(1, Ordering::Relaxed);
|
||||||
|
match registry {
|
||||||
|
"docker" => self.docker_uploads.fetch_add(1, Ordering::Relaxed),
|
||||||
|
"maven" => self.maven_uploads.fetch_add(1, Ordering::Relaxed),
|
||||||
|
"raw" => self.raw_uploads.fetch_add(1, Ordering::Relaxed),
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a cache hit
|
||||||
|
pub fn record_cache_hit(&self) {
|
||||||
|
self.cache_hits.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a cache miss
|
||||||
|
pub fn record_cache_miss(&self) {
|
||||||
|
self.cache_misses.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate the cache hit rate as a percentage
|
||||||
|
pub fn cache_hit_rate(&self) -> f64 {
|
||||||
|
let hits = self.cache_hits.load(Ordering::Relaxed);
|
||||||
|
let misses = self.cache_misses.load(Ordering::Relaxed);
|
||||||
|
let total = hits + misses;
|
||||||
|
if total == 0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
(hits as f64 / total as f64) * 100.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get download count for a specific registry
|
||||||
|
pub fn get_registry_downloads(&self, registry: &str) -> u64 {
|
||||||
|
match registry {
|
||||||
|
"docker" => self.docker_downloads.load(Ordering::Relaxed),
|
||||||
|
"npm" => self.npm_downloads.load(Ordering::Relaxed),
|
||||||
|
"maven" => self.maven_downloads.load(Ordering::Relaxed),
|
||||||
|
"cargo" => self.cargo_downloads.load(Ordering::Relaxed),
|
||||||
|
"pypi" => self.pypi_downloads.load(Ordering::Relaxed),
|
||||||
|
"raw" => self.raw_downloads.load(Ordering::Relaxed),
|
||||||
|
_ => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get upload count for a specific registry
|
||||||
|
pub fn get_registry_uploads(&self, registry: &str) -> u64 {
|
||||||
|
match registry {
|
||||||
|
"docker" => self.docker_uploads.load(Ordering::Relaxed),
|
||||||
|
"maven" => self.maven_uploads.load(Ordering::Relaxed),
|
||||||
|
"raw" => self.raw_uploads.load(Ordering::Relaxed),
|
||||||
|
_ => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for DashboardMetrics {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
#![allow(dead_code)]
|
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
//! Application error handling with HTTP response conversion
|
//! Application error handling with HTTP response conversion
|
||||||
//!
|
//!
|
||||||
//! Provides a unified error type that can be converted to HTTP responses
|
//! Provides a unified error type that can be converted to HTTP responses
|
||||||
@@ -15,6 +17,7 @@ use thiserror::Error;
|
|||||||
use crate::storage::StorageError;
|
use crate::storage::StorageError;
|
||||||
use crate::validation::ValidationError;
|
use crate::validation::ValidationError;
|
||||||
|
|
||||||
|
#[allow(dead_code)] // Wiring into handlers planned for v0.3
|
||||||
/// Application-level errors with HTTP response conversion
|
/// Application-level errors with HTTP response conversion
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum AppError {
|
pub enum AppError {
|
||||||
@@ -37,6 +40,7 @@ pub enum AppError {
|
|||||||
Validation(#[from] ValidationError),
|
Validation(#[from] ValidationError),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
/// JSON error response body
|
/// JSON error response body
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct ErrorResponse {
|
struct ErrorResponse {
|
||||||
@@ -71,6 +75,7 @@ impl IntoResponse for AppError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
impl AppError {
|
impl AppError {
|
||||||
/// Create a not found error
|
/// Create a not found error
|
||||||
pub fn not_found(msg: impl Into<String>) -> Self {
|
pub fn not_found(msg: impl Into<String>) -> Self {
|
||||||
|
|||||||
121
nora-registry/src/gc.rs
Normal file
121
nora-registry/src/gc.rs
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
//! Garbage Collection for orphaned blobs
|
||||||
|
//!
|
||||||
|
//! Mark-and-sweep approach:
|
||||||
|
//! 1. List all blobs across registries
|
||||||
|
//! 2. Parse all manifests to find referenced blobs
|
||||||
|
//! 3. Blobs not referenced by any manifest = orphans
|
||||||
|
//! 4. Delete orphans (with --dry-run support)
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
use crate::storage::Storage;
|
||||||
|
|
||||||
|
pub struct GcResult {
|
||||||
|
pub total_blobs: usize,
|
||||||
|
pub referenced_blobs: usize,
|
||||||
|
pub orphaned_blobs: usize,
|
||||||
|
pub deleted_blobs: usize,
|
||||||
|
pub orphan_keys: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run_gc(storage: &Storage, dry_run: bool) -> GcResult {
|
||||||
|
info!("Starting garbage collection (dry_run={})", dry_run);
|
||||||
|
|
||||||
|
// 1. Collect all blob keys
|
||||||
|
let all_blobs = collect_all_blobs(storage).await;
|
||||||
|
info!("Found {} total blobs", all_blobs.len());
|
||||||
|
|
||||||
|
// 2. Collect all referenced digests from manifests
|
||||||
|
let referenced = collect_referenced_digests(storage).await;
|
||||||
|
info!(
|
||||||
|
"Found {} referenced digests from manifests",
|
||||||
|
referenced.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Find orphans
|
||||||
|
let mut orphan_keys: Vec<String> = Vec::new();
|
||||||
|
for key in &all_blobs {
|
||||||
|
if let Some(digest) = key.rsplit('/').next() {
|
||||||
|
if !referenced.contains(digest) {
|
||||||
|
orphan_keys.push(key.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Found {} orphaned blobs", orphan_keys.len());
|
||||||
|
|
||||||
|
let mut deleted = 0;
|
||||||
|
if !dry_run {
|
||||||
|
for key in &orphan_keys {
|
||||||
|
if storage.delete(key).await.is_ok() {
|
||||||
|
deleted += 1;
|
||||||
|
info!("Deleted: {}", key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
info!("Deleted {} orphaned blobs", deleted);
|
||||||
|
} else {
|
||||||
|
for key in &orphan_keys {
|
||||||
|
info!("[dry-run] Would delete: {}", key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GcResult {
|
||||||
|
total_blobs: all_blobs.len(),
|
||||||
|
referenced_blobs: referenced.len(),
|
||||||
|
orphaned_blobs: orphan_keys.len(),
|
||||||
|
deleted_blobs: deleted,
|
||||||
|
orphan_keys,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn collect_all_blobs(storage: &Storage) -> Vec<String> {
|
||||||
|
let mut blobs = Vec::new();
|
||||||
|
let docker_blobs = storage.list("docker/").await;
|
||||||
|
for key in docker_blobs {
|
||||||
|
if key.contains("/blobs/") {
|
||||||
|
blobs.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
blobs
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn collect_referenced_digests(storage: &Storage) -> HashSet<String> {
|
||||||
|
let mut referenced = HashSet::new();
|
||||||
|
|
||||||
|
let all_keys = storage.list("docker/").await;
|
||||||
|
for key in &all_keys {
|
||||||
|
if !key.contains("/manifests/") || !key.ends_with(".json") || key.ends_with(".meta.json") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(data) = storage.get(key).await {
|
||||||
|
if let Ok(json) = serde_json::from_slice::<serde_json::Value>(&data) {
|
||||||
|
if let Some(config) = json.get("config") {
|
||||||
|
if let Some(digest) = config.get("digest").and_then(|v| v.as_str()) {
|
||||||
|
referenced.insert(digest.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(layers) = json.get("layers").and_then(|v| v.as_array()) {
|
||||||
|
for layer in layers {
|
||||||
|
if let Some(digest) = layer.get("digest").and_then(|v| v.as_str()) {
|
||||||
|
referenced.insert(digest.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(manifests) = json.get("manifests").and_then(|v| v.as_array()) {
|
||||||
|
for m in manifests {
|
||||||
|
if let Some(digest) = m.get("digest").and_then(|v| v.as_str()) {
|
||||||
|
referenced.insert(digest.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
referenced
|
||||||
|
}
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
use axum::{extract::State, http::StatusCode, response::Json, routing::get, Router};
|
use axum::{extract::State, http::StatusCode, response::Json, routing::get, Router};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|||||||
@@ -1,14 +1,23 @@
|
|||||||
|
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
mod activity_log;
|
||||||
|
mod audit;
|
||||||
mod auth;
|
mod auth;
|
||||||
mod backup;
|
mod backup;
|
||||||
mod config;
|
mod config;
|
||||||
|
mod dashboard_metrics;
|
||||||
mod error;
|
mod error;
|
||||||
|
mod gc;
|
||||||
mod health;
|
mod health;
|
||||||
mod metrics;
|
mod metrics;
|
||||||
mod migrate;
|
mod migrate;
|
||||||
mod openapi;
|
mod openapi;
|
||||||
mod rate_limit;
|
mod rate_limit;
|
||||||
mod registry;
|
mod registry;
|
||||||
|
mod repo_index;
|
||||||
mod request_id;
|
mod request_id;
|
||||||
|
mod secrets;
|
||||||
mod storage;
|
mod storage;
|
||||||
mod tokens;
|
mod tokens;
|
||||||
mod ui;
|
mod ui;
|
||||||
@@ -23,8 +32,12 @@ use tokio::signal;
|
|||||||
use tracing::{error, info, warn};
|
use tracing::{error, info, warn};
|
||||||
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
|
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
|
||||||
|
|
||||||
|
use activity_log::ActivityLog;
|
||||||
|
use audit::AuditLog;
|
||||||
use auth::HtpasswdAuth;
|
use auth::HtpasswdAuth;
|
||||||
use config::{Config, StorageMode};
|
use config::{Config, StorageMode};
|
||||||
|
use dashboard_metrics::DashboardMetrics;
|
||||||
|
use repo_index::RepoIndex;
|
||||||
pub use storage::Storage;
|
pub use storage::Storage;
|
||||||
use tokens::TokenStore;
|
use tokens::TokenStore;
|
||||||
|
|
||||||
@@ -51,6 +64,12 @@ enum Commands {
|
|||||||
#[arg(short, long)]
|
#[arg(short, long)]
|
||||||
input: PathBuf,
|
input: PathBuf,
|
||||||
},
|
},
|
||||||
|
/// Garbage collect orphaned blobs
|
||||||
|
Gc {
|
||||||
|
/// Dry run - show what would be deleted without deleting
|
||||||
|
#[arg(long, default_value = "false")]
|
||||||
|
dry_run: bool,
|
||||||
|
},
|
||||||
/// Migrate artifacts between storage backends
|
/// Migrate artifacts between storage backends
|
||||||
Migrate {
|
Migrate {
|
||||||
/// Source storage: local or s3
|
/// Source storage: local or s3
|
||||||
@@ -71,6 +90,12 @@ pub struct AppState {
|
|||||||
pub start_time: Instant,
|
pub start_time: Instant,
|
||||||
pub auth: Option<HtpasswdAuth>,
|
pub auth: Option<HtpasswdAuth>,
|
||||||
pub tokens: Option<TokenStore>,
|
pub tokens: Option<TokenStore>,
|
||||||
|
pub metrics: DashboardMetrics,
|
||||||
|
pub activity: ActivityLog,
|
||||||
|
pub audit: AuditLog,
|
||||||
|
pub docker_auth: registry::DockerAuth,
|
||||||
|
pub repo_index: RepoIndex,
|
||||||
|
pub http_client: reqwest::Client,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
@@ -96,10 +121,18 @@ async fn main() {
|
|||||||
info!(
|
info!(
|
||||||
s3_url = %config.storage.s3_url,
|
s3_url = %config.storage.s3_url,
|
||||||
bucket = %config.storage.bucket,
|
bucket = %config.storage.bucket,
|
||||||
|
region = %config.storage.s3_region,
|
||||||
|
has_credentials = config.storage.s3_access_key.is_some(),
|
||||||
"Using S3 storage"
|
"Using S3 storage"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Storage::new_s3(&config.storage.s3_url, &config.storage.bucket)
|
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(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -120,10 +153,27 @@ async fn main() {
|
|||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Some(Commands::Gc { dry_run }) => {
|
||||||
|
let result = gc::run_gc(&storage, dry_run).await;
|
||||||
|
println!("GC Summary:");
|
||||||
|
println!(" Total blobs: {}", result.total_blobs);
|
||||||
|
println!(" Referenced: {}", result.referenced_blobs);
|
||||||
|
println!(" Orphaned: {}", result.orphaned_blobs);
|
||||||
|
println!(" Deleted: {}", result.deleted_blobs);
|
||||||
|
if dry_run && !result.orphan_keys.is_empty() {
|
||||||
|
println!("\nRun without --dry-run to delete orphaned blobs.");
|
||||||
|
}
|
||||||
|
}
|
||||||
Some(Commands::Migrate { from, to, dry_run }) => {
|
Some(Commands::Migrate { from, to, dry_run }) => {
|
||||||
let source = match from.as_str() {
|
let source = match from.as_str() {
|
||||||
"local" => Storage::new_local(&config.storage.path),
|
"local" => Storage::new_local(&config.storage.path),
|
||||||
"s3" => Storage::new_s3(&config.storage.s3_url, &config.storage.bucket),
|
"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(),
|
||||||
|
),
|
||||||
_ => {
|
_ => {
|
||||||
error!("Invalid source: '{}'. Use 'local' or 's3'", from);
|
error!("Invalid source: '{}'. Use 'local' or 's3'", from);
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
@@ -132,7 +182,13 @@ async fn main() {
|
|||||||
|
|
||||||
let dest = match to.as_str() {
|
let dest = match to.as_str() {
|
||||||
"local" => Storage::new_local(&config.storage.path),
|
"local" => Storage::new_local(&config.storage.path),
|
||||||
"s3" => Storage::new_s3(&config.storage.s3_url, &config.storage.bucket),
|
"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(),
|
||||||
|
),
|
||||||
_ => {
|
_ => {
|
||||||
error!("Invalid destination: '{}'. Use 'local' or 's3'", to);
|
error!("Invalid destination: '{}'. Use 'local' or 's3'", to);
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
@@ -173,6 +229,37 @@ fn init_logging(json_format: bool) {
|
|||||||
async fn run_server(config: Config, storage: Storage) {
|
async fn run_server(config: Config, storage: Storage) {
|
||||||
let start_time = Instant::now();
|
let start_time = Instant::now();
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
upload_burst = config.rate_limit.upload_burst,
|
||||||
|
general_rps = config.rate_limit.general_rps,
|
||||||
|
general_burst = config.rate_limit.general_burst,
|
||||||
|
"Rate limiting configured"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Initialize secrets provider
|
||||||
|
let secrets_provider = match secrets::create_secrets_provider(&config.secrets) {
|
||||||
|
Ok(provider) => {
|
||||||
|
info!(
|
||||||
|
provider = provider.provider_name(),
|
||||||
|
clear_env = config.secrets.clear_env,
|
||||||
|
"Secrets provider initialized"
|
||||||
|
);
|
||||||
|
Some(provider)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!(error = %e, "Failed to initialize secrets provider, using defaults");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store secrets provider for future use (S3 credentials, etc.)
|
||||||
|
let _secrets = secrets_provider;
|
||||||
|
|
||||||
// Load auth if enabled
|
// Load auth if enabled
|
||||||
let auth = if config.auth.enabled {
|
let auth = if config.auth.enabled {
|
||||||
let path = Path::new(&config.auth.htpasswd_file);
|
let path = Path::new(&config.auth.htpasswd_file);
|
||||||
@@ -199,25 +286,25 @@ async fn run_server(config: Config, storage: Storage) {
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
let state = Arc::new(AppState {
|
let storage_path = config.storage.path.clone();
|
||||||
storage,
|
let rate_limit_enabled = config.rate_limit.enabled;
|
||||||
config,
|
|
||||||
start_time,
|
|
||||||
auth,
|
|
||||||
tokens,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Token routes with strict rate limiting (brute-force protection)
|
// Warn about plaintext credentials in config.toml
|
||||||
let auth_routes = auth::token_routes().layer(rate_limit::auth_rate_limiter());
|
config.warn_plaintext_credentials();
|
||||||
|
|
||||||
// Registry routes with upload rate limiting
|
// 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()
|
let registry_routes = Router::new()
|
||||||
.merge(registry::docker_routes())
|
.merge(registry::docker_routes())
|
||||||
.merge(registry::maven_routes())
|
.merge(registry::maven_routes())
|
||||||
.merge(registry::npm_routes())
|
.merge(registry::npm_routes())
|
||||||
.merge(registry::cargo_routes())
|
.merge(registry::cargo_routes())
|
||||||
.merge(registry::pypi_routes())
|
.merge(registry::pypi_routes())
|
||||||
.layer(rate_limit::upload_rate_limiter());
|
.merge(registry::raw_routes());
|
||||||
|
|
||||||
// Routes WITHOUT rate limiting (health, metrics, UI)
|
// Routes WITHOUT rate limiting (health, metrics, UI)
|
||||||
let public_routes = Router::new()
|
let public_routes = Router::new()
|
||||||
@@ -226,16 +313,46 @@ async fn run_server(config: Config, storage: Storage) {
|
|||||||
.merge(ui::routes())
|
.merge(ui::routes())
|
||||||
.merge(openapi::routes());
|
.merge(openapi::routes());
|
||||||
|
|
||||||
// Routes WITH rate limiting
|
let app_routes = if rate_limit_enabled {
|
||||||
let rate_limited_routes = Router::new()
|
// Create rate limiters before moving config to state
|
||||||
.merge(auth_routes)
|
let auth_limiter = rate_limit::auth_rate_limiter(&config.rate_limit);
|
||||||
.merge(registry_routes)
|
let upload_limiter = rate_limit::upload_rate_limiter(&config.rate_limit);
|
||||||
.layer(rate_limit::general_rate_limiter());
|
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,
|
||||||
|
start_time,
|
||||||
|
auth,
|
||||||
|
tokens,
|
||||||
|
metrics: DashboardMetrics::with_persistence(&storage_path),
|
||||||
|
activity: ActivityLog::new(50),
|
||||||
|
audit: AuditLog::new(&storage_path),
|
||||||
|
docker_auth,
|
||||||
|
repo_index: RepoIndex::new(),
|
||||||
|
http_client,
|
||||||
|
});
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.merge(public_routes)
|
.merge(public_routes)
|
||||||
.merge(rate_limited_routes)
|
.merge(app_routes)
|
||||||
.layer(DefaultBodyLimit::max(100 * 1024 * 1024)) // 100MB default body limit
|
.layer(DefaultBodyLimit::max(
|
||||||
|
state.config.server.body_limit_mb * 1024 * 1024,
|
||||||
|
))
|
||||||
.layer(middleware::from_fn(request_id::request_id_middleware))
|
.layer(middleware::from_fn(request_id::request_id_middleware))
|
||||||
.layer(middleware::from_fn(metrics::metrics_middleware))
|
.layer(middleware::from_fn(metrics::metrics_middleware))
|
||||||
.layer(middleware::from_fn_with_state(
|
.layer(middleware::from_fn_with_state(
|
||||||
@@ -254,6 +371,7 @@ async fn run_server(config: Config, storage: Storage) {
|
|||||||
version = env!("CARGO_PKG_VERSION"),
|
version = env!("CARGO_PKG_VERSION"),
|
||||||
storage = state.storage.backend_name(),
|
storage = state.storage.backend_name(),
|
||||||
auth_enabled = state.auth.is_some(),
|
auth_enabled = state.auth.is_some(),
|
||||||
|
body_limit_mb = state.config.server.body_limit_mb,
|
||||||
"Nora started"
|
"Nora started"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -268,9 +386,20 @@ async fn run_server(config: Config, storage: Storage) {
|
|||||||
npm = "/npm/",
|
npm = "/npm/",
|
||||||
cargo = "/cargo/",
|
cargo = "/cargo/",
|
||||||
pypi = "/simple/",
|
pypi = "/simple/",
|
||||||
|
raw = "/raw/",
|
||||||
"Available endpoints"
|
"Available endpoints"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Background task: persist metrics every 30 seconds
|
||||||
|
let metrics_state = state.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut interval = tokio::time::interval(std::time::Duration::from_secs(30));
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
metrics_state.metrics.save();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Graceful shutdown on SIGTERM/SIGINT
|
// Graceful shutdown on SIGTERM/SIGINT
|
||||||
axum::serve(
|
axum::serve(
|
||||||
listener,
|
listener,
|
||||||
@@ -280,6 +409,9 @@ async fn run_server(config: Config, storage: Storage) {
|
|||||||
.await
|
.await
|
||||||
.expect("Server error");
|
.expect("Server error");
|
||||||
|
|
||||||
|
// Save metrics on shutdown
|
||||||
|
state.metrics.save();
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
uptime_seconds = state.start_time.elapsed().as_secs(),
|
uptime_seconds = state.start_time.elapsed().as_secs(),
|
||||||
"Nora shutdown complete"
|
"Nora shutdown complete"
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
body::Body,
|
body::Body,
|
||||||
extract::MatchedPath,
|
extract::MatchedPath,
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
//! Migration between storage backends
|
//! Migration between storage backends
|
||||||
//!
|
//!
|
||||||
//! Supports migrating artifacts from one storage backend to another
|
//! Supports migrating artifacts from one storage backend to another
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
|
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
//! OpenAPI documentation and Swagger UI
|
//! OpenAPI documentation and Swagger UI
|
||||||
//!
|
//!
|
||||||
//! Functions in this module are stubs used only for generating OpenAPI documentation.
|
//! Functions in this module are stubs used only for generating OpenAPI documentation.
|
||||||
|
|
||||||
#![allow(dead_code)]
|
#![allow(dead_code)] // utoipa doc stubs — not called at runtime, used by derive macros
|
||||||
|
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -15,7 +18,7 @@ use crate::AppState;
|
|||||||
#[openapi(
|
#[openapi(
|
||||||
info(
|
info(
|
||||||
title = "Nora",
|
title = "Nora",
|
||||||
version = "0.1.0",
|
version = "0.2.12",
|
||||||
description = "Multi-protocol package registry supporting Docker, Maven, npm, Cargo, and PyPI",
|
description = "Multi-protocol package registry supporting Docker, Maven, npm, Cargo, and PyPI",
|
||||||
license(name = "MIT"),
|
license(name = "MIT"),
|
||||||
contact(name = "DevITWay", url = "https://github.com/getnora-io/nora")
|
contact(name = "DevITWay", url = "https://github.com/getnora-io/nora")
|
||||||
@@ -25,6 +28,8 @@ use crate::AppState;
|
|||||||
),
|
),
|
||||||
tags(
|
tags(
|
||||||
(name = "health", description = "Health check endpoints"),
|
(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 = "docker", description = "Docker Registry v2 API"),
|
||||||
(name = "maven", description = "Maven Repository API"),
|
(name = "maven", description = "Maven Repository API"),
|
||||||
(name = "npm", description = "npm Registry API"),
|
(name = "npm", description = "npm Registry API"),
|
||||||
@@ -36,16 +41,30 @@ use crate::AppState;
|
|||||||
// Health
|
// Health
|
||||||
crate::openapi::health_check,
|
crate::openapi::health_check,
|
||||||
crate::openapi::readiness_check,
|
crate::openapi::readiness_check,
|
||||||
// Docker
|
// Metrics
|
||||||
|
crate::openapi::prometheus_metrics,
|
||||||
|
// Dashboard
|
||||||
|
crate::openapi::dashboard_metrics,
|
||||||
|
// Docker - Read
|
||||||
crate::openapi::docker_version,
|
crate::openapi::docker_version,
|
||||||
crate::openapi::docker_catalog,
|
crate::openapi::docker_catalog,
|
||||||
crate::openapi::docker_tags,
|
crate::openapi::docker_tags,
|
||||||
crate::openapi::docker_manifest,
|
crate::openapi::docker_manifest_get,
|
||||||
crate::openapi::docker_blob,
|
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,
|
||||||
// Maven
|
// Maven
|
||||||
crate::openapi::maven_artifact,
|
crate::openapi::maven_artifact_get,
|
||||||
|
crate::openapi::maven_artifact_put,
|
||||||
// npm
|
// npm
|
||||||
crate::openapi::npm_package,
|
crate::openapi::npm_package,
|
||||||
|
// Cargo
|
||||||
|
crate::openapi::cargo_metadata,
|
||||||
|
crate::openapi::cargo_download,
|
||||||
// PyPI
|
// PyPI
|
||||||
crate::openapi::pypi_simple,
|
crate::openapi::pypi_simple,
|
||||||
crate::openapi::pypi_package,
|
crate::openapi::pypi_package,
|
||||||
@@ -59,6 +78,11 @@ use crate::AppState;
|
|||||||
HealthResponse,
|
HealthResponse,
|
||||||
StorageHealth,
|
StorageHealth,
|
||||||
RegistriesHealth,
|
RegistriesHealth,
|
||||||
|
DashboardResponse,
|
||||||
|
GlobalStats,
|
||||||
|
RegistryCardStats,
|
||||||
|
MountPoint,
|
||||||
|
ActivityEntry,
|
||||||
DockerVersion,
|
DockerVersion,
|
||||||
DockerCatalog,
|
DockerCatalog,
|
||||||
DockerTags,
|
DockerTags,
|
||||||
@@ -182,8 +206,76 @@ pub struct ErrorResponse {
|
|||||||
pub error: String,
|
pub error: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct DashboardResponse {
|
||||||
|
/// Global statistics across all registries
|
||||||
|
pub global_stats: GlobalStats,
|
||||||
|
/// Per-registry statistics
|
||||||
|
pub registry_stats: Vec<RegistryCardStats>,
|
||||||
|
/// Registry mount points and proxy configuration
|
||||||
|
pub mount_points: Vec<MountPoint>,
|
||||||
|
/// Recent activity log entries
|
||||||
|
pub activity: Vec<ActivityEntry>,
|
||||||
|
/// Server uptime in seconds
|
||||||
|
pub uptime_seconds: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct GlobalStats {
|
||||||
|
/// Total downloads across all registries
|
||||||
|
pub downloads: u64,
|
||||||
|
/// Total uploads across all registries
|
||||||
|
pub uploads: u64,
|
||||||
|
/// Total artifact count
|
||||||
|
pub artifacts: u64,
|
||||||
|
/// Cache hit percentage (0-100)
|
||||||
|
pub cache_hit_percent: f64,
|
||||||
|
/// Total storage used in bytes
|
||||||
|
pub storage_bytes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct RegistryCardStats {
|
||||||
|
/// Registry name (docker, maven, npm, cargo, pypi)
|
||||||
|
pub name: String,
|
||||||
|
/// Number of artifacts in this registry
|
||||||
|
pub artifact_count: usize,
|
||||||
|
/// Download count for this registry
|
||||||
|
pub downloads: u64,
|
||||||
|
/// Upload count for this registry
|
||||||
|
pub uploads: u64,
|
||||||
|
/// Storage used by this registry in bytes
|
||||||
|
pub size_bytes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct MountPoint {
|
||||||
|
/// Registry display name
|
||||||
|
pub registry: String,
|
||||||
|
/// URL mount path (e.g., /v2/, /maven2/)
|
||||||
|
pub mount_path: String,
|
||||||
|
/// Upstream proxy URL if configured
|
||||||
|
pub proxy_upstream: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct ActivityEntry {
|
||||||
|
/// ISO 8601 timestamp
|
||||||
|
pub timestamp: String,
|
||||||
|
/// Action type (Pull, Push, CacheHit, ProxyFetch)
|
||||||
|
pub action: String,
|
||||||
|
/// Artifact name/identifier
|
||||||
|
pub artifact: String,
|
||||||
|
/// Registry type
|
||||||
|
pub registry: String,
|
||||||
|
/// Source (LOCAL, PROXY, CACHE)
|
||||||
|
pub source: String,
|
||||||
|
}
|
||||||
|
|
||||||
// ============ Path Operations (documentation only) ============
|
// ============ Path Operations (documentation only) ============
|
||||||
|
|
||||||
|
// -------------------- Health --------------------
|
||||||
|
|
||||||
/// Health check endpoint
|
/// Health check endpoint
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
@@ -208,6 +300,39 @@ pub async fn health_check() {}
|
|||||||
)]
|
)]
|
||||||
pub async fn readiness_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,
|
||||||
|
/// per-registry stats, mount points configuration, and recent activity log.
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/api/ui/dashboard",
|
||||||
|
tag = "dashboard",
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Dashboard metrics", body = DashboardResponse)
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn dashboard_metrics() {}
|
||||||
|
|
||||||
|
// -------------------- Docker Registry v2 - Read Operations --------------------
|
||||||
|
|
||||||
/// Docker Registry version check
|
/// Docker Registry version check
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
@@ -237,7 +362,7 @@ pub async fn docker_catalog() {}
|
|||||||
path = "/v2/{name}/tags/list",
|
path = "/v2/{name}/tags/list",
|
||||||
tag = "docker",
|
tag = "docker",
|
||||||
params(
|
params(
|
||||||
("name" = String, Path, description = "Repository name")
|
("name" = String, Path, description = "Repository name (e.g., 'alpine' or 'library/nginx')")
|
||||||
),
|
),
|
||||||
responses(
|
responses(
|
||||||
(status = 200, description = "Tag list", body = DockerTags),
|
(status = 200, description = "Tag list", body = DockerTags),
|
||||||
@@ -253,14 +378,30 @@ pub async fn docker_tags() {}
|
|||||||
tag = "docker",
|
tag = "docker",
|
||||||
params(
|
params(
|
||||||
("name" = String, Path, description = "Repository name"),
|
("name" = String, Path, description = "Repository name"),
|
||||||
("reference" = String, Path, description = "Tag or digest")
|
("reference" = String, Path, description = "Tag or digest (sha256:...)")
|
||||||
),
|
),
|
||||||
responses(
|
responses(
|
||||||
(status = 200, description = "Manifest content"),
|
(status = 200, description = "Manifest content"),
|
||||||
(status = 404, description = "Manifest not found")
|
(status = 404, description = "Manifest not found")
|
||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
pub async fn docker_manifest() {}
|
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() {}
|
||||||
|
|
||||||
/// Get blob
|
/// Get blob
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
@@ -276,7 +417,79 @@ pub async fn docker_manifest() {}
|
|||||||
(status = 404, description = "Blob not found")
|
(status = 404, description = "Blob not found")
|
||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
pub async fn docker_blob() {}
|
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 --------------------
|
||||||
|
|
||||||
/// Get Maven artifact
|
/// Get Maven artifact
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
@@ -291,7 +504,24 @@ pub async fn docker_blob() {}
|
|||||||
(status = 404, description = "Artifact not found, trying upstream proxies")
|
(status = 404, description = "Artifact not found, trying upstream proxies")
|
||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
pub async fn maven_artifact() {}
|
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 --------------------
|
||||||
|
|
||||||
/// Get npm package metadata
|
/// Get npm package metadata
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
@@ -299,7 +529,7 @@ pub async fn maven_artifact() {}
|
|||||||
path = "/npm/{name}",
|
path = "/npm/{name}",
|
||||||
tag = "npm",
|
tag = "npm",
|
||||||
params(
|
params(
|
||||||
("name" = String, Path, description = "Package name")
|
("name" = String, Path, description = "Package name (e.g., 'lodash' or '@scope/package')")
|
||||||
),
|
),
|
||||||
responses(
|
responses(
|
||||||
(status = 200, description = "Package metadata (JSON)"),
|
(status = 200, description = "Package metadata (JSON)"),
|
||||||
@@ -308,6 +538,41 @@ pub async fn maven_artifact() {}
|
|||||||
)]
|
)]
|
||||||
pub async fn npm_package() {}
|
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
|
/// PyPI Simple index
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
@@ -334,6 +599,8 @@ pub async fn pypi_simple() {}
|
|||||||
)]
|
)]
|
||||||
pub async fn pypi_package() {}
|
pub async fn pypi_package() {}
|
||||||
|
|
||||||
|
// -------------------- Auth / Tokens --------------------
|
||||||
|
|
||||||
/// Create API token
|
/// Create API token
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
post,
|
post,
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
#![allow(dead_code)]
|
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
//! Rate limiting configuration and middleware
|
//! Rate limiting configuration and middleware
|
||||||
//!
|
//!
|
||||||
//! Provides rate limiting to protect against:
|
//! Provides rate limiting to protect against:
|
||||||
@@ -6,96 +8,72 @@
|
|||||||
//! - DoS attacks on upload endpoints
|
//! - DoS attacks on upload endpoints
|
||||||
//! - General API abuse
|
//! - General API abuse
|
||||||
|
|
||||||
|
use crate::config::RateLimitConfig;
|
||||||
use tower_governor::governor::GovernorConfigBuilder;
|
use tower_governor::governor::GovernorConfigBuilder;
|
||||||
|
use tower_governor::key_extractor::SmartIpKeyExtractor;
|
||||||
/// Rate limit configuration
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct RateLimitConfig {
|
|
||||||
/// Requests per second for auth endpoints (strict)
|
|
||||||
pub auth_rps: u32,
|
|
||||||
/// Burst size for auth endpoints
|
|
||||||
pub auth_burst: u32,
|
|
||||||
/// Requests per second for upload endpoints
|
|
||||||
pub upload_rps: u32,
|
|
||||||
/// Burst size for upload endpoints
|
|
||||||
pub upload_burst: u32,
|
|
||||||
/// Requests per second for general endpoints (lenient)
|
|
||||||
pub general_rps: u32,
|
|
||||||
/// Burst size for general endpoints
|
|
||||||
pub general_burst: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for RateLimitConfig {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
auth_rps: 1, // 1 req/sec for auth (strict)
|
|
||||||
auth_burst: 5, // Allow burst of 5
|
|
||||||
upload_rps: 200, // 200 req/sec for uploads (Docker needs high parallelism)
|
|
||||||
upload_burst: 500, // Allow burst of 500
|
|
||||||
general_rps: 100, // 100 req/sec general
|
|
||||||
general_burst: 200, // Allow burst of 200
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create rate limiter layer for auth endpoints (strict protection against brute-force)
|
/// Create rate limiter layer for auth endpoints (strict protection against brute-force)
|
||||||
///
|
pub fn auth_rate_limiter(
|
||||||
/// Default: 1 request per second, burst of 5
|
config: &RateLimitConfig,
|
||||||
pub fn auth_rate_limiter() -> tower_governor::GovernorLayer<
|
) -> tower_governor::GovernorLayer<
|
||||||
tower_governor::key_extractor::PeerIpKeyExtractor,
|
tower_governor::key_extractor::PeerIpKeyExtractor,
|
||||||
governor::middleware::StateInformationMiddleware,
|
governor::middleware::StateInformationMiddleware,
|
||||||
axum::body::Body,
|
axum::body::Body,
|
||||||
> {
|
> {
|
||||||
let config = GovernorConfigBuilder::default()
|
let gov_config = GovernorConfigBuilder::default()
|
||||||
.per_second(1)
|
.per_second(config.auth_rps)
|
||||||
.burst_size(5)
|
.burst_size(config.auth_burst)
|
||||||
.use_headers()
|
.use_headers()
|
||||||
.finish()
|
.finish()
|
||||||
.unwrap();
|
.expect("Failed to build auth rate limiter");
|
||||||
|
|
||||||
tower_governor::GovernorLayer::new(config)
|
tower_governor::GovernorLayer::new(gov_config)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create rate limiter layer for upload endpoints
|
/// Create rate limiter layer for upload endpoints
|
||||||
///
|
///
|
||||||
/// Default: 200 requests per second, burst of 500
|
|
||||||
/// High limits to accommodate Docker client's aggressive parallel layer uploads
|
/// High limits to accommodate Docker client's aggressive parallel layer uploads
|
||||||
pub fn upload_rate_limiter() -> tower_governor::GovernorLayer<
|
pub fn upload_rate_limiter(
|
||||||
tower_governor::key_extractor::PeerIpKeyExtractor,
|
config: &RateLimitConfig,
|
||||||
|
) -> tower_governor::GovernorLayer<
|
||||||
|
SmartIpKeyExtractor,
|
||||||
governor::middleware::StateInformationMiddleware,
|
governor::middleware::StateInformationMiddleware,
|
||||||
axum::body::Body,
|
axum::body::Body,
|
||||||
> {
|
> {
|
||||||
let config = GovernorConfigBuilder::default()
|
let gov_config = GovernorConfigBuilder::default()
|
||||||
.per_second(200)
|
.key_extractor(SmartIpKeyExtractor)
|
||||||
.burst_size(500)
|
.per_second(config.upload_rps)
|
||||||
|
.burst_size(config.upload_burst)
|
||||||
.use_headers()
|
.use_headers()
|
||||||
.finish()
|
.finish()
|
||||||
.unwrap();
|
.expect("Failed to build upload rate limiter");
|
||||||
|
|
||||||
tower_governor::GovernorLayer::new(config)
|
tower_governor::GovernorLayer::new(gov_config)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create rate limiter layer for general endpoints (lenient)
|
/// Create rate limiter layer for general endpoints (lenient)
|
||||||
///
|
pub fn general_rate_limiter(
|
||||||
/// Default: 100 requests per second, burst of 200
|
config: &RateLimitConfig,
|
||||||
pub fn general_rate_limiter() -> tower_governor::GovernorLayer<
|
) -> tower_governor::GovernorLayer<
|
||||||
tower_governor::key_extractor::PeerIpKeyExtractor,
|
SmartIpKeyExtractor,
|
||||||
governor::middleware::StateInformationMiddleware,
|
governor::middleware::StateInformationMiddleware,
|
||||||
axum::body::Body,
|
axum::body::Body,
|
||||||
> {
|
> {
|
||||||
let config = GovernorConfigBuilder::default()
|
let gov_config = GovernorConfigBuilder::default()
|
||||||
.per_second(100)
|
.key_extractor(SmartIpKeyExtractor)
|
||||||
.burst_size(200)
|
.per_second(config.general_rps)
|
||||||
|
.burst_size(config.general_burst)
|
||||||
.use_headers()
|
.use_headers()
|
||||||
.finish()
|
.finish()
|
||||||
.unwrap();
|
.expect("Failed to build general rate limiter");
|
||||||
|
|
||||||
tower_governor::GovernorLayer::new(config)
|
tower_governor::GovernorLayer::new(gov_config)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::config::RateLimitConfig;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_default_config() {
|
fn test_default_config() {
|
||||||
@@ -108,16 +86,35 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_auth_rate_limiter_creation() {
|
fn test_auth_rate_limiter_creation() {
|
||||||
let _limiter = auth_rate_limiter();
|
let config = RateLimitConfig::default();
|
||||||
|
let _limiter = auth_rate_limiter(&config);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_upload_rate_limiter_creation() {
|
fn test_upload_rate_limiter_creation() {
|
||||||
let _limiter = upload_rate_limiter();
|
let config = RateLimitConfig::default();
|
||||||
|
let _limiter = upload_rate_limiter(&config);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_general_rate_limiter_creation() {
|
fn test_general_rate_limiter_creation() {
|
||||||
let _limiter = general_rate_limiter();
|
let config = RateLimitConfig::default();
|
||||||
|
let _limiter = general_rate_limiter(&config);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_custom_config() {
|
||||||
|
let config = RateLimitConfig {
|
||||||
|
enabled: true,
|
||||||
|
auth_rps: 10,
|
||||||
|
auth_burst: 20,
|
||||||
|
upload_rps: 500,
|
||||||
|
upload_burst: 1000,
|
||||||
|
general_rps: 200,
|
||||||
|
general_burst: 400,
|
||||||
|
};
|
||||||
|
let _auth = auth_rate_limiter(&config);
|
||||||
|
let _upload = upload_rate_limiter(&config);
|
||||||
|
let _general = general_rate_limiter(&config);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
use crate::activity_log::{ActionType, ActivityEntry};
|
||||||
|
use crate::audit::AuditEntry;
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
@@ -37,7 +42,20 @@ async fn download(
|
|||||||
crate_name, version, crate_name, version
|
crate_name, version, crate_name, version
|
||||||
);
|
);
|
||||||
match state.storage.get(&key).await {
|
match state.storage.get(&key).await {
|
||||||
Ok(data) => (StatusCode::OK, data).into_response(),
|
Ok(data) => {
|
||||||
|
state.metrics.record_download("cargo");
|
||||||
|
state.metrics.record_cache_hit();
|
||||||
|
state.activity.push(ActivityEntry::new(
|
||||||
|
ActionType::Pull,
|
||||||
|
format!("{}@{}", crate_name, version),
|
||||||
|
"cargo",
|
||||||
|
"LOCAL",
|
||||||
|
));
|
||||||
|
state
|
||||||
|
.audit
|
||||||
|
.log(AuditEntry::new("pull", "api", "", "cargo", ""));
|
||||||
|
(StatusCode::OK, data).into_response()
|
||||||
|
}
|
||||||
Err(_) => StatusCode::NOT_FOUND.into_response(),
|
Err(_) => StatusCode::NOT_FOUND.into_response(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
170
nora-registry/src/registry/docker_auth.rs
Normal file
170
nora-registry/src/registry/docker_auth.rs
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
use crate::config::basic_auth_header;
|
||||||
|
use parking_lot::RwLock;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
/// Cached Docker registry token
|
||||||
|
struct CachedToken {
|
||||||
|
token: String,
|
||||||
|
expires_at: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Docker registry authentication handler
|
||||||
|
/// Manages Bearer token acquisition and caching for upstream registries
|
||||||
|
pub struct DockerAuth {
|
||||||
|
tokens: RwLock<HashMap<String, CachedToken>>,
|
||||||
|
client: reqwest::Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DockerAuth {
|
||||||
|
pub fn new(timeout: u64) -> Self {
|
||||||
|
Self {
|
||||||
|
tokens: RwLock::new(HashMap::new()),
|
||||||
|
client: reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(timeout))
|
||||||
|
.build()
|
||||||
|
.unwrap_or_default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a valid token for the given registry and repository scope
|
||||||
|
/// Returns cached token if still valid, otherwise fetches a new one
|
||||||
|
pub async fn get_token(
|
||||||
|
&self,
|
||||||
|
registry_url: &str,
|
||||||
|
name: &str,
|
||||||
|
www_authenticate: Option<&str>,
|
||||||
|
basic_auth: Option<&str>,
|
||||||
|
) -> Option<String> {
|
||||||
|
let cache_key = format!("{}:{}", registry_url, name);
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
{
|
||||||
|
let tokens = self.tokens.read();
|
||||||
|
if let Some(cached) = tokens.get(&cache_key) {
|
||||||
|
if cached.expires_at > Instant::now() {
|
||||||
|
return Some(cached.token.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Need to fetch a new token
|
||||||
|
let www_auth = www_authenticate?;
|
||||||
|
let token = self.fetch_token(www_auth, name, basic_auth).await?;
|
||||||
|
|
||||||
|
// Cache the token (default 5 minute expiry)
|
||||||
|
{
|
||||||
|
let mut tokens = self.tokens.write();
|
||||||
|
tokens.insert(
|
||||||
|
cache_key,
|
||||||
|
CachedToken {
|
||||||
|
token: token.clone(),
|
||||||
|
expires_at: Instant::now() + Duration::from_secs(300),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse Www-Authenticate header and fetch token from auth server
|
||||||
|
/// Format: Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:library/alpine:pull"
|
||||||
|
async fn fetch_token(
|
||||||
|
&self,
|
||||||
|
www_authenticate: &str,
|
||||||
|
name: &str,
|
||||||
|
basic_auth: Option<&str>,
|
||||||
|
) -> Option<String> {
|
||||||
|
let params = parse_www_authenticate(www_authenticate)?;
|
||||||
|
|
||||||
|
let realm = params.get("realm")?;
|
||||||
|
let service = params.get("service").map(|s| s.as_str()).unwrap_or("");
|
||||||
|
|
||||||
|
// Build token request URL
|
||||||
|
let scope = format!("repository:{}:pull", name);
|
||||||
|
let url = format!("{}?service={}&scope={}", realm, service, scope);
|
||||||
|
|
||||||
|
tracing::debug!(url = %url, "Fetching auth token");
|
||||||
|
|
||||||
|
let mut request = self.client.get(&url);
|
||||||
|
if let Some(credentials) = basic_auth {
|
||||||
|
request = request.header("Authorization", basic_auth_header(credentials));
|
||||||
|
tracing::debug!("Using basic auth for token request");
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = request.send().await.ok()?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
tracing::warn!(status = %response.status(), "Token request failed");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let json: serde_json::Value = response.json().await.ok()?;
|
||||||
|
|
||||||
|
// Docker Hub returns "token", some registries return "access_token"
|
||||||
|
json.get("token")
|
||||||
|
.or_else(|| json.get("access_token"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(String::from)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for DockerAuth {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new(60)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse Www-Authenticate header into key-value pairs
|
||||||
|
/// Example: Bearer realm="https://auth.docker.io/token",service="registry.docker.io"
|
||||||
|
fn parse_www_authenticate(header: &str) -> Option<HashMap<String, String>> {
|
||||||
|
let header = header
|
||||||
|
.strip_prefix("Bearer ")
|
||||||
|
.or_else(|| header.strip_prefix("bearer "))?;
|
||||||
|
|
||||||
|
let mut params = HashMap::new();
|
||||||
|
|
||||||
|
for part in header.split(',') {
|
||||||
|
let part = part.trim();
|
||||||
|
if let Some((key, value)) = part.split_once('=') {
|
||||||
|
let value = value.trim_matches('"');
|
||||||
|
params.insert(key.to_string(), value.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(params)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_www_authenticate() {
|
||||||
|
let header = r#"Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:library/alpine:pull""#;
|
||||||
|
let params = parse_www_authenticate(header).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
params.get("realm"),
|
||||||
|
Some(&"https://auth.docker.io/token".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
params.get("service"),
|
||||||
|
Some(&"registry.docker.io".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_www_authenticate_lowercase() {
|
||||||
|
let header = r#"bearer realm="https://ghcr.io/token",service="ghcr.io""#;
|
||||||
|
let params = parse_www_authenticate(header).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
params.get("realm"),
|
||||||
|
Some(&"https://ghcr.io/token".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,9 @@
|
|||||||
|
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
use crate::activity_log::{ActionType, ActivityEntry};
|
||||||
|
use crate::audit::AuditEntry;
|
||||||
|
use crate::config::basic_auth_header;
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use axum::{
|
use axum::{
|
||||||
body::Bytes,
|
body::Bytes,
|
||||||
@@ -19,18 +25,55 @@ pub fn routes() -> Router<Arc<AppState>> {
|
|||||||
async fn download(State(state): State<Arc<AppState>>, Path(path): Path<String>) -> Response {
|
async fn download(State(state): State<Arc<AppState>>, Path(path): Path<String>) -> Response {
|
||||||
let key = format!("maven/{}", path);
|
let key = format!("maven/{}", path);
|
||||||
|
|
||||||
// Try local storage first
|
let artifact_name = path
|
||||||
|
.split('/')
|
||||||
|
.rev()
|
||||||
|
.take(3)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.into_iter()
|
||||||
|
.rev()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("/");
|
||||||
|
|
||||||
if let Ok(data) = state.storage.get(&key).await {
|
if let Ok(data) = state.storage.get(&key).await {
|
||||||
|
state.metrics.record_download("maven");
|
||||||
|
state.metrics.record_cache_hit();
|
||||||
|
state.activity.push(ActivityEntry::new(
|
||||||
|
ActionType::CacheHit,
|
||||||
|
artifact_name,
|
||||||
|
"maven",
|
||||||
|
"CACHE",
|
||||||
|
));
|
||||||
|
state
|
||||||
|
.audit
|
||||||
|
.log(AuditEntry::new("cache_hit", "api", "", "maven", ""));
|
||||||
return with_content_type(&path, data).into_response();
|
return with_content_type(&path, data).into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try proxy servers
|
for proxy in &state.config.maven.proxies {
|
||||||
for proxy_url in &state.config.maven.proxies {
|
let url = format!("{}/{}", proxy.url().trim_end_matches('/'), path);
|
||||||
let url = format!("{}/{}", proxy_url.trim_end_matches('/'), path);
|
|
||||||
|
|
||||||
match fetch_from_proxy(&url, state.config.maven.proxy_timeout).await {
|
match fetch_from_proxy(
|
||||||
|
&state.http_client,
|
||||||
|
&url,
|
||||||
|
state.config.maven.proxy_timeout,
|
||||||
|
proxy.auth(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(data) => {
|
Ok(data) => {
|
||||||
// Cache in local storage (fire and forget)
|
state.metrics.record_download("maven");
|
||||||
|
state.metrics.record_cache_miss();
|
||||||
|
state.activity.push(ActivityEntry::new(
|
||||||
|
ActionType::ProxyFetch,
|
||||||
|
artifact_name,
|
||||||
|
"maven",
|
||||||
|
"PROXY",
|
||||||
|
));
|
||||||
|
state
|
||||||
|
.audit
|
||||||
|
.log(AuditEntry::new("proxy_fetch", "api", "", "maven", ""));
|
||||||
|
|
||||||
let storage = state.storage.clone();
|
let storage = state.storage.clone();
|
||||||
let key_clone = key.clone();
|
let key_clone = key.clone();
|
||||||
let data_clone = data.clone();
|
let data_clone = data.clone();
|
||||||
@@ -38,6 +81,8 @@ async fn download(State(state): State<Arc<AppState>>, Path(path): Path<String>)
|
|||||||
let _ = storage.put(&key_clone, &data_clone).await;
|
let _ = storage.put(&key_clone, &data_clone).await;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
state.repo_index.invalidate("maven");
|
||||||
|
|
||||||
return with_content_type(&path, data.into()).into_response();
|
return with_content_type(&path, data.into()).into_response();
|
||||||
}
|
}
|
||||||
Err(_) => continue,
|
Err(_) => continue,
|
||||||
@@ -53,19 +98,47 @@ async fn upload(
|
|||||||
body: Bytes,
|
body: Bytes,
|
||||||
) -> StatusCode {
|
) -> StatusCode {
|
||||||
let key = format!("maven/{}", path);
|
let key = format!("maven/{}", path);
|
||||||
|
|
||||||
|
let artifact_name = path
|
||||||
|
.split('/')
|
||||||
|
.rev()
|
||||||
|
.take(3)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.into_iter()
|
||||||
|
.rev()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("/");
|
||||||
|
|
||||||
match state.storage.put(&key, &body).await {
|
match state.storage.put(&key, &body).await {
|
||||||
Ok(()) => StatusCode::CREATED,
|
Ok(()) => {
|
||||||
|
state.metrics.record_upload("maven");
|
||||||
|
state.activity.push(ActivityEntry::new(
|
||||||
|
ActionType::Push,
|
||||||
|
artifact_name,
|
||||||
|
"maven",
|
||||||
|
"LOCAL",
|
||||||
|
));
|
||||||
|
state
|
||||||
|
.audit
|
||||||
|
.log(AuditEntry::new("push", "api", "", "maven", ""));
|
||||||
|
state.repo_index.invalidate("maven");
|
||||||
|
StatusCode::CREATED
|
||||||
|
}
|
||||||
Err(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
Err(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn fetch_from_proxy(url: &str, timeout_secs: u64) -> Result<Vec<u8>, ()> {
|
async fn fetch_from_proxy(
|
||||||
let client = reqwest::Client::builder()
|
client: &reqwest::Client,
|
||||||
.timeout(Duration::from_secs(timeout_secs))
|
url: &str,
|
||||||
.build()
|
timeout_secs: u64,
|
||||||
.map_err(|_| ())?;
|
auth: Option<&str>,
|
||||||
|
) -> Result<Vec<u8>, ()> {
|
||||||
let response = client.get(url).send().await.map_err(|_| ())?;
|
let mut request = client.get(url).timeout(Duration::from_secs(timeout_secs));
|
||||||
|
if let Some(credentials) = auth {
|
||||||
|
request = request.header("Authorization", basic_auth_header(credentials));
|
||||||
|
}
|
||||||
|
let response = request.send().await.map_err(|_| ())?;
|
||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
return Err(());
|
return Err(());
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
|
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
mod cargo_registry;
|
mod cargo_registry;
|
||||||
mod docker;
|
pub mod docker;
|
||||||
|
pub mod docker_auth;
|
||||||
mod maven;
|
mod maven;
|
||||||
mod npm;
|
mod npm;
|
||||||
mod pypi;
|
mod pypi;
|
||||||
|
mod raw;
|
||||||
|
|
||||||
pub use cargo_registry::routes as cargo_routes;
|
pub use cargo_registry::routes as cargo_routes;
|
||||||
pub use docker::routes as docker_routes;
|
pub use docker::routes as docker_routes;
|
||||||
|
pub use docker_auth::DockerAuth;
|
||||||
pub use maven::routes as maven_routes;
|
pub use maven::routes as maven_routes;
|
||||||
pub use npm::routes as npm_routes;
|
pub use npm::routes as npm_routes;
|
||||||
pub use pypi::routes as pypi_routes;
|
pub use pypi::routes as pypi_routes;
|
||||||
|
pub use raw::routes as raw_routes;
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
use crate::activity_log::{ActionType, ActivityEntry};
|
||||||
|
use crate::audit::AuditEntry;
|
||||||
|
use crate::config::basic_auth_header;
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use axum::{
|
use axum::{
|
||||||
body::Bytes,
|
body::Bytes,
|
||||||
@@ -15,7 +21,6 @@ pub fn routes() -> Router<Arc<AppState>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_request(State(state): State<Arc<AppState>>, Path(path): Path<String>) -> Response {
|
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 is_tarball = path.contains("/-/");
|
||||||
|
|
||||||
let key = if is_tarball {
|
let key = if is_tarball {
|
||||||
@@ -29,23 +34,54 @@ async fn handle_request(State(state): State<Arc<AppState>>, Path(path): Path<Str
|
|||||||
format!("npm/{}/metadata.json", path)
|
format!("npm/{}/metadata.json", path)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Try local storage first
|
let package_name = if is_tarball {
|
||||||
|
path.split("/-/").next().unwrap_or(&path).to_string()
|
||||||
|
} else {
|
||||||
|
path.clone()
|
||||||
|
};
|
||||||
|
|
||||||
if let Ok(data) = state.storage.get(&key).await {
|
if let Ok(data) = state.storage.get(&key).await {
|
||||||
|
if is_tarball {
|
||||||
|
state.metrics.record_download("npm");
|
||||||
|
state.metrics.record_cache_hit();
|
||||||
|
state.activity.push(ActivityEntry::new(
|
||||||
|
ActionType::CacheHit,
|
||||||
|
package_name,
|
||||||
|
"npm",
|
||||||
|
"CACHE",
|
||||||
|
));
|
||||||
|
state
|
||||||
|
.audit
|
||||||
|
.log(AuditEntry::new("cache_hit", "api", "", "npm", ""));
|
||||||
|
}
|
||||||
return with_content_type(is_tarball, data).into_response();
|
return with_content_type(is_tarball, data).into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try proxy if configured
|
|
||||||
if let Some(proxy_url) = &state.config.npm.proxy {
|
if let Some(proxy_url) = &state.config.npm.proxy {
|
||||||
let url = if is_tarball {
|
let url = format!("{}/{}", proxy_url.trim_end_matches('/'), path);
|
||||||
// Tarball URL: https://registry.npmjs.org/package/-/package-version.tgz
|
|
||||||
format!("{}/{}", proxy_url.trim_end_matches('/'), path)
|
if let Ok(data) = fetch_from_proxy(
|
||||||
} else {
|
&state.http_client,
|
||||||
// Metadata URL: https://registry.npmjs.org/package
|
&url,
|
||||||
format!("{}/{}", proxy_url.trim_end_matches('/'), path)
|
state.config.npm.proxy_timeout,
|
||||||
};
|
state.config.npm.proxy_auth.as_deref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
if is_tarball {
|
||||||
|
state.metrics.record_download("npm");
|
||||||
|
state.metrics.record_cache_miss();
|
||||||
|
state.activity.push(ActivityEntry::new(
|
||||||
|
ActionType::ProxyFetch,
|
||||||
|
package_name,
|
||||||
|
"npm",
|
||||||
|
"PROXY",
|
||||||
|
));
|
||||||
|
state
|
||||||
|
.audit
|
||||||
|
.log(AuditEntry::new("proxy_fetch", "api", "", "npm", ""));
|
||||||
|
}
|
||||||
|
|
||||||
if let Ok(data) = fetch_from_proxy(&url, state.config.npm.proxy_timeout).await {
|
|
||||||
// Cache in local storage (fire and forget)
|
|
||||||
let storage = state.storage.clone();
|
let storage = state.storage.clone();
|
||||||
let key_clone = key.clone();
|
let key_clone = key.clone();
|
||||||
let data_clone = data.clone();
|
let data_clone = data.clone();
|
||||||
@@ -53,6 +89,10 @@ async fn handle_request(State(state): State<Arc<AppState>>, Path(path): Path<Str
|
|||||||
let _ = storage.put(&key_clone, &data_clone).await;
|
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();
|
return with_content_type(is_tarball, data.into()).into_response();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -60,13 +100,17 @@ async fn handle_request(State(state): State<Arc<AppState>>, Path(path): Path<Str
|
|||||||
StatusCode::NOT_FOUND.into_response()
|
StatusCode::NOT_FOUND.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn fetch_from_proxy(url: &str, timeout_secs: u64) -> Result<Vec<u8>, ()> {
|
async fn fetch_from_proxy(
|
||||||
let client = reqwest::Client::builder()
|
client: &reqwest::Client,
|
||||||
.timeout(Duration::from_secs(timeout_secs))
|
url: &str,
|
||||||
.build()
|
timeout_secs: u64,
|
||||||
.map_err(|_| ())?;
|
auth: Option<&str>,
|
||||||
|
) -> Result<Vec<u8>, ()> {
|
||||||
let response = client.get(url).send().await.map_err(|_| ())?;
|
let mut request = client.get(url).timeout(Duration::from_secs(timeout_secs));
|
||||||
|
if let Some(credentials) = auth {
|
||||||
|
request = request.header("Authorization", basic_auth_header(credentials));
|
||||||
|
}
|
||||||
|
let response = request.send().await.map_err(|_| ())?;
|
||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
return Err(());
|
return Err(());
|
||||||
|
|||||||
@@ -1,35 +1,349 @@
|
|||||||
|
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
use crate::activity_log::{ActionType, ActivityEntry};
|
||||||
|
use crate::audit::AuditEntry;
|
||||||
|
use crate::config::basic_auth_header;
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::State,
|
extract::{Path, State},
|
||||||
http::StatusCode,
|
http::{header, StatusCode},
|
||||||
response::{Html, IntoResponse},
|
response::{Html, IntoResponse, Response},
|
||||||
routing::get,
|
routing::get,
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
pub fn routes() -> Router<Arc<AppState>> {
|
pub fn routes() -> Router<Arc<AppState>> {
|
||||||
Router::new().route("/simple/", get(list_packages))
|
Router::new()
|
||||||
|
.route("/simple/", get(list_packages))
|
||||||
|
.route("/simple/{name}/", get(package_versions))
|
||||||
|
.route("/simple/{name}/{filename}", get(download_file))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// List all packages (Simple API index)
|
||||||
async fn list_packages(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
async fn list_packages(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||||
let keys = state.storage.list("pypi/").await;
|
let keys = state.storage.list("pypi/").await;
|
||||||
let mut packages = std::collections::HashSet::new();
|
let mut packages = std::collections::HashSet::new();
|
||||||
|
|
||||||
for key in keys {
|
for key in keys {
|
||||||
if let Some(pkg) = key.strip_prefix("pypi/").and_then(|k| k.split('/').next()) {
|
if let Some(pkg) = key.strip_prefix("pypi/").and_then(|k| k.split('/').next()) {
|
||||||
packages.insert(pkg.to_string());
|
if !pkg.is_empty() {
|
||||||
|
packages.insert(pkg.to_string());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut html = String::from("<html><body><h1>Simple Index</h1>");
|
let mut html = String::from(
|
||||||
|
"<!DOCTYPE html>\n<html><head><title>Simple Index</title></head><body><h1>Simple Index</h1>\n",
|
||||||
|
);
|
||||||
let mut pkg_list: Vec<_> = packages.into_iter().collect();
|
let mut pkg_list: Vec<_> = packages.into_iter().collect();
|
||||||
pkg_list.sort();
|
pkg_list.sort();
|
||||||
|
|
||||||
for pkg in pkg_list {
|
for pkg in pkg_list {
|
||||||
html.push_str(&format!("<a href=\"/simple/{}/\">{}</a><br>", pkg, pkg));
|
html.push_str(&format!("<a href=\"/simple/{}/\">{}</a><br>\n", pkg, pkg));
|
||||||
}
|
}
|
||||||
html.push_str("</body></html>");
|
html.push_str("</body></html>");
|
||||||
|
|
||||||
(StatusCode::OK, Html(html))
|
(StatusCode::OK, Html(html))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// List versions/files for a specific package
|
||||||
|
async fn package_versions(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Path(name): Path<String>,
|
||||||
|
) -> Response {
|
||||||
|
// Normalize package name (PEP 503)
|
||||||
|
let normalized = normalize_name(&name);
|
||||||
|
|
||||||
|
// Try to get local files first
|
||||||
|
let prefix = format!("pypi/{}/", normalized);
|
||||||
|
let keys = state.storage.list(&prefix).await;
|
||||||
|
|
||||||
|
if !keys.is_empty() {
|
||||||
|
// We have local files
|
||||||
|
let mut html = format!(
|
||||||
|
"<!DOCTYPE html>\n<html><head><title>Links for {}</title></head><body><h1>Links for {}</h1>\n",
|
||||||
|
name, name
|
||||||
|
);
|
||||||
|
|
||||||
|
for key in &keys {
|
||||||
|
if let Some(filename) = key.strip_prefix(&prefix) {
|
||||||
|
if !filename.is_empty() {
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<a href=\"/simple/{}/{}\">{}</a><br>\n",
|
||||||
|
normalized, filename, filename
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
html.push_str("</body></html>");
|
||||||
|
|
||||||
|
return (StatusCode::OK, Html(html)).into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try proxy if configured
|
||||||
|
if let Some(proxy_url) = &state.config.pypi.proxy {
|
||||||
|
let url = format!("{}/{}/", proxy_url.trim_end_matches('/'), normalized);
|
||||||
|
|
||||||
|
if let Ok(html) = fetch_package_page(
|
||||||
|
&state.http_client,
|
||||||
|
&url,
|
||||||
|
state.config.pypi.proxy_timeout,
|
||||||
|
state.config.pypi.proxy_auth.as_deref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
// Rewrite URLs in the HTML to point to our registry
|
||||||
|
let rewritten = rewrite_pypi_links(&html, &normalized);
|
||||||
|
return (StatusCode::OK, Html(rewritten)).into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StatusCode::NOT_FOUND.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download a specific file
|
||||||
|
async fn download_file(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Path((name, filename)): Path<(String, String)>,
|
||||||
|
) -> Response {
|
||||||
|
let normalized = normalize_name(&name);
|
||||||
|
let key = format!("pypi/{}/{}", normalized, filename);
|
||||||
|
|
||||||
|
// Try local storage first
|
||||||
|
if let Ok(data) = state.storage.get(&key).await {
|
||||||
|
state.metrics.record_download("pypi");
|
||||||
|
state.metrics.record_cache_hit();
|
||||||
|
state.activity.push(ActivityEntry::new(
|
||||||
|
ActionType::CacheHit,
|
||||||
|
format!("{}/{}", name, filename),
|
||||||
|
"pypi",
|
||||||
|
"CACHE",
|
||||||
|
));
|
||||||
|
state
|
||||||
|
.audit
|
||||||
|
.log(AuditEntry::new("cache_hit", "api", "", "pypi", ""));
|
||||||
|
|
||||||
|
let content_type = if filename.ends_with(".whl") {
|
||||||
|
"application/zip"
|
||||||
|
} else if filename.ends_with(".tar.gz") || filename.ends_with(".tgz") {
|
||||||
|
"application/gzip"
|
||||||
|
} else {
|
||||||
|
"application/octet-stream"
|
||||||
|
};
|
||||||
|
|
||||||
|
return (StatusCode::OK, [(header::CONTENT_TYPE, content_type)], data).into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try proxy if configured
|
||||||
|
if let Some(proxy_url) = &state.config.pypi.proxy {
|
||||||
|
// First, fetch the package page to find the actual download URL
|
||||||
|
let page_url = format!("{}/{}/", proxy_url.trim_end_matches('/'), normalized);
|
||||||
|
|
||||||
|
if let Ok(html) = fetch_package_page(
|
||||||
|
&state.http_client,
|
||||||
|
&page_url,
|
||||||
|
state.config.pypi.proxy_timeout,
|
||||||
|
state.config.pypi.proxy_auth.as_deref(),
|
||||||
|
)
|
||||||
|
.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,
|
||||||
|
state.config.pypi.proxy_auth.as_deref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
state.metrics.record_download("pypi");
|
||||||
|
state.metrics.record_cache_miss();
|
||||||
|
state.activity.push(ActivityEntry::new(
|
||||||
|
ActionType::ProxyFetch,
|
||||||
|
format!("{}/{}", name, filename),
|
||||||
|
"pypi",
|
||||||
|
"PROXY",
|
||||||
|
));
|
||||||
|
state
|
||||||
|
.audit
|
||||||
|
.log(AuditEntry::new("proxy_fetch", "api", "", "pypi", ""));
|
||||||
|
|
||||||
|
// Cache in local storage
|
||||||
|
let storage = state.storage.clone();
|
||||||
|
let key_clone = key.clone();
|
||||||
|
let data_clone = data.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = storage.put(&key_clone, &data_clone).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
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") {
|
||||||
|
"application/gzip"
|
||||||
|
} else {
|
||||||
|
"application/octet-stream"
|
||||||
|
};
|
||||||
|
|
||||||
|
return (StatusCode::OK, [(header::CONTENT_TYPE, content_type)], data)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StatusCode::NOT_FOUND.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalize package name according to PEP 503
|
||||||
|
fn normalize_name(name: &str) -> String {
|
||||||
|
name.to_lowercase().replace(['-', '_', '.'], "-")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch package page from upstream
|
||||||
|
async fn fetch_package_page(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
url: &str,
|
||||||
|
timeout_secs: u64,
|
||||||
|
auth: Option<&str>,
|
||||||
|
) -> Result<String, ()> {
|
||||||
|
let mut request = client
|
||||||
|
.get(url)
|
||||||
|
.timeout(Duration::from_secs(timeout_secs))
|
||||||
|
.header("Accept", "text/html");
|
||||||
|
if let Some(credentials) = auth {
|
||||||
|
request = request.header("Authorization", basic_auth_header(credentials));
|
||||||
|
}
|
||||||
|
let response = request.send().await.map_err(|_| ())?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(());
|
||||||
|
}
|
||||||
|
|
||||||
|
response.text().await.map_err(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch file from upstream
|
||||||
|
async fn fetch_file(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
url: &str,
|
||||||
|
timeout_secs: u64,
|
||||||
|
auth: Option<&str>,
|
||||||
|
) -> Result<Vec<u8>, ()> {
|
||||||
|
let mut request = client.get(url).timeout(Duration::from_secs(timeout_secs));
|
||||||
|
if let Some(credentials) = auth {
|
||||||
|
request = request.header("Authorization", basic_auth_header(credentials));
|
||||||
|
}
|
||||||
|
let response = request.send().await.map_err(|_| ())?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(());
|
||||||
|
}
|
||||||
|
|
||||||
|
response.bytes().await.map(|b| b.to_vec()).map_err(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rewrite PyPI links to point to our registry
|
||||||
|
fn rewrite_pypi_links(html: &str, package_name: &str) -> String {
|
||||||
|
// Simple regex-free approach: find href="..." and rewrite
|
||||||
|
let mut result = String::with_capacity(html.len());
|
||||||
|
let mut remaining = html;
|
||||||
|
|
||||||
|
while let Some(href_start) = remaining.find("href=\"") {
|
||||||
|
result.push_str(&remaining[..href_start + 6]);
|
||||||
|
remaining = &remaining[href_start + 6..];
|
||||||
|
|
||||||
|
if let Some(href_end) = remaining.find('"') {
|
||||||
|
let url = &remaining[..href_end];
|
||||||
|
|
||||||
|
// Extract filename from URL
|
||||||
|
if let Some(filename) = extract_filename(url) {
|
||||||
|
// Rewrite to our local URL
|
||||||
|
result.push_str(&format!("/simple/{}/{}", package_name, filename));
|
||||||
|
} else {
|
||||||
|
result.push_str(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
remaining = &remaining[href_end..];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.push_str(remaining);
|
||||||
|
|
||||||
|
// Remove data-core-metadata and data-dist-info-metadata attributes
|
||||||
|
// as we don't serve .metadata files (PEP 658)
|
||||||
|
let result = remove_attribute(&result, "data-core-metadata");
|
||||||
|
remove_attribute(&result, "data-dist-info-metadata")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove an HTML attribute from all tags
|
||||||
|
fn remove_attribute(html: &str, attr_name: &str) -> String {
|
||||||
|
let mut result = String::with_capacity(html.len());
|
||||||
|
let mut remaining = html;
|
||||||
|
let pattern = format!(" {}=\"", attr_name);
|
||||||
|
|
||||||
|
while let Some(attr_start) = remaining.find(&pattern) {
|
||||||
|
result.push_str(&remaining[..attr_start]);
|
||||||
|
remaining = &remaining[attr_start + pattern.len()..];
|
||||||
|
|
||||||
|
// Skip the attribute value
|
||||||
|
if let Some(attr_end) = remaining.find('"') {
|
||||||
|
remaining = &remaining[attr_end + 1..];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.push_str(remaining);
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract filename from PyPI download URL
|
||||||
|
fn extract_filename(url: &str) -> Option<&str> {
|
||||||
|
// PyPI URLs look like:
|
||||||
|
// https://files.pythonhosted.org/packages/.../package-1.0.0.tar.gz#sha256=...
|
||||||
|
// or just the filename directly
|
||||||
|
|
||||||
|
// Remove hash fragment
|
||||||
|
let url = url.split('#').next()?;
|
||||||
|
|
||||||
|
// Get the last path component
|
||||||
|
let filename = url.rsplit('/').next()?;
|
||||||
|
|
||||||
|
// Must be a valid package file
|
||||||
|
if filename.ends_with(".tar.gz")
|
||||||
|
|| filename.ends_with(".tgz")
|
||||||
|
|| filename.ends_with(".whl")
|
||||||
|
|| filename.ends_with(".zip")
|
||||||
|
|| filename.ends_with(".egg")
|
||||||
|
{
|
||||||
|
Some(filename)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the download URL for a specific file in the HTML
|
||||||
|
fn find_file_url(html: &str, target_filename: &str) -> Option<String> {
|
||||||
|
let mut remaining = html;
|
||||||
|
|
||||||
|
while let Some(href_start) = remaining.find("href=\"") {
|
||||||
|
remaining = &remaining[href_start + 6..];
|
||||||
|
|
||||||
|
if let Some(href_end) = remaining.find('"') {
|
||||||
|
let url = &remaining[..href_end];
|
||||||
|
|
||||||
|
if let Some(filename) = extract_filename(url) {
|
||||||
|
if filename == target_filename {
|
||||||
|
// Remove hash fragment for actual download
|
||||||
|
return Some(url.split('#').next().unwrap_or(url).to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
remaining = &remaining[href_end..];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|||||||
143
nora-registry/src/registry/raw.rs
Normal file
143
nora-registry/src/registry/raw.rs
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
use crate::activity_log::{ActionType, ActivityEntry};
|
||||||
|
use crate::audit::AuditEntry;
|
||||||
|
use crate::AppState;
|
||||||
|
use axum::{
|
||||||
|
body::Bytes,
|
||||||
|
extract::{Path, State},
|
||||||
|
http::{header, StatusCode},
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
routing::get,
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
pub fn routes() -> Router<Arc<AppState>> {
|
||||||
|
Router::new().route(
|
||||||
|
"/raw/{*path}",
|
||||||
|
get(download)
|
||||||
|
.put(upload)
|
||||||
|
.delete(delete_file)
|
||||||
|
.head(check_exists),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn download(State(state): State<Arc<AppState>>, Path(path): Path<String>) -> Response {
|
||||||
|
if !state.config.raw.enabled {
|
||||||
|
return StatusCode::NOT_FOUND.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let key = format!("raw/{}", path);
|
||||||
|
match state.storage.get(&key).await {
|
||||||
|
Ok(data) => {
|
||||||
|
state.metrics.record_download("raw");
|
||||||
|
state
|
||||||
|
.activity
|
||||||
|
.push(ActivityEntry::new(ActionType::Pull, path, "raw", "LOCAL"));
|
||||||
|
state
|
||||||
|
.audit
|
||||||
|
.log(AuditEntry::new("pull", "api", "", "raw", ""));
|
||||||
|
|
||||||
|
// Guess content type from extension
|
||||||
|
let content_type = guess_content_type(&key);
|
||||||
|
(StatusCode::OK, [(header::CONTENT_TYPE, content_type)], data).into_response()
|
||||||
|
}
|
||||||
|
Err(_) => StatusCode::NOT_FOUND.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn upload(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Path(path): Path<String>,
|
||||||
|
body: Bytes,
|
||||||
|
) -> Response {
|
||||||
|
if !state.config.raw.enabled {
|
||||||
|
return StatusCode::NOT_FOUND.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check file size limit
|
||||||
|
if body.len() as u64 > state.config.raw.max_file_size {
|
||||||
|
return (
|
||||||
|
StatusCode::PAYLOAD_TOO_LARGE,
|
||||||
|
format!(
|
||||||
|
"File too large. Max size: {} bytes",
|
||||||
|
state.config.raw.max_file_size
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let key = format!("raw/{}", path);
|
||||||
|
match state.storage.put(&key, &body).await {
|
||||||
|
Ok(()) => {
|
||||||
|
state.metrics.record_upload("raw");
|
||||||
|
state
|
||||||
|
.activity
|
||||||
|
.push(ActivityEntry::new(ActionType::Push, path, "raw", "LOCAL"));
|
||||||
|
state
|
||||||
|
.audit
|
||||||
|
.log(AuditEntry::new("push", "api", "", "raw", ""));
|
||||||
|
StatusCode::CREATED.into_response()
|
||||||
|
}
|
||||||
|
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_file(State(state): State<Arc<AppState>>, Path(path): Path<String>) -> Response {
|
||||||
|
if !state.config.raw.enabled {
|
||||||
|
return StatusCode::NOT_FOUND.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let key = format!("raw/{}", path);
|
||||||
|
match state.storage.delete(&key).await {
|
||||||
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||||
|
Err(crate::storage::StorageError::NotFound) => StatusCode::NOT_FOUND.into_response(),
|
||||||
|
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn check_exists(State(state): State<Arc<AppState>>, Path(path): Path<String>) -> Response {
|
||||||
|
if !state.config.raw.enabled {
|
||||||
|
return StatusCode::NOT_FOUND.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let key = format!("raw/{}", path);
|
||||||
|
match state.storage.stat(&key).await {
|
||||||
|
Some(meta) => (
|
||||||
|
StatusCode::OK,
|
||||||
|
[
|
||||||
|
(header::CONTENT_LENGTH, meta.size.to_string()),
|
||||||
|
(header::CONTENT_TYPE, guess_content_type(&key).to_string()),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
None => StatusCode::NOT_FOUND.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn guess_content_type(path: &str) -> &'static str {
|
||||||
|
let ext = path.rsplit('.').next().unwrap_or("");
|
||||||
|
match ext.to_lowercase().as_str() {
|
||||||
|
"json" => "application/json",
|
||||||
|
"xml" => "application/xml",
|
||||||
|
"html" | "htm" => "text/html",
|
||||||
|
"css" => "text/css",
|
||||||
|
"js" => "application/javascript",
|
||||||
|
"txt" => "text/plain",
|
||||||
|
"md" => "text/markdown",
|
||||||
|
"yaml" | "yml" => "application/x-yaml",
|
||||||
|
"toml" => "application/toml",
|
||||||
|
"tar" => "application/x-tar",
|
||||||
|
"gz" | "gzip" => "application/gzip",
|
||||||
|
"zip" => "application/zip",
|
||||||
|
"png" => "image/png",
|
||||||
|
"jpg" | "jpeg" => "image/jpeg",
|
||||||
|
"gif" => "image/gif",
|
||||||
|
"svg" => "image/svg+xml",
|
||||||
|
"pdf" => "application/pdf",
|
||||||
|
"wasm" => "application/wasm",
|
||||||
|
_ => "application/octet-stream",
|
||||||
|
}
|
||||||
|
}
|
||||||
351
nora-registry/src/repo_index.rs
Normal file
351
nora-registry/src/repo_index.rs
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
// 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)
|
||||||
|
}
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
//! Request ID middleware for request tracking and correlation
|
//! Request ID middleware for request tracking and correlation
|
||||||
//!
|
//!
|
||||||
//! Generates a unique ID for each request that can be used for:
|
//! Generates a unique ID for each request that can be used for:
|
||||||
|
|||||||
130
nora-registry/src/secrets/env.rs
Normal file
130
nora-registry/src/secrets/env.rs
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//! Environment variables secrets provider
|
||||||
|
//!
|
||||||
|
//! Reads secrets from environment variables. This is the default provider
|
||||||
|
//! following 12-Factor App principles.
|
||||||
|
|
||||||
|
use std::env;
|
||||||
|
|
||||||
|
use super::{SecretsError, SecretsProvider};
|
||||||
|
use crate::secrets::protected::ProtectedString;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
/// Environment variables secrets provider
|
||||||
|
///
|
||||||
|
/// Reads secrets from environment variables.
|
||||||
|
/// Optionally clears variables after reading for extra security.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct EnvProvider {
|
||||||
|
/// Clear environment variables after reading
|
||||||
|
clear_after_read: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EnvProvider {
|
||||||
|
/// Create a new environment provider
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
clear_after_read: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a provider that clears env vars after reading
|
||||||
|
///
|
||||||
|
/// This prevents secrets from being visible in `/proc/<pid>/environ`
|
||||||
|
pub fn with_clear_after_read(mut self) -> Self {
|
||||||
|
self.clear_after_read = true;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for EnvProvider {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl SecretsProvider for EnvProvider {
|
||||||
|
async fn get_secret(&self, key: &str) -> Result<ProtectedString, SecretsError> {
|
||||||
|
let value = env::var(key).map_err(|_| SecretsError::NotFound(key.to_string()))?;
|
||||||
|
|
||||||
|
if self.clear_after_read {
|
||||||
|
env::remove_var(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ProtectedString::new(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_secret_optional(&self, key: &str) -> Option<ProtectedString> {
|
||||||
|
env::var(key).ok().map(|v| {
|
||||||
|
if self.clear_after_read {
|
||||||
|
env::remove_var(key);
|
||||||
|
}
|
||||||
|
ProtectedString::new(v)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn provider_name(&self) -> &'static str {
|
||||||
|
"env"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_get_secret_exists() {
|
||||||
|
env::set_var("TEST_SECRET_123", "secret-value");
|
||||||
|
let provider = EnvProvider::new();
|
||||||
|
let secret = provider.get_secret("TEST_SECRET_123").await.unwrap();
|
||||||
|
assert_eq!(secret.expose(), "secret-value");
|
||||||
|
env::remove_var("TEST_SECRET_123");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_get_secret_not_found() {
|
||||||
|
let provider = EnvProvider::new();
|
||||||
|
let result = provider.get_secret("NONEXISTENT_VAR_XYZ").await;
|
||||||
|
assert!(matches!(result, Err(SecretsError::NotFound(_))));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_get_secret_optional_exists() {
|
||||||
|
env::set_var("TEST_OPTIONAL_123", "optional-value");
|
||||||
|
let provider = EnvProvider::new();
|
||||||
|
let secret = provider.get_secret_optional("TEST_OPTIONAL_123").await;
|
||||||
|
assert!(secret.is_some());
|
||||||
|
assert_eq!(secret.unwrap().expose(), "optional-value");
|
||||||
|
env::remove_var("TEST_OPTIONAL_123");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_get_secret_optional_not_found() {
|
||||||
|
let provider = EnvProvider::new();
|
||||||
|
let secret = provider
|
||||||
|
.get_secret_optional("NONEXISTENT_OPTIONAL_XYZ")
|
||||||
|
.await;
|
||||||
|
assert!(secret.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_clear_after_read() {
|
||||||
|
env::set_var("TEST_CLEAR_123", "to-be-cleared");
|
||||||
|
let provider = EnvProvider::new().with_clear_after_read();
|
||||||
|
|
||||||
|
let secret = provider.get_secret("TEST_CLEAR_123").await.unwrap();
|
||||||
|
assert_eq!(secret.expose(), "to-be-cleared");
|
||||||
|
|
||||||
|
// Variable should be cleared
|
||||||
|
assert!(env::var("TEST_CLEAR_123").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_provider_name() {
|
||||||
|
let provider = EnvProvider::new();
|
||||||
|
assert_eq!(provider.provider_name(), "env");
|
||||||
|
}
|
||||||
|
}
|
||||||
170
nora-registry/src/secrets/mod.rs
Normal file
170
nora-registry/src/secrets/mod.rs
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//! Secrets management for NORA
|
||||||
|
//!
|
||||||
|
//! Provides a trait-based architecture for secrets providers:
|
||||||
|
//! - `env` - Environment variables (default, 12-Factor App)
|
||||||
|
//! - `aws-secrets` - AWS Secrets Manager (v0.4.0+)
|
||||||
|
//! - `vault` - HashiCorp Vault (v0.5.0+)
|
||||||
|
//! - `k8s` - Kubernetes Secrets (v0.4.0+)
|
||||||
|
//!
|
||||||
|
//! # Example
|
||||||
|
//!
|
||||||
|
//! ```rust,ignore
|
||||||
|
//! use nora::secrets::{create_secrets_provider, SecretsConfig};
|
||||||
|
//!
|
||||||
|
//! let config = SecretsConfig::default(); // Uses ENV provider
|
||||||
|
//! let provider = create_secrets_provider(&config)?;
|
||||||
|
//!
|
||||||
|
//! let api_key = provider.get_secret("API_KEY").await?;
|
||||||
|
//! println!("Got secret (redacted): {:?}", api_key);
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
mod env;
|
||||||
|
pub mod protected;
|
||||||
|
|
||||||
|
pub use env::EnvProvider;
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
pub use protected::{ProtectedString, S3Credentials};
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[allow(dead_code)] // Variants used by provider impls; external error handling planned for v0.4
|
||||||
|
/// Secrets provider error
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum SecretsError {
|
||||||
|
#[error("Secret not found: {0}")]
|
||||||
|
NotFound(String),
|
||||||
|
|
||||||
|
#[error("Provider error: {0}")]
|
||||||
|
Provider(String),
|
||||||
|
|
||||||
|
#[error("Configuration error: {0}")]
|
||||||
|
Config(String),
|
||||||
|
|
||||||
|
#[error("Unsupported provider: {0}")]
|
||||||
|
UnsupportedProvider(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Secrets provider trait
|
||||||
|
///
|
||||||
|
/// Implement this trait to add new secrets backends.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait SecretsProvider: Send + Sync {
|
||||||
|
/// Get a secret by key (required)
|
||||||
|
#[allow(dead_code)]
|
||||||
|
async fn get_secret(&self, key: &str) -> Result<ProtectedString, SecretsError>;
|
||||||
|
|
||||||
|
/// Get a secret by key (optional, returns None if not found)
|
||||||
|
#[allow(dead_code)]
|
||||||
|
async fn get_secret_optional(&self, key: &str) -> Option<ProtectedString> {
|
||||||
|
self.get_secret(key).await.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get provider name for logging
|
||||||
|
fn provider_name(&self) -> &'static str;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Secrets configuration
|
||||||
|
///
|
||||||
|
/// # Example config.toml
|
||||||
|
///
|
||||||
|
/// ```toml
|
||||||
|
/// [secrets]
|
||||||
|
/// provider = "env"
|
||||||
|
/// clear_env = false
|
||||||
|
/// ```
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SecretsConfig {
|
||||||
|
/// Provider type: "env", "aws-secrets", "vault", "k8s"
|
||||||
|
#[serde(default = "default_provider")]
|
||||||
|
pub provider: String,
|
||||||
|
|
||||||
|
/// Clear environment variables after reading (for env provider)
|
||||||
|
#[serde(default)]
|
||||||
|
pub clear_env: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_provider() -> String {
|
||||||
|
"env".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SecretsConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
provider: default_provider(),
|
||||||
|
clear_env: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a secrets provider based on configuration
|
||||||
|
///
|
||||||
|
/// Currently supports:
|
||||||
|
/// - `env` - Environment variables (default)
|
||||||
|
///
|
||||||
|
/// Future versions will add:
|
||||||
|
/// - `aws-secrets` - AWS Secrets Manager
|
||||||
|
/// - `vault` - HashiCorp Vault
|
||||||
|
/// - `k8s` - Kubernetes Secrets
|
||||||
|
pub fn create_secrets_provider(
|
||||||
|
config: &SecretsConfig,
|
||||||
|
) -> Result<Box<dyn SecretsProvider>, SecretsError> {
|
||||||
|
match config.provider.as_str() {
|
||||||
|
"env" => {
|
||||||
|
let mut provider = EnvProvider::new();
|
||||||
|
if config.clear_env {
|
||||||
|
provider = provider.with_clear_after_read();
|
||||||
|
}
|
||||||
|
Ok(Box::new(provider))
|
||||||
|
}
|
||||||
|
// Future providers:
|
||||||
|
// "aws-secrets" => { ... }
|
||||||
|
// "vault" => { ... }
|
||||||
|
// "k8s" => { ... }
|
||||||
|
other => Err(SecretsError::UnsupportedProvider(other.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_default_config() {
|
||||||
|
let config = SecretsConfig::default();
|
||||||
|
assert_eq!(config.provider, "env");
|
||||||
|
assert!(!config.clear_env);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_create_env_provider() {
|
||||||
|
let config = SecretsConfig::default();
|
||||||
|
let provider = create_secrets_provider(&config).unwrap();
|
||||||
|
assert_eq!(provider.provider_name(), "env");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_create_unsupported_provider() {
|
||||||
|
let config = SecretsConfig {
|
||||||
|
provider: "unknown".to_string(),
|
||||||
|
clear_env: false,
|
||||||
|
};
|
||||||
|
let result = create_secrets_provider(&config);
|
||||||
|
assert!(matches!(result, Err(SecretsError::UnsupportedProvider(_))));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_config_from_toml() {
|
||||||
|
let toml = r#"
|
||||||
|
provider = "env"
|
||||||
|
clear_env = true
|
||||||
|
"#;
|
||||||
|
let config: SecretsConfig = toml::from_str(toml).unwrap();
|
||||||
|
assert_eq!(config.provider, "env");
|
||||||
|
assert!(config.clear_env);
|
||||||
|
}
|
||||||
|
}
|
||||||
159
nora-registry/src/secrets/protected.rs
Normal file
159
nora-registry/src/secrets/protected.rs
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
// 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.
|
||||||
|
|
||||||
|
use std::fmt;
|
||||||
|
use zeroize::{Zeroize, Zeroizing};
|
||||||
|
|
||||||
|
/// A protected secret string that is zeroed on drop
|
||||||
|
///
|
||||||
|
/// - Implements Zeroize: memory is overwritten with zeros when dropped
|
||||||
|
/// - Debug shows `***REDACTED***` instead of actual value
|
||||||
|
/// - Clone creates a new protected copy
|
||||||
|
#[allow(dead_code)] // Used internally by SecretsProvider impls; external callers planned for v0.4
|
||||||
|
#[derive(Clone, Zeroize)]
|
||||||
|
#[zeroize(drop)]
|
||||||
|
pub struct ProtectedString {
|
||||||
|
inner: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
impl ProtectedString {
|
||||||
|
/// Create a new protected string
|
||||||
|
pub fn new(value: String) -> Self {
|
||||||
|
Self { inner: value }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the secret value (use sparingly!)
|
||||||
|
pub fn expose(&self) -> &str {
|
||||||
|
&self.inner
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consume and return the inner value
|
||||||
|
pub fn into_inner(self) -> Zeroizing<String> {
|
||||||
|
Zeroizing::new(self.inner.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if the secret is empty
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.inner.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for ProtectedString {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.debug_struct("ProtectedString")
|
||||||
|
.field("value", &"***REDACTED***")
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for ProtectedString {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "***REDACTED***")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<String> for ProtectedString {
|
||||||
|
fn from(value: String) -> Self {
|
||||||
|
Self::new(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&str> for ProtectedString {
|
||||||
|
fn from(value: &str) -> Self {
|
||||||
|
Self::new(value.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// S3 credentials with protected secrets
|
||||||
|
#[allow(dead_code)] // S3 storage backend planned for v0.4
|
||||||
|
#[derive(Clone, Zeroize)]
|
||||||
|
#[zeroize(drop)]
|
||||||
|
pub struct S3Credentials {
|
||||||
|
pub access_key_id: String,
|
||||||
|
#[zeroize(skip)] // access_key_id is not sensitive
|
||||||
|
pub secret_access_key: ProtectedString,
|
||||||
|
pub region: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
impl S3Credentials {
|
||||||
|
pub fn new(access_key_id: String, secret_access_key: String) -> Self {
|
||||||
|
Self {
|
||||||
|
access_key_id,
|
||||||
|
secret_access_key: ProtectedString::new(secret_access_key),
|
||||||
|
region: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_region(mut self, region: String) -> Self {
|
||||||
|
self.region = Some(region);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for S3Credentials {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.debug_struct("S3Credentials")
|
||||||
|
.field("access_key_id", &self.access_key_id)
|
||||||
|
.field("secret_access_key", &"***REDACTED***")
|
||||||
|
.field("region", &self.region)
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_protected_string_redacted_debug() {
|
||||||
|
let secret = ProtectedString::new("super-secret-value".to_string());
|
||||||
|
let debug_output = format!("{:?}", secret);
|
||||||
|
assert!(debug_output.contains("REDACTED"));
|
||||||
|
assert!(!debug_output.contains("super-secret-value"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_protected_string_redacted_display() {
|
||||||
|
let secret = ProtectedString::new("super-secret-value".to_string());
|
||||||
|
let display_output = format!("{}", secret);
|
||||||
|
assert_eq!(display_output, "***REDACTED***");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_protected_string_expose() {
|
||||||
|
let secret = ProtectedString::new("my-secret".to_string());
|
||||||
|
assert_eq!(secret.expose(), "my-secret");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_s3_credentials_redacted_debug() {
|
||||||
|
let creds = S3Credentials::new(
|
||||||
|
"AKIAIOSFODNN7EXAMPLE".to_string(),
|
||||||
|
"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".to_string(),
|
||||||
|
);
|
||||||
|
let debug_output = format!("{:?}", creds);
|
||||||
|
assert!(debug_output.contains("AKIAIOSFODNN7EXAMPLE"));
|
||||||
|
assert!(!debug_output.contains("wJalrXUtnFEMI"));
|
||||||
|
assert!(debug_output.contains("REDACTED"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_protected_string_from_str() {
|
||||||
|
let secret: ProtectedString = "test".into();
|
||||||
|
assert_eq!(secret.expose(), "test");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_protected_string_is_empty() {
|
||||||
|
let empty = ProtectedString::new(String::new());
|
||||||
|
let non_empty = ProtectedString::new("secret".to_string());
|
||||||
|
assert!(empty.is_empty());
|
||||||
|
assert!(!non_empty.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use axum::body::Bytes;
|
use axum::body::Bytes;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
@@ -85,6 +88,20 @@ impl StorageBackend for LocalStorage {
|
|||||||
Ok(Bytes::from(buffer))
|
Ok(Bytes::from(buffer))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn delete(&self, key: &str) -> Result<()> {
|
||||||
|
let path = self.key_to_path(key);
|
||||||
|
|
||||||
|
if !path.exists() {
|
||||||
|
return Err(StorageError::NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::remove_file(&path)
|
||||||
|
.await
|
||||||
|
.map_err(|e| StorageError::Io(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn list(&self, prefix: &str) -> Vec<String> {
|
async fn list(&self, prefix: &str) -> Vec<String> {
|
||||||
let base = self.base_path.clone();
|
let base = self.base_path.clone();
|
||||||
let prefix = prefix.to_string();
|
let prefix = prefix.to_string();
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
mod local;
|
mod local;
|
||||||
mod s3;
|
mod s3;
|
||||||
|
|
||||||
@@ -39,6 +42,7 @@ pub type Result<T> = std::result::Result<T, StorageError>;
|
|||||||
pub trait StorageBackend: Send + Sync {
|
pub trait StorageBackend: Send + Sync {
|
||||||
async fn put(&self, key: &str, data: &[u8]) -> Result<()>;
|
async fn put(&self, key: &str, data: &[u8]) -> Result<()>;
|
||||||
async fn get(&self, key: &str) -> Result<Bytes>;
|
async fn get(&self, key: &str) -> Result<Bytes>;
|
||||||
|
async fn delete(&self, key: &str) -> Result<()>;
|
||||||
async fn list(&self, prefix: &str) -> Vec<String>;
|
async fn list(&self, prefix: &str) -> Vec<String>;
|
||||||
async fn stat(&self, key: &str) -> Option<FileMeta>;
|
async fn stat(&self, key: &str) -> Option<FileMeta>;
|
||||||
async fn health_check(&self) -> bool;
|
async fn health_check(&self) -> bool;
|
||||||
@@ -58,9 +62,17 @@ impl Storage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn new_s3(s3_url: &str, bucket: &str) -> Self {
|
pub fn new_s3(
|
||||||
|
s3_url: &str,
|
||||||
|
bucket: &str,
|
||||||
|
region: &str,
|
||||||
|
access_key: Option<&str>,
|
||||||
|
secret_key: Option<&str>,
|
||||||
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
inner: Arc::new(S3Storage::new(s3_url, bucket)),
|
inner: Arc::new(S3Storage::new(
|
||||||
|
s3_url, bucket, region, access_key, secret_key,
|
||||||
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +86,11 @@ impl Storage {
|
|||||||
self.inner.get(key).await
|
self.inner.get(key).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn delete(&self, key: &str) -> Result<()> {
|
||||||
|
validate_storage_key(key)?;
|
||||||
|
self.inner.delete(key).await
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn list(&self, prefix: &str) -> Vec<String> {
|
pub async fn list(&self, prefix: &str) -> Vec<String> {
|
||||||
// Empty prefix is valid for listing all
|
// Empty prefix is valid for listing all
|
||||||
if !prefix.is_empty() && validate_storage_key(prefix).is_err() {
|
if !prefix.is_empty() && validate_storage_key(prefix).is_err() {
|
||||||
|
|||||||
@@ -1,24 +1,146 @@
|
|||||||
|
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use axum::body::Bytes;
|
use axum::body::Bytes;
|
||||||
|
use chrono::Utc;
|
||||||
|
use hmac::{Hmac, Mac};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
use super::{FileMeta, Result, StorageBackend, StorageError};
|
use super::{FileMeta, Result, StorageBackend, StorageError};
|
||||||
|
|
||||||
|
type HmacSha256 = Hmac<Sha256>;
|
||||||
|
|
||||||
/// S3-compatible storage backend (MinIO, AWS S3)
|
/// S3-compatible storage backend (MinIO, AWS S3)
|
||||||
pub struct S3Storage {
|
pub struct S3Storage {
|
||||||
s3_url: String,
|
s3_url: String,
|
||||||
bucket: String,
|
bucket: String,
|
||||||
|
region: String,
|
||||||
|
access_key: Option<String>,
|
||||||
|
secret_key: Option<String>,
|
||||||
client: reqwest::Client,
|
client: reqwest::Client,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl S3Storage {
|
impl S3Storage {
|
||||||
pub fn new(s3_url: &str, bucket: &str) -> Self {
|
/// 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 {
|
||||||
Self {
|
Self {
|
||||||
s3_url: s3_url.to_string(),
|
s3_url: s3_url.trim_end_matches('/').to_string(),
|
||||||
bucket: bucket.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(),
|
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", ×tamp)
|
||||||
|
.header("x-amz-content-sha256", &payload_hash);
|
||||||
|
|
||||||
|
if let Some(auth) =
|
||||||
|
self.sign_request(method.as_str(), key, &payload_hash, ×tamp, &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> {
|
fn parse_s3_keys(xml: &str, prefix: &str) -> Vec<String> {
|
||||||
xml.split("<Key>")
|
xml.split("<Key>")
|
||||||
.filter_map(|part| part.split("</Key>").next())
|
.filter_map(|part| part.split("</Key>").next())
|
||||||
@@ -28,17 +150,34 @@ 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]
|
#[async_trait]
|
||||||
impl StorageBackend for S3Storage {
|
impl StorageBackend for S3Storage {
|
||||||
async fn put(&self, key: &str, data: &[u8]) -> Result<()> {
|
async fn put(&self, key: &str, data: &[u8]) -> Result<()> {
|
||||||
let url = format!("{}/{}/{}", self.s3_url, self.bucket, key);
|
|
||||||
let response = self
|
let response = self
|
||||||
.client
|
.signed_request(reqwest::Method::PUT, key, Some(data))
|
||||||
.put(&url)
|
.await?;
|
||||||
.body(data.to_vec())
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| StorageError::Network(e.to_string()))?;
|
|
||||||
|
|
||||||
if response.status().is_success() {
|
if response.status().is_success() {
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -51,13 +190,7 @@ impl StorageBackend for S3Storage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn get(&self, key: &str) -> Result<Bytes> {
|
async fn get(&self, key: &str) -> Result<Bytes> {
|
||||||
let url = format!("{}/{}/{}", self.s3_url, self.bucket, key);
|
let response = self.signed_request(reqwest::Method::GET, key, None).await?;
|
||||||
let response = self
|
|
||||||
.client
|
|
||||||
.get(&url)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| StorageError::Network(e.to_string()))?;
|
|
||||||
|
|
||||||
if response.status().is_success() {
|
if response.status().is_success() {
|
||||||
response
|
response
|
||||||
@@ -74,9 +207,77 @@ impl StorageBackend for S3Storage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn delete(&self, key: &str) -> Result<()> {
|
||||||
|
let response = self
|
||||||
|
.signed_request(reqwest::Method::DELETE, key, None)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if response.status().is_success() || response.status().as_u16() == 204 {
|
||||||
|
Ok(())
|
||||||
|
} else if response.status().as_u16() == 404 {
|
||||||
|
Err(StorageError::NotFound)
|
||||||
|
} else {
|
||||||
|
Err(StorageError::Network(format!(
|
||||||
|
"DELETE failed: {}",
|
||||||
|
response.status()
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn list(&self, prefix: &str) -> Vec<String> {
|
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 url = format!("{}/{}", self.s3_url, self.bucket);
|
||||||
match self.client.get(&url).send().await {
|
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", ×tamp)
|
||||||
|
.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 {
|
||||||
Ok(response) if response.status().is_success() => {
|
Ok(response) if response.status().is_success() => {
|
||||||
if let Ok(xml) = response.text().await {
|
if let Ok(xml) = response.text().await {
|
||||||
Self::parse_s3_keys(&xml, prefix)
|
Self::parse_s3_keys(&xml, prefix)
|
||||||
@@ -89,18 +290,22 @@ impl StorageBackend for S3Storage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn stat(&self, key: &str) -> Option<FileMeta> {
|
async fn stat(&self, key: &str) -> Option<FileMeta> {
|
||||||
let url = format!("{}/{}/{}", self.s3_url, self.bucket, key);
|
let response = self
|
||||||
let response = self.client.head(&url).send().await.ok()?;
|
.signed_request(reqwest::Method::HEAD, key, None)
|
||||||
|
.await
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let size = response
|
let size = response
|
||||||
.headers()
|
.headers()
|
||||||
.get("content-length")
|
.get("content-length")
|
||||||
.and_then(|v| v.to_str().ok())
|
.and_then(|v| v.to_str().ok())
|
||||||
.and_then(|v| v.parse().ok())
|
.and_then(|v| v.parse().ok())
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
// S3 uses Last-Modified header, but for simplicity use current time if unavailable
|
|
||||||
let modified = response
|
let modified = response
|
||||||
.headers()
|
.headers()
|
||||||
.get("last-modified")
|
.get("last-modified")
|
||||||
@@ -112,12 +317,63 @@ impl StorageBackend for S3Storage {
|
|||||||
.as_secs()
|
.as_secs()
|
||||||
})
|
})
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
Some(FileMeta { size, modified })
|
Some(FileMeta { size, modified })
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn health_check(&self) -> bool {
|
async fn health_check(&self) -> bool {
|
||||||
|
// Try HEAD on the bucket
|
||||||
let url = format!("{}/{}", self.s3_url, self.bucket);
|
let url = format!("{}/{}", self.s3_url, self.bucket);
|
||||||
match self.client.head(&url).send().await {
|
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", ×tamp)
|
||||||
|
.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 {
|
||||||
Ok(response) => response.status().is_success() || response.status().as_u16() == 404,
|
Ok(response) => response.status().is_success() || response.status().as_u16() == 404,
|
||||||
Err(_) => false,
|
Err(_) => false,
|
||||||
}
|
}
|
||||||
@@ -131,173 +387,28 @@ impl StorageBackend for S3Storage {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use wiremock::matchers::{method, path};
|
|
||||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
|
||||||
|
|
||||||
#[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]
|
#[test]
|
||||||
fn test_backend_name() {
|
fn test_backend_name() {
|
||||||
let storage = S3Storage::new("http://localhost:9000", "bucket");
|
let storage = S3Storage::new(
|
||||||
|
"http://localhost:9000",
|
||||||
|
"test-bucket",
|
||||||
|
"us-east-1",
|
||||||
|
Some("access"),
|
||||||
|
Some("secret"),
|
||||||
|
);
|
||||||
|
assert_eq!(storage.backend_name(), "s3");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_s3_storage_creation_anonymous() {
|
||||||
|
let storage = S3Storage::new(
|
||||||
|
"http://localhost:9000",
|
||||||
|
"test-bucket",
|
||||||
|
"us-east-1",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
);
|
||||||
assert_eq!(storage.backend_name(), "s3");
|
assert_eq!(storage.backend_name(), "s3");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,4 +418,10 @@ mod tests {
|
|||||||
let keys = S3Storage::parse_s3_keys(xml, "docker/");
|
let keys = S3Storage::parse_s3_keys(xml, "docker/");
|
||||||
assert_eq!(keys, vec!["docker/a", "docker/b"]);
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
@@ -8,6 +11,35 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
const TOKEN_PREFIX: &str = "nra_";
|
const TOKEN_PREFIX: &str = "nra_";
|
||||||
|
|
||||||
|
/// Access role for API tokens
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum Role {
|
||||||
|
Read,
|
||||||
|
Write,
|
||||||
|
Admin,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for Role {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Role::Read => write!(f, "read"),
|
||||||
|
Role::Write => write!(f, "write"),
|
||||||
|
Role::Admin => write!(f, "admin"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Role {
|
||||||
|
pub fn can_write(&self) -> bool {
|
||||||
|
matches!(self, Role::Write | Role::Admin)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn can_admin(&self) -> bool {
|
||||||
|
matches!(self, Role::Admin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// API Token metadata stored on disk
|
/// API Token metadata stored on disk
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct TokenInfo {
|
pub struct TokenInfo {
|
||||||
@@ -17,6 +49,12 @@ pub struct TokenInfo {
|
|||||||
pub expires_at: u64,
|
pub expires_at: u64,
|
||||||
pub last_used: Option<u64>,
|
pub last_used: Option<u64>,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
|
#[serde(default = "default_role")]
|
||||||
|
pub role: Role,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_role() -> Role {
|
||||||
|
Role::Read
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Token store for managing API tokens
|
/// Token store for managing API tokens
|
||||||
@@ -41,6 +79,7 @@ impl TokenStore {
|
|||||||
user: &str,
|
user: &str,
|
||||||
ttl_days: u64,
|
ttl_days: u64,
|
||||||
description: Option<String>,
|
description: Option<String>,
|
||||||
|
role: Role,
|
||||||
) -> Result<String, TokenError> {
|
) -> Result<String, TokenError> {
|
||||||
// Generate random token
|
// Generate random token
|
||||||
let raw_token = format!(
|
let raw_token = format!(
|
||||||
@@ -64,6 +103,7 @@ impl TokenStore {
|
|||||||
expires_at,
|
expires_at,
|
||||||
last_used: None,
|
last_used: None,
|
||||||
description,
|
description,
|
||||||
|
role,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Save to file
|
// Save to file
|
||||||
@@ -78,7 +118,7 @@ impl TokenStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Verify a token and return user info if valid
|
/// Verify a token and return user info if valid
|
||||||
pub fn verify_token(&self, token: &str) -> Result<String, TokenError> {
|
pub fn verify_token(&self, token: &str) -> Result<(String, Role), TokenError> {
|
||||||
if !token.starts_with(TOKEN_PREFIX) {
|
if !token.starts_with(TOKEN_PREFIX) {
|
||||||
return Err(TokenError::InvalidFormat);
|
return Err(TokenError::InvalidFormat);
|
||||||
}
|
}
|
||||||
@@ -118,7 +158,7 @@ impl TokenStore {
|
|||||||
let _ = fs::write(&file_path, json);
|
let _ = fs::write(&file_path, json);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(info.user)
|
Ok((info.user, info.role))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List all tokens for a user
|
/// List all tokens for a user
|
||||||
@@ -207,7 +247,7 @@ mod tests {
|
|||||||
let store = TokenStore::new(temp_dir.path());
|
let store = TokenStore::new(temp_dir.path());
|
||||||
|
|
||||||
let token = store
|
let token = store
|
||||||
.create_token("testuser", 30, Some("Test token".to_string()))
|
.create_token("testuser", 30, Some("Test token".to_string()), Role::Write)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert!(token.starts_with("nra_"));
|
assert!(token.starts_with("nra_"));
|
||||||
@@ -219,10 +259,13 @@ mod tests {
|
|||||||
let temp_dir = TempDir::new().unwrap();
|
let temp_dir = TempDir::new().unwrap();
|
||||||
let store = TokenStore::new(temp_dir.path());
|
let store = TokenStore::new(temp_dir.path());
|
||||||
|
|
||||||
let token = store.create_token("testuser", 30, None).unwrap();
|
let token = store
|
||||||
let user = store.verify_token(&token).unwrap();
|
.create_token("testuser", 30, None, Role::Write)
|
||||||
|
.unwrap();
|
||||||
|
let (user, role) = store.verify_token(&token).unwrap();
|
||||||
|
|
||||||
assert_eq!(user, "testuser");
|
assert_eq!(user, "testuser");
|
||||||
|
assert_eq!(role, Role::Write);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -249,7 +292,9 @@ mod tests {
|
|||||||
let store = TokenStore::new(temp_dir.path());
|
let store = TokenStore::new(temp_dir.path());
|
||||||
|
|
||||||
// Create token and manually set it as expired
|
// Create token and manually set it as expired
|
||||||
let token = store.create_token("testuser", 1, None).unwrap();
|
let token = store
|
||||||
|
.create_token("testuser", 1, None, Role::Write)
|
||||||
|
.unwrap();
|
||||||
let token_hash = hash_token(&token);
|
let token_hash = hash_token(&token);
|
||||||
let file_path = temp_dir.path().join(format!("{}.json", &token_hash[..16]));
|
let file_path = temp_dir.path().join(format!("{}.json", &token_hash[..16]));
|
||||||
|
|
||||||
@@ -269,9 +314,9 @@ mod tests {
|
|||||||
let temp_dir = TempDir::new().unwrap();
|
let temp_dir = TempDir::new().unwrap();
|
||||||
let store = TokenStore::new(temp_dir.path());
|
let store = TokenStore::new(temp_dir.path());
|
||||||
|
|
||||||
store.create_token("user1", 30, None).unwrap();
|
store.create_token("user1", 30, None, Role::Write).unwrap();
|
||||||
store.create_token("user1", 30, None).unwrap();
|
store.create_token("user1", 30, None, Role::Write).unwrap();
|
||||||
store.create_token("user2", 30, None).unwrap();
|
store.create_token("user2", 30, None, Role::Read).unwrap();
|
||||||
|
|
||||||
let user1_tokens = store.list_tokens("user1");
|
let user1_tokens = store.list_tokens("user1");
|
||||||
assert_eq!(user1_tokens.len(), 2);
|
assert_eq!(user1_tokens.len(), 2);
|
||||||
@@ -288,7 +333,9 @@ mod tests {
|
|||||||
let temp_dir = TempDir::new().unwrap();
|
let temp_dir = TempDir::new().unwrap();
|
||||||
let store = TokenStore::new(temp_dir.path());
|
let store = TokenStore::new(temp_dir.path());
|
||||||
|
|
||||||
let token = store.create_token("testuser", 30, None).unwrap();
|
let token = store
|
||||||
|
.create_token("testuser", 30, None, Role::Write)
|
||||||
|
.unwrap();
|
||||||
let token_hash = hash_token(&token);
|
let token_hash = hash_token(&token);
|
||||||
let hash_prefix = &token_hash[..16];
|
let hash_prefix = &token_hash[..16];
|
||||||
|
|
||||||
@@ -317,9 +364,9 @@ mod tests {
|
|||||||
let temp_dir = TempDir::new().unwrap();
|
let temp_dir = TempDir::new().unwrap();
|
||||||
let store = TokenStore::new(temp_dir.path());
|
let store = TokenStore::new(temp_dir.path());
|
||||||
|
|
||||||
store.create_token("user1", 30, None).unwrap();
|
store.create_token("user1", 30, None, Role::Write).unwrap();
|
||||||
store.create_token("user1", 30, None).unwrap();
|
store.create_token("user1", 30, None, Role::Write).unwrap();
|
||||||
store.create_token("user2", 30, None).unwrap();
|
store.create_token("user2", 30, None, Role::Read).unwrap();
|
||||||
|
|
||||||
let revoked = store.revoke_all_for_user("user1");
|
let revoked = store.revoke_all_for_user("user1");
|
||||||
assert_eq!(revoked, 2);
|
assert_eq!(revoked, 2);
|
||||||
@@ -333,7 +380,9 @@ mod tests {
|
|||||||
let temp_dir = TempDir::new().unwrap();
|
let temp_dir = TempDir::new().unwrap();
|
||||||
let store = TokenStore::new(temp_dir.path());
|
let store = TokenStore::new(temp_dir.path());
|
||||||
|
|
||||||
let token = store.create_token("testuser", 30, None).unwrap();
|
let token = store
|
||||||
|
.create_token("testuser", 30, None, Role::Write)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
// First verification
|
// First verification
|
||||||
store.verify_token(&token).unwrap();
|
store.verify_token(&token).unwrap();
|
||||||
@@ -349,7 +398,12 @@ mod tests {
|
|||||||
let store = TokenStore::new(temp_dir.path());
|
let store = TokenStore::new(temp_dir.path());
|
||||||
|
|
||||||
store
|
store
|
||||||
.create_token("testuser", 30, Some("CI/CD Pipeline".to_string()))
|
.create_token(
|
||||||
|
"testuser",
|
||||||
|
30,
|
||||||
|
Some("CI/CD Pipeline".to_string()),
|
||||||
|
Role::Admin,
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let tokens = store.list_tokens("testuser");
|
let tokens = store.list_tokens("testuser");
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
|
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
use super::components::{format_size, format_timestamp, html_escape};
|
use super::components::{format_size, format_timestamp, html_escape};
|
||||||
use super::templates::encode_uri_component;
|
use super::templates::encode_uri_component;
|
||||||
|
use crate::activity_log::ActivityEntry;
|
||||||
|
use crate::repo_index::RepoInfo;
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use crate::Storage;
|
use crate::Storage;
|
||||||
use axum::{
|
use axum::{
|
||||||
@@ -8,6 +13,7 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::sync::atomic::Ordering;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -19,19 +25,17 @@ pub struct RegistryStats {
|
|||||||
pub pypi: usize,
|
pub pypi: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Clone)]
|
|
||||||
pub struct RepoInfo {
|
|
||||||
pub name: String,
|
|
||||||
pub versions: usize,
|
|
||||||
pub size: u64,
|
|
||||||
pub updated: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct TagInfo {
|
pub struct TagInfo {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub size: u64,
|
pub size: u64,
|
||||||
pub created: String,
|
pub created: String,
|
||||||
|
pub downloads: u64,
|
||||||
|
pub last_pulled: Option<String>,
|
||||||
|
pub os: String,
|
||||||
|
pub arch: String,
|
||||||
|
pub layers_count: usize,
|
||||||
|
pub pull_command: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -67,26 +71,182 @@ pub struct SearchQuery {
|
|||||||
pub q: Option<String>,
|
pub q: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct DashboardResponse {
|
||||||
|
pub global_stats: GlobalStats,
|
||||||
|
pub registry_stats: Vec<RegistryCardStats>,
|
||||||
|
pub mount_points: Vec<MountPoint>,
|
||||||
|
pub activity: Vec<ActivityEntry>,
|
||||||
|
pub uptime_seconds: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct GlobalStats {
|
||||||
|
pub downloads: u64,
|
||||||
|
pub uploads: u64,
|
||||||
|
pub artifacts: u64,
|
||||||
|
pub cache_hit_percent: f64,
|
||||||
|
pub storage_bytes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct RegistryCardStats {
|
||||||
|
pub name: String,
|
||||||
|
pub artifact_count: usize,
|
||||||
|
pub downloads: u64,
|
||||||
|
pub uploads: u64,
|
||||||
|
pub size_bytes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct MountPoint {
|
||||||
|
pub registry: String,
|
||||||
|
pub mount_path: String,
|
||||||
|
pub proxy_upstream: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
// ============ API Handlers ============
|
// ============ API Handlers ============
|
||||||
|
|
||||||
pub async fn api_stats(State(state): State<Arc<AppState>>) -> Json<RegistryStats> {
|
pub async fn api_stats(State(state): State<Arc<AppState>>) -> Json<RegistryStats> {
|
||||||
let stats = get_registry_stats(&state.storage).await;
|
// Trigger index rebuild if needed, then get counts
|
||||||
Json(stats)
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
// Count total versions/tags, not just repositories
|
||||||
|
let docker_versions: usize = docker_repos.iter().map(|r| r.versions).sum();
|
||||||
|
let maven_versions: usize = maven_repos.iter().map(|r| r.versions).sum();
|
||||||
|
let npm_versions: usize = npm_repos.iter().map(|r| r.versions).sum();
|
||||||
|
let cargo_versions: usize = cargo_repos.iter().map(|r| r.versions).sum();
|
||||||
|
let pypi_versions: usize = pypi_repos.iter().map(|r| r.versions).sum();
|
||||||
|
let total_artifacts =
|
||||||
|
docker_versions + maven_versions + npm_versions + cargo_versions + pypi_versions;
|
||||||
|
|
||||||
|
let global_stats = GlobalStats {
|
||||||
|
downloads: state.metrics.downloads.load(Ordering::Relaxed),
|
||||||
|
uploads: state.metrics.uploads.load(Ordering::Relaxed),
|
||||||
|
artifacts: total_artifacts as u64,
|
||||||
|
cache_hit_percent: state.metrics.cache_hit_rate(),
|
||||||
|
storage_bytes: total_storage,
|
||||||
|
};
|
||||||
|
|
||||||
|
let registry_card_stats = vec![
|
||||||
|
RegistryCardStats {
|
||||||
|
name: "docker".to_string(),
|
||||||
|
artifact_count: docker_versions,
|
||||||
|
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_versions,
|
||||||
|
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_versions,
|
||||||
|
downloads: state.metrics.get_registry_downloads("npm"),
|
||||||
|
uploads: 0,
|
||||||
|
size_bytes: npm_size,
|
||||||
|
},
|
||||||
|
RegistryCardStats {
|
||||||
|
name: "cargo".to_string(),
|
||||||
|
artifact_count: cargo_versions,
|
||||||
|
downloads: state.metrics.get_registry_downloads("cargo"),
|
||||||
|
uploads: 0,
|
||||||
|
size_bytes: cargo_size,
|
||||||
|
},
|
||||||
|
RegistryCardStats {
|
||||||
|
name: "pypi".to_string(),
|
||||||
|
artifact_count: pypi_versions,
|
||||||
|
downloads: state.metrics.get_registry_downloads("pypi"),
|
||||||
|
uploads: 0,
|
||||||
|
size_bytes: pypi_size,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let mount_points = vec![
|
||||||
|
MountPoint {
|
||||||
|
registry: "Docker".to_string(),
|
||||||
|
mount_path: "/v2/".to_string(),
|
||||||
|
proxy_upstream: None,
|
||||||
|
},
|
||||||
|
MountPoint {
|
||||||
|
registry: "Maven".to_string(),
|
||||||
|
mount_path: "/maven2/".to_string(),
|
||||||
|
proxy_upstream: state
|
||||||
|
.config
|
||||||
|
.maven
|
||||||
|
.proxies
|
||||||
|
.first()
|
||||||
|
.map(|p| p.url().to_string()),
|
||||||
|
},
|
||||||
|
MountPoint {
|
||||||
|
registry: "npm".to_string(),
|
||||||
|
mount_path: "/npm/".to_string(),
|
||||||
|
proxy_upstream: state.config.npm.proxy.clone(),
|
||||||
|
},
|
||||||
|
MountPoint {
|
||||||
|
registry: "Cargo".to_string(),
|
||||||
|
mount_path: "/cargo/".to_string(),
|
||||||
|
proxy_upstream: None,
|
||||||
|
},
|
||||||
|
MountPoint {
|
||||||
|
registry: "PyPI".to_string(),
|
||||||
|
mount_path: "/simple/".to_string(),
|
||||||
|
proxy_upstream: state.config.pypi.proxy.clone(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let activity = state.activity.recent(20);
|
||||||
|
let uptime_seconds = state.start_time.elapsed().as_secs();
|
||||||
|
|
||||||
|
Json(DashboardResponse {
|
||||||
|
global_stats,
|
||||||
|
registry_stats: registry_card_stats,
|
||||||
|
mount_points,
|
||||||
|
activity,
|
||||||
|
uptime_seconds,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn api_list(
|
pub async fn api_list(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Path(registry_type): Path<String>,
|
Path(registry_type): Path<String>,
|
||||||
) -> Json<Vec<RepoInfo>> {
|
) -> Json<Vec<RepoInfo>> {
|
||||||
let repos = match registry_type.as_str() {
|
let repos = state.repo_index.get(®istry_type, &state.storage).await;
|
||||||
"docker" => get_docker_repos(&state.storage).await,
|
Json((*repos).clone())
|
||||||
"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(
|
pub async fn api_detail(
|
||||||
@@ -95,7 +255,7 @@ pub async fn api_detail(
|
|||||||
) -> Json<serde_json::Value> {
|
) -> Json<serde_json::Value> {
|
||||||
match registry_type.as_str() {
|
match registry_type.as_str() {
|
||||||
"docker" => {
|
"docker" => {
|
||||||
let detail = get_docker_detail(&state.storage, &name).await;
|
let detail = get_docker_detail(&state, &name).await;
|
||||||
Json(serde_json::to_value(detail).unwrap_or_default())
|
Json(serde_json::to_value(detail).unwrap_or_default())
|
||||||
}
|
}
|
||||||
"npm" => {
|
"npm" => {
|
||||||
@@ -117,20 +277,13 @@ pub async fn api_search(
|
|||||||
) -> axum::response::Html<String> {
|
) -> axum::response::Html<String> {
|
||||||
let query = params.q.unwrap_or_default().to_lowercase();
|
let query = params.q.unwrap_or_default().to_lowercase();
|
||||||
|
|
||||||
let repos = match registry_type.as_str() {
|
let repos = state.repo_index.get(®istry_type, &state.storage).await;
|
||||||
"docker" => get_docker_repos(&state.storage).await,
|
|
||||||
"maven" => get_maven_repos(&state.storage).await,
|
|
||||||
"npm" => get_npm_packages(&state.storage).await,
|
|
||||||
"cargo" => get_cargo_crates(&state.storage).await,
|
|
||||||
"pypi" => get_pypi_packages(&state.storage).await,
|
|
||||||
_ => vec![],
|
|
||||||
};
|
|
||||||
|
|
||||||
let filtered: Vec<_> = if query.is_empty() {
|
let filtered: Vec<&RepoInfo> = if query.is_empty() {
|
||||||
repos
|
repos.iter().collect()
|
||||||
} else {
|
} else {
|
||||||
repos
|
repos
|
||||||
.into_iter()
|
.iter()
|
||||||
.filter(|r| r.name.to_lowercase().contains(&query))
|
.filter(|r| r.name.to_lowercase().contains(&query))
|
||||||
.collect()
|
.collect()
|
||||||
};
|
};
|
||||||
@@ -175,7 +328,9 @@ pub async fn api_search(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ============ Data Fetching Functions ============
|
// ============ 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 {
|
pub async fn get_registry_stats(storage: &Storage) -> RegistryStats {
|
||||||
let all_keys = storage.list("").await;
|
let all_keys = storage.list("").await;
|
||||||
|
|
||||||
@@ -227,12 +382,18 @@ pub async fn get_registry_stats(storage: &Storage) -> RegistryStats {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
pub async fn get_docker_repos(storage: &Storage) -> Vec<RepoInfo> {
|
pub async fn get_docker_repos(storage: &Storage) -> Vec<RepoInfo> {
|
||||||
let keys = storage.list("docker/").await;
|
let keys = storage.list("docker/").await;
|
||||||
|
|
||||||
let mut repos: HashMap<String, (RepoInfo, u64)> = HashMap::new(); // (info, latest_modified)
|
let mut repos: HashMap<String, (RepoInfo, u64)> = HashMap::new(); // (info, latest_modified)
|
||||||
|
|
||||||
for key in &keys {
|
for key in &keys {
|
||||||
|
// Skip .meta.json files
|
||||||
|
if key.ends_with(".meta.json") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(rest) = key.strip_prefix("docker/") {
|
if let Some(rest) = key.strip_prefix("docker/") {
|
||||||
let parts: Vec<_> = rest.split('/').collect();
|
let parts: Vec<_> = rest.split('/').collect();
|
||||||
if parts.len() >= 3 {
|
if parts.len() >= 3 {
|
||||||
@@ -249,10 +410,35 @@ pub async fn get_docker_repos(storage: &Storage) -> Vec<RepoInfo> {
|
|||||||
)
|
)
|
||||||
});
|
});
|
||||||
|
|
||||||
if parts[1] == "manifests" {
|
if parts[1] == "manifests" && key.ends_with(".json") {
|
||||||
entry.0.versions += 1;
|
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 {
|
if let Some(meta) = storage.stat(key).await {
|
||||||
entry.0.size += meta.size;
|
|
||||||
if meta.modified > entry.1 {
|
if meta.modified > entry.1 {
|
||||||
entry.1 = meta.modified;
|
entry.1 = meta.modified;
|
||||||
entry.0.updated = format_timestamp(meta.modified);
|
entry.0.updated = format_timestamp(meta.modified);
|
||||||
@@ -268,25 +454,106 @@ pub async fn get_docker_repos(storage: &Storage) -> Vec<RepoInfo> {
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_docker_detail(storage: &Storage, name: &str) -> DockerDetail {
|
pub async fn get_docker_detail(state: &AppState, name: &str) -> DockerDetail {
|
||||||
let prefix = format!("docker/{}/manifests/", name);
|
let prefix = format!("docker/{}/manifests/", name);
|
||||||
let keys = storage.list(&prefix).await;
|
let keys = state.storage.list(&prefix).await;
|
||||||
|
|
||||||
|
// Build public URL for pull commands
|
||||||
|
let registry_host =
|
||||||
|
state.config.server.public_url.clone().unwrap_or_else(|| {
|
||||||
|
format!("{}:{}", state.config.server.host, state.config.server.port)
|
||||||
|
});
|
||||||
|
|
||||||
let mut tags = Vec::new();
|
let mut tags = Vec::new();
|
||||||
for key in &keys {
|
for key in &keys {
|
||||||
|
// Skip .meta.json files
|
||||||
|
if key.ends_with(".meta.json") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(tag_name) = key
|
if let Some(tag_name) = key
|
||||||
.strip_prefix(&prefix)
|
.strip_prefix(&prefix)
|
||||||
.and_then(|s| s.strip_suffix(".json"))
|
.and_then(|s| s.strip_suffix(".json"))
|
||||||
{
|
{
|
||||||
let (size, created) = if let Some(meta) = storage.stat(key).await {
|
// Load metadata from .meta.json file
|
||||||
(meta.size, format_timestamp(meta.modified))
|
let meta_key = format!("{}.meta.json", key.trim_end_matches(".json"));
|
||||||
|
let metadata = if let Ok(meta_data) = state.storage.get(&meta_key).await {
|
||||||
|
serde_json::from_slice::<crate::registry::docker::ImageMetadata>(&meta_data)
|
||||||
|
.unwrap_or_default()
|
||||||
} else {
|
} else {
|
||||||
(0, "N/A".to_string())
|
crate::registry::docker::ImageMetadata::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Get file stats for created timestamp if metadata doesn't have push_timestamp
|
||||||
|
let created = if metadata.push_timestamp > 0 {
|
||||||
|
format_timestamp(metadata.push_timestamp)
|
||||||
|
} else if let Some(file_meta) = state.storage.stat(key).await {
|
||||||
|
format_timestamp(file_meta.modified)
|
||||||
|
} else {
|
||||||
|
"N/A".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate size from manifest layers (config + layers)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format last_pulled
|
||||||
|
let last_pulled = if metadata.last_pulled > 0 {
|
||||||
|
Some(format_timestamp(metadata.last_pulled))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build pull command
|
||||||
|
let pull_command = format!("docker pull {}/{}:{}", registry_host, name, tag_name);
|
||||||
|
|
||||||
tags.push(TagInfo {
|
tags.push(TagInfo {
|
||||||
name: tag_name.to_string(),
|
name: tag_name.to_string(),
|
||||||
size,
|
size,
|
||||||
created,
|
created,
|
||||||
|
downloads: metadata.downloads,
|
||||||
|
last_pulled,
|
||||||
|
os: if metadata.os.is_empty() {
|
||||||
|
"unknown".to_string()
|
||||||
|
} else {
|
||||||
|
metadata.os
|
||||||
|
},
|
||||||
|
arch: if metadata.arch.is_empty() {
|
||||||
|
"unknown".to_string()
|
||||||
|
} else {
|
||||||
|
metadata.arch
|
||||||
|
},
|
||||||
|
layers_count: metadata.layers.len(),
|
||||||
|
pull_command,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -294,6 +561,7 @@ pub async fn get_docker_detail(storage: &Storage, name: &str) -> DockerDetail {
|
|||||||
DockerDetail { tags }
|
DockerDetail { tags }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
pub async fn get_maven_repos(storage: &Storage) -> Vec<RepoInfo> {
|
pub async fn get_maven_repos(storage: &Storage) -> Vec<RepoInfo> {
|
||||||
let keys = storage.list("maven/").await;
|
let keys = storage.list("maven/").await;
|
||||||
|
|
||||||
@@ -353,75 +621,125 @@ pub async fn get_maven_detail(storage: &Storage, path: &str) -> MavenDetail {
|
|||||||
MavenDetail { artifacts }
|
MavenDetail { artifacts }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
pub async fn get_npm_packages(storage: &Storage) -> Vec<RepoInfo> {
|
pub async fn get_npm_packages(storage: &Storage) -> Vec<RepoInfo> {
|
||||||
let keys = storage.list("npm/").await;
|
let keys = storage.list("npm/").await;
|
||||||
|
|
||||||
let mut packages: HashMap<String, (RepoInfo, u64)> = HashMap::new();
|
let mut packages: HashMap<String, RepoInfo> = HashMap::new();
|
||||||
|
|
||||||
|
// Find all metadata.json files
|
||||||
for key in &keys {
|
for key in &keys {
|
||||||
if let Some(rest) = key.strip_prefix("npm/") {
|
if key.ends_with("/metadata.json") {
|
||||||
let parts: Vec<_> = rest.split('/').collect();
|
if let Some(name) = key
|
||||||
if !parts.is_empty() {
|
.strip_prefix("npm/")
|
||||||
let name = parts[0].to_string();
|
.and_then(|s| s.strip_suffix("/metadata.json"))
|
||||||
let entry = packages.entry(name.clone()).or_insert_with(|| {
|
{
|
||||||
(
|
// Parse metadata to get version count and info
|
||||||
RepoInfo {
|
if let Ok(data) = storage.get(key).await {
|
||||||
name,
|
if let Ok(metadata) = serde_json::from_slice::<serde_json::Value>(&data) {
|
||||||
versions: 0,
|
let versions_count = metadata
|
||||||
size: 0,
|
.get("versions")
|
||||||
updated: "N/A".to_string(),
|
.and_then(|v| v.as_object())
|
||||||
},
|
.map(|v| v.len())
|
||||||
0,
|
.unwrap_or(0);
|
||||||
)
|
|
||||||
});
|
|
||||||
|
|
||||||
if parts.len() >= 3 && parts[1] == "tarballs" {
|
// Calculate total size from dist.unpackedSize or estimate
|
||||||
entry.0.versions += 1;
|
let total_size: u64 = metadata
|
||||||
if let Some(meta) = storage.stat(key).await {
|
.get("versions")
|
||||||
entry.0.size += meta.size;
|
.and_then(|v| v.as_object())
|
||||||
if meta.modified > entry.1 {
|
.map(|versions| {
|
||||||
entry.1 = meta.modified;
|
versions
|
||||||
entry.0.updated = format_timestamp(meta.modified);
|
.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,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut result: Vec<_> = packages.into_values().map(|(r, _)| r).collect();
|
let mut result: Vec<_> = packages.into_values().collect();
|
||||||
result.sort_by(|a, b| a.name.cmp(&b.name));
|
result.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_npm_detail(storage: &Storage, name: &str) -> PackageDetail {
|
pub async fn get_npm_detail(storage: &Storage, name: &str) -> PackageDetail {
|
||||||
let prefix = format!("npm/{}/tarballs/", name);
|
let metadata_key = format!("npm/{}/metadata.json", name);
|
||||||
let keys = storage.list(&prefix).await;
|
|
||||||
|
|
||||||
let mut versions = Vec::new();
|
let mut versions = Vec::new();
|
||||||
for key in &keys {
|
|
||||||
if let Some(tarball) = key.strip_prefix(&prefix) {
|
// Parse metadata.json for version info
|
||||||
if let Some(version) = tarball
|
if let Ok(data) = storage.get(&metadata_key).await {
|
||||||
.strip_prefix(&format!("{}-", name))
|
if let Ok(metadata) = serde_json::from_slice::<serde_json::Value>(&data) {
|
||||||
.and_then(|s| s.strip_suffix(".tgz"))
|
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());
|
||||||
let (size, published) = if let Some(meta) = storage.stat(key).await {
|
|
||||||
(meta.size, format_timestamp(meta.modified))
|
for (version, info) in versions_obj {
|
||||||
} else {
|
let size = info
|
||||||
(0, "N/A".to_string())
|
.get("dist")
|
||||||
};
|
.and_then(|d| d.get("unpackedSize"))
|
||||||
versions.push(VersionInfo {
|
.and_then(|s| s.as_u64())
|
||||||
version: version.to_string(),
|
.unwrap_or(0);
|
||||||
size,
|
|
||||||
published,
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 }
|
PackageDetail { versions }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
pub async fn get_cargo_crates(storage: &Storage) -> Vec<RepoInfo> {
|
pub async fn get_cargo_crates(storage: &Storage) -> Vec<RepoInfo> {
|
||||||
let keys = storage.list("cargo/").await;
|
let keys = storage.list("cargo/").await;
|
||||||
|
|
||||||
@@ -489,6 +807,7 @@ pub async fn get_cargo_detail(storage: &Storage, name: &str) -> PackageDetail {
|
|||||||
PackageDetail { versions }
|
PackageDetail { versions }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
pub async fn get_pypi_packages(storage: &Storage) -> Vec<RepoInfo> {
|
pub async fn get_pypi_packages(storage: &Storage) -> Vec<RepoInfo> {
|
||||||
let keys = storage.list("pypi/").await;
|
let keys = storage.list("pypi/").await;
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,23 @@
|
|||||||
/// Main layout wrapper with header and sidebar
|
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||||
pub fn layout(title: &str, content: &str, active_page: Option<&str>) -> String {
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
use super::i18n::{get_translations, Lang, Translations};
|
||||||
|
|
||||||
|
/// Application version from Cargo.toml
|
||||||
|
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
|
|
||||||
|
/// Dark theme layout wrapper for dashboard
|
||||||
|
pub fn layout_dark(
|
||||||
|
title: &str,
|
||||||
|
content: &str,
|
||||||
|
active_page: Option<&str>,
|
||||||
|
extra_scripts: &str,
|
||||||
|
lang: Lang,
|
||||||
|
) -> String {
|
||||||
|
let t = get_translations(lang);
|
||||||
format!(
|
format!(
|
||||||
r##"<!DOCTYPE html>
|
r##"<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="{}">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
@@ -14,7 +29,7 @@ pub fn layout(title: &str, content: &str, active_page: Option<&str>) -> String {
|
|||||||
.sidebar-open {{ overflow: hidden; }}
|
.sidebar-open {{ overflow: hidden; }}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-slate-100 min-h-screen">
|
<body class="bg-[#0f172a] min-h-screen">
|
||||||
<div class="flex h-screen overflow-hidden">
|
<div class="flex h-screen overflow-hidden">
|
||||||
<!-- Mobile sidebar overlay -->
|
<!-- Mobile sidebar overlay -->
|
||||||
<div id="sidebar-overlay" class="fixed inset-0 bg-black/50 z-40 hidden md:hidden" onclick="toggleSidebar()"></div>
|
<div id="sidebar-overlay" class="fixed inset-0 bg-black/50 z-40 hidden md:hidden" onclick="toggleSidebar()"></div>
|
||||||
@@ -50,17 +65,424 @@ pub fn layout(title: &str, content: &str, active_page: Option<&str>) -> String {
|
|||||||
document.body.classList.add('sidebar-open');
|
document.body.classList.add('sidebar-open');
|
||||||
}}
|
}}
|
||||||
}}
|
}}
|
||||||
|
|
||||||
|
function setLang(lang) {{
|
||||||
|
document.cookie = 'nora_lang=' + lang + ';path=/;max-age=31536000';
|
||||||
|
window.location.reload();
|
||||||
|
}}
|
||||||
</script>
|
</script>
|
||||||
|
{}
|
||||||
</body>
|
</body>
|
||||||
</html>"##,
|
</html>"##,
|
||||||
|
lang.code(),
|
||||||
html_escape(title),
|
html_escape(title),
|
||||||
sidebar(active_page),
|
sidebar_dark(active_page, t),
|
||||||
header(),
|
header_dark(lang),
|
||||||
content
|
content,
|
||||||
|
extra_scripts
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sidebar navigation component
|
/// Dark theme sidebar
|
||||||
|
fn sidebar_dark(active_page: Option<&str>, t: &Translations) -> String {
|
||||||
|
let active = active_page.unwrap_or("");
|
||||||
|
|
||||||
|
let docker_icon = r#"<path fill="currentColor" d="M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.186m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.185-.186h-2.12a.186.186 0 00-.185.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 00-.75.748 11.376 11.376 0 00.692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 003.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288Z"/>"#;
|
||||||
|
let maven_icon = r#"<path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/>"#;
|
||||||
|
let npm_icon = r#"<path fill="currentColor" d="M0 7.334v8h6.666v1.332H12v-1.332h12v-8H0zm6.666 6.664H5.334v-4H3.999v4H1.335V8.667h5.331v5.331zm4 0v1.336H8.001V8.667h5.334v5.332h-2.669v-.001zm12.001 0h-1.33v-4h-1.336v4h-1.335v-4h-1.33v4h-2.671V8.667h8.002v5.331zM10.665 10H12v2.667h-1.335V10z"/>"#;
|
||||||
|
let cargo_icon = r#"<path fill="currentColor" d="M20 8h-3V4H3c-1.1 0-2 .9-2 2v11h2c0 1.66 1.34 3 3 3s3-1.34 3-3h6c0 1.66 1.34 3 3 3s3-1.34 3-3h2v-5l-3-4zM6 18.5c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm13.5-9l1.96 2.5H17V9.5h2.5zm-1.5 9c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5z"/>"#;
|
||||||
|
let pypi_icon = r#"<path fill="currentColor" d="M14.25.18l.9.2.73.26.59.3.45.32.34.34.25.34.16.33.1.3.04.26.02.2-.01.13V8.5l-.05.63-.13.55-.21.46-.26.38-.3.31-.33.25-.35.19-.35.14-.33.1-.3.07-.26.04-.21.02H8.83l-.69.05-.59.14-.5.22-.41.27-.33.32-.27.35-.2.36-.15.37-.1.35-.07.32-.04.27-.02.21v3.06H3.23l-.21-.03-.28-.07-.32-.12-.35-.18-.36-.26-.36-.36-.35-.46-.32-.59-.28-.73-.21-.88-.14-1.05L0 11.97l.06-1.22.16-1.04.24-.87.32-.71.36-.57.4-.44.42-.33.42-.24.4-.16.36-.1.32-.05.24-.01h.16l.06.01h8.16v-.83H6.24l-.01-2.75-.02-.37.05-.34.11-.31.17-.28.25-.26.31-.23.38-.2.44-.18.51-.15.58-.12.64-.1.71-.06.77-.04.84-.02 1.27.05 1.07.13zm-6.3 1.98l-.23.33-.08.41.08.41.23.34.33.22.41.09.41-.09.33-.22.23-.34.08-.41-.08-.41-.23-.33-.33-.22-.41-.09-.41.09-.33.22zM21.1 6.11l.28.06.32.12.35.18.36.27.36.35.35.47.32.59.28.73.21.88.14 1.04.05 1.23-.06 1.23-.16 1.04-.24.86-.32.71-.36.57-.4.45-.42.33-.42.24-.4.16-.36.09-.32.05-.24.02-.16-.01h-8.22v.82h5.84l.01 2.76.02.36-.05.34-.11.31-.17.29-.25.25-.31.24-.38.2-.44.17-.51.15-.58.13-.64.09-.71.07-.77.04-.84.01-1.27-.04-1.07-.14-.9-.2-.73-.25-.59-.3-.45-.33-.34-.34-.25-.34-.16-.33-.1-.3-.04-.25-.02-.2.01-.13v-5.34l.05-.64.13-.54.21-.46.26-.38.3-.32.33-.24.35-.2.35-.14.33-.1.3-.06.26-.04.21-.02.13-.01h5.84l.69-.05.59-.14.5-.21.41-.28.33-.32.27-.35.2-.36.15-.36.1-.35.07-.32.04-.28.02-.21V6.07h2.09l.14.01.21.03zm-6.47 14.25l-.23.33-.08.41.08.41.23.33.33.23.41.08.41-.08.33-.23.23-.33.08-.41-.08-.41-.23-.33-.33-.23-.41-.08-.41.08-.33.23z"/>"#;
|
||||||
|
|
||||||
|
// Dashboard label is translated, registry names stay as-is
|
||||||
|
let dashboard_label = t.nav_dashboard;
|
||||||
|
|
||||||
|
let nav_items = [
|
||||||
|
(
|
||||||
|
"dashboard",
|
||||||
|
"/ui/",
|
||||||
|
dashboard_label,
|
||||||
|
r#"<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/>"#,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
("docker", "/ui/docker", "Docker", docker_icon, false),
|
||||||
|
("maven", "/ui/maven", "Maven", maven_icon, false),
|
||||||
|
("npm", "/ui/npm", "npm", npm_icon, false),
|
||||||
|
("cargo", "/ui/cargo", "Cargo", cargo_icon, false),
|
||||||
|
("pypi", "/ui/pypi", "PyPI", pypi_icon, false),
|
||||||
|
];
|
||||||
|
|
||||||
|
let nav_html: String = nav_items.iter().map(|(id, href, label, icon_path, is_stroke)| {
|
||||||
|
let is_active = active == *id;
|
||||||
|
let active_class = if is_active {
|
||||||
|
"bg-slate-700 text-white"
|
||||||
|
} else {
|
||||||
|
"text-slate-300 hover:bg-slate-700 hover:text-white"
|
||||||
|
};
|
||||||
|
|
||||||
|
let (fill_attr, stroke_attr) = if *is_stroke {
|
||||||
|
("none", r#" stroke="currentColor""#)
|
||||||
|
} else {
|
||||||
|
("currentColor", "")
|
||||||
|
};
|
||||||
|
|
||||||
|
format!(r##"
|
||||||
|
<a href="{}" class="flex items-center px-4 py-3 text-sm font-medium rounded-lg transition-colors {}">
|
||||||
|
<svg class="w-5 h-5 mr-3" fill="{}"{} viewBox="0 0 24 24">
|
||||||
|
{}
|
||||||
|
</svg>
|
||||||
|
{}
|
||||||
|
</a>
|
||||||
|
"##, href, active_class, fill_attr, stroke_attr, icon_path, label)
|
||||||
|
}).collect();
|
||||||
|
|
||||||
|
format!(
|
||||||
|
r#"
|
||||||
|
<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>
|
||||||
|
</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">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<nav class="flex-1 px-4 py-6 space-y-1 overflow-y-auto">
|
||||||
|
{}
|
||||||
|
<div class="text-xs font-semibold text-slate-400 uppercase tracking-wider px-4 mt-6 mb-3">
|
||||||
|
{}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<div class="px-4 py-4 border-t border-slate-700">
|
||||||
|
<div class="text-xs text-slate-400">
|
||||||
|
Nora v{}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"#,
|
||||||
|
nav_html, t.nav_registries, VERSION
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dark theme header with language switcher
|
||||||
|
fn header_dark(lang: Lang) -> String {
|
||||||
|
let (en_class, ru_class) = match lang {
|
||||||
|
Lang::En => (
|
||||||
|
"text-white font-semibold",
|
||||||
|
"text-slate-400 hover:text-slate-200",
|
||||||
|
),
|
||||||
|
Lang::Ru => (
|
||||||
|
"text-slate-400 hover:text-slate-200",
|
||||||
|
"text-white font-semibold",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
format!(
|
||||||
|
r##"
|
||||||
|
<header class="h-16 bg-[#1e293b] border-b border-slate-700 flex items-center justify-between px-4 md:px-6">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<button onclick="toggleSidebar()" class="md:hidden p-2 -ml-2 mr-2 rounded-lg hover:bg-slate-700">
|
||||||
|
<svg class="w-6 h-6 text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="md:hidden flex items-center">
|
||||||
|
<span class="font-bold text-slate-200 tracking-tight">N<span class="inline-block w-4 h-4 rounded-full border-2 border-current align-middle mx-px"></span>RA</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2 md:space-x-4">
|
||||||
|
<!-- Language switcher -->
|
||||||
|
<div class="flex items-center border border-slate-600 rounded-lg overflow-hidden text-sm">
|
||||||
|
<button onclick="setLang('en')" class="px-3 py-1.5 {} transition-colors">EN</button>
|
||||||
|
<span class="text-slate-600">|</span>
|
||||||
|
<button onclick="setLang('ru')" class="px-3 py-1.5 {} transition-colors">RU</button>
|
||||||
|
</div>
|
||||||
|
<a href="https://github.com/getnora-io/nora" target="_blank" class="p-2 text-slate-400 hover:text-slate-200 hover:bg-slate-700 rounded-lg">
|
||||||
|
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path fill-rule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<a href="/api-docs" class="p-2 text-slate-400 hover:text-slate-200 hover:bg-slate-700 rounded-lg" title="API Docs">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
"##,
|
||||||
|
en_class, ru_class
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render global stats row (5-column grid)
|
||||||
|
pub fn render_global_stats(
|
||||||
|
downloads: u64,
|
||||||
|
uploads: u64,
|
||||||
|
artifacts: u64,
|
||||||
|
cache_hit_percent: f64,
|
||||||
|
storage_bytes: u64,
|
||||||
|
lang: Lang,
|
||||||
|
) -> String {
|
||||||
|
let t = get_translations(lang);
|
||||||
|
format!(
|
||||||
|
r##"
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4 mb-6">
|
||||||
|
<div class="bg-[#1e293b] rounded-lg p-4 border border-slate-700">
|
||||||
|
<div class="text-slate-400 text-sm mb-1">{}</div>
|
||||||
|
<div id="stat-downloads" class="text-2xl font-bold text-slate-200">{}</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-[#1e293b] rounded-lg p-4 border border-slate-700">
|
||||||
|
<div class="text-slate-400 text-sm mb-1">{}</div>
|
||||||
|
<div id="stat-uploads" class="text-2xl font-bold text-slate-200">{}</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-[#1e293b] rounded-lg p-4 border border-slate-700">
|
||||||
|
<div class="text-slate-400 text-sm mb-1">{}</div>
|
||||||
|
<div id="stat-artifacts" class="text-2xl font-bold text-slate-200">{}</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-[#1e293b] rounded-lg p-4 border border-slate-700">
|
||||||
|
<div class="text-slate-400 text-sm mb-1">{}</div>
|
||||||
|
<div id="stat-cache-hit" class="text-2xl font-bold text-slate-200">{:.1}%</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-[#1e293b] rounded-lg p-4 border border-slate-700">
|
||||||
|
<div class="text-slate-400 text-sm mb-1">{}</div>
|
||||||
|
<div id="stat-storage" class="text-2xl font-bold text-slate-200">{}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"##,
|
||||||
|
t.stat_downloads,
|
||||||
|
downloads,
|
||||||
|
t.stat_uploads,
|
||||||
|
uploads,
|
||||||
|
t.stat_artifacts,
|
||||||
|
artifacts,
|
||||||
|
t.stat_cache_hit,
|
||||||
|
cache_hit_percent,
|
||||||
|
t.stat_storage,
|
||||||
|
format_size(storage_bytes)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render registry card with extended metrics
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn render_registry_card(
|
||||||
|
name: &str,
|
||||||
|
icon_path: &str,
|
||||||
|
artifact_count: usize,
|
||||||
|
downloads: u64,
|
||||||
|
uploads: u64,
|
||||||
|
size_bytes: u64,
|
||||||
|
href: &str,
|
||||||
|
t: &Translations,
|
||||||
|
) -> String {
|
||||||
|
format!(
|
||||||
|
r##"
|
||||||
|
<a href="{}" id="registry-{}" class="block bg-[#1e293b] rounded-lg border border-slate-700 p-4 md:p-6 hover:border-blue-400 transition-all">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<svg class="w-8 h-8 text-slate-400" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
{}
|
||||||
|
</svg>
|
||||||
|
<span class="text-xs font-medium text-green-400 bg-green-400/10 px-2 py-1 rounded-full">{}</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-lg font-semibold text-slate-200 mb-2">{}</div>
|
||||||
|
<div class="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<span class="text-slate-500">{}</span>
|
||||||
|
<div class="text-slate-300 font-medium">{}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-slate-500">{}</span>
|
||||||
|
<div class="text-slate-300 font-medium">{}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-slate-500">{}</span>
|
||||||
|
<div class="text-slate-300 font-medium">{}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-slate-500">{}</span>
|
||||||
|
<div class="text-slate-300 font-medium">{}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
"##,
|
||||||
|
href,
|
||||||
|
name.to_lowercase(),
|
||||||
|
icon_path,
|
||||||
|
t.active,
|
||||||
|
name,
|
||||||
|
t.artifacts,
|
||||||
|
artifact_count,
|
||||||
|
t.size,
|
||||||
|
format_size(size_bytes),
|
||||||
|
t.downloads,
|
||||||
|
downloads,
|
||||||
|
t.uploads,
|
||||||
|
uploads
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render mount points table
|
||||||
|
pub fn render_mount_points_table(
|
||||||
|
mount_points: &[(String, String, Option<String>)],
|
||||||
|
t: &Translations,
|
||||||
|
) -> String {
|
||||||
|
let rows: String = mount_points
|
||||||
|
.iter()
|
||||||
|
.map(|(registry, mount_path, proxy)| {
|
||||||
|
let proxy_display = proxy.as_deref().unwrap_or("-");
|
||||||
|
format!(
|
||||||
|
r##"
|
||||||
|
<tr class="border-b border-slate-700">
|
||||||
|
<td class="py-3 text-slate-300">{}</td>
|
||||||
|
<td class="py-3 font-mono text-blue-400">{}</td>
|
||||||
|
<td class="py-3 text-slate-400">{}</td>
|
||||||
|
</tr>
|
||||||
|
"##,
|
||||||
|
registry, mount_path, proxy_display
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
format!(
|
||||||
|
r##"
|
||||||
|
<div class="bg-[#1e293b] rounded-lg border border-slate-700 overflow-hidden">
|
||||||
|
<div class="px-4 py-3 border-b border-slate-700">
|
||||||
|
<h3 class="text-slate-200 font-semibold">{}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-auto max-h-80">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="sticky top-0 bg-slate-800">
|
||||||
|
<tr class="text-left text-xs text-slate-500 uppercase border-b border-slate-700">
|
||||||
|
<th class="px-4 py-2">{}</th>
|
||||||
|
<th class="px-4 py-2">{}</th>
|
||||||
|
<th class="px-4 py-2">{}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="px-4">
|
||||||
|
{}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"##,
|
||||||
|
t.mount_points, t.registry, t.mount_path, t.proxy_upstream, rows
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render a single activity log row
|
||||||
|
pub fn render_activity_row(
|
||||||
|
timestamp: &str,
|
||||||
|
action: &str,
|
||||||
|
artifact: &str,
|
||||||
|
registry: &str,
|
||||||
|
source: &str,
|
||||||
|
) -> String {
|
||||||
|
let action_color = match action {
|
||||||
|
"PULL" => "text-blue-400",
|
||||||
|
"PUSH" => "text-green-400",
|
||||||
|
"CACHE" => "text-yellow-400",
|
||||||
|
"PROXY" => "text-purple-400",
|
||||||
|
_ => "text-slate-400",
|
||||||
|
};
|
||||||
|
|
||||||
|
format!(
|
||||||
|
r##"
|
||||||
|
<tr class="border-b border-slate-700/50 text-sm">
|
||||||
|
<td class="py-2 text-slate-500">{}</td>
|
||||||
|
<td class="py-2 font-medium {}"><span class="px-2 py-0.5 bg-slate-700 rounded">{}</span></td>
|
||||||
|
<td class="py-2 text-slate-300 font-mono text-xs">{}</td>
|
||||||
|
<td class="py-2 text-slate-400">{}</td>
|
||||||
|
<td class="py-2 text-slate-500">{}</td>
|
||||||
|
</tr>
|
||||||
|
"##,
|
||||||
|
timestamp,
|
||||||
|
action_color,
|
||||||
|
action,
|
||||||
|
html_escape(artifact),
|
||||||
|
registry,
|
||||||
|
source
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the activity log container
|
||||||
|
pub fn render_activity_log(rows: &str, t: &Translations) -> String {
|
||||||
|
format!(
|
||||||
|
r##"
|
||||||
|
<div class="bg-[#1e293b] rounded-lg border border-slate-700 overflow-hidden">
|
||||||
|
<div class="px-4 py-3 border-b border-slate-700 flex items-center justify-between">
|
||||||
|
<h3 class="text-slate-200 font-semibold">{}</h3>
|
||||||
|
<span class="text-xs text-slate-500">{}</span>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-auto max-h-80">
|
||||||
|
<table class="w-full" id="activity-log">
|
||||||
|
<thead class="sticky top-0 bg-slate-800">
|
||||||
|
<tr class="text-left text-xs text-slate-500 uppercase border-b border-slate-700">
|
||||||
|
<th class="px-4 py-2">{}</th>
|
||||||
|
<th class="px-4 py-2">{}</th>
|
||||||
|
<th class="px-4 py-2">{}</th>
|
||||||
|
<th class="px-4 py-2">{}</th>
|
||||||
|
<th class="px-4 py-2">{}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="px-4">
|
||||||
|
{}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"##,
|
||||||
|
t.recent_activity,
|
||||||
|
t.last_n_events,
|
||||||
|
t.time,
|
||||||
|
t.action,
|
||||||
|
t.artifact,
|
||||||
|
t.registry,
|
||||||
|
t.source,
|
||||||
|
rows
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the polling script for auto-refresh
|
||||||
|
pub fn render_polling_script() -> String {
|
||||||
|
r##"
|
||||||
|
<script>
|
||||||
|
setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const data = await fetch('/api/ui/dashboard').then(r => r.json());
|
||||||
|
|
||||||
|
// Update global stats
|
||||||
|
document.getElementById('stat-downloads').textContent = data.global_stats.downloads;
|
||||||
|
document.getElementById('stat-uploads').textContent = data.global_stats.uploads;
|
||||||
|
document.getElementById('stat-artifacts').textContent = data.global_stats.artifacts;
|
||||||
|
document.getElementById('stat-cache-hit').textContent = data.global_stats.cache_hit_percent.toFixed(1) + '%';
|
||||||
|
|
||||||
|
// Format storage size
|
||||||
|
const bytes = data.global_stats.storage_bytes;
|
||||||
|
let sizeStr;
|
||||||
|
if (bytes >= 1073741824) sizeStr = (bytes / 1073741824).toFixed(1) + ' GB';
|
||||||
|
else if (bytes >= 1048576) sizeStr = (bytes / 1048576).toFixed(1) + ' MB';
|
||||||
|
else if (bytes >= 1024) sizeStr = (bytes / 1024).toFixed(1) + ' KB';
|
||||||
|
else sizeStr = bytes + ' B';
|
||||||
|
document.getElementById('stat-storage').textContent = sizeStr;
|
||||||
|
|
||||||
|
// Update uptime
|
||||||
|
const uptime = document.getElementById('uptime');
|
||||||
|
if (uptime) {
|
||||||
|
const secs = data.uptime_seconds;
|
||||||
|
const hours = Math.floor(secs / 3600);
|
||||||
|
const mins = Math.floor((secs % 3600) / 60);
|
||||||
|
uptime.textContent = hours + 'h ' + mins + 'm';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Dashboard poll failed:', e);
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
</script>
|
||||||
|
"##.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sidebar navigation component (light theme, unused)
|
||||||
|
#[allow(dead_code)]
|
||||||
fn sidebar(active_page: Option<&str>) -> String {
|
fn sidebar(active_page: Option<&str>) -> String {
|
||||||
let active = active_page.unwrap_or("");
|
let active = active_page.unwrap_or("");
|
||||||
|
|
||||||
@@ -68,7 +490,7 @@ fn sidebar(active_page: Option<&str>) -> String {
|
|||||||
let docker_icon = r#"<path fill="currentColor" d="M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.186m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.185-.186h-2.12a.186.186 0 00-.185.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 00-.75.748 11.376 11.376 0 00.692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 003.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288Z"/>"#;
|
let docker_icon = r#"<path fill="currentColor" d="M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.186m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.185-.186h-2.12a.186.186 0 00-.185.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 00-.75.748 11.376 11.376 0 00.692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 003.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288Z"/>"#;
|
||||||
let maven_icon = r#"<path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/>"#;
|
let maven_icon = r#"<path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/>"#;
|
||||||
let npm_icon = r#"<path fill="currentColor" d="M0 7.334v8h6.666v1.332H12v-1.332h12v-8H0zm6.666 6.664H5.334v-4H3.999v4H1.335V8.667h5.331v5.331zm4 0v1.336H8.001V8.667h5.334v5.332h-2.669v-.001zm12.001 0h-1.33v-4h-1.336v4h-1.335v-4h-1.33v4h-2.671V8.667h8.002v5.331zM10.665 10H12v2.667h-1.335V10z"/>"#;
|
let npm_icon = r#"<path fill="currentColor" d="M0 7.334v8h6.666v1.332H12v-1.332h12v-8H0zm6.666 6.664H5.334v-4H3.999v4H1.335V8.667h5.331v5.331zm4 0v1.336H8.001V8.667h5.334v5.332h-2.669v-.001zm12.001 0h-1.33v-4h-1.336v4h-1.335v-4h-1.33v4h-2.671V8.667h8.002v5.331zM10.665 10H12v2.667h-1.335V10z"/>"#;
|
||||||
let cargo_icon = r#"<path fill="currentColor" d="M23.834 8.101a13.912 13.912 0 0 1-13.643 11.72 10.105 10.105 0 0 1-1.994-.12 6.111 6.111 0 0 1-5.082-5.761 5.934 5.934 0 0 1 11.867-.084c.025.983-.401 1.846-1.277 1.871-.936 0-1.374-.668-1.374-1.567v-2.5a1.531 1.531 0 0 0-1.52-1.533H8.715a3.648 3.648 0 1 0 2.695 6.08l.073-.11.074.121a2.58 2.58 0 0 0 2.2 1.048 2.909 2.909 0 0 0 2.695-3.04 7.912 7.912 0 0 0-.217-1.933 7.404 7.404 0 0 0-14.64 1.603 7.497 7.497 0 0 0 7.308 7.405 12.822 12.822 0 0 0 2.14-.12 11.927 11.927 0 0 0 9.98-10.023.117.117 0 0 0-.043-.117.115.115 0 0 0-.084-.023l-.09.024a.116.116 0 0 1-.147-.085.116.116 0 0 1 .054-.133zm-14.49 7.072a2.162 2.162 0 1 1 0-4.324 2.162 2.162 0 0 1 0 4.324z"/>"#;
|
let cargo_icon = r#"<path fill="currentColor" d="M20 8h-3V4H3c-1.1 0-2 .9-2 2v11h2c0 1.66 1.34 3 3 3s3-1.34 3-3h6c0 1.66 1.34 3 3 3s3-1.34 3-3h2v-5l-3-4zM6 18.5c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm13.5-9l1.96 2.5H17V9.5h2.5zm-1.5 9c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5z"/>"#;
|
||||||
let pypi_icon = r#"<path fill="currentColor" d="M14.25.18l.9.2.73.26.59.3.45.32.34.34.25.34.16.33.1.3.04.26.02.2-.01.13V8.5l-.05.63-.13.55-.21.46-.26.38-.3.31-.33.25-.35.19-.35.14-.33.1-.3.07-.26.04-.21.02H8.83l-.69.05-.59.14-.5.22-.41.27-.33.32-.27.35-.2.36-.15.37-.1.35-.07.32-.04.27-.02.21v3.06H3.23l-.21-.03-.28-.07-.32-.12-.35-.18-.36-.26-.36-.36-.35-.46-.32-.59-.28-.73-.21-.88-.14-1.05L0 11.97l.06-1.22.16-1.04.24-.87.32-.71.36-.57.4-.44.42-.33.42-.24.4-.16.36-.1.32-.05.24-.01h.16l.06.01h8.16v-.83H6.24l-.01-2.75-.02-.37.05-.34.11-.31.17-.28.25-.26.31-.23.38-.2.44-.18.51-.15.58-.12.64-.1.71-.06.77-.04.84-.02 1.27.05 1.07.13zm-6.3 1.98l-.23.33-.08.41.08.41.23.34.33.22.41.09.41-.09.33-.22.23-.34.08-.41-.08-.41-.23-.33-.33-.22-.41-.09-.41.09-.33.22zM21.1 6.11l.28.06.32.12.35.18.36.27.36.35.35.47.32.59.28.73.21.88.14 1.04.05 1.23-.06 1.23-.16 1.04-.24.86-.32.71-.36.57-.4.45-.42.33-.42.24-.4.16-.36.09-.32.05-.24.02-.16-.01h-8.22v.82h5.84l.01 2.76.02.36-.05.34-.11.31-.17.29-.25.25-.31.24-.38.2-.44.17-.51.15-.58.13-.64.09-.71.07-.77.04-.84.01-1.27-.04-1.07-.14-.9-.2-.73-.25-.59-.3-.45-.33-.34-.34-.25-.34-.16-.33-.1-.3-.04-.25-.02-.2.01-.13v-5.34l.05-.64.13-.54.21-.46.26-.38.3-.32.33-.24.35-.2.35-.14.33-.1.3-.06.26-.04.21-.02.13-.01h5.84l.69-.05.59-.14.5-.21.41-.28.33-.32.27-.35.2-.36.15-.36.1-.35.07-.32.04-.28.02-.21V6.07h2.09l.14.01.21.03zm-6.47 14.25l-.23.33-.08.41.08.41.23.33.33.23.41.08.41-.08.33-.23.23-.33.08-.41-.08-.41-.23-.33-.33-.23-.41-.08-.41.08-.33.23z"/>"#;
|
let pypi_icon = r#"<path fill="currentColor" d="M14.25.18l.9.2.73.26.59.3.45.32.34.34.25.34.16.33.1.3.04.26.02.2-.01.13V8.5l-.05.63-.13.55-.21.46-.26.38-.3.31-.33.25-.35.19-.35.14-.33.1-.3.07-.26.04-.21.02H8.83l-.69.05-.59.14-.5.22-.41.27-.33.32-.27.35-.2.36-.15.37-.1.35-.07.32-.04.27-.02.21v3.06H3.23l-.21-.03-.28-.07-.32-.12-.35-.18-.36-.26-.36-.36-.35-.46-.32-.59-.28-.73-.21-.88-.14-1.05L0 11.97l.06-1.22.16-1.04.24-.87.32-.71.36-.57.4-.44.42-.33.42-.24.4-.16.36-.1.32-.05.24-.01h.16l.06.01h8.16v-.83H6.24l-.01-2.75-.02-.37.05-.34.11-.31.17-.28.25-.26.31-.23.38-.2.44-.18.51-.15.58-.12.64-.1.71-.06.77-.04.84-.02 1.27.05 1.07.13zm-6.3 1.98l-.23.33-.08.41.08.41.23.34.33.22.41.09.41-.09.33-.22.23-.34.08-.41-.08-.41-.23-.33-.33-.22-.41-.09-.41.09-.33.22zM21.1 6.11l.28.06.32.12.35.18.36.27.36.35.35.47.32.59.28.73.21.88.14 1.04.05 1.23-.06 1.23-.16 1.04-.24.86-.32.71-.36.57-.4.45-.42.33-.42.24-.4.16-.36.09-.32.05-.24.02-.16-.01h-8.22v.82h5.84l.01 2.76.02.36-.05.34-.11.31-.17.29-.25.25-.31.24-.38.2-.44.17-.51.15-.58.13-.64.09-.71.07-.77.04-.84.01-1.27-.04-1.07-.14-.9-.2-.73-.25-.59-.3-.45-.33-.34-.34-.25-.34-.16-.33-.1-.3-.04-.25-.02-.2.01-.13v-5.34l.05-.64.13-.54.21-.46.26-.38.3-.32.33-.24.35-.2.35-.14.33-.1.3-.06.26-.04.21-.02.13-.01h5.84l.69-.05.59-.14.5-.21.41-.28.33-.32.27-.35.2-.36.15-.36.1-.35.07-.32.04-.28.02-.21V6.07h2.09l.14.01.21.03zm-6.47 14.25l-.23.33-.08.41.08.41.23.33.33.23.41.08.41-.08.33-.23.23-.33.08-.41-.08-.41-.23-.33-.33-.23-.41-.08-.41.08-.33.23z"/>"#;
|
||||||
|
|
||||||
let nav_items = [
|
let nav_items = [
|
||||||
@@ -142,17 +564,19 @@ fn sidebar(active_page: Option<&str>) -> String {
|
|||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<div class="px-4 py-4 border-t border-slate-700">
|
<div class="px-4 py-4 border-t border-slate-700">
|
||||||
<div class="text-xs text-slate-400">
|
<div class="text-xs text-slate-400">
|
||||||
Nora v0.2.0
|
Nora v{}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
"#,
|
"#,
|
||||||
super::logo::LOGO_BASE64,
|
super::logo::LOGO_BASE64,
|
||||||
nav_html
|
nav_html,
|
||||||
|
VERSION
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Header component
|
/// Header component (light theme, unused)
|
||||||
|
#[allow(dead_code)]
|
||||||
fn header() -> String {
|
fn header() -> String {
|
||||||
r##"
|
r##"
|
||||||
<header class="h-16 bg-white border-b border-slate-200 flex items-center justify-between px-4 md:px-6">
|
<header class="h-16 bg-white border-b border-slate-200 flex items-center justify-between px-4 md:px-6">
|
||||||
@@ -189,11 +613,12 @@ pub mod icons {
|
|||||||
pub const DOCKER: &str = r#"<path fill="currentColor" d="M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.186m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.185-.186h-2.12a.186.186 0 00-.185.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 00-.75.748 11.376 11.376 0 00.692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 003.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288Z"/>"#;
|
pub const DOCKER: &str = r#"<path fill="currentColor" d="M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.186m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.185-.186h-2.12a.186.186 0 00-.185.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 00-.75.748 11.376 11.376 0 00.692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 003.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288Z"/>"#;
|
||||||
pub const MAVEN: &str = r#"<path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/>"#;
|
pub const MAVEN: &str = r#"<path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/>"#;
|
||||||
pub const NPM: &str = r#"<path fill="currentColor" d="M0 7.334v8h6.666v1.332H12v-1.332h12v-8H0zm6.666 6.664H5.334v-4H3.999v4H1.335V8.667h5.331v5.331zm4 0v1.336H8.001V8.667h5.334v5.332h-2.669v-.001zm12.001 0h-1.33v-4h-1.336v4h-1.335v-4h-1.33v4h-2.671V8.667h8.002v5.331zM10.665 10H12v2.667h-1.335V10z"/>"#;
|
pub const NPM: &str = r#"<path fill="currentColor" d="M0 7.334v8h6.666v1.332H12v-1.332h12v-8H0zm6.666 6.664H5.334v-4H3.999v4H1.335V8.667h5.331v5.331zm4 0v1.336H8.001V8.667h5.334v5.332h-2.669v-.001zm12.001 0h-1.33v-4h-1.336v4h-1.335v-4h-1.33v4h-2.671V8.667h8.002v5.331zM10.665 10H12v2.667h-1.335V10z"/>"#;
|
||||||
pub const CARGO: &str = r#"<path fill="currentColor" d="M23.834 8.101a13.912 13.912 0 0 1-13.643 11.72 10.105 10.105 0 0 1-1.994-.12 6.111 6.111 0 0 1-5.082-5.761 5.934 5.934 0 0 1 11.867-.084c.025.983-.401 1.846-1.277 1.871-.936 0-1.374-.668-1.374-1.567v-2.5a1.531 1.531 0 0 0-1.52-1.533H8.715a3.648 3.648 0 1 0 2.695 6.08l.073-.11.074.121a2.58 2.58 0 0 0 2.2 1.048 2.909 2.909 0 0 0 2.695-3.04 7.912 7.912 0 0 0-.217-1.933 7.404 7.404 0 0 0-14.64 1.603 7.497 7.497 0 0 0 7.308 7.405 12.822 12.822 0 0 0 2.14-.12 11.927 11.927 0 0 0 9.98-10.023.117.117 0 0 0-.043-.117.115.115 0 0 0-.084-.023l-.09.024a.116.116 0 0 1-.147-.085.116.116 0 0 1 .054-.133zm-14.49 7.072a2.162 2.162 0 1 1 0-4.324 2.162 2.162 0 0 1 0 4.324z"/>"#;
|
pub const CARGO: &str = r#"<path fill="currentColor" d="M20 8h-3V4H3c-1.1 0-2 .9-2 2v11h2c0 1.66 1.34 3 3 3s3-1.34 3-3h6c0 1.66 1.34 3 3 3s3-1.34 3-3h2v-5l-3-4zM6 18.5c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm13.5-9l1.96 2.5H17V9.5h2.5zm-1.5 9c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5z"/>"#;
|
||||||
pub const PYPI: &str = r#"<path fill="currentColor" d="M14.25.18l.9.2.73.26.59.3.45.32.34.34.25.34.16.33.1.3.04.26.02.2-.01.13V8.5l-.05.63-.13.55-.21.46-.26.38-.3.31-.33.25-.35.19-.35.14-.33.1-.3.07-.26.04-.21.02H8.83l-.69.05-.59.14-.5.22-.41.27-.33.32-.27.35-.2.36-.15.37-.1.35-.07.32-.04.27-.02.21v3.06H3.23l-.21-.03-.28-.07-.32-.12-.35-.18-.36-.26-.36-.36-.35-.46-.32-.59-.28-.73-.21-.88-.14-1.05L0 11.97l.06-1.22.16-1.04.24-.87.32-.71.36-.57.4-.44.42-.33.42-.24.4-.16.36-.1.32-.05.24-.01h.16l.06.01h8.16v-.83H6.24l-.01-2.75-.02-.37.05-.34.11-.31.17-.28.25-.26.31-.23.38-.2.44-.18.51-.15.58-.12.64-.1.71-.06.77-.04.84-.02 1.27.05 1.07.13zm-6.3 1.98l-.23.33-.08.41.08.41.23.34.33.22.41.09.41-.09.33-.22.23-.34.08-.41-.08-.41-.23-.33-.33-.22-.41-.09-.41.09-.33.22zM21.1 6.11l.28.06.32.12.35.18.36.27.36.35.35.47.32.59.28.73.21.88.14 1.04.05 1.23-.06 1.23-.16 1.04-.24.86-.32.71-.36.57-.4.45-.42.33-.42.24-.4.16-.36.09-.32.05-.24.02-.16-.01h-8.22v.82h5.84l.01 2.76.02.36-.05.34-.11.31-.17.29-.25.25-.31.24-.38.2-.44.17-.51.15-.58.13-.64.09-.71.07-.77.04-.84.01-1.27-.04-1.07-.14-.9-.2-.73-.25-.59-.3-.45-.33-.34-.34-.25-.34-.16-.33-.1-.3-.04-.25-.02-.2.01-.13v-5.34l.05-.64.13-.54.21-.46.26-.38.3-.32.33-.24.35-.2.35-.14.33-.1.3-.06.26-.04.21-.02.13-.01h5.84l.69-.05.59-.14.5-.21.41-.28.33-.32.27-.35.2-.36.15-.36.1-.35.07-.32.04-.28.02-.21V6.07h2.09l.14.01.21.03zm-6.47 14.25l-.23.33-.08.41.08.41.23.33.33.23.41.08.41-.08.33-.23.23-.33.08-.41-.08-.41-.23-.33-.33-.23-.41-.08-.41.08-.33.23z"/>"#;
|
pub const PYPI: &str = r#"<path fill="currentColor" d="M14.25.18l.9.2.73.26.59.3.45.32.34.34.25.34.16.33.1.3.04.26.02.2-.01.13V8.5l-.05.63-.13.55-.21.46-.26.38-.3.31-.33.25-.35.19-.35.14-.33.1-.3.07-.26.04-.21.02H8.83l-.69.05-.59.14-.5.22-.41.27-.33.32-.27.35-.2.36-.15.37-.1.35-.07.32-.04.27-.02.21v3.06H3.23l-.21-.03-.28-.07-.32-.12-.35-.18-.36-.26-.36-.36-.35-.46-.32-.59-.28-.73-.21-.88-.14-1.05L0 11.97l.06-1.22.16-1.04.24-.87.32-.71.36-.57.4-.44.42-.33.42-.24.4-.16.36-.1.32-.05.24-.01h.16l.06.01h8.16v-.83H6.24l-.01-2.75-.02-.37.05-.34.11-.31.17-.28.25-.26.31-.23.38-.2.44-.18.51-.15.58-.12.64-.1.71-.06.77-.04.84-.02 1.27.05 1.07.13zm-6.3 1.98l-.23.33-.08.41.08.41.23.34.33.22.41.09.41-.09.33-.22.23-.34.08-.41-.08-.41-.23-.33-.33-.22-.41-.09-.41.09-.33.22zM21.1 6.11l.28.06.32.12.35.18.36.27.36.35.35.47.32.59.28.73.21.88.14 1.04.05 1.23-.06 1.23-.16 1.04-.24.86-.32.71-.36.57-.4.45-.42.33-.42.24-.4.16-.36.09-.32.05-.24.02-.16-.01h-8.22v.82h5.84l.01 2.76.02.36-.05.34-.11.31-.17.29-.25.25-.31.24-.38.2-.44.17-.51.15-.58.13-.64.09-.71.07-.77.04-.84.01-1.27-.04-1.07-.14-.9-.2-.73-.25-.59-.3-.45-.33-.34-.34-.25-.34-.16-.33-.1-.3-.04-.25-.02-.2.01-.13v-5.34l.05-.64.13-.54.21-.46.26-.38.3-.32.33-.24.35-.2.35-.14.33-.1.3-.06.26-.04.21-.02.13-.01h5.84l.69-.05.59-.14.5-.21.41-.28.33-.32.27-.35.2-.36.15-.36.1-.35.07-.32.04-.28.02-.21V6.07h2.09l.14.01.21.03zm-6.47 14.25l-.23.33-.08.41.08.41.23.33.33.23.41.08.41-.08.33-.23.23-.33.08-.41-.08-.41-.23-.33-.33-.23-.41-.08-.41.08-.33.23z"/>"#;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stat card for dashboard with SVG icon
|
/// Stat card for dashboard with SVG icon (used in light theme pages)
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn stat_card(name: &str, icon_path: &str, count: usize, href: &str, unit: &str) -> String {
|
pub fn stat_card(name: &str, icon_path: &str, count: usize, href: &str, unit: &str) -> String {
|
||||||
format!(
|
format!(
|
||||||
r##"
|
r##"
|
||||||
@@ -239,6 +664,57 @@ pub fn html_escape(s: &str) -> String {
|
|||||||
.replace('\'', "'")
|
.replace('\'', "'")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Render the "bragging" footer with NORA stats
|
||||||
|
pub fn render_bragging_footer(lang: Lang) -> String {
|
||||||
|
let t = get_translations(lang);
|
||||||
|
format!(
|
||||||
|
r##"
|
||||||
|
<div class="mt-8 bg-gradient-to-r from-slate-800 to-slate-900 rounded-lg border border-slate-700 p-6">
|
||||||
|
<div class="text-center mb-4">
|
||||||
|
<span class="text-slate-400 text-sm uppercase tracking-wider">{}</span>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4 text-center">
|
||||||
|
<div class="p-3">
|
||||||
|
<div class="text-2xl font-bold text-blue-400">34 MB</div>
|
||||||
|
<div class="text-xs text-slate-500 mt-1">{}</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-3">
|
||||||
|
<div class="text-2xl font-bold text-green-400"><1s</div>
|
||||||
|
<div class="text-xs text-slate-500 mt-1">{}</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-3">
|
||||||
|
<div class="text-2xl font-bold text-purple-400">~30 MB</div>
|
||||||
|
<div class="text-xs text-slate-500 mt-1">{}</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-3">
|
||||||
|
<div class="text-2xl font-bold text-yellow-400">5</div>
|
||||||
|
<div class="text-xs text-slate-500 mt-1">{}</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-3">
|
||||||
|
<div class="text-2xl font-bold text-pink-400">{}</div>
|
||||||
|
<div class="text-xs text-slate-500 mt-1">amd64 / arm64</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-3">
|
||||||
|
<div class="text-2xl font-bold text-cyan-400">{}</div>
|
||||||
|
<div class="text-xs text-slate-500 mt-1">Config</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-center mt-4">
|
||||||
|
<span class="text-slate-500 text-xs">{}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"##,
|
||||||
|
t.built_for_speed,
|
||||||
|
t.docker_image,
|
||||||
|
t.cold_start,
|
||||||
|
t.memory,
|
||||||
|
t.registries_count,
|
||||||
|
t.multi_arch,
|
||||||
|
t.zero_config,
|
||||||
|
t.tagline
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Format Unix timestamp as relative time
|
/// Format Unix timestamp as relative time
|
||||||
pub fn format_timestamp(ts: u64) -> String {
|
pub fn format_timestamp(ts: u64) -> String {
|
||||||
if ts == 0 {
|
if ts == 0 {
|
||||||
|
|||||||
275
nora-registry/src/ui/i18n.rs
Normal file
275
nora-registry/src/ui/i18n.rs
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
/// Internationalization support for the UI
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum Lang {
|
||||||
|
#[default]
|
||||||
|
En,
|
||||||
|
Ru,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Lang {
|
||||||
|
pub fn from_str(s: &str) -> Self {
|
||||||
|
match s.to_lowercase().as_str() {
|
||||||
|
"ru" | "rus" | "russian" => Lang::Ru,
|
||||||
|
_ => Lang::En,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn code(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Lang::En => "en",
|
||||||
|
Lang::Ru => "ru",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All translatable strings
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct Translations {
|
||||||
|
// Navigation
|
||||||
|
pub nav_dashboard: &'static str,
|
||||||
|
pub nav_registries: &'static str,
|
||||||
|
|
||||||
|
// Dashboard
|
||||||
|
pub dashboard_title: &'static str,
|
||||||
|
pub dashboard_subtitle: &'static str,
|
||||||
|
pub uptime: &'static str,
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
pub stat_downloads: &'static str,
|
||||||
|
pub stat_uploads: &'static str,
|
||||||
|
pub stat_artifacts: &'static str,
|
||||||
|
pub stat_cache_hit: &'static str,
|
||||||
|
pub stat_storage: &'static str,
|
||||||
|
|
||||||
|
// Registry cards
|
||||||
|
pub active: &'static str,
|
||||||
|
pub artifacts: &'static str,
|
||||||
|
pub size: &'static str,
|
||||||
|
pub downloads: &'static str,
|
||||||
|
pub uploads: &'static str,
|
||||||
|
|
||||||
|
// Mount points
|
||||||
|
pub mount_points: &'static str,
|
||||||
|
pub registry: &'static str,
|
||||||
|
pub mount_path: &'static str,
|
||||||
|
pub proxy_upstream: &'static str,
|
||||||
|
|
||||||
|
// Activity
|
||||||
|
pub recent_activity: &'static str,
|
||||||
|
pub last_n_events: &'static str,
|
||||||
|
pub time: &'static str,
|
||||||
|
pub action: &'static str,
|
||||||
|
pub artifact: &'static str,
|
||||||
|
pub source: &'static str,
|
||||||
|
pub no_activity: &'static str,
|
||||||
|
|
||||||
|
// Relative time
|
||||||
|
pub just_now: &'static str,
|
||||||
|
pub min_ago: &'static str,
|
||||||
|
pub mins_ago: &'static str,
|
||||||
|
pub hour_ago: &'static str,
|
||||||
|
pub hours_ago: &'static str,
|
||||||
|
pub day_ago: &'static str,
|
||||||
|
pub days_ago: &'static str,
|
||||||
|
|
||||||
|
// Registry pages
|
||||||
|
pub repositories: &'static str,
|
||||||
|
pub search_placeholder: &'static str,
|
||||||
|
pub no_repos_found: &'static str,
|
||||||
|
pub push_first_artifact: &'static str,
|
||||||
|
pub name: &'static str,
|
||||||
|
pub tags: &'static str,
|
||||||
|
pub versions: &'static str,
|
||||||
|
pub updated: &'static str,
|
||||||
|
|
||||||
|
// Detail pages
|
||||||
|
pub pull_command: &'static str,
|
||||||
|
pub install_command: &'static str,
|
||||||
|
pub maven_dependency: &'static str,
|
||||||
|
pub total: &'static str,
|
||||||
|
pub created: &'static str,
|
||||||
|
pub published: &'static str,
|
||||||
|
pub filename: &'static str,
|
||||||
|
pub files: &'static str,
|
||||||
|
|
||||||
|
// Bragging footer
|
||||||
|
pub built_for_speed: &'static str,
|
||||||
|
pub docker_image: &'static str,
|
||||||
|
pub cold_start: &'static str,
|
||||||
|
pub memory: &'static str,
|
||||||
|
pub registries_count: &'static str,
|
||||||
|
pub multi_arch: &'static str,
|
||||||
|
pub zero_config: &'static str,
|
||||||
|
pub tagline: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_translations(lang: Lang) -> &'static Translations {
|
||||||
|
match lang {
|
||||||
|
Lang::En => &TRANSLATIONS_EN,
|
||||||
|
Lang::Ru => &TRANSLATIONS_RU,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub static TRANSLATIONS_EN: Translations = Translations {
|
||||||
|
// Navigation
|
||||||
|
nav_dashboard: "Dashboard",
|
||||||
|
nav_registries: "Registries",
|
||||||
|
|
||||||
|
// Dashboard
|
||||||
|
dashboard_title: "Dashboard",
|
||||||
|
dashboard_subtitle: "Overview of all registries",
|
||||||
|
uptime: "Uptime",
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
stat_downloads: "Downloads",
|
||||||
|
stat_uploads: "Uploads",
|
||||||
|
stat_artifacts: "Artifacts",
|
||||||
|
stat_cache_hit: "Cache Hit",
|
||||||
|
stat_storage: "Storage",
|
||||||
|
|
||||||
|
// Registry cards
|
||||||
|
active: "ACTIVE",
|
||||||
|
artifacts: "Artifacts",
|
||||||
|
size: "Size",
|
||||||
|
downloads: "Downloads",
|
||||||
|
uploads: "Uploads",
|
||||||
|
|
||||||
|
// Mount points
|
||||||
|
mount_points: "Mount Points",
|
||||||
|
registry: "Registry",
|
||||||
|
mount_path: "Mount Path",
|
||||||
|
proxy_upstream: "Proxy Upstream",
|
||||||
|
|
||||||
|
// Activity
|
||||||
|
recent_activity: "Recent Activity",
|
||||||
|
last_n_events: "Last 20 events",
|
||||||
|
time: "Time",
|
||||||
|
action: "Action",
|
||||||
|
artifact: "Artifact",
|
||||||
|
source: "Source",
|
||||||
|
no_activity: "No recent activity",
|
||||||
|
|
||||||
|
// Relative time
|
||||||
|
just_now: "just now",
|
||||||
|
min_ago: "min ago",
|
||||||
|
mins_ago: "mins ago",
|
||||||
|
hour_ago: "hour ago",
|
||||||
|
hours_ago: "hours ago",
|
||||||
|
day_ago: "day ago",
|
||||||
|
days_ago: "days ago",
|
||||||
|
|
||||||
|
// Registry pages
|
||||||
|
repositories: "repositories",
|
||||||
|
search_placeholder: "Search repositories...",
|
||||||
|
no_repos_found: "No repositories found",
|
||||||
|
push_first_artifact: "Push your first artifact to see it here",
|
||||||
|
name: "Name",
|
||||||
|
tags: "Tags",
|
||||||
|
versions: "Versions",
|
||||||
|
updated: "Updated",
|
||||||
|
|
||||||
|
// Detail pages
|
||||||
|
pull_command: "Pull Command",
|
||||||
|
install_command: "Install Command",
|
||||||
|
maven_dependency: "Maven Dependency",
|
||||||
|
total: "total",
|
||||||
|
created: "Created",
|
||||||
|
published: "Published",
|
||||||
|
filename: "Filename",
|
||||||
|
files: "files",
|
||||||
|
|
||||||
|
// Bragging footer
|
||||||
|
built_for_speed: "Built for speed",
|
||||||
|
docker_image: "Docker Image",
|
||||||
|
cold_start: "Cold Start",
|
||||||
|
memory: "Memory",
|
||||||
|
registries_count: "Registries",
|
||||||
|
multi_arch: "Multi-arch",
|
||||||
|
zero_config: "Zero",
|
||||||
|
tagline: "Pure Rust. Single binary. OCI compatible.",
|
||||||
|
};
|
||||||
|
|
||||||
|
pub static TRANSLATIONS_RU: Translations = Translations {
|
||||||
|
// Navigation
|
||||||
|
nav_dashboard: "Панель",
|
||||||
|
nav_registries: "Реестры",
|
||||||
|
|
||||||
|
// Dashboard
|
||||||
|
dashboard_title: "Панель управления",
|
||||||
|
dashboard_subtitle: "Обзор всех реестров",
|
||||||
|
uptime: "Аптайм",
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
stat_downloads: "Загрузки",
|
||||||
|
stat_uploads: "Публикации",
|
||||||
|
stat_artifacts: "Артефакты",
|
||||||
|
stat_cache_hit: "Кэш",
|
||||||
|
stat_storage: "Хранилище",
|
||||||
|
|
||||||
|
// Registry cards
|
||||||
|
active: "АКТИВЕН",
|
||||||
|
artifacts: "Артефакты",
|
||||||
|
size: "Размер",
|
||||||
|
downloads: "Загрузки",
|
||||||
|
uploads: "Публикации",
|
||||||
|
|
||||||
|
// Mount points
|
||||||
|
mount_points: "Точки монтирования",
|
||||||
|
registry: "Реестр",
|
||||||
|
mount_path: "Путь",
|
||||||
|
proxy_upstream: "Прокси",
|
||||||
|
|
||||||
|
// Activity
|
||||||
|
recent_activity: "Последняя активность",
|
||||||
|
last_n_events: "Последние 20 событий",
|
||||||
|
time: "Время",
|
||||||
|
action: "Действие",
|
||||||
|
artifact: "Артефакт",
|
||||||
|
source: "Источник",
|
||||||
|
no_activity: "Нет активности",
|
||||||
|
|
||||||
|
// Relative time
|
||||||
|
just_now: "только что",
|
||||||
|
min_ago: "мин назад",
|
||||||
|
mins_ago: "мин назад",
|
||||||
|
hour_ago: "час назад",
|
||||||
|
hours_ago: "ч назад",
|
||||||
|
day_ago: "день назад",
|
||||||
|
days_ago: "дн назад",
|
||||||
|
|
||||||
|
// Registry pages
|
||||||
|
repositories: "репозиториев",
|
||||||
|
search_placeholder: "Поиск репозиториев...",
|
||||||
|
no_repos_found: "Репозитории не найдены",
|
||||||
|
push_first_artifact: "Загрузите первый артефакт, чтобы увидеть его здесь",
|
||||||
|
name: "Название",
|
||||||
|
tags: "Теги",
|
||||||
|
versions: "Версии",
|
||||||
|
updated: "Обновлено",
|
||||||
|
|
||||||
|
// Detail pages
|
||||||
|
pull_command: "Команда загрузки",
|
||||||
|
install_command: "Команда установки",
|
||||||
|
maven_dependency: "Maven зависимость",
|
||||||
|
total: "всего",
|
||||||
|
created: "Создан",
|
||||||
|
published: "Опубликован",
|
||||||
|
filename: "Файл",
|
||||||
|
files: "файлов",
|
||||||
|
|
||||||
|
// Bragging footer
|
||||||
|
built_for_speed: "Создан для скорости",
|
||||||
|
docker_image: "Docker образ",
|
||||||
|
cold_start: "Холодный старт",
|
||||||
|
memory: "Память",
|
||||||
|
registries_count: "Реестров",
|
||||||
|
multi_arch: "Мульти-арх",
|
||||||
|
zero_config: "Без",
|
||||||
|
tagline: "Чистый Rust. Один бинарник. OCI совместимый.",
|
||||||
|
};
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,11 +1,16 @@
|
|||||||
|
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
mod api;
|
mod api;
|
||||||
mod components;
|
pub mod components;
|
||||||
|
pub mod i18n;
|
||||||
mod logo;
|
mod logo;
|
||||||
mod templates;
|
mod templates;
|
||||||
|
|
||||||
|
use crate::repo_index::paginate;
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, Query, State},
|
||||||
response::{Html, IntoResponse, Redirect},
|
response::{Html, IntoResponse, Redirect},
|
||||||
routing::get,
|
routing::get,
|
||||||
Router,
|
Router,
|
||||||
@@ -13,8 +18,59 @@ use axum::{
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use api::*;
|
use api::*;
|
||||||
|
use i18n::Lang;
|
||||||
use templates::*;
|
use templates::*;
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
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 {
|
||||||
|
return Lang::from_str(lang);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try cookie
|
||||||
|
if let Some(cookies) = cookie_header {
|
||||||
|
for part in cookies.split(';') {
|
||||||
|
let part = part.trim();
|
||||||
|
if let Some(value) = part.strip_prefix("nora_lang=") {
|
||||||
|
return Lang::from_str(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Lang::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
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>> {
|
pub fn routes() -> Router<Arc<AppState>> {
|
||||||
Router::new()
|
Router::new()
|
||||||
// UI Pages
|
// UI Pages
|
||||||
@@ -33,83 +89,212 @@ pub fn routes() -> Router<Arc<AppState>> {
|
|||||||
.route("/ui/pypi/{name}", get(pypi_detail))
|
.route("/ui/pypi/{name}", get(pypi_detail))
|
||||||
// API endpoints for HTMX
|
// API endpoints for HTMX
|
||||||
.route("/api/ui/stats", get(api_stats))
|
.route("/api/ui/stats", get(api_stats))
|
||||||
|
.route("/api/ui/dashboard", get(api_dashboard))
|
||||||
.route("/api/ui/{registry_type}/list", get(api_list))
|
.route("/api/ui/{registry_type}/list", get(api_list))
|
||||||
.route("/api/ui/{registry_type}/{name}", get(api_detail))
|
.route("/api/ui/{registry_type}/{name}", get(api_detail))
|
||||||
.route("/api/ui/{registry_type}/search", get(api_search))
|
.route("/api/ui/{registry_type}/search", get(api_search))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dashboard page
|
// Dashboard page
|
||||||
async fn dashboard(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
async fn dashboard(
|
||||||
let stats = get_registry_stats(&state.storage).await;
|
State(state): State<Arc<AppState>>,
|
||||||
Html(render_dashboard(&stats))
|
Query(query): Query<LangQuery>,
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let lang = extract_lang(
|
||||||
|
&Query(query),
|
||||||
|
headers.get("cookie").and_then(|v| v.to_str().ok()),
|
||||||
|
);
|
||||||
|
let response = api_dashboard(State(state)).await.0;
|
||||||
|
Html(render_dashboard(&response, lang))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Docker pages
|
// Docker pages
|
||||||
async fn docker_list(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
async fn docker_list(
|
||||||
let repos = get_docker_repos(&state.storage).await;
|
State(state): State<Arc<AppState>>,
|
||||||
Html(render_registry_list("docker", "Docker Registry", &repos))
|
Query(query): Query<ListQuery>,
|
||||||
|
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(
|
||||||
|
"docker",
|
||||||
|
"Docker Registry",
|
||||||
|
&repos,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
lang,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn docker_detail(
|
async fn docker_detail(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Path(name): Path<String>,
|
Path(name): Path<String>,
|
||||||
|
Query(query): Query<LangQuery>,
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let detail = get_docker_detail(&state.storage, &name).await;
|
let lang = extract_lang(
|
||||||
Html(render_docker_detail(&name, &detail))
|
&Query(query),
|
||||||
|
headers.get("cookie").and_then(|v| v.to_str().ok()),
|
||||||
|
);
|
||||||
|
let detail = get_docker_detail(&state, &name).await;
|
||||||
|
Html(render_docker_detail(&name, &detail, lang))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Maven pages
|
// Maven pages
|
||||||
async fn maven_list(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
async fn maven_list(
|
||||||
let repos = get_maven_repos(&state.storage).await;
|
State(state): State<Arc<AppState>>,
|
||||||
Html(render_registry_list("maven", "Maven Repository", &repos))
|
Query(query): Query<ListQuery>,
|
||||||
|
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(
|
||||||
|
"maven",
|
||||||
|
"Maven Repository",
|
||||||
|
&repos,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
lang,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn maven_detail(
|
async fn maven_detail(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Path(path): Path<String>,
|
Path(path): Path<String>,
|
||||||
|
Query(query): Query<LangQuery>,
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
let lang = extract_lang(
|
||||||
|
&Query(query),
|
||||||
|
headers.get("cookie").and_then(|v| v.to_str().ok()),
|
||||||
|
);
|
||||||
let detail = get_maven_detail(&state.storage, &path).await;
|
let detail = get_maven_detail(&state.storage, &path).await;
|
||||||
Html(render_maven_detail(&path, &detail))
|
Html(render_maven_detail(&path, &detail, lang))
|
||||||
}
|
}
|
||||||
|
|
||||||
// npm pages
|
// npm pages
|
||||||
async fn npm_list(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
async fn npm_list(
|
||||||
let packages = get_npm_packages(&state.storage).await;
|
State(state): State<Arc<AppState>>,
|
||||||
Html(render_registry_list("npm", "npm Registry", &packages))
|
Query(query): Query<ListQuery>,
|
||||||
|
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,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn npm_detail(
|
async fn npm_detail(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Path(name): Path<String>,
|
Path(name): Path<String>,
|
||||||
|
Query(query): Query<LangQuery>,
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
let lang = extract_lang(
|
||||||
|
&Query(query),
|
||||||
|
headers.get("cookie").and_then(|v| v.to_str().ok()),
|
||||||
|
);
|
||||||
let detail = get_npm_detail(&state.storage, &name).await;
|
let detail = get_npm_detail(&state.storage, &name).await;
|
||||||
Html(render_package_detail("npm", &name, &detail))
|
Html(render_package_detail("npm", &name, &detail, lang))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cargo pages
|
// Cargo pages
|
||||||
async fn cargo_list(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
async fn cargo_list(
|
||||||
let crates = get_cargo_crates(&state.storage).await;
|
State(state): State<Arc<AppState>>,
|
||||||
Html(render_registry_list("cargo", "Cargo Registry", &crates))
|
Query(query): Query<ListQuery>,
|
||||||
|
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(
|
||||||
|
"cargo",
|
||||||
|
"Cargo Registry",
|
||||||
|
&crates,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
lang,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn cargo_detail(
|
async fn cargo_detail(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Path(name): Path<String>,
|
Path(name): Path<String>,
|
||||||
|
Query(query): Query<LangQuery>,
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
let lang = extract_lang(
|
||||||
|
&Query(query),
|
||||||
|
headers.get("cookie").and_then(|v| v.to_str().ok()),
|
||||||
|
);
|
||||||
let detail = get_cargo_detail(&state.storage, &name).await;
|
let detail = get_cargo_detail(&state.storage, &name).await;
|
||||||
Html(render_package_detail("cargo", &name, &detail))
|
Html(render_package_detail("cargo", &name, &detail, lang))
|
||||||
}
|
}
|
||||||
|
|
||||||
// PyPI pages
|
// PyPI pages
|
||||||
async fn pypi_list(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
async fn pypi_list(
|
||||||
let packages = get_pypi_packages(&state.storage).await;
|
State(state): State<Arc<AppState>>,
|
||||||
Html(render_registry_list("pypi", "PyPI Repository", &packages))
|
Query(query): Query<ListQuery>,
|
||||||
|
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(
|
||||||
|
"pypi",
|
||||||
|
"PyPI Repository",
|
||||||
|
&packages,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
lang,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn pypi_detail(
|
async fn pypi_detail(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Path(name): Path<String>,
|
Path(name): Path<String>,
|
||||||
|
Query(query): Query<LangQuery>,
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
let lang = extract_lang(
|
||||||
|
&Query(query),
|
||||||
|
headers.get("cookie").and_then(|v| v.to_str().ok()),
|
||||||
|
);
|
||||||
let detail = get_pypi_detail(&state.storage, &name).await;
|
let detail = get_pypi_detail(&state.storage, &name).await;
|
||||||
Html(render_package_detail("pypi", &name, &detail))
|
Html(render_package_detail("pypi", &name, &detail, lang))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,97 +1,191 @@
|
|||||||
use super::api::{DockerDetail, MavenDetail, PackageDetail, RegistryStats, RepoInfo};
|
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
use super::api::{DashboardResponse, DockerDetail, MavenDetail, PackageDetail};
|
||||||
use super::components::*;
|
use super::components::*;
|
||||||
|
use super::i18n::{get_translations, Lang};
|
||||||
|
use crate::repo_index::RepoInfo;
|
||||||
|
|
||||||
/// Renders the main dashboard page
|
/// Renders the main dashboard page with dark theme
|
||||||
pub fn render_dashboard(stats: &RegistryStats) -> String {
|
pub fn render_dashboard(data: &DashboardResponse, lang: Lang) -> String {
|
||||||
let content = format!(
|
let t = get_translations(lang);
|
||||||
r##"
|
// Render global stats
|
||||||
<div class="mb-8">
|
let global_stats = render_global_stats(
|
||||||
<h1 class="text-2xl font-bold text-slate-800 mb-2">Dashboard</h1>
|
data.global_stats.downloads,
|
||||||
<p class="text-slate-500">Overview of all registries</p>
|
data.global_stats.uploads,
|
||||||
</div>
|
data.global_stats.artifacts,
|
||||||
|
data.global_stats.cache_hit_percent,
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-6 mb-8">
|
data.global_stats.storage_bytes,
|
||||||
{}
|
lang,
|
||||||
{}
|
|
||||||
{}
|
|
||||||
{}
|
|
||||||
{}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-white rounded-lg shadow-sm border border-slate-200 p-6">
|
|
||||||
<h2 class="text-lg font-semibold text-slate-800 mb-4">Quick Links</h2>
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
<a href="/ui/docker" class="flex items-center p-3 rounded-lg border border-slate-200 hover:border-blue-300 hover:bg-blue-50 transition-colors">
|
|
||||||
<svg class="w-8 h-8 mr-3 text-slate-600" fill="currentColor" viewBox="0 0 24 24">{}</svg>
|
|
||||||
<div>
|
|
||||||
<div class="font-medium text-slate-700">Docker Registry</div>
|
|
||||||
<div class="text-sm text-slate-500">API: /v2/</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
<a href="/ui/maven" class="flex items-center p-3 rounded-lg border border-slate-200 hover:border-blue-300 hover:bg-blue-50 transition-colors">
|
|
||||||
<svg class="w-8 h-8 mr-3 text-slate-600" fill="currentColor" viewBox="0 0 24 24">{}</svg>
|
|
||||||
<div>
|
|
||||||
<div class="font-medium text-slate-700">Maven Repository</div>
|
|
||||||
<div class="text-sm text-slate-500">API: /maven2/</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
<a href="/ui/npm" class="flex items-center p-3 rounded-lg border border-slate-200 hover:border-blue-300 hover:bg-blue-50 transition-colors">
|
|
||||||
<svg class="w-8 h-8 mr-3 text-slate-600" fill="currentColor" viewBox="0 0 24 24">{}</svg>
|
|
||||||
<div>
|
|
||||||
<div class="font-medium text-slate-700">npm Registry</div>
|
|
||||||
<div class="text-sm text-slate-500">API: /npm/</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
<a href="/ui/cargo" class="flex items-center p-3 rounded-lg border border-slate-200 hover:border-blue-300 hover:bg-blue-50 transition-colors">
|
|
||||||
<svg class="w-8 h-8 mr-3 text-slate-600" fill="currentColor" viewBox="0 0 24 24">{}</svg>
|
|
||||||
<div>
|
|
||||||
<div class="font-medium text-slate-700">Cargo Registry</div>
|
|
||||||
<div class="text-sm text-slate-500">API: /cargo/</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
<a href="/ui/pypi" class="flex items-center p-3 rounded-lg border border-slate-200 hover:border-blue-300 hover:bg-blue-50 transition-colors">
|
|
||||||
<svg class="w-8 h-8 mr-3 text-slate-600" fill="currentColor" viewBox="0 0 24 24">{}</svg>
|
|
||||||
<div>
|
|
||||||
<div class="font-medium text-slate-700">PyPI Repository</div>
|
|
||||||
<div class="text-sm text-slate-500">API: /simple/</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
"##,
|
|
||||||
stat_card(
|
|
||||||
"Docker",
|
|
||||||
icons::DOCKER,
|
|
||||||
stats.docker,
|
|
||||||
"/ui/docker",
|
|
||||||
"images"
|
|
||||||
),
|
|
||||||
stat_card("Maven", icons::MAVEN, stats.maven, "/ui/maven", "artifacts"),
|
|
||||||
stat_card("npm", icons::NPM, stats.npm, "/ui/npm", "packages"),
|
|
||||||
stat_card("Cargo", icons::CARGO, stats.cargo, "/ui/cargo", "crates"),
|
|
||||||
stat_card("PyPI", icons::PYPI, stats.pypi, "/ui/pypi", "packages"),
|
|
||||||
// Quick Links icons
|
|
||||||
icons::DOCKER,
|
|
||||||
icons::MAVEN,
|
|
||||||
icons::NPM,
|
|
||||||
icons::CARGO,
|
|
||||||
icons::PYPI,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
layout("Dashboard", &content, Some("dashboard"))
|
// Render registry cards
|
||||||
|
let registry_cards: String = data
|
||||||
|
.registry_stats
|
||||||
|
.iter()
|
||||||
|
.map(|r| {
|
||||||
|
let icon = match r.name.as_str() {
|
||||||
|
"docker" => icons::DOCKER,
|
||||||
|
"maven" => icons::MAVEN,
|
||||||
|
"npm" => icons::NPM,
|
||||||
|
"cargo" => icons::CARGO,
|
||||||
|
"pypi" => icons::PYPI,
|
||||||
|
_ => icons::DOCKER,
|
||||||
|
};
|
||||||
|
let display_name = match r.name.as_str() {
|
||||||
|
"docker" => "Docker",
|
||||||
|
"maven" => "Maven",
|
||||||
|
"npm" => "npm",
|
||||||
|
"cargo" => "Cargo",
|
||||||
|
"pypi" => "PyPI",
|
||||||
|
_ => &r.name,
|
||||||
|
};
|
||||||
|
render_registry_card(
|
||||||
|
display_name,
|
||||||
|
icon,
|
||||||
|
r.artifact_count,
|
||||||
|
r.downloads,
|
||||||
|
r.uploads,
|
||||||
|
r.size_bytes,
|
||||||
|
&format!("/ui/{}", r.name),
|
||||||
|
t,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Render mount points
|
||||||
|
let mount_data: Vec<(String, String, Option<String>)> = data
|
||||||
|
.mount_points
|
||||||
|
.iter()
|
||||||
|
.map(|m| {
|
||||||
|
(
|
||||||
|
m.registry.clone(),
|
||||||
|
m.mount_path.clone(),
|
||||||
|
m.proxy_upstream.clone(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let mount_points = render_mount_points_table(&mount_data, t);
|
||||||
|
|
||||||
|
// Render activity log
|
||||||
|
let activity_rows: String = if data.activity.is_empty() {
|
||||||
|
format!(
|
||||||
|
r##"<tr><td colspan="5" class="py-8 text-center text-slate-500">{}</td></tr>"##,
|
||||||
|
t.no_activity
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
data.activity
|
||||||
|
.iter()
|
||||||
|
.map(|entry| {
|
||||||
|
let time_ago = format_relative_time(&entry.timestamp);
|
||||||
|
render_activity_row(
|
||||||
|
&time_ago,
|
||||||
|
&entry.action.to_string(),
|
||||||
|
&entry.artifact,
|
||||||
|
&entry.registry,
|
||||||
|
&entry.source,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
let activity_log = render_activity_log(&activity_rows, t);
|
||||||
|
|
||||||
|
// Format uptime
|
||||||
|
let hours = data.uptime_seconds / 3600;
|
||||||
|
let mins = (data.uptime_seconds % 3600) / 60;
|
||||||
|
let uptime_str = format!("{}h {}m", hours, mins);
|
||||||
|
|
||||||
|
// Render bragging footer
|
||||||
|
let bragging_footer = render_bragging_footer(lang);
|
||||||
|
|
||||||
|
let content = format!(
|
||||||
|
r##"
|
||||||
|
<div class="mb-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-slate-200 mb-1">{}</h1>
|
||||||
|
<p class="text-slate-400">{}</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<div class="text-sm text-slate-500">{}</div>
|
||||||
|
<div id="uptime" class="text-lg font-semibold text-slate-300">{}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{}
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4 mb-6">
|
||||||
|
{}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||||
|
{}
|
||||||
|
{}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{}
|
||||||
|
"##,
|
||||||
|
t.dashboard_title,
|
||||||
|
t.dashboard_subtitle,
|
||||||
|
t.uptime,
|
||||||
|
uptime_str,
|
||||||
|
global_stats,
|
||||||
|
registry_cards,
|
||||||
|
mount_points,
|
||||||
|
activity_log,
|
||||||
|
bragging_footer,
|
||||||
|
);
|
||||||
|
|
||||||
|
let polling_script = render_polling_script();
|
||||||
|
layout_dark(
|
||||||
|
t.dashboard_title,
|
||||||
|
&content,
|
||||||
|
Some("dashboard"),
|
||||||
|
&polling_script,
|
||||||
|
lang,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format timestamp as relative time (e.g., "2 min ago")
|
||||||
|
fn format_relative_time(timestamp: &chrono::DateTime<chrono::Utc>) -> String {
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
let diff = now.signed_duration_since(*timestamp);
|
||||||
|
|
||||||
|
if diff.num_seconds() < 60 {
|
||||||
|
"just now".to_string()
|
||||||
|
} else if diff.num_minutes() < 60 {
|
||||||
|
let mins = diff.num_minutes();
|
||||||
|
format!("{} min{} ago", mins, if mins == 1 { "" } else { "s" })
|
||||||
|
} else if diff.num_hours() < 24 {
|
||||||
|
let hours = diff.num_hours();
|
||||||
|
format!("{} hour{} ago", hours, if hours == 1 { "" } else { "s" })
|
||||||
|
} else {
|
||||||
|
let days = diff.num_days();
|
||||||
|
format!("{} day{} ago", days, if days == 1 { "" } else { "s" })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Renders a registry list page (docker, maven, npm, cargo, pypi)
|
/// Renders a registry list page (docker, maven, npm, cargo, pypi)
|
||||||
pub fn render_registry_list(registry_type: &str, title: &str, repos: &[RepoInfo]) -> String {
|
#[allow(dead_code)]
|
||||||
|
pub fn render_registry_list(
|
||||||
|
registry_type: &str,
|
||||||
|
title: &str,
|
||||||
|
repos: &[RepoInfo],
|
||||||
|
lang: Lang,
|
||||||
|
) -> String {
|
||||||
|
let t = get_translations(lang);
|
||||||
let icon = get_registry_icon(registry_type);
|
let icon = get_registry_icon(registry_type);
|
||||||
|
|
||||||
let table_rows = if repos.is_empty() {
|
let table_rows = if repos.is_empty() {
|
||||||
r##"<tr><td colspan="4" class="px-6 py-12 text-center text-slate-500">
|
format!(
|
||||||
|
r##"<tr><td colspan="4" class="px-6 py-12 text-center text-slate-500">
|
||||||
<div class="text-4xl mb-2">📭</div>
|
<div class="text-4xl mb-2">📭</div>
|
||||||
<div>No repositories found</div>
|
<div>{}</div>
|
||||||
<div class="text-sm mt-1">Push your first artifact to see it here</div>
|
<div class="text-sm mt-1">{}</div>
|
||||||
</td></tr>"##
|
</td></tr>"##,
|
||||||
.to_string()
|
t.no_repos_found, t.push_first_artifact
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
repos
|
repos
|
||||||
.iter()
|
.iter()
|
||||||
@@ -100,12 +194,12 @@ pub fn render_registry_list(registry_type: &str, title: &str, repos: &[RepoInfo]
|
|||||||
format!("/ui/{}/{}", registry_type, encode_uri_component(&repo.name));
|
format!("/ui/{}/{}", registry_type, encode_uri_component(&repo.name));
|
||||||
format!(
|
format!(
|
||||||
r##"
|
r##"
|
||||||
<tr class="hover:bg-slate-50 cursor-pointer" onclick="window.location='{}'">
|
<tr class="hover:bg-slate-700 cursor-pointer" onclick="window.location='{}'">
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
<a href="{}" class="text-blue-600 hover:text-blue-800 font-medium">{}</a>
|
<a href="{}" class="text-blue-400 hover:text-blue-300 font-medium">{}</a>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 text-slate-600">{}</td>
|
<td class="px-6 py-4 text-slate-400">{}</td>
|
||||||
<td class="px-6 py-4 text-slate-600">{}</td>
|
<td class="px-6 py-4 text-slate-400">{}</td>
|
||||||
<td class="px-6 py-4 text-slate-500 text-sm">{}</td>
|
<td class="px-6 py-4 text-slate-500 text-sm">{}</td>
|
||||||
</tr>
|
</tr>
|
||||||
"##,
|
"##,
|
||||||
@@ -122,48 +216,47 @@ pub fn render_registry_list(registry_type: &str, title: &str, repos: &[RepoInfo]
|
|||||||
};
|
};
|
||||||
|
|
||||||
let version_label = match registry_type {
|
let version_label = match registry_type {
|
||||||
"docker" => "Tags",
|
"docker" => t.tags,
|
||||||
"maven" => "Versions",
|
_ => t.versions,
|
||||||
_ => "Versions",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let content = format!(
|
let content = format!(
|
||||||
r##"
|
r##"
|
||||||
<div class="mb-6 flex items-center justify-between">
|
<div class="mb-6 flex items-center justify-between">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<svg class="w-10 h-10 mr-3 text-slate-600" fill="currentColor" viewBox="0 0 24 24">{}</svg>
|
<svg class="w-10 h-10 mr-3 text-slate-400" fill="currentColor" viewBox="0 0 24 24">{}</svg>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold text-slate-800">{}</h1>
|
<h1 class="text-2xl font-bold text-slate-200">{}</h1>
|
||||||
<p class="text-slate-500">{} repositories</p>
|
<p class="text-slate-500">{} {}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<input type="text"
|
<input type="text"
|
||||||
placeholder="Search repositories..."
|
placeholder="{}"
|
||||||
class="pl-10 pr-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
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-get="/api/ui/{}/search"
|
||||||
hx-trigger="keyup changed delay:300ms"
|
hx-trigger="keyup changed delay:300ms"
|
||||||
hx-target="#repo-table-body"
|
hx-target="#repo-table-body"
|
||||||
name="q">
|
name="q">
|
||||||
<svg class="absolute left-3 top-2.5 h-5 w-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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"/>
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-white rounded-lg shadow-sm border border-slate-200 overflow-hidden">
|
<div class="bg-[#1e293b] rounded-lg shadow-sm border border-slate-700 overflow-hidden">
|
||||||
<table class="w-full">
|
<table class="w-full">
|
||||||
<thead class="bg-slate-50 border-b border-slate-200">
|
<thead class="bg-slate-800 border-b border-slate-700">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider">Name</th>
|
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-400 uppercase tracking-wider">{}</th>
|
||||||
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider">{}</th>
|
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-400 uppercase tracking-wider">{}</th>
|
||||||
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider">Size</th>
|
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-400 uppercase tracking-wider">{}</th>
|
||||||
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider">Updated</th>
|
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-400 uppercase tracking-wider">{}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="repo-table-body" class="divide-y divide-slate-200">
|
<tbody id="repo-table-body" class="divide-y divide-slate-700">
|
||||||
{}
|
{}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -172,16 +265,236 @@ pub fn render_registry_list(registry_type: &str, title: &str, repos: &[RepoInfo]
|
|||||||
icon,
|
icon,
|
||||||
title,
|
title,
|
||||||
repos.len(),
|
repos.len(),
|
||||||
|
t.repositories,
|
||||||
|
t.search_placeholder,
|
||||||
registry_type,
|
registry_type,
|
||||||
|
t.name,
|
||||||
version_label,
|
version_label,
|
||||||
|
t.size,
|
||||||
|
t.updated,
|
||||||
table_rows
|
table_rows
|
||||||
);
|
);
|
||||||
|
|
||||||
layout(title, &content, Some(registry_type))
|
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
|
/// Renders Docker image detail page
|
||||||
pub fn render_docker_detail(name: &str, detail: &DockerDetail) -> String {
|
pub fn render_docker_detail(name: &str, detail: &DockerDetail, lang: Lang) -> String {
|
||||||
|
let _t = get_translations(lang);
|
||||||
let tags_rows = if detail.tags.is_empty() {
|
let tags_rows = if detail.tags.is_empty() {
|
||||||
r##"<tr><td colspan="3" class="px-6 py-8 text-center text-slate-500">No tags found</td></tr>"##.to_string()
|
r##"<tr><td colspan="3" class="px-6 py-8 text-center text-slate-500">No tags found</td></tr>"##.to_string()
|
||||||
} else {
|
} else {
|
||||||
@@ -191,11 +504,11 @@ pub fn render_docker_detail(name: &str, detail: &DockerDetail) -> String {
|
|||||||
.map(|tag| {
|
.map(|tag| {
|
||||||
format!(
|
format!(
|
||||||
r##"
|
r##"
|
||||||
<tr class="hover:bg-slate-50">
|
<tr class="hover:bg-slate-700">
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
<span class="font-mono text-sm bg-slate-100 px-2 py-1 rounded">{}</span>
|
<span class="font-mono text-sm bg-slate-700 text-slate-200 px-2 py-1 rounded">{}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 text-slate-600">{}</td>
|
<td class="px-6 py-4 text-slate-400">{}</td>
|
||||||
<td class="px-6 py-4 text-slate-500 text-sm">{}</td>
|
<td class="px-6 py-4 text-slate-500 text-sm">{}</td>
|
||||||
</tr>
|
</tr>
|
||||||
"##,
|
"##,
|
||||||
@@ -214,18 +527,18 @@ pub fn render_docker_detail(name: &str, detail: &DockerDetail) -> String {
|
|||||||
r##"
|
r##"
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<div class="flex items-center mb-2">
|
<div class="flex items-center mb-2">
|
||||||
<a href="/ui/docker" class="text-blue-600 hover:text-blue-800">Docker Registry</a>
|
<a href="/ui/docker" class="text-blue-400 hover:text-blue-300">Docker Registry</a>
|
||||||
<span class="mx-2 text-slate-400">/</span>
|
<span class="mx-2 text-slate-500">/</span>
|
||||||
<span class="text-slate-800 font-medium">{}</span>
|
<span class="text-slate-200 font-medium">{}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<svg class="w-10 h-10 mr-3 text-slate-600" fill="currentColor" viewBox="0 0 24 24">{}</svg>
|
<svg class="w-10 h-10 mr-3 text-slate-400" fill="currentColor" viewBox="0 0 24 24">{}</svg>
|
||||||
<h1 class="text-2xl font-bold text-slate-800">{}</h1>
|
<h1 class="text-2xl font-bold text-slate-200">{}</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-white rounded-lg shadow-sm border border-slate-200 p-6 mb-6">
|
<div class="bg-[#1e293b] rounded-lg shadow-sm border border-slate-700 p-6 mb-6">
|
||||||
<h2 class="text-lg font-semibold text-slate-800 mb-3">Pull Command</h2>
|
<h2 class="text-lg font-semibold text-slate-200 mb-3">Pull Command</h2>
|
||||||
<div class="flex items-center bg-slate-900 text-green-400 rounded-lg p-4 font-mono text-sm">
|
<div class="flex items-center bg-slate-900 text-green-400 rounded-lg p-4 font-mono text-sm">
|
||||||
<code class="flex-1">{}</code>
|
<code class="flex-1">{}</code>
|
||||||
<button onclick="navigator.clipboard.writeText('{}')" class="ml-4 text-slate-400 hover:text-white transition-colors" title="Copy to clipboard">
|
<button onclick="navigator.clipboard.writeText('{}')" class="ml-4 text-slate-400 hover:text-white transition-colors" title="Copy to clipboard">
|
||||||
@@ -236,19 +549,19 @@ pub fn render_docker_detail(name: &str, detail: &DockerDetail) -> String {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-white rounded-lg shadow-sm border border-slate-200 overflow-hidden">
|
<div class="bg-[#1e293b] rounded-lg shadow-sm border border-slate-700 overflow-hidden">
|
||||||
<div class="px-6 py-4 border-b border-slate-200">
|
<div class="px-6 py-4 border-b border-slate-700">
|
||||||
<h2 class="text-lg font-semibold text-slate-800">Tags ({} total)</h2>
|
<h2 class="text-lg font-semibold text-slate-200">Tags ({} total)</h2>
|
||||||
</div>
|
</div>
|
||||||
<table class="w-full">
|
<table class="w-full">
|
||||||
<thead class="bg-slate-50 border-b border-slate-200">
|
<thead class="bg-slate-800 border-b border-slate-700">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider">Tag</th>
|
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-400 uppercase tracking-wider">Tag</th>
|
||||||
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider">Size</th>
|
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-400 uppercase tracking-wider">Size</th>
|
||||||
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider">Created</th>
|
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-400 uppercase tracking-wider">Created</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-slate-200">
|
<tbody class="divide-y divide-slate-700">
|
||||||
{}
|
{}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -263,11 +576,23 @@ pub fn render_docker_detail(name: &str, detail: &DockerDetail) -> String {
|
|||||||
tags_rows
|
tags_rows
|
||||||
);
|
);
|
||||||
|
|
||||||
layout(&format!("{} - Docker", name), &content, Some("docker"))
|
layout_dark(
|
||||||
|
&format!("{} - Docker", name),
|
||||||
|
&content,
|
||||||
|
Some("docker"),
|
||||||
|
"",
|
||||||
|
lang,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Renders package detail page (npm, cargo, pypi)
|
/// Renders package detail page (npm, cargo, pypi)
|
||||||
pub fn render_package_detail(registry_type: &str, name: &str, detail: &PackageDetail) -> String {
|
pub fn render_package_detail(
|
||||||
|
registry_type: &str,
|
||||||
|
name: &str,
|
||||||
|
detail: &PackageDetail,
|
||||||
|
lang: Lang,
|
||||||
|
) -> String {
|
||||||
|
let _t = get_translations(lang);
|
||||||
let icon = get_registry_icon(registry_type);
|
let icon = get_registry_icon(registry_type);
|
||||||
let registry_title = get_registry_title(registry_type);
|
let registry_title = get_registry_title(registry_type);
|
||||||
|
|
||||||
@@ -280,11 +605,11 @@ pub fn render_package_detail(registry_type: &str, name: &str, detail: &PackageDe
|
|||||||
.map(|v| {
|
.map(|v| {
|
||||||
format!(
|
format!(
|
||||||
r##"
|
r##"
|
||||||
<tr class="hover:bg-slate-50">
|
<tr class="hover:bg-slate-700">
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
<span class="font-mono text-sm bg-slate-100 px-2 py-1 rounded">{}</span>
|
<span class="font-mono text-sm bg-slate-700 text-slate-200 px-2 py-1 rounded">{}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 text-slate-600">{}</td>
|
<td class="px-6 py-4 text-slate-400">{}</td>
|
||||||
<td class="px-6 py-4 text-slate-500 text-sm">{}</td>
|
<td class="px-6 py-4 text-slate-500 text-sm">{}</td>
|
||||||
</tr>
|
</tr>
|
||||||
"##,
|
"##,
|
||||||
@@ -311,18 +636,18 @@ pub fn render_package_detail(registry_type: &str, name: &str, detail: &PackageDe
|
|||||||
r##"
|
r##"
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<div class="flex items-center mb-2">
|
<div class="flex items-center mb-2">
|
||||||
<a href="/ui/{}" class="text-blue-600 hover:text-blue-800">{}</a>
|
<a href="/ui/{}" class="text-blue-400 hover:text-blue-300">{}</a>
|
||||||
<span class="mx-2 text-slate-400">/</span>
|
<span class="mx-2 text-slate-500">/</span>
|
||||||
<span class="text-slate-800 font-medium">{}</span>
|
<span class="text-slate-200 font-medium">{}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<svg class="w-10 h-10 mr-3 text-slate-600" fill="currentColor" viewBox="0 0 24 24">{}</svg>
|
<svg class="w-10 h-10 mr-3 text-slate-400" fill="currentColor" viewBox="0 0 24 24">{}</svg>
|
||||||
<h1 class="text-2xl font-bold text-slate-800">{}</h1>
|
<h1 class="text-2xl font-bold text-slate-200">{}</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-white rounded-lg shadow-sm border border-slate-200 p-6 mb-6">
|
<div class="bg-[#1e293b] rounded-lg shadow-sm border border-slate-700 p-6 mb-6">
|
||||||
<h2 class="text-lg font-semibold text-slate-800 mb-3">Install Command</h2>
|
<h2 class="text-lg font-semibold text-slate-200 mb-3">Install Command</h2>
|
||||||
<div class="flex items-center bg-slate-900 text-green-400 rounded-lg p-4 font-mono text-sm">
|
<div class="flex items-center bg-slate-900 text-green-400 rounded-lg p-4 font-mono text-sm">
|
||||||
<code class="flex-1">{}</code>
|
<code class="flex-1">{}</code>
|
||||||
<button onclick="navigator.clipboard.writeText('{}')" class="ml-4 text-slate-400 hover:text-white transition-colors" title="Copy to clipboard">
|
<button onclick="navigator.clipboard.writeText('{}')" class="ml-4 text-slate-400 hover:text-white transition-colors" title="Copy to clipboard">
|
||||||
@@ -333,19 +658,19 @@ pub fn render_package_detail(registry_type: &str, name: &str, detail: &PackageDe
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-white rounded-lg shadow-sm border border-slate-200 overflow-hidden">
|
<div class="bg-[#1e293b] rounded-lg shadow-sm border border-slate-700 overflow-hidden">
|
||||||
<div class="px-6 py-4 border-b border-slate-200">
|
<div class="px-6 py-4 border-b border-slate-700">
|
||||||
<h2 class="text-lg font-semibold text-slate-800">Versions ({} total)</h2>
|
<h2 class="text-lg font-semibold text-slate-200">Versions ({} total)</h2>
|
||||||
</div>
|
</div>
|
||||||
<table class="w-full">
|
<table class="w-full">
|
||||||
<thead class="bg-slate-50 border-b border-slate-200">
|
<thead class="bg-slate-800 border-b border-slate-700">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider">Version</th>
|
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-400 uppercase tracking-wider">Version</th>
|
||||||
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider">Size</th>
|
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-400 uppercase tracking-wider">Size</th>
|
||||||
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider">Published</th>
|
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-400 uppercase tracking-wider">Published</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-slate-200">
|
<tbody class="divide-y divide-slate-700">
|
||||||
{}
|
{}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -362,26 +687,29 @@ pub fn render_package_detail(registry_type: &str, name: &str, detail: &PackageDe
|
|||||||
versions_rows
|
versions_rows
|
||||||
);
|
);
|
||||||
|
|
||||||
layout(
|
layout_dark(
|
||||||
&format!("{} - {}", name, registry_title),
|
&format!("{} - {}", name, registry_title),
|
||||||
&content,
|
&content,
|
||||||
Some(registry_type),
|
Some(registry_type),
|
||||||
|
"",
|
||||||
|
lang,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Renders Maven artifact detail page
|
/// Renders Maven artifact detail page
|
||||||
pub fn render_maven_detail(path: &str, detail: &MavenDetail) -> String {
|
pub fn render_maven_detail(path: &str, detail: &MavenDetail, lang: Lang) -> String {
|
||||||
|
let _t = get_translations(lang);
|
||||||
let artifact_rows = if detail.artifacts.is_empty() {
|
let artifact_rows = if detail.artifacts.is_empty() {
|
||||||
r##"<tr><td colspan="2" class="px-6 py-8 text-center text-slate-500">No artifacts found</td></tr>"##.to_string()
|
r##"<tr><td colspan="2" class="px-6 py-8 text-center text-slate-500">No artifacts found</td></tr>"##.to_string()
|
||||||
} else {
|
} else {
|
||||||
detail.artifacts.iter().map(|a| {
|
detail.artifacts.iter().map(|a| {
|
||||||
let download_url = format!("/maven2/{}/{}", path, a.filename);
|
let download_url = format!("/maven2/{}/{}", path, a.filename);
|
||||||
format!(r##"
|
format!(r##"
|
||||||
<tr class="hover:bg-slate-50">
|
<tr class="hover:bg-slate-700">
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
<a href="{}" class="text-blue-600 hover:text-blue-800 font-mono text-sm">{}</a>
|
<a href="{}" class="text-blue-400 hover:text-blue-300 font-mono text-sm">{}</a>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 text-slate-600">{}</td>
|
<td class="px-6 py-4 text-slate-400">{}</td>
|
||||||
</tr>
|
</tr>
|
||||||
"##, download_url, html_escape(&a.filename), format_size(a.size))
|
"##, download_url, html_escape(&a.filename), format_size(a.size))
|
||||||
}).collect::<Vec<_>>().join("")
|
}).collect::<Vec<_>>().join("")
|
||||||
@@ -410,33 +738,33 @@ pub fn render_maven_detail(path: &str, detail: &MavenDetail) -> String {
|
|||||||
r##"
|
r##"
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<div class="flex items-center mb-2">
|
<div class="flex items-center mb-2">
|
||||||
<a href="/ui/maven" class="text-blue-600 hover:text-blue-800">Maven Repository</a>
|
<a href="/ui/maven" class="text-blue-400 hover:text-blue-300">Maven Repository</a>
|
||||||
<span class="mx-2 text-slate-400">/</span>
|
<span class="mx-2 text-slate-500">/</span>
|
||||||
<span class="text-slate-800 font-medium">{}</span>
|
<span class="text-slate-200 font-medium">{}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<svg class="w-10 h-10 mr-3 text-slate-600" fill="currentColor" viewBox="0 0 24 24">{}</svg>
|
<svg class="w-10 h-10 mr-3 text-slate-400" fill="currentColor" viewBox="0 0 24 24">{}</svg>
|
||||||
<h1 class="text-2xl font-bold text-slate-800">{}</h1>
|
<h1 class="text-2xl font-bold text-slate-200">{}</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-white rounded-lg shadow-sm border border-slate-200 p-6 mb-6">
|
<div class="bg-[#1e293b] rounded-lg shadow-sm border border-slate-700 p-6 mb-6">
|
||||||
<h2 class="text-lg font-semibold text-slate-800 mb-3">Maven Dependency</h2>
|
<h2 class="text-lg font-semibold text-slate-200 mb-3">Maven Dependency</h2>
|
||||||
<pre class="bg-slate-900 text-green-400 rounded-lg p-4 font-mono text-sm overflow-x-auto">{}</pre>
|
<pre class="bg-slate-900 text-green-400 rounded-lg p-4 font-mono text-sm overflow-x-auto">{}</pre>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-white rounded-lg shadow-sm border border-slate-200 overflow-hidden">
|
<div class="bg-[#1e293b] rounded-lg shadow-sm border border-slate-700 overflow-hidden">
|
||||||
<div class="px-6 py-4 border-b border-slate-200">
|
<div class="px-6 py-4 border-b border-slate-700">
|
||||||
<h2 class="text-lg font-semibold text-slate-800">Artifacts ({} files)</h2>
|
<h2 class="text-lg font-semibold text-slate-200">Artifacts ({} files)</h2>
|
||||||
</div>
|
</div>
|
||||||
<table class="w-full">
|
<table class="w-full">
|
||||||
<thead class="bg-slate-50 border-b border-slate-200">
|
<thead class="bg-slate-800 border-b border-slate-700">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider">Filename</th>
|
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-400 uppercase tracking-wider">Filename</th>
|
||||||
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider">Size</th>
|
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-400 uppercase tracking-wider">Size</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-slate-200">
|
<tbody class="divide-y divide-slate-700">
|
||||||
{}
|
{}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -450,7 +778,13 @@ pub fn render_maven_detail(path: &str, detail: &MavenDetail) -> String {
|
|||||||
artifact_rows
|
artifact_rows
|
||||||
);
|
);
|
||||||
|
|
||||||
layout(&format!("{} - Maven", path), &content, Some("maven"))
|
layout_dark(
|
||||||
|
&format!("{} - Maven", path),
|
||||||
|
&content,
|
||||||
|
Some("maven"),
|
||||||
|
"",
|
||||||
|
lang,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns SVG icon path for the registry type
|
/// Returns SVG icon path for the registry type
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
#![allow(dead_code)]
|
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
//! Input validation for artifact registry paths and identifiers
|
//! Input validation for artifact registry paths and identifiers
|
||||||
//!
|
//!
|
||||||
//! Provides security validation to prevent path traversal attacks and
|
//! Provides security validation to prevent path traversal attacks and
|
||||||
@@ -306,63 +308,6 @@ pub fn validate_docker_reference(reference: &str) -> Result<(), ValidationError>
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validate Maven artifact path.
|
|
||||||
///
|
|
||||||
/// Maven paths follow the pattern: groupId/artifactId/version/filename
|
|
||||||
/// Example: `org/apache/commons/commons-lang3/3.12.0/commons-lang3-3.12.0.jar`
|
|
||||||
pub fn validate_maven_path(path: &str) -> Result<(), ValidationError> {
|
|
||||||
validate_storage_key(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validate npm package name.
|
|
||||||
pub fn validate_npm_name(name: &str) -> Result<(), ValidationError> {
|
|
||||||
if name.is_empty() {
|
|
||||||
return Err(ValidationError::EmptyInput);
|
|
||||||
}
|
|
||||||
|
|
||||||
if name.len() > 214 {
|
|
||||||
return Err(ValidationError::TooLong {
|
|
||||||
max: 214,
|
|
||||||
actual: name.len(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for path traversal
|
|
||||||
if name.contains("..") {
|
|
||||||
return Err(ValidationError::PathTraversal);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validate Cargo crate name.
|
|
||||||
pub fn validate_crate_name(name: &str) -> Result<(), ValidationError> {
|
|
||||||
if name.is_empty() {
|
|
||||||
return Err(ValidationError::EmptyInput);
|
|
||||||
}
|
|
||||||
|
|
||||||
if name.len() > 64 {
|
|
||||||
return Err(ValidationError::TooLong {
|
|
||||||
max: 64,
|
|
||||||
actual: name.len(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for path traversal
|
|
||||||
if name.contains("..") || name.contains('/') {
|
|
||||||
return Err(ValidationError::PathTraversal);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Crate names: alphanumeric, underscores, hyphens
|
|
||||||
for c in name.chars() {
|
|
||||||
if !matches!(c, 'a'..='z' | 'A'..='Z' | '0'..='9' | '_' | '-') {
|
|
||||||
return Err(ValidationError::ForbiddenCharacter(c));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -19,10 +19,10 @@ serde.workspace = true
|
|||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
tracing-subscriber.workspace = true
|
tracing-subscriber.workspace = true
|
||||||
toml = "0.8"
|
toml = "1.0"
|
||||||
uuid = { version = "1", features = ["v4"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
httpdate = "1"
|
httpdate = "1"
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
quick-xml = { version = "0.31", features = ["serialize"] }
|
quick-xml = { version = "0.39", features = ["serialize"] }
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
mod config;
|
mod config;
|
||||||
|
|
||||||
use axum::extract::DefaultBodyLimit;
|
use axum::extract::DefaultBodyLimit;
|
||||||
|
|||||||
Reference in New Issue
Block a user