From bf601627507bc63c94baf6be5182491466078667 Mon Sep 17 00:00:00 2001 From: Jason Ernst Date: Sat, 17 Oct 2026 02:29:08 -0700 Subject: [PATCH] Auto-update installed themes and plugins at startup UpdateAvailable now requires the directory release to be compatible with the running goblog, so the admin page stops offering updates Update() would refuse with ErrIncompatible. Both installers gain AutoUpdate(ctx), which applies every compatible update and logs each result. goblog runs it once at startup in the background: themes by default (auto_update_themes), plugins only when auto_update_plugins is turned on. Closes #641 Co-Authored-By: Claude Opus 5.5 --- README.md | 2 ++ admin/settings.go | 2 ++ admin/settings_test.go | 2 +- goblog.go | 18 ++++++++++++ plugin/installer/installer.go | 27 +++++++++++++++++- plugin/installer/installer_test.go | 36 ++++++++++++++++++++++++ theme/installer/installer.go | 24 +++++++++++++++- theme/installer/installer_test.go | 45 ++++++++++++++++++++++++++++++ tools/migrate.go | 2 ++ 9 files changed, 155 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index a16cb99f..3c7284b9 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,8 @@ Full guide: [goblog.live/docs/writing-a-theme](https://www.goblog.live/docs/writ **Admin → Themes** browses the [theme directory](https://www.goblog.live/themes), installs a theme into `themes/installed/` (bind-mount it in Docker, set with `THEMES_INSTALLED_DIR`, or installs vanish on restart), activates it, updates it when the directory has a newer release, and removes it. The directory URL is the `theme_directory_url` setting; see [Publishing a theme](https://www.goblog.live/docs/publishing-a-theme) to publish one. +At startup goblog updates installed directory themes to the latest release compatible with it, in the background, and logs each update or failure (a failed update leaves the theme as it was). Turn this off with **Settings → Directories → Auto-update themes**. **Auto-update plugins** does the same for directory plugins and is off by default, since a plugin runs code. + A theme is code: once activated its templates render every page, including the admin, with the same template functions and data goblog's own templates get. The directory's validation checks that a theme is well-formed, not that it is benign, and a listing on goblog.live is a maintainer's approval, not a code audit — install only themes you trust, as with plugins. ## Plugins diff --git a/admin/settings.go b/admin/settings.go index 345b1cac..6f722f60 100644 --- a/admin/settings.go +++ b/admin/settings.go @@ -69,6 +69,8 @@ var settingGroups = []settingGroupDef{ {ID: "directories", Title: "Directories", Settings: []settingMeta{ {Key: "plugin_directory_url", Label: "Plugin directory URL", Help: "Index the Plugins page installs from."}, {Key: "theme_directory_url", Label: "Theme directory URL", Help: "Index the Themes page installs from."}, + {Key: "auto_update_themes", Label: "Auto-update themes", Help: "At startup, update installed directory themes to the directory's latest compatible version. A theme whose update fails to download or parse is left as it was."}, + {Key: "auto_update_plugins", Label: "Auto-update plugins", Help: "At startup, update installed directory plugins to the directory's latest compatible version. Plugins run code, so this is off unless you trust the directory to change it unattended."}, }}, } diff --git a/admin/settings_test.go b/admin/settings_test.go index a0ac91b3..fa72809a 100644 --- a/admin/settings_test.go +++ b/admin/settings_test.go @@ -13,7 +13,7 @@ var seededSettings = map[string]string{ "site_title": "text", "site_subtitle": "text", "site_logo_letters": "text", "site_tags": "text", "landing_page_image": "file", "favicon": "file", "custom_header_code": "textarea", "custom_footer_code": "textarea", "theme": "text", "robots_tag": "text", "site_url": "text", "comments_require_login": "checkbox", - "plugin_directory_url": "text", "theme_directory_url": "text", + "plugin_directory_url": "text", "theme_directory_url": "text", "auto_update_themes": "checkbox", "auto_update_plugins": "checkbox", "site_description": "textarea", "site_image": "file", "show_powered_by": "checkbox", } diff --git a/goblog.go b/goblog.go index edb55c79..2b00a6c0 100644 --- a/goblog.go +++ b/goblog.go @@ -3,6 +3,7 @@ package main //this implements https://jsonapi.org/format/ as best as possible import ( + "context" "github.com/joho/godotenv" "goblog/admin" "goblog/auth" @@ -27,6 +28,7 @@ import ( "strings" "sync" "sync/atomic" + "time" "github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions/cookie" @@ -546,6 +548,22 @@ func main() { } loadTheme(initialTheme) + // Auto-update in the background so an unreachable directory never + // delays startup. Themes default on (an old theme can lock the admin + // out after a goblog upgrade, #640); plugins run code, so opt-in. + if db != nil { + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + if _blog.SettingValue("auto_update_themes", "true") == "true" { + themeInstaller.AutoUpdate(ctx) + } + if _blog.SettingValue("auto_update_plugins", "false") == "true" { + pluginInstaller.AutoUpdate(ctx) + } + }() + } + // Wire up hot-reload callback so theme changes take effect without restart _admin.OnThemeChange = func(name string) { loadTheme(name) diff --git a/plugin/installer/installer.go b/plugin/installer/installer.go index 96fad110..e62b103a 100644 --- a/plugin/installer/installer.go +++ b/plugin/installer/installer.go @@ -273,7 +273,9 @@ func (i *Installer) Status() Status { row.Path = d.Path if e, ok := byName[p.Name()]; ok { row.LatestVersion = e.Version - row.UpdateAvailable = Newer(e.Version, p.Version()) + // An update this goblog cannot run is not offered: Update + // would refuse it with ErrIncompatible. + row.UpdateAvailable = Newer(e.Version, p.Version()) && Compatible(i.Version, e.MinGoblogVersion) } } st.Installed = append(st.Installed, row) @@ -514,6 +516,29 @@ func (i *Installer) Update(ctx context.Context, name string) (Result, error) { return Result{Name: e.Name, Version: e.Version, Message: fmt.Sprintf("Updated %s to v%s", e.DisplayName, e.Version)}, nil } +// AutoUpdate updates every installed dynamic plugin that Status says has a +// compatible update. A failure is logged and leaves that plugin as it was +// (Update rolls back); it never stops the others. +func (i *Installer) AutoUpdate(ctx context.Context) []Result { + if !i.WasmEnabled { + return nil + } + var done []Result + for _, row := range i.Status().Installed { + if !row.UpdateAvailable { + continue + } + res, err := i.Update(ctx, row.Name) + if err != nil { + log.Printf("Plugin auto-update: %s v%s → v%s failed, keeping the installed version: %v", row.Name, row.Version, row.LatestVersion, err) + continue + } + log.Printf("Plugin auto-update: %s", res.Message) + done = append(done, res) + } + return done +} + // Uninstall unregisters a dynamic plugin, releases its instance and deletes // its files, settings, stored data and the pages it declared. func (i *Installer) Uninstall(name string) error { diff --git a/plugin/installer/installer_test.go b/plugin/installer/installer_test.go index 5917768c..e9597af1 100644 --- a/plugin/installer/installer_test.go +++ b/plugin/installer/installer_test.go @@ -1031,3 +1031,39 @@ type compiledPlugin struct{ plugin.BasePlugin } func (compiledPlugin) Name() string { return "scholar" } func (compiledPlugin) DisplayName() string { return "Scholar" } func (compiledPlugin) Version() string { return "1.0.0" } + +// TestAutoUpdate: an update this goblog cannot run is neither offered nor +// attempted; a compatible one is applied. +func TestAutoUpdate(t *testing.T) { + f := newFixture(t) + f.entries = []map[string]any{f.wasmEntry("echo", "1.2.3", nil)} + inst := newInstaller(t, f) + ctx := context.Background() + if _, err := inst.Install(ctx, "echo"); err != nil { + t.Fatal(err) + } + + future := f.wasmEntryV2(nil) + future["min_goblog_version"] = "9.0.0" + f.entries = []map[string]any{future} + if err := inst.Refresh(); err != nil { + t.Fatal(err) + } + if st := inst.Status(); st.Installed[0].UpdateAvailable { + t.Errorf("an incompatible update must not be offered: %+v", st.Installed[0]) + } + if res := inst.AutoUpdate(ctx); len(res) != 0 { + t.Errorf("an incompatible update must not be applied: %+v", res) + } + + f.entries = []map[string]any{f.wasmEntryV2(nil)} + if err := inst.Refresh(); err != nil { + t.Fatal(err) + } + if res := inst.AutoUpdate(ctx); len(res) != 1 || res[0].Version != "1.2.4" { + t.Errorf("auto-update = %+v", res) + } + if dyn := inst.Registry.Dynamic(); dyn[0].Version != "1.2.4" { + t.Errorf("registry = %+v", dyn) + } +} diff --git a/theme/installer/installer.go b/theme/installer/installer.go index 706e55cf..dba7d49e 100644 --- a/theme/installer/installer.go +++ b/theme/installer/installer.go @@ -241,7 +241,9 @@ func (i *Installer) Status() Status { } if e, ok := byName[name]; ok && !row.Builtin { row.LatestVersion = e.Version - row.UpdateAvailable = row.Version != "" && pinstaller.Newer(e.Version, row.Version) + // An update this goblog cannot run is not offered: Update would + // refuse it with ErrIncompatible. + row.UpdateAvailable = row.Version != "" && pinstaller.Newer(e.Version, row.Version) && pinstaller.Compatible(i.Version, e.MinGoblogVersion) row.ScreenshotURL = e.ScreenshotURL } if _, ok := theme.Screenshot(name); ok { @@ -406,6 +408,26 @@ func (i *Installer) Update(ctx context.Context, name string) (Result, error) { return Result{Name: e.Name, Version: e.Version, Message: fmt.Sprintf("Updated %s to v%s", e.DisplayName, e.Version)}, nil } +// AutoUpdate updates every installed directory theme that Status says has +// a compatible update. A failure is logged and leaves that theme as it +// was; it never stops the others. +func (i *Installer) AutoUpdate(ctx context.Context) []Result { + var done []Result + for _, row := range i.Status().Installed { + if !row.UpdateAvailable { + continue + } + res, err := i.Update(ctx, row.Name) + if err != nil { + log.Printf("Theme auto-update: %s v%s → v%s failed, keeping the installed version: %v", row.Name, row.Version, row.LatestVersion, err) + continue + } + log.Printf("Theme auto-update: %s", res.Message) + done = append(done, res) + } + return done +} + // place writes files into a temp dir under Dir and swaps it into // Dir/, setting an existing directory aside as .prev until the // swap succeeds. Any failure restores the previous directory. diff --git a/theme/installer/installer_test.go b/theme/installer/installer_test.go index 676f015c..3648b905 100644 --- a/theme/installer/installer_test.go +++ b/theme/installer/installer_test.go @@ -567,3 +567,48 @@ func TestSweep(t *testing.T) { t.Errorf("sweep of a missing root: %v", err) } } + +// TestAutoUpdate: a compatible update is applied; an incompatible one is +// neither offered nor attempted; one whose templates do not parse leaves +// the installed theme in place. +func TestAutoUpdate(t *testing.T) { + h := newHarness(t) + ctx := context.Background() + if _, err := h.inst.Install(ctx, "ocean"); err != nil { + t.Fatal(err) + } + read := func() string { + b, _ := os.ReadFile(filepath.Join(h.root, "ocean", "templates", "home.html")) + return string(b) + } + setV110 := func(home, min string) { + h.zips["/o/ocean/archive/refs/tags/v1.1.0.zip"] = zipOf(t, "ocean-1.1.0/", map[string]string{"templates/home.html": home}) + h.index[0].Version = "1.1.0" + h.index[0].DownloadURL = h.srv.URL + "/o/ocean/archive/refs/tags/v1.1.0.zip" + h.index[0].SHA256 = registry.ContentHash(map[string][]byte{"templates/home.html": []byte(home)}) + h.index[0].MinGoblogVersion = min + if err := h.inst.Refresh(); err != nil { + t.Fatal(err) + } + } + + setV110("ocean 2", "9.0.0") + for _, i := range h.inst.Status().Installed { + if i.Name == "ocean" && i.UpdateAvailable { + t.Errorf("an incompatible update must not be offered: %+v", i) + } + } + if res := h.inst.AutoUpdate(ctx); len(res) != 0 || read() != "ocean" { + t.Errorf("incompatible update applied: %+v %q", res, read()) + } + + setV110("{{ if }}", "0.5.0") + if res := h.inst.AutoUpdate(ctx); len(res) != 0 || read() != "ocean" { + t.Errorf("a theme that does not parse must leave the old one in place: %+v %q", res, read()) + } + + setV110("ocean 2", "0.5.0") + if res := h.inst.AutoUpdate(ctx); len(res) != 1 || res[0].Version != "1.1.0" || read() != "ocean 2" { + t.Errorf("auto-update = %+v %q", res, read()) + } +} diff --git a/tools/migrate.go b/tools/migrate.go index 73decb18..d6c7dfac 100644 --- a/tools/migrate.go +++ b/tools/migrate.go @@ -339,6 +339,8 @@ func seedDefaultSettings(db *gorm.DB) { {Key: "comments_require_login", Type: "checkbox", Value: "true"}, {Key: "plugin_directory_url", Type: "text", Value: "https://www.goblog.live/plugins/index.json"}, {Key: "theme_directory_url", Type: "text", Value: "https://www.goblog.live/themes/index.json"}, + {Key: "auto_update_themes", Type: "checkbox", Value: "true"}, + {Key: "auto_update_plugins", Type: "checkbox", Value: "false"}, } for _, s := range defaults { db.Where(map[string]any{"key": s.Key}).FirstOrCreate(&s)