summaryrefslogtreecommitdiffstats
path: root/routers/api/packages
diff options
context:
space:
mode:
authorDaniel Baumann <daniel@debian.org>2024-10-18 20:33:49 +0200
committerDaniel Baumann <daniel@debian.org>2024-10-18 20:33:49 +0200
commitdd136858f1ea40ad3c94191d647487fa4f31926c (patch)
tree58fec94a7b2a12510c9664b21793f1ed560c6518 /routers/api/packages
parentInitial commit. (diff)
downloadforgejo-dd136858f1ea40ad3c94191d647487fa4f31926c.tar.xz
forgejo-dd136858f1ea40ad3c94191d647487fa4f31926c.zip
Adding upstream version 9.0.0.
Signed-off-by: Daniel Baumann <daniel@debian.org>
Diffstat (limited to '')
-rw-r--r--routers/api/packages/README.md50
-rw-r--r--routers/api/packages/alpine/alpine.go287
-rw-r--r--routers/api/packages/api.go916
-rw-r--r--routers/api/packages/arch/arch.go269
-rw-r--r--routers/api/packages/cargo/cargo.go311
-rw-r--r--routers/api/packages/chef/auth.go274
-rw-r--r--routers/api/packages/chef/chef.go403
-rw-r--r--routers/api/packages/composer/api.go117
-rw-r--r--routers/api/packages/composer/composer.go261
-rw-r--r--routers/api/packages/conan/auth.go48
-rw-r--r--routers/api/packages/conan/conan.go807
-rw-r--r--routers/api/packages/conan/search.go163
-rw-r--r--routers/api/packages/conda/conda.go303
-rw-r--r--routers/api/packages/container/auth.go49
-rw-r--r--routers/api/packages/container/blob.go202
-rw-r--r--routers/api/packages/container/container.go785
-rw-r--r--routers/api/packages/container/errors.go52
-rw-r--r--routers/api/packages/container/manifest.go483
-rw-r--r--routers/api/packages/cran/cran.go264
-rw-r--r--routers/api/packages/debian/debian.go309
-rw-r--r--routers/api/packages/generic/generic.go212
-rw-r--r--routers/api/packages/generic/generic_test.go65
-rw-r--r--routers/api/packages/goproxy/goproxy.go224
-rw-r--r--routers/api/packages/helm/helm.go217
-rw-r--r--routers/api/packages/helper/helper.go63
-rw-r--r--routers/api/packages/maven/api.go50
-rw-r--r--routers/api/packages/maven/maven.go433
-rw-r--r--routers/api/packages/npm/api.go114
-rw-r--r--routers/api/packages/npm/npm.go462
-rw-r--r--routers/api/packages/nuget/api_v2.go402
-rw-r--r--routers/api/packages/nuget/api_v3.go255
-rw-r--r--routers/api/packages/nuget/auth.go47
-rw-r--r--routers/api/packages/nuget/links.go52
-rw-r--r--routers/api/packages/nuget/nuget.go710
-rw-r--r--routers/api/packages/pub/pub.go284
-rw-r--r--routers/api/packages/pypi/pypi.go194
-rw-r--r--routers/api/packages/pypi/pypi_test.go38
-rw-r--r--routers/api/packages/rpm/rpm.go318
-rw-r--r--routers/api/packages/rubygems/rubygems.go451
-rw-r--r--routers/api/packages/swift/swift.go465
-rw-r--r--routers/api/packages/vagrant/vagrant.go242
41 files changed, 11651 insertions, 0 deletions
diff --git a/routers/api/packages/README.md b/routers/api/packages/README.md
new file mode 100644
index 0000000..74d1492
--- /dev/null
+++ b/routers/api/packages/README.md
@@ -0,0 +1,50 @@
+# Gitea Package Registry
+
+This document gives a brief overview how the package registry is organized in code.
+
+## Structure
+
+The package registry code is divided into multiple modules to split the functionality and make code reuse possible.
+
+| Module | Description |
+| - | - |
+| `models/packages` | Common methods and models used by all registry types |
+| `models/packages/<type>` | Methods used by specific registry type. There should be no need to use type specific models. |
+| `modules/packages` | Common methods and types used by multiple registry types |
+| `modules/packages/<type>` | Registry type specific methods and types (e.g. metadata extraction of package files) |
+| `routers/api/packages` | Route definitions for all registry types |
+| `routers/api/packages/<type>` | Route implementation for a specific registry type |
+| `services/packages` | Helper methods used by registry types to handle common tasks like package creation and deletion in `routers` |
+| `services/packages/<type>` | Registry type specific methods used by `routers` and `services` |
+
+## Models
+
+Every package registry implementation uses the same underlying models:
+
+| Model | Description |
+| - | - |
+| `Package` | The root of a package providing values fixed for every version (e.g. the package name) |
+| `PackageVersion` | A version of a package containing metadata (e.g. the package description) |
+| `PackageFile` | A file of a package describing its content (e.g. file name) |
+| `PackageBlob` | The content of a file (may be shared by multiple files) |
+| `PackageProperty` | Additional properties attached to `Package`, `PackageVersion` or `PackageFile` (e.g. used if metadata is needed for routing) |
+
+The following diagram shows the relationship between the models:
+```
+Package <1---*> PackageVersion <1---*> PackageFile <*---1> PackageBlob
+```
+
+## Adding a new package registry type
+
+Before adding a new package registry type have a look at the existing implementation to get an impression of how it could work.
+Most registry types offer endpoints to retrieve the metadata, upload and download package files.
+The upload endpoint is often the heavy part because it must validate the uploaded blob, extract metadata and create the models.
+The methods to validate and extract the metadata should be added in the `modules/packages/<type>` package.
+If the upload is valid the methods in `services/packages` allow to store the upload and create the corresponding models.
+It depends if the registry type allows multiple files per package version which method should be called:
+- `CreatePackageAndAddFile`: error if package version already exists
+- `CreatePackageOrAddFileToExisting`: error if file already exists
+- `AddFileToExistingPackage`: error if package version does not exist or file already exists
+
+`services/packages` also contains helper methods to download a file or to remove a package version.
+There are no helper methods for metadata endpoints because they are very type specific.
diff --git a/routers/api/packages/alpine/alpine.go b/routers/api/packages/alpine/alpine.go
new file mode 100644
index 0000000..831a910
--- /dev/null
+++ b/routers/api/packages/alpine/alpine.go
@@ -0,0 +1,287 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package alpine
+
+import (
+ "crypto/x509"
+ "encoding/hex"
+ "encoding/pem"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+
+ packages_model "code.gitea.io/gitea/models/packages"
+ alpine_model "code.gitea.io/gitea/models/packages/alpine"
+ "code.gitea.io/gitea/modules/json"
+ packages_module "code.gitea.io/gitea/modules/packages"
+ alpine_module "code.gitea.io/gitea/modules/packages/alpine"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/routers/api/packages/helper"
+ "code.gitea.io/gitea/services/context"
+ packages_service "code.gitea.io/gitea/services/packages"
+ alpine_service "code.gitea.io/gitea/services/packages/alpine"
+)
+
+func apiError(ctx *context.Context, status int, obj any) {
+ helper.LogAndProcessError(ctx, status, obj, func(message string) {
+ ctx.PlainText(status, message)
+ })
+}
+
+func createOrAddToExisting(ctx *context.Context, pck *alpine_module.Package, branch, repository, architecture string, buf packages_module.HashedSizeReader, fileMetadataRaw []byte) {
+ _, _, err := packages_service.CreatePackageOrAddFileToExisting(
+ ctx,
+ &packages_service.PackageCreationInfo{
+ PackageInfo: packages_service.PackageInfo{
+ Owner: ctx.Package.Owner,
+ PackageType: packages_model.TypeAlpine,
+ Name: pck.Name,
+ Version: pck.Version,
+ },
+ Creator: ctx.Doer,
+ Metadata: pck.VersionMetadata,
+ },
+ &packages_service.PackageFileCreationInfo{
+ PackageFileInfo: packages_service.PackageFileInfo{
+ Filename: fmt.Sprintf("%s-%s.apk", pck.Name, pck.Version),
+ CompositeKey: fmt.Sprintf("%s|%s|%s", branch, repository, architecture),
+ },
+ Creator: ctx.Doer,
+ Data: buf,
+ IsLead: true,
+ Properties: map[string]string{
+ alpine_module.PropertyBranch: branch,
+ alpine_module.PropertyRepository: repository,
+ alpine_module.PropertyArchitecture: architecture,
+ alpine_module.PropertyMetadata: string(fileMetadataRaw),
+ },
+ },
+ )
+ if err != nil {
+ switch err {
+ case packages_model.ErrDuplicatePackageVersion, packages_model.ErrDuplicatePackageFile:
+ apiError(ctx, http.StatusConflict, err)
+ case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize:
+ apiError(ctx, http.StatusForbidden, err)
+ default:
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ if err := alpine_service.BuildSpecificRepositoryFiles(ctx, ctx.Package.Owner.ID, branch, repository, pck.FileMetadata.Architecture); err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+}
+
+func GetRepositoryKey(ctx *context.Context) {
+ _, pub, err := alpine_service.GetOrCreateKeyPair(ctx, ctx.Package.Owner.ID)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ pubPem, _ := pem.Decode([]byte(pub))
+ if pubPem == nil {
+ apiError(ctx, http.StatusInternalServerError, "failed to decode private key pem")
+ return
+ }
+
+ pubKey, err := x509.ParsePKIXPublicKey(pubPem.Bytes)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ fingerprint, err := util.CreatePublicKeyFingerprint(pubKey)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ ctx.ServeContent(strings.NewReader(pub), &context.ServeHeaderOptions{
+ ContentType: "application/x-pem-file",
+ Filename: fmt.Sprintf("%s@%s.rsa.pub", ctx.Package.Owner.LowerName, hex.EncodeToString(fingerprint)),
+ })
+}
+
+func GetRepositoryFile(ctx *context.Context) {
+ pv, err := alpine_service.GetOrCreateRepositoryVersion(ctx, ctx.Package.Owner.ID)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ s, u, pf, err := packages_service.GetFileStreamByPackageVersion(
+ ctx,
+ pv,
+ &packages_service.PackageFileInfo{
+ Filename: alpine_service.IndexArchiveFilename,
+ CompositeKey: fmt.Sprintf("%s|%s|%s", ctx.Params("branch"), ctx.Params("repository"), ctx.Params("architecture")),
+ },
+ )
+ if err != nil {
+ if errors.Is(err, util.ErrNotExist) {
+ apiError(ctx, http.StatusNotFound, err)
+ } else {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ helper.ServePackageFile(ctx, s, u, pf)
+}
+
+func UploadPackageFile(ctx *context.Context) {
+ branch := strings.TrimSpace(ctx.Params("branch"))
+ repository := strings.TrimSpace(ctx.Params("repository"))
+ if branch == "" || repository == "" {
+ apiError(ctx, http.StatusBadRequest, "invalid branch or repository")
+ return
+ }
+
+ upload, needToClose, err := ctx.UploadStream()
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ if needToClose {
+ defer upload.Close()
+ }
+
+ buf, err := packages_module.CreateHashedBufferFromReader(upload)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ defer buf.Close()
+
+ pck, err := alpine_module.ParsePackage(buf)
+ if err != nil {
+ if errors.Is(err, util.ErrInvalidArgument) || err == io.EOF {
+ apiError(ctx, http.StatusBadRequest, err)
+ } else {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ if _, err := buf.Seek(0, io.SeekStart); err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ fileMetadataRaw, err := json.Marshal(pck.FileMetadata)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ // Check whether the package being uploaded has no architecture defined.
+ // If true, loop through the available architectures in the repo and create
+ // the package file for the each architecture. If there are no architectures
+ // available on the repository, fallback to x86_64
+ if pck.FileMetadata.Architecture == "noarch" {
+ architectures, err := alpine_model.GetArchitectures(ctx, ctx.Package.Owner.ID, repository)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ if len(architectures) == 0 {
+ architectures = []string{
+ "x86_64",
+ }
+ }
+
+ for _, arch := range architectures {
+ pck.FileMetadata.Architecture = arch
+
+ fileMetadataRaw, err := json.Marshal(pck.FileMetadata)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ createOrAddToExisting(ctx, pck, branch, repository, pck.FileMetadata.Architecture, buf, fileMetadataRaw)
+ }
+ } else {
+ createOrAddToExisting(ctx, pck, branch, repository, pck.FileMetadata.Architecture, buf, fileMetadataRaw)
+ }
+
+ ctx.Status(http.StatusCreated)
+}
+
+func DownloadPackageFile(ctx *context.Context) {
+ branch := ctx.Params("branch")
+ repository := ctx.Params("repository")
+ architecture := ctx.Params("architecture")
+
+ opts := &packages_model.PackageFileSearchOptions{
+ OwnerID: ctx.Package.Owner.ID,
+ PackageType: packages_model.TypeAlpine,
+ Query: ctx.Params("filename"),
+ CompositeKey: fmt.Sprintf("%s|%s|%s", branch, repository, architecture),
+ }
+
+ pfs, _, err := packages_model.SearchFiles(ctx, opts)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ if len(pfs) == 0 {
+ apiError(ctx, http.StatusNotFound, nil)
+ return
+ }
+
+ s, u, pf, err := packages_service.GetPackageFileStream(ctx, pfs[0])
+ if err != nil {
+ if errors.Is(err, util.ErrNotExist) {
+ apiError(ctx, http.StatusNotFound, err)
+ } else {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ helper.ServePackageFile(ctx, s, u, pf)
+}
+
+func DeletePackageFile(ctx *context.Context) {
+ branch, repository, architecture := ctx.Params("branch"), ctx.Params("repository"), ctx.Params("architecture")
+
+ pfs, _, err := packages_model.SearchFiles(ctx, &packages_model.PackageFileSearchOptions{
+ OwnerID: ctx.Package.Owner.ID,
+ PackageType: packages_model.TypeAlpine,
+ Query: ctx.Params("filename"),
+ CompositeKey: fmt.Sprintf("%s|%s|%s", branch, repository, architecture),
+ })
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ if len(pfs) != 1 {
+ apiError(ctx, http.StatusNotFound, nil)
+ return
+ }
+
+ if err := packages_service.RemovePackageFileAndVersionIfUnreferenced(ctx, ctx.Doer, pfs[0]); err != nil {
+ if errors.Is(err, util.ErrNotExist) {
+ apiError(ctx, http.StatusNotFound, err)
+ } else {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ if err := alpine_service.BuildSpecificRepositoryFiles(ctx, ctx.Package.Owner.ID, branch, repository, architecture); err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ ctx.Status(http.StatusNoContent)
+}
diff --git a/routers/api/packages/api.go b/routers/api/packages/api.go
new file mode 100644
index 0000000..1337ce4
--- /dev/null
+++ b/routers/api/packages/api.go
@@ -0,0 +1,916 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package packages
+
+import (
+ "net/http"
+ "regexp"
+ "strings"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/perm"
+ quota_model "code.gitea.io/gitea/models/quota"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/routers/api/packages/alpine"
+ "code.gitea.io/gitea/routers/api/packages/arch"
+ "code.gitea.io/gitea/routers/api/packages/cargo"
+ "code.gitea.io/gitea/routers/api/packages/chef"
+ "code.gitea.io/gitea/routers/api/packages/composer"
+ "code.gitea.io/gitea/routers/api/packages/conan"
+ "code.gitea.io/gitea/routers/api/packages/conda"
+ "code.gitea.io/gitea/routers/api/packages/container"
+ "code.gitea.io/gitea/routers/api/packages/cran"
+ "code.gitea.io/gitea/routers/api/packages/debian"
+ "code.gitea.io/gitea/routers/api/packages/generic"
+ "code.gitea.io/gitea/routers/api/packages/goproxy"
+ "code.gitea.io/gitea/routers/api/packages/helm"
+ "code.gitea.io/gitea/routers/api/packages/maven"
+ "code.gitea.io/gitea/routers/api/packages/npm"
+ "code.gitea.io/gitea/routers/api/packages/nuget"
+ "code.gitea.io/gitea/routers/api/packages/pub"
+ "code.gitea.io/gitea/routers/api/packages/pypi"
+ "code.gitea.io/gitea/routers/api/packages/rpm"
+ "code.gitea.io/gitea/routers/api/packages/rubygems"
+ "code.gitea.io/gitea/routers/api/packages/swift"
+ "code.gitea.io/gitea/routers/api/packages/vagrant"
+ "code.gitea.io/gitea/services/auth"
+ "code.gitea.io/gitea/services/context"
+)
+
+func reqPackageAccess(accessMode perm.AccessMode) func(ctx *context.Context) {
+ return func(ctx *context.Context) {
+ if ctx.Data["IsApiToken"] == true {
+ scope, ok := ctx.Data["ApiTokenScope"].(auth_model.AccessTokenScope)
+ if ok { // it's a personal access token but not oauth2 token
+ scopeMatched := false
+ var err error
+ if accessMode == perm.AccessModeRead {
+ scopeMatched, err = scope.HasScope(auth_model.AccessTokenScopeReadPackage)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "HasScope", err.Error())
+ return
+ }
+ } else if accessMode == perm.AccessModeWrite {
+ scopeMatched, err = scope.HasScope(auth_model.AccessTokenScopeWritePackage)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "HasScope", err.Error())
+ return
+ }
+ }
+ if !scopeMatched {
+ ctx.Resp.Header().Set("WWW-Authenticate", `Basic realm="Gitea Package API"`)
+ ctx.Error(http.StatusUnauthorized, "reqPackageAccess", "user should have specific permission or be a site admin")
+ return
+ }
+
+ // check if scope only applies to public resources
+ publicOnly, err := scope.PublicOnly()
+ if err != nil {
+ ctx.Error(http.StatusForbidden, "tokenRequiresScope", "parsing public resource scope failed: "+err.Error())
+ return
+ }
+
+ if publicOnly {
+ if ctx.Package != nil && ctx.Package.Owner.Visibility.IsPrivate() {
+ ctx.Error(http.StatusForbidden, "reqToken", "token scope is limited to public packages")
+ return
+ }
+ }
+ }
+ }
+
+ if ctx.Package.AccessMode < accessMode && !ctx.IsUserSiteAdmin() {
+ ctx.Resp.Header().Set("WWW-Authenticate", `Basic realm="Gitea Package API"`)
+ ctx.Error(http.StatusUnauthorized, "reqPackageAccess", "user should have specific permission or be a site admin")
+ return
+ }
+ }
+}
+
+func enforcePackagesQuota() func(ctx *context.Context) {
+ return func(ctx *context.Context) {
+ ok, err := quota_model.EvaluateForUser(ctx, ctx.Doer.ID, quota_model.LimitSubjectSizeAssetsPackagesAll)
+ if err != nil {
+ log.Error("quota_model.EvaluateForUser: %v", err)
+ ctx.Error(http.StatusInternalServerError, "Error checking quota")
+ return
+ }
+ if !ok {
+ ctx.Error(http.StatusRequestEntityTooLarge, "enforcePackagesQuota", "quota exceeded")
+ return
+ }
+ }
+}
+
+func verifyAuth(r *web.Route, authMethods []auth.Method) {
+ if setting.Service.EnableReverseProxyAuth {
+ authMethods = append(authMethods, &auth.ReverseProxy{})
+ }
+ authGroup := auth.NewGroup(authMethods...)
+
+ r.Use(func(ctx *context.Context) {
+ var err error
+ ctx.Doer, err = authGroup.Verify(ctx.Req, ctx.Resp, ctx, ctx.Session)
+ if err != nil {
+ log.Error("Failed to verify user: %v", err)
+ ctx.Error(http.StatusUnauthorized, "authGroup.Verify")
+ return
+ }
+ ctx.IsSigned = ctx.Doer != nil
+ })
+}
+
+// CommonRoutes provide endpoints for most package managers (except containers - see below)
+// These are mounted on `/api/packages` (not `/api/v1/packages`)
+func CommonRoutes() *web.Route {
+ r := web.NewRoute()
+
+ r.Use(context.PackageContexter())
+
+ verifyAuth(r, []auth.Method{
+ &auth.OAuth2{},
+ &auth.Basic{},
+ &nuget.Auth{},
+ &conan.Auth{},
+ &chef.Auth{},
+ })
+
+ r.Group("/{username}", func() {
+ r.Group("/alpine", func() {
+ r.Get("/key", alpine.GetRepositoryKey)
+ r.Group("/{branch}/{repository}", func() {
+ r.Put("", reqPackageAccess(perm.AccessModeWrite), enforcePackagesQuota(), alpine.UploadPackageFile)
+ r.Group("/{architecture}", func() {
+ r.Get("/APKINDEX.tar.gz", alpine.GetRepositoryFile)
+ r.Group("/{filename}", func() {
+ r.Get("", alpine.DownloadPackageFile)
+ r.Delete("", reqPackageAccess(perm.AccessModeWrite), alpine.DeletePackageFile)
+ })
+ })
+ })
+ }, reqPackageAccess(perm.AccessModeRead))
+ r.Group("/arch", func() {
+ r.Group("/repository.key", func() {
+ r.Head("", arch.GetRepositoryKey)
+ r.Get("", arch.GetRepositoryKey)
+ })
+
+ r.Methods("HEAD,GET,PUT,DELETE", "*", func(ctx *context.Context) {
+ pathGroups := strings.Split(strings.Trim(ctx.Params("*"), "/"), "/")
+ groupLen := len(pathGroups)
+ isGetHead := ctx.Req.Method == "HEAD" || ctx.Req.Method == "GET"
+ isPut := ctx.Req.Method == "PUT"
+ isDelete := ctx.Req.Method == "DELETE"
+ if isGetHead {
+ if groupLen < 2 {
+ ctx.Status(http.StatusNotFound)
+ return
+ }
+ if groupLen == 2 {
+ ctx.SetParams("group", "")
+ ctx.SetParams("arch", pathGroups[0])
+ ctx.SetParams("file", pathGroups[1])
+ } else {
+ ctx.SetParams("group", strings.Join(pathGroups[:groupLen-2], "/"))
+ ctx.SetParams("arch", pathGroups[groupLen-2])
+ ctx.SetParams("file", pathGroups[groupLen-1])
+ }
+ arch.GetPackageOrDB(ctx)
+ return
+ } else if isPut {
+ ctx.SetParams("group", strings.Join(pathGroups, "/"))
+ reqPackageAccess(perm.AccessModeWrite)(ctx)
+ if ctx.Written() {
+ return
+ }
+ arch.PushPackage(ctx)
+ return
+ } else if isDelete {
+ if groupLen < 3 {
+ ctx.Status(http.StatusBadRequest)
+ return
+ }
+ if groupLen == 3 {
+ ctx.SetParams("group", "")
+ ctx.SetParams("package", pathGroups[0])
+ ctx.SetParams("version", pathGroups[1])
+ ctx.SetParams("arch", pathGroups[2])
+ } else {
+ ctx.SetParams("group", strings.Join(pathGroups[:groupLen-3], "/"))
+ ctx.SetParams("package", pathGroups[groupLen-3])
+ ctx.SetParams("version", pathGroups[groupLen-2])
+ ctx.SetParams("arch", pathGroups[groupLen-1])
+ }
+ reqPackageAccess(perm.AccessModeWrite)(ctx)
+ if ctx.Written() {
+ return
+ }
+ arch.RemovePackage(ctx)
+ return
+ }
+ ctx.Status(http.StatusNotFound)
+ })
+ }, reqPackageAccess(perm.AccessModeRead))
+ r.Group("/cargo", func() {
+ r.Group("/api/v1/crates", func() {
+ r.Get("", cargo.SearchPackages)
+ r.Put("/new", reqPackageAccess(perm.AccessModeWrite), enforcePackagesQuota(), cargo.UploadPackage)
+ r.Group("/{package}", func() {
+ r.Group("/{version}", func() {
+ r.Get("/download", cargo.DownloadPackageFile)
+ r.Delete("/yank", reqPackageAccess(perm.AccessModeWrite), cargo.YankPackage)
+ r.Put("/unyank", reqPackageAccess(perm.AccessModeWrite), enforcePackagesQuota(), cargo.UnyankPackage)
+ })
+ r.Get("/owners", cargo.ListOwners)
+ })
+ })
+ r.Get("/config.json", cargo.RepositoryConfig)
+ r.Get("/1/{package}", cargo.EnumeratePackageVersions)
+ r.Get("/2/{package}", cargo.EnumeratePackageVersions)
+ // Use dummy placeholders because these parts are not of interest
+ r.Get("/3/{_}/{package}", cargo.EnumeratePackageVersions)
+ r.Get("/{_}/{__}/{package}", cargo.EnumeratePackageVersions)
+ }, reqPackageAccess(perm.AccessModeRead))
+ r.Group("/chef", func() {
+ r.Group("/api/v1", func() {
+ r.Get("/universe", chef.PackagesUniverse)
+ r.Get("/search", chef.EnumeratePackages)
+ r.Group("/cookbooks", func() {
+ r.Get("", chef.EnumeratePackages)
+ r.Post("", reqPackageAccess(perm.AccessModeWrite), enforcePackagesQuota(), chef.UploadPackage)
+ r.Group("/{name}", func() {
+ r.Get("", chef.PackageMetadata)
+ r.Group("/versions/{version}", func() {
+ r.Get("", chef.PackageVersionMetadata)
+ r.Delete("", reqPackageAccess(perm.AccessModeWrite), chef.DeletePackageVersion)
+ r.Get("/download", chef.DownloadPackage)
+ })
+ r.Delete("", reqPackageAccess(perm.AccessModeWrite), chef.DeletePackage)
+ })
+ })
+ })
+ }, reqPackageAccess(perm.AccessModeRead))
+ r.Group("/composer", func() {
+ r.Get("/packages.json", composer.ServiceIndex)
+ r.Get("/search.json", composer.SearchPackages)
+ r.Get("/list.json", composer.EnumeratePackages)
+ r.Get("/p2/{vendorname}/{projectname}~dev.json", composer.PackageMetadata)
+ r.Get("/p2/{vendorname}/{projectname}.json", composer.PackageMetadata)
+ r.Get("/files/{package}/{version}/{filename}", composer.DownloadPackageFile)
+ r.Put("", reqPackageAccess(perm.AccessModeWrite), enforcePackagesQuota(), composer.UploadPackage)
+ }, reqPackageAccess(perm.AccessModeRead))
+ r.Group("/conan", func() {
+ r.Group("/v1", func() {
+ r.Get("/ping", conan.Ping)
+ r.Group("/users", func() {
+ r.Get("/authenticate", conan.Authenticate)
+ r.Get("/check_credentials", conan.CheckCredentials)
+ })
+ r.Group("/conans", func() {
+ r.Get("/search", conan.SearchRecipes)
+ r.Group("/{name}/{version}/{user}/{channel}", func() {
+ r.Get("", conan.RecipeSnapshot)
+ r.Delete("", reqPackageAccess(perm.AccessModeWrite), conan.DeleteRecipeV1)
+ r.Get("/search", conan.SearchPackagesV1)
+ r.Get("/digest", conan.RecipeDownloadURLs)
+ r.Post("/upload_urls", reqPackageAccess(perm.AccessModeWrite), enforcePackagesQuota(), conan.RecipeUploadURLs)
+ r.Get("/download_urls", conan.RecipeDownloadURLs)
+ r.Group("/packages", func() {
+ r.Post("/delete", reqPackageAccess(perm.AccessModeWrite), conan.DeletePackageV1)
+ r.Group("/{package_reference}", func() {
+ r.Get("", conan.PackageSnapshot)
+ r.Get("/digest", conan.PackageDownloadURLs)
+ r.Post("/upload_urls", reqPackageAccess(perm.AccessModeWrite), enforcePackagesQuota(), conan.PackageUploadURLs)
+ r.Get("/download_urls", conan.PackageDownloadURLs)
+ })
+ })
+ }, conan.ExtractPathParameters)
+ })
+ r.Group("/files/{name}/{version}/{user}/{channel}/{recipe_revision}", func() {
+ r.Group("/recipe/{filename}", func() {
+ r.Get("", conan.DownloadRecipeFile)
+ r.Put("", reqPackageAccess(perm.AccessModeWrite), enforcePackagesQuota(), conan.UploadRecipeFile)
+ })
+ r.Group("/package/{package_reference}/{package_revision}/{filename}", func() {
+ r.Get("", conan.DownloadPackageFile)
+ r.Put("", reqPackageAccess(perm.AccessModeWrite), enforcePackagesQuota(), conan.UploadPackageFile)
+ })
+ }, conan.ExtractPathParameters)
+ })
+ r.Group("/v2", func() {
+ r.Get("/ping", conan.Ping)
+ r.Group("/users", func() {
+ r.Get("/authenticate", conan.Authenticate)
+ r.Get("/check_credentials", conan.CheckCredentials)
+ })
+ r.Group("/conans", func() {
+ r.Get("/search", conan.SearchRecipes)
+ r.Group("/{name}/{version}/{user}/{channel}", func() {
+ r.Delete("", reqPackageAccess(perm.AccessModeWrite), conan.DeleteRecipeV2)
+ r.Get("/search", conan.SearchPackagesV2)
+ r.Get("/latest", conan.LatestRecipeRevision)
+ r.Group("/revisions", func() {
+ r.Get("", conan.ListRecipeRevisions)
+ r.Group("/{recipe_revision}", func() {
+ r.Delete("", reqPackageAccess(perm.AccessModeWrite), conan.DeleteRecipeV2)
+ r.Get("/search", conan.SearchPackagesV2)
+ r.Group("/files", func() {
+ r.Get("", conan.ListRecipeRevisionFiles)
+ r.Group("/{filename}", func() {
+ r.Get("", conan.DownloadRecipeFile)
+ r.Put("", reqPackageAccess(perm.AccessModeWrite), enforcePackagesQuota(), conan.UploadRecipeFile)
+ })
+ })
+ r.Group("/packages", func() {
+ r.Delete("", reqPackageAccess(perm.AccessModeWrite), conan.DeletePackageV2)
+ r.Group("/{package_reference}", func() {
+ r.Delete("", reqPackageAccess(perm.AccessModeWrite), conan.DeletePackageV2)
+ r.Get("/latest", conan.LatestPackageRevision)
+ r.Group("/revisions", func() {
+ r.Get("", conan.ListPackageRevisions)
+ r.Group("/{package_revision}", func() {
+ r.Delete("", reqPackageAccess(perm.AccessModeWrite), conan.DeletePackageV2)
+ r.Group("/files", func() {
+ r.Get("", conan.ListPackageRevisionFiles)
+ r.Group("/{filename}", func() {
+ r.Get("", conan.DownloadPackageFile)
+ r.Put("", reqPackageAccess(perm.AccessModeWrite), enforcePackagesQuota(), conan.UploadPackageFile)
+ })
+ })
+ })
+ })
+ })
+ })
+ })
+ })
+ }, conan.ExtractPathParameters)
+ })
+ })
+ }, reqPackageAccess(perm.AccessModeRead))
+ r.Group("/conda", func() {
+ var (
+ downloadPattern = regexp.MustCompile(`\A(.+/)?(.+)/((?:[^/]+(?:\.tar\.bz2|\.conda))|(?:current_)?repodata\.json(?:\.bz2)?)\z`)
+ uploadPattern = regexp.MustCompile(`\A(.+/)?([^/]+(?:\.tar\.bz2|\.conda))\z`)
+ )
+
+ r.Get("/*", func(ctx *context.Context) {
+ m := downloadPattern.FindStringSubmatch(ctx.Params("*"))
+ if len(m) == 0 {
+ ctx.Status(http.StatusNotFound)
+ return
+ }
+
+ ctx.SetParams("channel", strings.TrimSuffix(m[1], "/"))
+ ctx.SetParams("architecture", m[2])
+ ctx.SetParams("filename", m[3])
+
+ switch m[3] {
+ case "repodata.json", "repodata.json.bz2", "current_repodata.json", "current_repodata.json.bz2":
+ conda.EnumeratePackages(ctx)
+ default:
+ conda.DownloadPackageFile(ctx)
+ }
+ })
+ r.Put("/*", reqPackageAccess(perm.AccessModeWrite), enforcePackagesQuota(), func(ctx *context.Context) {
+ m := uploadPattern.FindStringSubmatch(ctx.Params("*"))
+ if len(m) == 0 {
+ ctx.Status(http.StatusNotFound)
+ return
+ }
+
+ ctx.SetParams("channel", strings.TrimSuffix(m[1], "/"))
+ ctx.SetParams("filename", m[2])
+
+ conda.UploadPackageFile(ctx)
+ })
+ }, reqPackageAccess(perm.AccessModeRead))
+ r.Group("/cran", func() {
+ r.Group("/src", func() {
+ r.Group("/contrib", func() {
+ r.Get("/PACKAGES", cran.EnumerateSourcePackages)
+ r.Get("/PACKAGES{format}", cran.EnumerateSourcePackages)
+ r.Get("/{filename}", cran.DownloadSourcePackageFile)
+ })
+ r.Put("", reqPackageAccess(perm.AccessModeWrite), enforcePackagesQuota(), cran.UploadSourcePackageFile)
+ })
+ r.Group("/bin", func() {
+ r.Group("/{platform}/contrib/{rversion}", func() {
+ r.Get("/PACKAGES", cran.EnumerateBinaryPackages)
+ r.Get("/PACKAGES{format}", cran.EnumerateBinaryPackages)
+ r.Get("/{filename}", cran.DownloadBinaryPackageFile)
+ })
+ r.Put("", reqPackageAccess(perm.AccessModeWrite), enforcePackagesQuota(), cran.UploadBinaryPackageFile)
+ })
+ }, reqPackageAccess(perm.AccessModeRead))
+ r.Group("/debian", func() {
+ r.Get("/repository.key", debian.GetRepositoryKey)
+ r.Group("/dists/{distribution}", func() {
+ r.Get("/{filename}", debian.GetRepositoryFile)
+ r.Get("/by-hash/{algorithm}/{hash}", debian.GetRepositoryFileByHash)
+ r.Group("/{component}/{architecture}", func() {
+ r.Get("/{filename}", debian.GetRepositoryFile)
+ r.Get("/by-hash/{algorithm}/{hash}", debian.GetRepositoryFileByHash)
+ })
+ })
+ r.Group("/pool/{distribution}/{component}", func() {
+ r.Get("/{name}_{version}_{architecture}.deb", debian.DownloadPackageFile)
+ r.Group("", func() {
+ r.Put("/upload", enforcePackagesQuota(), debian.UploadPackageFile)
+ r.Delete("/{name}/{version}/{architecture}", debian.DeletePackageFile)
+ }, reqPackageAccess(perm.AccessModeWrite))
+ })
+ }, reqPackageAccess(perm.AccessModeRead))
+ r.Group("/go", func() {
+ r.Put("/upload", reqPackageAccess(perm.AccessModeWrite), enforcePackagesQuota(), goproxy.UploadPackage)
+ r.Get("/sumdb/sum.golang.org/supported", func(ctx *context.Context) {
+ ctx.Status(http.StatusNotFound)
+ })
+
+ // Manual mapping of routes because the package name contains slashes which chi does not support
+ // https://go.dev/ref/mod#goproxy-protocol
+ r.Get("/*", func(ctx *context.Context) {
+ path := ctx.Params("*")
+
+ if strings.HasSuffix(path, "/@latest") {
+ ctx.SetParams("name", path[:len(path)-len("/@latest")])
+ ctx.SetParams("version", "latest")
+
+ goproxy.PackageVersionMetadata(ctx)
+ return
+ }
+
+ parts := strings.SplitN(path, "/@v/", 2)
+ if len(parts) != 2 {
+ ctx.Status(http.StatusNotFound)
+ return
+ }
+
+ ctx.SetParams("name", parts[0])
+
+ // <package/name>/@v/list
+ if parts[1] == "list" {
+ goproxy.EnumeratePackageVersions(ctx)
+ return
+ }
+
+ // <package/name>/@v/<version>.zip
+ if strings.HasSuffix(parts[1], ".zip") {
+ ctx.SetParams("version", parts[1][:len(parts[1])-len(".zip")])
+
+ goproxy.DownloadPackageFile(ctx)
+ return
+ }
+ // <package/name>/@v/<version>.info
+ if strings.HasSuffix(parts[1], ".info") {
+ ctx.SetParams("version", parts[1][:len(parts[1])-len(".info")])
+
+ goproxy.PackageVersionMetadata(ctx)
+ return
+ }
+ // <package/name>/@v/<version>.mod
+ if strings.HasSuffix(parts[1], ".mod") {
+ ctx.SetParams("version", parts[1][:len(parts[1])-len(".mod")])
+
+ goproxy.PackageVersionGoModContent(ctx)
+ return
+ }
+
+ ctx.Status(http.StatusNotFound)
+ })
+ }, reqPackageAccess(perm.AccessModeRead))
+ r.Group("/generic", func() {
+ r.Group("/{packagename}/{packageversion}", func() {
+ r.Delete("", reqPackageAccess(perm.AccessModeWrite), generic.DeletePackage)
+ r.Group("/{filename}", func() {
+ r.Get("", generic.DownloadPackageFile)
+ r.Group("", func() {
+ r.Put("", enforcePackagesQuota(), generic.UploadPackage)
+ r.Delete("", generic.DeletePackageFile)
+ }, reqPackageAccess(perm.AccessModeWrite))
+ })
+ })
+ }, reqPackageAccess(perm.AccessModeRead))
+ r.Group("/helm", func() {
+ r.Get("/index.yaml", helm.Index)
+ r.Get("/{filename}", helm.DownloadPackageFile)
+ r.Post("/api/charts", reqPackageAccess(perm.AccessModeWrite), enforcePackagesQuota(), helm.UploadPackage)
+ }, reqPackageAccess(perm.AccessModeRead))
+ r.Group("/maven", func() {
+ r.Put("/*", reqPackageAccess(perm.AccessModeWrite), enforcePackagesQuota(), maven.UploadPackageFile)
+ r.Get("/*", maven.DownloadPackageFile)
+ r.Head("/*", maven.ProvidePackageFileHeader)
+ }, reqPackageAccess(perm.AccessModeRead))
+ r.Group("/nuget", func() {
+ r.Group("", func() { // Needs to be unauthenticated for the NuGet client.
+ r.Get("/", nuget.ServiceIndexV2)
+ r.Get("/index.json", nuget.ServiceIndexV3)
+ r.Get("/$metadata", nuget.FeedCapabilityResource)
+ })
+ r.Group("", func() {
+ r.Get("/query", nuget.SearchServiceV3)
+ r.Group("/registration/{id}", func() {
+ r.Get("/index.json", nuget.RegistrationIndex)
+ r.Get("/{version}", nuget.RegistrationLeafV3)
+ })
+ r.Group("/package/{id}", func() {
+ r.Get("/index.json", nuget.EnumeratePackageVersionsV3)
+ r.Get("/{version}/{filename}", nuget.DownloadPackageFile)
+ })
+ r.Group("", func() {
+ r.Put("/", enforcePackagesQuota(), nuget.UploadPackage)
+ r.Put("/symbolpackage", enforcePackagesQuota(), nuget.UploadSymbolPackage)
+ r.Delete("/{id}/{version}", nuget.DeletePackage)
+ }, reqPackageAccess(perm.AccessModeWrite))
+ r.Get("/symbols/{filename}/{guid:[0-9a-fA-F]{32}[fF]{8}}/{filename2}", nuget.DownloadSymbolFile)
+ r.Get("/Packages(Id='{id:[^']+}',Version='{version:[^']+}')", nuget.RegistrationLeafV2)
+ r.Group("/Packages()", func() {
+ r.Get("", nuget.SearchServiceV2)
+ r.Get("/$count", nuget.SearchServiceV2Count)
+ })
+ r.Group("/FindPackagesById()", func() {
+ r.Get("", nuget.EnumeratePackageVersionsV2)
+ r.Get("/$count", nuget.EnumeratePackageVersionsV2Count)
+ })
+ r.Group("/Search()", func() {
+ r.Get("", nuget.SearchServiceV2)
+ r.Get("/$count", nuget.SearchServiceV2Count)
+ })
+ }, reqPackageAccess(perm.AccessModeRead))
+ })
+ r.Group("/npm", func() {
+ r.Group("/@{scope}/{id}", func() {
+ r.Get("", npm.PackageMetadata)
+ r.Put("", reqPackageAccess(perm.AccessModeWrite), enforcePackagesQuota(), npm.UploadPackage)
+ r.Group("/-/{version}/{filename}", func() {
+ r.Get("", npm.DownloadPackageFile)
+ r.Delete("/-rev/{revision}", reqPackageAccess(perm.AccessModeWrite), npm.DeletePackageVersion)
+ })
+ r.Get("/-/{filename}", npm.DownloadPackageFileByName)
+ r.Group("/-rev/{revision}", func() {
+ r.Delete("", npm.DeletePackage)
+ r.Put("", npm.DeletePreview)
+ }, reqPackageAccess(perm.AccessModeWrite))
+ })
+ r.Group("/{id}", func() {
+ r.Get("", npm.PackageMetadata)
+ r.Put("", reqPackageAccess(perm.AccessModeWrite), enforcePackagesQuota(), npm.UploadPackage)
+ r.Group("/-/{version}/{filename}", func() {
+ r.Get("", npm.DownloadPackageFile)
+ r.Delete("/-rev/{revision}", reqPackageAccess(perm.AccessModeWrite), npm.DeletePackageVersion)
+ })
+ r.Get("/-/{filename}", npm.DownloadPackageFileByName)
+ r.Group("/-rev/{revision}", func() {
+ r.Delete("", npm.DeletePackage)
+ r.Put("", npm.DeletePreview)
+ }, reqPackageAccess(perm.AccessModeWrite))
+ })
+ r.Group("/-/package/@{scope}/{id}/dist-tags", func() {
+ r.Get("", npm.ListPackageTags)
+ r.Group("/{tag}", func() {
+ r.Put("", npm.AddPackageTag)
+ r.Delete("", npm.DeletePackageTag)
+ }, reqPackageAccess(perm.AccessModeWrite))
+ })
+ r.Group("/-/package/{id}/dist-tags", func() {
+ r.Get("", npm.ListPackageTags)
+ r.Group("/{tag}", func() {
+ r.Put("", npm.AddPackageTag)
+ r.Delete("", npm.DeletePackageTag)
+ }, reqPackageAccess(perm.AccessModeWrite))
+ })
+ r.Group("/-/v1/search", func() {
+ r.Get("", npm.PackageSearch)
+ })
+ }, reqPackageAccess(perm.AccessModeRead))
+ r.Group("/pub", func() {
+ r.Group("/api/packages", func() {
+ r.Group("/versions/new", func() {
+ r.Get("", pub.RequestUpload)
+ r.Post("/upload", enforcePackagesQuota(), pub.UploadPackageFile)
+ r.Get("/finalize/{id}/{version}", pub.FinalizePackage)
+ }, reqPackageAccess(perm.AccessModeWrite))
+ r.Group("/{id}", func() {
+ r.Get("", pub.EnumeratePackageVersions)
+ r.Get("/files/{version}", pub.DownloadPackageFile)
+ r.Get("/{version}", pub.PackageVersionMetadata)
+ })
+ })
+ }, reqPackageAccess(perm.AccessModeRead))
+ r.Group("/pypi", func() {
+ r.Post("/", reqPackageAccess(perm.AccessModeWrite), enforcePackagesQuota(), pypi.UploadPackageFile)
+ r.Get("/files/{id}/{version}/{filename}", pypi.DownloadPackageFile)
+ r.Get("/simple/{id}", pypi.PackageMetadata)
+ }, reqPackageAccess(perm.AccessModeRead))
+ r.Group("/rpm", func() {
+ r.Group("/repository.key", func() {
+ r.Head("", rpm.GetRepositoryKey)
+ r.Get("", rpm.GetRepositoryKey)
+ })
+
+ var (
+ repoPattern = regexp.MustCompile(`\A(.*?)\.repo\z`)
+ uploadPattern = regexp.MustCompile(`\A(.*?)/upload\z`)
+ filePattern = regexp.MustCompile(`\A(.*?)/package/([^/]+)/([^/]+)/([^/]+)(?:/([^/]+\.rpm)|)\z`)
+ repoFilePattern = regexp.MustCompile(`\A(.*?)/repodata/([^/]+)\z`)
+ )
+
+ r.Methods("HEAD,GET,PUT,DELETE", "*", func(ctx *context.Context) {
+ path := ctx.Params("*")
+ isHead := ctx.Req.Method == "HEAD"
+ isGetHead := ctx.Req.Method == "HEAD" || ctx.Req.Method == "GET"
+ isPut := ctx.Req.Method == "PUT"
+ isDelete := ctx.Req.Method == "DELETE"
+
+ m := repoPattern.FindStringSubmatch(path)
+ if len(m) == 2 && isGetHead {
+ ctx.SetParams("group", strings.Trim(m[1], "/"))
+ rpm.GetRepositoryConfig(ctx)
+ return
+ }
+
+ m = repoFilePattern.FindStringSubmatch(path)
+ if len(m) == 3 && isGetHead {
+ ctx.SetParams("group", strings.Trim(m[1], "/"))
+ ctx.SetParams("filename", m[2])
+ if isHead {
+ rpm.CheckRepositoryFileExistence(ctx)
+ } else {
+ rpm.GetRepositoryFile(ctx)
+ }
+ return
+ }
+
+ m = uploadPattern.FindStringSubmatch(path)
+ if len(m) == 2 && isPut {
+ reqPackageAccess(perm.AccessModeWrite)(ctx)
+ if ctx.Written() {
+ return
+ }
+ enforcePackagesQuota()(ctx)
+ if ctx.Written() {
+ return
+ }
+ ctx.SetParams("group", strings.Trim(m[1], "/"))
+ rpm.UploadPackageFile(ctx)
+ return
+ }
+
+ m = filePattern.FindStringSubmatch(path)
+ if len(m) == 6 && (isGetHead || isDelete) {
+ ctx.SetParams("group", strings.Trim(m[1], "/"))
+ ctx.SetParams("name", m[2])
+ ctx.SetParams("version", m[3])
+ ctx.SetParams("architecture", m[4])
+ if isGetHead {
+ rpm.DownloadPackageFile(ctx)
+ } else {
+ reqPackageAccess(perm.AccessModeWrite)(ctx)
+ if ctx.Written() {
+ return
+ }
+ rpm.DeletePackageFile(ctx)
+ }
+ return
+ }
+
+ ctx.Status(http.StatusNotFound)
+ })
+ }, reqPackageAccess(perm.AccessModeRead))
+ r.Group("/rubygems", func() {
+ r.Get("/specs.4.8.gz", rubygems.EnumeratePackages)
+ r.Get("/latest_specs.4.8.gz", rubygems.EnumeratePackagesLatest)
+ r.Get("/prerelease_specs.4.8.gz", rubygems.EnumeratePackagesPreRelease)
+ r.Get("/info/{package}", rubygems.ServePackageInfo)
+ r.Get("/versions", rubygems.ServeVersionsFile)
+ r.Get("/quick/Marshal.4.8/{filename}", rubygems.ServePackageSpecification)
+ r.Get("/gems/{filename}", rubygems.DownloadPackageFile)
+ r.Group("/api/v1/gems", func() {
+ r.Post("/", enforcePackagesQuota(), rubygems.UploadPackageFile)
+ r.Delete("/yank", rubygems.DeletePackage)
+ }, reqPackageAccess(perm.AccessModeWrite))
+ }, reqPackageAccess(perm.AccessModeRead))
+ r.Group("/swift", func() {
+ r.Group("/{scope}/{name}", func() {
+ r.Group("", func() {
+ r.Get("", swift.EnumeratePackageVersions)
+ r.Get(".json", swift.EnumeratePackageVersions)
+ }, swift.CheckAcceptMediaType(swift.AcceptJSON))
+ r.Group("/{version}", func() {
+ r.Get("/Package.swift", swift.CheckAcceptMediaType(swift.AcceptSwift), swift.DownloadManifest)
+ r.Put("", reqPackageAccess(perm.AccessModeWrite), swift.CheckAcceptMediaType(swift.AcceptJSON), enforcePackagesQuota(), swift.UploadPackageFile)
+ r.Get("", func(ctx *context.Context) {
+ // Can't use normal routes here: https://github.com/go-chi/chi/issues/781
+
+ version := ctx.Params("version")
+ if strings.HasSuffix(version, ".zip") {
+ swift.CheckAcceptMediaType(swift.AcceptZip)(ctx)
+ if ctx.Written() {
+ return
+ }
+ ctx.SetParams("version", version[:len(version)-4])
+ swift.DownloadPackageFile(ctx)
+ } else {
+ swift.CheckAcceptMediaType(swift.AcceptJSON)(ctx)
+ if ctx.Written() {
+ return
+ }
+ if strings.HasSuffix(version, ".json") {
+ ctx.SetParams("version", version[:len(version)-5])
+ }
+ swift.PackageVersionMetadata(ctx)
+ }
+ })
+ })
+ })
+ r.Get("/identifiers", swift.CheckAcceptMediaType(swift.AcceptJSON), swift.LookupPackageIdentifiers)
+ }, reqPackageAccess(perm.AccessModeRead))
+ r.Group("/vagrant", func() {
+ r.Group("/authenticate", func() {
+ r.Get("", vagrant.CheckAuthenticate)
+ })
+ r.Group("/{name}", func() {
+ r.Head("", vagrant.CheckBoxAvailable)
+ r.Get("", vagrant.EnumeratePackageVersions)
+ r.Group("/{version}/{provider}", func() {
+ r.Get("", vagrant.DownloadPackageFile)
+ r.Put("", reqPackageAccess(perm.AccessModeWrite), enforcePackagesQuota(), vagrant.UploadPackageFile)
+ })
+ })
+ }, reqPackageAccess(perm.AccessModeRead))
+ }, context.UserAssignmentWeb(), context.PackageAssignment())
+
+ return r
+}
+
+// ContainerRoutes provides endpoints that implement the OCI API to serve containers
+// These have to be mounted on `/v2/...` to comply with the OCI spec:
+// https://github.com/opencontainers/distribution-spec/blob/main/spec.md
+func ContainerRoutes() *web.Route {
+ r := web.NewRoute()
+
+ r.Use(context.PackageContexter())
+
+ verifyAuth(r, []auth.Method{
+ &auth.Basic{},
+ &container.Auth{},
+ })
+
+ r.Get("", container.ReqContainerAccess, container.DetermineSupport)
+ r.Group("/token", func() {
+ r.Get("", container.Authenticate)
+ r.Post("", container.AuthenticateNotImplemented)
+ })
+ r.Get("/_catalog", container.ReqContainerAccess, container.GetRepositoryList)
+ r.Group("/{username}", func() {
+ r.Group("/{image}", func() {
+ r.Group("/blobs/uploads", func() {
+ r.Post("", container.InitiateUploadBlob)
+ r.Group("/{uuid}", func() {
+ r.Get("", container.GetUploadBlob)
+ r.Patch("", container.UploadBlob)
+ r.Put("", container.EndUploadBlob)
+ r.Delete("", container.CancelUploadBlob)
+ })
+ }, reqPackageAccess(perm.AccessModeWrite))
+ r.Group("/blobs/{digest}", func() {
+ r.Head("", container.HeadBlob)
+ r.Get("", container.GetBlob)
+ r.Delete("", reqPackageAccess(perm.AccessModeWrite), container.DeleteBlob)
+ })
+ r.Group("/manifests/{reference}", func() {
+ r.Put("", reqPackageAccess(perm.AccessModeWrite), container.UploadManifest)
+ r.Head("", container.HeadManifest)
+ r.Get("", container.GetManifest)
+ r.Delete("", reqPackageAccess(perm.AccessModeWrite), container.DeleteManifest)
+ })
+ r.Get("/tags/list", container.GetTagList)
+ }, container.VerifyImageName)
+
+ var (
+ blobsUploadsPattern = regexp.MustCompile(`\A(.+)/blobs/uploads/([a-zA-Z0-9-_.=]+)\z`)
+ blobsPattern = regexp.MustCompile(`\A(.+)/blobs/([^/]+)\z`)
+ manifestsPattern = regexp.MustCompile(`\A(.+)/manifests/([^/]+)\z`)
+ )
+
+ // Manual mapping of routes because {image} can contain slashes which chi does not support
+ r.Methods("HEAD,GET,POST,PUT,PATCH,DELETE", "/*", func(ctx *context.Context) {
+ path := ctx.Params("*")
+ isHead := ctx.Req.Method == "HEAD"
+ isGet := ctx.Req.Method == "GET"
+ isPost := ctx.Req.Method == "POST"
+ isPut := ctx.Req.Method == "PUT"
+ isPatch := ctx.Req.Method == "PATCH"
+ isDelete := ctx.Req.Method == "DELETE"
+
+ if isPost && strings.HasSuffix(path, "/blobs/uploads") {
+ reqPackageAccess(perm.AccessModeWrite)(ctx)
+ if ctx.Written() {
+ return
+ }
+
+ ctx.SetParams("image", path[:len(path)-14])
+ container.VerifyImageName(ctx)
+ if ctx.Written() {
+ return
+ }
+
+ container.InitiateUploadBlob(ctx)
+ return
+ }
+ if isGet && strings.HasSuffix(path, "/tags/list") {
+ ctx.SetParams("image", path[:len(path)-10])
+ container.VerifyImageName(ctx)
+ if ctx.Written() {
+ return
+ }
+
+ container.GetTagList(ctx)
+ return
+ }
+
+ m := blobsUploadsPattern.FindStringSubmatch(path)
+ if len(m) == 3 && (isGet || isPut || isPatch || isDelete) {
+ reqPackageAccess(perm.AccessModeWrite)(ctx)
+ if ctx.Written() {
+ return
+ }
+
+ ctx.SetParams("image", m[1])
+ container.VerifyImageName(ctx)
+ if ctx.Written() {
+ return
+ }
+
+ ctx.SetParams("uuid", m[2])
+
+ if isGet {
+ container.GetUploadBlob(ctx)
+ } else if isPatch {
+ container.UploadBlob(ctx)
+ } else if isPut {
+ container.EndUploadBlob(ctx)
+ } else {
+ container.CancelUploadBlob(ctx)
+ }
+ return
+ }
+ m = blobsPattern.FindStringSubmatch(path)
+ if len(m) == 3 && (isHead || isGet || isDelete) {
+ ctx.SetParams("image", m[1])
+ container.VerifyImageName(ctx)
+ if ctx.Written() {
+ return
+ }
+
+ ctx.SetParams("digest", m[2])
+
+ if isHead {
+ container.HeadBlob(ctx)
+ } else if isGet {
+ container.GetBlob(ctx)
+ } else {
+ reqPackageAccess(perm.AccessModeWrite)(ctx)
+ if ctx.Written() {
+ return
+ }
+ container.DeleteBlob(ctx)
+ }
+ return
+ }
+ m = manifestsPattern.FindStringSubmatch(path)
+ if len(m) == 3 && (isHead || isGet || isPut || isDelete) {
+ ctx.SetParams("image", m[1])
+ container.VerifyImageName(ctx)
+ if ctx.Written() {
+ return
+ }
+
+ ctx.SetParams("reference", m[2])
+
+ if isHead {
+ container.HeadManifest(ctx)
+ } else if isGet {
+ container.GetManifest(ctx)
+ } else {
+ reqPackageAccess(perm.AccessModeWrite)(ctx)
+ if ctx.Written() {
+ return
+ }
+ if isPut {
+ container.UploadManifest(ctx)
+ } else {
+ container.DeleteManifest(ctx)
+ }
+ }
+ return
+ }
+
+ ctx.Status(http.StatusNotFound)
+ })
+ }, container.ReqContainerAccess, context.UserAssignmentWeb(), context.PackageAssignment(), reqPackageAccess(perm.AccessModeRead))
+
+ return r
+}
diff --git a/routers/api/packages/arch/arch.go b/routers/api/packages/arch/arch.go
new file mode 100644
index 0000000..15fcc37
--- /dev/null
+++ b/routers/api/packages/arch/arch.go
@@ -0,0 +1,269 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package arch
+
+import (
+ "encoding/base64"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "path/filepath"
+ "regexp"
+ "strings"
+
+ packages_model "code.gitea.io/gitea/models/packages"
+ packages_module "code.gitea.io/gitea/modules/packages"
+ arch_module "code.gitea.io/gitea/modules/packages/arch"
+ "code.gitea.io/gitea/modules/sync"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/routers/api/packages/helper"
+ "code.gitea.io/gitea/services/context"
+ packages_service "code.gitea.io/gitea/services/packages"
+ arch_service "code.gitea.io/gitea/services/packages/arch"
+)
+
+var (
+ archPkgOrSig = regexp.MustCompile(`^.*\.pkg\.tar\.\w+(\.sig)*$`)
+ archDBOrSig = regexp.MustCompile(`^.*.db(\.tar\.gz)*(\.sig)*$`)
+
+ locker = sync.NewExclusivePool()
+)
+
+func apiError(ctx *context.Context, status int, obj any) {
+ helper.LogAndProcessError(ctx, status, obj, func(message string) {
+ ctx.PlainText(status, message)
+ })
+}
+
+func refreshLocker(ctx *context.Context, group string) func() {
+ key := fmt.Sprintf("pkg_%d_arch_pkg_%s", ctx.Package.Owner.ID, group)
+ locker.CheckIn(key)
+ return func() {
+ locker.CheckOut(key)
+ }
+}
+
+func GetRepositoryKey(ctx *context.Context) {
+ _, pub, err := arch_service.GetOrCreateKeyPair(ctx, ctx.Package.Owner.ID)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ ctx.ServeContent(strings.NewReader(pub), &context.ServeHeaderOptions{
+ ContentType: "application/pgp-keys",
+ Filename: "repository.key",
+ })
+}
+
+func PushPackage(ctx *context.Context) {
+ group := ctx.Params("group")
+ releaser := refreshLocker(ctx, group)
+ defer releaser()
+ upload, needToClose, err := ctx.UploadStream()
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ if needToClose {
+ defer upload.Close()
+ }
+
+ buf, err := packages_module.CreateHashedBufferFromReader(upload)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ defer buf.Close()
+
+ p, err := arch_module.ParsePackage(buf)
+ if err != nil {
+ apiError(ctx, http.StatusBadRequest, err)
+ return
+ }
+
+ _, err = buf.Seek(0, io.SeekStart)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ sign, err := arch_service.NewFileSign(ctx, ctx.Package.Owner.ID, buf)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ defer sign.Close()
+ _, err = buf.Seek(0, io.SeekStart)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ // update gpg sign
+ pgp, err := io.ReadAll(sign)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ p.FileMetadata.PgpSigned = base64.StdEncoding.EncodeToString(pgp)
+ _, err = sign.Seek(0, io.SeekStart)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ properties := map[string]string{
+ arch_module.PropertyDescription: p.Desc(),
+ arch_module.PropertyArch: p.FileMetadata.Arch,
+ arch_module.PropertyDistribution: group,
+ }
+
+ version, _, err := packages_service.CreatePackageOrAddFileToExisting(
+ ctx,
+ &packages_service.PackageCreationInfo{
+ PackageInfo: packages_service.PackageInfo{
+ Owner: ctx.Package.Owner,
+ PackageType: packages_model.TypeArch,
+ Name: p.Name,
+ Version: p.Version,
+ },
+ Creator: ctx.Doer,
+ Metadata: p.VersionMetadata,
+ },
+ &packages_service.PackageFileCreationInfo{
+ PackageFileInfo: packages_service.PackageFileInfo{
+ Filename: fmt.Sprintf("%s-%s-%s.pkg.tar.%s", p.Name, p.Version, p.FileMetadata.Arch, p.CompressType),
+ CompositeKey: group,
+ },
+ OverwriteExisting: false,
+ IsLead: true,
+ Creator: ctx.ContextUser,
+ Data: buf,
+ Properties: properties,
+ },
+ )
+ if err != nil {
+ switch {
+ case errors.Is(err, packages_model.ErrDuplicatePackageVersion), errors.Is(err, packages_model.ErrDuplicatePackageFile):
+ apiError(ctx, http.StatusConflict, err)
+ case errors.Is(err, packages_service.ErrQuotaTotalCount), errors.Is(err, packages_service.ErrQuotaTypeSize), errors.Is(err, packages_service.ErrQuotaTotalSize):
+ apiError(ctx, http.StatusForbidden, err)
+ default:
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+ // add sign file
+ _, err = packages_service.AddFileToPackageVersionInternal(ctx, version, &packages_service.PackageFileCreationInfo{
+ PackageFileInfo: packages_service.PackageFileInfo{
+ CompositeKey: group,
+ Filename: fmt.Sprintf("%s-%s-%s.pkg.tar.%s.sig", p.Name, p.Version, p.FileMetadata.Arch, p.CompressType),
+ },
+ OverwriteExisting: true,
+ IsLead: false,
+ Creator: ctx.Doer,
+ Data: sign,
+ })
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ if err = arch_service.BuildPacmanDB(ctx, ctx.Package.Owner.ID, group, p.FileMetadata.Arch); err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ ctx.Status(http.StatusCreated)
+}
+
+func GetPackageOrDB(ctx *context.Context) {
+ var (
+ file = ctx.Params("file")
+ group = ctx.Params("group")
+ arch = ctx.Params("arch")
+ )
+ if archPkgOrSig.MatchString(file) {
+ pkg, u, pf, err := arch_service.GetPackageFile(ctx, group, file, ctx.Package.Owner.ID)
+ if err != nil {
+ if errors.Is(err, util.ErrNotExist) {
+ apiError(ctx, http.StatusNotFound, err)
+ } else {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+ helper.ServePackageFile(ctx, pkg, u, pf)
+ return
+ }
+
+ if archDBOrSig.MatchString(file) {
+ pkg, u, pf, err := arch_service.GetPackageDBFile(ctx, group, arch, ctx.Package.Owner.ID,
+ strings.HasSuffix(file, ".sig"))
+ if err != nil {
+ if errors.Is(err, util.ErrNotExist) {
+ apiError(ctx, http.StatusNotFound, err)
+ } else {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+ helper.ServePackageFile(ctx, pkg, u, pf)
+ return
+ }
+
+ ctx.Status(http.StatusNotFound)
+}
+
+func RemovePackage(ctx *context.Context) {
+ var (
+ group = ctx.Params("group")
+ pkg = ctx.Params("package")
+ ver = ctx.Params("version")
+ pkgArch = ctx.Params("arch")
+ )
+ releaser := refreshLocker(ctx, group)
+ defer releaser()
+ pv, err := packages_model.GetVersionByNameAndVersion(
+ ctx, ctx.Package.Owner.ID, packages_model.TypeArch, pkg, ver,
+ )
+ if err != nil {
+ if errors.Is(err, util.ErrNotExist) {
+ apiError(ctx, http.StatusNotFound, err)
+ } else {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+ files, err := packages_model.GetFilesByVersionID(ctx, pv.ID)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ deleted := false
+ for _, file := range files {
+ extName := fmt.Sprintf("-%s.pkg.tar%s", pkgArch, filepath.Ext(file.LowerName))
+ if strings.HasSuffix(file.LowerName, ".sig") {
+ extName = fmt.Sprintf("-%s.pkg.tar%s.sig", pkgArch,
+ filepath.Ext(strings.TrimSuffix(file.LowerName, filepath.Ext(file.LowerName))))
+ }
+ if file.CompositeKey == group &&
+ strings.HasSuffix(file.LowerName, extName) {
+ deleted = true
+ err := packages_service.RemovePackageFileAndVersionIfUnreferenced(ctx, ctx.ContextUser, file)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ }
+ }
+ if deleted {
+ err = arch_service.BuildCustomRepositoryFiles(ctx, ctx.Package.Owner.ID, group)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ ctx.Status(http.StatusNoContent)
+ } else {
+ ctx.Error(http.StatusNotFound)
+ }
+}
diff --git a/routers/api/packages/cargo/cargo.go b/routers/api/packages/cargo/cargo.go
new file mode 100644
index 0000000..140e532
--- /dev/null
+++ b/routers/api/packages/cargo/cargo.go
@@ -0,0 +1,311 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package cargo
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "strconv"
+ "strings"
+
+ "code.gitea.io/gitea/models/db"
+ packages_model "code.gitea.io/gitea/models/packages"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/optional"
+ packages_module "code.gitea.io/gitea/modules/packages"
+ cargo_module "code.gitea.io/gitea/modules/packages/cargo"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/routers/api/packages/helper"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/convert"
+ packages_service "code.gitea.io/gitea/services/packages"
+ cargo_service "code.gitea.io/gitea/services/packages/cargo"
+)
+
+// https://doc.rust-lang.org/cargo/reference/registries.html#web-api
+type StatusResponse struct {
+ OK bool `json:"ok"`
+ Errors []StatusMessage `json:"errors,omitempty"`
+}
+
+type StatusMessage struct {
+ Message string `json:"detail"`
+}
+
+func apiError(ctx *context.Context, status int, obj any) {
+ helper.LogAndProcessError(ctx, status, obj, func(message string) {
+ ctx.JSON(status, StatusResponse{
+ OK: false,
+ Errors: []StatusMessage{
+ {
+ Message: message,
+ },
+ },
+ })
+ })
+}
+
+// https://rust-lang.github.io/rfcs/2789-sparse-index.html
+func RepositoryConfig(ctx *context.Context) {
+ ctx.JSON(http.StatusOK, cargo_service.BuildConfig(ctx.Package.Owner, setting.Service.RequireSignInView || ctx.Package.Owner.Visibility != structs.VisibleTypePublic))
+}
+
+func EnumeratePackageVersions(ctx *context.Context) {
+ p, err := packages_model.GetPackageByName(ctx, ctx.Package.Owner.ID, packages_model.TypeCargo, ctx.Params("package"))
+ if err != nil {
+ if errors.Is(err, util.ErrNotExist) {
+ apiError(ctx, http.StatusNotFound, err)
+ } else {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ b, err := cargo_service.BuildPackageIndex(ctx, p)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ if b == nil {
+ apiError(ctx, http.StatusNotFound, nil)
+ return
+ }
+
+ ctx.PlainTextBytes(http.StatusOK, b.Bytes())
+}
+
+type SearchResult struct {
+ Crates []*SearchResultCrate `json:"crates"`
+ Meta SearchResultMeta `json:"meta"`
+}
+
+type SearchResultCrate struct {
+ Name string `json:"name"`
+ LatestVersion string `json:"max_version"`
+ Description string `json:"description"`
+}
+
+type SearchResultMeta struct {
+ Total int64 `json:"total"`
+}
+
+// https://doc.rust-lang.org/cargo/reference/registries.html#search
+func SearchPackages(ctx *context.Context) {
+ page := ctx.FormInt("page")
+ if page < 1 {
+ page = 1
+ }
+ perPage := ctx.FormInt("per_page")
+ paginator := db.ListOptions{
+ Page: page,
+ PageSize: convert.ToCorrectPageSize(perPage),
+ }
+
+ pvs, total, err := packages_model.SearchLatestVersions(
+ ctx,
+ &packages_model.PackageSearchOptions{
+ OwnerID: ctx.Package.Owner.ID,
+ Type: packages_model.TypeCargo,
+ Name: packages_model.SearchValue{Value: ctx.FormTrim("q")},
+ IsInternal: optional.Some(false),
+ Paginator: &paginator,
+ },
+ )
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ pds, err := packages_model.GetPackageDescriptors(ctx, pvs)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ crates := make([]*SearchResultCrate, 0, len(pvs))
+ for _, pd := range pds {
+ crates = append(crates, &SearchResultCrate{
+ Name: pd.Package.Name,
+ LatestVersion: pd.Version.Version,
+ Description: pd.Metadata.(*cargo_module.Metadata).Description,
+ })
+ }
+
+ ctx.JSON(http.StatusOK, SearchResult{
+ Crates: crates,
+ Meta: SearchResultMeta{
+ Total: total,
+ },
+ })
+}
+
+type Owners struct {
+ Users []OwnerUser `json:"users"`
+}
+
+type OwnerUser struct {
+ ID int64 `json:"id"`
+ Login string `json:"login"`
+ Name string `json:"name"`
+}
+
+// https://doc.rust-lang.org/cargo/reference/registries.html#owners-list
+func ListOwners(ctx *context.Context) {
+ ctx.JSON(http.StatusOK, Owners{
+ Users: []OwnerUser{
+ {
+ ID: ctx.Package.Owner.ID,
+ Login: ctx.Package.Owner.Name,
+ Name: ctx.Package.Owner.DisplayName(),
+ },
+ },
+ })
+}
+
+// DownloadPackageFile serves the content of a package
+func DownloadPackageFile(ctx *context.Context) {
+ s, u, pf, err := packages_service.GetFileStreamByPackageNameAndVersion(
+ ctx,
+ &packages_service.PackageInfo{
+ Owner: ctx.Package.Owner,
+ PackageType: packages_model.TypeCargo,
+ Name: ctx.Params("package"),
+ Version: ctx.Params("version"),
+ },
+ &packages_service.PackageFileInfo{
+ Filename: strings.ToLower(fmt.Sprintf("%s-%s.crate", ctx.Params("package"), ctx.Params("version"))),
+ },
+ )
+ if err != nil {
+ if err == packages_model.ErrPackageNotExist || err == packages_model.ErrPackageFileNotExist {
+ apiError(ctx, http.StatusNotFound, err)
+ return
+ }
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ helper.ServePackageFile(ctx, s, u, pf)
+}
+
+// https://doc.rust-lang.org/cargo/reference/registries.html#publish
+func UploadPackage(ctx *context.Context) {
+ defer ctx.Req.Body.Close()
+
+ cp, err := cargo_module.ParsePackage(ctx.Req.Body)
+ if err != nil {
+ apiError(ctx, http.StatusBadRequest, err)
+ return
+ }
+
+ buf, err := packages_module.CreateHashedBufferFromReader(cp.Content)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ defer buf.Close()
+
+ if buf.Size() != cp.ContentSize {
+ apiError(ctx, http.StatusBadRequest, "invalid content size")
+ return
+ }
+
+ pv, _, err := packages_service.CreatePackageAndAddFile(
+ ctx,
+ &packages_service.PackageCreationInfo{
+ PackageInfo: packages_service.PackageInfo{
+ Owner: ctx.Package.Owner,
+ PackageType: packages_model.TypeCargo,
+ Name: cp.Name,
+ Version: cp.Version,
+ },
+ SemverCompatible: true,
+ Creator: ctx.Doer,
+ Metadata: cp.Metadata,
+ VersionProperties: map[string]string{
+ cargo_module.PropertyYanked: strconv.FormatBool(false),
+ },
+ },
+ &packages_service.PackageFileCreationInfo{
+ PackageFileInfo: packages_service.PackageFileInfo{
+ Filename: strings.ToLower(fmt.Sprintf("%s-%s.crate", cp.Name, cp.Version)),
+ },
+ Creator: ctx.Doer,
+ Data: buf,
+ IsLead: true,
+ },
+ )
+ if err != nil {
+ switch err {
+ case packages_model.ErrDuplicatePackageVersion:
+ apiError(ctx, http.StatusConflict, err)
+ case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize:
+ apiError(ctx, http.StatusForbidden, err)
+ default:
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ if err := cargo_service.UpdatePackageIndexIfExists(ctx, ctx.Doer, ctx.Package.Owner, pv.PackageID); err != nil {
+ if err := packages_service.DeletePackageVersionAndReferences(ctx, pv); err != nil {
+ log.Error("Rollback creation of package version: %v", err)
+ }
+
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ ctx.JSON(http.StatusOK, StatusResponse{OK: true})
+}
+
+// https://doc.rust-lang.org/cargo/reference/registries.html#yank
+func YankPackage(ctx *context.Context) {
+ yankPackage(ctx, true)
+}
+
+// https://doc.rust-lang.org/cargo/reference/registries.html#unyank
+func UnyankPackage(ctx *context.Context) {
+ yankPackage(ctx, false)
+}
+
+func yankPackage(ctx *context.Context, yank bool) {
+ pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeCargo, ctx.Params("package"), ctx.Params("version"))
+ if err != nil {
+ if err == packages_model.ErrPackageNotExist {
+ apiError(ctx, http.StatusNotFound, err)
+ return
+ }
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ pps, err := packages_model.GetPropertiesByName(ctx, packages_model.PropertyTypeVersion, pv.ID, cargo_module.PropertyYanked)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ if len(pps) == 0 {
+ apiError(ctx, http.StatusInternalServerError, "Property not found")
+ return
+ }
+
+ pp := pps[0]
+ pp.Value = strconv.FormatBool(yank)
+
+ if err := packages_model.UpdateProperty(ctx, pp); err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ if err := cargo_service.UpdatePackageIndexIfExists(ctx, ctx.Doer, ctx.Package.Owner, pv.PackageID); err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ ctx.JSON(http.StatusOK, StatusResponse{OK: true})
+}
diff --git a/routers/api/packages/chef/auth.go b/routers/api/packages/chef/auth.go
new file mode 100644
index 0000000..a790e9a
--- /dev/null
+++ b/routers/api/packages/chef/auth.go
@@ -0,0 +1,274 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package chef
+
+import (
+ "context"
+ "crypto"
+ "crypto/rsa"
+ "crypto/sha1"
+ "crypto/sha256"
+ "crypto/x509"
+ "encoding/base64"
+ "encoding/pem"
+ "fmt"
+ "hash"
+ "math/big"
+ "net/http"
+ "path"
+ "regexp"
+ "slices"
+ "strconv"
+ "strings"
+ "time"
+
+ user_model "code.gitea.io/gitea/models/user"
+ chef_module "code.gitea.io/gitea/modules/packages/chef"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/services/auth"
+)
+
+const (
+ maxTimeDifference = 10 * time.Minute
+)
+
+var (
+ algorithmPattern = regexp.MustCompile(`algorithm=(\w+)`)
+ versionPattern = regexp.MustCompile(`version=(\d+\.\d+)`)
+ authorizationPattern = regexp.MustCompile(`\AX-Ops-Authorization-(\d+)`)
+
+ _ auth.Method = &Auth{}
+)
+
+// Documentation:
+// https://docs.chef.io/server/api_chef_server/#required-headers
+// https://github.com/chef-boneyard/chef-rfc/blob/master/rfc065-sign-v1.3.md
+// https://github.com/chef/mixlib-authentication/blob/bc8adbef833d4be23dc78cb23e6fe44b51ebc34f/lib/mixlib/authentication/signedheaderauth.rb
+
+type Auth struct{}
+
+func (a *Auth) Name() string {
+ return "chef"
+}
+
+// Verify extracts the user from the signed request
+// If the request is signed with the user private key the user is verified.
+func (a *Auth) Verify(req *http.Request, w http.ResponseWriter, store auth.DataStore, sess auth.SessionStore) (*user_model.User, error) {
+ u, err := getUserFromRequest(req)
+ if err != nil {
+ return nil, err
+ }
+ if u == nil {
+ return nil, nil
+ }
+
+ pub, err := getUserPublicKey(req.Context(), u)
+ if err != nil {
+ return nil, err
+ }
+
+ if err := verifyTimestamp(req); err != nil {
+ return nil, err
+ }
+
+ version, err := getSignVersion(req)
+ if err != nil {
+ return nil, err
+ }
+
+ if err := verifySignedHeaders(req, version, pub.(*rsa.PublicKey)); err != nil {
+ return nil, err
+ }
+
+ return u, nil
+}
+
+func getUserFromRequest(req *http.Request) (*user_model.User, error) {
+ username := req.Header.Get("X-Ops-Userid")
+ if username == "" {
+ return nil, nil
+ }
+
+ return user_model.GetUserByName(req.Context(), username)
+}
+
+func getUserPublicKey(ctx context.Context, u *user_model.User) (crypto.PublicKey, error) {
+ pubKey, err := user_model.GetSetting(ctx, u.ID, chef_module.SettingPublicPem)
+ if err != nil {
+ return nil, err
+ }
+
+ pubPem, _ := pem.Decode([]byte(pubKey))
+
+ return x509.ParsePKIXPublicKey(pubPem.Bytes)
+}
+
+func verifyTimestamp(req *http.Request) error {
+ hdr := req.Header.Get("X-Ops-Timestamp")
+ if hdr == "" {
+ return util.NewInvalidArgumentErrorf("X-Ops-Timestamp header missing")
+ }
+
+ ts, err := time.Parse(time.RFC3339, hdr)
+ if err != nil {
+ return err
+ }
+
+ diff := time.Now().UTC().Sub(ts)
+ if diff < 0 {
+ diff = -diff
+ }
+
+ if diff > maxTimeDifference {
+ return fmt.Errorf("time difference")
+ }
+
+ return nil
+}
+
+func getSignVersion(req *http.Request) (string, error) {
+ hdr := req.Header.Get("X-Ops-Sign")
+ if hdr == "" {
+ return "", util.NewInvalidArgumentErrorf("X-Ops-Sign header missing")
+ }
+
+ m := versionPattern.FindStringSubmatch(hdr)
+ if len(m) != 2 {
+ return "", util.NewInvalidArgumentErrorf("invalid X-Ops-Sign header")
+ }
+
+ switch m[1] {
+ case "1.0", "1.1", "1.2", "1.3":
+ default:
+ return "", util.NewInvalidArgumentErrorf("unsupported version")
+ }
+
+ version := m[1]
+
+ m = algorithmPattern.FindStringSubmatch(hdr)
+ if len(m) == 2 && m[1] != "sha1" && !(m[1] == "sha256" && version == "1.3") {
+ return "", util.NewInvalidArgumentErrorf("unsupported algorithm")
+ }
+
+ return version, nil
+}
+
+func verifySignedHeaders(req *http.Request, version string, pub *rsa.PublicKey) error {
+ authorizationData, err := getAuthorizationData(req)
+ if err != nil {
+ return err
+ }
+
+ checkData := buildCheckData(req, version)
+
+ switch version {
+ case "1.3":
+ return verifyDataNew(authorizationData, checkData, pub, crypto.SHA256)
+ case "1.2":
+ return verifyDataNew(authorizationData, checkData, pub, crypto.SHA1)
+ default:
+ return verifyDataOld(authorizationData, checkData, pub)
+ }
+}
+
+func getAuthorizationData(req *http.Request) ([]byte, error) {
+ valueList := make(map[int]string)
+ for k, vs := range req.Header {
+ if m := authorizationPattern.FindStringSubmatch(k); m != nil {
+ index, _ := strconv.Atoi(m[1])
+ var v string
+ if len(vs) == 0 {
+ v = ""
+ } else {
+ v = vs[0]
+ }
+ valueList[index] = v
+ }
+ }
+
+ tmp := make([]string, len(valueList))
+ for k, v := range valueList {
+ if k > len(tmp) {
+ return nil, fmt.Errorf("invalid X-Ops-Authorization headers")
+ }
+ tmp[k-1] = v
+ }
+
+ return base64.StdEncoding.DecodeString(strings.Join(tmp, ""))
+}
+
+func buildCheckData(req *http.Request, version string) []byte {
+ username := req.Header.Get("X-Ops-Userid")
+ if version != "1.0" && version != "1.3" {
+ sum := sha1.Sum([]byte(username))
+ username = base64.StdEncoding.EncodeToString(sum[:])
+ }
+
+ var data string
+ if version == "1.3" {
+ data = fmt.Sprintf(
+ "Method:%s\nPath:%s\nX-Ops-Content-Hash:%s\nX-Ops-Sign:version=%s\nX-Ops-Timestamp:%s\nX-Ops-UserId:%s\nX-Ops-Server-API-Version:%s",
+ req.Method,
+ path.Clean(req.URL.Path),
+ req.Header.Get("X-Ops-Content-Hash"),
+ version,
+ req.Header.Get("X-Ops-Timestamp"),
+ username,
+ req.Header.Get("X-Ops-Server-Api-Version"),
+ )
+ } else {
+ sum := sha1.Sum([]byte(path.Clean(req.URL.Path)))
+ data = fmt.Sprintf(
+ "Method:%s\nHashed Path:%s\nX-Ops-Content-Hash:%s\nX-Ops-Timestamp:%s\nX-Ops-UserId:%s",
+ req.Method,
+ base64.StdEncoding.EncodeToString(sum[:]),
+ req.Header.Get("X-Ops-Content-Hash"),
+ req.Header.Get("X-Ops-Timestamp"),
+ username,
+ )
+ }
+
+ return []byte(data)
+}
+
+func verifyDataNew(signature, data []byte, pub *rsa.PublicKey, algo crypto.Hash) error {
+ var h hash.Hash
+ if algo == crypto.SHA256 {
+ h = sha256.New()
+ } else {
+ h = sha1.New()
+ }
+ if _, err := h.Write(data); err != nil {
+ return err
+ }
+
+ return rsa.VerifyPKCS1v15(pub, algo, h.Sum(nil), signature)
+}
+
+func verifyDataOld(signature, data []byte, pub *rsa.PublicKey) error {
+ c := new(big.Int)
+ m := new(big.Int)
+ m.SetBytes(signature)
+ e := big.NewInt(int64(pub.E))
+ c.Exp(m, e, pub.N)
+
+ out := c.Bytes()
+
+ skip := 0
+ for i := 2; i < len(out); i++ {
+ if i+1 >= len(out) {
+ break
+ }
+ if out[i] == 0xFF && out[i+1] == 0 {
+ skip = i + 2
+ break
+ }
+ }
+
+ if !slices.Equal(out[skip:], data) {
+ return fmt.Errorf("could not verify signature")
+ }
+
+ return nil
+}
diff --git a/routers/api/packages/chef/chef.go b/routers/api/packages/chef/chef.go
new file mode 100644
index 0000000..b49f4e9
--- /dev/null
+++ b/routers/api/packages/chef/chef.go
@@ -0,0 +1,403 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package chef
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "sort"
+ "strings"
+ "time"
+
+ "code.gitea.io/gitea/models/db"
+ packages_model "code.gitea.io/gitea/models/packages"
+ "code.gitea.io/gitea/modules/optional"
+ packages_module "code.gitea.io/gitea/modules/packages"
+ chef_module "code.gitea.io/gitea/modules/packages/chef"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/routers/api/packages/helper"
+ "code.gitea.io/gitea/services/context"
+ packages_service "code.gitea.io/gitea/services/packages"
+)
+
+func apiError(ctx *context.Context, status int, obj any) {
+ type Error struct {
+ ErrorMessages []string `json:"error_messages"`
+ }
+
+ helper.LogAndProcessError(ctx, status, obj, func(message string) {
+ ctx.JSON(status, Error{
+ ErrorMessages: []string{message},
+ })
+ })
+}
+
+func PackagesUniverse(ctx *context.Context) {
+ pvs, _, err := packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{
+ OwnerID: ctx.Package.Owner.ID,
+ Type: packages_model.TypeChef,
+ IsInternal: optional.Some(false),
+ })
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ pds, err := packages_model.GetPackageDescriptors(ctx, pvs)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ type VersionInfo struct {
+ LocationType string `json:"location_type"`
+ LocationPath string `json:"location_path"`
+ DownloadURL string `json:"download_url"`
+ Dependencies map[string]string `json:"dependencies"`
+ }
+
+ baseURL := setting.AppURL + "api/packages/" + ctx.Package.Owner.Name + "/chef/api/v1"
+
+ universe := make(map[string]map[string]*VersionInfo)
+ for _, pd := range pds {
+ if _, ok := universe[pd.Package.Name]; !ok {
+ universe[pd.Package.Name] = make(map[string]*VersionInfo)
+ }
+ universe[pd.Package.Name][pd.Version.Version] = &VersionInfo{
+ LocationType: "opscode",
+ LocationPath: baseURL,
+ DownloadURL: fmt.Sprintf("%s/cookbooks/%s/versions/%s/download", baseURL, url.PathEscape(pd.Package.Name), pd.Version.Version),
+ Dependencies: pd.Metadata.(*chef_module.Metadata).Dependencies,
+ }
+ }
+
+ ctx.JSON(http.StatusOK, universe)
+}
+
+// https://github.com/chef/chef/blob/main/knife/lib/chef/knife/supermarket_list.rb
+// https://github.com/chef/chef/blob/main/knife/lib/chef/knife/supermarket_search.rb
+func EnumeratePackages(ctx *context.Context) {
+ opts := &packages_model.PackageSearchOptions{
+ OwnerID: ctx.Package.Owner.ID,
+ Type: packages_model.TypeChef,
+ Name: packages_model.SearchValue{Value: ctx.FormTrim("q")},
+ IsInternal: optional.Some(false),
+ Paginator: db.NewAbsoluteListOptions(
+ ctx.FormInt("start"),
+ ctx.FormInt("items"),
+ ),
+ }
+
+ switch strings.ToLower(ctx.FormTrim("order")) {
+ case "recently_updated", "recently_added":
+ opts.Sort = packages_model.SortCreatedDesc
+ default:
+ opts.Sort = packages_model.SortNameAsc
+ }
+
+ pvs, total, err := packages_model.SearchLatestVersions(ctx, opts)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ pds, err := packages_model.GetPackageDescriptors(ctx, pvs)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ type Item struct {
+ CookbookName string `json:"cookbook_name"`
+ CookbookMaintainer string `json:"cookbook_maintainer"`
+ CookbookDescription string `json:"cookbook_description"`
+ Cookbook string `json:"cookbook"`
+ }
+
+ type Result struct {
+ Start int `json:"start"`
+ Total int `json:"total"`
+ Items []*Item `json:"items"`
+ }
+
+ baseURL := setting.AppURL + "api/packages/" + ctx.Package.Owner.Name + "/chef/api/v1/cookbooks/"
+
+ items := make([]*Item, 0, len(pds))
+ for _, pd := range pds {
+ metadata := pd.Metadata.(*chef_module.Metadata)
+
+ items = append(items, &Item{
+ CookbookName: pd.Package.Name,
+ CookbookMaintainer: metadata.Author,
+ CookbookDescription: metadata.Description,
+ Cookbook: baseURL + url.PathEscape(pd.Package.Name),
+ })
+ }
+
+ skip, _ := opts.Paginator.GetSkipTake()
+
+ ctx.JSON(http.StatusOK, &Result{
+ Start: skip,
+ Total: int(total),
+ Items: items,
+ })
+}
+
+// https://github.com/chef/chef/blob/main/knife/lib/chef/knife/supermarket_show.rb
+func PackageMetadata(ctx *context.Context) {
+ packageName := ctx.Params("name")
+
+ pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeChef, packageName)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ if len(pvs) == 0 {
+ apiError(ctx, http.StatusNotFound, nil)
+ return
+ }
+
+ pds, err := packages_model.GetPackageDescriptors(ctx, pvs)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ sort.Slice(pds, func(i, j int) bool {
+ return pds[i].SemVer.LessThan(pds[j].SemVer)
+ })
+
+ type Result struct {
+ Name string `json:"name"`
+ Maintainer string `json:"maintainer"`
+ Description string `json:"description"`
+ Category string `json:"category"`
+ LatestVersion string `json:"latest_version"`
+ SourceURL string `json:"source_url"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ Deprecated bool `json:"deprecated"`
+ Versions []string `json:"versions"`
+ }
+
+ baseURL := fmt.Sprintf("%sapi/packages/%s/chef/api/v1/cookbooks/%s/versions/", setting.AppURL, ctx.Package.Owner.Name, url.PathEscape(packageName))
+
+ versions := make([]string, 0, len(pds))
+ for _, pd := range pds {
+ versions = append(versions, baseURL+pd.Version.Version)
+ }
+
+ latest := pds[len(pds)-1]
+
+ metadata := latest.Metadata.(*chef_module.Metadata)
+
+ ctx.JSON(http.StatusOK, &Result{
+ Name: latest.Package.Name,
+ Maintainer: metadata.Author,
+ Description: metadata.Description,
+ LatestVersion: baseURL + latest.Version.Version,
+ SourceURL: metadata.RepositoryURL,
+ CreatedAt: latest.Version.CreatedUnix.AsLocalTime(),
+ UpdatedAt: latest.Version.CreatedUnix.AsLocalTime(),
+ Deprecated: false,
+ Versions: versions,
+ })
+}
+
+// https://github.com/chef/chef/blob/main/knife/lib/chef/knife/supermarket_show.rb
+func PackageVersionMetadata(ctx *context.Context) {
+ packageName := ctx.Params("name")
+ packageVersion := strings.ReplaceAll(ctx.Params("version"), "_", ".") // Chef calls this endpoint with "_" instead of "."?!
+
+ pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeChef, packageName, packageVersion)
+ if err != nil {
+ if err == packages_model.ErrPackageNotExist {
+ apiError(ctx, http.StatusNotFound, err)
+ return
+ }
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ pd, err := packages_model.GetPackageDescriptor(ctx, pv)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ type Result struct {
+ Version string `json:"version"`
+ TarballFileSize int64 `json:"tarball_file_size"`
+ PublishedAt time.Time `json:"published_at"`
+ Cookbook string `json:"cookbook"`
+ File string `json:"file"`
+ License string `json:"license"`
+ Dependencies map[string]string `json:"dependencies"`
+ }
+
+ baseURL := fmt.Sprintf("%sapi/packages/%s/chef/api/v1/cookbooks/%s", setting.AppURL, ctx.Package.Owner.Name, url.PathEscape(pd.Package.Name))
+
+ metadata := pd.Metadata.(*chef_module.Metadata)
+
+ ctx.JSON(http.StatusOK, &Result{
+ Version: pd.Version.Version,
+ TarballFileSize: pd.Files[0].Blob.Size,
+ PublishedAt: pd.Version.CreatedUnix.AsLocalTime(),
+ Cookbook: baseURL,
+ File: fmt.Sprintf("%s/versions/%s/download", baseURL, pd.Version.Version),
+ License: metadata.License,
+ Dependencies: metadata.Dependencies,
+ })
+}
+
+// https://github.com/chef/chef/blob/main/knife/lib/chef/knife/supermarket_share.rb
+func UploadPackage(ctx *context.Context) {
+ file, _, err := ctx.Req.FormFile("tarball")
+ if err != nil {
+ apiError(ctx, http.StatusBadRequest, err)
+ return
+ }
+ defer file.Close()
+
+ buf, err := packages_module.CreateHashedBufferFromReader(file)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ defer buf.Close()
+
+ pck, err := chef_module.ParsePackage(buf)
+ if err != nil {
+ if errors.Is(err, util.ErrInvalidArgument) {
+ apiError(ctx, http.StatusBadRequest, err)
+ } else {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ if _, err := buf.Seek(0, io.SeekStart); err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ _, _, err = packages_service.CreatePackageAndAddFile(
+ ctx,
+ &packages_service.PackageCreationInfo{
+ PackageInfo: packages_service.PackageInfo{
+ Owner: ctx.Package.Owner,
+ PackageType: packages_model.TypeChef,
+ Name: pck.Name,
+ Version: pck.Version,
+ },
+ Creator: ctx.Doer,
+ SemverCompatible: true,
+ Metadata: pck.Metadata,
+ },
+ &packages_service.PackageFileCreationInfo{
+ PackageFileInfo: packages_service.PackageFileInfo{
+ Filename: strings.ToLower(pck.Version + ".tar.gz"),
+ },
+ Creator: ctx.Doer,
+ Data: buf,
+ IsLead: true,
+ },
+ )
+ if err != nil {
+ switch err {
+ case packages_model.ErrDuplicatePackageVersion:
+ apiError(ctx, http.StatusConflict, err)
+ case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize:
+ apiError(ctx, http.StatusForbidden, err)
+ default:
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ ctx.JSON(http.StatusCreated, make(map[any]any))
+}
+
+// https://github.com/chef/chef/blob/main/knife/lib/chef/knife/supermarket_download.rb
+func DownloadPackage(ctx *context.Context) {
+ pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeChef, ctx.Params("name"), ctx.Params("version"))
+ if err != nil {
+ if err == packages_model.ErrPackageNotExist {
+ apiError(ctx, http.StatusNotFound, err)
+ return
+ }
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ pd, err := packages_model.GetPackageDescriptor(ctx, pv)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ pf := pd.Files[0].File
+
+ s, u, _, err := packages_service.GetPackageFileStream(ctx, pf)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ helper.ServePackageFile(ctx, s, u, pf)
+}
+
+// https://github.com/chef/chef/blob/main/knife/lib/chef/knife/supermarket_unshare.rb
+func DeletePackageVersion(ctx *context.Context) {
+ packageName := ctx.Params("name")
+ packageVersion := ctx.Params("version")
+
+ err := packages_service.RemovePackageVersionByNameAndVersion(
+ ctx,
+ ctx.Doer,
+ &packages_service.PackageInfo{
+ Owner: ctx.Package.Owner,
+ PackageType: packages_model.TypeChef,
+ Name: packageName,
+ Version: packageVersion,
+ },
+ )
+ if err != nil {
+ if err == packages_model.ErrPackageNotExist {
+ apiError(ctx, http.StatusNotFound, err)
+ } else {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ ctx.Status(http.StatusOK)
+}
+
+// https://github.com/chef/chef/blob/main/knife/lib/chef/knife/supermarket_unshare.rb
+func DeletePackage(ctx *context.Context) {
+ pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeChef, ctx.Params("name"))
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ if len(pvs) == 0 {
+ apiError(ctx, http.StatusNotFound, err)
+ return
+ }
+
+ for _, pv := range pvs {
+ if err := packages_service.RemovePackageVersion(ctx, ctx.Doer, pv); err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ }
+
+ ctx.Status(http.StatusOK)
+}
diff --git a/routers/api/packages/composer/api.go b/routers/api/packages/composer/api.go
new file mode 100644
index 0000000..a3bcf80
--- /dev/null
+++ b/routers/api/packages/composer/api.go
@@ -0,0 +1,117 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package composer
+
+import (
+ "fmt"
+ "net/url"
+ "time"
+
+ packages_model "code.gitea.io/gitea/models/packages"
+ composer_module "code.gitea.io/gitea/modules/packages/composer"
+)
+
+// ServiceIndexResponse contains registry endpoints
+type ServiceIndexResponse struct {
+ SearchTemplate string `json:"search"`
+ MetadataTemplate string `json:"metadata-url"`
+ PackageList string `json:"list"`
+}
+
+func createServiceIndexResponse(registryURL string) *ServiceIndexResponse {
+ return &ServiceIndexResponse{
+ SearchTemplate: registryURL + "/search.json?q=%query%&type=%type%",
+ MetadataTemplate: registryURL + "/p2/%package%.json",
+ PackageList: registryURL + "/list.json",
+ }
+}
+
+// SearchResultResponse contains search results
+type SearchResultResponse struct {
+ Total int64 `json:"total"`
+ Results []*SearchResult `json:"results"`
+ NextLink string `json:"next,omitempty"`
+}
+
+// SearchResult contains a search result
+type SearchResult struct {
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Downloads int64 `json:"downloads"`
+}
+
+func createSearchResultResponse(total int64, pds []*packages_model.PackageDescriptor, nextLink string) *SearchResultResponse {
+ results := make([]*SearchResult, 0, len(pds))
+
+ for _, pd := range pds {
+ results = append(results, &SearchResult{
+ Name: pd.Package.Name,
+ Description: pd.Metadata.(*composer_module.Metadata).Description,
+ Downloads: pd.Version.DownloadCount,
+ })
+ }
+
+ return &SearchResultResponse{
+ Total: total,
+ Results: results,
+ NextLink: nextLink,
+ }
+}
+
+// PackageMetadataResponse contains packages metadata
+type PackageMetadataResponse struct {
+ Minified string `json:"minified"`
+ Packages map[string][]*PackageVersionMetadata `json:"packages"`
+}
+
+// PackageVersionMetadata contains package metadata
+type PackageVersionMetadata struct {
+ *composer_module.Metadata
+ Name string `json:"name"`
+ Version string `json:"version"`
+ Type string `json:"type"`
+ Created time.Time `json:"time"`
+ Dist Dist `json:"dist"`
+}
+
+// Dist contains package download information
+type Dist struct {
+ Type string `json:"type"`
+ URL string `json:"url"`
+ Checksum string `json:"shasum"`
+}
+
+func createPackageMetadataResponse(registryURL string, pds []*packages_model.PackageDescriptor) *PackageMetadataResponse {
+ versions := make([]*PackageVersionMetadata, 0, len(pds))
+
+ for _, pd := range pds {
+ packageType := ""
+ for _, pvp := range pd.VersionProperties {
+ if pvp.Name == composer_module.TypeProperty {
+ packageType = pvp.Value
+ break
+ }
+ }
+
+ versions = append(versions, &PackageVersionMetadata{
+ Name: pd.Package.Name,
+ Version: pd.Version.Version,
+ Type: packageType,
+ Created: pd.Version.CreatedUnix.AsLocalTime(),
+ Metadata: pd.Metadata.(*composer_module.Metadata),
+ Dist: Dist{
+ Type: "zip",
+ URL: fmt.Sprintf("%s/files/%s/%s/%s", registryURL, url.PathEscape(pd.Package.LowerName), url.PathEscape(pd.Version.LowerVersion), url.PathEscape(pd.Files[0].File.LowerName)),
+ Checksum: pd.Files[0].Blob.HashSHA1,
+ },
+ })
+ }
+
+ return &PackageMetadataResponse{
+ Minified: "composer/2.0",
+ Packages: map[string][]*PackageVersionMetadata{
+ pds[0].Package.Name: versions,
+ },
+ }
+}
diff --git a/routers/api/packages/composer/composer.go b/routers/api/packages/composer/composer.go
new file mode 100644
index 0000000..a045da4
--- /dev/null
+++ b/routers/api/packages/composer/composer.go
@@ -0,0 +1,261 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package composer
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+
+ "code.gitea.io/gitea/models/db"
+ packages_model "code.gitea.io/gitea/models/packages"
+ "code.gitea.io/gitea/modules/optional"
+ packages_module "code.gitea.io/gitea/modules/packages"
+ composer_module "code.gitea.io/gitea/modules/packages/composer"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/routers/api/packages/helper"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/convert"
+ packages_service "code.gitea.io/gitea/services/packages"
+
+ "github.com/hashicorp/go-version"
+)
+
+func apiError(ctx *context.Context, status int, obj any) {
+ helper.LogAndProcessError(ctx, status, obj, func(message string) {
+ type Error struct {
+ Status int `json:"status"`
+ Message string `json:"message"`
+ }
+ ctx.JSON(status, struct {
+ Errors []Error `json:"errors"`
+ }{
+ Errors: []Error{
+ {Status: status, Message: message},
+ },
+ })
+ })
+}
+
+// ServiceIndex displays registry endpoints
+func ServiceIndex(ctx *context.Context) {
+ resp := createServiceIndexResponse(setting.AppURL + "api/packages/" + ctx.Package.Owner.Name + "/composer")
+
+ ctx.JSON(http.StatusOK, resp)
+}
+
+// SearchPackages searches packages, only "q" is supported
+// https://packagist.org/apidoc#search-packages
+func SearchPackages(ctx *context.Context) {
+ page := ctx.FormInt("page")
+ if page < 1 {
+ page = 1
+ }
+ perPage := ctx.FormInt("per_page")
+ paginator := db.ListOptions{
+ Page: page,
+ PageSize: convert.ToCorrectPageSize(perPage),
+ }
+
+ opts := &packages_model.PackageSearchOptions{
+ OwnerID: ctx.Package.Owner.ID,
+ Type: packages_model.TypeComposer,
+ Name: packages_model.SearchValue{Value: ctx.FormTrim("q")},
+ IsInternal: optional.Some(false),
+ Paginator: &paginator,
+ }
+ if ctx.FormTrim("type") != "" {
+ opts.Properties = map[string]string{
+ composer_module.TypeProperty: ctx.FormTrim("type"),
+ }
+ }
+
+ pvs, total, err := packages_model.SearchLatestVersions(ctx, opts)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ nextLink := ""
+ if len(pvs) == paginator.PageSize {
+ u, err := url.Parse(setting.AppURL + "api/packages/" + ctx.Package.Owner.Name + "/composer/search.json")
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ q := u.Query()
+ q.Set("q", ctx.FormTrim("q"))
+ q.Set("type", ctx.FormTrim("type"))
+ q.Set("page", strconv.Itoa(page+1))
+ if perPage != 0 {
+ q.Set("per_page", strconv.Itoa(perPage))
+ }
+ u.RawQuery = q.Encode()
+
+ nextLink = u.String()
+ }
+
+ pds, err := packages_model.GetPackageDescriptors(ctx, pvs)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ resp := createSearchResultResponse(total, pds, nextLink)
+
+ ctx.JSON(http.StatusOK, resp)
+}
+
+// EnumeratePackages lists all package names
+// https://packagist.org/apidoc#list-packages
+func EnumeratePackages(ctx *context.Context) {
+ ps, err := packages_model.GetPackagesByType(ctx, ctx.Package.Owner.ID, packages_model.TypeComposer)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ names := make([]string, 0, len(ps))
+ for _, p := range ps {
+ names = append(names, p.Name)
+ }
+
+ ctx.JSON(http.StatusOK, map[string][]string{
+ "packageNames": names,
+ })
+}
+
+// PackageMetadata returns the metadata for a single package
+// https://packagist.org/apidoc#get-package-data
+func PackageMetadata(ctx *context.Context) {
+ vendorName := ctx.Params("vendorname")
+ projectName := ctx.Params("projectname")
+
+ pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeComposer, vendorName+"/"+projectName)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ if len(pvs) == 0 {
+ apiError(ctx, http.StatusNotFound, packages_model.ErrPackageNotExist)
+ return
+ }
+
+ pds, err := packages_model.GetPackageDescriptors(ctx, pvs)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ resp := createPackageMetadataResponse(
+ setting.AppURL+"api/packages/"+ctx.Package.Owner.Name+"/composer",
+ pds,
+ )
+
+ ctx.JSON(http.StatusOK, resp)
+}
+
+// DownloadPackageFile serves the content of a package
+func DownloadPackageFile(ctx *context.Context) {
+ s, u, pf, err := packages_service.GetFileStreamByPackageNameAndVersion(
+ ctx,
+ &packages_service.PackageInfo{
+ Owner: ctx.Package.Owner,
+ PackageType: packages_model.TypeComposer,
+ Name: ctx.Params("package"),
+ Version: ctx.Params("version"),
+ },
+ &packages_service.PackageFileInfo{
+ Filename: ctx.Params("filename"),
+ },
+ )
+ if err != nil {
+ if err == packages_model.ErrPackageNotExist || err == packages_model.ErrPackageFileNotExist {
+ apiError(ctx, http.StatusNotFound, err)
+ return
+ }
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ helper.ServePackageFile(ctx, s, u, pf)
+}
+
+// UploadPackage creates a new package
+func UploadPackage(ctx *context.Context) {
+ buf, err := packages_module.CreateHashedBufferFromReader(ctx.Req.Body)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ defer buf.Close()
+
+ cp, err := composer_module.ParsePackage(buf, buf.Size())
+ if err != nil {
+ if errors.Is(err, util.ErrInvalidArgument) {
+ apiError(ctx, http.StatusBadRequest, err)
+ } else {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ if _, err := buf.Seek(0, io.SeekStart); err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ if cp.Version == "" {
+ v, err := version.NewVersion(ctx.FormTrim("version"))
+ if err != nil {
+ apiError(ctx, http.StatusBadRequest, composer_module.ErrInvalidVersion)
+ return
+ }
+ cp.Version = v.String()
+ }
+
+ _, _, err = packages_service.CreatePackageAndAddFile(
+ ctx,
+ &packages_service.PackageCreationInfo{
+ PackageInfo: packages_service.PackageInfo{
+ Owner: ctx.Package.Owner,
+ PackageType: packages_model.TypeComposer,
+ Name: cp.Name,
+ Version: cp.Version,
+ },
+ SemverCompatible: true,
+ Creator: ctx.Doer,
+ Metadata: cp.Metadata,
+ VersionProperties: map[string]string{
+ composer_module.TypeProperty: cp.Type,
+ },
+ },
+ &packages_service.PackageFileCreationInfo{
+ PackageFileInfo: packages_service.PackageFileInfo{
+ Filename: strings.ToLower(fmt.Sprintf("%s.%s.zip", strings.ReplaceAll(cp.Name, "/", "-"), cp.Version)),
+ },
+ Creator: ctx.Doer,
+ Data: buf,
+ IsLead: true,
+ },
+ )
+ if err != nil {
+ switch err {
+ case packages_model.ErrDuplicatePackageVersion:
+ apiError(ctx, http.StatusConflict, err)
+ case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize:
+ apiError(ctx, http.StatusForbidden, err)
+ default:
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ ctx.Status(http.StatusCreated)
+}
diff --git a/routers/api/packages/conan/auth.go b/routers/api/packages/conan/auth.go
new file mode 100644
index 0000000..e2e1901
--- /dev/null
+++ b/routers/api/packages/conan/auth.go
@@ -0,0 +1,48 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package conan
+
+import (
+ "net/http"
+
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/services/auth"
+ "code.gitea.io/gitea/services/packages"
+)
+
+var _ auth.Method = &Auth{}
+
+type Auth struct{}
+
+func (a *Auth) Name() string {
+ return "conan"
+}
+
+// Verify extracts the user from the Bearer token
+func (a *Auth) Verify(req *http.Request, w http.ResponseWriter, store auth.DataStore, sess auth.SessionStore) (*user_model.User, error) {
+ uid, scope, err := packages.ParseAuthorizationToken(req)
+ if err != nil {
+ log.Trace("ParseAuthorizationToken: %v", err)
+ return nil, err
+ }
+
+ if uid == 0 {
+ return nil, nil
+ }
+
+ // Propagate scope of the authorization token.
+ if scope != "" {
+ store.GetData()["IsApiToken"] = true
+ store.GetData()["ApiTokenScope"] = scope
+ }
+
+ u, err := user_model.GetUserByID(req.Context(), uid)
+ if err != nil {
+ log.Error("GetUserByID: %v", err)
+ return nil, err
+ }
+
+ return u, nil
+}
diff --git a/routers/api/packages/conan/conan.go b/routers/api/packages/conan/conan.go
new file mode 100644
index 0000000..e07907a
--- /dev/null
+++ b/routers/api/packages/conan/conan.go
@@ -0,0 +1,807 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package conan
+
+import (
+ std_ctx "context"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ packages_model "code.gitea.io/gitea/models/packages"
+ conan_model "code.gitea.io/gitea/models/packages/conan"
+ "code.gitea.io/gitea/modules/container"
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/log"
+ packages_module "code.gitea.io/gitea/modules/packages"
+ conan_module "code.gitea.io/gitea/modules/packages/conan"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/routers/api/packages/helper"
+ "code.gitea.io/gitea/services/context"
+ notify_service "code.gitea.io/gitea/services/notify"
+ packages_service "code.gitea.io/gitea/services/packages"
+)
+
+const (
+ conanfileFile = "conanfile.py"
+ conaninfoFile = "conaninfo.txt"
+
+ recipeReferenceKey = "RecipeReference"
+ packageReferenceKey = "PackageReference"
+)
+
+var (
+ recipeFileList = container.SetOf(
+ conanfileFile,
+ "conanmanifest.txt",
+ "conan_sources.tgz",
+ "conan_export.tgz",
+ )
+ packageFileList = container.SetOf(
+ conaninfoFile,
+ "conanmanifest.txt",
+ "conan_package.tgz",
+ )
+)
+
+func jsonResponse(ctx *context.Context, status int, obj any) {
+ // https://github.com/conan-io/conan/issues/6613
+ ctx.Resp.Header().Set("Content-Type", "application/json")
+ ctx.Status(status)
+ if err := json.NewEncoder(ctx.Resp).Encode(obj); err != nil {
+ log.Error("JSON encode: %v", err)
+ }
+}
+
+func apiError(ctx *context.Context, status int, obj any) {
+ helper.LogAndProcessError(ctx, status, obj, func(message string) {
+ jsonResponse(ctx, status, map[string]string{
+ "message": message,
+ })
+ })
+}
+
+func baseURL(ctx *context.Context) string {
+ return setting.AppURL + "api/packages/" + ctx.Package.Owner.Name + "/conan"
+}
+
+// ExtractPathParameters is a middleware to extract common parameters from path
+func ExtractPathParameters(ctx *context.Context) {
+ rref, err := conan_module.NewRecipeReference(
+ ctx.Params("name"),
+ ctx.Params("version"),
+ ctx.Params("user"),
+ ctx.Params("channel"),
+ ctx.Params("recipe_revision"),
+ )
+ if err != nil {
+ apiError(ctx, http.StatusBadRequest, err)
+ return
+ }
+
+ ctx.Data[recipeReferenceKey] = rref
+
+ reference := ctx.Params("package_reference")
+
+ var pref *conan_module.PackageReference
+ if reference != "" {
+ pref, err = conan_module.NewPackageReference(
+ rref,
+ reference,
+ ctx.Params("package_revision"),
+ )
+ if err != nil {
+ apiError(ctx, http.StatusBadRequest, err)
+ return
+ }
+ }
+
+ ctx.Data[packageReferenceKey] = pref
+}
+
+// Ping reports the server capabilities
+func Ping(ctx *context.Context) {
+ ctx.RespHeader().Add("X-Conan-Server-Capabilities", "revisions") // complex_search,checksum_deploy,matrix_params
+
+ ctx.Status(http.StatusOK)
+}
+
+// Authenticate creates an authentication token for the user
+func Authenticate(ctx *context.Context) {
+ if ctx.Doer == nil {
+ apiError(ctx, http.StatusBadRequest, nil)
+ return
+ }
+
+ // If there's an API scope, ensure it propagates.
+ scope, _ := ctx.Data.GetData()["ApiTokenScope"].(auth_model.AccessTokenScope)
+
+ token, err := packages_service.CreateAuthorizationToken(ctx.Doer, scope)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ ctx.PlainText(http.StatusOK, token)
+}
+
+// CheckCredentials tests if the provided authentication token is valid
+func CheckCredentials(ctx *context.Context) {
+ if ctx.Doer == nil {
+ ctx.Status(http.StatusUnauthorized)
+ } else {
+ ctx.Status(http.StatusOK)
+ }
+}
+
+// RecipeSnapshot displays the recipe files with their md5 hash
+func RecipeSnapshot(ctx *context.Context) {
+ rref := ctx.Data[recipeReferenceKey].(*conan_module.RecipeReference)
+
+ serveSnapshot(ctx, rref.AsKey())
+}
+
+// RecipeSnapshot displays the package files with their md5 hash
+func PackageSnapshot(ctx *context.Context) {
+ pref := ctx.Data[packageReferenceKey].(*conan_module.PackageReference)
+
+ serveSnapshot(ctx, pref.AsKey())
+}
+
+func serveSnapshot(ctx *context.Context, fileKey string) {
+ rref := ctx.Data[recipeReferenceKey].(*conan_module.RecipeReference)
+
+ pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeConan, rref.Name, rref.Version)
+ if err != nil {
+ if err == packages_model.ErrPackageNotExist {
+ apiError(ctx, http.StatusNotFound, err)
+ } else {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ pfs, _, err := packages_model.SearchFiles(ctx, &packages_model.PackageFileSearchOptions{
+ VersionID: pv.ID,
+ CompositeKey: fileKey,
+ })
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ if len(pfs) == 0 {
+ apiError(ctx, http.StatusNotFound, nil)
+ return
+ }
+
+ files := make(map[string]string)
+ for _, pf := range pfs {
+ pb, err := packages_model.GetBlobByID(ctx, pf.BlobID)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ files[pf.Name] = pb.HashMD5
+ }
+
+ jsonResponse(ctx, http.StatusOK, files)
+}
+
+// RecipeDownloadURLs displays the recipe files with their download url
+func RecipeDownloadURLs(ctx *context.Context) {
+ rref := ctx.Data[recipeReferenceKey].(*conan_module.RecipeReference)
+
+ serveDownloadURLs(
+ ctx,
+ rref.AsKey(),
+ fmt.Sprintf(baseURL(ctx)+"/v1/files/%s/recipe", rref.LinkName()),
+ )
+}
+
+// PackageDownloadURLs displays the package files with their download url
+func PackageDownloadURLs(ctx *context.Context) {
+ pref := ctx.Data[packageReferenceKey].(*conan_module.PackageReference)
+
+ serveDownloadURLs(
+ ctx,
+ pref.AsKey(),
+ fmt.Sprintf(baseURL(ctx)+"/v1/files/%s/package/%s", pref.Recipe.LinkName(), pref.LinkName()),
+ )
+}
+
+func serveDownloadURLs(ctx *context.Context, fileKey, downloadURL string) {
+ rref := ctx.Data[recipeReferenceKey].(*conan_module.RecipeReference)
+
+ pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeConan, rref.Name, rref.Version)
+ if err != nil {
+ if err == packages_model.ErrPackageNotExist {
+ apiError(ctx, http.StatusNotFound, err)
+ } else {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ pfs, _, err := packages_model.SearchFiles(ctx, &packages_model.PackageFileSearchOptions{
+ VersionID: pv.ID,
+ CompositeKey: fileKey,
+ })
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ if len(pfs) == 0 {
+ apiError(ctx, http.StatusNotFound, nil)
+ return
+ }
+
+ urls := make(map[string]string)
+ for _, pf := range pfs {
+ urls[pf.Name] = fmt.Sprintf("%s/%s", downloadURL, pf.Name)
+ }
+
+ jsonResponse(ctx, http.StatusOK, urls)
+}
+
+// RecipeUploadURLs displays the upload urls for the provided recipe files
+func RecipeUploadURLs(ctx *context.Context) {
+ rref := ctx.Data[recipeReferenceKey].(*conan_module.RecipeReference)
+
+ serveUploadURLs(
+ ctx,
+ recipeFileList,
+ fmt.Sprintf(baseURL(ctx)+"/v1/files/%s/recipe", rref.LinkName()),
+ )
+}
+
+// PackageUploadURLs displays the upload urls for the provided package files
+func PackageUploadURLs(ctx *context.Context) {
+ pref := ctx.Data[packageReferenceKey].(*conan_module.PackageReference)
+
+ serveUploadURLs(
+ ctx,
+ packageFileList,
+ fmt.Sprintf(baseURL(ctx)+"/v1/files/%s/package/%s", pref.Recipe.LinkName(), pref.LinkName()),
+ )
+}
+
+func serveUploadURLs(ctx *context.Context, fileFilter container.Set[string], uploadURL string) {
+ defer ctx.Req.Body.Close()
+
+ var files map[string]int64
+ if err := json.NewDecoder(ctx.Req.Body).Decode(&files); err != nil {
+ apiError(ctx, http.StatusBadRequest, err)
+ return
+ }
+
+ urls := make(map[string]string)
+ for file := range files {
+ if fileFilter.Contains(file) {
+ urls[file] = fmt.Sprintf("%s/%s", uploadURL, file)
+ }
+ }
+
+ jsonResponse(ctx, http.StatusOK, urls)
+}
+
+// UploadRecipeFile handles the upload of a recipe file
+func UploadRecipeFile(ctx *context.Context) {
+ rref := ctx.Data[recipeReferenceKey].(*conan_module.RecipeReference)
+
+ uploadFile(ctx, recipeFileList, rref.AsKey())
+}
+
+// UploadPackageFile handles the upload of a package file
+func UploadPackageFile(ctx *context.Context) {
+ pref := ctx.Data[packageReferenceKey].(*conan_module.PackageReference)
+
+ uploadFile(ctx, packageFileList, pref.AsKey())
+}
+
+func uploadFile(ctx *context.Context, fileFilter container.Set[string], fileKey string) {
+ rref := ctx.Data[recipeReferenceKey].(*conan_module.RecipeReference)
+ pref := ctx.Data[packageReferenceKey].(*conan_module.PackageReference)
+
+ filename := ctx.Params("filename")
+ if !fileFilter.Contains(filename) {
+ apiError(ctx, http.StatusBadRequest, nil)
+ return
+ }
+
+ upload, needToClose, err := ctx.UploadStream()
+ if err != nil {
+ apiError(ctx, http.StatusBadRequest, err)
+ return
+ }
+ if needToClose {
+ defer upload.Close()
+ }
+
+ buf, err := packages_module.CreateHashedBufferFromReader(upload)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ defer buf.Close()
+
+ isConanfileFile := filename == conanfileFile
+ isConaninfoFile := filename == conaninfoFile
+
+ pci := &packages_service.PackageCreationInfo{
+ PackageInfo: packages_service.PackageInfo{
+ Owner: ctx.Package.Owner,
+ PackageType: packages_model.TypeConan,
+ Name: rref.Name,
+ Version: rref.Version,
+ },
+ Creator: ctx.Doer,
+ }
+ pfci := &packages_service.PackageFileCreationInfo{
+ PackageFileInfo: packages_service.PackageFileInfo{
+ Filename: strings.ToLower(filename),
+ CompositeKey: fileKey,
+ },
+ Creator: ctx.Doer,
+ Data: buf,
+ IsLead: isConanfileFile,
+ Properties: map[string]string{
+ conan_module.PropertyRecipeUser: rref.User,
+ conan_module.PropertyRecipeChannel: rref.Channel,
+ conan_module.PropertyRecipeRevision: rref.RevisionOrDefault(),
+ },
+ OverwriteExisting: true,
+ }
+
+ if pref != nil {
+ pfci.Properties[conan_module.PropertyPackageReference] = pref.Reference
+ pfci.Properties[conan_module.PropertyPackageRevision] = pref.RevisionOrDefault()
+ }
+
+ if isConanfileFile || isConaninfoFile {
+ if isConanfileFile {
+ metadata, err := conan_module.ParseConanfile(buf)
+ if err != nil {
+ log.Error("Error parsing package metadata: %v", err)
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ pv, err := packages_model.GetVersionByNameAndVersion(ctx, pci.Owner.ID, pci.PackageType, pci.Name, pci.Version)
+ if err != nil && err != packages_model.ErrPackageNotExist {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ if pv != nil {
+ raw, err := json.Marshal(metadata)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ pv.MetadataJSON = string(raw)
+ if err := packages_model.UpdateVersion(ctx, pv); err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ } else {
+ pci.Metadata = metadata
+ }
+ } else {
+ info, err := conan_module.ParseConaninfo(buf)
+ if err != nil {
+ log.Error("Error parsing conan info: %v", err)
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ raw, err := json.Marshal(info)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ pfci.Properties[conan_module.PropertyPackageInfo] = string(raw)
+ }
+
+ if _, err := buf.Seek(0, io.SeekStart); err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ }
+
+ _, _, err = packages_service.CreatePackageOrAddFileToExisting(
+ ctx,
+ pci,
+ pfci,
+ )
+ if err != nil {
+ switch err {
+ case packages_model.ErrDuplicatePackageFile:
+ apiError(ctx, http.StatusConflict, err)
+ case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize:
+ apiError(ctx, http.StatusForbidden, err)
+ default:
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ ctx.Status(http.StatusCreated)
+}
+
+// DownloadRecipeFile serves the content of the requested recipe file
+func DownloadRecipeFile(ctx *context.Context) {
+ rref := ctx.Data[recipeReferenceKey].(*conan_module.RecipeReference)
+
+ downloadFile(ctx, recipeFileList, rref.AsKey())
+}
+
+// DownloadPackageFile serves the content of the requested package file
+func DownloadPackageFile(ctx *context.Context) {
+ pref := ctx.Data[packageReferenceKey].(*conan_module.PackageReference)
+
+ downloadFile(ctx, packageFileList, pref.AsKey())
+}
+
+func downloadFile(ctx *context.Context, fileFilter container.Set[string], fileKey string) {
+ rref := ctx.Data[recipeReferenceKey].(*conan_module.RecipeReference)
+
+ filename := ctx.Params("filename")
+ if !fileFilter.Contains(filename) {
+ apiError(ctx, http.StatusBadRequest, nil)
+ return
+ }
+
+ s, u, pf, err := packages_service.GetFileStreamByPackageNameAndVersion(
+ ctx,
+ &packages_service.PackageInfo{
+ Owner: ctx.Package.Owner,
+ PackageType: packages_model.TypeConan,
+ Name: rref.Name,
+ Version: rref.Version,
+ },
+ &packages_service.PackageFileInfo{
+ Filename: filename,
+ CompositeKey: fileKey,
+ },
+ )
+ if err != nil {
+ if err == packages_model.ErrPackageNotExist || err == packages_model.ErrPackageFileNotExist {
+ apiError(ctx, http.StatusNotFound, err)
+ return
+ }
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ helper.ServePackageFile(ctx, s, u, pf)
+}
+
+// DeleteRecipeV1 deletes the requested recipe(s)
+func DeleteRecipeV1(ctx *context.Context) {
+ rref := ctx.Data[recipeReferenceKey].(*conan_module.RecipeReference)
+
+ if err := deleteRecipeOrPackage(ctx, rref, true, nil, false); err != nil {
+ if err == packages_model.ErrPackageNotExist || err == conan_model.ErrPackageReferenceNotExist {
+ apiError(ctx, http.StatusNotFound, err)
+ } else {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+ ctx.Status(http.StatusOK)
+}
+
+// DeleteRecipeV2 deletes the requested recipe(s) respecting its revisions
+func DeleteRecipeV2(ctx *context.Context) {
+ rref := ctx.Data[recipeReferenceKey].(*conan_module.RecipeReference)
+
+ if err := deleteRecipeOrPackage(ctx, rref, rref.Revision == "", nil, false); err != nil {
+ if err == packages_model.ErrPackageNotExist || err == conan_model.ErrPackageReferenceNotExist {
+ apiError(ctx, http.StatusNotFound, err)
+ } else {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+ ctx.Status(http.StatusOK)
+}
+
+// DeletePackageV1 deletes the requested package(s)
+func DeletePackageV1(ctx *context.Context) {
+ rref := ctx.Data[recipeReferenceKey].(*conan_module.RecipeReference)
+
+ type PackageReferences struct {
+ References []string `json:"package_ids"`
+ }
+
+ var ids *PackageReferences
+ if err := json.NewDecoder(ctx.Req.Body).Decode(&ids); err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ revisions, err := conan_model.GetRecipeRevisions(ctx, ctx.Package.Owner.ID, rref)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ for _, revision := range revisions {
+ currentRref := rref.WithRevision(revision.Value)
+
+ var references []*conan_model.PropertyValue
+ if len(ids.References) == 0 {
+ if references, err = conan_model.GetPackageReferences(ctx, ctx.Package.Owner.ID, currentRref); err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ } else {
+ for _, reference := range ids.References {
+ references = append(references, &conan_model.PropertyValue{Value: reference})
+ }
+ }
+
+ for _, reference := range references {
+ pref, _ := conan_module.NewPackageReference(currentRref, reference.Value, conan_module.DefaultRevision)
+ if err := deleteRecipeOrPackage(ctx, currentRref, true, pref, true); err != nil {
+ if err == packages_model.ErrPackageNotExist || err == conan_model.ErrPackageReferenceNotExist {
+ apiError(ctx, http.StatusNotFound, err)
+ } else {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+ }
+ }
+ ctx.Status(http.StatusOK)
+}
+
+// DeletePackageV2 deletes the requested package(s) respecting its revisions
+func DeletePackageV2(ctx *context.Context) {
+ rref := ctx.Data[recipeReferenceKey].(*conan_module.RecipeReference)
+ pref := ctx.Data[packageReferenceKey].(*conan_module.PackageReference)
+
+ if pref != nil { // has package reference
+ if err := deleteRecipeOrPackage(ctx, rref, false, pref, pref.Revision == ""); err != nil {
+ if err == packages_model.ErrPackageNotExist || err == conan_model.ErrPackageReferenceNotExist {
+ apiError(ctx, http.StatusNotFound, err)
+ } else {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ } else {
+ ctx.Status(http.StatusOK)
+ }
+ return
+ }
+
+ references, err := conan_model.GetPackageReferences(ctx, ctx.Package.Owner.ID, rref)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ if len(references) == 0 {
+ apiError(ctx, http.StatusNotFound, conan_model.ErrPackageReferenceNotExist)
+ return
+ }
+
+ for _, reference := range references {
+ pref, _ := conan_module.NewPackageReference(rref, reference.Value, conan_module.DefaultRevision)
+
+ if err := deleteRecipeOrPackage(ctx, rref, false, pref, true); err != nil {
+ if err == packages_model.ErrPackageNotExist || err == conan_model.ErrPackageReferenceNotExist {
+ apiError(ctx, http.StatusNotFound, err)
+ } else {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+ }
+
+ ctx.Status(http.StatusOK)
+}
+
+func deleteRecipeOrPackage(apictx *context.Context, rref *conan_module.RecipeReference, ignoreRecipeRevision bool, pref *conan_module.PackageReference, ignorePackageRevision bool) error {
+ var pd *packages_model.PackageDescriptor
+ versionDeleted := false
+
+ err := db.WithTx(apictx, func(ctx std_ctx.Context) error {
+ pv, err := packages_model.GetVersionByNameAndVersion(ctx, apictx.Package.Owner.ID, packages_model.TypeConan, rref.Name, rref.Version)
+ if err != nil {
+ return err
+ }
+
+ pd, err = packages_model.GetPackageDescriptor(ctx, pv)
+ if err != nil {
+ return err
+ }
+
+ filter := map[string]string{
+ conan_module.PropertyRecipeUser: rref.User,
+ conan_module.PropertyRecipeChannel: rref.Channel,
+ }
+ if !ignoreRecipeRevision {
+ filter[conan_module.PropertyRecipeRevision] = rref.RevisionOrDefault()
+ }
+ if pref != nil {
+ filter[conan_module.PropertyPackageReference] = pref.Reference
+ if !ignorePackageRevision {
+ filter[conan_module.PropertyPackageRevision] = pref.RevisionOrDefault()
+ }
+ }
+
+ pfs, _, err := packages_model.SearchFiles(ctx, &packages_model.PackageFileSearchOptions{
+ VersionID: pv.ID,
+ Properties: filter,
+ })
+ if err != nil {
+ return err
+ }
+ if len(pfs) == 0 {
+ return conan_model.ErrPackageReferenceNotExist
+ }
+
+ for _, pf := range pfs {
+ if err := packages_service.DeletePackageFile(ctx, pf); err != nil {
+ return err
+ }
+ }
+ has, err := packages_model.HasVersionFileReferences(ctx, pv.ID)
+ if err != nil {
+ return err
+ }
+ if !has {
+ versionDeleted = true
+
+ return packages_service.DeletePackageVersionAndReferences(ctx, pv)
+ }
+ return nil
+ })
+ if err != nil {
+ return err
+ }
+
+ if versionDeleted {
+ notify_service.PackageDelete(apictx, apictx.Doer, pd)
+ }
+
+ return nil
+}
+
+// ListRecipeRevisions gets a list of all recipe revisions
+func ListRecipeRevisions(ctx *context.Context) {
+ rref := ctx.Data[recipeReferenceKey].(*conan_module.RecipeReference)
+
+ revisions, err := conan_model.GetRecipeRevisions(ctx, ctx.Package.Owner.ID, rref)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ listRevisions(ctx, revisions)
+}
+
+// ListPackageRevisions gets a list of all package revisions
+func ListPackageRevisions(ctx *context.Context) {
+ pref := ctx.Data[packageReferenceKey].(*conan_module.PackageReference)
+
+ revisions, err := conan_model.GetPackageRevisions(ctx, ctx.Package.Owner.ID, pref)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ listRevisions(ctx, revisions)
+}
+
+type revisionInfo struct {
+ Revision string `json:"revision"`
+ Time time.Time `json:"time"`
+}
+
+func listRevisions(ctx *context.Context, revisions []*conan_model.PropertyValue) {
+ if len(revisions) == 0 {
+ apiError(ctx, http.StatusNotFound, conan_model.ErrRecipeReferenceNotExist)
+ return
+ }
+
+ type RevisionList struct {
+ Revisions []*revisionInfo `json:"revisions"`
+ }
+
+ revs := make([]*revisionInfo, 0, len(revisions))
+ for _, rev := range revisions {
+ revs = append(revs, &revisionInfo{Revision: rev.Value, Time: rev.CreatedUnix.AsLocalTime()})
+ }
+
+ jsonResponse(ctx, http.StatusOK, &RevisionList{revs})
+}
+
+// LatestRecipeRevision gets the latest recipe revision
+func LatestRecipeRevision(ctx *context.Context) {
+ rref := ctx.Data[recipeReferenceKey].(*conan_module.RecipeReference)
+
+ revision, err := conan_model.GetLastRecipeRevision(ctx, ctx.Package.Owner.ID, rref)
+ if err != nil {
+ if err == conan_model.ErrRecipeReferenceNotExist || err == conan_model.ErrPackageReferenceNotExist {
+ apiError(ctx, http.StatusNotFound, err)
+ } else {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ jsonResponse(ctx, http.StatusOK, &revisionInfo{Revision: revision.Value, Time: revision.CreatedUnix.AsLocalTime()})
+}
+
+// LatestPackageRevision gets the latest package revision
+func LatestPackageRevision(ctx *context.Context) {
+ pref := ctx.Data[packageReferenceKey].(*conan_module.PackageReference)
+
+ revision, err := conan_model.GetLastPackageRevision(ctx, ctx.Package.Owner.ID, pref)
+ if err != nil {
+ if err == conan_model.ErrRecipeReferenceNotExist || err == conan_model.ErrPackageReferenceNotExist {
+ apiError(ctx, http.StatusNotFound, err)
+ } else {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ jsonResponse(ctx, http.StatusOK, &revisionInfo{Revision: revision.Value, Time: revision.CreatedUnix.AsLocalTime()})
+}
+
+// ListRecipeRevisionFiles gets a list of all recipe revision files
+func ListRecipeRevisionFiles(ctx *context.Context) {
+ rref := ctx.Data[recipeReferenceKey].(*conan_module.RecipeReference)
+
+ listRevisionFiles(ctx, rref.AsKey())
+}
+
+// ListPackageRevisionFiles gets a list of all package revision files
+func ListPackageRevisionFiles(ctx *context.Context) {
+ pref := ctx.Data[packageReferenceKey].(*conan_module.PackageReference)
+
+ listRevisionFiles(ctx, pref.AsKey())
+}
+
+func listRevisionFiles(ctx *context.Context, fileKey string) {
+ rref := ctx.Data[recipeReferenceKey].(*conan_module.RecipeReference)
+
+ pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeConan, rref.Name, rref.Version)
+ if err != nil {
+ if err == packages_model.ErrPackageNotExist {
+ apiError(ctx, http.StatusNotFound, err)
+ } else {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ pfs, _, err := packages_model.SearchFiles(ctx, &packages_model.PackageFileSearchOptions{
+ VersionID: pv.ID,
+ CompositeKey: fileKey,
+ })
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ if len(pfs) == 0 {
+ apiError(ctx, http.StatusNotFound, nil)
+ return
+ }
+
+ files := make(map[string]any)
+ for _, pf := range pfs {
+ files[pf.Name] = nil
+ }
+
+ type FileList struct {
+ Files map[string]any `json:"files"`
+ }
+
+ jsonResponse(ctx, http.StatusOK, &FileList{
+ Files: files,
+ })
+}
diff --git a/routers/api/packages/conan/search.go b/routers/api/packages/conan/search.go
new file mode 100644
index 0000000..7370c70
--- /dev/null
+++ b/routers/api/packages/conan/search.go
@@ -0,0 +1,163 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package conan
+
+import (
+ "net/http"
+ "strings"
+
+ conan_model "code.gitea.io/gitea/models/packages/conan"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/json"
+ conan_module "code.gitea.io/gitea/modules/packages/conan"
+ "code.gitea.io/gitea/services/context"
+)
+
+// SearchResult contains the found recipe names
+type SearchResult struct {
+ Results []string `json:"results"`
+}
+
+// SearchRecipes searches all recipes matching the query
+func SearchRecipes(ctx *context.Context) {
+ q := ctx.FormTrim("q")
+
+ opts := parseQuery(ctx.Package.Owner, q)
+
+ results, err := conan_model.SearchRecipes(ctx, opts)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ jsonResponse(ctx, http.StatusOK, &SearchResult{
+ Results: results,
+ })
+}
+
+// parseQuery creates search options for the given query
+func parseQuery(owner *user_model.User, query string) *conan_model.RecipeSearchOptions {
+ opts := &conan_model.RecipeSearchOptions{
+ OwnerID: owner.ID,
+ }
+
+ if query != "" {
+ parts := strings.Split(strings.ReplaceAll(query, "@", "/"), "/")
+
+ opts.Name = parts[0]
+ if len(parts) > 1 && parts[1] != "*" {
+ opts.Version = parts[1]
+ }
+ if len(parts) > 2 && parts[2] != "*" {
+ opts.User = parts[2]
+ }
+ if len(parts) > 3 && parts[3] != "*" {
+ opts.Channel = parts[3]
+ }
+ }
+
+ return opts
+}
+
+// SearchPackagesV1 searches all packages of a recipe (Conan v1 endpoint)
+func SearchPackagesV1(ctx *context.Context) {
+ searchPackages(ctx, true)
+}
+
+// SearchPackagesV2 searches all packages of a recipe (Conan v2 endpoint)
+func SearchPackagesV2(ctx *context.Context) {
+ searchPackages(ctx, false)
+}
+
+func searchPackages(ctx *context.Context, searchAllRevisions bool) {
+ rref := ctx.Data[recipeReferenceKey].(*conan_module.RecipeReference)
+
+ if !searchAllRevisions && rref.Revision == "" {
+ lastRevision, err := conan_model.GetLastRecipeRevision(ctx, ctx.Package.Owner.ID, rref)
+ if err != nil {
+ if err == conan_model.ErrRecipeReferenceNotExist {
+ apiError(ctx, http.StatusNotFound, err)
+ } else {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+ rref = rref.WithRevision(lastRevision.Value)
+ } else {
+ has, err := conan_model.RecipeExists(ctx, ctx.Package.Owner.ID, rref)
+ if err != nil {
+ if err == conan_model.ErrRecipeReferenceNotExist {
+ apiError(ctx, http.StatusNotFound, err)
+ } else {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+ if !has {
+ apiError(ctx, http.StatusNotFound, nil)
+ return
+ }
+ }
+
+ recipeRevisions := []*conan_model.PropertyValue{{Value: rref.Revision}}
+ if searchAllRevisions {
+ var err error
+ recipeRevisions, err = conan_model.GetRecipeRevisions(ctx, ctx.Package.Owner.ID, rref)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ }
+
+ result := make(map[string]*conan_module.Conaninfo)
+
+ for _, recipeRevision := range recipeRevisions {
+ currentRef := rref
+ if recipeRevision.Value != "" {
+ currentRef = rref.WithRevision(recipeRevision.Value)
+ }
+ packageReferences, err := conan_model.GetPackageReferences(ctx, ctx.Package.Owner.ID, currentRef)
+ if err != nil {
+ if err == conan_model.ErrRecipeReferenceNotExist {
+ apiError(ctx, http.StatusNotFound, err)
+ } else {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+ for _, packageReference := range packageReferences {
+ if _, ok := result[packageReference.Value]; ok {
+ continue
+ }
+ pref, _ := conan_module.NewPackageReference(currentRef, packageReference.Value, "")
+ lastPackageRevision, err := conan_model.GetLastPackageRevision(ctx, ctx.Package.Owner.ID, pref)
+ if err != nil {
+ if err == conan_model.ErrPackageReferenceNotExist {
+ apiError(ctx, http.StatusNotFound, err)
+ } else {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+ pref = pref.WithRevision(lastPackageRevision.Value)
+ infoRaw, err := conan_model.GetPackageInfo(ctx, ctx.Package.Owner.ID, pref)
+ if err != nil {
+ if err == conan_model.ErrPackageReferenceNotExist {
+ apiError(ctx, http.StatusNotFound, err)
+ } else {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+ var info *conan_module.Conaninfo
+ if err := json.Unmarshal([]byte(infoRaw), &info); err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ result[pref.Reference] = info
+ }
+ }
+
+ jsonResponse(ctx, http.StatusOK, result)
+}
diff --git a/routers/api/packages/conda/conda.go b/routers/api/packages/conda/conda.go
new file mode 100644
index 0000000..c7e4544
--- /dev/null
+++ b/routers/api/packages/conda/conda.go
@@ -0,0 +1,303 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package conda
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+
+ packages_model "code.gitea.io/gitea/models/packages"
+ conda_model "code.gitea.io/gitea/models/packages/conda"
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/log"
+ packages_module "code.gitea.io/gitea/modules/packages"
+ conda_module "code.gitea.io/gitea/modules/packages/conda"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/routers/api/packages/helper"
+ "code.gitea.io/gitea/services/context"
+ packages_service "code.gitea.io/gitea/services/packages"
+
+ "github.com/dsnet/compress/bzip2"
+)
+
+func apiError(ctx *context.Context, status int, obj any) {
+ helper.LogAndProcessError(ctx, status, obj, func(message string) {
+ ctx.JSON(status, struct {
+ Reason string `json:"reason"`
+ Message string `json:"message"`
+ }{
+ Reason: http.StatusText(status),
+ Message: message,
+ })
+ })
+}
+
+func EnumeratePackages(ctx *context.Context) {
+ type Info struct {
+ Subdir string `json:"subdir"`
+ }
+
+ type PackageInfo struct {
+ Name string `json:"name"`
+ Version string `json:"version"`
+ NoArch string `json:"noarch"`
+ Subdir string `json:"subdir"`
+ Timestamp int64 `json:"timestamp"`
+ Build string `json:"build"`
+ BuildNumber int64 `json:"build_number"`
+ Dependencies []string `json:"depends"`
+ License string `json:"license"`
+ LicenseFamily string `json:"license_family"`
+ HashMD5 string `json:"md5"`
+ HashSHA256 string `json:"sha256"`
+ Size int64 `json:"size"`
+ }
+
+ type RepoData struct {
+ Info Info `json:"info"`
+ Packages map[string]*PackageInfo `json:"packages"`
+ PackagesConda map[string]*PackageInfo `json:"packages.conda"`
+ Removed map[string]*PackageInfo `json:"removed"`
+ }
+
+ repoData := &RepoData{
+ Info: Info{
+ Subdir: ctx.Params("architecture"),
+ },
+ Packages: make(map[string]*PackageInfo),
+ PackagesConda: make(map[string]*PackageInfo),
+ Removed: make(map[string]*PackageInfo),
+ }
+
+ pfs, err := conda_model.SearchFiles(ctx, &conda_model.FileSearchOptions{
+ OwnerID: ctx.Package.Owner.ID,
+ Channel: ctx.Params("channel"),
+ Subdir: repoData.Info.Subdir,
+ })
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ if len(pfs) == 0 {
+ apiError(ctx, http.StatusNotFound, nil)
+ return
+ }
+
+ pds := make(map[int64]*packages_model.PackageDescriptor)
+
+ for _, pf := range pfs {
+ pd, exists := pds[pf.VersionID]
+ if !exists {
+ pv, err := packages_model.GetVersionByID(ctx, pf.VersionID)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ pd, err = packages_model.GetPackageDescriptor(ctx, pv)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ pds[pf.VersionID] = pd
+ }
+
+ var pfd *packages_model.PackageFileDescriptor
+ for _, d := range pd.Files {
+ if d.File.ID == pf.ID {
+ pfd = d
+ break
+ }
+ }
+
+ var fileMetadata *conda_module.FileMetadata
+ if err := json.Unmarshal([]byte(pfd.Properties.GetByName(conda_module.PropertyMetadata)), &fileMetadata); err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ versionMetadata := pd.Metadata.(*conda_module.VersionMetadata)
+
+ pi := &PackageInfo{
+ Name: pd.PackageProperties.GetByName(conda_module.PropertyName),
+ Version: pd.Version.Version,
+ NoArch: fileMetadata.NoArch,
+ Subdir: repoData.Info.Subdir,
+ Timestamp: fileMetadata.Timestamp,
+ Build: fileMetadata.Build,
+ BuildNumber: fileMetadata.BuildNumber,
+ Dependencies: fileMetadata.Dependencies,
+ License: versionMetadata.License,
+ LicenseFamily: versionMetadata.LicenseFamily,
+ HashMD5: pfd.Blob.HashMD5,
+ HashSHA256: pfd.Blob.HashSHA256,
+ Size: pfd.Blob.Size,
+ }
+
+ if fileMetadata.IsCondaPackage {
+ repoData.PackagesConda[pfd.File.Name] = pi
+ } else {
+ repoData.Packages[pfd.File.Name] = pi
+ }
+ }
+
+ resp := ctx.Resp
+
+ var w io.Writer = resp
+
+ if strings.HasSuffix(ctx.Params("filename"), ".json") {
+ resp.Header().Set("Content-Type", "application/json")
+ } else {
+ resp.Header().Set("Content-Type", "application/x-bzip2")
+
+ zw, err := bzip2.NewWriter(w, nil)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ defer zw.Close()
+
+ w = zw
+ }
+
+ resp.WriteHeader(http.StatusOK)
+
+ if err := json.NewEncoder(w).Encode(repoData); err != nil {
+ log.Error("JSON encode: %v", err)
+ }
+}
+
+func UploadPackageFile(ctx *context.Context) {
+ upload, needToClose, err := ctx.UploadStream()
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ if needToClose {
+ defer upload.Close()
+ }
+
+ buf, err := packages_module.CreateHashedBufferFromReader(upload)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ defer buf.Close()
+
+ var pck *conda_module.Package
+ if strings.HasSuffix(strings.ToLower(ctx.Params("filename")), ".tar.bz2") {
+ pck, err = conda_module.ParsePackageBZ2(buf)
+ } else {
+ pck, err = conda_module.ParsePackageConda(buf, buf.Size())
+ }
+ if err != nil {
+ if errors.Is(err, util.ErrInvalidArgument) {
+ apiError(ctx, http.StatusBadRequest, err)
+ } else {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ if _, err := buf.Seek(0, io.SeekStart); err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ fullName := pck.Name
+
+ channel := ctx.Params("channel")
+ if channel != "" {
+ fullName = channel + "/" + pck.Name
+ }
+
+ extension := ".tar.bz2"
+ if pck.FileMetadata.IsCondaPackage {
+ extension = ".conda"
+ }
+
+ fileMetadataRaw, err := json.Marshal(pck.FileMetadata)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ _, _, err = packages_service.CreatePackageOrAddFileToExisting(
+ ctx,
+ &packages_service.PackageCreationInfo{
+ PackageInfo: packages_service.PackageInfo{
+ Owner: ctx.Package.Owner,
+ PackageType: packages_model.TypeConda,
+ Name: fullName,
+ Version: pck.Version,
+ },
+ SemverCompatible: false,
+ Creator: ctx.Doer,
+ Metadata: pck.VersionMetadata,
+ PackageProperties: map[string]string{
+ conda_module.PropertyName: pck.Name,
+ conda_module.PropertyChannel: channel,
+ },
+ },
+ &packages_service.PackageFileCreationInfo{
+ PackageFileInfo: packages_service.PackageFileInfo{
+ Filename: fmt.Sprintf("%s-%s-%s%s", pck.Name, pck.Version, pck.FileMetadata.Build, extension),
+ CompositeKey: pck.Subdir,
+ },
+ Creator: ctx.Doer,
+ Data: buf,
+ IsLead: true,
+ Properties: map[string]string{
+ conda_module.PropertySubdir: pck.Subdir,
+ conda_module.PropertyMetadata: string(fileMetadataRaw),
+ },
+ },
+ )
+ if err != nil {
+ switch err {
+ case packages_model.ErrDuplicatePackageFile:
+ apiError(ctx, http.StatusConflict, err)
+ case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize:
+ apiError(ctx, http.StatusForbidden, err)
+ default:
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ ctx.Status(http.StatusCreated)
+}
+
+func DownloadPackageFile(ctx *context.Context) {
+ pfs, err := conda_model.SearchFiles(ctx, &conda_model.FileSearchOptions{
+ OwnerID: ctx.Package.Owner.ID,
+ Channel: ctx.Params("channel"),
+ Subdir: ctx.Params("architecture"),
+ Filename: ctx.Params("filename"),
+ })
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ if len(pfs) != 1 {
+ apiError(ctx, http.StatusNotFound, nil)
+ return
+ }
+
+ pf := pfs[0]
+
+ s, u, _, err := packages_service.GetPackageFileStream(ctx, pf)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ helper.ServePackageFile(ctx, s, u, pf)
+}
diff --git a/routers/api/packages/container/auth.go b/routers/api/packages/container/auth.go
new file mode 100644
index 0000000..a8b3ec1
--- /dev/null
+++ b/routers/api/packages/container/auth.go
@@ -0,0 +1,49 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package container
+
+import (
+ "net/http"
+
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/services/auth"
+ "code.gitea.io/gitea/services/packages"
+)
+
+var _ auth.Method = &Auth{}
+
+type Auth struct{}
+
+func (a *Auth) Name() string {
+ return "container"
+}
+
+// Verify extracts the user from the Bearer token
+// If it's an anonymous session a ghost user is returned
+func (a *Auth) Verify(req *http.Request, w http.ResponseWriter, store auth.DataStore, sess auth.SessionStore) (*user_model.User, error) {
+ uid, scope, err := packages.ParseAuthorizationToken(req)
+ if err != nil {
+ log.Trace("ParseAuthorizationToken: %v", err)
+ return nil, err
+ }
+
+ if uid == 0 {
+ return nil, nil
+ }
+
+ // Propagate scope of the authorization token.
+ if scope != "" {
+ store.GetData()["IsApiToken"] = true
+ store.GetData()["ApiTokenScope"] = scope
+ }
+
+ u, err := user_model.GetPossibleUserByID(req.Context(), uid)
+ if err != nil {
+ log.Error("GetPossibleUserByID: %v", err)
+ return nil, err
+ }
+
+ return u, nil
+}
diff --git a/routers/api/packages/container/blob.go b/routers/api/packages/container/blob.go
new file mode 100644
index 0000000..9e3a470
--- /dev/null
+++ b/routers/api/packages/container/blob.go
@@ -0,0 +1,202 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package container
+
+import (
+ "context"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "os"
+ "strings"
+ "sync"
+
+ "code.gitea.io/gitea/models/db"
+ packages_model "code.gitea.io/gitea/models/packages"
+ container_model "code.gitea.io/gitea/models/packages/container"
+ "code.gitea.io/gitea/modules/log"
+ packages_module "code.gitea.io/gitea/modules/packages"
+ container_module "code.gitea.io/gitea/modules/packages/container"
+ "code.gitea.io/gitea/modules/util"
+ packages_service "code.gitea.io/gitea/services/packages"
+)
+
+var uploadVersionMutex sync.Mutex
+
+// 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
+ pb := packages_service.NewPackageBlob(hsr)
+
+ exists := false
+
+ contentStore := packages_module.NewContentStore()
+
+ uploadVersion, err := getOrCreateUploadVersion(ctx, &pci.PackageInfo)
+ if err != nil {
+ return nil, err
+ }
+
+ err = db.WithTx(ctx, func(ctx context.Context) error {
+ if err := packages_service.CheckSizeQuotaExceeded(ctx, pci.Creator, pci.Owner, packages_model.TypeContainer, hsr.Size()); err != nil {
+ return err
+ }
+
+ pb, exists, err = packages_model.GetOrInsertBlob(ctx, pb)
+ if err != nil {
+ log.Error("Error inserting package blob: %v", err)
+ return err
+ }
+ // FIXME: Workaround to be removed in v1.20
+ // https://github.com/go-gitea/gitea/issues/19586
+ if exists {
+ err = contentStore.Has(packages_module.BlobHash256Key(pb.HashSHA256))
+ if err != nil && (errors.Is(err, util.ErrNotExist) || errors.Is(err, os.ErrNotExist)) {
+ log.Debug("Package registry inconsistent: blob %s does not exist on file system", pb.HashSHA256)
+ exists = false
+ }
+ }
+ if !exists {
+ if err := contentStore.Save(packages_module.BlobHash256Key(pb.HashSHA256), hsr, hsr.Size()); err != nil {
+ log.Error("Error saving package blob in content store: %v", err)
+ return err
+ }
+ }
+
+ return createFileForBlob(ctx, uploadVersion, pb)
+ })
+ if err != nil {
+ if !exists {
+ if err := contentStore.Delete(packages_module.BlobHash256Key(pb.HashSHA256)); err != nil {
+ log.Error("Error deleting package blob from content store: %v", err)
+ }
+ }
+ return nil, err
+ }
+
+ 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)
+ if err != nil {
+ return err
+ }
+
+ return db.WithTx(ctx, func(ctx context.Context) error {
+ return createFileForBlob(ctx, uploadVersion, pb)
+ })
+}
+
+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
+ // https://github.com/go-gitea/gitea/pull/21862
+ uploadVersionMutex.Lock()
+ err := db.WithTx(ctx, func(ctx context.Context) error {
+ created := true
+ p := &packages_model.Package{
+ OwnerID: pi.Owner.ID,
+ Type: packages_model.TypeContainer,
+ Name: strings.ToLower(pi.Name),
+ LowerName: strings.ToLower(pi.Name),
+ }
+ var err error
+ if p, err = packages_model.TryInsertPackage(ctx, p); err != nil {
+ if err == packages_model.ErrDuplicatePackage {
+ created = false
+ } else {
+ log.Error("Error inserting package: %v", err)
+ return err
+ }
+ }
+
+ if created {
+ if _, err := packages_model.InsertProperty(ctx, packages_model.PropertyTypePackage, p.ID, container_module.PropertyRepository, strings.ToLower(pi.Owner.LowerName+"/"+pi.Name)); err != nil {
+ log.Error("Error setting package property: %v", err)
+ return err
+ }
+ }
+
+ pv := &packages_model.PackageVersion{
+ PackageID: p.ID,
+ CreatorID: pi.Owner.ID,
+ Version: container_model.UploadVersion,
+ LowerVersion: container_model.UploadVersion,
+ IsInternal: true,
+ MetadataJSON: "null",
+ }
+ if pv, err = packages_model.GetOrInsertVersion(ctx, pv); err != nil {
+ if err != packages_model.ErrDuplicatePackageVersion {
+ log.Error("Error inserting package: %v", err)
+ return err
+ }
+ }
+
+ uploadVersion = pv
+
+ return nil
+ })
+ uploadVersionMutex.Unlock()
+
+ return uploadVersion, err
+}
+
+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{
+ VersionID: pv.ID,
+ BlobID: pb.ID,
+ Name: filename,
+ LowerName: filename,
+ CompositeKey: packages_model.EmptyFileKey,
+ }
+ var err error
+ if pf, err = packages_model.TryInsertFile(ctx, pf); err != nil {
+ if err == packages_model.ErrDuplicatePackageFile {
+ return nil
+ }
+ log.Error("Error inserting package file: %v", err)
+ return err
+ }
+
+ 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
+ }
+
+ return nil
+}
+
+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,
+ Image: image,
+ Digest: digest,
+ })
+ if err != nil {
+ return err
+ }
+
+ for _, file := range pfds {
+ if err := packages_service.DeletePackageFile(ctx, file.File); err != nil {
+ return err
+ }
+ }
+ return nil
+ })
+}
+
+func digestFromHashSummer(h packages_module.HashSummer) string {
+ _, _, hashSHA256, _ := h.Sums()
+ return "sha256:" + hex.EncodeToString(hashSHA256)
+}
+
+func digestFromPackageBlob(pb *packages_model.PackageBlob) string {
+ return "sha256:" + pb.HashSHA256
+}
diff --git a/routers/api/packages/container/container.go b/routers/api/packages/container/container.go
new file mode 100644
index 0000000..f376e7b
--- /dev/null
+++ b/routers/api/packages/container/container.go
@@ -0,0 +1,785 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package container
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+ "regexp"
+ "strconv"
+ "strings"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ packages_model "code.gitea.io/gitea/models/packages"
+ container_model "code.gitea.io/gitea/models/packages/container"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/log"
+ packages_module "code.gitea.io/gitea/modules/packages"
+ container_module "code.gitea.io/gitea/modules/packages/container"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/routers/api/packages/helper"
+ "code.gitea.io/gitea/services/context"
+ packages_service "code.gitea.io/gitea/services/packages"
+ container_service "code.gitea.io/gitea/services/packages/container"
+
+ 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`)
+)
+
+type containerHeaders struct {
+ Status int
+ ContentDigest string
+ UploadUUID string
+ Range string
+ Location string
+ ContentType string
+ ContentLength int64
+}
+
+// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#legacy-docker-support-http-headers
+func setResponseHeaders(resp http.ResponseWriter, h *containerHeaders) {
+ if h.Location != "" {
+ resp.Header().Set("Location", h.Location)
+ }
+ if h.Range != "" {
+ resp.Header().Set("Range", h.Range)
+ }
+ if h.ContentType != "" {
+ resp.Header().Set("Content-Type", h.ContentType)
+ }
+ if h.ContentLength != 0 {
+ resp.Header().Set("Content-Length", strconv.FormatInt(h.ContentLength, 10))
+ }
+ if h.UploadUUID != "" {
+ resp.Header().Set("Docker-Upload-Uuid", h.UploadUUID)
+ }
+ if h.ContentDigest != "" {
+ resp.Header().Set("Docker-Content-Digest", h.ContentDigest)
+ resp.Header().Set("ETag", fmt.Sprintf(`"%s"`, h.ContentDigest))
+ }
+ resp.Header().Set("Docker-Distribution-Api-Version", "registry/2.0")
+ resp.WriteHeader(h.Status)
+}
+
+func jsonResponse(ctx *context.Context, status int, obj any) {
+ setResponseHeaders(ctx.Resp, &containerHeaders{
+ Status: status,
+ ContentType: "application/json",
+ })
+ if err := json.NewEncoder(ctx.Resp).Encode(obj); err != nil {
+ log.Error("JSON encode: %v", err)
+ }
+}
+
+func apiError(ctx *context.Context, status int, err error) {
+ helper.LogAndProcessError(ctx, status, err, func(message string) {
+ setResponseHeaders(ctx.Resp, &containerHeaders{
+ Status: status,
+ })
+ })
+}
+
+// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#error-codes
+func apiErrorDefined(ctx *context.Context, err *namedError) {
+ type ContainerError struct {
+ Code string `json:"code"`
+ Message string `json:"message"`
+ }
+
+ type ContainerErrors struct {
+ Errors []ContainerError `json:"errors"`
+ }
+
+ jsonResponse(ctx, err.StatusCode, ContainerErrors{
+ Errors: []ContainerError{
+ {
+ Code: err.Code,
+ Message: err.Message,
+ },
+ },
+ })
+}
+
+func apiUnauthorizedError(ctx *context.Context) {
+ ctx.Resp.Header().Add("WWW-Authenticate", `Bearer realm="`+setting.AppURL+`v2/token",service="container_registry",scope="*"`)
+ apiErrorDefined(ctx, errUnauthorized)
+}
+
+// ReqContainerAccess is a middleware which checks the current user valid (real user or ghost if anonymous access is enabled)
+func ReqContainerAccess(ctx *context.Context) {
+ if ctx.Doer == nil || (setting.Service.RequireSignInView && ctx.Doer.IsGhost()) {
+ apiUnauthorizedError(ctx)
+ }
+}
+
+// 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)
+ }
+}
+
+// DetermineSupport is used to test if the registry supports OCI
+// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#determining-support
+func DetermineSupport(ctx *context.Context) {
+ setResponseHeaders(ctx.Resp, &containerHeaders{
+ Status: http.StatusOK,
+ })
+}
+
+// Authenticate creates a token for the current user
+// If the current user is anonymous, the ghost user is used unless RequireSignInView is enabled.
+func Authenticate(ctx *context.Context) {
+ u := ctx.Doer
+ if u == nil {
+ if setting.Service.RequireSignInView {
+ apiUnauthorizedError(ctx)
+ return
+ }
+
+ u = user_model.NewGhostUser()
+ }
+
+ // If there's an API scope, ensure it propagates.
+ scope, _ := ctx.Data["ApiTokenScope"].(auth_model.AccessTokenScope)
+
+ token, err := packages_service.CreateAuthorizationToken(u, scope)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ ctx.JSON(http.StatusOK, map[string]string{
+ "token": token,
+ })
+}
+
+// https://distribution.github.io/distribution/spec/auth/oauth/
+func AuthenticateNotImplemented(ctx *context.Context) {
+ // This optional endpoint can be used to authenticate a client.
+ // It must implement the specification described in:
+ // https://datatracker.ietf.org/doc/html/rfc6749
+ // https://distribution.github.io/distribution/spec/auth/oauth/
+ // Purpose of this stub is to respond with 404 Not Found instead of 405 Method Not Allowed.
+
+ ctx.Status(http.StatusNotFound)
+}
+
+// https://docs.docker.com/registry/spec/api/#listing-repositories
+func GetRepositoryList(ctx *context.Context) {
+ n := ctx.FormInt("n")
+ if n <= 0 || n > 100 {
+ n = 100
+ }
+ last := ctx.FormTrim("last")
+
+ repositories, err := container_model.GetRepositories(ctx, ctx.Doer, n, last)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ type RepositoryList struct {
+ Repositories []string `json:"repositories"`
+ }
+
+ if len(repositories) == n {
+ v := url.Values{}
+ if n > 0 {
+ v.Add("n", strconv.Itoa(n))
+ }
+ v.Add("last", repositories[len(repositories)-1])
+
+ ctx.Resp.Header().Set("Link", fmt.Sprintf(`</v2/_catalog?%s>; rel="next"`, v.Encode()))
+ }
+
+ jsonResponse(ctx, http.StatusOK, RepositoryList{
+ Repositories: repositories,
+ })
+}
+
+// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#mounting-a-blob-from-another-repository
+// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#single-post
+// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pushing-a-blob-in-chunks
+func InitiateUploadBlob(ctx *context.Context) {
+ image := ctx.Params("image")
+
+ mount := ctx.FormTrim("mount")
+ from := ctx.FormTrim("from")
+ if mount != "" {
+ blob, _ := workaroundGetContainerBlob(ctx, &container_model.BlobSearchOptions{
+ Repository: from,
+ Digest: mount,
+ })
+ if blob != nil {
+ accessible, err := packages_model.IsBlobAccessibleForUser(ctx, blob.Blob.ID, ctx.Doer)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ if accessible {
+ if err := mountBlob(ctx, &packages_service.PackageInfo{Owner: ctx.Package.Owner, Name: image}, blob.Blob); err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ setResponseHeaders(ctx.Resp, &containerHeaders{
+ Location: fmt.Sprintf("/v2/%s/%s/blobs/%s", ctx.Package.Owner.LowerName, image, mount),
+ ContentDigest: mount,
+ Status: http.StatusCreated,
+ })
+ return
+ }
+ }
+ }
+
+ digest := ctx.FormTrim("digest")
+ if digest != "" {
+ buf, err := packages_module.CreateHashedBufferFromReader(ctx.Req.Body)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ defer buf.Close()
+
+ if digest != digestFromHashSummer(buf) {
+ apiErrorDefined(ctx, errDigestInvalid)
+ return
+ }
+
+ if _, err := saveAsPackageBlob(ctx,
+ buf,
+ &packages_service.PackageCreationInfo{
+ PackageInfo: packages_service.PackageInfo{
+ Owner: ctx.Package.Owner,
+ Name: image,
+ },
+ Creator: ctx.Doer,
+ },
+ ); err != nil {
+ switch err {
+ case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize:
+ apiError(ctx, http.StatusForbidden, err)
+ default:
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ setResponseHeaders(ctx.Resp, &containerHeaders{
+ Location: fmt.Sprintf("/v2/%s/%s/blobs/%s", ctx.Package.Owner.LowerName, image, digest),
+ ContentDigest: digest,
+ Status: http.StatusCreated,
+ })
+ return
+ }
+
+ upload, err := packages_model.CreateBlobUpload(ctx)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ setResponseHeaders(ctx.Resp, &containerHeaders{
+ Location: fmt.Sprintf("/v2/%s/%s/blobs/uploads/%s", ctx.Package.Owner.LowerName, image, upload.ID),
+ Range: "0-0",
+ UploadUUID: upload.ID,
+ Status: http.StatusAccepted,
+ })
+}
+
+// https://docs.docker.com/registry/spec/api/#get-blob-upload
+func GetUploadBlob(ctx *context.Context) {
+ uuid := ctx.Params("uuid")
+
+ upload, err := packages_model.GetBlobUploadByID(ctx, uuid)
+ if err != nil {
+ if err == packages_model.ErrPackageBlobUploadNotExist {
+ apiErrorDefined(ctx, errBlobUploadUnknown)
+ } else {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ setResponseHeaders(ctx.Resp, &containerHeaders{
+ Range: fmt.Sprintf("0-%d", upload.BytesReceived),
+ UploadUUID: upload.ID,
+ Status: http.StatusNoContent,
+ })
+}
+
+// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pushing-a-blob-in-chunks
+func UploadBlob(ctx *context.Context) {
+ image := ctx.Params("image")
+
+ uploader, err := container_service.NewBlobUploader(ctx, ctx.Params("uuid"))
+ if err != nil {
+ if err == packages_model.ErrPackageBlobUploadNotExist {
+ apiErrorDefined(ctx, errBlobUploadUnknown)
+ } else {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+ defer uploader.Close()
+
+ contentRange := ctx.Req.Header.Get("Content-Range")
+ if contentRange != "" {
+ start, end := 0, 0
+ if _, err := fmt.Sscanf(contentRange, "%d-%d", &start, &end); err != nil {
+ apiErrorDefined(ctx, errBlobUploadInvalid)
+ return
+ }
+
+ if int64(start) != uploader.Size() {
+ apiErrorDefined(ctx, errBlobUploadInvalid.WithStatusCode(http.StatusRequestedRangeNotSatisfiable))
+ return
+ }
+ } else if uploader.Size() != 0 {
+ apiErrorDefined(ctx, errBlobUploadInvalid.WithMessage("Stream uploads after first write are not allowed"))
+ return
+ }
+
+ if err := uploader.Append(ctx, ctx.Req.Body); err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ setResponseHeaders(ctx.Resp, &containerHeaders{
+ Location: fmt.Sprintf("/v2/%s/%s/blobs/uploads/%s", ctx.Package.Owner.LowerName, image, uploader.ID),
+ Range: fmt.Sprintf("0-%d", uploader.Size()-1),
+ UploadUUID: uploader.ID,
+ Status: http.StatusAccepted,
+ })
+}
+
+// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pushing-a-blob-in-chunks
+func EndUploadBlob(ctx *context.Context) {
+ image := ctx.Params("image")
+
+ digest := ctx.FormTrim("digest")
+ if digest == "" {
+ apiErrorDefined(ctx, errDigestInvalid)
+ return
+ }
+
+ uploader, err := container_service.NewBlobUploader(ctx, ctx.Params("uuid"))
+ if err != nil {
+ if err == packages_model.ErrPackageBlobUploadNotExist {
+ apiErrorDefined(ctx, errBlobUploadUnknown)
+ } else {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+ doClose := true
+ defer func() {
+ if doClose {
+ uploader.Close()
+ }
+ }()
+
+ if ctx.Req.Body != nil {
+ if err := uploader.Append(ctx, ctx.Req.Body); err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ }
+
+ if digest != digestFromHashSummer(uploader) {
+ apiErrorDefined(ctx, errDigestInvalid)
+ return
+ }
+
+ if _, err := saveAsPackageBlob(ctx,
+ uploader,
+ &packages_service.PackageCreationInfo{
+ PackageInfo: packages_service.PackageInfo{
+ Owner: ctx.Package.Owner,
+ Name: image,
+ },
+ Creator: ctx.Doer,
+ },
+ ); err != nil {
+ switch err {
+ case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize:
+ apiError(ctx, http.StatusForbidden, err)
+ default:
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ if err := uploader.Close(); err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ doClose = false
+
+ if err := container_service.RemoveBlobUploadByID(ctx, uploader.ID); err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ setResponseHeaders(ctx.Resp, &containerHeaders{
+ Location: fmt.Sprintf("/v2/%s/%s/blobs/%s", ctx.Package.Owner.LowerName, image, digest),
+ ContentDigest: digest,
+ Status: http.StatusCreated,
+ })
+}
+
+// https://docs.docker.com/registry/spec/api/#delete-blob-upload
+func CancelUploadBlob(ctx *context.Context) {
+ uuid := ctx.Params("uuid")
+
+ _, err := packages_model.GetBlobUploadByID(ctx, uuid)
+ if err != nil {
+ if err == packages_model.ErrPackageBlobUploadNotExist {
+ apiErrorDefined(ctx, errBlobUploadUnknown)
+ } else {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ if err := container_service.RemoveBlobUploadByID(ctx, uuid); err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ setResponseHeaders(ctx.Resp, &containerHeaders{
+ Status: http.StatusNoContent,
+ })
+}
+
+func getBlobFromContext(ctx *context.Context) (*packages_model.PackageFileDescriptor, error) {
+ d := 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)
+ if err != nil {
+ if err == container_model.ErrContainerBlobNotExist {
+ apiErrorDefined(ctx, errBlobUnknown)
+ } else {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ setResponseHeaders(ctx.Resp, &containerHeaders{
+ ContentDigest: blob.Properties.GetByName(container_module.PropertyDigest),
+ ContentLength: blob.Blob.Size,
+ Status: http.StatusOK,
+ })
+}
+
+// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pulling-blobs
+func GetBlob(ctx *context.Context) {
+ blob, err := getBlobFromContext(ctx)
+ if err != nil {
+ if err == container_model.ErrContainerBlobNotExist {
+ apiErrorDefined(ctx, errBlobUnknown)
+ } else {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ serveBlob(ctx, blob)
+}
+
+// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#deleting-blobs
+func DeleteBlob(ctx *context.Context) {
+ d := ctx.Params("digest")
+
+ if digest.Digest(d).Validate() != nil {
+ apiErrorDefined(ctx, errBlobUnknown)
+ return
+ }
+
+ if err := deleteBlob(ctx, ctx.Package.Owner.ID, ctx.Params("image"), d); err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ setResponseHeaders(ctx.Resp, &containerHeaders{
+ Status: http.StatusAccepted,
+ })
+}
+
+// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pushing-manifests
+func 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"))
+ return
+ }
+
+ maxSize := maxManifestSize + 1
+ buf, err := packages_module.CreateHashedBufferFromReaderWithSize(&io.LimitedReader{R: ctx.Req.Body, N: int64(maxSize)}, maxSize)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ defer buf.Close()
+
+ if buf.Size() > maxManifestSize {
+ apiErrorDefined(ctx, errManifestInvalid.WithMessage("Manifest exceeds maximum size").WithStatusCode(http.StatusRequestEntityTooLarge))
+ return
+ }
+
+ digest, err := processManifest(ctx, mci, buf)
+ if err != nil {
+ var namedError *namedError
+ if errors.As(err, &namedError) {
+ apiErrorDefined(ctx, namedError)
+ } else if errors.Is(err, container_model.ErrContainerBlobNotExist) {
+ apiErrorDefined(ctx, errBlobUnknown)
+ } else {
+ switch err {
+ case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize:
+ apiError(ctx, http.StatusForbidden, err)
+ default:
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ }
+ return
+ }
+
+ setResponseHeaders(ctx.Resp, &containerHeaders{
+ Location: fmt.Sprintf("/v2/%s/%s/manifests/%s", ctx.Package.Owner.LowerName, mci.Image, 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)
+ if err != nil {
+ if err == container_model.ErrContainerBlobNotExist {
+ apiErrorDefined(ctx, errManifestUnknown)
+ } else {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ setResponseHeaders(ctx.Resp, &containerHeaders{
+ ContentDigest: manifest.Properties.GetByName(container_module.PropertyDigest),
+ ContentType: manifest.Properties.GetByName(container_module.PropertyMediaType),
+ ContentLength: manifest.Blob.Size,
+ Status: http.StatusOK,
+ })
+}
+
+// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pulling-manifests
+func GetManifest(ctx *context.Context) {
+ manifest, err := getManifestFromContext(ctx)
+ if err != nil {
+ if err == container_model.ErrContainerBlobNotExist {
+ apiErrorDefined(ctx, errManifestUnknown)
+ } else {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ serveBlob(ctx, manifest)
+}
+
+// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#deleting-tags
+// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#deleting-manifests
+func DeleteManifest(ctx *context.Context) {
+ opts, err := getBlobSearchOptionsFromContext(ctx)
+ if err != nil {
+ apiErrorDefined(ctx, errManifestUnknown)
+ return
+ }
+
+ pvs, err := container_model.GetManifestVersions(ctx, opts)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ if len(pvs) == 0 {
+ apiErrorDefined(ctx, errManifestUnknown)
+ return
+ }
+
+ for _, pv := range pvs {
+ if err := packages_service.RemovePackageVersion(ctx, ctx.Doer, pv); err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ }
+
+ setResponseHeaders(ctx.Resp, &containerHeaders{
+ Status: http.StatusAccepted,
+ })
+}
+
+func serveBlob(ctx *context.Context, pfd *packages_model.PackageFileDescriptor) {
+ s, u, _, err := packages_service.GetPackageBlobStream(ctx, pfd.File, pfd.Blob)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ headers := &containerHeaders{
+ ContentDigest: pfd.Properties.GetByName(container_module.PropertyDigest),
+ ContentType: pfd.Properties.GetByName(container_module.PropertyMediaType),
+ ContentLength: pfd.Blob.Size,
+ Status: http.StatusOK,
+ }
+
+ if u != nil {
+ headers.Status = http.StatusTemporaryRedirect
+ headers.Location = u.String()
+
+ setResponseHeaders(ctx.Resp, headers)
+ return
+ }
+
+ defer s.Close()
+
+ setResponseHeaders(ctx.Resp, headers)
+ if _, err := io.Copy(ctx.Resp, s); err != nil {
+ log.Error("Error whilst copying content to response: %v", err)
+ }
+}
+
+// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#content-discovery
+func GetTagList(ctx *context.Context) {
+ image := ctx.Params("image")
+
+ if _, err := packages_model.GetPackageByName(ctx, ctx.Package.Owner.ID, packages_model.TypeContainer, image); err != nil {
+ if err == packages_model.ErrPackageNotExist {
+ apiErrorDefined(ctx, errNameUnknown)
+ } else {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ n := -1
+ if ctx.FormTrim("n") != "" {
+ n = ctx.FormInt("n")
+ }
+ last := ctx.FormTrim("last")
+
+ tags, err := container_model.GetImageTags(ctx, ctx.Package.Owner.ID, image, n, last)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ type TagList struct {
+ Name string `json:"name"`
+ Tags []string `json:"tags"`
+ }
+
+ if len(tags) > 0 {
+ v := url.Values{}
+ if n > 0 {
+ v.Add("n", strconv.Itoa(n))
+ }
+ v.Add("last", tags[len(tags)-1])
+
+ ctx.Resp.Header().Set("Link", fmt.Sprintf(`</v2/%s/%s/tags/list?%s>; rel="next"`, ctx.Package.Owner.LowerName, image, v.Encode()))
+ }
+
+ jsonResponse(ctx, http.StatusOK, TagList{
+ Name: strings.ToLower(ctx.Package.Owner.LowerName + "/" + image),
+ Tags: tags,
+ })
+}
+
+// FIXME: Workaround to be removed in v1.20
+// 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/routers/api/packages/container/errors.go
new file mode 100644
index 0000000..1a9b0f3
--- /dev/null
+++ b/routers/api/packages/container/errors.go
@@ -0,0 +1,52 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package container
+
+import (
+ "net/http"
+)
+
+// 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}
+)
+
+type namedError struct {
+ Code string
+ StatusCode int
+ Message 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{
+ Code: e.Code,
+ StatusCode: e.StatusCode,
+ Message: message,
+ }
+}
+
+// WithStatusCode creates a new instance of the error with a different status code
+func (e *namedError) WithStatusCode(statusCode int) *namedError {
+ return &namedError{
+ Code: e.Code,
+ StatusCode: statusCode,
+ Message: e.Message,
+ }
+}
diff --git a/routers/api/packages/container/manifest.go b/routers/api/packages/container/manifest.go
new file mode 100644
index 0000000..4a79a58
--- /dev/null
+++ b/routers/api/packages/container/manifest.go
@@ -0,0 +1,483 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package container
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "strings"
+
+ "code.gitea.io/gitea/models/db"
+ packages_model "code.gitea.io/gitea/models/packages"
+ container_model "code.gitea.io/gitea/models/packages/container"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/log"
+ packages_module "code.gitea.io/gitea/modules/packages"
+ container_module "code.gitea.io/gitea/modules/packages/container"
+ "code.gitea.io/gitea/modules/util"
+ notify_service "code.gitea.io/gitea/services/notify"
+ packages_service "code.gitea.io/gitea/services/packages"
+
+ digest "github.com/opencontainers/go-digest"
+ oci "github.com/opencontainers/image-spec/specs-go/v1"
+)
+
+func isValidMediaType(mt string) bool {
+ return strings.HasPrefix(mt, "application/vnd.docker.") || strings.HasPrefix(mt, "application/vnd.oci.")
+}
+
+func isImageManifestMediaType(mt string) bool {
+ return strings.EqualFold(mt, oci.MediaTypeImageManifest) || strings.EqualFold(mt, "application/vnd.docker.distribution.manifest.v2+json")
+}
+
+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 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")
+ }
+
+ if _, err := buf.Seek(0, io.SeekStart); err != nil {
+ return "", err
+ }
+
+ if !isValidMediaType(mci.MediaType) {
+ mci.MediaType = index.MediaType
+ if !isValidMediaType(mci.MediaType) {
+ return "", errManifestInvalid.WithMessage("MediaType not recognized")
+ }
+ }
+
+ if isImageManifestMediaType(mci.MediaType) {
+ return processImageManifest(ctx, mci, buf)
+ } else if isImageIndexMediaType(mci.MediaType) {
+ return processImageManifestIndex(ctx, mci, buf)
+ }
+ return "", errManifestInvalid
+}
+
+func processImageManifest(ctx context.Context, mci *manifestCreationInfo, buf *packages_module.HashedBuffer) (string, error) {
+ manifestDigest := ""
+
+ err := func() error {
+ var manifest oci.Manifest
+ if err := json.NewDecoder(buf).Decode(&manifest); err != nil {
+ return err
+ }
+
+ if _, err := buf.Seek(0, io.SeekStart); err != nil {
+ return err
+ }
+
+ ctx, committer, err := db.TxContext(ctx)
+ if err != nil {
+ return err
+ }
+ defer committer.Close()
+
+ configDescriptor, err := container_model.GetContainerBlob(ctx, &container_model.BlobSearchOptions{
+ OwnerID: mci.Owner.ID,
+ Image: mci.Image,
+ Digest: string(manifest.Config.Digest),
+ })
+ if err != nil {
+ return err
+ }
+
+ configReader, err := packages_module.NewContentStore().Get(packages_module.BlobHash256Key(configDescriptor.Blob.HashSHA256))
+ if err != nil {
+ return err
+ }
+ defer configReader.Close()
+
+ metadata, err := container_module.ParseImageConfig(manifest.Config.MediaType, configReader)
+ if err != nil {
+ return err
+ }
+
+ blobReferences := make([]*blobReference, 0, 1+len(manifest.Layers))
+
+ blobReferences = append(blobReferences, &blobReference{
+ Digest: manifest.Config.Digest,
+ MediaType: manifest.Config.MediaType,
+ File: configDescriptor,
+ ExpectedSize: manifest.Config.Size,
+ })
+
+ for _, layer := range manifest.Layers {
+ pfd, err := container_model.GetContainerBlob(ctx, &container_model.BlobSearchOptions{
+ OwnerID: mci.Owner.ID,
+ Image: mci.Image,
+ Digest: string(layer.Digest),
+ })
+ if err != nil {
+ return err
+ }
+
+ blobReferences = append(blobReferences, &blobReference{
+ Digest: layer.Digest,
+ MediaType: layer.MediaType,
+ File: pfd,
+ ExpectedSize: layer.Size,
+ })
+ }
+
+ pv, err := createPackageAndVersion(ctx, mci, metadata)
+ if err != nil {
+ return err
+ }
+
+ uploadVersion, err := packages_model.GetInternalVersionByNameAndVersion(ctx, mci.Owner.ID, packages_model.TypeContainer, mci.Image, container_model.UploadVersion)
+ if err != nil && err != packages_model.ErrPackageNotExist {
+ return err
+ }
+
+ for _, ref := range blobReferences {
+ if err := createFileFromBlobReference(ctx, pv, uploadVersion, ref); err != nil {
+ return err
+ }
+ }
+
+ pb, created, digest, err := createManifestBlob(ctx, mci, pv, buf)
+ removeBlob := false
+ defer func() {
+ if removeBlob {
+ contentStore := packages_module.NewContentStore()
+ if err := contentStore.Delete(packages_module.BlobHash256Key(pb.HashSHA256)); err != nil {
+ log.Error("Error deleting package blob from content store: %v", err)
+ }
+ }
+ }()
+ if err != nil {
+ removeBlob = created
+ return err
+ }
+
+ if err := committer.Commit(); err != nil {
+ removeBlob = created
+ return err
+ }
+
+ if err := notifyPackageCreate(ctx, mci.Creator, pv); err != nil {
+ return err
+ }
+
+ manifestDigest = digest
+
+ return nil
+ }()
+ if err != nil {
+ return "", err
+ }
+
+ return manifestDigest, nil
+}
+
+func processImageManifestIndex(ctx context.Context, mci *manifestCreationInfo, buf *packages_module.HashedBuffer) (string, error) {
+ manifestDigest := ""
+
+ err := func() error {
+ var index oci.Index
+ if err := json.NewDecoder(buf).Decode(&index); err != nil {
+ return err
+ }
+
+ if _, err := buf.Seek(0, io.SeekStart); err != nil {
+ return err
+ }
+
+ ctx, committer, err := db.TxContext(ctx)
+ if err != nil {
+ return err
+ }
+ defer committer.Close()
+
+ metadata := &container_module.Metadata{
+ Type: container_module.TypeOCI,
+ Manifests: make([]*container_module.Manifest, 0, len(index.Manifests)),
+ }
+
+ for _, manifest := range index.Manifests {
+ if !isImageManifestMediaType(manifest.MediaType) {
+ return errManifestInvalid
+ }
+
+ platform := container_module.DefaultPlatform
+ if manifest.Platform != nil {
+ platform = fmt.Sprintf("%s/%s", manifest.Platform.OS, manifest.Platform.Architecture)
+ if manifest.Platform.Variant != "" {
+ platform = fmt.Sprintf("%s/%s", platform, manifest.Platform.Variant)
+ }
+ }
+
+ pfd, err := container_model.GetContainerBlob(ctx, &container_model.BlobSearchOptions{
+ OwnerID: mci.Owner.ID,
+ Image: mci.Image,
+ Digest: string(manifest.Digest),
+ IsManifest: true,
+ })
+ if err != nil {
+ if err == container_model.ErrContainerBlobNotExist {
+ return errManifestBlobUnknown
+ }
+ return err
+ }
+
+ size, err := packages_model.CalculateFileSize(ctx, &packages_model.PackageFileSearchOptions{
+ VersionID: pfd.File.VersionID,
+ })
+ if err != nil {
+ return err
+ }
+
+ metadata.Manifests = append(metadata.Manifests, &container_module.Manifest{
+ Platform: platform,
+ Digest: string(manifest.Digest),
+ Size: size,
+ })
+ }
+
+ pv, err := createPackageAndVersion(ctx, mci, metadata)
+ if err != nil {
+ return err
+ }
+
+ pb, created, digest, err := createManifestBlob(ctx, mci, pv, buf)
+ removeBlob := false
+ defer func() {
+ if removeBlob {
+ contentStore := packages_module.NewContentStore()
+ if err := contentStore.Delete(packages_module.BlobHash256Key(pb.HashSHA256)); err != nil {
+ log.Error("Error deleting package blob from content store: %v", err)
+ }
+ }
+ }()
+ if err != nil {
+ removeBlob = created
+ return err
+ }
+
+ if err := committer.Commit(); err != nil {
+ removeBlob = created
+ return err
+ }
+
+ if err := notifyPackageCreate(ctx, mci.Creator, pv); err != nil {
+ return err
+ }
+
+ manifestDigest = digest
+
+ return nil
+ }()
+ if err != nil {
+ return "", err
+ }
+
+ return manifestDigest, nil
+}
+
+func notifyPackageCreate(ctx context.Context, doer *user_model.User, pv *packages_model.PackageVersion) error {
+ pd, err := packages_model.GetPackageDescriptor(ctx, pv)
+ if err != nil {
+ return err
+ }
+
+ notify_service.PackageCreate(ctx, doer, pd)
+
+ return nil
+}
+
+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,
+ Type: packages_model.TypeContainer,
+ Name: strings.ToLower(mci.Image),
+ LowerName: strings.ToLower(mci.Image),
+ }
+ var err error
+ if p, err = packages_model.TryInsertPackage(ctx, p); err != nil {
+ if err == packages_model.ErrDuplicatePackage {
+ created = false
+ } else {
+ log.Error("Error inserting package: %v", err)
+ return nil, err
+ }
+ }
+
+ if created {
+ if _, err := packages_model.InsertProperty(ctx, packages_model.PropertyTypePackage, p.ID, container_module.PropertyRepository, strings.ToLower(mci.Owner.LowerName+"/"+mci.Image)); err != nil {
+ log.Error("Error setting package property: %v", err)
+ return nil, err
+ }
+ }
+
+ metadata.IsTagged = mci.IsTagged
+
+ metadataJSON, err := json.Marshal(metadata)
+ if err != nil {
+ return nil, err
+ }
+
+ _pv := &packages_model.PackageVersion{
+ PackageID: p.ID,
+ CreatorID: mci.Creator.ID,
+ Version: strings.ToLower(mci.Reference),
+ LowerVersion: strings.ToLower(mci.Reference),
+ MetadataJSON: string(metadataJSON),
+ }
+ var pv *packages_model.PackageVersion
+ if pv, err = packages_model.GetOrInsertVersion(ctx, _pv); err != nil {
+ if err == packages_model.ErrDuplicatePackageVersion {
+ if err := packages_service.DeletePackageVersionAndReferences(ctx, pv); err != nil {
+ return nil, err
+ }
+
+ // keep download count on overwrite
+ _pv.DownloadCount = pv.DownloadCount
+
+ if pv, err = packages_model.GetOrInsertVersion(ctx, _pv); err != nil {
+ log.Error("Error inserting package: %v", err)
+ return nil, err
+ }
+ } else {
+ log.Error("Error inserting package: %v", err)
+ return nil, err
+ }
+ }
+
+ if err := packages_service.CheckCountQuotaExceeded(ctx, mci.Creator, mci.Owner); err != nil {
+ return nil, err
+ }
+
+ if mci.IsTagged {
+ if _, err := packages_model.InsertProperty(ctx, packages_model.PropertyTypeVersion, pv.ID, container_module.PropertyManifestTagged, ""); err != nil {
+ log.Error("Error setting package version property: %v", err)
+ return nil, err
+ }
+ }
+ for _, manifest := range metadata.Manifests {
+ if _, err := packages_model.InsertProperty(ctx, packages_model.PropertyTypeVersion, pv.ID, container_module.PropertyManifestReference, manifest.Digest); err != nil {
+ log.Error("Error setting package version property: %v", err)
+ return nil, err
+ }
+ }
+
+ return pv, nil
+}
+
+type blobReference struct {
+ Digest digest.Digest
+ MediaType string
+ Name string
+ File *packages_model.PackageFileDescriptor
+ ExpectedSize int64
+ IsLead bool
+}
+
+func createFileFromBlobReference(ctx context.Context, pv, uploadVersion *packages_model.PackageVersion, ref *blobReference) error {
+ if ref.File.Blob.Size != ref.ExpectedSize {
+ return errSizeInvalid
+ }
+
+ if ref.Name == "" {
+ ref.Name = strings.ToLower(fmt.Sprintf("sha256_%s", ref.File.Blob.HashSHA256))
+ }
+
+ pf := &packages_model.PackageFile{
+ VersionID: pv.ID,
+ BlobID: ref.File.Blob.ID,
+ Name: ref.Name,
+ LowerName: ref.Name,
+ IsLead: ref.IsLead,
+ }
+ var err error
+ if pf, err = packages_model.TryInsertFile(ctx, pf); err != nil {
+ if err == packages_model.ErrDuplicatePackageFile {
+ // Skip this blob because the manifest contains the same filesystem layer multiple times.
+ return nil
+ }
+ log.Error("Error inserting package file: %v", err)
+ return err
+ }
+
+ props := map[string]string{
+ container_module.PropertyMediaType: ref.MediaType,
+ container_module.PropertyDigest: string(ref.Digest),
+ }
+ for name, value := range props {
+ if _, err := packages_model.InsertProperty(ctx, packages_model.PropertyTypeFile, pf.ID, name, value); err != nil {
+ log.Error("Error setting package file property: %v", err)
+ return err
+ }
+ }
+
+ // Remove the file from the blob upload version
+ if uploadVersion != nil && ref.File.File != nil && uploadVersion.ID == ref.File.File.VersionID {
+ if err := packages_service.DeletePackageFile(ctx, ref.File.File); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+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)
+ return nil, false, "", err
+ }
+ // FIXME: Workaround to be removed in v1.20
+ // https://github.com/go-gitea/gitea/issues/19586
+ if exists {
+ err = packages_module.NewContentStore().Has(packages_module.BlobHash256Key(pb.HashSHA256))
+ if err != nil && (errors.Is(err, util.ErrNotExist) || errors.Is(err, os.ErrNotExist)) {
+ log.Debug("Package registry inconsistent: blob %s does not exist on file system", pb.HashSHA256)
+ exists = false
+ }
+ }
+ if !exists {
+ contentStore := packages_module.NewContentStore()
+ if err := contentStore.Save(packages_module.BlobHash256Key(pb.HashSHA256), buf, buf.Size()); err != nil {
+ log.Error("Error saving package blob in content store: %v", err)
+ return nil, false, "", err
+ }
+ }
+
+ manifestDigest := digestFromHashSummer(buf)
+ err = createFileFromBlobReference(ctx, pv, nil, &blobReference{
+ Digest: digest.Digest(manifestDigest),
+ MediaType: mci.MediaType,
+ Name: container_model.ManifestFilename,
+ File: &packages_model.PackageFileDescriptor{Blob: pb},
+ ExpectedSize: pb.Size,
+ IsLead: true,
+ })
+
+ return pb, !exists, manifestDigest, err
+}
diff --git a/routers/api/packages/cran/cran.go b/routers/api/packages/cran/cran.go
new file mode 100644
index 0000000..f1d6167
--- /dev/null
+++ b/routers/api/packages/cran/cran.go
@@ -0,0 +1,264 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package cran
+
+import (
+ "compress/gzip"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+
+ packages_model "code.gitea.io/gitea/models/packages"
+ cran_model "code.gitea.io/gitea/models/packages/cran"
+ packages_module "code.gitea.io/gitea/modules/packages"
+ cran_module "code.gitea.io/gitea/modules/packages/cran"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/routers/api/packages/helper"
+ "code.gitea.io/gitea/services/context"
+ packages_service "code.gitea.io/gitea/services/packages"
+)
+
+func apiError(ctx *context.Context, status int, obj any) {
+ helper.LogAndProcessError(ctx, status, obj, func(message string) {
+ ctx.PlainText(status, message)
+ })
+}
+
+func EnumerateSourcePackages(ctx *context.Context) {
+ enumeratePackages(ctx, ctx.Params("format"), &cran_model.SearchOptions{
+ OwnerID: ctx.Package.Owner.ID,
+ FileType: cran_module.TypeSource,
+ })
+}
+
+func EnumerateBinaryPackages(ctx *context.Context) {
+ enumeratePackages(ctx, ctx.Params("format"), &cran_model.SearchOptions{
+ OwnerID: ctx.Package.Owner.ID,
+ FileType: cran_module.TypeBinary,
+ Platform: ctx.Params("platform"),
+ RVersion: ctx.Params("rversion"),
+ })
+}
+
+func enumeratePackages(ctx *context.Context, format string, opts *cran_model.SearchOptions) {
+ if format != "" && format != ".gz" {
+ apiError(ctx, http.StatusNotFound, nil)
+ return
+ }
+
+ pvs, err := cran_model.SearchLatestVersions(ctx, opts)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ if len(pvs) == 0 {
+ apiError(ctx, http.StatusNotFound, nil)
+ return
+ }
+
+ pds, err := packages_model.GetPackageDescriptors(ctx, pvs)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ var w io.Writer = ctx.Resp
+
+ if format == ".gz" {
+ ctx.Resp.Header().Set("Content-Type", "application/x-gzip")
+
+ gzw := gzip.NewWriter(w)
+ defer gzw.Close()
+
+ w = gzw
+ } else {
+ ctx.Resp.Header().Set("Content-Type", "text/plain;charset=utf-8")
+ }
+ ctx.Resp.WriteHeader(http.StatusOK)
+
+ for i, pd := range pds {
+ if i > 0 {
+ fmt.Fprintln(w)
+ }
+
+ var pfd *packages_model.PackageFileDescriptor
+ for _, d := range pd.Files {
+ if d.Properties.GetByName(cran_module.PropertyType) == opts.FileType &&
+ d.Properties.GetByName(cran_module.PropertyPlatform) == opts.Platform &&
+ d.Properties.GetByName(cran_module.PropertyRVersion) == opts.RVersion {
+ pfd = d
+ break
+ }
+ }
+
+ metadata := pd.Metadata.(*cran_module.Metadata)
+
+ fmt.Fprintln(w, "Package:", pd.Package.Name)
+ fmt.Fprintln(w, "Version:", pd.Version.Version)
+ if metadata.License != "" {
+ fmt.Fprintln(w, "License:", metadata.License)
+ }
+ if len(metadata.Depends) > 0 {
+ fmt.Fprintln(w, "Depends:", strings.Join(metadata.Depends, ", "))
+ }
+ if len(metadata.Imports) > 0 {
+ fmt.Fprintln(w, "Imports:", strings.Join(metadata.Imports, ", "))
+ }
+ if len(metadata.LinkingTo) > 0 {
+ fmt.Fprintln(w, "LinkingTo:", strings.Join(metadata.LinkingTo, ", "))
+ }
+ if len(metadata.Suggests) > 0 {
+ fmt.Fprintln(w, "Suggests:", strings.Join(metadata.Suggests, ", "))
+ }
+ needsCompilation := "no"
+ if metadata.NeedsCompilation {
+ needsCompilation = "yes"
+ }
+ fmt.Fprintln(w, "NeedsCompilation:", needsCompilation)
+ fmt.Fprintln(w, "MD5sum:", pfd.Blob.HashMD5)
+ }
+}
+
+func UploadSourcePackageFile(ctx *context.Context) {
+ uploadPackageFile(
+ ctx,
+ packages_model.EmptyFileKey,
+ map[string]string{
+ cran_module.PropertyType: cran_module.TypeSource,
+ },
+ )
+}
+
+func UploadBinaryPackageFile(ctx *context.Context) {
+ platform, rversion := ctx.FormTrim("platform"), ctx.FormTrim("rversion")
+ if platform == "" || rversion == "" {
+ apiError(ctx, http.StatusBadRequest, nil)
+ return
+ }
+
+ uploadPackageFile(
+ ctx,
+ platform+"|"+rversion,
+ map[string]string{
+ cran_module.PropertyType: cran_module.TypeBinary,
+ cran_module.PropertyPlatform: platform,
+ cran_module.PropertyRVersion: rversion,
+ },
+ )
+}
+
+func uploadPackageFile(ctx *context.Context, compositeKey string, properties map[string]string) {
+ upload, needToClose, err := ctx.UploadStream()
+ if err != nil {
+ apiError(ctx, http.StatusBadRequest, err)
+ return
+ }
+ if needToClose {
+ defer upload.Close()
+ }
+
+ buf, err := packages_module.CreateHashedBufferFromReader(upload)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ defer buf.Close()
+
+ pck, err := cran_module.ParsePackage(buf, buf.Size())
+ if err != nil {
+ if errors.Is(err, util.ErrInvalidArgument) {
+ apiError(ctx, http.StatusBadRequest, err)
+ } else {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ if _, err := buf.Seek(0, io.SeekStart); err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ _, _, err = packages_service.CreatePackageOrAddFileToExisting(
+ ctx,
+ &packages_service.PackageCreationInfo{
+ PackageInfo: packages_service.PackageInfo{
+ Owner: ctx.Package.Owner,
+ PackageType: packages_model.TypeCran,
+ Name: pck.Name,
+ Version: pck.Version,
+ },
+ SemverCompatible: false,
+ Creator: ctx.Doer,
+ Metadata: pck.Metadata,
+ },
+ &packages_service.PackageFileCreationInfo{
+ PackageFileInfo: packages_service.PackageFileInfo{
+ Filename: fmt.Sprintf("%s_%s%s", pck.Name, pck.Version, pck.FileExtension),
+ CompositeKey: compositeKey,
+ },
+ Creator: ctx.Doer,
+ Data: buf,
+ IsLead: true,
+ Properties: properties,
+ },
+ )
+ if err != nil {
+ switch err {
+ case packages_model.ErrDuplicatePackageFile:
+ apiError(ctx, http.StatusConflict, err)
+ case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize:
+ apiError(ctx, http.StatusForbidden, err)
+ default:
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ ctx.Status(http.StatusCreated)
+}
+
+func DownloadSourcePackageFile(ctx *context.Context) {
+ downloadPackageFile(ctx, &cran_model.SearchOptions{
+ OwnerID: ctx.Package.Owner.ID,
+ FileType: cran_module.TypeSource,
+ Filename: ctx.Params("filename"),
+ })
+}
+
+func DownloadBinaryPackageFile(ctx *context.Context) {
+ downloadPackageFile(ctx, &cran_model.SearchOptions{
+ OwnerID: ctx.Package.Owner.ID,
+ FileType: cran_module.TypeBinary,
+ Platform: ctx.Params("platform"),
+ RVersion: ctx.Params("rversion"),
+ Filename: ctx.Params("filename"),
+ })
+}
+
+func downloadPackageFile(ctx *context.Context, opts *cran_model.SearchOptions) {
+ pf, err := cran_model.SearchFile(ctx, opts)
+ if err != nil {
+ if errors.Is(err, util.ErrNotExist) {
+ apiError(ctx, http.StatusNotFound, err)
+ } else {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ s, u, _, err := packages_service.GetPackageFileStream(ctx, pf)
+ if err != nil {
+ if errors.Is(err, util.ErrNotExist) {
+ apiError(ctx, http.StatusNotFound, err)
+ } else {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ helper.ServePackageFile(ctx, s, u, pf)
+}
diff --git a/routers/api/packages/debian/debian.go b/routers/api/packages/debian/debian.go
new file mode 100644
index 0000000..8c05476
--- /dev/null
+++ b/routers/api/packages/debian/debian.go
@@ -0,0 +1,309 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package debian
+
+import (
+ stdctx "context"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+
+ "code.gitea.io/gitea/models/db"
+ packages_model "code.gitea.io/gitea/models/packages"
+ packages_module "code.gitea.io/gitea/modules/packages"
+ debian_module "code.gitea.io/gitea/modules/packages/debian"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/routers/api/packages/helper"
+ "code.gitea.io/gitea/services/context"
+ notify_service "code.gitea.io/gitea/services/notify"
+ packages_service "code.gitea.io/gitea/services/packages"
+ debian_service "code.gitea.io/gitea/services/packages/debian"
+)
+
+func apiError(ctx *context.Context, status int, obj any) {
+ helper.LogAndProcessError(ctx, status, obj, func(message string) {
+ ctx.PlainText(status, message)
+ })
+}
+
+func GetRepositoryKey(ctx *context.Context) {
+ _, pub, err := debian_service.GetOrCreateKeyPair(ctx, ctx.Package.Owner.ID)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ ctx.ServeContent(strings.NewReader(pub), &context.ServeHeaderOptions{
+ ContentType: "application/pgp-keys",
+ Filename: "repository.key",
+ })
+}
+
+// https://wiki.debian.org/DebianRepository/Format#A.22Release.22_files
+// https://wiki.debian.org/DebianRepository/Format#A.22Packages.22_Indices
+func GetRepositoryFile(ctx *context.Context) {
+ pv, err := debian_service.GetOrCreateRepositoryVersion(ctx, ctx.Package.Owner.ID)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ key := ctx.Params("distribution")
+
+ component := ctx.Params("component")
+ architecture := strings.TrimPrefix(ctx.Params("architecture"), "binary-")
+ if component != "" && architecture != "" {
+ key += "|" + component + "|" + architecture
+ }
+
+ s, u, pf, err := packages_service.GetFileStreamByPackageVersion(
+ ctx,
+ pv,
+ &packages_service.PackageFileInfo{
+ Filename: ctx.Params("filename"),
+ CompositeKey: key,
+ },
+ )
+ if err != nil {
+ if err == packages_model.ErrPackageNotExist || err == packages_model.ErrPackageFileNotExist {
+ apiError(ctx, http.StatusNotFound, err)
+ } else {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ helper.ServePackageFile(ctx, s, u, pf)
+}
+
+// https://wiki.debian.org/DebianRepository/Format#indices_acquisition_via_hashsums_.28by-hash.29
+func GetRepositoryFileByHash(ctx *context.Context) {
+ pv, err := debian_service.GetOrCreateRepositoryVersion(ctx, ctx.Package.Owner.ID)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ algorithm := strings.ToLower(ctx.Params("algorithm"))
+ if algorithm == "md5sum" {
+ algorithm = "md5"
+ }
+
+ pfs, _, err := packages_model.SearchFiles(ctx, &packages_model.PackageFileSearchOptions{
+ VersionID: pv.ID,
+ Hash: strings.ToLower(ctx.Params("hash")),
+ HashAlgorithm: algorithm,
+ })
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ if len(pfs) != 1 {
+ apiError(ctx, http.StatusNotFound, nil)
+ return
+ }
+
+ s, u, pf, err := packages_service.GetPackageFileStream(ctx, pfs[0])
+ if err != nil {
+ if errors.Is(err, util.ErrNotExist) {
+ apiError(ctx, http.StatusNotFound, err)
+ } else {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ helper.ServePackageFile(ctx, s, u, pf)
+}
+
+func UploadPackageFile(ctx *context.Context) {
+ distribution := strings.TrimSpace(ctx.Params("distribution"))
+ component := strings.TrimSpace(ctx.Params("component"))
+ if distribution == "" || component == "" {
+ apiError(ctx, http.StatusBadRequest, "invalid distribution or component")
+ return
+ }
+
+ upload, needToClose, err := ctx.UploadStream()
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ if needToClose {
+ defer upload.Close()
+ }
+
+ buf, err := packages_module.CreateHashedBufferFromReader(upload)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ defer buf.Close()
+
+ pck, err := debian_module.ParsePackage(buf)
+ if err != nil {
+ if errors.Is(err, util.ErrInvalidArgument) {
+ apiError(ctx, http.StatusBadRequest, err)
+ } else {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ if _, err := buf.Seek(0, io.SeekStart); err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ _, _, err = packages_service.CreatePackageOrAddFileToExisting(
+ ctx,
+ &packages_service.PackageCreationInfo{
+ PackageInfo: packages_service.PackageInfo{
+ Owner: ctx.Package.Owner,
+ PackageType: packages_model.TypeDebian,
+ Name: pck.Name,
+ Version: pck.Version,
+ },
+ Creator: ctx.Doer,
+ Metadata: pck.Metadata,
+ },
+ &packages_service.PackageFileCreationInfo{
+ PackageFileInfo: packages_service.PackageFileInfo{
+ Filename: fmt.Sprintf("%s_%s_%s.deb", pck.Name, pck.Version, pck.Architecture),
+ CompositeKey: fmt.Sprintf("%s|%s", distribution, component),
+ },
+ Creator: ctx.Doer,
+ Data: buf,
+ IsLead: true,
+ Properties: map[string]string{
+ debian_module.PropertyDistribution: distribution,
+ debian_module.PropertyComponent: component,
+ debian_module.PropertyArchitecture: pck.Architecture,
+ debian_module.PropertyControl: pck.Control,
+ },
+ },
+ )
+ if err != nil {
+ switch err {
+ case packages_model.ErrDuplicatePackageVersion, packages_model.ErrDuplicatePackageFile:
+ apiError(ctx, http.StatusConflict, err)
+ case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize:
+ apiError(ctx, http.StatusForbidden, err)
+ default:
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ if err := debian_service.BuildSpecificRepositoryFiles(ctx, ctx.Package.Owner.ID, distribution, component, pck.Architecture); err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ ctx.Status(http.StatusCreated)
+}
+
+func DownloadPackageFile(ctx *context.Context) {
+ name := ctx.Params("name")
+ version := ctx.Params("version")
+
+ s, u, pf, err := packages_service.GetFileStreamByPackageNameAndVersion(
+ ctx,
+ &packages_service.PackageInfo{
+ Owner: ctx.Package.Owner,
+ PackageType: packages_model.TypeDebian,
+ Name: name,
+ Version: version,
+ },
+ &packages_service.PackageFileInfo{
+ Filename: fmt.Sprintf("%s_%s_%s.deb", name, version, ctx.Params("architecture")),
+ CompositeKey: fmt.Sprintf("%s|%s", ctx.Params("distribution"), ctx.Params("component")),
+ },
+ )
+ if err != nil {
+ if errors.Is(err, util.ErrNotExist) {
+ apiError(ctx, http.StatusNotFound, err)
+ } else {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ helper.ServePackageFile(ctx, s, u, pf, &context.ServeHeaderOptions{
+ ContentType: "application/vnd.debian.binary-package",
+ Filename: pf.Name,
+ LastModified: pf.CreatedUnix.AsLocalTime(),
+ })
+}
+
+func DeletePackageFile(ctx *context.Context) {
+ distribution := ctx.Params("distribution")
+ component := ctx.Params("component")
+ name := ctx.Params("name")
+ version := ctx.Params("version")
+ architecture := ctx.Params("architecture")
+
+ owner := ctx.Package.Owner
+
+ var pd *packages_model.PackageDescriptor
+
+ err := db.WithTx(ctx, func(ctx stdctx.Context) error {
+ pv, err := packages_model.GetVersionByNameAndVersion(ctx, owner.ID, packages_model.TypeDebian, name, version)
+ if err != nil {
+ return err
+ }
+
+ pf, err := packages_model.GetFileForVersionByName(
+ ctx,
+ pv.ID,
+ fmt.Sprintf("%s_%s_%s.deb", name, version, architecture),
+ fmt.Sprintf("%s|%s", distribution, component),
+ )
+ if err != nil {
+ return err
+ }
+
+ if err := packages_service.DeletePackageFile(ctx, pf); err != nil {
+ return err
+ }
+
+ has, err := packages_model.HasVersionFileReferences(ctx, pv.ID)
+ if err != nil {
+ return err
+ }
+ if !has {
+ pd, err = packages_model.GetPackageDescriptor(ctx, pv)
+ if err != nil {
+ return err
+ }
+
+ if err := packages_service.DeletePackageVersionAndReferences(ctx, pv); err != nil {
+ return err
+ }
+ }
+
+ return nil
+ })
+ if err != nil {
+ if errors.Is(err, util.ErrNotExist) {
+ apiError(ctx, http.StatusNotFound, err)
+ } else {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ if pd != nil {
+ notify_service.PackageDelete(ctx, ctx.Doer, pd)
+ }
+
+ if err := debian_service.BuildSpecificRepositoryFiles(ctx, ctx.Package.Owner.ID, distribution, component, architecture); err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ ctx.Status(http.StatusNoContent)
+}
diff --git a/routers/api/packages/generic/generic.go b/routers/api/packages/generic/generic.go
new file mode 100644
index 0000000..e66f3ee
--- /dev/null
+++ b/routers/api/packages/generic/generic.go
@@ -0,0 +1,212 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package generic
+
+import (
+ "errors"
+ "net/http"
+ "regexp"
+ "strings"
+ "unicode"
+
+ packages_model "code.gitea.io/gitea/models/packages"
+ "code.gitea.io/gitea/modules/log"
+ packages_module "code.gitea.io/gitea/modules/packages"
+ "code.gitea.io/gitea/routers/api/packages/helper"
+ "code.gitea.io/gitea/services/context"
+ packages_service "code.gitea.io/gitea/services/packages"
+)
+
+var (
+ packageNameRegex = regexp.MustCompile(`\A[-_+.\w]+\z`)
+ filenameRegex = regexp.MustCompile(`\A[-_+=:;.()\[\]{}~!@#$%^& \w]+\z`)
+)
+
+func apiError(ctx *context.Context, status int, obj any) {
+ helper.LogAndProcessError(ctx, status, obj, func(message string) {
+ ctx.PlainText(status, message)
+ })
+}
+
+// DownloadPackageFile serves the specific generic package.
+func DownloadPackageFile(ctx *context.Context) {
+ s, u, pf, err := packages_service.GetFileStreamByPackageNameAndVersion(
+ ctx,
+ &packages_service.PackageInfo{
+ Owner: ctx.Package.Owner,
+ PackageType: packages_model.TypeGeneric,
+ Name: ctx.Params("packagename"),
+ Version: ctx.Params("packageversion"),
+ },
+ &packages_service.PackageFileInfo{
+ Filename: ctx.Params("filename"),
+ },
+ )
+ if err != nil {
+ if err == packages_model.ErrPackageNotExist || err == packages_model.ErrPackageFileNotExist {
+ apiError(ctx, http.StatusNotFound, err)
+ return
+ }
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ helper.ServePackageFile(ctx, s, u, pf)
+}
+
+func isValidPackageName(packageName string) bool {
+ if len(packageName) == 1 && !unicode.IsLetter(rune(packageName[0])) && !unicode.IsNumber(rune(packageName[0])) {
+ return false
+ }
+ return packageNameRegex.MatchString(packageName) && packageName != ".."
+}
+
+func isValidFileName(filename string) bool {
+ return filenameRegex.MatchString(filename) &&
+ strings.TrimSpace(filename) == filename &&
+ filename != "." && filename != ".."
+}
+
+// UploadPackage uploads the specific generic package.
+// Duplicated packages get rejected.
+func UploadPackage(ctx *context.Context) {
+ packageName := ctx.Params("packagename")
+ filename := ctx.Params("filename")
+
+ if !isValidPackageName(packageName) {
+ apiError(ctx, http.StatusBadRequest, errors.New("invalid package name"))
+ return
+ }
+
+ if !isValidFileName(filename) {
+ apiError(ctx, http.StatusBadRequest, errors.New("invalid filename"))
+ return
+ }
+
+ packageVersion := ctx.Params("packageversion")
+ if packageVersion != strings.TrimSpace(packageVersion) {
+ apiError(ctx, http.StatusBadRequest, errors.New("invalid package version"))
+ return
+ }
+
+ upload, needToClose, err := ctx.UploadStream()
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ if needToClose {
+ defer upload.Close()
+ }
+
+ buf, err := packages_module.CreateHashedBufferFromReader(upload)
+ if err != nil {
+ log.Error("Error creating hashed buffer: %v", err)
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ defer buf.Close()
+
+ _, _, err = packages_service.CreatePackageOrAddFileToExisting(
+ ctx,
+ &packages_service.PackageCreationInfo{
+ PackageInfo: packages_service.PackageInfo{
+ Owner: ctx.Package.Owner,
+ PackageType: packages_model.TypeGeneric,
+ Name: packageName,
+ Version: packageVersion,
+ },
+ Creator: ctx.Doer,
+ },
+ &packages_service.PackageFileCreationInfo{
+ PackageFileInfo: packages_service.PackageFileInfo{
+ Filename: filename,
+ },
+ Creator: ctx.Doer,
+ Data: buf,
+ IsLead: true,
+ },
+ )
+ if err != nil {
+ switch err {
+ case packages_model.ErrDuplicatePackageFile:
+ apiError(ctx, http.StatusConflict, err)
+ case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize:
+ apiError(ctx, http.StatusForbidden, err)
+ default:
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ ctx.Status(http.StatusCreated)
+}
+
+// DeletePackage deletes the specific generic package.
+func DeletePackage(ctx *context.Context) {
+ err := packages_service.RemovePackageVersionByNameAndVersion(
+ ctx,
+ ctx.Doer,
+ &packages_service.PackageInfo{
+ Owner: ctx.Package.Owner,
+ PackageType: packages_model.TypeGeneric,
+ Name: ctx.Params("packagename"),
+ Version: ctx.Params("packageversion"),
+ },
+ )
+ if err != nil {
+ if err == packages_model.ErrPackageNotExist {
+ apiError(ctx, http.StatusNotFound, err)
+ return
+ }
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ ctx.Status(http.StatusNoContent)
+}
+
+// DeletePackageFile deletes the specific file of a generic package.
+func DeletePackageFile(ctx *context.Context) {
+ pv, pf, err := func() (*packages_model.PackageVersion, *packages_model.PackageFile, error) {
+ pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeGeneric, ctx.Params("packagename"), ctx.Params("packageversion"))
+ if err != nil {
+ return nil, nil, err
+ }
+
+ pf, err := packages_model.GetFileForVersionByName(ctx, pv.ID, ctx.Params("filename"), packages_model.EmptyFileKey)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ return pv, pf, nil
+ }()
+ if err != nil {
+ if err == packages_model.ErrPackageNotExist || err == packages_model.ErrPackageFileNotExist {
+ apiError(ctx, http.StatusNotFound, err)
+ return
+ }
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ pfs, err := packages_model.GetFilesByVersionID(ctx, pv.ID)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ if len(pfs) == 1 {
+ if err := packages_service.RemovePackageVersion(ctx, ctx.Doer, pv); err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ } else {
+ if err := packages_service.DeletePackageFile(ctx, pf); err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ }
+
+ ctx.Status(http.StatusNoContent)
+}
diff --git a/routers/api/packages/generic/generic_test.go b/routers/api/packages/generic/generic_test.go
new file mode 100644
index 0000000..1acaafe
--- /dev/null
+++ b/routers/api/packages/generic/generic_test.go
@@ -0,0 +1,65 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package generic
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestValidatePackageName(t *testing.T) {
+ bad := []string{
+ "",
+ ".",
+ "..",
+ "-",
+ "a?b",
+ "a b",
+ "a/b",
+ }
+ for _, name := range bad {
+ assert.False(t, isValidPackageName(name), "bad=%q", name)
+ }
+
+ good := []string{
+ "a",
+ "1",
+ "a-",
+ "a_b",
+ "c.d+",
+ }
+ for _, name := range good {
+ assert.True(t, isValidPackageName(name), "good=%q", name)
+ }
+}
+
+func TestValidateFileName(t *testing.T) {
+ bad := []string{
+ "",
+ ".",
+ "..",
+ "a?b",
+ "a/b",
+ " a",
+ "a ",
+ }
+ for _, name := range bad {
+ assert.False(t, isValidFileName(name), "bad=%q", name)
+ }
+
+ good := []string{
+ "-",
+ "a",
+ "1",
+ "a-",
+ "a_b",
+ "a b",
+ "c.d+",
+ `-_+=:;.()[]{}~!@#$%^& aA1`,
+ }
+ for _, name := range good {
+ assert.True(t, isValidFileName(name), "good=%q", name)
+ }
+}
diff --git a/routers/api/packages/goproxy/goproxy.go b/routers/api/packages/goproxy/goproxy.go
new file mode 100644
index 0000000..56a07db
--- /dev/null
+++ b/routers/api/packages/goproxy/goproxy.go
@@ -0,0 +1,224 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package goproxy
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "sort"
+ "time"
+
+ packages_model "code.gitea.io/gitea/models/packages"
+ "code.gitea.io/gitea/modules/optional"
+ packages_module "code.gitea.io/gitea/modules/packages"
+ goproxy_module "code.gitea.io/gitea/modules/packages/goproxy"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/routers/api/packages/helper"
+ "code.gitea.io/gitea/services/context"
+ packages_service "code.gitea.io/gitea/services/packages"
+)
+
+func apiError(ctx *context.Context, status int, obj any) {
+ helper.LogAndProcessError(ctx, status, obj, func(message string) {
+ ctx.PlainText(status, message)
+ })
+}
+
+func EnumeratePackageVersions(ctx *context.Context) {
+ pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeGo, ctx.Params("name"))
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ if len(pvs) == 0 {
+ apiError(ctx, http.StatusNotFound, err)
+ return
+ }
+
+ sort.Slice(pvs, func(i, j int) bool {
+ return pvs[i].CreatedUnix < pvs[j].CreatedUnix
+ })
+
+ ctx.Resp.Header().Set("Content-Type", "text/plain;charset=utf-8")
+
+ for _, pv := range pvs {
+ fmt.Fprintln(ctx.Resp, pv.Version)
+ }
+}
+
+func PackageVersionMetadata(ctx *context.Context) {
+ pv, err := resolvePackage(ctx, ctx.Package.Owner.ID, ctx.Params("name"), ctx.Params("version"))
+ if err != nil {
+ if errors.Is(err, util.ErrNotExist) {
+ apiError(ctx, http.StatusNotFound, err)
+ } else {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ ctx.JSON(http.StatusOK, struct {
+ Version string `json:"Version"`
+ Time time.Time `json:"Time"`
+ }{
+ Version: pv.Version,
+ Time: pv.CreatedUnix.AsLocalTime(),
+ })
+}
+
+func PackageVersionGoModContent(ctx *context.Context) {
+ pv, err := resolvePackage(ctx, ctx.Package.Owner.ID, ctx.Params("name"), ctx.Params("version"))
+ if err != nil {
+ if errors.Is(err, util.ErrNotExist) {
+ apiError(ctx, http.StatusNotFound, err)
+ } else {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ pps, err := packages_model.GetPropertiesByName(ctx, packages_model.PropertyTypeVersion, pv.ID, goproxy_module.PropertyGoMod)
+ if err != nil || len(pps) != 1 {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ ctx.PlainText(http.StatusOK, pps[0].Value)
+}
+
+func DownloadPackageFile(ctx *context.Context) {
+ pv, err := resolvePackage(ctx, ctx.Package.Owner.ID, ctx.Params("name"), ctx.Params("version"))
+ if err != nil {
+ if errors.Is(err, util.ErrNotExist) {
+ apiError(ctx, http.StatusNotFound, err)
+ } else {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ pfs, err := packages_model.GetFilesByVersionID(ctx, pv.ID)
+ if err != nil || len(pfs) != 1 {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ s, u, _, err := packages_service.GetPackageFileStream(ctx, pfs[0])
+ if err != nil {
+ if errors.Is(err, util.ErrNotExist) {
+ apiError(ctx, http.StatusNotFound, err)
+ } else {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ helper.ServePackageFile(ctx, s, u, pfs[0])
+}
+
+func resolvePackage(ctx *context.Context, ownerID int64, name, version string) (*packages_model.PackageVersion, error) {
+ var pv *packages_model.PackageVersion
+
+ if version == "latest" {
+ pvs, _, err := packages_model.SearchLatestVersions(ctx, &packages_model.PackageSearchOptions{
+ OwnerID: ownerID,
+ Type: packages_model.TypeGo,
+ Name: packages_model.SearchValue{
+ Value: name,
+ ExactMatch: true,
+ },
+ IsInternal: optional.Some(false),
+ Sort: packages_model.SortCreatedDesc,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ if len(pvs) != 1 {
+ return nil, packages_model.ErrPackageNotExist
+ }
+
+ pv = pvs[0]
+ } else {
+ var err error
+ pv, err = packages_model.GetVersionByNameAndVersion(ctx, ownerID, packages_model.TypeGo, name, version)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ return pv, nil
+}
+
+func UploadPackage(ctx *context.Context) {
+ upload, needToClose, err := ctx.UploadStream()
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ if needToClose {
+ defer upload.Close()
+ }
+
+ buf, err := packages_module.CreateHashedBufferFromReader(upload)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ defer buf.Close()
+
+ pck, err := goproxy_module.ParsePackage(buf, buf.Size())
+ if err != nil {
+ if errors.Is(err, util.ErrInvalidArgument) {
+ apiError(ctx, http.StatusBadRequest, err)
+ } else {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ if _, err := buf.Seek(0, io.SeekStart); err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ _, _, err = packages_service.CreatePackageAndAddFile(
+ ctx,
+ &packages_service.PackageCreationInfo{
+ PackageInfo: packages_service.PackageInfo{
+ Owner: ctx.Package.Owner,
+ PackageType: packages_model.TypeGo,
+ Name: pck.Name,
+ Version: pck.Version,
+ },
+ Creator: ctx.Doer,
+ VersionProperties: map[string]string{
+ goproxy_module.PropertyGoMod: pck.GoMod,
+ },
+ },
+ &packages_service.PackageFileCreationInfo{
+ PackageFileInfo: packages_service.PackageFileInfo{
+ Filename: fmt.Sprintf("%v.zip", pck.Version),
+ },
+ Creator: ctx.Doer,
+ Data: buf,
+ IsLead: true,
+ },
+ )
+ if err != nil {
+ switch err {
+ case packages_model.ErrDuplicatePackageVersion:
+ apiError(ctx, http.StatusConflict, err)
+ case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize:
+ apiError(ctx, http.StatusForbidden, err)
+ default:
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ ctx.Status(http.StatusCreated)
+}
diff --git a/routers/api/packages/helm/helm.go b/routers/api/packages/helm/helm.go
new file mode 100644
index 0000000..efdb83e
--- /dev/null
+++ b/routers/api/packages/helm/helm.go
@@ -0,0 +1,217 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package helm
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ packages_model "code.gitea.io/gitea/models/packages"
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/optional"
+ packages_module "code.gitea.io/gitea/modules/packages"
+ helm_module "code.gitea.io/gitea/modules/packages/helm"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/routers/api/packages/helper"
+ "code.gitea.io/gitea/services/context"
+ packages_service "code.gitea.io/gitea/services/packages"
+
+ "gopkg.in/yaml.v3"
+)
+
+func apiError(ctx *context.Context, status int, obj any) {
+ helper.LogAndProcessError(ctx, status, obj, func(message string) {
+ type Error struct {
+ Error string `json:"error"`
+ }
+ ctx.JSON(status, Error{
+ Error: message,
+ })
+ })
+}
+
+// Index generates the Helm charts index
+func Index(ctx *context.Context) {
+ pvs, _, err := packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{
+ OwnerID: ctx.Package.Owner.ID,
+ Type: packages_model.TypeHelm,
+ IsInternal: optional.Some(false),
+ })
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ baseURL := setting.AppURL + "api/packages/" + url.PathEscape(ctx.Package.Owner.Name) + "/helm"
+
+ type ChartVersion struct {
+ helm_module.Metadata `yaml:",inline"`
+ URLs []string `yaml:"urls"`
+ Created time.Time `yaml:"created,omitempty"`
+ Removed bool `yaml:"removed,omitempty"`
+ Digest string `yaml:"digest,omitempty"`
+ }
+
+ type ServerInfo struct {
+ ContextPath string `yaml:"contextPath,omitempty"`
+ }
+
+ type Index struct {
+ APIVersion string `yaml:"apiVersion"`
+ Entries map[string][]*ChartVersion `yaml:"entries"`
+ Generated time.Time `yaml:"generated,omitempty"`
+ ServerInfo *ServerInfo `yaml:"serverInfo,omitempty"`
+ }
+
+ entries := make(map[string][]*ChartVersion)
+ for _, pv := range pvs {
+ metadata := &helm_module.Metadata{}
+ if err := json.Unmarshal([]byte(pv.MetadataJSON), &metadata); err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ entries[metadata.Name] = append(entries[metadata.Name], &ChartVersion{
+ Metadata: *metadata,
+ Created: pv.CreatedUnix.AsTime(),
+ URLs: []string{fmt.Sprintf("%s/%s", baseURL, url.PathEscape(createFilename(metadata)))},
+ })
+ }
+
+ ctx.Resp.WriteHeader(http.StatusOK)
+ if err := yaml.NewEncoder(ctx.Resp).Encode(&Index{
+ APIVersion: "v1",
+ Entries: entries,
+ Generated: time.Now(),
+ ServerInfo: &ServerInfo{
+ ContextPath: setting.AppSubURL + "/api/packages/" + url.PathEscape(ctx.Package.Owner.Name) + "/helm",
+ },
+ }); err != nil {
+ log.Error("YAML encode failed: %v", err)
+ }
+}
+
+// DownloadPackageFile serves the content of a package
+func DownloadPackageFile(ctx *context.Context) {
+ filename := ctx.Params("filename")
+
+ pvs, _, err := packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{
+ OwnerID: ctx.Package.Owner.ID,
+ Type: packages_model.TypeHelm,
+ Name: packages_model.SearchValue{
+ ExactMatch: true,
+ Value: ctx.Params("package"),
+ },
+ HasFileWithName: filename,
+ IsInternal: optional.Some(false),
+ })
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ if len(pvs) != 1 {
+ apiError(ctx, http.StatusNotFound, nil)
+ return
+ }
+
+ s, u, pf, err := packages_service.GetFileStreamByPackageVersion(
+ ctx,
+ pvs[0],
+ &packages_service.PackageFileInfo{
+ Filename: filename,
+ },
+ )
+ if err != nil {
+ if err == packages_model.ErrPackageFileNotExist {
+ apiError(ctx, http.StatusNotFound, err)
+ return
+ }
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ helper.ServePackageFile(ctx, s, u, pf)
+}
+
+// UploadPackage creates a new package
+func UploadPackage(ctx *context.Context) {
+ upload, needToClose, err := ctx.UploadStream()
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ if needToClose {
+ defer upload.Close()
+ }
+
+ buf, err := packages_module.CreateHashedBufferFromReader(upload)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ defer buf.Close()
+
+ metadata, err := helm_module.ParseChartArchive(buf)
+ if err != nil {
+ if errors.Is(err, util.ErrInvalidArgument) {
+ apiError(ctx, http.StatusBadRequest, err)
+ } else {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ if _, err := buf.Seek(0, io.SeekStart); err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ _, _, err = packages_service.CreatePackageOrAddFileToExisting(
+ ctx,
+ &packages_service.PackageCreationInfo{
+ PackageInfo: packages_service.PackageInfo{
+ Owner: ctx.Package.Owner,
+ PackageType: packages_model.TypeHelm,
+ Name: metadata.Name,
+ Version: metadata.Version,
+ },
+ SemverCompatible: true,
+ Creator: ctx.Doer,
+ Metadata: metadata,
+ },
+ &packages_service.PackageFileCreationInfo{
+ PackageFileInfo: packages_service.PackageFileInfo{
+ Filename: createFilename(metadata),
+ },
+ Creator: ctx.Doer,
+ Data: buf,
+ IsLead: true,
+ OverwriteExisting: true,
+ },
+ )
+ if err != nil {
+ switch err {
+ case packages_model.ErrDuplicatePackageVersion:
+ apiError(ctx, http.StatusConflict, err)
+ case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize:
+ apiError(ctx, http.StatusForbidden, err)
+ default:
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ ctx.Status(http.StatusCreated)
+}
+
+func createFilename(metadata *helm_module.Metadata) string {
+ return strings.ToLower(fmt.Sprintf("%s-%s.tgz", metadata.Name, metadata.Version))
+}
diff --git a/routers/api/packages/helper/helper.go b/routers/api/packages/helper/helper.go
new file mode 100644
index 0000000..cdb6410
--- /dev/null
+++ b/routers/api/packages/helper/helper.go
@@ -0,0 +1,63 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package helper
+
+import (
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+
+ packages_model "code.gitea.io/gitea/models/packages"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/services/context"
+)
+
+// LogAndProcessError logs an error and calls a custom callback with the processed error message.
+// If the error is an InternalServerError the message is stripped if the user is not an admin.
+func LogAndProcessError(ctx *context.Context, status int, obj any, cb func(string)) {
+ var message string
+ if err, ok := obj.(error); ok {
+ message = err.Error()
+ } else if obj != nil {
+ message = fmt.Sprintf("%s", obj)
+ }
+ if status == http.StatusInternalServerError {
+ log.ErrorWithSkip(1, message)
+
+ if setting.IsProd && (ctx.Doer == nil || !ctx.Doer.IsAdmin) {
+ message = ""
+ }
+ } else {
+ log.Debug(message)
+ }
+
+ if cb != nil {
+ cb(message)
+ }
+}
+
+// Serves the content of the package file
+// If the url is set it will redirect the request, otherwise the content is copied to the response.
+func ServePackageFile(ctx *context.Context, s io.ReadSeekCloser, u *url.URL, pf *packages_model.PackageFile, forceOpts ...*context.ServeHeaderOptions) {
+ if u != nil {
+ ctx.Redirect(u.String())
+ return
+ }
+
+ defer s.Close()
+
+ var opts *context.ServeHeaderOptions
+ if len(forceOpts) > 0 {
+ opts = forceOpts[0]
+ } else {
+ opts = &context.ServeHeaderOptions{
+ Filename: pf.Name,
+ LastModified: pf.CreatedUnix.AsLocalTime(),
+ }
+ }
+
+ ctx.ServeContent(s, opts)
+}
diff --git a/routers/api/packages/maven/api.go b/routers/api/packages/maven/api.go
new file mode 100644
index 0000000..167fe42
--- /dev/null
+++ b/routers/api/packages/maven/api.go
@@ -0,0 +1,50 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package maven
+
+import (
+ "encoding/xml"
+ "strings"
+
+ packages_model "code.gitea.io/gitea/models/packages"
+ maven_module "code.gitea.io/gitea/modules/packages/maven"
+)
+
+// MetadataResponse https://maven.apache.org/ref/3.2.5/maven-repository-metadata/repository-metadata.html
+type MetadataResponse struct {
+ XMLName xml.Name `xml:"metadata"`
+ GroupID string `xml:"groupId"`
+ ArtifactID string `xml:"artifactId"`
+ Release string `xml:"versioning>release,omitempty"`
+ Latest string `xml:"versioning>latest"`
+ Version []string `xml:"versioning>versions>version"`
+}
+
+// pds is expected to be sorted ascending by CreatedUnix
+func createMetadataResponse(pds []*packages_model.PackageDescriptor) *MetadataResponse {
+ var release *packages_model.PackageDescriptor
+
+ versions := make([]string, 0, len(pds))
+ for _, pd := range pds {
+ if !strings.HasSuffix(pd.Version.Version, "-SNAPSHOT") {
+ release = pd
+ }
+ versions = append(versions, pd.Version.Version)
+ }
+
+ latest := pds[len(pds)-1]
+
+ metadata := latest.Metadata.(*maven_module.Metadata)
+
+ resp := &MetadataResponse{
+ GroupID: metadata.GroupID,
+ ArtifactID: metadata.ArtifactID,
+ Latest: latest.Version.Version,
+ Version: versions,
+ }
+ if release != nil {
+ resp.Release = release.Version.Version
+ }
+ return resp
+}
diff --git a/routers/api/packages/maven/maven.go b/routers/api/packages/maven/maven.go
new file mode 100644
index 0000000..4181577
--- /dev/null
+++ b/routers/api/packages/maven/maven.go
@@ -0,0 +1,433 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package maven
+
+import (
+ "crypto/md5"
+ "crypto/sha1"
+ "crypto/sha256"
+ "crypto/sha512"
+ "encoding/hex"
+ "encoding/xml"
+ "errors"
+ "io"
+ "net/http"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strconv"
+ "strings"
+
+ packages_model "code.gitea.io/gitea/models/packages"
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/log"
+ packages_module "code.gitea.io/gitea/modules/packages"
+ maven_module "code.gitea.io/gitea/modules/packages/maven"
+ "code.gitea.io/gitea/routers/api/packages/helper"
+ "code.gitea.io/gitea/services/context"
+ packages_service "code.gitea.io/gitea/services/packages"
+)
+
+const (
+ mavenMetadataFile = "maven-metadata.xml"
+ extensionMD5 = ".md5"
+ extensionSHA1 = ".sha1"
+ extensionSHA256 = ".sha256"
+ extensionSHA512 = ".sha512"
+ extensionPom = ".pom"
+ extensionJar = ".jar"
+ contentTypeJar = "application/java-archive"
+ contentTypeXML = "text/xml"
+)
+
+var (
+ errInvalidParameters = errors.New("request parameters are invalid")
+ illegalCharacters = regexp.MustCompile(`[\\/:"<>|?\*]`)
+)
+
+func apiError(ctx *context.Context, status int, obj any) {
+ helper.LogAndProcessError(ctx, status, obj, func(message string) {
+ // The maven client does not present the error message to the user. Log it for users with access to server logs.
+ switch status {
+ case http.StatusBadRequest:
+ log.Warn(message)
+ case http.StatusInternalServerError:
+ log.Error(message)
+ }
+
+ ctx.PlainText(status, message)
+ })
+}
+
+// DownloadPackageFile serves the content of a package
+func DownloadPackageFile(ctx *context.Context) {
+ handlePackageFile(ctx, true)
+}
+
+// ProvidePackageFileHeader provides only the headers describing a package
+func ProvidePackageFileHeader(ctx *context.Context) {
+ handlePackageFile(ctx, false)
+}
+
+func handlePackageFile(ctx *context.Context, serveContent bool) {
+ params, err := extractPathParameters(ctx)
+ if err != nil {
+ apiError(ctx, http.StatusBadRequest, err)
+ return
+ }
+
+ if params.IsMeta && params.Version == "" {
+ serveMavenMetadata(ctx, params)
+ } else {
+ servePackageFile(ctx, params, serveContent)
+ }
+}
+
+func serveMavenMetadata(ctx *context.Context, params parameters) {
+ // /com/foo/project/maven-metadata.xml[.md5/.sha1/.sha256/.sha512]
+
+ packageName := params.GroupID + "-" + params.ArtifactID
+ pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeMaven, packageName)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ if len(pvs) == 0 {
+ apiError(ctx, http.StatusNotFound, packages_model.ErrPackageNotExist)
+ return
+ }
+
+ pds, err := packages_model.GetPackageDescriptors(ctx, pvs)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ sort.Slice(pds, func(i, j int) bool {
+ // Maven and Gradle order packages by their creation timestamp and not by their version string
+ return pds[i].Version.CreatedUnix < pds[j].Version.CreatedUnix
+ })
+
+ xmlMetadata, err := xml.Marshal(createMetadataResponse(pds))
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ xmlMetadataWithHeader := append([]byte(xml.Header), xmlMetadata...)
+
+ latest := pds[len(pds)-1]
+ // http.TimeFormat required a UTC time, refer to https://pkg.go.dev/net/http#TimeFormat
+ lastModifed := latest.Version.CreatedUnix.AsTime().UTC().Format(http.TimeFormat)
+ ctx.Resp.Header().Set("Last-Modified", lastModifed)
+
+ ext := strings.ToLower(filepath.Ext(params.Filename))
+ if isChecksumExtension(ext) {
+ var hash []byte
+ switch ext {
+ case extensionMD5:
+ tmp := md5.Sum(xmlMetadataWithHeader)
+ hash = tmp[:]
+ case extensionSHA1:
+ tmp := sha1.Sum(xmlMetadataWithHeader)
+ hash = tmp[:]
+ case extensionSHA256:
+ tmp := sha256.Sum256(xmlMetadataWithHeader)
+ hash = tmp[:]
+ case extensionSHA512:
+ tmp := sha512.Sum512(xmlMetadataWithHeader)
+ hash = tmp[:]
+ }
+ ctx.PlainText(http.StatusOK, hex.EncodeToString(hash))
+ return
+ }
+
+ ctx.Resp.Header().Set("Content-Length", strconv.Itoa(len(xmlMetadataWithHeader)))
+ ctx.Resp.Header().Set("Content-Type", contentTypeXML)
+
+ _, _ = ctx.Resp.Write(xmlMetadataWithHeader)
+}
+
+func servePackageFile(ctx *context.Context, params parameters, serveContent bool) {
+ packageName := params.GroupID + "-" + params.ArtifactID
+
+ pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeMaven, packageName, params.Version)
+ if err != nil {
+ if err == packages_model.ErrPackageNotExist {
+ apiError(ctx, http.StatusNotFound, err)
+ } else {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ filename := params.Filename
+
+ ext := strings.ToLower(filepath.Ext(filename))
+ if isChecksumExtension(ext) {
+ filename = filename[:len(filename)-len(ext)]
+ }
+
+ pf, err := packages_model.GetFileForVersionByName(ctx, pv.ID, filename, packages_model.EmptyFileKey)
+ if err != nil {
+ if err == packages_model.ErrPackageFileNotExist {
+ apiError(ctx, http.StatusNotFound, err)
+ } else {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ pb, err := packages_model.GetBlobByID(ctx, pf.BlobID)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ if isChecksumExtension(ext) {
+ var hash string
+ switch ext {
+ case extensionMD5:
+ hash = pb.HashMD5
+ case extensionSHA1:
+ hash = pb.HashSHA1
+ case extensionSHA256:
+ hash = pb.HashSHA256
+ case extensionSHA512:
+ hash = pb.HashSHA512
+ }
+ ctx.PlainText(http.StatusOK, hash)
+ return
+ }
+
+ opts := &context.ServeHeaderOptions{
+ ContentLength: &pb.Size,
+ LastModified: pf.CreatedUnix.AsLocalTime(),
+ }
+ switch ext {
+ case extensionJar:
+ opts.ContentType = contentTypeJar
+ case extensionPom:
+ opts.ContentType = contentTypeXML
+ }
+
+ if !serveContent {
+ ctx.SetServeHeaders(opts)
+ ctx.Status(http.StatusOK)
+ return
+ }
+
+ s, u, _, err := packages_service.GetPackageBlobStream(ctx, pf, pb)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ opts.Filename = pf.Name
+
+ helper.ServePackageFile(ctx, s, u, pf, opts)
+}
+
+// UploadPackageFile adds a file to the package. If the package does not exist, it gets created.
+func UploadPackageFile(ctx *context.Context) {
+ params, err := extractPathParameters(ctx)
+ if err != nil {
+ apiError(ctx, http.StatusBadRequest, err)
+ return
+ }
+
+ log.Trace("Parameters: %+v", params)
+
+ // Ignore the package index /<name>/maven-metadata.xml
+ if params.IsMeta && params.Version == "" {
+ ctx.Status(http.StatusOK)
+ return
+ }
+
+ packageName := params.GroupID + "-" + params.ArtifactID
+
+ buf, err := packages_module.CreateHashedBufferFromReader(ctx.Req.Body)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ defer buf.Close()
+
+ pvci := &packages_service.PackageCreationInfo{
+ PackageInfo: packages_service.PackageInfo{
+ Owner: ctx.Package.Owner,
+ PackageType: packages_model.TypeMaven,
+ Name: packageName,
+ Version: params.Version,
+ },
+ SemverCompatible: false,
+ Creator: ctx.Doer,
+ }
+
+ ext := filepath.Ext(params.Filename)
+
+ // Do not upload checksum files but compare the hashes.
+ if isChecksumExtension(ext) {
+ pv, err := packages_model.GetVersionByNameAndVersion(ctx, pvci.Owner.ID, pvci.PackageType, pvci.Name, pvci.Version)
+ if err != nil {
+ if err == packages_model.ErrPackageNotExist {
+ apiError(ctx, http.StatusNotFound, err)
+ return
+ }
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ pf, err := packages_model.GetFileForVersionByName(ctx, pv.ID, params.Filename[:len(params.Filename)-len(ext)], packages_model.EmptyFileKey)
+ if err != nil {
+ if err == packages_model.ErrPackageFileNotExist {
+ apiError(ctx, http.StatusNotFound, err)
+ return
+ }
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ pb, err := packages_model.GetBlobByID(ctx, pf.BlobID)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ hash, err := io.ReadAll(buf)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ if (ext == extensionMD5 && pb.HashMD5 != string(hash)) ||
+ (ext == extensionSHA1 && pb.HashSHA1 != string(hash)) ||
+ (ext == extensionSHA256 && pb.HashSHA256 != string(hash)) ||
+ (ext == extensionSHA512 && pb.HashSHA512 != string(hash)) {
+ apiError(ctx, http.StatusBadRequest, "hash mismatch")
+ return
+ }
+
+ ctx.Status(http.StatusOK)
+ return
+ }
+
+ pfci := &packages_service.PackageFileCreationInfo{
+ PackageFileInfo: packages_service.PackageFileInfo{
+ Filename: params.Filename,
+ },
+ Creator: ctx.Doer,
+ Data: buf,
+ IsLead: false,
+ OverwriteExisting: params.IsMeta,
+ }
+
+ // If it's the package pom file extract the metadata
+ if ext == extensionPom {
+ pfci.IsLead = true
+
+ var err error
+ pvci.Metadata, err = maven_module.ParsePackageMetaData(buf)
+ if err != nil {
+ apiError(ctx, http.StatusBadRequest, err)
+ return
+ }
+
+ if pvci.Metadata != nil {
+ pv, err := packages_model.GetVersionByNameAndVersion(ctx, pvci.Owner.ID, pvci.PackageType, pvci.Name, pvci.Version)
+ if err != nil && err != packages_model.ErrPackageNotExist {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ if pv != nil {
+ raw, err := json.Marshal(pvci.Metadata)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ pv.MetadataJSON = string(raw)
+ if err := packages_model.UpdateVersion(ctx, pv); err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ }
+ }
+
+ if _, err := buf.Seek(0, io.SeekStart); err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ }
+
+ _, _, err = packages_service.CreatePackageOrAddFileToExisting(
+ ctx,
+ pvci,
+ pfci,
+ )
+ if err != nil {
+ switch err {
+ case packages_model.ErrDuplicatePackageFile:
+ apiError(ctx, http.StatusConflict, err)
+ case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize:
+ apiError(ctx, http.StatusForbidden, err)
+ default:
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ ctx.Status(http.StatusCreated)
+}
+
+func isChecksumExtension(ext string) bool {
+ return ext == extensionMD5 || ext == extensionSHA1 || ext == extensionSHA256 || ext == extensionSHA512
+}
+
+type parameters struct {
+ GroupID string
+ ArtifactID string
+ Version string
+ Filename string
+ IsMeta bool
+}
+
+func extractPathParameters(ctx *context.Context) (parameters, error) {
+ parts := strings.Split(ctx.Params("*"), "/")
+
+ p := parameters{
+ Filename: parts[len(parts)-1],
+ }
+
+ p.IsMeta = p.Filename == mavenMetadataFile ||
+ p.Filename == mavenMetadataFile+extensionMD5 ||
+ p.Filename == mavenMetadataFile+extensionSHA1 ||
+ p.Filename == mavenMetadataFile+extensionSHA256 ||
+ p.Filename == mavenMetadataFile+extensionSHA512
+
+ parts = parts[:len(parts)-1]
+ if len(parts) == 0 {
+ return p, errInvalidParameters
+ }
+
+ p.Version = parts[len(parts)-1]
+ if p.IsMeta && !strings.HasSuffix(p.Version, "-SNAPSHOT") {
+ p.Version = ""
+ } else {
+ parts = parts[:len(parts)-1]
+ }
+
+ if illegalCharacters.MatchString(p.Version) {
+ return p, errInvalidParameters
+ }
+
+ if len(parts) < 2 {
+ return p, errInvalidParameters
+ }
+
+ p.ArtifactID = parts[len(parts)-1]
+ p.GroupID = strings.Join(parts[:len(parts)-1], ".")
+
+ if illegalCharacters.MatchString(p.GroupID) || illegalCharacters.MatchString(p.ArtifactID) {
+ return p, errInvalidParameters
+ }
+
+ return p, nil
+}
diff --git a/routers/api/packages/npm/api.go b/routers/api/packages/npm/api.go
new file mode 100644
index 0000000..b4379f3
--- /dev/null
+++ b/routers/api/packages/npm/api.go
@@ -0,0 +1,114 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package npm
+
+import (
+ "encoding/base64"
+ "encoding/hex"
+ "fmt"
+ "net/url"
+ "sort"
+
+ packages_model "code.gitea.io/gitea/models/packages"
+ npm_module "code.gitea.io/gitea/modules/packages/npm"
+ "code.gitea.io/gitea/modules/setting"
+)
+
+func createPackageMetadataResponse(registryURL string, pds []*packages_model.PackageDescriptor) *npm_module.PackageMetadata {
+ sort.Slice(pds, func(i, j int) bool {
+ return pds[i].SemVer.LessThan(pds[j].SemVer)
+ })
+
+ versions := make(map[string]*npm_module.PackageMetadataVersion)
+ distTags := make(map[string]string)
+ for _, pd := range pds {
+ versions[pd.SemVer.String()] = createPackageMetadataVersion(registryURL, pd)
+
+ for _, pvp := range pd.VersionProperties {
+ if pvp.Name == npm_module.TagProperty {
+ distTags[pvp.Value] = pd.Version.Version
+ }
+ }
+ }
+
+ latest := pds[len(pds)-1]
+
+ metadata := latest.Metadata.(*npm_module.Metadata)
+
+ return &npm_module.PackageMetadata{
+ ID: latest.Package.Name,
+ Name: latest.Package.Name,
+ DistTags: distTags,
+ Description: metadata.Description,
+ Readme: metadata.Readme,
+ Homepage: metadata.ProjectURL,
+ Author: npm_module.User{Name: metadata.Author},
+ License: metadata.License,
+ Versions: versions,
+ Repository: metadata.Repository,
+ }
+}
+
+func createPackageMetadataVersion(registryURL string, pd *packages_model.PackageDescriptor) *npm_module.PackageMetadataVersion {
+ hashBytes, _ := hex.DecodeString(pd.Files[0].Blob.HashSHA512)
+
+ metadata := pd.Metadata.(*npm_module.Metadata)
+
+ return &npm_module.PackageMetadataVersion{
+ ID: fmt.Sprintf("%s@%s", pd.Package.Name, pd.Version.Version),
+ Name: pd.Package.Name,
+ Version: pd.Version.Version,
+ Description: metadata.Description,
+ Author: npm_module.User{Name: metadata.Author},
+ Homepage: metadata.ProjectURL,
+ License: metadata.License,
+ Dependencies: metadata.Dependencies,
+ BundleDependencies: metadata.BundleDependencies,
+ DevDependencies: metadata.DevelopmentDependencies,
+ PeerDependencies: metadata.PeerDependencies,
+ OptionalDependencies: metadata.OptionalDependencies,
+ Readme: metadata.Readme,
+ Bin: metadata.Bin,
+ Dist: npm_module.PackageDistribution{
+ Shasum: pd.Files[0].Blob.HashSHA1,
+ Integrity: "sha512-" + base64.StdEncoding.EncodeToString(hashBytes),
+ Tarball: fmt.Sprintf("%s/%s/-/%s/%s", registryURL, url.QueryEscape(pd.Package.Name), url.PathEscape(pd.Version.Version), url.PathEscape(pd.Files[0].File.LowerName)),
+ },
+ }
+}
+
+func createPackageSearchResponse(pds []*packages_model.PackageDescriptor, total int64) *npm_module.PackageSearch {
+ objects := make([]*npm_module.PackageSearchObject, 0, len(pds))
+ for _, pd := range pds {
+ metadata := pd.Metadata.(*npm_module.Metadata)
+
+ scope := metadata.Scope
+ if scope == "" {
+ scope = "unscoped"
+ }
+
+ objects = append(objects, &npm_module.PackageSearchObject{
+ Package: &npm_module.PackageSearchPackage{
+ Scope: scope,
+ Name: metadata.Name,
+ Version: pd.Version.Version,
+ Date: pd.Version.CreatedUnix.AsLocalTime(),
+ Description: metadata.Description,
+ Author: npm_module.User{Name: metadata.Author},
+ Publisher: npm_module.User{Name: pd.Owner.Name},
+ Maintainers: []npm_module.User{}, // npm cli needs this field
+ Keywords: metadata.Keywords,
+ Links: &npm_module.PackageSearchPackageLinks{
+ Registry: setting.AppURL + "api/packages/" + pd.Owner.Name + "/npm",
+ Homepage: metadata.ProjectURL,
+ },
+ },
+ })
+ }
+
+ return &npm_module.PackageSearch{
+ Objects: objects,
+ Total: total,
+ }
+}
diff --git a/routers/api/packages/npm/npm.go b/routers/api/packages/npm/npm.go
new file mode 100644
index 0000000..84acfff
--- /dev/null
+++ b/routers/api/packages/npm/npm.go
@@ -0,0 +1,462 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package npm
+
+import (
+ "bytes"
+ std_ctx "context"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+
+ "code.gitea.io/gitea/models/db"
+ packages_model "code.gitea.io/gitea/models/packages"
+ access_model "code.gitea.io/gitea/models/perm/access"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unit"
+ "code.gitea.io/gitea/modules/optional"
+ packages_module "code.gitea.io/gitea/modules/packages"
+ npm_module "code.gitea.io/gitea/modules/packages/npm"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/routers/api/packages/helper"
+ "code.gitea.io/gitea/services/context"
+ packages_service "code.gitea.io/gitea/services/packages"
+
+ "github.com/hashicorp/go-version"
+)
+
+// errInvalidTagName indicates an invalid tag name
+var errInvalidTagName = errors.New("The tag name is invalid")
+
+func apiError(ctx *context.Context, status int, obj any) {
+ helper.LogAndProcessError(ctx, status, obj, func(message string) {
+ ctx.JSON(status, map[string]string{
+ "error": message,
+ })
+ })
+}
+
+// packageNameFromParams gets the package name from the url parameters
+// Variations: /name/, /@scope/name/, /@scope%2Fname/
+func packageNameFromParams(ctx *context.Context) string {
+ scope := ctx.Params("scope")
+ id := ctx.Params("id")
+ if scope != "" {
+ return fmt.Sprintf("@%s/%s", scope, id)
+ }
+ return id
+}
+
+// PackageMetadata returns the metadata for a single package
+func PackageMetadata(ctx *context.Context) {
+ packageName := packageNameFromParams(ctx)
+
+ pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeNpm, packageName)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ if len(pvs) == 0 {
+ apiError(ctx, http.StatusNotFound, err)
+ return
+ }
+
+ pds, err := packages_model.GetPackageDescriptors(ctx, pvs)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ resp := createPackageMetadataResponse(
+ setting.AppURL+"api/packages/"+ctx.Package.Owner.Name+"/npm",
+ pds,
+ )
+
+ ctx.JSON(http.StatusOK, resp)
+}
+
+// DownloadPackageFile serves the content of a package
+func DownloadPackageFile(ctx *context.Context) {
+ packageName := packageNameFromParams(ctx)
+ packageVersion := ctx.Params("version")
+ filename := ctx.Params("filename")
+
+ s, u, pf, err := packages_service.GetFileStreamByPackageNameAndVersion(
+ ctx,
+ &packages_service.PackageInfo{
+ Owner: ctx.Package.Owner,
+ PackageType: packages_model.TypeNpm,
+ Name: packageName,
+ Version: packageVersion,
+ },
+ &packages_service.PackageFileInfo{
+ Filename: filename,
+ },
+ )
+ if err != nil {
+ if err == packages_model.ErrPackageNotExist || err == packages_model.ErrPackageFileNotExist {
+ apiError(ctx, http.StatusNotFound, err)
+ return
+ }
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ helper.ServePackageFile(ctx, s, u, pf)
+}
+
+// DownloadPackageFileByName finds the version and serves the contents of a package
+func DownloadPackageFileByName(ctx *context.Context) {
+ filename := ctx.Params("filename")
+
+ pvs, _, err := packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{
+ OwnerID: ctx.Package.Owner.ID,
+ Type: packages_model.TypeNpm,
+ Name: packages_model.SearchValue{
+ ExactMatch: true,
+ Value: packageNameFromParams(ctx),
+ },
+ HasFileWithName: filename,
+ IsInternal: optional.Some(false),
+ })
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ if len(pvs) != 1 {
+ apiError(ctx, http.StatusNotFound, nil)
+ return
+ }
+
+ s, u, pf, err := packages_service.GetFileStreamByPackageVersion(
+ ctx,
+ pvs[0],
+ &packages_service.PackageFileInfo{
+ Filename: filename,
+ },
+ )
+ if err != nil {
+ if err == packages_model.ErrPackageFileNotExist {
+ apiError(ctx, http.StatusNotFound, err)
+ return
+ }
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ helper.ServePackageFile(ctx, s, u, pf)
+}
+
+// UploadPackage creates a new package
+func UploadPackage(ctx *context.Context) {
+ npmPackage, err := npm_module.ParsePackage(ctx.Req.Body)
+ if err != nil {
+ if errors.Is(err, util.ErrInvalidArgument) {
+ apiError(ctx, http.StatusBadRequest, err)
+ } else {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ repo, err := repo_model.GetRepositoryByURL(ctx, npmPackage.Metadata.Repository.URL)
+ if err == nil {
+ canWrite := repo.OwnerID == ctx.Doer.ID
+
+ if !canWrite {
+ perms, err := access_model.GetUserRepoPermission(ctx, repo, ctx.Doer)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ canWrite = perms.CanWrite(unit.TypePackages)
+ }
+
+ if !canWrite {
+ apiError(ctx, http.StatusForbidden, "no permission to upload this package")
+ return
+ }
+ }
+
+ buf, err := packages_module.CreateHashedBufferFromReader(bytes.NewReader(npmPackage.Data))
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ defer buf.Close()
+
+ pv, _, err := packages_service.CreatePackageAndAddFile(
+ ctx,
+ &packages_service.PackageCreationInfo{
+ PackageInfo: packages_service.PackageInfo{
+ Owner: ctx.Package.Owner,
+ PackageType: packages_model.TypeNpm,
+ Name: npmPackage.Name,
+ Version: npmPackage.Version,
+ },
+ SemverCompatible: true,
+ Creator: ctx.Doer,
+ Metadata: npmPackage.Metadata,
+ },
+ &packages_service.PackageFileCreationInfo{
+ PackageFileInfo: packages_service.PackageFileInfo{
+ Filename: npmPackage.Filename,
+ },
+ Creator: ctx.Doer,
+ Data: buf,
+ IsLead: true,
+ },
+ )
+ if err != nil {
+ switch err {
+ case packages_model.ErrDuplicatePackageVersion:
+ apiError(ctx, http.StatusConflict, err)
+ case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize:
+ apiError(ctx, http.StatusForbidden, err)
+ default:
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ for _, tag := range npmPackage.DistTags {
+ if err := setPackageTag(ctx, tag, pv, false); err != nil {
+ if err == errInvalidTagName {
+ apiError(ctx, http.StatusBadRequest, err)
+ return
+ }
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ }
+
+ if repo != nil {
+ if err := packages_model.SetRepositoryLink(ctx, pv.PackageID, repo.ID); err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ }
+
+ ctx.Status(http.StatusCreated)
+}
+
+// DeletePreview does nothing
+// The client tells the server what package version it knows about after deleting a version.
+func DeletePreview(ctx *context.Context) {
+ ctx.Status(http.StatusOK)
+}
+
+// DeletePackageVersion deletes the package version
+func DeletePackageVersion(ctx *context.Context) {
+ packageName := packageNameFromParams(ctx)
+ packageVersion := ctx.Params("version")
+
+ err := packages_service.RemovePackageVersionByNameAndVersion(
+ ctx,
+ ctx.Doer,
+ &packages_service.PackageInfo{
+ Owner: ctx.Package.Owner,
+ PackageType: packages_model.TypeNpm,
+ Name: packageName,
+ Version: packageVersion,
+ },
+ )
+ if err != nil {
+ if err == packages_model.ErrPackageNotExist {
+ apiError(ctx, http.StatusNotFound, err)
+ return
+ }
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ ctx.Status(http.StatusOK)
+}
+
+// DeletePackage deletes the package and all versions
+func DeletePackage(ctx *context.Context) {
+ packageName := packageNameFromParams(ctx)
+
+ pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeNpm, packageName)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ if len(pvs) == 0 {
+ apiError(ctx, http.StatusNotFound, err)
+ return
+ }
+
+ for _, pv := range pvs {
+ if err := packages_service.RemovePackageVersion(ctx, ctx.Doer, pv); err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ }
+
+ ctx.Status(http.StatusOK)
+}
+
+// ListPackageTags returns all tags for a package
+func ListPackageTags(ctx *context.Context) {
+ packageName := packageNameFromParams(ctx)
+
+ pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeNpm, packageName)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ tags := make(map[string]string)
+ for _, pv := range pvs {
+ pvps, err := packages_model.GetPropertiesByName(ctx, packages_model.PropertyTypeVersion, pv.ID, npm_module.TagProperty)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ for _, pvp := range pvps {
+ tags[pvp.Value] = pv.Version
+ }
+ }
+
+ ctx.JSON(http.StatusOK, tags)
+}
+
+// AddPackageTag adds a tag to the package
+func AddPackageTag(ctx *context.Context) {
+ packageName := packageNameFromParams(ctx)
+
+ body, err := io.ReadAll(ctx.Req.Body)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ version := strings.Trim(string(body), "\"") // is as "version" in the body
+
+ pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeNpm, packageName, version)
+ if err != nil {
+ if err == packages_model.ErrPackageNotExist {
+ apiError(ctx, http.StatusNotFound, err)
+ return
+ }
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ if err := setPackageTag(ctx, ctx.Params("tag"), pv, false); err != nil {
+ if err == errInvalidTagName {
+ apiError(ctx, http.StatusBadRequest, err)
+ return
+ }
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+}
+
+// DeletePackageTag deletes a package tag
+func DeletePackageTag(ctx *context.Context) {
+ packageName := packageNameFromParams(ctx)
+
+ pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeNpm, packageName)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ if len(pvs) != 0 {
+ if err := setPackageTag(ctx, ctx.Params("tag"), pvs[0], true); err != nil {
+ if err == errInvalidTagName {
+ apiError(ctx, http.StatusBadRequest, err)
+ return
+ }
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ }
+}
+
+func setPackageTag(ctx std_ctx.Context, tag string, pv *packages_model.PackageVersion, deleteOnly bool) error {
+ if tag == "" {
+ return errInvalidTagName
+ }
+ _, err := version.NewVersion(tag)
+ if err == nil {
+ return errInvalidTagName
+ }
+
+ return db.WithTx(ctx, func(ctx std_ctx.Context) error {
+ pvs, _, err := packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{
+ PackageID: pv.PackageID,
+ Properties: map[string]string{
+ npm_module.TagProperty: tag,
+ },
+ IsInternal: optional.Some(false),
+ })
+ if err != nil {
+ return err
+ }
+
+ if len(pvs) == 1 {
+ pvps, err := packages_model.GetPropertiesByName(ctx, packages_model.PropertyTypeVersion, pvs[0].ID, npm_module.TagProperty)
+ if err != nil {
+ return err
+ }
+
+ for _, pvp := range pvps {
+ if pvp.Value == tag {
+ if err := packages_model.DeletePropertyByID(ctx, pvp.ID); err != nil {
+ return err
+ }
+ break
+ }
+ }
+ }
+
+ if !deleteOnly {
+ _, err = packages_model.InsertProperty(ctx, packages_model.PropertyTypeVersion, pv.ID, npm_module.TagProperty, tag)
+ if err != nil {
+ return err
+ }
+ }
+ return nil
+ })
+}
+
+func PackageSearch(ctx *context.Context) {
+ pvs, total, err := packages_model.SearchLatestVersions(ctx, &packages_model.PackageSearchOptions{
+ OwnerID: ctx.Package.Owner.ID,
+ Type: packages_model.TypeNpm,
+ IsInternal: optional.Some(false),
+ Name: packages_model.SearchValue{
+ ExactMatch: false,
+ Value: ctx.FormTrim("text"),
+ },
+ Paginator: db.NewAbsoluteListOptions(
+ ctx.FormInt("from"),
+ ctx.FormInt("size"),
+ ),
+ })
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ pds, err := packages_model.GetPackageDescriptors(ctx, pvs)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ resp := createPackageSearchResponse(
+ pds,
+ total,
+ )
+
+ ctx.JSON(http.StatusOK, resp)
+}
diff --git a/routers/api/packages/nuget/api_v2.go b/routers/api/packages/nuget/api_v2.go
new file mode 100644
index 0000000..a726065
--- /dev/null
+++ b/routers/api/packages/nuget/api_v2.go
@@ -0,0 +1,402 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package nuget
+
+import (
+ "encoding/xml"
+ "strings"
+ "time"
+
+ packages_model "code.gitea.io/gitea/models/packages"
+ nuget_module "code.gitea.io/gitea/modules/packages/nuget"
+)
+
+type AtomTitle struct {
+ Type string `xml:"type,attr"`
+ Text string `xml:",chardata"`
+}
+
+type ServiceCollection struct {
+ Href string `xml:"href,attr"`
+ Title AtomTitle `xml:"atom:title"`
+}
+
+type ServiceWorkspace struct {
+ Title AtomTitle `xml:"atom:title"`
+ Collection ServiceCollection `xml:"collection"`
+}
+
+type ServiceIndexResponseV2 struct {
+ XMLName xml.Name `xml:"service"`
+ Base string `xml:"base,attr"`
+ Xmlns string `xml:"xmlns,attr"`
+ XmlnsAtom string `xml:"xmlns:atom,attr"`
+ Workspace ServiceWorkspace `xml:"workspace"`
+}
+
+type EdmxPropertyRef struct {
+ Name string `xml:"Name,attr"`
+}
+
+type EdmxProperty struct {
+ Name string `xml:"Name,attr"`
+ Type string `xml:"Type,attr"`
+ Nullable bool `xml:"Nullable,attr"`
+}
+
+type EdmxEntityType struct {
+ Name string `xml:"Name,attr"`
+ HasStream bool `xml:"m:HasStream,attr"`
+ Keys []EdmxPropertyRef `xml:"Key>PropertyRef"`
+ Properties []EdmxProperty `xml:"Property"`
+}
+
+type EdmxFunctionParameter struct {
+ Name string `xml:"Name,attr"`
+ Type string `xml:"Type,attr"`
+}
+
+type EdmxFunctionImport struct {
+ Name string `xml:"Name,attr"`
+ ReturnType string `xml:"ReturnType,attr"`
+ EntitySet string `xml:"EntitySet,attr"`
+ Parameter []EdmxFunctionParameter `xml:"Parameter"`
+}
+
+type EdmxEntitySet struct {
+ Name string `xml:"Name,attr"`
+ EntityType string `xml:"EntityType,attr"`
+}
+
+type EdmxEntityContainer struct {
+ Name string `xml:"Name,attr"`
+ IsDefaultEntityContainer bool `xml:"m:IsDefaultEntityContainer,attr"`
+ EntitySet EdmxEntitySet `xml:"EntitySet"`
+ FunctionImports []EdmxFunctionImport `xml:"FunctionImport"`
+}
+
+type EdmxSchema struct {
+ Xmlns string `xml:"xmlns,attr"`
+ Namespace string `xml:"Namespace,attr"`
+ EntityType *EdmxEntityType `xml:"EntityType,omitempty"`
+ EntityContainer *EdmxEntityContainer `xml:"EntityContainer,omitempty"`
+}
+
+type EdmxDataServices struct {
+ XmlnsM string `xml:"xmlns:m,attr"`
+ DataServiceVersion string `xml:"m:DataServiceVersion,attr"`
+ MaxDataServiceVersion string `xml:"m:MaxDataServiceVersion,attr"`
+ Schema []EdmxSchema `xml:"Schema"`
+}
+
+type EdmxMetadata struct {
+ XMLName xml.Name `xml:"edmx:Edmx"`
+ XmlnsEdmx string `xml:"xmlns:edmx,attr"`
+ Version string `xml:"Version,attr"`
+ DataServices EdmxDataServices `xml:"edmx:DataServices"`
+}
+
+var Metadata = &EdmxMetadata{
+ XmlnsEdmx: "http://schemas.microsoft.com/ado/2007/06/edmx",
+ Version: "1.0",
+ DataServices: EdmxDataServices{
+ XmlnsM: "http://schemas.microsoft.com/ado/2007/08/dataservices/metadata",
+ DataServiceVersion: "2.0",
+ MaxDataServiceVersion: "2.0",
+ Schema: []EdmxSchema{
+ {
+ Xmlns: "http://schemas.microsoft.com/ado/2006/04/edm",
+ Namespace: "NuGetGallery.OData",
+ EntityType: &EdmxEntityType{
+ Name: "V2FeedPackage",
+ HasStream: true,
+ Keys: []EdmxPropertyRef{
+ {Name: "Id"},
+ {Name: "Version"},
+ },
+ Properties: []EdmxProperty{
+ {
+ Name: "Id",
+ Type: "Edm.String",
+ },
+ {
+ Name: "Version",
+ Type: "Edm.String",
+ },
+ {
+ Name: "NormalizedVersion",
+ Type: "Edm.String",
+ Nullable: true,
+ },
+ {
+ Name: "Authors",
+ Type: "Edm.String",
+ Nullable: true,
+ },
+ {
+ Name: "Created",
+ Type: "Edm.DateTime",
+ },
+ {
+ Name: "Dependencies",
+ Type: "Edm.String",
+ },
+ {
+ Name: "Description",
+ Type: "Edm.String",
+ },
+ {
+ Name: "DownloadCount",
+ Type: "Edm.Int64",
+ },
+ {
+ Name: "LastUpdated",
+ Type: "Edm.DateTime",
+ },
+ {
+ Name: "Published",
+ Type: "Edm.DateTime",
+ },
+ {
+ Name: "PackageSize",
+ Type: "Edm.Int64",
+ },
+ {
+ Name: "ProjectUrl",
+ Type: "Edm.String",
+ Nullable: true,
+ },
+ {
+ Name: "ReleaseNotes",
+ Type: "Edm.String",
+ Nullable: true,
+ },
+ {
+ Name: "RequireLicenseAcceptance",
+ Type: "Edm.Boolean",
+ Nullable: false,
+ },
+ {
+ Name: "Title",
+ Type: "Edm.String",
+ Nullable: true,
+ },
+ {
+ Name: "VersionDownloadCount",
+ Type: "Edm.Int64",
+ Nullable: false,
+ },
+ },
+ },
+ },
+ {
+ Xmlns: "http://schemas.microsoft.com/ado/2006/04/edm",
+ Namespace: "NuGetGallery",
+ EntityContainer: &EdmxEntityContainer{
+ Name: "V2FeedContext",
+ IsDefaultEntityContainer: true,
+ EntitySet: EdmxEntitySet{
+ Name: "Packages",
+ EntityType: "NuGetGallery.OData.V2FeedPackage",
+ },
+ FunctionImports: []EdmxFunctionImport{
+ {
+ Name: "Search",
+ ReturnType: "Collection(NuGetGallery.OData.V2FeedPackage)",
+ EntitySet: "Packages",
+ Parameter: []EdmxFunctionParameter{
+ {
+ Name: "searchTerm",
+ Type: "Edm.String",
+ },
+ },
+ },
+ {
+ Name: "FindPackagesById",
+ ReturnType: "Collection(NuGetGallery.OData.V2FeedPackage)",
+ EntitySet: "Packages",
+ Parameter: []EdmxFunctionParameter{
+ {
+ Name: "id",
+ Type: "Edm.String",
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+}
+
+type FeedEntryCategory struct {
+ Term string `xml:"term,attr"`
+ Scheme string `xml:"scheme,attr"`
+}
+
+type FeedEntryLink struct {
+ Rel string `xml:"rel,attr"`
+ Href string `xml:"href,attr"`
+}
+
+type TypedValue[T any] struct {
+ Type string `xml:"type,attr,omitempty"`
+ Value T `xml:",chardata"`
+}
+
+type FeedEntryProperties struct {
+ Version string `xml:"d:Version"`
+ NormalizedVersion string `xml:"d:NormalizedVersion"`
+ Authors string `xml:"d:Authors"`
+ Dependencies string `xml:"d:Dependencies"`
+ Description string `xml:"d:Description"`
+ VersionDownloadCount TypedValue[int64] `xml:"d:VersionDownloadCount"`
+ DownloadCount TypedValue[int64] `xml:"d:DownloadCount"`
+ PackageSize TypedValue[int64] `xml:"d:PackageSize"`
+ Created TypedValue[time.Time] `xml:"d:Created"`
+ LastUpdated TypedValue[time.Time] `xml:"d:LastUpdated"`
+ Published TypedValue[time.Time] `xml:"d:Published"`
+ ProjectURL string `xml:"d:ProjectUrl,omitempty"`
+ ReleaseNotes string `xml:"d:ReleaseNotes,omitempty"`
+ RequireLicenseAcceptance TypedValue[bool] `xml:"d:RequireLicenseAcceptance"`
+ Title string `xml:"d:Title"`
+}
+
+type FeedEntry struct {
+ XMLName xml.Name `xml:"entry"`
+ Xmlns string `xml:"xmlns,attr,omitempty"`
+ XmlnsD string `xml:"xmlns:d,attr,omitempty"`
+ XmlnsM string `xml:"xmlns:m,attr,omitempty"`
+ Base string `xml:"xml:base,attr,omitempty"`
+ ID string `xml:"id"`
+ Category FeedEntryCategory `xml:"category"`
+ Links []FeedEntryLink `xml:"link"`
+ Title TypedValue[string] `xml:"title"`
+ Updated time.Time `xml:"updated"`
+ Author string `xml:"author>name"`
+ Summary string `xml:"summary"`
+ Properties *FeedEntryProperties `xml:"m:properties"`
+ Content string `xml:",innerxml"`
+}
+
+type FeedResponse struct {
+ XMLName xml.Name `xml:"feed"`
+ Xmlns string `xml:"xmlns,attr,omitempty"`
+ XmlnsD string `xml:"xmlns:d,attr,omitempty"`
+ XmlnsM string `xml:"xmlns:m,attr,omitempty"`
+ Base string `xml:"xml:base,attr,omitempty"`
+ ID string `xml:"id"`
+ Title TypedValue[string] `xml:"title"`
+ Updated time.Time `xml:"updated"`
+ Links []FeedEntryLink `xml:"link"`
+ Entries []*FeedEntry `xml:"entry"`
+ Count int64 `xml:"m:count"`
+}
+
+func createFeedResponse(l *linkBuilder, totalEntries int64, pds []*packages_model.PackageDescriptor) *FeedResponse {
+ entries := make([]*FeedEntry, 0, len(pds))
+ for _, pd := range pds {
+ entries = append(entries, createEntry(l, pd, false))
+ }
+
+ links := []FeedEntryLink{
+ {Rel: "self", Href: l.Base},
+ }
+ if l.Next != nil {
+ links = append(links, FeedEntryLink{
+ Rel: "next",
+ Href: l.GetNextURL(),
+ })
+ }
+
+ return &FeedResponse{
+ Xmlns: "http://www.w3.org/2005/Atom",
+ Base: l.Base,
+ XmlnsD: "http://schemas.microsoft.com/ado/2007/08/dataservices",
+ XmlnsM: "http://schemas.microsoft.com/ado/2007/08/dataservices/metadata",
+ ID: "http://schemas.datacontract.org/2004/07/",
+ Updated: time.Now(),
+ Links: links,
+ Count: totalEntries,
+ Entries: entries,
+ }
+}
+
+func createEntryResponse(l *linkBuilder, pd *packages_model.PackageDescriptor) *FeedEntry {
+ return createEntry(l, pd, true)
+}
+
+func createEntry(l *linkBuilder, pd *packages_model.PackageDescriptor, withNamespace bool) *FeedEntry {
+ metadata := pd.Metadata.(*nuget_module.Metadata)
+
+ id := l.GetPackageMetadataURL(pd.Package.Name, pd.Version.Version)
+
+ // Workaround to force a self-closing tag to satisfy XmlReader.IsEmptyElement used by the NuGet client.
+ // https://learn.microsoft.com/en-us/dotnet/api/system.xml.xmlreader.isemptyelement
+ content := `<content type="application/zip" src="` + l.GetPackageDownloadURL(pd.Package.Name, pd.Version.Version) + `"/>`
+
+ createdValue := TypedValue[time.Time]{
+ Type: "Edm.DateTime",
+ Value: pd.Version.CreatedUnix.AsLocalTime(),
+ }
+
+ entry := &FeedEntry{
+ ID: id,
+ Category: FeedEntryCategory{Term: "NuGetGallery.OData.V2FeedPackage", Scheme: "http://schemas.microsoft.com/ado/2007/08/dataservices/scheme"},
+ Links: []FeedEntryLink{
+ {Rel: "self", Href: id},
+ {Rel: "edit", Href: id},
+ },
+ Title: TypedValue[string]{Type: "text", Value: pd.Package.Name},
+ Updated: pd.Version.CreatedUnix.AsLocalTime(),
+ Author: metadata.Authors,
+ Content: content,
+ Properties: &FeedEntryProperties{
+ Version: pd.Version.Version,
+ NormalizedVersion: pd.Version.Version,
+ Authors: metadata.Authors,
+ Dependencies: buildDependencyString(metadata),
+ Description: metadata.Description,
+ VersionDownloadCount: TypedValue[int64]{Type: "Edm.Int64", Value: pd.Version.DownloadCount},
+ DownloadCount: TypedValue[int64]{Type: "Edm.Int64", Value: pd.Version.DownloadCount},
+ PackageSize: TypedValue[int64]{Type: "Edm.Int64", Value: pd.CalculateBlobSize()},
+ Created: createdValue,
+ LastUpdated: createdValue,
+ Published: createdValue,
+ ProjectURL: metadata.ProjectURL,
+ ReleaseNotes: metadata.ReleaseNotes,
+ RequireLicenseAcceptance: TypedValue[bool]{Type: "Edm.Boolean", Value: metadata.RequireLicenseAcceptance},
+ Title: pd.Package.Name,
+ },
+ }
+
+ if withNamespace {
+ entry.Xmlns = "http://www.w3.org/2005/Atom"
+ entry.Base = l.Base
+ entry.XmlnsD = "http://schemas.microsoft.com/ado/2007/08/dataservices"
+ entry.XmlnsM = "http://schemas.microsoft.com/ado/2007/08/dataservices/metadata"
+ }
+
+ return entry
+}
+
+func buildDependencyString(metadata *nuget_module.Metadata) string {
+ var b strings.Builder
+ first := true
+ for group, deps := range metadata.Dependencies {
+ for _, dep := range deps {
+ if !first {
+ b.WriteByte('|')
+ }
+ first = false
+
+ b.WriteString(dep.ID)
+ b.WriteByte(':')
+ b.WriteString(dep.Version)
+ b.WriteByte(':')
+ b.WriteString(group)
+ }
+ }
+ return b.String()
+}
diff --git a/routers/api/packages/nuget/api_v3.go b/routers/api/packages/nuget/api_v3.go
new file mode 100644
index 0000000..2fe25dc
--- /dev/null
+++ b/routers/api/packages/nuget/api_v3.go
@@ -0,0 +1,255 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package nuget
+
+import (
+ "sort"
+ "time"
+
+ packages_model "code.gitea.io/gitea/models/packages"
+ nuget_module "code.gitea.io/gitea/modules/packages/nuget"
+
+ "golang.org/x/text/collate"
+ "golang.org/x/text/language"
+)
+
+// https://docs.microsoft.com/en-us/nuget/api/service-index#resources
+type ServiceIndexResponseV3 struct {
+ Version string `json:"version"`
+ Resources []ServiceResource `json:"resources"`
+}
+
+// https://docs.microsoft.com/en-us/nuget/api/service-index#resource
+type ServiceResource struct {
+ ID string `json:"@id"`
+ Type string `json:"@type"`
+}
+
+// https://docs.microsoft.com/en-us/nuget/api/registration-base-url-resource#response
+type RegistrationIndexResponse struct {
+ RegistrationIndexURL string `json:"@id"`
+ Type []string `json:"@type"`
+ Count int `json:"count"`
+ Pages []*RegistrationIndexPage `json:"items"`
+}
+
+// https://docs.microsoft.com/en-us/nuget/api/registration-base-url-resource#registration-page-object
+type RegistrationIndexPage struct {
+ RegistrationPageURL string `json:"@id"`
+ Lower string `json:"lower"`
+ Upper string `json:"upper"`
+ Count int `json:"count"`
+ Items []*RegistrationIndexPageItem `json:"items"`
+}
+
+// https://docs.microsoft.com/en-us/nuget/api/registration-base-url-resource#registration-leaf-object-in-a-page
+type RegistrationIndexPageItem struct {
+ RegistrationLeafURL string `json:"@id"`
+ PackageContentURL string `json:"packageContent"`
+ CatalogEntry *CatalogEntry `json:"catalogEntry"`
+}
+
+// https://docs.microsoft.com/en-us/nuget/api/registration-base-url-resource#catalog-entry
+type CatalogEntry struct {
+ CatalogLeafURL string `json:"@id"`
+ PackageContentURL string `json:"packageContent"`
+ ID string `json:"id"`
+ Version string `json:"version"`
+ Description string `json:"description"`
+ ReleaseNotes string `json:"releaseNotes"`
+ Authors string `json:"authors"`
+ RequireLicenseAcceptance bool `json:"requireLicenseAcceptance"`
+ ProjectURL string `json:"projectURL"`
+ DependencyGroups []*PackageDependencyGroup `json:"dependencyGroups"`
+}
+
+// https://docs.microsoft.com/en-us/nuget/api/registration-base-url-resource#package-dependency-group
+type PackageDependencyGroup struct {
+ TargetFramework string `json:"targetFramework"`
+ Dependencies []*PackageDependency `json:"dependencies"`
+}
+
+// https://docs.microsoft.com/en-us/nuget/api/registration-base-url-resource#package-dependency
+type PackageDependency struct {
+ ID string `json:"id"`
+ Range string `json:"range"`
+}
+
+func createRegistrationIndexResponse(l *linkBuilder, pds []*packages_model.PackageDescriptor) *RegistrationIndexResponse {
+ sort.Slice(pds, func(i, j int) bool {
+ return pds[i].SemVer.LessThan(pds[j].SemVer)
+ })
+
+ items := make([]*RegistrationIndexPageItem, 0, len(pds))
+ for _, p := range pds {
+ items = append(items, createRegistrationIndexPageItem(l, p))
+ }
+
+ return &RegistrationIndexResponse{
+ RegistrationIndexURL: l.GetRegistrationIndexURL(pds[0].Package.Name),
+ Type: []string{"catalog:CatalogRoot", "PackageRegistration", "catalog:Permalink"},
+ Count: 1,
+ Pages: []*RegistrationIndexPage{
+ {
+ RegistrationPageURL: l.GetRegistrationIndexURL(pds[0].Package.Name),
+ Count: len(pds),
+ Lower: pds[0].Version.Version,
+ Upper: pds[len(pds)-1].Version.Version,
+ Items: items,
+ },
+ },
+ }
+}
+
+func createRegistrationIndexPageItem(l *linkBuilder, pd *packages_model.PackageDescriptor) *RegistrationIndexPageItem {
+ metadata := pd.Metadata.(*nuget_module.Metadata)
+
+ return &RegistrationIndexPageItem{
+ RegistrationLeafURL: l.GetRegistrationLeafURL(pd.Package.Name, pd.Version.Version),
+ PackageContentURL: l.GetPackageDownloadURL(pd.Package.Name, pd.Version.Version),
+ CatalogEntry: &CatalogEntry{
+ CatalogLeafURL: l.GetRegistrationLeafURL(pd.Package.Name, pd.Version.Version),
+ PackageContentURL: l.GetPackageDownloadURL(pd.Package.Name, pd.Version.Version),
+ ID: pd.Package.Name,
+ Version: pd.Version.Version,
+ Description: metadata.Description,
+ ReleaseNotes: metadata.ReleaseNotes,
+ Authors: metadata.Authors,
+ ProjectURL: metadata.ProjectURL,
+ DependencyGroups: createDependencyGroups(pd),
+ },
+ }
+}
+
+func createDependencyGroups(pd *packages_model.PackageDescriptor) []*PackageDependencyGroup {
+ metadata := pd.Metadata.(*nuget_module.Metadata)
+
+ dependencyGroups := make([]*PackageDependencyGroup, 0, len(metadata.Dependencies))
+ for k, v := range metadata.Dependencies {
+ dependencies := make([]*PackageDependency, 0, len(v))
+ for _, dep := range v {
+ dependencies = append(dependencies, &PackageDependency{
+ ID: dep.ID,
+ Range: dep.Version,
+ })
+ }
+
+ dependencyGroups = append(dependencyGroups, &PackageDependencyGroup{
+ TargetFramework: k,
+ Dependencies: dependencies,
+ })
+ }
+ return dependencyGroups
+}
+
+// https://docs.microsoft.com/en-us/nuget/api/registration-base-url-resource#registration-leaf
+type RegistrationLeafResponse struct {
+ RegistrationLeafURL string `json:"@id"`
+ Type []string `json:"@type"`
+ Listed bool `json:"listed"`
+ PackageContentURL string `json:"packageContent"`
+ Published time.Time `json:"published"`
+ RegistrationIndexURL string `json:"registration"`
+}
+
+func createRegistrationLeafResponse(l *linkBuilder, pd *packages_model.PackageDescriptor) *RegistrationLeafResponse {
+ return &RegistrationLeafResponse{
+ Type: []string{"Package", "http://schema.nuget.org/catalog#Permalink"},
+ Listed: true,
+ Published: pd.Version.CreatedUnix.AsLocalTime(),
+ RegistrationLeafURL: l.GetRegistrationLeafURL(pd.Package.Name, pd.Version.Version),
+ PackageContentURL: l.GetPackageDownloadURL(pd.Package.Name, pd.Version.Version),
+ RegistrationIndexURL: l.GetRegistrationIndexURL(pd.Package.Name),
+ }
+}
+
+// https://docs.microsoft.com/en-us/nuget/api/package-base-address-resource#response
+type PackageVersionsResponse struct {
+ Versions []string `json:"versions"`
+}
+
+func createPackageVersionsResponse(pvs []*packages_model.PackageVersion) *PackageVersionsResponse {
+ versions := make([]string, 0, len(pvs))
+ for _, pv := range pvs {
+ versions = append(versions, pv.Version)
+ }
+
+ return &PackageVersionsResponse{
+ Versions: versions,
+ }
+}
+
+// https://docs.microsoft.com/en-us/nuget/api/search-query-service-resource#response
+type SearchResultResponse struct {
+ TotalHits int64 `json:"totalHits"`
+ Data []*SearchResult `json:"data"`
+}
+
+// https://docs.microsoft.com/en-us/nuget/api/search-query-service-resource#search-result
+type SearchResult struct {
+ ID string `json:"id"`
+ Version string `json:"version"`
+ Versions []*SearchResultVersion `json:"versions"`
+ Description string `json:"description"`
+ Authors string `json:"authors"`
+ ProjectURL string `json:"projectURL"`
+ RegistrationIndexURL string `json:"registration"`
+}
+
+// https://docs.microsoft.com/en-us/nuget/api/search-query-service-resource#search-result
+type SearchResultVersion struct {
+ RegistrationLeafURL string `json:"@id"`
+ Version string `json:"version"`
+ Downloads int64 `json:"downloads"`
+}
+
+func createSearchResultResponse(l *linkBuilder, totalHits int64, pds []*packages_model.PackageDescriptor) *SearchResultResponse {
+ grouped := make(map[string][]*packages_model.PackageDescriptor)
+ for _, pd := range pds {
+ grouped[pd.Package.Name] = append(grouped[pd.Package.Name], pd)
+ }
+
+ keys := make([]string, 0, len(grouped))
+ for key := range grouped {
+ keys = append(keys, key)
+ }
+ collate.New(language.English, collate.IgnoreCase).SortStrings(keys)
+
+ data := make([]*SearchResult, 0, len(pds))
+ for _, key := range keys {
+ data = append(data, createSearchResult(l, grouped[key]))
+ }
+
+ return &SearchResultResponse{
+ TotalHits: totalHits,
+ Data: data,
+ }
+}
+
+func createSearchResult(l *linkBuilder, pds []*packages_model.PackageDescriptor) *SearchResult {
+ latest := pds[0]
+ versions := make([]*SearchResultVersion, 0, len(pds))
+ for _, pd := range pds {
+ if latest.SemVer.LessThan(pd.SemVer) {
+ latest = pd
+ }
+
+ versions = append(versions, &SearchResultVersion{
+ RegistrationLeafURL: l.GetRegistrationLeafURL(pd.Package.Name, pd.Version.Version),
+ Version: pd.Version.Version,
+ })
+ }
+
+ metadata := latest.Metadata.(*nuget_module.Metadata)
+
+ return &SearchResult{
+ ID: latest.Package.Name,
+ Version: latest.Version.Version,
+ Versions: versions,
+ Description: metadata.Description,
+ Authors: metadata.Authors,
+ ProjectURL: metadata.ProjectURL,
+ RegistrationIndexURL: l.GetRegistrationIndexURL(latest.Package.Name),
+ }
+}
diff --git a/routers/api/packages/nuget/auth.go b/routers/api/packages/nuget/auth.go
new file mode 100644
index 0000000..1bb68d0
--- /dev/null
+++ b/routers/api/packages/nuget/auth.go
@@ -0,0 +1,47 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package nuget
+
+import (
+ "net/http"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/timeutil"
+ "code.gitea.io/gitea/services/auth"
+)
+
+var _ auth.Method = &Auth{}
+
+type Auth struct{}
+
+func (a *Auth) Name() string {
+ return "nuget"
+}
+
+// https://docs.microsoft.com/en-us/nuget/api/package-publish-resource#request-parameters
+func (a *Auth) Verify(req *http.Request, w http.ResponseWriter, store auth.DataStore, sess auth.SessionStore) (*user_model.User, error) {
+ token, err := auth_model.GetAccessTokenBySHA(req.Context(), req.Header.Get("X-NuGet-ApiKey"))
+ if err != nil {
+ if !(auth_model.IsErrAccessTokenNotExist(err) || auth_model.IsErrAccessTokenEmpty(err)) {
+ log.Error("GetAccessTokenBySHA: %v", err)
+ return nil, err
+ }
+ return nil, nil
+ }
+
+ u, err := user_model.GetUserByID(req.Context(), token.UID)
+ if err != nil {
+ log.Error("GetUserByID: %v", err)
+ return nil, err
+ }
+
+ token.UpdatedUnix = timeutil.TimeStampNow()
+ if err := auth_model.UpdateAccessToken(req.Context(), token); err != nil {
+ log.Error("UpdateAccessToken: %v", err)
+ }
+
+ return u, nil
+}
diff --git a/routers/api/packages/nuget/links.go b/routers/api/packages/nuget/links.go
new file mode 100644
index 0000000..4c573fe
--- /dev/null
+++ b/routers/api/packages/nuget/links.go
@@ -0,0 +1,52 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package nuget
+
+import (
+ "fmt"
+ "net/url"
+)
+
+type nextOptions struct {
+ Path string
+ Query url.Values
+}
+
+type linkBuilder struct {
+ Base string
+ Next *nextOptions
+}
+
+// GetRegistrationIndexURL builds the registration index url
+func (l *linkBuilder) GetRegistrationIndexURL(id string) string {
+ return fmt.Sprintf("%s/registration/%s/index.json", l.Base, id)
+}
+
+// GetRegistrationLeafURL builds the registration leaf url
+func (l *linkBuilder) GetRegistrationLeafURL(id, version string) string {
+ return fmt.Sprintf("%s/registration/%s/%s.json", l.Base, id, version)
+}
+
+// GetPackageDownloadURL builds the download url
+func (l *linkBuilder) GetPackageDownloadURL(id, version string) string {
+ return fmt.Sprintf("%s/package/%s/%s/%s.%s.nupkg", l.Base, id, version, id, version)
+}
+
+// GetPackageMetadataURL builds the package metadata url
+func (l *linkBuilder) GetPackageMetadataURL(id, version string) string {
+ return fmt.Sprintf("%s/Packages(Id='%s',Version='%s')", l.Base, id, version)
+}
+
+func (l *linkBuilder) GetNextURL() string {
+ u, _ := url.Parse(l.Base)
+ u = u.JoinPath(l.Next.Path)
+ q := u.Query()
+ for k, vs := range l.Next.Query {
+ for _, v := range vs {
+ q.Add(k, v)
+ }
+ }
+ u.RawQuery = q.Encode()
+ return u.String()
+}
diff --git a/routers/api/packages/nuget/nuget.go b/routers/api/packages/nuget/nuget.go
new file mode 100644
index 0000000..0d7212d
--- /dev/null
+++ b/routers/api/packages/nuget/nuget.go
@@ -0,0 +1,710 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package nuget
+
+import (
+ "encoding/xml"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "regexp"
+ "strconv"
+ "strings"
+
+ "code.gitea.io/gitea/models/db"
+ packages_model "code.gitea.io/gitea/models/packages"
+ nuget_model "code.gitea.io/gitea/models/packages/nuget"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/optional"
+ packages_module "code.gitea.io/gitea/modules/packages"
+ nuget_module "code.gitea.io/gitea/modules/packages/nuget"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/routers/api/packages/helper"
+ "code.gitea.io/gitea/services/context"
+ packages_service "code.gitea.io/gitea/services/packages"
+)
+
+func apiError(ctx *context.Context, status int, obj any) {
+ helper.LogAndProcessError(ctx, status, obj, func(message string) {
+ ctx.JSON(status, map[string]string{
+ "Message": message,
+ })
+ })
+}
+
+func xmlResponse(ctx *context.Context, status int, obj any) { //nolint:unparam
+ ctx.Resp.Header().Set("Content-Type", "application/atom+xml; charset=utf-8")
+ ctx.Resp.WriteHeader(status)
+ if _, err := ctx.Resp.Write([]byte(xml.Header)); err != nil {
+ log.Error("Write failed: %v", err)
+ }
+ if err := xml.NewEncoder(ctx.Resp).Encode(obj); err != nil {
+ log.Error("XML encode failed: %v", err)
+ }
+}
+
+// https://github.com/NuGet/NuGet.Client/blob/dev/src/NuGet.Core/NuGet.Protocol/LegacyFeed/V2FeedQueryBuilder.cs
+func ServiceIndexV2(ctx *context.Context) {
+ base := setting.AppURL + "api/packages/" + ctx.Package.Owner.Name + "/nuget"
+
+ xmlResponse(ctx, http.StatusOK, &ServiceIndexResponseV2{
+ Base: base,
+ Xmlns: "http://www.w3.org/2007/app",
+ XmlnsAtom: "http://www.w3.org/2005/Atom",
+ Workspace: ServiceWorkspace{
+ Title: AtomTitle{
+ Type: "text",
+ Text: "Default",
+ },
+ Collection: ServiceCollection{
+ Href: "Packages",
+ Title: AtomTitle{
+ Type: "text",
+ Text: "Packages",
+ },
+ },
+ },
+ })
+}
+
+// https://docs.microsoft.com/en-us/nuget/api/service-index
+func ServiceIndexV3(ctx *context.Context) {
+ root := setting.AppURL + "api/packages/" + ctx.Package.Owner.Name + "/nuget"
+
+ ctx.JSON(http.StatusOK, &ServiceIndexResponseV3{
+ Version: "3.0.0",
+ Resources: []ServiceResource{
+ {ID: root + "/query", Type: "SearchQueryService"},
+ {ID: root + "/query", Type: "SearchQueryService/3.0.0-beta"},
+ {ID: root + "/query", Type: "SearchQueryService/3.0.0-rc"},
+ {ID: root + "/registration", Type: "RegistrationsBaseUrl"},
+ {ID: root + "/registration", Type: "RegistrationsBaseUrl/3.0.0-beta"},
+ {ID: root + "/registration", Type: "RegistrationsBaseUrl/3.0.0-rc"},
+ {ID: root + "/package", Type: "PackageBaseAddress/3.0.0"},
+ {ID: root, Type: "PackagePublish/2.0.0"},
+ {ID: root + "/symbolpackage", Type: "SymbolPackagePublish/4.9.0"},
+ },
+ })
+}
+
+// https://github.com/NuGet/NuGet.Client/blob/dev/src/NuGet.Core/NuGet.Protocol/LegacyFeed/LegacyFeedCapabilityResourceV2Feed.cs
+func FeedCapabilityResource(ctx *context.Context) {
+ xmlResponse(ctx, http.StatusOK, Metadata)
+}
+
+var (
+ searchTermExtract = regexp.MustCompile(`'([^']+)'`)
+ searchTermExact = regexp.MustCompile(`\s+eq\s+'`)
+)
+
+func getSearchTerm(ctx *context.Context) packages_model.SearchValue {
+ searchTerm := strings.Trim(ctx.FormTrim("searchTerm"), "'")
+ if searchTerm != "" {
+ return packages_model.SearchValue{
+ Value: searchTerm,
+ ExactMatch: false,
+ }
+ }
+
+ // $filter contains a query like:
+ // (((Id ne null) and substringof('microsoft',tolower(Id)))
+ // https://www.odata.org/documentation/odata-version-2-0/uri-conventions/ section 4.5
+ // We don't support these queries, just extract the search term.
+ filter := ctx.FormTrim("$filter")
+ match := searchTermExtract.FindStringSubmatch(filter)
+ if len(match) == 2 {
+ return packages_model.SearchValue{
+ Value: strings.TrimSpace(match[1]),
+ ExactMatch: searchTermExact.MatchString(filter),
+ }
+ }
+
+ return packages_model.SearchValue{}
+}
+
+// https://github.com/NuGet/NuGet.Client/blob/dev/src/NuGet.Core/NuGet.Protocol/LegacyFeed/V2FeedQueryBuilder.cs
+func SearchServiceV2(ctx *context.Context) {
+ skip, take := ctx.FormInt("$skip"), ctx.FormInt("$top")
+ paginator := db.NewAbsoluteListOptions(skip, take)
+
+ pvs, total, err := packages_model.SearchLatestVersions(ctx, &packages_model.PackageSearchOptions{
+ OwnerID: ctx.Package.Owner.ID,
+ Type: packages_model.TypeNuGet,
+ Name: getSearchTerm(ctx),
+ IsInternal: optional.Some(false),
+ Paginator: paginator,
+ })
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ pds, err := packages_model.GetPackageDescriptors(ctx, pvs)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ skip, take = paginator.GetSkipTake()
+
+ var next *nextOptions
+ if len(pvs) == take {
+ next = &nextOptions{
+ Path: "Search()",
+ Query: url.Values{},
+ }
+ searchTerm := ctx.FormTrim("searchTerm")
+ if searchTerm != "" {
+ next.Query.Set("searchTerm", searchTerm)
+ }
+ filter := ctx.FormTrim("$filter")
+ if filter != "" {
+ next.Query.Set("$filter", filter)
+ }
+ next.Query.Set("$skip", strconv.Itoa(skip+take))
+ next.Query.Set("$top", strconv.Itoa(take))
+ }
+
+ resp := createFeedResponse(
+ &linkBuilder{Base: setting.AppURL + "api/packages/" + ctx.Package.Owner.Name + "/nuget", Next: next},
+ total,
+ pds,
+ )
+
+ xmlResponse(ctx, http.StatusOK, resp)
+}
+
+// http://docs.oasis-open.org/odata/odata/v4.0/errata03/os/complete/part2-url-conventions/odata-v4.0-errata03-os-part2-url-conventions-complete.html#_Toc453752351
+func SearchServiceV2Count(ctx *context.Context) {
+ count, err := nuget_model.CountPackages(ctx, &packages_model.PackageSearchOptions{
+ OwnerID: ctx.Package.Owner.ID,
+ Name: getSearchTerm(ctx),
+ IsInternal: optional.Some(false),
+ })
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ ctx.PlainText(http.StatusOK, strconv.FormatInt(count, 10))
+}
+
+// https://docs.microsoft.com/en-us/nuget/api/search-query-service-resource#search-for-packages
+func SearchServiceV3(ctx *context.Context) {
+ pvs, count, err := nuget_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{
+ OwnerID: ctx.Package.Owner.ID,
+ Name: packages_model.SearchValue{Value: ctx.FormTrim("q")},
+ IsInternal: optional.Some(false),
+ Paginator: db.NewAbsoluteListOptions(
+ ctx.FormInt("skip"),
+ ctx.FormInt("take"),
+ ),
+ })
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ pds, err := packages_model.GetPackageDescriptors(ctx, pvs)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ resp := createSearchResultResponse(
+ &linkBuilder{Base: setting.AppURL + "api/packages/" + ctx.Package.Owner.Name + "/nuget"},
+ count,
+ pds,
+ )
+
+ ctx.JSON(http.StatusOK, resp)
+}
+
+// https://docs.microsoft.com/en-us/nuget/api/registration-base-url-resource#registration-index
+func RegistrationIndex(ctx *context.Context) {
+ packageName := ctx.Params("id")
+
+ pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeNuGet, packageName)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ if len(pvs) == 0 {
+ apiError(ctx, http.StatusNotFound, err)
+ return
+ }
+
+ pds, err := packages_model.GetPackageDescriptors(ctx, pvs)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ resp := createRegistrationIndexResponse(
+ &linkBuilder{Base: setting.AppURL + "api/packages/" + ctx.Package.Owner.Name + "/nuget"},
+ pds,
+ )
+
+ ctx.JSON(http.StatusOK, resp)
+}
+
+// https://github.com/NuGet/NuGet.Client/blob/dev/src/NuGet.Core/NuGet.Protocol/LegacyFeed/V2FeedQueryBuilder.cs
+func RegistrationLeafV2(ctx *context.Context) {
+ packageName := ctx.Params("id")
+ packageVersion := ctx.Params("version")
+
+ pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeNuGet, packageName, packageVersion)
+ if err != nil {
+ if err == packages_model.ErrPackageNotExist {
+ apiError(ctx, http.StatusNotFound, err)
+ return
+ }
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ pd, err := packages_model.GetPackageDescriptor(ctx, pv)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ resp := createEntryResponse(
+ &linkBuilder{Base: setting.AppURL + "api/packages/" + ctx.Package.Owner.Name + "/nuget"},
+ pd,
+ )
+
+ xmlResponse(ctx, http.StatusOK, resp)
+}
+
+// https://docs.microsoft.com/en-us/nuget/api/registration-base-url-resource#registration-leaf
+func RegistrationLeafV3(ctx *context.Context) {
+ packageName := ctx.Params("id")
+ packageVersion := strings.TrimSuffix(ctx.Params("version"), ".json")
+
+ pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeNuGet, packageName, packageVersion)
+ if err != nil {
+ if err == packages_model.ErrPackageNotExist {
+ apiError(ctx, http.StatusNotFound, err)
+ return
+ }
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ pd, err := packages_model.GetPackageDescriptor(ctx, pv)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ resp := createRegistrationLeafResponse(
+ &linkBuilder{Base: setting.AppURL + "api/packages/" + ctx.Package.Owner.Name + "/nuget"},
+ pd,
+ )
+
+ ctx.JSON(http.StatusOK, resp)
+}
+
+// https://github.com/NuGet/NuGet.Client/blob/dev/src/NuGet.Core/NuGet.Protocol/LegacyFeed/V2FeedQueryBuilder.cs
+func EnumeratePackageVersionsV2(ctx *context.Context) {
+ packageName := strings.Trim(ctx.FormTrim("id"), "'")
+
+ skip, take := ctx.FormInt("$skip"), ctx.FormInt("$top")
+ paginator := db.NewAbsoluteListOptions(skip, take)
+
+ pvs, total, err := packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{
+ OwnerID: ctx.Package.Owner.ID,
+ Type: packages_model.TypeNuGet,
+ Name: packages_model.SearchValue{
+ ExactMatch: true,
+ Value: packageName,
+ },
+ IsInternal: optional.Some(false),
+ Paginator: paginator,
+ })
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ pds, err := packages_model.GetPackageDescriptors(ctx, pvs)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ skip, take = paginator.GetSkipTake()
+
+ var next *nextOptions
+ if len(pvs) == take {
+ next = &nextOptions{
+ Path: "FindPackagesById()",
+ Query: url.Values{},
+ }
+ next.Query.Set("id", packageName)
+ next.Query.Set("$skip", strconv.Itoa(skip+take))
+ next.Query.Set("$top", strconv.Itoa(take))
+ }
+
+ resp := createFeedResponse(
+ &linkBuilder{Base: setting.AppURL + "api/packages/" + ctx.Package.Owner.Name + "/nuget", Next: next},
+ total,
+ pds,
+ )
+
+ xmlResponse(ctx, http.StatusOK, resp)
+}
+
+// http://docs.oasis-open.org/odata/odata/v4.0/errata03/os/complete/part2-url-conventions/odata-v4.0-errata03-os-part2-url-conventions-complete.html#_Toc453752351
+func EnumeratePackageVersionsV2Count(ctx *context.Context) {
+ count, err := packages_model.CountVersions(ctx, &packages_model.PackageSearchOptions{
+ OwnerID: ctx.Package.Owner.ID,
+ Type: packages_model.TypeNuGet,
+ Name: packages_model.SearchValue{
+ ExactMatch: true,
+ Value: strings.Trim(ctx.FormTrim("id"), "'"),
+ },
+ IsInternal: optional.Some(false),
+ })
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ ctx.PlainText(http.StatusOK, strconv.FormatInt(count, 10))
+}
+
+// https://docs.microsoft.com/en-us/nuget/api/package-base-address-resource#enumerate-package-versions
+func EnumeratePackageVersionsV3(ctx *context.Context) {
+ packageName := ctx.Params("id")
+
+ pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeNuGet, packageName)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ if len(pvs) == 0 {
+ apiError(ctx, http.StatusNotFound, err)
+ return
+ }
+
+ resp := createPackageVersionsResponse(pvs)
+
+ ctx.JSON(http.StatusOK, resp)
+}
+
+// https://learn.microsoft.com/en-us/nuget/api/package-base-address-resource#download-package-manifest-nuspec
+// https://learn.microsoft.com/en-us/nuget/api/package-base-address-resource#download-package-content-nupkg
+func DownloadPackageFile(ctx *context.Context) {
+ packageName := ctx.Params("id")
+ packageVersion := ctx.Params("version")
+ filename := ctx.Params("filename")
+
+ s, u, pf, err := packages_service.GetFileStreamByPackageNameAndVersion(
+ ctx,
+ &packages_service.PackageInfo{
+ Owner: ctx.Package.Owner,
+ PackageType: packages_model.TypeNuGet,
+ Name: packageName,
+ Version: packageVersion,
+ },
+ &packages_service.PackageFileInfo{
+ Filename: filename,
+ },
+ )
+ if err != nil {
+ if err == packages_model.ErrPackageNotExist || err == packages_model.ErrPackageFileNotExist {
+ apiError(ctx, http.StatusNotFound, err)
+ return
+ }
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ helper.ServePackageFile(ctx, s, u, pf)
+}
+
+// UploadPackage creates a new package with the metadata contained in the uploaded nupgk file
+// https://docs.microsoft.com/en-us/nuget/api/package-publish-resource#push-a-package
+func UploadPackage(ctx *context.Context) {
+ np, buf, closables := processUploadedFile(ctx, nuget_module.DependencyPackage)
+ defer func() {
+ for _, c := range closables {
+ c.Close()
+ }
+ }()
+ if np == nil {
+ return
+ }
+
+ pv, _, err := packages_service.CreatePackageAndAddFile(
+ ctx,
+ &packages_service.PackageCreationInfo{
+ PackageInfo: packages_service.PackageInfo{
+ Owner: ctx.Package.Owner,
+ PackageType: packages_model.TypeNuGet,
+ Name: np.ID,
+ Version: np.Version,
+ },
+ SemverCompatible: true,
+ Creator: ctx.Doer,
+ Metadata: np.Metadata,
+ },
+ &packages_service.PackageFileCreationInfo{
+ PackageFileInfo: packages_service.PackageFileInfo{
+ Filename: strings.ToLower(fmt.Sprintf("%s.%s.nupkg", np.ID, np.Version)),
+ },
+ Creator: ctx.Doer,
+ Data: buf,
+ IsLead: true,
+ },
+ )
+ if err != nil {
+ switch err {
+ case packages_model.ErrDuplicatePackageVersion:
+ apiError(ctx, http.StatusConflict, err)
+ case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize:
+ apiError(ctx, http.StatusForbidden, err)
+ default:
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ nuspecBuf, err := packages_module.CreateHashedBufferFromReaderWithSize(np.NuspecContent, np.NuspecContent.Len())
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ defer nuspecBuf.Close()
+
+ _, err = packages_service.AddFileToPackageVersionInternal(
+ ctx,
+ pv,
+ &packages_service.PackageFileCreationInfo{
+ PackageFileInfo: packages_service.PackageFileInfo{
+ Filename: strings.ToLower(fmt.Sprintf("%s.nuspec", np.ID)),
+ },
+ Data: nuspecBuf,
+ },
+ )
+ if err != nil {
+ switch err {
+ case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize:
+ apiError(ctx, http.StatusForbidden, err)
+ default:
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ ctx.Status(http.StatusCreated)
+}
+
+// UploadSymbolPackage adds a symbol package to an existing package
+// https://docs.microsoft.com/en-us/nuget/api/symbol-package-publish-resource
+func UploadSymbolPackage(ctx *context.Context) {
+ np, buf, closables := processUploadedFile(ctx, nuget_module.SymbolsPackage)
+ defer func() {
+ for _, c := range closables {
+ c.Close()
+ }
+ }()
+ if np == nil {
+ return
+ }
+
+ pdbs, err := nuget_module.ExtractPortablePdb(buf, buf.Size())
+ if err != nil {
+ if errors.Is(err, util.ErrInvalidArgument) {
+ apiError(ctx, http.StatusBadRequest, err)
+ } else {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+ defer pdbs.Close()
+
+ if _, err := buf.Seek(0, io.SeekStart); err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ pi := &packages_service.PackageInfo{
+ Owner: ctx.Package.Owner,
+ PackageType: packages_model.TypeNuGet,
+ Name: np.ID,
+ Version: np.Version,
+ }
+
+ _, err = packages_service.AddFileToExistingPackage(
+ ctx,
+ pi,
+ &packages_service.PackageFileCreationInfo{
+ PackageFileInfo: packages_service.PackageFileInfo{
+ Filename: strings.ToLower(fmt.Sprintf("%s.%s.snupkg", np.ID, np.Version)),
+ },
+ Creator: ctx.Doer,
+ Data: buf,
+ IsLead: false,
+ },
+ )
+ if err != nil {
+ switch err {
+ case packages_model.ErrPackageNotExist:
+ apiError(ctx, http.StatusNotFound, err)
+ case packages_model.ErrDuplicatePackageFile:
+ apiError(ctx, http.StatusConflict, err)
+ case packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize:
+ apiError(ctx, http.StatusForbidden, err)
+ default:
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ for _, pdb := range pdbs {
+ _, err := packages_service.AddFileToExistingPackage(
+ ctx,
+ pi,
+ &packages_service.PackageFileCreationInfo{
+ PackageFileInfo: packages_service.PackageFileInfo{
+ Filename: strings.ToLower(pdb.Name),
+ CompositeKey: strings.ToLower(pdb.ID),
+ },
+ Creator: ctx.Doer,
+ Data: pdb.Content,
+ IsLead: false,
+ Properties: map[string]string{
+ nuget_module.PropertySymbolID: strings.ToLower(pdb.ID),
+ },
+ },
+ )
+ if err != nil {
+ switch err {
+ case packages_model.ErrDuplicatePackageFile:
+ apiError(ctx, http.StatusConflict, err)
+ case packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize:
+ apiError(ctx, http.StatusForbidden, err)
+ default:
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+ }
+
+ ctx.Status(http.StatusCreated)
+}
+
+func processUploadedFile(ctx *context.Context, expectedType nuget_module.PackageType) (*nuget_module.Package, *packages_module.HashedBuffer, []io.Closer) {
+ closables := make([]io.Closer, 0, 2)
+
+ upload, needToClose, err := ctx.UploadStream()
+ if err != nil {
+ apiError(ctx, http.StatusBadRequest, err)
+ return nil, nil, closables
+ }
+
+ if needToClose {
+ closables = append(closables, upload)
+ }
+
+ buf, err := packages_module.CreateHashedBufferFromReader(upload)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return nil, nil, closables
+ }
+ closables = append(closables, buf)
+
+ np, err := nuget_module.ParsePackageMetaData(buf, buf.Size())
+ if err != nil {
+ if errors.Is(err, util.ErrInvalidArgument) {
+ apiError(ctx, http.StatusBadRequest, err)
+ } else {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return nil, nil, closables
+ }
+ if np.PackageType != expectedType {
+ apiError(ctx, http.StatusBadRequest, errors.New("unexpected package type"))
+ return nil, nil, closables
+ }
+ if _, err := buf.Seek(0, io.SeekStart); err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return nil, nil, closables
+ }
+ return np, buf, closables
+}
+
+// https://github.com/dotnet/symstore/blob/main/docs/specs/Simple_Symbol_Query_Protocol.md#request
+func DownloadSymbolFile(ctx *context.Context) {
+ filename := ctx.Params("filename")
+ guid := ctx.Params("guid")[:32]
+ filename2 := ctx.Params("filename2")
+
+ if filename != filename2 {
+ apiError(ctx, http.StatusBadRequest, nil)
+ return
+ }
+
+ pfs, _, err := packages_model.SearchFiles(ctx, &packages_model.PackageFileSearchOptions{
+ OwnerID: ctx.Package.Owner.ID,
+ PackageType: packages_model.TypeNuGet,
+ Query: filename,
+ Properties: map[string]string{
+ nuget_module.PropertySymbolID: strings.ToLower(guid),
+ },
+ })
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ if len(pfs) != 1 {
+ apiError(ctx, http.StatusNotFound, nil)
+ return
+ }
+
+ s, u, pf, err := packages_service.GetPackageFileStream(ctx, pfs[0])
+ if err != nil {
+ if err == packages_model.ErrPackageNotExist || err == packages_model.ErrPackageFileNotExist {
+ apiError(ctx, http.StatusNotFound, err)
+ return
+ }
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ helper.ServePackageFile(ctx, s, u, pf)
+}
+
+// DeletePackage hard deletes the package
+// https://docs.microsoft.com/en-us/nuget/api/package-publish-resource#delete-a-package
+func DeletePackage(ctx *context.Context) {
+ packageName := ctx.Params("id")
+ packageVersion := ctx.Params("version")
+
+ err := packages_service.RemovePackageVersionByNameAndVersion(
+ ctx,
+ ctx.Doer,
+ &packages_service.PackageInfo{
+ Owner: ctx.Package.Owner,
+ PackageType: packages_model.TypeNuGet,
+ Name: packageName,
+ Version: packageVersion,
+ },
+ )
+ if err != nil {
+ if err == packages_model.ErrPackageNotExist {
+ apiError(ctx, http.StatusNotFound, err)
+ return
+ }
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+
+ ctx.Status(http.StatusNoContent)
+}
diff --git a/routers/api/packages/pub/pub.go b/routers/api/packages/pub/pub.go
new file mode 100644
index 0000000..f87df52
--- /dev/null
+++ b/routers/api/packages/pub/pub.go
@@ -0,0 +1,284 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package pub
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "sort"
+ "strings"
+ "time"
+
+ packages_model "code.gitea.io/gitea/models/packages"
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/log"
+ packages_module "code.gitea.io/gitea/modules/packages"
+ pub_module "code.gitea.io/gitea/modules/packages/pub"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/routers/api/packages/helper"
+ "code.gitea.io/gitea/services/context"
+ packages_service "code.gitea.io/gitea/services/packages"
+)
+
+func jsonResponse(ctx *context.Context, status int, obj any) {
+ resp := ctx.Resp
+ resp.Header().Set("Content-Type", "application/vnd.pub.v2+json")
+ resp.WriteHeader(status)
+ if err := json.NewEncoder(resp).Encode(obj); err != nil {
+ log.Error("JSON encode: %v", err)
+ }
+}
+
+func apiError(ctx *context.Context, status int, obj any) {
+ type Error struct {
+ Code string `json:"code"`
+ Message string `json:"message"`
+ }
+ type ErrorWrapper struct {
+ Error Error `json:"error"`
+ }
+
+ helper.LogAndProcessError(ctx, status, obj, func(message string) {
+ jsonResponse(ctx, status, ErrorWrapper{
+ Error: Error{
+ Code: http.StatusText(status),
+ Message: message,
+ },
+ })
+ })
+}
+
+type packageVersions struct {
+ Name string `json:"name"`
+ Latest *versionMetadata `json:"latest"`
+ Versions []*versionMetadata `json:"versions"`
+}
+
+type versionMetadata struct {
+ Version string `json:"version"`
+ ArchiveURL string `json:"archive_url"`
+ Published time.Time `json:"published"`
+ Pubspec any `json:"pubspec,omitempty"`
+}
+
+func packageDescriptorToMetadata(baseURL string, pd *packages_model.PackageDescriptor) *versionMetadata {
+ return &versionMetadata{
+ Version: pd.Version.Version,
+ ArchiveURL: fmt.Sprintf("%s/files/%s.tar.gz", baseURL, url.PathEscape(pd.Version.Version)),
+ Published: pd.Version.CreatedUnix.AsLocalTime(),
+ Pubspec: pd.Metadata.(*pub_module.Metadata).Pubspec,
+ }
+}
+
+func baseURL(ctx *context.Context) string {
+ return setting.AppURL + "api/packages/" + ctx.Package.Owner.Name + "/pub/api/packages"
+}
+
+// https://github.com/dart-lang/pub/blob/master/doc/repository-spec-v2.md#list-all-versions-of-a-package
+func EnumeratePackageVersions(ctx *context.Context) {
+ packageName := ctx.Params("id")
+
+ pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypePub, packageName)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ if len(pvs) == 0 {
+ apiError(ctx, http.StatusNotFound, err)
+ return
+ }
+
+ pds, err := packages_model.GetPackageDescriptors(ctx, pvs)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ sort.Slice(pds, func(i, j int) bool {
+ return pds[i].SemVer.LessThan(pds[j].SemVer)
+ })
+
+ baseURL := fmt.Sprintf("%s/%s", baseURL(ctx), url.PathEscape(pds[0].Package.Name))
+
+ versions := make([]*versionMetadata, 0, len(pds))
+ for _, pd := range pds {
+ versions = append(versions, packageDescriptorToMetadata(baseURL, pd))
+ }
+
+ jsonResponse(ctx, http.StatusOK, &packageVersions{
+ Name: pds[0].Package.Name,
+ Latest: packageDescriptorToMetadata(baseURL, pds[0]),
+ Versions: versions,
+ })
+}
+
+// https://github.com/dart-lang/pub/blob/master/doc/repository-spec-v2.md#deprecated-inspect-a-specific-version-of-a-package
+func PackageVersionMetadata(ctx *context.Context) {
+ packageName := ctx.Params("id")
+ packageVersion := ctx.Params("version")
+
+ pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypePub, packageName, packageVersion)
+ if err != nil {
+ if err == packages_model.ErrPackageNotExist {
+ apiError(ctx, http.StatusNotFound, err)
+ return
+ }
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ pd, err := packages_model.GetPackageDescriptor(ctx, pv)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ jsonResponse(ctx, http.StatusOK, packageDescriptorToMetadata(
+ fmt.Sprintf("%s/%s", baseURL(ctx), url.PathEscape(pd.Package.Name)),
+ pd,
+ ))
+}
+
+// https://github.com/dart-lang/pub/blob/master/doc/repository-spec-v2.md#publishing-packages
+func RequestUpload(ctx *context.Context) {
+ type UploadRequest struct {
+ URL string `json:"url"`
+ Fields map[string]string `json:"fields"`
+ }
+
+ jsonResponse(ctx, http.StatusOK, UploadRequest{
+ URL: baseURL(ctx) + "/versions/new/upload",
+ Fields: make(map[string]string),
+ })
+}
+
+// https://github.com/dart-lang/pub/blob/master/doc/repository-spec-v2.md#publishing-packages
+func UploadPackageFile(ctx *context.Context) {
+ file, _, err := ctx.Req.FormFile("file")
+ if err != nil {
+ apiError(ctx, http.StatusBadRequest, err)
+ return
+ }
+ defer file.Close()
+
+ buf, err := packages_module.CreateHashedBufferFromReader(file)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ defer buf.Close()
+
+ pck, err := pub_module.ParsePackage(buf)
+ if err != nil {
+ if errors.Is(err, util.ErrInvalidArgument) {
+ apiError(ctx, http.StatusBadRequest, err)
+ } else {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ if _, err := buf.Seek(0, io.SeekStart); err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ _, _, err = packages_service.CreatePackageAndAddFile(
+ ctx,
+ &packages_service.PackageCreationInfo{
+ PackageInfo: packages_service.PackageInfo{
+ Owner: ctx.Package.Owner,
+ PackageType: packages_model.TypePub,
+ Name: pck.Name,
+ Version: pck.Version,
+ },
+ SemverCompatible: true,
+ Creator: ctx.Doer,
+ Metadata: pck.Metadata,
+ },
+ &packages_service.PackageFileCreationInfo{
+ PackageFileInfo: packages_service.PackageFileInfo{
+ Filename: strings.ToLower(pck.Version + ".tar.gz"),
+ },
+ Creator: ctx.Doer,
+ Data: buf,
+ IsLead: true,
+ },
+ )
+ if err != nil {
+ switch err {
+ case packages_model.ErrDuplicatePackageVersion:
+ apiError(ctx, http.StatusConflict, err)
+ case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize:
+ apiError(ctx, http.StatusForbidden, err)
+ default:
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ ctx.Resp.Header().Set("Location", fmt.Sprintf("%s/versions/new/finalize/%s/%s", baseURL(ctx), url.PathEscape(pck.Name), url.PathEscape(pck.Version)))
+ ctx.Status(http.StatusNoContent)
+}
+
+// https://github.com/dart-lang/pub/blob/master/doc/repository-spec-v2.md#publishing-packages
+func FinalizePackage(ctx *context.Context) {
+ packageName := ctx.Params("id")
+ packageVersion := ctx.Params("version")
+
+ _, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypePub, packageName, packageVersion)
+ if err != nil {
+ if err == packages_model.ErrPackageNotExist {
+ apiError(ctx, http.StatusNotFound, err)
+ return
+ }
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ type Success struct {
+ Message string `json:"message"`
+ }
+ type SuccessWrapper struct {
+ Success Success `json:"success"`
+ }
+
+ jsonResponse(ctx, http.StatusOK, SuccessWrapper{Success{}})
+}
+
+// https://github.com/dart-lang/pub/blob/master/doc/repository-spec-v2.md#deprecated-download-a-specific-version-of-a-package
+func DownloadPackageFile(ctx *context.Context) {
+ packageName := ctx.Params("id")
+ packageVersion := strings.TrimSuffix(ctx.Params("version"), ".tar.gz")
+
+ pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypePub, packageName, packageVersion)
+ if err != nil {
+ if err == packages_model.ErrPackageNotExist {
+ apiError(ctx, http.StatusNotFound, err)
+ return
+ }
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ pd, err := packages_model.GetPackageDescriptor(ctx, pv)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ pf := pd.Files[0].File
+
+ s, u, _, err := packages_service.GetPackageFileStream(ctx, pf)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ helper.ServePackageFile(ctx, s, u, pf)
+}
diff --git a/routers/api/packages/pypi/pypi.go b/routers/api/packages/pypi/pypi.go
new file mode 100644
index 0000000..7824db1
--- /dev/null
+++ b/routers/api/packages/pypi/pypi.go
@@ -0,0 +1,194 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package pypi
+
+import (
+ "encoding/hex"
+ "io"
+ "net/http"
+ "regexp"
+ "sort"
+ "strings"
+
+ packages_model "code.gitea.io/gitea/models/packages"
+ packages_module "code.gitea.io/gitea/modules/packages"
+ pypi_module "code.gitea.io/gitea/modules/packages/pypi"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/validation"
+ "code.gitea.io/gitea/routers/api/packages/helper"
+ "code.gitea.io/gitea/services/context"
+ packages_service "code.gitea.io/gitea/services/packages"
+)
+
+// https://peps.python.org/pep-0426/#name
+var (
+ normalizer = strings.NewReplacer(".", "-", "_", "-")
+ nameMatcher = regexp.MustCompile(`\A(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\.\-_]*[a-zA-Z0-9])\z`)
+)
+
+// https://peps.python.org/pep-0440/#appendix-b-parsing-version-strings-with-regular-expressions
+var versionMatcher = regexp.MustCompile(`\Av?` +
+ `(?:[0-9]+!)?` + // epoch
+ `[0-9]+(?:\.[0-9]+)*` + // release segment
+ `(?:[-_\.]?(?:a|b|c|rc|alpha|beta|pre|preview)[-_\.]?[0-9]*)?` + // pre-release
+ `(?:-[0-9]+|[-_\.]?(?:post|rev|r)[-_\.]?[0-9]*)?` + // post release
+ `(?:[-_\.]?dev[-_\.]?[0-9]*)?` + // dev release
+ `(?:\+[a-z0-9]+(?:[-_\.][a-z0-9]+)*)?` + // local version
+ `\z`)
+
+func apiError(ctx *context.Context, status int, obj any) {
+ helper.LogAndProcessError(ctx, status, obj, func(message string) {
+ ctx.PlainText(status, message)
+ })
+}
+
+// PackageMetadata returns the metadata for a single package
+func PackageMetadata(ctx *context.Context) {
+ packageName := normalizer.Replace(ctx.Params("id"))
+
+ pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypePyPI, packageName)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ if len(pvs) == 0 {
+ apiError(ctx, http.StatusNotFound, err)
+ return
+ }
+
+ pds, err := packages_model.GetPackageDescriptors(ctx, pvs)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ // sort package descriptors by version to mimic PyPI format
+ sort.Slice(pds, func(i, j int) bool {
+ return strings.Compare(pds[i].Version.Version, pds[j].Version.Version) < 0
+ })
+
+ ctx.Data["RegistryURL"] = setting.AppURL + "api/packages/" + ctx.Package.Owner.Name + "/pypi"
+ ctx.Data["PackageDescriptor"] = pds[0]
+ ctx.Data["PackageDescriptors"] = pds
+ ctx.HTML(http.StatusOK, "api/packages/pypi/simple")
+}
+
+// DownloadPackageFile serves the content of a package
+func DownloadPackageFile(ctx *context.Context) {
+ packageName := normalizer.Replace(ctx.Params("id"))
+ packageVersion := ctx.Params("version")
+ filename := ctx.Params("filename")
+
+ s, u, pf, err := packages_service.GetFileStreamByPackageNameAndVersion(
+ ctx,
+ &packages_service.PackageInfo{
+ Owner: ctx.Package.Owner,
+ PackageType: packages_model.TypePyPI,
+ Name: packageName,
+ Version: packageVersion,
+ },
+ &packages_service.PackageFileInfo{
+ Filename: filename,
+ },
+ )
+ if err != nil {
+ if err == packages_model.ErrPackageNotExist || err == packages_model.ErrPackageFileNotExist {
+ apiError(ctx, http.StatusNotFound, err)
+ return
+ }
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ helper.ServePackageFile(ctx, s, u, pf)
+}
+
+// UploadPackageFile adds a file to the package. If the package does not exist, it gets created.
+func UploadPackageFile(ctx *context.Context) {
+ file, fileHeader, err := ctx.Req.FormFile("content")
+ if err != nil {
+ apiError(ctx, http.StatusBadRequest, err)
+ return
+ }
+ defer file.Close()
+
+ buf, err := packages_module.CreateHashedBufferFromReader(file)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ defer buf.Close()
+
+ _, _, hashSHA256, _ := buf.Sums()
+
+ if !strings.EqualFold(ctx.Req.FormValue("sha256_digest"), hex.EncodeToString(hashSHA256)) {
+ apiError(ctx, http.StatusBadRequest, "hash mismatch")
+ return
+ }
+
+ if _, err := buf.Seek(0, io.SeekStart); err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ packageName := normalizer.Replace(ctx.Req.FormValue("name"))
+ packageVersion := ctx.Req.FormValue("version")
+ if !isValidNameAndVersion(packageName, packageVersion) {
+ apiError(ctx, http.StatusBadRequest, "invalid name or version")
+ return
+ }
+
+ projectURL := ctx.Req.FormValue("home_page")
+ if !validation.IsValidURL(projectURL) {
+ projectURL = ""
+ }
+
+ _, _, err = packages_service.CreatePackageOrAddFileToExisting(
+ ctx,
+ &packages_service.PackageCreationInfo{
+ PackageInfo: packages_service.PackageInfo{
+ Owner: ctx.Package.Owner,
+ PackageType: packages_model.TypePyPI,
+ Name: packageName,
+ Version: packageVersion,
+ },
+ SemverCompatible: false,
+ Creator: ctx.Doer,
+ Metadata: &pypi_module.Metadata{
+ Author: ctx.Req.FormValue("author"),
+ Description: ctx.Req.FormValue("description"),
+ LongDescription: ctx.Req.FormValue("long_description"),
+ Summary: ctx.Req.FormValue("summary"),
+ ProjectURL: projectURL,
+ License: ctx.Req.FormValue("license"),
+ RequiresPython: ctx.Req.FormValue("requires_python"),
+ },
+ },
+ &packages_service.PackageFileCreationInfo{
+ PackageFileInfo: packages_service.PackageFileInfo{
+ Filename: fileHeader.Filename,
+ },
+ Creator: ctx.Doer,
+ Data: buf,
+ IsLead: true,
+ },
+ )
+ if err != nil {
+ switch err {
+ case packages_model.ErrDuplicatePackageFile:
+ apiError(ctx, http.StatusConflict, err)
+ case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize:
+ apiError(ctx, http.StatusForbidden, err)
+ default:
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ ctx.Status(http.StatusCreated)
+}
+
+func isValidNameAndVersion(packageName, packageVersion string) bool {
+ return nameMatcher.MatchString(packageName) && versionMatcher.MatchString(packageVersion)
+}
diff --git a/routers/api/packages/pypi/pypi_test.go b/routers/api/packages/pypi/pypi_test.go
new file mode 100644
index 0000000..3023692
--- /dev/null
+++ b/routers/api/packages/pypi/pypi_test.go
@@ -0,0 +1,38 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package pypi
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestIsValidNameAndVersion(t *testing.T) {
+ // The test cases below were created from the following Python PEPs:
+ // https://peps.python.org/pep-0426/#name
+ // https://peps.python.org/pep-0440/#appendix-b-parsing-version-strings-with-regular-expressions
+
+ // Valid Cases
+ assert.True(t, isValidNameAndVersion("A", "1.0.1"))
+ assert.True(t, isValidNameAndVersion("Test.Name.1234", "1.0.1"))
+ assert.True(t, isValidNameAndVersion("test_name", "1.0.1"))
+ assert.True(t, isValidNameAndVersion("test-name", "1.0.1"))
+ assert.True(t, isValidNameAndVersion("test-name", "v1.0.1"))
+ assert.True(t, isValidNameAndVersion("test-name", "2012.4"))
+ assert.True(t, isValidNameAndVersion("test-name", "1.0.1-alpha"))
+ assert.True(t, isValidNameAndVersion("test-name", "1.0.1a1"))
+ assert.True(t, isValidNameAndVersion("test-name", "1.0b2.r345.dev456"))
+ assert.True(t, isValidNameAndVersion("test-name", "1!1.0.1"))
+ assert.True(t, isValidNameAndVersion("test-name", "1.0.1+local.1"))
+
+ // Invalid Cases
+ assert.False(t, isValidNameAndVersion(".test-name", "1.0.1"))
+ assert.False(t, isValidNameAndVersion("test!name", "1.0.1"))
+ assert.False(t, isValidNameAndVersion("-test-name", "1.0.1"))
+ assert.False(t, isValidNameAndVersion("test-name-", "1.0.1"))
+ assert.False(t, isValidNameAndVersion("test-name", "a1.0.1"))
+ assert.False(t, isValidNameAndVersion("test-name", "1.0.1aa"))
+ assert.False(t, isValidNameAndVersion("test-name", "1.0.0-alpha.beta"))
+}
diff --git a/routers/api/packages/rpm/rpm.go b/routers/api/packages/rpm/rpm.go
new file mode 100644
index 0000000..54fb01c
--- /dev/null
+++ b/routers/api/packages/rpm/rpm.go
@@ -0,0 +1,318 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package rpm
+
+import (
+ stdctx "context"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+
+ "code.gitea.io/gitea/models/db"
+ packages_model "code.gitea.io/gitea/models/packages"
+ "code.gitea.io/gitea/modules/json"
+ packages_module "code.gitea.io/gitea/modules/packages"
+ rpm_module "code.gitea.io/gitea/modules/packages/rpm"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/routers/api/packages/helper"
+ "code.gitea.io/gitea/services/context"
+ notify_service "code.gitea.io/gitea/services/notify"
+ packages_service "code.gitea.io/gitea/services/packages"
+ rpm_service "code.gitea.io/gitea/services/packages/rpm"
+)
+
+func apiError(ctx *context.Context, status int, obj any) {
+ helper.LogAndProcessError(ctx, status, obj, func(message string) {
+ ctx.PlainText(status, message)
+ })
+}
+
+// https://dnf.readthedocs.io/en/latest/conf_ref.html
+func GetRepositoryConfig(ctx *context.Context) {
+ group := ctx.Params("group")
+
+ var groupParts []string
+ if group != "" {
+ groupParts = strings.Split(group, "/")
+ }
+
+ url := fmt.Sprintf("%sapi/packages/%s/rpm", setting.AppURL, ctx.Package.Owner.Name)
+
+ ctx.PlainText(http.StatusOK, `[gitea-`+strings.Join(append([]string{ctx.Package.Owner.LowerName}, groupParts...), "-")+`]
+name=`+strings.Join(append([]string{ctx.Package.Owner.Name, setting.AppName}, groupParts...), " - ")+`
+baseurl=`+strings.Join(append([]string{url}, groupParts...), "/")+`
+enabled=1
+gpgcheck=1
+gpgkey=`+url+`/repository.key`)
+}
+
+// Gets or creates the PGP public key used to sign repository metadata files
+func GetRepositoryKey(ctx *context.Context) {
+ _, pub, err := rpm_service.GetOrCreateKeyPair(ctx, ctx.Package.Owner.ID)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ ctx.ServeContent(strings.NewReader(pub), &context.ServeHeaderOptions{
+ ContentType: "application/pgp-keys",
+ Filename: "repository.key",
+ })
+}
+
+func CheckRepositoryFileExistence(ctx *context.Context) {
+ pv, err := rpm_service.GetOrCreateRepositoryVersion(ctx, ctx.Package.Owner.ID)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ pf, err := packages_model.GetFileForVersionByName(ctx, pv.ID, ctx.Params("filename"), ctx.Params("group"))
+ if err != nil {
+ if errors.Is(err, util.ErrNotExist) {
+ ctx.Status(http.StatusNotFound)
+ } else {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ ctx.SetServeHeaders(&context.ServeHeaderOptions{
+ Filename: pf.Name,
+ LastModified: pf.CreatedUnix.AsLocalTime(),
+ })
+ ctx.Status(http.StatusOK)
+}
+
+// Gets a pre-generated repository metadata file
+func GetRepositoryFile(ctx *context.Context) {
+ pv, err := rpm_service.GetOrCreateRepositoryVersion(ctx, ctx.Package.Owner.ID)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ s, u, pf, err := packages_service.GetFileStreamByPackageVersion(
+ ctx,
+ pv,
+ &packages_service.PackageFileInfo{
+ Filename: ctx.Params("filename"),
+ CompositeKey: ctx.Params("group"),
+ },
+ )
+ if err != nil {
+ if errors.Is(err, util.ErrNotExist) {
+ apiError(ctx, http.StatusNotFound, err)
+ } else {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ helper.ServePackageFile(ctx, s, u, pf)
+}
+
+func UploadPackageFile(ctx *context.Context) {
+ upload, needToClose, err := ctx.UploadStream()
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ if needToClose {
+ defer upload.Close()
+ }
+
+ buf, err := packages_module.CreateHashedBufferFromReader(upload)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ defer buf.Close()
+ // if rpm sign enabled
+ if setting.Packages.DefaultRPMSignEnabled || ctx.FormBool("sign") {
+ pri, _, err := rpm_service.GetOrCreateKeyPair(ctx, ctx.Package.Owner.ID)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ signedBuf, err := rpm_service.NewSignedRPMBuffer(buf, pri)
+ if err != nil {
+ // Not in rpm format, parsing failed.
+ apiError(ctx, http.StatusBadRequest, err)
+ return
+ }
+ defer signedBuf.Close()
+ buf = signedBuf
+ }
+
+ pck, err := rpm_module.ParsePackage(buf)
+ if err != nil {
+ if errors.Is(err, util.ErrInvalidArgument) {
+ apiError(ctx, http.StatusBadRequest, err)
+ } else {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ if _, err := buf.Seek(0, io.SeekStart); err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ fileMetadataRaw, err := json.Marshal(pck.FileMetadata)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ group := ctx.Params("group")
+ _, _, err = packages_service.CreatePackageOrAddFileToExisting(
+ ctx,
+ &packages_service.PackageCreationInfo{
+ PackageInfo: packages_service.PackageInfo{
+ Owner: ctx.Package.Owner,
+ PackageType: packages_model.TypeRpm,
+ Name: pck.Name,
+ Version: pck.Version,
+ },
+ Creator: ctx.Doer,
+ Metadata: pck.VersionMetadata,
+ },
+ &packages_service.PackageFileCreationInfo{
+ PackageFileInfo: packages_service.PackageFileInfo{
+ Filename: fmt.Sprintf("%s-%s.%s.rpm", pck.Name, pck.Version, pck.FileMetadata.Architecture),
+ CompositeKey: group,
+ },
+ Creator: ctx.Doer,
+ Data: buf,
+ IsLead: true,
+ Properties: map[string]string{
+ rpm_module.PropertyGroup: group,
+ rpm_module.PropertyArchitecture: pck.FileMetadata.Architecture,
+ rpm_module.PropertyMetadata: string(fileMetadataRaw),
+ },
+ },
+ )
+ if err != nil {
+ switch err {
+ case packages_model.ErrDuplicatePackageVersion, packages_model.ErrDuplicatePackageFile:
+ apiError(ctx, http.StatusConflict, err)
+ case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize:
+ apiError(ctx, http.StatusForbidden, err)
+ default:
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ if err := rpm_service.BuildSpecificRepositoryFiles(ctx, ctx.Package.Owner.ID, group); err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ ctx.Status(http.StatusCreated)
+}
+
+func DownloadPackageFile(ctx *context.Context) {
+ name := ctx.Params("name")
+ version := ctx.Params("version")
+
+ s, u, pf, err := packages_service.GetFileStreamByPackageNameAndVersion(
+ ctx,
+ &packages_service.PackageInfo{
+ Owner: ctx.Package.Owner,
+ PackageType: packages_model.TypeRpm,
+ Name: name,
+ Version: version,
+ },
+ &packages_service.PackageFileInfo{
+ Filename: fmt.Sprintf("%s-%s.%s.rpm", name, version, ctx.Params("architecture")),
+ CompositeKey: ctx.Params("group"),
+ },
+ )
+ if err != nil {
+ if errors.Is(err, util.ErrNotExist) {
+ apiError(ctx, http.StatusNotFound, err)
+ } else {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ helper.ServePackageFile(ctx, s, u, pf)
+}
+
+func DeletePackageFile(webctx *context.Context) {
+ group := webctx.Params("group")
+ name := webctx.Params("name")
+ version := webctx.Params("version")
+ architecture := webctx.Params("architecture")
+
+ var pd *packages_model.PackageDescriptor
+
+ err := db.WithTx(webctx, func(ctx stdctx.Context) error {
+ pv, err := packages_model.GetVersionByNameAndVersion(ctx,
+ webctx.Package.Owner.ID,
+ packages_model.TypeRpm,
+ name,
+ version,
+ )
+ if err != nil {
+ return err
+ }
+
+ pf, err := packages_model.GetFileForVersionByName(
+ ctx,
+ pv.ID,
+ fmt.Sprintf("%s-%s.%s.rpm", name, version, architecture),
+ group,
+ )
+ if err != nil {
+ return err
+ }
+
+ if err := packages_service.DeletePackageFile(ctx, pf); err != nil {
+ return err
+ }
+
+ has, err := packages_model.HasVersionFileReferences(ctx, pv.ID)
+ if err != nil {
+ return err
+ }
+ if !has {
+ pd, err = packages_model.GetPackageDescriptor(ctx, pv)
+ if err != nil {
+ return err
+ }
+
+ if err := packages_service.DeletePackageVersionAndReferences(ctx, pv); err != nil {
+ return err
+ }
+ }
+
+ return nil
+ })
+ if err != nil {
+ if errors.Is(err, util.ErrNotExist) {
+ apiError(webctx, http.StatusNotFound, err)
+ } else {
+ apiError(webctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ if pd != nil {
+ notify_service.PackageDelete(webctx, webctx.Doer, pd)
+ }
+
+ if err := rpm_service.BuildSpecificRepositoryFiles(webctx, webctx.Package.Owner.ID, group); err != nil {
+ apiError(webctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ webctx.Status(http.StatusNoContent)
+}
diff --git a/routers/api/packages/rubygems/rubygems.go b/routers/api/packages/rubygems/rubygems.go
new file mode 100644
index 0000000..dfefe2c
--- /dev/null
+++ b/routers/api/packages/rubygems/rubygems.go
@@ -0,0 +1,451 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package rubygems
+
+import (
+ "compress/gzip"
+ "compress/zlib"
+ "crypto/md5"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+
+ packages_model "code.gitea.io/gitea/models/packages"
+ "code.gitea.io/gitea/modules/optional"
+ packages_module "code.gitea.io/gitea/modules/packages"
+ rubygems_module "code.gitea.io/gitea/modules/packages/rubygems"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/routers/api/packages/helper"
+ "code.gitea.io/gitea/services/context"
+ packages_service "code.gitea.io/gitea/services/packages"
+)
+
+const (
+ Sep = "---\n"
+)
+
+func apiError(ctx *context.Context, status int, obj any) {
+ helper.LogAndProcessError(ctx, status, obj, func(message string) {
+ ctx.PlainText(status, message)
+ })
+}
+
+// EnumeratePackages serves the package list
+func EnumeratePackages(ctx *context.Context) {
+ packages, err := packages_model.GetVersionsByPackageType(ctx, ctx.Package.Owner.ID, packages_model.TypeRubyGems)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ enumeratePackages(ctx, "specs.4.8", packages)
+}
+
+// EnumeratePackagesLatest serves the list of the latest version of every package
+func EnumeratePackagesLatest(ctx *context.Context) {
+ pvs, _, err := packages_model.SearchLatestVersions(ctx, &packages_model.PackageSearchOptions{
+ OwnerID: ctx.Package.Owner.ID,
+ Type: packages_model.TypeRubyGems,
+ IsInternal: optional.Some(false),
+ })
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ enumeratePackages(ctx, "latest_specs.4.8", pvs)
+}
+
+// EnumeratePackagesPreRelease is not supported and serves an empty list
+func EnumeratePackagesPreRelease(ctx *context.Context) {
+ enumeratePackages(ctx, "prerelease_specs.4.8", []*packages_model.PackageVersion{})
+}
+
+func enumeratePackages(ctx *context.Context, filename string, pvs []*packages_model.PackageVersion) {
+ pds, err := packages_model.GetPackageDescriptors(ctx, pvs)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ specs := make([]any, 0, len(pds))
+ for _, p := range pds {
+ specs = append(specs, []any{
+ p.Package.Name,
+ &rubygems_module.RubyUserMarshal{
+ Name: "Gem::Version",
+ Value: []string{p.Version.Version},
+ },
+ p.Metadata.(*rubygems_module.Metadata).Platform,
+ })
+ }
+
+ ctx.SetServeHeaders(&context.ServeHeaderOptions{
+ Filename: filename + ".gz",
+ })
+
+ zw := gzip.NewWriter(ctx.Resp)
+ defer zw.Close()
+
+ zw.Name = filename
+
+ if err := rubygems_module.NewMarshalEncoder(zw).Encode(specs); err != nil {
+ ctx.ServerError("Download file failed", err)
+ }
+}
+
+// Serves info file for rubygems.org compatible /info/{gem} file.
+// See also https://guides.rubygems.org/rubygems-org-compact-index-api/.
+func ServePackageInfo(ctx *context.Context) {
+ packageName := ctx.Params("package")
+ versions, err := packages_model.GetVersionsByPackageName(
+ ctx, ctx.Package.Owner.ID, packages_model.TypeRubyGems, packageName)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ if len(versions) == 0 {
+ apiError(ctx, http.StatusNotFound, fmt.Sprintf("Could not find package %s", packageName))
+ }
+
+ result, err := buildInfoFileForPackage(ctx, versions)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ ctx.PlainText(http.StatusOK, *result)
+}
+
+// ServeVersionsFile creates rubygems.org compatible /versions file.
+// See also https://guides.rubygems.org/rubygems-org-compact-index-api/.
+func ServeVersionsFile(ctx *context.Context) {
+ packages, err := packages_model.GetPackagesByType(
+ ctx, ctx.Package.Owner.ID, packages_model.TypeRubyGems)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ result := new(strings.Builder)
+ result.WriteString(Sep)
+ for _, pack := range packages {
+ versions, err := packages_model.GetVersionsByPackageName(
+ ctx, ctx.Package.Owner.ID, packages_model.TypeRubyGems, pack.Name)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ if len(versions) == 0 {
+ // No versions left for this package, we should continue.
+ continue
+ }
+
+ fmt.Fprintf(result, "%s ", pack.Name)
+ for i, v := range versions {
+ result.WriteString(v.Version)
+ if i != len(versions)-1 {
+ result.WriteString(",")
+ }
+ }
+
+ info, err := buildInfoFileForPackage(ctx, versions)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+
+ checksum := md5.Sum([]byte(*info))
+ fmt.Fprintf(result, " %x\n", checksum)
+ }
+ ctx.PlainText(http.StatusOK, result.String())
+}
+
+// ServePackageSpecification serves the compressed Gemspec file of a package
+func ServePackageSpecification(ctx *context.Context) {
+ filename := ctx.Params("filename")
+
+ if !strings.HasSuffix(filename, ".gemspec.rz") {
+ apiError(ctx, http.StatusNotImplemented, nil)
+ return
+ }
+
+ pvs, err := getVersionsByFilename(ctx, filename[:len(filename)-10]+"gem")
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ if len(pvs) != 1 {
+ apiError(ctx, http.StatusNotFound, nil)
+ return
+ }
+
+ pd, err := packages_model.GetPackageDescriptor(ctx, pvs[0])
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ ctx.SetServeHeaders(&context.ServeHeaderOptions{
+ Filename: filename,
+ })
+
+ zw := zlib.NewWriter(ctx.Resp)
+ defer zw.Close()
+
+ metadata := pd.Metadata.(*rubygems_module.Metadata)
+
+ // create a Ruby Gem::Specification object
+ spec := &rubygems_module.RubyUserDef{
+ Name: "Gem::Specification",
+ Value: []any{
+ "3.2.3", // @rubygems_version
+ 4, // @specification_version,
+ pd.Package.Name,
+ &rubygems_module.RubyUserMarshal{
+ Name: "Gem::Version",
+ Value: []string{pd.Version.Version},
+ },
+ nil, // date
+ metadata.Summary, // @summary
+ nil, // @required_ruby_version
+ nil, // @required_rubygems_version
+ metadata.Platform, // @original_platform
+ []any{}, // @dependencies
+ nil, // rubyforge_project
+ "", // @email
+ metadata.Authors,
+ metadata.Description,
+ metadata.ProjectURL,
+ true, // has_rdoc
+ metadata.Platform, // @new_platform
+ nil,
+ metadata.Licenses,
+ },
+ }
+
+ if err := rubygems_module.NewMarshalEncoder(zw).Encode(spec); err != nil {
+ ctx.ServerError("Download file failed", err)
+ }
+}
+
+// DownloadPackageFile serves the content of a package
+func DownloadPackageFile(ctx *context.Context) {
+ filename := ctx.Params("filename")
+
+ pvs, err := getVersionsByFilename(ctx, filename)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ if len(pvs) != 1 {
+ apiError(ctx, http.StatusNotFound, nil)
+ return
+ }
+
+ s, u, pf, err := packages_service.GetFileStreamByPackageVersion(
+ ctx,
+ pvs[0],
+ &packages_service.PackageFileInfo{
+ Filename: filename,
+ },
+ )
+ if err != nil {
+ if err == packages_model.ErrPackageFileNotExist {
+ apiError(ctx, http.StatusNotFound, err)
+ return
+ }
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ helper.ServePackageFile(ctx, s, u, pf)
+}
+
+// UploadPackageFile adds a file to the package. If the package does not exist, it gets created.
+func UploadPackageFile(ctx *context.Context) {
+ upload, needToClose, err := ctx.UploadStream()
+ if err != nil {
+ apiError(ctx, http.StatusBadRequest, err)
+ return
+ }
+ if needToClose {
+ defer upload.Close()
+ }
+
+ buf, err := packages_module.CreateHashedBufferFromReader(upload)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ defer buf.Close()
+
+ rp, err := rubygems_module.ParsePackageMetaData(buf)
+ if err != nil {
+ if errors.Is(err, util.ErrInvalidArgument) {
+ apiError(ctx, http.StatusBadRequest, err)
+ } else {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+ if _, err := buf.Seek(0, io.SeekStart); err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ filename := getFullFilename(rp.Name, rp.Version, rp.Metadata.Platform)
+
+ _, _, err = packages_service.CreatePackageAndAddFile(
+ ctx,
+ &packages_service.PackageCreationInfo{
+ PackageInfo: packages_service.PackageInfo{
+ Owner: ctx.Package.Owner,
+ PackageType: packages_model.TypeRubyGems,
+ Name: rp.Name,
+ Version: rp.Version,
+ },
+ SemverCompatible: true,
+ Creator: ctx.Doer,
+ Metadata: rp.Metadata,
+ },
+ &packages_service.PackageFileCreationInfo{
+ PackageFileInfo: packages_service.PackageFileInfo{
+ Filename: filename,
+ },
+ Creator: ctx.Doer,
+ Data: buf,
+ IsLead: true,
+ },
+ )
+ if err != nil {
+ switch err {
+ case packages_model.ErrDuplicatePackageVersion:
+ apiError(ctx, http.StatusConflict, err)
+ case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize:
+ apiError(ctx, http.StatusForbidden, err)
+ default:
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ ctx.Status(http.StatusCreated)
+}
+
+// DeletePackage deletes a package
+func DeletePackage(ctx *context.Context) {
+ // Go populates the form only for POST, PUT and PATCH requests
+ if err := ctx.Req.ParseMultipartForm(32 << 20); err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ packageName := ctx.FormString("gem_name")
+ packageVersion := ctx.FormString("version")
+
+ err := packages_service.RemovePackageVersionByNameAndVersion(
+ ctx,
+ ctx.Doer,
+ &packages_service.PackageInfo{
+ Owner: ctx.Package.Owner,
+ PackageType: packages_model.TypeRubyGems,
+ Name: packageName,
+ Version: packageVersion,
+ },
+ )
+ if err != nil {
+ if err == packages_model.ErrPackageNotExist {
+ apiError(ctx, http.StatusNotFound, err)
+ return
+ }
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+}
+
+func writeRequirements(reqs []rubygems_module.VersionRequirement, result *strings.Builder) {
+ if len(reqs) == 0 {
+ reqs = []rubygems_module.VersionRequirement{{Restriction: ">=", Version: "0"}}
+ }
+ for i, req := range reqs {
+ if i != 0 {
+ result.WriteString("&")
+ }
+ result.WriteString(req.Restriction)
+ result.WriteString(" ")
+ result.WriteString(req.Version)
+ }
+}
+
+func buildRequirementStringFromVersion(ctx *context.Context, version *packages_model.PackageVersion) (string, error) {
+ pd, err := packages_model.GetPackageDescriptor(ctx, version)
+ if err != nil {
+ return "", err
+ }
+ metadata := pd.Metadata.(*rubygems_module.Metadata)
+ dependencyRequirements := new(strings.Builder)
+ for i, dep := range metadata.RuntimeDependencies {
+ if i != 0 {
+ dependencyRequirements.WriteString(",")
+ }
+
+ dependencyRequirements.WriteString(dep.Name)
+ dependencyRequirements.WriteString(":")
+ reqs := dep.Version
+ writeRequirements(reqs, dependencyRequirements)
+ }
+ fullname := getFullFilename(pd.Package.Name, version.Version, metadata.Platform)
+ file, err := packages_model.GetFileForVersionByName(ctx, version.ID, fullname, "")
+ if err != nil {
+ return "", err
+ }
+ blob, err := packages_model.GetBlobByID(ctx, file.BlobID)
+ if err != nil {
+ return "", err
+ }
+ additionalRequirements := new(strings.Builder)
+ fmt.Fprintf(additionalRequirements, "checksum:%s", blob.HashSHA256)
+ if len(metadata.RequiredRubyVersion) != 0 {
+ additionalRequirements.WriteString(",ruby:")
+ writeRequirements(metadata.RequiredRubyVersion, additionalRequirements)
+ }
+ if len(metadata.RequiredRubygemsVersion) != 0 {
+ additionalRequirements.WriteString(",rubygems:")
+ writeRequirements(metadata.RequiredRubygemsVersion, additionalRequirements)
+ }
+ return fmt.Sprintf("%s %s|%s", version.Version, dependencyRequirements, additionalRequirements), nil
+}
+
+func buildInfoFileForPackage(ctx *context.Context, versions []*packages_model.PackageVersion) (*string, error) {
+ result := "---\n"
+ for _, v := range versions {
+ str, err := buildRequirementStringFromVersion(ctx, v)
+ if err != nil {
+ return nil, err
+ }
+ result += str
+ result += "\n"
+ }
+ return &result, nil
+}
+
+func getFullFilename(gemName, version, platform string) string {
+ return strings.ToLower(getFullName(gemName, version, platform)) + ".gem"
+}
+
+func getFullName(gemName, version, platform string) string {
+ if platform == "" || platform == "ruby" {
+ return fmt.Sprintf("%s-%s", gemName, version)
+ }
+ return fmt.Sprintf("%s-%s-%s", gemName, version, platform)
+}
+
+func getVersionsByFilename(ctx *context.Context, filename string) ([]*packages_model.PackageVersion, error) {
+ pvs, _, err := packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{
+ OwnerID: ctx.Package.Owner.ID,
+ Type: packages_model.TypeRubyGems,
+ HasFileWithName: filename,
+ IsInternal: optional.Some(false),
+ })
+ return pvs, err
+}
diff --git a/routers/api/packages/swift/swift.go b/routers/api/packages/swift/swift.go
new file mode 100644
index 0000000..a9da3ea
--- /dev/null
+++ b/routers/api/packages/swift/swift.go
@@ -0,0 +1,465 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package swift
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "regexp"
+ "sort"
+ "strings"
+
+ packages_model "code.gitea.io/gitea/models/packages"
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/optional"
+ packages_module "code.gitea.io/gitea/modules/packages"
+ swift_module "code.gitea.io/gitea/modules/packages/swift"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/routers/api/packages/helper"
+ "code.gitea.io/gitea/services/context"
+ packages_service "code.gitea.io/gitea/services/packages"
+
+ "github.com/hashicorp/go-version"
+)
+
+// https://github.com/apple/swift-package-manager/blob/main/Documentation/Registry.md#35-api-versioning
+const (
+ AcceptJSON = "application/vnd.swift.registry.v1+json"
+ AcceptSwift = "application/vnd.swift.registry.v1+swift"
+ AcceptZip = "application/vnd.swift.registry.v1+zip"
+)
+
+var (
+ // https://github.com/apple/swift-package-manager/blob/main/Documentation/Registry.md#361-package-scope
+ scopePattern = regexp.MustCompile(`\A[a-zA-Z0-9][a-zA-Z0-9-]{0,38}\z`)
+ // https://github.com/apple/swift-package-manager/blob/main/Documentation/Registry.md#362-package-name
+ namePattern = regexp.MustCompile(`\A[a-zA-Z0-9][a-zA-Z0-9-_]{0,99}\z`)
+)
+
+type headers struct {
+ Status int
+ ContentType string
+ Digest string
+ Location string
+ Link string
+}
+
+// https://github.com/apple/swift-package-manager/blob/main/Documentation/Registry.md#35-api-versioning
+func setResponseHeaders(resp http.ResponseWriter, h *headers) {
+ if h.ContentType != "" {
+ resp.Header().Set("Content-Type", h.ContentType)
+ }
+ if h.Digest != "" {
+ resp.Header().Set("Digest", "sha256="+h.Digest)
+ }
+ if h.Location != "" {
+ resp.Header().Set("Location", h.Location)
+ }
+ if h.Link != "" {
+ resp.Header().Set("Link", h.Link)
+ }
+ resp.Header().Set("Content-Version", "1")
+ if h.Status != 0 {
+ resp.WriteHeader(h.Status)
+ }
+}
+
+// https://github.com/apple/swift-package-manager/blob/main/Documentation/Registry.md#33-error-handling
+func apiError(ctx *context.Context, status int, obj any) {
+ // https://www.rfc-editor.org/rfc/rfc7807
+ type Problem struct {
+ Status int `json:"status"`
+ Detail string `json:"detail"`
+ }
+
+ helper.LogAndProcessError(ctx, status, obj, func(message string) {
+ setResponseHeaders(ctx.Resp, &headers{
+ Status: status,
+ ContentType: "application/problem+json",
+ })
+ if err := json.NewEncoder(ctx.Resp).Encode(Problem{
+ Status: status,
+ Detail: message,
+ }); err != nil {
+ log.Error("JSON encode: %v", err)
+ }
+ })
+}
+
+// https://github.com/apple/swift-package-manager/blob/main/Documentation/Registry.md#35-api-versioning
+func CheckAcceptMediaType(requiredAcceptHeader string) func(ctx *context.Context) {
+ return func(ctx *context.Context) {
+ accept := ctx.Req.Header.Get("Accept")
+ if accept != "" && accept != requiredAcceptHeader {
+ apiError(ctx, http.StatusBadRequest, fmt.Sprintf("Unexpected accept header. Should be '%s'.", requiredAcceptHeader))
+ }
+ }
+}
+
+func buildPackageID(scope, name string) string {
+ return scope + "." + name
+}
+
+type Release struct {
+ URL string `json:"url"`
+}
+
+type EnumeratePackageVersionsResponse struct {
+ Releases map[string]Release `json:"releases"`
+}
+
+// https://github.com/apple/swift-package-manager/blob/main/Documentation/Registry.md#41-list-package-releases
+func EnumeratePackageVersions(ctx *context.Context) {
+ packageScope := ctx.Params("scope")
+ packageName := ctx.Params("name")
+
+ pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeSwift, buildPackageID(packageScope, packageName))
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ if len(pvs) == 0 {
+ apiError(ctx, http.StatusNotFound, nil)
+ return
+ }
+
+ pds, err := packages_model.GetPackageDescriptors(ctx, pvs)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ sort.Slice(pds, func(i, j int) bool {
+ return pds[i].SemVer.LessThan(pds[j].SemVer)
+ })
+
+ baseURL := fmt.Sprintf("%sapi/packages/%s/swift/%s/%s/", setting.AppURL, ctx.Package.Owner.LowerName, packageScope, packageName)
+
+ releases := make(map[string]Release)
+ for _, pd := range pds {
+ version := pd.SemVer.String()
+ releases[version] = Release{
+ URL: baseURL + version,
+ }
+ }
+
+ setResponseHeaders(ctx.Resp, &headers{
+ Link: fmt.Sprintf(`<%s%s>; rel="latest-version"`, baseURL, pds[len(pds)-1].Version.Version),
+ })
+
+ ctx.JSON(http.StatusOK, EnumeratePackageVersionsResponse{
+ Releases: releases,
+ })
+}
+
+type Resource struct {
+ Name string `json:"name"`
+ Type string `json:"type"`
+ Checksum string `json:"checksum"`
+}
+
+type PackageVersionMetadataResponse struct {
+ ID string `json:"id"`
+ Version string `json:"version"`
+ Resources []Resource `json:"resources"`
+ Metadata *swift_module.SoftwareSourceCode `json:"metadata"`
+}
+
+// https://github.com/apple/swift-package-manager/blob/main/Documentation/Registry.md#endpoint-2
+func PackageVersionMetadata(ctx *context.Context) {
+ id := buildPackageID(ctx.Params("scope"), ctx.Params("name"))
+
+ pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeSwift, id, ctx.Params("version"))
+ if err != nil {
+ if errors.Is(err, util.ErrNotExist) {
+ apiError(ctx, http.StatusNotFound, err)
+ } else {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ pd, err := packages_model.GetPackageDescriptor(ctx, pv)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ metadata := pd.Metadata.(*swift_module.Metadata)
+
+ setResponseHeaders(ctx.Resp, &headers{})
+
+ ctx.JSON(http.StatusOK, PackageVersionMetadataResponse{
+ ID: id,
+ Version: pd.Version.Version,
+ Resources: []Resource{
+ {
+ Name: "source-archive",
+ Type: "application/zip",
+ Checksum: pd.Files[0].Blob.HashSHA256,
+ },
+ },
+ Metadata: &swift_module.SoftwareSourceCode{
+ Context: []string{"http://schema.org/"},
+ Type: "SoftwareSourceCode",
+ Name: pd.PackageProperties.GetByName(swift_module.PropertyName),
+ Version: pd.Version.Version,
+ Description: metadata.Description,
+ Keywords: metadata.Keywords,
+ CodeRepository: metadata.RepositoryURL,
+ License: metadata.License,
+ ProgrammingLanguage: swift_module.ProgrammingLanguage{
+ Type: "ComputerLanguage",
+ Name: "Swift",
+ URL: "https://swift.org",
+ },
+ Author: swift_module.Person{
+ Type: "Person",
+ GivenName: metadata.Author.GivenName,
+ MiddleName: metadata.Author.MiddleName,
+ FamilyName: metadata.Author.FamilyName,
+ },
+ },
+ })
+}
+
+// https://github.com/apple/swift-package-manager/blob/main/Documentation/Registry.md#43-fetch-manifest-for-a-package-release
+func DownloadManifest(ctx *context.Context) {
+ packageScope := ctx.Params("scope")
+ packageName := ctx.Params("name")
+ packageVersion := ctx.Params("version")
+
+ pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeSwift, buildPackageID(packageScope, packageName), packageVersion)
+ if err != nil {
+ if errors.Is(err, util.ErrNotExist) {
+ apiError(ctx, http.StatusNotFound, err)
+ } else {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ pd, err := packages_model.GetPackageDescriptor(ctx, pv)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ swiftVersion := ctx.FormTrim("swift-version")
+ if swiftVersion != "" {
+ v, err := version.NewVersion(swiftVersion)
+ if err == nil {
+ swiftVersion = swift_module.TrimmedVersionString(v)
+ }
+ }
+ m, ok := pd.Metadata.(*swift_module.Metadata).Manifests[swiftVersion]
+ if !ok {
+ setResponseHeaders(ctx.Resp, &headers{
+ Status: http.StatusSeeOther,
+ Location: fmt.Sprintf("%sapi/packages/%s/swift/%s/%s/%s/Package.swift", setting.AppURL, ctx.Package.Owner.LowerName, packageScope, packageName, packageVersion),
+ })
+ return
+ }
+
+ setResponseHeaders(ctx.Resp, &headers{})
+
+ filename := "Package.swift"
+ if swiftVersion != "" {
+ filename = fmt.Sprintf("Package@swift-%s.swift", swiftVersion)
+ }
+
+ ctx.ServeContent(strings.NewReader(m.Content), &context.ServeHeaderOptions{
+ ContentType: "text/x-swift",
+ Filename: filename,
+ LastModified: pv.CreatedUnix.AsLocalTime(),
+ })
+}
+
+// https://github.com/apple/swift-package-manager/blob/main/Documentation/Registry.md#endpoint-6
+func UploadPackageFile(ctx *context.Context) {
+ packageScope := ctx.Params("scope")
+ packageName := ctx.Params("name")
+
+ v, err := version.NewVersion(ctx.Params("version"))
+
+ if !scopePattern.MatchString(packageScope) || !namePattern.MatchString(packageName) || err != nil {
+ apiError(ctx, http.StatusBadRequest, err)
+ return
+ }
+
+ packageVersion := v.Core().String()
+
+ file, _, err := ctx.Req.FormFile("source-archive")
+ if err != nil {
+ apiError(ctx, http.StatusBadRequest, err)
+ return
+ }
+ defer file.Close()
+
+ buf, err := packages_module.CreateHashedBufferFromReader(file)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ defer buf.Close()
+
+ var mr io.Reader
+ metadata := ctx.Req.FormValue("metadata")
+ if metadata != "" {
+ mr = strings.NewReader(metadata)
+ }
+
+ pck, err := swift_module.ParsePackage(buf, buf.Size(), mr)
+ if err != nil {
+ if errors.Is(err, util.ErrInvalidArgument) {
+ apiError(ctx, http.StatusBadRequest, err)
+ } else {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ if _, err := buf.Seek(0, io.SeekStart); err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ pv, _, err := packages_service.CreatePackageAndAddFile(
+ ctx,
+ &packages_service.PackageCreationInfo{
+ PackageInfo: packages_service.PackageInfo{
+ Owner: ctx.Package.Owner,
+ PackageType: packages_model.TypeSwift,
+ Name: buildPackageID(packageScope, packageName),
+ Version: packageVersion,
+ },
+ SemverCompatible: true,
+ Creator: ctx.Doer,
+ Metadata: pck.Metadata,
+ PackageProperties: map[string]string{
+ swift_module.PropertyScope: packageScope,
+ swift_module.PropertyName: packageName,
+ },
+ },
+ &packages_service.PackageFileCreationInfo{
+ PackageFileInfo: packages_service.PackageFileInfo{
+ Filename: fmt.Sprintf("%s-%s.zip", packageName, packageVersion),
+ },
+ Creator: ctx.Doer,
+ Data: buf,
+ IsLead: true,
+ },
+ )
+ if err != nil {
+ switch err {
+ case packages_model.ErrDuplicatePackageVersion:
+ apiError(ctx, http.StatusConflict, err)
+ case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize:
+ apiError(ctx, http.StatusForbidden, err)
+ default:
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ for _, url := range pck.RepositoryURLs {
+ _, err = packages_model.InsertProperty(ctx, packages_model.PropertyTypeVersion, pv.ID, swift_module.PropertyRepositoryURL, url)
+ if err != nil {
+ log.Error("InsertProperty failed: %v", err)
+ }
+ }
+
+ setResponseHeaders(ctx.Resp, &headers{})
+
+ ctx.Status(http.StatusCreated)
+}
+
+// https://github.com/apple/swift-package-manager/blob/main/Documentation/Registry.md#endpoint-4
+func DownloadPackageFile(ctx *context.Context) {
+ pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeSwift, buildPackageID(ctx.Params("scope"), ctx.Params("name")), ctx.Params("version"))
+ if err != nil {
+ if errors.Is(err, util.ErrNotExist) {
+ apiError(ctx, http.StatusNotFound, err)
+ } else {
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ pd, err := packages_model.GetPackageDescriptor(ctx, pv)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ pf := pd.Files[0].File
+
+ s, u, _, err := packages_service.GetPackageFileStream(ctx, pf)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ setResponseHeaders(ctx.Resp, &headers{
+ Digest: pd.Files[0].Blob.HashSHA256,
+ })
+
+ helper.ServePackageFile(ctx, s, u, pf, &context.ServeHeaderOptions{
+ Filename: pf.Name,
+ ContentType: "application/zip",
+ LastModified: pf.CreatedUnix.AsLocalTime(),
+ })
+}
+
+type LookupPackageIdentifiersResponse struct {
+ Identifiers []string `json:"identifiers"`
+}
+
+// https://github.com/apple/swift-package-manager/blob/main/Documentation/Registry.md#endpoint-5
+func LookupPackageIdentifiers(ctx *context.Context) {
+ url := ctx.FormTrim("url")
+ if url == "" {
+ apiError(ctx, http.StatusBadRequest, nil)
+ return
+ }
+
+ pvs, _, err := packages_model.SearchLatestVersions(ctx, &packages_model.PackageSearchOptions{
+ OwnerID: ctx.Package.Owner.ID,
+ Type: packages_model.TypeSwift,
+ Properties: map[string]string{
+ swift_module.PropertyRepositoryURL: url,
+ },
+ IsInternal: optional.Some(false),
+ })
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ if len(pvs) == 0 {
+ apiError(ctx, http.StatusNotFound, nil)
+ return
+ }
+
+ pds, err := packages_model.GetPackageDescriptors(ctx, pvs)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ identifiers := make([]string, 0, len(pds))
+ for _, pd := range pds {
+ identifiers = append(identifiers, pd.Package.Name)
+ }
+
+ setResponseHeaders(ctx.Resp, &headers{})
+
+ ctx.JSON(http.StatusOK, LookupPackageIdentifiersResponse{
+ Identifiers: identifiers,
+ })
+}
diff --git a/routers/api/packages/vagrant/vagrant.go b/routers/api/packages/vagrant/vagrant.go
new file mode 100644
index 0000000..98a81da
--- /dev/null
+++ b/routers/api/packages/vagrant/vagrant.go
@@ -0,0 +1,242 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package vagrant
+
+import (
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "sort"
+ "strings"
+
+ packages_model "code.gitea.io/gitea/models/packages"
+ packages_module "code.gitea.io/gitea/modules/packages"
+ vagrant_module "code.gitea.io/gitea/modules/packages/vagrant"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/routers/api/packages/helper"
+ "code.gitea.io/gitea/services/context"
+ packages_service "code.gitea.io/gitea/services/packages"
+
+ "github.com/hashicorp/go-version"
+)
+
+func apiError(ctx *context.Context, status int, obj any) {
+ helper.LogAndProcessError(ctx, status, obj, func(message string) {
+ ctx.JSON(status, struct {
+ Errors []string `json:"errors"`
+ }{
+ Errors: []string{
+ message,
+ },
+ })
+ })
+}
+
+func CheckAuthenticate(ctx *context.Context) {
+ if ctx.Doer == nil {
+ apiError(ctx, http.StatusUnauthorized, "Invalid access token")
+ return
+ }
+
+ ctx.Status(http.StatusOK)
+}
+
+func CheckBoxAvailable(ctx *context.Context) {
+ pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeVagrant, ctx.Params("name"))
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ if len(pvs) == 0 {
+ apiError(ctx, http.StatusNotFound, err)
+ return
+ }
+
+ ctx.JSON(http.StatusOK, nil) // needs to be Content-Type: application/json
+}
+
+type packageMetadata struct {
+ Name string `json:"name"`
+ Description string `json:"description,omitempty"`
+ ShortDescription string `json:"short_description,omitempty"`
+ Versions []*versionMetadata `json:"versions"`
+}
+
+type versionMetadata struct {
+ Version string `json:"version"`
+ Status string `json:"status"`
+ DescriptionHTML string `json:"description_html,omitempty"`
+ DescriptionMarkdown string `json:"description_markdown,omitempty"`
+ Providers []*providerData `json:"providers"`
+}
+
+type providerData struct {
+ Name string `json:"name"`
+ URL string `json:"url"`
+ Checksum string `json:"checksum"`
+ ChecksumType string `json:"checksum_type"`
+}
+
+func packageDescriptorToMetadata(baseURL string, pd *packages_model.PackageDescriptor) *versionMetadata {
+ versionURL := baseURL + "/" + url.PathEscape(pd.Version.Version)
+
+ providers := make([]*providerData, 0, len(pd.Files))
+
+ for _, f := range pd.Files {
+ providers = append(providers, &providerData{
+ Name: f.Properties.GetByName(vagrant_module.PropertyProvider),
+ URL: versionURL + "/" + url.PathEscape(f.File.Name),
+ Checksum: f.Blob.HashSHA512,
+ ChecksumType: "sha512",
+ })
+ }
+
+ return &versionMetadata{
+ Status: "active",
+ Version: pd.Version.Version,
+ Providers: providers,
+ }
+}
+
+func EnumeratePackageVersions(ctx *context.Context) {
+ pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeVagrant, ctx.Params("name"))
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ if len(pvs) == 0 {
+ apiError(ctx, http.StatusNotFound, err)
+ return
+ }
+
+ pds, err := packages_model.GetPackageDescriptors(ctx, pvs)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ sort.Slice(pds, func(i, j int) bool {
+ return pds[i].SemVer.LessThan(pds[j].SemVer)
+ })
+
+ baseURL := fmt.Sprintf("%sapi/packages/%s/vagrant/%s", setting.AppURL, url.PathEscape(ctx.Package.Owner.Name), url.PathEscape(pds[0].Package.Name))
+
+ versions := make([]*versionMetadata, 0, len(pds))
+ for _, pd := range pds {
+ versions = append(versions, packageDescriptorToMetadata(baseURL, pd))
+ }
+
+ ctx.JSON(http.StatusOK, &packageMetadata{
+ Name: pds[0].Package.Name,
+ Description: pds[len(pds)-1].Metadata.(*vagrant_module.Metadata).Description,
+ Versions: versions,
+ })
+}
+
+func UploadPackageFile(ctx *context.Context) {
+ boxName := ctx.Params("name")
+ boxVersion := ctx.Params("version")
+ _, err := version.NewSemver(boxVersion)
+ if err != nil {
+ apiError(ctx, http.StatusBadRequest, err)
+ return
+ }
+ boxProvider := ctx.Params("provider")
+ if !strings.HasSuffix(boxProvider, ".box") {
+ apiError(ctx, http.StatusBadRequest, err)
+ return
+ }
+
+ upload, needsClose, err := ctx.UploadStream()
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ if needsClose {
+ defer upload.Close()
+ }
+
+ buf, err := packages_module.CreateHashedBufferFromReader(upload)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+ defer buf.Close()
+
+ metadata, err := vagrant_module.ParseMetadataFromBox(buf)
+ if err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ if _, err := buf.Seek(0, io.SeekStart); err != nil {
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ _, _, err = packages_service.CreatePackageOrAddFileToExisting(
+ ctx,
+ &packages_service.PackageCreationInfo{
+ PackageInfo: packages_service.PackageInfo{
+ Owner: ctx.Package.Owner,
+ PackageType: packages_model.TypeVagrant,
+ Name: boxName,
+ Version: boxVersion,
+ },
+ SemverCompatible: true,
+ Creator: ctx.Doer,
+ Metadata: metadata,
+ },
+ &packages_service.PackageFileCreationInfo{
+ PackageFileInfo: packages_service.PackageFileInfo{
+ Filename: strings.ToLower(boxProvider),
+ },
+ Creator: ctx.Doer,
+ Data: buf,
+ IsLead: true,
+ Properties: map[string]string{
+ vagrant_module.PropertyProvider: strings.TrimSuffix(boxProvider, ".box"),
+ },
+ },
+ )
+ if err != nil {
+ switch err {
+ case packages_model.ErrDuplicatePackageFile:
+ apiError(ctx, http.StatusConflict, err)
+ case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize:
+ apiError(ctx, http.StatusForbidden, err)
+ default:
+ apiError(ctx, http.StatusInternalServerError, err)
+ }
+ return
+ }
+
+ ctx.Status(http.StatusCreated)
+}
+
+func DownloadPackageFile(ctx *context.Context) {
+ s, u, pf, err := packages_service.GetFileStreamByPackageNameAndVersion(
+ ctx,
+ &packages_service.PackageInfo{
+ Owner: ctx.Package.Owner,
+ PackageType: packages_model.TypeVagrant,
+ Name: ctx.Params("name"),
+ Version: ctx.Params("version"),
+ },
+ &packages_service.PackageFileInfo{
+ Filename: ctx.Params("provider"),
+ },
+ )
+ if err != nil {
+ if err == packages_model.ErrPackageNotExist || err == packages_model.ErrPackageFileNotExist {
+ apiError(ctx, http.StatusNotFound, err)
+ return
+ }
+ apiError(ctx, http.StatusInternalServerError, err)
+ return
+ }
+
+ helper.ServePackageFile(ctx, s, u, pf)
+}