wip: oci image management

This commit is contained in:
loveuer
2025-11-09 15:19:11 +08:00
commit 8de8234372
58 changed files with 6142 additions and 0 deletions

76
handler/blob.go Normal file
View File

@@ -0,0 +1,76 @@
package handler
import (
"strconv"
"strings"
"gitea.loveuer.com/loveuer/cluster/controller"
"gitea.loveuer.com/loveuer/cluster/internal/registry/storage"
"github.com/gofiber/fiber/v3"
)
// GetBlob 获取 blob
func GetBlob(store storage.Storage) fiber.Handler {
return func(c fiber.Ctx) error {
repo := c.Locals("repo_name")
repoStr := ""
if repo != nil {
repoStr = repo.(string)
}
if repoStr == "" {
repoStr = strings.TrimPrefix(c.Params("name"), "/")
}
digest := c.Locals("digest")
digestStr := ""
if digest != nil {
digestStr = digest.(string)
}
reader, size, err := controller.GetBlob(store, repoStr, digestStr)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
"errors": []fiber.Map{
{
"code": "BLOB_UNKNOWN",
"message": err.Error(),
},
},
})
}
defer reader.Close()
c.Set("Content-Type", "application/octet-stream")
c.Set("Content-Length", strconv.FormatInt(size, 10))
c.Set("Docker-Content-Digest", digestStr)
return c.SendStream(reader, int(size))
}
}
// HeadBlob 检查 blob 是否存在
func HeadBlob(store storage.Storage) fiber.Handler {
return func(c fiber.Ctx) error {
repo := c.Locals("repo_name")
repoStr := ""
if repo != nil {
repoStr = repo.(string)
}
if repoStr == "" {
repoStr = strings.TrimPrefix(c.Params("name"), "/")
}
digest := c.Locals("digest")
digestStr := ""
if digest != nil {
digestStr = digest.(string)
}
exists, size, err := controller.HeadBlob(store, repoStr, digestStr)
if err != nil || !exists {
return c.SendStatus(fiber.StatusNotFound)
}
c.Set("Content-Length", strconv.FormatInt(size, 10))
c.Set("Docker-Content-Digest", digestStr)
return c.SendStatus(fiber.StatusOK)
}
}

37
handler/blob_handler.go Normal file
View File

@@ -0,0 +1,37 @@
package handler
import (
"context"
"io"
"gitea.loveuer.com/loveuer/cluster/internal/model"
"gitea.loveuer.com/loveuer/cluster/internal/registry/storage"
)
type blobHandlerImpl struct {
store storage.Storage
}
// NewBlobHandler creates a new blob handler
func NewBlobHandler(store storage.Storage) BlobHandler {
return &blobHandlerImpl{store: store}
}
func (b *blobHandlerImpl) Get(ctx context.Context, repo string, h model.Hash) (io.ReadCloser, error) {
reader, _, err := b.store.GetBlob(repo, h.String())
return reader, err
}
func (b *blobHandlerImpl) Put(ctx context.Context, repo string, h model.Hash, rc io.ReadCloser) error {
defer rc.Close()
return b.store.PutBlob(repo, h.String(), rc)
}
func (b *blobHandlerImpl) Stat(ctx context.Context, repo string, h model.Hash) (int64, error) {
return b.store.GetBlobSize(repo, h.String())
}
func (b *blobHandlerImpl) Delete(ctx context.Context, repo string, h model.Hash) error {
// TODO: implement delete
return nil
}

8
handler/globals.go Normal file
View File

@@ -0,0 +1,8 @@
package handler
// Global handlers
var (
blobHandler BlobHandler
uploadHandler UploadHandler
m ManifestHandler
)

34
handler/interfaces.go Normal file
View File

@@ -0,0 +1,34 @@
package handler
import (
"context"
"io"
"gitea.loveuer.com/loveuer/cluster/internal/rerr"
"gitea.loveuer.com/loveuer/cluster/internal/model"
)
// BlobHandler handles blob operations
type BlobHandler interface {
Get(ctx context.Context, repo string, h model.Hash) (io.ReadCloser, error)
Put(ctx context.Context, repo string, h model.Hash, rc io.ReadCloser) error
Stat(ctx context.Context, repo string, h model.Hash) (int64, error)
Delete(ctx context.Context, repo string, h model.Hash) error
}
// UploadHandler handles upload operations
type UploadHandler interface {
UploadId() string
Write(ctx context.Context, sessionID string, r io.Reader, start, end int) (int, *rerr.RepositoryError)
Done(ctx context.Context, blobHandler BlobHandler, sessionID string, r io.Reader, contentLength int, repo string, h model.Hash) *rerr.RepositoryError
}
// ManifestHandler handles manifest operations
type ManifestHandler interface {
Get(ctx context.Context, repo, tag string) (io.ReadCloser, string, *rerr.RepositoryError)
Put(ctx context.Context, repo, tag, digest string, mf *model.RepoSimpleManifest) error
Delete(ctx context.Context, repo, tag string) error
Tags(ctx context.Context, repo string, n, last int, prefix string) (*model.Tag, *rerr.RepositoryError)
Catalog(ctx context.Context, n, last int, prefix string) (*model.Catalog, *rerr.RepositoryError)
Referrers(ctx context.Context, repo, target string) (*model.IndexManifest, *rerr.RepositoryError)
}

