mirror of
https://github.com/project-zot/zot.git
synced 2024-12-16 21:56:37 -05:00
af77876306
Go version changed to 1.14.4 Golangci-lint changed to 1.26.0 Bazel version changed to 3.0.0 Bazel rules_go version changed to 0.23.3 Bazel gazelle version changed to v0.21.0 Bazel build tools version changed to 0.25.1 Bazel skylib version changed to 1.0.2
1138 lines
34 KiB
Go
1138 lines
34 KiB
Go
// @title Open Container Initiative Distribution Specification
|
|
// @version v0.1.0-dev
|
|
// @description APIs for Open Container Initiative Distribution Specification
|
|
|
|
// @contact.name API Support
|
|
// @contact.url http://www.swagger.io/support
|
|
// @contact.email support@swagger.io
|
|
|
|
// @license.name Apache 2.0
|
|
// @license.url http://www.apache.org/licenses/LICENSE-2.0.html
|
|
|
|
package api
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"io/ioutil"
|
|
"net/http"
|
|
"path"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
_ "github.com/anuvu/zot/docs" // as required by swaggo
|
|
"github.com/anuvu/zot/errors"
|
|
"github.com/anuvu/zot/pkg/log"
|
|
"github.com/gorilla/mux"
|
|
jsoniter "github.com/json-iterator/go"
|
|
ispec "github.com/opencontainers/image-spec/specs-go/v1"
|
|
httpSwagger "github.com/swaggo/http-swagger"
|
|
)
|
|
|
|
const (
|
|
RoutePrefix = "/v2"
|
|
DistAPIVersion = "Docker-Distribution-API-Version"
|
|
DistContentDigestKey = "Docker-Content-Digest"
|
|
BlobUploadUUID = "Blob-Upload-UUID"
|
|
DefaultMediaType = "application/json"
|
|
BinaryMediaType = "application/octet-stream"
|
|
)
|
|
|
|
type RouteHandler struct {
|
|
c *Controller
|
|
}
|
|
|
|
func NewRouteHandler(c *Controller) *RouteHandler {
|
|
rh := &RouteHandler{c: c}
|
|
rh.SetupRoutes()
|
|
|
|
return rh
|
|
}
|
|
|
|
// blobRLockWrapper calls the real handler with read-lock held.
|
|
func (rh *RouteHandler) blobRLockWrapper(f func(w http.ResponseWriter,
|
|
r *http.Request)) func(w http.ResponseWriter, r *http.Request) {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
rh.c.ImageStore.RLock()
|
|
f(w, r)
|
|
rh.c.ImageStore.RUnlock()
|
|
}
|
|
}
|
|
|
|
// blobLockWrapper calls the real handler with write-lock held.
|
|
func (rh *RouteHandler) blobLockWrapper(f func(w http.ResponseWriter,
|
|
r *http.Request)) func(w http.ResponseWriter, r *http.Request) {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
rh.c.ImageStore.Lock()
|
|
f(w, r)
|
|
rh.c.ImageStore.Unlock()
|
|
}
|
|
}
|
|
|
|
func (rh *RouteHandler) SetupRoutes() {
|
|
rh.c.Router.Use(AuthHandler(rh.c))
|
|
g := rh.c.Router.PathPrefix(RoutePrefix).Subrouter()
|
|
{
|
|
g.HandleFunc(fmt.Sprintf("/{name:%s}/tags/list", NameRegexp.String()),
|
|
rh.ListTags).Methods("GET")
|
|
g.HandleFunc(fmt.Sprintf("/{name:%s}/manifests/{reference}", NameRegexp.String()),
|
|
rh.blobRLockWrapper(rh.CheckManifest)).Methods("HEAD")
|
|
g.HandleFunc(fmt.Sprintf("/{name:%s}/manifests/{reference}", NameRegexp.String()),
|
|
rh.blobRLockWrapper(rh.GetManifest)).Methods("GET")
|
|
g.HandleFunc(fmt.Sprintf("/{name:%s}/manifests/{reference}", NameRegexp.String()),
|
|
rh.blobLockWrapper(rh.UpdateManifest)).Methods("PUT")
|
|
g.HandleFunc(fmt.Sprintf("/{name:%s}/manifests/{reference}", NameRegexp.String()),
|
|
rh.blobLockWrapper(rh.DeleteManifest)).Methods("DELETE")
|
|
g.HandleFunc(fmt.Sprintf("/{name:%s}/blobs/{digest}", NameRegexp.String()),
|
|
rh.blobRLockWrapper(rh.CheckBlob)).Methods("HEAD")
|
|
g.HandleFunc(fmt.Sprintf("/{name:%s}/blobs/{digest}", NameRegexp.String()),
|
|
rh.blobRLockWrapper(rh.GetBlob)).Methods("GET")
|
|
g.HandleFunc(fmt.Sprintf("/{name:%s}/blobs/{digest}", NameRegexp.String()),
|
|
rh.blobLockWrapper(rh.DeleteBlob)).Methods("DELETE")
|
|
g.HandleFunc(fmt.Sprintf("/{name:%s}/blobs/uploads/", NameRegexp.String()),
|
|
rh.blobLockWrapper(rh.CreateBlobUpload)).Methods("POST")
|
|
g.HandleFunc(fmt.Sprintf("/{name:%s}/blobs/uploads/{session_id}", NameRegexp.String()),
|
|
rh.blobRLockWrapper(rh.GetBlobUpload)).Methods("GET")
|
|
g.HandleFunc(fmt.Sprintf("/{name:%s}/blobs/uploads/{session_id}", NameRegexp.String()),
|
|
rh.blobLockWrapper(rh.PatchBlobUpload)).Methods("PATCH")
|
|
g.HandleFunc(fmt.Sprintf("/{name:%s}/blobs/uploads/{session_id}", NameRegexp.String()),
|
|
rh.blobLockWrapper(rh.UpdateBlobUpload)).Methods("PUT")
|
|
g.HandleFunc(fmt.Sprintf("/{name:%s}/blobs/uploads/{session_id}", NameRegexp.String()),
|
|
rh.blobLockWrapper(rh.DeleteBlobUpload)).Methods("DELETE")
|
|
g.HandleFunc("/_catalog",
|
|
rh.ListRepositories).Methods("GET")
|
|
g.HandleFunc("/",
|
|
rh.CheckVersionSupport).Methods("GET")
|
|
}
|
|
// swagger docs "/swagger/v2/index.html"
|
|
rh.c.Router.PathPrefix("/swagger/v2/").Methods("GET").Handler(httpSwagger.WrapHandler)
|
|
}
|
|
|
|
// 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(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set(DistAPIVersion, "registry/2.0")
|
|
// NOTE: compatibility workaround - return this header in "allowed-read" mode to allow for clients to
|
|
// work correctly
|
|
if rh.c.Config.HTTP.AllowReadAccess {
|
|
if rh.c.Config.HTTP.Auth != nil {
|
|
if rh.c.Config.HTTP.Auth.Bearer != nil {
|
|
w.Header().Set("WWW-Authenticate", fmt.Sprintf("bearer realm=%s", rh.c.Config.HTTP.Auth.Bearer.Realm))
|
|
} else {
|
|
w.Header().Set("WWW-Authenticate", fmt.Sprintf("basic realm=%s", rh.c.Config.HTTP.Realm))
|
|
}
|
|
}
|
|
}
|
|
|
|
WriteData(w, http.StatusOK, "application/json", []byte{})
|
|
}
|
|
|
|
type ImageTags struct {
|
|
Name string `json:"name"`
|
|
Tags []string `json:"tags"`
|
|
}
|
|
|
|
// 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 "test"
|
|
// @Param n query integer true "limit entries for pagination"
|
|
// @Param last query string true "last tag value for pagination"
|
|
// @Success 200 {object} api.ImageTags
|
|
// @Failure 404 {string} string "not found"
|
|
// @Failure 400 {string} string "bad request".
|
|
func (rh *RouteHandler) ListTags(w http.ResponseWriter, r *http.Request) {
|
|
vars := mux.Vars(r)
|
|
name, ok := vars["name"]
|
|
|
|
if !ok || name == "" {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
paginate := false
|
|
n := -1
|
|
|
|
var err error
|
|
|
|
nQuery, ok := r.URL.Query()["n"]
|
|
|
|
if ok {
|
|
if len(nQuery) != 1 {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var n1 int64
|
|
|
|
if n1, err = strconv.ParseInt(nQuery[0], 10, 0); err != nil {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
n = int(n1)
|
|
paginate = true
|
|
}
|
|
|
|
last := ""
|
|
lastQuery, ok := r.URL.Query()["last"]
|
|
|
|
if ok {
|
|
if len(lastQuery) != 1 {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
last = lastQuery[0]
|
|
}
|
|
|
|
tags, err := rh.c.ImageStore.GetImageTags(name)
|
|
if err != nil {
|
|
WriteJSON(w, http.StatusNotFound, NewErrorList(NewError(NAME_UNKNOWN, map[string]string{"name": name})))
|
|
return
|
|
}
|
|
|
|
if paginate && (n < len(tags)) {
|
|
sort.Strings(tags)
|
|
|
|
pTags := ImageTags{Name: name}
|
|
|
|
if last == "" {
|
|
// first
|
|
pTags.Tags = tags[:n]
|
|
} else {
|
|
// next
|
|
i := -1
|
|
tag := ""
|
|
found := false
|
|
for i, tag = range tags {
|
|
if tag == last {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
if n >= len(tags)-i {
|
|
pTags.Tags = tags[i+1:]
|
|
WriteJSON(w, http.StatusOK, pTags)
|
|
return
|
|
}
|
|
pTags.Tags = tags[i+1 : i+1+n]
|
|
}
|
|
|
|
if len(pTags.Tags) == 0 {
|
|
last = ""
|
|
} else {
|
|
last = pTags.Tags[len(pTags.Tags)-1]
|
|
}
|
|
|
|
w.Header().Set("Link", fmt.Sprintf("/v2/%s/tags/list?n=%d&last=%s; rel=\"next\"", name, n, last))
|
|
WriteJSON(w, http.StatusOK, pTags)
|
|
|
|
return
|
|
}
|
|
|
|
WriteJSON(w, http.StatusOK, ImageTags{Name: name, Tags: tags})
|
|
}
|
|
|
|
// 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 {object} api.DistContentDigestKey
|
|
// @Failure 404 {string} string "not found"
|
|
// @Failure 500 {string} string "internal server error".
|
|
func (rh *RouteHandler) CheckManifest(w http.ResponseWriter, r *http.Request) {
|
|
vars := mux.Vars(r)
|
|
name, ok := vars["name"]
|
|
|
|
if !ok || name == "" {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
reference, ok := vars["reference"]
|
|
if !ok || reference == "" {
|
|
WriteJSON(w, http.StatusNotFound, NewErrorList(NewError(MANIFEST_INVALID, map[string]string{"reference": reference})))
|
|
return
|
|
}
|
|
|
|
_, digest, _, err := rh.c.ImageStore.GetImageManifest(name, reference)
|
|
if err != nil {
|
|
switch err {
|
|
case errors.ErrRepoNotFound:
|
|
WriteJSON(w, http.StatusNotFound,
|
|
NewErrorList(NewError(NAME_UNKNOWN, map[string]string{"reference": reference})))
|
|
case errors.ErrManifestNotFound:
|
|
WriteJSON(w, http.StatusNotFound,
|
|
NewErrorList(NewError(MANIFEST_UNKNOWN, map[string]string{"reference": reference})))
|
|
default:
|
|
rh.c.Log.Error().Err(err).Msg("unexpected error")
|
|
WriteJSON(w, http.StatusInternalServerError,
|
|
NewErrorList(NewError(MANIFEST_INVALID, map[string]string{"reference": reference})))
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
w.Header().Set(DistContentDigestKey, digest)
|
|
w.Header().Set("Content-Length", "0")
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
|
|
// NOTE: https://github.com/swaggo/swag/issues/387.
|
|
type ImageManifest struct {
|
|
ispec.Manifest
|
|
}
|
|
|
|
// 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 {object} api.DistContentDigestKey
|
|
// @Failure 404 {string} string "not found"
|
|
// @Failure 500 {string} string "internal server error"
|
|
// @Router /v2/{name}/manifests/{reference} [get].
|
|
func (rh *RouteHandler) GetManifest(w http.ResponseWriter, r *http.Request) {
|
|
vars := mux.Vars(r)
|
|
name, ok := vars["name"]
|
|
|
|
if !ok || name == "" {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
reference, ok := vars["reference"]
|
|
if !ok || reference == "" {
|
|
WriteJSON(w, http.StatusNotFound, NewErrorList(NewError(MANIFEST_UNKNOWN, map[string]string{"reference": reference})))
|
|
return
|
|
}
|
|
|
|
content, digest, mediaType, err := rh.c.ImageStore.GetImageManifest(name, reference)
|
|
if err != nil {
|
|
switch err {
|
|
case errors.ErrRepoNotFound:
|
|
WriteJSON(w, http.StatusNotFound,
|
|
NewErrorList(NewError(NAME_UNKNOWN, map[string]string{"name": name})))
|
|
case errors.ErrRepoBadVersion:
|
|
WriteJSON(w, http.StatusNotFound,
|
|
NewErrorList(NewError(NAME_UNKNOWN, map[string]string{"name": name})))
|
|
case errors.ErrManifestNotFound:
|
|
WriteJSON(w, http.StatusNotFound,
|
|
NewErrorList(NewError(MANIFEST_UNKNOWN, map[string]string{"reference": reference})))
|
|
default:
|
|
rh.c.Log.Error().Err(err).Msg("unexpected error")
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
w.Header().Set(DistContentDigestKey, digest)
|
|
WriteData(w, http.StatusOK, mediaType, content)
|
|
}
|
|
|
|
// UpdateManifest godoc
|
|
// @Summary Update image manifest
|
|
// @Description Update 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"
|
|
// @Header 201 {object} api.DistContentDigestKey
|
|
// @Success 201 {string} string "created"
|
|
// @Failure 400 {string} string "bad request"
|
|
// @Failure 404 {string} string "not found"
|
|
// @Failure 500 {string} string "internal server error"
|
|
// @Router /v2/{name}/manifests/{reference} [put].
|
|
func (rh *RouteHandler) UpdateManifest(w http.ResponseWriter, r *http.Request) {
|
|
vars := mux.Vars(r)
|
|
name, ok := vars["name"]
|
|
|
|
if !ok || name == "" {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
reference, ok := vars["reference"]
|
|
if !ok || reference == "" {
|
|
WriteJSON(w, http.StatusNotFound, NewErrorList(NewError(MANIFEST_INVALID, map[string]string{"reference": reference})))
|
|
return
|
|
}
|
|
|
|
mediaType := r.Header.Get("Content-Type")
|
|
if mediaType != ispec.MediaTypeImageManifest {
|
|
w.WriteHeader(http.StatusUnsupportedMediaType)
|
|
return
|
|
}
|
|
|
|
body, err := ioutil.ReadAll(r.Body)
|
|
if err != nil {
|
|
rh.c.Log.Error().Err(err).Msg("unexpected error")
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
digest, err := rh.c.ImageStore.PutImageManifest(name, reference, mediaType, body)
|
|
if err != nil {
|
|
switch err {
|
|
case errors.ErrRepoNotFound:
|
|
WriteJSON(w, http.StatusNotFound,
|
|
NewErrorList(NewError(NAME_UNKNOWN, map[string]string{"name": name})))
|
|
case errors.ErrManifestNotFound:
|
|
WriteJSON(w, http.StatusNotFound,
|
|
NewErrorList(NewError(MANIFEST_UNKNOWN, map[string]string{"reference": reference})))
|
|
case errors.ErrBadManifest:
|
|
WriteJSON(w, http.StatusBadRequest,
|
|
NewErrorList(NewError(MANIFEST_INVALID, map[string]string{"reference": reference})))
|
|
case errors.ErrBlobNotFound:
|
|
WriteJSON(w, http.StatusBadRequest,
|
|
NewErrorList(NewError(BLOB_UNKNOWN, map[string]string{"blob": digest})))
|
|
default:
|
|
rh.c.Log.Error().Err(err).Msg("unexpected error")
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Location", fmt.Sprintf("/v2/%s/manifests/%s", name, digest))
|
|
w.Header().Set(DistContentDigestKey, digest)
|
|
w.WriteHeader(http.StatusCreated)
|
|
}
|
|
|
|
// 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 200 {string} string "ok"
|
|
// @Router /v2/{name}/manifests/{reference} [delete].
|
|
func (rh *RouteHandler) DeleteManifest(w http.ResponseWriter, r *http.Request) {
|
|
vars := mux.Vars(r)
|
|
name, ok := vars["name"]
|
|
|
|
if !ok || name == "" {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
reference, ok := vars["reference"]
|
|
if !ok || reference == "" {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
err := rh.c.ImageStore.DeleteImageManifest(name, reference)
|
|
if err != nil {
|
|
switch err {
|
|
case errors.ErrRepoNotFound:
|
|
WriteJSON(w, http.StatusBadRequest,
|
|
NewErrorList(NewError(NAME_UNKNOWN, map[string]string{"name": name})))
|
|
case errors.ErrManifestNotFound:
|
|
WriteJSON(w, http.StatusNotFound,
|
|
NewErrorList(NewError(MANIFEST_UNKNOWN, map[string]string{"reference": reference})))
|
|
case errors.ErrBadManifest:
|
|
WriteJSON(w, http.StatusBadRequest,
|
|
NewErrorList(NewError(UNSUPPORTED, map[string]string{"reference": reference})))
|
|
default:
|
|
rh.c.Log.Error().Err(err).Msg("unexpected error")
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusAccepted)
|
|
}
|
|
|
|
// 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 {object} api.DistContentDigestKey
|
|
// @Router /v2/{name}/blobs/{digest} [head].
|
|
func (rh *RouteHandler) CheckBlob(w http.ResponseWriter, r *http.Request) {
|
|
vars := mux.Vars(r)
|
|
name, ok := vars["name"]
|
|
|
|
if !ok || name == "" {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
digest, ok := vars["digest"]
|
|
if !ok || digest == "" {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
mediaType := r.Header.Get("Accept")
|
|
|
|
ok, blen, err := rh.c.ImageStore.CheckBlob(name, digest, mediaType)
|
|
if err != nil {
|
|
switch err {
|
|
case errors.ErrBadBlobDigest:
|
|
WriteJSON(w, http.StatusBadRequest, NewErrorList(NewError(DIGEST_INVALID, map[string]string{"digest": digest})))
|
|
case errors.ErrRepoNotFound:
|
|
WriteJSON(w, http.StatusNotFound, NewErrorList(NewError(NAME_UNKNOWN, map[string]string{"name": name})))
|
|
case errors.ErrBlobNotFound:
|
|
WriteJSON(w, http.StatusNotFound, NewErrorList(NewError(BLOB_UNKNOWN, map[string]string{"digest": digest})))
|
|
default:
|
|
rh.c.Log.Error().Err(err).Msg("unexpected error")
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
if !ok {
|
|
WriteJSON(w, http.StatusNotFound, NewErrorList(NewError(BLOB_UNKNOWN, map[string]string{"digest": digest})))
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", blen))
|
|
w.Header().Set(DistContentDigestKey, digest)
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
|
|
// 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 {object} api.DistContentDigestKey
|
|
// @Success 200 {object} api.ImageManifest
|
|
// @Router /v2/{name}/blobs/{digest} [get].
|
|
func (rh *RouteHandler) GetBlob(w http.ResponseWriter, r *http.Request) {
|
|
vars := mux.Vars(r)
|
|
name, ok := vars["name"]
|
|
|
|
if !ok || name == "" {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
digest, ok := vars["digest"]
|
|
if !ok || digest == "" {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
mediaType := r.Header.Get("Accept")
|
|
|
|
br, blen, err := rh.c.ImageStore.GetBlob(name, digest, mediaType)
|
|
if err != nil {
|
|
switch err {
|
|
case errors.ErrBadBlobDigest:
|
|
WriteJSON(w, http.StatusBadRequest, NewErrorList(NewError(DIGEST_INVALID, map[string]string{"digest": digest})))
|
|
case errors.ErrRepoNotFound:
|
|
WriteJSON(w, http.StatusNotFound, NewErrorList(NewError(NAME_UNKNOWN, map[string]string{"name": name})))
|
|
case errors.ErrBlobNotFound:
|
|
WriteJSON(w, http.StatusNotFound, NewErrorList(NewError(BLOB_UNKNOWN, map[string]string{"digest": digest})))
|
|
default:
|
|
rh.c.Log.Error().Err(err).Msg("unexpected error")
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", blen))
|
|
w.Header().Set(DistContentDigestKey, digest)
|
|
// return the blob data
|
|
WriteDataFromReader(w, http.StatusOK, blen, mediaType, br, 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 {string} string "accepted"
|
|
// @Router /v2/{name}/blobs/{digest} [delete].
|
|
func (rh *RouteHandler) DeleteBlob(w http.ResponseWriter, r *http.Request) {
|
|
vars := mux.Vars(r)
|
|
name, ok := vars["name"]
|
|
|
|
if !ok || name == "" {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
digest, ok := vars["digest"]
|
|
if !ok || digest == "" {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
err := rh.c.ImageStore.DeleteBlob(name, digest)
|
|
if err != nil {
|
|
switch err {
|
|
case errors.ErrBadBlobDigest:
|
|
WriteJSON(w, http.StatusBadRequest, NewErrorList(NewError(DIGEST_INVALID, map[string]string{"digest": digest})))
|
|
case errors.ErrRepoNotFound:
|
|
WriteJSON(w, http.StatusNotFound, NewErrorList(NewError(NAME_UNKNOWN, map[string]string{"name": name})))
|
|
case errors.ErrBlobNotFound:
|
|
WriteJSON(w, http.StatusNotFound, NewErrorList(NewError(BLOB_UNKNOWN, map[string]string{"digest": digest})))
|
|
default:
|
|
rh.c.Log.Error().Err(err).Msg("unexpected error")
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
w.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 {string} string "accepted"
|
|
// @Header 202 {string} Location "/v2/{name}/blobs/uploads/{session_id}"
|
|
// @Header 202 {string} Range "bytes=0-0"
|
|
// @Failure 404 {string} string "not found"
|
|
// @Failure 500 {string} string "internal server error"
|
|
// @Router /v2/{name}/blobs/uploads [post].
|
|
func (rh *RouteHandler) CreateBlobUpload(w http.ResponseWriter, r *http.Request) {
|
|
vars := mux.Vars(r)
|
|
name, ok := vars["name"]
|
|
|
|
if !ok || name == "" {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
// blob mounts not allowed since we don't have access control yet, and this
|
|
// may be a uncommon use case, but remain compliant
|
|
if _, ok := r.URL.Query()["mount"]; ok {
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
if _, ok := r.URL.Query()["from"]; ok {
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
// a full blob upload if "digest" is present
|
|
digests, ok := r.URL.Query()["digest"]
|
|
if ok {
|
|
if len(digests) != 1 {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
digest := digests[0]
|
|
|
|
if contentType := r.Header.Get("Content-Type"); contentType != BinaryMediaType {
|
|
rh.c.Log.Warn().Str("actual", contentType).Str("expected", BinaryMediaType).Msg("invalid media type")
|
|
w.WriteHeader(http.StatusUnsupportedMediaType)
|
|
|
|
return
|
|
}
|
|
|
|
rh.c.Log.Info().Int64("r.ContentLength", r.ContentLength).Msg("DEBUG")
|
|
|
|
var contentLength int64
|
|
|
|
var err error
|
|
|
|
if contentLength, err = strconv.ParseInt(r.Header.Get("Content-Length"), 10, 64); err != nil || contentLength <= 0 {
|
|
rh.c.Log.Warn().Str("actual", r.Header.Get("Content-Length")).Msg("invalid content length")
|
|
WriteJSON(w, http.StatusBadRequest,
|
|
NewErrorList(NewError(BLOB_UPLOAD_INVALID, map[string]string{"digest": digest})))
|
|
|
|
return
|
|
}
|
|
|
|
sessionID, size, err := rh.c.ImageStore.FullBlobUpload(name, r.Body, digest)
|
|
if err != nil {
|
|
rh.c.Log.Error().Err(err).Int64("actual", size).Int64("expected", contentLength).Msg("failed full upload")
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
if size != contentLength {
|
|
rh.c.Log.Warn().Int64("actual", size).Int64("expected", contentLength).Msg("invalid content length")
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/%s", name, digest))
|
|
w.Header().Set(BlobUploadUUID, sessionID)
|
|
w.WriteHeader(http.StatusCreated)
|
|
|
|
return
|
|
}
|
|
|
|
u, err := rh.c.ImageStore.NewBlobUpload(name)
|
|
if err != nil {
|
|
switch err {
|
|
case errors.ErrRepoNotFound:
|
|
WriteJSON(w, http.StatusNotFound, NewErrorList(NewError(NAME_UNKNOWN, map[string]string{"name": name})))
|
|
default:
|
|
rh.c.Log.Error().Err(err).Msg("unexpected error")
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Location", path.Join(r.URL.String(), u))
|
|
w.Header().Set("Range", "bytes=0-0")
|
|
w.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 {string} string "no content"
|
|
// @Header 202 {string} Location "/v2/{name}/blobs/uploads/{session_id}"
|
|
// @Header 202 {string} Range "bytes=0-128"
|
|
// @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(w http.ResponseWriter, r *http.Request) {
|
|
vars := mux.Vars(r)
|
|
name, ok := vars["name"]
|
|
|
|
if !ok || name == "" {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
sessionID, ok := vars["session_id"]
|
|
if !ok || sessionID == "" {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
size, err := rh.c.ImageStore.GetBlobUpload(name, sessionID)
|
|
if err != nil {
|
|
switch err {
|
|
case errors.ErrBadUploadRange:
|
|
WriteJSON(w, http.StatusBadRequest,
|
|
NewErrorList(NewError(BLOB_UPLOAD_INVALID, map[string]string{"session_id": sessionID})))
|
|
case errors.ErrBadBlobDigest:
|
|
WriteJSON(w, http.StatusBadRequest,
|
|
NewErrorList(NewError(BLOB_UPLOAD_INVALID, map[string]string{"session_id": sessionID})))
|
|
case errors.ErrRepoNotFound:
|
|
WriteJSON(w, http.StatusNotFound,
|
|
NewErrorList(NewError(NAME_UNKNOWN, map[string]string{"name": name})))
|
|
case errors.ErrUploadNotFound:
|
|
WriteJSON(w, http.StatusNotFound,
|
|
NewErrorList(NewError(BLOB_UPLOAD_UNKNOWN, map[string]string{"session_id": sessionID})))
|
|
default:
|
|
rh.c.Log.Error().Err(err).Msg("unexpected error")
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Location", path.Join(r.URL.String(), sessionID))
|
|
w.Header().Set("Range", fmt.Sprintf("bytes=0-%d", size-1))
|
|
w.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 {string} string "accepted"
|
|
// @Header 202 {string} Location "/v2/{name}/blobs/uploads/{session_id}"
|
|
// @Header 202 {string} Range "bytes=0-128"
|
|
// @Header 200 {object} api.BlobUploadUUID
|
|
// @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(w http.ResponseWriter, r *http.Request) {
|
|
vars := mux.Vars(r)
|
|
name, ok := vars["name"]
|
|
|
|
if !ok || name == "" {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
sessionID, ok := vars["session_id"]
|
|
if !ok || sessionID == "" {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
if contentType := r.Header.Get("Content-Type"); contentType != BinaryMediaType {
|
|
rh.c.Log.Warn().Str("actual", contentType).Str("expected", BinaryMediaType).Msg("invalid media type")
|
|
w.WriteHeader(http.StatusUnsupportedMediaType)
|
|
|
|
return
|
|
}
|
|
|
|
var err error
|
|
|
|
var clen int64
|
|
|
|
if r.Header.Get("Content-Length") == "" || r.Header.Get("Content-Range") == "" {
|
|
// streamed blob upload
|
|
clen, err = rh.c.ImageStore.PutBlobChunkStreamed(name, sessionID, r.Body)
|
|
} else {
|
|
// chunked blob upload
|
|
|
|
var contentLength int64
|
|
|
|
if contentLength, err = strconv.ParseInt(r.Header.Get("Content-Length"), 10, 64); err != nil {
|
|
rh.c.Log.Warn().Str("actual", r.Header.Get("Content-Length")).Msg("invalid content length")
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
|
|
return
|
|
}
|
|
|
|
contentRange := r.Header.Get("Content-Range")
|
|
if contentRange == "" {
|
|
rh.c.Log.Warn().Str("actual", r.Header.Get("Content-Range")).Msg("invalid content range")
|
|
w.WriteHeader(http.StatusRequestedRangeNotSatisfiable)
|
|
|
|
return
|
|
}
|
|
|
|
var from, to int64
|
|
if from, to, err = getContentRange(r); err != nil || (to-from)+1 != contentLength {
|
|
w.WriteHeader(http.StatusRequestedRangeNotSatisfiable)
|
|
return
|
|
}
|
|
|
|
clen, err = rh.c.ImageStore.PutBlobChunk(name, sessionID, from, to, r.Body)
|
|
}
|
|
|
|
if err != nil {
|
|
switch err {
|
|
case errors.ErrBadUploadRange:
|
|
WriteJSON(w, http.StatusRequestedRangeNotSatisfiable,
|
|
NewErrorList(NewError(BLOB_UPLOAD_INVALID, map[string]string{"session_id": sessionID})))
|
|
case errors.ErrRepoNotFound:
|
|
WriteJSON(w, http.StatusNotFound,
|
|
NewErrorList(NewError(NAME_UNKNOWN, map[string]string{"name": name})))
|
|
case errors.ErrUploadNotFound:
|
|
WriteJSON(w, http.StatusNotFound,
|
|
NewErrorList(NewError(BLOB_UPLOAD_UNKNOWN, map[string]string{"session_id": sessionID})))
|
|
default:
|
|
rh.c.Log.Error().Err(err).Msg("unexpected error")
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Location", r.URL.String())
|
|
w.Header().Set("Range", fmt.Sprintf("bytes=0-%d", clen-1))
|
|
w.Header().Set("Content-Length", "0")
|
|
w.Header().Set(BlobUploadUUID, sessionID)
|
|
w.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 {string} string "created"
|
|
// @Header 202 {string} Location "/v2/{name}/blobs/uploads/{digest}"
|
|
// @Header 200 {object} api.DistContentDigestKey
|
|
// @Failure 404 {string} string "not found"
|
|
// @Failure 500 {string} string "internal server error"
|
|
// @Router /v2/{name}/blobs/uploads/{session_id} [put].
|
|
func (rh *RouteHandler) UpdateBlobUpload(w http.ResponseWriter, r *http.Request) {
|
|
rh.c.Log.Info().Interface("headers", r.Header).Msg("HEADERS")
|
|
vars := mux.Vars(r)
|
|
name, ok := vars["name"]
|
|
|
|
if !ok || name == "" {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
sessionID, ok := vars["session_id"]
|
|
if !ok || sessionID == "" {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
digests, ok := r.URL.Query()["digest"]
|
|
if !ok || len(digests) != 1 {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
digest := digests[0]
|
|
|
|
rh.c.Log.Info().Int64("r.ContentLength", r.ContentLength).Msg("DEBUG")
|
|
|
|
contentPresent := true
|
|
contentLen, err := strconv.ParseInt(r.Header.Get("Content-Length"), 10, 64)
|
|
|
|
if err != nil {
|
|
contentPresent = false
|
|
}
|
|
|
|
contentRangePresent := true
|
|
|
|
if r.Header.Get("Content-Range") == "" {
|
|
contentRangePresent = false
|
|
}
|
|
|
|
// we expect at least one of "Content-Length" or "Content-Range" to be
|
|
// present
|
|
if !contentPresent && !contentRangePresent {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var from, to int64
|
|
|
|
if contentPresent {
|
|
contentRange := r.Header.Get("Content-Range")
|
|
if contentRange == "" { // monolithic upload
|
|
from = 0
|
|
|
|
if contentLen == 0 {
|
|
goto finish // FIXME:
|
|
}
|
|
|
|
to = contentLen
|
|
} else if from, to, err = getContentRange(r); err != nil { // finish chunked upload
|
|
w.WriteHeader(http.StatusRequestedRangeNotSatisfiable)
|
|
return
|
|
}
|
|
|
|
_, err = rh.c.ImageStore.PutBlobChunk(name, sessionID, from, to, r.Body)
|
|
if err != nil {
|
|
switch err {
|
|
case errors.ErrBadUploadRange:
|
|
WriteJSON(w, http.StatusBadRequest,
|
|
NewErrorList(NewError(BLOB_UPLOAD_INVALID, map[string]string{"session_id": sessionID})))
|
|
case errors.ErrRepoNotFound:
|
|
WriteJSON(w, http.StatusNotFound,
|
|
NewErrorList(NewError(NAME_UNKNOWN, map[string]string{"name": name})))
|
|
case errors.ErrUploadNotFound:
|
|
WriteJSON(w, http.StatusNotFound,
|
|
NewErrorList(NewError(BLOB_UPLOAD_UNKNOWN, map[string]string{"session_id": sessionID})))
|
|
default:
|
|
rh.c.Log.Error().Err(err).Msg("unexpected error")
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
}
|
|
|
|
return
|
|
}
|
|
}
|
|
|
|
finish:
|
|
// blob chunks already transferred, just finish
|
|
if err := rh.c.ImageStore.FinishBlobUpload(name, sessionID, r.Body, digest); err != nil {
|
|
switch err {
|
|
case errors.ErrBadBlobDigest:
|
|
WriteJSON(w, http.StatusBadRequest,
|
|
NewErrorList(NewError(DIGEST_INVALID, map[string]string{"digest": digest})))
|
|
case errors.ErrBadUploadRange:
|
|
WriteJSON(w, http.StatusBadRequest,
|
|
NewErrorList(NewError(BLOB_UPLOAD_INVALID, map[string]string{"session_id": sessionID})))
|
|
case errors.ErrRepoNotFound:
|
|
WriteJSON(w, http.StatusNotFound,
|
|
NewErrorList(NewError(NAME_UNKNOWN, map[string]string{"name": name})))
|
|
case errors.ErrUploadNotFound:
|
|
WriteJSON(w, http.StatusNotFound,
|
|
NewErrorList(NewError(BLOB_UPLOAD_UNKNOWN, map[string]string{"session_id": sessionID})))
|
|
default:
|
|
rh.c.Log.Error().Err(err).Msg("unexpected error")
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/%s", name, digest))
|
|
w.Header().Set("Content-Length", "0")
|
|
w.Header().Set(DistContentDigestKey, digest)
|
|
w.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 200 {string} string "ok"
|
|
// @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(w http.ResponseWriter, r *http.Request) {
|
|
vars := mux.Vars(r)
|
|
name, ok := vars["name"]
|
|
|
|
if !ok || name == "" {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
sessionID, ok := vars["session_id"]
|
|
if !ok || sessionID == "" {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
if err := rh.c.ImageStore.DeleteBlobUpload(name, sessionID); err != nil {
|
|
switch err {
|
|
case errors.ErrRepoNotFound:
|
|
WriteJSON(w, http.StatusNotFound,
|
|
NewErrorList(NewError(NAME_UNKNOWN, map[string]string{"name": name})))
|
|
case errors.ErrUploadNotFound:
|
|
WriteJSON(w, http.StatusNotFound,
|
|
NewErrorList(NewError(BLOB_UPLOAD_UNKNOWN, map[string]string{"session_id": sessionID})))
|
|
default:
|
|
rh.c.Log.Error().Err(err).Msg("unexpected error")
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
type RepositoryList struct {
|
|
Repositories []string `json:"repositories"`
|
|
}
|
|
|
|
// 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(w http.ResponseWriter, r *http.Request) {
|
|
repos, err := rh.c.ImageStore.GetRepositories()
|
|
if err != nil {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
is := RepositoryList{Repositories: repos}
|
|
|
|
WriteJSON(w, http.StatusOK, is)
|
|
}
|
|
|
|
// helper routines
|
|
|
|
func getContentRange(r *http.Request) (int64 /* from */, int64 /* to */, error) {
|
|
contentRange := r.Header.Get("Content-Range")
|
|
tokens := strings.Split(contentRange, "-")
|
|
from, err := strconv.ParseInt(tokens[0], 10, 64)
|
|
|
|
if err != nil {
|
|
return -1, -1, errors.ErrBadUploadRange
|
|
}
|
|
|
|
to, err := strconv.ParseInt(tokens[1], 10, 64)
|
|
if err != nil {
|
|
return -1, -1, errors.ErrBadUploadRange
|
|
}
|
|
|
|
if from > to {
|
|
return -1, -1, errors.ErrBadUploadRange
|
|
}
|
|
|
|
return from, to, nil
|
|
}
|
|
|
|
func WriteJSON(w http.ResponseWriter, status int, data interface{}) {
|
|
var json = jsoniter.ConfigCompatibleWithStandardLibrary
|
|
body, err := json.Marshal(data)
|
|
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
WriteData(w, status, DefaultMediaType, body)
|
|
}
|
|
|
|
func WriteData(w http.ResponseWriter, status int, mediaType string, data []byte) {
|
|
w.Header().Set("Content-Type", mediaType)
|
|
w.WriteHeader(status)
|
|
_, _ = w.Write(data)
|
|
}
|
|
|
|
func WriteDataFromReader(w http.ResponseWriter, status int, length int64, mediaType string,
|
|
reader io.Reader, logger log.Logger) {
|
|
w.Header().Set("Content-Type", mediaType)
|
|
w.Header().Set("Content-Length", strconv.FormatInt(length, 10))
|
|
w.WriteHeader(status)
|
|
|
|
const maxSize = 10 * 1024 * 1024
|
|
|
|
for {
|
|
_, err := io.CopyN(w, reader, maxSize)
|
|
if err == io.EOF {
|
|
break
|
|
} else if err != nil {
|
|
// other kinds of intermittent errors can occur, e.g, io.ErrShortWrite
|
|
logger.Error().Err(err).Msg("copying data into http response")
|
|
return
|
|
}
|
|
}
|
|
}
|