329 lines
12 KiB
Go
329 lines
12 KiB
Go
package gitea
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"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 &forge.PreflightFailure{Kind: forge.PreflightInvalidDestination, Err: 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 (b *Backend) CreateRelease(ctx context.Context, request forge.CreateReleaseRequest) (forge.Release, forge.WriteEvidence, error) {
|
|
if strings.TrimSpace(request.Tag) == "" || !validObjectID(request.Commit) {
|
|
return forge.Release{}, forge.WriteEvidence{Disposition: forge.WriteNotAttempted}, &forge.Failure{
|
|
Kind: forge.FailureInvalid, Err: errors.New("a full tag and lowercase Git commit ID are required"),
|
|
}
|
|
}
|
|
_, apiRoot, err := b.destinationAPI("create release")
|
|
if err != nil {
|
|
return forge.Release{}, forge.WriteEvidence{Disposition: forge.WriteNotAttempted}, err
|
|
}
|
|
body, err := json.Marshal(createReleaseRequest{
|
|
TagName: request.Tag, TargetCommitish: request.Commit,
|
|
Name: request.Title, Body: request.Body,
|
|
Draft: request.Draft, Prerelease: request.Prerelease,
|
|
})
|
|
if err != nil {
|
|
return forge.Release{}, forge.WriteEvidence{Disposition: forge.WriteNotAttempted}, &forge.Failure{Kind: forge.FailureInvalid, Err: err}
|
|
}
|
|
created, status, err := b.client.postRelease(ctx, b.client.httpClient(), b.destination.Token, apiRoot+"/releases", body)
|
|
if err == nil && created.ID <= 0 {
|
|
err = failure(FailureMalformed, "verify created release", status, errors.New("release response does not contain a positive ID"))
|
|
}
|
|
evidence := writeEvidence(status, err)
|
|
if err != nil {
|
|
return forge.Release{}, evidence, neutralFailure(err)
|
|
}
|
|
return forge.Release{
|
|
URL: created.URL,
|
|
Tag: created.TagName,
|
|
Title: created.Title,
|
|
Body: created.Body,
|
|
Draft: created.Draft,
|
|
Prerelease: created.Prerelease,
|
|
Handle: releaseHandle{id: created.ID},
|
|
}, evidence, nil
|
|
}
|
|
|
|
func (b *Backend) UploadAsset(ctx context.Context, handle any, filename string, content io.Reader) (forge.WriteEvidence, error) {
|
|
release, ok := handle.(releaseHandle)
|
|
if !ok || release.id <= 0 {
|
|
return forge.WriteEvidence{Disposition: forge.WriteNotAttempted}, &forge.Failure{Kind: forge.FailureInvalid, Err: errors.New("release handle was not created by the Gitea backend")}
|
|
}
|
|
if strings.TrimSpace(filename) == "" || content == nil {
|
|
return forge.WriteEvidence{Disposition: forge.WriteNotAttempted}, &forge.Failure{Kind: forge.FailureInvalid, Err: errors.New("asset filename and content reader are required")}
|
|
}
|
|
_, apiRoot, err := b.destinationAPI("upload release asset")
|
|
if err != nil {
|
|
return forge.WriteEvidence{Disposition: forge.WriteNotAttempted}, err
|
|
}
|
|
status, err := b.client.uploadAsset(ctx, b.client.httpClient(), b.destination.Token, apiRoot, release.id, filename, content)
|
|
evidence := writeEvidence(status, err)
|
|
if err != nil {
|
|
return evidence, neutralFailure(err)
|
|
}
|
|
return evidence, nil
|
|
}
|
|
|
|
func writeEvidence(status int, err error) forge.WriteEvidence {
|
|
evidence := forge.WriteEvidence{Attempted: status != 0, Disposition: forge.WriteNotAttempted}
|
|
var backendFailure *Failure
|
|
if err != nil && errors.As(err, &backendFailure) && backendFailure.Kind == FailureTransport {
|
|
evidence.Attempted = true
|
|
}
|
|
if !evidence.Attempted {
|
|
return evidence
|
|
}
|
|
if err == nil {
|
|
evidence.Disposition = forge.WriteAccepted
|
|
return evidence
|
|
}
|
|
if status == http.StatusConflict {
|
|
evidence.Disposition = forge.WriteConflict
|
|
return evidence
|
|
}
|
|
if status >= http.StatusInternalServerError || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
|
evidence.Disposition = forge.WriteUncertain
|
|
return evidence
|
|
}
|
|
if errors.As(err, &backendFailure) && (backendFailure.Kind == FailureTransport || backendFailure.Kind == FailureMalformed) {
|
|
evidence.Disposition = forge.WriteUncertain
|
|
return evidence
|
|
}
|
|
evidence.Disposition = forge.WriteRejected
|
|
return evidence
|
|
}
|
|
|
|
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}
|
|
}
|
|
|
|
var _ forge.Backend = (*Backend)(nil)
|