Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion admin/admin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion admin/plugins_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions admin/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -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."},
}},
}

Expand Down
2 changes: 1 addition & 1 deletion admin/settings_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}

Expand Down
2 changes: 1 addition & 1 deletion blog/blog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
18 changes: 18 additions & 0 deletions goblog.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -27,6 +28,7 @@ import (
"strings"
"sync"
"sync/atomic"
"time"

"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
Expand Down Expand Up @@ -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)
Expand Down
27 changes: 26 additions & 1 deletion plugin/installer/installer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
36 changes: 36 additions & 0 deletions plugin/installer/installer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
24 changes: 23 additions & 1 deletion theme/installer/installer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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/<name>, setting an existing directory aside as <name>.prev until the
// swap succeeds. Any failure restores the previous directory.
Expand Down
45 changes: 45 additions & 0 deletions theme/installer/installer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
}
2 changes: 2 additions & 0 deletions tools/migrate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading