Compare commits

..
94 Commits
Author SHA1 Message Date
lukaszraczyloandGitHub f17f0b3310 Update go.mod and go.sum (#42) 2026-03-25 03:53:18 +00:00
lukaszraczyloandGitHub 140f19ed6f Update go.mod and go.sum (#41) 2026-03-24 03:53:06 +00:00
lukaszraczyloandGitHub 1e9ac2a9f2 Update go.mod and go.sum (#40) 2026-03-23 03:58:55 +00:00
lukaszraczyloandGitHub aeb67f7dd9 Update go.mod and go.sum (#39) 2026-03-22 03:54:22 +00:00
lukaszraczyloandGitHub afc0ece8d0 Update go.mod and go.sum (#38) 2026-03-21 03:48:43 +00:00
lukaszraczyloandGitHub 170d5a9f08 Update go.mod and go.sum (#37) 2026-03-19 03:56:15 +00:00
lukaszraczyloandGitHub 6e351f1a25 Update go.mod and go.sum (#35) 2026-03-17 03:53:16 +00:00
lukaszraczyloandGitHub d67cc0bf8e Update go.mod and go.sum (#34) 2026-03-14 03:51:56 +00:00
lukaszraczyloandGitHub 239b7e1f75 Update go.mod and go.sum (#33) 2026-03-13 03:51:41 +00:00
lukaszraczyloandGitHub 9f1d134d7d Update go.mod and go.sum (#32) 2026-03-12 03:52:18 +00:00
lukaszraczyloandGitHub 2dfad4f4e3 Update go.mod and go.sum (#31) 2026-03-09 03:52:49 +00:00
lukaszraczyloandGitHub defa86573e Update go.mod and go.sum (#30) 2026-03-08 03:51:16 +00:00
lukaszraczyloandGitHub 0880cc45f7 Update go.mod and go.sum (#29) 2026-03-07 03:45:21 +00:00
lukaszraczyloandGitHub 0c2dc0795a Update go.mod and go.sum (#28) 2026-03-06 03:51:06 +00:00
lukaszraczyloandGitHub af81f6c99b Update go.mod and go.sum (#27) 2026-03-04 03:51:01 +00:00
lukaszraczyloandGitHub a46af4b645 Update go.mod and go.sum (#26) 2026-03-01 03:56:38 +00:00
lukaszraczyloandGitHub fe3c0ebb63 Update go.mod and go.sum (#25) 2026-02-28 03:41:31 +00:00
lukaszraczyloandGitHub b3e15180af Update go.mod and go.sum (#24) 2026-02-27 03:51:56 +00:00
lukaszraczyloandGitHub 6735ed2071 Update go.mod and go.sum (#23) 2026-02-26 03:53:37 +00:00
lukaszraczyloandGitHub 2f379efc83 Update go.mod and go.sum (#22) 2026-02-25 03:53:35 +00:00
lukaszraczyloandGitHub 5f8e3b4dee Update go.mod and go.sum (#20) 2026-02-24 03:53:21 +00:00
lukaszraczyloandGitHub 0704a3b7c8 Update go.mod and go.sum (#19) 2026-02-21 03:49:36 +00:00
lukaszraczyloandGitHub b22129fcba Update go.mod and go.sum (#18) 2026-02-19 03:54:06 +00:00
lukaszraczyloandGitHub 210602566e Update go.mod and go.sum (#17) 2026-02-18 03:54:48 +00:00
lukaszraczyloandGitHub 1e39f9f2f4 Update go.mod and go.sum (#16) 2026-02-17 03:52:41 +00:00
lukaszraczyloandGitHub a98ee60d8b Update go.mod and go.sum (#15) 2026-02-11 04:01:31 +00:00
lukaszraczyloandGitHub 1293ca4f9d Update go.mod and go.sum (#13) 2026-02-10 04:03:22 +00:00
lukaszraczyloandGitHub 2e95518d48 Update go.mod and go.sum (#12) 2026-02-09 03:59:00 +00:00
lukaszraczyloandGitHub 0388d99d9e Update go.mod and go.sum (#11) 2026-02-02 03:56:15 +00:00
lukaszraczyloandGitHub 113564b497 Update go.mod and go.sum (#10) 2026-02-01 04:02:13 +00:00
lukaszraczyloandGitHub b78e6e79e3 Update go.mod and go.sum (#9) 2026-01-30 03:50:54 +00:00
lukaszraczyloandGitHub 3b26752e3c Update go.mod and go.sum (#8) 2026-01-29 03:51:11 +00:00
lukaszraczyloandGitHub ec3312d836 Update go.mod and go.sum (#7) 2026-01-25 03:40:38 +00:00
lukaszraczyloandGitHub f1913087e4 Update go.mod and go.sum (#6) 2026-01-19 03:40:28 +00:00
lukaszraczyloandGitHub eae3e33e7e Update go.mod and go.sum (#5) 2026-01-17 03:35:25 +00:00
lukaszraczyloandGitHub 2e8baad645 Update go.mod and go.sum (#4) 2026-01-13 03:37:10 +00:00
lukaszraczyloandGitHub e6f127cf9e Update go.mod and go.sum (#3) 2026-01-10 03:35:57 +00:00
lukaszraczyloandGitHub a1e16c989b Update go.mod and go.sum (#2) 2026-01-09 03:37:04 +00:00
lukaszraczyloandGitHub 955250650e Update go.mod and go.sum (#1) 2026-01-06 03:36:44 +00:00
lukaszraczylo 85c76150f5 fixup! fix: remove missing logo reference from Helm chart 2026-01-04 14:47:27 +00:00
lukaszraczylo 1baf0993de fix: remove missing logo reference from Helm chart
The referenced logo file (docs/logo.png) doesn't exist, causing
Artifact Hub to fail with 404 errors when indexing the chart.

Commented out the icon line until a logo is created.

Resolves: "error getting logo image https://raw.githubusercontent.com/
lukaszraczylo/gohoarder/main/docs/logo.png: unexpected status code
received: 404"
2026-01-04 14:25:57 +00:00
lukaszraczylo 434a15076e fix: generate continuous time series data with zero-filled gaps for charts
Issues fixed:
1. Charts not rendering correctly due to sparse data (missing time buckets)
2. Period "1h" returning empty data when aggregate stats not yet available

Changes:
- Generate continuous time series with 0 values for all time buckets
- Truncate start time to hour/day boundaries for consistent bucketing
- Fallback to package-level stats aggregation when registry totals unavailable
- Add proper time range filtering (since <= time_bucket <= now)

Behavior now:
- All time periods (1h, 1day, 7day, 30day) return complete data sets
- Missing hours/days are filled with value: 0
- Chart libraries can render continuous lines/bars correctly
- No more empty data for "1h" period

Example output (1 hour period):
Before: [{"timestamp":"14:00","value":5}, {"timestamp":"15:00","value":3}]
After:  [{"timestamp":"13:00","value":0}, {"timestamp":"14:00","value":5},
         {"timestamp":"15:00","value":3}, {"timestamp":"16:00","value":0}]

Resolves: "Chart doesn't generate correctly - it should automatically
fill 0 for the empty periods to render correctly"
2026-01-04 14:23:08 +00:00
lukaszraczylo 4e7350363d feat: track download counts for both cache hits and cache misses
Previously, download counts only incremented on cache hits (when package
was served from cache). First-time downloads (cache misses) were not counted.

Changes:
- Add UpdateDownloadCount() call when serving newly cached packages
- This ensures every download through the proxy increments the counter
- Analytics tracking also added for cache misses

Behavior now:
- First download (cache miss): counter = 1
- Second download (cache hit): counter = 2
- Third download (cache hit): counter = 3
- etc.

Updated all relevant tests to expect the additional UpdateDownloadCount call.

Resolves user requirement: "I want the counters to increase whenever
package is downloaded via proxy - regardless of it being new download
or cached download"
2026-01-04 13:50:42 +00:00
lukaszraczylo 38edd735b6 fix: improve download statistics tracking resilience
Problem:
- Download counts were not incrementing when packages existed in storage
  but not in the database (e.g., after database migration/reset)
- UpdateDownloadCount() was failing silently when package didn't exist in DB
- Error was completely ignored despite comment claiming "error logged"

Changes:
1. Log errors when UpdateDownloadCount() fails instead of silently ignoring
2. Auto-save package to database if UpdateDownloadCount() fails
3. Retry download count update after saving package
4. Provide detailed error logging for troubleshooting

This fixes the issue where:
- Database is migrated but storage still has cached packages
- Cache hits occur but download events aren't recorded
- Statistics show zero downloads despite actual usage

Resolves user report: "I cleaned go mod which redownloaded the modules
through the proxy but counters did not increased"
2026-01-04 13:44:36 +00:00
lukaszraczylo c207aa72e9 fix: accept GitHub API rate limits in GHSA health check
- Rate limits (403) are now accepted as healthy
- Rate limiting is expected without a GitHub token
- Only real errors (network failures, 500s) fail the health check
- Prevents health check failures due to unauthenticated API usage

Related: GHSA scanner health checks
2026-01-04 13:33:49 +00:00
lukaszraczylo bf0925a4fc fix: return degraded status for scanner health check failures
- Scanner failures (e.g., GitHub API rate limits) no longer mark server as unhealthy
- Server can still serve cached packages when scanners are unavailable
- Readiness probes will now pass with degraded scanner status
- Prevents unnecessary pod restarts due to external API issues

Fixes: Readiness probes failing with 503 due to GHSA rate limiting
2026-01-04 13:33:10 +00:00
lukaszraczylo bc854aa183 perf: remove nginx rate limiting for internal package proxy
- Removed rate limiting zones (10r/s for API, 5r/s for downloads)
- Internal package proxy doesn't need rate limiting
- During builds, package managers make hundreds of concurrent requests
- Rate limiting was causing unnecessary delays and potential 429 errors

This improves build performance significantly for CI/CD pipelines
2026-01-04 13:09:18 +00:00
lukaszraczylo c4bb2f6e3a fix: clean Go module paths before calling OSV API
- OSV API expects clean module paths like 'gorm.io/driver/sqlite'
- Cache keys include Go proxy format like 'gorm.io/driver/sqlite/@v/v1.6.0.zip'
- Added cleanGoModuleName() to extract module path and version
- Strips /@v/ prefix and .zip/.mod/.info suffixes
- Fixes 404 errors when scanning Go modules

Resolves: OSV API returning 404 for Go module scans
2026-01-04 13:07:31 +00:00
lukaszraczylo 8848656193 fix: preserve /api prefix in nginx reverse proxy
- Changed proxy_pass from http://backend/ to http://backend
- Trailing slash was stripping /api prefix causing 404 errors
- Backend expects /api/stats not /stats
2026-01-04 03:55:40 +00:00
lukaszraczylo 3ecff61114 fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! perf: build frontend once on runner instead of in Docker 2026-01-04 03:18:49 +00:00
lukaszraczylo f86943b884 fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! perf: build frontend once on runner instead of in Docker 2026-01-04 02:48:28 +00:00
lukaszraczylo 448bb70ac8 fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! perf: build frontend once on runner instead of in Docker 2026-01-04 02:17:11 +00:00
lukaszraczylo d63ae21133 fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! perf: build frontend once on runner instead of in Docker 2026-01-04 02:01:24 +00:00
lukaszraczylo f71414bbb1 fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! perf: build frontend once on runner instead of in Docker 2026-01-04 01:37:57 +00:00
lukaszraczylo bdcacfc8db fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! perf: build frontend once on runner instead of in Docker 2026-01-04 01:32:12 +00:00
lukaszraczylo a36080c6de fixup! fixup! fixup! fixup! fixup! fixup! fixup! perf: build frontend once on runner instead of in Docker 2026-01-04 01:24:14 +00:00
lukaszraczylo 0e6510636d fixup! fixup! fixup! fixup! fixup! fixup! perf: build frontend once on runner instead of in Docker 2026-01-04 01:20:27 +00:00
lukaszraczylo 276d406fb6 fixup! fixup! fixup! fixup! fixup! perf: build frontend once on runner instead of in Docker 2026-01-04 01:16:13 +00:00
lukaszraczylo fb79d82072 fixup! fixup! fixup! fixup! perf: build frontend once on runner instead of in Docker 2026-01-04 01:13:41 +00:00
lukaszraczylo bbcbeeaf88 fixup! fixup! fixup! perf: build frontend once on runner instead of in Docker 2026-01-04 01:11:26 +00:00
lukaszraczylo 9f4d8fc777 fixup! fixup! perf: build frontend once on runner instead of in Docker 2026-01-04 01:08:55 +00:00
lukaszraczylo 1f781f6620 fixup! perf: build frontend once on runner instead of in Docker 2026-01-04 01:01:53 +00:00
lukaszraczylo 8a9d786b1a perf: build frontend once on runner instead of in Docker
- [x] Remove Docker compilation from server, scanner, migrate Dockerfiles
- [x] Use pre-built binaries injected by GoReleaser instead
- [x] Delete separate gateway container and merge into frontend
- [x] Update .goreleaser.yaml to reference pre-built binaries
- [x] Simplify Kubernetes deployment to remove gateway service
- [x] Simplify docker-compose to remove gateway container
- [x] Add backend proxy configuration to frontend
2026-01-04 00:53:44 +00:00
lukaszraczylo e1a02a6d69 perf: build frontend once on runner instead of in Docker
MAJOR PERFORMANCE IMPROVEMENT: Reduced frontend Docker build time
from ~20 minutes to ~30 seconds by building the SPA once on the
runner instead of twice (amd64 + arm64) in Docker.

Changes:
1. Release workflow now builds frontend on CI runner (native, fast)
2. Frontend artifact uploaded and downloaded in release job
3. Dockerfile.frontend simplified to just copy pre-built files
4. Multi-arch Docker build is now just copying files into nginx

Before:
- Docker builds frontend 2x (amd64 + arm64 with QEMU emulation)
- Each: pnpm install + pnpm build = ~10 min per arch
- Total: ~20 minutes for frontend image

After:
- Build frontend 1x on runner = ~2 min (native)
- Docker just copies files = ~30 sec (both architectures)
- Total: ~2.5 minutes for frontend image

Impact:
- 8x faster frontend builds
- Total release time reduced from ~25 min to ~7 min
- Lower resource usage (no QEMU emulation)

Files changed:
- .github/workflows/release.yaml: Enable node build
- Dockerfile.frontend: Remove build stage, expect pre-built files
- .goreleaser.yaml: Copy frontend/dist instead of full source
2026-01-04 00:33:47 +00:00
lukaszraczylo 5c8565367c refactor: merge gateway functionality into frontend container
Eliminated duplicate nginx containers by merging gateway reverse proxy
functionality into the frontend container. This simplifies deployment
and reduces resource usage.

Architecture changes:
- Frontend now serves both static files AND reverse proxies to backend
- Single nginx container handles all HTTP routing
- Gateway container removed from builds and Helm chart

Dockerfile.frontend changes:
- Added upstream backend configuration
- Added proxy locations for /api, /health, /metrics, /npm, /pypi, /go, /ws
- Added rate limiting for API and downloads
- Added WebSocket support
- Configurable via BACKEND_HOST and BACKEND_PORT env vars

Helm chart changes:
- Updated frontend deployment to configure backend connection
- Simplified ingress to single route (all traffic → frontend)
- Frontend proxies backend requests internally
- Removed separate frontend/api ingress configurations

GoReleaser changes:
- Removed gohoarder-gateway Docker build
- Now builds: server, scanner, migrate, frontend (4 images)

Benefits:
- Fewer containers to manage
- Reduced complexity in Docker Compose and Kubernetes
- Single point of configuration for routing
- Better resource utilization
2026-01-04 00:30:20 +00:00
lukaszraczylo 9db929ed8b fix: remove hardcoded platform args causing wrong architecture builds
CRITICAL FIX: arm64 images were getting amd64 binaries causing
"exec format error" when running on ARM platforms.

Root cause:
- Dockerfiles had ARG TARGETOS=linux and ARG TARGETARCH=amd64
- FROM --platform=$TARGETOS/$TARGETARCH used these defaults
- buildx couldn't override them, so all builds used amd64

Solution:
- Removed --platform from FROM statement
- Let buildx automatically handle platform selection
- Declared ARGs AFTER FROM to receive buildx values
- buildx automatically sets TARGETPLATFORM, TARGETOS, TARGETARCH

Now each platform builds native binaries:
- linux/amd64 → amd64 binary
- linux/arm64 → arm64 binary

This fixes: exec /usr/local/bin/migrate: exec format error
2026-01-04 00:05:08 +00:00
lukaszraczylo 0a2c5f6c2c fix: update Dockerfiles to use Go 1.25 to match go.mod requirement
go.mod requires go >= 1.25.5 but Dockerfiles were using golang:1.23-alpine.
Updated all Go-based Dockerfiles to use golang:1.25-alpine.

Affected files:
- Dockerfile.server
- Dockerfile.scanner
- Dockerfile.migrate

This fixes the build error:
"go: go.mod requires go >= 1.25.5 (running go 1.23.12; GOTOOLCHAIN=local)"
2026-01-03 23:50:55 +00:00
lukaszraczylo ae632a3dd7 fix: add extra_files to copy source directories into Docker build context
GoReleaser creates a temporary build context with only binaries, but our
Dockerfiles are multi-stage builds that need the full source code to
compile. Added extra_files to copy necessary directories.

Files copied per image:
- gohoarder-server: go.mod, go.sum, cmd, pkg, internal, config
- gohoarder-scanner: go.mod, go.sum, cmd, pkg, internal, config
- gohoarder-migrate: go.mod, go.sum, cmd, pkg, internal, migrations
- gohoarder-frontend: frontend/ directory (Node.js source)
- gohoarder-gateway: no extra files needed (static config)

This fixes the build context error:
"Seems like you tried to copy a file that is not available in the
build context."
2026-01-03 23:41:13 +00:00
lukaszraczylo c212077478 refactor: rebuild GoReleaser configuration with dockers_v2
Completely rebuilt GoReleaser Pro configuration using modern dockers_v2
syntax to fix Docker build issues. All Dockerfiles are self-contained
multi-stage builds that compile their own binaries.

Key changes:
- Migrated from deprecated 'dockers' to 'dockers_v2'
- Removed 'use: buildx' (implicit in dockers_v2)
- Changed 'image_templates' to 'images' + 'tags' structure
- Replaced 'build_flag_templates' with 'build_args' + 'flags'
- Removed incorrect 'extra_files' that caused build context errors
- Added 'platforms' for multi-arch support (linux/amd64, linux/arm64)

Binary builds:
- gohoarder: darwin/arm64, linux/amd64 (CGO enabled)
- migrate: darwin/arm64, linux/amd64 (CGO enabled)
- linux/arm64 binaries skipped (Docker provides multi-arch)

Docker images (all self-building):
- gohoarder-server (Go multi-stage build)
- gohoarder-scanner (Go multi-stage build)
- gohoarder-migrate (Go multi-stage build)
- gohoarder-frontend (Node.js multi-stage build)
- gohoarder-gateway (Nginx static config)
2026-01-03 23:34:10 +00:00
lukaszraczylo af5e08a864 fix: disable SBOM attestations for Docker builds
Docker SBOM attestations require docker-container driver which is not
available in the default Docker driver used by GoReleaser. Disabled
SBOM generation for all Docker images to prevent build failures.

Error fixed:
- "Attestation is not supported for the docker driver"

Applied to all Docker images:
- gohoarder-server
- gohoarder-frontend
- gohoarder-scanner
- gohoarder-gateway
- gohoarder-migrate
2026-01-03 23:14:27 +00:00
lukaszraczylo adc7388ee1 fix: revert to using shared workflow (now fixed upstream)
Removed local workflow copy and reverted to using the shared workflow
from lukaszraczylo/shared-actions@main, which now has the proper
if condition to prevent the release job from being skipped.
2026-01-03 23:00:08 +00:00
lukaszraczylo 116ba9b006 fix: add missing condition to release job to prevent skipping
Problem:
- Release job (merge phase) was being skipped even though all builds succeeded
- The shared workflow's release job has `needs: [version, build]` but no `if` condition
- Without `if: always()`, GitHub Actions skips the job if dependencies have non-success status
- Frontend job being skipped caused downstream effects

Solution:
- Created local copy of go-release-cgo.yaml workflow
- Added explicit condition to release job:
  if: |
    always() &&
    needs.version.result == 'success' &&
    needs.build.result == 'success'
- Updated release.yaml to use local workflow instead of remote

This ensures the release/merge phase runs as long as version and build jobs succeed,
regardless of optional jobs like frontend being skipped.

Note: This is a workaround until the fix is merged upstream in shared-actions repo.
2026-01-03 22:57:21 +00:00
lukaszraczylo 03860dcb49 fix: skip linux/arm64 binary builds to avoid CGO cross-compilation
Problem:
- linux_amd64 runner was trying to build BOTH:
  - linux/amd64 (native - OK)
  - linux/arm64 (cross-compile with CGO - FAILS)
- Error: gcc_arm64.S assembler errors when cross-compiling ARM64 on x86_64
- Workflow default platforms only include linux/amd64, not linux/arm64

Solution:
- Added linux/arm64 to ignore list in both builds
- Only build linux/amd64 binaries (native compilation on ubuntu-latest)
- Docker images still provide linux/arm64 via multi-stage builds
- Users get ARM64 support through Docker, not standalone binaries

Build matrix now:
-  darwin/arm64 (macOS Apple Silicon) - native on macos-latest
-  linux/amd64 (Linux x86_64) - native on ubuntu-latest
-  linux/arm64 (skipped for binaries, available in Docker)

This eliminates CGO cross-compilation while maintaining full platform support
via Docker multi-arch images.
2026-01-03 22:47:56 +00:00
lukaszraczylo a1ec05b210 fix: add workflow-prepare.sh for CGO SQLite dependencies
Problem:
- CGO builds failing in CI with "cannot find sqlite3.h"
- go-release-cgo.yaml workflow looks for workflow-prepare.sh to install deps
- Script was missing, causing build failures

Solution:
- Created workflow-prepare.sh to install SQLite development headers
- Platform-specific installation:
  - Linux (Ubuntu/Debian): libsqlite3-dev via apt-get
  - Linux (RHEL/CentOS): sqlite-devel via yum
  - Linux (Alpine): sqlite-dev via apk
  - macOS: sqlite3 via Homebrew (if needed)
  - Windows: Downloads SQLite amalgamation, sets CGO_CFLAGS/CGO_LDFLAGS
- Includes verification step to confirm SQLite availability

This script is automatically called by the shared GitHub Actions workflow
before running GoReleaser builds with CGO_ENABLED=1.
2026-01-03 22:41:00 +00:00
lukaszraczylo dc1d507a20 fix: enable binary builds for proper GitHub releases and Helm charts
Problem:
- With builds: skip: true, no artifacts were created
- GoReleaser wasn't creating GitHub releases or tags
- Helm chart workflow wasn't triggered (depends on tags)
- No downloadable binaries for users

Solution:
- Enabled builds for both gohoarder and migrate binaries
- CGO_ENABLED=1 for SQLite support
- Added fts5 tag for full-text search
- Builds run natively per platform in split/merge workflow:
  - darwin/arm64 (Apple Silicon Macs)
  - linux/amd64 (x86_64 Linux)
  - linux/arm64 (ARM64 Linux)
- Ignored darwin/amd64 (Intel Macs) to limit build matrix

How it works:
1. Split phase: Each platform builds natively (no cross-compilation)
2. Merge phase: Combines all artifacts, creates release, builds Docker images
3. Docker images still use multi-stage builds (independent of binaries)
4. GitHub release created with tags
5. Helm chart workflow triggered

Benefits:
- Downloadable binaries for all platforms
- Archives created automatically
- GitHub releases with proper tags
- Helm charts published
- Docker images built separately with multi-stage builds
2026-01-03 22:36:09 +00:00
lukaszraczylo 72f284f987 fix: correct GoReleaser Pro configuration for CGO and Docker builds
Problem:
- Used incorrect field names (use: buildx, build_flag_templates) not supported in GoReleaser v2.13.2
- GitHub Actions workflow using non-CGO release workflow
- Docker builds failing due to invalid configuration

Solution:
- Updated dockers_v2 configuration with correct field names:
  - Removed unsupported `use: buildx` field
  - Changed `build_flag_templates` to `build_args` (map format)
  - Kept `platforms` for multi-arch support (linux/amd64, linux/arm64)
- Updated GitHub Actions workflow to use go-release-cgo.yaml for CGO support
- Build args now passed correctly to Docker builds for version info

Changes:
- .goreleaser.yaml: Fixed all Docker image configurations
- .github/workflows/release.yaml: Changed to go-release-cgo.yaml workflow

Validation:
- goreleaser check: PASSED ✓
- Configuration validated with GoReleaser Pro v2.13.2

References:
- GoReleaser dockers_v2 docs: https://goreleaser.com/customization/dockers_v2/
2026-01-03 22:12:31 +00:00
lukaszraczylo ef11972274 Revert "fix: use free GoReleaser syntax for Docker builds"
This reverts commit 96f9f4a36c.
2026-01-03 22:09:06 +00:00
lukaszraczylo 96f9f4a36c fix: use free GoReleaser syntax for Docker builds
Problem:
- GoReleaser Pro features (use: buildx, build_flag_templates) not available in free version
- CI/CD failing with "field not found in type config.DockerV2" errors

Solution:
- Split each Docker image into separate amd64 and arm64 builds
- Use goarch field to specify architecture
- Use build_flags instead of build_flag_templates
- Add docker_manifests section to combine arch-specific images into multi-arch manifests

Changes:
- Each service now has two Docker image definitions (amd64 and arm64)
- Images tagged with architecture suffix (e.g., v1.0.0-amd64, v1.0.0-arm64)
- Docker manifests combine them into unified tags (e.g., v1.0.0, latest)
- Users can pull multi-arch images normally, Docker will select correct arch

Result:
- Works with free GoReleaser version
- Maintains multi-architecture support
- Multi-stage Dockerfiles compile for each architecture natively
2026-01-03 21:59:28 +00:00
lukaszraczylo 311e4d13f6 fix: resolve CGO cross-compilation issues with multi-stage Docker builds
Problem:
- Enabling CGO_ENABLED=1 for SQLite support caused cross-compilation failures
- ARM64 assembly errors when building from amd64 host
- Cross-compilation with CGO requires architecture-specific toolchains

Solution:
- Converted all Dockerfiles to multi-stage builds
- Binaries now compile inside Docker using native platform builders
- Used --platform flag to build for target architecture natively
- Removed binary builds from .goreleaser.yaml (skip: true)
- Updated dockers_v2 to use buildx with multi-platform support

Changes:
- .goreleaser.yaml: Skip standalone builds, use Docker buildx
- Dockerfile.server: Multi-stage build with CGO
- Dockerfile.scanner: Multi-stage build with CGO
- Dockerfile.migrate: Multi-stage build with CGO

Benefits:
- No cross-compilation needed (each platform builds natively)
- Docker buildx handles multi-platform builds automatically
- SQLite support working with CGO enabled
- Cleaner separation between build and runtime environments
2026-01-03 21:55:01 +00:00
lukaszraczylo f936dfa359 Enable CGO for all GoHoarder binaries to support SQLite
Changes:
- Set CGO_ENABLED=1 for gohoarder main binary in .goreleaser.yaml
- Add sqlite-libs and musl to Dockerfile.server
- Add sqlite-libs and musl to Dockerfile.scanner

All Go binaries that interact with SQLite now have CGO enabled:
 gohoarder (main binary) - used by server and scanner
 migrate (migration tool)

Runtime containers include necessary C libraries:
 Dockerfile.server - SQLite runtime support
 Dockerfile.scanner - SQLite runtime support
 Dockerfile.migrate - SQLite runtime support

This fixes: 'Binary was compiled with CGO_ENABLED=0, go-sqlite3 requires cgo'
2026-01-03 21:41:59 +00:00
lukaszraczylo c1103630f0 Enable CGO for migrate binary to support SQLite
Changes:
- Set CGO_ENABLED=1 for migrate build in .goreleaser.yaml
- Add sqlite-libs and musl runtime dependencies to Dockerfile.migrate

This fixes the migration error: 'Binary was compiled with CGO_ENABLED=0,
go-sqlite3 requires cgo to work'
2026-01-03 21:38:34 +00:00
lukaszraczylo 64f6f5cda4 Fix test files to include new Stats fields
Add max_cache_size and blocked_packages fields to all Stats mock objects in:
- Dashboard.spec.ts
- Stats.spec.ts

This fixes TypeScript compilation errors in the build process.
2026-01-03 21:22:31 +00:00
lukaszraczylo 0be529f7be Add blocked packages counter and storage progress bar
Backend:
- Add blocked_packages count to stats API by checking vulnerabilities against thresholds
- Add max_cache_size to stats API from config
- Add isBlocked field to package API responses

Frontend:
- Add blocked_packages and max_cache_size to Stats interface
- Add blocked packages counter card to stats page with fa-hand icon
- Add storage usage progress bar with color coding (green/yellow/orange/red)
- Add /blocked-packages route that filters vulnerable packages by isBlocked
- Update VulnerabilityBadge to show BLOCKED badge with fa-hand icon
- Fix TypeScript imports for useRoute in VulnerablePackages

Features:
- Stats page now shows blocked packages count (clickable)
- Storage display shows usage vs max with visual progress bar
- Blocked packages view accessible from stats page
- All blocked indicators use fa-hand icon instead of fa-ban
2026-01-03 21:09:34 +00:00
lukaszraczylo c0061b99e3 chore(schema): migrate to GORM V2 with multi-database support
- [x] Implement GORM V2 metadata store with SQLite, PostgreSQL, and MySQL support
- [x] Add database migration system using gormigrate for schema versioning
- [x] Create migration CLI tool with support for migrate, rollback, and status commands
- [x] Add Docker support for migration container (Dockerfile.migrate)
- [x] Implement automatic partition management for PostgreSQL time-series tables
- [x] Add background aggregation worker for download statistics
- [x] Support connection pooling configuration (max_open_conns, max_idle_conns, conn_max_lifetime)
- [x] Add blocking mechanism based on vulnerability thresholds in stats and handlers
- [x] Update Helm charts with migration init containers and multi-database configuration
- [x] Replace deprecated SQLite store with optimized GORM implementation
- [x] Add comprehensive integration tests for MySQL and PostgreSQL
- [x] Update frontend to display blocked packages and storage utilization
- [x] Add goreleaser configuration for migrate binary and container image
- [x] Update configuration examples with database backend options and recommendations
2026-01-03 20:44:23 +00:00
lukaszraczylo b129279fb8 fixup! fixup! fixup! fixup! fixup! chore: move directory setup from Helm initContainers to Dockerfiles 2026-01-03 12:26:38 +00:00
lukaszraczylo bcc0185924 fixup! fixup! fixup! fixup! chore: move directory setup from Helm initContainers to Dockerfiles 2026-01-03 09:18:42 +00:00
lukaszraczylo 89bf86a6e1 fixup! fixup! fixup! chore: move directory setup from Helm initContainers to Dockerfiles 2026-01-03 09:06:17 +00:00
lukaszraczylo 6f2be85b9e fixup! fixup! chore: move directory setup from Helm initContainers to Dockerfiles 2026-01-03 08:57:31 +00:00
lukaszraczylo 72143ecd3d fixup! chore: move directory setup from Helm initContainers to Dockerfiles 2026-01-03 08:39:34 +00:00
lukaszraczylo e6fe925dcb chore: move directory setup from Helm initContainers to Dockerfiles
- [x] Standardize directory structure across scanner and server Dockerfiles
- [x] Use FHS-compliant paths (/var/cache, /var/lib, /tmp)
- [x] Add explicit permission management (chmod 750) in build stage
- [x] Remove initContainers from both Helm deployments
2026-01-03 01:15:09 +00:00
lukaszraczylo f03a288326 fixup! chore(helm): enhance security context and volume handling 2026-01-03 01:10:49 +00:00
lukaszraczylo 6afa55b5f5 chore(helm): enhance security context and volume handling
- [x] Add explicit security context with fsGroup and runAsUser to frontend deployment
- [x] Add initContainer to copy nginx static files and config to writable volumes
- [x] Add security context to initContainer with capability restrictions
- [x] Add runAsUser to frontend container security context
- [x] Add emptyDir volumes for nginx HTML and conf directories
- [x] Replace template includes with explicit volumeMounts in scanner deployment
- [x] Conditionally mount trivy cache volume in scanner deployment
- [x] Replace template includes with explicit volumeMounts in server deployment
2026-01-03 00:52:59 +00:00
lukaszraczylo 6b037a92b4 refactor: reorganize struct fields, add new handlers and storage backends
- [x] Reorder struct fields across codebase for consistency
- [x] Add analytics event handlers and tests
- [x] Add authentication API key management handlers and tests
- [x] Add pre-warming control handlers and tests
- [x] Implement S3 storage backend with tests
- [x] Implement SMB/CIFS storage backend with tests
- [x] Add CDN middleware tests
- [x] Integrate analytics tracking into cache manager
- [x] Add S3 and SMB storage initialization in app setup
- [x] Add CDN caching to proxy handlers
- [x] Remove distributed locking (Redis lock manager)
- [x] Remove proxy common package and utilities
- [x] Remove standalone HTTP server package
- [x] Remove logger middleware
- [x] Simplify error handling utilities
- [x] Update config with S3 and SMB options
- [x] Update cache manager signature to include analytics
2026-01-03 00:18:58 +00:00
lukaszraczylo 48b834a62a Initial commit 2026-01-02 23:14:23 +00:00
196 changed files with 38442 additions and 0 deletions
+19
View File
@@ -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
+16
View File
@@ -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"
+101
View File
@@ -0,0 +1,101 @@
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-cgo.yaml@main
with:
go-version: "1.25"
# Enable frontend build
node-enabled: true
node-version: "20"
node-build-script: "cd frontend && npm install -g pnpm && pnpm install --frozen-lockfile && pnpm run build"
node-output-path: "frontend/dist"
node-cache-dependency-path: "frontend/pnpm-lock.yaml"
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
+82
View File
@@ -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
+280
View File
@@ -0,0 +1,280 @@
version: 2
# Project metadata
project_name: gohoarder
# Pre-release hooks
before:
hooks:
- go mod tidy
# Download and setup Zig for cross-compilation (Linux only)
- bash -c 'if [ "$(uname -s)" = "Linux" ]; then wget https://ziglang.org/download/0.15.2/zig-x86_64-linux-0.15.2.tar.xz -O /tmp/zig.tar.xz && ls -lh /tmp/zig.tar.xz && tar -xvf /tmp/zig.tar.xz -C /tmp && echo "/tmp/zig-x86_64-linux-0.15.2" >> $GITHUB_PATH; fi'
# Build configuration
# All binaries built using Zig for consistent cross-compilation
# Zig handles CGO cross-compilation without platform-specific toolchains
# Binaries are injected into Docker images (no Docker compilation)
builds:
- id: gohoarder
main: ./cmd/gohoarder
binary: gohoarder
env:
- CGO_ENABLED=1
tags:
- fts5
flags:
- -trimpath
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}}
goos:
- linux
- darwin
goarch:
- amd64
- arm64
ignore:
- goos: darwin
goarch: amd64
overrides:
# Use Zig only for Linux cross-compilation
- goos: linux
goarch: amd64
env:
- CGO_ENABLED=1
- CC=/tmp/zig-x86_64-linux-0.15.2/zig cc -target x86_64-linux-musl
- CXX=/tmp/zig-x86_64-linux-0.15.2/zig c++ -target x86_64-linux-musl
- goos: linux
goarch: arm64
env:
- CGO_ENABLED=1
- CC=/tmp/zig-x86_64-linux-0.15.2/zig cc -target aarch64-linux-musl
- CXX=/tmp/zig-x86_64-linux-0.15.2/zig c++ -target aarch64-linux-musl
# darwin/arm64 builds natively on macOS runner (no Zig)
- id: migrate
main: ./cmd/migrate
binary: migrate
env:
- CGO_ENABLED=1
tags:
- fts5
flags:
- -trimpath
ldflags:
- -s -w
- -X main.Version={{.Version}}
- -X main.GitCommit={{.ShortCommit}}
- -X main.BuildTime={{.Date}}
goos:
- linux
- darwin
goarch:
- amd64
- arm64
ignore:
- goos: darwin
goarch: amd64
overrides:
# Use Zig only for Linux cross-compilation
- goos: linux
goarch: amd64
env:
- CGO_ENABLED=1
- CC=/tmp/zig-x86_64-linux-0.15.2/zig cc -target x86_64-linux-musl
- CXX=/tmp/zig-x86_64-linux-0.15.2/zig c++ -target x86_64-linux-musl
- goos: linux
goarch: arm64
env:
- CGO_ENABLED=1
- CC=/tmp/zig-x86_64-linux-0.15.2/zig cc -target aarch64-linux-musl
- CXX=/tmp/zig-x86_64-linux-0.15.2/zig c++ -target aarch64-linux-musl
# darwin/arm64 builds natively on macOS runner (no Zig)
# 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)
# Uses PRE-BUILT binaries from native builds (no Docker compilation - much faster!)
# GoReleaser injects the platform-specific binary into each Docker image automatically
# This avoids slow QEMU emulation for cross-architecture builds
dockers_v2:
# 1. Application Engine - Main GoHoarder server
# Uses pre-built binary from 'gohoarder' build (no Docker compilation)
# linux/arm64 binary is cross-compiled using Zig (fast, no QEMU!)
- id: gohoarder-server
ids:
- gohoarder
images:
- ghcr.io/lukaszraczylo/gohoarder-server
tags:
- "{{ .Version }}"
- latest
platforms:
- linux/amd64
- linux/arm64
dockerfile: Dockerfile.server
flags:
- "--pull"
- "--label=org.opencontainers.image.title=GoHoarder Server"
- "--label=org.opencontainers.image.description=Universal package cache proxy server"
- "--label=org.opencontainers.image.url=https://github.com/lukaszraczylo/gohoarder"
- "--label=org.opencontainers.image.source=https://github.com/lukaszraczylo/gohoarder"
- "--label=org.opencontainers.image.version={{ .Version }}"
- "--label=org.opencontainers.image.created={{ .Date }}"
- "--label=org.opencontainers.image.revision={{ .FullCommit }}"
extra_files:
- config.yaml.example
# 2. Website - Frontend Dashboard
# Note: Frontend is pre-built on CI runner and injected via frontend/dist
- id: gohoarder-frontend
images:
- ghcr.io/lukaszraczylo/gohoarder-frontend
tags:
- "{{ .Version }}"
- latest
platforms:
- linux/amd64
- linux/arm64
dockerfile: Dockerfile.frontend
flags:
- "--pull"
- "--label=org.opencontainers.image.title=GoHoarder Frontend"
- "--label=org.opencontainers.image.description=GoHoarder web dashboard"
- "--label=org.opencontainers.image.url=https://github.com/lukaszraczylo/gohoarder"
- "--label=org.opencontainers.image.source=https://github.com/lukaszraczylo/gohoarder"
- "--label=org.opencontainers.image.version={{ .Version }}"
- "--label=org.opencontainers.image.created={{ .Date }}"
- "--label=org.opencontainers.image.revision={{ .FullCommit }}"
extra_files:
- frontend/dist
# 3. Scanning Engine - Background scanner worker
# Uses pre-built binary from 'gohoarder' build (no Docker compilation)
# linux/arm64 binary is cross-compiled using Zig (fast, no QEMU!)
- id: gohoarder-scanner
ids:
- gohoarder
images:
- ghcr.io/lukaszraczylo/gohoarder-scanner
tags:
- "{{ .Version }}"
- latest
platforms:
- linux/amd64
- linux/arm64
dockerfile: Dockerfile.scanner
flags:
- "--pull"
- "--label=org.opencontainers.image.title=GoHoarder Scanner"
- "--label=org.opencontainers.image.description=GoHoarder vulnerability scanning engine"
- "--label=org.opencontainers.image.url=https://github.com/lukaszraczylo/gohoarder"
- "--label=org.opencontainers.image.source=https://github.com/lukaszraczylo/gohoarder"
- "--label=org.opencontainers.image.version={{ .Version }}"
- "--label=org.opencontainers.image.created={{ .Date }}"
- "--label=org.opencontainers.image.revision={{ .FullCommit }}"
extra_files:
- config.yaml.example
# 4. Migration Engine - Database migration tool
# Uses pre-built binary from 'migrate' build (no Docker compilation)
# linux/arm64 binary is cross-compiled using Zig (fast, no QEMU!)
- id: gohoarder-migrate
ids:
- migrate
images:
- ghcr.io/lukaszraczylo/gohoarder-migrate
tags:
- "{{ .Version }}"
- latest
platforms:
- linux/amd64
- linux/arm64
dockerfile: Dockerfile.migrate
flags:
- "--pull"
- "--label=org.opencontainers.image.title=GoHoarder Migrate"
- "--label=org.opencontainers.image.description=Database migration tool for GoHoarder V2 schema"
- "--label=org.opencontainers.image.url=https://github.com/lukaszraczylo/gohoarder"
- "--label=org.opencontainers.image.source=https://github.com/lukaszraczylo/gohoarder"
- "--label=org.opencontainers.image.version={{ .Version }}"
- "--label=org.opencontainers.image.created={{ .Date }}"
- "--label=org.opencontainers.image.revision={{ .FullCommit }}"
extra_files:
- migrations
# 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"
+195
View File
@@ -0,0 +1,195 @@
# Website - Frontend Dashboard with integrated reverse proxy
# Combines frontend serving and backend proxy (previously separate gateway)
# EXPECTS: Pre-built frontend files in frontend/dist/ directory
FROM nginx:alpine
# Install envsubst for runtime configuration
RUN apk add --no-cache gettext
# Copy pre-built frontend files
# These are built on the CI runner and injected via extra_files
COPY frontend/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 <<JSEOF
window.__RUNTIME_CONFIG__ = {
API_BASE_URL: "${API_BASE_URL:-/api}",
APP_VERSION: "${APP_VERSION:-unknown}",
APP_NAME: "${APP_NAME:-GoHoarder}"
};
JSEOF
# Substitute environment variables
envsubst < /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
# Create nginx templates directory and configuration template with backend proxy support
RUN mkdir -p /etc/nginx/templates && \
cat > /etc/nginx/templates/default.conf.template <<'EOF'
# Upstream backend server
upstream backend {
server ${BACKEND_HOST}:${BACKEND_PORT};
keepalive 32;
}
# 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};
root /usr/share/nginx/html;
index index.html;
# 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;
# Compression
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript;
# API endpoints - proxy to backend
location /api/ {
# 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;
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
location /metrics {
proxy_pass http://backend/metrics;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
# Package download endpoints
location ~ ^/(npm|pypi|go)/ {
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;
}
# WebSocket support
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;
}
# 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";
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Frontend SPA - serve index.html for all other routes
location / {
try_files $uri $uri/ /index.html;
}
}
EOF
# Create cache directory
RUN mkdir -p /var/cache/nginx/static && \
chown -R nginx:nginx /var/cache/nginx
# 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 \
BACKEND_HOST=gohoarder-server \
BACKEND_PORT=8080 \
SERVER_NAME=_
# Use nginx template substitution and start nginx
CMD ["/bin/sh", "-c", "envsubst '$$BACKEND_HOST $$BACKEND_PORT $$SERVER_NAME' < /etc/nginx/templates/default.conf.template > /etc/nginx/conf.d/default.conf && nginx -g 'daemon off;'"]
+37
View File
@@ -0,0 +1,37 @@
# Migration Engine - Database Migration Tool
# This Dockerfile expects a PRE-BUILT binary from GoReleaser (no compilation)
# GoReleaser injects the platform-specific binary automatically
FROM --platform=$TARGETPLATFORM alpine:latest
ARG TARGETARCH
# Install runtime dependencies (including CGO/SQLite dependencies)
RUN apk add --no-cache \
ca-certificates \
postgresql-client \
mysql-client \
busybox-extras \
sqlite-libs \
musl \
&& update-ca-certificates
# Create non-root user
RUN addgroup -g 1000 gohoarder && \
adduser -D -u 1000 -G gohoarder gohoarder
# Copy pre-built binary from GoReleaser
# GoReleaser will automatically inject the correct binary for the target platform
# In split/merge mode, binaries are in linux/${TARGETARCH}/ subdirectories
COPY linux/${TARGETARCH}/migrate /usr/local/bin/migrate
RUN chmod +x /usr/local/bin/migrate
# Copy migration SQL files
COPY migrations /migrations
WORKDIR /app
USER gohoarder
# Run migrations
ENTRYPOINT ["/usr/local/bin/migrate"]
CMD ["--action=migrate"]
+83
View File
@@ -0,0 +1,83 @@
# Scanning Engine - Background Scanner Worker
# This Dockerfile expects a PRE-BUILT binary from GoReleaser (no compilation)
# GoReleaser injects the platform-specific binary automatically
FROM --platform=$TARGETPLATFORM alpine:latest
ARG TARGETARCH
# Install scanning tools and runtime dependencies (including CGO/SQLite dependencies)
RUN apk add --no-cache \
ca-certificates \
tzdata \
git \
curl \
wget \
bash \
sqlite-libs \
musl \
python3 \
py3-pip \
npm \
go \
&& 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
# Install govulncheck for Go vulnerability scanning
RUN go install golang.org/x/vuln/cmd/govulncheck@latest && \
mv /root/go/bin/govulncheck /usr/local/bin/
# Install pip-audit for Python package vulnerability scanning
RUN pip3 install --no-cache-dir pip-audit --break-system-packages
# Create non-root user
RUN addgroup -g 1000 scanner && \
adduser -D -u 1000 -G scanner scanner
# Create necessary directories with proper permissions
RUN mkdir -p /var/cache/gohoarder \
/var/lib/gohoarder/metadata \
/var/lib/trivy \
/tmp/gohoarder && \
chown -R scanner:scanner /var/cache/gohoarder \
/var/lib/gohoarder \
/var/lib/trivy \
/tmp/gohoarder && \
chmod -R 750 /var/cache/gohoarder \
/var/lib/gohoarder \
/var/lib/trivy
# Copy pre-built binary from GoReleaser
# GoReleaser will automatically inject the correct binary for the target platform
# In split/merge mode, binaries are in linux/${TARGETARCH}/ subdirectories
COPY linux/${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 /var/cache/gohoarder
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
# The scanner runs the same serve command but uses SCANNER_MODE env var
# and configuration to determine its role
ENTRYPOINT ["/usr/local/bin/gohoarder"]
CMD ["serve"]
+54
View File
@@ -0,0 +1,54 @@
# Application Engine - Main GoHoarder Server
# This Dockerfile expects a PRE-BUILT binary from GoReleaser (no compilation)
# GoReleaser injects the platform-specific binary automatically
FROM --platform=$TARGETPLATFORM alpine:latest
ARG TARGETARCH
# Install runtime dependencies (CGO/SQLite requires these)
RUN apk add --no-cache \
ca-certificates \
tzdata \
sqlite-libs \
musl \
&& update-ca-certificates
# Create non-root user
RUN addgroup -g 1000 gohoarder && \
adduser -D -u 1000 -G gohoarder gohoarder
# Create necessary directories with proper permissions
RUN mkdir -p /var/cache/gohoarder \
/var/lib/gohoarder/metadata \
/tmp/gohoarder && \
chown -R gohoarder:gohoarder /var/cache/gohoarder \
/var/lib/gohoarder \
/tmp/gohoarder && \
chmod -R 750 /var/cache/gohoarder \
/var/lib/gohoarder
# Copy pre-built binary from GoReleaser
# GoReleaser will automatically inject the correct binary for the target platform
# In split/merge mode, binaries are in linux/${TARGETARCH}/ subdirectories
COPY linux/${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 /var/cache/gohoarder
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"]
+21
View File
@@ -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.
+139
View File
@@ -0,0 +1,139 @@
.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 (requires confirmation)
@echo "WARNING: This will delete all cached packages and scan results!"
@echo "Paths to be cleaned:"
@echo " - ./data/storage (package cache)"
@echo " - ./data/gohoarder.db and gohoarder.db (metadata database)"
@echo " - /tmp/trivy (Trivy cache)"
@echo ""
@printf "Are you sure you want to continue? [y/N] " && read confirm && [ "$$confirm" = "y" ] || (echo "Cancelled." && exit 1)
@echo "Cleaning database and cache..."
@rm -rf ./data/storage ./data
@rm -f gohoarder.db gohoarder.db-shm gohoarder.db-wal
@rm -rf /tmp/trivy
@echo "Database and cache cleaned successfully"
clean-db-force: ## Clean all local cache and database files (no confirmation)
@echo "Cleaning database and cache..."
@rm -rf ./data/storage ./data
@rm -f gohoarder.db gohoarder.db-shm 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
+1261
View File
File diff suppressed because it is too large Load Diff
+62
View File
@@ -0,0 +1,62 @@
package commands
import (
"fmt"
"github.com/lukaszraczylo/gohoarder/internal/version"
"github.com/lukaszraczylo/gohoarder/pkg/app"
"github.com/lukaszraczylo/gohoarder/pkg/config"
"github.com/lukaszraczylo/gohoarder/pkg/logger"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
)
var (
configPath string
)
// ServeCmd starts the HTTP server
var ServeCmd = &cobra.Command{
Use: "serve",
Short: "Start the GoHoarder server",
Long: "Start the HTTP server to serve as a package cache proxy",
RunE: runServe,
}
func init() {
ServeCmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
}
func runServe(cmd *cobra.Command, args []string) error {
// Load configuration
cfg, err := config.Load(configPath)
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
// Initialize logger
if err := logger.Init(logger.Config{
Level: cfg.Logging.Level,
Format: cfg.Logging.Format,
}); err != nil {
return fmt.Errorf("failed to initialize logger: %w", err)
}
log.Info().
Str("version", version.Version).
Str("commit", version.GitCommit).
Msg("Starting GoHoarder")
// Create and run application
application, err := app.New(cfg)
if err != nil {
return fmt.Errorf("failed to create application: %w", err)
}
// Run application (blocks until shutdown)
if err := application.Run(); err != nil {
return fmt.Errorf("application error: %w", err)
}
return nil
}
+42
View File
@@ -0,0 +1,42 @@
package commands
import (
"fmt"
json "github.com/goccy/go-json"
"github.com/lukaszraczylo/gohoarder/internal/version"
"github.com/spf13/cobra"
)
var (
jsonOutput bool
)
// VersionCmd displays version information
var VersionCmd = &cobra.Command{
Use: "version",
Short: "Print version information",
Long: "Display detailed version information about GoHoarder",
Run: func(cmd *cobra.Command, args []string) {
info := version.Get()
if jsonOutput {
data, err := json.MarshalIndent(info, "", " ")
if err != nil {
fmt.Fprintf(cmd.OutOrStderr(), "Error: %v\n", err)
return
}
fmt.Fprintln(cmd.OutOrStdout(), string(data))
} else {
fmt.Fprintf(cmd.OutOrStdout(), "GoHoarder %s\n", info.Version)
fmt.Fprintf(cmd.OutOrStdout(), "Git Commit: %s\n", info.GitCommit)
fmt.Fprintf(cmd.OutOrStdout(), "Built: %s\n", info.BuildTime)
fmt.Fprintf(cmd.OutOrStdout(), "Go Version: %s\n", info.GoVersion)
fmt.Fprintf(cmd.OutOrStdout(), "Platform: %s\n", info.Platform)
}
},
}
func init() {
VersionCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output version information as JSON")
}
+41
View File
@@ -0,0 +1,41 @@
package main
import (
"fmt"
"os"
"github.com/lukaszraczylo/gohoarder/cmd/gohoarder/commands"
"github.com/lukaszraczylo/gohoarder/internal/version"
"github.com/spf13/cobra"
)
var rootCmd = &cobra.Command{
Use: "gohoarder",
Short: "Universal package cache proxy",
Long: `GoHoarder is a universal pass-through cache proxy for package managers.
Supports npm, pip, and Go modules with transparent caching, security scanning, and multi-backend storage.`,
Version: version.Version,
}
func init() {
// Add commands
rootCmd.AddCommand(commands.ServeCmd)
rootCmd.AddCommand(commands.VersionCmd)
// Set version template
rootCmd.SetVersionTemplate(fmt.Sprintf(
"GoHoarder %s\nGit Commit: %s\nBuilt: %s\nGo Version: %s\nPlatform: %s\n",
version.Version,
version.GitCommit,
version.BuildTime,
version.GoVersion,
"GOOS/GOARCH",
))
}
func main() {
if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
+258
View File
@@ -0,0 +1,258 @@
package main
import (
"context"
"database/sql"
"flag"
"fmt"
stdlog "log"
"os"
"time"
"github.com/go-gormigrate/gormigrate/v2"
_ "github.com/go-sql-driver/mysql"
_ "github.com/lib/pq"
"github.com/lukaszraczylo/gohoarder/pkg/metadata/gormstore"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
type MigrationConfig struct {
Driver string
DSN string
Timeout time.Duration
Action string // migrate, rollback, rollback-to, list
TargetID string // For rollback-to
LogLevel string
}
func main() {
cfg := MigrationConfig{}
flag.StringVar(&cfg.Driver, "driver", os.Getenv("DB_DRIVER"), "Database driver (postgres, mysql, sqlite)")
flag.StringVar(&cfg.DSN, "dsn", os.Getenv("DATABASE_URL"), "Database connection string")
flag.DurationVar(&cfg.Timeout, "timeout", 10*time.Minute, "Migration timeout")
flag.StringVar(&cfg.Action, "action", "migrate", "Action: migrate, rollback, rollback-to, list")
flag.StringVar(&cfg.TargetID, "target", "", "Target migration ID (for rollback-to)")
flag.StringVar(&cfg.LogLevel, "log-level", "info", "Log level: debug, info, warn, error")
flag.Parse()
// Setup logging
setupLogging(cfg.LogLevel)
log.Info().
Str("driver", cfg.Driver).
Str("action", cfg.Action).
Msg("Starting database migration")
if err := RunMigration(cfg); err != nil {
log.Fatal().Err(err).Msg("Migration failed")
}
log.Info().Msg("Migration completed successfully")
}
func setupLogging(level string) {
zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.RFC3339})
switch level {
case "debug":
zerolog.SetGlobalLevel(zerolog.DebugLevel)
case "info":
zerolog.SetGlobalLevel(zerolog.InfoLevel)
case "warn":
zerolog.SetGlobalLevel(zerolog.WarnLevel)
case "error":
zerolog.SetGlobalLevel(zerolog.ErrorLevel)
default:
zerolog.SetGlobalLevel(zerolog.InfoLevel)
}
}
func RunMigration(cfg MigrationConfig) error {
// Validate config
if cfg.Driver == "" {
return fmt.Errorf("driver is required (set DB_DRIVER or --driver)")
}
if cfg.DSN == "" {
return fmt.Errorf("DSN is required (set DATABASE_URL or --dsn)")
}
ctx, cancel := context.WithTimeout(context.Background(), cfg.Timeout)
defer cancel()
// Connect to database
db, err := connectToDatabase(cfg.Driver, cfg.DSN)
if err != nil {
return fmt.Errorf("failed to connect to database: %w", err)
}
sqlDB, err := db.DB()
if err != nil {
return fmt.Errorf("failed to get sql.DB: %w", err)
}
defer sqlDB.Close()
// Wait for database to be ready
if err := waitForDB(ctx, sqlDB, 60*time.Second); err != nil {
return fmt.Errorf("database not ready: %w", err)
}
// Initialize gormigrate with custom options
opts := gormigrate.DefaultOptions
opts.TableName = "gohoarder_migrations"
m := gormigrate.New(db, opts, gormstore.GetMigrations())
log.Info().
Str("table", "gohoarder_migrations").
Msg("Migration tracking table initialized")
// Execute action
switch cfg.Action {
case "migrate":
return runMigrate(m)
case "rollback":
return runRollback(m)
case "rollback-to":
if cfg.TargetID == "" {
return fmt.Errorf("target migration ID required for rollback-to")
}
return runRollbackTo(m, cfg.TargetID)
case "list":
return listMigrations(db)
default:
return fmt.Errorf("unknown action: %s (use: migrate, rollback, rollback-to, list)", cfg.Action)
}
}
func connectToDatabase(driver, dsn string) (*gorm.DB, error) {
// Configure GORM logger using standard library log
gormLogger := logger.New(
stdlog.New(os.Stdout, "\r\n", stdlog.LstdFlags),
logger.Config{
SlowThreshold: 200 * time.Millisecond,
LogLevel: logger.Info,
IgnoreRecordNotFoundError: true,
Colorful: true,
},
)
var dialector gorm.Dialector
switch driver {
case "sqlite":
dialector = sqlite.Open(dsn)
case "postgres", "postgresql":
dialector = postgres.Open(dsn)
case "mysql":
dialector = mysql.Open(dsn)
default:
return nil, fmt.Errorf("unsupported driver: %s", driver)
}
db, err := gorm.Open(dialector, &gorm.Config{
Logger: gormLogger,
SkipDefaultTransaction: false, // Migrations should be transactional
PrepareStmt: true,
})
if err != nil {
return nil, err
}
return db, nil
}
func waitForDB(ctx context.Context, db *sql.DB, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
attempt := 0
for {
attempt++
if time.Now().After(deadline) {
return fmt.Errorf("timeout waiting for database after %d attempts", attempt)
}
if err := db.PingContext(ctx); err == nil {
log.Info().
Int("attempts", attempt).
Msg("Database is ready")
return nil
}
log.Debug().
Int("attempt", attempt).
Msg("Waiting for database...")
time.Sleep(2 * time.Second)
}
}
func runMigrate(m *gormigrate.Gormigrate) error {
log.Info().Msg("Running migrations...")
if err := m.Migrate(); err != nil {
return fmt.Errorf("migration failed: %w", err)
}
log.Info().Msg("✓ All migrations applied successfully")
return nil
}
func runRollback(m *gormigrate.Gormigrate) error {
log.Warn().Msg("Rolling back last migration...")
if err := m.RollbackLast(); err != nil {
return fmt.Errorf("rollback failed: %w", err)
}
log.Info().Msg("✓ Rollback completed")
return nil
}
func runRollbackTo(m *gormigrate.Gormigrate, targetID string) error {
log.Warn().
Str("target_id", targetID).
Msg("Rolling back to migration...")
if err := m.RollbackTo(targetID); err != nil {
return fmt.Errorf("rollback to %s failed: %w", targetID, err)
}
log.Info().
Str("target_id", targetID).
Msg("✓ Rollback completed")
return nil
}
func listMigrations(db *gorm.DB) error {
log.Info().Msg("Applied migrations:")
type Migration struct {
ID string
}
var migrations []Migration
if err := db.Table("gohoarder_migrations").Find(&migrations).Error; err != nil {
return fmt.Errorf("failed to list migrations: %w", err)
}
if len(migrations) == 0 {
log.Info().Msg(" (no migrations applied yet)")
return nil
}
for _, m := range migrations {
log.Info().Str("id", m.ID).Msg(" ✓")
}
log.Info().
Int("total", len(migrations)).
Msg("Applied migrations")
return nil
}
+217
View File
@@ -0,0 +1,217 @@
# GoHoarder Configuration Example
#
# Port Configuration:
# - Backend server port is configured below (server.port)
# - Frontend dev server uses frontend/.env (VITE_PORT and VITE_BACKEND_URL)
# - When running `make run`, both will start with their configured ports
# - The frontend automatically proxies /api and /ws requests to the backend
server:
host: "0.0.0.0"
port: 8080 # Backend API server port
read_timeout: "5m"
write_timeout: "5m"
idle_timeout: "2m"
tls:
enabled: false
cert_file: ""
key_file: ""
storage:
backend: "filesystem" # filesystem, s3, smb, nfs
path: "/var/cache/gohoarder"
filesystem:
base_path: "/var/cache/gohoarder"
s3:
endpoint: "s3.amazonaws.com"
region: "us-east-1"
bucket: "gohoarder-cache"
access_key_id: ""
secret_access_key: ""
use_ssl: true
smb:
host: ""
share: ""
username: ""
password: ""
domain: ""
metadata:
# Backend: sqlite, postgresql, mysql, mariadb, file
#
# Choose based on your deployment:
# - sqlite: Single instance, local storage (NOT for network filesystems like SMB/NFS!)
# - postgresql: Production, multiple replicas, works with any storage including SMB/NFS
# - mysql: Production alternative to PostgreSQL
# - file: Simple file-based metadata (limited features)
#
# IMPORTANT: SQLite + SMB/NFS = Database locked errors!
# For network storage (SMB, NFS), use PostgreSQL or MySQL.
backend: "sqlite"
connection: "file:gohoarder.db?cache=shared&mode=rwc" # Legacy, not used with GORM
# SQLite configuration (for local storage only)
# Use with local storage classes (local-path, hostPath, or RWX like longhorn)
# DO NOT use with SMB/NFS network storage!
sqlite:
path: "gohoarder.db"
wal_mode: true # Set to false for network filesystems if you must use SQLite
# PostgreSQL configuration (recommended for production)
# Works with any storage including SMB/NFS
# Supports multiple replicas and high availability
postgresql:
host: "localhost"
port: 5432
database: "gohoarder"
user: "gohoarder"
password: ""
ssl_mode: "disable" # disable, require, verify-ca, verify-full
# MySQL/MariaDB configuration (alternative to PostgreSQL)
# Works with any storage including SMB/NFS
mysql:
host: "localhost"
port: 3306
database: "gohoarder"
user: "gohoarder"
password: ""
charset: "utf8mb4"
parse_time: true
# GORM connection pool settings (applies to all database backends)
max_open_conns: 25 # Maximum number of open connections to the database
max_idle_conns: 5 # Maximum number of idle connections in the pool
conn_max_lifetime: 3600 # Maximum lifetime of a connection in seconds (1 hour)
log_level: "warn" # GORM log level: silent, error, warn, info
cache:
default_ttl: "168h" # 7 days
cleanup_interval: "1h"
max_size_bytes: 536870912000 # 500GB
per_project_quota: 53687091200 # 50GB
ttl_overrides:
npm: "168h"
pip: "168h"
go: "168h"
security:
enabled: false
block_on_severity: "high" # none, low, medium, high, critical
scan_on_download: true # Scan packages on first download
rescan_interval: "24h" # How often to re-scan packages (e.g., 24h, 168h for weekly)
update_db_on_startup: false # Update vulnerability databases on startup
allowed_packages: [] # Packages that bypass security checks (format: "registry/name@version")
ignored_cves: [] # CVE IDs to ignore globally (e.g., "CVE-2021-23337")
block_thresholds:
critical: 0 # Max critical vulns (0 = block any)
high: -1 # Max high vulns (-1 = unlimited)
medium: -1 # Max medium vulns
low: -1 # Max low vulns
scanners:
# Trivy - Comprehensive vulnerability scanner from Aqua Security
# Supports: containers, OS packages, language packages
trivy:
enabled: false
timeout: "5m"
cache_db: "/var/lib/trivy"
# OSV - Google's Open Source Vulnerabilities database
# Supports: npm, PyPI, Go, Maven, NuGet, etc.
osv:
enabled: false
api_url: "https://api.osv.dev"
timeout: "30s"
# Grype - Multi-ecosystem vulnerability scanner from Anchore
# Supports: all package types, containers, SBOMs
grype:
enabled: false
timeout: "5m"
# govulncheck - Official Go vulnerability scanner from the Go team
# Supports: Go modules only
govulncheck:
enabled: false
timeout: "5m"
# npm-audit - npm's built-in vulnerability scanner
# Supports: npm packages only
npm_audit:
enabled: false
timeout: "2m"
# pip-audit - Python package vulnerability scanner
# Supports: PyPI packages only
pip_audit:
enabled: false
timeout: "2m"
# GitHub Advisory Database - GitHub's security advisory database
# Supports: npm, pip, go, maven, nuget, cargo, pub
# Optional: Set token for higher API rate limits (60 req/hour unauthenticated, 5000 req/hour authenticated)
ghsa:
enabled: false
timeout: "30s"
token: "" # Optional: GitHub personal access token (ghp_...)
# Static Analysis - Basic static analysis and package validation
static:
enabled: true
max_package_size: 2147483648 # 2GB
check_checksums: true
block_suspicious: false
allowed_licenses: []
auth:
enabled: true
key_expiration: "0" # Never expire (0), or duration like "8760h" for 1 year
bcrypt_cost: 10
audit_log: true
network:
connect_timeout: "10s"
read_timeout: "5m"
write_timeout: "5m"
max_idle_conns: 100
max_conns_per_host: 10
rate_limit:
per_api_key: 1000
per_ip: 100
burst_size: 50
circuit_breaker:
threshold: 5
timeout: "30s"
reset_interval: "60s"
retry:
max_attempts: 3
initial_backoff: "1s"
max_backoff: "30s"
logging:
level: "info" # debug, info, warn, error
format: "json" # json, pretty
handlers:
go:
enabled: true
upstream_proxy: "https://proxy.golang.org"
checksum_db: "https://sum.golang.org"
verify_checksums: true
npm:
enabled: true
upstream_registry: "https://registry.npmjs.org"
pypi:
enabled: true
upstream_url: "https://pypi.org"
simple_api_url: "https://pypi.org/simple"
+416
View File
@@ -0,0 +1,416 @@
# Kubernetes Deployment Guide
This directory contains Kubernetes manifests for deploying GoHoarder in a production environment.
## Architecture Overview
```
┌─────────────────────────────────────────────────────┐
│ Kubernetes Pod │
│ ┌────────────────────────────────────────────────┐ │
│ │ GoHoarder Container │ │
│ │ ┌──────────────────────────────────────────┐ │ │
│ │ │ Pattern-Based Credential Store │ │ │
│ │ │ ├─ github.com/myorg/* → token_A │ │ │
│ │ │ ├─ gitlab.com/team/* → token_B │ │ │
│ │ │ └─ * → fallback_token │ │ │
│ │ └──────────────────────────────────────────┘ │ │
│ └────────────────────────────────────────────────┘ │
│ │
│ Mounted Volumes: │
│ • config.yaml (ConfigMap) │
│ • git-credentials.json (Secret) │
│ • /var/lib/gohoarder/cache (PVC) │
│ • /var/lib/gohoarder (PVC for metadata DB) │
└─────────────────────────────────────────────────────┘
```
## Files
- `secret-git-credentials.yaml` - Git credentials for private repositories
- `configmap-config.yaml` - Application configuration
- `pvc.yaml` - Persistent volume claims for cache and metadata
- `deployment.yaml` - Main application deployment
- `service.yaml` - Service and optional ingress configuration
## Quick Start
### 1. Configure Git Credentials
Edit `secret-git-credentials.yaml` and replace the placeholder tokens with your actual tokens:
```yaml
{
"credentials": [
{
"pattern": "github.com/mycompany/*",
"host": "github.com",
"username": "oauth2",
"token": "ghp_YOUR_ACTUAL_TOKEN_HERE"
}
]
}
```
**Pattern Matching Examples:**
- `github.com/myorg/*` - Matches all repos under myorg
- `github.com/myorg/specific-repo` - Matches only specific-repo
- `gitlab.com/backend-team/*` - Matches all GitLab repos under backend-team
- `*` - Fallback pattern (matches everything)
**Credential Priority:**
1. Most specific pattern wins (longest match)
2. Fallback credential (`"fallback": true`)
3. System git config (if no matches)
### 2. Customize Configuration
Edit `configmap-config.yaml` to adjust:
- Cache size (`max_size_bytes`)
- Security scanning settings
- Upstream registries
- Logging level
### 3. Deploy to Kubernetes
```bash
# Create namespace (optional)
kubectl create namespace gohoarder
# Apply manifests
kubectl apply -f pvc.yaml
kubectl apply -f secret-git-credentials.yaml
kubectl apply -f configmap-config.yaml
kubectl apply -f deployment.yaml
kubectl apply -f service.yaml
# Verify deployment
kubectl get pods -l app=gohoarder
kubectl logs -l app=gohoarder -f
```
### 4. Configure Go Client
```bash
# Set GOPROXY environment variable
export GOPROXY=http://gohoarder.default.svc.cluster.local:8080/go,direct
# Or in your Dockerfile
ENV GOPROXY=http://gohoarder.default.svc.cluster.local:8080/go,direct
# Test with a private module
go get github.com/mycompany/private-module@latest
```
## Advanced Configuration
### Using External Secrets Operator (ESO)
If you're using External Secrets Operator, uncomment the ExternalSecret section in `secret-git-credentials.yaml` and configure your SecretStore:
```yaml
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: gohoarder-git-credentials
spec:
refreshInterval: 1h
secretStoreRef:
name: vault-backend
kind: SecretStore
target:
name: gohoarder-git-credentials
data:
- secretKey: credentials.json
remoteRef:
key: secret/gohoarder/git-credentials
```
### Storage Classes
For production deployments, specify appropriate storage classes:
```yaml
# In pvc.yaml
storageClassName: fast-ssd # For cache (needs fast I/O)
storageClassName: standard # For metadata (smaller, less critical)
```
### Horizontal Pod Autoscaling
```bash
kubectl autoscale deployment gohoarder \
--cpu-percent=70 \
--min=2 \
--max=10
```
### Monitoring
Check health and metrics:
```bash
# Health check
kubectl port-forward svc/gohoarder 8080:8080
curl http://localhost:8080/health
# Metrics (Prometheus format)
curl http://localhost:8080/metrics
```
## Multi-Organization Setup
### Example 1: Multiple GitHub Organizations
```json
{
"credentials": [
{
"pattern": "github.com/company-frontend/*",
"host": "github.com",
"username": "oauth2",
"token": "ghp_frontend_team_token"
},
{
"pattern": "github.com/company-backend/*",
"host": "github.com",
"username": "oauth2",
"token": "ghp_backend_team_token"
},
{
"pattern": "github.com/company-infra/*",
"host": "github.com",
"username": "oauth2",
"token": "ghp_infra_team_token"
},
{
"pattern": "*",
"host": "*",
"username": "oauth2",
"token": "ghp_readonly_default_token",
"fallback": true
}
]
}
```
### Example 2: GitHub + GitLab
```json
{
"credentials": [
{
"pattern": "github.com/myorg/*",
"host": "github.com",
"username": "oauth2",
"token": "ghp_github_token"
},
{
"pattern": "gitlab.com/myteam/*",
"host": "gitlab.com",
"username": "oauth2",
"token": "glpat_gitlab_token"
}
]
}
```
### Example 3: Enterprise GitHub
```json
{
"credentials": [
{
"pattern": "github.enterprise.com/engineering/*",
"host": "github.enterprise.com",
"username": "oauth2",
"token": "ghp_enterprise_token"
}
]
}
```
## Security Best Practices
1. **Token Scoping**: Use fine-grained personal access tokens with minimal permissions
- GitHub: Only `repo` scope needed for private repos
- GitLab: Only `read_repository` scope needed
2. **Secret Rotation**: Regularly rotate tokens
```bash
# Update secret
kubectl create secret generic gohoarder-git-credentials \
--from-file=credentials.json=./new-credentials.json \
--dry-run=client -o yaml | kubectl apply -f -
# Restart pods to pick up new credentials
kubectl rollout restart deployment gohoarder
```
3. **RBAC**: Limit who can read the secret
```bash
kubectl create role secret-reader \
--verb=get,list \
--resource=secrets \
--resource-name=gohoarder-git-credentials
```
4. **Audit Logging**: Enable Kubernetes audit logging for secret access
5. **Network Policies**: Restrict which pods can access GoHoarder
```yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-from-build-namespace
spec:
podSelector:
matchLabels:
app: gohoarder
ingress:
- from:
- namespaceSelector:
matchLabels:
name: build-namespace
```
## Troubleshooting
### Check if credentials are loaded
```bash
# Check logs for credential loading
kubectl logs -l app=gohoarder | grep "Loaded git credentials"
# Expected output:
# {"level":"info","file":"/etc/gohoarder/git-credentials.json","credentials":3,"message":"Loaded git credentials from file"}
# {"level":"debug","pattern":"github.com/myorg/*","host":"github.com","message":"Registered credential pattern"}
```
### Test credential pattern matching
```bash
# Enable debug logging
kubectl set env deployment/gohoarder LOG_LEVEL=debug
# Watch logs during a go get request
kubectl logs -l app=gohoarder -f
```
### Common Issues
**Issue**: `git clone failed: authentication required`
- **Cause**: No matching credential pattern
- **Solution**: Check pattern syntax in credentials.json, ensure it matches the module path
**Issue**: `Failed to load git credentials`
- **Cause**: Secret not mounted or JSON syntax error
- **Solution**: Verify secret exists and JSON is valid
```bash
kubectl get secret gohoarder-git-credentials
kubectl get secret gohoarder-git-credentials -o jsonpath='{.data.credentials\.json}' | base64 -d | jq .
```
**Issue**: Module fetch slow
- **Cause**: Git clone timeout or large repository
- **Solution**: Increase timeout in config.yaml or use upstream proxy for public modules
## Performance Tuning
### Cache Configuration
```yaml
cache:
max_size_bytes: 107374182400 # 100GB for large organizations
default_ttl: 168h # 7 days for stable modules
```
### Resource Limits
For high-traffic deployments:
```yaml
resources:
requests:
memory: "2Gi"
cpu: "1000m"
limits:
memory: "8Gi"
cpu: "4000m"
```
### Replicas
Run multiple replicas for high availability:
```yaml
spec:
replicas: 3
```
## Backup and Recovery
### Backup Metadata Database
```bash
# Backup SQLite database
kubectl exec -it deployment/gohoarder -- \
sqlite3 /var/lib/gohoarder/gohoarder.db ".backup /tmp/backup.db"
kubectl cp gohoarder-pod:/tmp/backup.db ./gohoarder-backup-$(date +%Y%m%d).db
```
### Restore from Backup
```bash
kubectl cp ./gohoarder-backup-20260102.db gohoarder-pod:/var/lib/gohoarder/gohoarder.db
kubectl rollout restart deployment gohoarder
```
## Integration Examples
### CI/CD Pipeline (GitHub Actions)
```yaml
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.21'
- name: Configure GOPROXY
run: |
echo "GOPROXY=http://gohoarder.company.internal:8080/go,direct" >> $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
@@ -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
@@ -0,0 +1,428 @@
# GoHoarder - Kubernetes Deployment (All-in-One)
# This manifest deploys all GoHoarder services under a single ingress
# The frontend includes integrated nginx reverse proxy to the backend
#
# 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
# Backend proxy configuration (frontend includes nginx reverse proxy)
- name: BACKEND_HOST
value: gohoarder-server
- name: BACKEND_PORT
value: "8080"
- name: SERVER_NAME
value: hoarder.i.raczylo.com
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
---
# 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-frontend
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 - Frontend
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: gohoarder-frontend
namespace: gohoarder
labels:
app.kubernetes.io/name: gohoarder
app.kubernetes.io/component: frontend
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: gohoarder-frontend
minReplicas: 2
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
+104
View File
@@ -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
+29
View File
@@ -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
@@ -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
+44
View File
@@ -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
+127
View File
@@ -0,0 +1,127 @@
version: '3.8'
# GoHoarder - Unified Deployment Example
# This docker-compose file demonstrates deploying all GoHoarder services
# The frontend includes integrated reverse proxy to the backend
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 with integrated reverse proxy
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
# Backend proxy configuration (frontend includes nginx reverse proxy)
- BACKEND_HOST=gohoarder-server
- BACKEND_PORT=8080
- 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
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
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 frontend container (add SSL certificates to nginx)
# - Set up proper SSL certificates
# - Configure firewall rules
# - Set appropriate resource limits
# - Enable monitoring and alerting
+7
View File
@@ -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
+8
View File
@@ -0,0 +1,8 @@
{
"hash": "eb707743",
"configHash": "e17a1322",
"lockfileHash": "c2e83396",
"browserHash": "ab796ace",
"optimized": {},
"chunks": {}
}
+3
View File
@@ -0,0 +1,3 @@
{
"type": "module"
}
+21
View File
@@ -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": {}
}
+16
View File
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<title>GoHoarder Dashboard</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+43
View File
@@ -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"
}
}
+3795
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+61
View File
@@ -0,0 +1,61 @@
<template>
<div class="min-h-screen bg-background">
<nav class="sticky top-0 z-50 bg-card/95 backdrop-blur-lg shadow-sm border-b">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-primary text-primary-foreground">
<i class="fas fa-box-open text-lg"></i>
</div>
<div class="flex flex-col">
<h1 class="text-lg font-semibold tracking-tight text-foreground">GoHoarder</h1>
<p class="text-xs text-muted-foreground">Package Cache Manager</p>
</div>
</div>
<div class="flex items-center gap-1">
<router-link
to="/"
class="inline-flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium text-foreground/70 hover:text-foreground hover:bg-accent transition-colors"
active-class="text-foreground bg-accent"
>
<i class="fas fa-chart-pie text-sm"></i>
<span>Dashboard</span>
</router-link>
<router-link
to="/packages"
class="inline-flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium text-foreground/70 hover:text-foreground hover:bg-accent transition-colors"
active-class="text-foreground bg-accent"
>
<i class="fas fa-boxes text-sm"></i>
<span>Packages</span>
</router-link>
<router-link
to="/stats"
class="inline-flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium text-foreground/70 hover:text-foreground hover:bg-accent transition-colors"
active-class="text-foreground bg-accent"
>
<i class="fas fa-chart-bar text-sm"></i>
<span>Statistics</span>
</router-link>
<router-link
to="/admin/bypasses"
class="inline-flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium text-foreground/70 hover:text-foreground hover:bg-accent transition-colors"
active-class="text-foreground bg-accent"
>
<i class="fas fa-shield-alt text-sm"></i>
<span>Admin</span>
</router-link>
</div>
</div>
</div>
</nav>
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10">
<router-view />
</main>
</div>
</template>
<script setup lang="ts">
// App component
</script>
@@ -0,0 +1,609 @@
<template>
<div>
<div class="flex justify-between items-center mb-8">
<div>
<h2 class="text-3xl font-bold text-gray-900">CVE Bypass Management</h2>
<p class="text-gray-600 mt-1">Manage temporary security bypasses for packages and CVEs</p>
</div>
<Button @click="showCreateModal = true" class="bg-green-600 hover:bg-green-700">
<i class="fas fa-plus mr-2"></i>Create Bypass
</Button>
</div>
<!-- Filter Section -->
<div class="mb-6 flex flex-col sm:flex-row items-start sm:items-center gap-4">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-gray-700">Filter:</span>
<div class="flex gap-2">
<Button
@click="activeFilter = 'all'"
:variant="activeFilter === 'all' ? 'default' : 'outline'"
size="sm"
>
All
</Button>
<Button
@click="activeFilter = 'active'"
:variant="activeFilter === 'active' ? 'default' : 'outline'"
size="sm"
>
<i class="fas fa-check-circle mr-1"></i>Active
</Button>
<Button
@click="activeFilter === 'expired'"
:variant="activeFilter === 'expired' ? 'default' : 'outline'"
size="sm"
>
<i class="fas fa-clock mr-1"></i>Expired
</Button>
</div>
</div>
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-gray-700">Type:</span>
<div class="flex gap-2">
<Button
@click="typeFilter = ''"
:variant="typeFilter === '' ? 'default' : 'outline'"
size="sm"
>
All
</Button>
<Button
@click="typeFilter = 'cve'"
:variant="typeFilter === 'cve' ? 'default' : 'outline'"
size="sm"
>
CVE
</Button>
<Button
@click="typeFilter = 'package'"
:variant="typeFilter === 'package' ? 'default' : 'outline'"
size="sm"
>
Package
</Button>
</div>
</div>
<Button @click="fetchBypasses" variant="outline" size="sm" class="sm:ml-auto">
<i class="fas fa-sync-alt mr-2"></i>Refresh
</Button>
</div>
<!-- Error Alert -->
<Alert v-if="error" variant="destructive" class="mb-4">
<i class="fas fa-exclamation-circle mr-2"></i>
<AlertDescription>{{ error }}</AlertDescription>
</Alert>
<!-- Success Alert -->
<Alert v-if="successMessage" class="mb-4 bg-green-50 border-green-200">
<i class="fas fa-check-circle mr-2 text-green-600"></i>
<AlertDescription class="text-green-800">{{ successMessage }}</AlertDescription>
</Alert>
<!-- Loading State -->
<div v-if="loading" class="text-center py-12">
<i class="fas fa-spinner fa-spin text-4xl text-primary-600"></i>
<p class="mt-4 text-gray-600">Loading bypasses...</p>
</div>
<!-- Bypass List -->
<Card v-else>
<CardContent class="p-6">
<div v-if="filteredBypasses.length === 0" class="text-center py-12 text-gray-500">
<i class="fas fa-shield-alt text-6xl mb-4"></i>
<p class="text-xl">No bypasses found</p>
<p class="mt-2">Create a bypass to allow packages with known vulnerabilities</p>
</div>
<div v-else class="space-y-4">
<div
v-for="bypass in filteredBypasses"
:key="bypass.id"
class="border rounded-lg p-4 hover:bg-gray-50"
:class="bypass.active ? 'border-gray-200' : 'border-gray-300 bg-gray-50'"
>
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-2 mb-2">
<Badge :variant="bypass.type === 'cve' ? 'default' : 'outline'">
{{ bypass.type.toUpperCase() }}
</Badge>
<Badge
:class="bypass.active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-600'"
>
{{ bypass.active ? 'ACTIVE' : 'INACTIVE' }}
</Badge>
<Badge
v-if="isExpired(bypass.expires_at)"
class="bg-red-100 text-red-800"
>
EXPIRED
</Badge>
</div>
<h4 class="font-semibold text-lg text-gray-900">{{ bypass.target }}</h4>
<p class="text-sm text-gray-600 mt-1">{{ bypass.reason }}</p>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mt-3 text-sm text-gray-500">
<div>
<i class="fas fa-user mr-1"></i>
<strong>Created by:</strong> {{ bypass.created_by }}
</div>
<div>
<i class="fas fa-calendar mr-1"></i>
<strong>Created:</strong> {{ formatDate(bypass.created_at) }}
</div>
<div>
<i class="fas fa-clock mr-1"></i>
<strong>Expires:</strong> {{ formatDate(bypass.expires_at) }}
</div>
<div v-if="bypass.applies_to">
<i class="fas fa-box mr-1"></i>
<strong>Applies to:</strong> {{ bypass.applies_to }}
</div>
</div>
</div>
<div class="flex items-center gap-2 ml-4">
<Button
@click="editBypass(bypass)"
variant="outline"
size="sm"
title="Edit bypass"
>
<i class="fas fa-edit"></i>
</Button>
<Button
@click="confirmDeleteBypass(bypass)"
variant="destructive"
size="sm"
title="Delete bypass"
>
<i class="fas fa-trash"></i>
</Button>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
<!-- Create/Edit Bypass Modal -->
<Dialog v-model:open="showCreateModal">
<DialogContent class="max-w-2xl">
<DialogHeader>
<DialogTitle>{{ editingBypass ? 'Edit' : 'Create' }} Bypass</DialogTitle>
<DialogDescription>
{{ editingBypass ? 'Update bypass settings' : 'Create a temporary bypass for a CVE or package' }}
</DialogDescription>
</DialogHeader>
<div class="space-y-4 py-4">
<!-- Type Selection -->
<div v-if="!editingBypass">
<label class="text-sm font-medium text-gray-700 mb-2 block">Bypass Type</label>
<div class="flex gap-4">
<Button
@click="bypassForm.type = 'cve'"
:variant="bypassForm.type === 'cve' ? 'default' : 'outline'"
class="flex-1"
>
<i class="fas fa-bug mr-2"></i>CVE Bypass
</Button>
<Button
@click="bypassForm.type = 'package'"
:variant="bypassForm.type === 'package' ? 'default' : 'outline'"
class="flex-1"
>
<i class="fas fa-box mr-2"></i>Package Bypass
</Button>
</div>
</div>
<!-- Target -->
<div v-if="!editingBypass">
<label class="text-sm font-medium text-gray-700 mb-2 block">
{{ bypassForm.type === 'cve' ? 'CVE ID' : 'Package' }}
</label>
<Input
v-model="bypassForm.target"
:placeholder="bypassForm.type === 'cve' ? 'CVE-2021-23337' : 'npm/[email protected]'"
class="w-full"
/>
<p class="text-xs text-gray-500 mt-1">
{{ bypassForm.type === 'cve' ? 'Enter the CVE ID (e.g., CVE-2021-23337)' : 'Enter package (e.g., npm/[email protected] or npm/lodash for all versions)' }}
</p>
</div>
<!-- Applies To (CVE only) -->
<div v-if="!editingBypass && bypassForm.type === 'cve'">
<label class="text-sm font-medium text-gray-700 mb-2 block">
Applies To (Optional)
</label>
<Input
v-model="bypassForm.applies_to"
placeholder="npm/[email protected]"
class="w-full"
/>
<p class="text-xs text-gray-500 mt-1">
Limit this CVE bypass to a specific package. Leave empty to apply to all packages with this CVE.
</p>
</div>
<!-- Reason -->
<div>
<label class="text-sm font-medium text-gray-700 mb-2 block">Reason *</label>
<Input
v-model="bypassForm.reason"
placeholder="No fix available, business critical dependency"
class="w-full"
/>
<p class="text-xs text-gray-500 mt-1">
Explain why this bypass is needed (required for audit trail)
</p>
</div>
<!-- Created By -->
<div v-if="!editingBypass">
<label class="text-sm font-medium text-gray-700 mb-2 block">Created By *</label>
<Input
v-model="bypassForm.created_by"
placeholder="[email protected]"
class="w-full"
/>
</div>
<!-- Expires In -->
<div>
<label class="text-sm font-medium text-gray-700 mb-2 block">Expires In (Hours) *</label>
<Input
v-model.number="bypassForm.expires_in_hours"
type="number"
min="1"
placeholder="168"
class="w-full"
/>
<p class="text-xs text-gray-500 mt-1">
How many hours until this bypass expires (e.g., 168 = 7 days, 720 = 30 days)
</p>
</div>
<!-- Active (Edit only) -->
<div v-if="editingBypass" class="flex items-center gap-2">
<input
type="checkbox"
v-model="bypassForm.active"
id="active-checkbox"
class="w-4 h-4"
/>
<label for="active-checkbox" class="text-sm font-medium text-gray-700">
Active
</label>
</div>
<!-- Notify on Expiry -->
<div class="flex items-center gap-2">
<input
type="checkbox"
v-model="bypassForm.notify_on_expiry"
id="notify-checkbox"
class="w-4 h-4"
/>
<label for="notify-checkbox" class="text-sm font-medium text-gray-700">
Send notification when bypass expires
</label>
</div>
</div>
<DialogFooter>
<Button @click="closeCreateModal" variant="outline">
Cancel
</Button>
<Button @click="submitBypass" :disabled="!isFormValid">
{{ editingBypass ? 'Update' : 'Create' }} Bypass
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<!-- Delete Confirmation Dialog -->
<Dialog v-model:open="showDeleteModal">
<DialogContent>
<DialogHeader>
<DialogTitle>Confirm Deletion</DialogTitle>
<DialogDescription>
Are you sure you want to delete this bypass for <strong>{{ bypassToDelete?.target }}</strong>?
This action cannot be undone and the security check will be re-enabled immediately.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button @click="showDeleteModal = false" variant="outline">
Cancel
</Button>
<Button @click="deleteBypass" variant="destructive">
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import axios from 'axios'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
interface Bypass {
id: string
type: 'cve' | 'package'
target: string
reason: string
created_by: string
created_at: string
expires_at: string
applies_to?: string
notify_on_expiry: boolean
active: boolean
}
const loading = ref(false)
const error = ref<string | null>(null)
const successMessage = ref<string | null>(null)
const bypasses = ref<Bypass[]>([])
const activeFilter = ref<'all' | 'active' | 'expired'>('all')
const typeFilter = ref<'' | 'cve' | 'package'>('')
const showCreateModal = ref(false)
const showDeleteModal = ref(false)
const editingBypass = ref<Bypass | null>(null)
const bypassToDelete = ref<Bypass | null>(null)
const bypassForm = ref({
type: 'cve' as 'cve' | 'package',
target: '',
reason: '',
created_by: '',
expires_in_hours: 168,
applies_to: '',
notify_on_expiry: false,
active: true,
})
// Get API key from localStorage or prompt user
const apiKey = ref<string>('')
const filteredBypasses = computed(() => {
let filtered = bypasses.value
// Filter by active/expired
if (activeFilter.value === 'active') {
filtered = filtered.filter(b => b.active && !isExpired(b.expires_at))
} else if (activeFilter.value === 'expired') {
filtered = filtered.filter(b => isExpired(b.expires_at))
}
// Filter by type
if (typeFilter.value) {
filtered = filtered.filter(b => b.type === typeFilter.value)
}
return filtered
})
const isFormValid = computed(() => {
if (editingBypass.value) {
return bypassForm.value.reason.trim() !== '' && bypassForm.value.expires_in_hours > 0
}
return (
bypassForm.value.target.trim() !== '' &&
bypassForm.value.reason.trim() !== '' &&
bypassForm.value.created_by.trim() !== '' &&
bypassForm.value.expires_in_hours > 0
)
})
onMounted(() => {
// Try to get API key from localStorage
apiKey.value = localStorage.getItem('admin_api_key') || ''
if (!apiKey.value) {
promptForApiKey()
} else {
fetchBypasses()
}
})
function promptForApiKey() {
const key = prompt('Enter your admin API key:')
if (key) {
apiKey.value = key
localStorage.setItem('admin_api_key', key)
fetchBypasses()
} else {
error.value = 'Admin API key required to manage bypasses'
}
}
async function fetchBypasses() {
if (!apiKey.value) {
promptForApiKey()
return
}
loading.value = true
error.value = null
try {
const params = new URLSearchParams()
if (activeFilter.value === 'active') {
params.append('active_only', 'true')
} else if (activeFilter.value === 'expired') {
params.append('include_expired', 'true')
}
if (typeFilter.value) {
params.append('type', typeFilter.value)
}
const response = await axios.get('/api/admin/bypasses?' + params.toString(), {
headers: {
Authorization: `Bearer ${apiKey.value}`,
},
})
bypasses.value = response.data.bypasses || []
} catch (err: any) {
console.error('Failed to fetch bypasses:', err)
if (err.response?.status === 401) {
error.value = 'Invalid API key. Please check your credentials.'
localStorage.removeItem('admin_api_key')
apiKey.value = ''
} else {
error.value = err.response?.data?.error?.message || err.message || 'Failed to load bypasses'
}
} finally {
loading.value = false
}
}
async function submitBypass() {
if (!isFormValid.value) return
loading.value = true
error.value = null
successMessage.value = null
try {
if (editingBypass.value) {
// Update existing bypass
await axios.patch(
`/api/admin/bypasses/${editingBypass.value.id}`,
{
active: bypassForm.value.active,
reason: bypassForm.value.reason,
expires_in_hours: bypassForm.value.expires_in_hours,
},
{
headers: {
Authorization: `Bearer ${apiKey.value}`,
},
}
)
successMessage.value = 'Bypass updated successfully'
} else {
// Create new bypass
await axios.post(
'/api/admin/bypasses',
{
type: bypassForm.value.type,
target: bypassForm.value.target,
reason: bypassForm.value.reason,
created_by: bypassForm.value.created_by,
expires_in_hours: bypassForm.value.expires_in_hours,
applies_to: bypassForm.value.applies_to || undefined,
notify_on_expiry: bypassForm.value.notify_on_expiry,
},
{
headers: {
Authorization: `Bearer ${apiKey.value}`,
},
}
)
successMessage.value = 'Bypass created successfully'
}
closeCreateModal()
await fetchBypasses()
// Clear success message after 5 seconds
setTimeout(() => {
successMessage.value = null
}, 5000)
} catch (err: any) {
console.error('Failed to submit bypass:', err)
error.value = err.response?.data?.error?.message || err.message || 'Failed to save bypass'
} finally {
loading.value = false
}
}
function editBypass(bypass: Bypass) {
editingBypass.value = bypass
bypassForm.value = {
type: bypass.type,
target: bypass.target,
reason: bypass.reason,
created_by: bypass.created_by,
expires_in_hours: 168, // Default extension
applies_to: bypass.applies_to || '',
notify_on_expiry: bypass.notify_on_expiry,
active: bypass.active,
}
showCreateModal.value = true
}
function closeCreateModal() {
showCreateModal.value = false
editingBypass.value = null
bypassForm.value = {
type: 'cve',
target: '',
reason: '',
created_by: '',
expires_in_hours: 168,
applies_to: '',
notify_on_expiry: false,
active: true,
}
}
function confirmDeleteBypass(bypass: Bypass) {
bypassToDelete.value = bypass
showDeleteModal.value = true
}
async function deleteBypass() {
if (!bypassToDelete.value) return
loading.value = true
error.value = null
successMessage.value = null
try {
await axios.delete(`/api/admin/bypasses/${bypassToDelete.value.id}`, {
headers: {
Authorization: `Bearer ${apiKey.value}`,
},
})
successMessage.value = 'Bypass deleted successfully'
showDeleteModal.value = false
bypassToDelete.value = null
await fetchBypasses()
// Clear success message after 5 seconds
setTimeout(() => {
successMessage.value = null
}, 5000)
} catch (err: any) {
console.error('Failed to delete bypass:', err)
error.value = err.response?.data?.error?.message || err.message || 'Failed to delete bypass'
} finally {
loading.value = false
}
}
function isExpired(expiresAt: string): boolean {
return new Date(expiresAt) < new Date()
}
function formatDate(date: string): string {
return new Date(date).toLocaleString()
}
</script>
+216
View File
@@ -0,0 +1,216 @@
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,
max_cache_size: 10737418240,
total_downloads: 30,
scanned_packages: 2,
vulnerable_packages: 0,
blocked_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,
max_cache_size: 10737418240,
total_downloads: 0,
scanned_packages: 0,
vulnerable_packages: 0,
blocked_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,
max_cache_size: 10737418240,
total_downloads: 500,
scanned_packages: 0,
vulnerable_packages: 0,
blocked_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
max_cache_size: 10737418240,
total_downloads: 0,
scanned_packages: 0,
vulnerable_packages: 0,
blocked_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')
})
})
+305
View File
@@ -0,0 +1,305 @@
<template>
<div>
<h2 class="text-3xl font-bold text-gray-900 mb-8">Dashboard</h2>
<!-- Error Alert -->
<Alert v-if="error" variant="destructive" class="mb-4">
<i class="fas fa-exclamation-circle mr-2"></i>
<AlertDescription>{{ error }}</AlertDescription>
</Alert>
<!-- Loading State -->
<div v-if="loading" class="text-center py-12">
<i class="fas fa-spinner fa-spin text-4xl text-primary-600"></i>
<p class="mt-4 text-gray-600">Loading statistics...</p>
</div>
<!-- Stats Grid -->
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-10">
<Card class="border-0 shadow-lg hover:shadow-xl transition-shadow">
<CardContent class="p-6">
<div class="flex items-start justify-between">
<div class="space-y-2">
<p class="text-sm font-medium text-muted-foreground">Total Packages</p>
<p class="text-3xl font-bold text-foreground tracking-tight">
{{ formatNumber(stats?.total_packages || 0) }}
</p>
</div>
<div class="w-12 h-12 bg-slate-100 rounded-xl flex items-center justify-center">
<i class="fas fa-boxes text-slate-700 text-lg"></i>
</div>
</div>
</CardContent>
</Card>
<Card class="border-0 shadow-lg hover:shadow-xl transition-shadow">
<CardContent class="p-6">
<div class="flex items-start justify-between">
<div class="space-y-2">
<p class="text-sm font-medium text-muted-foreground">Total Size</p>
<p class="text-3xl font-bold text-foreground tracking-tight">
{{ formatBytes(stats?.total_size || 0) }}
</p>
</div>
<div class="w-12 h-12 bg-sky-50 rounded-xl flex items-center justify-center">
<i class="fas fa-hard-drive text-sky-600 text-lg"></i>
</div>
</div>
</CardContent>
</Card>
<Card class="border-0 shadow-lg hover:shadow-xl transition-shadow">
<CardContent class="p-6">
<div class="flex items-start justify-between">
<div class="space-y-2">
<p class="text-sm font-medium text-muted-foreground">Total Downloads</p>
<p class="text-3xl font-bold text-foreground tracking-tight">
{{ formatNumber(stats?.total_downloads || 0) }}
</p>
</div>
<div class="w-12 h-12 bg-emerald-50 rounded-xl flex items-center justify-center">
<i class="fas fa-download text-emerald-600 text-lg"></i>
</div>
</div>
</CardContent>
</Card>
<Card class="border-0 shadow-lg hover:shadow-xl transition-shadow">
<CardContent class="p-6">
<div class="flex items-start justify-between">
<div class="space-y-2">
<p class="text-sm font-medium text-muted-foreground">Scanned Packages</p>
<p class="text-3xl font-bold text-foreground tracking-tight">
{{ formatNumber(stats?.scanned_packages || 0) }}
</p>
</div>
<div class="w-12 h-12 bg-violet-50 rounded-xl flex items-center justify-center">
<i class="fas fa-shield-alt text-violet-600 text-lg"></i>
</div>
</div>
</CardContent>
</Card>
</div>
<!-- Downloads Chart -->
<Card class="border-0 shadow-lg mb-10">
<CardContent class="p-6">
<div class="flex items-center justify-between mb-6">
<h3 class="text-xl font-semibold text-foreground">Download Activity</h3>
<div class="flex gap-2">
<Button
v-for="period in chartPeriods"
:key="period.value"
@click="selectedPeriod = period.value"
:variant="selectedPeriod === period.value ? 'default' : 'outline'"
size="sm"
>
{{ period.label }}
</Button>
</div>
</div>
<div class="h-64 flex items-end justify-between gap-2">
<div
v-for="(value, index) in chartData"
:key="index"
class="flex-1 flex flex-col items-center gap-2"
>
<div class="w-full bg-slate-100 rounded-t-lg relative" :style="{ height: `${(value / maxChartValue) * 100}%` }">
<div class="absolute inset-0 bg-gradient-to-t from-slate-700 to-slate-500 rounded-t-lg"></div>
</div>
<span class="text-xs text-muted-foreground">{{ getChartLabel(index) }}</span>
</div>
</div>
<div v-if="chartLoading || chartData.length === 0" class="mt-4 text-center">
<p class="text-sm text-muted-foreground">
<i class="fas fa-info-circle mr-1"></i>
{{ chartLoading ? 'Loading chart data...' : 'No download activity in this period' }}
</p>
</div>
</CardContent>
</Card>
<!-- Recent Packages -->
<Card><CardContent class="p-6">
<h3 class="text-xl font-semibold text-gray-900 mb-4">
<i class="fas fa-clock mr-2"></i>Recent Packages
</h3>
<div v-if="packages.length === 0" class="text-center py-8 text-gray-500">
<i class="fas fa-inbox text-4xl mb-4"></i>
<p>No packages cached yet</p>
</div>
<div v-else class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead>
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Package
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Version
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Registry
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Size
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Downloads
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr v-for="pkg in recentPackages" :key="pkg.id">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{{ pkg.name }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ pkg.version }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
<Badge variant="outline" :class="getRegistryBadgeClass(pkg.registry)">
{{ pkg.registry }}
</Badge>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ formatBytes(pkg.size) }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ formatNumber(pkg.download_count) }}
</td>
</tr>
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { storeToRefs } from 'pinia'
import axios from 'axios'
import { usePackageStore } from '../stores/packages'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { getRegistryBadgeClass } from '@/composables/useBadgeStyles'
const store = usePackageStore()
const { packages, stats, loading, error } = storeToRefs(store)
// Chart periods and data
const selectedPeriod = ref<string>('1day')
const chartPeriods = [
{ value: '1h', label: '1 Hour' },
{ value: '1day', label: '24 Hours' },
{ value: '7day', label: '7 Days' },
{ value: '30day', label: '30 Days' },
]
// Time-series data from API
interface TimeSeriesDataPoint {
timestamp: string
value: number
}
interface TimeSeriesStats {
period: string
registry: string
data_points: TimeSeriesDataPoint[]
}
const timeSeriesData = ref<TimeSeriesStats | null>(null)
const chartLoading = ref(false)
// Fetch time-series data from API
async function fetchTimeSeriesData() {
chartLoading.value = true
try {
const response = await axios.get(`/api/stats/timeseries?period=${selectedPeriod.value}`)
timeSeriesData.value = response.data
} catch (err) {
console.error('Failed to fetch time-series data:', err)
timeSeriesData.value = null
} finally {
chartLoading.value = false
}
}
// Extract chart values from time-series data
const chartData = computed(() => {
if (!timeSeriesData.value || !timeSeriesData.value.data_points) {
return []
}
return timeSeriesData.value.data_points.map(point => point.value)
})
const maxChartValue = computed(() => {
if (chartData.value.length === 0) return 100
const max = Math.max(...chartData.value)
return max === 0 ? 100 : max
})
function getChartLabel(index: number): string {
// Use timestamp from data if available
if (timeSeriesData.value && timeSeriesData.value.data_points[index]) {
const date = new Date(timeSeriesData.value.data_points[index].timestamp)
switch (selectedPeriod.value) {
case '1h':
return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
case '1day':
return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
case '7day':
return date.toLocaleDateString('en-US', { weekday: 'short' })
case '30day':
return date.toLocaleDateString('en-US', { day: 'numeric' })
default:
return `${index}`
}
}
// Fallback to index-based labels
const labels: Record<string, (i: number) => string> = {
'1h': (i) => `${i * 5}m`,
'1day': (i) => `${i}:00`,
'7day': (i) => ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][i] || `Day ${i + 1}`,
'30day': (i) => `${i + 1}`,
}
return labels[selectedPeriod.value]?.(index) || `${index}`
}
// API returns clean, deduplicated data - just sort and limit
const recentPackages = computed(() => {
return packages.value
.slice()
.sort((a, b) => new Date(b.cached_at).getTime() - new Date(a.cached_at).getTime())
.slice(0, 10)
})
// Watch for period changes and fetch new data
watch(selectedPeriod, () => {
fetchTimeSeriesData()
})
onMounted(async () => {
await store.fetchStats()
await store.fetchPackages()
await fetchTimeSeriesData()
})
function formatNumber(num: number): string {
return new Intl.NumberFormat().format(num)
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
}
</script>
+416
View File
@@ -0,0 +1,416 @@
<template>
<div class="max-w-7xl mx-auto px-4 py-8">
<!-- Header with Back Button -->
<div class="mb-6">
<Button @click="goBack" variant="ghost" class="mb-4">
<i class="fas fa-arrow-left mr-2"></i>
Back to Packages
</Button>
<div class="flex items-center gap-3">
<i class="fas fa-box text-3xl text-primary-600"></i>
<div>
<h1 class="text-3xl font-bold text-gray-900">{{ packageName }}</h1>
<div class="flex items-center gap-2 mt-2">
<Badge :class="getRegistryBadgeClass(registry)">{{ registry }}</Badge>
<Badge variant="outline" class="font-mono">v{{ version }}</Badge>
</div>
</div>
</div>
</div>
<!-- Loading State -->
<div v-if="loading" class="text-center py-12">
<i class="fas fa-spinner fa-spin text-4xl text-primary-600"></i>
<p class="mt-4 text-gray-600">Loading vulnerability details...</p>
</div>
<!-- Error State -->
<Alert v-else-if="error" variant="destructive" class="mb-4">
<i class="fas fa-exclamation-circle mr-2"></i>
<AlertDescription>{{ error }}</AlertDescription>
</Alert>
<!-- Vulnerability Details -->
<div v-else-if="vulnerabilities" class="space-y-6">
<!-- Summary Card -->
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<i class="fas fa-shield-virus text-red-600"></i>
Security Scan Summary
</CardTitle>
</CardHeader>
<CardContent>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
<div class="text-center p-4 bg-red-50 rounded-lg border border-red-200">
<p class="text-3xl font-bold text-red-600">{{ severityCounts.critical }}</p>
<p class="text-sm text-gray-600 mt-1">Critical</p>
</div>
<div class="text-center p-4 bg-orange-50 rounded-lg border border-orange-200">
<p class="text-3xl font-bold text-orange-600">{{ severityCounts.high }}</p>
<p class="text-sm text-gray-600 mt-1">High</p>
</div>
<div class="text-center p-4 bg-yellow-50 rounded-lg border border-yellow-200">
<p class="text-3xl font-bold text-yellow-600">{{ severityCounts.moderate }}</p>
<p class="text-sm text-gray-600 mt-1">Moderate</p>
</div>
<div class="text-center p-4 bg-blue-50 rounded-lg border border-blue-200">
<p class="text-3xl font-bold text-blue-600">{{ severityCounts.low }}</p>
<p class="text-sm text-gray-600 mt-1">Low</p>
</div>
</div>
<Separator class="my-4" />
<div class="flex items-center justify-between text-sm">
<div class="flex flex-col gap-3">
<span class="text-gray-600">
<i class="fas fa-search mr-1"></i>
Scanned: {{ formatDate(vulnerabilities.scanned_at) }}
</span>
<div class="flex items-center gap-2">
<span class="text-gray-600">
<i class="fas fa-cog mr-1"></i>
Scanners:
</span>
<div class="flex flex-wrap gap-1">
<Badge
v-for="scanner in scannerList"
:key="scanner"
variant="secondary"
class="text-xs"
>
{{ scanner }}
</Badge>
</div>
</div>
</div>
<div v-if="bypassedCount > 0" class="flex items-center gap-2 text-green-600">
<i class="fas fa-check-circle"></i>
<span>{{ bypassedCount }} bypassed</span>
</div>
</div>
</CardContent>
</Card>
<!-- No Vulnerabilities -->
<Card v-if="vulnerabilityList.length === 0">
<CardContent class="text-center py-12">
<i class="fas fa-check-circle text-6xl text-green-500 mb-4"></i>
<p class="text-xl font-semibold text-gray-900">No Vulnerabilities Found</p>
<p class="mt-2 text-gray-600">This package is clean and safe to use</p>
</CardContent>
</Card>
<!-- Vulnerability List -->
<Card v-else>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<i class="fas fa-list text-gray-600"></i>
Detected Vulnerabilities ({{ vulnerabilityList.length }})
</CardTitle>
</CardHeader>
<CardContent class="p-0">
<div class="border-t overflow-x-auto">
<Table class="min-w-[800px]">
<TableHeader>
<TableRow class="bg-gray-50 hover:bg-gray-50">
<TableHead class="w-[100px]">Severity</TableHead>
<TableHead class="w-[180px]">CVE ID</TableHead>
<TableHead>Description</TableHead>
<TableHead class="w-[120px]">Fix Version</TableHead>
<TableHead class="w-[100px] text-center">Details</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<template v-for="(vuln, index) in vulnerabilityList" :key="vuln.id">
<!-- Main Row -->
<TableRow
:class="getRowClass(index, vuln.severity)"
@click="toggleRow(index)"
>
<TableCell>
<Badge :class="getSeverityBadgeClass(vuln.severity)">
{{ formatSeverityName(vuln.severity) }}
</Badge>
<span
v-if="vuln.bypassed"
class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800"
title="Bypassed"
>
<i class="fas fa-unlock text-xs"></i>
</span>
</TableCell>
<TableCell>
<span class="font-mono text-sm font-medium">{{ vuln.id }}</span>
</TableCell>
<TableCell>
<p class="text-sm text-gray-900 line-clamp-2 break-words">{{ vuln.title || vuln.description }}</p>
</TableCell>
<TableCell>
<span v-if="vuln.fixed_in" class="inline-flex items-center text-sm text-green-700">
<i class="fas fa-arrow-up text-xs mr-1"></i>
v{{ vuln.fixed_in }}
</span>
<span v-else class="text-sm text-gray-400">-</span>
</TableCell>
<TableCell class="text-center">
<Button
variant="ghost"
size="sm"
class="h-8 w-8 p-0"
@click.stop="toggleRow(index)"
>
<i
:class="[
'fas transition-transform',
expandedRows.has(index) ? 'fa-chevron-up' : 'fa-chevron-down'
]"
></i>
</Button>
</TableCell>
</TableRow>
<!-- Expanded Details Row -->
<TableRow v-if="expandedRows.has(index)" class="bg-gray-50 hover:bg-gray-50">
<TableCell colspan="5" class="p-0">
<div class="px-6 py-4 space-y-3">
<!-- Full Description -->
<div>
<h5 class="font-semibold text-gray-900 mb-2">Description</h5>
<div
class="text-sm text-gray-700 leading-relaxed prose prose-sm max-w-none break-words overflow-hidden"
v-html="renderMarkdown(vuln.description)"
></div>
</div>
<!-- Bypass Information -->
<div v-if="vuln.bypassed && vuln.bypass" class="bg-green-50 border border-green-200 rounded-lg p-3">
<h5 class="font-semibold text-green-900 mb-2 flex items-center gap-2">
<i class="fas fa-info-circle"></i>
Bypass Active
</h5>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm text-green-800">
<div class="break-words">
<span class="font-medium">Reason:</span> {{ vuln.bypass.reason }}
</div>
<div class="break-words">
<span class="font-medium">By:</span> {{ vuln.bypass.created_by }}
</div>
<div class="break-words">
<span class="font-medium">Expires:</span> {{ formatDate(vuln.bypass.expires_at) }}
</div>
</div>
</div>
<!-- Fix Information -->
<div v-if="vuln.fixed_in" class="bg-blue-50 border border-blue-200 rounded-lg p-3">
<div class="flex items-center gap-2">
<i class="fas fa-wrench text-blue-700"></i>
<span class="text-sm text-blue-900">
<span class="font-semibold">Fix Available:</span> Upgrade to version <strong>{{ vuln.fixed_in }}</strong> or later
</span>
</div>
</div>
<!-- References -->
<div v-if="vuln.references && vuln.references.length > 0">
<h5 class="font-semibold text-gray-900 mb-2 flex items-center gap-2">
<i class="fas fa-link"></i>
References ({{ vuln.references.length }})
</h5>
<div class="space-y-1.5">
<div
v-for="(ref, refIndex) in vuln.references"
:key="refIndex"
class="flex items-start gap-2 text-sm"
>
<i class="fas fa-external-link-alt text-gray-400 text-xs mt-0.5"></i>
<a
:href="ref"
target="_blank"
rel="noopener noreferrer"
class="text-blue-600 hover:text-blue-800 hover:underline break-all"
>
{{ ref }}
</a>
</div>
</div>
</div>
</div>
</TableCell>
</TableRow>
</template>
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import axios from 'axios'
import { marked } from 'marked'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Separator } from '@/components/ui/separator'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
getSeverityBadgeClass,
getRegistryBadgeClass,
getVulnerabilityBorderClass,
formatSeverityName,
} from '@/composables/useBadgeStyles'
// Configure marked
marked.setOptions({
breaks: true,
gfm: true,
})
const router = useRouter()
const route = useRoute()
const registry = computed(() => route.params.registry as string)
const packageName = computed(() => {
// Handle package names with slashes (e.g., Go packages like github.com/user/repo)
const nameParam = route.params.name
if (Array.isArray(nameParam)) {
return nameParam.join('/')
}
return nameParam as string
})
const version = computed(() => route.params.version as string)
interface BypassInfo {
id: string
reason: string
created_by: string
expires_at: string
}
interface Vulnerability {
id: string
severity: string
title: string
description: string
references: string[]
fixed_in: string
bypassed: boolean
bypass?: BypassInfo
}
interface VulnerabilityResponse {
scanned: boolean
scanner: string
scanned_at: string
status: string
vulnerabilities: Vulnerability[]
vulnerability_count: number
severity_counts: {
critical: number
high: number
moderate: number
low: number
}
bypassed_count: number
}
const loading = ref(false)
const error = ref<string | null>(null)
const vulnerabilities = ref<VulnerabilityResponse | null>(null)
const expandedRows = ref<Set<number>>(new Set())
// Severity order for sorting (higher values = more severe)
const severityOrder: Record<string, number> = {
CRITICAL: 4,
HIGH: 3,
MEDIUM: 2,
MODERATE: 2, // Treat MODERATE same as MEDIUM
LOW: 1,
UNKNOWN: 0,
}
const vulnerabilityList = computed(() => {
const vulns = vulnerabilities.value?.vulnerabilities || []
// Sort by severity (most severe first)
return [...vulns].sort((a, b) => {
const severityA = severityOrder[a.severity.toUpperCase()] || 0
const severityB = severityOrder[b.severity.toUpperCase()] || 0
return severityB - severityA // Descending order
})
})
const severityCounts = computed(() => vulnerabilities.value?.severity_counts || { critical: 0, high: 0, moderate: 0, low: 0 })
const bypassedCount = computed(() => vulnerabilities.value?.bypassed_count || 0)
// Parse scanner string into array of scanner names
const scannerList = computed(() => {
if (!vulnerabilities.value?.scanner) return []
return vulnerabilities.value.scanner.split('+').map((s: string) => s.trim())
})
onMounted(() => {
fetchVulnerabilities()
})
async function fetchVulnerabilities() {
loading.value = true
error.value = null
vulnerabilities.value = null
try {
const response = await axios.get(
`/api/packages/${registry.value}/${packageName.value}/${version.value}/vulnerabilities`
)
// Store the response data
vulnerabilities.value = response.data
} catch (err: any) {
console.error('Failed to fetch vulnerabilities:', err)
error.value = err.response?.data?.error || err.message || 'Failed to load vulnerability details'
} finally {
loading.value = false
}
}
function goBack() {
router.push('/packages')
}
function toggleRow(index: number) {
if (expandedRows.value.has(index)) {
expandedRows.value.delete(index)
} else {
expandedRows.value.add(index)
}
}
function getRowClass(index: number, severity: string): string {
const classes = ['cursor-pointer']
if (expandedRows.value.has(index)) {
classes.push('bg-gray-50')
}
classes.push(getVulnerabilityBorderClass(severity))
return classes.join(' ')
}
function formatDate(date: string): string {
return new Date(date).toLocaleString()
}
function renderMarkdown(text: string): string {
if (!text) return ''
return marked.parse(text) as string
}
</script>
+187
View File
@@ -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
})
})
+419
View File
@@ -0,0 +1,419 @@
<template>
<div>
<div class="flex justify-between items-center mb-8">
<h2 class="text-3xl font-bold text-gray-900">Packages</h2>
<Button @click="store.fetchPackages()">
<i class="fas fa-sync-alt mr-2"></i>Refresh
</Button>
</div>
<!-- Filter and Search Section -->
<div class="mb-6 flex flex-col sm:flex-row items-start sm:items-center gap-4">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-gray-700">Filter by registry:</span>
<div class="flex gap-2">
<Button
@click="changeRegistryFilter('all')"
:variant="selectedRegistry === 'all' ? 'default' : 'outline'"
size="sm"
>
All
</Button>
<Button
@click="changeRegistryFilter('npm')"
:variant="selectedRegistry === 'npm' ? 'default' : 'outline'"
size="sm"
>
<i class="fab fa-npm mr-2"></i>NPM
</Button>
<Button
@click="changeRegistryFilter('pypi')"
:variant="selectedRegistry === 'pypi' ? 'default' : 'outline'"
size="sm"
>
<i class="fab fa-python mr-2"></i>PyPI
</Button>
<Button
@click="changeRegistryFilter('go')"
:variant="selectedRegistry === 'go' ? 'default' : 'outline'"
size="sm"
>
<i class="fas fa-code mr-2"></i>Go
</Button>
</div>
</div>
<div class="flex items-center gap-2 w-full sm:w-auto sm:ml-auto">
<i class="fas fa-search text-gray-500"></i>
<Input
v-model="searchTerm"
type="text"
placeholder="Search packages..."
class="w-full sm:w-64"
/>
</div>
</div>
<!-- Error Alert -->
<Alert v-if="error" variant="destructive" class="mb-4">
<i class="fas fa-exclamation-circle mr-2"></i>
<AlertDescription>{{ error }}</AlertDescription>
</Alert>
<!-- Loading State -->
<div v-if="loading" class="text-center py-12">
<i class="fas fa-spinner fa-spin text-4xl text-primary-600"></i>
<p class="mt-4 text-gray-600">Loading packages...</p>
</div>
<!-- Package List -->
<Card v-else>
<CardContent class="p-6">
<div v-if="packages.length === 0" class="text-center py-12 text-gray-500">
<i class="fas fa-inbox text-6xl mb-4"></i>
<p class="text-xl">No packages cached yet</p>
<p class="mt-2">Packages will appear here once they are downloaded through the proxy</p>
</div>
<Accordion v-else type="multiple" class="w-full">
<AccordionItem
v-for="group in groupedPackages"
:key="`${group.registry}:${group.name}`"
:value="`${group.registry}:${group.name}`"
class="border-b border-gray-200"
>
<AccordionTrigger class="px-4 py-4 hover:bg-gray-50">
<div class="flex items-center justify-between w-full pr-4">
<div class="flex items-center space-x-4">
<div class="text-left">
<h4 class="font-semibold text-gray-900">{{ group.name }}</h4>
<p class="text-sm text-gray-500">{{ group.versions.length }} version{{ group.versions.length > 1 ? 's' : '' }}</p>
</div>
</div>
<div class="flex items-center space-x-6">
<Badge variant="outline" :class="getRegistryBadgeClass(group.registry)">
{{ group.registry }}
</Badge>
<div class="text-right">
<p class="text-sm font-medium text-gray-900">{{ formatBytes(group.totalSize) }}</p>
<p class="text-xs text-gray-500">{{ formatNumber(group.totalDownloads) }} downloads</p>
</div>
</div>
</div>
</AccordionTrigger>
<AccordionContent class="px-4 pb-4">
<div class="space-y-3">
<div
v-for="version in group.versions"
:key="version.id"
class="flex items-center justify-between p-4 bg-gray-50 rounded-lg hover:bg-gray-100"
>
<div class="flex items-center space-x-4 flex-1">
<div class="flex-1">
<p class="font-medium text-gray-900">{{ version.version.startsWith('v') ? version.version : 'v' + version.version }}</p>
<div class="flex items-center space-x-4 mt-1 text-sm text-gray-500">
<span>
<i class="fas fa-download mr-1"></i>{{ formatNumber(version.download_count) }}
</span>
<span>
<i class="fas fa-hard-drive mr-1"></i>{{ formatBytes(version.size) }}
</span>
<span>
<i class="fas fa-clock mr-1"></i>{{ formatDate(version.cached_at) }}
</span>
</div>
<!-- Vulnerability Badge -->
<div v-if="version.vulnerabilities" class="mt-2">
<VulnerabilityBadge
:scanned="version.vulnerabilities.scanned"
:status="version.vulnerabilities.status"
:counts="version.vulnerabilities.counts"
:total="version.vulnerabilities.total"
:scannedAt="version.vulnerabilities.scannedAt"
:isBlocked="version.vulnerabilities.isBlocked"
@click="showVulnerabilityDetails(group.registry, group.name, version.version)"
/>
</div>
</div>
</div>
<button
@click="confirmDelete(version)"
class="text-red-600 hover:text-red-900 p-2"
title="Delete this version"
>
<i class="fas fa-trash"></i>
</button>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
<!-- Pagination -->
<div v-if="totalPages > 1" class="mt-6 flex items-center justify-between border-t pt-4">
<div class="text-sm text-gray-700">
Page {{ currentPage }} of {{ totalPages }} ({{ allGroupedPackages.length }} total packages)
</div>
<div class="flex items-center gap-2">
<Button
@click="changePage(currentPage - 1)"
:disabled="currentPage === 1"
variant="outline"
size="sm"
>
<i class="fas fa-chevron-left mr-2"></i>Previous
</Button>
<div class="flex gap-1">
<!-- First page -->
<Button
v-if="currentPage > 3"
@click="changePage(1)"
variant="outline"
size="sm"
>
1
</Button>
<span v-if="currentPage > 4" class="px-2">...</span>
<!-- Page numbers around current page -->
<Button
v-for="page in getPageNumbers()"
:key="page"
@click="changePage(page)"
:variant="page === currentPage ? 'default' : 'outline'"
size="sm"
>
{{ page }}
</Button>
<span v-if="currentPage < totalPages - 3" class="px-2">...</span>
<!-- Last page -->
<Button
v-if="currentPage < totalPages - 2"
@click="changePage(totalPages)"
variant="outline"
size="sm"
>
{{ totalPages }}
</Button>
</div>
<Button
@click="changePage(currentPage + 1)"
:disabled="currentPage === totalPages"
variant="outline"
size="sm"
>
Next<i class="fas fa-chevron-right ml-2"></i>
</Button>
</div>
</div>
</CardContent>
</Card>
<!-- Delete Confirmation Dialog -->
<Dialog v-model:open="showDeleteModal">
<DialogContent>
<DialogHeader>
<DialogTitle>Confirm Deletion</DialogTitle>
<DialogDescription>
Are you sure you want to delete <strong>{{ packageToDelete?.name }}@{{ packageToDelete?.version }}</strong>?
This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button @click="showDeleteModal = false" variant="outline">
Cancel
</Button>
<Button @click="deletePackage" variant="destructive">
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref, computed, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { useRoute, useRouter } from 'vue-router'
import { usePackageStore, type Package } from '../stores/packages'
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import VulnerabilityBadge from './VulnerabilityBadge.vue'
import { getRegistryBadgeClass } from '@/composables/useBadgeStyles'
// Props from router
const props = defineProps<{
registry?: string
}>()
const route = useRoute()
const router = useRouter()
const store = usePackageStore()
const { packages, loading, error } = storeToRefs(store)
const showDeleteModal = ref(false)
const packageToDelete = ref<Package | null>(null)
const selectedRegistry = ref<string>(props.registry || 'all')
const searchTerm = ref<string>('')
const currentPage = ref<number>(1)
const itemsPerPage = ref<number>(10)
// Group packages by name
const allGroupedPackages = computed(() => {
const groups = new Map<string, Package[]>()
// Filter packages by selected registry and search term
let filteredPackages = selectedRegistry.value === 'all'
? packages.value
: packages.value.filter(pkg => pkg.registry === selectedRegistry.value)
// Apply search filter if search term exists
if (searchTerm.value.trim()) {
const searchLower = searchTerm.value.toLowerCase()
filteredPackages = filteredPackages.filter(pkg =>
pkg.name.toLowerCase().includes(searchLower) ||
pkg.version.toLowerCase().includes(searchLower)
)
}
filteredPackages.forEach(pkg => {
const key = `${pkg.registry}:${pkg.name}`
if (!groups.has(key)) {
groups.set(key, [])
}
groups.get(key)!.push(pkg)
})
// Convert to array and sort versions within each group
return Array.from(groups.entries()).map(([key, versions]) => {
const [registry, name] = key.split(':')
return {
registry,
name,
versions: versions.sort((a, b) =>
new Date(b.cached_at).getTime() - new Date(a.cached_at).getTime()
),
totalSize: versions.reduce((sum, v) => sum + v.size, 0),
totalDownloads: versions.reduce((sum, v) => sum + v.download_count, 0),
}
}).sort((a, b) => a.name.localeCompare(b.name))
})
// Pagination
const totalPages = computed(() => Math.ceil(allGroupedPackages.value.length / itemsPerPage.value))
const groupedPackages = computed(() => {
const start = (currentPage.value - 1) * itemsPerPage.value
const end = start + itemsPerPage.value
return allGroupedPackages.value.slice(start, end)
})
function changePage(page: number) {
if (page >= 1 && page <= totalPages.value) {
currentPage.value = page
// Scroll to top of packages list
window.scrollTo({ top: 0, behavior: 'smooth' })
}
}
// Get page numbers to display around current page
function getPageNumbers(): number[] {
const pages: number[] = []
const start = Math.max(1, currentPage.value - 2)
const end = Math.min(totalPages.value, currentPage.value + 2)
for (let i = start; i <= end; i++) {
pages.push(i)
}
return pages
}
// Reset to page 1 when filters change
function resetPagination() {
currentPage.value = 1
}
// Watch for filter/search changes and reset pagination
watch([selectedRegistry, searchTerm], () => {
resetPagination()
})
// Watch for route parameter changes to update filter
watch(() => route.params.registry, (newRegistry) => {
selectedRegistry.value = (newRegistry as string) || 'all'
})
onMounted(async () => {
await store.fetchPackages()
})
function confirmDelete(pkg: Package) {
packageToDelete.value = pkg
showDeleteModal.value = true
}
async function deletePackage() {
if (packageToDelete.value) {
await store.deletePackage(
packageToDelete.value.registry,
packageToDelete.value.name,
packageToDelete.value.version
)
showDeleteModal.value = false
packageToDelete.value = null
}
}
function formatNumber(num: number): string {
return new Intl.NumberFormat().format(num)
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]
}
function formatDate(date: string): string {
return new Date(date).toLocaleString()
}
function showVulnerabilityDetails(registry: string, name: string, version: string) {
// Navigate to the package details page
router.push({
name: 'package-details',
params: {
registry,
name,
version,
},
})
}
function changeRegistryFilter(registry: string) {
const path = registry === 'all' ? '/packages' : `/packages/${registry}`
router.push(path)
}
</script>
+204
View File
@@ -0,0 +1,204 @@
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
max_cache_size: 10737418240,
total_downloads: 500,
scanned_packages: 90,
vulnerable_packages: 5,
blocked_packages: 0,
}
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,
max_cache_size: 10737418240,
total_downloads: 500,
scanned_packages: 90,
vulnerable_packages: 5,
blocked_packages: 0,
}
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,
max_cache_size: 10737418240,
total_downloads: 500,
scanned_packages: 90,
vulnerable_packages: 5,
blocked_packages: 0,
}
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
max_cache_size: 10737418240,
total_downloads: 500,
scanned_packages: 90,
vulnerable_packages: 5,
blocked_packages: 0,
}
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,
max_cache_size: 10737418240,
total_downloads: 0,
scanned_packages: 0,
vulnerable_packages: 0,
blocked_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,
max_cache_size: 10737418240,
total_downloads: 0,
scanned_packages: 0,
vulnerable_packages: 0,
blocked_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
})
})
+239
View File
@@ -0,0 +1,239 @@
<template>
<div>
<h2 class="text-3xl font-bold text-gray-900 mb-8">Statistics</h2>
<!-- Error Alert -->
<Alert v-if="error" variant="destructive" class="mb-4">
<i class="fas fa-exclamation-circle mr-2"></i>
<AlertDescription>{{ error }}</AlertDescription>
</Alert>
<!-- Loading State -->
<div v-if="loading" class="text-center py-12">
<i class="fas fa-spinner fa-spin text-4xl text-primary-600"></i>
<p class="mt-4 text-gray-600">Loading statistics...</p>
</div>
<!-- Stats Cards -->
<div v-else>
<!-- Overall Stats -->
<Card class="mb-8">
<CardContent class="p-6">
<h3 class="text-xl font-semibold text-gray-900 mb-6">
<i class="fas fa-chart-bar mr-2"></i>Overall Statistics
</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="text-center p-6 bg-gray-50 rounded-lg">
<p class="text-4xl font-bold text-primary-600 mb-2">
{{ formatNumber(stats?.total_packages || 0) }}
</p>
<p class="text-sm text-gray-600">Total Packages</p>
</div>
<div class="p-6 bg-gray-50 rounded-lg">
<div class="text-center mb-3">
<p class="text-2xl font-bold text-blue-600">
{{ formatBytes(stats?.total_size || 0) }} / {{ formatBytes(stats?.max_cache_size || 0) }}
</p>
<p class="text-sm text-gray-600 mt-1">Storage Used</p>
</div>
<div class="w-full bg-gray-200 rounded-full h-3 overflow-hidden">
<div
class="h-full bg-blue-600 rounded-full transition-all duration-300"
:style="{ width: storagePercentage + '%' }"
:class="{
'bg-green-600': storagePercentage < 50,
'bg-yellow-600': storagePercentage >= 50 && storagePercentage < 80,
'bg-orange-600': storagePercentage >= 80 && storagePercentage < 90,
'bg-red-600': storagePercentage >= 90
}"
></div>
</div>
<p class="text-xs text-gray-500 text-center mt-1">{{ storagePercentage.toFixed(1) }}% used</p>
</div>
<div class="text-center p-6 bg-gray-50 rounded-lg">
<p class="text-4xl font-bold text-green-600 mb-2">
{{ formatNumber(stats?.total_downloads || 0) }}
</p>
<p class="text-sm text-gray-600">Total Downloads</p>
</div>
</div>
</CardContent>
</Card>
<!-- Security Stats -->
<Card class="mb-8">
<CardContent class="p-6">
<h3 class="text-xl font-semibold text-gray-900 mb-6">
<i class="fas fa-shield-alt mr-2"></i>Security Scanning
</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="flex items-center justify-between p-6 bg-green-50 rounded-lg border border-green-200">
<div>
<p class="text-3xl font-bold text-green-600">
{{ formatNumber(stats?.scanned_packages || 0) }}
</p>
<p class="text-sm text-gray-600 mt-1">Scanned Packages</p>
</div>
<i class="fas fa-check-circle text-5xl text-green-400"></i>
</div>
<div
@click="showVulnerablePackages"
class="flex items-center justify-between p-6 bg-orange-50 rounded-lg border border-orange-200 cursor-pointer hover:bg-orange-100 transition-colors"
:class="{ 'opacity-50': (stats?.vulnerable_packages || 0) === 0 }"
>
<div>
<p class="text-3xl font-bold text-orange-600">
{{ formatNumber(stats?.vulnerable_packages || 0) }}
</p>
<p class="text-sm text-gray-600 mt-1">
Vulnerable Packages
<span v-if="(stats?.vulnerable_packages || 0) > 0" class="text-xs ml-1">(click to view)</span>
</p>
</div>
<i class="fas fa-exclamation-triangle text-5xl text-orange-400"></i>
</div>
<div
@click="showBlockedPackages"
class="flex items-center justify-between p-6 bg-red-50 rounded-lg border border-red-200 cursor-pointer hover:bg-red-100 transition-colors"
:class="{ 'opacity-50': (stats?.blocked_packages || 0) === 0 }"
>
<div>
<p class="text-3xl font-bold text-red-600">
{{ formatNumber(stats?.blocked_packages || 0) }}
</p>
<p class="text-sm text-gray-600 mt-1">
Blocked Packages
<span v-if="(stats?.blocked_packages || 0) > 0" class="text-xs ml-1">(click to view)</span>
</p>
</div>
<i class="fas fa-hand text-5xl text-red-400"></i>
</div>
</div>
</CardContent>
</Card>
<!-- Registry Breakdown -->
<Card>
<CardContent class="p-6">
<h3 class="text-xl font-semibold text-gray-900 mb-6">
<i class="fas fa-server mr-2"></i>Registry Breakdown
</h3>
<div class="space-y-4">
<div
v-for="registry in registries"
:key="registry.name"
class="flex items-center justify-between p-4 bg-gray-50 rounded-lg"
>
<div class="flex items-center space-x-4">
<div
class="w-12 h-12 rounded-full flex items-center justify-center"
:class="registry.color"
>
<i :class="registry.icon + ' text-2xl'"></i>
</div>
<div>
<p class="font-bold text-gray-900">{{ registry.label }}</p>
<p class="text-sm text-gray-600">{{ registry.packages }} packages</p>
</div>
</div>
<div class="text-right">
<p class="text-lg font-bold text-gray-900">{{ registry.size }}</p>
<p class="text-sm text-gray-600">{{ registry.downloads }} downloads</p>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useRouter } from 'vue-router'
import { usePackageStore } from '../stores/packages'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Card, CardContent } from '@/components/ui/card'
const store = usePackageStore()
const { stats, loading, error } = storeToRefs(store)
const router = useRouter()
onMounted(async () => {
await store.fetchStats()
})
function showVulnerablePackages() {
if ((stats.value?.vulnerable_packages || 0) === 0) {
return
}
router.push('/vulnerable-packages')
}
function showBlockedPackages() {
if ((stats.value?.blocked_packages || 0) === 0) {
return
}
router.push('/blocked-packages')
}
// Registry configuration for icons and colors
const registryConfig: Record<string, {label: string, icon: string, color: string}> = {
npm: {
label: 'NPM Registry',
icon: 'fab fa-npm text-red-500',
color: 'bg-red-100'
},
pypi: {
label: 'PyPI Registry',
icon: 'fab fa-python text-blue-500',
color: 'bg-blue-100'
},
go: {
label: 'Go Modules',
icon: 'fas fa-code text-cyan-500',
color: 'bg-cyan-100'
}
}
const registries = computed(() => {
const apiRegistries = store.registries || {}
return Object.entries(apiRegistries).map(([name, data]: [string, any]) => {
const config = registryConfig[name] || {
label: name.toUpperCase(),
icon: 'fas fa-box',
color: 'bg-gray-100'
}
return {
name,
label: config.label,
icon: config.icon,
color: config.color,
packages: data.count || 0,
size: formatBytes(data.size || 0),
downloads: data.downloads || 0
}
})
})
const storagePercentage = computed(() => {
const totalSize = stats.value?.total_size || 0
const maxSize = stats.value?.max_cache_size || 1
return (totalSize / maxSize) * 100
})
function formatNumber(num: number): string {
return new Intl.NumberFormat().format(num)
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]
}
</script>
@@ -0,0 +1,141 @@
<template>
<div class="flex items-center gap-2">
<!-- Blocked Icon (if package exceeds thresholds) -->
<span
v-if="isBlocked"
class="inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium bg-red-600 text-white border border-red-700"
title="Download blocked - exceeds vulnerability thresholds"
>
<i class="fas fa-hand mr-1"></i>
BLOCKED
</span>
<!-- Critical Vulnerabilities -->
<button
v-if="counts.critical > 0"
@click="handleClick('critical')"
class="inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium bg-red-100 text-red-800 hover:bg-red-200 border border-red-300 transition-colors cursor-pointer"
:title="`${counts.critical} critical vulnerabilities - click for details`"
>
<i class="fas fa-shield-virus mr-1"></i>
CRITICAL: {{ counts.critical }}
</button>
<!-- High Vulnerabilities -->
<button
v-if="counts.high > 0"
@click="handleClick('high')"
class="inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium bg-orange-100 text-orange-800 hover:bg-orange-200 border border-orange-300 transition-colors cursor-pointer"
:title="`${counts.high} high severity vulnerabilities - click for details`"
>
<i class="fas fa-exclamation-triangle mr-1"></i>
HIGH: {{ counts.high }}
</button>
<!-- Moderate Vulnerabilities -->
<button
v-if="counts.moderate > 0"
@click="handleClick('moderate')"
class="inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium bg-yellow-100 text-yellow-800 hover:bg-yellow-200 border border-yellow-300 transition-colors cursor-pointer"
:title="`${counts.moderate} moderate severity vulnerabilities - click for details`"
>
<i class="fas fa-exclamation-circle mr-1"></i>
MODERATE: {{ counts.moderate }}
</button>
<!-- Low Vulnerabilities -->
<button
v-if="counts.low > 0"
@click="handleClick('low')"
class="inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium bg-blue-100 text-blue-800 hover:bg-blue-200 border border-blue-300 transition-colors cursor-pointer"
:title="`${counts.low} low severity vulnerabilities - click for details`"
>
<i class="fas fa-info-circle mr-1"></i>
LOW: {{ counts.low }}
</button>
<!-- Clean Badge (no vulnerabilities) -->
<span
v-if="status === 'clean' && total === 0"
class="inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium bg-green-100 text-green-800 border border-green-300"
:title="scannedAt ? `No vulnerabilities found - Scanned ${formatTimestamp(scannedAt)}` : 'No vulnerabilities found'"
>
<i class="fas fa-check-circle mr-1"></i>
CLEAN
<span v-if="scannedAt" class="ml-1 text-[10px] opacity-70">
({{ formatRelativeTime(scannedAt) }})
</span>
</span>
<!-- Pending Badge -->
<span
v-if="status === 'pending'"
class="inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium bg-gray-100 text-gray-700 border border-gray-300"
title="Security scan in progress"
>
<i class="fas fa-spinner fa-spin mr-1"></i>
SCANNING...
</span>
<!-- Not Scanned Badge -->
<span
v-if="status === 'not_scanned' || !scanned"
class="inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium bg-gray-100 text-gray-600 border border-gray-300"
title="Not yet scanned for vulnerabilities"
>
<i class="fas fa-question-circle mr-1"></i>
NOT SCANNED
</span>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { VulnerabilityCounts } from '../stores/packages'
interface Props {
scanned?: boolean
status?: 'clean' | 'vulnerable' | 'pending' | 'not_scanned'
counts?: VulnerabilityCounts
total?: number
scannedAt?: string // ISO 8601 timestamp
isBlocked?: boolean // Whether download is blocked due to vulnerabilities
}
const props = withDefaults(defineProps<Props>(), {
scanned: false,
status: 'not_scanned',
total: 0,
isBlocked: false,
})
const emit = defineEmits<{
click: [severity: string]
}>()
const counts = computed(() => props.counts || { critical: 0, high: 0, moderate: 0, low: 0 })
function handleClick(severity: string) {
emit('click', severity)
}
function formatTimestamp(timestamp: string): string {
const date = new Date(timestamp)
return date.toLocaleString()
}
function formatRelativeTime(timestamp: string): string {
const date = new Date(timestamp)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMs / 3600000)
const diffDays = Math.floor(diffMs / 86400000)
if (diffMins < 1) return 'just now'
if (diffMins < 60) return `${diffMins}m ago`
if (diffHours < 24) return `${diffHours}h ago`
if (diffDays < 7) return `${diffDays}d ago`
return formatTimestamp(timestamp).split(',')[0] // Just the date part
}
</script>
@@ -0,0 +1,321 @@
<template>
<div class="max-w-7xl mx-auto px-4 py-8">
<!-- Header -->
<div class="mb-6">
<Button @click="goBack" variant="ghost" class="mb-4">
<i class="fas fa-arrow-left mr-2"></i>
Back to Stats
</Button>
<div class="flex items-center gap-3">
<i :class="showOnlyBlocked ? 'fas fa-hand' : 'fas fa-exclamation-triangle'" class="text-3xl text-red-600"></i>
<div>
<h1 class="text-3xl font-bold text-gray-900">
{{ showOnlyBlocked ? 'Blocked Packages' : 'Vulnerable Packages' }}
</h1>
<p class="text-gray-600 mt-1">
{{ showOnlyBlocked
? 'Packages blocked from download due to exceeding vulnerability thresholds'
: 'Packages with known security vulnerabilities, sorted by risk'
}}
</p>
</div>
</div>
</div>
<!-- Loading State -->
<div v-if="loading" class="text-center py-12">
<i class="fas fa-spinner fa-spin text-4xl text-primary-600"></i>
<p class="mt-4 text-gray-600">Loading vulnerable packages...</p>
</div>
<!-- Error State -->
<Alert v-else-if="error" variant="destructive" class="mb-4">
<i class="fas fa-exclamation-circle mr-2"></i>
<AlertDescription>{{ error }}</AlertDescription>
</Alert>
<!-- Empty State -->
<Card v-else-if="sortedVulnerablePackages.length === 0">
<CardContent class="text-center py-12">
<i class="fas fa-check-circle text-6xl text-green-500 mb-4"></i>
<p class="text-xl font-semibold text-gray-900">No Vulnerable Packages</p>
<p class="mt-2 text-gray-600">All your packages are clean and safe to use!</p>
</CardContent>
</Card>
<!-- Vulnerable Packages List -->
<div v-else class="space-y-6">
<!-- Summary Card -->
<Card>
<CardContent class="p-6">
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div class="text-center p-4 bg-red-50 rounded-lg border border-red-200">
<p class="text-3xl font-bold text-red-600">{{ criticalCount }}</p>
<p class="text-sm text-gray-600 mt-1">Critical</p>
</div>
<div class="text-center p-4 bg-orange-50 rounded-lg border border-orange-200">
<p class="text-3xl font-bold text-orange-600">{{ highCount }}</p>
<p class="text-sm text-gray-600 mt-1">High</p>
</div>
<div class="text-center p-4 bg-yellow-50 rounded-lg border border-yellow-200">
<p class="text-3xl font-bold text-yellow-600">{{ moderateCount }}</p>
<p class="text-sm text-gray-600 mt-1">Moderate</p>
</div>
<div class="text-center p-4 bg-blue-50 rounded-lg border border-blue-200">
<p class="text-3xl font-bold text-blue-600">{{ lowCount }}</p>
<p class="text-sm text-gray-600 mt-1">Low</p>
</div>
</div>
</CardContent>
</Card>
<!-- Packages List -->
<Card>
<CardContent class="p-6">
<div class="mb-4">
<h3 class="text-xl font-semibold text-gray-900">
<i class="fas fa-list mr-2"></i>
Vulnerable Packages ({{ sortedVulnerablePackages.length }})
</h3>
<p class="text-sm text-gray-600 mt-1">
{{ groupedPackages.length }} unique package{{ groupedPackages.length !== 1 ? 's' : '' }} Sorted by risk: Critical High Moderate Low
</p>
</div>
<Accordion type="multiple" class="w-full">
<AccordionItem
v-for="group in groupedPackages"
:key="`${group.registry}:${group.name}`"
:value="`${group.registry}:${group.name}`"
class="border-b border-gray-200"
>
<AccordionTrigger class="px-4 py-4 hover:bg-gray-50">
<div class="flex items-center justify-between w-full pr-4">
<div class="flex items-center space-x-4 flex-1 min-w-0">
<div class="text-left flex-1 min-w-0">
<h4 class="font-semibold text-gray-900 break-words">{{ group.name }}</h4>
<p class="text-sm text-gray-500">{{ group.versions.length }} vulnerable version{{ group.versions.length > 1 ? 's' : '' }}</p>
</div>
</div>
<div class="flex items-center space-x-6 flex-shrink-0">
<Badge variant="outline" :class="getRegistryBadgeClass(group.registry)">
{{ group.registry }}
</Badge>
<div class="text-right whitespace-nowrap">
<p class="text-sm font-medium text-gray-900">{{ formatBytes(group.totalSize) }}</p>
<p class="text-xs text-gray-500">{{ formatNumber(group.totalDownloads) }} downloads</p>
</div>
</div>
</div>
</AccordionTrigger>
<AccordionContent class="px-4 pb-4">
<div class="space-y-3">
<div
v-for="version in group.versions"
:key="version.id"
class="flex items-center justify-between p-4 bg-gray-50 rounded-lg hover:bg-gray-100 cursor-pointer"
@click="navigateToPackage(version)"
>
<div class="flex items-center space-x-4 flex-1">
<div class="flex-1">
<p class="font-medium text-gray-900">{{ version.version.startsWith('v') ? version.version : 'v' + version.version }}</p>
<div class="flex items-center space-x-4 mt-1 text-sm text-gray-500">
<span>
<i class="fas fa-download mr-1"></i>{{ formatNumber(version.download_count) }}
</span>
<span>
<i class="fas fa-hard-drive mr-1"></i>{{ formatBytes(version.size) }}
</span>
<span>
<i class="fas fa-clock mr-1"></i>{{ formatDate(version.cached_at) }}
</span>
</div>
<!-- Vulnerability Badge -->
<div v-if="version.vulnerabilities" class="mt-2">
<VulnerabilityBadge
:scanned="version.vulnerabilities.scanned"
:status="version.vulnerabilities.status"
:counts="version.vulnerabilities.counts"
:total="version.vulnerabilities.total"
:scannedAt="version.vulnerabilities.scannedAt"
:isBlocked="version.vulnerabilities.isBlocked"
@click.stop="navigateToPackage(version)"
/>
</div>
</div>
</div>
<div class="ml-4 flex-shrink-0">
<i class="fas fa-chevron-right text-gray-400"></i>
</div>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</CardContent>
</Card>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { usePackageStore, type Package } from '../stores/packages'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion'
import VulnerabilityBadge from './VulnerabilityBadge.vue'
import { getRegistryBadgeClass } from '@/composables/useBadgeStyles'
const store = usePackageStore()
const router = useRouter()
const route = useRoute()
const loading = ref(false)
const error = ref<string | null>(null)
const vulnerablePackages = ref<Package[]>([])
// Check if we should filter to show only blocked packages
const showOnlyBlocked = computed(() => route.path === '/blocked-packages')
onMounted(async () => {
await fetchVulnerablePackages()
})
async function fetchVulnerablePackages() {
loading.value = true
error.value = null
try {
await store.fetchPackages()
vulnerablePackages.value = store.packages.filter(pkg => {
const isVulnerable = pkg.vulnerabilities?.status === 'vulnerable'
if (showOnlyBlocked.value) {
return isVulnerable && pkg.vulnerabilities?.isBlocked === true
}
return isVulnerable
})
} catch (err: any) {
console.error('Failed to load vulnerable packages:', err)
error.value = err.message || 'Failed to load vulnerable packages'
} finally {
loading.value = false
}
}
// Sort packages by risk: Critical count DESC, High count DESC, Moderate count DESC, Low count DESC
const sortedVulnerablePackages = computed(() => {
return [...vulnerablePackages.value].sort((a, b) => {
const aVulns = a.vulnerabilities?.counts || { critical: 0, high: 0, moderate: 0, low: 0 }
const bVulns = b.vulnerabilities?.counts || { critical: 0, high: 0, moderate: 0, low: 0 }
// Compare critical count (descending)
if (aVulns.critical !== bVulns.critical) {
return bVulns.critical - aVulns.critical
}
// Compare high count (descending)
if (aVulns.high !== bVulns.high) {
return bVulns.high - aVulns.high
}
// Compare moderate count (descending)
if (aVulns.moderate !== bVulns.moderate) {
return bVulns.moderate - aVulns.moderate
}
// Compare low count (descending)
return bVulns.low - aVulns.low
})
})
// Group packages by name and registry, with versions sorted by risk
const groupedPackages = computed(() => {
const groups = new Map<string, {
registry: string
name: string
versions: Package[]
totalSize: number
totalDownloads: number
}>()
sortedVulnerablePackages.value.forEach((pkg) => {
const key = `${pkg.registry}:${pkg.name}`
if (!groups.has(key)) {
groups.set(key, {
registry: pkg.registry,
name: pkg.name,
versions: [],
totalSize: 0,
totalDownloads: 0,
})
}
const group = groups.get(key)!
group.versions.push(pkg)
group.totalSize += pkg.size || 0
group.totalDownloads += pkg.download_count || 0
})
return Array.from(groups.values())
})
// Calculate total counts across all packages
const criticalCount = computed(() =>
vulnerablePackages.value.reduce((sum, pkg) => sum + (pkg.vulnerabilities?.counts?.critical || 0), 0)
)
const highCount = computed(() =>
vulnerablePackages.value.reduce((sum, pkg) => sum + (pkg.vulnerabilities?.counts?.high || 0), 0)
)
const moderateCount = computed(() =>
vulnerablePackages.value.reduce((sum, pkg) => sum + (pkg.vulnerabilities?.counts?.moderate || 0), 0)
)
const lowCount = computed(() =>
vulnerablePackages.value.reduce((sum, pkg) => sum + (pkg.vulnerabilities?.counts?.low || 0), 0)
)
function navigateToPackage(pkg: Package) {
router.push(`/package/${pkg.registry}/${pkg.name}/${pkg.version}`)
}
function goBack() {
router.push('/stats')
}
function formatNumber(num: number): string {
return new Intl.NumberFormat().format(num)
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]
}
function formatDate(dateString: string): string {
if (!dateString) return 'N/A'
try {
const date = new Date(dateString)
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
} catch {
return dateString
}
}
</script>
@@ -0,0 +1,18 @@
<script setup lang="ts">
import type { AccordionRootEmits, AccordionRootProps } from "reka-ui"
import {
AccordionRoot,
useForwardPropsEmits,
} from "reka-ui"
const props = defineProps<AccordionRootProps>()
const emits = defineEmits<AccordionRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<AccordionRoot v-bind="forwarded">
<slot />
</AccordionRoot>
</template>
@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { AccordionContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { AccordionContent } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<AccordionContentProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<AccordionContent
v-bind="delegatedProps"
class="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
>
<div :class="cn('pb-4 pt-0', props.class)">
<slot />
</div>
</AccordionContent>
</template>
@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { AccordionItemProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { AccordionItem, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<AccordionItemProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<AccordionItem
v-bind="forwardedProps"
:class="cn('border-b', props.class)"
>
<slot />
</AccordionItem>
</template>
@@ -0,0 +1,36 @@
<script setup lang="ts">
import type { AccordionTriggerProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ChevronDown } from "lucide-vue-next"
import {
AccordionHeader,
AccordionTrigger,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<AccordionTriggerProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<AccordionHeader class="flex">
<AccordionTrigger
v-bind="delegatedProps"
:class="
cn(
'flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
props.class,
)
"
>
<slot />
<slot name="icon">
<ChevronDown
class="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200"
/>
</slot>
</AccordionTrigger>
</AccordionHeader>
</template>
@@ -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"
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import type { AlertVariants } from "."
import { cn } from "@/lib/utils"
import { alertVariants } from "."
const props = defineProps<{
class?: HTMLAttributes["class"]
variant?: AlertVariants["variant"]
}>()
</script>
<template>
<div :class="cn(alertVariants({ variant }), props.class)" role="alert">
<slot />
</div>
</template>
@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div :class="cn('text-sm [&_p]:leading-relaxed', props.class)">
<slot />
</div>
</template>
@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<h5 :class="cn('mb-1 font-medium leading-none tracking-tight', props.class)">
<slot />
</h5>
</template>
+24
View File
@@ -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<typeof alertVariants>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import type { BadgeVariants } from "."
import { cn } from "@/lib/utils"
import { badgeVariants } from "."
const props = defineProps<{
variant?: BadgeVariants["variant"]
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div :class="cn(badgeVariants({ variant }), props.class)">
<slot />
</div>
</template>
+26
View File
@@ -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<typeof badgeVariants>
@@ -0,0 +1,28 @@
<script setup lang="ts">
import type { PrimitiveProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import type { ButtonVariants } from "."
import { Primitive } from "reka-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from "."
interface Props extends PrimitiveProps {
variant?: ButtonVariants["variant"]
size?: ButtonVariants["size"]
class?: HTMLAttributes["class"]
}
const props = withDefaults(defineProps<Props>(), {
as: "button",
})
</script>
<template>
<Primitive
:as="as"
:as-child="asChild"
:class="cn(buttonVariants({ variant, size }), props.class)"
>
<slot />
</Primitive>
</template>
@@ -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<typeof buttonVariants>
+21
View File
@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
:class="
cn(
'rounded-xl border bg-card text-card-foreground shadow',
props.class,
)
"
>
<slot />
</div>
</template>
@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div :class="cn('p-6 pt-0', props.class)">
<slot />
</div>
</template>
@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<p :class="cn('text-sm text-muted-foreground', props.class)">
<slot />
</p>
</template>
@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div :class="cn('flex items-center p-6 pt-0', props.class)">
<slot />
</div>
</template>
@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div :class="cn('flex flex-col gap-y-1.5 p-6', props.class)">
<slot />
</div>
</template>
@@ -0,0 +1,18 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<h3
:class="
cn('font-semibold leading-none tracking-tight', props.class)
"
>
<slot />
</h3>
</template>
+6
View File
@@ -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"
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { DialogRootEmits, DialogRootProps } from "reka-ui"
import { DialogRoot, useForwardPropsEmits } from "reka-ui"
const props = defineProps<DialogRootProps>()
const emits = defineEmits<DialogRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<DialogRoot v-bind="forwarded">
<slot />
</DialogRoot>
</template>
@@ -0,0 +1,12 @@
<script setup lang="ts">
import type { DialogCloseProps } from "reka-ui"
import { DialogClose } from "reka-ui"
const props = defineProps<DialogCloseProps>()
</script>
<template>
<DialogClose v-bind="props">
<slot />
</DialogClose>
</template>
@@ -0,0 +1,46 @@
<script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { X } from "lucide-vue-next"
import {
DialogClose,
DialogContent,
DialogOverlay,
DialogPortal,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DialogContentProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<DialogContentEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DialogPortal>
<DialogOverlay
class="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
/>
<DialogContent
v-bind="forwarded"
:class="
cn(
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
props.class,
)"
>
<slot />
<DialogClose
class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"
>
<X class="w-4 h-4" />
<span class="sr-only">Close</span>
</DialogClose>
</DialogContent>
</DialogPortal>
</template>
@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { DialogDescriptionProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { DialogDescription, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DialogDescription
v-bind="forwardedProps"
:class="cn('text-sm text-muted-foreground', props.class)"
>
<slot />
</DialogDescription>
</template>
@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{ class?: HTMLAttributes["class"] }>()
</script>
<template>
<div
:class="
cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2',
props.class,
)
"
>
<slot />
</div>
</template>
@@ -0,0 +1,16 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
:class="cn('flex flex-col gap-y-1.5 text-center sm:text-left', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,55 @@
<script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { X } from "lucide-vue-next"
import {
DialogClose,
DialogContent,
DialogOverlay,
DialogPortal,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DialogContentProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<DialogContentEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DialogPortal>
<DialogOverlay
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
>
<DialogContent
:class="
cn(
'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',
props.class,
)
"
v-bind="forwarded"
@pointer-down-outside="(event) => {
const originalEvent = event.detail.originalEvent;
const target = originalEvent.target as HTMLElement;
if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {
event.preventDefault();
}
}"
>
<slot />
<DialogClose
class="absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary"
>
<X class="w-4 h-4" />
<span class="sr-only">Close</span>
</DialogClose>
</DialogContent>
</DialogOverlay>
</DialogPortal>
</template>
@@ -0,0 +1,27 @@
<script setup lang="ts">
import type { DialogTitleProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { DialogTitle, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DialogTitle
v-bind="forwardedProps"
:class="
cn(
'text-lg font-semibold leading-none tracking-tight',
props.class,
)
"
>
<slot />
</DialogTitle>
</template>
@@ -0,0 +1,12 @@
<script setup lang="ts">
import type { DialogTriggerProps } from "reka-ui"
import { DialogTrigger } from "reka-ui"
const props = defineProps<DialogTriggerProps>()
</script>
<template>
<DialogTrigger v-bind="props">
<slot />
</DialogTrigger>
</template>
@@ -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"
@@ -0,0 +1,24 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { useVModel } from "@vueuse/core"
import { cn } from "@/lib/utils"
const props = defineProps<{
defaultValue?: string | number
modelValue?: string | number
class?: HTMLAttributes["class"]
}>()
const emits = defineEmits<{
(e: "update:modelValue", payload: string | number): void
}>()
const modelValue = useVModel(props, "modelValue", emits, {
passive: true,
defaultValue: props.defaultValue,
})
</script>
<template>
<input v-model="modelValue" :class="cn('flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50', props.class)">
</template>
@@ -0,0 +1 @@
export { default as Input } from "./Input.vue"
@@ -0,0 +1,29 @@
<script setup lang="ts">
import type { SeparatorProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { Separator } from "reka-ui"
import { cn } from "@/lib/utils"
const props = withDefaults(defineProps<
SeparatorProps & { class?: HTMLAttributes["class"] }
>(), {
orientation: "horizontal",
decorative: true,
})
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<Separator
v-bind="delegatedProps"
:class="
cn(
'shrink-0 bg-border',
props.orientation === 'horizontal' ? 'h-px w-full' : 'w-px h-full',
props.class,
)
"
/>
</template>
@@ -0,0 +1 @@
export { default as Separator } from "./Separator.vue"
@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
interface SkeletonProps {
class?: HTMLAttributes["class"]
}
const props = defineProps<SkeletonProps>()
</script>
<template>
<div :class="cn('animate-pulse rounded-md bg-primary/10', props.class)" />
</template>
@@ -0,0 +1 @@
export { default as Skeleton } from "./Skeleton.vue"
@@ -0,0 +1,15 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: string
}>()
</script>
<template>
<div class="relative w-full overflow-auto">
<table :class="cn('w-full caption-bottom text-sm', props.class)">
<slot />
</table>
</div>
</template>
@@ -0,0 +1,13 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: string
}>()
</script>
<template>
<tbody :class="cn('[&_tr:last-child]:border-0', props.class)">
<slot />
</tbody>
</template>
@@ -0,0 +1,13 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: string
}>()
</script>
<template>
<td :class="cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', props.class)">
<slot />
</td>
</template>
@@ -0,0 +1,13 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: string
}>()
</script>
<template>
<th :class="cn('h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0', props.class)">
<slot />
</th>
</template>
@@ -0,0 +1,13 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: string
}>()
</script>
<template>
<thead :class="cn('[&_tr]:border-b', props.class)">
<slot />
</thead>
</template>
@@ -0,0 +1,13 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: string
}>()
</script>
<template>
<tr :class="cn('border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted', props.class)">
<slot />
</tr>
</template>
@@ -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'
@@ -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<string, string> = {
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<string, string> = {
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<string, string> = {
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()
}
+7
View File
@@ -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))
}
+11
View File
@@ -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')
+53
View File
@@ -0,0 +1,53 @@
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: '/blocked-packages',
name: 'blocked-packages',
component: VulnerablePackages,
},
{
path: '/admin/bypasses',
name: 'bypasses',
component: BypassManagementPanel,
},
],
})
export default router
+118
View File
@@ -0,0 +1,118 @@
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
isBlocked?: boolean // Whether download is blocked due to vulnerability thresholds
}
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
max_cache_size: number
total_downloads: number
scanned_packages: number
vulnerable_packages: number
blocked_packages: number
}
export const usePackageStore = defineStore('packages', () => {
const packages = ref<Package[]>([])
const stats = ref<Stats | null>(null)
const registries = ref<Record<string, any>>({})
const loading = ref(false)
const error = ref<string | null>(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,
}
})
+83
View File
@@ -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 */
+11
View File
@@ -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()
})
+91
View File
@@ -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"),
],
}
+25
View File
@@ -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" }],
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+34
View File
@@ -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,
},
})
+17
View File
@@ -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'),
},
},
})
+136
View File
@@ -0,0 +1,136 @@
module github.com/lukaszraczylo/gohoarder
go 1.25.5
require (
github.com/aws/aws-sdk-go-v2 v1.41.4
github.com/aws/aws-sdk-go-v2/config v1.32.12
github.com/aws/aws-sdk-go-v2/credentials v1.19.12
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.2
github.com/go-gormigrate/gormigrate/v2 v2.1.5
github.com/go-sql-driver/mysql v1.9.3
github.com/goccy/go-json v0.10.6
github.com/gofiber/fiber/v2 v2.52.12
github.com/gorilla/websocket v1.5.3
github.com/hirochachacha/go-smb2 v1.1.0
github.com/lib/pq v1.12.0
github.com/prometheus/client_golang v1.23.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
github.com/testcontainers/testcontainers-go v0.40.0
github.com/testcontainers/testcontainers-go/modules/mysql v0.40.0
github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0
golang.org/x/crypto v0.49.0
golang.org/x/sync v0.20.0
golang.org/x/time v0.15.0
gorm.io/driver/mysql v1.6.0
gorm.io/driver/postgres v1.6.0
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1
)
require (
dario.cat/mergo v1.0.2 // indirect
filippo.io/edwards25519 v1.2.0 // indirect
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.21 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.12 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.20 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 // indirect
github.com/aws/smithy-go v1.24.2 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v0.2.1 // indirect
github.com/cpuguy83/dockercfg v0.3.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/docker v28.5.2+incompatible // indirect
github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/ebitengine/purego v0.9.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/geoffgarside/ber v1.2.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.4 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.9.1 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect
github.com/magiconair/properties v1.8.10 // 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.21 // indirect
github.com/mattn/go-sqlite3 v1.14.37 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.2.0 // indirect
github.com/moby/patternmatcher v0.6.0 // indirect
github.com/moby/sys/sequential v0.6.0 // indirect
github.com/moby/sys/user v0.4.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/morikuni/aec v1.1.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pelletier/go-toml/v2 v2.3.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/procfs v0.20.1 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/shirou/gopsutil/v4 v4.25.12 // indirect
github.com/sirupsen/logrus v1.9.3 // 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/tklauser/go-sysconf v0.3.16 // indirect
github.com/tklauser/numcpus v0.11.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.69.0 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect
go.opentelemetry.io/otel v1.39.0 // indirect
go.opentelemetry.io/otel/metric v1.39.0 // indirect
go.opentelemetry.io/otel/trace v1.39.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect
google.golang.org/grpc v1.78.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

Some files were not shown because too many files have changed in this diff Show More