mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-29 10:57:44 +09:00 
			
		
		
		
	Add CRAN package registry (#22331)
This PR adds a [CRAN](https://cran.r-project.org/) package registry. 
This commit is contained in:
		| @@ -2420,6 +2420,8 @@ LEVEL = Info | |||||||
| ;LIMIT_SIZE_CONDA = -1 | ;LIMIT_SIZE_CONDA = -1 | ||||||
| ;; Maximum size of a Container upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`) | ;; Maximum size of a Container upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`) | ||||||
| ;LIMIT_SIZE_CONTAINER = -1 | ;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`) | ;; Maximum size of a Debian upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`) | ||||||
| ;LIMIT_SIZE_DEBIAN = -1 | ;LIMIT_SIZE_DEBIAN = -1 | ||||||
| ;; Maximum size of a Generic upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`) | ;; Maximum size of a Generic upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`) | ||||||
|   | |||||||
| @@ -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_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_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_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_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_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`) | - `LIMIT_SIZE_GO`: **-1**: Maximum size of a Go upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`) | ||||||
|   | |||||||
							
								
								
									
										93
									
								
								docs/content/doc/usage/packages/cran.en-us.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										93
									
								
								docs/content/doc/usage/packages/cran.en-us.md
									
									
									
									
									
										Normal file
									
								
							| @@ -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") | ||||||
|  | ``` | ||||||
| @@ -34,6 +34,7 @@ The following package managers are currently supported: | |||||||
| | [Conan]({{< relref "doc/usage/packages/conan.en-us.md" >}}) | C++ | `conan` | | | [Conan]({{< relref "doc/usage/packages/conan.en-us.md" >}}) | C++ | `conan` | | ||||||
| | [Conda]({{< relref "doc/usage/packages/conda.en-us.md" >}}) | - | `conda` | | | [Conda]({{< relref "doc/usage/packages/conda.en-us.md" >}}) | - | `conda` | | ||||||
| | [Container]({{< relref "doc/usage/packages/container.en-us.md" >}}) | - | any OCI compliant client | | | [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` | | | [Debian]({{< relref "doc/usage/packages/debian.en-us.md" >}}) | - | `apt` | | ||||||
| | [Generic]({{< relref "doc/usage/packages/generic.en-us.md" >}}) | - | any HTTP client | | | [Generic]({{< relref "doc/usage/packages/generic.en-us.md" >}}) | - | any HTTP client | | ||||||
| | [Go]({{< relref "doc/usage/packages/go.en-us.md" >}}) | Go | `go` | | | [Go]({{< relref "doc/usage/packages/go.en-us.md" >}}) | Go | `go` | | ||||||
|   | |||||||
							
								
								
									
										90
									
								
								models/packages/cran/search.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										90
									
								
								models/packages/cran/search.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||||
