From 3d722c9c82d1d6733fd2c18ecaecab88f3d69beb Mon Sep 17 00:00:00 2001 From: KN4CK3R Date: Tue, 3 Jan 2023 22:11:35 +0000 Subject: [PATCH 1/7] Add CRAN package registry. --- custom/conf/app.example.ini | 2 + .../doc/advanced/config-cheat-sheet.en-us.md | 1 + docs/content/doc/packages/cran.en-us.md | 96 +++++++ docs/content/doc/packages/overview.en-us.md | 1 + models/packages/cran/search.go | 90 ++++++ models/packages/descriptor.go | 3 + models/packages/package.go | 6 + modules/packages/cran/metadata.go | 244 ++++++++++++++++ modules/packages/cran/metadata_test.go | 152 ++++++++++ modules/setting/packages.go | 2 + options/locale/locale_en-US.ini | 3 + public/img/svg/gitea-cran.svg | 1 + routers/api/packages/api.go | 19 ++ routers/api/packages/cran/cran.go | 267 ++++++++++++++++++ routers/api/v1/packages/package.go | 2 +- services/forms/package_form.go | 2 +- services/packages/packages.go | 2 + templates/package/content/cran.tmpl | 62 ++++ templates/package/metadata/cran.tmpl | 5 + templates/package/view.tmpl | 2 + templates/swagger/v1_json.tmpl | 1 + tests/integration/api_packages_cran_test.go | 242 ++++++++++++++++ web_src/svg/gitea-cran.svg | 15 + 23 files changed, 1218 insertions(+), 2 deletions(-) create mode 100644 docs/content/doc/packages/cran.en-us.md create mode 100644 models/packages/cran/search.go create mode 100644 modules/packages/cran/metadata.go create mode 100644 modules/packages/cran/metadata_test.go create mode 100644 public/img/svg/gitea-cran.svg create mode 100644 routers/api/packages/cran/cran.go create mode 100644 templates/package/content/cran.tmpl create mode 100644 templates/package/metadata/cran.tmpl create mode 100644 tests/integration/api_packages_cran_test.go create mode 100644 web_src/svg/gitea-cran.svg diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index cec5e8cf03821..8683e8883f180 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -2397,6 +2397,8 @@ ROUTER = console ;LIMIT_SIZE_CONAN = -1 ;; Maximum size of a Container upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`) ;LIMIT_SIZE_CONTAINER = -1 +;; Maximum size of a CRAN upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`) +;LIMIT_SIZE_CRAN = -1 ;; Maximum size of a Generic upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`) ;LIMIT_SIZE_GENERIC = -1 ;; Maximum size of a Helm 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 3ccef3130cac3..5fdbfa222a030 100644 --- a/docs/content/doc/advanced/config-cheat-sheet.en-us.md +++ b/docs/content/doc/advanced/config-cheat-sheet.en-us.md @@ -1191,6 +1191,7 @@ Task queue configuration has been moved to `queue.task`. However, the below conf - `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_CONTAINER`: **-1**: Maximum size of a Container upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`) +- `LIMIT_SIZE_CRAN`: **-1**: Maximum size of a CRAN upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`) - `LIMIT_SIZE_GENERIC`: **-1**: Maximum size of a Generic upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`) - `LIMIT_SIZE_HELM`: **-1**: Maximum size of a Helm upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`) - `LIMIT_SIZE_MAVEN`: **-1**: Maximum size of a Maven upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`) diff --git a/docs/content/doc/packages/cran.en-us.md b/docs/content/doc/packages/cran.en-us.md new file mode 100644 index 0000000000000..60ca572d95cfb --- /dev/null +++ b/docs/content/doc/packages/cran.en-us.md @@ -0,0 +1,96 @@ +--- +date: "2023-01-01T00:00:00+00:00" +title: "CRAN Packages Repository" +slug: "packages/cran" +draft: false +toc: false +menu: + sidebar: + parent: "packages" + name: "CRAN" + weight: 35 + identifier: "cran" +--- + +# CRAN Packages Repository + +Publish [CRAN](https://cran.r-project.org/) packages for your user or organization. + +**Table of Contents** + +{{< toc >}} + +## Requirements + +To work with the CRAN package registry, you need to install [the R toolset](https://cran.r-project.org/). + +## Configuring the package registry + +To register the package registry you need to add it in your `Rprofile.site` file: + +``` +local({ + r <- list("gitea" = "https://gitea.example.com/api/packages/{owner}/cran") + options(repos = r) +}) +``` + +| Parameter | Description | +| --------- | ----------- | +| `owner` | The owner of the package. | + +If you need to provide credentials, you may embed them as part of the url (`https://user:password@gitea.example.com/...`). + +## Publish a package + +To publish a package, perform a HTTP PUT operation with the package content in the request body. + +Source packages: + +``` +PUT https://gitea.example.com/api/packages/{owner}/cran/src +``` + +| Parameter | Description | +| --------- | ----------- | +| `owner` | The owner of the package. | + +Binary packages: + +``` +PUT https://gitea.example.com/api/packages/{owner}/cran/bin?platform={platform}&rversion={rversion} +``` + +| Parameter | Description | +| ---------- | ----------- | +| `owner` | The owner of the package. | +| `platform` | The name of the platform. | +| `rversion` | The R version of the binary. | + +For example: + +```shell +curl --user your_username:your_password_or_token \ + --upload-file path/to/package.zip \ + https://gitea.example.com/api/packages/testuser/cran/bin?platform=windows&rversion=4.2 +``` + +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 CRAN package from the package registry, execute the following command: + +```shell +install.packages({package_name}) +``` + +| Parameter | Description | +| -------------- | ----------- | +| `package_name` | The package name. | + +For example: + +```shell +install.packages("testpackage") +``` diff --git a/docs/content/doc/packages/overview.en-us.md b/docs/content/doc/packages/overview.en-us.md index 239ba6834e88d..6b77dbe999747 100644 --- a/docs/content/doc/packages/overview.en-us.md +++ b/docs/content/doc/packages/overview.en-us.md @@ -29,6 +29,7 @@ The following package managers are currently supported: | [Composer]({{< relref "doc/packages/composer.en-us.md" >}}) | PHP | `composer` | | [Conan]({{< relref "doc/packages/conan.en-us.md" >}}) | C++ | `conan` | | [Container]({{< relref "doc/packages/container.en-us.md" >}}) | - | any OCI compliant client | +| [CRAN]({{< relref "doc/packages/cran.en-us.md" >}}) | R | - | | [Generic]({{< relref "doc/packages/generic.en-us.md" >}}) | - | any HTTP client | | [Helm]({{< relref "doc/packages/helm.en-us.md" >}}) | - | any HTTP client, `cm-push` | | [Maven]({{< relref "doc/packages/maven.en-us.md" >}}) | Java | `mvn`, `gradle` | diff --git a/models/packages/cran/search.go b/models/packages/cran/search.go new file mode 100644 index 0000000000000..8a8b52a35ec80 --- /dev/null +++ b/models/packages/cran/search.go @@ -0,0 +1,90 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cran + +import ( + "context" + "strconv" + "strings" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/packages" + cran_module "code.gitea.io/gitea/modules/packages/cran" + + "xorm.io/builder" +) + +type SearchOptions struct { + OwnerID int64 + FileType string + Platform string + RVersion string + Filename string +} + +func (opts *SearchOptions) toConds() builder.Cond { + var cond builder.Cond = builder.Eq{ + "package.type": packages.TypeCran, + "package.owner_id": opts.OwnerID, + "package_version.is_internal": false, + } + + if opts.Filename != "" { + cond = cond.And(builder.Eq{"package_file.lower_name": strings.ToLower(opts.Filename)}) + } + + var propsCond builder.Cond = builder.Eq{ + "package_property.ref_type": packages.PropertyTypeFile, + } + propsCond = propsCond.And(builder.Expr("package_property.ref_id = package_file.id")) + + count := 1 + propsCondBlock := builder.Eq{"package_property.name": cran_module.PropertyType}.And(builder.Eq{"package_property.value": opts.FileType}) + + if opts.Platform != "" { + count += 2 + propsCondBlock = propsCondBlock. + Or(builder.Eq{"package_property.name": cran_module.PropertyPlatform}.And(builder.Eq{"package_property.value": opts.Platform})). + Or(builder.Eq{"package_property.name": cran_module.PropertyRVersion}.And(builder.Eq{"package_property.value": opts.RVersion})) + } + + propsCond = propsCond.And(propsCondBlock) + + cond = cond.And(builder.Eq{ + strconv.Itoa(count): builder.Select("COUNT(*)").Where(propsCond).From("package_property"), + }) + + return cond +} + +func SearchLatestVersions(ctx context.Context, opts *SearchOptions) ([]*packages.PackageVersion, error) { + sess := db.GetEngine(ctx). + Table("package_version"). + Select("package_version.*"). + Join("LEFT", "package_version pv2", builder.Expr("package_version.package_id = pv2.package_id AND pv2.is_internal = ? AND (package_version.created_unix < pv2.created_unix OR (package_version.created_unix = pv2.created_unix AND package_version.id < pv2.id))", false)). + Join("INNER", "package", "package.id = package_version.package_id"). + Join("INNER", "package_file", "package_file.version_id = package_version.id"). + Where(opts.toConds().And(builder.Expr("pv2.id IS NULL"))). + Asc("package.name") + + pvs := make([]*packages.PackageVersion, 0, 10) + return pvs, sess.Find(&pvs) +} + +func SearchFile(ctx context.Context, opts *SearchOptions) (*packages.PackageFile, error) { + sess := db.GetEngine(ctx). + Table("package_version"). + Select("package_file.*"). + Join("INNER", "package", "package.id = package_version.package_id"). + Join("INNER", "package_file", "package_file.version_id = package_version.id"). + Where(opts.toConds()) + + pf := &packages.PackageFile{} + if has, err := sess.Get(pf); err != nil { + return nil, err + } else if !has { + return nil, packages.ErrPackageFileNotExist + } + return pf, nil +} diff --git a/models/packages/descriptor.go b/models/packages/descriptor.go index 34f1cad87dc45..dc4bdcf9451da 100644 --- a/models/packages/descriptor.go +++ b/models/packages/descriptor.go @@ -14,6 +14,7 @@ import ( "code.gitea.io/gitea/modules/packages/composer" "code.gitea.io/gitea/modules/packages/conan" "code.gitea.io/gitea/modules/packages/container" + "code.gitea.io/gitea/modules/packages/cran" "code.gitea.io/gitea/modules/packages/helm" "code.gitea.io/gitea/modules/packages/maven" "code.gitea.io/gitea/modules/packages/npm" @@ -134,6 +135,8 @@ func GetPackageDescriptor(ctx context.Context, pv *PackageVersion) (*PackageDesc metadata = &conan.Metadata{} case TypeContainer: metadata = &container.Metadata{} + case TypeCran: + metadata = &cran.Metadata{} case TypeGeneric: // generic packages have no metadata case TypeHelm: diff --git a/models/packages/package.go b/models/packages/package.go index a804f35de3526..ea76c0639d316 100644 --- a/models/packages/package.go +++ b/models/packages/package.go @@ -33,6 +33,7 @@ const ( TypeComposer Type = "composer" TypeConan Type = "conan" TypeContainer Type = "container" + TypeCran Type = "cran" TypeGeneric Type = "generic" TypeHelm Type = "helm" TypeMaven Type = "maven" @@ -48,6 +49,7 @@ var TypeList = []Type{ TypeComposer, TypeConan, TypeContainer, + TypeCran, TypeGeneric, TypeHelm, TypeMaven, @@ -68,6 +70,8 @@ func (pt Type) Name() string { return "Conan" case TypeContainer: return "Container" + case TypeCran: + return "CRAN" case TypeGeneric: return "Generic" case TypeHelm: @@ -99,6 +103,8 @@ func (pt Type) SVGName() string { return "gitea-conan" case TypeContainer: return "octicon-container" + case TypeCran: + return "gitea-cran" case TypeGeneric: return "octicon-package" case TypeHelm: diff --git a/modules/packages/cran/metadata.go b/modules/packages/cran/metadata.go new file mode 100644 index 0000000000000..24e6f323af888 --- /dev/null +++ b/modules/packages/cran/metadata.go @@ -0,0 +1,244 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cran + +import ( + "archive/tar" + "archive/zip" + "bufio" + "compress/gzip" + "io" + "path" + "regexp" + "strings" + + "code.gitea.io/gitea/modules/util" +) + +const ( + PropertyType = "cran.type" + PropertyPlatform = "cran.platform" + PropertyRVersion = "cran.rvserion" + + TypeSource = "source" + TypeBinary = "binary" +) + +var ( + ErrMissingDescriptionFile = util.NewInvalidArgumentErrorf("DESCRIPTION file is missing") + ErrInvalidName = util.NewInvalidArgumentErrorf("package name is invalid") + ErrInvalidVersion = util.NewInvalidArgumentErrorf("package version is invalid") +) + +var ( + fieldPattern = regexp.MustCompile(`\A\S+:`) + namePattern = regexp.MustCompile(`\A[a-zA-Z][a-zA-Z0-9\.]*[a-zA-Z0-9]\z`) + versionPattern = regexp.MustCompile(`\A[0-9]+(?:[.\-][0-9]+){1,3}\z`) + authorReplacePattern = regexp.MustCompile(`[\[\(].+?[\]\)]`) +) + +// Package represents a CRAN package +type Package struct { + Name string + Version string + FileExtension string + Metadata *Metadata +} + +// Metadata represents the metadata of a CRAN package +type Metadata struct { + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` + ProjectURL []string `json:"project_url,omitempty"` + License string `json:"license,omitempty"` + Authors []string `json:"authors,omitempty"` + Depends []string `json:"depends,omitempty"` + Imports []string `json:"imports,omitempty"` + Suggests []string `json:"suggests,omitempty"` + LinkingTo []string `json:"linking_to,omitempty"` + NeedsCompilation bool `json:"needs_compilation"` +} + +type ReaderReaderAt interface { + io.Reader + io.ReaderAt +} + +// ParsePackage reads the package metadata from a CRAN package +// .zip and .tar.gz/.tgz files are supported. +func ParsePackage(r ReaderReaderAt, size int64) (*Package, error) { + magicBytes := make([]byte, 2) + if _, err := r.ReadAt(magicBytes, 0); err != nil { + return nil, err + } + + if magicBytes[0] == 0x1F && magicBytes[1] == 0x8B { + return parsePackageTarGz(r) + } + return parsePackageZip(r, size) +} + +func parsePackageTarGz(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 path.Base(hd.Name) == "DESCRIPTION" { + p, err := ParseDescription(tr) + if p != nil { + p.FileExtension = ".tar.gz" + } + return p, err + } + } + + return nil, ErrMissingDescriptionFile +} + +func parsePackageZip(r io.ReaderAt, size int64) (*Package, error) { + zr, err := zip.NewReader(r, size) + if err != nil { + return nil, err + } + + for _, file := range zr.File { + if strings.Count(file.Name, "/") > 1 { + continue + } + + if path.Base(file.Name) == "DESCRIPTION" { + f, err := zr.Open(file.Name) + if err != nil { + return nil, err + } + defer f.Close() + + p, err := ParseDescription(f) + if p != nil { + p.FileExtension = ".zip" + } + return p, err + } + } + + return nil, ErrMissingDescriptionFile +} + +// ParseDescription parses a DESCRIPTION file to retrieve the metadata of a CRAN package +func ParseDescription(r io.Reader) (*Package, error) { + p := &Package{ + Metadata: &Metadata{}, + } + + scanner := bufio.NewScanner(r) + + var b strings.Builder + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + if !fieldPattern.MatchString(line) { + b.WriteRune(' ') + b.WriteString(line) + continue + } + + if err := setField(p, b.String()); err != nil { + return nil, err + } + + b.Reset() + b.WriteString(line) + } + + if err := setField(p, b.String()); err != nil { + return nil, err + } + + if err := scanner.Err(); err != nil { + return nil, err + } + + return p, nil +} + +func setField(p *Package, data string) error { + const listDelimiter = ", " + + if data == "" { + return nil + } + + parts := strings.SplitN(data, ":", 2) + if len(parts) != 2 { + return nil + } + + name := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + + switch name { + case "Package": + if !namePattern.MatchString(value) { + return ErrInvalidName + } + p.Name = value + case "Version": + if !versionPattern.MatchString(value) { + return ErrInvalidVersion + } + p.Version = value + case "Title": + p.Metadata.Title = value + case "Description": + p.Metadata.Description = value + case "URL": + p.Metadata.ProjectURL = splitAndTrim(value, listDelimiter) + case "License": + p.Metadata.License = value + case "Author": + p.Metadata.Authors = splitAndTrim(authorReplacePattern.ReplaceAllString(value, ""), listDelimiter) + case "Depends": + p.Metadata.Depends = splitAndTrim(value, listDelimiter) + case "Imports": + p.Metadata.Imports = splitAndTrim(value, listDelimiter) + case "Suggests": + p.Metadata.Suggests = splitAndTrim(value, listDelimiter) + case "LinkingTo": + p.Metadata.LinkingTo = splitAndTrim(value, listDelimiter) + case "NeedsCompilation": + p.Metadata.NeedsCompilation = value == "yes" + } + + return nil +} + +func splitAndTrim(s, sep string) []string { + items := strings.Split(s, sep) + for i := range items { + items[i] = strings.TrimSpace(items[i]) + } + return items +} diff --git a/modules/packages/cran/metadata_test.go b/modules/packages/cran/metadata_test.go new file mode 100644 index 0000000000000..ff68c34c51a36 --- /dev/null +++ b/modules/packages/cran/metadata_test.go @@ -0,0 +1,152 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cran + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +const ( + packageName = "gitea" + packageVersion = "1.0.1" + author = "KN4CK3R" + description = "Package Description" + projectURL = "https://gitea.io" + license = "GPL (>= 2)" +) + +func createDescription(name, version string) *bytes.Buffer { + var buf bytes.Buffer + fmt.Fprintln(&buf, "Package:", name) + fmt.Fprintln(&buf, "Version:", version) + fmt.Fprintln(&buf, "Description:", "Package\n\n Description") + fmt.Fprintln(&buf, "URL:", projectURL) + fmt.Fprintln(&buf, "Imports: abc,\n123") + fmt.Fprintln(&buf, "NeedsCompilation: yes") + fmt.Fprintln(&buf, "License:", license) + fmt.Fprintln(&buf, "Author:", author) + return &buf +} + +func TestParsePackage(t *testing.T) { + t.Run(".tar.gz", func(t *testing.T) { + createArchive := func(filename string, content []byte) *bytes.Reader { + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gw) + hdr := &tar.Header{ + Name: filename, + Mode: 0o600, + Size: int64(len(content)), + } + tw.WriteHeader(hdr) + tw.Write(content) + tw.Close() + gw.Close() + return bytes.NewReader(buf.Bytes()) + } + + t.Run("MissingDescriptionFile", func(t *testing.T) { + buf := createArchive( + "dummy.txt", + []byte{}, + ) + + p, err := ParsePackage(buf, buf.Size()) + assert.Nil(t, p) + assert.ErrorIs(t, err, ErrMissingDescriptionFile) + }) + + t.Run("Valid", func(t *testing.T) { + buf := createArchive( + "package/DESCRIPTION", + createDescription(packageName, packageVersion).Bytes(), + ) + + p, err := ParsePackage(buf, buf.Size()) + + assert.NotNil(t, p) + assert.NoError(t, err) + + assert.Equal(t, packageName, p.Name) + assert.Equal(t, packageVersion, p.Version) + }) + }) + + t.Run(".zip", func(t *testing.T) { + createArchive := func(filename string, content []byte) *bytes.Reader { + var buf bytes.Buffer + archive := zip.NewWriter(&buf) + w, _ := archive.Create(filename) + w.Write(content) + archive.Close() + return bytes.NewReader(buf.Bytes()) + } + + t.Run("MissingDescriptionFile", func(t *testing.T) { + buf := createArchive( + "dummy.txt", + []byte{}, + ) + + p, err := ParsePackage(buf, buf.Size()) + assert.Nil(t, p) + assert.ErrorIs(t, err, ErrMissingDescriptionFile) + }) + + t.Run("Valid", func(t *testing.T) { + buf := createArchive( + "package/DESCRIPTION", + createDescription(packageName, packageVersion).Bytes(), + ) + + p, err := ParsePackage(buf, buf.Size()) + assert.NotNil(t, p) + assert.NoError(t, err) + + assert.Equal(t, packageName, p.Name) + assert.Equal(t, packageVersion, p.Version) + }) + }) +} + +func TestParseDescription(t *testing.T) { + t.Run("InvalidName", func(t *testing.T) { + for _, name := range []string{"123abc", "ab-cd", "ab cd", "ab/cd"} { + p, err := ParseDescription(createDescription(name, packageVersion)) + assert.Nil(t, p) + assert.ErrorIs(t, err, ErrInvalidName) + } + }) + + t.Run("InvalidVersion", func(t *testing.T) { + for _, version := range []string{"1", "1 0", "1.2.3.4.5", "1-2-3-4-5", "1.", "1.0.", "1-", "1-0-"} { + p, err := ParseDescription(createDescription(packageName, version)) + assert.Nil(t, p) + assert.ErrorIs(t, err, ErrInvalidVersion) + } + }) + + t.Run("Valid", func(t *testing.T) { + p, err := ParseDescription(createDescription(packageName, packageVersion)) + assert.NoError(t, err) + assert.NotNil(t, p) + + assert.Equal(t, packageName, p.Name) + assert.Equal(t, packageVersion, p.Version) + assert.Equal(t, description, p.Metadata.Description) + assert.ElementsMatch(t, []string{projectURL}, p.Metadata.ProjectURL) + assert.ElementsMatch(t, []string{author}, p.Metadata.Authors) + assert.Equal(t, license, p.Metadata.License) + assert.ElementsMatch(t, []string{"abc", "123"}, p.Metadata.Imports) + assert.True(t, p.Metadata.NeedsCompilation) + }) +} diff --git a/modules/setting/packages.go b/modules/setting/packages.go index 120fbb5bda8e7..f5af16b6dc451 100644 --- a/modules/setting/packages.go +++ b/modules/setting/packages.go @@ -28,6 +28,7 @@ var ( LimitSizeComposer int64 LimitSizeConan int64 LimitSizeContainer int64 + LimitSizeCran int64 LimitSizeGeneric int64 LimitSizeHelm int64 LimitSizeMaven int64 @@ -67,6 +68,7 @@ func newPackages() { Packages.LimitSizeComposer = mustBytes(sec, "LIMIT_SIZE_COMPOSER") Packages.LimitSizeConan = mustBytes(sec, "LIMIT_SIZE_CONAN") Packages.LimitSizeContainer = mustBytes(sec, "LIMIT_SIZE_CONTAINER") + Packages.LimitSizeCran = mustBytes(sec, "LIMIT_SIZE_CRAN") Packages.LimitSizeGeneric = mustBytes(sec, "LIMIT_SIZE_GENERIC") Packages.LimitSizeHelm = mustBytes(sec, "LIMIT_SIZE_HELM") Packages.LimitSizeMaven = mustBytes(sec, "LIMIT_SIZE_MAVEN") diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 62471abe6f19c..0517c33a66ae2 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -3144,6 +3144,9 @@ container.layers = Image Layers container.labels = Labels container.labels.key = Key container.labels.value = Value +cran.registry = Setup this registry in your Rprofile.site file: +cran.install = To install the package, run the following command: +cran.documentation = For more information on the CRAN registry, see the documentation. generic.download = Download package from the command line: generic.documentation = For more information on the generic registry, see the documentation. helm.registry = Setup this registry from the command line: diff --git a/public/img/svg/gitea-cran.svg b/public/img/svg/gitea-cran.svg new file mode 100644 index 0000000000000..7b63a84d53ec0 --- /dev/null +++ b/public/img/svg/gitea-cran.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 78eb5e860be26..3599a370b219e 100644 --- a/routers/api/packages/api.go +++ b/routers/api/packages/api.go @@ -17,6 +17,7 @@ import ( "code.gitea.io/gitea/routers/api/packages/composer" "code.gitea.io/gitea/routers/api/packages/conan" "code.gitea.io/gitea/routers/api/packages/container" + "code.gitea.io/gitea/routers/api/packages/cran" "code.gitea.io/gitea/routers/api/packages/generic" "code.gitea.io/gitea/routers/api/packages/helm" "code.gitea.io/gitea/routers/api/packages/maven" @@ -167,6 +168,24 @@ func CommonRoutes(ctx gocontext.Context) *web.Route { }) }) }, reqPackageAccess(perm.AccessModeRead)) + r.Group("/cran", func() { + r.Group("/src", func() { + r.Group("/contrib", func() { + r.Get("/PACKAGES", cran.EnumerateSourcePackages) + r.Get("/PACKAGES{format}", cran.EnumerateSourcePackages) + r.Get("/{filename}", cran.DownloadSourcePackageFile) + }) + r.Put("", reqPackageAccess(perm.AccessModeWrite), 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("/generic", func() { r.Group("/{packagename}/{packageversion}", func() { r.Delete("", reqPackageAccess(perm.AccessModeWrite), generic.DeletePackage) diff --git a/routers/api/packages/cran/cran.go b/routers/api/packages/cran/cran.go new file mode 100644 index 0000000000000..6528a15d17b8e --- /dev/null +++ b/routers/api/packages/cran/cran.go @@ -0,0 +1,267 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cran + +import ( + "compress/gzip" + "errors" + "fmt" + "io" + "net/http" + "strings" + + packages_model "code.gitea.io/gitea/models/packages" + cran_model "code.gitea.io/gitea/models/packages/cran" + "code.gitea.io/gitea/modules/context" + packages_module "code.gitea.io/gitea/modules/packages" + cran_module "code.gitea.io/gitea/modules/packages/cran" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/routers/api/packages/helper" + packages_service "code.gitea.io/gitea/services/packages" +) + +func apiError(ctx *context.Context, status int, obj interface{}) { + helper.LogAndProcessError(ctx, status, obj, func(message string) { + ctx.PlainText(status, message) + }) +} + +func EnumerateSourcePackages(ctx *context.Context) { + enumeratePackages(ctx, ctx.Params("format"), &cran_model.SearchOptions{ + OwnerID: ctx.Package.Owner.ID, + FileType: cran_module.TypeSource, + }) +} + +func EnumerateBinaryPackages(ctx *context.Context) { + enumeratePackages(ctx, ctx.Params("format"), &cran_model.SearchOptions{ + OwnerID: ctx.Package.Owner.ID, + FileType: cran_module.TypeBinary, + Platform: ctx.Params("platform"), + RVersion: ctx.Params("rversion"), + }) +} + +func enumeratePackages(ctx *context.Context, format string, opts *cran_model.SearchOptions) { + if format != "" && format != ".gz" { + apiError(ctx, http.StatusNotFound, nil) + return + } + + pvs, err := cran_model.SearchLatestVersions(ctx, opts) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + if len(pvs) == 0 { + apiError(ctx, http.StatusNotFound, nil) + return + } + + pds, err := packages_model.GetPackageDescriptors(ctx, pvs) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + var w io.Writer = ctx.Resp + + if format == ".gz" { + ctx.Resp.Header().Set("Content-Type", "application/x-gzip") + + gzw := gzip.NewWriter(w) + defer gzw.Close() + + w = gzw + } else { + ctx.Resp.Header().Set("Content-Type", "text/plain;charset=utf-8") + } + ctx.Resp.WriteHeader(http.StatusOK) + + for i, pd := range pds { + if i > 0 { + fmt.Fprintln(w) + } + + var pfd *packages_model.PackageFileDescriptor + for _, d := range pd.Files { + if d.Properties.GetByName(cran_module.PropertyType) == opts.FileType && + d.Properties.GetByName(cran_module.PropertyPlatform) == opts.Platform && + d.Properties.GetByName(cran_module.PropertyRVersion) == opts.RVersion { + pfd = d + break + } + } + + metadata := pd.Metadata.(*cran_module.Metadata) + + fmt.Fprintln(w, "Package:", pd.Package.Name) + fmt.Fprintln(w, "Version:", pd.Version.Version) + if metadata.License != "" { + fmt.Fprintln(w, "License:", metadata.License) + } + if len(metadata.Depends) > 0 { + fmt.Fprintln(w, "Depends:", strings.Join(metadata.Depends, ", ")) + } + if len(metadata.Imports) > 0 { + fmt.Fprintln(w, "Imports:", strings.Join(metadata.Imports, ", ")) + } + if len(metadata.LinkingTo) > 0 { + fmt.Fprintln(w, "LinkingTo:", strings.Join(metadata.LinkingTo, ", ")) + } + if len(metadata.Suggests) > 0 { + fmt.Fprintln(w, "Suggests:", strings.Join(metadata.Suggests, ", ")) + } + needsCompilation := "no" + if metadata.NeedsCompilation { + needsCompilation = "yes" + } + fmt.Fprintln(w, "NeedsCompilation:", needsCompilation) + fmt.Fprintln(w, "MD5sum:", pfd.Blob.HashMD5) + } +} + +func UploadSourcePackageFile(ctx *context.Context) { + uploadPackageFile( + ctx, + packages_model.EmptyFileKey, + map[string]string{ + cran_module.PropertyType: cran_module.TypeSource, + }, + ) +} + +func UploadBinaryPackageFile(ctx *context.Context) { + platform, rversion := ctx.FormTrim("platform"), ctx.FormTrim("rversion") + if platform == "" || rversion == "" { + apiError(ctx, http.StatusBadRequest, nil) + return + } + + uploadPackageFile( + ctx, + platform+"|"+rversion, + map[string]string{ + cran_module.PropertyType: cran_module.TypeBinary, + cran_module.PropertyPlatform: platform, + cran_module.PropertyRVersion: rversion, + }, + ) +} + +func uploadPackageFile(ctx *context.Context, compositeKey string, properties map[string]string) { + upload, close, err := ctx.UploadStream() + if err != nil { + apiError(ctx, http.StatusBadRequest, err) + return + } + if close { + defer upload.Close() + } + + buf, err := packages_module.CreateHashedBufferFromReader(upload, 32*1024*1024) + 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( + &packages_service.PackageCreationInfo{ + PackageInfo: packages_service.PackageInfo{ + Owner: ctx.Package.Owner, + PackageType: packages_model.TypeCran, + Name: pck.Name, + Version: pck.Version, + }, + SemverCompatible: false, + Creator: ctx.Doer, + Metadata: pck.Metadata, + }, + &packages_service.PackageFileCreationInfo{ + PackageFileInfo: packages_service.PackageFileInfo{ + Filename: fmt.Sprintf("%s_%s%s", pck.Name, pck.Version, pck.FileExtension), + CompositeKey: compositeKey, + }, + Creator: ctx.Doer, + Data: buf, + IsLead: true, + Properties: properties, + }, + ) + if err != nil { + switch err { + case packages_model.ErrDuplicatePackageFile: + apiError(ctx, http.StatusConflict, err) + case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize: + apiError(ctx, http.StatusForbidden, err) + default: + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + ctx.Status(http.StatusCreated) +} + +func DownloadSourcePackageFile(ctx *context.Context) { + downloadPackageFile(ctx, &cran_model.SearchOptions{ + OwnerID: ctx.Package.Owner.ID, + FileType: cran_module.TypeSource, + Filename: ctx.Params("filename"), + }) +} + +func DownloadBinaryPackageFile(ctx *context.Context) { + downloadPackageFile(ctx, &cran_model.SearchOptions{ + OwnerID: ctx.Package.Owner.ID, + FileType: cran_module.TypeBinary, + Platform: ctx.Params("platform"), + RVersion: ctx.Params("rversion"), + Filename: ctx.Params("filename"), + }) +} + +func downloadPackageFile(ctx *context.Context, opts *cran_model.SearchOptions) { + pf, err := cran_model.SearchFile(ctx, opts) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + apiError(ctx, http.StatusNotFound, err) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + + s, _, err := packages_service.GetPackageFileStream(ctx, pf) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + apiError(ctx, http.StatusNotFound, err) + } else { + apiError(ctx, http.StatusInternalServerError, err) + } + return + } + defer s.Close() + + ctx.ServeContent(s, &context.ServeHeaderOptions{ + Filename: pf.Name, + LastModified: pf.CreatedUnix.AsLocalTime(), + }) +} diff --git a/routers/api/v1/packages/package.go b/routers/api/v1/packages/package.go index 6f9083ba327b5..cad68e8f34642 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: [composer, conan, container, generic, helm, maven, npm, nuget, pub, pypi, rubygems, vagrant] + // enum: [composer, conan, container, cran, generic, helm, maven, npm, nuget, pub, pypi, rubygems, vagrant] // - name: q // in: query // description: name filter diff --git a/services/forms/package_form.go b/services/forms/package_form.go index 734bb05dc69f7..85de50bd91f0e 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(composer,conan,container,generic,helm,maven,npm,nuget,pub,pypi,rubygems,vagrant)"` + Type string `binding:"Required;In(composer,conan,container,cran,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 49f5a2fac4e21..e07e024ddd14c 100644 --- a/services/packages/packages.go +++ b/services/packages/packages.go @@ -337,6 +337,8 @@ func checkSizeQuotaExceeded(ctx context.Context, doer, owner *user_model.User, p typeSpecificSize = setting.Packages.LimitSizeConan case packages_model.TypeContainer: typeSpecificSize = setting.Packages.LimitSizeContainer + case packages_model.TypeCran: + typeSpecificSize = setting.Packages.LimitSizeCran case packages_model.TypeGeneric: typeSpecificSize = setting.Packages.LimitSizeGeneric case packages_model.TypeHelm: diff --git a/templates/package/content/cran.tmpl b/templates/package/content/cran.tmpl new file mode 100644 index 0000000000000..34cf893de2a80 --- /dev/null +++ b/templates/package/content/cran.tmpl @@ -0,0 +1,62 @@ +{{if eq .PackageDescriptor.Package.Type "cran"}} +

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

