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 }