diff --git a/backend/internal/handler/admin/system_handler.go b/backend/internal/handler/admin/system_handler.go index fb6c0ef726..49bd5bc20a 100644 --- a/backend/internal/handler/admin/system_handler.go +++ b/backend/internal/handler/admin/system_handler.go @@ -26,6 +26,8 @@ type systemUpdateService interface { CheckUpdate(ctx context.Context, force bool) (*service.UpdateInfo, error) PerformUpdate(ctx context.Context) error Rollback() error + ListRollbackVersions(ctx context.Context) ([]service.RollbackVersion, error) + RollbackToVersion(ctx context.Context, version string) error } // NewSystemHandler creates a new SystemHandler @@ -102,11 +104,42 @@ func (h *SystemHandler) PerformUpdate(c *gin.Context) { }) } -// Rollback restores the previous version +// GetRollbackVersions lists versions available for rollback +// GET /api/v1/admin/system/rollback-versions +func (h *SystemHandler) GetRollbackVersions(c *gin.Context) { + versions, err := h.updateSvc.ListRollbackVersions(c.Request.Context()) + if err != nil { + response.Error(c, http.StatusInternalServerError, err.Error()) + return + } + response.Success(c, gin.H{ + "versions": versions, + }) +} + +// Rollback restores a previous version. +// Without a body (or with an empty version) it restores the local .backup binary +// left by the last in-place update. With {"version": "x.y.z"} it downloads and +// installs that specific release (must be one of the recent rollback versions). // POST /api/v1/admin/system/rollback func (h *SystemHandler) Rollback(c *gin.Context) { - operationID := buildSystemOperationID(c, "rollback") - payload := gin.H{"operation_id": operationID} + var req struct { + Version string `json:"version"` + } + if c.Request.Body != nil && c.Request.ContentLength > 0 { + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, http.StatusBadRequest, "invalid request body") + return + } + } + targetVersion := strings.TrimSpace(req.Version) + + operation := "rollback" + if targetVersion != "" { + operation = "rollback:" + targetVersion + } + operationID := buildSystemOperationID(c, operation) + payload := gin.H{"operation_id": operationID, "version": targetVersion} executeAdminIdempotentJSON(c, "admin.system.rollback", payload, service.DefaultSystemOperationIdempotencyTTL(), func(ctx context.Context) (any, error) { lock, release, err := h.acquireSystemLock(ctx, operationID) if err != nil { @@ -118,7 +151,12 @@ func (h *SystemHandler) Rollback(c *gin.Context) { release(releaseReason, succeeded) }() - if err := h.updateSvc.Rollback(); err != nil { + if targetVersion != "" { + err = h.updateSvc.RollbackToVersion(ctx, targetVersion) + } else { + err = h.updateSvc.Rollback() + } + if err != nil { releaseReason = "SYSTEM_ROLLBACK_FAILED" return nil, err } @@ -127,6 +165,7 @@ func (h *SystemHandler) Rollback(c *gin.Context) { return gin.H{ "message": "Rollback completed. Please restart the service.", "need_restart": true, + "version": targetVersion, "operation_id": lock.OperationID(), }, nil }) diff --git a/backend/internal/handler/admin/system_handler_test.go b/backend/internal/handler/admin/system_handler_test.go index 0f33a4528d..78e24d008e 100644 --- a/backend/internal/handler/admin/system_handler_test.go +++ b/backend/internal/handler/admin/system_handler_test.go @@ -8,6 +8,7 @@ import ( "errors" "net/http" "net/http/httptest" + "strings" "testing" "time" @@ -17,11 +18,18 @@ import ( ) type systemHandlerUpdateServiceStub struct { - performErr error - updateInfo *service.UpdateInfo - checkErr error - checkForces []bool - performCall int + performErr error + updateInfo *service.UpdateInfo + checkErr error + checkForces []bool + performCall int + rollbackCall int + rollbackToCall int + rollbackToVersions []string + rollbackToErr error + rollbackVersions []service.RollbackVersion + rollbackVersionsErr error + rollbackVersionsCall int } func (s *systemHandlerUpdateServiceStub) CheckUpdate(_ context.Context, force bool) (*service.UpdateInfo, error) { @@ -35,9 +43,21 @@ func (s *systemHandlerUpdateServiceStub) PerformUpdate(context.Context) error { } func (s *systemHandlerUpdateServiceStub) Rollback() error { + s.rollbackCall++ return nil } +func (s *systemHandlerUpdateServiceStub) ListRollbackVersions(context.Context) ([]service.RollbackVersion, error) { + s.rollbackVersionsCall++ + return s.rollbackVersions, s.rollbackVersionsErr +} + +func (s *systemHandlerUpdateServiceStub) RollbackToVersion(_ context.Context, version string) error { + s.rollbackToCall++ + s.rollbackToVersions = append(s.rollbackToVersions, version) + return s.rollbackToErr +} + type systemUpdateResponseEnvelope struct { Code int `json:"code"` Message string `json:"message"` @@ -71,6 +91,8 @@ func newSystemHandlerTestRouter(t *testing.T, updateSvc *systemHandlerUpdateServ router := gin.New() router.POST("/api/v1/admin/system/update", handler.PerformUpdate) + router.POST("/api/v1/admin/system/rollback", handler.Rollback) + router.GET("/api/v1/admin/system/rollback-versions", handler.GetRollbackVersions) return router } @@ -142,3 +164,104 @@ func TestSystemHandlerPerformUpdateFailureStillReturnsInternalError(t *testing.T require.Equal(t, http.StatusInternalServerError, body.Code) require.Equal(t, "internal error", body.Message) } + +func TestSystemHandlerRollbackWithoutBodyUsesLegacyBackup(t *testing.T) { + updateSvc := &systemHandlerUpdateServiceStub{} + repo := newMemoryIdempotencyRepoStub() + router := newSystemHandlerTestRouter(t, updateSvc, repo) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/system/rollback", nil) + req.Header.Set("Idempotency-Key", "legacy-rollback") + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, 1, updateSvc.rollbackCall) + require.Equal(t, 0, updateSvc.rollbackToCall) + requireSystemLockStatus(t, repo, service.IdempotencyStatusSucceeded) +} + +func TestSystemHandlerRollbackWithVersionCallsRollbackToVersion(t *testing.T) { + updateSvc := &systemHandlerUpdateServiceStub{} + repo := newMemoryIdempotencyRepoStub() + router := newSystemHandlerTestRouter(t, updateSvc, repo) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/system/rollback", + strings.NewReader(`{"version":"0.1.146"}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Idempotency-Key", "rollback-to-146") + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, 0, updateSvc.rollbackCall) + require.Equal(t, 1, updateSvc.rollbackToCall) + require.Equal(t, []string{"0.1.146"}, updateSvc.rollbackToVersions) + requireSystemLockStatus(t, repo, service.IdempotencyStatusSucceeded) + + var body systemUpdateResponseEnvelope + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + require.Equal(t, 0, body.Code) + require.Equal(t, "Rollback completed. Please restart the service.", body.Data.Message) +} + +func TestSystemHandlerRollbackWithDisallowedVersionReturnsBadRequest(t *testing.T) { + updateSvc := &systemHandlerUpdateServiceStub{ + rollbackToErr: service.ErrRollbackVersionNotAllowed, + } + repo := newMemoryIdempotencyRepoStub() + router := newSystemHandlerTestRouter(t, updateSvc, repo) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/system/rollback", + strings.NewReader(`{"version":"9.9.9"}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Idempotency-Key", "rollback-to-bad") + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusBadRequest, rec.Code) + require.Equal(t, 1, updateSvc.rollbackToCall) +} + +func TestSystemHandlerGetRollbackVersions(t *testing.T) { + updateSvc := &systemHandlerUpdateServiceStub{ + rollbackVersions: []service.RollbackVersion{ + {Version: "0.1.146", PublishedAt: "2026-07-07T00:00:00Z", HTMLURL: "https://example.com/v0.1.146"}, + {Version: "0.1.145", PublishedAt: "2026-07-06T00:00:00Z", HTMLURL: "https://example.com/v0.1.145"}, + }, + } + repo := newMemoryIdempotencyRepoStub() + router := newSystemHandlerTestRouter(t, updateSvc, repo) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/system/rollback-versions", nil) + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, 1, updateSvc.rollbackVersionsCall) + + var body struct { + Code int `json:"code"` + Data struct { + Versions []service.RollbackVersion `json:"versions"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + require.Equal(t, 0, body.Code) + require.Len(t, body.Data.Versions, 2) + require.Equal(t, "0.1.146", body.Data.Versions[0].Version) +} + +func TestSystemHandlerGetRollbackVersionsError(t *testing.T) { + updateSvc := &systemHandlerUpdateServiceStub{ + rollbackVersionsErr: errors.New("github unavailable"), + } + repo := newMemoryIdempotencyRepoStub() + router := newSystemHandlerTestRouter(t, updateSvc, repo) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/system/rollback-versions", nil) + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusInternalServerError, rec.Code) +} diff --git a/backend/internal/repository/github_release_service.go b/backend/internal/repository/github_release_service.go index ad1f22e39b..85fab2b344 100644 --- a/backend/internal/repository/github_release_service.go +++ b/backend/internal/repository/github_release_service.go @@ -67,6 +67,10 @@ func (c *githubReleaseClientError) FetchLatestRelease(ctx context.Context, repo return nil, c.err } +func (c *githubReleaseClientError) FetchRecentReleases(ctx context.Context, repo string, perPage int) ([]*service.GitHubRelease, error) { + return nil, c.err +} + func (c *githubReleaseClientError) DownloadFile(ctx context.Context, url, dest string, maxSize int64) error { return c.err } @@ -103,6 +107,40 @@ func (c *githubReleaseClient) FetchLatestRelease(ctx context.Context, repo strin return &release, nil } +func (c *githubReleaseClient) FetchRecentReleases(ctx context.Context, repo string, perPage int) ([]*service.GitHubRelease, error) { + if perPage <= 0 { + perPage = 10 + } + if perPage > 100 { + perPage = 100 // GitHub API hard limit + } + url := fmt.Sprintf("https://api.github.com/repos/%s/releases?per_page=%d", repo, perPage) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/vnd.github.v3+json") + req.Header.Set("User-Agent", "Sub2API-Updater") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GitHub API returned %d", resp.StatusCode) + } + + var releases []*service.GitHubRelease + if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil { + return nil, err + } + + return releases, nil +} + func (c *githubReleaseClient) DownloadFile(ctx context.Context, url, dest string, maxSize int64) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { diff --git a/backend/internal/repository/github_release_service_test.go b/backend/internal/repository/github_release_service_test.go index d375a19300..abc9a604e6 100644 --- a/backend/internal/repository/github_release_service_test.go +++ b/backend/internal/repository/github_release_service_test.go @@ -29,6 +29,9 @@ type testTransport struct { func (t *testTransport) RoundTrip(req *http.Request) (*http.Response, error) { // Rewrite the URL to point to our test server testURL := t.testServerURL + req.URL.Path + if req.URL.RawQuery != "" { + testURL += "?" + req.URL.RawQuery + } newReq, err := http.NewRequestWithContext(req.Context(), req.Method, testURL, req.Body) if err != nil { return nil, err @@ -245,6 +248,76 @@ func (s *GitHubReleaseServiceSuite) TestFetchLatestRelease_Success() { require.Equal(s.T(), "app-linux-amd64.tar.gz", release.Assets[0].Name) } +func (s *GitHubReleaseServiceSuite) TestFetchRecentReleases_Success() { + releasesJSON := `[ + { + "tag_name": "v1.0.1", + "name": "Release 1.0.1", + "html_url": "https://github.com/test/repo/releases/v1.0.1", + "published_at": "2026-07-08T00:00:00Z", + "prerelease": false, + "assets": [ + { + "name": "app-linux-amd64.tar.gz", + "browser_download_url": "https://github.com/test/repo/releases/download/v1.0.1/app-linux-amd64.tar.gz" + } + ] + }, + { + "tag_name": "v1.0.1-rc1", + "name": "Release 1.0.1-rc1", + "prerelease": true + }, + { + "tag_name": "v1.0.0", + "name": "Release 1.0.0" + } + ]` + + s.srv = newLocalTestServer(s.T(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(s.T(), "/repos/test/repo/releases", r.URL.Path) + require.Equal(s.T(), "15", r.URL.Query().Get("per_page")) + require.Equal(s.T(), "application/vnd.github.v3+json", r.Header.Get("Accept")) + require.Equal(s.T(), "Sub2API-Updater", r.Header.Get("User-Agent")) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(releasesJSON)) + })) + + s.client = &githubReleaseClient{ + httpClient: &http.Client{ + Transport: &testTransport{testServerURL: s.srv.URL}, + }, + downloadHTTPClient: &http.Client{}, + } + + releases, err := s.client.FetchRecentReleases(context.Background(), "test/repo", 15) + require.NoError(s.T(), err) + require.Len(s.T(), releases, 3) + require.Equal(s.T(), "v1.0.1", releases[0].TagName) + require.False(s.T(), releases[0].Prerelease) + require.Len(s.T(), releases[0].Assets, 1) + require.True(s.T(), releases[1].Prerelease) + require.Equal(s.T(), "v1.0.0", releases[2].TagName) +} + +func (s *GitHubReleaseServiceSuite) TestFetchRecentReleases_Non200() { + s.srv = newLocalTestServer(s.T(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + })) + + s.client = &githubReleaseClient{ + httpClient: &http.Client{ + Transport: &testTransport{testServerURL: s.srv.URL}, + }, + downloadHTTPClient: &http.Client{}, + } + + _, err := s.client.FetchRecentReleases(context.Background(), "test/repo", 15) + require.Error(s.T(), err) + require.Contains(s.T(), err.Error(), "403") +} + func (s *GitHubReleaseServiceSuite) TestFetchLatestRelease_Non200() { s.srv = newLocalTestServer(s.T(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index a456c8c370..0d7e2a505a 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -545,6 +545,7 @@ func registerSystemRoutes(admin *gin.RouterGroup, h *handler.Handlers) { { system.GET("/version", h.Admin.System.GetVersion) system.GET("/check-updates", h.Admin.System.CheckUpdates) + system.GET("/rollback-versions", h.Admin.System.GetRollbackVersions) system.POST("/update", h.Admin.System.PerformUpdate) system.POST("/rollback", h.Admin.System.Rollback) system.POST("/restart", h.Admin.System.RestartService) diff --git a/backend/internal/service/update_service.go b/backend/internal/service/update_service.go index de8c5e16d8..82df3bb024 100644 --- a/backend/internal/service/update_service.go +++ b/backend/internal/service/update_service.go @@ -14,6 +14,7 @@ import ( "os" "path/filepath" "runtime" + "sort" "strconv" "strings" "time" @@ -22,7 +23,8 @@ import ( ) var ( - ErrNoUpdateAvailable = infraerrors.Conflict("ALREADY_UP_TO_DATE", "no update available; current version is latest") + ErrNoUpdateAvailable = infraerrors.Conflict("ALREADY_UP_TO_DATE", "no update available; current version is latest") + ErrRollbackVersionNotAllowed = infraerrors.BadRequest("ROLLBACK_VERSION_NOT_ALLOWED", "version is not in the allowed rollback list") ) const ( @@ -36,6 +38,11 @@ const ( // Security: max download size (500MB) maxDownloadSize = 500 * 1024 * 1024 + + // Rollback: expose at most the 3 most recent versions older than current + maxRollbackVersions = 3 + // Fetch a few extra releases so filtering (current/newer/prerelease) still leaves enough candidates + rollbackFetchPageSize = 15 ) // UpdateCache defines cache operations for update service @@ -47,6 +54,7 @@ type UpdateCache interface { // GitHubReleaseClient 获取 GitHub release 信息的接口 type GitHubReleaseClient interface { FetchLatestRelease(ctx context.Context, repo string) (*GitHubRelease, error) + FetchRecentReleases(ctx context.Context, repo string, perPage int) ([]*GitHubRelease, error) DownloadFile(ctx context.Context, url, dest string, maxSize int64) error FetchChecksumFile(ctx context.Context, url string) ([]byte, error) } @@ -103,9 +111,18 @@ type GitHubRelease struct { Body string `json:"body"` PublishedAt string `json:"published_at"` HTMLURL string `json:"html_url"` + Draft bool `json:"draft"` + Prerelease bool `json:"prerelease"` Assets []GitHubAsset `json:"assets"` } +// RollbackVersion describes a release version the system can roll back to +type RollbackVersion struct { + Version string `json:"version"` // without "v" prefix, e.g. "0.1.146" + PublishedAt string `json:"published_at"` + HTMLURL string `json:"html_url"` +} + type GitHubAsset struct { Name string `json:"name"` BrowserDownloadURL string `json:"browser_download_url"` @@ -155,12 +172,19 @@ func (s *UpdateService) PerformUpdate(ctx context.Context) error { return ErrNoUpdateAvailable } + return s.applyReleaseAssets(ctx, info.ReleaseInfo.Assets) +} + +// applyReleaseAssets downloads the platform archive from the given release assets, +// verifies its checksum, and atomically swaps the running binary. +// Shared by PerformUpdate (latest) and RollbackToVersion (specific older version). +func (s *UpdateService) applyReleaseAssets(ctx context.Context, releaseAssets []Asset) error { // Find matching archive and checksum for current platform archiveName := s.getArchiveName() var downloadURL string var checksumURL string - for _, asset := range info.ReleaseInfo.Assets { + for _, asset := range releaseAssets { if strings.Contains(asset.Name, archiveName) && !strings.HasSuffix(asset.Name, ".txt") { downloadURL = asset.DownloadURL } @@ -279,6 +303,102 @@ func (s *UpdateService) Rollback() error { return nil } +// ListRollbackVersions returns up to maxRollbackVersions release versions that are +// strictly older than the current version (the current version itself is excluded), +// newest first. Draft and prerelease entries are skipped. +func (s *UpdateService) ListRollbackVersions(ctx context.Context) ([]RollbackVersion, error) { + releases, err := s.fetchRollbackCandidates(ctx) + if err != nil { + return nil, err + } + + versions := make([]RollbackVersion, 0, len(releases)) + for _, r := range releases { + versions = append(versions, RollbackVersion{ + Version: strings.TrimPrefix(r.TagName, "v"), + PublishedAt: r.PublishedAt, + HTMLURL: r.HTMLURL, + }) + } + return versions, nil +} + +// RollbackToVersion downloads and installs a specific older version. +// The target must be one of the versions returned by ListRollbackVersions; +// anything else (including the current version) is rejected. +func (s *UpdateService) RollbackToVersion(ctx context.Context, version string) error { + target := strings.TrimPrefix(strings.TrimSpace(version), "v") + if target == "" { + return ErrRollbackVersionNotAllowed + } + + releases, err := s.fetchRollbackCandidates(ctx) + if err != nil { + return err + } + + var match *GitHubRelease + for _, r := range releases { + if strings.TrimPrefix(r.TagName, "v") == target { + match = r + break + } + } + if match == nil { + return ErrRollbackVersionNotAllowed + } + + assets := make([]Asset, len(match.Assets)) + for i, a := range match.Assets { + assets[i] = Asset{ + Name: a.Name, + DownloadURL: a.BrowserDownloadURL, + Size: a.Size, + } + } + + return s.applyReleaseAssets(ctx, assets) +} + +// fetchRollbackCandidates fetches recent releases and keeps the newest +// maxRollbackVersions entries strictly older than the current version. +func (s *UpdateService) fetchRollbackCandidates(ctx context.Context) ([]*GitHubRelease, error) { + releases, err := s.githubClient.FetchRecentReleases(ctx, githubRepo, rollbackFetchPageSize) + if err != nil { + return nil, err + } + + seen := make(map[string]bool, len(releases)) + candidates := make([]*GitHubRelease, 0, maxRollbackVersions) + for _, r := range releases { + if r == nil || r.Draft || r.Prerelease { + continue + } + v := strings.TrimPrefix(r.TagName, "v") + if v == "" || seen[v] { + continue + } + // Only versions strictly older than current (also excludes current itself) + if compareVersions(v, s.currentVersion) >= 0 { + continue + } + seen[v] = true + candidates = append(candidates, r) + } + + sort.SliceStable(candidates, func(i, j int) bool { + return compareVersions( + strings.TrimPrefix(candidates[i].TagName, "v"), + strings.TrimPrefix(candidates[j].TagName, "v"), + ) > 0 + }) + + if len(candidates) > maxRollbackVersions { + candidates = candidates[:maxRollbackVersions] + } + return candidates, nil +} + func (s *UpdateService) fetchLatestRelease(ctx context.Context) (*UpdateInfo, error) { release, err := s.githubClient.FetchLatestRelease(ctx, githubRepo) if err != nil { diff --git a/backend/internal/service/update_service_test.go b/backend/internal/service/update_service_test.go index 8d8310d4b6..243e25d1e4 100644 --- a/backend/internal/service/update_service_test.go +++ b/backend/internal/service/update_service_test.go @@ -28,13 +28,19 @@ func (s *updateServiceCacheStub) SetUpdateInfo(_ context.Context, data string, _ } type updateServiceGitHubClientStub struct { - release *GitHubRelease + release *GitHubRelease + recentReleases []*GitHubRelease + recentErr error } func (s *updateServiceGitHubClientStub) FetchLatestRelease(context.Context, string) (*GitHubRelease, error) { return s.release, nil } +func (s *updateServiceGitHubClientStub) FetchRecentReleases(context.Context, string, int) ([]*GitHubRelease, error) { + return s.recentReleases, s.recentErr +} + func (s *updateServiceGitHubClientStub) DownloadFile(context.Context, string, string, int64) error { panic("DownloadFile should not be called when no update is available") } @@ -62,3 +68,120 @@ func TestUpdateServicePerformUpdateNoUpdateReturnsSentinel(t *testing.T) { require.True(t, errors.Is(err, ErrNoUpdateAvailable)) require.ErrorIs(t, err, ErrNoUpdateAvailable) } + +func newRollbackTestService(current string, releases []*GitHubRelease) *UpdateService { + return NewUpdateService( + &updateServiceCacheStub{}, + &updateServiceGitHubClientStub{recentReleases: releases}, + current, + "release", + ) +} + +func TestUpdateServiceListRollbackVersionsFiltersAndCaps(t *testing.T) { + releases := []*GitHubRelease{ + {TagName: "v0.1.148", PublishedAt: "2026-07-09T00:00:00Z"}, // newer than current: excluded + {TagName: "v0.1.147", PublishedAt: "2026-07-08T00:00:00Z"}, // current: excluded + {TagName: "v0.1.146-rc1", PublishedAt: "2026-07-07T12:00:00Z", Prerelease: true}, // prerelease: excluded + {TagName: "v0.1.146", PublishedAt: "2026-07-07T00:00:00Z"}, + {TagName: "v0.1.145", PublishedAt: "2026-07-06T00:00:00Z", Draft: true}, // draft: excluded + {TagName: "v0.1.144", PublishedAt: "2026-07-05T00:00:00Z"}, + {TagName: "v0.1.144", PublishedAt: "2026-07-05T00:00:00Z"}, // duplicate: excluded + {TagName: "v0.1.143", PublishedAt: "2026-07-04T00:00:00Z"}, + {TagName: "v0.1.142", PublishedAt: "2026-07-03T00:00:00Z"}, // beyond cap of 3: excluded + } + svc := newRollbackTestService("0.1.147", releases) + + versions, err := svc.ListRollbackVersions(context.Background()) + + require.NoError(t, err) + require.Len(t, versions, 3) + require.Equal(t, "0.1.146", versions[0].Version) + require.Equal(t, "0.1.144", versions[1].Version) + require.Equal(t, "0.1.143", versions[2].Version) +} + +func TestUpdateServiceListRollbackVersionsSortsUnorderedInput(t *testing.T) { + releases := []*GitHubRelease{ + {TagName: "v0.1.144"}, + {TagName: "v0.1.146"}, + {TagName: "v0.1.145"}, + } + svc := newRollbackTestService("0.1.147", releases) + + versions, err := svc.ListRollbackVersions(context.Background()) + + require.NoError(t, err) + require.Len(t, versions, 3) + require.Equal(t, "0.1.146", versions[0].Version) + require.Equal(t, "0.1.145", versions[1].Version) + require.Equal(t, "0.1.144", versions[2].Version) +} + +func TestUpdateServiceListRollbackVersionsEmptyWhenNoneOlder(t *testing.T) { + releases := []*GitHubRelease{ + {TagName: "v0.1.147"}, + {TagName: "v0.1.148"}, + } + svc := newRollbackTestService("0.1.147", releases) + + versions, err := svc.ListRollbackVersions(context.Background()) + + require.NoError(t, err) + require.Empty(t, versions) +} + +func TestUpdateServiceListRollbackVersionsPropagatesFetchError(t *testing.T) { + svc := NewUpdateService( + &updateServiceCacheStub{}, + &updateServiceGitHubClientStub{recentErr: errors.New("github unavailable")}, + "0.1.147", + "release", + ) + + _, err := svc.ListRollbackVersions(context.Background()) + + require.Error(t, err) + require.Contains(t, err.Error(), "github unavailable") +} + +func TestUpdateServiceRollbackToVersionRejectsDisallowedTargets(t *testing.T) { + releases := []*GitHubRelease{ + {TagName: "v0.1.148"}, + {TagName: "v0.1.147"}, + {TagName: "v0.1.146"}, + {TagName: "v0.1.145"}, + {TagName: "v0.1.144"}, + {TagName: "v0.1.143"}, + {TagName: "v0.1.142"}, + } + svc := newRollbackTestService("0.1.147", releases) + + for _, target := range []string{ + "", // empty + "0.1.147", // current version + "v0.1.147", // current version with prefix + "0.1.148", // newer than current + "0.1.142", // older than the 3 most recent + "9.9.9", // nonexistent + } { + err := svc.RollbackToVersion(context.Background(), target) + require.ErrorIs(t, err, ErrRollbackVersionNotAllowed, "target %q should be rejected", target) + } +} + +func TestUpdateServiceRollbackToVersionAcceptsVPrefix(t *testing.T) { + // No platform asset in the release: the target passes the allowlist check + // and fails later at asset lookup, proving the version itself was accepted. + releases := []*GitHubRelease{ + {TagName: "v0.1.147"}, + {TagName: "v0.1.146"}, + } + svc := newRollbackTestService("0.1.147", releases) + + err := svc.RollbackToVersion(context.Background(), "v0.1.146") + + require.Error(t, err) + require.NotErrorIs(t, err, ErrRollbackVersionNotAllowed) + require.Contains(t, err.Error(), "no compatible release found") +} diff --git a/frontend/src/api/__tests__/admin.system.rollback.spec.ts b/frontend/src/api/__tests__/admin.system.rollback.spec.ts new file mode 100644 index 0000000000..15d0989ffb --- /dev/null +++ b/frontend/src/api/__tests__/admin.system.rollback.spec.ts @@ -0,0 +1,55 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { get, post } = vi.hoisted(() => ({ + get: vi.fn(), + post: vi.fn(), +})) + +vi.mock('../client', () => ({ + apiClient: { + get, + post, + }, +})) + +import { getRollbackVersions, rollback, type RollbackVersionInfo } from '@/api/admin/system' + +describe('admin system rollback API', () => { + beforeEach(() => { + get.mockReset() + post.mockReset() + }) + + it('getRollbackVersions fetches the rollback version list', async () => { + const versions: RollbackVersionInfo[] = [ + { + version: '0.1.146', + published_at: '2026-07-07T00:00:00Z', + html_url: 'https://github.com/Wei-Shaw/sub2api/releases/tag/v0.1.146' + } + ] + get.mockResolvedValue({ data: { versions } }) + + const result = await getRollbackVersions() + + expect(get).toHaveBeenCalledWith('/admin/system/rollback-versions') + expect(result.versions).toEqual(versions) + }) + + it('rollback posts the target version in the request body', async () => { + post.mockResolvedValue({ data: { message: 'ok', need_restart: true } }) + + const result = await rollback('0.1.146') + + expect(post).toHaveBeenCalledWith('/admin/system/rollback', { version: '0.1.146' }) + expect(result.need_restart).toBe(true) + }) + + it('rollback without a version posts no body (legacy backup rollback)', async () => { + post.mockResolvedValue({ data: { message: 'ok', need_restart: true } }) + + await rollback() + + expect(post).toHaveBeenCalledWith('/admin/system/rollback', undefined) + }) +}) diff --git a/frontend/src/api/admin/system.ts b/frontend/src/api/admin/system.ts index 9ea312d568..eb3db9b06c 100644 --- a/frontend/src/api/admin/system.ts +++ b/frontend/src/api/admin/system.ts @@ -45,6 +45,22 @@ export interface UpdateResult { need_restart: boolean } +export interface RollbackVersionInfo { + version: string + published_at: string + html_url: string +} + +/** + * Get versions available for rollback (up to 3 versions older than current) + */ +export async function getRollbackVersions(): Promise<{ versions: RollbackVersionInfo[] }> { + const { data } = await apiClient.get<{ versions: RollbackVersionInfo[] }>( + '/admin/system/rollback-versions' + ) + return data +} + /** * Perform system update * Downloads and applies the latest version @@ -55,10 +71,14 @@ export async function performUpdate(): Promise { } /** - * Rollback to previous version + * Rollback to a previous version + * @param version - Target version (e.g. "0.1.146"); omit to restore the local backup binary */ -export async function rollback(): Promise { - const { data } = await apiClient.post('/admin/system/rollback') +export async function rollback(version?: string): Promise { + const { data } = await apiClient.post( + '/admin/system/rollback', + version ? { version } : undefined + ) return data } @@ -74,6 +94,7 @@ export const systemAPI = { getVersion, checkUpdates, performUpdate, + getRollbackVersions, rollback, restartService } diff --git a/frontend/src/components/common/VersionBadge.vue b/frontend/src/components/common/VersionBadge.vue index d4937156e1..7fd62947f7 100644 --- a/frontend/src/components/common/VersionBadge.vue +++ b/frontend/src/components/common/VersionBadge.vue @@ -31,7 +31,8 @@

