初始提交: Gitea 项目代码
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
# Gitea Package Registry
|
||||
|
||||
This document gives a brief overview how the package registry is organized in code.
|
||||
|
||||
## Structure
|
||||
|
||||
The package registry code is divided into multiple modules to split the functionality and make code reuse possible.
|
||||
|
||||
| Module | Description |
|
||||
| - | - |
|
||||
| `models/packages` | Common methods and models used by all registry types |
|
||||
| `models/packages/<type>` | Methods used by specific registry type. There should be no need to use type specific models. |
|
||||
| `modules/packages` | Common methods and types used by multiple registry types |
|
||||
| `modules/packages/<type>` | Registry type specific methods and types (e.g. metadata extraction of package files) |
|
||||
| `routers/api/packages` | Route definitions for all registry types |
|
||||
| `routers/api/packages/<type>` | Route implementation for a specific registry type |
|
||||
| `services/packages` | Helper methods used by registry types to handle common tasks like package creation and deletion in `routers` |
|
||||
| `services/packages/<type>` | Registry type specific methods used by `routers` and `services` |
|
||||
|
||||
## Models
|
||||
|
||||
Every package registry implementation uses the same underlying models:
|
||||
|
||||
| Model | Description |
|
||||
| - | - |
|
||||
| `Package` | The root of a package providing values fixed for every version (e.g. the package name) |
|
||||
| `PackageVersion` | A version of a package containing metadata (e.g. the package description) |
|
||||
| `PackageFile` | A file of a package describing its content (e.g. file name) |
|
||||
| `PackageBlob` | The content of a file (may be shared by multiple files) |
|
||||
| `PackageProperty` | Additional properties attached to `Package`, `PackageVersion` or `PackageFile` (e.g. used if metadata is needed for routing) |
|
||||
|
||||
The following diagram shows the relationship between the models:
|
||||
```
|
||||
Package <1---*> PackageVersion <1---*> PackageFile <*---1> PackageBlob
|
||||
```
|
||||
|
||||
## Adding a new package registry type
|
||||
|
||||
Before adding a new package registry type have a look at the existing implementation to get an impression of how it could work.
|
||||
Most registry types offer endpoints to retrieve the metadata, upload and download package files.
|
||||
The upload endpoint is often the heavy part because it must validate the uploaded blob, extract metadata and create the models.
|
||||
The methods to validate and extract the metadata should be added in the `modules/packages/<type>` package.
|
||||
If the upload is valid the methods in `services/packages` allow to store the upload and create the corresponding models.
|
||||
It depends if the registry type allows multiple files per package version which method should be called:
|
||||
- `CreatePackageAndAddFile`: error if package version already exists
|
||||
- `CreatePackageOrAddFileToExisting`: error if file already exists
|
||||
- `AddFileToExistingPackage`: error if package version does not exist or file already exists
|
||||
|
||||
`services/packages` also contains helper methods to download a file or to remove a package version.
|
||||
There are no helper methods for metadata endpoints because they are very type specific.
|
||||
@@ -0,0 +1,265 @@
|
||||
// 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 "gitea.dev/models/packages"
|
||||
"gitea.dev/modules/json"
|
||||
packages_module "gitea.dev/modules/packages"
|
||||
alpine_module "gitea.dev/modules/packages/alpine"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/routers/api/packages/helper"
|
||||
"gitea.dev/services/context"
|
||||
packages_service "gitea.dev/services/packages"
|
||||
alpine_service "gitea.dev/services/packages/alpine"
|
||||
)
|
||||
|
||||
func apiError(ctx *context.Context, status int, obj any) {
|
||||
message := helper.ProcessErrorForUser(ctx, status, obj)
|
||||
ctx.PlainText(status, message)
|
||||
}
|
||||
|
||||
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.OpenFileForDownloadByPackageVersion(
|
||||
ctx,
|
||||
pv,
|
||||
&packages_service.PackageFileInfo{
|
||||
Filename: alpine_service.IndexArchiveFilename,
|
||||
CompositeKey: fmt.Sprintf("%s|%s|%s", ctx.PathParam("branch"), ctx.PathParam("repository"), ctx.PathParam("architecture")),
|
||||
},
|
||||
ctx.Req.Method,
|
||||
)
|
||||
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.PathParam("branch"))
|
||||
repository := strings.TrimSpace(ctx.PathParam("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) || errors.Is(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
|
||||
}
|
||||
|
||||
_, _, 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, pck.FileMetadata.Architecture),
|
||||
},
|
||||
Creator: ctx.Doer,
|
||||
Data: buf,
|
||||
IsLead: true,
|
||||
Properties: map[string]string{
|
||||
alpine_module.PropertyBranch: branch,
|
||||
alpine_module.PropertyRepository: repository,
|
||||
alpine_module.PropertyArchitecture: pck.FileMetadata.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
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusCreated)
|
||||
}
|
||||
|
||||
func DownloadPackageFile(ctx *context.Context) {
|
||||
branch := ctx.PathParam("branch")
|
||||
repository := ctx.PathParam("repository")
|
||||
architecture := ctx.PathParam("architecture")
|
||||
|
||||
opts := &packages_model.PackageFileSearchOptions{
|
||||
OwnerID: ctx.Package.Owner.ID,
|
||||
PackageType: packages_model.TypeAlpine,
|
||||
Query: ctx.PathParam("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 {
|
||||
// Try again with architecture 'noarch'
|
||||
if architecture == alpine_module.NoArch {
|
||||
apiError(ctx, http.StatusNotFound, nil)
|
||||
return
|
||||
}
|
||||
|
||||
opts.CompositeKey = fmt.Sprintf("%s|%s|%s", branch, repository, alpine_module.NoArch)
|
||||
if pfs, _, err = packages_model.SearchFiles(ctx, opts); err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(pfs) == 0 {
|
||||
apiError(ctx, http.StatusNotFound, nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
s, u, pf, err := packages_service.OpenFileForDownload(ctx, pfs[0], ctx.Req.Method)
|
||||
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.PathParam("branch"), ctx.PathParam("repository"), ctx.PathParam("architecture")
|
||||
|
||||
pfs, _, err := packages_model.SearchFiles(ctx, &packages_model.PackageFileSearchOptions{
|
||||
OwnerID: ctx.Package.Owner.ID,
|
||||
PackageType: packages_model.TypeAlpine,
|
||||
Query: ctx.PathParam("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)
|
||||
}
|
||||
@@ -0,0 +1,604 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package packages
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
auth_model "gitea.dev/models/auth"
|
||||
"gitea.dev/models/perm"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/web"
|
||||
"gitea.dev/routers/api/packages/alpine"
|
||||
"gitea.dev/routers/api/packages/arch"
|
||||
"gitea.dev/routers/api/packages/cargo"
|
||||
"gitea.dev/routers/api/packages/chef"
|
||||
"gitea.dev/routers/api/packages/composer"
|
||||
"gitea.dev/routers/api/packages/conan"
|
||||
"gitea.dev/routers/api/packages/conda"
|
||||
"gitea.dev/routers/api/packages/container"
|
||||
"gitea.dev/routers/api/packages/cran"
|
||||
"gitea.dev/routers/api/packages/debian"
|
||||
"gitea.dev/routers/api/packages/generic"
|
||||
"gitea.dev/routers/api/packages/goproxy"
|
||||
"gitea.dev/routers/api/packages/helm"
|
||||
"gitea.dev/routers/api/packages/maven"
|
||||
"gitea.dev/routers/api/packages/npm"
|
||||
"gitea.dev/routers/api/packages/nuget"
|
||||
"gitea.dev/routers/api/packages/pub"
|
||||
"gitea.dev/routers/api/packages/pypi"
|
||||
"gitea.dev/routers/api/packages/rpm"
|
||||
"gitea.dev/routers/api/packages/rubygems"
|
||||
"gitea.dev/routers/api/packages/swift"
|
||||
"gitea.dev/routers/api/packages/terraform"
|
||||
"gitea.dev/routers/api/packages/vagrant"
|
||||
"gitea.dev/services/auth"
|
||||
"gitea.dev/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
|
||||
switch accessMode {
|
||||
case perm.AccessModeRead:
|
||||
scopeMatched, err = scope.HasScope(auth_model.AccessTokenScopeReadPackage)
|
||||
if err != nil {
|
||||
ctx.HTTPError(http.StatusInternalServerError, "HasScope", err.Error())
|
||||
return
|
||||
}
|
||||
case perm.AccessModeWrite:
|
||||
scopeMatched, err = scope.HasScope(auth_model.AccessTokenScopeWritePackage)
|
||||
if err != nil {
|
||||
ctx.HTTPError(http.StatusInternalServerError, "HasScope", err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
if !scopeMatched {
|
||||
ctx.Resp.Header().Set("WWW-Authenticate", `Basic realm="Gitea Package API"`)
|
||||
ctx.HTTPError(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.HTTPError(http.StatusForbidden, "tokenRequiresScope", "parsing public resource scope failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if publicOnly {
|
||||
if ctx.Package != nil && ctx.Package.Owner.Visibility.IsPrivate() {
|
||||
ctx.HTTPError(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.HTTPError(http.StatusUnauthorized, "reqPackageAccess", "user should have specific permission or be a site admin")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type verifyAuthOptions struct {
|
||||
afterAuthCallback func(ctx *context.Context, err error)
|
||||
}
|
||||
|
||||
func verifyAuth(r *web.Router, authMethods []auth.Method, opts verifyAuthOptions) {
|
||||
if setting.Service.EnableReverseProxyAuth {
|
||||
authMethods = append(authMethods, &auth.ReverseProxy{})
|
||||
}
|
||||
authGroup := auth.NewGroup(authMethods...)
|
||||
|
||||
r.AfterRouting(func(ctx *context.Context) {
|
||||
var err error
|
||||
ctx.Doer, err = authGroup.Verify(ctx.Req, ctx.Resp, ctx, ctx.Session)
|
||||
ctx.IsSigned = ctx.Doer != nil
|
||||
if opts.afterAuthCallback != nil {
|
||||
opts.afterAuthCallback(ctx, err)
|
||||
} else if err != nil {
|
||||
log.Error("Failed to verify user: %v", err)
|
||||
ctx.HTTPError(http.StatusUnauthorized, "Failed to authenticate user")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// CommonRoutes provide endpoints for most package managers (except containers - see below)
|
||||
// These are mounted on `/api/packages` (not `/api/v1/packages`)
|
||||
func CommonRoutes() *web.Router {
|
||||
r := web.NewRouter()
|
||||
|
||||
r.AfterRouting(context.PackageContexter())
|
||||
|
||||
verifyAuth(r, []auth.Method{
|
||||
&auth.OAuth2{},
|
||||
&auth.Basic{},
|
||||
&nuget.Auth{},
|
||||
&Auth{},
|
||||
&chef.Auth{},
|
||||
}, verifyAuthOptions{})
|
||||
|
||||
r.Group("/{username}", func() {
|
||||
r.Group("/alpine", func() {
|
||||
r.Get("/key", alpine.GetRepositoryKey)
|
||||
r.Group("/{branch}/{repository}", func() {
|
||||
r.Put("", reqPackageAccess(perm.AccessModeWrite), 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.Methods("HEAD,GET", "/repository.key", arch.GetRepositoryKey)
|
||||
r.Methods("PUT", "" /* no repository */, reqPackageAccess(perm.AccessModeWrite), arch.UploadPackageFile)
|
||||
r.PathGroup("/*", func(g *web.RouterPathGroup) {
|
||||
g.MatchPath("PUT", "/<repository:*>", reqPackageAccess(perm.AccessModeWrite), arch.UploadPackageFile)
|
||||
g.MatchPath("HEAD,GET", "/<repository:*>/<architecture>/<filename>", arch.GetPackageOrRepositoryFile)
|
||||
g.MatchPath("DELETE", "/<repository:*>/<name>/<version>/<architecture>", reqPackageAccess(perm.AccessModeWrite), arch.DeletePackageVersion)
|
||||
})
|
||||
}, reqPackageAccess(perm.AccessModeRead))
|
||||
r.Group("/cargo", func() {
|
||||
r.Group("/api/v1/crates", func() {
|
||||
r.Get("", cargo.SearchPackages)
|
||||
r.Put("/new", reqPackageAccess(perm.AccessModeWrite), 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), 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), 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), 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), 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), 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), conan.UploadRecipeFile)
|
||||
})
|
||||
r.Group("/package/{package_reference}/{package_revision}/{filename}", func() {
|
||||
r.Get("", conan.DownloadPackageFile)
|
||||
r.Put("", reqPackageAccess(perm.AccessModeWrite), 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), 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), conan.UploadPackageFile)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
}, conan.ExtractPathParameters)
|
||||
})
|
||||
})
|
||||
}, reqPackageAccess(perm.AccessModeRead))
|
||||
r.PathGroup("/conda/*", func(g *web.RouterPathGroup) {
|
||||
g.MatchPath("GET", "/<architecture>/<filename>", conda.ListOrGetPackages)
|
||||
g.MatchPath("GET", "/<channel:*>/<architecture>/<filename>", conda.ListOrGetPackages)
|
||||
g.MatchPath("PUT", "/<channel:*>/<filename>", reqPackageAccess(perm.AccessModeWrite), conda.UploadPackageFile)
|
||||
}, 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.Get("/Archive/{packagename}/{filename}", cran.DownloadSourcePackageFile)
|
||||
})
|
||||
r.Put("", reqPackageAccess(perm.AccessModeWrite), 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), 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", 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), goproxy.UploadPackage)
|
||||
r.Get("/sumdb/sum.golang.org/supported", http.NotFound)
|
||||
|
||||
// https://go.dev/ref/mod#goproxy-protocol
|
||||
r.PathGroup("/*", func(g *web.RouterPathGroup) {
|
||||
g.MatchPath("GET", "/<name:*>/@<version:latest>", goproxy.PackageVersionMetadata)
|
||||
g.MatchPath("GET", "/<name:*>/@v/list", goproxy.EnumeratePackageVersions)
|
||||
g.MatchPath("GET", "/<name:*>/@v/<version>.zip", goproxy.DownloadPackageFile)
|
||||
g.MatchPath("GET", "/<name:*>/@v/<version>.info", goproxy.PackageVersionMetadata)
|
||||
g.MatchPath("GET", "/<name:*>/@v/<version>.mod", goproxy.PackageVersionGoModContent)
|
||||
})
|
||||
}, reqPackageAccess(perm.AccessModeRead))
|
||||
r.Group("/generic", func() {
|
||||
r.Group("/{packagename}/{packageversion}", func() {
|
||||
r.Delete("", reqPackageAccess(perm.AccessModeWrite), generic.DeletePackage)
|
||||
r.Group("/{filename}", func() {
|
||||
r.Methods("HEAD,GET", "", generic.DownloadPackageFile)
|
||||
r.Group("", func() {
|
||||
r.Put("", 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), helm.UploadPackage)
|
||||
}, reqPackageAccess(perm.AccessModeRead))
|
||||
r.Group("/maven", func() {
|
||||
r.Put("/*", reqPackageAccess(perm.AccessModeWrite), 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("/", nuget.UploadPackage)
|
||||
r.Put("/symbolpackage", 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), 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), 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", 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), pypi.UploadPackageFile)
|
||||
r.Get("/files/{id}/{version}/{filename}", pypi.DownloadPackageFile)
|
||||
r.Get("/simple/{id}", pypi.PackageMetadata)
|
||||
}, reqPackageAccess(perm.AccessModeRead))
|
||||
|
||||
r.Methods("HEAD,GET", "/rpm.repo", reqPackageAccess(perm.AccessModeRead), rpm.GetRepositoryConfig)
|
||||
r.PathGroup("/rpm/*", func(g *web.RouterPathGroup) {
|
||||
g.MatchPath("HEAD,GET", "/repository.key", rpm.GetRepositoryKey)
|
||||
g.MatchPath("HEAD,GET", "/<group:*>.repo", rpm.GetRepositoryConfig)
|
||||
g.MatchPath("HEAD", "/<group:*>/repodata/<filename>", rpm.CheckRepositoryFileExistence)
|
||||
g.MatchPath("GET", "/<group:*>/repodata/<filename>", rpm.GetRepositoryFile)
|
||||
g.MatchPath("PUT", "/<group:*>/upload", reqPackageAccess(perm.AccessModeWrite), rpm.UploadPackageFile)
|
||||
g.MatchPath("POST", "/<group:*>/package/<name>/<version>/errata", reqPackageAccess(perm.AccessModeWrite), rpm.UploadErrata)
|
||||
// this URL pattern is only used internally in the RPM index, it is generated by us, the filename part is not really used (can be anything)
|
||||
g.MatchPath("HEAD,GET", "/<group:*>/package/<name>/<version>/<architecture>/<filename>", rpm.DownloadPackageFile)
|
||||
g.MatchPath("HEAD,GET", "/<group:*>/package/<name>/<version>/<architecture>", rpm.DownloadPackageFile)
|
||||
g.MatchPath("DELETE", "/<group:*>/package/<name>/<version>/<architecture>", reqPackageAccess(perm.AccessModeWrite), rpm.DeletePackageFile)
|
||||
}, 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("/quick/Marshal.4.8/{filename}", rubygems.ServePackageSpecification)
|
||||
r.Get("/gems/{filename}", rubygems.DownloadPackageFile)
|
||||
r.Get("/info/{packagename}", rubygems.GetPackageInfo)
|
||||
r.Get("/versions", rubygems.GetAllPackagesVersions)
|
||||
r.Group("/api/v1/gems", func() {
|
||||
r.Post("/", rubygems.UploadPackageFile)
|
||||
r.Delete("/yank", rubygems.DeletePackage)
|
||||
}, reqPackageAccess(perm.AccessModeWrite))
|
||||
}, reqPackageAccess(perm.AccessModeRead))
|
||||
|
||||
r.Group("/swift", func() {
|
||||
r.Group("", func() { // Needs to be unauthenticated.
|
||||
r.Post("", swift.CheckAuthenticate)
|
||||
r.Post("/login", swift.CheckAuthenticate)
|
||||
})
|
||||
r.Group("", func() {
|
||||
r.Group("/{scope}/{name}", func() {
|
||||
r.Group("", func() {
|
||||
r.Get("", swift.EnumeratePackageVersions)
|
||||
r.Get(".json", swift.EnumeratePackageVersions)
|
||||
}, swift.CheckAcceptMediaType(swift.AcceptJSON))
|
||||
r.PathGroup("/*", func(g *web.RouterPathGroup) {
|
||||
g.MatchPath("GET", "/<version>.json", swift.CheckAcceptMediaType(swift.AcceptJSON), swift.PackageVersionMetadata)
|
||||
g.MatchPath("GET", "/<version>.zip", swift.CheckAcceptMediaType(swift.AcceptZip), swift.DownloadPackageFile)
|
||||
g.MatchPath("GET", "/<version>/Package.swift", swift.CheckAcceptMediaType(swift.AcceptSwift), swift.DownloadManifest)
|
||||
g.MatchPath("GET", "/<version>", swift.CheckAcceptMediaType(swift.AcceptJSON), swift.PackageVersionMetadata)
|
||||
g.MatchPath("PUT", "/<version>", reqPackageAccess(perm.AccessModeWrite), swift.CheckAcceptMediaType(swift.AcceptJSON), swift.UploadPackageFile)
|
||||
})
|
||||
})
|
||||
r.Get("/identifiers", swift.CheckAcceptMediaType(swift.AcceptJSON), swift.LookupPackageIdentifiers)
|
||||
}, reqPackageAccess(perm.AccessModeRead))
|
||||
})
|
||||
// See https://docs.gitlab.com/ci/jobs/fine_grained_permissions/#terraform-state-endpoints
|
||||
// For endpoint and permission reference
|
||||
r.Group("/terraform/state/{name}", func() {
|
||||
r.Get("", terraform.GetTerraformState)
|
||||
r.Get("/versions/{serial}", terraform.GetTerraformStateBySerial)
|
||||
r.Group("", func() {
|
||||
r.Post("", terraform.UploadState)
|
||||
r.Delete("", terraform.DeleteState)
|
||||
r.Delete("/versions/{serial}", terraform.DeleteStateBySerial)
|
||||
}, reqPackageAccess(perm.AccessModeWrite))
|
||||
r.Group("/lock", func() {
|
||||
r.Post("", terraform.LockState)
|
||||
r.Delete("", terraform.UnlockState)
|
||||
}, reqPackageAccess(perm.AccessModeWrite))
|
||||
}, 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), 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.Router {
|
||||
r := web.NewRouter()
|
||||
|
||||
r.AfterRouting(context.PackageContexter())
|
||||
|
||||
verifyAuth(r, []auth.Method{
|
||||
&auth.Basic{},
|
||||
// container auth requires token, so container.Authenticate issues a Ghost user token for anonymous access
|
||||
&Auth{AllowGhostUser: true},
|
||||
}, verifyAuthOptions{
|
||||
afterAuthCallback: func(ctx *context.Context, err error) {
|
||||
if err != nil {
|
||||
log.Error("Failed to verify container user: %v", err)
|
||||
container.APIUnauthorizedError(ctx)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// TODO: Content Discovery / References (not implemented yet)
|
||||
|
||||
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.PathGroup("/*", func(g *web.RouterPathGroup) {
|
||||
g.MatchPath("POST", "/<image:*>/blobs/uploads", reqPackageAccess(perm.AccessModeWrite), container.VerifyImageName, container.PostBlobsUploads)
|
||||
g.MatchPath("GET", "/<image:*>/tags/list", container.VerifyImageName, container.GetTagsList)
|
||||
|
||||
patternBlobsUploadsUUID := g.PatternRegexp(`/<image:*>/blobs/uploads/<uuid:[-.=\w]+>`, reqPackageAccess(perm.AccessModeWrite), container.VerifyImageName)
|
||||
g.MatchPattern("GET", patternBlobsUploadsUUID, container.GetBlobsUpload)
|
||||
g.MatchPattern("PATCH", patternBlobsUploadsUUID, container.PatchBlobsUpload)
|
||||
g.MatchPattern("PUT", patternBlobsUploadsUUID, container.PutBlobsUpload)
|
||||
g.MatchPattern("DELETE", patternBlobsUploadsUUID, container.DeleteBlobsUpload)
|
||||
|
||||
g.MatchPath("HEAD", `/<image:*>/blobs/<digest>`, container.VerifyImageName, container.HeadBlob)
|
||||
g.MatchPath("GET", `/<image:*>/blobs/<digest>`, container.VerifyImageName, container.GetBlob)
|
||||
g.MatchPath("DELETE", `/<image:*>/blobs/<digest>`, container.VerifyImageName, reqPackageAccess(perm.AccessModeWrite), container.DeleteBlob)
|
||||
|
||||
g.MatchPath("HEAD", `/<image:*>/manifests/<reference>`, container.VerifyImageName, container.HeadManifest)
|
||||
g.MatchPath("GET", `/<image:*>/manifests/<reference>`, container.VerifyImageName, container.GetManifest)
|
||||
g.MatchPath("PUT", `/<image:*>/manifests/<reference>`, container.VerifyImageName, reqPackageAccess(perm.AccessModeWrite), container.PutManifest)
|
||||
g.MatchPath("DELETE", `/<image:*>/manifests/<reference>`, container.VerifyImageName, reqPackageAccess(perm.AccessModeWrite), container.DeleteManifest)
|
||||
})
|
||||
}, container.ReqContainerAccess, context.UserAssignmentWeb(), context.PackageAssignment(), reqPackageAccess(perm.AccessModeRead))
|
||||
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package arch
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
packages_model "gitea.dev/models/packages"
|
||||
"gitea.dev/modules/json"
|
||||
packages_module "gitea.dev/modules/packages"
|
||||
arch_module "gitea.dev/modules/packages/arch"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/routers/api/packages/helper"
|
||||
"gitea.dev/services/context"
|
||||
packages_service "gitea.dev/services/packages"
|
||||
arch_service "gitea.dev/services/packages/arch"
|
||||
)
|
||||
|
||||
func apiError(ctx *context.Context, status int, obj any) {
|
||||
message := helper.ProcessErrorForUser(ctx, status, obj)
|
||||
ctx.PlainText(status, message)
|
||||
}
|
||||
|
||||
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",
|
||||
})
|
||||
}
|
||||
|
||||
func UploadPackageFile(ctx *context.Context) {
|
||||
repository := strings.TrimSpace(ctx.PathParam("repository"))
|
||||
|
||||
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 := arch_module.ParsePackage(buf)
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrInvalidArgument) || errors.Is(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
|
||||
}
|
||||
|
||||
signature, err := arch_service.SignData(ctx, ctx.Package.Owner.ID, 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
|
||||
}
|
||||
|
||||
release, err := arch_service.AcquireRegistryLock(ctx, ctx.Package.Owner.ID)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
defer release()
|
||||
|
||||
// Search for duplicates with different file compression
|
||||
has, err := packages_model.HasFiles(ctx, &packages_model.PackageFileSearchOptions{
|
||||
OwnerID: ctx.Package.Owner.ID,
|
||||
PackageType: packages_model.TypeArch,
|
||||
Query: fmt.Sprintf("%s-%s-%s.pkg.tar.%%", pck.Name, pck.Version, pck.FileMetadata.Architecture),
|
||||
Properties: map[string]string{
|
||||
arch_module.PropertyRepository: repository,
|
||||
arch_module.PropertyArchitecture: pck.FileMetadata.Architecture,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if has {
|
||||
apiError(ctx, http.StatusConflict, packages_model.ErrDuplicatePackageFile)
|
||||
return
|
||||
}
|
||||
|
||||
_, _, err = packages_service.CreatePackageOrAddFileToExisting(
|
||||
ctx,
|
||||
&packages_service.PackageCreationInfo{
|
||||
PackageInfo: packages_service.PackageInfo{
|
||||
Owner: ctx.Package.Owner,
|
||||
PackageType: packages_model.TypeArch,
|
||||
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.pkg.tar.%s", pck.Name, pck.Version, pck.FileMetadata.Architecture, pck.FileCompressionExtension),
|
||||
CompositeKey: fmt.Sprintf("%s|%s", repository, pck.FileMetadata.Architecture),
|
||||
},
|
||||
Creator: ctx.Doer,
|
||||
Data: buf,
|
||||
IsLead: true,
|
||||
Properties: map[string]string{
|
||||
arch_module.PropertyRepository: repository,
|
||||
arch_module.PropertyArchitecture: pck.FileMetadata.Architecture,
|
||||
arch_module.PropertyMetadata: string(fileMetadataRaw),
|
||||
arch_module.PropertySignature: base64.StdEncoding.EncodeToString(signature),
|
||||
},
|
||||
},
|
||||
)
|
||||
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 := arch_service.BuildSpecificRepositoryFiles(ctx, ctx.Package.Owner.ID, repository, pck.FileMetadata.Architecture); err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusCreated)
|
||||
}
|
||||
|
||||
func GetPackageOrRepositoryFile(ctx *context.Context) {
|
||||
repository := ctx.PathParam("repository")
|
||||
architecture := ctx.PathParam("architecture")
|
||||
filename := ctx.PathParam("filename")
|
||||
filenameOrig := filename
|
||||
|
||||
isSignature := strings.HasSuffix(filename, ".sig")
|
||||
if isSignature {
|
||||
filename = filename[:len(filename)-len(".sig")]
|
||||
}
|
||||
|
||||
opts := &packages_model.PackageFileSearchOptions{
|
||||
OwnerID: ctx.Package.Owner.ID,
|
||||
PackageType: packages_model.TypeArch,
|
||||
Query: filename,
|
||||
CompositeKey: fmt.Sprintf("%s|%s", repository, architecture),
|
||||
}
|
||||
|
||||
if strings.HasSuffix(filename, ".db.tar.gz") || strings.HasSuffix(filename, ".files.tar.gz") || strings.HasSuffix(filename, ".files") || strings.HasSuffix(filename, ".db") {
|
||||
// The requested filename is based on the user-defined repository name.
|
||||
// Normalize everything to "packages.db".
|
||||
opts.Query = arch_service.IndexArchiveFilename
|
||||
|
||||
pv, err := arch_service.GetOrCreateRepositoryVersion(ctx, ctx.Package.Owner.ID)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
opts.VersionID = pv.ID
|
||||
}
|
||||
|
||||
pfs, _, err := packages_model.SearchFiles(ctx, opts)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if len(pfs) == 0 {
|
||||
// Try again with architecture 'any'
|
||||
if architecture == arch_module.AnyArch {
|
||||
apiError(ctx, http.StatusNotFound, nil)
|
||||
return
|
||||
}
|
||||
|
||||
opts.CompositeKey = fmt.Sprintf("%s|%s", repository, arch_module.AnyArch)
|
||||
if pfs, _, err = packages_model.SearchFiles(ctx, opts); err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if len(pfs) != 1 {
|
||||
apiError(ctx, http.StatusNotFound, nil)
|
||||
return
|
||||
}
|
||||
|
||||
if isSignature {
|
||||
pfps, err := packages_model.GetPropertiesByName(ctx, packages_model.PropertyTypeFile, pfs[0].ID, arch_module.PropertySignature)
|
||||
if err != nil || len(pfps) == 0 {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := base64.StdEncoding.DecodeString(pfps[0].Value)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.ServeContent(bytes.NewReader(data), context.ServeHeaderOptions{
|
||||
Filename: filenameOrig,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
s, u, pf, err := packages_service.OpenFileForDownload(ctx, pfs[0], ctx.Req.Method)
|
||||
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 DeletePackageVersion(ctx *context.Context) {
|
||||
repository := ctx.PathParam("repository")
|
||||
architecture := ctx.PathParam("architecture")
|
||||
name := ctx.PathParam("name")
|
||||
version := ctx.PathParam("version")
|
||||
|
||||
release, err := arch_service.AcquireRegistryLock(ctx, ctx.Package.Owner.ID)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
defer release()
|
||||
|
||||
pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeArch, name, 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.SearchFiles(ctx, &packages_model.PackageFileSearchOptions{
|
||||
VersionID: pv.ID,
|
||||
CompositeKey: fmt.Sprintf("%s|%s", 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 := arch_service.BuildSpecificRepositoryFiles(ctx, ctx.Package.Owner.ID, repository, architecture); err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package packages
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/services/auth"
|
||||
"gitea.dev/services/packages"
|
||||
)
|
||||
|
||||
var _ auth.Method = &Auth{}
|
||||
|
||||
// Auth is for conan and container
|
||||
type Auth struct {
|
||||
AllowGhostUser bool
|
||||
}
|
||||
|
||||
func (a *Auth) Name() string {
|
||||
return "packages"
|
||||
}
|
||||
|
||||
// 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) {
|
||||
packageMeta, err := packages.ParseAuthorizationRequest(req)
|
||||
if err != nil {
|
||||
log.Trace("ParseAuthorizationToken: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if packageMeta == nil || packageMeta.UserID == 0 {
|
||||
return nil, nil //nolint:nilnil // the auth method is not applicable
|
||||
}
|
||||
|
||||
var u *user_model.User
|
||||
switch packageMeta.UserID {
|
||||
case user_model.GhostUserID:
|
||||
if !a.AllowGhostUser {
|
||||
return nil, nil //nolint:nilnil // the auth method is not applicable
|
||||
}
|
||||
u = user_model.NewGhostUser()
|
||||
case user_model.ActionsUserID:
|
||||
u = user_model.NewActionsUserWithTaskID(packageMeta.ActionsUserTaskID)
|
||||
default:
|
||||
u, err = user_model.GetUserByID(req.Context(), packageMeta.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if packageMeta.Scope != "" {
|
||||
store.GetData()["IsApiToken"] = true
|
||||
store.GetData()["ApiTokenScope"] = packageMeta.Scope
|
||||
}
|
||||
|
||||
return u, nil
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cargo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
packages_model "gitea.dev/models/packages"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/optional"
|
||||
packages_module "gitea.dev/modules/packages"
|
||||
cargo_module "gitea.dev/modules/packages/cargo"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/routers/api/packages/helper"
|
||||
"gitea.dev/services/context"
|
||||
"gitea.dev/services/convert"
|
||||
packages_service "gitea.dev/services/packages"
|
||||
cargo_service "gitea.dev/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) {
|
||||
message := helper.ProcessErrorForUser(ctx, status, obj)
|
||||
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.RequireSignInViewStrict || 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.PathParam("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 := max(ctx.FormInt("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.OpenFileForDownloadByPackageNameAndVersion(
|
||||
ctx,
|
||||
&packages_service.PackageInfo{
|
||||
Owner: ctx.Package.Owner,
|
||||
PackageType: packages_model.TypeCargo,
|
||||
Name: ctx.PathParam("package"),
|
||||
Version: ctx.PathParam("version"),
|
||||
},
|
||||
&packages_service.PackageFileInfo{
|
||||
Filename: strings.ToLower(fmt.Sprintf("%s-%s.crate", ctx.PathParam("package"), ctx.PathParam("version"))),
|
||||
},
|
||||
ctx.Req.Method,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageNotExist) || errors.Is(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.PathParam("package"), ctx.PathParam("version"))
|
||||
if err != nil {
|
||||
if errors.Is(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})
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
// 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"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"path"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
user_model "gitea.dev/models/user"
|
||||
chef_module "gitea.dev/modules/packages/chef"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/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 //nolint:nilnil // the auth method is not applicable
|
||||
}
|
||||
|
||||
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 //nolint:nilnil // the auth method is not applicable
|
||||
}
|
||||
|
||||
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 errors.New("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, errors.New("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 errors.New("could not verify signature")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
// 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"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
packages_model "gitea.dev/models/packages"
|
||||
"gitea.dev/modules/optional"
|
||||
packages_module "gitea.dev/modules/packages"
|
||||
chef_module "gitea.dev/modules/packages/chef"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/routers/api/packages/helper"
|
||||
"gitea.dev/services/context"
|
||||
packages_service "gitea.dev/services/packages"
|
||||
)
|
||||
|
||||
func apiError(ctx *context.Context, status int, obj any) {
|
||||
type Error struct {
|
||||
ErrorMessages []string `json:"error_messages"`
|
||||
}
|
||||
|
||||
message := helper.ProcessErrorForUser(ctx, status, obj)
|
||||
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.PathParam("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.PathParam("name")
|
||||
packageVersion := strings.ReplaceAll(ctx.PathParam("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 errors.Is(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.PathParam("name"), ctx.PathParam("version"))
|
||||
if err != nil {
|
||||
if errors.Is(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.OpenFileForDownload(ctx, pf, ctx.Req.Method)
|
||||
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.PathParam("name")
|
||||
packageVersion := ctx.PathParam("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 errors.Is(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.PathParam("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)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package composer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
packages_model "gitea.dev/models/packages"
|
||||
access_model "gitea.dev/models/perm/access"
|
||||
"gitea.dev/modules/log"
|
||||
composer_module "gitea.dev/modules/packages/composer"
|
||||
"gitea.dev/services/context"
|
||||
)
|
||||
|
||||
// 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
|
||||
// https://getcomposer.org/doc/05-repositories.md#package
|
||||
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"`
|
||||
Source Source `json:"source"`
|
||||
}
|
||||
|
||||
// Dist contains package download information
|
||||
type Dist struct {
|
||||
Type string `json:"type"`
|
||||
URL string `json:"url"`
|
||||
Checksum string `json:"shasum"`
|
||||
}
|
||||
|
||||
// Source contains package source information
|
||||
type Source struct {
|
||||
URL string `json:"url"`
|
||||
Type string `json:"type"`
|
||||
Reference string `json:"reference"`
|
||||
}
|
||||
|
||||
func createPackageMetadataResponse(ctx *context.Context, 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
|
||||
}
|
||||
}
|
||||
|
||||
pkg := 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,
|
||||
},
|
||||
}
|
||||
if pd.Repository != nil {
|
||||
permission, err := access_model.GetDoerRepoPermission(ctx, pd.Repository, ctx.Doer)
|
||||
if err != nil {
|
||||
log.Error("GetDoerRepoPermission[%d]: %v", pd.Repository.ID, err)
|
||||
} else if permission.HasAnyUnitAccessOrPublicAccess() {
|
||||
pkg.Source = Source{
|
||||
URL: pd.Repository.HTMLURL(),
|
||||
Type: "git",
|
||||
Reference: pd.Version.Version,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
versions = append(versions, &pkg)
|
||||
}
|
||||
|
||||
return &PackageMetadataResponse{
|
||||
Minified: "composer/2.0",
|
||||
Packages: map[string][]*PackageVersionMetadata{
|
||||
pds[0].Package.Name: versions,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package composer
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
packages_model "gitea.dev/models/packages"
|
||||
"gitea.dev/modules/optional"
|
||||
packages_module "gitea.dev/modules/packages"
|
||||
composer_module "gitea.dev/modules/packages/composer"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/routers/api/packages/helper"
|
||||
"gitea.dev/services/context"
|
||||
"gitea.dev/services/convert"
|
||||
packages_service "gitea.dev/services/packages"
|
||||
)
|
||||
|
||||
func apiError(ctx *context.Context, status int, obj any) {
|
||||
message := helper.ProcessErrorForUser(ctx, status, obj)
|
||||
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 := max(ctx.FormInt("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.PathParam("vendorname")
|
||||
projectName := ctx.PathParam("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(
|
||||
ctx,
|
||||
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.OpenFileForDownloadByPackageNameAndVersion(
|
||||
ctx,
|
||||
&packages_service.PackageInfo{
|
||||
Owner: ctx.Package.Owner,
|
||||
PackageType: packages_model.TypeComposer,
|
||||
Name: ctx.PathParam("package"),
|
||||
Version: ctx.PathParam("version"),
|
||||
},
|
||||
&packages_service.PackageFileInfo{
|
||||
Filename: ctx.PathParam("filename"),
|
||||
},
|
||||
ctx.Req.Method,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageNotExist) || errors.Is(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, ctx.FormTrim("version"))
|
||||
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 == "" {
|
||||
// the version should be either set in the "composer.json", or as a query parameter "?version=xxx"
|
||||
apiError(ctx, http.StatusBadRequest, composer_module.ErrInvalidVersion)
|
||||
return
|
||||
}
|
||||
|
||||
_, _, 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: cp.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)
|
||||
}
|
||||
@@ -0,0 +1,829 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package conan
|
||||
|
||||
import (
|
||||
std_ctx "context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
auth_model "gitea.dev/models/auth"
|
||||
"gitea.dev/models/db"
|
||||
packages_model "gitea.dev/models/packages"
|
||||
conan_model "gitea.dev/models/packages/conan"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/log"
|
||||
packages_module "gitea.dev/modules/packages"
|
||||
conan_module "gitea.dev/modules/packages/conan"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/routers/api/packages/helper"
|
||||
auth_service "gitea.dev/services/auth"
|
||||
"gitea.dev/services/context"
|
||||
notify_service "gitea.dev/services/notify"
|
||||
packages_service "gitea.dev/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)
|
||||
_ = json.NewEncoder(ctx.Resp).Encode(obj)
|
||||
}
|
||||
|
||||
func apiError(ctx *context.Context, status int, obj any) {
|
||||
message := helper.ProcessErrorForUser(ctx, status, obj)
|
||||
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.PathParam("name"),
|
||||
ctx.PathParam("version"),
|
||||
ctx.PathParam("user"),
|
||||
ctx.PathParam("channel"),
|
||||
ctx.PathParam("recipe_revision"),
|
||||
)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Data[recipeReferenceKey] = rref
|
||||
|
||||
reference := ctx.PathParam("package_reference")
|
||||
|
||||
var pref *conan_module.PackageReference
|
||||
if reference != "" {
|
||||
pref, err = conan_module.NewPackageReference(
|
||||
rref,
|
||||
reference,
|
||||
ctx.PathParam("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
|
||||
}
|
||||
|
||||
packageScope := auth_service.GetAccessScope(ctx.Data)
|
||||
if has, err := packageScope.HasAnyScope(
|
||||
auth_model.AccessTokenScopeReadPackage,
|
||||
auth_model.AccessTokenScopeWritePackage,
|
||||
auth_model.AccessTokenScopeAll,
|
||||
); !has {
|
||||
if err != nil {
|
||||
log.Error("Error checking access scope: %v", err)
|
||||
}
|
||||
apiError(ctx, http.StatusForbidden, nil)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := packages_service.CreateAuthorizationToken(ctx.Doer, packageScope)
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
packageScope := auth_service.GetAccessScope(ctx.Data)
|
||||
if has, err := packageScope.HasAnyScope(
|
||||
auth_model.AccessTokenScopeReadPackage,
|
||||
auth_model.AccessTokenScopeWritePackage,
|
||||
auth_model.AccessTokenScopeAll,
|
||||
); !has {
|
||||
if err != nil {
|
||||
log.Error("Error checking access scope: %v", err)
|
||||
}
|
||||
ctx.Status(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
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 errors.Is(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 errors.Is(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.PathParam("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 {
|
||||
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 {
|
||||
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.PathParam("filename")
|
||||
if !fileFilter.Contains(filename) {
|
||||
apiError(ctx, http.StatusBadRequest, nil)
|
||||
return
|
||||
}
|
||||
|
||||
s, u, pf, err := packages_service.OpenFileForDownloadByPackageNameAndVersion(
|
||||
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,
|
||||
},
|
||||
ctx.Req.Method,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageNotExist) || errors.Is(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 errors.Is(err, packages_model.ErrPackageNotExist) || errors.Is(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 errors.Is(err, packages_model.ErrPackageNotExist) || errors.Is(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 errors.Is(err, packages_model.ErrPackageNotExist) || errors.Is(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 errors.Is(err, packages_model.ErrPackageNotExist) || errors.Is(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 errors.Is(err, packages_model.ErrPackageNotExist) || errors.Is(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 errors.Is(err, conan_model.ErrRecipeReferenceNotExist) || errors.Is(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 errors.Is(err, conan_model.ErrRecipeReferenceNotExist) || errors.Is(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 errors.Is(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,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package conan
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
conan_model "gitea.dev/models/packages/conan"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/json"
|
||||
conan_module "gitea.dev/modules/packages/conan"
|
||||
"gitea.dev/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 errors.Is(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 errors.Is(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 errors.Is(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 errors.Is(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 errors.Is(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)
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package conda
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
packages_model "gitea.dev/models/packages"
|
||||
conda_model "gitea.dev/models/packages/conda"
|
||||
"gitea.dev/modules/json"
|
||||
packages_module "gitea.dev/modules/packages"
|
||||
conda_module "gitea.dev/modules/packages/conda"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/routers/api/packages/helper"
|
||||
"gitea.dev/services/context"
|
||||
packages_service "gitea.dev/services/packages"
|
||||
|
||||
"github.com/dsnet/compress/bzip2"
|
||||
)
|
||||
|
||||
func apiError(ctx *context.Context, status int, obj any) {
|
||||
message := helper.ProcessErrorForUser(ctx, status, obj)
|
||||
ctx.JSON(status, struct {
|
||||
Reason string `json:"reason"`
|
||||
Message string `json:"message"`
|
||||
}{
|
||||
Reason: http.StatusText(status),
|
||||
Message: message,
|
||||
})
|
||||
}
|
||||
|
||||
func isCondaPackageFileName(filename string) bool {
|
||||
return strings.HasSuffix(filename, ".tar.bz2") || strings.HasSuffix(filename, ".conda")
|
||||
}
|
||||
|
||||
func ListOrGetPackages(ctx *context.Context) {
|
||||
filename := ctx.PathParam("filename")
|
||||
switch filename {
|
||||
case "repodata.json", "repodata.json.bz2", "current_repodata.json", "current_repodata.json.bz2":
|
||||
EnumeratePackages(ctx)
|
||||
return
|
||||
}
|
||||
if isCondaPackageFileName(filename) {
|
||||
DownloadPackageFile(ctx)
|
||||
return
|
||||
}
|
||||
http.NotFound(ctx.Resp, ctx.Req)
|
||||
}
|
||||
|
||||
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.PathParam("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.PathParam("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: util.SliceNilAsEmpty(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.PathParam("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)
|
||||
_ = json.NewEncoder(w).Encode(repoData)
|
||||
}
|
||||
|
||||
func UploadPackageFile(ctx *context.Context) {
|
||||
filename := ctx.PathParam("filename")
|
||||
if !isCondaPackageFileName(filename) {
|
||||
apiError(ctx, http.StatusBadRequest, nil)
|
||||
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()
|
||||
|
||||
var pck *conda_module.Package
|
||||
if strings.HasSuffix(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.PathParam("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.PathParam("channel"),
|
||||
Subdir: ctx.PathParam("architecture"),
|
||||
Filename: ctx.PathParam("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.OpenFileForDownload(ctx, pf, ctx.Req.Method)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
helper.ServePackageFile(ctx, s, u, pf)
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
packages_model "gitea.dev/models/packages"
|
||||
container_model "gitea.dev/models/packages/container"
|
||||
"gitea.dev/modules/globallock"
|
||||
"gitea.dev/modules/log"
|
||||
packages_module "gitea.dev/modules/packages"
|
||||
container_module "gitea.dev/modules/packages/container"
|
||||
"gitea.dev/modules/util"
|
||||
packages_service "gitea.dev/services/packages"
|
||||
|
||||
"github.com/opencontainers/go-digest"
|
||||
)
|
||||
|
||||
// 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
|
||||
// There will be concurrent uploading for the same blob, so it needs a global lock per blob hash
|
||||
func saveAsPackageBlob(ctx context.Context, hsr packages_module.HashedSizeReader, pci *packages_service.PackageCreationInfo) (*packages_model.PackageBlob, error) { //nolint:unparam //returned PackageBlob is never used
|
||||
pb := packages_service.NewPackageBlob(hsr)
|
||||
err := globallock.LockAndDo(ctx, "container-blob:"+pb.HashSHA256, func(ctx context.Context) error {
|
||||
var err error
|
||||
pb, err = saveAsPackageBlobInternal(ctx, hsr, pci, pb)
|
||||
return err
|
||||
})
|
||||
return pb, err
|
||||
}
|
||||
|
||||
func saveAsPackageBlobInternal(ctx context.Context, hsr packages_module.HashedSizeReader, pci *packages_service.PackageCreationInfo, pb *packages_model.PackageBlob) (*packages_model.PackageBlob, error) {
|
||||
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 && pb != nil { // pb can be nil if GetOrInsertBlob failed
|
||||
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 containerGlobalLockKey(piOwnerID int64, piName, usage string) string {
|
||||
return fmt.Sprintf("pkg_%d_container_%s_%s", piOwnerID, strings.ToLower(piName), usage)
|
||||
}
|
||||
|
||||
func getOrCreateUploadVersion(ctx context.Context, pi *packages_service.PackageInfo) (*packages_model.PackageVersion, error) {
|
||||
releaser, err := globallock.Lock(ctx, containerGlobalLockKey(pi.Owner.ID, pi.Name, "package"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer releaser()
|
||||
|
||||
return db.WithTx2(ctx, func(ctx context.Context) (*packages_model.PackageVersion, 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 !errors.Is(err, packages_model.ErrDuplicatePackage) {
|
||||
log.Error("Error inserting package: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
created = false
|
||||
}
|
||||
|
||||
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 nil, err
|
||||
}
|
||||
}
|
||||
|
||||
pv := &packages_model.PackageVersion{
|
||||
PackageID: p.ID,
|
||||
CreatorID: pi.Owner.ID,
|
||||
Version: container_module.UploadVersion,
|
||||
LowerVersion: container_module.UploadVersion,
|
||||
IsInternal: true,
|
||||
MetadataJSON: "null",
|
||||
}
|
||||
if pv, err = packages_model.GetOrInsertVersion(ctx, pv); err != nil {
|
||||
if !errors.Is(err, packages_model.ErrDuplicatePackageVersion) {
|
||||
log.Error("Error inserting package: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return pv, nil
|
||||
})
|
||||
}
|
||||
|
||||
func createFileForBlob(ctx context.Context, pv *packages_model.PackageVersion, pb *packages_model.PackageBlob) error {
|
||||
filename := strings.ToLower("sha256_" + 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 errors.Is(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 string, digest digest.Digest) error {
|
||||
releaser, err := globallock.Lock(ctx, containerGlobalLockKey(ownerID, image, "blob"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer releaser()
|
||||
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
pfds, err := container_model.GetContainerBlobs(ctx, &container_model.BlobSearchOptions{
|
||||
OwnerID: ownerID,
|
||||
Image: image,
|
||||
Digest: string(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
|
||||
}
|
||||
@@ -0,0 +1,812 @@
|
||||
// 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"
|
||||
"sync"
|
||||
|
||||
auth_model "gitea.dev/models/auth"
|
||||
packages_model "gitea.dev/models/packages"
|
||||
container_model "gitea.dev/models/packages/container"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/httplib"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/optional"
|
||||
packages_module "gitea.dev/modules/packages"
|
||||
container_module "gitea.dev/modules/packages/container"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/storage"
|
||||
"gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/routers/api/packages/helper"
|
||||
auth_service "gitea.dev/services/auth"
|
||||
"gitea.dev/services/context"
|
||||
packages_service "gitea.dev/services/packages"
|
||||
container_service "gitea.dev/services/packages/container"
|
||||
|
||||
"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 globalVars = sync.OnceValue(func() (ret struct {
|
||||
imageNamePattern, referencePattern *regexp.Regexp
|
||||
},
|
||||
) {
|
||||
ret.imageNamePattern = regexp.MustCompile(`\A[a-z0-9]+([._-][a-z0-9]+)*(/[a-z0-9]+([._-][a-z0-9]+)*)*\z`)
|
||||
ret.referencePattern = regexp.MustCompile(`\A[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}\z`)
|
||||
return ret
|
||||
})
|
||||
|
||||
type containerHeaders struct {
|
||||
Status int
|
||||
ContentDigest string
|
||||
UploadUUID string
|
||||
Range string
|
||||
Location string
|
||||
ContentType string
|
||||
ContentLength optional.Option[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.Has() {
|
||||
resp.Header().Set("Content-Length", strconv.FormatInt(h.ContentLength.Value(), 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",
|
||||
})
|
||||
_ = json.NewEncoder(ctx.Resp).Encode(obj) // ignore network errors
|
||||
}
|
||||
|
||||
func apiError(ctx *context.Context, status int, err error) {
|
||||
_ = helper.ProcessErrorForUser(ctx, status, err)
|
||||
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) {
|
||||
// container registry requires that the "/v2" must be in the root, so the sub-path in AppURL should be removed
|
||||
realmURL := httplib.GuessCurrentHostURL(ctx) + "/v2/token"
|
||||
ctx.Resp.Header().Add("WWW-Authenticate", `Bearer realm="`+realmURL+`",service="container_registry",scope="*"`)
|
||||
|
||||
ownerName := ctx.PathParam("username")
|
||||
owner, _ := user_model.GetUserByName(ctx, ownerName)
|
||||
requireSignIn := owner != nil && owner.Visibility != structs.VisibleTypePublic
|
||||
requireSignIn = requireSignIn || setting.Service.RequireSignInViewStrict
|
||||
if requireSignIn {
|
||||
// support apple container like: container registry login <gitea-host> -u
|
||||
ctx.Resp.Header().Add("WWW-Authenticate", `Basic realm="Gitea Container Registry"`)
|
||||
}
|
||||
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.RequireSignInViewStrict && ctx.Doer.IsGhost()) {
|
||||
APIUnauthorizedError(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// VerifyImageName is a middleware which checks if the image name is allowed
|
||||
func VerifyImageName(ctx *context.Context) {
|
||||
if !globalVars().imageNamePattern.MatchString(ctx.PathParam("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
|
||||
packageScope := auth_service.GetAccessScope(ctx.Data)
|
||||
if u == nil {
|
||||
if setting.Service.RequireSignInViewStrict {
|
||||
APIUnauthorizedError(ctx)
|
||||
return
|
||||
}
|
||||
|
||||
u = user_model.NewGhostUser()
|
||||
} else {
|
||||
if has, err := packageScope.HasAnyScope(
|
||||
auth_model.AccessTokenScopeReadPackage,
|
||||
auth_model.AccessTokenScopeWritePackage,
|
||||
auth_model.AccessTokenScopeAll,
|
||||
); !has {
|
||||
if err != nil {
|
||||
log.Error("Error checking access scope: %v", err)
|
||||
}
|
||||
APIUnauthorizedError(ctx)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
token, err := packages_service.CreateAuthorizationToken(u, packageScope)
|
||||
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)) // FIXME: "n" can't be zero here, the logic is inconsistent with GetTagsList
|
||||
}
|
||||
v.Add("last", repositories[len(repositories)-1])
|
||||
|
||||
ctx.Resp.Header().Set("Link", fmt.Sprintf(`</v2/_catalog?%s>; rel="next"`, v.Encode()))
|
||||
}
|
||||
|
||||
jsonResponse(ctx, http.StatusOK, RepositoryList{
|
||||
Repositories: repositories,
|
||||
})
|
||||
}
|
||||
|
||||
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#mounting-a-blob-from-another-repository
|
||||
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#single-post
|
||||
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pushing-a-blob-in-chunks
|
||||
func PostBlobsUploads(ctx *context.Context) {
|
||||
image := ctx.PathParam("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 {
|
||||
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
|
||||
}
|
||||
|
||||
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),
|
||||
UploadUUID: upload.ID,
|
||||
Status: http.StatusAccepted,
|
||||
})
|
||||
}
|
||||
|
||||
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pushing-a-blob-in-chunks
|
||||
func GetBlobsUpload(ctx *context.Context) {
|
||||
image := ctx.PathParam("image")
|
||||
uuid := ctx.PathParam("uuid")
|
||||
|
||||
upload, err := packages_model.GetBlobUploadByID(ctx, uuid)
|
||||
if err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageBlobUploadNotExist) {
|
||||
apiErrorDefined(ctx, errBlobUploadUnknown)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// FIXME: undefined behavior when the uploaded content is empty: https://github.com/opencontainers/distribution-spec/issues/578
|
||||
respHeaders := &containerHeaders{
|
||||
Location: fmt.Sprintf("/v2/%s/%s/blobs/uploads/%s", ctx.Package.Owner.LowerName, image, upload.ID),
|
||||
UploadUUID: upload.ID,
|
||||
Status: http.StatusNoContent,
|
||||
}
|
||||
if upload.BytesReceived > 0 {
|
||||
respHeaders.Range = fmt.Sprintf("0-%d", upload.BytesReceived-1)
|
||||
}
|
||||
setResponseHeaders(ctx.Resp, respHeaders)
|
||||
}
|
||||
|
||||
// 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 PatchBlobsUpload(ctx *context.Context) {
|
||||
image := ctx.PathParam("image")
|
||||
|
||||
uploader, err := container_service.NewBlobUploader(ctx, ctx.PathParam("uuid"))
|
||||
if err != nil {
|
||||
if errors.Is(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
|
||||
}
|
||||
|
||||
respHeaders := &containerHeaders{
|
||||
Location: fmt.Sprintf("/v2/%s/%s/blobs/uploads/%s", ctx.Package.Owner.LowerName, image, uploader.ID),
|
||||
UploadUUID: uploader.ID,
|
||||
Status: http.StatusAccepted,
|
||||
}
|
||||
if uploader.Size() > 0 {
|
||||
respHeaders.Range = fmt.Sprintf("0-%d", uploader.Size()-1)
|
||||
}
|
||||
setResponseHeaders(ctx.Resp, respHeaders)
|
||||
}
|
||||
|
||||
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pushing-a-blob-in-chunks
|
||||
func PutBlobsUpload(ctx *context.Context) {
|
||||
image := ctx.PathParam("image")
|
||||
|
||||
digest := ctx.FormTrim("digest")
|
||||
if digest == "" {
|
||||
apiErrorDefined(ctx, errDigestInvalid)
|
||||
return
|
||||
}
|
||||
|
||||
uploader, err := container_service.NewBlobUploader(ctx, ctx.PathParam("uuid"))
|
||||
if err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageBlobUploadNotExist) {
|
||||
apiErrorDefined(ctx, errBlobUploadUnknown)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
defer 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 {
|
||||
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
|
||||
}
|
||||
|
||||
// Some SDK (e.g.: minio) will close the Reader if it is also a Closer after "uploading".
|
||||
// And we don't need to wrap the reader to anything else because the SDK will benefit from other interfaces like Seeker.
|
||||
// It's safe to call Close twice, so ignore the error.
|
||||
_ = uploader.Close()
|
||||
|
||||
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 DeleteBlobsUpload(ctx *context.Context) {
|
||||
uuid := ctx.PathParam("uuid")
|
||||
|
||||
_, err := packages_model.GetBlobUploadByID(ctx, uuid)
|
||||
if err != nil {
|
||||
if errors.Is(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 := digest.Digest(ctx.PathParam("digest"))
|
||||
if d.Validate() != nil {
|
||||
return nil, container_model.ErrContainerBlobNotExist
|
||||
}
|
||||
|
||||
return workaroundGetContainerBlob(ctx, &container_model.BlobSearchOptions{
|
||||
OwnerID: ctx.Package.Owner.ID,
|
||||
Image: ctx.PathParam("image"),
|
||||
Digest: string(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 errors.Is(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: optional.Some(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 errors.Is(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 := digest.Digest(ctx.PathParam("digest"))
|
||||
if d.Validate() != nil {
|
||||
apiErrorDefined(ctx, errBlobUnknown)
|
||||
return
|
||||
}
|
||||
|
||||
if err := deleteBlob(ctx, ctx.Package.Owner.ID, ctx.PathParam("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 PutManifest(ctx *context.Context) {
|
||||
reference := ctx.PathParam("reference")
|
||||
|
||||
mci := &manifestCreationInfo{
|
||||
MediaType: ctx.Req.Header.Get("Content-Type"),
|
||||
Owner: ctx.Package.Owner,
|
||||
Creator: ctx.Doer,
|
||||
Image: ctx.PathParam("image"),
|
||||
Reference: reference,
|
||||
IsTagged: digest.Digest(reference).Validate() != nil,
|
||||
}
|
||||
|
||||
if mci.IsTagged && !globalVars().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 if errors.Is(err, packages_service.ErrQuotaTotalCount) || errors.Is(err, packages_service.ErrQuotaTypeSize) || errors.Is(err, packages_service.ErrQuotaTotalSize) {
|
||||
apiError(ctx, http.StatusForbidden, err)
|
||||
} else {
|
||||
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) {
|
||||
opts := &container_model.BlobSearchOptions{
|
||||
OwnerID: ctx.Package.Owner.ID,
|
||||
Image: ctx.PathParam("image"),
|
||||
IsManifest: true,
|
||||
}
|
||||
|
||||
reference := ctx.PathParam("reference")
|
||||
if d := digest.Digest(reference); d.Validate() == nil {
|
||||
opts.Digest = string(d)
|
||||
} else if globalVars().referencePattern.MatchString(reference) {
|
||||
opts.Tag = reference
|
||||
opts.OnlyLead = true
|
||||
} 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 errors.Is(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: optional.Some(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 errors.Is(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.OpenBlobForDownload(ctx, pfd.File, pfd.Blob, ctx.Req.Method, &storage.ServeDirectOptions{
|
||||
ContentType: pfd.Properties.GetByName(container_module.PropertyMediaType),
|
||||
})
|
||||
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: optional.Some(pfd.Blob.Size),
|
||||
Status: http.StatusOK,
|
||||
}
|
||||
|
||||
if u != nil {
|
||||
headers.Status = http.StatusTemporaryRedirect
|
||||
headers.Location = u.String()
|
||||
headers.ContentLength = optional.None[int64]() // do not set Content-Length for redirect responses
|
||||
setResponseHeaders(ctx.Resp, headers)
|
||||
return
|
||||
}
|
||||
|
||||
defer s.Close()
|
||||
|
||||
setResponseHeaders(ctx.Resp, headers)
|
||||
_, _ = io.Copy(ctx.Resp, s)
|
||||
}
|
||||
|
||||
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#content-discovery
|
||||
func GetTagsList(ctx *context.Context) {
|
||||
image := ctx.PathParam("image")
|
||||
|
||||
if _, err := packages_model.GetPackageByName(ctx, ctx.Package.Owner.ID, packages_model.TypeContainer, image); err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageNotExist) {
|
||||
apiErrorDefined(ctx, errNameUnknown)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
n := -1
|
||||
if ctx.FormTrim("n") != "" {
|
||||
n = ctx.FormInt("n")
|
||||
}
|
||||
last := ctx.FormTrim("last")
|
||||
|
||||
tags, err := container_model.GetImageTags(ctx, ctx.Package.Owner.ID, image, n, last)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
type TagList struct {
|
||||
Name string `json:"name"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
if len(tags) > 0 {
|
||||
v := url.Values{}
|
||||
if n > 0 {
|
||||
v.Add("n", strconv.Itoa(n))
|
||||
}
|
||||
v.Add("last", tags[len(tags)-1])
|
||||
|
||||
ctx.Resp.Header().Set("Link", fmt.Sprintf(`</v2/%s/%s/tags/list?%s>; rel="next"`, ctx.Package.Owner.LowerName, image, v.Encode()))
|
||||
}
|
||||
|
||||
jsonResponse(ctx, http.StatusOK, TagList{
|
||||
Name: strings.ToLower(ctx.Package.Owner.LowerName + "/" + image),
|
||||
Tags: tags,
|
||||
})
|
||||
}
|
||||
|
||||
// FIXME: Workaround to be removed in v1.20.
|
||||
// Update maybe we should never really remote it, as long as there is legacy data?
|
||||
// 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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
packages_model "gitea.dev/models/packages"
|
||||
container_model "gitea.dev/models/packages/container"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/globallock"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/log"
|
||||
packages_module "gitea.dev/modules/packages"
|
||||
container_module "gitea.dev/modules/packages/container"
|
||||
"gitea.dev/modules/util"
|
||||
notify_service "gitea.dev/services/notify"
|
||||
packages_service "gitea.dev/services/packages"
|
||||
container_service "gitea.dev/services/packages/container"
|
||||
|
||||
"github.com/opencontainers/go-digest"
|
||||
oci "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
)
|
||||
|
||||
// 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 !container_module.IsMediaTypeValid(mci.MediaType) {
|
||||
mci.MediaType = index.MediaType
|
||||
if !container_module.IsMediaTypeValid(mci.MediaType) {
|
||||
return "", errManifestInvalid.WithMessage("MediaType not recognized")
|
||||
}
|
||||
}
|
||||
|
||||
// .../container/manifest.go:453:createManifestBlob() [E] Error inserting package blob: Error 1062 (23000): Duplicate entry '..........' for key 'package_blob.UQE_package_blob_md5'
|
||||
releaser, err := globallock.Lock(ctx, containerGlobalLockKey(mci.Owner.ID, mci.Image, "manifest"))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer releaser()
|
||||
|
||||
if container_module.IsMediaTypeImageManifest(mci.MediaType) {
|
||||
return processOciImageManifest(ctx, mci, buf)
|
||||
} else if container_module.IsMediaTypeImageIndex(mci.MediaType) {
|
||||
return processOciImageIndex(ctx, mci, buf)
|
||||
}
|
||||
return "", errManifestInvalid
|
||||
}
|
||||
|
||||
type processManifestTxRet struct {
|
||||
pv *packages_model.PackageVersion
|
||||
pb *packages_model.PackageBlob
|
||||
created bool
|
||||
digest string
|
||||
}
|
||||
|
||||
func handleCreateManifestResult(ctx context.Context, err error, mci *manifestCreationInfo, contentStore *packages_module.ContentStore, txRet *processManifestTxRet) (string, error) {
|
||||
if err != nil {
|
||||
if txRet.created && txRet.pb != nil {
|
||||
if err := contentStore.Delete(packages_module.BlobHash256Key(txRet.pb.HashSHA256)); err != nil {
|
||||
log.Error("Error deleting package blob from content store: %v", err)
|
||||
}
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
pd, err := packages_model.GetPackageDescriptor(ctx, txRet.pv)
|
||||
if err != nil {
|
||||
log.Error("Error getting package descriptor: %v", err) // ignore this error
|
||||
} else {
|
||||
notify_service.PackageCreate(ctx, mci.Creator, pd)
|
||||
}
|
||||
return txRet.digest, nil
|
||||
}
|
||||
|
||||
func processOciImageManifest(ctx context.Context, mci *manifestCreationInfo, buf *packages_module.HashedBuffer) (manifestDigest string, errRet error) {
|
||||
manifest, configDescriptor, metadata, err := container_service.ParseManifestMetadata(ctx, buf, mci.Owner.ID, mci.Image)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err = buf.Seek(0, io.SeekStart); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
contentStore := packages_module.NewContentStore()
|
||||
var txRet processManifestTxRet
|
||||
err = db.WithTx(ctx, func(ctx context.Context) (err error) {
|
||||
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_module.UploadVersion)
|
||||
if err != nil && !errors.Is(err, packages_model.ErrPackageNotExist) {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, ref := range blobReferences {
|
||||
if _, err = createFileFromBlobReference(ctx, pv, uploadVersion, ref); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
txRet.pv = pv
|
||||
txRet.pb, txRet.created, txRet.digest, err = createManifestBlob(ctx, contentStore, mci, pv, buf)
|
||||
return err
|
||||
})
|
||||
|
||||
return handleCreateManifestResult(ctx, err, mci, contentStore, &txRet)
|
||||
}
|
||||
|
||||
func processOciImageIndex(ctx context.Context, mci *manifestCreationInfo, buf *packages_module.HashedBuffer) (manifestDigest string, errRet 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
|
||||
}
|
||||
|
||||
contentStore := packages_module.NewContentStore()
|
||||
var txRet processManifestTxRet
|
||||
err := db.WithTx(ctx, func(ctx context.Context) (err error) {
|
||||
metadata := &container_module.Metadata{
|
||||
Type: container_module.TypeOCI,
|
||||
Manifests: make([]*container_module.Manifest, 0, len(index.Manifests)),
|
||||
}
|
||||
|
||||
for _, manifest := range index.Manifests {
|
||||
if !container_module.IsMediaTypeImageManifest(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 errors.Is(err, container_model.ErrContainerBlobNotExist) {
|
||||
return errManifestBlobUnknown
|
||||
}
|
||||
return fmt.Errorf("GetContainerBlob: %w", err)
|
||||
}
|
||||
|
||||
size, err := packages_model.CalculateFileSize(ctx, &packages_model.PackageFileSearchOptions{
|
||||
VersionID: pfd.File.VersionID,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("CalculateFileSize: %w", 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 fmt.Errorf("createPackageAndVersion: %w", err)
|
||||
}
|
||||
|
||||
txRet.pv = pv
|
||||
txRet.pb, txRet.created, txRet.digest, err = createManifestBlob(ctx, contentStore, mci, pv, buf)
|
||||
return err
|
||||
})
|
||||
|
||||
return handleCreateManifestResult(ctx, err, mci, contentStore, &txRet)
|
||||
}
|
||||
|
||||
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 !errors.Is(err, packages_model.ErrDuplicatePackage) {
|
||||
log.Error("Error inserting package: %v", err)
|
||||
return nil, fmt.Errorf("TryInsertPackage: %w", err)
|
||||
}
|
||||
created = false
|
||||
}
|
||||
|
||||
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, fmt.Errorf("InsertProperty(PropertyRepository): %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
metadata.IsTagged = mci.IsTagged
|
||||
|
||||
metadataJSON, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("json.Marshal(metadata): %w", err)
|
||||
}
|
||||
|
||||
// "docker buildx imagetools create" multi-arch operations:
|
||||
// {"type":"oci","is_tagged":false,"platform":"unknown/unknown"}
|
||||
// {"type":"oci","is_tagged":false,"platform":"linux/amd64","layer_creation":["ADD file:9233f6f2237d79659a9521f7e390df217cec49f1a8aa3a12147bbca1956acdb9 in /","CMD [\"/bin/sh\"]"]}
|
||||
// {"type":"oci","is_tagged":false,"platform":"unknown/unknown"}
|
||||
// {"type":"oci","is_tagged":false,"platform":"linux/arm64","layer_creation":["ADD file:df53811312284306901fdaaff0a357a4bf40d631e662fe9ce6d342442e494b6c in /","CMD [\"/bin/sh\"]"]}
|
||||
// {"type":"oci","is_tagged":true,"manifests":[{"platform":"linux/amd64","digest":"sha256:72bb73e706c0dec424d00a1febb21deaf1175a70ead009ad8b159729cfcf5769","size":2819478},{"platform":"linux/arm64","digest":"sha256:9e1426dd084a3221663b85ca1ee99d140c50b153917a5c5604c1f9b78229fd24","size":2716499},{"platform":"unknown/unknown","digest":"sha256:b93f03d0ae11b988243e1b2cd8d29accf5b9670547b7bd8c7d96abecc7283e6e","size":1798},{"platform":"unknown/unknown","digest":"sha256:f034b182ba66366c63a5d195c6dfcd3333c027409c0ac98e55ade36aaa3b2963","size":1798}]}
|
||||
|
||||
_pv := &packages_model.PackageVersion{
|
||||
PackageID: p.ID,
|
||||
CreatorID: mci.Creator.ID,
|
||||
Version: strings.ToLower(mci.Reference),
|
||||
LowerVersion: strings.ToLower(mci.Reference),
|
||||
MetadataJSON: string(metadataJSON),
|
||||
}
|
||||
pv, err := packages_model.GetOrInsertVersion(ctx, _pv)
|
||||
if err != nil {
|
||||
if !errors.Is(err, packages_model.ErrDuplicatePackageVersion) {
|
||||
log.Error("Error GetOrInsertVersion (first try) package: %v", err)
|
||||
return nil, fmt.Errorf("GetOrInsertVersion: first try: %w", err)
|
||||
}
|
||||
if err = packages_service.DeletePackageVersionAndReferences(ctx, pv); err != nil {
|
||||
return nil, fmt.Errorf("DeletePackageVersionAndReferences: %w", err)
|
||||
}
|
||||
// keep download count on overwriting
|
||||
_pv.DownloadCount = pv.DownloadCount
|
||||
pv, err = packages_model.GetOrInsertVersion(ctx, _pv)
|
||||
if err != nil {
|
||||
if !errors.Is(err, packages_model.ErrDuplicatePackageVersion) {
|
||||
log.Error("Error GetOrInsertVersion (second try) package: %v", err)
|
||||
return nil, fmt.Errorf("GetOrInsertVersion: second try: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := packages_service.CheckCountQuotaExceeded(ctx, mci.Creator, mci.Owner); err != nil {
|
||||
return nil, fmt.Errorf("CheckCountQuotaExceeded: %w", err)
|
||||
}
|
||||
|
||||
if mci.IsTagged {
|
||||
if err = packages_model.InsertOrUpdateProperty(ctx, packages_model.PropertyTypeVersion, pv.ID, container_module.PropertyManifestTagged, ""); err != nil {
|
||||
return nil, fmt.Errorf("InsertOrUpdateProperty(ManifestTagged): %w", err)
|
||||
}
|
||||
} else {
|
||||
if err = packages_model.DeletePropertiesByName(ctx, packages_model.PropertyTypeVersion, pv.ID, container_module.PropertyManifestTagged); err != nil {
|
||||
return nil, fmt.Errorf("DeletePropertiesByName(ManifestTagged): %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err = packages_model.DeletePropertiesByName(ctx, packages_model.PropertyTypeVersion, pv.ID, container_module.PropertyManifestReference); err != nil {
|
||||
return nil, fmt.Errorf("DeletePropertiesByName(ManifestReference): %w", err)
|
||||
}
|
||||
for _, manifest := range metadata.Manifests {
|
||||
if _, err = packages_model.InsertProperty(ctx, packages_model.PropertyTypeVersion, pv.ID, container_module.PropertyManifestReference, manifest.Digest); err != nil {
|
||||
return nil, fmt.Errorf("InsertProperty(ManifestReference): %w", 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) (*packages_model.PackageFile, error) {
|
||||
if ref.File.Blob.Size != ref.ExpectedSize {
|
||||
return nil, errSizeInvalid
|
||||
}
|
||||
|
||||
if ref.Name == "" {
|
||||
ref.Name = strings.ToLower("sha256_" + ref.File.Blob.HashSHA256)
|
||||
}
|
||||
|
||||
pf := &packages_model.PackageFile{
|
||||
VersionID: pv.ID,
|
||||
BlobID: ref.File.Blob.ID,
|
||||
Name: ref.Name,
|
||||
LowerName: ref.Name,
|
||||
CompositeKey: string(ref.Digest),
|
||||
IsLead: ref.IsLead,
|
||||
}
|
||||
var err error
|
||||
if pf, err = packages_model.TryInsertFile(ctx, pf); err != nil {
|
||||
if errors.Is(err, packages_model.ErrDuplicatePackageFile) {
|
||||
// Skip this blob because the manifest contains the same filesystem layer multiple times.
|
||||
return pf, nil
|
||||
}
|
||||
log.Error("Error inserting package file: %v", err)
|
||||
return nil, 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 nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the ref file (old 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 nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return pf, nil
|
||||
}
|
||||
|
||||
func createManifestBlob(ctx context.Context, contentStore *packages_module.ContentStore, mci *manifestCreationInfo, pv *packages_model.PackageVersion, buf *packages_module.HashedBuffer) (_ *packages_model.PackageBlob, created bool, manifestDigest 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 = 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), buf, buf.Size()); err != nil {
|
||||
log.Error("Error saving package blob in content store: %v", err)
|
||||
return nil, false, "", err
|
||||
}
|
||||
}
|
||||
|
||||
manifestDigest = digestFromHashSummer(buf)
|
||||
pf, err := createFileFromBlobReference(ctx, pv, nil, &blobReference{
|
||||
Digest: digest.Digest(manifestDigest),
|
||||
MediaType: mci.MediaType,
|
||||
Name: container_module.ManifestFilename,
|
||||
File: &packages_model.PackageFileDescriptor{Blob: pb},
|
||||
ExpectedSize: pb.Size,
|
||||
IsLead: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, false, "", err
|
||||
}
|
||||
|
||||
oldManifestFiles, _, err := packages_model.SearchFiles(ctx, &packages_model.PackageFileSearchOptions{
|
||||
OwnerID: mci.Owner.ID,
|
||||
PackageType: packages_model.TypeContainer,
|
||||
VersionID: pv.ID,
|
||||
Query: container_module.ManifestFilename,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, false, "", err
|
||||
}
|
||||
for _, oldManifestFile := range oldManifestFiles {
|
||||
if oldManifestFile.ID != pf.ID && oldManifestFile.IsLead {
|
||||
err = packages_model.UpdateFile(ctx, &packages_model.PackageFile{ID: oldManifestFile.ID, IsLead: false}, []string{"is_lead"})
|
||||
if err != nil {
|
||||
return nil, false, "", err
|
||||
}
|
||||
}
|
||||
}
|
||||
return pb, !exists, manifestDigest, err
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
// 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 "gitea.dev/models/packages"
|
||||
cran_model "gitea.dev/models/packages/cran"
|
||||
packages_module "gitea.dev/modules/packages"
|
||||
cran_module "gitea.dev/modules/packages/cran"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/routers/api/packages/helper"
|
||||
"gitea.dev/services/context"
|
||||
packages_service "gitea.dev/services/packages"
|
||||
)
|
||||
|
||||
func apiError(ctx *context.Context, status int, obj any) {
|
||||
message := helper.ProcessErrorForUser(ctx, status, obj)
|
||||
ctx.PlainText(status, message)
|
||||
}
|
||||
|
||||
func EnumerateSourcePackages(ctx *context.Context) {
|
||||
enumeratePackages(ctx, ctx.PathParam("format"), &cran_model.SearchOptions{
|
||||
OwnerID: ctx.Package.Owner.ID,
|
||||
FileType: cran_module.TypeSource,
|
||||
})
|
||||
}
|
||||
|
||||
func EnumerateBinaryPackages(ctx *context.Context) {
|
||||
enumeratePackages(ctx, ctx.PathParam("format"), &cran_model.SearchOptions{
|
||||
OwnerID: ctx.Package.Owner.ID,
|
||||
FileType: cran_module.TypeBinary,
|
||||
Platform: ctx.PathParam("platform"),
|
||||
RVersion: ctx.PathParam("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.PathParam("filename"),
|
||||
})
|
||||
}
|
||||
|
||||
func DownloadBinaryPackageFile(ctx *context.Context) {
|
||||
downloadPackageFile(ctx, &cran_model.SearchOptions{
|
||||
OwnerID: ctx.Package.Owner.ID,
|
||||
FileType: cran_module.TypeBinary,
|
||||
Platform: ctx.PathParam("platform"),
|
||||
RVersion: ctx.PathParam("rversion"),
|
||||
Filename: ctx.PathParam("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.OpenFileForDownload(ctx, pf, ctx.Req.Method)
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package debian
|
||||
|
||||
import (
|
||||
stdctx "context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
packages_model "gitea.dev/models/packages"
|
||||
packages_module "gitea.dev/modules/packages"
|
||||
debian_module "gitea.dev/modules/packages/debian"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/routers/api/packages/helper"
|
||||
"gitea.dev/services/context"
|
||||
notify_service "gitea.dev/services/notify"
|
||||
packages_service "gitea.dev/services/packages"
|
||||
debian_service "gitea.dev/services/packages/debian"
|
||||
)
|
||||
|
||||
func apiError(ctx *context.Context, status int, obj any) {
|
||||
message := helper.ProcessErrorForUser(ctx, status, obj)
|
||||
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.PathParam("distribution")
|
||||
|
||||
component := ctx.PathParam("component")
|
||||
architecture := strings.TrimPrefix(ctx.PathParam("architecture"), "binary-")
|
||||
if component != "" && architecture != "" {
|
||||
key += "|" + component + "|" + architecture
|
||||
}
|
||||
|
||||
s, u, pf, err := packages_service.OpenFileForDownloadByPackageVersion(
|
||||
ctx,
|
||||
pv,
|
||||
&packages_service.PackageFileInfo{
|
||||
Filename: ctx.PathParam("filename"),
|
||||
CompositeKey: key,
|
||||
},
|
||||
ctx.Req.Method,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageNotExist) || errors.Is(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.PathParam("algorithm"))
|
||||
if algorithm == "md5sum" {
|
||||
algorithm = "md5"
|
||||
}
|
||||
|
||||
pfs, _, err := packages_model.SearchFiles(ctx, &packages_model.PackageFileSearchOptions{
|
||||
VersionID: pv.ID,
|
||||
Hash: strings.ToLower(ctx.PathParam("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.OpenFileForDownload(ctx, pfs[0], ctx.Req.Method)
|
||||
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.PathParam("distribution"))
|
||||
component := strings.TrimSpace(ctx.PathParam("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.PathParam("name")
|
||||
version := ctx.PathParam("version")
|
||||
|
||||
s, u, pf, err := packages_service.OpenFileForDownloadByPackageNameAndVersion(
|
||||
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.PathParam("architecture")),
|
||||
CompositeKey: fmt.Sprintf("%s|%s", ctx.PathParam("distribution"), ctx.PathParam("component")),
|
||||
},
|
||||
ctx.Req.Method,
|
||||
)
|
||||
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.PathParam("distribution")
|
||||
component := ctx.PathParam("component")
|
||||
name := ctx.PathParam("name")
|
||||
version := ctx.PathParam("version")
|
||||
architecture := ctx.PathParam("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)
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package generic
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
packages_model "gitea.dev/models/packages"
|
||||
packages_module "gitea.dev/modules/packages"
|
||||
"gitea.dev/routers/api/packages/helper"
|
||||
"gitea.dev/services/context"
|
||||
packages_service "gitea.dev/services/packages"
|
||||
)
|
||||
|
||||
var (
|
||||
packageNameRegex = regexp.MustCompile(`\A[-_+.\w]+\z`)
|
||||
filenameRegex = regexp.MustCompile(`\A[-_+=:;.()\[\]{}~!@#$%^& \w]+\z`)
|
||||
)
|
||||
|
||||
func apiError(ctx *context.Context, status int, obj any) {
|
||||
message := helper.ProcessErrorForUser(ctx, status, obj)
|
||||
ctx.PlainText(status, message)
|
||||
}
|
||||
|
||||
// DownloadPackageFile serves the specific generic package.
|
||||
func DownloadPackageFile(ctx *context.Context) {
|
||||
s, u, pf, err := packages_service.OpenFileForDownloadByPackageNameAndVersion(
|
||||
ctx,
|
||||
&packages_service.PackageInfo{
|
||||
Owner: ctx.Package.Owner,
|
||||
PackageType: packages_model.TypeGeneric,
|
||||
Name: ctx.PathParam("packagename"),
|
||||
Version: ctx.PathParam("packageversion"),
|
||||
},
|
||||
&packages_service.PackageFileInfo{
|
||||
Filename: ctx.PathParam("filename"),
|
||||
},
|
||||
ctx.Req.Method,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageNotExist) || errors.Is(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.PathParam("packagename")
|
||||
filename := ctx.PathParam("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.PathParam("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 {
|
||||
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.PathParam("packagename"),
|
||||
Version: ctx.PathParam("packageversion"),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(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.PathParam("packagename"), ctx.PathParam("packageversion"))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
pf, err := packages_model.GetFileForVersionByName(ctx, pv.ID, ctx.PathParam("filename"), packages_model.EmptyFileKey)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return pv, pf, nil
|
||||
}()
|
||||
if err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageNotExist) || errors.Is(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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package goproxy
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
packages_model "gitea.dev/models/packages"
|
||||
"gitea.dev/modules/optional"
|
||||
packages_module "gitea.dev/modules/packages"
|
||||
goproxy_module "gitea.dev/modules/packages/goproxy"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/routers/api/packages/helper"
|
||||
"gitea.dev/services/context"
|
||||
packages_service "gitea.dev/services/packages"
|
||||
)
|
||||
|
||||
func apiError(ctx *context.Context, status int, obj any) {
|
||||
message := helper.ProcessErrorForUser(ctx, status, obj)
|
||||
ctx.PlainText(status, message)
|
||||
}
|
||||
|
||||
func EnumeratePackageVersions(ctx *context.Context) {
|
||||
pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeGo, ctx.PathParam("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.PathParam("name"), ctx.PathParam("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.PathParam("name"), ctx.PathParam("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.PathParam("name"), ctx.PathParam("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.OpenFileForDownload(ctx, pfs[0], ctx.Req.Method)
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
// 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 "gitea.dev/models/packages"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/optional"
|
||||
packages_module "gitea.dev/modules/packages"
|
||||
helm_module "gitea.dev/modules/packages/helm"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/routers/api/packages/helper"
|
||||
"gitea.dev/services/context"
|
||||
packages_service "gitea.dev/services/packages"
|
||||
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
func apiError(ctx *context.Context, status int, obj any) {
|
||||
message := helper.ProcessErrorForUser(ctx, status, obj)
|
||||
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)
|
||||
_ = 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",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// DownloadPackageFile serves the content of a package
|
||||
func DownloadPackageFile(ctx *context.Context) {
|
||||
filename := ctx.PathParam("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.PathParam("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.OpenFileForDownloadByPackageVersion(
|
||||
ctx,
|
||||
pvs[0],
|
||||
&packages_service.PackageFileInfo{
|
||||
Filename: filename,
|
||||
},
|
||||
ctx.Req.Method,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(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))
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package helper
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
packages_model "gitea.dev/models/packages"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/services/context"
|
||||
)
|
||||
|
||||
// ProcessErrorForUser logs the error and returns a user-error message for the end user.
|
||||
// If the status is http.StatusInternalServerError, the message is stripped for non-admin users in production.
|
||||
func ProcessErrorForUser(ctx *context.Context, status int, errObj any) string {
|
||||
var message string
|
||||
if err, ok := errObj.(error); ok {
|
||||
message = err.Error()
|
||||
} else if errObj != nil {
|
||||
message = fmt.Sprint(errObj)
|
||||
}
|
||||
|
||||
if status == http.StatusInternalServerError {
|
||||
log.Log(2, log.ERROR, "Package registry API internal error: %d %s", status, message)
|
||||
if setting.IsProd && (ctx.Doer == nil || !ctx.Doer.IsAdmin) {
|
||||
message = "internal server error"
|
||||
}
|
||||
return message
|
||||
}
|
||||
|
||||
log.Log(2, log.DEBUG, "Package registry API user error: %d %s", status, message)
|
||||
return message
|
||||
}
|
||||
|
||||
// ServePackageFile 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)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package maven
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"strings"
|
||||
|
||||
packages_model "gitea.dev/models/packages"
|
||||
)
|
||||
|
||||
// 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, groupID, artifactID string) *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]
|
||||
|
||||
resp := &MetadataResponse{
|
||||
GroupID: groupID,
|
||||
ArtifactID: artifactID,
|
||||
Latest: latest.Version.Version,
|
||||
Version: versions,
|
||||
}
|
||||
if release != nil {
|
||||
resp.Release = release.Version.Version
|
||||
}
|
||||
return resp
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
// 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"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
packages_model "gitea.dev/models/packages"
|
||||
"gitea.dev/modules/globallock"
|
||||
"gitea.dev/modules/json"
|
||||
packages_module "gitea.dev/modules/packages"
|
||||
maven_module "gitea.dev/modules/packages/maven"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/routers/api/packages/helper"
|
||||
"gitea.dev/services/context"
|
||||
packages_service "gitea.dev/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) {
|
||||
message := helper.ProcessErrorForUser(ctx, status, obj)
|
||||
// Maven client doesn't present the error message to end users; site admin can check the server logs that outputted by ProcessErrorForUser
|
||||
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) {
|
||||
// path pattern: /com/foo/project/maven-metadata.xml[.md5/.sha1/.sha256/.sha512]
|
||||
// in case there are legacy package names ("GroupID-ArtifactID") we need to check both, new packages always use ":" as separator("GroupID:ArtifactID")
|
||||
pvsLegacy, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeMaven, params.toInternalPackageNameLegacy())
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeMaven, params.toInternalPackageName())
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
pvs = append(pvsLegacy, pvs...)
|
||||
|
||||
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, params.GroupID, params.ArtifactID))
|
||||
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
|
||||
lastModified := latest.Version.CreatedUnix.AsTime().UTC().Format(http.TimeFormat)
|
||||
ctx.Resp.Header().Set("Last-Modified", lastModified)
|
||||
|
||||
ext := strings.ToLower(path.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) {
|
||||
pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeMaven, params.toInternalPackageName(), params.Version)
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
pv, err = packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeMaven, params.toInternalPackageNameLegacy(), params.Version)
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
filename := params.Filename
|
||||
|
||||
ext := strings.ToLower(path.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 errors.Is(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.OpenBlobForDownload(ctx, pf, pb, ctx.Req.Method, nil)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
opts.Filename = pf.Name
|
||||
|
||||
helper.ServePackageFile(ctx, s, u, pf, opts)
|
||||
}
|
||||
|
||||
func mavenPkgNameKey(packageName string) string {
|
||||
return "pkg_maven_" + packageName
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Ignore the package index /<name>/maven-metadata.xml
|
||||
if params.IsMeta && params.Version == "" {
|
||||
ctx.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
packageName := params.toInternalPackageName()
|
||||
if ctx.FormBool("use_legacy_package_name") {
|
||||
// for testing purpose only
|
||||
packageName = params.toInternalPackageNameLegacy()
|
||||
}
|
||||
|
||||
// for the same package, only one upload at a time
|
||||
releaser, err := globallock.Lock(ctx, mavenPkgNameKey(packageName))
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
defer releaser()
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
// old maven package uses "groupId-artifactId" as package name, so we need to update to the new format "groupId:artifactId"
|
||||
legacyPackage, err := packages_model.GetPackageByName(ctx, ctx.Package.Owner.ID, packages_model.TypeMaven, params.toInternalPackageNameLegacy())
|
||||
if err != nil && !errors.Is(err, packages_model.ErrPackageNotExist) {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
} else if legacyPackage != nil {
|
||||
err = packages_model.UpdatePackageNameByID(ctx, ctx.Package.Owner.ID, packages_model.TypeMaven, legacyPackage.ID, packageName)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ext := path.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 errors.Is(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 errors.Is(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 && !errors.Is(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 (p *parameters) toInternalPackageName() string {
|
||||
// there cuold be 2 choices: "/" or ":"
|
||||
// Maven says: "groupId:artifactId:version" in their document: https://maven.apache.org/pom.html#Maven_Coordinates
|
||||
// but it would be slightly ugly in URL: "/-/packages/maven/group-id%3Aartifact-id"
|
||||
return p.GroupID + ":" + p.ArtifactID
|
||||
}
|
||||
|
||||
func (p *parameters) toInternalPackageNameLegacy() string {
|
||||
return p.GroupID + "-" + p.ArtifactID
|
||||
}
|
||||
|
||||
func extractPathParameters(ctx *context.Context) (parameters, error) {
|
||||
parts := strings.Split(ctx.PathParam("*"), "/")
|
||||
|
||||
// formats:
|
||||
// * /com/group/id/artifactId/maven-metadata.xml[.md5|.sha1|.sha256|.sha512]
|
||||
// * /com/group/id/artifactId/version-SNAPSHOT/maven-metadata.xml[.md5|.sha1|.sha256|.sha512]
|
||||
// * /com/group/id/artifactId/version/any-file
|
||||
// * /com/group/id/artifactId/version-SNAPSHOT/any-file
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// 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 "gitea.dev/models/packages"
|
||||
npm_module "gitea.dev/modules/packages/npm"
|
||||
"gitea.dev/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,
|
||||
PeerDependenciesMeta: metadata.PeerDependenciesMeta,
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
// 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"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
packages_model "gitea.dev/models/packages"
|
||||
access_model "gitea.dev/models/perm/access"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unit"
|
||||
"gitea.dev/modules/optional"
|
||||
packages_module "gitea.dev/modules/packages"
|
||||
npm_module "gitea.dev/modules/packages/npm"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/routers/api/packages/helper"
|
||||
"gitea.dev/services/context"
|
||||
packages_service "gitea.dev/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) {
|
||||
message := helper.ProcessErrorForUser(ctx, status, obj)
|
||||
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.PathParam("scope")
|
||||
id := ctx.PathParam("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.PathParam("version")
|
||||
filename := ctx.PathParam("filename")
|
||||
|
||||
s, u, pf, err := packages_service.OpenFileForDownloadByPackageNameAndVersion(
|
||||
ctx,
|
||||
&packages_service.PackageInfo{
|
||||
Owner: ctx.Package.Owner,
|
||||
PackageType: packages_model.TypeNpm,
|
||||
Name: packageName,
|
||||
Version: packageVersion,
|
||||
},
|
||||
&packages_service.PackageFileInfo{
|
||||
Filename: filename,
|
||||
},
|
||||
ctx.Req.Method,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageNotExist) || errors.Is(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.PathParam("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.OpenFileForDownloadByPackageVersion(
|
||||
ctx,
|
||||
pvs[0],
|
||||
&packages_service.PackageFileInfo{
|
||||
Filename: filename,
|
||||
},
|
||||
ctx.Req.Method,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(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.GetRepositoryByURLRelax(ctx, npmPackage.Metadata.Repository.URL)
|
||||
if err == nil {
|
||||
canWrite := repo.OwnerID == ctx.Doer.ID
|
||||
|
||||
if !canWrite {
|
||||
perms, err := access_model.GetDoerRepoPermission(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.PathParam("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 errors.Is(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 errors.Is(err, packages_model.ErrPackageNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := setPackageTag(ctx, ctx.PathParam("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.PathParam("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)
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package nuget
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
packages_model "gitea.dev/models/packages"
|
||||
nuget_module "gitea.dev/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 {
|
||||
Authors string `xml:"d:Authors"`
|
||||
Copyright string `xml:"d:Copyright,omitempty"`
|
||||
Created TypedValue[time.Time] `xml:"d:Created"`
|
||||
Dependencies string `xml:"d:Dependencies"`
|
||||
Description string `xml:"d:Description"`
|
||||
DevelopmentDependency TypedValue[bool] `xml:"d:DevelopmentDependency"`
|
||||
DownloadCount TypedValue[int64] `xml:"d:DownloadCount"`
|
||||
ID string `xml:"d:Id"`
|
||||
IconURL string `xml:"d:IconUrl,omitempty"`
|
||||
Language string `xml:"d:Language,omitempty"`
|
||||
LastUpdated TypedValue[time.Time] `xml:"d:LastUpdated"`
|
||||
LicenseURL string `xml:"d:LicenseUrl,omitempty"`
|
||||
MinClientVersion string `xml:"d:MinClientVersion,omitempty"`
|
||||
NormalizedVersion string `xml:"d:NormalizedVersion"`
|
||||
Owners string `xml:"d:Owners,omitempty"`
|
||||
PackageSize TypedValue[int64] `xml:"d:PackageSize"`
|
||||
ProjectURL string `xml:"d:ProjectUrl,omitempty"`
|
||||
Published TypedValue[time.Time] `xml:"d:Published"`
|
||||
ReleaseNotes string `xml:"d:ReleaseNotes,omitempty"`
|
||||
RequireLicenseAcceptance TypedValue[bool] `xml:"d:RequireLicenseAcceptance"`
|
||||
Tags string `xml:"d:Tags,omitempty"`
|
||||
Title string `xml:"d:Title,omitempty"`
|
||||
Version string `xml:"d:Version"`
|
||||
VersionDownloadCount TypedValue[int64] `xml:"d:VersionDownloadCount"`
|
||||
}
|
||||
|
||||
type FeedEntry struct {
|
||||
XMLName xml.Name `xml:"entry"`
|
||||
Xmlns string `xml:"xmlns,attr,omitempty"`
|
||||
XmlnsD string `xml:"xmlns:d,attr,omitempty"`
|
||||
XmlnsM string `xml:"xmlns:m,attr,omitempty"`
|
||||
Base string `xml:"xml:base,attr,omitempty"`
|
||||
ID string `xml:"id"`
|
||||
Category FeedEntryCategory `xml:"category"`
|
||||
Links []FeedEntryLink `xml:"link"`
|
||||
Title TypedValue[string] `xml:"title"`
|
||||
Updated time.Time `xml:"updated"`
|
||||
Author string `xml:"author>name"`
|
||||
Summary string `xml:"summary"`
|
||||
Properties *FeedEntryProperties `xml:"m:properties"`
|
||||
Content string `xml:",innerxml"`
|
||||
}
|
||||
|
||||
type FeedResponse struct {
|
||||
XMLName xml.Name `xml:"feed"`
|
||||
Xmlns string `xml:"xmlns,attr,omitempty"`
|
||||
XmlnsD string `xml:"xmlns:d,attr,omitempty"`
|
||||
XmlnsM string `xml:"xmlns:m,attr,omitempty"`
|
||||
Base string `xml:"xml:base,attr,omitempty"`
|
||||
ID string `xml:"id"`
|
||||
Title TypedValue[string] `xml:"title"`
|
||||
Updated time.Time `xml:"updated"`
|
||||
Links []FeedEntryLink `xml:"link"`
|
||||
Entries []*FeedEntry `xml:"entry"`
|
||||
Count int64 `xml:"m:count"`
|
||||
}
|
||||
|
||||
func createFeedResponse(l *linkBuilder, totalEntries int64, pds []*packages_model.PackageDescriptor) *FeedResponse {
|
||||
entries := make([]*FeedEntry, 0, len(pds))
|
||||
for _, pd := range pds {
|
||||
entries = append(entries, createEntry(l, pd, false))
|
||||
}
|
||||
|
||||
links := []FeedEntryLink{
|
||||
{Rel: "self", Href: l.Base},
|
||||
}
|
||||
if l.Next != nil {
|
||||
links = append(links, FeedEntryLink{
|
||||
Rel: "next",
|
||||
Href: l.GetNextURL(),
|
||||
})
|
||||
}
|
||||
|
||||
return &FeedResponse{
|
||||
Xmlns: "http://www.w3.org/2005/Atom",
|
||||
Base: l.Base,
|
||||
XmlnsD: "http://schemas.microsoft.com/ado/2007/08/dataservices",
|
||||
XmlnsM: "http://schemas.microsoft.com/ado/2007/08/dataservices/metadata",
|
||||
ID: "http://schemas.datacontract.org/2004/07/",
|
||||
Updated: time.Now(),
|
||||
Links: links,
|
||||
Count: totalEntries,
|
||||
Entries: entries,
|
||||
}
|
||||
}
|
||||
|
||||
func createEntryResponse(l *linkBuilder, pd *packages_model.PackageDescriptor) *FeedEntry {
|
||||
return createEntry(l, pd, true)
|
||||
}
|
||||
|
||||
func createEntry(l *linkBuilder, pd *packages_model.PackageDescriptor, withNamespace bool) *FeedEntry {
|
||||
metadata := pd.Metadata.(*nuget_module.Metadata)
|
||||
|
||||
id := l.GetPackageMetadataURL(pd.Package.Name, pd.Version.Version)
|
||||
|
||||
// Workaround to force a self-closing tag to satisfy XmlReader.IsEmptyElement used by the NuGet client.
|
||||
// https://learn.microsoft.com/en-us/dotnet/api/system.xml.xmlreader.isemptyelement
|
||||
content := `<content type="application/zip" src="` + l.GetPackageDownloadURL(pd.Package.Name, pd.Version.Version) + `"/>`
|
||||
|
||||
createdValue := TypedValue[time.Time]{
|
||||
Type: "Edm.DateTime",
|
||||
Value: pd.Version.CreatedUnix.AsLocalTime(),
|
||||
}
|
||||
|
||||
entry := &FeedEntry{
|
||||
ID: id,
|
||||
Category: FeedEntryCategory{Term: "NuGetGallery.OData.V2FeedPackage", Scheme: "http://schemas.microsoft.com/ado/2007/08/dataservices/scheme"},
|
||||
Links: []FeedEntryLink{
|
||||
{Rel: "self", Href: id},
|
||||
{Rel: "edit", Href: id},
|
||||
},
|
||||
Title: TypedValue[string]{Type: "text", Value: pd.Package.Name},
|
||||
Updated: pd.Version.CreatedUnix.AsLocalTime(),
|
||||
Author: metadata.Authors,
|
||||
Content: content,
|
||||
Properties: &FeedEntryProperties{
|
||||
Authors: metadata.Authors,
|
||||
Copyright: metadata.Copyright,
|
||||
Created: createdValue,
|
||||
Dependencies: buildDependencyString(metadata),
|
||||
Description: metadata.Description,
|
||||
DevelopmentDependency: TypedValue[bool]{Type: "Edm.Boolean", Value: metadata.DevelopmentDependency},
|
||||
DownloadCount: TypedValue[int64]{Type: "Edm.Int64", Value: pd.Version.DownloadCount},
|
||||
ID: pd.Package.Name,
|
||||
IconURL: metadata.IconURL,
|
||||
Language: metadata.Language,
|
||||
LastUpdated: createdValue,
|
||||
LicenseURL: metadata.LicenseURL,
|
||||
MinClientVersion: metadata.MinClientVersion,
|
||||
NormalizedVersion: pd.Version.Version,
|
||||
Owners: metadata.Owners,
|
||||
PackageSize: TypedValue[int64]{Type: "Edm.Int64", Value: pd.CalculateBlobSize()},
|
||||
ProjectURL: metadata.ProjectURL,
|
||||
Published: createdValue,
|
||||
ReleaseNotes: metadata.ReleaseNotes,
|
||||
RequireLicenseAcceptance: TypedValue[bool]{Type: "Edm.Boolean", Value: metadata.RequireLicenseAcceptance},
|
||||
Tags: metadata.Tags,
|
||||
Title: metadata.Title,
|
||||
Version: pd.Version.Version,
|
||||
VersionDownloadCount: TypedValue[int64]{Type: "Edm.Int64", Value: pd.Version.DownloadCount},
|
||||
},
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package nuget
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
packages_model "gitea.dev/models/packages"
|
||||
nuget_module "gitea.dev/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"`
|
||||
Authors string `json:"authors"`
|
||||
Copyright string `json:"copyright"`
|
||||
DependencyGroups []*PackageDependencyGroup `json:"dependencyGroups"`
|
||||
Description string `json:"description"`
|
||||
IconURL string `json:"iconUrl"`
|
||||
ID string `json:"id"`
|
||||
IsPrerelease bool `json:"isPrerelease"`
|
||||
Language string `json:"language"`
|
||||
LicenseURL string `json:"licenseUrl"`
|
||||
PackageContentURL string `json:"packageContent"`
|
||||
ProjectURL string `json:"projectUrl"`
|
||||
RequireLicenseAcceptance bool `json:"requireLicenseAcceptance"`
|
||||
Summary string `json:"summary"`
|
||||
Tags string `json:"tags"`
|
||||
Version string `json:"version"`
|
||||
ReleaseNotes string `json:"releaseNotes"`
|
||||
Published time.Time `json:"published"`
|
||||
}
|
||||
|
||||
// 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),
|
||||
Authors: metadata.Authors,
|
||||
Copyright: metadata.Copyright,
|
||||
DependencyGroups: createDependencyGroups(pd),
|
||||
Description: metadata.Description,
|
||||
IconURL: metadata.IconURL,
|
||||
ID: pd.Package.Name,
|
||||
IsPrerelease: pd.Version.IsPrerelease(),
|
||||
Language: metadata.Language,
|
||||
LicenseURL: metadata.LicenseURL,
|
||||
PackageContentURL: l.GetPackageDownloadURL(pd.Package.Name, pd.Version.Version),
|
||||
ProjectURL: metadata.ProjectURL,
|
||||
RequireLicenseAcceptance: metadata.RequireLicenseAcceptance,
|
||||
Summary: metadata.Summary,
|
||||
Tags: metadata.Tags,
|
||||
Version: pd.Version.Version,
|
||||
ReleaseNotes: metadata.ReleaseNotes,
|
||||
Published: pd.Version.CreatedUnix.AsLocalTime(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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"`
|
||||
PackageContentURL string `json:"packageContent"`
|
||||
RegistrationIndexURL string `json:"registration"`
|
||||
CatalogEntry CatalogEntry `json:"catalogEntry"`
|
||||
}
|
||||
|
||||
func createRegistrationLeafResponse(l *linkBuilder, pd *packages_model.PackageDescriptor) *RegistrationLeafResponse {
|
||||
registrationLeafURL := l.GetRegistrationLeafURL(pd.Package.Name, pd.Version.Version)
|
||||
packageDownloadURL := l.GetPackageDownloadURL(pd.Package.Name, pd.Version.Version)
|
||||
metadata := pd.Metadata.(*nuget_module.Metadata)
|
||||
return &RegistrationLeafResponse{
|
||||
RegistrationLeafURL: registrationLeafURL,
|
||||
RegistrationIndexURL: l.GetRegistrationIndexURL(pd.Package.Name),
|
||||
PackageContentURL: packageDownloadURL,
|
||||
Type: []string{"Package", "http://schema.nuget.org/catalog#Permalink"},
|
||||
CatalogEntry: CatalogEntry{
|
||||
CatalogLeafURL: registrationLeafURL,
|
||||
Authors: metadata.Authors,
|
||||
Copyright: metadata.Copyright,
|
||||
DependencyGroups: createDependencyGroups(pd),
|
||||
Description: metadata.Description,
|
||||
IconURL: metadata.IconURL,
|
||||
ID: pd.Package.Name,
|
||||
IsPrerelease: pd.Version.IsPrerelease(),
|
||||
Language: metadata.Language,
|
||||
LicenseURL: metadata.LicenseURL,
|
||||
PackageContentURL: packageDownloadURL,
|
||||
ProjectURL: metadata.ProjectURL,
|
||||
RequireLicenseAcceptance: metadata.RequireLicenseAcceptance,
|
||||
Summary: metadata.Summary,
|
||||
Tags: metadata.Tags,
|
||||
Version: pd.Version.Version,
|
||||
ReleaseNotes: metadata.ReleaseNotes,
|
||||
Published: pd.Version.CreatedUnix.AsLocalTime(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
Authors string `json:"authors"`
|
||||
Copyright string `json:"copyright"`
|
||||
DependencyGroups []*PackageDependencyGroup `json:"dependencyGroups"`
|
||||
Description string `json:"description"`
|
||||
IconURL string `json:"iconUrl"`
|
||||
ID string `json:"id"`
|
||||
IsPrerelease bool `json:"isPrerelease"`
|
||||
Language string `json:"language"`
|
||||
LicenseURL string `json:"licenseUrl"`
|
||||
ProjectURL string `json:"projectUrl"`
|
||||
RequireLicenseAcceptance bool `json:"requireLicenseAcceptance"`
|
||||
Summary string `json:"summary"`
|
||||
Tags string `json:"tags"`
|
||||
Title string `json:"title"`
|
||||
TotalDownloads int64 `json:"totalDownloads"`
|
||||
Version string `json:"version"`
|
||||
Versions []*SearchResultVersion `json:"versions"`
|
||||
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))
|
||||
totalDownloads := int64(0)
|
||||
for _, pd := range pds {
|
||||
if latest.SemVer.LessThan(pd.SemVer) {
|
||||
latest = pd
|
||||
}
|
||||
totalDownloads += pd.Version.DownloadCount
|
||||
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{
|
||||
Authors: metadata.Authors,
|
||||
Copyright: metadata.Copyright,
|
||||
Description: metadata.Description,
|
||||
DependencyGroups: createDependencyGroups(latest),
|
||||
IconURL: metadata.IconURL,
|
||||
ID: latest.Package.Name,
|
||||
IsPrerelease: latest.Version.IsPrerelease(),
|
||||
Language: metadata.Language,
|
||||
LicenseURL: metadata.LicenseURL,
|
||||
ProjectURL: metadata.ProjectURL,
|
||||
RequireLicenseAcceptance: metadata.RequireLicenseAcceptance,
|
||||
Summary: metadata.Summary,
|
||||
Tags: metadata.Tags,
|
||||
Title: metadata.Title,
|
||||
TotalDownloads: totalDownloads,
|
||||
Version: latest.Version.Version,
|
||||
Versions: versions,
|
||||
RegistrationIndexURL: l.GetRegistrationIndexURL(latest.Package.Name),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package nuget
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/services/auth"
|
||||
)
|
||||
|
||||
var _ auth.Method = &Auth{}
|
||||
|
||||
type Auth struct {
|
||||
basicAuth auth.Basic
|
||||
}
|
||||
|
||||
func (a *Auth) Name() string {
|
||||
return "nuget"
|
||||
}
|
||||
|
||||
func (a *Auth) Verify(req *http.Request, w http.ResponseWriter, store auth.DataStore, sess auth.SessionStore) (*user_model.User, error) {
|
||||
// ref: https://docs.microsoft.com/en-us/nuget/api/package-publish-resource#request-parameters
|
||||
return a.basicAuth.VerifyAuthToken(req, w, store, sess, req.Header.Get("X-NuGet-ApiKey"))
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,705 @@
|
||||
// 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"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
packages_model "gitea.dev/models/packages"
|
||||
nuget_model "gitea.dev/models/packages/nuget"
|
||||
"gitea.dev/modules/optional"
|
||||
packages_module "gitea.dev/modules/packages"
|
||||
nuget_module "gitea.dev/modules/packages/nuget"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/routers/api/packages/helper"
|
||||
"gitea.dev/services/context"
|
||||
packages_service "gitea.dev/services/packages"
|
||||
)
|
||||
|
||||
func apiError(ctx *context.Context, status int, obj any) {
|
||||
message := helper.ProcessErrorForUser(ctx, status, obj)
|
||||
ctx.JSON(status, map[string]string{
|
||||
"Message": message,
|
||||
})
|
||||
}
|
||||
|
||||
func xmlResponse(ctx *context.Context, status int, obj any) { //nolint:unparam // status is always StatusOK
|
||||
ctx.Resp.Header().Set("Content-Type", "application/atom+xml; charset=utf-8")
|
||||
ctx.Resp.WriteHeader(status)
|
||||
_, _ = ctx.Resp.Write([]byte(xml.Header))
|
||||
_ = xml.NewEncoder(ctx.Resp).Encode(obj)
|
||||
}
|
||||
|
||||
// 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.PathParam("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.PathParam("id")
|
||||
packageVersion := ctx.PathParam("version")
|
||||
|
||||
pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeNuGet, packageName, packageVersion)
|
||||
if err != nil {
|
||||
if errors.Is(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.PathParam("id")
|
||||
packageVersion := strings.TrimSuffix(ctx.PathParam("version"), ".json")
|
||||
|
||||
pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeNuGet, packageName, packageVersion)
|
||||
if err != nil {
|
||||
if errors.Is(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.PathParam("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.PathParam("id")
|
||||
packageVersion := ctx.PathParam("version")
|
||||
filename := ctx.PathParam("filename")
|
||||
|
||||
s, u, pf, err := packages_service.OpenFileForDownloadByPackageNameAndVersion(
|
||||
ctx,
|
||||
&packages_service.PackageInfo{
|
||||
Owner: ctx.Package.Owner,
|
||||
PackageType: packages_model.TypeNuGet,
|
||||
Name: packageName,
|
||||
Version: packageVersion,
|
||||
},
|
||||
&packages_service.PackageFileInfo{
|
||||
Filename: filename,
|
||||
},
|
||||
ctx.Req.Method,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageNotExist) || errors.Is(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(np.ID + ".nuspec"),
|
||||
},
|
||||
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.PathParam("filename")
|
||||
guid := ctx.PathParam("guid")[:32]
|
||||
filename2 := ctx.PathParam("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.OpenFileForDownload(ctx, pfs[0], ctx.Req.Method)
|
||||
if err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageNotExist) || errors.Is(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.PathParam("id")
|
||||
packageVersion := ctx.PathParam("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 errors.Is(err, packages_model.ErrPackageNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
// 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 "gitea.dev/models/packages"
|
||||
"gitea.dev/modules/json"
|
||||
packages_module "gitea.dev/modules/packages"
|
||||
pub_module "gitea.dev/modules/packages/pub"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/routers/api/packages/helper"
|
||||
"gitea.dev/services/context"
|
||||
packages_service "gitea.dev/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)
|
||||
_ = json.NewEncoder(resp).Encode(obj)
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
message := helper.ProcessErrorForUser(ctx, status, obj)
|
||||
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.PathParam("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.PathParam("id")
|
||||
packageVersion := ctx.PathParam("version")
|
||||
|
||||
pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypePub, packageName, packageVersion)
|
||||
if err != nil {
|
||||
if errors.Is(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.PathParam("id")
|
||||
packageVersion := ctx.PathParam("version")
|
||||
|
||||
_, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypePub, packageName, packageVersion)
|
||||
if err != nil {
|
||||
if errors.Is(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.PathParam("id")
|
||||
packageVersion := strings.TrimSuffix(ctx.PathParam("version"), ".tar.gz")
|
||||
|
||||
pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypePub, packageName, packageVersion)
|
||||
if err != nil {
|
||||
if errors.Is(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.OpenFileForDownload(ctx, pf, ctx.Req.Method)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
helper.ServePackageFile(ctx, s, u, pf)
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package pypi
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
packages_model "gitea.dev/models/packages"
|
||||
packages_module "gitea.dev/modules/packages"
|
||||
pypi_module "gitea.dev/modules/packages/pypi"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/validation"
|
||||
"gitea.dev/routers/api/packages/helper"
|
||||
"gitea.dev/services/context"
|
||||
packages_service "gitea.dev/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) {
|
||||
message := helper.ProcessErrorForUser(ctx, status, obj)
|
||||
ctx.PlainText(status, message)
|
||||
}
|
||||
|
||||
// PackageMetadata returns the metadata for a single package
|
||||
func PackageMetadata(ctx *context.Context) {
|
||||
packageName := normalizer.Replace(ctx.PathParam("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.PathParam("id"))
|
||||
packageVersion := ctx.PathParam("version")
|
||||
filename := ctx.PathParam("filename")
|
||||
|
||||
s, u, pf, err := packages_service.OpenFileForDownloadByPackageNameAndVersion(
|
||||
ctx,
|
||||
&packages_service.PackageInfo{
|
||||
Owner: ctx.Package.Owner,
|
||||
PackageType: packages_model.TypePyPI,
|
||||
Name: packageName,
|
||||
Version: packageVersion,
|
||||
},
|
||||
&packages_service.PackageFileInfo{
|
||||
Filename: filename,
|
||||
},
|
||||
ctx.Req.Method,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageNotExist) || errors.Is(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
|
||||
}
|
||||
|
||||
// Ensure ctx.Req.Form exists.
|
||||
_ = ctx.Req.ParseForm()
|
||||
|
||||
var homepageURL string
|
||||
projectURLs := ctx.Req.Form["project_urls"]
|
||||
for _, purl := range projectURLs {
|
||||
label, url, found := strings.Cut(purl, ",")
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
if normalizeLabel(label) != "homepage" {
|
||||
continue
|
||||
}
|
||||
homepageURL = strings.TrimSpace(url)
|
||||
break
|
||||
}
|
||||
|
||||
if len(homepageURL) == 0 {
|
||||
// TODO: Home-page is a deprecated metadata field. Remove this branch once it's no longer apart of the spec.
|
||||
homepageURL = ctx.Req.FormValue("home_page")
|
||||
}
|
||||
|
||||
if !validation.IsValidURL(homepageURL) {
|
||||
homepageURL = ""
|
||||
}
|
||||
|
||||
_, _, 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: homepageURL,
|
||||
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)
|
||||
}
|
||||
|
||||
// Normalizes a Project-URL label.
|
||||
// See https://packaging.python.org/en/latest/specifications/well-known-project-urls/#label-normalization.
|
||||
func normalizeLabel(label string) string {
|
||||
var builder strings.Builder
|
||||
|
||||
// "A label is normalized by deleting all ASCII punctuation and whitespace, and then converting the result
|
||||
// to lowercase."
|
||||
for _, r := range label {
|
||||
if unicode.IsPunct(r) || unicode.IsSpace(r) {
|
||||
continue
|
||||
}
|
||||
builder.WriteRune(unicode.ToLower(r))
|
||||
}
|
||||
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func isValidNameAndVersion(packageName, packageVersion string) bool {
|
||||
return nameMatcher.MatchString(packageName) && versionMatcher.MatchString(packageVersion)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// 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"))
|
||||
}
|
||||
|
||||
func TestNormalizeLabel(t *testing.T) {
|
||||
// Cases fetched from https://packaging.python.org/en/latest/specifications/well-known-project-urls/#label-normalization.
|
||||
assert.Equal(t, "homepage", normalizeLabel("Homepage"))
|
||||
assert.Equal(t, "homepage", normalizeLabel("Home-page"))
|
||||
assert.Equal(t, "homepage", normalizeLabel("Home page"))
|
||||
assert.Equal(t, "changelog", normalizeLabel("Change_Log"))
|
||||
assert.Equal(t, "whatsnew", normalizeLabel("What's New?"))
|
||||
assert.Equal(t, "github", normalizeLabel("github"))
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package rpm
|
||||
|
||||
import (
|
||||
stdctx "context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
packages_model "gitea.dev/models/packages"
|
||||
"gitea.dev/modules/json"
|
||||
packages_module "gitea.dev/modules/packages"
|
||||
rpm_module "gitea.dev/modules/packages/rpm"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/routers/api/packages/helper"
|
||||
"gitea.dev/services/context"
|
||||
notify_service "gitea.dev/services/notify"
|
||||
packages_service "gitea.dev/services/packages"
|
||||
rpm_service "gitea.dev/services/packages/rpm"
|
||||
)
|
||||
|
||||
func apiError(ctx *context.Context, status int, obj any) {
|
||||
message := helper.ProcessErrorForUser(ctx, status, obj)
|
||||
ctx.PlainText(status, message)
|
||||
}
|
||||
|
||||
// https://dnf.readthedocs.io/en/latest/conf_ref.html
|
||||
func GetRepositoryConfig(ctx *context.Context) {
|
||||
group := ctx.PathParam("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.PathParam("filename"), ctx.PathParam("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.OpenFileForDownloadByPackageVersion(
|
||||
ctx,
|
||||
pv,
|
||||
&packages_service.PackageFileInfo{
|
||||
Filename: ctx.PathParam("filename"),
|
||||
CompositeKey: ctx.PathParam("group"),
|
||||
},
|
||||
ctx.Req.Method,
|
||||
)
|
||||
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 setting.Packages.DefaultRPMSignEnabled || ctx.FormBool("sign") {
|
||||
priv, _, err := rpm_service.GetOrCreateKeyPair(ctx, ctx.Package.Owner.ID)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
signedBuf, err := rpm_service.SignPackage(buf, priv)
|
||||
if err != nil {
|
||||
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.PathParam("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.PathParam("name")
|
||||
version := ctx.PathParam("version")
|
||||
architecture := ctx.PathParam("architecture")
|
||||
group := ctx.PathParam("group")
|
||||
|
||||
openForDownload := func(filename string) (io.ReadSeekCloser, *url.URL, *packages_model.PackageFile, error) {
|
||||
return packages_service.OpenFileForDownloadByPackageNameAndVersion(
|
||||
ctx,
|
||||
&packages_service.PackageInfo{
|
||||
Owner: ctx.Package.Owner,
|
||||
PackageType: packages_model.TypeRpm,
|
||||
Name: name,
|
||||
Version: version,
|
||||
},
|
||||
&packages_service.PackageFileInfo{
|
||||
Filename: filename,
|
||||
CompositeKey: group,
|
||||
},
|
||||
ctx.Req.Method,
|
||||
)
|
||||
}
|
||||
|
||||
s, u, pf, err := openForDownload(fmt.Sprintf("%s-%s.%s.rpm", name, version, architecture))
|
||||
if errors.Is(err, util.ErrNotExist) && architecture != "noarch" {
|
||||
s, u, pf, err = openForDownload(fmt.Sprintf("%s-%s.%s.rpm", name, version, "noarch"))
|
||||
}
|
||||
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
return
|
||||
} else if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
helper.ServePackageFile(ctx, s, u, pf)
|
||||
}
|
||||
|
||||
func DeletePackageFile(webctx *context.Context) {
|
||||
group := webctx.PathParam("group")
|
||||
name := webctx.PathParam("name")
|
||||
version := webctx.PathParam("version")
|
||||
architecture := webctx.PathParam("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)
|
||||
}
|
||||
|
||||
// UploadErrata handles uploading errata information for a package version
|
||||
func UploadErrata(ctx *context.Context) {
|
||||
name := ctx.PathParam("name")
|
||||
version := ctx.PathParam("version")
|
||||
group := ctx.PathParam("group")
|
||||
|
||||
var updates []*rpm_module.Update
|
||||
if err := json.NewDecoder(ctx.Req.Body).Decode(&updates); err != nil {
|
||||
apiError(ctx, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
pv, err := packages_model.GetVersionByNameAndVersion(ctx,
|
||||
ctx.Package.Owner.ID,
|
||||
packages_model.TypeRpm,
|
||||
name,
|
||||
version,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
var vm *rpm_module.VersionMetadata
|
||||
if pv.MetadataJSON != "" {
|
||||
if err := json.Unmarshal([]byte(pv.MetadataJSON), &vm); err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
vm = &rpm_module.VersionMetadata{}
|
||||
}
|
||||
|
||||
now := time.Now().Format("2006-01-02 15:04:05")
|
||||
for _, u := range updates {
|
||||
if u == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Sanitize to remove nil elements from JSON payload
|
||||
var cleanPkgList []*rpm_module.Collection
|
||||
for _, coll := range u.PkgList {
|
||||
if coll == nil {
|
||||
continue
|
||||
}
|
||||
var cleanPackages []*rpm_module.UpdatePackage
|
||||
for _, pkg := range coll.Packages {
|
||||
if pkg == nil {
|
||||
continue
|
||||
}
|
||||
cleanPackages = append(cleanPackages, pkg)
|
||||
}
|
||||
coll.Packages = cleanPackages
|
||||
cleanPkgList = append(cleanPkgList, coll)
|
||||
}
|
||||
u.PkgList = cleanPkgList
|
||||
|
||||
found := false
|
||||
for i, existing := range vm.Updates {
|
||||
if existing.ID == u.ID {
|
||||
// Merge PkgList with deduplication
|
||||
for _, newColl := range u.PkgList {
|
||||
if newColl == nil {
|
||||
continue
|
||||
}
|
||||
collFound := false
|
||||
for j, existingColl := range existing.PkgList {
|
||||
if existingColl.Short == newColl.Short {
|
||||
// Merge packages
|
||||
for _, newPkg := range newColl.Packages {
|
||||
if newPkg == nil {
|
||||
continue
|
||||
}
|
||||
pkgFound := false
|
||||
for _, existingPkg := range existingColl.Packages {
|
||||
if existingPkg.Name == newPkg.Name &&
|
||||
existingPkg.Version == newPkg.Version &&
|
||||
existingPkg.Release == newPkg.Release &&
|
||||
existingPkg.Arch == newPkg.Arch {
|
||||
pkgFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !pkgFound {
|
||||
vm.Updates[i].PkgList[j].Packages = append(vm.Updates[i].PkgList[j].Packages, newPkg)
|
||||
}
|
||||
}
|
||||
collFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !collFound {
|
||||
vm.Updates[i].PkgList = append(vm.Updates[i].PkgList, newColl)
|
||||
}
|
||||
}
|
||||
vm.Updates[i].From = u.From
|
||||
vm.Updates[i].Status = u.Status
|
||||
vm.Updates[i].Type = u.Type
|
||||
vm.Updates[i].Version = u.Version
|
||||
vm.Updates[i].Title = u.Title
|
||||
vm.Updates[i].Severity = u.Severity
|
||||
vm.Updates[i].Description = u.Description
|
||||
vm.Updates[i].References = u.References
|
||||
vm.Updates[i].Updated = &rpm_module.DateAttr{Date: now}
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
if u.Issued == nil {
|
||||
u.Issued = &rpm_module.DateAttr{Date: now}
|
||||
}
|
||||
if u.Updated == nil {
|
||||
u.Updated = &rpm_module.DateAttr{Date: now}
|
||||
}
|
||||
vm.Updates = append(vm.Updates, u)
|
||||
}
|
||||
}
|
||||
|
||||
vmBytes, err := json.Marshal(vm)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
pv.MetadataJSON = string(vmBytes)
|
||||
if err := packages_model.UpdateVersion(ctx, pv); err != nil {
|
||||
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.StatusOK)
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
// 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 "gitea.dev/models/packages"
|
||||
"gitea.dev/modules/cache"
|
||||
"gitea.dev/modules/optional"
|
||||
packages_module "gitea.dev/modules/packages"
|
||||
rubygems_module "gitea.dev/modules/packages/rubygems"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/routers/api/packages/helper"
|
||||
"gitea.dev/services/context"
|
||||
packages_service "gitea.dev/services/packages"
|
||||
)
|
||||
|
||||
func apiError(ctx *context.Context, status int, obj any) {
|
||||
message := helper.ProcessErrorForUser(ctx, status, obj)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// ServePackageSpecification serves the compressed Gemspec file of a package
|
||||
func ServePackageSpecification(ctx *context.Context) {
|
||||
filename := ctx.PathParam("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.PathParam("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.OpenFileForDownloadByPackageVersion(
|
||||
ctx,
|
||||
pvs[0],
|
||||
&packages_service.PackageFileInfo{
|
||||
Filename: filename,
|
||||
},
|
||||
ctx.Req.Method,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(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 := makeGemFullFileName(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 errors.Is(err, packages_model.ErrPackageNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
}
|
||||
|
||||
// GetPackageInfo returns a custom text based format for the single rubygem with a line for each version of the rubygem
|
||||
// ref: https://guides.rubygems.org/rubygems-org-compact-index-api/
|
||||
func GetPackageInfo(ctx *context.Context) {
|
||||
packageName := ctx.PathParam("packagename")
|
||||
versions, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeRubyGems, packageName)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if len(versions) == 0 {
|
||||
apiError(ctx, http.StatusNotFound, nil)
|
||||
return
|
||||
}
|
||||
infoContent, err := makePackageInfo(ctx, versions, cache.NewEphemeralCache())
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
ctx.PlainText(http.StatusOK, infoContent)
|
||||
}
|
||||
|
||||
// GetAllPackagesVersions returns a custom text-based format containing information about all versions of all rubygems.
|
||||
// ref: https://guides.rubygems.org/rubygems-org-compact-index-api/
|
||||
func GetAllPackagesVersions(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
|
||||
}
|
||||
|
||||
ephemeralCache := cache.NewEphemeralCache()
|
||||
out := &strings.Builder{}
|
||||
out.WriteString("---\n")
|
||||
for _, pkg := range packages {
|
||||
versions, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeRubyGems, pkg.Name)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if len(versions) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
info, err := makePackageInfo(ctx, versions, ephemeralCache)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
// format: RUBYGEM [-]VERSION_PLATFORM[,VERSION_PLATFORM],...] MD5
|
||||
_, _ = fmt.Fprintf(out, "%s ", pkg.Name)
|
||||
for i, v := range versions {
|
||||
sep := util.Iif(i == len(versions)-1, "", ",")
|
||||
pd, err := packages_model.GetPackageDescriptorWithCache(ctx, v, ephemeralCache)
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
continue
|
||||
} else if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
writePackageVersionForList(pd.Metadata, v.Version, sep, out)
|
||||
}
|
||||
_, _ = fmt.Fprintf(out, " %x\n", md5.Sum([]byte(info)))
|
||||
}
|
||||
|
||||
ctx.PlainText(http.StatusOK, out.String())
|
||||
}
|
||||
|
||||
func writePackageVersionForList(metadata any, version, sep string, out *strings.Builder) {
|
||||
if metadata, _ := metadata.(*rubygems_module.Metadata); metadata != nil && metadata.Platform != "" && metadata.Platform != "ruby" {
|
||||
// VERSION_PLATFORM (see comment above in GetAllPackagesVersions)
|
||||
_, _ = fmt.Fprintf(out, "%s_%s%s", version, metadata.Platform, sep)
|
||||
} else {
|
||||
// VERSION only
|
||||
_, _ = fmt.Fprintf(out, "%s%s", version, sep)
|
||||
}
|
||||
}
|
||||
|
||||
func writePackageVersionRequirements(prefix string, reqs []rubygems_module.VersionRequirement, out *strings.Builder) {
|
||||
out.WriteString(prefix)
|
||||
if len(reqs) == 0 {
|
||||
reqs = []rubygems_module.VersionRequirement{{Restriction: ">=", Version: "0"}}
|
||||
}
|
||||
for i, req := range reqs {
|
||||
sep := util.Iif(i == 0, "", "&")
|
||||
_, _ = fmt.Fprintf(out, "%s%s %s", sep, req.Restriction, req.Version)
|
||||
}
|
||||
}
|
||||
|
||||
func writePackageVersionForDependency(version, platform string, out *strings.Builder) {
|
||||
if platform != "" && platform != "ruby" {
|
||||
// VERSION-PLATFORM (see comment below in makePackageVersionDependency)
|
||||
_, _ = fmt.Fprintf(out, "%s-%s ", version, platform)
|
||||
} else {
|
||||
// VERSION only
|
||||
_, _ = fmt.Fprintf(out, "%s ", version)
|
||||
}
|
||||
}
|
||||
|
||||
func makePackageVersionDependency(ctx *context.Context, version *packages_model.PackageVersion, c *cache.EphemeralCache) (string, error) {
|
||||
// format: VERSION[-PLATFORM] [DEPENDENCY[,DEPENDENCY,...]]|REQUIREMENT[,REQUIREMENT,...]
|
||||
// DEPENDENCY: GEM:CONSTRAINT[&CONSTRAINT]
|
||||
// REQUIREMENT: KEY:VALUE (always contains "checksum")
|
||||
pd, err := packages_model.GetPackageDescriptorWithCache(ctx, version, c)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
metadata := pd.Metadata.(*rubygems_module.Metadata)
|
||||
fullFilename := makeGemFullFileName(pd.Package.Name, version.Version, metadata.Platform)
|
||||
file, err := packages_model.GetFileForVersionByName(ctx, version.ID, fullFilename, "")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
blob, err := packages_model.GetBlobByID(ctx, file.BlobID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
buf := &strings.Builder{}
|
||||
writePackageVersionForDependency(version.Version, metadata.Platform, buf)
|
||||
for i, dep := range metadata.RuntimeDependencies {
|
||||
sep := util.Iif(i == 0, "", ",")
|
||||
writePackageVersionRequirements(fmt.Sprintf("%s%s:", sep, dep.Name), dep.Version, buf)
|
||||
}
|
||||
_, _ = fmt.Fprintf(buf, "|checksum:%s", blob.HashSHA256)
|
||||
if len(metadata.RequiredRubyVersion) != 0 {
|
||||
writePackageVersionRequirements(",ruby:", metadata.RequiredRubyVersion, buf)
|
||||
}
|
||||
if len(metadata.RequiredRubygemsVersion) != 0 {
|
||||
writePackageVersionRequirements(",rubygems:", metadata.RequiredRubygemsVersion, buf)
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
func makePackageInfo(ctx *context.Context, versions []*packages_model.PackageVersion, c *cache.EphemeralCache) (string, error) {
|
||||
var ret strings.Builder
|
||||
ret.WriteString("---\n")
|
||||
for _, v := range versions {
|
||||
dep, err := makePackageVersionDependency(ctx, v, c)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
ret.WriteString(dep + "\n")
|
||||
}
|
||||
return ret.String(), nil
|
||||
}
|
||||
|
||||
func makeGemFullFileName(gemName, version, platform string) string {
|
||||
var basename string
|
||||
if platform == "" || platform == "ruby" {
|
||||
basename = fmt.Sprintf("%s-%s", gemName, version)
|
||||
} else {
|
||||
basename = fmt.Sprintf("%s-%s-%s", gemName, version, platform)
|
||||
}
|
||||
return strings.ToLower(basename) + ".gem"
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package rubygems
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
rubygems_module "gitea.dev/modules/packages/rubygems"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestWritePackageVersion(t *testing.T) {
|
||||
buf := &strings.Builder{}
|
||||
|
||||
writePackageVersionForList(nil, "1.0", " ", buf)
|
||||
assert.Equal(t, "1.0 ", buf.String())
|
||||
buf.Reset()
|
||||
|
||||
writePackageVersionForList(&rubygems_module.Metadata{Platform: "ruby"}, "1.0", " ", buf)
|
||||
assert.Equal(t, "1.0 ", buf.String())
|
||||
buf.Reset()
|
||||
|
||||
writePackageVersionForList(&rubygems_module.Metadata{Platform: "linux"}, "1.0", " ", buf)
|
||||
assert.Equal(t, "1.0_linux ", buf.String())
|
||||
buf.Reset()
|
||||
|
||||
writePackageVersionForDependency("1.0", "", buf)
|
||||
assert.Equal(t, "1.0 ", buf.String())
|
||||
buf.Reset()
|
||||
|
||||
writePackageVersionForDependency("1.0", "ruby", buf)
|
||||
assert.Equal(t, "1.0 ", buf.String())
|
||||
buf.Reset()
|
||||
|
||||
writePackageVersionForDependency("1.0", "os", buf)
|
||||
assert.Equal(t, "1.0-os ", buf.String())
|
||||
buf.Reset()
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
// 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 "gitea.dev/models/packages"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/optional"
|
||||
packages_module "gitea.dev/modules/packages"
|
||||
swift_module "gitea.dev/modules/packages/swift"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/routers/api/packages/helper"
|
||||
"gitea.dev/services/context"
|
||||
packages_service "gitea.dev/services/packages"
|
||||
|
||||
"github.com/hashicorp/go-version"
|
||||
)
|
||||
|
||||
// https://github.com/swiftlang/swift-package-manager/blob/main/Documentation/PackageRegistry/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/swiftlang/swift-package-manager/blob/main/Documentation/PackageRegistry/Registry.md#361-package-scope
|
||||
scopePattern = regexp.MustCompile(`\A[a-zA-Z0-9][a-zA-Z0-9-]{0,38}\z`)
|
||||
// https://github.com/swiftlang/swift-package-manager/blob/main/Documentation/PackageRegistry/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/swiftlang/swift-package-manager/blob/main/Documentation/PackageRegistry/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/swiftlang/swift-package-manager/blob/main/Documentation/PackageRegistry/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"`
|
||||
}
|
||||
|
||||
message := helper.ProcessErrorForUser(ctx, status, obj)
|
||||
setResponseHeaders(ctx.Resp, &headers{
|
||||
Status: status,
|
||||
ContentType: "application/problem+json",
|
||||
})
|
||||
_ = json.NewEncoder(ctx.Resp).Encode(Problem{
|
||||
Status: status,
|
||||
Detail: message,
|
||||
})
|
||||
}
|
||||
|
||||
// https://github.com/swiftlang/swift-package-manager/blob/main/Documentation/PackageRegistry/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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// https://github.com/swiftlang/swift-package-manager/blob/main/Documentation/PackageRegistry/PackageRegistryUsage.md#registry-authentication
|
||||
func CheckAuthenticate(ctx *context.Context) {
|
||||
if ctx.Doer == nil {
|
||||
apiError(ctx, http.StatusUnauthorized, nil)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
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/swiftlang/swift-package-manager/blob/main/Documentation/PackageRegistry/Registry.md#41-list-package-releases
|
||||
func EnumeratePackageVersions(ctx *context.Context) {
|
||||
packageScope := ctx.PathParam("scope")
|
||||
packageName := ctx.PathParam("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/swiftlang/swift-package-manager/blob/main/Documentation/PackageRegistry/Registry.md#endpoint-2
|
||||
func PackageVersionMetadata(ctx *context.Context) {
|
||||
id := buildPackageID(ctx.PathParam("scope"), ctx.PathParam("name"))
|
||||
|
||||
pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeSwift, id, ctx.PathParam("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)
|
||||
repositoryURLs := make([]string, 0, len(pd.VersionProperties))
|
||||
for _, property := range pd.VersionProperties {
|
||||
if property.Name == swift_module.PropertyRepositoryURL {
|
||||
repositoryURLs = append(repositoryURLs, property.Value)
|
||||
}
|
||||
}
|
||||
|
||||
var author *swift_module.Person
|
||||
if metadata.Author.Name != "" || metadata.Author.GivenName != "" || metadata.Author.MiddleName != "" || metadata.Author.FamilyName != "" {
|
||||
author = &swift_module.Person{
|
||||
Type: "Person",
|
||||
Name: metadata.Author.Name,
|
||||
GivenName: metadata.Author.GivenName,
|
||||
MiddleName: metadata.Author.MiddleName,
|
||||
FamilyName: metadata.Author.FamilyName,
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
LicenseURL: metadata.LicenseURL,
|
||||
Author: author,
|
||||
ProgrammingLanguage: swift_module.ProgrammingLanguage{
|
||||
Type: "ComputerLanguage",
|
||||
Name: "Swift",
|
||||
URL: "https://swift.org",
|
||||
},
|
||||
RepositoryURLs: repositoryURLs,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// https://github.com/swiftlang/swift-package-manager/blob/main/Documentation/PackageRegistry/Registry.md#43-fetch-manifest-for-a-package-release
|
||||
func DownloadManifest(ctx *context.Context) {
|
||||
packageScope := ctx.PathParam("scope")
|
||||
packageName := ctx.PathParam("name")
|
||||
packageVersion := ctx.PathParam("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(),
|
||||
})
|
||||
}
|
||||
|
||||
// formFileOptionalReadCloser returns (nil, nil) if the formKey is not present.
|
||||
func formFileOptionalReadCloser(ctx *context.Context, formKey string) (io.ReadCloser, error) {
|
||||
multipartFile, _, err := ctx.Req.FormFile(formKey)
|
||||
if err != nil && !errors.Is(err, http.ErrMissingFile) {
|
||||
return nil, err
|
||||
}
|
||||
if multipartFile != nil {
|
||||
return multipartFile, nil
|
||||
}
|
||||
|
||||
content := ctx.Req.FormValue(formKey)
|
||||
if content == "" {
|
||||
return nil, nil //nolint:nilnil // return nil to indicate that the content does not exist
|
||||
}
|
||||
return io.NopCloser(strings.NewReader(content)), nil
|
||||
}
|
||||
|
||||
// UploadPackageFile refers to https://github.com/swiftlang/swift-package-manager/blob/main/Documentation/PackageRegistry/Registry.md#endpoint-6
|
||||
func UploadPackageFile(ctx *context.Context) {
|
||||
packageScope := ctx.PathParam("scope")
|
||||
packageName := ctx.PathParam("name")
|
||||
|
||||
v, err := version.NewVersion(ctx.PathParam("version"))
|
||||
|
||||
if !scopePattern.MatchString(packageScope) || !namePattern.MatchString(packageName) || err != nil {
|
||||
apiError(ctx, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
packageVersion := v.Core().String()
|
||||
|
||||
file, err := formFileOptionalReadCloser(ctx, "source-archive")
|
||||
if file == nil || err != nil {
|
||||
apiError(ctx, http.StatusBadRequest, "unable to read source-archive file")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
buf, err := packages_module.CreateHashedBufferFromReader(file)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
defer buf.Close()
|
||||
|
||||
mr, err := formFileOptionalReadCloser(ctx, "metadata")
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusBadRequest, "unable to read metadata file")
|
||||
return
|
||||
}
|
||||
if mr != nil {
|
||||
defer mr.Close()
|
||||
}
|
||||
|
||||
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/swiftlang/swift-package-manager/blob/main/Documentation/PackageRegistry/Registry.md#endpoint-4
|
||||
func DownloadPackageFile(ctx *context.Context) {
|
||||
pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeSwift, buildPackageID(ctx.PathParam("scope"), ctx.PathParam("name")), ctx.PathParam("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.OpenFileForDownload(ctx, pf, ctx.Req.Method)
|
||||
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/swiftlang/swift-package-manager/blob/main/Documentation/PackageRegistry/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,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package terraform
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
packages_model "gitea.dev/models/packages"
|
||||
"gitea.dev/modules/globallock"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/optional"
|
||||
packages_module "gitea.dev/modules/packages"
|
||||
terraform_module "gitea.dev/modules/packages/terraform"
|
||||
"gitea.dev/routers/api/packages/helper"
|
||||
"gitea.dev/services/context"
|
||||
packages_service "gitea.dev/services/packages"
|
||||
)
|
||||
|
||||
var packageNameRegex = regexp.MustCompile(`\A[-_+.\w]+\z`)
|
||||
|
||||
const (
|
||||
stateFilename = "tfstate"
|
||||
)
|
||||
|
||||
func apiError(ctx *context.Context, status int, obj any) {
|
||||
message := helper.ProcessErrorForUser(ctx, status, obj)
|
||||
ctx.PlainText(status, message)
|
||||
}
|
||||
|
||||
// GetTerraformState serves the latest version of the state
|
||||
func GetTerraformState(ctx *context.Context) {
|
||||
stateName := ctx.PathParam("name")
|
||||
pv, err := getLatestVersion(ctx, stateName)
|
||||
if errors.Is(err, packages_model.ErrPackageNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, nil)
|
||||
return
|
||||
} else if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
streamState(ctx, stateName, pv.Version)
|
||||
}
|
||||
|
||||
// GetTerraformStateBySerial serves a specific version of terraform state.
|
||||
func GetTerraformStateBySerial(ctx *context.Context) {
|
||||
streamState(ctx, ctx.PathParam("name"), ctx.PathParam("serial"))
|
||||
}
|
||||
|
||||
// streamState serves the terraform state file
|
||||
func streamState(ctx *context.Context, name, serial string) {
|
||||
s, u, pf, err := packages_service.OpenFileForDownloadByPackageNameAndVersion(
|
||||
ctx,
|
||||
&packages_service.PackageInfo{
|
||||
Owner: ctx.Package.Owner,
|
||||
PackageType: packages_model.TypeTerraformState,
|
||||
Name: name,
|
||||
Version: serial,
|
||||
},
|
||||
&packages_service.PackageFileInfo{
|
||||
Filename: stateFilename,
|
||||
},
|
||||
ctx.Req.Method,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageNotExist) || errors.Is(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 != ".."
|
||||
}
|
||||
|
||||
// UploadState uploads the specific terraform package.
|
||||
func UploadState(ctx *context.Context) {
|
||||
packageName := ctx.PathParam("name")
|
||||
|
||||
if !isValidPackageName(packageName) {
|
||||
apiError(ctx, http.StatusBadRequest, errors.New("invalid package name"))
|
||||
return
|
||||
}
|
||||
lockKey := getLockKey(ctx)
|
||||
release, err := globallock.Lock(ctx, lockKey)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
defer release()
|
||||
|
||||
p, err := packages_model.GetPackageByName(ctx, ctx.Package.Owner.ID, packages_model.TypeTerraformState, packageName)
|
||||
if err != nil && !errors.Is(err, packages_model.ErrPackageNotExist) {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if p != nil {
|
||||
// Check lock
|
||||
lock, err := terraform_module.GetLock(ctx, p.ID)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
// If the state is locked, enforce the lock
|
||||
if lock.IsLocked() && lock.ID != ctx.FormString("ID") {
|
||||
ctx.JSON(http.StatusLocked, lock)
|
||||
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()
|
||||
|
||||
state, err := terraform_module.ParseState(buf)
|
||||
if err != nil {
|
||||
log.Error("Error decoding state: %v", err)
|
||||
apiError(ctx, http.StatusBadRequest, 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.TypeTerraformState,
|
||||
Name: packageName,
|
||||
Version: strconv.FormatUint(state.Serial, 10),
|
||||
},
|
||||
Creator: ctx.Doer,
|
||||
},
|
||||
&packages_service.PackageFileCreationInfo{
|
||||
PackageFileInfo: packages_service.PackageFileInfo{
|
||||
Filename: stateFilename,
|
||||
},
|
||||
Creator: ctx.Doer,
|
||||
Data: buf,
|
||||
IsLead: true,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
switch {
|
||||
case 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
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusCreated)
|
||||
}
|
||||
|
||||
// DeleteStateBySerial deletes the specific serial of a terraform package as long as it's not the latest one.
|
||||
func DeleteStateBySerial(ctx *context.Context) {
|
||||
lockKey := getLockKey(ctx)
|
||||
release, err := globallock.Lock(ctx, lockKey)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
defer release()
|
||||
|
||||
serial := ctx.PathParam("serial")
|
||||
pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeTerraformState, ctx.PathParam("name"), serial)
|
||||
if errors.Is(err, packages_model.ErrPackageNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
return
|
||||
} else if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
pvLatest, err := getLatestVersion(ctx, ctx.PathParam("name"))
|
||||
if errors.Is(err, packages_model.ErrPackageNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
return
|
||||
} else if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if pvLatest.ID == pv.ID {
|
||||
apiError(ctx, http.StatusForbidden, errors.New("cannot delete the latest version"))
|
||||
return
|
||||
}
|
||||
|
||||
err = packages_service.DeletePackageVersionAndReferences(ctx, pv)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// DeleteState deletes the specific file of a terraform package.
|
||||
// Fails if the state is locked
|
||||
func DeleteState(ctx *context.Context) {
|
||||
packageName := ctx.PathParam("name")
|
||||
|
||||
lockKey := getLockKey(ctx)
|
||||
release, err := globallock.Lock(ctx, lockKey)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
defer release()
|
||||
|
||||
p, err := packages_model.GetPackageByName(ctx, ctx.Package.Owner.ID, packages_model.TypeTerraformState, packageName)
|
||||
if err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
lock, err := terraform_module.GetLock(ctx, p.ID)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if lock.IsLocked() {
|
||||
apiError(ctx, http.StatusLocked, errors.New("terraform state is locked"))
|
||||
return
|
||||
}
|
||||
|
||||
pvs, _, err := packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{
|
||||
PackageID: p.ID,
|
||||
IsInternal: optional.None[bool](),
|
||||
})
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = packages_model.DeleteAllProperties(ctx, packages_model.PropertyTypePackage, p.ID)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, pv := range pvs {
|
||||
if err := packages_service.RemovePackageVersion(ctx, ctx.Doer, pv); err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := packages_model.DeletePackageByID(ctx, p.ID); err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
// LockState locks the specific terraform state.
|
||||
// Internally, it adds a property to the package with the lock information
|
||||
// Caveat being that it allocates a package if one doesn't exist to attach the property
|
||||
func LockState(ctx *context.Context) {
|
||||
packageName := ctx.PathParam("name")
|
||||
if !isValidPackageName(packageName) {
|
||||
apiError(ctx, http.StatusBadRequest, errors.New("invalid package name"))
|
||||
return
|
||||
}
|
||||
|
||||
var reqLockInfo *terraform_module.LockInfo
|
||||
reqLockInfo, err := terraform_module.ParseLockInfo(ctx.Req.Body)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
lockKey := getLockKey(ctx)
|
||||
release, err := globallock.Lock(ctx, lockKey)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
defer release()
|
||||
|
||||
p, err := packages_model.GetPackageByName(ctx, ctx.Package.Owner.ID, packages_model.TypeTerraformState, packageName)
|
||||
if err != nil {
|
||||
// If the package doesn't exist, allocate it for the lock.
|
||||
if errors.Is(err, packages_model.ErrPackageNotExist) {
|
||||
p = &packages_model.Package{
|
||||
OwnerID: ctx.Package.Owner.ID,
|
||||
Type: packages_model.TypeTerraformState,
|
||||
Name: packageName,
|
||||
LowerName: strings.ToLower(packageName),
|
||||
}
|
||||
if p, err = packages_model.TryInsertPackage(ctx, p); err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
currentLock, err := terraform_module.GetLock(ctx, p.ID)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
if currentLock.IsLocked() {
|
||||
ctx.JSON(http.StatusLocked, currentLock)
|
||||
return
|
||||
}
|
||||
|
||||
err = terraform_module.SetLock(ctx, p.ID, reqLockInfo)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
// UnlockState unlock the specific terraform state.
|
||||
// Internally, it clears the package property
|
||||
func UnlockState(ctx *context.Context) {
|
||||
packageName := ctx.PathParam("name")
|
||||
if !isValidPackageName(packageName) {
|
||||
apiError(ctx, http.StatusBadRequest, errors.New("invalid package name"))
|
||||
return
|
||||
}
|
||||
|
||||
reqLockInfo, err := terraform_module.ParseLockInfo(ctx.Req.Body)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
lockKey := getLockKey(ctx)
|
||||
release, err := globallock.Lock(ctx, lockKey)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
defer release()
|
||||
|
||||
p, err := packages_model.GetPackageByName(ctx, ctx.Package.Owner.ID, packages_model.TypeTerraformState, packageName)
|
||||
if err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageNotExist) {
|
||||
ctx.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
existingLock, err := terraform_module.GetLock(ctx, p.ID)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
// we can bypass messing with the lock since it's empty
|
||||
if !existingLock.IsLocked() {
|
||||
ctx.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
// Unlocking ID must be the same as locker one.
|
||||
if existingLock.ID != reqLockInfo.ID {
|
||||
apiError(ctx, http.StatusLocked, errors.New("lock ID mismatch"))
|
||||
return
|
||||
}
|
||||
// We can clear the state if lock id matches
|
||||
err = terraform_module.RemoveLock(ctx, p.ID)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
func getLatestVersion(ctx *context.Context, packageName string) (*packages_model.PackageVersion, error) {
|
||||
pvs, _, err := packages_model.SearchLatestVersions(ctx, &packages_model.PackageSearchOptions{
|
||||
OwnerID: ctx.Package.Owner.ID,
|
||||
Type: packages_model.TypeTerraformState,
|
||||
Name: packages_model.SearchValue{ExactMatch: true, Value: packageName},
|
||||
IsInternal: optional.Some(false),
|
||||
Sort: packages_model.SortCreatedDesc,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(pvs) == 0 {
|
||||
return nil, packages_model.ErrPackageNotExist
|
||||
}
|
||||
return pvs[0], nil
|
||||
}
|
||||
|
||||
func getLockKey(ctx *context.Context) string {
|
||||
return fmt.Sprintf("terraform_lock_%d_%s", ctx.Package.Owner.ID, strings.ToLower(ctx.PathParam("name")))
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package terraform
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vagrant
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
packages_model "gitea.dev/models/packages"
|
||||
packages_module "gitea.dev/modules/packages"
|
||||
vagrant_module "gitea.dev/modules/packages/vagrant"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/routers/api/packages/helper"
|
||||
"gitea.dev/services/context"
|
||||
packages_service "gitea.dev/services/packages"
|
||||
|
||||
"github.com/hashicorp/go-version"
|
||||
)
|
||||
|
||||
func apiError(ctx *context.Context, status int, obj any) {
|
||||
message := helper.ProcessErrorForUser(ctx, status, obj)
|
||||
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.PathParam("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.PathParam("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.PathParam("name")
|
||||
boxVersion := ctx.PathParam("version")
|
||||
_, err := version.NewSemver(boxVersion)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
boxProvider := ctx.PathParam("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.OpenFileForDownloadByPackageNameAndVersion(
|
||||
ctx,
|
||||
&packages_service.PackageInfo{
|
||||
Owner: ctx.Package.Owner,
|
||||
PackageType: packages_model.TypeVagrant,
|
||||
Name: ctx.PathParam("name"),
|
||||
Version: ctx.PathParam("version"),
|
||||
},
|
||||
&packages_service.PackageFileInfo{
|
||||
Filename: ctx.PathParam("provider"),
|
||||
},
|
||||
ctx.Req.Method,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageNotExist) || errors.Is(err, packages_model.ErrPackageFileNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
helper.ServePackageFile(ctx, s, u, pf)
|
||||
}
|
||||
Reference in New Issue
Block a user