Files
Anitrack/updater/gitea/gitea.go
T
John O'Keefe e9b04c0a84 feat(updater): custom Gitea releases provider with fail-closed verification
Wails ships no Gitea provider, so add updater/gitea (~250 lines): latest non-draft release newer than current wins, pre-releases skipped unless AllowPrerelease, exact AniTrack-linux-amd64 asset match, .sha512/.sig sidecars parsed into updater.Verification (sha512+ed25519ph, mirroring wails3 updater sign). Anything missing or malformed is an error, never an unsigned install. Includes semver compare, streaming download with progress, and unit tests (picking rules, fail-closed sidecars, server errors, progress) plus an opt-in live probe (ANITRACK_LIVE_TEST=1) proven against the real instance. Also: embed updater.pub (public half; private key lives in password manager + UPDATER_SIGNING_KEY CI secret, updater.key gitignored) and ignore CI-side updater-dist/ staging.
2026-09-16 08:51:59 -04:00

330 lines
9.3 KiB
Go

// Package gitea implements updater.Provider for Gitea releases.
//
// Releases live on a self-hosted Gitea instance, for which Wails ships no
// in-tree provider, so this mirrors the in-tree github provider against
// Gitea's releases API. Everything else (verification, staging, swap,
// restart, window) stays the framework's job.
//
// Asset layout per release (see .gitea/workflows/release.yml):
//
// AniTrack-linux-amd64 bare updater binary, exact name match
// AniTrack-linux-amd64.sha512 "<hex> <filename>" (sha512sum format)
// AniTrack-linux-amd64.sig base64 ed25519ph signature from
// `wails3 updater sign`
//
// A release missing any of the three is skipped: verification fails closed.
package gitea
import (
"context"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"github.com/wailsapp/wails/v3/pkg/updater"
)
// Config configures the Gitea provider.
type Config struct {
// BaseURL is the instance root, e.g. https://git.linuxhg.com.
BaseURL string
// Owner and Repo identify the repository, e.g. john-okeefe/Anitrack.
Owner string
Repo string
// AssetName is the exact updater artifact name, e.g. AniTrack-linux-amd64.
AssetName string
// HTTPClient overrides the default client (30s timeout). Optional.
HTTPClient *http.Client
// AllowPrerelease includes pre-release-tagged releases. Default false:
// -rc tags stay invisible (stable channel). Beta/test flows opt in.
AllowPrerelease bool
}
// Provider is an updater.Provider backed by Gitea releases.
type Provider struct {
cfg Config
client *http.Client
}
// New validates config and returns a Provider.
func New(cfg Config) (*Provider, error) {
cfg.BaseURL = strings.TrimSuffix(cfg.BaseURL, "/")
if cfg.BaseURL == "" || cfg.Owner == "" || cfg.Repo == "" || cfg.AssetName == "" {
return nil, fmt.Errorf("gitea: BaseURL, Owner, Repo and AssetName are all required")
}
client := cfg.HTTPClient
if client == nil {
client = &http.Client{Timeout: 30 * time.Second}
}
return &Provider{cfg: cfg, client: client}, nil
}
// Name identifies the provider in logs and event payloads.
func (p *Provider) Name() string { return "gitea" }
// giteaRelease mirrors the fields we read from the Gitea releases API.
type giteaRelease struct {
TagName string `json:"tag_name"`
Name string `json:"name"`
Body string `json:"body"`
Draft bool `json:"draft"`
Prerelease bool `json:"prerelease"`
PublishedAt string `json:"published_at"`
Assets []giteaAsset `json:"assets"`
}
// giteaAsset mirrors one release attachment.
type giteaAsset struct {
Name string `json:"name"`
Size int64 `json:"size"`
BrowserDownloadURL string `json:"browser_download_url"`
}
// Check finds the newest usable release newer than req.CurrentVersion.
// (nil, nil) means up-to-date.
func (p *Provider) Check(ctx context.Context, req updater.CheckRequest) (*updater.Release, error) {
releases, err := p.listReleases(ctx)
if err != nil {
return nil, err
}
var best *giteaRelease
var bestAsset *giteaAsset
for i := range releases {
r := &releases[i]
if r.Draft {
continue
}
if r.Prerelease && !p.cfg.AllowPrerelease {
continue
}
if compareSemver(r.TagName, req.CurrentVersion) <= 0 {
continue
}
asset := findAsset(r.Assets, p.cfg.AssetName)
if asset == nil {
continue
}
if best == nil || compareSemver(r.TagName, best.TagName) > 0 {
best = r
bestAsset = asset
}
}
if best == nil {
return nil, nil
}
verification, err := p.fetchVerification(ctx, *bestAsset)
if err != nil {
return nil, err
}
rel := &updater.Release{
Version: strings.TrimPrefix(strings.TrimPrefix(best.TagName, "v"), "V"),
Channel: "stable",
Name: best.Name,
Notes: best.Body,
Artifact: updater.Artifact{
Filename: bestAsset.Name,
Size: bestAsset.Size,
Platform: req.Platform,
Arch: req.Arch,
},
Verification: verification,
Metadata: map[string]any{
"downloadURL": bestAsset.BrowserDownloadURL,
},
}
if rel.Channel == "stable" && (best.Prerelease) {
rel.Channel = "beta"
}
if t, err := time.Parse(time.RFC3339, best.PublishedAt); err == nil {
rel.PublishedAt = t
}
return rel, nil
}
// Download streams the release artifact to dst, reporting progress.
func (p *Provider) Download(ctx context.Context, r *updater.Release, dst io.Writer, onProgress func(written, total int64)) error {
url, _ := r.Metadata["downloadURL"].(string)
if url == "" {
return fmt.Errorf("gitea: release %s has no download URL", r.Version)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
req.Header.Set("User-Agent", "AniTrack-updater")
resp, err := p.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("gitea: download %s: %s", url, resp.Status)
}
total := r.Artifact.Size
if total <= 0 {
total = resp.ContentLength
}
var written int64
buf := make([]byte, 128*1024)
for {
n, rerr := resp.Body.Read(buf)
if n > 0 {
if _, werr := dst.Write(buf[:n]); werr != nil {
return werr
}
written += int64(n)
onProgress(written, total)
}
if rerr == io.EOF {
onProgress(written, total)
return nil
}
if rerr != nil {
return rerr
}
}
}
// listReleases fetches the releases index (newest first page is enough).
func (p *Provider) listReleases(ctx context.Context) ([]giteaRelease, error) {
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/releases?limit=50", p.cfg.BaseURL, p.cfg.Owner, p.cfg.Repo)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "AniTrack-updater")
resp, err := p.client.Do(req)
if err != nil {
return nil, fmt.Errorf("gitea: list releases: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("gitea: list releases: %s", resp.Status)
}
var releases []giteaRelease
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
return nil, fmt.Errorf("gitea: decode releases: %w", err)
}
return releases, nil
}
// findAsset returns the attachment with exactly assetName, or nil.
func findAsset(assets []giteaAsset, assetName string) *giteaAsset {
for i := range assets {
if assets[i].Name == assetName && assets[i].BrowserDownloadURL != "" {
return &assets[i]
}
}
return nil
}
// fetchVerification downloads the .sha512 + .sig sidecars next to the asset.
// This mirrors `wails3 updater sign` (SHA-512 digest, ed25519ph signature).
// Anything missing or malformed is an error: verification fails closed.
func (p *Provider) fetchVerification(ctx context.Context, asset giteaAsset) (*updater.Verification, error) {
base := strings.TrimSuffix(asset.BrowserDownloadURL, asset.Name)
digestHex, err := p.fetchText(ctx, base+asset.Name+".sha512")
if err != nil {
return nil, fmt.Errorf("gitea: digest sidecar: %w", err)
}
sigB64, err := p.fetchText(ctx, base+asset.Name+".sig")
if err != nil {
return nil, fmt.Errorf("gitea: signature sidecar: %w", err)
}
// "<hex> <filename>" (sha512sum format); tolerate bare hex too.
digestHex = strings.Fields(digestHex)[0]
digest, err := hex.DecodeString(digestHex)
if err != nil || len(digest) != 64 {
return nil, fmt.Errorf("gitea: malformed digest sidecar for %s", asset.Name)
}
sig, err := base64.StdEncoding.DecodeString(strings.TrimSpace(sigB64))
if err != nil || len(sig) == 0 {
return nil, fmt.Errorf("gitea: malformed signature sidecar for %s", asset.Name)
}
return &updater.Verification{
DigestAlgo: "sha512",
Digest: digest,
SignatureAlgo: "ed25519ph",
Signature: sig,
}, nil
}
// fetchText GETs url and returns the trimmed body.
func (p *Provider) fetchText(ctx context.Context, url string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", err
}
req.Header.Set("User-Agent", "AniTrack-updater")
resp, err := p.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("%s: %s", url, resp.Status)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 8*1024))
if err != nil {
return "", err
}
return strings.TrimSpace(string(body)), nil
}
// compareSemver compares plain versions ("1.99.0", optional v prefix).
// Pre-release suffixes (-rc1) sort below the plain release. Returns
// -1, 0 or +1. Unparseable input compares as 0.0.0 (never newest).
func compareSemver(a, b string) int {
an, ap := splitSemver(a)
bn, bp := splitSemver(b)
for i := 0; i < 3; i++ {
if an[i] != bn[i] {
if an[i] < bn[i] {
return -1
}
return 1
}
}
switch {
case ap == bp:
return 0
case ap == "":
return 1
case bp == "":
return -1
case ap < bp:
return -1
case ap > bp:
return 1
}
return 0
}
// splitSemver splits "v1.99.0-rc1" into ([1 99 0], "rc1").
func splitSemver(v string) ([3]int, string) {
var nums [3]int
v = strings.TrimPrefix(strings.TrimPrefix(strings.TrimSpace(v), "v"), "V")
core, pre, _ := strings.Cut(v, "-")
for i, part := range strings.Split(core, ".") {
if i >= 3 {
break
}
n, err := strconv.Atoi(part)
if err != nil {
return [3]int{}, pre
}
nums[i] = n
}
return nums, pre
}