fix(upgrade): fetch releases from TorrentClaw app, not GitHub

The org GitHub shadow-ban 404s releases/raw/API to anonymous clients, so the
self-updater (api.github.com/releases/latest + github.com/.../releases/download)
was broken: `unarr upgrade` could neither check nor download.

- fetchLatestVersion → GET {base}/version (plain text)
- releaseURL → {base}/releases/download/v{ver}/{file}
- base resolves from cfg.Auth.APIURL via upgrade.SetBaseURL (PersistentPreRun),
  so mirrors / onion / staging / UNARR_API_URL all route updates correctly
- tests updated to the new endpoints
This commit is contained in:
Deivid Soto 2026-05-21 14:46:10 +02:00
parent 7de8955c4f
commit 0537de0ec1
5 changed files with 71 additions and 52 deletions

View file

@ -9,6 +9,7 @@ import (
tc "github.com/torrentclaw/go-client" tc "github.com/torrentclaw/go-client"
"github.com/torrentclaw/unarr/internal/config" "github.com/torrentclaw/unarr/internal/config"
"github.com/torrentclaw/unarr/internal/sentry" "github.com/torrentclaw/unarr/internal/sentry"
"github.com/torrentclaw/unarr/internal/upgrade"
) )
var ( var (
@ -42,6 +43,10 @@ Source: https://github.com/torrentclaw/unarr`,
if noColor || os.Getenv("NO_COLOR") != "" { if noColor || os.Getenv("NO_COLOR") != "" {
color.NoColor = true color.NoColor = true
} }
// Self-updater fetches releases from the configured host (default
// torrentclaw.com), not GitHub — so mirrors / onion / staging /
// UNARR_API_URL all route updates correctly.
upgrade.SetBaseURL(loadConfig().Auth.APIURL)
}, },
SilenceUsage: true, SilenceUsage: true,
SilenceErrors: true, SilenceErrors: true,

View file

@ -6,7 +6,6 @@ import (
"context" "context"
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"encoding/json"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@ -182,36 +181,35 @@ func verifyChecksumWithOptions(ctx context.Context, version, archivePath string,
return nil return nil
} }
// fetchLatestVersion queries GitHub API for the latest release tag. // fetchLatestVersion queries the TorrentClaw release endpoint (/version) for the
// latest version string (e.g. "0.8.1"). No GitHub dependency.
func fetchLatestVersion(ctx context.Context) (string, error) { func fetchLatestVersion(ctx context.Context) (string, error) {
url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", githubRepo) url := updateBaseURL + "/version"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil { if err != nil {
return "", err return "", err
} }
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", "unarr-updater") req.Header.Set("User-Agent", "unarr-updater")
resp, err := httpClient.Do(req) resp, err := httpClient.Do(req)
if err != nil { if err != nil {
return "", fmt.Errorf("fetch latest release: %w", err) return "", fmt.Errorf("fetch latest version: %w", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("GitHub API: HTTP %d", resp.StatusCode) return "", fmt.Errorf("version endpoint: HTTP %d", resp.StatusCode)
} }
var release struct { body, err := io.ReadAll(io.LimitReader(resp.Body, 64))
TagName string `json:"tag_name"` if err != nil {
} return "", fmt.Errorf("read version: %w", err)
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return "", fmt.Errorf("decode response: %w", err)
} }
if release.TagName == "" { version := strings.TrimPrefix(strings.TrimSpace(string(body)), "v")
return "", fmt.Errorf("empty tag_name in release") if version == "" {
return "", fmt.Errorf("empty version from %s", url)
} }
return strings.TrimPrefix(release.TagName, "v"), nil return version, nil
} }

View file