153
handler/manifest.go Normal file
View File

@@ -0,0 +1,153 @@
package handler
import (
"strconv"
"strings"
"gitea.loveuer.com/loveuer/cluster/controller"
"gitea.loveuer.com/loveuer/cluster/internal/registry/storage"
"github.com/gofiber/fiber/v3"
)
// GetManifest 获取 manifest
func GetManifest(store storage.Storage) fiber.Handler {
return func(c fiber.Ctx) error {
repo := c.Locals("repo_name")
repoStr := ""
if repo != nil {
repoStr = repo.(string)
}
if repoStr == "" {
repoStr = strings.TrimPrefix(c.Params("name"), "/")
}
reference := c.Locals("reference")
referenceStr := ""
if reference != nil {
referenceStr = reference.(string)
}
data, mediaType, digest, err := controller.GetManifest(store, repoStr, referenceStr)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
"errors": []fiber.Map{
{
"code": "MANIFEST_UNKNOWN",
"message": err.Error(),
},
},
})
}
c.Set("Content-Type", mediaType)
c.Set("Content-Length", strconv.FormatInt(int64(len(data)), 10))
c.Set("Docker-Content-Digest", digest)
return c.Send(data)
}
}
// PutManifest 推送 manifest
func PutManifest(store storage.Storage) fiber.Handler {
return func(c fiber.Ctx) error {
repo := c.Locals("repo_name")
repoStr := ""
if repo != nil {
repoStr = repo.(string)
}
if repoStr == "" {
repoStr = c.Params("name")
}
reference := c.Locals("reference")
referenceStr := ""
if reference != nil {
referenceStr = reference.(string)
}
// 读取请求体
data := c.Body()
// 获取 Content-Type
mediaType := c.Get("Content-Type")
if mediaType == "" {
mediaType = "application/vnd.docker.distribution.manifest.v2+json"
}
digest, err := controller.PutManifest(store, repoStr, referenceStr, data, mediaType)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"errors": []fiber.Map{
{
"code": "INTERNAL_ERROR",
"message": err.Error(),
},
},
})
}
// 返回 Location 和 Digest
c.Set("Location", c.Path())
c.Set("Docker-Content-Digest", digest)
return c.SendStatus(fiber.StatusCreated)
}
}
// DeleteManifest 删除 manifest
func DeleteManifest(store storage.Storage) fiber.Handler {
return func(c fiber.Ctx) error {
repo := c.Locals("repo_name")
repoStr := ""
if repo != nil {
repoStr = repo.(string)
}
if repoStr == "" {
repoStr = c.Params("name")
}
reference := c.Locals("reference")
referenceStr := ""
if reference != nil {
referenceStr = reference.(string)
}
if err := controller.DeleteManifest(store, repoStr, referenceStr); err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
"errors": []fiber.Map{
{
"code": "MANIFEST_UNKNOWN",
"message": err.Error(),
},
},
})
}
return c.SendStatus(fiber.StatusAccepted)
}
}
// HeadManifest 检查 manifest 是否存在
func HeadManifest(store storage.Storage) fiber.Handler {
return func(c fiber.Ctx) error {
repo := c.Locals("repo_name")
repoStr := ""
if repo != nil {
repoStr = repo.(string)
}
if repoStr == "" {
repoStr = strings.TrimPrefix(c.Params("name"), "/")
}
reference := c.Locals("reference")
referenceStr := ""
if reference != nil {
referenceStr = reference.(string)
}
exists, data, mediaType, digest, err := controller.HeadManifest(store, repoStr, referenceStr)
if err != nil || !exists {
return c.SendStatus(fiber.StatusNotFound)
}
c.Set("Content-Type", mediaType)
c.Set("Content-Length", strconv.FormatInt(int64(len(data)), 10))
c.Set("Docker-Content-Digest", digest)
return c.SendStatus(fiber.StatusOK)
}
}

106
handler/manifest_handler.go Normal file
View File

