240 lines
8.1 KiB
Go
240 lines
8.1 KiB
Go
package gitea
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"go.maximumdirect.net/releasekit/internal/forge"
|
|
)
|
|
|
|
// Backend adapts a configured Gitea destination to the hosted delivery
|
|
// operations. Its destination and client are private so callers cannot bypass
|
|
// Gitea's validation or replace opaque handles.
|
|
type Backend struct {
|
|
destination Destination
|
|
client Client
|
|
}
|
|
|
|
// NewBackend creates a Gitea backend with an optional HTTP client.
|
|
func NewBackend(destination Destination, client *http.Client) *Backend {
|
|
return &Backend{destination: destination, client: Client{HTTPClient: client}}
|
|
}
|
|
|
|
type releaseHandle struct {
|
|
id int64
|
|
}
|
|
|
|
type assetHandle struct {
|
|
id int64
|
|
downloadURL string
|
|
}
|
|
|
|
type repositoryResponse struct {
|
|
ID int64 `json:"id"`
|
|
Name string `json:"name"`
|
|
FullName string `json:"full_name"`
|
|
Owner struct {
|
|
Login string `json:"login"`
|
|
} `json:"owner"`
|
|
}
|
|
|
|
func (b *Backend) Preflight(ctx context.Context) error {
|
|
if strings.TrimSpace(b.destination.Token) == "" {
|
|
return &forge.PreflightFailure{Kind: forge.PreflightMissingCredential, Err: errors.New("Gitea token is required")}
|
|
}
|
|
base, apiRoot, err := b.destinationAPI("preflight repository access")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
client := b.client.httpClient()
|
|
var resolved repositoryResponse
|
|
endpoint := apiRoot
|
|
if err := b.client.getJSON(ctx, client, b.destination.Token, "resolve repository", endpoint, &resolved); err != nil {
|
|
return preflightFailure(err)
|
|
}
|
|
if !resolved.matches(b.destination) {
|
|
return &forge.PreflightFailure{Kind: forge.PreflightUnavailable, Err: errors.New("repository response does not identify the configured destination")}
|
|
}
|
|
|
|
var authenticated repositoryResponse
|
|
apiPrefix := strings.TrimRight(base.String(), "/") + "/api/v1"
|
|
endpoint = apiPrefix + "/repositories/" + strconv.FormatInt(resolved.ID, 10)
|
|
if err := b.client.getJSON(ctx, client, b.destination.Token, "verify authenticated repository access", endpoint, &authenticated); err != nil {
|
|
return preflightFailure(err)
|
|
}
|
|
if !authenticated.matches(b.destination) || authenticated.ID != resolved.ID {
|
|
return &forge.PreflightFailure{Kind: forge.PreflightUnavailable, Err: errors.New("authenticated repository response does not match the resolved destination")}
|
|
}
|
|
// Gitea's permissions.push describes Git code pushes, not release or asset
|
|
// writes. That metadata cannot establish a delivery-permission deficiency.
|
|
return nil
|
|
}
|
|
|
|
func (repository repositoryResponse) matches(destination Destination) bool {
|
|
if repository.ID <= 0 || !strings.EqualFold(repository.Name, destination.Repository) {
|
|
return false
|
|
}
|
|
if !strings.EqualFold(repository.FullName, destination.Owner+"/"+destination.Repository) {
|
|
return false
|
|
}
|
|
return strings.EqualFold(repository.Owner.Login, destination.Owner)
|
|
}
|
|
|
|
func preflightFailure(err error) error {
|
|
var backendFailure *Failure
|
|
if !errors.As(err, &backendFailure) {
|
|
return &forge.PreflightFailure{Kind: forge.PreflightUnavailable, Err: err}
|
|
}
|
|
kind := forge.PreflightUnavailable
|
|
switch backendFailure.Kind {
|
|
case FailureUnauthorized:
|
|
if backendFailure.StatusCode == http.StatusUnauthorized {
|
|
kind = forge.PreflightInvalidCredential
|
|
} else {
|
|
kind = forge.PreflightInaccessibleDestination
|
|
}
|
|
case FailureNotFound:
|
|
kind = forge.PreflightInaccessibleDestination
|
|
case FailureRejected:
|
|
if backendFailure.StatusCode >= http.StatusInternalServerError {
|
|
kind = forge.PreflightUnavailable
|
|
}
|
|
}
|
|
return &forge.PreflightFailure{Kind: kind, Err: neutralFailure(err)}
|
|
}
|
|
|
|
func (b *Backend) TagCommit(ctx context.Context, tag string) (string, error) {
|
|
if strings.TrimSpace(tag) == "" {
|
|
return "", &forge.Failure{Kind: forge.FailureInvalid, Err: errors.New("full tag is required")}
|
|
}
|
|
_, apiRoot, err := b.destinationAPI("get hosted tag")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
var observed tagResponse
|
|
endpoint := apiRoot + "/tags/" + url.PathEscape(tag)
|
|
if err := b.client.getJSON(ctx, b.client.httpClient(), b.destination.Token, "get hosted tag", endpoint, &observed); err != nil {
|
|
return "", neutralFailure(err)
|
|
}
|
|
if observed.Commit.SHA == "" {
|
|
return "", &forge.Failure{Kind: forge.FailureMalformed, Err: errors.New("tag response does not identify a commit")}
|
|
}
|
|
return observed.Commit.SHA, nil
|
|
}
|
|
|
|
func (b *Backend) FindRelease(ctx context.Context, tag string) (forge.Release, error) {
|
|
if strings.TrimSpace(tag) == "" {
|
|
return forge.Release{}, &forge.Failure{Kind: forge.FailureInvalid, Err: errors.New("full tag is required")}
|
|
}
|
|
_, apiRoot, err := b.destinationAPI("find release")
|
|
if err != nil {
|
|
return forge.Release{}, err
|
|
}
|
|
endpoint := apiRoot + "/releases/tags/" + url.PathEscape(tag)
|
|
release, _, err := b.client.getRelease(ctx, b.client.httpClient(), b.destination.Token, endpoint)
|
|
if err != nil {
|
|
return forge.Release{}, neutralFailure(err)
|
|
}
|
|
return forge.Release{
|
|
URL: release.URL,
|
|
Tag: release.TagName,
|
|
Title: release.Title,
|
|
Body: release.Body,
|
|
Draft: release.Draft,
|
|
Prerelease: release.Prerelease,
|
|
Handle: releaseHandle{id: release.ID},
|
|
}, nil
|
|
}
|
|
|
|
func (b *Backend) ListAssets(ctx context.Context, handle any) ([]forge.Asset, error) {
|
|
release, ok := handle.(releaseHandle)
|
|
if !ok {
|
|
return nil, &forge.Failure{Kind: forge.FailureInvalid, Err: errors.New("release handle was not created by the Gitea backend")}
|
|
}
|
|
_, apiRoot, err := b.destinationAPI("list release assets")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
remote, err := b.client.listAssets(ctx, b.client.httpClient(), b.destination.Token, release.id, apiRoot)
|
|
if err != nil {
|
|
return nil, neutralFailure(err)
|
|
}
|
|
assets := make([]forge.Asset, 0, len(remote))
|
|
for _, item := range remote {
|
|
assets = append(assets, forge.Asset{
|
|
Filename: item.Filename,
|
|
Size: item.Size,
|
|
Handle: assetHandle{id: item.ID, downloadURL: item.DownloadURL},
|
|
})
|
|
}
|
|
return assets, nil
|
|
}
|
|
|
|
func (b *Backend) OpenAsset(ctx context.Context, handle any) (io.ReadCloser, error) {
|
|
asset, ok := handle.(assetHandle)
|
|
if !ok {
|
|
return nil, &forge.Failure{Kind: forge.FailureInvalid, Err: errors.New("asset handle was not created by the Gitea backend")}
|
|
}
|
|
base, _, err := b.destinationAPI("download release asset")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
stream, err := b.client.openAsset(ctx, b.client.httpClient(), base, b.destination.Token, asset.downloadURL)
|
|
if err != nil {
|
|
return nil, neutralFailure(err)
|
|
}
|
|
return stream, nil
|
|
}
|
|
|
|
func (b *Backend) destinationAPI(operation string) (*url.URL, string, error) {
|
|
base, err := parseBaseURL(b.destination.BaseURL)
|
|
if err != nil {
|
|
return nil, "", neutralFailure(failure(FailureInvalid, operation, 0, err))
|
|
}
|
|
if strings.TrimSpace(b.destination.Token) == "" {
|
|
return nil, "", neutralFailure(failure(FailureInvalid, operation, 0, errors.New("Gitea token is required")))
|
|
}
|
|
if strings.TrimSpace(b.destination.Owner) == "" || strings.ContainsAny(b.destination.Owner, "/\\") {
|
|
return nil, "", neutralFailure(failure(FailureInvalid, operation, 0, errors.New("Gitea owner must be one path segment")))
|
|
}
|
|
if strings.TrimSpace(b.destination.Repository) == "" || strings.ContainsAny(b.destination.Repository, "/\\") {
|
|
return nil, "", neutralFailure(failure(FailureInvalid, operation, 0, errors.New("Gitea repository must be one path segment")))
|
|
}
|
|
apiRoot := strings.TrimRight(base.String(), "/") + "/api/v1/repos/" + url.PathEscape(b.destination.Owner) + "/" + url.PathEscape(b.destination.Repository)
|
|
return base, apiRoot, nil
|
|
}
|
|
|
|
func neutralFailure(err error) error {
|
|
var giteaFailure *Failure
|
|
if !errors.As(err, &giteaFailure) {
|
|
return err
|
|
}
|
|
kind := forge.FailureUnknown
|
|
switch giteaFailure.Kind {
|
|
case FailureInvalid:
|
|
kind = forge.FailureInvalid
|
|
case FailureNotFound:
|
|
kind = forge.FailureNotFound
|
|
case FailureUnauthorized:
|
|
kind = forge.FailureUnauthorized
|
|
case FailureConflict:
|
|
kind = forge.FailureConflict
|
|
case FailureMalformed:
|
|
kind = forge.FailureMalformed
|
|
case FailureUnverifiable:
|
|
kind = forge.FailureUnverifiable
|
|
case FailureTransport:
|
|
kind = forge.FailureTransport
|
|
case FailureRejected:
|
|
kind = forge.FailureRejected
|
|
case FailureUnknown:
|
|
kind = forge.FailureUnknown
|
|
}
|
|
return &forge.Failure{Kind: kind, Err: err}
|
|
}
|