@ -66,9 +66,9 @@ func TestSignatureVerifiesGoodSignature(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
prevHost := githubReleaseHost prevHost := updateBaseURL
githubReleaseHost = srv.URL updateBaseURL = srv.URL
t.Cleanup(func() { githubReleaseHost = prevHost }) t.Cleanup(func() { updateBaseURL = prevHost })
if err := verifyChecksumsSignature(context.Background(), "0.0.0", checksumsBody); err != nil { if err := verifyChecksumsSignature(context.Background(), "0.0.0", checksumsBody); err != nil {
t.Fatalf("verifyChecksumsSignature(good) = %v, want nil", err) t.Fatalf("verifyChecksumsSignature(good) = %v, want nil", err)
@ -92,9 +92,9 @@ func TestSignatureRejectsBadSignature(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
prevHost := githubReleaseHost prevHost := updateBaseURL
githubReleaseHost = srv.URL updateBaseURL = srv.URL
t.Cleanup(func() { githubReleaseHost = prevHost }) t.Cleanup(func() { updateBaseURL = prevHost })
err = verifyChecksumsSignature(context.Background(), "0.0.0", body) err = verifyChecksumsSignature(context.Background(), "0.0.0", body)
if err == nil || !strings.Contains(err.Error(), "verification failed") { if err == nil || !strings.Contains(err.Error(), "verification failed") {
@ -110,9 +110,9 @@ func TestSignatureMissingFile(t *testing.T) {
http.NotFound(w, r) http.NotFound(w, r)
})) }))
defer srv.Close() defer srv.Close()
prevHost := githubReleaseHost prevHost := updateBaseURL
githubReleaseHost = srv.URL updateBaseURL = srv.URL
t.Cleanup(func() { githubReleaseHost = prevHost }) t.Cleanup(func() { updateBaseURL = prevHost })
err := verifyChecksumsSignature(context.Background(), "0.0.0", []byte("body")) err := verifyChecksumsSignature(context.Background(), "0.0.0", []byte("body"))
if !errors.Is(err, ErrMissingSignature) { if !errors.Is(err, ErrMissingSignature) {

View file

@ -25,7 +25,6 @@ import (
) )
const ( const (
githubRepo = "torrentclaw/unarr"
binaryName = "unarr" binaryName = "unarr"
smokeTestTO = 5 * time.Second smokeTestTO = 5 * time.Second
) )
@ -243,12 +242,26 @@ func archiveName(version string) string {
return fmt.Sprintf("%s_%s_%s_%s.%s", binaryName, version, runtime.GOOS, runtime.GOARCH, ext) return fmt.Sprintf("%s_%s_%s_%s.%s", binaryName, version, runtime.GOOS, runtime.GOARCH, ext)
} }
// githubReleaseHost is the base URL used to build release asset URLs. Exposed // updateBaseURL is the base URL the self-updater fetches releases from —
// as a var (not a const) so tests can point it at an httptest.Server without // TorrentClaw's own app, no GitHub dependency (the org is shadow-banned, so
// touching production behaviour. // GitHub releases/raw/API all 404 to anonymous clients). Defaults to the
var githubReleaseHost = "https://github.com" // production apex; SetBaseURL points it at the configured host (cfg.Auth.APIURL)
// so mirrors / onion / staging work, and tests can point it at an httptest.Server.
var updateBaseURL = "https://torrentclaw.com"
// releaseURL returns the download URL for a release asset. // SetBaseURL overrides the release endpoint base (trailing slash trimmed).
func releaseURL(version, filename string) string { // No-op for empty input so a blank config can't break the default.
return fmt.Sprintf("%s/%s/releases/download/v%s/%s", githubReleaseHost, githubRepo, version, filename) func SetBaseURL(base string) {
if base != "" {
updateBaseURL = strings.TrimRight(base, "/")
}
}
// releaseURL returns the download URL for a release asset:
//
// {base}/releases/download/v{version}/{filename}
//
// served by the app's src/app/releases/download/[...seg] route handler.
func releaseURL(version, filename string) string {
return fmt.Sprintf("%s/releases/download/v%s/%s", updateBaseURL, version, filename)
} }

View file

@ -57,7 +57,7 @@ func TestArchiveName(t *testing.T) {
func TestReleaseURL(t *testing.T) { func TestReleaseURL(t *testing.T) {
url := releaseURL("0.3.0", "unarr_0.3.0_linux_amd64.tar.gz") url := releaseURL("0.3.0", "unarr_0.3.0_linux_amd64.tar.gz")
want := "https://github.com/torrentclaw/unarr/releases/download/v0.3.0/unarr_0.3.0_linux_amd64.tar.gz" want := "https://torrentclaw.com/releases/download/v0.3.0/unarr_0.3.0_linux_amd64.tar.gz"
if url != want { if url != want {
t.Errorf("releaseURL = %q, want %q", url, want) t.Errorf("releaseURL = %q, want %q", url, want)
} }
@ -289,21 +289,24 @@ func TestUpgraderSameVersionWithPrefix(t *testing.T) {
func TestFetchLatestVersionMockServer(t *testing.T) { func TestFetchLatestVersionMockServer(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") if r.URL.Path != "/version" {
fmt.Fprint(w, `{"tag_name":"v2.5.1","published_at":"2025-01-01T00:00:00Z"}`) http.NotFound(w, r)
return
}
fmt.Fprintln(w, "v2.5.1")
})) }))
defer srv.Close() defer srv.Close()
// We can't directly test fetchLatestVersion because it uses a hardcoded URL. prev := updateBaseURL
// But we can test the JSON parsing logic by calling the endpoint ourselves. updateBaseURL = srv.URL
resp, err := http.Get(srv.URL) t.Cleanup(func() { updateBaseURL = prev })
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 { ver, err := fetchLatestVersion(context.Background())
t.Errorf("status = %d, want 200", resp.StatusCode) if err != nil {
t.Fatalf("fetchLatestVersion() = %v", err)
}
if ver != "2.5.1" {
t.Errorf("fetchLatestVersion() = %q, want %q", ver, "2.5.1")
} }
} }
@ -403,19 +406,19 @@ func TestReleaseURLEdgeCases(t *testing.T) {
name: "pre-release version", name: "pre-release version",
version: "2.0.0-beta.1", version: "2.0.0-beta.1",
filename: "unarr_2.0.0-beta.1_darwin_arm64.tar.gz", filename: "unarr_2.0.0-beta.1_darwin_arm64.tar.gz",
wantURL: "https://github.com/torrentclaw/unarr/releases/download/v2.0.0-beta.1/unarr_2.0.0-beta.1_darwin_arm64.tar.gz", wantURL: "https://torrentclaw.com/releases/download/v2.0.0-beta.1/unarr_2.0.0-beta.1_darwin_arm64.tar.gz",
}, },
{ {
name: "checksums file", name: "checksums file",
version: "3.0.0", version: "3.0.0",
filename: "checksums.txt", filename: "checksums.txt",
wantURL: "https://github.com/torrentclaw/unarr/releases/download/v3.0.0/checksums.txt", wantURL: "https://torrentclaw.com/releases/download/v3.0.0/checksums.txt",
}, },
{ {
name: "windows zip", name: "windows zip",
version: "1.2.3", version: "1.2.3",
filename: "unarr_1.2.3_windows_amd64.zip", filename: "unarr_1.2.3_windows_amd64.zip",
wantURL: "https://github.com/torrentclaw/unarr/releases/download/v1.2.3/unarr_1.2.3_windows_amd64.zip", wantURL: "https://torrentclaw.com/releases/download/v1.2.3/unarr_1.2.3_windows_amd64.zip",
}, },
} }
for _, tt := range tests { for _, tt := range tests {
@ -530,19 +533,19 @@ func TestFetchLatestVersionWithHTTPTest(t *testing.T) {
}{ }{
{ {
name: "valid response", name: "valid response",
body: `{"tag_name":"v3.1.4"}`, body: "v3.1.4\n",
statusCode: 200, statusCode: 200,
wantVer: "3.1.4", wantVer: "3.1.4",
}, },
{ {
name: "valid response without v prefix", name: "valid response without v prefix",
body: `{"tag_name":"2.0.0"}`, body: "2.0.0",
statusCode: 200, statusCode: 200,
wantVer: "2.0.0", wantVer: "2.0.0",
}, },
{ {
name: "empty tag_name", name: "empty body",
body: `{"tag_name":""}`, body: "",
statusCode: 200, statusCode: 200,
wantErr: true, wantErr: true,
}, },
@ -553,8 +556,8 @@ func TestFetchLatestVersionWithHTTPTest(t *testing.T) {
wantErr: true, wantErr: true,
}, },
{ {
name: "invalid json", name: "whitespace only",
body: `{invalid`, body: " \n",
statusCode: 200, statusCode: 200,
wantErr: true, wantErr: true,
}, },