commit 48b834a62adbd4a1417b3a2b5fbf9848574114a5 Author: Lukasz Raczylo Date: Fri Jan 2 23:14:23 2026 +0000 Initial commit diff --git a/.github/workflows/autoupdate.yaml b/.github/workflows/autoupdate.yaml new file mode 100644 index 0000000..dda0328 --- /dev/null +++ b/.github/workflows/autoupdate.yaml @@ -0,0 +1,19 @@ +name: Autoupdate go.mod and go.sum + +on: + workflow_dispatch: + schedule: + - cron: "0 3 * * *" + +permissions: + contents: write + actions: write + pull-requests: write + +jobs: + autoupdate: + uses: lukaszraczylo/shared-actions/.github/workflows/go-autoupdate.yaml@main + with: + go-version: ">=1.25" + release-workflow: "release.yaml" + secrets: inherit diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml new file mode 100644 index 0000000..cd9ba69 --- /dev/null +++ b/.github/workflows/pr.yaml @@ -0,0 +1,16 @@ +name: Pull Request + +on: + pull_request: + branches: + - main + push: + branches: + - "**" + - "!main" + +jobs: + pr-checks: + uses: lukaszraczylo/shared-actions/.github/workflows/go-pr.yaml@main + with: + go-version: "1.25" diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..b611811 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,96 @@ +name: Release + +on: + workflow_dispatch: + push: + paths-ignore: + - "**.md" + - "**/release.yaml" + - "frontend/**" + - "deployments/**" + - "docs/**" + branches: + - main + +permissions: + id-token: write + contents: write + packages: write + deployments: write + +jobs: + release: + uses: lukaszraczylo/shared-actions/.github/workflows/go-release.yaml@main + with: + go-version: "1.25" + docker-enabled: true + secrets: inherit + + benchmark: + name: Publish Benchmarks + needs: release + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + ref: main + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: "1.25" + + - name: Run benchmarks + run: go test -bench=. -benchmem ./... -run=^# | tee output.txt + + - name: Store benchmark result + uses: benchmark-action/github-action-benchmark@v1 + with: + tool: "go" + output-file-path: output.txt + fail-on-alert: true + github-token: ${{ secrets.GITHUB_TOKEN }} + comment-on-alert: true + summary-always: true + auto-push: false + benchmark-data-dir-path: "docs/bench" + + - name: Push benchmark results + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add docs/bench + git diff --staged --quiet || git commit -m "Update benchmark results" + git push origin main + + publish-helm-chart: + name: Publish Helm Chart + needs: release + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Get release version + id: version + run: | + VERSION=$(git describe --tags --abbrev=0 2>/dev/null || echo "0.0.0") + VERSION=${VERSION#v} + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Trigger helm-charts release + env: + GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + run: | + gh api repos/lukaszraczylo/helm-charts/dispatches \ + -f event_type=release-chart \ + -f client_payload[chart_name]=gohoarder \ + -f client_payload[version]=${{ steps.version.outputs.version }} \ + -f client_payload[source_repo]=lukaszraczylo/gohoarder \ + -f client_payload[chart_path]=helm/gohoarder diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..76914b8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,82 @@ +# Binaries +bin/ +*.exe +*.exe~ +*.dll +*.so +*.dylib +/gohoarder + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool +*.out +coverage.txt +coverage.html + +# Dependency directories +vendor/ + +# Go workspace file +go.work + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Config files (keep example) +config.yaml +*.local.yaml + +# Databases +*.db +*.db-shm +*.db-wal + +# Cache +/var/cache/gohoarder/ +/cache/ + +# Logs +*.log + +# Temporary files +tmp/ +temp/ + +# Security +.env +*.pem +*.key +*.crt + +# Build artifacts +dist/ +build/ + +# Node modules (for frontend) +web/node_modules/ +web/dist/ + +# Test fixtures +tests/fixtures/temp/ + +# Markdown files (except README.md) +*.md +!README.md + +/gohoarder +*.log +*.out +test-go-proxy +frontend/node_modules +data/storage +*.pid diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..99a8d22 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,205 @@ +version: 2 + +# Project metadata +project_name: gohoarder + +# Pre-release hooks +before: + hooks: + - go mod tidy + # Generate semantic version if not provided via git tag + # This script can be used by CI/CD to inject custom versions + # Usage: export GORELEASER_CURRENT_TAG=$(./script/generate-version.sh) + # - ./script/generate-version.sh + +# Build configuration +builds: + - id: gohoarder + main: ./cmd/gohoarder + binary: gohoarder + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + - windows + goarch: + - amd64 + - arm64 + ldflags: + - -s -w + - -X github.com/lukaszraczylo/gohoarder/internal/version.Version={{.Version}} + - -X github.com/lukaszraczylo/gohoarder/internal/version.GitCommit={{.ShortCommit}} + - -X github.com/lukaszraczylo/gohoarder/internal/version.BuildTime={{.Date}} + +# Archives for releases +archives: + - id: default + name_template: >- + {{ .ProjectName }}_ + {{- title .Os }}_ + {{- if eq .Arch "amd64" }}x86_64 + {{- else if eq .Arch "386" }}i386 + {{- else }}{{ .Arch }}{{ end }} + {{- if .Arm }}v{{ .Arm }}{{ end }} + formats: + - tar.gz + - zip + format_overrides: + - goos: windows + formats: + - zip + files: + - README.md + - LICENSE + - config.yaml.example + +# Checksum +checksum: + name_template: 'checksums.txt' + algorithm: sha256 + +# Snapshot configuration +snapshot: + version_template: "{{ incpatch .Version }}-next" + +# Changelog +changelog: + sort: asc + filters: + exclude: + - '^docs:' + - '^test:' + - '^ci:' + - '^chore:' + - '^Merge' + - '^WIP' + - '^Update go.mod' + - 'README' + +# GitHub release configuration +release: + github: + owner: lukaszraczylo + name: gohoarder + name_template: "version {{.Version}}" + draft: false + prerelease: auto + +# Docker images (v2 - modern syntax) +dockers_v2: + # 1. Application Engine - Main GoHoarder server + - id: gohoarder-server + ids: + - gohoarder + images: + - ghcr.io/lukaszraczylo/gohoarder-server + tags: + - "{{ .Version }}" + - latest + platforms: + - linux/amd64 + - linux/arm64 + dockerfile: Dockerfile.server + labels: + org.opencontainers.image.title: GoHoarder Server + org.opencontainers.image.description: Universal package cache proxy server + org.opencontainers.image.url: https://github.com/lukaszraczylo/gohoarder + org.opencontainers.image.source: https://github.com/lukaszraczylo/gohoarder + org.opencontainers.image.version: "{{ .Version }}" + org.opencontainers.image.created: "{{ .Date }}" + org.opencontainers.image.revision: "{{ .FullCommit }}" + extra_files: + - config.yaml.example + + # 2. Website - Frontend Dashboard + - id: gohoarder-frontend + ids: + - gohoarder + images: + - ghcr.io/lukaszraczylo/gohoarder-frontend + tags: + - "{{ .Version }}" + - latest + platforms: + - linux/amd64 + - linux/arm64 + dockerfile: Dockerfile.frontend + labels: + org.opencontainers.image.title: GoHoarder Frontend + org.opencontainers.image.description: GoHoarder web dashboard + org.opencontainers.image.url: https://github.com/lukaszraczylo/gohoarder + org.opencontainers.image.source: https://github.com/lukaszraczylo/gohoarder + org.opencontainers.image.version: "{{ .Version }}" + org.opencontainers.image.created: "{{ .Date }}" + org.opencontainers.image.revision: "{{ .FullCommit }}" + extra_files: + - frontend + + # 3. Scanning Engine - Background scanner worker + - id: gohoarder-scanner + ids: + - gohoarder + images: + - ghcr.io/lukaszraczylo/gohoarder-scanner + tags: + - "{{ .Version }}" + - latest + platforms: + - linux/amd64 + - linux/arm64 + dockerfile: Dockerfile.scanner + labels: + org.opencontainers.image.title: GoHoarder Scanner + org.opencontainers.image.description: GoHoarder vulnerability scanning engine + org.opencontainers.image.url: https://github.com/lukaszraczylo/gohoarder + org.opencontainers.image.source: https://github.com/lukaszraczylo/gohoarder + org.opencontainers.image.version: "{{ .Version }}" + org.opencontainers.image.created: "{{ .Date }}" + org.opencontainers.image.revision: "{{ .FullCommit }}" + extra_files: + - config.yaml.example + + # 4. Gateway - Nginx reverse proxy for unified deployment + - id: gohoarder-gateway + ids: + - gohoarder + images: + - ghcr.io/lukaszraczylo/gohoarder-gateway + tags: + - "{{ .Version }}" + - latest + platforms: + - linux/amd64 + - linux/arm64 + dockerfile: Dockerfile.gateway + labels: + org.opencontainers.image.title: GoHoarder Gateway + org.opencontainers.image.description: Nginx reverse proxy for unified GoHoarder deployment + org.opencontainers.image.url: https://github.com/lukaszraczylo/gohoarder + org.opencontainers.image.source: https://github.com/lukaszraczylo/gohoarder + org.opencontainers.image.version: "{{ .Version }}" + org.opencontainers.image.created: "{{ .Date }}" + org.opencontainers.image.revision: "{{ .FullCommit }}" + +# Artifact signing with cosign +signs: + - cmd: cosign + signature: "${artifact}.sigstore.json" + args: + - sign-blob + - "--bundle=${signature}" + - "${artifact}" + - "--yes" + artifacts: checksum + output: true + +# Docker image signing with cosign +docker_signs: + - cmd: cosign + artifacts: manifests + output: true + args: + - sign + - "${artifact}@${digest}" + - "--yes" diff --git a/Dockerfile.frontend b/Dockerfile.frontend new file mode 100644 index 0000000..c4f3175 --- /dev/null +++ b/Dockerfile.frontend @@ -0,0 +1,100 @@ +# Website - Frontend Dashboard +# Build stage +FROM node:20-alpine AS builder + +WORKDIR /build + +# Copy frontend source +COPY frontend/package.json frontend/pnpm-lock.yaml ./ +COPY frontend/ ./ + +# Install pnpm and dependencies +RUN npm install -g pnpm && \ + pnpm install --frozen-lockfile + +# Build the frontend +RUN pnpm run build + +# Production stage +FROM nginx:alpine + +# Install envsubst for runtime configuration +RUN apk add --no-cache gettext + +# Copy built frontend +COPY --from=builder /build/dist /usr/share/nginx/html + +# Create runtime config injection script +RUN cat > /docker-entrypoint.d/40-inject-config.sh <<'EOF' +#!/bin/sh +set -e + +# Create runtime configuration file +cat > /usr/share/nginx/html/config.js < /usr/share/nginx/html/config.tmp.js +mv /usr/share/nginx/html/config.tmp.js /usr/share/nginx/html/config.js + +echo "Runtime configuration injected:" +cat /usr/share/nginx/html/config.js +EOF + +RUN chmod +x /docker-entrypoint.d/40-inject-config.sh + +# Copy nginx configuration +RUN cat > /etc/nginx/conf.d/default.conf <<'EOF' +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # Compression + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript; + + # SPA routing + location / { + try_files $uri $uri/ /index.html; + } + + # Runtime configuration endpoint + location = /config.js { + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Pragma "no-cache"; + add_header Expires "0"; + } + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } +} +EOF + +# Expose port +EXPOSE 80 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD wget --quiet --tries=1 --spider http://localhost/ || exit 1 + +# Environment variables with defaults +ENV API_BASE_URL=/api \ + APP_VERSION=unknown \ + APP_NAME=GoHoarder + +CMD ["nginx", "-g", "daemon off;"] diff --git a/Dockerfile.gateway b/Dockerfile.gateway new file mode 100644 index 0000000..4a5a5bd --- /dev/null +++ b/Dockerfile.gateway @@ -0,0 +1,197 @@ +# Gateway - Nginx reverse proxy for unified deployment +# Routes traffic between frontend and backend under single vhost +FROM nginx:alpine + +# Install envsubst for runtime configuration +RUN apk add --no-cache gettext + +# Copy nginx configuration template +COPY <<'EOF' /etc/nginx/templates/default.conf.template +# Upstream servers +upstream backend { + server ${BACKEND_HOST}:${BACKEND_PORT}; + keepalive 32; +} + +upstream frontend { + server ${FRONTEND_HOST}:${FRONTEND_PORT}; + keepalive 32; +} + +# Rate limiting zones +limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s; +limit_req_zone $binary_remote_addr zone=download_limit:10m rate=5r/s; + +# Cache configuration +proxy_cache_path /var/cache/nginx/static levels=1:2 keys_zone=static_cache:10m max_size=100m inactive=60m use_temp_path=off; + +server { + listen 80; + server_name ${SERVER_NAME}; + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # Client body size for package uploads + client_max_body_size 500M; + client_body_timeout 300s; + + # Logging + access_log /var/log/nginx/access.log combined; + error_log /var/log/nginx/error.log warn; + + # API endpoints - proxy to backend + location /api/ { + # Rate limiting + limit_req zone=api_limit burst=20 nodelay; + + # Proxy settings + proxy_pass http://backend/; + proxy_http_version 1.1; + + # Headers + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; + + # Connection reuse + proxy_set_header Connection ""; + + # Timeouts for long-running operations + proxy_connect_timeout 60s; + proxy_send_timeout 300s; + proxy_read_timeout 300s; + + # Buffer settings + proxy_buffering on; + proxy_buffer_size 4k; + proxy_buffers 8 4k; + proxy_busy_buffers_size 8k; + } + + # Health check endpoint + location /health { + proxy_pass http://backend/health; + proxy_http_version 1.1; + proxy_set_header Connection ""; + access_log off; + } + + # Metrics endpoint (optional - may want to restrict access) + location /metrics { + # Uncomment to restrict to internal networks + # allow 10.0.0.0/8; + # allow 172.16.0.0/12; + # allow 192.168.0.0/16; + # deny all; + + proxy_pass http://backend/metrics; + proxy_http_version 1.1; + proxy_set_header Connection ""; + } + + # Package download endpoints with rate limiting + location ~ ^/(npm|pypi|go)/ { + limit_req zone=download_limit burst=10 nodelay; + + proxy_pass http://backend; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Connection ""; + + # Extended timeouts for package downloads + proxy_connect_timeout 60s; + proxy_send_timeout 600s; + proxy_read_timeout 600s; + + # Large buffer for package downloads + proxy_buffering on; + proxy_buffer_size 128k; + proxy_buffers 4 256k; + proxy_busy_buffers_size 256k; + } + + # Frontend - serve SPA + location / { + proxy_pass http://frontend; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Connection ""; + + # Cache static assets + proxy_cache static_cache; + proxy_cache_valid 200 1h; + proxy_cache_bypass $http_cache_control; + add_header X-Cache-Status $upstream_cache_status; + } + + # WebSocket support (if needed for future features) + location /ws/ { + proxy_pass http://backend/ws/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # WebSocket timeouts + proxy_connect_timeout 7d; + proxy_send_timeout 7d; + proxy_read_timeout 7d; + } +} + +# HTTPS server (uncomment and configure SSL certificates) +# server { +# listen 443 ssl http2; +# server_name ${SERVER_NAME}; +# +# ssl_certificate /etc/nginx/ssl/cert.pem; +# ssl_certificate_key /etc/nginx/ssl/key.pem; +# +# # SSL configuration +# ssl_protocols TLSv1.2 TLSv1.3; +# ssl_ciphers HIGH:!aNULL:!MD5; +# ssl_prefer_server_ciphers on; +# ssl_session_cache shared:SSL:10m; +# ssl_session_timeout 10m; +# +# # Include all location blocks from above +# # ... (copy from HTTP server block) +# } +EOF + +# Create cache directory +RUN mkdir -p /var/cache/nginx/static && \ + chown -R nginx:nginx /var/cache/nginx + +# Expose port +EXPOSE 80 443 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD wget --quiet --tries=1 --spider http://localhost/health || exit 1 + +# Environment variables with defaults +ENV BACKEND_HOST=gohoarder-server \ + BACKEND_PORT=8080 \ + FRONTEND_HOST=gohoarder-frontend \ + FRONTEND_PORT=80 \ + SERVER_NAME=_ + +# Use nginx with template substitution +CMD ["/bin/sh", "-c", "envsubst '$$BACKEND_HOST $$BACKEND_PORT $$FRONTEND_HOST $$FRONTEND_PORT $$SERVER_NAME' < /etc/nginx/templates/default.conf.template > /etc/nginx/conf.d/default.conf && nginx -g 'daemon off;'"] diff --git a/Dockerfile.scanner b/Dockerfile.scanner new file mode 100644 index 0000000..00c116b --- /dev/null +++ b/Dockerfile.scanner @@ -0,0 +1,59 @@ +# Scanning Engine - Background Scanner Worker +ARG TARGETOS +ARG TARGETARCH + +FROM alpine:latest + +# Install scanning tools and runtime dependencies +RUN apk add --no-cache \ + ca-certificates \ + tzdata \ + git \ + curl \ + wget \ + bash \ + && update-ca-certificates + +# Install Trivy for container scanning +RUN curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin + +# Install Grype for vulnerability scanning +RUN curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin + +# Create non-root user +RUN addgroup -g 1000 scanner && \ + adduser -D -u 1000 -G scanner scanner + +# Create necessary directories +RUN mkdir -p /data/cache /data/scans && \ + chown -R scanner:scanner /data + +# Copy binary (from platform-specific path) +ARG TARGETOS +ARG TARGETARCH +COPY ${TARGETOS}/${TARGETARCH}/gohoarder /usr/local/bin/gohoarder +RUN chmod +x /usr/local/bin/gohoarder + +# Copy example config +COPY config.yaml.example /etc/gohoarder/config.yaml.example + +WORKDIR /data +USER scanner + +# Expose metrics port +EXPOSE 9091 + +# Health check +HEALTHCHECK --interval=60s --timeout=30s --start-period=10s --retries=3 \ + CMD ["/usr/local/bin/gohoarder", "version"] || exit 1 + +# Environment variables for scanner mode +ENV SCANNER_MODE=true \ + SCANNER_WORKERS=4 \ + SCANNER_INTERVAL=300 + +# Run the scanner in background mode +# Note: You may need to add a scanner-specific command to your CLI +# For now, this assumes the serve command can run in scanner mode +ENTRYPOINT ["/usr/local/bin/gohoarder"] +CMD ["serve", "--scanner-only"] diff --git a/Dockerfile.server b/Dockerfile.server new file mode 100644 index 0000000..089eb34 --- /dev/null +++ b/Dockerfile.server @@ -0,0 +1,44 @@ +# Application Engine - GoHoarder Server +ARG TARGETOS +ARG TARGETARCH + +FROM alpine:latest + +# Install runtime dependencies +RUN apk add --no-cache \ + ca-certificates \ + tzdata \ + && update-ca-certificates + +# Create non-root user +RUN addgroup -g 1000 gohoarder && \ + adduser -D -u 1000 -G gohoarder gohoarder + +# Create necessary directories +RUN mkdir -p /data/cache /data/metadata && \ + chown -R gohoarder:gohoarder /data + +# Copy binary (from platform-specific path) +ARG TARGETOS +ARG TARGETARCH +COPY ${TARGETOS}/${TARGETARCH}/gohoarder /usr/local/bin/gohoarder +RUN chmod +x /usr/local/bin/gohoarder + +# Copy example config +COPY config.yaml.example /etc/gohoarder/config.yaml.example + +WORKDIR /data +USER gohoarder + +# Expose ports +# 8080: Main proxy port +# 9090: Metrics/health port +EXPOSE 8080 9090 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD ["/usr/local/bin/gohoarder", "version"] || exit 1 + +# Run the server +ENTRYPOINT ["/usr/local/bin/gohoarder"] +CMD ["serve"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..745270f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Lukasz Raczylo + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..19c9b16 --- /dev/null +++ b/Makefile @@ -0,0 +1,132 @@ +.PHONY: help build test test-coverage run clean install lint fmt vet + +# Variables +BINARY_NAME=gohoarder +BINARY_PATH=bin/$(BINARY_NAME) +CMD_PATH=./cmd/gohoarder +# Generate semantic version using script, fallback to 'dev' if script fails +VERSION?=$(shell ./script/generate-version.sh 2>/dev/null || echo "dev") +GIT_COMMIT=$(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") +BUILD_TIME=$(shell date -u '+%Y-%m-%dT%H:%M:%SZ') +LDFLAGS=-ldflags "-X github.com/lukaszraczylo/gohoarder/internal/version.Version=$(VERSION) \ + -X github.com/lukaszraczylo/gohoarder/internal/version.GitCommit=$(GIT_COMMIT) \ + -X github.com/lukaszraczylo/gohoarder/internal/version.BuildTime=$(BUILD_TIME)" + +help: ## Display this help screen + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' + +build: ## Build the binary + @echo "Building $(BINARY_NAME)..." + @mkdir -p bin + @go build -buildvcs=false $(LDFLAGS) -o $(BINARY_PATH) $(CMD_PATH) + @echo "Binary built: $(BINARY_PATH)" + +build-all: ## Build for all platforms + @echo "Building for all platforms..." + @mkdir -p bin + GOOS=linux GOARCH=amd64 go build -buildvcs=false $(LDFLAGS) -o bin/$(BINARY_NAME)-linux-amd64 $(CMD_PATH) + GOOS=linux GOARCH=arm64 go build -buildvcs=false $(LDFLAGS) -o bin/$(BINARY_NAME)-linux-arm64 $(CMD_PATH) + GOOS=darwin GOARCH=amd64 go build -buildvcs=false $(LDFLAGS) -o bin/$(BINARY_NAME)-darwin-amd64 $(CMD_PATH) + GOOS=darwin GOARCH=arm64 go build -buildvcs=false $(LDFLAGS) -o bin/$(BINARY_NAME)-darwin-arm64 $(CMD_PATH) + @echo "All binaries built" + +test: ## Run tests + @echo "Running tests..." + @go test -v ./... + +test-coverage: ## Run tests with coverage + @echo "Running tests with coverage..." + @go test -coverprofile=coverage.out ./... + @go tool cover -html=coverage.out -o coverage.html + @echo "Coverage report: coverage.html" + +test-race: ## Run tests with race detector + @echo "Running tests with race detector..." + @go test -race ./... + +bench: ## Run benchmarks + @echo "Running benchmarks..." + @go test -bench=. -benchmem ./... + +run: build ## Build and run both backend and frontend for development + @echo "Starting $(BINARY_NAME) and frontend in development mode..." + @echo "" + @echo "Backend will run on: http://localhost:8080 (configured in config.yaml)" + @echo "Frontend will run on: http://localhost:5173 (configured in frontend/.env)" + @echo "" + @echo "To change ports:" + @echo " - Backend: Edit 'server.port' in config.yaml" + @echo " - Frontend: Edit 'VITE_PORT' and 'VITE_BACKEND_URL' in frontend/.env" + @echo "" + @trap 'kill 0' SIGINT; \ + $(BINARY_PATH) serve & \ + cd frontend && pnpm dev & \ + wait + +run-backend: build ## Build and run only the backend server + @echo "Starting $(BINARY_NAME)..." + @$(BINARY_PATH) serve + +run-dev: ## Run with example config + @echo "Starting $(BINARY_NAME) in development mode..." + @go run $(CMD_PATH) serve --config config.yaml.example + +clean: ## Clean build artifacts + @echo "Cleaning..." + @rm -rf bin/ + @rm -f coverage.out coverage.html + @rm -f *.db *.db-shm *.db-wal + @echo "Clean complete" + +clean-db: ## Clean all local cache and database files (from config.yaml paths) + @echo "WARNING: This will delete all cached packages and scan results!" + @echo "Paths from config.yaml:" + @echo " - ./data/storage (package cache)" + @echo " - ./data/gohoarder.db (metadata database)" + @echo " - /tmp/trivy (Trivy cache)" + @echo "" + @read -p "Are you sure you want to continue? [y/N] " confirm && [ "$$confirm" = "y" ] || exit 1 + @echo "Cleaning database and cache..." + @rm -rf ./data/storage + @rm -f ./data/gohoarder.db ./data/gohoarder.db-shm ./data/gohoarder.db-wal + @rm -rf /tmp/trivy + @echo "Database and cache cleaned successfully" + +install: build ## Install the binary + @echo "Installing $(BINARY_NAME)..." + @cp $(BINARY_PATH) $(GOPATH)/bin/ + @echo "Installed to $(GOPATH)/bin/$(BINARY_NAME)" + +lint: ## Run linters + @echo "Running linters..." + @go vet ./... + @which golangci-lint > /dev/null || (echo "golangci-lint not installed" && exit 1) + @golangci-lint run + +fmt: ## Format code + @echo "Formatting code..." + @gofmt -s -w . + @which goimports > /dev/null && goimports -w . || true + +vet: ## Run go vet + @go vet ./... + +tidy: ## Tidy dependencies + @go mod tidy + +docker-build: ## Build Docker image + @echo "Building Docker image..." + @docker build -t $(BINARY_NAME):$(VERSION) . + +docker-run: docker-build ## Run Docker container + @echo "Running Docker container..." + @docker run -p 8080:8080 $(BINARY_NAME):$(VERSION) + +test-packages: ## Download test packages through gohoarder proxy (clean + vulnerable packages) + @echo "Reading backend port from config.yaml..." + @PORT=$$(grep "^ port:" config.yaml | awk '{print $$2}'); \ + if [ -z "$$PORT" ]; then PORT=8080; fi; \ + export GOHOARDER_URL="http://localhost:$$PORT"; \ + ./script/test-packages.sh + +.DEFAULT_GOAL := help diff --git a/README.md b/README.md new file mode 100644 index 0000000..d27fa9c --- /dev/null +++ b/README.md @@ -0,0 +1,1261 @@ +# GoHoarder + +**A universal, security-first caching proxy for package managers with automated vulnerability scanning.** + +GoHoarder is a transparent pass-through cache proxy that supports npm, pip, and Go modules. It caches packages locally, scans them for vulnerabilities using multiple security scanners, and blocks packages that exceed your security thresholdsβ€”all without requiring changes to your existing workflows. + +[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) +[![Go Version](https://img.shields.io/badge/go-1.22+-blue.svg)](https://golang.org) + +--- + +## ✨ Features + +### πŸ”’ **Security-First** +- **Automated vulnerability scanning** with multiple scanners (Trivy, OSV, Grype, npm-audit, pip-audit, GitHub Advisory Database, govulncheck) +- **Configurable blocking thresholds** by severity (CRITICAL, HIGH, MODERATE, LOW) +- **CVE bypass system** for managing false positives or accepted risks +- **Real-time scanning** before package delivery - blocks vulnerable packages on **first download** +- **403 Forbidden responses** for blocked packages (not 502 errors) +- **No fallback mechanisms** - security is enforced across all package managers + +### πŸš€ **Performance** +- **Intelligent caching** reduces bandwidth and speeds up builds +- **Scan-once, serve-many** - packages scanned once, results cached +- **Background rescanning** keeps security assessments up-to-date +- **Multi-backend storage** (filesystem, S3, SMB/CIFS) +- **Connection pooling** and **rate limiting** for upstream registries +- **Circuit breaker** pattern for resilience + +### πŸ“Š **Observability** +- **Web dashboard** with Vue 3 frontend for package management +- **Detailed vulnerability reports** with CVE information and severity breakdown +- **Download analytics** and usage statistics +- **Health check endpoints** for monitoring +- **Prometheus metrics** integration +- **Structured JSON logging** with zerolog + +### 🌐 **Universal Support** +- **npm/pnpm/yarn** - Full npm registry protocol support +- **pip** - PyPI Simple API (PEP 503) implementation +- **Go modules** - GOPROXY protocol with sumdb support +- **Transparent proxying** - Works with existing tools without modification + +--- + +## πŸ“‹ Table of Contents + +- [Quick Start](#-quick-start) +- [Installation](#-installation) +- [Configuration](#-configuration) +- [Package Manager Setup](#-package-manager-setup) +- [Private Repository Support](#-private-repository-support) +- [Kubernetes Deployment](#️-kubernetes-deployment) +- [Security Scanning](#-security-scanning) +- [Web Dashboard](#-web-dashboard) +- [API Reference](#-api-reference) +- [Architecture](#-architecture) +- [Development](#-development) +- [Troubleshooting](#-troubleshooting) +- [Contributing](#-contributing) + +--- + +## πŸš€ Quick Start + +### 1. Install and Run + +```bash +# Clone the repository +git clone https://github.com/lukaszraczylo/gohoarder.git +cd gohoarder + +# Build +make build + +# Run (starts both backend and frontend) +make run +``` + +GoHoarder will start on **http://localhost:8080** + +### 2. Configure Your Package Manager + +**npm/pnpm:** +```bash +npm config set registry http://localhost:8080/npm +``` + +**pip:** +```bash +pip install --index-url http://localhost:8080/pypi/simple/ \ + --trusted-host localhost \ + package-name +``` + +**Go:** +```bash +# ⚠️ IMPORTANT: Do NOT use ",direct" fallback - it bypasses security! +export GOPROXY="http://localhost:8080/go" +``` + +### 3. Install Packages Normally + +```bash +# npm +npm install axios + +# pip +pip install requests + +# Go +go get github.com/gin-gonic/gin +``` + +**Vulnerable packages are automatically blocked:** +``` +npm install axios@0.21.1 +❌ ERROR: 403 Forbidden - Package has 3 HIGH vulnerabilities (threshold: 0) +``` + +--- + +## πŸ“¦ Installation + +### Prerequisites + +- **Go 1.22+** for building the backend +- **Node.js 18+** and **pnpm** for building the frontend +- **Security scanners** (optional, but recommended): + - [Trivy](https://github.com/aquasecurity/trivy) - Container and package scanning + - [Grype](https://github.com/anchore/grype) - Vulnerability scanner + - [govulncheck](https://pkg.go.dev/golang.org/x/vuln/cmd/govulncheck) - Go-specific scanner + +### Building from Source + +```bash +# Clone repository +git clone https://github.com/lukaszraczylo/gohoarder.git +cd gohoarder + +# Build backend only +make build + +# Build backend + frontend +make build-all + +# Run with frontend +make run + +# Run backend only +./bin/gohoarder serve +``` + +### Install Security Scanners + +**Trivy:** +```bash +# macOS +brew install aquasecurity/trivy/trivy + +# Linux +wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | sudo apt-key add - +echo "deb https://aquasecurity.github.io/trivy-repo/deb $(lsb_release -sc) main" | sudo tee -a /etc/apt/sources.list.d/trivy.list +sudo apt-get update && sudo apt-get install trivy +``` + +**Grype:** +```bash +# macOS +brew tap anchore/grype +brew install grype + +# Linux +curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin +``` + +**govulncheck:** +```bash +go install golang.org/x/vuln/cmd/govulncheck@latest +``` + +--- + +## βš™οΈ Configuration + +### Configuration File + +Create `config.yaml` in the project root: + +```yaml +server: + port: 8080 + host: "0.0.0.0" + read_timeout: "5m" + write_timeout: "5m" + +storage: + backend: "filesystem" # Options: filesystem, s3, smb + path: "./data/storage" + +metadata: + backend: "sqlite" # Options: sqlite, postgresql + path: "./data/gohoarder.db" + +security: + enabled: true + update_db_on_startup: true + + # Block packages based on vulnerability counts + block_thresholds: + critical: 0 # Block if ANY critical vulnerabilities + high: 0 # Block if ANY high vulnerabilities + medium: 5 # Block if MORE than 5 medium vulnerabilities + low: -1 # -1 = don't block based on low severity + + # Or block based on highest severity present + block_on_severity: "high" # Options: critical, high, moderate, low, none + + scanners: + trivy: + enabled: true + osv: + enabled: true + grype: + enabled: true + govulncheck: + enabled: true + npm_audit: + enabled: true + pip_audit: + enabled: true + ghsa: + enabled: true + +cache: + default_ttl: 86400 # 24 hours in seconds + +logging: + level: "info" # debug, info, warn, error + format: "json" + +upstream: + npm: "https://registry.npmjs.org" + pypi: "https://pypi.org/simple" + go: "https://proxy.golang.org" +``` + +### Environment Variables + +All configuration values can be overridden with environment variables: + +```bash +# Server +export GOHOARDER_SERVER_PORT=8080 +export GOHOARDER_SERVER_HOST="0.0.0.0" + +# Storage +export GOHOARDER_STORAGE_BACKEND="filesystem" +export GOHOARDER_STORAGE_PATH="./data/storage" + +# Security +export GOHOARDER_SECURITY_ENABLED=true +export GOHOARDER_SECURITY_BLOCK_CRITICAL=0 +export GOHOARDER_SECURITY_BLOCK_HIGH=0 + +# Logging +export GOHOARDER_LOG_LEVEL="info" +``` + +--- + +## πŸ”§ Package Manager Setup + +### npm / pnpm / yarn + +#### ⚠️ Security Notice + +**All three package managers enforce security correctly - no fallback mechanisms.** + +#### Configuration + +**npm:** +```bash +npm config set registry http://localhost:8080/npm +``` + +**pnpm:** +```bash +pnpm config set registry http://localhost:8080/npm +``` + +**yarn (v4+):** +```yaml +# .yarnrc.yml +npmRegistryServer: "http://localhost:8080/npm" +unsafeHttpWhitelist: + - localhost +``` + +#### Usage + +```bash +# Install packages normally +npm install express +pnpm add react +yarn add lodash + +# Vulnerable packages will fail with 403 Forbidden +npm install axios@0.21.1 +# ❌ ERROR: 403 Forbidden - Package has 3 HIGH vulnerabilities (threshold: 0) +``` + +#### Clear Cache + +```bash +npm cache clean --force +pnpm store prune +yarn cache clean --all +``` + +--- + +### Python (pip) + +#### Configuration + +**Per-install:** +```bash +pip install --index-url http://localhost:8080/pypi/simple/ \ + --trusted-host localhost \ + package-name +``` + +**Global configuration:** +```ini +# ~/.pip/pip.conf (Linux/macOS) +# %APPDATA%\pip\pip.ini (Windows) + +[global] +index-url = http://localhost:8080/pypi/simple/ +trusted-host = localhost +``` + +#### Usage + +```bash +# Install packages normally +pip install requests + +# Vulnerable packages will fail +pip install flask==0.12.0 +# ❌ ERROR: HTTP error 403 while getting ... +# ❌ ERROR: 403 Client Error: Forbidden +``` + +#### Clear Cache + +```bash +pip cache purge +``` + +--- + +### Go Modules + +#### ⚠️ CRITICAL: No Fallback Configuration + +**The `,direct` fallback completely bypasses security scanning and must NEVER be used!** + +**❌ INSECURE (bypasses security):** +```bash +export GOPROXY="http://localhost:8080/go,direct" +# ^^^^^^^ NEVER USE THIS! +``` + +**βœ… SECURE (enforces scanning):** +```bash +export GOPROXY="http://localhost:8080/go" +``` + +**Persistent configuration:** +```bash +# Add to ~/.bashrc, ~/.zshrc, or ~/.profile +echo 'export GOPROXY="http://localhost:8080/go"' >> ~/.bashrc +source ~/.bashrc +``` + +#### Usage + +```bash +# Download packages normally +go get github.com/gin-gonic/gin@v1.7.0 +go mod download + +# Vulnerable packages will fail with 403 Forbidden +# (if vulnerability databases detect issues) +``` + +#### Clear Cache + +```bash +go clean -modcache +``` + +## πŸ” Private Repository Support + +GoHoarder supports private packages through **automatic credential forwarding** - no server-side configuration needed! Your existing authentication automatically works through the proxy. + +### How It Works + +1. **Client Authentication** β†’ Your package manager sends credentials to GoHoarder +2. **Credential Forwarding** β†’ GoHoarder forwards credentials to upstream registry +3. **Package Caching** β†’ Packages are cached with credential-aware keys +4. **Access Validation** β†’ For private packages, credentials are validated on every request (cached for 5 minutes) +5. **Multi-User Isolation** β†’ Different users with different credentials get separate cache entries + +### Security Model + +- **Per-Request Validation**: Private packages verify credentials with upstream before serving +- **Credential Isolation**: Each user's credentials create separate cache entries +- **Validation Caching**: Validation results cached for 5 minutes to reduce upstream load +- **Access Control**: 403 Forbidden if credentials are invalid or missing + +### Setup + +#### npm Private Packages + +**GitHub Packages:** + +```bash +# Configure .npmrc for GitHub Packages +echo "@yourorg:registry=https://npm.pkg.github.com" >> ~/.npmrc +echo "//npm.pkg.github.com/:_authToken=YOUR_GITHUB_TOKEN" >> ~/.npmrc + +# Use GoHoarder proxy +npm config set registry http://localhost:8080/npm +npm install @yourorg/private-package +``` + +**GitLab Packages:** + +```bash +# Configure .npmrc for GitLab +echo "@yourgroup:registry=https://gitlab.com/api/v4/packages/npm/" >> ~/.npmrc +echo "//gitlab.com/api/v4/packages/npm/:_authToken=YOUR_GITLAB_TOKEN" >> ~/.npmrc + +# Use GoHoarder proxy +npm config set registry http://localhost:8080/npm +``` + +**Private Artifactory / Nexus:** + +```bash +# Configure .npmrc with Basic auth +echo "//your-registry.com/:_auth=BASE64_CREDENTIALS" >> ~/.npmrc + +# Use GoHoarder proxy +npm config set registry http://localhost:8080/npm +``` + +#### PyPI Private Packages + +**Private PyPI Index:** + +```bash +# Configure pip with credentials in URL +pip config set global.index-url http://localhost:8080/pypi/simple + +# Install with credentials in request (pip handles auth) +pip install --index-url http://username:password@localhost:8080/pypi/simple private-package //trufflehog:ignore +``` + +**AWS CodeArtifact:** + +```bash +# Get CodeArtifact token +export CODEARTIFACT_AUTH_TOKEN=$(aws codeartifact get-authorization-token --domain your-domain --query authorizationToken --output text) + +# Use with pip +pip install --index-url http://aws:$CODEARTIFACT_AUTH_TOKEN@localhost:8080/pypi/simple private-package +``` + +**GitHub Packages (PyPI):** + +```bash +# Configure pip to use GitHub Packages through GoHoarder +pip install --index-url http://USERNAME:GITHUB_TOKEN@localhost:8080/pypi/simple your-private-package //trufflehog:ignore +``` + +#### Go Private Modules + +**GitHub Private Repositories:** + +```bash +# Configure .netrc with GitHub credentials +cat >> ~/.netrc <> ~/.netrc <> $GITHUB_ENV + + - name: Build + run: go build ./... +``` + +### Dockerfile + +```dockerfile +FROM golang:1.21-alpine + +# Configure proxy +ENV GOPROXY=http://gohoarder.default.svc.cluster.local:8080/go,direct +ENV GONOPROXY=none +ENV GONOSUMDB=github.com/yourcompany + +WORKDIR /app +COPY . . +RUN go build -o myapp ./cmd/myapp + +CMD ["/app/myapp"] +``` + +## Support + +For issues or questions: +- Check logs: `kubectl logs -l app=gohoarder` +- Enable debug logging: Set `logging.level: debug` in ConfigMap +- Review credential patterns in Secret diff --git a/deployments/kubernetes/configmap-config.yaml b/deployments/kubernetes/configmap-config.yaml new file mode 100644 index 0000000..d349f70 --- /dev/null +++ b/deployments/kubernetes/configmap-config.yaml @@ -0,0 +1,54 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: gohoarder-config + namespace: default +data: + config.yaml: | + server: + host: "0.0.0.0" + port: 8080 + read_timeout: 30s + write_timeout: 30s + + cache: + max_size_bytes: 10737418240 # 10GB + default_ttl: 24h + cleanup_interval: 1h + + storage: + backend: filesystem + path: /var/lib/gohoarder/cache + + metadata: + backend: sqlite + connection: /var/lib/gohoarder/gohoarder.db + + security: + enabled: true + providers: + - osv + - github + severity_threshold: medium + block_on_vulnerability: false + rescan_interval: 24h + + handlers: + npm: + enabled: true + upstream_registry: "https://registry.npmjs.org" + + pypi: + enabled: true + upstream_index: "https://pypi.org/simple" + + go: + enabled: true + upstream_proxy: "https://proxy.golang.org" + checksum_db: "https://sum.golang.org" + # Path to git credentials file (mounted from Secret) + git_credentials_file: /etc/gohoarder/git-credentials.json + + logging: + level: info + format: json diff --git a/deployments/kubernetes/deployment-all-in-one.yaml b/deployments/kubernetes/deployment-all-in-one.yaml new file mode 100644 index 0000000..22eec32 --- /dev/null +++ b/deployments/kubernetes/deployment-all-in-one.yaml @@ -0,0 +1,502 @@ +# GoHoarder - Kubernetes Deployment (All-in-One) +# This manifest deploys all GoHoarder services under a single ingress +# +# Usage: +# kubectl create namespace gohoarder +# kubectl apply -f deployment-all-in-one.yaml -n gohoarder +# +# Prerequisites: +# - Kubernetes 1.19+ +# - Ingress controller (nginx, traefik, etc.) +# - Persistent volume provisioner +# - Optional: cert-manager for TLS certificates + +--- +# Namespace +apiVersion: v1 +kind: Namespace +metadata: + name: gohoarder + labels: + app.kubernetes.io/name: gohoarder + app.kubernetes.io/component: namespace + +--- +# ConfigMap for application configuration +apiVersion: v1 +kind: ConfigMap +metadata: + name: gohoarder-config + namespace: gohoarder + labels: + app.kubernetes.io/name: gohoarder + app.kubernetes.io/component: config +data: + # Add your configuration here or mount from a file + # config.yaml: | + # server: + # port: 8080 + # ... + +--- +# PersistentVolumeClaim for cache storage +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: gohoarder-cache + namespace: gohoarder + labels: + app.kubernetes.io/name: gohoarder + app.kubernetes.io/component: storage +spec: + accessModes: + - ReadWriteMany # Multiple pods can access for scanner + server + resources: + requests: + storage: 100Gi + # storageClassName: your-storage-class # Specify your storage class + +--- +# PersistentVolumeClaim for metadata storage +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: gohoarder-metadata + namespace: gohoarder + labels: + app.kubernetes.io/name: gohoarder + app.kubernetes.io/component: storage +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 10Gi + # storageClassName: your-storage-class + +--- +# Deployment - Application Server +apiVersion: apps/v1 +kind: Deployment +metadata: + name: gohoarder-server + namespace: gohoarder + labels: + app.kubernetes.io/name: gohoarder + app.kubernetes.io/component: server +spec: + replicas: 2 + selector: + matchLabels: + app.kubernetes.io/name: gohoarder + app.kubernetes.io/component: server + template: + metadata: + labels: + app.kubernetes.io/name: gohoarder + app.kubernetes.io/component: server + spec: + containers: + - name: server + image: ghcr.io/lukaszraczylo/gohoarder-server:latest + imagePullPolicy: Always + ports: + - name: http + containerPort: 8080 + protocol: TCP + - name: metrics + containerPort: 9090 + protocol: TCP + env: + - name: CONFIG_FILE + value: /config/config.yaml + - name: STORAGE_BACKEND + value: filesystem + - name: STORAGE_PATH + value: /data/cache + - name: DB_PATH + value: /data/metadata/gohoarder.db + - name: LOG_LEVEL + value: info + - name: LOG_FORMAT + value: json + volumeMounts: + - name: cache + mountPath: /data/cache + - name: metadata + mountPath: /data/metadata + - name: config + mountPath: /config + readOnly: true + livenessProbe: + exec: + command: + - /usr/local/bin/gohoarder + - version + initialDelaySeconds: 5 + periodSeconds: 30 + timeoutSeconds: 10 + readinessProbe: + httpGet: + path: /health + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 10 + resources: + requests: + cpu: 500m + memory: 512Mi + limits: + cpu: 2000m + memory: 2Gi + volumes: + - name: cache + persistentVolumeClaim: + claimName: gohoarder-cache + - name: metadata + persistentVolumeClaim: + claimName: gohoarder-metadata + - name: config + configMap: + name: gohoarder-config + +--- +# Service - Application Server +apiVersion: v1 +kind: Service +metadata: + name: gohoarder-server + namespace: gohoarder + labels: + app.kubernetes.io/name: gohoarder + app.kubernetes.io/component: server +spec: + type: ClusterIP + ports: + - name: http + port: 8080 + targetPort: http + protocol: TCP + - name: metrics + port: 9090 + targetPort: metrics + protocol: TCP + selector: + app.kubernetes.io/name: gohoarder + app.kubernetes.io/component: server + +--- +# Deployment - Frontend +apiVersion: apps/v1 +kind: Deployment +metadata: + name: gohoarder-frontend + namespace: gohoarder + labels: + app.kubernetes.io/name: gohoarder + app.kubernetes.io/component: frontend +spec: + replicas: 2 + selector: + matchLabels: + app.kubernetes.io/name: gohoarder + app.kubernetes.io/component: frontend + template: + metadata: + labels: + app.kubernetes.io/name: gohoarder + app.kubernetes.io/component: frontend + spec: + containers: + - name: frontend + image: ghcr.io/lukaszraczylo/gohoarder-frontend:latest + imagePullPolicy: Always + ports: + - name: http + containerPort: 80 + protocol: TCP + env: + - name: API_BASE_URL + value: /api + - name: APP_VERSION + value: "1.0.0" + - name: APP_NAME + value: GoHoarder + livenessProbe: + httpGet: + path: / + port: 80 + initialDelaySeconds: 5 + periodSeconds: 30 + readinessProbe: + httpGet: + path: / + port: 80 + initialDelaySeconds: 5 + periodSeconds: 10 + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 256Mi + +--- +# Service - Frontend +apiVersion: v1 +kind: Service +metadata: + name: gohoarder-frontend + namespace: gohoarder + labels: + app.kubernetes.io/name: gohoarder + app.kubernetes.io/component: frontend +spec: + type: ClusterIP + ports: + - name: http + port: 80 + targetPort: http + protocol: TCP + selector: + app.kubernetes.io/name: gohoarder + app.kubernetes.io/component: frontend + +--- +# Deployment - Scanner (Optional) +apiVersion: apps/v1 +kind: Deployment +metadata: + name: gohoarder-scanner + namespace: gohoarder + labels: + app.kubernetes.io/name: gohoarder + app.kubernetes.io/component: scanner +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: gohoarder + app.kubernetes.io/component: scanner + template: + metadata: + labels: + app.kubernetes.io/name: gohoarder + app.kubernetes.io/component: scanner + spec: + containers: + - name: scanner + image: ghcr.io/lukaszraczylo/gohoarder-scanner:latest + imagePullPolicy: Always + env: + - name: CONFIG_FILE + value: /config/config.yaml + - name: SCANNER_MODE + value: "true" + - name: SCANNER_WORKERS + value: "4" + - name: LOG_LEVEL + value: info + volumeMounts: + - name: cache + mountPath: /data/cache + readOnly: true + - name: metadata + mountPath: /data/metadata + - name: config + mountPath: /config + readOnly: true + resources: + requests: + cpu: 500m + memory: 1Gi + limits: + cpu: 2000m + memory: 4Gi + volumes: + - name: cache + persistentVolumeClaim: + claimName: gohoarder-cache + - name: metadata + persistentVolumeClaim: + claimName: gohoarder-metadata + - name: config + configMap: + name: gohoarder-config + +--- +# Deployment - Gateway (Nginx Reverse Proxy) +apiVersion: apps/v1 +kind: Deployment +metadata: + name: gohoarder-gateway + namespace: gohoarder + labels: + app.kubernetes.io/name: gohoarder + app.kubernetes.io/component: gateway +spec: + replicas: 2 + selector: + matchLabels: + app.kubernetes.io/name: gohoarder + app.kubernetes.io/component: gateway + template: + metadata: + labels: + app.kubernetes.io/name: gohoarder + app.kubernetes.io/component: gateway + spec: + containers: + - name: gateway + image: ghcr.io/lukaszraczylo/gohoarder-gateway:latest + imagePullPolicy: Always + ports: + - name: http + containerPort: 80 + protocol: TCP + env: + - name: BACKEND_HOST + value: gohoarder-server + - name: BACKEND_PORT + value: "8080" + - name: FRONTEND_HOST + value: gohoarder-frontend + - name: FRONTEND_PORT + value: "80" + - name: SERVER_NAME + value: hoarder.i.raczylo.com + livenessProbe: + httpGet: + path: /health + port: 80 + initialDelaySeconds: 5 + periodSeconds: 30 + readinessProbe: + httpGet: + path: /health + port: 80 + initialDelaySeconds: 5 + periodSeconds: 10 + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 256Mi + +--- +# Service - Gateway +apiVersion: v1 +kind: Service +metadata: + name: gohoarder-gateway + namespace: gohoarder + labels: + app.kubernetes.io/name: gohoarder + app.kubernetes.io/component: gateway +spec: + type: ClusterIP + ports: + - name: http + port: 80 + targetPort: http + protocol: TCP + selector: + app.kubernetes.io/name: gohoarder + app.kubernetes.io/component: gateway + +--- +# Ingress - Expose via domain +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: gohoarder + namespace: gohoarder + labels: + app.kubernetes.io/name: gohoarder + app.kubernetes.io/component: ingress + annotations: + # Nginx ingress annotations + nginx.ingress.kubernetes.io/proxy-body-size: "500m" + nginx.ingress.kubernetes.io/proxy-read-timeout: "600" + nginx.ingress.kubernetes.io/proxy-send-timeout: "600" + # Enable CORS if needed + # nginx.ingress.kubernetes.io/enable-cors: "true" + # TLS/SSL configuration (uncomment if using cert-manager) + # cert-manager.io/cluster-issuer: "letsencrypt-prod" +spec: + ingressClassName: nginx # Adjust based on your ingress controller + rules: + - host: hoarder.i.raczylo.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: gohoarder-gateway + port: + number: 80 + # Uncomment for HTTPS/TLS + # tls: + # - hosts: + # - hoarder.i.raczylo.com + # secretName: gohoarder-tls + +--- +# HorizontalPodAutoscaler - Server +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: gohoarder-server + namespace: gohoarder + labels: + app.kubernetes.io/name: gohoarder + app.kubernetes.io/component: server +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: gohoarder-server + minReplicas: 2 + maxReplicas: 10 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 70 + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: 80 + +--- +# HorizontalPodAutoscaler - Gateway +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: gohoarder-gateway + namespace: gohoarder + labels: + app.kubernetes.io/name: gohoarder + app.kubernetes.io/component: gateway +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: gohoarder-gateway + minReplicas: 2 + maxReplicas: 10 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 70 diff --git a/deployments/kubernetes/deployment.yaml b/deployments/kubernetes/deployment.yaml new file mode 100644 index 0000000..4ee481e --- /dev/null +++ b/deployments/kubernetes/deployment.yaml @@ -0,0 +1,104 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: gohoarder + namespace: default + labels: + app: gohoarder +spec: + replicas: 2 + selector: + matchLabels: + app: gohoarder + template: + metadata: + labels: + app: gohoarder + spec: + securityContext: + runAsNonRoot: true + runAsUser: 1000 + fsGroup: 1000 + + containers: + - name: gohoarder + image: gohoarder:latest + imagePullPolicy: IfNotPresent + + ports: + - name: http + containerPort: 8080 + protocol: TCP + + env: + - name: CONFIG_FILE + value: /etc/gohoarder/config.yaml + + volumeMounts: + # Configuration file + - name: config + mountPath: /etc/gohoarder/config.yaml + subPath: config.yaml + readOnly: true + + # Git credentials (pattern-based) + - name: git-credentials + mountPath: /etc/gohoarder/git-credentials.json + subPath: credentials.json + readOnly: true + + # Persistent storage for cache + - name: cache + mountPath: /var/lib/gohoarder/cache + + # Persistent storage for metadata database + - name: metadata + mountPath: /var/lib/gohoarder + + resources: + requests: + memory: "512Mi" + cpu: "250m" + limits: + memory: "2Gi" + cpu: "1000m" + + livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 10 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 + + readinessProbe: + httpGet: + path: /health/ready + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 + + volumes: + # ConfigMap with application configuration + - name: config + configMap: + name: gohoarder-config + + # Secret with git credentials + - name: git-credentials + secret: + secretName: gohoarder-git-credentials + defaultMode: 0400 # Read-only for owner + + # PersistentVolumeClaim for cache + - name: cache + persistentVolumeClaim: + claimName: gohoarder-cache-pvc + + # PersistentVolumeClaim for metadata + - name: metadata + persistentVolumeClaim: + claimName: gohoarder-metadata-pvc diff --git a/deployments/kubernetes/pvc.yaml b/deployments/kubernetes/pvc.yaml new file mode 100644 index 0000000..58c8fe3 --- /dev/null +++ b/deployments/kubernetes/pvc.yaml @@ -0,0 +1,29 @@ +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: gohoarder-cache-pvc + namespace: default +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 20Gi + # Uncomment and set your storage class if needed + # storageClassName: fast-ssd + +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: gohoarder-metadata-pvc + namespace: default +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 5Gi + # Uncomment and set your storage class if needed + # storageClassName: standard diff --git a/deployments/kubernetes/secret-git-credentials.yaml b/deployments/kubernetes/secret-git-credentials.yaml new file mode 100644 index 0000000..b3747cc --- /dev/null +++ b/deployments/kubernetes/secret-git-credentials.yaml @@ -0,0 +1,61 @@ +apiVersion: v1 +kind: Secret +metadata: + name: gohoarder-git-credentials + namespace: default +type: Opaque +stringData: + credentials.json: | + { + "credentials": [ + { + "pattern": "github.com/mycompany/*", + "host": "github.com", + "username": "oauth2", + "token": "ghp_REPLACE_WITH_YOUR_GITHUB_TOKEN", + "fallback": false + }, + { + "pattern": "github.com/external-vendor/*", + "host": "github.com", + "username": "oauth2", + "token": "ghp_REPLACE_WITH_VENDOR_TOKEN", + "fallback": false + }, + { + "pattern": "gitlab.com/backend-team/*", + "host": "gitlab.com", + "username": "oauth2", + "token": "glpat_REPLACE_WITH_GITLAB_TOKEN", + "fallback": false + }, + { + "pattern": "*", + "host": "*", + "username": "oauth2", + "token": "ghp_REPLACE_WITH_DEFAULT_READONLY_TOKEN", + "fallback": true + } + ] + } +--- +# Example using External Secrets Operator (ESO) +# Uncomment and configure if you're using ESO +# apiVersion: external-secrets.io/v1beta1 +# kind: ExternalSecret +# metadata: +# name: gohoarder-git-credentials +# namespace: default +# spec: +# refreshInterval: 1h +# secretStoreRef: +# name: vault-backend # Your SecretStore name +# kind: SecretStore +# target: +# name: gohoarder-git-credentials +# creationPolicy: Owner +# data: +# - secretKey: credentials.json +# remoteRef: +# key: secret/gohoarder/git-credentials +# property: credentials.json diff --git a/deployments/kubernetes/service.yaml b/deployments/kubernetes/service.yaml new file mode 100644 index 0000000..91f0d14 --- /dev/null +++ b/deployments/kubernetes/service.yaml @@ -0,0 +1,44 @@ +apiVersion: v1 +kind: Service +metadata: + name: gohoarder + namespace: default + labels: + app: gohoarder +spec: + type: ClusterIP + ports: + - port: 8080 + targetPort: http + protocol: TCP + name: http + selector: + app: gohoarder +--- +# Optional: Ingress for external access +# Uncomment and configure based on your ingress controller +# apiVersion: networking.k8s.io/v1 +# kind: Ingress +# metadata: +# name: gohoarder +# namespace: default +# annotations: +# nginx.ingress.kubernetes.io/proxy-body-size: "500m" +# nginx.ingress.kubernetes.io/proxy-read-timeout: "600" +# spec: +# ingressClassName: nginx +# rules: +# - host: gohoarder.example.com +# http: +# paths: +# - path: / +# pathType: Prefix +# backend: +# service: +# name: gohoarder +# port: +# name: http +# tls: +# - hosts: +# - gohoarder.example.com +# secretName: gohoarder-tls diff --git a/docker-compose.example.yaml b/docker-compose.example.yaml new file mode 100644 index 0000000..29446d0 --- /dev/null +++ b/docker-compose.example.yaml @@ -0,0 +1,151 @@ +version: '3.8' + +# GoHoarder - Unified Deployment Example +# This docker-compose file demonstrates deploying all GoHoarder services +# under a single domain using the gateway reverse proxy + +services: + # Backend - Main application server + gohoarder-server: + image: ghcr.io/lukaszraczylo/gohoarder-server:latest + container_name: gohoarder-server + restart: unless-stopped + environment: + # Application configuration + - CONFIG_FILE=/config/config.yaml + # Database + - DB_PATH=/data/metadata/gohoarder.db + # Storage + - STORAGE_BACKEND=filesystem + - STORAGE_PATH=/data/cache + # Security scanning + - ENABLE_SCANNING=true + - SCAN_ON_DOWNLOAD=true + # Logging + - LOG_LEVEL=info + - LOG_FORMAT=json + volumes: + # Configuration + - ./config.yaml:/config/config.yaml:ro + # Data persistence + - gohoarder-cache:/data/cache + - gohoarder-metadata:/data/metadata + networks: + - gohoarder-internal + healthcheck: + test: ["CMD", "/usr/local/bin/gohoarder", "version"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 5s + + # Frontend - Web dashboard + gohoarder-frontend: + image: ghcr.io/lukaszraczylo/gohoarder-frontend:latest + container_name: gohoarder-frontend + restart: unless-stopped + environment: + # Runtime configuration - injected into /config.js + - API_BASE_URL=/api + - APP_VERSION=1.0.0 + - APP_NAME=GoHoarder + networks: + - gohoarder-internal + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 5s + + # Scanner - Background vulnerability scanner (optional) + gohoarder-scanner: + image: ghcr.io/lukaszraczylo/gohoarder-scanner:latest + container_name: gohoarder-scanner + restart: unless-stopped + environment: + - CONFIG_FILE=/config/config.yaml + - SCANNER_MODE=true + - SCANNER_WORKERS=4 + - SCANNER_INTERVAL=300 + - LOG_LEVEL=info + volumes: + - ./config.yaml:/config/config.yaml:ro + - gohoarder-cache:/data/cache:ro + - gohoarder-metadata:/data/metadata + networks: + - gohoarder-internal + depends_on: + - gohoarder-server + # Uncomment if you want to run scanner separately + # If commented out, scanning happens inline in the server + # profiles: + # - scanner + + # Gateway - Nginx reverse proxy + gohoarder-gateway: + image: ghcr.io/lukaszraczylo/gohoarder-gateway:latest + container_name: gohoarder-gateway + restart: unless-stopped + environment: + # Backend service connection + - BACKEND_HOST=gohoarder-server + - BACKEND_PORT=8080 + # Frontend service connection + - FRONTEND_HOST=gohoarder-frontend + - FRONTEND_PORT=80 + # Server configuration + - SERVER_NAME=hoarder.i.raczylo.com + ports: + # Map to host port 80 (HTTP) + - "80:80" + # Map to host port 443 (HTTPS) - uncomment if using SSL + # - "443:443" + networks: + - gohoarder-internal + depends_on: + - gohoarder-server + - gohoarder-frontend + # Uncomment if using custom SSL certificates + # volumes: + # - ./ssl/cert.pem:/etc/nginx/ssl/cert.pem:ro + # - ./ssl/key.pem:/etc/nginx/ssl/key.pem:ro + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 5s + +networks: + gohoarder-internal: + driver: bridge + +volumes: + # Persistent storage for cached packages + gohoarder-cache: + driver: local + # Persistent storage for metadata and scan results + gohoarder-metadata: + driver: local + +# Usage: +# 1. Copy this file: cp docker-compose.example.yaml docker-compose.yaml +# 2. Copy config: cp config.yaml.example config.yaml +# 3. Edit config.yaml with your settings +# 4. Start services: docker-compose up -d +# 5. View logs: docker-compose logs -f +# 6. Stop services: docker-compose down +# +# Access: +# - Web UI: http://localhost or http://hoarder.i.raczylo.com +# - API: http://localhost/api or http://hoarder.i.raczylo.com/api +# - Health: http://localhost/health +# - Metrics: http://localhost/metrics +# +# For production: +# - Enable HTTPS in the gateway container +# - Set up proper SSL certificates +# - Configure firewall rules +# - Set appropriate resource limits +# - Enable monitoring and alerting diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..22001c8 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,7 @@ +# Backend API URL (used by Vite dev server proxy) +# Change this if your gohoarder backend is running on a different port +VITE_BACKEND_URL=http://localhost:8080 + +# Frontend dev server port +# The Vite development server will run on this port +VITE_PORT=5173 diff --git a/frontend/components.json b/frontend/components.json new file mode 100644 index 0000000..eba41d0 --- /dev/null +++ b/frontend/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://shadcn-vue.com/schema.json", + "style": "new-york", + "typescript": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/styles/main.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "composables": "@/composables" + }, + "registries": {} +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..a13c333 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,16 @@ + + + + + + + + + + GoHoarder Dashboard + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..f033f24 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,43 @@ +{ + "name": "gohoarder-dashboard", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc && vite build", + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage" + }, + "dependencies": { + "@vueuse/core": "^14.1.0", + "axios": "^1.13.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-vue-next": "^0.562.0", + "marked": "^17.0.1", + "pinia": "^3.0.4", + "reka-ui": "^2.7.0", + "tailwind-merge": "^3.4.0", + "tailwindcss-animate": "^1.0.7", + "vue": "^3.5.26", + "vue-router": "^4.6.4" + }, + "devDependencies": { + "@fortawesome/fontawesome-free": "^7.1.0", + "@tailwindcss/typography": "^0.5.19", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/vue": "^8.1.0", + "@vitejs/plugin-vue": "^6.0.3", + "@vue/test-utils": "^2.4.6", + "autoprefixer": "^10.4.23", + "jsdom": "^27.4.0", + "postcss": "^8.5.6", + "tailwindcss": "^3.4.19", + "typescript": "^5.9.3", + "vite": "^7.3.0", + "vitest": "^4.0.16", + "vue-tsc": "^3.2.1" + } +} diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml new file mode 100644 index 0000000..14e1eef --- /dev/null +++ b/frontend/pnpm-lock.yaml @@ -0,0 +1,3795 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@vueuse/core': + specifier: ^14.1.0 + version: 14.1.0(vue@3.5.26(typescript@5.9.3)) + axios: + specifier: ^1.13.2 + version: 1.13.2 + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + lucide-vue-next: + specifier: ^0.562.0 + version: 0.562.0(vue@3.5.26(typescript@5.9.3)) + marked: + specifier: ^17.0.1 + version: 17.0.1 + pinia: + specifier: ^3.0.4 + version: 3.0.4(typescript@5.9.3)(vue@3.5.26(typescript@5.9.3)) + reka-ui: + specifier: ^2.7.0 + version: 2.7.0(typescript@5.9.3)(vue@3.5.26(typescript@5.9.3)) + tailwind-merge: + specifier: ^3.4.0 + version: 3.4.0 + tailwindcss-animate: + specifier: ^1.0.7 + version: 1.0.7(tailwindcss@3.4.19) + vue: + specifier: ^3.5.26 + version: 3.5.26(typescript@5.9.3) + vue-router: + specifier: ^4.6.4 + version: 4.6.4(vue@3.5.26(typescript@5.9.3)) + devDependencies: + '@fortawesome/fontawesome-free': + specifier: ^7.1.0 + version: 7.1.0 + '@tailwindcss/typography': + specifier: ^0.5.19 + version: 0.5.19(tailwindcss@3.4.19) + '@testing-library/jest-dom': + specifier: ^6.9.1 + version: 6.9.1 + '@testing-library/vue': + specifier: ^8.1.0 + version: 8.1.0(@vue/compiler-sfc@3.5.26)(vue@3.5.26(typescript@5.9.3)) + '@vitejs/plugin-vue': + specifier: ^6.0.3 + version: 6.0.3(vite@7.3.0(jiti@1.21.7)(lightningcss@1.30.2))(vue@3.5.26(typescript@5.9.3)) + '@vue/test-utils': + specifier: ^2.4.6 + version: 2.4.6 + autoprefixer: + specifier: ^10.4.23 + version: 10.4.23(postcss@8.5.6) + jsdom: + specifier: ^27.4.0 + version: 27.4.0 + postcss: + specifier: ^8.5.6 + version: 8.5.6 + tailwindcss: + specifier: ^3.4.19 + version: 3.4.19 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vite: + specifier: ^7.3.0 + version: 7.3.0(jiti@1.21.7)(lightningcss@1.30.2) + vitest: + specifier: ^4.0.16 + version: 4.0.16(jiti@1.21.7)(jsdom@27.4.0)(lightningcss@1.30.2) + vue-tsc: + specifier: ^3.2.1 + version: 3.2.1(typescript@5.9.3) + +packages: + + '@acemir/cssom@0.9.30': + resolution: {integrity: sha512-9CnlMCI0LmCIq0olalQqdWrJHPzm0/tw3gzOA9zJSgvFX7Xau3D24mAGa4BtwxwY69nsuJW6kQqqCzf/mEcQgg==} + + '@adobe/css-tools@4.4.4': + resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + + '@alloc/quick-lru@5.2.0': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + + '@asamuzakjp/css-color@4.1.1': + resolution: {integrity: sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==} + + '@asamuzakjp/dom-selector@6.7.6': + resolution: {integrity: sha512-hBaJER6A9MpdG3WgdlOolHmbOYvSk46y7IQN/1+iqiCuUu6iWdQrs9DGKF8ocqsEqWujWf/V7b7vaDgiUmIvUg==} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + + '@babel/code-frame@7.27.1': + resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.28.5': + resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/runtime@7.28.4': + resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.28.5': + resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} + engines: {node: '>=6.9.0'} + + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-syntax-patches-for-csstree@1.0.22': + resolution: {integrity: sha512-qBcx6zYlhleiFfdtzkRgwNC7VVoAwfK76Vmsw5t+PbvtdknO9StgRk7ROvq9so1iqbdW4uLIDAsXRsTfUrIoOw==} + engines: {node: '>=18'} + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + + '@esbuild/aix-ppc64@0.27.2': + resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.2': + resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.2': + resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.2': + resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.2': + resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.2': + resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.2': + resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.2': + resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.2': + resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.2': + resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.2': + resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.2': + resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.2': + resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.2': + resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.2': + resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.2': + resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.2': + resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.2': + resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.2': + resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.2': + resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.2': + resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.2': + resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.2': + resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.2': + resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.2': + resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.2': + resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@exodus/bytes@1.8.0': + resolution: {integrity: sha512-8JPn18Bcp8Uo1T82gR8lh2guEOa5KKU/IEKvvdp0sgmi7coPBWf1Doi1EXsGZb2ehc8ym/StJCjffYV+ne7sXQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@exodus/crypto': ^1.0.0-rc.4 + peerDependenciesMeta: + '@exodus/crypto': + optional: true + + '@floating-ui/core@1.7.3': + resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} + + '@floating-ui/dom@1.7.4': + resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==} + + '@floating-ui/utils@0.2.10': + resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + + '@floating-ui/vue@1.1.9': + resolution: {integrity: sha512-BfNqNW6KA83Nexspgb9DZuz578R7HT8MZw1CfK9I6Ah4QReNWEJsXWHN+SdmOVLNGmTPDi+fDT535Df5PzMLbQ==} + + '@fortawesome/fontawesome-free@7.1.0': + resolution: {integrity: sha512-+WxNld5ZCJHvPQCr/GnzCTVREyStrAJjisUPtUxG5ngDA8TMlPnKp6dddlTpai4+1GNmltAeuk1hJEkBohwZYA==} + engines: {node: '>=6'} + + '@internationalized/date@3.10.1': + resolution: {integrity: sha512-oJrXtQiAXLvT9clCf1K4kxp3eKsQhIaZqxEyowkBcsvZDdZkbWrVmnGknxs5flTD0VGsxrxKgBCZty1EzoiMzA==} + + '@internationalized/number@3.6.5': + resolution: {integrity: sha512-6hY4Kl4HPBvtfS62asS/R22JzNNy8vi/Ssev7x6EobfCp+9QIB2hKvI2EtbdJ0VSQacxVNtqhE/NmF/NZ0gm6g==} + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@one-ini/wasm@0.1.1': + resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@rolldown/pluginutils@1.0.0-beta.53': + resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==} + + '@rollup/rollup-android-arm-eabi@4.54.0': + resolution: {integrity: sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.54.0': + resolution: {integrity: sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.54.0': + resolution: {integrity: sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.54.0': + resolution: {integrity: sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.54.0': + resolution: {integrity: sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.54.0': + resolution: {integrity: sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.54.0': + resolution: {integrity: sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.54.0': + resolution: {integrity: sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.54.0': + resolution: {integrity: sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.54.0': + resolution: {integrity: sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.54.0': + resolution: {integrity: sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-gnu@4.54.0': + resolution: {integrity: sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-gnu@4.54.0': + resolution: {integrity: sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.54.0': + resolution: {integrity: sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.54.0': + resolution: {integrity: sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.54.0': + resolution: {integrity: sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.54.0': + resolution: {integrity: sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openharmony-arm64@4.54.0': + resolution: {integrity: sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.54.0': + resolution: {integrity: sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.54.0': + resolution: {integrity: sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.54.0': + resolution: {integrity: sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.54.0': + resolution: {integrity: sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==} + cpu: [x64] + os: [win32] + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@swc/helpers@0.5.18': + resolution: {integrity: sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==} + + '@tailwindcss/typography@0.5.19': + resolution: {integrity: sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==} + peerDependencies: + tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1' + + '@tanstack/virtual-core@3.13.14': + resolution: {integrity: sha512-b5Uvd8J2dc7ICeX9SRb/wkCxWk7pUwN214eEPAQsqrsktSKTCmyLxOQWSMgogBByXclZeAdgZ3k4o0fIYUIBqQ==} + + '@tanstack/vue-virtual@3.13.14': + resolution: {integrity: sha512-dLKQCWj0uu6Rc1OsTGiClpH75hyf92MvJ9YALAzWdblwImSFnxfXD0mu8yOI7PlxiDAcDA5Pq0Q47YvADAfyfg==} + peerDependencies: + vue: ^2.7.0 || ^3.0.0 + + '@testing-library/dom@9.3.4': + resolution: {integrity: sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==} + engines: {node: '>=14'} + + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/vue@8.1.0': + resolution: {integrity: sha512-ls4RiHO1ta4mxqqajWRh8158uFObVrrtAPoxk7cIp4HrnQUj/ScKzqz53HxYpG3X6Zb7H2v+0eTGLSoy8HQ2nA==} + engines: {node: '>=14'} + peerDependencies: + '@vue/compiler-sfc': '>= 3' + vue: '>= 3' + peerDependenciesMeta: + '@vue/compiler-sfc': + optional: true + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/web-bluetooth@0.0.21': + resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + + '@vitejs/plugin-vue@6.0.3': + resolution: {integrity: sha512-TlGPkLFLVOY3T7fZrwdvKpjprR3s4fxRln0ORDo1VQ7HHyxJwTlrjKU3kpVWTlaAjIEuCTokmjkZnr8Tpc925w==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 + vue: ^3.2.25 + + '@vitest/expect@4.0.16': + resolution: {integrity: sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA==} + + '@vitest/mocker@4.0.16': + resolution: {integrity: sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.0.16': + resolution: {integrity: sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA==} + + '@vitest/runner@4.0.16': + resolution: {integrity: sha512-VWEDm5Wv9xEo80ctjORcTQRJ539EGPB3Pb9ApvVRAY1U/WkHXmmYISqU5E79uCwcW7xYUV38gwZD+RV755fu3Q==} + + '@vitest/snapshot@4.0.16': + resolution: {integrity: sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA==} + + '@vitest/spy@4.0.16': + resolution: {integrity: sha512-4jIOWjKP0ZUaEmJm00E0cOBLU+5WE0BpeNr3XN6TEF05ltro6NJqHWxXD0kA8/Zc8Nh23AT8WQxwNG+WeROupw==} + + '@vitest/utils@4.0.16': + resolution: {integrity: sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA==} + + '@volar/language-core@2.4.27': + resolution: {integrity: sha512-DjmjBWZ4tJKxfNC1F6HyYERNHPYS7L7OPFyCrestykNdUZMFYzI9WTyvwPcaNaHlrEUwESHYsfEw3isInncZxQ==} + + '@volar/source-map@2.4.27': + resolution: {integrity: sha512-ynlcBReMgOZj2i6po+qVswtDUeeBRCTgDurjMGShbm8WYZgJ0PA4RmtebBJ0BCYol1qPv3GQF6jK7C9qoVc7lg==} + + '@volar/typescript@2.4.27': + resolution: {integrity: sha512-eWaYCcl/uAPInSK2Lze6IqVWaBu/itVqR5InXcHXFyles4zO++Mglt3oxdgj75BDcv1Knr9Y93nowS8U3wqhxg==} + + '@vue/compiler-core@3.5.26': + resolution: {integrity: sha512-vXyI5GMfuoBCnv5ucIT7jhHKl55Y477yxP6fc4eUswjP8FG3FFVFd41eNDArR+Uk3QKn2Z85NavjaxLxOC19/w==} + + '@vue/compiler-dom@3.5.26': + resolution: {integrity: sha512-y1Tcd3eXs834QjswshSilCBnKGeQjQXB6PqFn/1nxcQw4pmG42G8lwz+FZPAZAby6gZeHSt/8LMPfZ4Rb+Bd/A==} + + '@vue/compiler-sfc@3.5.26': + resolution: {integrity: sha512-egp69qDTSEZcf4bGOSsprUr4xI73wfrY5oRs6GSgXFTiHrWj4Y3X5Ydtip9QMqiCMCPVwLglB9GBxXtTadJ3mA==} + + '@vue/compiler-ssr@3.5.26': + resolution: {integrity: sha512-lZT9/Y0nSIRUPVvapFJEVDbEXruZh2IYHMk2zTtEgJSlP5gVOqeWXH54xDKAaFS4rTnDeDBQUYDtxKyoW9FwDw==} + + '@vue/devtools-api@6.6.4': + resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==} + + '@vue/devtools-api@7.7.9': + resolution: {integrity: sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==} + + '@vue/devtools-kit@7.7.9': + resolution: {integrity: sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==} + + '@vue/devtools-shared@7.7.9': + resolution: {integrity: sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==} + + '@vue/language-core@3.2.1': + resolution: {integrity: sha512-g6oSenpnGMtpxHGAwKuu7HJJkNZpemK/zg3vZzZbJ6cnnXq1ssxuNrXSsAHYM3NvH8p4IkTw+NLmuxyeYz4r8A==} + + '@vue/reactivity@3.5.26': + resolution: {integrity: sha512-9EnYB1/DIiUYYnzlnUBgwU32NNvLp/nhxLXeWRhHUEeWNTn1ECxX8aGO7RTXeX6PPcxe3LLuNBFoJbV4QZ+CFQ==} + + '@vue/runtime-core@3.5.26': + resolution: {integrity: sha512-xJWM9KH1kd201w5DvMDOwDHYhrdPTrAatn56oB/LRG4plEQeZRQLw0Bpwih9KYoqmzaxF0OKSn6swzYi84e1/Q==} + + '@vue/runtime-dom@3.5.26': + resolution: {integrity: sha512-XLLd/+4sPC2ZkN/6+V4O4gjJu6kSDbHAChvsyWgm1oGbdSO3efvGYnm25yCjtFm/K7rrSDvSfPDgN1pHgS4VNQ==} + + '@vue/server-renderer@3.5.26': + resolution: {integrity: sha512-TYKLXmrwWKSodyVuO1WAubucd+1XlLg4set0YoV+Hu8Lo79mp/YMwWV5mC5FgtsDxX3qo1ONrxFaTP1OQgy1uA==} + peerDependencies: + vue: 3.5.26 + + '@vue/shared@3.5.26': + resolution: {integrity: sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==} + + '@vue/test-utils@2.4.6': + resolution: {integrity: sha512-FMxEjOpYNYiFe0GkaHsnJPXFHxQ6m4t8vI/ElPGpMWxZKpmRvQ33OIrvRXemy6yha03RxhOlQuy+gZMC3CQSow==} + + '@vueuse/core@12.8.2': + resolution: {integrity: sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==} + + '@vueuse/core@14.1.0': + resolution: {integrity: sha512-rgBinKs07hAYyPF834mDTigH7BtPqvZ3Pryuzt1SD/lg5wEcWqvwzXXYGEDb2/cP0Sj5zSvHl3WkmMELr5kfWw==} + peerDependencies: + vue: ^3.5.0 + + '@vueuse/metadata@12.8.2': + resolution: {integrity: sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==} + + '@vueuse/metadata@14.1.0': + resolution: {integrity: sha512-7hK4g015rWn2PhKcZ99NyT+ZD9sbwm7SGvp7k+k+rKGWnLjS/oQozoIZzWfCewSUeBmnJkIb+CNr7Zc/EyRnnA==} + + '@vueuse/shared@12.8.2': + resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==} + + '@vueuse/shared@14.1.0': + resolution: {integrity: sha512-EcKxtYvn6gx1F8z9J5/rsg3+lTQnvOruQd8fUecW99DCK04BkWD7z5KQ/wTAx+DazyoEE9dJt/zV8OIEQbM6kw==} + peerDependencies: + vue: ^3.5.0 + + abbrev@2.0.0: + resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + alien-signals@3.1.2: + resolution: {integrity: sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + + aria-query@5.1.3: + resolution: {integrity: sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==} + + array-buffer-byte-length@1.0.2: + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} + engines: {node: '>= 0.4'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + autoprefixer@10.4.23: + resolution: {integrity: sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + + axios@1.13.2: + resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + baseline-browser-mapping@2.9.11: + resolution: {integrity: sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==} + hasBin: true + + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + birpc@2.9.0: + resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} + + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.1: + resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.8: + resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + camelcase-css@2.0.1: + resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} + engines: {node: '>= 6'} + + caniuse-lite@1.0.30001762: + resolution: {integrity: sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + commander@10.0.1: + resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} + engines: {node: '>=14'} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + config-chain@1.1.13: + resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} + + copy-anything@4.0.5: + resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} + engines: {node: '>=18'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + css-tree@3.1.0: + resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + cssstyle@5.3.6: + resolution: {integrity: sha512-legscpSpgSAeGEe0TNcai97DKt9Vd9AsAdOL7Uoetb52Ar/8eJm3LIa39qpv8wWzLFlNG4vVvppQM+teaMPj3A==} + engines: {node: '>=20'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-urls@6.0.0: + resolution: {integrity: sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==} + engines: {node: '>=20'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + deep-equal@2.2.3: + resolution: {integrity: sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==} + engines: {node: '>= 0.4'} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + + defu@6.1.4: + resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + didyoumean@1.2.2: + resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + + dlv@1.1.3: + resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + editorconfig@1.0.4: + resolution: {integrity: sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q==} + engines: {node: '>=14'} + hasBin: true + + electron-to-chromium@1.5.267: + resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + entities@7.0.0: + resolution: {integrity: sha512-FDWG5cmEYf2Z00IkYRhbFrwIwvdFKH07uV8dvNy0omp/Qb1xcyCWp2UDtcwJF4QZZvk0sLudP6/hAu42TaqVhQ==} + engines: {node: '>=0.12'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-get-iterator@1.1.3: + resolution: {integrity: sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + esbuild@0.27.2: + resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + follow-redirects@1.15.11: + resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + + fraction.js@5.3.4: + resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + functions-have-names@1.2.3: + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + hasBin: true + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-bigints@1.1.0: + resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} + engines: {node: '>= 0.4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + hookable@5.5.3: + resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + internal-slot@1.1.0: + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} + engines: {node: '>= 0.4'} + + is-arguments@1.2.0: + resolution: {integrity: sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==} + engines: {node: '>= 0.4'} + + is-array-buffer@3.0.5: + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} + engines: {node: '>= 0.4'} + + is-bigint@1.1.0: + resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} + engines: {node: '>= 0.4'} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-boolean-object@1.2.2: + resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} + engines: {node: '>= 0.4'} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + + is-date-object@1.1.0: + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-map@2.0.3: + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} + engines: {node: '>= 0.4'} + + is-number-object@1.1.1: + resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} + engines: {node: '>= 0.4'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + is-set@2.0.3: + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} + engines: {node: '>= 0.4'} + + is-shared-array-buffer@1.0.4: + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} + engines: {node: '>= 0.4'} + + is-string@1.1.1: + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} + engines: {node: '>= 0.4'} + + is-symbol@1.1.1: + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} + engines: {node: '>= 0.4'} + + is-weakmap@2.0.2: + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} + engines: {node: '>= 0.4'} + + is-weakset@2.0.4: + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} + engines: {node: '>= 0.4'} + + is-what@5.5.0: + resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==} + engines: {node: '>=18'} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + jiti@1.21.7: + resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} + hasBin: true + + js-beautify@1.15.4: + resolution: {integrity: sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==} + engines: {node: '>=14'} + hasBin: true + + js-cookie@3.0.5: + resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==} + engines: {node: '>=14'} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsdom@27.4.0: + resolution: {integrity: sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + + lightningcss-android-arm64@1.30.2: + resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.30.2: + resolution: {integrity: sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.30.2: + resolution: {integrity: sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.30.2: + resolution: {integrity: sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.30.2: + resolution: {integrity: sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.30.2: + resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.30.2: + resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.30.2: + resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.30.2: + resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.30.2: + resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.30.2: + resolution: {integrity: sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.30.2: + resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==} + engines: {node: '>= 12.0.0'} + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@11.2.4: + resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==} + engines: {node: 20 || >=22} + + lucide-vue-next@0.562.0: + resolution: {integrity: sha512-LN0BLGKMFulv0lnfK29r14DcngRUhIqdcaL0zXTt2o0oS9odlrjCGaU3/X9hIihOjjN8l8e+Y9G/famcNYaI7Q==} + peerDependencies: + vue: '>=3.0.1' + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + marked@17.0.1: + resolution: {integrity: sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==} + engines: {node: '>= 20'} + hasBin: true + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mdn-data@2.12.2: + resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + + minimatch@9.0.1: + resolution: {integrity: sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==} + engines: {node: '>=16 || 14 >=14.17'} + + minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + + minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + + mitt@3.0.1: + resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + muggle-string@0.4.1: + resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-releases@2.0.27: + resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + + nopt@7.2.1: + resolution: {integrity: sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + hasBin: true + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + object-is@1.1.6: + resolution: {integrity: sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==} + engines: {node: '>= 0.4'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + object.assign@4.1.7: + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} + engines: {node: '>= 0.4'} + + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + + ohash@2.0.11: + resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + parse5@8.0.0: + resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + perfect-debounce@1.0.0: + resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + pify@2.3.0: + resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} + engines: {node: '>=0.10.0'} + + pinia@3.0.4: + resolution: {integrity: sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==} + peerDependencies: + typescript: '>=4.5.0' + vue: ^3.5.11 + peerDependenciesMeta: + typescript: + optional: true + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + + postcss-import@15.1.0: + resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} + engines: {node: '>=14.0.0'} + peerDependencies: + postcss: ^8.0.0 + + postcss-js@4.1.0: + resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==} + engines: {node: ^12 || ^14 || >= 16} + peerDependencies: + postcss: ^8.4.21 + + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + + postcss-nested@6.2.0: + resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.2.14 + + postcss-selector-parser@6.0.10: + resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} + engines: {node: '>=4'} + + postcss-selector-parser@6.1.2: + resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} + engines: {node: '>=4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + proto-list@1.2.4: + resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} + + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + + read-cache@1.0.0: + resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + + regexp.prototype.flags@1.5.4: + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} + engines: {node: '>= 0.4'} + + reka-ui@2.7.0: + resolution: {integrity: sha512-m+XmxQN2xtFzBP3OAdIafKq7C8OETo2fqfxcIIxYmNN2Ch3r5oAf6yEYCIJg5tL/yJU2mHqF70dCCekUkrAnXA==} + peerDependencies: + vue: '>= 3.2.0' + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve@1.22.11: + resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} + engines: {node: '>= 0.4'} + hasBin: true + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + rollup@4.54.0: + resolution: {integrity: sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + semver@7.7.3: + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + engines: {node: '>=10'} + hasBin: true + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + set-function-name@2.0.2: + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} + engines: {node: '>= 0.4'} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + speakingurl@14.0.1: + resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + stop-iteration-iterator@1.1.0: + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} + engines: {node: '>= 0.4'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.1.2: + resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} + engines: {node: '>=12'} + + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + + sucrase@3.35.1: + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + superjson@2.2.6: + resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==} + engines: {node: '>=16'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + tailwind-merge@3.4.0: + resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==} + + tailwindcss-animate@1.0.7: + resolution: {integrity: sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==} + peerDependencies: + tailwindcss: '>=3.0.0 || insiders' + + tailwindcss@3.4.19: + resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==} + engines: {node: '>=14.0.0'} + hasBin: true + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.0.2: + resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + engines: {node: '>=18'} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.0.3: + resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} + engines: {node: '>=14.0.0'} + + tldts-core@7.0.19: + resolution: {integrity: sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==} + + tldts@7.0.19: + resolution: {integrity: sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==} + hasBin: true + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + tough-cookie@6.0.0: + resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vite@7.3.0: + resolution: {integrity: sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.0.16: + resolution: {integrity: sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.0.16 + '@vitest/browser-preview': 4.0.16 + '@vitest/browser-webdriverio': 4.0.16 + '@vitest/ui': 4.0.16 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + vscode-uri@3.1.0: + resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + + vue-component-type-helpers@2.2.12: + resolution: {integrity: sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==} + + vue-demi@0.14.10: + resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} + engines: {node: '>=12'} + hasBin: true + peerDependencies: + '@vue/composition-api': ^1.0.0-rc.1 + vue: ^3.0.0-0 || ^2.6.0 + peerDependenciesMeta: + '@vue/composition-api': + optional: true + + vue-router@4.6.4: + resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==} + peerDependencies: + vue: ^3.5.0 + + vue-tsc@3.2.1: + resolution: {integrity: sha512-I23Rk8dkQfmcSbxDO0dmg9ioMLjKA1pjlU3Lz6Jfk2pMGu3Uryu9810XkcZH24IzPbhzPCnkKo2rEMRX0skSrw==} + hasBin: true + peerDependencies: + typescript: '>=5.0.0' + + vue@3.5.26: + resolution: {integrity: sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@8.0.0: + resolution: {integrity: sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA==} + engines: {node: '>=20'} + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@15.1.0: + resolution: {integrity: sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==} + engines: {node: '>=20'} + + which-boxed-primitive@1.1.1: + resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} + engines: {node: '>= 0.4'} + + which-collection@1.0.2: + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} + engines: {node: '>= 0.4'} + + which-typed-array@1.1.19: + resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==} + engines: {node: '>= 0.4'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + ws@8.18.3: + resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + +snapshots: + + '@acemir/cssom@0.9.30': {} + + '@adobe/css-tools@4.4.4': {} + + '@alloc/quick-lru@5.2.0': {} + + '@asamuzakjp/css-color@4.1.1': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 11.2.4 + + '@asamuzakjp/dom-selector@6.7.6': + dependencies: + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.1.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.2.4 + + '@asamuzakjp/nwsapi@2.3.9': {} + + '@babel/code-frame@7.27.1': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/parser@7.28.5': + dependencies: + '@babel/types': 7.28.5 + + '@babel/runtime@7.28.4': {} + + '@babel/types@7.28.5': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-syntax-patches-for-csstree@1.0.22': {} + + '@csstools/css-tokenizer@3.0.4': {} + + '@esbuild/aix-ppc64@0.27.2': + optional: true + + '@esbuild/android-arm64@0.27.2': + optional: true + + '@esbuild/android-arm@0.27.2': + optional: true + + '@esbuild/android-x64@0.27.2': + optional: true + + '@esbuild/darwin-arm64@0.27.2': + optional: true + + '@esbuild/darwin-x64@0.27.2': + optional: true + + '@esbuild/freebsd-arm64@0.27.2': + optional: true + + '@esbuild/freebsd-x64@0.27.2': + optional: true + + '@esbuild/linux-arm64@0.27.2': + optional: true + + '@esbuild/linux-arm@0.27.2': + optional: true + + '@esbuild/linux-ia32@0.27.2': + optional: true + + '@esbuild/linux-loong64@0.27.2': + optional: true + + '@esbuild/linux-mips64el@0.27.2': + optional: true + + '@esbuild/linux-ppc64@0.27.2': + optional: true + + '@esbuild/linux-riscv64@0.27.2': + optional: true + + '@esbuild/linux-s390x@0.27.2': + optional: true + + '@esbuild/linux-x64@0.27.2': + optional: true + + '@esbuild/netbsd-arm64@0.27.2': + optional: true + + '@esbuild/netbsd-x64@0.27.2': + optional: true + + '@esbuild/openbsd-arm64@0.27.2': + optional: true + + '@esbuild/openbsd-x64@0.27.2': + optional: true + + '@esbuild/openharmony-arm64@0.27.2': + optional: true + + '@esbuild/sunos-x64@0.27.2': + optional: true + + '@esbuild/win32-arm64@0.27.2': + optional: true + + '@esbuild/win32-ia32@0.27.2': + optional: true + + '@esbuild/win32-x64@0.27.2': + optional: true + + '@exodus/bytes@1.8.0': {} + + '@floating-ui/core@1.7.3': + dependencies: + '@floating-ui/utils': 0.2.10 + + '@floating-ui/dom@1.7.4': + dependencies: + '@floating-ui/core': 1.7.3 + '@floating-ui/utils': 0.2.10 + + '@floating-ui/utils@0.2.10': {} + + '@floating-ui/vue@1.1.9(vue@3.5.26(typescript@5.9.3))': + dependencies: + '@floating-ui/dom': 1.7.4 + '@floating-ui/utils': 0.2.10 + vue-demi: 0.14.10(vue@3.5.26(typescript@5.9.3)) + transitivePeerDependencies: + - '@vue/composition-api' + - vue + + '@fortawesome/fontawesome-free@7.1.0': {} + + '@internationalized/date@3.10.1': + dependencies: + '@swc/helpers': 0.5.18 + + '@internationalized/number@3.6.5': + dependencies: + '@swc/helpers': 0.5.18 + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.1.2 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@one-ini/wasm@0.1.1': {} + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@rolldown/pluginutils@1.0.0-beta.53': {} + + '@rollup/rollup-android-arm-eabi@4.54.0': + optional: true + + '@rollup/rollup-android-arm64@4.54.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.54.0': + optional: true + + '@rollup/rollup-darwin-x64@4.54.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.54.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.54.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.54.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.54.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.54.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.54.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.54.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.54.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.54.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.54.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.54.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.54.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.54.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.54.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.54.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.54.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.54.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.54.0': + optional: true + + '@standard-schema/spec@1.1.0': {} + + '@swc/helpers@0.5.18': + dependencies: + tslib: 2.8.1 + + '@tailwindcss/typography@0.5.19(tailwindcss@3.4.19)': + dependencies: + postcss-selector-parser: 6.0.10 + tailwindcss: 3.4.19 + + '@tanstack/virtual-core@3.13.14': {} + + '@tanstack/vue-virtual@3.13.14(vue@3.5.26(typescript@5.9.3))': + dependencies: + '@tanstack/virtual-core': 3.13.14 + vue: 3.5.26(typescript@5.9.3) + + '@testing-library/dom@9.3.4': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/runtime': 7.28.4 + '@types/aria-query': 5.0.4 + aria-query: 5.1.3 + chalk: 4.1.2 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.4.4 + aria-query: 5.1.3 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/vue@8.1.0(@vue/compiler-sfc@3.5.26)(vue@3.5.26(typescript@5.9.3))': + dependencies: + '@babel/runtime': 7.28.4 + '@testing-library/dom': 9.3.4 + '@vue/test-utils': 2.4.6 + vue: 3.5.26(typescript@5.9.3) + optionalDependencies: + '@vue/compiler-sfc': 3.5.26 + + '@types/aria-query@5.0.4': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.8': {} + + '@types/web-bluetooth@0.0.21': {} + + '@vitejs/plugin-vue@6.0.3(vite@7.3.0(jiti@1.21.7)(lightningcss@1.30.2))(vue@3.5.26(typescript@5.9.3))': + dependencies: + '@rolldown/pluginutils': 1.0.0-beta.53 + vite: 7.3.0(jiti@1.21.7)(lightningcss@1.30.2) + vue: 3.5.26(typescript@5.9.3) + + '@vitest/expect@4.0.16': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.0.16 + '@vitest/utils': 4.0.16 + chai: 6.2.2 + tinyrainbow: 3.0.3 + + '@vitest/mocker@4.0.16(vite@7.3.0(jiti@1.21.7)(lightningcss@1.30.2))': + dependencies: + '@vitest/spy': 4.0.16 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.0(jiti@1.21.7)(lightningcss@1.30.2) + + '@vitest/pretty-format@4.0.16': + dependencies: + tinyrainbow: 3.0.3 + + '@vitest/runner@4.0.16': + dependencies: + '@vitest/utils': 4.0.16 + pathe: 2.0.3 + + '@vitest/snapshot@4.0.16': + dependencies: + '@vitest/pretty-format': 4.0.16 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.0.16': {} + + '@vitest/utils@4.0.16': + dependencies: + '@vitest/pretty-format': 4.0.16 + tinyrainbow: 3.0.3 + + '@volar/language-core@2.4.27': + dependencies: + '@volar/source-map': 2.4.27 + + '@volar/source-map@2.4.27': {} + + '@volar/typescript@2.4.27': + dependencies: + '@volar/language-core': 2.4.27 + path-browserify: 1.0.1 + vscode-uri: 3.1.0 + + '@vue/compiler-core@3.5.26': + dependencies: + '@babel/parser': 7.28.5 + '@vue/shared': 3.5.26 + entities: 7.0.0 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.26': + dependencies: + '@vue/compiler-core': 3.5.26 + '@vue/shared': 3.5.26 + + '@vue/compiler-sfc@3.5.26': + dependencies: + '@babel/parser': 7.28.5 + '@vue/compiler-core': 3.5.26 + '@vue/compiler-dom': 3.5.26 + '@vue/compiler-ssr': 3.5.26 + '@vue/shared': 3.5.26 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.6 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.26': + dependencies: + '@vue/compiler-dom': 3.5.26 + '@vue/shared': 3.5.26 + + '@vue/devtools-api@6.6.4': {} + + '@vue/devtools-api@7.7.9': + dependencies: + '@vue/devtools-kit': 7.7.9 + + '@vue/devtools-kit@7.7.9': + dependencies: + '@vue/devtools-shared': 7.7.9 + birpc: 2.9.0 + hookable: 5.5.3 + mitt: 3.0.1 + perfect-debounce: 1.0.0 + speakingurl: 14.0.1 + superjson: 2.2.6 + + '@vue/devtools-shared@7.7.9': + dependencies: + rfdc: 1.4.1 + + '@vue/language-core@3.2.1': + dependencies: + '@volar/language-core': 2.4.27 + '@vue/compiler-dom': 3.5.26 + '@vue/shared': 3.5.26 + alien-signals: 3.1.2 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + picomatch: 4.0.3 + + '@vue/reactivity@3.5.26': + dependencies: + '@vue/shared': 3.5.26 + + '@vue/runtime-core@3.5.26': + dependencies: + '@vue/reactivity': 3.5.26 + '@vue/shared': 3.5.26 + + '@vue/runtime-dom@3.5.26': + dependencies: + '@vue/reactivity': 3.5.26 + '@vue/runtime-core': 3.5.26 + '@vue/shared': 3.5.26 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.26(vue@3.5.26(typescript@5.9.3))': + dependencies: + '@vue/compiler-ssr': 3.5.26 + '@vue/shared': 3.5.26 + vue: 3.5.26(typescript@5.9.3) + + '@vue/shared@3.5.26': {} + + '@vue/test-utils@2.4.6': + dependencies: + js-beautify: 1.15.4 + vue-component-type-helpers: 2.2.12 + + '@vueuse/core@12.8.2(typescript@5.9.3)': + dependencies: + '@types/web-bluetooth': 0.0.21 + '@vueuse/metadata': 12.8.2 + '@vueuse/shared': 12.8.2(typescript@5.9.3) + vue: 3.5.26(typescript@5.9.3) + transitivePeerDependencies: + - typescript + + '@vueuse/core@14.1.0(vue@3.5.26(typescript@5.9.3))': + dependencies: + '@types/web-bluetooth': 0.0.21 + '@vueuse/metadata': 14.1.0 + '@vueuse/shared': 14.1.0(vue@3.5.26(typescript@5.9.3)) + vue: 3.5.26(typescript@5.9.3) + + '@vueuse/metadata@12.8.2': {} + + '@vueuse/metadata@14.1.0': {} + + '@vueuse/shared@12.8.2(typescript@5.9.3)': + dependencies: + vue: 3.5.26(typescript@5.9.3) + transitivePeerDependencies: + - typescript + + '@vueuse/shared@14.1.0(vue@3.5.26(typescript@5.9.3))': + dependencies: + vue: 3.5.26(typescript@5.9.3) + + abbrev@2.0.0: {} + + agent-base@7.1.4: {} + + alien-signals@3.1.2: {} + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.2.0: {} + + ansi-styles@6.2.3: {} + + any-promise@1.3.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + + arg@5.0.2: {} + + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + + aria-query@5.1.3: + dependencies: + deep-equal: 2.2.3 + + array-buffer-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + is-array-buffer: 3.0.5 + + assertion-error@2.0.1: {} + + asynckit@0.4.0: {} + + autoprefixer@10.4.23(postcss@8.5.6): + dependencies: + browserslist: 4.28.1 + caniuse-lite: 1.0.30001762 + fraction.js: 5.3.4 + picocolors: 1.1.1 + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + + axios@1.13.2: + dependencies: + follow-redirects: 1.15.11 + form-data: 4.0.5 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + + balanced-match@1.0.2: {} + + baseline-browser-mapping@2.9.11: {} + + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + + binary-extensions@2.3.0: {} + + birpc@2.9.0: {} + + brace-expansion@2.0.2: + dependencies: + balanced-match: 1.0.2 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.1: + dependencies: + baseline-browser-mapping: 2.9.11 + caniuse-lite: 1.0.30001762 + electron-to-chromium: 1.5.267 + node-releases: 2.0.27 + update-browserslist-db: 1.2.3(browserslist@4.28.1) + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.8: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + camelcase-css@2.0.1: {} + + caniuse-lite@1.0.30001762: {} + + chai@6.2.2: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + + clsx@2.1.1: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + commander@10.0.1: {} + + commander@4.1.1: {} + + config-chain@1.1.13: + dependencies: + ini: 1.3.8 + proto-list: 1.2.4 + + copy-anything@4.0.5: + dependencies: + is-what: 5.5.0 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + css-tree@3.1.0: + dependencies: + mdn-data: 2.12.2 + source-map-js: 1.2.1 + + css.escape@1.5.1: {} + + cssesc@3.0.0: {} + + cssstyle@5.3.6: + dependencies: + '@asamuzakjp/css-color': 4.1.1 + '@csstools/css-syntax-patches-for-csstree': 1.0.22 + css-tree: 3.1.0 + lru-cache: 11.2.4 + + csstype@3.2.3: {} + + data-urls@6.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 15.1.0 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decimal.js@10.6.0: {} + + deep-equal@2.2.3: + dependencies: + array-buffer-byte-length: 1.0.2 + call-bind: 1.0.8 + es-get-iterator: 1.1.3 + get-intrinsic: 1.3.0 + is-arguments: 1.2.0 + is-array-buffer: 3.0.5 + is-date-object: 1.1.0 + is-regex: 1.2.1 + is-shared-array-buffer: 1.0.4 + isarray: 2.0.5 + object-is: 1.1.6 + object-keys: 1.1.1 + object.assign: 4.1.7 + regexp.prototype.flags: 1.5.4 + side-channel: 1.1.0 + which-boxed-primitive: 1.1.1 + which-collection: 1.0.2 + which-typed-array: 1.1.19 + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + + defu@6.1.4: {} + + delayed-stream@1.0.0: {} + + detect-libc@2.1.2: + optional: true + + didyoumean@1.2.2: {} + + dlv@1.1.3: {} + + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + eastasianwidth@0.2.0: {} + + editorconfig@1.0.4: + dependencies: + '@one-ini/wasm': 0.1.1 + commander: 10.0.1 + minimatch: 9.0.1 + semver: 7.7.3 + + electron-to-chromium@1.5.267: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + entities@6.0.1: {} + + entities@7.0.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-get-iterator@1.1.3: + dependencies: + call-bind: 1.0.8 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 + is-arguments: 1.2.0 + is-map: 2.0.3 + is-set: 2.0.3 + is-string: 1.1.1 + isarray: 2.0.5 + stop-iteration-iterator: 1.1.0 + + es-module-lexer@1.7.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + esbuild@0.27.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.2 + '@esbuild/android-arm': 0.27.2 + '@esbuild/android-arm64': 0.27.2 + '@esbuild/android-x64': 0.27.2 + '@esbuild/darwin-arm64': 0.27.2 + '@esbuild/darwin-x64': 0.27.2 + '@esbuild/freebsd-arm64': 0.27.2 + '@esbuild/freebsd-x64': 0.27.2 + '@esbuild/linux-arm': 0.27.2 + '@esbuild/linux-arm64': 0.27.2 + '@esbuild/linux-ia32': 0.27.2 + '@esbuild/linux-loong64': 0.27.2 + '@esbuild/linux-mips64el': 0.27.2 + '@esbuild/linux-ppc64': 0.27.2 + '@esbuild/linux-riscv64': 0.27.2 + '@esbuild/linux-s390x': 0.27.2 + '@esbuild/linux-x64': 0.27.2 + '@esbuild/netbsd-arm64': 0.27.2 + '@esbuild/netbsd-x64': 0.27.2 + '@esbuild/openbsd-arm64': 0.27.2 + '@esbuild/openbsd-x64': 0.27.2 + '@esbuild/openharmony-arm64': 0.27.2 + '@esbuild/sunos-x64': 0.27.2 + '@esbuild/win32-arm64': 0.27.2 + '@esbuild/win32-ia32': 0.27.2 + '@esbuild/win32-x64': 0.27.2 + + escalade@3.2.0: {} + + estree-walker@2.0.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + expect-type@1.3.0: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + follow-redirects@1.15.11: {} + + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + + fraction.js@5.3.4: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + functions-have-names@1.2.3: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.5 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + gopd@1.2.0: {} + + has-bigints@1.1.0: {} + + has-flag@4.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + hookable@5.5.3: {} + + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.8.0 + transitivePeerDependencies: + - '@exodus/crypto' + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + indent-string@4.0.0: {} + + ini@1.3.8: {} + + internal-slot@1.1.0: + dependencies: + es-errors: 1.3.0 + hasown: 2.0.2 + side-channel: 1.1.0 + + is-arguments@1.2.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-array-buffer@3.0.5: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + is-bigint@1.1.0: + dependencies: + has-bigints: 1.1.0 + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-boolean-object@1.2.2: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-callable@1.2.7: {} + + is-core-module@2.16.1: + dependencies: + hasown: 2.0.2 + + is-date-object@1.1.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-map@2.0.3: {} + + is-number-object@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-number@7.0.0: {} + + is-potential-custom-element-name@1.0.1: {} + + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + is-set@2.0.3: {} + + is-shared-array-buffer@1.0.4: + dependencies: + call-bound: 1.0.4 + + is-string@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-symbol@1.1.1: + dependencies: + call-bound: 1.0.4 + has-symbols: 1.1.0 + safe-regex-test: 1.1.0 + + is-weakmap@2.0.2: {} + + is-weakset@2.0.4: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + is-what@5.5.0: {} + + isarray@2.0.5: {} + + isexe@2.0.0: {} + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + jiti@1.21.7: {} + + js-beautify@1.15.4: + dependencies: + config-chain: 1.1.13 + editorconfig: 1.0.4 + glob: 10.5.0 + js-cookie: 3.0.5 + nopt: 7.2.1 + + js-cookie@3.0.5: {} + + js-tokens@4.0.0: {} + + jsdom@27.4.0: + dependencies: + '@acemir/cssom': 0.9.30 + '@asamuzakjp/dom-selector': 6.7.6 + '@exodus/bytes': 1.8.0 + cssstyle: 5.3.6 + data-urls: 6.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + parse5: 8.0.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.0 + whatwg-mimetype: 4.0.0 + whatwg-url: 15.1.0 + ws: 8.18.3 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@exodus/crypto' + - bufferutil + - supports-color + - utf-8-validate + + lightningcss-android-arm64@1.30.2: + optional: true + + lightningcss-darwin-arm64@1.30.2: + optional: true + + lightningcss-darwin-x64@1.30.2: + optional: true + + lightningcss-freebsd-x64@1.30.2: + optional: true + + lightningcss-linux-arm-gnueabihf@1.30.2: + optional: true + + lightningcss-linux-arm64-gnu@1.30.2: + optional: true + + lightningcss-linux-arm64-musl@1.30.2: + optional: true + + lightningcss-linux-x64-gnu@1.30.2: + optional: true + + lightningcss-linux-x64-musl@1.30.2: + optional: true + + lightningcss-win32-arm64-msvc@1.30.2: + optional: true + + lightningcss-win32-x64-msvc@1.30.2: + optional: true + + lightningcss@1.30.2: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.30.2 + lightningcss-darwin-arm64: 1.30.2 + lightningcss-darwin-x64: 1.30.2 + lightningcss-freebsd-x64: 1.30.2 + lightningcss-linux-arm-gnueabihf: 1.30.2 + lightningcss-linux-arm64-gnu: 1.30.2 + lightningcss-linux-arm64-musl: 1.30.2 + lightningcss-linux-x64-gnu: 1.30.2 + lightningcss-linux-x64-musl: 1.30.2 + lightningcss-win32-arm64-msvc: 1.30.2 + lightningcss-win32-x64-msvc: 1.30.2 + optional: true + + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + lru-cache@10.4.3: {} + + lru-cache@11.2.4: {} + + lucide-vue-next@0.562.0(vue@3.5.26(typescript@5.9.3)): + dependencies: + vue: 3.5.26(typescript@5.9.3) + + lz-string@1.5.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + marked@17.0.1: {} + + math-intrinsics@1.1.0: {} + + mdn-data@2.12.2: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + min-indent@1.0.1: {} + + minimatch@9.0.1: + dependencies: + brace-expansion: 2.0.2 + + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.2 + + minipass@7.1.2: {} + + mitt@3.0.1: {} + + ms@2.1.3: {} + + muggle-string@0.4.1: {} + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + nanoid@3.3.11: {} + + node-releases@2.0.27: {} + + nopt@7.2.1: + dependencies: + abbrev: 2.0.0 + + normalize-path@3.0.0: {} + + object-assign@4.1.1: {} + + object-hash@3.0.0: {} + + object-inspect@1.13.4: {} + + object-is@1.1.6: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + + object-keys@1.1.1: {} + + object.assign@4.1.7: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + has-symbols: 1.1.0 + object-keys: 1.1.1 + + obug@2.1.1: {} + + ohash@2.0.11: {} + + package-json-from-dist@1.0.1: {} + + parse5@8.0.0: + dependencies: + entities: 6.0.1 + + path-browserify@1.0.1: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.2 + + pathe@2.0.3: {} + + perfect-debounce@1.0.0: {} + + picocolors@1.1.1: {} + + picomatch@2.3.1: {} + + picomatch@4.0.3: {} + + pify@2.3.0: {} + + pinia@3.0.4(typescript@5.9.3)(vue@3.5.26(typescript@5.9.3)): + dependencies: + '@vue/devtools-api': 7.7.9 + vue: 3.5.26(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + + pirates@4.0.7: {} + + possible-typed-array-names@1.1.0: {} + + postcss-import@15.1.0(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + read-cache: 1.0.0 + resolve: 1.22.11 + + postcss-js@4.1.0(postcss@8.5.6): + dependencies: + camelcase-css: 2.0.1 + postcss: 8.5.6 + + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.6): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + jiti: 1.21.7 + postcss: 8.5.6 + + postcss-nested@6.2.0(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-selector-parser: 6.1.2 + + postcss-selector-parser@6.0.10: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-selector-parser@6.1.2: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-value-parser@4.2.0: {} + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + + proto-list@1.2.4: {} + + proxy-from-env@1.1.0: {} + + punycode@2.3.1: {} + + queue-microtask@1.2.3: {} + + react-is@17.0.2: {} + + read-cache@1.0.0: + dependencies: + pify: 2.3.0 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.1 + + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + + regexp.prototype.flags@1.5.4: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-errors: 1.3.0 + get-proto: 1.0.1 + gopd: 1.2.0 + set-function-name: 2.0.2 + + reka-ui@2.7.0(typescript@5.9.3)(vue@3.5.26(typescript@5.9.3)): + dependencies: + '@floating-ui/dom': 1.7.4 + '@floating-ui/vue': 1.1.9(vue@3.5.26(typescript@5.9.3)) + '@internationalized/date': 3.10.1 + '@internationalized/number': 3.6.5 + '@tanstack/vue-virtual': 3.13.14(vue@3.5.26(typescript@5.9.3)) + '@vueuse/core': 12.8.2(typescript@5.9.3) + '@vueuse/shared': 12.8.2(typescript@5.9.3) + aria-hidden: 1.2.6 + defu: 6.1.4 + ohash: 2.0.11 + vue: 3.5.26(typescript@5.9.3) + transitivePeerDependencies: + - '@vue/composition-api' + - typescript + + require-from-string@2.0.2: {} + + resolve@1.22.11: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + reusify@1.1.0: {} + + rfdc@1.4.1: {} + + rollup@4.54.0: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.54.0 + '@rollup/rollup-android-arm64': 4.54.0 + '@rollup/rollup-darwin-arm64': 4.54.0 + '@rollup/rollup-darwin-x64': 4.54.0 + '@rollup/rollup-freebsd-arm64': 4.54.0 + '@rollup/rollup-freebsd-x64': 4.54.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.54.0 + '@rollup/rollup-linux-arm-musleabihf': 4.54.0 + '@rollup/rollup-linux-arm64-gnu': 4.54.0 + '@rollup/rollup-linux-arm64-musl': 4.54.0 + '@rollup/rollup-linux-loong64-gnu': 4.54.0 + '@rollup/rollup-linux-ppc64-gnu': 4.54.0 + '@rollup/rollup-linux-riscv64-gnu': 4.54.0 + '@rollup/rollup-linux-riscv64-musl': 4.54.0 + '@rollup/rollup-linux-s390x-gnu': 4.54.0 + '@rollup/rollup-linux-x64-gnu': 4.54.0 + '@rollup/rollup-linux-x64-musl': 4.54.0 + '@rollup/rollup-openharmony-arm64': 4.54.0 + '@rollup/rollup-win32-arm64-msvc': 4.54.0 + '@rollup/rollup-win32-ia32-msvc': 4.54.0 + '@rollup/rollup-win32-x64-gnu': 4.54.0 + '@rollup/rollup-win32-x64-msvc': 4.54.0 + fsevents: 2.3.3 + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + semver@7.7.3: {} + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + set-function-name@2.0.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + siginfo@2.0.0: {} + + signal-exit@4.1.0: {} + + source-map-js@1.2.1: {} + + speakingurl@14.0.1: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + stop-iteration-iterator@1.1.0: + dependencies: + es-errors: 1.3.0 + internal-slot: 1.1.0 + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.2 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.1.2: + dependencies: + ansi-regex: 6.2.2 + + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + + sucrase@3.35.1: + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + commander: 4.1.1 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + tinyglobby: 0.2.15 + ts-interface-checker: 0.1.13 + + superjson@2.2.6: + dependencies: + copy-anything: 4.0.5 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + symbol-tree@3.2.4: {} + + tailwind-merge@3.4.0: {} + + tailwindcss-animate@1.0.7(tailwindcss@3.4.19): + dependencies: + tailwindcss: 3.4.19 + + tailwindcss@3.4.19: + dependencies: + '@alloc/quick-lru': 5.2.0 + arg: 5.0.2 + chokidar: 3.6.0 + didyoumean: 1.2.2 + dlv: 1.1.3 + fast-glob: 3.3.3 + glob-parent: 6.0.2 + is-glob: 4.0.3 + jiti: 1.21.7 + lilconfig: 3.1.3 + micromatch: 4.0.8 + normalize-path: 3.0.0 + object-hash: 3.0.0 + picocolors: 1.1.1 + postcss: 8.5.6 + postcss-import: 15.1.0(postcss@8.5.6) + postcss-js: 4.1.0(postcss@8.5.6) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.6) + postcss-nested: 6.2.0(postcss@8.5.6) + postcss-selector-parser: 6.1.2 + resolve: 1.22.11 + sucrase: 3.35.1 + transitivePeerDependencies: + - tsx + - yaml + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + tinybench@2.9.0: {} + + tinyexec@1.0.2: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + tinyrainbow@3.0.3: {} + + tldts-core@7.0.19: {} + + tldts@7.0.19: + dependencies: + tldts-core: 7.0.19 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + tough-cookie@6.0.0: + dependencies: + tldts: 7.0.19 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + + ts-interface-checker@0.1.13: {} + + tslib@2.8.1: {} + + typescript@5.9.3: {} + + update-browserslist-db@1.2.3(browserslist@4.28.1): + dependencies: + browserslist: 4.28.1 + escalade: 3.2.0 + picocolors: 1.1.1 + + util-deprecate@1.0.2: {} + + vite@7.3.0(jiti@1.21.7)(lightningcss@1.30.2): + dependencies: + esbuild: 0.27.2 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.54.0 + tinyglobby: 0.2.15 + optionalDependencies: + fsevents: 2.3.3 + jiti: 1.21.7 + lightningcss: 1.30.2 + + vitest@4.0.16(jiti@1.21.7)(jsdom@27.4.0)(lightningcss@1.30.2): + dependencies: + '@vitest/expect': 4.0.16 + '@vitest/mocker': 4.0.16(vite@7.3.0(jiti@1.21.7)(lightningcss@1.30.2)) + '@vitest/pretty-format': 4.0.16 + '@vitest/runner': 4.0.16 + '@vitest/snapshot': 4.0.16 + '@vitest/spy': 4.0.16 + '@vitest/utils': 4.0.16 + es-module-lexer: 1.7.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vite: 7.3.0(jiti@1.21.7)(lightningcss@1.30.2) + why-is-node-running: 2.3.0 + optionalDependencies: + jsdom: 27.4.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + + vscode-uri@3.1.0: {} + + vue-component-type-helpers@2.2.12: {} + + vue-demi@0.14.10(vue@3.5.26(typescript@5.9.3)): + dependencies: + vue: 3.5.26(typescript@5.9.3) + + vue-router@4.6.4(vue@3.5.26(typescript@5.9.3)): + dependencies: + '@vue/devtools-api': 6.6.4 + vue: 3.5.26(typescript@5.9.3) + + vue-tsc@3.2.1(typescript@5.9.3): + dependencies: + '@volar/typescript': 2.4.27 + '@vue/language-core': 3.2.1 + typescript: 5.9.3 + + vue@3.5.26(typescript@5.9.3): + dependencies: + '@vue/compiler-dom': 3.5.26 + '@vue/compiler-sfc': 3.5.26 + '@vue/runtime-dom': 3.5.26 + '@vue/server-renderer': 3.5.26(vue@3.5.26(typescript@5.9.3)) + '@vue/shared': 3.5.26 + optionalDependencies: + typescript: 5.9.3 + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@8.0.0: {} + + whatwg-mimetype@4.0.0: {} + + whatwg-url@15.1.0: + dependencies: + tr46: 6.0.0 + webidl-conversions: 8.0.0 + + which-boxed-primitive@1.1.1: + dependencies: + is-bigint: 1.1.0 + is-boolean-object: 1.2.2 + is-number-object: 1.1.1 + is-string: 1.1.1 + is-symbol: 1.1.1 + + which-collection@1.0.2: + dependencies: + is-map: 2.0.3 + is-set: 2.0.3 + is-weakmap: 2.0.2 + is-weakset: 2.0.4 + + which-typed-array@1.1.19: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.1.2 + + ws@8.18.3: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..64981d8 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,61 @@ + + + diff --git a/frontend/src/components/BypassManagementPanel.vue b/frontend/src/components/BypassManagementPanel.vue new file mode 100644 index 0000000..7152e45 --- /dev/null +++ b/frontend/src/components/BypassManagementPanel.vue @@ -0,0 +1,609 @@ + + + diff --git a/frontend/src/components/Dashboard.spec.ts b/frontend/src/components/Dashboard.spec.ts new file mode 100644 index 0000000..13b6048 --- /dev/null +++ b/frontend/src/components/Dashboard.spec.ts @@ -0,0 +1,208 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import Dashboard from './Dashboard.vue' +import { usePackageStore } from '../stores/packages' + +describe('Dashboard.vue', () => { + beforeEach(() => { + setActivePinia(createPinia()) + // Mock the fetch functions to prevent actual API calls + const store = usePackageStore() + vi.spyOn(store, 'fetchStats').mockResolvedValue() + vi.spyOn(store, 'fetchPackages').mockResolvedValue() + }) + + it('renders dashboard component', () => { + const wrapper = mount(Dashboard) + expect(wrapper.find('h2').text()).toBe('Dashboard') + }) + + it('displays error message when error occurs', async () => { + const wrapper = mount(Dashboard) + const store = usePackageStore() + + store.error = 'Failed to load dashboard' + await wrapper.vm.$nextTick() + + expect(wrapper.text()).toContain('Failed to load dashboard') + }) + + it('displays loading state when loading', async () => { + const wrapper = mount(Dashboard) + const store = usePackageStore() + + store.loading = true + await wrapper.vm.$nextTick() + + expect(wrapper.text()).toContain('Loading statistics...') + }) + + it('displays overview stats cards', async () => { + const wrapper = mount(Dashboard) + const store = usePackageStore() + + store.loading = false + store.stats = { + registry: '', + total_packages: 2, + total_size: 3072, + total_downloads: 30, + scanned_packages: 2, + vulnerable_packages: 0, + } + await wrapper.vm.$nextTick() + + expect(wrapper.text()).toContain('Total Packages') + expect(wrapper.text()).toContain('Total Size') + expect(wrapper.text()).toContain('Total Downloads') + }) + + it('displays total packages from stats', async () => { + const wrapper = mount(Dashboard) + const store = usePackageStore() + + store.loading = false + store.stats = { + registry: '', + total_packages: 100, + total_size: 0, + total_downloads: 0, + scanned_packages: 0, + vulnerable_packages: 0, + } + await wrapper.vm.$nextTick() + + expect(wrapper.text()).toContain('100') + }) + + it('displays total downloads from stats', async () => { + const wrapper = mount(Dashboard) + const store = usePackageStore() + + store.loading = false + store.stats = { + registry: '', + total_packages: 0, + total_size: 0, + total_downloads: 500, + scanned_packages: 0, + vulnerable_packages: 0, + } + await wrapper.vm.$nextTick() + + expect(wrapper.text()).toContain('500') + }) + + it('displays total size from stats', async () => { + const wrapper = mount(Dashboard) + const store = usePackageStore() + + store.loading = false + store.stats = { + registry: '', + total_packages: 0, + total_size: 1048576, // 1 MB + total_downloads: 0, + scanned_packages: 0, + vulnerable_packages: 0, + } + await wrapper.vm.$nextTick() + + expect(wrapper.text()).toContain('1 MB') + }) + + it('displays recent packages section', async () => { + const wrapper = mount(Dashboard) + const store = usePackageStore() + + store.loading = false + store.packages = [ + { + id: '1', + registry: 'npm', + name: 'test-package', + version: '1.0.0', + size: 1024, + cached_at: '2025-01-01T00:00:00Z', + last_accessed: '2025-01-01T00:00:00Z', + download_count: 10, + }, + ] + await wrapper.vm.$nextTick() + + expect(wrapper.text()).toContain('Recent Packages') + }) + + it('shows recent packages sorted by cached_at', async () => { + const wrapper = mount(Dashboard) + const store = usePackageStore() + + store.loading = false + store.packages = [ + { + id: '1', + registry: 'npm', + name: 'old-package', + version: '1.0.0', + size: 1024, + cached_at: '2025-01-01T00:00:00Z', + last_accessed: '2025-01-01T00:00:00Z', + download_count: 10, + }, + { + id: '2', + registry: 'npm', + name: 'new-package', + version: '1.0.0', + size: 1024, + cached_at: '2025-01-02T00:00:00Z', + last_accessed: '2025-01-02T00:00:00Z', + download_count: 5, + }, + ] + await wrapper.vm.$nextTick() + + const recentPackagesSection = wrapper.text().split('Recent Packages')[1] + + // new-package should appear before old-package since it's more recent + expect(recentPackagesSection.indexOf('new-package')).toBeLessThan( + recentPackagesSection.indexOf('old-package') + ) + }) + + it('limits recent packages to 10 items', async () => { + const wrapper = mount(Dashboard) + const store = usePackageStore() + + store.loading = false + store.packages = Array.from({ length: 15 }, (_, i) => ({ + id: `${i}`, + registry: 'npm', + name: `package${i}`, + version: '1.0.0', + size: 1024, + cached_at: `2025-01-${String(i + 1).padStart(2, '0')}T00:00:00Z`, + last_accessed: `2025-01-${String(i + 1).padStart(2, '0')}T00:00:00Z`, + download_count: i, + })) + await wrapper.vm.$nextTick() + + const recentSection = wrapper.text().split('Recent Packages')[1] + // Count how many "package" strings appear (each package has the word "package" in its name) + const packageCount = (recentSection.match(/package\d+/g) || []).length + expect(packageCount).toBeLessThanOrEqual(10) + }) + + it('handles empty packages array', async () => { + const wrapper = mount(Dashboard) + const store = usePackageStore() + + store.loading = false + store.packages = [] + await wrapper.vm.$nextTick() + + expect(wrapper.text()).toContain('0') + expect(wrapper.text()).toContain('0 B') + }) +}) diff --git a/frontend/src/components/Dashboard.vue b/frontend/src/components/Dashboard.vue new file mode 100644 index 0000000..7281ecc --- /dev/null +++ b/frontend/src/components/Dashboard.vue @@ -0,0 +1,305 @@ + + + diff --git a/frontend/src/components/PackageDetails.vue b/frontend/src/components/PackageDetails.vue new file mode 100644 index 0000000..0c518f1 --- /dev/null +++ b/frontend/src/components/PackageDetails.vue @@ -0,0 +1,416 @@ + + + diff --git a/frontend/src/components/PackageList.spec.ts b/frontend/src/components/PackageList.spec.ts new file mode 100644 index 0000000..a295b26 --- /dev/null +++ b/frontend/src/components/PackageList.spec.ts @@ -0,0 +1,187 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import PackageList from './PackageList.vue' +import { usePackageStore } from '../stores/packages' + +describe('PackageList.vue', () => { + beforeEach(() => { + // Create a fresh pinia instance before each test + setActivePinia(createPinia()) + }) + + it('renders package list component', () => { + const wrapper = mount(PackageList) + expect(wrapper.find('h2').text()).toBe('Packages') + }) + + it('displays loading state when loading', async () => { + const wrapper = mount(PackageList) + const store = usePackageStore() + + store.loading = true + await wrapper.vm.$nextTick() + + expect(wrapper.text()).toContain('Loading packages...') + }) + + it('displays error message when error occurs', async () => { + const wrapper = mount(PackageList) + const store = usePackageStore() + + store.error = 'Failed to fetch packages' + await wrapper.vm.$nextTick() + + expect(wrapper.text()).toContain('Failed to fetch packages') + }) + + it('displays empty state when no packages', async () => { + const wrapper = mount(PackageList) + const store = usePackageStore() + + store.loading = false + store.packages = [] + await wrapper.vm.$nextTick() + + expect(wrapper.text()).toContain('No packages cached yet') + }) + + it('displays package accordion when packages exist', async () => { + const wrapper = mount(PackageList) + const store = usePackageStore() + + store.loading = false + store.packages = [ + { + id: '1', + registry: 'npm', + name: 'test-package', + version: '1.0.0', + size: 1024, + cached_at: '2025-01-01T00:00:00Z', + last_accessed: '2025-01-01T00:00:00Z', + download_count: 10, + }, + ] + await wrapper.vm.$nextTick() + + expect(wrapper.text()).toContain('test-package') + expect(wrapper.text()).toContain('1 version') + }) + + it('calls fetchPackages on mount', () => { + const store = usePackageStore() + const fetchSpy = vi.spyOn(store, 'fetchPackages') + + mount(PackageList) + + expect(fetchSpy).toHaveBeenCalled() + }) + + it('groups packages and displays version counts', async () => { + const wrapper = mount(PackageList) + const store = usePackageStore() + + store.loading = false + store.packages = [ + { + id: '1', + registry: 'npm', + name: 'test-package', + version: '1.0.0', + size: 1024, + cached_at: '2025-01-01T00:00:00Z', + last_accessed: '2025-01-01T00:00:00Z', + download_count: 10, + }, + { + id: '2', + registry: 'npm', + name: 'test-package', + version: '2.0.0', + size: 2048, + cached_at: '2025-01-02T00:00:00Z', + last_accessed: '2025-01-02T00:00:00Z', + download_count: 20, + }, + ] + await wrapper.vm.$nextTick() + + expect(wrapper.text()).toContain('test-package') + expect(wrapper.text()).toContain('2 versions') + }) + + it('formats bytes correctly', async () => { + const wrapper = mount(PackageList) + const store = usePackageStore() + + store.loading = false + store.packages = [ + { + id: '1', + registry: 'npm', + name: 'test-package', + version: '1.0.0', + size: 1048576, // 1 MB + cached_at: '2025-01-01T00:00:00Z', + last_accessed: '2025-01-01T00:00:00Z', + download_count: 10, + }, + ] + await wrapper.vm.$nextTick() + + expect(wrapper.text()).toContain('1 MB') + }) + + it('applies correct registry badge classes', async () => { + const wrapper = mount(PackageList) + const store = usePackageStore() + + store.loading = false + store.packages = [ + { + id: '1', + registry: 'npm', + name: 'npm-package', + version: '1.0.0', + size: 1024, + cached_at: '2025-01-01T00:00:00Z', + last_accessed: '2025-01-01T00:00:00Z', + download_count: 10, + }, + { + id: '2', + registry: 'pypi', + name: 'python-package', + version: '1.0.0', + size: 1024, + cached_at: '2025-01-01T00:00:00Z', + last_accessed: '2025-01-01T00:00:00Z', + download_count: 5, + }, + { + id: '3', + registry: 'go', + name: 'go-module', + version: '1.0.0', + size: 1024, + cached_at: '2025-01-01T00:00:00Z', + last_accessed: '2025-01-01T00:00:00Z', + download_count: 3, + }, + ] + await wrapper.vm.$nextTick() + + // Verify that all registry badges are displayed + // Packages are grouped and sorted alphabetically, so order is: go-module, npm-package, python-package + expect(wrapper.text()).toContain('npm') + expect(wrapper.text()).toContain('pypi') + expect(wrapper.text()).toContain('go') + + // Verify badge component is used with correct classes + const html = wrapper.html() + expect(html).toContain('bg-blue-100') // npm badge + expect(html).toContain('bg-green-100') // pypi badge + expect(html).toContain('bg-yellow-100') // go badge + }) +}) diff --git a/frontend/src/components/PackageList.vue b/frontend/src/components/PackageList.vue new file mode 100644 index 0000000..6c49a9a --- /dev/null +++ b/frontend/src/components/PackageList.vue @@ -0,0 +1,418 @@ + + + diff --git a/frontend/src/components/Stats.spec.ts b/frontend/src/components/Stats.spec.ts new file mode 100644 index 0000000..bc7dc7a --- /dev/null +++ b/frontend/src/components/Stats.spec.ts @@ -0,0 +1,192 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import Stats from './Stats.vue' +import { usePackageStore } from '../stores/packages' + +describe('Stats.vue', () => { + beforeEach(() => { + setActivePinia(createPinia()) + }) + + it('renders stats component', () => { + const wrapper = mount(Stats) + expect(wrapper.find('h2').text()).toBe('Statistics') + }) + + it('displays loading state when loading', async () => { + const wrapper = mount(Stats) + const store = usePackageStore() + + store.loading = true + await wrapper.vm.$nextTick() + + expect(wrapper.text()).toContain('Loading statistics...') + }) + + it('displays error message when error occurs', async () => { + const wrapper = mount(Stats) + const store = usePackageStore() + + store.error = 'Failed to fetch statistics' + await wrapper.vm.$nextTick() + + expect(wrapper.text()).toContain('Failed to fetch statistics') + }) + + it('displays overall statistics', async () => { + const wrapper = mount(Stats) + const store = usePackageStore() + + store.loading = false + store.stats = { + registry: '', + total_packages: 100, + total_size: 1073741824, // 1 GB + total_downloads: 500, + scanned_packages: 90, + vulnerable_packages: 5, + } + await wrapper.vm.$nextTick() + + expect(wrapper.text()).toContain('Overall Statistics') + expect(wrapper.text()).toContain('100') + expect(wrapper.text()).toContain('500') + }) + + it('displays security scanning statistics', async () => { + const wrapper = mount(Stats) + const store = usePackageStore() + + store.loading = false + store.stats = { + registry: '', + total_packages: 100, + total_size: 1073741824, + total_downloads: 500, + scanned_packages: 90, + vulnerable_packages: 5, + } + await wrapper.vm.$nextTick() + + expect(wrapper.text()).toContain('Security Scanning') + expect(wrapper.text()).toContain('Scanned Packages') + expect(wrapper.text()).toContain('Vulnerable Packages') + }) + + it('displays registry breakdown', async () => { + const wrapper = mount(Stats) + const store = usePackageStore() + + store.loading = false + store.stats = { + registry: '', + total_packages: 100, + total_size: 1073741824, + total_downloads: 500, + scanned_packages: 90, + vulnerable_packages: 5, + } + store.registries = { + npm: { + count: 50, + size: 536870912, // 512 MB + downloads: 300, + }, + pypi: { + count: 30, + size: 322122547, // ~307 MB + downloads: 150, + }, + go: { + count: 20, + size: 214748365, // ~205 MB + downloads: 50, + }, + } + await wrapper.vm.$nextTick() + + expect(wrapper.text()).toContain('Registry Breakdown') + expect(wrapper.text()).toContain('NPM Registry') + expect(wrapper.text()).toContain('PyPI Registry') + expect(wrapper.text()).toContain('Go Modules') + expect(wrapper.text()).toContain('50 packages') + expect(wrapper.text()).toContain('30 packages') + expect(wrapper.text()).toContain('20 packages') + }) + + it('formats bytes correctly in overall stats', async () => { + const wrapper = mount(Stats) + const store = usePackageStore() + + store.loading = false + store.stats = { + registry: '', + total_packages: 100, + total_size: 1073741824, // 1 GB + total_downloads: 500, + scanned_packages: 90, + vulnerable_packages: 5, + } + await wrapper.vm.$nextTick() + + expect(wrapper.text()).toContain('1 GB') + }) + + it('calls fetchStats on mount', () => { + const store = usePackageStore() + const fetchSpy = vi.spyOn(store, 'fetchStats') + + mount(Stats) + + expect(fetchSpy).toHaveBeenCalled() + }) + + it('displays correct icon colors for different registries', async () => { + const wrapper = mount(Stats) + const store = usePackageStore() + + store.loading = false + store.stats = { + registry: '', + total_packages: 3, + total_size: 0, + total_downloads: 0, + scanned_packages: 0, + vulnerable_packages: 0, + } + store.registries = { + npm: { count: 1, size: 0, downloads: 0 }, + pypi: { count: 1, size: 0, downloads: 0 }, + go: { count: 1, size: 0, downloads: 0 }, + } + await wrapper.vm.$nextTick() + + const containers = wrapper.findAll('.rounded-full') + expect(containers[0].classes()).toContain('bg-red-100') // npm + expect(containers[1].classes()).toContain('bg-blue-100') // pypi + expect(containers[2].classes()).toContain('bg-cyan-100') // go + }) + + it('handles empty registries data', async () => { + const wrapper = mount(Stats) + const store = usePackageStore() + + store.loading = false + store.stats = { + registry: '', + total_packages: 0, + total_size: 0, + total_downloads: 0, + scanned_packages: 0, + vulnerable_packages: 0, + } + store.registries = {} + await wrapper.vm.$nextTick() + + expect(wrapper.text()).toContain('Registry Breakdown') + // Should have no registry items + const registryItems = wrapper.findAll('.bg-gray-50') + expect(registryItems.length).toBe(3) // Only the overall stats cards + }) +}) diff --git a/frontend/src/components/Stats.vue b/frontend/src/components/Stats.vue new file mode 100644 index 0000000..5dc5723 --- /dev/null +++ b/frontend/src/components/Stats.vue @@ -0,0 +1,194 @@ + + + diff --git a/frontend/src/components/VulnerabilityBadge.vue b/frontend/src/components/VulnerabilityBadge.vue new file mode 100644 index 0000000..085d9c8 --- /dev/null +++ b/frontend/src/components/VulnerabilityBadge.vue @@ -0,0 +1,129 @@ + + + diff --git a/frontend/src/components/VulnerablePackages.vue b/frontend/src/components/VulnerablePackages.vue new file mode 100644 index 0000000..3db4db0 --- /dev/null +++ b/frontend/src/components/VulnerablePackages.vue @@ -0,0 +1,307 @@ + + + diff --git a/frontend/src/components/ui/accordion/Accordion.vue b/frontend/src/components/ui/accordion/Accordion.vue new file mode 100644 index 0000000..6f233fc --- /dev/null +++ b/frontend/src/components/ui/accordion/Accordion.vue @@ -0,0 +1,18 @@ + + + diff --git a/frontend/src/components/ui/accordion/AccordionContent.vue b/frontend/src/components/ui/accordion/AccordionContent.vue new file mode 100644 index 0000000..1c83822 --- /dev/null +++ b/frontend/src/components/ui/accordion/AccordionContent.vue @@ -0,0 +1,22 @@ + + + diff --git a/frontend/src/components/ui/accordion/AccordionItem.vue b/frontend/src/components/ui/accordion/AccordionItem.vue new file mode 100644 index 0000000..0866cf1 --- /dev/null +++ b/frontend/src/components/ui/accordion/AccordionItem.vue @@ -0,0 +1,22 @@ + + + diff --git a/frontend/src/components/ui/accordion/AccordionTrigger.vue b/frontend/src/components/ui/accordion/AccordionTrigger.vue new file mode 100644 index 0000000..5fa7a03 --- /dev/null +++ b/frontend/src/components/ui/accordion/AccordionTrigger.vue @@ -0,0 +1,36 @@ + + + diff --git a/frontend/src/components/ui/accordion/index.ts b/frontend/src/components/ui/accordion/index.ts new file mode 100644 index 0000000..b18018b --- /dev/null +++ b/frontend/src/components/ui/accordion/index.ts @@ -0,0 +1,4 @@ +export { default as Accordion } from "./Accordion.vue" +export { default as AccordionContent } from "./AccordionContent.vue" +export { default as AccordionItem } from "./AccordionItem.vue" +export { default as AccordionTrigger } from "./AccordionTrigger.vue" diff --git a/frontend/src/components/ui/alert/Alert.vue b/frontend/src/components/ui/alert/Alert.vue new file mode 100644 index 0000000..9feea31 --- /dev/null +++ b/frontend/src/components/ui/alert/Alert.vue @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/components/ui/alert/AlertDescription.vue b/frontend/src/components/ui/alert/AlertDescription.vue new file mode 100644 index 0000000..afeaa01 --- /dev/null +++ b/frontend/src/components/ui/alert/AlertDescription.vue @@ -0,0 +1,14 @@ + + + diff --git a/frontend/src/components/ui/alert/AlertTitle.vue b/frontend/src/components/ui/alert/AlertTitle.vue new file mode 100644 index 0000000..1f98d11 --- /dev/null +++ b/frontend/src/components/ui/alert/AlertTitle.vue @@ -0,0 +1,14 @@ + + + diff --git a/frontend/src/components/ui/alert/index.ts b/frontend/src/components/ui/alert/index.ts new file mode 100644 index 0000000..22746b5 --- /dev/null +++ b/frontend/src/components/ui/alert/index.ts @@ -0,0 +1,24 @@ +import type { VariantProps } from "class-variance-authority" +import { cva } from "class-variance-authority" + +export { default as Alert } from "./Alert.vue" +export { default as AlertDescription } from "./AlertDescription.vue" +export { default as AlertTitle } from "./AlertTitle.vue" + +export const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +) + +export type AlertVariants = VariantProps diff --git a/frontend/src/components/ui/badge/Badge.vue b/frontend/src/components/ui/badge/Badge.vue new file mode 100644 index 0000000..0374568 --- /dev/null +++ b/frontend/src/components/ui/badge/Badge.vue @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/components/ui/badge/index.ts b/frontend/src/components/ui/badge/index.ts new file mode 100644 index 0000000..5ab6ef6 --- /dev/null +++ b/frontend/src/components/ui/badge/index.ts @@ -0,0 +1,26 @@ +import type { VariantProps } from "class-variance-authority" +import { cva } from "class-variance-authority" + +export { default as Badge } from "./Badge.vue" + +export const badgeVariants = cva( + "inline-flex gap-1 items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +) + +export type BadgeVariants = VariantProps diff --git a/frontend/src/components/ui/button/Button.vue b/frontend/src/components/ui/button/Button.vue new file mode 100644 index 0000000..3330ec9 --- /dev/null +++ b/frontend/src/components/ui/button/Button.vue @@ -0,0 +1,28 @@ + + + diff --git a/frontend/src/components/ui/button/index.ts b/frontend/src/components/ui/button/index.ts new file mode 100644 index 0000000..3b23ad4 --- /dev/null +++ b/frontend/src/components/ui/button/index.ts @@ -0,0 +1,38 @@ +import type { VariantProps } from "class-variance-authority" +import { cva } from "class-variance-authority" + +export { default as Button } from "./Button.vue" + +export const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground shadow hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", + outline: + "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + "default": "h-9 px-4 py-2", + "xs": "h-7 rounded px-2", + "sm": "h-8 rounded-md px-3 text-xs", + "lg": "h-10 rounded-md px-8", + "icon": "h-9 w-9", + "icon-sm": "size-8", + "icon-lg": "size-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +) + +export type ButtonVariants = VariantProps diff --git a/frontend/src/components/ui/card/Card.vue b/frontend/src/components/ui/card/Card.vue new file mode 100644 index 0000000..9b0be92 --- /dev/null +++ b/frontend/src/components/ui/card/Card.vue @@ -0,0 +1,21 @@ + + + diff --git a/frontend/src/components/ui/card/CardContent.vue b/frontend/src/components/ui/card/CardContent.vue new file mode 100644 index 0000000..4c4dfc7 --- /dev/null +++ b/frontend/src/components/ui/card/CardContent.vue @@ -0,0 +1,14 @@ + + + diff --git a/frontend/src/components/ui/card/CardDescription.vue b/frontend/src/components/ui/card/CardDescription.vue new file mode 100644 index 0000000..02bddba --- /dev/null +++ b/frontend/src/components/ui/card/CardDescription.vue @@ -0,0 +1,14 @@ + + + diff --git a/frontend/src/components/ui/card/CardFooter.vue b/frontend/src/components/ui/card/CardFooter.vue new file mode 100644 index 0000000..fad3928 --- /dev/null +++ b/frontend/src/components/ui/card/CardFooter.vue @@ -0,0 +1,14 @@ + + + diff --git a/frontend/src/components/ui/card/CardHeader.vue b/frontend/src/components/ui/card/CardHeader.vue new file mode 100644 index 0000000..119700c --- /dev/null +++ b/frontend/src/components/ui/card/CardHeader.vue @@ -0,0 +1,14 @@ + + + diff --git a/frontend/src/components/ui/card/CardTitle.vue b/frontend/src/components/ui/card/CardTitle.vue new file mode 100644 index 0000000..66d04ad --- /dev/null +++ b/frontend/src/components/ui/card/CardTitle.vue @@ -0,0 +1,18 @@ + + + diff --git a/frontend/src/components/ui/card/index.ts b/frontend/src/components/ui/card/index.ts new file mode 100644 index 0000000..e5c7cb2 --- /dev/null +++ b/frontend/src/components/ui/card/index.ts @@ -0,0 +1,6 @@ +export { default as Card } from "./Card.vue" +export { default as CardContent } from "./CardContent.vue" +export { default as CardDescription } from "./CardDescription.vue" +export { default as CardFooter } from "./CardFooter.vue" +export { default as CardHeader } from "./CardHeader.vue" +export { default as CardTitle } from "./CardTitle.vue" diff --git a/frontend/src/components/ui/dialog/Dialog.vue b/frontend/src/components/ui/dialog/Dialog.vue new file mode 100644 index 0000000..47b0968 --- /dev/null +++ b/frontend/src/components/ui/dialog/Dialog.vue @@ -0,0 +1,15 @@ + + + diff --git a/frontend/src/components/ui/dialog/DialogClose.vue b/frontend/src/components/ui/dialog/DialogClose.vue new file mode 100644 index 0000000..0295976 --- /dev/null +++ b/frontend/src/components/ui/dialog/DialogClose.vue @@ -0,0 +1,12 @@ + + + diff --git a/frontend/src/components/ui/dialog/DialogContent.vue b/frontend/src/components/ui/dialog/DialogContent.vue new file mode 100644 index 0000000..4bc0a1c --- /dev/null +++ b/frontend/src/components/ui/dialog/DialogContent.vue @@ -0,0 +1,46 @@ + + + diff --git a/frontend/src/components/ui/dialog/DialogDescription.vue b/frontend/src/components/ui/dialog/DialogDescription.vue new file mode 100644 index 0000000..062c3a5 --- /dev/null +++ b/frontend/src/components/ui/dialog/DialogDescription.vue @@ -0,0 +1,22 @@ + + + diff --git a/frontend/src/components/ui/dialog/DialogFooter.vue b/frontend/src/components/ui/dialog/DialogFooter.vue new file mode 100644 index 0000000..5f481e5 --- /dev/null +++ b/frontend/src/components/ui/dialog/DialogFooter.vue @@ -0,0 +1,19 @@ + + + diff --git a/frontend/src/components/ui/dialog/DialogHeader.vue b/frontend/src/components/ui/dialog/DialogHeader.vue new file mode 100644 index 0000000..33aa003 --- /dev/null +++ b/frontend/src/components/ui/dialog/DialogHeader.vue @@ -0,0 +1,16 @@ + + + diff --git a/frontend/src/components/ui/dialog/DialogScrollContent.vue b/frontend/src/components/ui/dialog/DialogScrollContent.vue new file mode 100644 index 0000000..17ba9ea --- /dev/null +++ b/frontend/src/components/ui/dialog/DialogScrollContent.vue @@ -0,0 +1,55 @@ + + + diff --git a/frontend/src/components/ui/dialog/DialogTitle.vue b/frontend/src/components/ui/dialog/DialogTitle.vue new file mode 100644 index 0000000..1de56de --- /dev/null +++ b/frontend/src/components/ui/dialog/DialogTitle.vue @@ -0,0 +1,27 @@ + + + diff --git a/frontend/src/components/ui/dialog/DialogTrigger.vue b/frontend/src/components/ui/dialog/DialogTrigger.vue new file mode 100644 index 0000000..a4fc3ee --- /dev/null +++ b/frontend/src/components/ui/dialog/DialogTrigger.vue @@ -0,0 +1,12 @@ + + + diff --git a/frontend/src/components/ui/dialog/index.ts b/frontend/src/components/ui/dialog/index.ts new file mode 100644 index 0000000..c9c577f --- /dev/null +++ b/frontend/src/components/ui/dialog/index.ts @@ -0,0 +1,9 @@ +export { default as Dialog } from "./Dialog.vue" +export { default as DialogClose } from "./DialogClose.vue" +export { default as DialogContent } from "./DialogContent.vue" +export { default as DialogDescription } from "./DialogDescription.vue" +export { default as DialogFooter } from "./DialogFooter.vue" +export { default as DialogHeader } from "./DialogHeader.vue" +export { default as DialogScrollContent } from "./DialogScrollContent.vue" +export { default as DialogTitle } from "./DialogTitle.vue" +export { default as DialogTrigger } from "./DialogTrigger.vue" diff --git a/frontend/src/components/ui/input/Input.vue b/frontend/src/components/ui/input/Input.vue new file mode 100644 index 0000000..f924222 --- /dev/null +++ b/frontend/src/components/ui/input/Input.vue @@ -0,0 +1,24 @@ + + + diff --git a/frontend/src/components/ui/input/index.ts b/frontend/src/components/ui/input/index.ts new file mode 100644 index 0000000..9976b86 --- /dev/null +++ b/frontend/src/components/ui/input/index.ts @@ -0,0 +1 @@ +export { default as Input } from "./Input.vue" diff --git a/frontend/src/components/ui/separator/Separator.vue b/frontend/src/components/ui/separator/Separator.vue new file mode 100644 index 0000000..3daf562 --- /dev/null +++ b/frontend/src/components/ui/separator/Separator.vue @@ -0,0 +1,29 @@ + + + diff --git a/frontend/src/components/ui/separator/index.ts b/frontend/src/components/ui/separator/index.ts new file mode 100644 index 0000000..4407287 --- /dev/null +++ b/frontend/src/components/ui/separator/index.ts @@ -0,0 +1 @@ +export { default as Separator } from "./Separator.vue" diff --git a/frontend/src/components/ui/skeleton/Skeleton.vue b/frontend/src/components/ui/skeleton/Skeleton.vue new file mode 100644 index 0000000..7dd5e2f --- /dev/null +++ b/frontend/src/components/ui/skeleton/Skeleton.vue @@ -0,0 +1,14 @@ + + + diff --git a/frontend/src/components/ui/skeleton/index.ts b/frontend/src/components/ui/skeleton/index.ts new file mode 100644 index 0000000..e5ce72c --- /dev/null +++ b/frontend/src/components/ui/skeleton/index.ts @@ -0,0 +1 @@ +export { default as Skeleton } from "./Skeleton.vue" diff --git a/frontend/src/components/ui/table/Table.vue b/frontend/src/components/ui/table/Table.vue new file mode 100644 index 0000000..efc0e91 --- /dev/null +++ b/frontend/src/components/ui/table/Table.vue @@ -0,0 +1,15 @@ + + + diff --git a/frontend/src/components/ui/table/TableBody.vue b/frontend/src/components/ui/table/TableBody.vue new file mode 100644 index 0000000..e1d33ec --- /dev/null +++ b/frontend/src/components/ui/table/TableBody.vue @@ -0,0 +1,13 @@ + + + diff --git a/frontend/src/components/ui/table/TableCell.vue b/frontend/src/components/ui/table/TableCell.vue new file mode 100644 index 0000000..ea7f34e --- /dev/null +++ b/frontend/src/components/ui/table/TableCell.vue @@ -0,0 +1,13 @@ + + + diff --git a/frontend/src/components/ui/table/TableHead.vue b/frontend/src/components/ui/table/TableHead.vue new file mode 100644 index 0000000..d3f5ce0 --- /dev/null +++ b/frontend/src/components/ui/table/TableHead.vue @@ -0,0 +1,13 @@ + + + diff --git a/frontend/src/components/ui/table/TableHeader.vue b/frontend/src/components/ui/table/TableHeader.vue new file mode 100644 index 0000000..ff95b56 --- /dev/null +++ b/frontend/src/components/ui/table/TableHeader.vue @@ -0,0 +1,13 @@ + + + diff --git a/frontend/src/components/ui/table/TableRow.vue b/frontend/src/components/ui/table/TableRow.vue new file mode 100644 index 0000000..7b61bde --- /dev/null +++ b/frontend/src/components/ui/table/TableRow.vue @@ -0,0 +1,13 @@ + + + diff --git a/frontend/src/components/ui/table/index.ts b/frontend/src/components/ui/table/index.ts new file mode 100644 index 0000000..79faa02 --- /dev/null +++ b/frontend/src/components/ui/table/index.ts @@ -0,0 +1,6 @@ +export { default as Table } from './Table.vue' +export { default as TableHeader } from './TableHeader.vue' +export { default as TableBody } from './TableBody.vue' +export { default as TableRow } from './TableRow.vue' +export { default as TableHead } from './TableHead.vue' +export { default as TableCell } from './TableCell.vue' diff --git a/frontend/src/composables/useBadgeStyles.ts b/frontend/src/composables/useBadgeStyles.ts new file mode 100644 index 0000000..c62b7fe --- /dev/null +++ b/frontend/src/composables/useBadgeStyles.ts @@ -0,0 +1,59 @@ +/** + * Shared badge styling utilities for consistent UI across the application + */ + +/** + * Get Tailwind CSS classes for severity badges (light theme) + * @param severity - Severity level (CRITICAL, HIGH, MODERATE/MEDIUM, LOW) + * @returns Tailwind CSS class string + */ +export function getSeverityBadgeClass(severity: string): string { + const classes: Record = { + CRITICAL: 'bg-red-100 text-red-800 border-red-300', + HIGH: 'bg-orange-100 text-orange-800 border-orange-300', + MEDIUM: 'bg-yellow-100 text-yellow-800 border-yellow-300', + MODERATE: 'bg-yellow-100 text-yellow-800 border-yellow-300', + LOW: 'bg-blue-100 text-blue-800 border-blue-300', + } + return classes[severity.toUpperCase()] || 'bg-gray-100 text-gray-800 border-gray-300' +} + +/** + * Get Tailwind CSS classes for registry badges (light theme) + * @param registry - Registry name (npm, pypi, go) + * @returns Tailwind CSS class string + */ +export function getRegistryBadgeClass(registry: string): string { + const classes: Record = { + npm: 'bg-red-100 text-red-800 border-red-300', + pypi: 'bg-blue-100 text-blue-800 border-blue-300', + go: 'bg-cyan-100 text-cyan-800 border-cyan-300', + } + return classes[registry.toLowerCase()] || 'bg-gray-100 text-gray-800 border-gray-300' +} + +/** + * Get Tailwind CSS classes for vulnerability border indicators + * @param severity - Severity level (CRITICAL, HIGH, MODERATE/MEDIUM, LOW) + * @returns Tailwind CSS class string for left border + */ +export function getVulnerabilityBorderClass(severity: string): string { + const classes: Record = { + CRITICAL: 'border-l-4 border-l-red-600', + HIGH: 'border-l-4 border-l-orange-500', + MEDIUM: 'border-l-4 border-l-yellow-500', + MODERATE: 'border-l-4 border-l-yellow-500', + LOW: 'border-l-4 border-l-blue-500', + } + return classes[severity.toUpperCase()] || 'border-l-4 border-l-gray-500' +} + +/** + * Format severity name for display (title case) + * @param severity - Severity level (e.g., "CRITICAL", "HIGH") + * @returns Formatted severity name (e.g., "Critical", "High") + */ +export function formatSeverityName(severity: string): string { + const normalized = severity.toUpperCase() + return normalized.charAt(0) + normalized.slice(1).toLowerCase() +} diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts new file mode 100644 index 0000000..c66a9d9 --- /dev/null +++ b/frontend/src/lib/utils.ts @@ -0,0 +1,7 @@ +import type { ClassValue } from "clsx" +import { clsx } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..5434785 --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,11 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import router from './router' +import App from './App.vue' +import './styles/main.css' + +const app = createApp(App) + +app.use(createPinia()) +app.use(router) +app.mount('#app') diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts new file mode 100644 index 0000000..206dfe3 --- /dev/null +++ b/frontend/src/router/index.ts @@ -0,0 +1,48 @@ +import { createRouter, createWebHistory } from 'vue-router' +import Dashboard from '../components/Dashboard.vue' +import PackageList from '../components/PackageList.vue' +import PackageDetails from '../components/PackageDetails.vue' +import Stats from '../components/Stats.vue' +import VulnerablePackages from '../components/VulnerablePackages.vue' +import BypassManagementPanel from '../components/BypassManagementPanel.vue' + +const router = createRouter({ + history: createWebHistory(), + routes: [ + { + path: '/', + name: 'dashboard', + component: Dashboard, + }, + { + path: '/packages/:registry?', + name: 'packages', + component: PackageList, + props: true, + }, + { + // Separate route for package details - supports names with slashes (Go packages) + path: '/package/:registry/:name+/:version', + name: 'package-details', + component: PackageDetails, + props: true, + }, + { + path: '/stats', + name: 'stats', + component: Stats, + }, + { + path: '/vulnerable-packages', + name: 'vulnerable-packages', + component: VulnerablePackages, + }, + { + path: '/admin/bypasses', + name: 'bypasses', + component: BypassManagementPanel, + }, + ], +}) + +export default router diff --git a/frontend/src/stores/packages.ts b/frontend/src/stores/packages.ts new file mode 100644 index 0000000..6380be2 --- /dev/null +++ b/frontend/src/stores/packages.ts @@ -0,0 +1,115 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import axios from 'axios' + +export interface VulnerabilityCounts { + critical: number + high: number + moderate: number + low: number +} + +export interface VulnerabilityInfo { + scanned: boolean + status: 'clean' | 'vulnerable' | 'pending' | 'not_scanned' + counts?: VulnerabilityCounts + total?: number + scannedAt?: string // ISO 8601 timestamp +} + +export interface Package { + id: string + registry: string + name: string + version: string + size: number + cached_at: string + last_accessed: string + download_count: number + vulnerabilities?: VulnerabilityInfo +} + +export interface Stats { + registry: string + total_packages: number + total_size: number + total_downloads: number + scanned_packages: number + vulnerable_packages: number +} + +export const usePackageStore = defineStore('packages', () => { + const packages = ref([]) + const stats = ref(null) + const registries = ref>({}) + const loading = ref(false) + const error = ref(null) + + async function fetchPackages() { + loading.value = true + error.value = null + try { + const response = await axios.get('/api/packages') + // Only update packages if we got valid data + if (response.data && Array.isArray(response.data.packages)) { + packages.value = response.data.packages + } else { + console.warn('Unexpected API response format:', response.data) + error.value = 'Unexpected response format from server' + } + } catch (err: any) { + console.error('Failed to fetch packages:', err) + error.value = err.message + // Don't clear packages on error - keep showing the cached data + } finally { + loading.value = false + } + } + + async function fetchStats(registry = '') { + loading.value = true + error.value = null + try { + const url = registry ? `/api/stats?registry=${registry}` : '/api/stats' + const response = await axios.get(url) + // Only update stats if we got valid data + if (response.data && response.data.stats) { + stats.value = response.data.stats + registries.value = response.data.registries || {} + } else { + console.warn('Unexpected stats response format:', response.data) + error.value = 'Unexpected stats response format from server' + } + } catch (err: any) { + console.error('Failed to fetch stats:', err) + error.value = err.message + // Don't clear stats on error - keep showing the cached data + } finally { + loading.value = false + } + } + + async function deletePackage(registry: string, name: string, version: string) { + loading.value = true + error.value = null + try { + await axios.delete(`/api/packages/${registry}/${name}/${version}`) + await fetchPackages() + } catch (err: any) { + error.value = err.message + } finally { + loading.value = false + } + } + + return { + packages, + stats, + registries, + loading, + error, + fetchPackages, + fetchStats, + deletePackage, + } +}) diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css new file mode 100644 index 0000000..cef7dbb --- /dev/null +++ b/frontend/src/styles/main.css @@ -0,0 +1,83 @@ +@import '@fortawesome/fontawesome-free/css/all.min.css'; + +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + /* Tailwind slate-50 for background */ + --background: 210 40% 98%; + /* Tailwind slate-900 for foreground */ + --foreground: 222 47% 11%; + /* White for cards */ + --card: 0 0% 100%; + --card-foreground: 222 47% 11%; + --popover: 0 0% 100%; + --popover-foreground: 222 47% 11%; + /* Tailwind slate-700 for primary */ + --primary: 215 25% 27%; + --primary-foreground: 0 0% 100%; + /* Tailwind slate-100 for secondary */ + --secondary: 210 40% 96%; + --secondary-foreground: 222 47% 11%; + /* Tailwind slate-100 for muted */ + --muted: 210 40% 96%; + /* Tailwind slate-500 for muted-foreground */ + --muted-foreground: 215 20% 65%; + --accent: 210 40% 96%; + --accent-foreground: 222 47% 11%; + /* Tailwind red-500 for destructive */ + --destructive: 0 84% 60%; + --destructive-foreground: 0 0% 98%; + /* Tailwind slate-200 for border */ + --border: 214 32% 91%; + --input: 214 32% 91%; + /* Tailwind slate-700 for ring */ + --ring: 215 25% 27%; + /* Chart colors using Tailwind palette with slate base */ + --chart-1: 215 25% 27%; + --chart-2: 200 98% 39%; + --chart-3: 142 71% 45%; + --chart-4: 25 95% 53%; + --chart-5: 262 83% 58%; + --radius: 0.75rem; + } + + .dark { + --background: 222 47% 11%; + --foreground: 210 40% 98%; + --card: 222 47% 11%; + --card-foreground: 210 40% 98%; + --popover: 222 47% 11%; + --popover-foreground: 210 40% 98%; + --primary: 200 98% 39%; + --primary-foreground: 0 0% 100%; + --secondary: 217 33% 17%; + --secondary-foreground: 210 40% 98%; + --muted: 217 33% 17%; + --muted-foreground: 215 20% 65%; + --accent: 217 33% 17%; + --accent-foreground: 210 40% 98%; + --destructive: 0 84% 60%; + --destructive-foreground: 0 0% 98%; + --border: 217 33% 17%; + --input: 217 33% 17%; + --ring: 200 98% 39%; + --chart-1: 200 98% 39%; + --chart-2: 142 71% 45%; + --chart-3: 262 83% 58%; + --chart-4: 25 95% 53%; + --chart-5: 340 82% 52%; + } + + * { + @apply border-border; + } + + body { + @apply bg-background text-foreground; + } +} + +/* Custom component styles have been replaced with shadcn-vue components */ diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts new file mode 100644 index 0000000..2f3ea6b --- /dev/null +++ b/frontend/src/test/setup.ts @@ -0,0 +1,11 @@ +import { expect, afterEach } from 'vitest' +import { cleanup } from '@testing-library/vue' +import * as matchers from '@testing-library/jest-dom/matchers' + +// Extend Vitest's expect with jest-dom matchers +expect.extend(matchers) + +// Cleanup after each test case +afterEach(() => { + cleanup() +}) diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..82575c4 --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,91 @@ +/** @type {import('tailwindcss').Config} */ +export default { + darkMode: ["class"], + content: [ + "./index.html", + "./src/**/*.{vue,js,ts,jsx,tsx}", + ], + theme: { + extend: { + screens: { + '3xl': '2560px', + }, + fontFamily: { + sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'], + }, + colors: { + primary: { + DEFAULT: 'hsl(var(--primary))', + foreground: 'hsl(var(--primary-foreground))' + }, + background: 'hsl(var(--background))', + foreground: 'hsl(var(--foreground))', + card: { + DEFAULT: 'hsl(var(--card))', + foreground: 'hsl(var(--card-foreground))' + }, + popover: { + DEFAULT: 'hsl(var(--popover))', + foreground: 'hsl(var(--popover-foreground))' + }, + secondary: { + DEFAULT: 'hsl(var(--secondary))', + foreground: 'hsl(var(--secondary-foreground))' + }, + muted: { + DEFAULT: 'hsl(var(--muted))', + foreground: 'hsl(var(--muted-foreground))' + }, + accent: { + DEFAULT: 'hsl(var(--accent))', + foreground: 'hsl(var(--accent-foreground))' + }, + destructive: { + DEFAULT: 'hsl(var(--destructive))', + foreground: 'hsl(var(--destructive-foreground))' + }, + border: 'hsl(var(--border))', + input: 'hsl(var(--input))', + ring: 'hsl(var(--ring))', + chart: { + '1': 'hsl(var(--chart-1))', + '2': 'hsl(var(--chart-2))', + '3': 'hsl(var(--chart-3))', + '4': 'hsl(var(--chart-4))', + '5': 'hsl(var(--chart-5))' + } + }, + borderRadius: { + lg: 'var(--radius)', + md: 'calc(var(--radius) - 2px)', + sm: 'calc(var(--radius) - 4px)' + }, + keyframes: { + 'accordion-down': { + from: { + height: '0' + }, + to: { + height: 'var(--reka-accordion-content-height)' + } + }, + 'accordion-up': { + from: { + height: 'var(--reka-accordion-content-height)' + }, + to: { + height: '0' + } + } + }, + animation: { + 'accordion-down': 'accordion-down 0.2s ease-out', + 'accordion-up': 'accordion-up 0.2s ease-out' + } + } + }, + plugins: [ + require("tailwindcss-animate"), + require("@tailwindcss/typography"), + ], +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..f7ffb6a --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "preserve", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"], + }, + }, + "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], + "references": [{ "path": "./tsconfig.node.json" }], +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..42872c5 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..f73c940 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,34 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import path from 'path' + +// Get backend URL from environment or use default +const BACKEND_URL = process.env.VITE_BACKEND_URL || 'http://localhost:8080' +const FRONTEND_PORT = parseInt(process.env.VITE_PORT || '5173') + +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + server: { + port: FRONTEND_PORT, + proxy: { + '/api': { + target: BACKEND_URL, + changeOrigin: true, + }, + '/ws': { + target: BACKEND_URL.replace('http', 'ws'), + ws: true, + changeOrigin: true, + }, + }, + }, + build: { + outDir: 'dist', + sourcemap: false, + }, +}) diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 0000000..3e38ccc --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'vitest/config' +import vue from '@vitejs/plugin-vue' +import path from 'path' + +export default defineConfig({ + plugins: [vue()], + test: { + globals: true, + environment: 'jsdom', + setupFiles: ['./src/test/setup.ts'], + }, + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, +}) diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e51e58d --- /dev/null +++ b/go.mod @@ -0,0 +1,85 @@ +module github.com/lukaszraczylo/gohoarder + +go 1.25.5 + +require ( + github.com/aws/aws-sdk-go-v2 v1.41.0 + github.com/aws/aws-sdk-go-v2/config v1.32.6 + github.com/aws/aws-sdk-go-v2/credentials v1.19.6 + github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0 + github.com/goccy/go-json v0.10.5 + github.com/gofiber/fiber/v2 v2.52.10 + github.com/gorilla/websocket v1.5.3 + github.com/hirochachacha/go-smb2 v1.1.0 + github.com/prometheus/client_golang v1.23.2 + github.com/redis/go-redis/v9 v9.17.2 + github.com/rs/zerolog v1.34.0 + github.com/spf13/cobra v1.10.2 + github.com/spf13/viper v1.21.0 + github.com/stretchr/testify v1.11.1 + golang.org/x/crypto v0.46.0 + golang.org/x/sync v0.19.0 + golang.org/x/time v0.14.0 + modernc.org/sqlite v1.42.2 +) + +require ( + github.com/andybalholm/brotli v1.2.0 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect + github.com/aws/smithy-go v1.24.0 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/geoffgarside/ber v1.2.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/klauspost/compress v1.18.2 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.67.4 // indirect + github.com/prometheus/procfs v0.19.2 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/sagikazarmark/locafero v0.12.0 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/stretchr/objx v0.5.2 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.68.0 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.32.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/libc v1.67.4 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..bf46690 --- /dev/null +++ b/go.sum @@ -0,0 +1,223 @@ +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4= +github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4= +github.com/aws/aws-sdk-go-v2/config v1.32.6 h1:hFLBGUKjmLAekvi1evLi5hVvFQtSo3GYwi+Bx4lpJf8= +github.com/aws/aws-sdk-go-v2/config v1.32.6/go.mod h1:lcUL/gcd8WyjCrMnxez5OXkO3/rwcNmvfno62tnXNcI= +github.com/aws/aws-sdk-go-v2/credentials v1.19.6 h1:F9vWao2TwjV2MyiyVS+duza0NIRtAslgLUM0vTA1ZaE= +github.com/aws/aws-sdk-go-v2/credentials v1.19.6/go.mod h1:SgHzKjEVsdQr6Opor0ihgWtkWdfRAIwxYzSJ8O85VHY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 h1:80+uETIWS1BqjnN9uJ0dBUaETh+P1XwFy5vwHwK5r9k= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16/go.mod h1:wOOsYuxYuB/7FlnVtzeBYRcjSRtQpAW0hCP7tIULMwo= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16/go.mod h1:L/UxsGeKpGoIj6DxfhOWHWQ/kGKcd4I1VncE4++IyKA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx2F0xmJcOw5374L7N6juGW6x6U= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16/go.mod h1:M2E5OQf+XLe+SZGmmpaI2yy+J326aFf6/+54PoxSANc= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 h1:CjMzUs78RDDv4ROu3JnJn/Ig1r6ZD7/T2DXLLRpejic= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16/go.mod h1:uVW4OLBqbJXSHJYA9svT9BluSvvwbzLQ2Crf6UPzR3c= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 h1:DIBqIrJ7hv+e4CmIk2z3pyKT+3B6qVMgRsawHiR3qso= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7/go.mod h1:vLm00xmBke75UmpNvOcZQ/Q30ZFjbczeLFqGx5urmGo= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy0ImIV0bsrX0X91GkV5nJAyv1l1CC9lnO0TI= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 h1:NSbvS17MlI2lurYgXnCOLvCFX38sBW4eiVER7+kkgsU= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16/go.mod h1:SwT8Tmqd4sA6G1qaGdzWCJN99bUmPGHfRwwq3G5Qb+A= +github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0 h1:MIWra+MSq53CFaXXAywB2qg9YvVZifkk6vEGl/1Qor0= +github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0/go.mod h1:79S2BdqCJpScXZA2y+cpZuocWsjGjJINyXnOsf5DTz8= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv5EFYXePW30p1EIrbQ= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.4/go.mod h1:C5RdGMYGlfM0gYq/tifqgn4EbyX99V15P2V3R+VHbQU= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 h1:aM/Q24rIlS3bRAhTyFurowU8A0SMyGDtEOY/l/s/1Uw= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.8/go.mod h1:+fWt2UHSb4kS7Pu8y+BMBvJF0EWx+4H0hzNwtDNRTrg= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 h1:AHDr0DaHIAo8c9t1emrzAlVDFp+iMMKnPdYy6XO4MCE= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12/go.mod h1:GQ73XawFFiWxyWXMHWfhiomvP3tXtdNar/fi8z18sx0= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk= +github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= +github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= +github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/geoffgarside/ber v1.1.0/go.mod h1:jVPKeCbj6MvQZhwLYsGwaGI52oUorHoHKNecGT85ZCc= +github.com/geoffgarside/ber v1.2.0 h1:/loowoRcs/MWLYmGX9QtIAbA+V/FrnVLsMMPhwiRm64= +github.com/geoffgarside/ber v1.2.0/go.mod h1:jVPKeCbj6MvQZhwLYsGwaGI52oUorHoHKNecGT85ZCc= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gofiber/fiber/v2 v2.52.10 h1:jRHROi2BuNti6NYXmZ6gbNSfT3zj/8c0xy94GOU5elY= +github.com/gofiber/fiber/v2 v2.52.10/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/hirochachacha/go-smb2 v1.1.0 h1:b6hs9qKIql9eVXAiN0M2wSFY5xnhbHAQoCwRKbaRTZI= +github.com/hirochachacha/go-smb2 v1.1.0/go.mod h1:8F1A4d5EZzrGu5R7PU163UcMRDJQl4FtcxjBfsY8TZE= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= +github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc= +github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= +github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= +github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= +github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI= +github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= +github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.68.0 h1:v12Nx16iepr8r9ySOwqI+5RBJ/DqTxhOy1HrHoDFnok= +github.com/valyala/fasthttp v1.68.0/go.mod h1:5EXiRfYQAoiO/khu4oU9VISC/eVY6JqmSpPJoHCKsz4= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0= +golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= +modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= +modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM= +modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= +modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE= +modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.67.4 h1:zZGmCMUVPORtKv95c2ReQN5VDjvkoRm9GWPTEPuvlWg= +modernc.org/libc v1.67.4/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.42.2 h1:7hkZUNJvJFN2PgfUdjni9Kbvd4ef4mNLOu0B9FGxM74= +modernc.org/sqlite v1.42.2/go.mod h1:+VkC6v3pLOAE0A0uVucQEcbVW0I5nHCeDaBf+DpsQT8= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/helm/gohoarder/.helmignore b/helm/gohoarder/.helmignore new file mode 100644 index 0000000..819ee6e --- /dev/null +++ b/helm/gohoarder/.helmignore @@ -0,0 +1,31 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ +# CI/CD +.github/ +.gitlab-ci.yml +.travis.yml +# Documentation (keep README.md for chart documentation) +# README.md should be included in the chart package +docs/ +examples/ diff --git a/helm/gohoarder/Chart.yaml b/helm/gohoarder/Chart.yaml new file mode 100644 index 0000000..fe8ec29 --- /dev/null +++ b/helm/gohoarder/Chart.yaml @@ -0,0 +1,22 @@ +apiVersion: v2 +name: gohoarder +description: A universal package cache proxy supporting npm, PyPI, and Go modules with security scanning +type: application +version: 1.0.0 +appVersion: "1.0.0" +keywords: + - package-manager + - cache + - proxy + - npm + - pypi + - go-modules + - security + - vulnerability-scanning +home: https://github.com/lukaszraczylo/gohoarder +sources: + - https://github.com/lukaszraczylo/gohoarder +maintainers: + - name: Lukasz Raczylo + email: lukasz@raczylo.com +icon: https://raw.githubusercontent.com/lukaszraczylo/gohoarder/main/docs/logo.png diff --git a/helm/gohoarder/LICENSE b/helm/gohoarder/LICENSE new file mode 100644 index 0000000..745270f --- /dev/null +++ b/helm/gohoarder/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Lukasz Raczylo + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/helm/gohoarder/README.md b/helm/gohoarder/README.md new file mode 100644 index 0000000..c41e26b --- /dev/null +++ b/helm/gohoarder/README.md @@ -0,0 +1,499 @@ +# GoHoarder Helm Chart + +A universal package cache proxy supporting npm, PyPI, and Go modules with integrated security scanning. + +## Features + +- **Multi-Registry Support**: Proxy for npm, PyPI, and Go modules +- **Security Scanning**: Integrated vulnerability scanning with multiple scanners +- **Flexible Storage**: Support for filesystem, S3, and SMB storage backends +- **Metadata Storage**: SQLite or PostgreSQL for metadata +- **Auto-Configuration**: Generates configuration from Helm values +- **Production Ready**: Includes health checks, resource limits, and security contexts + +## Prerequisites + +- Kubernetes 1.19+ +- Helm 3.0+ +- PV provisioner support in the underlying infrastructure (for persistent storage) + +## Installation + +### Add Helm Repository + +```bash +helm repo add gohoarder https://lukaszraczylo.github.io/gohoarder +helm repo update +``` + +### Install Chart + +```bash +# Install with default values +helm install gohoarder gohoarder/gohoarder + +# Install with custom values +helm install gohoarder gohoarder/gohoarder -f values.yaml + +# Install in a specific namespace +helm install gohoarder gohoarder/gohoarder -n gohoarder --create-namespace +``` + +## Quick Start Examples + +### Minimal Installation + +```bash +helm install gohoarder gohoarder/gohoarder \ + --set global.domain=example.com \ + --set ingress.enabled=true +``` + +### With Security Scanning + +```bash +helm install gohoarder gohoarder/gohoarder \ + --set security.enabled=true \ + --set security.scanners.trivy.enabled=true \ + --set security.scanners.osv.enabled=true +``` + +### With S3 Storage + +```bash +helm install gohoarder gohoarder/gohoarder \ + --set storage.backend=s3 \ + --set storage.s3.bucket=my-bucket \ + --set storage.s3.region=us-east-1 \ + --set storage.s3.accessKeyId=AKIAIOSFODNN7EXAMPLE \ + --set storage.s3.secretAccessKey=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY +``` + +### With Private Container Registry + +If using images from a private registry, create an image pull secret and reference it: + +```bash +# Create a Docker registry secret +kubectl create secret docker-registry ghcr-secret \ + --docker-server=ghcr.io \ + --docker-username= \ + --docker-password= \ + --docker-email= \ + -n gohoarder + +# Install with the secret +helm install gohoarder gohoarder/gohoarder \ + --set global.imagePullSecrets[0].name=ghcr-secret \ + -n gohoarder +``` + +Or using a values file to reference existing secrets: + +```yaml +global: + imagePullSecrets: + - name: ghcr-secret + - name: dockerhub-secret # Multiple secrets supported +``` + +**Auto-create secrets** (chart will create them for you): + +```yaml +imageCredentials: + ghcr-secret: + registry: ghcr.io + username: myusername + password: mytoken + email: myemail@example.com + +global: + imagePullSecrets: + - name: ghcr-secret +``` + +> **Note**: Storing credentials in values files is less secure than creating secrets manually. Consider using external secret management solutions like Sealed Secrets or External Secrets Operator for production. + +## Configuration Methods + +GoHoarder supports two configuration methods that can be used together: + +### 1. ConfigMap (Default) + +The chart automatically generates a `config.yaml` from Helm values and mounts it as a ConfigMap. This is the default approach and works out of the box. + +### 2. Environment Variables + +You can override any configuration using environment variables with the format `GOHOARDER_` where dots are replaced with underscores. + +**Example using values file:** + +```yaml +server: + env: + - name: GOHOARDER_STORAGE_BACKEND + value: "s3" + - name: GOHOARDER_STORAGE_S3_BUCKET + value: "my-bucket" + # Reference secrets for sensitive data + - name: GOHOARDER_STORAGE_S3_SECRET_ACCESS_KEY + valueFrom: + secretKeyRef: + name: aws-credentials + key: secret-access-key + - name: GOHOARDER_METADATA_POSTGRESQL_PASSWORD + valueFrom: + secretKeyRef: + name: postgres-secret + key: password +``` + +**Example using command line:** + +```bash +helm install gohoarder gohoarder/gohoarder \ + --set server.env[0].name=GOHOARDER_STORAGE_BACKEND \ + --set server.env[0].value=s3 \ + --set server.env[1].name=GOHOARDER_LOGGING_LEVEL \ + --set server.env[1].value=debug +``` + +**Benefits of environment variables:** +- Better integration with Kubernetes secrets +- Override specific values without modifying ConfigMap +- Support for secret references (no plain-text passwords) +- Compatible with external secret management (External Secrets Operator, Sealed Secrets) + +**Common environment variable mappings:** + +| Config Path | Environment Variable | +|-------------|---------------------| +| `storage.backend` | `GOHOARDER_STORAGE_BACKEND` | +| `storage.s3.bucket` | `GOHOARDER_STORAGE_S3_BUCKET` | +| `storage.s3.region` | `GOHOARDER_STORAGE_S3_REGION` | +| `storage.s3.access_key_id` | `GOHOARDER_STORAGE_S3_ACCESS_KEY_ID` | +| `storage.s3.secret_access_key` | `GOHOARDER_STORAGE_S3_SECRET_ACCESS_KEY` | +| `metadata.backend` | `GOHOARDER_METADATA_BACKEND` | +| `metadata.postgresql.host` | `GOHOARDER_METADATA_POSTGRESQL_HOST` | +| `metadata.postgresql.password` | `GOHOARDER_METADATA_POSTGRESQL_PASSWORD` | +| `security.enabled` | `GOHOARDER_SECURITY_ENABLED` | +| `security.scanners.trivy.enabled` | `GOHOARDER_SECURITY_SCANNERS_TRIVY_ENABLED` | +| `logging.level` | `GOHOARDER_LOGGING_LEVEL` | +| `logging.format` | `GOHOARDER_LOGGING_FORMAT` | + +## Configuration Reference + +The following table lists the configurable parameters and their default values. + +### Global Parameters + +| Parameter | Description | Default | +|-----------|-------------|---------| +| `nameOverride` | Override the name of the chart | `""` | +| `fullnameOverride` | Override the full name of the chart | `""` | +| `global.domain` | Base domain for the deployment | `gohoarder.local` | +| `global.imagePullSecrets` | Image pull secrets (reference existing) | `[]` | +| `imageCredentials` | Auto-create image pull secrets from credentials | `{}` | + +### Replica Count + +| Parameter | Description | Default | +|-----------|-------------|---------| +| `replicaCount.server` | Number of server replicas | `1` | +| `replicaCount.frontend` | Number of frontend replicas | `1` | +| `replicaCount.scanner` | Number of scanner replicas | `1` | + +### Image Configuration + +| Parameter | Description | Default | +|-----------|-------------|---------| +| `image.server.repository` | Server image repository | `ghcr.io/lukaszraczylo/gohoarder-server` | +| `image.server.tag` | Server image tag | `latest` | +| `image.server.pullPolicy` | Server image pull policy | `IfNotPresent` | +| `image.frontend.repository` | Frontend image repository | `ghcr.io/lukaszraczylo/gohoarder-frontend` | +| `image.frontend.tag` | Frontend image tag | `latest` | +| `image.frontend.pullPolicy` | Frontend image pull policy | `IfNotPresent` | +| `image.scanner.repository` | Scanner image repository | `ghcr.io/lukaszraczylo/gohoarder-scanner` | +| `image.scanner.tag` | Scanner image tag | `latest` | +| `image.scanner.pullPolicy` | Scanner image pull policy | `IfNotPresent` | + +### Environment Variables + +| Parameter | Description | Default | +|-----------|-------------|---------| +| `server.env` | Additional environment variables for server | `[]` | +| `frontend.env` | Additional environment variables for frontend | `[]` | +| `scanner.env` | Additional environment variables for scanner | `[]` | + +### Storage Configuration + +| Parameter | Description | Default | +|-----------|-------------|---------| +| `storage.backend` | Storage backend (filesystem, s3, smb) | `filesystem` | +| `storage.filesystem.storageClass` | Storage class for PVC | `""` | +| `storage.filesystem.size` | Storage size | `100Gi` | +| `storage.filesystem.useHostPath` | Use hostPath instead of PVC | `false` | +| `storage.filesystem.hostPath` | Host path for storage | `/var/lib/gohoarder` | +| `storage.s3.endpoint` | S3 endpoint | `s3.amazonaws.com` | +| `storage.s3.bucket` | S3 bucket name | `gohoarder-cache` | +| `storage.s3.region` | S3 region | `us-east-1` | + +### Metadata Configuration + +| Parameter | Description | Default | +|-----------|-------------|---------| +| `metadata.backend` | Metadata backend (sqlite, postgresql) | `sqlite` | +| `metadata.sqlite.persistence.enabled` | Enable persistence for SQLite | `true` | +| `metadata.sqlite.persistence.size` | SQLite storage size | `10Gi` | +| `metadata.postgresql.host` | PostgreSQL host | `localhost` | +| `metadata.postgresql.database` | PostgreSQL database | `gohoarder` | + +### Security Configuration + +| Parameter | Description | Default | +|-----------|-------------|---------| +| `security.enabled` | Enable security scanning | `false` | +| `security.blockOnSeverity` | Block packages on severity | `high` | +| `security.scanners.trivy.enabled` | Enable Trivy scanner | `false` | +| `security.scanners.osv.enabled` | Enable OSV scanner | `false` | +| `security.scanners.grype.enabled` | Enable Grype scanner | `false` | + +### Authentication + +| Parameter | Description | Default | +|-----------|-------------|---------| +| `auth.enabled` | Enable authentication | `true` | +| `auth.adminApiKey` | Admin API key (auto-generated if empty) | `""` | +| `auth.existingSecret` | Use existing secret for admin key | `""` | + +### Ingress + +| Parameter | Description | Default | +|-----------|-------------|---------| +| `ingress.enabled` | Enable ingress | `false` | +| `ingress.className` | Ingress class name | `nginx` | +| `ingress.frontend.enabled` | Enable frontend ingress | `true` | +| `ingress.frontend.host` | Frontend hostname | `gohoarder.local` | +| `ingress.frontend.tls.enabled` | Enable TLS for frontend | `false` | + +## High Availability & Scaling + +### Running Multiple Server Replicas + +GoHoarder can run with multiple server replicas for high availability and load distribution, but the configuration must be set correctly to avoid data inconsistency. + +#### βœ… Compatible Configurations (Safe for Multiple Replicas) + +**Storage:** +- βœ… **S3** - Fully compatible, recommended for production HA setups +- βœ… **SMB** - Compatible, shared network storage +- βœ… **Filesystem with RWX** - Compatible when using ReadWriteMany storage classes + - βœ… Examples: Longhorn RWX, NFS, CephFS, GlusterFS, Azure Files + - βœ… Uses atomic rename operations for safe concurrent writes + - βœ… Packages are static/immutable - perfect for shared storage + - ❌ Not compatible with local storage or ReadWriteOnce (RWO) PVCs + +**Metadata:** +- βœ… **PostgreSQL** - Fully compatible, handles concurrent writes, recommended for HA +- ⚠️ **SQLite** - Limited compatibility: + - Uses WAL mode which supports concurrent reads + - Multiple writers can cause lock contention + - Works but may have performance issues under high concurrency + - Only if using shared storage (NFS, etc.) + +#### πŸ“‹ Recommended HA Configurations + +**Option 1: Cloud Storage (S3)** + +Best for cloud deployments, object storage: + +```yaml +replicaCount: + server: 3 + +storage: + backend: s3 + s3: + endpoint: s3.amazonaws.com + region: us-east-1 + bucket: gohoarder-cache + +metadata: + backend: postgresql + postgresql: + host: postgres.database.svc.cluster.local + database: gohoarder + +podDisruptionBudget: + enabled: true + minAvailable: 1 +``` + +**Option 2: Shared Filesystem (Longhorn/NFS)** + +Best for on-premises or self-hosted Kubernetes: + +```yaml +replicaCount: + server: 3 + +storage: + backend: filesystem + filesystem: + # Use RWX storage class (Longhorn, NFS, CephFS, etc.) + storageClass: "longhorn" # or "nfs-client", "cephfs", etc. + size: "500Gi" + accessMode: "ReadWriteMany" # RWX - Critical for multiple replicas! + +metadata: + backend: postgresql # Or SQLite with RWX storage + postgresql: + host: postgres.database.svc.cluster.local + database: gohoarder + +podDisruptionBudget: + enabled: true + minAvailable: 1 +``` + +**Why Filesystem with RWX Works:** +- Packages are immutable once cached (static files) +- Filesystem backend uses atomic `rename()` operations +- Race condition safe: If two replicas cache same package, one wins +- Performance: Local filesystem often faster than object storage for reads + +#### ⚠️ What Won't Work with Multiple Replicas + +**Filesystem storage with local volumes:** +```yaml +# ❌ DON'T DO THIS with multiple replicas +storage: + backend: filesystem + filesystem: + useHostPath: true # Each replica gets different storage +``` + +**SQLite with local storage:** +```yaml +# ⚠️ AVOID with multiple replicas +metadata: + backend: sqlite + sqlite: + persistence: + enabled: true # Each replica gets its own database +``` + +#### πŸ”„ How It Works + +**Request Deduplication:** +- Single replica: Uses `singleflight` to prevent duplicate upstream fetches +- Multiple replicas: Each replica may fetch the same package independently +- **Mitigation**: Package metadata in shared database prevents duplicate downloads once one replica completes + +**Cache Consistency:** +- Storage backend (S3/SMB) ensures all replicas see the same cached packages +- Metadata database ensures consistent package information across replicas +- First replica to cache a package wins, others will use the cached version + +**Session Affinity:** +- Not required - GoHoarder is stateless +- Load balancer can distribute requests randomly + +**Scanner Replicas:** +- Scanner can run as a single replica or multiple +- If multiple scanners enabled, they share work through the metadata database +- Package scans are deduplicated via database state + +#### πŸ”¬ Technical Details: Concurrent Write Safety + +**Filesystem Backend with RWX Storage:** + +The filesystem storage backend uses a **temp-file + atomic rename** pattern: + +```go +1. Write package to: /cache/npm/package@1.0.0.tmp +2. Calculate checksums (MD5, SHA256) +3. Atomic rename: .tmp β†’ /cache/npm/package@1.0.0 +``` + +**Why this is safe for concurrent writes:** +- `os.Rename()` is atomic on POSIX filesystems +- If two replicas cache the same package simultaneously: + - Both write to separate `.tmp` files + - Both attempt atomic rename + - One succeeds, one gets "file exists" error + - Result: Same file content, no corruption + +**Package immutability:** +- Packages are versioned and immutable (npm/pypi/go semantics) +- Same package@version always has identical content +- Concurrent writes produce identical results +- No risk of partial/corrupted files + +**Quota tracking:** +- Per-process mutex (minor inaccuracy across replicas) +- Conservative: May undercount slightly +- Not critical for operation + +## Uninstallation + +```bash +helm uninstall gohoarder -n gohoarder +``` + +## Upgrading + +```bash +helm upgrade gohoarder gohoarder/gohoarder -f values.yaml +``` + +## Package Manager Configuration + +After installation, configure your package managers to use GoHoarder: + +### NPM + +```bash +npm config set registry http:///npm/ +``` + +### Go + +```bash +export GOPROXY=http:///go,direct +``` + +### PyPI + +```bash +pip config set global.index-url http:///pypi/simple +``` + +## Troubleshooting + +### Check Pod Status + +```bash +kubectl get pods -n gohoarder +kubectl logs -n gohoarder +``` + +### Verify Configuration + +```bash +kubectl get configmap -n gohoarder -gohoarder-config -o yaml +``` + +### Get Admin API Key + +```bash +kubectl get secret -n gohoarder -gohoarder-auth -o jsonpath='{.data.admin-api-key}' | base64 -d +``` + +## Contributing + +Contributions are welcome! Please visit [GitHub](https://github.com/lukaszraczylo/gohoarder) for more information. + +## License + +See the [LICENSE](https://github.com/lukaszraczylo/gohoarder/blob/main/LICENSE) file. diff --git a/helm/gohoarder/templates/NOTES.txt b/helm/gohoarder/templates/NOTES.txt new file mode 100644 index 0000000..15f6d50 --- /dev/null +++ b/helm/gohoarder/templates/NOTES.txt @@ -0,0 +1,70 @@ +** GoHoarder has been installed! ** + +1. Get the application URL by running these commands: +{{- if .Values.ingress.enabled }} +{{- if .Values.ingress.frontend.enabled }} + http{{ if .Values.ingress.frontend.tls.enabled }}s{{ end }}://{{ .Values.ingress.frontend.host | default (printf "%s.%s" "gohoarder" .Values.global.domain) }} +{{- end }} +{{- else if contains "NodePort" .Values.frontend.service.type }} + export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "gohoarder.fullname" . }}-frontend) + export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") + echo http://$NODE_IP:$NODE_PORT +{{- else if contains "LoadBalancer" .Values.frontend.service.type }} + NOTE: It may take a few minutes for the LoadBalancer IP to be available. + You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "gohoarder.fullname" . }}-frontend' + export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "gohoarder.fullname" . }}-frontend --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}") + echo http://$SERVICE_IP:{{ .Values.frontend.service.port }} +{{- else if contains "ClusterIP" .Values.frontend.service.type }} + export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "gohoarder.name" . }},app.kubernetes.io/instance={{ .Release.Name }},app.kubernetes.io/component=frontend" -o jsonpath="{.items[0].metadata.name}") + export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}") + echo "Visit http://127.0.0.1:8080 to use your application" + kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT +{{- end }} + +2. Admin API Key: +{{- if .Values.auth.enabled }} +{{- if .Values.auth.existingSecret }} + The admin API key is stored in the existing secret: {{ .Values.auth.existingSecret }} + + To retrieve it: + kubectl get secret {{ .Values.auth.existingSecret }} -n {{ .Release.Namespace }} -o jsonpath='{.data.{{ .Values.auth.secretKey }}}' | base64 -d +{{- else if .Values.auth.adminApiKey }} + The admin API key you provided: {{ .Values.auth.adminApiKey }} +{{- else }} + A random admin API key has been generated. To retrieve it: + kubectl get secret {{ include "gohoarder.fullname" . }}-auth -n {{ .Release.Namespace }} -o jsonpath='{.data.{{ .Values.auth.secretKey }}}' | base64 -d +{{- end }} +{{- else }} + Authentication is disabled. +{{- end }} + +3. Configuration: + - Storage backend: {{ .Values.storage.backend }} + - Metadata backend: {{ .Values.metadata.backend }} + - Security scanning: {{ if .Values.security.enabled }}enabled{{ else }}disabled{{ end }} + {{- if .Values.security.enabled }} + - Active scanners: + {{- range $scanner, $config := .Values.security.scanners }} + {{- if $config.enabled }} + * {{ $scanner }} + {{- end }} + {{- end }} + {{- end }} + +4. Package Proxies: + Configure your package managers to use GoHoarder: + + NPM: + npm config set registry http://{{ include "gohoarder.fullname" . }}-server.{{ .Release.Namespace }}.svc.cluster.local/npm/ + + Go: + export GOPROXY=http://{{ include "gohoarder.fullname" . }}-server.{{ .Release.Namespace }}.svc.cluster.local/go,direct + + PyPI: + pip config set global.index-url http://{{ include "gohoarder.fullname" . }}-server.{{ .Release.Namespace }}.svc.cluster.local/pypi/simple + +5. Health Checks: + - Server health: http://{{ include "gohoarder.fullname" . }}-server.{{ .Release.Namespace }}.svc.cluster.local/health + - Server ready: http://{{ include "gohoarder.fullname" . }}-server.{{ .Release.Namespace }}.svc.cluster.local/health/ready + +For more information, visit: https://github.com/lukaszraczylo/gohoarder diff --git a/helm/gohoarder/templates/_helpers.tpl b/helm/gohoarder/templates/_helpers.tpl new file mode 100644 index 0000000..b3232b5 --- /dev/null +++ b/helm/gohoarder/templates/_helpers.tpl @@ -0,0 +1,174 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "gohoarder.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +*/}} +{{- define "gohoarder.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "gohoarder.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "gohoarder.labels" -}} +helm.sh/chart: {{ include "gohoarder.chart" . }} +{{ include "gohoarder.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "gohoarder.selectorLabels" -}} +app.kubernetes.io/name: {{ include "gohoarder.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Server labels +*/}} +{{- define "gohoarder.server.labels" -}} +{{ include "gohoarder.labels" . }} +app.kubernetes.io/component: server +{{- end }} + +{{/* +Server selector labels +*/}} +{{- define "gohoarder.server.selectorLabels" -}} +{{ include "gohoarder.selectorLabels" . }} +app.kubernetes.io/component: server +{{- end }} + +{{/* +Frontend labels +*/}} +{{- define "gohoarder.frontend.labels" -}} +{{ include "gohoarder.labels" . }} +app.kubernetes.io/component: frontend +{{- end }} + +{{/* +Frontend selector labels +*/}} +{{- define "gohoarder.frontend.selectorLabels" -}} +{{ include "gohoarder.selectorLabels" . }} +app.kubernetes.io/component: frontend +{{- end }} + +{{/* +Scanner labels +*/}} +{{- define "gohoarder.scanner.labels" -}} +{{ include "gohoarder.labels" . }} +app.kubernetes.io/component: scanner +{{- end }} + +{{/* +Scanner selector labels +*/}} +{{- define "gohoarder.scanner.selectorLabels" -}} +{{ include "gohoarder.selectorLabels" . }} +app.kubernetes.io/component: scanner +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "gohoarder.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "gohoarder.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} + +{{/* +Generate admin API key +*/}} +{{- define "gohoarder.adminApiKey" -}} +{{- if .Values.auth.adminApiKey }} +{{- .Values.auth.adminApiKey }} +{{- else }} +{{- randAlphaNum 32 }} +{{- end }} +{{- end }} + +{{/* +Storage volume configuration +*/}} +{{- define "gohoarder.storageVolume" -}} +{{- if eq .Values.storage.backend "filesystem" }} +{{- if .Values.storage.filesystem.useHostPath }} +- name: storage + hostPath: + path: {{ .Values.storage.filesystem.hostPath }} + type: DirectoryOrCreate +{{- else if .Values.storage.filesystem.existingClaim }} +- name: storage + persistentVolumeClaim: + claimName: {{ .Values.storage.filesystem.existingClaim }} +{{- else }} +- name: storage + persistentVolumeClaim: + claimName: {{ include "gohoarder.fullname" . }}-storage +{{- end }} +{{- else }} +- name: storage + emptyDir: {} +{{- end }} +{{- end }} + +{{/* +Metadata volume configuration +*/}} +{{- define "gohoarder.metadataVolume" -}} +{{- if and (eq .Values.metadata.backend "sqlite") .Values.metadata.sqlite.persistence.enabled }} +{{- if .Values.metadata.sqlite.persistence.existingClaim }} +- name: metadata + persistentVolumeClaim: + claimName: {{ .Values.metadata.sqlite.persistence.existingClaim }} +{{- else }} +- name: metadata + persistentVolumeClaim: + claimName: {{ include "gohoarder.fullname" . }}-metadata +{{- end }} +{{- else }} +- name: metadata + emptyDir: {} +{{- end }} +{{- end }} + +{{/* +Trivy cache volume configuration +*/}} +{{- define "gohoarder.trivyCacheVolume" -}} +{{- if .Values.security.scanners.trivy.enabled }} +- name: trivy-cache + emptyDir: {} +{{- end }} +{{- end }} diff --git a/helm/gohoarder/templates/configmap.yaml b/helm/gohoarder/templates/configmap.yaml new file mode 100644 index 0000000..cc742a4 --- /dev/null +++ b/helm/gohoarder/templates/configmap.yaml @@ -0,0 +1,168 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "gohoarder.fullname" . }}-config + labels: + {{- include "gohoarder.labels" . | nindent 4 }} +data: + config.yaml: | + server: + host: {{ .Values.server.host | quote }} + port: {{ .Values.server.port }} + read_timeout: {{ .Values.server.readTimeout | quote }} + write_timeout: {{ .Values.server.writeTimeout | quote }} + idle_timeout: {{ .Values.server.idleTimeout | quote }} + tls: + enabled: false + + storage: + backend: {{ .Values.storage.backend | quote }} + {{- if eq .Values.storage.backend "filesystem" }} + path: "/var/cache/gohoarder" + filesystem: + base_path: "/var/cache/gohoarder" + {{- else if eq .Values.storage.backend "s3" }} + s3: + endpoint: {{ .Values.storage.s3.endpoint | quote }} + region: {{ .Values.storage.s3.region | quote }} + bucket: {{ .Values.storage.s3.bucket | quote }} + {{- if .Values.storage.s3.existingSecret }} + access_key_id: "${S3_ACCESS_KEY_ID}" + secret_access_key: "${S3_SECRET_ACCESS_KEY}" + {{- else }} + access_key_id: {{ .Values.storage.s3.accessKeyId | quote }} + secret_access_key: {{ .Values.storage.s3.secretAccessKey | quote }} + {{- end }} + use_ssl: {{ .Values.storage.s3.useSSL }} + {{- else if eq .Values.storage.backend "smb" }} + smb: + host: {{ .Values.storage.smb.host | quote }} + share: {{ .Values.storage.smb.share | quote }} + {{- if .Values.storage.smb.existingSecret }} + username: "${SMB_USERNAME}" + password: "${SMB_PASSWORD}" + {{- else }} + username: {{ .Values.storage.smb.username | quote }} + password: {{ .Values.storage.smb.password | quote }} + {{- end }} + domain: {{ .Values.storage.smb.domain | quote }} + {{- end }} + + metadata: + backend: {{ .Values.metadata.backend | quote }} + {{- if eq .Values.metadata.backend "sqlite" }} + connection: "file:/var/lib/gohoarder/metadata/gohoarder.db?cache=shared&mode=rwc" + sqlite: + path: "/var/lib/gohoarder/metadata/gohoarder.db" + wal_mode: {{ .Values.metadata.sqlite.walMode }} + {{- else if eq .Values.metadata.backend "postgresql" }} + postgresql: + host: {{ .Values.metadata.postgresql.host | quote }} + port: {{ .Values.metadata.postgresql.port }} + database: {{ .Values.metadata.postgresql.database | quote }} + {{- if .Values.metadata.postgresql.existingSecret }} + user: "${POSTGRES_USER}" + password: "${POSTGRES_PASSWORD}" + {{- else }} + user: {{ .Values.metadata.postgresql.username | quote }} + password: {{ .Values.metadata.postgresql.password | quote }} + {{- end }} + ssl_mode: {{ .Values.metadata.postgresql.sslMode | quote }} + {{- end }} + + cache: + default_ttl: {{ .Values.cache.defaultTTL | quote }} + cleanup_interval: {{ .Values.cache.cleanupInterval | quote }} + max_size_bytes: {{ .Values.cache.maxSizeBytes }} + per_project_quota: {{ .Values.cache.perProjectQuota }} + ttl_overrides: + {{- range $key, $value := .Values.cache.ttlOverrides }} + {{ $key }}: {{ $value | quote }} + {{- end }} + + security: + enabled: {{ .Values.security.enabled }} + block_on_severity: {{ .Values.security.blockOnSeverity | quote }} + scan_on_download: {{ .Values.security.scanOnDownload }} + rescan_interval: {{ .Values.security.rescanInterval | quote }} + update_db_on_startup: {{ .Values.security.updateDbOnStartup }} + block_thresholds: + critical: {{ .Values.security.blockThresholds.critical }} + high: {{ .Values.security.blockThresholds.high }} + medium: {{ .Values.security.blockThresholds.medium }} + low: {{ .Values.security.blockThresholds.low }} + scanners: + trivy: + enabled: {{ .Values.security.scanners.trivy.enabled }} + timeout: {{ .Values.security.scanners.trivy.timeout | quote }} + cache_db: {{ .Values.security.scanners.trivy.cacheDb | quote }} + osv: + enabled: {{ .Values.security.scanners.osv.enabled }} + api_url: {{ .Values.security.scanners.osv.apiUrl | quote }} + timeout: {{ .Values.security.scanners.osv.timeout | quote }} + grype: + enabled: {{ .Values.security.scanners.grype.enabled }} + timeout: {{ .Values.security.scanners.grype.timeout | quote }} + govulncheck: + enabled: {{ .Values.security.scanners.govulncheck.enabled }} + timeout: {{ .Values.security.scanners.govulncheck.timeout | quote }} + npm_audit: + enabled: {{ .Values.security.scanners.npmAudit.enabled }} + timeout: {{ .Values.security.scanners.npmAudit.timeout | quote }} + pip_audit: + enabled: {{ .Values.security.scanners.pipAudit.enabled }} + timeout: {{ .Values.security.scanners.pipAudit.timeout | quote }} + ghsa: + enabled: {{ .Values.security.scanners.ghsa.enabled }} + timeout: {{ .Values.security.scanners.ghsa.timeout | quote }} + {{- if or .Values.security.scanners.ghsa.token .Values.security.scanners.ghsa.existingSecret }} + token: "${GHSA_TOKEN}" + {{- end }} + static: + enabled: {{ .Values.security.scanners.static.enabled }} + max_package_size: {{ .Values.security.scanners.static.maxPackageSize }} + check_checksums: {{ .Values.security.scanners.static.checkChecksums }} + block_suspicious: {{ .Values.security.scanners.static.blockSuspicious }} + + auth: + enabled: {{ .Values.auth.enabled }} + key_expiration: {{ .Values.auth.keyExpiration | quote }} + bcrypt_cost: {{ .Values.auth.bcryptCost }} + audit_log: {{ .Values.auth.auditLog }} + + network: + connect_timeout: {{ .Values.network.connectTimeout | quote }} + read_timeout: {{ .Values.network.readTimeout | quote }} + write_timeout: {{ .Values.network.writeTimeout | quote }} + max_idle_conns: {{ .Values.network.maxIdleConns }} + max_conns_per_host: {{ .Values.network.maxConnsPerHost }} + rate_limit: + per_api_key: {{ .Values.network.rateLimit.perApiKey }} + per_ip: {{ .Values.network.rateLimit.perIp }} + burst_size: {{ .Values.network.rateLimit.burstSize }} + circuit_breaker: + threshold: {{ .Values.network.circuitBreaker.threshold }} + timeout: {{ .Values.network.circuitBreaker.timeout | quote }} + reset_interval: {{ .Values.network.circuitBreaker.resetInterval | quote }} + retry: + max_attempts: {{ .Values.network.retry.maxAttempts }} + initial_backoff: {{ .Values.network.retry.initialBackoff | quote }} + max_backoff: {{ .Values.network.retry.maxBackoff | quote }} + + logging: + level: {{ .Values.logging.level | quote }} + format: {{ .Values.logging.format | quote }} + + handlers: + go: + enabled: {{ .Values.handlers.go.enabled }} + upstream_proxy: {{ .Values.handlers.go.upstreamProxy | quote }} + checksum_db: {{ .Values.handlers.go.checksumDb | quote }} + verify_checksums: {{ .Values.handlers.go.verifyChecksums }} + npm: + enabled: {{ .Values.handlers.npm.enabled }} + upstream_registry: {{ .Values.handlers.npm.upstreamRegistry | quote }} + pypi: + enabled: {{ .Values.handlers.pypi.enabled }} + upstream_url: {{ .Values.handlers.pypi.upstreamUrl | quote }} + simple_api_url: {{ .Values.handlers.pypi.simpleApiUrl | quote }} diff --git a/helm/gohoarder/templates/deployment-frontend.yaml b/helm/gohoarder/templates/deployment-frontend.yaml new file mode 100644 index 0000000..5d91921 --- /dev/null +++ b/helm/gohoarder/templates/deployment-frontend.yaml @@ -0,0 +1,85 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "gohoarder.fullname" . }}-frontend + labels: + {{- include "gohoarder.frontend.labels" . | nindent 4 }} +spec: + {{- if not .Values.autoscaling.enabled }} + replicas: {{ .Values.replicaCount.frontend }} + {{- end }} + selector: + matchLabels: + {{- include "gohoarder.frontend.selectorLabels" . | nindent 6 }} + template: + metadata: + annotations: + {{- with .Values.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "gohoarder.frontend.selectorLabels" . | nindent 8 }} + spec: + {{- with .Values.global.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "gohoarder.serviceAccountName" . }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: frontend + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: false + image: "{{ .Values.image.frontend.repository }}:{{ .Values.image.frontend.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.frontend.pullPolicy }} + ports: + - name: http + containerPort: 80 + protocol: TCP + env: + - name: API_BASE_URL + value: {{ .Values.frontend.backendUrl | default (printf "http://%s-server:%d" (include "gohoarder.fullname" .) (.Values.server.service.port | int)) | quote }} + - name: APP_VERSION + value: {{ .Chart.AppVersion | quote }} + - name: APP_NAME + value: "GoHoarder" + {{- with .Values.frontend.env }} + {{- toYaml . | nindent 8 }} + {{- end }} + livenessProbe: + {{- toYaml .Values.frontend.livenessProbe | nindent 12 }} + readinessProbe: + {{- toYaml .Values.frontend.readinessProbe | nindent 12 }} + resources: + {{- toYaml .Values.frontend.resources | nindent 12 }} + volumeMounts: + - name: tmp + mountPath: /tmp + - name: nginx-cache + mountPath: /var/cache/nginx + - name: nginx-run + mountPath: /var/run + volumes: + - name: tmp + emptyDir: {} + - name: nginx-cache + emptyDir: {} + - name: nginx-run + emptyDir: {} + {{- with .Values.frontend.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.frontend.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.frontend.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/helm/gohoarder/templates/deployment-scanner.yaml b/helm/gohoarder/templates/deployment-scanner.yaml new file mode 100644 index 0000000..61e8287 --- /dev/null +++ b/helm/gohoarder/templates/deployment-scanner.yaml @@ -0,0 +1,114 @@ +{{- if .Values.security.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "gohoarder.fullname" . }}-scanner + labels: + {{- include "gohoarder.scanner.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount.scanner }} + selector: + matchLabels: + {{- include "gohoarder.scanner.selectorLabels" . | nindent 6 }} + template: + metadata: + annotations: + checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} + checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }} + {{- with .Values.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "gohoarder.scanner.selectorLabels" . | nindent 8 }} + spec: + {{- with .Values.global.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "gohoarder.serviceAccountName" . }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + initContainers: + - name: init-permissions + image: busybox:latest + command: ['sh', '-c'] + args: + - | + mkdir -p /var/cache/gohoarder /var/lib/gohoarder/metadata /tmp/gohoarder + {{- if .Values.security.scanners.trivy.enabled }} + mkdir -p {{ .Values.security.scanners.trivy.cacheDb }} + chown -R 1000:1000 {{ .Values.security.scanners.trivy.cacheDb }} + {{- end }} + chown -R 1000:1000 /var/cache/gohoarder /var/lib/gohoarder /tmp/gohoarder + chmod 750 /var/cache/gohoarder /var/lib/gohoarder + volumeMounts: + {{- include "gohoarder.storageVolume" . | nindent 8 }} + {{- include "gohoarder.metadataVolume" . | nindent 8 }} + {{- include "gohoarder.trivyCacheVolume" . | nindent 8 }} + - name: tmp + mountPath: /tmp/gohoarder + securityContext: + runAsUser: 0 + containers: + - name: scanner + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + image: "{{ .Values.image.scanner.repository }}:{{ .Values.image.scanner.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.scanner.pullPolicy }} + env: + - name: CONFIG_FILE + value: /etc/gohoarder/config.yaml + {{- if and .Values.security.scanners.ghsa.enabled .Values.security.scanners.ghsa.existingSecret }} + - name: GHSA_TOKEN + valueFrom: + secretKeyRef: + name: {{ .Values.security.scanners.ghsa.existingSecret }} + key: token + {{- else if and .Values.security.scanners.ghsa.enabled .Values.security.scanners.ghsa.token }} + - name: GHSA_TOKEN + valueFrom: + secretKeyRef: + name: {{ include "gohoarder.fullname" . }}-ghsa + key: token + {{- end }} + {{- with .Values.scanner.env }} + {{- toYaml . | nindent 8 }} + {{- end }} + resources: + {{- toYaml .Values.scanner.resources | nindent 12 }} + volumeMounts: + - name: config + mountPath: /etc/gohoarder + readOnly: true + - name: storage + mountPath: /var/cache/gohoarder + - name: metadata + mountPath: /var/lib/gohoarder/metadata + {{- if .Values.security.scanners.trivy.enabled }} + - name: trivy-cache + mountPath: {{ .Values.security.scanners.trivy.cacheDb }} + {{- end }} + - name: tmp + mountPath: /tmp + volumes: + - name: config + configMap: + name: {{ include "gohoarder.fullname" . }}-config + {{- include "gohoarder.storageVolume" . | nindent 6 }} + {{- include "gohoarder.metadataVolume" . | nindent 6 }} + {{- include "gohoarder.trivyCacheVolume" . | nindent 6 }} + - name: tmp + emptyDir: {} + {{- with .Values.scanner.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.scanner.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.scanner.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} +{{- end }} diff --git a/helm/gohoarder/templates/deployment-server.yaml b/helm/gohoarder/templates/deployment-server.yaml new file mode 100644 index 0000000..bdb90e5 --- /dev/null +++ b/helm/gohoarder/templates/deployment-server.yaml @@ -0,0 +1,194 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "gohoarder.fullname" . }}-server + labels: + {{- include "gohoarder.server.labels" . | nindent 4 }} +spec: + {{- if not .Values.autoscaling.enabled }} + replicas: {{ .Values.replicaCount.server }} + {{- end }} + selector: + matchLabels: + {{- include "gohoarder.server.selectorLabels" . | nindent 6 }} + template: + metadata: + annotations: + checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} + checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }} + {{- with .Values.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "gohoarder.server.selectorLabels" . | nindent 8 }} + spec: + {{- with .Values.global.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "gohoarder.serviceAccountName" . }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + initContainers: + - name: init-permissions + image: busybox:latest + command: ['sh', '-c'] + args: + - | + mkdir -p /var/cache/gohoarder /var/lib/gohoarder/metadata /tmp/gohoarder + chown -R 1000:1000 /var/cache/gohoarder /var/lib/gohoarder /tmp/gohoarder + chmod 750 /var/cache/gohoarder /var/lib/gohoarder + volumeMounts: + {{- include "gohoarder.storageVolume" . | nindent 8 }} + {{- include "gohoarder.metadataVolume" . | nindent 8 }} + - name: tmp + mountPath: /tmp/gohoarder + securityContext: + runAsUser: 0 + containers: + - name: server + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + image: "{{ .Values.image.server.repository }}:{{ .Values.image.server.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.server.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.server.port }} + protocol: TCP + env: + - name: CONFIG_FILE + value: /etc/gohoarder/config.yaml + {{- if and .Values.auth.enabled .Values.auth.existingSecret }} + - name: ADMIN_API_KEY + valueFrom: + secretKeyRef: + name: {{ .Values.auth.existingSecret }} + key: {{ .Values.auth.secretKey }} + {{- else if .Values.auth.enabled }} + - name: ADMIN_API_KEY + valueFrom: + secretKeyRef: + name: {{ include "gohoarder.fullname" . }}-auth + key: {{ .Values.auth.secretKey }} + {{- end }} + {{- if and (eq .Values.storage.backend "s3") .Values.storage.s3.existingSecret }} + - name: S3_ACCESS_KEY_ID + valueFrom: + secretKeyRef: + name: {{ .Values.storage.s3.existingSecret }} + key: access-key-id + - name: S3_SECRET_ACCESS_KEY + valueFrom: + secretKeyRef: + name: {{ .Values.storage.s3.existingSecret }} + key: secret-access-key + {{- else if and (eq .Values.storage.backend "s3") .Values.storage.s3.accessKeyId }} + - name: S3_ACCESS_KEY_ID + valueFrom: + secretKeyRef: + name: {{ include "gohoarder.fullname" . }}-s3 + key: access-key-id + - name: S3_SECRET_ACCESS_KEY + valueFrom: + secretKeyRef: + name: {{ include "gohoarder.fullname" . }}-s3 + key: secret-access-key + {{- end }} + {{- if and (eq .Values.storage.backend "smb") .Values.storage.smb.existingSecret }} + - name: SMB_USERNAME + valueFrom: + secretKeyRef: + name: {{ .Values.storage.smb.existingSecret }} + key: username + - name: SMB_PASSWORD + valueFrom: + secretKeyRef: + name: {{ .Values.storage.smb.existingSecret }} + key: password + {{- else if and (eq .Values.storage.backend "smb") .Values.storage.smb.username }} + - name: SMB_USERNAME + valueFrom: + secretKeyRef: + name: {{ include "gohoarder.fullname" . }}-smb + key: username + - name: SMB_PASSWORD + valueFrom: + secretKeyRef: + name: {{ include "gohoarder.fullname" . }}-smb + key: password + {{- end }} + {{- if and (eq .Values.metadata.backend "postgresql") .Values.metadata.postgresql.existingSecret }} + - name: POSTGRES_USER + valueFrom: + secretKeyRef: + name: {{ .Values.metadata.postgresql.existingSecret }} + key: username + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: {{ .Values.metadata.postgresql.existingSecret }} + key: password + {{- else if and (eq .Values.metadata.backend "postgresql") .Values.metadata.postgresql.username }} + - name: POSTGRES_USER + valueFrom: + secretKeyRef: + name: {{ include "gohoarder.fullname" . }}-postgresql + key: username + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: {{ include "gohoarder.fullname" . }}-postgresql + key: password + {{- end }} + {{- if and .Values.security.scanners.ghsa.enabled .Values.security.scanners.ghsa.existingSecret }} + - name: GHSA_TOKEN + valueFrom: + secretKeyRef: + name: {{ .Values.security.scanners.ghsa.existingSecret }} + key: token + {{- else if and .Values.security.scanners.ghsa.enabled .Values.security.scanners.ghsa.token }} + - name: GHSA_TOKEN + valueFrom: + secretKeyRef: + name: {{ include "gohoarder.fullname" . }}-ghsa + key: token + {{- end }} + {{- with .Values.server.env }} + {{- toYaml . | nindent 8 }} + {{- end }} + livenessProbe: + {{- toYaml .Values.server.livenessProbe | nindent 12 }} + readinessProbe: + {{- toYaml .Values.server.readinessProbe | nindent 12 }} + resources: + {{- toYaml .Values.server.resources | nindent 12 }} + volumeMounts: + - name: config + mountPath: /etc/gohoarder + readOnly: true + - name: storage + mountPath: /var/cache/gohoarder + - name: metadata + mountPath: /var/lib/gohoarder/metadata + - name: tmp + mountPath: /tmp + volumes: + - name: config + configMap: + name: {{ include "gohoarder.fullname" . }}-config + {{- include "gohoarder.storageVolume" . | nindent 6 }} + {{- include "gohoarder.metadataVolume" . | nindent 6 }} + - name: tmp + emptyDir: {} + {{- with .Values.server.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.server.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.server.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/helm/gohoarder/templates/imagepullsecret.yaml b/helm/gohoarder/templates/imagepullsecret.yaml new file mode 100644 index 0000000..6e92009 --- /dev/null +++ b/helm/gohoarder/templates/imagepullsecret.yaml @@ -0,0 +1,14 @@ +{{- if .Values.imageCredentials }} +{{- range $name, $config := .Values.imageCredentials }} +--- +apiVersion: v1 +kind: Secret +metadata: + name: {{ $name }} + labels: + {{- include "gohoarder.labels" $ | nindent 4 }} +type: kubernetes.io/dockerconfigjson +data: + .dockerconfigjson: {{ printf "{\"auths\":{\"%s\":{\"username\":\"%s\",\"password\":\"%s\",\"email\":\"%s\",\"auth\":\"%s\"}}}" $config.registry $config.username $config.password $config.email (printf "%s:%s" $config.username $config.password | b64enc) | b64enc }} +{{- end }} +{{- end }} diff --git a/helm/gohoarder/templates/ingress.yaml b/helm/gohoarder/templates/ingress.yaml new file mode 100644 index 0000000..cd4f08c --- /dev/null +++ b/helm/gohoarder/templates/ingress.yaml @@ -0,0 +1,118 @@ +{{- if .Values.ingress.enabled -}} +{{- if .Values.ingress.frontend.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "gohoarder.fullname" . }}-frontend + labels: + {{- include "gohoarder.frontend.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if .Values.ingress.className }} + ingressClassName: {{ .Values.ingress.className }} + {{- end }} + {{- if .Values.ingress.frontend.tls.enabled }} + tls: + - hosts: + - {{ .Values.ingress.frontend.host | default (printf "%s.%s" "gohoarder" .Values.global.domain) | quote }} + secretName: {{ .Values.ingress.frontend.tls.secretName }} + {{- end }} + rules: + - host: {{ .Values.ingress.frontend.host | default (printf "%s.%s" "gohoarder" .Values.global.domain) | quote }} + http: + paths: + - path: /npm + pathType: Prefix + backend: + service: + name: {{ include "gohoarder.fullname" . }}-server + port: + number: {{ .Values.server.service.port }} + - path: /pypi + pathType: Prefix + backend: + service: + name: {{ include "gohoarder.fullname" . }}-server + port: + number: {{ .Values.server.service.port }} + - path: /go + pathType: Prefix + backend: + service: + name: {{ include "gohoarder.fullname" . }}-server + port: + number: {{ .Values.server.service.port }} + - path: /api + pathType: Prefix + backend: + service: + name: {{ include "gohoarder.fullname" . }}-server + port: + number: {{ .Values.server.service.port }} + - path: /ws + pathType: Prefix + backend: + service: + name: {{ include "gohoarder.fullname" . }}-server + port: + number: {{ .Values.server.service.port }} + - path: /health + pathType: Prefix + backend: + service: + name: {{ include "gohoarder.fullname" . }}-server + port: + number: {{ .Values.server.service.port }} + - path: /metrics + pathType: Prefix + backend: + service: + name: {{ include "gohoarder.fullname" . }}-server + port: + number: {{ .Values.server.service.port }} + - path: / + pathType: Prefix + backend: + service: + name: {{ include "gohoarder.fullname" . }}-frontend + port: + number: {{ .Values.frontend.service.port }} +{{- end }} +--- +{{- if .Values.ingress.api.enabled }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "gohoarder.fullname" . }}-api + labels: + {{- include "gohoarder.server.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if .Values.ingress.className }} + ingressClassName: {{ .Values.ingress.className }} + {{- end }} + {{- if .Values.ingress.api.tls.enabled }} + tls: + - hosts: + - {{ .Values.ingress.api.host | default (printf "api.%s.%s" "gohoarder" .Values.global.domain) | quote }} + secretName: {{ .Values.ingress.api.tls.secretName }} + {{- end }} + rules: + - host: {{ .Values.ingress.api.host | default (printf "api.%s.%s" "gohoarder" .Values.global.domain) | quote }} + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: {{ include "gohoarder.fullname" . }}-server + port: + number: {{ .Values.server.service.port }} +{{- end }} +{{- end }} diff --git a/helm/gohoarder/templates/pvc.yaml b/helm/gohoarder/templates/pvc.yaml new file mode 100644 index 0000000..f16c7c4 --- /dev/null +++ b/helm/gohoarder/templates/pvc.yaml @@ -0,0 +1,37 @@ +{{- if and (eq .Values.storage.backend "filesystem") (not .Values.storage.filesystem.useHostPath) (not .Values.storage.filesystem.existingClaim) }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "gohoarder.fullname" . }}-storage + labels: + {{- include "gohoarder.labels" . | nindent 4 }} + app.kubernetes.io/component: storage +spec: + accessModes: + - {{ .Values.storage.filesystem.accessMode }} + {{- if .Values.storage.filesystem.storageClass }} + storageClassName: {{ .Values.storage.filesystem.storageClass | quote }} + {{- end }} + resources: + requests: + storage: {{ .Values.storage.filesystem.size | quote }} +{{- end }} +--- +{{- if and (eq .Values.metadata.backend "sqlite") .Values.metadata.sqlite.persistence.enabled (not .Values.metadata.sqlite.persistence.existingClaim) }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "gohoarder.fullname" . }}-metadata + labels: + {{- include "gohoarder.labels" . | nindent 4 }} + app.kubernetes.io/component: metadata +spec: + accessModes: + - {{ .Values.metadata.sqlite.persistence.accessMode }} + {{- if .Values.metadata.sqlite.persistence.storageClass }} + storageClassName: {{ .Values.metadata.sqlite.persistence.storageClass | quote }} + {{- end }} + resources: + requests: + storage: {{ .Values.metadata.sqlite.persistence.size | quote }} +{{- end }} diff --git a/helm/gohoarder/templates/secret.yaml b/helm/gohoarder/templates/secret.yaml new file mode 100644 index 0000000..cfa1876 --- /dev/null +++ b/helm/gohoarder/templates/secret.yaml @@ -0,0 +1,66 @@ +{{- if and .Values.auth.enabled (not .Values.auth.existingSecret) }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "gohoarder.fullname" . }}-auth + labels: + {{- include "gohoarder.labels" . | nindent 4 }} +type: Opaque +data: + {{- if .Values.auth.adminApiKey }} + {{ .Values.auth.secretKey }}: {{ .Values.auth.adminApiKey | b64enc | quote }} + {{- else }} + {{ .Values.auth.secretKey }}: {{ include "gohoarder.adminApiKey" . | b64enc | quote }} + {{- end }} +{{- end }} +--- +{{- if and (eq .Values.storage.backend "s3") (not .Values.storage.s3.existingSecret) .Values.storage.s3.accessKeyId }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "gohoarder.fullname" . }}-s3 + labels: + {{- include "gohoarder.labels" . | nindent 4 }} +type: Opaque +data: + access-key-id: {{ .Values.storage.s3.accessKeyId | b64enc | quote }} + secret-access-key: {{ .Values.storage.s3.secretAccessKey | b64enc | quote }} +{{- end }} +--- +{{- if and (eq .Values.storage.backend "smb") (not .Values.storage.smb.existingSecret) .Values.storage.smb.username }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "gohoarder.fullname" . }}-smb + labels: + {{- include "gohoarder.labels" . | nindent 4 }} +type: Opaque +data: + username: {{ .Values.storage.smb.username | b64enc | quote }} + password: {{ .Values.storage.smb.password | b64enc | quote }} +{{- end }} +--- +{{- if and (eq .Values.metadata.backend "postgresql") (not .Values.metadata.postgresql.existingSecret) .Values.metadata.postgresql.username }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "gohoarder.fullname" . }}-postgresql + labels: + {{- include "gohoarder.labels" . | nindent 4 }} +type: Opaque +data: + username: {{ .Values.metadata.postgresql.username | b64enc | quote }} + password: {{ .Values.metadata.postgresql.password | b64enc | quote }} +{{- end }} +--- +{{- if and .Values.security.scanners.ghsa.enabled (not .Values.security.scanners.ghsa.existingSecret) .Values.security.scanners.ghsa.token }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "gohoarder.fullname" . }}-ghsa + labels: + {{- include "gohoarder.labels" . | nindent 4 }} +type: Opaque +data: + token: {{ .Values.security.scanners.ghsa.token | b64enc | quote }} +{{- end }} diff --git a/helm/gohoarder/templates/service.yaml b/helm/gohoarder/templates/service.yaml new file mode 100644 index 0000000..7cb3848 --- /dev/null +++ b/helm/gohoarder/templates/service.yaml @@ -0,0 +1,39 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "gohoarder.fullname" . }}-server + labels: + {{- include "gohoarder.server.labels" . | nindent 4 }} + {{- with .Values.server.service.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + type: {{ .Values.server.service.type }} + ports: + - port: {{ .Values.server.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "gohoarder.server.selectorLabels" . | nindent 4 }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ include "gohoarder.fullname" . }}-frontend + labels: + {{- include "gohoarder.frontend.labels" . | nindent 4 }} + {{- with .Values.frontend.service.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + type: {{ .Values.frontend.service.type }} + ports: + - port: {{ .Values.frontend.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "gohoarder.frontend.selectorLabels" . | nindent 4 }} diff --git a/helm/gohoarder/templates/serviceaccount.yaml b/helm/gohoarder/templates/serviceaccount.yaml new file mode 100644 index 0000000..facf516 --- /dev/null +++ b/helm/gohoarder/templates/serviceaccount.yaml @@ -0,0 +1,12 @@ +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "gohoarder.serviceAccountName" . }} + labels: + {{- include "gohoarder.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/helm/gohoarder/values.yaml b/helm/gohoarder/values.yaml new file mode 100644 index 0000000..5b4de28 --- /dev/null +++ b/helm/gohoarder/values.yaml @@ -0,0 +1,475 @@ +# Default values for gohoarder +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. + +# Override the name of the chart +nameOverride: "" +# Override the full name of the chart +fullnameOverride: "" + +# Global configuration +global: + # Base domain for the deployment + domain: "gohoarder.local" + + # Image pull secrets for private registries + # Reference existing secrets by name: + # imagePullSecrets: + # - name: ghcr-secret + # - name: dockerhub-secret + imagePullSecrets: [] + +# Auto-create image pull secrets from credentials (optional) +# If you want the chart to create the secrets for you, use this instead: +# imageCredentials: +# ghcr-secret: +# registry: ghcr.io +# username: myusername +# password: mytoken +# email: myemail@example.com +# dockerhub-secret: +# registry: https://index.docker.io/v1/ +# username: myusername +# password: mytoken +# email: myemail@example.com +# Then reference them in global.imagePullSecrets: +# - name: ghcr-secret +imageCredentials: {} + +# Deployment replicas +# NOTE: When running multiple server replicas (>1): +# - Use S3 or SMB for storage.backend (not filesystem with local storage) +# - Use PostgreSQL for metadata.backend (SQLite has limited concurrency) +# - See "High Availability & Scaling" section in README +replicaCount: + server: 1 + frontend: 1 + scanner: 1 + +# Image configuration +image: + server: + repository: ghcr.io/lukaszraczylo/gohoarder-server + pullPolicy: IfNotPresent + tag: "latest" + + frontend: + repository: ghcr.io/lukaszraczylo/gohoarder-frontend + pullPolicy: IfNotPresent + tag: "latest" + + scanner: + repository: ghcr.io/lukaszraczylo/gohoarder-scanner + pullPolicy: IfNotPresent + tag: "latest" + +# Service Account +serviceAccount: + create: true + annotations: {} + name: "" + +# Pod annotations +podAnnotations: {} + +# Pod security context +podSecurityContext: + fsGroup: 1000 + runAsNonRoot: true + runAsUser: 1000 + +# Container security context +securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + +# Server configuration +server: + host: "0.0.0.0" + port: 8080 + readTimeout: "5m" + writeTimeout: "5m" + idleTimeout: "2m" + + # Additional environment variables for server container + # Use this to override config via environment variables + # Format: GOHOARDER_ (dots replaced with underscores) + # Examples: + # GOHOARDER_STORAGE_BACKEND: s3 + # GOHOARDER_METADATA_BACKEND: postgresql + # env: + # - name: GOHOARDER_STORAGE_BACKEND + # value: "s3" + # - name: GOHOARDER_STORAGE_S3_BUCKET + # value: "my-bucket" + # - name: GOHOARDER_METADATA_POSTGRESQL_PASSWORD + # valueFrom: + # secretKeyRef: + # name: postgres-secret + # key: password + env: [] + + # Service configuration + service: + type: ClusterIP + port: 80 + targetPort: 8080 + annotations: {} + + # Resource limits + resources: + limits: + cpu: 2000m + memory: 2Gi + requests: + cpu: 500m + memory: 512Mi + + # Liveness and readiness probes + livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + + readinessProbe: + httpGet: + path: /health/ready + port: http + initialDelaySeconds: 10 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 3 + + # Node selector + nodeSelector: {} + + # Tolerations + tolerations: [] + + # Affinity + affinity: {} + +# Frontend configuration +frontend: + # Backend URL for API calls + backendUrl: "" # Auto-configured if empty + + # Additional environment variables for frontend container + # env: + # - name: API_BASE_URL + # value: "https://api.example.com" + env: [] + + # Service configuration + service: + type: ClusterIP + port: 80 + targetPort: 80 + annotations: {} + + # Resource limits + resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 100m + memory: 128Mi + + # Liveness and readiness probes + livenessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 30 + periodSeconds: 10 + + readinessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 10 + periodSeconds: 5 + + nodeSelector: {} + tolerations: [] + affinity: {} + +# Scanner configuration +scanner: + # Additional environment variables for scanner container + # env: + # - name: GOHOARDER_SECURITY_SCANNERS_TRIVY_ENABLED + # value: "true" + env: [] + + # Resource limits + resources: + limits: + cpu: 2000m + memory: 4Gi + requests: + cpu: 500m + memory: 1Gi + + nodeSelector: {} + tolerations: [] + affinity: {} + +# Storage configuration +storage: + # Storage backend: filesystem, s3, smb + # For multiple server replicas: + # - S3 or SMB (recommended) + # - Filesystem with ReadWriteMany (RWX) storage class (Longhorn, NFS, CephFS) + # - NOT filesystem with ReadWriteOnce (RWO) or local storage + backend: "filesystem" + + # Filesystem storage + filesystem: + # Storage class for PVC + # For multiple replicas: use RWX-capable storage class (longhorn, nfs-client, cephfs, etc.) + storageClass: "" + # Storage size + size: "100Gi" + # Access mode: + # ReadWriteOnce (RWO) - Single replica only + # ReadWriteMany (RWX) - Multiple replicas (requires RWX storage class) + accessMode: "ReadWriteOnce" + # Use hostPath instead of PVC (for single-node testing only) + useHostPath: false + hostPath: "/var/lib/gohoarder" + # Existing PVC name (if you want to use existing PVC) + existingClaim: "" + + # S3 storage + s3: + endpoint: "s3.amazonaws.com" + region: "us-east-1" + bucket: "gohoarder-cache" + accessKeyId: "" + secretAccessKey: "" + # Use existing secret for S3 credentials + existingSecret: "" + useSSL: true + + # SMB storage + smb: + host: "" + share: "" + username: "" + password: "" + domain: "" + # Use existing secret for SMB credentials + existingSecret: "" + +# Metadata storage configuration +metadata: + # Backend: sqlite, postgresql + # For multiple server replicas: postgresql is recommended (sqlite has concurrency limitations) + backend: "sqlite" + + # SQLite configuration + sqlite: + # Use PVC for SQLite database + persistence: + enabled: true + storageClass: "" + size: "10Gi" + accessMode: "ReadWriteOnce" + existingClaim: "" + walMode: true + + # PostgreSQL configuration + postgresql: + # Use bundled PostgreSQL (sets up postgresql subchart) + enabled: false + host: "localhost" + port: 5432 + database: "gohoarder" + username: "gohoarder" + password: "" + sslMode: "disable" + # Use existing secret for PostgreSQL credentials + existingSecret: "" + +# Cache configuration +cache: + defaultTTL: "168h" # 7 days + cleanupInterval: "1h" + maxSizeBytes: 536870912000 # 500GB + perProjectQuota: 53687091200 # 50GB + ttlOverrides: + npm: "168h" + pip: "168h" + go: "168h" + +# Security scanning configuration +security: + enabled: false + blockOnSeverity: "high" # none, low, medium, high, critical + scanOnDownload: true + rescanInterval: "24h" + updateDbOnStartup: false + + blockThresholds: + critical: 0 + high: -1 + medium: -1 + low: -1 + + scanners: + trivy: + enabled: false + timeout: "5m" + cacheDb: "/var/lib/trivy" + + osv: + enabled: false + apiUrl: "https://api.osv.dev" + timeout: "30s" + + grype: + enabled: false + timeout: "5m" + + govulncheck: + enabled: false + timeout: "5m" + + npmAudit: + enabled: false + timeout: "2m" + + pipAudit: + enabled: false + timeout: "2m" + + ghsa: + enabled: false + timeout: "30s" + # GitHub token for higher rate limits + token: "" + existingSecret: "" + + static: + enabled: true + maxPackageSize: 2147483648 # 2GB + checkChecksums: true + blockSuspicious: false + +# Authentication configuration +auth: + enabled: true + keyExpiration: "0" # Never expire + bcryptCost: 10 + auditLog: true + + # Admin API key - will be auto-generated if not provided + adminApiKey: "" + # Use existing secret for admin API key + existingSecret: "" + # Secret key name for admin API key + secretKey: "admin-api-key" + +# Network configuration +network: + connectTimeout: "10s" + readTimeout: "5m" + writeTimeout: "5m" + maxIdleConns: 100 + maxConnsPerHost: 10 + + rateLimit: + perApiKey: 1000 + perIp: 100 + burstSize: 50 + + circuitBreaker: + threshold: 5 + timeout: "30s" + resetInterval: "60s" + + retry: + maxAttempts: 3 + initialBackoff: "1s" + maxBackoff: "30s" + +# Logging configuration +logging: + level: "info" # debug, info, warn, error + format: "json" # json, pretty + +# Package handlers configuration +handlers: + go: + enabled: true + upstreamProxy: "https://proxy.golang.org" + checksumDb: "https://sum.golang.org" + verifyChecksums: true + + npm: + enabled: true + upstreamRegistry: "https://registry.npmjs.org" + + pypi: + enabled: true + upstreamUrl: "https://pypi.org" + simpleApiUrl: "https://pypi.org/simple" + +# Ingress configuration +ingress: + enabled: false + className: "nginx" + annotations: + cert-manager.io/cluster-issuer: "letsencrypt-prod" + nginx.ingress.kubernetes.io/proxy-body-size: "2048m" + nginx.ingress.kubernetes.io/proxy-read-timeout: "300" + nginx.ingress.kubernetes.io/proxy-send-timeout: "300" + + # Ingress for frontend + frontend: + enabled: true + host: "gohoarder.local" + tls: + enabled: false + secretName: "gohoarder-frontend-tls" + + # Ingress for API (if you want separate ingress) + api: + enabled: false + host: "api.gohoarder.local" + tls: + enabled: false + secretName: "gohoarder-api-tls" + +# Autoscaling configuration +autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 10 + targetCPUUtilizationPercentage: 80 + targetMemoryUtilizationPercentage: 80 + +# Pod Disruption Budget +podDisruptionBudget: + enabled: false + minAvailable: 1 + +# Network Policy +networkPolicy: + enabled: false + # Allow external access to server + ingress: + - from: + - namespaceSelector: {} + ports: + - protocol: TCP + port: 8080 diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..78bc5a5 --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,34 @@ +package version + +import "runtime" + +var ( + // Version is the semantic version (set by linker flags) + Version = "dev" + // GitCommit is the git commit hash (set by linker flags) + GitCommit = "unknown" + // BuildTime is the build timestamp (set by linker flags) + BuildTime = "unknown" + // GoVersion is the Go version used to build + GoVersion = runtime.Version() +) + +// Info contains version information +type Info struct { + Version string `json:"version"` + GitCommit string `json:"git_commit"` + BuildTime string `json:"build_time"` + GoVersion string `json:"go_version"` + Platform string `json:"platform"` +} + +// Get returns the version information +func Get() Info { + return Info{ + Version: Version, + GitCommit: GitCommit, + BuildTime: BuildTime, + GoVersion: GoVersion, + Platform: runtime.GOOS + "/" + runtime.GOARCH, + } +} diff --git a/pkg/analytics/analytics.go b/pkg/analytics/analytics.go new file mode 100644 index 0000000..bf9c5ad --- /dev/null +++ b/pkg/analytics/analytics.go @@ -0,0 +1,437 @@ +package analytics + +import ( + "sort" + "sync" + "time" + + "github.com/rs/zerolog/log" +) + +// PackageDownload represents a package download event +type PackageDownload struct { + Registry string + Name string + Version string + Timestamp time.Time + BytesSize int64 + ClientIP string + UserAgent string +} + +// PackageStats holds statistics for a package +type PackageStats struct { + Registry string + Name string + TotalDownloads int64 + UniqueVersions int + LastDownload time.Time + FirstSeen time.Time + BytesServed int64 +} + +// TrendData represents trend information over time +type TrendData struct { + Period time.Duration + Downloads int64 + Packages int +} + +// PopularPackage represents a popular package entry +type PopularPackage struct { + Registry string + Name string + Downloads int64 + RecentDownloads int64 // Downloads in last 7 days + Trend float64 // Growth rate +} + +// Engine tracks and analyzes package downloads +type Engine struct { + downloads []PackageDownload + downloadsMu sync.RWMutex + stats map[string]*PackageStats // key: registry:name + statsMu sync.RWMutex + maxEvents int + flushTicker *time.Ticker + stopChan chan struct{} +} + +// Config holds analytics engine configuration +type Config struct { + MaxEvents int + FlushInterval time.Duration +} + +// NewEngine creates a new analytics engine +func NewEngine(cfg Config) *Engine { + if cfg.MaxEvents <= 0 { + cfg.MaxEvents = 10000 + } + if cfg.FlushInterval <= 0 { + cfg.FlushInterval = 5 * time.Minute + } + + engine := &Engine{ + downloads: make([]PackageDownload, 0, cfg.MaxEvents), + stats: make(map[string]*PackageStats), + maxEvents: cfg.MaxEvents, + flushTicker: time.NewTicker(cfg.FlushInterval), + stopChan: make(chan struct{}), + } + + // Load existing stats from metadata store + engine.loadStats() + + // Start background flush goroutine + go engine.flushLoop() + + log.Info(). + Int("max_events", cfg.MaxEvents). + Dur("flush_interval", cfg.FlushInterval). + Msg("Analytics engine started") + + return engine +} + +// TrackDownload records a package download event +func (e *Engine) TrackDownload(download PackageDownload) { + e.downloadsMu.Lock() + defer e.downloadsMu.Unlock() + + // Add to event buffer + e.downloads = append(e.downloads, download) + + // Update in-memory stats + e.updateStats(download) + + // Flush if buffer is full + if len(e.downloads) >= e.maxEvents { + go e.flush() + } + + log.Debug(). + Str("registry", download.Registry). + Str("package", download.Name). + Str("version", download.Version). + Msg("Download tracked") +} + +// updateStats updates in-memory statistics +func (e *Engine) updateStats(download PackageDownload) { + e.statsMu.Lock() + defer e.statsMu.Unlock() + + key := download.Registry + ":" + download.Name + stats, exists := e.stats[key] + if !exists { + stats = &PackageStats{ + Registry: download.Registry, + Name: download.Name, + FirstSeen: download.Timestamp, + } + e.stats[key] = stats + } + + stats.TotalDownloads++ + stats.BytesServed += download.BytesSize + stats.LastDownload = download.Timestamp + + // Track unique versions (simplified) + stats.UniqueVersions++ +} + +// GetPackageStats returns statistics for a specific package +func (e *Engine) GetPackageStats(registry, name string) (*PackageStats, bool) { + e.statsMu.RLock() + defer e.statsMu.RUnlock() + + key := registry + ":" + name + stats, exists := e.stats[key] + if !exists { + return nil, false + } + + // Return a copy to avoid race conditions + statsCopy := *stats + return &statsCopy, true +} + +// GetTopPackages returns the most downloaded packages +func (e *Engine) GetTopPackages(limit int) []PopularPackage { + e.statsMu.RLock() + defer e.statsMu.RUnlock() + + packages := make([]PopularPackage, 0, len(e.stats)) + for _, stats := range e.stats { + packages = append(packages, PopularPackage{ + Registry: stats.Registry, + Name: stats.Name, + Downloads: stats.TotalDownloads, + }) + } + + // Sort by downloads descending + sort.Slice(packages, func(i, j int) bool { + return packages[i].Downloads > packages[j].Downloads + }) + + if limit > 0 && limit < len(packages) { + packages = packages[:limit] + } + + return packages +} + +// GetTrendingPackages returns packages with growing popularity +func (e *Engine) GetTrendingPackages(limit int) []PopularPackage { + e.statsMu.RLock() + defer e.statsMu.RUnlock() + + sevenDaysAgo := time.Now().Add(-7 * 24 * time.Hour) + + packages := make([]PopularPackage, 0) + for _, stats := range e.stats { + // Calculate recent downloads (last 7 days) + recent := e.getRecentDownloads(stats.Registry, stats.Name, sevenDaysAgo) + + // Calculate trend (simple growth rate) + trend := 0.0 + if stats.TotalDownloads > 0 { + trend = float64(recent) / float64(stats.TotalDownloads) * 100 + } + + packages = append(packages, PopularPackage{ + Registry: stats.Registry, + Name: stats.Name, + Downloads: stats.TotalDownloads, + RecentDownloads: recent, + Trend: trend, + }) + } + + // Sort by trend descending + sort.Slice(packages, func(i, j int) bool { + return packages[i].Trend > packages[j].Trend + }) + + if limit > 0 && limit < len(packages) { + packages = packages[:limit] + } + + return packages +} + +// getRecentDownloads counts downloads since a given time +func (e *Engine) getRecentDownloads(registry, name string, since time.Time) int64 { + e.downloadsMu.RLock() + defer e.downloadsMu.RUnlock() + + count := int64(0) + for _, download := range e.downloads { + if download.Registry == registry && + download.Name == name && + download.Timestamp.After(since) { + count++ + } + } + return count +} + +// GetTrends returns download trends over different time periods +func (e *Engine) GetTrends() []TrendData { + e.downloadsMu.RLock() + defer e.downloadsMu.RUnlock() + + now := time.Now() + periods := []time.Duration{ + 1 * time.Hour, + 24 * time.Hour, + 7 * 24 * time.Hour, + 30 * 24 * time.Hour, + } + + trends := make([]TrendData, len(periods)) + for i, period := range periods { + since := now.Add(-period) + downloads := int64(0) + packages := make(map[string]bool) + + for _, download := range e.downloads { + if download.Timestamp.After(since) { + downloads++ + packages[download.Registry+":"+download.Name] = true + } + } + + trends[i] = TrendData{ + Period: period, + Downloads: downloads, + Packages: len(packages), + } + } + + return trends +} + +// GetTotalStats returns overall statistics +func (e *Engine) GetTotalStats() map[string]interface{} { + e.statsMu.RLock() + defer e.statsMu.RUnlock() + + totalDownloads := int64(0) + totalBytes := int64(0) + registries := make(map[string]int64) + + for _, stats := range e.stats { + totalDownloads += stats.TotalDownloads + totalBytes += stats.BytesServed + registries[stats.Registry]++ + } + + return map[string]interface{}{ + "total_packages": len(e.stats), + "total_downloads": totalDownloads, + "total_bytes": totalBytes, + "registries": registries, + } +} + +// flushLoop periodically flushes download events to metadata store +func (e *Engine) flushLoop() { + for { + select { + case <-e.flushTicker.C: + e.flush() + case <-e.stopChan: + e.flush() // Final flush + return + } + } +} + +// flush persists download events to metadata store +func (e *Engine) flush() { + e.downloadsMu.Lock() + downloads := e.downloads + e.downloads = make([]PackageDownload, 0, e.maxEvents) + e.downloadsMu.Unlock() + + if len(downloads) == 0 { + return + } + + log.Debug(). + Int("events", len(downloads)). + Msg("Flushing analytics events") + + // In a real implementation, this would persist to the metadata store + // For now, we just clear the buffer + // TODO: Add actual persistence when metadata store supports analytics tables +} + +// loadStats loads existing statistics from metadata store +func (e *Engine) loadStats() { + // TODO: Load stats from metadata store when analytics tables are implemented + log.Debug().Msg("Loading analytics stats from metadata store") +} + +// Close stops the analytics engine +func (e *Engine) Close() { + close(e.stopChan) + e.flushTicker.Stop() + e.flush() // Final flush + log.Info().Msg("Analytics engine stopped") +} + +// GetRegistryStats returns per-registry statistics +func (e *Engine) GetRegistryStats(registry string) map[string]interface{} { + e.statsMu.RLock() + defer e.statsMu.RUnlock() + + totalPackages := 0 + totalDownloads := int64(0) + totalBytes := int64(0) + + for _, stats := range e.stats { + if stats.Registry == registry { + totalPackages++ + totalDownloads += stats.TotalDownloads + totalBytes += stats.BytesServed + } + } + + return map[string]interface{}{ + "registry": registry, + "total_packages": totalPackages, + "total_downloads": totalDownloads, + "total_bytes": totalBytes, + } +} + +// SearchPackages finds packages matching a query +func (e *Engine) SearchPackages(query string, limit int) []PackageStats { + e.statsMu.RLock() + defer e.statsMu.RUnlock() + + results := make([]PackageStats, 0) + for _, stats := range e.stats { + // Simple substring search + if contains(stats.Name, query) { + results = append(results, *stats) + } + if len(results) >= limit { + break + } + } + + // Sort by downloads + sort.Slice(results, func(i, j int) bool { + return results[i].TotalDownloads > results[j].TotalDownloads + }) + + return results +} + +// contains performs a case-insensitive substring search +func contains(s, substr string) bool { + sLower := toLower(s) + substrLower := toLower(substr) + return len(sLower) >= len(substrLower) && + findSubstring(sLower, substrLower) +} + +func toLower(s string) string { + result := make([]byte, len(s)) + for i := 0; i < len(s); i++ { + c := s[i] + if c >= 'A' && c <= 'Z' { + result[i] = c + 32 + } else { + result[i] = c + } + } + return string(result) +} + +func findSubstring(s, substr string) bool { + if len(substr) == 0 { + return true + } + if len(s) < len(substr) { + return false + } + for i := 0; i <= len(s)-len(substr); i++ { + match := true + for j := 0; j < len(substr); j++ { + if s[i+j] != substr[j] { + match = false + break + } + } + if match { + return true + } + } + return false +} diff --git a/pkg/app/app.go b/pkg/app/app.go new file mode 100644 index 0000000..81dc777 --- /dev/null +++ b/pkg/app/app.go @@ -0,0 +1,435 @@ +package app + +import ( + "context" + "fmt" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/adaptor" + "github.com/lukaszraczylo/gohoarder/pkg/analytics" + "github.com/lukaszraczylo/gohoarder/pkg/auth" + "github.com/lukaszraczylo/gohoarder/pkg/cache" + "github.com/lukaszraczylo/gohoarder/pkg/cdn" + "github.com/lukaszraczylo/gohoarder/pkg/config" + "github.com/lukaszraczylo/gohoarder/pkg/health" + "github.com/lukaszraczylo/gohoarder/pkg/lock" + "github.com/lukaszraczylo/gohoarder/pkg/metadata" + metafile "github.com/lukaszraczylo/gohoarder/pkg/metadata/file" + metasqlite "github.com/lukaszraczylo/gohoarder/pkg/metadata/sqlite" + "github.com/lukaszraczylo/gohoarder/pkg/metrics" + "github.com/lukaszraczylo/gohoarder/pkg/network" + "github.com/lukaszraczylo/gohoarder/pkg/prewarming" + "github.com/lukaszraczylo/gohoarder/pkg/proxy/goproxy" + "github.com/lukaszraczylo/gohoarder/pkg/proxy/npm" + "github.com/lukaszraczylo/gohoarder/pkg/proxy/pypi" + "github.com/lukaszraczylo/gohoarder/pkg/scanner" + "github.com/lukaszraczylo/gohoarder/pkg/storage" + "github.com/lukaszraczylo/gohoarder/pkg/storage/filesystem" + "github.com/lukaszraczylo/gohoarder/pkg/vcs" + "github.com/lukaszraczylo/gohoarder/pkg/websocket" + "github.com/rs/zerolog/log" +) + +// App represents the main application +type App struct { + config *config.Config + app *fiber.App + healthChecker *health.Checker + cache *cache.Manager + storage storage.StorageBackend + metadata metadata.Store + authManager *auth.Manager + networkClient *network.Client + scanManager *scanner.Manager + rescanWorker *scanner.RescanWorker + analyticsEngine *analytics.Engine + wsServer *websocket.Server + prewarmWorker *prewarming.Worker + lockManager *lock.Manager + cdnMiddleware *cdn.Middleware +} + +// New creates a new application instance +func New(cfg *config.Config) (*App, error) { + app := &App{ + config: cfg, + } + + // Initialize components + if err := app.initializeComponents(); err != nil { + return nil, err + } + + // Setup HTTP server and routes + if err := app.setupServer(); err != nil { + return nil, err + } + + return app, nil +} + +// initializeComponents initializes all application components +func (a *App) initializeComponents() error { + var err error + + // Initialize storage backend + log.Info().Str("backend", a.config.Storage.Backend).Msg("Initializing storage backend") + switch a.config.Storage.Backend { + case "filesystem": + a.storage, err = filesystem.New(a.config.Storage.Path, a.config.Cache.MaxSizeBytes) + default: + a.storage, err = filesystem.New(a.config.Storage.Path, a.config.Cache.MaxSizeBytes) + } + if err != nil { + return fmt.Errorf("failed to initialize storage: %w", err) + } + + // Initialize metadata store + log.Info().Str("backend", a.config.Metadata.Backend).Msg("Initializing metadata store") + switch a.config.Metadata.Backend { + case "sqlite": + a.metadata, err = metasqlite.New(metasqlite.Config{ + Path: a.config.Metadata.Connection, + }) + case "file": + a.metadata, err = metafile.New(metafile.Config{ + Path: a.config.Metadata.Connection, + }) + default: + a.metadata, err = metasqlite.New(metasqlite.Config{ + Path: "gohoarder.db", + }) + } + if err != nil { + return fmt.Errorf("failed to initialize metadata: %w", err) + } + + // Initialize scanner manager first (before cache) + log.Info().Msg("Initializing security scanner") + a.scanManager, err = scanner.New(a.config.Security, a.metadata) + if err != nil { + return fmt.Errorf("failed to initialize scanner: %w", err) + } + + // Initialize cache manager with scanner + log.Info().Msg("Initializing cache manager") + a.cache, err = cache.New(a.storage, a.metadata, a.scanManager, cache.Config{ + DefaultTTL: a.config.Cache.DefaultTTL, + CleanupInterval: 5 * time.Minute, + }) + if err != nil { + return fmt.Errorf("failed to initialize cache: %w", err) + } + + // Initialize network client + log.Info().Msg("Initializing network client") + a.networkClient = network.NewClient(network.Config{ + Timeout: 5 * time.Minute, + MaxRetries: 3, + RetryDelay: 1 * time.Second, + RateLimit: 100, + RateBurst: 10, + CircuitBreaker: network.CircuitBreakerConfig{ + Enabled: true, + FailureThreshold: 5, + SuccessThreshold: 2, + Timeout: 30 * time.Second, + }, + UserAgent: "GoHoarder/1.0", + }) + + // Initialize authentication manager + log.Info().Msg("Initializing authentication manager") + a.authManager = auth.New() + + // Initialize rescan worker if enabled + if a.config.Security.Enabled && a.config.Security.RescanInterval > 0 { + log.Info().Dur("interval", a.config.Security.RescanInterval).Msg("Initializing package rescan worker") + a.rescanWorker = scanner.NewRescanWorker(a.scanManager, a.metadata, a.storage, a.config.Security.RescanInterval) + } + + // Initialize analytics engine + log.Info().Msg("Initializing analytics engine") + a.analyticsEngine = analytics.NewEngine(analytics.Config{ + MaxEvents: 10000, + FlushInterval: 5 * time.Minute, + }) + + // Initialize WebSocket server + log.Info().Msg("Initializing WebSocket server") + a.wsServer = websocket.NewServer(websocket.Config{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + CheckOrigin: func(_ *http.Request) bool { + return true // Allow all origins in development + }, + }) + + // Initialize pre-warming worker + log.Info().Msg("Initializing pre-warming worker") + a.prewarmWorker = prewarming.NewWorker(prewarming.Config{ + Enabled: false, // Disabled by default + Interval: 1 * time.Hour, + MaxConcurrent: 5, + CacheManager: a.cache, + Analytics: a.analyticsEngine, + NetworkClient: a.networkClient, + }) + + // Initialize CDN middleware + log.Info().Msg("Initializing CDN middleware") + a.cdnMiddleware = cdn.NewMiddleware(cdn.Config{ + DefaultCacheControl: cdn.CacheControl{ + Public: true, + MaxAge: 3600, + SMaxAge: 7200, + }, + EnableETag: true, + EnableVary: true, + }) + + // Initialize health checker + a.healthChecker = health.New() + a.healthChecker.AddCheck("storage", func(ctx context.Context) (health.Status, string) { + if err := a.storage.Health(ctx); err != nil { + return health.StatusUnhealthy, err.Error() + } + return health.StatusHealthy, "" + }) + a.healthChecker.AddCheck("metadata", func(ctx context.Context) (health.Status, string) { + if err := a.metadata.Health(ctx); err != nil { + return health.StatusUnhealthy, err.Error() + } + return health.StatusHealthy, "" + }) + a.healthChecker.AddCheck("cache", func(ctx context.Context) (health.Status, string) { + return health.StatusHealthy, "" // Cache is always healthy if initialized + }) + a.healthChecker.AddCheck("scanner", func(ctx context.Context) (health.Status, string) { + if a.config.Security.Enabled { + if err := a.scanManager.Health(ctx); err != nil { + return health.StatusUnhealthy, err.Error() + } + } + return health.StatusHealthy, "" + }) + + log.Info().Msg("All components initialized successfully") + return nil +} + +// setupServer sets up the Fiber server and routes +func (a *App) setupServer() error { + // Create Fiber app + a.app = fiber.New(fiber.Config{ + ReadTimeout: a.config.Server.ReadTimeout, + WriteTimeout: a.config.Server.WriteTimeout, + ServerHeader: "GoHoarder", + AppName: "GoHoarder v1.0", + }) + + // Health and metrics endpoints (adapted from net/http) + a.app.Get("/health", adaptor.HTTPHandlerFunc(a.healthChecker.HealthHandler())) + a.app.Get("/health/ready", adaptor.HTTPHandlerFunc(a.healthChecker.ReadyHandler())) + a.app.Get("/metrics", adaptor.HTTPHandler(metrics.Handler())) + + // WebSocket endpoint (adapted from net/http) + a.app.Get("/ws", adaptor.HTTPHandlerFunc(a.wsServer.HandleWebSocket)) + + // API endpoints + a.app.Get("/api/config", a.handleConfig) + a.app.All("/api/packages/*", a.handlePackages) // Handles packages and vulnerabilities + a.app.Get("/api/stats", a.handleStats) + a.app.Get("/api/stats/timeseries", a.handleTimeSeriesStats) + a.app.Get("/api/info", a.handleInfo) + + // Admin endpoints (bypass management) + a.app.All("/api/admin/bypasses/:id?", a.requireAdmin, a.handleAdminBypasses) + + // Proxy handlers (adapted from net/http) + // Load git credentials if configured + var credStore *vcs.CredentialStore + if a.config.Handlers.Go.GitCredentialsFile != "" { + credStore = vcs.NewCredentialStore() + if err := credStore.LoadFromFile(a.config.Handlers.Go.GitCredentialsFile); err != nil { + log.Error(). + Err(err). + Str("file", a.config.Handlers.Go.GitCredentialsFile). + Msg("Failed to load git credentials, continuing without pattern-based credentials") + } else if err := credStore.ValidateConfig(); err != nil { + log.Error(). + Err(err). + Str("file", a.config.Handlers.Go.GitCredentialsFile). + Msg("Invalid git credentials configuration, continuing without pattern-based credentials") + credStore = nil + } + } + + goProxyHandler := goproxy.New(a.cache, a.networkClient, goproxy.Config{ + Upstream: "https://proxy.golang.org", + SumDBURL: "https://sum.golang.org", + CredStore: credStore, + }) + a.app.All("/go/*", adaptor.HTTPHandler(http.StripPrefix("/go", goProxyHandler))) + + npmProxyHandler := npm.New(a.cache, a.networkClient, npm.Config{ + Upstream: "https://registry.npmjs.org", + }) + a.app.All("/npm/*", adaptor.HTTPHandler(http.StripPrefix("/npm", npmProxyHandler))) + + pypiProxyHandler := pypi.New(a.cache, a.networkClient, pypi.Config{ + Upstream: "https://pypi.org/simple", + }) + a.app.All("/pypi/*", adaptor.HTTPHandler(http.StripPrefix("/pypi", pypiProxyHandler))) + + // Serve frontend static files + frontendDir := "frontend/dist" + if _, err := os.Stat(frontendDir); err == nil { + log.Info().Str("dir", frontendDir).Msg("Serving frontend static files") + a.app.Static("/", frontendDir) + } else { + log.Warn().Msg("Frontend dist directory not found, frontend won't be served") + a.app.Get("/", func(c *fiber.Ctx) error { + return c.Type("html").SendString(` + + GoHoarder + +

GoHoarder Package Cache Proxy

+

Frontend not built. Build with: cd frontend && npm run build

+

Available Endpoints:

+ + + + `) + }) + } + + log.Info(). + Str("addr", fmt.Sprintf("%s:%d", a.config.Server.Host, a.config.Server.Port)). + Msg("Fiber server configured") + + return nil +} + +// Run starts the application +func (a *App) Run() error { + ctx := context.Background() + + // Start WebSocket server + a.wsServer.Start(ctx) + + // Start pre-warming worker + a.prewarmWorker.Start(ctx) + + // Start rescan worker if enabled + if a.rescanWorker != nil { + go a.rescanWorker.Start(ctx) + } + + // Start download data aggregation worker (runs every hour) + go a.startAggregationWorker(ctx) + + // Start Fiber server in goroutine + errChan := make(chan error, 1) + go func() { + addr := fmt.Sprintf("%s:%d", a.config.Server.Host, a.config.Server.Port) + log.Info(). + Str("addr", addr). + Msg("Starting Fiber server") + if err := a.app.Listen(addr); err != nil { + errChan <- err + } + }() + + // Wait for interrupt signal + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + + select { + case err := <-errChan: + return fmt.Errorf("server error: %w", err) + case sig := <-sigChan: + log.Info(). + Str("signal", sig.String()). + Msg("Shutdown signal received") + } + + // Graceful shutdown + return a.Shutdown() +} + +// Shutdown gracefully shuts down the application +func (a *App) Shutdown() error { + log.Info().Msg("Starting graceful shutdown") + + // Stop Fiber server + if err := a.app.Shutdown(); err != nil { + log.Error().Err(err).Msg("Error shutting down Fiber server") + } + + // Stop pre-warming worker + a.prewarmWorker.Stop() + + // Stop rescan worker if running + if a.rescanWorker != nil { + a.rescanWorker.Stop() + } + + // Close analytics engine + a.analyticsEngine.Close() // #nosec G104 -- Cleanup, error not critical + + // Close storage + if err := a.storage.Close(); err != nil { + log.Error().Err(err).Msg("Error closing storage") + } + + // Close metadata store + if err := a.metadata.Close(); err != nil { + log.Error().Err(err).Msg("Error closing metadata store") + } + + // Close lock manager if initialized + if a.lockManager != nil { + if err := a.lockManager.Close(); err != nil { + log.Error().Err(err).Msg("Error closing lock manager") + } + } + + log.Info().Msg("Shutdown complete") + return nil +} + +// startAggregationWorker runs download data aggregation periodically +func (a *App) startAggregationWorker(ctx context.Context) { + log.Info().Msg("Starting download data aggregation worker (runs every hour)") + + // Run immediately on startup + if err := a.metadata.AggregateDownloadData(ctx); err != nil { + log.Error().Err(err).Msg("Failed to run initial download data aggregation") + } + + // Then run every hour + ticker := time.NewTicker(1 * time.Hour) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + log.Info().Msg("Aggregation worker stopped") + return + case <-ticker.C: + if err := a.metadata.AggregateDownloadData(ctx); err != nil { + log.Error().Err(err).Msg("Failed to aggregate download data") + } + } + } +} diff --git a/pkg/app/handlers.go b/pkg/app/handlers.go new file mode 100644 index 0000000..ac9d55c --- /dev/null +++ b/pkg/app/handlers.go @@ -0,0 +1,512 @@ +package app + +import ( + "strings" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/lukaszraczylo/gohoarder/internal/version" + "github.com/lukaszraczylo/gohoarder/pkg/metadata" + "github.com/lukaszraczylo/gohoarder/pkg/websocket" + "github.com/rs/zerolog/log" +) + +// handlePackages handles /api/packages endpoint +func (a *App) handlePackages(c *fiber.Ctx) error { + c.Set("Content-Type", "application/json") + c.Set("Access-Control-Allow-Origin", "*") + c.Set("Access-Control-Allow-Methods", "GET, DELETE, OPTIONS") + c.Set("Access-Control-Allow-Headers", "Content-Type") + + if c.Method() == "OPTIONS" { + return c.SendStatus(fiber.StatusOK) + } + + // Check if this is a vulnerability endpoint request + if strings.HasSuffix(c.Path(), "/vulnerabilities") { + return a.handleVulnerabilities(c) + } + + switch c.Method() { + case "GET": + return a.handleListPackages(c) + case "DELETE": + return a.handleDeletePackage(c) + default: + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "method not allowed"}) + } +} + +// handleListPackages returns list of cached packages +func (a *App) handleListPackages(c *fiber.Ctx) error { + ctx := c.Context() + + // Get packages from metadata store + allPackages, err := a.metadata.ListPackages(ctx, &metadata.ListOptions{ + Limit: 1000, // Get more to account for duplicates + Offset: 0, + }) + if err != nil { + log.Error().Err(err).Msg("Failed to list packages") + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to list packages"}) + } + + log.Debug().Int("total_packages_from_db", len(allPackages)).Msg("Retrieved packages from database") + + // Filter, clean, and deduplicate packages + // Map stores both cleaned package and original name for scan lookups + type packageEntry struct { + pkg *metadata.Package + originalName string + } + seen := make(map[string]*packageEntry) + skippedCount := 0 + for _, pkg := range allPackages { + // Skip metadata entries (npm metadata pages, pypi pages, etc.) + if pkg.Version == "list" || pkg.Version == "latest" || pkg.Version == "metadata" || pkg.Version == "page" { + skippedCount++ + log.Debug(). + Str("name", pkg.Name). + Str("version", pkg.Version). + Str("registry", pkg.Registry). + Msg("Skipping metadata entry") + continue + } + + // Clean the package name (remove /@v/version.ext suffix) + originalName := pkg.Name + cleanName := pkg.Name + if idx := strings.Index(cleanName, "/@v/"); idx != -1 { + cleanName = cleanName[:idx] + } + + // Create deduplication key + key := cleanName + "@" + pkg.Version + + // Keep the entry with the largest size (typically .zip files) + if existing, ok := seen[key]; !ok || pkg.Size > existing.pkg.Size { + // Create a copy with cleaned name + cleanPkg := *pkg + cleanPkg.Name = cleanName + seen[key] = &packageEntry{ + pkg: &cleanPkg, + originalName: originalName, + } + } + } + + log.Debug(). + Int("skipped_metadata", skippedCount). + Int("unique_packages", len(seen)). + Msg("Filtered and deduplicated packages") + + // Convert map to slice, keeping track of original names + type packageWithOriginalName struct { + pkg *metadata.Package + originalName string + } + packagesWithNames := make([]packageWithOriginalName, 0, len(seen)) + for _, entry := range seen { + packagesWithNames = append(packagesWithNames, packageWithOriginalName{ + pkg: entry.pkg, + originalName: entry.originalName, + }) + } + + // Enhance packages with vulnerability information if security scanning is enabled + var response map[string]interface{} + if a.config.Security.Enabled { + enhancedPackages := make([]map[string]interface{}, 0, len(packagesWithNames)) + for _, entry := range packagesWithNames { + pkg := entry.pkg + pkgMap := map[string]interface{}{ + "id": pkg.ID, + "registry": pkg.Registry, + "name": pkg.Name, + "version": pkg.Version, + "size": pkg.Size, + "checksum_sha256": pkg.ChecksumSHA256, + "cached_at": pkg.CachedAt, + "last_accessed": pkg.LastAccessed, + "download_count": pkg.DownloadCount, + } + + // Add vulnerability info if scanned + if pkg.SecurityScanned { + // Use original name for scan result lookup (handles Go packages with /@v/ suffix) + scanResult, err := a.metadata.GetScanResult(ctx, pkg.Registry, entry.originalName, pkg.Version) + if err == nil && scanResult != nil { + // Count vulnerabilities by severity + severityCounts := make(map[string]int) + for _, vuln := range scanResult.Vulnerabilities { + severityCounts[strings.ToUpper(vuln.Severity)]++ + } + + pkgMap["vulnerabilities"] = map[string]interface{}{ + "scanned": true, + "status": scanResult.Status, + "scannedAt": scanResult.ScannedAt.Format(time.RFC3339), + "counts": map[string]int{ + "critical": severityCounts["CRITICAL"], + "high": severityCounts["HIGH"], + "moderate": severityCounts["MODERATE"], + "low": severityCounts["LOW"], + }, + "total": scanResult.VulnerabilityCount, + } + } else { + pkgMap["vulnerabilities"] = map[string]interface{}{ + "scanned": false, + "status": "pending", + } + } + } else { + pkgMap["vulnerabilities"] = map[string]interface{}{ + "scanned": false, + "status": "not_scanned", + } + } + + enhancedPackages = append(enhancedPackages, pkgMap) + } + + response = map[string]interface{}{ + "packages": enhancedPackages, + "total": len(enhancedPackages), + } + } else { + // Non-enhanced mode - just return the packages + packages := make([]*metadata.Package, 0, len(packagesWithNames)) + for _, entry := range packagesWithNames { + packages = append(packages, entry.pkg) + } + response = map[string]interface{}{ + "packages": packages, + "total": len(packages), + } + } + + // Success response + return c.Status(fiber.StatusOK).JSON(response) +} + +// handleDeletePackage deletes a cached package +func (a *App) handleDeletePackage(c *fiber.Ctx) error { + ctx := c.Context() + + // Parse path: /api/packages/{registry}/{name}/{version} + // For Go packages, name can contain slashes (e.g., github.com/user/repo) + // Version is always the last segment + path := strings.TrimPrefix(c.Path(), "/api/packages/") + parts := strings.Split(path, "/") + if len(parts) < 3 { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "invalid path format, expected /api/packages/{registry}/{name}/{version}", + }) + } + + registry := parts[0] + version := parts[len(parts)-1] + name := strings.Join(parts[1:len(parts)-1], "/") + + // For Go packages, we need to find and delete all cache entries (.info, .mod, .zip) + // For other registries, we can delete directly + var deletedCount int + var lastErr error + + if registry == "go" { + // List all packages matching the base name and version + allPackages, err := a.metadata.ListPackages(ctx, &metadata.ListOptions{ + Limit: 1000, + }) + if err != nil { + log.Error().Err(err).Msg("Failed to list packages for deletion") + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to list packages"}) + } + + log.Debug(). + Str("registry", registry). + Str("name", name). + Str("version", version). + Int("total_packages", len(allPackages)). + Msg("Searching for packages to delete") + + // Find and delete all entries for this package + for _, pkg := range allPackages { + if pkg.Registry != registry || pkg.Version != version { + continue + } + + // Check if this package name matches (either exact or with /@v/ suffix) + cleanName := pkg.Name + if idx := strings.Index(cleanName, "/@v/"); idx != -1 { + cleanName = cleanName[:idx] + } + + log.Debug(). + Str("db_name", pkg.Name). + Str("clean_name", cleanName). + Str("search_name", name). + Bool("matches", cleanName == name). + Msg("Checking package") + + if cleanName == name { + if err := a.cache.Delete(ctx, pkg.Registry, pkg.Name, pkg.Version); err != nil { + log.Warn(). + Err(err). + Str("registry", pkg.Registry). + Str("name", pkg.Name). + Str("version", pkg.Version). + Msg("Failed to delete package variant") + lastErr = err + } else { + deletedCount++ + log.Info(). + Str("registry", pkg.Registry). + Str("name", pkg.Name). + Str("version", pkg.Version). + Msg("Deleted package variant") + } + } + } + + log.Debug(). + Int("deleted_count", deletedCount). + Msg("Delete operation completed") + + if deletedCount == 0 { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "package not found"}) + } + + if lastErr != nil && deletedCount == 0 { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to delete package"}) + } + } else { + // For NPM and PyPI, delete directly + if err := a.cache.Delete(ctx, registry, name, version); err != nil { + log.Error(). + Err(err). + Str("registry", registry). + Str("name", name). + Str("version", version). + Msg("Failed to delete package") + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to delete package"}) + } + deletedCount = 1 + } + + // Broadcast event via WebSocket + a.wsServer.Broadcast(websocket.EventPackageDeleted, map[string]interface{}{ + "registry": registry, + "name": name, + "version": version, + }) + + // Success response + response := map[string]interface{}{ + "deleted": true, + "package": map[string]string{ + "registry": registry, + "name": name, + "version": version, + }, + } + + // For Go packages, include count of deleted variants + if registry == "go" { + response["deleted_count"] = deletedCount + } + + return c.Status(fiber.StatusOK).JSON(response) +} + +// handleStats handles /api/stats endpoint +func (a *App) handleStats(c *fiber.Ctx) error { + c.Set("Content-Type", "application/json") + c.Set("Access-Control-Allow-Origin", "*") + c.Set("Access-Control-Allow-Methods", "GET, OPTIONS") + c.Set("Access-Control-Allow-Headers", "Content-Type") + + if c.Method() == "OPTIONS" { + return c.SendStatus(fiber.StatusOK) + } + + if c.Method() != "GET" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "method not allowed"}) + } + + ctx := c.Context() + + // Get cache statistics for all registries from database + cacheStats, err := a.cache.GetStats(ctx, "") + if err != nil { + log.Error().Err(err).Msg("Failed to get cache stats") + cacheStats = &metadata.Stats{} + } + + // Get all packages to calculate per-registry breakdown + packages, err := a.metadata.ListPackages(ctx, nil) + if err != nil { + log.Error().Err(err).Msg("Failed to list packages") + packages = []*metadata.Package{} + } + + // Calculate per-registry breakdown (exclude metadata entries like "list", "latest") + registryStats := make(map[string]map[string]interface{}) + + for _, pkg := range packages { + // Skip metadata entries (npm metadata pages, pypi pages, etc.) + if pkg.Version == "list" || pkg.Version == "latest" || pkg.Version == "metadata" || pkg.Version == "page" { + continue + } + + // Track per-registry stats + if _, ok := registryStats[pkg.Registry]; !ok { + registryStats[pkg.Registry] = map[string]interface{}{ + "count": 0, + "size": int64(0), + "downloads": int64(0), + } + } + registryStats[pkg.Registry]["count"] = registryStats[pkg.Registry]["count"].(int) + 1 + registryStats[pkg.Registry]["size"] = registryStats[pkg.Registry]["size"].(int64) + pkg.Size + registryStats[pkg.Registry]["downloads"] = registryStats[pkg.Registry]["downloads"].(int64) + int64(pkg.DownloadCount) + } + + // Combine statistics using database stats for accuracy + stats := map[string]interface{}{ + "total_packages": cacheStats.TotalPackages, + "total_downloads": cacheStats.TotalDownloads, + "total_size": cacheStats.TotalSize, + "cache_hits": cacheStats.TotalDownloads, + "cache_misses": 0, // TODO: Track cache misses + "cache_evictions": 0, // TODO: Track evictions + "cache_size": cacheStats.TotalSize, + "scanned_packages": cacheStats.ScannedPackages, + "vulnerable_packages": cacheStats.VulnerablePackages, + } + + // Convert registry stats to interface map + registries := make(map[string]interface{}) + for registry, regStats := range registryStats { + registries[registry] = regStats + } + + return c.Status(fiber.StatusOK).JSON(fiber.Map{ + "stats": stats, + "registries": registries, + }) +} + +// handleTimeSeriesStats handles /api/stats/timeseries endpoint +// Returns time-series download statistics for charts +func (a *App) handleTimeSeriesStats(c *fiber.Ctx) error { + c.Set("Content-Type", "application/json") + c.Set("Access-Control-Allow-Origin", "*") + c.Set("Access-Control-Allow-Methods", "GET, OPTIONS") + c.Set("Access-Control-Allow-Headers", "Content-Type") + + if c.Method() == "OPTIONS" { + return c.SendStatus(fiber.StatusOK) + } + + if c.Method() != "GET" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "method not allowed"}) + } + + ctx := c.Context() + + // Get query parameters + period := c.Query("period", "1day") // Default to 1 day + registry := c.Query("registry") // Optional registry filter + + // Validate period + validPeriods := map[string]bool{"1h": true, "1day": true, "7day": true, "30day": true} + if !validPeriods[period] { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "invalid period, must be one of: 1h, 1day, 7day, 30day", + }) + } + + // Get time-series stats + stats, err := a.metadata.GetTimeSeriesStats(ctx, period, registry) + if err != nil { + log.Error().Err(err).Str("period", period).Str("registry", registry).Msg("Failed to get time-series stats") + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "failed to get time-series statistics", + }) + } + + return c.Status(fiber.StatusOK).JSON(stats) +} + +// handleConfig handles /api/config endpoint +// Returns runtime configuration for the frontend +func (a *App) handleConfig(c *fiber.Ctx) error { + c.Set("Content-Type", "application/json") + c.Set("Access-Control-Allow-Origin", "*") + c.Set("Access-Control-Allow-Methods", "GET, OPTIONS") + c.Set("Access-Control-Allow-Headers", "Content-Type") + + if c.Method() == "OPTIONS" { + return c.SendStatus(fiber.StatusOK) + } + + if c.Method() != "GET" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "method not allowed"}) + } + + // Build server URL from request + scheme := "http" + if c.Protocol() == "https" { + scheme = "https" + } + serverURL := scheme + "://" + c.Hostname() + + config := map[string]interface{}{ + "server_url": serverURL, + "version": version.Version, + "features": map[string]bool{ + "security_scanning": a.config.Security.Enabled, + "websockets": true, + }, + } + + return c.Status(fiber.StatusOK).JSON(config) +} + +// handleInfo handles /api/info endpoint +func (a *App) handleInfo(c *fiber.Ctx) error { + c.Set("Content-Type", "application/json") + c.Set("Access-Control-Allow-Origin", "*") + c.Set("Access-Control-Allow-Methods", "GET, OPTIONS") + c.Set("Access-Control-Allow-Headers", "Content-Type") + + if c.Method() == "OPTIONS" { + return c.SendStatus(fiber.StatusOK) + } + + if c.Method() != "GET" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "method not allowed"}) + } + + info := map[string]interface{}{ + "name": "GoHoarder", + "version": version.Version, + "config": map[string]interface{}{ + "storage_backend": a.config.Storage.Backend, + "metadata_backend": a.config.Metadata.Backend, + "cache_ttl": a.config.Cache.DefaultTTL.String(), + "max_cache_size": a.config.Cache.MaxSizeBytes, + }, + "features": map[string]bool{ + "distributed_locking": a.lockManager != nil, + "security_scanning": a.config.Security.Enabled, + "pre_warming": a.prewarmWorker != nil, + "websockets": true, + "analytics": true, + }, + } + + return c.Status(fiber.StatusOK).JSON(info) +} diff --git a/pkg/app/handlers.go.bak b/pkg/app/handlers.go.bak new file mode 100644 index 0000000..3f6d7ac --- /dev/null +++ b/pkg/app/handlers.go.bak @@ -0,0 +1,413 @@ +package app + +import ( + "net/http" + "strings" + + "github.com/lukaszraczylo/gohoarder/internal/version" + "github.com/lukaszraczylo/gohoarder/pkg/errors" + "github.com/lukaszraczylo/gohoarder/pkg/metadata" + "github.com/lukaszraczylo/gohoarder/pkg/websocket" + "github.com/rs/zerolog/log" +) + +// handlePackages handles /api/packages endpoint +func (a *App) handlePackages(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + // Check if this is a vulnerability endpoint request + if strings.HasSuffix(r.URL.Path, "/vulnerabilities") { + a.handleVulnerabilities(w, r) + return + } + + switch r.Method { + case "GET": + a.handleListPackages(w, r) + case "DELETE": + a.handleDeletePackage(w, r) + default: + errors.WriteErrorSimple(w, errors.BadRequest("method not allowed")) + } +} + +// handleListPackages returns list of cached packages +func (a *App) handleListPackages(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + // Get packages from metadata store + allPackages, err := a.metadata.ListPackages(ctx, &metadata.ListOptions{ + Limit: 1000, // Get more to account for duplicates + Offset: 0, + }) + if err != nil { + log.Error().Err(err).Msg("Failed to list packages") + errors.WriteErrorSimple(w, errors.InternalServer("failed to list packages")) + return + } + + // Filter, clean, and deduplicate packages + seen := make(map[string]*metadata.Package) + for _, pkg := range allPackages { + // Skip metadata entries + if pkg.Version == "list" || pkg.Version == "latest" { + continue + } + + // Clean the package name (remove /@v/version.ext suffix) + cleanName := pkg.Name + if idx := strings.Index(cleanName, "/@v/"); idx != -1 { + cleanName = cleanName[:idx] + } + + // Create deduplication key + key := cleanName + "@" + pkg.Version + + // Keep the entry with the largest size (typically .zip files) + if existing, ok := seen[key]; !ok || pkg.Size > existing.Size { + // Create a copy with cleaned name + cleanPkg := *pkg + cleanPkg.Name = cleanName + seen[key] = &cleanPkg + } + } + + // Convert map to slice + packages := make([]*metadata.Package, 0, len(seen)) + for _, pkg := range seen { + packages = append(packages, pkg) + } + + // Enhance packages with vulnerability information if security scanning is enabled + var response map[string]interface{} + if a.config.Security.Enabled { + enhancedPackages := make([]map[string]interface{}, 0, len(packages)) + for _, pkg := range packages { + pkgMap := map[string]interface{}{ + "id": pkg.ID, + "registry": pkg.Registry, + "name": pkg.Name, + "version": pkg.Version, + "size": pkg.Size, + "checksum_sha256": pkg.ChecksumSHA256, + "cached_at": pkg.CachedAt, + "last_accessed": pkg.LastAccessed, + "download_count": pkg.DownloadCount, + } + + // Add vulnerability info if scanned + if pkg.SecurityScanned { + scanResult, err := a.metadata.GetScanResult(ctx, pkg.Registry, pkg.Name, pkg.Version) + if err == nil && scanResult != nil { + // Count vulnerabilities by severity + severityCounts := make(map[string]int) + for _, vuln := range scanResult.Vulnerabilities { + severityCounts[strings.ToUpper(vuln.Severity)]++ + } + + pkgMap["vulnerabilities"] = map[string]interface{}{ + "scanned": true, + "status": scanResult.Status, + "counts": map[string]int{ + "critical": severityCounts["CRITICAL"], + "high": severityCounts["HIGH"], + "medium": severityCounts["MEDIUM"], + "low": severityCounts["LOW"], + }, + "total": scanResult.VulnerabilityCount, + } + } else { + pkgMap["vulnerabilities"] = map[string]interface{}{ + "scanned": false, + "status": "pending", + } + } + } else { + pkgMap["vulnerabilities"] = map[string]interface{}{ + "scanned": false, + "status": "not_scanned", + } + } + + enhancedPackages = append(enhancedPackages, pkgMap) + } + + response = map[string]interface{}{ + "packages": enhancedPackages, + "total": len(enhancedPackages), + } + } else { + response = map[string]interface{}{ + "packages": packages, + "total": len(packages), + } + } + + // Success response + errors.WriteJSONSimple(w, http.StatusOK, response) +} + +// handleDeletePackage deletes a cached package +func (a *App) handleDeletePackage(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + // Parse path: /api/packages/{registry}/{name}/{version} + // For Go packages, name can contain slashes (e.g., github.com/user/repo) + // Version is always the last segment + path := strings.TrimPrefix(r.URL.Path, "/api/packages/") + parts := strings.Split(path, "/") + if len(parts) < 3 { + errors.WriteErrorSimple(w, errors.BadRequest("invalid path format, expected /api/packages/{registry}/{name}/{version}")) + return + } + + registry := parts[0] + version := parts[len(parts)-1] + name := strings.Join(parts[1:len(parts)-1], "/") + + // For Go packages, we need to find and delete all cache entries (.info, .mod, .zip) + // For other registries, we can delete directly + var deletedCount int + var lastErr error + + if registry == "go" { + // List all packages matching the base name and version + allPackages, err := a.metadata.ListPackages(ctx, &metadata.ListOptions{ + Limit: 1000, + }) + if err != nil { + log.Error().Err(err).Msg("Failed to list packages for deletion") + errors.WriteErrorSimple(w, errors.InternalServer("failed to list packages")) + return + } + + log.Debug(). + Str("registry", registry). + Str("name", name). + Str("version", version). + Int("total_packages", len(allPackages)). + Msg("Searching for packages to delete") + + // Find and delete all entries for this package + for _, pkg := range allPackages { + if pkg.Registry != registry || pkg.Version != version { + continue + } + + // Check if this package name matches (either exact or with /@v/ suffix) + cleanName := pkg.Name + if idx := strings.Index(cleanName, "/@v/"); idx != -1 { + cleanName = cleanName[:idx] + } + + log.Debug(). + Str("db_name", pkg.Name). + Str("clean_name", cleanName). + Str("search_name", name). + Bool("matches", cleanName == name). + Msg("Checking package") + + if cleanName == name { + if err := a.cache.Delete(ctx, pkg.Registry, pkg.Name, pkg.Version); err != nil { + log.Warn(). + Err(err). + Str("registry", pkg.Registry). + Str("name", pkg.Name). + Str("version", pkg.Version). + Msg("Failed to delete package variant") + lastErr = err + } else { + deletedCount++ + log.Info(). + Str("registry", pkg.Registry). + Str("name", pkg.Name). + Str("version", pkg.Version). + Msg("Deleted package variant") + } + } + } + + log.Debug(). + Int("deleted_count", deletedCount). + Msg("Delete operation completed") + + if deletedCount == 0 { + errors.WriteErrorSimple(w, errors.NotFound("package not found")) + return + } + + if lastErr != nil && deletedCount == 0 { + errors.WriteErrorSimple(w, errors.InternalServer("failed to delete package")) + return + } + } else { + // For NPM and PyPI, delete directly + if err := a.cache.Delete(ctx, registry, name, version); err != nil { + log.Error(). + Err(err). + Str("registry", registry). + Str("name", name). + Str("version", version). + Msg("Failed to delete package") + errors.WriteErrorSimple(w, errors.InternalServer("failed to delete package")) + return + } + deletedCount = 1 + } + + // Broadcast event via WebSocket + a.wsServer.Broadcast(websocket.EventPackageDeleted, map[string]interface{}{ + "registry": registry, + "name": name, + "version": version, + }) + + // Success response + response := map[string]interface{}{ + "deleted": true, + "package": map[string]string{ + "registry": registry, + "name": name, + "version": version, + }, + } + + // For Go packages, include count of deleted variants + if registry == "go" { + response["deleted_count"] = deletedCount + } + + errors.WriteJSONSimple(w, http.StatusOK, response) +} + +// handleStats handles /api/stats endpoint +func (a *App) handleStats(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + if r.Method != "GET" { + errors.WriteErrorSimple(w, errors.BadRequest("method not allowed")) + return + } + + ctx := r.Context() + + // Get cache statistics for all registries + cacheStats, err := a.cache.GetStats(ctx, "") + if err != nil { + log.Error().Err(err).Msg("Failed to get cache stats") + cacheStats = &metadata.Stats{} + } + + // Get all packages to calculate total size and downloads + packages, err := a.metadata.ListPackages(ctx, nil) + if err != nil { + log.Error().Err(err).Msg("Failed to list packages") + packages = []*metadata.Package{} + } + + // Calculate totals and registry breakdown from actual packages (exclude metadata entries like "list", "latest") + var totalSize int64 + var totalDownloads int64 + var actualPackageCount int + registryStats := make(map[string]map[string]interface{}) + + for _, pkg := range packages { + // Skip metadata entries + if pkg.Version == "list" || pkg.Version == "latest" { + continue + } + totalSize += pkg.Size + totalDownloads += int64(pkg.DownloadCount) + actualPackageCount++ + + // Track per-registry stats + if _, ok := registryStats[pkg.Registry]; !ok { + registryStats[pkg.Registry] = map[string]interface{}{ + "count": 0, + "size": int64(0), + "downloads": int64(0), + } + } + registryStats[pkg.Registry]["count"] = registryStats[pkg.Registry]["count"].(int) + 1 + registryStats[pkg.Registry]["size"] = registryStats[pkg.Registry]["size"].(int64) + pkg.Size + registryStats[pkg.Registry]["downloads"] = registryStats[pkg.Registry]["downloads"].(int64) + int64(pkg.DownloadCount) + } + + // Combine statistics + stats := map[string]interface{}{ + "total_packages": actualPackageCount, + "total_downloads": totalDownloads, + "total_size": totalSize, + "cache_hits": cacheStats.TotalDownloads, + "cache_misses": 0, // TODO: Track cache misses + "cache_evictions": 0, // TODO: Track evictions + "cache_size": cacheStats.TotalSize, + "scanned_packages": cacheStats.ScannedPackages, + "vulnerable_packages": cacheStats.VulnerablePackages, + } + + // Convert registry stats to interface map + registries := make(map[string]interface{}) + for registry, regStats := range registryStats { + registries[registry] = regStats + } + + errors.WriteJSONSimple(w, http.StatusOK, map[string]interface{}{ + "stats": stats, + "registries": registries, + }) +} + +// handleInfo handles /api/info endpoint +func (a *App) handleInfo(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + if r.Method != "GET" { + errors.WriteErrorSimple(w, errors.BadRequest("method not allowed")) + return + } + + info := map[string]interface{}{ + "name": "GoHoarder", + "version": version.Version, + "config": map[string]interface{}{ + "storage_backend": a.config.Storage.Backend, + "metadata_backend": a.config.Metadata.Backend, + "cache_ttl": a.config.Cache.DefaultTTL.String(), + "max_cache_size": a.config.Cache.MaxSizeBytes, + }, + "features": map[string]bool{ + "distributed_locking": a.lockManager != nil, + "security_scanning": a.config.Security.Enabled, + "pre_warming": a.prewarmWorker != nil, + "websockets": true, + "analytics": true, + }, + } + + errors.WriteJSONSimple(w, http.StatusOK, info) +} diff --git a/pkg/app/handlers.go.bak2 b/pkg/app/handlers.go.bak2 new file mode 100644 index 0000000..c3c5f65 --- /dev/null +++ b/pkg/app/handlers.go.bak2 @@ -0,0 +1,415 @@ +package app + +import ( + "net/http" + "strings" + "time" + + "github.com/lukaszraczylo/gohoarder/internal/version" + "github.com/lukaszraczylo/gohoarder/pkg/errors" + "github.com/lukaszraczylo/gohoarder/pkg/metadata" + "github.com/lukaszraczylo/gohoarder/pkg/websocket" + "github.com/rs/zerolog/log" +) + +// handlePackages handles /api/packages endpoint +func (a *App) handlePackages(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + // Check if this is a vulnerability endpoint request + if strings.HasSuffix(r.URL.Path, "/vulnerabilities") { + a.handleVulnerabilities(w, r) + return + } + + switch r.Method { + case "GET": + a.handleListPackages(w, r) + case "DELETE": + a.handleDeletePackage(w, r) + default: + errors.WriteErrorSimple(w, errors.BadRequest("method not allowed")) + } +} + +// handleListPackages returns list of cached packages +func (a *App) handleListPackages(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + // Get packages from metadata store + allPackages, err := a.metadata.ListPackages(ctx, &metadata.ListOptions{ + Limit: 1000, // Get more to account for duplicates + Offset: 0, + }) + if err != nil { + log.Error().Err(err).Msg("Failed to list packages") + errors.WriteErrorSimple(w, errors.InternalServer("failed to list packages")) + return + } + + // Filter, clean, and deduplicate packages + seen := make(map[string]*metadata.Package) + for _, pkg := range allPackages { + // Skip metadata entries + if pkg.Version == "list" || pkg.Version == "latest" { + continue + } + + // Clean the package name (remove /@v/version.ext suffix) + cleanName := pkg.Name + if idx := strings.Index(cleanName, "/@v/"); idx != -1 { + cleanName = cleanName[:idx] + } + + // Create deduplication key + key := cleanName + "@" + pkg.Version + + // Keep the entry with the largest size (typically .zip files) + if existing, ok := seen[key]; !ok || pkg.Size > existing.Size { + // Create a copy with cleaned name + cleanPkg := *pkg + cleanPkg.Name = cleanName + seen[key] = &cleanPkg + } + } + + // Convert map to slice + packages := make([]*metadata.Package, 0, len(seen)) + for _, pkg := range seen { + packages = append(packages, pkg) + } + + // Enhance packages with vulnerability information if security scanning is enabled + var response map[string]interface{} + if a.config.Security.Enabled { + enhancedPackages := make([]map[string]interface{}, 0, len(packages)) + for _, pkg := range packages { + pkgMap := map[string]interface{}{ + "id": pkg.ID, + "registry": pkg.Registry, + "name": pkg.Name, + "version": pkg.Version, + "size": pkg.Size, + "checksum_sha256": pkg.ChecksumSHA256, + "cached_at": pkg.CachedAt, + "last_accessed": pkg.LastAccessed, + "download_count": pkg.DownloadCount, + } + + // Add vulnerability info if scanned + if pkg.SecurityScanned { + scanResult, err := a.metadata.GetScanResult(ctx, pkg.Registry, pkg.Name, pkg.Version) + if err == nil && scanResult != nil { + // Count vulnerabilities by severity + severityCounts := make(map[string]int) + for _, vuln := range scanResult.Vulnerabilities { + severityCounts[strings.ToUpper(vuln.Severity)]++ + } + + pkgMap["vulnerabilities"] = map[string]interface{}{ + "scanned": true, + "status": scanResult.Status, + "scannedAt": scanResult.ScannedAt.Format(time.RFC3339), + "counts": map[string]int{ + "critical": severityCounts["CRITICAL"], + "high": severityCounts["HIGH"], + "medium": severityCounts["MEDIUM"], + "low": severityCounts["LOW"], + }, + "total": scanResult.VulnerabilityCount, + } + } else { + pkgMap["vulnerabilities"] = map[string]interface{}{ + "scanned": false, + "status": "pending", + } + } + } else { + pkgMap["vulnerabilities"] = map[string]interface{}{ + "scanned": false, + "status": "not_scanned", + } + } + + enhancedPackages = append(enhancedPackages, pkgMap) + } + + response = map[string]interface{}{ + "packages": enhancedPackages, + "total": len(enhancedPackages), + } + } else { + response = map[string]interface{}{ + "packages": packages, + "total": len(packages), + } + } + + // Success response + errors.WriteJSONSimple(w, http.StatusOK, response) +} + +// handleDeletePackage deletes a cached package +func (a *App) handleDeletePackage(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + // Parse path: /api/packages/{registry}/{name}/{version} + // For Go packages, name can contain slashes (e.g., github.com/user/repo) + // Version is always the last segment + path := strings.TrimPrefix(r.URL.Path, "/api/packages/") + parts := strings.Split(path, "/") + if len(parts) < 3 { + errors.WriteErrorSimple(w, errors.BadRequest("invalid path format, expected /api/packages/{registry}/{name}/{version}")) + return + } + + registry := parts[0] + version := parts[len(parts)-1] + name := strings.Join(parts[1:len(parts)-1], "/") + + // For Go packages, we need to find and delete all cache entries (.info, .mod, .zip) + // For other registries, we can delete directly + var deletedCount int + var lastErr error + + if registry == "go" { + // List all packages matching the base name and version + allPackages, err := a.metadata.ListPackages(ctx, &metadata.ListOptions{ + Limit: 1000, + }) + if err != nil { + log.Error().Err(err).Msg("Failed to list packages for deletion") + errors.WriteErrorSimple(w, errors.InternalServer("failed to list packages")) + return + } + + log.Debug(). + Str("registry", registry). + Str("name", name). + Str("version", version). + Int("total_packages", len(allPackages)). + Msg("Searching for packages to delete") + + // Find and delete all entries for this package + for _, pkg := range allPackages { + if pkg.Registry != registry || pkg.Version != version { + continue + } + + // Check if this package name matches (either exact or with /@v/ suffix) + cleanName := pkg.Name + if idx := strings.Index(cleanName, "/@v/"); idx != -1 { + cleanName = cleanName[:idx] + } + + log.Debug(). + Str("db_name", pkg.Name). + Str("clean_name", cleanName). + Str("search_name", name). + Bool("matches", cleanName == name). + Msg("Checking package") + + if cleanName == name { + if err := a.cache.Delete(ctx, pkg.Registry, pkg.Name, pkg.Version); err != nil { + log.Warn(). + Err(err). + Str("registry", pkg.Registry). + Str("name", pkg.Name). + Str("version", pkg.Version). + Msg("Failed to delete package variant") + lastErr = err + } else { + deletedCount++ + log.Info(). + Str("registry", pkg.Registry). + Str("name", pkg.Name). + Str("version", pkg.Version). + Msg("Deleted package variant") + } + } + } + + log.Debug(). + Int("deleted_count", deletedCount). + Msg("Delete operation completed") + + if deletedCount == 0 { + errors.WriteErrorSimple(w, errors.NotFound("package not found")) + return + } + + if lastErr != nil && deletedCount == 0 { + errors.WriteErrorSimple(w, errors.InternalServer("failed to delete package")) + return + } + } else { + // For NPM and PyPI, delete directly + if err := a.cache.Delete(ctx, registry, name, version); err != nil { + log.Error(). + Err(err). + Str("registry", registry). + Str("name", name). + Str("version", version). + Msg("Failed to delete package") + errors.WriteErrorSimple(w, errors.InternalServer("failed to delete package")) + return + } + deletedCount = 1 + } + + // Broadcast event via WebSocket + a.wsServer.Broadcast(websocket.EventPackageDeleted, map[string]interface{}{ + "registry": registry, + "name": name, + "version": version, + }) + + // Success response + response := map[string]interface{}{ + "deleted": true, + "package": map[string]string{ + "registry": registry, + "name": name, + "version": version, + }, + } + + // For Go packages, include count of deleted variants + if registry == "go" { + response["deleted_count"] = deletedCount + } + + errors.WriteJSONSimple(w, http.StatusOK, response) +} + +// handleStats handles /api/stats endpoint +func (a *App) handleStats(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + if r.Method != "GET" { + errors.WriteErrorSimple(w, errors.BadRequest("method not allowed")) + return + } + + ctx := r.Context() + + // Get cache statistics for all registries + cacheStats, err := a.cache.GetStats(ctx, "") + if err != nil { + log.Error().Err(err).Msg("Failed to get cache stats") + cacheStats = &metadata.Stats{} + } + + // Get all packages to calculate total size and downloads + packages, err := a.metadata.ListPackages(ctx, nil) + if err != nil { + log.Error().Err(err).Msg("Failed to list packages") + packages = []*metadata.Package{} + } + + // Calculate totals and registry breakdown from actual packages (exclude metadata entries like "list", "latest") + var totalSize int64 + var totalDownloads int64 + var actualPackageCount int + registryStats := make(map[string]map[string]interface{}) + + for _, pkg := range packages { + // Skip metadata entries + if pkg.Version == "list" || pkg.Version == "latest" { + continue + } + totalSize += pkg.Size + totalDownloads += int64(pkg.DownloadCount) + actualPackageCount++ + + // Track per-registry stats + if _, ok := registryStats[pkg.Registry]; !ok { + registryStats[pkg.Registry] = map[string]interface{}{ + "count": 0, + "size": int64(0), + "downloads": int64(0), + } + } + registryStats[pkg.Registry]["count"] = registryStats[pkg.Registry]["count"].(int) + 1 + registryStats[pkg.Registry]["size"] = registryStats[pkg.Registry]["size"].(int64) + pkg.Size + registryStats[pkg.Registry]["downloads"] = registryStats[pkg.Registry]["downloads"].(int64) + int64(pkg.DownloadCount) + } + + // Combine statistics + stats := map[string]interface{}{ + "total_packages": actualPackageCount, + "total_downloads": totalDownloads, + "total_size": totalSize, + "cache_hits": cacheStats.TotalDownloads, + "cache_misses": 0, // TODO: Track cache misses + "cache_evictions": 0, // TODO: Track evictions + "cache_size": cacheStats.TotalSize, + "scanned_packages": cacheStats.ScannedPackages, + "vulnerable_packages": cacheStats.VulnerablePackages, + } + + // Convert registry stats to interface map + registries := make(map[string]interface{}) + for registry, regStats := range registryStats { + registries[registry] = regStats + } + + errors.WriteJSONSimple(w, http.StatusOK, map[string]interface{}{ + "stats": stats, + "registries": registries, + }) +} + +// handleInfo handles /api/info endpoint +func (a *App) handleInfo(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + if r.Method != "GET" { + errors.WriteErrorSimple(w, errors.BadRequest("method not allowed")) + return + } + + info := map[string]interface{}{ + "name": "GoHoarder", + "version": version.Version, + "config": map[string]interface{}{ + "storage_backend": a.config.Storage.Backend, + "metadata_backend": a.config.Metadata.Backend, + "cache_ttl": a.config.Cache.DefaultTTL.String(), + "max_cache_size": a.config.Cache.MaxSizeBytes, + }, + "features": map[string]bool{ + "distributed_locking": a.lockManager != nil, + "security_scanning": a.config.Security.Enabled, + "pre_warming": a.prewarmWorker != nil, + "websockets": true, + "analytics": true, + }, + } + + errors.WriteJSONSimple(w, http.StatusOK, info) +} diff --git a/pkg/app/handlers_admin.go b/pkg/app/handlers_admin.go new file mode 100644 index 0000000..8cd6b14 --- /dev/null +++ b/pkg/app/handlers_admin.go @@ -0,0 +1,323 @@ +package app + +import ( + "strings" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/lukaszraczylo/gohoarder/pkg/auth" + "github.com/lukaszraczylo/gohoarder/pkg/metadata" + "github.com/lukaszraczylo/gohoarder/pkg/uuid" + "github.com/rs/zerolog/log" +) + +// requireAdmin middleware checks for admin authentication +func (a *App) requireAdmin(c *fiber.Ctx) error { + // Get API key from Authorization header + authHeader := c.Get("Authorization") + if authHeader == "" { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "error": "missing authorization header", + }) + } + + // Extract bearer token + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "error": "invalid authorization header format, expected: Bearer ", + }) + } + + apiKey := parts[1] + + // Validate API key + key, err := a.authManager.ValidateAPIKey(c.Context(), apiKey) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "error": "invalid or expired API key", + }) + } + + // Check if user has admin role or bypass management permission + if key.Role != auth.RoleAdmin && !key.HasPermission(auth.PermissionManageBypasses) { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ + "error": "insufficient permissions, admin role required", + }) + } + + // Continue to next handler + return c.Next() +} + +// handleAdminBypasses handles /api/admin/bypasses endpoint +func (a *App) handleAdminBypasses(c *fiber.Ctx) error { + c.Set("Content-Type", "application/json") + c.Set("Access-Control-Allow-Origin", "*") + c.Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS") + c.Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + + if c.Method() == "OPTIONS" { + return c.SendStatus(fiber.StatusOK) + } + + // Check if there's an ID parameter + id := c.Params("id") + + switch c.Method() { + case "GET": + if id != "" { + return a.handleGetBypass(c) + } + return a.handleListBypasses(c) + case "POST": + return a.handleCreateBypass(c) + case "PATCH": + return a.handleUpdateBypass(c) + case "DELETE": + return a.handleDeleteBypass(c) + default: + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "method not allowed"}) + } +} + +// handleListBypasses lists all CVE bypasses +func (a *App) handleListBypasses(c *fiber.Ctx) error { + ctx := c.Context() + + // Parse query parameters + includeExpired := c.Query("include_expired") == "true" + activeOnly := c.Query("active_only") == "true" + bypassType := metadata.BypassType(c.Query("type")) + + opts := &metadata.BypassListOptions{ + IncludeExpired: includeExpired, + ActiveOnly: activeOnly, + Type: bypassType, + } + + bypasses, err := a.metadata.ListCVEBypasses(ctx, opts) + if err != nil { + log.Error().Err(err).Msg("Failed to list CVE bypasses") + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to list bypasses"}) + } + + return c.Status(fiber.StatusOK).JSON(fiber.Map{ + "bypasses": bypasses, + "total": len(bypasses), + }) +} + +// CreateBypassRequest represents the request body for creating a bypass +type CreateBypassRequest struct { + Type metadata.BypassType `json:"type"` // "cve" or "package" + Target string `json:"target"` // CVE ID or package name + Reason string `json:"reason"` // Why this bypass is needed + CreatedBy string `json:"created_by"` // Admin username + ExpiresInHours int `json:"expires_in_hours"` // How many hours until expiration + AppliesTo string `json:"applies_to,omitempty"` // Optional: limit CVE bypass to specific package + NotifyOnExpiry bool `json:"notify_on_expiry"` // Send notification when expired +} + +// handleCreateBypass creates a new CVE bypass +func (a *App) handleCreateBypass(c *fiber.Ctx) error { + ctx := c.Context() + + var req CreateBypassRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid JSON in request body"}) + } + + // Validate request + if req.Type != metadata.BypassTypeCVE && req.Type != metadata.BypassTypePackage { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "type must be 'cve' or 'package'"}) + } + + if req.Target == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "target is required"}) + } + + if req.Reason == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "reason is required"}) + } + + if req.CreatedBy == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "created_by is required"}) + } + + if req.ExpiresInHours <= 0 { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "expires_in_hours must be greater than 0"}) + } + + // Create bypass + now := time.Now() + expiresAt := now.Add(time.Duration(req.ExpiresInHours) * time.Hour) + + bypass := &metadata.CVEBypass{ + ID: uuid.New().String(), + Type: req.Type, + Target: req.Target, + Reason: req.Reason, + CreatedBy: req.CreatedBy, + CreatedAt: now, + ExpiresAt: expiresAt, + AppliesTo: req.AppliesTo, + NotifyOnExpiry: req.NotifyOnExpiry, + Active: true, + } + + // Save to database + if err := a.metadata.SaveCVEBypass(ctx, bypass); err != nil { + log.Error().Err(err).Msg("Failed to save CVE bypass") + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to create bypass"}) + } + + log.Info(). + Str("bypass_id", bypass.ID). + Str("type", string(bypass.Type)). + Str("target", bypass.Target). + Str("created_by", bypass.CreatedBy). + Time("expires_at", bypass.ExpiresAt). + Msg("CVE bypass created") + + return c.Status(fiber.StatusCreated).JSON(fiber.Map{ + "bypass": bypass, + "message": "Bypass created successfully", + }) +} + +// handleGetBypass gets a specific bypass by ID +func (a *App) handleGetBypass(c *fiber.Ctx) error { + ctx := c.Context() + + // Extract ID from parameter + bypassID := c.Params("id") + + if bypassID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "bypass ID is required"}) + } + + // Get all bypasses and find the one with matching ID + bypasses, err := a.metadata.ListCVEBypasses(ctx, &metadata.BypassListOptions{ + IncludeExpired: true, + }) + if err != nil { + log.Error().Err(err).Msg("Failed to list bypasses") + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to get bypass"}) + } + + for _, bypass := range bypasses { + if bypass.ID == bypassID { + return c.Status(fiber.StatusOK).JSON(fiber.Map{ + "bypass": bypass, + }) + } + } + + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "bypass not found"}) +} + +// UpdateBypassRequest represents the request body for updating a bypass +type UpdateBypassRequest struct { + Active *bool `json:"active,omitempty"` + Reason string `json:"reason,omitempty"` + ExpiresInHours int `json:"expires_in_hours,omitempty"` +} + +// handleUpdateBypass updates a bypass (activate/deactivate or extend expiration) +func (a *App) handleUpdateBypass(c *fiber.Ctx) error { + ctx := c.Context() + + // Extract ID from parameter + bypassID := c.Params("id") + + if bypassID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "bypass ID is required"}) + } + + var req UpdateBypassRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid JSON in request body"}) + } + + // Get current bypass + bypasses, err := a.metadata.ListCVEBypasses(ctx, &metadata.BypassListOptions{ + IncludeExpired: true, + }) + if err != nil { + log.Error().Err(err).Msg("Failed to list bypasses") + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to get bypass"}) + } + + var currentBypass *metadata.CVEBypass + for _, bypass := range bypasses { + if bypass.ID == bypassID { + currentBypass = bypass + break + } + } + + if currentBypass == nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "bypass not found"}) + } + + // Update fields + if req.Active != nil { + currentBypass.Active = *req.Active + } + + if req.Reason != "" { + currentBypass.Reason = req.Reason + } + + if req.ExpiresInHours > 0 { + currentBypass.ExpiresAt = time.Now().Add(time.Duration(req.ExpiresInHours) * time.Hour) + } + + // Save updated bypass + if err := a.metadata.SaveCVEBypass(ctx, currentBypass); err != nil { + log.Error().Err(err).Msg("Failed to update bypass") + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to update bypass"}) + } + + log.Info(). + Str("bypass_id", currentBypass.ID). + Bool("active", currentBypass.Active). + Msg("CVE bypass updated") + + return c.Status(fiber.StatusOK).JSON(fiber.Map{ + "bypass": currentBypass, + "message": "Bypass updated successfully", + }) +} + +// handleDeleteBypass deletes a bypass +func (a *App) handleDeleteBypass(c *fiber.Ctx) error { + ctx := c.Context() + + // Extract ID from parameter + bypassID := c.Params("id") + + if bypassID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "bypass ID is required"}) + } + + // Delete bypass + if err := a.metadata.DeleteCVEBypass(ctx, bypassID); err != nil { + if strings.Contains(err.Error(), "not found") { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "bypass not found"}) + } + log.Error().Err(err).Msg("Failed to delete bypass") + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to delete bypass"}) + } + + log.Info(). + Str("bypass_id", bypassID). + Msg("CVE bypass deleted") + + return c.Status(fiber.StatusOK).JSON(fiber.Map{ + "deleted": true, + "bypass_id": bypassID, + "message": "Bypass deleted successfully", + }) +} diff --git a/pkg/app/handlers_vulnerabilities.go b/pkg/app/handlers_vulnerabilities.go new file mode 100644 index 0000000..e13e63d --- /dev/null +++ b/pkg/app/handlers_vulnerabilities.go @@ -0,0 +1,156 @@ +package app + +import ( + "strings" + + "github.com/gofiber/fiber/v2" + "github.com/lukaszraczylo/gohoarder/pkg/metadata" + "github.com/rs/zerolog/log" +) + +// handleVulnerabilities handles /api/packages/{registry}/{name}/{version}/vulnerabilities endpoint +func (a *App) handleVulnerabilities(c *fiber.Ctx) error { + c.Set("Content-Type", "application/json") + c.Set("Access-Control-Allow-Origin", "*") + c.Set("Access-Control-Allow-Methods", "GET, OPTIONS") + c.Set("Access-Control-Allow-Headers", "Content-Type") + + if c.Method() == "OPTIONS" { + return c.SendStatus(fiber.StatusOK) + } + + if c.Method() != "GET" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "method not allowed"}) + } + + ctx := c.Context() + + // Parse path: /api/packages/{registry}/{name}/{version}/vulnerabilities + path := strings.TrimPrefix(c.Path(), "/api/packages/") + path = strings.TrimSuffix(path, "/vulnerabilities") + parts := strings.Split(path, "/") + if len(parts) < 3 { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "invalid path format, expected /api/packages/{registry}/{name}/{version}/vulnerabilities", + }) + } + + registry := parts[0] + version := parts[len(parts)-1] + name := strings.Join(parts[1:len(parts)-1], "/") + + log.Debug(). + Str("registry", registry). + Str("name", name). + Str("version", version). + Msg("Getting vulnerabilities for package") + + // Get scan result from metadata store + scanResult, err := a.metadata.GetScanResult(ctx, registry, name, version) + if err != nil { + // Check if package exists + pkg, pkgErr := a.metadata.GetPackage(ctx, registry, name, version) + if pkgErr != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "package not found"}) + } + + // Package exists but not scanned yet + return c.Status(fiber.StatusOK).JSON(fiber.Map{ + "package": fiber.Map{ + "registry": registry, + "name": name, + "version": version, + }, + "scanned": false, + "status": "pending", + "vulnerabilities": []interface{}{}, + "vulnerability_count": 0, + "message": "Package not yet scanned for vulnerabilities", + "security_scanned": pkg.SecurityScanned, + }) + } + + // Get active bypasses to show which vulnerabilities are bypassed + bypasses, err := a.metadata.GetActiveCVEBypasses(ctx) + if err != nil { + log.Warn().Err(err).Msg("Failed to get CVE bypasses") + bypasses = []*metadata.CVEBypass{} + } + + // Build bypass map for fast lookup + bypassedCVEs := make(map[string]*metadata.CVEBypass) + packageKey := registry + "/" + name + "@" + version + packageKeyNoVersion := registry + "/" + name + + for _, bypass := range bypasses { + if bypass.Type == metadata.BypassTypeCVE && bypass.Active { + // Check if bypass applies to this package + if bypass.AppliesTo == "" || bypass.AppliesTo == packageKey || bypass.AppliesTo == packageKeyNoVersion { + bypassedCVEs[strings.ToUpper(bypass.Target)] = bypass + } + } + } + + // Enrich vulnerabilities with bypass information + enrichedVulns := make([]map[string]interface{}, 0, len(scanResult.Vulnerabilities)) + severityCounts := make(map[string]int) + + for _, vuln := range scanResult.Vulnerabilities { + bypassed := false + var bypassInfo map[string]interface{} + + // Check if this CVE is bypassed + if bypass, ok := bypassedCVEs[strings.ToUpper(vuln.ID)]; ok { + bypassed = true + bypassInfo = map[string]interface{}{ + "id": bypass.ID, + "reason": bypass.Reason, + "created_by": bypass.CreatedBy, + "expires_at": bypass.ExpiresAt, + } + } else { + // Count non-bypassed vulnerabilities by severity + severityCounts[strings.ToUpper(vuln.Severity)]++ + } + + enrichedVuln := map[string]interface{}{ + "id": vuln.ID, + "severity": vuln.Severity, + "title": vuln.Title, + "description": vuln.Description, + "references": vuln.References, + "fixed_in": vuln.FixedIn, + "bypassed": bypassed, + } + + if bypassed { + enrichedVuln["bypass"] = bypassInfo + } + + enrichedVulns = append(enrichedVulns, enrichedVuln) + } + + // Build response + response := fiber.Map{ + "package": fiber.Map{ + "registry": registry, + "name": name, + "version": version, + }, + "scanned": true, + "scanner": scanResult.Scanner, + "scanned_at": scanResult.ScannedAt, + "status": scanResult.Status, + "vulnerabilities": enrichedVulns, + "vulnerability_count": scanResult.VulnerabilityCount, + "severity_counts": fiber.Map{ + "critical": severityCounts["CRITICAL"], + "high": severityCounts["HIGH"], + "moderate": severityCounts["MODERATE"], + "low": severityCounts["LOW"], + }, + "bypassed_count": len(scanResult.Vulnerabilities) - (severityCounts["CRITICAL"] + severityCounts["HIGH"] + severityCounts["MODERATE"] + severityCounts["LOW"]), + } + + return c.Status(fiber.StatusOK).JSON(response) +} diff --git a/pkg/auth/auth.go b/pkg/auth/auth.go new file mode 100644 index 0000000..7a47f3c --- /dev/null +++ b/pkg/auth/auth.go @@ -0,0 +1,193 @@ +package auth + +import ( + "context" + "crypto/rand" + "encoding/base64" + "sync" + "time" + + "github.com/lukaszraczylo/gohoarder/pkg/errors" + "golang.org/x/crypto/bcrypt" +) + +// Manager handles authentication and authorization +type Manager struct { + keys map[string]*APIKey + mu sync.RWMutex +} + +// APIKey represents an API key +type APIKey struct { + ID string + Name string + HashedKey string + Role Role + CreatedAt time.Time + ExpiresAt *time.Time + LastUsedAt time.Time + Permissions []Permission +} + +// Role represents user role +type Role string + +const ( + RoleReadOnly Role = "readonly" + RoleReadWrite Role = "readwrite" + RoleAdmin Role = "admin" +) + +// Permission represents a specific permission +type Permission string + +const ( + PermissionReadPackage Permission = "package:read" + PermissionWritePackage Permission = "package:write" + PermissionDeletePackage Permission = "package:delete" + PermissionViewStats Permission = "stats:view" + PermissionManageKeys Permission = "keys:manage" + PermissionManageSettings Permission = "settings:manage" + PermissionScanPackages Permission = "scan:execute" + PermissionManageBypasses Permission = "bypasses:manage" +) + +// New creates a new authentication manager +func New() *Manager { + return &Manager{ + keys: make(map[string]*APIKey), + } +} + +// GenerateAPIKey generates a new API key +func (m *Manager) GenerateAPIKey(name string, role Role, expiresIn *time.Duration) (*APIKey, string, error) { + // Generate random key + keyBytes := make([]byte, 32) + if _, err := rand.Read(keyBytes); err != nil { + return nil, "", errors.Wrap(err, errors.ErrCodeInternalServer, "failed to generate random key") + } + + rawKey := base64.URLEncoding.EncodeToString(keyBytes) + + // Hash the key + hashedKey, err := bcrypt.GenerateFromPassword([]byte(rawKey), bcrypt.DefaultCost) + if err != nil { + return nil, "", errors.Wrap(err, errors.ErrCodeInternalServer, "failed to hash key") + } + + var expiresAt *time.Time + if expiresIn != nil { + t := time.Now().Add(*expiresIn) + expiresAt = &t + } + + apiKey := &APIKey{ + ID: generateID(), + Name: name, + HashedKey: string(hashedKey), + Role: role, + CreatedAt: time.Now(), + ExpiresAt: expiresAt, + Permissions: getPermissionsForRole(role), + } + + m.mu.Lock() + m.keys[apiKey.ID] = apiKey + m.mu.Unlock() + + return apiKey, rawKey, nil +} + +// ValidateAPIKey validates an API key and returns the associated key object +func (m *Manager) ValidateAPIKey(ctx context.Context, rawKey string) (*APIKey, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + for _, apiKey := range m.keys { + // Check if key is expired + if apiKey.ExpiresAt != nil && time.Now().After(*apiKey.ExpiresAt) { + continue + } + + // Compare hashed key + if err := bcrypt.CompareHashAndPassword([]byte(apiKey.HashedKey), []byte(rawKey)); err == nil { + // Update last used + apiKey.LastUsedAt = time.Now() + return apiKey, nil + } + } + + return nil, errors.New(errors.ErrCodeUnauthorized, "invalid API key") +} + +// RevokeAPIKey revokes an API key +func (m *Manager) RevokeAPIKey(keyID string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if _, exists := m.keys[keyID]; !exists { + return errors.NotFound("API key not found") + } + + delete(m.keys, keyID) + return nil +} + +// ListAPIKeys lists all API keys +func (m *Manager) ListAPIKeys() []*APIKey { + m.mu.RLock() + defer m.mu.RUnlock() + + keys := make([]*APIKey, 0, len(m.keys)) + for _, key := range m.keys { + keys = append(keys, key) + } + return keys +} + +// HasPermission checks if an API key has a specific permission +func (k *APIKey) HasPermission(permission Permission) bool { + for _, p := range k.Permissions { + if p == permission { + return true + } + } + return false +} + +// getPermissionsForRole returns permissions for a role +func getPermissionsForRole(role Role) []Permission { + switch role { + case RoleReadOnly: + return []Permission{ + PermissionReadPackage, + PermissionViewStats, + } + case RoleReadWrite: + return []Permission{ + PermissionReadPackage, + PermissionWritePackage, + PermissionViewStats, + } + case RoleAdmin: + return []Permission{ + PermissionReadPackage, + PermissionWritePackage, + PermissionDeletePackage, + PermissionViewStats, + PermissionManageKeys, + PermissionManageSettings, + PermissionScanPackages, + PermissionManageBypasses, + } + default: + return []Permission{} + } +} + +// generateID generates a unique ID +func generateID() string { + b := make([]byte, 16) + _, _ = rand.Read(b) // #nosec G104 -- Rand read always succeeds + return base64.URLEncoding.EncodeToString(b) +} diff --git a/pkg/auth/extractor.go b/pkg/auth/extractor.go new file mode 100644 index 0000000..de72269 --- /dev/null +++ b/pkg/auth/extractor.go @@ -0,0 +1,68 @@ +package auth + +import ( + "encoding/base64" + "net/http" + "strings" +) + +// CredentialExtractor extracts authentication credentials from HTTP requests +type CredentialExtractor struct{} + +// NewCredentialExtractor creates a new credential extractor +func NewCredentialExtractor() *CredentialExtractor { + return &CredentialExtractor{} +} + +// Extract extracts authentication credentials from an HTTP request +// Returns the full Authorization header value or constructed auth string +func (e *CredentialExtractor) Extract(r *http.Request) string { + // Try Authorization header first (most common) + if auth := r.Header.Get("Authorization"); auth != "" { + return auth + } + + // Try Basic auth from URL (for PyPI compatibility) + if username, password, ok := r.BasicAuth(); ok { + auth := base64.StdEncoding.EncodeToString([]byte(username + ":" + password)) + return "Basic " + auth + } + + // No credentials found + return "" +} + +// ExtractScheme returns the authentication scheme (Bearer, Basic, Token) +func (e *CredentialExtractor) ExtractScheme(r *http.Request) string { + auth := e.Extract(r) + if auth == "" { + return "" + } + + parts := strings.SplitN(auth, " ", 2) + if len(parts) == 2 { + return parts[0] + } + + return "" +} + +// ExtractToken extracts just the token part (without scheme) +func (e *CredentialExtractor) ExtractToken(r *http.Request) string { + auth := e.Extract(r) + if auth == "" { + return "" + } + + // Remove scheme prefix + auth = strings.TrimPrefix(auth, "Bearer ") + auth = strings.TrimPrefix(auth, "Token ") + auth = strings.TrimPrefix(auth, "Basic ") + + return auth +} + +// HasCredentials checks if request has any credentials +func (e *CredentialExtractor) HasCredentials(r *http.Request) bool { + return e.Extract(r) != "" +} diff --git a/pkg/auth/hasher.go b/pkg/auth/hasher.go new file mode 100644 index 0000000..c9bcb17 --- /dev/null +++ b/pkg/auth/hasher.go @@ -0,0 +1,38 @@ +package auth + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" +) + +// CredentialHasher generates hashes of credentials for cache keys +type CredentialHasher struct{} + +// NewCredentialHasher creates a new credential hasher +func NewCredentialHasher() *CredentialHasher { + return &CredentialHasher{} +} + +// Hash generates a short hash of credentials for use in cache keys +// Returns "public" if no credentials provided +func (h *CredentialHasher) Hash(credentials string) string { + if credentials == "" { + return "public" + } + + // Use SHA256 and take first 16 characters (8 bytes) + hash := sha256.Sum256([]byte(credentials)) + return hex.EncodeToString(hash[:8]) +} + +// GenerateCacheKey generates a cache key that includes credential hash +func (h *CredentialHasher) GenerateCacheKey(registry, packageName, version, credentials string) string { + credHash := h.Hash(credentials) + return fmt.Sprintf("%s:%s:%s:%s", registry, packageName, version, credHash) +} + +// IsPublicKey checks if a cache key is for public packages (no credentials) +func (h *CredentialHasher) IsPublicKey(cacheKey string) bool { + return len(cacheKey) > 0 && cacheKey[len(cacheKey)-6:] == "public" +} diff --git a/pkg/auth/validation_cache.go b/pkg/auth/validation_cache.go new file mode 100644 index 0000000..4b9e316 --- /dev/null +++ b/pkg/auth/validation_cache.go @@ -0,0 +1,109 @@ +package auth + +import ( + "sync" + "time" +) + +// ValidationResult represents a cached credential validation result +type ValidationResult struct { + Allowed bool + ExpiresAt time.Time + Reason string +} + +// ValidationCache caches credential validation results to reduce upstream checks +type ValidationCache struct { + cache map[string]*ValidationResult + mu sync.RWMutex + ttl time.Duration +} + +// NewValidationCache creates a new validation cache +func NewValidationCache(ttl time.Duration) *ValidationCache { + vc := &ValidationCache{ + cache: make(map[string]*ValidationResult), + ttl: ttl, + } + + // Start cleanup goroutine + go vc.cleanupExpired() + + return vc +} + +// Get retrieves a validation result from cache +// Returns (allowed bool, cached bool, reason string) +func (vc *ValidationCache) Get(credHash, packageURL string) (bool, bool, string) { + vc.mu.RLock() + defer vc.mu.RUnlock() + + key := credHash + ":" + packageURL + result, exists := vc.cache[key] + + if !exists { + return false, false, "" + } + + // Check if expired + if time.Now().After(result.ExpiresAt) { + return false, false, "" + } + + return result.Allowed, true, result.Reason +} + +// Set stores a validation result in cache +func (vc *ValidationCache) Set(credHash, packageURL string, allowed bool, reason string) { + vc.mu.Lock() + defer vc.mu.Unlock() + + key := credHash + ":" + packageURL + vc.cache[key] = &ValidationResult{ + Allowed: allowed, + ExpiresAt: time.Now().Add(vc.ttl), + Reason: reason, + } +} + +// Invalidate removes a specific entry from cache +func (vc *ValidationCache) Invalidate(credHash, packageURL string) { + vc.mu.Lock() + defer vc.mu.Unlock() + + key := credHash + ":" + packageURL + delete(vc.cache, key) +} + +// InvalidateAll clears the entire cache +func (vc *ValidationCache) InvalidateAll() { + vc.mu.Lock() + defer vc.mu.Unlock() + + vc.cache = make(map[string]*ValidationResult) +} + +// Size returns the number of cached entries +func (vc *ValidationCache) Size() int { + vc.mu.RLock() + defer vc.mu.RUnlock() + + return len(vc.cache) +} + +// cleanupExpired removes expired entries periodically +func (vc *ValidationCache) cleanupExpired() { + ticker := time.NewTicker(1 * time.Minute) + defer ticker.Stop() + + for range ticker.C { + vc.mu.Lock() + now := time.Now() + for key, result := range vc.cache { + if now.After(result.ExpiresAt) { + delete(vc.cache, key) + } + } + vc.mu.Unlock() + } +} diff --git a/pkg/auth/validator.go b/pkg/auth/validator.go new file mode 100644 index 0000000..071174a --- /dev/null +++ b/pkg/auth/validator.go @@ -0,0 +1,284 @@ +package auth + +import ( + "context" + "fmt" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/rs/zerolog/log" +) + +// CredentialValidator validates credentials with upstream registries +type CredentialValidator interface { + // ValidateAccess checks if credentials grant access to a package + // Returns (allowed bool, error) + ValidateAccess(ctx context.Context, packageURL string, credentials string) (bool, error) +} + +// NPMValidator validates npm registry credentials +type NPMValidator struct { + client *http.Client + timeout time.Duration +} + +// NewNPMValidator creates a new npm credential validator +func NewNPMValidator() *NPMValidator { + return &NPMValidator{ + client: &http.Client{ + Timeout: 5 * time.Second, + }, + timeout: 5 * time.Second, + } +} + +// ValidateAccess validates npm package access using HEAD request +func (v *NPMValidator) ValidateAccess(ctx context.Context, packageURL string, credentials string) (bool, error) { + req, err := http.NewRequestWithContext(ctx, "HEAD", packageURL, nil) + if err != nil { + return false, err + } + + // Add credentials if provided + if credentials != "" { + req.Header.Set("Authorization", credentials) + } + + resp, err := v.client.Do(req) + if err != nil { + // Network error - allow cache fallback with warning + log.Warn().Err(err).Str("url", packageURL).Msg("Validation request failed, allowing cache fallback") + return true, fmt.Errorf("validation failed: %w (allowing cache fallback)", err) + } + defer resp.Body.Close() // #nosec G104 -- Cleanup, error not critical + + // Check status code + switch resp.StatusCode { + case 200, 304: + // Access granted + return true, nil + case 401, 403, 404: + // Access denied + return false, fmt.Errorf("access denied: HTTP %d", resp.StatusCode) + default: + // Unexpected status - allow cache fallback with warning + log.Warn().Int("status", resp.StatusCode).Str("url", packageURL).Msg("Unexpected validation status, allowing cache fallback") + return true, fmt.Errorf("unexpected status %d (allowing cache fallback)", resp.StatusCode) + } +} + +// PyPIValidator validates PyPI registry credentials +type PyPIValidator struct { + client *http.Client + timeout time.Duration +} + +// NewPyPIValidator creates a new PyPI credential validator +func NewPyPIValidator() *PyPIValidator { + return &PyPIValidator{ + client: &http.Client{ + Timeout: 5 * time.Second, + }, + timeout: 5 * time.Second, + } +} + +// ValidateAccess validates PyPI package access using HEAD request +func (v *PyPIValidator) ValidateAccess(ctx context.Context, packageURL string, credentials string) (bool, error) { + req, err := http.NewRequestWithContext(ctx, "HEAD", packageURL, nil) + if err != nil { + return false, err + } + + // Add credentials if provided + if credentials != "" { + req.Header.Set("Authorization", credentials) + } + + resp, err := v.client.Do(req) + if err != nil { + // Network error - allow cache fallback with warning + log.Warn().Err(err).Str("url", packageURL).Msg("Validation request failed, allowing cache fallback") + return true, fmt.Errorf("validation failed: %w (allowing cache fallback)", err) + } + defer resp.Body.Close() // #nosec G104 -- Cleanup, error not critical + + // Check status code + switch resp.StatusCode { + case 200, 304: + // Access granted + return true, nil + case 401, 403, 404: + // Access denied + return false, fmt.Errorf("access denied: HTTP %d", resp.StatusCode) + default: + // Unexpected status - allow cache fallback with warning + log.Warn().Int("status", resp.StatusCode).Str("url", packageURL).Msg("Unexpected validation status, allowing cache fallback") + return true, fmt.Errorf("unexpected status %d (allowing cache fallback)", resp.StatusCode) + } +} + +// GoValidator validates Go module credentials +type GoValidator struct { + timeout time.Duration +} + +// NewGoValidator creates a new Go module credential validator +func NewGoValidator() *GoValidator { + return &GoValidator{ + timeout: 10 * time.Second, + } +} + +// ValidateAccess validates Go module access using git ls-remote +func (v *GoValidator) ValidateAccess(ctx context.Context, modulePath string, credentials string) (bool, error) { + // Create context with timeout + ctx, cancel := context.WithTimeout(ctx, v.timeout) + defer cancel() + + // Determine repository type and validate accordingly + if strings.HasPrefix(modulePath, "github.com/") { + return v.validateGitHub(ctx, modulePath, credentials) + } + + if strings.HasPrefix(modulePath, "gitlab.com/") { + return v.validateGitLab(ctx, modulePath, credentials) + } + + // For other Git providers, use generic git validation + return v.validateGit(ctx, modulePath, credentials) +} + +func (v *GoValidator) validateGitHub(ctx context.Context, modulePath, credentials string) (bool, error) { + // Extract token from credentials + token := strings.TrimPrefix(credentials, "Bearer ") + token = strings.TrimPrefix(token, "Token ") + + if token == "" || token == credentials { + // No token provided or not in expected format + return false, fmt.Errorf("no GitHub token provided") + } + + // Build git URL + repoURL := fmt.Sprintf("https://%s.git", modulePath) + + // Create temporary directory for .netrc + tempDir, err := os.MkdirTemp("", "gohoarder-validate-*") + if err != nil { + return false, err + } + defer os.RemoveAll(tempDir) + + // Create .netrc file with credentials + netrcPath := filepath.Join(tempDir, ".netrc") + netrcContent := fmt.Sprintf("machine github.com\nlogin oauth2\npassword %s\n", token) + if err := os.WriteFile(netrcPath, []byte(netrcContent), 0600); err != nil { + return false, err + } + + // Run git ls-remote (lightweight, just checks access) + cmd := exec.CommandContext(ctx, "git", "ls-remote", repoURL, "HEAD") // #nosec G204 -- git command with validated URL + cmd.Env = append(os.Environ(), + "HOME="+tempDir, // Use temp .netrc + "GIT_TERMINAL_PROMPT=0", // Disable prompts + ) + + output, err := cmd.CombinedOutput() + if err != nil { + // Check error message + errMsg := string(output) + if strings.Contains(errMsg, "could not read Username") || + strings.Contains(errMsg, "Authentication failed") || + strings.Contains(errMsg, "fatal: repository") || + strings.Contains(errMsg, "not found") { + // Access denied + return false, fmt.Errorf("access denied: %s", strings.TrimSpace(errMsg)) + } + + // Other error (network, etc.) - allow cache fallback + log.Warn().Err(err).Str("module", modulePath).Msg("Git validation failed, allowing cache fallback") + return true, fmt.Errorf("validation error (allowing cache): %w", err) + } + + // Success - repository accessible + return true, nil +} + +func (v *GoValidator) validateGitLab(ctx context.Context, modulePath, credentials string) (bool, error) { + // Extract token from credentials + token := strings.TrimPrefix(credentials, "Bearer ") + token = strings.TrimPrefix(token, "Token ") + token = strings.TrimPrefix(token, "Private-Token ") + + if token == "" || token == credentials { + // No token provided + return false, fmt.Errorf("no GitLab token provided") + } + + // Build git URL + repoURL := fmt.Sprintf("https://%s.git", modulePath) + + // Create temporary directory for .netrc + tempDir, err := os.MkdirTemp("", "gohoarder-validate-*") + if err != nil { + return false, err + } + defer os.RemoveAll(tempDir) + + // Create .netrc file with credentials + netrcPath := filepath.Join(tempDir, ".netrc") + netrcContent := fmt.Sprintf("machine gitlab.com\nlogin oauth2\npassword %s\n", token) + if err := os.WriteFile(netrcPath, []byte(netrcContent), 0600); err != nil { + return false, err + } + + // Run git ls-remote + cmd := exec.CommandContext(ctx, "git", "ls-remote", repoURL, "HEAD") // #nosec G204 -- git command with validated URL + cmd.Env = append(os.Environ(), + "HOME="+tempDir, + "GIT_TERMINAL_PROMPT=0", + ) + + output, err := cmd.CombinedOutput() + if err != nil { + errMsg := string(output) + if strings.Contains(errMsg, "could not read Username") || + strings.Contains(errMsg, "Authentication failed") || + strings.Contains(errMsg, "not found") { + return false, fmt.Errorf("access denied: %s", strings.TrimSpace(errMsg)) + } + + log.Warn().Err(err).Str("module", modulePath).Msg("Git validation failed, allowing cache fallback") + return true, fmt.Errorf("validation error (allowing cache): %w", err) + } + + return true, nil +} + +func (v *GoValidator) validateGit(ctx context.Context, modulePath, credentials string) (bool, error) { + // Generic git validation for other providers + // Similar to GitHub validation but with generic host detection + repoURL := fmt.Sprintf("https://%s.git", modulePath) + + cmd := exec.CommandContext(ctx, "git", "ls-remote", repoURL, "HEAD") // #nosec G204 -- git command with validated URL + cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") + + output, err := cmd.CombinedOutput() + if err != nil { + errMsg := string(output) + if strings.Contains(errMsg, "could not read Username") || + strings.Contains(errMsg, "Authentication failed") || + strings.Contains(errMsg, "not found") { + return false, fmt.Errorf("access denied: %s", strings.TrimSpace(errMsg)) + } + + log.Warn().Err(err).Str("module", modulePath).Msg("Git validation failed, allowing cache fallback") + return true, fmt.Errorf("validation error (allowing cache): %w", err) + } + + return true, nil +} diff --git a/pkg/cache/cache.go b/pkg/cache/cache.go new file mode 100644 index 0000000..c350813 --- /dev/null +++ b/pkg/cache/cache.go @@ -0,0 +1,572 @@ +package cache + +import ( + "bytes" + "context" + "crypto/sha256" + "fmt" + "io" + "os" + "path/filepath" + "sync" + "time" + + "github.com/lukaszraczylo/gohoarder/pkg/errors" + "github.com/lukaszraczylo/gohoarder/pkg/metadata" + "github.com/lukaszraczylo/gohoarder/pkg/metrics" + "github.com/lukaszraczylo/gohoarder/pkg/storage" + "github.com/lukaszraczylo/gohoarder/pkg/uuid" + "github.com/rs/zerolog/log" + "golang.org/x/sync/singleflight" +) + +// ScannerInterface defines the interface for security scanners +// Defined here to avoid circular dependency with scanner package +type ScannerInterface interface { + ScanPackage(ctx context.Context, registry, packageName, version string, filePath string) error + CheckVulnerabilities(ctx context.Context, registry, packageName, version string) (blocked bool, reason string, err error) +} + +// Manager coordinates caching operations between storage and metadata +type Manager struct { + storage storage.StorageBackend + metadata metadata.MetadataStore + scanner ScannerInterface + config Config + sf singleflight.Group + mu sync.RWMutex + evicting bool +} + +// Config holds cache manager configuration +type Config struct { + DefaultTTL time.Duration // Default TTL for cached packages + CleanupInterval time.Duration // How often to run cleanup + EvictionThreshold float64 // Trigger eviction when usage > threshold (0.0-1.0) + MaxConcurrent int // Max concurrent upstream fetches +} + +// CacheEntry represents a cached package +type CacheEntry struct { + Package *metadata.Package + Data io.ReadCloser + FromCache bool + UpstreamURL string + CacheControl string +} + +// New creates a new cache manager +func New(storage storage.StorageBackend, metadata metadata.MetadataStore, scanner ScannerInterface, config Config) (*Manager, error) { + if storage == nil { + return nil, errors.New(errors.ErrCodeInvalidConfig, "storage backend is required") + } + + if metadata == nil { + return nil, errors.New(errors.ErrCodeInvalidConfig, "metadata store is required") + } + + // Scanner is optional - can be nil if security scanning is disabled + if scanner != nil { + log.Info().Msg("Cache manager initialized with security scanning enabled") + } + + if config.DefaultTTL == 0 { + config.DefaultTTL = 7 * 24 * time.Hour // 7 days default + } + + if config.CleanupInterval == 0 { + config.CleanupInterval = 1 * time.Hour + } + + if config.EvictionThreshold == 0 { + config.EvictionThreshold = 0.9 // 90% full + } + + if config.MaxConcurrent == 0 { + config.MaxConcurrent = 100 + } + + manager := &Manager{ + storage: storage, + metadata: metadata, + scanner: scanner, + config: config, + } + + // Start background cleanup worker + go manager.cleanupWorker() + + return manager, nil +} + +// Get retrieves a package from cache or upstream +func (m *Manager) Get(ctx context.Context, registry, name, version string, fetchFunc func(context.Context) (io.ReadCloser, string, error)) (*CacheEntry, error) { + // Use singleflight to deduplicate concurrent requests + key := fmt.Sprintf("%s/%s/%s", registry, name, version) + + result, err, _ := m.sf.Do(key, func() (interface{}, error) { + return m.getOrFetch(ctx, registry, name, version, fetchFunc) + }) + + if err != nil { + return nil, err + } + + return result.(*CacheEntry), nil +} + +// getOrFetch implements the actual get-or-fetch logic +func (m *Manager) getOrFetch(ctx context.Context, registry, name, version string, fetchFunc func(context.Context) (io.ReadCloser, string, error)) (*CacheEntry, error) { + // Check metadata first + pkg, err := m.metadata.GetPackage(ctx, registry, name, version) + if err == nil { + // Package found in metadata, check if expired + if pkg.ExpiresAt != nil && time.Now().After(*pkg.ExpiresAt) { + log.Debug().Str("package", name).Str("version", version).Msg("Package expired, re-fetching") + metrics.RecordCacheEviction("ttl") + // Delete expired package + _ = m.deletePackage(ctx, pkg) // #nosec G104 -- Async cleanup + } else { + // Try to get from storage + data, err := m.storage.Get(ctx, pkg.StorageKey) + if err == nil { + // Cache hit! + metrics.RecordCacheHit(registry) + _ = m.metadata.UpdateDownloadCount(ctx, registry, name, version) // #nosec G104 -- Async update, error logged + + // Check for vulnerabilities if scanner is enabled + if m.scanner != nil { + blocked, reason, err := m.scanner.CheckVulnerabilities(ctx, registry, name, version) + if err != nil { + log.Warn().Err(err).Str("package", name).Msg("Failed to check vulnerabilities") + } + if blocked { + metrics.RecordCacheHit(registry) // Record as blocked + _ = data.Close() // #nosec G104 // Close the data reader + return nil, errors.New(errors.ErrCodeSecurityViolation, reason) + } + } + + return &CacheEntry{ + Package: pkg, + Data: data, + FromCache: true, + }, nil + } + + // Storage miss but metadata exists - inconsistency, clean up + log.Warn().Str("package", name).Str("version", version).Msg("Metadata exists but storage missing") + _ = m.metadata.DeletePackage(ctx, registry, name, version) // #nosec G104 -- Cleanup, error logged + } + } + + // Cache miss - fetch from upstream + metrics.RecordCacheMiss(registry) + + if fetchFunc == nil { + return nil, errors.NotFound(fmt.Sprintf("package not found and no fetch function provided: %s/%s@%s", registry, name, version)) + } + + log.Debug().Str("package", name).Str("version", version).Msg("Fetching from upstream") + + // Fetch from upstream + data, upstreamURL, err := fetchFunc(ctx) + if err != nil { + metrics.RecordUpstreamRequest(registry, "error") + return nil, errors.Wrap(err, errors.ErrCodeUpstreamFailure, "failed to fetch from upstream") + } + defer data.Close() // #nosec G104 -- Cleanup, error not critical + + metrics.RecordUpstreamRequest(registry, "success") + + // Store in cache (this will also trigger background scan) + storedPkg, err := m.store(ctx, registry, name, version, data, upstreamURL) + if err != nil { + return nil, err + } + + // Wait briefly for initial scan to complete if scanner is enabled + // This prevents serving vulnerable packages on first request + if m.scanner != nil { + // Wait up to 30 seconds for scan to complete + scanCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-scanCtx.Done(): + // Timeout or context cancelled - proceed anyway + // Package is cached, will be blocked on next request if vulnerable + log.Warn(). + Str("package", name). + Str("version", version). + Msg("Scan timeout - allowing first download, will block on subsequent requests if vulnerable") + goto servePkg + + case <-ticker.C: + // First check if scan has completed by checking the SecurityScanned flag + // This prevents race condition where CheckVulnerabilities() returns "clean" + // before all scanners have finished + pkg, err := m.metadata.GetPackage(scanCtx, registry, name, version) + if err != nil { + // Failed to get package metadata - continue waiting + log.Debug(). + Str("package", name). + Str("version", version). + Err(err). + Msg("Failed to get package metadata, waiting...") + continue + } + + if !pkg.SecurityScanned { + // Scan still in progress - continue waiting + log.Debug(). + Str("package", name). + Str("version", version). + Msg("Scan in progress, waiting...") + continue + } + + // Scan completed - now check if package should be blocked + blocked, reason, err := m.scanner.CheckVulnerabilities(scanCtx, registry, name, version) + if err != nil { + // Unexpected error after scan complete - log and continue waiting + log.Warn(). + Str("package", name). + Str("version", version). + Err(err). + Msg("Error checking vulnerabilities, waiting...") + continue + } + + // Scan completed - check if blocked + if blocked { + log.Info(). + Str("package", name). + Str("version", version). + Str("reason", reason). + Msg("Package cached but blocked due to vulnerabilities") + return nil, errors.New(errors.ErrCodeSecurityViolation, reason) + } + + // Package is clean - proceed to serve + log.Info(). + Str("package", name). + Str("version", version). + Msg("Scan completed, package is clean") + goto servePkg + } + } + } + +servePkg: + // Re-open from storage for consistency + storedData, err := m.storage.Get(ctx, storedPkg.StorageKey) + if err != nil { + return nil, errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to retrieve just-stored package") + } + + return &CacheEntry{ + Package: storedPkg, + Data: storedData, + FromCache: false, + UpstreamURL: upstreamURL, + }, nil +} + +// store stores a package in cache +func (m *Manager) store(ctx context.Context, registry, name, version string, data io.ReadCloser, upstreamURL string) (*metadata.Package, error) { + // Generate storage key + storageKey := m.generateStorageKey(registry, name, version) + + // Calculate checksums while storing + // We need to read the data, calculate checksums, and store it + // This requires buffering the data + var buf []byte + var err error + + // Read all data + buf, err = io.ReadAll(data) + if err != nil { + return nil, errors.Wrap(err, errors.ErrCodeUpstreamFailure, "failed to read upstream data") + } + + // Calculate checksums + h := sha256.New() + h.Write(buf) + checksumSHA256 := fmt.Sprintf("%x", h.Sum(nil)) + + size := int64(len(buf)) + + // Check quota before storing + quota, err := m.storage.GetQuota(ctx) + if err == nil && quota.Limit > 0 { + if quota.Used+size > quota.Limit { + // Trigger eviction + if err := m.evict(ctx, size); err != nil { + return nil, errors.QuotaExceeded(quota.Limit) + } + } + } + + // Store in storage backend + opts := &storage.PutOptions{ + ChecksumSHA256: checksumSHA256, + } + + err = m.storage.Put(ctx, storageKey, io.NopCloser(bytes.NewReader(buf)), opts) + if err != nil { + return nil, err + } + + // Create metadata entry + now := time.Now() + expiresAt := now.Add(m.config.DefaultTTL) + + pkg := &metadata.Package{ + ID: uuid.New().String(), + Registry: registry, + Name: name, + Version: version, + StorageKey: storageKey, + Size: size, + ChecksumSHA256: checksumSHA256, + UpstreamURL: upstreamURL, + CachedAt: now, + LastAccessed: now, + ExpiresAt: &expiresAt, + DownloadCount: 0, + Metadata: make(map[string]string), + } + + // Save metadata + if err := m.metadata.SavePackage(ctx, pkg); err != nil { + // Clean up storage if metadata save fails + _ = m.storage.Delete(ctx, storageKey) // #nosec G104 -- Cleanup, error logged + return nil, err + } + + // Scan package if scanner is enabled (run in background to not block cache operations) + if m.scanner != nil { + go func() { + scanCtx := context.Background() + var filePath string + var cleanupFunc func() + + // Check if storage backend supports local paths + if localProvider, ok := m.storage.(interface { + GetLocalPath(ctx context.Context, key string) (string, error) + }); ok { + // Use direct file path from storage (avoid double download) + path, err := localProvider.GetLocalPath(scanCtx, storageKey) + if err != nil { + log.Error().Err(err).Str("package", name).Msg("Failed to get local path for scanning") + return + } + filePath = path + cleanupFunc = func() {} // No cleanup needed for direct path + log.Debug().Str("package", name).Str("path", filePath).Msg("Scanning package from storage path") + } else { + // Fallback: Create temp file for remote storage (S3, SMB, etc.) + tempFilePath := filepath.Join(os.TempDir(), storageKey) + + // Create parent directories if they don't exist + if err := os.MkdirAll(filepath.Dir(tempFilePath), 0750); err != nil { + log.Error().Err(err).Str("package", name).Msg("Failed to create temp directory for scanning") + return + } + + tempFile, err := os.Create(tempFilePath) // #nosec G304 -- Temp file path is constructed from validated package name + if err != nil { + log.Error().Err(err).Str("package", name).Msg("Failed to create temp file for scanning") + return + } + + // Write package data to temp file + if _, err := tempFile.Write(buf); err != nil { + tempFile.Close() // #nosec G104 -- Cleanup, error not critical + _ = os.Remove(tempFilePath) // #nosec G104 -- Cleanup, error not critical + log.Error().Err(err).Str("package", name).Msg("Failed to write temp file for scanning") + return + } + tempFile.Close() // #nosec G104 -- Cleanup, error not critical + + filePath = tempFilePath + cleanupFunc = func() { _ = os.Remove(tempFilePath) } // #nosec G104 -- Cleanup + log.Debug().Str("package", name).Str("path", filePath).Msg("Scanning package from temp file") + } + + defer cleanupFunc() + + // Scan package + if err := m.scanner.ScanPackage(scanCtx, registry, name, version, filePath); err != nil { + log.Error().Err(err).Str("package", name).Msg("Failed to scan package") + } + }() + } + + return pkg, nil +} + +// Delete removes a package from cache +func (m *Manager) Delete(ctx context.Context, registry, name, version string) error { + pkg, err := m.metadata.GetPackage(ctx, registry, name, version) + if err != nil { + return err + } + + return m.deletePackage(ctx, pkg) +} + +// deletePackage deletes a package from both storage and metadata +func (m *Manager) deletePackage(ctx context.Context, pkg *metadata.Package) error { + // Delete from storage + if err := m.storage.Delete(ctx, pkg.StorageKey); err != nil { + log.Warn().Err(err).Str("key", pkg.StorageKey).Msg("Failed to delete from storage") + } + + // Delete from metadata + return m.metadata.DeletePackage(ctx, pkg.Registry, pkg.Name, pkg.Version) +} + +// evict implements LRU eviction +func (m *Manager) evict(ctx context.Context, needed int64) error { + m.mu.Lock() + if m.evicting { + m.mu.Unlock() + return errors.New(errors.ErrCodeStorageFailure, "eviction already in progress") + } + m.evicting = true + m.mu.Unlock() + + defer func() { + m.mu.Lock() + m.evicting = false + m.mu.Unlock() + }() + + log.Info().Int64("needed", needed).Msg("Starting LRU eviction") + + // List packages sorted by last accessed (oldest first) + opts := &metadata.ListOptions{ + SortBy: "last_accessed", + SortDesc: false, + Limit: 100, + } + + var freed int64 + for freed < needed { + packages, err := m.metadata.ListPackages(ctx, opts) + if err != nil || len(packages) == 0 { + break + } + + for _, pkg := range packages { + if err := m.deletePackage(ctx, pkg); err != nil { + log.Warn().Err(err).Str("package", pkg.Name).Msg("Failed to evict package") + continue + } + + freed += pkg.Size + metrics.RecordCacheEviction("lru") + + if freed >= needed { + break + } + } + + if len(packages) < opts.Limit { + break // No more packages + } + } + + log.Info().Int64("freed", freed).Msg("Eviction completed") + return nil +} + +// cleanupWorker runs periodic cleanup of expired packages +func (m *Manager) cleanupWorker() { + ticker := time.NewTicker(m.config.CleanupInterval) + defer ticker.Stop() + + for range ticker.C { + ctx := context.Background() + m.cleanup(ctx) + } +} + +// cleanup removes expired packages +func (m *Manager) cleanup(ctx context.Context) { + log.Debug().Msg("Starting cleanup worker") + + // List all packages + packages, err := m.metadata.ListPackages(ctx, &metadata.ListOptions{}) + if err != nil { + log.Error().Err(err).Msg("Failed to list packages for cleanup") + return + } + + now := time.Now() + var cleaned int + + for _, pkg := range packages { + if pkg.ExpiresAt != nil && now.After(*pkg.ExpiresAt) { + if err := m.deletePackage(ctx, pkg); err != nil { + log.Warn().Err(err).Str("package", pkg.Name).Msg("Failed to clean up expired package") + continue + } + cleaned++ + } + } + + if cleaned > 0 { + log.Info().Int("count", cleaned).Msg("Cleanup completed") + } +} + +// generateStorageKey generates a storage key for a package +func (m *Manager) generateStorageKey(registry, name, version string) string { + return fmt.Sprintf("%s/%s/%s", registry, name, version) +} + +// GetStats returns cache statistics +func (m *Manager) GetStats(ctx context.Context, registry string) (*metadata.Stats, error) { + return m.metadata.GetStats(ctx, registry) +} + +// Health checks cache manager health +func (m *Manager) Health(ctx context.Context) error { + // Check storage health + if err := m.storage.Health(ctx); err != nil { + return errors.Wrap(err, errors.ErrCodeStorageFailure, "storage health check failed") + } + + // Check metadata health + if err := m.metadata.Health(ctx); err != nil { + return errors.Wrap(err, errors.ErrCodeDatabaseFailure, "metadata health check failed") + } + + return nil +} + +// Close closes the cache manager +func (m *Manager) Close() error { + var err error + + if closeErr := m.storage.Close(); closeErr != nil { + err = closeErr + } + + if closeErr := m.metadata.Close(); closeErr != nil { + if err != nil { + err = fmt.Errorf("%w; %w", err, closeErr) + } else { + err = closeErr + } + } + + return err +} diff --git a/pkg/cache/cache_test.go b/pkg/cache/cache_test.go new file mode 100644 index 0000000..67ef6b8 --- /dev/null +++ b/pkg/cache/cache_test.go @@ -0,0 +1,980 @@ +package cache + +import ( + "bytes" + "context" + "errors" + "io" + "strings" + "testing" + "time" + + "github.com/lukaszraczylo/gohoarder/pkg/metadata" + "github.com/lukaszraczylo/gohoarder/pkg/storage" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// MockStorageBackend is a mock for storage.StorageBackend +type MockStorageBackend struct { + mock.Mock +} + +func (m *MockStorageBackend) Get(ctx context.Context, key string) (io.ReadCloser, error) { + args := m.Called(ctx, key) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(io.ReadCloser), args.Error(1) +} + +func (m *MockStorageBackend) Put(ctx context.Context, key string, data io.Reader, opts *storage.PutOptions) error { + args := m.Called(ctx, key, data, opts) + return args.Error(0) +} + +func (m *MockStorageBackend) Delete(ctx context.Context, key string) error { + args := m.Called(ctx, key) + return args.Error(0) +} + +func (m *MockStorageBackend) Exists(ctx context.Context, key string) (bool, error) { + args := m.Called(ctx, key) + return args.Bool(0), args.Error(1) +} + +func (m *MockStorageBackend) List(ctx context.Context, prefix string, opts *storage.ListOptions) ([]storage.StorageObject, error) { + args := m.Called(ctx, prefix, opts) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]storage.StorageObject), args.Error(1) +} + +func (m *MockStorageBackend) Stat(ctx context.Context, key string) (*storage.StorageInfo, error) { + args := m.Called(ctx, key) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*storage.StorageInfo), args.Error(1) +} + +func (m *MockStorageBackend) GetQuota(ctx context.Context) (*storage.QuotaInfo, error) { + args := m.Called(ctx) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*storage.QuotaInfo), args.Error(1) +} + +func (m *MockStorageBackend) Health(ctx context.Context) error { + args := m.Called(ctx) + return args.Error(0) +} + +func (m *MockStorageBackend) Close() error { + args := m.Called() + return args.Error(0) +} + +// MockMetadataStore is a mock for metadata.MetadataStore +type MockMetadataStore struct { + mock.Mock +} + +func (m *MockMetadataStore) SavePackage(ctx context.Context, pkg *metadata.Package) error { + args := m.Called(ctx, pkg) + return args.Error(0) +} + +func (m *MockMetadataStore) GetPackage(ctx context.Context, registry, name, version string) (*metadata.Package, error) { + args := m.Called(ctx, registry, name, version) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*metadata.Package), args.Error(1) +} + +func (m *MockMetadataStore) DeletePackage(ctx context.Context, registry, name, version string) error { + args := m.Called(ctx, registry, name, version) + return args.Error(0) +} + +func (m *MockMetadataStore) ListPackages(ctx context.Context, opts *metadata.ListOptions) ([]*metadata.Package, error) { + args := m.Called(ctx, opts) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]*metadata.Package), args.Error(1) +} + +func (m *MockMetadataStore) UpdateDownloadCount(ctx context.Context, registry, name, version string) error { + args := m.Called(ctx, registry, name, version) + return args.Error(0) +} + +func (m *MockMetadataStore) GetStats(ctx context.Context, registry string) (*metadata.Stats, error) { + args := m.Called(ctx, registry) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*metadata.Stats), args.Error(1) +} + +func (m *MockMetadataStore) SaveScanResult(ctx context.Context, result *metadata.ScanResult) error { + args := m.Called(ctx, result) + return args.Error(0) +} + +func (m *MockMetadataStore) GetScanResult(ctx context.Context, registry, name, version string) (*metadata.ScanResult, error) { + args := m.Called(ctx, registry, name, version) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*metadata.ScanResult), args.Error(1) +} + +func (m *MockMetadataStore) Count(ctx context.Context) (int, error) { + args := m.Called(ctx) + return args.Int(0), args.Error(1) +} + +func (m *MockMetadataStore) Health(ctx context.Context) error { + args := m.Called(ctx) + return args.Error(0) +} + +func (m *MockMetadataStore) Close() error { + args := m.Called() + return args.Error(0) +} + +func (m *MockMetadataStore) SaveCVEBypass(ctx context.Context, bypass *metadata.CVEBypass) error { + args := m.Called(ctx, bypass) + return args.Error(0) +} + +func (m *MockMetadataStore) GetActiveCVEBypasses(ctx context.Context) ([]*metadata.CVEBypass, error) { + args := m.Called(ctx) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]*metadata.CVEBypass), args.Error(1) +} + +func (m *MockMetadataStore) ListCVEBypasses(ctx context.Context, opts *metadata.BypassListOptions) ([]*metadata.CVEBypass, error) { + args := m.Called(ctx, opts) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]*metadata.CVEBypass), args.Error(1) +} + +func (m *MockMetadataStore) DeleteCVEBypass(ctx context.Context, id string) error { + args := m.Called(ctx, id) + return args.Error(0) +} + +func (m *MockMetadataStore) CleanupExpiredBypasses(ctx context.Context) (int, error) { + args := m.Called(ctx) + return args.Int(0), args.Error(1) +} + +func (m *MockMetadataStore) GetTimeSeriesStats(ctx context.Context, period string, registry string) (*metadata.TimeSeriesStats, error) { + args := m.Called(ctx, period, registry) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*metadata.TimeSeriesStats), args.Error(1) +} + +func (m *MockMetadataStore) AggregateDownloadData(ctx context.Context) error { + args := m.Called(ctx) + return args.Error(0) +} + +// TestNew tests cache manager creation +func TestNew(t *testing.T) { + tests := []struct { + name string + storage storage.StorageBackend + metadata metadata.MetadataStore + config Config + wantErr bool + errContains string + }{ + // GOOD: Valid configuration + { + name: "valid config with defaults", + storage: &MockStorageBackend{}, + metadata: &MockMetadataStore{}, + config: Config{}, + wantErr: false, + }, + { + name: "valid config with custom settings", + storage: &MockStorageBackend{}, + metadata: &MockMetadataStore{}, + config: Config{ + DefaultTTL: 24 * time.Hour, + CleanupInterval: 30 * time.Minute, + EvictionThreshold: 0.8, + MaxConcurrent: 50, + }, + wantErr: false, + }, + // WRONG: Missing required components + { + name: "nil storage", + storage: nil, + metadata: &MockMetadataStore{}, + config: Config{}, + wantErr: true, + errContains: "storage backend is required", + }, + { + name: "nil metadata", + storage: &MockStorageBackend{}, + metadata: nil, + config: Config{}, + wantErr: true, + errContains: "metadata store is required", + }, + // EDGE: Both nil + { + name: "both nil", + storage: nil, + metadata: nil, + config: Config{}, + wantErr: true, + errContains: "storage backend is required", + }, + // EDGE: Zero values get defaults + { + name: "zero config gets defaults", + storage: &MockStorageBackend{}, + metadata: &MockMetadataStore{}, + config: Config{}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + manager, err := New(tt.storage, tt.metadata, nil, tt.config) + + if tt.wantErr { + require.Error(t, err) + if tt.errContains != "" { + assert.Contains(t, err.Error(), tt.errContains) + } + assert.Nil(t, manager) + } else { + require.NoError(t, err) + require.NotNil(t, manager) + + // Verify defaults were set + if tt.config.DefaultTTL == 0 { + assert.Equal(t, 7*24*time.Hour, manager.config.DefaultTTL) + } + if tt.config.CleanupInterval == 0 { + assert.Equal(t, 1*time.Hour, manager.config.CleanupInterval) + } + if tt.config.EvictionThreshold == 0 { + assert.Equal(t, 0.9, manager.config.EvictionThreshold) + } + if tt.config.MaxConcurrent == 0 { + assert.Equal(t, 100, manager.config.MaxConcurrent) + } + } + }) + } +} + +// TestGet tests cache retrieval with various scenarios +func TestGet(t *testing.T) { + tests := []struct { + name string + registry string + packageName string + version string + setupMock func(*MockStorageBackend, *MockMetadataStore) + fetchFunc func(context.Context) (io.ReadCloser, string, error) + wantFromCache bool + wantErr bool + errContains string + }{ + // GOOD: Cache hit + { + name: "cache hit - package exists and valid", + registry: "npm", + packageName: "react", + version: "18.2.0", + setupMock: func(s *MockStorageBackend, m *MockMetadataStore) { + now := time.Now() + expiresAt := now.Add(24 * time.Hour) + pkg := &metadata.Package{ + ID: "test-id", + Registry: "npm", + Name: "react", + Version: "18.2.0", + StorageKey: "npm/react/18.2.0", + CachedAt: now, + LastAccessed: now, + ExpiresAt: &expiresAt, + } + m.On("GetPackage", mock.Anything, "npm", "react", "18.2.0").Return(pkg, nil) + s.On("Get", mock.Anything, "npm/react/18.2.0").Return(io.NopCloser(strings.NewReader("cached data")), nil) + m.On("UpdateDownloadCount", mock.Anything, "npm", "react", "18.2.0").Return(nil) + }, + wantFromCache: true, + wantErr: false, + }, + // GOOD: Cache miss - fetch from upstream + { + name: "cache miss - fetch from upstream", + registry: "npm", + packageName: "lodash", + version: "4.17.21", + setupMock: func(s *MockStorageBackend, m *MockMetadataStore) { + m.On("GetPackage", mock.Anything, "npm", "lodash", "4.17.21").Return(nil, errors.New("not found")) + s.On("GetQuota", mock.Anything).Return(&storage.QuotaInfo{Used: 100, Available: 900, Limit: 1000}, nil) + s.On("Put", mock.Anything, "npm/lodash/4.17.21", mock.Anything, mock.Anything).Return(nil) + m.On("SavePackage", mock.Anything, mock.Anything).Return(nil) + s.On("Get", mock.Anything, "npm/lodash/4.17.21").Return(io.NopCloser(strings.NewReader("upstream data")), nil) + }, + fetchFunc: func(ctx context.Context) (io.ReadCloser, string, error) { + return io.NopCloser(strings.NewReader("upstream data")), "https://registry.npmjs.org/lodash", nil + }, + wantFromCache: false, + wantErr: false, + }, + // WRONG: Expired package + { + name: "expired package - re-fetch", + registry: "npm", + packageName: "expired-pkg", + version: "1.0.0", + setupMock: func(s *MockStorageBackend, m *MockMetadataStore) { + now := time.Now() + expiresAt := now.Add(-1 * time.Hour) // Expired 1 hour ago + pkg := &metadata.Package{ + ID: "test-id", + Registry: "npm", + Name: "expired-pkg", + Version: "1.0.0", + StorageKey: "npm/expired-pkg/1.0.0", + ExpiresAt: &expiresAt, + } + m.On("GetPackage", mock.Anything, "npm", "expired-pkg", "1.0.0").Return(pkg, nil) + m.On("DeletePackage", mock.Anything, "npm", "expired-pkg", "1.0.0").Return(nil) + s.On("Delete", mock.Anything, "npm/expired-pkg/1.0.0").Return(nil) + s.On("GetQuota", mock.Anything).Return(&storage.QuotaInfo{Used: 100, Available: 900, Limit: 1000}, nil) + s.On("Put", mock.Anything, "npm/expired-pkg/1.0.0", mock.Anything, mock.Anything).Return(nil) + m.On("SavePackage", mock.Anything, mock.Anything).Return(nil) + s.On("Get", mock.Anything, "npm/expired-pkg/1.0.0").Return(io.NopCloser(strings.NewReader("refreshed data")), nil) + }, + fetchFunc: func(ctx context.Context) (io.ReadCloser, string, error) { + return io.NopCloser(strings.NewReader("refreshed data")), "https://registry.npmjs.org/expired-pkg", nil + }, + wantFromCache: false, + wantErr: false, + }, + // BAD: Fetch function is nil and package not cached + { + name: "nil fetch function and not cached", + registry: "npm", + packageName: "missing", + version: "1.0.0", + setupMock: func(s *MockStorageBackend, m *MockMetadataStore) { + m.On("GetPackage", mock.Anything, "npm", "missing", "1.0.0").Return(nil, errors.New("not found")) + }, + fetchFunc: nil, + wantErr: true, + errContains: "package not found and no fetch function provided", + }, + // BAD: Upstream fetch fails + { + name: "upstream fetch error", + registry: "npm", + packageName: "fail-pkg", + version: "1.0.0", + setupMock: func(s *MockStorageBackend, m *MockMetadataStore) { + m.On("GetPackage", mock.Anything, "npm", "fail-pkg", "1.0.0").Return(nil, errors.New("not found")) + }, + fetchFunc: func(ctx context.Context) (io.ReadCloser, string, error) { + return nil, "", errors.New("upstream error") + }, + wantErr: true, + errContains: "failed to fetch from upstream", + }, + // EDGE: Metadata exists but storage missing + { + name: "metadata exists but storage missing - inconsistency", + registry: "npm", + packageName: "inconsistent", + version: "1.0.0", + setupMock: func(s *MockStorageBackend, m *MockMetadataStore) { + now := time.Now() + expiresAt := now.Add(24 * time.Hour) + pkg := &metadata.Package{ + ID: "test-id", + Registry: "npm", + Name: "inconsistent", + Version: "1.0.0", + StorageKey: "npm/inconsistent/1.0.0", + ExpiresAt: &expiresAt, + } + m.On("GetPackage", mock.Anything, "npm", "inconsistent", "1.0.0").Return(pkg, nil) + // First Get fails (storage missing) + s.On("Get", mock.Anything, "npm/inconsistent/1.0.0").Return(nil, errors.New("not found")).Once() + m.On("DeletePackage", mock.Anything, "npm", "inconsistent", "1.0.0").Return(nil) + s.On("GetQuota", mock.Anything).Return(&storage.QuotaInfo{Used: 100, Available: 900, Limit: 1000}, nil) + s.On("Put", mock.Anything, "npm/inconsistent/1.0.0", mock.Anything, mock.Anything).Return(nil) + m.On("SavePackage", mock.Anything, mock.Anything).Return(nil) + // Second Get succeeds (after re-storing) + s.On("Get", mock.Anything, "npm/inconsistent/1.0.0").Return(io.NopCloser(strings.NewReader("recovered data")), nil).Once() + }, + fetchFunc: func(ctx context.Context) (io.ReadCloser, string, error) { + return io.NopCloser(strings.NewReader("recovered data")), "https://registry.npmjs.org/inconsistent", nil + }, + wantFromCache: false, + wantErr: false, + }, + // EDGE: Storage save fails + { + name: "storage save fails", + registry: "npm", + packageName: "save-fail", + version: "1.0.0", + setupMock: func(s *MockStorageBackend, m *MockMetadataStore) { + m.On("GetPackage", mock.Anything, "npm", "save-fail", "1.0.0").Return(nil, errors.New("not found")) + s.On("GetQuota", mock.Anything).Return(&storage.QuotaInfo{Used: 100, Available: 900, Limit: 1000}, nil) + s.On("Put", mock.Anything, "npm/save-fail/1.0.0", mock.Anything, mock.Anything).Return(errors.New("storage error")) + }, + fetchFunc: func(ctx context.Context) (io.ReadCloser, string, error) { + return io.NopCloser(strings.NewReader("data")), "https://registry.npmjs.org/save-fail", nil + }, + wantErr: true, + errContains: "storage error", + }, + // EDGE: Metadata save fails (should cleanup storage) + { + name: "metadata save fails - storage cleanup", + registry: "npm", + packageName: "meta-fail", + version: "1.0.0", + setupMock: func(s *MockStorageBackend, m *MockMetadataStore) { + m.On("GetPackage", mock.Anything, "npm", "meta-fail", "1.0.0").Return(nil, errors.New("not found")) + s.On("GetQuota", mock.Anything).Return(&storage.QuotaInfo{Used: 100, Available: 900, Limit: 1000}, nil) + s.On("Put", mock.Anything, "npm/meta-fail/1.0.0", mock.Anything, mock.Anything).Return(nil) + m.On("SavePackage", mock.Anything, mock.Anything).Return(errors.New("metadata error")) + s.On("Delete", mock.Anything, "npm/meta-fail/1.0.0").Return(nil) + }, + fetchFunc: func(ctx context.Context) (io.ReadCloser, string, error) { + return io.NopCloser(strings.NewReader("data")), "https://registry.npmjs.org/meta-fail", nil + }, + wantErr: true, + errContains: "metadata error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockStorage := &MockStorageBackend{} + mockMetadata := &MockMetadataStore{} + + if tt.setupMock != nil { + tt.setupMock(mockStorage, mockMetadata) + } + + manager, err := New(mockStorage, mockMetadata, nil, Config{ + DefaultTTL: 24 * time.Hour, + CleanupInterval: 1 * time.Hour, + }) + require.NoError(t, err) + + ctx := context.Background() + entry, err := manager.Get(ctx, tt.registry, tt.packageName, tt.version, tt.fetchFunc) + + if tt.wantErr { + require.Error(t, err) + if tt.errContains != "" { + assert.Contains(t, err.Error(), tt.errContains) + } + assert.Nil(t, entry) + } else { + require.NoError(t, err) + require.NotNil(t, entry) + assert.Equal(t, tt.wantFromCache, entry.FromCache) + assert.NotNil(t, entry.Data) + // Read and verify data exists + data, _ := io.ReadAll(entry.Data) + assert.NotEmpty(t, data) + } + + mockStorage.AssertExpectations(t) + mockMetadata.AssertExpectations(t) + }) + } +} + +// TestDelete tests package deletion +func TestDelete(t *testing.T) { + tests := []struct { + name string + registry string + packageName string + version string + setupMock func(*MockStorageBackend, *MockMetadataStore) + wantErr bool + errContains string + }{ + // GOOD: Successful deletion + { + name: "successful deletion", + registry: "npm", + packageName: "react", + version: "18.2.0", + setupMock: func(s *MockStorageBackend, m *MockMetadataStore) { + pkg := &metadata.Package{ + ID: "test-id", + Registry: "npm", + Name: "react", + Version: "18.2.0", + StorageKey: "npm/react/18.2.0", + } + m.On("GetPackage", mock.Anything, "npm", "react", "18.2.0").Return(pkg, nil) + s.On("Delete", mock.Anything, "npm/react/18.2.0").Return(nil) + m.On("DeletePackage", mock.Anything, "npm", "react", "18.2.0").Return(nil) + }, + wantErr: false, + }, + // WRONG: Package not found + { + name: "package not found", + registry: "npm", + packageName: "missing", + version: "1.0.0", + setupMock: func(s *MockStorageBackend, m *MockMetadataStore) { + m.On("GetPackage", mock.Anything, "npm", "missing", "1.0.0").Return(nil, errors.New("not found")) + }, + wantErr: true, + errContains: "not found", + }, + // EDGE: Storage delete fails but metadata succeeds + { + name: "storage delete fails", + registry: "npm", + packageName: "storage-fail", + version: "1.0.0", + setupMock: func(s *MockStorageBackend, m *MockMetadataStore) { + pkg := &metadata.Package{ + ID: "test-id", + Registry: "npm", + Name: "storage-fail", + Version: "1.0.0", + StorageKey: "npm/storage-fail/1.0.0", + } + m.On("GetPackage", mock.Anything, "npm", "storage-fail", "1.0.0").Return(pkg, nil) + s.On("Delete", mock.Anything, "npm/storage-fail/1.0.0").Return(errors.New("storage error")) + m.On("DeletePackage", mock.Anything, "npm", "storage-fail", "1.0.0").Return(nil) + }, + wantErr: false, // Metadata delete still succeeds + }, + // EDGE: Metadata delete fails + { + name: "metadata delete fails", + registry: "npm", + packageName: "meta-fail", + version: "1.0.0", + setupMock: func(s *MockStorageBackend, m *MockMetadataStore) { + pkg := &metadata.Package{ + ID: "test-id", + Registry: "npm", + Name: "meta-fail", + Version: "1.0.0", + StorageKey: "npm/meta-fail/1.0.0", + } + m.On("GetPackage", mock.Anything, "npm", "meta-fail", "1.0.0").Return(pkg, nil) + s.On("Delete", mock.Anything, "npm/meta-fail/1.0.0").Return(nil) + m.On("DeletePackage", mock.Anything, "npm", "meta-fail", "1.0.0").Return(errors.New("metadata error")) + }, + wantErr: true, + errContains: "metadata error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockStorage := &MockStorageBackend{} + mockMetadata := &MockMetadataStore{} + + if tt.setupMock != nil { + tt.setupMock(mockStorage, mockMetadata) + } + + manager, err := New(mockStorage, mockMetadata, nil, Config{}) + require.NoError(t, err) + + ctx := context.Background() + err = manager.Delete(ctx, tt.registry, tt.packageName, tt.version) + + if tt.wantErr { + require.Error(t, err) + if tt.errContains != "" { + assert.Contains(t, err.Error(), tt.errContains) + } + } else { + require.NoError(t, err) + } + + mockStorage.AssertExpectations(t) + mockMetadata.AssertExpectations(t) + }) + } +} + +// TestHealth tests health check functionality +func TestHealth(t *testing.T) { + tests := []struct { + name string + setupMock func(*MockStorageBackend, *MockMetadataStore) + wantErr bool + errContains string + }{ + // GOOD: Both healthy + { + name: "both storage and metadata healthy", + setupMock: func(s *MockStorageBackend, m *MockMetadataStore) { + s.On("Health", mock.Anything).Return(nil) + m.On("Health", mock.Anything).Return(nil) + }, + wantErr: false, + }, + // WRONG: Storage unhealthy + { + name: "storage unhealthy", + setupMock: func(s *MockStorageBackend, m *MockMetadataStore) { + s.On("Health", mock.Anything).Return(errors.New("storage error")) + }, + wantErr: true, + errContains: "storage health check failed", + }, + // WRONG: Metadata unhealthy + { + name: "metadata unhealthy", + setupMock: func(s *MockStorageBackend, m *MockMetadataStore) { + s.On("Health", mock.Anything).Return(nil) + m.On("Health", mock.Anything).Return(errors.New("metadata error")) + }, + wantErr: true, + errContains: "metadata health check failed", + }, + // BAD: Both unhealthy + { + name: "both unhealthy", + setupMock: func(s *MockStorageBackend, m *MockMetadataStore) { + s.On("Health", mock.Anything).Return(errors.New("storage error")) + }, + wantErr: true, + errContains: "storage health check failed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockStorage := &MockStorageBackend{} + mockMetadata := &MockMetadataStore{} + + if tt.setupMock != nil { + tt.setupMock(mockStorage, mockMetadata) + } + + manager, err := New(mockStorage, mockMetadata, nil, Config{}) + require.NoError(t, err) + + ctx := context.Background() + err = manager.Health(ctx) + + if tt.wantErr { + require.Error(t, err) + if tt.errContains != "" { + assert.Contains(t, err.Error(), tt.errContains) + } + } else { + require.NoError(t, err) + } + + mockStorage.AssertExpectations(t) + mockMetadata.AssertExpectations(t) + }) + } +} + +// TestGetStats tests statistics retrieval +func TestGetStats(t *testing.T) { + mockStorage := &MockStorageBackend{} + mockMetadata := &MockMetadataStore{} + + expectedStats := &metadata.Stats{ + Registry: "npm", + TotalPackages: 100, + TotalSize: 1024 * 1024 * 100, + TotalDownloads: 5000, + } + + mockMetadata.On("GetStats", mock.Anything, "npm").Return(expectedStats, nil) + + manager, err := New(mockStorage, mockMetadata, nil, Config{}) + require.NoError(t, err) + + ctx := context.Background() + stats, err := manager.GetStats(ctx, "npm") + + require.NoError(t, err) + assert.Equal(t, expectedStats, stats) + mockMetadata.AssertExpectations(t) +} + +// TestClose tests manager cleanup +func TestClose(t *testing.T) { + tests := []struct { + name string + setupMock func(*MockStorageBackend, *MockMetadataStore) + wantErr bool + }{ + // GOOD: Clean close + { + name: "both close successfully", + setupMock: func(s *MockStorageBackend, m *MockMetadataStore) { + s.On("Close").Return(nil) + m.On("Close").Return(nil) + }, + wantErr: false, + }, + // WRONG: Storage close fails + { + name: "storage close fails", + setupMock: func(s *MockStorageBackend, m *MockMetadataStore) { + s.On("Close").Return(errors.New("storage error")) + m.On("Close").Return(nil) + }, + wantErr: true, + }, + // WRONG: Metadata close fails + { + name: "metadata close fails", + setupMock: func(s *MockStorageBackend, m *MockMetadataStore) { + s.On("Close").Return(nil) + m.On("Close").Return(errors.New("metadata error")) + }, + wantErr: true, + }, + // BAD: Both close fail + { + name: "both close fail", + setupMock: func(s *MockStorageBackend, m *MockMetadataStore) { + s.On("Close").Return(errors.New("storage error")) + m.On("Close").Return(errors.New("metadata error")) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockStorage := &MockStorageBackend{} + mockMetadata := &MockMetadataStore{} + + if tt.setupMock != nil { + tt.setupMock(mockStorage, mockMetadata) + } + + manager, err := New(mockStorage, mockMetadata, nil, Config{}) + require.NoError(t, err) + + err = manager.Close() // #nosec G104 -- Cleanup, error not critical + + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + + mockStorage.AssertExpectations(t) + mockMetadata.AssertExpectations(t) + }) + } +} + +// TestEvict tests LRU eviction +func TestEvict(t *testing.T) { + tests := []struct { + name string + needed int64 + setupMock func(*MockStorageBackend, *MockMetadataStore) + wantErr bool + errContains string + }{ + // GOOD: Successful eviction + { + name: "evict enough to free space", + needed: 200, + setupMock: func(s *MockStorageBackend, m *MockMetadataStore) { + packages := []*metadata.Package{ + { + ID: "1", + Name: "old-pkg-1", + Version: "1.0.0", + Registry: "npm", + StorageKey: "npm/old-pkg-1/1.0.0", + Size: 100, + }, + { + ID: "2", + Name: "old-pkg-2", + Version: "1.0.0", + Registry: "npm", + StorageKey: "npm/old-pkg-2/1.0.0", + Size: 150, + }, + } + m.On("ListPackages", mock.Anything, mock.MatchedBy(func(opts *metadata.ListOptions) bool { + return opts.SortBy == "last_accessed" && !opts.SortDesc + })).Return(packages, nil).Once() + + s.On("Delete", mock.Anything, "npm/old-pkg-1/1.0.0").Return(nil) + m.On("DeletePackage", mock.Anything, "npm", "old-pkg-1", "1.0.0").Return(nil) + s.On("Delete", mock.Anything, "npm/old-pkg-2/1.0.0").Return(nil) + m.On("DeletePackage", mock.Anything, "npm", "old-pkg-2", "1.0.0").Return(nil) + }, + wantErr: false, + }, + // EDGE: No packages to evict + { + name: "no packages available to evict", + needed: 100, + setupMock: func(s *MockStorageBackend, m *MockMetadataStore) { + m.On("ListPackages", mock.Anything, mock.Anything).Return([]*metadata.Package{}, nil) + }, + wantErr: false, // Doesn't error, just can't free enough + }, + // EDGE: Eviction list error + { + name: "list packages fails", + needed: 100, + setupMock: func(s *MockStorageBackend, m *MockMetadataStore) { + m.On("ListPackages", mock.Anything, mock.Anything).Return(nil, errors.New("list error")) + }, + wantErr: false, // Doesn't error, just can't complete + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockStorage := &MockStorageBackend{} + mockMetadata := &MockMetadataStore{} + + if tt.setupMock != nil { + tt.setupMock(mockStorage, mockMetadata) + } + + manager, err := New(mockStorage, mockMetadata, nil, Config{}) + require.NoError(t, err) + + ctx := context.Background() + err = manager.evict(ctx, tt.needed) + + if tt.wantErr { + require.Error(t, err) + if tt.errContains != "" { + assert.Contains(t, err.Error(), tt.errContains) + } + } else { + require.NoError(t, err) + } + + mockStorage.AssertExpectations(t) + mockMetadata.AssertExpectations(t) + }) + } +} + +// TestGenerateStorageKey tests storage key generation +func TestGenerateStorageKey(t *testing.T) { + mockStorage := &MockStorageBackend{} + mockMetadata := &MockMetadataStore{} + + manager, err := New(mockStorage, mockMetadata, nil, Config{}) + require.NoError(t, err) + + tests := []struct { + registry string + name string + version string + expected string + }{ + {"npm", "react", "18.2.0", "npm/react/18.2.0"}, + {"pypi", "requests", "2.28.0", "pypi/requests/2.28.0"}, + {"go", "github.com/gin-gonic/gin", "v1.9.0", "go/github.com/gin-gonic/gin/v1.9.0"}, + } + + for _, tt := range tests { + t.Run(tt.expected, func(t *testing.T) { + key := manager.generateStorageKey(tt.registry, tt.name, tt.version) + assert.Equal(t, tt.expected, key) + }) + } +} + +// TestConcurrentGet tests concurrent access doesn't cause data races +func TestConcurrentGet(t *testing.T) { + mockStorage := &MockStorageBackend{} + mockMetadata := &MockMetadataStore{} + + // Setup mocks for concurrent access + now := time.Now() + expiresAt := now.Add(24 * time.Hour) + pkg := &metadata.Package{ + ID: "test-id", + Registry: "npm", + Name: "concurrent", + Version: "1.0.0", + StorageKey: "npm/concurrent/1.0.0", + CachedAt: now, + LastAccessed: now, + ExpiresAt: &expiresAt, + } + + // Use Maybe() to allow variable number of calls due to singleflight deduplication + mockMetadata.On("GetPackage", mock.Anything, "npm", "concurrent", "1.0.0").Return(pkg, nil).Maybe() + mockStorage.On("Get", mock.Anything, "npm/concurrent/1.0.0").Return( + io.NopCloser(bytes.NewReader([]byte("test data"))), nil).Maybe() + mockMetadata.On("UpdateDownloadCount", mock.Anything, "npm", "concurrent", "1.0.0").Return(nil).Maybe() + + manager, err := New(mockStorage, mockMetadata, nil, Config{}) + require.NoError(t, err) + + ctx := context.Background() + const numGoroutines = 10 + + // Run concurrent gets + errs := make(chan error, numGoroutines) + for i := 0; i < numGoroutines; i++ { + go func() { + _, err := manager.Get(ctx, "npm", "concurrent", "1.0.0", nil) + errs <- err + }() + } + + // Collect results + for i := 0; i < numGoroutines; i++ { + err := <-errs + assert.NoError(t, err) + } + + // Verify at least one call was made (singleflight may deduplicate others) + mockMetadata.AssertCalled(t, "GetPackage", mock.Anything, "npm", "concurrent", "1.0.0") +} diff --git a/pkg/cdn/cdn.go b/pkg/cdn/cdn.go new file mode 100644 index 0000000..645d19b --- /dev/null +++ b/pkg/cdn/cdn.go @@ -0,0 +1,360 @@ +package cdn + +import ( + "crypto/md5" // #nosec G501 -- MD5 used for ETag generation, not cryptographic security + "encoding/hex" + "fmt" + "io" + "net/http" + "strconv" + "time" + + "github.com/rs/zerolog/log" +) + +// CacheControl represents cache control directives +type CacheControl struct { + MaxAge int // max-age in seconds + SMaxAge int // s-maxage in seconds (for shared caches) + Public bool // public directive + Private bool // private directive + NoCache bool // no-cache directive + NoStore bool // no-store directive + MustRevalidate bool // must-revalidate directive + ProxyRevalidate bool // proxy-revalidate directive + Immutable bool // immutable directive + StaleWhileRevalidate int // stale-while-revalidate in seconds +} + +// String returns the Cache-Control header value +func (cc CacheControl) String() string { + var parts []string + + if cc.Public { + parts = append(parts, "public") + } + if cc.Private { + parts = append(parts, "private") + } + if cc.NoCache { + parts = append(parts, "no-cache") + } + if cc.NoStore { + parts = append(parts, "no-store") + } + if cc.MustRevalidate { + parts = append(parts, "must-revalidate") + } + if cc.ProxyRevalidate { + parts = append(parts, "proxy-revalidate") + } + if cc.Immutable { + parts = append(parts, "immutable") + } + if cc.MaxAge > 0 { + parts = append(parts, fmt.Sprintf("max-age=%d", cc.MaxAge)) + } + if cc.SMaxAge > 0 { + parts = append(parts, fmt.Sprintf("s-maxage=%d", cc.SMaxAge)) + } + if cc.StaleWhileRevalidate > 0 { + parts = append(parts, fmt.Sprintf("stale-while-revalidate=%d", cc.StaleWhileRevalidate)) + } + + result := "" + for i, part := range parts { + if i > 0 { + result += ", " + } + result += part + } + return result +} + +// Middleware provides CDN and HTTP caching functionality +type Middleware struct { + defaultCacheControl CacheControl + enableETag bool + enableVary bool +} + +// Config holds CDN middleware configuration +type Config struct { + DefaultCacheControl CacheControl + EnableETag bool + EnableVary bool +} + +// NewMiddleware creates a new CDN middleware +func NewMiddleware(cfg Config) *Middleware { + return &Middleware{ + defaultCacheControl: cfg.DefaultCacheControl, + enableETag: cfg.EnableETag, + enableVary: cfg.EnableVary, + } +} + +// Handler wraps an HTTP handler with CDN caching support +func (m *Middleware) Handler(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Wrap response writer to capture response for ETag generation + rw := &responseWriter{ + ResponseWriter: w, + statusCode: http.StatusOK, + body: nil, + } + + // Call next handler + next.ServeHTTP(rw, r) + + // Apply caching headers if successful response + if rw.statusCode >= 200 && rw.statusCode < 300 { + m.applyCachingHeaders(rw, r) + } + }) +} + +// applyCachingHeaders applies appropriate caching headers to the response +func (m *Middleware) applyCachingHeaders(w *responseWriter, r *http.Request) { + // Set Cache-Control header if not already set + if w.Header().Get("Cache-Control") == "" { + w.Header().Set("Cache-Control", m.defaultCacheControl.String()) + } + + // Set Vary header for content negotiation + if m.enableVary { + m.setVaryHeader(w, r) + } + + // Generate and check ETag if enabled + if m.enableETag && w.body != nil { + m.handleETag(w, r) + } +} + +// setVaryHeader sets the Vary header based on request +func (m *Middleware) setVaryHeader(w *responseWriter, r *http.Request) { + varies := []string{} + + // Vary on Accept-Encoding for compression + if r.Header.Get("Accept-Encoding") != "" { + varies = append(varies, "Accept-Encoding") + } + + // Vary on Authorization for authenticated requests + if r.Header.Get("Authorization") != "" { + varies = append(varies, "Authorization") + } + + // Vary on Accept for content negotiation + if r.Header.Get("Accept") != "" { + varies = append(varies, "Accept") + } + + if len(varies) > 0 { + varyHeader := "" + for i, v := range varies { + if i > 0 { + varyHeader += ", " + } + varyHeader += v + } + w.Header().Set("Vary", varyHeader) + } +} + +// handleETag generates ETag and handles conditional requests +func (m *Middleware) handleETag(w *responseWriter, r *http.Request) { + // Generate ETag from response body + etag := m.generateETag(w.body) + w.Header().Set("ETag", etag) + + // Handle conditional requests + if ifNoneMatch := r.Header.Get("If-None-Match"); ifNoneMatch != "" { + if ifNoneMatch == etag { + // ETag matches - return 304 Not Modified + w.WriteHeader(http.StatusNotModified) + w.body = nil // Clear body for 304 response + log.Debug(). + Str("path", r.URL.Path). + Str("etag", etag). + Msg("ETag match - returning 304 Not Modified") + return + } + } + + // Handle If-Modified-Since + if lastModified := w.Header().Get("Last-Modified"); lastModified != "" { + if ifModifiedSince := r.Header.Get("If-Modified-Since"); ifModifiedSince != "" { + lastModTime, err := http.ParseTime(lastModified) + if err == nil { + ifModTime, err := http.ParseTime(ifModifiedSince) + if err == nil && !lastModTime.After(ifModTime) { + // Not modified - return 304 + w.WriteHeader(http.StatusNotModified) + w.body = nil + log.Debug(). + Str("path", r.URL.Path). + Time("last_modified", lastModTime). + Msg("Not modified - returning 304") + return + } + } + } + } +} + +// generateETag creates an ETag for HTTP caching +// NOTE: MD5 is used for content fingerprinting (ETag), not cryptographic security +func (m *Middleware) generateETag(body []byte) string { + if body == nil { + return "" + } + hash := md5.Sum(body) // #nosec G401 -- MD5 used for ETag, not cryptographic security + return `"` + hex.EncodeToString(hash[:]) + `"` +} + +// SetLastModified sets the Last-Modified header +func SetLastModified(w http.ResponseWriter, t time.Time) { + w.Header().Set("Last-Modified", t.UTC().Format(http.TimeFormat)) +} + +// SetCacheControl sets a custom Cache-Control header +func SetCacheControl(w http.ResponseWriter, cc CacheControl) { + w.Header().Set("Cache-Control", cc.String()) +} + +// SetNoCache sets headers to prevent caching +func SetNoCache(w http.ResponseWriter) { + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + w.Header().Set("Pragma", "no-cache") + w.Header().Set("Expires", "0") +} + +// SetImmutable sets headers for immutable content (content-addressed files) +func SetImmutable(w http.ResponseWriter, maxAge int) { + cc := CacheControl{ + Public: true, + MaxAge: maxAge, + Immutable: true, + } + w.Header().Set("Cache-Control", cc.String()) +} + +// responseWriter wraps http.ResponseWriter to capture response +type responseWriter struct { + http.ResponseWriter + statusCode int + body []byte +} + +func (rw *responseWriter) WriteHeader(statusCode int) { + rw.statusCode = statusCode + rw.ResponseWriter.WriteHeader(statusCode) +} + +func (rw *responseWriter) Write(b []byte) (int, error) { + // Capture body for ETag generation + if rw.body == nil { + rw.body = make([]byte, 0, len(b)) + } + rw.body = append(rw.body, b...) + return rw.ResponseWriter.Write(b) +} + +// HandleRange handles HTTP Range requests for partial content +func HandleRange(w http.ResponseWriter, r *http.Request, content io.ReadSeeker, size int64, modTime time.Time) error { + // Set Last-Modified header + SetLastModified(w, modTime) + + // Check for Range header + rangeHeader := r.Header.Get("Range") + if rangeHeader == "" { + // No range request - serve full content + w.Header().Set("Content-Length", strconv.FormatInt(size, 10)) + w.Header().Set("Accept-Ranges", "bytes") + w.WriteHeader(http.StatusOK) + _, err := io.Copy(w, content) + return err + } + + // Parse range header (simplified - only handles single range) + // Format: bytes=start-end + var start, end int64 + n, err := fmt.Sscanf(rangeHeader, "bytes=%d-%d", &start, &end) + if err != nil || n != 2 { + // Invalid range - serve full content + w.Header().Set("Content-Length", strconv.FormatInt(size, 10)) + w.Header().Set("Accept-Ranges", "bytes") + w.WriteHeader(http.StatusOK) + _, err := io.Copy(w, content) + return err + } + + // Validate range + if start < 0 || start >= size || end < start || end >= size { + w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", size)) + w.WriteHeader(http.StatusRequestedRangeNotSatisfiable) + return nil + } + + // Seek to start position + if _, err := content.Seek(start, io.SeekStart); err != nil { + return err + } + + // Calculate content length + contentLength := end - start + 1 + + // Set headers for partial content + w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, size)) + w.Header().Set("Content-Length", strconv.FormatInt(contentLength, 10)) + w.Header().Set("Accept-Ranges", "bytes") + w.WriteHeader(http.StatusPartialContent) + + // Copy range to response + _, err = io.CopyN(w, content, contentLength) + return err +} + +// DefaultCacheControl returns sensible defaults for different content types +func DefaultCacheControl(contentType string, versioned bool) CacheControl { + if versioned { + // Content-addressed or versioned resources can be cached forever + return CacheControl{ + Public: true, + MaxAge: 31536000, // 1 year + Immutable: true, + } + } + + // Default caching based on content type + switch contentType { + case "application/json": + return CacheControl{ + Public: true, + MaxAge: 3600, // 1 hour + SMaxAge: 7200, // 2 hours for shared caches + } + case "application/octet-stream", "application/x-gzip", "application/zip": + // Binary packages + return CacheControl{ + Public: true, + MaxAge: 86400, // 1 day + SMaxAge: 604800, // 1 week for shared caches + } + case "text/html": + // HTML should revalidate + return CacheControl{ + Public: true, + MaxAge: 0, + MustRevalidate: true, + } + default: + return CacheControl{ + Public: true, + MaxAge: 3600, // 1 hour default + SMaxAge: 7200, + } + } +} diff --git a/pkg/config/config.go b/pkg/config/config.go new file mode 100644 index 0000000..357d824 --- /dev/null +++ b/pkg/config/config.go @@ -0,0 +1,453 @@ +package config + +import ( + "fmt" + "time" +) + +// Config is the main configuration struct +type Config struct { + Server ServerConfig `mapstructure:"server" json:"server"` + Storage StorageConfig `mapstructure:"storage" json:"storage"` + Metadata MetadataConfig `mapstructure:"metadata" json:"metadata"` + Cache CacheConfig `mapstructure:"cache" json:"cache"` + Security SecurityConfig `mapstructure:"security" json:"security"` + Auth AuthConfig `mapstructure:"auth" json:"auth"` + Network NetworkConfig `mapstructure:"network" json:"network"` + Logging LoggingConfig `mapstructure:"logging" json:"logging"` + Handlers HandlersConfig `mapstructure:"handlers" json:"handlers"` +} + +// ServerConfig contains HTTP server configuration +type ServerConfig struct { + Host string `mapstructure:"host" json:"host"` + Port int `mapstructure:"port" json:"port"` + ReadTimeout time.Duration `mapstructure:"read_timeout" json:"read_timeout"` + WriteTimeout time.Duration `mapstructure:"write_timeout" json:"write_timeout"` + IdleTimeout time.Duration `mapstructure:"idle_timeout" json:"idle_timeout"` + TLS TLSConfig `mapstructure:"tls" json:"tls"` +} + +// TLSConfig contains TLS/HTTPS configuration +type TLSConfig struct { + Enabled bool `mapstructure:"enabled" json:"enabled"` + CertFile string `mapstructure:"cert_file" json:"cert_file"` + KeyFile string `mapstructure:"key_file" json:"key_file"` +} + +// StorageConfig contains storage backend configuration +type StorageConfig struct { + Backend string `mapstructure:"backend" json:"backend"` // filesystem, s3, smb, nfs + Path string `mapstructure:"path" json:"path"` + Filesystem FilesystemConfig `mapstructure:"filesystem" json:"filesystem"` + S3 S3Config `mapstructure:"s3" json:"s3"` + SMB SMBConfig `mapstructure:"smb" json:"smb"` + Options map[string]interface{} `mapstructure:"options" json:"options"` +} + +// FilesystemConfig contains local filesystem storage configuration +type FilesystemConfig struct { + BasePath string `mapstructure:"base_path" json:"base_path"` +} + +// S3Config contains S3-compatible storage configuration +type S3Config struct { + Endpoint string `mapstructure:"endpoint" json:"endpoint"` + Region string `mapstructure:"region" json:"region"` + Bucket string `mapstructure:"bucket" json:"bucket"` + AccessKeyID string `mapstructure:"access_key_id" json:"access_key_id"` + SecretAccessKey string `mapstructure:"secret_access_key" json:"-"` // Don't serialize secrets + UseSSL bool `mapstructure:"use_ssl" json:"use_ssl"` +} + +// SMBConfig contains SMB/CIFS storage configuration +type SMBConfig struct { + Host string `mapstructure:"host" json:"host"` + Share string `mapstructure:"share" json:"share"` + Username string `mapstructure:"username" json:"username"` + Password string `mapstructure:"password" json:"-"` // Don't serialize secrets + Domain string `mapstructure:"domain" json:"domain"` +} + +// MetadataConfig contains metadata store configuration +type MetadataConfig struct { + Backend string `mapstructure:"backend" json:"backend"` // sqlite, postgresql, file + Connection string `mapstructure:"connection" json:"connection"` + SQLite SQLiteConfig `mapstructure:"sqlite" json:"sqlite"` + PostgreSQL PostgreSQLConfig `mapstructure:"postgresql" json:"postgresql"` +} + +// SQLiteConfig contains SQLite-specific configuration +type SQLiteConfig struct { + Path string `mapstructure:"path" json:"path"` + WALMode bool `mapstructure:"wal_mode" json:"wal_mode"` +} + +// PostgreSQLConfig contains PostgreSQL-specific configuration +type PostgreSQLConfig struct { + Host string `mapstructure:"host" json:"host"` + Port int `mapstructure:"port" json:"port"` + Database string `mapstructure:"database" json:"database"` + User string `mapstructure:"user" json:"user"` + Password string `mapstructure:"password" json:"-"` // Don't serialize secrets + SSLMode string `mapstructure:"ssl_mode" json:"ssl_mode"` +} + +// CacheConfig contains cache management configuration +type CacheConfig struct { + DefaultTTL time.Duration `mapstructure:"default_ttl" json:"default_ttl"` + CleanupInterval time.Duration `mapstructure:"cleanup_interval" json:"cleanup_interval"` + MaxSizeBytes int64 `mapstructure:"max_size_bytes" json:"max_size_bytes"` + PerProjectQuota int64 `mapstructure:"per_project_quota" json:"per_project_quota"` + TTLOverrides map[string]time.Duration `mapstructure:"ttl_overrides" json:"ttl_overrides"` // Per ecosystem +} + +// SecurityConfig contains security scanning configuration +type SecurityConfig struct { + Enabled bool `mapstructure:"enabled" json:"enabled"` + ScanOnDownload bool `mapstructure:"scan_on_download" json:"scan_on_download"` // Scan packages on first download + RescanInterval time.Duration `mapstructure:"rescan_interval" json:"rescan_interval"` // How often to re-scan (e.g., 24h, 168h for weekly) + BlockOnSeverity string `mapstructure:"block_on_severity" json:"block_on_severity"` // none, low, medium, high, critical + BlockThresholds VulnerabilityThresholds `mapstructure:"block_thresholds" json:"block_thresholds"` // Max vulns per severity before blocking + UpdateDBOnStartup bool `mapstructure:"update_db_on_startup" json:"update_db_on_startup"` // Update vulnerability databases on startup + AllowedPackages []string `mapstructure:"allowed_packages" json:"allowed_packages"` // Packages that bypass security checks (format: "registry/name@version" or "registry/name") + IgnoredCVEs []string `mapstructure:"ignored_cves" json:"ignored_cves"` // CVE IDs to ignore globally (e.g., "CVE-2021-23337") + Scanners ScannersConfig `mapstructure:"scanners" json:"scanners"` +} + +// VulnerabilityThresholds defines max allowed vulnerabilities per severity +type VulnerabilityThresholds struct { + Critical int `mapstructure:"critical" json:"critical"` // Max critical vulns (0 = block any) + High int `mapstructure:"high" json:"high"` // Max high vulns + Medium int `mapstructure:"medium" json:"medium"` // Max medium vulns + Low int `mapstructure:"low" json:"low"` // Max low vulns (-1 = unlimited) +} + +// ScannersConfig contains individual scanner configurations +type ScannersConfig struct { + Trivy TrivyConfig `mapstructure:"trivy" json:"trivy"` + OSV OSVConfig `mapstructure:"osv" json:"osv"` + Static StaticConfig `mapstructure:"static" json:"static"` + Grype GrypeConfig `mapstructure:"grype" json:"grype"` + Govulncheck GovulncheckConfig `mapstructure:"govulncheck" json:"govulncheck"` + NpmAudit NpmAuditConfig `mapstructure:"npm_audit" json:"npm_audit"` + PipAudit PipAuditConfig `mapstructure:"pip_audit" json:"pip_audit"` + GHSA GHSAConfig `mapstructure:"ghsa" json:"ghsa"` +} + +// TrivyConfig contains Trivy scanner configuration +type TrivyConfig struct { + Enabled bool `mapstructure:"enabled" json:"enabled"` + Timeout time.Duration `mapstructure:"timeout" json:"timeout"` + CacheDB string `mapstructure:"cache_db" json:"cache_db"` +} + +// OSVConfig contains OSV scanner configuration +type OSVConfig struct { + Enabled bool `mapstructure:"enabled" json:"enabled"` + APIURL string `mapstructure:"api_url" json:"api_url"` + Timeout time.Duration `mapstructure:"timeout" json:"timeout"` +} + +// StaticConfig contains static analysis configuration +type StaticConfig struct { + Enabled bool `mapstructure:"enabled" json:"enabled"` + MaxPackageSize int64 `mapstructure:"max_package_size" json:"max_package_size"` + CheckChecksums bool `mapstructure:"check_checksums" json:"check_checksums"` + BlockSuspicious bool `mapstructure:"block_suspicious" json:"block_suspicious"` + AllowedLicenses []string `mapstructure:"allowed_licenses" json:"allowed_licenses"` +} + +// GrypeConfig contains Grype scanner configuration +type GrypeConfig struct { + Enabled bool `mapstructure:"enabled" json:"enabled"` + Timeout time.Duration `mapstructure:"timeout" json:"timeout"` +} + +// GovulncheckConfig contains govulncheck scanner configuration +type GovulncheckConfig struct { + Enabled bool `mapstructure:"enabled" json:"enabled"` + Timeout time.Duration `mapstructure:"timeout" json:"timeout"` +} + +// NpmAuditConfig contains npm audit scanner configuration +type NpmAuditConfig struct { + Enabled bool `mapstructure:"enabled" json:"enabled"` + Timeout time.Duration `mapstructure:"timeout" json:"timeout"` +} + +// PipAuditConfig contains pip-audit scanner configuration +type PipAuditConfig struct { + Enabled bool `mapstructure:"enabled" json:"enabled"` + Timeout time.Duration `mapstructure:"timeout" json:"timeout"` +} + +// GHSAConfig contains GitHub Advisory Database scanner configuration +type GHSAConfig struct { + Enabled bool `mapstructure:"enabled" json:"enabled"` + Timeout time.Duration `mapstructure:"timeout" json:"timeout"` + Token string `mapstructure:"token" json:"-"` // GitHub token for higher rate limits (don't serialize) +} + +// AuthConfig contains authentication configuration +type AuthConfig struct { + Enabled bool `mapstructure:"enabled" json:"enabled"` + KeyExpiration time.Duration `mapstructure:"key_expiration" json:"key_expiration"` + BcryptCost int `mapstructure:"bcrypt_cost" json:"bcrypt_cost"` + AuditLog bool `mapstructure:"audit_log" json:"audit_log"` +} + +// NetworkConfig contains network resilience configuration +type NetworkConfig struct { + ConnectTimeout time.Duration `mapstructure:"connect_timeout" json:"connect_timeout"` + ReadTimeout time.Duration `mapstructure:"read_timeout" json:"read_timeout"` + WriteTimeout time.Duration `mapstructure:"write_timeout" json:"write_timeout"` + MaxIdleConns int `mapstructure:"max_idle_conns" json:"max_idle_conns"` + MaxConnsPerHost int `mapstructure:"max_conns_per_host" json:"max_conns_per_host"` + RateLimit RateLimitConfig `mapstructure:"rate_limit" json:"rate_limit"` + CircuitBreaker CircuitBreakerConfig `mapstructure:"circuit_breaker" json:"circuit_breaker"` + Retry RetryConfig `mapstructure:"retry" json:"retry"` +} + +// RateLimitConfig contains rate limiting configuration +type RateLimitConfig struct { + PerAPIKey int `mapstructure:"per_api_key" json:"per_api_key"` + PerIP int `mapstructure:"per_ip" json:"per_ip"` + BurstSize int `mapstructure:"burst_size" json:"burst_size"` +} + +// CircuitBreakerConfig contains circuit breaker configuration +type CircuitBreakerConfig struct { + Threshold int `mapstructure:"threshold" json:"threshold"` + Timeout time.Duration `mapstructure:"timeout" json:"timeout"` + ResetInterval time.Duration `mapstructure:"reset_interval" json:"reset_interval"` +} + +// RetryConfig contains retry policy configuration +type RetryConfig struct { + MaxAttempts int `mapstructure:"max_attempts" json:"max_attempts"` + InitialBackoff time.Duration `mapstructure:"initial_backoff" json:"initial_backoff"` + MaxBackoff time.Duration `mapstructure:"max_backoff" json:"max_backoff"` +} + +// LoggingConfig contains logging configuration +type LoggingConfig struct { + Level string `mapstructure:"level" json:"level"` // debug, info, warn, error + Format string `mapstructure:"format" json:"format"` // json, pretty +} + +// HandlersConfig contains package manager handler configurations +type HandlersConfig struct { + Go GoHandlerConfig `mapstructure:"go" json:"go"` + NPM NPMHandlerConfig `mapstructure:"npm" json:"npm"` + PyPI PyPIHandlerConfig `mapstructure:"pypi" json:"pypi"` +} + +// GoHandlerConfig contains Go proxy configuration +type GoHandlerConfig struct { + Enabled bool `mapstructure:"enabled" json:"enabled"` + UpstreamProxy string `mapstructure:"upstream_proxy" json:"upstream_proxy"` + ChecksumDB string `mapstructure:"checksum_db" json:"checksum_db"` + VerifyChecksums bool `mapstructure:"verify_checksums" json:"verify_checksums"` + GitCredentialsFile string `mapstructure:"git_credentials_file" json:"git_credentials_file"` // Path to git credentials JSON file +} + +// NPMHandlerConfig contains NPM registry configuration +type NPMHandlerConfig struct { + Enabled bool `mapstructure:"enabled" json:"enabled"` + UpstreamRegistry string `mapstructure:"upstream_registry" json:"upstream_registry"` +} + +// PyPIHandlerConfig contains PyPI configuration +type PyPIHandlerConfig struct { + Enabled bool `mapstructure:"enabled" json:"enabled"` + UpstreamURL string `mapstructure:"upstream_url" json:"upstream_url"` + SimpleAPIURL string `mapstructure:"simple_api_url" json:"simple_api_url"` +} + +// Default returns a configuration with sensible defaults +func Default() *Config { + return &Config{ + Server: ServerConfig{ + Host: "0.0.0.0", + Port: 8080, + ReadTimeout: 5 * time.Minute, + WriteTimeout: 5 * time.Minute, + IdleTimeout: 2 * time.Minute, + TLS: TLSConfig{ + Enabled: false, + }, + }, + Storage: StorageConfig{ + Backend: "filesystem", + Path: "/var/cache/gohoarder", + Filesystem: FilesystemConfig{ + BasePath: "/var/cache/gohoarder", + }, + }, + Metadata: MetadataConfig{ + Backend: "sqlite", + Connection: "file:gohoarder.db?cache=shared&mode=rwc", + SQLite: SQLiteConfig{ + Path: "gohoarder.db", + WALMode: true, + }, + }, + Cache: CacheConfig{ + DefaultTTL: 7 * 24 * time.Hour, + CleanupInterval: 1 * time.Hour, + MaxSizeBytes: 500 * 1024 * 1024 * 1024, // 500GB + PerProjectQuota: 50 * 1024 * 1024 * 1024, // 50GB + TTLOverrides: map[string]time.Duration{ + "npm": 7 * 24 * time.Hour, + "pip": 7 * 24 * time.Hour, + "go": 7 * 24 * time.Hour, + }, + }, + Security: SecurityConfig{ + Enabled: false, + BlockOnSeverity: "high", + Scanners: ScannersConfig{ + Trivy: TrivyConfig{ + Enabled: false, + Timeout: 5 * time.Minute, + CacheDB: "/var/lib/trivy", + }, + OSV: OSVConfig{ + Enabled: false, + APIURL: "https://api.osv.dev", + Timeout: 30 * time.Second, + }, + Static: StaticConfig{ + Enabled: true, + MaxPackageSize: 2 * 1024 * 1024 * 1024, // 2GB + CheckChecksums: true, + BlockSuspicious: false, + }, + Grype: GrypeConfig{ + Enabled: false, + Timeout: 5 * time.Minute, + }, + Govulncheck: GovulncheckConfig{ + Enabled: false, + Timeout: 5 * time.Minute, + }, + NpmAudit: NpmAuditConfig{ + Enabled: false, + Timeout: 2 * time.Minute, + }, + PipAudit: PipAuditConfig{ + Enabled: false, + Timeout: 2 * time.Minute, + }, + GHSA: GHSAConfig{ + Enabled: false, + Timeout: 30 * time.Second, + Token: "", + }, + }, + }, + Auth: AuthConfig{ + Enabled: true, + KeyExpiration: 0, // Never expire + BcryptCost: 10, + AuditLog: true, + }, + Network: NetworkConfig{ + ConnectTimeout: 10 * time.Second, + ReadTimeout: 5 * time.Minute, + WriteTimeout: 5 * time.Minute, + MaxIdleConns: 100, + MaxConnsPerHost: 10, + RateLimit: RateLimitConfig{ + PerAPIKey: 1000, + PerIP: 100, + BurstSize: 50, + }, + CircuitBreaker: CircuitBreakerConfig{ + Threshold: 5, + Timeout: 30 * time.Second, + ResetInterval: 60 * time.Second, + }, + Retry: RetryConfig{ + MaxAttempts: 3, + InitialBackoff: 1 * time.Second, + MaxBackoff: 30 * time.Second, + }, + }, + Logging: LoggingConfig{ + Level: "info", + Format: "json", + }, + Handlers: HandlersConfig{ + Go: GoHandlerConfig{ + Enabled: true, + UpstreamProxy: "https://proxy.golang.org", + ChecksumDB: "https://sum.golang.org", + VerifyChecksums: true, + }, + NPM: NPMHandlerConfig{ + Enabled: true, + UpstreamRegistry: "https://registry.npmjs.org", + }, + PyPI: PyPIHandlerConfig{ + Enabled: true, + UpstreamURL: "https://pypi.org", + SimpleAPIURL: "https://pypi.org/simple", + }, + }, + } +} + +// Validate validates the configuration +func (c *Config) Validate() error { + // Validate server + if c.Server.Port < 1 || c.Server.Port > 65535 { + return fmt.Errorf("server.port must be between 1 and 65535, got %d", c.Server.Port) + } + + // Validate storage backend + validStorageBackends := map[string]bool{"filesystem": true, "s3": true, "smb": true, "nfs": true} + if !validStorageBackends[c.Storage.Backend] { + return fmt.Errorf("storage.backend must be one of: filesystem, s3, smb, nfs; got %s", c.Storage.Backend) + } + + // Validate metadata backend + validMetadataBackends := map[string]bool{"sqlite": true, "postgresql": true, "file": true} + if !validMetadataBackends[c.Metadata.Backend] { + return fmt.Errorf("metadata.backend must be one of: sqlite, postgresql, file; got %s", c.Metadata.Backend) + } + + // Validate cache + if c.Cache.DefaultTTL < 0 { + return fmt.Errorf("cache.default_ttl cannot be negative") + } + if c.Cache.MaxSizeBytes < 0 { + return fmt.Errorf("cache.max_size_bytes cannot be negative") + } + + // Validate security + validSeverities := map[string]bool{"none": true, "low": true, "medium": true, "high": true, "critical": true} + if !validSeverities[c.Security.BlockOnSeverity] { + return fmt.Errorf("security.block_on_severity must be one of: none, low, medium, high, critical; got %s", c.Security.BlockOnSeverity) + } + + // Validate logging level + validLevels := map[string]bool{"debug": true, "info": true, "warn": true, "error": true} + if !validLevels[c.Logging.Level] { + return fmt.Errorf("logging.level must be one of: debug, info, warn, error; got %s", c.Logging.Level) + } + + // Validate logging format + validFormats := map[string]bool{"json": true, "pretty": true} + if !validFormats[c.Logging.Format] { + return fmt.Errorf("logging.format must be one of: json, pretty; got %s", c.Logging.Format) + } + + // Validate auth + if c.Auth.BcryptCost < 4 || c.Auth.BcryptCost > 31 { + return fmt.Errorf("auth.bcrypt_cost must be between 4 and 31, got %d", c.Auth.BcryptCost) + } + + return nil +} diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go new file mode 100644 index 0000000..211b585 --- /dev/null +++ b/pkg/config/config_test.go @@ -0,0 +1,383 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" +) + +type ConfigTestSuite struct { + suite.Suite + tempDir string +} + +func TestConfigTestSuite(t *testing.T) { + suite.Run(t, new(ConfigTestSuite)) +} + +func (s *ConfigTestSuite) SetupTest() { + var err error + s.tempDir, err = os.MkdirTemp("", "gohoarder-config-test-*") + s.Require().NoError(err) +} + +func (s *ConfigTestSuite) TearDownTest() { + _ = os.RemoveAll(s.tempDir) // #nosec G104 -- Cleanup +} + +func (s *ConfigTestSuite) TestDefault() { + cfg := Default() + s.NotNil(cfg) + s.Equal("0.0.0.0", cfg.Server.Host) + s.Equal(8080, cfg.Server.Port) + s.Equal("filesystem", cfg.Storage.Backend) + s.Equal("sqlite", cfg.Metadata.Backend) + s.NoError(cfg.Validate()) +} + +func (s *ConfigTestSuite) TestValidate() { + tests := []struct { + name string + modify func(*Config) + expectError bool + errorSubstr string + }{ + { + name: "valid_config", + modify: func(c *Config) {}, + expectError: false, + }, + { + name: "invalid_port_too_low", + modify: func(c *Config) { + c.Server.Port = 0 + }, + expectError: true, + errorSubstr: "port must be between", + }, + { + name: "invalid_port_too_high", + modify: func(c *Config) { + c.Server.Port = 70000 + }, + expectError: true, + errorSubstr: "port must be between", + }, + { + name: "invalid_storage_backend", + modify: func(c *Config) { + c.Storage.Backend = "invalid" + }, + expectError: true, + errorSubstr: "storage.backend must be one of", + }, + { + name: "invalid_metadata_backend", + modify: func(c *Config) { + c.Metadata.Backend = "mongodb" + }, + expectError: true, + errorSubstr: "metadata.backend must be one of", + }, + { + name: "negative_ttl", + modify: func(c *Config) { + c.Cache.DefaultTTL = -1 * time.Hour + }, + expectError: true, + errorSubstr: "cannot be negative", + }, + { + name: "negative_cache_size", + modify: func(c *Config) { + c.Cache.MaxSizeBytes = -100 + }, + expectError: true, + errorSubstr: "cannot be negative", + }, + { + name: "invalid_severity", + modify: func(c *Config) { + c.Security.BlockOnSeverity = "super-high" + }, + expectError: true, + errorSubstr: "block_on_severity must be one of", + }, + { + name: "invalid_log_level", + modify: func(c *Config) { + c.Logging.Level = "verbose" + }, + expectError: true, + errorSubstr: "logging.level must be one of", + }, + { + name: "invalid_log_format", + modify: func(c *Config) { + c.Logging.Format = "xml" + }, + expectError: true, + errorSubstr: "logging.format must be one of", + }, + { + name: "invalid_bcrypt_cost_too_low", + modify: func(c *Config) { + c.Auth.BcryptCost = 3 + }, + expectError: true, + errorSubstr: "bcrypt_cost must be between", + }, + { + name: "invalid_bcrypt_cost_too_high", + modify: func(c *Config) { + c.Auth.BcryptCost = 32 + }, + expectError: true, + errorSubstr: "bcrypt_cost must be between", + }, + { + name: "valid_s3_backend", + modify: func(c *Config) { + c.Storage.Backend = "s3" + }, + expectError: false, + }, + { + name: "valid_postgresql_backend", + modify: func(c *Config) { + c.Metadata.Backend = "postgresql" + }, + expectError: false, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + cfg := Default() + tt.modify(cfg) + err := cfg.Validate() + + if tt.expectError { + s.Error(err) + if tt.errorSubstr != "" { + s.Contains(err.Error(), tt.errorSubstr) + } + } else { + s.NoError(err) + } + }) + } +} + +func (s *ConfigTestSuite) TestLoad() { + tests := []struct { + name string + configYAML string + envVars map[string]string + expectError bool + validate func(*Config) + }{ + { + name: "valid_yaml_config", + configYAML: ` +server: + host: 127.0.0.1 + port: 9000 +storage: + backend: filesystem + path: /custom/path +logging: + level: debug + format: pretty +`, + expectError: false, + validate: func(cfg *Config) { + s.Equal("127.0.0.1", cfg.Server.Host) + s.Equal(9000, cfg.Server.Port) + s.Equal("/custom/path", cfg.Storage.Path) + s.Equal("debug", cfg.Logging.Level) + s.Equal("pretty", cfg.Logging.Format) + }, + }, + { + name: "env_var_override", + configYAML: ` +server: + port: 8080 +`, + envVars: map[string]string{ + "GOHOARDER_SERVER_PORT": "9090", + }, + expectError: false, + validate: func(cfg *Config) { + s.Equal(9090, cfg.Server.Port) + }, + }, + { + name: "invalid_yaml", + configYAML: ` +server: [invalid +`, + expectError: true, + }, + { + name: "validation_failure", + configYAML: ` +server: + port: 100000 +`, + expectError: true, + }, + { + name: "complete_config", + configYAML: ` +server: + host: 0.0.0.0 + port: 8080 + read_timeout: 300s + write_timeout: 300s +storage: + backend: s3 + s3: + endpoint: s3.amazonaws.com + region: us-east-1 + bucket: my-cache + access_key_id: AKIAIOSFODNN7EXAMPLE + secret_access_key: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY +metadata: + backend: postgresql + postgresql: + host: localhost + port: 5432 + database: gohoarder + user: postgres + password: secret + ssl_mode: require +cache: + default_ttl: 168h + max_size_bytes: 536870912000 +security: + enabled: true + block_on_severity: high + scanners: + trivy: + enabled: true + timeout: 300s +auth: + enabled: true + bcrypt_cost: 12 +`, + expectError: false, + validate: func(cfg *Config) { + s.Equal("s3", cfg.Storage.Backend) + s.Equal("s3.amazonaws.com", cfg.Storage.S3.Endpoint) + s.Equal("postgresql", cfg.Metadata.Backend) + s.Equal("localhost", cfg.Metadata.PostgreSQL.Host) + s.True(cfg.Security.Enabled) + s.Equal(12, cfg.Auth.BcryptCost) + }, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + // Write config file + configPath := filepath.Join(s.tempDir, "config.yaml") + err := os.WriteFile(configPath, []byte(tt.configYAML), 0644) + s.Require().NoError(err) + + // Set environment variables + for k, v := range tt.envVars { + os.Setenv(k, v) + defer os.Unsetenv(k) + } + + // Load config + cfg, err := Load(configPath) + + if tt.expectError { + s.Error(err) + } else { + s.NoError(err) + s.NotNil(cfg) + if tt.validate != nil { + tt.validate(cfg) + } + } + }) + } +} + +func (s *ConfigTestSuite) TestLoadMissingFile() { + // Should return error when file explicitly specified but not found + cfg, err := Load("/nonexistent/path/to/config.yaml") + s.Error(err) + s.Nil(cfg) +} + +func (s *ConfigTestSuite) TestLoadWithDefaults() { + // Invalid config path should return defaults + cfg := LoadWithDefaults("/invalid/path/config.yaml") + s.NotNil(cfg) + s.Equal(8080, cfg.Server.Port) +} + +// Benchmark tests +func BenchmarkDefault(b *testing.B) { + for i := 0; i < b.N; i++ { + _ = Default() + } +} + +func BenchmarkValidate(b *testing.B) { + cfg := Default() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = cfg.Validate() + } +} + +// Table-driven edge cases +func TestConfigEdgeCases(t *testing.T) { + tests := []struct { + name string + config *Config + valid bool + }{ + { + name: "minimal_config", + config: &Config{Server: ServerConfig{Port: 8080}, Storage: StorageConfig{Backend: "filesystem"}, Metadata: MetadataConfig{Backend: "sqlite"}, Logging: LoggingConfig{Level: "info", Format: "json"}, Security: SecurityConfig{BlockOnSeverity: "high"}, Auth: AuthConfig{BcryptCost: 10}}, + valid: true, + }, + { + name: "zero_ttl", + config: func() *Config { c := Default(); c.Cache.DefaultTTL = 0; return c }(), + valid: true, // Zero is valid (no caching) + }, + { + name: "max_bcrypt_cost", + config: func() *Config { c := Default(); c.Auth.BcryptCost = 31; return c }(), + valid: true, + }, + { + name: "min_bcrypt_cost", + config: func() *Config { c := Default(); c.Auth.BcryptCost = 4; return c }(), + valid: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.config.Validate() + if tt.valid { + assert.NoError(t, err) + } else { + assert.Error(t, err) + } + }) + } +} diff --git a/pkg/config/loader.go b/pkg/config/loader.go new file mode 100644 index 0000000..5800df8 --- /dev/null +++ b/pkg/config/loader.go @@ -0,0 +1,62 @@ +package config + +import ( + "fmt" + "strings" + + "github.com/spf13/viper" +) + +// Load loads configuration from file and environment variables +func Load(configPath string) (*Config, error) { + v := viper.New() + + // Set config file if provided + if configPath != "" { + v.SetConfigFile(configPath) + } else { + // Look for config.yaml in current directory and /etc/gohoarder + v.SetConfigName("config") + v.SetConfigType("yaml") + v.AddConfigPath(".") + v.AddConfigPath("/etc/gohoarder") + v.AddConfigPath("$HOME/.gohoarder") + } + + // Set environment variable prefix + v.SetEnvPrefix("GOHOARDER") + v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) + v.AutomaticEnv() + + // Read config file + if err := v.ReadInConfig(); err != nil { + // If no config file found, use defaults + if _, ok := err.(viper.ConfigFileNotFoundError); !ok { + return nil, fmt.Errorf("failed to read config file: %w", err) + } + } + + // Start with defaults + cfg := Default() + + // Unmarshal into config struct + if err := v.Unmarshal(cfg); err != nil { + return nil, fmt.Errorf("failed to unmarshal config: %w", err) + } + + // Validate configuration + if err := cfg.Validate(); err != nil { + return nil, fmt.Errorf("config validation failed: %w", err) + } + + return cfg, nil +} + +// LoadWithDefaults loads configuration or returns defaults on error +func LoadWithDefaults(configPath string) *Config { + cfg, err := Load(configPath) + if err != nil { + return Default() + } + return cfg +} diff --git a/pkg/errors/codes.go b/pkg/errors/codes.go new file mode 100644 index 0000000..443dfa0 --- /dev/null +++ b/pkg/errors/codes.go @@ -0,0 +1,68 @@ +package errors + +// Error codes following consistent naming convention +const ( + // Client errors (4xx) + ErrCodeBadRequest = "BAD_REQUEST" + ErrCodeUnauthorized = "UNAUTHORIZED" + ErrCodeForbidden = "FORBIDDEN" + ErrCodeNotFound = "NOT_FOUND" + ErrCodeRateLimited = "RATE_LIMITED" + ErrCodePayloadTooLarge = "PAYLOAD_TOO_LARGE" + ErrCodeInvalidAPIKey = "INVALID_API_KEY" // #nosec G101 -- Not a credential, just an error code constant + ErrCodeQuotaExceeded = "QUOTA_EXCEEDED" + ErrCodeConflict = "CONFLICT" + ErrCodeInvalidConfig = "INVALID_CONFIG" + + // Package-specific errors + ErrCodePackageNotFound = "PACKAGE_NOT_FOUND" + ErrCodeVersionNotFound = "VERSION_NOT_FOUND" + ErrCodeChecksumMismatch = "CHECKSUM_MISMATCH" + ErrCodeCorruptPackage = "CORRUPT_PACKAGE" + ErrCodeSecurityBlocked = "SECURITY_BLOCKED" + ErrCodeSecurityViolation = "SECURITY_VIOLATION" // Package has vulnerabilities exceeding thresholds + ErrCodeUpstreamError = "UPSTREAM_ERROR" + + // Server errors (5xx) + ErrCodeInternalServer = "INTERNAL_SERVER_ERROR" + ErrCodeStorageFailure = "STORAGE_FAILURE" + ErrCodeUpstreamFailure = "UPSTREAM_FAILURE" + ErrCodeDatabaseFailure = "DATABASE_FAILURE" + ErrCodeServiceUnavailable = "SERVICE_UNAVAILABLE" + ErrCodeCircuitOpen = "CIRCUIT_OPEN" +) + +// HTTPStatusCode maps error codes to HTTP status codes +var HTTPStatusCode = map[string]int{ + ErrCodeBadRequest: 400, + ErrCodeUnauthorized: 401, + ErrCodeForbidden: 403, + ErrCodeNotFound: 404, + ErrCodeConflict: 409, + ErrCodeRateLimited: 429, + ErrCodePayloadTooLarge: 413, + ErrCodeInvalidAPIKey: 401, + ErrCodeQuotaExceeded: 429, + ErrCodeInvalidConfig: 400, + ErrCodePackageNotFound: 404, + ErrCodeVersionNotFound: 404, + ErrCodeChecksumMismatch: 422, + ErrCodeCorruptPackage: 422, + ErrCodeSecurityBlocked: 403, + ErrCodeSecurityViolation: 426, // Upgrade Required + ErrCodeUpstreamError: 502, + ErrCodeInternalServer: 500, + ErrCodeStorageFailure: 500, + ErrCodeUpstreamFailure: 502, + ErrCodeDatabaseFailure: 500, + ErrCodeServiceUnavailable: 503, + ErrCodeCircuitOpen: 503, +} + +// GetHTTPStatus returns the HTTP status code for an error code +func GetHTTPStatus(code string) int { + if status, ok := HTTPStatusCode[code]; ok { + return status + } + return 500 // Default to internal server error +} diff --git a/pkg/errors/errors.go b/pkg/errors/errors.go new file mode 100644 index 0000000..cebb637 --- /dev/null +++ b/pkg/errors/errors.go @@ -0,0 +1,115 @@ +package errors + +import ( + "fmt" +) + +// Error represents a structured error with code and details +type Error struct { + Code string `json:"code"` + Message string `json:"message"` + Details interface{} `json:"details,omitempty"` + Trace []string `json:"trace,omitempty"` + Cause error `json:"-"` // Internal cause, not serialized +} + +// Error implements the error interface +func (e *Error) Error() string { + if e.Cause != nil { + return fmt.Sprintf("%s: %s (caused by: %v)", e.Code, e.Message, e.Cause) + } + return fmt.Sprintf("%s: %s", e.Code, e.Message) +} + +// Unwrap returns the cause for errors.Is/As support +func (e *Error) Unwrap() error { + return e.Cause +} + +// New creates a new error with the given code and message +func New(code, message string) *Error { + return &Error{ + Code: code, + Message: message, + } +} + +// Newf creates a new error with formatted message +func Newf(code, format string, args ...interface{}) *Error { + return &Error{ + Code: code, + Message: fmt.Sprintf(format, args...), + } +} + +// WithDetails adds details to the error +func (e *Error) WithDetails(details interface{}) *Error { + e.Details = details + return e +} + +// WithTrace adds stack trace to the error +func (e *Error) WithTrace(trace []string) *Error { + e.Trace = trace + return e +} + +// WithCause adds an underlying cause to the error +func (e *Error) WithCause(cause error) *Error { + e.Cause = cause + return e +} + +// Wrap wraps an existing error with a new code and message +func Wrap(err error, code, message string) *Error { + return &Error{ + Code: code, + Message: message, + Cause: err, + } +} + +// Wrapf wraps an existing error with formatted message +func Wrapf(err error, code, format string, args ...interface{}) *Error { + return &Error{ + Code: code, + Message: fmt.Sprintf(format, args...), + Cause: err, + } +} + +// Common error constructors +func BadRequest(message string) *Error { + return New(ErrCodeBadRequest, message) +} + +func Unauthorized(message string) *Error { + return New(ErrCodeUnauthorized, message) +} + +func Forbidden(message string) *Error { + return New(ErrCodeForbidden, message) +} + +func NotFound(message string) *Error { + return New(ErrCodeNotFound, message) +} + +func InternalServer(message string) *Error { + return New(ErrCodeInternalServer, message) +} + +func PackageNotFound(name, version string) *Error { + return New(ErrCodePackageNotFound, fmt.Sprintf("Package %s@%s not found", name, version)). + WithDetails(map[string]string{ + "package": name, + "version": version, + }) +} + +func QuotaExceeded(limit int64) *Error { + return New(ErrCodeQuotaExceeded, "Storage quota exceeded"). + WithDetails(map[string]interface{}{ + "limit_bytes": limit, + }) +} diff --git a/pkg/errors/errors_test.go b/pkg/errors/errors_test.go new file mode 100644 index 0000000..77885f0 --- /dev/null +++ b/pkg/errors/errors_test.go @@ -0,0 +1,305 @@ +package errors + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" +) + +type ErrorsTestSuite struct { + suite.Suite +} + +func TestErrorsTestSuite(t *testing.T) { + suite.Run(t, new(ErrorsTestSuite)) +} + +func (s *ErrorsTestSuite) TestNew() { + tests := []struct { + name string + code string + message string + }{ + { + name: "simple_error", + code: ErrCodeNotFound, + message: "Resource not found", + }, + { + name: "empty_message", + code: ErrCodeBadRequest, + message: "", + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + err := New(tt.code, tt.message) + s.Equal(tt.code, err.Code) + s.Equal(tt.message, err.Message) + s.Nil(err.Details) + s.Nil(err.Trace) + s.Nil(err.Cause) + }) + } +} + +func (s *ErrorsTestSuite) TestNewf() { + tests := []struct { + name string + code string + format string + args []interface{} + expected string + }{ + { + name: "formatted_message", + code: ErrCodePackageNotFound, + format: "Package %s@%s not found", + args: []interface{}{"react", "18.2.0"}, + expected: "Package react@18.2.0 not found", + }, + { + name: "no_args", + code: ErrCodeInternalServer, + format: "Internal error", + args: []interface{}{}, + expected: "Internal error", + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + err := Newf(tt.code, tt.format, tt.args...) + s.Equal(tt.code, err.Code) + s.Equal(tt.expected, err.Message) + }) + } +} + +func (s *ErrorsTestSuite) TestWithDetails() { + tests := []struct { + name string + details interface{} + }{ + { + name: "map_details", + details: map[string]string{"key": "value"}, + }, + { + name: "string_details", + details: "some details", + }, + { + name: "nil_details", + details: nil, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + err := New(ErrCodeBadRequest, "test").WithDetails(tt.details) + s.Equal(tt.details, err.Details) + }) + } +} + +func (s *ErrorsTestSuite) TestWithTrace() { + trace := []string{"file1.go:10", "file2.go:20"} + err := New(ErrCodeInternalServer, "test").WithTrace(trace) + s.Equal(trace, err.Trace) +} + +func (s *ErrorsTestSuite) TestWithCause() { + cause := errors.New("underlying error") + err := New(ErrCodeStorageFailure, "test").WithCause(cause) + s.Equal(cause, err.Cause) + s.Contains(err.Error(), "underlying error") +} + +func (s *ErrorsTestSuite) TestWrap() { + cause := errors.New("original error") + wrapped := Wrap(cause, ErrCodeDatabaseFailure, "database connection failed") + + s.Equal(ErrCodeDatabaseFailure, wrapped.Code) + s.Equal("database connection failed", wrapped.Message) + s.Equal(cause, wrapped.Cause) + s.True(errors.Is(wrapped, cause)) +} + +func (s *ErrorsTestSuite) TestWrapf() { + cause := errors.New("connection refused") + wrapped := Wrapf(cause, ErrCodeUpstreamFailure, "failed to connect to %s", "registry.npmjs.org") + + s.Equal(ErrCodeUpstreamFailure, wrapped.Code) + s.Equal("failed to connect to registry.npmjs.org", wrapped.Message) + s.Equal(cause, wrapped.Cause) +} + +func (s *ErrorsTestSuite) TestErrorString() { + tests := []struct { + name string + err *Error + expected string + }{ + { + name: "error_without_cause", + err: New(ErrCodeNotFound, "not found"), + expected: "NOT_FOUND: not found", + }, + { + name: "error_with_cause", + err: Wrap(errors.New("io error"), ErrCodeStorageFailure, "storage failed"), + expected: "STORAGE_FAILURE: storage failed (caused by: io error)", + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + s.Equal(tt.expected, tt.err.Error()) + }) + } +} + +func (s *ErrorsTestSuite) TestCommonConstructors() { + tests := []struct { + name string + fn func() *Error + wantCode string + }{ + { + name: "bad_request", + fn: func() *Error { return BadRequest("invalid input") }, + wantCode: ErrCodeBadRequest, + }, + { + name: "unauthorized", + fn: func() *Error { return Unauthorized("invalid token") }, + wantCode: ErrCodeUnauthorized, + }, + { + name: "forbidden", + fn: func() *Error { return Forbidden("access denied") }, + wantCode: ErrCodeForbidden, + }, + { + name: "not_found", + fn: func() *Error { return NotFound("resource missing") }, + wantCode: ErrCodeNotFound, + }, + { + name: "internal_server", + fn: func() *Error { return InternalServer("server error") }, + wantCode: ErrCodeInternalServer, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + err := tt.fn() + s.Equal(tt.wantCode, err.Code) + }) + } +} + +func (s *ErrorsTestSuite) TestPackageNotFound() { + err := PackageNotFound("lodash", "4.17.21") + s.Equal(ErrCodePackageNotFound, err.Code) + s.Equal("Package lodash@4.17.21 not found", err.Message) + s.NotNil(err.Details) + + details, ok := err.Details.(map[string]string) + s.True(ok) + s.Equal("lodash", details["package"]) + s.Equal("4.17.21", details["version"]) +} + +func (s *ErrorsTestSuite) TestQuotaExceeded() { + limit := int64(1000000) + err := QuotaExceeded(limit) + s.Equal(ErrCodeQuotaExceeded, err.Code) + s.NotNil(err.Details) + + details, ok := err.Details.(map[string]interface{}) + s.True(ok) + s.Equal(limit, details["limit_bytes"]) +} + +func (s *ErrorsTestSuite) TestUnwrap() { + cause := errors.New("root cause") + wrapped := Wrap(cause, ErrCodeDatabaseFailure, "db error") + + unwrapped := wrapped.Unwrap() + s.Equal(cause, unwrapped) +} + +// Benchmark tests +func BenchmarkNewError(b *testing.B) { + for i := 0; i < b.N; i++ { + _ = New(ErrCodeNotFound, "test error") + } +} + +func BenchmarkNewErrorWithDetails(b *testing.B) { + details := map[string]string{"key": "value"} + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = New(ErrCodeNotFound, "test error").WithDetails(details) + } +} + +// Test edge cases +func (s *ErrorsTestSuite) TestEdgeCases() { + s.Run("nil_error_wrap", func() { + wrapped := Wrap(nil, ErrCodeInternalServer, "test") + s.Nil(wrapped.Cause) + }) + + s.Run("chained_wrapping", func() { + err1 := errors.New("base") + err2 := Wrap(err1, ErrCodeStorageFailure, "storage") + err3 := Wrap(err2, ErrCodeInternalServer, "internal") + + s.True(errors.Is(err3, err2)) + s.True(errors.Is(err3, err1)) + }) + + s.Run("large_details", func() { + largeDetails := make(map[string]string) + for i := 0; i < 1000; i++ { + largeDetails[string(rune(i))] = "value" + } + err := New(ErrCodeBadRequest, "test").WithDetails(largeDetails) + s.Equal(largeDetails, err.Details) + }) +} + +// Table-driven test for error codes +func TestGetHTTPStatus(t *testing.T) { + tests := []struct { + code string + expectedStatus int + }{ + {ErrCodeBadRequest, 400}, + {ErrCodeUnauthorized, 401}, + {ErrCodeForbidden, 403}, + {ErrCodeNotFound, 404}, + {ErrCodeConflict, 409}, + {ErrCodePayloadTooLarge, 413}, + {ErrCodeChecksumMismatch, 422}, + {ErrCodeRateLimited, 429}, + {ErrCodeInternalServer, 500}, + {ErrCodeDatabaseFailure, 500}, + {ErrCodeUpstreamFailure, 502}, + {ErrCodeServiceUnavailable, 503}, + {"UNKNOWN_CODE", 500}, // Default + } + + for _, tt := range tests { + t.Run(tt.code, func(t *testing.T) { + assert.Equal(t, tt.expectedStatus, GetHTTPStatus(tt.code)) + }) + } +} diff --git a/pkg/errors/response.go b/pkg/errors/response.go new file mode 100644 index 0000000..362c85d --- /dev/null +++ b/pkg/errors/response.go @@ -0,0 +1,90 @@ +package errors + +import ( + "net/http" + "time" + + json "github.com/goccy/go-json" +) + +// Response is the standard API response envelope +type Response struct { + Success bool `json:"success"` + Data interface{} `json:"data,omitempty"` + Error *ErrorResponse `json:"error,omitempty"` + Metadata *ResponseMeta `json:"metadata,omitempty"` +} + +// ErrorResponse contains error details +type ErrorResponse struct { + Code string `json:"code"` + Message string `json:"message"` + Details interface{} `json:"details,omitempty"` + Trace []string `json:"trace,omitempty"` +} + +// ResponseMeta contains request metadata +type ResponseMeta struct { + RequestID string `json:"request_id"` + Timestamp string `json:"timestamp"` + Duration string `json:"duration,omitempty"` + Version string `json:"version"` +} + +// WriteJSON writes a success response as JSON +func WriteJSON(w http.ResponseWriter, statusCode int, data interface{}, meta *ResponseMeta) { + response := Response{ + Success: statusCode < 400, + Data: data, + Metadata: meta, + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + + if err := json.NewEncoder(w).Encode(response); err != nil { + // Fallback to simple error response + http.Error(w, `{"success":false,"error":{"code":"ENCODING_ERROR","message":"Failed to encode response"}}`, http.StatusInternalServerError) + } +} + +// WriteError writes an error response as JSON +func WriteError(w http.ResponseWriter, statusCode int, err *Error, meta *ResponseMeta) { + errResp := &ErrorResponse{ + Code: err.Code, + Message: err.Message, + Details: err.Details, + Trace: err.Trace, + } + + response := Response{ + Success: false, + Error: errResp, + Metadata: meta, + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + + if encErr := json.NewEncoder(w).Encode(response); encErr != nil { + // Fallback to simple error response + http.Error(w, `{"success":false,"error":{"code":"ENCODING_ERROR","message":"Failed to encode error response"}}`, http.StatusInternalServerError) + } +} + +// WriteErrorSimple writes an error without metadata +func WriteErrorSimple(w http.ResponseWriter, err *Error) { + statusCode := GetHTTPStatus(err.Code) + meta := &ResponseMeta{ + Timestamp: time.Now().UTC().Format(time.RFC3339), + } + WriteError(w, statusCode, err, meta) +} + +// WriteJSONSimple writes a success response without metadata +func WriteJSONSimple(w http.ResponseWriter, statusCode int, data interface{}) { + meta := &ResponseMeta{ + Timestamp: time.Now().UTC().Format(time.RFC3339), + } + WriteJSON(w, statusCode, data, meta) +} diff --git a/pkg/health/health.go b/pkg/health/health.go new file mode 100644 index 0000000..1504427 --- /dev/null +++ b/pkg/health/health.go @@ -0,0 +1,178 @@ +package health + +import ( + "context" + "net/http" + "sync" + "time" + + json "github.com/goccy/go-json" + "github.com/lukaszraczylo/gohoarder/internal/version" +) + +// Status represents component health status +type Status string + +const ( + StatusHealthy Status = "healthy" + StatusUnhealthy Status = "unhealthy" + StatusDegraded Status = "degraded" +) + +// Check represents a single health check +type Check struct { + Name string `json:"name"` + Status Status `json:"status"` + Error string `json:"error,omitempty"` + Fn func(context.Context) (Status, string) `json:"-"` +} + +// Response is the health check response +type Response struct { + Success bool `json:"success"` + Data *HealthData `json:"data,omitempty"` + Metadata *Metadata `json:"metadata,omitempty"` +} + +// HealthData contains health check data +type HealthData struct { + Status Status `json:"status"` + Version string `json:"version"` + Uptime string `json:"uptime"` + Components map[string]*Component `json:"components"` +} + +// Component represents a system component +type Component struct { + Status Status `json:"status"` + Details map[string]interface{} `json:"details,omitempty"` + Error string `json:"error,omitempty"` +} + +// Metadata contains response metadata +type Metadata struct { + RequestID string `json:"request_id"` + Timestamp string `json:"timestamp"` +} + +// Checker manages health checks +type Checker struct { + checks []*Check + startTime time.Time + mu sync.RWMutex +} + +// New creates a new health checker +func New() *Checker { + return &Checker{ + checks: make([]*Check, 0), + startTime: time.Now(), + } +} + +// AddCheck adds a health check +func (c *Checker) AddCheck(name string, fn func(context.Context) (Status, string)) { + c.mu.Lock() + defer c.mu.Unlock() + + c.checks = append(c.checks, &Check{ + Name: name, + Fn: fn, + }) +} + +// RunChecks runs all health checks +func (c *Checker) RunChecks(ctx context.Context) *HealthData { + c.mu.RLock() + checks := make([]*Check, len(c.checks)) + copy(checks, c.checks) + c.mu.RUnlock() + + components := make(map[string]*Component) + overallStatus := StatusHealthy + + for _, check := range checks { + status, errMsg := check.Fn(ctx) + components[check.Name] = &Component{ + Status: status, + Error: errMsg, + } + + // Determine overall status + if status == StatusUnhealthy { + overallStatus = StatusUnhealthy + } else if status == StatusDegraded && overallStatus == StatusHealthy { + overallStatus = StatusDegraded + } + } + + return &HealthData{ + Status: overallStatus, + Version: version.Version, + Uptime: time.Since(c.startTime).String(), + Components: components, + } +} + +// HealthHandler returns an HTTP handler for health checks +func (c *Checker) HealthHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + healthData := c.RunChecks(ctx) + + response := Response{ + Success: healthData.Status == StatusHealthy, + Data: healthData, + Metadata: &Metadata{ + RequestID: r.Header.Get("X-Request-ID"), + Timestamp: time.Now().UTC().Format(time.RFC3339), + }, + } + + statusCode := http.StatusOK + if healthData.Status == StatusUnhealthy { + statusCode = http.StatusServiceUnavailable + } else if healthData.Status == StatusDegraded { + statusCode = http.StatusOK // 200 but degraded + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + _ = json.NewEncoder(w).Encode(response) // #nosec G104 -- JSON response write + } +} + +// ReadyHandler returns an HTTP handler for readiness checks +func (c *Checker) ReadyHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + healthData := c.RunChecks(ctx) + + ready := healthData.Status != StatusUnhealthy + + response := Response{ + Success: ready, + Data: &HealthData{ + Status: healthData.Status, + Components: healthData.Components, + }, + Metadata: &Metadata{ + RequestID: r.Header.Get("X-Request-ID"), + Timestamp: time.Now().UTC().Format(time.RFC3339), + }, + } + + statusCode := http.StatusOK + if !ready { + statusCode = http.StatusServiceUnavailable + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + _ = json.NewEncoder(w).Encode(response) // #nosec G104 -- JSON response write + } +} diff --git a/pkg/lock/redis.go b/pkg/lock/redis.go new file mode 100644 index 0000000..ee13873 --- /dev/null +++ b/pkg/lock/redis.go @@ -0,0 +1,275 @@ +package lock + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "time" + + "github.com/redis/go-redis/v9" + "github.com/rs/zerolog/log" +) + +var ( + ErrLockNotAcquired = errors.New("lock not acquired") + ErrLockNotHeld = errors.New("lock not held by this instance") + ErrInvalidTTL = errors.New("invalid TTL: must be positive") +) + +// Lock represents a distributed lock +type Lock struct { + client *redis.Client + key string + value string + ttl time.Duration +} + +// Manager manages distributed locks using Redis +type Manager struct { + client *redis.Client +} + +// Config holds Redis connection configuration +type Config struct { + Addr string + Password string + DB int +} + +// NewManager creates a new lock manager +func NewManager(cfg Config) (*Manager, error) { + client := redis.NewClient(&redis.Options{ + Addr: cfg.Addr, + Password: cfg.Password, + DB: cfg.DB, + }) + + // Test connection + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := client.Ping(ctx).Err(); err != nil { + return nil, err + } + + log.Info(). + Str("addr", cfg.Addr). + Int("db", cfg.DB). + Msg("Connected to Redis for distributed locking") + + return &Manager{ + client: client, + }, nil +} + +// Acquire attempts to acquire a lock with the given key and TTL +// Returns a Lock instance if successful, or an error if the lock is already held +func (m *Manager) Acquire(ctx context.Context, key string, ttl time.Duration) (*Lock, error) { + if ttl <= 0 { + return nil, ErrInvalidTTL + } + + // Generate unique value for this lock instance + value, err := generateLockValue() + if err != nil { + return nil, err + } + + // Try to acquire lock using SET NX (set if not exists) + success, err := m.client.SetNX(ctx, key, value, ttl).Result() + if err != nil { + log.Error(). + Err(err). + Str("key", key). + Msg("Failed to acquire lock") + return nil, err + } + + if !success { + log.Debug(). + Str("key", key). + Msg("Lock already held by another instance") + return nil, ErrLockNotAcquired + } + + log.Debug(). + Str("key", key). + Dur("ttl", ttl). + Msg("Lock acquired successfully") + + return &Lock{ + client: m.client, + key: key, + value: value, + ttl: ttl, + }, nil +} + +// TryAcquire attempts to acquire a lock, retrying for the specified duration +// Returns a Lock instance if successful within the timeout, or an error +func (m *Manager) TryAcquire(ctx context.Context, key string, ttl, timeout time.Duration) (*Lock, error) { + if ttl <= 0 { + return nil, ErrInvalidTTL + } + + deadline := time.Now().Add(timeout) + ticker := time.NewTicker(50 * time.Millisecond) + defer ticker.Stop() + + for { + lock, err := m.Acquire(ctx, key, ttl) + if err == nil { + return lock, nil + } + + if err != ErrLockNotAcquired { + return nil, err + } + + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-ticker.C: + if time.Now().After(deadline) { + return nil, ErrLockNotAcquired + } + } + } +} + +// Release releases the lock +// Returns an error if the lock is not held by this instance +func (l *Lock) Release(ctx context.Context) error { + // Use Lua script to ensure atomic check-and-delete + // Only delete if the value matches (ensures we own the lock) + script := redis.NewScript(` + if redis.call("get", KEYS[1]) == ARGV[1] then + return redis.call("del", KEYS[1]) + else + return 0 + end + `) + + result, err := script.Run(ctx, l.client, []string{l.key}, l.value).Result() + if err != nil { + log.Error(). + Err(err). + Str("key", l.key). + Msg("Failed to release lock") + return err + } + + // Result of 0 means the lock was not deleted (not owned by us) + if result.(int64) == 0 { + log.Warn(). + Str("key", l.key). + Msg("Attempted to release lock not held by this instance") + return ErrLockNotHeld + } + + log.Debug(). + Str("key", l.key). + Msg("Lock released successfully") + + return nil +} + +// Extend extends the lock TTL +// Returns an error if the lock is not held by this instance +func (l *Lock) Extend(ctx context.Context, additionalTTL time.Duration) error { + // Use Lua script to ensure atomic check-and-extend + script := redis.NewScript(` + if redis.call("get", KEYS[1]) == ARGV[1] then + return redis.call("expire", KEYS[1], ARGV[2]) + else + return 0 + end + `) + + newTTL := l.ttl + additionalTTL + result, err := script.Run(ctx, l.client, []string{l.key}, l.value, int(newTTL.Seconds())).Result() + if err != nil { + log.Error(). + Err(err). + Str("key", l.key). + Msg("Failed to extend lock") + return err + } + + if result.(int64) == 0 { + log.Warn(). + Str("key", l.key). + Msg("Attempted to extend lock not held by this instance") + return ErrLockNotHeld + } + + l.ttl = newTTL + log.Debug(). + Str("key", l.key). + Dur("new_ttl", newTTL). + Msg("Lock TTL extended") + + return nil +} + +// IsHeld checks if the lock is still held by this instance +func (l *Lock) IsHeld(ctx context.Context) bool { + value, err := l.client.Get(ctx, l.key).Result() + if err != nil { + return false + } + return value == l.value +} + +// Close closes the lock manager and its Redis connection +func (m *Manager) Close() error { + return m.client.Close() // #nosec G104 -- Cleanup, error not critical +} + +// generateLockValue generates a cryptographically random lock value +func generateLockValue() (string, error) { + bytes := make([]byte, 16) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + return hex.EncodeToString(bytes), nil +} + +// WithLock executes a function while holding a distributed lock +// The lock is automatically released when the function returns +func (m *Manager) WithLock(ctx context.Context, key string, ttl time.Duration, fn func(context.Context) error) error { + lock, err := m.Acquire(ctx, key, ttl) + if err != nil { + return err + } + defer func() { + if err := lock.Release(context.Background()); err != nil { + log.Error(). + Err(err). + Str("key", key). + Msg("Failed to release lock in defer") + } + }() + + return fn(ctx) +} + +// WithRetryLock executes a function while holding a distributed lock +// It retries acquisition for the specified timeout duration +func (m *Manager) WithRetryLock(ctx context.Context, key string, ttl, timeout time.Duration, fn func(context.Context) error) error { + lock, err := m.TryAcquire(ctx, key, ttl, timeout) + if err != nil { + return err + } + defer func() { + if err := lock.Release(context.Background()); err != nil { + log.Error(). + Err(err). + Str("key", key). + Msg("Failed to release lock in defer") + } + }() + + return fn(ctx) +} diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go new file mode 100644 index 0000000..fb1a73e --- /dev/null +++ b/pkg/logger/logger.go @@ -0,0 +1,57 @@ +package logger + +import ( + "os" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" +) + +// Config contains logger configuration +type Config struct { + Level string // debug, info, warn, error + Format string // json, pretty +} + +// Init initializes the global logger +func Init(cfg Config) error { + // Set log level + level, err := zerolog.ParseLevel(cfg.Level) + if err != nil { + level = zerolog.InfoLevel + } + zerolog.SetGlobalLevel(level) + + // Set time format + zerolog.TimeFieldFormat = zerolog.TimeFormatUnixMs + + // Set format + if cfg.Format == "pretty" { + log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: "15:04:05.000"}) + } else { + // JSON format (default for production) + log.Logger = zerolog.New(os.Stdout).With().Timestamp().Logger() + } + + return nil +} + +// Get returns the global logger +func Get() *zerolog.Logger { + return &log.Logger +} + +// WithFields returns a logger with additional fields +func WithFields(fields map[string]interface{}) *zerolog.Logger { + logger := log.Logger + for k, v := range fields { + logger = logger.With().Interface(k, v).Logger() + } + return &logger +} + +// WithRequestID returns a logger with request ID +func WithRequestID(requestID string) *zerolog.Logger { + logger := log.With().Str("request_id", requestID).Logger() + return &logger +} diff --git a/pkg/logger/middleware.go b/pkg/logger/middleware.go new file mode 100644 index 0000000..f7a5e1d --- /dev/null +++ b/pkg/logger/middleware.go @@ -0,0 +1,65 @@ +package logger + +import ( + "net/http" + "time" + + "github.com/lukaszraczylo/gohoarder/pkg/uuid" + "github.com/rs/zerolog/log" +) + +// responseWriter wraps http.ResponseWriter to capture status code +type responseWriter struct { + http.ResponseWriter + statusCode int + written int64 +} + +func (rw *responseWriter) WriteHeader(code int) { + rw.statusCode = code + rw.ResponseWriter.WriteHeader(code) +} + +func (rw *responseWriter) Write(b []byte) (int, error) { + n, err := rw.ResponseWriter.Write(b) + rw.written += int64(n) + return n, err +} + +// Middleware is HTTP middleware for request logging +func Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + + // Generate request ID + requestID := r.Header.Get("X-Request-ID") + if requestID == "" { + requestID = "req_" + uuid.New().String()[:8] + } + + // Wrap response writer + rw := &responseWriter{ + ResponseWriter: w, + statusCode: http.StatusOK, + } + + // Set request ID in response header + rw.Header().Set("X-Request-ID", requestID) + + // Call next handler + next.ServeHTTP(rw, r) + + // Log request + duration := time.Since(start) + log.Info(). + Str("request_id", requestID). + Str("method", r.Method). + Str("path", r.URL.Path). + Str("remote_addr", r.RemoteAddr). + Str("user_agent", r.UserAgent()). + Int("status", rw.statusCode). + Int64("bytes", rw.written). + Dur("duration_ms", duration). + Msg("HTTP request") + }) +} diff --git a/pkg/metadata/file/file.go b/pkg/metadata/file/file.go new file mode 100644 index 0000000..9d46ed7 --- /dev/null +++ b/pkg/metadata/file/file.go @@ -0,0 +1,546 @@ +package file + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + "time" + + "github.com/lukaszraczylo/gohoarder/pkg/metadata" + "github.com/rs/zerolog/log" +) + +// Store implements a file-based metadata store +type Store struct { + basePath string + mu sync.RWMutex +} + +// Config holds file store configuration +type Config struct { + Path string +} + +// New creates a new file-based metadata store +func New(cfg Config) (*Store, error) { + if cfg.Path == "" { + cfg.Path = "./metadata" + } + + // Create directory if it doesn't exist + if err := os.MkdirAll(cfg.Path, 0750); err != nil { + return nil, fmt.Errorf("failed to create metadata directory: %w", err) + } + + log.Info(). + Str("path", cfg.Path). + Msg("File-based metadata store initialized") + + return &Store{ + basePath: cfg.Path, + }, nil +} + +// SavePackage saves package metadata +func (s *Store) SavePackage(ctx context.Context, pkg *metadata.Package) error { + s.mu.Lock() + defer s.mu.Unlock() + + // Create registry directory + regDir := filepath.Join(s.basePath, pkg.Registry) + if err := os.MkdirAll(regDir, 0750); err != nil { + return err + } + + // Save to file + filename := filepath.Join(regDir, fmt.Sprintf("%s-%s.json", pkg.Name, pkg.Version)) + data, err := json.MarshalIndent(pkg, "", " ") + if err != nil { + return err + } + + return os.WriteFile(filename, data, 0600) +} + +// GetPackage retrieves package metadata +func (s *Store) GetPackage(ctx context.Context, registry, name, version string) (*metadata.Package, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + filename := filepath.Join(s.basePath, registry, fmt.Sprintf("%s-%s.json", name, version)) + data, err := os.ReadFile(filename) // #nosec G304 -- Filename is from internal registry structure + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + + var pkg metadata.Package + if err := json.Unmarshal(data, &pkg); err != nil { + return nil, err + } + + return &pkg, nil +} + +// ListPackages lists all packages +func (s *Store) ListPackages(ctx context.Context, opts *metadata.ListOptions) ([]*metadata.Package, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + var packages []*metadata.Package + + // Walk through all files + err := filepath.Walk(s.basePath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + if info.IsDir() || filepath.Ext(path) != ".json" { + return nil + } + + data, err := os.ReadFile(path) // #nosec G304 -- Path from internal file structure + if err != nil { + return nil // Skip files we can't read + } + + var pkg metadata.Package + if err := json.Unmarshal(data, &pkg); err != nil { + return nil // Skip invalid JSON + } + + packages = append(packages, &pkg) + return nil + }) + + if err != nil { + return nil, err + } + + // Apply pagination if options provided + if opts != nil { + if opts.Offset >= len(packages) { + return []*metadata.Package{}, nil + } + + end := opts.Offset + opts.Limit + if end > len(packages) { + end = len(packages) + } + + return packages[opts.Offset:end], nil + } + + return packages, nil +} + +// DeletePackage deletes package metadata +func (s *Store) DeletePackage(ctx context.Context, registry, name, version string) error { + s.mu.Lock() + defer s.mu.Unlock() + + filename := filepath.Join(s.basePath, registry, fmt.Sprintf("%s-%s.json", name, version)) + if err := os.Remove(filename); err != nil && !os.IsNotExist(err) { + return err + } + + return nil +} + +// SaveScanResult saves scan result +func (s *Store) SaveScanResult(ctx context.Context, result *metadata.ScanResult) error { + s.mu.Lock() + defer s.mu.Unlock() + + // Create scans directory + scanDir := filepath.Join(s.basePath, "scans", result.Registry, result.PackageName) + if err := os.MkdirAll(scanDir, 0750); err != nil { + return err + } + + // Save to file with timestamp + timestamp := time.Now().Unix() + filename := filepath.Join(scanDir, fmt.Sprintf("%s-%d.json", result.PackageVersion, timestamp)) + data, err := json.MarshalIndent(result, "", " ") + if err != nil { + return err + } + + return os.WriteFile(filename, data, 0600) +} + +// UpdateDownloadCount increments download counter +func (s *Store) UpdateDownloadCount(ctx context.Context, registry, name, version string) error { + s.mu.Lock() + defer s.mu.Unlock() + + // Load package + pkg, err := s.GetPackage(ctx, registry, name, version) + if err != nil || pkg == nil { + return err + } + + // Increment counter + pkg.DownloadCount++ + pkg.LastAccessed = time.Now() + + // Save back + return s.SavePackage(ctx, pkg) +} + +// GetStats returns statistics for a registry +func (s *Store) GetStats(ctx context.Context, registry string) (*metadata.Stats, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + stats := &metadata.Stats{ + Registry: registry, + LastUpdated: time.Now(), + } + + // Walk through files and calculate stats + err := filepath.Walk(s.basePath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + if info.IsDir() || filepath.Ext(path) != ".json" { + return nil + } + + data, err := os.ReadFile(path) // #nosec G304 -- Path from internal file structure + if err != nil { + return nil + } + + var pkg metadata.Package + if err := json.Unmarshal(data, &pkg); err != nil { + return nil + } + + // Filter by registry if specified + if registry != "" && pkg.Registry != registry { + return nil + } + + stats.TotalPackages++ + stats.TotalSize += pkg.Size + stats.TotalDownloads += pkg.DownloadCount + + if pkg.SecurityScanned { + stats.ScannedPackages++ + } + + return nil + }) + + if err != nil { + return nil, err + } + + return stats, nil +} + +// GetScanResult retrieves latest scan result +func (s *Store) GetScanResult(ctx context.Context, registry, name, version string) (*metadata.ScanResult, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + scanDir := filepath.Join(s.basePath, "scans", registry, name) + pattern := filepath.Join(scanDir, fmt.Sprintf("%s-*.json", version)) + + matches, err := filepath.Glob(pattern) + if err != nil { + return nil, err + } + + if len(matches) == 0 { + return nil, nil + } + + // Get the latest file + latestFile := matches[len(matches)-1] + data, err := os.ReadFile(latestFile) // #nosec G304 -- Path from glob match on internal structure + if err != nil { + return nil, err + } + + var result metadata.ScanResult + if err := json.Unmarshal(data, &result); err != nil { + return nil, err + } + + return &result, nil +} + +// Count returns total number of packages +func (s *Store) Count(ctx context.Context) (int, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + count := 0 + err := filepath.Walk(s.basePath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + if !info.IsDir() && filepath.Ext(path) == ".json" && filepath.Dir(path) != filepath.Join(s.basePath, "scans") { + count++ + } + + return nil + }) + + if err != nil { + return 0, err + } + + return count, nil +} + +// Health checks if the store is healthy +func (s *Store) Health(ctx context.Context) error { + // Check if directory is accessible + _, err := os.Stat(s.basePath) + return err +} + +// SaveCVEBypass saves a CVE bypass (admin only) +func (s *Store) SaveCVEBypass(ctx context.Context, bypass *metadata.CVEBypass) error { + s.mu.Lock() + defer s.mu.Unlock() + + // Create bypasses directory + bypassesDir := filepath.Join(s.basePath, "bypasses") + if err := os.MkdirAll(bypassesDir, 0750); err != nil { + return err + } + + // Save to file + filename := filepath.Join(bypassesDir, fmt.Sprintf("%s.json", bypass.ID)) + data, err := json.MarshalIndent(bypass, "", " ") + if err != nil { + return err + } + + return os.WriteFile(filename, data, 0600) +} + +// GetActiveCVEBypasses retrieves all active (non-expired) CVE bypasses +func (s *Store) GetActiveCVEBypasses(ctx context.Context) ([]*metadata.CVEBypass, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + bypassesDir := filepath.Join(s.basePath, "bypasses") + var bypasses []*metadata.CVEBypass + now := time.Now() + + // Read all bypass files + err := filepath.Walk(bypassesDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + if os.IsNotExist(err) { + return nil // bypasses directory doesn't exist yet + } + return err + } + + if info.IsDir() || filepath.Ext(path) != ".json" { + return nil + } + + data, err := os.ReadFile(path) // #nosec G304 -- Path from internal file structure + if err != nil { + return err + } + + var bypass metadata.CVEBypass + if err := json.Unmarshal(data, &bypass); err != nil { + log.Warn().Err(err).Str("file", path).Msg("Failed to unmarshal bypass") + return nil + } + + // Only include active and non-expired bypasses + if bypass.Active && bypass.ExpiresAt.After(now) { + bypasses = append(bypasses, &bypass) + } + + return nil + }) + + if err != nil { + return nil, err + } + + return bypasses, nil +} + +// ListCVEBypasses lists all CVE bypasses (including expired) +func (s *Store) ListCVEBypasses(ctx context.Context, opts *metadata.BypassListOptions) ([]*metadata.CVEBypass, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + bypassesDir := filepath.Join(s.basePath, "bypasses") + var bypasses []*metadata.CVEBypass + now := time.Now() + + // Read all bypass files + err := filepath.Walk(bypassesDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + if os.IsNotExist(err) { + return nil // bypasses directory doesn't exist yet + } + return err + } + + if info.IsDir() || filepath.Ext(path) != ".json" { + return nil + } + + data, err := os.ReadFile(path) // #nosec G304 -- Path from internal file structure + if err != nil { + return err + } + + var bypass metadata.CVEBypass + if err := json.Unmarshal(data, &bypass); err != nil { + log.Warn().Err(err).Str("file", path).Msg("Failed to unmarshal bypass") + return nil + } + + // Apply filters if options provided + if opts != nil { + if opts.Type != "" && bypass.Type != opts.Type { + return nil + } + + if !opts.IncludeExpired && bypass.ExpiresAt.Before(now) { + return nil + } + + if opts.ActiveOnly && !bypass.Active { + return nil + } + } + + bypasses = append(bypasses, &bypass) + + return nil + }) + + if err != nil { + return nil, err + } + + // Apply limit and offset if specified + if opts != nil { + if opts.Offset > 0 && opts.Offset < len(bypasses) { + bypasses = bypasses[opts.Offset:] + } else if opts.Offset >= len(bypasses) { + return []*metadata.CVEBypass{}, nil + } + + if opts.Limit > 0 && opts.Limit < len(bypasses) { + bypasses = bypasses[:opts.Limit] + } + } + + return bypasses, nil +} + +// DeleteCVEBypass deletes a CVE bypass by ID +func (s *Store) DeleteCVEBypass(ctx context.Context, id string) error { + s.mu.Lock() + defer s.mu.Unlock() + + filename := filepath.Join(s.basePath, "bypasses", fmt.Sprintf("%s.json", id)) + err := os.Remove(filename) + if err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("CVE bypass not found: %s", id) + } + return err + } + + return nil +} + +// CleanupExpiredBypasses removes expired bypasses +func (s *Store) CleanupExpiredBypasses(ctx context.Context) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + + bypassesDir := filepath.Join(s.basePath, "bypasses") + count := 0 + now := time.Now() + + // Read all bypass files + err := filepath.Walk(bypassesDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + if os.IsNotExist(err) { + return nil // bypasses directory doesn't exist yet + } + return err + } + + if info.IsDir() || filepath.Ext(path) != ".json" { + return nil + } + + data, err := os.ReadFile(path) // #nosec G304 -- Path from internal file structure + if err != nil { + return err + } + + var bypass metadata.CVEBypass + if err := json.Unmarshal(data, &bypass); err != nil { + log.Warn().Err(err).Str("file", path).Msg("Failed to unmarshal bypass") + return nil + } + + // Delete if expired + if bypass.ExpiresAt.Before(now) { + if err := os.Remove(path); err != nil { + log.Warn().Err(err).Str("file", path).Msg("Failed to delete expired bypass") + } else { + count++ + } + } + + return nil + }) + + if err != nil { + return 0, err + } + + return count, nil +} + +// GetTimeSeriesStats returns time-series download statistics +// File-based store doesn't support time-series statistics +func (s *Store) GetTimeSeriesStats(ctx context.Context, period string, registry string) (*metadata.TimeSeriesStats, error) { + // Return empty time-series data for file-based store + return &metadata.TimeSeriesStats{ + Period: period, + Registry: registry, + DataPoints: []*metadata.TimeSeriesDataPoint{}, + }, nil +} + +// AggregateDownloadData aggregates download data +// File-based store doesn't support aggregation +func (s *Store) AggregateDownloadData(ctx context.Context) error { + // No-op for file-based store + return nil +} + +// Close closes the store +func (s *Store) Close() error { + // Nothing to close for file-based store + return nil +} diff --git a/pkg/metadata/interface.go b/pkg/metadata/interface.go new file mode 100644 index 0000000..95aa6da --- /dev/null +++ b/pkg/metadata/interface.go @@ -0,0 +1,211 @@ +package metadata + +import ( + "context" + "strings" + "time" +) + +// Store is an alias for MetadataStore for convenience +type Store = MetadataStore + +// MetadataStore defines the interface for package metadata storage +type MetadataStore interface { + // SavePackage saves package metadata + SavePackage(ctx context.Context, pkg *Package) error + + // GetPackage retrieves package metadata + GetPackage(ctx context.Context, registry, name, version string) (*Package, error) + + // DeletePackage deletes package metadata + DeletePackage(ctx context.Context, registry, name, version string) error + + // ListPackages lists packages with optional filtering + ListPackages(ctx context.Context, opts *ListOptions) ([]*Package, error) + + // UpdateDownloadCount increments download counter + UpdateDownloadCount(ctx context.Context, registry, name, version string) error + + // GetStats returns statistics + GetStats(ctx context.Context, registry string) (*Stats, error) + + // SaveScanResult saves security scan result + SaveScanResult(ctx context.Context, result *ScanResult) error + + // GetScanResult retrieves security scan result + GetScanResult(ctx context.Context, registry, name, version string) (*ScanResult, error) + + // SaveCVEBypass saves a CVE bypass (admin only) + SaveCVEBypass(ctx context.Context, bypass *CVEBypass) error + + // GetActiveCVEBypasses retrieves all active (non-expired) CVE bypasses + GetActiveCVEBypasses(ctx context.Context) ([]*CVEBypass, error) + + // ListCVEBypasses lists all CVE bypasses (including expired) + ListCVEBypasses(ctx context.Context, opts *BypassListOptions) ([]*CVEBypass, error) + + // DeleteCVEBypass deletes a CVE bypass by ID + DeleteCVEBypass(ctx context.Context, id string) error + + // CleanupExpiredBypasses removes expired bypasses + CleanupExpiredBypasses(ctx context.Context) (int, error) + + // Count returns total number of packages + Count(ctx context.Context) (int, error) + + // Health checks metadata store health + Health(ctx context.Context) error + + // GetTimeSeriesStats returns time-series download statistics + GetTimeSeriesStats(ctx context.Context, period string, registry string) (*TimeSeriesStats, error) + + // AggregateDownloadData aggregates raw download events and cleans up old data + AggregateDownloadData(ctx context.Context) error + + // Close closes the metadata store + Close() error +} + +// Package represents package metadata +type Package struct { + ID string `json:"id"` + Registry string `json:"registry"` // npm, pypi, go + Name string `json:"name"` // Package name + Version string `json:"version"` // Package version + StorageKey string `json:"storage_key"` // Key in storage backend + Size int64 `json:"size"` // Package size in bytes + ChecksumMD5 string `json:"checksum_md5"` // MD5 checksum + ChecksumSHA256 string `json:"checksum_sha256"` // SHA256 checksum + UpstreamURL string `json:"upstream_url"` // Original upstream URL + CachedAt time.Time `json:"cached_at"` // When cached + LastAccessed time.Time `json:"last_accessed"` // Last access time + ExpiresAt *time.Time `json:"expires_at"` // Expiration time (nil = never) + DownloadCount int64 `json:"download_count"` // Download counter + Metadata map[string]string `json:"metadata"` // Additional metadata + SecurityScanned bool `json:"security_scanned"` // Has been scanned + RequiresAuth bool `json:"requires_auth"` // Package requires authentication + AuthProvider string `json:"auth_provider"` // Auth provider (github.com, npm.pkg.github.com, etc.) +} + +// ScanResult represents a security scan result +type ScanResult struct { + ID string `json:"id"` + Registry string `json:"registry"` + PackageName string `json:"package_name"` + PackageVersion string `json:"package_version"` + Scanner string `json:"scanner"` // trivy, osv, etc. + ScannedAt time.Time `json:"scanned_at"` + Status ScanStatus `json:"status"` // clean, vulnerable, error + VulnerabilityCount int `json:"vulnerability_count"` + Vulnerabilities []Vulnerability `json:"vulnerabilities"` + Details map[string]interface{} `json:"details"` // Scanner-specific details +} + +// Vulnerability represents a security vulnerability +type Vulnerability struct { + ID string `json:"id"` // CVE-xxx, GHSA-xxx, etc. + Severity string `json:"severity"` // critical, high, moderate, low + Title string `json:"title"` + Description string `json:"description"` + References []string `json:"references"` + FixedIn string `json:"fixed_in"` // Version where fixed + DetectedBy []string `json:"detected_by,omitempty"` // List of scanners that detected this vulnerability +} + +// NormalizeSeverity normalizes severity names to standard values +// Ensures consistent naming: CRITICAL, HIGH, MODERATE, LOW +func NormalizeSeverity(severity string) string { + normalized := strings.ToUpper(strings.TrimSpace(severity)) + + // Map MEDIUM to MODERATE for consistency + if normalized == "MEDIUM" { + return "MODERATE" + } + + // Ensure we only return valid severity levels + switch normalized { + case "CRITICAL", "HIGH", "MODERATE", "LOW": + return normalized + default: + return "LOW" // Default unknown severities to LOW + } +} + +// ScanStatus represents scan result status +type ScanStatus string + +const ( + ScanStatusClean ScanStatus = "clean" + ScanStatusVulnerable ScanStatus = "vulnerable" + ScanStatusError ScanStatus = "error" + ScanStatusPending ScanStatus = "pending" +) + +// Stats represents metadata statistics +type Stats struct { + Registry string `json:"registry"` + TotalPackages int64 `json:"total_packages"` + TotalSize int64 `json:"total_size"` + TotalDownloads int64 `json:"total_downloads"` + ScannedPackages int64 `json:"scanned_packages"` + VulnerablePackages int64 `json:"vulnerable_packages"` + LastUpdated time.Time `json:"last_updated"` +} + +// TimeSeriesDataPoint represents a single data point in time-series +type TimeSeriesDataPoint struct { + Timestamp time.Time `json:"timestamp"` + Value int64 `json:"value"` +} + +// TimeSeriesStats represents time-series download statistics +type TimeSeriesStats struct { + Period string `json:"period"` // 1h, 1day, 7day, 30day + Registry string `json:"registry"` // empty string for all registries + DataPoints []*TimeSeriesDataPoint `json:"data_points"` +} + +// CVEBypass represents a temporary bypass for a CVE or package +type CVEBypass struct { + ID string `json:"id"` // Unique bypass ID + Type BypassType `json:"type"` // cve, package + Target string `json:"target"` // CVE ID (e.g., "CVE-2021-23337") or package (e.g., "npm/lodash@4.17.20") + Reason string `json:"reason"` // Why this bypass was created + CreatedBy string `json:"created_by"` // Admin user who created it + CreatedAt time.Time `json:"created_at"` // When created + ExpiresAt time.Time `json:"expires_at"` // When it expires + AppliesTo string `json:"applies_to,omitempty"` // Optional: limit to specific package (for CVE bypasses) + NotifyOnExpiry bool `json:"notify_on_expiry"` // Send notification when expired + Active bool `json:"active"` // Can be deactivated without deletion +} + +// BypassType represents the type of bypass +type BypassType string + +const ( + BypassTypeCVE BypassType = "cve" // Bypass specific CVE + BypassTypePackage BypassType = "package" // Bypass entire package +) + +// BypassListOptions contains options for listing CVE bypasses +type BypassListOptions struct { + Type BypassType // Filter by type + IncludeExpired bool // Include expired bypasses + ActiveOnly bool // Only active bypasses + Limit int // Max results + Offset int // Pagination offset +} + +// ListOptions contains options for listing packages +type ListOptions struct { + Registry string // Filter by registry + NamePrefix string // Filter by name prefix + MinSize int64 // Minimum package size + MaxSize int64 // Maximum package size + ScannedOnly bool // Only scanned packages + SinceDate time.Time // Packages cached since date + Limit int // Max results + Offset int // Pagination offset + SortBy string // Sort field (name, size, cached_at, download_count) + SortDesc bool // Sort descending +} diff --git a/pkg/metadata/sqlite/sqlite.go b/pkg/metadata/sqlite/sqlite.go new file mode 100644 index 0000000..d746727 --- /dev/null +++ b/pkg/metadata/sqlite/sqlite.go @@ -0,0 +1,1089 @@ +package sqlite + +import ( + "context" + "database/sql" + "fmt" + "strings" + "sync" + "time" + + goccy_json "github.com/goccy/go-json" + _ "modernc.org/sqlite" + + "github.com/lukaszraczylo/gohoarder/pkg/errors" + "github.com/lukaszraczylo/gohoarder/pkg/metadata" + "github.com/rs/zerolog/log" +) + +// SQLiteStore implements metadata.MetadataStore using SQLite +type SQLiteStore struct { + db *sql.DB + mu sync.RWMutex +} + +// Config holds SQLite configuration +type Config struct { + Path string // Database file path + MaxOpenConns int // Maximum open connections + MaxIdleConns int // Maximum idle connections +} + +const schema = ` +CREATE TABLE IF NOT EXISTS packages ( + id TEXT PRIMARY KEY, + registry TEXT NOT NULL, + name TEXT NOT NULL, + version TEXT NOT NULL, + storage_key TEXT NOT NULL, + size INTEGER NOT NULL, + checksum_md5 TEXT, + checksum_sha256 TEXT, + upstream_url TEXT, + cached_at DATETIME NOT NULL, + last_accessed DATETIME NOT NULL, + expires_at DATETIME, + download_count INTEGER DEFAULT 0, + metadata TEXT, + security_scanned BOOLEAN DEFAULT 0, + requires_auth BOOLEAN DEFAULT 0, + auth_provider TEXT, + UNIQUE(registry, name, version) +); + +CREATE INDEX IF NOT EXISTS idx_packages_registry ON packages(registry); +CREATE INDEX IF NOT EXISTS idx_packages_name ON packages(name); +CREATE INDEX IF NOT EXISTS idx_packages_cached_at ON packages(cached_at); +CREATE INDEX IF NOT EXISTS idx_packages_last_accessed ON packages(last_accessed); +CREATE INDEX IF NOT EXISTS idx_packages_expires_at ON packages(expires_at); + +CREATE TABLE IF NOT EXISTS scan_results ( + id TEXT PRIMARY KEY, + registry TEXT NOT NULL, + package_name TEXT NOT NULL, + package_version TEXT NOT NULL, + scanner TEXT NOT NULL, + scanned_at DATETIME NOT NULL, + status TEXT NOT NULL, + vulnerability_count INTEGER DEFAULT 0, + vulnerabilities TEXT, + details TEXT, + UNIQUE(registry, package_name, package_version, scanner) +); + +CREATE INDEX IF NOT EXISTS idx_scan_results_registry ON scan_results(registry); +CREATE INDEX IF NOT EXISTS idx_scan_results_package ON scan_results(package_name); +CREATE INDEX IF NOT EXISTS idx_scan_results_status ON scan_results(status); + +CREATE TABLE IF NOT EXISTS cve_bypasses ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL, + target TEXT NOT NULL, + reason TEXT NOT NULL, + created_by TEXT NOT NULL, + created_at DATETIME NOT NULL, + expires_at DATETIME NOT NULL, + applies_to TEXT, + notify_on_expiry BOOLEAN DEFAULT 0, + active BOOLEAN DEFAULT 1 +); + +CREATE INDEX IF NOT EXISTS idx_cve_bypasses_type ON cve_bypasses(type); +CREATE INDEX IF NOT EXISTS idx_cve_bypasses_target ON cve_bypasses(target); +CREATE INDEX IF NOT EXISTS idx_cve_bypasses_expires_at ON cve_bypasses(expires_at); +CREATE INDEX IF NOT EXISTS idx_cve_bypasses_active ON cve_bypasses(active); + +CREATE TABLE IF NOT EXISTS download_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + registry TEXT NOT NULL, + package_name TEXT NOT NULL, + package_version TEXT NOT NULL, + downloaded_at DATETIME NOT NULL, + FOREIGN KEY(registry, package_name, package_version) REFERENCES packages(registry, name, version) +); + +CREATE INDEX IF NOT EXISTS idx_download_events_registry ON download_events(registry); +CREATE INDEX IF NOT EXISTS idx_download_events_downloaded_at ON download_events(downloaded_at); +CREATE INDEX IF NOT EXISTS idx_download_events_package ON download_events(registry, package_name, package_version); + +CREATE TABLE IF NOT EXISTS aggregated_download_stats ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + registry TEXT NOT NULL, + time_bucket DATETIME NOT NULL, + resolution TEXT NOT NULL, + download_count INTEGER NOT NULL, + UNIQUE(registry, time_bucket, resolution) +); + +CREATE INDEX IF NOT EXISTS idx_aggregated_stats_registry ON aggregated_download_stats(registry); +CREATE INDEX IF NOT EXISTS idx_aggregated_stats_time_bucket ON aggregated_download_stats(time_bucket); +CREATE INDEX IF NOT EXISTS idx_aggregated_stats_resolution ON aggregated_download_stats(resolution); +` + +// New creates a new SQLite metadata store +func New(cfg Config) (*SQLiteStore, error) { + if cfg.Path == "" { + return nil, errors.New(errors.ErrCodeInvalidConfig, "SQLite database path is required") + } + + if cfg.MaxOpenConns == 0 { + cfg.MaxOpenConns = 10 + } + + if cfg.MaxIdleConns == 0 { + cfg.MaxIdleConns = 5 + } + + // Open database with WAL mode for better concurrency + dsn := fmt.Sprintf("%s?_journal_mode=WAL&_busy_timeout=5000&_synchronous=NORMAL&_cache_size=2000", cfg.Path) + db, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to open SQLite database") + } + + db.SetMaxOpenConns(cfg.MaxOpenConns) + db.SetMaxIdleConns(cfg.MaxIdleConns) + db.SetConnMaxLifetime(time.Hour) + + // Create schema + if _, err := db.Exec(schema); err != nil { + db.Close() // #nosec G104 -- Cleanup, error not critical + return nil, errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to create SQLite schema") + } + + // Run migrations for existing databases + if err := runMigrations(db); err != nil { + db.Close() // #nosec G104 -- Cleanup, error not critical + return nil, errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to run database migrations") + } + + return &SQLiteStore{ + db: db, + }, nil +} + +// runMigrations runs database migrations for existing databases +func runMigrations(db *sql.DB) error { + // Migration 1: Add requires_auth and auth_provider columns (if they don't exist) + // SQLite doesn't have IF NOT EXISTS for ALTER TABLE, so we need to check first + var columnExists int + err := db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('packages') WHERE name='requires_auth'").Scan(&columnExists) + if err != nil { + return err + } + + if columnExists == 0 { + log.Info().Msg("Running migration: adding requires_auth and auth_provider columns") + + // Add requires_auth column + if _, err := db.Exec("ALTER TABLE packages ADD COLUMN requires_auth BOOLEAN DEFAULT 0"); err != nil { + return fmt.Errorf("failed to add requires_auth column: %w", err) + } + + // Add auth_provider column + if _, err := db.Exec("ALTER TABLE packages ADD COLUMN auth_provider TEXT"); err != nil { + return fmt.Errorf("failed to add auth_provider column: %w", err) + } + + // Create index + if _, err := db.Exec("CREATE INDEX IF NOT EXISTS idx_packages_requires_auth ON packages(requires_auth)"); err != nil { + return fmt.Errorf("failed to create requires_auth index: %w", err) + } + + log.Info().Msg("Migration completed successfully") + } + + return nil +} + +// SavePackage saves package metadata +func (s *SQLiteStore) SavePackage(ctx context.Context, pkg *metadata.Package) error { + s.mu.Lock() + defer s.mu.Unlock() + + // Serialize metadata + metadataJSON, err := goccy_json.Marshal(pkg.Metadata) + if err != nil { + return errors.Wrap(err, errors.ErrCodeInternalServer, "failed to serialize package metadata") + } + + var expiresAt interface{} + if pkg.ExpiresAt != nil { + expiresAt = pkg.ExpiresAt + } + + query := ` + INSERT INTO packages ( + id, registry, name, version, storage_key, size, + checksum_md5, checksum_sha256, upstream_url, + cached_at, last_accessed, expires_at, download_count, + metadata, security_scanned, requires_auth, auth_provider + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(registry, name, version) DO UPDATE SET + storage_key = excluded.storage_key, + size = excluded.size, + checksum_md5 = excluded.checksum_md5, + checksum_sha256 = excluded.checksum_sha256, + upstream_url = excluded.upstream_url, + last_accessed = excluded.last_accessed, + expires_at = excluded.expires_at, + metadata = excluded.metadata, + security_scanned = excluded.security_scanned, + requires_auth = excluded.requires_auth, + auth_provider = excluded.auth_provider + ` + + _, err = s.db.ExecContext(ctx, query, + pkg.ID, pkg.Registry, pkg.Name, pkg.Version, pkg.StorageKey, pkg.Size, + pkg.ChecksumMD5, pkg.ChecksumSHA256, pkg.UpstreamURL, + pkg.CachedAt, pkg.LastAccessed, expiresAt, pkg.DownloadCount, + string(metadataJSON), pkg.SecurityScanned, pkg.RequiresAuth, pkg.AuthProvider, + ) + + if err != nil { + return errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to save package metadata") + } + + return nil +} + +// GetPackage retrieves package metadata +func (s *SQLiteStore) GetPackage(ctx context.Context, registry, name, version string) (*metadata.Package, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + query := ` + SELECT id, registry, name, version, storage_key, size, + checksum_md5, checksum_sha256, upstream_url, + cached_at, last_accessed, expires_at, download_count, + metadata, security_scanned, requires_auth, auth_provider + FROM packages + WHERE registry = ? AND name = ? AND version = ? + ` + + var pkg metadata.Package + var metadataJSON string + var expiresAt sql.NullTime + var authProvider sql.NullString + + err := s.db.QueryRowContext(ctx, query, registry, name, version).Scan( + &pkg.ID, &pkg.Registry, &pkg.Name, &pkg.Version, &pkg.StorageKey, &pkg.Size, + &pkg.ChecksumMD5, &pkg.ChecksumSHA256, &pkg.UpstreamURL, + &pkg.CachedAt, &pkg.LastAccessed, &expiresAt, &pkg.DownloadCount, + &metadataJSON, &pkg.SecurityScanned, &pkg.RequiresAuth, &authProvider, + ) + + if err == sql.ErrNoRows { + return nil, errors.NotFound(fmt.Sprintf("package not found: %s/%s@%s", registry, name, version)) + } + + if err != nil { + return nil, errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to get package metadata") + } + + if expiresAt.Valid { + pkg.ExpiresAt = &expiresAt.Time + } + + if authProvider.Valid { + pkg.AuthProvider = authProvider.String + } + + // Deserialize metadata + if metadataJSON != "" { + if err := goccy_json.Unmarshal([]byte(metadataJSON), &pkg.Metadata); err != nil { + log.Warn().Err(err).Msg("Failed to deserialize package metadata") + } + } + + return &pkg, nil +} + +// DeletePackage deletes package metadata +func (s *SQLiteStore) DeletePackage(ctx context.Context, registry, name, version string) error { + s.mu.Lock() + defer s.mu.Unlock() + + query := "DELETE FROM packages WHERE registry = ? AND name = ? AND version = ?" + result, err := s.db.ExecContext(ctx, query, registry, name, version) + if err != nil { + return errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to delete package metadata") + } + + rows, _ := result.RowsAffected() + if rows == 0 { + return errors.NotFound(fmt.Sprintf("package not found: %s/%s@%s", registry, name, version)) + } + + return nil +} + +// ListPackages lists packages with optional filtering +func (s *SQLiteStore) ListPackages(ctx context.Context, opts *metadata.ListOptions) ([]*metadata.Package, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + query := "SELECT id, registry, name, version, storage_key, size, checksum_md5, checksum_sha256, upstream_url, cached_at, last_accessed, expires_at, download_count, metadata, security_scanned FROM packages WHERE 1=1" + args := []interface{}{} + + if opts != nil { + if opts.Registry != "" { + query += " AND registry = ?" + args = append(args, opts.Registry) + } + + if opts.NamePrefix != "" { + query += " AND name LIKE ?" + args = append(args, opts.NamePrefix+"%") + } + + if opts.MinSize > 0 { + query += " AND size >= ?" + args = append(args, opts.MinSize) + } + + if opts.MaxSize > 0 { + query += " AND size <= ?" + args = append(args, opts.MaxSize) + } + + if opts.ScannedOnly { + query += " AND security_scanned = 1" + } + + if !opts.SinceDate.IsZero() { + query += " AND cached_at >= ?" + args = append(args, opts.SinceDate) + } + + // Sorting + sortBy := "cached_at" + if opts.SortBy != "" { + sortBy = opts.SortBy + } + sortOrder := "ASC" + if opts.SortDesc { + sortOrder = "DESC" + } + query += fmt.Sprintf(" ORDER BY %s %s", sortBy, sortOrder) + + // Pagination + if opts.Limit > 0 { + query += " LIMIT ?" + args = append(args, opts.Limit) + } + + if opts.Offset > 0 { + query += " OFFSET ?" + args = append(args, opts.Offset) + } + } + + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to list packages") + } + defer rows.Close() // #nosec G104 -- Cleanup, error not critical + + var packages []*metadata.Package + for rows.Next() { + var pkg metadata.Package + var metadataJSON string + var expiresAt sql.NullTime + + err := rows.Scan( + &pkg.ID, &pkg.Registry, &pkg.Name, &pkg.Version, &pkg.StorageKey, &pkg.Size, + &pkg.ChecksumMD5, &pkg.ChecksumSHA256, &pkg.UpstreamURL, + &pkg.CachedAt, &pkg.LastAccessed, &expiresAt, &pkg.DownloadCount, + &metadataJSON, &pkg.SecurityScanned, + ) + + if err != nil { + return nil, errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to scan package row") + } + + if expiresAt.Valid { + pkg.ExpiresAt = &expiresAt.Time + } + + if metadataJSON != "" { + _ = goccy_json.Unmarshal([]byte(metadataJSON), &pkg.Metadata) // #nosec G104 -- Best-effort unmarshal + } + + packages = append(packages, &pkg) + } + + return packages, nil +} + +// UpdateDownloadCount increments download counter and records download event +func (s *SQLiteStore) UpdateDownloadCount(ctx context.Context, registry, name, version string) error { + s.mu.Lock() + defer s.mu.Unlock() + + now := time.Now() + + // Start transaction + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to start transaction") + } + defer tx.Rollback() + + // Update download count + updateQuery := ` + UPDATE packages + SET download_count = download_count + 1, + last_accessed = ? + WHERE registry = ? AND name = ? AND version = ? + ` + _, err = tx.ExecContext(ctx, updateQuery, now, registry, name, version) + if err != nil { + return errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to update download count") + } + + // Record download event for time-series statistics + insertQuery := ` + INSERT INTO download_events (registry, package_name, package_version, downloaded_at) + VALUES (?, ?, ?, ?) + ` + _, err = tx.ExecContext(ctx, insertQuery, registry, name, version, now) + if err != nil { + return errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to record download event") + } + + // Commit transaction + if err := tx.Commit(); err != nil { + return errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to commit transaction") + } + + return nil +} + +// GetStats returns statistics +func (s *SQLiteStore) GetStats(ctx context.Context, registry string) (*metadata.Stats, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + query := ` + SELECT + COUNT(*) as total_packages, + COALESCE(SUM(size), 0) as total_size, + COALESCE(SUM(download_count), 0) as total_downloads, + COALESCE(SUM(CASE WHEN security_scanned = 1 THEN 1 ELSE 0 END), 0) as scanned_packages + FROM packages + WHERE version NOT IN ('list', 'latest', 'metadata', 'page') + ` + + args := []interface{}{} + if registry != "" { + query += " AND registry = ?" + args = append(args, registry) + } + + var stats metadata.Stats + stats.Registry = registry + stats.LastUpdated = time.Now() + + err := s.db.QueryRowContext(ctx, query, args...).Scan( + &stats.TotalPackages, + &stats.TotalSize, + &stats.TotalDownloads, + &stats.ScannedPackages, + ) + + if err != nil { + return nil, errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to get stats") + } + + // Count vulnerable packages + vulnQuery := `SELECT COUNT(*) FROM scan_results WHERE status = 'vulnerable'` + vulnArgs := []interface{}{} + if registry != "" { + vulnQuery += " AND registry = ?" + vulnArgs = append(vulnArgs, registry) + } + + _ = s.db.QueryRowContext(ctx, vulnQuery, vulnArgs...).Scan(&stats.VulnerablePackages) // #nosec G104 -- Optional query + + return &stats, nil +} + +// GetTimeSeriesStats returns time-series download statistics +// Uses different data sources based on period for efficiency: +// - 1h: raw download_events (last hour only) +// - 1day: hourly aggregates +// - 7day, 30day: daily aggregates +func (s *SQLiteStore) GetTimeSeriesStats(ctx context.Context, period string, registry string) (*metadata.TimeSeriesStats, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + var ( + timeFormat string + startTime time.Time + bucketCount int + useRawEvents bool + useResolution string + ) + + now := time.Now() + + // Determine time range, bucket size, and data source based on period + switch period { + case "1h": + startTime = now.Add(-1 * time.Hour) + timeFormat = "%Y-%m-%d %H:%M:00" // 5-minute buckets + bucketCount = 12 // 12 x 5min = 60min + useRawEvents = true // Use raw events for last hour + case "1day": + startTime = now.Add(-24 * time.Hour) + timeFormat = "%Y-%m-%d %H:00:00" // hourly buckets + bucketCount = 24 + useResolution = "hourly" // Use hourly aggregates + case "7day": + startTime = now.Add(-7 * 24 * time.Hour) + timeFormat = "%Y-%m-%d 00:00:00" // daily buckets + bucketCount = 7 + useResolution = "daily" // Use daily aggregates + case "30day": + startTime = now.Add(-30 * 24 * time.Hour) + timeFormat = "%Y-%m-%d 00:00:00" // daily buckets + bucketCount = 30 + useResolution = "daily" // Use daily aggregates + default: + return nil, errors.New(errors.ErrCodeBadRequest, "invalid period, must be one of: 1h, 1day, 7day, 30day") + } + + var query string + var args []interface{} + + if useRawEvents { + // Query raw download_events for 1h period + query = ` + SELECT + strftime(?, downloaded_at) as time_bucket, + COUNT(*) as download_count + FROM download_events + WHERE downloaded_at >= ? + AND downloaded_at IS NOT NULL + ` + args = []interface{}{timeFormat, startTime} + + if registry != "" { + query += " AND registry = ?" + args = append(args, registry) + } + + query += ` + GROUP BY time_bucket + HAVING time_bucket IS NOT NULL + ORDER BY time_bucket ASC + ` + } else { + // Query aggregated_download_stats for longer periods + query = ` + SELECT + time_bucket, + SUM(download_count) as download_count + FROM aggregated_download_stats + WHERE resolution = ? + AND time_bucket >= ? + AND time_bucket IS NOT NULL + ` + args = []interface{}{useResolution, startTime} + + if registry != "" { + query += " AND registry = ?" + args = append(args, registry) + } + + query += ` + GROUP BY time_bucket + ORDER BY time_bucket ASC + ` + } + + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to query time-series stats") + } + defer rows.Close() // #nosec G104 -- Cleanup, error not critical + + // Collect data points + dataMap := make(map[string]int64) + for rows.Next() { + var bucket sql.NullString + var count int64 + if err := rows.Scan(&bucket, &count); err != nil { + return nil, errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to scan time-series data") + } + // Skip NULL buckets (shouldn't happen with NOT NULL constraint, but defensive) + if bucket.Valid && bucket.String != "" { + dataMap[bucket.String] = count + } + } + + if err := rows.Err(); err != nil { + return nil, errors.Wrap(err, errors.ErrCodeStorageFailure, "error iterating time-series data") + } + + // Create complete data points array with zeros for missing buckets + dataPoints := make([]*metadata.TimeSeriesDataPoint, 0, bucketCount) + + // Generate all expected buckets + currentTime := startTime + var increment time.Duration + switch period { + case "1h": + increment = 5 * time.Minute + case "1day": + increment = time.Hour + case "7day", "30day": + increment = 24 * time.Hour + } + + for i := 0; i < bucketCount; i++ { + var bucket string + if useRawEvents { + bucket = currentTime.Format(convertGoTimeFormat(timeFormat)) + } else { + // For aggregated data, time_bucket is already in the right format + bucket = currentTime.Format("2006-01-02 15:04:05") + } + count := dataMap[bucket] + + dataPoints = append(dataPoints, &metadata.TimeSeriesDataPoint{ + Timestamp: currentTime, + Value: count, + }) + + currentTime = currentTime.Add(increment) + } + + return &metadata.TimeSeriesStats{ + Period: period, + Registry: registry, + DataPoints: dataPoints, + }, nil +} + +// convertGoTimeFormat converts SQLite strftime format to Go time format +func convertGoTimeFormat(sqliteFormat string) string { + // SQLite strftime to Go time.Format mapping + format := sqliteFormat + format = strings.ReplaceAll(format, "%Y", "2006") + format = strings.ReplaceAll(format, "%m", "01") + format = strings.ReplaceAll(format, "%d", "02") + format = strings.ReplaceAll(format, "%H", "15") + format = strings.ReplaceAll(format, "%M", "04") + format = strings.ReplaceAll(format, "%S", "05") + return format +} + +// AggregateDownloadData aggregates raw download events into hourly/daily buckets and cleans up old data +// This should be called periodically (e.g., every hour) as a background job +func (s *SQLiteStore) AggregateDownloadData(ctx context.Context) error { + s.mu.Lock() + defer s.mu.Unlock() + + log.Info().Msg("Starting download data aggregation") + + // Start transaction + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to start aggregation transaction") + } + defer tx.Rollback() + + now := time.Now() + oneHourAgo := now.Add(-1 * time.Hour) + oneDayAgo := now.Add(-24 * time.Hour) + + // Step 1: Aggregate raw events older than 1 hour into hourly buckets + // Group by registry and hour, then insert into aggregated_download_stats + hourlyAggQuery := ` + INSERT OR REPLACE INTO aggregated_download_stats (registry, time_bucket, resolution, download_count) + SELECT + registry, + strftime('%Y-%m-%d %H:00:00', downloaded_at) as time_bucket, + 'hourly' as resolution, + COUNT(*) as download_count + FROM download_events + WHERE downloaded_at < ? + AND downloaded_at IS NOT NULL + GROUP BY registry, time_bucket + HAVING time_bucket IS NOT NULL + ` + _, err = tx.ExecContext(ctx, hourlyAggQuery, oneHourAgo) + if err != nil { + log.Error().Err(err).Msg("Failed to aggregate hourly data") + return errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to aggregate hourly download data") + } + + // Step 2: Delete raw events older than 1 hour (they're now aggregated) + deleteRawQuery := `DELETE FROM download_events WHERE downloaded_at < ?` + result, err := tx.ExecContext(ctx, deleteRawQuery, oneHourAgo) + if err != nil { + log.Error().Err(err).Msg("Failed to delete old raw events") + return errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to delete old download events") + } + rawDeleted, _ := result.RowsAffected() + + // Step 3: Aggregate hourly stats older than 24 hours into daily buckets + dailyAggQuery := ` + INSERT OR REPLACE INTO aggregated_download_stats (registry, time_bucket, resolution, download_count) + SELECT + registry, + strftime('%Y-%m-%d 00:00:00', time_bucket) as time_bucket, + 'daily' as resolution, + SUM(download_count) as download_count + FROM aggregated_download_stats + WHERE resolution = 'hourly' + AND time_bucket < ? + AND time_bucket IS NOT NULL + GROUP BY registry, strftime('%Y-%m-%d 00:00:00', time_bucket) + HAVING time_bucket IS NOT NULL + ` + _, err = tx.ExecContext(ctx, dailyAggQuery, oneDayAgo) + if err != nil { + log.Error().Err(err).Msg("Failed to aggregate daily data") + return errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to aggregate daily download data") + } + + // Step 4: Delete hourly stats older than 24 hours (they're now aggregated into daily) + deleteHourlyQuery := `DELETE FROM aggregated_download_stats WHERE resolution = 'hourly' AND time_bucket < ?` + result, err = tx.ExecContext(ctx, deleteHourlyQuery, oneDayAgo) + if err != nil { + log.Error().Err(err).Msg("Failed to delete old hourly aggregates") + return errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to delete old hourly aggregates") + } + hourlyDeleted, _ := result.RowsAffected() + + // Commit transaction + if err := tx.Commit(); err != nil { + return errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to commit aggregation transaction") + } + + log.Info(). + Int64("raw_events_deleted", rawDeleted). + Int64("hourly_aggregates_deleted", hourlyDeleted). + Msg("Download data aggregation completed successfully") + + return nil +} + +// SaveScanResult saves security scan result +func (s *SQLiteStore) SaveScanResult(ctx context.Context, result *metadata.ScanResult) error { + s.mu.Lock() + defer s.mu.Unlock() + + // Serialize vulnerabilities and details + vulnJSON, err := goccy_json.Marshal(result.Vulnerabilities) + if err != nil { + return errors.Wrap(err, errors.ErrCodeInternalServer, "failed to serialize vulnerabilities") + } + + detailsJSON, err := goccy_json.Marshal(result.Details) + if err != nil { + return errors.Wrap(err, errors.ErrCodeInternalServer, "failed to serialize scan details") + } + + query := ` + INSERT INTO scan_results ( + id, registry, package_name, package_version, scanner, + scanned_at, status, vulnerability_count, vulnerabilities, details + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(registry, package_name, package_version, scanner) DO UPDATE SET + scanned_at = excluded.scanned_at, + status = excluded.status, + vulnerability_count = excluded.vulnerability_count, + vulnerabilities = excluded.vulnerabilities, + details = excluded.details + ` + + _, err = s.db.ExecContext(ctx, query, + result.ID, result.Registry, result.PackageName, result.PackageVersion, result.Scanner, + result.ScannedAt, result.Status, result.VulnerabilityCount, + string(vulnJSON), string(detailsJSON), + ) + + if err != nil { + return errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to save scan result") + } + + // Update package security_scanned flag + updateQuery := `UPDATE packages SET security_scanned = 1 WHERE registry = ? AND name = ? AND version = ?` + updateResult, err := s.db.ExecContext(ctx, updateQuery, result.Registry, result.PackageName, result.PackageVersion) + if err != nil { + log.Warn(). + Err(err). + Str("registry", result.Registry). + Str("package", result.PackageName). + Str("version", result.PackageVersion). + Msg("Failed to update security_scanned flag") + // Don't return error - scan result is already saved + } else { + rowsAffected, _ := updateResult.RowsAffected() + if rowsAffected == 0 { + log.Warn(). + Str("registry", result.Registry). + Str("package", result.PackageName). + Str("version", result.PackageVersion). + Msg("Package not found when updating security_scanned flag - possibly name mismatch") + } + } + + return nil +} + +// GetScanResult retrieves security scan result +func (s *SQLiteStore) GetScanResult(ctx context.Context, registry, name, version string) (*metadata.ScanResult, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + query := ` + SELECT id, registry, package_name, package_version, scanner, + scanned_at, status, vulnerability_count, vulnerabilities, details + FROM scan_results + WHERE registry = ? AND package_name = ? AND package_version = ? + ORDER BY scanned_at DESC + LIMIT 1 + ` + + var result metadata.ScanResult + var vulnJSON, detailsJSON string + + err := s.db.QueryRowContext(ctx, query, registry, name, version).Scan( + &result.ID, &result.Registry, &result.PackageName, &result.PackageVersion, &result.Scanner, + &result.ScannedAt, &result.Status, &result.VulnerabilityCount, + &vulnJSON, &detailsJSON, + ) + + if err == sql.ErrNoRows { + return nil, errors.NotFound(fmt.Sprintf("scan result not found: %s/%s@%s", registry, name, version)) + } + + if err != nil { + return nil, errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to get scan result") + } + + // Deserialize + if vulnJSON != "" { + _ = goccy_json.Unmarshal([]byte(vulnJSON), &result.Vulnerabilities) // #nosec G104 -- Best-effort unmarshal + } + + if detailsJSON != "" { + _ = goccy_json.Unmarshal([]byte(detailsJSON), &result.Details) // #nosec G104 -- Best-effort unmarshal + } + + return &result, nil +} + +// Count returns total number of packages +func (s *SQLiteStore) Count(ctx context.Context) (int, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + var count int + query := "SELECT COUNT(*) FROM packages" + + err := s.db.QueryRowContext(ctx, query).Scan(&count) + if err != nil { + return 0, errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to count packages") + } + + return count, nil +} + +// Health checks metadata store health +func (s *SQLiteStore) Health(ctx context.Context) error { + return s.db.PingContext(ctx) +} + +// SaveCVEBypass saves a CVE bypass (admin only) +func (s *SQLiteStore) SaveCVEBypass(ctx context.Context, bypass *metadata.CVEBypass) error { + s.mu.Lock() + defer s.mu.Unlock() + + query := ` + INSERT INTO cve_bypasses ( + id, type, target, reason, created_by, created_at, + expires_at, applies_to, notify_on_expiry, active + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + type = excluded.type, + target = excluded.target, + reason = excluded.reason, + expires_at = excluded.expires_at, + applies_to = excluded.applies_to, + notify_on_expiry = excluded.notify_on_expiry, + active = excluded.active + ` + + _, err := s.db.ExecContext(ctx, query, + bypass.ID, bypass.Type, bypass.Target, bypass.Reason, bypass.CreatedBy, + bypass.CreatedAt, bypass.ExpiresAt, bypass.AppliesTo, + bypass.NotifyOnExpiry, bypass.Active, + ) + + if err != nil { + return errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to save CVE bypass") + } + + return nil +} + +// GetActiveCVEBypasses retrieves all active (non-expired) CVE bypasses +func (s *SQLiteStore) GetActiveCVEBypasses(ctx context.Context) ([]*metadata.CVEBypass, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + query := ` + SELECT id, type, target, reason, created_by, created_at, + expires_at, applies_to, notify_on_expiry, active + FROM cve_bypasses + WHERE active = 1 AND expires_at > ? + ORDER BY created_at DESC + ` + + rows, err := s.db.QueryContext(ctx, query, time.Now()) + if err != nil { + return nil, errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to get active CVE bypasses") + } + defer rows.Close() // #nosec G104 -- Cleanup, error not critical + + var bypasses []*metadata.CVEBypass + for rows.Next() { + var bypass metadata.CVEBypass + var appliesTo sql.NullString + + err := rows.Scan( + &bypass.ID, &bypass.Type, &bypass.Target, &bypass.Reason, &bypass.CreatedBy, + &bypass.CreatedAt, &bypass.ExpiresAt, &appliesTo, + &bypass.NotifyOnExpiry, &bypass.Active, + ) + + if err != nil { + return nil, errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to scan CVE bypass row") + } + + if appliesTo.Valid { + bypass.AppliesTo = appliesTo.String + } + + bypasses = append(bypasses, &bypass) + } + + return bypasses, nil +} + +// ListCVEBypasses lists all CVE bypasses (including expired) +func (s *SQLiteStore) ListCVEBypasses(ctx context.Context, opts *metadata.BypassListOptions) ([]*metadata.CVEBypass, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + query := ` + SELECT id, type, target, reason, created_by, created_at, + expires_at, applies_to, notify_on_expiry, active + FROM cve_bypasses + WHERE 1=1 + ` + args := []interface{}{} + + if opts != nil { + if opts.Type != "" { + query += " AND type = ?" + args = append(args, opts.Type) + } + + if !opts.IncludeExpired { + query += " AND expires_at > ?" + args = append(args, time.Now()) + } + + if opts.ActiveOnly { + query += " AND active = 1" + } + + query += " ORDER BY created_at DESC" + + if opts.Limit > 0 { + query += " LIMIT ?" + args = append(args, opts.Limit) + } + + if opts.Offset > 0 { + query += " OFFSET ?" + args = append(args, opts.Offset) + } + } + + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to list CVE bypasses") + } + defer rows.Close() // #nosec G104 -- Cleanup, error not critical + + var bypasses []*metadata.CVEBypass + for rows.Next() { + var bypass metadata.CVEBypass + var appliesTo sql.NullString + + err := rows.Scan( + &bypass.ID, &bypass.Type, &bypass.Target, &bypass.Reason, &bypass.CreatedBy, + &bypass.CreatedAt, &bypass.ExpiresAt, &appliesTo, + &bypass.NotifyOnExpiry, &bypass.Active, + ) + + if err != nil { + return nil, errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to scan CVE bypass row") + } + + if appliesTo.Valid { + bypass.AppliesTo = appliesTo.String + } + + bypasses = append(bypasses, &bypass) + } + + return bypasses, nil +} + +// DeleteCVEBypass deletes a CVE bypass by ID +func (s *SQLiteStore) DeleteCVEBypass(ctx context.Context, id string) error { + s.mu.Lock() + defer s.mu.Unlock() + + query := "DELETE FROM cve_bypasses WHERE id = ?" + result, err := s.db.ExecContext(ctx, query, id) + if err != nil { + return errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to delete CVE bypass") + } + + rows, _ := result.RowsAffected() + if rows == 0 { + return errors.NotFound(fmt.Sprintf("CVE bypass not found: %s", id)) + } + + return nil +} + +// CleanupExpiredBypasses removes expired bypasses +func (s *SQLiteStore) CleanupExpiredBypasses(ctx context.Context) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + + query := "DELETE FROM cve_bypasses WHERE expires_at <= ?" + result, err := s.db.ExecContext(ctx, query, time.Now()) + if err != nil { + return 0, errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to cleanup expired CVE bypasses") + } + + rows, _ := result.RowsAffected() + return int(rows), nil +} + +// Close closes the metadata store +func (s *SQLiteStore) Close() error { + return s.db.Close() // #nosec G104 -- Cleanup, error not critical +} diff --git a/pkg/metrics/metrics.go b/pkg/metrics/metrics.go new file mode 100644 index 0000000..f4ed868 --- /dev/null +++ b/pkg/metrics/metrics.go @@ -0,0 +1,188 @@ +package metrics + +import ( + "net/http" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +var ( + // HTTP metrics + HTTPRequestsTotal = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "gohoarder_http_requests_total", + Help: "Total number of HTTP requests", + }, + []string{"handler", "method", "status"}, + ) + + HTTPRequestDuration = promauto.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "gohoarder_http_request_duration_seconds", + Help: "HTTP request duration in seconds", + Buckets: prometheus.DefBuckets, + }, + []string{"handler", "method"}, + ) + + // Cache metrics + CacheRequests = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "gohoarder_cache_requests_total", + Help: "Total number of cache requests", + }, + []string{"status", "handler"}, // hit, miss, error + ) + + CacheSizeBytes = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "gohoarder_cache_size_bytes", + Help: "Current cache size in bytes", + }, + []string{"backend"}, + ) + + CacheItemsTotal = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "gohoarder_cache_items_total", + Help: "Total number of cached items", + }, + []string{"handler"}, + ) + + CacheEvictions = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "gohoarder_cache_evictions_total", + Help: "Total number of cache evictions", + }, + []string{"reason"}, // ttl, lru, manual + ) + + // Storage metrics + StorageOperations = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "gohoarder_storage_operations_total", + Help: "Total number of storage operations", + }, + []string{"backend", "operation", "status"}, // get, put, delete + ) + + StorageQuotaBytes = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "gohoarder_storage_quota_bytes", + Help: "Storage quota in bytes per project", + }, + []string{"project"}, + ) + + // Upstream metrics + UpstreamRequests = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "gohoarder_upstream_requests_total", + Help: "Total number of upstream requests", + }, + []string{"registry", "status"}, + ) + + UpstreamDuration = promauto.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "gohoarder_upstream_duration_seconds", + Help: "Upstream request duration in seconds", + Buckets: prometheus.DefBuckets, + }, + []string{"registry"}, + ) + + // Security metrics + SecurityScans = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "gohoarder_security_scans_total", + Help: "Total number of security scans", + }, + []string{"scanner", "result"}, // clean, blocked, error + ) + + VulnerabilitiesFound = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "gohoarder_vulnerabilities_found_total", + Help: "Total number of vulnerabilities found", + }, + []string{"severity"}, // low, medium, high, critical + ) + + // Circuit breaker metrics + CircuitBreakerState = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "gohoarder_circuit_breaker_state", + Help: "Circuit breaker state (0=closed, 1=open, 2=half-open)", + }, + []string{"name"}, + ) +) + +// Handler returns the Prometheus HTTP handler +func Handler() http.Handler { + return promhttp.Handler() +} + +// RecordCacheHit records a cache hit +func RecordCacheHit(handler string) { + CacheRequests.WithLabelValues("hit", handler).Inc() +} + +// RecordCacheMiss records a cache miss +func RecordCacheMiss(handler string) { + CacheRequests.WithLabelValues("miss", handler).Inc() +} + +// RecordCacheError records a cache error +func RecordCacheError(handler string) { + CacheRequests.WithLabelValues("error", handler).Inc() +} + +// UpdateCacheSize updates the cache size metric +func UpdateCacheSize(backend string, bytes int64) { + CacheSizeBytes.WithLabelValues(backend).Set(float64(bytes)) +} + +// UpdateCacheItems updates the cache items metric +func UpdateCacheItems(handler string, count int64) { + CacheItemsTotal.WithLabelValues(handler).Set(float64(count)) +} + +// RecordCacheEviction records a cache eviction +func RecordCacheEviction(reason string) { + CacheEvictions.WithLabelValues(reason).Inc() +} + +// RecordStorageOperation records a storage operation +func RecordStorageOperation(backend, operation, status string) { + StorageOperations.WithLabelValues(backend, operation, status).Inc() +} + +// UpdateStorageQuota updates the storage quota metric +func UpdateStorageQuota(project string, bytes int64) { + StorageQuotaBytes.WithLabelValues(project).Set(float64(bytes)) +} + +// RecordUpstreamRequest records an upstream request +func RecordUpstreamRequest(registry, status string) { + UpstreamRequests.WithLabelValues(registry, status).Inc() +} + +// RecordSecurityScan records a security scan +func RecordSecurityScan(scanner, result string) { + SecurityScans.WithLabelValues(scanner, result).Inc() +} + +// RecordVulnerability records a vulnerability finding +func RecordVulnerability(severity string) { + VulnerabilitiesFound.WithLabelValues(severity).Inc() +} + +// UpdateCircuitBreakerState updates the circuit breaker state +func UpdateCircuitBreakerState(name string, state int) { + CircuitBreakerState.WithLabelValues(name).Set(float64(state)) +} diff --git a/pkg/network/client.go b/pkg/network/client.go new file mode 100644 index 0000000..2f68b45 --- /dev/null +++ b/pkg/network/client.go @@ -0,0 +1,360 @@ +package network + +import ( + "context" + "fmt" + "io" + "net/http" + "sync" + "time" + + "github.com/lukaszraczylo/gohoarder/pkg/errors" + "github.com/lukaszraczylo/gohoarder/pkg/metrics" + "github.com/rs/zerolog/log" + "golang.org/x/time/rate" +) + +// Client is an HTTP client with resilience features +type Client struct { + client *http.Client + rateLimiter *rate.Limiter + circuitBreaker *CircuitBreaker + retryConfig RetryConfig +} + +// Config holds client configuration +type Config struct { + Timeout time.Duration // Request timeout + MaxRetries int // Max retry attempts + RetryDelay time.Duration // Initial retry delay + RateLimit float64 // Requests per second (0 = unlimited) + RateBurst int // Rate limiter burst + CircuitBreaker CircuitBreakerConfig + UserAgent string + MaxConnsPerHost int +} + +// RetryConfig holds retry configuration +type RetryConfig struct { + MaxAttempts int + InitialDelay time.Duration + MaxDelay time.Duration + Multiplier float64 + FixedDelays []time.Duration // If set, use these delays instead of exponential backoff +} + +// CircuitBreakerConfig holds circuit breaker configuration +type CircuitBreakerConfig struct { + Enabled bool + FailureThreshold int // Failures before opening + SuccessThreshold int // Successes before closing + Timeout time.Duration // How long to stay open + HalfOpenMaxCalls int // Max calls in half-open state +} + +// CircuitBreakerState represents circuit breaker state +type CircuitBreakerState int + +const ( + StateClosed CircuitBreakerState = iota + StateOpen + StateHalfOpen +) + +// CircuitBreaker implements the circuit breaker pattern +type CircuitBreaker struct { + config CircuitBreakerConfig + state CircuitBreakerState + failures int + successes int + lastFailureTime time.Time + halfOpenCalls int + mu sync.RWMutex +} + +// NewClient creates a new HTTP client with resilience features +func NewClient(config Config) *Client { + if config.Timeout == 0 { + config.Timeout = 30 * time.Second + } + + if config.MaxRetries == 0 { + config.MaxRetries = 3 + } + + if config.RetryDelay == 0 { + config.RetryDelay = 1 * time.Second + } + + if config.UserAgent == "" { + config.UserAgent = "GoHoarder/1.0" + } + + if config.MaxConnsPerHost == 0 { + config.MaxConnsPerHost = 100 + } + + transport := &http.Transport{ + MaxIdleConns: 100, + MaxIdleConnsPerHost: config.MaxConnsPerHost, + MaxConnsPerHost: config.MaxConnsPerHost, + IdleConnTimeout: 90 * time.Second, + DisableCompression: false, + } + + httpClient := &http.Client{ + Timeout: config.Timeout, + Transport: transport, + } + + var rateLimiter *rate.Limiter + if config.RateLimit > 0 { + if config.RateBurst == 0 { + config.RateBurst = int(config.RateLimit) + } + rateLimiter = rate.NewLimiter(rate.Limit(config.RateLimit), config.RateBurst) + } + + var cb *CircuitBreaker + if config.CircuitBreaker.Enabled { + if config.CircuitBreaker.FailureThreshold == 0 { + config.CircuitBreaker.FailureThreshold = 5 + } + if config.CircuitBreaker.SuccessThreshold == 0 { + config.CircuitBreaker.SuccessThreshold = 2 + } + if config.CircuitBreaker.Timeout == 0 { + config.CircuitBreaker.Timeout = 60 * time.Second + } + if config.CircuitBreaker.HalfOpenMaxCalls == 0 { + config.CircuitBreaker.HalfOpenMaxCalls = 3 + } + + cb = &CircuitBreaker{ + config: config.CircuitBreaker, + state: StateClosed, + } + } + + return &Client{ + client: httpClient, + rateLimiter: rateLimiter, + circuitBreaker: cb, + retryConfig: RetryConfig{ + MaxAttempts: config.MaxRetries, + InitialDelay: config.RetryDelay, + MaxDelay: 30 * time.Second, + Multiplier: 2.0, + // Fixed delays: 1s, 5s, 10s for retry attempts 1, 2, 3 + FixedDelays: []time.Duration{1 * time.Second, 5 * time.Second, 10 * time.Second}, + }, + } +} + +// Get performs a GET request with resilience features +func (c *Client) Get(ctx context.Context, url string, headers map[string]string) (io.ReadCloser, int, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, 0, errors.Wrap(err, errors.ErrCodeUpstreamError, "failed to create request") + } + + for key, value := range headers { + req.Header.Set(key, value) + } + + resp, err := c.do(ctx, req) + if err != nil { + return nil, 0, err + } + + return resp.Body, resp.StatusCode, nil +} + +// do executes an HTTP request with retries and circuit breaker +func (c *Client) do(ctx context.Context, req *http.Request) (*http.Response, error) { + // Check circuit breaker + if c.circuitBreaker != nil { + if !c.circuitBreaker.AllowRequest() { + metrics.UpdateCircuitBreakerState("upstream", int(StateOpen)) + return nil, errors.New(errors.ErrCodeCircuitOpen, "circuit breaker is open") + } + } + + // Apply rate limiting + if c.rateLimiter != nil { + if err := c.rateLimiter.Wait(ctx); err != nil { + return nil, errors.Wrap(err, errors.ErrCodeRateLimited, "rate limit exceeded") + } + } + + // Execute with retries + var lastErr error + delay := c.retryConfig.InitialDelay + + for attempt := 0; attempt < c.retryConfig.MaxAttempts; attempt++ { + if attempt > 0 { + // Calculate delay: use fixed delays if configured, otherwise exponential backoff + if len(c.retryConfig.FixedDelays) > 0 { + // Use fixed delay schedule + delayIndex := attempt - 1 + if delayIndex < len(c.retryConfig.FixedDelays) { + delay = c.retryConfig.FixedDelays[delayIndex] + } else { + // Use last delay if we run out of configured delays + delay = c.retryConfig.FixedDelays[len(c.retryConfig.FixedDelays)-1] + } + } else { + // Exponential backoff + delay = time.Duration(float64(delay) * c.retryConfig.Multiplier) + if delay > c.retryConfig.MaxDelay { + delay = c.retryConfig.MaxDelay + } + } + + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(delay): + } + + log.Debug(). + Str("url", req.URL.String()). + Int("attempt", attempt+1). + Dur("delay", delay). + Msg("Retrying request") + } + + resp, err := c.client.Do(req) + if err != nil { + lastErr = err + if c.circuitBreaker != nil { + c.circuitBreaker.RecordFailure() + } + continue + } + + // Check if response is retryable + if c.isRetryable(resp.StatusCode) { + resp.Body.Close() // #nosec G104 -- Cleanup, error not critical + lastErr = fmt.Errorf("received retryable status code: %d", resp.StatusCode) + if c.circuitBreaker != nil { + c.circuitBreaker.RecordFailure() + } + continue + } + + // Success + if c.circuitBreaker != nil { + c.circuitBreaker.RecordSuccess() + metrics.UpdateCircuitBreakerState("upstream", int(StateClosed)) + } + + return resp, nil + } + + // All retries exhausted + if c.circuitBreaker != nil { + c.circuitBreaker.RecordFailure() + } + + if lastErr != nil { + return nil, errors.Wrap(lastErr, errors.ErrCodeUpstreamFailure, "all retry attempts failed") + } + + return nil, errors.New(errors.ErrCodeUpstreamFailure, "request failed without error") +} + +// isRetryable checks if a status code should trigger a retry +func (c *Client) isRetryable(statusCode int) bool { + // Retry on server errors and some client errors + return statusCode >= 500 || statusCode == 408 || statusCode == 429 +} + +// AllowRequest checks if a request is allowed by the circuit breaker +func (cb *CircuitBreaker) AllowRequest() bool { + cb.mu.Lock() + defer cb.mu.Unlock() + + switch cb.state { + case StateClosed: + return true + + case StateOpen: + // Check if timeout has elapsed + if time.Since(cb.lastFailureTime) > cb.config.Timeout { + cb.state = StateHalfOpen + cb.halfOpenCalls = 0 + cb.successes = 0 + log.Info().Msg("Circuit breaker transitioning to half-open") + metrics.UpdateCircuitBreakerState("upstream", int(StateHalfOpen)) + return true + } + return false + + case StateHalfOpen: + // Allow limited requests in half-open state + if cb.halfOpenCalls < cb.config.HalfOpenMaxCalls { + cb.halfOpenCalls++ + return true + } + return false + + default: + return false + } +} + +// RecordSuccess records a successful request +func (cb *CircuitBreaker) RecordSuccess() { + cb.mu.Lock() + defer cb.mu.Unlock() + + switch cb.state { + case StateClosed: + cb.failures = 0 + + case StateHalfOpen: + cb.successes++ + if cb.successes >= cb.config.SuccessThreshold { + cb.state = StateClosed + cb.failures = 0 + cb.successes = 0 + cb.halfOpenCalls = 0 + log.Info().Msg("Circuit breaker closed") + metrics.UpdateCircuitBreakerState("upstream", int(StateClosed)) + } + } +} + +// RecordFailure records a failed request +func (cb *CircuitBreaker) RecordFailure() { + cb.mu.Lock() + defer cb.mu.Unlock() + + cb.lastFailureTime = time.Now() + + switch cb.state { + case StateClosed: + cb.failures++ + if cb.failures >= cb.config.FailureThreshold { + cb.state = StateOpen + log.Warn().Int("failures", cb.failures).Msg("Circuit breaker opened") + metrics.UpdateCircuitBreakerState("upstream", int(StateOpen)) + } + + case StateHalfOpen: + // Single failure in half-open returns to open + cb.state = StateOpen + cb.halfOpenCalls = 0 + cb.successes = 0 + log.Warn().Msg("Circuit breaker re-opened from half-open") + metrics.UpdateCircuitBreakerState("upstream", int(StateOpen)) + } +} + +// GetState returns the current circuit breaker state +func (cb *CircuitBreaker) GetState() CircuitBreakerState { + cb.mu.RLock() + defer cb.mu.RUnlock() + return cb.state +} diff --git a/pkg/network/client_test.go b/pkg/network/client_test.go new file mode 100644 index 0000000..2860e95 --- /dev/null +++ b/pkg/network/client_test.go @@ -0,0 +1,407 @@ +package network_test + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/lukaszraczylo/gohoarder/pkg/network" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestClientGet tests the HTTP client Get method with various scenarios +func TestClientGet(t *testing.T) { + tests := []struct { + name string + serverBehavior func(*testing.T) *httptest.Server + config network.Config + headers map[string]string + wantErr bool + errContains string + validateBody func(*testing.T, io.ReadCloser) + validateStatus func(*testing.T, int) + }{ + // GOOD: Successful GET request + { + name: "successful get request returns body", + serverBehavior: func(t *testing.T) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("success")) // #nosec G104 -- Websocket buffer write + })) + }, + config: network.Config{ + Timeout: 5 * time.Second, + MaxRetries: 3, + }, + validateBody: func(t *testing.T, body io.ReadCloser) { + defer body.Close() // #nosec G104 -- Cleanup, error not critical + data, err := io.ReadAll(body) + require.NoError(t, err) + assert.Equal(t, "success", string(data)) + }, + validateStatus: func(t *testing.T, status int) { + assert.Equal(t, http.StatusOK, status) + }, + }, + // GOOD: Retry succeeds on second attempt + { + name: "retry succeeds after transient failure", + serverBehavior: func(t *testing.T) *httptest.Server { + var attemptCount int32 + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + count := atomic.AddInt32(&attemptCount, 1) + if count == 1 { + w.WriteHeader(http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("retry-success")) // #nosec G104 -- Websocket buffer write + })) + }, + config: network.Config{ + Timeout: 5 * time.Second, + MaxRetries: 3, + RetryDelay: 10 * time.Millisecond, + }, + validateBody: func(t *testing.T, body io.ReadCloser) { + defer body.Close() // #nosec G104 -- Cleanup, error not critical + data, err := io.ReadAll(body) + require.NoError(t, err) + assert.Equal(t, "retry-success", string(data)) + }, + validateStatus: func(t *testing.T, status int) { + assert.Equal(t, http.StatusOK, status) + }, + }, + // GOOD: Headers are properly sent + { + name: "custom headers are sent correctly", + serverBehavior: func(t *testing.T) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "application/json", r.Header.Get("Accept")) + assert.Equal(t, "Bearer token123", r.Header.Get("Authorization")) + w.WriteHeader(http.StatusOK) + })) + }, + config: network.Config{ + Timeout: 5 * time.Second, + MaxRetries: 1, + }, + headers: map[string]string{ + "Accept": "application/json", + "Authorization": "Bearer token123", + }, + validateStatus: func(t *testing.T, status int) { + assert.Equal(t, http.StatusOK, status) + }, + }, + // WRONG: Server returns 404 (non-retryable) + { + name: "404 error is not retried", + serverBehavior: func(t *testing.T) *httptest.Server { + var attemptCount int32 + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&attemptCount, 1) + w.WriteHeader(http.StatusNotFound) + })) + }, + config: network.Config{ + Timeout: 5 * time.Second, + MaxRetries: 3, + RetryDelay: 10 * time.Millisecond, + }, + validateStatus: func(t *testing.T, status int) { + assert.Equal(t, http.StatusNotFound, status) + }, + }, + // WRONG: Server returns 429 (rate limited - retryable) + { + name: "429 rate limit triggers retry with fixed delays", + serverBehavior: func(t *testing.T) *httptest.Server { + var attemptCount int32 + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + count := atomic.AddInt32(&attemptCount, 1) + if count <= 2 { + w.WriteHeader(http.StatusTooManyRequests) + return + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("success-after-rate-limit")) // #nosec G104 -- Websocket buffer write + })) + }, + config: network.Config{ + Timeout: 10 * time.Second, + MaxRetries: 3, + RetryDelay: 10 * time.Millisecond, + }, + validateBody: func(t *testing.T, body io.ReadCloser) { + defer body.Close() // #nosec G104 -- Cleanup, error not critical + data, err := io.ReadAll(body) + require.NoError(t, err) + assert.Equal(t, "success-after-rate-limit", string(data)) + }, + }, + // BAD: All retries exhausted + { + name: "all retries fail returns error", + serverBehavior: func(t *testing.T) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + }, + config: network.Config{ + Timeout: 5 * time.Second, + MaxRetries: 2, + RetryDelay: 10 * time.Millisecond, + }, + wantErr: true, + errContains: "retry attempts failed", + }, + // BAD: Server timeout + { + name: "server timeout returns error", + serverBehavior: func(t *testing.T) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(200 * time.Millisecond) + w.WriteHeader(http.StatusOK) + })) + }, + config: network.Config{ + Timeout: 50 * time.Millisecond, + MaxRetries: 1, + }, + wantErr: true, + errContains: "context deadline exceeded", + }, + // EDGE 1: Context timeout (deadline exceeded) + { + name: "context timeout stops retry", + serverBehavior: func(t *testing.T) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(100 * time.Millisecond) + w.WriteHeader(http.StatusInternalServerError) + })) + }, + config: network.Config{ + Timeout: 5 * time.Second, + MaxRetries: 5, + RetryDelay: 50 * time.Millisecond, + }, + wantErr: true, + errContains: "context deadline exceeded", + }, + // EDGE 2: Empty response body + { + name: "empty response body handled correctly", + serverBehavior: func(t *testing.T) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + }, + config: network.Config{ + Timeout: 5 * time.Second, + MaxRetries: 1, + }, + validateBody: func(t *testing.T, body io.ReadCloser) { + defer body.Close() // #nosec G104 -- Cleanup, error not critical + data, err := io.ReadAll(body) + require.NoError(t, err) + assert.Empty(t, data) + }, + }, + // EDGE 3: Large response body + { + name: "large response body handled correctly", + serverBehavior: func(t *testing.T) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + largeBody := strings.Repeat("a", 1024*1024) // 1MB + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(largeBody)) // #nosec G104 -- Websocket buffer write + })) + }, + config: network.Config{ + Timeout: 10 * time.Second, + MaxRetries: 1, + }, + validateBody: func(t *testing.T, body io.ReadCloser) { + defer body.Close() // #nosec G104 -- Cleanup, error not critical + data, err := io.ReadAll(body) + require.NoError(t, err) + assert.Len(t, data, 1024*1024) + }, + }, + // EDGE 4: Circuit breaker enabled + { + name: "circuit breaker opens after failures", + serverBehavior: func(t *testing.T) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + }, + config: network.Config{ + Timeout: 5 * time.Second, + MaxRetries: 2, + RetryDelay: 10 * time.Millisecond, + CircuitBreaker: network.CircuitBreakerConfig{ + Enabled: true, + FailureThreshold: 3, + SuccessThreshold: 2, + Timeout: 100 * time.Millisecond, + }, + }, + wantErr: true, + errContains: "retry attempts failed", + }, + // EDGE 5: Rate limiting enabled + { + name: "rate limiter throttles requests", + serverBehavior: func(t *testing.T) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + }, + config: network.Config{ + Timeout: 5 * time.Second, + MaxRetries: 1, + RateLimit: 10, // 10 req/sec + RateBurst: 1, + }, + validateStatus: func(t *testing.T, status int) { + assert.Equal(t, http.StatusOK, status) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Arrange + server := tt.serverBehavior(t) + defer server.Close() // #nosec G104 -- Cleanup, error not critical + + client := network.NewClient(tt.config) + ctx := context.Background() + + // For context timeout test + if strings.Contains(tt.name, "context timeout") { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, 100*time.Millisecond) + defer cancel() + } + + // Act + body, status, err := client.Get(ctx, server.URL, tt.headers) + + // Assert + if tt.wantErr { + require.Error(t, err) + if tt.errContains != "" { + assert.Contains(t, err.Error(), tt.errContains) + } + return + } + + require.NoError(t, err) + require.NotNil(t, body) + + if tt.validateBody != nil { + tt.validateBody(t, body) + } else { + body.Close() // #nosec G104 -- Cleanup, error not critical + } + + if tt.validateStatus != nil { + tt.validateStatus(t, status) + } + }) + } +} + +// TestRetryDelays verifies fixed retry delays are used correctly +func TestRetryDelays(t *testing.T) { + var attemptTimes []time.Time + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attemptTimes = append(attemptTimes, time.Now()) + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() // #nosec G104 -- Cleanup, error not critical + + client := network.NewClient(network.Config{ + Timeout: 10 * time.Second, + MaxRetries: 3, + RetryDelay: 100 * time.Millisecond, + }) + + ctx := context.Background() + _, _, err := client.Get(ctx, server.URL, nil) + + require.Error(t, err) + require.Len(t, attemptTimes, 3, "should have made exactly 3 attempts") + + // Verify delays are approximately 1s, 5s, 10s (with some tolerance) + // Note: The actual implementation uses fixed delays [1s, 5s, 10s] + // but for this test we're using RetryDelay as base which would be used + // if FixedDelays wasn't set +} + +// TestConcurrentRequests verifies the client is safe for concurrent use +func TestConcurrentRequests(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("concurrent-ok")) // #nosec G104 -- Websocket buffer write + })) + defer server.Close() // #nosec G104 -- Cleanup, error not critical + + client := network.NewClient(network.Config{ + Timeout: 5 * time.Second, + MaxRetries: 1, + }) + + const concurrent = 10 + errs := make(chan error, concurrent) + + // Launch concurrent requests + for i := 0; i < concurrent; i++ { + go func() { + ctx := context.Background() + body, status, err := client.Get(ctx, server.URL, nil) + if err != nil { + errs <- err + return + } + defer body.Close() // #nosec G104 -- Cleanup, error not critical + + if status != http.StatusOK { + errs <- fmt.Errorf("unexpected status: %d", status) + return + } + + data, err := io.ReadAll(body) + if err != nil { + errs <- err + return + } + + if string(data) != "concurrent-ok" { + errs <- fmt.Errorf("unexpected body: %s", data) + return + } + + errs <- nil + }() + } + + // Wait for all to complete + for i := 0; i < concurrent; i++ { + err := <-errs + assert.NoError(t, err) + } +} diff --git a/pkg/prewarming/worker.go b/pkg/prewarming/worker.go new file mode 100644 index 0000000..5f969e9 --- /dev/null +++ b/pkg/prewarming/worker.go @@ -0,0 +1,311 @@ +package prewarming + +import ( + "context" + "sync" + "time" + + "github.com/lukaszraczylo/gohoarder/pkg/analytics" + "github.com/lukaszraczylo/gohoarder/pkg/cache" + "github.com/lukaszraczylo/gohoarder/pkg/network" + "github.com/rs/zerolog/log" +) + +// PackageInfo represents a package to pre-warm +type PackageInfo struct { + Registry string + Name string + Version string + Priority int +} + +// Worker handles background pre-warming of popular packages +type Worker struct { + cache *cache.Manager + analytics *analytics.Engine + client *network.Client + interval time.Duration + maxConcurrent int + enabled bool + stopChan chan struct{} + wg sync.WaitGroup +} + +// Config holds pre-warming worker configuration +type Config struct { + Enabled bool + Interval time.Duration + MaxConcurrent int + TopPackages int + CacheManager *cache.Manager + Analytics *analytics.Engine + NetworkClient *network.Client +} + +// NewWorker creates a new pre-warming worker +func NewWorker(cfg Config) *Worker { + if cfg.Interval <= 0 { + cfg.Interval = 1 * time.Hour + } + if cfg.MaxConcurrent <= 0 { + cfg.MaxConcurrent = 5 + } + if cfg.TopPackages <= 0 { + cfg.TopPackages = 100 + } + + worker := &Worker{ + cache: cfg.CacheManager, + analytics: cfg.Analytics, + client: cfg.NetworkClient, + interval: cfg.Interval, + maxConcurrent: cfg.MaxConcurrent, + enabled: cfg.Enabled, + stopChan: make(chan struct{}), + } + + if cfg.Enabled { + log.Info(). + Dur("interval", cfg.Interval). + Int("max_concurrent", cfg.MaxConcurrent). + Msg("Pre-warming worker initialized") + } else { + log.Info().Msg("Pre-warming worker disabled") + } + + return worker +} + +// Start begins the pre-warming worker +func (w *Worker) Start(ctx context.Context) { + if !w.enabled { + log.Debug().Msg("Pre-warming worker is disabled, not starting") + return + } + + w.wg.Add(1) + go w.run(ctx) + log.Info().Msg("Pre-warming worker started") +} + +// run is the main worker loop +func (w *Worker) run(ctx context.Context) { + defer w.wg.Done() + + ticker := time.NewTicker(w.interval) + defer ticker.Stop() + + // Run immediately on start + w.prewarmPopularPackages(ctx) + + for { + select { + case <-ctx.Done(): + log.Info().Msg("Pre-warming worker stopping due to context cancellation") + return + case <-w.stopChan: + log.Info().Msg("Pre-warming worker stopped") + return + case <-ticker.C: + w.prewarmPopularPackages(ctx) + } + } +} + +// prewarmPopularPackages fetches and caches popular packages +func (w *Worker) prewarmPopularPackages(ctx context.Context) { + log.Info().Msg("Starting pre-warming cycle") + + // Get popular packages from analytics + popularPackages := w.analytics.GetTopPackages(100) + if len(popularPackages) == 0 { + log.Debug().Msg("No popular packages found for pre-warming") + return + } + + // Get trending packages for additional candidates + trendingPackages := w.analytics.GetTrendingPackages(50) + + // Combine and deduplicate + packages := w.combinePackages(popularPackages, trendingPackages) + + log.Info(). + Int("packages", len(packages)). + Msg("Identified packages for pre-warming") + + // Create work queue + workChan := make(chan PackageInfo, len(packages)) + for _, pkg := range packages { + workChan <- PackageInfo{ + Registry: pkg.Registry, + Name: pkg.Name, + Version: "latest", // Pre-warm latest version + Priority: int(pkg.Downloads), + } + } + close(workChan) + + // Start worker goroutines + var wg sync.WaitGroup + for i := 0; i < w.maxConcurrent; i++ { + wg.Add(1) + go func(workerID int) { + defer wg.Done() + w.processPackages(ctx, workerID, workChan) + }(i) + } + + wg.Wait() + log.Info().Msg("Pre-warming cycle completed") +} + +// processPackages processes packages from the work queue +func (w *Worker) processPackages(ctx context.Context, workerID int, workChan <-chan PackageInfo) { + for pkg := range workChan { + select { + case <-ctx.Done(): + return + default: + w.prewarmPackage(ctx, pkg, workerID) + } + } +} + +// prewarmPackage fetches and caches a single package +func (w *Worker) prewarmPackage(ctx context.Context, pkg PackageInfo, workerID int) { + log.Debug(). + Int("worker", workerID). + Str("registry", pkg.Registry). + Str("package", pkg.Name). + Str("version", pkg.Version). + Msg("Pre-warming package") + + // Build URL based on registry + url := w.buildPackageURL(pkg) + if url == "" { + log.Warn(). + Str("registry", pkg.Registry). + Str("package", pkg.Name). + Msg("Cannot build URL for registry") + return + } + + // Fetch package from upstream + reqCtx, cancel := context.WithTimeout(ctx, 5*time.Minute) + defer cancel() + + body, statusCode, err := w.client.Get(reqCtx, url, nil) + if err != nil { + log.Error(). + Err(err). + Str("package", pkg.Name). + Msg("Failed to fetch package for pre-warming") + return + } + defer body.Close() // #nosec G104 -- Cleanup, error not critical + + if statusCode != 200 { + log.Warn(). + Int("status", statusCode). + Str("package", pkg.Name). + Msg("Non-200 response for package") + return + } + + // Cache the package + // In a real implementation, this would read the response body and store it + log.Info(). + Str("package", pkg.Name). + Str("version", pkg.Version). + Msg("Successfully pre-warmed package") +} + +// buildPackageURL builds the upstream URL for a package +func (w *Worker) buildPackageURL(pkg PackageInfo) string { + // This is simplified - in reality, each registry has different URL patterns + switch pkg.Registry { + case "npm": + return "https://registry.npmjs.org/" + pkg.Name + case "pypi": + return "https://pypi.org/simple/" + pkg.Name + "/" + case "go": + // Go modules use different URL patterns + return "https://proxy.golang.org/" + pkg.Name + "/@latest" + default: + return "" + } +} + +// combinePackages merges popular and trending packages, removing duplicates +func (w *Worker) combinePackages(popular, trending []analytics.PopularPackage) []analytics.PopularPackage { + seen := make(map[string]bool) + result := make([]analytics.PopularPackage, 0, len(popular)+len(trending)) + + for _, pkg := range popular { + key := pkg.Registry + ":" + pkg.Name + if !seen[key] { + result = append(result, pkg) + seen[key] = true + } + } + + for _, pkg := range trending { + key := pkg.Registry + ":" + pkg.Name + if !seen[key] { + result = append(result, pkg) + seen[key] = true + } + } + + return result +} + +// Stop gracefully stops the pre-warming worker +func (w *Worker) Stop() { + if !w.enabled { + return + } + + log.Info().Msg("Stopping pre-warming worker") + close(w.stopChan) + w.wg.Wait() + log.Info().Msg("Pre-warming worker stopped") +} + +// TriggerPrewarm manually triggers a pre-warming cycle +func (w *Worker) TriggerPrewarm(ctx context.Context) { + if !w.enabled { + log.Warn().Msg("Cannot trigger pre-warm: worker is disabled") + return + } + + log.Info().Msg("Manual pre-warming triggered") + go w.prewarmPopularPackages(ctx) +} + +// PrewarmPackage pre-warms a specific package +func (w *Worker) PrewarmPackage(ctx context.Context, registry, name, version string) error { + if !w.enabled { + log.Warn().Msg("Pre-warming worker is disabled") + return nil + } + + pkg := PackageInfo{ + Registry: registry, + Name: name, + Version: version, + Priority: 100, + } + + w.prewarmPackage(ctx, pkg, 0) + return nil +} + +// GetStatus returns the current status of the pre-warming worker +func (w *Worker) GetStatus() map[string]interface{} { + return map[string]interface{}{ + "enabled": w.enabled, + "interval": w.interval.String(), + "max_concurrent": w.maxConcurrent, + } +} diff --git a/pkg/proxy/common/base.go b/pkg/proxy/common/base.go new file mode 100644 index 0000000..28ac205 --- /dev/null +++ b/pkg/proxy/common/base.go @@ -0,0 +1,34 @@ +package common + +import ( + "github.com/lukaszraczylo/gohoarder/pkg/cache" + "github.com/lukaszraczylo/gohoarder/pkg/network" +) + +// BaseHandler provides common functionality for all proxy handlers +type BaseHandler struct { + Cache *cache.Manager + Client *network.Client + Upstream string + Registry string +} + +// Config holds common proxy configuration +type Config struct { + Upstream string // Upstream registry URL (e.g., registry.npmjs.org) +} + +// GetRegistry returns the registry type +func (h *BaseHandler) GetRegistry() string { + return h.Registry +} + +// NewBaseHandler creates a new base handler with common fields +func NewBaseHandler(cache *cache.Manager, client *network.Client, registry, upstream string) *BaseHandler { + return &BaseHandler{ + Cache: cache, + Client: client, + Upstream: upstream, + Registry: registry, + } +} diff --git a/pkg/proxy/common/common_test.go b/pkg/proxy/common/common_test.go new file mode 100644 index 0000000..1dcd70c --- /dev/null +++ b/pkg/proxy/common/common_test.go @@ -0,0 +1,385 @@ +package common + +import ( + "bytes" + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/lukaszraczylo/gohoarder/pkg/cache" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestNewBaseHandler tests base handler creation +func TestNewBaseHandler(t *testing.T) { + // Use nil for cache and client since we're only testing structure + handler := NewBaseHandler(nil, nil, "npm", "https://registry.npmjs.org") + + require.NotNil(t, handler) + assert.Equal(t, "npm", handler.Registry) + assert.Equal(t, "https://registry.npmjs.org", handler.Upstream) + assert.Nil(t, handler.Cache) + assert.Nil(t, handler.Client) +} + +// TestGetRegistry tests registry type retrieval +func TestGetRegistry(t *testing.T) { + tests := []struct { + name string + registry string + }{ + {"npm registry", "npm"}, + {"pypi registry", "pypi"}, + {"go registry", "go"}, + {"custom registry", "custom"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + handler := &BaseHandler{Registry: tt.registry} + assert.Equal(t, tt.registry, handler.GetRegistry()) + }) + } +} + +// TestHandleUpstreamError tests upstream error handling +func TestHandleUpstreamError(t *testing.T) { + tests := []struct { + name string + err error + url string + context string + wantStatus int + wantContain string + }{ + // GOOD: Standard error + { + name: "connection error", + err: errors.New("connection refused"), + url: "https://registry.npmjs.org/react", + context: "package", + wantStatus: http.StatusBadGateway, + wantContain: "Failed to fetch package", + }, + // WRONG: Timeout error + { + name: "timeout error", + err: context.DeadlineExceeded, + url: "https://registry.npmjs.org/lodash", + context: "metadata", + wantStatus: http.StatusBadGateway, + wantContain: "Failed to fetch metadata", + }, + // EDGE: Empty context + { + name: "empty context", + err: errors.New("error"), + url: "https://example.com", + context: "", + wantStatus: http.StatusBadGateway, + wantContain: "Failed to fetch", + }, + // EDGE: Long URL + { + name: "long URL", + err: errors.New("error"), + url: "https://registry.npmjs.org/@scope/very-long-package-name/versions/1.2.3", + context: "package", + wantStatus: http.StatusBadGateway, + wantContain: "Failed to fetch package", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := httptest.NewRecorder() + HandleUpstreamError(w, tt.err, tt.url, tt.context) + + assert.Equal(t, tt.wantStatus, w.Code) + assert.Contains(t, w.Body.String(), tt.wantContain) + }) + } +} + +// TestCheckUpstreamStatus tests upstream status validation +func TestCheckUpstreamStatus(t *testing.T) { + tests := []struct { + name string + statusCode int + body io.ReadCloser + wantErr bool + errContains string + bodyClosed bool + }{ + // GOOD: OK status + { + name: "200 OK", + statusCode: http.StatusOK, + body: io.NopCloser(strings.NewReader("success")), + wantErr: false, + }, + // WRONG: Not found + { + name: "404 Not Found", + statusCode: http.StatusNotFound, + body: io.NopCloser(strings.NewReader("not found")), + wantErr: true, + errContains: "upstream returned status 404", + }, + // WRONG: Server error + { + name: "500 Internal Server Error", + statusCode: http.StatusInternalServerError, + body: io.NopCloser(strings.NewReader("error")), + wantErr: true, + errContains: "upstream returned status 500", + }, + // BAD: Unauthorized + { + name: "401 Unauthorized", + statusCode: http.StatusUnauthorized, + body: io.NopCloser(strings.NewReader("unauthorized")), + wantErr: true, + errContains: "upstream returned status 401", + }, + // EDGE: Nil body + { + name: "nil body with error", + statusCode: http.StatusNotFound, + body: nil, + wantErr: true, + errContains: "upstream returned status 404", + }, + // EDGE: Redirect status + { + name: "302 Found", + statusCode: http.StatusFound, + body: io.NopCloser(strings.NewReader("redirect")), + wantErr: true, + errContains: "upstream returned status 302", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := CheckUpstreamStatus(tt.statusCode, tt.body) + + if tt.wantErr { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errContains) + } else { + require.NoError(t, err) + } + }) + } +} + +// TestHandleInvalidRequest tests invalid request handling +func TestHandleInvalidRequest(t *testing.T) { + tests := []struct { + name string + registry string + wantStatus int + wantContain string + }{ + { + name: "npm invalid request", + registry: "npm", + wantStatus: http.StatusBadRequest, + wantContain: "Invalid npm request", + }, + { + name: "pypi invalid request", + registry: "pypi", + wantStatus: http.StatusBadRequest, + wantContain: "Invalid pypi request", + }, + { + name: "go invalid request", + registry: "go", + wantStatus: http.StatusBadRequest, + wantContain: "Invalid go request", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := httptest.NewRecorder() + HandleInvalidRequest(w, tt.registry) + + assert.Equal(t, tt.wantStatus, w.Code) + assert.Contains(t, w.Body.String(), tt.wantContain) + }) + } +} + +// TestHandleInternalError tests internal error handling +func TestHandleInternalError(t *testing.T) { + tests := []struct { + name string + err error + context string + wantStatus int + wantContain string + }{ + { + name: "database error", + err: errors.New("database connection failed"), + context: "database", + wantStatus: http.StatusInternalServerError, + wantContain: "Internal error: database", + }, + { + name: "cache error", + err: errors.New("cache write failed"), + context: "cache", + wantStatus: http.StatusInternalServerError, + wantContain: "Internal error: cache", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := httptest.NewRecorder() + HandleInternalError(w, tt.err, tt.context) + + assert.Equal(t, tt.wantStatus, w.Code) + assert.Contains(t, w.Body.String(), tt.wantContain) + }) + } +} + +// Note: FetchFromUpstream tests would require mocking cache.Manager and network.Client +// which requires concrete implementations. Integration tests cover this functionality. + +// TestWriteResponse tests HTTP response writing +func TestWriteResponse(t *testing.T) { + tests := []struct { + name string + data string + contentType string + wantStatus int + wantBody string + wantErr bool + }{ + // GOOD: Write tarball + { + name: "write tarball", + data: "package data here", + contentType: "application/octet-stream", + wantStatus: http.StatusOK, + wantBody: "package data here", + wantErr: false, + }, + // GOOD: Write JSON + { + name: "write JSON metadata", + data: `{"name":"react","version":"18.2.0"}`, + contentType: "application/json", + wantStatus: http.StatusOK, + wantBody: `{"name":"react","version":"18.2.0"}`, + wantErr: false, + }, + // EDGE: Empty data + { + name: "empty data", + data: "", + contentType: "text/plain", + wantStatus: http.StatusOK, + wantBody: "", + wantErr: false, + }, + // EDGE: Large data + { + name: "large data", + data: strings.Repeat("x", 100000), + contentType: "application/octet-stream", + wantStatus: http.StatusOK, + wantBody: strings.Repeat("x", 100000), + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := httptest.NewRecorder() + entry := &cache.CacheEntry{ + Data: io.NopCloser(bytes.NewReader([]byte(tt.data))), + } + + err := WriteResponse(w, entry, tt.contentType) + + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, tt.contentType, w.Header().Get("Content-Type")) + assert.Equal(t, tt.wantBody, w.Body.String()) + } + }) + } +} + +// TestBaseHandlerFields tests that BaseHandler fields are properly set +func TestBaseHandlerFields(t *testing.T) { + handler := NewBaseHandler(nil, nil, "npm", "https://registry.npmjs.org") + + tests := []struct { + name string + field string + expected interface{} + }{ + {"registry field", "registry", "npm"}, + {"upstream field", "upstream", "https://registry.npmjs.org"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + switch tt.field { + case "registry": + assert.Equal(t, tt.expected, handler.Registry) + case "upstream": + assert.Equal(t, tt.expected, handler.Upstream) + } + }) + } +} + +// TestProxyHandlerInterface tests that BaseHandler can be used as ProxyHandler +func TestProxyHandlerInterface(t *testing.T) { + handler := NewBaseHandler(nil, nil, "npm", "https://registry.npmjs.org") + + // Verify GetRegistry works + registry := handler.GetRegistry() + assert.Equal(t, "npm", registry) +} + +// TestConcurrentWriteResponse tests that WriteResponse is safe for concurrent use +func TestConcurrentWriteResponse(t *testing.T) { + const numGoroutines = 10 + + errs := make(chan error, numGoroutines) + for i := 0; i < numGoroutines; i++ { + go func(n int) { + w := httptest.NewRecorder() + data := strings.Repeat("x", 1000) + entry := &cache.CacheEntry{ + Data: io.NopCloser(bytes.NewReader([]byte(data))), + } + + err := WriteResponse(w, entry, "text/plain") + errs <- err + }(i) + } + + // Collect results + for i := 0; i < numGoroutines; i++ { + err := <-errs + assert.NoError(t, err) + } +} diff --git a/pkg/proxy/common/errors.go b/pkg/proxy/common/errors.go new file mode 100644 index 0000000..50f6b66 --- /dev/null +++ b/pkg/proxy/common/errors.go @@ -0,0 +1,48 @@ +package common + +import ( + "fmt" + "io" + "net/http" + + "github.com/rs/zerolog/log" +) + +// HandleUpstreamError logs an error and sends an HTTP 502 Bad Gateway response +// This is the common pattern used across all proxy handlers when upstream fetch fails +func HandleUpstreamError(w http.ResponseWriter, err error, url, context string) { + log.Error(). + Err(err). + Str("url", url). + Str("context", context). + Msg("Failed to fetch from upstream") + + http.Error(w, fmt.Sprintf("Failed to fetch %s", context), http.StatusBadGateway) +} + +// CheckUpstreamStatus validates HTTP status code from upstream +// Returns error if status is not OK, closing body if needed +func CheckUpstreamStatus(statusCode int, body io.ReadCloser) error { + if statusCode != http.StatusOK { + if body != nil { + body.Close() // #nosec G104 -- Cleanup, error not critical + } + return fmt.Errorf("upstream returned status %d", statusCode) + } + return nil +} + +// HandleInvalidRequest sends a 400 Bad Request response for invalid proxy requests +func HandleInvalidRequest(w http.ResponseWriter, registry string) { + http.Error(w, fmt.Sprintf("Invalid %s request", registry), http.StatusBadRequest) +} + +// HandleInternalError logs an internal error and sends 500 response +func HandleInternalError(w http.ResponseWriter, err error, context string) { + log.Error(). + Err(err). + Str("context", context). + Msg("Internal error processing request") + + http.Error(w, fmt.Sprintf("Internal error: %s", context), http.StatusInternalServerError) +} diff --git a/pkg/proxy/common/http.go b/pkg/proxy/common/http.go new file mode 100644 index 0000000..4d02349 --- /dev/null +++ b/pkg/proxy/common/http.go @@ -0,0 +1,58 @@ +package common + +import ( + "context" + "io" + "net/http" + + "github.com/lukaszraczylo/gohoarder/pkg/cache" + "github.com/lukaszraczylo/gohoarder/pkg/network" + "github.com/rs/zerolog/log" +) + +// FetchFromUpstream is a common helper to fetch content from upstream with caching +// This encapsulates the common pattern of: cache.Get -> network.Get -> error handling +func FetchFromUpstream( + ctx context.Context, + cacheManager *cache.Manager, + client *network.Client, + registry, name, version, upstreamURL string, +) (*cache.CacheEntry, error) { + entry, err := cacheManager.Get(ctx, registry, name, version, func(ctx context.Context) (io.ReadCloser, string, error) { + body, statusCode, err := client.Get(ctx, upstreamURL, nil) + if err != nil { + return nil, "", err + } + if err := CheckUpstreamStatus(statusCode, body); err != nil { + return nil, "", err + } + return body, upstreamURL, nil + }) + + if err != nil { + log.Error(). + Err(err). + Str("url", upstreamURL). + Str("registry", registry). + Str("name", name). + Str("version", version). + Msg("Failed to fetch package from upstream") + return nil, err + } + + return entry, nil +} + +// WriteResponse writes the cache entry data to the HTTP response writer +// Sets appropriate content type and handles errors +func WriteResponse(w http.ResponseWriter, entry *cache.CacheEntry, contentType string) error { + defer entry.Data.Close() // #nosec G104 -- Cleanup, error not critical + + w.Header().Set("Content-Type", contentType) + if _, err := io.Copy(w, entry.Data); err != nil { + log.Error().Err(err).Msg("Failed to write response") + return err + } + + return nil +} diff --git a/pkg/proxy/common/interface.go b/pkg/proxy/common/interface.go new file mode 100644 index 0000000..eb08a39 --- /dev/null +++ b/pkg/proxy/common/interface.go @@ -0,0 +1,29 @@ +package common + +import ( + "context" + "net/http" + "time" +) + +// ProxyHandler defines the common interface for all registry proxies +type ProxyHandler interface { + http.Handler // ServeHTTP(w http.ResponseWriter, r *http.Request) + + // GetRegistry returns the registry type (npm, pypi, go) + GetRegistry() string + + // Health checks if the proxy can reach its upstream + Health(ctx context.Context) error +} + +// Stats represents proxy statistics +type Stats struct { + Registry string + TotalRequests int64 + CacheHits int64 + CacheMisses int64 + UpstreamErrors int64 + AvgResponseTime time.Duration + LastUpdated time.Time +} diff --git a/pkg/proxy/goproxy/goproxy.go b/pkg/proxy/goproxy/goproxy.go new file mode 100644 index 0000000..7d629c7 --- /dev/null +++ b/pkg/proxy/goproxy/goproxy.go @@ -0,0 +1,485 @@ +package goproxy + +import ( + "context" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/lukaszraczylo/gohoarder/pkg/auth" + "github.com/lukaszraczylo/gohoarder/pkg/cache" + "github.com/lukaszraczylo/gohoarder/pkg/errors" + "github.com/lukaszraczylo/gohoarder/pkg/network" + "github.com/lukaszraczylo/gohoarder/pkg/vcs" + "github.com/rs/zerolog/log" +) + +// Handler implements the GOPROXY protocol +type Handler struct { + cache *cache.Manager + client *network.Client + upstream string + sumDBURL string + credExtractor *auth.CredentialExtractor + credHasher *auth.CredentialHasher + credValidator *auth.GoValidator + validationCache *auth.ValidationCache + gitFetcher *vcs.GitFetcher + moduleBuilder *vcs.ModuleBuilder +} + +// Config holds Go proxy configuration +type Config struct { + Upstream string // Upstream Go proxy (e.g., proxy.golang.org) + SumDBURL string // Checksum database URL + CredStore *vcs.CredentialStore // Optional credential store for git access +} + +// New creates a new Go proxy handler +func New(cacheManager *cache.Manager, client *network.Client, config Config) *Handler { + if config.Upstream == "" { + config.Upstream = "https://proxy.golang.org" + } + + if config.SumDBURL == "" { + config.SumDBURL = "https://sum.golang.org" + } + + // Use provided credential store or create empty one + credStore := config.CredStore + if credStore == nil { + credStore = vcs.NewCredentialStore() + } + + return &Handler{ + cache: cacheManager, + client: client, + upstream: config.Upstream, + sumDBURL: config.SumDBURL, + credExtractor: auth.NewCredentialExtractor(), + credHasher: auth.NewCredentialHasher(), + credValidator: auth.NewGoValidator(), + validationCache: auth.NewValidationCache(5 * time.Minute), + gitFetcher: vcs.NewGitFetcher("", credStore), + moduleBuilder: vcs.NewModuleBuilder(), + } +} + +// ServeHTTP handles GOPROXY protocol requests +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + // Path is already stripped by http.StripPrefix in app.go + path := r.URL.Path + + log.Debug(). + Str("path", path). + Msg("Processing Go proxy request") + + // Parse GOPROXY request + // Formats: + // /@v/list - list versions + // /@v/$version.info - version info + // /@v/$version.mod - go.mod file + // /@v/$version.zip - module zip + // /@latest - latest version + + log.Debug().Str("path", path).Msg("Go proxy request") + + // Route request based on path + if strings.HasPrefix(path, "/sumdb/") { + h.handleSumDB(ctx, w, r, path) + } else if strings.HasSuffix(path, "/@v/list") { + h.handleList(ctx, w, r, path) + } else if strings.Contains(path, "/@v/") && strings.HasSuffix(path, ".info") { + h.handleInfo(ctx, w, r, path) + } else if strings.Contains(path, "/@v/") && strings.HasSuffix(path, ".mod") { + h.handleMod(ctx, w, r, path) + } else if strings.Contains(path, "/@v/") && strings.HasSuffix(path, ".zip") { + h.handleZip(ctx, w, r, path) + } else if strings.HasSuffix(path, "/@latest") { + h.handleLatest(ctx, w, r, path) + } else { + http.Error(w, "Invalid Go proxy request", http.StatusBadRequest) + } +} + +// handleList handles /@v/list requests +func (h *Handler) handleList(ctx context.Context, w http.ResponseWriter, r *http.Request, path string) { + url := h.upstream + path + modulePath := h.extractModulePath(path) + + // Extract credentials from request + credentials := h.credExtractor.Extract(r) + + entry, err := h.cache.Get(ctx, "go", modulePath, "list", func(ctx context.Context) (io.ReadCloser, string, error) { + // Prepare headers for upstream request + headers := make(map[string]string) + if credentials != "" { + headers["Authorization"] = credentials + } + + body, statusCode, err := h.client.Get(ctx, url, headers) + if err != nil { + return nil, "", err + } + if statusCode != http.StatusOK { + body.Close() // #nosec G104 -- Cleanup, error not critical + return nil, "", fmt.Errorf("upstream returned status %d", statusCode) + } + return body, url, nil + }) + + if err != nil { + log.Error().Err(err).Str("url", url).Msg("Failed to fetch version list") + http.Error(w, "Failed to fetch version list", http.StatusBadGateway) + return + } + defer entry.Data.Close() // #nosec G104 -- Cleanup, error not critical + + w.Header().Set("Content-Type", "text/plain; charset=UTF-8") + _, _ = io.Copy(w, entry.Data) // #nosec G104 -- HTTP response write +} + +// handleInfo handles /@v/$version.info requests +func (h *Handler) handleInfo(ctx context.Context, w http.ResponseWriter, r *http.Request, path string) { + url := h.upstream + path + modulePath := h.extractModulePath(path) + version := h.extractVersion(path, ".info") + // Use .info suffix to distinguish from .mod and .zip in cache + cacheKey := modulePath + "/@v/" + version + ".info" + + // Extract credentials from request + credentials := h.credExtractor.Extract(r) + + entry, err := h.cache.Get(ctx, "go", cacheKey, version, func(ctx context.Context) (io.ReadCloser, string, error) { + // Prepare headers for upstream request + headers := make(map[string]string) + if credentials != "" { + headers["Authorization"] = credentials + } + + body, statusCode, err := h.client.Get(ctx, url, headers) + if err != nil { + return nil, "", err + } + if statusCode != http.StatusOK { + body.Close() // #nosec G104 -- Cleanup, error not critical + return nil, "", fmt.Errorf("upstream returned status %d", statusCode) + } + return body, url, nil + }) + + if err != nil { + log.Error().Err(err).Str("url", url).Msg("Failed to fetch version info") + http.Error(w, "Failed to fetch version info", http.StatusBadGateway) + return + } + defer entry.Data.Close() // #nosec G104 -- Cleanup, error not critical + + w.Header().Set("Content-Type", "application/json; charset=UTF-8") + _, _ = io.Copy(w, entry.Data) // #nosec G104 -- HTTP response write +} + +// handleMod handles /@v/$version.mod requests +func (h *Handler) handleMod(ctx context.Context, w http.ResponseWriter, r *http.Request, path string) { + url := h.upstream + path + modulePath := h.extractModulePath(path) + version := h.extractVersion(path, ".mod") + // Use .mod suffix to distinguish from .info and .zip in cache + cacheKey := modulePath + "/@v/" + version + ".mod" + + // Extract credentials from request + credentials := h.credExtractor.Extract(r) + + entry, err := h.cache.Get(ctx, "go", cacheKey, version, func(ctx context.Context) (io.ReadCloser, string, error) { + // Prepare headers for upstream request + headers := make(map[string]string) + if credentials != "" { + headers["Authorization"] = credentials + } + + body, statusCode, err := h.client.Get(ctx, url, headers) + if err != nil { + return nil, "", err + } + if statusCode != http.StatusOK { + body.Close() // #nosec G104 -- Cleanup, error not critical + return nil, "", fmt.Errorf("upstream returned status %d", statusCode) + } + return body, url, nil + }) + + if err != nil { + log.Error().Err(err).Str("url", url).Msg("Failed to fetch go.mod") + http.Error(w, "Failed to fetch go.mod", http.StatusBadGateway) + return + } + defer entry.Data.Close() // #nosec G104 -- Cleanup, error not critical + + w.Header().Set("Content-Type", "text/plain; charset=UTF-8") + _, _ = io.Copy(w, entry.Data) // #nosec G104 -- HTTP response write +} + +// handleZip handles /@v/$version.zip requests +func (h *Handler) handleZip(ctx context.Context, w http.ResponseWriter, r *http.Request, path string) { + url := h.upstream + path + modulePath := h.extractModulePath(path) + version := h.extractVersion(path, ".zip") + // Use .zip suffix to distinguish from .info and .mod in cache + cacheKey := modulePath + "/@v/" + version + ".zip" + + // Extract credentials from request + credentials := h.credExtractor.Extract(r) + credHash := h.credHasher.Hash(credentials) + + log.Debug(). + Str("path", path). + Str("module", modulePath). + Str("version", version). + Str("url", url). + Str("cred_hash", credHash). + Bool("has_credentials", credentials != ""). + Msg("Handling Go module zip request") + + entry, err := h.cache.Get(ctx, "go", cacheKey, version, func(ctx context.Context) (io.ReadCloser, string, error) { + // Prepare headers for upstream request + headers := make(map[string]string) + if credentials != "" { + headers["Authorization"] = credentials + } + + // Try upstream proxy first (fast path for public modules) + body, statusCode, err := h.client.Get(ctx, url, headers) + if err == nil && statusCode == http.StatusOK { + return body, url, nil + } + + // If upstream failed with 404 or 403, try git fallback (private modules) + if statusCode == http.StatusNotFound || statusCode == http.StatusForbidden { + if body != nil { + body.Close() // #nosec G104 -- Cleanup, error not critical + } + + log.Debug(). + Str("module", modulePath). + Str("version", version). + Int("upstream_status", statusCode). + Msg("Upstream proxy returned not found, trying git fallback") + + return h.fetchModuleFromGit(ctx, modulePath, version, credentials) + } + + // Other errors + if body != nil { + body.Close() // #nosec G104 -- Cleanup, error not critical + } + if err != nil { + return nil, "", err + } + return nil, "", fmt.Errorf("upstream returned status %d", statusCode) + }) + + if err != nil { + log.Error().Err(err).Str("url", url).Msg("Failed to fetch module zip") + + // Check if error is a security violation - return 403 Forbidden + if ghErr, ok := err.(*errors.Error); ok && ghErr.Code == errors.ErrCodeSecurityViolation { + http.Error(w, fmt.Sprintf("Package blocked: %s", ghErr.Message), http.StatusForbidden) + return + } + + // All other errors return 502 Bad Gateway (upstream issues) + http.Error(w, "Failed to fetch module zip", http.StatusBadGateway) + return + } + defer entry.Data.Close() // #nosec G104 -- Cleanup, error not critical + + // CRITICAL SECURITY CHECK: If module requires auth, validate credentials + if entry.Package != nil && entry.Package.RequiresAuth { + // Check validation cache first + allowed, cached, reason := h.validationCache.Get(credHash, modulePath) + if cached { + if !allowed { + log.Warn(). + Str("module", modulePath). + Str("version", version). + Str("reason", reason). + Msg("Access denied (cached validation)") + http.Error(w, "Module not found", http.StatusNotFound) + return + } + log.Debug(). + Str("module", modulePath). + Str("version", version). + Msg("Access granted (cached validation)") + } else { + // Validate with upstream using git ls-remote + log.Debug(). + Str("module", modulePath). + Str("version", version). + Str("provider", entry.Package.AuthProvider). + Msg("Validating credentials with upstream") + + allowed, err := h.credValidator.ValidateAccess(ctx, modulePath, credentials) + if err != nil { + reason = err.Error() + } + + // Cache validation result + h.validationCache.Set(credHash, modulePath, allowed, reason) + + if !allowed { + log.Warn(). + Str("module", modulePath). + Str("version", version). + Err(err). + Msg("Access denied by upstream") + // Return 404 (same as GitHub does for private repos) + http.Error(w, "Module not found", http.StatusNotFound) + return + } + + log.Debug(). + Str("module", modulePath). + Str("version", version). + Msg("Access granted by upstream") + } + } + + w.Header().Set("Content-Type", "application/zip") + _, _ = io.Copy(w, entry.Data) // #nosec G104 -- HTTP response write +} + +// handleLatest handles /@latest requests +func (h *Handler) handleLatest(ctx context.Context, w http.ResponseWriter, r *http.Request, path string) { + url := h.upstream + path + modulePath := h.extractModulePath(path) + + // Extract credentials from request + credentials := h.credExtractor.Extract(r) + + entry, err := h.cache.Get(ctx, "go", modulePath, "latest", func(ctx context.Context) (io.ReadCloser, string, error) { + // Prepare headers for upstream request + headers := make(map[string]string) + if credentials != "" { + headers["Authorization"] = credentials + } + + body, statusCode, err := h.client.Get(ctx, url, headers) + if err != nil { + return nil, "", err + } + if statusCode != http.StatusOK { + body.Close() // #nosec G104 -- Cleanup, error not critical + return nil, "", fmt.Errorf("upstream returned status %d", statusCode) + } + return body, url, nil + }) + + if err != nil { + log.Error().Err(err).Str("url", url).Msg("Failed to fetch latest version") + http.Error(w, "Failed to fetch latest version", http.StatusBadGateway) + return + } + defer entry.Data.Close() // #nosec G104 -- Cleanup, error not critical + + w.Header().Set("Content-Type", "application/json; charset=UTF-8") + _, _ = io.Copy(w, entry.Data) // #nosec G104 -- HTTP response write +} + +// handleSumDB handles sumdb requests (checksum database) +func (h *Handler) handleSumDB(ctx context.Context, w http.ResponseWriter, r *http.Request, path string) { + // path format: /sumdb/sum.golang.org/... + // Remove /sumdb/ prefix and proxy to sumdb URL + sumdbPath := strings.TrimPrefix(path, "/sumdb/sum.golang.org") + url := h.sumDBURL + sumdbPath + + log.Debug().Str("url", url).Msg("Proxying sumdb request") + + // Sumdb requests should not be cached, proxy directly + body, statusCode, err := h.client.Get(ctx, url, nil) + if err != nil { + log.Error().Err(err).Str("url", url).Msg("Failed to fetch from sumdb") + http.Error(w, "Failed to fetch from sumdb", http.StatusBadGateway) + return + } + defer body.Close() // #nosec G104 -- Cleanup, error not critical + + if statusCode != http.StatusOK { + log.Error().Int("status", statusCode).Str("url", url).Msg("Sumdb returned non-OK status") + http.Error(w, "Sumdb error", statusCode) + return + } + + w.Header().Set("Content-Type", "text/plain; charset=UTF-8") + _, _ = io.Copy(w, body) // #nosec G104 -- HTTP response write +} + +// extractVersion extracts version from path +func (h *Handler) extractVersion(path, suffix string) string { + // path format: /module/path/@v/v1.2.3.suffix + parts := strings.Split(path, "/@v/") + if len(parts) != 2 { + return "" + } + return strings.TrimSuffix(parts[1], suffix) +} + +// extractModulePath extracts the clean module path from a GOPROXY path +// Examples: +// +// /github.com/avast/retry-go/v4/@v/v4.6.1.zip -> github.com/avast/retry-go/v4 +// /golang.org/x/net/@v/v0.40.0.mod -> golang.org/x/net +// /github.com/user/repo/@v/list -> github.com/user/repo +func (h *Handler) extractModulePath(path string) string { + // Remove leading slash + path = strings.TrimPrefix(path, "/") + + // Split on /@v/ to get the module path + parts := strings.Split(path, "/@v/") + if len(parts) > 0 { + return parts[0] + } + + // Fallback: remove /@latest suffix if present + return strings.TrimSuffix(path, "/@latest") +} + +// fetchModuleFromGit fetches a Go module directly from git repository +func (h *Handler) fetchModuleFromGit(ctx context.Context, modulePath, version, credentials string) (io.ReadCloser, string, error) { + log.Info(). + Str("module", modulePath). + Str("version", version). + Msg("Fetching module from git repository") + + // 1. Fetch module source from git + srcPath, err := h.gitFetcher.FetchModule(ctx, modulePath, version, credentials) + if err != nil { + return nil, "", fmt.Errorf("git fetch failed: %w", err) + } + defer h.gitFetcher.Cleanup(srcPath) + + // 2. Validate module + if err := h.moduleBuilder.ValidateModule(ctx, srcPath, modulePath); err != nil { + return nil, "", fmt.Errorf("module validation failed: %w", err) + } + + // 3. Build module zip + zipReader, err := h.moduleBuilder.BuildModuleZip(ctx, srcPath, modulePath, version) + if err != nil { + return nil, "", fmt.Errorf("module zip build failed: %w", err) + } + + // Create source URL for logging + sourceURL := fmt.Sprintf("git+https://%s@%s", modulePath, version) + + log.Info(). + Str("module", modulePath). + Str("version", version). + Str("source", sourceURL). + Msg("Successfully built module from git") + + return zipReader, sourceURL, nil +} diff --git a/pkg/proxy/npm/npm.go b/pkg/proxy/npm/npm.go new file mode 100644 index 0000000..18ef9af --- /dev/null +++ b/pkg/proxy/npm/npm.go @@ -0,0 +1,377 @@ +package npm + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/lukaszraczylo/gohoarder/pkg/auth" + "github.com/lukaszraczylo/gohoarder/pkg/cache" + "github.com/lukaszraczylo/gohoarder/pkg/errors" + "github.com/lukaszraczylo/gohoarder/pkg/network" + "github.com/rs/zerolog/log" +) + +// Handler implements the NPM registry protocol +type Handler struct { + cache *cache.Manager + client *network.Client + upstream string + credExtractor *auth.CredentialExtractor + credHasher *auth.CredentialHasher + credValidator *auth.NPMValidator + validationCache *auth.ValidationCache +} + +// Config holds NPM proxy configuration +type Config struct { + Upstream string // Upstream NPM registry (e.g., registry.npmjs.org) +} + +// New creates a new NPM proxy handler +func New(cacheManager *cache.Manager, client *network.Client, config Config) *Handler { + if config.Upstream == "" { + config.Upstream = "https://registry.npmjs.org" + } + + return &Handler{ + cache: cacheManager, + client: client, + upstream: config.Upstream, + credExtractor: auth.NewCredentialExtractor(), + credHasher: auth.NewCredentialHasher(), + credValidator: auth.NewNPMValidator(), + validationCache: auth.NewValidationCache(5 * time.Minute), + } +} + +// ServeHTTP handles NPM registry requests +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + path := strings.TrimPrefix(r.URL.Path, "/npm") + + log.Debug().Str("path", path).Str("method", r.Method).Msg("NPM proxy request") + + // Handle different NPM request types + // Check for tarballs FIRST before special endpoints (tarballs also contain "/-/") + if isTarballRequest(path) { + // Package tarball: /@scope/package/-/package-version.tgz + h.handleTarball(ctx, w, r, path) + } else if strings.Contains(path, "/-/") { + // Special NPM endpoints (e.g., /-/ping, /-/user/token) + h.handleSpecial(ctx, w, r, path) + } else if isPackageMetadata(path) { + // Package metadata: /@scope/package or /package + h.handleMetadata(ctx, w, r, path) + } else { + http.Error(w, "Invalid NPM request", http.StatusBadRequest) + } +} + +// handleMetadata handles package metadata requests +func (h *Handler) handleMetadata(ctx context.Context, w http.ResponseWriter, r *http.Request, path string) { + url := h.upstream + path + packageName := extractPackageName(path) + + entry, err := h.cache.Get(ctx, "npm", packageName, "metadata", func(ctx context.Context) (io.ReadCloser, string, error) { + body, statusCode, err := h.client.Get(ctx, url, nil) + if err != nil { + return nil, "", err + } + if statusCode != http.StatusOK { + body.Close() // #nosec G104 -- Cleanup, error not critical + return nil, "", fmt.Errorf("upstream returned status %d", statusCode) + } + return body, url, nil + }) + + if err != nil { + log.Error().Err(err).Str("url", url).Msg("Failed to fetch package metadata") + http.Error(w, "Failed to fetch package metadata", http.StatusBadGateway) + return + } + defer entry.Data.Close() // #nosec G104 -- Cleanup, error not critical + + // Read metadata into memory for URL rewriting + var buf bytes.Buffer + if _, err := io.Copy(&buf, entry.Data); err != nil { + log.Error().Err(err).Msg("Failed to read metadata") + http.Error(w, "Failed to read metadata", http.StatusInternalServerError) + return + } + + // Parse JSON metadata + var metadata map[string]interface{} + if err := json.Unmarshal(buf.Bytes(), &metadata); err != nil { + log.Error().Err(err).Msg("Failed to parse metadata JSON") + http.Error(w, "Failed to parse metadata", http.StatusInternalServerError) + return + } + + // Rewrite tarball URLs to point to our proxy + proxyBaseURL := getProxyBaseURL(r) + rewriteMetadataURLs(metadata, h.upstream, proxyBaseURL) + + // Serialize modified metadata + modifiedJSON, err := json.Marshal(metadata) + if err != nil { + log.Error().Err(err).Msg("Failed to serialize modified metadata") + http.Error(w, "Failed to serialize metadata", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json; charset=UTF-8") + _, _ = w.Write(modifiedJSON) // #nosec G104 -- Websocket buffer write +} + +// handleTarball handles package tarball requests +func (h *Handler) handleTarball(ctx context.Context, w http.ResponseWriter, r *http.Request, path string) { + packageName, version := extractTarballInfo(path) + + // Extract credentials from request + credentials := h.credExtractor.Extract(r) + credHash := h.credHasher.Hash(credentials) + + // Construct proper upstream URL with /-/ format + // Format: https://registry.npmjs.org/package/-/package-version.tgz + tarballFilename := strings.ReplaceAll(packageName, "/", "-") + "-" + version + ".tgz" + url := fmt.Sprintf("%s/%s/-/%s", h.upstream, packageName, tarballFilename) + + log.Debug(). + Str("path", path). + Str("package", packageName). + Str("version", version). + Str("upstream_url", url). + Str("cred_hash", credHash). + Bool("has_credentials", credentials != ""). + Msg("Handling tarball request") + + // Try to get from cache first (with credential-aware key) + entry, err := h.cache.Get(ctx, "npm", packageName, version, func(ctx context.Context) (io.ReadCloser, string, error) { + // Prepare headers for upstream request + headers := make(map[string]string) + if credentials != "" { + headers["Authorization"] = credentials + } + + body, statusCode, err := h.client.Get(ctx, url, headers) + if err != nil { + return nil, "", err + } + if statusCode != http.StatusOK { + body.Close() // #nosec G104 -- Cleanup, error not critical + return nil, "", fmt.Errorf("upstream returned status %d", statusCode) + } + return body, url, nil + }) + + if err != nil { + log.Error().Err(err).Str("url", url).Msg("Failed to fetch package tarball") + + // Check if error is a security violation - return 403 Forbidden + if ghErr, ok := err.(*errors.Error); ok && ghErr.Code == errors.ErrCodeSecurityViolation { + http.Error(w, fmt.Sprintf("Package blocked: %s", ghErr.Message), http.StatusForbidden) + return + } + + // All other errors return 502 Bad Gateway (upstream issues) + http.Error(w, "Failed to fetch package tarball", http.StatusBadGateway) + return + } + defer entry.Data.Close() // #nosec G104 -- Cleanup, error not critical + + // CRITICAL SECURITY CHECK: If package requires auth, validate credentials + if entry.Package != nil && entry.Package.RequiresAuth { + // Check validation cache first + allowed, cached, reason := h.validationCache.Get(credHash, url) + if cached { + if !allowed { + log.Warn(). + Str("package", packageName). + Str("version", version). + Str("reason", reason). + Msg("Access denied (cached validation)") + http.Error(w, "Access denied", http.StatusForbidden) + return + } + log.Debug(). + Str("package", packageName). + Str("version", version). + Msg("Access granted (cached validation)") + } else { + // Validate with upstream + log.Debug(). + Str("package", packageName). + Str("version", version). + Str("provider", entry.Package.AuthProvider). + Msg("Validating credentials with upstream") + + allowed, err := h.credValidator.ValidateAccess(ctx, url, credentials) + if err != nil { + reason = err.Error() + } + + // Cache validation result + h.validationCache.Set(credHash, url, allowed, reason) + + if !allowed { + log.Warn(). + Str("package", packageName). + Str("version", version). + Err(err). + Msg("Access denied by upstream") + http.Error(w, "Access denied", http.StatusForbidden) + return + } + + log.Debug(). + Str("package", packageName). + Str("version", version). + Msg("Access granted by upstream") + } + } + + w.Header().Set("Content-Type", "application/octet-stream") + _, _ = io.Copy(w, entry.Data) // #nosec G104 -- HTTP response write +} + +// handleSpecial handles special NPM endpoints +func (h *Handler) handleSpecial(ctx context.Context, w http.ResponseWriter, r *http.Request, path string) { + url := h.upstream + path + + // Don't cache special endpoints, proxy directly + body, statusCode, err := h.client.Get(ctx, url, nil) + if err != nil { + log.Error().Err(err).Str("url", url).Msg("Failed to fetch special endpoint") + http.Error(w, "Failed to fetch from upstream", http.StatusBadGateway) + return + } + defer body.Close() // #nosec G104 -- Cleanup, error not critical + + w.WriteHeader(statusCode) + _, _ = io.Copy(w, body) // #nosec G104 -- HTTP response write +} + +// isTarballRequest checks if the request is for a tarball +func isTarballRequest(path string) bool { + return strings.HasSuffix(path, ".tgz") || strings.HasSuffix(path, ".tar.gz") +} + +// isPackageMetadata checks if the request is for package metadata +func isPackageMetadata(path string) bool { + // Package metadata doesn't have file extensions + return !isTarballRequest(path) && !strings.Contains(path, "/-/") +} + +// extractPackageName extracts package name from path +func extractPackageName(path string) string { + // Remove leading slash + path = strings.TrimPrefix(path, "/") + + // Handle scoped packages (@scope/package) + if strings.HasPrefix(path, "@") { + parts := strings.Split(path, "/") + if len(parts) >= 2 { + return parts[0] + "/" + parts[1] + } + } + + // Regular package + parts := strings.Split(path, "/") + if len(parts) > 0 { + return parts[0] + } + + return path +} + +// extractTarballInfo extracts package name and version from tarball path +func extractTarballInfo(path string) (string, string) { + // Format: /@scope/package/-/package-version.tgz + // or: /package/-/package-version.tgz + // Also handle: /package/package-version.tgz (fallback) + + // Try standard format with /-/ + parts := strings.Split(path, "/-/") + if len(parts) == 2 { + packageName := extractPackageName(parts[0]) + tarballName := parts[1] + tarballName = strings.TrimSuffix(tarballName, ".tgz") + tarballName = strings.TrimSuffix(tarballName, ".tar.gz") + + // Remove package name prefix to get version + prefix := strings.ReplaceAll(packageName, "/", "-") + "-" + version := strings.TrimPrefix(tarballName, prefix) + + return packageName, version + } + + // Fallback: parse path without /-/ + // Format: /package/package-version.tgz or /@scope/package/package-version.tgz + path = strings.TrimPrefix(path, "/") + pathParts := strings.Split(path, "/") + + if len(pathParts) < 2 { + return "", "" + } + + var packageName, tarballName string + + // Handle scoped packages + if strings.HasPrefix(pathParts[0], "@") && len(pathParts) >= 3 { + packageName = pathParts[0] + "/" + pathParts[1] + tarballName = pathParts[len(pathParts)-1] + } else { + packageName = pathParts[0] + tarballName = pathParts[len(pathParts)-1] + } + + tarballName = strings.TrimSuffix(tarballName, ".tgz") + tarballName = strings.TrimSuffix(tarballName, ".tar.gz") + + // Remove package name prefix to get version + prefix := strings.ReplaceAll(packageName, "/", "-") + "-" + version := strings.TrimPrefix(tarballName, prefix) + + return packageName, version +} + +// getProxyBaseURL constructs the proxy base URL from the request +func getProxyBaseURL(r *http.Request) string { + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + host := r.Host + return fmt.Sprintf("%s://%s/npm", scheme, host) +} + +// rewriteMetadataURLs recursively rewrites upstream URLs to proxy URLs in metadata +func rewriteMetadataURLs(data interface{}, upstream, proxyBaseURL string) { + switch v := data.(type) { + case map[string]interface{}: + for key, value := range v { + if key == "tarball" || key == "dist" { + // Rewrite tarball URL + if strVal, ok := value.(string); ok { + v[key] = strings.Replace(strVal, upstream, proxyBaseURL, 1) + } else if distMap, ok := value.(map[string]interface{}); ok { + // Handle dist object with tarball field + rewriteMetadataURLs(distMap, upstream, proxyBaseURL) + } + } else { + // Recursively process nested objects + rewriteMetadataURLs(value, upstream, proxyBaseURL) + } + } + case []interface{}: + for _, item := range v { + rewriteMetadataURLs(item, upstream, proxyBaseURL) + } + } +} diff --git a/pkg/proxy/pypi/pypi.go b/pkg/proxy/pypi/pypi.go new file mode 100644 index 0000000..1f45d4b --- /dev/null +++ b/pkg/proxy/pypi/pypi.go @@ -0,0 +1,398 @@ +package pypi + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "regexp" + "strings" + "time" + + "github.com/lukaszraczylo/gohoarder/pkg/auth" + "github.com/lukaszraczylo/gohoarder/pkg/cache" + "github.com/lukaszraczylo/gohoarder/pkg/errors" + "github.com/lukaszraczylo/gohoarder/pkg/network" + "github.com/rs/zerolog/log" +) + +// Handler implements the PyPI Simple API (PEP 503) +type Handler struct { + cache *cache.Manager + client *network.Client + upstream string + credExtractor *auth.CredentialExtractor + credHasher *auth.CredentialHasher + credValidator *auth.PyPIValidator + validationCache *auth.ValidationCache +} + +// Config holds PyPI proxy configuration +type Config struct { + Upstream string // Upstream PyPI index (e.g., pypi.org/simple) +} + +// New creates a new PyPI proxy handler +func New(cacheManager *cache.Manager, client *network.Client, config Config) *Handler { + if config.Upstream == "" { + config.Upstream = "https://pypi.org/simple" + } + + return &Handler{ + cache: cacheManager, + client: client, + upstream: config.Upstream, + credExtractor: auth.NewCredentialExtractor(), + credHasher: auth.NewCredentialHasher(), + credValidator: auth.NewPyPIValidator(), + validationCache: auth.NewValidationCache(5 * time.Minute), + } +} + +// ServeHTTP handles PyPI Simple API requests +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + path := strings.TrimPrefix(r.URL.Path, "/pypi") + // Also trim /simple prefix since upstream already includes it + path = strings.TrimPrefix(path, "/simple") + + log.Debug().Str("path", path).Str("method", r.Method).Msg("PyPI proxy request") + + // PEP 503 Simple API endpoints: + // / - index page + // /{package}/ - package page with links to files + + if path == "/" || path == "" { + // Index page + h.handleIndex(ctx, w, r) + } else if isPackagePage(path) { + // Package page + h.handlePackagePage(ctx, w, r, path) + } else if isPackageFile(path) { + // Package file download (wheel or sdist) + h.handlePackageFile(ctx, w, r, path) + } else { + http.Error(w, "Invalid PyPI request", http.StatusBadRequest) + } +} + +// handleIndex handles the index page request +func (h *Handler) handleIndex(ctx context.Context, w http.ResponseWriter, r *http.Request) { + url := h.upstream + "/" + + entry, err := h.cache.Get(ctx, "pypi", "index", "latest", func(ctx context.Context) (io.ReadCloser, string, error) { + body, statusCode, err := h.client.Get(ctx, url, nil) + if err != nil { + return nil, "", err + } + if statusCode != http.StatusOK { + body.Close() // #nosec G104 -- Cleanup, error not critical + return nil, "", fmt.Errorf("upstream returned status %d", statusCode) + } + return body, url, nil + }) + + if err != nil { + log.Error().Err(err).Str("url", url).Msg("Failed to fetch PyPI index") + http.Error(w, "Failed to fetch PyPI index", http.StatusBadGateway) + return + } + defer entry.Data.Close() // #nosec G104 -- Cleanup, error not critical + + w.Header().Set("Content-Type", "text/html; charset=UTF-8") + _, _ = io.Copy(w, entry.Data) // #nosec G104 -- HTTP response write +} + +// handlePackagePage handles package page requests +func (h *Handler) handlePackagePage(ctx context.Context, w http.ResponseWriter, r *http.Request, path string) { + url := h.upstream + path + packageName := extractPackageName(path) + + entry, err := h.cache.Get(ctx, "pypi", packageName, "page", func(ctx context.Context) (io.ReadCloser, string, error) { + body, statusCode, err := h.client.Get(ctx, url, nil) + if err != nil { + return nil, "", err + } + if statusCode != http.StatusOK { + body.Close() // #nosec G104 -- Cleanup, error not critical + return nil, "", fmt.Errorf("upstream returned status %d", statusCode) + } + return body, url, nil + }) + + if err != nil { + log.Error().Err(err).Str("url", url).Msg("Failed to fetch package page") + http.Error(w, "Failed to fetch package page", http.StatusBadGateway) + return + } + defer entry.Data.Close() // #nosec G104 -- Cleanup, error not critical + + // Read page into memory for URL rewriting + var buf bytes.Buffer + if _, err := io.Copy(&buf, entry.Data); err != nil { + log.Error().Err(err).Msg("Failed to read package page") + http.Error(w, "Failed to read package page", http.StatusInternalServerError) + return + } + + // Rewrite package file URLs to point to our proxy + proxyBaseURL := getProxyBaseURL(r) + modifiedHTML := rewritePackagePageURLs(buf.String(), packageName, proxyBaseURL) + + w.Header().Set("Content-Type", "text/html; charset=UTF-8") + _, _ = w.Write([]byte(modifiedHTML)) // #nosec G104 -- Websocket buffer write +} + +// handlePackageFile handles package file download requests +func (h *Handler) handlePackageFile(ctx context.Context, w http.ResponseWriter, r *http.Request, path string) { + packageName, version := extractPackageFileInfo(path) + + // Extract credentials from request + credentials := h.credExtractor.Extract(r) + credHash := h.credHasher.Hash(credentials) + + // Check if we have the original URL from the rewritten package page + originalURL := r.URL.Query().Get("original_url") + + // If no original URL provided, fall back to constructing from upstream + // (this handles direct file requests not from rewritten package pages) + if originalURL == "" { + originalURL = h.upstream + path + } else { + // Make the URL absolute if it's relative + if !strings.HasPrefix(originalURL, "http://") && !strings.HasPrefix(originalURL, "https://") { + originalURL = "https://pypi.org" + originalURL + } + } + + log.Debug(). + Str("path", path). + Str("package", packageName). + Str("version", version). + Str("url", originalURL). + Str("cred_hash", credHash). + Bool("has_credentials", credentials != ""). + Msg("Handling PyPI package file request") + + entry, err := h.cache.Get(ctx, "pypi", packageName, version, func(ctx context.Context) (io.ReadCloser, string, error) { + // Prepare headers for upstream request + headers := make(map[string]string) + if credentials != "" { + headers["Authorization"] = credentials + } + + body, statusCode, err := h.client.Get(ctx, originalURL, headers) + if err != nil { + return nil, "", err + } + if statusCode != http.StatusOK { + body.Close() // #nosec G104 -- Cleanup, error not critical + return nil, "", fmt.Errorf("upstream returned status %d", statusCode) + } + return body, originalURL, nil + }) + + if err != nil { + log.Error().Err(err).Str("url", originalURL).Msg("Failed to fetch package file") + + // Check if error is a security violation - return 403 Forbidden + if ghErr, ok := err.(*errors.Error); ok && ghErr.Code == errors.ErrCodeSecurityViolation { + http.Error(w, fmt.Sprintf("Package blocked: %s", ghErr.Message), http.StatusForbidden) + return + } + + // All other errors return 502 Bad Gateway (upstream issues) + http.Error(w, "Failed to fetch package file", http.StatusBadGateway) + return + } + defer entry.Data.Close() // #nosec G104 -- Cleanup, error not critical + + // CRITICAL SECURITY CHECK: If package requires auth, validate credentials + if entry.Package != nil && entry.Package.RequiresAuth { + // Check validation cache first + allowed, cached, reason := h.validationCache.Get(credHash, originalURL) + if cached { + if !allowed { + log.Warn(). + Str("package", packageName). + Str("version", version). + Str("reason", reason). + Msg("Access denied (cached validation)") + http.Error(w, "Access denied", http.StatusForbidden) + return + } + log.Debug(). + Str("package", packageName). + Str("version", version). + Msg("Access granted (cached validation)") + } else { + // Validate with upstream + log.Debug(). + Str("package", packageName). + Str("version", version). + Str("provider", entry.Package.AuthProvider). + Msg("Validating credentials with upstream") + + allowed, err := h.credValidator.ValidateAccess(ctx, originalURL, credentials) + if err != nil { + reason = err.Error() + } + + // Cache validation result + h.validationCache.Set(credHash, originalURL, allowed, reason) + + if !allowed { + log.Warn(). + Str("package", packageName). + Str("version", version). + Err(err). + Msg("Access denied by upstream") + http.Error(w, "Access denied", http.StatusForbidden) + return + } + + log.Debug(). + Str("package", packageName). + Str("version", version). + Msg("Access granted by upstream") + } + } + + // Determine content type based on file extension + contentType := "application/octet-stream" + if strings.HasSuffix(path, ".whl") { + contentType = "application/zip" + } else if strings.HasSuffix(path, ".tar.gz") { + contentType = "application/x-gzip" + } else if strings.HasSuffix(path, ".metadata") { + contentType = "text/plain; charset=UTF-8" + } + + w.Header().Set("Content-Type", contentType) + _, _ = io.Copy(w, entry.Data) // #nosec G104 -- HTTP response write +} + +// isPackagePage checks if the request is for a package page +func isPackagePage(path string) bool { + // Package pages end with / + return strings.HasSuffix(path, "/") +} + +// isPackageFile checks if the request is for a package file +func isPackageFile(path string) bool { + // Package files (not including .metadata files which need special handling) + return strings.HasSuffix(path, ".whl") || + strings.HasSuffix(path, ".tar.gz") || + strings.HasSuffix(path, ".zip") || + strings.HasSuffix(path, ".egg") +} + +// extractPackageName extracts package name from path +func extractPackageName(path string) string { + // Remove leading and trailing slashes + path = strings.Trim(path, "/") + + // Remove /simple/ prefix if present + path = strings.TrimPrefix(path, "simple/") + + // For package pages: /package-name/ + // For files: /package-name/package-name-version.whl + parts := strings.Split(path, "/") + if len(parts) > 0 { + return parts[0] + } + + return path +} + +// extractPackageFileInfo extracts package name and version from file path +func extractPackageFileInfo(path string) (string, string) { + // Format: /package-name/package-name-version.whl + // or: /package-name/package-name-version.tar.gz + + packageName := extractPackageName(path) + + // Extract filename + parts := strings.Split(path, "/") + if len(parts) < 2 { + return packageName, "" + } + + filename := parts[len(parts)-1] + + // Remove extension + filename = strings.TrimSuffix(filename, ".whl") + filename = strings.TrimSuffix(filename, ".tar.gz") + filename = strings.TrimSuffix(filename, ".zip") + filename = strings.TrimSuffix(filename, ".egg") + + // Extract version + // Filename format: package-name-version or package_name-version + // Version typically starts after last dash before build tags + versionParts := strings.Split(filename, "-") + if len(versionParts) >= 2 { + // Simple heuristic: version is the part that starts with a digit + for i := 1; i < len(versionParts); i++ { + if len(versionParts[i]) > 0 && versionParts[i][0] >= '0' && versionParts[i][0] <= '9' { + return packageName, versionParts[i] + } + } + } + + return packageName, filename +} + +// getProxyBaseURL constructs the proxy base URL from the request +func getProxyBaseURL(r *http.Request) string { + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + host := r.Host + return fmt.Sprintf("%s://%s/pypi", scheme, host) +} + +// rewritePackagePageURLs rewrites package file URLs in HTML to point to proxy +func rewritePackagePageURLs(html, packageName, proxyBaseURL string) string { + // PyPI Simple API uses href attributes in anchor tags + // We need to rewrite URLs pointing to files.pythonhosted.org or pypi.org + // We preserve the original URL as a query parameter so we can fetch from the correct CDN + + // Regex pattern to match href URLs pointing to package files + // Matches: href="https://files.pythonhosted.org/packages/.../filename.whl" + // Also matches: href="../../packages/.../filename.whl" + pattern := regexp.MustCompile(`href="([^"]*?(\.whl|\.tar\.gz|\.zip|\.egg)[^"]*?)"`) + + result := pattern.ReplaceAllStringFunc(html, func(match string) string { + // Extract the full URL and filename + urlPattern := regexp.MustCompile(`href="([^"]+)"`) + urlMatch := urlPattern.FindStringSubmatch(match) + if len(urlMatch) < 2 { + return match + } + + originalURL := urlMatch[1] + + // Extract just the filename + filenamePattern := regexp.MustCompile(`([^/]+\.(whl|tar\.gz|zip|egg))`) + filenameMatch := filenamePattern.FindString(originalURL) + + if filenameMatch != "" { + // Rewrite to proxy URL format: /pypi/package-name/filename?original_url=... + // This preserves the original CDN URL so we can fetch from the correct location + baseURL := strings.TrimSuffix(proxyBaseURL, "/simple") + + // URL encode the original URL + encodedURL := strings.ReplaceAll(originalURL, "&", "%26") + encodedURL = strings.ReplaceAll(encodedURL, "=", "%3D") + + newURL := fmt.Sprintf(`href="%s/%s/%s?original_url=%s"`, baseURL, packageName, filenameMatch, encodedURL) + return newURL + } + + return match + }) + + return result +} diff --git a/pkg/scanner/ghsa/ghsa.go b/pkg/scanner/ghsa/ghsa.go new file mode 100644 index 0000000..a8099c5 --- /dev/null +++ b/pkg/scanner/ghsa/ghsa.go @@ -0,0 +1,283 @@ +package ghsa + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/lukaszraczylo/gohoarder/pkg/config" + "github.com/lukaszraczylo/gohoarder/pkg/metadata" + "github.com/lukaszraczylo/gohoarder/pkg/uuid" + "github.com/rs/zerolog/log" +) + +// ScannerName is the name of this scanner +const ScannerName = "github-advisory-database" + +// Scanner implements the GitHub Advisory Database vulnerability scanner +type Scanner struct { + config config.GHSAConfig + httpClient *http.Client +} + +// New creates a new GitHub Advisory Database scanner +func New(cfg config.GHSAConfig) *Scanner { + return &Scanner{ + config: cfg, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// Name returns the scanner name +func (s *Scanner) Name() string { + return ScannerName +} + +// Scan scans a package using GitHub Advisory Database API +func (s *Scanner) Scan(ctx context.Context, registry, packageName, version string, filePath string) (*metadata.ScanResult, error) { + log.Info(). + Str("scanner", ScannerName). + Str("package", packageName). + Str("version", version). + Str("registry", registry). + Msg("Starting GitHub Advisory Database scan") + + // Map registry to GitHub ecosystem + ecosystem := s.mapRegistryToEcosystem(registry) + if ecosystem == "" { + return &metadata.ScanResult{ + ID: uuid.New().String(), + Registry: registry, + PackageName: packageName, + PackageVersion: version, + Scanner: ScannerName, + ScannedAt: time.Now(), + Status: metadata.ScanStatusClean, + VulnerabilityCount: 0, + Vulnerabilities: []metadata.Vulnerability{}, + Details: map[string]interface{}{ + "skipped": fmt.Sprintf("GitHub Advisory Database does not support registry: %s", registry), + }, + }, nil + } + + // Query GitHub Advisory Database + advisories, err := s.queryAdvisories(ctx, ecosystem, packageName) + if err != nil { + log.Warn().Err(err).Msg("Failed to query GitHub Advisory Database") + return s.emptyResult(registry, packageName, version), nil + } + + // Filter advisories that affect this version + affectedAdvisories := s.filterAffectedAdvisories(advisories, version) + + // Convert to our format + result := s.convertResult(affectedAdvisories, registry, packageName, version) + + log.Info(). + Str("scanner", ScannerName). + Str("package", packageName). + Int("vulnerabilities", result.VulnerabilityCount). + Msg("GitHub Advisory Database scan completed") + + return result, nil +} + +// Health checks if GitHub API is accessible +func (s *Scanner) Health(ctx context.Context) error { + req, err := http.NewRequestWithContext(ctx, "GET", "https://api.github.com/advisories", nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Accept", "application/vnd.github+json") + if s.config.Token != "" { + req.Header.Set("Authorization", "Bearer "+s.config.Token) + } + + resp, err := s.httpClient.Do(req) + if err != nil { + return fmt.Errorf("github advisory database not accessible: %w", err) + } + defer resp.Body.Close() // #nosec G104 -- Cleanup, error not critical + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("github api returned status: %d", resp.StatusCode) + } + + return nil +} + +// mapRegistryToEcosystem maps our registry names to GitHub ecosystem names +func (s *Scanner) mapRegistryToEcosystem(registry string) string { + mapping := map[string]string{ + "npm": "npm", + "pypi": "pip", + "go": "go", + "maven": "maven", + "nuget": "nuget", + "cargo": "cargo", + "pub": "pub", + } + return mapping[strings.ToLower(registry)] +} + +// queryAdvisories queries GitHub Advisory Database for a package +func (s *Scanner) queryAdvisories(ctx context.Context, ecosystem, packageName string) ([]GHSAAdvisory, error) { + url := fmt.Sprintf("https://api.github.com/advisories?ecosystem=%s&affects=%s&per_page=100", ecosystem, packageName) + + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Accept", "application/vnd.github+json") + if s.config.Token != "" { + req.Header.Set("Authorization", "Bearer "+s.config.Token) + } + + resp, err := s.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to query advisories: %w", err) + } + defer resp.Body.Close() // #nosec G104 -- Cleanup, error not critical + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("github api returned status %d: %s", resp.StatusCode, string(body)) + } + + var advisories []GHSAAdvisory + if err := json.NewDecoder(resp.Body).Decode(&advisories); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return advisories, nil +} + +// filterAffectedAdvisories filters advisories that affect the given version +func (s *Scanner) filterAffectedAdvisories(advisories []GHSAAdvisory, version string) []GHSAAdvisory { + // Check if this version is affected + // GitHub API already filters by package, but we need to check version ranges + // For now, we'll include all advisories that match the package + // A more sophisticated implementation would parse version ranges + affected := append([]GHSAAdvisory(nil), advisories...) + + return affected +} + +// emptyResult returns an empty scan result +func (s *Scanner) emptyResult(registry, packageName, version string) *metadata.ScanResult { + return &metadata.ScanResult{ + ID: uuid.New().String(), + Registry: registry, + PackageName: packageName, + PackageVersion: version, + Scanner: ScannerName, + ScannedAt: time.Now(), + Status: metadata.ScanStatusClean, + VulnerabilityCount: 0, + Vulnerabilities: []metadata.Vulnerability{}, + Details: map[string]interface{}{}, + } +} + +// convertResult converts GitHub Advisory Database results to our ScanResult format +func (s *Scanner) convertResult(advisories []GHSAAdvisory, registry, packageName, version string) *metadata.ScanResult { + vulnerabilities := make([]metadata.Vulnerability, 0) + severityCounts := make(map[string]int) + + for _, advisory := range advisories { + // Normalize severity + normalizedSeverity := metadata.NormalizeSeverity(advisory.Severity) + severityCounts[normalizedSeverity]++ + + // Extract references + refs := make([]string, 0) + if advisory.HTMLURL != "" { + refs = append(refs, advisory.HTMLURL) + } + for _, ref := range advisory.References { + if ref.URL != "" { + refs = append(refs, ref.URL) + } + } + + // Get fixed versions + fixedIn := "" + for _, vuln := range advisory.Vulnerabilities { + if vuln.FirstPatchedVersion != nil && vuln.FirstPatchedVersion.Identifier != "" { + fixedIn = vuln.FirstPatchedVersion.Identifier + break + } + } + + vulnerabilities = append(vulnerabilities, metadata.Vulnerability{ + ID: advisory.GHSAID, + Severity: normalizedSeverity, + Title: advisory.Summary, + Description: advisory.Description, + References: refs, + FixedIn: fixedIn, + }) + } + + status := metadata.ScanStatusClean + if len(vulnerabilities) > 0 { + status = metadata.ScanStatusVulnerable + } + + return &metadata.ScanResult{ + ID: uuid.New().String(), + Registry: registry, + PackageName: packageName, + PackageVersion: version, + Scanner: ScannerName, + ScannedAt: time.Now(), + Status: status, + VulnerabilityCount: len(vulnerabilities), + Vulnerabilities: vulnerabilities, + Details: map[string]interface{}{ + "severity_counts": severityCounts, + }, + } +} + +// GHSAAdvisory represents a GitHub Security Advisory +type GHSAAdvisory struct { + GHSAID string `json:"ghsa_id"` + CVEID string `json:"cve_id"` + Summary string `json:"summary"` + Description string `json:"description"` + Severity string `json:"severity"` + HTMLURL string `json:"html_url"` + References []GHSAReference `json:"references"` + Vulnerabilities []GHSAVulnerability `json:"vulnerabilities"` + PublishedAt string `json:"published_at"` + UpdatedAt string `json:"updated_at"` +} + +type GHSAReference struct { + URL string `json:"url"` +} + +type GHSAVulnerability struct { + Package GHSAPackage `json:"package"` + VulnerableVersions string `json:"vulnerable_version_range"` + FirstPatchedVersion *GHSAPatchVersion `json:"first_patched_version"` +} + +type GHSAPackage struct { + Ecosystem string `json:"ecosystem"` + Name string `json:"name"` +} + +type GHSAPatchVersion struct { + Identifier string `json:"identifier"` +} diff --git a/pkg/scanner/govulncheck/govulncheck.go b/pkg/scanner/govulncheck/govulncheck.go new file mode 100644 index 0000000..47af1f7 --- /dev/null +++ b/pkg/scanner/govulncheck/govulncheck.go @@ -0,0 +1,194 @@ +package govulncheck + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "strings" + "time" + + "github.com/lukaszraczylo/gohoarder/pkg/config" + "github.com/lukaszraczylo/gohoarder/pkg/metadata" + "github.com/lukaszraczylo/gohoarder/pkg/uuid" + "github.com/rs/zerolog/log" +) + +// ScannerName is the name of this scanner +const ScannerName = "govulncheck" + +// Scanner implements the govulncheck vulnerability scanner for Go modules +type Scanner struct { + config config.GovulncheckConfig +} + +// New creates a new govulncheck scanner +func New(cfg config.GovulncheckConfig) *Scanner { + return &Scanner{ + config: cfg, + } +} + +// Name returns the scanner name +func (s *Scanner) Name() string { + return ScannerName +} + +// Scan scans a Go module using govulncheck +func (s *Scanner) Scan(ctx context.Context, registry, packageName, version string, filePath string) (*metadata.ScanResult, error) { + // Only scan Go packages + if registry != "go" { + return &metadata.ScanResult{ + ID: uuid.New().String(), + Registry: registry, + PackageName: packageName, + PackageVersion: version, + Scanner: ScannerName, + ScannedAt: time.Now(), + Status: metadata.ScanStatusClean, + VulnerabilityCount: 0, + Vulnerabilities: []metadata.Vulnerability{}, + Details: map[string]interface{}{ + "skipped": "govulncheck only supports Go modules", + }, + }, nil + } + + log.Info(). + Str("scanner", ScannerName). + Str("package", packageName). + Str("version", version). + Msg("Starting govulncheck scan") + + // Create a temporary directory for extraction + tmpDir, err := os.MkdirTemp("", "govulncheck-*") + if err != nil { + return nil, fmt.Errorf("failed to create temp dir: %w", err) + } + defer os.RemoveAll(tmpDir) + + // Extract the .zip file + if err := s.extractZip(filePath, tmpDir); err != nil { + return nil, fmt.Errorf("failed to extract zip: %w", err) + } + + // Run govulncheck + cmd := exec.CommandContext(ctx, "govulncheck", "-json", "-mode=binary", tmpDir) // #nosec G204 -- govulncheck command with temp directory + output, _ := cmd.CombinedOutput() + + // govulncheck returns non-zero when vulnerabilities are found + // Parse output regardless of error + var vulns []GovulncheckVuln + if len(output) > 0 { + // Parse line-delimited JSON + lines := strings.Split(string(output), "\n") + for _, line := range lines { + if strings.TrimSpace(line) == "" { + continue + } + var entry GovulncheckEntry + if err := json.Unmarshal([]byte(line), &entry); err != nil { + log.Warn().Err(err).Str("line", line).Msg("Failed to parse govulncheck line") + continue + } + if entry.Finding != nil && entry.Finding.OSV != "" { + vulns = append(vulns, GovulncheckVuln{ + OSV: entry.Finding.OSV, + FixedVersion: entry.Finding.FixedVersion, + }) + } + } + } + + // Convert to our format + result := s.convertResult(vulns, registry, packageName, version) + + log.Info(). + Str("scanner", ScannerName). + Str("package", packageName). + Int("vulnerabilities", result.VulnerabilityCount). + Msg("govulncheck scan completed") + + return result, nil +} + +// Health checks if govulncheck is available +func (s *Scanner) Health(ctx context.Context) error { + cmd := exec.CommandContext(ctx, "govulncheck", "-version") + if err := cmd.Run(); err != nil { + return fmt.Errorf("govulncheck not available: %w (install with: go install golang.org/x/vuln/cmd/govulncheck@latest)", err) + } + return nil +} + +// extractZip extracts a zip file to destination +func (s *Scanner) extractZip(zipPath, destDir string) error { + cmd := exec.Command("unzip", "-q", zipPath, "-d", destDir) + return cmd.Run() +} + +// convertResult converts govulncheck findings to our ScanResult format +func (s *Scanner) convertResult(vulns []GovulncheckVuln, registry, packageName, version string) *metadata.ScanResult { + vulnerabilities := make([]metadata.Vulnerability, 0) + severityCounts := make(map[string]int) + seen := make(map[string]bool) + + for _, vuln := range vulns { + // Deduplicate by OSV ID + if seen[vuln.OSV] { + continue + } + seen[vuln.OSV] = true + + // govulncheck doesn't provide severity in output + // Default to HIGH for found vulnerabilities + severity := metadata.NormalizeSeverity("HIGH") + severityCounts[severity]++ + + vulnerabilities = append(vulnerabilities, metadata.Vulnerability{ + ID: vuln.OSV, + Severity: severity, + Title: vuln.OSV, + Description: fmt.Sprintf("Vulnerability %s found by govulncheck", vuln.OSV), + References: []string{fmt.Sprintf("https://pkg.go.dev/vuln/%s", vuln.OSV)}, + FixedIn: vuln.FixedVersion, + }) + } + + status := metadata.ScanStatusClean + if len(vulnerabilities) > 0 { + status = metadata.ScanStatusVulnerable + } + + return &metadata.ScanResult{ + ID: uuid.New().String(), + Registry: registry, + PackageName: packageName, + PackageVersion: version, + Scanner: ScannerName, + ScannedAt: time.Now(), + Status: status, + VulnerabilityCount: len(vulnerabilities), + Vulnerabilities: vulnerabilities, + Details: map[string]interface{}{ + "severity_counts": severityCounts, + "note": "govulncheck provides reachability analysis for Go modules", + }, + } +} + +// GovulncheckEntry represents a single line of govulncheck JSON output +type GovulncheckEntry struct { + Finding *GovulncheckFinding `json:"finding,omitempty"` +} + +type GovulncheckFinding struct { + OSV string `json:"osv"` + FixedVersion string `json:"fixed_version,omitempty"` +} + +type GovulncheckVuln struct { + OSV string + FixedVersion string +} diff --git a/pkg/scanner/grype/grype.go b/pkg/scanner/grype/grype.go new file mode 100644 index 0000000..aa6c41f --- /dev/null +++ b/pkg/scanner/grype/grype.go @@ -0,0 +1,193 @@ +package grype + +import ( + "context" + "encoding/json" + "fmt" + "os/exec" + "time" + + "github.com/lukaszraczylo/gohoarder/pkg/config" + "github.com/lukaszraczylo/gohoarder/pkg/metadata" + "github.com/lukaszraczylo/gohoarder/pkg/uuid" + "github.com/rs/zerolog/log" +) + +// ScannerName is the name of this scanner +const ScannerName = "grype" + +// Scanner implements the Grype vulnerability scanner +type Scanner struct { + config config.GrypeConfig +} + +// New creates a new Grype scanner +func New(cfg config.GrypeConfig) *Scanner { + return &Scanner{ + config: cfg, + } +} + +// Name returns the scanner name +func (s *Scanner) Name() string { + return ScannerName +} + +// Scan scans a package using Grype +func (s *Scanner) Scan(ctx context.Context, registry, packageName, version string, filePath string) (*metadata.ScanResult, error) { + log.Info(). + Str("scanner", ScannerName). + Str("package", packageName). + Str("version", version). + Str("file", filePath). + Msg("Starting Grype scan") + + // Run grype scan + cmd := exec.CommandContext(ctx, "grype", filePath, "-o", "json", "-q") + output, err := cmd.CombinedOutput() + if err != nil { + // Grype returns non-zero exit code when vulnerabilities are found + // Only treat it as error if we got no output + if len(output) == 0 { + return nil, fmt.Errorf("grype scan failed: %w (output: %s)", err, string(output)) + } + } + + // Parse Grype JSON output + var grypeResult GrypeResult + if err := json.Unmarshal(output, &grypeResult); err != nil { + return nil, fmt.Errorf("failed to parse grype output: %w", err) + } + + // Convert to our format + result := s.convertGrypeResult(&grypeResult, registry, packageName, version) + + log.Info(). + Str("scanner", ScannerName). + Str("package", packageName). + Int("vulnerabilities", result.VulnerabilityCount). + Msg("Grype scan completed") + + return result, nil +} + +// Health checks if Grype is available +func (s *Scanner) Health(ctx context.Context) error { + cmd := exec.CommandContext(ctx, "grype", "version") + if err := cmd.Run(); err != nil { + return fmt.Errorf("grype not available: %w", err) + } + return nil +} + +// UpdateDatabase updates Grype's vulnerability database +func (s *Scanner) UpdateDatabase(ctx context.Context) error { + log.Info().Str("scanner", ScannerName).Msg("Updating Grype database") + + cmd := exec.CommandContext(ctx, "grype", "db", "update") + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to update grype database: %w (output: %s)", err, string(output)) + } + + log.Info().Str("scanner", ScannerName).Msg("Grype database updated successfully") + return nil +} + +// convertGrypeResult converts Grype output to our ScanResult format +func (s *Scanner) convertGrypeResult(grypeResult *GrypeResult, registry, packageName, version string) *metadata.ScanResult { + vulnerabilities := make([]metadata.Vulnerability, 0) + severityCounts := make(map[string]int) + + // Process each vulnerability match + for _, match := range grypeResult.Matches { + // Normalize severity + normalizedSeverity := metadata.NormalizeSeverity(match.Vulnerability.Severity) + + // Count by severity + severityCounts[normalizedSeverity]++ + + // Extract fixed version + fixedIn := "" + if match.Vulnerability.Fix.State == "fixed" { + for _, version := range match.Vulnerability.Fix.Versions { + if fixedIn == "" { + fixedIn = version + } + } + } + + // Add to vulnerabilities list + vulnerabilities = append(vulnerabilities, metadata.Vulnerability{ + ID: match.Vulnerability.ID, + Severity: normalizedSeverity, + Title: match.Vulnerability.ID, // Grype doesn't have separate title + Description: match.Vulnerability.Description, + References: match.Vulnerability.URLs, + FixedIn: fixedIn, + }) + } + + // Determine overall status + status := metadata.ScanStatusClean + if len(vulnerabilities) > 0 { + status = metadata.ScanStatusVulnerable + } + + return &metadata.ScanResult{ + ID: uuid.New().String(), + Registry: registry, + PackageName: packageName, + PackageVersion: version, + Scanner: ScannerName, + ScannedAt: time.Now(), + Status: status, + VulnerabilityCount: len(vulnerabilities), + Vulnerabilities: vulnerabilities, + Details: map[string]interface{}{ + "severity_counts": severityCounts, + "grype_version": grypeResult.Descriptor.Version, + }, + } +} + +// GrypeResult represents Grype JSON output structure +type GrypeResult struct { + Matches []GrypeMatch `json:"matches"` + Descriptor GrypeDescriptor `json:"descriptor"` + Source GrypeSource `json:"source"` +} + +type GrypeDescriptor struct { + Name string `json:"name"` + Version string `json:"version"` +} + +type GrypeSource struct { + Type string `json:"type"` + Target map[string]interface{} `json:"target"` +} + +type GrypeMatch struct { + Vulnerability GrypeVulnerability `json:"vulnerability"` + Artifact GrypeArtifact `json:"artifact"` +} + +type GrypeVulnerability struct { + ID string `json:"id"` + Severity string `json:"severity"` + Description string `json:"description"` + URLs []string `json:"urls"` + Fix GrypeFix `json:"fix"` +} + +type GrypeFix struct { + State string `json:"state"` + Versions []string `json:"versions"` +} + +type GrypeArtifact struct { + Name string `json:"name"` + Version string `json:"version"` + Type string `json:"type"` +} diff --git a/pkg/scanner/npmaudit/npmaudit.go b/pkg/scanner/npmaudit/npmaudit.go new file mode 100644 index 0000000..bec736a --- /dev/null +++ b/pkg/scanner/npmaudit/npmaudit.go @@ -0,0 +1,234 @@ +package npmaudit + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "time" + + "github.com/lukaszraczylo/gohoarder/pkg/config" + "github.com/lukaszraczylo/gohoarder/pkg/metadata" + "github.com/lukaszraczylo/gohoarder/pkg/uuid" + "github.com/rs/zerolog/log" +) + +// ScannerName is the name of this scanner +const ScannerName = "npm-audit" + +// Scanner implements the npm audit vulnerability scanner +type Scanner struct { + config config.NpmAuditConfig +} + +// New creates a new npm audit scanner +func New(cfg config.NpmAuditConfig) *Scanner { + return &Scanner{ + config: cfg, + } +} + +// Name returns the scanner name +func (s *Scanner) Name() string { + return ScannerName +} + +// Scan scans an npm package using npm audit +func (s *Scanner) Scan(ctx context.Context, registry, packageName, version string, filePath string) (*metadata.ScanResult, error) { + // Only scan npm packages + if registry != "npm" { + return &metadata.ScanResult{ + ID: uuid.New().String(), + Registry: registry, + PackageName: packageName, + PackageVersion: version, + Scanner: ScannerName, + ScannedAt: time.Now(), + Status: metadata.ScanStatusClean, + VulnerabilityCount: 0, + Vulnerabilities: []metadata.Vulnerability{}, + Details: map[string]interface{}{ + "skipped": "npm-audit only supports npm packages", + }, + }, nil + } + + log.Info(). + Str("scanner", ScannerName). + Str("package", packageName). + Str("version", version). + Msg("Starting npm audit scan") + + // Create a temporary directory + tmpDir, err := os.MkdirTemp("", "npm-audit-*") + if err != nil { + return nil, fmt.Errorf("failed to create temp dir: %w", err) + } + defer os.RemoveAll(tmpDir) + + // Extract the .tgz file + if err := s.extractTgz(filePath, tmpDir); err != nil { + return nil, fmt.Errorf("failed to extract tgz: %w", err) + } + + // Find the package directory (usually "package/") + packageDir := filepath.Join(tmpDir, "package") + if _, err := os.Stat(packageDir); os.IsNotExist(err) { + // Try the tmpDir itself + packageDir = tmpDir + } + + // Run npm audit + cmd := exec.CommandContext(ctx, "npm", "audit", "--json", "--package-lock-only") + cmd.Dir = packageDir + output, _ := cmd.CombinedOutput() // npm audit returns non-zero when vulns found + + // Parse npm audit output + var auditResult NpmAuditResult + if len(output) > 0 { + if err := json.Unmarshal(output, &auditResult); err != nil { + log.Warn().Err(err).Msg("Failed to parse npm audit output") + // Return clean result on parse error + return s.emptyResult(registry, packageName, version), nil + } + } + + // Convert to our format + result := s.convertResult(&auditResult, registry, packageName, version) + + log.Info(). + Str("scanner", ScannerName). + Str("package", packageName). + Int("vulnerabilities", result.VulnerabilityCount). + Msg("npm audit scan completed") + + return result, nil +} + +// Health checks if npm is available +func (s *Scanner) Health(ctx context.Context) error { + cmd := exec.CommandContext(ctx, "npm", "--version") + if err := cmd.Run(); err != nil { + return fmt.Errorf("npm not available: %w", err) + } + return nil +} + +// extractTgz extracts a .tgz file +func (s *Scanner) extractTgz(tgzPath, destDir string) error { + cmd := exec.Command("tar", "-xzf", tgzPath, "-C", destDir) + return cmd.Run() +} + +// emptyResult returns an empty scan result +func (s *Scanner) emptyResult(registry, packageName, version string) *metadata.ScanResult { + return &metadata.ScanResult{ + ID: uuid.New().String(), + Registry: registry, + PackageName: packageName, + PackageVersion: version, + Scanner: ScannerName, + ScannedAt: time.Now(), + Status: metadata.ScanStatusClean, + VulnerabilityCount: 0, + Vulnerabilities: []metadata.Vulnerability{}, + Details: map[string]interface{}{}, + } +} + +// convertResult converts npm audit output to our ScanResult format +func (s *Scanner) convertResult(auditResult *NpmAuditResult, registry, packageName, version string) *metadata.ScanResult { + vulnerabilities := make([]metadata.Vulnerability, 0) + severityCounts := make(map[string]int) + + // Process vulnerabilities from the audit result + for _, vuln := range auditResult.Vulnerabilities { + // Normalize severity + normalizedSeverity := metadata.NormalizeSeverity(vuln.Severity) + severityCounts[normalizedSeverity]++ + + // Get references + refs := make([]string, 0) + if vuln.URL != "" { + refs = append(refs, vuln.URL) + } + for _, ref := range vuln.References { + if ref.URL != "" { + refs = append(refs, ref.URL) + } + } + + // Get fixed version + fixedIn := "" + if vuln.FixAvailable != nil { + fixedIn = fmt.Sprintf("%v", vuln.FixAvailable) + } + + vulnerabilities = append(vulnerabilities, metadata.Vulnerability{ + ID: vuln.Via, + Severity: normalizedSeverity, + Title: vuln.Name, + Description: vuln.Name, + References: refs, + FixedIn: fixedIn, + }) + } + + status := metadata.ScanStatusClean + if len(vulnerabilities) > 0 { + status = metadata.ScanStatusVulnerable + } + + return &metadata.ScanResult{ + ID: uuid.New().String(), + Registry: registry, + PackageName: packageName, + PackageVersion: version, + Scanner: ScannerName, + ScannedAt: time.Now(), + Status: status, + VulnerabilityCount: len(vulnerabilities), + Vulnerabilities: vulnerabilities, + Details: map[string]interface{}{ + "severity_counts": severityCounts, + }, + } +} + +// NpmAuditResult represents npm audit JSON output +type NpmAuditResult struct { + AuditReportVersion int `json:"auditReportVersion"` + Vulnerabilities map[string]NpmVulnerability `json:"vulnerabilities"` + Metadata NpmAuditMetadata `json:"metadata"` +} + +type NpmVulnerability struct { + Name string `json:"name"` + Severity string `json:"severity"` + Via string `json:"via"` + Effects []string `json:"effects"` + Range string `json:"range"` + FixAvailable interface{} `json:"fixAvailable"` + URL string `json:"url"` + References []NpmReference `json:"references"` +} + +type NpmReference struct { + URL string `json:"url"` +} + +type NpmAuditMetadata struct { + Vulnerabilities NpmVulnCounts `json:"vulnerabilities"` + Dependencies int `json:"dependencies"` +} + +type NpmVulnCounts struct { + Info int `json:"info"` + Low int `json:"low"` + Moderate int `json:"moderate"` + High int `json:"high"` + Critical int `json:"critical"` + Total int `json:"total"` +} diff --git a/pkg/scanner/osv/osv.go b/pkg/scanner/osv/osv.go new file mode 100644 index 0000000..e1650ee --- /dev/null +++ b/pkg/scanner/osv/osv.go @@ -0,0 +1,329 @@ +package osv + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/lukaszraczylo/gohoarder/pkg/config" + "github.com/lukaszraczylo/gohoarder/pkg/metadata" + "github.com/lukaszraczylo/gohoarder/pkg/uuid" + "github.com/rs/zerolog/log" +) + +const ( + // ScannerName is the name of this scanner + ScannerName = "osv" + + defaultOSVAPIURL = "https://api.osv.dev/v1/query" +) + +// Scanner implements the Scanner interface using OSV.dev API +type Scanner struct { + config config.OSVConfig + httpClient *http.Client +} + +// OSVRequest represents the request structure for OSV API +type OSVRequest struct { + Package PackageInfo `json:"package"` + Version string `json:"version,omitempty"` +} + +// PackageInfo contains package ecosystem and name +type PackageInfo struct { + Name string `json:"name"` + Ecosystem string `json:"ecosystem"` // npm, PyPI, Go, etc. +} + +// OSVResponse represents the response from OSV API +type OSVResponse struct { + Vulns []OSVVulnerability `json:"vulns"` +} + +// OSVVulnerability represents a vulnerability in OSV format +type OSVVulnerability struct { + ID string `json:"id"` + Summary string `json:"summary"` + Details string `json:"details"` + Severity []OSVSeverity `json:"severity,omitempty"` + References []OSVReference `json:"references,omitempty"` + Affected []OSVAffected `json:"affected"` + DatabaseSpecific map[string]interface{} `json:"database_specific,omitempty"` +} + +// OSVSeverity represents severity information +type OSVSeverity struct { + Type string `json:"type"` // CVSS_V3, etc. + Score string `json:"score"` // Severity score +} + +// OSVReference represents a reference link +type OSVReference struct { + Type string `json:"type"` // WEB, ADVISORY, etc. + URL string `json:"url"` +} + +// OSVAffected represents affected package versions +type OSVAffected struct { + Package PackageInfo `json:"package"` + Ranges []OSVRange `json:"ranges,omitempty"` + Versions []string `json:"versions,omitempty"` + DatabaseSpecific map[string]interface{} `json:"database_specific,omitempty"` + EcosystemSpecific map[string]interface{} `json:"ecosystem_specific,omitempty"` +} + +// OSVRange represents version ranges +type OSVRange struct { + Type string `json:"type"` // SEMVER, GIT, etc. + Events []OSVEvent `json:"events"` +} + +// OSVEvent represents version range events +type OSVEvent struct { + Introduced string `json:"introduced,omitempty"` + Fixed string `json:"fixed,omitempty"` + LastAffected string `json:"last_affected,omitempty"` +} + +// New creates a new OSV scanner +func New(cfg config.OSVConfig) *Scanner { + apiURL := cfg.APIURL + if apiURL == "" { + apiURL = defaultOSVAPIURL + } + + timeout := cfg.Timeout + if timeout == 0 { + timeout = 30 * time.Second + } + + return &Scanner{ + config: cfg, + httpClient: &http.Client{ + Timeout: timeout, + }, + } +} + +// Name returns the scanner name +func (s *Scanner) Name() string { + return ScannerName +} + +// Scan scans a package for vulnerabilities using OSV.dev API +func (s *Scanner) Scan(ctx context.Context, registry, packageName, version string, filePath string) (*metadata.ScanResult, error) { + // Convert registry to OSV ecosystem + ecosystem := s.registryToEcosystem(registry) + + // Build request + req := OSVRequest{ + Package: PackageInfo{ + Name: packageName, + Ecosystem: ecosystem, + }, + Version: version, + } + + // Marshal request + reqBody, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal OSV request: %w", err) + } + + // Create HTTP request + apiURL := s.config.APIURL + if apiURL == "" { + apiURL = defaultOSVAPIURL + } + + httpReq, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(reqBody)) + if err != nil { + return nil, fmt.Errorf("failed to create OSV request: %w", err) + } + + httpReq.Header.Set("Content-Type", "application/json") + + // Execute request + resp, err := s.httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("OSV API request failed: %w", err) + } + defer resp.Body.Close() // #nosec G104 -- Cleanup, error not critical + + // Read response + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read OSV response: %w", err) + } + + // Check status code + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("OSV API returned status %d: %s", resp.StatusCode, string(body)) + } + + // Parse response + var osvResp OSVResponse + if err := json.Unmarshal(body, &osvResp); err != nil { + return nil, fmt.Errorf("failed to parse OSV response: %w", err) + } + + // Convert to metadata.ScanResult + return s.convertOSVResult(&osvResp, registry, packageName, version), nil +} + +// registryToEcosystem converts our registry name to OSV ecosystem +func (s *Scanner) registryToEcosystem(registry string) string { + switch strings.ToLower(registry) { + case "npm": + return "npm" + case "pypi": + return "PyPI" + case "go": + return "Go" + case "maven": + return "Maven" + case "nuget": + return "NuGet" + case "cargo", "crates": + return "crates.io" + case "rubygems": + return "RubyGems" + default: + return registry + } +} + +// convertOSVResult converts OSV response to metadata.ScanResult +func (s *Scanner) convertOSVResult(osvResp *OSVResponse, registry, packageName, version string) *metadata.ScanResult { + vulnerabilities := make([]metadata.Vulnerability, 0, len(osvResp.Vulns)) + severityCounts := make(map[string]int) + + for _, vuln := range osvResp.Vulns { + // Determine severity from various sources + severity := s.determineSeverity(&vuln) + severityCounts[severity]++ + + // Extract references + references := make([]string, 0, len(vuln.References)) + for _, ref := range vuln.References { + references = append(references, ref.URL) + } + + // Find fixed version + fixedVersion := s.findFixedVersion(&vuln, version) + + vulnerabilities = append(vulnerabilities, metadata.Vulnerability{ + ID: vuln.ID, + Severity: severity, + Title: vuln.Summary, + Description: vuln.Details, + References: references, + FixedIn: fixedVersion, + }) + } + + // Determine overall status + status := metadata.ScanStatusClean + if len(vulnerabilities) > 0 { + status = metadata.ScanStatusVulnerable + } + + return &metadata.ScanResult{ + ID: uuid.New().String(), + Registry: registry, + PackageName: packageName, + PackageVersion: version, + Scanner: s.Name(), + ScannedAt: time.Now(), + Status: status, + VulnerabilityCount: len(vulnerabilities), + Vulnerabilities: vulnerabilities, + Details: map[string]interface{}{ + "ecosystem": s.registryToEcosystem(registry), + "severity_counts": severityCounts, + }, + } +} + +// determineSeverity extracts severity from OSV vulnerability +func (s *Scanner) determineSeverity(vuln *OSVVulnerability) string { + var rawSeverity string + + // Try to get severity from CVSS + for _, sev := range vuln.Severity { + if sev.Type == "CVSS_V3" || sev.Type == "CVSS_V2" { + // Parse CVSS score to severity + score := sev.Score + if strings.Contains(strings.ToUpper(score), "CRITICAL") { + rawSeverity = "CRITICAL" + } else if strings.Contains(strings.ToUpper(score), "HIGH") { + rawSeverity = "HIGH" + } else if strings.Contains(strings.ToUpper(score), "MEDIUM") || strings.Contains(strings.ToUpper(score), "MODERATE") { + rawSeverity = "MODERATE" + } else if strings.Contains(strings.ToUpper(score), "LOW") { + rawSeverity = "LOW" + } + if rawSeverity != "" { + break + } + } + } + + // Check database_specific for severity if not found in CVSS + if rawSeverity == "" && vuln.DatabaseSpecific != nil { + if sev, ok := vuln.DatabaseSpecific["severity"].(string); ok { + rawSeverity = sev + } + } + + // Default to MODERATE if unknown + if rawSeverity == "" { + rawSeverity = "MODERATE" + } + + // Normalize to standard severity values + return metadata.NormalizeSeverity(rawSeverity) +} + +// findFixedVersion extracts the fixed version from OSV affected ranges +func (s *Scanner) findFixedVersion(vuln *OSVVulnerability, currentVersion string) string { + for _, affected := range vuln.Affected { + for _, r := range affected.Ranges { + for _, event := range r.Events { + if event.Fixed != "" { + return event.Fixed + } + } + } + } + return "" +} + +// Health checks if OSV API is reachable +func (s *Scanner) Health(ctx context.Context) error { + // Make a simple request to check API availability + apiURL := s.config.APIURL + if apiURL == "" { + apiURL = defaultOSVAPIURL + } + + req, err := http.NewRequestWithContext(ctx, "GET", strings.Replace(apiURL, "/query", "", 1), nil) + if err != nil { + return fmt.Errorf("failed to create health check request: %w", err) + } + + resp, err := s.httpClient.Do(req) + if err != nil { + return fmt.Errorf("OSV API not reachable: %w", err) + } + defer resp.Body.Close() // #nosec G104 -- Cleanup, error not critical + + log.Debug().Int("status", resp.StatusCode).Msg("OSV health check passed") + return nil +} diff --git a/pkg/scanner/pipaudit/pipaudit.go b/pkg/scanner/pipaudit/pipaudit.go new file mode 100644 index 0000000..5096f80 --- /dev/null +++ b/pkg/scanner/pipaudit/pipaudit.go @@ -0,0 +1,209 @@ +package pipaudit + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "time" + + "github.com/lukaszraczylo/gohoarder/pkg/config" + "github.com/lukaszraczylo/gohoarder/pkg/metadata" + "github.com/lukaszraczylo/gohoarder/pkg/uuid" + "github.com/rs/zerolog/log" +) + +// ScannerName is the name of this scanner +const ScannerName = "pip-audit" + +// Scanner implements the pip-audit vulnerability scanner +type Scanner struct { + config config.PipAuditConfig +} + +// New creates a new pip-audit scanner +func New(cfg config.PipAuditConfig) *Scanner { + return &Scanner{ + config: cfg, + } +} + +// Name returns the scanner name +func (s *Scanner) Name() string { + return ScannerName +} + +// Scan scans a Python package using pip-audit +func (s *Scanner) Scan(ctx context.Context, registry, packageName, version string, filePath string) (*metadata.ScanResult, error) { + // Only scan PyPI packages + if registry != "pypi" { + return &metadata.ScanResult{ + ID: uuid.New().String(), + Registry: registry, + PackageName: packageName, + PackageVersion: version, + Scanner: ScannerName, + ScannedAt: time.Now(), + Status: metadata.ScanStatusClean, + VulnerabilityCount: 0, + Vulnerabilities: []metadata.Vulnerability{}, + Details: map[string]interface{}{ + "skipped": "pip-audit only supports PyPI packages", + }, + }, nil + } + + log.Info(). + Str("scanner", ScannerName). + Str("package", packageName). + Str("version", version). + Msg("Starting pip-audit scan") + + // Create a temporary directory + tmpDir, err := os.MkdirTemp("", "pip-audit-*") + if err != nil { + return nil, fmt.Errorf("failed to create temp dir: %w", err) + } + defer os.RemoveAll(tmpDir) + + // Copy the wheel/tar.gz file to temp directory + tmpFile := filepath.Join(tmpDir, filepath.Base(filePath)) + if err := s.copyFile(filePath, tmpFile); err != nil { + return nil, fmt.Errorf("failed to copy file: %w", err) + } + + // Run pip-audit on the package file + cmd := exec.CommandContext(ctx, "pip-audit", "-r", tmpFile, "--format", "json") // #nosec G204 -- pip-audit command with temp file + output, _ := cmd.CombinedOutput() // pip-audit returns non-zero when vulns found + + // Parse pip-audit output + var auditResult PipAuditResult + if len(output) > 0 { + if err := json.Unmarshal(output, &auditResult); err != nil { + log.Warn().Err(err).Msg("Failed to parse pip-audit output") + return s.emptyResult(registry, packageName, version), nil + } + } + + // Convert to our format + result := s.convertResult(&auditResult, registry, packageName, version) + + log.Info(). + Str("scanner", ScannerName). + Str("package", packageName). + Int("vulnerabilities", result.VulnerabilityCount). + Msg("pip-audit scan completed") + + return result, nil +} + +// Health checks if pip-audit is available +func (s *Scanner) Health(ctx context.Context) error { + cmd := exec.CommandContext(ctx, "pip-audit", "--version") + if err := cmd.Run(); err != nil { + return fmt.Errorf("pip-audit not available: %w (install with: pip install pip-audit)", err) + } + return nil +} + +// copyFile copies a file from src to dst +func (s *Scanner) copyFile(src, dst string) error { + input, err := os.ReadFile(src) // #nosec G304 -- Source path is from scanner, controlled + if err != nil { + return err + } + return os.WriteFile(dst, input, 0600) +} + +// emptyResult returns an empty scan result +func (s *Scanner) emptyResult(registry, packageName, version string) *metadata.ScanResult { + return &metadata.ScanResult{ + ID: uuid.New().String(), + Registry: registry, + PackageName: packageName, + PackageVersion: version, + Scanner: ScannerName, + ScannedAt: time.Now(), + Status: metadata.ScanStatusClean, + VulnerabilityCount: 0, + Vulnerabilities: []metadata.Vulnerability{}, + Details: map[string]interface{}{}, + } +} + +// convertResult converts pip-audit output to our ScanResult format +func (s *Scanner) convertResult(auditResult *PipAuditResult, registry, packageName, version string) *metadata.ScanResult { + vulnerabilities := make([]metadata.Vulnerability, 0) + severityCounts := make(map[string]int) + + for _, dep := range auditResult.Dependencies { + for _, vuln := range dep.Vulns { + // Map pip-audit severity to our standard + severity := s.mapSeverity(vuln.ID) + normalizedSeverity := metadata.NormalizeSeverity(severity) + severityCounts[normalizedSeverity]++ + + // Get fixed versions + fixedIn := "" + if len(vuln.FixVersions) > 0 { + fixedIn = vuln.FixVersions[0] + } + + vulnerabilities = append(vulnerabilities, metadata.Vulnerability{ + ID: vuln.ID, + Severity: normalizedSeverity, + Title: vuln.ID, + Description: vuln.Description, + References: []string{fmt.Sprintf("https://osv.dev/vulnerability/%s", vuln.ID)}, + FixedIn: fixedIn, + }) + } + } + + status := metadata.ScanStatusClean + if len(vulnerabilities) > 0 { + status = metadata.ScanStatusVulnerable + } + + return &metadata.ScanResult{ + ID: uuid.New().String(), + Registry: registry, + PackageName: packageName, + PackageVersion: version, + Scanner: ScannerName, + ScannedAt: time.Now(), + Status: status, + VulnerabilityCount: len(vulnerabilities), + Vulnerabilities: vulnerabilities, + Details: map[string]interface{}{ + "severity_counts": severityCounts, + }, + } +} + +// mapSeverity maps vulnerability ID patterns to severity levels +func (s *Scanner) mapSeverity(vulnID string) string { + // pip-audit doesn't provide severity directly + // Default to MODERATE for all findings + return "MODERATE" +} + +// PipAuditResult represents pip-audit JSON output +type PipAuditResult struct { + Dependencies []PipDependency `json:"dependencies"` +} + +type PipDependency struct { + Name string `json:"name"` + Version string `json:"version"` + Vulns []PipVuln `json:"vulns"` +} + +type PipVuln struct { + ID string `json:"id"` + Description string `json:"description"` + FixVersions []string `json:"fix_versions"` + Aliases []string `json:"aliases"` +} diff --git a/pkg/scanner/rescanner.go b/pkg/scanner/rescanner.go new file mode 100644 index 0000000..958696c --- /dev/null +++ b/pkg/scanner/rescanner.go @@ -0,0 +1,219 @@ +package scanner + +import ( + "context" + "time" + + "github.com/lukaszraczylo/gohoarder/pkg/metadata" + "github.com/lukaszraczylo/gohoarder/pkg/storage" + "github.com/rs/zerolog/log" +) + +// RescanWorker handles periodic re-scanning of cached packages +type RescanWorker struct { + manager *Manager + metadataStore metadata.MetadataStore + storage storage.StorageBackend + interval time.Duration + stopCh chan struct{} +} + +// NewRescanWorker creates a new rescan worker +func NewRescanWorker(manager *Manager, metadataStore metadata.MetadataStore, storageBackend storage.StorageBackend, interval time.Duration) *RescanWorker { + return &RescanWorker{ + manager: manager, + metadataStore: metadataStore, + storage: storageBackend, + interval: interval, + stopCh: make(chan struct{}), + } +} + +// Start begins the periodic re-scanning process +func (w *RescanWorker) Start(ctx context.Context) { + if !w.manager.enabled || w.interval == 0 { + log.Info().Msg("Rescan worker disabled") + return + } + + log.Info(). + Dur("interval", w.interval). + Msg("Starting package rescan worker") + + ticker := time.NewTicker(w.interval) + defer ticker.Stop() + + // Run initial scan immediately on startup + log.Info().Msg("Running initial package scan on startup") + w.rescanPackages(ctx) + log.Info(). + Dur("next_scan", w.interval). + Msg("Initial scan complete, next scan scheduled") + + for { + select { + case <-ticker.C: + w.rescanPackages(ctx) + case <-w.stopCh: + log.Info().Msg("Rescan worker stopped") + return + case <-ctx.Done(): + log.Info().Msg("Rescan worker stopped (context cancelled)") + return + } + } +} + +// Stop stops the rescan worker +func (w *RescanWorker) Stop() { + close(w.stopCh) +} + +// rescanPackages re-scans packages that need updating +func (w *RescanWorker) rescanPackages(ctx context.Context) { + log.Info().Msg("Starting package rescan cycle - checking all packages for scan status") + + // Get all packages + packages, err := w.metadataStore.ListPackages(ctx, &metadata.ListOptions{}) + if err != nil { + log.Error().Err(err).Msg("Failed to list packages for rescan") + return + } + + scanned := 0 + skipped := 0 + failed := 0 + + for _, pkg := range packages { + // Skip metadata entries (npm metadata pages, pypi pages, etc.) + if pkg.Version == "list" || pkg.Version == "latest" || pkg.Version == "metadata" || pkg.Version == "page" { + skipped++ + continue + } + + // Check if package needs rescanning + needsRescan, err := w.needsRescan(ctx, pkg) + if err != nil { + log.Error(). + Err(err). + Str("package", pkg.Name). + Str("version", pkg.Version). + Msg("Failed to check rescan status") + failed++ + continue + } + + if !needsRescan { + log.Debug(). + Str("package", pkg.Name). + Str("version", pkg.Version). + Bool("security_scanned", pkg.SecurityScanned). + Msg("Package does not need rescanning, skipping") + skipped++ + continue + } + + log.Info(). + Str("registry", pkg.Registry). + Str("package", pkg.Name). + Str("version", pkg.Version). + Msg("Package needs rescanning") + + // Get file path from storage using the storage key from the package metadata + if pkg.StorageKey == "" { + log.Warn(). + Str("registry", pkg.Registry). + Str("package", pkg.Name). + Str("version", pkg.Version). + Msg("Package has no storage key, skipping rescan") + failed++ + continue + } + + filePath, err := w.getPackageFilePath(ctx, pkg.StorageKey) + if err != nil { + log.Warn(). + Err(err). + Str("registry", pkg.Registry). + Str("package", pkg.Name). + Str("version", pkg.Version). + Str("storage_key", pkg.StorageKey). + Msg("Failed to get package file path, skipping rescan") + failed++ + continue + } + + if filePath == "" { + log.Debug(). + Str("registry", pkg.Registry). + Str("package", pkg.Name). + Str("version", pkg.Version). + Msg("No local file path available, skipping rescan") + skipped++ + continue + } + + // Perform the actual scan + if err := w.manager.ScanPackage(ctx, pkg.Registry, pkg.Name, pkg.Version, filePath); err != nil { + log.Error(). + Err(err). + Str("registry", pkg.Registry). + Str("package", pkg.Name). + Str("version", pkg.Version). + Msg("Failed to rescan package") + failed++ + continue + } + + scanned++ + } + + log.Info(). + Int("total", len(packages)). + Int("scanned", scanned). + Int("skipped", skipped). + Int("failed", failed). + Msg("Rescan cycle completed") +} + +// needsRescan checks if a package needs to be rescanned +func (w *RescanWorker) needsRescan(ctx context.Context, pkg *metadata.Package) (bool, error) { + // Get latest scan result + scanResult, err := w.metadataStore.GetScanResult(ctx, pkg.Registry, pkg.Name, pkg.Version) + if err != nil { + // No scan result - needs scanning + log.Debug(). + Str("package", pkg.Name). + Str("version", pkg.Version). + Msg("Package has no scan result, needs scanning") + return true, nil + } + + // If package is not marked as scanned but has scan result, it's a stale state - rescan + if !pkg.SecurityScanned { + log.Info(). + Str("package", pkg.Name). + Str("version", pkg.Version). + Msg("Package has scan result but security_scanned flag is false, needs update") + return true, nil + } + + // Check if scan is older than rescan interval + timeSinceLastScan := time.Since(scanResult.ScannedAt) + if timeSinceLastScan >= w.interval { + return true, nil + } + + return false, nil +} + +// getPackageFilePath retrieves the local file path for a package from storage +func (w *RescanWorker) getPackageFilePath(ctx context.Context, storageKey string) (string, error) { + // Check if storage backend supports local paths + if localProvider, ok := w.storage.(storage.LocalPathProvider); ok { + return localProvider.GetLocalPath(ctx, storageKey) + } + + // If storage doesn't support local paths (S3, SMB), we can't rescan + return "", nil +} diff --git a/pkg/scanner/scanner.go b/pkg/scanner/scanner.go new file mode 100644 index 0000000..5afc9aa --- /dev/null +++ b/pkg/scanner/scanner.go @@ -0,0 +1,515 @@ +package scanner + +import ( + "context" + "fmt" + "strings" + + "github.com/lukaszraczylo/gohoarder/pkg/config" + "github.com/lukaszraczylo/gohoarder/pkg/metadata" + "github.com/lukaszraczylo/gohoarder/pkg/scanner/ghsa" + "github.com/lukaszraczylo/gohoarder/pkg/scanner/govulncheck" + "github.com/lukaszraczylo/gohoarder/pkg/scanner/grype" + "github.com/lukaszraczylo/gohoarder/pkg/scanner/npmaudit" + "github.com/lukaszraczylo/gohoarder/pkg/scanner/osv" + "github.com/lukaszraczylo/gohoarder/pkg/scanner/pipaudit" + "github.com/lukaszraczylo/gohoarder/pkg/scanner/trivy" + "github.com/rs/zerolog/log" +) + +// Scanner defines the interface for security scanners +type Scanner interface { + // Name returns the scanner name + Name() string + + // Scan scans a package for vulnerabilities + Scan(ctx context.Context, registry, packageName, version string, filePath string) (*metadata.ScanResult, error) + + // Health checks scanner health + Health(ctx context.Context) error +} + +// DatabaseUpdater is implemented by scanners that need database updates +type DatabaseUpdater interface { + UpdateDatabase(ctx context.Context) error +} + +// Manager manages multiple security scanners +type Manager struct { + scanners []Scanner + enabled bool + config config.SecurityConfig + metadataStore metadata.MetadataStore +} + +// New creates a new scanner manager with configured scanners +func New(cfg config.SecurityConfig, metadataStore metadata.MetadataStore) (*Manager, error) { + manager := &Manager{ + scanners: make([]Scanner, 0), + enabled: cfg.Enabled, + config: cfg, + metadataStore: metadataStore, + } + + if !cfg.Enabled { + log.Info().Msg("Security scanning disabled") + return manager, nil + } + + // Initialize Trivy scanner + if cfg.Scanners.Trivy.Enabled { + trivyScanner := trivy.New(cfg.Scanners.Trivy) + manager.RegisterScanner(trivyScanner) + log.Info().Msg("Trivy scanner enabled") + + // Update database on startup if configured + if cfg.UpdateDBOnStartup { + if err := trivyScanner.UpdateDatabase(context.Background()); err != nil { + log.Warn().Err(err).Msg("Failed to update Trivy database on startup") + } + } + } + + // Initialize OSV scanner + if cfg.Scanners.OSV.Enabled { + osvScanner := osv.New(cfg.Scanners.OSV) + manager.RegisterScanner(osvScanner) + log.Info().Msg("OSV scanner enabled") + } + + // Initialize Grype scanner + if cfg.Scanners.Grype.Enabled { + grypeScanner := grype.New(cfg.Scanners.Grype) + manager.RegisterScanner(grypeScanner) + log.Info().Msg("Grype scanner enabled") + + // Update database on startup if configured + if cfg.UpdateDBOnStartup { + if err := grypeScanner.UpdateDatabase(context.Background()); err != nil { + log.Warn().Err(err).Msg("Failed to update Grype database on startup") + } + } + } + + // Initialize govulncheck scanner + if cfg.Scanners.Govulncheck.Enabled { + govulncheckScanner := govulncheck.New(cfg.Scanners.Govulncheck) + manager.RegisterScanner(govulncheckScanner) + log.Info().Msg("govulncheck scanner enabled") + } + + // Initialize npm-audit scanner + if cfg.Scanners.NpmAudit.Enabled { + npmAuditScanner := npmaudit.New(cfg.Scanners.NpmAudit) + manager.RegisterScanner(npmAuditScanner) + log.Info().Msg("npm-audit scanner enabled") + } + + // Initialize pip-audit scanner + if cfg.Scanners.PipAudit.Enabled { + pipAuditScanner := pipaudit.New(cfg.Scanners.PipAudit) + manager.RegisterScanner(pipAuditScanner) + log.Info().Msg("pip-audit scanner enabled") + } + + // Initialize GitHub Advisory Database scanner + if cfg.Scanners.GHSA.Enabled { + ghsaScanner := ghsa.New(cfg.Scanners.GHSA) + manager.RegisterScanner(ghsaScanner) + log.Info().Msg("GitHub Advisory Database scanner enabled") + } + + if len(manager.scanners) == 0 { + log.Warn().Msg("Security scanning enabled but no scanners configured") + } + + return manager, nil +} + +// RegisterScanner registers a scanner +func (m *Manager) RegisterScanner(scanner Scanner) { + m.scanners = append(m.scanners, scanner) +} + +// ScanPackage scans a package using all registered scanners and saves results +func (m *Manager) ScanPackage(ctx context.Context, registry, packageName, version string, filePath string) error { + if !m.enabled { + return nil + } + + log.Info(). + Str("registry", registry). + Str("package", packageName). + Str("version", version). + Msg("Starting security scan") + + // Collect results from all scanners + var scanResults []*metadata.ScanResult + scannerNames := make([]string, 0) + + for _, scanner := range m.scanners { + // Skip scanners that don't support this registry + if !m.shouldRunScanner(scanner.Name(), registry) { + log.Debug(). + Str("scanner", scanner.Name()). + Str("registry", registry). + Msg("Skipping scanner - not compatible with registry") + continue + } + + result, err := scanner.Scan(ctx, registry, packageName, version, filePath) + if err != nil { + log.Error(). + Err(err). + Str("scanner", scanner.Name()). + Str("package", packageName). + Msg("Scanner failed") + continue + } + + scanResults = append(scanResults, result) + scannerNames = append(scannerNames, scanner.Name()) + + log.Info(). + Str("scanner", scanner.Name()). + Str("package", packageName). + Str("status", string(result.Status)). + Int("vulnerabilities", result.VulnerabilityCount). + Msg("Scan completed") + } + + // If no scanners succeeded, return + if len(scanResults) == 0 { + log.Warn(). + Str("package", packageName). + Msg("All scanners failed, no results to save") + return nil + } + + // Merge and deduplicate results from all scanners + mergedResult := m.mergeResults(scanResults, scannerNames) + + // Save consolidated result to metadata store + if err := m.metadataStore.SaveScanResult(ctx, mergedResult); err != nil { + log.Error(). + Err(err). + Str("package", packageName). + Msg("Failed to save consolidated scan result") + return err + } + + log.Info(). + Str("package", packageName). + Str("status", string(mergedResult.Status)). + Int("total_vulnerabilities", mergedResult.VulnerabilityCount). + Int("unique_cves", len(mergedResult.Vulnerabilities)). + Strs("scanners", scannerNames). + Msg("Consolidated scan results saved") + + return nil +} + +// mergeResults merges and deduplicates scan results from multiple scanners +func (m *Manager) mergeResults(results []*metadata.ScanResult, scannerNames []string) *metadata.ScanResult { + if len(results) == 0 { + return nil + } + + // Use first result as base + merged := &metadata.ScanResult{ + ID: results[0].ID, + Registry: results[0].Registry, + PackageName: results[0].PackageName, + PackageVersion: results[0].PackageVersion, + Scanner: strings.Join(scannerNames, "+"), // Combined scanner name + ScannedAt: results[0].ScannedAt, + Status: metadata.ScanStatusClean, + Vulnerabilities: make([]metadata.Vulnerability, 0), + Details: make(map[string]interface{}), + } + + // Use map for deduplication - key is CVE ID in uppercase + vulnMap := make(map[string]*metadata.Vulnerability) + severityCounts := make(map[string]int) + + // Merge vulnerabilities from all scanners + for i, result := range results { + scannerName := scannerNames[i] + + // Track scanner details + merged.Details[scannerName] = result.Details + + // Add/merge vulnerabilities + for _, vuln := range result.Vulnerabilities { + cveKey := strings.ToUpper(vuln.ID) + + // Check if CVE already exists + if existing, exists := vulnMap[cveKey]; exists { + // CVE found by multiple scanners - merge information + log.Debug(). + Str("cve", vuln.ID). + Strs("existing_scanners", existing.DetectedBy). + Str("new_scanner", scannerName). + Msg("CVE found by multiple scanners, merging") + + // Add scanner to DetectedBy list + existing.DetectedBy = append(existing.DetectedBy, scannerName) + + // Prefer higher severity if different + if m.compareSeverity(vuln.Severity, existing.Severity) > 0 { + existing.Severity = vuln.Severity + } + + // Merge references (deduplicate URLs) + refSet := make(map[string]bool) + for _, ref := range existing.References { + refSet[ref] = true + } + for _, ref := range vuln.References { + if !refSet[ref] { + existing.References = append(existing.References, ref) + refSet[ref] = true + } + } + + // Prefer fixed_in version if not already set + if existing.FixedIn == "" && vuln.FixedIn != "" { + existing.FixedIn = vuln.FixedIn + } + + } else { + // New CVE - add to map + vulnCopy := vuln + vulnCopy.DetectedBy = []string{scannerName} + vulnMap[cveKey] = &vulnCopy + } + } + + // Update status to worst case + if result.Status == metadata.ScanStatusVulnerable { + merged.Status = metadata.ScanStatusVulnerable + } else if result.Status == metadata.ScanStatusPending && merged.Status != metadata.ScanStatusVulnerable { + merged.Status = metadata.ScanStatusPending + } + } + + // Convert map to slice and count severities + for _, vuln := range vulnMap { + merged.Vulnerabilities = append(merged.Vulnerabilities, *vuln) + severityCounts[strings.ToUpper(vuln.Severity)]++ + } + + // Update counts + merged.VulnerabilityCount = len(merged.Vulnerabilities) + merged.Details["severity_counts"] = severityCounts + merged.Details["deduplication_summary"] = fmt.Sprintf( + "Merged results from %d scanners (%s)", + len(scannerNames), + strings.Join(scannerNames, ", "), + ) + + return merged +} + +// compareSeverity returns >0 if s1 is more severe than s2, <0 if less, 0 if equal +func (m *Manager) compareSeverity(s1, s2 string) int { + severityOrder := map[string]int{ + "CRITICAL": 4, + "HIGH": 3, + "MODERATE": 2, + "MEDIUM": 2, // Support both for backwards compatibility + "LOW": 1, + "UNKNOWN": 0, + } + + v1 := severityOrder[strings.ToUpper(s1)] + v2 := severityOrder[strings.ToUpper(s2)] + + return v1 - v2 +} + +// CheckVulnerabilities checks if a package exceeds vulnerability thresholds +func (m *Manager) CheckVulnerabilities(ctx context.Context, registry, packageName, version string) (bool, string, error) { + if !m.enabled { + return false, "", nil + } + + // Get active CVE bypasses from database + bypasses, err := m.metadataStore.GetActiveCVEBypasses(ctx) + if err != nil { + log.Warn().Err(err).Msg("Failed to get CVE bypasses, continuing without bypasses") + bypasses = []*metadata.CVEBypass{} // Continue without bypasses + } + + // Check if entire package is bypassed + packageKey := fmt.Sprintf("%s/%s@%s", registry, packageName, version) + packageKeyNoVersion := fmt.Sprintf("%s/%s", registry, packageName) + + for _, bypass := range bypasses { + if bypass.Type == metadata.BypassTypePackage && bypass.Active { + if bypass.Target == packageKey || bypass.Target == packageKeyNoVersion { + log.Info(). + Str("package", packageKey). + Str("bypass_id", bypass.ID). + Str("reason", bypass.Reason). + Time("expires_at", bypass.ExpiresAt). + Msg("Package bypassed by admin") + return false, "", nil + } + } + } + + // Get latest scan result + result, err := m.metadataStore.GetScanResult(ctx, registry, packageName, version) + if err != nil { + // No scan result found - allow download (will be scanned after) + return false, "", nil + } + + // Build set of bypassed CVEs for fast lookup + bypassedCVEs := make(map[string]*metadata.CVEBypass) + for _, bypass := range bypasses { + if bypass.Type == metadata.BypassTypeCVE && bypass.Active { + // Check if bypass applies to this package (if AppliesTo is set) + if bypass.AppliesTo != "" && bypass.AppliesTo != packageKey && bypass.AppliesTo != packageKeyNoVersion { + continue // This bypass doesn't apply to this package + } + bypassedCVEs[strings.ToUpper(bypass.Target)] = bypass + } + } + + // Count vulnerabilities by severity, excluding bypassed CVEs + severityCounts := make(map[string]int) + for _, vuln := range result.Vulnerabilities { + // Check if this CVE is bypassed + if bypass, ok := bypassedCVEs[strings.ToUpper(vuln.ID)]; ok { + log.Debug(). + Str("cve", vuln.ID). + Str("package", packageName). + Str("bypass_id", bypass.ID). + Str("reason", bypass.Reason). + Time("expires_at", bypass.ExpiresAt). + Msg("CVE bypassed by admin") + continue + } + severityCounts[strings.ToUpper(vuln.Severity)]++ + } + + // Check against thresholds + thresholds := m.config.BlockThresholds + + // Check critical + if thresholds.Critical >= 0 && severityCounts["CRITICAL"] > thresholds.Critical { + return true, fmt.Sprintf("Package has %d CRITICAL vulnerabilities (threshold: %d)", + severityCounts["CRITICAL"], thresholds.Critical), nil + } + + // Check high + if thresholds.High >= 0 && severityCounts["HIGH"] > thresholds.High { + return true, fmt.Sprintf("Package has %d HIGH vulnerabilities (threshold: %d)", + severityCounts["HIGH"], thresholds.High), nil + } + + // Check moderate (medium) + moderateCount := severityCounts["MODERATE"] + severityCounts["MEDIUM"] // Support both for backwards compatibility + if thresholds.Medium >= 0 && moderateCount > thresholds.Medium { + return true, fmt.Sprintf("Package has %d MODERATE vulnerabilities (threshold: %d)", + moderateCount, thresholds.Medium), nil + } + + // Check low + if thresholds.Low >= 0 && severityCounts["LOW"] > thresholds.Low { + return true, fmt.Sprintf("Package has %d LOW vulnerabilities (threshold: %d)", + severityCounts["LOW"], thresholds.Low), nil + } + + // Check block on severity + if m.config.BlockOnSeverity != "" && m.config.BlockOnSeverity != "none" { + severity := strings.ToUpper(m.config.BlockOnSeverity) + + // Block if any vulnerabilities at or above the specified severity exist + switch severity { + case "CRITICAL": + if severityCounts["CRITICAL"] > 0 { + return true, "Package has CRITICAL vulnerabilities", nil + } + case "HIGH": + if severityCounts["CRITICAL"] > 0 || severityCounts["HIGH"] > 0 { + return true, "Package has HIGH or CRITICAL vulnerabilities", nil + } + case "MODERATE", "MEDIUM": + moderateCount := severityCounts["MODERATE"] + severityCounts["MEDIUM"] + if severityCounts["CRITICAL"] > 0 || severityCounts["HIGH"] > 0 || moderateCount > 0 { + return true, "Package has MODERATE, HIGH, or CRITICAL vulnerabilities", nil + } + case "LOW": + if len(result.Vulnerabilities) > 0 { + return true, "Package has vulnerabilities", nil + } + } + } + + return false, "", nil +} + +// UpdateDatabases updates vulnerability databases for all scanners +func (m *Manager) UpdateDatabases(ctx context.Context) error { + if !m.enabled { + return nil + } + + log.Info().Msg("Updating vulnerability databases") + + for _, scanner := range m.scanners { + if updater, ok := scanner.(DatabaseUpdater); ok { + if err := updater.UpdateDatabase(ctx); err != nil { + log.Error(). + Err(err). + Str("scanner", scanner.Name()). + Msg("Failed to update database") + return err + } + } + } + + log.Info().Msg("Vulnerability databases updated successfully") + return nil +} + +// Health checks health of all scanners +func (m *Manager) Health(ctx context.Context) error { + if !m.enabled { + return nil + } + + for _, scanner := range m.scanners { + if err := scanner.Health(ctx); err != nil { + return fmt.Errorf("scanner %s health check failed: %w", scanner.Name(), err) + } + } + return nil +} + +// shouldRunScanner determines if a scanner should run for a given registry +// Language-specific scanners only run for their target ecosystems +func (m *Manager) shouldRunScanner(scannerName, registry string) bool { + registry = strings.ToLower(registry) + + // Language-specific scanners - only run for their target registry + switch scannerName { + case "govulncheck": + return registry == "go" + case "npm-audit": + return registry == "npm" + case "pip-audit": + return registry == "pypi" + + // Multi-ecosystem scanners - run for all registries + case "trivy", "osv", "grype", "github-advisory-database": + return true + + // Default: allow scanner to run (for future scanners) + default: + return true + } +} diff --git a/pkg/scanner/trivy/trivy.go b/pkg/scanner/trivy/trivy.go new file mode 100644 index 0000000..ae120f9 --- /dev/null +++ b/pkg/scanner/trivy/trivy.go @@ -0,0 +1,243 @@ +package trivy + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "strings" + "time" + + "github.com/lukaszraczylo/gohoarder/pkg/config" + "github.com/lukaszraczylo/gohoarder/pkg/metadata" + "github.com/lukaszraczylo/gohoarder/pkg/uuid" + "github.com/rs/zerolog/log" +) + +// ScannerName is the name of this scanner +const ScannerName = "trivy" + +// Scanner implements the Scanner interface using Trivy +type Scanner struct { + config config.TrivyConfig +} + +// TrivyResult represents Trivy JSON output structure +type TrivyResult struct { + SchemaVersion int `json:"SchemaVersion"` + ArtifactName string `json:"ArtifactName"` + ArtifactType string `json:"ArtifactType"` + Metadata TrivyMetadata `json:"Metadata"` + Results []TrivyVulnResult `json:"Results"` +} + +type TrivyMetadata struct { + OS *TrivyOS `json:"OS,omitempty"` + RepoTags []string `json:"RepoTags,omitempty"` + RepoDigests []string `json:"RepoDigests,omitempty"` + ImageConfig *TrivyImageConfig `json:"ImageConfig,omitempty"` +} + +type TrivyOS struct { + Family string `json:"Family"` + Name string `json:"Name"` +} + +type TrivyImageConfig struct { + Architecture string `json:"architecture"` + Created string `json:"created"` +} + +type TrivyVulnResult struct { + Target string `json:"Target"` + Class string `json:"Class"` + Type string `json:"Type"` + Vulnerabilities []TrivyVulnerability `json:"Vulnerabilities"` +} + +type TrivyVulnerability struct { + VulnerabilityID string `json:"VulnerabilityID"` + PkgName string `json:"PkgName"` + InstalledVersion string `json:"InstalledVersion"` + FixedVersion string `json:"FixedVersion"` + Severity string `json:"Severity"` + Title string `json:"Title"` + Description string `json:"Description"` + References []string `json:"References"` + PrimaryURL string `json:"PrimaryURL"` +} + +// New creates a new Trivy scanner +func New(cfg config.TrivyConfig) *Scanner { + return &Scanner{ + config: cfg, + } +} + +// Name returns the scanner name +func (s *Scanner) Name() string { + return ScannerName +} + +// UpdateDatabase updates Trivy's vulnerability database +func (s *Scanner) UpdateDatabase(ctx context.Context) error { + log.Info().Msg("Updating Trivy vulnerability database") + + cmd := exec.CommandContext(ctx, "trivy", "image", "--download-db-only") + if s.config.CacheDB != "" { + cmd.Env = append(os.Environ(), fmt.Sprintf("TRIVY_CACHE_DIR=%s", s.config.CacheDB)) + } + + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to update Trivy database: %w (output: %s)", err, string(output)) + } + + log.Info().Msg("Trivy vulnerability database updated successfully") + return nil +} + +// Scan scans a package for vulnerabilities using Trivy +func (s *Scanner) Scan(ctx context.Context, registry, packageName, version string, filePath string) (*metadata.ScanResult, error) { + // Set timeout + if s.config.Timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, s.config.Timeout) + defer cancel() + } + + // Determine scan type based on registry + scanType := s.determineScanType(registry, filePath) + + // Build Trivy command + args := []string{ + scanType, + "--format", "json", + "--quiet", + filePath, + } + + cmd := exec.CommandContext(ctx, "trivy", args...) // #nosec G204 -- trivy command with controlled arguments + + // Set cache directory if configured + if s.config.CacheDB != "" { + cmd.Env = append(os.Environ(), fmt.Sprintf("TRIVY_CACHE_DIR=%s", s.config.CacheDB)) + } + + // Execute scan + output, err := cmd.Output() + if err != nil { + // Check if it's a timeout + if ctx.Err() == context.DeadlineExceeded { + return &metadata.ScanResult{ + ID: uuid.New().String(), + Registry: registry, + PackageName: packageName, + PackageVersion: version, + Scanner: s.Name(), + ScannedAt: time.Now(), + Status: metadata.ScanStatusError, + Details: map[string]interface{}{ + "error": "scan timeout", + }, + }, nil + } + + return nil, fmt.Errorf("trivy scan failed: %w", err) + } + + // Parse Trivy output + var trivyResult TrivyResult + if err := json.Unmarshal(output, &trivyResult); err != nil { + return nil, fmt.Errorf("failed to parse Trivy output: %w", err) + } + + // Convert to metadata.ScanResult + return s.convertTrivyResult(&trivyResult, registry, packageName, version), nil +} + +// determineScanType determines the appropriate Trivy scan type +func (s *Scanner) determineScanType(registry, filePath string) string { + // For now, use filesystem scan for packages + // Container image scanning would need different handling + ext := strings.ToLower(filePath[strings.LastIndex(filePath, ".")+1:]) + + switch registry { + case "npm": + return "fs" // Filesystem scan for npm packages + case "pypi": + return "fs" // Filesystem scan for Python packages + case "go": + return "fs" // Filesystem scan for Go modules + default: + // Check file extension + if ext == "tar" || ext == "tgz" || ext == "gz" { + return "fs" + } + return "fs" + } +} + +// convertTrivyResult converts Trivy result to metadata.ScanResult +func (s *Scanner) convertTrivyResult(trivyResult *TrivyResult, registry, packageName, version string) *metadata.ScanResult { + vulnerabilities := make([]metadata.Vulnerability, 0) + severityCounts := make(map[string]int) + + // Aggregate all vulnerabilities from all results + for _, result := range trivyResult.Results { + for _, vuln := range result.Vulnerabilities { + // Normalize severity to standard values (CRITICAL, HIGH, MODERATE, LOW) + normalizedSeverity := metadata.NormalizeSeverity(vuln.Severity) + + // Count by severity + severityCounts[normalizedSeverity]++ + + // Add to vulnerabilities list + vulnerabilities = append(vulnerabilities, metadata.Vulnerability{ + ID: vuln.VulnerabilityID, + Severity: normalizedSeverity, + Title: vuln.Title, + Description: vuln.Description, + References: vuln.References, + FixedIn: vuln.FixedVersion, + }) + } + } + + // Determine overall status + status := metadata.ScanStatusClean + if len(vulnerabilities) > 0 { + status = metadata.ScanStatusVulnerable + } + + return &metadata.ScanResult{ + ID: uuid.New().String(), + Registry: registry, + PackageName: packageName, + PackageVersion: version, + Scanner: s.Name(), + ScannedAt: time.Now(), + Status: status, + VulnerabilityCount: len(vulnerabilities), + Vulnerabilities: vulnerabilities, + Details: map[string]interface{}{ + "artifact_name": trivyResult.ArtifactName, + "artifact_type": trivyResult.ArtifactType, + "severity_counts": severityCounts, + }, + } +} + +// Health checks if Trivy is available and working +func (s *Scanner) Health(ctx context.Context) error { + // Check if trivy command exists + cmd := exec.CommandContext(ctx, "trivy", "--version") + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("trivy not available: %w (output: %s)", err, string(output)) + } + + log.Debug().Str("version", strings.TrimSpace(string(output))).Msg("Trivy health check passed") + return nil +} diff --git a/pkg/server/server.go b/pkg/server/server.go new file mode 100644 index 0000000..48bc4e7 --- /dev/null +++ b/pkg/server/server.go @@ -0,0 +1,130 @@ +package server + +import ( + "fmt" + "net/http" + + "github.com/lukaszraczylo/gohoarder/pkg/config" + "github.com/lukaszraczylo/gohoarder/pkg/health" + "github.com/lukaszraczylo/gohoarder/pkg/logger" + "github.com/lukaszraczylo/gohoarder/pkg/metrics" +) + +// Server wraps http.Server with configuration +type Server struct { + *http.Server + config *config.Config + healthChecker *health.Checker +} + +// New creates a new HTTP server +func New(cfg *config.Config, healthChecker *health.Checker) (*Server, error) { + mux := http.NewServeMux() + + // Register routes + registerRoutes(mux, cfg, healthChecker) + + srv := &http.Server{ + Addr: fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port), + Handler: logger.Middleware(mux), + ReadTimeout: cfg.Server.ReadTimeout, + WriteTimeout: cfg.Server.WriteTimeout, + IdleTimeout: cfg.Server.IdleTimeout, + } + + return &Server{ + Server: srv, + config: cfg, + healthChecker: healthChecker, + }, nil +} + +// registerRoutes registers all HTTP routes +func registerRoutes(mux *http.ServeMux, cfg *config.Config, healthChecker *health.Checker) { + // Health endpoints + mux.HandleFunc("/health", healthChecker.HealthHandler()) + mux.HandleFunc("/health/ready", healthChecker.ReadyHandler()) + + // Metrics endpoint + mux.Handle("/metrics", metrics.Handler()) + + // API endpoints + mux.HandleFunc("/api/v1/info", handleInfo(cfg)) + + // Package manager proxy endpoints (placeholders for now) + if cfg.Handlers.Go.Enabled { + mux.HandleFunc("/go/", handleGoProxy()) + } + if cfg.Handlers.NPM.Enabled { + mux.HandleFunc("/npm/", handleNPMProxy()) + } + if cfg.Handlers.PyPI.Enabled { + mux.HandleFunc("/pypi/", handlePyPIProxy()) + } + + // Root endpoint + mux.HandleFunc("/", handleRoot()) +} + +// handleInfo returns server information +func handleInfo(cfg *config.Config) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + info := map[string]interface{}{ + "name": "GoHoarder", + "version": "dev", + "handlers": map[string]bool{ + "go": cfg.Handlers.Go.Enabled, + "npm": cfg.Handlers.NPM.Enabled, + "pypi": cfg.Handlers.PyPI.Enabled, + }, + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, `{"success":true,"data":%v}`, toJSON(info)) + } +} + +// handleGoProxy handles Go module proxy requests +func handleGoProxy() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // TODO: Implement Go proxy handler + http.Error(w, `{"success":false,"error":{"code":"NOT_IMPLEMENTED","message":"Go proxy not yet implemented"}}`, http.StatusNotImplemented) + } +} + +// handleNPMProxy handles NPM registry requests +func handleNPMProxy() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // TODO: Implement NPM proxy handler + http.Error(w, `{"success":false,"error":{"code":"NOT_IMPLEMENTED","message":"NPM proxy not yet implemented"}}`, http.StatusNotImplemented) + } +} + +// handlePyPIProxy handles PyPI requests +func handlePyPIProxy() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // TODO: Implement PyPI proxy handler + http.Error(w, `{"success":false,"error":{"code":"NOT_IMPLEMENTED","message":"PyPI proxy not yet implemented"}}`, http.StatusNotImplemented) + } +} + +// handleRoot handles root path +func handleRoot() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `{"success":true,"data":{"message":"GoHoarder - Universal Package Cache Proxy","docs":"https://github.com/lukaszraczylo/gohoarder"}}`) + } +} + +// toJSON is a simple JSON encoder (replace with proper implementation) +func toJSON(v interface{}) string { + // Simplified for now - proper implementation would use goccy/go-json + return fmt.Sprintf("%v", v) +} diff --git a/pkg/storage/filesystem/filesystem.go b/pkg/storage/filesystem/filesystem.go new file mode 100644 index 0000000..6e944c3 --- /dev/null +++ b/pkg/storage/filesystem/filesystem.go @@ -0,0 +1,415 @@ +package filesystem + +import ( + "context" + "crypto/md5" // #nosec G501 -- MD5 used for file checksums, not cryptographic security + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "sync" + + "github.com/lukaszraczylo/gohoarder/pkg/errors" + "github.com/lukaszraczylo/gohoarder/pkg/metrics" + "github.com/lukaszraczylo/gohoarder/pkg/storage" + "github.com/rs/zerolog/log" +) + +// FilesystemStorage implements storage.StorageBackend for local filesystem +type FilesystemStorage struct { + basePath string + quota int64 + mu sync.RWMutex + used int64 +} + +// New creates a new filesystem storage backend +func New(basePath string, quota int64) (*FilesystemStorage, error) { + // Create base directory if it doesn't exist + if err := os.MkdirAll(basePath, 0750); err != nil { + return nil, errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to create base directory") + } + + fs := &FilesystemStorage{ + basePath: basePath, + quota: quota, + } + + // Calculate initial usage + if err := fs.calculateUsage(); err != nil { + log.Warn().Err(err).Msg("Failed to calculate initial storage usage") + } + + return fs, nil +} + +// Get retrieves a file +func (fs *FilesystemStorage) Get(ctx context.Context, key string) (io.ReadCloser, error) { + // Check context + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + + path := fs.keyToPath(key) + + file, err := os.Open(path) // #nosec G304 -- Path is sanitized storage key + if err != nil { + if os.IsNotExist(err) { + metrics.RecordStorageOperation("filesystem", "get", "not_found") + return nil, errors.NotFound(fmt.Sprintf("file not found: %s", key)) + } + metrics.RecordStorageOperation("filesystem", "get", "error") + return nil, errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to open file") + } + + metrics.RecordStorageOperation("filesystem", "get", "success") + return file, nil +} + +// Put stores a file atomically +func (fs *FilesystemStorage) Put(ctx context.Context, key string, data io.Reader, opts *storage.PutOptions) error { + // Check context + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + path := fs.keyToPath(key) + dir := filepath.Dir(path) + + // Create directory + if err := os.MkdirAll(dir, 0750); err != nil { + metrics.RecordStorageOperation("filesystem", "put", "error") + return errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to create directory") + } + + // Create temp file for atomic write + tempPath := path + ".tmp" + tempFile, err := os.Create(tempPath) // #nosec G304 -- Temp path is constructed from sanitized storage key + if err != nil { + metrics.RecordStorageOperation("filesystem", "put", "error") + return errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to create temp file") + } + + // Calculate checksums while writing + // NOTE: MD5 is used for integrity verification (checksums), not cryptographic security + md5Hash := md5.New() // #nosec G401 -- MD5 used for file integrity check, not cryptographic security + sha256Hash := sha256.New() + multiWriter := io.MultiWriter(tempFile, md5Hash, sha256Hash) + + written, err := io.Copy(multiWriter, data) + if err != nil { + tempFile.Close() // #nosec G104 -- Cleanup, error not critical + _ = os.Remove(tempPath) // #nosec G104 -- Cleanup, error not critical + metrics.RecordStorageOperation("filesystem", "put", "error") + return errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to write data") + } + + if err := tempFile.Close(); err != nil { + _ = os.Remove(tempPath) // #nosec G104 -- Cleanup, error not critical + metrics.RecordStorageOperation("filesystem", "put", "error") + return errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to close temp file") + } + + // Check quota + fs.mu.Lock() + if fs.quota > 0 && fs.used+written > fs.quota { + fs.mu.Unlock() + _ = os.Remove(tempPath) // #nosec G104 -- Cleanup, error not critical + metrics.RecordStorageOperation("filesystem", "put", "quota_exceeded") + return errors.QuotaExceeded(fs.quota) + } + fs.used += written + fs.mu.Unlock() + + // Verify checksums if provided + if opts != nil { + md5Sum := hex.EncodeToString(md5Hash.Sum(nil)) + sha256Sum := hex.EncodeToString(sha256Hash.Sum(nil)) + + if opts.ChecksumMD5 != "" && opts.ChecksumMD5 != md5Sum { + _ = os.Remove(tempPath) // #nosec G104 -- Cleanup, error not critical + metrics.RecordStorageOperation("filesystem", "put", "checksum_error") + return errors.New(errors.ErrCodeChecksumMismatch, "MD5 checksum mismatch") + } + + if opts.ChecksumSHA256 != "" && opts.ChecksumSHA256 != sha256Sum { + _ = os.Remove(tempPath) // #nosec G104 -- Cleanup, error not critical + metrics.RecordStorageOperation("filesystem", "put", "checksum_error") + return errors.New(errors.ErrCodeChecksumMismatch, "SHA256 checksum mismatch") + } + } + + // Atomic rename + if err := os.Rename(tempPath, path); err != nil { + _ = os.Remove(tempPath) // #nosec G104 -- Cleanup, error not critical + fs.mu.Lock() + fs.used -= written + currentUsed := fs.used + fs.mu.Unlock() + metrics.RecordStorageOperation("filesystem", "put", "error") + metrics.UpdateCacheSize("filesystem", currentUsed) + return errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to rename temp file") + } + + fs.mu.RLock() + currentUsed := fs.used + fs.mu.RUnlock() + + metrics.RecordStorageOperation("filesystem", "put", "success") + metrics.UpdateCacheSize("filesystem", currentUsed) + return nil +} + +// Delete removes a file +func (fs *FilesystemStorage) Delete(ctx context.Context, key string) error { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + path := fs.keyToPath(key) + + // Get size before deletion + info, err := os.Stat(path) + if err != nil { + if os.IsNotExist(err) { + metrics.RecordStorageOperation("filesystem", "delete", "not_found") + return errors.NotFound(fmt.Sprintf("file not found: %s", key)) + } + metrics.RecordStorageOperation("filesystem", "delete", "error") + return errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to stat file") + } + + size := info.Size() + + if err := os.Remove(path); err != nil { + metrics.RecordStorageOperation("filesystem", "delete", "error") + return errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to delete file") + } + + fs.mu.Lock() + fs.used -= size + currentUsed := fs.used + fs.mu.Unlock() + + metrics.RecordStorageOperation("filesystem", "delete", "success") + metrics.UpdateCacheSize("filesystem", currentUsed) + return nil +} + +// Exists checks if a file exists +func (fs *FilesystemStorage) Exists(ctx context.Context, key string) (bool, error) { + select { + case <-ctx.Done(): + return false, ctx.Err() + default: + } + + path := fs.keyToPath(key) + _, err := os.Stat(path) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to check existence") + } + return true, nil +} + +// List lists files with prefix +func (fs *FilesystemStorage) List(ctx context.Context, prefix string, opts *storage.ListOptions) ([]storage.StorageObject, error) { + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + + searchPath := fs.keyToPath(prefix) + var objects []storage.StorageObject + + err := filepath.Walk(searchPath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil // Skip errors + } + + if info.IsDir() { + return nil + } + + // Convert path back to key + relPath, _ := filepath.Rel(fs.basePath, path) + key := filepath.ToSlash(relPath) + + objects = append(objects, storage.StorageObject{ + Key: key, + Size: info.Size(), + Modified: info.ModTime(), + }) + + return nil + }) + + if err != nil && !os.IsNotExist(err) { + return nil, errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to list files") + } + + // Apply pagination if requested + if opts != nil { + start := opts.Offset + end := len(objects) + if opts.MaxResults > 0 && start+opts.MaxResults < end { + end = start + opts.MaxResults + } + if start < len(objects) { + objects = objects[start:end] + } + } + + return objects, nil +} + +// Stat gets file metadata +func (fs *FilesystemStorage) Stat(ctx context.Context, key string) (*storage.StorageInfo, error) { + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + + path := fs.keyToPath(key) + info, err := os.Stat(path) + if err != nil { + if os.IsNotExist(err) { + return nil, errors.NotFound(fmt.Sprintf("file not found: %s", key)) + } + return nil, errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to stat file") + } + + return &storage.StorageInfo{ + Key: key, + Size: info.Size(), + Modified: info.ModTime(), + }, nil +} + +// GetQuota returns quota information +func (fs *FilesystemStorage) GetQuota(ctx context.Context) (*storage.QuotaInfo, error) { + fs.mu.RLock() + used := fs.used + fs.mu.RUnlock() + + available := fs.quota - used + if available < 0 { + available = 0 + } + + return &storage.QuotaInfo{ + Used: used, + Available: available, + Limit: fs.quota, + }, nil +} + +// Health checks filesystem health +func (fs *FilesystemStorage) Health(ctx context.Context) error { + // Check if base path is accessible + if _, err := os.Stat(fs.basePath); err != nil { + return errors.Wrap(err, errors.ErrCodeStorageFailure, "base path not accessible") + } + + // Try to create a temp file (sanitize path to prevent traversal) + tempPath := filepath.Clean(filepath.Join(fs.basePath, ".health_check")) + f, err := os.Create(tempPath) + if err != nil { + return errors.Wrap(err, errors.ErrCodeStorageFailure, "cannot write to storage") + } + f.Close() // #nosec G104 -- Cleanup, error not critical + _ = os.Remove(tempPath) // #nosec G104 -- Cleanup, error not critical + + return nil +} + +// Close closes the storage backend +func (fs *FilesystemStorage) Close() error { + // Nothing to close for filesystem + return nil +} + +// GetLocalPath returns the local filesystem path for a storage key +// This implements storage.LocalPathProvider interface +func (fs *FilesystemStorage) GetLocalPath(ctx context.Context, key string) (string, error) { + select { + case <-ctx.Done(): + return "", ctx.Err() + default: + } + + path := fs.keyToPath(key) + + // Verify file exists + if _, err := os.Stat(path); err != nil { + if os.IsNotExist(err) { + return "", errors.NotFound(fmt.Sprintf("file not found: %s", key)) + } + return "", errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to stat file") + } + + return path, nil +} + +// keyToPath converts a storage key to filesystem path +func (fs *FilesystemStorage) keyToPath(key string) string { + // Sanitize key to prevent path traversal + key = filepath.Clean(key) + + // Remove any leading slashes or dots + key = strings.TrimPrefix(key, "/") + + // Keep removing ../ until there are no more + for strings.HasPrefix(key, "../") || strings.HasPrefix(key, "..\\") { + key = strings.TrimPrefix(key, "../") + key = strings.TrimPrefix(key, "..\\") + } + + // Final clean and ensure it's within base path + key = filepath.Clean(key) + if key == ".." || strings.HasPrefix(key, "../") || strings.HasPrefix(key, "..\\") { + key = "" + } + + return filepath.Join(fs.basePath, key) +} + +// calculateUsage calculates current storage usage +func (fs *FilesystemStorage) calculateUsage() error { + var total int64 + + err := filepath.Walk(fs.basePath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil // Skip errors + } + if !info.IsDir() { + total += info.Size() + } + return nil + }) + + if err != nil { + return err + } + + fs.mu.Lock() + fs.used = total + fs.mu.Unlock() + + metrics.UpdateCacheSize("filesystem", total) + return nil +} diff --git a/pkg/storage/filesystem/filesystem_test.go b/pkg/storage/filesystem/filesystem_test.go new file mode 100644 index 0000000..0988cb8 --- /dev/null +++ b/pkg/storage/filesystem/filesystem_test.go @@ -0,0 +1,757 @@ +package filesystem + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/lukaszraczylo/gohoarder/pkg/storage" + "github.com/stretchr/testify/suite" +) + +type FilesystemStorageTestSuite struct { + suite.Suite + tempDir string + fs *FilesystemStorage +} + +func (s *FilesystemStorageTestSuite) SetupTest() { + var err error + s.tempDir, err = os.MkdirTemp("", "gohoarder-test-*") + s.Require().NoError(err) + + s.fs, err = New(s.tempDir, 1024*1024) // 1MB quota + s.Require().NoError(err) +} + +func (s *FilesystemStorageTestSuite) TearDownTest() { + if s.fs != nil { + s.fs.Close() // #nosec G104 -- Cleanup, error not critical + } + if s.tempDir != "" { + _ = os.RemoveAll(s.tempDir) // #nosec G104 -- Cleanup + } +} + +func TestFilesystemStorageTestSuite(t *testing.T) { + suite.Run(t, new(FilesystemStorageTestSuite)) +} + +// Test Put operation +func (s *FilesystemStorageTestSuite) TestPut() { + tests := []struct { + name string + key string + data string + opts *storage.PutOptions + expectError bool + errorCheck func(error) bool + }{ + { + name: "successful put", + key: "test/file.txt", + data: "hello world", + opts: nil, + expectError: false, + }, + { + name: "put with valid MD5 checksum", + key: "test/checksummed.txt", + data: "test data", + opts: &storage.PutOptions{ChecksumMD5: "eb733a00c0c9d336e65691a37ab54293"}, + expectError: false, + }, + { + name: "put with invalid MD5 checksum", + key: "test/bad-checksum.txt", + data: "test data", + opts: &storage.PutOptions{ChecksumMD5: "invalid"}, + expectError: true, + }, + { + name: "put with nested path", + key: "deep/nested/path/file.txt", + data: "nested content", + opts: nil, + expectError: false, + }, + { + name: "put with path traversal attempt", + key: "../../../etc/passwd", + data: "malicious", + opts: nil, + expectError: false, // Should be sanitized, not error + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + ctx := context.Background() + reader := strings.NewReader(tt.data) + + err := s.fs.Put(ctx, tt.key, reader, tt.opts) + + if tt.expectError { + s.Error(err) + } else { + s.NoError(err) + // Verify file exists + exists, err := s.fs.Exists(ctx, tt.key) + s.NoError(err) + s.True(exists) + } + }) + } +} + +// Test Get operation +func (s *FilesystemStorageTestSuite) TestGet() { + ctx := context.Background() + + // Setup: Put a test file + testData := "test content for retrieval" + err := s.fs.Put(ctx, "test/get.txt", strings.NewReader(testData), nil) + s.Require().NoError(err) + + tests := []struct { + name string + key string + expectError bool + expectData string + }{ + { + name: "get existing file", + key: "test/get.txt", + expectError: false, + expectData: testData, + }, + { + name: "get non-existent file", + key: "does/not/exist.txt", + expectError: true, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + reader, err := s.fs.Get(ctx, tt.key) + + if tt.expectError { + s.Error(err) + s.Nil(reader) + } else { + s.NoError(err) + s.NotNil(reader) + defer reader.Close() // #nosec G104 -- Cleanup, error not critical + + data, err := io.ReadAll(reader) + s.NoError(err) + s.Equal(tt.expectData, string(data)) + } + }) + } +} + +// Test Delete operation +func (s *FilesystemStorageTestSuite) TestDelete() { + ctx := context.Background() + + tests := []struct { + name string + setupKey string + deleteKey string + expectError bool + }{ + { + name: "delete existing file", + setupKey: "test/delete-me.txt", + deleteKey: "test/delete-me.txt", + expectError: false, + }, + { + name: "delete non-existent file", + setupKey: "", + deleteKey: "does/not/exist.txt", + expectError: true, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + // Setup + if tt.setupKey != "" { + err := s.fs.Put(ctx, tt.setupKey, strings.NewReader("to be deleted"), nil) + s.Require().NoError(err) + } + + // Test delete + err := s.fs.Delete(ctx, tt.deleteKey) + + if tt.expectError { + s.Error(err) + } else { + s.NoError(err) + // Verify file no longer exists + exists, err := s.fs.Exists(ctx, tt.deleteKey) + s.NoError(err) + s.False(exists) + } + }) + } +} + +// Test Exists operation +func (s *FilesystemStorageTestSuite) TestExists() { + ctx := context.Background() + + // Setup: Put a test file + err := s.fs.Put(ctx, "test/exists.txt", strings.NewReader("content"), nil) + s.Require().NoError(err) + + tests := []struct { + name string + key string + exists bool + }{ + { + name: "existing file", + key: "test/exists.txt", + exists: true, + }, + { + name: "non-existent file", + key: "test/does-not-exist.txt", + exists: false, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + exists, err := s.fs.Exists(ctx, tt.key) + s.NoError(err) + s.Equal(tt.exists, exists) + }) + } +} + +// Test List operation +func (s *FilesystemStorageTestSuite) TestList() { + ctx := context.Background() + + // Setup: Create multiple files + files := []string{ + "packages/npm/react/17.0.1/package.json", + "packages/npm/react/17.0.2/package.json", + "packages/npm/vue/3.0.0/package.json", + "packages/pypi/django/3.2.0/wheel.whl", + } + + for _, file := range files { + err := s.fs.Put(ctx, file, strings.NewReader("content"), nil) + s.Require().NoError(err) + } + + tests := []struct { + name string + prefix string + opts *storage.ListOptions + expectedCount int + expectedKeys []string + }{ + { + name: "list all npm packages", + prefix: "packages/npm", + opts: nil, + expectedCount: 3, + }, + { + name: "list react packages", + prefix: "packages/npm/react", + opts: nil, + expectedCount: 2, + }, + { + name: "list with pagination", + prefix: "packages/npm", + opts: &storage.ListOptions{MaxResults: 2, Offset: 0}, + expectedCount: 2, + }, + { + name: "list with offset", + prefix: "packages/npm", + opts: &storage.ListOptions{MaxResults: 2, Offset: 1}, + expectedCount: 2, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + objects, err := s.fs.List(ctx, tt.prefix, tt.opts) + s.NoError(err) + s.Equal(tt.expectedCount, len(objects)) + + // Verify objects have required fields + for _, obj := range objects { + s.NotEmpty(obj.Key) + s.Greater(obj.Size, int64(0)) + s.False(obj.Modified.IsZero()) + } + }) + } +} + +// Test Stat operation +func (s *FilesystemStorageTestSuite) TestStat() { + ctx := context.Background() + + // Setup: Put a test file + testData := "stat test content" + testKey := "test/stat.txt" + err := s.fs.Put(ctx, testKey, strings.NewReader(testData), nil) + s.Require().NoError(err) + + tests := []struct { + name string + key string + expectError bool + }{ + { + name: "stat existing file", + key: testKey, + expectError: false, + }, + { + name: "stat non-existent file", + key: "does/not/exist.txt", + expectError: true, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + info, err := s.fs.Stat(ctx, tt.key) + + if tt.expectError { + s.Error(err) + s.Nil(info) + } else { + s.NoError(err) + s.NotNil(info) + s.Equal(tt.key, info.Key) + s.Equal(int64(len(testData)), info.Size) + s.False(info.Modified.IsZero()) + } + }) + } +} + +// Test Quota enforcement +func (s *FilesystemStorageTestSuite) TestQuotaEnforcement() { + ctx := context.Background() + + // Create a new filesystem with small quota (100 bytes) + smallQuotaDir, err := os.MkdirTemp("", "gohoarder-quota-*") + s.Require().NoError(err) + defer os.RemoveAll(smallQuotaDir) + + smallFs, err := New(smallQuotaDir, 100) + s.Require().NoError(err) + defer smallFs.Close() // #nosec G104 -- Cleanup, error not critical + + // First write should succeed + err = smallFs.Put(ctx, "file1.txt", strings.NewReader("small content"), nil) + s.NoError(err) + + // Large write should fail due to quota + largeData := strings.Repeat("x", 200) + err = smallFs.Put(ctx, "large.txt", strings.NewReader(largeData), nil) + s.Error(err) + + // Verify quota info + quotaInfo, err := smallFs.GetQuota(ctx) + s.NoError(err) + s.Equal(int64(100), quotaInfo.Limit) + s.Greater(quotaInfo.Used, int64(0)) + s.LessOrEqual(quotaInfo.Used, quotaInfo.Limit) +} + +// Test GetQuota operation +func (s *FilesystemStorageTestSuite) TestGetQuota() { + ctx := context.Background() + + // Put some files + err := s.fs.Put(ctx, "file1.txt", strings.NewReader("content1"), nil) + s.Require().NoError(err) + err = s.fs.Put(ctx, "file2.txt", strings.NewReader("content2"), nil) + s.Require().NoError(err) + + quotaInfo, err := s.fs.GetQuota(ctx) + s.NoError(err) + s.NotNil(quotaInfo) + s.Equal(int64(1024*1024), quotaInfo.Limit) + s.Greater(quotaInfo.Used, int64(0)) + s.Greater(quotaInfo.Available, int64(0)) + s.Equal(quotaInfo.Limit, quotaInfo.Used+quotaInfo.Available) +} + +// Test Health check +func (s *FilesystemStorageTestSuite) TestHealth() { + ctx := context.Background() + + // Healthy filesystem + err := s.fs.Health(ctx) + s.NoError(err) + + // Unhealthy filesystem (removed directory) + badDir := filepath.Join(s.tempDir, "nonexistent") + badFs := &FilesystemStorage{basePath: badDir} + err = badFs.Health(ctx) + s.Error(err) +} + +// Test Context cancellation +func (s *FilesystemStorageTestSuite) TestContextCancellation() { + // Create cancelled context + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + tests := []struct { + name string + fn func() error + }{ + { + name: "Get with cancelled context", + fn: func() error { + _, err := s.fs.Get(ctx, "test.txt") + return err + }, + }, + { + name: "Put with cancelled context", + fn: func() error { + return s.fs.Put(ctx, "test.txt", strings.NewReader("data"), nil) + }, + }, + { + name: "Delete with cancelled context", + fn: func() error { + return s.fs.Delete(ctx, "test.txt") + }, + }, + { + name: "Exists with cancelled context", + fn: func() error { + _, err := s.fs.Exists(ctx, "test.txt") + return err + }, + }, + { + name: "List with cancelled context", + fn: func() error { + _, err := s.fs.List(ctx, "test", nil) + return err + }, + }, + { + name: "Stat with cancelled context", + fn: func() error { + _, err := s.fs.Stat(ctx, "test.txt") + return err + }, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + err := tt.fn() + s.Error(err) + s.Equal(context.Canceled, err) + }) + } +} + +// Test concurrent access (race condition testing) +func (s *FilesystemStorageTestSuite) TestConcurrentAccess() { + ctx := context.Background() + numGoroutines := 10 + numOperations := 100 + + var wg sync.WaitGroup + + // Concurrent writes + for i := 0; i < numGoroutines; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + for j := 0; j < numOperations; j++ { + key := fmt.Sprintf("concurrent/%d/%d.txt", id, j) + data := fmt.Sprintf("data-%d-%d", id, j) + err := s.fs.Put(ctx, key, strings.NewReader(data), nil) + s.NoError(err) + } + }(i) + } + + wg.Wait() + + // Verify all files exist + objects, err := s.fs.List(ctx, "concurrent", nil) + s.NoError(err) + s.Equal(numGoroutines*numOperations, len(objects)) +} + +// Test concurrent reads and writes +func (s *FilesystemStorageTestSuite) TestConcurrentReadsAndWrites() { + ctx := context.Background() + + // Setup: Create some initial files + for i := 0; i < 10; i++ { + key := fmt.Sprintf("shared/file-%d.txt", i) + err := s.fs.Put(ctx, key, strings.NewReader(fmt.Sprintf("initial-%d", i)), nil) + s.Require().NoError(err) + } + + var wg sync.WaitGroup + numReaders := 5 + numWriters := 5 + numOps := 50 + + // Concurrent readers + for i := 0; i < numReaders; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + for j := 0; j < numOps; j++ { + key := fmt.Sprintf("shared/file-%d.txt", j%10) + reader, err := s.fs.Get(ctx, key) + if err == nil { + io.ReadAll(reader) + reader.Close() // #nosec G104 -- Cleanup, error not critical + } + } + }(i) + } + + // Concurrent writers + for i := 0; i < numWriters; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + for j := 0; j < numOps; j++ { + key := fmt.Sprintf("shared/writer-%d-%d.txt", id, j) + data := fmt.Sprintf("writer-%d-%d", id, j) + s.fs.Put(ctx, key, strings.NewReader(data), nil) + } + }(i) + } + + wg.Wait() + + // Verify quota tracking is consistent + quotaInfo, err := s.fs.GetQuota(ctx) + s.NoError(err) + s.Greater(quotaInfo.Used, int64(0)) +} + +// Test Delete updates quota correctly +func (s *FilesystemStorageTestSuite) TestDeleteUpdatesQuota() { + ctx := context.Background() + + // Put a file + testData := "test data for quota tracking" + err := s.fs.Put(ctx, "quota/test.txt", strings.NewReader(testData), nil) + s.Require().NoError(err) + + // Get quota before delete + quotaBefore, err := s.fs.GetQuota(ctx) + s.Require().NoError(err) + + // Delete the file + err = s.fs.Delete(ctx, "quota/test.txt") + s.NoError(err) + + // Get quota after delete + quotaAfter, err := s.fs.GetQuota(ctx) + s.NoError(err) + + // Quota should have decreased + s.Less(quotaAfter.Used, quotaBefore.Used) +} + +// Test atomic write behavior +func (s *FilesystemStorageTestSuite) TestAtomicWrite() { + ctx := context.Background() + key := "atomic/test.txt" + + // Initial write + err := s.fs.Put(ctx, key, strings.NewReader("initial"), nil) + s.Require().NoError(err) + + // Concurrent readers should never see partial writes + var wg sync.WaitGroup + stopReading := make(chan struct{}) + readErrors := make(chan error, 100) + + // Start readers + for i := 0; i < 5; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stopReading: + return + default: + reader, err := s.fs.Get(ctx, key) + if err != nil { + readErrors <- err + continue + } + data, err := io.ReadAll(reader) + reader.Close() // #nosec G104 -- Cleanup, error not critical + if err != nil { + readErrors <- err + continue + } + // Data should be either "initial" or "updated", never partial + content := string(data) + if content != "initial" && content != "updated" { + readErrors <- fmt.Errorf("read partial data: %s", content) + } + } + } + }() + } + + // Perform update + time.Sleep(10 * time.Millisecond) + err = s.fs.Put(ctx, key, strings.NewReader("updated"), nil) + s.NoError(err) + + // Stop readers + time.Sleep(10 * time.Millisecond) + close(stopReading) + wg.Wait() + close(readErrors) + + // Check for read errors + for err := range readErrors { + s.NoError(err) + } +} + +// Test path sanitization +func (s *FilesystemStorageTestSuite) TestPathSanitization() { + ctx := context.Background() + + maliciousPaths := []string{ + "../../../etc/passwd", + "/../secret.txt", + "./../../outside.txt", + "//etc/passwd", + } + + for _, path := range maliciousPaths { + s.Run(fmt.Sprintf("sanitize_%s", path), func() { + err := s.fs.Put(ctx, path, strings.NewReader("malicious"), nil) + s.NoError(err) // Should succeed but sanitize path + + // Verify file is inside base directory + sanitized := s.fs.keyToPath(path) + s.True(strings.HasPrefix(sanitized, s.tempDir), + "Sanitized path %s should be inside %s", sanitized, s.tempDir) + }) + } +} + +// Test checksum validation +func (s *FilesystemStorageTestSuite) TestChecksumValidation() { + ctx := context.Background() + + testData := "checksum test data" + // Correct checksums calculated for "checksum test data" + correctMD5 := "7dd7323e8ce3e087972f93d3711ef62b" + + tests := []struct { + name string + opts *storage.PutOptions + expectError bool + }{ + { + name: "valid MD5", + opts: &storage.PutOptions{ChecksumMD5: correctMD5}, + expectError: false, + }, + { + name: "invalid MD5", + opts: &storage.PutOptions{ChecksumMD5: "invalid"}, + expectError: true, + }, + { + name: "empty checksum (no validation)", + opts: &storage.PutOptions{ChecksumMD5: ""}, + expectError: false, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + key := fmt.Sprintf("checksum/%s.txt", tt.name) + err := s.fs.Put(ctx, key, strings.NewReader(testData), tt.opts) + + if tt.expectError { + s.Error(err) + } else { + s.NoError(err) + } + }) + } +} + +// Benchmark Put operation +func BenchmarkFilesystemPut(b *testing.B) { + tempDir, _ := os.MkdirTemp("", "gohoarder-bench-*") + defer os.RemoveAll(tempDir) + + fs, _ := New(tempDir, 1024*1024*1024) // 1GB quota + defer fs.Close() // #nosec G104 -- Cleanup, error not critical + + ctx := context.Background() + data := strings.Repeat("x", 1024) // 1KB + + b.ResetTimer() + for i := 0; i < b.N; i++ { + key := fmt.Sprintf("bench/file-%d.txt", i) + fs.Put(ctx, key, strings.NewReader(data), nil) + } +} + +// Benchmark Get operation +func BenchmarkFilesystemGet(b *testing.B) { + tempDir, _ := os.MkdirTemp("", "gohoarder-bench-*") + defer os.RemoveAll(tempDir) + + fs, _ := New(tempDir, 1024*1024*1024) + defer fs.Close() // #nosec G104 -- Cleanup, error not critical + + ctx := context.Background() + data := strings.Repeat("x", 1024) + + // Setup: Create test file + fs.Put(ctx, "bench/test.txt", strings.NewReader(data), nil) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + reader, _ := fs.Get(ctx, "bench/test.txt") + if reader != nil { + io.ReadAll(reader) + reader.Close() // #nosec G104 -- Cleanup, error not critical + } + } +} diff --git a/pkg/storage/interface.go b/pkg/storage/interface.go new file mode 100644 index 0000000..d5d0230 --- /dev/null +++ b/pkg/storage/interface.go @@ -0,0 +1,91 @@ +package storage + +import ( + "context" + "io" + "time" +) + +// StorageBackend defines the interface for package storage +type StorageBackend interface { + // Get retrieves a package by key + Get(ctx context.Context, key string) (io.ReadCloser, error) + + // Put stores a package + Put(ctx context.Context, key string, data io.Reader, opts *PutOptions) error + + // Delete removes a package + Delete(ctx context.Context, key string) error + + // Exists checks if a package exists + Exists(ctx context.Context, key string) (bool, error) + + // List lists packages with prefix + List(ctx context.Context, prefix string, opts *ListOptions) ([]StorageObject, error) + + // Stat gets package metadata + Stat(ctx context.Context, key string) (*StorageInfo, error) + + // GetQuota returns quota information + GetQuota(ctx context.Context) (*QuotaInfo, error) + + // Health checks backend health + Health(ctx context.Context) error + + // Close closes the backend + Close() error +} + +// PutOptions contains options for Put operations +type PutOptions struct { + ContentType string + Metadata map[string]string + ChecksumMD5 string + ChecksumSHA256 string +} + +// ListOptions contains options for List operations +type ListOptions struct { + MaxResults int + Offset int +} + +// StorageObject represents a stored object +type StorageObject struct { + Key string + Size int64 + Modified time.Time + ETag string +} + +// StorageInfo contains detailed object information +type StorageInfo struct { + Key string + Size int64 + Modified time.Time + ETag string + ContentType string + Metadata map[string]string + Checksums *Checksums +} + +// Checksums contains file checksums +type Checksums struct { + MD5 string + SHA256 string +} + +// QuotaInfo contains quota information +type QuotaInfo struct { + Used int64 + Available int64 + Limit int64 +} + +// LocalPathProvider is an optional interface that storage backends can implement +// to provide direct file system paths for scanning without creating temp copies +type LocalPathProvider interface { + // GetLocalPath returns the local filesystem path for a storage key + // Returns empty string if the backend doesn't support local paths (e.g., S3, SMB) + GetLocalPath(ctx context.Context, key string) (string, error) +} diff --git a/pkg/storage/s3/s3.go b/pkg/storage/s3/s3.go new file mode 100644 index 0000000..3274a90 --- /dev/null +++ b/pkg/storage/s3/s3.go @@ -0,0 +1,443 @@ +package s3 + +import ( + "bytes" + "context" + "crypto/md5" // #nosec G501 -- MD5 used for S3 Content-MD5 header, not cryptographic security + "crypto/sha256" + "encoding/hex" + stderrors "errors" + "fmt" + "io" + "strings" + "sync" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/aws-sdk-go-v2/service/s3/types" + "github.com/lukaszraczylo/gohoarder/pkg/errors" + "github.com/lukaszraczylo/gohoarder/pkg/metrics" + "github.com/lukaszraczylo/gohoarder/pkg/storage" + "github.com/rs/zerolog/log" +) + +// S3Storage implements storage.StorageBackend for AWS S3 +type S3Storage struct { + client *s3.Client + bucket string + prefix string + quota int64 + mu sync.RWMutex + used int64 +} + +// Config holds S3 configuration +type Config struct { + Bucket string + Region string + Endpoint string // For S3-compatible services (MinIO, etc.) + AccessKeyID string + SecretAccessKey string + Prefix string // Optional prefix for all keys + Quota int64 // Quota in bytes (0 = unlimited) + ForcePathStyle bool // For S3-compatible services +} + +// New creates a new S3 storage backend +func New(ctx context.Context, cfg Config) (*S3Storage, error) { + if cfg.Bucket == "" { + return nil, errors.New(errors.ErrCodeInvalidConfig, "S3 bucket is required") + } + + if cfg.Region == "" { + return nil, errors.New(errors.ErrCodeInvalidConfig, "S3 region is required") + } + + // Build AWS config + var awsCfg aws.Config + var err error + + if cfg.AccessKeyID != "" && cfg.SecretAccessKey != "" { + // Use static credentials + awsCfg, err = config.LoadDefaultConfig(ctx, + config.WithRegion(cfg.Region), + config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider( + cfg.AccessKeyID, + cfg.SecretAccessKey, + "", + )), + ) + } else { + // Use default credential chain + awsCfg, err = config.LoadDefaultConfig(ctx, + config.WithRegion(cfg.Region), + ) + } + + if err != nil { + return nil, errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to load AWS config") + } + + // Create S3 client + var s3Options []func(*s3.Options) + + if cfg.Endpoint != "" { + s3Options = append(s3Options, func(o *s3.Options) { + o.BaseEndpoint = aws.String(cfg.Endpoint) + o.UsePathStyle = cfg.ForcePathStyle + }) + } + + client := s3.NewFromConfig(awsCfg, s3Options...) + + s3Storage := &S3Storage{ + client: client, + bucket: cfg.Bucket, + prefix: strings.TrimSuffix(cfg.Prefix, "/"), + quota: cfg.Quota, + } + + // Calculate initial usage + if err := s3Storage.calculateUsage(ctx); err != nil { + log.Warn().Err(err).Msg("Failed to calculate initial S3 storage usage") + } + + return s3Storage, nil +} + +// Get retrieves a file from S3 +func (s *S3Storage) Get(ctx context.Context, key string) (io.ReadCloser, error) { + s3Key := s.buildKey(key) + + input := &s3.GetObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(s3Key), + } + + result, err := s.client.GetObject(ctx, input) + if err != nil { + if isNotFoundError(err) { + metrics.RecordStorageOperation("s3", "get", "not_found") + return nil, errors.NotFound(fmt.Sprintf("file not found: %s", key)) + } + metrics.RecordStorageOperation("s3", "get", "error") + return nil, errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to get object from S3") + } + + metrics.RecordStorageOperation("s3", "get", "success") + return result.Body, nil +} + +// Put stores a file in S3 +func (s *S3Storage) Put(ctx context.Context, key string, data io.Reader, opts *storage.PutOptions) error { + s3Key := s.buildKey(key) + + // Read data into buffer to calculate checksums and size + var buf bytes.Buffer + md5Hash := md5.New() // #nosec G401 -- MD5 used for S3 integrity check, not cryptographic security + sha256Hash := sha256.New() + multiWriter := io.MultiWriter(&buf, md5Hash, sha256Hash) + + written, err := io.Copy(multiWriter, data) + if err != nil { + metrics.RecordStorageOperation("s3", "put", "error") + return errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to read data") + } + + // Check quota before upload + if s.quota > 0 { + s.mu.RLock() + used := s.used + s.mu.RUnlock() + + if used+written > s.quota { + metrics.RecordStorageOperation("s3", "put", "quota_exceeded") + return errors.QuotaExceeded(s.quota) + } + } + + // Verify checksums if provided + if opts != nil { + md5Sum := hex.EncodeToString(md5Hash.Sum(nil)) + sha256Sum := hex.EncodeToString(sha256Hash.Sum(nil)) + + if opts.ChecksumMD5 != "" && opts.ChecksumMD5 != md5Sum { + metrics.RecordStorageOperation("s3", "put", "checksum_error") + return errors.New(errors.ErrCodeChecksumMismatch, "MD5 checksum mismatch") + } + + if opts.ChecksumSHA256 != "" && opts.ChecksumSHA256 != sha256Sum { + metrics.RecordStorageOperation("s3", "put", "checksum_error") + return errors.New(errors.ErrCodeChecksumMismatch, "SHA256 checksum mismatch") + } + } + + // Prepare metadata + metadata := make(map[string]string) + if opts != nil && opts.Metadata != nil { + metadata = opts.Metadata + } + + // Build put input + input := &s3.PutObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(s3Key), + Body: bytes.NewReader(buf.Bytes()), + Metadata: metadata, + } + + if opts != nil && opts.ContentType != "" { + input.ContentType = aws.String(opts.ContentType) + } + + // Upload to S3 + _, err = s.client.PutObject(ctx, input) + if err != nil { + metrics.RecordStorageOperation("s3", "put", "error") + return errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to upload to S3") + } + + // Update usage + s.mu.Lock() + s.used += written + currentUsed := s.used + s.mu.Unlock() + + metrics.RecordStorageOperation("s3", "put", "success") + metrics.UpdateCacheSize("s3", currentUsed) + return nil +} + +// Delete removes a file from S3 +func (s *S3Storage) Delete(ctx context.Context, key string) error { + s3Key := s.buildKey(key) + + // Get size before deletion for quota tracking + statInfo, err := s.Stat(ctx, key) + if err != nil { + return err + } + + input := &s3.DeleteObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(s3Key), + } + + _, err = s.client.DeleteObject(ctx, input) + if err != nil { + metrics.RecordStorageOperation("s3", "delete", "error") + return errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to delete from S3") + } + + // Update usage + s.mu.Lock() + s.used -= statInfo.Size + currentUsed := s.used + s.mu.Unlock() + + metrics.RecordStorageOperation("s3", "delete", "success") + metrics.UpdateCacheSize("s3", currentUsed) + return nil +} + +// Exists checks if a file exists in S3 +func (s *S3Storage) Exists(ctx context.Context, key string) (bool, error) { + s3Key := s.buildKey(key) + + input := &s3.HeadObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(s3Key), + } + + _, err := s.client.HeadObject(ctx, input) + if err != nil { + if isNotFoundError(err) { + return false, nil + } + return false, errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to check existence in S3") + } + + return true, nil +} + +// List lists files with prefix in S3 +func (s *S3Storage) List(ctx context.Context, prefix string, opts *storage.ListOptions) ([]storage.StorageObject, error) { + s3Prefix := s.buildKey(prefix) + + input := &s3.ListObjectsV2Input{ + Bucket: aws.String(s.bucket), + Prefix: aws.String(s3Prefix), + } + + var objects []storage.StorageObject + paginator := s3.NewListObjectsV2Paginator(s.client, input) + + for paginator.HasMorePages() { + page, err := paginator.NextPage(ctx) + if err != nil { + return nil, errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to list objects in S3") + } + + for _, obj := range page.Contents { + key := s.stripPrefix(*obj.Key) + objects = append(objects, storage.StorageObject{ + Key: key, + Size: *obj.Size, + Modified: *obj.LastModified, + ETag: strings.Trim(*obj.ETag, "\""), + }) + } + } + + // Apply pagination if requested + if opts != nil { + start := opts.Offset + end := len(objects) + if opts.MaxResults > 0 && start+opts.MaxResults < end { + end = start + opts.MaxResults + } + if start < len(objects) { + objects = objects[start:end] + } else { + objects = []storage.StorageObject{} + } + } + + return objects, nil +} + +// Stat gets file metadata from S3 +func (s *S3Storage) Stat(ctx context.Context, key string) (*storage.StorageInfo, error) { + s3Key := s.buildKey(key) + + input := &s3.HeadObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(s3Key), + } + + result, err := s.client.HeadObject(ctx, input) + if err != nil { + if isNotFoundError(err) { + return nil, errors.NotFound(fmt.Sprintf("file not found: %s", key)) + } + return nil, errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to stat object in S3") + } + + info := &storage.StorageInfo{ + Key: key, + Size: *result.ContentLength, + Modified: *result.LastModified, + ETag: strings.Trim(*result.ETag, "\""), + Metadata: result.Metadata, + } + + if result.ContentType != nil { + info.ContentType = *result.ContentType + } + + return info, nil +} + +// GetQuota returns quota information +func (s *S3Storage) GetQuota(ctx context.Context) (*storage.QuotaInfo, error) { + s.mu.RLock() + used := s.used + s.mu.RUnlock() + + available := s.quota - used + if available < 0 { + available = 0 + } + + return &storage.QuotaInfo{ + Used: used, + Available: available, + Limit: s.quota, + }, nil +} + +// Health checks S3 health +func (s *S3Storage) Health(ctx context.Context) error { + // Try to list bucket to verify connectivity + input := &s3.ListObjectsV2Input{ + Bucket: aws.String(s.bucket), + MaxKeys: aws.Int32(1), + } + + _, err := s.client.ListObjectsV2(ctx, input) + if err != nil { + return errors.Wrap(err, errors.ErrCodeStorageFailure, "S3 health check failed") + } + + return nil +} + +// Close closes the storage backend +func (s *S3Storage) Close() error { + // No cleanup needed for S3 client + return nil +} + +// buildKey builds the full S3 key with prefix +func (s *S3Storage) buildKey(key string) string { + key = strings.TrimPrefix(key, "/") + if s.prefix != "" { + return s.prefix + "/" + key + } + return key +} + +// stripPrefix removes the configured prefix from an S3 key +func (s *S3Storage) stripPrefix(s3Key string) string { + if s.prefix != "" { + return strings.TrimPrefix(s3Key, s.prefix+"/") + } + return s3Key +} + +// calculateUsage calculates current S3 storage usage +func (s *S3Storage) calculateUsage(ctx context.Context) error { + var total int64 + + input := &s3.ListObjectsV2Input{ + Bucket: aws.String(s.bucket), + } + + if s.prefix != "" { + input.Prefix = aws.String(s.prefix + "/") + } + + paginator := s3.NewListObjectsV2Paginator(s.client, input) + + for paginator.HasMorePages() { + page, err := paginator.NextPage(ctx) + if err != nil { + return err + } + + for _, obj := range page.Contents { + total += *obj.Size + } + } + + s.mu.Lock() + s.used = total + s.mu.Unlock() + + metrics.UpdateCacheSize("s3", total) + return nil +} + +// isNotFoundError checks if an error is a "not found" error +func isNotFoundError(err error) bool { + if err == nil { + return false + } + + var notFound *types.NotFound + var noSuchKey *types.NoSuchKey + + return stderrors.As(err, ¬Found) || stderrors.As(err, &noSuchKey) +} diff --git a/pkg/storage/smb/smb.go b/pkg/storage/smb/smb.go new file mode 100644 index 0000000..d8d4d9a --- /dev/null +++ b/pkg/storage/smb/smb.go @@ -0,0 +1,579 @@ +package smb + +import ( + "bytes" + "context" + "crypto/md5" // #nosec G501 -- MD5 used for file checksums, not cryptographic security + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "net" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/hirochachacha/go-smb2" + "github.com/lukaszraczylo/gohoarder/pkg/errors" + "github.com/lukaszraczylo/gohoarder/pkg/metrics" + "github.com/lukaszraczylo/gohoarder/pkg/storage" + "github.com/rs/zerolog/log" +) + +// SMBStorage implements storage.StorageBackend for SMB/CIFS shares +type SMBStorage struct { + host string + share string + basePath string + username string + password string + quota int64 + mu sync.RWMutex + used int64 + connPool chan *smbConnection + poolSize int +} + +// smbConnection wraps an SMB session and share +type smbConnection struct { + conn net.Conn + session *smb2.Session + share *smb2.Share + lastUse time.Time +} + +// Config holds SMB configuration +type Config struct { + Host string // SMB server hostname or IP + Port int // SMB server port (default: 445) + Share string // SMB share name + BasePath string // Base path within the share + Username string // SMB username + Password string // SMB password + Domain string // SMB domain (optional) + Quota int64 // Quota in bytes (0 = unlimited) + PoolSize int // Connection pool size (default: 5) +} + +// New creates a new SMB storage backend +func New(ctx context.Context, cfg Config) (*SMBStorage, error) { + if cfg.Host == "" { + return nil, errors.New(errors.ErrCodeInvalidConfig, "SMB host is required") + } + + if cfg.Share == "" { + return nil, errors.New(errors.ErrCodeInvalidConfig, "SMB share is required") + } + + if cfg.Port == 0 { + cfg.Port = 445 // Default SMB port + } + + if cfg.PoolSize == 0 { + cfg.PoolSize = 5 // Default pool size + } + + smbStorage := &SMBStorage{ + host: fmt.Sprintf("%s:%d", cfg.Host, cfg.Port), + share: cfg.Share, + basePath: strings.Trim(cfg.BasePath, "/\\"), + username: cfg.Username, + password: cfg.Password, + quota: cfg.Quota, + connPool: make(chan *smbConnection, cfg.PoolSize), + poolSize: cfg.PoolSize, + } + + // Initialize connection pool + for i := 0; i < cfg.PoolSize; i++ { + conn, err := smbStorage.createConnection(ctx) + if err != nil { + // Clean up any created connections + close(smbStorage.connPool) + for c := range smbStorage.connPool { + c.close() + } + return nil, errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to create SMB connection pool") + } + smbStorage.connPool <- conn + } + + // Calculate initial usage + if err := smbStorage.calculateUsage(ctx); err != nil { + log.Warn().Err(err).Msg("Failed to calculate initial SMB storage usage") + } + + return smbStorage, nil +} + +// createConnection creates a new SMB connection +func (s *SMBStorage) createConnection(ctx context.Context) (*smbConnection, error) { + conn, err := net.Dial("tcp", s.host) + if err != nil { + return nil, err + } + + dialer := &smb2.Dialer{ + Initiator: &smb2.NTLMInitiator{ + User: s.username, + Password: s.password, + }, + } + + session, err := dialer.Dial(conn) + if err != nil { + conn.Close() // #nosec G104 -- Cleanup, error not critical + return nil, err + } + + share, err := session.Mount(s.share) + if err != nil { + _ = session.Logoff() // #nosec G104 -- SMB cleanup + conn.Close() // #nosec G104 -- Cleanup, error not critical + return nil, err + } + + return &smbConnection{ + conn: conn, + session: session, + share: share, + lastUse: time.Now(), + }, nil +} + +// getConnection gets a connection from the pool +func (s *SMBStorage) getConnection(ctx context.Context) (*smbConnection, error) { + select { + case conn := <-s.connPool: + conn.lastUse = time.Now() + return conn, nil + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(30 * time.Second): + return nil, errors.New(errors.ErrCodeStorageFailure, "timeout waiting for SMB connection") + } +} + +// returnConnection returns a connection to the pool +func (s *SMBStorage) returnConnection(conn *smbConnection) { + select { + case s.connPool <- conn: + default: + // Pool is full, close the connection + conn.close() + } +} + +// close closes an SMB connection +func (c *smbConnection) close() { + if c.share != nil { + _ = c.share.Umount() // #nosec G104 -- SMB cleanup + } + if c.session != nil { + _ = c.session.Logoff() // #nosec G104 -- SMB cleanup + } + if c.conn != nil { + c.conn.Close() // #nosec G104 -- Cleanup, error not critical + } +} + +// Get retrieves a file from SMB share +func (s *SMBStorage) Get(ctx context.Context, key string) (io.ReadCloser, error) { + conn, err := s.getConnection(ctx) + if err != nil { + return nil, err + } + + path := s.keyToPath(key) + + // Open file + file, err := conn.share.Open(path) + if err != nil { + s.returnConnection(conn) + if os.IsNotExist(err) { + metrics.RecordStorageOperation("smb", "get", "not_found") + return nil, errors.NotFound(fmt.Sprintf("file not found: %s", key)) + } + metrics.RecordStorageOperation("smb", "get", "error") + return nil, errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to open SMB file") + } + + // Read entire file into memory and close SMB connection + // This is necessary because we need to return the connection to the pool + data, err := io.ReadAll(file) + file.Close() // #nosec G104 -- Cleanup, error not critical + s.returnConnection(conn) + + if err != nil { + metrics.RecordStorageOperation("smb", "get", "error") + return nil, errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to read SMB file") + } + + metrics.RecordStorageOperation("smb", "get", "success") + return io.NopCloser(bytes.NewReader(data)), nil +} + +// Put stores a file on SMB share +func (s *SMBStorage) Put(ctx context.Context, key string, data io.Reader, opts *storage.PutOptions) error { + conn, err := s.getConnection(ctx) + if err != nil { + return err + } + defer s.returnConnection(conn) + + path := s.keyToPath(key) + dir := filepath.Dir(path) + + // Create directory structure + if err := conn.share.MkdirAll(dir, 0755); err != nil { + metrics.RecordStorageOperation("smb", "put", "error") + return errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to create SMB directory") + } + + // Read data into buffer to calculate checksums and size + var buf bytes.Buffer + md5Hash := md5.New() // #nosec G401 -- MD5 used for file integrity check, not cryptographic security + sha256Hash := sha256.New() + multiWriter := io.MultiWriter(&buf, md5Hash, sha256Hash) + + written, err := io.Copy(multiWriter, data) + if err != nil { + metrics.RecordStorageOperation("smb", "put", "error") + return errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to read data") + } + + // Check quota + if s.quota > 0 { + s.mu.RLock() + used := s.used + s.mu.RUnlock() + + if used+written > s.quota { + metrics.RecordStorageOperation("smb", "put", "quota_exceeded") + return errors.QuotaExceeded(s.quota) + } + } + + // Verify checksums if provided + if opts != nil { + md5Sum := hex.EncodeToString(md5Hash.Sum(nil)) + sha256Sum := hex.EncodeToString(sha256Hash.Sum(nil)) + + if opts.ChecksumMD5 != "" && opts.ChecksumMD5 != md5Sum { + metrics.RecordStorageOperation("smb", "put", "checksum_error") + return errors.New(errors.ErrCodeChecksumMismatch, "MD5 checksum mismatch") + } + + if opts.ChecksumSHA256 != "" && opts.ChecksumSHA256 != sha256Sum { + metrics.RecordStorageOperation("smb", "put", "checksum_error") + return errors.New(errors.ErrCodeChecksumMismatch, "SHA256 checksum mismatch") + } + } + + // Create temp file for atomic write + tempPath := path + ".tmp" + file, err := conn.share.Create(tempPath) + if err != nil { + metrics.RecordStorageOperation("smb", "put", "error") + return errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to create SMB temp file") + } + + // Write data + _, err = io.Copy(file, bytes.NewReader(buf.Bytes())) + file.Close() // #nosec G104 -- Cleanup, error not critical + + if err != nil { + _ = conn.share.Remove(tempPath) // #nosec G104 -- SMB cleanup + metrics.RecordStorageOperation("smb", "put", "error") + return errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to write SMB file") + } + + // Atomic rename + if err := conn.share.Rename(tempPath, path); err != nil { + _ = conn.share.Remove(tempPath) // #nosec G104 -- SMB cleanup + metrics.RecordStorageOperation("smb", "put", "error") + return errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to rename SMB temp file") + } + + // Update usage + s.mu.Lock() + s.used += written + currentUsed := s.used + s.mu.Unlock() + + metrics.RecordStorageOperation("smb", "put", "success") + metrics.UpdateCacheSize("smb", currentUsed) + return nil +} + +// Delete removes a file from SMB share +func (s *SMBStorage) Delete(ctx context.Context, key string) error { + conn, err := s.getConnection(ctx) + if err != nil { + return err + } + defer s.returnConnection(conn) + + path := s.keyToPath(key) + + // Get size before deletion + info, err := conn.share.Stat(path) + if err != nil { + if os.IsNotExist(err) { + metrics.RecordStorageOperation("smb", "delete", "not_found") + return errors.NotFound(fmt.Sprintf("file not found: %s", key)) + } + metrics.RecordStorageOperation("smb", "delete", "error") + return errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to stat SMB file") + } + + size := info.Size() + + if err := conn.share.Remove(path); err != nil { + metrics.RecordStorageOperation("smb", "delete", "error") + return errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to delete SMB file") + } + + // Update usage + s.mu.Lock() + s.used -= size + currentUsed := s.used + s.mu.Unlock() + + metrics.RecordStorageOperation("smb", "delete", "success") + metrics.UpdateCacheSize("smb", currentUsed) + return nil +} + +// Exists checks if a file exists on SMB share +func (s *SMBStorage) Exists(ctx context.Context, key string) (bool, error) { + conn, err := s.getConnection(ctx) + if err != nil { + return false, err + } + defer s.returnConnection(conn) + + path := s.keyToPath(key) + + _, err = conn.share.Stat(path) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to check SMB file existence") + } + + return true, nil +} + +// List lists files with prefix on SMB share +func (s *SMBStorage) List(ctx context.Context, prefix string, opts *storage.ListOptions) ([]storage.StorageObject, error) { + conn, err := s.getConnection(ctx) + if err != nil { + return nil, err + } + defer s.returnConnection(conn) + + searchPath := s.keyToPath(prefix) + var objects []storage.StorageObject + + err = s.walkPath(conn.share, searchPath, func(path string, info os.FileInfo) error { + if info.IsDir() { + return nil + } + + key := s.pathToKey(path) + objects = append(objects, storage.StorageObject{ + Key: key, + Size: info.Size(), + Modified: info.ModTime(), + }) + return nil + }) + + if err != nil && !os.IsNotExist(err) { + return nil, errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to list SMB files") + } + + // Apply pagination if requested + if opts != nil { + start := opts.Offset + end := len(objects) + if opts.MaxResults > 0 && start+opts.MaxResults < end { + end = start + opts.MaxResults + } + if start < len(objects) { + objects = objects[start:end] + } else { + objects = []storage.StorageObject{} + } + } + + return objects, nil +} + +// Stat gets file metadata from SMB share +func (s *SMBStorage) Stat(ctx context.Context, key string) (*storage.StorageInfo, error) { + conn, err := s.getConnection(ctx) + if err != nil { + return nil, err + } + defer s.returnConnection(conn) + + path := s.keyToPath(key) + + info, err := conn.share.Stat(path) + if err != nil { + if os.IsNotExist(err) { + return nil, errors.NotFound(fmt.Sprintf("file not found: %s", key)) + } + return nil, errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to stat SMB file") + } + + return &storage.StorageInfo{ + Key: key, + Size: info.Size(), + Modified: info.ModTime(), + }, nil +} + +// GetQuota returns quota information +func (s *SMBStorage) GetQuota(ctx context.Context) (*storage.QuotaInfo, error) { + s.mu.RLock() + used := s.used + s.mu.RUnlock() + + available := s.quota - used + if available < 0 { + available = 0 + } + + return &storage.QuotaInfo{ + Used: used, + Available: available, + Limit: s.quota, + }, nil +} + +// Health checks SMB health +func (s *SMBStorage) Health(ctx context.Context) error { + conn, err := s.getConnection(ctx) + if err != nil { + return errors.Wrap(err, errors.ErrCodeStorageFailure, "SMB health check failed - connection error") + } + defer s.returnConnection(conn) + + // Try to stat the base path + path := s.keyToPath("") + _, err = conn.share.Stat(path) + if err != nil && !os.IsNotExist(err) { + return errors.Wrap(err, errors.ErrCodeStorageFailure, "SMB health check failed") + } + + return nil +} + +// Close closes the storage backend +func (s *SMBStorage) Close() error { + close(s.connPool) + for conn := range s.connPool { + conn.close() + } + return nil +} + +// keyToPath converts a storage key to SMB path +func (s *SMBStorage) keyToPath(key string) string { + key = strings.TrimPrefix(key, "/") + key = filepath.Clean(key) + + // Remove path traversal attempts + for strings.HasPrefix(key, "../") || strings.HasPrefix(key, "..\\") { + key = strings.TrimPrefix(key, "../") + key = strings.TrimPrefix(key, "..\\") + } + + key = filepath.Clean(key) + if key == ".." || strings.HasPrefix(key, "../") || strings.HasPrefix(key, "..\\") { + key = "" + } + + if s.basePath != "" { + return filepath.Join(s.basePath, key) + } + return key +} + +// pathToKey converts an SMB path back to a storage key +func (s *SMBStorage) pathToKey(path string) string { + if s.basePath != "" { + path = strings.TrimPrefix(path, s.basePath) + path = strings.TrimPrefix(path, "/") + path = strings.TrimPrefix(path, "\\") + } + return filepath.ToSlash(path) +} + +// walkPath recursively walks an SMB directory +func (s *SMBStorage) walkPath(share *smb2.Share, path string, fn func(string, os.FileInfo) error) error { + info, err := share.Stat(path) + if err != nil { + return err + } + + if !info.IsDir() { + return fn(path, info) + } + + entries, err := share.ReadDir(path) + if err != nil { + return err + } + + for _, entry := range entries { + entryPath := filepath.Join(path, entry.Name()) + if entry.IsDir() { + if err := s.walkPath(share, entryPath, fn); err != nil { + return err + } + } else { + if err := fn(entryPath, entry); err != nil { + return err + } + } + } + + return nil +} + +// calculateUsage calculates current SMB storage usage +func (s *SMBStorage) calculateUsage(ctx context.Context) error { + conn, err := s.getConnection(ctx) + if err != nil { + return err + } + defer s.returnConnection(conn) + + var total int64 + basePath := s.keyToPath("") + + err = s.walkPath(conn.share, basePath, func(path string, info os.FileInfo) error { + if !info.IsDir() { + total += info.Size() + } + return nil + }) + + if err != nil && !os.IsNotExist(err) { + return err + } + + s.mu.Lock() + s.used = total + s.mu.Unlock() + + metrics.UpdateCacheSize("smb", total) + return nil +} diff --git a/pkg/uuid/uuid.go b/pkg/uuid/uuid.go new file mode 100644 index 0000000..6400556 --- /dev/null +++ b/pkg/uuid/uuid.go @@ -0,0 +1,30 @@ +package uuid + +import ( + "crypto/rand" + "fmt" +) + +// UUID represents a UUID (RFC 4122) +type UUID [16]byte + +// New generates a random UUID v4 +func New() UUID { + var u UUID + // Read random bytes + if _, err := rand.Read(u[:]); err != nil { + panic(fmt.Sprintf("failed to generate UUID: %v", err)) + } + + // Set version (4) and variant (RFC 4122) + u[6] = (u[6] & 0x0f) | 0x40 // Version 4 + u[8] = (u[8] & 0x3f) | 0x80 // Variant RFC 4122 + + return u +} + +// String returns the UUID in standard format (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) +func (u UUID) String() string { + return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", + u[0:4], u[4:6], u[6:8], u[8:10], u[10:16]) +} diff --git a/pkg/uuid/uuid_test.go b/pkg/uuid/uuid_test.go new file mode 100644 index 0000000..0aa77f6 --- /dev/null +++ b/pkg/uuid/uuid_test.go @@ -0,0 +1,217 @@ +package uuid + +import ( + "regexp" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestNew tests UUID generation +func TestNew(t *testing.T) { + tests := []struct { + name string + runs int + }{ + { + name: "generate single UUID", + runs: 1, + }, + { + name: "generate multiple UUIDs", + runs: 100, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + seen := make(map[string]bool) + + for i := 0; i < tt.runs; i++ { + uuid := New() + + // Verify UUID is 16 bytes + assert.Equal(t, 16, len(uuid)) + + // Verify version is 4 + version := (uuid[6] >> 4) & 0x0f + assert.Equal(t, uint8(4), version, "UUID version should be 4") + + // Verify variant is RFC 4122 + variant := (uuid[8] >> 6) & 0x03 + assert.Equal(t, uint8(2), variant, "UUID variant should be RFC 4122 (10 in binary)") + + // Check uniqueness + str := uuid.String() + assert.False(t, seen[str], "UUID should be unique") + seen[str] = true + } + }) + } +} + +// TestString tests UUID string formatting +func TestString(t *testing.T) { + tests := []struct { + name string + uuid UUID + expected string + }{ + { + name: "zero UUID", + uuid: UUID{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, + expected: "00000000-0000-0000-0000-000000000000", + }, + { + name: "all ones UUID", + uuid: UUID{255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255}, + expected: "ffffffff-ffff-ffff-ffff-ffffffffffff", + }, + { + name: "mixed values UUID", + uuid: UUID{0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88}, + expected: "12345678-9abc-def0-1122-334455667788", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + str := tt.uuid.String() + assert.Equal(t, tt.expected, str) + + // Verify format matches UUID regex + matched, err := regexp.MatchString(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`, str) + require.NoError(t, err) + assert.True(t, matched, "UUID string should match standard format") + + // Verify dashes are in correct positions + assert.Equal(t, "-", string(str[8])) + assert.Equal(t, "-", string(str[13])) + assert.Equal(t, "-", string(str[18])) + assert.Equal(t, "-", string(str[23])) + + // Verify length + assert.Equal(t, 36, len(str)) + }) + } +} + +// TestUUIDFormat tests that generated UUIDs match the standard format +func TestUUIDFormat(t *testing.T) { + const iterations = 1000 + + // Compile regex once for performance + hexPattern := regexp.MustCompile(`^[0-9a-f]+$`) + + for i := 0; i < iterations; i++ { + uuid := New() + str := uuid.String() + + // Test standard UUID format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + parts := strings.Split(str, "-") + require.Len(t, parts, 5, "UUID should have 5 parts separated by dashes") + assert.Equal(t, 8, len(parts[0]), "First part should be 8 characters") + assert.Equal(t, 4, len(parts[1]), "Second part should be 4 characters") + assert.Equal(t, 4, len(parts[2]), "Third part should be 4 characters") + assert.Equal(t, 4, len(parts[3]), "Fourth part should be 4 characters") + assert.Equal(t, 12, len(parts[4]), "Fifth part should be 12 characters") + + // Verify all characters are hexadecimal + for _, part := range parts { + assert.True(t, hexPattern.MatchString(part), "UUID parts should only contain hex characters") + } + + // Verify version bits (4th character of third part should start with 4) + versionChar := parts[2][0] + assert.Equal(t, byte('4'), versionChar, "UUID version should be 4") + + // Verify variant bits (first character of fourth part should be 8, 9, a, or b) + variantChar := parts[3][0] + assert.Contains(t, []byte{'8', '9', 'a', 'b'}, variantChar, "UUID variant should be RFC 4122") + } +} + +// TestConcurrentGeneration tests that UUID generation is safe for concurrent use +func TestConcurrentGeneration(t *testing.T) { + const numGoroutines = 100 + const uuidsPerGoroutine = 100 + + results := make(chan UUID, numGoroutines*uuidsPerGoroutine) + + // Generate UUIDs concurrently + for i := 0; i < numGoroutines; i++ { + go func() { + for j := 0; j < uuidsPerGoroutine; j++ { + results <- New() + } + }() + } + + // Collect all UUIDs + seen := make(map[string]bool) + for i := 0; i < numGoroutines*uuidsPerGoroutine; i++ { + uuid := <-results + str := uuid.String() + + // Verify uniqueness + assert.False(t, seen[str], "UUID should be unique even in concurrent generation") + seen[str] = true + + // Verify version and variant + version := (uuid[6] >> 4) & 0x0f + assert.Equal(t, uint8(4), version) + + variant := (uuid[8] >> 6) & 0x03 + assert.Equal(t, uint8(2), variant) + } + + // Verify we got all expected UUIDs + assert.Equal(t, numGoroutines*uuidsPerGoroutine, len(seen)) +} + +// TestUUIDEquality tests UUID equality +func TestUUIDEquality(t *testing.T) { + uuid1 := New() + uuid2 := New() + + // Different UUIDs should not be equal + assert.NotEqual(t, uuid1, uuid2) + assert.NotEqual(t, uuid1.String(), uuid2.String()) + + // Same UUID should be equal + uuid3 := uuid1 + assert.Equal(t, uuid1, uuid3) + assert.Equal(t, uuid1.String(), uuid3.String()) +} + +// TestUUIDArrayAccess tests that UUID can be accessed as a byte array +func TestUUIDArrayAccess(t *testing.T) { + uuid := New() + + // Verify we can access all bytes + for i := 0; i < 16; i++ { + _ = uuid[i] + } + + // Verify length + assert.Equal(t, 16, len(uuid)) +} + +// BenchmarkNew benchmarks UUID generation +func BenchmarkNew(b *testing.B) { + for i := 0; i < b.N; i++ { + _ = New() + } +} + +// BenchmarkString benchmarks UUID string conversion +func BenchmarkString(b *testing.B) { + uuid := New() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + _ = uuid.String() + } +} diff --git a/pkg/vcs/credentials.go b/pkg/vcs/credentials.go new file mode 100644 index 0000000..7be8078 --- /dev/null +++ b/pkg/vcs/credentials.go @@ -0,0 +1,247 @@ +package vcs + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/rs/zerolog/log" +) + +// CredentialStore manages git credentials for different repository patterns +type CredentialStore struct { + credentials []CredentialEntry +} + +// CredentialEntry represents credentials for a specific pattern +type CredentialEntry struct { + Pattern string `json:"pattern"` // Glob pattern: "github.com/myorg/*" + Host string `json:"host"` // Git host: "github.com" + Username string `json:"username"` // Usually "oauth2" for tokens + Token string `json:"token"` // Access token + Fallback bool `json:"fallback"` // Use as fallback if no match +} + +// CredentialConfig represents the JSON configuration format +type CredentialConfig struct { + Credentials []CredentialEntry `json:"credentials"` +} + +// NewCredentialStore creates a new credential store +func NewCredentialStore() *CredentialStore { + return &CredentialStore{ + credentials: make([]CredentialEntry, 0), + } +} + +// LoadFromFile loads credentials from a JSON file +func (cs *CredentialStore) LoadFromFile(path string) error { + if path == "" { + log.Debug().Msg("No credential file specified, using system git config") + return nil + } + + // Check if file exists + if _, err := os.Stat(path); os.IsNotExist(err) { + log.Warn().Str("path", path).Msg("Credential file not found, using system git config") + return nil + } + + data, err := os.ReadFile(path) // #nosec G304 -- Path is from config, not user input + if err != nil { + return fmt.Errorf("failed to read credential file: %w", err) + } + + var config CredentialConfig + if err := json.Unmarshal(data, &config); err != nil { + return fmt.Errorf("failed to parse credential file: %w", err) + } + + cs.credentials = config.Credentials + + log.Info(). + Str("file", path). + Int("credentials", len(cs.credentials)). + Msg("Loaded git credentials from file") + + // Log patterns (not tokens!) for debugging + for i, cred := range cs.credentials { + log.Debug(). + Int("index", i). + Str("pattern", cred.Pattern). + Str("host", cred.Host). + Bool("fallback", cred.Fallback). + Msg("Registered credential pattern") + } + + return nil +} + +// GetCredentialsForModule finds the best matching credentials for a module path +// Returns (username, token, found) +func (cs *CredentialStore) GetCredentialsForModule(modulePath string) (string, string, bool) { + if len(cs.credentials) == 0 { + // No credentials configured, rely on system git config + return "", "", false + } + + // Find best match + var bestMatch *CredentialEntry + var fallbackMatch *CredentialEntry + bestMatchLen := 0 + + for i := range cs.credentials { + cred := &cs.credentials[i] + + // Check for fallback + if cred.Fallback { + fallbackMatch = cred + continue + } + + // Check if pattern matches + if cs.matchPattern(cred.Pattern, modulePath) { + // Use longest matching pattern (most specific) + if len(cred.Pattern) > bestMatchLen { + bestMatch = cred + bestMatchLen = len(cred.Pattern) + } + } + } + + // Use best match if found + if bestMatch != nil { + log.Debug(). + Str("module", modulePath). + Str("pattern", bestMatch.Pattern). + Str("host", bestMatch.Host). + Msg("Matched credential pattern") + return bestMatch.Username, bestMatch.Token, true + } + + // Use fallback if available + if fallbackMatch != nil { + log.Debug(). + Str("module", modulePath). + Str("pattern", fallbackMatch.Pattern). + Msg("Using fallback credentials") + return fallbackMatch.Username, fallbackMatch.Token, true + } + + // No match found + log.Debug(). + Str("module", modulePath). + Msg("No credential pattern matched, using system git config") + return "", "", false +} + +// matchPattern checks if a module path matches a credential pattern +// Supports glob-style patterns: +// - github.com/myorg/* matches github.com/myorg/repo1, github.com/myorg/repo2 +// - github.com/myorg/repo matches exactly github.com/myorg/repo +// - * matches everything +func (cs *CredentialStore) matchPattern(pattern, modulePath string) bool { + // Exact match + if pattern == modulePath { + return true + } + + // Wildcard match all + if pattern == "*" { + return true + } + + // Glob-style matching + matched, err := filepath.Match(pattern, modulePath) + if err != nil { + log.Warn().Err(err).Str("pattern", pattern).Msg("Invalid pattern") + return false + } + + if matched { + return true + } + + // Prefix matching with /* + if strings.HasSuffix(pattern, "/*") { + prefix := strings.TrimSuffix(pattern, "/*") + return strings.HasPrefix(modulePath, prefix+"/") + } + + return false +} + +// CreateNetrcContent creates .netrc file content for a specific host +func (cs *CredentialStore) CreateNetrcContent(host, username, token string) string { + return fmt.Sprintf("machine %s\nlogin %s\npassword %s\n", host, username, token) +} + +// GetCredentialsForHost finds credentials for a specific git host (e.g., "github.com") +// This is useful when you need credentials for a host but don't have a full module path +func (cs *CredentialStore) GetCredentialsForHost(host string) (string, string, bool) { + if len(cs.credentials) == 0 { + return "", "", false + } + + // Look for exact host match first + for i := range cs.credentials { + cred := &cs.credentials[i] + if cred.Host == host && !cred.Fallback { + log.Debug(). + Str("host", host). + Str("pattern", cred.Pattern). + Msg("Found credentials for host") + return cred.Username, cred.Token, true + } + } + + // Try fallback + for i := range cs.credentials { + cred := &cs.credentials[i] + if cred.Fallback { + log.Debug(). + Str("host", host). + Msg("Using fallback credentials for host") + return cred.Username, cred.Token, true + } + } + + return "", "", false +} + +// ValidateConfig validates the credential configuration +func (cs *CredentialStore) ValidateConfig() error { + hostPatterns := make(map[string]bool) + + for i, cred := range cs.credentials { + // Check required fields + if cred.Pattern == "" { + return fmt.Errorf("credential entry %d: pattern is required", i) + } + if cred.Host == "" && cred.Pattern != "*" { + return fmt.Errorf("credential entry %d: host is required (pattern: %s)", i, cred.Pattern) + } + if cred.Token == "" { + return fmt.Errorf("credential entry %d: token is required (pattern: %s)", i, cred.Pattern) + } + + // Set default username if not provided + if cred.Username == "" { + cs.credentials[i].Username = "oauth2" + } + + // Check for duplicate patterns + key := cred.Pattern + ":" + cred.Host + if hostPatterns[key] && !cred.Fallback { + log.Warn(). + Str("pattern", cred.Pattern). + Str("host", cred.Host). + Msg("Duplicate credential pattern, last one wins") + } + hostPatterns[key] = true + } + + return nil +} diff --git a/pkg/vcs/git.go b/pkg/vcs/git.go new file mode 100644 index 0000000..eab6518 --- /dev/null +++ b/pkg/vcs/git.go @@ -0,0 +1,280 @@ +package vcs + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/rs/zerolog/log" +) + +// GitFetcher handles git repository operations +type GitFetcher struct { + workDir string + timeout time.Duration + credStore *CredentialStore +} + +// NewGitFetcher creates a new git fetcher +func NewGitFetcher(workDir string, credStore *CredentialStore) *GitFetcher { + if workDir == "" { + workDir = os.TempDir() + } + + if credStore == nil { + credStore = NewCredentialStore() + } + + return &GitFetcher{ + workDir: workDir, + timeout: 30 * time.Second, + credStore: credStore, + } +} + +// FetchModule clones a git repository and checks out a specific version +// Returns the path to the checked-out source directory +func (g *GitFetcher) FetchModule(ctx context.Context, modulePath, version, credentials string) (string, error) { + // Create context with timeout + ctx, cancel := context.WithTimeout(ctx, g.timeout) + defer cancel() + + // Parse module path to extract repository URL + repoURL, err := g.modulePathToRepoURL(modulePath) + if err != nil { + return "", err + } + + // Create temporary directory for this clone + cloneDir, err := os.MkdirTemp(g.workDir, "gohoarder-git-*") + if err != nil { + return "", fmt.Errorf("failed to create temp directory: %w", err) + } + + log.Debug(). + Str("module", modulePath). + Str("version", version). + Str("repo_url", repoURL). + Str("clone_dir", cloneDir). + Msg("Fetching module from git") + + // Set up credentials + credentialHelper, cleanup, err := g.setupCredentials(repoURL, modulePath, credentials) + if err != nil { + _ = os.RemoveAll(cloneDir) // #nosec G104 -- Cleanup + return "", fmt.Errorf("failed to setup credentials: %w", err) + } + defer cleanup() + + // Try shallow clone with specific version first (fastest) + if err := g.shallowClone(ctx, repoURL, version, cloneDir, credentialHelper); err != nil { + log.Debug().Err(err).Msg("Shallow clone failed, trying full clone") + + // Fallback to full clone + if err := g.fullClone(ctx, repoURL, cloneDir, credentialHelper); err != nil { + _ = os.RemoveAll(cloneDir) // #nosec G104 -- Cleanup + return "", fmt.Errorf("git clone failed: %w", err) + } + + // Checkout specific version + if err := g.checkout(ctx, cloneDir, version); err != nil { + _ = os.RemoveAll(cloneDir) // #nosec G104 -- Cleanup + return "", fmt.Errorf("git checkout failed: %w", err) + } + } + + log.Debug(). + Str("module", modulePath). + Str("version", version). + Str("path", cloneDir). + Msg("Successfully fetched module from git") + + return cloneDir, nil +} + +// modulePathToRepoURL converts a Go module path to a git repository URL +// Examples: +// +// github.com/user/repo β†’ https://github.com/user/repo.git +// gitlab.com/group/project β†’ https://gitlab.com/group/project.git +func (g *GitFetcher) modulePathToRepoURL(modulePath string) (string, error) { + // Remove any path components after the repository + // e.g., github.com/user/repo/v2 β†’ github.com/user/repo + parts := strings.Split(modulePath, "/") + if len(parts) < 3 { + return "", fmt.Errorf("invalid module path: %s", modulePath) + } + + // For github.com, gitlab.com, bitbucket.org, etc. + // Format: host/owner/repo + host := parts[0] + owner := parts[1] + repo := parts[2] + + // Remove version suffix if present (e.g., /v2, /v3) + repo = strings.TrimPrefix(repo, "v") + + repoURL := fmt.Sprintf("https://%s/%s/%s.git", host, owner, repo) + return repoURL, nil +} + +// setupCredentials configures git credentials for authentication +// Returns credential helper configuration and cleanup function +func (g *GitFetcher) setupCredentials(repoURL, modulePath, credentials string) (map[string]string, func(), error) { + env := make(map[string]string) + cleanup := func() {} + + // Priority 1: Check credential store for pattern-based credentials + if g.credStore != nil { + username, token, found := g.credStore.GetCredentialsForModule(modulePath) + if found { + log.Debug(). + Str("module", modulePath). + Msg("Using credentials from credential store") + return g.createTempNetrc(repoURL, username, token) + } + } + + // Priority 2: Use credentials from HTTP Authorization header (if provided) + if credentials != "" { + log.Debug().Msg("Using credentials from Authorization header") + return g.createTempNetrcFromHeader(repoURL, credentials) + } + + // Priority 3: Rely on system git config (.netrc, etc.) + log.Debug().Msg("No credentials provided, using system git config") + return env, cleanup, nil +} + +// createTempNetrc creates a temporary .netrc file with the provided credentials +func (g *GitFetcher) createTempNetrc(repoURL, username, token string) (map[string]string, func(), error) { + // Create temporary .netrc file + tempDir, err := os.MkdirTemp("", "gohoarder-netrc-*") + if err != nil { + return nil, nil, fmt.Errorf("failed to create temp netrc directory: %w", err) + } + + // Extract host from repo URL + host := g.extractHost(repoURL) + + // Create .netrc file + netrcPath := filepath.Join(tempDir, ".netrc") + netrcContent := fmt.Sprintf("machine %s\nlogin %s\npassword %s\n", host, username, token) + if err := os.WriteFile(netrcPath, []byte(netrcContent), 0600); err != nil { + _ = os.RemoveAll(tempDir) // #nosec G104 -- Cleanup + return nil, nil, fmt.Errorf("failed to write .netrc: %w", err) + } + + env := map[string]string{ + "HOME": tempDir, + "GIT_TERMINAL_PROMPT": "0", + } + + cleanup := func() { + _ = os.RemoveAll(tempDir) // #nosec G104 -- Cleanup + } + + log.Debug().Str("host", host).Msg("Created temporary .netrc for git authentication") + + return env, cleanup, nil +} + +// createTempNetrcFromHeader creates a temporary .netrc from Authorization header credentials +func (g *GitFetcher) createTempNetrcFromHeader(repoURL, credentials string) (map[string]string, func(), error) { + // Extract token from credentials + token := strings.TrimPrefix(credentials, "Bearer ") + token = strings.TrimPrefix(token, "Token ") + token = strings.TrimPrefix(token, "Private-Token ") + + if token == "" || token == credentials { + // Not in expected format, rely on system config + log.Debug().Msg("Credentials not in Bearer/Token format, using system git config") + return make(map[string]string), func() {}, nil + } + + // Use oauth2 as default username for token-based auth + return g.createTempNetrc(repoURL, "oauth2", token) +} + +// extractHost extracts the git host from a repository URL +func (g *GitFetcher) extractHost(repoURL string) string { + if strings.Contains(repoURL, "github.com") { + return "github.com" + } + if strings.Contains(repoURL, "gitlab.com") { + return "gitlab.com" + } + if strings.Contains(repoURL, "bitbucket.org") { + return "bitbucket.org" + } + + // Generic extraction + parts := strings.Split(repoURL, "/") + if len(parts) >= 3 { + return strings.TrimPrefix(parts[2], "//") + } + + return "" +} + +// shallowClone performs a shallow clone of a specific version +func (g *GitFetcher) shallowClone(ctx context.Context, repoURL, version, cloneDir string, credentialHelper map[string]string) error { + cmd := exec.CommandContext(ctx, "git", "clone", "--depth", "1", "--branch", version, repoURL, cloneDir) + cmd.Env = append(os.Environ(), g.envMapToSlice(credentialHelper)...) + + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("shallow clone failed: %w (output: %s)", err, string(output)) + } + + return nil +} + +// fullClone performs a full clone of the repository +func (g *GitFetcher) fullClone(ctx context.Context, repoURL, cloneDir string, credentialHelper map[string]string) error { + cmd := exec.CommandContext(ctx, "git", "clone", repoURL, cloneDir) + cmd.Env = append(os.Environ(), g.envMapToSlice(credentialHelper)...) + + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("full clone failed: %w (output: %s)", err, string(output)) + } + + return nil +} + +// checkout checks out a specific version (tag, branch, or commit) +func (g *GitFetcher) checkout(ctx context.Context, repoDir, version string) error { + cmd := exec.CommandContext(ctx, "git", "checkout", version) + cmd.Dir = repoDir + cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") + + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("checkout failed: %w (output: %s)", err, string(output)) + } + + return nil +} + +// envMapToSlice converts environment map to slice +func (g *GitFetcher) envMapToSlice(envMap map[string]string) []string { + var env []string + for k, v := range envMap { + env = append(env, fmt.Sprintf("%s=%s", k, v)) + } + return env +} + +// Cleanup removes temporary directories +func (g *GitFetcher) Cleanup(paths ...string) { + for _, path := range paths { + if err := os.RemoveAll(path); err != nil { + log.Warn().Err(err).Str("path", path).Msg("Failed to cleanup temporary directory") + } + } +} diff --git a/pkg/vcs/module.go b/pkg/vcs/module.go new file mode 100644 index 0000000..5a1b0bb --- /dev/null +++ b/pkg/vcs/module.go @@ -0,0 +1,252 @@ +package vcs + +import ( + "archive/zip" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/rs/zerolog/log" +) + +// ModuleBuilder builds Go module artifacts from source +type ModuleBuilder struct{} + +// NewModuleBuilder creates a new module builder +func NewModuleBuilder() *ModuleBuilder { + return &ModuleBuilder{} +} + +// ModuleInfo represents Go module version metadata (.info file) +type ModuleInfo struct { + Version string `json:"Version"` + Time time.Time `json:"Time"` +} + +// BuildModuleZip creates a Go module zip from source directory +// Follows the Go module zip format specification: https://go.dev/ref/mod#zip-files +func (b *ModuleBuilder) BuildModuleZip(ctx context.Context, srcPath, modulePath, version string) (io.ReadCloser, error) { + log.Debug(). + Str("src_path", srcPath). + Str("module", modulePath). + Str("version", version). + Msg("Building module zip") + + // Create in-memory zip + var buf bytes.Buffer + zipWriter := zip.NewWriter(&buf) + + // Collect all files to include in zip + files, err := b.collectFiles(srcPath) + if err != nil { + return nil, fmt.Errorf("failed to collect files: %w", err) + } + + // Sort files for deterministic zip + sort.Strings(files) + + // Add files to zip with proper prefix + prefix := fmt.Sprintf("%s@%s/", modulePath, version) + for _, relPath := range files { + if err := b.addFileToZip(zipWriter, srcPath, relPath, prefix); err != nil { + zipWriter.Close() // #nosec G104 -- Cleanup, error not critical + return nil, fmt.Errorf("failed to add file %s: %w", relPath, err) + } + } + + if err := zipWriter.Close(); err != nil { + return nil, fmt.Errorf("failed to close zip writer: %w", err) + } + + log.Debug(). + Str("module", modulePath). + Str("version", version). + Int("files", len(files)). + Int("size", buf.Len()). + Msg("Successfully built module zip") + + return io.NopCloser(bytes.NewReader(buf.Bytes())), nil +} + +// collectFiles walks the source directory and collects files to include +func (b *ModuleBuilder) collectFiles(srcPath string) ([]string, error) { + var files []string + + err := filepath.Walk(srcPath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Skip directories + if info.IsDir() { + // Skip .git directory + if info.Name() == ".git" { + return filepath.SkipDir + } + // Skip vendor directory (per Go module zip spec) + if info.Name() == "vendor" { + return filepath.SkipDir + } + return nil + } + + // Get relative path + relPath, err := filepath.Rel(srcPath, path) + if err != nil { + return err + } + + // Skip hidden files (except .gitignore, etc. if needed) + if strings.HasPrefix(filepath.Base(relPath), ".") && relPath != ".gitignore" { + return nil + } + + // Include file + files = append(files, relPath) + return nil + }) + + if err != nil { + return nil, err + } + + return files, nil +} + +// addFileToZip adds a single file to the zip archive +func (b *ModuleBuilder) addFileToZip(zipWriter *zip.Writer, srcPath, relPath, prefix string) error { + // Create zip header + header := &zip.FileHeader{ + Name: prefix + filepath.ToSlash(relPath), + Method: zip.Deflate, + } + + // Get file info for permissions + fullPath := filepath.Join(srcPath, relPath) + info, err := os.Stat(fullPath) + if err != nil { + return err + } + + // Set modification time to a fixed value for deterministic zips + // Go uses the timestamp from the version info + header.Modified = time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC) + header.SetMode(info.Mode()) + + // Create file in zip + writer, err := zipWriter.CreateHeader(header) + if err != nil { + return err + } + + // Copy file contents + file, err := os.Open(fullPath) // #nosec G304 -- Path is from zip archive extraction + if err != nil { + return err + } + defer file.Close() // #nosec G104 -- Cleanup, error not critical + + if _, err := io.Copy(writer, file); err != nil { + return err + } + + return nil +} + +// GenerateModInfo creates .info file (JSON metadata) +func (b *ModuleBuilder) GenerateModInfo(ctx context.Context, srcPath, version string) ([]byte, error) { + // Get commit timestamp from git + timestamp, err := b.getGitCommitTime(srcPath) + if err != nil { + // Fallback to current time if git info not available + log.Warn().Err(err).Msg("Failed to get git commit time, using current time") + timestamp = time.Now() + } + + info := ModuleInfo{ + Version: version, + Time: timestamp, + } + + data, err := json.Marshal(info) + if err != nil { + return nil, fmt.Errorf("failed to marshal module info: %w", err) + } + + return data, nil +} + +// getGitCommitTime retrieves the commit timestamp from git +func (b *ModuleBuilder) getGitCommitTime(repoPath string) (time.Time, error) { + cmd := exec.Command("git", "log", "-1", "--format=%cI") + cmd.Dir = repoPath + + output, err := cmd.Output() + if err != nil { + return time.Time{}, err + } + + // Parse ISO 8601 timestamp + timestamp, err := time.Parse(time.RFC3339, strings.TrimSpace(string(output))) + if err != nil { + return time.Time{}, err + } + + return timestamp, nil +} + +// ExtractGoMod extracts go.mod content +func (b *ModuleBuilder) ExtractGoMod(ctx context.Context, srcPath string) ([]byte, error) { + goModPath := filepath.Join(srcPath, "go.mod") + + data, err := os.ReadFile(goModPath) // #nosec G304 -- Path is from controlled temp directory + if err != nil { + return nil, fmt.Errorf("failed to read go.mod: %w", err) + } + + // Validate go.mod (basic check) + if !strings.Contains(string(data), "module ") { + return nil, fmt.Errorf("invalid go.mod: missing module directive") + } + + return data, nil +} + +// ValidateModule performs basic validation on the module +func (b *ModuleBuilder) ValidateModule(ctx context.Context, srcPath, expectedModulePath string) error { + // Read go.mod + goModData, err := b.ExtractGoMod(ctx, srcPath) + if err != nil { + return err + } + + // Extract module path from go.mod + lines := strings.Split(string(goModData), "\n") + var declaredModulePath string + for _, line := range lines { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "module ") { + declaredModulePath = strings.TrimSpace(strings.TrimPrefix(line, "module ")) + break + } + } + + if declaredModulePath == "" { + return fmt.Errorf("go.mod missing module declaration") + } + + // Check if module path matches (allow version suffixes) + if !strings.HasPrefix(expectedModulePath, declaredModulePath) { + return fmt.Errorf("module path mismatch: expected %s, got %s", expectedModulePath, declaredModulePath) + } + + return nil +} diff --git a/pkg/websocket/server.go b/pkg/websocket/server.go new file mode 100644 index 0000000..44c7333 --- /dev/null +++ b/pkg/websocket/server.go @@ -0,0 +1,388 @@ +package websocket + +import ( + "context" + "encoding/json" + "net/http" + "sync" + "time" + + "github.com/gorilla/websocket" + "github.com/rs/zerolog/log" +) + +// EventType represents the type of event being broadcast +type EventType string + +const ( + EventPackageCached EventType = "package_cached" + EventPackageDeleted EventType = "package_deleted" + EventPackageDownloaded EventType = "package_downloaded" + EventScanComplete EventType = "scan_complete" + EventStatsUpdate EventType = "stats_update" + EventSystemAlert EventType = "system_alert" +) + +// Event represents a WebSocket event message +type Event struct { + Type EventType `json:"type"` + Timestamp time.Time `json:"timestamp"` + Data map[string]interface{} `json:"data"` +} + +// Client represents a WebSocket client connection +type Client struct { + conn *websocket.Conn + send chan []byte + server *Server + subscriptions map[EventType]bool + mu sync.RWMutex +} + +// Server manages WebSocket connections and event broadcasting +type Server struct { + clients map[*Client]bool + broadcast chan Event + register chan *Client + unregister chan *Client + mu sync.RWMutex + upgrader websocket.Upgrader +} + +// Config holds WebSocket server configuration +type Config struct { + ReadBufferSize int + WriteBufferSize int + CheckOrigin func(r *http.Request) bool +} + +// NewServer creates a new WebSocket server +func NewServer(cfg Config) *Server { + if cfg.CheckOrigin == nil { + cfg.CheckOrigin = func(r *http.Request) bool { + return true // Allow all origins by default + } + } + + server := &Server{ + clients: make(map[*Client]bool), + broadcast: make(chan Event, 256), + register: make(chan *Client), + unregister: make(chan *Client), + upgrader: websocket.Upgrader{ + ReadBufferSize: cfg.ReadBufferSize, + WriteBufferSize: cfg.WriteBufferSize, + CheckOrigin: cfg.CheckOrigin, + }, + } + + return server +} + +// Start starts the WebSocket server event loop +func (s *Server) Start(ctx context.Context) { + go s.run(ctx) + log.Info().Msg("WebSocket server started") +} + +// run handles client registration/unregistration and broadcasting +func (s *Server) run(ctx context.Context) { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + log.Info().Msg("WebSocket server shutting down") + s.closeAllClients() + return + + case client := <-s.register: + s.mu.Lock() + s.clients[client] = true + s.mu.Unlock() + log.Debug(). + Int("total_clients", len(s.clients)). + Msg("Client registered") + + case client := <-s.unregister: + s.mu.Lock() + if _, ok := s.clients[client]; ok { + delete(s.clients, client) + close(client.send) + } + s.mu.Unlock() + log.Debug(). + Int("total_clients", len(s.clients)). + Msg("Client unregistered") + + case event := <-s.broadcast: + s.broadcastEvent(event) + + case <-ticker.C: + // Ping all clients to keep connections alive + s.pingClients() + } + } +} + +// broadcastEvent sends an event to all subscribed clients +func (s *Server) broadcastEvent(event Event) { + message, err := json.Marshal(event) + if err != nil { + log.Error().Err(err).Msg("Failed to marshal event") + return + } + + s.mu.RLock() + defer s.mu.RUnlock() + + for client := range s.clients { + // Check if client is subscribed to this event type + client.mu.RLock() + subscribed := len(client.subscriptions) == 0 || client.subscriptions[event.Type] + client.mu.RUnlock() + + if subscribed { + select { + case client.send <- message: + default: + // Client send buffer full - close connection + go func(c *Client) { + s.unregister <- c + }(client) + } + } + } + + log.Debug(). + Str("event_type", string(event.Type)). + Int("clients_notified", len(s.clients)). + Msg("Event broadcast") +} + +// pingClients sends ping messages to all connected clients +func (s *Server) pingClients() { + s.mu.RLock() + defer s.mu.RUnlock() + + for client := range s.clients { + if err := client.conn.WriteControl( + websocket.PingMessage, + []byte{}, + time.Now().Add(10*time.Second), + ); err != nil { + log.Debug().Err(err).Msg("Failed to ping client") + go func(c *Client) { + s.unregister <- c + }(client) + } + } +} + +// closeAllClients closes all client connections +func (s *Server) closeAllClients() { + s.mu.Lock() + defer s.mu.Unlock() + + for client := range s.clients { + client.conn.Close() // #nosec G104 -- Cleanup, error not critical + close(client.send) + } + s.clients = make(map[*Client]bool) +} + +// Broadcast sends an event to all connected clients +func (s *Server) Broadcast(eventType EventType, data map[string]interface{}) { + event := Event{ + Type: eventType, + Timestamp: time.Now(), + Data: data, + } + + select { + case s.broadcast <- event: + default: + log.Warn().Msg("Broadcast channel full - dropping event") + } +} + +// HandleWebSocket upgrades HTTP connection to WebSocket +func (s *Server) HandleWebSocket(w http.ResponseWriter, r *http.Request) { + conn, err := s.upgrader.Upgrade(w, r, nil) + if err != nil { + log.Error().Err(err).Msg("Failed to upgrade connection") + return + } + + client := &Client{ + conn: conn, + send: make(chan []byte, 256), + server: s, + subscriptions: make(map[EventType]bool), + } + + s.register <- client + + // Start goroutines for reading and writing + go client.readPump() + go client.writePump() + + log.Info(). + Str("remote_addr", r.RemoteAddr). + Msg("WebSocket connection established") +} + +// readPump handles incoming messages from the client +func (c *Client) readPump() { + defer func() { + c.server.unregister <- c + c.conn.Close() // #nosec G104 -- Cleanup, error not critical + }() + + _ = c.conn.SetReadDeadline(time.Now().Add(60 * time.Second)) // #nosec G104 -- Websocket deadline + c.conn.SetPongHandler(func(string) error { // #nosec G104 -- Websocket handler + _ = c.conn.SetReadDeadline(time.Now().Add(60 * time.Second)) // #nosec G104 -- Websocket deadline + return nil + }) + + for { + _, message, err := c.conn.ReadMessage() + if err != nil { + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { + log.Error().Err(err).Msg("WebSocket read error") + } + break + } + + // Handle client messages (subscriptions, etc.) + c.handleMessage(message) + } +} + +// writePump handles outgoing messages to the client +func (c *Client) writePump() { + ticker := time.NewTicker(54 * time.Second) + defer func() { + ticker.Stop() + c.conn.Close() // #nosec G104 -- Cleanup, error not critical + }() + + for { + select { + case message, ok := <-c.send: + _ = c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) // #nosec G104 -- Websocket deadline, error not critical + if !ok { + // Channel closed + _ = c.conn.WriteMessage(websocket.CloseMessage, []byte{}) // #nosec G104 -- Websocket write + return + } + + w, err := c.conn.NextWriter(websocket.TextMessage) + if err != nil { + return + } + _, _ = w.Write(message) // #nosec G104 -- Websocket buffer write + + // Write any additional queued messages + n := len(c.send) + for i := 0; i < n; i++ { + _, _ = w.Write([]byte{'\n'}) // #nosec G104 -- Websocket buffer write + _, _ = w.Write(<-c.send) // #nosec G104 -- Websocket buffer write + } + + if err := w.Close(); err != nil { + return + } + + case <-ticker.C: + _ = c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) // #nosec G104 -- Websocket deadline, error not critical + if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil { + return + } + } + } +} + +// handleMessage processes incoming client messages +func (c *Client) handleMessage(message []byte) { + var msg struct { + Action string `json:"action"` + Data interface{} `json:"data"` + } + + if err := json.Unmarshal(message, &msg); err != nil { + log.Error().Err(err).Msg("Failed to unmarshal client message") + return + } + + switch msg.Action { + case "subscribe": + c.handleSubscribe(msg.Data) + case "unsubscribe": + c.handleUnsubscribe(msg.Data) + case "ping": + c.sendPong() + default: + log.Warn().Str("action", msg.Action).Msg("Unknown client action") + } +} + +// handleSubscribe subscribes the client to specific event types +func (c *Client) handleSubscribe(data interface{}) { + eventTypes, ok := data.([]interface{}) + if !ok { + log.Error().Msg("Invalid subscribe data format") + return + } + + c.mu.Lock() + defer c.mu.Unlock() + + for _, et := range eventTypes { + if eventType, ok := et.(string); ok { + c.subscriptions[EventType(eventType)] = true + log.Debug(). + Str("event_type", eventType). + Msg("Client subscribed to event type") + } + } +} + +// handleUnsubscribe unsubscribes the client from specific event types +func (c *Client) handleUnsubscribe(data interface{}) { + eventTypes, ok := data.([]interface{}) + if !ok { + log.Error().Msg("Invalid unsubscribe data format") + return + } + + c.mu.Lock() + defer c.mu.Unlock() + + for _, et := range eventTypes { + if eventType, ok := et.(string); ok { + delete(c.subscriptions, EventType(eventType)) + log.Debug(). + Str("event_type", eventType). + Msg("Client unsubscribed from event type") + } + } +} + +// sendPong sends a pong response to the client +func (c *Client) sendPong() { + response := map[string]string{"type": "pong"} + message, _ := json.Marshal(response) + select { + case c.send <- message: + default: + } +} + +// GetConnectedClients returns the number of connected clients +func (s *Server) GetConnectedClients() int { + s.mu.RLock() + defer s.mu.RUnlock() + return len(s.clients) +} diff --git a/script/generate-version.sh b/script/generate-version.sh new file mode 100755 index 0000000..7f90602 --- /dev/null +++ b/script/generate-version.sh @@ -0,0 +1,55 @@ +#!/bin/bash +set -e + +# generate-version.sh +# Generates semantic version based on git tags and commits +# +# Usage: +# ./script/generate-version.sh +# +# Environment variables (optional): +# VERSION_PREFIX - Prefix for version tags (default: v) +# FALLBACK_VERSION - Version to use if no tags found (default: 0.0.0) + +VERSION_PREFIX="${VERSION_PREFIX:-v}" +FALLBACK_VERSION="${FALLBACK_VERSION:-0.0.0}" + +# Try to get version from git describe +if git describe --tags --abbrev=0 2>/dev/null >/dev/null; then + # Get the latest tag + LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null) + + # Remove prefix if present + VERSION="${LATEST_TAG#$VERSION_PREFIX}" + + # Get commits since last tag + COMMITS_SINCE_TAG=$(git rev-list ${LATEST_TAG}..HEAD --count 2>/dev/null || echo "0") + + # Get current commit hash + COMMIT_HASH=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") + + # If there are commits since the last tag, add pre-release identifier + if [ "$COMMITS_SINCE_TAG" != "0" ]; then + # Increment patch version and add pre-release identifier + # Parse the version + IFS='.' read -r MAJOR MINOR PATCH <<< "$VERSION" + + # Increment patch for next development version + NEXT_PATCH=$((PATCH + 1)) + + # Generate pre-release version + VERSION="${MAJOR}.${MINOR}.${NEXT_PATCH}-dev.${COMMITS_SINCE_TAG}+${COMMIT_HASH}" + fi +else + # No tags found, use fallback + COMMIT_HASH=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") + COMMIT_COUNT=$(git rev-list --count HEAD 2>/dev/null || echo "0") + VERSION="${FALLBACK_VERSION}-dev.${COMMIT_COUNT}+${COMMIT_HASH}" +fi + +# Check if working directory is dirty +if [ -n "$(git status --porcelain 2>/dev/null)" ]; then + VERSION="${VERSION}-dirty" +fi + +echo "$VERSION" diff --git a/script/test-packages.sh b/script/test-packages.sh new file mode 100755 index 0000000..318c02d --- /dev/null +++ b/script/test-packages.sh @@ -0,0 +1,155 @@ +#!/bin/bash +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Configuration +GOHOARDER_URL="${GOHOARDER_URL:-}" +TEMP_DIR="/tmp/gohoarder-test-$$" + +# Cleanup function +cleanup() { + echo "" + echo "Cleaning up temporary directories..." + rm -rf "$TEMP_DIR" +} +trap cleanup EXIT + +# Auto-detect gohoarder URL if not set +if [ -z "$GOHOARDER_URL" ]; then + # Try to read port from config.yaml + if [ -f "config.yaml" ]; then + PORT=$(grep "^ port:" config.yaml | awk '{print $2}') + if [ -n "$PORT" ]; then + GOHOARDER_URL="http://localhost:$PORT" + fi + fi + + # Fallback to default + if [ -z "$GOHOARDER_URL" ]; then + GOHOARDER_URL="http://localhost:8080" + fi +fi + +echo "=========================================" +echo "Downloading test packages through gohoarder" +echo "GoHoarder URL: $GOHOARDER_URL" +echo "=========================================" +echo "" + +# Check if gohoarder is running +if ! curl -s -f "$GOHOARDER_URL/api/stats" > /dev/null 2>&1; then + echo -e "${RED}ERROR: gohoarder is not running at $GOHOARDER_URL${NC}" + echo "" + echo "Please start gohoarder first with: make run" + echo "" + echo "If gohoarder is running on a different port, set GOHOARDER_URL:" + echo " GOHOARDER_URL=http://localhost:9090 make test-packages" + exit 1 +fi + +echo -e "${GREEN}βœ“ gohoarder is running${NC}" +echo "" + +# Create temp directories +mkdir -p "$TEMP_DIR/npm" "$TEMP_DIR/pypi" "$TEMP_DIR/go" + +# +# npm packages +# +echo -e "${YELLOW}Testing npm packages...${NC}" + +npm_packages=( + "axios@0.21.1:has vulnerabilities (SSRF, ReDoS)" + "lodash@4.17.15:has vulnerabilities (prototype pollution)" + "express@4.17.1:has vulnerabilities (open redirect)" + "react@18.2.0:clean package" +) + +for pkg_info in "${npm_packages[@]}"; do + IFS=':' read -r pkg desc <<< "$pkg_info" + IFS='@' read -r pkg_name pkg_version <<< "$pkg" + echo -n " β€’ $pkg ($desc)... " + + # Download tarball directly to ensure it goes through proxy + # npm/pnpm may use local cache and bypass the proxy + tarball_filename="${pkg_name##*/}-${pkg_version}.tgz" + tarball_url="$GOHOARDER_URL/npm/$pkg_name/-/$tarball_filename" + + if curl -f -s "$tarball_url" -o "$TEMP_DIR/npm/$tarball_filename" > /dev/null 2>&1; then + echo -e "${GREEN}βœ“${NC}" + else + echo -e "${RED}βœ—${NC}" + fi +done + +echo "" + +# +# PyPI packages +# +echo -e "${YELLOW}Testing PyPI packages...${NC}" + +pypi_packages=( + "requests==2.25.0:older version, may have vulnerabilities" + "django==2.2.0:old version with known security issues" + "flask==0.12.0:old version with XSS vulnerabilities" + "certifi==2023.7.22:clean package" +) + +for pkg_info in "${pypi_packages[@]}"; do + IFS=':' read -r pkg desc <<< "$pkg_info" + echo -n " β€’ $pkg ($desc)... " + if pip install --index-url "$GOHOARDER_URL/pypi/simple/" \ + --trusted-host localhost \ + "$pkg" \ + --target "$TEMP_DIR/pypi" \ + --quiet > /dev/null 2>&1; then + echo -e "${GREEN}βœ“${NC}" + else + echo -e "${RED}βœ—${NC}" + fi +done + +echo "" + +# +# Go packages +# +echo -e "${YELLOW}Testing Go packages...${NC}" + +cd "$TEMP_DIR/go" +go mod init test > /dev/null 2>&1 + +go_packages=( + "github.com/gin-gonic/gin@v1.7.0:may have vulnerabilities" + "github.com/dgrijalva/jwt-go@v3.2.0:known JWT signing vulnerabilities" + "golang.org/x/crypto@v0.0.0-20200622213623-75b288015ac9:old version" + "github.com/google/uuid@v1.6.0:clean package" +) + +for pkg_info in "${go_packages[@]}"; do + IFS=':' read -r pkg desc <<< "$pkg_info" + echo -n " β€’ $pkg ($desc)... " + # Removed ",direct" fallback to enforce security scanning + # Packages will fail if blocked (same behavior as pip/npm/pnpm/yarn) + if GOPROXY="$GOHOARDER_URL/go" go get "$pkg" > /dev/null 2>&1; then + echo -e "${GREEN}βœ“${NC}" + else + echo -e "${RED}βœ—${NC}" + fi +done + +echo "" +echo "=========================================" +echo -e "${GREEN}Test package downloads complete!${NC}" +echo "" +echo "Next steps:" +echo " β€’ Visit $GOHOARDER_URL to view packages" +echo " β€’ Check vulnerability scan results" +echo " β€’ Compare clean vs vulnerable packages" +echo "=========================================" diff --git a/semver.yaml b/semver.yaml new file mode 100644 index 0000000..e69de29