@@ -0,0 +1,106 @@
package handler
import (
"bytes"
"context"
"io"
"gitea.loveuer.com/loveuer/cluster/internal/database"
"gitea.loveuer.com/loveuer/cluster/internal/model"
"gitea.loveuer.com/loveuer/cluster/internal/rerr"
"gitea.loveuer.com/loveuer/cluster/internal/registry/storage"
"gorm.io/gorm"
)
type manifestHandlerImpl struct {
store storage.Storage
}
// NewManifestHandler creates a new manifest handler
func NewManifestHandler(store storage.Storage) ManifestHandler {
return &manifestHandlerImpl{store: store}
}
func (m *manifestHandlerImpl) Get(ctx context.Context, repo, tag string) (io.ReadCloser, string, *rerr.RepositoryError) {
data, mediaType, err := m.store.GetManifest(repo, tag)
if err != nil {
return nil, "", &rerr.RepositoryError{
Status: 404,
Code: "MANIFEST_UNKNOWN",
Message: "manifest not found",
}
}
return io.NopCloser(bytes.NewReader(data)), mediaType, nil
}
func (m *manifestHandlerImpl) Put(ctx context.Context, repo, tag, digest string, mf *model.RepoSimpleManifest) error {
return m.store.PutManifest(repo, tag, mf.Blob, mf.ContentType)
}
func (m *manifestHandlerImpl) Delete(ctx context.Context, repo, tag string) error {
return m.store.DeleteManifest(repo, tag)
}
func (m *manifestHandlerImpl) Tags(ctx context.Context, repo string, n, last int, prefix string) (*model.Tag, *rerr.RepositoryError) {
var repository model.RegistryRepository
if err := database.DB.Where("name = ?", repo).First(&repository).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return &model.Tag{Name: repo, Tags: []string{}}, nil
}
return nil, rerr.ErrInternal(err)
}
var tags []model.RegistryTag
query := database.DB.Where("repository_id = ?", repository.ID)
if prefix != "" {
query = query.Where("name LIKE ?", prefix+"%")
}
if last > 0 {
query = query.Where("id > ?", last)
}
query = query.Order("id ASC").Limit(n)
if err := query.Find(&tags).Error; err != nil {
return nil, rerr.ErrInternal(err)
}
tagNames := make([]string, len(tags))
for i, tag := range tags {
tagNames[i] = tag.Name
}
return &model.Tag{Name: repo, Tags: tagNames}, nil
}
func (m *manifestHandlerImpl) Catalog(ctx context.Context, n, last int, prefix string) (*model.Catalog, *rerr.RepositoryError) {
var repositories []model.RegistryRepository
query := database.DB
if prefix != "" {
query = query.Where("name LIKE ?", prefix+"%")
}
if last > 0 {
query = query.Where("id > ?", last)
}
query = query.Order("id ASC").Limit(n)
if err := query.Find(&repositories).Error; err != nil {
return nil, rerr.ErrInternal(err)
}
repoNames := make([]string, len(repositories))
for i, repo := range repositories {
repoNames[i] = repo.Name
}
return &model.Catalog{Repositories: repoNames}, nil
}
func (m *manifestHandlerImpl) Referrers(ctx context.Context, repo, target string) (*model.IndexManifest, *rerr.RepositoryError) {
// For now, return an empty index manifest
return &model.IndexManifest{
SchemaVersion: 2,
MediaType: "application/vnd.oci.image.index.v1+json",
Manifests: []model.IndexManifestEntry{},
}, nil
}

113
handler/registry.go Normal file
View File

