mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-31 21:28:11 +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 | ||||
| ;; 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`) | ||||
|   | ||||
| @@ -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`) | ||||
|   | ||||
							
								
								
									
										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` | | ||||
| | [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` | | ||||
|   | ||||
							
								
								
									
										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/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: | ||||
|   | ||||
| @@ -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: | ||||
|   | ||||
							
								
								
									
										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 | ||||
| 		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") | ||||
|   | ||||
| @@ -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: | ||||
|   | ||||
							
								
								
									
										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/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() { | ||||
|   | ||||
							
								
								
									
										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 | ||||
| 	//   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 | ||||
|   | ||||
| @@ -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)"` | ||||
|   | ||||
| @@ -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: | ||||
|   | ||||
							
								
								
									
										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/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" .}} | ||||
|   | ||||
							
								
								
									
										1
									
								
								templates/swagger/v1_json.tmpl
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1
									
								
								templates/swagger/v1_json.tmpl
									
									
									
										generated
									
									
									
								
							| @@ -2416,6 +2416,7 @@ | ||||
|               "conan", | ||||
|               "conda", | ||||
|               "container", | ||||
|               "cran", | ||||
|               "debian", | ||||
|               "generic", | ||||
|               "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