mirror of
https://github.com/hanzoai/forge.git
synced 2026-10-30 15:00:39 +00:00
Storage buckets, indexer names, the bleve analyzer, the lock prefix, SSH host keys, the markdown front-matter key, the container data paths, the RPM repo id and the API security scheme all read as this product now, and app.example.ini matches the code again. The upstream maintainer list and the changelog generator config went with the release history they belonged to, and the citation comments state the fact rather than link the issue. Three inert rules turn out to have been inert because they named a module path this tree does not use: gci grouped no first-party imports under prefix(), and depguard let models/migrations import models and modules/structs. They now name github.com/hanzoai/git. The config example also documented .gitea/workflows while the code has read .hanzo/workflows for some time. The authorized_keys marker follows the delegate-hook precedent: the new name is written and the old one is still recognised, because a marker we do not know is copied through as a hand-added key and leaves a revoked key authorized. The stored user_setting key keeps its value; only a migration may change that. Co-authored-by: Hanzo Dev <[email protected]>
812 lines
24 KiB
Go
812 lines
24 KiB
Go
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package container
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
|
|
auth_model "github.com/hanzoai/git/models/auth"
|
|
packages_model "github.com/hanzoai/git/models/packages"
|
|
container_model "github.com/hanzoai/git/models/packages/container"
|
|
user_model "github.com/hanzoai/git/models/user"
|
|
"github.com/hanzoai/git/modules/httplib"
|
|
"github.com/hanzoai/git/modules/json"
|
|
"github.com/hanzoai/git/modules/log"
|
|
"github.com/hanzoai/git/modules/optional"
|
|
packages_module "github.com/hanzoai/git/modules/packages"
|
|
container_module "github.com/hanzoai/git/modules/packages/container"
|
|
"github.com/hanzoai/git/modules/setting"
|
|
"github.com/hanzoai/git/modules/storage"
|
|
"github.com/hanzoai/git/modules/structs"
|
|
"github.com/hanzoai/git/modules/util"
|
|
"github.com/hanzoai/git/routers/api/packages/helper"
|
|
auth_service "github.com/hanzoai/git/services/auth"
|
|
"github.com/hanzoai/git/services/context"
|
|
packages_service "github.com/hanzoai/git/services/packages"
|
|
container_service "github.com/hanzoai/git/services/packages/container"
|
|
|
|
"github.com/opencontainers/go-digest"
|
|
)
|
|
|
|
// maximum size of a container manifest
|
|
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pushing-manifests
|
|
const maxManifestSize = 10 * 1024 * 1024
|
|
|
|
var globalVars = sync.OnceValue(func() (ret struct {
|
|
imageNamePattern, referencePattern *regexp.Regexp
|
|
},
|
|
) {
|
|
ret.imageNamePattern = regexp.MustCompile(`\A[a-z0-9]+([._-][a-z0-9]+)*(/[a-z0-9]+([._-][a-z0-9]+)*)*\z`)
|
|
ret.referencePattern = regexp.MustCompile(`\A[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}\z`)
|
|
return ret
|
|
})
|
|
|
|
type containerHeaders struct {
|
|
Status int
|
|
ContentDigest string
|
|
UploadUUID string
|
|
Range string
|
|
Location string
|
|
ContentType string
|
|
ContentLength optional.Option[int64]
|
|
}
|
|
|
|
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#legacy-docker-support-http-headers
|
|
func setResponseHeaders(resp http.ResponseWriter, h *containerHeaders) {
|
|
if h.Location != "" {
|
|
resp.Header().Set("Location", h.Location)
|
|
}
|
|
if h.Range != "" {
|
|
resp.Header().Set("Range", h.Range)
|
|
}
|
|
if h.ContentType != "" {
|
|
resp.Header().Set("Content-Type", h.ContentType)
|
|
}
|
|
if h.ContentLength.Has() {
|
|
resp.Header().Set("Content-Length", strconv.FormatInt(h.ContentLength.Value(), 10))
|
|
}
|
|
if h.UploadUUID != "" {
|
|
resp.Header().Set("Docker-Upload-Uuid", h.UploadUUID)
|
|
}
|
|
if h.ContentDigest != "" {
|
|
resp.Header().Set("Docker-Content-Digest", h.ContentDigest)
|
|
resp.Header().Set("ETag", fmt.Sprintf(`"%s"`, h.ContentDigest))
|
|
}
|
|
resp.Header().Set("Docker-Distribution-Api-Version", "registry/2.0")
|
|
resp.WriteHeader(h.Status)
|
|
}
|
|
|
|
func jsonResponse(ctx *context.Context, status int, obj any) {
|
|
setResponseHeaders(ctx.Resp, &containerHeaders{
|
|
Status: status,
|
|
ContentType: "application/json",
|
|
})
|
|
_ = json.NewEncoder(ctx.Resp).Encode(obj) // ignore network errors
|
|
}
|
|
|
|
func apiError(ctx *context.Context, status int, err error) {
|
|
_ = helper.ProcessErrorForUser(ctx, status, err)
|
|
setResponseHeaders(ctx.Resp, &containerHeaders{
|
|
Status: status,
|
|
})
|
|
}
|
|
|
|
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#error-codes
|
|
func apiErrorDefined(ctx *context.Context, err *namedError) {
|
|
type ContainerError struct {
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
type ContainerErrors struct {
|
|
Errors []ContainerError `json:"errors"`
|
|
}
|
|
|
|
jsonResponse(ctx, err.StatusCode, ContainerErrors{
|
|
Errors: []ContainerError{
|
|
{
|
|
Code: err.Code,
|
|
Message: err.Message,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
func APIUnauthorizedError(ctx *context.Context) {
|
|
// container registry requires that the "/v2" must be in the root, so the sub-path in AppURL should be removed
|
|
realmURL := httplib.GuessCurrentHostURL(ctx) + "/v2/token"
|
|
ctx.Resp.Header().Add("WWW-Authenticate", `Bearer realm="`+realmURL+`",service="container_registry",scope="*"`)
|
|
|
|
ownerName := ctx.PathParam("username")
|
|
owner, _ := user_model.GetUserByName(ctx, ownerName)
|
|
requireSignIn := owner != nil && owner.Visibility != structs.VisibleTypePublic
|
|
requireSignIn = requireSignIn || setting.Service.RequireSignInViewStrict
|
|
if requireSignIn {
|
|
// support apple container like: container registry login <forge-host> -u
|
|
ctx.Resp.Header().Add("WWW-Authenticate", `Basic realm="Hanzo Git Container Registry"`)
|
|
}
|
|
apiErrorDefined(ctx, errUnauthorized)
|
|
}
|
|
|
|
// ReqContainerAccess is a middleware which checks the current user valid (real user or ghost if anonymous access is enabled)
|
|
func ReqContainerAccess(ctx *context.Context) {
|
|
if ctx.Doer == nil || (setting.Service.RequireSignInViewStrict && ctx.Doer.IsGhost()) {
|
|
APIUnauthorizedError(ctx)
|
|
}
|
|
}
|
|
|
|
// VerifyImageName is a middleware which checks if the image name is allowed
|
|
func VerifyImageName(ctx *context.Context) {
|
|
if !globalVars().imageNamePattern.MatchString(ctx.PathParam("image")) {
|
|
apiErrorDefined(ctx, errNameInvalid)
|
|
}
|
|
}
|
|
|
|
// DetermineSupport is used to test if the registry supports OCI
|
|
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#determining-support
|
|
func DetermineSupport(ctx *context.Context) {
|
|
setResponseHeaders(ctx.Resp, &containerHeaders{
|
|
Status: http.StatusOK,
|
|
})
|
|
}
|
|
|
|
// Authenticate creates a token for the current user
|
|
// If the current user is anonymous, the ghost user is used unless RequireSignInView is enabled.
|
|
func Authenticate(ctx *context.Context) {
|
|
u := ctx.Doer
|
|
packageScope := auth_service.GetAccessScope(ctx.Data)
|
|
if u == nil {
|
|
if setting.Service.RequireSignInViewStrict {
|
|
APIUnauthorizedError(ctx)
|
|
return
|
|
}
|
|
|
|
u = user_model.NewGhostUser()
|
|
} else {
|
|
if has, err := packageScope.HasAnyScope(
|
|
auth_model.AccessTokenScopeReadPackage,
|
|
auth_model.AccessTokenScopeWritePackage,
|
|
auth_model.AccessTokenScopeAll,
|
|
); !has {
|
|
if err != nil {
|
|
log.Error("Error checking access scope: %v", err)
|
|
}
|
|
APIUnauthorizedError(ctx)
|
|
return
|
|
}
|
|
}
|
|
|
|
token, err := packages_service.CreateAuthorizationToken(u, packageScope)
|
|
if err != nil {
|
|
apiError(ctx, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
|
|
ctx.JSON(http.StatusOK, map[string]string{
|
|
"token": token,
|
|
})
|
|
}
|
|
|
|
// https://distribution.github.io/distribution/spec/auth/oauth/
|
|
func AuthenticateNotImplemented(ctx *context.Context) {
|
|
// This optional endpoint can be used to authenticate a client.
|
|
// It must implement the specification described in:
|
|
// https://datatracker.ietf.org/doc/html/rfc6749
|
|
// https://distribution.github.io/distribution/spec/auth/oauth/
|
|
// Purpose of this stub is to respond with 404 Not Found instead of 405 Method Not Allowed.
|
|
|
|
ctx.Status(http.StatusNotFound)
|
|
}
|
|
|
|
// https://docs.docker.com/registry/spec/api/#listing-repositories
|
|
func GetRepositoryList(ctx *context.Context) {
|
|
n := ctx.FormInt("n")
|
|
if n <= 0 || n > 100 {
|
|
n = 100
|
|
}
|
|
last := ctx.FormTrim("last")
|
|
|
|
repositories, err := container_model.GetRepositories(ctx, ctx.Doer, n, last)
|
|
if err != nil {
|
|
apiError(ctx, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
|
|
type RepositoryList struct {
|
|
Repositories []string `json:"repositories"`
|
|
}
|
|
|
|
if len(repositories) == n {
|
|
v := url.Values{}
|
|
if n > 0 {
|
|
v.Add("n", strconv.Itoa(n)) // FIXME: "n" can't be zero here, the logic is inconsistent with GetTagsList
|
|
}
|
|
v.Add("last", repositories[len(repositories)-1])
|
|
|
|
ctx.Resp.Header().Set("Link", fmt.Sprintf(`</v2/_catalog?%s>; rel="next"`, v.Encode()))
|
|
}
|
|
|
|
jsonResponse(ctx, http.StatusOK, RepositoryList{
|
|
Repositories: repositories,
|
|
})
|
|
}
|
|
|
|
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#mounting-a-blob-from-another-repository
|
|
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#single-post
|
|
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pushing-a-blob-in-chunks
|
|
func PostBlobsUploads(ctx *context.Context) {
|
|
image := ctx.PathParam("image")
|
|
|
|
mount := ctx.FormTrim("mount")
|
|
from := ctx.FormTrim("from")
|
|
if mount != "" {
|
|
blob, _ := workaroundGetContainerBlob(ctx, &container_model.BlobSearchOptions{
|
|
Repository: from,
|
|
Digest: mount,
|
|
})
|
|
if blob != nil {
|
|
accessible, err := packages_model.IsBlobAccessibleForUser(ctx, blob.Blob.ID, ctx.Doer)
|
|
if err != nil {
|
|
apiError(ctx, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
|
|
if accessible {
|
|
if err := mountBlob(ctx, &packages_service.PackageInfo{Owner: ctx.Package.Owner, Name: image}, blob.Blob); err != nil {
|
|
apiError(ctx, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
|
|
setResponseHeaders(ctx.Resp, &containerHeaders{
|
|
Location: fmt.Sprintf("/v2/%s/%s/blobs/%s", ctx.Package.Owner.LowerName, image, mount),
|
|
ContentDigest: mount,
|
|
Status: http.StatusCreated,
|
|
})
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
digest := ctx.FormTrim("digest")
|
|
if digest != "" {
|
|
buf, err := packages_module.CreateHashedBufferFromReader(ctx.Req.Body)
|
|
if err != nil {
|
|
apiError(ctx, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
defer buf.Close()
|
|
|
|
if digest != digestFromHashSummer(buf) {
|
|
apiErrorDefined(ctx, errDigestInvalid)
|
|
return
|
|
}
|
|
|
|
if _, err := saveAsPackageBlob(ctx,
|
|
buf,
|
|
&packages_service.PackageCreationInfo{
|
|
PackageInfo: packages_service.PackageInfo{
|
|
Owner: ctx.Package.Owner,
|
|
Name: image,
|
|
},
|
|
Creator: ctx.Doer,
|
|
},
|
|
); err != nil {
|
|
switch {
|
|
case errors.Is(err, packages_service.ErrQuotaTotalCount), errors.Is(err, packages_service.ErrQuotaTypeSize), errors.Is(err, packages_service.ErrQuotaTotalSize):
|
|
apiError(ctx, http.StatusForbidden, err)
|
|
default:
|
|
apiError(ctx, http.StatusInternalServerError, err)
|
|
}
|
|
return
|
|
}
|
|
|
|
setResponseHeaders(ctx.Resp, &containerHeaders{
|
|
Location: fmt.Sprintf("/v2/%s/%s/blobs/%s", ctx.Package.Owner.LowerName, image, digest),
|
|
ContentDigest: digest,
|
|
Status: http.StatusCreated,
|
|
})
|
|
return
|
|
}
|
|
|
|
upload, err := packages_model.CreateBlobUpload(ctx)
|
|
if err != nil {
|
|
apiError(ctx, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
|
|
setResponseHeaders(ctx.Resp, &containerHeaders{
|
|
Location: fmt.Sprintf("/v2/%s/%s/blobs/uploads/%s", ctx.Package.Owner.LowerName, image, upload.ID),
|
|
UploadUUID: upload.ID,
|
|
Status: http.StatusAccepted,
|
|
})
|
|
}
|
|
|
|
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pushing-a-blob-in-chunks
|
|
func GetBlobsUpload(ctx *context.Context) {
|
|
image := ctx.PathParam("image")
|
|
uuid := ctx.PathParam("uuid")
|
|
|
|
upload, err := packages_model.GetBlobUploadByID(ctx, uuid)
|
|
if err != nil {
|
|
if errors.Is(err, packages_model.ErrPackageBlobUploadNotExist) {
|
|
apiErrorDefined(ctx, errBlobUploadUnknown)
|
|
} else {
|
|
apiError(ctx, http.StatusInternalServerError, err)
|
|
}
|
|
return
|
|
}
|
|
|
|
// FIXME: undefined behavior when the uploaded content is empty: https://github.com/opencontainers/distribution-spec/issues/578
|
|
respHeaders := &containerHeaders{
|
|
Location: fmt.Sprintf("/v2/%s/%s/blobs/uploads/%s", ctx.Package.Owner.LowerName, image, upload.ID),
|
|
UploadUUID: upload.ID,
|
|
Status: http.StatusNoContent,
|
|
}
|
|
if upload.BytesReceived > 0 {
|
|
respHeaders.Range = fmt.Sprintf("0-%d", upload.BytesReceived-1)
|
|
}
|
|
setResponseHeaders(ctx.Resp, respHeaders)
|
|
}
|
|
|
|
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#single-post
|
|
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pushing-a-blob-in-chunks
|
|
func PatchBlobsUpload(ctx *context.Context) {
|
|
image := ctx.PathParam("image")
|
|
|
|
uploader, err := container_service.NewBlobUploader(ctx, ctx.PathParam("uuid"))
|
|
if err != nil {
|
|
if errors.Is(err, packages_model.ErrPackageBlobUploadNotExist) {
|
|
apiErrorDefined(ctx, errBlobUploadUnknown)
|
|
} else {
|
|
apiError(ctx, http.StatusInternalServerError, err)
|
|
}
|
|
return
|
|
}
|
|
defer uploader.Close()
|
|
|
|
contentRange := ctx.Req.Header.Get("Content-Range")
|
|
if contentRange != "" {
|
|
start, end := 0, 0
|
|
if _, err := fmt.Sscanf(contentRange, "%d-%d", &start, &end); err != nil {
|
|
apiErrorDefined(ctx, errBlobUploadInvalid)
|
|
return
|
|
}
|
|
|
|
if int64(start) != uploader.Size() {
|
|
apiErrorDefined(ctx, errBlobUploadInvalid.WithStatusCode(http.StatusRequestedRangeNotSatisfiable))
|
|
return
|
|
}
|
|
} else if uploader.Size() != 0 {
|
|
apiErrorDefined(ctx, errBlobUploadInvalid.WithMessage("Stream uploads after first write are not allowed"))
|
|
return
|
|
}
|
|
|
|
if err := uploader.Append(ctx, ctx.Req.Body); err != nil {
|
|
apiError(ctx, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
|
|
respHeaders := &containerHeaders{
|
|
Location: fmt.Sprintf("/v2/%s/%s/blobs/uploads/%s", ctx.Package.Owner.LowerName, image, uploader.ID),
|
|
UploadUUID: uploader.ID,
|
|
Status: http.StatusAccepted,
|
|
}
|
|
if uploader.Size() > 0 {
|
|
respHeaders.Range = fmt.Sprintf("0-%d", uploader.Size()-1)
|
|
}
|
|
setResponseHeaders(ctx.Resp, respHeaders)
|
|
}
|
|
|
|
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pushing-a-blob-in-chunks
|
|
func PutBlobsUpload(ctx *context.Context) {
|
|
image := ctx.PathParam("image")
|
|
|
|
digest := ctx.FormTrim("digest")
|
|
if digest == "" {
|
|
apiErrorDefined(ctx, errDigestInvalid)
|
|
return
|
|
}
|
|
|
|
uploader, err := container_service.NewBlobUploader(ctx, ctx.PathParam("uuid"))
|
|
if err != nil {
|
|
if errors.Is(err, packages_model.ErrPackageBlobUploadNotExist) {
|
|
apiErrorDefined(ctx, errBlobUploadUnknown)
|
|
} else {
|
|
apiError(ctx, http.StatusInternalServerError, err)
|
|
}
|
|
return
|
|
}
|
|
defer uploader.Close()
|
|
|
|
if ctx.Req.Body != nil {
|
|
if err := uploader.Append(ctx, ctx.Req.Body); err != nil {
|
|
apiError(ctx, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
}
|
|
|
|
if digest != digestFromHashSummer(uploader) {
|
|
apiErrorDefined(ctx, errDigestInvalid)
|
|
return
|
|
}
|
|
|
|
if _, err := saveAsPackageBlob(ctx,
|
|
uploader,
|
|
&packages_service.PackageCreationInfo{
|
|
PackageInfo: packages_service.PackageInfo{
|
|
Owner: ctx.Package.Owner,
|
|
Name: image,
|
|
},
|
|
Creator: ctx.Doer,
|
|
},
|
|
); err != nil {
|
|
switch {
|
|
case errors.Is(err, packages_service.ErrQuotaTotalCount), errors.Is(err, packages_service.ErrQuotaTypeSize), errors.Is(err, packages_service.ErrQuotaTotalSize):
|
|
apiError(ctx, http.StatusForbidden, err)
|
|
default:
|
|
apiError(ctx, http.StatusInternalServerError, err)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Some SDK (e.g.: the S3 client) will close the Reader if it is also a Closer after "uploading".
|
|
// And we don't need to wrap the reader to anything else because the SDK will benefit from other interfaces like Seeker.
|
|
// It's safe to call Close twice, so ignore the error.
|
|
_ = uploader.Close()
|
|
|
|
if err := container_service.RemoveBlobUploadByID(ctx, uploader.ID); err != nil {
|
|
apiError(ctx, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
|
|
setResponseHeaders(ctx.Resp, &containerHeaders{
|
|
Location: fmt.Sprintf("/v2/%s/%s/blobs/%s", ctx.Package.Owner.LowerName, image, digest),
|
|
ContentDigest: digest,
|
|
Status: http.StatusCreated,
|
|
})
|
|
}
|
|
|
|
// https://docs.docker.com/registry/spec/api/#delete-blob-upload
|
|
func DeleteBlobsUpload(ctx *context.Context) {
|
|
uuid := ctx.PathParam("uuid")
|
|
|
|
_, err := packages_model.GetBlobUploadByID(ctx, uuid)
|
|
if err != nil {
|
|
if errors.Is(err, packages_model.ErrPackageBlobUploadNotExist) {
|
|
apiErrorDefined(ctx, errBlobUploadUnknown)
|
|
} else {
|
|
apiError(ctx, http.StatusInternalServerError, err)
|
|
}
|
|
return
|
|
}
|
|
|
|
if err := container_service.RemoveBlobUploadByID(ctx, uuid); err != nil {
|
|
apiError(ctx, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
|
|
setResponseHeaders(ctx.Resp, &containerHeaders{
|
|
Status: http.StatusNoContent,
|
|
})
|
|
}
|
|
|
|
func getBlobFromContext(ctx *context.Context) (*packages_model.PackageFileDescriptor, error) {
|
|
d := digest.Digest(ctx.PathParam("digest"))
|
|
if d.Validate() != nil {
|
|
return nil, container_model.ErrContainerBlobNotExist
|
|
}
|
|
|
|
return workaroundGetContainerBlob(ctx, &container_model.BlobSearchOptions{
|
|
OwnerID: ctx.Package.Owner.ID,
|
|
Image: ctx.PathParam("image"),
|
|
Digest: string(d),
|
|
})
|
|
}
|
|
|
|
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#checking-if-content-exists-in-the-registry
|
|
func HeadBlob(ctx *context.Context) {
|
|
blob, err := getBlobFromContext(ctx)
|
|
if err != nil {
|
|
if errors.Is(err, container_model.ErrContainerBlobNotExist) {
|
|
apiErrorDefined(ctx, errBlobUnknown)
|
|
} else {
|
|
apiError(ctx, http.StatusInternalServerError, err)
|
|
}
|
|
return
|
|
}
|
|
|
|
setResponseHeaders(ctx.Resp, &containerHeaders{
|
|
ContentDigest: blob.Properties.GetByName(container_module.PropertyDigest),
|
|
ContentLength: optional.Some(blob.Blob.Size),
|
|
Status: http.StatusOK,
|
|
})
|
|
}
|
|
|
|
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pulling-blobs
|
|
func GetBlob(ctx *context.Context) {
|
|
blob, err := getBlobFromContext(ctx)
|
|
if err != nil {
|
|
if errors.Is(err, container_model.ErrContainerBlobNotExist) {
|
|
apiErrorDefined(ctx, errBlobUnknown)
|
|
} else {
|
|
apiError(ctx, http.StatusInternalServerError, err)
|
|
}
|
|
return
|
|
}
|
|
|
|
serveBlob(ctx, blob)
|
|
}
|
|
|
|
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#deleting-blobs
|
|
func DeleteBlob(ctx *context.Context) {
|
|
d := digest.Digest(ctx.PathParam("digest"))
|
|
if d.Validate() != nil {
|
|
apiErrorDefined(ctx, errBlobUnknown)
|
|
return
|
|
}
|
|
|
|
if err := deleteBlob(ctx, ctx.Package.Owner.ID, ctx.PathParam("image"), d); err != nil {
|
|
apiError(ctx, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
|
|
setResponseHeaders(ctx.Resp, &containerHeaders{
|
|
Status: http.StatusAccepted,
|
|
})
|
|
}
|
|
|
|
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pushing-manifests
|
|
func PutManifest(ctx *context.Context) {
|
|
reference := ctx.PathParam("reference")
|
|
|
|
mci := &manifestCreationInfo{
|
|
MediaType: ctx.Req.Header.Get("Content-Type"),
|
|
Owner: ctx.Package.Owner,
|
|
Creator: ctx.Doer,
|
|
Image: ctx.PathParam("image"),
|
|
Reference: reference,
|
|
IsTagged: digest.Digest(reference).Validate() != nil,
|
|
}
|
|
|
|
if mci.IsTagged && !globalVars().referencePattern.MatchString(reference) {
|
|
apiErrorDefined(ctx, errManifestInvalid.WithMessage("Tag is invalid"))
|
|
return
|
|
}
|
|
|
|
maxSize := maxManifestSize + 1
|
|
buf, err := packages_module.CreateHashedBufferFromReaderWithSize(&io.LimitedReader{R: ctx.Req.Body, N: int64(maxSize)}, maxSize)
|
|
if err != nil {
|
|
apiError(ctx, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
defer buf.Close()
|
|
|
|
if buf.Size() > maxManifestSize {
|
|
apiErrorDefined(ctx, errManifestInvalid.WithMessage("Manifest exceeds maximum size").WithStatusCode(http.StatusRequestEntityTooLarge))
|
|
return
|
|
}
|
|
|
|
digest, err := processManifest(ctx, mci, buf)
|
|
if err != nil {
|
|
var namedError *namedError
|
|
if errors.As(err, &namedError) {
|
|
apiErrorDefined(ctx, namedError)
|
|
} else if errors.Is(err, container_model.ErrContainerBlobNotExist) {
|
|
apiErrorDefined(ctx, errBlobUnknown)
|
|
} else if errors.Is(err, packages_service.ErrQuotaTotalCount) || errors.Is(err, packages_service.ErrQuotaTypeSize) || errors.Is(err, packages_service.ErrQuotaTotalSize) {
|
|
apiError(ctx, http.StatusForbidden, err)
|
|
} else {
|
|
apiError(ctx, http.StatusInternalServerError, err)
|
|
}
|
|
return
|
|
}
|
|
|
|
setResponseHeaders(ctx.Resp, &containerHeaders{
|
|
Location: fmt.Sprintf("/v2/%s/%s/manifests/%s", ctx.Package.Owner.LowerName, mci.Image, reference),
|
|
ContentDigest: digest,
|
|
Status: http.StatusCreated,
|
|
})
|
|
}
|
|
|
|
func getBlobSearchOptionsFromContext(ctx *context.Context) (*container_model.BlobSearchOptions, error) {
|
|
opts := &container_model.BlobSearchOptions{
|
|
OwnerID: ctx.Package.Owner.ID,
|
|
Image: ctx.PathParam("image"),
|
|
IsManifest: true,
|
|
}
|
|
|
|
reference := ctx.PathParam("reference")
|
|
if d := digest.Digest(reference); d.Validate() == nil {
|
|
opts.Digest = string(d)
|
|
} else if globalVars().referencePattern.MatchString(reference) {
|
|
opts.Tag = reference
|
|
opts.OnlyLead = true
|
|
} else {
|
|
return nil, container_model.ErrContainerBlobNotExist
|
|
}
|
|
|
|
return opts, nil
|
|
}
|
|
|
|
func getManifestFromContext(ctx *context.Context) (*packages_model.PackageFileDescriptor, error) {
|
|
opts, err := getBlobSearchOptionsFromContext(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return workaroundGetContainerBlob(ctx, opts)
|
|
}
|
|
|
|
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#checking-if-content-exists-in-the-registry
|
|
func HeadManifest(ctx *context.Context) {
|
|
manifest, err := getManifestFromContext(ctx)
|
|
if err != nil {
|
|
if errors.Is(err, container_model.ErrContainerBlobNotExist) {
|
|
apiErrorDefined(ctx, errManifestUnknown)
|
|
} else {
|
|
apiError(ctx, http.StatusInternalServerError, err)
|
|
}
|
|
return
|
|
}
|
|
|
|
setResponseHeaders(ctx.Resp, &containerHeaders{
|
|
ContentDigest: manifest.Properties.GetByName(container_module.PropertyDigest),
|
|
ContentType: manifest.Properties.GetByName(container_module.PropertyMediaType),
|
|
ContentLength: optional.Some(manifest.Blob.Size),
|
|
Status: http.StatusOK,
|
|
})
|
|
}
|
|
|
|
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pulling-manifests
|
|
func GetManifest(ctx *context.Context) {
|
|
manifest, err := getManifestFromContext(ctx)
|
|
if err != nil {
|
|
if errors.Is(err, container_model.ErrContainerBlobNotExist) {
|
|
apiErrorDefined(ctx, errManifestUnknown)
|
|
} else {
|
|
apiError(ctx, http.StatusInternalServerError, err)
|
|
}
|
|
return
|
|
}
|
|
|
|
serveBlob(ctx, manifest)
|
|
}
|
|
|
|
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#deleting-tags
|
|
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#deleting-manifests
|
|
func DeleteManifest(ctx *context.Context) {
|
|
opts, err := getBlobSearchOptionsFromContext(ctx)
|
|
if err != nil {
|
|
apiErrorDefined(ctx, errManifestUnknown)
|
|
return
|
|
}
|
|
|
|
pvs, err := container_model.GetManifestVersions(ctx, opts)
|
|
if err != nil {
|
|
apiError(ctx, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
|
|
if len(pvs) == 0 {
|
|
apiErrorDefined(ctx, errManifestUnknown)
|
|
return
|
|
}
|
|
|
|
for _, pv := range pvs {
|
|
if err := packages_service.RemovePackageVersion(ctx, ctx.Doer, pv); err != nil {
|
|
apiError(ctx, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
}
|
|
|
|
setResponseHeaders(ctx.Resp, &containerHeaders{
|
|
Status: http.StatusAccepted,
|
|
})
|
|
}
|
|
|
|
func serveBlob(ctx *context.Context, pfd *packages_model.PackageFileDescriptor) {
|
|
s, u, _, err := packages_service.OpenBlobForDownload(ctx, pfd.File, pfd.Blob, ctx.Req.Method, &storage.ServeDirectOptions{
|
|
ContentType: pfd.Properties.GetByName(container_module.PropertyMediaType),
|
|
})
|
|
if err != nil {
|
|
apiError(ctx, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
|
|
headers := &containerHeaders{
|
|
ContentDigest: pfd.Properties.GetByName(container_module.PropertyDigest),
|
|
ContentType: pfd.Properties.GetByName(container_module.PropertyMediaType),
|
|
ContentLength: optional.Some(pfd.Blob.Size),
|
|
Status: http.StatusOK,
|
|
}
|
|
|
|
if u != nil {
|
|
headers.Status = http.StatusTemporaryRedirect
|
|
headers.Location = u.String()
|
|
headers.ContentLength = optional.None[int64]() // do not set Content-Length for redirect responses
|
|
setResponseHeaders(ctx.Resp, headers)
|
|
return
|
|
}
|
|
|
|
defer s.Close()
|
|
|
|
setResponseHeaders(ctx.Resp, headers)
|
|
_, _ = io.Copy(ctx.Resp, s)
|
|
}
|
|
|
|
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#content-discovery
|
|
func GetTagsList(ctx *context.Context) {
|
|
image := ctx.PathParam("image")
|
|
|
|
if _, err := packages_model.GetPackageByName(ctx, ctx.Package.Owner.ID, packages_model.TypeContainer, image); err != nil {
|
|
if errors.Is(err, packages_model.ErrPackageNotExist) {
|
|
apiErrorDefined(ctx, errNameUnknown)
|
|
} else {
|
|
apiError(ctx, http.StatusInternalServerError, err)
|
|
}
|
|
return
|
|
}
|
|
|
|
n := -1
|
|
if ctx.FormTrim("n") != "" {
|
|
n = ctx.FormInt("n")
|
|
}
|
|
last := ctx.FormTrim("last")
|
|
|
|
tags, err := container_model.GetImageTags(ctx, ctx.Package.Owner.ID, image, n, last)
|
|
if err != nil {
|
|
apiError(ctx, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
|
|
type TagList struct {
|
|
Name string `json:"name"`
|
|
Tags []string `json:"tags"`
|
|
}
|
|
|
|
if len(tags) > 0 {
|
|
v := url.Values{}
|
|
if n > 0 {
|
|
v.Add("n", strconv.Itoa(n))
|
|
}
|
|
v.Add("last", tags[len(tags)-1])
|
|
|
|
ctx.Resp.Header().Set("Link", fmt.Sprintf(`</v2/%s/%s/tags/list?%s>; rel="next"`, ctx.Package.Owner.LowerName, image, v.Encode()))
|
|
}
|
|
|
|
jsonResponse(ctx, http.StatusOK, TagList{
|
|
Name: strings.ToLower(ctx.Package.Owner.LowerName + "/" + image),
|
|
Tags: tags,
|
|
})
|
|
}
|
|
|
|
// FIXME: Workaround to be removed in v1.20.
|
|
// Update maybe we should never really remote it, as long as there is legacy data?
|
|
func workaroundGetContainerBlob(ctx *context.Context, opts *container_model.BlobSearchOptions) (*packages_model.PackageFileDescriptor, error) {
|
|
blob, err := container_model.GetContainerBlob(ctx, opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
err = packages_module.NewContentStore().Has(packages_module.BlobHash256Key(blob.Blob.HashSHA256))
|
|
if err != nil {
|
|
if errors.Is(err, util.ErrNotExist) || errors.Is(err, os.ErrNotExist) {
|
|
log.Debug("Package registry inconsistent: blob %s does not exist on file system", blob.Blob.HashSHA256)
|
|
return nil, container_model.ErrContainerBlobNotExist
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
return blob, nil
|
|
}
|