added signed artifacts and SBOM generation capabilities
This commit is contained in:
@@ -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,6 +191,63 @@ func (h *ArtifactHandler) Download(w http.ResponseWriter, r *http.Request) {
|
||||
io.Copy(w, f) //nolint:errcheck
|
||||
}
|
||||
|
||||
// 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 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 {
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
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>
|
||||
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
|
||||
}
|
||||
|
||||
report, err := h.generator.GenerateOnDemand(repoID, sha)
|
||||
if err != nil {
|
||||
jsonError(w, "generation failed: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
jsonOK(w, report)
|
||||
}
|
||||
+12
-2
@@ -19,11 +19,13 @@ import (
|
||||
"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/signing"
|
||||
"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) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
r.Use(chimiddleware.Logger)
|
||||
@@ -62,7 +64,7 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus even
|
||||
auditH := handlers.NewAuditHandler(engine)
|
||||
healthH := handlers.NewHealthHandler(engine, bus)
|
||||
repoHealthH := handlers.NewRepoHealthHandler(engine)
|
||||
artifactH := handlers.NewArtifactHandler(engine, artifactRoot)
|
||||
artifactH := handlers.NewArtifactHandler(engine, artifactRoot, &keys)
|
||||
runnerH := handlers.NewRunnerHandler(engine)
|
||||
gitopsH := handlers.NewGitOpsHandler(engine, bus)
|
||||
fedH := handlers.NewFederationHandler(engine, cfg)
|
||||
@@ -70,6 +72,7 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus even
|
||||
timelineH := handlers.NewTimelineHandler(engine, cfg.RepoRoot)
|
||||
workspaceH := handlers.NewWorkspaceHandler(engine, cfg)
|
||||
secretH := handlers.NewSecretHandler(engine, cfg.SessionSecret)
|
||||
sbomH := handlers.NewSBOMHandler(engine, sbomGen)
|
||||
|
||||
// ── Git smart-HTTP transport ───────────────────────────────────────────────
|
||||
// Regex constraint ensures only *.git paths match, so asset/SPA URLs
|
||||
@@ -198,6 +201,10 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus even
|
||||
})
|
||||
})
|
||||
r.Get("/artifacts/{artifactID}/download", artifactH.Download)
|
||||
r.Get("/artifacts/{artifactID}/signature", artifactH.GetSignature)
|
||||
r.Get("/artifacts/{artifactID}/verify", artifactH.VerifySignature)
|
||||
r.Get("/sbom", sbomH.GetForRun)
|
||||
r.Get("/sbom/document", sbomH.GetDocumentForRun)
|
||||
r.Route("/members", func(r chi.Router) {
|
||||
r.Get("/", memberH.List)
|
||||
r.With(csrf).Post("/", memberH.Add)
|
||||
@@ -247,6 +254,9 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus even
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user