@@ -0,0 +1,113 @@
package handler
import (
"log"
"strings"
"gitea.loveuer.com/loveuer/cluster/controller"
"gitea.loveuer.com/loveuer/cluster/internal/registry/storage"
"github.com/gofiber/fiber/v3"
)
func isBlob(c fiber.Ctx) bool {
elem := strings.Split(c.Path(), "/")
elem = elem[1:]
if elem[len(elem)-1] == "" {
elem = elem[:len(elem)-1]
}
if len(elem) < 3 {
return false
}
return elem[len(elem)-2] == "blobs" || (elem[len(elem)-3] == "blobs" &&
elem[len(elem)-2] == "uploads")
}
func isManifest(c fiber.Ctx) bool {
elems := strings.Split(c.Path(), "/")
elems = elems[1:]
if len(elems) < 4 {
return false
}
return elems[len(elems)-2] == "manifests"
}
func isTags(c fiber.Ctx) bool {
elems := strings.Split(c.Path(), "/")
elems = elems[1:]
if len(elems) < 4 {
return false
}
return elems[len(elems)-2] == "tags"
}
func isCatalog(c fiber.Ctx) bool {
elems := strings.Split(c.Path(), "/")
elems = elems[1:]
if len(elems) < 2 {
return false
}
return elems[len(elems)-1] == "_catalog"
}
func isReferrers(c fiber.Ctx) bool {
elems := strings.Split(c.Path(), "/")
elems = elems[1:]
if len(elems) < 4 {
return false
}
return elems[len(elems)-2] == "referrers"
}
// RegistryV2 handles all OCI Registry API v2 requests
func RegistryV2(store storage.Storage) fiber.Handler {
// Initialize handlers
blobHandler = NewBlobHandler(store)
uploadHandler = NewUploadHandler()
m = NewManifestHandler(store)
return func(c fiber.Ctx) error {
if isBlob(c) {
return handleBlobs(c)
}
if isManifest(c) {
return handleManifest(c)
}
if isTags(c) {
return handleTags(c)
}
if isCatalog(c) {
return handleCatalog(c)
}
if isReferrers(c) {
return handleReferrers(c)
}
// Handle root v2 endpoint
if c.Path() == "/v2/" {
c.Set("Docker-Distribution-API-Version", "registry/2.0")
return c.SendStatus(200)
}
c.Set("Docker-Distribution-API-Version", "registry/2.0")
log.Printf("[WARN] RegistryV2: unknown endpoint - path = %s, method = %s", c.Path(), c.Method())
return c.Status(404).JSON(fiber.Map{
"errors": []fiber.Map{{"code": "NOT_FOUND", "message": "endpoint not found"}},
})
}
}
// ListImages 返回所有仓库列表
func ListImages(c fiber.Ctx) error {
repos, err := controller.ListImages()
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"images": repos})
}

288
handler/registry_blob.go Normal file
View File

