diff --git a/internal/ghupdate/ghupdate.go b/internal/ghupdate/ghupdate.go index 6a068a2b..deb61b01 100644 --- a/internal/ghupdate/ghupdate.go +++ b/internal/ghupdate/ghupdate.go @@ -110,21 +110,13 @@ func (p *updater) update() (updated bool, err error) { } var latest *release - var useMirror bool - // Determine the API endpoint based on UseMirror flag - apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", p.config.Owner, p.config.Repo) + apiURL := getApiURL(p.config.UseMirror, p.config.Owner, p.config.Repo) if p.config.UseMirror { - useMirror = true - apiURL = fmt.Sprintf("https://gh.beszel.dev/repos/%s/%s/releases/latest?api=true", p.config.Owner, p.config.Repo) ColorPrint(ColorYellow, "Using mirror for update.") } - latest, err = fetchLatestRelease( - p.config.Context, - p.config.HttpClient, - apiURL, - ) + latest, err = FetchLatestRelease(p.config.Context, p.config.HttpClient, apiURL) if err != nil { return false, err } @@ -150,7 +142,7 @@ func (p *updater) update() (updated bool, err error) { // download the release asset assetPath := filepath.Join(releaseDir, asset.Name) - if err := downloadFile(p.config.Context, p.config.HttpClient, asset.DownloadUrl, assetPath, useMirror); err != nil { + if err := downloadFile(p.config.Context, p.config.HttpClient, asset.DownloadUrl, assetPath, p.config.UseMirror); err != nil { return false, err } @@ -226,11 +218,11 @@ func (p *updater) update() (updated bool, err error) { return true, nil } -func fetchLatestRelease( - ctx context.Context, - client HttpClient, - url string, -) (*release, error) { +func FetchLatestRelease(ctx context.Context, client HttpClient, url string) (*release, error) { + if url == "" { + url = getApiURL(false, "henrygd", "beszel") + } + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return nil, err @@ -375,3 +367,10 @@ func isGlibc() bool { } return false } + +func getApiURL(useMirror bool, owner, repo string) string { + if useMirror { + return fmt.Sprintf("https://gh.beszel.dev/repos/%s/%s/releases/latest?api=true", owner, repo) + } + return fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", owner, repo) +} diff --git a/internal/hub/hub.go b/internal/hub/hub.go index 4f9f4be6..5a9761da 100644 --- a/internal/hub/hub.go +++ b/internal/hub/hub.go @@ -2,6 +2,7 @@ package hub import ( + "context" "crypto/ed25519" "encoding/pem" "errors" @@ -14,8 +15,10 @@ import ( "strings" "time" + "github.com/blang/semver" "github.com/henrygd/beszel" "github.com/henrygd/beszel/internal/alerts" + "github.com/henrygd/beszel/internal/ghupdate" "github.com/henrygd/beszel/internal/hub/config" "github.com/henrygd/beszel/internal/hub/heartbeat" "github.com/henrygd/beszel/internal/hub/systems" @@ -191,6 +194,36 @@ func (h *Hub) registerMiddlewares(se *core.ServeEvent) { } } +type UpdateInfo struct { + lastCheck time.Time + Version string `json:"v"` + Url string `json:"url"` +} + +func (info *UpdateInfo) getUpdate(e *core.RequestEvent) error { + if time.Since(info.lastCheck) < 6*time.Hour { + return e.JSON(http.StatusOK, info) + } + latestRelease, err := ghupdate.FetchLatestRelease(context.Background(), http.DefaultClient, "") + if err != nil { + return err + } + currentVersion, err := semver.Parse(strings.TrimPrefix(beszel.Version, "v")) + if err != nil { + return err + } + latestVersion, err := semver.Parse(strings.TrimPrefix(latestRelease.Tag, "v")) + if err != nil { + return err + } + info.lastCheck = time.Now() + if latestVersion.GT(currentVersion) { + info.Version = strings.TrimPrefix(latestRelease.Tag, "v") + info.Url = latestRelease.Url + } + return e.JSON(http.StatusOK, info) +} + // registerApiRoutes registers custom API routes func (h *Hub) registerApiRoutes(se *core.ServeEvent) error { // auth protected routes @@ -209,9 +242,13 @@ func (h *Hub) registerApiRoutes(se *core.ServeEvent) error { return e.JSON(http.StatusOK, map[string]bool{"firstRun": err == nil && total == 0}) }) // get public key and version - apiAuth.GET("/getkey", func(e *core.RequestEvent) error { - return e.JSON(http.StatusOK, map[string]string{"key": h.pubKey, "v": beszel.Version}) - }) + apiAuth.GET("/info", h.getInfo) + apiAuth.GET("/getkey", h.getInfo) // deprecated - keep for compatibility w/ integrations + // check for updates + if optIn, _ := GetEnv("CHECK_UPDATES"); optIn == "true" { + var updateInfo UpdateInfo + apiAuth.GET("/update", updateInfo.getUpdate) + } // send test notification apiAuth.POST("/test-notification", h.SendTestNotification) // heartbeat status and test @@ -240,6 +277,23 @@ func (h *Hub) registerApiRoutes(se *core.ServeEvent) error { return nil } +// getInfo returns data needed by authenticated users, such as the public key and current version +func (h *Hub) getInfo(e *core.RequestEvent) error { + type infoResponse struct { + Key string `json:"key"` + Version string `json:"v"` + CheckUpdate bool `json:"cu"` + } + info := infoResponse{ + Key: h.pubKey, + Version: beszel.Version, + } + if optIn, _ := GetEnv("CHECK_UPDATES"); optIn == "true" { + info.CheckUpdate = true + } + return e.JSON(http.StatusOK, info) +} + // GetUniversalToken handles the universal token API endpoint (create, read, delete) func (h *Hub) getUniversalToken(e *core.RequestEvent) error { tokenMap := universalTokenMap.GetMap() diff --git a/internal/hub/hub_test.go b/internal/hub/hub_test.go index a7a36e19..a43243ca 100644 --- a/internal/hub/hub_test.go +++ b/internal/hub/hub_test.go @@ -606,6 +606,17 @@ func TestApiRoutesAuthentication(t *testing.T) { ExpectedContent: []string{"\"key\":", "\"v\":"}, TestAppFactory: testAppFactory, }, + { + Name: "GET /info - should return the same as /getkey", + Method: http.MethodGet, + URL: "/api/beszel/info", + Headers: map[string]string{ + "Authorization": userToken, + }, + ExpectedStatus: 200, + ExpectedContent: []string{"\"key\":", "\"v\":"}, + TestAppFactory: testAppFactory, + }, { Name: "GET /first-run - no auth should succeed", Method: http.MethodGet, diff --git a/internal/site/src/components/footer-repo-link.tsx b/internal/site/src/components/footer-repo-link.tsx index 70368d43..2106b120 100644 --- a/internal/site/src/components/footer-repo-link.tsx +++ b/internal/site/src/components/footer-repo-link.tsx @@ -1,7 +1,11 @@ +import { useStore } from "@nanostores/react" import { GithubIcon } from "lucide-react" +import { $newVersion } from "@/lib/stores" import { Separator } from "./ui/separator" +import { Trans } from "@lingui/react/macro" export function FooterRepoLink() { + const newVersion = useStore($newVersion) return (