diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 8df3cc3..2217340 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -9,6 +9,7 @@ import ( tc "github.com/torrentclaw/go-client" "github.com/torrentclaw/unarr/internal/config" "github.com/torrentclaw/unarr/internal/sentry" + "github.com/torrentclaw/unarr/internal/upgrade" ) var ( @@ -42,6 +43,10 @@ Source: https://github.com/torrentclaw/unarr`, if noColor || os.Getenv("NO_COLOR") != "" { 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, SilenceErrors: true, diff --git a/internal/upgrade/download.go b/internal/upgrade/download.go index b112b1d..958b08b 100644 --- a/internal/upgrade/download.go +++ b/internal/upgrade/download.go @@ -6,7 +6,6 @@ import ( "context" "crypto/sha256" "encoding/hex" - "encoding/json" "fmt" "io" "net/http" @@ -182,36 +181,35 @@ func verifyChecksumWithOptions(ctx context.Context, version, archivePath string, 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) { - url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", githubRepo) + url := updateBaseURL + "/version" req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return "", err } - req.Header.Set("Accept", "application/vnd.github+json") req.Header.Set("User-Agent", "unarr-updater") resp, err := httpClient.Do(req) if err != nil { - return "", fmt.Errorf("fetch latest release: %w", err) + return "", fmt.Errorf("fetch latest version: %w", err) } defer resp.Body.Close() 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 { - TagName string `json:"tag_name"` - } - if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { - return "", fmt.Errorf("decode response: %w", err) + body, err := io.ReadAll(io.LimitReader(resp.Body, 64)) + if err != nil { + return "", fmt.Errorf("read version: %w", err) } - if release.TagName == "" { - return "", fmt.Errorf("empty tag_name in release") + version := strings.TrimPrefix(strings.TrimSpace(string(body)), "v") + if version == "" { + return "", fmt.Errorf("empty version from %s", url) } - return strings.TrimPrefix(release.TagName, "v"), nil + return version, nil } diff --git a/internal/upgrade/signature_test.go b/internal/upgrade/signature_test.go index eb85b68..2075987 100644 --- a/internal/upgrade/signature_test.go +++ b/internal/upgrade/signature_test.go @@ -66,9 +66,9 @@ func TestSignatureVerifiesGoodSignature(t *testing.T) { })) defer srv.Close() - prevHost := githubReleaseHost - githubReleaseHost = srv.URL - t.Cleanup(func() { githubReleaseHost = prevHost }) + prevHost := updateBaseURL + updateBaseURL = srv.URL + t.Cleanup(func() { updateBaseURL = prevHost }) if err := verifyChecksumsSignature(context.Background(), "0.0.0", checksumsBody); err != nil { t.Fatalf("verifyChecksumsSignature(good) = %v, want nil", err) @@ -92,9 +92,9 @@ func TestSignatureRejectsBadSignature(t *testing.T) { })) defer srv.Close() - prevHost := githubReleaseHost - githubReleaseHost = srv.URL - t.Cleanup(func() { githubReleaseHost = prevHost }) + prevHost := updateBaseURL + updateBaseURL = srv.URL + t.Cleanup(func() { updateBaseURL = prevHost }) err = verifyChecksumsSignature(context.Background(), "0.0.0", body) if err == nil || !strings.Contains(err.Error(), "verification failed") { @@ -110,9 +110,9 @@ func TestSignatureMissingFile(t *testing.T) { http.NotFound(w, r) })) defer srv.Close() - prevHost := githubReleaseHost - githubReleaseHost = srv.URL - t.Cleanup(func() { githubReleaseHost = prevHost }) + prevHost := updateBaseURL + updateBaseURL = srv.URL + t.Cleanup(func() { updateBaseURL = prevHost }) err := verifyChecksumsSignature(context.Background(), "0.0.0", []byte("body")) if !errors.Is(err, ErrMissingSignature) { diff --git a/internal/upgrade/upgrade.go b/internal/upgrade/upgrade.go index 6470ef1..50053c5 100644 --- a/internal/upgrade/upgrade.go +++ b/internal/upgrade/upgrade.go @@ -25,7 +25,6 @@ import ( ) const ( - githubRepo = "torrentclaw/unarr" binaryName = "unarr" 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) } -// githubReleaseHost is the base URL used to build release asset URLs. Exposed -// as a var (not a const) so tests can point it at an httptest.Server without -// touching production behaviour. -var githubReleaseHost = "https://github.com" +// updateBaseURL is the base URL the self-updater fetches releases from — +// TorrentClaw's own app, no GitHub dependency (the org is shadow-banned, so +// GitHub releases/raw/API all 404 to anonymous clients). Defaults to the +// 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. -func releaseURL(version, filename string) string { - return fmt.Sprintf("%s/%s/releases/download/v%s/%s", githubReleaseHost, githubRepo, version, filename) +// SetBaseURL overrides the release endpoint base (trailing slash trimmed). +// No-op for empty input so a blank config can't break the default. +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) } diff --git a/internal/upgrade/upgrade_test.go b/internal/upgrade/upgrade_test.go index 6e691f9..18cde17 100644 --- a/internal/upgrade/upgrade_test.go +++ b/internal/upgrade/upgrade_test.go @@ -57,7 +57,7 @@ func TestArchiveName(t *testing.T) { func TestReleaseURL(t *testing.T) { 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 { t.Errorf("releaseURL = %q, want %q", url, want) } @@ -289,21 +289,24 @@ func TestUpgraderSameVersionWithPrefix(t *testing.T) { func TestFetchLatestVersionMockServer(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - fmt.Fprint(w, `{"tag_name":"v2.5.1","published_at":"2025-01-01T00:00:00Z"}`) + if r.URL.Path != "/version" { + http.NotFound(w, r) + return + } + fmt.Fprintln(w, "v2.5.1") })) defer srv.Close() - // We can't directly test fetchLatestVersion because it uses a hardcoded URL. - // But we can test the JSON parsing logic by calling the endpoint ourselves. - resp, err := http.Get(srv.URL) - if err != nil { - t.Fatal(err) - } - defer resp.Body.Close() + prev := updateBaseURL + updateBaseURL = srv.URL + t.Cleanup(func() { updateBaseURL = prev }) - if resp.StatusCode != 200 { - t.Errorf("status = %d, want 200", resp.StatusCode) + ver, err := fetchLatestVersion(context.Background()) + 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", version: "2.0.0-beta.1", 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", version: "3.0.0", 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", version: "1.2.3", 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 { @@ -530,19 +533,19 @@ func TestFetchLatestVersionWithHTTPTest(t *testing.T) { }{ { name: "valid response", - body: `{"tag_name":"v3.1.4"}`, + body: "v3.1.4\n", statusCode: 200, wantVer: "3.1.4", }, { name: "valid response without v prefix", - body: `{"tag_name":"2.0.0"}`, + body: "2.0.0", statusCode: 200, wantVer: "2.0.0", }, { - name: "empty tag_name", - body: `{"tag_name":""}`, + name: "empty body", + body: "", statusCode: 200, wantErr: true, }, @@ -553,8 +556,8 @@ func TestFetchLatestVersionWithHTTPTest(t *testing.T) { wantErr: true, }, { - name: "invalid json", - body: `{invalid`, + name: "whitespace only", + body: " \n", statusCode: 200, wantErr: true, },