+
+
+
+ +
local({
+	r <- list("gitea" = "{{AppUrl}}api/packages/{{.PackageDescriptor.Owner.Name}}/cran")
+	options(repos = r)
+})
+
+
+ +
install.packages("{{.PackageDescriptor.Package.Name}}")
+
+
+ +
+
+
+ + {{if or .PackageDescriptor.Metadata.Description .PackageDescriptor.Metadata.Title}} +

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

+
+ {{if .PackageDescriptor.Metadata.Description}}{{.PackageDescriptor.Metadata.Description}}{{.PackageDescriptor.Metadata.Title}}{{else}}{{end}} +
+ {{end}} + + {{if or .PackageDescriptor.Metadata.Imports .PackageDescriptor.Metadata.Depends .PackageDescriptor.Metadata.LinkingTo .PackageDescriptor.Metadata.Suggests}} +

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

+
+ + + {{if .PackageDescriptor.Metadata.Imports}} + + + + + {{end}} + {{if .PackageDescriptor.Metadata.Depends}} + + + + + {{end}} + {{if .PackageDescriptor.Metadata.LinkingTo}} + + + + + {{end}} + {{if .PackageDescriptor.Metadata.Suggests}} + + + + + {{end}} + +
Imports{{Join .PackageDescriptor.Metadata.Imports ", "}}
Depends{{Join .PackageDescriptor.Metadata.Depends ", "}}
LinkingTo{{Join .PackageDescriptor.Metadata.LinkingTo ", "}}
Suggests{{Join .PackageDescriptor.Metadata.Suggests ", "}}
+
+ {{end}} +{{end}} diff --git a/templates/package/metadata/cran.tmpl b/templates/package/metadata/cran.tmpl new file mode 100644 index 0000000000000..7b113ec797647 --- /dev/null +++ b/templates/package/metadata/cran.tmpl @@ -0,0 +1,5 @@ +{{if eq .PackageDescriptor.Package.Type "cran"}} + {{if .PackageDescriptor.Metadata.License}}
{{svg "octicon-law" 16 "mr-3"}} {{.PackageDescriptor.Metadata.License}}
{{end}} + {{range .PackageDescriptor.Metadata.Authors}}
{{svg "octicon-person" 16 "mr-3"}} {{.}}
{{end}} + {{range .PackageDescriptor.Metadata.ProjectURL}}
{{svg "octicon-link-external" 16 "mr-3"}} {{$.locale.Tr "packages.details.project_site"}}
{{end}} +{{end}} diff --git a/templates/package/view.tmpl b/templates/package/view.tmpl index a5b2a2ef68a39..662b10ddf9c26 100644 --- a/templates/package/view.tmpl +++ b/templates/package/view.tmpl @@ -22,6 +22,7 @@ {{template "package/content/composer" .}} {{template "package/content/conan" .}} {{template "package/content/container" .}} + {{template "package/content/cran" .}} {{template "package/content/generic" .}} {{template "package/content/helm" .}} {{template "package/content/maven" .}} @@ -45,6 +46,7 @@ {{template "package/metadata/composer" .}} {{template "package/metadata/conan" .}} {{template "package/metadata/container" .}} + {{template "package/metadata/cran" .}} {{template "package/metadata/generic" .}} {{template "package/metadata/helm" .}} {{template "package/metadata/maven" .}} diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index c2232825966d3..6c09db7dabdd1 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -1955,6 +1955,7 @@ "composer", "conan", "container", + "cran", "generic", "helm", "maven", diff --git a/tests/integration/api_packages_cran_test.go b/tests/integration/api_packages_cran_test.go new file mode 100644 index 0000000000000..9ef23226db07f --- /dev/null +++ b/tests/integration/api_packages_cran_test.go @@ -0,0 +1,242 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "fmt" + "net/http" + "testing" + + "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" + cran_module "code.gitea.io/gitea/modules/packages/cran" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestPackageCran(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + packageName := "test.package" + packageVersion := "1.0.3" + packageAuthor := "KN4CK3R" + packageDescription := "Gitea Test Package" + + createDescription := func(name, version string) []byte { + var buf bytes.Buffer + fmt.Fprintln(&buf, "Package:", name) + fmt.Fprintln(&buf, "Version:", version) + fmt.Fprintln(&buf, "Description:", packageDescription) + fmt.Fprintln(&buf, "Imports: abc,\n123") + fmt.Fprintln(&buf, "NeedsCompilation: yes") + fmt.Fprintln(&buf, "License: MIT") + fmt.Fprintln(&buf, "Author:", packageAuthor) + return buf.Bytes() + } + + url := fmt.Sprintf("/api/packages/%s/cran", user.Name) + + t.Run("Source", func(t *testing.T) { + createArchive := func(filename string, content []byte) *bytes.Buffer { + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gw) + hdr := &tar.Header{ + Name: filename, + Mode: 0o600, + Size: int64(len(content)), + } + tw.WriteHeader(hdr) + tw.Write(content) + tw.Close() + gw.Close() + return &buf + } + + t.Run("Upload", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + uploadURL := url + "/src" + + req := NewRequestWithBody(t, "PUT", uploadURL, bytes.NewReader([]byte{})) + MakeRequest(t, req, http.StatusUnauthorized) + + req = NewRequestWithBody(t, "PUT", uploadURL, createArchive( + "dummy.txt", + []byte{}, + )) + req = AddBasicAuthHeader(req, user.Name) + MakeRequest(t, req, http.StatusBadRequest) + + req = NewRequestWithBody(t, "PUT", uploadURL, createArchive( + "package/DESCRIPTION", + createDescription(packageName, packageVersion), + )) + req = AddBasicAuthHeader(req, user.Name) + MakeRequest(t, req, http.StatusCreated) + + pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeCran) + assert.NoError(t, err) + assert.Len(t, pvs, 1) + + pd, err := packages.GetPackageDescriptor(db.DefaultContext, pvs[0]) + assert.NoError(t, err) + assert.Nil(t, pd.SemVer) + assert.IsType(t, &cran_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_%s.tar.gz", packageName, packageVersion), pfs[0].Name) + assert.True(t, pfs[0].IsLead) + + req = NewRequestWithBody(t, "PUT", uploadURL, createArchive( + "package/DESCRIPTION", + createDescription(packageName, packageVersion), + )) + req = AddBasicAuthHeader(req, user.Name) + MakeRequest(t, req, http.StatusConflict) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/src/contrib/%s_%s.tar.gz", url, packageName, packageVersion)) + req = AddBasicAuthHeader(req, user.Name) + MakeRequest(t, req, http.StatusOK) + }) + + t.Run("Enumerate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", url+"/src/contrib/PACKAGES") + req = AddBasicAuthHeader(req, user.Name) + resp := MakeRequest(t, req, http.StatusOK) + + assert.Contains(t, resp.Header().Get("Content-Type"), "text/plain") + + body := resp.Body.String() + assert.Contains(t, body, fmt.Sprintf("Package: %s", packageName)) + assert.Contains(t, body, fmt.Sprintf("Version: %s", packageVersion)) + + req = NewRequest(t, "GET", url+"/src/contrib/PACKAGES.gz") + req = AddBasicAuthHeader(req, user.Name) + resp = MakeRequest(t, req, http.StatusOK) + + assert.Contains(t, resp.Header().Get("Content-Type"), "application/x-gzip") + }) + }) + + t.Run("Binary", func(t *testing.T) { + createArchive := func(filename string, content []byte) *bytes.Buffer { + var buf bytes.Buffer + archive := zip.NewWriter(&buf) + w, _ := archive.Create(filename) + w.Write(content) + archive.Close() + return &buf + } + + t.Run("Upload", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + uploadURL := url + "/bin" + + req := NewRequestWithBody(t, "PUT", uploadURL, bytes.NewReader([]byte{})) + MakeRequest(t, req, http.StatusUnauthorized) + + req = NewRequestWithBody(t, "PUT", uploadURL, createArchive( + "dummy.txt", + []byte{}, + )) + req = AddBasicAuthHeader(req, user.Name) + MakeRequest(t, req, http.StatusBadRequest) + + req = NewRequestWithBody(t, "PUT", uploadURL+"?platform=&rversion=", createArchive( + "package/DESCRIPTION", + createDescription(packageName, packageVersion), + )) + req = AddBasicAuthHeader(req, user.Name) + MakeRequest(t, req, http.StatusBadRequest) + + uploadURL += "?platform=windows&rversion=4.2" + + req = NewRequestWithBody(t, "PUT", uploadURL, createArchive( + "package/DESCRIPTION", + createDescription(packageName, packageVersion), + )) + req = AddBasicAuthHeader(req, user.Name) + MakeRequest(t, req, http.StatusCreated) + + pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeCran) + assert.NoError(t, err) + assert.Len(t, pvs, 1) + + pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID) + assert.NoError(t, err) + assert.Len(t, pfs, 2) + + req = NewRequestWithBody(t, "PUT", uploadURL, createArchive( + "package/DESCRIPTION", + createDescription(packageName, packageVersion), + )) + req = AddBasicAuthHeader(req, user.Name) + MakeRequest(t, req, http.StatusConflict) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + cases := []struct { + Platform string + RVersion string + ExpectedStatus int + }{ + {"osx", "4.2", http.StatusNotFound}, + {"windows", "4.1", http.StatusNotFound}, + {"windows", "4.2", http.StatusOK}, + } + + for _, c := range cases { + req := NewRequest(t, "GET", fmt.Sprintf("%s/bin/%s/contrib/%s/%s_%s.zip", url, c.Platform, c.RVersion, packageName, packageVersion)) + req = AddBasicAuthHeader(req, user.Name) + MakeRequest(t, req, c.ExpectedStatus) + } + }) + + t.Run("Enumerate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", url+"/bin/windows/contrib/4.1/PACKAGES") + MakeRequest(t, req, http.StatusNotFound) + + req = NewRequest(t, "GET", url+"/bin/windows/contrib/4.2/PACKAGES") + req = AddBasicAuthHeader(req, user.Name) + resp := MakeRequest(t, req, http.StatusOK) + + assert.Contains(t, resp.Header().Get("Content-Type"), "text/plain") + + body := resp.Body.String() + assert.Contains(t, body, fmt.Sprintf("Package: %s", packageName)) + assert.Contains(t, body, fmt.Sprintf("Version: %s", packageVersion)) + + req = NewRequest(t, "GET", url+"/bin/windows/contrib/4.2/PACKAGES.gz") + req = AddBasicAuthHeader(req, user.Name) + resp = MakeRequest(t, req, http.StatusOK) + + assert.Contains(t, resp.Header().Get("Content-Type"), "application/x-gzip") + }) + }) +} diff --git a/web_src/svg/gitea-cran.svg b/web_src/svg/gitea-cran.svg new file mode 100644 index 0000000000000..4072a50ba8820 --- /dev/null +++ b/web_src/svg/gitea-cran.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file From 6c075d80a4144f257058d79129b66e8da1f46bb6 Mon Sep 17 00:00:00 2001 From: KN4CK3R Date: Mon, 13 Mar 2023 15:21:46 +0000 Subject: [PATCH 2/7] Use relative url. --- templates/package/content/cran.tmpl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/package/content/cran.tmpl b/templates/package/content/cran.tmpl index 34cf893de2a80..f763a7bd6664f 100644 --- a/templates/package/content/cran.tmpl +++ b/templates/package/content/cran.tmpl @@ -5,7 +5,7 @@
local({
-	r <- list("gitea" = "{{AppUrl}}api/packages/{{.PackageDescriptor.Owner.Name}}/cran")
+	r <- list("gitea" = "")
 	options(repos = r)
 })
