From 48b834a62adbd4a1417b3a2b5fbf9848574114a5 Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Fri, 2 Jan 2026 23:14:23 +0000 Subject: [PATCH] Initial commit --- .github/workflows/autoupdate.yaml | 19 + .github/workflows/pr.yaml | 16 + .github/workflows/release.yaml | 96 + .gitignore | 82 + .goreleaser.yaml | 205 + Dockerfile.frontend | 100 + Dockerfile.gateway | 197 + Dockerfile.scanner | 59 + Dockerfile.server | 44 + LICENSE | 21 + Makefile | 132 + README.md | 1261 ++++++ cmd/gohoarder/commands/serve.go | 62 + cmd/gohoarder/commands/version.go | 42 + cmd/gohoarder/main.go | 41 + config.yaml.example | 184 + deployments/kubernetes/README.md | 416 ++ deployments/kubernetes/configmap-config.yaml | 54 + .../kubernetes/deployment-all-in-one.yaml | 502 +++ deployments/kubernetes/deployment.yaml | 104 + deployments/kubernetes/pvc.yaml | 29 + .../kubernetes/secret-git-credentials.yaml | 61 + deployments/kubernetes/service.yaml | 44 + docker-compose.example.yaml | 151 + frontend/.env.example | 7 + frontend/components.json | 21 + frontend/index.html | 16 + frontend/package.json | 43 + frontend/pnpm-lock.yaml | 3795 +++++++++++++++++ frontend/postcss.config.js | 6 + frontend/src/App.vue | 61 + .../src/components/BypassManagementPanel.vue | 609 +++ frontend/src/components/Dashboard.spec.ts | 208 + frontend/src/components/Dashboard.vue | 305 ++ frontend/src/components/PackageDetails.vue | 416 ++ frontend/src/components/PackageList.spec.ts | 187 + frontend/src/components/PackageList.vue | 418 ++ frontend/src/components/Stats.spec.ts | 192 + frontend/src/components/Stats.vue | 194 + .../src/components/VulnerabilityBadge.vue | 129 + .../src/components/VulnerablePackages.vue | 307 ++ .../src/components/ui/accordion/Accordion.vue | 18 + .../ui/accordion/AccordionContent.vue | 22 + .../components/ui/accordion/AccordionItem.vue | 22 + .../ui/accordion/AccordionTrigger.vue | 36 + frontend/src/components/ui/accordion/index.ts | 4 + frontend/src/components/ui/alert/Alert.vue | 17 + .../components/ui/alert/AlertDescription.vue | 14 + .../src/components/ui/alert/AlertTitle.vue | 14 + frontend/src/components/ui/alert/index.ts | 24 + frontend/src/components/ui/badge/Badge.vue | 17 + frontend/src/components/ui/badge/index.ts | 26 + frontend/src/components/ui/button/Button.vue | 28 + frontend/src/components/ui/button/index.ts | 38 + frontend/src/components/ui/card/Card.vue | 21 + .../src/components/ui/card/CardContent.vue | 14 + .../components/ui/card/CardDescription.vue | 14 + .../src/components/ui/card/CardFooter.vue | 14 + .../src/components/ui/card/CardHeader.vue | 14 + frontend/src/components/ui/card/CardTitle.vue | 18 + frontend/src/components/ui/card/index.ts | 6 + frontend/src/components/ui/dialog/Dialog.vue | 15 + .../src/components/ui/dialog/DialogClose.vue | 12 + .../components/ui/dialog/DialogContent.vue | 46 + .../ui/dialog/DialogDescription.vue | 22 + .../src/components/ui/dialog/DialogFooter.vue | 19 + .../src/components/ui/dialog/DialogHeader.vue | 16 + .../ui/dialog/DialogScrollContent.vue | 55 + .../src/components/ui/dialog/DialogTitle.vue | 27 + .../components/ui/dialog/DialogTrigger.vue | 12 + frontend/src/components/ui/dialog/index.ts | 9 + frontend/src/components/ui/input/Input.vue | 24 + frontend/src/components/ui/input/index.ts | 1 + .../src/components/ui/separator/Separator.vue | 29 + frontend/src/components/ui/separator/index.ts | 1 + .../src/components/ui/skeleton/Skeleton.vue | 14 + frontend/src/components/ui/skeleton/index.ts | 1 + frontend/src/components/ui/table/Table.vue | 15 + .../src/components/ui/table/TableBody.vue | 13 + .../src/components/ui/table/TableCell.vue | 13 + .../src/components/ui/table/TableHead.vue | 13 + .../src/components/ui/table/TableHeader.vue | 13 + frontend/src/components/ui/table/TableRow.vue | 13 + frontend/src/components/ui/table/index.ts | 6 + frontend/src/composables/useBadgeStyles.ts | 59 + frontend/src/lib/utils.ts | 7 + frontend/src/main.ts | 11 + frontend/src/router/index.ts | 48 + frontend/src/stores/packages.ts | 115 + frontend/src/styles/main.css | 83 + frontend/src/test/setup.ts | 11 + frontend/tailwind.config.js | 91 + frontend/tsconfig.json | 25 + frontend/tsconfig.node.json | 10 + frontend/vite.config.ts | 34 + frontend/vitest.config.ts | 17 + go.mod | 85 + go.sum | 223 + helm/gohoarder/.helmignore | 31 + helm/gohoarder/Chart.yaml | 22 + helm/gohoarder/LICENSE | 21 + helm/gohoarder/README.md | 499 +++ helm/gohoarder/templates/NOTES.txt | 70 + helm/gohoarder/templates/_helpers.tpl | 174 + helm/gohoarder/templates/configmap.yaml | 168 + .../templates/deployment-frontend.yaml | 85 + .../templates/deployment-scanner.yaml | 114 + .../templates/deployment-server.yaml | 194 + helm/gohoarder/templates/imagepullsecret.yaml | 14 + helm/gohoarder/templates/ingress.yaml | 118 + helm/gohoarder/templates/pvc.yaml | 37 + helm/gohoarder/templates/secret.yaml | 66 + helm/gohoarder/templates/service.yaml | 39 + helm/gohoarder/templates/serviceaccount.yaml | 12 + helm/gohoarder/values.yaml | 475 +++ internal/version/version.go | 34 + pkg/analytics/analytics.go | 437 ++ pkg/app/app.go | 435 ++ pkg/app/handlers.go | 512 +++ pkg/app/handlers.go.bak | 413 ++ pkg/app/handlers.go.bak2 | 415 ++ pkg/app/handlers_admin.go | 323 ++ pkg/app/handlers_vulnerabilities.go | 156 + pkg/auth/auth.go | 193 + pkg/auth/extractor.go | 68 + pkg/auth/hasher.go | 38 + pkg/auth/validation_cache.go | 109 + pkg/auth/validator.go | 284 ++ pkg/cache/cache.go | 572 +++ pkg/cache/cache_test.go | 980 +++++ pkg/cdn/cdn.go | 360 ++ pkg/config/config.go | 453 ++ pkg/config/config_test.go | 383 ++ pkg/config/loader.go | 62 + pkg/errors/codes.go | 68 + pkg/errors/errors.go | 115 + pkg/errors/errors_test.go | 305 ++ pkg/errors/response.go | 90 + pkg/health/health.go | 178 + pkg/lock/redis.go | 275 ++ pkg/logger/logger.go | 57 + pkg/logger/middleware.go | 65 + pkg/metadata/file/file.go | 546 +++ pkg/metadata/interface.go | 211 + pkg/metadata/sqlite/sqlite.go | 1089 +++++ pkg/metrics/metrics.go | 188 + pkg/network/client.go | 360 ++ pkg/network/client_test.go | 407 ++ pkg/prewarming/worker.go | 311 ++ pkg/proxy/common/base.go | 34 + pkg/proxy/common/common_test.go | 385 ++ pkg/proxy/common/errors.go | 48 + pkg/proxy/common/http.go | 58 + pkg/proxy/common/interface.go | 29 + pkg/proxy/goproxy/goproxy.go | 485 +++ pkg/proxy/npm/npm.go | 377 ++ pkg/proxy/pypi/pypi.go | 398 ++ pkg/scanner/ghsa/ghsa.go | 283 ++ pkg/scanner/govulncheck/govulncheck.go | 194 + pkg/scanner/grype/grype.go | 193 + pkg/scanner/npmaudit/npmaudit.go | 234 + pkg/scanner/osv/osv.go | 329 ++ pkg/scanner/pipaudit/pipaudit.go | 209 + pkg/scanner/rescanner.go | 219 + pkg/scanner/scanner.go | 515 +++ pkg/scanner/trivy/trivy.go | 243 ++ pkg/server/server.go | 130 + pkg/storage/filesystem/filesystem.go | 415 ++ pkg/storage/filesystem/filesystem_test.go | 757 ++++ pkg/storage/interface.go | 91 + pkg/storage/s3/s3.go | 443 ++ pkg/storage/smb/smb.go | 579 +++ pkg/uuid/uuid.go | 30 + pkg/uuid/uuid_test.go | 217 + pkg/vcs/credentials.go | 247 ++ pkg/vcs/git.go | 280 ++ pkg/vcs/module.go | 252 ++ pkg/websocket/server.go | 388 ++ script/generate-version.sh | 55 + script/test-packages.sh | 155 + semver.yaml | 0 181 files changed, 33328 insertions(+) create mode 100644 .github/workflows/autoupdate.yaml create mode 100644 .github/workflows/pr.yaml create mode 100644 .github/workflows/release.yaml create mode 100644 .gitignore create mode 100644 .goreleaser.yaml create mode 100644 Dockerfile.frontend create mode 100644 Dockerfile.gateway create mode 100644 Dockerfile.scanner create mode 100644 Dockerfile.server create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 cmd/gohoarder/commands/serve.go create mode 100644 cmd/gohoarder/commands/version.go create mode 100644 cmd/gohoarder/main.go create mode 100644 config.yaml.example create mode 100644 deployments/kubernetes/README.md create mode 100644 deployments/kubernetes/configmap-config.yaml create mode 100644 deployments/kubernetes/deployment-all-in-one.yaml create mode 100644 deployments/kubernetes/deployment.yaml create mode 100644 deployments/kubernetes/pvc.yaml create mode 100644 deployments/kubernetes/secret-git-credentials.yaml create mode 100644 deployments/kubernetes/service.yaml create mode 100644 docker-compose.example.yaml create mode 100644 frontend/.env.example create mode 100644 frontend/components.json create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/pnpm-lock.yaml create mode 100644 frontend/postcss.config.js create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/components/BypassManagementPanel.vue create mode 100644 frontend/src/components/Dashboard.spec.ts create mode 100644 frontend/src/components/Dashboard.vue create mode 100644 frontend/src/components/PackageDetails.vue create mode 100644 frontend/src/components/PackageList.spec.ts create mode 100644 frontend/src/components/PackageList.vue create mode 100644 frontend/src/components/Stats.spec.ts create mode 100644 frontend/src/components/Stats.vue create mode 100644 frontend/src/components/VulnerabilityBadge.vue create mode 100644 frontend/src/components/VulnerablePackages.vue create mode 100644 frontend/src/components/ui/accordion/Accordion.vue create mode 100644 frontend/src/components/ui/accordion/AccordionContent.vue create mode 100644 frontend/src/components/ui/accordion/AccordionItem.vue create mode 100644 frontend/src/components/ui/accordion/AccordionTrigger.vue create mode 100644 frontend/src/components/ui/accordion/index.ts create mode 100644 frontend/src/components/ui/alert/Alert.vue create mode 100644 frontend/src/components/ui/alert/AlertDescription.vue create mode 100644 frontend/src/components/ui/alert/AlertTitle.vue create mode 100644 frontend/src/components/ui/alert/index.ts create mode 100644 frontend/src/components/ui/badge/Badge.vue create mode 100644 frontend/src/components/ui/badge/index.ts create mode 100644 frontend/src/components/ui/button/Button.vue create mode 100644 frontend/src/components/ui/button/index.ts create mode 100644 frontend/src/components/ui/card/Card.vue create mode 100644 frontend/src/components/ui/card/CardContent.vue create mode 100644 frontend/src/components/ui/card/CardDescription.vue create mode 100644 frontend/src/components/ui/card/CardFooter.vue create mode 100644 frontend/src/components/ui/card/CardHeader.vue create mode 100644 frontend/src/components/ui/card/CardTitle.vue create mode 100644 frontend/src/components/ui/card/index.ts create mode 100644 frontend/src/components/ui/dialog/Dialog.vue create mode 100644 frontend/src/components/ui/dialog/DialogClose.vue create mode 100644 frontend/src/components/ui/dialog/DialogContent.vue create mode 100644 frontend/src/components/ui/dialog/DialogDescription.vue create mode 100644 frontend/src/components/ui/dialog/DialogFooter.vue create mode 100644 frontend/src/components/ui/dialog/DialogHeader.vue create mode 100644 frontend/src/components/ui/dialog/DialogScrollContent.vue create mode 100644 frontend/src/components/ui/dialog/DialogTitle.vue create mode 100644 frontend/src/components/ui/dialog/DialogTrigger.vue create mode 100644 frontend/src/components/ui/dialog/index.ts create mode 100644 frontend/src/components/ui/input/Input.vue create mode 100644 frontend/src/components/ui/input/index.ts create mode 100644 frontend/src/components/ui/separator/Separator.vue create mode 100644 frontend/src/components/ui/separator/index.ts create mode 100644 frontend/src/components/ui/skeleton/Skeleton.vue create mode 100644 frontend/src/components/ui/skeleton/index.ts create mode 100644 frontend/src/components/ui/table/Table.vue create mode 100644 frontend/src/components/ui/table/TableBody.vue create mode 100644 frontend/src/components/ui/table/TableCell.vue create mode 100644 frontend/src/components/ui/table/TableHead.vue create mode 100644 frontend/src/components/ui/table/TableHeader.vue create mode 100644 frontend/src/components/ui/table/TableRow.vue create mode 100644 frontend/src/components/ui/table/index.ts create mode 100644 frontend/src/composables/useBadgeStyles.ts create mode 100644 frontend/src/lib/utils.ts create mode 100644 frontend/src/main.ts create mode 100644 frontend/src/router/index.ts create mode 100644 frontend/src/stores/packages.ts create mode 100644 frontend/src/styles/main.css create mode 100644 frontend/src/test/setup.ts create mode 100644 frontend/tailwind.config.js create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts create mode 100644 frontend/vitest.config.ts create mode 100644 go.mod create mode 100644 go.sum create mode 100644 helm/gohoarder/.helmignore create mode 100644 helm/gohoarder/Chart.yaml create mode 100644 helm/gohoarder/LICENSE create mode 100644 helm/gohoarder/README.md create mode 100644 helm/gohoarder/templates/NOTES.txt create mode 100644 helm/gohoarder/templates/_helpers.tpl create mode 100644 helm/gohoarder/templates/configmap.yaml create mode 100644 helm/gohoarder/templates/deployment-frontend.yaml create mode 100644 helm/gohoarder/templates/deployment-scanner.yaml create mode 100644 helm/gohoarder/templates/deployment-server.yaml create mode 100644 helm/gohoarder/templates/imagepullsecret.yaml create mode 100644 helm/gohoarder/templates/ingress.yaml create mode 100644 helm/gohoarder/templates/pvc.yaml create mode 100644 helm/gohoarder/templates/secret.yaml create mode 100644 helm/gohoarder/templates/service.yaml create mode 100644 helm/gohoarder/templates/serviceaccount.yaml create mode 100644 helm/gohoarder/values.yaml create mode 100644 internal/version/version.go create mode 100644 pkg/analytics/analytics.go create mode 100644 pkg/app/app.go create mode 100644 pkg/app/handlers.go create mode 100644 pkg/app/handlers.go.bak create mode 100644 pkg/app/handlers.go.bak2 create mode 100644 pkg/app/handlers_admin.go create mode 100644 pkg/app/handlers_vulnerabilities.go create mode 100644 pkg/auth/auth.go create mode 100644 pkg/auth/extractor.go create mode 100644 pkg/auth/hasher.go create mode 100644 pkg/auth/validation_cache.go create mode 100644 pkg/auth/validator.go create mode 100644 pkg/cache/cache.go create mode 100644 pkg/cache/cache_test.go create mode 100644 pkg/cdn/cdn.go create mode 100644 pkg/config/config.go create mode 100644 pkg/config/config_test.go create mode 100644 pkg/config/loader.go create mode 100644 pkg/errors/codes.go create mode 100644 pkg/errors/errors.go create mode 100644 pkg/errors/errors_test.go create mode 100644 pkg/errors/response.go create mode 100644 pkg/health/health.go create mode 100644 pkg/lock/redis.go create mode 100644 pkg/logger/logger.go create mode 100644 pkg/logger/middleware.go create mode 100644 pkg/metadata/file/file.go create mode 100644 pkg/metadata/interface.go create mode 100644 pkg/metadata/sqlite/sqlite.go create mode 100644 pkg/metrics/metrics.go create mode 100644 pkg/network/client.go create mode 100644 pkg/network/client_test.go create mode 100644 pkg/prewarming/worker.go create mode 100644 pkg/proxy/common/base.go create mode 100644 pkg/proxy/common/common_test.go create mode 100644 pkg/proxy/common/errors.go create mode 100644 pkg/proxy/common/http.go create mode 100644 pkg/proxy/common/interface.go create mode 100644 pkg/proxy/goproxy/goproxy.go create mode 100644 pkg/proxy/npm/npm.go create mode 100644 pkg/proxy/pypi/pypi.go create mode 100644 pkg/scanner/ghsa/ghsa.go create mode 100644 pkg/scanner/govulncheck/govulncheck.go create mode 100644 pkg/scanner/grype/grype.go create mode 100644 pkg/scanner/npmaudit/npmaudit.go create mode 100644 pkg/scanner/osv/osv.go create mode 100644 pkg/scanner/pipaudit/pipaudit.go create mode 100644 pkg/scanner/rescanner.go create mode 100644 pkg/scanner/scanner.go create mode 100644 pkg/scanner/trivy/trivy.go create mode 100644 pkg/server/server.go create mode 100644 pkg/storage/filesystem/filesystem.go create mode 100644 pkg/storage/filesystem/filesystem_test.go create mode 100644 pkg/storage/interface.go create mode 100644 pkg/storage/s3/s3.go create mode 100644 pkg/storage/smb/smb.go create mode 100644 pkg/uuid/uuid.go create mode 100644 pkg/uuid/uuid_test.go create mode 100644 pkg/vcs/credentials.go create mode 100644 pkg/vcs/git.go create mode 100644 pkg/vcs/module.go create mode 100644 pkg/websocket/server.go create mode 100755 script/generate-version.sh create mode 100755 script/test-packages.sh create mode 100644 semver.yaml 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