@@ -0,0 +1,288 @@
package handler
import (
"bytes"
"errors"
"fmt"
"io"
"net/http"
"os"
"path"
"strings"
"gitea.loveuer.com/loveuer/cluster/internal/database"
"gitea.loveuer.com/loveuer/cluster/internal/model"
"gitea.loveuer.com/loveuer/cluster/internal/rerr"
"github.com/gofiber/fiber/v3"
"gorm.io/gorm"
)
var ErrNotFound = errors.New("not found")
func handleBlobs(c fiber.Ctx) error {
elem := strings.Split(c.Path(), "/")
elem = elem[1:]
if elem[len(elem)-1] == "" {
elem = elem[:len(elem)-1]
}
// Must have a path of form /v2/{name}/blobs/{upload,sha256:}
if len(elem) < 4 {
return rerr.Error(c, &rerr.RepositoryError{
Status: http.StatusBadRequest,
Code: "NAME_INVALID",
Message: "blobs must be attached to a repo",
})
}
target := elem[len(elem)-1]
service := elem[len(elem)-2]
digest := c.Query("digest")
contentRange := c.Get("Content-Range")
rangeHeader := c.Get("Range")
repo := strings.Join(elem[1:len(elem)-2], "/")
switch c.Method() {
case http.MethodHead:
h, err := model.NewHash(target)
if err != nil {
return rerr.Error(c, &rerr.RepositoryError{
Status: http.StatusBadRequest,
Code: "NAME_INVALID",
Message: "invalid digest",
})
}
size, err := blobHandler.Stat(c.Context(), repo, h)
if errors.Is(err, ErrNotFound) {
return rerr.Error(c, rerr.ErrBlobUnknown)
} else if err != nil {
return rerr.Error(c, rerr.ErrInternal(err))
}
c.Set("Content-Length", fmt.Sprint(size))
c.Set("Docker-Content-Digest", h.String())
return c.Send(nil)
case http.MethodGet:
h, err := model.NewHash(target)
if err != nil {
return rerr.Error(c, &rerr.RepositoryError{
Status: http.StatusBadRequest,
Code: "NAME_INVALID",
Message: "invalid digest",
})
}
size, err := blobHandler.Stat(c.Context(), repo, h)
if errors.Is(err, ErrNotFound) {
return rerr.Error(c, rerr.ErrBlobUnknown)
} else if err != nil {
return rerr.Error(c, rerr.ErrInternal(err))
}
rc, err := blobHandler.Get(c.Context(), repo, h)
if errors.Is(err, ErrNotFound) {
return rerr.Error(c, rerr.ErrBlobUnknown)
} else if err != nil {
return rerr.Error(c, rerr.ErrInternal(err))
}
defer rc.Close()
r := rc
if rangeHeader != "" {
start, end := int64(0), int64(0)
if _, err := fmt.Sscanf(rangeHeader, "bytes=%d-%d", &start, &end); err != nil {
return rerr.Error(c, &rerr.RepositoryError{
Status: http.StatusRequestedRangeNotSatisfiable,
Code: "BLOB_UNKNOWN",
Message: "We don't understand your Range",
})
}
n := (end + 1) - start
if ra, ok := r.(io.ReaderAt); ok {
if end+1 > size {
return rerr.Error(c, &rerr.RepositoryError{
Status: http.StatusRequestedRangeNotSatisfiable,
Code: "BLOB_UNKNOWN",
Message: fmt.Sprintf("range end %d > %d size", end+1, size),
})
}
sr := io.NewSectionReader(ra, start, n)
r = io.NopCloser(sr)
} else {
if _, err := io.CopyN(io.Discard, r, start); err != nil {
return rerr.Error(c, &rerr.RepositoryError{
Status: http.StatusRequestedRangeNotSatisfiable,
Code: "BLOB_UNKNOWN",
Message: fmt.Sprintf("Failed to discard %d bytes", start),
})
}
lr := io.LimitReader(r, n)
r = io.NopCloser(lr)
}
c.Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, size))
c.Set("Content-Length", fmt.Sprint(n))
c.Set("Docker-Content-Digest", h.String())
c.Status(http.StatusPartialContent)
} else {
c.Set("Content-Length", fmt.Sprint(size))
c.Set("Docker-Content-Digest", h.String())
c.Status(http.StatusOK)
}
_, err = io.Copy(c, r)
return err
case http.MethodPost:
if target != "uploads" {
return rerr.Error(c, &rerr.RepositoryError{
Status: http.StatusBadRequest,
Code: "METHOD_UNKNOWN",
Message: fmt.Sprintf("POST to /blobs must be followed by /uploads, got %s", target),
})
}
if digest != "" {
h, err := model.NewHash(digest)
if err != nil {
return rerr.Error(c, rerr.ErrDigestInvalid)
}
vrc := io.NopCloser(bytes.NewReader(c.Body()))
defer vrc.Close()
if err = blobHandler.Put(c.Context(), repo, h, vrc); err != nil {
return rerr.Error(c, rerr.ErrInternal(err))
}
c.Set("Docker-Content-Digest", h.String())
return c.SendStatus(http.StatusCreated)
}
id := uploadHandler.UploadId()
// Get or create repository
var repository model.RegistryRepository
if err := database.DB.Where("name = ?", repo).First(&repository).Error; err != nil {
if err == gorm.ErrRecordNotFound {
repository = model.RegistryRepository{Name: repo}
if err := database.DB.Create(&repository).Error; err != nil {
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
if err := database.DB.Where("name = ?", repo).First(&repository).Error; err != nil {
return rerr.Error(c, rerr.ErrInternal(err))
}
} else {
return rerr.Error(c, rerr.ErrInternal(err))
}
}
} else {
return rerr.Error(c, rerr.ErrInternal(err))
}
}
// Create upload session
uploadPath := model.GetUploadPath("./x-storage", id)
if err := os.MkdirAll(path.Dir(uploadPath), 0755); err != nil {
return rerr.Error(c, rerr.ErrInternal(err))
}
session := model.RegistryUploadSession{
RepositoryID: repository.ID,
SessionID: id,
Path: uploadPath,
Size: 0,
}
if err := database.DB.Create(&session).Error; err != nil {
return rerr.Error(c, rerr.ErrInternal(err))
}
c.Set("Location", "/"+path.Join("v2", path.Join(elem[1:len(elem)-2]...), "blobs/uploads", id))
c.Set("Range", "0-0")
return c.SendStatus(http.StatusAccepted)
case http.MethodPatch:
if service != "uploads" {
return rerr.Error(c, &rerr.RepositoryError{
Status: http.StatusBadRequest,
Code: "METHOD_UNKNOWN",
Message: fmt.Sprintf("PATCH to /blobs must be followed by /uploads, got %s", service),
})
}
start, end := 0, 0
if contentRange != "" {
if _, err := fmt.Sscanf(contentRange, "%d-%d", &start, &end); err != nil {
return rerr.Error(c, &rerr.RepositoryError{
Status: http.StatusRequestedRangeNotSatisfiable,
Code: "BLOB_UPLOAD_UNKNOWN",
Message: "We don't understand your Content-Range",
})
}
expectedEnd := start + len(c.Body()) - 1
if end != expectedEnd {
return rerr.Error(c, &rerr.RepositoryError{
Status: http.StatusRequestedRangeNotSatisfiable,
Code: "BLOB_UPLOAD_INVALID",
Message: fmt.Sprintf("blob upload content range mismatch: expected end %d, got %d", expectedEnd, end),
})
}
} else {
end = start + len(c.Body()) - 1
}
length, re := uploadHandler.Write(c.Context(), target, bytes.NewReader(c.Body()), start, end)
if re != nil {
return rerr.Error(c, re)
}
c.Set("Location", "/"+path.Join("v2", path.Join(elem[1:len(elem)-3]...), "blobs/uploads", target))
c.Set("Range", fmt.Sprintf("0-%d", length-1))
return c.SendStatus(http.StatusNoContent)
case http.MethodPut:
if service != "uploads" {
return rerr.Error(c, &rerr.RepositoryError{
Status: http.StatusBadRequest,
Code: "METHOD_UNKNOWN",
Message: fmt.Sprintf("PUT to /blobs must be followed by /uploads, got %s", service),
})
}
if digest == "" {
return rerr.Error(c, &rerr.RepositoryError{
Status: http.StatusBadRequest,
Code: "DIGEST_INVALID",
Message: "digest not specified",
})
}
hash, err := model.NewHash(digest)
if err != nil {
return rerr.Error(c, &rerr.RepositoryError{
Status: http.StatusBadRequest,
Code: "NAME_INVALID",
Message: "invalid digest",
})
}
re := uploadHandler.Done(c.Context(), blobHandler, target, bytes.NewReader(c.Body()), len(c.Body()), repo, hash)
if re != nil {
return rerr.Error(c, re)
}
c.Set("Docker-Content-Digest", hash.String())
return c.SendStatus(http.StatusCreated)
default:
return rerr.Error(c, &rerr.RepositoryError{
Status: http.StatusBadRequest,
Code: "METHOD_UNKNOWN",
Message: "We don't understand your method + url",
})
}
}

