Files
releasekit/internal/gitea/backend.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}
}