From cdb088cec288a20e14240f86a689dd14f4cd603b Mon Sep 17 00:00:00 2001 From: KN4CK3R Date: Mon, 22 May 2023 04:57:49 +0200 Subject: [PATCH] Add CRAN package registry (#22331) This PR adds a [CRAN](https://cran.r-project.org/) package registry. ![grafik](https://user-images.githubusercontent.com/1666336/210450039-d6fa6f77-20cd-4741-89a8-1624def267f7.png) --- custom/conf/app.example.ini | 2 + .../config-cheat-sheet.en-us.md | 1 + docs/content/doc/usage/packages/cran.en-us.md | 93 ++++++ .../doc/usage/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 | 59 ++++ 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, 1212 insertions(+), 2 deletions(-) create mode 100644 docs/content/doc/usage/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 3ee2270d3..121ceb715 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -2420,6 +2420,8 @@ LEVEL = Info ;LIMIT_SIZE_CONDA = -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 Debian upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`) ;LIMIT_SIZE_DEBIAN = -1 ;; Maximum size of a Generic upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`) diff --git a/docs/content/doc/administration/config-cheat-sheet.en-us.md b/docs/content/doc/administration/config-cheat-sheet.en-us.md index cb75fc588..349c480ae 100644 --- a/docs/content/doc/administration/config-cheat-sheet.en-us.md +++ b/docs/content/doc/administration/config-cheat-sheet.en-us.md @@ -1207,6 +1207,7 @@ Task queue configuration has been moved to `queue.task`. However, the below conf - `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`) - `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_DEBIAN`: **-1**: Maximum size of a Debian 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_GO`: **-1**: Maximum size of a Go upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`) diff --git a/docs/content/doc/usage/packages/cran.en-us.md b/docs/content/doc/usage/packages/cran.en-us.md new file mode 100644 index 000000000..cd323e5c5 --- /dev/null +++ b/docs/content/doc/usage/packages/cran.en-us.md @@ -0,0 +1,93 @@ +--- +date: "2023-01-01T00:00:00+00:00" +title: "CRAN Packages Repository" +slug: "cran" +draft: false +toc: false +menu: + sidebar: + parent: "packages" + name: "CRAN" + weight: 35 + identifier: "cran" +--- + +# CRAN Packages Repository + +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** + +{{< toc >}} + +## Requirements + +To work with the CRAN package registry, you need to install [R](https://cran.r-project.org/). + +## Configuring the package registry + +To register the package registry you need to add it to `Rprofile.site`, either on the system-level, user-level (`~/.Rprofile`) or project-level: + +``` +options("repos" = c(getOption("repos"), c(gitea="https://gitea.example.com/api/packages/{owner}/cran"))) +``` + +| 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 R 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 R 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/usage/packages/overview.en-us.md b/docs/content/doc/usage/packages/overview.en-us.md index 6e0ab0da3..944505a04 100644 --- a/docs/content/doc/usage/packages/overview.en-us.md +++ b/docs/content/doc/usage/packages/overview.en-us.md @@ -34,6 +34,7 @@ The following package managers are currently supported: | [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/usage/packages/generic.en-us.md" >}}) | - | any HTTP client | | [Go]({{< relref "doc/usage/packages/go.en-us.md" >}}) | Go | `go` | diff --git a/models/packages/cran/search.go b/models/packages/cran/search.go new file mode 100644 index 000000000..8a8b52a35 --- /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 8e0165086..ee35ffe0f 100644 --- a/models/packages/descriptor.go +++ b/models/packages/descriptor.go @@ -19,6 +19,7 @@ import ( "code.gitea.io/gitea/modules/packages/conan" "code.gitea.io/gitea/modules/packages/conda" "code.gitea.io/gitea/modules/packages/container" + "code.gitea.io/gitea/modules/packages/cran" "code.gitea.io/gitea/modules/packages/debian" "code.gitea.io/gitea/modules/packages/helm" "code.gitea.io/gitea/modules/packages/maven" @@ -151,6 +152,8 @@ func GetPackageDescriptor(ctx context.Context, pv *PackageVersion) (*PackageDesc metadata = &conda.VersionMetadata{} case TypeContainer: metadata = &container.Metadata{} + case TypeCran: + metadata = &cran.Metadata{} case TypeDebian: metadata = &debian.Metadata{} case TypeGeneric: diff --git a/models/packages/package.go b/models/packages/package.go index 2dfed7804..380a076f9 100644 --- a/models/packages/package.go +++ b/models/packages/package.go @@ -37,6 +37,7 @@ const ( TypeConan Type = "conan" TypeConda Type = "conda" TypeContainer Type = "container" + TypeCran Type = "cran" TypeDebian Type = "debian" TypeGeneric Type = "generic" TypeGo Type = "go" @@ -60,6 +61,7 @@ var TypeList = []Type{ TypeConan, TypeConda, TypeContainer, + TypeCran, TypeDebian, TypeGeneric, TypeGo, @@ -92,6 +94,8 @@ func (pt Type) Name() string { return "Conda" case TypeContainer: return "Container" + case TypeCran: + return "CRAN" case TypeDebian: return "Debian" case TypeGeneric: @@ -139,6 +143,8 @@ func (pt Type) SVGName() string { return "gitea-conda" case TypeContainer: return "octicon-container" + case TypeCran: + return "gitea-cran" case TypeDebian: return "gitea-debian" case TypeGeneric: diff --git a/modules/packages/cran/metadata.go b/modules/packages/cran/metadata.go new file mode 100644 index 000000000..24e6f323a --- /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 000000000..ff68c34c5 --- /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 a9b91adf1..5e64d7fe9 100644 --- a/modules/setting/packages.go +++ b/modules/setting/packages.go @@ -31,6 +31,7 @@ var ( LimitSizeConan int64 LimitSizeConda int64 LimitSizeContainer int64 + LimitSizeCran int64 LimitSizeDebian int64 LimitSizeGeneric int64 LimitSizeGo int64 @@ -78,6 +79,7 @@ func loadPackagesFrom(rootCfg ConfigProvider) { Packages.LimitSizeConan = mustBytes(sec, "LIMIT_SIZE_CONAN") Packages.LimitSizeConda = mustBytes(sec, "LIMIT_SIZE_CONDA") Packages.LimitSizeContainer = mustBytes(sec, "LIMIT_SIZE_CONTAINER") + Packages.LimitSizeCran = mustBytes(sec, "LIMIT_SIZE_CRAN") Packages.LimitSizeDebian = mustBytes(sec, "LIMIT_SIZE_DEBIAN") Packages.LimitSizeGeneric = mustBytes(sec, "LIMIT_SIZE_GENERIC") Packages.LimitSizeGo = mustBytes(sec, "LIMIT_SIZE_GO") diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 6305a3907..e5742157d 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -3258,6 +3258,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. debian.registry = Setup this registry from the command line: debian.registry.info = Choose $distribution and $component from the list below. debian.install = To install the package, run the following command: diff --git a/public/img/svg/gitea-cran.svg b/public/img/svg/gitea-cran.svg new file mode 100644 index 000000000..de85ccab5 --- /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 e715997e8..4f0f637fa 100644 --- a/routers/api/packages/api.go +++ b/routers/api/packages/api.go @@ -22,6 +22,7 @@ import ( "code.gitea.io/gitea/routers/api/packages/conan" "code.gitea.io/gitea/routers/api/packages/conda" "code.gitea.io/gitea/routers/api/packages/container" + "code.gitea.io/gitea/routers/api/packages/cran" "code.gitea.io/gitea/routers/api/packages/debian" "code.gitea.io/gitea/routers/api/packages/generic" "code.gitea.io/gitea/routers/api/packages/goproxy" @@ -295,6 +296,24 @@ func CommonRoutes(ctx gocontext.Context) *web.Route { conda.UploadPackageFile(ctx) }) }, reqPackageAccess(perm.AccessModeRead)) + r.Group("/cran", func() { + r.Group("/src", func() { + r.Group("/contrib", func() { + r.Get("/PACKAGES", cran.EnumerateSourcePackages) + r.Get("/PACKAGES{format}", cran.EnumerateSourcePackages) + r.Get("/{filename}", cran.DownloadSourcePackageFile) + }) + r.Put("", reqPackageAccess(perm.AccessModeWrite), cran.UploadSourcePackageFile) + }) + r.Group("/bin", func() { + r.Group("/{platform}/contrib/{rversion}", func() { + r.Get("/PACKAGES", cran.EnumerateBinaryPackages) + r.Get("/PACKAGES{format}", cran.EnumerateBinaryPackages) + r.Get("/{filename}", cran.DownloadBinaryPackageFile) + }) + r.Put("", reqPackageAccess(perm.AccessModeWrite), cran.UploadBinaryPackageFile) + }) + }, reqPackageAccess(perm.AccessModeRead)) r.Group("/debian", func() { r.Get("/repository.key", debian.GetRepositoryKey) r.Group("/dists/{distribution}", func() { diff --git a/routers/api/packages/cran/cran.go b/routers/api/packages/cran/cran.go new file mode 100644 index 000000000..eb3f9a452 --- /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) + 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 0c9a13428..5129c7d4f 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: [alpine, cargo, chef, composer, conan, conda, container, debian, generic, go, helm, maven, npm, nuget, pub, pypi, rpm, rubygems, swift, vagrant] + // enum: [alpine, cargo, chef, composer, conan, conda, container, cran, debian, generic, go, helm, maven, npm, nuget, pub, pypi, rpm, rubygems, swift, vagrant] // - name: q // in: query // description: name filter diff --git a/services/forms/package_form.go b/services/forms/package_form.go index cf8abfb8f..2f08dfe9f 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(alpine,cargo,chef,composer,conan,conda,container,debian,generic,go,helm,maven,npm,nuget,pub,pypi,rpm,rubygems,swift,vagrant)"` + Type string `binding:"Required;In(alpine,cargo,chef,composer,conan,conda,container,cran,debian,generic,go,helm,maven,npm,nuget,pub,pypi,rpm,rubygems,swift,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 9d5ce04a0..23aa8a5c3 100644 --- a/services/packages/packages.go +++ b/services/packages/packages.go @@ -365,6 +365,8 @@ func CheckSizeQuotaExceeded(ctx context.Context, doer, owner *user_model.User, p typeSpecificSize = setting.Packages.LimitSizeConda case packages_model.TypeContainer: typeSpecificSize = setting.Packages.LimitSizeContainer + case packages_model.TypeCran: + typeSpecificSize = setting.Packages.LimitSizeCran case packages_model.TypeDebian: typeSpecificSize = setting.Packages.LimitSizeDebian case packages_model.TypeGeneric: diff --git a/templates/package/content/cran.tmpl b/templates/package/content/cran.tmpl new file mode 100644 index 000000000..dcabdbce9 --- /dev/null +++ b/templates/package/content/cran.tmpl @@ -0,0 +1,59 @@ +{{if eq .PackageDescriptor.Package.Type "cran"}} +

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

+
+
+
+ +
options("repos" = c(getOption("repos"), c(gitea="")))
+
+
+ +
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{{StringUtils.Join .PackageDescriptor.Metadata.Imports ", "}}
Depends{{StringUtils.Join .PackageDescriptor.Metadata.Depends ", "}}
LinkingTo{{StringUtils.Join .PackageDescriptor.Metadata.LinkingTo ", "}}
Suggests{{StringUtils.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 000000000..7b113ec79 --- /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 5285a0838..aadaaa412 100644 --- a/templates/package/view.tmpl +++ b/templates/package/view.tmpl @@ -26,6 +26,7 @@ {{template "package/content/conan" .}} {{template "package/content/conda" .}} {{template "package/content/container" .}} + {{template "package/content/cran" .}} {{template "package/content/debian" .}} {{template "package/content/generic" .}} {{template "package/content/go" .}} @@ -57,6 +58,7 @@ {{template "package/metadata/conan" .}} {{template "package/metadata/conda" .}} {{template "package/metadata/container" .}} + {{template "package/metadata/cran" .}} {{template "package/metadata/debian" .}} {{template "package/metadata/generic" .}} {{template "package/metadata/helm" .}} diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 3859eb556..3c56fc9ef 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -2416,6 +2416,7 @@ "conan", "conda", "container", + "cran", "debian", "generic", "go", diff --git a/tests/integration/api_packages_cran_test.go b/tests/integration/api_packages_cran_test.go new file mode 100644 index 000000000..9ef23226d --- /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 000000000..41d98aad4 --- /dev/null +++ b/web_src/svg/gitea-cran.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file