]> source.dussan.org Git - gitea.git/commitdiff
Add CRAN package registry (#22331)
authorKN4CK3R <admin@oldschoolhack.me>
Mon, 22 May 2023 02:57:49 +0000 (04:57 +0200)
committerGitHub <noreply@github.com>
Mon, 22 May 2023 02:57:49 +0000 (10:57 +0800)
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)

23 files changed:
custom/conf/app.example.ini
docs/content/doc/administration/config-cheat-sheet.en-us.md
docs/content/doc/usage/packages/cran.en-us.md [new file with mode: 0644]
docs/content/doc/usage/packages/overview.en-us.md
models/packages/cran/search.go [new file with mode: 0644]
models/packages/descriptor.go
models/packages/package.go
modules/packages/cran/metadata.go [new file with mode: 0644]
modules/packages/cran/metadata_test.go [new file with mode: 0644]
modules/setting/packages.go
options/locale/locale_en-US.ini
public/img/svg/gitea-cran.svg [new file with mode: 0644]
routers/api/packages/api.go
routers/api/packages/cran/cran.go [new file with mode: 0644]
routers/api/v1/packages/package.go
services/forms/package_form.go
services/packages/packages.go
templates/package/content/cran.tmpl [new file with mode: 0644]
templates/package/metadata/cran.tmpl [new file with mode: 0644]
templates/package/view.tmpl
templates/swagger/v1_json.tmpl
tests/integration/api_packages_cran_test.go [new file with mode: 0644]
web_src/svg/gitea-cran.svg [new file with mode: 0644]

index 3ee2270d332ea6aa1fabf37270be205c9da0a596..121ceb7152c556d06bfb1aea70917b6392f61de4 100644 (file)
@@ -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`)
index cb75fc588af7dd27b34aeaae3a99605217519d23..349c480ae6b9169be946c47820c345d5bf38eff1 100644 (file)
@@ -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 (file)
index 0000000..cd323e5
--- /dev/null
@@ -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")
+```
index 6e0ab0da31fb19dbf85ffb541985f5b1df6101ab..944505a04a00a86c730f41ccb191f07331ae4b9a 100644 (file)
@@ -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 (file)
index 0000000..8a8b52a
--- /dev/null
@@ -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
+}
index 8e0165086639990b0b55e8ccce048147a21ec92b..ee35ffe0f2a3ad6b399be37006b592c06e922a6a 100644 (file)
@@ -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:
index 2dfed78046274a42c3f15d820c071d6aaef3e853..380a076f9dfe195025038373decb65a0cb75fd9a 100644 (file)
@@ -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 (file)
index 0000000..24e6f32
--- /dev/null
@@ -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 (file)
index 0000000..ff68c34
--- /dev/null
@@ -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)
+       })
+}
index a9b91adf1621c8b1c044e8dbef32d60542288395..5e64d7fe9f7fc22a97c656b7896b03dc089b33e1 100644 (file)
@@ -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")
index 6305a3907b7be3c39bcc3d5b2e4687c9c56f2146..e5742157d882b4318c57529aaee017546259298e 100644 (file)
@@ -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 <code>Rprofile.site</code> file:
+cran.install = To install the package, run the following command:
+cran.documentation = For more information on the CRAN registry, see <a target="_blank" rel="noopener noreferrer" href="https://docs.gitea.io/en-us/packages/cran/">the documentation</a>.
 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 (file)
