Files
ForgeBucket/internal/api/handlers/oci.go
T
2026-05-12 22:34:26 +02:00

526 lines
18 KiB
Go

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
}