Backend: - Add registry_address configuration API (GET/POST) - Add tar image upload with OCI and Docker format support - Add image download with streaming optimization - Fix blob download using c.Send (Fiber v3 SendStream bug) - Add registry_address prefix stripping for all OCI v2 endpoints - Add AGENTS.md for project documentation Frontend: - Add settings store with Snackbar notifications - Add image upload dialog with progress bar - Add download state tracking with multi-stage feedback - Replace alert() with MUI Snackbar messages - Display image names without registry_address prefix 🤖 Generated with [Qoder](https://qoder.com)
431 lines
12 KiB
Go
431 lines
12 KiB
Go
package registry
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"gitea.loveuer.com/loveuer/cluster/internal/model"
|
|
"gitea.loveuer.com/loveuer/cluster/pkg/resp"
|
|
"gitea.loveuer.com/loveuer/cluster/pkg/store"
|
|
"github.com/gofiber/fiber/v3"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// isDigestFormat checks if a string is in digest format (e.g., sha256:abc123...)
|
|
func isDigestFormat(s string) bool {
|
|
parts := strings.SplitN(s, ":", 2)
|
|
if len(parts) != 2 {
|
|
return false
|
|
}
|
|
|
|
algo := parts[0]
|
|
hash := parts[1]
|
|
|
|
// Check algorithm
|
|
if algo != "sha256" {
|
|
// Could be extended to support other algorithms like sha512
|
|
return false
|
|
}
|
|
|
|
// Check that hash is a valid hex string of expected length (64 for sha256)
|
|
if len(hash) != 64 {
|
|
return false
|
|
}
|
|
|
|
// Verify it's all hex characters
|
|
for _, r := range hash {
|
|
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f')) {
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// HandleManifest ?? manifest ????
|
|
// PUT /v2/{repo}/manifests/{tag} - ?? manifest
|
|
// GET /v2/{repo}/manifests/{tag} - ?? manifest
|
|
// DELETE /v2/{repo}/manifests/{tag} - ?? manifest
|
|
func HandleManifest(c fiber.Ctx, db *gorm.DB, store store.Store) error {
|
|
path := c.Path()
|
|
method := c.Method()
|
|
|
|
// ????: /v2/{repo}/manifests/{tag}
|
|
// ??????????? "test/redis"
|
|
pathWithoutV2 := strings.TrimPrefix(path, "/v2/")
|
|
parts := strings.Split(pathWithoutV2, "/")
|
|
if len(parts) < 2 {
|
|
return resp.R404(c, "INVALID_PATH", nil, "invalid path")
|
|
}
|
|
|
|
// ?? "manifests" ???
|
|
manifestsIndex := -1
|
|
for i, part := range parts {
|
|
if part == "manifests" {
|
|
manifestsIndex = i
|
|
break
|
|
}
|
|
}
|
|
if manifestsIndex < 1 || manifestsIndex >= len(parts)-1 {
|
|
return resp.R404(c, "INVALID_PATH", nil, "invalid path: manifests not found")
|
|
}
|
|
|
|
// ???? manifests ???????
|
|
repo := strings.Join(parts[:manifestsIndex], "/")
|
|
|
|
// Strip registry_address prefix from repo if present
|
|
var registryConfig model.RegistryConfig
|
|
registryAddress := ""
|
|
if err := db.Where("key = ?", "registry_address").First(®istryConfig).Error; err == nil {
|
|
registryAddress = registryConfig.Value
|
|
}
|
|
if registryAddress != "" && strings.HasPrefix(repo, registryAddress+"/") {
|
|
repo = strings.TrimPrefix(repo, registryAddress+"/")
|
|
}
|
|
|
|
// tag ? manifests ?????
|
|
tag := parts[manifestsIndex+1]
|
|
|
|
switch method {
|
|
case "PUT":
|
|
return handleManifestPut(c, db, store, repo, tag)
|
|
case "GET":
|
|
return handleManifestGet(c, db, store, repo, tag)
|
|
case "HEAD":
|
|
return handleManifestHead(c, db, store, repo, tag)
|
|
case "DELETE":
|
|
return handleManifestDelete(c, db, store, repo, tag)
|
|
}
|
|
|
|
return resp.R404(c, "NOT_FOUND", nil, "method not allowed")
|
|
}
|
|
|
|
// handleManifestPut ?? manifest
|
|
func handleManifestPut(c fiber.Ctx, db *gorm.DB, store store.Store, repo string, tag string) error {
|
|
// ?? manifest ??
|
|
content := c.Body()
|
|
if len(content) == 0 {
|
|
return resp.R400(c, "EMPTY_BODY", nil, "manifest content is empty")
|
|
}
|
|
|
|
// ?? digest
|
|
hasher := sha256.New()
|
|
hasher.Write(content)
|
|
digest := "sha256:" + hex.EncodeToString(hasher.Sum(nil))
|
|
|
|
// ?? Content-Type
|
|
mediaType := c.Get("Content-Type")
|
|
if mediaType == "" {
|
|
// ??? manifest ????
|
|
var mf map[string]interface{}
|
|
if err := json.Unmarshal(content, &mf); err == nil {
|
|
if mt, ok := mf["mediaType"].(string); ok {
|
|
mediaType = mt
|
|
} else {
|
|
// ???? Docker manifest v2
|
|
mediaType = "application/vnd.docker.distribution.manifest.v2+json"
|
|
}
|
|
} else {
|
|
mediaType = "application/vnd.docker.distribution.manifest.v2+json"
|
|
}
|
|
}
|
|
|
|
// ?? manifest ????????
|
|
var manifestData map[string]interface{}
|
|
if err := json.Unmarshal(content, &manifestData); err != nil {
|
|
return resp.R400(c, "INVALID_MANIFEST", nil, "invalid manifest format")
|
|
}
|
|
|
|
// ??????
|
|
var repository model.Repository
|
|
if err := db.Where("name = ?", repo).First(&repository).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
repository = model.Repository{Name: repo}
|
|
if err := db.Create(&repository).Error; err != nil {
|
|
return resp.R500(c, "", nil, err)
|
|
}
|
|
} else {
|
|
return resp.R500(c, "", nil, err)
|
|
}
|
|
}
|
|
|
|
// ?? manifest ?????
|
|
if err := store.WriteManifest(c.Context(), digest, content); err != nil {
|
|
return resp.R500(c, "", nil, err)
|
|
}
|
|
|
|
// ?? manifest ?????
|
|
var manifest model.Manifest
|
|
if err := db.Where("digest = ?", digest).First(&manifest).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
// ???? manifest ??
|
|
manifest = model.Manifest{
|
|
Repository: repo,
|
|
Tag: tag,
|
|
Digest: digest,
|
|
MediaType: mediaType,
|
|
Size: int64(len(content)),
|
|
Content: content,
|
|
}
|
|
if err := db.Create(&manifest).Error; err != nil {
|
|
return resp.R500(c, "", nil, err)
|
|
}
|
|
} else {
|
|
return resp.R500(c, "", nil, err)
|
|
}
|
|
} else {
|
|
// ???? manifest ? tag ??
|
|
manifest.Tag = tag
|
|
manifest.Repository = repo
|
|
if err := db.Save(&manifest).Error; err != nil {
|
|
return resp.R500(c, "", nil, err)
|
|
}
|
|
}
|
|
|
|
// ????? tag ??
|
|
var tagRecord model.Tag
|
|
if err := db.Where("repository = ? AND tag = ?", repo, tag).First(&tagRecord).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
tagRecord = model.Tag{
|
|
Repository: repo,
|
|
Tag: tag,
|
|
Digest: digest,
|
|
}
|
|
if err := db.Create(&tagRecord).Error; err != nil {
|
|
return resp.R500(c, "", nil, err)
|
|
}
|
|
} else {
|
|
return resp.R500(c, "", nil, err)
|
|
}
|
|
} else {
|
|
tagRecord.Digest = digest
|
|
if err := db.Save(&tagRecord).Error; err != nil {
|
|
return resp.R500(c, "", nil, err)
|
|
}
|
|
}
|
|
|
|
// ?????
|
|
c.Set("Location", fmt.Sprintf("/v2/%s/manifests/%s", repo, tag))
|
|
c.Set("Docker-Content-Digest", digest)
|
|
c.Set("Content-Type", mediaType)
|
|
c.Set("Content-Length", fmt.Sprintf("%d", len(content)))
|
|
return c.SendStatus(201)
|
|
}
|
|
|
|
// handleManifestGet ?? manifest
|
|
func handleManifestGet(c fiber.Ctx, db *gorm.DB, store store.Store, repo string, tag string) error {
|
|
var manifest model.Manifest
|
|
|
|
// ?? tag ??????????????????????
|
|
if isDigestFormat(tag) {
|
|
// ?? digest ???????????? repository
|
|
digest := tag
|
|
|
|
// ?? manifest ???
|
|
if err := db.Where("digest = ?", digest).First(&manifest).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found")
|
|
}
|
|
return resp.R500(c, "", nil, err)
|
|
}
|
|
|
|
// ???? manifest ?????????? repository ??
|
|
var tagRecord model.Tag
|
|
if err := db.Where("repository = ? AND digest = ?", repo, digest).First(&tagRecord).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found in this repository")
|
|
}
|
|
return resp.R500(c, "", nil, err)
|
|
}
|
|
} else {
|
|
// ?? tag ???? tag ?????????
|
|
var tagRecord model.Tag
|
|
if err := db.Where("repository = ? AND tag = ?", repo, tag).First(&tagRecord).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found")
|
|
}
|
|
return resp.R500(c, "", nil, err)
|
|
}
|
|
|
|
// ?? manifest ??
|
|
if err := db.Where("digest = ?", tagRecord.Digest).First(&manifest).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found")
|
|
}
|
|
return resp.R500(c, "", nil, err)
|
|
}
|
|
}
|
|
|
|
// Check Accept header if provided
|
|
acceptHeader := c.Get("Accept")
|
|
if acceptHeader != "" {
|
|
// Parse Accept header to check if client accepts the manifest's media type
|
|
acceptTypes := strings.Split(acceptHeader, ",")
|
|
accepted := false
|
|
for _, at := range acceptTypes {
|
|
// Remove quality values (e.g., "application/vnd.docker.distribution.manifest.v2+json;q=0.9")
|
|
mediaType := strings.TrimSpace(strings.Split(at, ";")[0])
|
|
if mediaType == manifest.MediaType || mediaType == "*/*" {
|
|
accepted = true
|
|
break
|
|
}
|
|
}
|
|
if !accepted {
|
|
// Check for wildcard or common Docker manifest types
|
|
for _, at := range acceptTypes {
|
|
mediaType := strings.TrimSpace(strings.Split(at, ";")[0])
|
|
if strings.Contains(mediaType, "manifest") || mediaType == "*/*" {
|
|
accepted = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
// Note: We still return the manifest even if not explicitly accepted,
|
|
// as some clients may not send proper Accept headers
|
|
}
|
|
|
|
// Read manifest content
|
|
content, err := store.ReadManifest(c.Context(), manifest.Digest)
|
|
if err != nil {
|
|
return resp.R500(c, "", nil, err)
|
|
}
|
|
|
|
// Set response headers
|
|
c.Set("Content-Type", manifest.MediaType)
|
|
c.Set("Content-Length", fmt.Sprintf("%d", len(content)))
|
|
c.Set("Docker-Content-Digest", manifest.Digest)
|
|
return c.Send(content)
|
|
}
|
|
|
|
// handleManifestHead ?? manifest ????
|
|
func handleManifestHead(c fiber.Ctx, db *gorm.DB, store store.Store, repo string, tag string) error {
|
|
var manifest model.Manifest
|
|
|
|
// ?? tag ??????????????????????
|
|
if isDigestFormat(tag) {
|
|
// ?? digest ???????????? repository
|
|
digest := tag
|
|
|
|
// ?? manifest ???
|
|
if err := db.Where("digest = ?", digest).First(&manifest).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found")
|
|
}
|
|
return resp.R500(c, "", nil, err)
|
|
}
|
|
|
|
// ???? manifest ?????????? repository ??
|
|
var tagRecord model.Tag
|
|
if err := db.Where("repository = ? AND digest = ?", repo, digest).First(&tagRecord).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found in this repository")
|
|
}
|
|
return resp.R500(c, "", nil, err)
|
|
}
|
|
} else {
|
|
// ?? tag ???? tag ?????????
|
|
var tagRecord model.Tag
|
|
if err := db.Where("repository = ? AND tag = ?", repo, tag).First(&tagRecord).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found")
|
|
}
|
|
return resp.R500(c, "", nil, err)
|
|
}
|
|
|
|
// ?? manifest ??
|
|
if err := db.Where("digest = ?", tagRecord.Digest).First(&manifest).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found")
|
|
}
|
|
return resp.R500(c, "", nil, err)
|
|
}
|
|
}
|
|
|
|
// ?????
|
|
c.Set("Content-Type", manifest.MediaType)
|
|
c.Set("Content-Length", fmt.Sprintf("%d", manifest.Size))
|
|
c.Set("Docker-Content-Digest", manifest.Digest)
|
|
return c.SendStatus(200)
|
|
}
|
|
|
|
// handleManifestDelete ?? manifest
|
|
func handleManifestDelete(c fiber.Ctx, db *gorm.DB, store store.Store, repo string, tag string) error {
|
|
var digest string
|
|
|
|
if isDigestFormat(tag) {
|
|
// ?? digest ???????????? repository
|
|
digest = tag
|
|
|
|
// ???? manifest ?????????? repository ??
|
|
var tagRecord model.Tag
|
|
if err := db.Where("repository = ? AND digest = ?", repo, digest).First(&tagRecord).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found in this repository")
|
|
}
|
|
return resp.R500(c, "", nil, err)
|
|
}
|
|
|
|
// ???????? tag ??? manifest
|
|
var count int64
|
|
if err := db.Model(&model.Tag{}).Where("digest = ?", digest).Count(&count).Error; err != nil {
|
|
return resp.R500(c, "", nil, err)
|
|
}
|
|
|
|
// ??? tag ??????? manifest ??
|
|
if count == 0 {
|
|
var manifest model.Manifest
|
|
if err := db.Where("digest = ?", digest).First(&manifest).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found")
|
|
}
|
|
return resp.R500(c, "", nil, err)
|
|
}
|
|
|
|
if err := db.Delete(&manifest).Error; err != nil {
|
|
return resp.R500(c, "", nil, err)
|
|
}
|
|
} else {
|
|
// ?? tag ?????????????????
|
|
// ??? manifest ???????????
|
|
return resp.R400(c, "CANNOT_DELETE_DIGEST_REFERENCED_BY_TAGS", nil, "cannot delete manifest referenced by tags")
|
|
}
|
|
} else {
|
|
// ?? tag ???? tag ?????????
|
|
var tagRecord model.Tag
|
|
if err := db.Where("repository = ? AND tag = ?", repo, tag).First(&tagRecord).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found")
|
|
}
|
|
return resp.R500(c, "", nil, err)
|
|
}
|
|
|
|
digest = tagRecord.Digest
|
|
|
|
// ?? tag ??
|
|
if err := db.Delete(&tagRecord).Error; err != nil {
|
|
return resp.R500(c, "", nil, err)
|
|
}
|
|
|
|
// ???????? tag ??? manifest
|
|
var count int64
|
|
if err := db.Model(&model.Tag{}).Where("digest = ?", digest).Count(&count).Error; err != nil {
|
|
return resp.R500(c, "", nil, err)
|
|
}
|
|
|
|
// ?????? tag ????? manifest ??
|
|
if count == 0 {
|
|
var manifest model.Manifest
|
|
if err := db.Where("digest = ?", digest).First(&manifest).Error; err == nil {
|
|
if err := db.Delete(&manifest).Error; err != nil {
|
|
return resp.R500(c, "", nil, err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return c.SendStatus(202)
|
|
}
|