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} }