diff --git a/README.md b/README.md index 93e3d3ca4..532c7d637 100644 --- a/README.md +++ b/README.md @@ -262,6 +262,8 @@ Channel icons identify their respective upstream providers. All trademarks belon For problems or feature ideas, open a [GitHub Issue](https://github.com/tbphp/gpt-load/issues). Report security vulnerabilities through the process in [SECURITY.md](SECURITY.md). +For community chat and usage discussion, join the [Telegram group](https://t.me/+GHpy5SwEllg3MTUx). + If GPT-Load is useful to you, a star is appreciated. ## Sponsors and support diff --git a/README_CN.md b/README_CN.md index 57f9ce0f2..a523cf255 100644 --- a/README_CN.md +++ b/README_CN.md @@ -261,6 +261,8 @@ GPT-Load 自身负责凭据存储、账号选择、调度、重试、健康、 遇到问题或有功能建议,欢迎提交 [GitHub Issue](https://github.com/tbphp/gpt-load/issues)。安全漏洞请按 [SECURITY.md](SECURITY.md) 的流程报告。 +社区交流与使用讨论可加入 [Telegram 群组](https://t.me/+GHpy5SwEllg3MTUx)。 + 如果 GPT-Load 对你有帮助,欢迎点个 Star。 ## 赞助与支持 diff --git a/README_JP.md b/README_JP.md index eb4c80822..a3c2b3544 100644 --- a/README_JP.md +++ b/README_JP.md @@ -262,6 +262,8 @@ GPT-Load の一部機能は以下のプロジェクトを基盤としていま 問題や機能の提案は [GitHub Issue](https://github.com/tbphp/gpt-load/issues) へお寄せください。セキュリティ脆弱性は [SECURITY.md](SECURITY.md) の手順に従って報告してください。 +コミュニティでの交流や利用方法の相談は、[Telegram グループ](https://t.me/+GHpy5SwEllg3MTUx) にご参加ください。 + GPT-Load が役に立ったら、Star をいただけると嬉しいです。 ## スポンサーと支援 diff --git a/internal/container/container.go b/internal/container/container.go index a4e16e8ee..90f375025 100644 --- a/internal/container/container.go +++ b/internal/container/container.go @@ -29,6 +29,7 @@ import ( "gpt-load/internal/pricing" "gpt-load/internal/provideradapter" "gpt-load/internal/ratelimit" + "gpt-load/internal/releasecheck" "gpt-load/internal/requestlog" "gpt-load/internal/state" stateloader "gpt-load/internal/state/loader" @@ -110,6 +111,8 @@ func BuildContainer() (*dig.Container, error) { control.NewRuntime, func(runtime *control.Runtime) app.ControlRuntime { return runtime }, httpclient.NewHTTPClientManager, + releasecheck.NewClient, + releasecheck.NewChecker, func(manager *httpclient.HTTPClientManager) *catalog.Client { return catalog.NewClient(manager, "") }, @@ -162,7 +165,13 @@ func BuildContainer() (*dig.Container, error) { control.NewCatalogSyncCoordinator, func(service *control.Service) app.StartupBootstrap { return service }, func(service *control.Service) app.StartupRecovery { return service }, - control.NewServer, + func( + cfg *config.Config, + service *control.Service, + checker *releasecheck.Checker, + ) *control.Server { + return control.NewServerWithReleaseUpdateChecker(cfg, service, checker) + }, newHTTPRegistry, func( db *gorm.DB, diff --git a/internal/control/http_routes.go b/internal/control/http_routes.go index 1a1c5712a..a77721818 100644 --- a/internal/control/http_routes.go +++ b/internal/control/http_routes.go @@ -162,6 +162,7 @@ func (s *Server) HTTPModule() httproute.Module { s.handleUpdateSettings, ), controlRoute("control.system.info", http.MethodGet, "/system/info", s.handleSystemInfo), + controlRoute("control.system.update", http.MethodGet, "/system/update", s.handleSystemUpdate), controlRoute( "control.groups.list", http.MethodGet, diff --git a/internal/control/server.go b/internal/control/server.go index 69c7ce678..ba414d15b 100644 --- a/internal/control/server.go +++ b/internal/control/server.go @@ -22,9 +22,15 @@ import ( "gpt-load/internal/platform/i18n" "gpt-load/internal/platform/response" "gpt-load/internal/platform/utils" + "gpt-load/internal/releasecheck" subscriptionruntime "gpt-load/internal/subscription/runtime" ) +// ReleaseUpdateChecker is the control-plane on-demand view of the public release checker. +type ReleaseUpdateChecker interface { + Check(context.Context) (*releasecheck.Update, error) +} + type Server struct { authDigest [sha256.Size]byte service *Service @@ -35,6 +41,7 @@ type Server struct { authFailureEvents *utils.RateLimitedEventCounter startedAt time.Time now func() time.Time + releaseChecker ReleaseUpdateChecker } const maxControlJSONBodyBytes int64 = 32 << 20 @@ -61,6 +68,17 @@ func NewServer(cfg *config.Config, service *Service) *Server { } } +// NewServerWithReleaseUpdateChecker wires the on-demand public update checker. +func NewServerWithReleaseUpdateChecker( + cfg *config.Config, + service *Service, + releaseChecker ReleaseUpdateChecker, +) *Server { + server := NewServer(cfg, service) + server.releaseChecker = releaseChecker + return server +} + func (s *Server) handleGetSettings(c *gin.Context) { result, err := s.service.GetSettings(c.Request.Context()) if err != nil { diff --git a/internal/control/server_test.go b/internal/control/server_test.go index 8957c089f..9d135bb01 100644 --- a/internal/control/server_test.go +++ b/internal/control/server_test.go @@ -29,7 +29,7 @@ import ( "gpt-load/internal/storage/models" ) -func TestServerHomeRoutesUseExactManagementContracts(t *testing.T) { +func TestServerHomeAndSystemUpdateRoutesUseExactManagementContracts(t *testing.T) { fixture := newServiceFixture(t) module := NewServer( &config.Config{AuthKey: "test-auth-key"}, @@ -48,6 +48,10 @@ func TestServerHomeRoutesUseExactManagementContracts(t *testing.T) { method: http.MethodGet, path: "/home/statistics", }, + "control.system.update": { + method: http.MethodGet, + path: "/system/update", + }, } seen := make(map[string]int, len(want)) for _, route := range module.Routes { diff --git a/internal/control/system_update.go b/internal/control/system_update.go new file mode 100644 index 000000000..3317efe0f --- /dev/null +++ b/internal/control/system_update.go @@ -0,0 +1,49 @@ +package control + +import ( + "fmt" + + "github.com/gin-gonic/gin" + + app_errors "gpt-load/internal/platform/errors" + "gpt-load/internal/platform/response" +) + +type systemUpdateResponse struct { + Update *releaseUpdateResponse `json:"update"` +} + +type releaseUpdateResponse struct { + Version string `json:"version"` + ReleaseURL string `json:"release_url"` + PublishedAtMS int64 `json:"published_at_ms"` +} + +func (s *Server) handleSystemUpdate(c *gin.Context) { + if c.Request.URL.RawQuery != "" || c.Request.URL.ForceQuery { + writeServiceError(c, "system_update", app_errors.ErrBadRequest) + return + } + if s.releaseChecker == nil { + writeServiceError(c, "system_update", app_errors.ErrInternalServer) + return + } + available, err := s.releaseChecker.Check(c.Request.Context()) + if err != nil { + writeServiceError( + c, + "system_update", + fmt.Errorf("check public release update: %w: %w", err, app_errors.ErrBadGateway), + ) + return + } + var update *releaseUpdateResponse + if available != nil { + update = &releaseUpdateResponse{ + Version: available.Version, + ReleaseURL: available.ReleaseURL, + PublishedAtMS: available.PublishedAtMS, + } + } + response.SuccessI18n(c, "common.success", systemUpdateResponse{Update: update}) +} diff --git a/internal/control/system_update_test.go b/internal/control/system_update_test.go new file mode 100644 index 000000000..20b0e4ea0 --- /dev/null +++ b/internal/control/system_update_test.go @@ -0,0 +1,158 @@ +package control + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" + + "gpt-load/internal/platform/config" + app_errors "gpt-load/internal/platform/errors" + "gpt-load/internal/releasecheck" +) + +type recordingReleaseUpdateChecker struct { + update *releasecheck.Update + err error + calls int +} + +func (checker *recordingReleaseUpdateChecker) Check(context.Context) (*releasecheck.Update, error) { + checker.calls++ + if checker.update == nil { + return nil, checker.err + } + result := *checker.update + return &result, checker.err +} + +func TestSystemUpdateHTTPChecksOnDemandWithoutAffectingHome(t *testing.T) { + initControlI18n(t) + fixture := newServiceFixture(t) + checker := &recordingReleaseUpdateChecker{update: &releasecheck.Update{ + Version: "v2.0.0-beta.9", + ReleaseURL: "https://github.com/tbphp/gpt-load/releases/tag/v2.0.0-beta.9", + PublishedAtMS: time.Date(2026, time.August, 20, 0, 0, 0, 0, time.UTC).UnixMilli(), + }} + server := NewServerWithReleaseUpdateChecker( + &config.Config{AuthKey: "test-auth-key"}, + fixture.service, + checker, + ) + engine := gin.New() + server.RegisterRoutes(engine) + + home := performHomeRequest(engine, "/api/home", "test-auth-key") + if home.Code != http.StatusOK { + t.Fatalf("GET /api/home = %d %s", home.Code, home.Body.String()) + } + var homeEnvelope struct { + Data map[string]json.RawMessage `json:"data"` + } + if err := json.Unmarshal(home.Body.Bytes(), &homeEnvelope); err != nil { + t.Fatalf("decode home response: %v", err) + } + if _, exists := homeEnvelope.Data["update"]; exists || checker.calls != 0 { + t.Fatalf("home update field/check calls = %v/%d, want absent/0", exists, checker.calls) + } + + recorder := performHomeRequest(engine, "/api/system/update", "test-auth-key") + if recorder.Code != http.StatusOK { + t.Fatalf("GET /api/system/update = %d %s", recorder.Code, recorder.Body.String()) + } + var envelope struct { + Data systemUpdateResponse `json:"data"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil { + t.Fatalf("decode update response: %v", err) + } + want := releaseUpdateResponse{ + Version: checker.update.Version, + ReleaseURL: checker.update.ReleaseURL, + PublishedAtMS: checker.update.PublishedAtMS, + } + if envelope.Data.Update == nil || *envelope.Data.Update != want || checker.calls != 1 { + t.Fatalf("update/calls = %#v/%d, want %#v/1", envelope.Data.Update, checker.calls, want) + } + assertManagementWireObject(t, envelope.Data, []string{"update"}) + assertManagementWireObject(t, *envelope.Data.Update, []string{ + "version", "release_url", "published_at_ms", + }) +} + +func TestSystemUpdateHTTPReturnsNullForSuccessfulNoUpdate(t *testing.T) { + initControlI18n(t) + fixture := newServiceFixture(t) + checker := &recordingReleaseUpdateChecker{} + server := NewServerWithReleaseUpdateChecker( + &config.Config{AuthKey: "test-auth-key"}, + fixture.service, + checker, + ) + engine := gin.New() + server.RegisterRoutes(engine) + + recorder := performHomeRequest(engine, "/api/system/update", "test-auth-key") + if recorder.Code != http.StatusOK || checker.calls != 1 { + t.Fatalf("GET /api/system/update = %d %s, calls=%d", recorder.Code, recorder.Body.String(), checker.calls) + } + var envelope struct { + Data map[string]json.RawMessage `json:"data"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil { + t.Fatal(err) + } + if raw, exists := envelope.Data["update"]; !exists || string(raw) != "null" { + t.Fatalf("update = %s, exists=%v, want null/true", raw, exists) + } +} + +func TestSystemUpdateHTTPHidesUpstreamFailureBehindBadGateway(t *testing.T) { + initControlI18n(t) + fixture := newServiceFixture(t) + checker := &recordingReleaseUpdateChecker{err: errors.New("private upstream detail")} + server := NewServerWithReleaseUpdateChecker( + &config.Config{AuthKey: "test-auth-key"}, + fixture.service, + checker, + ) + engine := gin.New() + server.RegisterRoutes(engine) + + recorder := performHomeRequest(engine, "/api/system/update", "test-auth-key") + if recorder.Code != http.StatusBadGateway || checker.calls != 1 || + !strings.Contains(recorder.Body.String(), `"code":"`+app_errors.ErrBadGateway.Code+`"`) || + strings.Contains(recorder.Body.String(), "private upstream detail") { + t.Fatalf("GET /api/system/update = %d %s, calls=%d", recorder.Code, recorder.Body.String(), checker.calls) + } +} + +func TestSystemUpdateHTTPRejectsAccessKeyAndQueryBeforeCheck(t *testing.T) { + initControlI18n(t) + fixture := newServiceFixture(t) + accessKey, err := fixture.service.CreateAccessKey(t.Context(), AccessKeyCreateRequest{Name: "read only"}) + if err != nil { + t.Fatalf("CreateAccessKey() error = %v", err) + } + checker := &recordingReleaseUpdateChecker{} + server := NewServerWithReleaseUpdateChecker( + &config.Config{AuthKey: "test-auth-key"}, + fixture.service, + checker, + ) + engine := gin.New() + server.RegisterRoutes(engine) + + accessKeyResponse := performHomeRequest(engine, "/api/system/update", accessKey.Key) + assertHomeHTTPError(t, accessKeyResponse, http.StatusForbidden, app_errors.ErrForbidden.Code) + queryResponse := performHomeRequest(engine, "/api/system/update?refresh=1", "test-auth-key") + assertHomeHTTPError(t, queryResponse, http.StatusBadRequest, app_errors.ErrBadRequest.Code) + if checker.calls != 0 { + t.Fatalf("rejected request check calls = %d, want 0", checker.calls) + } +} diff --git a/internal/releasecheck/checker.go b/internal/releasecheck/checker.go new file mode 100644 index 000000000..b4c4006ad --- /dev/null +++ b/internal/releasecheck/checker.go @@ -0,0 +1,95 @@ +package releasecheck + +import ( + "context" + "errors" + "sync" + "time" + + "gpt-load/internal/platform/version" +) + +const ( + successCacheDuration = 6 * time.Hour + failureCacheDuration = 30 * time.Minute +) + +type releaseFetcher interface { + Fetch(context.Context) ([]Release, error) +} + +// Checker maintains the current public update result. +type Checker struct { + fetcher releaseFetcher + current string + + mu sync.Mutex + update *Update + cachedErr error + expiresAt time.Time + now func() time.Time +} + +// NewChecker creates the process-local release checker. +func NewChecker(client *Client) *Checker { + return newChecker(client, version.Version) +} + +func newChecker(fetcher releaseFetcher, current string) *Checker { + return &Checker{ + fetcher: fetcher, + current: current, + now: time.Now, + } +} + +// Check returns the cached result or synchronously refreshes it from GitHub. +func (checker *Checker) Check(ctx context.Context) (*Update, error) { + if checker == nil || checker.fetcher == nil { + return nil, errors.New("check GitHub releases: checker is unavailable") + } + if ctx == nil { + ctx = context.Background() + } + checker.mu.Lock() + defer checker.mu.Unlock() + if err := ctx.Err(); err != nil { + return nil, err + } + + now := checker.currentTime() + if now.Before(checker.expiresAt) { + return cloneUpdate(checker.update), checker.cachedErr + } + + releases, err := checker.fetcher.Fetch(ctx) + if ctx.Err() != nil { + return nil, ctx.Err() + } + if err != nil { + checker.update = nil + checker.cachedErr = err + checker.expiresAt = checker.currentTime().Add(failureCacheDuration) + return nil, err + } + + checker.update = SelectUpdate(checker.current, releases) + checker.cachedErr = nil + checker.expiresAt = checker.currentTime().Add(successCacheDuration) + return cloneUpdate(checker.update), nil +} + +func (checker *Checker) currentTime() time.Time { + if checker.now == nil { + return time.Now().UTC() + } + return checker.now().UTC() +} + +func cloneUpdate(update *Update) *Update { + if update == nil { + return nil + } + result := *update + return &result +} diff --git a/internal/releasecheck/checker_test.go b/internal/releasecheck/checker_test.go new file mode 100644 index 000000000..9acce2010 --- /dev/null +++ b/internal/releasecheck/checker_test.go @@ -0,0 +1,198 @@ +package releasecheck + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "testing" + "time" +) + +type fetchResult struct { + releases []Release + err error +} + +type sequenceFetcher struct { + mu sync.Mutex + results []fetchResult + calls int +} + +func (fetcher *sequenceFetcher) Fetch(context.Context) ([]Release, error) { + fetcher.mu.Lock() + defer fetcher.mu.Unlock() + index := fetcher.calls + fetcher.calls++ + if index >= len(fetcher.results) { + return nil, errors.New("unexpected fetch") + } + return fetcher.results[index].releases, fetcher.results[index].err +} + +func (fetcher *sequenceFetcher) callCount() int { + fetcher.mu.Lock() + defer fetcher.mu.Unlock() + return fetcher.calls +} + +func TestCheckerCheckRunsOnlyOnDemandAndCachesSuccess(t *testing.T) { + base := time.Date(2026, time.August, 20, 8, 0, 0, 0, time.UTC) + now := base + fetcher := &sequenceFetcher{results: []fetchResult{{ + releases: []Release{testRelease("v2.0.1", "2026-08-20T07:00:00Z")}, + }}} + checker := newChecker(fetcher, "v2.0.0") + checker.now = func() time.Time { return now } + + if calls := fetcher.callCount(); calls != 0 { + t.Fatalf("constructor fetch calls = %d, want 0", calls) + } + first, err := checker.Check(t.Context()) + if err != nil || first == nil || first.Version != "v2.0.1" || fetcher.callCount() != 1 { + t.Fatalf("first Check() = %#v, %v, calls=%d", first, err, fetcher.callCount()) + } + first.Version = "mutated" + now = base.Add(2 * time.Hour) + second, err := checker.Check(t.Context()) + if err != nil || second == nil || second.Version != "v2.0.1" || fetcher.callCount() != 1 { + t.Fatalf("cached Check() = %#v, %v, calls=%d", second, err, fetcher.callCount()) + } +} + +func TestCheckerCachesSuccessForSixHoursAndFailureForThirtyMinutes(t *testing.T) { + base := time.Date(2026, time.August, 19, 12, 0, 0, 0, time.UTC) + now := base + offlineErr := errors.New("offline") + fetcher := &sequenceFetcher{results: []fetchResult{ + {releases: []Release{testRelease("v2.0.1", "2026-08-19T11:00:00Z")}}, + {err: offlineErr}, + {releases: []Release{testRelease("v2.0.2", "2026-08-19T19:00:00Z")}}, + }} + checker := newChecker(fetcher, "v2.0.0") + checker.now = func() time.Time { return now } + + update, err := checker.Check(t.Context()) + if err != nil || update == nil || update.Version != "v2.0.1" { + t.Fatalf("first Check() = %#v, %v", update, err) + } + + now = base.Add(2 * time.Hour) + update, err = checker.Check(t.Context()) + if err != nil || update == nil || update.Version != "v2.0.1" || fetcher.callCount() != 1 { + t.Fatalf("cached Check() = %#v, %v, calls=%d", update, err, fetcher.callCount()) + } + + now = base.Add(6 * time.Hour) + update, err = checker.Check(t.Context()) + if update != nil || !errors.Is(err, offlineErr) || fetcher.callCount() != 2 { + t.Fatalf("failed Check() = %#v, %v, calls=%d", update, err, fetcher.callCount()) + } + + now = base.Add(6*time.Hour + 15*time.Minute) + update, err = checker.Check(t.Context()) + if update != nil || !errors.Is(err, offlineErr) || fetcher.callCount() != 2 { + t.Fatalf("cached failure Check() = %#v, %v, calls=%d", update, err, fetcher.callCount()) + } + + now = base.Add(6*time.Hour + 30*time.Minute) + update, err = checker.Check(t.Context()) + if err != nil || update == nil || update.Version != "v2.0.2" || fetcher.callCount() != 3 { + t.Fatalf("recovered Check() = %#v, %v, calls=%d", update, err, fetcher.callCount()) + } +} + +func TestCheckerCachesSuccessfulNoUpdateResult(t *testing.T) { + fetcher := &sequenceFetcher{results: []fetchResult{{ + releases: []Release{testRelease("v2.0.0-beta.8", "2026-08-19T11:00:00Z")}, + }}} + checker := newChecker(fetcher, "v2.0.0") + update, err := checker.Check(t.Context()) + if err != nil || update != nil || fetcher.callCount() != 1 { + t.Fatalf("Check() = %#v, %v, calls=%d", update, err, fetcher.callCount()) + } + update, err = checker.Check(t.Context()) + if err != nil || update != nil || fetcher.callCount() != 1 { + t.Fatalf("cached Check() = %#v, %v, calls=%d", update, err, fetcher.callCount()) + } +} + +type concurrentFetcher struct { + calls atomic.Int32 + started chan struct{} + release chan struct{} +} + +func (fetcher *concurrentFetcher) Fetch(context.Context) ([]Release, error) { + if fetcher.calls.Add(1) == 1 { + close(fetcher.started) + } + <-fetcher.release + return nil, nil +} + +func TestCheckerCoalescesConcurrentChecks(t *testing.T) { + fetcher := &concurrentFetcher{ + started: make(chan struct{}), + release: make(chan struct{}), + } + checker := newChecker(fetcher, "v2.0.0") + done := make(chan error, 2) + go func() { + _, err := checker.Check(t.Context()) + done <- err + }() + <-fetcher.started + go func() { + _, err := checker.Check(t.Context()) + done <- err + }() + time.Sleep(25 * time.Millisecond) + if calls := fetcher.calls.Load(); calls != 1 { + close(fetcher.release) + t.Fatalf("concurrent fetch calls = %d, want 1", calls) + } + close(fetcher.release) + if firstErr, secondErr := <-done, <-done; firstErr != nil || secondErr != nil { + t.Fatalf("concurrent Check errors = %v, %v", firstErr, secondErr) + } + if calls := fetcher.calls.Load(); calls != 1 { + t.Fatalf("completed fetch calls = %d, want 1", calls) + } +} + +type blockingFetcher struct { + started chan struct{} +} + +func (fetcher blockingFetcher) Fetch(ctx context.Context) ([]Release, error) { + close(fetcher.started) + <-ctx.Done() + return nil, ctx.Err() +} + +func TestCheckerCheckCancelsActiveFetch(t *testing.T) { + fetcher := blockingFetcher{started: make(chan struct{})} + checker := newChecker(fetcher, "v2.0.0") + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { + _, err := checker.Check(ctx) + done <- err + }() + select { + case <-fetcher.started: + case <-time.After(time.Second): + t.Fatal("Run did not start an immediate release check") + } + cancel() + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Fatalf("Check() error = %v, want context canceled", err) + } + case <-time.After(time.Second): + t.Fatal("Check did not return after cancellation") + } +} diff --git a/internal/releasecheck/client.go b/internal/releasecheck/client.go new file mode 100644 index 000000000..3a4b63ef8 --- /dev/null +++ b/internal/releasecheck/client.go @@ -0,0 +1,173 @@ +package releasecheck + +import ( + "context" + "encoding/json" + "fmt" + "io" + "mime" + "net/http" + "net/url" + "strconv" + "time" + + "gpt-load/internal/platform/httpclient" +) + +const githubReleasesEndpoint = "https://api.github.com/repos/tbphp/gpt-load/releases" + +const ( + githubReleasesPerPage = 30 + maxGitHubReleasePages = 10 + maxGitHubResponseBytes = int64(1 << 20) +) + +type httpDoer interface { + Do(*http.Request) (*http.Response, error) +} + +// Client reads public GPT-Load release metadata from GitHub. +type Client struct { + httpClient httpDoer + endpoint string + maxResponseBytes int64 +} + +// NewClient creates the fixed public GitHub release client. +func NewClient(manager *httpclient.HTTPClientManager) *Client { + if manager == nil { + manager = httpclient.NewHTTPClientManager() + } + return &Client{ + httpClient: manager.GetClient(&httpclient.Config{ + ConnectTimeout: 5 * time.Second, + RequestTimeout: 8 * time.Second, + IdleConnTimeout: 30 * time.Second, + MaxIdleConns: 2, + MaxIdleConnsPerHost: 2, + ResponseHeaderTimeout: 5 * time.Second, + DisableCompression: true, + ForceAttemptHTTP2: true, + TLSHandshakeTimeout: 5 * time.Second, + ExpectContinueTimeout: time.Second, + DisableRedirects: true, + }), + endpoint: githubReleasesEndpoint, + maxResponseBytes: maxGitHubResponseBytes, + } +} + +// Fetch returns the public releases used by the selector. +func (client *Client) Fetch(ctx context.Context) ([]Release, error) { + if client == nil || client.httpClient == nil { + return nil, fmt.Errorf("fetch GitHub releases: HTTP client is unavailable") + } + if ctx == nil { + ctx = context.Background() + } + endpoint := client.endpoint + if endpoint == "" { + endpoint = githubReleasesEndpoint + } + releases := make([]Release, 0, githubReleasesPerPage) + // 额外探测一页,用于区分刚好达到上限和仍有未读取数据。 + for page := 1; page <= maxGitHubReleasePages+1; page++ { + pageEndpoint, err := releasePageEndpoint(endpoint, page) + if err != nil { + return nil, fmt.Errorf("fetch GitHub releases: create page endpoint: %w", err) + } + pageReleases, err := client.fetchPage(ctx, pageEndpoint) + if err != nil { + return nil, err + } + if len(pageReleases) > githubReleasesPerPage { + return nil, fmt.Errorf( + "fetch GitHub releases: page %d contains more than %d releases", + page, + githubReleasesPerPage, + ) + } + if page > maxGitHubReleasePages { + if len(pageReleases) == 0 { + return releases, nil + } + return nil, fmt.Errorf( + "fetch GitHub releases: history exceeds %d pages", + maxGitHubReleasePages, + ) + } + releases = append(releases, pageReleases...) + if len(pageReleases) < githubReleasesPerPage { + return releases, nil + } + } + return nil, fmt.Errorf("fetch GitHub releases: pagination did not terminate") +} + +func releasePageEndpoint(endpoint string, page int) (string, error) { + parsed, err := url.Parse(endpoint) + if err != nil { + return "", err + } + query := parsed.Query() + query.Set("per_page", strconv.Itoa(githubReleasesPerPage)) + query.Del("page") + if page > 1 { + query.Set("page", strconv.Itoa(page)) + } + parsed.RawQuery = query.Encode() + return parsed.String(), nil +} + +func (client *Client) fetchPage(ctx context.Context, endpoint string) ([]Release, error) { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, fmt.Errorf("fetch GitHub releases: create request: %w", err) + } + request.Header.Set("Accept", "application/vnd.github+json") + request.Header.Set("X-GitHub-Api-Version", "2022-11-28") + request.Header.Set("User-Agent", "GPT-Load") + + response, err := client.httpClient.Do(request) + if err != nil { + return nil, fmt.Errorf("fetch GitHub releases: %w", err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("fetch GitHub releases: status %d", response.StatusCode) + } + mediaType, _, err := mime.ParseMediaType(response.Header.Get("Content-Type")) + if err != nil || (mediaType != "application/json" && mediaType != "application/vnd.github+json") { + return nil, fmt.Errorf("fetch GitHub releases: invalid content type") + } + limit := client.maxResponseBytes + if limit <= 0 { + limit = maxGitHubResponseBytes + } + payload, err := io.ReadAll(io.LimitReader(response.Body, limit+1)) + if err != nil { + return nil, fmt.Errorf("fetch GitHub releases: read response: %w", err) + } + if int64(len(payload)) > limit { + return nil, fmt.Errorf("fetch GitHub releases: response exceeds %d bytes", limit) + } + var upstream []struct { + TagName string `json:"tag_name"` + HTMLURL string `json:"html_url"` + PublishedAt time.Time `json:"published_at"` + Draft bool `json:"draft"` + } + if err := json.Unmarshal(payload, &upstream); err != nil { + return nil, fmt.Errorf("fetch GitHub releases: decode response: %w", err) + } + releases := make([]Release, 0, len(upstream)) + for _, release := range upstream { + releases = append(releases, Release{ + TagName: release.TagName, + HTMLURL: release.HTMLURL, + PublishedAt: release.PublishedAt, + Draft: release.Draft, + }) + } + return releases, nil +} diff --git a/internal/releasecheck/client_test.go b/internal/releasecheck/client_test.go new file mode 100644 index 000000000..5c09249f7 --- /dev/null +++ b/internal/releasecheck/client_test.go @@ -0,0 +1,216 @@ +package releasecheck + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" +) + +func TestClientFetchUsesFixedPublicGitHubContract(t *testing.T) { + published := "2026-08-19T13:09:53Z" + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodGet || request.URL.Path != "/repos/tbphp/gpt-load/releases" || + request.URL.Query().Get("per_page") != "30" || len(request.URL.Query()) != 1 { + t.Errorf("request = %s %s", request.Method, request.URL.String()) + } + if request.Header.Get("Accept") != "application/vnd.github+json" || + request.Header.Get("X-GitHub-Api-Version") != "2022-11-28" || + request.Header.Get("User-Agent") != "GPT-Load" || + request.Header.Get("Authorization") != "" { + t.Errorf("request headers = %#v", request.Header) + } + writer.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(writer, `[ + {"tag_name":"v2.0.0-beta.7","html_url":"%s","published_at":"%s","draft":false,"prerelease":true,"ignored":"value"} + ]`, testReleaseURL("v2.0.0-beta.7"), published) + })) + defer server.Close() + + client := &Client{ + httpClient: server.Client(), + endpoint: server.URL + "/repos/tbphp/gpt-load/releases?per_page=30", + maxResponseBytes: maxGitHubResponseBytes, + } + releases, err := client.Fetch(t.Context()) + if err != nil { + t.Fatalf("Fetch() error = %v", err) + } + if len(releases) != 1 || releases[0].TagName != "v2.0.0-beta.7" || + releases[0].HTMLURL != testReleaseURL("v2.0.0-beta.7") || releases[0].Draft || + !releases[0].PublishedAt.Equal(mustParseReleaseTime(published)) { + t.Fatalf("Fetch() = %#v", releases) + } +} + +func TestClientFetchReadsEligibleV2ReleaseFromSecondPage(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + requests++ + if request.URL.Path != "/repos/tbphp/gpt-load/releases" || + request.URL.Query().Get("per_page") != "30" { + t.Errorf("request = %s %s", request.Method, request.URL.String()) + } + page := request.URL.Query().Get("page") + switch requests { + case 1: + if page != "" { + t.Errorf("first page query = %q, want empty", page) + } + writer.Header().Set("Link", `; rel="next"`) + writeGitHubReleasePage(t, writer, releasePageTags("v3.0", githubReleasesPerPage)) + case 2: + if page != "2" { + t.Errorf("second page query = %q, want 2", page) + } + writeGitHubReleasePage(t, writer, []string{"v2.0.1"}) + default: + t.Errorf("unexpected request %d: %s", requests, request.URL.String()) + writeGitHubReleasePage(t, writer, nil) + } + })) + defer server.Close() + + client := &Client{ + httpClient: server.Client(), + endpoint: server.URL + "/repos/tbphp/gpt-load/releases?per_page=30", + maxResponseBytes: maxGitHubResponseBytes, + } + releases, err := client.Fetch(t.Context()) + if err != nil { + t.Fatalf("Fetch() error = %v", err) + } + update := SelectUpdate("v2.0.0", releases) + if update == nil || update.Version != "v2.0.1" || requests != 2 { + t.Fatalf("SelectUpdate()/requests = %#v/%d, want v2.0.1/2", update, requests) + } +} + +func TestClientFetchRejectsReleaseHistoryBeyondPageLimit(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + requests++ + page := request.URL.Query().Get("page") + wantPage := "" + if requests > 1 { + wantPage = strconv.Itoa(requests) + } + if page != wantPage { + t.Errorf("request %d page = %q, want %q", requests, page, wantPage) + } + writeGitHubReleasePage(t, writer, releasePageTags("v3."+strconv.Itoa(requests), githubReleasesPerPage)) + })) + defer server.Close() + + client := &Client{ + httpClient: server.Client(), + endpoint: server.URL + "/repos/tbphp/gpt-load/releases?per_page=30", + maxResponseBytes: maxGitHubResponseBytes, + } + if releases, err := client.Fetch(t.Context()); err == nil || releases != nil || + requests != maxGitHubReleasePages+1 { + t.Fatalf( + "Fetch() = %#v, %v after %d requests, want nil/error after %d", + releases, + err, + requests, + maxGitHubReleasePages+1, + ) + } +} + +func TestClientFetchRejectsUnusableResponses(t *testing.T) { + tests := []struct { + name string + status int + contentType string + body string + limit int64 + }{ + {name: "non success", status: http.StatusForbidden, contentType: "application/json", body: `{"message":"limited"}`}, + {name: "wrong content type", status: http.StatusOK, contentType: "text/html", body: `[]`}, + {name: "invalid json", status: http.StatusOK, contentType: "application/json", body: `[`}, + {name: "trailing json", status: http.StatusOK, contentType: "application/json", body: `[] {}`}, + {name: "oversized", status: http.StatusOK, contentType: "application/json", body: strings.Repeat(" ", 33), limit: 32}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + writer.Header().Set("Content-Type", test.contentType) + writer.WriteHeader(test.status) + _, _ = writer.Write([]byte(test.body)) + })) + defer server.Close() + limit := test.limit + if limit == 0 { + limit = maxGitHubResponseBytes + } + client := &Client{httpClient: server.Client(), endpoint: server.URL, maxResponseBytes: limit} + if releases, err := client.Fetch(context.Background()); err == nil || releases != nil { + t.Fatalf("Fetch() = %#v, %v, want error", releases, err) + } + }) + } +} + +func TestClientFetchHonorsCancellation(t *testing.T) { + started := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, request *http.Request) { + close(started) + <-request.Context().Done() + })) + defer server.Close() + + client := &Client{httpClient: server.Client(), endpoint: server.URL, maxResponseBytes: maxGitHubResponseBytes} + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { + _, err := client.Fetch(ctx) + done <- err + }() + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("Fetch() did not start an HTTP request") + } + cancel() + select { + case err := <-done: + if err == nil { + t.Fatal("Fetch() error = nil after cancellation") + } + case <-time.After(time.Second): + t.Fatal("Fetch() did not return after cancellation") + } +} + +func writeGitHubReleasePage(t *testing.T, writer http.ResponseWriter, tags []string) { + t.Helper() + published := "2026-08-19T13:09:53Z" + payload := make([]map[string]any, 0, len(tags)) + for _, tag := range tags { + payload = append(payload, map[string]any{ + "tag_name": tag, + "html_url": testReleaseURL(tag), + "published_at": published, + "draft": false, + }) + } + writer.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(writer).Encode(payload); err != nil { + t.Errorf("encode release page: %v", err) + } +} + +func releasePageTags(prefix string, count int) []string { + tags := make([]string, 0, count) + for index := 0; index < count; index++ { + tags = append(tags, prefix+"."+strconv.Itoa(index)) + } + return tags +} diff --git a/internal/releasecheck/selection.go b/internal/releasecheck/selection.go new file mode 100644 index 000000000..afd66ad77 --- /dev/null +++ b/internal/releasecheck/selection.go @@ -0,0 +1,214 @@ +package releasecheck + +import ( + "net/url" + "regexp" + "strconv" + "strings" + "time" +) + +const supportedMajor uint64 = 2 + +const maxSafeInteger int64 = 1<<53 - 1 + +var releaseVersionPattern = regexp.MustCompile( + `^(?:v)?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$`, +) + +// Release is the public GitHub metadata used to select a newer version. +type Release struct { + TagName string + HTMLURL string + PublishedAt time.Time + Draft bool +} + +// Update is the immutable public result exposed to the control plane. +type Update struct { + Version string + ReleaseURL string + PublishedAtMS int64 +} + +// SelectUpdate returns the highest eligible V2 release newer than current. +func SelectUpdate(current string, releases []Release) *Update { + currentVersion, ok := parseVersion(current) + if !ok || currentVersion.major != supportedMajor { + return nil + } + + var selected *Release + var selectedVersion semanticVersion + for index := range releases { + candidate := &releases[index] + candidateVersion, valid := validCandidate(*candidate) + if !valid || candidateVersion.major != supportedMajor || + compareVersions(candidateVersion, currentVersion) <= 0 || + (currentVersion.stable() && !candidateVersion.stable()) { + continue + } + if selected == nil || compareVersions(candidateVersion, selectedVersion) > 0 { + selected = candidate + selectedVersion = candidateVersion + } + } + if selected == nil { + return nil + } + return &Update{ + Version: selected.TagName, + ReleaseURL: selected.HTMLURL, + PublishedAtMS: selected.PublishedAt.UnixMilli(), + } +} + +type semanticVersion struct { + major uint64 + minor uint64 + patch uint64 + prerelease []prereleaseIdentifier +} + +type prereleaseIdentifier struct { + raw string + number uint64 + numeric bool +} + +func (version semanticVersion) stable() bool { + return len(version.prerelease) == 0 +} + +func parseVersion(raw string) (semanticVersion, bool) { + matches := releaseVersionPattern.FindStringSubmatch(strings.TrimSpace(raw)) + if matches == nil { + return semanticVersion{}, false + } + major, ok := parseVersionNumber(matches[1]) + if !ok { + return semanticVersion{}, false + } + minor, ok := parseVersionNumber(matches[2]) + if !ok { + return semanticVersion{}, false + } + patch, ok := parseVersionNumber(matches[3]) + if !ok { + return semanticVersion{}, false + } + result := semanticVersion{major: major, minor: minor, patch: patch} + if matches[4] == "" { + return result, true + } + for _, rawIdentifier := range strings.Split(matches[4], ".") { + identifier := prereleaseIdentifier{raw: rawIdentifier} + if allASCIIBytes(rawIdentifier, '0', '9') { + if len(rawIdentifier) > 1 && rawIdentifier[0] == '0' { + return semanticVersion{}, false + } + identifier.number, ok = parseVersionNumber(rawIdentifier) + if !ok { + return semanticVersion{}, false + } + identifier.numeric = true + } + result.prerelease = append(result.prerelease, identifier) + } + return result, true +} + +func parseVersionNumber(raw string) (uint64, bool) { + value, err := strconv.ParseUint(raw, 10, 64) + return value, err == nil +} + +func allASCIIBytes(value string, minimum, maximum byte) bool { + if value == "" { + return false + } + for index := range len(value) { + if value[index] < minimum || value[index] > maximum { + return false + } + } + return true +} + +func compareVersions(left, right semanticVersion) int { + for _, pair := range [][2]uint64{ + {left.major, right.major}, + {left.minor, right.minor}, + {left.patch, right.patch}, + } { + if pair[0] < pair[1] { + return -1 + } + if pair[0] > pair[1] { + return 1 + } + } + if left.stable() { + if right.stable() { + return 0 + } + return 1 + } + if right.stable() { + return -1 + } + for index := 0; index < len(left.prerelease) && index < len(right.prerelease); index++ { + leftIdentifier := left.prerelease[index] + rightIdentifier := right.prerelease[index] + switch { + case leftIdentifier.numeric && rightIdentifier.numeric: + if leftIdentifier.number < rightIdentifier.number { + return -1 + } + if leftIdentifier.number > rightIdentifier.number { + return 1 + } + case leftIdentifier.numeric: + return -1 + case rightIdentifier.numeric: + return 1 + case leftIdentifier.raw < rightIdentifier.raw: + return -1 + case leftIdentifier.raw > rightIdentifier.raw: + return 1 + } + } + if len(left.prerelease) < len(right.prerelease) { + return -1 + } + if len(left.prerelease) > len(right.prerelease) { + return 1 + } + return 0 +} + +func validCandidate(release Release) (semanticVersion, bool) { + version, ok := parseVersion(release.TagName) + if !ok || release.Draft || !validPublishedAt(release.PublishedAt) || + !validReleaseURL(release.HTMLURL, release.TagName) { + return semanticVersion{}, false + } + return version, true +} + +func validPublishedAt(publishedAt time.Time) bool { + if publishedAt.IsZero() { + return false + } + milliseconds := publishedAt.UnixMilli() + return milliseconds >= 0 && milliseconds <= maxSafeInteger +} + +func validReleaseURL(raw, tag string) bool { + parsed, err := url.Parse(raw) + if err != nil || parsed.Scheme != "https" || parsed.Host != "github.com" || + parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { + return false + } + return parsed.Path == "/tbphp/gpt-load/releases/tag/"+tag +} diff --git a/internal/releasecheck/selection_test.go b/internal/releasecheck/selection_test.go new file mode 100644 index 000000000..bcff4c4f8 --- /dev/null +++ b/internal/releasecheck/selection_test.go @@ -0,0 +1,138 @@ +package releasecheck + +import ( + "testing" + "time" +) + +func TestSelectUpdateStableCurrentOnlyAcceptsNewerStableV2(t *testing.T) { + releases := []Release{ + testRelease("v2.0.1-beta.1", "2026-08-18T00:00:00Z"), + testRelease("v2.0.1", "2026-08-19T00:00:00Z"), + testRelease("v2.2.0-rc.1", "2026-08-20T00:00:00Z"), + testRelease("v2.1.0", "2026-08-21T00:00:00Z"), + testRelease("v3.0.0", "2026-08-22T00:00:00Z"), + } + + got := SelectUpdate("v2.0.0", releases) + assertUpdate(t, got, "v2.1.0", "2026-08-21T00:00:00Z") +} + +func TestSelectUpdateTestCurrentAcceptsAnyNewerV2Release(t *testing.T) { + tests := []struct { + name string + current string + releases []Release + want string + }{ + { + name: "beta selects later beta over stable patch line", + current: "v2.0.0-beta.7", + releases: []Release{ + testRelease("v2.0.0-beta.8", "2026-08-18T00:00:00Z"), + testRelease("v2.0.0", "2026-08-19T00:00:00Z"), + testRelease("v2.1.0-beta.1", "2026-08-20T00:00:00Z"), + }, + want: "v2.1.0-beta.1", + }, + { + name: "rc selects stable", + current: "2.0.0-rc.1", + releases: []Release{ + testRelease("v2.0.0-rc.2", "2026-08-18T00:00:00Z"), + testRelease("v2.0.0", "2026-08-19T00:00:00Z"), + }, + want: "v2.0.0", + }, + { + name: "dev participates as an ordinary test version", + current: "2.0.0-dev", + releases: []Release{ + testRelease("v2.0.0-rc.1", "2026-08-18T00:00:00Z"), + testRelease("v2.0.0", "2026-08-19T00:00:00Z"), + }, + want: "v2.0.0", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got := SelectUpdate(test.current, test.releases) + if got == nil || got.Version != test.want { + t.Fatalf("SelectUpdate(%q) = %#v, want %q", test.current, got, test.want) + } + }) + } +} + +func TestSelectUpdateUsesSemVerPrereleasePrecedence(t *testing.T) { + releases := []Release{ + testRelease("v2.0.0-beta.11", "2026-08-18T00:00:00Z"), + testRelease("v2.0.0-rc.2", "2026-08-19T00:00:00Z"), + testRelease("v2.0.0-rc.10", "2026-08-20T00:00:00Z"), + } + + got := SelectUpdate("v2.0.0-beta.9", releases) + assertUpdate(t, got, "v2.0.0-rc.10", "2026-08-20T00:00:00Z") +} + +func TestSelectUpdateRejectsIneligibleReleases(t *testing.T) { + releases := []Release{ + testRelease("v2.0.0-beta.7", "2026-08-18T00:00:00Z"), + testRelease("v2.0.0-beta.6", "2026-08-17T00:00:00Z"), + testRelease("v3.0.0", "2026-08-20T00:00:00Z"), + testRelease("v2.0.0+build.1", "2026-08-21T00:00:00Z"), + testRelease("v2.0.0-rc.01", "2026-08-22T00:00:00Z"), + {TagName: "v2.0.0-rc.1", HTMLURL: testReleaseURL("v2.0.0-rc.1"), Draft: true}, + } + + if got := SelectUpdate("v2.0.0-beta.7", releases); got != nil { + t.Fatalf("SelectUpdate() = %#v, want nil", got) + } + for _, current := range []string{"", "latest", "v1.4.9", "v3.0.0-beta.1"} { + if got := SelectUpdate(current, []Release{testRelease("v2.1.0", "2026-08-23T00:00:00Z")}); got != nil { + t.Fatalf("SelectUpdate(%q) = %#v, want nil", current, got) + } + } +} + +func TestSelectUpdateRejectsUntrustedOrIncompleteMetadata(t *testing.T) { + releases := []Release{ + {TagName: "v2.0.1", HTMLURL: "http://github.com/tbphp/gpt-load/releases/tag/v2.0.1", PublishedAt: time.Now()}, + {TagName: "v2.0.2", HTMLURL: "https://evil.test/tbphp/gpt-load/releases/tag/v2.0.2", PublishedAt: time.Now()}, + {TagName: "v2.0.3", HTMLURL: testReleaseURL("v2.0.2"), PublishedAt: time.Now()}, + {TagName: "v2.0.4", HTMLURL: testReleaseURL("v2.0.4")}, + } + + if got := SelectUpdate("v2.0.0", releases); got != nil { + t.Fatalf("SelectUpdate() = %#v, want nil", got) + } +} + +func testRelease(tag, published string) Release { + return Release{ + TagName: tag, + HTMLURL: testReleaseURL(tag), + PublishedAt: mustParseReleaseTime(published), + } +} + +func testReleaseURL(tag string) string { + return "https://github.com/tbphp/gpt-load/releases/tag/" + tag +} + +func mustParseReleaseTime(value string) time.Time { + parsed, err := time.Parse(time.RFC3339, value) + if err != nil { + panic(err) + } + return parsed +} + +func assertUpdate(t *testing.T, got *Update, version, published string) { + t.Helper() + if got == nil || got.Version != version || got.ReleaseURL != testReleaseURL(version) || + got.PublishedAtMS != mustParseReleaseTime(published).UnixMilli() { + t.Fatalf("SelectUpdate() = %#v, want version=%q published=%q", got, version, published) + } +} diff --git a/web/src/app/query-keys.ts b/web/src/app/query-keys.ts index 5f9f88d60..b270ad223 100644 --- a/web/src/app/query-keys.ts +++ b/web/src/app/query-keys.ts @@ -125,6 +125,7 @@ export const controlQueryKeys = { settingsAll: ['control', 'settings'] as const, settings: (locale: string) => ['control', 'settings', locale] as const, systemInfo: () => ['control', 'system-info'] as const, + systemUpdate: () => ['control', 'system-update'] as const, models: { all: ['control', 'models'] as const, collection: (filters: ModelCollectionFilters) => diff --git a/web/src/app/resources/system-update.ts b/web/src/app/resources/system-update.ts new file mode 100644 index 000000000..8678e59c5 --- /dev/null +++ b/web/src/app/resources/system-update.ts @@ -0,0 +1,104 @@ +import { queryOptions } from '@tanstack/vue-query' +import type { MaybeRefOrGetter } from 'vue' + +import type { ApiClient } from '@/api/client' +import { InvalidResponseError } from '@/api/errors' +import { controlQueryKeys } from '@/app/query-keys' + +import { + assertNoSecretLikeFields, + projectEpochMilliseconds, + projectRecord, + projectString, +} from './projector' + +export interface ReleaseUpdateDto { + version: string + release_url: string + published_at_ms: number +} + +export interface SystemUpdateDto { + update: ReleaseUpdateDto | null +} + +const systemUpdateFields = ['update'] as const +const releaseUpdateFields = ['version', 'release_url', 'published_at_ms'] as const + +function invalidResponse(): never { + throw new InvalidResponseError() +} + +function projectNonBlankTrimmedString(value: unknown): string { + const result = projectString(value) + if (result !== result.trim() || result.length === 0) invalidResponse() + return result +} + +function assertExactFields(record: Record, fields: readonly string[]): void { + assertNoSecretLikeFields(record, fields) + if ( + Object.keys(record).length !== fields.length || + fields.some((field) => !Object.prototype.hasOwnProperty.call(record, field)) + ) { + invalidResponse() + } +} + +function projectReleaseUpdate(value: unknown): ReleaseUpdateDto | null { + if (value === null) return null + const record = projectRecord(value) + assertExactFields(record, releaseUpdateFields) + const version = projectNonBlankTrimmedString(record.version) + const releaseURL = projectNonBlankTrimmedString(record.release_url) + let parsed: URL + try { + parsed = new URL(releaseURL) + } catch { + return invalidResponse() + } + if ( + parsed.protocol !== 'https:' || + parsed.hostname !== 'github.com' || + parsed.port !== '' || + parsed.username !== '' || + parsed.password !== '' || + parsed.search !== '' || + parsed.hash !== '' || + parsed.pathname !== `/tbphp/gpt-load/releases/tag/${version}` + ) { + invalidResponse() + } + return { + version, + release_url: releaseURL, + published_at_ms: projectEpochMilliseconds(record.published_at_ms), + } +} + +export function projectSystemUpdate(value: unknown): SystemUpdateDto { + const record = projectRecord(value) + assertExactFields(record, systemUpdateFields) + return { update: projectReleaseUpdate(record.update) } +} + +export async function getSystemUpdate( + client: ApiClient, + signal?: AbortSignal, +): Promise { + return projectSystemUpdate(await client.request('/api/system/update', { signal })) +} + +export function systemUpdateQueryOptions(client: ApiClient, enabled?: MaybeRefOrGetter) { + return queryOptions({ + queryKey: controlQueryKeys.systemUpdate(), + queryFn: ({ signal }) => getSystemUpdate(client, signal), + retry: false, + staleTime: Number.POSITIVE_INFINITY, + refetchOnMount: 'always', + // 失败保持静默;只有重新进入首页才再次调用按需检查接口。 + refetchOnWindowFocus: false, + refetchOnReconnect: false, + ...(enabled === undefined ? {} : { enabled }), + }) +} diff --git a/web/src/features/home/HomeReleaseUpdateLink.vue b/web/src/features/home/HomeReleaseUpdateLink.vue new file mode 100644 index 000000000..add46e64a --- /dev/null +++ b/web/src/features/home/HomeReleaseUpdateLink.vue @@ -0,0 +1,61 @@ + + + + + diff --git a/web/src/features/home/HomeSummary.vue b/web/src/features/home/HomeSummary.vue index 62263512e..980d18806 100644 --- a/web/src/features/home/HomeSummary.vue +++ b/web/src/features/home/HomeSummary.vue @@ -3,10 +3,14 @@ import { computed } from 'vue' import { useI18n } from 'vue-i18n' import type { HomeBaseDto } from '@/app/resources/home' +import type { ReleaseUpdateDto } from '@/app/resources/system-update' import { formatDuration, formatInteger, formatLocalInstant, formatLocalTime } from '@/lib/format' +import HomeReleaseUpdateLink from './HomeReleaseUpdateLink.vue' + const props = defineProps<{ base: HomeBaseDto + update: ReleaseUpdateDto | null observedAtMs: number | null uptimeNowMs: number }>() @@ -52,7 +56,10 @@ const updatedTitle = computed(() =>
{{ t('home.ledger.version') }}
-
{{ base.version }}
+
+ {{ base.version }} + +
{{ t('home.ledger.uptime') }}
@@ -124,6 +131,13 @@ const updatedTitle = computed(() => .home-summary__stamp dd { margin: 0; } + +.home-summary__version { + display: inline-flex; + align-items: center; + gap: var(--space-1); +} + .home-summary__stamp dt { text-align: right; } diff --git a/web/src/features/home/HomeView.vue b/web/src/features/home/HomeView.vue index 892ae8cd3..2d5df2e59 100644 --- a/web/src/features/home/HomeView.vue +++ b/web/src/features/home/HomeView.vue @@ -8,6 +8,7 @@ import { useApiClient } from '@/api/client-context' import { useStableLoading } from '@/app/loading-state' import { healthQueryOptions } from '@/app/resources/health' import { homeBaseQueryOptions } from '@/app/resources/home' +import { systemUpdateQueryOptions } from '@/app/resources/system-update' import { homeLocation } from '@/app/route-locations' import LedgerSheet from '@/components/layout/LedgerSheet.vue' import PageFrame from '@/components/layout/PageFrame.vue' @@ -36,8 +37,12 @@ const session = useAuthSession() const route = useRoute() const router = useRouter() const { t } = useI18n() -const baseQuery = useQuery(homeBaseQueryOptions(client)) const isAccessKey = computed(() => session.state.principalType === 'access_key') +const baseQuery = useQuery(homeBaseQueryOptions(client)) +// 更新检查与首页数据解耦,仅由管理员进入首页时按需触发一次。 +const updateQuery = useQuery( + systemUpdateQueryOptions(client, () => session.state.principalType === 'admin'), +) // /api/health 不在 AccessKey 白名单里,必须前端主动 gate, // 否则 AccessKey 用户首页会挂一个永远 403 的区块。首页不轮询,进页面拉一次即可。 const healthQuery = useQuery(healthQueryOptions(client, undefined, () => !isAccessKey.value)) @@ -59,6 +64,11 @@ watch( ) const uptimeNowMS = computed(() => nowMS.value + serverClockOffsetMS.value) +const releaseUpdate = computed(() => + updateQuery.isSuccess.value && !updateQuery.isFetching.value + ? (updateQuery.data.value?.update ?? null) + : null, +) const snapshot = computed(() => { const state = statistics.state.value return state.kind === 'initial' ? null : state.snapshot @@ -155,7 +165,11 @@ onBeforeUnmount(() => window.clearInterval(uptimeTimer)) /> - +