From dd02f8570e9ab253a66d0e1af5c74af2aa103a18 Mon Sep 17 00:00:00 2001 From: KN4CK3R Date: Wed, 15 Mar 2023 10:09:41 +0000 Subject: [PATCH 3/7] Add suggestions. Co-authored-by: pat-s --- docs/content/doc/packages/cran.en-us.md | 7 ++----- templates/package/content/cran.tmpl | 5 +---- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/docs/content/doc/packages/cran.en-us.md b/docs/content/doc/packages/cran.en-us.md index 60ca572d95cfb..27ccb248b573b 100644 --- a/docs/content/doc/packages/cran.en-us.md +++ b/docs/content/doc/packages/cran.en-us.md @@ -26,13 +26,10 @@ To work with the CRAN package registry, you need to install [the R toolset](http ## Configuring the package registry -To register the package registry you need to add it in your `Rprofile.site` file: +To register the package registry you need to add it to `Rprofile.site`, either on the system-level, user-level (`~/.Rprofile`) or project-level: ``` -local({ - r <- list("gitea" = "https://gitea.example.com/api/packages/{owner}/cran") - options(repos = r) -}) +options("repos" = c(getOption("repos"), c(gitea="https://gitea.example.com/api/packages/{owner}/cran"))) ``` | Parameter | Description | diff --git a/templates/package/content/cran.tmpl b/templates/package/content/cran.tmpl index f763a7bd6664f..f6a523206cbad 100644 --- a/templates/package/content/cran.tmpl +++ b/templates/package/content/cran.tmpl @@ -4,10 +4,7 @@
-
local({
-	r <- list("gitea" = "")
-	options(repos = r)
-})
+
options("repos" = c(getOption("repos"), c(gitea="")))
From 15cf315e2f8b28b1e1b1c22ed880e4d1990348fd Mon Sep 17 00:00:00 2001 From: KN4CK3R Date: Sun, 14 May 2023 08:31:39 +0000 Subject: [PATCH 4/7] Crop svg. --- public/img/svg/gitea-cran.svg | 2 +- web_src/svg/gitea-cran.svg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/public/img/svg/gitea-cran.svg b/public/img/svg/gitea-cran.svg index 7b63a84d53ec0..de85ccab52975 100644 --- a/public/img/svg/gitea-cran.svg +++ b/public/img/svg/gitea-cran.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/web_src/svg/gitea-cran.svg b/web_src/svg/gitea-cran.svg index 4072a50ba8820..41d98aad45ca5 100644 --- a/web_src/svg/gitea-cran.svg +++ b/web_src/svg/gitea-cran.svg @@ -1,5 +1,5 @@ - + From c3bdb0091c09c84bf690566d3eb996b7e7de1d3d Mon Sep 17 00:00:00 2001 From: KN4CK3R Date: Sun, 14 May 2023 08:35:54 +0000 Subject: [PATCH 5/7] Update docs. --- docs/content/doc/usage/packages/cran.en-us.md | 6 ++-- .../doc/usage/packages/overview.en-us.md | 34 +++++++++---------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/docs/content/doc/usage/packages/cran.en-us.md b/docs/content/doc/usage/packages/cran.en-us.md index d0d5cb7988382..4a63d1cfa1a4b 100644 --- a/docs/content/doc/usage/packages/cran.en-us.md +++ b/docs/content/doc/usage/packages/cran.en-us.md @@ -14,7 +14,7 @@ menu: # CRAN Packages Repository -Publish [CRAN](https://cran.r-project.org/) packages for your user or organization. +Publish [R](https://www.r-project.org/) packages to a [CRAN](https://cran.r-project.org/)-like registry for your user or organization. **Table of Contents** @@ -40,7 +40,7 @@ If you need to provide credentials, you may embed them as part of the url (`http ## Publish a package -To publish a package, perform a HTTP PUT operation with the package content in the request body. +To publish a R package, perform a HTTP `PUT` operation with the package content in the request body. Source packages: @@ -76,7 +76,7 @@ You cannot publish a package if a package of the same name and version already e ## Install a package -To install a CRAN package from the package registry, execute the following command: +To install a R package from the package registry, execute the following command: ```shell install.packages({package_name}) diff --git a/docs/content/doc/usage/packages/overview.en-us.md b/docs/content/doc/usage/packages/overview.en-us.md index 5ea3423f70c27..49be5a40b72e9 100644 --- a/docs/content/doc/usage/packages/overview.en-us.md +++ b/docs/content/doc/usage/packages/overview.en-us.md @@ -28,25 +28,25 @@ The following package managers are currently supported: | Name | Language | Package client | | ---- | -------- | -------------- | | [Alpine]({{< relref "doc/usage/packages/alpine.en-us.md" >}}) | - | `apk` | -| [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` | -| [Container]({{< relref "doc/packages/container.en-us.md" >}}) | - | any OCI compliant client | -| [CRAN]({{< relref "doc/packages/cran.en-us.md" >}}) | R | - | +| [Cargo]({{< relref "doc/usage/packages/cargo.en-us.md" >}}) | Rust | `cargo` | +| [Chef]({{< relref "doc/usage/packages/chef.en-us.md" >}}) | - | `knife` | +| [Composer]({{< relref "doc//usagepackages/composer.en-us.md" >}}) | PHP | `composer` | +| [Conan]({{< relref "doc/usage/packages/conan.en-us.md" >}}) | C++ | `conan` | +| [Conda]({{< relref "doc/usage/packages/conda.en-us.md" >}}) | - | `conda` | +| [Container]({{< relref "doc/usage/packages/container.en-us.md" >}}) | - | any OCI compliant client | +| [CRAN]({{< relref "doc/usage/packages/cran.en-us.md" >}}) | R | - | | [Debian]({{< relref "doc/usage/packages/debian.en-us.md" >}}) | - | `apt` | -| [Generic]({{< relref "doc/packages/generic.en-us.md" >}}) | - | any HTTP client | -| [Helm]({{< relref "doc/packages/helm.en-us.md" >}}) | - | any HTTP client, `cm-push` | -| [Maven]({{< relref "doc/packages/maven.en-us.md" >}}) | Java | `mvn`, `gradle` | -| [npm]({{< relref "doc/packages/npm.en-us.md" >}}) | JavaScript | `npm`, `yarn`, `pnpm` | -| [NuGet]({{< relref "doc/packages/nuget.en-us.md" >}}) | .NET | `nuget` | -| [Pub]({{< relref "doc/packages/pub.en-us.md" >}}) | Dart | `dart`, `flutter` | -| [PyPI]({{< relref "doc/packages/pypi.en-us.md" >}}) | Python | `pip`, `twine` | +| [Generic]({{< relref "doc/usage/packages/generic.en-us.md" >}}) | - | any HTTP client | +| [Helm]({{< relref "doc/usage/packages/helm.en-us.md" >}}) | - | any HTTP client, `cm-push` | +| [Maven]({{< relref "doc/usage/packages/maven.en-us.md" >}}) | Java | `mvn`, `gradle` | +| [npm]({{< relref "doc/usage/packages/npm.en-us.md" >}}) | JavaScript | `npm`, `yarn`, `pnpm` | +| [NuGet]({{< relref "doc/usage/packages/nuget.en-us.md" >}}) | .NET | `nuget` | +| [Pub]({{< relref "doc/usage/packages/pub.en-us.md" >}}) | Dart | `dart`, `flutter` | +| [PyPI]({{< relref "doc/usage/packages/pypi.en-us.md" >}}) | Python | `pip`, `twine` | | [RPM]({{< relref "doc/usage/packages/rpm.en-us.md" >}}) | - | `yum`, `dnf` | -| [RubyGems]({{< relref "doc/packages/rubygems.en-us.md" >}}) | Ruby | `gem`, `Bundler` | -| [Swift]({{< relref "doc/packages/rubygems.en-us.md" >}}) | Swift | `swift` | -| [Vagrant]({{< relref "doc/packages/vagrant.en-us.md" >}}) | - | `vagrant` | +| [RubyGems]({{< relref "doc/usage/packages/rubygems.en-us.md" >}}) | Ruby | `gem`, `Bundler` | +| [Swift]({{< relref "doc/usage/packages/rubygems.en-us.md" >}}) | Swift | `swift` | +| [Vagrant]({{< relref "doc/usage/packages/vagrant.en-us.md" >}}) | - | `vagrant` | **The following paragraphs only apply if Packages are not globally disabled!** From 4a1859f47d57fbf26daa6705317d338dbcef6e79 Mon Sep 17 00:00:00 2001 From: KN4CK3R Date: Sun, 14 May 2023 08:37:26 +0000 Subject: [PATCH 6/7] fix path --- docs/content/doc/usage/packages/overview.en-us.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/content/doc/usage/packages/overview.en-us.md b/docs/content/doc/usage/packages/overview.en-us.md index 49be5a40b72e9..4987dfa867664 100644 --- a/docs/content/doc/usage/packages/overview.en-us.md +++ b/docs/content/doc/usage/packages/overview.en-us.md @@ -30,7 +30,7 @@ The following package managers are currently supported: | [Alpine]({{< relref "doc/usage/packages/alpine.en-us.md" >}}) | - | `apk` | | [Cargo]({{< relref "doc/usage/packages/cargo.en-us.md" >}}) | Rust | `cargo` | | [Chef]({{< relref "doc/usage/packages/chef.en-us.md" >}}) | - | `knife` | -| [Composer]({{< relref "doc//usagepackages/composer.en-us.md" >}}) | PHP | `composer` | +| [Composer]({{< relref "doc/usage/packages/composer.en-us.md" >}}) | PHP | `composer` | | [Conan]({{< relref "doc/usage/packages/conan.en-us.md" >}}) | C++ | `conan` | | [Conda]({{< relref "doc/usage/packages/conda.en-us.md" >}}) | - | `conda` | | [Container]({{< relref "doc/usage/packages/container.en-us.md" >}}) | - | any OCI compliant client | From 2fb6bd76f42e59b78c6b56a5cd913b9348cb57b6 Mon Sep 17 00:00:00 2001 From: KN4CK3R Date: Sun, 14 May 2023 11:00:02 +0200 Subject: [PATCH 7/7] Apply suggestions from code review Co-authored-by: Patrick Schratz --- docs/content/doc/usage/packages/cran.en-us.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/content/doc/usage/packages/cran.en-us.md b/docs/content/doc/usage/packages/cran.en-us.md index 4a63d1cfa1a4b..cd323e5c5dfb2 100644 --- a/docs/content/doc/usage/packages/cran.en-us.md +++ b/docs/content/doc/usage/packages/cran.en-us.md @@ -22,7 +22,7 @@ Publish [R](https://www.r-project.org/) packages to a [CRAN](https://cran.r-proj ## Requirements -To work with the CRAN package registry, you need to install [the R toolset](https://cran.r-project.org/). +To work with the CRAN package registry, you need to install [R](https://cran.r-project.org/). ## Configuring the package registry @@ -79,7 +79,7 @@ You cannot publish a package if a package of the same name and version already e To install a R package from the package registry, execute the following command: ```shell -install.packages({package_name}) +install.packages("{package_name}") ``` | Parameter | Description |