Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a81bda00e | |||
| ec9a286d33 | |||
| 5147c6bddb | |||
| e7c64e583b | |||
| f658d754a8 | |||
| a7b1fd2ae3 | |||
| ee1b56e833 | |||
| 2d6aabab9f | |||
| 54d6e6be36 | |||
| 7196b9f264 | |||
| f675032786 | |||
| cff6701864 | |||
| 469d900ac8 | |||
| 366941feb1 | |||
| df6d53c12c | |||
| d384af0d9c | |||
| dea58b85b8 | |||
| 994570ca74 | |||
| 77268e2302 | |||
| f99f0e0fc5 | |||
| 91462500f0 | |||
| 822f723ff1 | |||
| ab94775162 | |||
| e360f3697e | |||
| c7df53708c | |||
| 35afa8d8f1 |
@@ -21,7 +21,7 @@ NATS_URL=nats://localhost:4222
|
||||
|
||||
# ─── Federation (ActivityPub) ─────────────────────────────────────────────────
|
||||
# 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
|
||||
|
||||
# ─── OIDC / OAuth2 (optional) ────────────────────────────────────────────────
|
||||
@@ -31,4 +31,17 @@ INSTANCE_NAME=ForgeBucket
|
||||
|
||||
# ─── Dev only ─────────────────────────────────────────────────────────────────
|
||||
# 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
|
||||
|
||||
@@ -26,11 +26,34 @@ INSTANCE_NAME=ForgeBucket
|
||||
# OIDC_CLIENT_ID=
|
||||
# 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) ────────────────────────────────────────────────────────
|
||||
# Leave empty to disable event publishing (no-op mode).
|
||||
# Start NATS with: make docker-up
|
||||
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 ─────────────────────────────────────────────────────────────────
|
||||
# Set to true to disable Secure cookies and enable verbose logging
|
||||
DEBUG=false
|
||||
|
||||
@@ -13,3 +13,4 @@ uploads
|
||||
|
||||
# Database
|
||||
*.db
|
||||
html docs/
|
||||
|
||||
@@ -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)
|
||||
internal/
|
||||
api/
|
||||
router.go — Chi router, all route definitions (~26 routes)
|
||||
middleware/ — auth, CSRF, RBAC, logging
|
||||
handlers/ — one file per domain (repo, pr, issue, auth, user, ssh...)
|
||||
router.go — Chi router, all route definitions (60+ routes)
|
||||
middleware/ — auth.go, csrf.go, rbac.go, audit.go
|
||||
handlers/ — one file per domain area (see Key Files below)
|
||||
domain/
|
||||
git/ — sanitized git binary wrapper (exec.Command only, no shell)
|
||||
federation/ — ActivityPub / ForgeFed (DATA LAYER ONLY — no handlers yet)
|
||||
ci/ — CI orchestrator (EMPTY — Phase 2 stub)
|
||||
models/ — XORM structs + 7 migration files
|
||||
binary.go — Run, Log, Tree, Diff, BlobCat, RevParse, etc.
|
||||
agit.go — AGit ref parsing
|
||||
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
|
||||
events/ — NATS EventBus interface + NATSBus + NoOpBus (Healthy() bool)
|
||||
web/ — //go:embed target for the built React SPA
|
||||
frontend/
|
||||
src/
|
||||
pages/ — 20 route-level page components
|
||||
pages/ — route-level page components
|
||||
components/ — shared UI (AppShell, Sidebar, Header, DiffViewer, etc.)
|
||||
ui/
|
||||
tokens.ts — SINGLE SOURCE OF TRUTH for all design tokens
|
||||
@@ -44,48 +79,27 @@ frontend/
|
||||
|
||||
**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
|
||||
|
||||
Understand the phases before adding code — don't build Phase 3 infrastructure when Phase 2 is incomplete.
|
||||
|
||||
| Phase | Scope | Status |
|
||||
|-------|-------|--------|
|
||||
| 1 | Auth, Git HTTP, repos, PRs, issues, RBAC, webhooks, LFS, design system, 20-page SPA | **Complete** |
|
||||
| 2A | NATS event bus, WebSocket hub upgrade, audit log | **Complete** |
|
||||
| 1 | Auth, Git HTTP, repos, PRs, issues, RBAC, webhooks, LFS, design system | **Complete** |
|
||||
| 2A | NATS event bus, WebSocket hub, audit log | **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** |
|
||||
| 3B | Unified operational timeline | **Complete** |
|
||||
| 3C | Workspaces + secret management hierarchy | **Active** |
|
||||
| 3D | GitOps controller + drift detection | Planned |
|
||||
| 3E | Observability (Prometheus, health sparklines) | Planned |
|
||||
| 3F | Federation handlers (ActivityPub inbox/outbox) | Planned |
|
||||
| 4 | AI diagnostics, signed artifacts, OCI registry, secret/dep scanning | 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`
|
||||
| 3C | Workspaces + secret management (Global → Workspace → Repo → Env) | **Complete** |
|
||||
| 3D | GitOps controller + drift detection + auto-sync | **Complete** |
|
||||
| 3E | Observability (Prometheus `/metrics`, structured `/health`, repo health API) | **Complete** |
|
||||
| 3F | Federation handlers (ActivityPub WebFinger, actor, inbox/outbox, HTTP signatures, Follow/Accept) | **Complete** |
|
||||
| 4 | SBOM generation, secret scanning, vuln scanning, signed artifacts, OCI registry, security page | **Complete** |
|
||||
| 5 | Deployment promotions, rollback visualization | Planned |
|
||||
|
||||
---
|
||||
|
||||
@@ -107,13 +121,19 @@ This rule is non-negotiable. It prevents command injection.
|
||||
### Router / handlers
|
||||
- Chi router. Route definitions in `internal/api/router.go`.
|
||||
- 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
|
||||
- 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.
|
||||
|
||||
### 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
|
||||
- All secrets come from environment variables via `internal/config/`.
|
||||
- Never hardcode secrets, tokens, or credentials anywhere.
|
||||
@@ -121,7 +141,7 @@ This rule is non-negotiable. It prevents command injection.
|
||||
|
||||
### Error handling
|
||||
- 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).
|
||||
|
||||
### 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.
|
||||
|
||||
### Component patterns
|
||||
@@ -155,20 +175,21 @@ All spacing, color, and sizing values must come from `frontend/src/ui/tokens.ts`
|
||||
|
||||
### API calls
|
||||
- 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
|
||||
|
||||
- **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 skipping CSRF** — all mutating routes require it
|
||||
- **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** — if the design requires a new color, discuss it; don't invent one
|
||||
- **No modal-heavy UX** — this platform uses progressive disclosure; avoid deep modal chains
|
||||
- **No YAML-centric UI** — pipeline and environment config should feel operational, not config-file editing
|
||||
- **No arbitrary design values** — `tokens.ts` is the law
|
||||
- **No new color tokens without discussion** — the existing palette covers all cases
|
||||
- **No modal-heavy UX** — progressive disclosure; avoid deep modal chains
|
||||
- **No YAML-centric UI** — pipeline and GitOps 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 |
|
||||
|------|---------|
|
||||
| `internal/api/router.go` | All route definitions — start here for backend |
|
||||
| `internal/models/` | XORM models + migrations — all DB schemas |
|
||||
| `internal/config/config.go` | Env-driven config, required vars |
|
||||
| `internal/domain/git/` | Git binary wrapper — safe exec patterns |
|
||||
| `internal/api/router.go` | All route definitions — start here for backend work |
|
||||
| `internal/api/handlers/repo_lookup.go` | Shared `resolveRepoID` helper |
|
||||
| `internal/models/` | All XORM models + 13 migration files |
|
||||
| `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/components/AppShell.tsx` | Root layout wrapper |
|
||||
| `frontend/src/components/Sidebar.tsx` | 3-state navigation sidebar |
|
||||
| `frontend/src/pages/` | All 20 route-level pages |
|
||||
| `frontend/src/api/` | Typed API client |
|
||||
| `.env.example` | All required environment variables |
|
||||
| `frontend/src/api/client.ts` | Typed API client with CSRF handling |
|
||||
| `.env.example` | All environment variables with documentation |
|
||||
| `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
|
||||
cp .env.example .env # fill SESSION_SECRET and CSRF_SECRET
|
||||
make docker-up # PostgreSQL via Docker Compose
|
||||
make migrate # run XORM migrations
|
||||
make docker-up # PostgreSQL + NATS via Docker Compose
|
||||
make migrate # run XORM migrations (currently 020)
|
||||
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
@@ -9,63 +9,280 @@ Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### In Progress — Phase 3C (Workspaces + Secret management hierarchy)
|
||||
- `Workspace` model — named collaborative namespace (handle, displayName, description, avatarUrl)
|
||||
- `WorkspaceMember` model — user membership with owner/admin/member roles
|
||||
- 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
|
||||
- WorkspacesPage, WorkspacePage, WorkspaceSettingsPage (settings + members)
|
||||
- Workspace switcher in sidebar header
|
||||
- Create repo: workspace owner selector
|
||||
- RepoSecretsPage — write-only secret management per repo and per environment
|
||||
### Planned — Phase 5 (Deployment Promotions + Rollback Visualization)
|
||||
- Deployment promotion workflows (manual + automated)
|
||||
- Rollback visualization and timeline
|
||||
|
||||
---
|
||||
|
||||
## [1.0.0] — 2026-05-13
|
||||
|
||||
Phase 4 complete. SBOM generation, secret scanning, dependency vulnerability scanning, signed artifacts, and OCI registry are operational.
|
||||
|
||||
### 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
|
||||
|
||||
### 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
|
||||
- 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)
|
||||
- `PipelinesPage` — real cross-repo runs feed with status filter tabs
|
||||
## [0.4.0] — 2026-05-11
|
||||
|
||||
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`
|
||||
- `PipelineRunPage` — run detail with topological DAG visualization + step log viewer
|
||||
- `PipelineWaterfall` — rewritten to accept real `PipelineJob[]` data with `needs` graph
|
||||
- Dashboard CI widget — live recent runs replacing "coming soon" placeholder
|
||||
- Command palette — pipeline run results + Pipelines quick-nav
|
||||
- `GET /api/v1/pipelines/runs` — cross-repo recent runs endpoint
|
||||
- Dashboard `recentRuns[]` field added
|
||||
- `PipelineRunPage` — run detail with topological DAG visualization using real `PipelineJob[]` +
|
||||
`needs` graph; step log viewer (collapsible per step, ANSI color, auto-scroll with lock toggle)
|
||||
- `PipelineWaterfall` — rewritten to accept live job data instead of static mock stages
|
||||
- `GET /api/v1/pipelines/runs` — cross-repo recent runs for the dashboard
|
||||
|
||||
### Planned — Phase 3 (GitOps + Observability + Federation)
|
||||
- GitOps controller with reconciliation loops
|
||||
- Environment model + deployment tracking
|
||||
- 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)
|
||||
### Added — Dashboard CI Command Center
|
||||
- Dashboard CI widget replaced "coming soon" with live recent pipeline runs
|
||||
- Dashboard `recentRuns[]` field added to the `/api/v1/dashboard` response
|
||||
|
||||
### Planned — Phase 4
|
||||
- AI diagnostics (pipeline failure root-cause analysis)
|
||||
- Signed artifacts (Sigstore/Cosign)
|
||||
- OCI package registry
|
||||
- Secret and dependency vulnerability scanning
|
||||
### Added — Command Palette Wiring
|
||||
- Pipeline run results surfaced in command palette results
|
||||
- "Pipelines" quick-nav action
|
||||
|
||||
---
|
||||
|
||||
@@ -75,38 +292,35 @@ Phase 2B complete. Full CI/CD execution backend operational.
|
||||
|
||||
### Added — CI Orchestrator (`internal/domain/ci/`)
|
||||
- DAG-based pipeline orchestrator (`orchestrator.go`): subscribes to NATS `push.received`,
|
||||
parses `.forgebucket/workflows/*.yml`, creates `PipelineRun`/`PipelineJob`/`PipelineStep`
|
||||
records, advances DAG on `job.completed`/`job.failed`, recovers stale runs on startup
|
||||
- Docker executor (`executor.go`): runs steps in isolated containers (`docker run --rm`),
|
||||
streams logs to DB and NATS via `pipeline.log` subject, handles `git archive` workspace extraction
|
||||
- Runner manager (`runner_manager.go`): semaphore-limited concurrent job dispatch (default 4),
|
||||
subscribes to `job.queued`, calls executor when Docker is available
|
||||
- DAG engine (`dag.go`): full topological sort (`TopoSort`) and `ReadyJobs` for dependency resolution
|
||||
- Workflow parser (`parser.go`): reads `.forgebucket/workflows/*.yml` from git ref,
|
||||
`MatchesPushTrigger` with glob pattern support
|
||||
- CI types (`types.go`): `WorkflowFile`, `WorkflowJob`, `WorkflowStep`, YAML `StringOrSlice` unmarshaler
|
||||
parses `.forgebucket/workflows/*.yml`, creates `PipelineRun/Job/Step` records, advances
|
||||
DAG on `job.completed/failed`, recovers stale runs on startup
|
||||
- Docker executor (`executor.go`): steps run in isolated containers (`docker run --rm`),
|
||||
logs stream to DB and NATS via `pipeline.log`, workspace extracted via `git archive`
|
||||
- Runner manager (`runner_manager.go`): semaphore-limited (default 4 concurrent),
|
||||
subscribes to `job.queued`, skips gracefully if Docker is unavailable
|
||||
- DAG engine (`dag.go`): `TopoSort`, `ReadyJobs`
|
||||
- Workflow parser (`parser.go`): `.forgebucket/workflows/*.yml` from git ref,
|
||||
`MatchesPushTrigger` with glob branch patterns; `StringOrSlice` YAML unmarshaler
|
||||
|
||||
### Added — CI API Handlers
|
||||
- `GET /api/v1/repos/:owner/:repo/pipelines` — list pipeline definitions
|
||||
- `GET /api/v1/repos/:owner/:repo/runs` — list pipeline runs (most recent first, limit 30)
|
||||
- `GET /api/v1/repos/:owner/:repo/runs/:runID` — run detail with full 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/jobs/:jobID/retry` — re-queue failed/cancelled job
|
||||
- `GET /api/v1/repos/:owner/:repo/runs/:runID/jobs/:jobID/logs` — step-level log chunks
|
||||
- `GET /api/v1/repos/:owner/:repo/runs/:runID/artifacts` — list artifacts for a run
|
||||
- `POST /api/v1/repos/:owner/:repo/runs/:runID/artifacts` — upload artifact (multipart, 512 MB max)
|
||||
- `GET /api/v1/repos/:owner/:repo/artifacts/:artifactID/download` — artifact download with path traversal guard
|
||||
- `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)
|
||||
- `GET /api/v1/repos/:owner/:repo/pipelines` — pipeline definitions
|
||||
- `GET /api/v1/repos/:owner/:repo/runs` — pipeline runs (newest first)
|
||||
- `GET /api/v1/repos/:owner/:repo/runs/:runID` — run detail with job + step tree
|
||||
- `POST /api/v1/repos/:owner/:repo/runs/:runID/cancel`
|
||||
- `POST /api/v1/repos/:owner/:repo/runs/:runID/jobs/:jobID/retry`
|
||||
- `GET /api/v1/repos/:owner/:repo/runs/:runID/jobs/:jobID/logs` — step log chunks
|
||||
- `GET/POST /api/v1/repos/:owner/:repo/runs/:runID/artifacts`
|
||||
- `GET /api/v1/repos/:owner/:repo/artifacts/:artifactID/download` — path-traversal guarded
|
||||
- `GET/POST /api/v1/admin/runners` — runner list + registration (admin-only, bcrypt token)
|
||||
|
||||
### Added — Database Models (migration `009_ci`)
|
||||
- `Pipeline` — workflow definition record (name, filePath, repoId)
|
||||
- `PipelineRun` — execution record (triggerRef, triggerSha, triggeredBy, status, startedAt, finishedAt)
|
||||
- `PipelineJob` — single DAG node (name, image, needs JSON, status, timing)
|
||||
- `PipelineStep` — single command within a job (seq, runCmd, usesAction, exitCode, timing)
|
||||
- `PipelineStepLog` — append-only log chunk storage (stepId, chunkIndex, content)
|
||||
- `Runner` — registered execution backend (name, labels, status, tokenHash, lastSeenAt)
|
||||
- `Artifact` — build artifact (runId, repoId, name, storagePath, size, contentType)
|
||||
- `Pipeline`, `PipelineRun`, `PipelineJob`, `PipelineStep`, `PipelineStepLog`
|
||||
- `Runner` (name, labels, status, tokenHash, lastSeenAt)
|
||||
- `Artifact` (runId, repoId, name, storagePath, size, contentType)
|
||||
|
||||
### Changed — Git HTTP handler
|
||||
- `parseAndCheckBody` replaces `checkProtectionsFromBody` — now also returns parsed
|
||||
`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/`)
|
||||
- `EventBus` interface: `Publish`, `Subscribe`, `Close`
|
||||
- `NATSBus`: NATS-backed implementation with auto-reconnect, max-reconnect disabled
|
||||
- `NoOpBus`: silent fallback when `NATS_URL` is not configured (app fully functional without NATS)
|
||||
- `New(url)` factory: returns `NATSBus` if URL is set, `NoOpBus` otherwise
|
||||
- Event subjects defined in `subjects.go`:
|
||||
- `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`
|
||||
- `NATSBus`: NATS-backed with auto-reconnect; `NoOpBus` fallback when `NATS_URL` unset
|
||||
- `New(url)` factory: returns `NATSBus` or `NoOpBus`
|
||||
- 40+ event subjects in `subjects.go` covering repo, push, PR, issue, pipeline, job,
|
||||
deployment, environment, and audit namespaces
|
||||
|
||||
### Added — WebSocket Hub (`internal/api/handlers/ws.go`)
|
||||
- `GET /ws` — upgrades HTTP to WebSocket (nhooyr.io/websocket)
|
||||
- Subscribes to all NATS subjects on connect, fans events to the client as JSON
|
||||
- Optional session auth (`auth.Optional` middleware) — works for guests too
|
||||
- Phase 2B note: per-user event filtering is a planned upgrade
|
||||
### Added — WebSocket Hub
|
||||
- `GET /ws` — NATS wildcard subscription (`>`) fans all events to connected clients as JSON
|
||||
- `{ subject, payload }` envelope format
|
||||
- Goroutine per client with buffered send channel (64 events); slow clients drop events
|
||||
|
||||
### Added — Audit Log
|
||||
- `AuditLog` model (migration `008_audit_log`): actor, method, path, statusCode, requestBody, ipAddr, timestamp
|
||||
- `AuditLog` middleware: records every authenticated request to the DB and publishes `audit.event`
|
||||
- `GET /api/v1/audit` — paginated audit log query (admin-only, filterable by actor/method/time range)
|
||||
|
||||
### 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)
|
||||
### Added — Audit Log (migration `008_audit_log`)
|
||||
- `AuditLog` model: actorId, actorName, method, path, statusCode, ipAddress, userAgent
|
||||
- Middleware records every POST/PUT/PATCH/DELETE in the protected route group
|
||||
- Writes DB row + publishes `audit.event` asynchronously (never blocks the response)
|
||||
- `GET /api/v1/audit` — paginated, filterable by actor/method/since (admin-only)
|
||||
|
||||
---
|
||||
|
||||
## [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
|
||||
- User registration and login with secure session cookies
|
||||
- CSRF protection on all mutating routes via `X-CSRF-Token` header
|
||||
- Middleware chain: Logger → RealIP → Recoverer → CORS → CSRF → SessionAuth → RBAC → Handler
|
||||
- CSRF protection via double-submit cookie pattern (`X-CSRF-Token`)
|
||||
- SSH key management per user
|
||||
- OIDC / OAuth2 optional integration (configurable via env)
|
||||
- Scoped access tokens with optional expiration dates
|
||||
- Repository deploy keys (read-only or read-write HTTP tokens)
|
||||
- ENV-driven config with fail-fast validation on missing secrets
|
||||
- OIDC / OAuth2 optional integration
|
||||
- Scoped access tokens with optional expiration
|
||||
- Repository deploy keys (read-only or read-write)
|
||||
- ENV-driven config with fail-fast on missing secrets
|
||||
|
||||
### Added — Git Hosting
|
||||
- Smart HTTP transport (git clone, push, pull over HTTP)
|
||||
- AGit protocol support (`refs/for/` push for instant PR creation without branch switching)
|
||||
- Branch management (list, create, delete, default branch configuration)
|
||||
- Commit log and diff viewing
|
||||
- Git LFS per-repository (configurable file size limits, locking)
|
||||
- Branch protection rules (force-push blocking, required reviews)
|
||||
- Smart HTTP transport (clone, push, pull over HTTP)
|
||||
- AGit protocol (`refs/for/` push for instant PR creation)
|
||||
- Branch management, commit log, diff viewing
|
||||
- Git LFS per-repository (configurable file size limits)
|
||||
- Branch protection rules (force-push blocking)
|
||||
- Repository visibility (public / private)
|
||||
|
||||
### Added — Collaboration
|
||||
- Pull requests (open / merged / closed states) with author tracking
|
||||
- Pull requests (open / merged / closed) with author tracking
|
||||
- Issues (open / closed)
|
||||
- Reviewer assignment (default reviewer per repo, per-PR reviewer assignment)
|
||||
- Merge strategy selection per repository (merge commit / squash / rebase)
|
||||
- Reviewer assignment (default reviewer per repo, per-PR overrides)
|
||||
- Merge strategy selection per repository (merge / squash / rebase)
|
||||
- Branching model configuration (feature / bugfix / release / hotfix prefixes)
|
||||
- PR default description templates (per-repo)
|
||||
- Excluded files from diffs (glob pattern configuration)
|
||||
- PR default description templates + excluded-files configuration
|
||||
- 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
|
||||
- React 18 + TypeScript + Vite, embedded into Go binary via `//go:embed`
|
||||
- 20 route-level pages: Login, Register, Dashboard, Repos, CreateRepo, ImportRepo, Repo,
|
||||
RepoSettings, Blob, Commits, Branches, RepoIssues, RepoPRs, CreatePR, PRDetail, Starred,
|
||||
PRs (cross-repo), Pipelines (placeholder), Explore, Profile, Settings
|
||||
- AppShell layout wrapper for all authenticated pages
|
||||
- 20 route-level pages covering auth, dashboard, repos, code, PRs, issues, and settings
|
||||
- Triple-state sidebar: expanded (320px) / collapsed (56px) / mobile bottom bar
|
||||
- Mobile-first responsive design (375px → 1440px)
|
||||
- DiffViewer: side-by-side and unified views with syntax highlighting
|
||||
- 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
|
||||
- DiffViewer (side-by-side + unified), MobileComment (bottom-sheet), TreeBrowser
|
||||
|
||||
### Added — Design System
|
||||
- Custom semantic token palette in `frontend/src/ui/tokens.ts`
|
||||
- Full dark/light mode support via Tailwind CSS v4 `@variant dark`
|
||||
- Brand colors: `#0052CC` (light) / `#3B82F6` (dark)
|
||||
- 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 3–8px, full 9999px)
|
||||
- Full dark/light mode via Tailwind CSS v4 `@variant dark`
|
||||
- 8px grid system; 44px minimum touch targets (WCAG 2.5.5)
|
||||
- System font stack (Segoe UI, Roboto, sans-serif)
|
||||
|
||||
### Added — Infrastructure
|
||||
- PostgreSQL + XORM with 7 migration files covering: users, repositories, issues, SSH keys,
|
||||
access tokens, deploy keys, workflows, and LFS settings
|
||||
- ActivityPub actor data model (FederationActor with inbox/outbox URLs and RSA key pairs) — data layer only
|
||||
- Docker Compose setup for local PostgreSQL + NATS
|
||||
- Makefile targets: dev, build, migrate, test, lint, docker-up
|
||||
- WebSockets foundation for live logs and notifications
|
||||
- PostgreSQL + XORM with migrations 001–007
|
||||
- ActivityPub actor data model (FederationActor) — data layer only
|
||||
- Docker Compose for local PostgreSQL + NATS
|
||||
- Makefile: dev, build, migrate, test, lint, docker-up
|
||||
|
||||
---
|
||||
|
||||
[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.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
|
||||
|
||||
@@ -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.
|
||||
|
||||
**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 |
|
||||
| Access tokens (scoped, expiring) | Done |
|
||||
| Deploy keys | Done |
|
||||
| Audit log | Done |
|
||||
| Audit log (admin-only, filterable) | Done |
|
||||
| Workspaces (multi-tenant namespaces) | Done |
|
||||
|
||||
### Git Hosting
|
||||
| Feature | Status |
|
||||
@@ -59,44 +60,62 @@ ForgeBucket is a self-hosted, federated developer operations platform. Where oth
|
||||
### CI/CD
|
||||
| Feature | Status |
|
||||
|---------|--------|
|
||||
| CI orchestrator (DAG pipeline execution) | Done (Phase 2B) |
|
||||
| Runner manager (Docker backend) | Done (Phase 2B) |
|
||||
| Build artifact storage | Done (Phase 2B) |
|
||||
| Pipeline cancellation + job retry | Done (Phase 2B) |
|
||||
| NATS event bus + WebSocket live push | Done (Phase 2A) |
|
||||
| Pipeline DAG visualization (frontend) | Done (Phase 2C) |
|
||||
| Dashboard CI command center | Done (Phase 2C) |
|
||||
| Pipeline log viewer (per-step, collapsible) | Done (Phase 2C) |
|
||||
| Kubernetes / Firecracker runner backends | Planned (Phase 2D) |
|
||||
| Forgejo Actions gRPC integration | Planned |
|
||||
| NATS event bus + WebSocket live push | Done |
|
||||
| CI orchestrator (DAG pipeline execution) | Done |
|
||||
| Runner manager (Docker backend) | Done |
|
||||
| Build artifact storage + download | Done |
|
||||
| Pipeline cancellation + job retry | Done |
|
||||
| Pipeline log streaming (per-step, NATS) | Done |
|
||||
| Pipeline DAG visualization (frontend) | Done |
|
||||
| Dashboard CI command center | Done |
|
||||
| Pipeline log viewer (collapsible, per-step) | Done |
|
||||
| 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 |
|
||||
| Flaky test detection | Planned |
|
||||
|
||||
### GitOps + Environments
|
||||
### Environments + GitOps
|
||||
| Feature | Status |
|
||||
|---------|--------|
|
||||
| Environment model + deployment tracking | **In progress (Phase 3A)** |
|
||||
| Unified operational timeline | Planned (Phase 3B) |
|
||||
| Secret management hierarchy | Planned (Phase 3C) |
|
||||
| GitOps controller + drift detection | Planned (Phase 3D) |
|
||||
| Deployment promotion workflows | Planned (Phase 3D) |
|
||||
| Rollback visualization | Planned (Phase 3D) |
|
||||
| Canary / blue-green support | Planned (Phase 3D) |
|
||||
| Environment model + deployment tracking | Done |
|
||||
| Deployment status lifecycle API | Done |
|
||||
| Unified operational timeline | Done |
|
||||
| Secret management (Global → Workspace → Repo → Env) | Done |
|
||||
| GitOps controller (drift detection + auto-sync) | Done |
|
||||
| Deployment promotion workflows | Planned |
|
||||
| Rollback visualization | Planned |
|
||||
| Canary / blue-green support | Planned |
|
||||
|
||||
### Observability + Security
|
||||
| Feature | Status |
|
||||
|---------|--------|
|
||||
| Prometheus endpoint + health sparklines | Planned (Phase 3E) |
|
||||
| Secret scanning | Planned (Phase 4) |
|
||||
| Dependency scanning | Planned (Phase 4) |
|
||||
| Signed artifacts (Sigstore/Cosign) | Planned (Phase 4) |
|
||||
| `GET /health` — structured DB + NATS liveness check | Done |
|
||||
| `GET /metrics` — Prometheus endpoint (HTTP + platform metrics) | Done |
|
||||
| HTTP instrumentation middleware (latency histogram, request counter) | Done |
|
||||
| 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
|
||||
| Feature | Status |
|
||||
|---------|--------|
|
||||
| ActivityPub actor model | Done (data layer) |
|
||||
| Federation handlers / inbox / outbox | Planned (Phase 3F) |
|
||||
| Cross-instance pull requests | Planned (Phase 3F) |
|
||||
| ActivityPub actor model | Done |
|
||||
| WebFinger (`/.well-known/webfinger`) | Done |
|
||||
| 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.
|
||||
|
||||
> **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
|
||||
├── API Gateway (Chi router, internal/api/)
|
||||
├── API Gateway (Chi router — internal/api/router.go)
|
||||
├── Auth Service (sessions, CSRF, OIDC — internal/api/handlers/)
|
||||
├── Repository Service (git HTTP, branches, LFS — internal/domain/git/)
|
||||
├── Pull Request Service (PRs, reviews, merge — internal/api/handlers/)
|
||||
├── Issue Service (issues, labels — internal/api/handlers/)
|
||||
├── CI Orchestrator (DAG execution, Docker runner — internal/domain/ci/) ← Phase 2B done
|
||||
├── Event Bus (NATS core, NoOp fallback — internal/events/) ← Phase 2A done
|
||||
├── Federation Layer (ActivityPub actors — internal/domain/federation/) ← Phase 3F stub
|
||||
├── Secret Manager (env-based, scoped tokens — internal/config/)
|
||||
├── Database (PostgreSQL + XORM — internal/models/)
|
||||
└── Web Frontend (React 18 + TypeScript, embedded via //go:embed — web/)
|
||||
├── Issue Service (issues — internal/api/handlers/)
|
||||
├── CI Orchestrator (DAG execution, Docker runner — internal/domain/ci/)
|
||||
├── GitOps Controller (drift detection, auto-sync — internal/domain/gitops/)
|
||||
├── Observability (Prometheus metrics, health — internal/observability/)
|
||||
├── Environment Service (environments, deployments — internal/api/handlers/environment.go)
|
||||
├── Secret Manager (scoped AES-256-GCM — internal/api/handlers/secret.go)
|
||||
├── 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):**
|
||||
```
|
||||
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+ |
|
||||
| Router | Chi |
|
||||
| ORM / Migrations | XORM + PostgreSQL |
|
||||
| Event bus | NATS (core; JetStream planned for Phase 2B durability) |
|
||||
| Real-time | WebSockets (nhooyr.io/websocket) |
|
||||
| CI execution | Docker (`docker run --rm`) |
|
||||
| Event bus | NATS core (`github.com/nats-io/nats.go`) |
|
||||
| Real-time | WebSockets (`nhooyr.io/websocket`) |
|
||||
| CI execution | Docker (`docker run --rm` via `exec.Command`) |
|
||||
| Frontend framework | React 18 + TypeScript |
|
||||
| Build tool | Vite |
|
||||
| Styling | Tailwind CSS v4 |
|
||||
| YAML parsing | `gopkg.in/yaml.v3` (workflow definitions) |
|
||||
| Code editing | CodeMirror |
|
||||
| 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 |
|
||||
|----------|----------|-------------|
|
||||
| `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`) |
|
||||
| `CSRF_SECRET` | Yes | CSRF key, exactly 32 chars (`openssl rand -hex 16`) |
|
||||
| `PORT` | No | HTTP port, default `8080` |
|
||||
| `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_NAME` | No | Display name, default `ForgeBucket` |
|
||||
| `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 3A | Environment model + deployment tracking | Done |
|
||||
| Phase 3B | Unified operational timeline | Done |
|
||||
| Phase 3C | Workspaces + secret management hierarchy | **In progress** |
|
||||
| Phase 3D–F | GitOps/drift, federation, observability | Planned |
|
||||
| Phase 4 | AI diagnostics, signed artifacts, OCI registry, dep scanning | Planned |
|
||||
| Phase 3C | Workspaces + secret management hierarchy (Global → Workspace → Repo → Env) | Done |
|
||||
| Phase 3D | GitOps controller + drift detection + auto-sync | Done |
|
||||
| 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
@@ -19,7 +19,15 @@ import (
|
||||
"github.com/forgeo/forgebucket/internal/db"
|
||||
"github.com/forgeo/forgebucket/internal/domain/ci"
|
||||
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/observability"
|
||||
"github.com/forgeo/forgebucket/internal/models/migrations"
|
||||
"github.com/forgeo/forgebucket/web"
|
||||
)
|
||||
@@ -48,6 +56,12 @@ func main() {
|
||||
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)
|
||||
if err != nil {
|
||||
log.Fatalf("events: %v", err)
|
||||
@@ -67,13 +81,48 @@ func main() {
|
||||
ciCtx, ciCancel := context.WithCancel(context.Background())
|
||||
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)
|
||||
|
||||
runnerMgr := ci.NewRunnerManager(engine, bus, cfg, 4)
|
||||
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{
|
||||
Addr: fmt.Sprintf(":%s", cfg.Port),
|
||||
|
||||
+22
-4
@@ -1,21 +1,36 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:18.3
|
||||
container_name: fb-postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: forgebucket
|
||||
POSTGRES_USER: forgebucket
|
||||
POSTGRES_PASSWORD: forgebucket
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql
|
||||
- fb_pg_data:/var/lib/postgresql
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U forgebucket"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
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:
|
||||
build: .
|
||||
container_name: fb-app
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres:
|
||||
@@ -25,9 +40,12 @@ services:
|
||||
DATABASE_URL: postgres://forgebucket:forgebucket@postgres:5432/forgebucket?sslmode=disable
|
||||
ports:
|
||||
- "8080:8080"
|
||||
- "2222:22"
|
||||
volumes:
|
||||
- repo_data:/var/lib/forgebucket/repos
|
||||
- fb_repo_data:/tmp/forgebucket/repos
|
||||
- fb_oci_data:/tmp/forgebucket/oci
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
repo_data:
|
||||
fb_pg_data:
|
||||
fb_repo_data:
|
||||
fb_oci_data:
|
||||
|
||||
+7
-4
@@ -1,11 +1,9 @@
|
||||
version: "3.9"
|
||||
|
||||
# Dev: only PostgreSQL runs here. Run the Go server locally with `make dev`.
|
||||
# Production: docker compose -f docker-compose.prod.yml up
|
||||
|
||||
services:
|
||||
nats:
|
||||
image: nats:2-alpine
|
||||
image: mirror.gcr.io/nats:2-alpine
|
||||
restart: unless-stopped
|
||||
command: ["-js", "-m", "8222"]
|
||||
ports:
|
||||
@@ -18,7 +16,7 @@ services:
|
||||
retries: 10
|
||||
|
||||
postgres:
|
||||
image: postgres:18
|
||||
image: mirror.gcr.io/postgres:18
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: forgebucket
|
||||
@@ -34,5 +32,10 @@ services:
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
dbgate:
|
||||
image: dbgate/dbgate
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
|
||||
Generated
+5156
File diff suppressed because it is too large
Load Diff
@@ -10,8 +10,25 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"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-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/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",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
|
||||
@@ -40,6 +40,7 @@ const RepoPipelinesPage = lazy(() => import('./pages/RepoPipelinesPage'))
|
||||
const EnvironmentsPage = lazy(() => import('./pages/EnvironmentsPage'))
|
||||
const RepoTimelinePage = lazy(() => import('./pages/RepoTimelinePage'))
|
||||
const RepoSecretsPage = lazy(() => import('./pages/RepoSecretsPage'))
|
||||
const RepoSecurityPage = lazy(() => import('./pages/RepoSecurityPage'))
|
||||
const WorkspacesPage = lazy(() => import('./pages/WorkspacesPage'))
|
||||
const WorkspacePage = lazy(() => import('./pages/WorkspacePage'))
|
||||
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/timeline" element={<S><RepoTimelinePage /></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="starred" element={<S><StarredPage /></S>} />
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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 { getCSRFToken } from '../../api/client'
|
||||
import { Skeleton } from '../../ui/Skeleton'
|
||||
|
||||
interface TreeBrowserProps {
|
||||
@@ -32,23 +35,141 @@ function formatSize(bytes: number): string {
|
||||
}
|
||||
|
||||
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 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 (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 files = entries.filter(e => e.type === 'blob').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 sorted = [...dirs, ...files]
|
||||
|
||||
return (
|
||||
<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 && (
|
||||
<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>
|
||||
@@ -67,6 +188,11 @@ export function TreeBrowser({ owner, repo, ref, path = '' }: TreeBrowserProps) {
|
||||
</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">
|
||||
<colgroup>
|
||||
<col className="w-auto" />
|
||||
@@ -83,7 +209,6 @@ export function TreeBrowser({ owner, repo, ref, path = '' }: TreeBrowserProps) {
|
||||
|
||||
return (
|
||||
<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">
|
||||
<div className="flex items-center gap-2">
|
||||
{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" />
|
||||
</svg>
|
||||
)}
|
||||
<Link
|
||||
to={href}
|
||||
className={isDir ? 'text-[var(--c-brand)] hover:underline font-medium' : 'text-[var(--c-text)] hover:text-[var(--c-brand)]'}
|
||||
>
|
||||
<Link to={href} className={isDir ? 'text-[var(--c-brand)] hover:underline font-medium' : 'text-[var(--c-text)] hover:text-[var(--c-brand)]'}>
|
||||
{entry.name}
|
||||
</Link>
|
||||
{!isDir && entry.size > 0 && (
|
||||
@@ -106,11 +228,9 @@ export function TreeBrowser({ owner, repo, ref, path = '' }: TreeBrowserProps) {
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
{/* Commit message */}
|
||||
<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>
|
||||
</td>
|
||||
{/* Date */}
|
||||
<td className="px-3 py-2 text-xs text-[var(--c-muted)] whitespace-nowrap text-right">
|
||||
{relativeTime(entry.commitDate)}
|
||||
</td>
|
||||
@@ -119,6 +239,7 @@ export function TreeBrowser({ owner, repo, ref, path = '' }: TreeBrowserProps) {
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -43,6 +43,10 @@
|
||||
--c-warning: #FBBF24;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
|
||||
@@ -4,6 +4,8 @@ import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { useRepo, useRepoBlob, useUpdateBlob } from '../api/queries/repos'
|
||||
import { RepoListSkeleton } from '../ui/Skeleton'
|
||||
import { CodeEditor } from '../components/repos/CodeEditor'
|
||||
import { FileSideTree } from '../components/repos/FileSideTree'
|
||||
|
||||
export default function BlobPage() {
|
||||
const { owner = '', repo: repoName = '' } = useParams<{ owner: string; repo: string }>()
|
||||
@@ -14,12 +16,17 @@ export default function BlobPage() {
|
||||
const [commitMsg, setCommitMsg] = useState('')
|
||||
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 filePath = searchParams.get('path') ?? ''
|
||||
const filePath = isNew ? newPath : (searchParams.get('path') ?? '')
|
||||
const fileName = filePath.split('/').pop() ?? filePath
|
||||
const fileExt = fileName.includes('.') ? fileName.split('.').pop() ?? '' : ''
|
||||
|
||||
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 branch = ref || repo?.defaultBranch || 'main'
|
||||
@@ -33,30 +40,55 @@ export default function BlobPage() {
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
if (isNew) {
|
||||
navigate(-1)
|
||||
} else {
|
||||
setEditing(false)
|
||||
setPreview(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCommit() {
|
||||
if (!commitMsg.trim() || !filePath) return
|
||||
const path = isNew ? newPath.trim() : filePath
|
||||
if (!commitMsg.trim() || !path) return
|
||||
await updateBlob.mutateAsync({
|
||||
path: filePath,
|
||||
path,
|
||||
content: editContent,
|
||||
message: commitMsg.trim(),
|
||||
branch,
|
||||
})
|
||||
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>
|
||||
if (isError || !blob) return <div className="p-6 text-sm text-[var(--c-danger)]">File not found.</div>
|
||||
const isEditingState = editing || isNew
|
||||
|
||||
const lines = blob.content.split('\n')
|
||||
const pathParts = filePath.split('/')
|
||||
// For new file, start in edit mode with empty content.
|
||||
if (isNew && !editing && editContent === '') {
|
||||
setEditContent('')
|
||||
setCommitMsg('Add new file')
|
||||
setEditing(true)
|
||||
}
|
||||
|
||||
const pathParts = filePath.split('/').filter(Boolean)
|
||||
const content = blob?.content ?? ''
|
||||
|
||||
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 */}
|
||||
<div className="flex items-center gap-1 text-sm flex-wrap">
|
||||
@@ -76,21 +108,35 @@ export default function BlobPage() {
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
{isNew && <span className="text-[var(--c-muted)] font-semibold">New file</span>}
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
|
||||
{/* 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 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)]">
|
||||
<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" />
|
||||
</svg>
|
||||
{branch}
|
||||
</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)]">/</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" />
|
||||
</svg>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!editing && (
|
||||
{!isEditingState && (
|
||||
<div className="flex items-center gap-1">
|
||||
{isMarkdown && (
|
||||
<button
|
||||
@@ -125,7 +173,7 @@ export default function BlobPage() {
|
||||
Edit
|
||||
</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)]"
|
||||
>
|
||||
Copy
|
||||
@@ -134,15 +182,14 @@ export default function BlobPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{editing ? (
|
||||
{/* Content area */}
|
||||
{isEditingState ? (
|
||||
<div className="flex flex-col">
|
||||
<textarea
|
||||
<CodeEditor
|
||||
value={editContent}
|
||||
onChange={e => setEditContent(e.target.value)}
|
||||
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)]"
|
||||
style={{ minHeight: Math.max(300, lines.length * 20) }}
|
||||
spellCheck={false}
|
||||
onChange={setEditContent}
|
||||
language={isNew ? (newPath.split('.').pop() ?? '') : fileExt}
|
||||
minHeight="400px"
|
||||
/>
|
||||
<div className="p-4 bg-[var(--c-surface-raised)] border-t border-[var(--c-border)] space-y-3">
|
||||
<div>
|
||||
@@ -157,10 +204,10 @@ export default function BlobPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
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"
|
||||
>
|
||||
{updateBlob.isPending ? 'Committing…' : 'Commit changes'}
|
||||
{updateBlob.isPending ? 'Committing…' : isNew ? 'Create file' : 'Commit changes'}
|
||||
</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)]">
|
||||
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-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">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{blob.content}</ReactMarkdown>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse font-mono text-xs">
|
||||
<tbody>
|
||||
{lines.map((line, i) => (
|
||||
<tr key={i} className="hover:bg-[#FFFBDD]">
|
||||
<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>
|
||||
<CodeEditor
|
||||
value={content}
|
||||
language={fileExt}
|
||||
readOnly
|
||||
minHeight="400px"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { useRunDetail, useJobLogs, useCancelRun, useRetryJob } from '../api/queries/pipelines'
|
||||
import { useRunSBOM, getRunSBOMDocumentURL, useGenerateSBOM } from '../api/queries/sbom'
|
||||
import { Skeleton } from '../ui/Skeleton'
|
||||
import { cn } from '../lib/utils'
|
||||
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`
|
||||
}
|
||||
|
||||
// ── 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 {
|
||||
return ref.replace('refs/heads/', '').replace('refs/tags/', '')
|
||||
}
|
||||
@@ -172,7 +254,7 @@ function topoColumns(jobs: JobWithSteps[]): JobWithSteps[][] {
|
||||
const job = nameToJob.get(name)
|
||||
if (!job) return 0
|
||||
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))))
|
||||
depth.set(name, d)
|
||||
return d
|
||||
@@ -357,6 +439,11 @@ export default function PipelineRunPage() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* SBOM section */}
|
||||
{!isLoading && run && (
|
||||
<SBOMSection owner={owner} repo={repo} runId={runIdNum} runStatus={run.status as RunStatus} triggerSha={run.triggerSha} />
|
||||
)}
|
||||
|
||||
{/* DAG + log viewer */}
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
|
||||
|
||||
+100
-12
@@ -4,7 +4,10 @@ import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { useRepo, useRepoTree, useRepoBlob, useRepoBranches } from '../api/queries/repos'
|
||||
import { useEnvironments } from '../api/queries/environments'
|
||||
import { useInstance } from '../api/queries/instance'
|
||||
import { TreeBrowser } from '../components/repos/TreeBrowser'
|
||||
import { RepoContextPanel } from '../components/repos/RepoContextPanel'
|
||||
import { RepoFileSearch } from '../components/repos/RepoFileSearch'
|
||||
import { RepoListSkeleton } from '../ui/Skeleton'
|
||||
import { RepoAvatar } from '../ui/RepoAvatar'
|
||||
import { useRecentRepos } from '../hooks/useRecentRepos'
|
||||
@@ -14,6 +17,8 @@ export default function RepoPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const [showBranches, setShowBranches] = 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 cloneRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
@@ -23,6 +28,7 @@ export default function RepoPage() {
|
||||
const { data: repo, isLoading, isError } = useRepo(owner, repoName)
|
||||
const { data: branches } = useRepoBranches(owner, repoName)
|
||||
const { data: environments } = useEnvironments(owner, repoName)
|
||||
const { data: instance } = useInstance()
|
||||
const { track } = useRecentRepos()
|
||||
useEffect(() => { if (owner && repoName) track(owner, repoName) }, [owner, repoName])
|
||||
|
||||
@@ -42,13 +48,23 @@ export default function RepoPage() {
|
||||
const branch = ref || repo.defaultBranch
|
||||
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) {
|
||||
setSearchParams({ ref: b, ...(path ? { path } : {}) })
|
||||
setShowBranches(false)
|
||||
}
|
||||
|
||||
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 */}
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
@@ -123,17 +139,73 @@ export default function RepoPage() {
|
||||
</svg>
|
||||
</button>
|
||||
{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">
|
||||
<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">
|
||||
<code className="text-xs text-[var(--c-text)] flex-1 truncate">{cloneUrl}</code>
|
||||
<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">
|
||||
|
||||
{/* Clone URL tabs */}
|
||||
<div>
|
||||
<div className="flex gap-1 mb-2">
|
||||
{(['https', 'ssh'] as const).map(tab => (
|
||||
<button
|
||||
onClick={() => navigator.clipboard.writeText(cloneUrl)}
|
||||
className="text-[10px] text-[var(--c-brand)] hover:underline shrink-0"
|
||||
key={tab}
|
||||
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>
|
||||
</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>
|
||||
@@ -141,7 +213,7 @@ export default function RepoPage() {
|
||||
</div>
|
||||
|
||||
{repo.isEmpty ? (
|
||||
<GettingStarted repoName={repoName} branch={branch} cloneUrl={cloneUrl} />
|
||||
<GettingStarted repoName={repoName} branch={branch} cloneUrl={cloneUrl} sshUrl={sshUrl} />
|
||||
) : (
|
||||
<>
|
||||
{/* 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}/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}/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>
|
||||
</div>
|
||||
|
||||
@@ -202,6 +275,15 @@ export default function RepoPage() {
|
||||
</>
|
||||
)}
|
||||
</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 }: {
|
||||
repoName: string; branch: string; cloneUrl: string
|
||||
function GettingStarted({ repoName, branch, cloneUrl, sshUrl }: {
|
||||
repoName: string; branch: string; cloneUrl: string; sshUrl: string
|
||||
}) {
|
||||
return (
|
||||
<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>
|
||||
</div>
|
||||
<div className="px-5 py-5 space-y-6 text-sm">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<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} />
|
||||
</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>
|
||||
<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 />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -342,3 +342,50 @@ export interface ApiError {
|
||||
export interface HealthResponse {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -8,22 +8,31 @@ require (
|
||||
github.com/gorilla/sessions v1.4.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
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
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
nhooyr.io/websocket v1.8.17
|
||||
xorm.io/xorm v1.3.11
|
||||
)
|
||||
|
||||
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/golang/snappy v0.0.4 // indirect
|
||||
github.com/gorilla/securecookie v1.1.2 // 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/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
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
golang.org/x/sys 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
|
||||
)
|
||||
|
||||
@@ -2,6 +2,10 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
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/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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
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.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
|
||||
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/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
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/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||
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/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/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/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/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno=
|
||||
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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.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/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/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/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE=
|
||||
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/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
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.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
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.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
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/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 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/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,33 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"xorm.io/xorm"
|
||||
|
||||
"github.com/forgeo/forgebucket/internal/domain/signing"
|
||||
"github.com/forgeo/forgebucket/internal/models"
|
||||
)
|
||||
|
||||
type ArtifactHandler struct {
|
||||
db *xorm.Engine
|
||||
artifactRoot string
|
||||
keys *signing.KeyStore
|
||||
}
|
||||
|
||||
func NewArtifactHandler(db *xorm.Engine, artifactRoot string) *ArtifactHandler {
|
||||
return &ArtifactHandler{db: db, artifactRoot: artifactRoot}
|
||||
func NewArtifactHandler(db *xorm.Engine, artifactRoot string, keys *signing.KeyStore) *ArtifactHandler {
|
||||
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) {
|
||||
repoID, runID, ok := h.resolveRunIDs(w, r)
|
||||
if !ok {
|
||||
@@ -40,8 +44,8 @@ func (h *ArtifactHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
jsonOK(w, artifacts)
|
||||
}
|
||||
|
||||
// Upload accepts a multipart file upload and stores it as an artifact.
|
||||
// Callers must provide a valid Bearer access token with write scope (runner auth).
|
||||
// Upload accepts a multipart file upload, stores it as an artifact, and
|
||||
// immediately signs it using the server's signing key.
|
||||
func (h *ArtifactHandler) Upload(w http.ResponseWriter, r *http.Request) {
|
||||
repoID, runID, ok := h.resolveRunIDs(w, r)
|
||||
if !ok {
|
||||
@@ -87,8 +91,13 @@ func (h *ArtifactHandler) Upload(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
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 {
|
||||
jsonError(w, "could not read upload", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if _, err := dst.Write(content); err != nil {
|
||||
jsonError(w, "could not write file", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -106,7 +115,7 @@ func (h *ArtifactHandler) Upload(w http.ResponseWriter, r *http.Request) {
|
||||
RepoID: repoID,
|
||||
Name: name,
|
||||
StoragePath: relPath,
|
||||
Size: size,
|
||||
Size: int64(len(content)),
|
||||
ContentType: ct,
|
||||
}
|
||||
if _, err := h.db.Insert(artifact); err != nil {
|
||||
@@ -114,10 +123,38 @@ func (h *ArtifactHandler) Upload(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Sign the artifact and persist the bundle.
|
||||
go h.signArtifact(artifact.ID, name, content)
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
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.
|
||||
func (h *ArtifactHandler) Download(w http.ResponseWriter, r *http.Request) {
|
||||
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))
|
||||
// Ensure the resolved path stays within artifactRoot (traversal guard).
|
||||
if !isUnder(h.artifactRoot, fullPath) {
|
||||
jsonError(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
@@ -155,17 +191,66 @@ func (h *ArtifactHandler) Download(w http.ResponseWriter, r *http.Request) {
|
||||
io.Copy(w, f) //nolint:errcheck
|
||||
}
|
||||
|
||||
func (h *ArtifactHandler) resolveRunIDs(w http.ResponseWriter, r *http.Request) (repoID, runID int64, ok bool) {
|
||||
owner := chi.URLParam(r, "owner")
|
||||
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, 0, false
|
||||
// GetSignature returns the full signature bundle JSON for an artifact.
|
||||
// GET /api/v1/repos/{owner}/{repo}/artifacts/{artifactID}/signature
|
||||
func (h *ArtifactHandler) GetSignature(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 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)
|
||||
|
||||
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 — 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
|
||||
}
|
||||
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)
|
||||
return 0, 0, false
|
||||
}
|
||||
return repo.ID, runID, true
|
||||
return rID, runID, true
|
||||
}
|
||||
|
||||
func isUnder(root, path string) bool {
|
||||
|
||||
@@ -323,19 +323,7 @@ func (h *EnvironmentHandler) UpdateDeploymentStatus(w http.ResponseWriter, r *ht
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func (h *EnvironmentHandler) resolveRepo(w http.ResponseWriter, r *http.Request) (int64, bool) {
|
||||
owner := chi.URLParam(r, "owner")
|
||||
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
|
||||
return resolveRepoID(h.db, w, r)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
h.bus.Publish(subject, deployEventPayload{ //nolint:errcheck
|
||||
h.bus.Publish(subject, events.DeploymentEvent{ //nolint:errcheck
|
||||
DeploymentID: d.ID,
|
||||
EnvID: env.ID,
|
||||
EnvName: env.Name,
|
||||
RepoID: d.RepoID,
|
||||
SHA: d.SHA,
|
||||
Ref: d.Ref,
|
||||
Status: d.Status,
|
||||
Status: string(d.Status),
|
||||
TriggeredBy: d.TriggeredBy,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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) {
|
||||
ownerName := chi.URLParam(r, "owner")
|
||||
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
|
||||
return resolveRepoID(h.db, w, r)
|
||||
}
|
||||
|
||||
func (h *IssueHandler) lookupIssue(w http.ResponseWriter, r *http.Request) (*models.Issue, bool) {
|
||||
|
||||
@@ -48,19 +48,13 @@ type deployKeyResponse struct {
|
||||
}
|
||||
|
||||
func (h *DeployKeyHandler) lookupRepo(w http.ResponseWriter, r *http.Request) (*models.Repository, *models.User, bool) {
|
||||
ownerName := chi.URLParam(r, "owner")
|
||||
repoName := chi.URLParam(r, "repo")
|
||||
repo, ok := resolveRepo(h.db, w, r)
|
||||
if !ok {
|
||||
return nil, nil, false
|
||||
}
|
||||
var owner models.User
|
||||
if found, _ := h.db.Where("username = ?", ownerName).Get(&owner); !found {
|
||||
jsonError(w, "repository not found", http.StatusNotFound)
|
||||
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
|
||||
h.db.ID(repo.OwnerID).Get(&owner)
|
||||
return repo, &owner, true
|
||||
}
|
||||
|
||||
func (h *DeployKeyHandler) canManage(repo *models.Repository, callerID int64) bool {
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"xorm.io/xorm"
|
||||
|
||||
"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 (h *LFSHandler) resolveRepo(w http.ResponseWriter, r *http.Request) (*models.Repository, bool) {
|
||||
ownerName := chi.URLParam(r, "owner")
|
||||
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
|
||||
return resolveRepo(h.db, w, r)
|
||||
}
|
||||
|
||||
func (h *LFSHandler) canManage(repo *models.Repository, callerID int64) bool {
|
||||
|
||||
@@ -28,22 +28,15 @@ type memberResponse struct {
|
||||
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) {
|
||||
ownerName := chi.URLParam(r, "owner")
|
||||
repoName := chi.URLParam(r, "repo")
|
||||
|
||||
repo, ok := resolveRepo(h.db, w, r)
|
||||
if !ok {
|
||||
return nil, nil, false
|
||||
}
|
||||
var owner models.User
|
||||
if found, _ := h.db.Where("username = ?", ownerName).Get(&owner); !found {
|
||||
jsonError(w, "repository not found", http.StatusNotFound)
|
||||
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
|
||||
h.db.ID(repo.OwnerID).Get(&owner)
|
||||
return repo, &owner, true
|
||||
}
|
||||
|
||||
// callerCanManage returns true if callerID is the repo owner or has admin permission.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -247,19 +247,7 @@ func (h *PipelineHandler) RetryJob(w http.ResponseWriter, r *http.Request) {
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func (h *PipelineHandler) repoID(w http.ResponseWriter, r *http.Request) (int64, bool) {
|
||||
owner := chi.URLParam(r, "owner")
|
||||
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
|
||||
return resolveRepoID(h.db, w, r)
|
||||
}
|
||||
|
||||
func (h *PipelineHandler) lookupRun(w http.ResponseWriter, r *http.Request) (*models.PipelineRun, bool) {
|
||||
|
||||
@@ -18,29 +18,7 @@ func NewPRSettingsHandler(db *xorm.Engine) *PRSettingsHandler {
|
||||
}
|
||||
|
||||
func (h *PRSettingsHandler) resolveRepo(w http.ResponseWriter, r *http.Request) (*models.Repository, bool) {
|
||||
ownerName := chi.URLParam(r, "owner")
|
||||
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
|
||||
return resolveRepo(h.db, w, r)
|
||||
}
|
||||
|
||||
func (h *PRSettingsHandler) canManage(repo *models.Repository, callerID int64) bool {
|
||||
|
||||
@@ -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) {
|
||||
ownerName := chi.URLParam(r, "owner")
|
||||
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
|
||||
return resolveRepoID(h.db, w, r)
|
||||
}
|
||||
|
||||
func (h *PRHandler) lookupPR(w http.ResponseWriter, r *http.Request) (*models.PullRequest, bool) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -650,3 +653,148 @@ func (h *RepoHandler) lookupRepo(w http.ResponseWriter, r *http.Request) (*model
|
||||
jsonError(w, "repository not found", http.StatusNotFound)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -242,29 +242,7 @@ func ResolveSecretsForRun(db *xorm.Engine, repoID, workspaceID, envID int64, ses
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func (h *SecretHandler) resolveRepoID(w http.ResponseWriter, r *http.Request) (int64, bool) {
|
||||
owner := chi.URLParam(r, "owner")
|
||||
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
|
||||
return resolveRepoID(h.db, w, r)
|
||||
}
|
||||
|
||||
func (h *SecretHandler) resolveEnvID(w http.ResponseWriter, r *http.Request) (int64, bool) {
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"xorm.io/xorm"
|
||||
|
||||
gitdomain "github.com/forgeo/forgebucket/internal/domain/git"
|
||||
@@ -69,23 +68,13 @@ type TimelineEvent struct {
|
||||
//
|
||||
// GET /api/v1/repos/:owner/:repo/timeline?limit=60
|
||||
func (h *TimelineHandler) GetTimeline(w http.ResponseWriter, r *http.Request) {
|
||||
owner := chi.URLParam(r, "owner")
|
||||
repoName := chi.URLParam(r, "repo")
|
||||
|
||||
limit := 60
|
||||
if l, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil && l > 0 && l <= 200 {
|
||||
limit = l
|
||||
}
|
||||
|
||||
// ── Resolve repo ──────────────────────────────────────────────────────────
|
||||
var u models.User
|
||||
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)
|
||||
repo, ok := resolveRepo(h.db, w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -30,19 +30,7 @@ type accessTokenResponse struct {
|
||||
}
|
||||
|
||||
func (h *AccessTokenHandler) lookupRepo(w http.ResponseWriter, r *http.Request) (*models.Repository, bool) {
|
||||
ownerName := chi.URLParam(r, "owner")
|
||||
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
|
||||
return resolveRepo(h.db, w, r)
|
||||
}
|
||||
|
||||
func (h *AccessTokenHandler) canManage(repo *models.Repository, callerID int64) bool {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -55,19 +55,7 @@ func toWebhookResp(wh models.Webhook) webhookResponse {
|
||||
}
|
||||
|
||||
func (h *WebhookHandler) resolveRepo(w http.ResponseWriter, r *http.Request) (*models.Repository, bool) {
|
||||
ownerName := chi.URLParam(r, "owner")
|
||||
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
|
||||
return resolveRepo(h.db, w, r)
|
||||
}
|
||||
|
||||
func (h *WebhookHandler) canManage(repo *models.Repository, callerID int64) bool {
|
||||
|
||||
@@ -22,19 +22,13 @@ type WorkflowHandler struct{ db *xorm.Engine }
|
||||
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) {
|
||||
ownerName := chi.URLParam(r, "owner")
|
||||
repoName := chi.URLParam(r, "repo")
|
||||
repo, ok := resolveRepo(h.db, w, r)
|
||||
if !ok {
|
||||
return nil, nil, false
|
||||
}
|
||||
var owner models.User
|
||||
if found, _ := h.db.Where("username = ?", ownerName).Get(&owner); !found {
|
||||
jsonError(w, "repository not found", http.StatusNotFound)
|
||||
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
|
||||
h.db.ID(repo.OwnerID).Get(&owner)
|
||||
return repo, &owner, true
|
||||
}
|
||||
|
||||
func (h *WorkflowHandler) canManage(repo *models.Repository, callerID int64) bool {
|
||||
|
||||
+74
-7
@@ -14,18 +14,27 @@ import (
|
||||
"github.com/gorilla/sessions"
|
||||
"xorm.io/xorm"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
|
||||
"github.com/forgeo/forgebucket/internal/api/handlers"
|
||||
"github.com/forgeo/forgebucket/internal/api/middleware"
|
||||
"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/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.Use(chimiddleware.Logger)
|
||||
r.Use(chimiddleware.RealIP)
|
||||
r.Use(chimiddleware.Recoverer)
|
||||
r.Use(observability.Middleware())
|
||||
r.Use(cors.Handler(cors.Options{
|
||||
AllowedOrigins: []string{"http://localhost:5173", cfg.InstanceURL},
|
||||
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)
|
||||
dashH := handlers.NewDashboardHandler(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)
|
||||
gitopsH := handlers.NewGitOpsHandler(engine, bus)
|
||||
fedH := handlers.NewFederationHandler(engine, cfg)
|
||||
envH := handlers.NewEnvironmentHandler(engine, bus)
|
||||
timelineH := handlers.NewTimelineHandler(engine, cfg.RepoRoot)
|
||||
workspaceH := handlers.NewWorkspaceHandler(engine, cfg)
|
||||
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 ───────────────────────────────────────────────
|
||||
// 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)
|
||||
})
|
||||
|
||||
// ── 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) {
|
||||
|
||||
// ── Public ────────────────────────────────────────────────────────────
|
||||
r.Get("/explore/repos", exploreH.Repos)
|
||||
r.Get("/explore/users", exploreH.Users)
|
||||
|
||||
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"status":"ok"}`))
|
||||
})
|
||||
r.Get("/instance", instanceH.Get)
|
||||
|
||||
// Generates a CSRF token + cookie. SPA calls this once on load.
|
||||
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("/dashboard", dashH.Get)
|
||||
r.Get("/audit", auditH.List)
|
||||
r.Get("/secrets/leaks", scanH.ListAllSecrets)
|
||||
r.Get("/vulnerabilities", vulnH.ListAll)
|
||||
r.Get("/pipelines/runs", pipeH.ListRecentRuns)
|
||||
|
||||
// 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.Get("/commits", repoH.Commits)
|
||||
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.Route("/pulls", func(r chi.Router) {
|
||||
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.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}/signature", artifactH.GetSignature)
|
||||
r.Get("/artifacts/{artifactID}/verify", artifactH.VerifySignature)
|
||||
r.Route("/members", func(r chi.Router) {
|
||||
r.Get("/", memberH.List)
|
||||
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.With(csrf).Post("/secrets", secretH.UpsertRepoSecret)
|
||||
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.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.Get("/", envH.ListEnvironments)
|
||||
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.With(csrf).Post("/secrets", secretH.UpsertEnvSecret)
|
||||
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)
|
||||
|
||||
// ── 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 production the built React app is embedded and served from staticFiles.
|
||||
if cfg.Debug {
|
||||
|
||||
@@ -30,10 +30,25 @@ type Config struct {
|
||||
// Event bus
|
||||
NATSUrl string
|
||||
|
||||
// GitOps
|
||||
GitOpsReconcileInterval int // seconds between periodic drift checks; 0 disables
|
||||
|
||||
// Federation
|
||||
InstanceURL 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
|
||||
Debug bool
|
||||
}
|
||||
@@ -47,6 +62,7 @@ func Load() (*Config, error) {
|
||||
Debug: getEnvBool("DEBUG", false),
|
||||
|
||||
NATSUrl: getEnv("NATS_URL", ""),
|
||||
GitOpsReconcileInterval: getEnvInt("GITOPS_RECONCILE_INTERVAL", 300),
|
||||
InstanceURL: getEnv("INSTANCE_URL", ""),
|
||||
InstanceName: getEnv("INSTANCE_NAME", "ForgeBucket"),
|
||||
}
|
||||
@@ -57,6 +73,14 @@ func Load() (*Config, error) {
|
||||
cfg.SessionSecret = requireEnv("SESSION_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
|
||||
cfg.OIDCIssuer = os.Getenv("OIDC_ISSUER")
|
||||
cfg.OIDCClientID = os.Getenv("OIDC_CLIENT_ID")
|
||||
@@ -91,6 +115,18 @@ func getEnv(key, fallback string) string {
|
||||
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 {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"xorm.io/xorm"
|
||||
|
||||
"github.com/forgeo/forgebucket/internal/domain/sbom"
|
||||
"github.com/forgeo/forgebucket/internal/events"
|
||||
"github.com/forgeo/forgebucket/internal/models"
|
||||
)
|
||||
@@ -19,10 +20,11 @@ import (
|
||||
type Orchestrator struct {
|
||||
db *xorm.Engine
|
||||
bus events.EventBus
|
||||
sbomGen *sbom.Generator
|
||||
}
|
||||
|
||||
func NewOrchestrator(db *xorm.Engine, bus events.EventBus) *Orchestrator {
|
||||
return &Orchestrator{db: db, bus: bus}
|
||||
func NewOrchestrator(db *xorm.Engine, bus events.EventBus, sbomGen *sbom.Generator) *Orchestrator {
|
||||
return &Orchestrator{db: db, bus: bus, sbomGen: sbomGen}
|
||||
}
|
||||
|
||||
// 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.
|
||||
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{
|
||||
RunID: run.ID,
|
||||
Name: jobName,
|
||||
@@ -231,6 +237,9 @@ func (o *Orchestrator) advanceDAG(runID, jobID int64, result string) {
|
||||
run.FinishedAt = &now
|
||||
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
|
||||
if o.sbomGen != nil {
|
||||
go o.sbomGen.GenerateOnDemand(run.RepoID, run.ID, run.TriggerSHA)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -3,9 +3,12 @@ package git
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -250,6 +253,84 @@ func WriteFile(repoPath, branch, filePath, content, authorName, authorEmail, mes
|
||||
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 {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
@@ -283,6 +364,23 @@ func RepoSize(repoPath string) int64 {
|
||||
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.
|
||||
func SetDefaultBranch(repoPath, branch string) error {
|
||||
_, err := run(repoPath, "symbolic-ref", "HEAD", "refs/heads/"+branch)
|
||||
@@ -450,3 +548,237 @@ func parseUnifiedDiff(raw string) []FileDiff {
|
||||
commit()
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 == '/'
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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] + "..."
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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, ":")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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] + "..."
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
type EventBus interface {
|
||||
Publish(subject string, payload any) error
|
||||
Subscribe(subject string, handler func(subject string, data []byte)) (func(), error)
|
||||
Healthy() bool
|
||||
Close()
|
||||
}
|
||||
|
||||
@@ -63,6 +64,8 @@ func (b *NATSBus) Subscribe(subject string, handler func(subject string, data []
|
||||
return func() { sub.Unsubscribe() }, nil //nolint:errcheck
|
||||
}
|
||||
|
||||
func (b *NATSBus) Healthy() bool { return b.nc.IsConnected() }
|
||||
|
||||
func (b *NATSBus) Close() {
|
||||
if err := b.nc.Drain(); err != nil {
|
||||
log.Printf("nats: drain: %v", err)
|
||||
@@ -75,6 +78,7 @@ type NoOpBus struct{}
|
||||
|
||||
func (NoOpBus) Publish(_ string, _ any) error { return nil }
|
||||
func (NoOpBus) Subscribe(_ string, _ func(string, []byte)) (func(), error) { return func() {}, nil }
|
||||
func (NoOpBus) Healthy() bool { return true }
|
||||
func (NoOpBus) Close() {}
|
||||
|
||||
// New returns a NATSBus if url is non-empty, otherwise a NoOpBus.
|
||||
|
||||
@@ -79,6 +79,29 @@ type LogChunkEvent struct {
|
||||
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.
|
||||
type WSEnvelope struct {
|
||||
Subject string `json:"subject"`
|
||||
|
||||
@@ -2,6 +2,28 @@ package models
|
||||
|
||||
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 {
|
||||
ID int64 `xorm:"'id' pk autoincr" json:"id"`
|
||||
UserID int64 `xorm:"'user_id' notnull unique index" json:"userId"`
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -46,5 +46,29 @@ func Run(engine *xorm.Engine) error {
|
||||
if err := Run011(engine); err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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{},
|
||||
)
|
||||
}
|
||||
@@ -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{})
|
||||
}
|
||||
@@ -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{})
|
||||
}
|
||||
@@ -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{})
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -19,6 +19,7 @@ type PullRequest struct {
|
||||
SourceBranch string `xorm:"'source_branch' notnull varchar(255)" json:"sourceBranch"`
|
||||
TargetBranch string `xorm:"'target_branch' default 'main' varchar(255)" json:"targetBranch"`
|
||||
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"`
|
||||
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
Reference in New Issue
Block a user