26 Commits

Author SHA1 Message Date
erangel1 2a81bda00e did something 2026-05-19 22:55:26 +02:00
erangel1 ec9a286d33 added side context panel and repo only search bar 2026-05-17 21:13:45 +02:00
erangel1 5147c6bddb added git ssh support and ablity to download repo via zip, tar.gz, and bundle 2026-05-17 20:09:55 +02:00
erangel1 e7c64e583b updated .env file to reflect local development. removed AI features from agents and changelog files. 2026-05-17 19:40:24 +02:00
erangel1 f658d754a8 Merge branch 'main' of https://gitea.dokploy.second-breakfast.dev/HomeLab/ForgeBucket 2026-05-17 19:18:40 +02:00
erangel1 a7b1fd2ae3 updated dev docker compose file to add dbgate, which is a light weight db manager. mainly just for dev operations 2026-05-17 19:18:04 +02:00
erangel1 ee1b56e833 Update .env 2026-05-13 00:06:19 +00:00
erangel1 2d6aabab9f Update .env 2026-05-12 23:59:08 +00:00
erangel1 54d6e6be36 Update .env 2026-05-12 23:54:58 +00:00
erangel1 7196b9f264 Update docker-compose.prod.yml 2026-05-12 23:51:12 +00:00
erangel1 f675032786 Update docker-compose.prod.yml 2026-05-12 23:44:31 +00:00
erangel1 cff6701864 Update docker-compose.prod.yml 2026-05-12 23:43:23 +00:00
erangel1 469d900ac8 Update docker-compose.prod.yml 2026-05-12 23:42:25 +00:00
erangel1 366941feb1 Update docker-compose.prod.yml 2026-05-12 23:41:24 +00:00
erangel1 df6d53c12c Update docker-compose.prod.yml 2026-05-12 23:38:11 +00:00
erangel1 d384af0d9c Delete ai_agent_master_prompt_for_building_modern_git_platform.md 2026-05-12 23:30:34 +00:00
erangel1 dea58b85b8 fixed issues from opencode agent 2026-05-13 01:08:19 +02:00
erangel1 994570ca74 added ai prompt to gitignore file 2026-05-13 00:56:41 +02:00
erangel1 77268e2302 edited ci file 2026-05-13 00:55:28 +02:00
erangel1 f99f0e0fc5 random edits 2026-05-12 22:51:04 +02:00
erangel1 91462500f0 added artifacts 2026-05-12 22:34:26 +02:00
erangel1 822f723ff1 added signed artifacts and SBOM generation capabilities 2026-05-12 21:31:43 +02:00
erangel1 ab94775162 implemented federation 2026-05-12 20:55:13 +02:00
erangel1 e360f3697e implemented observability 2026-05-12 20:32:30 +02:00
erangel1 c7df53708c implemented gitops controller + drift detection 2026-05-12 19:51:59 +02:00
erangel1 35afa8d8f1 fixed PR issue 2026-05-11 23:56:45 +02:00
108 changed files with 14440 additions and 1485 deletions
Vendored
BIN
View File
Binary file not shown.
+15 -2
View File
@@ -21,7 +21,7 @@ NATS_URL=nats://localhost:4222
# ─── Federation (ActivityPub) ───────────────────────────────────────────────── # ─── Federation (ActivityPub) ─────────────────────────────────────────────────
# Public URL of this instance (no trailing slash) # Public URL of this instance (no trailing slash)
INSTANCE_URL=https://forgebucket.asgardlabs.net INSTANCE_URL=https://forgebucket.dokploy.second-breakfast.dev
INSTANCE_NAME=ForgeBucket INSTANCE_NAME=ForgeBucket
# ─── OIDC / OAuth2 (optional) ──────────────────────────────────────────────── # ─── OIDC / OAuth2 (optional) ────────────────────────────────────────────────
@@ -31,4 +31,17 @@ INSTANCE_NAME=ForgeBucket
# ─── Dev only ───────────────────────────────────────────────────────────────── # ─── Dev only ─────────────────────────────────────────────────────────────────
# Set to true to disable Secure cookies and enable verbose logging # Set to true to disable Secure cookies and enable verbose logging
DEBUG=true DEBUG=false
# PEM-encoded ECDSA P-256 private key. If empty, an ephemeral key is generated
# at startup (signatures will not survive restart). Generate with:
# openssl ecparam -genkey -name prime256v1 -noout -out signing-key.pem
# ARTIFACT_SIGNING_KEY="-----BEGIN EC PRIVATE KEY-----
# MHcCAQEEIKGMjCu0NdczHQ7BRDeo0hTOLauF9vOenWl0HlyN4bzToAoGCCqGSM49
# AwEHoUQDQgAE+VL1HhQ1us0QfNH+5Var8lo5Oww83B+QDQ2obzHL4JZl0UM3kVAB
# SePwUlkfdW6u4a0KYMYf3Op6wsXTp0kA2g==
# -----END EC PRIVATE KEY-----"
# ─── OCI Registry (Phase 4) ───────────────────────────────────────────────────
# Root directory for the OCI Distribution Spec blob and upload storage.
OCI_ROOT=/tmp/forgebucket/oci
+23
View File
@@ -26,11 +26,34 @@ INSTANCE_NAME=ForgeBucket
# OIDC_CLIENT_ID= # OIDC_CLIENT_ID=
# OIDC_CLIENT_SECRET= # OIDC_CLIENT_SECRET=
# ─── GitOps ──────────────────────────────────────────────────────────────────
# Seconds between periodic drift checks (0 disables the ticker; push-triggered checks always run).
GITOPS_RECONCILE_INTERVAL=300
# ─── Event Bus (NATS) ──────────────────────────────────────────────────────── # ─── Event Bus (NATS) ────────────────────────────────────────────────────────
# Leave empty to disable event publishing (no-op mode). # Leave empty to disable event publishing (no-op mode).
# Start NATS with: make docker-up # Start NATS with: make docker-up
NATS_URL=nats://localhost:4222 NATS_URL=nats://localhost:4222
# ─── Artifact Signing (Phase 4) ───────────────────────────────────────────────
# PEM-encoded ECDSA P-256 private key. If empty, an ephemeral key is generated
# at startup (signatures will not survive restart). Generate with:
# openssl ecparam -genkey -name prime256v1 -noout -out signing-key.pem
# ARTIFACT_SIGNING_KEY=
# ─── SSH Server ────────────────────────────────────────────────────────────────
# Hostname shown in SSH clone URLs. Auto-detected from INSTANCE_URL or request
# Host header when empty.
# SSH_HOST=ssh.example.com
# SSH_PORT=2222
# Path to PEM-encoded SSH host key. If empty, an ephemeral RSA-4096 key is
# generated at startup (host key changes on restart — warns clients).
# SSH_HOST_KEY_PATH=
# ─── OCI Registry (Phase 4) ───────────────────────────────────────────────────
# Root directory for the OCI Distribution Spec blob and upload storage.
OCI_ROOT=/var/lib/forgebucket/oci
# ─── Dev only ───────────────────────────────────────────────────────────────── # ─── Dev only ─────────────────────────────────────────────────────────────────
# Set to true to disable Secure cookies and enable verbose logging # Set to true to disable Secure cookies and enable verbose logging
DEBUG=false DEBUG=false
+1
View File
@@ -13,3 +13,4 @@ uploads
# Database # Database
*.db *.db
html docs/
+110 -59
View File
@@ -22,19 +22,54 @@ The full product vision lives in [`ai_agent_master_prompt_for_building_modern_gi
cmd/forgebucket/ — binary entry point (main.go) cmd/forgebucket/ — binary entry point (main.go)
internal/ internal/
api/ api/
router.go — Chi router, all route definitions (~26 routes) router.go — Chi router, all route definitions (60+ routes)
middleware/ — auth, CSRF, RBAC, logging middleware/ — auth.go, csrf.go, rbac.go, audit.go
handlers/ — one file per domain (repo, pr, issue, auth, user, ssh...) handlers/ — one file per domain area (see Key Files below)
domain/ domain/
git/ — sanitized git binary wrapper (exec.Command only, no shell) git/ — sanitized git binary wrapper (exec.Command only, no shell)
federation/ — ActivityPub / ForgeFed (DATA LAYER ONLY — no handlers yet) binary.go — Run, Log, Tree, Diff, BlobCat, RevParse, etc.
ci/ CI orchestrator (EMPTY — Phase 2 stub) agit.goAGit ref parsing
models/XORM structs + 7 migration files ci/ CI/CD execution engine (fully built — Phase 2B)
orchestrator.go — NATS-driven DAG orchestrator
runner_manager.go — job dispatch with Docker executor
executor.go — docker run, log streaming, workspace extraction
dag.go — topological sort, ReadyJobs
parser.go — .forgebucket/workflows/*.yml parser
types.go — WorkflowFile, WorkflowJob, WorkflowStep structs
sbom/ — SBOM generator (fully built — Phase 4)
generator.go — CycloneDX 1.4 generation, auto on pipeline success + on-demand
cyclonedx.go — CycloneDX document model and helpers
parsers.go — Manifest parsers: go.mod, package.json, requirements.txt, Cargo.toml, Gemfile.lock, pom.xml
scanning/ — Secret scanner (fully built — Phase 4)
scanner.go — Push-triggered regex scanning, listing, dismissal
secrets.go — 15 high/medium severity secret patterns (AWS, GitHub, SSH, JWT, etc.)
vulnscan/ — Vulnerability scanner (fully built — Phase 4)
scanner.go — OSV API-backed dependency vulnerability scanning
osv.go — HTTP client for api.osv.dev/v1
signing/ — Artifact signing (fully built — Phase 4)
keystore.go — ECDSA P-256 signing and verification, self-verifying bundles
oci/ — OCI registry (fully built — Phase 4)
registry.go — Content-addressable blob store, upload sessions, OCI Distribution Spec v1.1
gitops/ — GitOps controller (fully built — Phase 3D)
controller.go — NATS subscriptions, startup, periodic ticker
drift.go — CheckDrift, handlePush, periodicCheck
reconciler.go — TriggerSync, handleDeploymentSucceeded/Failed
federation/ — ActivityPub / ForgeFed (fully built — Phase 3F)
actor.go — GetOrCreate, ActorJSON, APID, RSA-2048 key gen
signatures.go — HTTP signature sign/verify (draft-cavage-http-signatures)
inbox.go — Receive, handleFollow (auto-accept), handleAccept, handleUndo
outbox.go — Collection builder, StubCollection
remote.go — FetchActor (cached), DeliverActivity (signed POST)
observability/ — Prometheus metrics + health (fully built — Phase 3E)
metrics.go — metric definitions, HTTP middleware, NATS watcher
health.go — Check() returning HealthStatus (DB ping + NATS)
models/ — XORM structs + 20 migration files
config/ — ENV-driven config, fails fast on missing secrets config/ — ENV-driven config, fails fast on missing secrets
events/ — NATS EventBus interface + NATSBus + NoOpBus (Healthy() bool)
web/ — //go:embed target for the built React SPA web/ — //go:embed target for the built React SPA
frontend/ frontend/
src/ src/
pages/ — 20 route-level page components pages/ — route-level page components
components/ — shared UI (AppShell, Sidebar, Header, DiffViewer, etc.) components/ — shared UI (AppShell, Sidebar, Header, DiffViewer, etc.)
ui/ ui/
tokens.ts — SINGLE SOURCE OF TRUTH for all design tokens tokens.ts — SINGLE SOURCE OF TRUTH for all design tokens
@@ -44,48 +79,27 @@ frontend/
**Middleware chain — this order is fixed, do not reorder:** **Middleware chain — this order is fixed, do not reorder:**
``` ```
Logger → RealIP → Recoverer → CORS → CSRF → SessionAuth → RBAC → Handler Logger → RealIP → Recoverer → Metrics → CORS → CSRF → SessionAuth → AuditLog → Handler
``` ```
--- ---
## Current Phase Status ## Current Phase Status
Understand the phases before adding code — don't build Phase 3 infrastructure when Phase 2 is incomplete.
| Phase | Scope | Status | | Phase | Scope | Status |
|-------|-------|--------| |-------|-------|--------|
| 1 | Auth, Git HTTP, repos, PRs, issues, RBAC, webhooks, LFS, design system, 20-page SPA | **Complete** | | 1 | Auth, Git HTTP, repos, PRs, issues, RBAC, webhooks, LFS, design system | **Complete** |
| 2A | NATS event bus, WebSocket hub upgrade, audit log | **Complete** | | 2A | NATS event bus, WebSocket hub, audit log | **Complete** |
| 2B | CI orchestrator, runner manager, Docker executor, artifact registry | **Complete** | | 2B | CI orchestrator, runner manager, Docker executor, artifact registry | **Complete** |
| 2C | Pipeline DAG visualization, dashboard CI upgrade, command palette wiring | **Complete** | | 2C | Pipeline DAG visualization, dashboard CI upgrade, command palette | **Complete** |
| 3A | Environment model + deployment tracking | **Complete** | | 3A | Environment model + deployment tracking | **Complete** |
| 3B | Unified operational timeline | **Complete** | | 3B | Unified operational timeline | **Complete** |
| 3C | Workspaces + secret management hierarchy | **Active** | | 3C | Workspaces + secret management (Global → Workspace → Repo → Env) | **Complete** |
| 3D | GitOps controller + drift detection | Planned | | 3D | GitOps controller + drift detection + auto-sync | **Complete** |
| 3E | Observability (Prometheus, health sparklines) | Planned | | 3E | Observability (Prometheus `/metrics`, structured `/health`, repo health API) | **Complete** |
| 3F | Federation handlers (ActivityPub inbox/outbox) | Planned | | 3F | Federation handlers (ActivityPub WebFinger, actor, inbox/outbox, HTTP signatures, Follow/Accept) | **Complete** |
| 4 | AI diagnostics, signed artifacts, OCI registry, secret/dep scanning | Planned | | 4 | SBOM generation, secret scanning, vuln scanning, signed artifacts, OCI registry, security page | **Complete** |
| 5 | Deployment promotions, rollback visualization | Planned |
Do not implement Phase 3+ features without explicit discussion. The `domain/federation/` directory is an intentional stub — the data model exists but no HTTP handlers should be wired until Phase 3F.
### Phase 3A — What to Build
Backend and frontend are both net-new for Phase 3A. Nothing exists yet.
**Backend:**
1. `internal/models/environment.go``Environment` (id, repoId, name, url, protectionRules JSON) + `Deployment` (id, envId, repoId, sha, ref, status, triggeredBy, description, runId, startedAt, finishedAt)
2. `internal/models/migrations/010_environments.go``Run010()` syncing both structs; call from `001_init.go`
3. `internal/api/handlers/environment.go``ListEnvironments`, `CreateEnvironment`, `GetEnvironment`, `UpdateEnvironment`, `DeleteEnvironment`, `ListDeployments`, `CreateDeployment`, `UpdateDeploymentStatus`; publish `deployment.*` NATS events
4. `internal/api/router.go` — wire routes under `/{owner}/{repo}/environments` and `/{owner}/{repo}/environments/{envName}/deployments`
**Frontend:**
5. `frontend/src/types/api.ts` — add `Environment`, `Deployment`, `DeployStatus` types
6. `frontend/src/api/queries/environments.ts``useEnvironments`, `useEnvironment`, `useCreateEnvironment`, `useUpdateEnvironment`, `useDeleteEnvironment`, `useDeployments`, `useCreateDeployment`, `useUpdateDeploymentStatus`
7. `frontend/src/pages/EnvironmentsPage.tsx` — environment cards each showing latest deployment status, SHA, who deployed, time; "New environment" flow; deployment history per env
8. `frontend/src/components/layout/Sidebar.tsx` — add `Environments` nav item between Pipelines and Settings in `RepoSubNav`
9. `frontend/src/pages/RepoPage.tsx` — surface deployment status badges in the repo header (latest deploy per env at a glance)
10. `frontend/src/App.tsx` — add route `repos/:owner/:repo/environments`
--- ---
@@ -107,13 +121,19 @@ This rule is non-negotiable. It prevents command injection.
### Router / handlers ### Router / handlers
- Chi router. Route definitions in `internal/api/router.go`. - Chi router. Route definitions in `internal/api/router.go`.
- One handler file per domain area. Keep handlers thin — business logic belongs in domain packages. - One handler file per domain area. Keep handlers thin — business logic belongs in domain packages.
- All POST/PUT/DELETE routes require `X-CSRF-Token` header matching the session cookie. The middleware enforces this, but don't remove it from routes. - All POST/PUT/DELETE routes require `X-CSRF-Token` header matching the session cookie. The CSRF middleware enforces this, but don't remove it from route definitions.
- There is a shared `resolveRepoID(db, w, r)` function in `internal/api/handlers/repo_lookup.go` — use it instead of duplicating repo resolution logic.
### Database ### Database
- XORM for all DB access. Structs in `internal/models/`. - XORM for all DB access. Structs in `internal/models/`.
- Migrations are numbered files in `internal/models/migrations/`. Always add a new migration file; never edit existing ones. - Migrations are numbered files in `internal/models/migrations/`. Always add a new file; never edit existing ones. Current highest: **020**.
- No raw SQL strings built from user input. - No raw SQL strings built from user input.
### Events
- Publish to NATS via `bus.Publish(events.SubjectXxx, payload)` where the subject is a constant from `internal/events/subjects.go`.
- Payload types are in `internal/events/types.go` — use them for type-safe unmarshaling in subscribers.
- `NoOpBus` silently drops events when `NATS_URL` is unset — the app must work normally without NATS.
### Secrets and config ### Secrets and config
- All secrets come from environment variables via `internal/config/`. - All secrets come from environment variables via `internal/config/`.
- Never hardcode secrets, tokens, or credentials anywhere. - Never hardcode secrets, tokens, or credentials anywhere.
@@ -121,7 +141,7 @@ This rule is non-negotiable. It prevents command injection.
### Error handling ### Error handling
- Return errors up the call stack. Don't swallow them silently. - Return errors up the call stack. Don't swallow them silently.
- HTTP handlers use consistent JSON error responses — follow the pattern in existing handlers. - HTTP handlers use consistent JSON error responses — follow the pattern in `jsonError` / `jsonOK` in `internal/api/handlers/helpers.go`.
--- ---
@@ -143,7 +163,7 @@ All spacing, color, and sizing values must come from `frontend/src/ui/tokens.ts`
- Touch targets: 44px minimum height/width on all interactive elements (buttons, links, icon buttons). - Touch targets: 44px minimum height/width on all interactive elements (buttons, links, icon buttons).
### Dark mode ### Dark mode
- Use Tailwind v4 `@variant dark` — not hardcoded dark: classes unless inside a component that explicitly handles both. - Use Tailwind v4 `@variant dark` — not hardcoded `dark:` classes unless inside a component that explicitly handles both.
- Colors must work in both light and dark modes. Test both. - Colors must work in both light and dark modes. Test both.
### Component patterns ### Component patterns
@@ -155,20 +175,21 @@ All spacing, color, and sizing values must come from `frontend/src/ui/tokens.ts`
### API calls ### API calls
- Use the typed API client in `frontend/src/api/` — don't write raw `fetch` calls in components. - Use the typed API client in `frontend/src/api/` — don't write raw `fetch` calls in components.
- Always include `X-CSRF-Token` header on mutating requests. - Always include `X-CSRF-Token` header on mutating requests (the client does this automatically via `getCSRFToken()`).
--- ---
## What NOT to Do ## What NOT to Do
- **No shell string injection** — see Go conventions above - **No shell string injection** — see Go conventions above; always discrete `exec.Command` args
- **No hardcoded secrets** — everything via env - **No hardcoded secrets** — everything via env
- **No skipping CSRF** — all mutating routes require it - **No skipping CSRF** — all mutating routes require it
- **No arbitrary design values** — tokens.ts is the law - **No arbitrary design values** — `tokens.ts` is the law
- **No Phase 3+ features without discussion** — don't wire up GitOps, federation handlers, or the command palette until Phase 2 is complete - **No new color tokens without discussion** — the existing palette covers all cases
- **No new color tokens** — if the design requires a new color, discuss it; don't invent one - **No modal-heavy UX** — progressive disclosure; avoid deep modal chains
- **No modal-heavy UX** — this platform uses progressive disclosure; avoid deep modal chains - **No YAML-centric UI** — pipeline and GitOps config should feel operational, not config-file editing
- **No YAML-centric UI** — pipeline and environment config should feel operational, not config-file editing - **No editing existing migration files** — always add a new numbered migration
- **No direct `fmt.Println` for logging** — use `log.Printf` so structured logs work correctly
--- ---
@@ -189,16 +210,44 @@ make lint # go vet + ESLint
| File | Purpose | | File | Purpose |
|------|---------| |------|---------|
| `internal/api/router.go` | All route definitions — start here for backend | | `internal/api/router.go` | All route definitions — start here for backend work |
| `internal/models/` | XORM models + migrations — all DB schemas | | `internal/api/handlers/repo_lookup.go` | Shared `resolveRepoID` helper |
| `internal/config/config.go` | Env-driven config, required vars | | `internal/models/` | All XORM models + 13 migration files |
| `internal/domain/git/` | Git binary wrapper — safe exec patterns | | `internal/config/config.go` | All env vars, fail-fast validation |
| `internal/events/subjects.go` | All NATS event subject constants |
| `internal/events/types.go` | Typed event payload structs |
| `internal/domain/git/binary.go` | Git binary wrapper — safe exec patterns, `RevParse`, `BlobCat`, etc. |
| `internal/domain/ci/orchestrator.go` | CI DAG orchestrator |
| `internal/domain/ci/executor.go` | Docker job executor + log streaming |
| `internal/domain/gitops/controller.go` | GitOps reconciliation controller |
| `internal/domain/gitops/drift.go` | `CheckDrift`, drift detection logic |
| `internal/observability/metrics.go` | Prometheus metric defs, HTTP middleware, NATS watcher |
| `internal/observability/health.go` | `Check()` — DB ping + NATS liveness |
| `internal/api/handlers/observability.go` | `/health` + `/repos/.../health` handlers |
| `internal/api/handlers/environment.go` | Environment + deployment CRUD |
| `internal/api/handlers/gitops.go` | GitOps config + drift HTTP endpoints |
| `internal/api/handlers/federation.go` | ActivityPub WebFinger, actor, inbox, outbox, followers/following |
| `internal/domain/federation/actor.go` | Actor lifecycle — GetOrCreate, ActorJSON, key generation |
| `internal/domain/federation/signatures.go` | HTTP signature sign/verify |
| `internal/domain/federation/inbox.go` | Receive + Follow/Accept auto-accept flow |
| `internal/domain/federation/remote.go` | FetchActor (cached remote actors), DeliverActivity |
| `internal/api/handlers/secret.go` | Scoped secret management |
| `internal/api/handlers/workspace.go` | Workspace + member management |
| `internal/api/handlers/secret.go` | Scoped secret management |
| `internal/api/handlers/sbom.go` | SBOM generation + download endpoints |
| `internal/api/handlers/scanning.go` | Secret leak list + dismiss endpoints |
| `internal/api/handlers/vulnscan.go` | Vulnerability scan + dismiss endpoints |
| `internal/api/handlers/oci.go` | OCI Distribution Spec v1.1 registry handler |
| `internal/domain/sbom/generator.go` | SBOM generator (CycloneDX 1.4) |
| `internal/domain/scanning/scanner.go` | Push-triggered secret scanner |
| `internal/domain/vulnscan/scanner.go` | OSV API-backed vulnerability scanner |
| `internal/domain/signing/keystore.go` | ECDSA P-256 artifact signing |
| `internal/domain/oci/registry.go` | Content-addressable OCI blob store |
| `internal/api/middleware/audit.go` | Audit log middleware |
| `frontend/src/ui/tokens.ts` | Design token source of truth | | `frontend/src/ui/tokens.ts` | Design token source of truth |
| `frontend/src/components/AppShell.tsx` | Root layout wrapper | | `frontend/src/components/AppShell.tsx` | Root layout wrapper |
| `frontend/src/components/Sidebar.tsx` | 3-state navigation sidebar | | `frontend/src/api/client.ts` | Typed API client with CSRF handling |
| `frontend/src/pages/` | All 20 route-level pages | | `.env.example` | All environment variables with documentation |
| `frontend/src/api/` | Typed API client |
| `.env.example` | All required environment variables |
| `CLAUDE.md` | Developer guide (rules overlap with this file — CLAUDE.md takes precedence on conflicts) | | `CLAUDE.md` | Developer guide (rules overlap with this file — CLAUDE.md takes precedence on conflicts) |
--- ---
@@ -207,7 +256,9 @@ make lint # go vet + ESLint
```bash ```bash
cp .env.example .env # fill SESSION_SECRET and CSRF_SECRET cp .env.example .env # fill SESSION_SECRET and CSRF_SECRET
make docker-up # PostgreSQL via Docker Compose make docker-up # PostgreSQL + NATS via Docker Compose
make migrate # run XORM migrations make migrate # run XORM migrations (currently 020)
make dev # Go :8080 + Vite :5173 make dev # Go :8080 + Vite :5173
``` ```
CI execution requires Docker to be running locally. If unavailable, the runner logs a warning and CI jobs are queued but not executed.
+336 -143
View File
@@ -9,63 +9,280 @@ Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] ## [Unreleased]
### In Progress — Phase 3C (Workspaces + Secret management hierarchy) ### Planned — Phase 5 (Deployment Promotions + Rollback Visualization)
- `Workspace` model — named collaborative namespace (handle, displayName, description, avatarUrl) - Deployment promotion workflows (manual + automated)
- `WorkspaceMember` model — user membership with owner/admin/member roles - Rollback visualization and timeline
- Repos can be owned by a workspace; URL format stays `/{owner}/{repo}` where owner is a workspace handle or username
- `Secret` model — AES-256-GCM encrypted, scoped to global / workspace / repo / env ---
- Secret hierarchy resolution in CI executor: Env → Repo → Workspace → Global
- Full CRUD APIs for workspaces, workspace members, secrets at all scope levels ## [1.0.0] — 2026-05-13
- WorkspacesPage, WorkspacePage, WorkspaceSettingsPage (settings + members)
- Workspace switcher in sidebar header Phase 4 complete. SBOM generation, secret scanning, dependency vulnerability scanning, signed artifacts, and OCI registry are operational.
- Create repo: workspace owner selector
- RepoSecretsPage — write-only secret management per repo and per environment ### Added — SBOM Generation (`internal/domain/sbom/`)
- **`Generator`** — subscribes to `pipeline.completed` events and auto-generates CycloneDX 1.4 SBOM documents for every successful pipeline run; also supports on-demand generation via `GenerateOnDemand`
- **6 manifest parsers**: `go.mod`, `package.json`, `requirements.txt`, `Cargo.toml`, `Gemfile.lock`, `pom.xml` — lightweight line-scanning, no external parser dependencies
- **API endpoints** — `GET /sbom`, `GET /sbom/document`, `GET /runs/{runID}/sbom`, `GET /runs/{runID}/sbom/document`, `POST /sbom/generate?ref=&runID=`
- **Database** — migration `016_sbom` adds `SBOMReport` model with CycloneDX document body
- Automatic generation on pipeline completion now also fires directly from the orchestrator (not solely via NATS), ensuring SBOMs are generated even when NATS is unavailable
### Added — Secret Scanning (`internal/domain/scanning/`)
- **`Scanner`** — subscribes to `push.received` events, scans git diffs against 15 regex patterns for high/medium severity secrets
- **Secret patterns**: AWS keys, GitHub/GitLab tokens, generic API keys, Bearer tokens, Slack tokens, Google API keys, Google service accounts, SSH private keys, JWTs, NPM tokens, PostgreSQL/Redis connection strings, generic passwords
- **API endpoints** — `GET /secrets/leaks`, `POST /secrets/leaks/{leakID}/dismiss` (repo-scoped), `GET /api/v1/secrets/leaks` (global admin)
- **Database** — migration `018_scanning` adds `SecretLeak` model
### Added — Vulnerability Scanning (`internal/domain/vulnscan/`)
- **`Scanner`** — triggers on-demand scans against the OSV API (`api.osv.dev/v1`); supports scanning by PURL or by fetching the latest SBOM and scanning all components
- **OSV client** — HTTP client with 30-second timeout, queries OSV database for CVEs by PURL or ecosystem+name, extracts CVSS scores and fixed version ranges
- **API endpoints** — `GET /vulnerabilities`, `POST /vulnerabilities/scan`, `POST /vulnerabilities/{findingID}/dismiss` (repo-scoped), `GET /api/v1/vulnerabilities` (global admin)
- **Database** — migration `019_vulnscan` adds `VulnerabilityFinding` model
- Findings deduplicated by `(vuln_id, purl, repo_id)`
### Added — Artifact Signing (`internal/domain/signing/`)
- **`KeyStore`** — ECDSA P-256 signing and verification; produces self-verifying `Bundle` carrying payload, signature (ASN.1 DER), and public key PEM
- `Sign(artifactID, name, rawContent)` — computes SHA-256 digest, signs, returns signed `Bundle` with key ID fingerprint
- `Verify(bundleJSON)` — extracts public key from bundle, verifies ECDSA signature, returns `VerifyResult` with key-matching check
- `Generate()` — creates ephemeral ECDSA P-256 key when `ARTIFACT_SIGNING_KEY` env var is unset (logs warning; signatures lost on restart unless persisted)
- **API endpoints** — `GET /artifacts/{artifactID}/signature`, `GET /artifacts/{artifactID}/verify`
- **Database** — migration `015_signing` adds `ArtifactSignature` model
### Added — OCI Registry (`internal/domain/oci/`)
- **`Registry`** — content-addressable on-disk blob store implementing OCI Distribution Spec v1.1
- Storage layout: `{root}/blobs/sha256/<hex>` for blobs, `{root}/uploads/<uuid>` for in-progress uploads
- Full upload session lifecycle: start (POST), append chunk (PATCH), finalize with digest verification (PUT), cancel (DELETE), offset query (GET)
- 13 OCI distribution error codes defined (`ErrBlobUnknown`, `ErrDigestInvalid`, `ErrManifestInvalid`, etc.)
- **API handlers** (`internal/api/handlers/oci.go`, 525 lines) — full `/v2/{name}/{kind}/{ref}` routing: manifest push/get/delete, blob HEAD/get/delete, tag listing, chunked upload
- **Database** — migration `017_oci` adds `OCIRepository`, `OCIManifest`, `OCITag`, `OCIBlob`, `OCIUpload` models
- Registry is consumed by standard OCI tools (Docker, Podman, Skopeo, containerd)
### Added — Unified Security Page (`/repos/:owner/:repo/security`)
- **`RepoSecurityPage`** — single-page view combining SBOM status, secret leak detection, and vulnerability findings
- SBOM section: displays existing SBOM metadata with download button, or "Generate SBOM" form with branch/SHA input
- Secret Leaks section: lists leaks with severity badge, pattern name, commit SHA, ref, match sample, dismiss button
- Vulnerabilities section: lists findings with CVSS severity (CRITICAL/HIGH/MEDIUM/LOW), vuln ID, score, summary, PURL, version, fix suggestion, dismiss button; "Scan now" trigger
- **Route** added in `App.tsx`, nav link added in `RepoPage.tsx` tab bar
### Added — Pipeline Run SBOM Integration
- `PipelineRunPage` shows per-run SBOM section: metadata (components, SHA, generation time) + download button
- "Generate SBOM" button for completed/failed runs that lack one
- `useRunSBOM` / `useGenerateSBOM` hooks in `frontend/src/api/queries/sbom.ts`
- 404 from `useRunSBOM` handled gracefully (returns `null` instead of throwing)
### Added — Database Models
- Migration `016_sbom``SBOMReport` (repoId, runId, sha, format, componentCount, bomDocument, generatedAt)
- Migration `017_oci``OCIRepository`, `OCIManifest`, `OCITag`, `OCIBlob`, `OCIUpload`
- Migration `018_scanning``SecretLeak` (repoId, commitSha, ref, patternName, description, severity, matchSample, dismissed, dismissedBy, dismissedAt, detectedAt)
- Migration `019_vulnscan``VulnerabilityFinding` (repoId, vulnId, purl, version, summary, details, cvssScore, fixedVersion, dismissed, dismissedBy, dismissedAt, detectedAt)
- Migration `020_forgefed` — repository + pull request column updates
### Fixed
- SBOM per-run download endpoint (`/runs/{runID}/sbom/document`) was registered at the wrong router nesting level, causing a route conflict with the `GetLatestDocument` handler. Moved into the correct `/runs/{runID}` route block.
- `username` context key extraction in scanning and vulnerability handlers changed from raw string `"user"` to typed `middleware.ContextKeyUsername`
- Nil-safe `Needs` marshalling in orchestrator job creation
- Nil-safe findings response in vulnerability scan API
- `GenerateOnDemand` SBOM cache key now includes `runID` to prevent per-run generation from being shadowed by prior on-demand generation
---
## [0.9.0] — 2026-05-12
Phase 3F complete. ForgeBucket is now a first-class ActivityPub node — interoperable with Mastodon, Forgejo, and any fediverse server.
### Added — ActivityPub Federation (`internal/domain/federation/`)
- **`GET /.well-known/webfinger`** — resolves `acct:user@domain` to the actor URL; returns `application/jrd+json`
- **`GET /users/{username}`** — returns a JSON-LD actor document (`Person` type) including public key object for HTTP signature verification
- **`POST /users/{username}/inbox`** — receives and dispatches inbound ActivityPub activities; HTTP signature verification enforced in production (skipped in `DEBUG=true` mode for local testing)
- **`GET /users/{username}/outbox`** — serves an `OrderedCollection` (summary on page 0, paginated `OrderedCollectionPage` on page ≥ 1, 20 activities per page)
- **`GET /users/{username}/followers`** — stub `OrderedCollection` (zero items; social graph in Phase 4)
- **`GET /users/{username}/following`** — stub `OrderedCollection`
### Added — HTTP Signatures (`internal/domain/federation/signatures.go`)
- `Sign(req, keyID, privateKeyPEM)` — signs outgoing HTTP requests with RSA-SHA256; covers `(request-target)`, `host`, and `date` headers
- `Verify(r, db, instanceURL)` — parses `Signature` header, resolves sender's public key (local `FederationActor` first, then network fetch via `FetchActor`), verifies RSA-SHA256 digest
### Added — Actor Lifecycle (`internal/domain/federation/actor.go`)
- `GetOrCreate` — lazily creates a `FederationActor` for a local user; generates a fresh RSA-2048 key pair and derives `InboxURL`, `OutboxURL`, `APID` from `INSTANCE_URL`; stable across requests
- `ActorJSON` — returns the JSON-LD document shape expected by all ActivityPub clients
- `APID(instanceURL, username)` — canonical `{instanceURL}/users/{username}` helper
### Added — Follow / Accept Flow (`internal/domain/federation/inbox.go`)
- Incoming `Follow` activities are auto-accepted: remote actor is fetched (or retrieved from cache), an `Accept` activity is signed and delivered to their inbox asynchronously
- Both the inbound `Follow` and outbound `Accept` are persisted to `FederationActivity` for audit
### Added — Remote Actor Cache (`internal/domain/federation/remote.go`)
- `FetchActor` — HTTP GET with `Accept: application/activity+json`, extracts inbox URL and public key PEM, stores in `RemoteActor` table to avoid repeated fetches
- `DeliverActivity` — marshals activity JSON, signs the request, POSTs to recipient inbox with 15-second timeout
### Added — Database Models (migration `014_federation`)
- `FederationActivity` — append-only log of all inbound and outbound activities: `ActorAPID`, `Type`, `ObjectJSON`, `Direction` (inbound/outbound), `RemoteActor`, `Published`
- `RemoteActor` — cache for remote actor documents: `APID` (unique), `InboxURL`, `PublicKey`, `FetchedAt`
---
## [0.8.0] — 2026-05-12
Phase 3E complete. Prometheus metrics, structured health checks, and per-repo operational health are operational.
### Added — Prometheus Metrics (`internal/observability/`)
- `GET /metrics` — Prometheus text format endpoint (standard root-level path for k8s/Prometheus scraping)
- `GET /health` — upgraded from static `{"status":"ok"}` to a structured liveness response:
`{"status":"healthy","checks":{"database":"ok","nats":"ok"},"version":"0.8.0"}`
Returns HTTP 503 when any dependency is degraded
- `internal/observability/metrics.go` — metric definitions:
- `forgebucket_http_requests_total{method,path,status}` — counter
- `forgebucket_http_request_duration_seconds{method,path}` — histogram (Prometheus default buckets)
- `forgebucket_pipeline_runs_total{status}` — counter (succeeded/failed/cancelled), pre-initialized to 0
- `forgebucket_deployments_total{status}` — counter (pending/success/failure/cancelled), pre-initialized to 0
- `forgebucket_active_pipeline_runs` — gauge (in-flight runs)
- `internal/observability/health.go``Check(db, bus)` pings PostgreSQL and calls `bus.Healthy()`
- HTTP instrumentation middleware inserted after `Recoverer`, before `CORS` — records every request
- Path normalization prevents label cardinality explosion: `/repos/alice/myrepo/runs/42`
`/api/v1/repos/:owner/:repo/runs/:id`
- NATS metric watcher subscribes to `pipeline.>` and `deployment.>` and increments counters
### Added — Per-Repo Operational Health (`GET /api/v1/repos/{owner}/{repo}/health`)
- Returns a JSON summary for the repo page operational header:
- `ciPassRate7d` — fraction of pipeline runs that succeeded in the last 7 days
- `totalRuns7d` — total run count in the last 7 days
- `latestRun` — most recent `PipelineRun` record
- `latestDeployments` — one entry per environment showing latest deploy (envName, status, sha, finishedAt)
- `openDriftCount` — GitOpsConfigs in `drifted` state
- `openPRCount` — open pull request count
### Added — EventBus `Healthy() bool`
- Added to the `EventBus` interface; `NATSBus` returns `nc.IsConnected()`; `NoOpBus` returns `true`
### Changed — Middleware chain
- `observability.Middleware()` added between `Recoverer` and `CORS` (applies to all requests including `/health` and `/metrics`)
---
## [0.7.0] — 2026-05-12
Phase 3D complete. Git is now the source of truth for environment deployment state.
### Added — GitOps Controller (`internal/domain/gitops/`)
- `controller.go` — starts as a background goroutine; subscribes to `push.received`,
`deployment.succeeded`, `deployment.failed`; runs a periodic reconciliation ticker
(interval configurable via `GITOPS_RECONCILE_INTERVAL`); recovers stale `syncing`
configs to `drifted` on startup
- `drift.go``CheckDrift` calls `git rev-parse` via the existing git domain wrapper;
`handlePush` queries all GitOpsConfigs matching the pushed branch and evaluates drift;
`periodicCheck` iterates configs whose `SyncInterval` has elapsed; publishes
`environment.drift_detected` when drift is found
- `reconciler.go``TriggerSync` creates a `Deployment` record and publishes
`deployment.started` (same lifecycle path as manual deployments, `TriggeredBy="gitops"`);
`handleDeploymentSucceeded` resolves open drift events and marks config `synced` for
both GitOps and manual deployments; `handleDeploymentFailed` reverts to `drifted`
### Added — GitOps HTTP API (`internal/api/handlers/gitops.go`)
All routes live under `/api/v1/repos/{owner}/{repo}/environments/{envName}/gitops/`:
- `GET /gitops` — current GitOpsConfig or 404 if not configured
- `PUT /gitops` — idempotent upsert (branch, autoSync, syncInterval)
- `DELETE /gitops` — remove config without deleting deployments
- `POST /gitops/sync` — manual reconciliation trigger; creates deployment record
- `GET /gitops/drift` — current sync status: syncStatus, desiredSha, actualSha, isDrifted
- `GET /gitops/drift/history` — paginated drift event log (newest first)
- `POST /gitops/drift/{driftID}/acknowledge` — acknowledge without syncing
### Added — Database Models (migration `013_gitops`)
- `GitOpsConfig` — links environment to a branch; tracks `DesiredSHA`, `ActualSHA`,
`SyncStatus` (`unknown/synced/drifted/syncing`), `AutoSync`, `SyncInterval`,
`LastCheckedAt`
- `GitOpsDriftEvent` — append-only drift record: `DesiredSHA`, `ActualSHA`,
`SyncStatus` (`drifted/synced/acknowledged`), `DetectedAt`, `ResolvedAt`
### Added — Supporting Changes
- `git.RevParse(repoPath, ref)` — new function in `internal/domain/git/binary.go`
used by `CheckDrift` to resolve branch HEAD SHA
- `events.DeploymentEvent` + `events.DriftEvent` types added to `internal/events/types.go`
- `EnvironmentHandler.publishDeployEvent` updated to use shared `events.DeploymentEvent`
so the GitOps controller can unmarshal deployment lifecycle events correctly
- `GITOPS_RECONCILE_INTERVAL` env var (default `300`s); `0` disables the periodic ticker
- `ArtifactRoot` config field + `ARTIFACT_ROOT` env var
---
## [0.6.0] — 2026-05-12
Phase 3C complete. Multi-tenant workspaces and a full secret management hierarchy operational.
### Added — Workspaces
- `Workspace` model (migration `011`): globally unique handle, display name, description, avatarUrl
- `WorkspaceMember` model: owner/admin/member roles per workspace
- Repository `workspace_id` column (optional; null = personal repo)
- Full workspace CRUD API: `GET/POST /api/v1/workspaces`, `GET/PATCH/DELETE /api/v1/workspaces/{handle}`
- Workspace member management: list, add, update role, remove
- `GET /api/v1/workspaces/{handle}/repos` — repos in workspace
- Workspace frontend: WorkspacesPage, WorkspacePage, workspace switcher in sidebar header
- Workspace owner selector in repo create flow
### Added — Secret Management (`internal/api/handlers/secret.go`)
- `Secret` model (migration `012`): `Scope` (global/workspace/repo/env), `ScopeID`, `Name`,
`EncryptedValue` (AES-256-GCM, never returned by API)
- Unique constraint on (scope, scope_id, name)
- CRUD at all scope levels:
- `GET/POST/DELETE /api/v1/admin/secrets` (global, admin-only)
- `GET/POST/DELETE /api/v1/workspaces/{handle}/secrets` (workspace-scoped)
- `GET/POST/DELETE /api/v1/repos/{owner}/{repo}/secrets` (repo-scoped)
- `GET/POST/DELETE /api/v1/repos/{owner}/{repo}/environments/{envName}/secrets` (env-scoped)
- `ResolveSecretsForRun(db, repoID, workspaceID, envID, sessionSecret)` — hierarchy
resolution for CI executor: Env > Repo > Workspace > Global
- CI executor updated to inject resolved secrets as Docker `--env` flags
- RepoSecretsPage — write-only UI, values never displayed after creation
- Sidebar "Secrets" nav item in repo context - Sidebar "Secrets" nav item in repo context
### Completed — Phase 3B (Unified Operational Timeline) ---
- `GET /api/v1/repos/:owner/:repo/timeline` — merges commits, pipeline runs, and deployments into a single chronological feed
- `RepoTimelinePage` at `/repos/:owner/:repo/timeline` — vertical event feed with type filter tabs ## [0.5.0] — 2026-05-11
Phases 3A and 3B complete. Environments, deployments, and the operational timeline are operational.
### Added — Environments + Deployments (Phase 3A)
- `Environment` model (migration `010`): repoId, name, URL, protectionRules (JSON)
- `Deployment` model: envId, repoId, sha, ref, status lifecycle
(`pending → in_progress → success/failure/cancelled`), triggeredBy, description, runId link
- CRUD API for environments: `GET/POST /environments`, `GET/PATCH/DELETE /environments/{envName}`
- Deployment API: `GET/POST /environments/{envName}/deployments`,
`PATCH /environments/{envName}/deployments/{id}/status`
- NATS events published on status transitions: `deployment.started`, `deployment.succeeded`,
`deployment.failed`
- `EnvironmentsPage` — environment cards each showing latest deployment status, SHA, actor,
and time since deploy; deployment history per env
- Sidebar "Environments" nav item in repo context
- Repo page deployment status badges (latest deploy per env at a glance)
### Added — Unified Operational Timeline (Phase 3B)
- `GET /api/v1/repos/{owner}/{repo}/timeline` — merged chronological feed of commits,
pipeline runs, and deployments; default 60 events, max 200
- `RepoTimelinePage` at `/repos/:owner/:repo/timeline` — vertical event feed with type
filter tabs (all / commits / runs / deployments)
- Sidebar "Timeline" nav item between Environments and Settings - Sidebar "Timeline" nav item between Environments and Settings
- Event types: commit (SHA, message, author), run (status, ref, duration), deployment (env, status, SHA) - Answers "what changed before things broke?" without navigating between separate pages
### Completed — Phase 3A (Environment model + deployment tracking) ---
- `Environment` model per repo (name, URL, protection rules)
- `Deployment` model (sha, ref, status, triggered_by, run_id link)
- Full CRUD API for environments
- Deployment trigger + status update API
- NATS event publishing for `deployment.*` subjects
- `EnvironmentsPage` per repo — environment cards with live deployment status
- Deployment history per environment
- Sidebar "Environments" nav item
- Repo page deployment status badges
### Completed — Phase 2C (CI Legibility) ## [0.4.0] — 2026-05-11
- `PipelinesPage` — real cross-repo runs feed with status filter tabs
Phase 2C complete. CI results are legible in the UI; the dashboard is an operational command center.
### Added — Pipeline Visualization
- `PipelinesPage` — cross-repo pipeline runs feed with status filter tabs (all / running / failed / succeeded)
- `RepoPipelinesPage` — repo-scoped runs list at `/repos/:owner/:repo/pipelines` - `RepoPipelinesPage` — repo-scoped runs list at `/repos/:owner/:repo/pipelines`
- `PipelineRunPage` — run detail with topological DAG visualization + step log viewer - `PipelineRunPage` — run detail with topological DAG visualization using real `PipelineJob[]` +
- `PipelineWaterfall` — rewritten to accept real `PipelineJob[]` data with `needs` graph `needs` graph; step log viewer (collapsible per step, ANSI color, auto-scroll with lock toggle)
- Dashboard CI widget — live recent runs replacing "coming soon" placeholder - `PipelineWaterfall` — rewritten to accept live job data instead of static mock stages
- Command palette — pipeline run results + Pipelines quick-nav - `GET /api/v1/pipelines/runs` — cross-repo recent runs for the dashboard
- `GET /api/v1/pipelines/runs` — cross-repo recent runs endpoint
- Dashboard `recentRuns[]` field added
### Planned — Phase 3 (GitOps + Observability + Federation) ### Added — Dashboard CI Command Center
- GitOps controller with reconciliation loops - Dashboard CI widget replaced "coming soon" with live recent pipeline runs
- Environment model + deployment tracking - Dashboard `recentRuns[]` field added to the `/api/v1/dashboard` response
- Unified operational timeline (commits + deployments + CI failures merged)
- Drift detection and sync status
- Deployment promotion workflows (dev → staging → production)
- Rollback visualization and one-click rollbacks
- Canary and blue/green deployment support
- ActivityPub / ForgeFed federation handlers (inbox, outbox, cross-instance PRs)
- Secret management hierarchy (Global → Org → Repo → Env)
- Observability (Prometheus endpoint, health sparklines)
### Planned — Phase 4 ### Added — Command Palette Wiring
- AI diagnostics (pipeline failure root-cause analysis) - Pipeline run results surfaced in command palette results
- Signed artifacts (Sigstore/Cosign) - "Pipelines" quick-nav action
- OCI package registry
- Secret and dependency vulnerability scanning
--- ---
@@ -75,38 +292,35 @@ Phase 2B complete. Full CI/CD execution backend operational.
### Added — CI Orchestrator (`internal/domain/ci/`) ### Added — CI Orchestrator (`internal/domain/ci/`)
- DAG-based pipeline orchestrator (`orchestrator.go`): subscribes to NATS `push.received`, - DAG-based pipeline orchestrator (`orchestrator.go`): subscribes to NATS `push.received`,
parses `.forgebucket/workflows/*.yml`, creates `PipelineRun`/`PipelineJob`/`PipelineStep` parses `.forgebucket/workflows/*.yml`, creates `PipelineRun/Job/Step` records, advances
records, advances DAG on `job.completed`/`job.failed`, recovers stale runs on startup DAG on `job.completed/failed`, recovers stale runs on startup
- Docker executor (`executor.go`): runs steps in isolated containers (`docker run --rm`), - Docker executor (`executor.go`): steps run in isolated containers (`docker run --rm`),
streams logs to DB and NATS via `pipeline.log` subject, handles `git archive` workspace extraction logs stream to DB and NATS via `pipeline.log`, workspace extracted via `git archive`
- Runner manager (`runner_manager.go`): semaphore-limited concurrent job dispatch (default 4), - Runner manager (`runner_manager.go`): semaphore-limited (default 4 concurrent),
subscribes to `job.queued`, calls executor when Docker is available subscribes to `job.queued`, skips gracefully if Docker is unavailable
- DAG engine (`dag.go`): full topological sort (`TopoSort`) and `ReadyJobs` for dependency resolution - DAG engine (`dag.go`): `TopoSort`, `ReadyJobs`
- Workflow parser (`parser.go`): reads `.forgebucket/workflows/*.yml` from git ref, - Workflow parser (`parser.go`): `.forgebucket/workflows/*.yml` from git ref,
`MatchesPushTrigger` with glob pattern support `MatchesPushTrigger` with glob branch patterns; `StringOrSlice` YAML unmarshaler
- CI types (`types.go`): `WorkflowFile`, `WorkflowJob`, `WorkflowStep`, YAML `StringOrSlice` unmarshaler
### Added — CI API Handlers ### Added — CI API Handlers
- `GET /api/v1/repos/:owner/:repo/pipelines` list pipeline definitions - `GET /api/v1/repos/:owner/:repo/pipelines` — pipeline definitions
- `GET /api/v1/repos/:owner/:repo/runs` list pipeline runs (most recent first, limit 30) - `GET /api/v1/repos/:owner/:repo/runs` — pipeline runs (newest first)
- `GET /api/v1/repos/:owner/:repo/runs/:runID` — run detail with full job + step tree - `GET /api/v1/repos/:owner/:repo/runs/:runID` — run detail with job + step tree
- `POST /api/v1/repos/:owner/:repo/runs/:runID/cancel` — cancel queued or running run - `POST /api/v1/repos/:owner/:repo/runs/:runID/cancel`
- `POST /api/v1/repos/:owner/:repo/runs/:runID/jobs/:jobID/retry` — re-queue failed/cancelled job - `POST /api/v1/repos/:owner/:repo/runs/:runID/jobs/:jobID/retry`
- `GET /api/v1/repos/:owner/:repo/runs/:runID/jobs/:jobID/logs` — step-level log chunks - `GET /api/v1/repos/:owner/:repo/runs/:runID/jobs/:jobID/logs` — step log chunks
- `GET /api/v1/repos/:owner/:repo/runs/:runID/artifacts` — list artifacts for a run - `GET/POST /api/v1/repos/:owner/:repo/runs/:runID/artifacts`
- `POST /api/v1/repos/:owner/:repo/runs/:runID/artifacts` — upload artifact (multipart, 512 MB max) - `GET /api/v1/repos/:owner/:repo/artifacts/:artifactID/download` — path-traversal guarded
- `GET /api/v1/repos/:owner/:repo/artifacts/:artifactID/download` — artifact download with path traversal guard - `GET/POST /api/v1/admin/runners` — runner list + registration (admin-only, bcrypt token)
- `GET /api/v1/admin/runners` — list registered runners (admin-only)
- `POST /api/v1/admin/runners/register` — register a new runner with bcrypt token hashing (admin-only)
### Added — Database Models (migration `009_ci`) ### Added — Database Models (migration `009_ci`)
- `Pipeline` — workflow definition record (name, filePath, repoId) - `Pipeline`, `PipelineRun`, `PipelineJob`, `PipelineStep`, `PipelineStepLog`
- `PipelineRun` — execution record (triggerRef, triggerSha, triggeredBy, status, startedAt, finishedAt) - `Runner` (name, labels, status, tokenHash, lastSeenAt)
- `PipelineJob` — single DAG node (name, image, needs JSON, status, timing) - `Artifact` (runId, repoId, name, storagePath, size, contentType)
- `PipelineStep` — single command within a job (seq, runCmd, usesAction, exitCode, timing)
- `PipelineStepLog` — append-only log chunk storage (stepId, chunkIndex, content) ### Changed — Git HTTP handler
- `Runner` — registered execution backend (name, labels, status, tokenHash, lastSeenAt) - `parseAndCheckBody` replaces `checkProtectionsFromBody` — now also returns parsed
- `Artifact` — build artifact (runId, repoId, name, storagePath, size, contentType) `refUpdate` structs for publishing `push.received` after each successful receive-pack
--- ---
@@ -116,105 +330,84 @@ Phase 2A complete. Real-time event infrastructure and audit log operational.
### Added — NATS Event Bus (`internal/events/`) ### Added — NATS Event Bus (`internal/events/`)
- `EventBus` interface: `Publish`, `Subscribe`, `Close` - `EventBus` interface: `Publish`, `Subscribe`, `Close`
- `NATSBus`: NATS-backed implementation with auto-reconnect, max-reconnect disabled - `NATSBus`: NATS-backed with auto-reconnect; `NoOpBus` fallback when `NATS_URL` unset
- `NoOpBus`: silent fallback when `NATS_URL` is not configured (app fully functional without NATS) - `New(url)` factory: returns `NATSBus` or `NoOpBus`
- `New(url)` factory: returns `NATSBus` if URL is set, `NoOpBus` otherwise - 40+ event subjects in `subjects.go` covering repo, push, PR, issue, pipeline, job,
- Event subjects defined in `subjects.go`: deployment, environment, and audit namespaces
- `repo.*` (created, deleted, pushed)
- `push.received`
- `pr.*` (opened, merged, closed)
- `issue.*` (opened, closed)
- `pipeline.*` (queued, started, succeeded, failed, cancelled)
- `job.*` (queued, started, completed, failed), `pipeline.log`
- `deployment.*`, `environment.*` (Phase 3 stubs)
- `audit.event`
### Added — WebSocket Hub (`internal/api/handlers/ws.go`) ### Added — WebSocket Hub
- `GET /ws`upgrades HTTP to WebSocket (nhooyr.io/websocket) - `GET /ws`NATS wildcard subscription (`>`) fans all events to connected clients as JSON
- Subscribes to all NATS subjects on connect, fans events to the client as JSON - `{ subject, payload }` envelope format
- Optional session auth (`auth.Optional` middleware) — works for guests too - Goroutine per client with buffered send channel (64 events); slow clients drop events
- Phase 2B note: per-user event filtering is a planned upgrade
### Added — Audit Log ### Added — Audit Log (migration `008_audit_log`)
- `AuditLog` model (migration `008_audit_log`): actor, method, path, statusCode, requestBody, ipAddr, timestamp - `AuditLog` model: actorId, actorName, method, path, statusCode, ipAddress, userAgent
- `AuditLog` middleware: records every authenticated request to the DB and publishes `audit.event` - Middleware records every POST/PUT/PATCH/DELETE in the protected route group
- `GET /api/v1/audit` — paginated audit log query (admin-only, filterable by actor/method/time range) - Writes DB row + publishes `audit.event` asynchronously (never blocks the response)
- `GET /api/v1/audit` — paginated, filterable by actor/method/since (admin-only)
### Fixed — Local development environment
- `DATABASE_URL` was using Docker-internal hostname `postgres`; corrected to `localhost` for `make dev`
- Added `NATS_URL=nats://localhost:4222` to `.env` (was missing; CI orchestrator requires it)
- `REPO_ROOT` corrected to `/tmp/forgebucket/repos` (Docker path `/var/lib/forgebucket/repos` requires sudo on macOS)
--- ---
## [0.1.0] — 2026-05-11 ## [0.1.0] — 2026-05-11
Initial development milestone. Core Git hosting, collaboration, and frontend SPA are functional. Initial development milestone. Core Git hosting, collaboration, and frontend SPA functional.
### Added — Authentication & Security ### Added — Authentication & Security
- User registration and login with secure session cookies - User registration and login with secure session cookies
- CSRF protection on all mutating routes via `X-CSRF-Token` header - CSRF protection via double-submit cookie pattern (`X-CSRF-Token`)
- Middleware chain: Logger → RealIP → Recoverer → CORS → CSRF → SessionAuth → RBAC → Handler
- SSH key management per user - SSH key management per user
- OIDC / OAuth2 optional integration (configurable via env) - OIDC / OAuth2 optional integration
- Scoped access tokens with optional expiration dates - Scoped access tokens with optional expiration
- Repository deploy keys (read-only or read-write HTTP tokens) - Repository deploy keys (read-only or read-write)
- ENV-driven config with fail-fast validation on missing secrets - ENV-driven config with fail-fast on missing secrets
### Added — Git Hosting ### Added — Git Hosting
- Smart HTTP transport (git clone, push, pull over HTTP) - Smart HTTP transport (clone, push, pull over HTTP)
- AGit protocol support (`refs/for/` push for instant PR creation without branch switching) - AGit protocol (`refs/for/` push for instant PR creation)
- Branch management (list, create, delete, default branch configuration) - Branch management, commit log, diff viewing
- Commit log and diff viewing - Git LFS per-repository (configurable file size limits)
- Git LFS per-repository (configurable file size limits, locking) - Branch protection rules (force-push blocking)
- Branch protection rules (force-push blocking, required reviews)
- Repository visibility (public / private) - Repository visibility (public / private)
### Added — Collaboration ### Added — Collaboration
- Pull requests (open / merged / closed states) with author tracking - Pull requests (open / merged / closed) with author tracking
- Issues (open / closed) - Issues (open / closed)
- Reviewer assignment (default reviewer per repo, per-PR reviewer assignment) - Reviewer assignment (default reviewer per repo, per-PR overrides)
- Merge strategy selection per repository (merge commit / squash / rebase) - Merge strategy selection per repository (merge / squash / rebase)
- Branching model configuration (feature / bugfix / release / hotfix prefixes) - Branching model configuration (feature / bugfix / release / hotfix prefixes)
- PR default description templates (per-repo) - PR default description templates + excluded-files configuration
- Excluded files from diffs (glob pattern configuration)
- Webhook system with event filtering (push, pull_request, issue) - Webhook system with event filtering (push, pull_request, issue)
- Repository member RBAC (read / write / admin roles) - Repository member RBAC (read / write / admin)
### Added — Frontend SPA ### Added — Frontend SPA
- React 18 + TypeScript + Vite, embedded into Go binary via `//go:embed` - React 18 + TypeScript + Vite, embedded into Go binary via `//go:embed`
- 20 route-level pages: Login, Register, Dashboard, Repos, CreateRepo, ImportRepo, Repo, - 20 route-level pages covering auth, dashboard, repos, code, PRs, issues, and settings
RepoSettings, Blob, Commits, Branches, RepoIssues, RepoPRs, CreatePR, PRDetail, Starred,
PRs (cross-repo), Pipelines (placeholder), Explore, Profile, Settings
- AppShell layout wrapper for all authenticated pages
- Triple-state sidebar: expanded (320px) / collapsed (56px) / mobile bottom bar - Triple-state sidebar: expanded (320px) / collapsed (56px) / mobile bottom bar
- Mobile-first responsive design (375px → 1440px) - Mobile-first responsive design (375px → 1440px)
- DiffViewer: side-by-side and unified views with syntax highlighting - DiffViewer (side-by-side + unified), MobileComment (bottom-sheet), TreeBrowser
- MobileComment: bottom-sheet overlay for inline code review on mobile
- TreeBrowser: repository file tree navigation
- PipelineWaterfall: placeholder pipeline visualization component
- Skeleton loading states for perceived performance
### Added — Design System ### Added — Design System
- Custom semantic token palette in `frontend/src/ui/tokens.ts` - Custom semantic token palette in `frontend/src/ui/tokens.ts`
- Full dark/light mode support via Tailwind CSS v4 `@variant dark` - Full dark/light mode via Tailwind CSS v4 `@variant dark`
- Brand colors: `#0052CC` (light) / `#3B82F6` (dark) - 8px grid system; 44px minimum touch targets (WCAG 2.5.5)
- 8px grid system (xs: 4px, sm: 8px, md: 16px, lg: 24px, xl: 32px, xxl: 48px)
- 44px minimum touch targets on all interactive elements (WCAG 2.5.5)
- Consistent border radius scale (subtle 38px, full 9999px)
- System font stack (Segoe UI, Roboto, sans-serif) - System font stack (Segoe UI, Roboto, sans-serif)
### Added — Infrastructure ### Added — Infrastructure
- PostgreSQL + XORM with 7 migration files covering: users, repositories, issues, SSH keys, - PostgreSQL + XORM with migrations 001007
access tokens, deploy keys, workflows, and LFS settings - ActivityPub actor data model (FederationActor) — data layer only
- ActivityPub actor data model (FederationActor with inbox/outbox URLs and RSA key pairs) — data layer only - Docker Compose for local PostgreSQL + NATS
- Docker Compose setup for local PostgreSQL + NATS - Makefile: dev, build, migrate, test, lint, docker-up
- Makefile targets: dev, build, migrate, test, lint, docker-up
- WebSockets foundation for live logs and notifications
--- ---
[Unreleased]: https://github.com/forgeo/forgebucket/compare/v0.3.0...HEAD [Unreleased]: https://github.com/forgeo/forgebucket/compare/v1.0.0...HEAD
[1.0.0]: https://github.com/forgeo/forgebucket/compare/v0.9.0...v1.0.0
[0.9.0]: https://github.com/forgeo/forgebucket/compare/v0.8.0...v0.9.0
[0.8.0]: https://github.com/forgeo/forgebucket/compare/v0.7.0...v0.8.0
[0.7.0]: https://github.com/forgeo/forgebucket/compare/v0.6.0...v0.7.0
[0.6.0]: https://github.com/forgeo/forgebucket/compare/v0.5.0...v0.6.0
[0.5.0]: https://github.com/forgeo/forgebucket/compare/v0.4.0...v0.5.0
[0.4.0]: https://github.com/forgeo/forgebucket/compare/v0.3.0...v0.4.0
[0.3.0]: https://github.com/forgeo/forgebucket/compare/v0.2.0...v0.3.0 [0.3.0]: https://github.com/forgeo/forgebucket/compare/v0.2.0...v0.3.0
[0.2.0]: https://github.com/forgeo/forgebucket/compare/v0.1.0...v0.2.0 [0.2.0]: https://github.com/forgeo/forgebucket/compare/v0.1.0...v0.2.0
[0.1.0]: https://github.com/forgeo/forgebucket/releases/tag/v0.1.0 [0.1.0]: https://github.com/forgeo/forgebucket/releases/tag/v0.1.0
+88 -46
View File
@@ -4,7 +4,7 @@
ForgeBucket is a self-hosted, federated developer operations platform. Where other Git platforms show you a list of files, ForgeBucket surfaces deployments, pipeline health, environment drift, and operational context directly alongside your code. Repositories are runtime systems. The dashboard is a command center. ForgeBucket is a self-hosted, federated developer operations platform. Where other Git platforms show you a list of files, ForgeBucket surfaces deployments, pipeline health, environment drift, and operational context directly alongside your code. Repositories are runtime systems. The dashboard is a command center.
**Status:** Phase 2C in progress. CI/CD execution backend is fully operational. Pipeline visualization and dashboard integration are being wired up now. **Status:** Active development. Phase 4 (signed artifacts, SBOM, secret/dependency scanning, OCI registry) complete. Phase 5 (AI diagnostics) is next.
--- ---
@@ -32,7 +32,8 @@ ForgeBucket is a self-hosted, federated developer operations platform. Where oth
| OIDC / OAuth2 (optional) | Done | | OIDC / OAuth2 (optional) | Done |
| Access tokens (scoped, expiring) | Done | | Access tokens (scoped, expiring) | Done |
| Deploy keys | Done | | Deploy keys | Done |
| Audit log | Done | | Audit log (admin-only, filterable) | Done |
| Workspaces (multi-tenant namespaces) | Done |
### Git Hosting ### Git Hosting
| Feature | Status | | Feature | Status |
@@ -59,44 +60,62 @@ ForgeBucket is a self-hosted, federated developer operations platform. Where oth
### CI/CD ### CI/CD
| Feature | Status | | Feature | Status |
|---------|--------| |---------|--------|
| CI orchestrator (DAG pipeline execution) | Done (Phase 2B) | | NATS event bus + WebSocket live push | Done |
| Runner manager (Docker backend) | Done (Phase 2B) | | CI orchestrator (DAG pipeline execution) | Done |
| Build artifact storage | Done (Phase 2B) | | Runner manager (Docker backend) | Done |
| Pipeline cancellation + job retry | Done (Phase 2B) | | Build artifact storage + download | Done |
| NATS event bus + WebSocket live push | Done (Phase 2A) | | Pipeline cancellation + job retry | Done |
| Pipeline DAG visualization (frontend) | Done (Phase 2C) | | Pipeline log streaming (per-step, NATS) | Done |
| Dashboard CI command center | Done (Phase 2C) | | Pipeline DAG visualization (frontend) | Done |
| Pipeline log viewer (per-step, collapsible) | Done (Phase 2C) | | Dashboard CI command center | Done |
| Kubernetes / Firecracker runner backends | Planned (Phase 2D) | | Pipeline log viewer (collapsible, per-step) | Done |
| Forgejo Actions gRPC integration | Planned | | SBOM auto-generation on pipeline success | Done |
| Per-run SBOM download on pipeline detail page | Done |
| Kubernetes / Firecracker runner backends | Planned |
| Matrix builds + reusable workflow templates | Planned | | Matrix builds + reusable workflow templates | Planned |
| Flaky test detection | Planned | | Flaky test detection | Planned |
### GitOps + Environments ### Environments + GitOps
| Feature | Status | | Feature | Status |
|---------|--------| |---------|--------|
| Environment model + deployment tracking | **In progress (Phase 3A)** | | Environment model + deployment tracking | Done |
| Unified operational timeline | Planned (Phase 3B) | | Deployment status lifecycle API | Done |
| Secret management hierarchy | Planned (Phase 3C) | | Unified operational timeline | Done |
| GitOps controller + drift detection | Planned (Phase 3D) | | Secret management (Global → Workspace → Repo → Env) | Done |
| Deployment promotion workflows | Planned (Phase 3D) | | GitOps controller (drift detection + auto-sync) | Done |
| Rollback visualization | Planned (Phase 3D) | | Deployment promotion workflows | Planned |
| Canary / blue-green support | Planned (Phase 3D) | | Rollback visualization | Planned |
| Canary / blue-green support | Planned |
### Observability + Security ### Observability + Security
| Feature | Status | | Feature | Status |
|---------|--------| |---------|--------|
| Prometheus endpoint + health sparklines | Planned (Phase 3E) | | `GET /health` — structured DB + NATS liveness check | Done |
| Secret scanning | Planned (Phase 4) | | `GET /metrics` — Prometheus endpoint (HTTP + platform metrics) | Done |
| Dependency scanning | Planned (Phase 4) | | HTTP instrumentation middleware (latency histogram, request counter) | Done |
| Signed artifacts (Sigstore/Cosign) | Planned (Phase 4) | | Per-repo operational health summary (`GET /repos/.../health`) | Done |
| NATS-driven pipeline + deployment counters | Done |
| SBOM generation (CycloneDX 1.4, auto on pipeline success) | Done |
| Secret scanning (15 regex patterns, push-triggered) | Done |
| Dependency vulnerability scanning (OSV API backed) | Done |
| Signed artifacts (ECDSA P-256, self-verifying bundles) | Done |
| OCI Distribution Spec v1.1 registry | Done |
| Unified repo Security page (SBOM + leaks + vulns) | Done |
| Health sparklines in repo/env pages (frontend) | Planned |
### Federation ### Federation
| Feature | Status | | Feature | Status |
|---------|--------| |---------|--------|
| ActivityPub actor model | Done (data layer) | | ActivityPub actor model | Done |
| Federation handlers / inbox / outbox | Planned (Phase 3F) | | WebFinger (`/.well-known/webfinger`) | Done |
| Cross-instance pull requests | Planned (Phase 3F) | | Actor documents (`/users/{username}`) | Done |
| Inbox (receive + HTTP signature verify) | Done |
| Outbox (OrderedCollection, paginated) | Done |
| Followers / Following collections | Done |
| HTTP signatures (draft-cavage-http-signatures) | Done |
| Follow / Accept auto-accept flow | Done |
| RSA-2048 key pair lazy generation | Done |
| Cross-instance pull requests (ForgeFed) | Planned |
--- ---
@@ -120,7 +139,7 @@ make dev
The Go API runs at `http://localhost:8080`. The Vite dev server runs at `http://localhost:5173` and proxies API requests. The Go API runs at `http://localhost:8080`. The Vite dev server runs at `http://localhost:5173` and proxies API requests.
> **Local dev note:** `DATABASE_URL` must use `localhost` (not `postgres`) and `NATS_URL` must be set to `nats://localhost:4222`. The `.env` file ships with correct defaults for local development. See `.env.example` for all variables. > **Docker note:** CI execution requires the Docker daemon to be running. If Docker is unavailable, the runner manager logs a warning and disables CI; the rest of the platform works normally.
--- ---
@@ -128,22 +147,32 @@ The Go API runs at `http://localhost:8080`. The Vite dev server runs at `http://
``` ```
ForgeBucket ForgeBucket
├── API Gateway (Chi router, internal/api/) ├── API Gateway (Chi router internal/api/router.go)
├── Auth Service (sessions, CSRF, OIDC — internal/api/handlers/) ├── Auth Service (sessions, CSRF, OIDC — internal/api/handlers/)
├── Repository Service (git HTTP, branches, LFS — internal/domain/git/) ├── Repository Service (git HTTP, branches, LFS — internal/domain/git/)
├── Pull Request Service (PRs, reviews, merge — internal/api/handlers/) ├── Pull Request Service (PRs, reviews, merge — internal/api/handlers/)
├── Issue Service (issues, labels — internal/api/handlers/) ├── Issue Service (issues — internal/api/handlers/)
├── CI Orchestrator (DAG execution, Docker runner — internal/domain/ci/) ← Phase 2B done ├── CI Orchestrator (DAG execution, Docker runner — internal/domain/ci/)
├── Event Bus (NATS core, NoOp fallback — internal/events/) ← Phase 2A done ├── GitOps Controller (drift detection, auto-sync — internal/domain/gitops/)
├── Federation Layer (ActivityPub actors — internal/domain/federation/) ← Phase 3F stub ├── Observability (Prometheus metrics, health — internal/observability/)
├── Secret Manager (env-based, scoped tokens — internal/config/) ├── Environment Service (environments, deployments — internal/api/handlers/environment.go)
├── Database (PostgreSQL + XORM — internal/models/) ├── Secret Manager (scoped AES-256-GCM — internal/api/handlers/secret.go)
── Web Frontend (React 18 + TypeScript, embedded via //go:embed — web/) ── Workspace Service (multi-tenant namespaces — internal/api/handlers/workspace.go)
├── SBOM Generator (CycloneDX 1.4, auto on pipeline success — internal/domain/sbom/)
├── Secret Scanner (15 push-triggered regex patterns — internal/domain/scanning/)
├── Vulnerability Scanner (OSV API-backed dependency scanning — internal/domain/vulnscan/)
├── Artifact Signing (ECDSA P-256 self-verifying bundles — internal/domain/signing/)
├── OCI Registry (Distribution Spec v1.1 blob store — internal/domain/oci/)
├── Event Bus (NATS core, NoOp fallback — internal/events/)
├── Audit Log (every mutating request — internal/api/middleware/audit.go)
├── Federation Layer (ActivityPub inbox/outbox, HTTP signatures — internal/domain/federation/)
├── Database (PostgreSQL + XORM 20 migrations — internal/models/)
└── Web Frontend (React 18 + TypeScript, //go:embed — web/)
``` ```
**Middleware chain (every request):** **Middleware chain (every request):**
``` ```
Logger → RealIP → Recoverer → CORS → CSRF → SessionAuth → RBAC → AuditLog → Handler Logger → RealIP → Recoverer → Metrics → CORS → CSRF → SessionAuth → AuditLog → Handler
``` ```
--- ---
@@ -155,15 +184,21 @@ Logger → RealIP → Recoverer → CORS → CSRF → SessionAuth → RBAC → A
| Language | Go 1.21+ | | Language | Go 1.21+ |
| Router | Chi | | Router | Chi |
| ORM / Migrations | XORM + PostgreSQL | | ORM / Migrations | XORM + PostgreSQL |
| Event bus | NATS (core; JetStream planned for Phase 2B durability) | | Event bus | NATS core (`github.com/nats-io/nats.go`) |
| Real-time | WebSockets (nhooyr.io/websocket) | | Real-time | WebSockets (`nhooyr.io/websocket`) |
| CI execution | Docker (`docker run --rm`) | | CI execution | Docker (`docker run --rm` via `exec.Command`) |
| Frontend framework | React 18 + TypeScript | | Frontend framework | React 18 + TypeScript |
| Build tool | Vite | | Build tool | Vite |
| Styling | Tailwind CSS v4 | | Styling | Tailwind CSS v4 |
| YAML parsing | `gopkg.in/yaml.v3` (workflow definitions) |
| Code editing | CodeMirror | | Code editing | CodeMirror |
| Container | Docker Compose (dev) | | Container | Docker Compose (dev) |
| Federation | ActivityPub / ForgeFed (data layer only) | | Federation | ActivityPub / ForgeFed (WebFinger, actor, inbox/outbox, HTTP signatures) |
| SBOM format | CycloneDX 1.4 (JSON) |
| Vulnerability data | OSV API (`api.osv.dev`) |
| Secret detection | Regex-based (15 patterns, push-triggered) |
| Artifact signing | ECDSA P-256 (ASN.1 DER, self-verifying bundles) |
| OCI storage | On-disk content-addressable blob store (Distribution Spec v1.1) |
--- ---
@@ -186,12 +221,16 @@ ForgeBucket has its own design language — intentionally distinct from GitHub a
| Variable | Required | Description | | Variable | Required | Description |
|----------|----------|-------------| |----------|----------|-------------|
| `DATABASE_URL` | Yes | PostgreSQL connection string — use `localhost` for local dev | | `DATABASE_URL` | Yes | PostgreSQL connection string |
| `SESSION_SECRET` | Yes | Session signing key, ≥ 32 chars (`openssl rand -hex 32`) | | `SESSION_SECRET` | Yes | Session signing key, ≥ 32 chars (`openssl rand -hex 32`) |
| `CSRF_SECRET` | Yes | CSRF key, exactly 32 chars (`openssl rand -hex 16`) | | `CSRF_SECRET` | Yes | CSRF key, exactly 32 chars (`openssl rand -hex 16`) |
| `PORT` | No | HTTP port, default `8080` | | `PORT` | No | HTTP port, default `8080` |
| `REPO_ROOT` | Yes | Absolute path for bare git repository storage | | `REPO_ROOT` | Yes | Absolute path for bare git repository storage |
| `NATS_URL` | No | NATS connection URL (e.g. `nats://localhost:4222`). If unset, CI runs in no-op mode | | `ARTIFACT_ROOT` | No | Artifact storage path, defaults to `../artifacts` relative to `REPO_ROOT` |
| `NATS_URL` | No | NATS connection URL (e.g. `nats://localhost:4222`). If unset, event bus is no-op |
| `GITOPS_RECONCILE_INTERVAL` | No | Seconds between periodic drift checks, default `300`. `0` disables the ticker |
| `OCI_ROOT` | No | Root directory for OCI Distribution Spec blob and upload storage, defaults to `../oci` relative to `REPO_ROOT` |
| `ARTIFACT_SIGNING_KEY` | No | Path to ECDSA P-256 PEM for artifact signing; auto-generates ephemeral key if unset (warns on restart) |
| `INSTANCE_URL` | Yes | Public URL of this instance (no trailing slash) | | `INSTANCE_URL` | Yes | Public URL of this instance (no trailing slash) |
| `INSTANCE_NAME` | No | Display name, default `ForgeBucket` | | `INSTANCE_NAME` | No | Display name, default `ForgeBucket` |
| `OIDC_ISSUER` | No | OIDC provider URL | | `OIDC_ISSUER` | No | OIDC provider URL |
@@ -224,9 +263,12 @@ ForgeBucket has its own design language — intentionally distinct from GitHub a
| Phase 2C | Pipeline DAG visualization, dashboard CI upgrade, command palette | Done | | Phase 2C | Pipeline DAG visualization, dashboard CI upgrade, command palette | Done |
| Phase 3A | Environment model + deployment tracking | Done | | Phase 3A | Environment model + deployment tracking | Done |
| Phase 3B | Unified operational timeline | Done | | Phase 3B | Unified operational timeline | Done |
| Phase 3C | Workspaces + secret management hierarchy | **In progress** | | Phase 3C | Workspaces + secret management hierarchy (Global → Workspace → Repo → Env) | Done |
| Phase 3DF | GitOps/drift, federation, observability | Planned | | Phase 3D | GitOps controller + drift detection + auto-sync | Done |
| Phase 4 | AI diagnostics, signed artifacts, OCI registry, dep scanning | Planned | | Phase 3E | Observability (Prometheus `/metrics`, structured `/health`, repo health API) | Done |
| Phase 3F | Federation handlers (ActivityPub WebFinger, actor, inbox/outbox, HTTP signatures) | Done |
| Phase 4 | Signed artifacts, SBOM, OCI registry, secret/dep scanning, security page | Done |
| Phase 5 | AI diagnostics, deployment promotions, rollback visualization | Next |
--- ---
@@ -1,741 +0,0 @@
# MASTER IMPLEMENTATION PROMPT
## Building a Modern Git Platform with CI/CD + GitOps + Operational UX
You are an elite senior staff engineer, platform architect, DevOps architect, distributed systems engineer, and product UX engineer.
You are tasked with designing and implementing a next-generation self-hosted Git platform.
This platform exists in the category of:
- GitHub
- GitLab
- Forgejo
- Gitea
- Bitbucket
BUT:
You must NOT merely clone existing platforms.
The goal is to create:
> a modern developer operations platform
that deeply integrates:
- Git hosting
- pull requests
- CI/CD
- GitOps
- deployments
- environments
- observability
- security
- operational awareness
- developer productivity
The platform should feel:
- fast
- operational
- developer-first
- low cognitive load
- modern
- reliable
- modular
- keyboard-first
- context-aware
The product philosophy is:
> repositories are operational systems
NOT:
> collections of files.
==================================================
HIGH LEVEL PRODUCT GOALS
==================================================
The platform should optimize for:
- immediate situational awareness
- workflow continuity
- deployment visibility
- operational clarity
- reliability
- developer flow
- observability
- intelligent automation
- security by default
- excellent UX
Users should always be able to answer:
- what changed?
- what failed?
- what deployed?
- what is unhealthy?
- what needs my attention?
- what environments are affected?
- what should I review next?
- what caused this incident?
without navigating deeply.
==================================================
CORE PRODUCT PRINCIPLES
==================================================
1. UX FIRST
Most Git platforms prioritize functionality over usability.
This platform must prioritize:
- readability
- discoverability
- contextual awareness
- progressive disclosure
- speed
- low noise
- operational understanding
Avoid:
- tab overload
- enterprise clutter
- giant forms
- notification spam
- YAML-centric UX
- log-centric UX
The system should feel:
- intentional
- calm
- efficient
- operationally intelligent
--------------------------------------------------
2. OPERATIONAL AWARENESS
The platform must continuously surface:
- failing pipelines
- deployment health
- environment drift
- flaky tests
- security issues
- review bottlenecks
- dependency risks
- deployment risks
This information should appear naturally throughout the UI.
--------------------------------------------------
3. REPOSITORIES ARE RUNTIME SYSTEMS
The repository page should not simply display files.
It should display:
- deployments
- environments
- active PRs
- operational health
- timelines
- incidents
- ownership
- risk
- observability
The file tree is secondary.
--------------------------------------------------
4. GITOPS IS FIRST CLASS
Git should become:
- source of truth
- deployment history
- environment state definition
- rollback system
- audit log
The system must support:
- reconciliation
- drift detection
- declarative environments
- promotion workflows
- rollback visualization
- environment topology
--------------------------------------------------
5. RELIABILITY IS A PRIMARY FEATURE
The platform should optimize for:
- deterministic execution
- idempotency
- isolation
- resilience
- queue durability
- observability
- safe rollbacks
- fault tolerance
==================================================
ARCHITECTURE REQUIREMENTS
==================================================
Design the platform using modular services.
Suggested architecture:
```txt
Platform
├── API Gateway
├── Auth Service
├── Repository Service
├── Pull Request Service
├── Issue Service
├── Event Bus
├── CI Orchestrator
├── Runner Manager
├── Artifact Registry
├── Package Registry
├── Deployment Engine
├── GitOps Controller
├── Environment Service
├── Secret Manager
├── Notification Service
├── Search Service
├── Observability Layer
├── Metrics Service
├── Audit Service
└── Web Frontend
```
==================================================
EVENT DRIVEN ARCHITECTURE
==================================================
The platform should be event-driven.
Everything emits events.
Examples:
```txt
repo.created
push.created
pr.opened
review.requested
pipeline.started
pipeline.failed
artifact.published
deployment.started
deployment.failed
environment.drift_detected
incident.created
```
Requirements:
- durable events
- replay support
- auditability
- timeline reconstruction
- reactive UI updates
Recommended technologies:
- NATS
- Kafka
- RabbitMQ
Prefer NATS initially for simplicity.
==================================================
CI/CD SYSTEM REQUIREMENTS
==================================================
The CI/CD system must support:
- DAG pipelines
- matrix builds
- reusable workflows
- workflow templates
- conditional execution
- parallel execution
- artifacts
- caches
- retries
- concurrency controls
- cancellation
- pipeline graphs
- environment promotion
- rollback support
- preview environments
- flaky test detection
- deployment visualization
The orchestrator should:
- schedule
- coordinate
- monitor
NOT directly execute jobs.
==================================================
RUNNER SYSTEM REQUIREMENTS
==================================================
Runners should:
- be ephemeral
- isolated
- disposable
- reproducible
- stateless
Support execution backends:
1. Docker containers
2. Kubernetes jobs
3. Firecracker microVMs
4. Bare metal runners
Security is critical.
Forked PRs must never automatically receive secrets.
==================================================
SECRET MANAGEMENT
==================================================
Implement scoped secret management.
Secret hierarchy:
```txt
Global
→ Organization
→ Repository
→ Environment
→ Runtime ephemeral injection
```
Support:
- Vault
- OIDC federation
- cloud secret providers
- encrypted secret storage
Secrets must:
- never leak to logs
- be masked automatically
- support audit trails
- support rotation
==================================================
ARTIFACT + PACKAGE MANAGEMENT
==================================================
Implement:
- build artifact storage
- OCI registry
- package registries
- retention policies
- artifact provenance
- signing support
- SBOM support
Support:
- container images
- npm
- cargo
- pip
- maven
- generic artifacts
==================================================
GITOPS REQUIREMENTS
==================================================
GitOps must be deeply integrated.
Support:
- reconciliation loops
- drift detection
- sync visualization
- environment topology
- deployment promotion
- rollback flows
- canary releases
- blue/green deployments
- environment history
The UI should make GitOps understandable.
Avoid overwhelming Kubernetes-centric UX.
==================================================
DATABASE + STORAGE DESIGN
==================================================
Separate:
- application code
- repository storage
- artifacts
- caches
- logs
- uploads
Repository storage should NOT exist in the app repository.
Recommended:
```txt
/data/repos
/data/artifacts
/data/cache
/data/uploads
```
Repository storage is runtime instance data.
Treat it like:
- database files
- object storage
- runtime state
==================================================
DASHBOARD REQUIREMENTS
==================================================
The dashboard should behave like:
> an operational command center
NOT:
> a repository list.
Include:
1. Attention Required
- failing pipelines
- stale PRs
- security alerts
- deployment failures
- environment drift
2. Active Workspaces
- active repos
- active branches
- assigned reviews
- recent deployments
3. Team Activity
- merges
- releases
- deployments
- incidents
4. CI/CD Overview
- pipeline health
- queue health
- flaky tests
- deployment status
5. Review Dashboard
- pending reviews
- high risk PRs
- stale reviews
6. Security Overview
- dependency vulnerabilities
- secret leaks
- suspicious pushes
==================================================
REPOSITORY PAGE REQUIREMENTS
==================================================
The repository page must feel operational.
Top area should include:
```txt
Repo Name
Production: Healthy
CI: Passing
Deployments: 3 active
Risk: Medium
```
The page should include:
- active PRs
- deployments
- environments
- repository health
- security alerts
- ownership
- recent activity
- operational timeline
- preview environments
- CI/CD overview
- deployment history
The README should not dominate the page.
==================================================
UNIFIED OPERATIONAL TIMELINE
==================================================
Implement a unified timeline merging:
- commits
- deployments
- incidents
- rollbacks
- CI failures
- security events
- alerts
- releases
This is one of the most important features.
Users should easily answer:
> what changed before things broke?
==================================================
PIPELINE UX REQUIREMENTS
==================================================
Pipelines should be visual DAGs.
Support:
- dependency visualization
- execution timing
- bottleneck detection
- retry controls
- artifact visibility
- live execution state
- grouped logs
- semantic errors
Logs should support:
- filtering
- collapsing
- syntax highlighting
- structured parsing
- AI summarization
==================================================
DEPLOYMENT UX REQUIREMENTS
==================================================
Deployments must feel first-class.
Each environment should expose:
```txt
Production
Healthy
v1.4.2
3 pods
0.1% errors
Last deploy 14m ago
```
Support:
- rollback
- traffic shifting
- canary visibility
- deployment timelines
- release notes
- environment logs
- health checks
==================================================
COMMAND PALETTE REQUIREMENTS
==================================================
Implement a global command palette.
Inspired by:
- VS Code
- Raycast
- Linear
Examples:
```txt
/retry failed jobs
/deploy staging
/open logs
/review next
/show flaky tests
/open production incidents
```
Keyboard-first navigation is critical.
==================================================
AI-ASSISTED FEATURES
==================================================
Implement optional AI-assisted operational tooling.
Examples:
- failure diagnosis
- flaky test detection
- architecture summaries
- impact analysis
- deployment risk scoring
- review summaries
- onboarding explanations
Example:
```txt
Likely failure cause:
Database migration introduced lock contention.
```
==================================================
OBSERVABILITY REQUIREMENTS
==================================================
Integrate:
- metrics
- traces
- logs
- deployment state
- incident state
- service topology
Do not isolate observability into external systems.
Expose operational health directly in repository pages.
==================================================
RELIABILITY REQUIREMENTS
==================================================
Implement:
- durable queues
- retries
- dead-letter queues
- resumable pipelines
- distributed scheduling
- concurrency controls
- checkpointing
- idempotent operations
The platform should survive:
- runner crashes
- orchestrator crashes
- network partitions
- deployment interruptions
==================================================
SECURITY REQUIREMENTS
==================================================
Implement:
- signed artifacts
- provenance verification
- branch protections
- permission scopes
- audit logging
- secret scanning
- dependency scanning
- runner isolation
- sandboxing
Support:
- Sigstore
- Cosign
- SLSA concepts
==================================================
UI/UX DESIGN LANGUAGE
==================================================
The UI should feel:
- modern
- minimal
- operational
- clean
- responsive
- keyboard-first
- information dense but calm
Visual inspirations:
- Linear
- Datadog
- Grafana
- Raycast
- Arc Browser
- VS Code
- modern observability dashboards
Avoid:
- clutter
- giant tables
- excessive modal workflows
- deeply nested navigation
==================================================
IMPLEMENTATION EXPECTATIONS
==================================================
You should:
- think deeply about architecture
- optimize for maintainability
- optimize for extensibility
- prioritize UX heavily
- prioritize reliability heavily
- explain tradeoffs
- avoid overengineering early
- support future scalability
You should continuously ask:
- does this reduce cognitive load?
- does this improve operational awareness?
- does this improve developer flow?
- does this improve reliability?
- does this make debugging easier?
==================================================
DELIVERABLES
==================================================
When implementing features:
Provide:
- architecture reasoning
- schema design
- API design
- event definitions
- service boundaries
- UI mockups
- workflow diagrams
- UX rationale
- security implications
- scaling considerations
- operational implications
Always optimize for:
- clarity
- reliability
- developer experience
- operational intelligence
- future extensibility
The platform should ultimately feel like:
> a unified operating system for software delivery
rather than:
> a Git repository viewer with CI attached.
+51 -2
View File
@@ -19,7 +19,15 @@ import (
"github.com/forgeo/forgebucket/internal/db" "github.com/forgeo/forgebucket/internal/db"
"github.com/forgeo/forgebucket/internal/domain/ci" "github.com/forgeo/forgebucket/internal/domain/ci"
gitdomain "github.com/forgeo/forgebucket/internal/domain/git" gitdomain "github.com/forgeo/forgebucket/internal/domain/git"
"github.com/forgeo/forgebucket/internal/domain/gitops"
"github.com/forgeo/forgebucket/internal/domain/oci"
"github.com/forgeo/forgebucket/internal/domain/sshserver"
"github.com/forgeo/forgebucket/internal/domain/scanning"
"github.com/forgeo/forgebucket/internal/domain/vulnscan"
"github.com/forgeo/forgebucket/internal/domain/sbom"
"github.com/forgeo/forgebucket/internal/domain/signing"
"github.com/forgeo/forgebucket/internal/events" "github.com/forgeo/forgebucket/internal/events"
"github.com/forgeo/forgebucket/internal/observability"
"github.com/forgeo/forgebucket/internal/models/migrations" "github.com/forgeo/forgebucket/internal/models/migrations"
"github.com/forgeo/forgebucket/web" "github.com/forgeo/forgebucket/web"
) )
@@ -48,6 +56,12 @@ func main() {
log.Fatalf("artifact root: %v", err) log.Fatalf("artifact root: %v", err)
} }
ociRegistry, err := oci.New(cfg.OCIRoot)
if err != nil {
log.Fatalf("oci: %v", err)
}
log.Printf("oci: registry initialised at %s", cfg.OCIRoot)
bus, err := events.New(cfg.NATSUrl) bus, err := events.New(cfg.NATSUrl)
if err != nil { if err != nil {
log.Fatalf("events: %v", err) log.Fatalf("events: %v", err)
@@ -67,13 +81,48 @@ func main() {
ciCtx, ciCancel := context.WithCancel(context.Background()) ciCtx, ciCancel := context.WithCancel(context.Background())
defer ciCancel() defer ciCancel()
orchestrator := ci.NewOrchestrator(engine, bus) sbomGen := sbom.NewGenerator(engine, bus)
go sbomGen.Start(ciCtx)
orchestrator := ci.NewOrchestrator(engine, bus, sbomGen)
go orchestrator.Start(ciCtx) go orchestrator.Start(ciCtx)
runnerMgr := ci.NewRunnerManager(engine, bus, cfg, 4) runnerMgr := ci.NewRunnerManager(engine, bus, cfg, 4)
go runnerMgr.Start(ciCtx) go runnerMgr.Start(ciCtx)
handler := api.New(cfg, engine, store, bus, cfg.ArtifactRoot, web.FS()) gitopsCtrl := gitops.NewController(engine, bus, cfg)
go gitopsCtrl.Start(ciCtx)
go observability.StartNATSWatcher(ciCtx, bus)
sshSrv := sshserver.New(engine, cfg)
go sshSrv.ListenAndServe(ciCtx) //nolint:errcheck
// Initialise artifact signing key store.
var keyStore *signing.KeyStore
if cfg.ArtifactSigningKey != "" {
keyStore, err = signing.New(cfg.ArtifactSigningKey)
if err != nil {
log.Fatalf("signing: %v", err)
}
} else {
keyStore, err = signing.Generate()
if err != nil {
log.Fatalf("signing: %v", err)
}
}
log.Printf("signing: key store initialised (keyId=%s)", keyStore.KeyID())
secretScanner, err := scanning.New(engine, bus)
if err != nil {
log.Fatalf("secret scanner: %v", err)
}
go secretScanner.Start(ciCtx)
vulnScanner := vulnscan.NewScanner(engine, bus)
go vulnScanner.Start(ciCtx)
handler := api.New(cfg, engine, store, bus, cfg.ArtifactRoot, web.FS(), *keyStore, sbomGen, ociRegistry, secretScanner, vulnScanner)
srv := &http.Server{ srv := &http.Server{
Addr: fmt.Sprintf(":%s", cfg.Port), Addr: fmt.Sprintf(":%s", cfg.Port),
+22 -4
View File
@@ -1,21 +1,36 @@
services: services:
postgres: postgres:
image: postgres:18.3 image: postgres:18.3
container_name: fb-postgres
restart: unless-stopped restart: unless-stopped
environment: environment:
POSTGRES_DB: forgebucket POSTGRES_DB: forgebucket
POSTGRES_USER: forgebucket POSTGRES_USER: forgebucket
POSTGRES_PASSWORD: forgebucket POSTGRES_PASSWORD: forgebucket
volumes: volumes:
- postgres_data:/var/lib/postgresql - fb_pg_data:/var/lib/postgresql
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U forgebucket"] test: ["CMD-SHELL", "pg_isready -U forgebucket"]
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 10 retries: 10
nats:
image: mirror.gcr.io/nats:2-alpine
restart: unless-stopped
command: ["-js", "-m", "8222"]
ports:
- "4222:4222" # client connections
# "8222:8222" # monitoring HTTP
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8222/healthz"]
interval: 5s
timeout: 5s
retries: 10
app: app:
build: . build: .
container_name: fb-app
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
postgres: postgres:
@@ -25,9 +40,12 @@ services:
DATABASE_URL: postgres://forgebucket:forgebucket@postgres:5432/forgebucket?sslmode=disable DATABASE_URL: postgres://forgebucket:forgebucket@postgres:5432/forgebucket?sslmode=disable
ports: ports:
- "8080:8080" - "8080:8080"
- "2222:22"
volumes: volumes:
- repo_data:/var/lib/forgebucket/repos - fb_repo_data:/tmp/forgebucket/repos
- fb_oci_data:/tmp/forgebucket/oci
volumes: volumes:
postgres_data: fb_pg_data:
repo_data: fb_repo_data:
fb_oci_data:
+7 -4
View File
@@ -1,11 +1,9 @@
version: "3.9"
# Dev: only PostgreSQL runs here. Run the Go server locally with `make dev`. # Dev: only PostgreSQL runs here. Run the Go server locally with `make dev`.
# Production: docker compose -f docker-compose.prod.yml up # Production: docker compose -f docker-compose.prod.yml up
services: services:
nats: nats:
image: nats:2-alpine image: mirror.gcr.io/nats:2-alpine
restart: unless-stopped restart: unless-stopped
command: ["-js", "-m", "8222"] command: ["-js", "-m", "8222"]
ports: ports:
@@ -18,7 +16,7 @@ services:
retries: 10 retries: 10
postgres: postgres:
image: postgres:18 image: mirror.gcr.io/postgres:18
restart: unless-stopped restart: unless-stopped
environment: environment:
POSTGRES_DB: forgebucket POSTGRES_DB: forgebucket
@@ -34,5 +32,10 @@ services:
timeout: 5s timeout: 5s
retries: 10 retries: 10
dbgate:
image: dbgate/dbgate
ports:
- "3000:3000"
volumes: volumes:
postgres_data: postgres_data:
+5156
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -10,8 +10,25 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/commands": "^6.0.0",
"@codemirror/lang-cpp": "^6.0.0",
"@codemirror/lang-css": "^6.0.0",
"@codemirror/lang-html": "^6.0.0",
"@codemirror/lang-java": "^6.0.0",
"@codemirror/lang-javascript": "^6.2.5", "@codemirror/lang-javascript": "^6.2.5",
"@codemirror/lang-json": "^6.0.0",
"@codemirror/lang-markdown": "^6.0.0",
"@codemirror/lang-python": "^6.0.0",
"@codemirror/lang-rust": "^6.0.0",
"@codemirror/lang-xml": "^6.0.0",
"@codemirror/lang-yaml": "^6.1.3",
"@codemirror/language": "^6.0.0",
"@codemirror/merge": "^6.12.1", "@codemirror/merge": "^6.12.1",
"@codemirror/search": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/theme-one-dark": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@tanstack/react-query": "^5.100.9", "@tanstack/react-query": "^5.100.9",
"react": "^19.2.5", "react": "^19.2.5",
"react-dom": "^19.2.5", "react-dom": "^19.2.5",
+2
View File
@@ -40,6 +40,7 @@ const RepoPipelinesPage = lazy(() => import('./pages/RepoPipelinesPage'))
const EnvironmentsPage = lazy(() => import('./pages/EnvironmentsPage')) const EnvironmentsPage = lazy(() => import('./pages/EnvironmentsPage'))
const RepoTimelinePage = lazy(() => import('./pages/RepoTimelinePage')) const RepoTimelinePage = lazy(() => import('./pages/RepoTimelinePage'))
const RepoSecretsPage = lazy(() => import('./pages/RepoSecretsPage')) const RepoSecretsPage = lazy(() => import('./pages/RepoSecretsPage'))
const RepoSecurityPage = lazy(() => import('./pages/RepoSecurityPage'))
const WorkspacesPage = lazy(() => import('./pages/WorkspacesPage')) const WorkspacesPage = lazy(() => import('./pages/WorkspacesPage'))
const WorkspacePage = lazy(() => import('./pages/WorkspacePage')) const WorkspacePage = lazy(() => import('./pages/WorkspacePage'))
const WorkspaceSettingsPage = lazy(() => import('./pages/WorkspaceSettingsPage')) const WorkspaceSettingsPage = lazy(() => import('./pages/WorkspaceSettingsPage'))
@@ -94,6 +95,7 @@ export default function App() {
<Route path="repos/:owner/:repo/environments" element={<S><EnvironmentsPage /></S>} /> <Route path="repos/:owner/:repo/environments" element={<S><EnvironmentsPage /></S>} />
<Route path="repos/:owner/:repo/timeline" element={<S><RepoTimelinePage /></S>} /> <Route path="repos/:owner/:repo/timeline" element={<S><RepoTimelinePage /></S>} />
<Route path="repos/:owner/:repo/secrets" element={<S><RepoSecretsPage /></S>} /> <Route path="repos/:owner/:repo/secrets" element={<S><RepoSecretsPage /></S>} />
<Route path="repos/:owner/:repo/security" element={<S><RepoSecurityPage /></S>} />
<Route path="repos/:owner/:repo/runs/:runId" element={<S><PipelineRunPage /></S>} /> <Route path="repos/:owner/:repo/runs/:runId" element={<S><PipelineRunPage /></S>} />
<Route path="starred" element={<S><StarredPage /></S>} /> <Route path="starred" element={<S><StarredPage /></S>} />
+34
View File
@@ -0,0 +1,34 @@
import { useQuery } from '@tanstack/react-query'
import { z } from 'zod'
import { api } from '../client'
const langStatSchema = z.object({
name: z.string(),
color: z.string(),
count: z.number(),
pct: z.number(),
})
const contributorSchema = z.object({
name: z.string(),
commits: z.number(),
})
const insightsSchema = z.object({
languages: z.array(langStatSchema),
contributors: z.array(contributorSchema),
totalCommits: z.number(),
})
export type LangStat = z.infer<typeof langStatSchema>
export type Contributor = z.infer<typeof contributorSchema>
export type RepoInsights = z.infer<typeof insightsSchema>
export function useRepoInsights(owner: string, repo: string) {
return useQuery<RepoInsights>({
queryKey: ['repos', owner, repo, 'insights'],
queryFn: () => api.get<RepoInsights>(`/api/v1/repos/${owner}/${repo}/insights`, insightsSchema),
enabled: !!owner && !!repo,
staleTime: 5 * 60 * 1000, // 5 min — git stats don't change on every page load
})
}
+23
View File
@@ -0,0 +1,23 @@
import { useQuery } from '@tanstack/react-query'
import { z } from 'zod'
import { api } from '../client'
export interface InstanceConfig {
sshHost: string
sshPort: string
instanceName: string
}
const instanceSchema = z.object({
sshHost: z.string(),
sshPort: z.string(),
instanceName: z.string(),
})
export function useInstance() {
return useQuery<InstanceConfig>({
queryKey: ['instance'],
queryFn: () => api.get<InstanceConfig>('/api/v1/instance', instanceSchema),
staleTime: Infinity,
})
}
+36
View File
@@ -194,6 +194,42 @@ export function useCreateRepo() {
}) })
} }
const latestDeploymentSchema = z.object({
envName: z.string(),
status: z.string(),
sha: z.string(),
finishedAt: z.string().nullable().optional(),
})
const pipelineRunSchema = z.object({
id: z.number(),
status: z.string(),
triggerRef: z.string().optional(),
startedAt: z.string().nullable().optional(),
finishedAt: z.string().nullable().optional(),
}).nullable()
const repoHealthSchema = z.object({
ciPassRate7d: z.number(),
totalRuns7d: z.number(),
latestRun: pipelineRunSchema.optional(),
latestDeployments: z.array(latestDeploymentSchema),
openDriftCount: z.number(),
openPRCount: z.number(),
})
export type RepoHealth = z.infer<typeof repoHealthSchema>
export function useRepoHealth(owner: string, repo: string) {
return useQuery<RepoHealth>({
queryKey: ['repos', owner, repo, 'health'],
queryFn: () =>
api.get<RepoHealth>(`/api/v1/repos/${owner}/${repo}/health`, repoHealthSchema),
enabled: Boolean(owner && repo),
staleTime: 60 * 1000, // 1 min
})
}
export function useImportRepo() { export function useImportRepo() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
return useMutation({ return useMutation({
+76
View File
@@ -0,0 +1,76 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { z } from 'zod'
import { api, ApiError } from '../client'
import type { SBOMReport } from '../../types/api'
const sbomReportSchema = z.object({
id: z.number(),
repoId: z.number(),
runId: z.number(),
sha: z.string(),
format: z.string(),
componentCount: z.number(),
generatedAt: z.string(),
})
/** SBOM metadata for a specific pipeline run. */
export function useRunSBOM(owner: string, repo: string, runId: number) {
return useQuery<SBOMReport | null>({
queryKey: ['repos', owner, repo, 'runs', runId, 'sbom'],
queryFn: async () => {
try {
return await api.get<SBOMReport>(
`/api/v1/repos/${owner}/${repo}/runs/${runId}/sbom`,
sbomReportSchema,
)
} catch (err) {
if (err instanceof ApiError && err.status === 404) return null
throw err
}
},
enabled: Boolean(owner && repo && runId),
retry: false,
})
}
/** Latest SBOM metadata for a repo. */
export function useLatestSBOM(owner: string, repo: string) {
return useQuery({
queryKey: ['repos', owner, repo, 'sbom'],
queryFn: () =>
api.get<SBOMReport>(
`/api/v1/repos/${owner}/${repo}/sbom`,
sbomReportSchema,
),
enabled: Boolean(owner && repo),
retry: false,
})
}
/** Download SBOM document URL for a specific run. */
export function getRunSBOMDocumentURL(owner: string, repo: string, runId: number): string {
return `/api/v1/repos/${owner}/${repo}/runs/${runId}/sbom/document`
}
/** Download latest SBOM document URL. */
export function getLatestSBOMDocumentURL(owner: string, repo: string): string {
return `/api/v1/repos/${owner}/${repo}/sbom/document`
}
/** Trigger on-demand SBOM generation. */
export function useGenerateSBOM(owner: string, repo: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ ref, runId }: { ref: string; runId?: number }) => {
let url = `/api/v1/repos/${owner}/${repo}/sbom/generate?ref=${encodeURIComponent(ref)}`
if (runId) url += `&runID=${runId}`
return api.post<SBOMReport>(url, sbomReportSchema, undefined)
},
onSuccess: (data, { runId }) => {
qc.setQueryData(['repos', owner, repo, 'sbom'], data)
if (runId) {
qc.setQueryData(['repos', owner, repo, 'runs', runId, 'sbom'], data)
}
},
})
}
+117
View File
@@ -0,0 +1,117 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { z } from 'zod'
import { api } from '../client'
import type { SecretLeak, VulnerabilityFinding } from '../../types/api'
// ── Zod schemas ───────────────────────────────────────────────────────────────
const secretLeakSchema = z.object({
id: z.number(),
repoId: z.number(),
commitSha: z.string(),
ref: z.string(),
patternName: z.string(),
description: z.string(),
severity: z.string(),
matchSample: z.string(),
dismissed: z.boolean(),
dismissedBy: z.string().optional(),
dismissedAt: z.string().nullable().optional(),
detectedAt: z.string(),
})
const vulnerabilityFindingSchema = z.object({
id: z.number(),
repoId: z.number(),
vulnId: z.string(),
purl: z.string(),
version: z.string(),
summary: z.string(),
details: z.string().optional(),
cvssScore: z.number(),
fixedVersion: z.string(),
dismissed: z.boolean(),
dismissedBy: z.string().optional(),
dismissedAt: z.string().nullable().optional(),
detectedAt: z.string(),
})
// ── Secret Leak queries ───────────────────────────────────────────────────────
/** Active secret leaks for a repo. */
export function useSecretLeaks(owner: string, repo: string) {
return useQuery({
queryKey: ['repos', owner, repo, 'secrets', 'leaks'],
queryFn: () =>
api.get<SecretLeak[]>(
`/api/v1/repos/${owner}/${repo}/secrets/leaks`,
z.array(secretLeakSchema),
),
enabled: Boolean(owner && repo),
refetchInterval: 30_000,
})
}
/** Dismiss a secret leak. */
export function useDismissSecretLeak(owner: string, repo: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (leakId: number) =>
api.post(
`/api/v1/repos/${owner}/${repo}/secrets/leaks/${leakId}/dismiss`,
z.unknown(),
undefined,
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['repos', owner, repo, 'secrets', 'leaks'] })
},
})
}
// ── Vulnerability queries ─────────────────────────────────────────────────────
/** Active vulnerability findings for a repo. */
export function useVulnerabilities(owner: string, repo: string) {
return useQuery({
queryKey: ['repos', owner, repo, 'vulnerabilities'],
queryFn: () =>
api.get<VulnerabilityFinding[]>(
`/api/v1/repos/${owner}/${repo}/vulnerabilities`,
z.array(vulnerabilityFindingSchema),
),
enabled: Boolean(owner && repo),
refetchInterval: 30_000,
})
}
/** Trigger a vulnerability scan. */
export function useScanVulnerabilities(owner: string, repo: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: () =>
api.post<VulnerabilityFinding[]>(
`/api/v1/repos/${owner}/${repo}/vulnerabilities/scan`,
z.array(vulnerabilityFindingSchema),
undefined,
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['repos', owner, repo, 'vulnerabilities'] })
},
})
}
/** Dismiss a vulnerability finding. */
export function useDismissVulnerability(owner: string, repo: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (findingId: number) =>
api.post(
`/api/v1/repos/${owner}/${repo}/vulnerabilities/${findingId}/dismiss`,
z.unknown(),
undefined,
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['repos', owner, repo, 'vulnerabilities'] })
},
})
}
+137 -16
View File
@@ -1,5 +1,8 @@
import { Link } from 'react-router-dom' import { useRef, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useQueryClient } from '@tanstack/react-query'
import { useRepoTree } from '../../api/queries/repos' import { useRepoTree } from '../../api/queries/repos'
import { getCSRFToken } from '../../api/client'
import { Skeleton } from '../../ui/Skeleton' import { Skeleton } from '../../ui/Skeleton'
interface TreeBrowserProps { interface TreeBrowserProps {
@@ -32,23 +35,141 @@ function formatSize(bytes: number): string {
} }
export function TreeBrowser({ owner, repo, ref, path = '' }: TreeBrowserProps) { export function TreeBrowser({ owner, repo, ref, path = '' }: TreeBrowserProps) {
const navigate = useNavigate()
const queryClient = useQueryClient()
const { data: entries, isLoading, isError } = useRepoTree(owner, repo, ref, path) const { data: entries, isLoading, isError } = useRepoTree(owner, repo, ref, path)
const fileInputRef = useRef<HTMLInputElement>(null)
const folderInputRef = useRef<HTMLInputElement>(null)
const zipInputRef = useRef<HTMLInputElement>(null)
const [uploadStatus, setUploadStatus] = useState<string | null>(null)
const [uploading, setUploading] = useState(false)
async function handleUpload(files: FileList | null, isZip = false) {
if (!files || files.length === 0) return
setUploading(true)
setUploadStatus(`Uploading ${isZip ? 'archive' : `${files.length} file${files.length > 1 ? 's' : ''}`}`)
try {
const csrfToken = await getCSRFToken()
const form = new FormData()
form.append('branch', ref || 'main')
form.append('message', isZip ? 'Upload archive' : `Upload ${files.length} file${files.length > 1 ? 's' : ''}`)
if (isZip) {
form.append('zip', files[0])
} else {
for (const file of Array.from(files)) {
const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name
const f = new File([file], relativePath, { type: file.type })
form.append('file[]', f, relativePath)
}
}
const res = await fetch(`/api/v1/repos/${owner}/${repo}/upload`, {
method: 'POST',
credentials: 'include',
headers: { 'X-CSRF-Token': csrfToken },
body: form,
})
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }))
throw new Error(err.error || 'Upload failed')
}
const result = await res.json()
setUploadStatus(`Committed ${result.committed} file${result.committed !== 1 ? 's' : ''}`)
queryClient.invalidateQueries({ queryKey: ['repos', owner, repo, 'tree'] })
queryClient.invalidateQueries({ queryKey: ['repos', owner, repo, 'files'] })
setTimeout(() => setUploadStatus(null), 3000)
} catch (err) {
setUploadStatus(`Error: ${(err as Error).message}`)
setTimeout(() => setUploadStatus(null), 5000)
} finally {
setUploading(false)
if (fileInputRef.current) fileInputRef.current.value = ''
if (folderInputRef.current) folderInputRef.current.value = ''
if (zipInputRef.current) zipInputRef.current.value = ''
}
}
if (isLoading) return <TreeSkeleton /> if (isLoading) return <TreeSkeleton />
if (isError) return <p className="text-xs text-[var(--c-danger)] p-4">Failed to load file tree.</p> if (isError) return <p className="text-xs text-[var(--c-danger)] p-4">Failed to load file tree.</p>
if (!entries?.length) return (
<div className="border border-dashed border-[var(--c-border)] rounded p-6 text-center text-xs text-[var(--c-muted)]">
No files yet push your first commit to see them here.
</div>
)
const dirs = entries.filter(e => e.type === 'tree').sort((a, b) => a.name.localeCompare(b.name)) const dirs = (entries ?? []).filter(e => e.type === 'tree').sort((a, b) => a.name.localeCompare(b.name))
const files = entries.filter(e => e.type === 'blob').sort((a, b) => a.name.localeCompare(b.name)) const files = (entries ?? []).filter(e => e.type === 'blob').sort((a, b) => a.name.localeCompare(b.name))
const sorted = [...dirs, ...files] const sorted = [...dirs, ...files]
return ( return (
<div className="border border-[var(--c-border)] rounded overflow-hidden bg-[var(--c-surface)]"> <div className="border border-[var(--c-border)] rounded overflow-hidden bg-[var(--c-surface)]">
{/* Path breadcrumb inside tree */}
{/* Upload toolbar */}
<div className="flex items-center gap-2 px-3 py-2 bg-[var(--c-surface-muted)] border-b border-[var(--c-border)] flex-wrap">
<button
onClick={() => navigate(`/repos/${owner}/${repo}/blob?ref=${encodeURIComponent(ref || 'main')}&new=true`)}
className="flex items-center gap-1.5 px-2.5 py-1 rounded text-xs font-medium border border-[var(--c-border)] text-[var(--c-text)] hover:bg-[var(--c-surface)] bg-[var(--c-surface)]"
>
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
New file
</button>
<button
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="flex items-center gap-1.5 px-2.5 py-1 rounded text-xs font-medium border border-[var(--c-border)] text-[var(--c-text)] hover:bg-[var(--c-surface)] bg-[var(--c-surface)] disabled:opacity-50"
>
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5" />
</svg>
Upload files
</button>
<button
onClick={() => folderInputRef.current?.click()}
disabled={uploading}
className="flex items-center gap-1.5 px-2.5 py-1 rounded text-xs font-medium border border-[var(--c-border)] text-[var(--c-text)] hover:bg-[var(--c-surface)] bg-[var(--c-surface)] disabled:opacity-50"
>
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12.75V12A2.25 2.25 0 0 1 4.5 9.75h15A2.25 2.25 0 0 1 21.75 12v.75m-8.69-6.44-2.12-2.12a1.5 1.5 0 0 0-1.061-.44H4.5A2.25 2.25 0 0 0 2.25 6v8.25" />
</svg>
Upload folder
</button>
<button
onClick={() => zipInputRef.current?.click()}
disabled={uploading}
className="flex items-center gap-1.5 px-2.5 py-1 rounded text-xs font-medium border border-[var(--c-border)] text-[var(--c-text)] hover:bg-[var(--c-surface)] bg-[var(--c-surface)] disabled:opacity-50"
>
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 0 1-2.247 2.118H6.622a2.25 2.25 0 0 1-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125Z" />
</svg>
Upload ZIP
</button>
{uploadStatus && (
<span className={`text-xs ml-1 ${uploadStatus.startsWith('Error') ? 'text-[var(--c-danger)]' : 'text-[var(--c-success)]'}`}>
{uploadStatus}
</span>
)}
{/* Hidden file inputs */}
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={e => handleUpload(e.target.files)} />
<input
ref={folderInputRef}
type="file"
className="hidden"
// @ts-expect-error webkitdirectory is not in React types
webkitdirectory=""
multiple
onChange={e => handleUpload(e.target.files)}
/>
<input ref={zipInputRef} type="file" accept=".zip" className="hidden" onChange={e => handleUpload(e.target.files, true)} />
</div>
{/* Path breadcrumb */}
{path && ( {path && (
<div className="flex items-center gap-1 px-3 py-2 bg-[var(--c-surface-muted)] border-b border-[var(--c-border)] text-xs text-[var(--c-muted)]"> <div className="flex items-center gap-1 px-3 py-2 bg-[var(--c-surface-muted)] border-b border-[var(--c-border)] text-xs text-[var(--c-muted)]">
<Link to={`/repos/${owner}/${repo}`} className="hover:text-[var(--c-brand)]">{repo}</Link> <Link to={`/repos/${owner}/${repo}`} className="hover:text-[var(--c-brand)]">{repo}</Link>
@@ -67,6 +188,11 @@ export function TreeBrowser({ owner, repo, ref, path = '' }: TreeBrowserProps) {
</div> </div>
)} )}
{sorted.length === 0 ? (
<div className="p-6 text-center text-xs text-[var(--c-muted)]">
No files yet push your first commit or upload files above.
</div>
) : (
<table className="w-full text-sm border-collapse"> <table className="w-full text-sm border-collapse">
<colgroup> <colgroup>
<col className="w-auto" /> <col className="w-auto" />
@@ -83,7 +209,6 @@ export function TreeBrowser({ owner, repo, ref, path = '' }: TreeBrowserProps) {
return ( return (
<tr key={entry.hash} className="border-b border-[var(--c-border)] last:border-b-0 hover:bg-[var(--c-surface-raised)]"> <tr key={entry.hash} className="border-b border-[var(--c-border)] last:border-b-0 hover:bg-[var(--c-surface-raised)]">
{/* Name */}
<td className="px-3 py-2"> <td className="px-3 py-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{isDir ? ( {isDir ? (
@@ -95,10 +220,7 @@ export function TreeBrowser({ owner, repo, ref, path = '' }: TreeBrowserProps) {
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" /> <path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
</svg> </svg>
)} )}
<Link <Link to={href} className={isDir ? 'text-[var(--c-brand)] hover:underline font-medium' : 'text-[var(--c-text)] hover:text-[var(--c-brand)]'}>
to={href}
className={isDir ? 'text-[var(--c-brand)] hover:underline font-medium' : 'text-[var(--c-text)] hover:text-[var(--c-brand)]'}
>
{entry.name} {entry.name}
</Link> </Link>
{!isDir && entry.size > 0 && ( {!isDir && entry.size > 0 && (
@@ -106,11 +228,9 @@ export function TreeBrowser({ owner, repo, ref, path = '' }: TreeBrowserProps) {
)} )}
</div> </div>
</td> </td>
{/* Commit message */}
<td className="px-3 py-2 text-xs text-[var(--c-muted)] truncate max-w-0 hidden sm:table-cell"> <td className="px-3 py-2 text-xs text-[var(--c-muted)] truncate max-w-0 hidden sm:table-cell">
<span className="truncate block" title={entry.commitMsg}>{entry.commitMsg}</span> <span className="truncate block" title={entry.commitMsg}>{entry.commitMsg}</span>
</td> </td>
{/* Date */}
<td className="px-3 py-2 text-xs text-[var(--c-muted)] whitespace-nowrap text-right"> <td className="px-3 py-2 text-xs text-[var(--c-muted)] whitespace-nowrap text-right">
{relativeTime(entry.commitDate)} {relativeTime(entry.commitDate)}
</td> </td>
@@ -119,6 +239,7 @@ export function TreeBrowser({ owner, repo, ref, path = '' }: TreeBrowserProps) {
})} })}
</tbody> </tbody>
</table> </table>
)}
</div> </div>
) )
} }
+4
View File
@@ -43,6 +43,10 @@
--c-warning: #FBBF24; --c-warning: #FBBF24;
} }
html {
font-size: 17px;
}
body { body {
margin: 0; margin: 0;
font-family: system-ui, 'Segoe UI', Roboto, sans-serif; font-family: system-ui, 'Segoe UI', Roboto, sans-serif;
+78 -36
View File
@@ -4,6 +4,8 @@ import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
import { useRepo, useRepoBlob, useUpdateBlob } from '../api/queries/repos' import { useRepo, useRepoBlob, useUpdateBlob } from '../api/queries/repos'
import { RepoListSkeleton } from '../ui/Skeleton' import { RepoListSkeleton } from '../ui/Skeleton'
import { CodeEditor } from '../components/repos/CodeEditor'
import { FileSideTree } from '../components/repos/FileSideTree'
export default function BlobPage() { export default function BlobPage() {
const { owner = '', repo: repoName = '' } = useParams<{ owner: string; repo: string }>() const { owner = '', repo: repoName = '' } = useParams<{ owner: string; repo: string }>()
@@ -14,12 +16,17 @@ export default function BlobPage() {
const [commitMsg, setCommitMsg] = useState('') const [commitMsg, setCommitMsg] = useState('')
const [preview, setPreview] = useState(false) const [preview, setPreview] = useState(false)
// New-file mode: ?new=true&path=<desired-path>
const isNew = searchParams.get('new') === 'true'
const [newPath, setNewPath] = useState(searchParams.get('path') ?? '')
const ref = searchParams.get('ref') ?? '' const ref = searchParams.get('ref') ?? ''
const filePath = searchParams.get('path') ?? '' const filePath = isNew ? newPath : (searchParams.get('path') ?? '')
const fileName = filePath.split('/').pop() ?? filePath const fileName = filePath.split('/').pop() ?? filePath
const fileExt = fileName.includes('.') ? fileName.split('.').pop() ?? '' : ''
const { data: repo } = useRepo(owner, repoName) const { data: repo } = useRepo(owner, repoName)
const { data: blob, isLoading, isError } = useRepoBlob(owner, repoName, ref, filePath) const { data: blob, isLoading, isError } = useRepoBlob(owner, repoName, ref, isNew ? '' : filePath)
const updateBlob = useUpdateBlob(owner, repoName) const updateBlob = useUpdateBlob(owner, repoName)
const branch = ref || repo?.defaultBranch || 'main' const branch = ref || repo?.defaultBranch || 'main'
@@ -33,30 +40,55 @@ export default function BlobPage() {
} }
function cancelEdit() { function cancelEdit() {
if (isNew) {
navigate(-1)
} else {
setEditing(false) setEditing(false)
setPreview(false) setPreview(false)
} }
}
async function handleCommit() { async function handleCommit() {
if (!commitMsg.trim() || !filePath) return const path = isNew ? newPath.trim() : filePath
if (!commitMsg.trim() || !path) return
await updateBlob.mutateAsync({ await updateBlob.mutateAsync({
path: filePath, path,
content: editContent, content: editContent,
message: commitMsg.trim(), message: commitMsg.trim(),
branch, branch,
}) })
setEditing(false) setEditing(false)
navigate(`/repos/${owner}/${repoName}/blob?ref=${encodeURIComponent(branch)}&path=${encodeURIComponent(filePath)}`, { replace: true }) navigate(`/repos/${owner}/${repoName}/blob?ref=${encodeURIComponent(branch)}&path=${encodeURIComponent(path)}`, { replace: true })
} }
if (isLoading) return <div className="p-6"><RepoListSkeleton /></div> const isEditingState = editing || isNew
if (isError || !blob) return <div className="p-6 text-sm text-[var(--c-danger)]">File not found.</div>
const lines = blob.content.split('\n') // For new file, start in edit mode with empty content.
const pathParts = filePath.split('/') if (isNew && !editing && editContent === '') {
setEditContent('')
setCommitMsg('Add new file')
setEditing(true)
}
const pathParts = filePath.split('/').filter(Boolean)
const content = blob?.content ?? ''
return ( return (
<div className="max-w-5xl mx-auto px-4 md:px-6 py-6 space-y-4"> <div className="flex h-full min-h-0" style={{ height: 'calc(100vh - 56px)' }}>
{/* Left file tree — hidden on small screens */}
<div className="hidden md:flex">
<FileSideTree
owner={owner}
repo={repoName}
branch={branch}
activePath={filePath}
/>
</div>
{/* Main content */}
<div className="flex-1 min-w-0 overflow-auto">
<div className="w-full px-4 md:px-6 py-6 space-y-4">
{/* Breadcrumb */} {/* Breadcrumb */}
<div className="flex items-center gap-1 text-sm flex-wrap"> <div className="flex items-center gap-1 text-sm flex-wrap">
@@ -76,21 +108,35 @@ export default function BlobPage() {
</span> </span>
) )
})} })}
{isNew && <span className="text-[var(--c-muted)] font-semibold">New file</span>}
</div> </div>
{/* File card */} {/* File card */}
{isLoading && !isNew && <RepoListSkeleton />}
{(isError && !isNew) && <div className="text-sm text-[var(--c-danger)] p-4">File not found.</div>}
{(!isLoading || isNew) && (
<div className="border border-[var(--c-border)] rounded bg-[var(--c-surface)] overflow-hidden"> <div className="border border-[var(--c-border)] rounded bg-[var(--c-surface)] overflow-hidden">
{/* Toolbar */} {/* Toolbar */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-[var(--c-border)] bg-[var(--c-surface-raised)] gap-3 flex-wrap"> <div className="flex items-center justify-between px-4 py-2.5 border-b border-[var(--c-border)] bg-[var(--c-surface-raised)] gap-3 flex-wrap">
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
{/* Branch pill */}
<span className="flex items-center gap-1 px-2 py-0.5 border border-[var(--c-border)] rounded text-xs text-[var(--c-muted)] bg-[var(--c-surface)]"> <span className="flex items-center gap-1 px-2 py-0.5 border border-[var(--c-border)] rounded text-xs text-[var(--c-muted)] bg-[var(--c-surface)]">
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24"> <svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M9.568 3H5.25A2.25 2.25 0 0 0 3 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 0 0 5.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 0 0 9.568 3Z" /> <path strokeLinecap="round" strokeLinejoin="round" d="M9.568 3H5.25A2.25 2.25 0 0 0 3 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 0 0 5.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 0 0 9.568 3Z" />
</svg> </svg>
{branch} {branch}
</span> </span>
{isNew ? (
<input
value={newPath}
onChange={e => setNewPath(e.target.value)}
placeholder="path/to/new-file.ts"
className="border border-[var(--c-border)] rounded px-2 py-0.5 text-xs focus:outline-none focus:border-[var(--c-brand-focus)] text-[var(--c-text)] bg-[var(--c-surface)]"
style={{ minWidth: 200 }}
/>
) : (
<>
<span className="text-[var(--c-muted)]">{repoName}</span> <span className="text-[var(--c-muted)]">{repoName}</span>
<span className="text-[var(--c-muted)]">/</span> <span className="text-[var(--c-muted)]">/</span>
<span className="font-medium text-[var(--c-text)]">{fileName}</span> <span className="font-medium text-[var(--c-text)]">{fileName}</span>
@@ -103,9 +149,11 @@ export default function BlobPage() {
<path strokeLinecap="round" strokeLinejoin="round" d="M15.666 3.888A2.25 2.25 0 0 0 13.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 0 1-.75.75H9a.75.75 0 0 1-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 0 1-2.25 2.25H6.75A2.25 2.25 0 0 1 4.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 0 1 1.927-.184" /> <path strokeLinecap="round" strokeLinejoin="round" d="M15.666 3.888A2.25 2.25 0 0 0 13.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 0 1-.75.75H9a.75.75 0 0 1-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 0 1-2.25 2.25H6.75A2.25 2.25 0 0 1 4.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 0 1 1.927-.184" />
</svg> </svg>
</button> </button>
</>
)}
</div> </div>
{!editing && ( {!isEditingState && (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{isMarkdown && ( {isMarkdown && (
<button <button
@@ -125,7 +173,7 @@ export default function BlobPage() {
Edit Edit
</button> </button>
<button <button
onClick={() => navigator.clipboard.writeText(blob.content)} onClick={() => navigator.clipboard.writeText(content)}
className="px-3 py-1.5 text-xs font-medium border border-[var(--c-border)] rounded text-[var(--c-muted)] hover:bg-[var(--c-surface-muted)]" className="px-3 py-1.5 text-xs font-medium border border-[var(--c-border)] rounded text-[var(--c-muted)] hover:bg-[var(--c-surface-muted)]"
> >
Copy Copy
@@ -134,15 +182,14 @@ export default function BlobPage() {
)} )}
</div> </div>
{/* Content */} {/* Content area */}
{editing ? ( {isEditingState ? (
<div className="flex flex-col"> <div className="flex flex-col">
<textarea <CodeEditor
value={editContent} value={editContent}
onChange={e => setEditContent(e.target.value)} onChange={setEditContent}
className="w-full font-mono text-xs text-[var(--c-text)] bg-[var(--c-surface)] p-4 resize-none focus:outline-none border-b border-[var(--c-border)]" language={isNew ? (newPath.split('.').pop() ?? '') : fileExt}
style={{ minHeight: Math.max(300, lines.length * 20) }} minHeight="400px"
spellCheck={false}
/> />
<div className="p-4 bg-[var(--c-surface-raised)] border-t border-[var(--c-border)] space-y-3"> <div className="p-4 bg-[var(--c-surface-raised)] border-t border-[var(--c-border)] space-y-3">
<div> <div>
@@ -157,10 +204,10 @@ export default function BlobPage() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
onClick={handleCommit} onClick={handleCommit}
disabled={updateBlob.isPending || !commitMsg.trim()} disabled={updateBlob.isPending || !commitMsg.trim() || (isNew && !newPath.trim())}
className="px-4 py-2 rounded bg-[var(--c-brand)] text-white text-sm font-medium hover:bg-[var(--c-brand-hover)] disabled:opacity-50" className="px-4 py-2 rounded bg-[var(--c-brand)] text-white text-sm font-medium hover:bg-[var(--c-brand-hover)] disabled:opacity-50"
> >
{updateBlob.isPending ? 'Committing…' : 'Commit changes'} {updateBlob.isPending ? 'Committing…' : isNew ? 'Create file' : 'Commit changes'}
</button> </button>
<button onClick={cancelEdit} className="px-4 py-2 rounded border border-[var(--c-border)] text-sm text-[var(--c-text)] hover:bg-[var(--c-surface-muted)]"> <button onClick={cancelEdit} className="px-4 py-2 rounded border border-[var(--c-border)] text-sm text-[var(--c-text)] hover:bg-[var(--c-surface-muted)]">
Cancel Cancel
@@ -176,25 +223,20 @@ export default function BlobPage() {
prose-headings:text-[var(--c-text)] prose-headings:font-semibold prose-headings:border-b prose-headings:border-[var(--c-border)] prose-headings:pb-1 prose-headings:text-[var(--c-text)] prose-headings:font-semibold prose-headings:border-b prose-headings:border-[var(--c-border)] prose-headings:pb-1
prose-a:text-[var(--c-brand)] prose-code:bg-[var(--c-surface-muted)] prose-code:px-1 prose-code:rounded prose-a:text-[var(--c-brand)] prose-code:bg-[var(--c-surface-muted)] prose-code:px-1 prose-code:rounded
prose-pre:bg-[var(--c-surface-muted)] prose-pre:border prose-pre:border-[var(--c-border)] prose-pre:rounded"> prose-pre:bg-[var(--c-surface-muted)] prose-pre:border prose-pre:border-[var(--c-border)] prose-pre:rounded">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{blob.content}</ReactMarkdown> <ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</div> </div>
) : ( ) : (
<div className="overflow-x-auto"> <CodeEditor
<table className="w-full border-collapse font-mono text-xs"> value={content}
<tbody> language={fileExt}
{lines.map((line, i) => ( readOnly
<tr key={i} className="hover:bg-[#FFFBDD]"> minHeight="400px"
<td className="select-none text-right text-[var(--c-muted)] px-4 py-0.5 w-12 border-r border-[var(--c-border)] bg-[var(--c-surface-raised)] sticky left-0"> />
{i + 1} )}
</td>
<td className="px-4 py-0.5 text-[var(--c-text)] whitespace-pre">{line || ' '}</td>
</tr>
))}
</tbody>
</table>
</div> </div>
)} )}
</div> </div>
</div> </div>
</div>
) )
} }
+88 -1
View File
@@ -1,6 +1,7 @@
import { useState } from 'react' import { useState } from 'react'
import { useParams, Link } from 'react-router-dom' import { useParams, Link } from 'react-router-dom'
import { useRunDetail, useJobLogs, useCancelRun, useRetryJob } from '../api/queries/pipelines' import { useRunDetail, useJobLogs, useCancelRun, useRetryJob } from '../api/queries/pipelines'
import { useRunSBOM, getRunSBOMDocumentURL, useGenerateSBOM } from '../api/queries/sbom'
import { Skeleton } from '../ui/Skeleton' import { Skeleton } from '../ui/Skeleton'
import { cn } from '../lib/utils' import { cn } from '../lib/utils'
import type { PipelineJob, PipelineStep, RunStatus } from '../types/api' import type { PipelineJob, PipelineStep, RunStatus } from '../types/api'
@@ -27,6 +28,87 @@ function duration(start: string | null, end: string | null): string {
return `${m}m ${s % 60}s` return `${m}m ${s % 60}s`
} }
// ── SBOM section ──────────────────────────────────────────────────────────────
function SBOMSection({ owner, repo, runId, runStatus, triggerSha }: {
owner: string
repo: string
runId: number
runStatus: RunStatus
triggerSha: string
}) {
const { data: sbom, isLoading } = useRunSBOM(owner, repo, runId)
const generateSBOM = useGenerateSBOM(owner, repo)
if (isLoading) {
return (
<section className="border border-[var(--c-border)] rounded-lg bg-[var(--c-surface)] p-4">
<h2 className="text-xs font-semibold uppercase tracking-wider text-[var(--c-muted)] mb-3">
SBOM
</h2>
<Skeleton className="h-5 w-64 rounded" />
</section>
)
}
if (sbom) {
return (
<section className="border border-[var(--c-border)] rounded-lg bg-[var(--c-surface)] p-4">
<div className="flex items-center justify-between gap-4">
<div>
<h2 className="text-xs font-semibold uppercase tracking-wider text-[var(--c-muted)] mb-1">
SBOM CycloneDX
</h2>
<div className="flex items-center gap-3 text-xs text-[var(--c-muted)]">
<span>{sbom.componentCount} components</span>
<span className="font-mono">{sbom.sha.slice(0, 7)}</span>
<span>{new Date(sbom.generatedAt).toLocaleString()}</span>
</div>
</div>
<a
href={getRunSBOMDocumentURL(owner, repo, runId)}
download="bom.json"
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium border border-[var(--c-border)] rounded-lg text-[var(--c-text)] hover:bg-[var(--c-surface-muted)] transition-colors shrink-0"
>
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
Download BOM
</a>
</div>
</section>
)
}
// No SBOM yet — show generate option for completed/failed runs
if (runStatus === 'succeeded' || runStatus === 'failed') {
return (
<section className="border border-[var(--c-border)] rounded-lg bg-[var(--c-surface)] p-4">
<div className="flex items-center justify-between gap-4">
<div>
<h2 className="text-xs font-semibold uppercase tracking-wider text-[var(--c-muted)] mb-1">
SBOM
</h2>
<p className="text-xs text-[var(--c-muted)]">No SBOM generated for this run.</p>
</div>
<button
onClick={() => generateSBOM.mutate({ ref: triggerSha, runId })}
disabled={generateSBOM.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium border border-[var(--c-border)] rounded-lg text-[var(--c-text)] hover:bg-[var(--c-surface-muted)] transition-colors disabled:opacity-50 shrink-0"
>
{generateSBOM.isPending ? 'Generating…' : 'Generate SBOM'}
</button>
</div>
{generateSBOM.isError && (
<p className="mt-2 text-xs text-[var(--c-danger)]">{(generateSBOM.error as Error).message}</p>
)}
</section>
)
}
return null
}
function shortRef(ref: string): string { function shortRef(ref: string): string {
return ref.replace('refs/heads/', '').replace('refs/tags/', '') return ref.replace('refs/heads/', '').replace('refs/tags/', '')
} }
@@ -172,7 +254,7 @@ function topoColumns(jobs: JobWithSteps[]): JobWithSteps[][] {
const job = nameToJob.get(name) const job = nameToJob.get(name)
if (!job) return 0 if (!job) return 0
let needs: string[] = [] let needs: string[] = []
try { needs = JSON.parse(job.needs || '[]') } catch { needs = [] } try { needs = JSON.parse(job.needs || '[]') ?? [] } catch { needs = [] }
const d = needs.length === 0 ? 0 : 1 + Math.max(...needs.map(n => getDepth(n, new Set(visited)))) const d = needs.length === 0 ? 0 : 1 + Math.max(...needs.map(n => getDepth(n, new Set(visited))))
depth.set(name, d) depth.set(name, d)
return d return d
@@ -357,6 +439,11 @@ export default function PipelineRunPage() {
</div> </div>
) : null} ) : null}
{/* SBOM section */}
{!isLoading && run && (
<SBOMSection owner={owner} repo={repo} runId={runIdNum} runStatus={run.status as RunStatus} triggerSha={run.triggerSha} />
)}
{/* DAG + log viewer */} {/* DAG + log viewer */}
<div className="grid grid-cols-1 gap-4"> <div className="grid grid-cols-1 gap-4">
+100 -12
View File
@@ -4,7 +4,10 @@ import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
import { useRepo, useRepoTree, useRepoBlob, useRepoBranches } from '../api/queries/repos' import { useRepo, useRepoTree, useRepoBlob, useRepoBranches } from '../api/queries/repos'
import { useEnvironments } from '../api/queries/environments' import { useEnvironments } from '../api/queries/environments'
import { useInstance } from '../api/queries/instance'
import { TreeBrowser } from '../components/repos/TreeBrowser' import { TreeBrowser } from '../components/repos/TreeBrowser'
import { RepoContextPanel } from '../components/repos/RepoContextPanel'
import { RepoFileSearch } from '../components/repos/RepoFileSearch'
import { RepoListSkeleton } from '../ui/Skeleton' import { RepoListSkeleton } from '../ui/Skeleton'
import { RepoAvatar } from '../ui/RepoAvatar' import { RepoAvatar } from '../ui/RepoAvatar'
import { useRecentRepos } from '../hooks/useRecentRepos' import { useRecentRepos } from '../hooks/useRecentRepos'
@@ -14,6 +17,8 @@ export default function RepoPage() {
const [searchParams, setSearchParams] = useSearchParams() const [searchParams, setSearchParams] = useSearchParams()
const [showBranches, setShowBranches] = useState(false) const [showBranches, setShowBranches] = useState(false)
const [showClone, setShowClone] = useState(false) const [showClone, setShowClone] = useState(false)
const [cloneTab, setCloneTab] = useState<'https' | 'ssh'>('https')
const [cloneCopied, setCloneCopied] = useState(false)
const branchRef = useRef<HTMLDivElement>(null) const branchRef = useRef<HTMLDivElement>(null)
const cloneRef = useRef<HTMLDivElement>(null) const cloneRef = useRef<HTMLDivElement>(null)
@@ -23,6 +28,7 @@ export default function RepoPage() {
const { data: repo, isLoading, isError } = useRepo(owner, repoName) const { data: repo, isLoading, isError } = useRepo(owner, repoName)
const { data: branches } = useRepoBranches(owner, repoName) const { data: branches } = useRepoBranches(owner, repoName)
const { data: environments } = useEnvironments(owner, repoName) const { data: environments } = useEnvironments(owner, repoName)
const { data: instance } = useInstance()
const { track } = useRecentRepos() const { track } = useRecentRepos()
useEffect(() => { if (owner && repoName) track(owner, repoName) }, [owner, repoName]) useEffect(() => { if (owner && repoName) track(owner, repoName) }, [owner, repoName])
@@ -42,13 +48,23 @@ export default function RepoPage() {
const branch = ref || repo.defaultBranch const branch = ref || repo.defaultBranch
const cloneUrl = `${window.location.origin}/${owner}/${repoName}.git` const cloneUrl = `${window.location.origin}/${owner}/${repoName}.git`
const sshHost = instance?.sshHost || window.location.hostname
const sshPort = instance?.sshPort || '2222'
const sshUrl = sshPort === '22'
? `git@${sshHost}:${owner}/${repoName}.git`
: `ssh://git@${sshHost}:${sshPort}/${owner}/${repoName}.git`
const archiveBase = `/api/v1/repos/${owner}/${repoName}/archive?ref=${encodeURIComponent(branch)}`
function switchBranch(b: string) { function switchBranch(b: string) {
setSearchParams({ ref: b, ...(path ? { path } : {}) }) setSearchParams({ ref: b, ...(path ? { path } : {}) })
setShowBranches(false) setShowBranches(false)
} }
return ( return (
<div className="max-w-5xl mx-auto px-4 md:px-6 py-6 space-y-4"> <div className="max-w-[1400px] mx-auto px-4 md:px-6 py-6">
<div className="flex gap-6 items-start">
<div className="flex-1 min-w-0 space-y-4">
{/* Header row */} {/* Header row */}
<div className="flex items-start justify-between gap-4 flex-wrap"> <div className="flex items-start justify-between gap-4 flex-wrap">
@@ -123,17 +139,73 @@ export default function RepoPage() {
</svg> </svg>
</button> </button>
{showClone && ( {showClone && (
<div className="absolute right-0 top-full mt-1 w-80 bg-[var(--c-surface)] border border-[var(--c-border)] rounded-lg shadow-xl z-50 p-4"> <div className="absolute right-0 top-full mt-1 w-96 bg-[var(--c-surface)] border border-[var(--c-border)] rounded-lg shadow-xl z-50 p-4 space-y-3">
<p className="text-xs font-semibold text-[var(--c-muted)] uppercase tracking-wide mb-2">Clone over HTTP</p>
<div className="flex items-center gap-2 bg-[var(--c-surface-muted)] border border-[var(--c-border)] rounded px-3 py-2"> {/* Clone URL tabs */}
<code className="text-xs text-[var(--c-text)] flex-1 truncate">{cloneUrl}</code> <div>
<div className="flex gap-1 mb-2">
{(['https', 'ssh'] as const).map(tab => (
<button <button
onClick={() => navigator.clipboard.writeText(cloneUrl)} key={tab}
className="text-[10px] text-[var(--c-brand)] hover:underline shrink-0" onClick={() => setCloneTab(tab)}
className={`px-2.5 py-1 rounded text-xs font-medium transition-colors ${
cloneTab === tab
? 'bg-[var(--c-brand)] text-white'
: 'text-[var(--c-muted)] hover:text-[var(--c-text)] hover:bg-[var(--c-surface-muted)]'
}`}
> >
Copy {tab.toUpperCase()}
</button>
))}
</div>
<div className="flex items-center gap-2 bg-[var(--c-surface-muted)] border border-[var(--c-border)] rounded px-3 py-2">
<code className="text-xs text-[var(--c-text)] flex-1 truncate">
{cloneTab === 'https' ? cloneUrl : sshUrl}
</code>
<button
onClick={() => {
navigator.clipboard.writeText(cloneTab === 'https' ? cloneUrl : sshUrl)
setCloneCopied(true)
setTimeout(() => setCloneCopied(false), 1500)
}}
className={`text-[10px] font-medium shrink-0 transition-colors ${
cloneCopied
? 'text-[var(--c-success)]'
: 'text-[var(--c-brand)] hover:underline'
}`}
>
{cloneCopied ? 'Copied!' : 'Copy'}
</button> </button>
</div> </div>
{cloneTab === 'ssh' && (
<p className="text-[10px] text-[var(--c-muted)] mt-1.5">
Requires an SSH key added to your{' '}
<Link to="/settings" className="text-[var(--c-brand)] hover:underline">account settings</Link>.
</p>
)}
</div>
{/* Archive download */}
<div>
<p className="text-xs font-semibold text-[var(--c-muted)] uppercase tracking-wide mb-1.5">Download</p>
<div className="flex gap-2">
{[
{ label: 'ZIP', format: 'zip' },
{ label: 'tar.gz', format: 'tar.gz' },
{ label: 'Bundle', format: 'bundle' },
].map(({ label, format }) => (
<a
key={format}
href={`${archiveBase}&format=${format}`}
download
className="flex-1 text-center px-2 py-1.5 text-xs font-medium border border-[var(--c-border)] rounded hover:bg-[var(--c-surface-muted)] text-[var(--c-text)] transition-colors"
>
{label}
</a>
))}
</div>
</div>
</div> </div>
)} )}
</div> </div>
@@ -141,7 +213,7 @@ export default function RepoPage() {
</div> </div>
{repo.isEmpty ? ( {repo.isEmpty ? (
<GettingStarted repoName={repoName} branch={branch} cloneUrl={cloneUrl} /> <GettingStarted repoName={repoName} branch={branch} cloneUrl={cloneUrl} sshUrl={sshUrl} />
) : ( ) : (
<> <>
{/* Branch selector */} {/* Branch selector */}
@@ -192,6 +264,7 @@ export default function RepoPage() {
<Link to={`/repos/${owner}/${repoName}/commits`} className="text-sm text-[var(--c-muted)] hover:text-[var(--c-text)] px-2 py-1">Commits</Link> <Link to={`/repos/${owner}/${repoName}/commits`} className="text-sm text-[var(--c-muted)] hover:text-[var(--c-text)] px-2 py-1">Commits</Link>
<Link to={`/repos/${owner}/${repoName}/branches`} className="text-sm text-[var(--c-muted)] hover:text-[var(--c-text)] px-2 py-1">Branches</Link> <Link to={`/repos/${owner}/${repoName}/branches`} className="text-sm text-[var(--c-muted)] hover:text-[var(--c-text)] px-2 py-1">Branches</Link>
<Link to={`/repos/${owner}/${repoName}/issues`} className="text-sm text-[var(--c-muted)] hover:text-[var(--c-text)] px-2 py-1">Issues</Link> <Link to={`/repos/${owner}/${repoName}/issues`} className="text-sm text-[var(--c-muted)] hover:text-[var(--c-text)] px-2 py-1">Issues</Link>
<Link to={`/repos/${owner}/${repoName}/security`} className="text-sm text-[var(--c-muted)] hover:text-[var(--c-text)] px-2 py-1">Security</Link>
<Link to={`/repos/${owner}/${repoName}/settings`} className="text-sm text-[var(--c-muted)] hover:text-[var(--c-text)] px-2 py-1 ml-auto">Settings</Link> <Link to={`/repos/${owner}/${repoName}/settings`} className="text-sm text-[var(--c-muted)] hover:text-[var(--c-text)] px-2 py-1 ml-auto">Settings</Link>
</div> </div>
@@ -202,6 +275,15 @@ export default function RepoPage() {
</> </>
)} )}
</div> </div>
{/* Right sidebar — hidden below lg breakpoint */}
<div className="w-72 shrink-0 hidden lg:block space-y-3">
<RepoFileSearch owner={owner} repo={repoName} branch={branch} />
<RepoContextPanel owner={owner} repo={repoName} />
</div>
</div>
</div>
) )
} }
@@ -230,8 +312,8 @@ function ReadmePreview({ owner, repo, ref }: { owner: string; repo: string; ref:
) )
} }
function GettingStarted({ repoName, branch, cloneUrl }: { function GettingStarted({ repoName, branch, cloneUrl, sshUrl }: {
repoName: string; branch: string; cloneUrl: string repoName: string; branch: string; cloneUrl: string; sshUrl: string
}) { }) {
return ( return (
<div className="border border-[var(--c-border)] rounded-lg overflow-hidden"> <div className="border border-[var(--c-border)] rounded-lg overflow-hidden">
@@ -240,10 +322,16 @@ function GettingStarted({ repoName, branch, cloneUrl }: {
<p className="text-xs text-[var(--c-muted)] mt-0.5">Push your first commit to get started.</p> <p className="text-xs text-[var(--c-muted)] mt-0.5">Push your first commit to get started.</p>
</div> </div>
<div className="px-5 py-5 space-y-6 text-sm"> <div className="px-5 py-5 space-y-6 text-sm">
<div className="grid grid-cols-2 gap-3">
<div> <div>
<p className="text-xs font-semibold text-[var(--c-muted)] uppercase tracking-wide mb-2">Clone over HTTP</p> <p className="text-xs font-semibold text-[var(--c-muted)] uppercase tracking-wide mb-2">Clone over HTTPS</p>
<CopyBlock value={cloneUrl} /> <CopyBlock value={cloneUrl} />
</div> </div>
<div>
<p className="text-xs font-semibold text-[var(--c-muted)] uppercase tracking-wide mb-2">Clone over SSH</p>
<CopyBlock value={sshUrl} />
</div>
</div>
<div> <div>
<p className="text-xs font-semibold text-[var(--c-muted)] uppercase tracking-wide mb-2">or push an existing repository</p> <p className="text-xs font-semibold text-[var(--c-muted)] uppercase tracking-wide mb-2">or push an existing repository</p>
<CopyBlock value={`git remote add origin ${cloneUrl}\ngit branch -M ${branch}\ngit push -u origin ${branch}`} multiline /> <CopyBlock value={`git remote add origin ${cloneUrl}\ngit branch -M ${branch}\ngit push -u origin ${branch}`} multiline />
+298
View File
@@ -0,0 +1,298 @@
import { useState } from 'react'
import { useParams, Link } from 'react-router-dom'
import { useSecretLeaks, useDismissSecretLeak } from '../api/queries/security'
import { useVulnerabilities, useScanVulnerabilities, useDismissVulnerability } from '../api/queries/security'
import { useLatestSBOM, useGenerateSBOM, getLatestSBOMDocumentURL } from '../api/queries/sbom'
import { Skeleton } from '../ui/Skeleton'
import { cn } from '../lib/utils'
const SEVERITY_COLORS: Record<string, string> = {
critical: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300',
high: 'bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300',
medium: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-300',
low: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
}
function cvssSeverity(score: number): { label: string; color: string } {
if (score >= 9) return { label: 'CRITICAL', color: SEVERITY_COLORS.critical }
if (score >= 7) return { label: 'HIGH', color: SEVERITY_COLORS.high }
if (score >= 4) return { label: 'MEDIUM', color: SEVERITY_COLORS.medium }
return { label: 'LOW', color: SEVERITY_COLORS.low }
}
// ── SBOM Section ──────────────────────────────────────────────────────────────
function SBOMSection({ owner, repo }: { owner: string; repo: string }) {
const { data: sbom, isLoading } = useLatestSBOM(owner, repo)
const generateSBOM = useGenerateSBOM(owner, repo)
const [ref, setRef] = useState('main')
if (isLoading) {
return (
<section className="border border-[var(--c-border)] rounded-lg bg-[var(--c-surface)] p-4">
<Skeleton className="h-5 w-48 rounded" />
</section>
)
}
if (sbom) {
return (
<section className="border border-[var(--c-border)] rounded-lg bg-[var(--c-surface)] p-4">
<div className="flex items-center justify-between gap-4">
<div>
<h2 className="text-xs font-semibold uppercase tracking-wider text-[var(--c-muted)] mb-1">
SBOM {sbom.format}
</h2>
<div className="flex items-center gap-3 text-xs text-[var(--c-muted)]">
<span>{sbom.componentCount} components</span>
<span className="font-mono">{sbom.sha.slice(0, 7)}</span>
<span>{new Date(sbom.generatedAt).toLocaleString()}</span>
</div>
</div>
<a
href={getLatestSBOMDocumentURL(owner, repo)}
download="bom.json"
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium border border-[var(--c-border)] rounded-lg text-[var(--c-text)] hover:bg-[var(--c-surface-muted)] transition-colors shrink-0"
>
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
Download BOM
</a>
</div>
</section>
)
}
return (
<section className="border border-[var(--c-border)] rounded-lg bg-[var(--c-surface)] p-4">
<div className="flex items-center justify-between gap-4 flex-wrap">
<div>
<h2 className="text-xs font-semibold uppercase tracking-wider text-[var(--c-muted)] mb-1">
SBOM
</h2>
<p className="text-xs text-[var(--c-muted)]">
No SBOM generated yet. Generate one to enable vulnerability scanning.
</p>
</div>
<div className="flex items-center gap-2">
<input
value={ref}
onChange={e => setRef(e.target.value)}
placeholder="branch or SHA"
className="w-36 px-2.5 py-1.5 text-xs border border-[var(--c-border)] rounded-lg bg-[var(--c-surface-muted)] text-[var(--c-text)] placeholder:text-[var(--c-muted)] focus:outline-none focus:border-[var(--c-brand-focus)] font-mono"
/>
<button
onClick={() => generateSBOM.mutate({ ref })}
disabled={generateSBOM.isPending || !ref.trim()}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-[var(--c-brand)] hover:bg-[var(--c-brand-hover)] text-white rounded-lg transition-colors disabled:opacity-50 shrink-0"
>
{generateSBOM.isPending ? 'Generating…' : 'Generate SBOM'}
</button>
</div>
</div>
{generateSBOM.isError && (
<p className="mt-2 text-xs text-[var(--c-danger)]">{(generateSBOM.error as Error).message}</p>
)}
</section>
)
}
// ── Secret Leaks Section ──────────────────────────────────────────────────────
function SecretLeaksSection({ owner, repo }: { owner: string; repo: string }) {
const { data: leaks, isLoading } = useSecretLeaks(owner, repo)
const dismissLeak = useDismissSecretLeak(owner, repo)
return (
<section className="border border-[var(--c-border)] rounded-lg bg-[var(--c-surface)] overflow-hidden">
<div className="px-4 py-3 border-b border-[var(--c-border)] bg-[var(--c-surface-raised)] flex items-center justify-between">
<div className="flex items-center gap-2">
<svg width="14" height="14" fill="none" stroke="var(--c-danger)" strokeWidth="1.5" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 5.25a3 3 0 0 1 3 3m3 0a6 6 0 0 1-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 0 1 21.75 8.25Z" />
</svg>
<h2 className="text-sm font-semibold text-[var(--c-text)]">Secret Leaks</h2>
{!isLoading && leaks && leaks.length > 0 && (
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-[var(--c-danger-tint)] text-[var(--c-danger)]">
{leaks.length}
</span>
)}
</div>
</div>
{isLoading ? (
<div className="p-4 space-y-3">
{[1, 2].map(i => <Skeleton key={i} className="h-12 rounded" />)}
</div>
) : !leaks?.length ? (
<div className="px-4 py-8 text-center text-sm text-[var(--c-muted)]">
No secret leaks detected.
</div>
) : (
<div className="divide-y divide-[var(--c-border)]">
{leaks.map(leak => (
<div key={leak.id} className="flex items-start gap-3 px-4 py-3">
<span className={cn(
'text-[10px] font-semibold uppercase tracking-wide px-2 py-0.5 rounded-full mt-0.5 shrink-0',
SEVERITY_COLORS[leak.severity] ?? SEVERITY_COLORS.medium,
)}>
{leak.severity}
</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-[var(--c-text)]">{leak.patternName}</p>
<p className="text-xs text-[var(--c-muted)] mt-0.5">{leak.description}</p>
<div className="flex items-center gap-2.5 mt-1 text-[10px] text-[var(--c-subtle)] font-mono">
<span>{leak.commitSha}</span>
<span>{leak.ref.replace('refs/heads/', '')}</span>
<span>{new Date(leak.detectedAt).toLocaleDateString()}</span>
</div>
{leak.matchSample && (
<code className="inline-block mt-1 px-2 py-0.5 bg-[var(--c-surface-muted)] border border-[var(--c-border)] rounded text-[10px] font-mono text-[var(--c-muted)]">
{leak.matchSample}
</code>
)}
</div>
<button
onClick={() => dismissLeak.mutate(leak.id)}
disabled={dismissLeak.isPending}
className="shrink-0 px-2.5 py-1 text-[10px] font-medium border border-[var(--c-border)] rounded text-[var(--c-muted)] hover:text-[var(--c-text)] hover:bg-[var(--c-surface-muted)] transition-colors disabled:opacity-50"
>
Dismiss
</button>
</div>
))}
</div>
)}
</section>
)
}
// ── Vulnerabilities Section ───────────────────────────────────────────────────
function VulnerabilitiesSection({ owner, repo }: { owner: string; repo: string }) {
const { data: findings, isLoading } = useVulnerabilities(owner, repo)
const scanMut = useScanVulnerabilities(owner, repo)
const dismissVuln = useDismissVulnerability(owner, repo)
const { data: sbom } = useLatestSBOM(owner, repo)
return (
<section className="border border-[var(--c-border)] rounded-lg bg-[var(--c-surface)] overflow-hidden">
<div className="px-4 py-3 border-b border-[var(--c-border)] bg-[var(--c-surface-raised)] flex items-center justify-between">
<div className="flex items-center gap-2">
<svg width="14" height="14" fill="none" stroke="var(--c-danger)" strokeWidth="1.5" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126Z M12 15.75h.007v.008H12v-.008Z" />
</svg>
<h2 className="text-sm font-semibold text-[var(--c-text)]">Vulnerabilities</h2>
{!isLoading && findings && findings.length > 0 && (
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-[var(--c-danger-tint)] text-[var(--c-danger)]">
{findings.length}
</span>
)}
</div>
<div className="flex items-center gap-2">
{!sbom && (
<span className="text-[10px] text-[var(--c-muted)]">No SBOM available</span>
)}
<button
onClick={() => scanMut.mutate()}
disabled={scanMut.isPending || !sbom}
className="flex items-center gap-1 px-2.5 py-1 text-[10px] font-medium border border-[var(--c-border)] rounded text-[var(--c-text)] hover:bg-[var(--c-surface-muted)] transition-colors disabled:opacity-50"
>
<svg width="10" height="10" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
{scanMut.isPending ? 'Scanning…' : 'Scan now'}
</button>
</div>
</div>
{scanMut.isError && (
<div className="px-4 py-2 text-xs text-[var(--c-danger)] bg-[var(--c-danger-tint)]/30">
Scan failed: {(scanMut.error as Error).message}
</div>
)}
{scanMut.isSuccess && findings && findings.length === 0 && (
<div className="px-4 py-2 text-xs text-[var(--c-success)] bg-[#E3FCEF] dark:bg-green-900/20">
Scan complete no vulnerabilities found.
</div>
)}
{isLoading ? (
<div className="p-4 space-y-3">
{[1, 2].map(i => <Skeleton key={i} className="h-16 rounded" />)}
</div>
) : !findings?.length ? (
<div className="px-4 py-8 text-center text-sm text-[var(--c-muted)]">
{sbom
? 'No vulnerability findings. Run a scan to check dependencies.'
: 'No SBOM available. Push a commit with a supported manifest (package.json, go.mod, etc.) or trigger a pipeline run to generate one.'}
</div>
) : (
<div className="divide-y divide-[var(--c-border)]">
{findings.map(f => {
const sev = cvssSeverity(f.cvssScore)
return (
<div key={f.id} className="flex items-start gap-3 px-4 py-3">
<span className={cn(
'text-[10px] font-semibold uppercase tracking-wide px-2 py-0.5 rounded-full mt-0.5 shrink-0',
sev.color,
)}>
{sev.label}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-[var(--c-text)]">{f.vulnId}</span>
<span className="text-[10px] text-[var(--c-subtle)] font-mono">CVSS {f.cvssScore.toFixed(1)}</span>
</div>
<p className="text-xs text-[var(--c-text)] mt-0.5">{f.summary}</p>
<div className="flex items-center gap-2.5 mt-1 text-[10px] text-[var(--c-subtle)] font-mono">
<span>{f.purl}</span>
<span>v{f.version}</span>
{f.fixedVersion && <span> fix: {f.fixedVersion}</span>}
</div>
</div>
<button
onClick={() => dismissVuln.mutate(f.id)}
disabled={dismissVuln.isPending}
className="shrink-0 px-2.5 py-1 text-[10px] font-medium border border-[var(--c-border)] rounded text-[var(--c-muted)] hover:text-[var(--c-text)] hover:bg-[var(--c-surface-muted)] transition-colors disabled:opacity-50"
>
Dismiss
</button>
</div>
)
})}
</div>
)}
</section>
)
}
// ── Page ──────────────────────────────────────────────────────────────────────
export default function RepoSecurityPage() {
const { owner = '', repo = '' } = useParams()
return (
<div className="max-w-4xl mx-auto px-4 md:px-6 py-5 space-y-6">
{/* Breadcrumb */}
<div>
<div className="flex items-center gap-1.5 text-xs text-[var(--c-muted)] mb-1">
<Link to="/repos" className="hover:text-[var(--c-brand)]">Repositories</Link>
<span>/</span>
<Link to={`/repos/${owner}/${repo}`} className="hover:text-[var(--c-brand)]">{owner}/{repo}</Link>
<span>/</span>
<span className="text-[var(--c-text)]">Security</span>
</div>
<h1 className="text-lg font-semibold text-[var(--c-text)]">Security</h1>
<p className="text-xs text-[var(--c-muted)] mt-0.5">
Secret leak detection and dependency vulnerability scanning.
</p>
</div>
<SBOMSection owner={owner} repo={repo} />
<SecretLeaksSection owner={owner} repo={repo} />
<VulnerabilitiesSection owner={owner} repo={repo} />
</div>
)
}
+47
View File
@@ -342,3 +342,50 @@ export interface ApiError {
export interface HealthResponse { export interface HealthResponse {
status: 'ok' status: 'ok'
} }
// ── SBOM (Phase 4.2) ───────────────────────────────────────────────────────────
export interface SBOMReport {
id: number
repoId: number
runId: number
sha: string
format: string
componentCount: number
generatedAt: string
}
// ── Secret Scanning (Phase 4.4) ────────────────────────────────────────────────
export interface SecretLeak {
id: number
repoId: number
commitSha: string
ref: string
patternName: string
description: string
severity: string
matchSample: string
dismissed: boolean
dismissedBy?: string
dismissedAt?: string | null
detectedAt: string
}
// ── Vulnerability Scanning (Phase 4.5) ─────────────────────────────────────────
export interface VulnerabilityFinding {
id: number
repoId: number
vulnId: string
purl: string
version: string
summary: string
details?: string
cvssScore: number
fixedVersion: string
dismissed: boolean
dismissedBy?: string
dismissedAt?: string | null
detectedAt: string
}
+11 -2
View File
@@ -8,22 +8,31 @@ require (
github.com/gorilla/sessions v1.4.0 github.com/gorilla/sessions v1.4.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/lib/pq v1.12.3 github.com/lib/pq v1.12.3
github.com/nats-io/nats.go v1.52.0
github.com/prometheus/client_golang v1.23.2
golang.org/x/crypto v0.50.0 golang.org/x/crypto v0.50.0
gopkg.in/yaml.v3 v3.0.1
nhooyr.io/websocket v1.8.17 nhooyr.io/websocket v1.8.17
xorm.io/xorm v1.3.11 xorm.io/xorm v1.3.11
) )
require ( require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-json v0.10.5 // indirect
github.com/golang/snappy v0.0.4 // indirect github.com/golang/snappy v0.0.4 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect github.com/gorilla/securecookie v1.1.2 // indirect
github.com/klauspost/compress v1.18.5 // indirect github.com/klauspost/compress v1.18.5 // indirect
github.com/nats-io/nats.go v1.52.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nats-io/nkeys v0.4.15 // indirect github.com/nats-io/nkeys v0.4.15 // indirect
github.com/nats-io/nuid v1.0.1 // indirect github.com/nats-io/nuid v1.0.1 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/syndtr/goleveldb v1.0.0 // indirect github.com/syndtr/goleveldb v1.0.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/sys v0.43.0 // indirect golang.org/x/sys v0.43.0 // indirect
golang.org/x/tools v0.43.0 // indirect golang.org/x/tools v0.43.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect google.golang.org/protobuf v1.36.8 // indirect
xorm.io/builder v0.3.13 // indirect xorm.io/builder v0.3.13 // indirect
) )
+34
View File
@@ -2,6 +2,10 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a h1:lSA0F4e9A2NcQSqGqTOXqu2aRi/XEQxDCBwM8yJtE6s= gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a h1:lSA0F4e9A2NcQSqGqTOXqu2aRi/XEQxDCBwM8yJtE6s=
gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a/go.mod h1:EXuID2Zs0pAQhH8yz+DNjUbjppKQzKFAn28TMYPB6IU= gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a/go.mod h1:EXuID2Zs0pAQhH8yz+DNjUbjppKQzKFAn28TMYPB6IU=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -20,6 +24,8 @@ github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5y
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -36,12 +42,20 @@ github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNU
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nats-io/nats.go v1.52.0 h1:n3avV4VBsCgsdwh71TppsTwtv+QdPs7ntSKM8qJLGsc= github.com/nats-io/nats.go v1.52.0 h1:n3avV4VBsCgsdwh71TppsTwtv+QdPs7ntSKM8qJLGsc=
github.com/nats-io/nats.go v1.52.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno= github.com/nats-io/nats.go v1.52.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno=
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4= github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
@@ -57,14 +71,28 @@ github.com/onsi/gomega v1.4.3 h1:RE1xgDvH7imwFD45h+u2SgIfERHlS2yNG4DObb5BSKU=
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE= github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE=
github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ= github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
@@ -78,12 +106,18 @@ golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
+61
View File
@@ -0,0 +1,61 @@
package handlers
import (
"fmt"
"net/http"
"xorm.io/xorm"
gitdomain "github.com/forgeo/forgebucket/internal/domain/git"
)
type ArchiveHandler struct {
db *xorm.Engine
}
func NewArchiveHandler(db *xorm.Engine) *ArchiveHandler {
return &ArchiveHandler{db: db}
}
var archiveFormats = map[string]struct {
contentType string
ext string
}{
"zip": {"application/zip", "zip"},
"tar.gz": {"application/x-tar", "tar.gz"},
"bundle": {"application/octet-stream", "bundle"},
}
func (h *ArchiveHandler) Download(w http.ResponseWriter, r *http.Request) {
repo, ok := resolveRepo(h.db, w, r)
if !ok {
return
}
format := r.URL.Query().Get("format")
if format == "" {
format = "zip"
}
meta, allowed := archiveFormats[format]
if !allowed {
jsonError(w, "format must be zip, tar.gz, or bundle", http.StatusBadRequest)
return
}
ref := r.URL.Query().Get("ref")
if ref == "" {
ref = repo.DefaultBranch
}
if ref == "" {
ref = "HEAD"
}
filename := fmt.Sprintf("%s-%s.%s", repo.Name, ref, meta.ext)
w.Header().Set("Content-Type", meta.contentType)
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
if err := gitdomain.ArchiveStream(repo.DiskPath, ref, format, w); err != nil {
// Headers already written — can't change status code; just log and close.
_ = err
}
}
+104 -19
View File
@@ -1,29 +1,33 @@
package handlers package handlers
import ( import (
"encoding/json"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strconv" "strconv"
"time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"xorm.io/xorm" "xorm.io/xorm"
"github.com/forgeo/forgebucket/internal/domain/signing"
"github.com/forgeo/forgebucket/internal/models" "github.com/forgeo/forgebucket/internal/models"
) )
type ArtifactHandler struct { type ArtifactHandler struct {
db *xorm.Engine db *xorm.Engine
artifactRoot string artifactRoot string
keys *signing.KeyStore
} }
func NewArtifactHandler(db *xorm.Engine, artifactRoot string) *ArtifactHandler { func NewArtifactHandler(db *xorm.Engine, artifactRoot string, keys *signing.KeyStore) *ArtifactHandler {
return &ArtifactHandler{db: db, artifactRoot: artifactRoot} return &ArtifactHandler{db: db, artifactRoot: artifactRoot, keys: keys}
} }
// ListArtifacts returns all artifacts for a pipeline run. // List returns all artifacts for a pipeline run.
func (h *ArtifactHandler) List(w http.ResponseWriter, r *http.Request) { func (h *ArtifactHandler) List(w http.ResponseWriter, r *http.Request) {
repoID, runID, ok := h.resolveRunIDs(w, r) repoID, runID, ok := h.resolveRunIDs(w, r)
if !ok { if !ok {
@@ -40,8 +44,8 @@ func (h *ArtifactHandler) List(w http.ResponseWriter, r *http.Request) {
jsonOK(w, artifacts) jsonOK(w, artifacts)
} }
// Upload accepts a multipart file upload and stores it as an artifact. // Upload accepts a multipart file upload, stores it as an artifact, and
// Callers must provide a valid Bearer access token with write scope (runner auth). // immediately signs it using the server's signing key.
func (h *ArtifactHandler) Upload(w http.ResponseWriter, r *http.Request) { func (h *ArtifactHandler) Upload(w http.ResponseWriter, r *http.Request) {
repoID, runID, ok := h.resolveRunIDs(w, r) repoID, runID, ok := h.resolveRunIDs(w, r)
if !ok { if !ok {
@@ -87,8 +91,13 @@ func (h *ArtifactHandler) Upload(w http.ResponseWriter, r *http.Request) {
} }
defer dst.Close() defer dst.Close()
size, err := io.Copy(dst, file) // Read into memory so we can both write to disk and sign.
content, err := io.ReadAll(file)
if err != nil { if err != nil {
jsonError(w, "could not read upload", http.StatusInternalServerError)
return
}
if _, err := dst.Write(content); err != nil {
jsonError(w, "could not write file", http.StatusInternalServerError) jsonError(w, "could not write file", http.StatusInternalServerError)
return return
} }
@@ -106,7 +115,7 @@ func (h *ArtifactHandler) Upload(w http.ResponseWriter, r *http.Request) {
RepoID: repoID, RepoID: repoID,
Name: name, Name: name,
StoragePath: relPath, StoragePath: relPath,
Size: size, Size: int64(len(content)),
ContentType: ct, ContentType: ct,
} }
if _, err := h.db.Insert(artifact); err != nil { if _, err := h.db.Insert(artifact); err != nil {
@@ -114,10 +123,38 @@ func (h *ArtifactHandler) Upload(w http.ResponseWriter, r *http.Request) {
return return
} }
// Sign the artifact and persist the bundle.
go h.signArtifact(artifact.ID, name, content)
w.WriteHeader(http.StatusCreated) w.WriteHeader(http.StatusCreated)
jsonOK(w, artifact) jsonOK(w, artifact)
} }
// signArtifact is called in a goroutine after a successful upload.
func (h *ArtifactHandler) signArtifact(artifactID int64, name string, content []byte) {
bundle, err := h.keys.Sign(artifactID, name, content)
if err != nil {
fmt.Printf("signing: failed to sign artifact %d: %v\n", artifactID, err)
return
}
bundleJSON, err := json.Marshal(bundle)
if err != nil {
fmt.Printf("signing: failed to marshal bundle for artifact %d: %v\n", artifactID, err)
return
}
sig := &models.ArtifactSignature{
ArtifactID: artifactID,
KeyID: bundle.KeyID,
Algorithm: "ecdsa-p256-sha256",
Digest: bundle.Payload.Digest,
BundleJSON: string(bundleJSON),
SignedAt: time.Now().UTC(),
}
if _, err := h.db.Insert(sig); err != nil {
fmt.Printf("signing: failed to store signature for artifact %d: %v\n", artifactID, err)
}
}
// Download streams the artifact file to the client. // Download streams the artifact file to the client.
func (h *ArtifactHandler) Download(w http.ResponseWriter, r *http.Request) { func (h *ArtifactHandler) Download(w http.ResponseWriter, r *http.Request) {
artifactID, err := strconv.ParseInt(chi.URLParam(r, "artifactID"), 10, 64) artifactID, err := strconv.ParseInt(chi.URLParam(r, "artifactID"), 10, 64)
@@ -132,7 +169,6 @@ func (h *ArtifactHandler) Download(w http.ResponseWriter, r *http.Request) {
} }
fullPath := filepath.Join(h.artifactRoot, filepath.FromSlash(artifact.StoragePath)) fullPath := filepath.Join(h.artifactRoot, filepath.FromSlash(artifact.StoragePath))
// Ensure the resolved path stays within artifactRoot (traversal guard).
if !isUnder(h.artifactRoot, fullPath) { if !isUnder(h.artifactRoot, fullPath) {
jsonError(w, "forbidden", http.StatusForbidden) jsonError(w, "forbidden", http.StatusForbidden)
return return
@@ -155,17 +191,66 @@ func (h *ArtifactHandler) Download(w http.ResponseWriter, r *http.Request) {
io.Copy(w, f) //nolint:errcheck io.Copy(w, f) //nolint:errcheck
} }
func (h *ArtifactHandler) resolveRunIDs(w http.ResponseWriter, r *http.Request) (repoID, runID int64, ok bool) { // GetSignature returns the full signature bundle JSON for an artifact.
owner := chi.URLParam(r, "owner") // GET /api/v1/repos/{owner}/{repo}/artifacts/{artifactID}/signature
repoName := chi.URLParam(r, "repo") func (h *ArtifactHandler) GetSignature(w http.ResponseWriter, r *http.Request) {
var u models.User artifactID, err := strconv.ParseInt(chi.URLParam(r, "artifactID"), 10, 64)
if found, _ := h.db.Where("username = ?", owner).Get(&u); !found { if err != nil {
jsonError(w, "repository not found", http.StatusNotFound) jsonError(w, "invalid artifact ID", http.StatusBadRequest)
return 0, 0, false return
} }
var repo models.Repository
if found, _ := h.db.Where("owner_id = ? AND name = ?", u.ID, repoName).Get(&repo); !found { var sig models.ArtifactSignature
jsonError(w, "repository not found", http.StatusNotFound) found, err := h.db.Where("artifact_id = ?", artifactID).Get(&sig)
if err != nil {
jsonError(w, "database error", http.StatusInternalServerError)
return
}
if !found {
jsonError(w, "signature not found — artifact may still be pending signing", http.StatusNotFound)
return
}
// Return the raw bundle JSON so clients can verify independently.
w.Header().Set("Content-Type", "application/vnd.forgebucket.signature.bundle+json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(sig.BundleJSON)) //nolint:errcheck
}
// VerifySignature verifies the stored signature bundle for an artifact.
// GET /api/v1/repos/{owner}/{repo}/artifacts/{artifactID}/verify
func (h *ArtifactHandler) VerifySignature(w http.ResponseWriter, r *http.Request) {
artifactID, err := strconv.ParseInt(chi.URLParam(r, "artifactID"), 10, 64)
if err != nil {
jsonError(w, "invalid artifact ID", http.StatusBadRequest)
return
}
var sig models.ArtifactSignature
found, err := h.db.Where("artifact_id = ?", artifactID).Get(&sig)
if err != nil {
jsonError(w, "database error", http.StatusInternalServerError)
return
}
if !found {
jsonError(w, "signature not found", http.StatusNotFound)
return
}
result, err := h.keys.Verify([]byte(sig.BundleJSON))
if err != nil {
jsonError(w, fmt.Sprintf("verification error: %v", err), http.StatusUnprocessableEntity)
return
}
jsonOK(w, result)
}
// ─── helpers ─────────────────────────────────────────────────────────────────
func (h *ArtifactHandler) resolveRunIDs(w http.ResponseWriter, r *http.Request) (repoID, runID int64, ok bool) {
rID, ok := resolveRepoID(h.db, w, r)
if !ok {
return 0, 0, false return 0, 0, false
} }
runID, err := strconv.ParseInt(chi.URLParam(r, "runID"), 10, 64) runID, err := strconv.ParseInt(chi.URLParam(r, "runID"), 10, 64)
@@ -173,7 +258,7 @@ func (h *ArtifactHandler) resolveRunIDs(w http.ResponseWriter, r *http.Request)
jsonError(w, "invalid run ID", http.StatusBadRequest) jsonError(w, "invalid run ID", http.StatusBadRequest)
return 0, 0, false return 0, 0, false
} }
return repo.ID, runID, true return rID, runID, true
} }
func isUnder(root, path string) bool { func isUnder(root, path string) bool {
+3 -26
View File
@@ -323,19 +323,7 @@ func (h *EnvironmentHandler) UpdateDeploymentStatus(w http.ResponseWriter, r *ht
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
func (h *EnvironmentHandler) resolveRepo(w http.ResponseWriter, r *http.Request) (int64, bool) { func (h *EnvironmentHandler) resolveRepo(w http.ResponseWriter, r *http.Request) (int64, bool) {
owner := chi.URLParam(r, "owner") return resolveRepoID(h.db, w, r)
repoName := chi.URLParam(r, "repo")
var u models.User
if found, _ := h.db.Where("username = ?", owner).Get(&u); !found {
jsonError(w, "repository not found", http.StatusNotFound)
return 0, false
}
var repo models.Repository
if found, _ := h.db.Where("owner_id = ? AND name = ?", u.ID, repoName).Get(&repo); !found {
jsonError(w, "repository not found", http.StatusNotFound)
return 0, false
}
return repo.ID, true
} }
func (h *EnvironmentHandler) resolveEnv(w http.ResponseWriter, r *http.Request) (*models.Environment, bool) { func (h *EnvironmentHandler) resolveEnv(w http.ResponseWriter, r *http.Request) (*models.Environment, bool) {
@@ -352,26 +340,15 @@ func (h *EnvironmentHandler) resolveEnv(w http.ResponseWriter, r *http.Request)
return &env, true return &env, true
} }
type deployEventPayload struct {
DeploymentID int64 `json:"deploymentId"`
EnvID int64 `json:"envId"`
EnvName string `json:"envName"`
RepoID int64 `json:"repoId"`
SHA string `json:"sha"`
Ref string `json:"ref"`
Status models.DeployStatus `json:"status"`
TriggeredBy string `json:"triggeredBy"`
}
func (h *EnvironmentHandler) publishDeployEvent(subject string, env *models.Environment, d *models.Deployment) { func (h *EnvironmentHandler) publishDeployEvent(subject string, env *models.Environment, d *models.Deployment) {
h.bus.Publish(subject, deployEventPayload{ //nolint:errcheck h.bus.Publish(subject, events.DeploymentEvent{ //nolint:errcheck
DeploymentID: d.ID, DeploymentID: d.ID,
EnvID: env.ID, EnvID: env.ID,
EnvName: env.Name, EnvName: env.Name,
RepoID: d.RepoID, RepoID: d.RepoID,
SHA: d.SHA, SHA: d.SHA,
Ref: d.Ref, Ref: d.Ref,
Status: d.Status, Status: string(d.Status),
TriggeredBy: d.TriggeredBy, TriggeredBy: d.TriggeredBy,
}) })
} }
+255
View File
@@ -0,0 +1,255 @@
package handlers
import (
"encoding/json"
"io"
"log"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"xorm.io/xorm"
"github.com/forgeo/forgebucket/internal/config"
"github.com/forgeo/forgebucket/internal/domain/federation"
"github.com/forgeo/forgebucket/internal/models"
)
const activityJSONType = "application/activity+json"
type FederationHandler struct {
db *xorm.Engine
cfg *config.Config
}
func NewFederationHandler(db *xorm.Engine, cfg *config.Config) *FederationHandler {
return &FederationHandler{db: db, cfg: cfg}
}
// WebFinger handles GET /.well-known/webfinger?resource=acct:user@domain
func (h *FederationHandler) WebFinger(w http.ResponseWriter, r *http.Request) {
resource := r.URL.Query().Get("resource")
if !strings.HasPrefix(resource, "acct:") {
http.Error(w, "resource must use acct: scheme", http.StatusBadRequest)
return
}
// acct:username@domain — extract username
acct := strings.TrimPrefix(resource, "acct:")
username := strings.SplitN(acct, "@", 2)[0]
var user models.User
if found, _ := h.db.Where("username = ?", username).Get(&user); !found {
http.NotFound(w, r)
return
}
actorURL := federation.APID(h.cfg.InstanceURL, username)
resp := map[string]any{
"subject": resource,
"links": []map[string]any{
{
"rel": "self",
"type": activityJSONType,
"href": actorURL,
},
},
}
w.Header().Set("Content-Type", "application/jrd+json")
json.NewEncoder(w).Encode(resp) //nolint:errcheck
}
// Actor handles GET /users/{username} — returns the JSON-LD actor document.
func (h *FederationHandler) Actor(w http.ResponseWriter, r *http.Request) {
username := chi.URLParam(r, "username")
var user models.User
if found, _ := h.db.Where("username = ?", username).Get(&user); !found {
http.NotFound(w, r)
return
}
actor, err := federation.GetOrCreate(h.db, user.ID, username, h.cfg.InstanceURL)
if err != nil {
http.Error(w, "could not get actor", http.StatusInternalServerError)
return
}
doc := federation.ActorJSON(actor, username, username)
w.Header().Set("Content-Type", activityJSONType)
json.NewEncoder(w).Encode(doc) //nolint:errcheck
}
// Inbox handles POST /users/{username}/inbox — receive an ActivityPub activity.
func (h *FederationHandler) Inbox(w http.ResponseWriter, r *http.Request) {
username := chi.URLParam(r, "username")
var user models.User
if found, _ := h.db.Where("username = ?", username).Get(&user); !found {
http.NotFound(w, r)
return
}
actor, err := federation.GetOrCreate(h.db, user.ID, username, h.cfg.InstanceURL)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1 MB max
if err != nil {
http.Error(w, "could not read body", http.StatusBadRequest)
return
}
// Verify HTTP signature. In debug mode, skip verification so local testing works.
if !h.cfg.Debug {
if err := federation.Verify(r, h.db, h.cfg.InstanceURL); err != nil {
http.Error(w, "signature verification failed: "+err.Error(), http.StatusUnauthorized)
return
}
}
if err := federation.Receive(h.db, actor, body); err != nil {
http.Error(w, "could not process activity: "+err.Error(), http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusAccepted)
}
// OutboxGet handles GET /users/{username}/outbox — serve the activity collection.
func (h *FederationHandler) OutboxGet(w http.ResponseWriter, r *http.Request) {
username := chi.URLParam(r, "username")
var user models.User
if found, _ := h.db.Where("username = ?", username).Get(&user); !found {
http.NotFound(w, r)
return
}
actor, err := federation.GetOrCreate(h.db, user.ID, username, h.cfg.InstanceURL)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
coll := federation.Collection(h.db, actor.APID, actor.OutboxURL, page)
w.Header().Set("Content-Type", activityJSONType)
json.NewEncoder(w).Encode(coll) //nolint:errcheck
}
// Followers handles GET /users/{username}/followers
func (h *FederationHandler) Followers(w http.ResponseWriter, r *http.Request) {
username := chi.URLParam(r, "username")
var user models.User
if found, _ := h.db.Where("username = ?", username).Get(&user); !found {
http.NotFound(w, r)
return
}
actor, err := federation.GetOrCreate(h.db, user.ID, username, h.cfg.InstanceURL)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
coll := federation.StubCollection(actor.APID + "/followers")
w.Header().Set("Content-Type", activityJSONType)
json.NewEncoder(w).Encode(coll) //nolint:errcheck
}
// RepoActor handles GET /repos/{owner}/{repo}/actor — returns the ForgeFed
// Repository actor document for cross-instance pull requests.
func (h *FederationHandler) RepoActor(w http.ResponseWriter, r *http.Request) {
owner := chi.URLParam(r, "owner")
repoName := chi.URLParam(r, "repo")
var repo models.Repository
if found, _ := h.db.Where("name = ?", repoName).
Join("INNER", "user", "repository.owner_id = user.id AND user.username = ?", owner).
Get(&repo); !found {
http.NotFound(w, r)
return
}
doc := federation.RepoActorJSON(owner, repoName, repo.Description, h.cfg.InstanceURL)
w.Header().Set("Content-Type", activityJSONType)
json.NewEncoder(w).Encode(doc) //nolint:errcheck
}
// RepoInbox handles POST /repos/{owner}/{repo}/inbox — receive ForgeFed
// activities for a repository (e.g. Create(PullRequest)).
func (h *FederationHandler) RepoInbox(w http.ResponseWriter, r *http.Request) {
owner := chi.URLParam(r, "owner")
repoName := chi.URLParam(r, "repo")
var repo models.Repository
if found, _ := h.db.Where("name = ?", repoName).
Join("INNER", "user", "repository.owner_id = user.id AND user.username = ?", owner).
Get(&repo); !found {
http.NotFound(w, r)
return
}
_ = repo
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
http.Error(w, "could not read body", http.StatusBadRequest)
return
}
// Determine the local repo actor APID.
localActorAPID := federation.RepoAPID(h.cfg.InstanceURL, owner, repoName)
// For repository inbox, we need a local actor for the repo owner.
var ownerUser models.User
if found, _ := h.db.Where("username = ?", owner).Get(&ownerUser); !found {
http.Error(w, "owner not found", http.StatusInternalServerError)
return
}
if !h.cfg.Debug {
if err := federation.Verify(r, h.db, h.cfg.InstanceURL); err != nil {
http.Error(w, "signature verification failed: "+err.Error(), http.StatusUnauthorized)
return
}
}
// Persist the activity.
entry := &models.FederationActivity{
ActorAPID: localActorAPID,
Type: "Create",
ObjectJSON: string(body),
Direction: "inbound",
RemoteActor: localActorAPID,
Published: time.Now().UTC(),
}
h.db.Insert(entry) //nolint:errcheck
// Handle Create(PullRequest).
if err := federation.HandleCreatePullRequest(h.db, body, h.cfg.InstanceURL); err != nil {
log.Printf("federation: repo inbox handle: %v", err)
}
w.WriteHeader(http.StatusAccepted)
}
func (h *FederationHandler) Following(w http.ResponseWriter, r *http.Request) {
username := chi.URLParam(r, "username")
var user models.User
if found, _ := h.db.Where("username = ?", username).Get(&user); !found {
http.NotFound(w, r)
return
}
actor, err := federation.GetOrCreate(h.db, user.ID, username, h.cfg.InstanceURL)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
coll := federation.StubCollection(actor.APID + "/following")
w.Header().Set("Content-Type", activityJSONType)
json.NewEncoder(w).Encode(coll) //nolint:errcheck
}
+252
View File
@@ -0,0 +1,252 @@
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"xorm.io/xorm"
"github.com/forgeo/forgebucket/internal/events"
"github.com/forgeo/forgebucket/internal/models"
)
type GitOpsHandler struct {
db *xorm.Engine
bus events.EventBus
}
func NewGitOpsHandler(db *xorm.Engine, bus events.EventBus) *GitOpsHandler {
return &GitOpsHandler{db: db, bus: bus}
}
// GetConfig returns the GitOpsConfig for an environment, or 404 if not configured.
func (h *GitOpsHandler) GetConfig(w http.ResponseWriter, r *http.Request) {
env, ok := h.resolveGitOpsEnv(w, r)
if !ok {
return
}
var cfg models.GitOpsConfig
if found, _ := h.db.Where("env_id = ?", env.ID).Get(&cfg); !found {
jsonError(w, "gitops not configured for this environment", http.StatusNotFound)
return
}
jsonOK(w, cfg)
}
// UpsertConfig creates or replaces the GitOpsConfig for an environment.
func (h *GitOpsHandler) UpsertConfig(w http.ResponseWriter, r *http.Request) {
env, ok := h.resolveGitOpsEnv(w, r)
if !ok {
return
}
var body struct {
Branch string `json:"branch"`
AutoSync bool `json:"autoSync"`
SyncInterval int `json:"syncInterval"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
jsonError(w, "invalid request body", http.StatusBadRequest)
return
}
if body.Branch == "" {
jsonError(w, "branch is required", http.StatusBadRequest)
return
}
var cfg models.GitOpsConfig
exists, _ := h.db.Where("env_id = ?", env.ID).Get(&cfg)
cfg.EnvID = env.ID
cfg.RepoID = env.RepoID
cfg.Branch = body.Branch
cfg.AutoSync = body.AutoSync
cfg.SyncInterval = body.SyncInterval
if cfg.SyncStatus == "" {
cfg.SyncStatus = "unknown"
}
var err error
if exists {
_, err = h.db.ID(cfg.ID).Cols("branch", "auto_sync", "sync_interval").Update(&cfg)
} else {
_, err = h.db.Insert(&cfg)
}
if err != nil {
jsonError(w, "could not save gitops config", http.StatusInternalServerError)
return
}
jsonOK(w, cfg)
}
// DeleteConfig removes the GitOpsConfig for an environment without deleting deployments.
func (h *GitOpsHandler) DeleteConfig(w http.ResponseWriter, r *http.Request) {
env, ok := h.resolveGitOpsEnv(w, r)
if !ok {
return
}
if _, err := h.db.Where("env_id = ?", env.ID).Delete(&models.GitOpsConfig{}); err != nil {
jsonError(w, "could not delete gitops config", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// TriggerSync manually initiates a reconciliation for the environment.
func (h *GitOpsHandler) TriggerSync(w http.ResponseWriter, r *http.Request) {
env, ok := h.resolveGitOpsEnv(w, r)
if !ok {
return
}
var cfg models.GitOpsConfig
if found, _ := h.db.Where("env_id = ?", env.ID).Get(&cfg); !found {
jsonError(w, "gitops not configured for this environment", http.StatusNotFound)
return
}
if cfg.DesiredSHA == "" {
jsonError(w, "no desired SHA known yet — push to the configured branch first", http.StatusConflict)
return
}
if cfg.SyncStatus == "syncing" {
jsonError(w, "a sync is already in progress", http.StatusConflict)
return
}
now := time.Now().UTC()
deploy := &models.Deployment{
EnvID: env.ID,
RepoID: env.RepoID,
SHA: cfg.DesiredSHA,
Ref: "refs/heads/" + cfg.Branch,
Status: models.DeployStatusPending,
TriggeredBy: "gitops-manual",
Description: "Manual GitOps sync",
StartedAt: &now,
}
if _, err := h.db.Insert(deploy); err != nil {
jsonError(w, "could not create deployment", http.StatusInternalServerError)
return
}
cfg.SyncStatus = "syncing"
h.db.ID(cfg.ID).Cols("sync_status").Update(&cfg) //nolint:errcheck
h.bus.Publish(events.SubjectDeploymentStarted, events.DeploymentEvent{ //nolint:errcheck
DeploymentID: deploy.ID,
EnvID: env.ID,
EnvName: env.Name,
RepoID: deploy.RepoID,
SHA: deploy.SHA,
Ref: deploy.Ref,
Status: string(deploy.Status),
TriggeredBy: deploy.TriggeredBy,
})
w.WriteHeader(http.StatusCreated)
jsonOK(w, deploy)
}
// GetDriftStatus returns the current sync status and SHA comparison for an environment.
func (h *GitOpsHandler) GetDriftStatus(w http.ResponseWriter, r *http.Request) {
env, ok := h.resolveGitOpsEnv(w, r)
if !ok {
return
}
var cfg models.GitOpsConfig
if found, _ := h.db.Where("env_id = ?", env.ID).Get(&cfg); !found {
jsonError(w, "gitops not configured for this environment", http.StatusNotFound)
return
}
type driftStatus struct {
SyncStatus string `json:"syncStatus"`
DesiredSHA string `json:"desiredSha"`
ActualSHA string `json:"actualSha"`
Branch string `json:"branch"`
IsDrifted bool `json:"isDrifted"`
}
jsonOK(w, driftStatus{
SyncStatus: cfg.SyncStatus,
DesiredSHA: cfg.DesiredSHA,
ActualSHA: cfg.ActualSHA,
Branch: cfg.Branch,
IsDrifted: cfg.DesiredSHA != cfg.ActualSHA && cfg.DesiredSHA != "",
})
}
// ListDriftEvents returns the drift history for an environment, newest first.
func (h *GitOpsHandler) ListDriftEvents(w http.ResponseWriter, r *http.Request) {
env, ok := h.resolveGitOpsEnv(w, r)
if !ok {
return
}
limit := 50
if l, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil && l > 0 && l <= 200 {
limit = l
}
var drifts []models.GitOpsDriftEvent
if err := h.db.Where("env_id = ?", env.ID).Desc("id").Limit(limit).Find(&drifts); err != nil {
jsonError(w, "could not list drift events", http.StatusInternalServerError)
return
}
if drifts == nil {
drifts = []models.GitOpsDriftEvent{}
}
jsonOK(w, drifts)
}
// AcknowledgeDrift marks a drift event as acknowledged without triggering a sync.
func (h *GitOpsHandler) AcknowledgeDrift(w http.ResponseWriter, r *http.Request) {
env, ok := h.resolveGitOpsEnv(w, r)
if !ok {
return
}
driftID, err := strconv.ParseInt(chi.URLParam(r, "driftID"), 10, 64)
if err != nil {
jsonError(w, "invalid drift event ID", http.StatusBadRequest)
return
}
var drift models.GitOpsDriftEvent
if found, _ := h.db.Where("id = ? AND env_id = ?", driftID, env.ID).Get(&drift); !found {
jsonError(w, "drift event not found", http.StatusNotFound)
return
}
if drift.ResolvedAt != nil {
jsonError(w, "drift event is already resolved", http.StatusConflict)
return
}
now := time.Now().UTC()
drift.SyncStatus = "acknowledged"
drift.ResolvedAt = &now
if _, err := h.db.ID(drift.ID).Cols("sync_status", "resolved_at").Update(&drift); err != nil {
jsonError(w, "could not acknowledge drift", http.StatusInternalServerError)
return
}
jsonOK(w, drift)
}
// ── Helpers ───────────────────────────────────────────────────────────────────
func (h *GitOpsHandler) resolveGitOpsEnv(w http.ResponseWriter, r *http.Request) (*models.Environment, bool) {
repoID, ok := resolveRepoID(h.db, w, r)
if !ok {
return nil, false
}
envName := chi.URLParam(r, "envName")
var env models.Environment
if found, _ := h.db.Where("repo_id = ? AND name = ?", repoID, envName).Get(&env); !found {
jsonError(w, "environment not found", http.StatusNotFound)
return nil, false
}
return &env, true
}
+49
View File
@@ -0,0 +1,49 @@
package handlers
import (
"net/http"
"xorm.io/xorm"
gitdomain "github.com/forgeo/forgebucket/internal/domain/git"
)
type InsightsHandler struct {
db *xorm.Engine
}
func NewInsightsHandler(db *xorm.Engine) *InsightsHandler {
return &InsightsHandler{db: db}
}
type insightsResponse struct {
Languages []gitdomain.LangStat `json:"languages"`
Contributors []gitdomain.Contributor `json:"contributors"`
TotalCommits int `json:"totalCommits"`
}
// Get returns language breakdown, top contributors, and total commit count
// for a repository. All data is read directly from git — no DB writes.
func (h *InsightsHandler) Get(w http.ResponseWriter, r *http.Request) {
repo, ok := resolveRepo(h.db, w, r)
if !ok {
return
}
langs, _ := gitdomain.LanguageStats(repo.DiskPath, repo.DefaultBranch)
contribs, _ := gitdomain.Contributors(repo.DiskPath, 8)
count, _ := gitdomain.CommitCount(repo.DiskPath)
if langs == nil {
langs = []gitdomain.LangStat{}
}
if contribs == nil {
contribs = []gitdomain.Contributor{}
}
jsonOK(w, insightsResponse{
Languages: langs,
Contributors: contribs,
TotalCommits: count,
})
}
+48
View File
@@ -0,0 +1,48 @@
package handlers
import (
"encoding/json"
"net/http"
"net/url"
"github.com/forgeo/forgebucket/internal/config"
)
type InstanceHandler struct {
cfg *config.Config
}
func NewInstanceHandler(cfg *config.Config) *InstanceHandler {
return &InstanceHandler{cfg: cfg}
}
// Get returns the public instance configuration needed by the frontend to
// construct clone URLs (SSH host, SSH port, instance name).
func (h *InstanceHandler) Get(w http.ResponseWriter, r *http.Request) {
sshHost := h.sshHost(r)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{ //nolint:errcheck
"sshHost": sshHost,
"sshPort": h.cfg.SSHPort,
"instanceName": h.cfg.InstanceName,
})
}
// sshHost resolves the SSH hostname to display in clone URLs.
// Priority: SSH_HOST env var > InstanceURL hostname > request Host header > localhost.
func (h *InstanceHandler) sshHost(r *http.Request) string {
if h.cfg.SSHHost != "" {
return h.cfg.SSHHost
}
if h.cfg.InstanceURL != "" {
if u, err := url.Parse(h.cfg.InstanceURL); err == nil && u.Hostname() != "" {
return u.Hostname()
}
}
// Strip port from Host header if present.
host := r.Host
if u, err := url.Parse("http://" + host); err == nil {
return u.Hostname()
}
return "localhost"
}
+1 -14
View File
@@ -132,20 +132,7 @@ func (h *IssueHandler) Reopen(w http.ResponseWriter, r *http.Request) {
} }
func (h *IssueHandler) repoID(w http.ResponseWriter, r *http.Request) (int64, bool) { func (h *IssueHandler) repoID(w http.ResponseWriter, r *http.Request) (int64, bool) {
ownerName := chi.URLParam(r, "owner") return resolveRepoID(h.db, w, r)
repoName := chi.URLParam(r, "repo")
var owner models.User
if found, _ := h.db.Where("username = ?", ownerName).Get(&owner); !found {
jsonError(w, "repository not found", http.StatusNotFound)
return 0, false
}
var repo models.Repository
if found, _ := h.db.Where("owner_id = ? AND name = ?", owner.ID, repoName).Get(&repo); !found {
jsonError(w, "repository not found", http.StatusNotFound)
return 0, false
}
return repo.ID, true
} }
func (h *IssueHandler) lookupIssue(w http.ResponseWriter, r *http.Request) (*models.Issue, bool) { func (h *IssueHandler) lookupIssue(w http.ResponseWriter, r *http.Request) (*models.Issue, bool) {
+6 -12
View File
@@ -48,19 +48,13 @@ type deployKeyResponse struct {
} }
func (h *DeployKeyHandler) lookupRepo(w http.ResponseWriter, r *http.Request) (*models.Repository, *models.User, bool) { func (h *DeployKeyHandler) lookupRepo(w http.ResponseWriter, r *http.Request) (*models.Repository, *models.User, bool) {
ownerName := chi.URLParam(r, "owner") repo, ok := resolveRepo(h.db, w, r)
repoName := chi.URLParam(r, "repo") if !ok {
return nil, nil, false
}
var owner models.User var owner models.User
if found, _ := h.db.Where("username = ?", ownerName).Get(&owner); !found { h.db.ID(repo.OwnerID).Get(&owner)
jsonError(w, "repository not found", http.StatusNotFound) return repo, &owner, true
return nil, nil, false
}
var repo models.Repository
if found, _ := h.db.Where("owner_id = ? AND name = ?", owner.ID, repoName).Get(&repo); !found {
jsonError(w, "repository not found", http.StatusNotFound)
return nil, nil, false
}
return &repo, &owner, true
} }
func (h *DeployKeyHandler) canManage(repo *models.Repository, callerID int64) bool { func (h *DeployKeyHandler) canManage(repo *models.Repository, callerID int64) bool {
+1 -26
View File
@@ -4,7 +4,6 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"github.com/go-chi/chi/v5"
"xorm.io/xorm" "xorm.io/xorm"
"github.com/forgeo/forgebucket/internal/api/middleware" "github.com/forgeo/forgebucket/internal/api/middleware"
@@ -16,31 +15,7 @@ type LFSHandler struct{ db *xorm.Engine }
func NewLFSHandler(db *xorm.Engine) *LFSHandler { return &LFSHandler{db: db} } func NewLFSHandler(db *xorm.Engine) *LFSHandler { return &LFSHandler{db: db} }
func (h *LFSHandler) resolveRepo(w http.ResponseWriter, r *http.Request) (*models.Repository, bool) { func (h *LFSHandler) resolveRepo(w http.ResponseWriter, r *http.Request) (*models.Repository, bool) {
ownerName := chi.URLParam(r, "owner") return resolveRepo(h.db, w, r)
repoName := chi.URLParam(r, "repo")
var owner models.User
found, err := h.db.Where("username = ?", ownerName).Get(&owner)
if err != nil {
jsonError(w, "database error: "+err.Error(), http.StatusInternalServerError)
return nil, false
}
if !found {
jsonError(w, "repository not found", http.StatusNotFound)
return nil, false
}
var repo models.Repository
found, err = h.db.Where("owner_id = ? AND name = ?", owner.ID, repoName).Get(&repo)
if err != nil {
jsonError(w, "database error: "+err.Error(), http.StatusInternalServerError)
return nil, false
}
if !found {
jsonError(w, "repository not found", http.StatusNotFound)
return nil, false
}
return &repo, true
} }
func (h *LFSHandler) canManage(repo *models.Repository, callerID int64) bool { func (h *LFSHandler) canManage(repo *models.Repository, callerID int64) bool {
+7 -14
View File
@@ -28,22 +28,15 @@ type memberResponse struct {
AddedAt string `json:"addedAt"` AddedAt string `json:"addedAt"`
} }
// lookupRepoForMembers resolves the repo from URL params and returns the owner User. // lookupRepoAndOwner resolves {owner}/{repo} and returns the repo + its creator user.
func (h *MemberHandler) lookupRepoAndOwner(w http.ResponseWriter, r *http.Request) (*models.Repository, *models.User, bool) { func (h *MemberHandler) lookupRepoAndOwner(w http.ResponseWriter, r *http.Request) (*models.Repository, *models.User, bool) {
ownerName := chi.URLParam(r, "owner") repo, ok := resolveRepo(h.db, w, r)
repoName := chi.URLParam(r, "repo") if !ok {
return nil, nil, false
}
var owner models.User var owner models.User
if found, _ := h.db.Where("username = ?", ownerName).Get(&owner); !found { h.db.ID(repo.OwnerID).Get(&owner)
jsonError(w, "repository not found", http.StatusNotFound) return repo, &owner, true
return nil, nil, false
}
var repo models.Repository
if found, _ := h.db.Where("owner_id = ? AND name = ?", owner.ID, repoName).Get(&repo); !found {
jsonError(w, "repository not found", http.StatusNotFound)
return nil, nil, false
}
return &repo, &owner, true
} }
// callerCanManage returns true if callerID is the repo owner or has admin permission. // callerCanManage returns true if callerID is the repo owner or has admin permission.
+126
View File
@@ -0,0 +1,126 @@
package handlers
import (
"net/http"
"time"
"xorm.io/xorm"
"github.com/forgeo/forgebucket/internal/events"
"github.com/forgeo/forgebucket/internal/models"
"github.com/forgeo/forgebucket/internal/observability"
)
// ── /health ───────────────────────────────────────────────────────────────────
type HealthHandler struct {
db *xorm.Engine
bus events.EventBus
}
func NewHealthHandler(db *xorm.Engine, bus events.EventBus) *HealthHandler {
return &HealthHandler{db: db, bus: bus}
}
func (h *HealthHandler) Health(w http.ResponseWriter, r *http.Request) {
status := observability.Check(h.db, h.bus)
if status.Status != "healthy" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusServiceUnavailable)
jsonOK(w, status)
return
}
jsonOK(w, status)
}
// ── /api/v1/repos/{owner}/{repo}/health ──────────────────────────────────────
type RepoHealthHandler struct{ db *xorm.Engine }
func NewRepoHealthHandler(db *xorm.Engine) *RepoHealthHandler {
return &RepoHealthHandler{db: db}
}
type latestDeployment struct {
EnvName string `json:"envName"`
Status string `json:"status"`
SHA string `json:"sha"`
FinishedAt *time.Time `json:"finishedAt"`
}
type repoHealthResponse struct {
CIPassRate7d float64 `json:"ciPassRate7d"`
TotalRuns7d int `json:"totalRuns7d"`
LatestRun *models.PipelineRun `json:"latestRun"`
LatestDeployments []latestDeployment `json:"latestDeployments"`
OpenDriftCount int `json:"openDriftCount"`
OpenPRCount int `json:"openPRCount"`
}
// Get returns an operational health summary for a repository.
// This feeds the repo page header: CI pass rate, latest deploy per env, drift count.
func (h *RepoHealthHandler) Get(w http.ResponseWriter, r *http.Request) {
repoID, ok := resolveRepoID(h.db, w, r)
if !ok {
return
}
since7d := time.Now().UTC().Add(-7 * 24 * time.Hour)
// CI pass rate over last 7 days.
var runs []models.PipelineRun
h.db.Where("repo_id = ? AND created_at >= ?", repoID, since7d).Find(&runs)
total := len(runs)
succeeded := 0
for _, run := range runs {
if run.Status == "succeeded" {
succeeded++
}
}
var passRate float64
if total > 0 {
passRate = float64(succeeded) / float64(total)
}
// Latest run overall.
var latestRun models.PipelineRun
var hasLatest bool
hasLatest, _ = h.db.Where("repo_id = ?", repoID).Desc("id").Limit(1).Get(&latestRun)
// Latest deployment per environment.
var envs []models.Environment
h.db.Where("repo_id = ?", repoID).Find(&envs)
deploys := make([]latestDeployment, 0, len(envs))
for _, env := range envs {
var d models.Deployment
if found, _ := h.db.Where("env_id = ?", env.ID).Desc("id").Limit(1).Get(&d); found {
deploys = append(deploys, latestDeployment{
EnvName: env.Name,
Status: string(d.Status),
SHA: d.SHA,
FinishedAt: d.FinishedAt,
})
}
}
// Open drift count (GitOpsConfigs where sync_status = 'drifted').
driftCount, _ := h.db.Where("repo_id = ? AND sync_status = 'drifted'", repoID).
Count(&models.GitOpsConfig{})
// Open PR count.
prCount, _ := h.db.Where("repo_id = ? AND status = 'open'", repoID).
Count(&models.PullRequest{})
resp := repoHealthResponse{
CIPassRate7d: passRate,
TotalRuns7d: total,
LatestDeployments: deploys,
OpenDriftCount: int(driftCount),
OpenPRCount: int(prCount),
}
if hasLatest {
resp.LatestRun = &latestRun
}
jsonOK(w, resp)
}
+529
View File
@@ -0,0 +1,529 @@
package handlers
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
"xorm.io/xorm"
"github.com/forgeo/forgebucket/internal/domain/oci"
"github.com/forgeo/forgebucket/internal/models"
)
// OCIRegistryHandler serves the OCI Distribution API at /v2/.
type OCIRegistryHandler struct {
db *xorm.Engine
reg *oci.Registry
}
func NewOCIRegistryHandler(db *xorm.Engine, reg *oci.Registry) *OCIRegistryHandler {
return &OCIRegistryHandler{db: db, reg: reg}
}
// ServeOCI is the catch-all handler mounted at /v2/.
func (h *OCIRegistryHandler) ServeOCI(w http.ResponseWriter, r *http.Request) {
// GET /v2/ — API version check.
if r.Method == http.MethodGet && (r.URL.Path == "/v2/" || r.URL.Path == "/v2") {
w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
json.NewEncoder(w).Encode(map[string]string{"ok": "true"})
return
}
name, kind, ref := oci.ParseOCIPath(r.URL.Path)
if name == "" {
h.ociError(w, http.StatusNotFound, oci.ErrNameInvalid, "invalid OCI path")
return
}
// Resolve ForgeBucket repository from image name (expected format: owner/repo).
owner, repoName, found := strings.Cut(name, "/")
if !found {
h.ociError(w, http.StatusBadRequest, oci.ErrNameInvalid, "image name must be owner/repo-name")
return
}
var repo models.Repository
if ok, _ := h.db.Where("name = ?", repoName).Join("INNER", "user", "repository.owner_id = user.id AND user.username = ?", owner).Get(&repo); !ok {
h.ociError(w, http.StatusNotFound, oci.ErrNameUnknown, "repository not found")
return
}
// Authenticate.
authedUser := h.basicAuthOCI(r)
needsAuth := repo.IsPrivate || r.Method != http.MethodGet
if needsAuth && authedUser == "" {
w.Header().Set("Www-Authenticate", `Basic realm="ForgeBucket OCI Registry"`)
h.ociError(w, http.StatusUnauthorized, oci.ErrUnauthorized, "authentication required")
return
}
if authedUser != "" {
hasWrite := HasPermission(h.db, &repo, authedUser, "write")
hasRead := HasPermission(h.db, &repo, authedUser, "read")
if !hasRead {
h.ociError(w, http.StatusForbidden, oci.ErrDenied, "access denied")
return
}
// Mutations require write access.
isMut := r.Method == http.MethodPost || r.Method == http.MethodPut || r.Method == http.MethodPatch || r.Method == http.MethodDelete
if isMut && !hasWrite {
h.ociError(w, http.StatusForbidden, oci.ErrDenied, "write access required")
return
}
}
// Resolve or create OCIRepository row.
ociRepo, err := h.getOrCreateOCIRepo(repo.ID, name)
if err != nil {
h.ociError(w, http.StatusInternalServerError, oci.ErrBlobUnknown, "internal error")
return
}
// Route to handler by (method, kind).
switch r.Method {
case http.MethodGet:
switch kind {
case "tags":
h.listTags(w, r, ociRepo)
case "manifest":
h.getManifest(w, r, ociRepo, ref)
case "blob":
h.getBlob(w, r, repo, ociRepo, ref)
case "upload":
h.getUploadStatus(w, r, ociRepo, ref)
default:
h.ociError(w, http.StatusNotFound, oci.ErrNameInvalid, "not found")
}
case http.MethodHead:
if kind == "blob" {
h.headBlob(w, r, ref)
} else {
h.ociError(w, http.StatusNotFound, oci.ErrNameInvalid, "not found")
}
case http.MethodPost:
if kind == "upload" && ref == "" {
h.startUpload(w, r, ociRepo)
} else {
h.ociError(w, http.StatusMethodNotAllowed, oci.ErrUnsupported, "method not allowed")
}
case http.MethodPatch:
if kind == "upload" && ref != "" {
h.patchUpload(w, r, ociRepo, ref)
} else {
h.ociError(w, http.StatusNotFound, oci.ErrNameInvalid, "not found")
}
case http.MethodPut:
if kind == "upload" && ref != "" {
h.finishUpload(w, r, ociRepo, ref)
} else if kind == "manifest" {
h.pushManifest(w, r, ociRepo, ref)
} else {
h.ociError(w, http.StatusNotFound, oci.ErrNameInvalid, "not found")
}
case http.MethodDelete:
if kind == "manifest" {
h.deleteManifest(w, r, ociRepo, ref)
} else if kind == "blob" {
h.deleteBlob(w, r, ociRepo, ref)
} else if kind == "upload" && ref != "" {
h.cancelUpload(w, r, ref)
} else {
h.ociError(w, http.StatusNotFound, oci.ErrNameInvalid, "not found")
}
default:
h.ociError(w, http.StatusMethodNotAllowed, oci.ErrUnsupported, "method not allowed")
}
}
// ─── GET /v2/{name}/tags/list ────────────────────────────────────────────────
func (h *OCIRegistryHandler) listTags(w http.ResponseWriter, r *http.Request, ociRepo *models.OCIRepository) {
var tags []models.OCITag
h.db.Where("oci_repo_id = ?", ociRepo.ID).Find(&tags)
names := make([]string, 0, len(tags))
for _, t := range tags {
names = append(names, t.Name)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"name": ociRepo.Name,
"tags": names,
})
}
// ─── GET /v2/{name}/manifests/{ref} ──────────────────────────────────────────
func (h *OCIRegistryHandler) getManifest(w http.ResponseWriter, r *http.Request, ociRepo *models.OCIRepository, ref string) {
digest := ref
if !oci.IsDigestRef(ref) {
// ref is a tag — resolve to digest.
var tag models.OCITag
if found, _ := h.db.Where("oci_repo_id = ? AND name = ?", ociRepo.ID, ref).Get(&tag); !found {
h.ociError(w, http.StatusNotFound, oci.ErrManifestUnknown, fmt.Sprintf("tag %q not found", ref))
return
}
digest = tag.Digest
}
var manifest models.OCIManifest
if found, _ := h.db.Where("oci_repo_id = ? AND digest = ?", ociRepo.ID, digest).Get(&manifest); !found {
h.ociError(w, http.StatusNotFound, oci.ErrManifestUnknown, "manifest not found")
return
}
w.Header().Set("Content-Type", manifest.MediaType)
w.Header().Set("Docker-Content-Digest", manifest.Digest)
w.Header().Set("Content-Length", fmt.Sprintf("%d", manifest.Size))
w.WriteHeader(http.StatusOK)
w.Write([]byte(manifest.Content)) //nolint:errcheck
}
// ─── PUT /v2/{name}/manifests/{ref} ──────────────────────────────────────────
func (h *OCIRegistryHandler) pushManifest(w http.ResponseWriter, r *http.Request, ociRepo *models.OCIRepository, ref string) {
body, err := io.ReadAll(r.Body)
if err != nil {
h.ociError(w, http.StatusBadRequest, oci.ErrManifestInvalid, "cannot read body")
return
}
if len(body) == 0 {
h.ociError(w, http.StatusBadRequest, oci.ErrManifestInvalid, "empty manifest body")
return
}
mediaType := r.Header.Get("Content-Type")
if mediaType == "" {
mediaType = "application/vnd.docker.distribution.manifest.v2+json"
}
manifestDigest, manifestSize := oci.ManifestDescriptor(body)
// Persist manifest.
m := &models.OCIManifest{
OCIRepoID: ociRepo.ID,
Digest: manifestDigest,
MediaType: mediaType,
Size: manifestSize,
Content: string(body),
}
if _, err := h.db.Insert(m); err != nil {
// Duplicate digest is fine — manifests are immutable.
if has, _ := h.db.Where("oci_repo_id = ? AND digest = ?", ociRepo.ID, manifestDigest).Get(&models.OCIManifest{}); !has {
h.ociError(w, http.StatusInternalServerError, oci.ErrManifestInvalid, "store manifest failed")
return
}
}
// If ref is not a digest, treat it as a tag.
if !oci.IsDigestRef(ref) {
tag := &models.OCITag{
OCIRepoID: ociRepo.ID,
Name: ref,
Digest: manifestDigest,
}
existing := &models.OCITag{}
if has, _ := h.db.Where("oci_repo_id = ? AND name = ?", ociRepo.ID, ref).Get(existing); has {
existing.Digest = manifestDigest
existing.UpdatedAt = time.Now()
h.db.ID(existing.ID).Cols("digest", "updated_at").Update(existing)
} else {
h.db.Insert(tag)
}
}
// Track blobs referenced by this manifest so GC can work.
h.trackBlobRefs(ociRepo, body)
w.Header().Set("Location", fmt.Sprintf("/v2/%s/manifests/%s", ociRepo.Name, manifestDigest))
w.Header().Set("Content-Type", mediaType)
w.Header().Set("Docker-Content-Digest", manifestDigest)
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]string{"digest": manifestDigest})
}
// trackBlobRefs parses the manifest and ensures referenced blob digests exist as OCIBlob rows.
func (h *OCIRegistryHandler) trackBlobRefs(ociRepo *models.OCIRepository, body []byte) {
var manifest struct {
Layers []struct {
Digest string `json:"digest"`
} `json:"layers"`
Config struct {
Digest string `json:"digest"`
} `json:"config"`
}
if err := json.Unmarshal(body, &manifest); err != nil {
return
}
digests := []string{}
if manifest.Config.Digest != "" {
digests = append(digests, manifest.Config.Digest)
}
for _, layer := range manifest.Layers {
if layer.Digest != "" {
digests = append(digests, layer.Digest)
}
}
for _, d := range digests {
if h.reg.BlobExists(d) {
h.db.Insert(&models.OCIBlob{Digest: d, Size: h.reg.BlobSize(d)}) //nolint:errcheck,nestif
}
}
}
// ─── DELETE /v2/{name}/manifests/{ref} ───────────────────────────────────────
func (h *OCIRegistryHandler) deleteManifest(w http.ResponseWriter, r *http.Request, ociRepo *models.OCIRepository, ref string) {
digest := ref
if !oci.IsDigestRef(ref) {
var tag models.OCITag
if found, _ := h.db.Where("oci_repo_id = ? AND name = ?", ociRepo.ID, ref).Get(&tag); !found {
h.ociError(w, http.StatusNotFound, oci.ErrManifestUnknown, "tag not found")
return
}
digest = tag.Digest
// Delete the tag.
h.db.ID(tag.ID).Delete(&models.OCITag{})
}
affected, _ := h.db.Where("oci_repo_id = ? AND digest = ?", ociRepo.ID, digest).Delete(&models.OCIManifest{})
if affected == 0 {
h.ociError(w, http.StatusNotFound, oci.ErrManifestUnknown, "manifest not found")
return
}
w.WriteHeader(http.StatusAccepted)
}
// ─── HEAD /v2/{name}/blobs/{digest} ──────────────────────────────────────────
func (h *OCIRegistryHandler) headBlob(w http.ResponseWriter, r *http.Request, digest string) {
if !h.reg.BlobExists(digest) {
h.ociError(w, http.StatusNotFound, oci.ErrBlobUnknown, "blob not found")
return
}
size := h.reg.BlobSize(digest)
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Length", fmt.Sprintf("%d", size))
w.Header().Set("Docker-Content-Digest", digest)
w.WriteHeader(http.StatusOK)
}
// ─── GET /v2/{name}/blobs/{digest} ───────────────────────────────────────────
func (h *OCIRegistryHandler) getBlob(w http.ResponseWriter, r *http.Request, repo models.Repository, ociRepo *models.OCIRepository, digest string) {
if !h.reg.BlobExists(digest) {
h.ociError(w, http.StatusNotFound, oci.ErrBlobUnknown, "blob not found")
return
}
size := h.reg.BlobSize(digest)
f, err := h.reg.ReadBlob(digest)
if err != nil {
h.ociError(w, http.StatusInternalServerError, oci.ErrBlobUnknown, "cannot read blob")
return
}
defer f.Close()
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Length", fmt.Sprintf("%d", size))
w.Header().Set("Docker-Content-Digest", digest)
http.ServeContent(w, r, "", time.Time{}, f)
}
// ─── DELETE /v2/{name}/blobs/{digest} ────────────────────────────────────────
func (h *OCIRegistryHandler) deleteBlob(w http.ResponseWriter, r *http.Request, ociRepo *models.OCIRepository, digest string) {
if !h.reg.BlobExists(digest) {
h.ociError(w, http.StatusNotFound, oci.ErrBlobUnknown, "blob not found")
return
}
_ = h.reg.DeleteBlob(digest)
h.db.Where("digest = ?", digest).Delete(&models.OCIBlob{})
w.WriteHeader(http.StatusAccepted)
}
// ─── POST /v2/{name}/blobs/uploads/ ──────────────────────────────────────────
func (h *OCIRegistryHandler) startUpload(w http.ResponseWriter, r *http.Request, ociRepo *models.OCIRepository) {
uploadID := newOCIUploadID()
// Check for single-shot upload (body with ?digest param).
clientDigest := r.URL.Query().Get("digest")
contentLength := r.ContentLength
if clientDigest != "" && contentLength > 0 {
// Single-shot POST upload.
digest, size, err := h.reg.WriteBlob(r.Body)
if err != nil {
h.ociError(w, http.StatusInternalServerError, oci.ErrBlobUploadInvalid, "upload failed")
return
}
h.upsertOCIName(ociRepo, digest, size)
w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/%s", ociRepo.Name, digest))
w.Header().Set("Content-Range", fmt.Sprintf("0-%d", size-1))
w.Header().Set("Docker-Content-Digest", digest)
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]string{"digest": digest})
return
}
// Create upload session.
h.db.Insert(&models.OCIUpload{ //nolint:errcheck
UploadID: uploadID,
Name: ociRepo.Name,
ExpiresAt: time.Now().UTC().Add(30 * time.Minute),
})
w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/uploads/%s", ociRepo.Name, uploadID))
w.Header().Set("Range", "0-0")
w.WriteHeader(http.StatusAccepted)
}
// ─── PATCH /v2/{name}/blobs/uploads/{uuid} ───────────────────────────────────
func (h *OCIRegistryHandler) patchUpload(w http.ResponseWriter, r *http.Request, ociRepo *models.OCIRepository, ref string) {
// Validate the upload session exists on disk.
uploadPath := h.reg.UploadPath(ref)
_, statErr := os.Stat(uploadPath)
if h.reg.UploadOffset(ref) == 0 && os.IsNotExist(statErr) {
h.ociError(w, http.StatusNotFound, oci.ErrBlobUploadUnknown, "upload not found")
return
}
newOffset, err := h.reg.AppendUpload(ref, r.Body)
if err != nil {
h.ociError(w, http.StatusInternalServerError, oci.ErrBlobUploadInvalid, "append failed")
return
}
// Persist upload offset.
h.db.Where("upload_id = ?", ref).Cols("offset").Update(&models.OCIUpload{Offset: newOffset})
w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/uploads/%s", ociRepo.Name, ref))
w.Header().Set("Range", fmt.Sprintf("0-%d", newOffset-1))
w.Header().Set("Content-Length", "0")
w.WriteHeader(http.StatusAccepted)
}
// ─── PUT /v2/{name}/blobs/uploads/{uuid}?digest=sha256:... ───────────────────
func (h *OCIRegistryHandler) finishUpload(w http.ResponseWriter, r *http.Request, ociRepo *models.OCIRepository, ref string) {
clientDigest := r.URL.Query().Get("digest")
if clientDigest == "" {
h.ociError(w, http.StatusBadRequest, oci.ErrDigestInvalid, "digest query parameter required")
return
}
// If there's a body, append it before finalising.
if r.ContentLength > 0 || r.Body != http.NoBody {
h.reg.AppendUpload(ref, r.Body) //nolint:errcheck
}
digest, size, err := h.reg.FinishUpload(ref, clientDigest)
if err != nil {
if _, ok := err.(*oci.DigestMismatch); ok {
h.ociError(w, http.StatusBadRequest, oci.ErrDigestInvalid, err.Error())
} else {
h.ociError(w, http.StatusNotFound, oci.ErrBlobUploadUnknown, "upload not found")
}
return
}
h.upsertOCIName(ociRepo, digest, size)
// Remove upload session.
h.db.Where("upload_id = ?", ref).Delete(&models.OCIUpload{})
w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/%s", ociRepo.Name, digest))
w.Header().Set("Content-Range", fmt.Sprintf("0-%d", size-1))
w.Header().Set("Docker-Content-Digest", digest)
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]string{"digest": digest})
}
// ─── GET /v2/{name}/blobs/uploads/{uuid} ─────────────────────────────────────
func (h *OCIRegistryHandler) getUploadStatus(w http.ResponseWriter, r *http.Request, ociRepo *models.OCIRepository, ref string) {
offset := h.reg.UploadOffset(ref)
w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/uploads/%s", ociRepo.Name, ref))
w.Header().Set("Range", fmt.Sprintf("0-%d", offset))
w.WriteHeader(http.StatusNoContent)
}
// ─── DELETE /v2/{name}/blobs/uploads/{uuid} ──────────────────────────────────
func (h *OCIRegistryHandler) cancelUpload(w http.ResponseWriter, r *http.Request, ref string) {
h.reg.CancelUpload(ref)
h.db.Where("upload_id = ?", ref).Delete(&models.OCIUpload{})
w.WriteHeader(http.StatusNoContent)
}
// ─── helpers ──────────────────────────────────────────────────────────────────
func (h *OCIRegistryHandler) getOrCreateOCIRepo(repoID int64, name string) (*models.OCIRepository, error) {
r := &models.OCIRepository{}
if found, _ := h.db.Where("name = ?", name).Get(r); found {
return r, nil
}
r.RepoID = repoID
r.Name = name
if _, err := h.db.Insert(r); err != nil {
return nil, err
}
return r, nil
}
func (h *OCIRegistryHandler) upsertOCIName(ociRepo *models.OCIRepository, digest string, size int64) {
// Track blob in DB if not already tracked.
h.db.Insert(&models.OCIBlob{Digest: digest, Size: size}) //nolint:errcheck
}
func (h *OCIRegistryHandler) ociError(w http.ResponseWriter, status int, code oci.ErrorCode, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
w.Write(oci.NewError(code, msg)) //nolint:errcheck
}
// newOCIUploadID generates a random hex string used as the upload session ID.
func newOCIUploadID() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
panic("oci: crypto/rand failed: " + err.Error())
}
return hex.EncodeToString(b)
}
func (h *OCIRegistryHandler) basicAuthOCI(r *http.Request) string {
u, pass, hasAuth := r.BasicAuth()
if !hasAuth {
return ""
}
var user models.User
if found, _ := h.db.Where("username = ?", u).Get(&user); !found {
return ""
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(pass)); err != nil {
return ""
}
return u
}
+1 -13
View File
@@ -247,19 +247,7 @@ func (h *PipelineHandler) RetryJob(w http.ResponseWriter, r *http.Request) {
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
func (h *PipelineHandler) repoID(w http.ResponseWriter, r *http.Request) (int64, bool) { func (h *PipelineHandler) repoID(w http.ResponseWriter, r *http.Request) (int64, bool) {
owner := chi.URLParam(r, "owner") return resolveRepoID(h.db, w, r)
repoName := chi.URLParam(r, "repo")
var u models.User
if found, _ := h.db.Where("username = ?", owner).Get(&u); !found {
jsonError(w, "repository not found", http.StatusNotFound)
return 0, false
}
var repo models.Repository
if found, _ := h.db.Where("owner_id = ? AND name = ?", u.ID, repoName).Get(&repo); !found {
jsonError(w, "repository not found", http.StatusNotFound)
return 0, false
}
return repo.ID, true
} }
func (h *PipelineHandler) lookupRun(w http.ResponseWriter, r *http.Request) (*models.PipelineRun, bool) { func (h *PipelineHandler) lookupRun(w http.ResponseWriter, r *http.Request) (*models.PipelineRun, bool) {
+1 -23
View File
@@ -18,29 +18,7 @@ func NewPRSettingsHandler(db *xorm.Engine) *PRSettingsHandler {
} }
func (h *PRSettingsHandler) resolveRepo(w http.ResponseWriter, r *http.Request) (*models.Repository, bool) { func (h *PRSettingsHandler) resolveRepo(w http.ResponseWriter, r *http.Request) (*models.Repository, bool) {
ownerName := chi.URLParam(r, "owner") return resolveRepo(h.db, w, r)
repoName := chi.URLParam(r, "repo")
var owner models.User
found, err := h.db.Where("username = ?", ownerName).Get(&owner)
if err != nil {
jsonError(w, "database error: "+err.Error(), http.StatusInternalServerError)
return nil, false
}
if !found {
jsonError(w, "repository not found", http.StatusNotFound)
return nil, false
}
var repo models.Repository
found, err = h.db.Where("owner_id = ? AND name = ?", owner.ID, repoName).Get(&repo)
if err != nil {
jsonError(w, "database error: "+err.Error(), http.StatusInternalServerError)
return nil, false
}
if !found {
jsonError(w, "repository not found", http.StatusNotFound)
return nil, false
}
return &repo, true
} }
func (h *PRSettingsHandler) canManage(repo *models.Repository, callerID int64) bool { func (h *PRSettingsHandler) canManage(repo *models.Repository, callerID int64) bool {
+1 -17
View File
@@ -241,23 +241,7 @@ func (h *PRHandler) Update(w http.ResponseWriter, r *http.Request) {
} }
func (h *PRHandler) repoIDFromURL(w http.ResponseWriter, r *http.Request) (int64, bool) { func (h *PRHandler) repoIDFromURL(w http.ResponseWriter, r *http.Request) (int64, bool) {
ownerName := chi.URLParam(r, "owner") return resolveRepoID(h.db, w, r)
repoName := chi.URLParam(r, "repo")
var owner models.User
found, err := h.db.Where("username = ?", ownerName).Get(&owner)
if err != nil || !found {
jsonError(w, "repository not found", http.StatusNotFound)
return 0, false
}
var repo models.Repository
found, err = h.db.Where("owner_id = ? AND name = ?", owner.ID, repoName).Get(&repo)
if err != nil || !found {
jsonError(w, "repository not found", http.StatusNotFound)
return 0, false
}
return repo.ID, true
} }
func (h *PRHandler) lookupPR(w http.ResponseWriter, r *http.Request) (*models.PullRequest, bool) { func (h *PRHandler) lookupPR(w http.ResponseWriter, r *http.Request) (*models.PullRequest, bool) {
+55
View File
@@ -0,0 +1,55 @@
package handlers
// repo_lookup.go — shared helper used by all handlers that resolve
// {owner}/{repo} URL params to a repository row.
//
// The owner segment can be either a user username (user-owned repo) or a
// workspace handle (workspace-owned repo). This tries user-namespace first,
// then workspace-namespace, so the lookup is always unambiguous.
import (
"net/http"
"github.com/go-chi/chi/v5"
"xorm.io/xorm"
"github.com/forgeo/forgebucket/internal/models"
)
// resolveRepoID resolves /{owner}/{repo} to a repository ID.
// It tries user namespace first, then workspace namespace.
// Returns (repoID, true) on success or writes a 404 and returns (0, false).
func resolveRepoID(db *xorm.Engine, w http.ResponseWriter, r *http.Request) (int64, bool) {
repo, ok := resolveRepo(db, w, r)
if !ok {
return 0, false
}
return repo.ID, true
}
// resolveRepo is the full repo lookup returning the Repository struct.
func resolveRepo(db *xorm.Engine, w http.ResponseWriter, r *http.Request) (*models.Repository, bool) {
ownerName := chi.URLParam(r, "owner")
repoName := chi.URLParam(r, "repo")
// 1. Try user namespace.
var u models.User
if found, _ := db.Where("username = ?", ownerName).Get(&u); found {
var repo models.Repository
if found2, _ := db.Where("owner_id = ? AND name = ?", u.ID, repoName).Get(&repo); found2 {
return &repo, true
}
}
// 2. Try workspace namespace.
var ws models.Workspace
if found, _ := db.Where("handle = ?", ownerName).Get(&ws); found {
var repo models.Repository
if found2, _ := db.Where("workspace_id = ? AND name = ?", ws.ID, repoName).Get(&repo); found2 {
return &repo, true
}
}
jsonError(w, "repository not found", http.StatusNotFound)
return nil, false
}
+148
View File
@@ -1,7 +1,10 @@
package handlers package handlers
import ( import (
"archive/zip"
"bytes"
"encoding/json" "encoding/json"
"fmt"
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
@@ -650,3 +653,148 @@ func (h *RepoHandler) lookupRepo(w http.ResponseWriter, r *http.Request) (*model
jsonError(w, "repository not found", http.StatusNotFound) jsonError(w, "repository not found", http.StatusNotFound)
return nil, false return nil, false
} }
// SearchFiles handles GET /repos/{owner}/{repo}/files?q=...&ref=...
// Returns up to 20 matching file paths (case-insensitive substring match).
// When q is empty, returns all file paths up to 500 (used by the sidebar tree).
func (h *RepoHandler) SearchFiles(w http.ResponseWriter, r *http.Request) {
repo, ok := h.lookupRepo(w, r)
if !ok {
return
}
query := strings.TrimSpace(r.URL.Query().Get("q"))
ref := r.URL.Query().Get("ref")
if ref == "" {
ref = repo.DefaultBranch
}
limit := 20
if query == "" {
limit = 500
}
files, err := gitdomain.SearchFiles(repo.DiskPath, ref, query, limit)
if err != nil {
jsonError(w, "search failed", http.StatusInternalServerError)
return
}
if files == nil {
files = []string{}
}
jsonOK(w, files)
}
// UploadFiles handles POST /repos/{owner}/{repo}/upload — multipart upload.
// Accepts multiple regular files (field "file[]") and/or a ZIP archive (field "zip").
// All files are committed in a single git commit.
func (h *RepoHandler) UploadFiles(w http.ResponseWriter, r *http.Request) {
repo, ok := h.lookupRepo(w, r)
if !ok {
return
}
username, _ := r.Context().Value(middleware.ContextKeyUsername).(string)
if !HasPermission(h.db, repo, username, "write") {
jsonError(w, "you do not have write access to this repository", http.StatusForbidden)
return
}
const maxUpload = 50 << 20 // 50 MB
if err := r.ParseMultipartForm(maxUpload); err != nil {
jsonError(w, "could not parse upload: "+err.Error(), http.StatusBadRequest)
return
}
branch := r.FormValue("branch")
if branch == "" {
branch = repo.DefaultBranch
}
message := r.FormValue("message")
if message == "" {
message = "Upload files"
}
var uploads []gitdomain.FileUpload
// Regular files (field "file[]" or "file"). Browser sends webkitRelativePath
// via the custom header X-File-Path; fall back to the bare filename.
for _, fhs := range r.MultipartForm.File {
for _, fh := range fhs {
if fh.Size == 0 {
continue
}
f, err := fh.Open()
if err != nil {
continue
}
data, err := io.ReadAll(io.LimitReader(f, 10<<20)) // 10 MB per file
f.Close()
if err != nil {
continue
}
// Prefer the relative path sent by the browser (folder upload),
// otherwise use the bare filename.
relPath := fh.Filename
if rp := fh.Header.Get("X-File-Path"); rp != "" {
relPath = rp
}
if strings.EqualFold(fh.Header.Get("Content-Disposition"), "") {
// Skip the "zip" field — handled separately below.
}
clean := filepath.Clean(filepath.FromSlash(relPath))
if strings.HasPrefix(clean, "..") || filepath.IsAbs(clean) {
jsonError(w, fmt.Sprintf("invalid path: %s", relPath), http.StatusBadRequest)
return
}
uploads = append(uploads, gitdomain.FileUpload{Path: clean, Content: data})
}
}
// ZIP archive (field "zip").
if zipFHs, ok := r.MultipartForm.File["zip"]; ok && len(zipFHs) > 0 {
fh := zipFHs[0]
f, err := fh.Open()
if err == nil {
zipData, err := io.ReadAll(io.LimitReader(f, maxUpload))
f.Close()
if err == nil {
zr, err := zip.NewReader(bytes.NewReader(zipData), int64(len(zipData)))
if err == nil {
for _, zf := range zr.File {
if zf.FileInfo().IsDir() {
continue
}
clean := filepath.Clean(filepath.FromSlash(zf.Name))
if strings.HasPrefix(clean, "..") || filepath.IsAbs(clean) {
continue
}
rc, err := zf.Open()
if err != nil {
continue
}
data, err := io.ReadAll(io.LimitReader(rc, 10<<20))
rc.Close()
if err != nil {
continue
}
uploads = append(uploads, gitdomain.FileUpload{Path: clean, Content: data})
}
}
}
}
}
if len(uploads) == 0 {
jsonError(w, "no files found in upload", http.StatusBadRequest)
return
}
if err := gitdomain.WriteManyFiles(repo.DiskPath, branch, message, username, username+"@forgebucket", uploads); err != nil {
jsonError(w, "commit failed: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]int{"committed": len(uploads)}) //nolint:errcheck
}
+142
View File
@@ -0,0 +1,142 @@
package handlers
import (
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"xorm.io/xorm"
"github.com/forgeo/forgebucket/internal/domain/sbom"
)
type SBOMHandler struct {
db *xorm.Engine
generator *sbom.Generator
}
func NewSBOMHandler(db *xorm.Engine, gen *sbom.Generator) *SBOMHandler {
return &SBOMHandler{db: db, generator: gen}
}
// GetForRun returns the SBOM report metadata for a pipeline run.
// GET /api/v1/repos/{owner}/{repo}/runs/{runID}/sbom
func (h *SBOMHandler) GetForRun(w http.ResponseWriter, r *http.Request) {
runID, err := strconv.ParseInt(chi.URLParam(r, "runID"), 10, 64)
if err != nil {
jsonError(w, "invalid run ID", http.StatusBadRequest)
return
}
report, err := h.generator.GetForRun(runID)
if err != nil {
jsonError(w, "database error", http.StatusInternalServerError)
return
}
if report == nil {
jsonError(w, "SBOM not yet generated for this run", http.StatusNotFound)
return
}
jsonOK(w, report)
}
// GetDocumentForRun streams the full CycloneDX JSON document for a run.
// GET /api/v1/repos/{owner}/{repo}/runs/{runID}/sbom/document
func (h *SBOMHandler) GetDocumentForRun(w http.ResponseWriter, r *http.Request) {
runID, err := strconv.ParseInt(chi.URLParam(r, "runID"), 10, 64)
if err != nil {
jsonError(w, "invalid run ID", http.StatusBadRequest)
return
}
report, err := h.generator.GetForRun(runID)
if err != nil {
jsonError(w, "database error", http.StatusInternalServerError)
return
}
if report == nil {
jsonError(w, "SBOM not yet generated for this run", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/vnd.cyclonedx+json")
w.Header().Set("Content-Disposition", `attachment; filename="bom.json"`)
w.WriteHeader(http.StatusOK)
w.Write([]byte(report.BOMDocument)) //nolint:errcheck
}
// GetLatest returns the most recent SBOM report metadata for a repo.
// GET /api/v1/repos/{owner}/{repo}/sbom
func (h *SBOMHandler) GetLatest(w http.ResponseWriter, r *http.Request) {
repoID, ok := resolveRepoID(h.db, w, r)
if !ok {
return
}
report, err := h.generator.GetLatest(repoID)
if err != nil {
jsonError(w, "database error", http.StatusInternalServerError)
return
}
if report == nil {
jsonError(w, "no SBOM generated yet — push a commit or trigger a pipeline run", http.StatusNotFound)
return
}
jsonOK(w, report)
}
// GetLatestDocument streams the latest CycloneDX JSON for a repo.
// GET /api/v1/repos/{owner}/{repo}/sbom/document
func (h *SBOMHandler) GetLatestDocument(w http.ResponseWriter, r *http.Request) {
repoID, ok := resolveRepoID(h.db, w, r)
if !ok {
return
}
report, err := h.generator.GetLatest(repoID)
if err != nil {
jsonError(w, "database error", http.StatusInternalServerError)
return
}
if report == nil {
jsonError(w, "no SBOM generated yet", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/vnd.cyclonedx+json")
w.Header().Set("Content-Disposition", `attachment; filename="bom.json"`)
w.WriteHeader(http.StatusOK)
w.Write([]byte(report.BOMDocument)) //nolint:errcheck
}
// Generate triggers on-demand SBOM generation for a repo at a given ref/SHA.
// POST /api/v1/repos/{owner}/{repo}/sbom/generate?ref=<sha-or-branch>[&runID=<id>]
func (h *SBOMHandler) Generate(w http.ResponseWriter, r *http.Request) {
repoID, ok := resolveRepoID(h.db, w, r)
if !ok {
return
}
sha := r.URL.Query().Get("ref")
if sha == "" {
sha = r.URL.Query().Get("sha")
}
if sha == "" {
jsonError(w, "ref or sha query param required", http.StatusBadRequest)
return
}
var runID int64
if rid := r.URL.Query().Get("runID"); rid != "" {
runID, _ = strconv.ParseInt(rid, 10, 64)
}
report, err := h.generator.GenerateOnDemand(repoID, runID, sha)
if err != nil {
jsonError(w, "generation failed: "+err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
jsonOK(w, report)
}
+78
View File
@@ -0,0 +1,78 @@
package handlers
import (
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"xorm.io/xorm"
"github.com/forgeo/forgebucket/internal/api/middleware"
"github.com/forgeo/forgebucket/internal/domain/scanning"
"github.com/forgeo/forgebucket/internal/models"
)
type ScanningHandler struct {
db *xorm.Engine
scanner *scanning.Scanner
}
func NewScanningHandler(db *xorm.Engine, scanner *scanning.Scanner) *ScanningHandler {
return &ScanningHandler{db: db, scanner: scanner}
}
// ListSecrets returns all active (non-dismissed) secret leaks for a repo.
// GET /api/v1/repos/{owner}/{repo}/secrets/leaks
func (h *ScanningHandler) ListSecrets(w http.ResponseWriter, r *http.Request) {
repoID, ok := resolveRepoID(h.db, w, r)
if !ok {
return
}
leaks, err := h.scanner.ListFindings(repoID)
if err != nil {
jsonError(w, "database error", http.StatusInternalServerError)
return
}
jsonOK(w, leaks)
}
// DismissSecrets acknowledges a leak so it no longer appears in active lists.
// POST /api/v1/repos/{owner}/{repo}/secrets/leaks/{leakID}/dismiss
func (h *ScanningHandler) DismissSecrets(w http.ResponseWriter, r *http.Request) {
repoID, ok := resolveRepoID(h.db, w, r)
if !ok {
return
}
_ = repoID
leakID, err := strconv.ParseInt(chi.URLParam(r, "leakID"), 10, 64)
if err != nil {
jsonError(w, "invalid leak ID", http.StatusBadRequest)
return
}
// Get current user from session for audit trail.
username, _ := r.Context().Value(middleware.ContextKeyUsername).(string)
if err := h.scanner.DismissFindings(leakID, username); err != nil {
jsonError(w, err.Error(), http.StatusNotFound)
return
}
jsonOK(w, map[string]string{"status": "dismissed"})
}
// ListAllSecrets returns active leaks across all repos (admin/workspace).
// GET /api/v1/secrets/leaks
func (h *ScanningHandler) ListAllSecrets(w http.ResponseWriter, r *http.Request) {
var leaks []models.SecretLeak
if err := h.db.Where("dismissed = ?", false).
OrderBy("detected_at DESC").Find(&leaks); err != nil {
jsonError(w, "database error", http.StatusInternalServerError)
return
}
if leaks == nil {
leaks = []models.SecretLeak{}
}
jsonOK(w, leaks)
}
+1 -23
View File
@@ -242,29 +242,7 @@ func ResolveSecretsForRun(db *xorm.Engine, repoID, workspaceID, envID int64, ses
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
func (h *SecretHandler) resolveRepoID(w http.ResponseWriter, r *http.Request) (int64, bool) { func (h *SecretHandler) resolveRepoID(w http.ResponseWriter, r *http.Request) (int64, bool) {
owner := chi.URLParam(r, "owner") return resolveRepoID(h.db, w, r)
repoName := chi.URLParam(r, "repo")
var u models.User
if found, _ := h.db.Where("username = ?", owner).Get(&u); !found {
// Try workspace
var ws models.Workspace
if found2, _ := h.db.Where("handle = ?", owner).Get(&ws); !found2 {
jsonError(w, "repository not found", http.StatusNotFound)
return 0, false
}
var repo models.Repository
if found3, _ := h.db.Where("workspace_id = ? AND name = ?", ws.ID, repoName).Get(&repo); !found3 {
jsonError(w, "repository not found", http.StatusNotFound)
return 0, false
}
return repo.ID, true
}
var repo models.Repository
if found, _ := h.db.Where("owner_id = ? AND name = ?", u.ID, repoName).Get(&repo); !found {
jsonError(w, "repository not found", http.StatusNotFound)
return 0, false
}
return repo.ID, true
} }
func (h *SecretHandler) resolveEnvID(w http.ResponseWriter, r *http.Request) (int64, bool) { func (h *SecretHandler) resolveEnvID(w http.ResponseWriter, r *http.Request) (int64, bool) {
+2 -13
View File
@@ -6,7 +6,6 @@ import (
"strconv" "strconv"
"time" "time"
"github.com/go-chi/chi/v5"
"xorm.io/xorm" "xorm.io/xorm"
gitdomain "github.com/forgeo/forgebucket/internal/domain/git" gitdomain "github.com/forgeo/forgebucket/internal/domain/git"
@@ -69,23 +68,13 @@ type TimelineEvent struct {
// //
// GET /api/v1/repos/:owner/:repo/timeline?limit=60 // GET /api/v1/repos/:owner/:repo/timeline?limit=60
func (h *TimelineHandler) GetTimeline(w http.ResponseWriter, r *http.Request) { func (h *TimelineHandler) GetTimeline(w http.ResponseWriter, r *http.Request) {
owner := chi.URLParam(r, "owner")
repoName := chi.URLParam(r, "repo")
limit := 60 limit := 60
if l, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil && l > 0 && l <= 200 { if l, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil && l > 0 && l <= 200 {
limit = l limit = l
} }
// ── Resolve repo ────────────────────────────────────────────────────────── repo, ok := resolveRepo(h.db, w, r)
var u models.User if !ok {
if found, _ := h.db.Where("username = ?", owner).Get(&u); !found {
jsonError(w, "repository not found", http.StatusNotFound)
return
}
var repo models.Repository
if found, _ := h.db.Where("owner_id = ? AND name = ?", u.ID, repoName).Get(&repo); !found {
jsonError(w, "repository not found", http.StatusNotFound)
return return
} }
+1 -13
View File
@@ -30,19 +30,7 @@ type accessTokenResponse struct {
} }
func (h *AccessTokenHandler) lookupRepo(w http.ResponseWriter, r *http.Request) (*models.Repository, bool) { func (h *AccessTokenHandler) lookupRepo(w http.ResponseWriter, r *http.Request) (*models.Repository, bool) {
ownerName := chi.URLParam(r, "owner") return resolveRepo(h.db, w, r)
repoName := chi.URLParam(r, "repo")
var owner models.User
if found, _ := h.db.Where("username = ?", ownerName).Get(&owner); !found {
jsonError(w, "repository not found", http.StatusNotFound)
return nil, false
}
var repo models.Repository
if found, _ := h.db.Where("owner_id = ? AND name = ?", owner.ID, repoName).Get(&repo); !found {
jsonError(w, "repository not found", http.StatusNotFound)
return nil, false
}
return &repo, true
} }
func (h *AccessTokenHandler) canManage(repo *models.Repository, callerID int64) bool { func (h *AccessTokenHandler) canManage(repo *models.Repository, callerID int64) bool {
+97
View File
@@ -0,0 +1,97 @@
package handlers
import (
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"xorm.io/xorm"
"github.com/forgeo/forgebucket/internal/api/middleware"
"github.com/forgeo/forgebucket/internal/domain/vulnscan"
"github.com/forgeo/forgebucket/internal/models"
)
type VulnScanHandler struct {
db *xorm.Engine
scanner *vulnscan.Scanner
}
func NewVulnScanHandler(db *xorm.Engine, scanner *vulnscan.Scanner) *VulnScanHandler {
return &VulnScanHandler{db: db, scanner: scanner}
}
// List returns all active vulnerability findings for a repo.
// GET /api/v1/repos/{owner}/{repo}/vulnerabilities
func (h *VulnScanHandler) List(w http.ResponseWriter, r *http.Request) {
repoID, ok := resolveRepoID(h.db, w, r)
if !ok {
return
}
findings, err := h.scanner.ListFindings(repoID)
if err != nil {
jsonError(w, "database error", http.StatusInternalServerError)
return
}
jsonOK(w, findings)
}
// Scan triggers a full vulnerability scan against the latest SBOM.
// POST /api/v1/repos/{owner}/{repo}/vulnerabilities/scan
func (h *VulnScanHandler) Scan(w http.ResponseWriter, r *http.Request) {
repoID, ok := resolveRepoID(h.db, w, r)
if !ok {
return
}
findings, err := h.scanner.ScanSBOM(repoID)
if err != nil {
jsonError(w, "scan failed: "+err.Error(), http.StatusInternalServerError)
return
}
if findings == nil {
findings = []models.VulnerabilityFinding{}
}
w.WriteHeader(http.StatusCreated)
jsonOK(w, findings)
}
// Dismiss acknowledges a vulnerability finding.
// POST /api/v1/repos/{owner}/{repo}/vulnerabilities/{findingID}/dismiss
func (h *VulnScanHandler) Dismiss(w http.ResponseWriter, r *http.Request) {
repoID, ok := resolveRepoID(h.db, w, r)
if !ok {
return
}
_ = repoID
findingID, err := strconv.ParseInt(chi.URLParam(r, "findingID"), 10, 64)
if err != nil {
jsonError(w, "invalid finding ID", http.StatusBadRequest)
return
}
username, _ := r.Context().Value(middleware.ContextKeyUsername).(string)
if err := h.scanner.DismissFindings(findingID, username); err != nil {
jsonError(w, err.Error(), http.StatusNotFound)
return
}
jsonOK(w, map[string]string{"status": "dismissed"})
}
// ListAll returns active findings across all repos.
// GET /api/v1/vulnerabilities
func (h *VulnScanHandler) ListAll(w http.ResponseWriter, r *http.Request) {
var findings []models.VulnerabilityFinding
if err := h.db.Where("dismissed = ?", false).
OrderBy("cvss_score DESC, detected_at DESC").Find(&findings); err != nil {
jsonError(w, "database error", http.StatusInternalServerError)
return
}
if findings == nil {
findings = []models.VulnerabilityFinding{}
}
jsonOK(w, findings)
}
+1 -13
View File
@@ -55,19 +55,7 @@ func toWebhookResp(wh models.Webhook) webhookResponse {
} }
func (h *WebhookHandler) resolveRepo(w http.ResponseWriter, r *http.Request) (*models.Repository, bool) { func (h *WebhookHandler) resolveRepo(w http.ResponseWriter, r *http.Request) (*models.Repository, bool) {
ownerName := chi.URLParam(r, "owner") return resolveRepo(h.db, w, r)
repoName := chi.URLParam(r, "repo")
var owner models.User
if found, _ := h.db.Where("username = ?", ownerName).Get(&owner); !found {
jsonError(w, "repository not found", http.StatusNotFound)
return nil, false
}
var repo models.Repository
if found, _ := h.db.Where("owner_id = ? AND name = ?", owner.ID, repoName).Get(&repo); !found {
jsonError(w, "repository not found", http.StatusNotFound)
return nil, false
}
return &repo, true
} }
func (h *WebhookHandler) canManage(repo *models.Repository, callerID int64) bool { func (h *WebhookHandler) canManage(repo *models.Repository, callerID int64) bool {
+6 -12
View File
@@ -22,19 +22,13 @@ type WorkflowHandler struct{ db *xorm.Engine }
func NewWorkflowHandler(db *xorm.Engine) *WorkflowHandler { return &WorkflowHandler{db: db} } func NewWorkflowHandler(db *xorm.Engine) *WorkflowHandler { return &WorkflowHandler{db: db} }
func (h *WorkflowHandler) resolveRepo(w http.ResponseWriter, r *http.Request) (*models.Repository, *models.User, bool) { func (h *WorkflowHandler) resolveRepo(w http.ResponseWriter, r *http.Request) (*models.Repository, *models.User, bool) {
ownerName := chi.URLParam(r, "owner") repo, ok := resolveRepo(h.db, w, r)
repoName := chi.URLParam(r, "repo") if !ok {
return nil, nil, false
}
var owner models.User var owner models.User
if found, _ := h.db.Where("username = ?", ownerName).Get(&owner); !found { h.db.ID(repo.OwnerID).Get(&owner)
jsonError(w, "repository not found", http.StatusNotFound) return repo, &owner, true
return nil, nil, false
}
var repo models.Repository
if found, _ := h.db.Where("owner_id = ? AND name = ?", owner.ID, repoName).Get(&repo); !found {
jsonError(w, "repository not found", http.StatusNotFound)
return nil, nil, false
}
return &repo, &owner, true
} }
func (h *WorkflowHandler) canManage(repo *models.Repository, callerID int64) bool { func (h *WorkflowHandler) canManage(repo *models.Repository, callerID int64) bool {
+74 -7
View File
@@ -14,18 +14,27 @@ import (
"github.com/gorilla/sessions" "github.com/gorilla/sessions"
"xorm.io/xorm" "xorm.io/xorm"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/forgeo/forgebucket/internal/api/handlers" "github.com/forgeo/forgebucket/internal/api/handlers"
"github.com/forgeo/forgebucket/internal/api/middleware" "github.com/forgeo/forgebucket/internal/api/middleware"
"github.com/forgeo/forgebucket/internal/config" "github.com/forgeo/forgebucket/internal/config"
"github.com/forgeo/forgebucket/internal/domain/sbom"
"github.com/forgeo/forgebucket/internal/domain/oci"
"github.com/forgeo/forgebucket/internal/domain/scanning"
"github.com/forgeo/forgebucket/internal/domain/signing"
"github.com/forgeo/forgebucket/internal/domain/vulnscan"
"github.com/forgeo/forgebucket/internal/events" "github.com/forgeo/forgebucket/internal/events"
"github.com/forgeo/forgebucket/internal/observability"
) )
func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus events.EventBus, artifactRoot string, staticFiles fs.FS) http.Handler { func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus events.EventBus, artifactRoot string, staticFiles fs.FS, keys signing.KeyStore, sbomGen *sbom.Generator, ociRegistry *oci.Registry, scanner *scanning.Scanner, vulnScanner *vulnscan.Scanner) http.Handler {
r := chi.NewRouter() r := chi.NewRouter()
r.Use(chimiddleware.Logger) r.Use(chimiddleware.Logger)
r.Use(chimiddleware.RealIP) r.Use(chimiddleware.RealIP)
r.Use(chimiddleware.Recoverer) r.Use(chimiddleware.Recoverer)
r.Use(observability.Middleware())
r.Use(cors.Handler(cors.Options{ r.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"http://localhost:5173", cfg.InstanceURL}, AllowedOrigins: []string{"http://localhost:5173", cfg.InstanceURL},
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}, AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
@@ -56,12 +65,23 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus even
exploreH := handlers.NewExploreHandler(engine) exploreH := handlers.NewExploreHandler(engine)
dashH := handlers.NewDashboardHandler(engine) dashH := handlers.NewDashboardHandler(engine)
auditH := handlers.NewAuditHandler(engine) auditH := handlers.NewAuditHandler(engine)
artifactH := handlers.NewArtifactHandler(engine, artifactRoot) healthH := handlers.NewHealthHandler(engine, bus)
repoHealthH := handlers.NewRepoHealthHandler(engine)
artifactH := handlers.NewArtifactHandler(engine, artifactRoot, &keys)
runnerH := handlers.NewRunnerHandler(engine) runnerH := handlers.NewRunnerHandler(engine)
gitopsH := handlers.NewGitOpsHandler(engine, bus)
fedH := handlers.NewFederationHandler(engine, cfg)
envH := handlers.NewEnvironmentHandler(engine, bus) envH := handlers.NewEnvironmentHandler(engine, bus)
timelineH := handlers.NewTimelineHandler(engine, cfg.RepoRoot) timelineH := handlers.NewTimelineHandler(engine, cfg.RepoRoot)
workspaceH := handlers.NewWorkspaceHandler(engine, cfg) workspaceH := handlers.NewWorkspaceHandler(engine, cfg)
secretH := handlers.NewSecretHandler(engine, cfg.SessionSecret) secretH := handlers.NewSecretHandler(engine, cfg.SessionSecret)
sbomH := handlers.NewSBOMHandler(engine, sbomGen)
ociH := handlers.NewOCIRegistryHandler(engine, ociRegistry)
scanH := handlers.NewScanningHandler(engine, scanner)
vulnH := handlers.NewVulnScanHandler(engine, vulnScanner)
archiveH := handlers.NewArchiveHandler(engine)
instanceH := handlers.NewInstanceHandler(cfg)
insightsH := handlers.NewInsightsHandler(engine)
// ── Git smart-HTTP transport ─────────────────────────────────────────────── // ── Git smart-HTTP transport ───────────────────────────────────────────────
// Regex constraint ensures only *.git paths match, so asset/SPA URLs // Regex constraint ensures only *.git paths match, so asset/SPA URLs
@@ -73,16 +93,16 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus even
r.Post("/git-receive-pack", gitH.ServeGit) r.Post("/git-receive-pack", gitH.ServeGit)
}) })
// ── Ops endpoints (root-level, no auth, standard paths for k8s/Prometheus) ──
r.Get("/health", healthH.Health)
r.Get("/metrics", promhttp.Handler().ServeHTTP)
r.Route("/api/v1", func(r chi.Router) { r.Route("/api/v1", func(r chi.Router) {
// ── Public ──────────────────────────────────────────────────────────── // ── Public ────────────────────────────────────────────────────────────
r.Get("/explore/repos", exploreH.Repos) r.Get("/explore/repos", exploreH.Repos)
r.Get("/explore/users", exploreH.Users) r.Get("/explore/users", exploreH.Users)
r.Get("/instance", instanceH.Get)
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"ok"}`))
})
// Generates a CSRF token + cookie. SPA calls this once on load. // Generates a CSRF token + cookie. SPA calls this once on load.
r.Get("/csrf", func(w http.ResponseWriter, r *http.Request) { r.Get("/csrf", func(w http.ResponseWriter, r *http.Request) {
@@ -108,6 +128,8 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus even
r.Get("/me", userH.Me) r.Get("/me", userH.Me)
r.Get("/dashboard", dashH.Get) r.Get("/dashboard", dashH.Get)
r.Get("/audit", auditH.List) r.Get("/audit", auditH.List)
r.Get("/secrets/leaks", scanH.ListAllSecrets)
r.Get("/vulnerabilities", vulnH.ListAll)
r.Get("/pipelines/runs", pipeH.ListRecentRuns) r.Get("/pipelines/runs", pipeH.ListRecentRuns)
// Workspace routes // Workspace routes
@@ -159,6 +181,10 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus even
r.With(csrf).Put("/blob", repoH.UpdateBlob) r.With(csrf).Put("/blob", repoH.UpdateBlob)
r.Get("/commits", repoH.Commits) r.Get("/commits", repoH.Commits)
r.Get("/branches", repoH.Branches) r.Get("/branches", repoH.Branches)
r.Get("/archive", archiveH.Download)
r.Get("/insights", insightsH.Get)
r.Get("/files", repoH.SearchFiles)
r.With(csrf).Post("/upload", repoH.UploadFiles)
r.Get("/diff", repoH.Diff) r.Get("/diff", repoH.Diff)
r.Route("/pulls", func(r chi.Router) { r.Route("/pulls", func(r chi.Router) {
r.Get("/", prH.List) r.Get("/", prH.List)
@@ -188,9 +214,13 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus even
}) })
r.Get("/artifacts", artifactH.List) r.Get("/artifacts", artifactH.List)
r.With(csrf).Post("/artifacts", artifactH.Upload) r.With(csrf).Post("/artifacts", artifactH.Upload)
r.Get("/sbom", sbomH.GetForRun)
r.Get("/sbom/document", sbomH.GetDocumentForRun)
}) })
}) })
r.Get("/artifacts/{artifactID}/download", artifactH.Download) r.Get("/artifacts/{artifactID}/download", artifactH.Download)
r.Get("/artifacts/{artifactID}/signature", artifactH.GetSignature)
r.Get("/artifacts/{artifactID}/verify", artifactH.VerifySignature)
r.Route("/members", func(r chi.Router) { r.Route("/members", func(r chi.Router) {
r.Get("/", memberH.List) r.Get("/", memberH.List)
r.With(csrf).Post("/", memberH.Add) r.With(csrf).Post("/", memberH.Add)
@@ -237,8 +267,17 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus even
r.Get("/secrets", secretH.ListRepoSecrets) r.Get("/secrets", secretH.ListRepoSecrets)
r.With(csrf).Post("/secrets", secretH.UpsertRepoSecret) r.With(csrf).Post("/secrets", secretH.UpsertRepoSecret)
r.With(csrf).Delete("/secrets/{name}", secretH.DeleteRepoSecret) r.With(csrf).Delete("/secrets/{name}", secretH.DeleteRepoSecret)
r.Get("/secrets/leaks", scanH.ListSecrets)
r.With(csrf).Post("/secrets/leaks/{leakID}/dismiss", scanH.DismissSecrets)
r.Get("/vulnerabilities", vulnH.List)
r.With(csrf).Post("/vulnerabilities/scan", vulnH.Scan)
r.With(csrf).Post("/vulnerabilities/{findingID}/dismiss", vulnH.Dismiss)
r.Get("/lfs-settings", lfsH.Get) r.Get("/lfs-settings", lfsH.Get)
r.With(csrf).Put("/lfs-settings", lfsH.Update) r.With(csrf).Put("/lfs-settings", lfsH.Update)
r.Get("/health", repoHealthH.Get)
r.Get("/sbom", sbomH.GetLatest)
r.Get("/sbom/document", sbomH.GetLatestDocument)
r.With(csrf).Post("/sbom/generate", sbomH.Generate)
r.Route("/environments", func(r chi.Router) { r.Route("/environments", func(r chi.Router) {
r.Get("/", envH.ListEnvironments) r.Get("/", envH.ListEnvironments)
r.With(csrf).Post("/", envH.CreateEnvironment) r.With(csrf).Post("/", envH.CreateEnvironment)
@@ -254,6 +293,15 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus even
r.Get("/secrets", secretH.ListEnvSecrets) r.Get("/secrets", secretH.ListEnvSecrets)
r.With(csrf).Post("/secrets", secretH.UpsertEnvSecret) r.With(csrf).Post("/secrets", secretH.UpsertEnvSecret)
r.With(csrf).Delete("/secrets/{name}", secretH.DeleteEnvSecret) r.With(csrf).Delete("/secrets/{name}", secretH.DeleteEnvSecret)
r.Route("/gitops", func(r chi.Router) {
r.Get("/", gitopsH.GetConfig)
r.With(csrf).Put("/", gitopsH.UpsertConfig)
r.With(csrf).Delete("/", gitopsH.DeleteConfig)
r.With(csrf).Post("/sync", gitopsH.TriggerSync)
r.Get("/drift", gitopsH.GetDriftStatus)
r.Get("/drift/history", gitopsH.ListDriftEvents)
r.With(csrf).Post("/drift/{driftID}/acknowledge", gitopsH.AcknowledgeDrift)
})
}) })
}) })
}) })
@@ -263,6 +311,25 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus even
r.With(auth.Optional).Get("/ws", wsH.Hub) r.With(auth.Optional).Get("/ws", wsH.Hub)
// ── OCI Registry (Distribution Spec v1.1) ─────────────────────────────────
r.HandleFunc("/v2", ociH.ServeOCI)
r.HandleFunc("/v2/*", ociH.ServeOCI)
// ── ForgeFed Repository Actors (cross-instance PRs) ───────────────────────
// These must sit outside the auth-protected group since remote instances
// deliver activities without session cookies.
r.Get("/repos/{owner}/{repo}/actor", fedH.RepoActor)
r.Post("/repos/{owner}/{repo}/inbox", fedH.RepoInbox)
// ── ActivityPub / federation (root-level, no auth) ────────────────────────
// Must be registered before the /* catch-all so they are not proxied to Vite.
r.Get("/.well-known/webfinger", fedH.WebFinger)
r.Get("/users/{username}", fedH.Actor)
r.Post("/users/{username}/inbox", fedH.Inbox)
r.Get("/users/{username}/outbox", fedH.OutboxGet)
r.Get("/users/{username}/followers", fedH.Followers)
r.Get("/users/{username}/following", fedH.Following)
// In debug mode proxy non-API routes to the Vite dev server so :8080 works too. // In debug mode proxy non-API routes to the Vite dev server so :8080 works too.
// In production the built React app is embedded and served from staticFiles. // In production the built React app is embedded and served from staticFiles.
if cfg.Debug { if cfg.Debug {
+36
View File
@@ -30,10 +30,25 @@ type Config struct {
// Event bus // Event bus
NATSUrl string NATSUrl string
// GitOps
GitOpsReconcileInterval int // seconds between periodic drift checks; 0 disables
// Federation // Federation
InstanceURL string InstanceURL string
InstanceName string InstanceName string
// Artifact signing (Phase 4)
// PEM-encoded ECDSA P-256 private key. If empty an ephemeral key is generated.
ArtifactSigningKey string
// OCI Registry
OCIRoot string
// SSH server
SSHHost string // env: SSH_HOST, empty = auto-detect from request/instance URL
SSHPort string // env: SSH_PORT, default "2222"
SSHHostKeyPath string // env: SSH_HOST_KEY_PATH, empty = generate ephemeral
// Dev // Dev
Debug bool Debug bool
} }
@@ -47,6 +62,7 @@ func Load() (*Config, error) {
Debug: getEnvBool("DEBUG", false), Debug: getEnvBool("DEBUG", false),
NATSUrl: getEnv("NATS_URL", ""), NATSUrl: getEnv("NATS_URL", ""),
GitOpsReconcileInterval: getEnvInt("GITOPS_RECONCILE_INTERVAL", 300),
InstanceURL: getEnv("INSTANCE_URL", ""), InstanceURL: getEnv("INSTANCE_URL", ""),
InstanceName: getEnv("INSTANCE_NAME", "ForgeBucket"), InstanceName: getEnv("INSTANCE_NAME", "ForgeBucket"),
} }
@@ -57,6 +73,14 @@ func Load() (*Config, error) {
cfg.SessionSecret = requireEnv("SESSION_SECRET", &missing) cfg.SessionSecret = requireEnv("SESSION_SECRET", &missing)
cfg.CSRFSecret = requireEnv("CSRF_SECRET", &missing) cfg.CSRFSecret = requireEnv("CSRF_SECRET", &missing)
cfg.SSHHost = os.Getenv("SSH_HOST")
cfg.SSHPort = getEnv("SSH_PORT", "2222")
cfg.SSHHostKeyPath = os.Getenv("SSH_HOST_KEY_PATH")
// Optional signing key
cfg.ArtifactSigningKey = os.Getenv("ARTIFACT_SIGNING_KEY")
cfg.OCIRoot = getEnv("OCI_ROOT", filepath.Join(filepath.Dir(cfg.RepoRoot), "oci"))
// Optional OIDC // Optional OIDC
cfg.OIDCIssuer = os.Getenv("OIDC_ISSUER") cfg.OIDCIssuer = os.Getenv("OIDC_ISSUER")
cfg.OIDCClientID = os.Getenv("OIDC_CLIENT_ID") cfg.OIDCClientID = os.Getenv("OIDC_CLIENT_ID")
@@ -91,6 +115,18 @@ func getEnv(key, fallback string) string {
return fallback return fallback
} }
func getEnvInt(key string, fallback int) int {
v := os.Getenv(key)
if v == "" {
return fallback
}
n, err := strconv.Atoi(v)
if err != nil {
return fallback
}
return n
}
func getEnvBool(key string, fallback bool) bool { func getEnvBool(key string, fallback bool) bool {
v := os.Getenv(key) v := os.Getenv(key)
if v == "" { if v == "" {
+12 -3
View File
@@ -9,6 +9,7 @@ import (
"xorm.io/xorm" "xorm.io/xorm"
"github.com/forgeo/forgebucket/internal/domain/sbom"
"github.com/forgeo/forgebucket/internal/events" "github.com/forgeo/forgebucket/internal/events"
"github.com/forgeo/forgebucket/internal/models" "github.com/forgeo/forgebucket/internal/models"
) )
@@ -19,10 +20,11 @@ import (
type Orchestrator struct { type Orchestrator struct {
db *xorm.Engine db *xorm.Engine
bus events.EventBus bus events.EventBus
sbomGen *sbom.Generator
} }
func NewOrchestrator(db *xorm.Engine, bus events.EventBus) *Orchestrator { func NewOrchestrator(db *xorm.Engine, bus events.EventBus, sbomGen *sbom.Generator) *Orchestrator {
return &Orchestrator{db: db, bus: bus} return &Orchestrator{db: db, bus: bus, sbomGen: sbomGen}
} }
// Start subscribes to relevant NATS subjects and blocks until ctx is cancelled. // Start subscribes to relevant NATS subjects and blocks until ctx is cancelled.
@@ -142,7 +144,11 @@ func (o *Orchestrator) createRun(repo models.Repository, evt events.PushEvent, w
// Create job + step records for every job in the workflow. // Create job + step records for every job in the workflow.
for jobName, wfJob := range wf.Jobs { for jobName, wfJob := range wf.Jobs {
needsJSON, _ := json.Marshal([]string(wfJob.Needs)) needs := []string(wfJob.Needs)
if needs == nil {
needs = []string{}
}
needsJSON, _ := json.Marshal(needs)
job := &models.PipelineJob{ job := &models.PipelineJob{
RunID: run.ID, RunID: run.ID,
Name: jobName, Name: jobName,
@@ -231,6 +237,9 @@ func (o *Orchestrator) advanceDAG(runID, jobID int64, result string) {
run.FinishedAt = &now run.FinishedAt = &now
o.db.ID(run.ID).Cols("status", "finished_at").Update(&run) //nolint:errcheck o.db.ID(run.ID).Cols("status", "finished_at").Update(&run) //nolint:errcheck
o.bus.Publish(events.SubjectPipelineCompleted, events.PipelineEvent{RunID: run.ID, RepoID: run.RepoID, Status: "succeeded", At: now}) //nolint:errcheck o.bus.Publish(events.SubjectPipelineCompleted, events.PipelineEvent{RunID: run.ID, RepoID: run.RepoID, Status: "succeeded", At: now}) //nolint:errcheck
if o.sbomGen != nil {
go o.sbomGen.GenerateOnDemand(run.RepoID, run.ID, run.TriggerSHA)
}
return return
} }
+84
View File
@@ -0,0 +1,84 @@
package federation
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"fmt"
"strings"
"xorm.io/xorm"
"github.com/forgeo/forgebucket/internal/models"
)
// APID returns the canonical ActivityPub actor ID for a local username.
func APID(instanceURL, username string) string {
return strings.TrimRight(instanceURL, "/") + "/users/" + username
}
// GetOrCreate fetches the FederationActor for a user, creating it with a fresh
// RSA-2048 key pair if none exists. Actor URLs are derived from instanceURL.
func GetOrCreate(db *xorm.Engine, userID int64, username, instanceURL string) (*models.FederationActor, error) {
var actor models.FederationActor
if found, _ := db.Where("user_id = ?", userID).Get(&actor); found {
return &actor, nil
}
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, fmt.Errorf("generate rsa key: %w", err)
}
privPEM := pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(priv),
})
pubDER, err := x509.MarshalPKIXPublicKey(&priv.PublicKey)
if err != nil {
return nil, fmt.Errorf("marshal public key: %w", err)
}
pubPEM := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER})
base := APID(instanceURL, username)
actor = models.FederationActor{
UserID: userID,
APID: base,
InboxURL: base + "/inbox",
OutboxURL: base + "/outbox",
PublicKey: string(pubPEM),
PrivateKey: string(privPEM),
}
if _, err := db.Insert(&actor); err != nil {
// Race condition: another goroutine may have just created it.
if found, _ := db.Where("user_id = ?", userID).Get(&actor); found {
return &actor, nil
}
return nil, fmt.Errorf("insert actor: %w", err)
}
return &actor, nil
}
// ActorJSON builds the JSON-LD actor document returned by GET /users/{username}.
func ActorJSON(actor *models.FederationActor, username, displayName string) map[string]any {
return map[string]any{
"@context": []any{
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
},
"id": actor.APID,
"type": "Person",
"preferredUsername": username,
"name": displayName,
"inbox": actor.InboxURL,
"outbox": actor.OutboxURL,
"followers": actor.APID + "/followers",
"following": actor.APID + "/following",
"publicKey": map[string]any{
"id": actor.APID + "#main-key",
"owner": actor.APID,
"publicKeyPem": actor.PublicKey,
},
}
}
+224
View File
@@ -0,0 +1,224 @@
package federation
import (
"encoding/json"
"fmt"
"log"
"strings"
"time"
"xorm.io/xorm"
"github.com/forgeo/forgebucket/internal/models"
)
// RepoAPID returns the ActivityPub actor ID for a repository.
// Format: {instanceURL}/repos/{owner}/{name}
func RepoAPID(instanceURL, owner, name string) string {
return strings.TrimRight(instanceURL, "/") + "/repos/" + owner + "/" + name
}
// RepoActorJSON builds the JSON-LD actor document for a ForgeFed Repository actor.
func RepoActorJSON(owner, name, description, instanceURL string) map[string]any {
apid := RepoAPID(instanceURL, owner, name)
return map[string]any{
"@context": []any{
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
map[string]string{
"Repository": "https://www.w3.org/ns/activitystreams#Repository",
},
},
"id": apid,
"type": "Repository",
"preferredUsername": name,
"name": owner + "/" + name,
"summary": description,
"inbox": apid + "/inbox",
"outbox": apid + "/outbox",
"followers": apid + "/followers",
"following": apid + "/following",
}
}
// HandleCreatePullRequest processes an incoming Create activity whose object
// is a PullRequest (per the ForgeFed vocabulary). It creates a local PR record
// in the target repository for the cross-instance proposal.
func HandleCreatePullRequest(db *xorm.Engine, body []byte, instanceURL string) error {
var activity struct {
Actor string `json:"actor"`
Object struct {
Type string `json:"type"`
Summary string `json:"summary"`
Content string `json:"content"`
Source *struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"source"`
Target *struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"target"`
} `json:"object"`
}
if err := json.Unmarshal(body, &activity); err != nil {
return fmt.Errorf("parse activity: %w", err)
}
if activity.Object.Type != "PullRequest" {
return nil
}
// Extract target repository info from the object's target.
targetID := activity.Object.Target.ID
targetParts := strings.Split(strings.TrimRight(targetID, "/"), "/")
if len(targetParts) < 2 {
return fmt.Errorf("cannot parse target repo APID: %s", targetID)
}
// Last two segments should be owner/repo-name.
repoOwner := targetParts[len(targetParts)-2]
repoName := targetParts[len(targetParts)-1]
// Resolve the target repository.
var repo models.Repository
found, err := db.Where("name = ?", repoName).
Join("INNER", "user", "repository.owner_id = user.id AND user.username = ?", repoOwner).
Get(&repo)
if err != nil {
return fmt.Errorf("database error: %w", err)
}
if !found {
return fmt.Errorf("target repo %s/%s not found on this instance", repoOwner, repoName)
}
// Resolve or create a FederationActor for the repo owner (needed for key ops).
var ownerUser models.User
if found, _ := db.Where("username = ?", repoOwner).Get(&ownerUser); !found {
return fmt.Errorf("owner user %s not found", repoOwner)
}
localActor, err := GetOrCreate(db, ownerUser.ID, repoOwner, instanceURL)
if err != nil {
return fmt.Errorf("get actor: %w", err)
}
// Determine the PR title and body.
title := activity.Object.Summary
if title == "" {
title = fmt.Sprintf("Cross-instance PR from %s", activity.Actor)
}
bodyContent := activity.Object.Content
if bodyContent == "" {
bodyContent = fmt.Sprintf("Pull request proposed via ActivityPub from %s", activity.Actor)
}
// Create the PR. For cross-instance PRs, authorID is set to the target
// repo owner (we can't create a user for the remote actor automatically).
// The RemoteSource field records the source repository APID.
pr := &models.PullRequest{
RepoID: repo.ID,
AuthorID: ownerUser.ID,
Title: title,
Body: bodyContent,
SourceBranch: "refs/for/main",
TargetBranch: "main",
Status: models.PRStatusOpen,
RemoteSource: activity.Actor,
}
// Try to extract source branch from the source repo.
if activity.Object.Source != nil {
sourceID := activity.Object.Source.ID
if sourceID != "" {
pr.RemoteSource = sourceID
}
if activity.Object.Source.Name != "" {
pr.SourceBranch = activity.Object.Source.Name
}
}
if _, err := db.Insert(pr); err != nil {
return fmt.Errorf("insert PR: %w", err)
}
// Persist the outbound Accept for the PR activity so the remote knows
// we received it (we auto-accept all incoming PRs).
accept := map[string]any{
"@context": "https://www.w3.org/ns/activitystreams",
"id": localActor.APID + "/activities/accept-pr-" + fmt.Sprint(time.Now().UnixNano()),
"type": "Accept",
"actor": localActor.APID,
}
acceptJSON, _ := json.Marshal(accept)
db.Insert(&models.FederationActivity{ //nolint:errcheck
ActorAPID: localActor.APID,
Type: "Accept",
ObjectJSON: string(acceptJSON),
Direction: "outbound",
RemoteActor: activity.Actor,
Published: time.Now().UTC(),
})
log.Printf("forgefed: created PR %d from cross-instance actor %s", pr.ID, activity.Actor)
return nil
}
// SendCreatePullRequest delivers a Create(PullRequest) activity to a remote
// instance's inbox. The remote inbox URL is derived from the forked-from repo's
// APID by appending /inbox.
func SendCreatePullRequest(db *xorm.Engine, localActor *models.FederationActor, pr *models.PullRequest, remoteAPID, instanceURL string) error {
// Build the Create(PullRequest) activity.
activity := map[string]any{
"@context": "https://www.w3.org/ns/activitystreams",
"id": localActor.APID + "/activities/create-pr-" + fmt.Sprint(time.Now().UnixNano()),
"type": "Create",
"actor": localActor.APID,
"object": map[string]any{
"type": "PullRequest",
"id": localActor.APID + "/pull-requests/" + fmt.Sprint(pr.ID),
"summary": pr.Title,
"content": pr.Body,
"source": map[string]any{
"type": "Repository",
"id": localActor.APID,
},
"target": map[string]any{
"type": "Repository",
"id": remoteAPID,
},
},
"to": []string{remoteAPID + "/inbox", "https://www.w3.org/ns/activitystreams#Public"},
}
remoteInbox := strings.TrimSuffix(remoteAPID, "/") + "/inbox"
if err := DeliverActivity(localActor, activity, remoteInbox); err != nil {
return fmt.Errorf("deliver PR to %s: %w", remoteInbox, err)
}
actJSON, _ := json.Marshal(activity)
db.Insert(&models.FederationActivity{ //nolint:errcheck
ActorAPID: localActor.APID,
Type: "Create",
ObjectJSON: string(actJSON),
Direction: "outbound",
RemoteActor: remoteAPID,
Published: time.Now().UTC(),
})
log.Printf("forgefed: sent Create(PullRequest) for PR %d to %s", pr.ID, remoteInbox)
return nil
}
// IsCreatePullRequest checks whether the given body is a Create(PullRequest) activity.
func IsCreatePullRequest(body []byte) bool {
var check struct {
Type string `json:"type"`
Object struct {
Type string `json:"type"`
} `json:"object"`
}
if err := json.Unmarshal(body, &check); err != nil {
return false
}
return check.Type == "Create" && check.Object.Type == "PullRequest"
}
+100
View File
@@ -0,0 +1,100 @@
package federation
import (
"testing"
)
func TestRepoAPID(t *testing.T) {
apid := RepoAPID("https://example.com", "alice", "myrepo")
expected := "https://example.com/repos/alice/myrepo"
if apid != expected {
t.Errorf("got %q, want %q", apid, expected)
}
}
func TestRepoAPID_TrailingSlash(t *testing.T) {
apid := RepoAPID("https://example.com/", "bob", "app")
expected := "https://example.com/repos/bob/app"
if apid != expected {
t.Errorf("got %q, want %q", apid, expected)
}
}
func TestRepoActorJSON(t *testing.T) {
doc := RepoActorJSON("alice", "myrepo", "A cool repo", "https://example.com")
if doc["type"] != "Repository" {
t.Errorf("type = %v, want Repository", doc["type"])
}
if doc["preferredUsername"] != "myrepo" {
t.Errorf("preferredUsername = %v", doc["preferredUsername"])
}
if doc["name"] != "alice/myrepo" {
t.Errorf("name = %v", doc["name"])
}
if doc["summary"] != "A cool repo" {
t.Errorf("summary = %v", doc["summary"])
}
inbox, ok := doc["inbox"].(string)
if !ok || inbox != "https://example.com/repos/alice/myrepo/inbox" {
t.Errorf("inbox = %v", inbox)
}
outbox, ok := doc["outbox"].(string)
if !ok || outbox != "https://example.com/repos/alice/myrepo/outbox" {
t.Errorf("outbox = %v", outbox)
}
}
func TestIsCreatePullRequest(t *testing.T) {
tests := []struct {
name string
body []byte
want bool
}{
{
name: "valid Create(PullRequest)",
body: []byte(`{"type":"Create","object":{"type":"PullRequest","summary":"fix bug"}}`),
want: true,
},
{
name: "Create with non-PR object",
body: []byte(`{"type":"Create","object":{"type":"Note"}}`),
want: false,
},
{
name: "Follow activity",
body: []byte(`{"type":"Follow","object":"https://example.com/users/alice"}`),
want: false,
},
{
name: "invalid JSON",
body: []byte(`not json`),
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IsCreatePullRequest(tt.body); got != tt.want {
t.Errorf("IsCreatePullRequest() = %v, want %v", got, tt.want)
}
})
}
}
func TestExtractInstanceURL(t *testing.T) {
tests := []struct {
apid string
want string
}{
{"https://example.com/users/alice", "https://example.com"},
{"http://localhost:8080/users/bob", "http://localhost:8080"},
{"https://forge.example.org/users/charlie", "https://forge.example.org"},
}
for _, tt := range tests {
t.Run(tt.apid, func(t *testing.T) {
if got := extractInstanceURL(tt.apid); got != tt.want {
t.Errorf("extractInstanceURL() = %q, want %q", got, tt.want)
}
})
}
}
+134
View File
@@ -0,0 +1,134 @@
package federation
import (
"encoding/json"
"fmt"
"log"
"strings"
"time"
"xorm.io/xorm"
"github.com/forgeo/forgebucket/internal/models"
)
// Receive persists an inbound activity and dispatches it by type.
// The caller is responsible for verifying the HTTP signature before calling this.
func Receive(db *xorm.Engine, localActor *models.FederationActor, body []byte) error {
var activity map[string]any
if err := json.Unmarshal(body, &activity); err != nil {
return fmt.Errorf("parse activity: %w", err)
}
actType, _ := activity["type"].(string)
actorAPID, _ := activity["actor"].(string)
entry := &models.FederationActivity{
ActorAPID: localActor.APID,
Type: actType,
ObjectJSON: string(body),
Direction: "inbound",
RemoteActor: actorAPID,
Published: time.Now().UTC(),
}
db.Insert(entry) //nolint:errcheck
switch actType {
case "Follow":
return handleFollow(db, localActor, activity, actorAPID)
case "Accept":
handleAccept(db, localActor, activity)
case "Undo":
handleUndo(db, localActor, activity)
case "Create":
if IsCreatePullRequest(body) {
// Derive instanceURL from the local actor's APID.
instanceURL := extractInstanceURL(localActor.APID)
if err := HandleCreatePullRequest(db, body, instanceURL); err != nil {
log.Printf("federation: handle Create(PullRequest): %v", err)
}
} else {
log.Printf("federation: received Create activity from %s (non-PR, skipped)", actorAPID)
}
default:
log.Printf("federation: received unhandled activity type %q from %s", actType, actorAPID)
}
return nil
}
// handleFollow auto-accepts all incoming Follow activities and sends an Accept
// back to the sender's inbox.
func handleFollow(db *xorm.Engine, localActor *models.FederationActor, follow map[string]any, followerAPID string) error {
if followerAPID == "" {
return fmt.Errorf("Follow activity missing actor field")
}
// Fetch the follower's remote actor to get their inbox URL.
remote, err := FetchActor(db, followerAPID)
if err != nil {
return fmt.Errorf("fetch follower actor: %w", err)
}
if remote.InboxURL == "" {
return fmt.Errorf("follower has no inbox URL")
}
// Build Accept activity.
accept := map[string]any{
"@context": "https://www.w3.org/ns/activitystreams",
"id": localActor.APID + "/activities/accept-" + fmt.Sprint(time.Now().UnixNano()),
"type": "Accept",
"actor": localActor.APID,
"object": follow,
}
// Deliver asynchronously so inbox handler returns quickly.
go func() {
if err := DeliverActivity(localActor, accept, remote.InboxURL); err != nil {
log.Printf("federation: deliver Accept to %s: %v", remote.InboxURL, err)
return
}
// Store the outbound Accept.
db.Insert(&models.FederationActivity{ //nolint:errcheck
ActorAPID: localActor.APID,
Type: "Accept",
ObjectJSON: mustJSON(accept),
Direction: "outbound",
RemoteActor: followerAPID,
Published: time.Now().UTC(),
})
log.Printf("federation: accepted Follow from %s", followerAPID)
}()
return nil
}
func handleAccept(db *xorm.Engine, localActor *models.FederationActor, activity map[string]any) {
// A remote actor accepted our Follow. Nothing to store beyond the inbox entry.
log.Printf("federation: received Accept for actor %s", localActor.APID)
}
func handleUndo(db *xorm.Engine, localActor *models.FederationActor, activity map[string]any) {
// Common case: undo a Follow (unfollow).
obj, _ := activity["object"].(map[string]any)
if obj == nil {
return
}
if t, _ := obj["type"].(string); t == "Follow" {
log.Printf("federation: received Undo(Follow) for actor %s", localActor.APID)
}
}
func mustJSON(v any) string {
b, _ := json.Marshal(v)
return string(b)
}
func extractInstanceURL(apid string) string {
// apid is like "https://example.com/users/alice"
// Return "https://example.com"
parts := strings.SplitN(apid, "/", 4)
if len(parts) >= 3 {
return parts[0] + "//" + parts[2]
}
return apid
}
+84
View File
@@ -0,0 +1,84 @@
package federation
import (
"xorm.io/xorm"
"github.com/forgeo/forgebucket/internal/models"
)
const activitiesPerPage = 20
// Collection builds an ActivityStreams OrderedCollection (or page) for an actor's outbox.
// page=0 returns the collection summary; page≥1 returns a paginated OrderedCollectionPage.
func Collection(db *xorm.Engine, actorAPID string, outboxURL string, page int) map[string]any {
total, _ := db.Where("actor_ap_id = ? AND direction = 'outbound'", actorAPID).
Count(&models.FederationActivity{})
if page == 0 {
return map[string]any{
"@context": "https://www.w3.org/ns/activitystreams",
"id": outboxURL,
"type": "OrderedCollection",
"totalItems": total,
"first": outboxURL + "?page=1",
}
}
offset := (page - 1) * activitiesPerPage
var activities []models.FederationActivity
db.Where("actor_ap_id = ? AND direction = 'outbound'", actorAPID).
Desc("published").
Limit(activitiesPerPage, offset).
Find(&activities)
items := make([]any, 0, len(activities))
for _, a := range activities {
items = append(items, rawJSON(a.ObjectJSON))
}
coll := map[string]any{
"@context": "https://www.w3.org/ns/activitystreams",
"id": outboxURL + "?page=" + itoa(page),
"type": "OrderedCollectionPage",
"partOf": outboxURL,
"orderedItems": items,
}
if int64(offset+activitiesPerPage) < total {
coll["next"] = outboxURL + "?page=" + itoa(page+1)
}
return coll
}
// StubCollection returns a minimal OrderedCollection with zero items.
// Used for followers/following until the social graph is implemented.
func StubCollection(collectionURL string) map[string]any {
return map[string]any{
"@context": "https://www.w3.org/ns/activitystreams",
"id": collectionURL,
"type": "OrderedCollection",
"totalItems": 0,
"orderedItems": []any{},
}
}
func itoa(n int) string {
if n == 0 {
return "0"
}
result := ""
for n > 0 {
result = string(rune('0'+n%10)) + result
n /= 10
}
return result
}
// rawJSON wraps a JSON string so it marshals as-is (not double-encoded).
type rawJSON string
func (r rawJSON) MarshalJSON() ([]byte, error) {
if r == "" {
return []byte("null"), nil
}
return []byte(r), nil
}
+121
View File
@@ -0,0 +1,121 @@
package federation
import (
"encoding/json"
"fmt"
"net/http"
"time"
"xorm.io/xorm"
"github.com/forgeo/forgebucket/internal/models"
)
const activityJSONType = "application/activity+json"
// FetchActor retrieves a remote actor document by APID. If it is already cached
// in the remote_actor table it is returned immediately; otherwise it is fetched
// over HTTP and persisted before returning.
func FetchActor(db *xorm.Engine, apid string) (*models.RemoteActor, error) {
var cached models.RemoteActor
if found, _ := db.Where("ap_id = ?", apid).Get(&cached); found {
return &cached, nil
}
client := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequest("GET", apid, nil)
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
req.Header.Set("Accept", activityJSONType+", application/ld+json")
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("fetch actor %s: %w", apid, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("fetch actor %s: HTTP %d", apid, resp.StatusCode)
}
var doc map[string]any
if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil {
return nil, fmt.Errorf("decode actor: %w", err)
}
inbox, _ := doc["inbox"].(string)
pubKey := extractPublicKeyPEM(doc)
actor := &models.RemoteActor{
APID: apid,
InboxURL: inbox,
PublicKey: pubKey,
FetchedAt: time.Now().UTC(),
}
// Upsert: ignore duplicate key errors (concurrent fetch).
db.Insert(actor) //nolint:errcheck
// Reload to get the DB-assigned ID.
db.Where("ap_id = ?", apid).Get(actor) //nolint:errcheck
return actor, nil
}
// DeliverActivity POSTs a signed activity to a remote actor's inbox.
func DeliverActivity(localActor *models.FederationActor, activity map[string]any, recipientInbox string) error {
body, err := json.Marshal(activity)
if err != nil {
return fmt.Errorf("marshal activity: %w", err)
}
req, err := http.NewRequest("POST", recipientInbox, jsonReader(body))
if err != nil {
return fmt.Errorf("build request: %w", err)
}
req.Header.Set("Content-Type", activityJSONType)
req.Header.Set("Accept", activityJSONType)
if err := Sign(req, localActor.APID+"#main-key", localActor.PrivateKey); err != nil {
return fmt.Errorf("sign: %w", err)
}
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("deliver to %s: %w", recipientInbox, err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("deliver to %s: HTTP %d", recipientInbox, resp.StatusCode)
}
return nil
}
func extractPublicKeyPEM(doc map[string]any) string {
pk, ok := doc["publicKey"].(map[string]any)
if !ok {
return ""
}
pem, _ := pk["publicKeyPem"].(string)
return pem
}
// jsonReader wraps a byte slice in a reader that io.ReadCloser can use.
func jsonReader(data []byte) *bytesReader {
return &bytesReader{data: data, pos: 0}
}
type bytesReader struct {
data []byte
pos int
}
func (r *bytesReader) Read(p []byte) (int, error) {
if r.pos >= len(r.data) {
return 0, fmt.Errorf("EOF")
}
n := copy(p, r.data[r.pos:])
r.pos += n
return n, nil
}
+175
View File
@@ -0,0 +1,175 @@
package federation
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"fmt"
"net/http"
"strings"
"time"
"xorm.io/xorm"
)
// Sign adds an HTTP Signature header to req using the RSA private key.
// Implements draft-cavage-http-signatures (the fediverse de-facto standard).
// Signs: (request-target), host, date. If body is set, also signs digest.
func Sign(req *http.Request, keyID, privateKeyPEM string) error {
if req.Header.Get("Date") == "" {
req.Header.Set("Date", time.Now().UTC().Format(http.TimeFormat))
}
if req.Header.Get("Host") == "" {
req.Header.Set("Host", req.URL.Host)
}
method := strings.ToLower(req.Method)
target := req.URL.RequestURI()
signingString := fmt.Sprintf("(request-target): %s %s\nhost: %s\ndate: %s",
method, target,
req.Header.Get("Host"),
req.Header.Get("Date"),
)
headers := "(request-target) host date"
priv, err := parsePrivateKey(privateKeyPEM)
if err != nil {
return fmt.Errorf("parse private key: %w", err)
}
h := sha256.Sum256([]byte(signingString))
sig, err := rsa.SignPKCS1v15(rand.Reader, priv, crypto.SHA256, h[:])
if err != nil {
return fmt.Errorf("sign: %w", err)
}
req.Header.Set("Signature", fmt.Sprintf(
`keyId="%s",algorithm="rsa-sha256",headers="%s",signature="%s"`,
keyID, headers, base64.StdEncoding.EncodeToString(sig),
))
return nil
}
// Verify validates the HTTP Signature header on an incoming request.
// It fetches the sender's public key from their actor document (or the local DB).
func Verify(r *http.Request, db *xorm.Engine, instanceURL string) error {
sigHeader := r.Header.Get("Signature")
if sigHeader == "" {
return fmt.Errorf("missing Signature header")
}
params := parseSignatureHeader(sigHeader)
keyID := params["keyId"]
sigB64 := params["signature"]
headersList := params["headers"]
if keyID == "" || sigB64 == "" {
return fmt.Errorf("malformed Signature header")
}
sig, err := base64.StdEncoding.DecodeString(sigB64)
if err != nil {
return fmt.Errorf("decode signature: %w", err)
}
// Fetch the public key for this keyId.
// keyId is typically "{actorURL}#main-key" — strip the fragment to get the actor APID.
actorAPID := strings.SplitN(keyID, "#", 2)[0]
pubKeyPEM, err := resolvePublicKey(db, actorAPID, instanceURL)
if err != nil {
return fmt.Errorf("resolve public key for %s: %w", actorAPID, err)
}
// Reconstruct the signing string from the request.
signedHeaders := strings.Fields(headersList)
if len(signedHeaders) == 0 {
signedHeaders = []string{"date"}
}
var parts []string
for _, h := range signedHeaders {
switch h {
case "(request-target)":
parts = append(parts, fmt.Sprintf("(request-target): %s %s",
strings.ToLower(r.Method), r.URL.RequestURI()))
default:
parts = append(parts, h+": "+r.Header.Get(http.CanonicalHeaderKey(h)))
}
}
signingString := strings.Join(parts, "\n")
pub, err := parsePublicKey(pubKeyPEM)
if err != nil {
return fmt.Errorf("parse public key: %w", err)
}
h := sha256.Sum256([]byte(signingString))
if err := rsa.VerifyPKCS1v15(pub, crypto.SHA256, h[:], sig); err != nil {
return fmt.Errorf("signature verification failed: %w", err)
}
return nil
}
// ── helpers ──────────────────────────────────────────────────────────────────
func parseSignatureHeader(header string) map[string]string {
params := make(map[string]string)
for _, part := range strings.Split(header, ",") {
part = strings.TrimSpace(part)
idx := strings.Index(part, "=")
if idx < 0 {
continue
}
key := strings.TrimSpace(part[:idx])
val := strings.Trim(strings.TrimSpace(part[idx+1:]), `"`)
params[key] = val
}
return params
}
func parsePrivateKey(pemStr string) (*rsa.PrivateKey, error) {
block, _ := pem.Decode([]byte(pemStr))
if block == nil {
return nil, fmt.Errorf("no PEM block found")
}
return x509.ParsePKCS1PrivateKey(block.Bytes)
}
func parsePublicKey(pemStr string) (*rsa.PublicKey, error) {
block, _ := pem.Decode([]byte(pemStr))
if block == nil {
return nil, fmt.Errorf("no PEM block found")
}
pub, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return nil, err
}
rsaPub, ok := pub.(*rsa.PublicKey)
if !ok {
return nil, fmt.Errorf("not an RSA public key")
}
return rsaPub, nil
}
// resolvePublicKey returns the public key PEM for an actor APID.
// Checks local actors first, then remote cache, then fetches from network.
func resolvePublicKey(db *xorm.Engine, actorAPID, instanceURL string) (string, error) {
// Check if it's a local actor.
var local struct {
PublicKey string `xorm:"public_key"`
}
if found, _ := db.Table("federation_actor").
Where("ap_id = ?", actorAPID).
Cols("public_key").Get(&local); found && local.PublicKey != "" {
return local.PublicKey, nil
}
// Fetch (and cache) from network.
remote, err := FetchActor(db, actorAPID)
if err != nil {
return "", err
}
return remote.PublicKey, nil
}
+332
View File
@@ -3,9 +3,12 @@ package git
import ( import (
"errors" "errors"
"fmt" "fmt"
"io"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"sort"
"strconv"
"strings" "strings"
) )
@@ -250,6 +253,84 @@ func WriteFile(repoPath, branch, filePath, content, authorName, authorEmail, mes
return nil return nil
} }
// FileUpload holds a file path and its content for a batch commit.
type FileUpload struct {
Path string // repo-relative path, e.g. "src/main.go"
Content []byte
}
// WriteManyFiles commits all files in a single commit to branch. Each file path
// must be a clean relative path — no ".." or absolute paths allowed.
func WriteManyFiles(repoPath, branch, message, authorName, authorEmail string, files []FileUpload) error {
if len(files) == 0 {
return errors.New("no files to commit")
}
// Validate all paths before touching the filesystem.
for _, f := range files {
clean := filepath.Clean(filepath.FromSlash(f.Path))
if strings.HasPrefix(clean, "..") || filepath.IsAbs(clean) {
return fmt.Errorf("invalid file path: %s", f.Path)
}
}
tmpDir, err := os.MkdirTemp("", "fb-upload-*")
if err != nil {
return fmt.Errorf("mktemp: %w", err)
}
baseEnv := []string{"GIT_TERMINAL_PROMPT=0", "HOME=/tmp"}
authorEnv := append(baseEnv,
"GIT_AUTHOR_NAME="+authorName,
"GIT_AUTHOR_EMAIL="+authorEmail,
"GIT_COMMITTER_NAME="+authorName,
"GIT_COMMITTER_EMAIL="+authorEmail,
)
addWt := exec.Command("git", "worktree", "add", "--force", tmpDir, branch)
addWt.Dir = filepath.Clean(repoPath)
addWt.Env = baseEnv
if out, err := addWt.CombinedOutput(); err != nil {
os.RemoveAll(tmpDir)
return fmt.Errorf("worktree add: %w: %s", err, out)
}
defer func() {
rmWt := exec.Command("git", "worktree", "remove", "--force", tmpDir)
rmWt.Dir = filepath.Clean(repoPath)
rmWt.Env = baseEnv
rmWt.Run()
os.RemoveAll(tmpDir)
}()
for _, f := range files {
clean := filepath.Clean(filepath.FromSlash(f.Path))
fullPath := filepath.Join(tmpDir, clean)
if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil {
return fmt.Errorf("mkdirall %s: %w", clean, err)
}
if err := os.WriteFile(fullPath, f.Content, 0644); err != nil {
return fmt.Errorf("writefile %s: %w", clean, err)
}
}
addC := exec.Command("git", "add", ".")
addC.Dir = tmpDir
addC.Env = authorEnv
if out, err := addC.CombinedOutput(); err != nil {
return fmt.Errorf("git add: %w: %s", err, out)
}
commitC := exec.Command("git", "commit", "-m", message)
commitC.Dir = tmpDir
commitC.Env = authorEnv
if out, err := commitC.CombinedOutput(); err != nil {
return fmt.Errorf("git commit: %w: %s", err, out)
}
return nil
}
type Branch struct { type Branch struct {
Name string `json:"name"` Name string `json:"name"`
} }
@@ -283,6 +364,23 @@ func RepoSize(repoPath string) int64 {
return total return total
} }
// Run executes a git command in repoPath with discrete arguments and returns
// the raw stdout. WARNING: args must be constant literals or strictly validated
// — no user-controlled values belong here. This is the public equivalent of the
// internal run() helper and carries the same safety guarantees.
func Run(repoPath string, args ...string) ([]byte, error) {
return run(repoPath, args...)
}
// RevParse resolves a ref (branch name, tag, or SHA) to its full commit SHA.
func RevParse(repoPath, ref string) (string, error) {
out, err := run(repoPath, "rev-parse", "--verify", ref)
if err != nil {
return "", err
}
return strings.TrimSpace(string(out)), nil
}
// SetDefaultBranch updates HEAD to point at the given branch name. // SetDefaultBranch updates HEAD to point at the given branch name.
func SetDefaultBranch(repoPath, branch string) error { func SetDefaultBranch(repoPath, branch string) error {
_, err := run(repoPath, "symbolic-ref", "HEAD", "refs/heads/"+branch) _, err := run(repoPath, "symbolic-ref", "HEAD", "refs/heads/"+branch)
@@ -450,3 +548,237 @@ func parseUnifiedDiff(raw string) []FileDiff {
commit() commit()
return files return files
} }
// ArchiveStream writes a git archive of ref in the requested format to w.
// format must be one of "zip", "tar.gz", or "bundle".
// Output is streamed directly to w without buffering.
func ArchiveStream(repoPath string, ref string, format string, w io.Writer) error {
clean := filepath.Clean(repoPath)
if repoRoot != "" {
root := repoRoot + string(filepath.Separator)
if !strings.HasPrefix(clean+string(filepath.Separator), root) && clean != repoRoot {
return ErrPathTraversal
}
}
baseEnv := []string{"GIT_TERMINAL_PROMPT=0", "HOME=/tmp"}
var cmd *exec.Cmd
switch format {
case "zip", "tar.gz":
cmd = exec.Command("git", "archive", "--format="+format, ref)
case "bundle":
cmd = exec.Command("git", "bundle", "create", "-", "--all")
default:
return fmt.Errorf("git archive: unsupported format %q", format)
}
cmd.Dir = clean
cmd.Env = baseEnv
cmd.Stdout = w
var errBuf strings.Builder
cmd.Stderr = &errBuf
if err := cmd.Run(); err != nil {
if errBuf.Len() > 0 {
return fmt.Errorf("git archive: %w: %s", err, errBuf.String())
}
return fmt.Errorf("git archive: %w", err)
}
return nil
}
// ── Language stats ─────────────────────────────────────────────────────────────
// LangStat holds the aggregate file-count and percentage for one language.
type LangStat struct {
Name string `json:"name"`
Color string `json:"color"`
Count int `json:"count"`
Pct float64 `json:"pct"`
}
// extLang maps file extensions to (display name, hex color).
var extLang = map[string][2]string{
".go": {"Go", "#00ADD8"},
".ts": {"TypeScript", "#3178C6"},
".tsx": {"TypeScript", "#3178C6"},
".js": {"JavaScript", "#F7DF1E"},
".jsx": {"JavaScript", "#F7DF1E"},
".mjs": {"JavaScript", "#F7DF1E"},
".py": {"Python", "#3572A5"},
".rb": {"Ruby", "#CC342D"},
".rs": {"Rust", "#DEA584"},
".java": {"Java", "#B07219"},
".cs": {"C#", "#178600"},
".cpp": {"C++", "#F34B7D"},
".cc": {"C++", "#F34B7D"},
".c": {"C", "#555555"},
".h": {"C", "#555555"},
".swift": {"Swift", "#F05138"},
".kt": {"Kotlin", "#A97BFF"},
".php": {"PHP", "#4F5D95"},
".html": {"HTML", "#E34C26"},
".css": {"CSS", "#563D7C"},
".scss": {"SCSS", "#C6538C"},
".sql": {"SQL", "#e38c00"},
".sh": {"Shell", "#89E051"},
".bash": {"Shell", "#89E051"},
".yaml": {"YAML", "#CB171E"},
".yml": {"YAML", "#CB171E"},
".json": {"JSON", "#292929"},
".md": {"Markdown", "#083FA1"},
".tf": {"HCL", "#844FBA"},
".proto": {"Protobuf", "#5F5CE9"},
".dart": {"Dart", "#00B4AB"},
".lua": {"Lua", "#000080"},
".r": {"R", "#198CE7"},
}
// LanguageStats analyses the file tree at ref and returns language breakdown
// sorted by file count descending. Languages under 3% are collapsed into "Other".
func LanguageStats(repoPath, ref string) ([]LangStat, error) {
if ref == "" {
ref = "HEAD"
}
out, err := run(repoPath, "ls-tree", "-r", "--name-only", ref)
if err != nil {
return nil, err
}
counts := map[string]int{}
total := 0
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
ext := strings.ToLower(filepath.Ext(line))
lang, ok := extLang[ext]
if ok {
counts[lang[0]]++
} else {
counts["Other"]++
}
total++
}
if total == 0 {
return []LangStat{}, nil
}
// Aggregate by name with color lookup.
colorOf := map[string]string{"Other": "#8B8B8B"}
for _, v := range extLang {
colorOf[v[0]] = v[1]
}
var stats []LangStat
otherCount := 0
for name, count := range counts {
pct := float64(count) / float64(total) * 100
if name == "Other" || pct < 3.0 {
otherCount += count
continue
}
stats = append(stats, LangStat{Name: name, Color: colorOf[name], Count: count, Pct: pct})
}
sort.Slice(stats, func(i, j int) bool { return stats[i].Count > stats[j].Count })
if len(stats) > 10 {
for _, s := range stats[10:] {
otherCount += s.Count
}
stats = stats[:10]
}
if otherCount > 0 {
stats = append(stats, LangStat{
Name: "Other",
Color: "#8B8B8B",
Count: otherCount,
Pct: float64(otherCount) / float64(total) * 100,
})
}
return stats, nil
}
// ── Contributor stats ─────────────────────────────────────────────────────────
// Contributor holds a commit author name and their commit count.
type Contributor struct {
Name string `json:"name"`
Commits int `json:"commits"`
}
// Contributors returns the top limit commit authors sorted by commit count.
// Uses git shortlog which is fast even on large repos.
func Contributors(repoPath string, limit int) ([]Contributor, error) {
out, err := run(repoPath, "shortlog", "-sn", "--no-merges", "HEAD")
if err != nil {
return []Contributor{}, nil // empty repo or detached HEAD — not an error
}
var result []Contributor
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// Format: " 42\tAlice Wang"
idx := strings.Index(line, "\t")
if idx < 0 {
continue
}
countStr := strings.TrimSpace(line[:idx])
name := strings.TrimSpace(line[idx+1:])
n, err := strconv.Atoi(countStr)
if err != nil {
continue
}
result = append(result, Contributor{Name: name, Commits: n})
if len(result) >= limit {
break
}
}
return result, nil
}
// CommitCount returns the total number of commits reachable from HEAD.
func CommitCount(repoPath string) (int, error) {
out, err := run(repoPath, "rev-list", "--count", "HEAD")
if err != nil {
return 0, nil // empty repo
}
n, err := strconv.Atoi(strings.TrimSpace(string(out)))
if err != nil {
return 0, nil
}
return n, nil
}
// SearchFiles returns file paths matching query (case-insensitive substring)
// in the repository tree at ref, capped at limit results.
func SearchFiles(repoPath, ref, query string, limit int) ([]string, error) {
if ref == "" {
ref = "HEAD"
}
out, err := run(repoPath, "ls-tree", "-r", "--name-only", ref)
if err != nil {
return []string{}, nil // empty repo
}
lower := strings.ToLower(query)
var results []string
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
if strings.Contains(strings.ToLower(line), lower) {
results = append(results, line)
if len(results) >= limit {
break
}
}
}
return results, nil
}
+95
View File
@@ -0,0 +1,95 @@
package gitops
import (
"context"
"encoding/json"
"log"
"time"
"xorm.io/xorm"
"github.com/forgeo/forgebucket/internal/config"
"github.com/forgeo/forgebucket/internal/events"
"github.com/forgeo/forgebucket/internal/models"
)
// Controller is the GitOps reconciliation engine. It subscribes to NATS events
// and drives drift detection + auto-sync for every configured environment.
type Controller struct {
db *xorm.Engine
bus events.EventBus
cfg *config.Config
}
func NewController(db *xorm.Engine, bus events.EventBus, cfg *config.Config) *Controller {
return &Controller{db: db, bus: bus, cfg: cfg}
}
// Start subscribes to relevant events and blocks until ctx is cancelled.
func (c *Controller) Start(ctx context.Context) {
c.recoverSyncingState()
unsub1, err := c.bus.Subscribe(events.SubjectPushReceived, func(_ string, data []byte) {
var evt events.PushEvent
if err := json.Unmarshal(data, &evt); err != nil {
log.Printf("gitops: bad push.received payload: %v", err)
return
}
go c.handlePush(evt)
})
if err != nil {
log.Printf("gitops: subscribe push.received: %v", err)
} else {
defer unsub1()
}
unsub2, err := c.bus.Subscribe(events.SubjectDeploymentSucceeded, func(_ string, data []byte) {
go c.handleDeploymentSucceeded(data)
})
if err != nil {
log.Printf("gitops: subscribe deployment.succeeded: %v", err)
} else {
defer unsub2()
}
unsub3, err := c.bus.Subscribe(events.SubjectDeploymentFailed, func(_ string, data []byte) {
go c.handleDeploymentFailed(data)
})
if err != nil {
log.Printf("gitops: subscribe deployment.failed: %v", err)
} else {
defer unsub3()
}
if c.cfg.GitOpsReconcileInterval > 0 {
go c.runTicker(ctx)
}
log.Printf("gitops: controller started (reconcile interval: %ds)", c.cfg.GitOpsReconcileInterval)
<-ctx.Done()
}
func (c *Controller) runTicker(ctx context.Context) {
interval := time.Duration(c.cfg.GitOpsReconcileInterval) * time.Second
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
c.periodicCheck()
case <-ctx.Done():
return
}
}
}
// recoverSyncingState marks any configs left in "syncing" as "drifted" on startup
// (they were in-flight when the server last stopped).
func (c *Controller) recoverSyncingState() {
affected, _ := c.db.Where("sync_status = 'syncing'").
Cols("sync_status").
Update(&models.GitOpsConfig{SyncStatus: "drifted"})
if affected > 0 {
log.Printf("gitops: recovered %d stale syncing configs → drifted", affected)
}
}
+168
View File
@@ -0,0 +1,168 @@
package gitops
import (
"log"
"strings"
"time"
"xorm.io/xorm"
gitdomain "github.com/forgeo/forgebucket/internal/domain/git"
"github.com/forgeo/forgebucket/internal/events"
"github.com/forgeo/forgebucket/internal/models"
)
// CheckDrift resolves the HEAD SHA of branch in the repo at repoPath and
// compares it against actualSHA. Returns the resolved HEAD SHA, whether drift
// exists, and any error.
func CheckDrift(repoPath, branch, actualSHA string) (desiredSHA string, drifted bool, err error) {
sha, err := gitdomain.RevParse(repoPath, branch)
if err != nil {
return "", false, err
}
return sha, sha != actualSHA, nil
}
// refToBranch strips the refs/heads/ prefix from a full git ref.
// Returns "" for non-branch refs (tags, etc.).
func refToBranch(ref string) string {
return strings.TrimPrefix(ref, "refs/heads/")
}
// handlePush is called on every push.received event. For each GitOpsConfig
// on the pushed repo whose branch matches, it runs a drift check.
func (c *Controller) handlePush(evt events.PushEvent) {
pushedBranch := refToBranch(evt.Ref)
if pushedBranch == "" {
return // tag push or other non-branch ref — ignore
}
var cfgs []models.GitOpsConfig
if err := c.db.Where("repo_id = ?", evt.RepoID).Find(&cfgs); err != nil {
return
}
for _, cfg := range cfgs {
if cfg.Branch != pushedBranch {
continue
}
c.evaluateDrift(cfg, evt.After)
}
}
// evaluateDrift compares desiredSHA against the config's ActualSHA and takes
// the appropriate action: record drift and optionally auto-sync.
func (c *Controller) evaluateDrift(cfg models.GitOpsConfig, desiredSHA string) {
now := time.Now().UTC()
cfg.LastCheckedAt = &now
cfg.DesiredSHA = desiredSHA
if desiredSHA == cfg.ActualSHA {
// Already in sync.
cfg.SyncStatus = "synced"
c.db.ID(cfg.ID).Cols("sync_status", "desired_sha", "last_checked_at").Update(&cfg) //nolint:errcheck
return
}
// Drift detected — record and publish.
log.Printf("gitops: drift on env %d: desired=%s actual=%s", cfg.EnvID, desiredSHA[:7], sha7(cfg.ActualSHA))
drift := &models.GitOpsDriftEvent{
EnvID: cfg.EnvID,
RepoID: cfg.RepoID,
DesiredSHA: desiredSHA,
ActualSHA: cfg.ActualSHA,
SyncStatus: "drifted",
DetectedAt: now,
}
c.db.Insert(drift) //nolint:errcheck
cfg.SyncStatus = "drifted"
c.db.ID(cfg.ID).Cols("sync_status", "desired_sha", "last_checked_at").Update(&cfg) //nolint:errcheck
// Look up env name for the event payload.
var env models.Environment
c.db.ID(cfg.EnvID).Get(&env) //nolint:errcheck
c.bus.Publish(events.SubjectEnvironmentDriftDetected, events.DriftEvent{ //nolint:errcheck
EnvID: cfg.EnvID,
EnvName: env.Name,
RepoID: cfg.RepoID,
DesiredSHA: desiredSHA,
ActualSHA: cfg.ActualSHA,
At: now,
})
if cfg.AutoSync {
c.TriggerSync(cfg, desiredSHA)
}
}
// periodicCheck runs on a ticker and re-evaluates drift for every GitOpsConfig
// whose SyncInterval has elapsed.
func (c *Controller) periodicCheck() {
now := time.Now().UTC()
var cfgs []models.GitOpsConfig
if err := c.db.Where("sync_interval > 0").Find(&cfgs); err != nil {
return
}
for _, cfg := range cfgs {
elapsed := now.Unix() - lastChecked(cfg).Unix()
if int(elapsed) < cfg.SyncInterval {
continue
}
var repo models.Repository
if found, _ := c.db.ID(cfg.RepoID).Get(&repo); !found {
continue
}
desiredSHA, drifted, err := CheckDrift(repo.DiskPath, cfg.Branch, cfg.ActualSHA)
if err != nil {
log.Printf("gitops: periodic check env %d: %v", cfg.EnvID, err)
now2 := time.Now().UTC()
cfg.LastCheckedAt = &now2
c.db.ID(cfg.ID).Cols("last_checked_at").Update(&cfg) //nolint:errcheck
continue
}
if drifted {
c.evaluateDrift(cfg, desiredSHA)
} else {
now2 := time.Now().UTC()
cfg.LastCheckedAt = &now2
c.db.ID(cfg.ID).Cols("last_checked_at").Update(&cfg) //nolint:errcheck
}
}
}
// markSynced resolves any open drift events for envID and updates the config.
func markSynced(db *xorm.Engine, envID int64, sha string) {
now := time.Now().UTC()
db.Where("env_id = ? AND resolved_at IS NULL", envID).
Cols("sync_status", "resolved_at").
Update(&models.GitOpsDriftEvent{SyncStatus: "synced", ResolvedAt: &now}) //nolint:errcheck
db.Where("env_id = ?", envID).
Cols("sync_status", "actual_sha", "last_checked_at").
Update(&models.GitOpsConfig{SyncStatus: "synced", ActualSHA: sha, LastCheckedAt: &now}) //nolint:errcheck
}
func lastChecked(cfg models.GitOpsConfig) time.Time {
if cfg.LastCheckedAt != nil {
return *cfg.LastCheckedAt
}
return cfg.CreatedAt
}
func sha7(s string) string {
if len(s) >= 7 {
return s[:7]
}
if s == "" {
return "(none)"
}
return s
}
+97
View File
@@ -0,0 +1,97 @@
package gitops
import (
"encoding/json"
"log"
"time"
"github.com/forgeo/forgebucket/internal/events"
"github.com/forgeo/forgebucket/internal/models"
)
// TriggerSync creates a Deployment record in "pending" state and fires
// deployment.started — the same path as a manually-triggered deployment.
// GitOps is just the trigger; actual execution is handled externally (or via CI).
func (c *Controller) TriggerSync(cfg models.GitOpsConfig, desiredSHA string) {
var env models.Environment
if found, _ := c.db.ID(cfg.EnvID).Get(&env); !found {
log.Printf("gitops: sync env %d not found", cfg.EnvID)
return
}
now := time.Now().UTC()
deploy := &models.Deployment{
EnvID: cfg.EnvID,
RepoID: cfg.RepoID,
SHA: desiredSHA,
Ref: "refs/heads/" + cfg.Branch,
Status: models.DeployStatusPending,
TriggeredBy: "gitops",
Description: "GitOps auto-sync",
StartedAt: &now,
}
if _, err := c.db.Insert(deploy); err != nil {
log.Printf("gitops: create deployment: %v", err)
return
}
cfg.SyncStatus = "syncing"
c.db.ID(cfg.ID).Cols("sync_status").Update(&cfg) //nolint:errcheck
c.bus.Publish(events.SubjectDeploymentStarted, events.DeploymentEvent{ //nolint:errcheck
DeploymentID: deploy.ID,
EnvID: env.ID,
EnvName: env.Name,
RepoID: deploy.RepoID,
SHA: deploy.SHA,
Ref: deploy.Ref,
Status: string(deploy.Status),
TriggeredBy: deploy.TriggeredBy,
})
log.Printf("gitops: triggered sync deploy %d for env %d (%s)", deploy.ID, cfg.EnvID, desiredSHA[:7])
}
// handleDeploymentSucceeded is called when any deployment.succeeded event fires.
// If the deployment was GitOps-triggered, it marks the config as synced.
func (c *Controller) handleDeploymentSucceeded(data []byte) {
var evt events.DeploymentEvent
if err := json.Unmarshal(data, &evt); err != nil {
return
}
// Only act on deployments triggered by gitops.
if evt.TriggeredBy != "gitops" {
// Still update ActualSHA and resolve drift if this env has a GitOps config —
// manual deployments also advance the state.
var cfg models.GitOpsConfig
if found, _ := c.db.Where("env_id = ?", evt.EnvID).Get(&cfg); found {
markSynced(c.db, evt.EnvID, evt.SHA)
log.Printf("gitops: env %d synced via manual deploy (%s)", evt.EnvID, sha7(evt.SHA))
}
return
}
markSynced(c.db, evt.EnvID, evt.SHA)
log.Printf("gitops: env %d synced (%s)", evt.EnvID, sha7(evt.SHA))
}
// handleDeploymentFailed is called when deployment.failed fires.
// If the deployment was GitOps-triggered, it reverts SyncStatus back to drifted.
func (c *Controller) handleDeploymentFailed(data []byte) {
var evt events.DeploymentEvent
if err := json.Unmarshal(data, &evt); err != nil {
return
}
if evt.TriggeredBy != "gitops" {
return
}
var cfg models.GitOpsConfig
if found, _ := c.db.Where("env_id = ?", evt.EnvID).Get(&cfg); !found {
return
}
cfg.SyncStatus = "drifted"
c.db.ID(cfg.ID).Cols("sync_status").Update(&cfg) //nolint:errcheck
log.Printf("gitops: env %d sync failed — reverting to drifted", evt.EnvID)
}
+375
View File
@@ -0,0 +1,375 @@
// Package oci implements an OCI Distribution Specification v1.1 registry.
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md
//
// Storage layout under ociRoot:
//
// blobs/sha256/<hex64> — content-addressable layer/config blobs
// uploads/<uuid> — temporary files for in-progress chunked uploads
package oci
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
// Registry manages the on-disk blob store and is used by the HTTP handler.
type Registry struct {
root string // absolute path to the OCI storage root
}
// New creates a Registry rooted at ociRoot, creating the directory tree if needed.
func New(ociRoot string) (*Registry, error) {
for _, sub := range []string{"blobs/sha256", "uploads"} {
if err := os.MkdirAll(filepath.Join(ociRoot, sub), 0700); err != nil {
return nil, fmt.Errorf("oci: init storage %s: %w", sub, err)
}
}
return &Registry{root: ociRoot}, nil
}
// Root returns the storage root path.
func (r *Registry) Root() string { return r.root }
// ─── Blob paths ───────────────────────────────────────────────────────────────
// BlobPath returns the filesystem path for a blob identified by its digest.
// digest must be in the form "sha256:<hex>".
func (r *Registry) BlobPath(digest string) (string, error) {
hex, err := digestHex(digest)
if err != nil {
return "", err
}
return filepath.Join(r.root, "blobs", "sha256", hex), nil
}
// UploadPath returns the filesystem path for a chunked upload session.
func (r *Registry) UploadPath(uploadID string) string {
return filepath.Join(r.root, "uploads", sanitiseID(uploadID))
}
// BlobExists reports whether a blob with the given digest exists on disk.
func (r *Registry) BlobExists(digest string) bool {
p, err := r.BlobPath(digest)
if err != nil {
return false
}
_, err = os.Stat(p)
return err == nil
}
// BlobSize returns the size of the blob in bytes, or -1 if it doesn't exist.
func (r *Registry) BlobSize(digest string) int64 {
p, err := r.BlobPath(digest)
if err != nil {
return -1
}
info, err := os.Stat(p)
if err != nil {
return -1
}
return info.Size()
}
// ReadBlob opens a blob for streaming. Caller must close the returned file.
func (r *Registry) ReadBlob(digest string) (*os.File, error) {
p, err := r.BlobPath(digest)
if err != nil {
return nil, err
}
return os.Open(p)
}
// WriteBlob writes src into the blob store, verifies the digest, and returns
// the computed digest string ("sha256:<hex>") and size.
// If a blob with the same digest already exists it is not overwritten.
func (r *Registry) WriteBlob(src io.Reader) (digest string, size int64, err error) {
tmp, err := os.CreateTemp(filepath.Join(r.root, "uploads"), "blob-*")
if err != nil {
return "", 0, fmt.Errorf("oci: create tmp blob: %w", err)
}
tmpPath := tmp.Name()
defer func() {
tmp.Close()
if err != nil {
os.Remove(tmpPath)
}
}()
h := sha256.New()
mw := io.MultiWriter(tmp, h)
size, err = io.Copy(mw, src)
if err != nil {
return "", 0, fmt.Errorf("oci: write blob: %w", err)
}
tmp.Close()
digest = "sha256:" + hex.EncodeToString(h.Sum(nil))
dest, err2 := r.BlobPath(digest)
if err2 != nil {
os.Remove(tmpPath)
return "", 0, err2
}
if _, statErr := os.Stat(dest); statErr == nil {
// Already exists — deduplication.
os.Remove(tmpPath)
return digest, size, nil
}
if err = os.Rename(tmpPath, dest); err != nil {
return "", 0, fmt.Errorf("oci: commit blob: %w", err)
}
return digest, size, nil
}
// FinishUpload finalises a chunked upload: reads the temp file, verifies
// clientDigest (if non-empty), atomically moves it to the blob store, and
// returns the canonical digest and size.
func (r *Registry) FinishUpload(uploadID, clientDigest string) (digest string, size int64, err error) {
src := r.UploadPath(uploadID)
f, err := os.Open(src)
if err != nil {
return "", 0, fmt.Errorf("oci: open upload: %w", err)
}
h := sha256.New()
size, err = io.Copy(h, f)
f.Close()
if err != nil {
return "", 0, fmt.Errorf("oci: hash upload: %w", err)
}
digest = "sha256:" + hex.EncodeToString(h.Sum(nil))
if clientDigest != "" && clientDigest != digest {
os.Remove(src)
return "", 0, &DigestMismatch{Expected: clientDigest, Actual: digest}
}
dest, err := r.BlobPath(digest)
if err != nil {
return "", 0, err
}
if _, statErr := os.Stat(dest); statErr == nil {
// Blob already exists — dedup.
os.Remove(src)
return digest, size, nil
}
if err = os.Rename(src, dest); err != nil {
return "", 0, fmt.Errorf("oci: commit upload: %w", err)
}
return digest, size, nil
}
// AppendUpload appends src to an existing upload session file and returns the
// new total offset.
func (r *Registry) AppendUpload(uploadID string, src io.Reader) (newOffset int64, err error) {
path := r.UploadPath(uploadID)
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
if err != nil {
return 0, fmt.Errorf("oci: open upload for append: %w", err)
}
defer f.Close()
n, err := io.Copy(f, src)
if err != nil {
return 0, fmt.Errorf("oci: append upload: %w", err)
}
info, err := f.Stat()
if err != nil {
return n, nil
}
return info.Size(), nil
}
// UploadOffset returns the number of bytes written to an upload session so far.
func (r *Registry) UploadOffset(uploadID string) int64 {
info, err := os.Stat(r.UploadPath(uploadID))
if err != nil {
return 0
}
return info.Size()
}
// CancelUpload removes the temporary upload file.
func (r *Registry) CancelUpload(uploadID string) {
os.Remove(r.UploadPath(uploadID))
}
// DeleteBlob removes a blob from disk.
func (r *Registry) DeleteBlob(digest string) error {
p, err := r.BlobPath(digest)
if err != nil {
return err
}
return os.Remove(p)
}
// ─── Manifest helpers ─────────────────────────────────────────────────────────
// ManifestDescriptor extracts the digest and size from a raw manifest body.
func ManifestDescriptor(body []byte) (digest string, size int64) {
h := sha256.Sum256(body)
return "sha256:" + hex.EncodeToString(h[:]), int64(len(body))
}
// IsDigestRef returns true when ref looks like a digest ("sha256:<hex>").
func IsDigestRef(ref string) bool {
return strings.HasPrefix(ref, "sha256:")
}
// ─── OCI error types ─────────────────────────────────────────────────────────
// ErrorCode is an OCI Distribution API error code.
type ErrorCode string
const (
ErrBlobUnknown ErrorCode = "BLOB_UNKNOWN"
ErrBlobUploadInvalid ErrorCode = "BLOB_UPLOAD_INVALID"
ErrBlobUploadUnknown ErrorCode = "BLOB_UPLOAD_UNKNOWN"
ErrDigestInvalid ErrorCode = "DIGEST_INVALID"
ErrManifestBlobUnknown ErrorCode = "MANIFEST_BLOB_UNKNOWN"
ErrManifestInvalid ErrorCode = "MANIFEST_INVALID"
ErrManifestUnknown ErrorCode = "MANIFEST_UNKNOWN"
ErrNameInvalid ErrorCode = "NAME_INVALID"
ErrNameUnknown ErrorCode = "NAME_UNKNOWN"
ErrTagInvalid ErrorCode = "TAG_INVALID"
ErrUnauthorized ErrorCode = "UNAUTHORIZED"
ErrDenied ErrorCode = "DENIED"
ErrUnsupported ErrorCode = "UNSUPPORTED"
)
// APIError is a single OCI error entry.
type APIError struct {
Code ErrorCode `json:"code"`
Message string `json:"message"`
Detail interface{} `json:"detail,omitempty"`
}
// ErrorResponse is the top-level OCI error response body.
type ErrorResponse struct {
Errors []APIError `json:"errors"`
}
// NewError builds an ErrorResponse JSON body.
func NewError(code ErrorCode, msg string) []byte {
b, _ := json.Marshal(ErrorResponse{Errors: []APIError{{Code: code, Message: msg}}})
return b
}
// DigestMismatch is returned when a client-provided digest doesn't match the computed one.
type DigestMismatch struct {
Expected string
Actual string
}
func (e *DigestMismatch) Error() string {
return fmt.Sprintf("digest mismatch: expected %s, got %s", e.Expected, e.Actual)
}
// ─── path helpers ─────────────────────────────────────────────────────────────
// digestHex validates a "sha256:<hex>" digest string and returns the hex part.
func digestHex(digest string) (string, error) {
if !strings.HasPrefix(digest, "sha256:") {
return "", fmt.Errorf("oci: only sha256 digests are supported, got %q", digest)
}
h := strings.TrimPrefix(digest, "sha256:")
if len(h) != 64 {
return "", fmt.Errorf("oci: invalid sha256 digest length: %d", len(h))
}
return h, nil
}
// sanitiseID returns only the last path component of an upload ID,
// preventing any path traversal regardless of encoding.
func sanitiseID(id string) string {
return filepath.Base(id)
}
// ParseOCIPath extracts the image name and the operation kind from a path
// under /v2/. name may contain slashes (e.g. "alice/myapp").
//
// Returns: name, kind, ref where kind is one of:
//
// "tags" → ref = ""
// "manifest" → ref = tag or digest
// "blob" → ref = digest
// "upload" → ref = uploadID (empty for new upload)
// "" → unrecognised path
func ParseOCIPath(rawPath string) (name, kind, ref string) {
// Strip leading /v2/
p := strings.TrimPrefix(rawPath, "/v2/")
if p == "" || p == "/" {
return "", "", ""
}
// Try known suffixes from most to least specific.
type suffix struct {
needle string
kind string
}
suffixes := []suffix{
{"/blobs/uploads/", "upload"},
{"/blobs/sha256:", "blob"},
{"/blobs/", "blob"},
{"/manifests/", "manifest"},
{"/tags/list", "tags"},
}
for _, s := range suffixes {
idx := strings.Index(p, s.needle)
if idx < 0 {
continue
}
name = p[:idx]
rest := p[idx+len(s.needle):]
kind = s.kind
switch s.kind {
case "blob":
// ref is digest: re-attach the sha256: prefix if needed
if strings.HasSuffix(s.needle, ":") {
ref = "sha256:" + rest
} else {
ref = rest
}
case "upload":
ref = rest // upload UUID or empty for new session
default:
ref = rest
}
return name, kind, ref
}
return "", "", ""
}
// ValidateName returns an error if the image name is empty or contains
// invalid characters.
func ValidateName(name string) error {
if name == "" {
return errors.New("empty image name")
}
for _, c := range name {
if !isNameChar(c) {
return fmt.Errorf("invalid character %q in image name", c)
}
}
return nil
}
func isNameChar(c rune) bool {
return (c >= 'a' && c <= 'z') ||
(c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') ||
c == '.' || c == '-' || c == '_' || c == '/'
}
+254
View File
@@ -0,0 +1,254 @@
package oci_test
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"github.com/forgeo/forgebucket/internal/domain/oci"
)
func TestParseOCIPath(t *testing.T) {
tests := []struct {
path string
wantName string
wantKind string
wantRef string
}{
{"/v2/", "", "", ""},
{"/v2", "", "", ""},
{"/v2/alice/myapp/tags/list", "alice/myapp", "tags", ""},
{"/v2/alice/myapp/manifests/latest", "alice/myapp", "manifest", "latest"},
{"/v2/alice/myapp/manifests/sha256:abc123", "alice/myapp", "manifest", "sha256:abc123"},
{"/v2/alice/myapp/blobs/sha256:def456", "alice/myapp", "blob", "sha256:def456"},
{"/v2/alice/myapp/blobs/uploads/", "alice/myapp", "upload", ""},
{"/v2/alice/myapp/blobs/uploads/uuid123", "alice/myapp", "upload", "uuid123"},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
name, kind, ref := oci.ParseOCIPath(tt.path)
if name != tt.wantName {
t.Errorf("name = %q, want %q", name, tt.wantName)
}
if kind != tt.wantKind {
t.Errorf("kind = %q, want %q", kind, tt.wantKind)
}
if ref != tt.wantRef {
t.Errorf("ref = %q, want %q", ref, tt.wantRef)
}
})
}
}
func TestValidateName(t *testing.T) {
if err := oci.ValidateName("alice/myapp"); err != nil {
t.Errorf("valid name got error: %v", err)
}
if err := oci.ValidateName(""); err == nil {
t.Error("empty name should error")
}
if err := oci.ValidateName("alice/my app"); err == nil {
t.Error("name with spaces should error")
}
}
func TestBlobPath(t *testing.T) {
dir := t.TempDir()
reg, err := oci.New(dir)
if err != nil {
t.Fatal(err)
}
p, err := reg.BlobPath("sha256:" + strings.Repeat("a", 64))
if err != nil {
t.Fatal(err)
}
expectedSuffix := filepath.Join("blobs", "sha256", strings.Repeat("a", 64))
if !strings.HasSuffix(p, expectedSuffix) {
t.Errorf("path %q does not end with %q", p, expectedSuffix)
}
if _, err := reg.BlobPath("sha256:bad"); err == nil {
t.Error("expected error for short hex")
}
if _, err := reg.BlobPath("md5:abc"); err == nil {
t.Error("expected error for non-sha256 algorithm")
}
}
func TestWriteAndReadBlob(t *testing.T) {
dir := t.TempDir()
reg, err := oci.New(dir)
if err != nil {
t.Fatal(err)
}
content := []byte("hello oci blob")
digest, size, err := reg.WriteBlob(bytes.NewReader(content))
if err != nil {
t.Fatalf("WriteBlob: %v", err)
}
if !strings.HasPrefix(digest, "sha256:") {
t.Errorf("digest should start with sha256:, got %s", digest)
}
if size != int64(len(content)) {
t.Errorf("size = %d, want %d", size, len(content))
}
if !reg.BlobExists(digest) {
t.Error("blob should exist after write")
}
// Deduplication test: writing same content again should succeed without error.
d2, s2, err := reg.WriteBlob(bytes.NewReader(content))
if err != nil {
t.Fatalf("WriteBlob duplicate: %v", err)
}
if d2 != digest {
t.Errorf("digest mismatch: %s vs %s", d2, digest)
}
if s2 != size {
t.Errorf("size mismatch: %d vs %d", s2, size)
}
f, err := reg.ReadBlob(digest)
if err != nil {
t.Fatalf("ReadBlob: %v", err)
}
defer f.Close()
buf := new(bytes.Buffer)
buf.ReadFrom(f)
if buf.String() != string(content) {
t.Errorf("content mismatch: got %s", buf.String())
}
}
func TestUploadSession(t *testing.T) {
dir := t.TempDir()
reg, _ := oci.New(dir)
uploadID := "test-upload-001"
// Append content in chunks.
off, err := reg.AppendUpload(uploadID, strings.NewReader("chunk1"))
if err != nil {
t.Fatalf("AppendUpload: %v", err)
}
if off != 6 {
t.Errorf("expected offset 6, got %d", off)
}
off, err = reg.AppendUpload(uploadID, strings.NewReader("-chunk2"))
if err != nil {
t.Fatalf("AppendUpload second: %v", err)
}
if off != 13 {
t.Errorf("expected offset 13 after chunk2, got %d", off)
}
if reg.UploadOffset(uploadID) != 13 {
t.Errorf("UploadOffset = %d, want 13", reg.UploadOffset(uploadID))
}
// Finish upload with digest.
digest, size, err := reg.FinishUpload(uploadID, "")
if err != nil {
t.Fatalf("FinishUpload: %v", err)
}
if !strings.HasPrefix(digest, "sha256:") {
t.Errorf("expected sha256 digest, got %s", digest)
}
if size != 13 {
t.Errorf("expected size 13, got %d", size)
}
if !reg.BlobExists(digest) {
t.Error("blob should exist after finish upload")
}
// Verify content.
f, _ := reg.ReadBlob(digest)
buf := new(bytes.Buffer)
buf.ReadFrom(f)
f.Close()
if buf.String() != "chunk1-chunk2" {
t.Errorf("content = %q, want %q", buf.String(), "chunk1-chunk2")
}
}
func TestFinishUploadDigestMismatch(t *testing.T) {
dir := t.TempDir()
reg, _ := oci.New(dir)
uploadID := "mismatch-upload"
reg.AppendUpload(uploadID, strings.NewReader("some data"))
_, _, err := reg.FinishUpload(uploadID, "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
if err == nil {
t.Fatal("expected digest mismatch error")
}
if !strings.Contains(err.Error(), "digest mismatch") {
t.Errorf("expected 'digest mismatch', got: %v", err)
}
}
func TestManifestDescriptor(t *testing.T) {
body := []byte(`{"schemaVersion":2}`)
digest, size := oci.ManifestDescriptor(body)
if !strings.HasPrefix(digest, "sha256:") {
t.Errorf("digest should be sha256, got %s", digest)
}
if size != int64(len(body)) {
t.Errorf("size = %d, want %d", size, len(body))
}
}
func TestIsDigestRef(t *testing.T) {
if !oci.IsDigestRef("sha256:abc") {
t.Error("sha256:abc should be a digest ref")
}
if oci.IsDigestRef("latest") {
t.Error("latest should NOT be a digest ref")
}
}
func TestDeleteBlob(t *testing.T) {
dir := t.TempDir()
reg, _ := oci.New(dir)
content := []byte("delete me")
digest, _, _ := reg.WriteBlob(bytes.NewReader(content))
if !reg.BlobExists(digest) {
t.Fatal("blob should exist after write")
}
if err := reg.DeleteBlob(digest); err != nil {
t.Fatalf("DeleteBlob: %v", err)
}
if reg.BlobExists(digest) {
t.Error("blob should not exist after delete")
}
}
func TestNewCreatesDirectories(t *testing.T) {
dir := filepath.Join(t.TempDir(), "oci-storage")
reg, err := oci.New(dir)
if err != nil {
t.Fatal(err)
}
for _, sub := range []string{"blobs/sha256", "uploads"} {
p := filepath.Join(dir, sub)
if _, err := os.Stat(p); os.IsNotExist(err) {
t.Errorf("directory not created: %s", p)
}
}
_ = reg
}
+99
View File
@@ -0,0 +1,99 @@
// Package sbom generates Software Bills of Materials in CycloneDX 1.4 JSON format.
// https://cyclonedx.org/specification/overview/
package sbom
import (
"fmt"
"time"
)
const (
FormatCycloneDX = "cyclonedx-json-1.4"
SpecVersion = "1.4"
BOMFormat = "CycloneDX"
)
// Document is the top-level CycloneDX 1.4 BOM.
type Document struct {
BOMFormat string `json:"bomFormat"`
SpecVersion string `json:"specVersion"`
SerialNumber string `json:"serialNumber"`
Version int `json:"version"`
Metadata Metadata `json:"metadata"`
Components []Component `json:"components"`
}
type Metadata struct {
Timestamp string `json:"timestamp"`
Tools []Tool `json:"tools"`
Component *Component `json:"component,omitempty"`
}
type Tool struct {
Vendor string `json:"vendor"`
Name string `json:"name"`
Version string `json:"version"`
}
// Component represents a software dependency in the BOM.
type Component struct {
Type string `json:"type"` // "library", "application", "framework"
Name string `json:"name"`
Version string `json:"version,omitempty"`
PURL string `json:"purl,omitempty"`
Description string `json:"description,omitempty"`
Scope string `json:"scope,omitempty"` // "required", "optional"
ExternalRefs []ExternalRef `json:"externalReferences,omitempty"`
}
type ExternalRef struct {
Type string `json:"type"` // "website", "vcs", "distribution"
URL string `json:"url"`
}
// NewDocument creates a blank CycloneDX 1.4 document with metadata populated.
func NewDocument(repoName, sha string) *Document {
return &Document{
BOMFormat: BOMFormat,
SpecVersion: SpecVersion,
SerialNumber: fmt.Sprintf("urn:uuid:forgebucket:%s:%s", repoName, sha[:7]),
Version: 1,
Metadata: Metadata{
Timestamp: time.Now().UTC().Format(time.RFC3339),
Tools: []Tool{
{Vendor: "ForgeBucket", Name: "sbom-generator", Version: "1.0.0"},
},
Component: &Component{
Type: "application",
Name: repoName,
},
},
Components: []Component{},
}
}
// PURL helpers — produce Package URL strings per ecosystem.
func golangPURL(module, version string) string {
return fmt.Sprintf("pkg:golang/%s@%s", module, version)
}
func npmPURL(name, version string) string {
return fmt.Sprintf("pkg:npm/%s@%s", name, version)
}
func pypiPURL(name, version string) string {
return fmt.Sprintf("pkg:pypi/%s@%s", name, version)
}
func cargoPURL(name, version string) string {
return fmt.Sprintf("pkg:cargo/%s@%s", name, version)
}
func gemPURL(name, version string) string {
return fmt.Sprintf("pkg:gem/%s@%s", name, version)
}
func mavenPURL(group, artifact, version string) string {
return fmt.Sprintf("pkg:maven/%s/%s@%s", group, artifact, version)
}
+204
View File
@@ -0,0 +1,204 @@
package sbom
import (
"context"
"encoding/json"
"fmt"
"log"
"time"
"xorm.io/xorm"
"github.com/forgeo/forgebucket/internal/events"
gitdomain "github.com/forgeo/forgebucket/internal/domain/git"
"github.com/forgeo/forgebucket/internal/models"
)
// manifestEntry maps a known manifest file path to its parser function.
type manifestEntry struct {
path string
parser func([]byte) []Component
}
// knownManifests is the ordered list of manifest files the generator probes.
// Files are tried in order; all that exist at the given SHA are parsed.
var knownManifests = []manifestEntry{
{"go.mod", ParseGoMod},
{"package.json", ParsePackageJSON},
{"requirements.txt", ParseRequirementsTxt},
{"Cargo.toml", ParseCargoToml},
{"Gemfile.lock", ParseGemfileLock},
{"pom.xml", ParsePomXML},
}
// Generator subscribes to pipeline.completed events and produces SBOM reports.
type Generator struct {
db *xorm.Engine
bus events.EventBus
}
func NewGenerator(db *xorm.Engine, bus events.EventBus) *Generator {
return &Generator{db: db, bus: bus}
}
// Start subscribes to pipeline.completed and blocks until ctx is cancelled.
func (g *Generator) Start(ctx context.Context) {
unsub, err := g.bus.Subscribe(events.SubjectPipelineCompleted, func(_ string, data []byte) {
var evt events.PipelineEvent
if err := json.Unmarshal(data, &evt); err != nil {
log.Printf("sbom: bad pipeline.completed event: %v", err)
return
}
if evt.Status != "succeeded" {
return
}
go g.generateForRun(evt.RunID, evt.RepoID)
})
if err != nil {
log.Printf("sbom: subscribe pipeline.completed: %v", err)
} else {
defer unsub()
}
<-ctx.Done()
}
// generateForRun generates an SBOM for the pipeline run identified by runID.
func (g *Generator) generateForRun(runID, repoID int64) {
var run models.PipelineRun
if found, err := g.db.ID(runID).Get(&run); err != nil {
log.Printf("sbom: look up run %d: %v", runID, err)
return
} else if !found {
return
}
var repo models.Repository
if found, err := g.db.ID(repoID).Get(&repo); err != nil {
log.Printf("sbom: look up repo %d: %v", repoID, err)
return
} else if !found {
return
}
doc, err := Generate(repo.DiskPath, repo.Name, run.TriggerSHA)
if err != nil {
log.Printf("sbom: generate for run %d: %v", runID, err)
return
}
if err := g.persist(repoID, runID, run.TriggerSHA, doc); err != nil {
log.Printf("sbom: persist for run %d: %v", runID, err)
}
}
// GenerateOnDemand generates an SBOM for a specific repo + SHA and stores it
// (or returns the cached one if the SHA was already processed).
func (g *Generator) GenerateOnDemand(repoID, runID int64, ref string) (*models.SBOMReport, error) {
var repo models.Repository
if found, _ := g.db.ID(repoID).Get(&repo); !found {
return nil, fmt.Errorf("repo %d not found", repoID)
}
// Resolve the ref to a full commit SHA — ref can be a branch name, tag, etc.
sha, err := gitdomain.RevParse(repo.DiskPath, ref)
if err != nil {
return nil, fmt.Errorf("rev-parse %s: %w", ref, err)
}
// Return cached report for this exact SHA + runID if one already exists.
// Without runID in the cache key, a prior on-demand generation (runID=0)
// would shadow subsequent per-run generation requests.
var existing models.SBOMReport
if found, _ := g.db.Where("repo_id = ? AND sha = ? AND run_id = ?", repoID, sha, runID).Get(&existing); found {
return &existing, nil
}
doc, err := Generate(repo.DiskPath, repo.Name, sha)
if err != nil {
return nil, err
}
report, err := g.persistAndReturn(repoID, runID, sha, doc)
if err != nil {
return nil, err
}
return report, nil
}
// GetLatest returns the most recent SBOM report for a repo.
func (g *Generator) GetLatest(repoID int64) (*models.SBOMReport, error) {
var report models.SBOMReport
found, err := g.db.Where("repo_id = ?", repoID).
OrderBy("generated_at DESC").
Get(&report)
if err != nil {
return nil, err
}
if !found {
return nil, nil
}
return &report, nil
}
// GetForRun returns the SBOM report associated with a pipeline run.
func (g *Generator) GetForRun(runID int64) (*models.SBOMReport, error) {
var report models.SBOMReport
found, err := g.db.Where("run_id = ?", runID).Get(&report)
if err != nil {
return nil, err
}
if !found {
return nil, nil
}
return &report, nil
}
// ─── core generation logic ────────────────────────────────────────────────────
// Generate reads known manifest files from the git repo at sha and builds
// a CycloneDX 1.4 document. It is safe to call even if no manifests exist
// (the document will have an empty components list).
func Generate(repoPath, repoName, sha string) (*Document, error) {
doc := NewDocument(repoName, sha)
for _, m := range knownManifests {
content, err := gitdomain.BlobCat(repoPath, sha, m.path)
if err != nil {
// File simply doesn't exist at this SHA — skip silently.
continue
}
comps := m.parser(content)
doc.Components = append(doc.Components, comps...)
}
return doc, nil
}
// ─── persistence helpers ──────────────────────────────────────────────────────
func (g *Generator) persist(repoID, runID int64, sha string, doc *Document) error {
_, err := g.persistAndReturn(repoID, runID, sha, doc)
return err
}
func (g *Generator) persistAndReturn(repoID, runID int64, sha string, doc *Document) (*models.SBOMReport, error) {
bomJSON, err := json.Marshal(doc)
if err != nil {
return nil, fmt.Errorf("marshal BOM: %w", err)
}
report := &models.SBOMReport{
RepoID: repoID,
RunID: runID,
SHA: sha,
Format: FormatCycloneDX,
ComponentCount: len(doc.Components),
BOMDocument: string(bomJSON),
GeneratedAt: time.Now().UTC(),
}
if _, err := g.db.Insert(report); err != nil {
return nil, fmt.Errorf("insert sbom_report: %w", err)
}
log.Printf("sbom: generated report %d for repo %d @ %s (%d components)",
report.ID, repoID, sha[:7], report.ComponentCount)
return report, nil
}
+353
View File
@@ -0,0 +1,353 @@
package sbom
import (
"bufio"
"bytes"
"encoding/json"
"strings"
)
// ParseResult holds components extracted from a single manifest file.
type ParseResult struct {
Ecosystem string
Components []Component
}
// ─── go.mod ──────────────────────────────────────────────────────────────────
// ParseGoMod parses a go.mod file and returns Go module components.
// Handles both single-line `require x v1` and block `require ( ... )` forms.
func ParseGoMod(content []byte) []Component {
var components []Component
scanner := bufio.NewScanner(bytes.NewReader(content))
inBlock := false
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
// Strip inline comments.
if idx := strings.Index(line, "//"); idx >= 0 {
line = strings.TrimSpace(line[:idx])
}
if line == "" {
continue
}
if line == "require (" {
inBlock = true
continue
}
if inBlock && line == ")" {
inBlock = false
continue
}
var modulePath, version string
if inBlock {
parts := strings.Fields(line)
if len(parts) >= 2 {
modulePath, version = parts[0], parts[1]
}
} else if strings.HasPrefix(line, "require ") {
parts := strings.Fields(strings.TrimPrefix(line, "require "))
if len(parts) >= 2 {
modulePath, version = parts[0], parts[1]
}
}
if modulePath == "" {
continue
}
// Indirect deps are still included — they are part of the supply chain.
components = append(components, Component{
Type: "library",
Name: modulePath,
Version: version,
PURL: golangPURL(modulePath, version),
})
}
return components
}
// ─── package.json ─────────────────────────────────────────────────────────────
type packageJSON struct {
Dependencies map[string]string `json:"dependencies"`
DevDependencies map[string]string `json:"devDependencies"`
PeerDependencies map[string]string `json:"peerDependencies"`
}
// ParsePackageJSON parses a package.json and returns npm components.
func ParsePackageJSON(content []byte) []Component {
var pkg packageJSON
if err := json.Unmarshal(content, &pkg); err != nil {
return nil
}
seen := make(map[string]bool)
var components []Component
add := func(name, version, scope string) {
if seen[name] {
return
}
seen[name] = true
// Strip semver range prefixes: ^, ~, >=, >, <=, <, =
clean := strings.TrimLeft(version, "^~>=<")
components = append(components, Component{
Type: "library",
Name: name,
Version: clean,
PURL: npmPURL(name, clean),
Scope: scope,
})
}
for name, ver := range pkg.Dependencies {
add(name, ver, "required")
}
for name, ver := range pkg.DevDependencies {
add(name, ver, "optional")
}
for name, ver := range pkg.PeerDependencies {
add(name, ver, "optional")
}
return components
}
// ─── requirements.txt ────────────────────────────────────────────────────────
// ParseRequirementsTxt parses a pip requirements.txt.
// Handles: pkg==1.0, pkg>=1.0, pkg~=1.0, pkg (no version), comments, extras.
func ParseRequirementsTxt(content []byte) []Component {
var components []Component
scanner := bufio.NewScanner(bytes.NewReader(content))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "-") {
continue
}
// Strip inline comments.
if idx := strings.Index(line, " #"); idx >= 0 {
line = strings.TrimSpace(line[:idx])
}
// Strip extras: package[extra]==1.0 → package, ==1.0
name := line
version := ""
for _, op := range []string{"==", ">=", "<=", "~=", "!=", ">", "<"} {
if idx := strings.Index(line, op); idx >= 0 {
name = strings.TrimSpace(line[:idx])
version = strings.TrimSpace(line[idx+len(op):])
// Take only the first version specifier.
if commaIdx := strings.Index(version, ","); commaIdx >= 0 {
version = version[:commaIdx]
}
break
}
}
// Strip extras [extra1,extra2] from name.
if bIdx := strings.Index(name, "["); bIdx >= 0 {
name = name[:bIdx]
}
name = strings.ToLower(strings.TrimSpace(name))
if name == "" {
continue
}
components = append(components, Component{
Type: "library",
Name: name,
Version: version,
PURL: pypiPURL(name, version),
})
}
return components
}
// ─── Cargo.toml ──────────────────────────────────────────────────────────────
// ParseCargoToml parses a Cargo.toml [dependencies] section.
// Handles: name = "version" and name = { version = "x", ... }.
func ParseCargoToml(content []byte) []Component {
var components []Component
scanner := bufio.NewScanner(bytes.NewReader(content))
inDeps := false
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if strings.HasPrefix(line, "#") {
continue
}
// Section headers.
if strings.HasPrefix(line, "[") {
inDeps = line == "[dependencies]" ||
line == "[dev-dependencies]" ||
line == "[build-dependencies]"
continue
}
if !inDeps || line == "" {
continue
}
eqIdx := strings.Index(line, "=")
if eqIdx < 0 {
continue
}
name := strings.TrimSpace(line[:eqIdx])
rest := strings.TrimSpace(line[eqIdx+1:])
var version string
if strings.HasPrefix(rest, `"`) {
// name = "version"
version = strings.Trim(rest, `"`)
} else if strings.HasPrefix(rest, "{") {
// name = { version = "x", features = [...] }
if vIdx := strings.Index(rest, `version = "`); vIdx >= 0 {
vIdx += len(`version = "`)
endIdx := strings.Index(rest[vIdx:], `"`)
if endIdx >= 0 {
version = rest[vIdx : vIdx+endIdx]
}
}
}
if name == "" {
continue
}
components = append(components, Component{
Type: "library",
Name: name,
Version: version,
PURL: cargoPURL(name, version),
})
}
return components
}
// ─── Gemfile.lock ─────────────────────────────────────────────────────────────
// ParseGemfileLock parses a Gemfile.lock and extracts gem components.
// The GEM section format is:
//
// GEM
// remote: https://rubygems.org/
// specs:
// activesupport (7.1.0)
// ...
func ParseGemfileLock(content []byte) []Component {
var components []Component
scanner := bufio.NewScanner(bytes.NewReader(content))
inSpecs := false
for scanner.Scan() {
line := scanner.Text()
trimmed := strings.TrimSpace(line)
if trimmed == "GEM" {
continue
}
if trimmed == "specs:" {
inSpecs = true
continue
}
// Any non-indented non-empty line ends the specs block.
if inSpecs && !strings.HasPrefix(line, " ") && trimmed != "" {
inSpecs = false
}
if !inSpecs {
continue
}
// Specs entries are indented exactly 4 spaces: " name (version)"
// Sub-dependencies are indented 6+ spaces — skip them.
if !strings.HasPrefix(line, " ") || strings.HasPrefix(line, " ") {
continue
}
// Parse: " gemname (version)"
entry := strings.TrimSpace(line)
oIdx := strings.Index(entry, " (")
if oIdx < 0 {
continue
}
name := entry[:oIdx]
versionFull := strings.TrimSuffix(entry[oIdx+2:], ")")
version := strings.Fields(versionFull)[0]
components = append(components, Component{
Type: "library",
Name: name,
Version: version,
PURL: gemPURL(name, version),
})
}
return components
}
// ─── pom.xml (minimal) ───────────────────────────────────────────────────────
// ParsePomXML does a lightweight line-scan extraction of Maven dependencies.
// It avoids pulling in an XML parser — it looks for <dependency> blocks and
// extracts groupId, artifactId, version tags.
func ParsePomXML(content []byte) []Component {
var components []Component
scanner := bufio.NewScanner(bytes.NewReader(content))
var groupID, artifactID, version string
inDep := false
extract := func(line, tag string) string {
open := "<" + tag + ">"
close := "</" + tag + ">"
sIdx := strings.Index(line, open)
eIdx := strings.Index(line, close)
if sIdx >= 0 && eIdx > sIdx {
return line[sIdx+len(open) : eIdx]
}
return ""
}
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if strings.Contains(line, "<dependency>") {
inDep = true
groupID, artifactID, version = "", "", ""
continue
}
if strings.Contains(line, "</dependency>") {
if inDep && groupID != "" && artifactID != "" {
name := groupID + ":" + artifactID
components = append(components, Component{
Type: "library",
Name: name,
Version: version,
PURL: mavenPURL(groupID, artifactID, version),
})
}
inDep = false
continue
}
if !inDep {
continue
}
if v := extract(line, "groupId"); v != "" {
groupID = v
}
if v := extract(line, "artifactId"); v != "" {
artifactID = v
}
if v := extract(line, "version"); v != "" {
version = v
}
}
return components
}
+293
View File
@@ -0,0 +1,293 @@
package sbom_test
import (
"testing"
"github.com/forgeo/forgebucket/internal/domain/sbom"
)
// ─── go.mod ──────────────────────────────────────────────────────────────────
func TestParseGoMod_Block(t *testing.T) {
content := []byte(`module github.com/example/app
go 1.21
require (
github.com/go-chi/chi/v5 v5.2.5
golang.org/x/crypto v0.50.0 // indirect
)
require github.com/lib/pq v1.12.3
`)
comps := sbom.ParseGoMod(content)
if len(comps) != 3 {
t.Fatalf("expected 3 components, got %d", len(comps))
}
byName := make(map[string]sbom.Component)
for _, c := range comps {
byName[c.Name] = c
}
if c, ok := byName["github.com/go-chi/chi/v5"]; !ok {
t.Error("missing github.com/go-chi/chi/v5")
} else {
if c.Version != "v5.2.5" {
t.Errorf("wrong version: %s", c.Version)
}
if c.PURL != "pkg:golang/github.com/go-chi/chi/v5@v5.2.5" {
t.Errorf("wrong PURL: %s", c.PURL)
}
}
if _, ok := byName["golang.org/x/crypto"]; !ok {
t.Error("missing golang.org/x/crypto (indirect deps must be included)")
}
if _, ok := byName["github.com/lib/pq"]; !ok {
t.Error("missing github.com/lib/pq (single-line require)")
}
}
func TestParseGoMod_Empty(t *testing.T) {
comps := sbom.ParseGoMod([]byte("module foo\n\ngo 1.21\n"))
if len(comps) != 0 {
t.Errorf("expected 0 components, got %d", len(comps))
}
}
// ─── package.json ─────────────────────────────────────────────────────────────
func TestParsePackageJSON(t *testing.T) {
content := []byte(`{
"name": "my-app",
"dependencies": {
"react": "^18.2.0",
"axios": "1.4.0"
},
"devDependencies": {
"vitest": "~1.0.0"
}
}`)
comps := sbom.ParsePackageJSON(content)
if len(comps) != 3 {
t.Fatalf("expected 3 components, got %d", len(comps))
}
byName := make(map[string]sbom.Component)
for _, c := range comps {
byName[c.Name] = c
}
if c, ok := byName["react"]; !ok {
t.Error("missing react")
} else {
if c.Version != "18.2.0" {
t.Errorf("expected version stripped of ^, got %s", c.Version)
}
if c.PURL != "pkg:npm/react@18.2.0" {
t.Errorf("wrong PURL: %s", c.PURL)
}
if c.Scope != "required" {
t.Errorf("expected scope required, got %s", c.Scope)
}
}
if c, ok := byName["vitest"]; !ok {
t.Error("missing vitest")
} else if c.Scope != "optional" {
t.Errorf("devDependency should be optional, got %s", c.Scope)
}
}
func TestParsePackageJSON_Invalid(t *testing.T) {
comps := sbom.ParsePackageJSON([]byte("not json"))
if len(comps) != 0 {
t.Errorf("expected 0 on invalid JSON, got %d", len(comps))
}
}
// ─── requirements.txt ────────────────────────────────────────────────────────
func TestParseRequirementsTxt(t *testing.T) {
content := []byte(`# comment
requests==2.31.0
flask>=2.3.0
numpy~=1.24.0
boto3[s3]==1.28.0 # with extras
no-version-package
-r other-requirements.txt
`)
comps := sbom.ParseRequirementsTxt(content)
byName := make(map[string]sbom.Component)
for _, c := range comps {
byName[c.Name] = c
}
if c, ok := byName["requests"]; !ok {
t.Error("missing requests")
} else if c.Version != "2.31.0" {
t.Errorf("requests version: %s", c.Version)
}
if c, ok := byName["flask"]; !ok {
t.Error("missing flask")
} else if c.Version != "2.3.0" {
t.Errorf("flask version: %s", c.Version)
}
if _, ok := byName["boto3"]; !ok {
t.Error("missing boto3 (extras should be stripped from name)")
}
if _, ok := byName["no-version-package"]; !ok {
t.Error("missing no-version-package")
}
}
// ─── Cargo.toml ──────────────────────────────────────────────────────────────
func TestParseCargoToml(t *testing.T) {
content := []byte(`[package]
name = "my-crate"
version = "0.1.0"
[dependencies]
serde = "1.0"
tokio = { version = "1.28", features = ["full"] }
clap = "4.3"
[dev-dependencies]
criterion = "0.5"
`)
comps := sbom.ParseCargoToml(content)
if len(comps) != 4 {
t.Fatalf("expected 4 components, got %d: %v", len(comps), comps)
}
byName := make(map[string]sbom.Component)
for _, c := range comps {
byName[c.Name] = c
}
if c, ok := byName["serde"]; !ok {
t.Error("missing serde")
} else if c.Version != "1.0" {
t.Errorf("serde version: %s", c.Version)
}
if c, ok := byName["tokio"]; !ok {
t.Error("missing tokio")
} else if c.Version != "1.28" {
t.Errorf("tokio version: %s", c.Version)
}
if _, ok := byName["criterion"]; !ok {
t.Error("missing criterion (dev-dependency)")
}
}
// ─── Gemfile.lock ─────────────────────────────────────────────────────────────
func TestParseGemfileLock(t *testing.T) {
content := []byte(`GEM
remote: https://rubygems.org/
specs:
rails (7.1.0)
actionpack (= 7.1.0)
railties (= 7.1.0)
actionpack (7.1.0)
rake (13.0.6)
PLATFORMS
ruby
DEPENDENCIES
rails (~> 7.1.0)
`)
comps := sbom.ParseGemfileLock(content)
if len(comps) != 3 {
t.Fatalf("expected 3 components, got %d: %v", len(comps), comps)
}
byName := make(map[string]sbom.Component)
for _, c := range comps {
byName[c.Name] = c
}
if c, ok := byName["rails"]; !ok {
t.Error("missing rails")
} else if c.Version != "7.1.0" {
t.Errorf("rails version: %s", c.Version)
}
if c, ok := byName["rake"]; !ok {
t.Error("missing rake")
} else if c.Version != "13.0.6" {
t.Errorf("rake version: %s", c.Version)
}
}
// ─── pom.xml ─────────────────────────────────────────────────────────────────
func TestParsePomXML(t *testing.T) {
content := []byte(`<?xml version="1.0" encoding="UTF-8"?>
<project>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>3.1.0</version>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.6.0</version>
</dependency>
</dependencies>
</project>`)
comps := sbom.ParsePomXML(content)
if len(comps) != 2 {
t.Fatalf("expected 2 components, got %d", len(comps))
}
byName := make(map[string]sbom.Component)
for _, c := range comps {
byName[c.Name] = c
}
if c, ok := byName["org.springframework.boot:spring-boot-starter-web"]; !ok {
t.Error("missing spring-boot-starter-web")
} else {
if c.Version != "3.1.0" {
t.Errorf("spring-boot version: %s", c.Version)
}
if c.PURL != "pkg:maven/org.springframework.boot/spring-boot-starter-web@3.1.0" {
t.Errorf("wrong PURL: %s", c.PURL)
}
}
}
// ─── Document builder ─────────────────────────────────────────────────────────
func TestNewDocument(t *testing.T) {
doc := sbom.NewDocument("my-repo", "abc1234567890")
if doc.BOMFormat != "CycloneDX" {
t.Errorf("BOMFormat: %s", doc.BOMFormat)
}
if doc.SpecVersion != "1.4" {
t.Errorf("SpecVersion: %s", doc.SpecVersion)
}
if doc.Metadata.Component.Name != "my-repo" {
t.Errorf("metadata component name: %s", doc.Metadata.Component.Name)
}
if len(doc.Metadata.Tools) == 0 {
t.Error("expected at least one tool in metadata")
}
if doc.Components == nil {
t.Error("expected non-nil Components slice")
}
}
+173
View File
@@ -0,0 +1,173 @@
package scanning
import (
"context"
"encoding/json"
"fmt"
"log"
"regexp"
"time"
"xorm.io/xorm"
"github.com/forgeo/forgebucket/internal/events"
gitdomain "github.com/forgeo/forgebucket/internal/domain/git"
"github.com/forgeo/forgebucket/internal/models"
)
// compiledPattern is a pre-compiled regex pattern.
type compiledPattern struct {
pattern
re *regexp.Regexp
}
// Scanner subscribes to push.received and scans commit content for secrets.
type Scanner struct {
db *xorm.Engine
bus events.EventBus
patterns []compiledPattern
}
// New creates a Scanner with all patterns pre-compiled.
func New(db *xorm.Engine, bus events.EventBus) (*Scanner, error) {
cp := make([]compiledPattern, 0, len(Patterns))
for _, p := range Patterns {
re, err := regexp.Compile(p.Raw)
if err != nil {
return nil, fmt.Errorf("scanning: compile pattern %q: %w", p.Name, err)
}
cp = append(cp, compiledPattern{pattern: p, re: re})
}
return &Scanner{db: db, bus: bus, patterns: cp}, nil
}
// Start subscribes to push.received and blocks until ctx is cancelled.
func (s *Scanner) Start(ctx context.Context) {
unsub, err := s.bus.Subscribe(events.SubjectPushReceived, func(_ string, data []byte) {
var evt events.PushEvent
if err := json.Unmarshal(data, &evt); err != nil {
log.Printf("scanning: bad push event: %v", err)
return
}
go s.scanPush(evt)
})
if err != nil {
log.Printf("scanning: subscribe: %v", err)
} else {
defer unsub()
}
<-ctx.Done()
}
// scanPush scans the diff between before and after for all patterns.
func (s *Scanner) scanPush(evt events.PushEvent) {
// Branch deletion — nothing to scan.
zeroOID := "0000000000000000000000000000000000000000"
if evt.After == zeroOID {
return
}
// Resolve repo.
var repo models.Repository
if found, err := s.db.ID(evt.RepoID).Get(&repo); err != nil {
log.Printf("scanning: look up repo %d: %v", evt.RepoID, err)
return
} else if !found {
return
}
// Get the diff content between before and after.
diffContent, err := s.getDiff(repo.DiskPath, evt.Before, evt.After)
if err != nil {
log.Printf("scanning: get diff for repo %s: %v", repo.Name, err)
return
}
// Determine the commit SHA for the findings.
headSHA := evt.After
now := time.Now().UTC()
for _, p := range s.patterns {
matches := p.re.FindAllString(string(diffContent), -1)
for _, match := range matches {
// Skip very short matches (likely false positives).
if len(match) < 6 {
continue
}
leak := &models.SecretLeak{
RepoID: evt.RepoID,
CommitSHA: headSHA[:12],
Ref: evt.Ref,
PatternName: p.Name,
Description: p.Description,
Severity: p.Severity,
MatchSample: truncate(match, 40),
DetectedAt: now,
}
if _, err := s.db.Insert(leak); err != nil {
log.Printf("scanning: insert leak for %s: %v", repo.Name, err)
}
}
}
}
// getDiff returns the unified diff of all changes between two refs.
func (s *Scanner) getDiff(repoPath, oldRef, newRef string) ([]byte, error) {
// If oldRef is the zero OID (new branch), diff-tree against the empty tree so
// we get actual file contents rather than ls-tree metadata.
zeroOID := "0000000000000000000000000000000000000000"
if oldRef == zeroOID {
out, err := gitdomain.Run(repoPath, "diff-tree", "--no-commit-id", "-r", "-p", newRef)
if err != nil {
return nil, err
}
return out, nil
}
out, err := gitdomain.Run(repoPath, "diff", "--no-color", "--unified=3", oldRef, newRef)
if err != nil {
return nil, err
}
return out, nil
}
// ListFindings returns all active secret leaks for a repo, newest first.
func (s *Scanner) ListFindings(repoID int64) ([]models.SecretLeak, error) {
var leaks []models.SecretLeak
if err := s.db.Where("repo_id = ? AND dismissed = ?", repoID, false).
OrderBy("detected_at DESC").Find(&leaks); err != nil {
return nil, err
}
if leaks == nil {
leaks = []models.SecretLeak{}
}
return leaks, nil
}
// DismissFindings acknowledges a leak so it no longer appears in active lists.
func (s *Scanner) DismissFindings(leakID int64, dismissedBy string) error {
now := time.Now().UTC()
affected, err := s.db.ID(leakID).Cols("dismissed", "dismissed_by", "dismissed_at").
Update(&models.SecretLeak{
Dismissed: true,
DismissedBy: dismissedBy,
DismissedAt: &now,
})
if err != nil {
return err
}
if affected == 0 {
return fmt.Errorf("leak %d not found", leakID)
}
return nil
}
// truncate shortens a string to maxLen characters for safe display.
func truncate(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "..."
}
+106
View File
@@ -0,0 +1,106 @@
package scanning
// pattern holds a compiled regex-like pattern string and its metadata.
// We use raw string patterns rather than importing regexp for each check;
// the Scanner compiles all patterns once at startup.
type pattern struct {
Name string
Description string
Raw string // the regex pattern (compiled at init)
Severity string // "high", "medium", "low"
}
// Patterns is the list of secret patterns checked against every pushed commit.
// Patterns are ordered by severity — high first.
var Patterns = []pattern{
{
Name: "aws-access-key-id",
Description: "AWS Access Key ID",
Raw: `AKIA[0-9A-Z]{16}`,
Severity: "high",
},
{
Name: "aws-secret-key",
Description: "AWS Secret Access Key",
Raw: `(?i)aws[_-]?(secret|private)[_-]?(access[_-]?)?key['"]?\s*[:=]\s*['"]?[A-Za-z0-9\/+=]{40}`,
Severity: "high",
},
{
Name: "github-token",
Description: "GitHub Personal Access Token",
Raw: `gh[pousr]_[A-Za-z0-9_]{36,}`,
Severity: "high",
},
{
Name: "gitlab-token",
Description: "GitLab Personal Access Token",
Raw: `glpat-[A-Za-z0-9\-_]{20,}`,
Severity: "high",
},
{
Name: "generic-api-key",
Description: "Generic API key assignment (high entropy)",
Raw: `(?i)(api[_-]?key|apikey|api[_-]?secret|api[_-]?token)['"]?\s*[:=]\s*['"][A-Za-z0-9_\-\.]{20,64}`,
Severity: "high",
},
{
Name: "bearer-token",
Description: "Bearer token in HTTP header",
Raw: `(?i)authorization:\s*bearer\s+[A-Za-z0-9_\-\.]{20,}`,
Severity: "high",
},
{
Name: "slack-token",
Description: "Slack Bot / Webhook token",
Raw: `xox[baprs]-[A-Za-z0-9\-]{10,}`,
Severity: "high",
},
{
Name: "google-api-key",
Description: "Google API Key",
Raw: `AIza[0-9A-Za-z\-_]{35}`,
Severity: "high",
},
{
Name: "google-service-account",
Description: "Google Service Account",
Raw: `[0-9]+-[0-9a-z]{32}\.apps\.googleusercontent\.com`,
Severity: "high",
},
{
Name: "ssh-private-key",
Description: "SSH / TLS private key embed",
Raw: `-----BEGIN\s+(RSA|EC|OPENSSH|DSA|PRIVATE)(\s+PRIVATE)?\s+KEY-----`,
Severity: "high",
},
{
Name: "jwt-token",
Description: "JSON Web Token (JWT)",
Raw: `eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}`,
Severity: "medium",
},
{
Name: "generic-password",
Description: "Generic password/secret field assignment",
Raw: `(?i)(password|passwd|pwd|secret)['"]?\s*[:=]\s*['"][A-Za-z0-9!@#$%^&*()_+\-=\[\]{}|;:,.<>?]{8,}`,
Severity: "medium",
},
{
Name: "npm-token",
Description: "npm access token",
Raw: `npm_[A-Za-z0-9]{36,}`,
Severity: "high",
},
{
Name: "pg-connection-string",
Description: "PostgreSQL connection string",
Raw: `postgres(ql)?://[A-Za-z0-9_]+:[^@\s]+@`,
Severity: "high",
},
{
Name: "redis-connection-string",
Description: "Redis connection string with password",
Raw: `redis://[^:@\s]+:[^@\s]+@`,
Severity: "high",
},
}
+118
View File
@@ -0,0 +1,118 @@
package scanning
import (
"regexp"
"testing"
)
func TestPatternsCompile(t *testing.T) {
for _, p := range Patterns {
_, err := regexp.Compile(p.Raw)
if err != nil {
t.Errorf("pattern %q failed to compile: %v", p.Name, err)
}
}
}
func TestPatternsHaveNames(t *testing.T) {
for _, p := range Patterns {
if p.Name == "" {
t.Error("pattern with empty name")
}
if p.Description == "" {
t.Errorf("pattern %q has empty description", p.Name)
}
if p.Severity != "high" && p.Severity != "medium" && p.Severity != "low" {
t.Errorf("pattern %q has invalid severity %q", p.Name, p.Severity)
}
}
}
func TestAWSAccessKey(t *testing.T) {
re := regexp.MustCompile(`AKIA[0-9A-Z]{16}`)
cases := []struct {
input string
match bool
}{
{"AKIAIOSFODNN7EXAMPLE", true},
{"AKIA1234567890123456", true},
{"not-a-key", false},
{"SKIA1234567890123456", false},
}
for _, tc := range cases {
got := re.MatchString(tc.input)
if got != tc.match {
t.Errorf("input %q: got %v, want %v", tc.input, got, tc.match)
}
}
}
func TestGitHubToken(t *testing.T) {
re := regexp.MustCompile(`gh[pousr]_[A-Za-z0-9_]{36,}`)
cases := []struct {
input string
match bool
}{
{"ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", true},
{"gho_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", true},
{"ghu_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", true},
{"ghs_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", true},
{"ghr_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", true},
{"not-a-token", false},
{"ghp_short", false},
}
for _, tc := range cases {
got := re.MatchString(tc.input)
if got != tc.match {
t.Errorf("input %q: got %v, want %v", tc.input, got, tc.match)
}
}
}
func TestPrivateKey(t *testing.T) {
re := regexp.MustCompile(`-----BEGIN\s+(RSA|EC|OPENSSH|DSA|PRIVATE)(\s+PRIVATE)?\s+KEY-----`)
cases := []struct {
input string
match bool
}{
{"-----BEGIN RSA PRIVATE KEY-----", true},
{"-----BEGIN EC PRIVATE KEY-----", true},
{"-----BEGIN OPENSSH PRIVATE KEY-----", true},
{"-----BEGIN DSA PRIVATE KEY-----", true},
{"-----BEGIN PRIVATE KEY-----", true},
{"-----BEGIN CERTIFICATE-----", false},
{"public key is here", false},
}
for _, tc := range cases {
got := re.MatchString(tc.input)
if got != tc.match {
t.Errorf("input %q: got %v, want %v", tc.input, got, tc.match)
}
}
}
func TestJWT(t *testing.T) {
re := regexp.MustCompile(`eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}`)
cases := []struct {
input string
match bool
}{
{"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNnZctV9XjvP_oGZQZxGdAqVxQ", true},
{"not-a-jwt", false},
}
for _, tc := range cases {
got := re.MatchString(tc.input)
if got != tc.match {
t.Errorf("input %q: got %v, want %v", tc.input, got, tc.match)
}
}
}
func TestTruncate(t *testing.T) {
if truncate("hello", 10) != "hello" {
t.Error("should not truncate short strings")
}
if truncate("hello world this is long", 10) != "hello worl..." {
t.Errorf("got %q", truncate("hello world this is long", 10))
}
}
+189
View File
@@ -0,0 +1,189 @@
// Package signing provides ECDSA P-256 artifact signing and verification.
//
// Every artifact uploaded through the API is automatically signed by the
// server's signing key. The resulting Bundle is self-contained: it carries
// the payload JSON, the base64-encoded ASN.1 signature, and the signer's
// public key PEM, so any verifier can reconstruct the check without needing
// access to the server's private key.
package signing
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"log"
"time"
)
// KeyStore holds the server signing key pair.
type KeyStore struct {
privateKey *ecdsa.PrivateKey
publicKeyPEM string
keyID string // 16-char hex fingerprint of the DER public key
}
// New creates a KeyStore from a PEM-encoded ECDSA private key.
func New(privateKeyPEM string) (*KeyStore, error) {
block, _ := pem.Decode([]byte(privateKeyPEM))
if block == nil {
return nil, errors.New("signing: invalid PEM block")
}
key, err := x509.ParseECPrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("signing: parse private key: %w", err)
}
return newFromKey(key)
}
// Generate creates a fresh ephemeral ECDSA P-256 key pair.
// Logs a warning — not suitable for production; use ARTIFACT_SIGNING_KEY env var.
func Generate() (*KeyStore, error) {
log.Println("signing: ARTIFACT_SIGNING_KEY not set — generating ephemeral key (signatures will not survive restart)")
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, fmt.Errorf("signing: generate key: %w", err)
}
return newFromKey(key)
}
func newFromKey(key *ecdsa.PrivateKey) (*KeyStore, error) {
pubDER, err := x509.MarshalPKIXPublicKey(&key.PublicKey)
if err != nil {
return nil, fmt.Errorf("signing: marshal public key: %w", err)
}
pubPEM := string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER}))
sum := sha256.Sum256(pubDER)
keyID := fmt.Sprintf("%x", sum[:8])
return &KeyStore{privateKey: key, publicKeyPEM: pubPEM, keyID: keyID}, nil
}
// PrivateKeyPEM serialises the private key so callers can persist it.
func (ks *KeyStore) PrivateKeyPEM() (string, error) {
der, err := x509.MarshalECPrivateKey(ks.privateKey)
if err != nil {
return "", err
}
return string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der})), nil
}
// PublicKeyPEM returns the signer's PEM public key (embedded in every bundle).
func (ks *KeyStore) PublicKeyPEM() string { return ks.publicKeyPEM }
// KeyID returns the short fingerprint of the public key.
func (ks *KeyStore) KeyID() string { return ks.keyID }
// ─── Bundle types ─────────────────────────────────────────────────────────────
const bundleMediaType = "application/vnd.forgebucket.signature.bundle+json"
// Bundle is the self-contained signature artifact stored alongside each upload.
type Bundle struct {
MediaType string `json:"mediaType"`
Payload BundlePayload `json:"payload"`
Signature string `json:"signature"` // base64(ASN.1 DER ECDSA signature)
PublicKey string `json:"publicKey"` // PEM-encoded ECDSA public key
KeyID string `json:"keyId"`
}
// BundlePayload is the data that was signed (JSON-serialised before hashing).
type BundlePayload struct {
ArtifactID int64 `json:"artifactId"`
Name string `json:"name"`
Digest string `json:"digest"` // "sha256:<hex>"
SignedAt string `json:"signedAt"` // RFC 3339
}
// ─── Sign ─────────────────────────────────────────────────────────────────────
// Sign computes SHA-256(rawContent), builds a BundlePayload, signs
// SHA-256(JSON(payload)) with the private key, and returns the Bundle.
func (ks *KeyStore) Sign(artifactID int64, name string, rawContent []byte) (*Bundle, error) {
contentDigest := sha256.Sum256(rawContent)
payload := BundlePayload{
ArtifactID: artifactID,
Name: name,
Digest: fmt.Sprintf("sha256:%x", contentDigest),
SignedAt: time.Now().UTC().Format(time.RFC3339),
}
payloadJSON, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("signing: marshal payload: %w", err)
}
payloadHash := sha256.Sum256(payloadJSON)
sigDER, err := ecdsa.SignASN1(rand.Reader, ks.privateKey, payloadHash[:])
if err != nil {
return nil, fmt.Errorf("signing: ecdsa sign: %w", err)
}
return &Bundle{
MediaType: bundleMediaType,
Payload: payload,
Signature: base64.StdEncoding.EncodeToString(sigDER),
PublicKey: ks.publicKeyPEM,
KeyID: ks.keyID,
}, nil
}
// ─── Verify ───────────────────────────────────────────────────────────────────
// VerifyResult is returned by both verification functions.
type VerifyResult struct {
Valid bool `json:"valid"`
Digest string `json:"digest"`
SignedAt string `json:"signedAt"`
KeyID string `json:"keyId"`
KeyMatches bool `json:"keyMatchesServer"` // true if bundle public key == server public key
}
// Verify parses bundleJSON, verifies the embedded signature against the
// embedded public key, and returns a VerifyResult.
// The caller should also check KeyMatches to confirm it was signed by this server.
func (ks *KeyStore) Verify(bundleJSON []byte) (*VerifyResult, error) {
var b Bundle
if err := json.Unmarshal(bundleJSON, &b); err != nil {
return nil, fmt.Errorf("signing: parse bundle: %w", err)
}
block, _ := pem.Decode([]byte(b.PublicKey))
if block == nil {
return nil, errors.New("signing: invalid public key PEM in bundle")
}
pubInterface, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("signing: parse public key: %w", err)
}
pub, ok := pubInterface.(*ecdsa.PublicKey)
if !ok {
return nil, errors.New("signing: public key is not ECDSA")
}
payloadJSON, err := json.Marshal(b.Payload)
if err != nil {
return nil, fmt.Errorf("signing: marshal payload: %w", err)
}
payloadHash := sha256.Sum256(payloadJSON)
sigDER, err := base64.StdEncoding.DecodeString(b.Signature)
if err != nil {
return nil, fmt.Errorf("signing: decode signature base64: %w", err)
}
valid := ecdsa.VerifyASN1(pub, payloadHash[:], sigDER)
return &VerifyResult{
Valid: valid,
Digest: b.Payload.Digest,
SignedAt: b.Payload.SignedAt,
KeyID: b.KeyID,
KeyMatches: b.PublicKey == ks.publicKeyPEM,
}, nil
}
+153
View File
@@ -0,0 +1,153 @@
package signing_test
import (
"encoding/json"
"testing"
"github.com/forgeo/forgebucket/internal/domain/signing"
)
func TestGenerateAndSign(t *testing.T) {
ks, err := signing.Generate()
if err != nil {
t.Fatalf("Generate: %v", err)
}
if ks.KeyID() == "" {
t.Fatal("expected non-empty key ID")
}
if ks.PublicKeyPEM() == "" {
t.Fatal("expected non-empty public key PEM")
}
}
func TestSignAndVerify(t *testing.T) {
ks, err := signing.Generate()
if err != nil {
t.Fatalf("Generate: %v", err)
}
content := []byte("hello, forgebucket artifact")
bundle, err := ks.Sign(42, "binary.tar.gz", content)
if err != nil {
t.Fatalf("Sign: %v", err)
}
if bundle.MediaType != "application/vnd.forgebucket.signature.bundle+json" {
t.Errorf("unexpected media type: %s", bundle.MediaType)
}
if bundle.Payload.ArtifactID != 42 {
t.Errorf("artifact ID mismatch: got %d", bundle.Payload.ArtifactID)
}
if bundle.Payload.Name != "binary.tar.gz" {
t.Errorf("name mismatch: got %s", bundle.Payload.Name)
}
if bundle.Payload.Digest == "" {
t.Error("expected non-empty digest")
}
if bundle.Signature == "" {
t.Error("expected non-empty signature")
}
bundleJSON, err := json.Marshal(bundle)
if err != nil {
t.Fatalf("marshal bundle: %v", err)
}
result, err := ks.Verify(bundleJSON)
if err != nil {
t.Fatalf("Verify: %v", err)
}
if !result.Valid {
t.Error("expected valid=true")
}
if !result.KeyMatches {
t.Error("expected keyMatchesServer=true")
}
if result.Digest != bundle.Payload.Digest {
t.Errorf("digest mismatch: %s vs %s", result.Digest, bundle.Payload.Digest)
}
}
func TestVerifyTamperedSignature(t *testing.T) {
ks, _ := signing.Generate()
content := []byte("artifact content")
bundle, err := ks.Sign(1, "file.bin", content)
if err != nil {
t.Fatalf("Sign: %v", err)
}
// Tamper: valid base64 but not a valid ECDSA signature over this payload.
// "Z2FyYmFnZQ==" decodes to "garbage" which is not a valid DER ECDSA sig.
bundle.Signature = "Z2FyYmFnZQ=="
bundleJSON, _ := json.Marshal(bundle)
result, err := ks.Verify(bundleJSON)
if err != nil {
t.Fatalf("Verify should not error on invalid sig: %v", err)
}
if result.Valid {
t.Error("expected valid=false for tampered signature")
}
}
func TestVerifyWrongKey(t *testing.T) {
ks1, _ := signing.Generate()
ks2, _ := signing.Generate()
content := []byte("artifact")
bundle, err := ks1.Sign(10, "tool", content)
if err != nil {
t.Fatalf("Sign: %v", err)
}
bundleJSON, _ := json.Marshal(bundle)
// Verify with ks2 — key won't match.
result, err := ks2.Verify(bundleJSON)
if err != nil {
t.Fatalf("Verify: %v", err)
}
// Cryptographic signature is still valid (uses embedded pub key), but key doesn't match server.
if !result.Valid {
t.Error("signature itself should still be cryptographically valid")
}
if result.KeyMatches {
t.Error("expected keyMatchesServer=false when signed by a different key")
}
}
func TestNewFromPEM(t *testing.T) {
ks1, err := signing.Generate()
if err != nil {
t.Fatalf("Generate: %v", err)
}
pemStr, err := ks1.PrivateKeyPEM()
if err != nil {
t.Fatalf("PrivateKeyPEM: %v", err)
}
ks2, err := signing.New(pemStr)
if err != nil {
t.Fatalf("New from PEM: %v", err)
}
if ks1.KeyID() != ks2.KeyID() {
t.Errorf("key IDs differ: %s vs %s", ks1.KeyID(), ks2.KeyID())
}
// Sign with ks1, verify with ks2 (same underlying key).
bundle, _ := ks1.Sign(5, "bin", []byte("data"))
bundleJSON, _ := json.Marshal(bundle)
result, err := ks2.Verify(bundleJSON)
if err != nil {
t.Fatalf("Verify: %v", err)
}
if !result.Valid {
t.Error("expected valid=true")
}
if !result.KeyMatches {
t.Error("expected keyMatchesServer=true for same key material")
}
}
+45
View File
@@ -0,0 +1,45 @@
package sshserver
import (
"crypto/md5"
"fmt"
"strings"
"golang.org/x/crypto/ssh"
"github.com/forgeo/forgebucket/internal/models"
)
// lookupKey is the SSH PublicKeyCallback. It computes the MD5 fingerprint of
// the presented key (matching the format stored by the SSH key registration
// handler) and looks it up in the database.
func (s *Server) lookupKey(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
fp := fingerprintMD5(key)
var sshKey models.SSHKey
if found, _ := s.db.Where("fingerprint = ?", fp).Get(&sshKey); !found {
return nil, fmt.Errorf("unknown key")
}
// Resolve the username so the session handler can use it for permission checks.
var user models.User
if found, _ := s.db.ID(sshKey.UserID).Get(&user); !found {
return nil, fmt.Errorf("user not found")
}
return &ssh.Permissions{
Extensions: map[string]string{
"username": user.Username,
"user_id": fmt.Sprintf("%d", user.ID),
},
}, nil
}
func fingerprintMD5(pub ssh.PublicKey) string {
hash := md5.Sum(pub.Marshal())
parts := make([]string, len(hash))
for i, b := range hash {
parts[i] = fmt.Sprintf("%02x", b)
}
return strings.Join(parts, ":")
}
+121
View File
@@ -0,0 +1,121 @@
// Package sshserver implements an SSH server for git clone/push/pull operations.
// It authenticates users via their stored SSH public keys and executes
// git-upload-pack / git-receive-pack as subprocesses.
package sshserver
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"fmt"
"log"
"net"
"os"
"golang.org/x/crypto/ssh"
"xorm.io/xorm"
"github.com/forgeo/forgebucket/internal/config"
)
// Server is the SSH git server.
type Server struct {
db *xorm.Engine
cfg *config.Config
}
func New(db *xorm.Engine, cfg *config.Config) *Server {
return &Server{db: db, cfg: cfg}
}
// ListenAndServe binds to cfg.SSHPort, loads or generates a host key, and accepts
// connections until ctx is cancelled. Returns nil when the context is done.
func (s *Server) ListenAndServe(ctx context.Context) error {
hostKey, err := s.loadOrGenerateHostKey()
if err != nil {
return fmt.Errorf("sshserver: host key: %w", err)
}
srvConfig := &ssh.ServerConfig{
PublicKeyCallback: s.lookupKey,
}
srvConfig.AddHostKey(hostKey)
addr := ":" + s.cfg.SSHPort
ln, err := net.Listen("tcp", addr)
if err != nil {
log.Printf("sshserver: cannot bind %s — SSH transport disabled: %v", addr, err)
return nil
}
log.Printf("sshserver: listening on %s", addr)
go func() {
<-ctx.Done()
ln.Close()
}()
for {
conn, err := ln.Accept()
if err != nil {
select {
case <-ctx.Done():
return nil
default:
log.Printf("sshserver: accept: %v", err)
continue
}
}
go s.handleConn(conn, srvConfig)
}
}
func (s *Server) handleConn(netConn net.Conn, srvConfig *ssh.ServerConfig) {
defer netConn.Close()
sshConn, chans, reqs, err := ssh.NewServerConn(netConn, srvConfig)
if err != nil {
return
}
defer sshConn.Close()
go ssh.DiscardRequests(reqs)
username, _ := sshConn.Permissions.Extensions["username"]
for newChan := range chans {
if newChan.ChannelType() != "session" {
newChan.Reject(ssh.UnknownChannelType, "unknown channel type") //nolint:errcheck
continue
}
ch, requests, err := newChan.Accept()
if err != nil {
return
}
go s.handleSession(ch, requests, username)
}
}
// loadOrGenerateHostKey loads the host key from SSHHostKeyPath if set,
// otherwise generates an ephemeral RSA-4096 key (lost on restart).
func (s *Server) loadOrGenerateHostKey() (ssh.Signer, error) {
if s.cfg.SSHHostKeyPath != "" {
data, err := os.ReadFile(s.cfg.SSHHostKeyPath)
if err != nil {
return nil, fmt.Errorf("read host key %s: %w", s.cfg.SSHHostKeyPath, err)
}
return ssh.ParsePrivateKey(data)
}
log.Printf("sshserver: SSH_HOST_KEY_PATH not set — generating ephemeral host key (host key changes on restart)")
privKey, err := rsa.GenerateKey(rand.Reader, 4096)
if err != nil {
return nil, fmt.Errorf("generate host key: %w", err)
}
keyPEM := pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(privKey),
})
return ssh.ParsePrivateKey(keyPEM)
}
+199
View File
@@ -0,0 +1,199 @@
package sshserver
import (
"encoding/binary"
"fmt"
"io"
"log"
"os/exec"
"path/filepath"
"strings"
"golang.org/x/crypto/ssh"
"github.com/forgeo/forgebucket/internal/models"
)
// handleSession processes a single SSH session channel: waits for an exec
// request, dispatches to the appropriate git subcommand, then exits.
func (s *Server) handleSession(ch ssh.Channel, reqs <-chan *ssh.Request, username string) {
defer ch.Close()
for req := range reqs {
if req.Type != "exec" {
if req.WantReply {
req.Reply(false, nil) //nolint:errcheck
}
continue
}
cmdStr, err := parseExecPayload(req.Payload)
if err != nil {
req.Reply(false, nil) //nolint:errcheck
return
}
req.Reply(true, nil) //nolint:errcheck
exitCode := s.runGitCommand(ch, username, cmdStr)
sendExitStatus(ch, uint32(exitCode))
return
}
}
// runGitCommand parses the SSH exec command string, validates it, resolves the
// repo, checks permissions, and runs the git subprocess.
func (s *Server) runGitCommand(ch ssh.Channel, username, cmdStr string) int {
gitCmd, repoArg, err := parseGitCommand(cmdStr)
if err != nil {
fmt.Fprintf(ch.Stderr(), "error: %v\n", err)
return 1
}
// Resolve owner/repo from the path argument (e.g. "/alice/myrepo.git" or "alice/myrepo.git")
path := strings.TrimPrefix(strings.TrimSuffix(repoArg, ".git"), "/")
parts := strings.SplitN(path, "/", 2)
if len(parts) != 2 {
fmt.Fprintf(ch.Stderr(), "error: invalid repository path\n")
return 1
}
ownerName, repoName := parts[0], parts[1]
repo, err := s.resolveRepo(ownerName, repoName)
if err != nil {
fmt.Fprintf(ch.Stderr(), "error: repository not found\n")
return 1
}
// Check permissions.
if gitCmd == "receive-pack" {
if !s.hasPermission(repo, username, "write") {
fmt.Fprintf(ch.Stderr(), "error: you do not have write access to this repository\n")
return 1
}
} else {
// upload-pack: public repos are accessible to all; private repos require read.
if repo.IsPrivate && !s.hasPermission(repo, username, "read") {
fmt.Fprintf(ch.Stderr(), "error: you do not have read access to this repository\n")
return 1
}
}
// Exec the git subcommand against the bare repo path on disk.
// The disk path comes from the DB — never from user input.
cmd := exec.Command("git", gitCmd, repo.DiskPath)
cmd.Dir = filepath.Clean(repo.DiskPath)
cmd.Env = []string{"GIT_TERMINAL_PROMPT=0", "HOME=/tmp"}
cmd.Stdin = ch
cmd.Stdout = ch
cmd.Stderr = ch.Stderr()
if err := cmd.Run(); err != nil {
log.Printf("sshserver: git %s for %s/%s: %v", gitCmd, ownerName, repoName, err)
if exitErr, ok := err.(*exec.ExitError); ok {
return exitErr.ExitCode()
}
return 1
}
return 0
}
// resolveRepo looks up a repository by owner name (user or workspace) and repo name.
func (s *Server) resolveRepo(ownerName, repoName string) (*models.Repository, error) {
var u models.User
if found, _ := s.db.Where("username = ?", ownerName).Get(&u); found {
var repo models.Repository
if found2, _ := s.db.Where("owner_id = ? AND name = ?", u.ID, repoName).Get(&repo); found2 {
return &repo, nil
}
}
var ws models.Workspace
if found, _ := s.db.Where("handle = ?", ownerName).Get(&ws); found {
var repo models.Repository
if found2, _ := s.db.Where("workspace_id = ? AND name = ?", ws.ID, repoName).Get(&repo); found2 {
return &repo, nil
}
}
return nil, fmt.Errorf("not found")
}
// hasPermission checks whether username has at least the required permission on repo.
func (s *Server) hasPermission(repo *models.Repository, username, required string) bool {
var u models.User
if found, _ := s.db.Where("username = ?", username).Get(&u); !found {
return false
}
if u.ID == repo.OwnerID {
return true
}
var m models.RepoMember
if found, _ := s.db.Where("repo_id = ? AND user_id = ?", repo.ID, u.ID).Get(&m); !found {
return false
}
rank := map[string]int{"read": 1, "write": 2, "admin": 3}
return rank[m.Permission] >= rank[required]
}
// parseGitCommand splits the SSH exec command string into the git subcommand
// and the repo path argument. Only upload-pack and receive-pack are permitted.
//
// Accepts both "git-upload-pack '/path'" and "git upload-pack /path" forms.
func parseGitCommand(cmdStr string) (gitCmd string, repoPath string, err error) {
cmdStr = strings.TrimSpace(cmdStr)
var candidate string
var rest string
if strings.HasPrefix(cmdStr, "git-upload-pack") {
candidate = "upload-pack"
rest = strings.TrimPrefix(cmdStr, "git-upload-pack")
} else if strings.HasPrefix(cmdStr, "git-receive-pack") {
candidate = "receive-pack"
rest = strings.TrimPrefix(cmdStr, "git-receive-pack")
} else if strings.HasPrefix(cmdStr, "git upload-pack") {
candidate = "upload-pack"
rest = strings.TrimPrefix(cmdStr, "git upload-pack")
} else if strings.HasPrefix(cmdStr, "git receive-pack") {
candidate = "receive-pack"
rest = strings.TrimPrefix(cmdStr, "git receive-pack")
} else {
return "", "", fmt.Errorf("unsupported command: only git-upload-pack and git-receive-pack are allowed")
}
// Strip surrounding whitespace and single quotes from the path argument.
rest = strings.TrimSpace(rest)
rest = strings.Trim(rest, "'\"")
if rest == "" {
return "", "", fmt.Errorf("missing repository path argument")
}
return candidate, rest, nil
}
// parseExecPayload decodes the SSH exec request payload: 4-byte big-endian
// length followed by the command string.
func parseExecPayload(payload []byte) (string, error) {
if len(payload) < 4 {
return "", fmt.Errorf("exec payload too short")
}
length := binary.BigEndian.Uint32(payload[:4])
if int(length) > len(payload)-4 {
return "", fmt.Errorf("exec payload length mismatch")
}
return string(payload[4 : 4+length]), nil
}
// sendExitStatus sends an SSH exit-status channel request.
func sendExitStatus(ch ssh.Channel, code uint32) {
msg := struct{ Status uint32 }{code}
ch.SendRequest("exit-status", false, ssh.Marshal(msg)) //nolint:errcheck
}
// Stderr returns the stderr stream of an SSH channel.
// The ssh.Channel type embeds io.ReadWriteCloser for stdout/stdin;
// Stderr() is defined on *ssh.channel but not the interface — use a type assertion.
func init() {
// Compile-time interface check: ssh.Channel must have Stderr() method.
var _ interface{ Stderr() io.ReadWriter } = (ssh.Channel)(nil)
}
+140
View File
@@ -0,0 +1,140 @@
package vulnscan
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
const defaultOSVAPI = "https://api.osv.dev/v1"
// Client queries the OSV (Open Source Vulnerabilities) API.
// https://osv.dev/docs/
type Client struct {
baseURL string
http *http.Client
}
// NewClient creates a client that queries the public OSV API.
func NewClient() *Client {
return &Client{
baseURL: defaultOSVAPI,
http: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// QueryRequest is sent to POST /v1/query.
type QueryRequest struct {
Package PackageID `json:"package"`
Version string `json:"version"`
}
// PackageID identifies a package in a specific ecosystem.
type PackageID struct {
PURL string `json:"purl,omitempty"`
Name string `json:"name,omitempty"`
Ecosystem string `json:"ecosystem,omitempty"`
}
// QueryResponse is the response from POST /v1/query.
type QueryResponse struct {
Vulns []OSVVuln `json:"vulns"`
}
// OSVVuln is a vulnerability returned by the OSV API.
type OSVVuln struct {
ID string `json:"id"`
Summary string `json:"summary"`
Details string `json:"details"`
Aliases []string `json:"aliases"`
Fixed string `json:"fixed,omitempty"`
Severity []Severity `json:"severity,omitempty"`
Affected []Affected `json:"affected,omitempty"`
Published string `json:"published,omitempty"`
Modified string `json:"modified,omitempty"`
}
// Severity holds a CVSS score from the OSV response.
type Severity struct {
Type string `json:"type"`
Score string `json:"score"`
}
// Affected describes a package version range.
type Affected struct {
Package PackageID `json:"package"`
Ranges []AffectedRange `json:"ranges"`
Versions []string `json:"versions"`
}
type AffectedRange struct {
Type string `json:"type"`
Events []RangeEvent `json:"events"`
}
type RangeEvent struct {
Introduced string `json:"introduced"`
Fixed string `json:"fixed"`
Limit string `json:"limit"`
}
// QueryByPURL queries OSV for vulnerabilities affecting a given PURL + version.
func (c *Client) QueryByPURL(purl, version string) ([]OSVVuln, error) {
body := QueryRequest{
Package: PackageID{PURL: purl},
Version: version,
}
return c.doQuery(body)
}
// QueryByEcosystem queries OSV for vulnerabilities affecting a package in a
// specific ecosystem (e.g. "npm", "Go", "PyPI", "cargo", "Maven", "RubyGems").
func (c *Client) QueryByEcosystem(ecosystem, name, version string) ([]OSVVuln, error) {
body := QueryRequest{
Package: PackageID{
Name: name,
Ecosystem: ecosystem,
},
Version: version,
}
return c.doQuery(body)
}
func (c *Client) doQuery(body interface{}) ([]OSVVuln, error) {
payload, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("vulnscan: marshal body: %w", err)
}
req, err := http.NewRequest(http.MethodPost, c.baseURL+"/query", bytes.NewReader(payload))
if err != nil {
return nil, fmt.Errorf("vulnscan: create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("vulnscan: query: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("vulnscan: read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("vulnscan: OSV returned %d: %s", resp.StatusCode, string(respBody))
}
var qr QueryResponse
if err := json.Unmarshal(respBody, &qr); err != nil {
return nil, fmt.Errorf("vulnscan: parse response: %w", err)
}
return qr.Vulns, nil
}
+89
View File
@@ -0,0 +1,89 @@
package vulnscan
import (
"encoding/json"
"testing"
)
func TestParseCVSS(t *testing.T) {
v := OSVVuln{
ID: "CVE-2024-0001",
Severity: []Severity{
{Type: "CVSS_V3", Score: "7.5"},
},
}
score := parseCVSS(v)
if score != 7.5 {
t.Errorf("expected 7.5, got %f", score)
}
}
func TestParseCVSS_NoScore(t *testing.T) {
v := OSVVuln{
ID: "GHSA-xxxx",
}
score := parseCVSS(v)
if score != 0 {
t.Errorf("expected 0 for no severity, got %f", score)
}
}
func TestExtractFixedVersion(t *testing.T) {
v := OSVVuln{
Affected: []Affected{
{
Ranges: []AffectedRange{
{
Events: []RangeEvent{
{Introduced: "0"},
{Fixed: "1.2.3"},
},
},
},
},
},
}
fixed := extractFixedVersion(v)
if fixed != "1.2.3" {
t.Errorf("expected 1.2.3, got %s", fixed)
}
}
func TestExtractFixedVersion_None(t *testing.T) {
v := OSVVuln{}
fixed := extractFixedVersion(v)
if fixed != "" {
t.Errorf("expected empty, got %s", fixed)
}
}
func TestTruncateStr(t *testing.T) {
if truncateStr("short", 10) != "short" {
t.Error("should not truncate short strings")
}
if truncateStr("this is a long string", 10) != "this is a ..." {
t.Errorf("got %q", truncateStr("this is a long string", 10))
}
}
func TestNewClient(t *testing.T) {
c := NewClient()
if c.baseURL != defaultOSVAPI {
t.Errorf("baseURL = %s, want %s", c.baseURL, defaultOSVAPI)
}
}
func TestQueryRequest_Marshal(t *testing.T) {
body := QueryRequest{
Package: PackageID{PURL: "pkg:golang/github.com/foo/bar@v1.0.0"},
Version: "v1.0.0",
}
data, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal: %v", err)
}
// Ensure it produces valid JSON.
if len(data) == 0 {
t.Error("empty JSON")
}
}
+180
View File
@@ -0,0 +1,180 @@
package vulnscan
import (
"context"
"encoding/json"
"fmt"
"log"
"time"
"xorm.io/xorm"
"github.com/forgeo/forgebucket/internal/events"
"github.com/forgeo/forgebucket/internal/models"
)
// Scanner watches for SBOM generation events and queries OSV for vulns.
type Scanner struct {
db *xorm.Engine
bus events.EventBus
client *Client
}
func NewScanner(db *xorm.Engine, bus events.EventBus) *Scanner {
return &Scanner{
db: db,
bus: bus,
client: NewClient(),
}
}
// Start subscribes to SBOM-related events and scans for vulnerabilities.
func (s *Scanner) Start(ctx context.Context) {
// Listen for SBOM Report created events (sync trigger).
// In practice this is called on-demand via the API, so Start is minimal.
<-ctx.Done()
}
// ScanByPURL queries OSV for a single package and stores findings.
func (s *Scanner) ScanByPURL(repoID int64, purl, version string) ([]models.VulnerabilityFinding, error) {
vulns, err := s.client.QueryByPURL(purl, version)
if err != nil {
return nil, err
}
return s.persistFindings(repoID, purl, version, vulns), nil
}
// ScanSBOM reads the latest SBOM report for a repo, queries OSV for every
// component, and stores the findings. Returns the new findings.
func (s *Scanner) ScanSBOM(repoID int64) ([]models.VulnerabilityFinding, error) {
var report models.SBOMReport
found, err := s.db.Where("repo_id = ?", repoID).
OrderBy("generated_at DESC").Get(&report)
if err != nil {
return nil, err
}
if !found {
return nil, fmt.Errorf("no SBOM found for repo %d", repoID)
}
var doc struct {
Components []struct {
Name string `json:"name"`
Version string `json:"version"`
PURL string `json:"purl"`
} `json:"components"`
}
if err := json.Unmarshal([]byte(report.BOMDocument), &doc); err != nil {
return nil, fmt.Errorf("parse SBOM: %w", err)
}
var allFindings []models.VulnerabilityFinding
for _, comp := range doc.Components {
if comp.PURL == "" || comp.Version == "" {
continue
}
vulns, err := s.client.QueryByPURL(comp.PURL, comp.Version)
if err != nil {
log.Printf("vulnscan: query %s@%s: %v", comp.PURL, comp.Version, err)
continue
}
findings := s.persistFindings(repoID, comp.PURL, comp.Version, vulns)
allFindings = append(allFindings, findings...)
}
return allFindings, nil
}
// ListFindings returns unfixed vulnerability findings for a repo.
func (s *Scanner) ListFindings(repoID int64) ([]models.VulnerabilityFinding, error) {
var findings []models.VulnerabilityFinding
if err := s.db.Where("repo_id = ? AND dismissed = ?", repoID, false).
OrderBy("cvss_score DESC, detected_at DESC").Find(&findings); err != nil {
return nil, err
}
if findings == nil {
findings = []models.VulnerabilityFinding{}
}
return findings, nil
}
// DismissFindings acknowledges a vulnerability finding.
func (s *Scanner) DismissFindings(findingID int64, dismissedBy string) error {
now := time.Now().UTC()
affected, err := s.db.ID(findingID).Cols("dismissed", "dismissed_by", "dismissed_at").
Update(&models.VulnerabilityFinding{
Dismissed: true,
DismissedBy: dismissedBy,
DismissedAt: &now,
})
if err != nil {
return err
}
if affected == 0 {
return fmt.Errorf("finding %d not found", findingID)
}
return nil
}
func (s *Scanner) persistFindings(repoID int64, purl, version string, vulns []OSVVuln) []models.VulnerabilityFinding {
var findings []models.VulnerabilityFinding
for _, v := range vulns {
// Check for duplicate before inserting.
existing := &models.VulnerabilityFinding{}
if has, _ := s.db.Where("vuln_id = ? AND purl = ? AND repo_id = ?", v.ID, purl, repoID).Get(existing); has {
continue
}
cvssScore := parseCVSS(v)
finding := &models.VulnerabilityFinding{
RepoID: repoID,
VulnID: v.ID,
PURL: purl,
Version: version,
Summary: truncateStr(v.Summary, 300),
Details: v.Details,
CVSSScore: cvssScore,
FixedVersion: extractFixedVersion(v),
DetectedAt: time.Now().UTC(),
}
if _, err := s.db.Insert(finding); err != nil {
log.Printf("vulnscan: insert finding %s for %s: %v", v.ID, purl, err)
continue
}
findings = append(findings, *finding)
}
return findings
}
// parseCVSS extracts the CVSS score from OSV severity info.
func parseCVSS(v OSVVuln) float64 {
for _, sev := range v.Severity {
if sev.Type == "CVSS_V3" || sev.Type == "CVSS_V2" {
var score float64
fmt.Sscanf(sev.Score, "%f", &score)
return score
}
}
return 0
}
// extractFixedVersion tries to extract the fixed version from affected ranges.
func extractFixedVersion(v OSVVuln) string {
for _, a := range v.Affected {
for _, r := range a.Ranges {
for _, e := range r.Events {
if e.Fixed != "" {
return e.Fixed
}
}
}
}
return ""
}
func truncateStr(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}
+4
View File
@@ -16,6 +16,7 @@ import (
type EventBus interface { type EventBus interface {
Publish(subject string, payload any) error Publish(subject string, payload any) error
Subscribe(subject string, handler func(subject string, data []byte)) (func(), error) Subscribe(subject string, handler func(subject string, data []byte)) (func(), error)
Healthy() bool
Close() Close()
} }
@@ -63,6 +64,8 @@ func (b *NATSBus) Subscribe(subject string, handler func(subject string, data []
return func() { sub.Unsubscribe() }, nil //nolint:errcheck return func() { sub.Unsubscribe() }, nil //nolint:errcheck
} }
func (b *NATSBus) Healthy() bool { return b.nc.IsConnected() }
func (b *NATSBus) Close() { func (b *NATSBus) Close() {
if err := b.nc.Drain(); err != nil { if err := b.nc.Drain(); err != nil {
log.Printf("nats: drain: %v", err) log.Printf("nats: drain: %v", err)
@@ -75,6 +78,7 @@ type NoOpBus struct{}
func (NoOpBus) Publish(_ string, _ any) error { return nil } func (NoOpBus) Publish(_ string, _ any) error { return nil }
func (NoOpBus) Subscribe(_ string, _ func(string, []byte)) (func(), error) { return func() {}, nil } func (NoOpBus) Subscribe(_ string, _ func(string, []byte)) (func(), error) { return func() {}, nil }
func (NoOpBus) Healthy() bool { return true }
func (NoOpBus) Close() {} func (NoOpBus) Close() {}
// New returns a NATSBus if url is non-empty, otherwise a NoOpBus. // New returns a NATSBus if url is non-empty, otherwise a NoOpBus.
+23
View File
@@ -79,6 +79,29 @@ type LogChunkEvent struct {
Content string `json:"content"` Content string `json:"content"`
} }
// DeploymentEvent is published on deployment lifecycle transitions.
// It matches the payload shape used by EnvironmentHandler.publishDeployEvent.
type DeploymentEvent struct {
DeploymentID int64 `json:"deploymentId"`
EnvID int64 `json:"envId"`
EnvName string `json:"envName"`
RepoID int64 `json:"repoId"`
SHA string `json:"sha"`
Ref string `json:"ref"`
Status string `json:"status"`
TriggeredBy string `json:"triggeredBy"`
}
// DriftEvent is published when an environment's actual state diverges from desired.
type DriftEvent struct {
EnvID int64 `json:"envId"`
EnvName string `json:"envName"`
RepoID int64 `json:"repoId"`
DesiredSHA string `json:"desiredSha"`
ActualSHA string `json:"actualSha"`
At time.Time `json:"at"`
}
// WSEnvelope wraps any event for delivery over the WebSocket connection. // WSEnvelope wraps any event for delivery over the WebSocket connection.
type WSEnvelope struct { type WSEnvelope struct {
Subject string `json:"subject"` Subject string `json:"subject"`
+22
View File
@@ -2,6 +2,28 @@ package models
import "time" import "time"
// FederationActivity stores all inbound and outbound ActivityPub activities.
type FederationActivity struct {
ID int64 `xorm:"'id' pk autoincr" json:"id"`
ActorAPID string `xorm:"'actor_ap_id' notnull index varchar(500)" json:"actorApId"`
Type string `xorm:"'type' notnull varchar(50)" json:"type"`
ObjectJSON string `xorm:"'object_json' text" json:"objectJson"`
Direction string `xorm:"'direction' notnull varchar(10)" json:"direction"` // inbound|outbound
RemoteActor string `xorm:"'remote_actor' varchar(500)" json:"remoteActor"`
Published time.Time `xorm:"'published' index" json:"published"`
CreatedAt time.Time `xorm:"'created_at' created" json:"createdAt"`
}
// RemoteActor caches public actor documents fetched from remote instances.
type RemoteActor struct {
ID int64 `xorm:"'id' pk autoincr"`
APID string `xorm:"'ap_id' notnull unique varchar(500)"`
InboxURL string `xorm:"'inbox_url' varchar(500)"`
PublicKey string `xorm:"'public_key' text"`
FetchedAt time.Time `xorm:"'fetched_at'"`
CreatedAt time.Time `xorm:"'created_at' created"`
}
type FederationActor struct { type FederationActor struct {
ID int64 `xorm:"'id' pk autoincr" json:"id"` ID int64 `xorm:"'id' pk autoincr" json:"id"`
UserID int64 `xorm:"'user_id' notnull unique index" json:"userId"` UserID int64 `xorm:"'user_id' notnull unique index" json:"userId"`
+32
View File
@@ -0,0 +1,32 @@
package models
import "time"
// GitOpsConfig links an Environment to a branch that serves as its desired state.
// When the HEAD SHA of Branch diverges from ActualSHA, the environment is "drifted".
type GitOpsConfig struct {
ID int64 `xorm:"'id' pk autoincr" json:"id"`
EnvID int64 `xorm:"'env_id' unique notnull index" json:"envId"` // one config per env
RepoID int64 `xorm:"'repo_id' notnull index" json:"repoId"`
Branch string `xorm:"'branch' varchar(255) notnull" json:"branch"` // source-of-truth branch
AutoSync bool `xorm:"'auto_sync' default false" json:"autoSync"` // create deployment on drift
SyncInterval int `xorm:"'sync_interval' default 0" json:"syncInterval"` // seconds; 0 = push-only
SyncStatus string `xorm:"'sync_status' varchar(20) default 'unknown'" json:"syncStatus"` // unknown/synced/drifted/syncing
DesiredSHA string `xorm:"'desired_sha' varchar(40)" json:"desiredSha"` // last known branch HEAD
ActualSHA string `xorm:"'actual_sha' varchar(40)" json:"actualSha"` // SHA of last successful deploy
LastCheckedAt *time.Time `xorm:"'last_checked_at'" json:"lastCheckedAt"`
CreatedAt time.Time `xorm:"'created_at' created" json:"createdAt"`
UpdatedAt time.Time `xorm:"'updated_at' updated" json:"updatedAt"`
}
// GitOpsDriftEvent is an append-only record of each drift detection and its resolution.
type GitOpsDriftEvent struct {
ID int64 `xorm:"'id' pk autoincr" json:"id"`
EnvID int64 `xorm:"'env_id' notnull index" json:"envId"`
RepoID int64 `xorm:"'repo_id' notnull index" json:"repoId"`
DesiredSHA string `xorm:"'desired_sha' varchar(40)" json:"desiredSha"` // SHA that should be deployed
ActualSHA string `xorm:"'actual_sha' varchar(40)" json:"actualSha"` // SHA actually deployed (empty = never)
SyncStatus string `xorm:"'sync_status' varchar(20)" json:"syncStatus"` // drifted/synced/acknowledged
DetectedAt time.Time `xorm:"'detected_at' notnull index" json:"detectedAt"`
ResolvedAt *time.Time `xorm:"'resolved_at'" json:"resolvedAt"`
}
+25 -1
View File
@@ -46,5 +46,29 @@ func Run(engine *xorm.Engine) error {
if err := Run011(engine); err != nil { if err := Run011(engine); err != nil {
return err return err
} }
return Run012(engine) if err := Run012(engine); err != nil {
return err
}
if err := Run013(engine); err != nil {
return err
}
if err := Run014(engine); err != nil {
return err
}
if err := Run015(engine); err != nil {
return err
}
if err := Run016(engine); err != nil {
return err
}
if err := Run017(engine); err != nil {
return err
}
if err := Run018(engine); err != nil {
return err
}
if err := Run019(engine); err != nil {
return err
}
return Run020(engine)
} }
+13
View File
@@ -0,0 +1,13 @@
package migrations
import (
"github.com/forgeo/forgebucket/internal/models"
"xorm.io/xorm"
)
func Run013(engine *xorm.Engine) error {
return engine.Sync2(
&models.GitOpsConfig{},
&models.GitOpsDriftEvent{},
)
}
@@ -0,0 +1,13 @@
package migrations
import (
"github.com/forgeo/forgebucket/internal/models"
"xorm.io/xorm"
)
func Run014(engine *xorm.Engine) error {
return engine.Sync2(
&models.FederationActivity{},
&models.RemoteActor{},
)
}
+10
View File
@@ -0,0 +1,10 @@
package migrations
import (
"github.com/forgeo/forgebucket/internal/models"
"xorm.io/xorm"
)
func Run015(engine *xorm.Engine) error {
return engine.Sync2(&models.ArtifactSignature{})
}
+10
View File
@@ -0,0 +1,10 @@
package migrations
import (
"github.com/forgeo/forgebucket/internal/models"
"xorm.io/xorm"
)
func Run016(engine *xorm.Engine) error {
return engine.Sync2(&models.SBOMReport{})
}
+16
View File
@@ -0,0 +1,16 @@
package migrations
import (
"github.com/forgeo/forgebucket/internal/models"
"xorm.io/xorm"
)
func Run017(engine *xorm.Engine) error {
return engine.Sync2(
&models.OCIRepository{},
&models.OCIManifest{},
&models.OCITag{},
&models.OCIBlob{},
&models.OCIUpload{},
)
}
@@ -0,0 +1,10 @@
package migrations
import (
"github.com/forgeo/forgebucket/internal/models"
"xorm.io/xorm"
)
func Run018(engine *xorm.Engine) error {
return engine.Sync2(&models.SecretLeak{})
}
@@ -0,0 +1,10 @@
package migrations
import (
"github.com/forgeo/forgebucket/internal/models"
"xorm.io/xorm"
)
func Run019(engine *xorm.Engine) error {
return engine.Sync2(&models.VulnerabilityFinding{})
}
@@ -0,0 +1,13 @@
package migrations
import (
"github.com/forgeo/forgebucket/internal/models"
"xorm.io/xorm"
)
func Run020(engine *xorm.Engine) error {
if err := engine.Sync2(&models.Repository{}); err != nil {
return err
}
return engine.Sync2(&models.PullRequest{})
}
+53
View File
@@ -0,0 +1,53 @@
package models
import "time"
// OCIRepository represents a named image repository within the registry.
// Name mirrors the OCI distribution spec "name" component, e.g. "alice/myapp".
type OCIRepository struct {
ID int64 `xorm:"'id' pk autoincr" json:"id"`
RepoID int64 `xorm:"'repo_id' notnull index" json:"repoId"` // FK to Repository (git repo that owns this image)
Name string `xorm:"'name' varchar(255) unique" json:"name"` // e.g. "alice/myapp"
CreatedAt time.Time `xorm:"'created_at' created" json:"createdAt"`
}
// OCIManifest stores a pushed image manifest (OCI or Docker schema2).
// The full manifest JSON is stored in Content so it can be streamed without
// going to disk. Manifests are small (typically <4 KB).
type OCIManifest struct {
ID int64 `xorm:"'id' pk autoincr" json:"id"`
OCIRepoID int64 `xorm:"'oci_repo_id' notnull index" json:"ociRepoId"`
Digest string `xorm:"'digest' varchar(80) notnull" json:"digest"` // "sha256:<hex>"
MediaType string `xorm:"'media_type' varchar(150)" json:"mediaType"`
Size int64 `xorm:"'size'" json:"size"`
Content string `xorm:"'content' text" json:"-"` // raw JSON
PushedAt time.Time `xorm:"'pushed_at' created" json:"pushedAt"`
}
// OCITag maps a mutable tag (e.g. "latest", "v1.2.3") to a manifest digest.
type OCITag struct {
ID int64 `xorm:"'id' pk autoincr" json:"id"`
OCIRepoID int64 `xorm:"'oci_repo_id' notnull index" json:"ociRepoId"`
Name string `xorm:"'name' varchar(128)" json:"name"`
Digest string `xorm:"'digest' varchar(80)" json:"digest"`
UpdatedAt time.Time `xorm:"'updated_at' updated" json:"updatedAt"`
}
// OCIBlob tracks a content-addressable blob. The actual content lives at
// {oci_root}/blobs/sha256/<hex> on the filesystem.
type OCIBlob struct {
ID int64 `xorm:"'id' pk autoincr" json:"id"`
Digest string `xorm:"'digest' varchar(80) unique" json:"digest"`
Size int64 `xorm:"'size'" json:"size"`
CreatedAt time.Time `xorm:"'created_at' created" json:"createdAt"`
}
// OCIUpload tracks an in-progress blob upload session.
type OCIUpload struct {
ID int64 `xorm:"'id' pk autoincr" json:"id"`
UploadID string `xorm:"'upload_id' varchar(64) unique" json:"uploadId"` // UUID used in URL
Name string `xorm:"'name' varchar(255)" json:"name"` // image name
Offset int64 `xorm:"'offset'" json:"offset"`
ExpiresAt time.Time `xorm:"'expires_at'" json:"expiresAt"`
CreatedAt time.Time `xorm:"'created_at' created" json:"createdAt"`
}
+1
View File
@@ -19,6 +19,7 @@ type PullRequest struct {
SourceBranch string `xorm:"'source_branch' notnull varchar(255)" json:"sourceBranch"` SourceBranch string `xorm:"'source_branch' notnull varchar(255)" json:"sourceBranch"`
TargetBranch string `xorm:"'target_branch' default 'main' varchar(255)" json:"targetBranch"` TargetBranch string `xorm:"'target_branch' default 'main' varchar(255)" json:"targetBranch"`
Status PRStatus `xorm:"'status' default 'open' varchar(16)" json:"status"` Status PRStatus `xorm:"'status' default 'open' varchar(16)" json:"status"`
RemoteSource string `xorm:"'remote_source' varchar(500)" json:"remoteSource,omitempty"` // APID of remote fork repo (cross-instance)
CreatedAt time.Time `xorm:"'created_at' created" json:"createdAt"` CreatedAt time.Time `xorm:"'created_at' created" json:"createdAt"`
UpdatedAt time.Time `xorm:"'updated_at' updated" json:"updatedAt"` UpdatedAt time.Time `xorm:"'updated_at' updated" json:"updatedAt"`
} }

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