View File

@@ -0,0 +1,33 @@
package handler
import (
"net/http"
"strconv"
"gitea.loveuer.com/loveuer/cluster/internal/rerr"
"github.com/gofiber/fiber/v3"
)
func handleCatalog(c fiber.Ctx) error {
if c.Method() != "GET" {
return rerr.Error(c, &rerr.RepositoryError{
Status: http.StatusBadRequest,
Code: "METHOD_UNKNOWN",
Message: "We don't understand your method + url",
})
}
nStr := c.Query("n")
n := 10000
if nStr != "" {
n, _ = strconv.Atoi(nStr)
}
list, re := m.Catalog(c.Context(), n, 0, "")
if re != nil {
return rerr.Error(c, re)
}
return c.JSON(list)
}

View File

@@ -0,0 +1,92 @@
package handler
import (
"bytes"
"fmt"
"io"
"net/http"
"strings"
"gitea.loveuer.com/loveuer/cluster/internal/model"
"gitea.loveuer.com/loveuer/cluster/internal/rerr"
"github.com/gofiber/fiber/v3"
)
func handleManifest(c fiber.Ctx) error {
elem := strings.Split(c.Path(), "/")
elem = elem[1:]
target := elem[len(elem)-1]
repo := strings.Join(elem[1:len(elem)-2], "/")
switch c.Method() {
case http.MethodGet:
reader, contentType, re := m.Get(c.Context(), repo, target)
if re != nil {
return rerr.Error(c, re)
}
bs, err := io.ReadAll(reader)
if err != nil {
return rerr.Error(c, rerr.ErrInternal(err))
}
h, _, _ := model.SHA256(bytes.NewReader(bs))
c.Set("Docker-Content-Digest", h.String())
c.Set("Content-Type", contentType)
c.Set("Content-Length", fmt.Sprint(len(bs)))
return c.Send(bs)
case http.MethodHead:
reader, contentType, re := m.Get(c.Context(), repo, target)
if re != nil {
return rerr.Error(c, re)
}
bs, err := io.ReadAll(reader)
if err != nil {
return rerr.Error(c, rerr.ErrInternal(err))
}
h, _, _ := model.SHA256(bytes.NewReader(bs))
c.Set("Docker-Content-Digest", h.String())
c.Set("Content-Type", contentType)
c.Set("Content-Length", fmt.Sprint(len(bs)))
return c.Send(bs)
case http.MethodPut:
buf := &bytes.Buffer{}
if _, err := io.Copy(buf, bytes.NewReader(c.Body())); err != nil {
return rerr.Error(c, rerr.ErrInternal(err))
}
hash, _, err := model.SHA256(bytes.NewReader(buf.Bytes()))
if err != nil {
return rerr.Error(c, rerr.ErrInternal(err))
}
digest := hash.String()
mf := model.RepoSimpleManifest{
Blob: buf.Bytes(),
ContentType: c.Get("Content-Type"),
}
if err := m.Put(c.Context(), repo, target, digest, &mf); err != nil {
return rerr.Error(c, rerr.ErrInternal(err))
}
c.Set("Docker-Content-Digest", digest)
return c.SendStatus(http.StatusCreated)
case http.MethodDelete:
return c.SendStatus(http.StatusAccepted)
default:
return rerr.Error(c, &rerr.RepositoryError{
Status: http.StatusBadRequest,
Code: "METHOD_UNKNOWN",
Message: "We don't understand your method + url",
})
}
}

