From b5eb25663210effb9029c72e7358a681c26573ae Mon Sep 17 00:00:00 2001 From: Gani Georgiev Date: Thu, 22 Oct 2026 12:59:10 +0300 Subject: [PATCH] added BugStage update command --- CHANGELOG.md | 12 +- examples/base/main.go | 10 +- plugins/ghupdate/ghupdate.go | 3 + plugins/update/release.go | 41 +++ plugins/update/release_test.go | 38 +++ plugins/update/update.go | 466 +++++++++++++++++++++++++++++++++ plugins/update/update_test.go | 38 +++ 7 files changed, 603 insertions(+), 5 deletions(-) create mode 100644 plugins/update/release.go create mode 100644 plugins/update/release_test.go create mode 100644 plugins/update/update.go create mode 100644 plugins/update/update_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 029a2dc9..ac877242 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,18 @@ ## v0.40.6 (WIP) -- @todo Replaced the GitHub update command with update from the new host. +- Replaced the GitHub update command with update from the new host in relation to [#7841](https://github.com/pocketbase/pocketbase/discussions/7841). + Note that this will be most likely the last release here on GitHub. The next time when you run update, new releases will be fetched from `https://ganigeorgiev.com` _(there is nothing there yet)_. + +- Additional fileblob bucket escape checks for just in case if the filesystem abstraction is used as standalone without the PocketBase validations. - Updated `modernc.org/sqlite` to 1.61.0 _([SQLite 3.54.1](https://sqlite.org/src/timeline?from=version-3.54.0&to=version-3.54.1&y=ci))_. -- Other minor fixes (superuser IPs description grammar fix, additional fileblob bucket escape checks for just in case if the filesystem is used as standalone without the PocketBase validations, bumped `golang.org/x/` dependencies, bumped min Go GitHub action version to 1.27.3, rotating btn loader icon alignment fix, slight dark background correction in attempt to minimize the color profile differences between Chrome and Firefox etc.) +- Bumped the min Go GitHub action version to 1.27.3 as it includes a [security fix](https://groups.google.com/g/golang-announce/c/66pojueaQek) related to the recent eMudhra CA compromise. + _I didn't rush a fix 2 days ago as the impact for most PocketBase servers is not that serious and requires to already have a MiTM, but nonetheless it is recommended to update. If you can, check also for OS/Docker image updates to make sure that you have the latest `ca-certificates` bundle._ + +- Bumped `golang.org/x/` dependencies ([#7865](https://github.com/pocketbase/pocketbase/discussions/7865)). + +- Other minor UI fixes (superuser IPs description grammar fix, rotating btn loader icon alignment fix, slight dark background correction in attempt to minimize the color profile differences between Chrome and Firefox, etc.). ## v0.40.5 diff --git a/examples/base/main.go b/examples/base/main.go index 1ea529b4..e1b0f02b 100644 --- a/examples/base/main.go +++ b/examples/base/main.go @@ -9,9 +9,9 @@ import ( "github.com/pocketbase/pocketbase" "github.com/pocketbase/pocketbase/apis" "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/plugins/ghupdate" "github.com/pocketbase/pocketbase/plugins/jsvm" "github.com/pocketbase/pocketbase/plugins/migratecmd" + "github.com/pocketbase/pocketbase/plugins/update" "github.com/pocketbase/pocketbase/tools/hook" "github.com/pocketbase/pocketbase/tools/osutils" ) @@ -100,8 +100,12 @@ func main() { Dir: migrationsDir, }) - // GitHub selfupdate - ghupdate.MustRegister(app, app.RootCmd, ghupdate.Config{}) + // update command + update.MustRegister(app, app.RootCmd, update.Config{ + BaseURL: "https://ganigeorgiev.com", + Project: "pocketbase", + Repository: "pocketbase", + }) // static route to serves files from the provided public dir // (if publicDir exists and the route path is not already defined) diff --git a/plugins/ghupdate/ghupdate.go b/plugins/ghupdate/ghupdate.go index 340af1ac..d8a85392 100644 --- a/plugins/ghupdate/ghupdate.go +++ b/plugins/ghupdate/ghupdate.go @@ -1,6 +1,9 @@ // Package ghupdate implements a new command to selfupdate the current // PocketBase executable with the latest GitHub release. // +// This is kept only for backward-compatibility and it is replaced by +// the equivalent `update` BugStage plugin. +// // Example usage: // // ghupdate.MustRegister(app, app.RootCmd, ghupdate.Config{}) diff --git a/plugins/update/release.go b/plugins/update/release.go new file mode 100644 index 00000000..8eb8f10f --- /dev/null +++ b/plugins/update/release.go @@ -0,0 +1,41 @@ +package update + +import ( + "fmt" + "net/url" + "strings" +) + +type release struct { + Id string `json:"id"` + CollectionName string `json:"collectionName"` + Channel string `json:"channel"` + Tag string `json:"tag"` + Content string `json:"content"` + Created string `json:"created"` + Updated string `json:"updated"` + Files []string `json:"files"` +} + +// findFileBySuffix returns the first available asset containing the specified suffix. +func (r *release) findFileBySuffix(suffix string) (string, error) { + if suffix != "" { + for _, filename := range r.Files { + if strings.HasSuffix(filename, suffix) { + return filename, nil + } + } + } + + return "", fmt.Errorf("failed to find release file ending with %q", suffix) +} + +// buildFilePath constructs the URL path to the release file. +func (r *release) buildFilePath(filename string) string { + return fmt.Sprintf( + "/api/files/%s/%s/%s", + url.PathEscape(r.CollectionName), + url.PathEscape(r.Id), + url.PathEscape(filename), + ) +} diff --git a/plugins/update/release_test.go b/plugins/update/release_test.go new file mode 100644 index 00000000..60bdcc90 --- /dev/null +++ b/plugins/update/release_test.go @@ -0,0 +1,38 @@ +package update + +import "testing" + +func TestReleaseFindFileBySuffix(t *testing.T) { + r := release{ + Files: []string{ + "test1.zip", + "test2.zip", + "test22.zip", + "test3.zip", + }, + } + + filename, err := r.findFileBySuffix("2.zip") + if err != nil { + t.Fatalf("Expected nil, got err: %v", err) + } + + expected := "test2.zip" + if filename != expected { + t.Fatalf("Expected file %q, got %q", expected, filename) + } +} + +func TestReleaseBuildFilePath(t *testing.T) { + r := release{ + Id: "test id", + CollectionName: "test collection", + } + + result := r.buildFilePath("test file") + + expected := "/api/files/test%20collection/test%20id/test%20file" + if result != expected { + t.Fatalf("Expected path %q, got %q", expected, result) + } +} diff --git a/plugins/update/update.go b/plugins/update/update.go new file mode 100644 index 00000000..971db0af --- /dev/null +++ b/plugins/update/update.go @@ -0,0 +1,466 @@ +// Package update implements a new command to replace the current +// PocketBase executable with the one from the latest BugStage releases. +// +// Note that it expects the fetched release to have file(s) in the following formats: +// - "*_linux_amd64.zip" +// - "*_linux_arm64.zip" +// - "*_linux_armv7.zip" +// - "*_darwin_amd64.zip" +// - "*_darwin_arm64.zip" +// - "*_windows_amd64.zip" +// - "*_windows_arm64.zip" +// +// The appropriate file is auto downloaded based on the user platform. +// If there is no file matching the user platform then an error is returned. +// +// Example plugin registration: +// +// update.MustRegister(app, app.RootCmd, update.Config{ +// BaseURL: "https://ganigeorgiev.com", +// Project: "pocketbase", +// Repository: "pocketbase", +// }) +package update + +import ( + "context" + "encoding/json/v2" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + + "github.com/fatih/color" + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/tools/archive" + "github.com/pocketbase/pocketbase/tools/osutils" + "github.com/spf13/cobra" +) + +// HttpClient is a base HTTP client interface (usually used for test purposes). +type HttpClient interface { + Do(req *http.Request) (*http.Response, error) +} + +// Config defines the config options of the update plugin. +type Config struct { + // BaseURL is the base URL of the BugStage host (or similar compatible) + // used to fetch the latest releases information. + // + // This field is required! + BaseURL string + + // Project specifies the project name of the repository. + // + // This field is required! + Project string + + // Repository specifies the name of the repository. + // + // This field is required! + Repository string + + // Channel specifies the name of the release channel to check. + // + // If not set uses the system "default" channel. + Channel string + + // ArchiveExecutable specifies the name of the executable file in the release archive + // (default to the same value as in [Config.Repository]; an additional ".exe" check is also performed as a fallback). + ArchiveExecutable string + + // Optional context to use when fetching and downloading the latest release. + Context context.Context + + // The HTTP client to use when fetching and downloading the latest release. + // Defaults to [http.DefaultClient]. + HttpClient HttpClient +} + +// MustRegister registers the update plugin to the provided app instance +// and panic if it fails. +func MustRegister(app core.App, rootCmd *cobra.Command, config Config) { + if err := Register(app, rootCmd, config); err != nil { + panic(err) + } +} + +// Register registers the update plugin to the provided app instance. +func Register(app core.App, rootCmd *cobra.Command, config Config) error { + if config.BaseURL == "" { + return errors.New("update.Config.BaseURL is required and needs to point to a BugStage instance") + } + + if config.Project == "" { + return errors.New("update.Config.Project is required") + } + + if config.Repository == "" { + return errors.New("update.Config.Repository is required") + } + + p := &plugin{ + app: app, + currentVersion: rootCmd.Version, + config: config, + } + + p.config.BaseURL = strings.TrimRight(p.config.BaseURL, "/") + + if p.config.ArchiveExecutable == "" { + p.config.ArchiveExecutable = p.config.Repository + } + + if p.config.Channel == "" { + p.config.Channel = "default" + } + + if p.config.HttpClient == nil { + p.config.HttpClient = http.DefaultClient + } + + if p.config.Context == nil { + p.config.Context = context.Background() + } + + rootCmd.AddCommand(p.updateCmd()) + + return nil +} + +type plugin struct { + app core.App + config Config + currentVersion string +} + +func (p *plugin) updateCmd() *cobra.Command { + var withBackup bool + + command := &cobra.Command{ + Use: "update", + Short: "Automatically updates the current app executable with the latest available version", + SilenceUsage: true, + RunE: func(command *cobra.Command, args []string) error { + var needConfirm bool + if isMaybeRunningInDocker() { + needConfirm = true + color.Yellow("NB! It seems that you are in a Docker container.") + color.Yellow("The update command may not work as expected in this context because usually the version of the app is managed by the container image itself.") + } else if isMaybeRunningInNixOS() { + needConfirm = true + color.Yellow("NB! It seems that you are in a NixOS.") + color.Yellow("Due to the non-standard filesystem implementation of the environment, the update command may not work as expected.") + } + + if needConfirm { + confirm := osutils.YesNoPrompt("Do you want to proceed with the update?", false) + if !confirm { + fmt.Println("The command has been cancelled.") + return nil + } + } + + return p.update(withBackup) + }, + } + + command.PersistentFlags().BoolVar( + &withBackup, + "backup", + true, + "Creates a pb_data backup at the end of the update process", + ) + + return command +} + +func (p *plugin) update(withBackup bool) error { + color.Yellow("Fetching release information...") + + latest, err := p.fetchLatestRelease() + if err != nil { + return err + } + + if compareVersions(strings.TrimPrefix(p.currentVersion, "v"), strings.TrimPrefix(latest.Tag, "v")) <= 0 { + color.Green("You already have the latest version %s.", p.currentVersion) + return nil + } + + suffix := archiveSuffix(runtime.GOOS, runtime.GOARCH) + if suffix == "" { + return errors.New("unsupported platform") + } + + filename, err := latest.findFileBySuffix(suffix) + if err != nil { + return err + } + + fileURL := p.config.BaseURL + latest.buildFilePath(filename) + + releaseDir := filepath.Join(p.app.DataDir(), core.LocalTempDirName) + defer os.RemoveAll(releaseDir) + + color.Yellow("Downloading %s...", filename) + + // download the release asset + assetZip := filepath.Join(releaseDir, filename) + if err := downloadFile(p.config.Context, p.config.HttpClient, fileURL, assetZip); err != nil { + return err + } + + color.Yellow("Extracting %s...", filename) + + extractDir := filepath.Join(releaseDir, "extracted_"+filename) + defer os.RemoveAll(extractDir) + + if err := archive.Extract(assetZip, extractDir); err != nil { + return err + } + + color.Yellow("Replacing the executable...") + + oldExec, err := os.Executable() + if err != nil { + return err + } + renamedOldExec := oldExec + ".old" + defer os.Remove(renamedOldExec) + + newExec := filepath.Join(extractDir, p.config.ArchiveExecutable) + if _, err := os.Stat(newExec); err != nil { + // try again with an .exe extension + newExec = newExec + ".exe" + if _, fallbackErr := os.Stat(newExec); fallbackErr != nil { + return fmt.Errorf("the executable in the extracted path is missing or it is inaccessible: %v, %v", err, fallbackErr) + } + } + + // rename the current executable + if err := os.Rename(oldExec, renamedOldExec); err != nil { + return fmt.Errorf("failed to rename the current executable: %w", err) + } + + tryToRevertExecChanges := func() { + if revertErr := os.Rename(renamedOldExec, oldExec); revertErr != nil { + p.app.Logger().Debug( + "Failed to revert executable", + slog.String("old", renamedOldExec), + slog.String("new", oldExec), + slog.String("error", revertErr.Error()), + ) + } + } + + // replace with the extracted binary + if err := os.Rename(newExec, oldExec); err != nil { + tryToRevertExecChanges() + return fmt.Errorf("failed replacing the executable: %w", err) + } + + // backup existing pb_data + if withBackup { + color.Yellow("Creating pb_data backup...") + + backupName := fmt.Sprintf("@update_%s.zip", latest.Tag) + if err := p.app.CreateBackup(p.config.Context, backupName); err != nil { + tryToRevertExecChanges() + return err + } + } + + // print the release notes (if any) + if latest.Content != "" { + fmt.Print("\n") + color.Cyan("%s release notes:", latest.Tag) + color.Cyan(strings.TrimSpace(latest.Content)) + fmt.Print("\n") + } + + color.HiBlack("---") + fmt.Print("\n") + color.Green("Update completed successfully! You can start the executable as usual.") + fmt.Print("\n") + + return nil +} + +func (p *plugin) fetchLatestRelease() (*release, error) { + queryParams := url.Values{} + queryParams.Set("perPage", "1") + queryParams.Set("skipTotal", "1") + queryParams.Set("sort", "-semver") + queryParams.Set("filter", fmt.Sprintf( + "channel.name=%q && channel.repository.name=%q && channel.repository.project.name=%q", + p.config.Channel, + p.config.Repository, + p.config.Project, + )) + + releaseURL := fmt.Sprintf("%s/api/collections/releases/records?%s", p.config.BaseURL, queryParams.Encode()) + + req, err := http.NewRequestWithContext(p.config.Context, "GET", releaseURL, nil) + if err != nil { + return nil, err + } + + res, err := p.config.HttpClient.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + rawBody, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + + // http.Client doesn't treat non 2xx responses as error + if res.StatusCode >= 400 { + return nil, fmt.Errorf( + "(%d) failed to fetch latest releases:\n%s", + res.StatusCode, + string(rawBody), + ) + } + + result := struct { + Items []*release `json:"items"` + }{} + if err := json.Unmarshal(rawBody, &result); err != nil { + return nil, err + } + + if len(result.Items) == 0 { + return nil, errors.New("no releases found") + } + + return result.Items[0], nil +} + +func downloadFile( + ctx context.Context, + client HttpClient, + url string, + destPath string, +) error { + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return err + } + + res, err := client.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + + // http.Client doesn't treat non 2xx responses as error + if res.StatusCode >= 400 { + return fmt.Errorf("(%d) failed to send download file request", res.StatusCode) + } + + // ensure that the dest parent dir(s) exist + if err := os.MkdirAll(filepath.Dir(destPath), os.ModePerm); err != nil { + return err + } + + dest, err := os.Create(destPath) + if err != nil { + return err + } + defer dest.Close() + + if _, err := io.Copy(dest, res.Body); err != nil { + return err + } + + return nil +} + +func archiveSuffix(goos, goarch string) string { + switch goos { + case "linux": + switch goarch { + case "amd64": + return "_linux_amd64.zip" + case "arm64": + return "_linux_arm64.zip" + case "arm": + return "_linux_armv7.zip" + } + case "darwin": + switch goarch { + case "amd64": + return "_darwin_amd64.zip" + case "arm64": + return "_darwin_arm64.zip" + } + case "windows": + switch goarch { + case "amd64": + return "_windows_amd64.zip" + case "arm64": + return "_windows_arm64.zip" + } + } + + return "" +} + +func compareVersions(a, b string) int { + aSplit := strings.Split(a, ".") + aTotal := len(aSplit) + + bSplit := strings.Split(b, ".") + bTotal := len(bSplit) + + limit := aTotal + if bTotal > aTotal { + limit = bTotal + } + + for i := 0; i < limit; i++ { + var x, y int + + if i < aTotal { + x, _ = strconv.Atoi(aSplit[i]) + } + + if i < bTotal { + y, _ = strconv.Atoi(bSplit[i]) + } + + if x < y { + return 1 // b is newer + } + + if x > y { + return -1 // a is newer + } + } + + return 0 // equal +} + +// note: not completely reliable as it may not work on all platforms +// but should at least provide a warning for the most common use cases +func isMaybeRunningInDocker() bool { + _, err := os.Stat("/.dockerenv") + return err == nil +} + +// note: untested +func isMaybeRunningInNixOS() bool { + _, err := os.Stat("/etc/NIXOS") + return err == nil +} diff --git a/plugins/update/update_test.go b/plugins/update/update_test.go new file mode 100644 index 00000000..899f48dd --- /dev/null +++ b/plugins/update/update_test.go @@ -0,0 +1,38 @@ +package update + +import "testing" + +func TestCompareVersions(t *testing.T) { + scenarios := []struct { + a string + b string + expected int + }{ + {"", "", 0}, + {"0", "", 0}, + {"1", "1.0.0", 0}, + {"1.1", "1.1.0", 0}, + {"1.1", "1.1.1", 1}, + {"1.1", "1.0.1", -1}, + {"1.0", "1.0.1", 1}, + {"1.10", "1.9", -1}, + {"1.2", "1.12", 1}, + {"3.2", "1.6", -1}, + {"0.0.2", "0.0.1", -1}, + {"0.16.2", "0.17.0", 1}, + {"1.15.0", "0.16.1", -1}, + {"1.2.9", "1.2.10", 1}, + {"3.2", "4.0", 1}, + {"3.2.4", "3.2.3", -1}, + } + + for _, s := range scenarios { + t.Run(s.a+"VS"+s.b, func(t *testing.T) { + result := compareVersions(s.a, s.b) + + if result != s.expected { + t.Fatalf("Expected %q vs %q to result in %d, got %d", s.a, s.b, s.expected, result) + } + }) + } +}