mirror of
https://github.com/getnora-io/nora.git
synced 2026-04-13 10:50:32 +00:00
Compare commits
102 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
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]
|
||||||
62
.github/workflows/ci.yml
vendored
62
.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,63 @@ 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 || true
|
||||||
|
continue-on-error: true # findings are reported, do not block the pipeline
|
||||||
|
|
||||||
|
# ── CVE in Rust dependencies ────────────────────────────────────────────
|
||||||
|
- name: Install cargo-audit
|
||||||
|
run: cargo install cargo-audit --locked
|
||||||
|
|
||||||
|
- name: cargo audit — RustSec advisory database
|
||||||
|
run: cargo audit
|
||||||
|
continue-on-error: true # warn only; known CVEs should not block CI until triaged
|
||||||
|
|
||||||
|
# ── Licenses, banned crates, supply chain policy ────────────────────────
|
||||||
|
- name: cargo deny — licenses and banned crates
|
||||||
|
uses: EmbarkStudios/cargo-deny-action@v2
|
||||||
|
with:
|
||||||
|
command: check
|
||||||
|
arguments: --all-features
|
||||||
|
|
||||||
|
# ── CVE scan of source tree and Cargo.lock ──────────────────────────────
|
||||||
|
- name: Trivy — filesystem scan (Cargo.lock + source)
|
||||||
|
if: always()
|
||||||
|
uses: aquasecurity/trivy-action@0.35.0
|
||||||
|
with:
|
||||||
|
scan-type: fs
|
||||||
|
scan-ref: .
|
||||||
|
format: sarif
|
||||||
|
output: trivy-fs.sarif
|
||||||
|
severity: HIGH,CRITICAL
|
||||||
|
exit-code: 0 # warn only; change to 1 to block the pipeline
|
||||||
|
|
||||||
|
- name: Upload Trivy fs results to GitHub Security tab
|
||||||
|
uses: github/codeql-action/upload-sarif@v4
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
sarif_file: trivy-fs.sarif
|
||||||
|
category: trivy-fs
|
||||||
|
|||||||
224
.github/workflows/release.yml
vendored
224
.github/workflows/release.yml
vendored
@@ -6,75 +6,253 @@ 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
|
||||||
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
|
||||||
|
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
|
||||||
|
cache-to: type=registry,ref=${{ env.NORA }}/${{ env.IMAGE_NAME }}-cache:astra,mode=max
|
||||||
|
|
||||||
|
scan:
|
||||||
|
name: Scan (${{ matrix.name }})
|
||||||
|
runs-on: [self-hosted, nora]
|
||||||
|
needs: build
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: read
|
||||||
|
security-events: write
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- name: alpine
|
||||||
|
suffix: ""
|
||||||
|
- name: redos
|
||||||
|
suffix: "-redos"
|
||||||
|
- name: astra
|
||||||
|
suffix: "-astra"
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Set version tag (strip leading v)
|
||||||
|
id: ver
|
||||||
|
run: echo "tag=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Trivy — image scan (${{ matrix.name }})
|
||||||
|
uses: aquasecurity/trivy-action@0.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)
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -12,3 +12,8 @@ TODO.md
|
|||||||
ROADMAP*.md
|
ROADMAP*.md
|
||||||
docs-site/
|
docs-site/
|
||||||
docs/
|
docs/
|
||||||
|
*.txt
|
||||||
|
|
||||||
|
## Internal files
|
||||||
|
.internal/
|
||||||
|
examples/
|
||||||
|
|||||||
164
CHANGELOG.md
164
CHANGELOG.md
@@ -4,6 +4,170 @@ All notable changes to NORA will be documented in this file.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [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
|
## [0.2.18] - 2026-01-31
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
414
CHANGELOG.md.bak
Normal file
414
CHANGELOG.md.bak
Normal file
@@ -0,0 +1,414 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to NORA will be documented in this file.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [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
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
#### UI: SVG Brand Icons
|
||||||
|
- Replaced emoji icons with proper SVG brand icons (Simple Icons style)
|
||||||
|
- Docker, Maven, npm, Cargo, PyPI icons now render as scalable vector graphics
|
||||||
|
- Consistent icon styling across dashboard, sidebar, and detail pages
|
||||||
|
|
||||||
|
#### Testing Infrastructure
|
||||||
|
- Unit tests for LocalStorage (8 tests): put/get, list, stat, health_check
|
||||||
|
- Unit tests for S3Storage with wiremock HTTP mocking (11 tests)
|
||||||
|
- Integration tests for auth/htpasswd (7 tests)
|
||||||
|
- Token lifecycle tests (11 tests)
|
||||||
|
- Validation tests (21 tests)
|
||||||
|
- **Total: 75 tests passing**
|
||||||
|
|
||||||
|
#### Security: Input Validation (`validation.rs`)
|
||||||
|
- Path traversal protection: rejects `../`, `..\\`, null bytes, absolute paths
|
||||||
|
- Docker image name validation per OCI distribution spec
|
||||||
|
- Content digest validation (`sha256:[64 hex]`, `sha512:[128 hex]`)
|
||||||
|
- Docker tag/reference validation
|
||||||
|
- Storage key length limits (max 1024 chars)
|
||||||
|
|
||||||
|
#### Security: Rate Limiting (`rate_limit.rs`)
|
||||||
|
- Auth endpoints: 1 req/sec, burst 5 (brute-force protection)
|
||||||
|
- Upload endpoints: 10 req/sec, burst 20
|
||||||
|
- General endpoints: 100 req/sec, burst 200
|
||||||
|
- Uses `tower_governor` 0.8 with `PeerIpKeyExtractor`
|
||||||
|
|
||||||
|
#### Observability: Request ID Tracking (`request_id.rs`)
|
||||||
|
- `X-Request-ID` header added to all responses
|
||||||
|
- Accepts upstream request ID or generates UUID v4
|
||||||
|
- Tracing spans include request_id for log correlation
|
||||||
|
|
||||||
|
#### CLI: Migrate Command (`migrate.rs`)
|
||||||
|
- `nora migrate --from local --to s3` - migrate between storage backends
|
||||||
|
- `--dry-run` flag for preview without copying
|
||||||
|
- Progress bar with indicatif
|
||||||
|
- Skips existing files in destination
|
||||||
|
- Summary statistics (migrated, skipped, failed, bytes)
|
||||||
|
|
||||||
|
#### Error Handling (`error.rs`)
|
||||||
|
- `AppError` enum with `IntoResponse` for Axum
|
||||||
|
- Automatic conversion from `StorageError` and `ValidationError`
|
||||||
|
- JSON error responses with request_id support
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- `StorageError` now uses `thiserror` derive macro
|
||||||
|
- `TokenError` now uses `thiserror` derive macro
|
||||||
|
- Storage wrapper validates keys before delegating to backend
|
||||||
|
- Docker registry handlers validate name, digest, reference inputs
|
||||||
|
- Body size limit set to 100MB default via `DefaultBodyLimit`
|
||||||
|
|
||||||
|
### Dependencies Added
|
||||||
|
- `thiserror = "2"` - typed error handling
|
||||||
|
- `tower_governor = "0.8"` - rate limiting
|
||||||
|
- `governor = "0.10"` - rate limiting backend
|
||||||
|
- `tempfile = "3"` (dev) - temporary directories for tests
|
||||||
|
- `wiremock = "0.6"` (dev) - HTTP mocking for S3 tests
|
||||||
|
|
||||||
|
### Files Added
|
||||||
|
- `src/validation.rs` - input validation module
|
||||||
|
- `src/migrate.rs` - storage migration module
|
||||||
|
- `src/error.rs` - application error types
|
||||||
|
- `src/request_id.rs` - request ID middleware
|
||||||
|
- `src/rate_limit.rs` - rate limiting configuration
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.1.0] - 2026-01-24
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Multi-protocol support: Docker Registry v2, Maven, npm, Cargo, PyPI
|
||||||
|
- Web UI dashboard
|
||||||
|
- Swagger UI (`/api-docs`)
|
||||||
|
- Storage backends: Local filesystem, S3-compatible
|
||||||
|
- Smart proxy/cache for Maven and npm
|
||||||
|
- Health checks (`/health`, `/ready`)
|
||||||
|
- Basic authentication (htpasswd with bcrypt)
|
||||||
|
- API tokens (revocable, per-user)
|
||||||
|
- Prometheus metrics (`/metrics`)
|
||||||
|
- JSON structured logging
|
||||||
|
- Environment variable configuration
|
||||||
|
- Graceful shutdown (SIGTERM/SIGINT)
|
||||||
|
- Backup/restore commands
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Журнал изменений (RU)
|
||||||
|
|
||||||
|
Все значимые изменения NORA документируются в этом файле.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [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
|
||||||
|
|
||||||
|
### Добавлено
|
||||||
|
|
||||||
|
#### UI: SVG иконки брендов
|
||||||
|
- Эмоджи заменены на SVG иконки брендов (стиль Simple Icons)
|
||||||
|
- Docker, Maven, npm, Cargo, PyPI теперь отображаются как векторная графика
|
||||||
|
- Единый стиль иконок на дашборде, сайдбаре и страницах деталей
|
||||||
|
|
||||||
|
#### Тестовая инфраструктура
|
||||||
|
- Unit-тесты для LocalStorage (8 тестов): put/get, list, stat, health_check
|
||||||
|
- Unit-тесты для S3Storage с HTTP-мокированием wiremock (11 тестов)
|
||||||
|
- Интеграционные тесты auth/htpasswd (7 тестов)
|
||||||
|
- Тесты жизненного цикла токенов (11 тестов)
|
||||||
|
- Тесты валидации (21 тест)
|
||||||
|
- **Всего: 75 тестов проходят**
|
||||||
|
|
||||||
|
#### Безопасность: Валидация ввода (`validation.rs`)
|
||||||
|
- Защита от path traversal: отклоняет `../`, `..\\`, null-байты, абсолютные пути
|
||||||
|
- Валидация имён Docker-образов по спецификации OCI distribution
|
||||||
|
- Валидация дайджестов (`sha256:[64 hex]`, `sha512:[128 hex]`)
|
||||||
|
- Валидация тегов и ссылок Docker
|
||||||
|
- Ограничение длины ключей хранилища (макс. 1024 символа)
|
||||||
|
|
||||||
|
#### Безопасность: Rate Limiting (`rate_limit.rs`)
|
||||||
|
- Auth endpoints: 1 req/sec, burst 5 (защита от брутфорса)
|
||||||
|
- Upload endpoints: 10 req/sec, burst 20
|
||||||
|
- Общие endpoints: 100 req/sec, burst 200
|
||||||
|
- Использует `tower_governor` 0.8 с `PeerIpKeyExtractor`
|
||||||
|
|
||||||
|
#### Наблюдаемость: Отслеживание Request ID (`request_id.rs`)
|
||||||
|
- Заголовок `X-Request-ID` добавляется ко всем ответам
|
||||||
|
- Принимает upstream request ID или генерирует UUID v4
|
||||||
|
- Tracing spans включают request_id для корреляции логов
|
||||||
|
|
||||||
|
#### CLI: Команда миграции (`migrate.rs`)
|
||||||
|
- `nora migrate --from local --to s3` - миграция между storage backends
|
||||||
|
- Флаг `--dry-run` для предпросмотра без копирования
|
||||||
|
- Прогресс-бар с indicatif
|
||||||
|
- Пропуск существующих файлов в destination
|
||||||
|
- Итоговая статистика (migrated, skipped, failed, bytes)
|
||||||
|
|
||||||
|
#### Обработка ошибок (`error.rs`)
|
||||||
|
- Enum `AppError` с `IntoResponse` для Axum
|
||||||
|
- Автоматическая конверсия из `StorageError` и `ValidationError`
|
||||||
|
- JSON-ответы об ошибках с поддержкой request_id
|
||||||
|
|
||||||
|
### Изменено
|
||||||
|
- `StorageError` теперь использует макрос `thiserror`
|
||||||
|
- `TokenError` теперь использует макрос `thiserror`
|
||||||
|
- Storage wrapper валидирует ключи перед делегированием backend
|
||||||
|
- Docker registry handlers валидируют name, digest, reference
|
||||||
|
- Лимит размера body установлен в 100MB через `DefaultBodyLimit`
|
||||||
|
|
||||||
|
### Добавлены зависимости
|
||||||
|
- `thiserror = "2"` - типизированная обработка ошибок
|
||||||
|
- `tower_governor = "0.8"` - rate limiting
|
||||||
|
- `governor = "0.10"` - backend для rate limiting
|
||||||
|
- `tempfile = "3"` (dev) - временные директории для тестов
|
||||||
|
- `wiremock = "0.6"` (dev) - HTTP-мокирование для S3 тестов
|
||||||
|
|
||||||
|
### Добавлены файлы
|
||||||
|
- `src/validation.rs` - модуль валидации ввода
|
||||||
|
- `src/migrate.rs` - модуль миграции хранилища
|
||||||
|
- `src/error.rs` - типы ошибок приложения
|
||||||
|
- `src/request_id.rs` - middleware для request ID
|
||||||
|
- `src/rate_limit.rs` - конфигурация rate limiting
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.1.0] - 2026-01-24
|
||||||
|
|
||||||
|
### Добавлено
|
||||||
|
- Мульти-протокольная поддержка: Docker Registry v2, Maven, npm, Cargo, PyPI
|
||||||
|
- Web UI дашборд
|
||||||
|
- Swagger UI (`/api-docs`)
|
||||||
|
- Storage backends: локальная файловая система, S3-совместимое хранилище
|
||||||
|
- Умный прокси/кэш для Maven и npm
|
||||||
|
- Health checks (`/health`, `/ready`)
|
||||||
|
- Базовая аутентификация (htpasswd с bcrypt)
|
||||||
|
- API токены (отзываемые, per-user)
|
||||||
|
- Prometheus метрики (`/metrics`)
|
||||||
|
- JSON структурированное логирование
|
||||||
|
- Конфигурация через переменные окружения
|
||||||
|
- Graceful shutdown (SIGTERM/SIGINT)
|
||||||
|
- Команды backup/restore
|
||||||
@@ -14,6 +14,9 @@ Thank you for your interest in contributing to NORA!
|
|||||||
# Install Rust (if needed)
|
# Install Rust (if needed)
|
||||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||||
|
|
||||||
|
# Enable pre-commit hooks (important!)
|
||||||
|
git config core.hooksPath .githooks
|
||||||
|
|
||||||
# Build
|
# Build
|
||||||
cargo build
|
cargo build
|
||||||
|
|
||||||
|
|||||||
399
Cargo.lock
generated
399
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,9 +240,9 @@ 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"
|
||||||
@@ -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.56"
|
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 = "a75ca66430e33a14957acc24c5077b503e7d374151b2b4b3a10c83b4ceb4be0e"
|
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.56"
|
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 = "793207c7fa6300a0608d1080b858e5fdbe713cdc1c8db9fb17777d8a13e63df0"
|
checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anstream",
|
"anstream",
|
||||||
"anstyle",
|
"anstyle",
|
||||||
@@ -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]]
|
||||||
@@ -467,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.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -495,9 +501,9 @@ 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",
|
||||||
@@ -510,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"
|
||||||
@@ -667,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"
|
||||||
@@ -715,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"
|
||||||
@@ -723,7 +757,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"allocator-api2",
|
"allocator-api2",
|
||||||
"equivalent",
|
"equivalent",
|
||||||
"foldhash",
|
"foldhash 0.2.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -980,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"
|
||||||
@@ -1015,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",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1080,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"
|
||||||
@@ -1098,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"
|
||||||
@@ -1201,7 +1247,7 @@ checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nora-cli"
|
name = "nora-cli"
|
||||||
version = "0.2.12"
|
version = "0.2.28"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clap",
|
"clap",
|
||||||
"flate2",
|
"flate2",
|
||||||
@@ -1215,7 +1261,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nora-registry"
|
name = "nora-registry"
|
||||||
version = "0.2.12"
|
version = "0.2.28"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -1253,7 +1299,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nora-storage"
|
name = "nora-storage"
|
||||||
version = "0.2.12"
|
version = "0.2.28"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
"base64",
|
"base64",
|
||||||
@@ -1298,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"
|
||||||
@@ -1401,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"
|
||||||
@@ -1412,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",
|
||||||
@@ -1422,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"
|
||||||
@@ -1448,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",
|
||||||
@@ -1508,7 +1572,7 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
"socket2",
|
"socket2",
|
||||||
"tracing",
|
"tracing",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1707,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.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1780,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"
|
||||||
@@ -1836,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]]
|
||||||
@@ -1990,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.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2077,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",
|
||||||
@@ -2139,44 +2209,42 @@ 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"
|
||||||
@@ -2378,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"
|
||||||
@@ -2453,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",
|
||||||
]
|
]
|
||||||
@@ -2508,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"
|
||||||
@@ -2567,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"
|
||||||
@@ -2618,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.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2695,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"
|
||||||
@@ -2856,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"
|
||||||
@@ -2888,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"
|
||||||
@@ -3038,9 +3231,9 @@ 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"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.2.18"
|
version = "0.2.28"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
authors = ["DevITWay <devitway@gmail.com>"]
|
authors = ["DevITWay <devitway@gmail.com>"]
|
||||||
|
|||||||
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"]
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
# 🐿️ N○RA
|
<img src="logo.jpg" alt="NORA" height="120" />
|
||||||
|
|
||||||
|
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
|
[](https://github.com/getnora-io/nora/releases)
|
||||||
|
[](https://github.com/getnora-io/nora/actions)
|
||||||
|
[](https://www.rust-lang.org/)
|
||||||
[](https://t.me/DevITWay)
|
[](https://t.me/DevITWay)
|
||||||
|
|
||||||
> **Your Cloud-Native Artifact Registry**
|
> **Your Cloud-Native Artifact Registry**
|
||||||
|
|||||||
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, transitive via indicatif; no fix available
|
||||||
|
]
|
||||||
|
|
||||||
|
[licenses]
|
||||||
|
# Allowed open-source licenses
|
||||||
|
allow = [
|
||||||
|
"MIT",
|
||||||
|
"Apache-2.0",
|
||||||
|
"Apache-2.0 WITH LLVM-exception",
|
||||||
|
"BSD-2-Clause",
|
||||||
|
"BSD-3-Clause",
|
||||||
|
"ISC",
|
||||||
|
"Unicode-DFS-2016",
|
||||||
|
"Unicode-3.0",
|
||||||
|
"CC0-1.0",
|
||||||
|
"OpenSSL",
|
||||||
|
"Zlib",
|
||||||
|
"CDLA-Permissive-2.0", # webpki-roots (CA certificates bundle)
|
||||||
|
"MPL-2.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[bans]
|
||||||
|
multiple-versions = "warn"
|
||||||
|
deny = [
|
||||||
|
{ name = "openssl-sys" },
|
||||||
|
{ name = "openssl" },
|
||||||
|
]
|
||||||
|
skip = []
|
||||||
|
|
||||||
|
[sources]
|
||||||
|
unknown-registry = "warn"
|
||||||
|
unknown-git = "warn"
|
||||||
|
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
|
||||||
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"
|
||||||
|
|||||||
@@ -26,19 +26,19 @@ sha2.workspace = true
|
|||||||
async-trait.workspace = true
|
async-trait.workspace = true
|
||||||
hmac.workspace = true
|
hmac.workspace = true
|
||||||
hex.workspace = true
|
hex.workspace = true
|
||||||
toml = "0.8"
|
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"
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,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
|
||||||
@@ -63,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
|
||||||
@@ -102,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 {
|
||||||
@@ -169,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 {
|
||||||
@@ -188,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)]
|
||||||
@@ -221,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,
|
||||||
@@ -265,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();
|
||||||
|
|
||||||
@@ -404,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(
|
||||||
|
|||||||
@@ -36,6 +36,13 @@ pub struct ServerConfig {
|
|||||||
/// Public URL for generating pull commands (e.g., "registry.example.com")
|
/// Public URL for generating pull commands (e.g., "registry.example.com")
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub public_url: Option<String>,
|
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)]
|
||||||
@@ -249,6 +256,8 @@ impl Default for AuthConfig {
|
|||||||
/// - `NORA_RATE_LIMIT_GENERAL_BURST` - General burst size
|
/// - `NORA_RATE_LIMIT_GENERAL_BURST` - General burst size
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct RateLimitConfig {
|
pub struct RateLimitConfig {
|
||||||
|
#[serde(default = "default_rate_limit_enabled")]
|
||||||
|
pub enabled: bool,
|
||||||
#[serde(default = "default_auth_rps")]
|
#[serde(default = "default_auth_rps")]
|
||||||
pub auth_rps: u64,
|
pub auth_rps: u64,
|
||||||
#[serde(default = "default_auth_burst")]
|
#[serde(default = "default_auth_burst")]
|
||||||
@@ -263,6 +272,9 @@ pub struct RateLimitConfig {
|
|||||||
pub general_burst: u32,
|
pub general_burst: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_rate_limit_enabled() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
fn default_auth_rps() -> u64 {
|
fn default_auth_rps() -> u64 {
|
||||||
1
|
1
|
||||||
}
|
}
|
||||||
@@ -285,6 +297,7 @@ fn default_general_burst() -> u32 {
|
|||||||
impl Default for RateLimitConfig {
|
impl Default for RateLimitConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
enabled: default_rate_limit_enabled(),
|
||||||
auth_rps: default_auth_rps(),
|
auth_rps: default_auth_rps(),
|
||||||
auth_burst: default_auth_burst(),
|
auth_burst: default_auth_burst(),
|
||||||
upload_rps: default_upload_rps(),
|
upload_rps: default_upload_rps(),
|
||||||
@@ -324,6 +337,11 @@ impl Config {
|
|||||||
if let Ok(val) = env::var("NORA_PUBLIC_URL") {
|
if let Ok(val) = env::var("NORA_PUBLIC_URL") {
|
||||||
self.server.public_url = if val.is_empty() { None } else { Some(val) };
|
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") {
|
||||||
@@ -426,6 +444,9 @@ impl Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Rate limit config
|
// Rate limit config
|
||||||
|
if let Ok(val) = env::var("NORA_RATE_LIMIT_ENABLED") {
|
||||||
|
self.rate_limit.enabled = val.to_lowercase() == "true" || val == "1";
|
||||||
|
}
|
||||||
if let Ok(val) = env::var("NORA_RATE_LIMIT_AUTH_RPS") {
|
if let Ok(val) = env::var("NORA_RATE_LIMIT_AUTH_RPS") {
|
||||||
if let Ok(v) = val.parse::<u64>() {
|
if let Ok(v) = val.parse::<u64>() {
|
||||||
self.rate_limit.auth_rps = v;
|
self.rate_limit.auth_rps = v;
|
||||||
@@ -474,6 +495,7 @@ impl Default for Config {
|
|||||||
host: String::from("127.0.0.1"),
|
host: String::from("127.0.0.1"),
|
||||||
port: 4000,
|
port: 4000,
|
||||||
public_url: None,
|
public_url: None,
|
||||||
|
body_limit_mb: 2048,
|
||||||
},
|
},
|
||||||
storage: StorageConfig {
|
storage: StorageConfig {
|
||||||
mode: StorageMode::Local,
|
mode: StorageMode::Local,
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
@@ -2,17 +2,20 @@
|
|||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
mod activity_log;
|
mod activity_log;
|
||||||
|
mod audit;
|
||||||
mod auth;
|
mod auth;
|
||||||
mod backup;
|
mod backup;
|
||||||
mod config;
|
mod config;
|
||||||
mod dashboard_metrics;
|
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 secrets;
|
||||||
mod storage;
|
mod storage;
|
||||||
@@ -30,9 +33,11 @@ use tracing::{error, info, warn};
|
|||||||
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
|
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
|
||||||
|
|
||||||
use activity_log::ActivityLog;
|
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 dashboard_metrics::DashboardMetrics;
|
||||||
|
use repo_index::RepoIndex;
|
||||||
pub use storage::Storage;
|
pub use storage::Storage;
|
||||||
use tokens::TokenStore;
|
use tokens::TokenStore;
|
||||||
|
|
||||||
@@ -59,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
|
||||||
@@ -81,7 +92,10 @@ pub struct AppState {
|
|||||||
pub tokens: Option<TokenStore>,
|
pub tokens: Option<TokenStore>,
|
||||||
pub metrics: DashboardMetrics,
|
pub metrics: DashboardMetrics,
|
||||||
pub activity: ActivityLog,
|
pub activity: ActivityLog,
|
||||||
|
pub audit: AuditLog,
|
||||||
pub docker_auth: registry::DockerAuth,
|
pub docker_auth: registry::DockerAuth,
|
||||||
|
pub repo_index: RepoIndex,
|
||||||
|
pub http_client: reqwest::Client,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
@@ -139,6 +153,17 @@ 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),
|
||||||
@@ -206,6 +231,7 @@ async fn run_server(config: Config, storage: Storage) {
|
|||||||
|
|
||||||
// Log rate limiting configuration
|
// Log rate limiting configuration
|
||||||
info!(
|
info!(
|
||||||
|
enabled = config.rate_limit.enabled,
|
||||||
auth_rps = config.rate_limit.auth_rps,
|
auth_rps = config.rate_limit.auth_rps,
|
||||||
auth_burst = config.rate_limit.auth_burst,
|
auth_burst = config.rate_limit.auth_burst,
|
||||||
upload_rps = config.rate_limit.upload_rps,
|
upload_rps = config.rate_limit.upload_rps,
|
||||||
@@ -260,13 +286,49 @@ async fn run_server(config: Config, storage: Storage) {
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let storage_path = config.storage.path.clone();
|
||||||
|
let rate_limit_enabled = config.rate_limit.enabled;
|
||||||
|
|
||||||
|
// Initialize Docker auth with proxy timeout
|
||||||
|
let docker_auth = registry::DockerAuth::new(config.docker.proxy_timeout);
|
||||||
|
|
||||||
|
let http_client = reqwest::Client::new();
|
||||||
|
|
||||||
|
// Registry routes (shared between rate-limited and non-limited paths)
|
||||||
|
let registry_routes = Router::new()
|
||||||
|
.merge(registry::docker_routes())
|
||||||
|
.merge(registry::maven_routes())
|
||||||
|
.merge(registry::npm_routes())
|
||||||
|
.merge(registry::cargo_routes())
|
||||||
|
.merge(registry::pypi_routes())
|
||||||
|
.merge(registry::raw_routes());
|
||||||
|
|
||||||
|
// Routes WITHOUT rate limiting (health, metrics, UI)
|
||||||
|
let public_routes = Router::new()
|
||||||
|
.merge(health::routes())
|
||||||
|
.merge(metrics::routes())
|
||||||
|
.merge(ui::routes())
|
||||||
|
.merge(openapi::routes());
|
||||||
|
|
||||||
|
let app_routes = if rate_limit_enabled {
|
||||||
// Create rate limiters before moving config to state
|
// Create rate limiters before moving config to state
|
||||||
let auth_limiter = rate_limit::auth_rate_limiter(&config.rate_limit);
|
let auth_limiter = rate_limit::auth_rate_limiter(&config.rate_limit);
|
||||||
let upload_limiter = rate_limit::upload_rate_limiter(&config.rate_limit);
|
let upload_limiter = rate_limit::upload_rate_limiter(&config.rate_limit);
|
||||||
let general_limiter = rate_limit::general_rate_limiter(&config.rate_limit);
|
let general_limiter = rate_limit::general_rate_limiter(&config.rate_limit);
|
||||||
|
|
||||||
// Initialize Docker auth with proxy timeout
|
let auth_routes = auth::token_routes().layer(auth_limiter);
|
||||||
let docker_auth = registry::DockerAuth::new(config.docker.proxy_timeout);
|
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 {
|
let state = Arc::new(AppState {
|
||||||
storage,
|
storage,
|
||||||
@@ -276,39 +338,18 @@ async fn run_server(config: Config, storage: Storage) {
|
|||||||
tokens,
|
tokens,
|
||||||
metrics: DashboardMetrics::new(),
|
metrics: DashboardMetrics::new(),
|
||||||
activity: ActivityLog::new(50),
|
activity: ActivityLog::new(50),
|
||||||
|
audit: AuditLog::new(&storage_path),
|
||||||
docker_auth,
|
docker_auth,
|
||||||
|
repo_index: RepoIndex::new(),
|
||||||
|
http_client,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Token routes with strict rate limiting (brute-force protection)
|
|
||||||
let auth_routes = auth::token_routes().layer(auth_limiter);
|
|
||||||
|
|
||||||
// Registry routes with upload rate limiting
|
|
||||||
let registry_routes = Router::new()
|
|
||||||
.merge(registry::docker_routes())
|
|
||||||
.merge(registry::maven_routes())
|
|
||||||
.merge(registry::npm_routes())
|
|
||||||
.merge(registry::cargo_routes())
|
|
||||||
.merge(registry::pypi_routes())
|
|
||||||
.merge(registry::raw_routes())
|
|
||||||
.layer(upload_limiter);
|
|
||||||
|
|
||||||
// Routes WITHOUT rate limiting (health, metrics, UI)
|
|
||||||
let public_routes = Router::new()
|
|
||||||
.merge(health::routes())
|
|
||||||
.merge(metrics::routes())
|
|
||||||
.merge(ui::routes())
|
|
||||||
.merge(openapi::routes());
|
|
||||||
|
|
||||||
// Routes WITH rate limiting
|
|
||||||
let rate_limited_routes = Router::new()
|
|
||||||
.merge(auth_routes)
|
|
||||||
.merge(registry_routes)
|
|
||||||
.layer(general_limiter);
|
|
||||||
|
|
||||||
let app = Router::new()
|
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(
|
||||||
@@ -327,6 +368,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"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
use crate::config::RateLimitConfig;
|
use crate::config::RateLimitConfig;
|
||||||
use tower_governor::governor::GovernorConfigBuilder;
|
use tower_governor::governor::GovernorConfigBuilder;
|
||||||
|
use tower_governor::key_extractor::SmartIpKeyExtractor;
|
||||||
|
|
||||||
/// 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(
|
pub fn auth_rate_limiter(
|
||||||
@@ -35,11 +36,12 @@ pub fn auth_rate_limiter(
|
|||||||
pub fn upload_rate_limiter(
|
pub fn upload_rate_limiter(
|
||||||
config: &RateLimitConfig,
|
config: &RateLimitConfig,
|
||||||
) -> 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 gov_config = GovernorConfigBuilder::default()
|
let gov_config = GovernorConfigBuilder::default()
|
||||||
|
.key_extractor(SmartIpKeyExtractor)
|
||||||
.per_second(config.upload_rps)
|
.per_second(config.upload_rps)
|
||||||
.burst_size(config.upload_burst)
|
.burst_size(config.upload_burst)
|
||||||
.use_headers()
|
.use_headers()
|
||||||
@@ -53,11 +55,12 @@ pub fn upload_rate_limiter(
|
|||||||
pub fn general_rate_limiter(
|
pub fn general_rate_limiter(
|
||||||
config: &RateLimitConfig,
|
config: &RateLimitConfig,
|
||||||
) -> 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 gov_config = GovernorConfigBuilder::default()
|
let gov_config = GovernorConfigBuilder::default()
|
||||||
|
.key_extractor(SmartIpKeyExtractor)
|
||||||
.per_second(config.general_rps)
|
.per_second(config.general_rps)
|
||||||
.burst_size(config.general_burst)
|
.burst_size(config.general_burst)
|
||||||
.use_headers()
|
.use_headers()
|
||||||
@@ -102,6 +105,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_custom_config() {
|
fn test_custom_config() {
|
||||||
let config = RateLimitConfig {
|
let config = RateLimitConfig {
|
||||||
|
enabled: true,
|
||||||
auth_rps: 10,
|
auth_rps: 10,
|
||||||
auth_burst: 20,
|
auth_burst: 20,
|
||||||
upload_rps: 500,
|
upload_rps: 500,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
use crate::activity_log::{ActionType, ActivityEntry};
|
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},
|
||||||
@@ -50,6 +51,9 @@ async fn download(
|
|||||||
"cargo",
|
"cargo",
|
||||||
"LOCAL",
|
"LOCAL",
|
||||||
));
|
));
|
||||||
|
state
|
||||||
|
.audit
|
||||||
|
.log(AuditEntry::new("pull", "api", "", "cargo", ""));
|
||||||
(StatusCode::OK, data).into_response()
|
(StatusCode::OK, data).into_response()
|
||||||
}
|
}
|
||||||
Err(_) => StatusCode::NOT_FOUND.into_response(),
|
Err(_) => StatusCode::NOT_FOUND.into_response(),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
use crate::activity_log::{ActionType, ActivityEntry};
|
use crate::activity_log::{ActionType, ActivityEntry};
|
||||||
|
use crate::audit::AuditEntry;
|
||||||
use crate::registry::docker_auth::DockerAuth;
|
use crate::registry::docker_auth::DockerAuth;
|
||||||
use crate::storage::Storage;
|
use crate::storage::Storage;
|
||||||
use crate::validation::{validate_digest, validate_docker_name, validate_docker_reference};
|
use crate::validation::{validate_digest, validate_docker_name, validate_docker_reference};
|
||||||
@@ -11,7 +12,7 @@ use axum::{
|
|||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
http::{header, HeaderName, StatusCode},
|
http::{header, HeaderName, StatusCode},
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
routing::{get, head, patch, put},
|
routing::{delete, get, head, patch, put},
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
use parking_lot::RwLock;
|
use parking_lot::RwLock;
|
||||||
@@ -64,6 +65,8 @@ pub fn routes() -> Router<Arc<AppState>> {
|
|||||||
)
|
)
|
||||||
.route("/v2/{name}/manifests/{reference}", get(get_manifest))
|
.route("/v2/{name}/manifests/{reference}", get(get_manifest))
|
||||||
.route("/v2/{name}/manifests/{reference}", put(put_manifest))
|
.route("/v2/{name}/manifests/{reference}", put(put_manifest))
|
||||||
|
.route("/v2/{name}/manifests/{reference}", delete(delete_manifest))
|
||||||
|
.route("/v2/{name}/blobs/{digest}", delete(delete_blob))
|
||||||
.route("/v2/{name}/tags/list", get(list_tags))
|
.route("/v2/{name}/tags/list", get(list_tags))
|
||||||
// Two-segment name routes (e.g., /v2/library/alpine/...)
|
// Two-segment name routes (e.g., /v2/library/alpine/...)
|
||||||
.route("/v2/{ns}/{name}/blobs/{digest}", head(check_blob_ns))
|
.route("/v2/{ns}/{name}/blobs/{digest}", head(check_blob_ns))
|
||||||
@@ -84,6 +87,11 @@ pub fn routes() -> Router<Arc<AppState>> {
|
|||||||
"/v2/{ns}/{name}/manifests/{reference}",
|
"/v2/{ns}/{name}/manifests/{reference}",
|
||||||
put(put_manifest_ns),
|
put(put_manifest_ns),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/v2/{ns}/{name}/manifests/{reference}",
|
||||||
|
delete(delete_manifest_ns),
|
||||||
|
)
|
||||||
|
.route("/v2/{ns}/{name}/blobs/{digest}", delete(delete_blob_ns))
|
||||||
.route("/v2/{ns}/{name}/tags/list", get(list_tags_ns))
|
.route("/v2/{ns}/{name}/tags/list", get(list_tags_ns))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,6 +175,7 @@ async fn download_blob(
|
|||||||
// Try upstream proxies
|
// Try upstream proxies
|
||||||
for upstream in &state.config.docker.upstreams {
|
for upstream in &state.config.docker.upstreams {
|
||||||
if let Ok(data) = fetch_blob_from_upstream(
|
if let Ok(data) = fetch_blob_from_upstream(
|
||||||
|
&state.http_client,
|
||||||
&upstream.url,
|
&upstream.url,
|
||||||
&name,
|
&name,
|
||||||
&digest,
|
&digest,
|
||||||
@@ -192,6 +201,8 @@ async fn download_blob(
|
|||||||
let _ = storage.put(&key_clone, &data_clone).await;
|
let _ = storage.put(&key_clone, &data_clone).await;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
state.repo_index.invalidate("docker");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
StatusCode::OK,
|
StatusCode::OK,
|
||||||
[(header::CONTENT_TYPE, "application/octet-stream")],
|
[(header::CONTENT_TYPE, "application/octet-stream")],
|
||||||
@@ -302,8 +313,19 @@ async fn upload_blob(
|
|||||||
"docker",
|
"docker",
|
||||||
"LOCAL",
|
"LOCAL",
|
||||||
));
|
));
|
||||||
|
state.repo_index.invalidate("docker");
|
||||||
let location = format!("/v2/{}/blobs/{}", name, digest);
|
let location = format!("/v2/{}/blobs/{}", name, digest);
|
||||||
(StatusCode::CREATED, [(header::LOCATION, location)]).into_response()
|
(
|
||||||
|
StatusCode::CREATED,
|
||||||
|
[
|
||||||
|
(header::LOCATION, location),
|
||||||
|
(
|
||||||
|
HeaderName::from_static("docker-content-digest"),
|
||||||
|
digest.to_string(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
}
|
}
|
||||||
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||||
}
|
}
|
||||||
@@ -364,6 +386,7 @@ async fn get_manifest(
|
|||||||
for upstream in &state.config.docker.upstreams {
|
for upstream in &state.config.docker.upstreams {
|
||||||
tracing::debug!(upstream_url = %upstream.url, "Trying upstream");
|
tracing::debug!(upstream_url = %upstream.url, "Trying upstream");
|
||||||
if let Ok((data, content_type)) = fetch_manifest_from_upstream(
|
if let Ok((data, content_type)) = fetch_manifest_from_upstream(
|
||||||
|
&state.http_client,
|
||||||
&upstream.url,
|
&upstream.url,
|
||||||
&name,
|
&name,
|
||||||
&reference,
|
&reference,
|
||||||
@@ -413,6 +436,8 @@ async fn get_manifest(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
state.repo_index.invalidate("docker");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
StatusCode::OK,
|
StatusCode::OK,
|
||||||
[
|
[
|
||||||
@@ -474,6 +499,14 @@ async fn put_manifest(
|
|||||||
"docker",
|
"docker",
|
||||||
"LOCAL",
|
"LOCAL",
|
||||||
));
|
));
|
||||||
|
state.audit.log(AuditEntry::new(
|
||||||
|
"push",
|
||||||
|
"api",
|
||||||
|
&format!("{}:{}", name, reference),
|
||||||
|
"docker",
|
||||||
|
"manifest",
|
||||||
|
));
|
||||||
|
state.repo_index.invalidate("docker");
|
||||||
|
|
||||||
let location = format!("/v2/{}/manifests/{}", name, reference);
|
let location = format!("/v2/{}/manifests/{}", name, reference);
|
||||||
(
|
(
|
||||||
@@ -504,6 +537,109 @@ async fn list_tags(State(state): State<Arc<AppState>>, Path(name): Path<String>)
|
|||||||
(StatusCode::OK, Json(json!({"name": name, "tags": tags}))).into_response()
|
(StatusCode::OK, Json(json!({"name": name, "tags": tags}))).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Delete handlers (Docker Registry V2 spec)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
async fn delete_manifest(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Path((name, reference)): Path<(String, String)>,
|
||||||
|
) -> Response {
|
||||||
|
if let Err(e) = validate_docker_name(&name) {
|
||||||
|
return (StatusCode::BAD_REQUEST, e.to_string()).into_response();
|
||||||
|
}
|
||||||
|
if let Err(e) = validate_docker_reference(&reference) {
|
||||||
|
return (StatusCode::BAD_REQUEST, e.to_string()).into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let key = format!("docker/{}/manifests/{}.json", name, reference);
|
||||||
|
|
||||||
|
// If reference is a tag, also delete digest-keyed copy
|
||||||
|
let is_tag = !reference.starts_with("sha256:");
|
||||||
|
if is_tag {
|
||||||
|
if let Ok(data) = state.storage.get(&key).await {
|
||||||
|
use sha2::Digest;
|
||||||
|
let digest = format!("sha256:{:x}", sha2::Sha256::digest(&data));
|
||||||
|
let digest_key = format!("docker/{}/manifests/{}.json", name, digest);
|
||||||
|
let _ = state.storage.delete(&digest_key).await;
|
||||||
|
let digest_meta = format!("docker/{}/manifests/{}.meta.json", name, digest);
|
||||||
|
let _ = state.storage.delete(&digest_meta).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete manifest
|
||||||
|
match state.storage.delete(&key).await {
|
||||||
|
Ok(()) => {
|
||||||
|
// Delete associated metadata
|
||||||
|
let meta_key = format!("docker/{}/manifests/{}.meta.json", name, reference);
|
||||||
|
let _ = state.storage.delete(&meta_key).await;
|
||||||
|
|
||||||
|
state.audit.log(AuditEntry::new(
|
||||||
|
"delete",
|
||||||
|
"api",
|
||||||
|
&format!("{}:{}", name, reference),
|
||||||
|
"docker",
|
||||||
|
"manifest",
|
||||||
|
));
|
||||||
|
state.repo_index.invalidate("docker");
|
||||||
|
tracing::info!(name = %name, reference = %reference, "Docker manifest deleted");
|
||||||
|
StatusCode::ACCEPTED.into_response()
|
||||||
|
}
|
||||||
|
Err(crate::storage::StorageError::NotFound) => (
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Json(json!({
|
||||||
|
"errors": [{
|
||||||
|
"code": "MANIFEST_UNKNOWN",
|
||||||
|
"message": "manifest unknown",
|
||||||
|
"detail": { "name": name, "reference": reference }
|
||||||
|
}]
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_blob(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Path((name, digest)): Path<(String, String)>,
|
||||||
|
) -> Response {
|
||||||
|
if let Err(e) = validate_docker_name(&name) {
|
||||||
|
return (StatusCode::BAD_REQUEST, e.to_string()).into_response();
|
||||||
|
}
|
||||||
|
if let Err(e) = validate_digest(&digest) {
|
||||||
|
return (StatusCode::BAD_REQUEST, e.to_string()).into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let key = format!("docker/{}/blobs/{}", name, digest);
|
||||||
|
match state.storage.delete(&key).await {
|
||||||
|
Ok(()) => {
|
||||||
|
state.audit.log(AuditEntry::new(
|
||||||
|
"delete",
|
||||||
|
"api",
|
||||||
|
&format!("{}@{}", name, &digest[..19.min(digest.len())]),
|
||||||
|
"docker",
|
||||||
|
"blob",
|
||||||
|
));
|
||||||
|
state.repo_index.invalidate("docker");
|
||||||
|
tracing::info!(name = %name, digest = %digest, "Docker blob deleted");
|
||||||
|
StatusCode::ACCEPTED.into_response()
|
||||||
|
}
|
||||||
|
Err(crate::storage::StorageError::NotFound) => (
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Json(json!({
|
||||||
|
"errors": [{
|
||||||
|
"code": "BLOB_UNKNOWN",
|
||||||
|
"message": "blob unknown to registry",
|
||||||
|
"detail": { "digest": digest }
|
||||||
|
}]
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Namespace handlers (for two-segment names like library/alpine)
|
// Namespace handlers (for two-segment names like library/alpine)
|
||||||
// These combine ns/name into a single name and delegate to the main handlers
|
// These combine ns/name into a single name and delegate to the main handlers
|
||||||
@@ -573,8 +709,25 @@ async fn list_tags_ns(
|
|||||||
list_tags(state, Path(full_name)).await
|
list_tags(state, Path(full_name)).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn delete_manifest_ns(
|
||||||
|
state: State<Arc<AppState>>,
|
||||||
|
Path((ns, name, reference)): Path<(String, String, String)>,
|
||||||
|
) -> Response {
|
||||||
|
let full_name = format!("{}/{}", ns, name);
|
||||||
|
delete_manifest(state, Path((full_name, reference))).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_blob_ns(
|
||||||
|
state: State<Arc<AppState>>,
|
||||||
|
Path((ns, name, digest)): Path<(String, String, String)>,
|
||||||
|
) -> Response {
|
||||||
|
let full_name = format!("{}/{}", ns, name);
|
||||||
|
delete_blob(state, Path((full_name, digest))).await
|
||||||
|
}
|
||||||
|
|
||||||
/// Fetch a blob from an upstream Docker registry
|
/// Fetch a blob from an upstream Docker registry
|
||||||
async fn fetch_blob_from_upstream(
|
async fn fetch_blob_from_upstream(
|
||||||
|
client: &reqwest::Client,
|
||||||
upstream_url: &str,
|
upstream_url: &str,
|
||||||
name: &str,
|
name: &str,
|
||||||
digest: &str,
|
digest: &str,
|
||||||
@@ -588,13 +741,13 @@ async fn fetch_blob_from_upstream(
|
|||||||
digest
|
digest
|
||||||
);
|
);
|
||||||
|
|
||||||
let client = reqwest::Client::builder()
|
|
||||||
.timeout(Duration::from_secs(timeout))
|
|
||||||
.build()
|
|
||||||
.map_err(|_| ())?;
|
|
||||||
|
|
||||||
// First try without auth
|
// First try without auth
|
||||||
let response = client.get(&url).send().await.map_err(|_| ())?;
|
let response = client
|
||||||
|
.get(&url)
|
||||||
|
.timeout(Duration::from_secs(timeout))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|_| ())?;
|
||||||
|
|
||||||
let response = if response.status() == reqwest::StatusCode::UNAUTHORIZED {
|
let response = if response.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||||
// Get Www-Authenticate header and fetch token
|
// Get Www-Authenticate header and fetch token
|
||||||
@@ -631,6 +784,7 @@ async fn fetch_blob_from_upstream(
|
|||||||
/// Fetch a manifest from an upstream Docker registry
|
/// Fetch a manifest from an upstream Docker registry
|
||||||
/// Returns (manifest_bytes, content_type)
|
/// Returns (manifest_bytes, content_type)
|
||||||
async fn fetch_manifest_from_upstream(
|
async fn fetch_manifest_from_upstream(
|
||||||
|
client: &reqwest::Client,
|
||||||
upstream_url: &str,
|
upstream_url: &str,
|
||||||
name: &str,
|
name: &str,
|
||||||
reference: &str,
|
reference: &str,
|
||||||
@@ -646,13 +800,6 @@ async fn fetch_manifest_from_upstream(
|
|||||||
|
|
||||||
tracing::debug!(url = %url, "Fetching manifest from upstream");
|
tracing::debug!(url = %url, "Fetching manifest from upstream");
|
||||||
|
|
||||||
let client = reqwest::Client::builder()
|
|
||||||
.timeout(Duration::from_secs(timeout))
|
|
||||||
.build()
|
|
||||||
.map_err(|e| {
|
|
||||||
tracing::error!(error = %e, "Failed to build HTTP client");
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// Request with Accept header for manifest types
|
// Request with Accept header for manifest types
|
||||||
let accept_header = "application/vnd.docker.distribution.manifest.v2+json, \
|
let accept_header = "application/vnd.docker.distribution.manifest.v2+json, \
|
||||||
application/vnd.docker.distribution.manifest.list.v2+json, \
|
application/vnd.docker.distribution.manifest.list.v2+json, \
|
||||||
@@ -662,6 +809,7 @@ async fn fetch_manifest_from_upstream(
|
|||||||
// First try without auth
|
// First try without auth
|
||||||
let response = client
|
let response = client
|
||||||
.get(&url)
|
.get(&url)
|
||||||
|
.timeout(Duration::from_secs(timeout))
|
||||||
.header("Accept", accept_header)
|
.header("Accept", accept_header)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -735,8 +883,16 @@ fn detect_manifest_media_type(data: &[u8]) -> String {
|
|||||||
if schema_version == 1 {
|
if schema_version == 1 {
|
||||||
return "application/vnd.docker.distribution.manifest.v1+json".to_string();
|
return "application/vnd.docker.distribution.manifest.v1+json".to_string();
|
||||||
}
|
}
|
||||||
// schemaVersion 2 without mediaType is likely docker manifest v2
|
// schemaVersion 2 without mediaType - check config.mediaType to distinguish OCI vs Docker
|
||||||
if json.get("config").is_some() {
|
if let Some(config) = json.get("config") {
|
||||||
|
if let Some(config_mt) = config.get("mediaType").and_then(|v| v.as_str()) {
|
||||||
|
if config_mt.starts_with("application/vnd.docker.") {
|
||||||
|
return "application/vnd.docker.distribution.manifest.v2+json".to_string();
|
||||||
|
}
|
||||||
|
// OCI or Helm or any non-docker config mediaType
|
||||||
|
return "application/vnd.oci.image.manifest.v1+json".to_string();
|
||||||
|
}
|
||||||
|
// No config.mediaType - assume docker v2
|
||||||
return "application/vnd.docker.distribution.manifest.v2+json".to_string();
|
return "application/vnd.docker.distribution.manifest.v2+json".to_string();
|
||||||
}
|
}
|
||||||
// If it has "manifests" array, it's an index/list
|
// If it has "manifests" array, it's an index/list
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
use crate::activity_log::{ActionType, ActivityEntry};
|
use crate::activity_log::{ActionType, ActivityEntry};
|
||||||
|
use crate::audit::AuditEntry;
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use axum::{
|
use axum::{
|
||||||
body::Bytes,
|
body::Bytes,
|
||||||
@@ -23,7 +24,6 @@ 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);
|
||||||
|
|
||||||
// Extract artifact name for logging (last 2-3 path components)
|
|
||||||
let artifact_name = path
|
let artifact_name = path
|
||||||
.split('/')
|
.split('/')
|
||||||
.rev()
|
.rev()
|
||||||
@@ -34,7 +34,6 @@ async fn download(State(state): State<Arc<AppState>>, Path(path): Path<String>)
|
|||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join("/");
|
.join("/");
|
||||||
|
|
||||||
// Try local storage first
|
|
||||||
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_download("maven");
|
||||||
state.metrics.record_cache_hit();
|
state.metrics.record_cache_hit();
|
||||||
@@ -44,14 +43,16 @@ async fn download(State(state): State<Arc<AppState>>, Path(path): Path<String>)
|
|||||||
"maven",
|
"maven",
|
||||||
"CACHE",
|
"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_url 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).await {
|
||||||
Ok(data) => {
|
Ok(data) => {
|
||||||
state.metrics.record_download("maven");
|
state.metrics.record_download("maven");
|
||||||
state.metrics.record_cache_miss();
|
state.metrics.record_cache_miss();
|
||||||
@@ -61,8 +62,10 @@ async fn download(State(state): State<Arc<AppState>>, Path(path): Path<String>)
|
|||||||
"maven",
|
"maven",
|
||||||
"PROXY",
|
"PROXY",
|
||||||
));
|
));
|
||||||
|
state
|
||||||
|
.audit
|
||||||
|
.log(AuditEntry::new("proxy_fetch", "api", "", "maven", ""));
|
||||||
|
|
||||||
// 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();
|
||||||
@@ -70,6 +73,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,
|
||||||
@@ -86,7 +91,6 @@ async fn upload(
|
|||||||
) -> StatusCode {
|
) -> StatusCode {
|
||||||
let key = format!("maven/{}", path);
|
let key = format!("maven/{}", path);
|
||||||
|
|
||||||
// Extract artifact name for logging
|
|
||||||
let artifact_name = path
|
let artifact_name = path
|
||||||
.split('/')
|
.split('/')
|
||||||
.rev()
|
.rev()
|
||||||
@@ -106,20 +110,28 @@ async fn upload(
|
|||||||
"maven",
|
"maven",
|
||||||
"LOCAL",
|
"LOCAL",
|
||||||
));
|
));
|
||||||
|
state
|
||||||
|
.audit
|
||||||
|
.log(AuditEntry::new("push", "api", "", "maven", ""));
|
||||||
|
state.repo_index.invalidate("maven");
|
||||||
StatusCode::CREATED
|
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,
|
||||||
|
url: &str,
|
||||||
|
timeout_secs: u64,
|
||||||
|
) -> Result<Vec<u8>, ()> {
|
||||||
|
let response = client
|
||||||
|
.get(url)
|
||||||
.timeout(Duration::from_secs(timeout_secs))
|
.timeout(Duration::from_secs(timeout_secs))
|
||||||
.build()
|
.send()
|
||||||
|
.await
|
||||||
.map_err(|_| ())?;
|
.map_err(|_| ())?;
|
||||||
|
|
||||||
let response = client.get(url).send().await.map_err(|_| ())?;
|
|
||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
return Err(());
|
return Err(());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
use crate::activity_log::{ActionType, ActivityEntry};
|
use crate::activity_log::{ActionType, ActivityEntry};
|
||||||
|
use crate::audit::AuditEntry;
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use axum::{
|
use axum::{
|
||||||
body::Bytes,
|
body::Bytes,
|
||||||
@@ -19,7 +20,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 {
|
||||||
@@ -33,14 +33,12 @@ async fn handle_request(State(state): State<Arc<AppState>>, Path(path): Path<Str
|
|||||||
format!("npm/{}/metadata.json", path)
|
format!("npm/{}/metadata.json", path)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Extract package name for logging
|
|
||||||
let package_name = if is_tarball {
|
let package_name = if is_tarball {
|
||||||
path.split("/-/").next().unwrap_or(&path).to_string()
|
path.split("/-/").next().unwrap_or(&path).to_string()
|
||||||
} else {
|
} else {
|
||||||
path.clone()
|
path.clone()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Try local storage first
|
|
||||||
if let Ok(data) = state.storage.get(&key).await {
|
if let Ok(data) = state.storage.get(&key).await {
|
||||||
if is_tarball {
|
if is_tarball {
|
||||||
state.metrics.record_download("npm");
|
state.metrics.record_download("npm");
|
||||||
@@ -51,21 +49,19 @@ async fn handle_request(State(state): State<Arc<AppState>>, Path(path): Path<Str
|
|||||||
"npm",
|
"npm",
|
||||||
"CACHE",
|
"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)
|
|
||||||
} else {
|
|
||||||
// Metadata URL: https://registry.npmjs.org/package
|
|
||||||
format!("{}/{}", proxy_url.trim_end_matches('/'), path)
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Ok(data) = fetch_from_proxy(&url, state.config.npm.proxy_timeout).await {
|
if let Ok(data) =
|
||||||
|
fetch_from_proxy(&state.http_client, &url, state.config.npm.proxy_timeout).await
|
||||||
|
{
|
||||||
if is_tarball {
|
if is_tarball {
|
||||||
state.metrics.record_download("npm");
|
state.metrics.record_download("npm");
|
||||||
state.metrics.record_cache_miss();
|
state.metrics.record_cache_miss();
|
||||||
@@ -75,9 +71,11 @@ async fn handle_request(State(state): State<Arc<AppState>>, Path(path): Path<Str
|
|||||||
"npm",
|
"npm",
|
||||||
"PROXY",
|
"PROXY",
|
||||||
));
|
));
|
||||||
|
state
|
||||||
|
.audit
|
||||||
|
.log(AuditEntry::new("proxy_fetch", "api", "", "npm", ""));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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();
|
||||||
@@ -85,6 +83,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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,14 +94,18 @@ 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,
|
||||||
|
url: &str,
|
||||||
|
timeout_secs: u64,
|
||||||
|
) -> Result<Vec<u8>, ()> {
|
||||||
|
let response = client
|
||||||
|
.get(url)
|
||||||
.timeout(Duration::from_secs(timeout_secs))
|
.timeout(Duration::from_secs(timeout_secs))
|
||||||
.build()
|
.send()
|
||||||
|
.await
|
||||||
.map_err(|_| ())?;
|
.map_err(|_| ())?;
|
||||||
|
|
||||||
let response = client.get(url).send().await.map_err(|_| ())?;
|
|
||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
return Err(());
|
return Err(());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
use crate::activity_log::{ActionType, ActivityEntry};
|
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},
|
||||||
@@ -85,7 +86,9 @@ async fn package_versions(
|
|||||||
if let Some(proxy_url) = &state.config.pypi.proxy {
|
if let Some(proxy_url) = &state.config.pypi.proxy {
|
||||||
let url = format!("{}/{}/", proxy_url.trim_end_matches('/'), normalized);
|
let url = format!("{}/{}/", proxy_url.trim_end_matches('/'), normalized);
|
||||||
|
|
||||||
if let Ok(html) = fetch_package_page(&url, state.config.pypi.proxy_timeout).await {
|
if let Ok(html) =
|
||||||
|
fetch_package_page(&state.http_client, &url, state.config.pypi.proxy_timeout).await
|
||||||
|
{
|
||||||
// Rewrite URLs in the HTML to point to our registry
|
// Rewrite URLs in the HTML to point to our registry
|
||||||
let rewritten = rewrite_pypi_links(&html, &normalized);
|
let rewritten = rewrite_pypi_links(&html, &normalized);
|
||||||
return (StatusCode::OK, Html(rewritten)).into_response();
|
return (StatusCode::OK, Html(rewritten)).into_response();
|
||||||
@@ -113,6 +116,9 @@ async fn download_file(
|
|||||||
"pypi",
|
"pypi",
|
||||||
"CACHE",
|
"CACHE",
|
||||||
));
|
));
|
||||||
|
state
|
||||||
|
.audit
|
||||||
|
.log(AuditEntry::new("cache_hit", "api", "", "pypi", ""));
|
||||||
|
|
||||||
let content_type = if filename.ends_with(".whl") {
|
let content_type = if filename.ends_with(".whl") {
|
||||||
"application/zip"
|
"application/zip"
|
||||||
@@ -130,10 +136,22 @@ async fn download_file(
|
|||||||
// First, fetch the package page to find the actual download URL
|
// First, fetch the package page to find the actual download URL
|
||||||
let page_url = format!("{}/{}/", proxy_url.trim_end_matches('/'), normalized);
|
let page_url = format!("{}/{}/", proxy_url.trim_end_matches('/'), normalized);
|
||||||
|
|
||||||
if let Ok(html) = fetch_package_page(&page_url, state.config.pypi.proxy_timeout).await {
|
if let Ok(html) = fetch_package_page(
|
||||||
|
&state.http_client,
|
||||||
|
&page_url,
|
||||||
|
state.config.pypi.proxy_timeout,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
// Find the URL for this specific file
|
// Find the URL for this specific file
|
||||||
if let Some(file_url) = find_file_url(&html, &filename) {
|
if let Some(file_url) = find_file_url(&html, &filename) {
|
||||||
if let Ok(data) = fetch_file(&file_url, state.config.pypi.proxy_timeout).await {
|
if let Ok(data) = fetch_file(
|
||||||
|
&state.http_client,
|
||||||
|
&file_url,
|
||||||
|
state.config.pypi.proxy_timeout,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
state.metrics.record_download("pypi");
|
state.metrics.record_download("pypi");
|
||||||
state.metrics.record_cache_miss();
|
state.metrics.record_cache_miss();
|
||||||
state.activity.push(ActivityEntry::new(
|
state.activity.push(ActivityEntry::new(
|
||||||
@@ -142,6 +160,9 @@ async fn download_file(
|
|||||||
"pypi",
|
"pypi",
|
||||||
"PROXY",
|
"PROXY",
|
||||||
));
|
));
|
||||||
|
state
|
||||||
|
.audit
|
||||||
|
.log(AuditEntry::new("proxy_fetch", "api", "", "pypi", ""));
|
||||||
|
|
||||||
// Cache in local storage
|
// Cache in local storage
|
||||||
let storage = state.storage.clone();
|
let storage = state.storage.clone();
|
||||||
@@ -151,6 +172,8 @@ async fn download_file(
|
|||||||
let _ = storage.put(&key_clone, &data_clone).await;
|
let _ = storage.put(&key_clone, &data_clone).await;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
state.repo_index.invalidate("pypi");
|
||||||
|
|
||||||
let content_type = if filename.ends_with(".whl") {
|
let content_type = if filename.ends_with(".whl") {
|
||||||
"application/zip"
|
"application/zip"
|
||||||
} else if filename.ends_with(".tar.gz") || filename.ends_with(".tgz") {
|
} else if filename.ends_with(".tar.gz") || filename.ends_with(".tgz") {
|
||||||
@@ -175,14 +198,14 @@ fn normalize_name(name: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch package page from upstream
|
/// Fetch package page from upstream
|
||||||
async fn fetch_package_page(url: &str, timeout_secs: u64) -> Result<String, ()> {
|
async fn fetch_package_page(
|
||||||
let client = reqwest::Client::builder()
|
client: &reqwest::Client,
|
||||||
.timeout(Duration::from_secs(timeout_secs))
|
url: &str,
|
||||||
.build()
|
timeout_secs: u64,
|
||||||
.map_err(|_| ())?;
|
) -> Result<String, ()> {
|
||||||
|
|
||||||
let response = client
|
let response = client
|
||||||
.get(url)
|
.get(url)
|
||||||
|
.timeout(Duration::from_secs(timeout_secs))
|
||||||
.header("Accept", "text/html")
|
.header("Accept", "text/html")
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -196,14 +219,14 @@ async fn fetch_package_page(url: &str, timeout_secs: u64) -> Result<String, ()>
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch file from upstream
|
/// Fetch file from upstream
|
||||||
async fn fetch_file(url: &str, timeout_secs: u64) -> Result<Vec<u8>, ()> {
|
async fn fetch_file(client: &reqwest::Client, url: &str, timeout_secs: u64) -> Result<Vec<u8>, ()> {
|
||||||
let client = reqwest::Client::builder()
|
let response = client
|
||||||
|
.get(url)
|
||||||
.timeout(Duration::from_secs(timeout_secs))
|
.timeout(Duration::from_secs(timeout_secs))
|
||||||
.build()
|
.send()
|
||||||
|
.await
|
||||||
.map_err(|_| ())?;
|
.map_err(|_| ())?;
|
||||||
|
|
||||||
let response = client.get(url).send().await.map_err(|_| ())?;
|
|
||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
return Err(());
|
return Err(());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
use crate::activity_log::{ActionType, ActivityEntry};
|
use crate::activity_log::{ActionType, ActivityEntry};
|
||||||
|
use crate::audit::AuditEntry;
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use axum::{
|
use axum::{
|
||||||
body::Bytes,
|
body::Bytes,
|
||||||
@@ -35,6 +36,9 @@ async fn download(State(state): State<Arc<AppState>>, Path(path): Path<String>)
|
|||||||
state
|
state
|
||||||
.activity
|
.activity
|
||||||
.push(ActivityEntry::new(ActionType::Pull, path, "raw", "LOCAL"));
|
.push(ActivityEntry::new(ActionType::Pull, path, "raw", "LOCAL"));
|
||||||
|
state
|
||||||
|
.audit
|
||||||
|
.log(AuditEntry::new("pull", "api", "", "raw", ""));
|
||||||
|
|
||||||
// Guess content type from extension
|
// Guess content type from extension
|
||||||
let content_type = guess_content_type(&key);
|
let content_type = guess_content_type(&key);
|
||||||
@@ -72,6 +76,9 @@ async fn upload(
|
|||||||
state
|
state
|
||||||
.activity
|
.activity
|
||||||
.push(ActivityEntry::new(ActionType::Push, path, "raw", "LOCAL"));
|
.push(ActivityEntry::new(ActionType::Push, path, "raw", "LOCAL"));
|
||||||
|
state
|
||||||
|
.audit
|
||||||
|
.log(AuditEntry::new("push", "api", "", "raw", ""));
|
||||||
StatusCode::CREATED.into_response()
|
StatusCode::CREATED.into_response()
|
||||||
}
|
}
|
||||||
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
@@ -11,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 {
|
||||||
@@ -20,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
|
||||||
@@ -44,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!(
|
||||||
@@ -67,6 +103,7 @@ impl TokenStore {
|
|||||||
expires_at,
|
expires_at,
|
||||||
last_used: None,
|
last_used: None,
|
||||||
description,
|
description,
|
||||||
|
role,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Save to file
|
// Save to file
|
||||||
@@ -81,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);
|
||||||
}
|
}
|
||||||
@@ -121,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
|
||||||
@@ -210,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_"));
|
||||||
@@ -222,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]
|
||||||
@@ -252,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]));
|
||||||
|
|
||||||
@@ -272,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);
|
||||||
@@ -291,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];
|
||||||
|
|
||||||
@@ -320,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);
|
||||||
@@ -336,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();
|
||||||
@@ -352,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");
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
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::activity_log::ActivityEntry;
|
||||||
|
use crate::repo_index::RepoInfo;
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use crate::Storage;
|
use crate::Storage;
|
||||||
use axum::{
|
use axum::{
|
||||||
@@ -24,14 +25,6 @@ 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,
|
||||||
@@ -115,44 +108,44 @@ pub struct MountPoint {
|
|||||||
// ============ 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> {
|
pub async fn api_dashboard(State(state): State<Arc<AppState>>) -> Json<DashboardResponse> {
|
||||||
let registry_stats = get_registry_stats(&state.storage).await;
|
// 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 total storage size
|
// Calculate sizes from cached index
|
||||||
let all_keys = state.storage.list("").await;
|
let docker_size: u64 = docker_repos.iter().map(|r| r.size).sum();
|
||||||
let mut total_storage: u64 = 0;
|
let maven_size: u64 = maven_repos.iter().map(|r| r.size).sum();
|
||||||
let mut docker_size: u64 = 0;
|
let npm_size: u64 = npm_repos.iter().map(|r| r.size).sum();
|
||||||
let mut maven_size: u64 = 0;
|
let cargo_size: u64 = cargo_repos.iter().map(|r| r.size).sum();
|
||||||
let mut npm_size: u64 = 0;
|
let pypi_size: u64 = pypi_repos.iter().map(|r| r.size).sum();
|
||||||
let mut cargo_size: u64 = 0;
|
let total_storage = docker_size + maven_size + npm_size + cargo_size + pypi_size;
|
||||||
let mut pypi_size: u64 = 0;
|
|
||||||
|
|
||||||
for key in &all_keys {
|
let total_artifacts = docker_repos.len()
|
||||||
if let Some(meta) = state.storage.stat(key).await {
|
+ maven_repos.len()
|
||||||
total_storage += meta.size;
|
+ npm_repos.len()
|
||||||
if key.starts_with("docker/") {
|
+ cargo_repos.len()
|
||||||
docker_size += meta.size;
|
+ pypi_repos.len();
|
||||||
} else if key.starts_with("maven/") {
|
|
||||||
maven_size += meta.size;
|
|
||||||
} else if key.starts_with("npm/") {
|
|
||||||
npm_size += meta.size;
|
|
||||||
} else if key.starts_with("cargo/") {
|
|
||||||
cargo_size += meta.size;
|
|
||||||
} else if key.starts_with("pypi/") {
|
|
||||||
pypi_size += meta.size;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let total_artifacts = registry_stats.docker
|
|
||||||
+ registry_stats.maven
|
|
||||||
+ registry_stats.npm
|
|
||||||
+ registry_stats.cargo
|
|
||||||
+ registry_stats.pypi;
|
|
||||||
|
|
||||||
let global_stats = GlobalStats {
|
let global_stats = GlobalStats {
|
||||||
downloads: state.metrics.downloads.load(Ordering::Relaxed),
|
downloads: state.metrics.downloads.load(Ordering::Relaxed),
|
||||||
@@ -165,35 +158,35 @@ pub async fn api_dashboard(State(state): State<Arc<AppState>>) -> Json<Dashboard
|
|||||||
let registry_card_stats = vec![
|
let registry_card_stats = vec![
|
||||||
RegistryCardStats {
|
RegistryCardStats {
|
||||||
name: "docker".to_string(),
|
name: "docker".to_string(),
|
||||||
artifact_count: registry_stats.docker,
|
artifact_count: docker_repos.len(),
|
||||||
downloads: state.metrics.get_registry_downloads("docker"),
|
downloads: state.metrics.get_registry_downloads("docker"),
|
||||||
uploads: state.metrics.get_registry_uploads("docker"),
|
uploads: state.metrics.get_registry_uploads("docker"),
|
||||||
size_bytes: docker_size,
|
size_bytes: docker_size,
|
||||||
},
|
},
|
||||||
RegistryCardStats {
|
RegistryCardStats {
|
||||||
name: "maven".to_string(),
|
name: "maven".to_string(),
|
||||||
artifact_count: registry_stats.maven,
|
artifact_count: maven_repos.len(),
|
||||||
downloads: state.metrics.get_registry_downloads("maven"),
|
downloads: state.metrics.get_registry_downloads("maven"),
|
||||||
uploads: state.metrics.get_registry_uploads("maven"),
|
uploads: state.metrics.get_registry_uploads("maven"),
|
||||||
size_bytes: maven_size,
|
size_bytes: maven_size,
|
||||||
},
|
},
|
||||||
RegistryCardStats {
|
RegistryCardStats {
|
||||||
name: "npm".to_string(),
|
name: "npm".to_string(),
|
||||||
artifact_count: registry_stats.npm,
|
artifact_count: npm_repos.len(),
|
||||||
downloads: state.metrics.get_registry_downloads("npm"),
|
downloads: state.metrics.get_registry_downloads("npm"),
|
||||||
uploads: 0,
|
uploads: 0,
|
||||||
size_bytes: npm_size,
|
size_bytes: npm_size,
|
||||||
},
|
},
|
||||||
RegistryCardStats {
|
RegistryCardStats {
|
||||||
name: "cargo".to_string(),
|
name: "cargo".to_string(),
|
||||||
artifact_count: registry_stats.cargo,
|
artifact_count: cargo_repos.len(),
|
||||||
downloads: state.metrics.get_registry_downloads("cargo"),
|
downloads: state.metrics.get_registry_downloads("cargo"),
|
||||||
uploads: 0,
|
uploads: 0,
|
||||||
size_bytes: cargo_size,
|
size_bytes: cargo_size,
|
||||||
},
|
},
|
||||||
RegistryCardStats {
|
RegistryCardStats {
|
||||||
name: "pypi".to_string(),
|
name: "pypi".to_string(),
|
||||||
artifact_count: registry_stats.pypi,
|
artifact_count: pypi_repos.len(),
|
||||||
downloads: state.metrics.get_registry_downloads("pypi"),
|
downloads: state.metrics.get_registry_downloads("pypi"),
|
||||||
uploads: 0,
|
uploads: 0,
|
||||||
size_bytes: pypi_size,
|
size_bytes: pypi_size,
|
||||||
@@ -244,15 +237,8 @@ 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(
|
||||||
@@ -283,20 +269,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()
|
||||||
};
|
};
|
||||||
@@ -341,7 +320,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;
|
||||||
|
|
||||||
@@ -393,6 +374,7 @@ 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;
|
||||||
|
|
||||||
@@ -571,6 +553,7 @@ pub async fn get_docker_detail(state: &AppState, 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;
|
||||||
|
|
||||||
@@ -630,6 +613,7 @@ 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;
|
||||||
|
|
||||||
@@ -747,6 +731,7 @@ pub async fn get_npm_detail(storage: &Storage, name: &str) -> PackageDetail {
|
|||||||
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;
|
||||||
|
|
||||||
@@ -814,6 +799,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;
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,12 @@
|
|||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
mod api;
|
mod api;
|
||||||
mod components;
|
pub mod components;
|
||||||
pub mod i18n;
|
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, Query, State},
|
extract::{Path, Query, State},
|
||||||
@@ -25,6 +26,15 @@ struct LangQuery {
|
|||||||
lang: Option<String>,
|
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 {
|
fn extract_lang(query: &Query<LangQuery>, cookie_header: Option<&str>) -> Lang {
|
||||||
// Priority: query param > cookie > default
|
// Priority: query param > cookie > default
|
||||||
if let Some(ref lang) = query.lang {
|
if let Some(ref lang) = query.lang {
|
||||||
@@ -44,6 +54,23 @@ fn extract_lang(query: &Query<LangQuery>, cookie_header: Option<&str>) -> Lang {
|
|||||||
Lang::default()
|
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
|
||||||
@@ -85,18 +112,23 @@ async fn dashboard(
|
|||||||
// Docker pages
|
// Docker pages
|
||||||
async fn docker_list(
|
async fn docker_list(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Query(query): Query<LangQuery>,
|
Query(query): Query<ListQuery>,
|
||||||
headers: axum::http::HeaderMap,
|
headers: axum::http::HeaderMap,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let lang = extract_lang(
|
let lang = extract_lang_from_list(&query, headers.get("cookie").and_then(|v| v.to_str().ok()));
|
||||||
&Query(query),
|
let page = query.page.unwrap_or(1).max(1);
|
||||||
headers.get("cookie").and_then(|v| v.to_str().ok()),
|
let limit = query.limit.unwrap_or(DEFAULT_PAGE_SIZE).min(100);
|
||||||
);
|
|
||||||
let repos = get_docker_repos(&state.storage).await;
|
let all_repos = state.repo_index.get("docker", &state.storage).await;
|
||||||
Html(render_registry_list(
|
let (repos, total) = paginate(&all_repos, page, limit);
|
||||||
|
|
||||||
|
Html(render_registry_list_paginated(
|
||||||
"docker",
|
"docker",
|
||||||
"Docker Registry",
|
"Docker Registry",
|
||||||
&repos,
|
&repos,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
lang,
|
lang,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
@@ -118,18 +150,23 @@ async fn docker_detail(
|
|||||||
// Maven pages
|
// Maven pages
|
||||||
async fn maven_list(
|
async fn maven_list(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Query(query): Query<LangQuery>,
|
Query(query): Query<ListQuery>,
|
||||||
headers: axum::http::HeaderMap,
|
headers: axum::http::HeaderMap,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let lang = extract_lang(
|
let lang = extract_lang_from_list(&query, headers.get("cookie").and_then(|v| v.to_str().ok()));
|
||||||
&Query(query),
|
let page = query.page.unwrap_or(1).max(1);
|
||||||
headers.get("cookie").and_then(|v| v.to_str().ok()),
|
let limit = query.limit.unwrap_or(DEFAULT_PAGE_SIZE).min(100);
|
||||||
);
|
|
||||||
let repos = get_maven_repos(&state.storage).await;
|
let all_repos = state.repo_index.get("maven", &state.storage).await;
|
||||||
Html(render_registry_list(
|
let (repos, total) = paginate(&all_repos, page, limit);
|
||||||
|
|
||||||
|
Html(render_registry_list_paginated(
|
||||||
"maven",
|
"maven",
|
||||||
"Maven Repository",
|
"Maven Repository",
|
||||||
&repos,
|
&repos,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
lang,
|
lang,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
@@ -151,15 +188,25 @@ async fn maven_detail(
|
|||||||
// npm pages
|
// npm pages
|
||||||
async fn npm_list(
|
async fn npm_list(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Query(query): Query<LangQuery>,
|
Query(query): Query<ListQuery>,
|
||||||
headers: axum::http::HeaderMap,
|
headers: axum::http::HeaderMap,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let lang = extract_lang(
|
let lang = extract_lang_from_list(&query, headers.get("cookie").and_then(|v| v.to_str().ok()));
|
||||||
&Query(query),
|
let page = query.page.unwrap_or(1).max(1);
|
||||||
headers.get("cookie").and_then(|v| v.to_str().ok()),
|
let limit = query.limit.unwrap_or(DEFAULT_PAGE_SIZE).min(100);
|
||||||
);
|
|
||||||
let packages = get_npm_packages(&state.storage).await;
|
let all_packages = state.repo_index.get("npm", &state.storage).await;
|
||||||
Html(render_registry_list("npm", "npm Registry", &packages, lang))
|
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(
|
||||||
@@ -179,18 +226,23 @@ async fn npm_detail(
|
|||||||
// Cargo pages
|
// Cargo pages
|
||||||
async fn cargo_list(
|
async fn cargo_list(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Query(query): Query<LangQuery>,
|
Query(query): Query<ListQuery>,
|
||||||
headers: axum::http::HeaderMap,
|
headers: axum::http::HeaderMap,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let lang = extract_lang(
|
let lang = extract_lang_from_list(&query, headers.get("cookie").and_then(|v| v.to_str().ok()));
|
||||||
&Query(query),
|
let page = query.page.unwrap_or(1).max(1);
|
||||||
headers.get("cookie").and_then(|v| v.to_str().ok()),
|
let limit = query.limit.unwrap_or(DEFAULT_PAGE_SIZE).min(100);
|
||||||
);
|
|
||||||
let crates = get_cargo_crates(&state.storage).await;
|
let all_crates = state.repo_index.get("cargo", &state.storage).await;
|
||||||
Html(render_registry_list(
|
let (crates, total) = paginate(&all_crates, page, limit);
|
||||||
|
|
||||||
|
Html(render_registry_list_paginated(
|
||||||
"cargo",
|
"cargo",
|
||||||
"Cargo Registry",
|
"Cargo Registry",
|
||||||
&crates,
|
&crates,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
lang,
|
lang,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
@@ -212,18 +264,23 @@ async fn cargo_detail(
|
|||||||
// PyPI pages
|
// PyPI pages
|
||||||
async fn pypi_list(
|
async fn pypi_list(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Query(query): Query<LangQuery>,
|
Query(query): Query<ListQuery>,
|
||||||
headers: axum::http::HeaderMap,
|
headers: axum::http::HeaderMap,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let lang = extract_lang(
|
let lang = extract_lang_from_list(&query, headers.get("cookie").and_then(|v| v.to_str().ok()));
|
||||||
&Query(query),
|
let page = query.page.unwrap_or(1).max(1);
|
||||||
headers.get("cookie").and_then(|v| v.to_str().ok()),
|
let limit = query.limit.unwrap_or(DEFAULT_PAGE_SIZE).min(100);
|
||||||
);
|
|
||||||
let packages = get_pypi_packages(&state.storage).await;
|
let all_packages = state.repo_index.get("pypi", &state.storage).await;
|
||||||
Html(render_registry_list(
|
let (packages, total) = paginate(&all_packages, page, limit);
|
||||||
|
|
||||||
|
Html(render_registry_list_paginated(
|
||||||
"pypi",
|
"pypi",
|
||||||
"PyPI Repository",
|
"PyPI Repository",
|
||||||
&packages,
|
&packages,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
lang,
|
lang,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
use super::api::{DashboardResponse, DockerDetail, MavenDetail, PackageDetail, RepoInfo};
|
use super::api::{DashboardResponse, DockerDetail, MavenDetail, PackageDetail};
|
||||||
use super::components::*;
|
use super::components::*;
|
||||||
use super::i18n::{get_translations, Lang};
|
use super::i18n::{get_translations, Lang};
|
||||||
|
use crate::repo_index::RepoInfo;
|
||||||
|
|
||||||
/// Renders the main dashboard page with dark theme
|
/// Renders the main dashboard page with dark theme
|
||||||
pub fn render_dashboard(data: &DashboardResponse, lang: Lang) -> String {
|
pub fn render_dashboard(data: &DashboardResponse, lang: Lang) -> String {
|
||||||
@@ -166,6 +167,7 @@ fn format_relative_time(timestamp: &chrono::DateTime<chrono::Utc>) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Renders a registry list page (docker, maven, npm, cargo, pypi)
|
/// Renders a registry list page (docker, maven, npm, cargo, pypi)
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn render_registry_list(
|
pub fn render_registry_list(
|
||||||
registry_type: &str,
|
registry_type: &str,
|
||||||
title: &str,
|
title: &str,
|
||||||
@@ -276,6 +278,220 @@ pub fn render_registry_list(
|
|||||||
layout_dark(title, &content, Some(registry_type), "", lang)
|
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, lang: Lang) -> String {
|
pub fn render_docker_detail(name: &str, detail: &DockerDetail, lang: Lang) -> String {
|
||||||
let _t = get_translations(lang);
|
let _t = get_translations(lang);
|
||||||
|
|||||||
@@ -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"] }
|
||||||
|
|||||||
Reference in New Issue
Block a user