From dd136858f1ea40ad3c94191d647487fa4f31926c Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Fri, 18 Oct 2024 20:33:49 +0200 Subject: Adding upstream version 9.0.0. Signed-off-by: Daniel Baumann --- routers/api/packages/README.md | 50 ++ routers/api/packages/alpine/alpine.go | 287 +++++++++ routers/api/packages/api.go | 916 +++++++++++++++++++++++++++ routers/api/packages/arch/arch.go | 269 ++++++++ routers/api/packages/cargo/cargo.go | 311 +++++++++ routers/api/packages/chef/auth.go | 274 ++++++++ routers/api/packages/chef/chef.go | 403 ++++++++++++ routers/api/packages/composer/api.go | 117 ++++ routers/api/packages/composer/composer.go | 261 ++++++++ routers/api/packages/conan/auth.go | 48 ++ routers/api/packages/conan/conan.go | 807 +++++++++++++++++++++++ routers/api/packages/conan/search.go | 163 +++++ routers/api/packages/conda/conda.go | 303 +++++++++ routers/api/packages/container/auth.go | 49 ++ routers/api/packages/container/blob.go | 202 ++++++ routers/api/packages/container/container.go | 785 +++++++++++++++++++++++ routers/api/packages/container/errors.go | 52 ++ routers/api/packages/container/manifest.go | 483 ++++++++++++++ routers/api/packages/cran/cran.go | 264 ++++++++ routers/api/packages/debian/debian.go | 309 +++++++++ routers/api/packages/generic/generic.go | 212 +++++++ routers/api/packages/generic/generic_test.go | 65 ++ routers/api/packages/goproxy/goproxy.go | 224 +++++++ routers/api/packages/helm/helm.go | 217 +++++++ routers/api/packages/helper/helper.go | 63 ++ routers/api/packages/maven/api.go | 50 ++ routers/api/packages/maven/maven.go | 433 +++++++++++++ routers/api/packages/npm/api.go | 114 ++++ routers/api/packages/npm/npm.go | 462 ++++++++++++++ routers/api/packages/nuget/api_v2.go | 402 ++++++++++++ routers/api/packages/nuget/api_v3.go | 255 ++++++++ routers/api/packages/nuget/auth.go | 47 ++ routers/api/packages/nuget/links.go | 52 ++ routers/api/packages/nuget/nuget.go | 710 +++++++++++++++++++++ routers/api/packages/pub/pub.go | 284 +++++++++ routers/api/packages/pypi/pypi.go | 194 ++++++ routers/api/packages/pypi/pypi_test.go | 38 ++ routers/api/packages/rpm/rpm.go | 318 ++++++++++ routers/api/packages/rubygems/rubygems.go | 451 +++++++++++++ routers/api/packages/swift/swift.go | 465 ++++++++++++++ routers/api/packages/vagrant/vagrant.go | 242 +++++++ 41 files changed, 11651 insertions(+) create mode 100644 routers/api/packages/README.md create mode 100644 routers/api/packages/alpine/alpine.go create mode 100644 routers/api/packages/api.go create mode 100644 routers/api/packages/arch/arch.go create mode 100644 routers/api/packages/cargo/cargo.go create mode 100644 routers/api/packages/chef/auth.go create mode 100644 routers/api/packages/chef/chef.go create mode 100644 routers/api/packages/composer/api.go create mode 100644 routers/api/packages/composer/composer.go create mode 100644 routers/api/packages/conan/auth.go create mode 100644 routers/api/packages/conan/conan.go create mode 100644 routers/api/packages/conan/search.go create mode 100644 routers/api/packages/conda/conda.go create mode 100644 routers/api/packages/container/auth.go create mode 100644 routers/api/packages/container/blob.go create mode 100644 routers/api/packages/container/container.go create mode 100644 routers/api/packages/container/errors.go create mode 100644 routers/api/packages/container/manifest.go create mode 100644 routers/api/packages/cran/cran.go create mode 100644 routers/api/packages/debian/debian.go create mode 100644 routers/api/packages/generic/generic.go create mode 100644 routers/api/packages/generic/generic_test.go create mode 100644 routers/api/packages/goproxy/goproxy.go create mode 100644 routers/api/packages/helm/helm.go create mode 100644 routers/api/packages/helper/helper.go create mode 100644 routers/api/packages/maven/api.go create mode 100644 routers/api/packages/maven/maven.go create mode 100644 routers/api/packages/npm/api.go create mode 100644 routers/api/packages/npm/npm.go create mode 100644 routers/api/packages/nuget/api_v2.go create mode 100644 routers/api/packages/nuget/api_v3.go create mode 100644 routers/api/packages/nuget/auth.go create mode 100644 routers/api/packages/nuget/links.go create mode 100644 routers/api/packages/nuget/nuget.go create mode 100644 routers/api/packages/pub/pub.go create mode 100644 routers/api/packages/pypi/pypi.go create mode 100644 routers/api/packages/pypi/pypi_test.go create mode 100644 routers/api/packages/rpm/rpm.go create mode 100644 routers/api/packages/rubygems/rubygems.go create mode 100644 routers/api/packages/swift/swift.go create mode 100644 routers/api/packages/vagrant/vagrant.go (limited to 'routers/api/packages') 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/` | 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/` | 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/` | 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/` | 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/` 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]) + + // /@v/list + if parts[1] == "list" { + goproxy.EnumeratePackageVersions(ctx) + return + } + + // /@v/.zip + if strings.HasSuffix(parts[1], ".zip") { + ctx.SetParams("version", parts[1][:len(parts[1])-len(".zip")]) + + goproxy.DownloadPackageFile(ctx) + return + } + // /@v/.info + if strings.HasSuffix(parts[1], ".info") { + ctx.SetParams("version", parts[1][:len(parts[1])-len(".info")]) + + goproxy.PackageVersionMetadata(ctx) + return + } + // /@v/.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(`; 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(`; 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 //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 := `` + + 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) +} -- cgit v1.2.3