mirror of
https://github.com/project-zot/zot.git
synced 2026-06-15 11:37:56 +08:00
9aff5b8d08
* chore: fix dependabot alerts Signed-off-by: Ramkumar Chinchani <rchincha.dev@gmail.com> * chore: fix dependabot alerts Signed-off-by: Ramkumar Chinchani <rchincha.dev@gmail.com> * chore: fix dependabot alerts Signed-off-by: Ramkumar Chinchani <rchincha.dev@gmail.com> * chore: fix golangci-lint findings from CI Signed-off-by: Ramkumar Chinchani <rchincha.dev@gmail.com> * chore: fix golangci-lint gosec warnings Signed-off-by: Ramkumar Chinchani <rchincha.dev@gmail.com> * chore: update code to use slices package and address gosec linting issues Signed-off-by: Ramkumar Chinchani <rchincha.dev@gmail.com> * build: fix makefile target Signed-off-by: Ramkumar Chinchani <rchincha.dev@gmail.com> * chore: update tests to use context in HTTP requests and add gosec annotations Signed-off-by: Ramkumar Chinchani <rchincha.dev@gmail.com> * chore: update tests to use context in HTTP requests Signed-off-by: Ramkumar Chinchani <rchincha.dev@gmail.com> * chore: update tests to use context in HTTP requests Signed-off-by: Ramkumar Chinchani <rchincha.dev@gmail.com> * chore: update tests to use context in HTTP requests Signed-off-by: Ramkumar Chinchani <rchincha.dev@gmail.com> * chore: update tests to use context in HTTP requests Signed-off-by: Ramkumar Chinchani <rchincha.dev@gmail.com> * chore: bump zui version Signed-off-by: Ramkumar Chinchani <rchincha.dev@gmail.com> * chore: update test helpers and improve security settings in tests Signed-off-by: Ramkumar Chinchani <rchincha.dev@gmail.com> * chore: add gosec linting directive for test path construction Signed-off-by: Ramkumar Chinchani <rchincha.dev@gmail.com> --------- Signed-off-by: Ramkumar Chinchani <rchincha.dev@gmail.com>
2885 lines
93 KiB
Go
2885 lines
93 KiB
Go
// @title Open Container Initiative Distribution Specification
|
|
// @version v1.1.1
|
|
// @description APIs for Open Container Initiative Distribution Specification
|
|
|
|
// @license.name Apache 2.0
|
|
// @license.url http://www.apache.org/licenses/LICENSE-2.0.html
|
|
|
|
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"mime"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/textproto"
|
|
"net/url"
|
|
"path"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
guuid "github.com/gofrs/uuid"
|
|
"github.com/google/go-github/v62/github"
|
|
"github.com/gorilla/mux"
|
|
jsoniter "github.com/json-iterator/go"
|
|
"github.com/opencontainers/distribution-spec/specs-go/v1/extensions"
|
|
godigest "github.com/opencontainers/go-digest"
|
|
ispec "github.com/opencontainers/image-spec/specs-go/v1"
|
|
"github.com/zitadel/oidc/v3/pkg/client/rp"
|
|
"github.com/zitadel/oidc/v3/pkg/oidc"
|
|
|
|
zerr "zotregistry.dev/zot/v2/errors"
|
|
"zotregistry.dev/zot/v2/pkg/api/config"
|
|
"zotregistry.dev/zot/v2/pkg/api/constants"
|
|
apiErr "zotregistry.dev/zot/v2/pkg/api/errors"
|
|
zcommon "zotregistry.dev/zot/v2/pkg/common"
|
|
gqlPlayground "zotregistry.dev/zot/v2/pkg/debug/gqlplayground"
|
|
"zotregistry.dev/zot/v2/pkg/debug/pprof"
|
|
debug "zotregistry.dev/zot/v2/pkg/debug/swagger"
|
|
ext "zotregistry.dev/zot/v2/pkg/extensions"
|
|
"zotregistry.dev/zot/v2/pkg/log"
|
|
"zotregistry.dev/zot/v2/pkg/meta"
|
|
mTypes "zotregistry.dev/zot/v2/pkg/meta/types"
|
|
zreg "zotregistry.dev/zot/v2/pkg/regexp"
|
|
reqCtx "zotregistry.dev/zot/v2/pkg/requestcontext"
|
|
"zotregistry.dev/zot/v2/pkg/storage"
|
|
storageCommon "zotregistry.dev/zot/v2/pkg/storage/common"
|
|
storageTypes "zotregistry.dev/zot/v2/pkg/storage/types"
|
|
"zotregistry.dev/zot/v2/pkg/test/inject"
|
|
)
|
|
|
|
type RouteHandler struct {
|
|
c *Controller
|
|
}
|
|
|
|
func NewRouteHandler(c *Controller) *RouteHandler {
|
|
rh := &RouteHandler{c: c}
|
|
rh.SetupRoutes()
|
|
|
|
return rh
|
|
}
|
|
|
|
func (rh *RouteHandler) SetupRoutes() {
|
|
// health endpoints get added first
|
|
rh.c.Router.Path("/livez").Handler(rh.c.Healthz.Handler)
|
|
rh.c.Router.Path("/readyz").Handler(rh.c.Healthz.Handler)
|
|
rh.c.Router.Path("/startupz").Handler(rh.c.Healthz.Handler)
|
|
|
|
// first get Auth middleware in order to first setup openid/ldap/htpasswd, before oidc provider routes are setup
|
|
authHandler := AuthHandler(rh.c)
|
|
|
|
// Get CORS config safely
|
|
allowOrigin := rh.c.Config.GetAllowOrigin()
|
|
applyCORSHeaders := getCORSHeadersHandler(allowOrigin)
|
|
|
|
// Get auth config for OpenID checks
|
|
authConfig := rh.c.Config.CopyAuthConfig()
|
|
if authConfig.IsOpenIDAuthEnabled() {
|
|
// login path for openID
|
|
rh.c.Router.HandleFunc(constants.LoginPath, rh.AuthURLHandler())
|
|
|
|
// callback path for openID
|
|
for provider, relyingParty := range rh.c.RelyingParties {
|
|
if config.IsOauth2Supported(provider) {
|
|
rh.c.Router.HandleFunc(constants.CallbackBasePath+"/"+provider,
|
|
rp.CodeExchangeHandler(rh.GithubCodeExchangeCallback(), relyingParty))
|
|
} else if config.IsOpenIDSupported(provider) {
|
|
rh.c.Router.HandleFunc(constants.CallbackBasePath+"/"+provider,
|
|
rp.CodeExchangeHandler(rp.UserinfoCallback(rh.OpenIDCodeExchangeCallbackWithProvider(provider)), relyingParty))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Get auth config for API key checks
|
|
if authConfig.IsAPIKeyEnabled() {
|
|
// enable api key management urls
|
|
apiKeyRouter := rh.c.Router.PathPrefix(constants.APIKeyPath).Subrouter()
|
|
apiKeyRouter.Use(authHandler)
|
|
apiKeyRouter.Use(BaseAuthzHandler(rh.c))
|
|
|
|
// Always use CORSHeadersMiddleware before ACHeadersMiddleware
|
|
apiKeyRouter.Use(zcommon.CORSHeadersMiddleware(rh.c.Config.GetAllowOrigin()))
|
|
apiKeyRouter.Use(zcommon.ACHeadersMiddleware(rh.c.Config,
|
|
http.MethodGet, http.MethodPost, http.MethodDelete, http.MethodOptions))
|
|
|
|
apiKeyRouter.Methods(http.MethodPost, http.MethodOptions).HandlerFunc(rh.CreateAPIKey)
|
|
apiKeyRouter.Methods(http.MethodGet).HandlerFunc(rh.GetAPIKeys)
|
|
apiKeyRouter.Methods(http.MethodDelete).HandlerFunc(rh.RevokeAPIKey)
|
|
}
|
|
|
|
/* on every route which may be used by UI we set OPTIONS as allowed METHOD
|
|
to enable preflight request from UI to backend */
|
|
if authConfig.IsBasicAuthnEnabled() {
|
|
// logout path for openID
|
|
rh.c.Router.HandleFunc(constants.LogoutPath,
|
|
getUIHeadersHandler(rh.c.Config, http.MethodPost, http.MethodOptions)(applyCORSHeaders(rh.Logout))).
|
|
Methods(http.MethodPost, http.MethodOptions)
|
|
}
|
|
|
|
prefixedRouter := rh.c.Router.PathPrefix(constants.RoutePrefix).Subrouter()
|
|
prefixedRouter.Use(authHandler)
|
|
|
|
prefixedDistSpecRouter := prefixedRouter.NewRoute().Subrouter()
|
|
// authz is being enabled if AccessControl is specified
|
|
// if Authn is not present AccessControl will have only default policies
|
|
accessControlConfig := rh.c.Config.CopyAccessControlConfig()
|
|
if accessControlConfig != nil {
|
|
if authConfig.IsBasicAuthnEnabled() {
|
|
rh.c.Log.Info().Msg("access control is being enabled")
|
|
} else {
|
|
rh.c.Log.Info().Msg("anonymous policy only access control is being enabled")
|
|
}
|
|
|
|
prefixedRouter.Use(BaseAuthzHandler(rh.c))
|
|
prefixedDistSpecRouter.Use(DistSpecAuthzHandler(rh.c))
|
|
}
|
|
|
|
clusterRouteProxy := ClusterProxy(rh.c)
|
|
|
|
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#endpoints
|
|
// dist-spec APIs that need to be proxied are wrapped in clusterRouteProxy for scale-out proxying.
|
|
// these are handlers that have a repository name.
|
|
{
|
|
prefixedDistSpecRouter.HandleFunc(fmt.Sprintf("/{name:%s}/tags/list", zreg.NameRegexp.String()),
|
|
clusterRouteProxy(
|
|
getUIHeadersHandler(rh.c.Config, http.MethodGet, http.MethodOptions)(
|
|
applyCORSHeaders(rh.ListTags),
|
|
),
|
|
),
|
|
).Methods(http.MethodGet, http.MethodOptions)
|
|
prefixedDistSpecRouter.HandleFunc(fmt.Sprintf("/{name:%s}/manifests/{reference}", zreg.NameRegexp.String()),
|
|
clusterRouteProxy(
|
|
getUIHeadersHandler(rh.c.Config, http.MethodHead, http.MethodGet, http.MethodDelete, http.MethodOptions)(
|
|
applyCORSHeaders(rh.CheckManifest),
|
|
),
|
|
),
|
|
).Methods(http.MethodHead, http.MethodOptions)
|
|
prefixedDistSpecRouter.HandleFunc(fmt.Sprintf("/{name:%s}/manifests/{reference}", zreg.NameRegexp.String()),
|
|
clusterRouteProxy(
|
|
applyCORSHeaders(rh.GetManifest),
|
|
),
|
|
).Methods(http.MethodGet)
|
|
prefixedDistSpecRouter.HandleFunc(fmt.Sprintf("/{name:%s}/manifests/{reference}", zreg.NameRegexp.String()),
|
|
clusterRouteProxy(rh.UpdateManifest)).Methods(http.MethodPut)
|
|
prefixedDistSpecRouter.HandleFunc(fmt.Sprintf("/{name:%s}/manifests/{reference}", zreg.NameRegexp.String()),
|
|
clusterRouteProxy(
|
|
applyCORSHeaders(rh.DeleteManifest),
|
|
),
|
|
).Methods(http.MethodDelete)
|
|
prefixedDistSpecRouter.HandleFunc(fmt.Sprintf("/{name:%s}/blobs/{digest}", zreg.NameRegexp.String()),
|
|
clusterRouteProxy(rh.CheckBlob)).Methods(http.MethodHead)
|
|
prefixedDistSpecRouter.HandleFunc(fmt.Sprintf("/{name:%s}/blobs/{digest}", zreg.NameRegexp.String()),
|
|
clusterRouteProxy(rh.GetBlob)).Methods(http.MethodGet)
|
|
prefixedDistSpecRouter.HandleFunc(fmt.Sprintf("/{name:%s}/blobs/{digest}", zreg.NameRegexp.String()),
|
|
clusterRouteProxy(rh.DeleteBlob)).Methods(http.MethodDelete)
|
|
prefixedDistSpecRouter.HandleFunc(fmt.Sprintf("/{name:%s}/blobs/uploads/", zreg.NameRegexp.String()),
|
|
clusterRouteProxy(rh.CreateBlobUpload)).Methods(http.MethodPost)
|
|
prefixedDistSpecRouter.HandleFunc(fmt.Sprintf("/{name:%s}/blobs/uploads/{session_id}", zreg.NameRegexp.String()),
|
|
clusterRouteProxy(rh.GetBlobUpload)).Methods(http.MethodGet)
|
|
prefixedDistSpecRouter.HandleFunc(fmt.Sprintf("/{name:%s}/blobs/uploads/{session_id}", zreg.NameRegexp.String()),
|
|
clusterRouteProxy(rh.PatchBlobUpload)).Methods(http.MethodPatch)
|
|
prefixedDistSpecRouter.HandleFunc(fmt.Sprintf("/{name:%s}/blobs/uploads/{session_id}", zreg.NameRegexp.String()),
|
|
clusterRouteProxy(rh.UpdateBlobUpload)).Methods(http.MethodPut)
|
|
prefixedDistSpecRouter.HandleFunc(fmt.Sprintf("/{name:%s}/blobs/uploads/{session_id}", zreg.NameRegexp.String()),
|
|
clusterRouteProxy(rh.DeleteBlobUpload)).Methods(http.MethodDelete)
|
|
// support for OCI artifact references
|
|
prefixedDistSpecRouter.HandleFunc(fmt.Sprintf("/{name:%s}/referrers/{digest}", zreg.NameRegexp.String()),
|
|
clusterRouteProxy(
|
|
getUIHeadersHandler(rh.c.Config, http.MethodGet, http.MethodOptions)(
|
|
applyCORSHeaders(rh.GetReferrers),
|
|
),
|
|
),
|
|
).Methods(http.MethodGet, http.MethodOptions)
|
|
|
|
// handlers which work fine with a single node do not need proxying.
|
|
// catalog handler doesn't require proxying as the metadata and storage are shared.
|
|
// discover and the default path handlers are node-specific so do not require proxying.
|
|
prefixedRouter.HandleFunc(constants.ExtCatalogPrefix,
|
|
getUIHeadersHandler(rh.c.Config, http.MethodGet, http.MethodOptions)(
|
|
applyCORSHeaders(rh.ListRepositories))).Methods(http.MethodGet, http.MethodOptions)
|
|
prefixedRouter.HandleFunc(constants.ExtOciDiscoverPrefix,
|
|
getUIHeadersHandler(rh.c.Config, http.MethodGet, http.MethodOptions)(
|
|
applyCORSHeaders(rh.ListExtensions))).Methods(http.MethodGet, http.MethodOptions)
|
|
prefixedRouter.HandleFunc("/",
|
|
getUIHeadersHandler(rh.c.Config, http.MethodGet, http.MethodOptions)(
|
|
applyCORSHeaders(rh.CheckVersionSupport))).Methods(http.MethodGet, http.MethodOptions)
|
|
}
|
|
|
|
// swagger
|
|
debug.SetupSwaggerRoutes(rh.c.Config, rh.c.Router, authHandler, rh.c.Log)
|
|
// gql playground
|
|
gqlPlayground.SetupGQLPlaygroundRoutes(prefixedRouter, rh.c.StoreController, rh.c.Log)
|
|
// pprof
|
|
pprof.SetupPprofRoutes(rh.c.Config, prefixedRouter, authHandler, rh.c.Log)
|
|
|
|
// Preconditions for enabling the actual extension routes are part of extensions themselves
|
|
ext.SetupMetricsRoutes(rh.c.Config, rh.c.Router, authHandler, MetricsAuthzHandler(rh.c), rh.c.Log, rh.c.Metrics)
|
|
ext.SetupSearchRoutes(rh.c.Config, prefixedRouter, rh.c.StoreController, rh.c.MetaDB, rh.c.CveScanner,
|
|
rh.c.Log)
|
|
ext.SetupImageTrustRoutes(rh.c.Config, prefixedRouter, rh.c.MetaDB, rh.c.Log)
|
|
ext.SetupMgmtRoutes(rh.c.Config, prefixedRouter, rh.c.Log)
|
|
ext.SetupUserPreferencesRoutes(rh.c.Config, prefixedRouter, rh.c.MetaDB, rh.c.Log)
|
|
setupQuotaMiddleware(rh.c.Config, prefixedDistSpecRouter, rh.c.MetaDB, rh.c.Log)
|
|
// last should always be UI because it will setup a http.FileServer and paths will be resolved by this FileServer.
|
|
ext.SetupUIRoutes(rh.c.Config, rh.c.Router, rh.c.Log)
|
|
}
|
|
|
|
func getCORSHeadersHandler(allowOrigin string) func(http.HandlerFunc) http.HandlerFunc {
|
|
return func(next http.HandlerFunc) http.HandlerFunc {
|
|
return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
|
zcommon.AddCORSHeaders(allowOrigin, response)
|
|
|
|
next.ServeHTTP(response, request)
|
|
})
|
|
}
|
|
}
|
|
|
|
func getUIHeadersHandler(config *config.Config, allowedMethods ...string) func(http.HandlerFunc) http.HandlerFunc {
|
|
allowedMethodsValue := strings.Join(allowedMethods, ",")
|
|
|
|
return func(next http.HandlerFunc) http.HandlerFunc {
|
|
return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
|
response.Header().Set("Access-Control-Allow-Methods", allowedMethodsValue)
|
|
response.Header().Set("Access-Control-Allow-Headers",
|
|
"Authorization,content-type,"+constants.SessionClientHeaderName)
|
|
|
|
// Access-Control-Allow-Credentials must not be "true" when
|
|
// Access-Control-Allow-Origin is the wildcard "*" (CORS spec §3.2).
|
|
// Only advertise credentials support when an explicit origin is set.
|
|
authConfig := config.CopyAuthConfig()
|
|
allowOrigin := strings.TrimSpace(config.GetAllowOrigin())
|
|
if authConfig.IsBasicAuthnEnabled() && allowOrigin != "" && allowOrigin != "*" {
|
|
response.Header().Set("Access-Control-Allow-Credentials", "true")
|
|
}
|
|
|
|
next.ServeHTTP(response, request)
|
|
})
|
|
}
|
|
}
|
|
|
|
// Method handlers
|
|
|
|
// CheckVersionSupport godoc
|
|
// @Summary Check API support
|
|
// @Description Check if this API version is supported
|
|
// @Router /v2/ [get]
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Success 200 {string} string "ok"
|
|
func (rh *RouteHandler) CheckVersionSupport(response http.ResponseWriter, request *http.Request) {
|
|
if request.Method == http.MethodOptions {
|
|
return
|
|
}
|
|
|
|
response.Header().Set(constants.DistAPIVersion, "registry/2.0")
|
|
// NOTE: compatibility workaround - return this header in "allowed-read" mode to allow for clients to
|
|
// work correctly
|
|
// Get auth config safely
|
|
authConfig := rh.c.Config.CopyAuthConfig()
|
|
if authConfig.IsBasicAuthnEnabled() || authConfig.IsBearerAuthEnabled() {
|
|
// don't send auth headers if request is coming from UI
|
|
if request.Header.Get(constants.SessionClientHeaderName) != constants.SessionClientHeaderValue {
|
|
if authConfig.Bearer != nil {
|
|
realm := authConfig.Bearer.Realm
|
|
response.Header().Set("WWW-Authenticate", "bearer realm="+realm)
|
|
} else {
|
|
realm := rh.c.Config.GetRealm()
|
|
response.Header().Set("WWW-Authenticate", "basic realm="+realm)
|
|
}
|
|
}
|
|
}
|
|
|
|
zcommon.WriteData(response, http.StatusOK, "application/json", []byte{})
|
|
}
|
|
|
|
// ListTags godoc
|
|
// @Summary List image tags
|
|
// @Description List all image tags in a repository
|
|
// @Router /v2/{name}/tags/list [get]
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param name path string true "repository name"
|
|
// @Param n query integer true "limit entries for pagination"
|
|
// @Param last query string true "last tag value for pagination"
|
|
// @Success 200 {object} common.ImageTags
|
|
// @Failure 404 {string} string "not found"
|
|
// @Failure 400 {string} string "bad request"
|
|
func (rh *RouteHandler) ListTags(response http.ResponseWriter, request *http.Request) {
|
|
if request.Method == http.MethodOptions {
|
|
return
|
|
}
|
|
|
|
vars := mux.Vars(request)
|
|
|
|
name, ok := vars["name"]
|
|
|
|
if !ok || name == "" {
|
|
response.WriteHeader(http.StatusNotFound)
|
|
|
|
return
|
|
}
|
|
|
|
paginate := false
|
|
numTags := -1
|
|
|
|
nQuery, ok := request.URL.Query()["n"]
|
|
|
|
if ok {
|
|
if len(nQuery) != 1 {
|
|
response.WriteHeader(http.StatusBadRequest)
|
|
|
|
return
|
|
}
|
|
|
|
var nQuery1 int64
|
|
|
|
var err error
|
|
|
|
if nQuery1, err = strconv.ParseInt(nQuery[0], 10, 0); err != nil {
|
|
response.WriteHeader(http.StatusBadRequest)
|
|
|
|
return
|
|
}
|
|
|
|
numTags = int(nQuery1)
|
|
paginate = true
|
|
|
|
if numTags < 0 {
|
|
response.WriteHeader(http.StatusBadRequest)
|
|
|
|
return
|
|
}
|
|
}
|
|
|
|
last := ""
|
|
lastQuery, ok := request.URL.Query()["last"]
|
|
|
|
if ok {
|
|
if len(lastQuery) != 1 {
|
|
response.WriteHeader(http.StatusBadRequest)
|
|
|
|
return
|
|
}
|
|
|
|
last = lastQuery[0]
|
|
}
|
|
|
|
imgStore := rh.getImageStore(name)
|
|
|
|
tags, err := imgStore.GetImageTags(name)
|
|
if err != nil {
|
|
e := apiErr.NewError(apiErr.NAME_UNKNOWN).AddDetail(map[string]string{"name": name})
|
|
zcommon.WriteJSON(response, http.StatusNotFound, apiErr.NewErrorList(e))
|
|
|
|
return
|
|
}
|
|
|
|
// Tags need to be sorted regardless of pagination parameters
|
|
sort.Strings(tags)
|
|
|
|
// Determine index of first tag returned
|
|
startIndex := 0
|
|
|
|
if last != "" {
|
|
found := false
|
|
|
|
for i, tag := range tags {
|
|
if tag == last {
|
|
found = true
|
|
startIndex = i + 1
|
|
|
|
break
|
|
}
|
|
}
|
|
|
|
if !found {
|
|
response.WriteHeader(http.StatusNotFound)
|
|
|
|
return
|
|
}
|
|
}
|
|
|
|
pTags := zcommon.ImageTags{Name: name}
|
|
|
|
if paginate && numTags == 0 {
|
|
pTags.Tags = []string{}
|
|
zcommon.WriteJSON(response, http.StatusOK, pTags)
|
|
|
|
return
|
|
}
|
|
|
|
stopIndex := len(tags) - 1
|
|
if paginate && (startIndex+numTags < len(tags)) {
|
|
stopIndex = startIndex + numTags - 1
|
|
response.Header().Set(
|
|
"Link",
|
|
fmt.Sprintf("</v2/%s/tags/list?n=%d&last=%s>; rel=\"next\"",
|
|
name,
|
|
numTags,
|
|
tags[stopIndex],
|
|
),
|
|
)
|
|
}
|
|
|
|
pTags.Tags = tags[startIndex : stopIndex+1]
|
|
|
|
zcommon.WriteJSON(response, http.StatusOK, pTags)
|
|
}
|
|
|
|
// CheckManifest godoc
|
|
// @Summary Check image manifest
|
|
// @Description Check an image's manifest given a reference or a digest
|
|
// @Router /v2/{name}/manifests/{reference} [head]
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param name path string true "repository name"
|
|
// @Param reference path string true "image reference or digest"
|
|
// @Success 200 {string} string "ok"
|
|
// @Header 200 {string} Docker-Content-Digest "Manifest digest of the content"
|
|
// @Failure 404 {string} string "not found"
|
|
// @Failure 500 {string} string "internal server error"
|
|
func (rh *RouteHandler) CheckManifest(response http.ResponseWriter, request *http.Request) {
|
|
if request.Method == http.MethodOptions {
|
|
return
|
|
}
|
|
|
|
vars := mux.Vars(request)
|
|
name, ok := vars["name"]
|
|
|
|
if !ok || name == "" {
|
|
response.WriteHeader(http.StatusNotFound)
|
|
|
|
return
|
|
}
|
|
|
|
imgStore := rh.getImageStore(name)
|
|
|
|
reference, ok := vars["reference"]
|
|
if !ok || reference == "" {
|
|
e := apiErr.NewError(apiErr.MANIFEST_INVALID).AddDetail(map[string]string{"reference": reference})
|
|
zcommon.WriteJSON(response, http.StatusNotFound, apiErr.NewErrorList(e))
|
|
|
|
return
|
|
}
|
|
|
|
content, digest, mediaType, err := getImageManifest(request.Context(), rh, imgStore, name, reference)
|
|
if err != nil {
|
|
details := zerr.GetDetails(err)
|
|
details["reference"] = reference
|
|
|
|
if errors.Is(err, zerr.ErrRepoNotFound) { //nolint:gocritic // errorslint conflicts with gocritic:IfElseChain
|
|
e := apiErr.NewError(apiErr.NAME_UNKNOWN).AddDetail(details)
|
|
zcommon.WriteJSON(response, http.StatusNotFound, apiErr.NewErrorList(e))
|
|
} else if errors.Is(err, zerr.ErrManifestNotFound) {
|
|
e := apiErr.NewError(apiErr.MANIFEST_UNKNOWN).AddDetail(details)
|
|
zcommon.WriteJSON(response, http.StatusNotFound, apiErr.NewErrorList(e))
|
|
} else if errors.Is(err, zerr.ErrSyncParseRemoteRepo) {
|
|
e := apiErr.NewError(apiErr.DENIED).AddDetail(details)
|
|
zcommon.WriteJSON(response, http.StatusForbidden, apiErr.NewErrorList(e))
|
|
} else {
|
|
rh.c.Log.Error().Err(err).Msg("unexpected error")
|
|
|
|
e := apiErr.NewError(apiErr.MANIFEST_INVALID).AddDetail(details)
|
|
zcommon.WriteJSON(response, http.StatusInternalServerError, apiErr.NewErrorList(e))
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
response.Header().Set(constants.DistContentDigestKey, digest.String())
|
|
response.Header().Set("Content-Length", strconv.Itoa(len(content)))
|
|
response.Header().Set("Content-Type", mediaType)
|
|
response.WriteHeader(http.StatusOK)
|
|
}
|
|
|
|
type ImageManifest struct {
|
|
ispec.Manifest
|
|
}
|
|
|
|
type ExtensionList struct {
|
|
extensions.ExtensionList
|
|
}
|
|
|
|
// GetManifest godoc
|
|
// @Summary Get image manifest
|
|
// @Description Get an image's manifest given a reference or a digest
|
|
// @Accept json
|
|
// @Produce application/vnd.oci.image.manifest.v1+json
|
|
// @Param name path string true "repository name"
|
|
// @Param reference path string true "image reference or digest"
|
|
// @Success 200 {object} api.ImageManifest
|
|
// @Header 200 {string} Docker-Content-Digest "Manifest digest of the content"
|
|
// @Failure 404 {string} string "not found"
|
|
// @Failure 500 {string} string "internal server error"
|
|
// @Router /v2/{name}/manifests/{reference} [get].
|
|
func (rh *RouteHandler) GetManifest(response http.ResponseWriter, request *http.Request) {
|
|
// Get auth config safely
|
|
authConfig := rh.c.Config.CopyAuthConfig()
|
|
allowOrigin := strings.TrimSpace(rh.c.Config.GetAllowOrigin())
|
|
if authConfig.IsBasicAuthnEnabled() && allowOrigin != "" && allowOrigin != "*" {
|
|
response.Header().Set("Access-Control-Allow-Credentials", "true")
|
|
}
|
|
|
|
vars := mux.Vars(request)
|
|
name, ok := vars["name"]
|
|
|
|
if !ok || name == "" {
|
|
response.WriteHeader(http.StatusNotFound)
|
|
|
|
return
|
|
}
|
|
|
|
imgStore := rh.getImageStore(name)
|
|
|
|
reference, ok := vars["reference"]
|
|
if !ok || reference == "" {
|
|
err := apiErr.NewError(apiErr.MANIFEST_UNKNOWN).AddDetail(map[string]string{"reference": reference})
|
|
zcommon.WriteJSON(response, http.StatusNotFound, apiErr.NewErrorList(err))
|
|
|
|
return
|
|
}
|
|
|
|
content, digest, mediaType, err := getImageManifest(request.Context(), rh, imgStore, name, reference)
|
|
if err != nil {
|
|
details := zerr.GetDetails(err)
|
|
if errors.Is(err, zerr.ErrRepoNotFound) { //nolint:gocritic // errorslint conflicts with gocritic:IfElseChain
|
|
details["name"] = name
|
|
e := apiErr.NewError(apiErr.NAME_UNKNOWN).AddDetail(details)
|
|
zcommon.WriteJSON(response, http.StatusNotFound, apiErr.NewErrorList(e))
|
|
} else if errors.Is(err, zerr.ErrRepoBadVersion) {
|
|
details["name"] = name
|
|
e := apiErr.NewError(apiErr.NAME_UNKNOWN).AddDetail(details)
|
|
zcommon.WriteJSON(response, http.StatusNotFound, apiErr.NewErrorList(e))
|
|
} else if errors.Is(err, zerr.ErrManifestNotFound) {
|
|
details["reference"] = reference
|
|
e := apiErr.NewError(apiErr.MANIFEST_UNKNOWN).AddDetail(details)
|
|
zcommon.WriteJSON(response, http.StatusNotFound, apiErr.NewErrorList(e))
|
|
} else if errors.Is(err, zerr.ErrSyncParseRemoteRepo) {
|
|
e := apiErr.NewError(apiErr.DENIED).AddDetail(details)
|
|
zcommon.WriteJSON(response, http.StatusForbidden, apiErr.NewErrorList(e))
|
|
} else {
|
|
rh.c.Log.Error().Err(err).Msg("unexpected error")
|
|
response.WriteHeader(http.StatusInternalServerError)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
if rh.c.MetaDB != nil {
|
|
err := meta.OnGetManifest(name, reference, mediaType, content, rh.c.StoreController, rh.c.MetaDB, rh.c.Log)
|
|
if err != nil && !errors.Is(err, zerr.ErrImageMetaNotFound) && !errors.Is(err, zerr.ErrRepoMetaNotFound) {
|
|
response.WriteHeader(http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
}
|
|
|
|
response.Header().Set(constants.DistContentDigestKey, digest.String())
|
|
response.Header().Set("Content-Length", strconv.Itoa(len(content)))
|
|
response.Header().Set("Content-Type", mediaType)
|
|
zcommon.WriteData(response, http.StatusOK, mediaType, content)
|
|
}
|
|
|
|
type ImageIndex struct {
|
|
ispec.Index
|
|
}
|
|
|
|
func getReferrers(ctx context.Context, routeHandler *RouteHandler,
|
|
imgStore storageTypes.ImageStore, name string, digest godigest.Digest,
|
|
artifactTypes []string,
|
|
) (ispec.Index, error) {
|
|
if isSyncOnDemandEnabled(routeHandler.c) {
|
|
routeHandler.c.Log.Info().Str("repository", name).Str("reference", digest.String()).
|
|
Msg("trying to get updated referrers by syncing on demand")
|
|
|
|
if errSync := routeHandler.c.SyncOnDemand.SyncReferrers(ctx, name, digest.String(), artifactTypes); errSync != nil {
|
|
routeHandler.c.Log.Err(errSync).Str("repository", name).Str("reference", digest.String()).
|
|
Msg("failed to sync image referrers")
|
|
}
|
|
}
|
|
|
|
return imgStore.GetReferrers(name, digest, artifactTypes)
|
|
}
|
|
|
|
// GetReferrers godoc
|
|
// @Summary Get referrers for a given digest
|
|
// @Description Get referrers given a digest
|
|
// @Accept json
|
|
// @Produce application/vnd.oci.image.index.v1+json
|
|
// @Param name path string true "repository name"
|
|
// @Param digest path string true "digest"
|
|
// @Param artifactType query string false "artifact type"
|
|
// @Success 200 {object} api.ImageIndex
|
|
// @Failure 404 {string} string "not found"
|
|
// @Failure 500 {string} string "internal server error"
|
|
// @Router /v2/{name}/referrers/{digest} [get].
|
|
func (rh *RouteHandler) GetReferrers(response http.ResponseWriter, request *http.Request) {
|
|
if request.Method == http.MethodOptions {
|
|
return
|
|
}
|
|
|
|
vars := mux.Vars(request)
|
|
|
|
name, ok := vars["name"]
|
|
if !ok || name == "" {
|
|
response.WriteHeader(http.StatusNotFound)
|
|
|
|
return
|
|
}
|
|
|
|
digestStr, ok := vars["digest"]
|
|
|
|
digest, err := godigest.Parse(digestStr)
|
|
if !ok || digestStr == "" || err != nil {
|
|
response.WriteHeader(http.StatusBadRequest)
|
|
|
|
return
|
|
}
|
|
|
|
// filter by artifact type (more than one can be specified)
|
|
artifactTypes := request.URL.Query()["artifactType"]
|
|
|
|
rh.c.Log.Info().Str("digest", digest.String()).Interface("artifactType", artifactTypes).Msg("getting manifest")
|
|
|
|
imgStore := rh.getImageStore(name)
|
|
|
|
referrers, err := getReferrers(request.Context(), rh, imgStore, name, digest, artifactTypes)
|
|
if err != nil {
|
|
if errors.Is(err, zerr.ErrManifestNotFound) || errors.Is(err, zerr.ErrRepoNotFound) {
|
|
rh.c.Log.Error().Err(err).Str("name", name).Str("digest", digest.String()).
|
|
Msg("failed to get manifest")
|
|
response.WriteHeader(http.StatusNotFound)
|
|
} else {
|
|
rh.c.Log.Error().Err(err).Str("name", name).Str("digest", digest.String()).
|
|
Msg("failed to get references")
|
|
response.WriteHeader(http.StatusInternalServerError)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
out, err := json.Marshal(referrers)
|
|
if err != nil {
|
|
rh.c.Log.Error().Err(err).Str("name", name).Str("digest", digest.String()).Msg("failed to marshal json")
|
|
response.WriteHeader(http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
if len(artifactTypes) > 0 {
|
|
// currently, the only filter supported and on this end-point
|
|
response.Header().Set("OCI-Filters-Applied", "artifactType") //nolint:canonicalheader
|
|
}
|
|
|
|
zcommon.WriteData(response, http.StatusOK, ispec.MediaTypeImageIndex, out)
|
|
}
|
|
|
|
// UpdateManifest godoc
|
|
// @Summary Update image manifest
|
|
// @Description Update an image's manifest given a reference or a digest. On digest pushes with `tag=` query
|
|
// @Description parameters, 201 responses repeat the `OCI-Tag` header once per tag value.
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param name path string true "repository name"
|
|
// @Param reference path string true "image reference or digest"
|
|
// @Param tag query []string false "additional tag(s) for digest pushes" collectionFormat(multi)
|
|
// @Success 201 "created"
|
|
// @Header 201 {string} Docker-Content-Digest "Manifest digest of the uploaded content"
|
|
// @Header 201 {string} OCI-Tag "Echoed tag= value; this header is repeatable (one field per tag= query parameter)"
|
|
// @Failure 400 {string} string "bad request"
|
|
// @Failure 404 {string} string "not found"
|
|
// @Failure 413 {string} string "request entity too large"
|
|
// @Failure 414 {string} string "too many tag query parameters"
|
|
// @Failure 500 {string} string "internal server error"
|
|
// @Router /v2/{name}/manifests/{reference} [put].
|
|
func (rh *RouteHandler) UpdateManifest(response http.ResponseWriter, request *http.Request) {
|
|
vars := mux.Vars(request)
|
|
name, ok := vars["name"]
|
|
|
|
if !ok || name == "" {
|
|
response.WriteHeader(http.StatusNotFound)
|
|
|
|
return
|
|
}
|
|
|
|
imgStore := rh.getImageStore(name)
|
|
|
|
reference, ok := vars["reference"]
|
|
if !ok || reference == "" {
|
|
err := apiErr.NewError(apiErr.MANIFEST_INVALID).AddDetail(map[string]string{"reference": reference})
|
|
zcommon.WriteJSON(response, http.StatusNotFound, apiErr.NewErrorList(err))
|
|
|
|
return
|
|
}
|
|
|
|
mediaType := request.Header.Get("Content-Type")
|
|
compatConfig := rh.c.Config.GetCompat()
|
|
|
|
if !storageCommon.IsSupportedMediaType(compatConfig, mediaType) {
|
|
err := apiErr.NewError(apiErr.MANIFEST_INVALID).AddDetail(map[string]string{"mediaType": mediaType})
|
|
zcommon.WriteJSON(response, http.StatusUnsupportedMediaType, apiErr.NewErrorList(err))
|
|
|
|
return
|
|
}
|
|
|
|
var digestQueryTags []string
|
|
|
|
rawTagQuery := request.URL.Query()["tag"]
|
|
if len(rawTagQuery) > 0 {
|
|
if len(rawTagQuery) > constants.MaxManifestDigestQueryTags {
|
|
e := apiErr.NewError(apiErr.MANIFEST_INVALID).AddDetail(map[string]string{
|
|
"reason": fmt.Sprintf("too many tag query parameters (max %d)", constants.MaxManifestDigestQueryTags),
|
|
})
|
|
zcommon.WriteJSON(response, http.StatusRequestURITooLong, apiErr.NewErrorList(e))
|
|
|
|
return
|
|
}
|
|
|
|
var normErr error
|
|
|
|
digestQueryTags, normErr = normalizeManifestExtraTags(rawTagQuery)
|
|
if normErr != nil {
|
|
err := apiErr.NewError(apiErr.MANIFEST_INVALID).AddDetail(map[string]string{"reason": normErr.Error()})
|
|
zcommon.WriteJSON(response, http.StatusBadRequest, apiErr.NewErrorList(err))
|
|
|
|
return
|
|
}
|
|
}
|
|
|
|
if len(digestQueryTags) > 0 && !zcommon.IsDigest(reference) {
|
|
err := apiErr.NewError(apiErr.MANIFEST_INVALID).AddDetail(map[string]string{
|
|
"reason": "tag query parameters are only valid when pushing a manifest by digest",
|
|
})
|
|
zcommon.WriteJSON(response, http.StatusBadRequest, apiErr.NewErrorList(err))
|
|
|
|
return
|
|
}
|
|
|
|
body, err := io.ReadAll(http.MaxBytesReader(response, request.Body, constants.MaxManifestBodySize))
|
|
// hard to reach test case, injected error (simulates an interrupted image manifest upload)
|
|
// err could be io.ErrUnexpectedEOF or *http.MaxBytesError
|
|
if err := inject.Error(err); err != nil {
|
|
var mbe *http.MaxBytesError
|
|
if errors.As(err, &mbe) {
|
|
e := apiErr.NewError(apiErr.MANIFEST_INVALID).AddDetail(map[string]string{
|
|
"reason": fmt.Sprintf("manifest body exceeds maximum allowed size of %d bytes", constants.MaxManifestBodySize),
|
|
})
|
|
zcommon.WriteJSON(response, http.StatusRequestEntityTooLarge, apiErr.NewErrorList(e))
|
|
} else {
|
|
rh.c.Log.Error().Err(err).Msg("unexpected error")
|
|
response.WriteHeader(http.StatusInternalServerError)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
digest, subjectDigest, err := imgStore.PutImageManifest(name, reference, mediaType, body, digestQueryTags)
|
|
if err != nil {
|
|
details := zerr.GetDetails(err)
|
|
if errors.Is(err, zerr.ErrRepoNotFound) { //nolint:gocritic // errorslint conflicts with gocritic:IfElseChain
|
|
details["name"] = name
|
|
e := apiErr.NewError(apiErr.NAME_UNKNOWN).AddDetail(details)
|
|
zcommon.WriteJSON(response, http.StatusNotFound, apiErr.NewErrorList(e))
|
|
} else if errors.Is(err, zerr.ErrManifestNotFound) {
|
|
details["reference"] = reference
|
|
e := apiErr.NewError(apiErr.MANIFEST_UNKNOWN).AddDetail(details)
|
|
zcommon.WriteJSON(response, http.StatusNotFound, apiErr.NewErrorList(e))
|
|
} else if errors.Is(err, zerr.ErrBadManifest) {
|
|
details["reference"] = reference
|
|
e := apiErr.NewError(apiErr.MANIFEST_INVALID).AddDetail(details)
|
|
zcommon.WriteJSON(response, http.StatusBadRequest, apiErr.NewErrorList(e))
|
|
} else if errors.Is(err, zerr.ErrBlobNotFound) {
|
|
details["blob"] = digest.String()
|
|
e := apiErr.NewError(apiErr.BLOB_UNKNOWN).AddDetail(details)
|
|
zcommon.WriteJSON(response, http.StatusBadRequest, apiErr.NewErrorList(e))
|
|
} else if errors.Is(err, zerr.ErrImageLintAnnotations) {
|
|
details["reference"] = reference
|
|
e := apiErr.NewError(apiErr.MANIFEST_INVALID).AddDetail(details)
|
|
zcommon.WriteJSON(response, http.StatusBadRequest, apiErr.NewErrorList(e))
|
|
} else {
|
|
// could be syscall.EMFILE (Err:0x18 too many opened files), etc
|
|
rh.c.Log.Error().Err(err).Msg("unexpected error, performing cleanup")
|
|
|
|
if err = imgStore.DeleteImageManifest(name, reference, false); err != nil {
|
|
// deletion of image manifest is important, but not critical for image repo consistency
|
|
// in the worst scenario a partial manifest file written to disk will not affect the repo because
|
|
// the new manifest was not added to "index.json" file (it is possible that GC will take care of it)
|
|
rh.c.Log.Error().Err(err).Str("repository", name).Str("reference", reference).
|
|
Msg("couldn't remove image manifest in repo")
|
|
}
|
|
|
|
response.WriteHeader(http.StatusInternalServerError)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
if rh.c.MetaDB != nil {
|
|
if len(digestQueryTags) > 0 {
|
|
err := meta.OnUpdateManifestDigestTags(request.Context(), name, digestQueryTags, mediaType,
|
|
digest, body, rh.c.StoreController, rh.c.MetaDB, rh.c.Log)
|
|
if err != nil {
|
|
response.WriteHeader(http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
} else {
|
|
err := meta.OnUpdateManifest(request.Context(), name, reference, mediaType,
|
|
digest, body, rh.c.StoreController, rh.c.MetaDB, rh.c.Log)
|
|
if err != nil {
|
|
response.WriteHeader(http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
if subjectDigest.String() != "" {
|
|
response.Header().Set(constants.SubjectDigestKey, subjectDigest.String())
|
|
}
|
|
|
|
response.Header().Set("Location", fmt.Sprintf("/v2/%s/manifests/%s", name, digest))
|
|
response.Header().Set(constants.DistContentDigestKey, digest.String())
|
|
|
|
for _, tag := range digestQueryTags {
|
|
response.Header().Add(constants.OCITagResponseKey, tag) //nolint:canonicalheader
|
|
}
|
|
|
|
response.WriteHeader(http.StatusCreated)
|
|
}
|
|
|
|
// normalizeManifestExtraTags deduplicates tag query values in order, rejects empty components, and
|
|
// requires each value to match the OCI distribution-spec tag grammar (zreg.IsDistributionSpecTag).
|
|
func normalizeManifestExtraTags(raw []string) ([]string, error) {
|
|
seen := map[string]struct{}{}
|
|
|
|
out := make([]string, 0, len(raw))
|
|
|
|
for _, rawTag := range raw {
|
|
cleanedTag := strings.TrimSpace(rawTag)
|
|
if cleanedTag == "" {
|
|
return nil, zerr.ErrEmptyManifestTagQuery
|
|
}
|
|
|
|
if !zreg.IsDistributionSpecTag(cleanedTag) {
|
|
return nil, zerr.ErrInvalidManifestTagQuery
|
|
}
|
|
|
|
if _, ok := seen[cleanedTag]; ok {
|
|
continue
|
|
}
|
|
|
|
seen[cleanedTag] = struct{}{}
|
|
out = append(out, cleanedTag)
|
|
}
|
|
|
|
return out, nil
|
|
}
|
|
|
|
// DeleteManifest godoc
|
|
// @Summary Delete image manifest
|
|
// @Description Delete an image's manifest given a reference or a digest
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param name path string true "repository name"
|
|
// @Param reference path string true "image reference or digest"
|
|
// @Success 202 "accepted"
|
|
// @Failure 400 {string} string "bad request"
|
|
// @Failure 404 {string} string "not found"
|
|
// @Failure 405 {string} string "method not allowed"
|
|
// @Failure 409 {string} string "conflict"
|
|
// @Failure 500 {string} string "internal server error"
|
|
// @Router /v2/{name}/manifests/{reference} [delete].
|
|
func (rh *RouteHandler) DeleteManifest(response http.ResponseWriter, request *http.Request) {
|
|
vars := mux.Vars(request)
|
|
name, ok := vars["name"]
|
|
|
|
if !ok || name == "" {
|
|
response.WriteHeader(http.StatusNotFound)
|
|
|
|
return
|
|
}
|
|
|
|
imgStore := rh.getImageStore(name)
|
|
|
|
reference, ok := vars["reference"]
|
|
if !ok || reference == "" {
|
|
response.WriteHeader(http.StatusNotFound)
|
|
|
|
return
|
|
}
|
|
|
|
// user authz request context (set in authz middleware)
|
|
userAc, err := reqCtx.UserAcFromContext(request.Context())
|
|
if err != nil {
|
|
response.WriteHeader(http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
var detectCollision bool
|
|
if userAc != nil {
|
|
detectCollision = userAc.Can(constants.DetectManifestCollisionPermission, name)
|
|
}
|
|
|
|
manifestBlob, manifestDigest, mediaType, err := imgStore.GetImageManifest(name, reference)
|
|
if err != nil {
|
|
details := zerr.GetDetails(err)
|
|
if errors.Is(err, zerr.ErrRepoNotFound) { //nolint:gocritic // errorslint conflicts with gocritic:IfElseChain
|
|
details["name"] = name
|
|
e := apiErr.NewError(apiErr.NAME_UNKNOWN).AddDetail(details)
|
|
zcommon.WriteJSON(response, http.StatusBadRequest, apiErr.NewErrorList(e))
|
|
} else if errors.Is(err, zerr.ErrManifestNotFound) {
|
|
details["reference"] = reference
|
|
e := apiErr.NewError(apiErr.MANIFEST_UNKNOWN).AddDetail(details)
|
|
zcommon.WriteJSON(response, http.StatusNotFound, apiErr.NewErrorList(e))
|
|
} else if errors.Is(err, zerr.ErrBadManifest) {
|
|
details["reference"] = reference
|
|
e := apiErr.NewError(apiErr.UNSUPPORTED).AddDetail(details)
|
|
zcommon.WriteJSON(response, http.StatusBadRequest, apiErr.NewErrorList(e))
|
|
} else {
|
|
rh.c.Log.Error().Err(err).Msg("unexpected error")
|
|
response.WriteHeader(http.StatusInternalServerError)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
err = imgStore.DeleteImageManifest(name, reference, detectCollision)
|
|
if err != nil { //nolint: dupl
|
|
details := zerr.GetDetails(err)
|
|
if errors.Is(err, zerr.ErrRepoNotFound) { //nolint:gocritic // errorslint conflicts with gocritic:IfElseChain
|
|
details["name"] = name
|
|
e := apiErr.NewError(apiErr.NAME_UNKNOWN).AddDetail(details)
|
|
zcommon.WriteJSON(response, http.StatusBadRequest, apiErr.NewErrorList(e))
|
|
} else if errors.Is(err, zerr.ErrManifestNotFound) {
|
|
details["reference"] = reference
|
|
e := apiErr.NewError(apiErr.MANIFEST_UNKNOWN).AddDetail(details)
|
|
zcommon.WriteJSON(response, http.StatusNotFound, apiErr.NewErrorList(e))
|
|
} else if errors.Is(err, zerr.ErrManifestConflict) {
|
|
details["reference"] = reference
|
|
e := apiErr.NewError(apiErr.MANIFEST_INVALID).AddDetail(details)
|
|
zcommon.WriteJSON(response, http.StatusConflict, apiErr.NewErrorList(e))
|
|
} else if errors.Is(err, zerr.ErrBadManifest) {
|
|
details["reference"] = reference
|
|
e := apiErr.NewError(apiErr.UNSUPPORTED).AddDetail(details)
|
|
zcommon.WriteJSON(response, http.StatusBadRequest, apiErr.NewErrorList(e))
|
|
} else if errors.Is(err, zerr.ErrManifestReferenced) {
|
|
// manifest is part of an index image, don't allow index manipulations.
|
|
details["reference"] = reference
|
|
e := apiErr.NewError(apiErr.DENIED).AddDetail(details)
|
|
zcommon.WriteJSON(response, http.StatusMethodNotAllowed, apiErr.NewErrorList(e))
|
|
} else {
|
|
rh.c.Log.Error().Err(err).Msg("unexpected error")
|
|
response.WriteHeader(http.StatusInternalServerError)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
if rh.c.MetaDB != nil {
|
|
err := meta.OnDeleteManifest(name, reference, mediaType, manifestDigest, manifestBlob,
|
|
rh.c.StoreController, rh.c.MetaDB, rh.c.Log)
|
|
if err != nil {
|
|
response.WriteHeader(http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
}
|
|
|
|
response.WriteHeader(http.StatusAccepted)
|
|
}
|
|
|
|
// canMount checks if a user has read permission on cached blobs with this specific digest.
|
|
// returns true if the user have permission to copy blob from cache.
|
|
func canMount(userAc *reqCtx.UserAccessControl, imgStore storageTypes.ImageStore, digest godigest.Digest,
|
|
) (bool, error) {
|
|
canMount := true
|
|
|
|
if userAc != nil {
|
|
canMount = false
|
|
|
|
repos, err := imgStore.GetAllDedupeReposCandidates(digest)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
if len(repos) == 0 {
|
|
canMount = false
|
|
}
|
|
|
|
// check if user can read any repo which contain this blob
|
|
for _, repo := range repos {
|
|
if userAc.Can(constants.ReadPermission, repo) {
|
|
canMount = true
|
|
}
|
|
}
|
|
}
|
|
|
|
return canMount, nil
|
|
}
|
|
|
|
// resolveBlobResponseMediaType resolves the OCI media type to advertise for a blob via
|
|
// the repo's index/manifests. If the descriptor lookup fails (or the descriptor
|
|
// has no media type), it falls back to application/octet-stream.
|
|
//
|
|
// Use this for Content-Type on HEAD/GET blob responses to satisfy OCI
|
|
// distribution-spec conformance and consumers like stargz-snapshotter that
|
|
// require a non-empty, well-formed media type.
|
|
func resolveBlobResponseMediaType(
|
|
imgStore storageTypes.ImageStore,
|
|
repo string,
|
|
digest godigest.Digest,
|
|
logger log.Logger,
|
|
) string {
|
|
desc, err := storageCommon.GetBlobDescriptorFromRepo(imgStore, repo, digest, logger)
|
|
if err == nil && desc.MediaType != "" {
|
|
// Descriptor media types originate from manifest JSON and are not
|
|
// necessarily validated. Ensure we only emit a header-safe, parseable
|
|
// media type; otherwise fall back to application/octet-stream.
|
|
//
|
|
// ParseMediaType also strips parameters so we only propagate the base
|
|
// type (e.g. "application/vnd.oci.image.layer.v1.tar+gzip").
|
|
mediaType, _, parseErr := mime.ParseMediaType(desc.MediaType)
|
|
if parseErr == nil && mediaType != "" {
|
|
return mediaType
|
|
}
|
|
}
|
|
|
|
return constants.BinaryMediaType
|
|
}
|
|
|
|
// CheckBlob godoc
|
|
// @Summary Check image blob/layer
|
|
// @Description Check an image's blob/layer given a digest
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param name path string true "repository name"
|
|
// @Param digest path string true "blob/layer digest"
|
|
// @Success 200 {object} api.ImageManifest
|
|
// @Header 200 {string} Docker-Content-Digest "Manifest digest of the content"
|
|
// @Router /v2/{name}/blobs/{digest} [head].
|
|
func (rh *RouteHandler) CheckBlob(response http.ResponseWriter, request *http.Request) {
|
|
vars := mux.Vars(request)
|
|
name, ok := vars["name"]
|
|
|
|
if !ok || name == "" {
|
|
response.WriteHeader(http.StatusNotFound)
|
|
|
|
return
|
|
}
|
|
|
|
imgStore := rh.getImageStore(name)
|
|
|
|
digestStr, ok := vars["digest"]
|
|
|
|
if !ok || digestStr == "" {
|
|
response.WriteHeader(http.StatusNotFound)
|
|
|
|
return
|
|
}
|
|
|
|
digest := godigest.Digest(digestStr)
|
|
|
|
userAc, err := reqCtx.UserAcFromContext(request.Context())
|
|
if err != nil {
|
|
response.WriteHeader(http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
userCanMount := true
|
|
accessControlConfig := rh.c.Config.CopyAccessControlConfig()
|
|
|
|
if accessControlConfig.IsAuthzEnabled() {
|
|
userCanMount, err = canMount(userAc, imgStore, digest)
|
|
if err != nil {
|
|
rh.c.Log.Error().Err(err).Msg("unexpected error")
|
|
}
|
|
}
|
|
|
|
var blen int64
|
|
|
|
if userCanMount {
|
|
ok, blen, err = imgStore.CheckBlob(name, digest)
|
|
} else {
|
|
var lockLatency time.Time
|
|
|
|
imgStore.RLock(&lockLatency)
|
|
defer imgStore.RUnlock(&lockLatency)
|
|
|
|
ok, blen, _, err = imgStore.StatBlob(name, digest)
|
|
}
|
|
|
|
if err != nil {
|
|
details := zerr.GetDetails(err)
|
|
if errors.Is(err, zerr.ErrBadBlobDigest) { //nolint:gocritic,dupl // errorslint conflicts with gocritic:IfElseChain
|
|
details["digest"] = digest.String()
|
|
e := apiErr.NewError(apiErr.DIGEST_INVALID).AddDetail(details)
|
|
zcommon.WriteJSON(response, http.StatusBadRequest, apiErr.NewErrorList(e))
|
|
} else if errors.Is(err, zerr.ErrRepoNotFound) {
|
|
details["name"] = name
|
|
e := apiErr.NewError(apiErr.NAME_UNKNOWN).AddDetail(details)
|
|
zcommon.WriteJSON(response, http.StatusNotFound, apiErr.NewErrorList(e))
|
|
} else if errors.Is(err, zerr.ErrBlobNotFound) {
|
|
details["digest"] = digest.String()
|
|
e := apiErr.NewError(apiErr.BLOB_UNKNOWN).AddDetail(details)
|
|
zcommon.WriteJSON(response, http.StatusNotFound, apiErr.NewErrorList(e))
|
|
} else {
|
|
rh.c.Log.Error().Err(err).Msg("unexpected error")
|
|
response.WriteHeader(http.StatusInternalServerError)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
if !ok {
|
|
e := apiErr.NewError(apiErr.BLOB_UNKNOWN).AddDetail(map[string]string{"digest": digest.String()})
|
|
zcommon.WriteJSON(response, http.StatusNotFound, apiErr.NewErrorList(e))
|
|
|
|
return
|
|
}
|
|
|
|
response.Header().Set("Content-Length", strconv.FormatInt(blen, 10))
|
|
response.Header().Set("Accept-Ranges", "bytes")
|
|
response.Header().Set("Content-Type", resolveBlobResponseMediaType(imgStore, name, digest, rh.c.Log))
|
|
response.Header().Set(constants.DistContentDigestKey, digest.String())
|
|
response.WriteHeader(http.StatusOK)
|
|
}
|
|
|
|
type httpRange struct {
|
|
start int64
|
|
end int64
|
|
}
|
|
|
|
const maxRangeSpecCount = 16
|
|
|
|
func (r httpRange) length() int64 {
|
|
return r.end - r.start + 1
|
|
}
|
|
|
|
/* parseRangeHeader validates the "Range" HTTP header and returns normalized byte ranges. */
|
|
func parseRangeHeader(contentRange string, size int64) ([]httpRange, error) {
|
|
if size <= 0 || !strings.HasPrefix(contentRange, "bytes=") {
|
|
return nil, zerr.ErrParsingHTTPHeader
|
|
}
|
|
|
|
rangeSet := strings.TrimPrefix(contentRange, "bytes=")
|
|
if rangeSet == "" || strings.Count(rangeSet, ",")+1 > maxRangeSpecCount {
|
|
return nil, zerr.ErrParsingHTTPHeader
|
|
}
|
|
|
|
rangeSpecs := strings.Split(rangeSet, ",")
|
|
ranges := make([]httpRange, 0, len(rangeSpecs))
|
|
|
|
for _, rangeSpec := range rangeSpecs {
|
|
rangeSpec = strings.TrimSpace(rangeSpec)
|
|
if rangeSpec == "" {
|
|
return nil, zerr.ErrParsingHTTPHeader
|
|
}
|
|
|
|
startStr, endStr, ok := strings.Cut(rangeSpec, "-")
|
|
if !ok {
|
|
return nil, zerr.ErrParsingHTTPHeader
|
|
}
|
|
|
|
var start, end int64
|
|
|
|
if startStr == "" {
|
|
suffixLen, err := strconv.ParseInt(endStr, 10, 64)
|
|
if err != nil || suffixLen <= 0 {
|
|
return nil, zerr.ErrParsingHTTPHeader
|
|
}
|
|
|
|
if suffixLen > size {
|
|
start = 0
|
|
} else {
|
|
start = size - suffixLen
|
|
}
|
|
|
|
end = size - 1
|
|
} else {
|
|
parsedStart, err := strconv.ParseInt(startStr, 10, 64)
|
|
if err != nil || parsedStart < 0 {
|
|
return nil, zerr.ErrParsingHTTPHeader
|
|
}
|
|
|
|
start = parsedStart
|
|
|
|
if endStr == "" {
|
|
end = size - 1
|
|
} else {
|
|
parsedEnd, err := strconv.ParseInt(endStr, 10, 64)
|
|
if err != nil || parsedEnd < start {
|
|
return nil, zerr.ErrParsingHTTPHeader
|
|
}
|
|
|
|
end = min(parsedEnd, size-1)
|
|
}
|
|
}
|
|
|
|
if start >= size || start > end {
|
|
return nil, zerr.ErrParsingHTTPHeader
|
|
}
|
|
|
|
ranges = append(ranges, httpRange{start: start, end: end})
|
|
}
|
|
|
|
if len(ranges) == 0 {
|
|
return nil, zerr.ErrParsingHTTPHeader
|
|
}
|
|
|
|
return coalesceRanges(ranges), nil
|
|
}
|
|
|
|
func coalesceRanges(ranges []httpRange) []httpRange {
|
|
sort.Slice(ranges, func(i, j int) bool {
|
|
if ranges[i].start == ranges[j].start {
|
|
return ranges[i].end < ranges[j].end
|
|
}
|
|
|
|
return ranges[i].start < ranges[j].start
|
|
})
|
|
|
|
coalesced := ranges[:0]
|
|
|
|
for _, httpRange := range ranges {
|
|
if len(coalesced) == 0 {
|
|
coalesced = append(coalesced, httpRange)
|
|
|
|
continue
|
|
}
|
|
|
|
lastRange := &coalesced[len(coalesced)-1]
|
|
if httpRange.start <= lastRange.end+1 {
|
|
lastRange.end = max(lastRange.end, httpRange.end)
|
|
|
|
continue
|
|
}
|
|
|
|
coalesced = append(coalesced, httpRange)
|
|
}
|
|
|
|
return coalesced
|
|
}
|
|
|
|
// openRangeFunc lazily opens one range reader at a time for the
|
|
// multipart streaming goroutine. The reader must supply at least
|
|
// r.length() bytes from the start of r; writeMultipartRanges copies
|
|
// exactly that many per part and ignores any surplus. A short reader
|
|
// truncates the already-flushed 206 response.
|
|
type openRangeFunc func(r httpRange) (io.ReadCloser, error)
|
|
|
|
// multipartByterangesContentType is the media type of the response body
|
|
// produced by writeMultipartRanges, modulo the boundary parameter.
|
|
const multipartByterangesContentType = "multipart/byteranges"
|
|
|
|
// computeMultipartBodyLength returns the exact wire length of the
|
|
// multipart/byteranges body we will emit for these ranges, used to
|
|
// advertise Content-Length on the response. We run a real
|
|
// mime/multipart Writer against a counting sink rather than
|
|
// hard-coding the wire format, so the answer stays correct if the
|
|
// stdlib ever tweaks header ordering or whitespace.
|
|
func computeMultipartBodyLength(ranges []httpRange, mediaType, boundary string, size int64) int64 {
|
|
var counter byteCountingWriter
|
|
|
|
writer := multipart.NewWriter(&counter)
|
|
if err := writer.SetBoundary(boundary); err != nil {
|
|
return 0
|
|
}
|
|
|
|
for _, rng := range ranges {
|
|
partHeader := buildMultipartPartHeader(rng, mediaType, size)
|
|
if _, err := writer.CreatePart(partHeader); err != nil {
|
|
return 0
|
|
}
|
|
}
|
|
|
|
if err := writer.Close(); err != nil {
|
|
return 0
|
|
}
|
|
|
|
total := counter.n
|
|
for _, rng := range ranges {
|
|
total += rng.length()
|
|
}
|
|
|
|
return total
|
|
}
|
|
|
|
func buildMultipartPartHeader(rng httpRange, mediaType string, size int64) textproto.MIMEHeader {
|
|
partHeader := textproto.MIMEHeader{}
|
|
partHeader.Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", rng.start, rng.end, size))
|
|
|
|
// RFC 9110 §15.3.7 lets us omit per-part Content-Type, but when the
|
|
// caller has resolved a real media type (descriptor lookup succeeded
|
|
// or fell back to application/octet-stream) we propagate it so OCI
|
|
// clients don't have to re-derive it from the index for every part.
|
|
if mediaType != "" {
|
|
partHeader.Set("Content-Type", mediaType)
|
|
}
|
|
|
|
return partHeader
|
|
}
|
|
|
|
type byteCountingWriter struct{ n int64 }
|
|
|
|
func (c *byteCountingWriter) Write(p []byte) (int, error) {
|
|
c.n += int64(len(p))
|
|
|
|
return len(p), nil
|
|
}
|
|
|
|
// writeMultipartRanges streams a multipart/byteranges 206 response.
|
|
//
|
|
// The response advertises a precomputed Content-Length and opens range
|
|
// readers lazily — one at a time, inside a producer goroutine writing
|
|
// to an io.Pipe. Compared with the previous fan-out version, this
|
|
// halves the worst-case file-descriptor / read-buffer footprint when
|
|
// multiple ranges of a large blob are requested.
|
|
//
|
|
// Trade-off: lazy opening means the response status (206) and headers
|
|
// have already been flushed by the time we attempt to read range bodies.
|
|
// Per-range read errors mid-stream therefore manifest as a hard-closed
|
|
// connection (via pipe.CloseWithError) and a truncated body — they
|
|
// cannot be turned into a 5xx. Errors that originate from the storage
|
|
// layer's metadata path (e.g. a deleted blob) would historically have
|
|
// produced a 4xx; under this design they too truncate. The 16-range
|
|
// cap and coalesceRanges already bound the worst case, and the eager
|
|
// CheckBlob earlier in GetBlob still rejects the obvious "blob does
|
|
// not exist" case before we get here.
|
|
func writeMultipartRanges(
|
|
response http.ResponseWriter,
|
|
ranges []httpRange,
|
|
bsize int64,
|
|
mediaType string,
|
|
openRange openRangeFunc,
|
|
logger log.Logger,
|
|
) {
|
|
pipeReader, pipeWriter := io.Pipe()
|
|
defer func() { _ = pipeReader.Close() }()
|
|
|
|
writer := multipart.NewWriter(pipeWriter)
|
|
|
|
contentLength := computeMultipartBodyLength(ranges, mediaType, writer.Boundary(), bsize)
|
|
|
|
response.Header().Set("Content-Type", multipartByterangesContentType+"; boundary="+writer.Boundary())
|
|
response.Header().Set("Content-Length", strconv.FormatInt(contentLength, 10))
|
|
response.WriteHeader(http.StatusPartialContent)
|
|
|
|
go func() {
|
|
// CloseWithError(nil) is documented to be equivalent to Close(),
|
|
// so the success path ends the pipe cleanly with EOF.
|
|
var pipeErr error
|
|
defer func() { _ = pipeWriter.CloseWithError(pipeErr) }()
|
|
|
|
for _, rng := range ranges {
|
|
var part io.Writer
|
|
|
|
part, pipeErr = writer.CreatePart(buildMultipartPartHeader(rng, mediaType, bsize))
|
|
if pipeErr != nil {
|
|
return
|
|
}
|
|
|
|
var reader io.ReadCloser
|
|
|
|
reader, pipeErr = openRange(rng)
|
|
if pipeErr != nil {
|
|
return
|
|
}
|
|
|
|
_, copyErr := io.CopyN(part, reader, rng.length())
|
|
closeErr := reader.Close()
|
|
|
|
if copyErr != nil {
|
|
pipeErr = copyErr
|
|
|
|
return
|
|
}
|
|
|
|
if closeErr != nil {
|
|
pipeErr = closeErr
|
|
|
|
return
|
|
}
|
|
}
|
|
|
|
pipeErr = writer.Close()
|
|
}()
|
|
|
|
if _, err := io.CopyN(response, pipeReader, contentLength); err != nil {
|
|
logger.Error().Err(err).Msg("failed to copy multipart range data into http response")
|
|
}
|
|
}
|
|
|
|
// GetBlob godoc
|
|
// @Summary Get image blob/layer
|
|
// @Description Get an image's blob/layer given a digest
|
|
// @Accept json
|
|
// @Produce application/vnd.oci.image.layer.v1.tar+gzip
|
|
// @Param name path string true "repository name"
|
|
// @Param digest path string true "blob/layer digest"
|
|
// @Header 200 {string} Docker-Content-Digest "Manifest digest of the content"
|
|
// @Success 200 {object} api.ImageManifest
|
|
// @Router /v2/{name}/blobs/{digest} [get].
|
|
func (rh *RouteHandler) GetBlob(response http.ResponseWriter, request *http.Request) {
|
|
vars := mux.Vars(request)
|
|
name, ok := vars["name"]
|
|
|
|
if !ok || name == "" {
|
|
response.WriteHeader(http.StatusNotFound)
|
|
|
|
return
|
|
}
|
|
|
|
imgStore := rh.getImageStore(name)
|
|
|
|
digestStr, ok := vars["digest"]
|
|
|
|
if !ok || digestStr == "" {
|
|
response.WriteHeader(http.StatusNotFound)
|
|
|
|
return
|
|
}
|
|
|
|
digest := godigest.Digest(digestStr)
|
|
|
|
contentRange := request.Header.Get("Range")
|
|
_, rangeHeaderPresent := request.Header["Range"]
|
|
|
|
writeBlobError := func(err error) {
|
|
details := zerr.GetDetails(err)
|
|
if errors.Is(err, zerr.ErrBadBlobDigest) { //nolint:gocritic // errorslint conflicts with gocritic:IfElseChain
|
|
details["digest"] = digest.String()
|
|
e := apiErr.NewError(apiErr.DIGEST_INVALID).AddDetail(details)
|
|
zcommon.WriteJSON(response, http.StatusBadRequest, apiErr.NewErrorList(e))
|
|
} else if errors.Is(err, zerr.ErrRepoNotFound) {
|
|
details["name"] = name
|
|
e := apiErr.NewError(apiErr.NAME_UNKNOWN).AddDetail(details)
|
|
zcommon.WriteJSON(response, http.StatusNotFound, apiErr.NewErrorList(e))
|
|
} else if errors.Is(err, zerr.ErrBlobNotFound) {
|
|
details["digest"] = digest.String()
|
|
e := apiErr.NewError(apiErr.BLOB_UNKNOWN).AddDetail(details)
|
|
zcommon.WriteJSON(response, http.StatusNotFound, apiErr.NewErrorList(e))
|
|
} else {
|
|
rh.c.Log.Error().Err(err).Msg("unexpected error")
|
|
response.WriteHeader(http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
if rangeHeaderPresent {
|
|
ok, bsize, err := imgStore.CheckBlob(name, digest)
|
|
if err != nil {
|
|
writeBlobError(err)
|
|
|
|
return
|
|
}
|
|
|
|
if !ok {
|
|
e := apiErr.NewError(apiErr.BLOB_UNKNOWN).AddDetail(map[string]string{"digest": digest.String()})
|
|
zcommon.WriteJSON(response, http.StatusNotFound, apiErr.NewErrorList(e))
|
|
|
|
return
|
|
}
|
|
|
|
// Resolve the response Content-Type from the blob's OCI descriptor (if
|
|
// any), with a fallback to application/octet-stream.
|
|
mediaType := resolveBlobResponseMediaType(imgStore, name, digest, rh.c.Log)
|
|
|
|
ranges, err := parseRangeHeader(contentRange, bsize)
|
|
if err != nil {
|
|
response.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", bsize))
|
|
response.WriteHeader(http.StatusRequestedRangeNotSatisfiable)
|
|
|
|
return
|
|
}
|
|
|
|
if len(ranges) > 1 {
|
|
response.Header().Set(constants.DistContentDigestKey, digest.String())
|
|
|
|
// Multipart: lazy opener invoked one range at a time inside the
|
|
// streaming goroutine. We do not pre-verify the per-range length
|
|
// here; once the 206 headers are flushed there's no way to turn
|
|
// a mismatch into a 5xx, so writeMultipartRanges relies on
|
|
// io.CopyN to enforce it on the wire.
|
|
writeMultipartRanges(response, ranges, bsize, mediaType,
|
|
func(rng httpRange) (io.ReadCloser, error) {
|
|
reader, _, _, err := imgStore.GetBlobPartial(name, digest, mediaType, rng.start, rng.end)
|
|
|
|
return reader, err
|
|
},
|
|
rh.c.Log,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
// Single range: eager open + length sanity check + stream. Headers
|
|
// haven't been flushed yet so we can still return a 5xx if the
|
|
// storage layer hands us a reader with the wrong size.
|
|
rng := ranges[0]
|
|
|
|
reader, blen, _, err := imgStore.GetBlobPartial(name, digest, mediaType, rng.start, rng.end)
|
|
if err != nil {
|
|
writeBlobError(err)
|
|
|
|
return
|
|
}
|
|
defer func() { _ = reader.Close() }()
|
|
|
|
if blen != rng.length() {
|
|
rh.c.Log.Error().
|
|
Int64("expected", rng.length()).
|
|
Int64("actual", blen).
|
|
Msg("unexpected partial blob length")
|
|
response.WriteHeader(http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
response.Header().Set(constants.DistContentDigestKey, digest.String())
|
|
response.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", rng.start, rng.end, bsize))
|
|
WriteDataFromReader(
|
|
response, http.StatusPartialContent, rng.length(), mediaType, reader, rh.c.Log,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
var repo io.ReadCloser
|
|
|
|
var blen int64
|
|
|
|
// Resolve the response Content-Type from the blob's OCI descriptor
|
|
// (if any), with a fallback to application/octet-stream. This lookup
|
|
// may require an additional repo index/manifest walk before we read
|
|
// the blob, but preserves a more specific Content-Type when available.
|
|
mediaType := resolveBlobResponseMediaType(imgStore, name, digest, rh.c.Log)
|
|
|
|
repo, blen, err := imgStore.GetBlob(name, digest, mediaType)
|
|
if err != nil {
|
|
writeBlobError(err)
|
|
|
|
return
|
|
}
|
|
|
|
defer repo.Close()
|
|
|
|
response.Header().Set("Content-Length", strconv.FormatInt(blen, 10))
|
|
response.Header().Set(constants.DistContentDigestKey, digest.String())
|
|
|
|
WriteDataFromReader(response, http.StatusOK, blen, mediaType, repo, rh.c.Log)
|
|
}
|
|
|
|
// DeleteBlob godoc
|
|
// @Summary Delete image blob/layer
|
|
// @Description Delete an image's blob/layer given a digest
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param name path string true "repository name"
|
|
// @Param digest path string true "blob/layer digest"
|
|
// @Success 202 "accepted"
|
|
// @Router /v2/{name}/blobs/{digest} [delete].
|
|
func (rh *RouteHandler) DeleteBlob(response http.ResponseWriter, request *http.Request) {
|
|
vars := mux.Vars(request)
|
|
name, ok := vars["name"]
|
|
|
|
if !ok || name == "" {
|
|
response.WriteHeader(http.StatusNotFound)
|
|
|
|
return
|
|
}
|
|
|
|
digestStr, ok := vars["digest"]
|
|
|
|
digest, err := godigest.Parse(digestStr)
|
|
if !ok || digestStr == "" || err != nil {
|
|
response.WriteHeader(http.StatusNotFound)
|
|
|
|
return
|
|
}
|
|
|
|
imgStore := rh.getImageStore(name)
|
|
|
|
err = imgStore.DeleteBlob(name, digest)
|
|
if err != nil {
|
|
details := zerr.GetDetails(err)
|
|
if errors.Is(err, zerr.ErrBadBlobDigest) { //nolint:gocritic // errorslint conflicts with gocritic:IfElseChain
|
|
details["digest"] = digest.String()
|
|
e := apiErr.NewError(apiErr.DIGEST_INVALID).AddDetail(details)
|
|
zcommon.WriteJSON(response, http.StatusBadRequest, apiErr.NewErrorList(e))
|
|
} else if errors.Is(err, zerr.ErrRepoNotFound) {
|
|
details["name"] = name
|
|
e := apiErr.NewError(apiErr.NAME_UNKNOWN).AddDetail(map[string]string{"name": name})
|
|
zcommon.WriteJSON(response, http.StatusNotFound, apiErr.NewErrorList(e))
|
|
} else if errors.Is(err, zerr.ErrBlobNotFound) {
|
|
details["digest"] = digest.String()
|
|
e := apiErr.NewError(apiErr.BLOB_UNKNOWN).AddDetail(details)
|
|
zcommon.WriteJSON(response, http.StatusNotFound, apiErr.NewErrorList(e))
|
|
} else if errors.Is(err, zerr.ErrBlobReferenced) {
|
|
details["digest"] = digest.String()
|
|
e := apiErr.NewError(apiErr.DENIED).AddDetail(details)
|
|
zcommon.WriteJSON(response, http.StatusMethodNotAllowed, apiErr.NewErrorList(e))
|
|
} else {
|
|
rh.c.Log.Error().Err(err).Msg("unexpected error")
|
|
response.WriteHeader(http.StatusInternalServerError)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
response.WriteHeader(http.StatusAccepted)
|
|
}
|
|
|
|
// CreateBlobUpload godoc
|
|
// @Summary Create image blob/layer upload
|
|
// @Description Create a new image blob/layer upload
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param name path string true "repository name"
|
|
// @Success 202 "accepted"
|
|
// @Header 202 {string} Location "/v2/{name}/blobs/uploads/{session_id}"
|
|
// @Header 202 {string} Range "0-0"
|
|
// @Failure 401 {string} string "unauthorized"
|
|
// @Failure 404 {string} string "not found"
|
|
// @Failure 500 {string} string "internal server error"
|
|
// @Router /v2/{name}/blobs/uploads [post].
|
|
func (rh *RouteHandler) CreateBlobUpload(response http.ResponseWriter, request *http.Request) {
|
|
vars := mux.Vars(request)
|
|
name, ok := vars["name"]
|
|
|
|
if !ok || name == "" {
|
|
response.WriteHeader(http.StatusNotFound)
|
|
|
|
return
|
|
}
|
|
|
|
imgStore := rh.getImageStore(name)
|
|
|
|
// currently zot does not support cross-repository mounting, following dist-spec and returning 202
|
|
if mountDigests, ok := request.URL.Query()["mount"]; ok {
|
|
if len(mountDigests) != 1 {
|
|
response.WriteHeader(http.StatusBadRequest)
|
|
|
|
return
|
|
}
|
|
|
|
mountDigest := godigest.Digest(mountDigests[0])
|
|
|
|
userAc, err := reqCtx.UserAcFromContext(request.Context())
|
|
if err != nil {
|
|
response.WriteHeader(http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
userCanMount := true
|
|
accessControlConfig := rh.c.Config.CopyAccessControlConfig()
|
|
|
|
if accessControlConfig.IsAuthzEnabled() {
|
|
userCanMount, err = canMount(userAc, imgStore, mountDigest)
|
|
if err != nil {
|
|
rh.c.Log.Error().Err(err).Msg("unexpected error")
|
|
}
|
|
}
|
|
|
|
// zot does not support cross mounting directly and do a workaround creating using hard link.
|
|
// check blob looks for actual path (name+mountDigests[0]) first then look for cache and
|
|
// if found in cache, will do hard link and if fails we will start new upload.
|
|
if userCanMount {
|
|
_, _, err = imgStore.CheckBlob(name, mountDigest)
|
|
}
|
|
|
|
if err != nil || !userCanMount {
|
|
upload, err := imgStore.NewBlobUpload(name)
|
|
if err != nil {
|
|
details := zerr.GetDetails(err)
|
|
if errors.Is(err, zerr.ErrRepoNotFound) {
|
|
details["name"] = name
|
|
e := apiErr.NewError(apiErr.NAME_UNKNOWN).AddDetail(details)
|
|
zcommon.WriteJSON(response, http.StatusNotFound, apiErr.NewErrorList(e))
|
|
} else {
|
|
rh.c.Log.Error().Err(err).Msg("unexpected error")
|
|
response.WriteHeader(http.StatusInternalServerError)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
response.Header().Set("Location", getBlobUploadSessionLocation(request.URL, upload))
|
|
response.Header().Set("Range", "0-0")
|
|
response.WriteHeader(http.StatusAccepted)
|
|
|
|
return
|
|
}
|
|
|
|
response.Header().Set("Location", getBlobUploadLocation(request.URL, name, mountDigest))
|
|
response.WriteHeader(http.StatusCreated)
|
|
|
|
return
|
|
}
|
|
|
|
if _, ok := request.URL.Query()["from"]; ok {
|
|
response.WriteHeader(http.StatusMethodNotAllowed)
|
|
|
|
return
|
|
}
|
|
|
|
// a full blob upload if "digest" is present
|
|
digests, ok := request.URL.Query()["digest"]
|
|
if ok {
|
|
if len(digests) != 1 {
|
|
response.WriteHeader(http.StatusBadRequest)
|
|
|
|
return
|
|
}
|
|
|
|
if contentType := request.Header.Get("Content-Type"); contentType != constants.BinaryMediaType {
|
|
rh.c.Log.Warn().Str("actual", contentType).Str("expected", constants.BinaryMediaType).Msg("invalid media type")
|
|
response.WriteHeader(http.StatusUnsupportedMediaType)
|
|
|
|
return
|
|
}
|
|
|
|
digestStr := digests[0]
|
|
|
|
digest := godigest.Digest(digestStr)
|
|
|
|
var contentLength int64
|
|
|
|
contentLength, err := strconv.ParseInt(request.Header.Get("Content-Length"), 10, 64)
|
|
if err != nil || contentLength <= 0 {
|
|
rh.c.Log.Warn().Str("actual", request.Header.Get("Content-Length")).Msg("invalid content length")
|
|
|
|
details := map[string]string{"digest": digest.String()}
|
|
|
|
if err != nil {
|
|
details["conversion error"] = err.Error()
|
|
} else {
|
|
details["Content-Length"] = request.Header.Get("Content-Length")
|
|
}
|
|
|
|
e := apiErr.NewError(apiErr.BLOB_UPLOAD_INVALID).AddDetail(details)
|
|
zcommon.WriteJSON(response, http.StatusBadRequest, apiErr.NewErrorList(e))
|
|
|
|
return
|
|
}
|
|
|
|
sessionID, size, err := imgStore.FullBlobUpload(name, request.Body, digest)
|
|
if err != nil {
|
|
rh.c.Log.Error().Err(err).Int64("actual", size).Int64("expected", contentLength).
|
|
Msg("failed to full blob upload")
|
|
response.WriteHeader(http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
if size != contentLength {
|
|
rh.c.Log.Warn().Int64("actual", size).Int64("expected", contentLength).Msg("invalid content length")
|
|
response.WriteHeader(http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
response.Header().Set("Location", getBlobUploadLocation(request.URL, name, digest))
|
|
response.Header().Set(constants.BlobUploadUUID, sessionID)
|
|
response.WriteHeader(http.StatusCreated)
|
|
|
|
return
|
|
}
|
|
|
|
upload, err := imgStore.NewBlobUpload(name)
|
|
if err != nil {
|
|
details := zerr.GetDetails(err)
|
|
if errors.Is(err, zerr.ErrRepoNotFound) {
|
|
details["name"] = name
|
|
e := apiErr.NewError(apiErr.NAME_UNKNOWN).AddDetail(details)
|
|
zcommon.WriteJSON(response, http.StatusNotFound, apiErr.NewErrorList(e))
|
|
} else {
|
|
rh.c.Log.Error().Err(err).Msg("unexpected error")
|
|
response.WriteHeader(http.StatusInternalServerError)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
response.Header().Set("Location", getBlobUploadSessionLocation(request.URL, upload))
|
|
response.Header().Set("Range", "0-0")
|
|
response.WriteHeader(http.StatusAccepted)
|
|
}
|
|
|
|
// GetBlobUpload godoc
|
|
// @Summary Get image blob/layer upload
|
|
// @Description Get an image's blob/layer upload given a session_id
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param name path string true "repository name"
|
|
// @Param session_id path string true "upload session_id"
|
|
// @Success 204 "no content"
|
|
// @Header 204 {string} Location "/v2/{name}/blobs/uploads/{session_id}"
|
|
// @Header 204 {string} Range "0-128"
|
|
// @Failure 400 {string} string "bad request"
|
|
// @Failure 404 {string} string "not found"
|
|
// @Failure 500 {string} string "internal server error"
|
|
// @Router /v2/{name}/blobs/uploads/{session_id} [get].
|
|
func (rh *RouteHandler) GetBlobUpload(response http.ResponseWriter, request *http.Request) {
|
|
vars := mux.Vars(request)
|
|
name, ok := vars["name"]
|
|
|
|
if !ok || name == "" {
|
|
response.WriteHeader(http.StatusNotFound)
|
|
|
|
return
|
|
}
|
|
|
|
imgStore := rh.getImageStore(name)
|
|
|
|
sessionID, ok := vars["session_id"]
|
|
if !ok || sessionID == "" {
|
|
response.WriteHeader(http.StatusNotFound)
|
|
|
|
return
|
|
}
|
|
|
|
size, err := imgStore.GetBlobUpload(name, sessionID)
|
|
if err != nil {
|
|
details := zerr.GetDetails(err)
|
|
//nolint:gocritic // errorslint conflicts with gocritic:IfElseChain
|
|
if errors.Is(err, zerr.ErrBadBlobDigest) {
|
|
details["session_id"] = sessionID
|
|
e := apiErr.NewError(apiErr.BLOB_UPLOAD_INVALID).AddDetail(details)
|
|
zcommon.WriteJSON(response, http.StatusBadRequest, apiErr.NewErrorList(e))
|
|
} else if errors.Is(err, zerr.ErrRepoNotFound) {
|
|
details["name"] = name
|
|
e := apiErr.NewError(apiErr.NAME_UNKNOWN).AddDetail(details)
|
|
zcommon.WriteJSON(response, http.StatusNotFound, apiErr.NewErrorList(e))
|
|
} else if errors.Is(err, zerr.ErrUploadNotFound) {
|
|
details["session_id"] = sessionID
|
|
e := apiErr.NewError(apiErr.BLOB_UPLOAD_UNKNOWN).AddDetail(details)
|
|
zcommon.WriteJSON(response, http.StatusNotFound, apiErr.NewErrorList(e))
|
|
} else {
|
|
rh.c.Log.Error().Err(err).Msg("unexpected error")
|
|
response.WriteHeader(http.StatusInternalServerError)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
response.Header().Set("Location", getBlobUploadSessionLocation(request.URL, sessionID))
|
|
// Match POST new-upload Range for empty progress; otherwise 0..size-1 per dist-spec upload status.
|
|
rangeEnd := "0-0"
|
|
if size > 0 {
|
|
rangeEnd = fmt.Sprintf("0-%d", size-1)
|
|
}
|
|
|
|
response.Header().Set("Range", rangeEnd)
|
|
response.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// PatchBlobUpload godoc
|
|
// @Summary Resume image blob/layer upload
|
|
// @Description Resume an image's blob/layer upload given an session_id
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param name path string true "repository name"
|
|
// @Param session_id path string true "upload session_id"
|
|
// @Success 202 "accepted"
|
|
// @Header 202 {string} Location "/v2/{name}/blobs/uploads/{session_id}"
|
|
// @Header 202 {string} Range "0-128"
|
|
// @Header 202 {string} Blob-Upload-UUID "Opaque blob upload session identifier"
|
|
// @Failure 400 {string} string "bad request"
|
|
// @Failure 404 {string} string "not found"
|
|
// @Failure 416 {string} string "range not satisfiable"
|
|
// @Failure 500 {string} string "internal server error"
|
|
// @Router /v2/{name}/blobs/uploads/{session_id} [patch].
|
|
func (rh *RouteHandler) PatchBlobUpload(response http.ResponseWriter, request *http.Request) {
|
|
vars := mux.Vars(request)
|
|
name, ok := vars["name"]
|
|
|
|
if !ok || name == "" {
|
|
response.WriteHeader(http.StatusNotFound)
|
|
|
|
return
|
|
}
|
|
|
|
imgStore := rh.getImageStore(name)
|
|
|
|
sessionID, ok := vars["session_id"]
|
|
if !ok || sessionID == "" {
|
|
response.WriteHeader(http.StatusNotFound)
|
|
|
|
return
|
|
}
|
|
|
|
var clen int64
|
|
|
|
var err error
|
|
|
|
if request.Header.Get("Content-Length") == "" || request.Header.Get("Content-Range") == "" {
|
|
// streamed blob upload
|
|
clen, err = imgStore.PutBlobChunkStreamed(name, sessionID, request.Body)
|
|
} else {
|
|
// chunked blob upload
|
|
var contentLength int64
|
|
|
|
if contentLength, err = strconv.ParseInt(request.Header.Get("Content-Length"), 10, 64); err != nil {
|
|
rh.c.Log.Warn().Str("actual", request.Header.Get("Content-Length")).Msg("invalid content length")
|
|
response.WriteHeader(http.StatusBadRequest)
|
|
|
|
return
|
|
}
|
|
|
|
var from, to int64
|
|
if from, to, err = getContentRange(request); err != nil || (to-from)+1 != contentLength {
|
|
response.WriteHeader(http.StatusRequestedRangeNotSatisfiable)
|
|
|
|
return
|
|
}
|
|
|
|
clen, err = imgStore.PutBlobChunk(name, sessionID, from, to, request.Body)
|
|
}
|
|
|
|
if err != nil { //nolint: dupl
|
|
details := zerr.GetDetails(err)
|
|
if errors.Is(err, zerr.ErrBadUploadRange) { //nolint:gocritic // errorslint conflicts with gocritic:IfElseChain
|
|
details["session_id"] = sessionID
|
|
e := apiErr.NewError(apiErr.BLOB_UPLOAD_INVALID).AddDetail(details)
|
|
zcommon.WriteJSON(response, http.StatusRequestedRangeNotSatisfiable, apiErr.NewErrorList(e))
|
|
} else if errors.Is(err, zerr.ErrRepoNotFound) {
|
|
details["name"] = name
|
|
e := apiErr.NewError(apiErr.NAME_UNKNOWN).AddDetail(details)
|
|
zcommon.WriteJSON(response, http.StatusNotFound, apiErr.NewErrorList(e))
|
|
} else if errors.Is(err, zerr.ErrUploadNotFound) {
|
|
details["session_id"] = sessionID
|
|
e := apiErr.NewError(apiErr.BLOB_UPLOAD_UNKNOWN).AddDetail(details)
|
|
zcommon.WriteJSON(response, http.StatusNotFound, apiErr.NewErrorList(e))
|
|
} else {
|
|
// could be io.ErrUnexpectedEOF, syscall.EMFILE (Err:0x18 too many opened files), etc
|
|
rh.c.Log.Error().Err(err).Msg("unexpected error, removing .uploads/ files")
|
|
|
|
if err = imgStore.DeleteBlobUpload(name, sessionID); err != nil {
|
|
rh.c.Log.Error().Err(err).Str("blobUpload", sessionID).Str("repository", name).
|
|
Msg("couldn't remove blobUpload in repo")
|
|
}
|
|
|
|
response.WriteHeader(http.StatusInternalServerError)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
response.Header().Set("Location", getBlobUploadSessionLocation(request.URL, sessionID))
|
|
response.Header().Set("Range", fmt.Sprintf("0-%d", clen-1))
|
|
response.Header().Set("Content-Length", "0")
|
|
response.Header().Set(constants.BlobUploadUUID, sessionID)
|
|
response.WriteHeader(http.StatusAccepted)
|
|
}
|
|
|
|
// UpdateBlobUpload godoc
|
|
// @Summary Update image blob/layer upload
|
|
// @Description Update and finish an image's blob/layer upload given a digest
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param name path string true "repository name"
|
|
// @Param session_id path string true "upload session_id"
|
|
// @Param digest query string true "blob/layer digest"
|
|
// @Success 201 "created"
|
|
// @Header 201 {string} Location "/v2/{name}/blobs/{digest}"
|
|
// @Header 201 {string} Docker-Content-Digest "Digest of the committed blob"
|
|
// @Failure 400 {string} string "bad request"
|
|
// @Failure 404 {string} string "not found"
|
|
// @Failure 416 {string} string "range not satisfiable"
|
|
// @Failure 500 {string} string "internal server error"
|
|
// @Router /v2/{name}/blobs/uploads/{session_id} [put].
|
|
func (rh *RouteHandler) UpdateBlobUpload(response http.ResponseWriter, request *http.Request) {
|
|
vars := mux.Vars(request)
|
|
name, ok := vars["name"]
|
|
|
|
if !ok || name == "" {
|
|
response.WriteHeader(http.StatusNotFound)
|
|
|
|
return
|
|
}
|
|
|
|
imgStore := rh.getImageStore(name)
|
|
|
|
sessionID, ok := vars["session_id"]
|
|
if !ok || sessionID == "" {
|
|
response.WriteHeader(http.StatusNotFound)
|
|
|
|
return
|
|
}
|
|
|
|
digests, ok := request.URL.Query()["digest"]
|
|
if !ok || len(digests) != 1 {
|
|
response.WriteHeader(http.StatusBadRequest)
|
|
|
|
return
|
|
}
|
|
|
|
digest, err := godigest.Parse(digests[0])
|
|
if err != nil {
|
|
response.WriteHeader(http.StatusBadRequest)
|
|
|
|
return
|
|
}
|
|
|
|
contentPresent := true
|
|
|
|
contentLen, err := strconv.ParseInt(request.Header.Get("Content-Length"), 10, 64)
|
|
if err != nil {
|
|
contentPresent = false
|
|
}
|
|
|
|
contentRangePresent := true
|
|
|
|
if request.Header.Get("Content-Range") == "" {
|
|
contentRangePresent = false
|
|
}
|
|
|
|
// we expect at least one of "Content-Length" or "Content-Range" to be
|
|
// present
|
|
if !contentPresent && !contentRangePresent {
|
|
response.WriteHeader(http.StatusBadRequest)
|
|
|
|
return
|
|
}
|
|
|
|
var from, to int64
|
|
|
|
if contentPresent {
|
|
contentRange := request.Header.Get("Content-Range")
|
|
if contentRange == "" { // monolithic upload
|
|
from = 0
|
|
|
|
if contentLen == 0 {
|
|
goto finish
|
|
}
|
|
|
|
to = contentLen
|
|
} else if from, to, err = getContentRange(request); err != nil { // finish chunked upload
|
|
details := zerr.GetDetails(err)
|
|
details["session_id"] = sessionID
|
|
e := apiErr.NewError(apiErr.BLOB_UPLOAD_INVALID).AddDetail(details)
|
|
zcommon.WriteJSON(response, http.StatusRequestedRangeNotSatisfiable, apiErr.NewErrorList(e))
|
|
|
|
return
|
|
}
|
|
|
|
_, err = imgStore.PutBlobChunk(name, sessionID, from, to, request.Body)
|
|
if err != nil { //nolint:dupl
|
|
details := zerr.GetDetails(err)
|
|
if errors.Is(err, zerr.ErrBadUploadRange) { //nolint:gocritic // errorslint conflicts with gocritic:IfElseChain
|
|
details["session_id"] = sessionID
|
|
e := apiErr.NewError(apiErr.BLOB_UPLOAD_INVALID).AddDetail(details)
|
|
zcommon.WriteJSON(response, http.StatusRequestedRangeNotSatisfiable, apiErr.NewErrorList(e))
|
|
} else if errors.Is(err, zerr.ErrRepoNotFound) {
|
|
details["name"] = name
|
|
e := apiErr.NewError(apiErr.NAME_UNKNOWN).AddDetail(details)
|
|
zcommon.WriteJSON(response, http.StatusNotFound, apiErr.NewErrorList(e))
|
|
} else if errors.Is(err, zerr.ErrUploadNotFound) {
|
|
details["session_id"] = sessionID
|
|
e := apiErr.NewError(apiErr.BLOB_UPLOAD_UNKNOWN).AddDetail(details)
|
|
zcommon.WriteJSON(response, http.StatusNotFound, apiErr.NewErrorList(e))
|
|
} else {
|
|
// could be io.ErrUnexpectedEOF, syscall.EMFILE (Err:0x18 too many opened files), etc
|
|
rh.c.Log.Error().Err(err).Msg("unexpected error, removing .uploads/ files")
|
|
|
|
if err = imgStore.DeleteBlobUpload(name, sessionID); err != nil {
|
|
rh.c.Log.Error().Err(err).Str("blobUpload", sessionID).Str("repository", name).
|
|
Msg("failed to remove blobUpload in repo")
|
|
}
|
|
|
|
response.WriteHeader(http.StatusInternalServerError)
|
|
}
|
|
|
|
return
|
|
}
|
|
}
|
|
|
|
finish:
|
|
// blob chunks already transferred, just finish
|
|
if err := imgStore.FinishBlobUpload(name, sessionID, request.Body, digest); err != nil {
|
|
details := zerr.GetDetails(err)
|
|
if errors.Is(err, zerr.ErrBadBlobDigest) { //nolint:gocritic // errorslint conflicts with gocritic:IfElseChain
|
|
details["digest"] = digest.String()
|
|
e := apiErr.NewError(apiErr.DIGEST_INVALID).AddDetail(details)
|
|
zcommon.WriteJSON(response, http.StatusBadRequest, apiErr.NewErrorList(e))
|
|
} else if errors.Is(err, zerr.ErrBadUploadRange) {
|
|
details["session_id"] = sessionID
|
|
e := apiErr.NewError(apiErr.BLOB_UPLOAD_INVALID).AddDetail(details)
|
|
zcommon.WriteJSON(response, http.StatusRequestedRangeNotSatisfiable, apiErr.NewErrorList(e))
|
|
} else if errors.Is(err, zerr.ErrRepoNotFound) {
|
|
details["name"] = name
|
|
e := apiErr.NewError(apiErr.NAME_UNKNOWN).AddDetail(details)
|
|
zcommon.WriteJSON(response, http.StatusNotFound, apiErr.NewErrorList(e))
|
|
} else if errors.Is(err, zerr.ErrUploadNotFound) {
|
|
details["session_id"] = sessionID
|
|
e := apiErr.NewError(apiErr.BLOB_UPLOAD_UNKNOWN).AddDetail(details)
|
|
zcommon.WriteJSON(response, http.StatusNotFound, apiErr.NewErrorList(e))
|
|
} else {
|
|
// could be io.ErrUnexpectedEOF, syscall.EMFILE (Err:0x18 too many opened files), etc
|
|
rh.c.Log.Error().Err(err).Msg("unexpected error, removing .uploads/ files")
|
|
|
|
if err = imgStore.DeleteBlobUpload(name, sessionID); err != nil {
|
|
rh.c.Log.Error().Err(err).Str("blobUpload", sessionID).Str("repository", name).
|
|
Msg("failed to remove blobUpload in repo")
|
|
}
|
|
|
|
response.WriteHeader(http.StatusInternalServerError)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
response.Header().Set("Location", getBlobUploadLocation(request.URL, name, digest))
|
|
response.Header().Set("Content-Length", "0")
|
|
response.Header().Set(constants.DistContentDigestKey, digest.String())
|
|
response.WriteHeader(http.StatusCreated)
|
|
}
|
|
|
|
// DeleteBlobUpload godoc
|
|
// @Summary Delete image blob/layer
|
|
// @Description Delete an image's blob/layer given a digest
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param name path string true "repository name"
|
|
// @Param session_id path string true "upload session_id"
|
|
// @Success 204 "no content"
|
|
// @Failure 404 {string} string "not found"
|
|
// @Failure 500 {string} string "internal server error"
|
|
// @Router /v2/{name}/blobs/uploads/{session_id} [delete].
|
|
func (rh *RouteHandler) DeleteBlobUpload(response http.ResponseWriter, request *http.Request) {
|
|
vars := mux.Vars(request)
|
|
name, ok := vars["name"]
|
|
|
|
if !ok || name == "" {
|
|
response.WriteHeader(http.StatusNotFound)
|
|
|
|
return
|
|
}
|
|
|
|
imgStore := rh.getImageStore(name)
|
|
|
|
sessionID, ok := vars["session_id"]
|
|
if !ok || sessionID == "" {
|
|
response.WriteHeader(http.StatusNotFound)
|
|
|
|
return
|
|
}
|
|
|
|
if err := imgStore.DeleteBlobUpload(name, sessionID); err != nil {
|
|
details := zerr.GetDetails(err)
|
|
if errors.Is(err, zerr.ErrRepoNotFound) { //nolint:gocritic // errorslint conflicts with gocritic:IfElseChain
|
|
details["name"] = name
|
|
e := apiErr.NewError(apiErr.NAME_UNKNOWN).AddDetail(details)
|
|
zcommon.WriteJSON(response, http.StatusNotFound, apiErr.NewErrorList(e))
|
|
} else if errors.Is(err, zerr.ErrUploadNotFound) {
|
|
details["session_id"] = sessionID
|
|
e := apiErr.NewError(apiErr.BLOB_UPLOAD_UNKNOWN).AddDetail(details)
|
|
zcommon.WriteJSON(response, http.StatusNotFound, apiErr.NewErrorList(e))
|
|
} else {
|
|
rh.c.Log.Error().Err(err).Msg("unexpected error")
|
|
response.WriteHeader(http.StatusInternalServerError)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
response.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
type RepositoryList struct {
|
|
Repositories []string `json:"repositories"`
|
|
}
|
|
|
|
func (rh *RouteHandler) listStorageRepositories(lastEntry string, maxEntries int,
|
|
userAc *reqCtx.UserAccessControl,
|
|
) ([]string, bool, error) {
|
|
var moreEntries bool
|
|
|
|
var err error
|
|
|
|
var repos []string
|
|
|
|
remainder := maxEntries
|
|
|
|
combineRepoList := make([]string, 0)
|
|
|
|
subStore := rh.c.StoreController.SubStore
|
|
|
|
subPaths := make([]string, 0)
|
|
for subPath := range subStore {
|
|
subPaths = append(subPaths, subPath)
|
|
}
|
|
|
|
sort.Strings(subPaths)
|
|
|
|
storePath := rh.c.StoreController.GetStorePath(lastEntry)
|
|
if storePath == storage.DefaultStorePath {
|
|
singleStore := rh.c.StoreController.DefaultStore
|
|
|
|
repos, moreEntries, err = singleStore.GetNextRepositories(lastEntry, remainder, AuthzFilterFunc(userAc))
|
|
if err != nil {
|
|
return repos, false, err
|
|
}
|
|
|
|
remainder = maxEntries - len(repos)
|
|
|
|
if moreEntries && remainder <= 0 && len(repos) > 0 {
|
|
// maxEntries has been hit
|
|
lastEntry = repos[len(repos)-1]
|
|
} else {
|
|
// reset for the next substores
|
|
lastEntry = ""
|
|
}
|
|
|
|
combineRepoList = append(combineRepoList, repos...)
|
|
}
|
|
|
|
for _, subPath := range subPaths {
|
|
imgStore := subStore[subPath]
|
|
|
|
if lastEntry != "" && subPath != storePath {
|
|
continue
|
|
}
|
|
|
|
if remainder > 0 || maxEntries == -1 {
|
|
repos, moreEntries, err = imgStore.GetNextRepositories(lastEntry, remainder, AuthzFilterFunc(userAc))
|
|
if err != nil {
|
|
return combineRepoList, false, err
|
|
}
|
|
|
|
// compute remainder
|
|
remainder -= len(repos)
|
|
|
|
if moreEntries && remainder <= 0 && len(repos) > 0 {
|
|
// maxEntries has been hit
|
|
lastEntry = repos[len(repos)-1]
|
|
} else {
|
|
// reset for the next substores
|
|
lastEntry = ""
|
|
}
|
|
|
|
combineRepoList = append(combineRepoList, repos...)
|
|
}
|
|
}
|
|
|
|
return combineRepoList, moreEntries, nil
|
|
}
|
|
|
|
// ListRepositories godoc
|
|
// @Summary List image repositories
|
|
// @Description List all image repositories
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Success 200 {object} api.RepositoryList
|
|
// @Failure 500 {string} string "internal server error"
|
|
// @Router /v2/_catalog [get].
|
|
func (rh *RouteHandler) ListRepositories(response http.ResponseWriter, request *http.Request) {
|
|
if request.Method == http.MethodOptions {
|
|
return
|
|
}
|
|
|
|
q := request.URL.Query()
|
|
|
|
lastEntry := q.Get("last")
|
|
|
|
maxEntries, err := strconv.Atoi(q.Get("n"))
|
|
if err != nil {
|
|
maxEntries = -1
|
|
}
|
|
|
|
// authz context
|
|
userAc, err := reqCtx.UserAcFromContext(request.Context())
|
|
if err != nil {
|
|
response.WriteHeader(http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
repos, moreEntries, err := rh.listStorageRepositories(lastEntry, maxEntries, userAc)
|
|
if err != nil {
|
|
response.WriteHeader(http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
if moreEntries && len(repos) > 0 {
|
|
lastRepo := repos[len(repos)-1]
|
|
|
|
response.Header().Set(
|
|
"Link",
|
|
fmt.Sprintf("</v2/_catalog?n=%d&last=%s>; rel=\"next\"",
|
|
maxEntries,
|
|
lastRepo,
|
|
),
|
|
)
|
|
}
|
|
|
|
is := RepositoryList{Repositories: repos}
|
|
|
|
zcommon.WriteJSON(response, http.StatusOK, is)
|
|
}
|
|
|
|
// ListExtensions godoc
|
|
// @Summary List Registry level extensions
|
|
// @Description List all extensions present on registry
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Success 200 {object} api.ExtensionList
|
|
// @Router /v2/_oci/ext/discover [get].
|
|
func (rh *RouteHandler) ListExtensions(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == http.MethodOptions {
|
|
return
|
|
}
|
|
|
|
extensionList := ext.GetExtensions(rh.c.Config)
|
|
|
|
zcommon.WriteJSON(w, http.StatusOK, extensionList)
|
|
}
|
|
|
|
// The following routes are specific to zot and NOT part of the OCI dist-spec
|
|
|
|
// LogoutResponse is returned by POST /zot/auth/logout. When the session was established
|
|
// via an OIDC provider that advertises an `end_session_endpoint` in its discovery
|
|
// metadata, EndSessionURL is set to the URL the client should navigate to in order to
|
|
// terminate the session at the IdP. For local, basic, LDAP, or GitHub OAuth2 sessions
|
|
// the field is omitted from the JSON body.
|
|
type LogoutResponse struct {
|
|
EndSessionURL string `json:"endSessionUrl,omitempty"`
|
|
}
|
|
|
|
// Logout godoc
|
|
// @Summary Logout by removing current session
|
|
// @Description Logout by removing current session. For OIDC providers that advertise an
|
|
// @Description `end_session_endpoint` in their discovery metadata (OpenID Connect
|
|
// @Description RP-Initiated Logout 1.0), the response body contains an `endSessionUrl`
|
|
// @Description the client should navigate to in order to terminate the session at the IdP.
|
|
// @Router /zot/auth/logout [post]
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Success 200 {object} api.LogoutResponse
|
|
// @Failure 500 {string} string "internal server error"
|
|
func (rh *RouteHandler) Logout(response http.ResponseWriter, request *http.Request) {
|
|
if request.Method == http.MethodOptions {
|
|
return
|
|
}
|
|
|
|
session, sessionErr := rh.c.CookieStore.Get(request, "session")
|
|
if sessionErr != nil {
|
|
rh.c.Log.Warn().Err(sessionErr).
|
|
Msg("session cookie could not be decoded; falling back to local-only logout")
|
|
}
|
|
|
|
provider, _ := session.Values["provider"].(string)
|
|
session.Options.MaxAge = -1
|
|
|
|
err := session.Save(request, response)
|
|
if err != nil {
|
|
response.WriteHeader(http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
zcommon.WriteJSON(response, http.StatusOK, LogoutResponse{
|
|
EndSessionURL: rh.buildEndSessionURL(provider),
|
|
})
|
|
}
|
|
|
|
// buildEndSessionURL returns the OIDC provider's RP-Initiated Logout URL for the given
|
|
// provider name, or an empty string if the provider is not OIDC or does not advertise an
|
|
// `end_session_endpoint`. No `id_token_hint` is used — `client_id` identifies the RP per
|
|
// https://openid.net/specs/openid-connect-rpinitiated-1_0.html#RPLogout. The
|
|
// `post_logout_redirect_uri` is resolved by postLogoutRedirectURI and must be pre-
|
|
// registered with the IdP client, otherwise the IdP will ignore the parameter and keep
|
|
// the user on its own "logged out" page.
|
|
func (rh *RouteHandler) buildEndSessionURL(provider string) string {
|
|
if provider == "" || !config.IsOpenIDSupported(provider) {
|
|
return ""
|
|
}
|
|
|
|
relyingParty, ok := rh.c.RelyingParties[provider]
|
|
if !ok {
|
|
return ""
|
|
}
|
|
|
|
endSessionURL, err := composeEndSessionURL(
|
|
relyingParty.GetEndSessionEndpoint(),
|
|
relyingParty.OAuthConfig().ClientID,
|
|
postLogoutRedirectURI(rh.c.Config),
|
|
)
|
|
if err != nil {
|
|
rh.c.Log.Error().Err(err).Str("provider", provider).
|
|
Str("endpoint", relyingParty.GetEndSessionEndpoint()).
|
|
Msg("failed to parse OIDC end_session_endpoint")
|
|
|
|
return ""
|
|
}
|
|
|
|
return endSessionURL
|
|
}
|
|
|
|
// composeEndSessionURL parses `endpoint` and returns it with `client_id` and, if non-empty,
|
|
// `post_logout_redirect_uri` query parameters merged in. An empty endpoint yields an empty
|
|
// URL with no error. Relative URLs or non-http(s) schemes are rejected to prevent a client
|
|
// from being redirected to an unexpected location when the IdP discovery document is
|
|
// malformed.
|
|
func composeEndSessionURL(endpoint, clientID, redirectURI string) (string, error) {
|
|
if endpoint == "" {
|
|
return "", nil
|
|
}
|
|
|
|
logoutURL, err := url.Parse(endpoint)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if !logoutURL.IsAbs() || logoutURL.Host == "" ||
|
|
(logoutURL.Scheme != "http" && logoutURL.Scheme != "https") {
|
|
return "", fmt.Errorf("%w: got %q", zerr.ErrInvalidEndSessionEndpoint, endpoint)
|
|
}
|
|
|
|
query := logoutURL.Query()
|
|
query.Set("client_id", clientID)
|
|
|
|
if redirectURI != "" {
|
|
query.Set("post_logout_redirect_uri", redirectURI)
|
|
}
|
|
|
|
logoutURL.RawQuery = query.Encode()
|
|
|
|
return logoutURL.String(), nil
|
|
}
|
|
|
|
// postLogoutRedirectURI returns the URI where the IdP should send the user after logout.
|
|
// It derives the origin from the server configuration via originFromConfig — the same
|
|
// helper used to build the login redirect_uri — so that the IdP sees matching origins
|
|
// for both login and logout. The resulting URI must be pre-registered with the IdP
|
|
// client; otherwise the IdP will ignore the parameter and keep the user on its own
|
|
// "logged out" page.
|
|
func postLogoutRedirectURI(cfg *config.Config) string {
|
|
return originFromConfig(cfg) + "/login"
|
|
}
|
|
|
|
// GithubCodeExchangeCallback is a github Oauth2 CodeExchange callback.
|
|
func (rh *RouteHandler) GithubCodeExchangeCallback() rp.CodeExchangeCallback[*oidc.IDTokenClaims] {
|
|
return func(w http.ResponseWriter, r *http.Request,
|
|
tokens *oidc.Tokens[*oidc.IDTokenClaims], state string, relyingParty rp.RelyingParty,
|
|
) {
|
|
ctx := r.Context()
|
|
|
|
client := github.NewClient(relyingParty.OAuthConfig().Client(ctx, tokens.Token))
|
|
|
|
email, groups, err := GetGithubUserInfo(ctx, client, rh.c.Log)
|
|
if email == "" || err != nil {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
|
|
return
|
|
}
|
|
|
|
callbackUI, err := OAuth2Callback(rh.c, w, r, state, email, "", groups) //nolint: contextcheck
|
|
if err != nil {
|
|
if errors.Is(err, zerr.ErrInvalidStateCookie) {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
if callbackUI != "" {
|
|
http.Redirect(w, r, callbackUI, http.StatusFound) //nolint: gosec
|
|
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusCreated)
|
|
}
|
|
}
|
|
|
|
// OpenIDCodeExchangeCallback is an Openid CodeExchange callback (legacy, kept for compatibility).
|
|
func (rh *RouteHandler) OpenIDCodeExchangeCallback() rp.CodeExchangeUserinfoCallback[
|
|
*oidc.IDTokenClaims,
|
|
*oidc.UserInfo,
|
|
] {
|
|
return rh.OpenIDCodeExchangeCallbackWithProvider("")
|
|
}
|
|
|
|
// OpenIDCodeExchangeCallbackWithProvider is the OIDC CodeExchange callback that supports configurable claim mapping.
|
|
// The providerName parameter is used to lookup provider-specific claim mapping configuration.
|
|
// This differs from the legacy version by allowing per-provider claim mapping based on the providerName.
|
|
func (rh *RouteHandler) OpenIDCodeExchangeCallbackWithProvider(providerName string) rp.CodeExchangeUserinfoCallback[
|
|
*oidc.IDTokenClaims,
|
|
*oidc.UserInfo,
|
|
] {
|
|
return func(w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens[*oidc.IDTokenClaims], state string,
|
|
relyingParty rp.RelyingParty, info *oidc.UserInfo,
|
|
) {
|
|
authConfig := rh.c.Config.CopyAuthConfig()
|
|
|
|
var idTokenClaims map[string]any
|
|
if tokens != nil && tokens.IDTokenClaims != nil {
|
|
idTokenClaims = tokens.IDTokenClaims.Claims
|
|
}
|
|
|
|
username, groups, ok := extractOpenIDIdentity(rh.c.Log, authConfig, providerName, info, idTokenClaims)
|
|
if !ok {
|
|
rh.c.Log.Error().Msg("failed to set user record for empty username value")
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
|
|
return
|
|
}
|
|
|
|
callbackUI, err := OAuth2Callback(rh.c, w, r, state, username, providerName, groups)
|
|
if err != nil {
|
|
if errors.Is(err, zerr.ErrInvalidStateCookie) {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
if callbackUI != "" {
|
|
http.Redirect(w, r, callbackUI, http.StatusFound) //nolint: gosec
|
|
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusCreated)
|
|
}
|
|
}
|
|
|
|
// helper routines
|
|
|
|
func getContentRange(r *http.Request) (int64 /* from */, int64 /* to */, error) {
|
|
contentRange := strings.TrimSpace(r.Header.Get("Content-Range"))
|
|
if contentRange == "" {
|
|
return -1, -1, zerr.ErrBadUploadRange
|
|
}
|
|
|
|
startStr, endStr, ok := strings.Cut(contentRange, "-")
|
|
if !ok {
|
|
return -1, -1, zerr.ErrBadUploadRange
|
|
}
|
|
|
|
startStr = strings.TrimSpace(startStr)
|
|
endStr = strings.TrimSpace(endStr)
|
|
if startStr == "" || endStr == "" {
|
|
return -1, -1, zerr.ErrBadUploadRange
|
|
}
|
|
|
|
rangeStart, err := strconv.ParseInt(startStr, 10, 64)
|
|
if err != nil {
|
|
return -1, -1, zerr.ErrBadUploadRange
|
|
}
|
|
|
|
rangeEnd, err := strconv.ParseInt(endStr, 10, 64)
|
|
if err != nil {
|
|
return -1, -1, zerr.ErrBadUploadRange
|
|
}
|
|
|
|
if rangeStart > rangeEnd {
|
|
return -1, -1, zerr.ErrBadUploadRange
|
|
}
|
|
|
|
return rangeStart, rangeEnd, nil
|
|
}
|
|
|
|
func WriteDataFromReader(response http.ResponseWriter, status int, length int64, mediaType string,
|
|
reader io.Reader, logger log.Logger,
|
|
) {
|
|
response.Header().Set("Content-Type", mediaType)
|
|
response.Header().Set("Content-Length", strconv.FormatInt(length, 10))
|
|
response.WriteHeader(status)
|
|
|
|
const maxSize = 10 * 1024 * 1024
|
|
|
|
for {
|
|
_, err := io.CopyN(response, reader, maxSize)
|
|
if errors.Is(err, io.EOF) {
|
|
break
|
|
} else if err != nil {
|
|
// other kinds of intermittent errors can occur, e.g, io.ErrShortWrite
|
|
logger.Error().Err(err).Msg("failed to copy data into http response")
|
|
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// will return image storage corresponding to subpath provided in config.
|
|
func (rh *RouteHandler) getImageStore(name string) storageTypes.ImageStore {
|
|
return rh.c.StoreController.GetImageStore(name)
|
|
}
|
|
|
|
// will sync on demand if an image is not found, in case sync extensions is enabled.
|
|
func getImageManifest(ctx context.Context, routeHandler *RouteHandler, imgStore storageTypes.ImageStore, name,
|
|
reference string,
|
|
) ([]byte, godigest.Digest, string, error) {
|
|
syncEnabled := isSyncOnDemandEnabled(routeHandler.c)
|
|
|
|
_, digestErr := godigest.Parse(reference)
|
|
if digestErr == nil {
|
|
// if it's a digest then return local cached image, if not found and sync enabled, then try to sync
|
|
content, digest, mediaType, err := imgStore.GetImageManifest(name, reference)
|
|
if err == nil || !syncEnabled {
|
|
return content, digest, mediaType, err
|
|
}
|
|
}
|
|
|
|
if syncEnabled {
|
|
routeHandler.c.Log.Info().Str("repository", name).Str("reference", reference).
|
|
Msg("trying to get updated image by syncing on demand")
|
|
|
|
if errSync := routeHandler.c.SyncOnDemand.SyncImage(ctx, name, reference); errSync != nil {
|
|
routeHandler.c.Log.Err(errSync).Str("repository", name).Str("reference", reference).
|
|
Msg("failed to sync image")
|
|
}
|
|
}
|
|
|
|
return imgStore.GetImageManifest(name, reference)
|
|
}
|
|
|
|
type APIKeyPayload struct { //nolint:revive,gosec
|
|
Label string `json:"label"`
|
|
Scopes []string `json:"scopes"`
|
|
ExpirationDate string `json:"expirationDate"`
|
|
}
|
|
|
|
// GetAPIKeys godoc
|
|
// @Summary Get list of API keys for the current user
|
|
// @Description Get list of all API keys for a logged in user
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Success 200 {string} string "ok"
|
|
// @Failure 401 {string} string "unauthorized"
|
|
// @Failure 500 {string} string "internal server error"
|
|
// @Router /zot/auth/apikey [get].
|
|
func (rh *RouteHandler) GetAPIKeys(resp http.ResponseWriter, req *http.Request) {
|
|
apiKeys, err := rh.c.MetaDB.GetUserAPIKeys(req.Context())
|
|
if err != nil {
|
|
rh.c.Log.Error().Err(err).Msg("failed to get list of api keys for user")
|
|
resp.WriteHeader(http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
apiKeyResponse := struct {
|
|
APIKeys []mTypes.APIKeyDetails `json:"apiKeys"`
|
|
}{
|
|
APIKeys: apiKeys,
|
|
}
|
|
|
|
json := jsoniter.ConfigCompatibleWithStandardLibrary
|
|
|
|
data, err := json.Marshal(apiKeyResponse) //nolint:gosec // API key is intentionally returned on creation
|
|
if err != nil {
|
|
rh.c.Log.Error().Err(err).Msg("failed to marshal api key response")
|
|
|
|
resp.WriteHeader(http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
resp.Header().Set("Content-Type", constants.DefaultMediaType)
|
|
resp.WriteHeader(http.StatusOK)
|
|
_, _ = resp.Write(data)
|
|
}
|
|
|
|
// CreateAPIKey godoc
|
|
// @Summary Create an API key for the current user
|
|
// @Description Can create an api key for a logged in user, based on the provided label and scopes.
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param id body APIKeyPayload true "api token id (UUID)"
|
|
// @Success 201 {string} string "created"
|
|
// @Failure 400 {string} string "bad request"
|
|
// @Failure 401 {string} string "unauthorized"
|
|
// @Failure 413 {string} string "request entity too large"
|
|
// @Failure 500 {string} string "internal server error"
|
|
// @Router /zot/auth/apikey [post].
|
|
func (rh *RouteHandler) CreateAPIKey(resp http.ResponseWriter, req *http.Request) {
|
|
var payload APIKeyPayload
|
|
|
|
body, err := io.ReadAll(http.MaxBytesReader(resp, req.Body, constants.MaxAPIKeyBodySize))
|
|
if err != nil {
|
|
var mbe *http.MaxBytesError
|
|
if errors.As(err, &mbe) {
|
|
resp.WriteHeader(http.StatusRequestEntityTooLarge)
|
|
} else {
|
|
rh.c.Log.Error().Msg("failed to read request body")
|
|
resp.WriteHeader(http.StatusInternalServerError)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
err = json.Unmarshal(body, &payload)
|
|
if err != nil {
|
|
resp.WriteHeader(http.StatusBadRequest)
|
|
|
|
return
|
|
}
|
|
|
|
apiKey, apiKeyID, err := GenerateAPIKey(guuid.DefaultGenerator, rh.c.Log)
|
|
if err != nil {
|
|
resp.WriteHeader(http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
hashedAPIKey := hashUUID(apiKey)
|
|
|
|
createdAt := time.Now()
|
|
|
|
// won't expire if no value provided
|
|
expirationDate := time.Time{}
|
|
|
|
if payload.ExpirationDate != "" {
|
|
//nolint: gosmopolitan
|
|
expirationDate, err = time.ParseInLocation(constants.APIKeyTimeFormat, payload.ExpirationDate, time.Local)
|
|
if err != nil {
|
|
resp.WriteHeader(http.StatusBadRequest)
|
|
|
|
return
|
|
}
|
|
|
|
if createdAt.After(expirationDate) {
|
|
resp.WriteHeader(http.StatusBadRequest)
|
|
|
|
return
|
|
}
|
|
}
|
|
|
|
apiKeyDetails := &mTypes.APIKeyDetails{
|
|
CreatedAt: createdAt,
|
|
ExpirationDate: expirationDate,
|
|
IsExpired: false,
|
|
CreatorUA: req.UserAgent(),
|
|
GeneratedBy: "manual",
|
|
Label: payload.Label,
|
|
Scopes: payload.Scopes,
|
|
UUID: apiKeyID,
|
|
}
|
|
|
|
err = rh.c.MetaDB.AddUserAPIKey(req.Context(), hashedAPIKey, apiKeyDetails)
|
|
if err != nil {
|
|
rh.c.Log.Error().Err(err).Msg("failed to store api key")
|
|
resp.WriteHeader(http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
apiKeyResponse := struct {
|
|
mTypes.APIKeyDetails
|
|
|
|
APIKey string `json:"apiKey"`
|
|
}{
|
|
APIKey: fmt.Sprintf("%s%s", constants.APIKeysPrefix, apiKey),
|
|
APIKeyDetails: *apiKeyDetails,
|
|
}
|
|
|
|
json := jsoniter.ConfigCompatibleWithStandardLibrary
|
|
|
|
data, err := json.Marshal(apiKeyResponse) //nolint:gosec // API key is intentionally returned on creation
|
|
if err != nil {
|
|
rh.c.Log.Error().Err(err).Msg("failed to marshal api key response")
|
|
|
|
resp.WriteHeader(http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
resp.Header().Set("Content-Type", constants.DefaultMediaType)
|
|
resp.WriteHeader(http.StatusCreated)
|
|
_, _ = resp.Write(data)
|
|
}
|
|
|
|
// RevokeAPIKey godoc
|
|
// @Summary Revokes one current user API key
|
|
// @Description Revokes one current user API key based on given key ID
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param id query string true "api token id (UUID)"
|
|
// @Success 200 {string} string "ok"
|
|
// @Failure 500 {string} string "internal server error"
|
|
// @Failure 401 {string} string "unauthorized"
|
|
// @Failure 400 {string} string "bad request"
|
|
// @Router /zot/auth/apikey [delete].
|
|
func (rh *RouteHandler) RevokeAPIKey(resp http.ResponseWriter, req *http.Request) {
|
|
ids, ok := req.URL.Query()["id"]
|
|
if !ok || len(ids) != 1 {
|
|
resp.WriteHeader(http.StatusBadRequest)
|
|
|
|
return
|
|
}
|
|
|
|
keyID := ids[0]
|
|
|
|
err := rh.c.MetaDB.DeleteUserAPIKey(req.Context(), keyID)
|
|
if err != nil {
|
|
rh.c.Log.Error().Err(err).Str("keyID", keyID).Msg("failed to delete api key")
|
|
resp.WriteHeader(http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
resp.WriteHeader(http.StatusOK)
|
|
}
|
|
|
|
// GetBlobUploadSessionLocation returns actual blob location to start/resume uploading blobs.
|
|
// e.g. /v2/<name>/blobs/uploads/<session-id>.
|
|
func getBlobUploadSessionLocation(url *url.URL, sessionID string) string {
|
|
url.RawQuery = ""
|
|
|
|
if !strings.Contains(url.Path, sessionID) {
|
|
url.Path = path.Join(url.Path, sessionID)
|
|
}
|
|
|
|
return url.String()
|
|
}
|
|
|
|
// GetBlobUploadLocation returns actual blob location on registry
|
|
// e.g /v2/<name>/blobs/<digest>.
|
|
func getBlobUploadLocation(url *url.URL, name string, digest godigest.Digest) string {
|
|
url.RawQuery = ""
|
|
|
|
// we are relying on request URL to set location and
|
|
// if request URL contains uploads either we are resuming blob upload or starting a new blob upload.
|
|
// getBlobUploadLocation will be called only when blob upload is completed and
|
|
// location should be set as blob url <v2/<name>/blobs/<digest>>.
|
|
if strings.Contains(url.Path, "uploads") {
|
|
url.Path = path.Join(constants.RoutePrefix, name, constants.Blobs, digest.String())
|
|
}
|
|
|
|
return url.String()
|
|
}
|
|
|
|
func isSyncOnDemandEnabled(ctlr *Controller) bool {
|
|
if ctlr == nil {
|
|
return false
|
|
}
|
|
|
|
extensionsConfig := ctlr.Config.CopyExtensionsConfig()
|
|
if extensionsConfig.IsSyncEnabled() &&
|
|
fmt.Sprintf("%v", ctlr.SyncOnDemand) != fmt.Sprintf("%v", nil) {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|