View File

@@ -0,0 +1,32 @@
package handler
import (
"net/http"
"strings"
"gitea.loveuer.com/loveuer/cluster/internal/rerr"
"github.com/gofiber/fiber/v3"
)
func handleReferrers(c fiber.Ctx) error {
if c.Method() != "GET" {
return rerr.Error(c, &rerr.RepositoryError{
Status: http.StatusBadRequest,
Code: "METHOD_UNKNOWN",
Message: "We don't understand your method + url",
})
}
elem := strings.Split(c.Path(), "/")
elem = elem[1:]
repo := strings.Join(elem[1:len(elem)-2], "/")
target := elem[len(elem)-1]
index, re := m.Referrers(c.Context(), repo, target)
if re != nil {
return rerr.Error(c, re)
}
return c.JSON(index)
}

49
handler/registry_tag.go Normal file
View File

@@ -0,0 +1,49 @@
package handler
import (
"net/http"
"strings"
"gitea.loveuer.com/loveuer/cluster/internal/rerr"
"github.com/gofiber/fiber/v3"
)
func handleTags(c fiber.Ctx) error {
if c.Method() != "GET" {
return rerr.Error(c, &rerr.RepositoryError{
Status: http.StatusBadRequest,
Code: "METHOD_UNKNOWN",
Message: "We don't understand your method + url",
})
}
type Req struct {
Last int `json:"last" query:"last"`
N int `json:"n" query:"n"`
}
elem := strings.Split(c.Path(), "/")
elem = elem[1:]
repo := strings.Join(elem[1:len(elem)-2], "/")
var req Req
if err := c.Bind().Query(&req); err != nil {
return rerr.Error(c, &rerr.RepositoryError{
Status: http.StatusBadRequest,
Code: "BAD_REQUEST",
Message: err.Error(),
})
}
if req.N <= 0 {
req.N = 100
}
list, re := m.Tags(c.Context(), repo, req.N, req.Last, "")
if re != nil {
return rerr.Error(c, re)
}
return c.JSON(list)
}

173
handler/upload.go Normal file
View File

@@ -0,0 +1,173 @@
package handler
import (
"bytes"
"strconv"
"strings"
"gitea.loveuer.com/loveuer/cluster/controller"
"gitea.loveuer.com/loveuer/cluster/internal/registry/storage"
"github.com/gofiber/fiber/v3"
)
// StartBlobUpload 开始 blob 上传
func StartBlobUpload(store storage.Storage) fiber.Handler {
return func(c fiber.Ctx) error {
repo := c.Locals("repo_name")
repoStr := ""
if repo != nil {
repoStr = repo.(string)
}
if repoStr == "" {
repoStr = strings.TrimPrefix(c.Params("name"), "/")
}
uuid, err := controller.StartBlobUpload(store, repoStr)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"errors": []fiber.Map{
{
"code": "INTERNAL_ERROR",
"message": err.Error(),
},
},
})
}
// 返回上传 URL
location := c.Path() + "/" + uuid
c.Set("Location", location)
c.Set("Docker-Upload-UUID", uuid)
c.Set("Range", "0-0")
return c.SendStatus(fiber.StatusAccepted)
}
}
// PatchBlobUpload 上传 blob 数据块
func PatchBlobUpload(store storage.Storage) fiber.Handler {
return func(c fiber.Ctx) error {
uuid := c.Locals("uuid")
uuidStr := ""
if uuid != nil {
uuidStr = uuid.(string)
}
// 获取 Range header
rangeHeader := c.Get("Content-Range")
if rangeHeader == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"errors": []fiber.Map{
{
"code": "BAD_REQUEST",
"message": "Content-Range header required",
},
},
})
}
// 读取数据
data := bytes.NewReader(c.Body())
size, err := controller.PatchBlobUpload(store, uuidStr, data)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"errors": []fiber.Map{
{
"code": "INTERNAL_ERROR",
"message": err.Error(),
},
},
})
}
location := c.Path()
c.Set("Location", location)
c.Set("Docker-Upload-UUID", uuidStr)
c.Set("Range", "0-"+strconv.FormatInt(size-1, 10))
return c.SendStatus(fiber.StatusNoContent)
}
}
// PutBlobUpload 完成 blob 上传
func PutBlobUpload(store storage.Storage) fiber.Handler {
return func(c fiber.Ctx) error {
repo := c.Locals("repo_name")
repoStr := ""
if repo != nil {
repoStr = repo.(string)
}
if repoStr == "" {
repoStr = strings.TrimPrefix(c.Params("name"), "/")
}
uuid := c.Locals("uuid")
uuidStr := ""
if uuid != nil {
uuidStr = uuid.(string)
}
// 获取 digest
digest := c.Query("digest")
if digest == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"errors": []fiber.Map{
{
"code": "BAD_REQUEST",
"message": "digest query parameter required",
},
},
})
}
// 如果有请求体,先追加数据
var data interface{} = nil
if len(c.Body()) > 0 {
data = bytes.NewReader(c.Body())
}
location, err := controller.PutBlobUpload(store, repoStr, uuidStr, digest, data, c.Path())
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"errors": []fiber.Map{
{
"code": "BAD_REQUEST",
"message": err.Error(),
},
},
})
}
c.Set("Location", location)
c.Set("Content-Length", "0")
c.Set("Docker-Content-Digest", digest)
return c.SendStatus(fiber.StatusCreated)
}
}
// GetBlobUpload 获取上传状态
func GetBlobUpload(store storage.Storage) fiber.Handler {
return func(c fiber.Ctx) error {
uuid := c.Locals("uuid")
uuidStr := ""
if uuid != nil {
uuidStr = uuid.(string)
}
size, err := controller.GetBlobUpload(store, uuidStr)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
"errors": []fiber.Map{
{
"code": "UPLOAD_UNKNOWN",
"message": err.Error(),
},
},
})
}
location := c.Path()
c.Set("Location", location)
c.Set("Docker-Upload-UUID", uuidStr)
c.Set("Range", "0-"+strconv.FormatInt(size-1, 10))
return c.SendStatus(fiber.StatusNoContent)
}
}