|  | } | ||||||
| @@ -19,6 +19,7 @@ import ( | |||||||
| 	"code.gitea.io/gitea/modules/packages/conan" | 	"code.gitea.io/gitea/modules/packages/conan" | ||||||
| 	"code.gitea.io/gitea/modules/packages/conda" | 	"code.gitea.io/gitea/modules/packages/conda" | ||||||
| 	"code.gitea.io/gitea/modules/packages/container" | 	"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/debian" | ||||||
| 	"code.gitea.io/gitea/modules/packages/helm" | 	"code.gitea.io/gitea/modules/packages/helm" | ||||||
| 	"code.gitea.io/gitea/modules/packages/maven" | 	"code.gitea.io/gitea/modules/packages/maven" | ||||||
| @@ -151,6 +152,8 @@ func GetPackageDescriptor(ctx context.Context, pv *PackageVersion) (*PackageDesc | |||||||
| 		metadata = &conda.VersionMetadata{} | 		metadata = &conda.VersionMetadata{} | ||||||
| 	case TypeContainer: | 	case TypeContainer: | ||||||
| 		metadata = &container.Metadata{} | 		metadata = &container.Metadata{} | ||||||
|  | 	case TypeCran: | ||||||
|  | 		metadata = &cran.Metadata{} | ||||||
| 	case TypeDebian: | 	case TypeDebian: | ||||||
| 		metadata = &debian.Metadata{} | 		metadata = &debian.Metadata{} | ||||||
| 	case TypeGeneric: | 	case TypeGeneric: | ||||||
|   | |||||||
| @@ -37,6 +37,7 @@ const ( | |||||||
| 	TypeConan     Type = "conan" | 	TypeConan     Type = "conan" | ||||||
| 	TypeConda     Type = "conda" | 	TypeConda     Type = "conda" | ||||||
| 	TypeContainer Type = "container" | 	TypeContainer Type = "container" | ||||||
|  | 	TypeCran      Type = "cran" | ||||||
| 	TypeDebian    Type = "debian" | 	TypeDebian    Type = "debian" | ||||||
| 	TypeGeneric   Type = "generic" | 	TypeGeneric   Type = "generic" | ||||||
| 	TypeGo        Type = "go" | 	TypeGo        Type = "go" | ||||||
| @@ -60,6 +61,7 @@ var TypeList = []Type{ | |||||||
| 	TypeConan, | 	TypeConan, | ||||||
| 	TypeConda, | 	TypeConda, | ||||||
| 	TypeContainer, | 	TypeContainer, | ||||||
|  | 	TypeCran, | ||||||
| 	TypeDebian, | 	TypeDebian, | ||||||
| 	TypeGeneric, | 	TypeGeneric, | ||||||
| 	TypeGo, | 	TypeGo, | ||||||
| @@ -92,6 +94,8 @@ func (pt Type) Name() string { | |||||||
| 		return "Conda" | 		return "Conda" | ||||||
| 	case TypeContainer: | 	case TypeContainer: | ||||||
| 		return "Container" | 		return "Container" | ||||||
|  | 	case TypeCran: | ||||||
|  | 		return "CRAN" | ||||||
| 	case TypeDebian: | 	case TypeDebian: | ||||||
| 		return "Debian" | 		return "Debian" | ||||||
| 	case TypeGeneric: | 	case TypeGeneric: | ||||||
| @@ -139,6 +143,8 @@ func (pt Type) SVGName() string { | |||||||
| 		return "gitea-conda" | 		return "gitea-conda" | ||||||
| 	case TypeContainer: | 	case TypeContainer: | ||||||
| 		return "octicon-container" | 		return "octicon-container" | ||||||
|  | 	case TypeCran: | ||||||
|  | 		return "gitea-cran" | ||||||
| 	case TypeDebian: | 	case TypeDebian: | ||||||
| 		return "gitea-debian" | 		return "gitea-debian" | ||||||
| 	case TypeGeneric: | 	case TypeGeneric: | ||||||
|   | |||||||
							
								
								
									
										244
									
								
								modules/packages/cran/metadata.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										244
									
								
								modules/packages/cran/metadata.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||||
|  | } | ||||||
							
								
								
									
										152
									
								
								modules/packages/cran/metadata_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										152
									
								
								modules/packages/cran/metadata_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||||
|  | 	}) | ||||||
|  | } | ||||||
| @@ -31,6 +31,7 @@ var ( | |||||||
| 		LimitSizeConan       int64 | 		LimitSizeConan       int64 | ||||||
| 		LimitSizeConda       int64 | 		LimitSizeConda       int64 | ||||||
| 		LimitSizeContainer   int64 | 		LimitSizeContainer   int64 | ||||||
|  | 		LimitSizeCran        int64 | ||||||
| 		LimitSizeDebian      int64 | 		LimitSizeDebian      int64 | ||||||
| 		LimitSizeGeneric     int64 | 		LimitSizeGeneric     int64 | ||||||
| 		LimitSizeGo          int64 | 		LimitSizeGo          int64 | ||||||
| @@ -78,6 +79,7 @@ func loadPackagesFrom(rootCfg ConfigProvider) { | |||||||
| 	Packages.LimitSizeConan = mustBytes(sec, "LIMIT_SIZE_CONAN") | 	Packages.LimitSizeConan = mustBytes(sec, "LIMIT_SIZE_CONAN") | ||||||
| 	Packages.LimitSizeConda = mustBytes(sec, "LIMIT_SIZE_CONDA") | 	Packages.LimitSizeConda = mustBytes(sec, "LIMIT_SIZE_CONDA") | ||||||
| 	Packages.LimitSizeContainer = mustBytes(sec, "LIMIT_SIZE_CONTAINER") | 	Packages.LimitSizeContainer = mustBytes(sec, "LIMIT_SIZE_CONTAINER") | ||||||
|  | 	Packages.LimitSizeCran = mustBytes(sec, "LIMIT_SIZE_CRAN") | ||||||
| 	Packages.LimitSizeDebian = mustBytes(sec, "LIMIT_SIZE_DEBIAN") | 	Packages.LimitSizeDebian = mustBytes(sec, "LIMIT_SIZE_DEBIAN") | ||||||
| 	Packages.LimitSizeGeneric = mustBytes(sec, "LIMIT_SIZE_GENERIC") | 	Packages.LimitSizeGeneric = mustBytes(sec, "LIMIT_SIZE_GENERIC") | ||||||
| 	Packages.LimitSizeGo = mustBytes(sec, "LIMIT_SIZE_GO") | 	Packages.LimitSizeGo = mustBytes(sec, "LIMIT_SIZE_GO") | ||||||
|   | |||||||
| @@ -3258,6 +3258,9 @@ container.layers = Image Layers | |||||||
| container.labels = Labels | container.labels = Labels | ||||||
| container.labels.key = Key | container.labels.key = Key | ||||||
| container.labels.value = Value | 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 = Setup this registry from the command line: | ||||||
| debian.registry.info = Choose $distribution and $component from the list below. | debian.registry.info = Choose $distribution and $component from the list below. | ||||||
| debian.install = To install the package, run the following command: | debian.install = To install the package, run the following command: | ||||||
|   | |||||||
							
								
								
									
										1
									
								
								public/img/svg/gitea-cran.svg
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								public/img/svg/gitea-cran.svg
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -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> | ||||||
| After Width: | Height: | Size: 1.2 KiB | 
| @@ -22,6 +22,7 @@ import ( | |||||||
| 	"code.gitea.io/gitea/routers/api/packages/conan" | 	"code.gitea.io/gitea/routers/api/packages/conan" | ||||||
| 	"code.gitea.io/gitea/routers/api/packages/conda" | 	"code.gitea.io/gitea/routers/api/packages/conda" | ||||||
| 	"code.gitea.io/gitea/routers/api/packages/container" | 	"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/debian" | ||||||
| 	"code.gitea.io/gitea/routers/api/packages/generic" | 	"code.gitea.io/gitea/routers/api/packages/generic" | ||||||
| 	"code.gitea.io/gitea/routers/api/packages/goproxy" | 	"code.gitea.io/gitea/routers/api/packages/goproxy" | ||||||
| @@ -295,6 +296,24 @@ func CommonRoutes(ctx gocontext.Context) *web.Route { | |||||||
| 				conda.UploadPackageFile(ctx) | 				conda.UploadPackageFile(ctx) | ||||||
| 			}) | 			}) | ||||||
| 		}, reqPackageAccess(perm.AccessModeRead)) | 		}, 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.Group("/debian", func() { | ||||||
| 			r.Get("/repository.key", debian.GetRepositoryKey) | 			r.Get("/repository.key", debian.GetRepositoryKey) | ||||||
| 			r.Group("/dists/{distribution}", func() { | 			r.Group("/dists/{distribution}", func() { | ||||||
|   | |||||||
							
								
								
									
										267
									
								
								routers/api/packages/cran/cran.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										267
									
								
								routers/api/packages/cran/cran.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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(), | ||||||
|  | 	}) | ||||||
|  | } | ||||||
| @@ -40,7 +40,7 @@ func ListPackages(ctx *context.APIContext) { | |||||||
| 	//   in: query | 	//   in: query | ||||||
| 	//   description: package type filter | 	//   description: package type filter | ||||||
| 	//   type: string | 	//   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 | 	// - name: q | ||||||
| 	//   in: query | 	//   in: query | ||||||
| 	//   description: name filter | 	//   description: name filter | ||||||
|   | |||||||
| @@ -15,7 +15,7 @@ import ( | |||||||
| type PackageCleanupRuleForm struct { | type PackageCleanupRuleForm struct { | ||||||
| 	ID            int64 | 	ID            int64 | ||||||
| 	Enabled       bool | 	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)"` | 	KeepCount     int    `binding:"In(0,1,5,10,25,50,100)"` | ||||||
| 	KeepPattern   string `binding:"RegexPattern"` | 	KeepPattern   string `binding:"RegexPattern"` | ||||||
| 	RemoveDays    int    `binding:"In(0,7,14,30,60,90,180)"` | 	RemoveDays    int    `binding:"In(0,7,14,30,60,90,180)"` | ||||||
|   | |||||||
| @@ -365,6 +365,8 @@ func CheckSizeQuotaExceeded(ctx context.Context, doer, owner *user_model.User, p | |||||||
| 		typeSpecificSize = setting.Packages.LimitSizeConda | 		typeSpecificSize = setting.Packages.LimitSizeConda | ||||||
| 	case packages_model.TypeContainer: | 	case packages_model.TypeContainer: | ||||||
| 		typeSpecificSize = setting.Packages.LimitSizeContainer | 		typeSpecificSize = setting.Packages.LimitSizeContainer | ||||||
|  | 	case packages_model.TypeCran: | ||||||
|  | 		typeSpecificSize = setting.Packages.LimitSizeCran | ||||||
| 	case packages_model.TypeDebian: | 	case packages_model.TypeDebian: | ||||||
| 		typeSpecificSize = setting.Packages.LimitSizeDebian | 		typeSpecificSize = setting.Packages.LimitSizeDebian | ||||||
| 	case packages_model.TypeGeneric: | 	case packages_model.TypeGeneric: | ||||||
|   | |||||||
							
								
								
									
										59
									
								
								templates/package/content/cran.tmpl
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								templates/package/content/cran.tmpl
									
									
									
									
									
										Normal file
									
								
							| @@ -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}} | ||||||
							
								
								
									
										5
									
								
								templates/package/metadata/cran.tmpl
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								templates/package/metadata/cran.tmpl
									
									
									
									
									
										Normal file
									
								
							| @@ -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}} | ||||||
| @@ -26,6 +26,7 @@ | |||||||
| 					{{template "package/content/conan" .}} | 					{{template "package/content/conan" .}} | ||||||
| 					{{template "package/content/conda" .}} | 					{{template "package/content/conda" .}} | ||||||
| 					{{template "package/content/container" .}} | 					{{template "package/content/container" .}} | ||||||
|  | 					{{template "package/content/cran" .}} | ||||||
| 					{{template "package/content/debian" .}} | 					{{template "package/content/debian" .}} | ||||||
| 					{{template "package/content/generic" .}} | 					{{template "package/content/generic" .}} | ||||||
| 					{{template "package/content/go" .}} | 					{{template "package/content/go" .}} | ||||||
| @@ -57,6 +58,7 @@ | |||||||
| 							{{template "package/metadata/conan" .}} | 							{{template "package/metadata/conan" .}} | ||||||
| 							{{template "package/metadata/conda" .}} | 							{{template "package/metadata/conda" .}} | ||||||
| 							{{template "package/metadata/container" .}} | 							{{template "package/metadata/container" .}} | ||||||
|  | 							{{template "package/metadata/cran" .}} | ||||||
| 							{{template "package/metadata/debian" .}} | 							{{template "package/metadata/debian" .}} | ||||||
| 							{{template "package/metadata/generic" .}} | 							{{template "package/metadata/generic" .}} | ||||||
| 							{{template "package/metadata/helm" .}} | 							{{template "package/metadata/helm" .}} | ||||||
|   | |||||||
							
								
								
									
										1
									
								
								templates/swagger/v1_json.tmpl
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1
									
								
								templates/swagger/v1_json.tmpl
									
									
									
										generated
									
									
									
								
							| @@ -2416,6 +2416,7 @@ | |||||||
|               "conan", |               "conan", | ||||||
|               "conda", |               "conda", | ||||||
|               "container", |               "container", | ||||||
|  |               "cran", | ||||||
|               "debian", |               "debian", | ||||||
|               "generic", |               "generic", | ||||||
|               "go", |               "go", | ||||||
|   | |||||||
							
								
								
									
										242
									
								
								tests/integration/api_packages_cran_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										242
									
								
								tests/integration/api_packages_cran_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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") | ||||||
|  | 		}) | ||||||
|  | 	}) | ||||||
|  | } | ||||||
							
								
								
									
										15
									
								
								web_src/svg/gitea-cran.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								web_src/svg/gitea-cran.svg
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||||
| After Width: | Height: | Size: 1.2 KiB | 
		Reference in New Issue
	
	Block a user