aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--custom/conf/app.example.ini2
-rw-r--r--docs/content/doc/administration/config-cheat-sheet.en-us.md1
-rw-r--r--docs/content/doc/usage/packages/alpine.en-us.md133
-rw-r--r--docs/content/doc/usage/packages/overview.en-us.md1
-rw-r--r--docs/content/doc/usage/packages/storage.en-us.md2
-rw-r--r--models/packages/alpine/search.go53
-rw-r--r--models/packages/debian/search.go62
-rw-r--r--models/packages/descriptor.go3
-rw-r--r--models/packages/package.go6
-rw-r--r--models/packages/package_property.go38
-rw-r--r--modules/packages/alpine/metadata.go236
-rw-r--r--modules/packages/alpine/metadata_test.go143
-rw-r--r--modules/setting/packages.go2
-rw-r--r--modules/util/keypair.go16
-rw-r--r--options/locale/locale_en-US.ini9
-rw-r--r--public/img/svg/gitea-alpine.svg1
-rw-r--r--routers/api/packages/alpine/alpine.go253
-rw-r--r--routers/api/packages/api.go14
-rw-r--r--routers/api/v1/packages/package.go2
-rw-r--r--routers/web/user/package.go22
-rw-r--r--services/auth/source/oauth2/jwtsigningkey.go20
-rw-r--r--services/forms/package_form.go2
-rw-r--r--services/packages/alpine/repository.go328
-rw-r--r--services/packages/packages.go43
-rw-r--r--templates/package/content/alpine.tmpl52
-rw-r--r--templates/package/metadata/alpine.tmpl5
-rw-r--r--templates/package/view.tmpl2
-rw-r--r--templates/swagger/v1_json.tmpl1
-rw-r--r--tests/integration/api_packages_alpine_test.go229
-rw-r--r--web_src/svg/gitea-alpine.svg2
30 files changed, 1631 insertions, 52 deletions
diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini
index e279b67ded..6e89c42c64 100644
--- a/custom/conf/app.example.ini
+++ b/custom/conf/app.example.ini
@@ -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`)
diff --git a/docs/content/doc/administration/config-cheat-sheet.en-us.md b/docs/content/doc/administration/config-cheat-sheet.en-us.md
index 470e299a18..845840eb2d 100644
--- a/docs/content/doc/administration/config-cheat-sheet.en-us.md
+++ b/docs/content/doc/administration/config-cheat-sheet.en-us.md
@@ -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`)
diff --git a/docs/content/doc/usage/packages/alpine.en-us.md b/docs/content/doc/usage/packages/alpine.en-us.md
new file mode 100644
index 0000000000..aeb86093f0
--- /dev/null
+++ b/docs/content/doc/usage/packages/alpine.en-us.md
@@ -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}
+```
diff --git a/docs/content/doc/usage/packages/overview.en-us.md b/docs/content/doc/usage/packages/overview.en-us.md
index 8a70a352eb..87164e35d8 100644
--- a/docs/content/doc/usage/packages/overview.en-us.md
+++ b/docs/content/doc/usage/packages/overview.en-us.md
@@ -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` |
diff --git a/docs/content/doc/usage/packages/storage.en-us.md b/docs/content/doc/usage/packages/storage.en-us.md
index 15481ba7a3..598a636f5e 100644
--- a/docs/content/doc/usage/packages/storage.en-us.md
+++ b/docs/content/doc/usage/packages/storage.en-us.md
@@ -9,7 +9,7 @@ menu:
sidebar:
parent: "packages"
name: "Storage"
- weight: 5
+ weight: 2
identifier: "storage"
---
diff --git a/models/packages/alpine/search.go b/models/packages/alpine/search.go
new file mode 100644
index 0000000000..77eccb90ed
--- /dev/null
+++ b/models/packages/alpine/search.go
@@ -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,
+ },
+ )
+}
diff --git a/models/packages/debian/search.go b/models/packages/debian/search.go
index 332a4f7040..c63a319304 100644
--- a/models/packages/debian/search.go
+++ b/models/packages/debian/search.go
@@ -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,
+ },
+ )
}
diff --git a/models/packages/descriptor.go b/models/packages/descriptor.go
index 1cac2eb022..a69f477115 100644
--- a/models/packages/descriptor.go
+++ b/models/packages/descriptor.go
@@ -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:
diff --git a/models/packages/package.go b/models/packages/package.go
index a817ab6ff1..17d4d79f30 100644
--- a/models/packages/package.go
+++ b/models/packages/package.go
@@ -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:
diff --git a/models/packages/package_property.go b/models/packages/package_property.go
index e03b12c9df..e0170016cf 100644
--- a/models/packages/package_property.go
+++ b/models/packages/package_property.go
@@ -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)
+}
diff --git a/modules/packages/alpine/metadata.go b/modules/packages/alpine/metadata.go
new file mode 100644
index 0000000000..c2d0caffa1
--- /dev/null
+++ b/modules/packages/alpine/metadata.go
@@ -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
+}
diff --git a/modules/packages/alpine/metadata_test.go b/modules/packages/alpine/metadata_test.go
new file mode 100644
index 0000000000..2a3c48ffb9
--- /dev/null
+++ b/modules/packages/alpine/metadata_test.go
@@ -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)
+ })
+}
diff --git a/modules/setting/packages.go b/modules/setting/packages.go
index 00d8b6122f..3719e2f644 100644
--- a/modules/setting/packages.go
+++ b/modules/setting/packages.go
@@ -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")
diff --git a/modules/util/keypair.go b/modules/util/keypair.go
index 5a3ce715a4..97f2d9ebca 100644
--- a/modules/util/keypair.go
+++ b/modules/util/keypair.go
@@ -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
+}
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index d7c392a624..18b8bffe4a 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -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>.
diff --git a/public/img/svg/gitea-alpine.svg b/public/img/svg/gitea-alpine.svg
new file mode 100644
index 0000000000..1c878013ac
--- /dev/null
+++ b/public/img/svg/gitea-alpine.svg
@@ -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> \ No newline at end of file
diff --git a/routers/api/packages/alpine/alpine.go b/routers/api/packages/alpine/alpine.go
new file mode 100644
index 0000000000..9a551a219b
--- /dev/null
+++ b/routers/api/packages/alpine/alpine.go
@@ -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)
+}
diff --git a/routers/api/packages/api.go b/routers/api/packages/api.go
index 9b24918f51..355387332e 100644
--- a/routers/api/packages/api.go
+++ b/routers/api/packages/api.go
@@ -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)
diff --git a/routers/api/v1/packages/package.go b/routers/api/v1/packages/package.go
index e0811f8665..d7277247fc 100644
--- a/routers/api/v1/packages/package.go
+++ b/routers/api/v1/packages/package.go
@@ -40,7 +40,7 @@ func ListPackages(ctx *context.APIContext) {
// in: query
// description: package type filter
// type: string
- // enum: [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
diff --git a/routers/web/user/package.go b/routers/web/user/package.go
index 37ee0b8631..81a26da827 100644
--- a/routers/web/user/package.go
+++ b/routers/web/user/package.go
@@ -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])
diff --git a/services/auth/source/oauth2/jwtsigningkey.go b/services/auth/source/oauth2/jwtsigningkey.go
index ed60952ac7..ed0fc67ca0 100644
--- a/services/auth/source/oauth2/jwtsigningkey.go
+++ b/services/auth/source/oauth2/jwtsigningkey.go
@@ -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
diff --git a/services/forms/package_form.go b/services/forms/package_form.go
index 3029697107..96209ec840 100644
--- a/services/forms/package_form.go
+++ b/services/forms/package_form.go
@@ -15,7 +15,7 @@ import (
type PackageCleanupRuleForm struct {
ID int64
Enabled bool
- Type string `binding:"Required;In(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)"`
diff --git a/services/packages/alpine/repository.go b/services/packages/alpine/repository.go
new file mode 100644
index 0000000000..5264bd6c4a
--- /dev/null
+++ b/services/packages/alpine/repository.go
@@ -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
+}
diff --git a/services/packages/packages.go b/services/packages/packages.go
index 535f2fac8e..bf64890f4e 100644
--- a/services/packages/packages.go
+++ b/services/packages/packages.go
@@ -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 {
diff --git a/templates/package/content/alpine.tmpl b/templates/package/content/alpine.tmpl
new file mode 100644
index 0000000000..97e2289ad8
--- /dev/null
+++ b/templates/package/content/alpine.tmpl
@@ -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}}
diff --git a/templates/package/metadata/alpine.tmpl b/templates/package/metadata/alpine.tmpl
new file mode 100644
index 0000000000..9011bfce10
--- /dev/null
+++ b/templates/package/metadata/alpine.tmpl
@@ -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}}
diff --git a/templates/package/view.tmpl b/templates/package/view.tmpl
index 36637772cd..3c42c3adfd 100644
--- a/templates/package/view.tmpl
+++ b/templates/package/view.tmpl
@@ -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" .}}
diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl
index 35cbc71c8b..01055cb00d 100644
--- a/templates/swagger/v1_json.tmpl
+++ b/templates/swagger/v1_json.tmpl
@@ -2409,6 +2409,7 @@
},
{
"enum": [
+ "alpine",
"cargo",
"chef",
"composer",
diff --git a/tests/integration/api_packages_alpine_test.go b/tests/integration/api_packages_alpine_test.go
new file mode 100644
index 0000000000..473dcc0424
--- /dev/null
+++ b/tests/integration/api_packages_alpine_test.go
@@ -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)
+ }
+ }
+ })
+}
diff --git a/web_src/svg/gitea-alpine.svg b/web_src/svg/gitea-alpine.svg
new file mode 100644
index 0000000000..a297d95ec5
--- /dev/null
+++ b/web_src/svg/gitea-alpine.svg
@@ -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> \ No newline at end of file