index 0000000..de85cca
--- /dev/null
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid" viewBox="0.88 3 721.12 556.07" class="svg gitea-cran" width="16" height="16" aria-hidden="true"><defs><linearGradient id="gitea-cran__a" y2="1"><stop offset="0" stop-color="#cbced0"/><stop offset="1" stop-color="#84838b"/></linearGradient><linearGradient id="gitea-cran__b" y2="1"><stop offset="0" stop-color="#276dc3"/><stop offset="1" stop-color="#165caa"/></linearGradient></defs><path fill="url(#gitea-cran__a)" fill-rule="evenodd" d="M361.45 485.94C162.33 485.94.9 377.83.9 244.47S162.32 3 361.45 3C560.57 3 722 111.11 722 244.47S560.58 485.94 361.45 485.94zm55.188-388.53c-151.35 0-274.05 73.908-274.05 165.08s122.7 165.08 274.05 165.08c151.35 0 263.05-50.529 263.05-165.08 0-114.51-111.7-165.08-263.05-165.08z"/><path fill="url(#gitea-cran__b)" fill-rule="evenodd" d="M550 377s21.822 6.585 34.5 13c4.399 2.226 12.01 6.668 17.5 12.5 5.378 5.712 8 11.5 8 11.5l86 145-139 .062-65-122.06s-13.31-22.869-21.5-29.5c-6.832-5.531-9.745-7.5-16.5-7.5h-33.026l.026 158.97-123 .052v-406.09h247s112.5 2.029 112.5 109.06-107.5 115-107.5 115zm-53.5-135.98-74.463-.048-.037 69.05 74.5-.024s34.5-.107 34.5-35.125c0-35.722-34.5-33.853-34.5-33.853z"/></svg>
\ No newline at end of file
index e715997e82951a2a9395d8a8132f63b53b3a70e7..4f0f637fa57d98d410960036b62fef99ff1f41d5 100644 (file)
@@ -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 (file)
index 0000000..eb3f9a4
--- /dev/null
@@ -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(),
+       })
+}
index 0c9a134281500dc84e333cc12035cfd5f7bacd81..5129c7d4f05f851bf62b192b187595e637997929 100644 (file)
@@ -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
index cf8abfb8fbc934bd97d8565e9971933a3cfb4d76..2f08dfe9f4864de24af75cc35930e31627fd5779 100644 (file)
@@ -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)"`
index 9d5ce04a0e397db13902b4ae9d702981c14dd666..23aa8a5c31526d81d1037b2027883c9b3a993fda 100644 (file)
@@ -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 (file)
index 0000000..dcabdbc
--- /dev/null
@@ -0,0 +1,59 @@
+{{if eq .PackageDescriptor.Package.Type "cran"}}
+       <h4 class="ui top attached header">{{.locale.Tr "packages.installation"}}</h4>
+       <div class="ui attached segment">
+               <div class="ui form">
+                       <div class="field">
+                               <label>{{svg "octicon-code"}} {{.locale.Tr "packages.cran.registry" | Safe}}</label>
+                               <div class="markup"><pre class="code-block"><code>options("repos" = c(getOption("repos"), c(gitea="<gitea-origin-url data-url="{{AppSubUrl}}/api/packages/{{.PackageDescriptor.Owner.Name}}/cran"></gitea-origin-url>")))</code></pre></div>
+                       </div>
+                       <div class="field">
+                               <label>{{svg "octicon-terminal"}} {{.locale.Tr "packages.cran.install"}}</label>
+                               <div class="markup"><pre class="code-block"><code>install.packages("{{.PackageDescriptor.Package.Name}}")</code></pre></div>
+                       </div>
+                       <div class="field">
+                               <label>{{.locale.Tr "packages.cran.documentation" | Safe}}</label>
+                       </div>
+               </div>
+       </div>
+
+       {{if or .PackageDescriptor.Metadata.Description .PackageDescriptor.Metadata.Title}}
+               <h4 class="ui top attached header">{{.locale.Tr "packages.about"}}</h4>
+               <div class="ui attached segment">
+                       {{if .PackageDescriptor.Metadata.Description}}{{.PackageDescriptor.Metadata.Description}}{{.PackageDescriptor.Metadata.Title}}{{else}}{{end}}
+               </div>
+       {{end}}
+
+       {{if or .PackageDescriptor.Metadata.Imports .PackageDescriptor.Metadata.Depends .PackageDescriptor.Metadata.LinkingTo .PackageDescriptor.Metadata.Suggests}}
+               <h4 class="ui top attached header">{{.locale.Tr "packages.dependencies"}}</h4>
+               <div class="ui attached segment">
+                       <table class="ui single line very basic table">
+                               <tbody>
+                                       {{if .PackageDescriptor.Metadata.Imports}}
+                                               <tr>
+                                                       <td>Imports</td>
+                                                       <td>{{StringUtils.Join .PackageDescriptor.Metadata.Imports ", "}}</td>
+                                               </tr>
+                                       {{end}}
+                                       {{if .PackageDescriptor.Metadata.Depends}}
+                                               <tr>
+                                                       <td>Depends</td>
+                                                       <td>{{StringUtils.Join .PackageDescriptor.Metadata.Depends ", "}}</td>
+                                               </tr>
+                                       {{end}}
+                                       {{if .PackageDescriptor.Metadata.LinkingTo}}
+                                               <tr>
+                                                       <td>LinkingTo</td>
+                                                       <td>{{StringUtils.Join .PackageDescriptor.Metadata.LinkingTo ", "}}</td>
+                                               </tr>
+                                       {{end}}
+                                       {{if .PackageDescriptor.Metadata.Suggests}}
+                                               <tr>
+                                                       <td>Suggests</td>
+                                                       <td>{{StringUtils.Join .PackageDescriptor.Metadata.Suggests ", "}}</td>
+                                               </tr>
+                                       {{end}}
+                               </tbody>
+                       </table>
+               </div>
+       {{end}}
+{{end}}
diff --git a/templates/package/metadata/cran.tmpl b/templates/package/metadata/cran.tmpl
new file mode 100644 (file)
index 0000000..7b113ec
--- /dev/null
@@ -0,0 +1,5 @@
+{{if eq .PackageDescriptor.Package.Type "cran"}}
+       {{if .PackageDescriptor.Metadata.License}}<div class="item" title="{{.locale.Tr "packages.details.license"}}">{{svg "octicon-law" 16 "mr-3"}} {{.PackageDescriptor.Metadata.License}}</div>{{end}}
+       {{range .PackageDescriptor.Metadata.Authors}}<div class="item" title="{{$.locale.Tr "packages.details.author"}}">{{svg "octicon-person" 16 "mr-3"}} {{.}}</div>{{end}}
+       {{range .PackageDescriptor.Metadata.ProjectURL}}<div class="item">{{svg "octicon-link-external" 16 "mr-3"}} <a href="{{.}}" target="_blank" rel="noopener noreferrer me">{{$.locale.Tr "packages.details.project_site"}}</a></div>{{end}}
+{{end}}
index 5285a0838d5a785fa87a88923f389eefb33c14ae..aadaaa412a2e1f37c3d5ff8a20665f90d4f23a39 100644 (file)
@@ -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" .}}
index 3859eb5567a86441763206e8d3db73d99226a0bf..3c56fc9efb3c05113e7bc51dd8f0c60b75c00e96 100644 (file)
               "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 (file)
