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/admin_test.go b/admin/admin_test.go index e95add91..1142e57a 100644 --- a/admin/admin_test.go +++ b/admin/admin_test.go @@ -3,7 +3,7 @@ package admin_test import ( "bytes" "encoding/json" - "goblog/theme" + "github.com/goblogplatform/goblog/theme" "github.com/goblogplatform/goblog/admin" "github.com/goblogplatform/goblog/auth" diff --git a/admin/plugins_test.go b/admin/plugins_test.go index 17f1dbd7..063ddf17 100644 --- a/admin/plugins_test.go +++ b/admin/plugins_test.go @@ -5,7 +5,7 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" - "goblog/theme" + "github.com/goblogplatform/goblog/theme" "html/template" "net/http" "net/http/httptest" diff --git a/admin/settings.go b/admin/settings.go index 05a0ed64..8a9cd0ae 100644 --- a/admin/settings.go +++ b/admin/settings.go @@ -74,6 +74,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 acb5c20d..72069485 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/blog/blog_test.go b/blog/blog_test.go index 464658cc..93729aa5 100644 --- a/blog/blog_test.go +++ b/blog/blog_test.go @@ -4,7 +4,7 @@ import ( "bytes" "encoding/json" "fmt" - "goblog/theme" + "github.com/goblogplatform/goblog/theme" "github.com/goblogplatform/goblog/admin" "github.com/goblogplatform/goblog/auth" diff --git a/goblog.go b/goblog.go index d5e1cac8..33ba9685 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/goblogplatform/goblog/admin" "github.com/goblogplatform/goblog/auth" "github.com/goblogplatform/goblog/blog" @@ -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 12dea344..127755d1 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 ce7737e5..7dfbcc34 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 13b56b2e..af72ccb2 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 ab3c7a2e..202f5a51 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 2767918b..47372d9b 100644 --- a/tools/migrate.go +++ b/tools/migrate.go @@ -344,6 +344,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)