99
handler/upload_handler.go Normal file
View File

@@ -0,0 +1,99 @@
package handler
import (
"context"
"io"
"os"
"gitea.loveuer.com/loveuer/cluster/internal/database"
"gitea.loveuer.com/loveuer/cluster/internal/model"
"gitea.loveuer.com/loveuer/cluster/internal/rerr"
"github.com/google/uuid"
"gorm.io/gorm"
)
type uploadHandlerImpl struct{}
// NewUploadHandler creates a new upload handler
func NewUploadHandler() UploadHandler {
return &uploadHandlerImpl{}
}
func (u *uploadHandlerImpl) UploadId() string {
return uuid.New().String()
}
func (u *uploadHandlerImpl) Write(ctx context.Context, sessionID string, r io.Reader, start, end int) (int, *rerr.RepositoryError) {
// Get upload session
var session model.RegistryUploadSession
if err := database.DB.Where("session_id = ?", sessionID).First(&session).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return 0, &rerr.RepositoryError{
Status: 404,
Code: "BLOB_UPLOAD_UNKNOWN",
Message: "upload session not found",
}
}
return 0, rerr.ErrInternal(err)
}
// Open file for writing
file, err := os.OpenFile(session.Path, os.O_WRONLY|os.O_CREATE, 0644)
if err != nil {
return 0, rerr.ErrInternal(err)
}
defer file.Close()
// Seek to start position
if _, err := file.Seek(int64(start), 0); err != nil {
return 0, rerr.ErrInternal(err)
}
// Write data
n, err := io.CopyN(file, r, int64(end-start+1))
if err != nil && err != io.EOF {
return 0, rerr.ErrInternal(err)
}
// Update session size
session.Size = int64(start) + n
if err := database.DB.Save(&session).Error; err != nil {
return 0, rerr.ErrInternal(err)
}
return int(n), nil
}
func (u *uploadHandlerImpl) Done(ctx context.Context, blobHandler BlobHandler, sessionID string, r io.Reader, contentLength int, repo string, h model.Hash) *rerr.RepositoryError {
// Get upload session
var session model.RegistryUploadSession
if err := database.DB.Where("session_id = ?", sessionID).First(&session).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return &rerr.RepositoryError{
Status: 404,
Code: "BLOB_UPLOAD_UNKNOWN",
Message: "upload session not found",
}
}
return rerr.ErrInternal(err)
}
// Read from the uploaded file instead of the request body
file, err := os.Open(session.Path)
if err != nil {
return rerr.ErrInternal(err)
}
defer file.Close()
// Store blob
if err := blobHandler.Put(ctx, repo, h, file); err != nil {
return rerr.ErrInternal(err)
}
// Clean up upload session
os.Remove(session.Path)
database.DB.Delete(&session)
return nil
}

11
handler/version.go Normal file
View File

@@ -0,0 +1,11 @@
package handler
import (
"github.com/gofiber/fiber/v3"
)
// VersionCheck API 版本检查
func VersionCheck(c fiber.Ctx) error {
c.Set("Docker-Distribution-API-Version", "registry/2.0")
return c.SendStatus(fiber.StatusOK)
}