- {{ t('version.updateComplete') }} + {{ + successKind === 'rollback' + ? t('version.rollbackComplete') + : t('version.updateComplete') + }}

{{ t('version.restartRequired') }} @@ -350,23 +355,275 @@

- - - - - - {{ t('version.viewRelease') }} - + +
+ + + + + {{ t('version.viewRelease') }} + + + +
+ + + +
+ +
+ + + +

+ {{ t('version.rollbackSourceHint') }} +

+
+ + +
+ + + + +
+ + +
+

+ {{ rollbackVersionsError }} +

+ +
+ + +

+ {{ t('version.noRollbackVersions') }} +

+ + + +
+
+
+
@@ -384,9 +641,20 @@ import { ref, computed, onMounted, onBeforeUnmount } from 'vue' import { useI18n } from 'vue-i18n' import { useAuthStore, useAppStore } from '@/stores' -import { performUpdate, restartService } from '@/api/admin/system' +import { + performUpdate, + restartService, + getRollbackVersions, + rollback as rollbackAPI, + type RollbackVersionInfo +} from '@/api/admin/system' +import { useClipboard } from '@/composables/useClipboard' import Icon from '@/components/icons/Icon.vue' +const GITHUB_REPO = 'Wei-Shaw/sub2api' +// Docker Hub image published by CI (tags carry no "v" prefix, e.g. weishaw/sub2api:0.1.146) +const DOCKER_IMAGE = 'weishaw/sub2api' + const { t } = useI18n() const props = defineProps<{ @@ -416,6 +684,49 @@ const needRestart = ref(false) const updateError = ref('') const updateSuccess = ref(false) const restartCountdown = ref(0) +// Distinguishes the success + restart panel between update and rollback flows +const successKind = ref<'update' | 'rollback'>('update') + +// Rollback states +const rollbackPanelOpen = ref(false) +const rollbackVersions = ref([]) +const rollbackVersionsLoading = ref(false) +const rollbackVersionsError = ref('') +const selectedRollbackVersion = ref('') +const rollingBack = ref(false) +const rollbackError = ref('') + +const { copied, copyToClipboard } = useClipboard() + +// Manual rollback methods differ by deployment: script installs use install.sh, +// docker deployments pin the image tag instead +const manualTab = ref<'script' | 'docker'>('script') + +const manualTabs = computed(() => [ + { key: 'script' as const, label: t('version.deployScript') }, + { key: 'docker' as const, label: t('version.deployDocker') } +]) + +const scriptRollbackCommand = computed(() => { + if (!selectedRollbackVersion.value) return '' + const tag = `v${selectedRollbackVersion.value}` + return `curl -sSL https://raw.githubusercontent.com/${GITHUB_REPO}/${tag}/deploy/install.sh | sudo bash -s -- rollback ${tag}` +}) + +const dockerRollbackCommand = computed(() => { + if (!selectedRollbackVersion.value) return '' + return [ + `# ${t('version.dockerEditCompose')}`, + `image: ${DOCKER_IMAGE}:${selectedRollbackVersion.value}`, + '', + `# ${t('version.dockerRecreate')}`, + 'docker compose up -d' + ].join('\n') +}) + +const activeManualCommand = computed(() => + manualTab.value === 'docker' ? dockerRollbackCommand.value : scriptRollbackCommand.value +) // Only show update check for release builds (binary/docker deployment) const isReleaseBuild = computed(() => buildType.value === 'release') @@ -435,6 +746,7 @@ async function refreshVersion(force = true) { updateError.value = '' updateSuccess.value = false needRestart.value = false + resetRollbackState() await appStore.fetchVersion(force) } @@ -448,6 +760,7 @@ async function handleUpdate() { try { const result = await performUpdate() + successKind.value = 'update' updateSuccess.value = true needRestart.value = result.need_restart // Clear version cache to reflect update completed @@ -460,6 +773,81 @@ async function handleUpdate() { } } +function resetRollbackState() { + rollbackPanelOpen.value = false + rollbackVersions.value = [] + rollbackVersionsError.value = '' + selectedRollbackVersion.value = '' + rollbackError.value = '' + manualTab.value = 'script' +} + +async function toggleRollbackPanel() { + if (!isAdmin.value) return + rollbackPanelOpen.value = !rollbackPanelOpen.value + // Source builds only show a hint, no version list to fetch + if ( + rollbackPanelOpen.value && + isReleaseBuild.value && + rollbackVersions.value.length === 0 && + !rollbackVersionsLoading.value + ) { + await loadRollbackVersions() + } +} + +async function loadRollbackVersions() { + if (!isAdmin.value) return + rollbackVersionsLoading.value = true + rollbackVersionsError.value = '' + try { + const data = await getRollbackVersions() + rollbackVersions.value = data.versions || [] + } catch (error: unknown) { + const err = error as { response?: { data?: { message?: string } }; message?: string } + rollbackVersionsError.value = + err.response?.data?.message || err.message || t('version.loadVersionsFailed') + } finally { + rollbackVersionsLoading.value = false + } +} + +function selectRollbackVersion(version: string) { + if (rollingBack.value) return + rollbackError.value = '' + selectedRollbackVersion.value = selectedRollbackVersion.value === version ? '' : version +} + +function formatPublishedAt(publishedAt: string): string { + if (!publishedAt) return '' + const date = new Date(publishedAt) + if (Number.isNaN(date.getTime())) return '' + return date.toLocaleDateString() +} + +async function handleRollback() { + if (!isAdmin.value) return + if (rollingBack.value || !selectedRollbackVersion.value) return + + rollingBack.value = true + rollbackError.value = '' + + try { + const result = await rollbackAPI(selectedRollbackVersion.value) + successKind.value = 'rollback' + updateSuccess.value = true + needRestart.value = result.need_restart + rollbackPanelOpen.value = false + // Clear version cache so the next check reflects the rolled-back version + appStore.clearVersionCache() + } catch (error: unknown) { + const err = error as { response?: { data?: { message?: string } }; message?: string } + rollbackError.value = err.response?.data?.message || err.message || t('version.rollbackFailed') + } finally { + rollingBack.value = false + } +} + async function handleRestart() { if (restarting.value) return @@ -546,6 +934,17 @@ onBeforeUnmount(() => { transform: scale(0.95) translateY(-4px); } +.rollback-enter-active, +.rollback-leave-active { + transition: all 0.2s ease; +} + +.rollback-enter-from, +.rollback-leave-to { + opacity: 0; + transform: translateY(-4px); +} + .line-clamp-3 { display: -webkit-box; -webkit-line-clamp: 3; diff --git a/frontend/src/i18n/locales/en/misc.ts b/frontend/src/i18n/locales/en/misc.ts index e378a46cf3..d109a2b71b 100644 --- a/frontend/src/i18n/locales/en/misc.ts +++ b/frontend/src/i18n/locales/en/misc.ts @@ -38,7 +38,25 @@ export default { restartRequired: 'Please restart the service to apply the update', restartNow: 'Restart Now', restarting: 'Restarting...', - retry: 'Retry' + retry: 'Retry', + rollback: 'Version Rollback', + rollbackSelectVersion: 'Select a version to roll back to (last 3 versions)', + rollbackConfirm: 'Roll back to {version}', + rollbackWarning: + 'Rollback downloads the selected version and replaces the current binary. A service restart is required afterwards.', + rollingBack: 'Rolling back...', + rollbackComplete: 'Rollback Complete', + rollbackFailed: 'Rollback Failed', + manualRollbackCommand: 'Manual rollback', + copyCommand: 'Copy', + copied: 'Copied', + noRollbackVersions: 'No versions available for rollback', + loadVersionsFailed: 'Failed to load versions', + rollbackSourceHint: 'Online rollback is not available for source builds', + deployScript: 'Script', + deployDocker: 'Docker', + dockerEditCompose: 'Edit the image tag in docker-compose.yml', + dockerRecreate: 'Recreate the container' }, // Recharge / Subscription Page diff --git a/frontend/src/i18n/locales/zh/misc.ts b/frontend/src/i18n/locales/zh/misc.ts index 67dffc92bd..3ee641894e 100644 --- a/frontend/src/i18n/locales/zh/misc.ts +++ b/frontend/src/i18n/locales/zh/misc.ts @@ -38,7 +38,24 @@ export default { restartRequired: '请重启服务以应用更新', restartNow: '立即重启', restarting: '正在重启...', - retry: '重试' + retry: '重试', + rollback: '版本回退', + rollbackSelectVersion: '选择要回退到的版本(近 3 个版本)', + rollbackConfirm: '回退到 {version}', + rollbackWarning: '回退将下载所选版本并替换当前程序,完成后需重启服务', + rollingBack: '正在回退...', + rollbackComplete: '回退完成', + rollbackFailed: '回退失败', + manualRollbackCommand: '手动回退方式', + copyCommand: '复制', + copied: '已复制', + noRollbackVersions: '暂无可回退的版本', + loadVersionsFailed: '获取版本列表失败', + rollbackSourceHint: '源码构建不支持在线回退', + deployScript: '脚本部署', + deployDocker: 'Docker', + dockerEditCompose: '修改 docker-compose.yml 中的镜像版本', + dockerRecreate: '重新创建容器' }, // Recharge / Subscription Page