added artifacts
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user