From d987ac6bf1d78b3a9bbd213e73b871ebc687acb2 Mon Sep 17 00:00:00 2001 From: KN4CK3R Date: Mon, 6 Feb 2023 02:49:21 +0100 Subject: [PATCH] Add Chef package registry (#22554) This PR implements a [Chef registry](https://chef.io/) to manage cookbooks. This package type was a bit complicated because Chef uses RSA signed requests as authentication with the registry. ![grafik](https://user-images.githubusercontent.com/1666336/213747995-46819fd8-c3d6-45a2-afd4-a4c3c8505a4a.png) ![grafik](https://user-images.githubusercontent.com/1666336/213748145-d01c9e81-d4dd-41e3-a3cc-8241862c3166.png) Co-authored-by: Lunny Xiao --- custom/conf/app.example.ini | 2 + .../doc/advanced/config-cheat-sheet.en-us.md | 1 + docs/content/doc/packages/chef.en-us.md | 96 +++ docs/content/doc/packages/overview.en-us.md | 1 + models/packages/descriptor.go | 3 + models/packages/package.go | 6 + modules/activitypub/user_settings.go | 5 +- modules/packages/chef/metadata.go | 134 +++++ modules/packages/chef/metadata_test.go | 92 +++ modules/setting/packages.go | 2 + modules/{activitypub => util}/keypair.go | 10 +- modules/{activitypub => util}/keypair_test.go | 6 +- options/locale/locale_en-US.ini | 12 +- public/img/svg/gitea-chef.svg | 1 + routers/api/packages/api.go | 21 + routers/api/packages/chef/auth.go | 270 +++++++++ routers/api/packages/chef/chef.go | 404 +++++++++++++ routers/api/v1/packages/package.go | 2 +- routers/web/user/setting/packages.go | 22 + routers/web/web.go | 1 + services/forms/package_form.go | 2 +- services/packages/packages.go | 2 + templates/package/content/chef.tmpl | 48 ++ templates/package/metadata/chef.tmpl | 5 + templates/package/metadata/container.tmpl | 4 +- templates/package/metadata/pub.tmpl | 4 +- templates/package/view.tmpl | 2 + templates/swagger/v1_json.tmpl | 1 + templates/user/settings/packages.tmpl | 18 + tests/integration/api_packages_chef_test.go | 560 ++++++++++++++++++ web_src/svg/gitea-chef.svg | 20 + 31 files changed, 1737 insertions(+), 20 deletions(-) create mode 100644 docs/content/doc/packages/chef.en-us.md create mode 100644 modules/packages/chef/metadata.go create mode 100644 modules/packages/chef/metadata_test.go rename modules/{activitypub => util}/keypair.go (76%) rename modules/{activitypub => util}/keypair_test.go (93%) create mode 100644 public/img/svg/gitea-chef.svg create mode 100644 routers/api/packages/chef/auth.go create mode 100644 routers/api/packages/chef/chef.go create mode 100644 templates/package/content/chef.tmpl create mode 100644 templates/package/metadata/chef.tmpl create mode 100644 tests/integration/api_packages_chef_test.go create mode 100644 web_src/svg/gitea-chef.svg diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index 0f1f18646..b478785a0 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -2460,6 +2460,8 @@ ROUTER = console ;LIMIT_TOTAL_OWNER_SIZE = -1 ;; Maximum size of a Cargo upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`) ;LIMIT_SIZE_CARGO = -1 +;; Maximum size of a Chef upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`) +;LIMIT_SIZE_CHEF = -1 ;; Maximum size of a Composer upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`) ;LIMIT_SIZE_COMPOSER = -1 ;; Maximum size of a Conan upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`) diff --git a/docs/content/doc/advanced/config-cheat-sheet.en-us.md b/docs/content/doc/advanced/config-cheat-sheet.en-us.md index c9116edc6..04344b15d 100644 --- a/docs/content/doc/advanced/config-cheat-sheet.en-us.md +++ b/docs/content/doc/advanced/config-cheat-sheet.en-us.md @@ -1214,6 +1214,7 @@ Task queue configuration has been moved to `queue.task`. However, the below conf - `LIMIT_TOTAL_OWNER_COUNT`: **-1**: Maximum count of package versions a single owner can have (`-1` means no limits) - `LIMIT_TOTAL_OWNER_SIZE`: **-1**: Maximum size of packages a single owner can use (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`) - `LIMIT_SIZE_CARGO`: **-1**: Maximum size of a Cargo upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`) +- `LIMIT_SIZE_CHEF`: **-1**: Maximum size of a Chef upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`) - `LIMIT_SIZE_COMPOSER`: **-1**: Maximum size of a Composer upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`) - `LIMIT_SIZE_CONAN`: **-1**: Maximum size of a Conan upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`) - `LIMIT_SIZE_CONDA`: **-1**: Maximum size of a Conda upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`) diff --git a/docs/content/doc/packages/chef.en-us.md b/docs/content/doc/packages/chef.en-us.md new file mode 100644 index 000000000..ecc774d79 --- /dev/null +++ b/docs/content/doc/packages/chef.en-us.md @@ -0,0 +1,96 @@ +--- +date: "2023-01-20T00:00:00+00:00" +title: "Chef Packages Repository" +slug: "packages/chef" +draft: false +toc: false +menu: + sidebar: + parent: "packages" + name: "Chef" + weight: 5 + identifier: "chef" +--- + +# Chef Packages Repository + +Publish [Chef](https://chef.io/) cookbooks for your user or organization. + +**Table of Contents** + +{{< toc >}} + +## Requirements + +To work with the Chef package registry, you have to use [`knife`](https://docs.chef.io/workstation/knife/). + +## Authentication + +The Chef package registry does not use an username:password authentication but signed requests with a private:public key pair. +Visit the package owner settings page to create the necessary key pair. +Only the public key is stored inside Gitea. if you loose access to the private key you must re-generate the key pair. +[Configure `knife`](https://docs.chef.io/workstation/knife_setup/) to use the downloaded private key with your Gitea username as `client_name`. + +## Configure the package registry + +To [configure `knife`](https://docs.chef.io/workstation/knife_setup/) to use the Gitea package registry add the url to the `~/.chef/config.rb` file. + +``` +knife[:supermarket_site] = 'https://gitea.example.com/api/packages/{owner}/chef' +``` + +| Parameter | Description | +| --------- | ----------- | +| `owner` | The owner of the package. | + +## Publish a package + +To publish a Chef package execute the following command: + +```shell +knife supermarket share {package_name} +``` + +| Parameter | Description | +| -------------- | ----------- | +| `package_name` | The package name. | + +You cannot publish a package if a package of the same name and version already exists. You must delete the existing package first. + +## Install a package + +To install a package from the package registry, execute the following command: + +```shell +knife supermarket install {package_name} +``` + +Optional you can specify the package version: + +```shell +knife supermarket install {package_name} {package_version} +``` + +| Parameter | Description | +| ----------------- | ----------- | +| `package_name` | The package name. | +| `package_version` | The package version. | + +## Delete a package + +If you want to remove a package from the registry, execute the following command: + +```shell +knife supermarket unshare {package_name} +``` + +Optional you can specify the package version: + +```shell +knife supermarket unshare {package_name}/versions/{package_version} +``` + +| Parameter | Description | +| ----------------- | ----------- | +| `package_name` | The package name. | +| `package_version` | The package version. | diff --git a/docs/content/doc/packages/overview.en-us.md b/docs/content/doc/packages/overview.en-us.md index b3ccb73c1..1199d9ede 100644 --- a/docs/content/doc/packages/overview.en-us.md +++ b/docs/content/doc/packages/overview.en-us.md @@ -27,6 +27,7 @@ The following package managers are currently supported: | Name | Language | Package client | | ---- | -------- | -------------- | | [Cargo]({{< relref "doc/packages/cargo.en-us.md" >}}) | Rust | `cargo` | +| [Chef]({{< relref "doc/packages/chef.en-us.md" >}}) | - | `knife` | | [Composer]({{< relref "doc/packages/composer.en-us.md" >}}) | PHP | `composer` | | [Conan]({{< relref "doc/packages/conan.en-us.md" >}}) | C++ | `conan` | | [Conda]({{< relref "doc/packages/conda.en-us.md" >}}) | - | `conda` | diff --git a/models/packages/descriptor.go b/models/packages/descriptor.go index 40010eb72..96f34a22e 100644 --- a/models/packages/descriptor.go +++ b/models/packages/descriptor.go @@ -12,6 +12,7 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/packages/cargo" + "code.gitea.io/gitea/modules/packages/chef" "code.gitea.io/gitea/modules/packages/composer" "code.gitea.io/gitea/modules/packages/conan" "code.gitea.io/gitea/modules/packages/conda" @@ -132,6 +133,8 @@ func GetPackageDescriptor(ctx context.Context, pv *PackageVersion) (*PackageDesc switch p.Type { case TypeCargo: metadata = &cargo.Metadata{} + case TypeChef: + metadata = &chef.Metadata{} case TypeComposer: metadata = &composer.Metadata{} case TypeConan: diff --git a/models/packages/package.go b/models/packages/package.go index b6b033cc9..32f30fab9 100644 --- a/models/packages/package.go +++ b/models/packages/package.go @@ -31,6 +31,7 @@ type Type string // List of supported packages const ( TypeCargo Type = "cargo" + TypeChef Type = "chef" TypeComposer Type = "composer" TypeConan Type = "conan" TypeConda Type = "conda" @@ -48,6 +49,7 @@ const ( var TypeList = []Type{ TypeCargo, + TypeChef, TypeComposer, TypeConan, TypeConda, @@ -68,6 +70,8 @@ func (pt Type) Name() string { switch pt { case TypeCargo: return "Cargo" + case TypeChef: + return "Chef" case TypeComposer: return "Composer" case TypeConan: @@ -103,6 +107,8 @@ func (pt Type) SVGName() string { switch pt { case TypeCargo: return "gitea-cargo" + case TypeChef: + return "gitea-chef" case TypeComposer: return "gitea-composer" case TypeConan: diff --git a/modules/activitypub/user_settings.go b/modules/activitypub/user_settings.go index ec5fa5984..2d156c17e 100644 --- a/modules/activitypub/user_settings.go +++ b/modules/activitypub/user_settings.go @@ -5,8 +5,11 @@ package activitypub import ( user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/util" ) +const rsaBits = 2048 + // GetKeyPair function returns a user's private and public keys func GetKeyPair(user *user_model.User) (pub, priv string, err error) { var settings map[string]*user_model.Setting @@ -14,7 +17,7 @@ func GetKeyPair(user *user_model.User) (pub, priv string, err error) { if err != nil { return } else if len(settings) == 0 { - if priv, pub, err = GenerateKeyPair(); err != nil { + if priv, pub, err = util.GenerateKeyPair(rsaBits); err != nil { return } if err = user_model.SetUserSetting(user.ID, user_model.UserActivityPubPrivPem, priv); err != nil { diff --git a/modules/packages/chef/metadata.go b/modules/packages/chef/metadata.go new file mode 100644 index 000000000..a1c91870c --- /dev/null +++ b/modules/packages/chef/metadata.go @@ -0,0 +1,134 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package chef + +import ( + "archive/tar" + "compress/gzip" + "io" + "regexp" + "strings" + + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/validation" +) + +const ( + KeyBits = 4096 + SettingPublicPem = "chef.public_pem" +) + +var ( + ErrMissingMetadataFile = util.NewInvalidArgumentErrorf("metadata.json file is missing") + ErrInvalidName = util.NewInvalidArgumentErrorf("package name is invalid") + ErrInvalidVersion = util.NewInvalidArgumentErrorf("package version is invalid") + + namePattern = regexp.MustCompile(`\A\S+\z`) + versionPattern = regexp.MustCompile(`\A\d+\.\d+(?:\.\d+)?\z`) +) + +// Package represents a Chef package +type Package struct { + Name string + Version string + Metadata *Metadata +} + +// Metadata represents the metadata of a Chef package +type Metadata struct { + Description string `json:"description,omitempty"` + LongDescription string `json:"long_description,omitempty"` + Author string `json:"author,omitempty"` + License string `json:"license,omitempty"` + RepositoryURL string `json:"repository_url,omitempty"` + Dependencies map[string]string `json:"dependencies,omitempty"` +} + +type chefMetadata struct { + Name string `json:"name"` + Description string `json:"description"` + LongDescription string `json:"long_description"` + Maintainer string `json:"maintainer"` + MaintainerEmail string `json:"maintainer_email"` + License string `json:"license"` + Platforms map[string]string `json:"platforms"` + Dependencies map[string]string `json:"dependencies"` + Providing map[string]string `json:"providing"` + Recipes map[string]string `json:"recipes"` + Version string `json:"version"` + SourceURL string `json:"source_url"` + IssuesURL string `json:"issues_url"` + Privacy bool `json:"privacy"` + ChefVersions [][]string `json:"chef_versions"` + Gems [][]string `json:"gems"` + EagerLoadLibraries bool `json:"eager_load_libraries"` +} + +// ParsePackage parses the Chef package file +func ParsePackage(r io.Reader) (*Package, error) { + gzr, err := gzip.NewReader(r) + if err != nil { + return nil, err + } + defer gzr.Close() + + tr := tar.NewReader(gzr) + for { + hd, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return nil, err + } + + if hd.Typeflag != tar.TypeReg { + continue + } + + if strings.Count(hd.Name, "/") != 1 { + continue + } + + if hd.FileInfo().Name() == "metadata.json" { + return ParseChefMetadata(tr) + } + } + + return nil, ErrMissingMetadataFile +} + +// ParseChefMetadata parses a metadata.json file to retrieve the metadata of a Chef package +func ParseChefMetadata(r io.Reader) (*Package, error) { + var cm chefMetadata + if err := json.NewDecoder(r).Decode(&cm); err != nil { + return nil, err + } + + if !namePattern.MatchString(cm.Name) { + return nil, ErrInvalidName + } + + if !versionPattern.MatchString(cm.Version) { + return nil, ErrInvalidVersion + } + + if !validation.IsValidURL(cm.SourceURL) { + cm.SourceURL = "" + } + + return &Package{ + Name: cm.Name, + Version: cm.Version, + Metadata: &Metadata{ + Description: cm.Description, + LongDescription: cm.LongDescription, + Author: cm.Maintainer, + License: cm.License, + RepositoryURL: cm.SourceURL, + Dependencies: cm.Dependencies, + }, + }, nil +} diff --git a/modules/packages/chef/metadata_test.go b/modules/packages/chef/metadata_test.go new file mode 100644 index 000000000..6def4162a --- /dev/null +++ b/modules/packages/chef/metadata_test.go @@ -0,0 +1,92 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package chef + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +const ( + packageName = "gitea" + packageVersion = "1.0.1" + packageAuthor = "KN4CK3R" + packageDescription = "Package Description" + packageRepositoryURL = "https://gitea.io/gitea/gitea" +) + +func TestParsePackage(t *testing.T) { + t.Run("MissingMetadataFile", func(t *testing.T) { + var buf bytes.Buffer + zw := gzip.NewWriter(&buf) + tw := tar.NewWriter(zw) + tw.Close() + zw.Close() + + p, err := ParsePackage(&buf) + assert.Nil(t, p) + assert.ErrorIs(t, err, ErrMissingMetadataFile) + }) + + t.Run("Valid", func(t *testing.T) { + var buf bytes.Buffer + zw := gzip.NewWriter(&buf) + tw := tar.NewWriter(zw) + + content := `{"name":"` + packageName + `","version":"` + packageVersion + `"}` + + hdr := &tar.Header{ + Name: packageName + "/metadata.json", + Mode: 0o600, + Size: int64(len(content)), + } + tw.WriteHeader(hdr) + tw.Write([]byte(content)) + + tw.Close() + zw.Close() + + p, err := ParsePackage(&buf) + assert.NoError(t, err) + assert.NotNil(t, p) + assert.Equal(t, packageName, p.Name) + assert.Equal(t, packageVersion, p.Version) + assert.NotNil(t, p.Metadata) + }) +} + +func TestParseChefMetadata(t *testing.T) { + t.Run("InvalidName", func(t *testing.T) { + for _, name := range []string{" test", "test "} { + p, err := ParseChefMetadata(strings.NewReader(`{"name":"` + name + `","version":"1.0.0"}`)) + assert.Nil(t, p) + assert.ErrorIs(t, err, ErrInvalidName) + } + }) + + t.Run("InvalidVersion", func(t *testing.T) { + for _, version := range []string{"1", "1.2.3.4", "1.0.0 "} { + p, err := ParseChefMetadata(strings.NewReader(`{"name":"test","version":"` + version + `"}`)) + assert.Nil(t, p) + assert.ErrorIs(t, err, ErrInvalidVersion) + } + }) + + t.Run("Valid", func(t *testing.T) { + p, err := ParseChefMetadata(strings.NewReader(`{"name":"` + packageName + `","version":"` + packageVersion + `","description":"` + packageDescription + `","maintainer":"` + packageAuthor + `","source_url":"` + packageRepositoryURL + `"}`)) + assert.NotNil(t, p) + assert.NoError(t, err) + + assert.Equal(t, packageName, p.Name) + assert.Equal(t, packageVersion, p.Version) + assert.Equal(t, packageDescription, p.Metadata.Description) + assert.Equal(t, packageAuthor, p.Metadata.Author) + assert.Equal(t, packageRepositoryURL, p.Metadata.RepositoryURL) + }) +} diff --git a/modules/setting/packages.go b/modules/setting/packages.go index 190c17dd8..84da4eb53 100644 --- a/modules/setting/packages.go +++ b/modules/setting/packages.go @@ -26,6 +26,7 @@ var ( LimitTotalOwnerCount int64 LimitTotalOwnerSize int64 LimitSizeCargo int64 + LimitSizeChef int64 LimitSizeComposer int64 LimitSizeConan int64 LimitSizeConda int64 @@ -67,6 +68,7 @@ func newPackages() { Packages.LimitTotalOwnerSize = mustBytes(sec, "LIMIT_TOTAL_OWNER_SIZE") Packages.LimitSizeCargo = mustBytes(sec, "LIMIT_SIZE_CARGO") + Packages.LimitSizeChef = mustBytes(sec, "LIMIT_SIZE_CHEF") Packages.LimitSizeComposer = mustBytes(sec, "LIMIT_SIZE_COMPOSER") Packages.LimitSizeConan = mustBytes(sec, "LIMIT_SIZE_CONAN") Packages.LimitSizeConda = mustBytes(sec, "LIMIT_SIZE_CONDA") diff --git a/modules/activitypub/keypair.go b/modules/util/keypair.go similarity index 76% rename from modules/activitypub/keypair.go rename to modules/util/keypair.go index 299bdc43e..5a3ce715a 100644 --- a/modules/activitypub/keypair.go +++ b/modules/util/keypair.go @@ -1,7 +1,7 @@ // Copyright 2021 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT -package activitypub +package util import ( "crypto/rand" @@ -10,11 +10,9 @@ import ( "encoding/pem" ) -const rsaBits = 2048 - -// GenerateKeyPair generates a public and private keypair for signing actions by users for activitypub purposes -func GenerateKeyPair() (string, string, error) { - priv, _ := rsa.GenerateKey(rand.Reader, rsaBits) +// GenerateKeyPair generates a public and private keypair +func GenerateKeyPair(bits int) (string, string, error) { + priv, _ := rsa.GenerateKey(rand.Reader, bits) privPem, err := pemBlockForPriv(priv) if err != nil { return "", "", err diff --git a/modules/activitypub/keypair_test.go b/modules/util/keypair_test.go similarity index 93% rename from modules/activitypub/keypair_test.go rename to modules/util/keypair_test.go index 888254c9d..c6f68c845 100644 --- a/modules/activitypub/keypair_test.go +++ b/modules/util/keypair_test.go @@ -1,7 +1,7 @@ // Copyright 2021 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT -package activitypub +package util import ( "crypto" @@ -17,7 +17,7 @@ import ( ) func TestKeygen(t *testing.T) { - priv, pub, err := GenerateKeyPair() + priv, pub, err := GenerateKeyPair(2048) assert.NoError(t, err) assert.NotEmpty(t, priv) @@ -28,7 +28,7 @@ func TestKeygen(t *testing.T) { } func TestSignUsingKeys(t *testing.T) { - priv, pub, err := GenerateKeyPair() + priv, pub, err := GenerateKeyPair(2048) assert.NoError(t, err) privPem, _ := pem.Decode([]byte(priv)) diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index bc2e8cb91..a7506986f 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -3145,6 +3145,8 @@ keywords = Keywords details = Details details.author = Author details.project_site = Project Site +details.repository_site = Repository Site +details.documentation_site = Documentation Site details.license = License assets = Assets versions = Versions @@ -3157,6 +3159,9 @@ cargo.install = To install the package using Cargo, run the following command: cargo.documentation = For more information on the Cargo registry, see the documentation. cargo.details.repository_site = Repository Site cargo.details.documentation_site = Documentation Site +chef.registry = Setup this registry in your ~/.chef/config.rb file: +chef.install = To install the package, run the following command: +chef.documentation = For more information on the Chef registry, see the documentation. composer.registry = Setup this registry in your ~/.composer/config.json file: composer.install = To install the package using Composer, run the following command: composer.documentation = For more information on the Composer registry, see the documentation. @@ -3173,8 +3178,6 @@ conda.details.repository_site = Repository Site conda.details.documentation_site = Documentation Site container.details.type = Image Type container.details.platform = Platform -container.details.repository_site = Repository Site -container.details.documentation_site = Documentation Site container.pull = Pull the image from the command line: container.digest = Digest: container.documentation = For more information on the Container registry, see the documentation. @@ -3208,8 +3211,6 @@ npm.dependencies.optional = Optional Dependencies npm.details.tag = Tag pub.install = To install the package using Dart, run the following command: pub.documentation = For more information on the Pub registry, see the documentation. -pub.details.repository_site = Repository Site -pub.details.documentation_site = Documentation Site pypi.requires = Requires Python pypi.install = To install the package using pip, run the following command: pypi.documentation = For more information on the PyPI registry, see the documentation. @@ -3262,6 +3263,9 @@ owner.settings.cleanuprules.remove.days = Remove versions older than owner.settings.cleanuprules.remove.pattern = Remove versions matching owner.settings.cleanuprules.success.update = Cleanup rule has been updated. owner.settings.cleanuprules.success.delete = Cleanup rule has been deleted. +owner.settings.chef.title = Chef Registry +owner.settings.chef.keypair = Generate key pair +owner.settings.chef.keypair.description = Generate a key pair used to authenticate against the Chef registry. The previous key can not be used afterwards. [secrets] secrets = Secrets diff --git a/public/img/svg/gitea-chef.svg b/public/img/svg/gitea-chef.svg new file mode 100644 index 000000000..8f1cd6165 --- /dev/null +++ b/public/img/svg/gitea-chef.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/routers/api/packages/api.go b/routers/api/packages/api.go index 8ec9ae9bf..9f77367d6 100644 --- a/routers/api/packages/api.go +++ b/routers/api/packages/api.go @@ -15,6 +15,7 @@ import ( "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/api/packages/cargo" + "code.gitea.io/gitea/routers/api/packages/chef" "code.gitea.io/gitea/routers/api/packages/composer" "code.gitea.io/gitea/routers/api/packages/conan" "code.gitea.io/gitea/routers/api/packages/conda" @@ -54,6 +55,7 @@ func CommonRoutes(ctx gocontext.Context) *web.Route { &auth.Basic{}, &nuget.Auth{}, &conan.Auth{}, + &chef.Auth{}, } if setting.Service.EnableReverseProxyAuth { authMethods = append(authMethods, &auth.ReverseProxy{}) @@ -86,6 +88,25 @@ func CommonRoutes(ctx gocontext.Context) *web.Route { }) }) }, 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) diff --git a/routers/api/packages/chef/auth.go b/routers/api/packages/chef/auth.go new file mode 100644 index 000000000..69f7b763a --- /dev/null +++ b/routers/api/packages/chef/auth.go @@ -0,0 +1,270 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package chef + +import ( + "crypto" + "crypto/rsa" + "crypto/sha1" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "fmt" + "hash" + "math/big" + "net/http" + "path" + "regexp" + "strconv" + "strings" + "time" + + user_model "code.gitea.io/gitea/models/user" + chef_module "code.gitea.io/gitea/modules/packages/chef" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/auth" +) + +const ( + maxTimeDifference = 10 * time.Minute +) + +var ( + algorithmPattern = regexp.MustCompile(`algorithm=(\w+)`) + versionPattern = regexp.MustCompile(`version=(\d+\.\d+)`) + authorizationPattern = regexp.MustCompile(`\AX-Ops-Authorization-(\d+)`) +) + +// Documentation: +// https://docs.chef.io/server/api_chef_server/#required-headers +// https://github.com/chef-boneyard/chef-rfc/blob/master/rfc065-sign-v1.3.md +// https://github.com/chef/mixlib-authentication/blob/bc8adbef833d4be23dc78cb23e6fe44b51ebc34f/lib/mixlib/authentication/signedheaderauth.rb + +type Auth struct{} + +func (a *Auth) Name() string { + return "chef" +} + +// Verify extracts the user from the signed request +// If the request is signed with the user private key the user is verified. +func (a *Auth) Verify(req *http.Request, w http.ResponseWriter, store auth.DataStore, sess auth.SessionStore) (*user_model.User, error) { + u, err := getUserFromRequest(req) + if err != nil { + return nil, err + } + if u == nil { + return nil, nil + } + + pub, err := getUserPublicKey(u) + if err != nil { + return nil, err + } + + if err := verifyTimestamp(req); err != nil { + return nil, err + } + + version, err := getSignVersion(req) + if err != nil { + return nil, err + } + + if err := verifySignedHeaders(req, version, pub.(*rsa.PublicKey)); err != nil { + return nil, err + } + + return u, nil +} + +func getUserFromRequest(req *http.Request) (*user_model.User, error) { + username := req.Header.Get("X-Ops-Userid") + if username == "" { + return nil, nil + } + + return user_model.GetUserByName(req.Context(), username) +} + +func getUserPublicKey(u *user_model.User) (crypto.PublicKey, error) { + pubKey, err := user_model.GetSetting(u.ID, chef_module.SettingPublicPem) + if err != nil { + return nil, err + } + + pubPem, _ := pem.Decode([]byte(pubKey)) + + return x509.ParsePKIXPublicKey(pubPem.Bytes) +} + +func verifyTimestamp(req *http.Request) error { + hdr := req.Header.Get("X-Ops-Timestamp") + if hdr == "" { + return util.NewInvalidArgumentErrorf("X-Ops-Timestamp header missing") + } + + ts, err := time.Parse(time.RFC3339, hdr) + if err != nil { + return err + } + + diff := time.Now().UTC().Sub(ts) + if diff < 0 { + diff = -diff + } + + if diff > maxTimeDifference { + return fmt.Errorf("time difference") + } + + return nil +} + +func getSignVersion(req *http.Request) (string, error) { + hdr := req.Header.Get("X-Ops-Sign") + if hdr == "" { + return "", util.NewInvalidArgumentErrorf("X-Ops-Sign header missing") + } + + m := versionPattern.FindStringSubmatch(hdr) + if len(m) != 2 { + return "", util.NewInvalidArgumentErrorf("invalid X-Ops-Sign header") + } + + switch m[1] { + case "1.0", "1.1", "1.2", "1.3": + default: + return "", util.NewInvalidArgumentErrorf("unsupported version") + } + + version := m[1] + + m = algorithmPattern.FindStringSubmatch(hdr) + if len(m) == 2 && m[1] != "sha1" && !(m[1] == "sha256" && version == "1.3") { + return "", util.NewInvalidArgumentErrorf("unsupported algorithm") + } + + return version, nil +} + +func verifySignedHeaders(req *http.Request, version string, pub *rsa.PublicKey) error { + authorizationData, err := getAuthorizationData(req) + if err != nil { + return err + } + + checkData := buildCheckData(req, version) + + switch version { + case "1.3": + return verifyDataNew(authorizationData, checkData, pub, crypto.SHA256) + case "1.2": + return verifyDataNew(authorizationData, checkData, pub, crypto.SHA1) + default: + return verifyDataOld(authorizationData, checkData, pub) + } +} + +func getAuthorizationData(req *http.Request) ([]byte, error) { + valueList := make(map[int]string) + for k, vs := range req.Header { + if m := authorizationPattern.FindStringSubmatch(k); m != nil { + index, _ := strconv.Atoi(m[1]) + var v string + if len(vs) == 0 { + v = "" + } else { + v = vs[0] + } + valueList[index] = v + } + } + + tmp := make([]string, len(valueList)) + for k, v := range valueList { + if k > len(tmp) { + return nil, fmt.Errorf("invalid X-Ops-Authorization headers") + } + tmp[k-1] = v + } + + return base64.StdEncoding.DecodeString(strings.Join(tmp, "")) +} + +func buildCheckData(req *http.Request, version string) []byte { + username := req.Header.Get("X-Ops-Userid") + if version != "1.0" && version != "1.3" { + sum := sha1.Sum([]byte(username)) + username = base64.StdEncoding.EncodeToString(sum[:]) + } + + var data string + if version == "1.3" { + data = fmt.Sprintf( + "Method:%s\nPath:%s\nX-Ops-Content-Hash:%s\nX-Ops-Sign:version=%s\nX-Ops-Timestamp:%s\nX-Ops-UserId:%s\nX-Ops-Server-API-Version:%s", + req.Method, + path.Clean(req.URL.Path), + req.Header.Get("X-Ops-Content-Hash"), + version, + req.Header.Get("X-Ops-Timestamp"), + username, + req.Header.Get("X-Ops-Server-Api-Version"), + ) + } else { + sum := sha1.Sum([]byte(path.Clean(req.URL.Path))) + data = fmt.Sprintf( + "Method:%s\nHashed Path:%s\nX-Ops-Content-Hash:%s\nX-Ops-Timestamp:%s\nX-Ops-UserId:%s", + req.Method, + base64.StdEncoding.EncodeToString(sum[:]), + req.Header.Get("X-Ops-Content-Hash"), + req.Header.Get("X-Ops-Timestamp"), + username, + ) + } + + return []byte(data) +} + +func verifyDataNew(signature, data []byte, pub *rsa.PublicKey, algo crypto.Hash) error { + var h hash.Hash + if algo == crypto.SHA256 { + h = sha256.New() + } else { + h = sha1.New() + } + if _, err := h.Write(data); err != nil { + return err + } + + return rsa.VerifyPKCS1v15(pub, algo, h.Sum(nil), signature) +} + +func verifyDataOld(signature, data []byte, pub *rsa.PublicKey) error { + c := new(big.Int) + m := new(big.Int) + m.SetBytes(signature) + e := big.NewInt(int64(pub.E)) + c.Exp(m, e, pub.N) + + out := c.Bytes() + + skip := 0 + for i := 2; i < len(out); i++ { + if i+1 >= len(out) { + break + } + if out[i] == 0xFF && out[i+1] == 0 { + skip = i + 2 + break + } + } + + if !util.SliceEqual(out[skip:], data) { + return fmt.Errorf("could not verify signature") + } + + return nil +} diff --git a/routers/api/packages/chef/chef.go b/routers/api/packages/chef/chef.go new file mode 100644 index 000000000..28d07dea4 --- /dev/null +++ b/routers/api/packages/chef/chef.go @@ -0,0 +1,404 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package chef + +import ( + "errors" + "fmt" + "io" + "net/http" + "net/url" + "sort" + "strings" + "time" + + "code.gitea.io/gitea/models/db" + packages_model "code.gitea.io/gitea/models/packages" + "code.gitea.io/gitea/modules/context" + packages_module "code.gitea.io/gitea/modules/packages" + chef_module "code.gitea.io/gitea/modules/packages/chef" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/routers/api/packages/helper" + packages_service "code.gitea.io/gitea/services/packages" +) + +func apiError(ctx *context.Context, status int, obj interface{}) { + type Error struct { + ErrorMessages []string `json:"error_messages"` + } + + helper.LogAndProcessError(ctx, status, obj, func(message string) { + ctx.JSON(status, Error{ + ErrorMessages: []string{message}, + }) + }) +} + +func PackagesUniverse(ctx *context.Context) { + pvs, _, err := packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{ + OwnerID: ctx.Package.Owner.ID, + Type: packages_model.TypeChef, + IsInternal: util.OptionalBoolFalse, + }) + 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: util.OptionalBoolFalse, + Paginator: db.NewAbsoluteListOptions( + ctx.FormInt("start"), + ctx.FormInt("items"), + ), + } + + switch strings.ToLower(ctx.FormTrim("order")) { + case "recently_updated", "recently_added": + opts.Sort = packages_model.SortCreatedDesc + default: + opts.Sort = packages_model.SortNameAsc + } + + pvs, total, err := packages_model.SearchLatestVersions(ctx, opts) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + pds, err := packages_model.GetPackageDescriptors(ctx, pvs) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + type Item struct { + CookbookName string `json:"cookbook_name"` + CookbookMaintainer string `json:"cookbook_maintainer"` + CookbookDescription string `json:"cookbook_description"` + Cookbook string `json:"cookbook"` + } + + type Result struct { + Start int `json:"start"` + Total int `json:"total"` + Items []*Item `json:"items"` + } + + baseURL := setting.AppURL + "api/packages/" + ctx.Package.Owner.Name + "/chef/api/v1/cookbooks/" + + items := make([]*Item, 0, len(pds)) + for _, pd := range pds { + metadata := pd.Metadata.(*chef_module.Metadata) + + items = append(items, &Item{ + CookbookName: pd.Package.Name, + CookbookMaintainer: metadata.Author, + CookbookDescription: metadata.Description, + Cookbook: baseURL + url.PathEscape(pd.Package.Name), + }) + } + + skip, _ := opts.Paginator.GetSkipTake() + + ctx.JSON(http.StatusOK, &Result{ + Start: skip, + Total: int(total), + Items: items, + }) +} + +// https://github.com/chef/chef/blob/main/knife/lib/chef/knife/supermarket_show.rb +func PackageMetadata(ctx *context.Context) { + packageName := ctx.Params("name") + + pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeChef, packageName) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + if len(pvs) == 0 { + apiError(ctx, http.StatusNotFound, nil) + return + } + + pds, err := packages_model.GetPackageDescriptors(ctx, pvs) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + sort.Slice(pds, func(i, j int) bool { + return pds[i].SemVer.LessThan(pds[j].SemVer) + }) + + type Result struct { + Name string `json:"name"` + Maintainer string `json:"maintainer"` + Description string `json:"description"` + Category string `json:"category"` + LatestVersion string `json:"latest_version"` + SourceURL string `json:"source_url"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Deprecated bool `json:"deprecated"` + Versions []string `json:"versions"` + } + + baseURL := fmt.Sprintf("%sapi/packages/%s/chef/api/v1/cookbooks/%s/versions/", setting.AppURL, ctx.Package.Owner.Name, url.PathEscape(packageName)) + + versions := make([]string, 0, len(pds)) + for _, pd := range pds { + versions = append(versions, baseURL+pd.Version.Version) + } + + latest := pds[len(pds)-1] + + metadata := latest.Metadata.(*chef_module.Metadata) + + ctx.JSON(http.StatusOK, &Result{ + Name: latest.Package.Name, + Maintainer: metadata.Author, + Description: metadata.Description, + LatestVersion: baseURL + latest.Version.Version, + SourceURL: metadata.RepositoryURL, + CreatedAt: latest.Version.CreatedUnix.AsLocalTime(), + UpdatedAt: latest.Version.CreatedUnix.AsLocalTime(), + Deprecated: false, + Versions: versions, + }) +} + +// https://github.com/chef/chef/blob/main/knife/lib/chef/knife/supermarket_show.rb +func PackageVersionMetadata(ctx *context.Context) { + packageName := ctx.Params("name") + packageVersion := strings.ReplaceAll(ctx.Params("version"), "_", ".") // Chef calls this endpoint with "_" instead of "."?! + + pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeChef, packageName, packageVersion) + if err != nil { + if err == packages_model.ErrPackageNotExist { + apiError(ctx, http.StatusNotFound, err) + return + } + apiError(ctx, http.StatusInternalServerError, err) + return + } + + pd, err := packages_model.GetPackageDescriptor(ctx, pv) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + type Result struct { + Version string `json:"version"` + TarballFileSize int64 `json:"tarball_file_size"` + PublishedAt time.Time `json:"published_at"` + Cookbook string `json:"cookbook"` + File string `json:"file"` + License string `json:"license"` + Dependencies map[string]string `json:"dependencies"` + } + + baseURL := fmt.Sprintf("%sapi/packages/%s/chef/api/v1/cookbooks/%s", setting.AppURL, ctx.Package.Owner.Name, url.PathEscape(pd.Package.Name)) + + metadata := pd.Metadata.(*chef_module.Metadata) + + ctx.JSON(http.StatusOK, &Result{ + Version: pd.Version.Version, + TarballFileSize: pd.Files[0].Blob.Size, + PublishedAt: pd.Version.CreatedUnix.AsLocalTime(), + Cookbook: baseURL, + File: fmt.Sprintf("%s/versions/%s/download", baseURL, pd.Version.Version), + License: metadata.License, + Dependencies: metadata.Dependencies, + }) +} + +// https://github.com/chef/chef/blob/main/knife/lib/chef/knife/supermarket_share.rb +func UploadPackage(ctx *context.Context) { + file, _, err := ctx.Req.FormFile("tarball") + if err != nil { + apiError(ctx, http.StatusBadRequest, err) + return + } + defer file.Close() + + buf, err := packages_module.CreateHashedBufferFromReader(file, 32*1024*1024) + 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( + &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.StatusBadRequest, err) + case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize: + apiError(ctx, http.StatusForbidden, err) + default: + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + ctx.JSON(http.StatusCreated, make(map[any]any)) +} + +// https://github.com/chef/chef/blob/main/knife/lib/chef/knife/supermarket_download.rb +func DownloadPackage(ctx *context.Context) { + pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeChef, ctx.Params("name"), ctx.Params("version")) + if err != nil { + if err == packages_model.ErrPackageNotExist { + apiError(ctx, http.StatusNotFound, err) + return + } + apiError(ctx, http.StatusInternalServerError, err) + return + } + + pd, err := packages_model.GetPackageDescriptor(ctx, pv) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + pf := pd.Files[0].File + + s, _, err := packages_service.GetPackageFileStream(ctx, pf) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + defer s.Close() + + ctx.ServeContent(s, &context.ServeHeaderOptions{ + Filename: pf.Name, + LastModified: pf.CreatedUnix.AsLocalTime(), + }) +} + +// https://github.com/chef/chef/blob/main/knife/lib/chef/knife/supermarket_unshare.rb +func DeletePackageVersion(ctx *context.Context) { + packageName := ctx.Params("name") + packageVersion := ctx.Params("version") + + err := packages_service.RemovePackageVersionByNameAndVersion( + ctx.Doer, + &packages_service.PackageInfo{ + Owner: ctx.Package.Owner, + PackageType: packages_model.TypeChef, + Name: packageName, + Version: packageVersion, + }, + ) + if err != nil { + if err == packages_model.ErrPackageNotExist { + apiError(ctx, http.StatusNotFound, err) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + ctx.Status(http.StatusOK) +} + +// https://github.com/chef/chef/blob/main/knife/lib/chef/knife/supermarket_unshare.rb +func DeletePackage(ctx *context.Context) { + pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeChef, ctx.Params("name")) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + if len(pvs) == 0 { + apiError(ctx, http.StatusNotFound, err) + return + } + + for _, pv := range pvs { + if err := packages_service.RemovePackageVersion(ctx.Doer, pv); err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + } + + ctx.Status(http.StatusOK) +} diff --git a/routers/api/v1/packages/package.go b/routers/api/v1/packages/package.go index 2a20d66d1..ab077090d 100644 --- a/routers/api/v1/packages/package.go +++ b/routers/api/v1/packages/package.go @@ -40,7 +40,7 @@ func ListPackages(ctx *context.APIContext) { // in: query // description: package type filter // type: string - // enum: [cargo, composer, conan, conda, container, generic, helm, maven, npm, nuget, pub, pypi, rubygems, vagrant] + // enum: [cargo, chef, composer, conan, conda, container, generic, helm, maven, npm, nuget, pub, pypi, rubygems, vagrant] // - name: q // in: query // description: name filter diff --git a/routers/web/user/setting/packages.go b/routers/web/user/setting/packages.go index b3f8a3e41..0d2eb14c2 100644 --- a/routers/web/user/setting/packages.go +++ b/routers/web/user/setting/packages.go @@ -5,10 +5,14 @@ package setting import ( "net/http" + "strings" + user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/context" + chef_module "code.gitea.io/gitea/modules/packages/chef" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" shared "code.gitea.io/gitea/routers/web/shared/packages" ) @@ -95,3 +99,21 @@ func RebuildCargoIndex(ctx *context.Context) { ctx.Redirect(setting.AppSubURL + "/user/settings/packages") } + +func RegenerateChefKeyPair(ctx *context.Context) { + priv, pub, err := util.GenerateKeyPair(chef_module.KeyBits) + if err != nil { + ctx.ServerError("GenerateKeyPair", err) + return + } + + if err := user_model.SetUserSetting(ctx.Doer.ID, chef_module.SettingPublicPem, pub); err != nil { + ctx.ServerError("SetUserSetting", err) + return + } + + ctx.ServeContent(strings.NewReader(priv), &context.ServeHeaderOptions{ + ContentType: "application/x-pem-file", + Filename: ctx.Doer.Name + ".priv", + }) +} diff --git a/routers/web/web.go b/routers/web/web.go index a024c0ac3..689895605 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -472,6 +472,7 @@ func RegisterRoutes(m *web.Route) { m.Post("/initialize", user_setting.InitializeCargoIndex) m.Post("/rebuild", user_setting.RebuildCargoIndex) }) + m.Post("/chef/regenerate_keypair", user_setting.RegenerateChefKeyPair) }, packagesEnabled) m.Group("/secrets", func() { m.Get("", user_setting.Secrets) diff --git a/services/forms/package_form.go b/services/forms/package_form.go index 558ed54b6..b22ed47c7 100644 --- a/services/forms/package_form.go +++ b/services/forms/package_form.go @@ -15,7 +15,7 @@ import ( type PackageCleanupRuleForm struct { ID int64 Enabled bool - Type string `binding:"Required;In(cargo,composer,conan,conda,container,generic,helm,maven,npm,nuget,pub,pypi,rubygems,vagrant)"` + Type string `binding:"Required;In(cargo,chef,composer,conan,conda,container,generic,helm,maven,npm,nuget,pub,pypi,rubygems,vagrant)"` KeepCount int `binding:"In(0,1,5,10,25,50,100)"` KeepPattern string `binding:"RegexPattern"` RemoveDays int `binding:"In(0,7,14,30,60,90,180)"` diff --git a/services/packages/packages.go b/services/packages/packages.go index f50284075..3abca7337 100644 --- a/services/packages/packages.go +++ b/services/packages/packages.go @@ -335,6 +335,8 @@ func CheckSizeQuotaExceeded(ctx context.Context, doer, owner *user_model.User, p switch packageType { case packages_model.TypeCargo: typeSpecificSize = setting.Packages.LimitSizeCargo + case packages_model.TypeChef: + typeSpecificSize = setting.Packages.LimitSizeChef case packages_model.TypeComposer: typeSpecificSize = setting.Packages.LimitSizeComposer case packages_model.TypeConan: diff --git a/templates/package/content/chef.tmpl b/templates/package/content/chef.tmpl new file mode 100644 index 000000000..f0f8de7bd --- /dev/null +++ b/templates/package/content/chef.tmpl @@ -0,0 +1,48 @@ +{{if eq .PackageDescriptor.Package.Type "chef"}} +

{{.locale.Tr "packages.installation"}}

+
+
+
+ +
knife[:supermarket_site] = '{{AppUrl}}api/packages/{{.PackageDescriptor.Owner.Name}}/chef'
+
+
+ +
knife supermarket install {{.PackageDescriptor.Package.Name}} {{.PackageDescriptor.Version.Version}}
+
+
+ +
+
+
+ + {{if or .PackageDescriptor.Metadata.Description .PackageDescriptor.Metadata.LongDescription}} +

{{.locale.Tr "packages.about"}}

+
+ {{if .PackageDescriptor.Metadata.Description}}

{{.PackageDescriptor.Metadata.Description}}

{{end}} + {{if .PackageDescriptor.Metadata.LongDescription}}{{RenderMarkdownToHtml .PackageDescriptor.Metadata.LongDescription}}{{end}} +
+ {{end}} + + {{if .PackageDescriptor.Metadata.Dependencies}} +

{{.locale.Tr "packages.dependencies"}}

+
+ + + + + + + + + {{range $dependency, $version := .PackageDescriptor.Metadata.Dependencies}} + + + + + {{end}} + +
{{.locale.Tr "packages.dependency.id"}}{{.locale.Tr "packages.dependency.version"}}
{{$dependency}}{{$version}}
+
+ {{end}} +{{end}} diff --git a/templates/package/metadata/chef.tmpl b/templates/package/metadata/chef.tmpl new file mode 100644 index 000000000..00fc0e6c3 --- /dev/null +++ b/templates/package/metadata/chef.tmpl @@ -0,0 +1,5 @@ +{{if eq .PackageDescriptor.Package.Type "chef"}} + {{if .PackageDescriptor.Metadata.Author}}
{{svg "octicon-person" 16 "mr-3"}} {{.PackageDescriptor.Metadata.Author}}
{{end}} + {{if .PackageDescriptor.Metadata.RepositoryURL}}
{{svg "octicon-link-external" 16 "mr-3"}} {{.locale.Tr "packages.details.repository_site"}}
{{end}} + {{if .PackageDescriptor.Metadata.License}}
{{svg "octicon-law" 16 "mr-3"}} {{.PackageDescriptor.Metadata.License}}
{{end}} +{{end}} diff --git a/templates/package/metadata/container.tmpl b/templates/package/metadata/container.tmpl index 1b13072c3..71df960f7 100644 --- a/templates/package/metadata/container.tmpl +++ b/templates/package/metadata/container.tmpl @@ -4,6 +4,6 @@ {{range .PackageDescriptor.Metadata.Authors}}
{{svg "octicon-person" 16 "mr-3"}} {{.}}
{{end}} {{if .PackageDescriptor.Metadata.Licenses}}
{{svg "octicon-law" 16 "mr-3"}} {{.PackageDescriptor.Metadata.Licenses}}
{{end}} {{if .PackageDescriptor.Metadata.ProjectURL}}
{{svg "octicon-link-external" 16 "mr-3"}} {{.locale.Tr "packages.details.project_site"}}
{{end}} - {{if .PackageDescriptor.Metadata.RepositoryURL}}
{{svg "octicon-link-external" 16 "mr-3"}} {{.locale.Tr "packages.container.details.repository_site"}}
{{end}} - {{if .PackageDescriptor.Metadata.DocumentationURL}}
{{svg "octicon-link-external" 16 "mr-3"}} {{.locale.Tr "packages.container.details.documentation_site"}}
{{end}} + {{if .PackageDescriptor.Metadata.RepositoryURL}}
{{svg "octicon-link-external" 16 "mr-3"}} {{.locale.Tr "packages.details.repository_site"}}
{{end}} + {{if .PackageDescriptor.Metadata.DocumentationURL}}
{{svg "octicon-link-external" 16 "mr-3"}} {{.locale.Tr "packages.details.documentation_site"}}
{{end}} {{end}} diff --git a/templates/package/metadata/pub.tmpl b/templates/package/metadata/pub.tmpl index 8bd65b49d..632a50655 100644 --- a/templates/package/metadata/pub.tmpl +++ b/templates/package/metadata/pub.tmpl @@ -1,5 +1,5 @@ {{if eq .PackageDescriptor.Package.Type "pub"}} {{if .PackageDescriptor.Metadata.ProjectURL}}
{{svg "octicon-link-external" 16 "mr-3"}} {{.locale.Tr "packages.details.project_site"}}
{{end}} - {{if .PackageDescriptor.Metadata.RepositoryURL}}
{{svg "octicon-link-external" 16 "mr-3"}} {{.locale.Tr "packages.pub.details.repository_site"}}
{{end}} - {{if .PackageDescriptor.Metadata.DocumentationURL}}
{{svg "octicon-link-external" 16 "mr-3"}} {{.locale.Tr "packages.pub.details.documentation_site"}}
{{end}} + {{if .PackageDescriptor.Metadata.RepositoryURL}}
{{svg "octicon-link-external" 16 "mr-3"}} {{.locale.Tr "packages.details.repository_site"}}
{{end}} + {{if .PackageDescriptor.Metadata.DocumentationURL}}
{{svg "octicon-link-external" 16 "mr-3"}} {{.locale.Tr "packages.details.documentation_site"}}
{{end}} {{end}} diff --git a/templates/package/view.tmpl b/templates/package/view.tmpl index 6209e6cb0..a548d9e0b 100644 --- a/templates/package/view.tmpl +++ b/templates/package/view.tmpl @@ -20,6 +20,7 @@
{{template "package/content/cargo" .}} + {{template "package/content/chef" .}} {{template "package/content/composer" .}} {{template "package/content/conan" .}} {{template "package/content/conda" .}} @@ -45,6 +46,7 @@
{{svg "octicon-calendar" 16 "mr-3"}} {{TimeSinceUnix .PackageDescriptor.Version.CreatedUnix $.locale}}
{{svg "octicon-download" 16 "mr-3"}} {{.PackageDescriptor.Version.DownloadCount}}
{{template "package/metadata/cargo" .}} + {{template "package/metadata/chef" .}} {{template "package/metadata/composer" .}} {{template "package/metadata/conan" .}} {{template "package/metadata/conda" .}} diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index d6e3009b3..812e626d2 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -2101,6 +2101,7 @@ { "enum": [ "cargo", + "chef", "composer", "conan", "conda", diff --git a/templates/user/settings/packages.tmpl b/templates/user/settings/packages.tmpl index 472d84327..1faddab5d 100644 --- a/templates/user/settings/packages.tmpl +++ b/templates/user/settings/packages.tmpl @@ -5,6 +5,24 @@ {{template "base/alert" .}} {{template "package/shared/cleanup_rules/list" .}} {{template "package/shared/cargo" .}} + +

+ {{.locale.Tr "packages.owner.settings.chef.title"}} +

+
+
+
+ +
+
+ {{.CsrfTokenHtml}} + +
+
+ +
+
+
{{template "base/footer" .}} diff --git a/tests/integration/api_packages_chef_test.go b/tests/integration/api_packages_chef_test.go new file mode 100644 index 000000000..14baddca9 --- /dev/null +++ b/tests/integration/api_packages_chef_test.go @@ -0,0 +1,560 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha1" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "fmt" + "hash" + "math/big" + "mime/multipart" + "net/http" + "path" + "strings" + "testing" + "time" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/packages" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + chef_module "code.gitea.io/gitea/modules/packages/chef" + "code.gitea.io/gitea/modules/setting" + chef_router "code.gitea.io/gitea/routers/api/packages/chef" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestPackageChef(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + privPem := `-----BEGIN RSA PRIVATE KEY----- +MIIEpQIBAAKCAQEAtWp2PZz4TSU5A6ixw41HdbfBuGJwPuTtrsdoUf0DQ0/DJBNP +qOCBAgEu6ZdUqIbWJ5Da+nevjtncy5hENdi6XrXjyzlUxghMuXjE5SeLGpgfQvkq +bTkYaFpMe8PTzNeze3fei8+Eu6mzeb6g1GrqXznuPIc7bNss0w5iX9RiBM9dWPuX +onx9xSEy0LYqJm7yXmshNe1aRwkjG/y5C26BzBFnMKp9YRTua0DO1WqLNhcaRnda +lIFYouDNVTbwxSlYL16bZVoebqzZvLGrPvZJkPuCu6vH9brvOuYo0q8hLVNkBeXc +imRpsDjLhQYzEJjoMTbaiVGnjBky+PWNiofJnwIDAQABAoIBAQCotF1KxLt/ejr/ +9ROCh9JJXV3v6tL5GgkSPOv9Oq2bHgSZer/cixJNW+5VWd5nbiSe3K1WuJBw5pbW +Wj4sWORPiRRR+3mjQzqeS/nGJDTOwWJo9K8IrUzOVhLEEYLX/ksxaXJyT8PehFyb +vbNwdhCIB6ZNcXDItTWE+95twWJ5lxAIj2dNwZZni3UkwwjYnCnqFtvHCKOg0NH2 +RjQcFYmu3fncNeqLezUSdVyRyXxSCHsUdlYeX/e44StCnXdrmLUHlb2P27ZVdPGh +SW7qTUPpmJKekYiRPOpTLj+ZKXIsANkyWO+7dVtZLBm5bIyAsmp0W/DmK+wRsejj +alFbIsh5AoGBANJr7HSG695wkfn+kvu/V8qHbt+KDv4WjWHjGRsUqvxoHOUNkQmW +vZWdk4gjHYn1l+QHWmoOE3AgyqtCZ4bFILkZPLN/F8Mh3+r4B0Ac4biJJt7XGMNQ +Nv4wsk7TR7CCARsjO7GP1PT60hpjMvYmc1E36gNM7QIZE9jBE+L8eWYtAoGBANy2 +JOAWf+QeBlur6o9feH76cEmpQzUUq4Lj9mmnXgIirSsFoBnDb8VA6Ws+ltL9U9H2 +vaCoaTyi9twW9zWj+Ywg2mVR5nlSAPfdlTWS1GLUbDotlj5apc/lvnGuNlWzN+I4 +Tu64hhgBXqGvRZ0o7HzFodqRAkpVXp6CQCqBM7p7AoGAIgO0K3oL8t87ma/fTra1 +mFWgRJ5qogQ/Qo2VZ11F7ptd4GD7CxPE/cSFLsKOadi7fu75XJ994OhMGrcXSR/g +lEtSFqn6y15UdgU2FtUUX+I72FXo+Nmkqh5xFHDu68d4Kkzdv2xCvn81K3LRsByz +E3P4biQnQ+mN3cIIVu79KNkCgYEAm6uctrEn4y2KLn5DInyj8GuTZ2ELFhVOIzPG +SR7TH451tTJyiblezDHMcOfkWUx0IlN1zCr8jtgiZXmNQzg0erFxWKU7ebZtGGYh +J3g4dLx+2Unt/mzRJqFUgbnueOO/Nr+gbJ+ZdLUCmeeVohOLOTXrws0kYGl2Izab +K1+VrKECgYEAxQohoOegA0f4mofisXItbwwqTIX3bLpxBc4woa1sB4kjNrLo4slc +qtWZGVlRxwBvQUg0cYj+xtr5nyBdHLy0qwX/kMq4GqQnvW6NqsbrP3MjCZ8NX/Sj +A2W0jx50Hs/XNw6IZFLYgWVoOzCaD+jYFpHhzUZyQD6/rYhwhHrNQmU= +-----END RSA PRIVATE KEY-----` + + tmp, _ := pem.Decode([]byte(privPem)) + privKey, _ := x509.ParsePKCS1PrivateKey(tmp.Bytes) + + pubPem := `-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtWp2PZz4TSU5A6ixw41H +dbfBuGJwPuTtrsdoUf0DQ0/DJBNPqOCBAgEu6ZdUqIbWJ5Da+nevjtncy5hENdi6 +XrXjyzlUxghMuXjE5SeLGpgfQvkqbTkYaFpMe8PTzNeze3fei8+Eu6mzeb6g1Grq +XznuPIc7bNss0w5iX9RiBM9dWPuXonx9xSEy0LYqJm7yXmshNe1aRwkjG/y5C26B +zBFnMKp9YRTua0DO1WqLNhcaRndalIFYouDNVTbwxSlYL16bZVoebqzZvLGrPvZJ +kPuCu6vH9brvOuYo0q8hLVNkBeXcimRpsDjLhQYzEJjoMTbaiVGnjBky+PWNiofJ +nwIDAQAB +-----END PUBLIC KEY-----` + + err := user_model.SetUserSetting(user.ID, chef_module.SettingPublicPem, pubPem) + assert.NoError(t, err) + + t.Run("Authenticate", func(t *testing.T) { + auth := &chef_router.Auth{} + + t.Run("MissingUser", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "POST", "/dummy") + u, err := auth.Verify(req, nil, nil, nil) + assert.Nil(t, u) + assert.NoError(t, err) + }) + + t.Run("NotExistingUser", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "POST", "/dummy") + req.Header.Set("X-Ops-Userid", "not-existing-user") + u, err := auth.Verify(req, nil, nil, nil) + assert.Nil(t, u) + assert.Error(t, err) + }) + + t.Run("Timestamp", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "POST", "/dummy") + req.Header.Set("X-Ops-Userid", user.Name) + u, err := auth.Verify(req, nil, nil, nil) + assert.Nil(t, u) + assert.Error(t, err) + + req.Header.Set("X-Ops-Timestamp", "2023-01-01T00:00:00Z") + u, err = auth.Verify(req, nil, nil, nil) + assert.Nil(t, u) + assert.Error(t, err) + }) + + t.Run("SigningVersion", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "POST", "/dummy") + req.Header.Set("X-Ops-Userid", user.Name) + req.Header.Set("X-Ops-Timestamp", time.Now().UTC().Format(time.RFC3339)) + u, err := auth.Verify(req, nil, nil, nil) + assert.Nil(t, u) + assert.Error(t, err) + + req.Header.Set("X-Ops-Sign", "version=none") + u, err = auth.Verify(req, nil, nil, nil) + assert.Nil(t, u) + assert.Error(t, err) + + req.Header.Set("X-Ops-Sign", "version=1.4") + u, err = auth.Verify(req, nil, nil, nil) + assert.Nil(t, u) + assert.Error(t, err) + + req.Header.Set("X-Ops-Sign", "version=1.0;algorithm=sha2") + u, err = auth.Verify(req, nil, nil, nil) + assert.Nil(t, u) + assert.Error(t, err) + + req.Header.Set("X-Ops-Sign", "version=1.0;algorithm=sha256") + u, err = auth.Verify(req, nil, nil, nil) + assert.Nil(t, u) + assert.Error(t, err) + }) + + t.Run("SignedHeaders", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + ts := time.Now().UTC().Format(time.RFC3339) + + req := NewRequest(t, "POST", "/dummy") + req.Header.Set("X-Ops-Userid", user.Name) + req.Header.Set("X-Ops-Timestamp", ts) + req.Header.Set("X-Ops-Sign", "version=1.0;algorithm=sha1") + req.Header.Set("X-Ops-Content-Hash", "unused") + req.Header.Set("X-Ops-Authorization-4", "dummy") + u, err := auth.Verify(req, nil, nil, nil) + assert.Nil(t, u) + assert.Error(t, err) + + signRequest := func(t *testing.T, req *http.Request, version string) { + username := req.Header.Get("X-Ops-Userid") + if version != "1.0" && version != "1.3" { + sum := sha1.Sum([]byte(username)) + username = base64.StdEncoding.EncodeToString(sum[:]) + } + + req.Header.Set("X-Ops-Sign", "version="+version) + + var data []byte + if version == "1.3" { + data = []byte(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 = []byte(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, + )) + } + + for k := range req.Header { + if strings.HasPrefix(k, "X-Ops-Authorization-") { + req.Header.Del(k) + } + } + + var signature []byte + if version == "1.3" || version == "1.2" { + var h hash.Hash + var ch crypto.Hash + if version == "1.3" { + h = sha256.New() + ch = crypto.SHA256 + } else { + h = sha1.New() + ch = crypto.SHA1 + } + h.Write(data) + + signature, _ = rsa.SignPKCS1v15(rand.Reader, privKey, ch, h.Sum(nil)) + } else { + c := new(big.Int).SetBytes(data) + m := new(big.Int).Exp(c, privKey.D, privKey.N) + + signature = m.Bytes() + } + + enc := base64.StdEncoding.EncodeToString(signature) + + const chunkSize = 60 + chunks := make([]string, 0, (len(enc)-1)/chunkSize+1) + currentLen := 0 + currentStart := 0 + for i := range enc { + if currentLen == chunkSize { + chunks = append(chunks, enc[currentStart:i]) + currentLen = 0 + currentStart = i + } + currentLen++ + } + chunks = append(chunks, enc[currentStart:]) + + for i, chunk := range chunks { + req.Header.Set(fmt.Sprintf("X-Ops-Authorization-%d", i+1), chunk) + } + } + + for _, v := range []string{"1.0", "1.1", "1.2", "1.3"} { + t.Run(v, func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + signRequest(t, req, v) + u, err = auth.Verify(req, nil, nil, nil) + assert.NotNil(t, u) + assert.NoError(t, err) + }) + } + }) + }) + + packageName := "test" + packageVersion := "1.0.1" + packageDescription := "Test Description" + packageAuthor := "KN4CK3R" + + root := fmt.Sprintf("/api/packages/%s/chef/api/v1", user.Name) + + uploadPackage := func(t *testing.T, version string, expectedStatus int) { + var body bytes.Buffer + mpw := multipart.NewWriter(&body) + part, _ := mpw.CreateFormFile("tarball", fmt.Sprintf("%s.tar.gz", version)) + zw := gzip.NewWriter(part) + tw := tar.NewWriter(zw) + + content := `{"name":"` + packageName + `","version":"` + version + `","description":"` + packageDescription + `","maintainer":"` + packageAuthor + `"}` + + hdr := &tar.Header{ + Name: packageName + "/metadata.json", + Mode: 0o600, + Size: int64(len(content)), + } + tw.WriteHeader(hdr) + tw.Write([]byte(content)) + + tw.Close() + zw.Close() + mpw.Close() + + req := NewRequestWithBody(t, "POST", root+"/cookbooks", &body) + req.Header.Add("Content-Type", mpw.FormDataContentType()) + AddBasicAuthHeader(req, user.Name) + MakeRequest(t, req, expectedStatus) + } + + t.Run("Upload", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithBody(t, "POST", root+"/cookbooks", bytes.NewReader([]byte{})) + MakeRequest(t, req, http.StatusUnauthorized) + + uploadPackage(t, packageVersion, http.StatusCreated) + + pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeChef) + assert.NoError(t, err) + assert.Len(t, pvs, 1) + + pd, err := packages.GetPackageDescriptor(db.DefaultContext, pvs[0]) + assert.NoError(t, err) + assert.NotNil(t, pd.SemVer) + assert.IsType(t, &chef_module.Metadata{}, pd.Metadata) + assert.Equal(t, packageName, pd.Package.Name) + assert.Equal(t, packageVersion, pd.Version.Version) + + pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID) + assert.NoError(t, err) + assert.Len(t, pfs, 1) + assert.Equal(t, fmt.Sprintf("%s.tar.gz", packageVersion), pfs[0].Name) + assert.True(t, pfs[0].IsLead) + + uploadPackage(t, packageVersion, http.StatusBadRequest) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/cookbooks/%s/versions/%s/download", root, packageName, packageVersion)) + MakeRequest(t, req, http.StatusOK) + }) + + t.Run("Universe", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", root+"/universe") + resp := MakeRequest(t, req, http.StatusOK) + + type VersionInfo struct { + LocationType string `json:"location_type"` + LocationPath string `json:"location_path"` + DownloadURL string `json:"download_url"` + Dependencies map[string]string `json:"dependencies"` + } + + var result map[string]map[string]*VersionInfo + DecodeJSON(t, resp, &result) + + assert.Len(t, result, 1) + assert.Contains(t, result, packageName) + + versions := result[packageName] + + assert.Len(t, versions, 1) + assert.Contains(t, versions, packageVersion) + + info := versions[packageVersion] + + assert.Equal(t, "opscode", info.LocationType) + assert.Equal(t, setting.AppURL+root[1:], info.LocationPath) + assert.Equal(t, fmt.Sprintf("%s%s/cookbooks/%s/versions/%s/download", setting.AppURL, root[1:], packageName, packageVersion), info.DownloadURL) + }) + + t.Run("Search", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + cases := []struct { + Query string + Start int + Items int + ExpectedTotal int + ExpectedResults int + }{ + {"", 0, 0, 1, 1}, + {"", 0, 10, 1, 1}, + {"gitea", 0, 10, 0, 0}, + {"test", 0, 10, 1, 1}, + {"test", 1, 10, 1, 0}, + } + + 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"` + } + + for i, c := range cases { + req := NewRequest(t, "GET", fmt.Sprintf("%s/search?q=%s&start=%d&items=%d", root, c.Query, c.Start, c.Items)) + req = AddBasicAuthHeader(req, user.Name) + resp := MakeRequest(t, req, http.StatusOK) + + var result Result + DecodeJSON(t, resp, &result) + + assert.Equal(t, c.ExpectedTotal, result.Total, "case %d: unexpected total hits", i) + assert.Len(t, result.Items, c.ExpectedResults, "case %d: unexpected result count", i) + + if len(result.Items) == 1 { + item := result.Items[0] + assert.Equal(t, packageName, item.CookbookName) + assert.Equal(t, packageAuthor, item.CookbookMaintainer) + assert.Equal(t, packageDescription, item.CookbookDescription) + assert.Equal(t, fmt.Sprintf("%s%s/cookbooks/%s", setting.AppURL, root[1:], packageName), item.Cookbook) + } + } + }) + + t.Run("EnumeratePackages", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + cases := []struct { + Sort string + Start int + Items int + ExpectedTotal int + ExpectedResults int + }{ + {"", 0, 0, 1, 1}, + {"", 0, 10, 1, 1}, + {"RECENTLY_ADDED", 0, 10, 1, 1}, + {"RECENTLY_UPDATED", 0, 10, 1, 1}, + {"", 1, 10, 1, 0}, + } + + 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"` + } + + for i, c := range cases { + req := NewRequest(t, "GET", fmt.Sprintf("%s/cookbooks?start=%d&items=%d&sort=%s", root, c.Start, c.Items, c.Sort)) + req = AddBasicAuthHeader(req, user.Name) + resp := MakeRequest(t, req, http.StatusOK) + + var result Result + DecodeJSON(t, resp, &result) + + assert.Equal(t, c.ExpectedTotal, result.Total, "case %d: unexpected total hits", i) + assert.Len(t, result.Items, c.ExpectedResults, "case %d: unexpected result count", i) + + if len(result.Items) == 1 { + item := result.Items[0] + assert.Equal(t, packageName, item.CookbookName) + assert.Equal(t, packageAuthor, item.CookbookMaintainer) + assert.Equal(t, packageDescription, item.CookbookDescription) + assert.Equal(t, fmt.Sprintf("%s%s/cookbooks/%s", setting.AppURL, root[1:], packageName), item.Cookbook) + } + } + }) + + t.Run("PackageMetadata", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/cookbooks/%s", root, packageName)) + resp := MakeRequest(t, req, http.StatusOK) + + 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"` + } + + var result Result + DecodeJSON(t, resp, &result) + + versionURL := fmt.Sprintf("%s%s/cookbooks/%s/versions/%s", setting.AppURL, root[1:], packageName, packageVersion) + + assert.Equal(t, packageName, result.Name) + assert.Equal(t, packageAuthor, result.Maintainer) + assert.Equal(t, packageDescription, result.Description) + assert.Equal(t, versionURL, result.LatestVersion) + assert.False(t, result.Deprecated) + assert.ElementsMatch(t, []string{versionURL}, result.Versions) + }) + + t.Run("PackageVersionMetadata", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/cookbooks/%s/versions/%s", root, packageName, packageVersion)) + resp := MakeRequest(t, req, http.StatusOK) + + 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"` + } + + var result Result + DecodeJSON(t, resp, &result) + + packageURL := fmt.Sprintf("%s%s/cookbooks/%s", setting.AppURL, root[1:], packageName) + + assert.Equal(t, packageVersion, result.Version) + assert.Equal(t, packageURL, result.Cookbook) + assert.Equal(t, fmt.Sprintf("%s/versions/%s/download", packageURL, packageVersion), result.File) + }) + + t.Run("Delete", func(t *testing.T) { + uploadPackage(t, "1.0.2", http.StatusCreated) + uploadPackage(t, "1.0.3", http.StatusCreated) + + t.Run("Version", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "DELETE", fmt.Sprintf("%s/cookbooks/%s/versions/%s", root, packageName, "1.0.2")) + MakeRequest(t, req, http.StatusUnauthorized) + + req = NewRequest(t, "DELETE", fmt.Sprintf("%s/cookbooks/%s/versions/%s", root, packageName, "1.0.2")) + AddBasicAuthHeader(req, user.Name) + MakeRequest(t, req, http.StatusOK) + + pv, err := packages.GetVersionByNameAndVersion(db.DefaultContext, user.ID, packages.TypeChef, packageName, "1.0.2") + assert.Nil(t, pv) + assert.Error(t, err) + }) + + t.Run("Package", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "DELETE", fmt.Sprintf("%s/cookbooks/%s", root, packageName)) + MakeRequest(t, req, http.StatusUnauthorized) + + req = NewRequest(t, "DELETE", fmt.Sprintf("%s/cookbooks/%s", root, packageName)) + AddBasicAuthHeader(req, user.Name) + MakeRequest(t, req, http.StatusOK) + + pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeChef) + assert.NoError(t, err) + assert.Empty(t, pvs) + }) + }) +} diff --git a/web_src/svg/gitea-chef.svg b/web_src/svg/gitea-chef.svg new file mode 100644 index 000000000..ce318c626 --- /dev/null +++ b/web_src/svg/gitea-chef.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + +