mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2024-11-25 14:35:40 +00:00
Add Alpine package registry (#23714)
This PR adds an Alpine package registry. You can follow [this tutorial](https://wiki.alpinelinux.org/wiki/Creating_an_Alpine_package) to build a *.apk package for testing. This functionality is similar to the Debian registry (#22854) and therefore shares some methods. I marked this PR as blocked because it should be merged after #22854. ![grafik](https://user-images.githubusercontent.com/1666336/227779595-b76163aa-eea1-4a79-9583-775c24ad74e8.png) --------- Co-authored-by: techknowlogick <techknowlogick@gitea.io> Co-authored-by: silverwind <me@silverwind.io> Co-authored-by: Giteabot <teabot@gitea.io>
This commit is contained in:
parent
80bde0141b
commit
9173e079ae
|
@ -2442,6 +2442,8 @@ ROUTER = console
|
|||
;LIMIT_TOTAL_OWNER_COUNT = -1
|
||||
;; Maximum size of packages a single owner can use (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`)
|
||||
;LIMIT_TOTAL_OWNER_SIZE = -1
|
||||
;; Maximum size of an Alpine upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`)
|
||||
;LIMIT_SIZE_ALPINE = -1
|
||||
;; Maximum size of a Cargo upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`)
|
||||
;LIMIT_SIZE_CARGO = -1
|
||||
;; Maximum size of a Chef upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`)
|
||||
|
|
|
@ -1213,6 +1213,7 @@ Task queue configuration has been moved to `queue.task`. However, the below conf
|
|||
- `CHUNKED_UPLOAD_PATH`: **tmp/package-upload**: Path for chunked uploads. Defaults to `APP_DATA_PATH` + `tmp/package-upload`
|
||||
- `LIMIT_TOTAL_OWNER_COUNT`: **-1**: Maximum count of package versions a single owner can have (`-1` means no limits)
|
||||
- `LIMIT_TOTAL_OWNER_SIZE`: **-1**: Maximum size of packages a single owner can use (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`)
|
||||
- `LIMIT_SIZE_ALPINE`: **-1**: Maximum size of an Alpine upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`)
|
||||
- `LIMIT_SIZE_CARGO`: **-1**: Maximum size of a Cargo upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`)
|
||||
- `LIMIT_SIZE_CHEF`: **-1**: Maximum size of a Chef upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`)
|
||||
- `LIMIT_SIZE_COMPOSER`: **-1**: Maximum size of a Composer upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`)
|
||||
|
|
133
docs/content/doc/usage/packages/alpine.en-us.md
Normal file
133
docs/content/doc/usage/packages/alpine.en-us.md
Normal file
|
@ -0,0 +1,133 @@
|
|||
---
|
||||
date: "2023-03-25T00:00:00+00:00"
|
||||
title: "Alpine Packages Repository"
|
||||
slug: "packages/alpine"
|
||||
draft: false
|
||||
toc: false
|
||||
menu:
|
||||
sidebar:
|
||||
parent: "packages"
|
||||
name: "Alpine"
|
||||
weight: 4
|
||||
identifier: "alpine"
|
||||
---
|
||||
|
||||
# Alpine Packages Repository
|
||||
|
||||
Publish [Alpine](https://pkgs.alpinelinux.org/) packages for your user or organization.
|
||||
|
||||
**Table of Contents**
|
||||
|
||||
{{< toc >}}
|
||||
|
||||
## Requirements
|
||||
|
||||
To work with the Alpine registry, you need to use a HTTP client like `curl` to upload and a package manager like `apk` to consume packages.
|
||||
|
||||
The following examples use `apk`.
|
||||
|
||||
## Configuring the package registry
|
||||
|
||||
To register the Alpine registry add the url to the list of known apk sources (`/etc/apk/repositories`):
|
||||
|
||||
```
|
||||
https://gitea.example.com/api/packages/{owner}/alpine/<branch>/<repository>
|
||||
```
|
||||
|
||||
| Placeholder | Description |
|
||||
| ------------ | ----------- |
|
||||
| `owner` | The owner of the packages. |
|
||||
| `branch` | The branch to use. |
|
||||
| `repository` | The repository to use. |
|
||||
|
||||
If the registry is private, provide credentials in the url. You can use a password or a [personal access token]({{< relref "doc/development/api-usage.en-us.md#authentication" >}}):
|
||||
|
||||
```
|
||||
https://{username}:{your_password_or_token}@gitea.example.com/api/packages/{owner}/alpine/<branch>/<repository>
|
||||
```
|
||||
|
||||
The Alpine registry files are signed with a RSA key which must be known to apk. Download the public key and store it in `/etc/apk/keys/`:
|
||||
|
||||
```shell
|
||||
curl -JO https://gitea.example.com/api/packages/{owner}/alpine/key
|
||||
```
|
||||
|
||||
Afterwards update the local package index:
|
||||
|
||||
```shell
|
||||
apk update
|
||||
```
|
||||
|
||||
## Publish a package
|
||||
|
||||
To publish an Alpine package (`*.apk`), perform a HTTP `PUT` operation with the package content in the request body.
|
||||
|
||||
```
|
||||
PUT https://gitea.example.com/api/packages/{owner}/alpine/{branch}/{repository}
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
| ------------ | ----------- |
|
||||
| `owner` | The owner of the package. |
|
||||
| `branch` | The branch may match the release version of the OS, ex: `v3.17`. |
|
||||
| `repository` | The repository can be used [to group packages](https://wiki.alpinelinux.org/wiki/Repositories) or just `main` or similar. |
|
||||
|
||||
Example request using HTTP Basic authentication:
|
||||
|
||||
```shell
|
||||
curl --user your_username:your_password_or_token \
|
||||
--upload-file path/to/file.apk \
|
||||
https://gitea.example.com/api/packages/testuser/alpine/v3.17/main
|
||||
```
|
||||
|
||||
If you are using 2FA or OAuth use a [personal access token]({{< relref "doc/development/api-usage.en-us.md#authentication" >}}) instead of the password.
|
||||
You cannot publish a file with the same name twice to a package. You must delete the existing package file first.
|
||||
|
||||
The server responds with the following HTTP Status codes.
|
||||
|
||||
| HTTP Status Code | Meaning |
|
||||
| ----------------- | ------- |
|
||||
| `201 Created` | The package has been published. |
|
||||
| `400 Bad Request` | The package name, version, branch, repository or architecture are invalid. |
|
||||
| `409 Conflict` | A package file with the same combination of parameters exist already in the package. |
|
||||
|
||||
## Delete a package
|
||||
|
||||
To delete an Alpine package perform a HTTP `DELETE` operation. This will delete the package version too if there is no file left.
|
||||
|
||||
```
|
||||
DELETE https://gitea.example.com/api/packages/{owner}/alpine/{branch}/{repository}/{architecture}/{filename}
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
| -------------- | ----------- |
|
||||
| `owner` | The owner of the package. |
|
||||
| `branch` | The branch to use. |
|
||||
| `repository` | The repository to use. |
|
||||
| `architecture` | The package architecture. |
|
||||
| `filename` | The file to delete.
|
||||
|
||||
Example request using HTTP Basic authentication:
|
||||
|
||||
```shell
|
||||
curl --user your_username:your_token_or_password -X DELETE \
|
||||
https://gitea.example.com/api/packages/testuser/alpine/v3.17/main/test-package-1.0.0.apk
|
||||
```
|
||||
|
||||
The server responds with the following HTTP Status codes.
|
||||
|
||||
| HTTP Status Code | Meaning |
|
||||
| ----------------- | ------- |
|
||||
| `204 No Content` | Success |
|
||||
| `404 Not Found` | The package or file was not found. |
|
||||
|
||||
## Install a package
|
||||
|
||||
To install a package from the Alpine registry, execute the following commands:
|
||||
|
||||
```shell
|
||||
# use latest version
|
||||
apk add {package_name}
|
||||
# use specific version
|
||||
apk add {package_name}={package_version}
|
||||
```
|
|
@ -27,6 +27,7 @@ The following package managers are currently supported:
|
|||
|
||||
| Name | Language | Package client |
|
||||
| ---- | -------- | -------------- |
|
||||
| [Alpine]({{< relref "doc/usage/packages/alpine.en-us.md" >}}) | - | `apk` |
|
||||
| [Cargo]({{< relref "doc/usage/packages/cargo.en-us.md" >}}) | Rust | `cargo` |
|
||||
| [Chef]({{< relref "doc/usage/packages/chef.en-us.md" >}}) | - | `knife` |
|
||||
| [Composer]({{< relref "doc/usage/packages/composer.en-us.md" >}}) | PHP | `composer` |
|
||||
|
|
|
@ -9,7 +9,7 @@ menu:
|
|||
sidebar:
|
||||
parent: "packages"
|
||||
name: "Storage"
|
||||
weight: 5
|
||||
weight: 2
|
||||
identifier: "storage"
|
||||
---
|
||||
|
||||
|
|
53
models/packages/alpine/search.go
Normal file
53
models/packages/alpine/search.go
Normal file
|
@ -0,0 +1,53 @@
|
|||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package alpine
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
packages_model "code.gitea.io/gitea/models/packages"
|
||||
alpine_module "code.gitea.io/gitea/modules/packages/alpine"
|
||||
)
|
||||
|
||||
// GetBranches gets all available branches
|
||||
func GetBranches(ctx context.Context, ownerID int64) ([]string, error) {
|
||||
return packages_model.GetDistinctPropertyValues(
|
||||
ctx,
|
||||
packages_model.TypeAlpine,
|
||||
ownerID,
|
||||
packages_model.PropertyTypeFile,
|
||||
alpine_module.PropertyBranch,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
// GetRepositories gets all available repositories for the given branch
|
||||
func GetRepositories(ctx context.Context, ownerID int64, branch string) ([]string, error) {
|
||||
return packages_model.GetDistinctPropertyValues(
|
||||
ctx,
|
||||
packages_model.TypeAlpine,
|
||||
ownerID,
|
||||
packages_model.PropertyTypeFile,
|
||||
alpine_module.PropertyRepository,
|
||||
&packages_model.DistinctPropertyDependency{
|
||||
Name: alpine_module.PropertyBranch,
|
||||
Value: branch,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// GetArchitectures gets all available architectures for the given repository
|
||||
func GetArchitectures(ctx context.Context, ownerID int64, repository string) ([]string, error) {
|
||||
return packages_model.GetDistinctPropertyValues(
|
||||
ctx,
|
||||
packages_model.TypeAlpine,
|
||||
ownerID,
|
||||
packages_model.PropertyTypeFile,
|
||||
alpine_module.PropertyArchitecture,
|
||||
&packages_model.DistinctPropertyDependency{
|
||||
Name: alpine_module.PropertyRepository,
|
||||
Value: repository,
|
||||
},
|
||||
)
|
||||
}
|
|
@ -88,44 +88,42 @@ func SearchLatestPackages(ctx context.Context, opts *PackageSearchOptions) ([]*p
|
|||
|
||||
// GetDistributions gets all available distributions
|
||||
func GetDistributions(ctx context.Context, ownerID int64) ([]string, error) {
|
||||
return getDistinctPropertyValues(ctx, ownerID, "", debian_module.PropertyDistribution)
|
||||
return packages.GetDistinctPropertyValues(
|
||||
ctx,
|
||||
packages.TypeDebian,
|
||||
ownerID,
|
||||
packages.PropertyTypeFile,
|
||||
debian_module.PropertyDistribution,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
// GetComponents gets all available components for the given distribution
|
||||
func GetComponents(ctx context.Context, ownerID int64, distribution string) ([]string, error) {
|
||||
return getDistinctPropertyValues(ctx, ownerID, distribution, debian_module.PropertyComponent)
|
||||
return packages.GetDistinctPropertyValues(
|
||||
ctx,
|
||||
packages.TypeDebian,
|
||||
ownerID,
|
||||
packages.PropertyTypeFile,
|
||||
debian_module.PropertyComponent,
|
||||
&packages.DistinctPropertyDependency{
|
||||
Name: debian_module.PropertyDistribution,
|
||||
Value: distribution,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// GetArchitectures gets all available architectures for the given distribution
|
||||
func GetArchitectures(ctx context.Context, ownerID int64, distribution string) ([]string, error) {
|
||||
return getDistinctPropertyValues(ctx, ownerID, distribution, debian_module.PropertyArchitecture)
|
||||
}
|
||||
|
||||
func getDistinctPropertyValues(ctx context.Context, ownerID int64, distribution, propName string) ([]string, error) {
|
||||
var cond builder.Cond = builder.Eq{
|
||||
"package_property.ref_type": packages.PropertyTypeFile,
|
||||
"package_property.name": propName,
|
||||
"package.type": packages.TypeDebian,
|
||||
"package.owner_id": ownerID,
|
||||
}
|
||||
if distribution != "" {
|
||||
innerCond := builder.
|
||||
Expr("pp.ref_id = package_property.ref_id").
|
||||
And(builder.Eq{
|
||||
"pp.ref_type": packages.PropertyTypeFile,
|
||||
"pp.name": debian_module.PropertyDistribution,
|
||||
"pp.value": distribution,
|
||||
})
|
||||
cond = cond.And(builder.Exists(builder.Select("pp.ref_id").From("package_property pp").Where(innerCond)))
|
||||
}
|
||||
|
||||
values := make([]string, 0, 5)
|
||||
return values, db.GetEngine(ctx).
|
||||
Table("package_property").
|
||||
Distinct("package_property.value").
|
||||
Join("INNER", "package_file", "package_file.id = package_property.ref_id").
|
||||
Join("INNER", "package_version", "package_version.id = package_file.version_id").
|
||||
Join("INNER", "package", "package.id = package_version.package_id").
|
||||
Where(cond).
|
||||
Find(&values)
|
||||
return packages.GetDistinctPropertyValues(
|
||||
ctx,
|
||||
packages.TypeDebian,
|
||||
ownerID,
|
||||
packages.PropertyTypeFile,
|
||||
debian_module.PropertyArchitecture,
|
||||
&packages.DistinctPropertyDependency{
|
||||
Name: debian_module.PropertyDistribution,
|
||||
Value: distribution,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
|
@ -12,6 +12,7 @@ import (
|
|||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/json"
|
||||
"code.gitea.io/gitea/modules/packages/alpine"
|
||||
"code.gitea.io/gitea/modules/packages/cargo"
|
||||
"code.gitea.io/gitea/modules/packages/chef"
|
||||
"code.gitea.io/gitea/modules/packages/composer"
|
||||
|
@ -136,6 +137,8 @@ func GetPackageDescriptor(ctx context.Context, pv *PackageVersion) (*PackageDesc
|
|||
|
||||
var metadata interface{}
|
||||
switch p.Type {
|
||||
case TypeAlpine:
|
||||
metadata = &alpine.VersionMetadata{}
|
||||
case TypeCargo:
|
||||
metadata = &cargo.Metadata{}
|
||||
case TypeChef:
|
||||
|
|
|
@ -30,6 +30,7 @@ type Type string
|
|||
|
||||
// List of supported packages
|
||||
const (
|
||||
TypeAlpine Type = "alpine"
|
||||
TypeCargo Type = "cargo"
|
||||
TypeChef Type = "chef"
|
||||
TypeComposer Type = "composer"
|
||||
|
@ -51,6 +52,7 @@ const (
|
|||
)
|
||||
|
||||
var TypeList = []Type{
|
||||
TypeAlpine,
|
||||
TypeCargo,
|
||||
TypeChef,
|
||||
TypeComposer,
|
||||
|
@ -74,6 +76,8 @@ var TypeList = []Type{
|
|||
// Name gets the name of the package type
|
||||
func (pt Type) Name() string {
|
||||
switch pt {
|
||||
case TypeAlpine:
|
||||
return "Alpine"
|
||||
case TypeCargo:
|
||||
return "Cargo"
|
||||
case TypeChef:
|
||||
|
@ -117,6 +121,8 @@ func (pt Type) Name() string {
|
|||
// SVGName gets the name of the package type svg image
|
||||
func (pt Type) SVGName() string {
|
||||
switch pt {
|
||||
case TypeAlpine:
|
||||
return "gitea-alpine"
|
||||
case TypeCargo:
|
||||
return "gitea-cargo"
|
||||
case TypeChef:
|
||||
|
|
|
@ -7,6 +7,8 @@ import (
|
|||
"context"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
@ -81,3 +83,39 @@ func DeletePropertyByName(ctx context.Context, refType PropertyType, refID int64
|
|||
_, err := db.GetEngine(ctx).Where("ref_type = ? AND ref_id = ? AND name = ?", refType, refID, name).Delete(&PackageProperty{})
|
||||
return err
|
||||
}
|
||||
|
||||
type DistinctPropertyDependency struct {
|
||||
Name string
|
||||
Value string
|
||||
}
|
||||
|
||||
// GetDistinctPropertyValues returns all distinct property values for a given type.
|
||||
// Optional: Search only in dependence of another property.
|
||||
func GetDistinctPropertyValues(ctx context.Context, packageType Type, ownerID int64, refType PropertyType, propertyName string, dep *DistinctPropertyDependency) ([]string, error) {
|
||||
var cond builder.Cond = builder.Eq{
|
||||
"package_property.ref_type": refType,
|
||||
"package_property.name": propertyName,
|
||||
"package.type": packageType,
|
||||
"package.owner_id": ownerID,
|
||||
}
|
||||
if dep != nil {
|
||||
innerCond := builder.
|
||||
Expr("pp.ref_id = package_property.ref_id").
|
||||
And(builder.Eq{
|
||||
"pp.ref_type": refType,
|
||||
"pp.name": dep.Name,
|
||||
"pp.value": dep.Value,
|
||||
})
|
||||
cond = cond.And(builder.Exists(builder.Select("pp.ref_id").From("package_property pp").Where(innerCond)))
|
||||
}
|
||||
|
||||
values := make([]string, 0, 5)
|
||||
return values, db.GetEngine(ctx).
|
||||
Table("package_property").
|
||||
Distinct("package_property.value").
|
||||
Join("INNER", "package_file", "package_file.id = package_property.ref_id").
|
||||
Join("INNER", "package_version", "package_version.id = package_file.version_id").
|
||||
Join("INNER", "package", "package.id = package_version.package_id").
|
||||
Where(cond).
|
||||
Find(&values)
|
||||
}
|
||||
|
|
236
modules/packages/alpine/metadata.go
Normal file
236
modules/packages/alpine/metadata.go
Normal file
|
@ -0,0 +1,236 @@
|
|||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package alpine
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bufio"
|
||||
"compress/gzip"
|
||||
"crypto/sha1"
|
||||
"encoding/base64"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"code.gitea.io/gitea/modules/validation"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrMissingPKGINFOFile = util.NewInvalidArgumentErrorf("PKGINFO file is missing")
|
||||
ErrInvalidName = util.NewInvalidArgumentErrorf("package name is invalid")
|
||||
ErrInvalidVersion = util.NewInvalidArgumentErrorf("package version is invalid")
|
||||
)
|
||||
|
||||
const (
|
||||
PropertyMetadata = "alpine.metadata"
|
||||
PropertyBranch = "alpine.branch"
|
||||
PropertyRepository = "alpine.repository"
|
||||
PropertyArchitecture = "alpine.architecture"
|
||||
|
||||
SettingKeyPrivate = "alpine.key.private"
|
||||
SettingKeyPublic = "alpine.key.public"
|
||||
|
||||
RepositoryPackage = "_alpine"
|
||||
RepositoryVersion = "_repository"
|
||||
)
|
||||
|
||||
// https://wiki.alpinelinux.org/wiki/Apk_spec
|
||||
|
||||
// Package represents an Alpine package
|
||||
type Package struct {
|
||||
Name string
|
||||
Version string
|
||||
VersionMetadata VersionMetadata
|
||||
FileMetadata FileMetadata
|
||||
}
|
||||
|
||||
// Metadata of an Alpine package
|
||||
type VersionMetadata struct {
|
||||
Description string `json:"description,omitempty"`
|
||||
License string `json:"license,omitempty"`
|
||||
ProjectURL string `json:"project_url,omitempty"`
|
||||
Maintainer string `json:"maintainer,omitempty"`
|
||||
}
|
||||
|
||||
type FileMetadata struct {
|
||||
Checksum string `json:"checksum"`
|
||||
Packager string `json:"packager,omitempty"`
|
||||
BuildDate int64 `json:"build_date,omitempty"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
Architecture string `json:"architecture,omitempty"`
|
||||
Origin string `json:"origin,omitempty"`
|
||||
CommitHash string `json:"commit_hash,omitempty"`
|
||||
InstallIf string `json:"install_if,omitempty"`
|
||||
Provides []string `json:"provides,omitempty"`
|
||||
Dependencies []string `json:"dependencies,omitempty"`
|
||||
}
|
||||
|
||||
// ParsePackage parses the Alpine package file
|
||||
func ParsePackage(r io.Reader) (*Package, error) {
|
||||
// Alpine packages are concated .tar.gz streams. Usually the first stream contains the package metadata.
|
||||
|
||||
br := bufio.NewReader(r) // needed for gzip Multistream
|
||||
|
||||
h := sha1.New()
|
||||
|
||||
gzr, err := gzip.NewReader(&teeByteReader{br, h})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer gzr.Close()
|
||||
|
||||
for {
|
||||
gzr.Multistream(false)
|
||||
|
||||
tr := tar.NewReader(gzr)
|
||||
for {
|
||||
hd, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if hd.Name == ".PKGINFO" {
|
||||
p, err := ParsePackageInfo(tr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// drain the reader
|
||||
for {
|
||||
if _, err := tr.Next(); err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
p.FileMetadata.Checksum = "Q1" + base64.StdEncoding.EncodeToString(h.Sum(nil))
|
||||
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
|
||||
h = sha1.New()
|
||||
|
||||
err = gzr.Reset(&teeByteReader{br, h})
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return nil, ErrMissingPKGINFOFile
|
||||
}
|
||||
|
||||
// ParsePackageInfo parses a PKGINFO file to retrieve the metadata of an Alpine package
|
||||
func ParsePackageInfo(r io.Reader) (*Package, error) {
|
||||
p := &Package{}
|
||||
|
||||
scanner := bufio.NewScanner(r)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
|
||||
if strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
i := strings.IndexRune(line, '=')
|
||||
if i == -1 {
|
||||
continue
|
||||
}
|
||||
|
||||
key := strings.TrimSpace(line[:i])
|
||||
value := strings.TrimSpace(line[i+1:])
|
||||
|
||||
switch key {
|
||||
case "pkgname":
|
||||
p.Name = value
|
||||
case "pkgver":
|
||||
p.Version = value
|
||||
case "pkgdesc":
|
||||
p.VersionMetadata.Description = value
|
||||
case "url":
|
||||
p.VersionMetadata.ProjectURL = value
|
||||
case "builddate":
|
||||
n, err := strconv.ParseInt(value, 10, 64)
|
||||
if err == nil {
|
||||
p.FileMetadata.BuildDate = n
|
||||
}
|
||||
case "size":
|
||||
n, err := strconv.ParseInt(value, 10, 64)
|
||||
if err == nil {
|
||||
p.FileMetadata.Size = n
|
||||
}
|
||||
case "arch":
|
||||
p.FileMetadata.Architecture = value
|
||||
case "origin":
|
||||
p.FileMetadata.Origin = value
|
||||
case "commit":
|
||||
p.FileMetadata.CommitHash = value
|
||||
case "maintainer":
|
||||
p.VersionMetadata.Maintainer = value
|
||||
case "packager":
|
||||
p.FileMetadata.Packager = value
|
||||
case "license":
|
||||
p.VersionMetadata.License = value
|
||||
case "install_if":
|
||||
p.FileMetadata.InstallIf = value
|
||||
case "provides":
|
||||
if value != "" {
|
||||
p.FileMetadata.Provides = append(p.FileMetadata.Provides, value)
|
||||
}
|
||||
case "depend":
|
||||
if value != "" {
|
||||
p.FileMetadata.Dependencies = append(p.FileMetadata.Dependencies, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if p.Name == "" {
|
||||
return nil, ErrInvalidName
|
||||
}
|
||||
|
||||
if p.Version == "" {
|
||||
return nil, ErrInvalidVersion
|
||||
}
|
||||
|
||||
if !validation.IsValidURL(p.VersionMetadata.ProjectURL) {
|
||||
p.VersionMetadata.ProjectURL = ""
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// Same as io.TeeReader but implements io.ByteReader
|
||||
type teeByteReader struct {
|
||||
r *bufio.Reader
|
||||
w io.Writer
|
||||
}
|
||||
|
||||
func (t *teeByteReader) Read(p []byte) (int, error) {
|
||||
n, err := t.r.Read(p)
|
||||
if n > 0 {
|
||||
if n, err := t.w.Write(p[:n]); err != nil {
|
||||
return n, err
|
||||
}
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (t *teeByteReader) ReadByte() (byte, error) {
|
||||
b, err := t.r.ReadByte()
|
||||
if err == nil {
|
||||
if _, err := t.w.Write([]byte{b}); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
return b, err
|
||||
}
|
143
modules/packages/alpine/metadata_test.go
Normal file
143
modules/packages/alpine/metadata_test.go
Normal file
|
@ -0,0 +1,143 @@
|
|||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package alpine
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
const (
|
||||
packageName = "gitea"
|
||||
packageVersion = "1.0.1"
|
||||
packageDescription = "Package Description"
|
||||
packageProjectURL = "https://gitea.io"
|
||||
packageMaintainer = "KN4CK3R <dummy@gitea.io>"
|
||||
)
|
||||
|
||||
func createPKGINFOContent(name, version string) []byte {
|
||||
return []byte(`pkgname = ` + name + `
|
||||
pkgver = ` + version + `
|
||||
pkgdesc = ` + packageDescription + `
|
||||
url = ` + packageProjectURL + `
|
||||
# comment
|
||||
builddate = 1678834800
|
||||
packager = Gitea <pack@ag.er>
|
||||
size = 123456
|
||||
arch = aarch64
|
||||
origin = origin
|
||||
commit = 1111e709613fbc979651b09ac2bc27c6591a9999
|
||||
maintainer = ` + packageMaintainer + `
|
||||
license = MIT
|
||||
depend = common
|
||||
install_if = value
|
||||
depend = gitea
|
||||
provides = common
|
||||
provides = gitea`)
|
||||
}
|
||||
|
||||
func TestParsePackage(t *testing.T) {
|
||||
createPackage := func(name string, content []byte) io.Reader {
|
||||
names := []string{"first.stream", name}
|
||||
contents := [][]byte{{0}, content}
|
||||
|
||||
var buf bytes.Buffer
|
||||
zw := gzip.NewWriter(&buf)
|
||||
|
||||
for i := range names {
|
||||
if i != 0 {
|
||||
zw.Close()
|
||||
zw.Reset(&buf)
|
||||
}
|
||||
|
||||
tw := tar.NewWriter(zw)
|
||||
hdr := &tar.Header{
|
||||
Name: names[i],
|
||||
Mode: 0o600,
|
||||
Size: int64(len(contents[i])),
|
||||
}
|
||||
tw.WriteHeader(hdr)
|
||||
tw.Write(contents[i])
|
||||
tw.Close()
|
||||
}
|
||||
|
||||
zw.Close()
|
||||
|
||||
return &buf
|
||||
}
|
||||
|
||||
t.Run("MissingPKGINFOFile", func(t *testing.T) {
|
||||
data := createPackage("dummy.txt", []byte{})
|
||||
|
||||
pp, err := ParsePackage(data)
|
||||
assert.Nil(t, pp)
|
||||
assert.ErrorIs(t, err, ErrMissingPKGINFOFile)
|
||||
})
|
||||
|
||||
t.Run("InvalidPKGINFOFile", func(t *testing.T) {
|
||||
data := createPackage(".PKGINFO", []byte{})
|
||||
|
||||
pp, err := ParsePackage(data)
|
||||
assert.Nil(t, pp)
|
||||
assert.ErrorIs(t, err, ErrInvalidName)
|
||||
})
|
||||
|
||||
t.Run("Valid", func(t *testing.T) {
|
||||
data := createPackage(".PKGINFO", createPKGINFOContent(packageName, packageVersion))
|
||||
|
||||
p, err := ParsePackage(data)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, p)
|
||||
|
||||
assert.Equal(t, "Q1SRYURM5+uQDqfHSwTnNIOIuuDVQ=", p.FileMetadata.Checksum)
|
||||
})
|
||||
}
|
||||
|
||||
func TestParsePackageInfo(t *testing.T) {
|
||||
t.Run("InvalidName", func(t *testing.T) {
|
||||
data := createPKGINFOContent("", packageVersion)
|
||||
|
||||
p, err := ParsePackageInfo(bytes.NewReader(data))
|
||||
assert.Nil(t, p)
|
||||
assert.ErrorIs(t, err, ErrInvalidName)
|
||||
})
|
||||
|
||||
t.Run("InvalidVersion", func(t *testing.T) {
|
||||
data := createPKGINFOContent(packageName, "")
|
||||
|
||||
p, err := ParsePackageInfo(bytes.NewReader(data))
|
||||
assert.Nil(t, p)
|
||||
assert.ErrorIs(t, err, ErrInvalidVersion)
|
||||
})
|
||||
|
||||
t.Run("Valid", func(t *testing.T) {
|
||||
data := createPKGINFOContent(packageName, packageVersion)
|
||||
|
||||
p, err := ParsePackageInfo(bytes.NewReader(data))
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, p)
|
||||
|
||||
assert.Equal(t, packageName, p.Name)
|
||||
assert.Equal(t, packageVersion, p.Version)
|
||||
assert.Equal(t, packageDescription, p.VersionMetadata.Description)
|
||||
assert.Equal(t, packageMaintainer, p.VersionMetadata.Maintainer)
|
||||
assert.Equal(t, packageProjectURL, p.VersionMetadata.ProjectURL)
|
||||
assert.Equal(t, "MIT", p.VersionMetadata.License)
|
||||
assert.Empty(t, p.FileMetadata.Checksum)
|
||||
assert.Equal(t, "Gitea <pack@ag.er>", p.FileMetadata.Packager)
|
||||
assert.EqualValues(t, 1678834800, p.FileMetadata.BuildDate)
|
||||
assert.EqualValues(t, 123456, p.FileMetadata.Size)
|
||||
assert.Equal(t, "aarch64", p.FileMetadata.Architecture)
|
||||
assert.Equal(t, "origin", p.FileMetadata.Origin)
|
||||
assert.Equal(t, "1111e709613fbc979651b09ac2bc27c6591a9999", p.FileMetadata.CommitHash)
|
||||
assert.Equal(t, "value", p.FileMetadata.InstallIf)
|
||||
assert.ElementsMatch(t, []string{"common", "gitea"}, p.FileMetadata.Provides)
|
||||
assert.ElementsMatch(t, []string{"common", "gitea"}, p.FileMetadata.Dependencies)
|
||||
})
|
||||
}
|
|
@ -24,6 +24,7 @@ var (
|
|||
|
||||
LimitTotalOwnerCount int64
|
||||
LimitTotalOwnerSize int64
|
||||
LimitSizeAlpine int64
|
||||
LimitSizeCargo int64
|
||||
LimitSizeChef int64
|
||||
LimitSizeComposer int64
|
||||
|
@ -69,6 +70,7 @@ func loadPackagesFrom(rootCfg ConfigProvider) {
|
|||
}
|
||||
|
||||
Packages.LimitTotalOwnerSize = mustBytes(sec, "LIMIT_TOTAL_OWNER_SIZE")
|
||||
Packages.LimitSizeAlpine = mustBytes(sec, "LIMIT_SIZE_ALPINE")
|
||||
Packages.LimitSizeCargo = mustBytes(sec, "LIMIT_SIZE_CARGO")
|
||||
Packages.LimitSizeChef = mustBytes(sec, "LIMIT_SIZE_CHEF")
|
||||
Packages.LimitSizeComposer = mustBytes(sec, "LIMIT_SIZE_COMPOSER")
|
||||
|
|
|
@ -4,10 +4,13 @@
|
|||
package util
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
|
||||
"github.com/minio/sha256-simd"
|
||||
)
|
||||
|
||||
// GenerateKeyPair generates a public and private keypair
|
||||
|
@ -43,3 +46,16 @@ func pemBlockForPub(pub *rsa.PublicKey) (string, error) {
|
|||
})
|
||||
return string(pubBytes), nil
|
||||
}
|
||||
|
||||
// CreatePublicKeyFingerprint creates a fingerprint of the given key.
|
||||
// The fingerprint is the sha256 sum of the PKIX structure of the key.
|
||||
func CreatePublicKeyFingerprint(key crypto.PublicKey) ([]byte, error) {
|
||||
bytes, err := x509.MarshalPKIXPublicKey(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
checksum := sha256.Sum256(bytes)
|
||||
|
||||
return checksum[:], nil
|
||||
}
|
||||
|
|
|
@ -3212,6 +3212,15 @@ versions = Versions
|
|||
versions.view_all = View all
|
||||
dependency.id = ID
|
||||
dependency.version = Version
|
||||
alpine.registry = Setup this registry by adding the url in your <code>/etc/apk/repositories</code> file:
|
||||
alpine.registry.key = Download the registry public RSA key into the <code>/etc/apk/keys/</code> folder to verify the index signature:
|
||||
alpine.registry.info = Choose $branch and $repository from the list below.
|
||||
alpine.install = To install the package, run the following command:
|
||||
alpine.documentation = For more information on the Alpine registry, see <a target="_blank" rel="noopener noreferrer" href="%s">the documentation</a>.
|
||||
alpine.repository = Repository Info
|
||||
alpine.repository.branches = Branches
|
||||
alpine.repository.repositories = Repositories
|
||||
alpine.repository.architectures = Architectures
|
||||
cargo.registry = Setup this registry in the Cargo configuration file (for example <code>~/.cargo/config.toml</code>):
|
||||
cargo.install = To install the package using Cargo, run the following command:
|
||||
cargo.documentation = For more information on the Cargo registry, see <a target="_blank" rel="noopener noreferrer" href="%s">the documentation</a>.
|
||||
|
|
1
public/img/svg/gitea-alpine.svg
generated
Normal file
1
public/img/svg/gitea-alpine.svg
generated
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 186 162" class="svg gitea-alpine" width="16" height="16" aria-hidden="true"><g fill="#0d597f"><path d="M67 100.75V81.125L52.875 95.25a41.588 41.588 0 0 0 4.3 2.637c1.35.71 2.612 1.25 3.787 1.676a21.12 21.12 0 0 0 3.275.887c1.006.184 1.926.266 2.763.278m72.25-1.625c.025.02.163.137.416.298.255.163.628.372 1.123.578.494.205 1.111.409 1.85.56.745.152 1.612.252 2.625.252.838 0 1.762-.073 2.775-.25a20.93 20.93 0 0 0 3.3-.873 29.25 29.25 0 0 0 3.837-1.676 41.805 41.805 0 0 0 4.375-2.674l-10.712-10.5-35.5-35.625-15.625 15.625-21-21.625-52.75 52.125c1.55 1.075 3 1.95 4.362 2.675a28.324 28.324 0 0 0 3.838 1.675c1.189.414 2.287.696 3.3.872 1.012.177 1.937.251 2.775.251 1.005 0 1.875-.1 2.625-.252a9.726 9.726 0 0 0 1.85-.561c.495-.205.866-.414 1.121-.577s.393-.278.418-.3l23.875-23.875 8.512-8.162 23.625 23.625 8.238 8.475c.024.021.162.137.417.299.255.162.626.37 1.121.577.495.205 1.113.409 1.85.56.745.153 1.625.253 2.625.253.838 0 1.763-.074 2.775-.25a20.966 20.966 0 0 0 3.3-.874 29.323 29.323 0 0 0 3.838-1.675 41.805 41.805 0 0 0 4.375-2.675l-18.875-18.5 3.525-3.525 16.375 16.375 9.55 9.462m-.204-98.75 46.5 80.625-46.5 80.625H46.05L-.45 81.066 46.05.441z"/><path d="M110.75 77 98.363 64.625l.88-.886 12.476 12.337z"/></g></svg>
|
After Width: | Height: | Size: 1.3 KiB |
253
routers/api/packages/alpine/alpine.go
Normal file
253
routers/api/packages/alpine/alpine.go
Normal file
|
@ -0,0 +1,253 @@
|
|||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package alpine
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
packages_model "code.gitea.io/gitea/models/packages"
|
||||
"code.gitea.io/gitea/modules/context"
|
||||
"code.gitea.io/gitea/modules/json"
|
||||
packages_module "code.gitea.io/gitea/modules/packages"
|
||||
alpine_module "code.gitea.io/gitea/modules/packages/alpine"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"code.gitea.io/gitea/routers/api/packages/helper"
|
||||
packages_service "code.gitea.io/gitea/services/packages"
|
||||
alpine_service "code.gitea.io/gitea/services/packages/alpine"
|
||||
)
|
||||
|
||||
func apiError(ctx *context.Context, status int, obj interface{}) {
|
||||
helper.LogAndProcessError(ctx, status, obj, func(message string) {
|
||||
ctx.PlainText(status, message)
|
||||
})
|
||||
}
|
||||
|
||||
func GetRepositoryKey(ctx *context.Context) {
|
||||
_, pub, err := alpine_service.GetOrCreateKeyPair(ctx.Package.Owner.ID)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
pubPem, _ := pem.Decode([]byte(pub))
|
||||
if pubPem == nil {
|
||||
apiError(ctx, http.StatusInternalServerError, "failed to decode private key pem")
|
||||
return
|
||||
}
|
||||
|
||||
pubKey, err := x509.ParsePKIXPublicKey(pubPem.Bytes)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
fingerprint, err := util.CreatePublicKeyFingerprint(pubKey)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.ServeContent(strings.NewReader(pub), &context.ServeHeaderOptions{
|
||||
ContentType: "application/x-pem-file",
|
||||
Filename: fmt.Sprintf("%s@%s.rsa.pub", ctx.Package.Owner.LowerName, hex.EncodeToString(fingerprint)),
|
||||
})
|
||||
}
|
||||
|
||||
func GetRepositoryFile(ctx *context.Context) {
|
||||
pv, err := alpine_service.GetOrCreateRepositoryVersion(ctx.Package.Owner.ID)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
s, pf, err := packages_service.GetFileStreamByPackageVersion(
|
||||
ctx,
|
||||
pv,
|
||||
&packages_service.PackageFileInfo{
|
||||
Filename: alpine_service.IndexFilename,
|
||||
CompositeKey: fmt.Sprintf("%s|%s|%s", ctx.Params("branch"), ctx.Params("repository"), ctx.Params("architecture")),
|
||||
},
|
||||
)
|
||||
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(),
|
||||
})
|
||||
}
|
||||
|
||||
func UploadPackageFile(ctx *context.Context) {
|
||||
branch := strings.TrimSpace(ctx.Params("branch"))
|
||||
repository := strings.TrimSpace(ctx.Params("repository"))
|
||||
if branch == "" || repository == "" {
|
||||
apiError(ctx, http.StatusBadRequest, "invalid branch or repository")
|
||||
return
|
||||
}
|
||||
|
||||
upload, close, err := ctx.UploadStream()
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, 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 := alpine_module.ParsePackage(buf)
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrInvalidArgument) || err == io.EOF {
|
||||
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
|
||||
}
|
||||
|
||||
fileMetadataRaw, err := json.Marshal(pck.FileMetadata)
|
||||
if 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.TypeAlpine,
|
||||
Name: pck.Name,
|
||||
Version: pck.Version,
|
||||
},
|
||||
Creator: ctx.Doer,
|
||||
Metadata: pck.VersionMetadata,
|
||||
},
|
||||
&packages_service.PackageFileCreationInfo{
|
||||
PackageFileInfo: packages_service.PackageFileInfo{
|
||||
Filename: fmt.Sprintf("%s-%s.apk", pck.Name, pck.Version),
|
||||
CompositeKey: fmt.Sprintf("%s|%s|%s", branch, repository, pck.FileMetadata.Architecture),
|
||||
},
|
||||
Creator: ctx.Doer,
|
||||
Data: buf,
|
||||
IsLead: true,
|
||||
Properties: map[string]string{
|
||||
alpine_module.PropertyBranch: branch,
|
||||
alpine_module.PropertyRepository: repository,
|
||||
alpine_module.PropertyArchitecture: pck.FileMetadata.Architecture,
|
||||
alpine_module.PropertyMetadata: string(fileMetadataRaw),
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
switch err {
|
||||
case packages_model.ErrDuplicatePackageVersion, packages_model.ErrDuplicatePackageFile:
|
||||
apiError(ctx, http.StatusBadRequest, err)
|
||||
case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize:
|
||||
apiError(ctx, http.StatusForbidden, err)
|
||||
default:
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := alpine_service.BuildSpecificRepositoryFiles(ctx, ctx.Package.Owner.ID, branch, repository, pck.FileMetadata.Architecture); err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusCreated)
|
||||
}
|
||||
|
||||
func DownloadPackageFile(ctx *context.Context) {
|
||||
pfs, _, err := packages_model.SearchFiles(ctx, &packages_model.PackageFileSearchOptions{
|
||||
OwnerID: ctx.Package.Owner.ID,
|
||||
PackageType: packages_model.TypeAlpine,
|
||||
Query: ctx.Params("filename"),
|
||||
CompositeKey: fmt.Sprintf("%s|%s|%s", ctx.Params("branch"), ctx.Params("repository"), ctx.Params("architecture")),
|
||||
})
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if len(pfs) != 1 {
|
||||
apiError(ctx, http.StatusNotFound, nil)
|
||||
return
|
||||
}
|
||||
|
||||
s, pf, err := packages_service.GetPackageFileStream(ctx, pfs[0])
|
||||
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(),
|
||||
})
|
||||
}
|
||||
|
||||
func DeletePackageFile(ctx *context.Context) {
|
||||
branch, repository, architecture := ctx.Params("branch"), ctx.Params("repository"), ctx.Params("architecture")
|
||||
|
||||
pfs, _, err := packages_model.SearchFiles(ctx, &packages_model.PackageFileSearchOptions{
|
||||
OwnerID: ctx.Package.Owner.ID,
|
||||
PackageType: packages_model.TypeAlpine,
|
||||
Query: ctx.Params("filename"),
|
||||
CompositeKey: fmt.Sprintf("%s|%s|%s", branch, repository, architecture),
|
||||
})
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if len(pfs) != 1 {
|
||||
apiError(ctx, http.StatusNotFound, nil)
|
||||
return
|
||||
}
|
||||
|
||||
if err := packages_service.RemovePackageFileAndVersionIfUnreferenced(ctx.Doer, pfs[0]); err != nil {
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := alpine_service.BuildSpecificRepositoryFiles(ctx, ctx.Package.Owner.ID, branch, repository, architecture); err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
|
@ -15,6 +15,7 @@ import (
|
|||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/web"
|
||||
"code.gitea.io/gitea/routers/api/packages/alpine"
|
||||
"code.gitea.io/gitea/routers/api/packages/cargo"
|
||||
"code.gitea.io/gitea/routers/api/packages/chef"
|
||||
"code.gitea.io/gitea/routers/api/packages/composer"
|
||||
|
@ -107,6 +108,19 @@ func CommonRoutes(ctx gocontext.Context) *web.Route {
|
|||
})
|
||||
|
||||
r.Group("/{username}", func() {
|
||||
r.Group("/alpine", func() {
|
||||
r.Get("/key", alpine.GetRepositoryKey)
|
||||
r.Group("/{branch}/{repository}", func() {
|
||||
r.Put("", reqPackageAccess(perm.AccessModeWrite), alpine.UploadPackageFile)
|
||||
r.Group("/{architecture}", func() {
|
||||
r.Get("/APKINDEX.tar.gz", alpine.GetRepositoryFile)
|
||||
r.Group("/{filename}", func() {
|
||||
r.Get("", alpine.DownloadPackageFile)
|
||||
r.Delete("", reqPackageAccess(perm.AccessModeWrite), alpine.DeletePackageFile)
|
||||
})
|
||||
})
|
||||
})
|
||||
}, reqPackageAccess(perm.AccessModeRead))
|
||||
r.Group("/cargo", func() {
|
||||
r.Group("/api/v1/crates", func() {
|
||||
r.Get("", cargo.SearchPackages)
|
||||
|
|
|
@ -40,7 +40,7 @@ func ListPackages(ctx *context.APIContext) {
|
|||
// in: query
|
||||
// description: package type filter
|
||||
// type: string
|
||||
// enum: [cargo, chef, composer, conan, conda, container, debian, generic, helm, maven, npm, nuget, pub, pypi, rpm, rubygems, swift, vagrant]
|
||||
// enum: [alpine, cargo, chef, composer, conan, conda, container, debian, generic, helm, maven, npm, nuget, pub, pypi, rpm, rubygems, swift, vagrant]
|
||||
// - name: q
|
||||
// in: query
|
||||
// description: name filter
|
||||
|
|
|
@ -17,6 +17,7 @@ import (
|
|||
"code.gitea.io/gitea/modules/container"
|
||||
"code.gitea.io/gitea/modules/context"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
alpine_module "code.gitea.io/gitea/modules/packages/alpine"
|
||||
debian_module "code.gitea.io/gitea/modules/packages/debian"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
|
@ -168,6 +169,27 @@ func ViewPackageVersion(ctx *context.Context) {
|
|||
switch pd.Package.Type {
|
||||
case packages_model.TypeContainer:
|
||||
ctx.Data["RegistryHost"] = setting.Packages.RegistryHost
|
||||
case packages_model.TypeAlpine:
|
||||
branches := make(container.Set[string])
|
||||
repositories := make(container.Set[string])
|
||||
architectures := make(container.Set[string])
|
||||
|
||||
for _, f := range pd.Files {
|
||||
for _, pp := range f.Properties {
|
||||
switch pp.Name {
|
||||
case alpine_module.PropertyBranch:
|
||||
branches.Add(pp.Value)
|
||||
case alpine_module.PropertyRepository:
|
||||
repositories.Add(pp.Value)
|
||||
case alpine_module.PropertyArchitecture:
|
||||
architectures.Add(pp.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.Data["Branches"] = branches.Values()
|
||||
ctx.Data["Repositories"] = repositories.Values()
|
||||
ctx.Data["Architectures"] = architectures.Values()
|
||||
case packages_model.TypeDebian:
|
||||
distributions := make(container.Set[string])
|
||||
components := make(container.Set[string])
|
||||
|
|
|
@ -23,7 +23,6 @@ import (
|
|||
"code.gitea.io/gitea/modules/util"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/minio/sha256-simd"
|
||||
)
|
||||
|
||||
// ErrInvalidAlgorithmType represents an invalid algorithm error.
|
||||
|
@ -82,7 +81,7 @@ type rsaSingingKey struct {
|
|||
}
|
||||
|
||||
func newRSASingingKey(signingMethod jwt.SigningMethod, key *rsa.PrivateKey) (rsaSingingKey, error) {
|
||||
kid, err := createPublicKeyFingerprint(key.Public().(*rsa.PublicKey))
|
||||
kid, err := util.CreatePublicKeyFingerprint(key.Public().(*rsa.PublicKey))
|
||||
if err != nil {
|
||||
return rsaSingingKey{}, err
|
||||
}
|
||||
|
@ -133,7 +132,7 @@ type eddsaSigningKey struct {
|
|||
}
|
||||
|
||||
func newEdDSASingingKey(signingMethod jwt.SigningMethod, key ed25519.PrivateKey) (eddsaSigningKey, error) {
|
||||
kid, err := createPublicKeyFingerprint(key.Public().(ed25519.PublicKey))
|
||||
kid, err := util.CreatePublicKeyFingerprint(key.Public().(ed25519.PublicKey))
|
||||
if err != nil {
|
||||
return eddsaSigningKey{}, err
|
||||
}
|
||||
|
@ -184,7 +183,7 @@ type ecdsaSingingKey struct {
|
|||
}
|
||||
|
||||
func newECDSASingingKey(signingMethod jwt.SigningMethod, key *ecdsa.PrivateKey) (ecdsaSingingKey, error) {
|
||||
kid, err := createPublicKeyFingerprint(key.Public().(*ecdsa.PublicKey))
|
||||
kid, err := util.CreatePublicKeyFingerprint(key.Public().(*ecdsa.PublicKey))
|
||||
if err != nil {
|
||||
return ecdsaSingingKey{}, err
|
||||
}
|
||||
|
@ -229,19 +228,6 @@ func (key ecdsaSingingKey) PreProcessToken(token *jwt.Token) {
|
|||
token.Header["kid"] = key.id
|
||||
}
|
||||
|
||||
// createPublicKeyFingerprint creates a fingerprint of the given key.
|
||||
// The fingerprint is the sha256 sum of the PKIX structure of the key.
|
||||
func createPublicKeyFingerprint(key interface{}) ([]byte, error) {
|
||||
bytes, err := x509.MarshalPKIXPublicKey(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
checksum := sha256.Sum256(bytes)
|
||||
|
||||
return checksum[:], nil
|
||||
}
|
||||
|
||||
// CreateJWTSigningKey creates a signing key from an algorithm / key pair.
|
||||
func CreateJWTSigningKey(algorithm string, key interface{}) (JWTSigningKey, error) {
|
||||
var signingMethod jwt.SigningMethod
|
||||
|
|
|
@ -15,7 +15,7 @@ import (
|
|||
type PackageCleanupRuleForm struct {
|
||||
ID int64
|
||||
Enabled bool
|
||||
Type string `binding:"Required;In(cargo,chef,composer,conan,conda,container,debian,generic,helm,maven,npm,nuget,pub,pypi,rpm,rubygems,swift,vagrant)"`
|
||||
Type string `binding:"Required;In(alpine,cargo,chef,composer,conan,conda,container,debian,generic,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)"`
|
||||
|
|
328
services/packages/alpine/repository.go
Normal file
328
services/packages/alpine/repository.go
Normal file
|
@ -0,0 +1,328 @@
|
|||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package alpine
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha1"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
packages_model "code.gitea.io/gitea/models/packages"
|
||||
alpine_model "code.gitea.io/gitea/models/packages/alpine"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/json"
|
||||
packages_module "code.gitea.io/gitea/modules/packages"
|
||||
alpine_module "code.gitea.io/gitea/modules/packages/alpine"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
packages_service "code.gitea.io/gitea/services/packages"
|
||||
)
|
||||
|
||||
const IndexFilename = "APKINDEX.tar.gz"
|
||||
|
||||
// GetOrCreateRepositoryVersion gets or creates the internal repository package
|
||||
// The Alpine registry needs multiple index files which are stored in this package.
|
||||
func GetOrCreateRepositoryVersion(ownerID int64) (*packages_model.PackageVersion, error) {
|
||||
return packages_service.GetOrCreateInternalPackageVersion(ownerID, packages_model.TypeAlpine, alpine_module.RepositoryPackage, alpine_module.RepositoryVersion)
|
||||
}
|
||||
|
||||
// GetOrCreateKeyPair gets or creates the RSA keys used to sign repository files
|
||||
func GetOrCreateKeyPair(ownerID int64) (string, string, error) {
|
||||
priv, err := user_model.GetSetting(ownerID, alpine_module.SettingKeyPrivate)
|
||||
if err != nil && !errors.Is(err, util.ErrNotExist) {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
pub, err := user_model.GetSetting(ownerID, alpine_module.SettingKeyPublic)
|
||||
if err != nil && !errors.Is(err, util.ErrNotExist) {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
if priv == "" || pub == "" {
|
||||
priv, pub, err = util.GenerateKeyPair(4096)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
if err := user_model.SetUserSetting(ownerID, alpine_module.SettingKeyPrivate, priv); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
if err := user_model.SetUserSetting(ownerID, alpine_module.SettingKeyPublic, pub); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
}
|
||||
|
||||
return priv, pub, nil
|
||||
}
|
||||
|
||||
// BuildAllRepositoryFiles (re)builds all repository files for every available distributions, components and architectures
|
||||
func BuildAllRepositoryFiles(ctx context.Context, ownerID int64) error {
|
||||
pv, err := GetOrCreateRepositoryVersion(ownerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 1. Delete all existing repository files
|
||||
pfs, err := packages_model.GetFilesByVersionID(ctx, pv.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, pf := range pfs {
|
||||
if err := packages_model.DeleteAllProperties(ctx, packages_model.PropertyTypeFile, pf.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := packages_model.DeleteFileByID(ctx, pf.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 2. (Re)Build repository files for existing packages
|
||||
branches, err := alpine_model.GetBranches(ctx, ownerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, branch := range branches {
|
||||
repositories, err := alpine_model.GetRepositories(ctx, ownerID, branch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, repository := range repositories {
|
||||
architectures, err := alpine_model.GetArchitectures(ctx, ownerID, repository)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, architecture := range architectures {
|
||||
if err := buildPackagesIndex(ctx, ownerID, pv, branch, repository, architecture); err != nil {
|
||||
return fmt.Errorf("failed to build repository files [%s/%s/%s]: %w", branch, repository, architecture, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// BuildSpecificRepositoryFiles builds index files for the repository
|
||||
func BuildSpecificRepositoryFiles(ctx context.Context, ownerID int64, branch, repository, architecture string) error {
|
||||
pv, err := GetOrCreateRepositoryVersion(ownerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return buildPackagesIndex(ctx, ownerID, pv, branch, repository, architecture)
|
||||
}
|
||||
|
||||
type packageData struct {
|
||||
Package *packages_model.Package
|
||||
Version *packages_model.PackageVersion
|
||||
Blob *packages_model.PackageBlob
|
||||
VersionMetadata *alpine_module.VersionMetadata
|
||||
FileMetadata *alpine_module.FileMetadata
|
||||
}
|
||||
|
||||
type packageCache = map[*packages_model.PackageFile]*packageData
|
||||
|
||||
// https://wiki.alpinelinux.org/wiki/Apk_spec#APKINDEX_Format
|
||||
func buildPackagesIndex(ctx context.Context, ownerID int64, repoVersion *packages_model.PackageVersion, branch, repository, architecture string) error {
|
||||
pfs, _, err := packages_model.SearchFiles(ctx, &packages_model.PackageFileSearchOptions{
|
||||
OwnerID: ownerID,
|
||||
PackageType: packages_model.TypeAlpine,
|
||||
Query: "%.apk",
|
||||
Properties: map[string]string{
|
||||
alpine_module.PropertyBranch: branch,
|
||||
alpine_module.PropertyRepository: repository,
|
||||
alpine_module.PropertyArchitecture: architecture,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete the package indices if there are no packages
|
||||
if len(pfs) == 0 {
|
||||
pf, err := packages_model.GetFileForVersionByName(ctx, repoVersion.ID, IndexFilename, fmt.Sprintf("%s|%s|%s", branch, repository, architecture))
|
||||
if err != nil && !errors.Is(err, util.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := packages_model.DeleteAllProperties(ctx, packages_model.PropertyTypeFile, pf.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
return packages_model.DeleteFileByID(ctx, pf.ID)
|
||||
}
|
||||
|
||||
// Cache data needed for all repository files
|
||||
cache := make(packageCache)
|
||||
for _, pf := range pfs {
|
||||
pv, err := packages_model.GetVersionByID(ctx, pf.VersionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p, err := packages_model.GetPackageByID(ctx, pv.PackageID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pb, err := packages_model.GetBlobByID(ctx, pf.BlobID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pps, err := packages_model.GetPropertiesByName(ctx, packages_model.PropertyTypeFile, pf.ID, alpine_module.PropertyMetadata)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pd := &packageData{
|
||||
Package: p,
|
||||
Version: pv,
|
||||
Blob: pb,
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(pv.MetadataJSON), &pd.VersionMetadata); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(pps) > 0 {
|
||||
if err := json.Unmarshal([]byte(pps[0].Value), &pd.FileMetadata); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
cache[pf] = pd
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
for _, pf := range pfs {
|
||||
pd := cache[pf]
|
||||
|
||||
fmt.Fprintf(&buf, "C:%s\n", pd.FileMetadata.Checksum)
|
||||
fmt.Fprintf(&buf, "P:%s\n", pd.Package.Name)
|
||||
fmt.Fprintf(&buf, "V:%s\n", pd.Version.Version)
|
||||
fmt.Fprintf(&buf, "A:%s\n", pd.FileMetadata.Architecture)
|
||||
if pd.VersionMetadata.Description != "" {
|
||||
fmt.Fprintf(&buf, "T:%s\n", pd.VersionMetadata.Description)
|
||||
}
|
||||
if pd.VersionMetadata.ProjectURL != "" {
|
||||
fmt.Fprintf(&buf, "U:%s\n", pd.VersionMetadata.ProjectURL)
|
||||
}
|
||||
if pd.VersionMetadata.License != "" {
|
||||
fmt.Fprintf(&buf, "L:%s\n", pd.VersionMetadata.License)
|
||||
}
|
||||
fmt.Fprintf(&buf, "S:%d\n", pd.Blob.Size)
|
||||
fmt.Fprintf(&buf, "I:%d\n", pd.FileMetadata.Size)
|
||||
fmt.Fprintf(&buf, "o:%s\n", pd.FileMetadata.Origin)
|
||||
fmt.Fprintf(&buf, "m:%s\n", pd.VersionMetadata.Maintainer)
|
||||
fmt.Fprintf(&buf, "t:%d\n", pd.FileMetadata.BuildDate)
|
||||
if pd.FileMetadata.CommitHash != "" {
|
||||
fmt.Fprintf(&buf, "c:%s\n", pd.FileMetadata.CommitHash)
|
||||
}
|
||||
if len(pd.FileMetadata.Dependencies) > 0 {
|
||||
fmt.Fprintf(&buf, "D:%s\n", strings.Join(pd.FileMetadata.Dependencies, " "))
|
||||
}
|
||||
if len(pd.FileMetadata.Provides) > 0 {
|
||||
fmt.Fprintf(&buf, "p:%s\n", strings.Join(pd.FileMetadata.Provides, " "))
|
||||
}
|
||||
fmt.Fprint(&buf, "\n")
|
||||
}
|
||||
|
||||
unsignedIndexContent, _ := packages_module.NewHashedBuffer()
|
||||
h := sha1.New()
|
||||
|
||||
if err := writeGzipStream(io.MultiWriter(unsignedIndexContent, h), "APKINDEX", buf.Bytes(), true); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
priv, _, err := GetOrCreateKeyPair(ownerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
privPem, _ := pem.Decode([]byte(priv))
|
||||
if privPem == nil {
|
||||
return fmt.Errorf("failed to decode private key pem")
|
||||
}
|
||||
|
||||
privKey, err := x509.ParsePKCS1PrivateKey(privPem.Bytes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sign, err := rsa.SignPKCS1v15(rand.Reader, privKey, crypto.SHA1, h.Sum(nil))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
owner, err := user_model.GetUserByID(ctx, ownerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fingerprint, err := util.CreatePublicKeyFingerprint(&privKey.PublicKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
signedIndexContent, _ := packages_module.NewHashedBuffer()
|
||||
|
||||
if err := writeGzipStream(
|
||||
signedIndexContent,
|
||||
fmt.Sprintf(".SIGN.RSA.%s@%s.rsa.pub", owner.LowerName, hex.EncodeToString(fingerprint)),
|
||||
sign,
|
||||
false,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := io.Copy(signedIndexContent, unsignedIndexContent); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = packages_service.AddFileToPackageVersionInternal(
|
||||
repoVersion,
|
||||
&packages_service.PackageFileCreationInfo{
|
||||
PackageFileInfo: packages_service.PackageFileInfo{
|
||||
Filename: IndexFilename,
|
||||
CompositeKey: fmt.Sprintf("%s|%s|%s", branch, repository, architecture),
|
||||
},
|
||||
Creator: user_model.NewGhostUser(),
|
||||
Data: signedIndexContent,
|
||||
IsLead: false,
|
||||
OverwriteExisting: true,
|
||||
},
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func writeGzipStream(w io.Writer, filename string, content []byte, addTarEnd bool) error {
|
||||
zw := gzip.NewWriter(w)
|
||||
defer zw.Close()
|
||||
|
||||
tw := tar.NewWriter(zw)
|
||||
if addTarEnd {
|
||||
defer tw.Close()
|
||||
}
|
||||
hdr := &tar.Header{
|
||||
Name: filename,
|
||||
Mode: 0o600,
|
||||
Size: int64(len(content)),
|
||||
}
|
||||
if err := tw.WriteHeader(hdr); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tw.Write(content); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
|
@ -351,6 +351,8 @@ func CheckSizeQuotaExceeded(ctx context.Context, doer, owner *user_model.User, p
|
|||
|
||||
var typeSpecificSize int64
|
||||
switch packageType {
|
||||
case packages_model.TypeAlpine:
|
||||
typeSpecificSize = setting.Packages.LimitSizeAlpine
|
||||
case packages_model.TypeCargo:
|
||||
typeSpecificSize = setting.Packages.LimitSizeCargo
|
||||
case packages_model.TypeChef:
|
||||
|
@ -486,6 +488,47 @@ func RemovePackageVersion(doer *user_model.User, pv *packages_model.PackageVersi
|
|||
return nil
|
||||
}
|
||||
|
||||
// RemovePackageFileAndVersionIfUnreferenced deletes the package file and the version if there are no referenced files afterwards
|
||||
func RemovePackageFileAndVersionIfUnreferenced(doer *user_model.User, pf *packages_model.PackageFile) error {
|
||||
var pd *packages_model.PackageDescriptor
|
||||
|
||||
if err := db.WithTx(db.DefaultContext, func(ctx context.Context) error {
|
||||
if err := DeletePackageFile(ctx, pf); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
has, err := packages_model.HasVersionFileReferences(ctx, pf.VersionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !has {
|
||||
pv, err := packages_model.GetVersionByID(ctx, pf.VersionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pd, err = packages_model.GetPackageDescriptor(ctx, pv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := DeletePackageVersionAndReferences(ctx, pv); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if pd != nil {
|
||||
notification.NotifyPackageDelete(db.DefaultContext, doer, pd)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeletePackageVersionAndReferences deletes the package version and its properties and files
|
||||
func DeletePackageVersionAndReferences(ctx context.Context, pv *packages_model.PackageVersion) error {
|
||||
if err := packages_model.DeleteAllProperties(ctx, packages_model.PropertyTypeVersion, pv.ID); err != nil {
|
||||
|
|
52
templates/package/content/alpine.tmpl
Normal file
52
templates/package/content/alpine.tmpl
Normal file
|
@ -0,0 +1,52 @@
|
|||
{{if eq .PackageDescriptor.Package.Type "alpine"}}
|
||||
<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.alpine.registry" | Safe}}</label>
|
||||
<div class="markup"><pre class="code-block"><code><gitea-origin-url data-url="{{AppSubUrl}}/api/packages/{{$.PackageDescriptor.Owner.Name}}/alpine"></gitea-origin-url>/$branch/$repository</code></pre></div>
|
||||
<p>{{.locale.Tr "packages.alpine.registry.info" | Safe}}</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{svg "octicon-terminal"}} {{.locale.Tr "packages.alpine.registry.key" | Safe}}</label>
|
||||
<div class="markup"><pre class="code-block"><code>curl -JO <gitea-origin-url data-url="{{AppSubUrl}}/api/packages/{{$.PackageDescriptor.Owner.Name}}/alpine/key"></gitea-origin-url></code></pre></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{svg "octicon-terminal"}} {{.locale.Tr "packages.alpine.install"}}</label>
|
||||
<div class="markup">
|
||||
<pre class="code-block"><code>sudo apk add {{$.PackageDescriptor.Package.Name}}={{$.PackageDescriptor.Version.Version}}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{.locale.Tr "packages.alpine.documentation" "https://docs.gitea.io/en-us/packages/alpine/" | Safe}}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 class="ui top attached header">{{.locale.Tr "packages.alpine.repository"}}</h4>
|
||||
<div class="ui attached segment">
|
||||
<table class="ui single line very basic table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="collapsing"><h5>{{.locale.Tr "packages.alpine.repository.branches"}}</h5></td>
|
||||
<td>{{StringUtils.Join .Branches ", "}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="collapsing"><h5>{{.locale.Tr "packages.alpine.repository.repositories"}}</h5></td>
|
||||
<td>{{StringUtils.Join .Repositories ", "}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="collapsing"><h5>{{.locale.Tr "packages.alpine.repository.architectures"}}</h5></td>
|
||||
<td>{{StringUtils.Join .Architectures ", "}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{{if .PackageDescriptor.Metadata.Description}}
|
||||
<h4 class="ui top attached header">{{.locale.Tr "packages.about"}}</h4>
|
||||
<div class="ui attached segment">
|
||||
{{.PackageDescriptor.Metadata.Description}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
5
templates/package/metadata/alpine.tmpl
Normal file
5
templates/package/metadata/alpine.tmpl
Normal file
|
@ -0,0 +1,5 @@
|
|||
{{if eq .PackageDescriptor.Package.Type "alpine"}}
|
||||
{{if .PackageDescriptor.Metadata.Maintainer}}<div class="item" title="{{.locale.Tr "packages.details.author"}}">{{svg "octicon-person" 16 "mr-3"}} {{.PackageDescriptor.Metadata.Maintainer}}</div>{{end}}
|
||||
{{if .PackageDescriptor.Metadata.ProjectURL}}<div class="item">{{svg "octicon-link-external" 16 "mr-3"}} <a href="{{.PackageDescriptor.Metadata.ProjectURL}}" target="_blank" rel="noopener noreferrer me">{{.locale.Tr "packages.details.project_site"}}</a></div>{{end}}
|
||||
{{if .PackageDescriptor.Metadata.License}}<div class="item" title="{{$.locale.Tr "packages.details.license"}}">{{svg "octicon-law" 16 "gt-mr-3"}} {{.PackageDescriptor.Metadata.License}}</div>{{end}}
|
||||
{{end}}
|
|
@ -19,6 +19,7 @@
|
|||
<div class="ui divider"></div>
|
||||
</div>
|
||||
<div class="twelve wide column">
|
||||
{{template "package/content/alpine" .}}
|
||||
{{template "package/content/cargo" .}}
|
||||
{{template "package/content/chef" .}}
|
||||
{{template "package/content/composer" .}}
|
||||
|
@ -48,6 +49,7 @@
|
|||
{{end}}
|
||||
<div class="item">{{svg "octicon-calendar" 16 "gt-mr-3"}} {{TimeSinceUnix .PackageDescriptor.Version.CreatedUnix $.locale}}</div>
|
||||
<div class="item">{{svg "octicon-download" 16 "gt-mr-3"}} {{.PackageDescriptor.Version.DownloadCount}}</div>
|
||||
{{template "package/metadata/alpine" .}}
|
||||
{{template "package/metadata/cargo" .}}
|
||||
{{template "package/metadata/chef" .}}
|
||||
{{template "package/metadata/composer" .}}
|
||||
|
|
1
templates/swagger/v1_json.tmpl
generated
1
templates/swagger/v1_json.tmpl
generated
|
@ -2409,6 +2409,7 @@
|
|||
},
|
||||
{
|
||||
"enum": [
|
||||
"alpine",
|
||||
"cargo",
|
||||
"chef",
|
||||
"composer",
|
||||
|
|
229
tests/integration/api_packages_alpine_test.go
Normal file
229
tests/integration/api_packages_alpine_test.go
Normal file
|
@ -0,0 +1,229 @@
|
|||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bufio"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"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"
|
||||
alpine_module "code.gitea.io/gitea/modules/packages/alpine"
|
||||
"code.gitea.io/gitea/tests"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestPackageAlpine(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
|
||||
packageName := "gitea-test"
|
||||
packageVersion := "1.4.1-r3"
|
||||
|
||||
base64AlpinePackageContent := `H4sIAAAAAAACA9ML9nT30wsKdtTLzjNJzjYuckjPLElN1DUzMUxMNTa11CsqTtQrKE1ioAAYAIGZ
|
||||
iQmYBgJ02hDENjQxMTAzMzQ1MTVjMDA0MTQ1ZlAwYKADKC0uSSxSUGAYoWDm4sZZtypv75+q2fVT
|
||||
POD1bKkFB22ms+g1z+H4dk7AhC3HwUSj9EbT0Rk3Dn55dHxy/K7Q+Nl/i+L7Z036ypcRvvpZuMiN
|
||||
s7wbZL/klqRGGshv9Gi0qHTgTZfw3HytnJdx9c3NTRp/PHn+Z50uq2pjkilzjtpfd+uzQMw1M7cY
|
||||
i9RXJasnT2M+vDXCesLK7MilJt8sGplj4xUlLMUun9SzY+phFpxWxRXa06AseV9WvzH3jtGGoL5A
|
||||
vQkea+VKPj5R+Cb461tIk97qpa9nJYsJujTNl2B/J1P52H/D2rPr/j19uU8p7cMSq5tmXk51ReXl
|
||||
F/Yddr9XsMpEwFKlXSPo3QSGwnCOG8y2uadjm6ui998WYXNYubjg78N3a7bnXjhrl5fB8voI++LI
|
||||
1FP5W44e2xf4Ou2wrtyic1Onz7MzMV5ksuno2V/LVG4eN/15X/n2/2vJ2VV+T68aT327dOrhd6e6
|
||||
q5Y0V82Y83tdqkFa8TW2BvGCZ0ds/iibHVpzKuPcuSULO63/bNmfrnhjWqXzhMSXTb5Cv4vPaxSL
|
||||
8LFMdqmxbN7+Y+Yi0ZyZhz4UxexLuHHFd1VFvk+kwvniq3P+f9rh52InWnL8Lpvedcecoh1GFSc5
|
||||
xZ9VBGex2V269HZfwxSVCvP35wQfi2xKX+lYMXtF48n1R65O2PLWpm69RdESMa79dlrTGazsZacu
|
||||
MbMLeSSScPORZde76/MBV6SFJAAEAAAfiwgAAAAAAAID7VRLaxsxEN6zfoUgZ++OVq+1aUIhUDeY
|
||||
pKa49FhmJdkW3ofRysXpr69220t9SCk0gZJ+IGaY56eBmbxY4/m9Q+vCUOTr1fLu4d2H7O8CEpQQ
|
||||
k0y4lAClypgQoBSTQqoMGBMgMnrOXgCnIWJIVLLXCcaoib5110CSij/V7D9eCZ5p5f9o/5VkF/tf
|
||||
MqUzCi+5/6Hv41Nxv/Nffu4fwRVdus4FjM7S+pFiffKNpTxnkMMsALmin5PnHgMtS8rkgvGFBPpp
|
||||
c0tLKDk5HnYdto5e052PDmfRDXE0fnUh2VgucjYLU5h1g0mm5RhGNymMrtEccOfIKTTJsY/xOCyK
|
||||
YqqT+74gExWbmI2VlJ6LeQUcyPFH2lh/9SBuV/wjfXPohDnw8HZKviGD/zYmCZgrgsHsk36u1Bcl
|
||||
SB/8zne/0jV92/qYbKRF38X0niiemN2QxhvXDWOL+7tNGhGeYt+m22mwaR6pddGZNM8FSeRxj8PY
|
||||
X7PaqdqAVlqWXHKnmQGmK43VlqNlILRilbBSMI2jV5Vbu5XGSVsDyGc7yd8B/gK2qgAIAAAfiwgA
|
||||
AAAAAAID7dNNSgMxGAbg7MSCOxcu5wJOv0x+OlkU7K5QoYXqVsxMMihlKMwP1Fu48QQewCN4DfEQ
|
||||
egUz4sYuFKEtFN9n870hWSSQN+7P7GrsrfNV3Y9dW5Z3bNMo0FJ+zmB9EhcJ41KS1lxJpRnxbsWi
|
||||
FduBtm5sFa7C/ifOo7y5Lf2QeiHar6jTaDSbnF5Mp+fzOL/x+aJuy3g+HvGhs8JY4b3yOpMZOZEo
|
||||
lRW+MEoTTw3ZwqU0INNjsAe2VPk/9b/L3/s/kIKzqOtk+IbJGTtmr+bx7WoxOUoun98frk/un14O
|
||||
Djfa/2q5bH4699v++uMAAAAAAAAAAAAAAAAAAAAAAHbgA/eXQh8AKAAA`
|
||||
content, err := base64.StdEncoding.DecodeString(base64AlpinePackageContent)
|
||||
assert.NoError(t, err)
|
||||
|
||||
branches := []string{"v3.16", "v3.17"}
|
||||
repositories := []string{"main", "testing"}
|
||||
|
||||
rootURL := fmt.Sprintf("/api/packages/%s/alpine", user.Name)
|
||||
|
||||
t.Run("RepositoryKey", func(t *testing.T) {
|
||||
defer tests.PrintCurrentTest(t)()
|
||||
|
||||
req := NewRequest(t, "GET", rootURL+"/key")
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
assert.Equal(t, "application/x-pem-file", resp.Header().Get("Content-Type"))
|
||||
assert.Contains(t, resp.Body.String(), "-----BEGIN PUBLIC KEY-----")
|
||||
})
|
||||
|
||||
for _, branch := range branches {
|
||||
for _, repository := range repositories {
|
||||
t.Run(fmt.Sprintf("[Branch:%s,Repository:%s]", branch, repository), func(t *testing.T) {
|
||||
t.Run("Upload", func(t *testing.T) {
|
||||
defer tests.PrintCurrentTest(t)()
|
||||
|
||||
uploadURL := fmt.Sprintf("%s/%s/%s", rootURL, branch, repository)
|
||||
|
||||
req := NewRequestWithBody(t, "PUT", uploadURL, bytes.NewReader([]byte{}))
|
||||
MakeRequest(t, req, http.StatusUnauthorized)
|
||||
|
||||
req = NewRequestWithBody(t, "PUT", uploadURL, bytes.NewReader([]byte{}))
|
||||
AddBasicAuthHeader(req, user.Name)
|
||||
MakeRequest(t, req, http.StatusBadRequest)
|
||||
|
||||
req = NewRequestWithBody(t, "PUT", uploadURL, bytes.NewReader(content))
|
||||
AddBasicAuthHeader(req, user.Name)
|
||||
MakeRequest(t, req, http.StatusCreated)
|
||||
|
||||
pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeAlpine)
|
||||
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, &alpine_module.VersionMetadata{}, 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.NotEmpty(t, pfs)
|
||||
assert.Condition(t, func() bool {
|
||||
seen := false
|
||||
expectedFilename := fmt.Sprintf("%s-%s.apk", packageName, packageVersion)
|
||||
expectedCompositeKey := fmt.Sprintf("%s|%s|x86_64", branch, repository)
|
||||
for _, pf := range pfs {
|
||||
if pf.Name == expectedFilename && pf.CompositeKey == expectedCompositeKey {
|
||||
if seen {
|
||||
return false
|
||||
}
|
||||
seen = true
|
||||
|
||||
assert.True(t, pf.IsLead)
|
||||
|
||||
pfps, err := packages.GetProperties(db.DefaultContext, packages.PropertyTypeFile, pf.ID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
for _, pfp := range pfps {
|
||||
switch pfp.Name {
|
||||
case alpine_module.PropertyBranch:
|
||||
assert.Equal(t, branch, pfp.Value)
|
||||
case alpine_module.PropertyRepository:
|
||||
assert.Equal(t, repository, pfp.Value)
|
||||
case alpine_module.PropertyArchitecture:
|
||||
assert.Equal(t, "x86_64", pfp.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return seen
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("Index", func(t *testing.T) {
|
||||
defer tests.PrintCurrentTest(t)()
|
||||
|
||||
url := fmt.Sprintf("%s/%s/%s/x86_64/APKINDEX.tar.gz", rootURL, branch, repository)
|
||||
|
||||
req := NewRequest(t, "GET", url)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
assert.Condition(t, func() bool {
|
||||
br := bufio.NewReader(resp.Body)
|
||||
|
||||
gzr, err := gzip.NewReader(br)
|
||||
assert.NoError(t, err)
|
||||
|
||||
for {
|
||||
gzr.Multistream(false)
|
||||
|
||||
tr := tar.NewReader(gzr)
|
||||
for {
|
||||
hd, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
assert.NoError(t, err)
|
||||
|
||||
if hd.Name == "APKINDEX" {
|
||||
buf, err := io.ReadAll(tr)
|
||||
assert.NoError(t, err)
|
||||
|
||||
s := string(buf)
|
||||
|
||||
assert.Contains(t, s, "C:Q1/se1PjO94hYXbfpNR1/61hVORIc=\n")
|
||||
assert.Contains(t, s, "P:"+packageName+"\n")
|
||||
assert.Contains(t, s, "V:"+packageVersion+"\n")
|
||||
assert.Contains(t, s, "A:x86_64\n")
|
||||
assert.Contains(t, s, "T:Gitea Test Package\n")
|
||||
assert.Contains(t, s, "U:https://gitea.io/\n")
|
||||
assert.Contains(t, s, "L:MIT\n")
|
||||
assert.Contains(t, s, "S:1353\n")
|
||||
assert.Contains(t, s, "I:4096\n")
|
||||
assert.Contains(t, s, "o:gitea-test\n")
|
||||
assert.Contains(t, s, "m:KN4CK3R <kn4ck3r@gitea.io>\n")
|
||||
assert.Contains(t, s, "t:1679498030\n")
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
err = gzr.Reset(br)
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("Download", func(t *testing.T) {
|
||||
defer tests.PrintCurrentTest(t)()
|
||||
|
||||
req := NewRequest(t, "GET", fmt.Sprintf("%s/%s/%s/x86_64/%s-%s.apk", rootURL, branch, repository, packageName, packageVersion))
|
||||
MakeRequest(t, req, http.StatusOK)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("Delete", func(t *testing.T) {
|
||||
defer tests.PrintCurrentTest(t)()
|
||||
|
||||
for _, branch := range branches {
|
||||
for _, repository := range repositories {
|
||||
req := NewRequest(t, "DELETE", fmt.Sprintf("%s/%s/%s/x86_64/%s-%s.apk", rootURL, branch, repository, packageName, packageVersion))
|
||||
MakeRequest(t, req, http.StatusUnauthorized)
|
||||
|
||||
req = NewRequest(t, "DELETE", fmt.Sprintf("%s/%s/%s/x86_64/%s-%s.apk", rootURL, branch, repository, packageName, packageVersion))
|
||||
AddBasicAuthHeader(req, user.Name)
|
||||
MakeRequest(t, req, http.StatusNoContent)
|
||||
|
||||
// Deleting the last file of an architecture should remove that index
|
||||
req = NewRequest(t, "GET", fmt.Sprintf("%s/%s/%s/x86_64/APKINDEX.tar.gz", rootURL, branch, repository))
|
||||
MakeRequest(t, req, http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
2
web_src/svg/gitea-alpine.svg
Normal file
2
web_src/svg/gitea-alpine.svg
Normal file
|
@ -0,0 +1,2 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg version="1.1" viewBox="0 0 186 162" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><g transform="matrix(1.25 0 0 -1.25 -268 592)" fill="#0d597f"><g transform="translate(268 393)"><path d="m0 0v15.7l-11.3-11.3c1.22-0.847 2.36-1.54 3.44-2.11 1.08-0.567 2.09-1 3.03-1.34 0.941-0.334 1.81-0.562 2.62-0.71 0.804-0.147 1.54-0.213 2.21-0.222m57.8 1.3c0.02-0.017 0.13-0.11 0.333-0.239 0.204-0.13 0.502-0.297 0.898-0.462 0.395-0.164 0.889-0.327 1.48-0.448 0.596-0.122 1.29-0.202 2.1-0.202 0.671 0 1.41 0.059 2.22 0.2 0.812 0.142 1.69 0.367 2.64 0.699 0.953 0.333 1.98 0.773 3.07 1.34 1.09 0.572 2.26 1.28 3.5 2.14l-8.57 8.4-28.4 28.5-12.5-12.5-16.8 17.3-42.2-41.7c1.24-0.86 2.4-1.56 3.49-2.14 1.09-0.571 2.12-1.01 3.07-1.34 0.951-0.332 1.83-0.557 2.64-0.698 0.81-0.142 1.55-0.201 2.22-0.201 0.804 0 1.5 0.08 2.1 0.202 0.596 0.121 1.09 0.284 1.48 0.449 0.396 0.164 0.693 0.331 0.897 0.461s0.314 0.223 0.334 0.24l19.1 19.1 6.81 6.53 18.9-18.9 6.59-6.78c0.02-0.017 0.13-0.11 0.334-0.239 0.204-0.13 0.501-0.297 0.897-0.462 0.396-0.164 0.89-0.327 1.48-0.448 0.596-0.122 1.3-0.202 2.1-0.202 0.67 0 1.41 0.059 2.22 0.2 0.811 0.142 1.69 0.367 2.64 0.699 0.952 0.333 1.98 0.773 3.07 1.34 1.09 0.572 2.26 1.28 3.5 2.14l-15.1 14.8 2.82 2.82 13.1-13.1 7.64-7.57m-0.163 79 37.2-64.5-37.2-64.5h-74.5l-37.2 64.5 37.2 64.5z" fill="#0d597f"/></g><g transform="translate(303 412)"><path d="m0 0-9.91 9.9 0.705 0.709 9.98-9.87z" fill="#0d597f"/></g></g></svg>
|
After Width: | Height: | Size: 1.4 KiB |
Loading…
Reference in a new issue