From df79ccf7d8b69f63b7cb66d340e26ce1e3e79c89 Mon Sep 17 00:00:00 2001 From: patdyn Date: Fri, 6 Mar 2026 18:56:49 +0100 Subject: [PATCH] Move Container API processing logic to service (#11432) As discussed here: https://codeberg.org/forgejo/discussions/issues/444 the container v2 API logic does need some refactoring for better maintainability. This is a proposition on how to achieve that. My goal was to be able to write unit tests for functions like processImageManifest() which are currently only tested indirectly by TestPackageContainer() in tests/integration/api_packages_container_test.go. A first unit test was implemented that targets ProcessManifest(). I think that test also shows what steps are needed to successfully execute the ProcessManifest() function and hopefully helps understanding that code better. ## Checklist The [contributor guide](https://forgejo.org/docs/next/contributor/) contains information that will be helpful to first time contributors. There also are a few [conditions for merging Pull Requests in Forgejo repositories](https://codeberg.org/forgejo/governance/src/branch/main/PullRequestsAgreement.md). You are also welcome to join the [Forgejo development chatroom](https://matrix.to/#/#forgejo-development:matrix.org). ### Tests for Go changes (can be removed for JavaScript changes) - I added test coverage for Go changes... - [x ] in their respective `*_test.go` for unit tests. - [ ] in the `tests/integration` directory if it involves interactions with a live Forgejo server. - I ran... - [x] `make pr-go` before pushing ### Documentation - [ ] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change. - [x ] I did not document these changes and I do not expect someone else to do it. ### Release notes - [ ] This change will be noticed by a Forgejo user or admin (feature, bug fix, performance, etc.). I suggest to include a release note for this change. - [ x] This change is not visible to a Forgejo user or admin (refactor, dependency upgrade, etc.). I think there is no need to add a release note for this change. *The decision if the pull request will be shown in the release notes is up to the mergers / release team.* The content of the `release-notes/.md` file will serve as the basis for the release notes. If the file does not exist, the title of the pull request will be used instead. Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/11432 Reviewed-by: Andreas Ahlenstorf Reviewed-by: Mathieu Fenniak Co-authored-by: patdyn Co-committed-by: patdyn --- routers/api/packages/container/container.go | 216 ++++++------------ .../packages/container/blob.go | 55 +++-- services/packages/container/container.go | 36 +++ .../packages/container/errors.go | 36 +-- services/packages/container/main_test.go | 20 ++ .../packages/container/manifest.go | 112 +++++++-- services/packages/container/manifest_test.go | 95 ++++++++ services/packages/container/tags.go | 47 ++++ 8 files changed, 417 insertions(+), 200 deletions(-) rename {routers/api => services}/packages/container/blob.go (76%) create mode 100644 services/packages/container/container.go rename {routers/api => services}/packages/container/errors.go (50%) create mode 100644 services/packages/container/main_test.go rename {routers/api => services}/packages/container/manifest.go (86%) create mode 100644 services/packages/container/manifest_test.go create mode 100644 services/packages/container/tags.go diff --git a/routers/api/packages/container/container.go b/routers/api/packages/container/container.go index 873ce5c23a..a9f198c18d 100644 --- a/routers/api/packages/container/container.go +++ b/routers/api/packages/container/container.go @@ -10,10 +10,8 @@ import ( "io" "net/http" "net/url" - "os" "regexp" "strconv" - "strings" auth_model "forgejo.org/models/auth" packages_model "forgejo.org/models/packages" @@ -24,7 +22,6 @@ import ( packages_module "forgejo.org/modules/packages" container_module "forgejo.org/modules/packages/container" "forgejo.org/modules/setting" - "forgejo.org/modules/util" "forgejo.org/routers/api/packages/helper" "forgejo.org/services/context" packages_service "forgejo.org/services/packages" @@ -33,14 +30,7 @@ import ( digest "github.com/opencontainers/go-digest" ) -// maximum size of a container manifest -// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pushing-manifests -const maxManifestSize = 10 * 1024 * 1024 - -var ( - imageNamePattern = regexp.MustCompile(`\A[a-z0-9]+([._-][a-z0-9]+)*(/[a-z0-9]+([._-][a-z0-9]+)*)*\z`) - referencePattern = regexp.MustCompile(`\A[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}\z`) -) +var imageNamePattern = regexp.MustCompile(`\A[a-z0-9]+([._-][a-z0-9]+)*(/[a-z0-9]+([._-][a-z0-9]+)*)*\z`) type containerHeaders struct { Status int @@ -105,7 +95,7 @@ func apiError(ctx *context.Context, status int, err error) { } // https://github.com/opencontainers/distribution-spec/blob/main/spec.md#error-codes -func apiErrorDefined(ctx *context.Context, err *namedError) { +func apiErrorDefined(ctx *context.Context, err *container_service.NamedError) { type ContainerError struct { Code string `json:"code"` Message string `json:"message"` @@ -127,7 +117,7 @@ func apiErrorDefined(ctx *context.Context, err *namedError) { func apiUnauthorizedError(ctx *context.Context) { ctx.Resp.Header().Add("WWW-Authenticate", `Bearer realm="`+setting.AppURL+`v2/token",service="container_registry",scope="*"`) - apiErrorDefined(ctx, errUnauthorized) + apiErrorDefined(ctx, container_service.ErrUnauthorized) } // ReqContainerAccess is a middleware which checks the current user valid (real user or ghost if anonymous access is enabled) @@ -140,7 +130,7 @@ func ReqContainerAccess(ctx *context.Context) { // VerifyImageName is a middleware which checks if the image name is allowed func VerifyImageName(ctx *context.Context) { if !imageNamePattern.MatchString(ctx.Params("image")) { - apiErrorDefined(ctx, errNameInvalid) + apiErrorDefined(ctx, container_service.ErrNameInvalid) } } @@ -232,7 +222,7 @@ func InitiateUploadBlob(ctx *context.Context) { mount := ctx.FormTrim("mount") from := ctx.FormTrim("from") if mount != "" { - blob, _ := workaroundGetContainerBlob(ctx, &container_model.BlobSearchOptions{ + blob, _ := container_service.WorkaroundGetContainerBlob(ctx, &container_model.BlobSearchOptions{ Repository: from, Digest: mount, }) @@ -244,7 +234,7 @@ func InitiateUploadBlob(ctx *context.Context) { } if accessible { - if err := mountBlob(ctx, &packages_service.PackageInfo{Owner: ctx.Package.Owner, Name: image}, blob.Blob); err != nil { + if err := container_service.MountBlob(ctx, &packages_service.PackageInfo{Owner: ctx.Package.Owner, Name: image}, blob.Blob); err != nil { apiError(ctx, http.StatusInternalServerError, err) return } @@ -268,12 +258,12 @@ func InitiateUploadBlob(ctx *context.Context) { } defer buf.Close() - if digest != digestFromHashSummer(buf) { - apiErrorDefined(ctx, errDigestInvalid) + if digest != container_service.DigestFromHashSummer(buf) { + apiErrorDefined(ctx, container_service.ErrDigestInvalid) return } - if _, err := saveAsPackageBlob(ctx, + if _, err := container_service.SaveAsPackageBlob(ctx, buf, &packages_service.PackageCreationInfo{ PackageInfo: packages_service.PackageInfo{ @@ -321,7 +311,7 @@ func GetUploadBlob(ctx *context.Context) { upload, err := packages_model.GetBlobUploadByID(ctx, uuid) if err != nil { if err == packages_model.ErrPackageBlobUploadNotExist { - apiErrorDefined(ctx, errBlobUploadUnknown) + apiErrorDefined(ctx, container_service.ErrBlobUploadUnknown) } else { apiError(ctx, http.StatusInternalServerError, err) } @@ -342,7 +332,7 @@ func UploadBlob(ctx *context.Context) { uploader, err := container_service.NewBlobUploader(ctx, ctx.Params("uuid")) if err != nil { if err == packages_model.ErrPackageBlobUploadNotExist { - apiErrorDefined(ctx, errBlobUploadUnknown) + apiErrorDefined(ctx, container_service.ErrBlobUploadUnknown) } else { apiError(ctx, http.StatusInternalServerError, err) } @@ -354,16 +344,16 @@ func UploadBlob(ctx *context.Context) { if contentRange != "" { start, end := 0, 0 if _, err := fmt.Sscanf(contentRange, "%d-%d", &start, &end); err != nil { - apiErrorDefined(ctx, errBlobUploadInvalid) + apiErrorDefined(ctx, container_service.ErrBlobUploadInvalid) return } if int64(start) != uploader.Size() { - apiErrorDefined(ctx, errBlobUploadInvalid.WithStatusCode(http.StatusRequestedRangeNotSatisfiable)) + apiErrorDefined(ctx, container_service.ErrBlobUploadInvalid.WithStatusCode(http.StatusRequestedRangeNotSatisfiable)) return } } else if uploader.Size() != 0 { - apiErrorDefined(ctx, errBlobUploadInvalid.WithMessage("Stream uploads after first write are not allowed")) + apiErrorDefined(ctx, container_service.ErrBlobUploadInvalid.WithMessage("Stream uploads after first write are not allowed")) return } @@ -386,14 +376,14 @@ func EndUploadBlob(ctx *context.Context) { digest := ctx.FormTrim("digest") if digest == "" { - apiErrorDefined(ctx, errDigestInvalid) + apiErrorDefined(ctx, container_service.ErrDigestInvalid) return } uploader, err := container_service.NewBlobUploader(ctx, ctx.Params("uuid")) if err != nil { if err == packages_model.ErrPackageBlobUploadNotExist { - apiErrorDefined(ctx, errBlobUploadUnknown) + apiErrorDefined(ctx, container_service.ErrBlobUploadUnknown) } else { apiError(ctx, http.StatusInternalServerError, err) } @@ -408,12 +398,12 @@ func EndUploadBlob(ctx *context.Context) { } } - if digest != digestFromHashSummer(uploader) { - apiErrorDefined(ctx, errDigestInvalid) + if digest != container_service.DigestFromHashSummer(uploader) { + apiErrorDefined(ctx, container_service.ErrDigestInvalid) return } - if _, err := saveAsPackageBlob(ctx, + if _, err := container_service.SaveAsPackageBlob(ctx, uploader, &packages_service.PackageCreationInfo{ PackageInfo: packages_service.PackageInfo{ @@ -451,7 +441,7 @@ func CancelUploadBlob(ctx *context.Context) { _, err := packages_model.GetBlobUploadByID(ctx, uuid) if err != nil { if err == packages_model.ErrPackageBlobUploadNotExist { - apiErrorDefined(ctx, errBlobUploadUnknown) + apiErrorDefined(ctx, container_service.ErrBlobUploadUnknown) } else { apiError(ctx, http.StatusInternalServerError, err) } @@ -468,26 +458,12 @@ func CancelUploadBlob(ctx *context.Context) { }) } -func getBlobFromContext(ctx *context.Context) (*packages_model.PackageFileDescriptor, error) { - d := ctx.Params("digest") - - if digest.Digest(d).Validate() != nil { - return nil, container_model.ErrContainerBlobNotExist - } - - return workaroundGetContainerBlob(ctx, &container_model.BlobSearchOptions{ - OwnerID: ctx.Package.Owner.ID, - Image: ctx.Params("image"), - Digest: d, - }) -} - // https://github.com/opencontainers/distribution-spec/blob/main/spec.md#checking-if-content-exists-in-the-registry func HeadBlob(ctx *context.Context) { - blob, err := getBlobFromContext(ctx) + blob, err := container_service.GetLocalBlob(ctx, ctx.Package.Owner.ID, ctx.Params("digest"), ctx.Params("image")) if err != nil { - if err == container_model.ErrContainerBlobNotExist { - apiErrorDefined(ctx, errBlobUnknown) + if errors.Is(err, container_model.ErrContainerBlobNotExist) { + apiErrorDefined(ctx, container_service.ErrBlobUnknown) } else { apiError(ctx, http.StatusInternalServerError, err) } @@ -503,10 +479,10 @@ func HeadBlob(ctx *context.Context) { // https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pulling-blobs func GetBlob(ctx *context.Context) { - blob, err := getBlobFromContext(ctx) + blob, err := container_service.GetLocalBlob(ctx, ctx.Package.Owner.ID, ctx.Params("digest"), ctx.Params("image")) if err != nil { if err == container_model.ErrContainerBlobNotExist { - apiErrorDefined(ctx, errBlobUnknown) + apiErrorDefined(ctx, container_service.ErrBlobUnknown) } else { apiError(ctx, http.StatusInternalServerError, err) } @@ -521,11 +497,11 @@ func DeleteBlob(ctx *context.Context) { d := ctx.Params("digest") if digest.Digest(d).Validate() != nil { - apiErrorDefined(ctx, errBlobUnknown) + apiErrorDefined(ctx, container_service.ErrBlobUnknown) return } - if err := deleteBlob(ctx, ctx.Package.Owner.ID, ctx.Params("image"), d); err != nil { + if err := container_service.DeleteBlob(ctx, ctx.Package.Owner.ID, ctx.Params("image"), d); err != nil { apiError(ctx, http.StatusInternalServerError, err) return } @@ -537,23 +513,19 @@ func DeleteBlob(ctx *context.Context) { // https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pushing-manifests func UploadManifest(ctx *context.Context) { - reference := ctx.Params("reference") - - mci := &manifestCreationInfo{ - MediaType: ctx.Req.Header.Get("Content-Type"), - Owner: ctx.Package.Owner, - Creator: ctx.Doer, - Image: ctx.Params("image"), - Reference: reference, - IsTagged: digest.Digest(reference).Validate() != nil, - } - - if mci.IsTagged && !referencePattern.MatchString(reference) { - apiErrorDefined(ctx, errManifestInvalid.WithMessage("Tag is invalid")) + mci, err := container_service.NewManifestCreationInfo( + ctx.Package.Owner, + ctx.Doer, + ctx.Req.Header.Get("Content-Type"), + ctx.Params("image"), + ctx.Params("reference"), + ) + if err != nil { + apiErrorDefined(ctx, container_service.ErrManifestInvalid.WithMessage(err.Error())) return } - maxSize := maxManifestSize + 1 + maxSize := container_service.MaxManifestSize + 1 buf, err := packages_module.CreateHashedBufferFromReaderWithSize(&io.LimitedReader{R: ctx.Req.Body, N: int64(maxSize)}, maxSize) if err != nil { apiError(ctx, http.StatusInternalServerError, err) @@ -561,18 +533,18 @@ func UploadManifest(ctx *context.Context) { } defer buf.Close() - if buf.Size() > maxManifestSize { - apiErrorDefined(ctx, errManifestInvalid.WithMessage("Manifest exceeds maximum size").WithStatusCode(http.StatusRequestEntityTooLarge)) + if buf.Size() > container_service.MaxManifestSize { + apiErrorDefined(ctx, container_service.ErrManifestInvalid.WithMessage("Manifest exceeds maximum size").WithStatusCode(http.StatusRequestEntityTooLarge)) return } - digest, err := processManifest(ctx, mci, buf) + digest, err := container_service.ProcessManifest(ctx, *mci, buf) if err != nil { - var namedError *namedError + var namedError *container_service.NamedError if errors.As(err, &namedError) { apiErrorDefined(ctx, namedError) } else if errors.Is(err, container_model.ErrContainerBlobNotExist) { - apiErrorDefined(ctx, errBlobUnknown) + apiErrorDefined(ctx, container_service.ErrBlobUnknown) } else { switch err { case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize: @@ -585,47 +557,18 @@ func UploadManifest(ctx *context.Context) { } setResponseHeaders(ctx.Resp, &containerHeaders{ - Location: fmt.Sprintf("/v2/%s/%s/manifests/%s", ctx.Package.Owner.LowerName, mci.Image, reference), + Location: fmt.Sprintf("/v2/%s/%s/manifests/%s", ctx.Package.Owner.LowerName, mci.Image, mci.Reference), ContentDigest: digest, Status: http.StatusCreated, }) } -func getBlobSearchOptionsFromContext(ctx *context.Context) (*container_model.BlobSearchOptions, error) { - reference := ctx.Params("reference") - - opts := &container_model.BlobSearchOptions{ - OwnerID: ctx.Package.Owner.ID, - Image: ctx.Params("image"), - IsManifest: true, - } - - if digest.Digest(reference).Validate() == nil { - opts.Digest = reference - } else if referencePattern.MatchString(reference) { - opts.Tag = reference - } else { - return nil, container_model.ErrContainerBlobNotExist - } - - return opts, nil -} - -func getManifestFromContext(ctx *context.Context) (*packages_model.PackageFileDescriptor, error) { - opts, err := getBlobSearchOptionsFromContext(ctx) - if err != nil { - return nil, err - } - - return workaroundGetContainerBlob(ctx, opts) -} - // https://github.com/opencontainers/distribution-spec/blob/main/spec.md#checking-if-content-exists-in-the-registry func HeadManifest(ctx *context.Context) { - manifest, err := getManifestFromContext(ctx) + manifest, err := container_service.GetLocalManifest(ctx, ctx.Package.Owner.ID, ctx.Params("image"), ctx.Params("reference")) if err != nil { if err == container_model.ErrContainerBlobNotExist { - apiErrorDefined(ctx, errManifestUnknown) + apiErrorDefined(ctx, container_service.ErrManifestUnknown) } else { apiError(ctx, http.StatusInternalServerError, err) } @@ -642,10 +585,10 @@ func HeadManifest(ctx *context.Context) { // https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pulling-manifests func GetManifest(ctx *context.Context) { - manifest, err := getManifestFromContext(ctx) + manifest, err := container_service.GetLocalManifest(ctx, ctx.Package.Owner.ID, ctx.Params("image"), ctx.Params("reference")) if err != nil { if err == container_model.ErrContainerBlobNotExist { - apiErrorDefined(ctx, errManifestUnknown) + apiErrorDefined(ctx, container_service.ErrManifestUnknown) } else { apiError(ctx, http.StatusInternalServerError, err) } @@ -658,9 +601,13 @@ func GetManifest(ctx *context.Context) { // https://github.com/opencontainers/distribution-spec/blob/main/spec.md#deleting-tags // https://github.com/opencontainers/distribution-spec/blob/main/spec.md#deleting-manifests func DeleteManifest(ctx *context.Context) { - opts, err := getBlobSearchOptionsFromContext(ctx) + opts, err := container_service.GetManifestSearchOptions( + ctx.Package.Owner.ID, + ctx.Params("image"), + ctx.Params("reference"), + ) if err != nil { - apiErrorDefined(ctx, errManifestUnknown) + apiErrorDefined(ctx, container_service.ErrManifestUnknown) return } @@ -671,7 +618,7 @@ func DeleteManifest(ctx *context.Context) { } if len(pvs) == 0 { - apiErrorDefined(ctx, errManifestUnknown) + apiErrorDefined(ctx, container_service.ErrManifestUnknown) return } @@ -722,7 +669,7 @@ func GetTagList(ctx *context.Context) { if _, err := packages_model.GetPackageByName(ctx, ctx.Package.Owner.ID, packages_model.TypeContainer, image); err != nil { if errors.Is(err, packages_model.ErrPackageNotExist) { - apiErrorDefined(ctx, errNameUnknown) + apiErrorDefined(ctx, container_service.ErrNameUnknown) } else { apiError(ctx, http.StatusInternalServerError, err) } @@ -735,49 +682,24 @@ func GetTagList(ctx *context.Context) { } last := ctx.FormTrim("last") - tags, err := container_model.GetImageTags(ctx, ctx.Package.Owner.ID, image, n, last) - if err != nil { + tagList, vals, err := container_service.GetLocalTagList(ctx, + ctx.Package.Owner.LowerName, + image, + last, + n, + ctx.Package.Owner.ID) + + if errors.Is(err, packages_model.ErrPackageNotExist) { + apiErrorDefined(ctx, container_service.ErrNameUnknown) + return + } else if err != nil { apiError(ctx, http.StatusInternalServerError, err) return } - type TagList struct { - Name string `json:"name"` - Tags []string `json:"tags"` + if len(tagList.Tags) > 0 { + ctx.Resp.Header().Set("Link", fmt.Sprintf(`; rel="next"`, ctx.Package.Owner.LowerName, image, vals.Encode())) } - if len(tags) > 0 { - v := url.Values{} - if n > 0 { - v.Add("n", strconv.Itoa(n)) - } - v.Add("last", tags[len(tags)-1]) - - ctx.Resp.Header().Set("Link", fmt.Sprintf(`; rel="next"`, ctx.Package.Owner.LowerName, image, v.Encode())) - } - - jsonResponse(ctx, http.StatusOK, TagList{ - Name: strings.ToLower(ctx.Package.Owner.LowerName + "/" + image), - Tags: tags, - }) -} - -// FIXME: Workaround to be removed in v1.20 -// https://github.com/go-gitea/gitea/issues/19586 -func workaroundGetContainerBlob(ctx *context.Context, opts *container_model.BlobSearchOptions) (*packages_model.PackageFileDescriptor, error) { - blob, err := container_model.GetContainerBlob(ctx, opts) - if err != nil { - return nil, err - } - - err = packages_module.NewContentStore().Has(packages_module.BlobHash256Key(blob.Blob.HashSHA256)) - if err != nil { - if errors.Is(err, util.ErrNotExist) || errors.Is(err, os.ErrNotExist) { - log.Debug("Package registry inconsistent: blob %s does not exist on file system", blob.Blob.HashSHA256) - return nil, container_model.ErrContainerBlobNotExist - } - return nil, err - } - - return blob, nil + jsonResponse(ctx, http.StatusOK, tagList) } diff --git a/routers/api/packages/container/blob.go b/services/packages/container/blob.go similarity index 76% rename from routers/api/packages/container/blob.go rename to services/packages/container/blob.go index 29de375842..d577edc877 100644 --- a/routers/api/packages/container/blob.go +++ b/services/packages/container/blob.go @@ -20,20 +20,47 @@ import ( container_module "forgejo.org/modules/packages/container" "forgejo.org/modules/util" packages_service "forgejo.org/services/packages" + + oci_digest "github.com/opencontainers/go-digest" ) var uploadVersionMutex sync.Mutex -// saveAsPackageBlob creates a package blob from an upload +// GetLocalBlob finds a local blob if it exists, returns ErrContainerBlobNotExist otherwise +func GetLocalBlob(ctx context.Context, ownerID int64, dig, imageName string) (*packages_model.PackageFileDescriptor, error) { + if oci_digest.Digest(dig).Validate() != nil { + return nil, container_model.ErrContainerBlobNotExist + } + + opts := &container_model.BlobSearchOptions{ + OwnerID: ownerID, + Image: imageName, + Digest: dig, + } + + // Get blob or err + log.Debug("Trying to find blob %s locally", dig) + blobDescriptor, err := WorkaroundGetContainerBlob(ctx, opts) + if err != nil { + if errors.Is(err, container_model.ErrContainerBlobNotExist) { + return nil, err + } + return nil, fmt.Errorf("could not get container blob: %s", err.Error()) + } + + return blobDescriptor, nil +} + +// SaveAsPackageBlob creates a package blob from an upload // The uploaded blob gets stored in a special upload version to link them to the package/image -func saveAsPackageBlob(ctx context.Context, hsr packages_module.HashedSizeReader, pci *packages_service.PackageCreationInfo) (*packages_model.PackageBlob, error) { //nolint:unparam +func SaveAsPackageBlob(ctx context.Context, hsr packages_module.HashedSizeReader, pci *packages_service.PackageCreationInfo) (*packages_model.PackageBlob, error) { pb := packages_service.NewPackageBlob(hsr) exists := false contentStore := packages_module.NewContentStore() - uploadVersion, err := getOrCreateUploadVersion(ctx, &pci.PackageInfo) + uploadVersion, err := GetOrCreateUploadVersion(ctx, &pci.PackageInfo) if err != nil { return nil, err } @@ -64,7 +91,7 @@ func saveAsPackageBlob(ctx context.Context, hsr packages_module.HashedSizeReader } } - return createFileForBlob(ctx, uploadVersion, pb) + return CreateFileForBlob(ctx, uploadVersion, pb) }) if err != nil { if !exists { @@ -78,19 +105,19 @@ func saveAsPackageBlob(ctx context.Context, hsr packages_module.HashedSizeReader return pb, nil } -// mountBlob mounts the specific blob to a different package -func mountBlob(ctx context.Context, pi *packages_service.PackageInfo, pb *packages_model.PackageBlob) error { - uploadVersion, err := getOrCreateUploadVersion(ctx, pi) +// MountBlob mounts the specific blob to a different package +func MountBlob(ctx context.Context, pi *packages_service.PackageInfo, pb *packages_model.PackageBlob) error { + uploadVersion, err := GetOrCreateUploadVersion(ctx, pi) if err != nil { return err } return db.WithTx(ctx, func(ctx context.Context) error { - return createFileForBlob(ctx, uploadVersion, pb) + return CreateFileForBlob(ctx, uploadVersion, pb) }) } -func getOrCreateUploadVersion(ctx context.Context, pi *packages_service.PackageInfo) (*packages_model.PackageVersion, error) { +func GetOrCreateUploadVersion(ctx context.Context, pi *packages_service.PackageInfo) (*packages_model.PackageVersion, error) { var uploadVersion *packages_model.PackageVersion // FIXME: Replace usage of mutex with database transaction @@ -150,7 +177,7 @@ func getOrCreateUploadVersion(ctx context.Context, pi *packages_service.PackageI return uploadVersion, err } -func createFileForBlob(ctx context.Context, pv *packages_model.PackageVersion, pb *packages_model.PackageBlob) error { +func CreateFileForBlob(ctx context.Context, pv *packages_model.PackageVersion, pb *packages_model.PackageBlob) error { filename := strings.ToLower(fmt.Sprintf("sha256_%s", pb.HashSHA256)) pf := &packages_model.PackageFile{ @@ -169,7 +196,7 @@ func createFileForBlob(ctx context.Context, pv *packages_model.PackageVersion, p return err } - if _, err := packages_model.InsertProperty(ctx, packages_model.PropertyTypeFile, pf.ID, container_module.PropertyDigest, digestFromPackageBlob(pb)); err != nil { + if _, err := packages_model.InsertProperty(ctx, packages_model.PropertyTypeFile, pf.ID, container_module.PropertyDigest, DigestFromPackageBlob(pb)); err != nil { log.Error("Error setting package file property: %v", err) return err } @@ -177,7 +204,7 @@ func createFileForBlob(ctx context.Context, pv *packages_model.PackageVersion, p return nil } -func deleteBlob(ctx context.Context, ownerID int64, image, digest string) error { +func DeleteBlob(ctx context.Context, ownerID int64, image, digest string) error { return db.WithTx(ctx, func(ctx context.Context) error { pfds, err := container_model.GetContainerBlobs(ctx, &container_model.BlobSearchOptions{ OwnerID: ownerID, @@ -197,11 +224,11 @@ func deleteBlob(ctx context.Context, ownerID int64, image, digest string) error }) } -func digestFromHashSummer(h packages_module.HashSummer) string { +func DigestFromHashSummer(h packages_module.HashSummer) string { _, _, hashSHA256, _, _ := h.Sums() return "sha256:" + hex.EncodeToString(hashSHA256) } -func digestFromPackageBlob(pb *packages_model.PackageBlob) string { +func DigestFromPackageBlob(pb *packages_model.PackageBlob) string { return "sha256:" + pb.HashSHA256 } diff --git a/services/packages/container/container.go b/services/packages/container/container.go new file mode 100644 index 0000000000..909a667081 --- /dev/null +++ b/services/packages/container/container.go @@ -0,0 +1,36 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package container + +import ( + "context" + "errors" + "os" + + packages_model "forgejo.org/models/packages" + container_model "forgejo.org/models/packages/container" + "forgejo.org/modules/log" + packages_module "forgejo.org/modules/packages" + "forgejo.org/modules/util" +) + +// FIXME: Workaround to be removed in v1.20 +// https://github.com/go-gitea/gitea/issues/19586 +func WorkaroundGetContainerBlob(ctx context.Context, opts *container_model.BlobSearchOptions) (*packages_model.PackageFileDescriptor, error) { + blob, err := container_model.GetContainerBlob(ctx, opts) + if err != nil { + return nil, err + } + + err = packages_module.NewContentStore().Has(packages_module.BlobHash256Key(blob.Blob.HashSHA256)) + if err != nil { + if errors.Is(err, util.ErrNotExist) || errors.Is(err, os.ErrNotExist) { + log.Debug("Package registry inconsistent: blob %s does not exist on file system", blob.Blob.HashSHA256) + return nil, container_model.ErrContainerBlobNotExist + } + return nil, err + } + + return blob, nil +} diff --git a/routers/api/packages/container/errors.go b/services/packages/container/errors.go similarity index 50% rename from routers/api/packages/container/errors.go rename to services/packages/container/errors.go index 1a9b0f32d2..64dc48f850 100644 --- a/routers/api/packages/container/errors.go +++ b/services/packages/container/errors.go @@ -9,33 +9,33 @@ import ( // https://github.com/opencontainers/distribution-spec/blob/main/spec.md#error-codes var ( - errBlobUnknown = &namedError{Code: "BLOB_UNKNOWN", StatusCode: http.StatusNotFound} - errBlobUploadInvalid = &namedError{Code: "BLOB_UPLOAD_INVALID", StatusCode: http.StatusBadRequest} - errBlobUploadUnknown = &namedError{Code: "BLOB_UPLOAD_UNKNOWN", StatusCode: http.StatusNotFound} - errDigestInvalid = &namedError{Code: "DIGEST_INVALID", StatusCode: http.StatusBadRequest} - errManifestBlobUnknown = &namedError{Code: "MANIFEST_BLOB_UNKNOWN", StatusCode: http.StatusNotFound} - errManifestInvalid = &namedError{Code: "MANIFEST_INVALID", StatusCode: http.StatusBadRequest} - errManifestUnknown = &namedError{Code: "MANIFEST_UNKNOWN", StatusCode: http.StatusNotFound} - errNameInvalid = &namedError{Code: "NAME_INVALID", StatusCode: http.StatusBadRequest} - errNameUnknown = &namedError{Code: "NAME_UNKNOWN", StatusCode: http.StatusNotFound} - errSizeInvalid = &namedError{Code: "SIZE_INVALID", StatusCode: http.StatusBadRequest} - errUnauthorized = &namedError{Code: "UNAUTHORIZED", StatusCode: http.StatusUnauthorized} - errUnsupported = &namedError{Code: "UNSUPPORTED", StatusCode: http.StatusNotImplemented} + ErrBlobUnknown = &NamedError{Code: "BLOB_UNKNOWN", StatusCode: http.StatusNotFound} + ErrBlobUploadInvalid = &NamedError{Code: "BLOB_UPLOAD_INVALID", StatusCode: http.StatusBadRequest} + ErrBlobUploadUnknown = &NamedError{Code: "BLOB_UPLOAD_UNKNOWN", StatusCode: http.StatusNotFound} + ErrDigestInvalid = &NamedError{Code: "DIGEST_INVALID", StatusCode: http.StatusBadRequest} + ErrManifestBlobUnknown = &NamedError{Code: "MANIFEST_BLOB_UNKNOWN", StatusCode: http.StatusNotFound} + ErrManifestInvalid = &NamedError{Code: "MANIFEST_INVALID", StatusCode: http.StatusBadRequest} + ErrManifestUnknown = &NamedError{Code: "MANIFEST_UNKNOWN", StatusCode: http.StatusNotFound} + ErrNameInvalid = &NamedError{Code: "NAME_INVALID", StatusCode: http.StatusBadRequest} + ErrNameUnknown = &NamedError{Code: "NAME_UNKNOWN", StatusCode: http.StatusNotFound} + ErrSizeInvalid = &NamedError{Code: "SIZE_INVALID", StatusCode: http.StatusBadRequest} + ErrUnauthorized = &NamedError{Code: "UNAUTHORIZED", StatusCode: http.StatusUnauthorized} + ErrUnsupported = &NamedError{Code: "UNSUPPORTED", StatusCode: http.StatusNotImplemented} ) -type namedError struct { +type NamedError struct { Code string StatusCode int Message string } -func (e *namedError) Error() string { +func (e *NamedError) Error() string { return e.Message } // WithMessage creates a new instance of the error with a different message -func (e *namedError) WithMessage(message string) *namedError { - return &namedError{ +func (e *NamedError) WithMessage(message string) *NamedError { + return &NamedError{ Code: e.Code, StatusCode: e.StatusCode, Message: message, @@ -43,8 +43,8 @@ func (e *namedError) WithMessage(message string) *namedError { } // WithStatusCode creates a new instance of the error with a different status code -func (e *namedError) WithStatusCode(statusCode int) *namedError { - return &namedError{ +func (e *NamedError) WithStatusCode(statusCode int) *NamedError { + return &NamedError{ Code: e.Code, StatusCode: statusCode, Message: e.Message, diff --git a/services/packages/container/main_test.go b/services/packages/container/main_test.go new file mode 100644 index 0000000000..6ea1106fc9 --- /dev/null +++ b/services/packages/container/main_test.go @@ -0,0 +1,20 @@ +// Copyright 2026 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: GPL-3.0-or-later + +package container + +import ( + "testing" + + "forgejo.org/models/unittest" + + _ "forgejo.org/models" + _ "forgejo.org/models/actions" + _ "forgejo.org/models/activities" + _ "forgejo.org/models/forgefed" + _ "forgejo.org/models/packages" +) + +func TestMain(m *testing.M) { + unittest.MainTest(m) +} diff --git a/routers/api/packages/container/manifest.go b/services/packages/container/manifest.go similarity index 86% rename from routers/api/packages/container/manifest.go rename to services/packages/container/manifest.go index 545bfb9f15..22918706f2 100644 --- a/routers/api/packages/container/manifest.go +++ b/services/packages/container/manifest.go @@ -10,6 +10,7 @@ import ( "io" "net/url" "os" + "regexp" "strings" "forgejo.org/models/db" @@ -30,6 +31,67 @@ import ( oci "github.com/opencontainers/image-spec/specs-go/v1" ) +// maximum size of a container manifest +// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pushing-manifests +const MaxManifestSize = 10 * 1024 * 1024 + +var ( + ReferencePattern = regexp.MustCompile(`\A[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}\z`) + ErrTagInvalid = util.NewInvalidArgumentErrorf("Tag is invalid") +) + +// ManifestCreationInfo describes a manifest to create +type ManifestCreationInfo struct { + MediaType string + Owner *user_model.User + Creator *user_model.User + Image string + Reference string + IsTagged bool + Properties map[string]string +} + +func GetLocalManifest(ctx context.Context, ownerID int64, imageName, reference string) (*packages_model.PackageFileDescriptor, error) { + opts, err := GetManifestSearchOptions( + ownerID, + imageName, + reference, + ) + if err != nil { + return nil, err + } + // Get blob or err + log.Debug("Trying to find manifest with %s locally", reference) + pdf, err := WorkaroundGetContainerBlob(ctx, opts) + if err != nil { + if errors.Is(err, container_model.ErrContainerBlobNotExist) { + return nil, err + } + return nil, fmt.Errorf("could not get container blob: %s", err.Error()) + } + + return pdf, nil +} + +func NewManifestCreationInfo(owner, creator *user_model.User, mediaType, image, reference string) (*ManifestCreationInfo, error) { + isTagged := digest.Digest(reference).Validate() != nil + + mci := &ManifestCreationInfo{ + MediaType: mediaType, + Owner: owner, + Creator: creator, + Image: image, + Reference: reference, + IsTagged: isTagged, + } + + if mci.IsTagged && !ReferencePattern.MatchString(reference) { + return &ManifestCreationInfo{}, ErrTagInvalid + } + + return mci, nil +} + func isValidMediaType(mt string) bool { return strings.HasPrefix(mt, "application/vnd.docker.") || strings.HasPrefix(mt, "application/vnd.oci.") } @@ -42,25 +104,32 @@ func isImageIndexMediaType(mt string) bool { return strings.EqualFold(mt, oci.MediaTypeImageIndex) || strings.EqualFold(mt, "application/vnd.docker.distribution.manifest.list.v2+json") } -// manifestCreationInfo describes a manifest to create -type manifestCreationInfo struct { - MediaType string - Owner *user_model.User - Creator *user_model.User - Image string - Reference string - IsTagged bool - Properties map[string]string +func GetManifestSearchOptions(ownerID int64, image, reference string) (*container_model.BlobSearchOptions, error) { + opts := &container_model.BlobSearchOptions{ + OwnerID: ownerID, + Image: image, + IsManifest: true, + } + + if digest.Digest(reference).Validate() == nil { + opts.Digest = reference + } else if ReferencePattern.MatchString(reference) { + opts.Tag = reference + } else { + return nil, container_model.ErrContainerBlobNotExist + } + + return opts, nil } -func processManifest(ctx context.Context, mci *manifestCreationInfo, buf *packages_module.HashedBuffer) (string, error) { +func ProcessManifest(ctx context.Context, mci ManifestCreationInfo, buf *packages_module.HashedBuffer) (string, error) { var index oci.Index if err := json.NewDecoder(buf).Decode(&index); err != nil { return "", err } if index.SchemaVersion != 2 { - return "", errUnsupported.WithMessage("Schema version is not supported") + return "", ErrUnsupported.WithMessage("Schema version is not supported") } if _, err := buf.Seek(0, io.SeekStart); err != nil { @@ -70,7 +139,7 @@ func processManifest(ctx context.Context, mci *manifestCreationInfo, buf *packag if !isValidMediaType(mci.MediaType) { mci.MediaType = index.MediaType if !isValidMediaType(mci.MediaType) { - return "", errManifestInvalid.WithMessage("MediaType not recognized") + return "", ErrManifestInvalid.WithMessage("MediaType not recognized") } } @@ -79,10 +148,10 @@ func processManifest(ctx context.Context, mci *manifestCreationInfo, buf *packag } else if isImageIndexMediaType(mci.MediaType) { return processImageManifestIndex(ctx, mci, buf) } - return "", errManifestInvalid + return "", ErrManifestInvalid } -func processImageManifest(ctx context.Context, mci *manifestCreationInfo, buf *packages_module.HashedBuffer) (string, error) { +func processImageManifest(ctx context.Context, mci ManifestCreationInfo, buf *packages_module.HashedBuffer) (string, error) { manifestDigest := "" err := func() error { @@ -120,6 +189,7 @@ func processImageManifest(ctx context.Context, mci *manifestCreationInfo, buf *p if err != nil { return err } + metadata.Annotations = manifest.Annotations blobReferences := make([]*blobReference, 0, 1+len(manifest.Layers)) @@ -200,7 +270,7 @@ func processImageManifest(ctx context.Context, mci *manifestCreationInfo, buf *p return manifestDigest, nil } -func processImageManifestIndex(ctx context.Context, mci *manifestCreationInfo, buf *packages_module.HashedBuffer) (string, error) { +func processImageManifestIndex(ctx context.Context, mci ManifestCreationInfo, buf *packages_module.HashedBuffer) (string, error) { manifestDigest := "" err := func() error { @@ -226,7 +296,7 @@ func processImageManifestIndex(ctx context.Context, mci *manifestCreationInfo, b for _, manifest := range index.Manifests { if !isImageManifestMediaType(manifest.MediaType) { - return errManifestInvalid + return ErrManifestInvalid } platform := container_module.DefaultPlatform @@ -245,7 +315,7 @@ func processImageManifestIndex(ctx context.Context, mci *manifestCreationInfo, b }) if err != nil { if err == container_model.ErrContainerBlobNotExist { - return errManifestBlobUnknown + return ErrManifestBlobUnknown } return err } @@ -315,7 +385,7 @@ func notifyPackageCreate(ctx context.Context, doer *user_model.User, pv *package return nil } -func createPackageAndVersion(ctx context.Context, mci *manifestCreationInfo, metadata *container_module.Metadata) (*packages_model.PackageVersion, error) { +func createPackageAndVersion(ctx context.Context, mci ManifestCreationInfo, metadata *container_module.Metadata) (*packages_model.PackageVersion, error) { created := true p := &packages_model.Package{ OwnerID: mci.Owner.ID, @@ -429,7 +499,7 @@ type blobReference struct { func createFileFromBlobReference(ctx context.Context, pv, uploadVersion *packages_model.PackageVersion, ref *blobReference) error { if ref.File.Blob.Size != ref.ExpectedSize { - return errSizeInvalid + return ErrSizeInvalid } if ref.Name == "" { @@ -474,7 +544,7 @@ func createFileFromBlobReference(ctx context.Context, pv, uploadVersion *package return nil } -func createManifestBlob(ctx context.Context, mci *manifestCreationInfo, pv *packages_model.PackageVersion, buf *packages_module.HashedBuffer) (*packages_model.PackageBlob, bool, string, error) { +func createManifestBlob(ctx context.Context, mci ManifestCreationInfo, pv *packages_model.PackageVersion, buf *packages_module.HashedBuffer) (*packages_model.PackageBlob, bool, string, error) { pb, exists, err := packages_model.GetOrInsertBlob(ctx, packages_service.NewPackageBlob(buf)) if err != nil { log.Error("Error inserting package blob: %v", err) @@ -497,7 +567,7 @@ func createManifestBlob(ctx context.Context, mci *manifestCreationInfo, pv *pack } } - manifestDigest := digestFromHashSummer(buf) + manifestDigest := DigestFromHashSummer(buf) err = createFileFromBlobReference(ctx, pv, nil, &blobReference{ Digest: digest.Digest(manifestDigest), MediaType: mci.MediaType, diff --git a/services/packages/container/manifest_test.go b/services/packages/container/manifest_test.go new file mode 100644 index 0000000000..d2a65cf01a --- /dev/null +++ b/services/packages/container/manifest_test.go @@ -0,0 +1,95 @@ +// Copyright 2026 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: GPL-3.0-or-later + +package container + +import ( + "encoding/base64" + "io" + "strings" + "testing" + + "forgejo.org/models/unittest" + user_model "forgejo.org/models/user" + packages_module "forgejo.org/modules/packages" + packages_service "forgejo.org/services/packages" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_SaveAndGetManifestAndBlob(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + manifestMediaType := "application/vnd.docker.distribution.manifest.v2+json" + image := "test" + tag := "latest" + + mci, err := NewManifestCreationInfo(user2, user2, manifestMediaType, image, tag) + require.NoError(t, err) + + blobContent, _ := base64.StdEncoding.DecodeString(`H4sIAAAJbogA/2IYBaNgFIxYAAgAAP//Lq+17wAEAAA=`) + + blobReader := &io.LimitedReader{R: strings.NewReader(string(blobContent)), N: int64(MaxManifestSize + 1)} + blobBuf, err := packages_module.CreateHashedBufferFromReaderWithSize(blobReader, MaxManifestSize+1) + blobDigest := DigestFromHashSummer(blobBuf) + require.NoError(t, err) + defer blobBuf.Close() + + blobPCI := &packages_service.PackageCreationInfo{ + PackageInfo: packages_service.PackageInfo{ + Owner: user2, + Name: image, + }, + Creator: user2, + } + + _, err = SaveAsPackageBlob(t.Context(), blobBuf, blobPCI) + require.NoError(t, err) + + configDigest := "sha256:4607e093bec406eaadb6f3a340f63400c9d3a7038680744c406903766b938f0d" + configContent := `{"architecture":"amd64","config":{"Env":["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],"Cmd":["/true"],"ArgsEscaped":true,"Image":"sha256:9bd8b88dc68b80cffe126cc820e4b52c6e558eb3b37680bfee8e5f3ed7b8c257"},"container":"b89fe92a887d55c0961f02bdfbfd8ac3ddf66167db374770d2d9e9fab3311510","container_config":{"Hostname":"b89fe92a887d","Env":["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],"Cmd":["/bin/sh","-c","#(nop) ","CMD [\"/true\"]"],"ArgsEscaped":true,"Image":"sha256:9bd8b88dc68b80cffe126cc820e4b52c6e558eb3b37680bfee8e5f3ed7b8c257"},"created":"2022-01-01T00:00:00.000000000Z","docker_version":"20.10.12","history":[{"created":"2022-01-01T00:00:00.000000000Z","created_by":"/bin/sh -c #(nop) COPY file:0e7589b0c800daaf6fa460d2677101e4676dd9491980210cb345480e513f3602 in /true "},{"created":"2022-01-01T00:00:00.000000001Z","created_by":"/bin/sh -c #(nop) CMD [\"/true\"]","empty_layer":true}],"os":"linux","rootfs":{"type":"layers","diff_ids":["sha256:0ff3b91bdf21ecdf2f2f3d4372c2098a14dbe06cd678e8f0a85fd4902d00e2e2"]}}` + + cfgReader := &io.LimitedReader{R: strings.NewReader(configContent), N: int64(MaxManifestSize + 1)} + cfgBuf, err := packages_module.CreateHashedBufferFromReaderWithSize(cfgReader, MaxManifestSize+1) + require.NoError(t, err) + defer cfgBuf.Close() + + confPci := &packages_service.PackageCreationInfo{ + PackageInfo: packages_service.PackageInfo{ + Owner: user2, + Name: image, + Version: configDigest, + }, + Creator: user2, + } + + _, err = SaveAsPackageBlob(t.Context(), cfgBuf, confPci) + require.NoError(t, err) + + manifestDigest := "sha256:4f10484d1c1bb13e3956b4de1cd42db8e0f14a75be1617b60f2de3cd59c803c6" + manifestContent := `{"schemaVersion":2,"mediaType":"application/vnd.docker.distribution.manifest.v2+json","config":{"mediaType":"application/vnd.docker.container.image.v1+json","digest":"sha256:4607e093bec406eaadb6f3a340f63400c9d3a7038680744c406903766b938f0d","size":1069},"layers":[{"mediaType":"application/vnd.docker.image.rootfs.diff.tar.gzip","digest":"sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4","size":32}]}` + + reader := &io.LimitedReader{R: strings.NewReader(manifestContent), N: int64(MaxManifestSize + 1)} + buf, err := packages_module.CreateHashedBufferFromReaderWithSize(reader, MaxManifestSize+1) + require.NoError(t, err) + defer buf.Close() + + digest, err := ProcessManifest(t.Context(), *mci, buf) + require.NoError(t, err) + assert.Equal(t, digest, manifestDigest) + + pdf, err := GetLocalManifest(t.Context(), user2.ID, image, tag) + require.NoError(t, err) + assert.Equal(t, "sha256:"+pdf.Blob.HashSHA256, digest) + + pdf, err = GetLocalBlob(t.Context(), user2.ID, blobDigest, image) + require.NoError(t, err) + assert.Equal(t, "sha256:"+pdf.Blob.HashSHA256, blobDigest) + + tl, v, err := GetLocalTagList(t.Context(), user2.LowerName, image, "", 1, user2.ID) + require.NoError(t, err) + assert.Len(t, tl.Tags, 1) + assert.Equal(t, "latest", v.Get("last")) +} diff --git a/services/packages/container/tags.go b/services/packages/container/tags.go new file mode 100644 index 0000000000..d12324063a --- /dev/null +++ b/services/packages/container/tags.go @@ -0,0 +1,47 @@ +// Copyright 2026 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: GPL-3.0-or-later + +package container + +import ( + "context" + "net/url" + "strconv" + "strings" + + packages_model "forgejo.org/models/packages" + container_model "forgejo.org/models/packages/container" +) + +type TagList struct { + Name string `json:"name"` + Tags []string `json:"tags"` +} + +func GetLocalTagList(ctx context.Context, ownerLower, image, last string, n int, ownerID int64) (*TagList, *url.Values, error) { + _, err := packages_model.GetPackageByName(ctx, ownerID, packages_model.TypeContainer, image) + if err != nil { + return nil, nil, err + } + tags, err := container_model.GetImageTags(ctx, ownerID, image, n, last) + if err != nil { + return nil, nil, err + } + tagList := &TagList{ + Name: strings.ToLower(ownerLower + "/" + image), + Tags: tags, + } + v := setLinkHeaderValues(tagList, n) + return tagList, v, nil +} + +func setLinkHeaderValues(tagList *TagList, n int) *url.Values { + v := &url.Values{} + if len(tagList.Tags) > 0 { + if n > 0 { + v.Add("n", strconv.Itoa(n)) + } + v.Add("last", tagList.Tags[len(tagList.Tags)-1]) + } + return v +}