added artifacts

This commit is contained in:
2026-05-12 22:34:26 +02:00
parent 822f723ff1
commit 91462500f0
30 changed files with 2769 additions and 4 deletions
+77 -1
View File
@@ -3,9 +3,11 @@ package handlers
import (
"encoding/json"
"io"
"log"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"xorm.io/xorm"
@@ -160,7 +162,81 @@ func (h *FederationHandler) Followers(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(coll) //nolint:errcheck
}
// Following handles GET /users/{username}/following
// 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
+525
View File
@@ -0,0 +1,525 @@
package handlers
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"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, _, hasAuth := r.BasicAuth()
if !hasAuth {
return ""
}
var user models.User
if found, _ := h.db.Where("username = ?", u).Get(&user); !found {
return ""
}
return u
}
+77
View File
@@ -0,0 +1,77 @@
package handlers
import (
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"xorm.io/xorm"
"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("user").(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)
}
+93
View File
@@ -0,0 +1,93 @@
package handlers
import (
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"xorm.io/xorm"
"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
}
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("user").(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)
}
+24 -1
View File
@@ -20,12 +20,15 @@ import (
"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, keys signing.KeyStore, sbomGen *sbom.Generator) 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)
@@ -73,6 +76,9 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus even
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)
// ── Git smart-HTTP transport ───────────────────────────────────────────────
// Regex constraint ensures only *.git paths match, so asset/SPA URLs
@@ -118,6 +124,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
@@ -251,6 +259,11 @@ 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)
@@ -290,6 +303,16 @@ 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)