index 0000000..9ef2322
--- /dev/null
@@ -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 (file)
index 0000000..41d98aa
--- /dev/null
@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg preserveAspectRatio="xMidYMid" viewBox="0.88 3 721.12 556.07" xmlns="http://www.w3.org/2000/svg">
+<defs>
+<linearGradient id="b" y2="1">
+<stop stop-color="#cbced0" offset="0"/>
+<stop stop-color="#84838b" offset="1"/>
+</linearGradient>
+<linearGradient id="a" y2="1">
+<stop stop-color="#276dc3" offset="0"/>
+<stop stop-color="#165caa" offset="1"/>
+</linearGradient>
+</defs>
+<path d="m361.45 485.94c-199.12 0-360.55-108.11-360.55-241.47 0-133.36 161.42-241.47 360.55-241.47 199.12 0 360.55 108.11 360.55 241.47 0 133.36-161.42 241.47-360.55 241.47zm55.188-388.53c-151.35 0-274.05 73.908-274.05 165.08s122.7 165.08 274.05 165.08c151.35 0 263.05-50.529 263.05-165.08 0-114.51-111.7-165.08-263.05-165.08z" fill="url(#b)" fill-rule="evenodd"/>
+<path d="m550 377s21.822 6.585 34.5 13c4.399 2.226 12.01 6.668 17.5 12.5 5.378 5.712 8 11.5 8 11.5l86 145-139 0.062-65-122.06s-13.31-22.869-21.5-29.5c-6.832-5.531-9.745-7.5-16.5-7.5h-33.026l0.026 158.97-123 0.052v-406.09h247s112.5 2.029 112.5 109.06-107.5 115-107.5 115zm-53.5-135.98-74.463-0.048-0.037 69.05 74.5-0.024s34.5-0.107 34.5-35.125c0-35.722-34.5-33.853-34.5-33.853z" fill="url(#a)" fill-rule="evenodd"/>
+</svg>
\ No newline at end of file