From 67527a71476bbb86a417b5dae48aa998883065f2 Mon Sep 17 00:00:00 2001 From: Jason Ernst Date: Mon, 16 Mar 2026 19:53:55 -0700 Subject: [PATCH 01/12] Implement plugin system with compiled-in and dynamic plugin support Adds a plugin architecture that allows extending goblog without forking. Plugins can inject HTML into templates, provide data to templates, register scheduled background jobs, and define their own settings in the admin UI. Plugin interface (plugin/plugin.go): - Name, DisplayName, Version for identity - Settings() for plugin-specific configuration - ScheduledJobs() for periodic background tasks - TemplateHead/TemplateFooter for HTML injection - TemplateData for template data enrichment - BasePlugin provides no-op defaults Registry (plugin/registry.go): - Register compiled-in plugins - Init seeds plugin settings, calls OnInit - StartScheduledJobs launches background goroutines - InjectTemplateData enriches template data before render - GetAllSettings for admin UI - Gin middleware stores registry on context Dynamic loading (plugin/loader.go): - Yaegi Go interpreter loads .go files from plugins/dynamic/ - Dynamic plugins implement the same Plugin interface - No recompilation needed for dynamic plugins Integration: - Blog.Render() helper wraps c.HTML with plugin data injection - All 32 c.HTML calls in blog.go replaced with b.Render - Plugin middleware added to router in goblog.go - Template injection points in all theme headers/footers - Plugin settings section on admin settings page Proof of concept: Google Analytics plugin (plugins/analytics/) - Configurable measurement ID and enable/disable toggle - Injects GA script into when enabled Closes #480 Co-Authored-By: Claude Opus 4.6 (1M context) --- admin/admin.go | 18 +- blog/blog.go | 77 +++++--- go.mod | 2 + go.sum | 2 + goblog.go | 12 ++ plugin/loader.go | 74 ++++++++ plugin/plugin.go | 64 +++++++ plugin/registry.go | 187 +++++++++++++++++++ plugin/registry_test.go | 140 ++++++++++++++ plugin/symbols.go | 14 ++ plugins/analytics/analytics.go | 60 ++++++ themes/default/templates/admin_settings.html | 26 ++- themes/default/templates/footer.html | 1 + themes/default/templates/header.html | 1 + themes/forest/templates/footer.html | 1 + themes/forest/templates/header.html | 1 + themes/minimal/templates/footer.html | 1 + themes/minimal/templates/header.html | 1 + www/js/admin-script.js | 29 +++ 19 files changed, 676 insertions(+), 35 deletions(-) create mode 100644 plugin/loader.go create mode 100644 plugin/plugin.go create mode 100644 plugin/registry.go create mode 100644 plugin/registry_test.go create mode 100644 plugin/symbols.go create mode 100644 plugins/analytics/analytics.go diff --git a/admin/admin.go b/admin/admin.go index 5d0c4ab..55a4945 100644 --- a/admin/admin.go +++ b/admin/admin.go @@ -4,6 +4,7 @@ import ( "fmt" "goblog/auth" "goblog/blog" + gplugin "goblog/plugin" "log" "net/http" "net/url" @@ -56,6 +57,16 @@ func ListThemes() []string { return themes } +// getPluginSettings retrieves plugin settings groups from the registry on the Gin context. +func (a *Admin) getPluginSettings(c *gin.Context) interface{} { + if reg, exists := c.Get("plugin_registry"); exists { + if r, ok := reg.(*gplugin.Registry); ok { + return r.GetAllSettings() + } + } + return nil +} + func (a *Admin) UpdateDb(db *gorm.DB) { a.db = &db } @@ -619,9 +630,10 @@ func (a *Admin) AdminSettings(c *gin.Context) { "version": a.version, "recent": a.b.GetLatest(), "admin_page": true, - "settings": a.b.GetSettings(), - "nav_pages": a.b.GetNavPages(), - "themes": ListThemes(), + "settings": a.b.GetSettings(), + "nav_pages": a.b.GetNavPages(), + "themes": ListThemes(), + "plugin_settings": a.getPluginSettings(c), }) } diff --git a/blog/blog.go b/blog/blog.go index 7480297..7605614 100644 --- a/blog/blog.go +++ b/blog/blog.go @@ -56,6 +56,21 @@ func (b *Blog) IsDbNil() bool { return (*b.db) == nil } +// Render wraps c.HTML with plugin data injection. If a plugin registry +// is available on the Gin context, it enriches the template data with +// plugin_head_html, plugin_footer_html, and plugins data. +func (b *Blog) Render(c *gin.Context, code int, templateName string, data gin.H) { + if reg, exists := c.Get("plugin_registry"); exists { + type injector interface { + InjectTemplateData(c *gin.Context, templateName string, data gin.H) gin.H + } + if r, ok := reg.(injector); ok { + data = r.InjectTemplateData(c, templateName, data) + } + } + c.HTML(code, templateName, data) +} + // sortArticlesByDateDesc sorts scholar articles by publication date in descending order. func sortArticlesByDateDesc(articles []*scholar.Article) { sort.Slice(articles, func(i, j int) bool { @@ -416,7 +431,7 @@ func (b *Blog) getPostByTypeAndParams(typeSlug string, year int, month int, day // PostTypeListing renders the listing page for a post type func (b *Blog) PostTypeListing(c *gin.Context, pt *PostType) { posts := b.GetPostsByType(pt.ID, false) - c.HTML(http.StatusOK, "post_type_listing.html", gin.H{ + b.Render(c, http.StatusOK, "post_type_listing.html", gin.H{ "logged_in": b.auth.IsLoggedIn(c), "is_admin": b.auth.IsAdmin(c), "post_type": pt, @@ -441,7 +456,7 @@ func (b *Blog) DynamicPage(c *gin.Context, page *Page) { } else { posts = b.GetPosts(false) } - c.HTML(http.StatusOK, "page_writing.html", gin.H{ + b.Render(c, http.StatusOK, "page_writing.html", gin.H{ "logged_in": b.auth.IsLoggedIn(c), "is_admin": b.auth.IsAdmin(c), "posts": posts, @@ -458,7 +473,7 @@ func (b *Blog) DynamicPage(c *gin.Context, page *Page) { if err == nil { sortArticlesByDateDesc(articles) b.scholar.SaveCache("profiles.json", "articles.json") - c.HTML(http.StatusOK, "page_research.html", gin.H{ + b.Render(c, http.StatusOK, "page_research.html", gin.H{ "logged_in": b.auth.IsLoggedIn(c), "is_admin": b.auth.IsAdmin(c), "page": page, @@ -471,7 +486,7 @@ func (b *Blog) DynamicPage(c *gin.Context, page *Page) { "nav_pages": navPages, }) } else { - c.HTML(http.StatusOK, "page_research.html", gin.H{ + b.Render(c, http.StatusOK, "page_research.html", gin.H{ "logged_in": b.auth.IsLoggedIn(c), "is_admin": b.auth.IsAdmin(c), "page": page, @@ -486,7 +501,7 @@ func (b *Blog) DynamicPage(c *gin.Context, page *Page) { }) } case PageTypeTags: - c.HTML(http.StatusOK, "page_tags.html", gin.H{ + b.Render(c, http.StatusOK, "page_tags.html", gin.H{ "logged_in": b.auth.IsLoggedIn(c), "is_admin": b.auth.IsAdmin(c), "tags": b.getTags(), @@ -501,7 +516,7 @@ func (b *Blog) DynamicPage(c *gin.Context, page *Page) { case PageTypeArchives: yearKeys, byYear := b.getArchivesByYear() monthKeys, byYearMonth := b.getArchivesByYearMonth() - c.HTML(http.StatusOK, "page_archives.html", gin.H{ + b.Render(c, http.StatusOK, "page_archives.html", gin.H{ "logged_in": b.auth.IsLoggedIn(c), "is_admin": b.auth.IsAdmin(c), "yearKeys": yearKeys, @@ -517,7 +532,7 @@ func (b *Blog) DynamicPage(c *gin.Context, page *Page) { "nav_pages": navPages, }) default: // about, custom - c.HTML(http.StatusOK, "page_content.html", gin.H{ + b.Render(c, http.StatusOK, "page_content.html", gin.H{ "logged_in": b.auth.IsLoggedIn(c), "is_admin": b.auth.IsAdmin(c), "page": page, @@ -534,7 +549,7 @@ func (b *Blog) DynamicPage(c *gin.Context, page *Page) { // renderAdminPost renders the admin edit view for a post, used by NoRoute for admin type-prefixed URLs func (b *Blog) renderAdminPost(c *gin.Context, post *Post) { if !b.auth.IsAdmin(c) { - c.HTML(http.StatusUnauthorized, "error.html", gin.H{ + b.Render(c, http.StatusUnauthorized, "error.html", gin.H{ "error": "Unauthorized", "description": "You are not authorized to view this page", "version": b.Version, @@ -546,7 +561,7 @@ func (b *Blog) renderAdminPost(c *gin.Context, post *Post) { }) return } - c.HTML(http.StatusOK, "post-admin.html", gin.H{ + b.Render(c, http.StatusOK, "post-admin.html", gin.H{ "logged_in": b.auth.IsLoggedIn(c), "is_admin": b.auth.IsAdmin(c), "post": post, @@ -566,7 +581,7 @@ func (b *Blog) renderAdminPost(c *gin.Context, post *Post) { func (b *Blog) renderPost(c *gin.Context, post *Post) { b.TrackReferer(c, post.ID) if b.auth.IsAdmin(c) { - c.HTML(http.StatusOK, "post-admin.html", gin.H{ + b.Render(c, http.StatusOK, "post-admin.html", gin.H{ "logged_in": b.auth.IsLoggedIn(c), "is_admin": b.auth.IsAdmin(c), "post": post, @@ -583,7 +598,7 @@ func (b *Blog) renderPost(c *gin.Context, post *Post) { "nav_pages": b.GetNavPages(), }) } else { - c.HTML(http.StatusOK, "post.html", gin.H{ + b.Render(c, http.StatusOK, "post.html", gin.H{ "logged_in": b.auth.IsLoggedIn(c), "is_admin": b.auth.IsAdmin(c), "post": post, @@ -687,7 +702,7 @@ func (b *Blog) NoRoute(c *gin.Context) { } } - c.HTML(http.StatusNotFound, "error.html", gin.H{ + b.Render(c, http.StatusNotFound, "error.html", gin.H{ "logged_in": b.auth.IsLoggedIn(c), "is_admin": b.auth.IsAdmin(c), "error": "404: Page Not Found", @@ -710,7 +725,7 @@ func (b *Blog) Home(c *gin.Context) { if subtitle, ok := settings["site_subtitle"]; ok && subtitle.Value != "" { title = subtitle.Value } - c.HTML(http.StatusOK, "home.html", gin.H{ + b.Render(c, http.StatusOK, "home.html", gin.H{ "logged_in": b.auth.IsLoggedIn(c), "is_admin": b.auth.IsAdmin(c), "version": b.Version, @@ -726,7 +741,7 @@ func (b *Blog) Home(c *gin.Context) { // Posts is the index page for blog posts func (b *Blog) Posts(c *gin.Context) { - c.HTML(http.StatusOK, "posts.html", gin.H{ + b.Render(c, http.StatusOK, "posts.html", gin.H{ "logged_in": b.auth.IsLoggedIn(c), "is_admin": b.auth.IsAdmin(c), "posts": b.GetPosts(false), @@ -743,7 +758,7 @@ func (b *Blog) Posts(c *gin.Context) { func (b *Blog) Post(c *gin.Context) { post, err := b.GetPostObject(c) if err != nil { - c.HTML(http.StatusNotFound, "error.html", gin.H{ + b.Render(c, http.StatusNotFound, "error.html", gin.H{ "error": "Post Not Found", "description": err.Error(), "version": b.Version, @@ -773,16 +788,16 @@ func (b *Blog) Post(c *gin.Context) { data["external_backlinks"] = b.GetExternalBacklinks(post.ID) data["post_types"] = b.GetPostTypes() } - c.HTML(http.StatusOK, "post.html", data) + b.Render(c, http.StatusOK, "post.html", data) //if b.auth.IsAdmin(c) { - // c.HTML(http.StatusOK, "post-admin.html", gin.H{ + // b.Render(c, http.StatusOK, "post-admin.html", gin.H{ // "logged_in": b.auth.IsLoggedIn(c), // "is_admin": b.auth.IsAdmin(c), // "post": post, // "version": b.version, // }) //} else { - // c.HTML(http.StatusOK, "post.html", gin.H{ + // b.Render(c, http.StatusOK, "post.html", gin.H{ // "logged_in": b.auth.IsLoggedIn(c), // "is_admin": b.auth.IsAdmin(c), // "post": post, @@ -799,7 +814,7 @@ func (b *Blog) Search(c *gin.Context) { if query != "" { posts = b.SearchPosts(query) } - c.HTML(http.StatusOK, "search.html", gin.H{ + b.Render(c, http.StatusOK, "search.html", gin.H{ "logged_in": b.auth.IsLoggedIn(c), "is_admin": b.auth.IsAdmin(c), "posts": posts, @@ -818,7 +833,7 @@ func (b *Blog) Tag(c *gin.Context) { tag := strings.TrimPrefix(c.Param("name"), "/") posts, err := b.getPostsByTag(c) if err != nil { - c.HTML(http.StatusNotFound, "error.html", gin.H{ + b.Render(c, http.StatusNotFound, "error.html", gin.H{ "error": "Tag '" + tag + "' Not Found", "description": err.Error(), "version": b.Version, @@ -829,7 +844,7 @@ func (b *Blog) Tag(c *gin.Context) { "nav_pages": b.GetNavPages(), }) } else { - c.HTML(http.StatusOK, "tag.html", gin.H{ + b.Render(c, http.StatusOK, "tag.html", gin.H{ "logged_in": b.auth.IsLoggedIn(c), "is_admin": b.auth.IsAdmin(c), "posts": posts, @@ -846,7 +861,7 @@ func (b *Blog) Tag(c *gin.Context) { // Tags is the index page for all Tags func (b *Blog) Tags(c *gin.Context) { - c.HTML(http.StatusOK, "tags.html", gin.H{ + b.Render(c, http.StatusOK, "tags.html", gin.H{ "version": b.Version, "title": "Tags", "tags": b.getTags(), @@ -859,7 +874,7 @@ func (b *Blog) Tags(c *gin.Context) { // Speaking is the index page for presentations func (b *Blog) Speaking(c *gin.Context) { - c.HTML(http.StatusOK, "presentations.html", gin.H{ + b.Render(c, http.StatusOK, "presentations.html", gin.H{ "logged_in": b.auth.IsLoggedIn(c), "is_admin": b.auth.IsAdmin(c), "version": b.Version, @@ -877,7 +892,7 @@ func (b *Blog) Research(c *gin.Context) { if err == nil { sortArticlesByDateDesc(articles) b.scholar.SaveCache("profiles.json", "articles.json") - c.HTML(http.StatusOK, "research.html", gin.H{ + b.Render(c, http.StatusOK, "research.html", gin.H{ "logged_in": b.auth.IsLoggedIn(c), "is_admin": b.auth.IsAdmin(c), "version": b.Version, @@ -890,7 +905,7 @@ func (b *Blog) Research(c *gin.Context) { }) } else { articles := make([]*scholar.Article, 0) - c.HTML(http.StatusOK, "research.html", gin.H{ + b.Render(c, http.StatusOK, "research.html", gin.H{ "logged_in": b.auth.IsLoggedIn(c), "is_admin": b.auth.IsAdmin(c), "version": b.Version, @@ -907,7 +922,7 @@ func (b *Blog) Research(c *gin.Context) { // Projects is the index page for projects / code func (b *Blog) Projects(c *gin.Context) { - c.HTML(http.StatusOK, "projects.html", gin.H{ + b.Render(c, http.StatusOK, "projects.html", gin.H{ "logged_in": b.auth.IsLoggedIn(c), "is_admin": b.auth.IsAdmin(c), "version": b.Version, @@ -921,7 +936,7 @@ func (b *Blog) Projects(c *gin.Context) { // About is the about page func (b *Blog) About(c *gin.Context) { - c.HTML(http.StatusOK, "about.html", gin.H{ + b.Render(c, http.StatusOK, "about.html", gin.H{ "logged_in": b.auth.IsLoggedIn(c), "is_admin": b.auth.IsAdmin(c), "version": b.Version, @@ -937,7 +952,7 @@ func (b *Blog) About(c *gin.Context) { func (b *Blog) Archives(c *gin.Context) { yearKeys, byYear := b.getArchivesByYear() monthKeys, byYearMonth := b.getArchivesByYearMonth() - c.HTML(http.StatusOK, "archives.html", gin.H{ + b.Render(c, http.StatusOK, "archives.html", gin.H{ "logged_in": b.auth.IsLoggedIn(c), "is_admin": b.auth.IsAdmin(c), "version": b.Version, @@ -998,7 +1013,7 @@ func (b *Blog) Login(c *gin.Context) { err = godotenv.Load("local.env") if err != nil { //todo: handle better - perhaps return error to browser - c.HTML(http.StatusInternalServerError, "Error loading .env file: "+err.Error(), gin.H{ + b.Render(c, http.StatusInternalServerError, "Error loading .env file: "+err.Error(), gin.H{ "logged_in": b.auth.IsLoggedIn(c), "is_admin": b.auth.IsAdmin(c), "version": b.Version, @@ -1013,7 +1028,7 @@ func (b *Blog) Login(c *gin.Context) { } clientID := os.Getenv("client_id") - c.HTML(http.StatusOK, "login.html", gin.H{ + b.Render(c, http.StatusOK, "login.html", gin.H{ "logged_in": b.auth.IsLoggedIn(c), "is_admin": b.auth.IsAdmin(c), "client_id": clientID, @@ -1141,7 +1156,7 @@ func (b *Blog) SubmitComment(c *gin.Context) { func (b *Blog) checkValidDb(c *gin.Context) { if b.db == nil { - c.HTML(http.StatusInternalServerError, "error.html", gin.H{ + b.Render(c, http.StatusInternalServerError, "error.html", gin.H{ "error": "Database Not Found", "description": "Database is not connected", "version": b.Version, diff --git a/go.mod b/go.mod index 768e52a..2608cd7 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,8 @@ require ( gorm.io/gorm v1.31.1 ) +require github.com/traefik/yaegi v0.16.1 + require ( filippo.io/edwards25519 v1.1.1 // indirect github.com/PuerkitoBio/goquery v1.12.0 // indirect diff --git a/go.sum b/go.sum index 8098bda..bde9438 100644 --- a/go.sum +++ b/go.sum @@ -110,6 +110,8 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/traefik/yaegi v0.16.1 h1:f1De3DVJqIDKmnasUF6MwmWv1dSEEat0wcpXhD2On3E= +github.com/traefik/yaegi v0.16.1/go.mod h1:4eVhbPb3LnD2VigQjhYbEJ69vDRFdT2HQNrXx8eEwUY= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= diff --git a/goblog.go b/goblog.go index 7772462..9500f56 100644 --- a/goblog.go +++ b/goblog.go @@ -9,6 +9,8 @@ import ( "goblog/admin" "goblog/auth" "goblog/blog" + gplugin "goblog/plugin" + "goblog/plugins/analytics" "goblog/tools" "goblog/wizard" "gorm.io/driver/mysql" @@ -306,7 +308,17 @@ func main() { router: router, } + // Initialize plugin system + registry := gplugin.NewRegistry(db) + registry.Register(analytics.New()) + gplugin.LoadDynamicPlugins(registry, "plugins/dynamic") + if db != nil { + registry.Init() + registry.StartScheduledJobs() + } + router.Use(CORS()) + router.Use(gplugin.Middleware(registry)) store := cookie.NewStore([]byte(sessionKey)) hostname, err := os.Hostname() router.Use(sessions.Sessions(hostname, store)) diff --git a/plugin/loader.go b/plugin/loader.go new file mode 100644 index 0000000..c47e525 --- /dev/null +++ b/plugin/loader.go @@ -0,0 +1,74 @@ +package plugin + +import ( + "log" + "os" + "path/filepath" + "strings" + + "github.com/traefik/yaegi/interp" + "github.com/traefik/yaegi/stdlib" +) + +// LoadDynamicPlugins scans a directory for .go plugin files and loads them +// using the Yaegi Go interpreter. Each file must define a function: +// +// func NewPlugin() plugin.Plugin +// +// The returned plugins are registered with the given registry. +func LoadDynamicPlugins(registry *Registry, dir string) { + entries, err := os.ReadDir(dir) + if err != nil { + if !os.IsNotExist(err) { + log.Printf("Warning: could not read plugin directory %s: %v", dir, err) + } + return + } + + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") { + continue + } + + path := filepath.Join(dir, entry.Name()) + p, err := loadPlugin(path) + if err != nil { + log.Printf("Warning: failed to load plugin %s: %v", entry.Name(), err) + continue + } + registry.Register(p) + } +} + +func loadPlugin(path string) (Plugin, error) { + src, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + i := interp.New(interp.Options{}) + if err := i.Use(stdlib.Symbols); err != nil { + return nil, err + } + // Export the plugin package symbols so dynamic plugins can use them + if err := i.Use(Symbols); err != nil { + return nil, err + } + + _, err = i.Eval(string(src)) + if err != nil { + return nil, err + } + + v, err := i.Eval("NewPlugin()") + if err != nil { + return nil, err + } + + p, ok := v.Interface().(Plugin) + if !ok { + return nil, err + } + + return p, nil +} diff --git a/plugin/plugin.go b/plugin/plugin.go new file mode 100644 index 0000000..aaeea7c --- /dev/null +++ b/plugin/plugin.go @@ -0,0 +1,64 @@ +// Package plugin defines the interface for goblog plugins. +// Plugins can inject data into templates, add HTML to and , +// register scheduled background jobs, and define their own settings. +// +// Plugins can be compiled-in (imported and registered in main()) or +// loaded dynamically from .go files in the plugins/ directory at startup. +package plugin + +import ( + "time" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// SettingDefinition describes a single setting that a plugin requires. +// Settings are stored in the blog's Setting table namespaced as "pluginname.key". +type SettingDefinition struct { + Key string // short key, e.g. "tracking_id" + Type string // "text", "textarea", "file", "bool" + DefaultValue string + Label string // human-readable label for admin UI + Description string // help text +} + +// ScheduledJob describes a periodic task the plugin wants to run. +type ScheduledJob struct { + Name string + Interval time.Duration + Run func(db *gorm.DB, settings map[string]string) error +} + +// HookContext provides everything a plugin hook needs. +type HookContext struct { + GinContext *gin.Context + DB *gorm.DB + Settings map[string]string // plugin's own settings (namespace prefix stripped) + Template string // which template is being rendered + Data gin.H // the existing template data (read-only) +} + +// Plugin is the core interface. Embed BasePlugin to get no-op defaults +// and only implement the methods you need. +type Plugin interface { + Name() string + DisplayName() string + Version() string + Settings() []SettingDefinition + ScheduledJobs() []ScheduledJob + TemplateData(ctx *HookContext) gin.H + TemplateHead(ctx *HookContext) string + TemplateFooter(ctx *HookContext) string + OnInit(db *gorm.DB) error +} + +// BasePlugin provides no-op implementations of all Plugin methods. +type BasePlugin struct{} + +func (BasePlugin) Settings() []SettingDefinition { return nil } +func (BasePlugin) ScheduledJobs() []ScheduledJob { return nil } +func (BasePlugin) TemplateData(ctx *HookContext) gin.H { return nil } +func (BasePlugin) TemplateHead(ctx *HookContext) string { return "" } +func (BasePlugin) TemplateFooter(ctx *HookContext) string { return "" } +func (BasePlugin) OnInit(db *gorm.DB) error { return nil } diff --git a/plugin/registry.go b/plugin/registry.go new file mode 100644 index 0000000..943c230 --- /dev/null +++ b/plugin/registry.go @@ -0,0 +1,187 @@ +package plugin + +import ( + "goblog/blog" + "log" + "sync" + "time" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// PluginSettingsGroup holds a plugin's setting definitions and current values +// for rendering in the admin settings page. +type PluginSettingsGroup struct { + PluginName string + DisplayName string + Settings []SettingDefinition + CurrentValues map[string]string +} + +// Registry manages all registered plugins. +type Registry struct { + plugins []Plugin + db *gorm.DB + mu sync.RWMutex + stopCh chan struct{} +} + +// NewRegistry creates a plugin registry. +func NewRegistry(db *gorm.DB) *Registry { + return &Registry{ + db: db, + stopCh: make(chan struct{}), + } +} + +// UpdateDb updates the database reference (used after wizard setup). +func (r *Registry) UpdateDb(db *gorm.DB) { + r.mu.Lock() + defer r.mu.Unlock() + r.db = db +} + +// Register adds a compiled-in plugin to the registry. +func (r *Registry) Register(p Plugin) { + r.mu.Lock() + defer r.mu.Unlock() + r.plugins = append(r.plugins, p) + log.Printf("Plugin registered: %s v%s", p.DisplayName(), p.Version()) +} + +// Plugins returns the list of registered plugins. +func (r *Registry) Plugins() []Plugin { + r.mu.RLock() + defer r.mu.RUnlock() + return r.plugins +} + +// Init seeds plugin settings and calls OnInit for all plugins. +func (r *Registry) Init() error { + r.mu.RLock() + defer r.mu.RUnlock() + if r.db == nil { + return nil + } + for _, p := range r.plugins { + for _, s := range p.Settings() { + fullKey := p.Name() + "." + s.Key + setting := blog.Setting{ + Key: fullKey, + Type: s.Type, + Value: s.DefaultValue, + } + r.db.Where("key = ?", fullKey).FirstOrCreate(&setting) + } + if err := p.OnInit(r.db); err != nil { + log.Printf("Plugin %s init error: %v", p.Name(), err) + return err + } + } + return nil +} + +// StartScheduledJobs launches goroutines for all plugin scheduled jobs. +func (r *Registry) StartScheduledJobs() { + r.mu.RLock() + defer r.mu.RUnlock() + for _, p := range r.plugins { + for _, job := range p.ScheduledJobs() { + go func(p Plugin, job ScheduledJob) { + ticker := time.NewTicker(job.Interval) + defer ticker.Stop() + for { + select { + case <-ticker.C: + settings := r.getPluginSettings(p.Name()) + if err := job.Run(r.db, settings); err != nil { + log.Printf("Plugin %s job %s error: %v", p.Name(), job.Name, err) + } + case <-r.stopCh: + return + } + } + }(p, job) + } + } +} + +// Stop gracefully shuts down scheduled jobs. +func (r *Registry) Stop() { + close(r.stopCh) +} + +// getPluginSettings returns a plugin's settings as a map with the +// namespace prefix stripped (e.g. "analytics.tracking_id" -> "tracking_id"). +func (r *Registry) getPluginSettings(pluginName string) map[string]string { + var settings []blog.Setting + prefix := pluginName + "." + r.db.Where("key LIKE ?", prefix+"%").Find(&settings) + result := make(map[string]string) + for _, s := range settings { + result[s.Key[len(prefix):]] = s.Value + } + return result +} + +// InjectTemplateData gathers data from all plugins and merges it into +// the template data map. Adds "plugins", "plugin_head_html", and +// "plugin_footer_html" keys. +func (r *Registry) InjectTemplateData(c *gin.Context, templateName string, data gin.H) gin.H { + r.mu.RLock() + defer r.mu.RUnlock() + + pluginsData := gin.H{} + headHTML := "" + footerHTML := "" + + for _, p := range r.plugins { + settings := r.getPluginSettings(p.Name()) + ctx := &HookContext{ + GinContext: c, + DB: r.db, + Settings: settings, + Template: templateName, + Data: data, + } + + if pData := p.TemplateData(ctx); pData != nil { + pluginsData[p.Name()] = pData + } + headHTML += p.TemplateHead(ctx) + footerHTML += p.TemplateFooter(ctx) + } + + data["plugins"] = pluginsData + data["plugin_head_html"] = headHTML + data["plugin_footer_html"] = footerHTML + return data +} + +// GetAllSettings returns all plugin setting definitions grouped by plugin. +func (r *Registry) GetAllSettings() []PluginSettingsGroup { + r.mu.RLock() + defer r.mu.RUnlock() + var groups []PluginSettingsGroup + for _, p := range r.plugins { + if defs := p.Settings(); len(defs) > 0 { + values := r.getPluginSettings(p.Name()) + groups = append(groups, PluginSettingsGroup{ + PluginName: p.Name(), + DisplayName: p.DisplayName(), + Settings: defs, + CurrentValues: values, + }) + } + } + return groups +} + +// Middleware returns a Gin middleware that stores the registry on the context. +func Middleware(registry *Registry) gin.HandlerFunc { + return func(c *gin.Context) { + c.Set("plugin_registry", registry) + c.Next() + } +} diff --git a/plugin/registry_test.go b/plugin/registry_test.go new file mode 100644 index 0000000..f3cdb82 --- /dev/null +++ b/plugin/registry_test.go @@ -0,0 +1,140 @@ +package plugin_test + +import ( + "goblog/blog" + "goblog/plugin" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +type testPlugin struct { + plugin.BasePlugin +} + +func (p *testPlugin) Name() string { return "test" } +func (p *testPlugin) DisplayName() string { return "Test Plugin" } +func (p *testPlugin) Version() string { return "0.1.0" } + +func (p *testPlugin) Settings() []plugin.SettingDefinition { + return []plugin.SettingDefinition{ + {Key: "api_key", Type: "text", DefaultValue: "default123", Label: "API Key"}, + } +} + +func (p *testPlugin) TemplateHead(ctx *plugin.HookContext) string { + if key := ctx.Settings["api_key"]; key != "" { + return "" + } + return "" +} + +func (p *testPlugin) TemplateFooter(ctx *plugin.HookContext) string { + return "" +} + +func (p *testPlugin) TemplateData(ctx *plugin.HookContext) gin.H { + return gin.H{"greeting": "hello from test plugin"} +} + +func TestRegistryBasics(t *testing.T) { + db, _ := gorm.Open(sqlite.Open(":memory:")) + db.AutoMigrate(&blog.Setting{}) + + reg := plugin.NewRegistry(db) + tp := &testPlugin{} + reg.Register(tp) + + if len(reg.Plugins()) != 1 { + t.Fatalf("expected 1 plugin, got %d", len(reg.Plugins())) + } + if reg.Plugins()[0].Name() != "test" { + t.Fatalf("expected plugin name 'test', got %q", reg.Plugins()[0].Name()) + } +} + +func TestRegistryInit(t *testing.T) { + db, _ := gorm.Open(sqlite.Open(":memory:")) + db.AutoMigrate(&blog.Setting{}) + + reg := plugin.NewRegistry(db) + reg.Register(&testPlugin{}) + if err := reg.Init(); err != nil { + t.Fatalf("Init failed: %v", err) + } + + // Check setting was seeded + var setting blog.Setting + db.Where("key = ?", "test.api_key").First(&setting) + if setting.Value != "default123" { + t.Fatalf("expected default value 'default123', got %q", setting.Value) + } +} + +func TestRegistryInjectTemplateData(t *testing.T) { + db, _ := gorm.Open(sqlite.Open(":memory:")) + db.AutoMigrate(&blog.Setting{}) + + reg := plugin.NewRegistry(db) + reg.Register(&testPlugin{}) + reg.Init() + + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request, _ = http.NewRequest("GET", "/", nil) + + data := gin.H{"title": "Test Page"} + data = reg.InjectTemplateData(c, "home.html", data) + + // Check plugin data was injected + plugins, ok := data["plugins"].(gin.H) + if !ok { + t.Fatal("expected plugins key in data") + } + testData, ok := plugins["test"].(gin.H) + if !ok { + t.Fatal("expected test plugin data") + } + if testData["greeting"] != "hello from test plugin" { + t.Fatalf("expected greeting, got %v", testData["greeting"]) + } + + // Check head/footer HTML + headHTML, ok := data["plugin_head_html"].(string) + if !ok || headHTML == "" { + t.Fatal("expected plugin_head_html") + } + if headHTML != "" { + t.Fatalf("unexpected head HTML: %q", headHTML) + } + + footerHTML, ok := data["plugin_footer_html"].(string) + if !ok || footerHTML != "" { + t.Fatalf("unexpected footer HTML: %q", footerHTML) + } +} + +func TestGetAllSettings(t *testing.T) { + db, _ := gorm.Open(sqlite.Open(":memory:")) + db.AutoMigrate(&blog.Setting{}) + + reg := plugin.NewRegistry(db) + reg.Register(&testPlugin{}) + reg.Init() + + groups := reg.GetAllSettings() + if len(groups) != 1 { + t.Fatalf("expected 1 settings group, got %d", len(groups)) + } + if groups[0].PluginName != "test" { + t.Fatalf("expected plugin name 'test', got %q", groups[0].PluginName) + } + if groups[0].CurrentValues["api_key"] != "default123" { + t.Fatalf("expected current value 'default123', got %q", groups[0].CurrentValues["api_key"]) + } +} diff --git a/plugin/symbols.go b/plugin/symbols.go new file mode 100644 index 0000000..ce7943d --- /dev/null +++ b/plugin/symbols.go @@ -0,0 +1,14 @@ +package plugin + +import "reflect" + +// Symbols exports the plugin package types for the Yaegi interpreter, +// allowing dynamic plugins to use plugin.BasePlugin, plugin.HookContext, etc. +var Symbols = map[string]map[string]reflect.Value{ + "goblog/plugin/plugin": { + "BasePlugin": reflect.ValueOf((*BasePlugin)(nil)), + "HookContext": reflect.ValueOf((*HookContext)(nil)), + "SettingDefinition": reflect.ValueOf((*SettingDefinition)(nil)), + "ScheduledJob": reflect.ValueOf((*ScheduledJob)(nil)), + }, +} diff --git a/plugins/analytics/analytics.go b/plugins/analytics/analytics.go new file mode 100644 index 0000000..366373c --- /dev/null +++ b/plugins/analytics/analytics.go @@ -0,0 +1,60 @@ +// Package analytics provides a Google Analytics plugin for goblog. +// It injects the GA tracking script into the of every page +// when enabled and configured with a valid measurement ID. +package analytics + +import ( + "goblog/plugin" + + "gorm.io/gorm" +) + +// AnalyticsPlugin implements Google Analytics tracking. +type AnalyticsPlugin struct { + plugin.BasePlugin +} + +// New creates a new analytics plugin. +func New() *AnalyticsPlugin { + return &AnalyticsPlugin{} +} + +func (p *AnalyticsPlugin) Name() string { return "analytics" } +func (p *AnalyticsPlugin) DisplayName() string { return "Google Analytics" } +func (p *AnalyticsPlugin) Version() string { return "1.0.0" } + +func (p *AnalyticsPlugin) Settings() []plugin.SettingDefinition { + return []plugin.SettingDefinition{ + { + Key: "tracking_id", + Type: "text", + DefaultValue: "", + Label: "Measurement ID", + Description: "Google Analytics measurement ID (e.g. G-XXXXXXXXXX)", + }, + { + Key: "enabled", + Type: "text", + DefaultValue: "false", + Label: "Enabled", + Description: "Set to 'true' to enable tracking", + }, + } +} + +func (p *AnalyticsPlugin) OnInit(db *gorm.DB) error { return nil } + +func (p *AnalyticsPlugin) TemplateHead(ctx *plugin.HookContext) string { + trackingID := ctx.Settings["tracking_id"] + enabled := ctx.Settings["enabled"] + if trackingID == "" || enabled != "true" { + return "" + } + return ` +` +} diff --git a/themes/default/templates/admin_settings.html b/themes/default/templates/admin_settings.html index d77ff9b..63a01bf 100644 --- a/themes/default/templates/admin_settings.html +++ b/themes/default/templates/admin_settings.html @@ -23,11 +23,35 @@

Settings

{{ end }} {{ end }} - + {{ if .plugin_settings }} +
+

Plugins

+
+ {{ range .plugin_settings }} +

{{ .DisplayName }}

+ {{ $pluginName := .PluginName }} + {{ $values := .CurrentValues }} + {{ range .Settings }} + + {{ if .Description }}{{ .Description }}{{ end }} + {{ if eq .Type "textarea" }} + + {{ else }} + + {{ end }} + {{ end }} + {{ end }} + +
+ {{ end }} + {{ template "footer.html" .}} diff --git a/themes/default/templates/footer.html b/themes/default/templates/footer.html index d537848..7d93f9d 100644 --- a/themes/default/templates/footer.html +++ b/themes/default/templates/footer.html @@ -54,4 +54,5 @@

diff --git a/themes/default/templates/header.html b/themes/default/templates/header.html index 348f9d5..9c29cd6 100644 --- a/themes/default/templates/header.html +++ b/themes/default/templates/header.html @@ -76,6 +76,7 @@ {{ with index .settings "custom_header_code" }}{{ if .Value }} {{ .Value | rawHTML }} {{ end }}{{ end }} + {{ with .plugin_head_html }}{{ . | rawHTML }}{{ end }} diff --git a/themes/forest/templates/admin_settings.html b/themes/forest/templates/admin_settings.html index 0eb5276..aedb082 100644 --- a/themes/forest/templates/admin_settings.html +++ b/themes/forest/templates/admin_settings.html @@ -2,31 +2,66 @@
{{ template "admin_nav.html" . }} -

Settings

+ +

Site Settings

-
- {{ $themes := .themes }} - {{ range .settings }} - - {{ if eq .Type "file" }} - - {{ else if eq .Type "textarea" }} - - {{ else if eq .Key "theme" }} - + {{ else if eq .Type "textarea" }} + + {{ else if eq .Key "theme" }} + + {{ else }} + + {{ end }} +
+ {{ end }} + + + + + + {{ range .plugin_settings }} +
+
Plugin: {{ .DisplayName }} ({{ .PluginName }})
+
+
+ {{ $pluginName := .PluginName }} + {{ $values := .CurrentValues }} + {{ range .Settings }} +
+ + {{ if .Description }}{{ .Description }}{{ end }} + {{ if eq .Type "textarea" }} + + {{ else }} + + {{ end }} +
{{ end }} - - {{ else }} - - {{ end }} - {{ end }} - -
+ + +
+
+ {{ end }} diff --git a/themes/minimal/templates/admin_settings.html b/themes/minimal/templates/admin_settings.html index d77ff9b..aedb082 100644 --- a/themes/minimal/templates/admin_settings.html +++ b/themes/minimal/templates/admin_settings.html @@ -2,31 +2,66 @@
{{ template "admin_nav.html" . }} -

Settings

+ +

Site Settings

-
- {{ $themes := .themes }} - {{ range .settings }} - - {{ if eq .Type "file" }} - - {{ else if eq .Type "textarea" }} - - {{ else if eq .Key "theme" }} - + {{ else if eq .Type "textarea" }} + + {{ else if eq .Key "theme" }} + + {{ else }} + + {{ end }} +
+ {{ end }} + + + + + + {{ range .plugin_settings }} +
+
Plugin: {{ .DisplayName }} ({{ .PluginName }})
+
+
+ {{ $pluginName := .PluginName }} + {{ $values := .CurrentValues }} + {{ range .Settings }} +
+ + {{ if .Description }}{{ .Description }}{{ end }} + {{ if eq .Type "textarea" }} + + {{ else }} + + {{ end }} +
{{ end }} - - {{ else }} - - {{ end }} - {{ end }} - -
+ + +
+
+ {{ end }} diff --git a/www/js/admin-script.js b/www/js/admin-script.js index c477bf5..163a8d0 100644 --- a/www/js/admin-script.js +++ b/www/js/admin-script.js @@ -49,6 +49,12 @@ function updateSettings(redirect) { if (redirect !== undefined) { window.location = redirect; } + + // Reload if theme was changed so new templates take effect + var themeChanged = settings.some(function(s) { return s.key === "theme"; }); + if (themeChanged && redirect === undefined) { + window.location.reload(); + } }, error: function(jqXHR, textStatus, errorThrown) { // show #ajax-error with the error message @@ -225,15 +231,16 @@ function rollbackRevision(postId, revisionId) { return false; } -function updatePluginSettings() { +function updatePluginSettings(btn) { $("#ajax-error").hide(); var settings = []; + var $form = $(btn).closest("form"); - $("#plugin-settings-form :input").each(function() { + $form.find(":input").each(function() { var key = this.name; var type = this.tagName === "TEXTAREA" ? "textarea" : "text"; var value = this.value; - if (this.type === "submit" || !key) return; + if (this.type === "submit" || this.type === "button" || !key) return; settings.push({"key": key, "value": value, "type": type}); }); From e308e2a7ba2bd49d6cdfc19f949f8555570a422c Mon Sep 17 00:00:00 2001 From: Jason Ernst Date: Mon, 16 Mar 2026 20:09:06 -0700 Subject: [PATCH 03/12] Plugin settings: collapsible cards with enable/disable checkbox - Plugin cards are collapsed by default, expanded only when enabled - Enabled setting rendered as a Bootstrap switch checkbox in the card header instead of a text input - Toggling the checkbox immediately saves the enabled state and expands/collapses the settings card - The "enabled" field is excluded from the settings form body since it lives in the card header - Save button includes the enabled state along with other settings Co-Authored-By: Claude Opus 4.6 (1M context) --- themes/default/templates/admin_settings.html | 50 ++++++++++++-------- themes/forest/templates/admin_settings.html | 50 ++++++++++++-------- themes/minimal/templates/admin_settings.html | 50 ++++++++++++-------- www/js/admin-script.js | 37 +++++++++++++++ 4 files changed, 130 insertions(+), 57 deletions(-) diff --git a/themes/default/templates/admin_settings.html b/themes/default/templates/admin_settings.html index aedb082..eb71f67 100644 --- a/themes/default/templates/admin_settings.html +++ b/themes/default/templates/admin_settings.html @@ -38,27 +38,39 @@

Site Settings

{{ range .plugin_settings }} + {{ $pluginName := .PluginName }} + {{ $values := .CurrentValues }} + {{ $enabled := index $values "enabled" }}
-
Plugin: {{ .DisplayName }} ({{ .PluginName }})
-
-
- {{ $pluginName := .PluginName }} - {{ $values := .CurrentValues }} - {{ range .Settings }} -
- - {{ if .Description }}{{ .Description }}{{ end }} - {{ if eq .Type "textarea" }} - - {{ else }} - +
+ Plugin: {{ .DisplayName }} ({{ .PluginName }}) +
+ + +
+
+
+
+ + {{ range .Settings }} + {{ if ne .Key "enabled" }} +
+ + {{ if .Description }}{{ .Description }}{{ end }} + {{ if eq .Type "textarea" }} + + {{ else }} + + {{ end }} +
{{ end }} -
- {{ end }} - - + {{ end }} + + +
{{ end }} diff --git a/themes/forest/templates/admin_settings.html b/themes/forest/templates/admin_settings.html index aedb082..eb71f67 100644 --- a/themes/forest/templates/admin_settings.html +++ b/themes/forest/templates/admin_settings.html @@ -38,27 +38,39 @@

Site Settings

{{ range .plugin_settings }} + {{ $pluginName := .PluginName }} + {{ $values := .CurrentValues }} + {{ $enabled := index $values "enabled" }}
-
Plugin: {{ .DisplayName }} ({{ .PluginName }})
-
-
- {{ $pluginName := .PluginName }} - {{ $values := .CurrentValues }} - {{ range .Settings }} -
- - {{ if .Description }}{{ .Description }}{{ end }} - {{ if eq .Type "textarea" }} - - {{ else }} - +
+ Plugin: {{ .DisplayName }} ({{ .PluginName }}) +
+ + +
+
+
+
+ + {{ range .Settings }} + {{ if ne .Key "enabled" }} +
+ + {{ if .Description }}{{ .Description }}{{ end }} + {{ if eq .Type "textarea" }} + + {{ else }} + + {{ end }} +
{{ end }} -
- {{ end }} - - + {{ end }} + + +
{{ end }} diff --git a/themes/minimal/templates/admin_settings.html b/themes/minimal/templates/admin_settings.html index aedb082..eb71f67 100644 --- a/themes/minimal/templates/admin_settings.html +++ b/themes/minimal/templates/admin_settings.html @@ -38,27 +38,39 @@

Site Settings

{{ range .plugin_settings }} + {{ $pluginName := .PluginName }} + {{ $values := .CurrentValues }} + {{ $enabled := index $values "enabled" }}
-
Plugin: {{ .DisplayName }} ({{ .PluginName }})
-
-
- {{ $pluginName := .PluginName }} - {{ $values := .CurrentValues }} - {{ range .Settings }} -
- - {{ if .Description }}{{ .Description }}{{ end }} - {{ if eq .Type "textarea" }} - - {{ else }} - +
+ Plugin: {{ .DisplayName }} ({{ .PluginName }}) +
+ + +
+
+
+
+ + {{ range .Settings }} + {{ if ne .Key "enabled" }} +
+ + {{ if .Description }}{{ .Description }}{{ end }} + {{ if eq .Type "textarea" }} + + {{ else }} + + {{ end }} +
{{ end }} -
- {{ end }} - - + {{ end }} + + +
{{ end }} diff --git a/www/js/admin-script.js b/www/js/admin-script.js index 163a8d0..36a0b58 100644 --- a/www/js/admin-script.js +++ b/www/js/admin-script.js @@ -231,11 +231,48 @@ function rollbackRevision(postId, revisionId) { return false; } +function togglePluginEnabled(checkbox) { + var pluginName = $(checkbox).data("plugin"); + var enabled = checkbox.checked ? "true" : "false"; + var $body = $("#plugin-body-" + pluginName); + + if (checkbox.checked) { + $body.collapse("show"); + } else { + $body.collapse("hide"); + } + + // Save the enabled setting immediately + var settings = [{"key": pluginName + ".enabled", "value": enabled, "type": "text"}]; + $.ajax({ + url: "/api/v1/settings", + type: "patch", + dataType: "json", + contentType: "application/json", + success: function(json) { + $("#ajax-error").html(pluginName + (checkbox.checked ? " enabled" : " disabled")).show(); + $("#ajax-error").removeClass("alert-danger").addClass("alert-success"); + }, + error: function(jqXHR, textStatus, errorThrown) { + $("#ajax-error").html("ERROR: " + textStatus + " " + errorThrown).show(); + $("#ajax-error").removeClass("alert-success").addClass("alert-danger"); + }, + data: JSON.stringify(settings) + }); +} + function updatePluginSettings(btn) { $("#ajax-error").hide(); var settings = []; + var $card = $(btn).closest(".card"); var $form = $(btn).closest("form"); + // Include the enabled checkbox from the card header + var $toggle = $card.find(".plugin-enable-toggle"); + if ($toggle.length) { + settings.push({"key": $toggle.attr("name"), "value": $toggle.is(":checked") ? "true" : "false", "type": "text"}); + } + $form.find(":input").each(function() { var key = this.name; var type = this.tagName === "TEXTAREA" ? "textarea" : "text"; From 1c90ffdf14a8a68b5c5ad255ca934649910cf3aa Mon Sep 17 00:00:00 2001 From: Jason Ernst Date: Mon, 16 Mar 2026 20:11:51 -0700 Subject: [PATCH 04/12] Make plugin cards always collapsible, start collapsed - Card header is clickable to expand/collapse regardless of enabled state - Cards start collapsed by default so the page is clean - Enable/disable toggle only saves the setting, doesn't control collapse - stopPropagation on the switch prevents clicking it from also toggling the collapse Co-Authored-By: Claude Opus 4.6 (1M context) --- themes/default/templates/admin_settings.html | 6 +++--- themes/forest/templates/admin_settings.html | 6 +++--- themes/minimal/templates/admin_settings.html | 6 +++--- www/js/admin-script.js | 7 ------- 4 files changed, 9 insertions(+), 16 deletions(-) diff --git a/themes/default/templates/admin_settings.html b/themes/default/templates/admin_settings.html index eb71f67..03f5737 100644 --- a/themes/default/templates/admin_settings.html +++ b/themes/default/templates/admin_settings.html @@ -42,15 +42,15 @@

Site Settings

{{ $values := .CurrentValues }} {{ $enabled := index $values "enabled" }}
-
+
Plugin: {{ .DisplayName }} ({{ .PluginName }}) -
+
-
+
{{ range .Settings }} diff --git a/themes/forest/templates/admin_settings.html b/themes/forest/templates/admin_settings.html index eb71f67..03f5737 100644 --- a/themes/forest/templates/admin_settings.html +++ b/themes/forest/templates/admin_settings.html @@ -42,15 +42,15 @@

Site Settings

{{ $values := .CurrentValues }} {{ $enabled := index $values "enabled" }}
-
+
Plugin: {{ .DisplayName }} ({{ .PluginName }}) -
+
-
+
{{ range .Settings }} diff --git a/themes/minimal/templates/admin_settings.html b/themes/minimal/templates/admin_settings.html index eb71f67..03f5737 100644 --- a/themes/minimal/templates/admin_settings.html +++ b/themes/minimal/templates/admin_settings.html @@ -42,15 +42,15 @@

Site Settings

{{ $values := .CurrentValues }} {{ $enabled := index $values "enabled" }}
-
+
Plugin: {{ .DisplayName }} ({{ .PluginName }}) -
+
-
+
{{ range .Settings }} diff --git a/www/js/admin-script.js b/www/js/admin-script.js index 36a0b58..1c2a092 100644 --- a/www/js/admin-script.js +++ b/www/js/admin-script.js @@ -234,13 +234,6 @@ function rollbackRevision(postId, revisionId) { function togglePluginEnabled(checkbox) { var pluginName = $(checkbox).data("plugin"); var enabled = checkbox.checked ? "true" : "false"; - var $body = $("#plugin-body-" + pluginName); - - if (checkbox.checked) { - $body.collapse("show"); - } else { - $body.collapse("hide"); - } // Save the enabled setting immediately var settings = [{"key": pluginName + ".enabled", "value": enabled, "type": "text"}]; From 6176fc468285eacd47d17808f2005ee025b42cb8 Mon Sep 17 00:00:00 2001 From: Jason Ernst Date: Mon, 16 Mar 2026 20:18:34 -0700 Subject: [PATCH 05/12] Move plugin settings to separate plugin_settings table Plugin settings were stored in the main settings table with namespaced keys (analytics.enabled, analytics.tracking_id), which caused them to appear in the global settings form. - Add PluginSetting model with composite PK (plugin_name, key) - Auto-migrate plugin_settings table in Registry.Init() - Add UpdateSetting method on Registry - Add PATCH /api/v1/plugin-settings endpoint for saving plugin settings - Update JS to use the new endpoint for both toggle and form save - Remove blog.Setting dependency from plugin package Co-Authored-By: Claude Opus 4.6 (1M context) --- admin/admin.go | 39 +++++++++++++++++++++++++++++++++++++++ goblog.go | 1 + plugin/registry.go | 31 ++++++++++++++++++------------- plugin/registry_test.go | 13 ++++++------- plugin/setting.go | 9 +++++++++ www/js/admin-script.js | 4 ++-- 6 files changed, 75 insertions(+), 22 deletions(-) create mode 100644 plugin/setting.go diff --git a/admin/admin.go b/admin/admin.go index 55a4945..7ccb1c0 100644 --- a/admin/admin.go +++ b/admin/admin.go @@ -67,6 +67,45 @@ func (a *Admin) getPluginSettings(c *gin.Context) interface{} { return nil } +// UpdatePluginSettings saves plugin settings via the plugin registry. +func (a *Admin) UpdatePluginSettings(c *gin.Context) { + if !a.auth.IsAdmin(c) { + c.JSON(http.StatusUnauthorized, "Not Authorized") + return + } + + var settings []struct { + Key string `json:"key"` + Value string `json:"value"` + } + if err := c.BindJSON(&settings); err != nil { + c.JSON(http.StatusBadRequest, "Malformed request") + return + } + + reg, exists := c.Get("plugin_registry") + if !exists { + c.JSON(http.StatusInternalServerError, "Plugin registry not available") + return + } + r, ok := reg.(*gplugin.Registry) + if !ok { + c.JSON(http.StatusInternalServerError, "Plugin registry not available") + return + } + + for _, s := range settings { + // Key format is "pluginname.settingkey" + parts := strings.SplitN(s.Key, ".", 2) + if len(parts) != 2 { + continue + } + r.UpdateSetting(parts[0], parts[1], s.Value) + } + + c.JSON(http.StatusAccepted, settings) +} + func (a *Admin) UpdateDb(db *gorm.DB) { a.db = &db } diff --git a/goblog.go b/goblog.go index 9500f56..7b92adc 100644 --- a/goblog.go +++ b/goblog.go @@ -387,6 +387,7 @@ func main() { router.POST("/test_db", testDB) router.POST("/api/v1/upload", goblog._admin.UploadFile) router.PATCH("/api/v1/settings", goblog._admin.UpdateSettings) + router.PATCH("/api/v1/plugin-settings", goblog._admin.UpdatePluginSettings) //if we use true here - it will override the home route and just show files router.Use(static.Serve("/", static.LocalFile("www", false))) if err != nil { diff --git a/plugin/registry.go b/plugin/registry.go index 943c230..8e76d95 100644 --- a/plugin/registry.go +++ b/plugin/registry.go @@ -1,7 +1,6 @@ package plugin import ( - "goblog/blog" "log" "sync" "time" @@ -64,15 +63,16 @@ func (r *Registry) Init() error { if r.db == nil { return nil } + // Create the plugin_settings table if it doesn't exist + r.db.AutoMigrate(&PluginSetting{}) for _, p := range r.plugins { for _, s := range p.Settings() { - fullKey := p.Name() + "." + s.Key - setting := blog.Setting{ - Key: fullKey, - Type: s.Type, - Value: s.DefaultValue, + setting := PluginSetting{ + PluginName: p.Name(), + Key: s.Key, + Value: s.DefaultValue, } - r.db.Where("key = ?", fullKey).FirstOrCreate(&setting) + r.db.Where("plugin_name = ? AND key = ?", p.Name(), s.Key).FirstOrCreate(&setting) } if err := p.OnInit(r.db); err != nil { log.Printf("Plugin %s init error: %v", p.Name(), err) @@ -112,15 +112,13 @@ func (r *Registry) Stop() { close(r.stopCh) } -// getPluginSettings returns a plugin's settings as a map with the -// namespace prefix stripped (e.g. "analytics.tracking_id" -> "tracking_id"). +// getPluginSettings returns a plugin's settings as a simple key→value map. func (r *Registry) getPluginSettings(pluginName string) map[string]string { - var settings []blog.Setting - prefix := pluginName + "." - r.db.Where("key LIKE ?", prefix+"%").Find(&settings) + var settings []PluginSetting + r.db.Where("plugin_name = ?", pluginName).Find(&settings) result := make(map[string]string) for _, s := range settings { - result[s.Key[len(prefix):]] = s.Value + result[s.Key] = s.Value } return result } @@ -178,6 +176,13 @@ func (r *Registry) GetAllSettings() []PluginSettingsGroup { return groups } +// UpdateSetting saves a single plugin setting. +func (r *Registry) UpdateSetting(pluginName, key, value string) { + r.db.Where("plugin_name = ? AND key = ?", pluginName, key). + Assign(PluginSetting{Value: value}). + FirstOrCreate(&PluginSetting{PluginName: pluginName, Key: key, Value: value}) +} + // Middleware returns a Gin middleware that stores the registry on the context. func Middleware(registry *Registry) gin.HandlerFunc { return func(c *gin.Context) { diff --git a/plugin/registry_test.go b/plugin/registry_test.go index f3cdb82..1d33475 100644 --- a/plugin/registry_test.go +++ b/plugin/registry_test.go @@ -1,7 +1,6 @@ package plugin_test import ( - "goblog/blog" "goblog/plugin" "net/http" "net/http/httptest" @@ -43,7 +42,7 @@ func (p *testPlugin) TemplateData(ctx *plugin.HookContext) gin.H { func TestRegistryBasics(t *testing.T) { db, _ := gorm.Open(sqlite.Open(":memory:")) - db.AutoMigrate(&blog.Setting{}) + db.AutoMigrate(&plugin.PluginSetting{}) reg := plugin.NewRegistry(db) tp := &testPlugin{} @@ -59,7 +58,7 @@ func TestRegistryBasics(t *testing.T) { func TestRegistryInit(t *testing.T) { db, _ := gorm.Open(sqlite.Open(":memory:")) - db.AutoMigrate(&blog.Setting{}) + db.AutoMigrate(&plugin.PluginSetting{}) reg := plugin.NewRegistry(db) reg.Register(&testPlugin{}) @@ -68,8 +67,8 @@ func TestRegistryInit(t *testing.T) { } // Check setting was seeded - var setting blog.Setting - db.Where("key = ?", "test.api_key").First(&setting) + var setting plugin.PluginSetting + db.Where("plugin_name = ? AND key = ?", "test", "api_key").First(&setting) if setting.Value != "default123" { t.Fatalf("expected default value 'default123', got %q", setting.Value) } @@ -77,7 +76,7 @@ func TestRegistryInit(t *testing.T) { func TestRegistryInjectTemplateData(t *testing.T) { db, _ := gorm.Open(sqlite.Open(":memory:")) - db.AutoMigrate(&blog.Setting{}) + db.AutoMigrate(&plugin.PluginSetting{}) reg := plugin.NewRegistry(db) reg.Register(&testPlugin{}) @@ -121,7 +120,7 @@ func TestRegistryInjectTemplateData(t *testing.T) { func TestGetAllSettings(t *testing.T) { db, _ := gorm.Open(sqlite.Open(":memory:")) - db.AutoMigrate(&blog.Setting{}) + db.AutoMigrate(&plugin.PluginSetting{}) reg := plugin.NewRegistry(db) reg.Register(&testPlugin{}) diff --git a/plugin/setting.go b/plugin/setting.go new file mode 100644 index 0000000..b53de11 --- /dev/null +++ b/plugin/setting.go @@ -0,0 +1,9 @@ +package plugin + +// PluginSetting stores a plugin's configuration in its own table, +// separate from the blog's core Setting table. +type PluginSetting struct { + PluginName string `gorm:"primaryKey" json:"plugin_name"` + Key string `gorm:"primaryKey" json:"key"` + Value string `json:"value"` +} diff --git a/www/js/admin-script.js b/www/js/admin-script.js index 1c2a092..c8fe056 100644 --- a/www/js/admin-script.js +++ b/www/js/admin-script.js @@ -238,7 +238,7 @@ function togglePluginEnabled(checkbox) { // Save the enabled setting immediately var settings = [{"key": pluginName + ".enabled", "value": enabled, "type": "text"}]; $.ajax({ - url: "/api/v1/settings", + url: "/api/v1/plugin-settings", type: "patch", dataType: "json", contentType: "application/json", @@ -275,7 +275,7 @@ function updatePluginSettings(btn) { }); $.ajax({ - url: "/api/v1/settings", + url: "/api/v1/plugin-settings", type: "patch", dataType: "json", contentType: "application/json", From 73585fb2d46ed7c48c1039c99f78ca5da0fa6843 Mon Sep 17 00:00:00 2001 From: Jason Ernst Date: Mon, 16 Mar 2026 20:20:02 -0700 Subject: [PATCH 06/12] Add migration to clean up plugin settings from main settings table Remove any dot-namespaced keys (e.g. analytics.enabled) from the settings table that were seeded before plugin settings moved to their own plugin_settings table. Co-Authored-By: Claude Opus 4.6 (1M context) --- tools/migrate.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tools/migrate.go b/tools/migrate.go index f05cdbc..1651ec8 100644 --- a/tools/migrate.go +++ b/tools/migrate.go @@ -379,10 +379,24 @@ func Migrate(db *gorm.DB) error { seedDefaultPages(db) linkWritingPagesToPostType(db) cleanupEmptyTags(db) + cleanupPluginSettingsFromMainTable(db) return nil } +// cleanupPluginSettingsFromMainTable removes any dot-namespaced keys +// from the main settings table that belong in plugin_settings instead. +func cleanupPluginSettingsFromMainTable(db *gorm.DB) { + result := db.Exec("DELETE FROM settings WHERE key LIKE '%.%'") + if result.Error != nil { + log.Printf("Warning: failed to clean up plugin settings from main table: %v", result.Error) + return + } + if result.RowsAffected > 0 { + log.Printf("Cleaned up %d plugin settings from main settings table", result.RowsAffected) + } +} + // cleanupEmptyTags removes empty-name tag associations and the empty tag itself. func cleanupEmptyTags(db *gorm.DB) { result := db.Exec("DELETE FROM post_tags WHERE tag_name = ''") From 686f2faa4148f3d3fa1391776c44dbb6ab8386bc Mon Sep 17 00:00:00 2001 From: Jason Ernst Date: Mon, 16 Mar 2026 20:24:35 -0700 Subject: [PATCH 07/12] Extract social icons into a plugin Move social URL settings out of the core settings table and hardcoded footer templates into a socialicons plugin. Plugin (plugins/socialicons/): - Defines settings for 10 social platforms (GitHub, LinkedIn, X, etc.) - TemplateFooter renders icon links with Font Awesome - TemplateData provides structured links list for themes that want it - Enabled by default Migration: - Moves existing social URL values from settings to plugin_settings - Removes the social URL keys from the main settings table - Sets socialicons.enabled to true for existing installs Footer templates: - Removed hardcoded social icon blocks from all three themes - Social icons now rendered via {{ .plugin_footer_html }} Co-Authored-By: Claude Opus 4.6 (1M context) --- goblog.go | 2 + plugins/socialicons/socialicons.go | 97 ++++++++++++++++++++++++++++ themes/default/templates/footer.html | 34 +--------- themes/forest/templates/footer.html | 34 +--------- themes/minimal/templates/footer.html | 36 +---------- tools/migrate.go | 53 ++++++++++++--- 6 files changed, 147 insertions(+), 109 deletions(-) create mode 100644 plugins/socialicons/socialicons.go diff --git a/goblog.go b/goblog.go index 7b92adc..f02b7ae 100644 --- a/goblog.go +++ b/goblog.go @@ -11,6 +11,7 @@ import ( "goblog/blog" gplugin "goblog/plugin" "goblog/plugins/analytics" + "goblog/plugins/socialicons" "goblog/tools" "goblog/wizard" "gorm.io/driver/mysql" @@ -311,6 +312,7 @@ func main() { // Initialize plugin system registry := gplugin.NewRegistry(db) registry.Register(analytics.New()) + registry.Register(socialicons.New()) gplugin.LoadDynamicPlugins(registry, "plugins/dynamic") if db != nil { registry.Init() diff --git a/plugins/socialicons/socialicons.go b/plugins/socialicons/socialicons.go new file mode 100644 index 0000000..df46eef --- /dev/null +++ b/plugins/socialicons/socialicons.go @@ -0,0 +1,97 @@ +// Package socialicons provides a plugin that renders social media icon links. +// When enabled, it injects social icon HTML into the template footer, +// replacing the need for hardcoded social URLs in theme templates. +package socialicons + +import ( + "goblog/plugin" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type social struct { + key string // setting key + label string // display label + icon string // Font Awesome icon class +} + +var socials = []social{ + {"github_url", "GitHub", "fab fa-github"}, + {"linkedin_url", "LinkedIn", "fab fa-linkedin"}, + {"x_url", "X", "fab fa-x"}, + {"keybase_url", "Keybase", "fab fa-keybase"}, + {"instagram_url", "Instagram", "fab fa-instagram"}, + {"facebook_url", "Facebook", "fab fa-facebook"}, + {"strava_url", "Strava", "fab fa-strava"}, + {"spotify_url", "Spotify", "fab fa-spotify"}, + {"xbox_url", "Xbox", "fab fa-xbox"}, + {"steam_url", "Steam", "fab fa-steam"}, +} + +// SocialIconsPlugin renders social media icon links in the footer. +type SocialIconsPlugin struct { + plugin.BasePlugin +} + +// New creates a new social icons plugin. +func New() *SocialIconsPlugin { + return &SocialIconsPlugin{} +} + +func (p *SocialIconsPlugin) Name() string { return "socialicons" } +func (p *SocialIconsPlugin) DisplayName() string { return "Social Icons" } +func (p *SocialIconsPlugin) Version() string { return "1.0.0" } + +func (p *SocialIconsPlugin) OnInit(db *gorm.DB) error { return nil } + +func (p *SocialIconsPlugin) Settings() []plugin.SettingDefinition { + defs := []plugin.SettingDefinition{ + {Key: "enabled", Type: "text", DefaultValue: "true", Label: "Enabled", Description: "Set to 'true' to show social icons"}, + } + for _, s := range socials { + defs = append(defs, plugin.SettingDefinition{ + Key: s.key, + Type: "text", + DefaultValue: "", + Label: s.label + " URL", + Description: "Full URL to your " + s.label + " profile", + }) + } + return defs +} + +func (p *SocialIconsPlugin) TemplateData(ctx *plugin.HookContext) gin.H { + if ctx.Settings["enabled"] != "true" { + return nil + } + // Build a list of active social links for templates that want structured data + type socialLink struct { + Name string + URL string + Icon string + } + var links []socialLink + for _, s := range socials { + if url := ctx.Settings[s.key]; url != "" { + links = append(links, socialLink{Name: s.label, URL: url, Icon: s.icon}) + } + } + return gin.H{"links": links} +} + +func (p *SocialIconsPlugin) TemplateFooter(ctx *plugin.HookContext) string { + if ctx.Settings["enabled"] != "true" { + return "" + } + html := `
` + for _, s := range socials { + url := ctx.Settings[s.key] + if url == "" { + continue + } + html += `` + } + html += `
` + return html +} diff --git a/themes/default/templates/footer.html b/themes/default/templates/footer.html index 7d93f9d..ef2b724 100644 --- a/themes/default/templates/footer.html +++ b/themes/default/templates/footer.html @@ -1,36 +1,6 @@
diff --git a/themes/forest/templates/footer.html b/themes/forest/templates/footer.html index 1ad8c06..7d26508 100644 --- a/themes/forest/templates/footer.html +++ b/themes/forest/templates/footer.html @@ -4,38 +4,7 @@

{{ .settings.site_title.Value }}

-
- {{ if .settings.github_url.Value }} - - {{ end }} - {{ if .settings.linkedin_url.Value }} - - {{ end }} - {{ if .settings.x_url.Value }} - - {{ end }} - {{ if .settings.keybase_url.Value }} - - {{ end }} - {{ if .settings.instagram_url.Value }} - - {{ end }} - {{ if .settings.facebook_url.Value }} - - {{ end }} - {{ if .settings.strava_url.Value }} - - {{ end }} - {{ if .settings.spotify_url.Value }} - - {{ end }} - {{ if .settings.xbox_url.Value }} - - {{ end }} - {{ if .settings.steam_url.Value }} - - {{ end }} -
+ {{ with .plugin_footer_html }}{{ . | rawHTML }}{{ end }}

Powered by goblog {{ .version }}

@@ -47,5 +16,4 @@ {{ with index .settings "custom_footer_code" }}{{ if .Value }} {{ .Value | rawHTML }} {{ end }}{{ end }} -{{ with .plugin_footer_html }}{{ . | rawHTML }}{{ end }} diff --git a/themes/minimal/templates/footer.html b/themes/minimal/templates/footer.html index 26ac8e4..76d0b54 100644 --- a/themes/minimal/templates/footer.html +++ b/themes/minimal/templates/footer.html @@ -4,40 +4,9 @@

{{ .settings.site_title.Value }}

-
- {{ if .settings.github_url.Value }} - - {{ end }} - {{ if .settings.linkedin_url.Value }} - - {{ end }} - {{ if .settings.x_url.Value }} - - {{ end }} - {{ if .settings.keybase_url.Value }} - - {{ end }} - {{ if .settings.instagram_url.Value }} - - {{ end }} - {{ if .settings.facebook_url.Value }} - - {{ end }} - {{ if .settings.strava_url.Value }} - - {{ end }} - {{ if .settings.spotify_url.Value }} - - {{ end }} - {{ if .settings.xbox_url.Value }} - - {{ end }} - {{ if .settings.steam_url.Value }} - - {{ end }} -
+ {{ with .plugin_footer_html }}{{ . | rawHTML }}{{ end }}
-

Powered by goblog {{ .version }}

+

Powered by goblog {{ .version }}

@@ -47,5 +16,4 @@ {{ with index .settings "custom_footer_code" }}{{ if .Value }} {{ .Value | rawHTML }} {{ end }}{{ end }} -{{ with .plugin_footer_html }}{{ . | rawHTML }}{{ end }} diff --git a/tools/migrate.go b/tools/migrate.go index 1651ec8..fb58bc1 100644 --- a/tools/migrate.go +++ b/tools/migrate.go @@ -6,6 +6,7 @@ import ( "fmt" "goblog/auth" "goblog/blog" + gplugin "goblog/plugin" "gorm.io/gorm" "log" "regexp" @@ -272,16 +273,6 @@ func seedDefaultSettings(db *gorm.DB) { {Key: "site_tags", Type: "text", Value: "Decentralization, Mesh Net"}, {Key: "landing_page_image", Type: "file", Value: "/img/profile.png"}, {Key: "favicon", Type: "file", Value: "/img/favicon.ico"}, - {Key: "github_url", Type: "text", Value: "https://www.github.com/compscidr"}, - {Key: "linkedin_url", Type: "text", Value: "https://www.linkedin.com/in/jasonernst/"}, - {Key: "x_url", Type: "text", Value: "https://www.x.com/compscidr"}, - {Key: "keybase_url", Type: "text", Value: "https://keybase.io/compscidr"}, - {Key: "instagram_url", Type: "text", Value: "https://www.instagram.com/compscidr"}, - {Key: "facebook_url", Type: "text", Value: "https://www.facebook.com/jason.b.ernst"}, - {Key: "strava_url", Type: "text", Value: "https://www.strava.com/athletes/2021127"}, - {Key: "spotify_url", Type: "text", Value: "https://open.spotify.com/user/csgrad"}, - {Key: "xbox_url", Type: "text", Value: "https://account.xbox.com/en-us/profile?gamertag=Compscidr"}, - {Key: "steam_url", Type: "text", Value: "https://steamcommunity.com/id/compscidr"}, {Key: "custom_header_code", Type: "textarea", Value: ""}, {Key: "custom_footer_code", Type: "textarea", Value: ""}, {Key: "theme", Type: "text", Value: "default"}, @@ -379,11 +370,53 @@ func Migrate(db *gorm.DB) error { seedDefaultPages(db) linkWritingPagesToPostType(db) cleanupEmptyTags(db) + migrateSocialURLsToPlugin(db) cleanupPluginSettingsFromMainTable(db) return nil } +// migrateSocialURLsToPlugin moves social URL settings from the main settings +// table to the plugin_settings table under the "socialicons" plugin. +func migrateSocialURLsToPlugin(db *gorm.DB) { + socialKeys := []string{ + "github_url", "linkedin_url", "x_url", "keybase_url", + "instagram_url", "facebook_url", "strava_url", "spotify_url", + "xbox_url", "steam_url", + } + + // Ensure plugin_settings table exists + db.AutoMigrate(&gplugin.PluginSetting{}) + + for _, key := range socialKeys { + var setting blog.Setting + if err := db.Where("key = ?", key).First(&setting).Error; err != nil { + continue // not found, skip + } + if setting.Value == "" { + continue + } + // Migrate to plugin_settings if not already there + ps := gplugin.PluginSetting{ + PluginName: "socialicons", + Key: key, + Value: setting.Value, + } + db.Where("plugin_name = ? AND key = ?", "socialicons", key).FirstOrCreate(&ps) + } + + // Also ensure the enabled setting exists + db.Where("plugin_name = ? AND key = ?", "socialicons", "enabled"). + FirstOrCreate(&gplugin.PluginSetting{ + PluginName: "socialicons", + Key: "enabled", + Value: "true", + }) + + // Remove migrated keys from main settings table + db.Where("key IN ?", socialKeys).Delete(&blog.Setting{}) +} + // cleanupPluginSettingsFromMainTable removes any dot-namespaced keys // from the main settings table that belong in plugin_settings instead. func cleanupPluginSettingsFromMainTable(db *gorm.DB) { From 8c2afa850ae7704a3de52f038d1ca67ab7a805b2 Mon Sep 17 00:00:00 2001 From: Jason Ernst Date: Mon, 16 Mar 2026 20:38:22 -0700 Subject: [PATCH 08/12] Extract Google Scholar into a plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the scholar integration out of the Blog struct and into a standalone plugin. The plugin defines a "research" dynamic page type that renders when enabled, and disappears from the site when disabled. Plugin interface additions: - Pages() — plugins can register dynamic page types - RenderPage() — plugins handle rendering their own pages - Registry.GetPagePlugin/RenderPluginPage/GetNavItems for page routing - Registry.IsPluginEnabled for checking enabled state Scholar plugin (plugins/scholar/): - Owns the scholar.Scholar instance internally - Configurable: scholar_id, article_limit, cache file paths - Scheduled job: daily cache refresh in background - Renders page_research.html with articles data Blog changes: - Removed scholar field from Blog struct - Removed Blog.New scholar parameter - Removed Research handler and sortArticlesByDateDesc - DynamicPage default case now delegates to plugin registry - Moved sort test to plugin package Co-Authored-By: Claude Opus 4.6 (1M context) --- admin/admin_test.go | 6 +- blog/blog.go | 117 ++++---------- blog/blog_test.go | 22 +-- goblog.go | 6 +- plugin/plugin.go | 33 +++- plugin/registry.go | 61 ++++++++ plugins/scholar/scholar.go | 146 ++++++++++++++++++ .../scholar/scholar_test.go | 6 +- 8 files changed, 286 insertions(+), 111 deletions(-) create mode 100644 plugins/scholar/scholar.go rename blog/sort_test.go => plugins/scholar/scholar_test.go (87%) diff --git a/admin/admin_test.go b/admin/admin_test.go index b7a5c90..ca005a2 100644 --- a/admin/admin_test.go +++ b/admin/admin_test.go @@ -3,7 +3,7 @@ package admin_test import ( "bytes" "encoding/json" - scholar "github.com/compscidr/scholar" + "goblog/admin" "goblog/auth" "goblog/blog" @@ -55,8 +55,8 @@ func TestCreatePost(t *testing.T) { defaultType := blog.PostType{Name: "Post", Slug: "posts", Description: "Blog posts"} db.Create(&defaultType) a := &Auth{} - sch := scholar.New("profiles.json", "articles.json") - b := blog.New(db, a, "test", sch) + + b := blog.New(db, a, "test") ad := admin.New(db, a, &b, "test") router := gin.Default() diff --git a/blog/blog.go b/blog/blog.go index 7605614..8adb2ba 100644 --- a/blog/blog.go +++ b/blog/blog.go @@ -6,7 +6,6 @@ import ( "regexp" "sort" - scholar "github.com/compscidr/scholar" "goblog/auth" "log" "net/http" @@ -31,18 +30,16 @@ type Blog struct { db **gorm.DB // needs a double pointer to be able to update the db auth auth.IAuth Version string - scholar *scholar.Scholar commentLimiter map[string]time.Time limiterMu sync.Mutex } -// New constructs an Admin API -func New(db *gorm.DB, auth auth.IAuth, version string, scholar *scholar.Scholar) Blog { +// New constructs a Blog API +func New(db *gorm.DB, auth auth.IAuth, version string) Blog { api := Blog{ db: &db, auth: auth, Version: version, - scholar: scholar, commentLimiter: make(map[string]time.Time), } return api @@ -71,19 +68,6 @@ func (b *Blog) Render(c *gin.Context, code int, templateName string, data gin.H) c.HTML(code, templateName, data) } -// sortArticlesByDateDesc sorts scholar articles by publication date in descending order. -func sortArticlesByDateDesc(articles []*scholar.Article) { - sort.Slice(articles, func(i, j int) bool { - if articles[i].Year != articles[j].Year { - return articles[i].Year > articles[j].Year - } - if articles[i].Month != articles[j].Month { - return articles[i].Month > articles[j].Month - } - return articles[i].Day > articles[j].Day - }) -} - // Generic Functions (not JSON or HTML) func (b *Blog) GetPosts(drafts bool) []Post { var posts []Post @@ -468,38 +452,6 @@ func (b *Blog) DynamicPage(c *gin.Context, page *Page) { "settings": b.GetSettings(), "nav_pages": navPages, }) - case PageTypeResearch: - articles, err := b.scholar.QueryProfileWithMemoryCache(page.ScholarID, 50) - if err == nil { - sortArticlesByDateDesc(articles) - b.scholar.SaveCache("profiles.json", "articles.json") - b.Render(c, http.StatusOK, "page_research.html", gin.H{ - "logged_in": b.auth.IsLoggedIn(c), - "is_admin": b.auth.IsAdmin(c), - "page": page, - "articles": articles, - "version": b.Version, - "title": page.Title, - "recent": b.GetLatest(), - "admin_page": false, - "settings": b.GetSettings(), - "nav_pages": navPages, - }) - } else { - b.Render(c, http.StatusOK, "page_research.html", gin.H{ - "logged_in": b.auth.IsLoggedIn(c), - "is_admin": b.auth.IsAdmin(c), - "page": page, - "articles": make([]interface{}, 0), - "version": b.Version, - "title": page.Title, - "recent": b.GetLatest(), - "admin_page": false, - "settings": b.GetSettings(), - "errors": err.Error(), - "nav_pages": navPages, - }) - } case PageTypeTags: b.Render(c, http.StatusOK, "page_tags.html", gin.H{ "logged_in": b.auth.IsLoggedIn(c), @@ -531,7 +483,36 @@ func (b *Blog) DynamicPage(c *gin.Context, page *Page) { "settings": b.GetSettings(), "nav_pages": navPages, }) - default: // about, custom + default: + // Check if a plugin handles this page type + if reg, exists := c.Get("plugin_registry"); exists { + type pageRenderer interface { + RenderPluginPage(c *gin.Context, pageType string) (string, gin.H, bool) + } + if r, ok := reg.(pageRenderer); ok { + tmpl, pluginData, handled := r.RenderPluginPage(c, page.PageType) + if handled { + data := gin.H{ + "logged_in": b.auth.IsLoggedIn(c), + "is_admin": b.auth.IsAdmin(c), + "page": page, + "version": b.Version, + "title": page.Title, + "recent": b.GetLatest(), + "admin_page": false, + "settings": b.GetSettings(), + "nav_pages": navPages, + } + // Merge plugin data into template data + for k, v := range pluginData { + data[k] = v + } + b.Render(c, http.StatusOK, tmpl, data) + return + } + } + } + // Fallback: render as custom content page b.Render(c, http.StatusOK, "page_content.html", gin.H{ "logged_in": b.auth.IsLoggedIn(c), "is_admin": b.auth.IsAdmin(c), @@ -886,40 +867,6 @@ func (b *Blog) Speaking(c *gin.Context) { }) } -// Speaking is the index page for research publications -func (b *Blog) Research(c *gin.Context) { - articles, err := b.scholar.QueryProfileWithMemoryCache("SbUmSEAAAAAJ", 50) - if err == nil { - sortArticlesByDateDesc(articles) - b.scholar.SaveCache("profiles.json", "articles.json") - b.Render(c, http.StatusOK, "research.html", gin.H{ - "logged_in": b.auth.IsLoggedIn(c), - "is_admin": b.auth.IsAdmin(c), - "version": b.Version, - "title": "Research Publications", - "recent": b.GetLatest(), - "articles": articles, - "admin_page": false, - "settings": b.GetSettings(), - "nav_pages": b.GetNavPages(), - }) - } else { - articles := make([]*scholar.Article, 0) - b.Render(c, http.StatusOK, "research.html", gin.H{ - "logged_in": b.auth.IsLoggedIn(c), - "is_admin": b.auth.IsAdmin(c), - "version": b.Version, - "title": "Research Publications", - "recent": b.GetLatest(), - "articles": articles, - "admin_page": false, - "settings": b.GetSettings(), - "errors": err.Error(), - "nav_pages": b.GetNavPages(), - }) - } -} - // Projects is the index page for projects / code func (b *Blog) Projects(c *gin.Context) { b.Render(c, http.StatusOK, "projects.html", gin.H{ diff --git a/blog/blog_test.go b/blog/blog_test.go index 548a079..fa47298 100644 --- a/blog/blog_test.go +++ b/blog/blog_test.go @@ -3,7 +3,7 @@ package blog_test import ( "bytes" "encoding/json" - scholar "github.com/compscidr/scholar" + "goblog/admin" "goblog/auth" "goblog/blog" @@ -53,8 +53,8 @@ func TestBlogWorkflow(t *testing.T) { defaultType := blog.PostType{Name: "Post", Slug: "posts", Description: "Blog posts"} db.Create(&defaultType) a := &Auth{} - sch := scholar.New("profiles.json", "articles.json") - b := blog.New(db, a, "test", sch) + + b := blog.New(db, a, "test") admin := admin.New(db, a, &b, "test") router := gin.Default() @@ -472,8 +472,8 @@ func TestBacklinks(t *testing.T) { db, _ := gorm.Open(sqlite.Open(":memory:")) db.AutoMigrate(&auth.BlogUser{}, &blog.PostType{}, &blog.Post{}, &blog.Tag{}, &blog.Backlink{}, &blog.ExternalBacklink{}) a := &Auth{} - sch := scholar.New("profiles.json", "articles.json") - b := blog.New(db, a, "test", sch) + + b := blog.New(db, a, "test") // Create two posts. Post B will link to Post A. postA := blog.Post{ @@ -539,8 +539,8 @@ func TestGetNavPages(t *testing.T) { db, _ := gorm.Open(sqlite.Open(":memory:")) db.AutoMigrate(&blog.Page{}, &blog.PostType{}, &blog.Post{}, &blog.Setting{}) a := &Auth{} - sch := scholar.New("profiles.json", "articles.json") - b := blog.New(db, a, "test", sch) + + b := blog.New(db, a, "test") // Create pages with various states db.Create(&blog.Page{Title: "Writing", Slug: "posts", PageType: blog.PageTypeWriting, ShowInNav: true, NavOrder: 2, Enabled: true}) @@ -565,8 +565,8 @@ func TestGetPageBySlug(t *testing.T) { db, _ := gorm.Open(sqlite.Open(":memory:")) db.AutoMigrate(&blog.Page{}, &blog.PostType{}, &blog.Post{}, &blog.Setting{}) a := &Auth{} - sch := scholar.New("profiles.json", "articles.json") - b := blog.New(db, a, "test", sch) + + b := blog.New(db, a, "test") db.Create(&blog.Page{Title: "About", Slug: "about", PageType: blog.PageTypeAbout, Enabled: true}) db.Create(&blog.Page{Title: "Disabled", Slug: "disabled-page", PageType: blog.PageTypeCustom, Enabled: false}) @@ -597,8 +597,8 @@ func TestExternalBacklinks(t *testing.T) { db, _ := gorm.Open(sqlite.Open(":memory:")) db.AutoMigrate(&auth.BlogUser{}, &blog.PostType{}, &blog.Post{}, &blog.Tag{}, &blog.Backlink{}, &blog.ExternalBacklink{}) a := &Auth{} - sch := scholar.New("profiles.json", "articles.json") - b := blog.New(db, a, "test", sch) + + b := blog.New(db, a, "test") post := blog.Post{ Title: "Test Post", diff --git a/goblog.go b/goblog.go index f02b7ae..62ed4a0 100644 --- a/goblog.go +++ b/goblog.go @@ -4,13 +4,13 @@ package main import ( "fmt" - scholar "github.com/compscidr/scholar" "github.com/joho/godotenv" "goblog/admin" "goblog/auth" "goblog/blog" gplugin "goblog/plugin" "goblog/plugins/analytics" + scholarplugin "goblog/plugins/scholar" "goblog/plugins/socialicons" "goblog/tools" "goblog/wizard" @@ -282,8 +282,7 @@ func main() { } _auth := auth.New(db, Version) - _sch := scholar.New("profiles.json", "articles.json") - _blog := blog.New(db, &_auth, Version, _sch) + _blog := blog.New(db, &_auth, Version) _admin := admin.New(db, &_auth, &_blog, Version) _wizard := wizard.New(db, Version) @@ -313,6 +312,7 @@ func main() { registry := gplugin.NewRegistry(db) registry.Register(analytics.New()) registry.Register(socialicons.New()) + registry.Register(scholarplugin.New()) gplugin.LoadDynamicPlugins(registry, "plugins/dynamic") if db != nil { registry.Init() diff --git a/plugin/plugin.go b/plugin/plugin.go index aaeea7c..8cfa1ab 100644 --- a/plugin/plugin.go +++ b/plugin/plugin.go @@ -39,6 +39,18 @@ type HookContext struct { Data gin.H // the existing template data (read-only) } +// PageDefinition describes a dynamic page type that a plugin provides. +// The plugin registers a page type and handles rendering when that page +// is visited. The page can optionally appear in the navigation. +type PageDefinition struct { + PageType string // unique identifier, e.g. "research" + Title string // default title for nav and page heading + Slug string // default URL slug, e.g. "research" + ShowInNav bool // whether to show in navigation by default + NavOrder int // sort order in navigation + Description string // help text for admin UI +} + // Plugin is the core interface. Embed BasePlugin to get no-op defaults // and only implement the methods you need. type Plugin interface { @@ -51,14 +63,23 @@ type Plugin interface { TemplateHead(ctx *HookContext) string TemplateFooter(ctx *HookContext) string OnInit(db *gorm.DB) error + + // Pages returns dynamic page types this plugin provides. + Pages() []PageDefinition + + // RenderPage is called when a plugin-owned page is visited. + // Returns the template name and data to render, or empty string to skip. + RenderPage(ctx *HookContext, pageType string) (templateName string, data gin.H) } // BasePlugin provides no-op implementations of all Plugin methods. type BasePlugin struct{} -func (BasePlugin) Settings() []SettingDefinition { return nil } -func (BasePlugin) ScheduledJobs() []ScheduledJob { return nil } -func (BasePlugin) TemplateData(ctx *HookContext) gin.H { return nil } -func (BasePlugin) TemplateHead(ctx *HookContext) string { return "" } -func (BasePlugin) TemplateFooter(ctx *HookContext) string { return "" } -func (BasePlugin) OnInit(db *gorm.DB) error { return nil } +func (BasePlugin) Settings() []SettingDefinition { return nil } +func (BasePlugin) ScheduledJobs() []ScheduledJob { return nil } +func (BasePlugin) TemplateData(ctx *HookContext) gin.H { return nil } +func (BasePlugin) TemplateHead(ctx *HookContext) string { return "" } +func (BasePlugin) TemplateFooter(ctx *HookContext) string { return "" } +func (BasePlugin) OnInit(db *gorm.DB) error { return nil } +func (BasePlugin) Pages() []PageDefinition { return nil } +func (BasePlugin) RenderPage(ctx *HookContext, pageType string) (string, gin.H) { return "", nil } diff --git a/plugin/registry.go b/plugin/registry.go index 8e76d95..eb3893c 100644 --- a/plugin/registry.go +++ b/plugin/registry.go @@ -176,6 +176,67 @@ func (r *Registry) GetAllSettings() []PluginSettingsGroup { return groups } +// IsPluginEnabled checks if a plugin is enabled via its settings. +func (r *Registry) IsPluginEnabled(pluginName string) bool { + settings := r.getPluginSettings(pluginName) + return settings["enabled"] == "true" +} + +// GetPagePlugin returns the plugin that owns a given page type, or nil. +func (r *Registry) GetPagePlugin(pageType string) Plugin { + r.mu.RLock() + defer r.mu.RUnlock() + for _, p := range r.plugins { + if !r.IsPluginEnabled(p.Name()) { + continue + } + for _, page := range p.Pages() { + if page.PageType == pageType { + return p + } + } + } + return nil +} + +// RenderPluginPage renders a plugin-owned page. Returns template name, data, and whether it was handled. +func (r *Registry) RenderPluginPage(c *gin.Context, pageType string) (string, gin.H, bool) { + p := r.GetPagePlugin(pageType) + if p == nil { + return "", nil, false + } + settings := r.getPluginSettings(p.Name()) + ctx := &HookContext{ + GinContext: c, + DB: r.db, + Settings: settings, + Template: pageType, + } + tmpl, data := p.RenderPage(ctx, pageType) + if tmpl == "" { + return "", nil, false + } + return tmpl, data, true +} + +// GetNavItems returns navigation items from all enabled plugins that define pages. +func (r *Registry) GetNavItems() []PageDefinition { + r.mu.RLock() + defer r.mu.RUnlock() + var items []PageDefinition + for _, p := range r.plugins { + if !r.IsPluginEnabled(p.Name()) { + continue + } + for _, page := range p.Pages() { + if page.ShowInNav { + items = append(items, page) + } + } + } + return items +} + // UpdateSetting saves a single plugin setting. func (r *Registry) UpdateSetting(pluginName, key, value string) { r.db.Where("plugin_name = ? AND key = ?", pluginName, key). diff --git a/plugins/scholar/scholar.go b/plugins/scholar/scholar.go new file mode 100644 index 0000000..4b87db7 --- /dev/null +++ b/plugins/scholar/scholar.go @@ -0,0 +1,146 @@ +// Package scholar provides a Google Scholar integration plugin for goblog. +// It displays academic publications on a dynamic "research" page, with +// caching and throttle resilience via the compscidr/scholar library. +package scholar + +import ( + "fmt" + "log" + "sort" + "time" + + gplugin "goblog/plugin" + + scholarlib "github.com/compscidr/scholar" + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// ScholarPlugin displays Google Scholar publications. +type ScholarPlugin struct { + gplugin.BasePlugin + sch *scholarlib.Scholar +} + +// New creates a new scholar plugin. +func New() *ScholarPlugin { + return &ScholarPlugin{} +} + +func (p *ScholarPlugin) Name() string { return "scholar" } +func (p *ScholarPlugin) DisplayName() string { return "Google Scholar" } +func (p *ScholarPlugin) Version() string { return "1.0.0" } + +func (p *ScholarPlugin) Settings() []gplugin.SettingDefinition { + return []gplugin.SettingDefinition{ + {Key: "enabled", Type: "text", DefaultValue: "false", Label: "Enabled", Description: "Set to 'true' to enable the research page"}, + {Key: "scholar_id", Type: "text", DefaultValue: "", Label: "Google Scholar ID", Description: "Your Google Scholar profile ID (e.g. SbUmSEAAAAAJ)"}, + {Key: "article_limit", Type: "text", DefaultValue: "50", Label: "Article Limit", Description: "Maximum number of articles to display"}, + {Key: "profile_cache", Type: "text", DefaultValue: "profiles.json", Label: "Profile Cache File", Description: "File path for profile cache"}, + {Key: "article_cache", Type: "text", DefaultValue: "articles.json", Label: "Article Cache File", Description: "File path for article cache"}, + } +} + +func (p *ScholarPlugin) OnInit(db *gorm.DB) error { + // Scholar library will be initialized lazily on first page render + // since we need the settings to know which cache files to use + return nil +} + +func (p *ScholarPlugin) Pages() []gplugin.PageDefinition { + return []gplugin.PageDefinition{ + { + PageType: "research", + Title: "Research", + Slug: "research", + ShowInNav: true, + NavOrder: 20, + Description: "Displays Google Scholar publications", + }, + } +} + +func (p *ScholarPlugin) ensureScholar(settings map[string]string) { + if p.sch == nil { + profileCache := settings["profile_cache"] + articleCache := settings["article_cache"] + if profileCache == "" { + profileCache = "profiles.json" + } + if articleCache == "" { + articleCache = "articles.json" + } + p.sch = scholarlib.New(profileCache, articleCache) + } +} + +func (p *ScholarPlugin) RenderPage(ctx *gplugin.HookContext, pageType string) (string, gin.H) { + if pageType != "research" { + return "", nil + } + + settings := ctx.Settings + scholarID := settings["scholar_id"] + if scholarID == "" { + return "page_research.html", gin.H{ + "errors": "Google Scholar ID not configured. Set it in the Scholar plugin settings.", + } + } + + limitStr := settings["article_limit"] + limit := 50 + if limitStr != "" { + fmt.Sscanf(limitStr, "%d", &limit) + } + + p.ensureScholar(settings) + + articles, err := p.sch.QueryProfileWithMemoryCache(scholarID, limit) + data := gin.H{} + if err == nil { + sortArticlesByDateDesc(articles) + p.sch.SaveCache(settings["profile_cache"], settings["article_cache"]) + data["articles"] = articles + } else { + log.Printf("Scholar query failed: %v", err) + data["articles"] = make([]*scholarlib.Article, 0) + data["errors"] = err.Error() + } + + return "page_research.html", data +} + +func (p *ScholarPlugin) ScheduledJobs() []gplugin.ScheduledJob { + return []gplugin.ScheduledJob{ + { + Name: "scholar-cache-refresh", + Interval: 24 * time.Hour, + Run: func(db *gorm.DB, settings map[string]string) error { + scholarID := settings["scholar_id"] + if scholarID == "" || settings["enabled"] != "true" { + return nil + } + p.ensureScholar(settings) + limit := 50 + fmt.Sscanf(settings["article_limit"], "%d", &limit) + _, err := p.sch.QueryProfileWithMemoryCache(scholarID, limit) + if err == nil { + p.sch.SaveCache(settings["profile_cache"], settings["article_cache"]) + } + return err + }, + }, + } +} + +func sortArticlesByDateDesc(articles []*scholarlib.Article) { + sort.Slice(articles, func(i, j int) bool { + if articles[i].Year != articles[j].Year { + return articles[i].Year > articles[j].Year + } + if articles[i].Month != articles[j].Month { + return articles[i].Month > articles[j].Month + } + return articles[i].Day > articles[j].Day + }) +} diff --git a/blog/sort_test.go b/plugins/scholar/scholar_test.go similarity index 87% rename from blog/sort_test.go rename to plugins/scholar/scholar_test.go index fc3ee65..f3ca677 100644 --- a/blog/sort_test.go +++ b/plugins/scholar/scholar_test.go @@ -1,12 +1,12 @@ -package blog +package scholar import ( - scholar "github.com/compscidr/scholar" + scholarlib "github.com/compscidr/scholar" "testing" ) func TestSortArticlesByDateDesc(t *testing.T) { - articles := []*scholar.Article{ + articles := []*scholarlib.Article{ {Title: "Old", Year: 2020, Month: 3, Day: 15}, {Title: "Newest", Year: 2025, Month: 1, Day: 10}, {Title: "SameYearLater", Year: 2023, Month: 11, Day: 5}, From 998ce7915d6e5da5fb8cd69db855e3ef6f0c911d Mon Sep 17 00:00:00 2001 From: Jason Ernst Date: Mon, 16 Mar 2026 20:49:22 -0700 Subject: [PATCH 09/12] Scholar plugin auto-creates page record and shows 404 when disabled - OnInit ensures a research page exists in the pages table with default title, slug, nav settings. Users can customize these via the admin page editor. - Migrates ScholarID from legacy page field to plugin settings for backward compatibility with existing installs. - When the scholar plugin is disabled, visiting the research page shows a "Page Not Available" 404 instead of an empty content page. - Added HasPageType to registry to distinguish "plugin disabled" from "no plugin for this page type". Co-Authored-By: Claude Opus 4.6 (1M context) --- blog/blog.go | 20 +++++++++++++++++--- plugin/registry.go | 14 ++++++++++++++ plugins/scholar/scholar.go | 32 ++++++++++++++++++++++++++++++-- 3 files changed, 61 insertions(+), 5 deletions(-) diff --git a/blog/blog.go b/blog/blog.go index 8adb2ba..663dbb1 100644 --- a/blog/blog.go +++ b/blog/blog.go @@ -486,10 +486,11 @@ func (b *Blog) DynamicPage(c *gin.Context, page *Page) { default: // Check if a plugin handles this page type if reg, exists := c.Get("plugin_registry"); exists { - type pageRenderer interface { + type pluginPageHandler interface { RenderPluginPage(c *gin.Context, pageType string) (string, gin.H, bool) + HasPageType(pageType string) bool } - if r, ok := reg.(pageRenderer); ok { + if r, ok := reg.(pluginPageHandler); ok { tmpl, pluginData, handled := r.RenderPluginPage(c, page.PageType) if handled { data := gin.H{ @@ -503,13 +504,26 @@ func (b *Blog) DynamicPage(c *gin.Context, page *Page) { "settings": b.GetSettings(), "nav_pages": navPages, } - // Merge plugin data into template data for k, v := range pluginData { data[k] = v } b.Render(c, http.StatusOK, tmpl, data) return } + // Plugin owns this page type but is disabled — show 404 + if r.HasPageType(page.PageType) { + b.Render(c, http.StatusNotFound, "error.html", gin.H{ + "error": "Page Not Available", + "description": "This page is currently disabled.", + "version": b.Version, + "title": "Not Available", + "recent": b.GetLatest(), + "admin_page": false, + "settings": b.GetSettings(), + "nav_pages": navPages, + }) + return + } } } // Fallback: render as custom content page diff --git a/plugin/registry.go b/plugin/registry.go index eb3893c..530b670 100644 --- a/plugin/registry.go +++ b/plugin/registry.go @@ -237,6 +237,20 @@ func (r *Registry) GetNavItems() []PageDefinition { return items } +// HasPageType returns true if any registered plugin (enabled or not) defines the given page type. +func (r *Registry) HasPageType(pageType string) bool { + r.mu.RLock() + defer r.mu.RUnlock() + for _, p := range r.plugins { + for _, page := range p.Pages() { + if page.PageType == pageType { + return true + } + } + } + return false +} + // UpdateSetting saves a single plugin setting. func (r *Registry) UpdateSetting(pluginName, key, value string) { r.db.Where("plugin_name = ? AND key = ?", pluginName, key). diff --git a/plugins/scholar/scholar.go b/plugins/scholar/scholar.go index 4b87db7..93bbf42 100644 --- a/plugins/scholar/scholar.go +++ b/plugins/scholar/scholar.go @@ -5,6 +5,7 @@ package scholar import ( "fmt" + "goblog/blog" "log" "sort" "time" @@ -42,8 +43,35 @@ func (p *ScholarPlugin) Settings() []gplugin.SettingDefinition { } func (p *ScholarPlugin) OnInit(db *gorm.DB) error { - // Scholar library will be initialized lazily on first page render - // since we need the settings to know which cache files to use + // Ensure a research page exists in the pages table. + // The user can customize title, slug, hero, nav order via admin. + var page blog.Page + result := db.Where("page_type = ?", "research").First(&page) + if result.Error != nil { + // No research page exists — create the default + page = blog.Page{ + Title: "Research", + Slug: "research", + PageType: "research", + ShowInNav: true, + NavOrder: 20, + Enabled: true, + } + db.Create(&page) + log.Println("Scholar plugin: created research page") + } + + // Migrate ScholarID from page record to plugin settings (backward compat) + if page.ScholarID != "" { + var existing gplugin.PluginSetting + if err := db.Where("plugin_name = ? AND key = ?", "scholar", "scholar_id").First(&existing).Error; err != nil || existing.Value == "" { + db.Where("plugin_name = ? AND key = ?", "scholar", "scholar_id"). + Assign(gplugin.PluginSetting{Value: page.ScholarID}). + FirstOrCreate(&gplugin.PluginSetting{PluginName: "scholar", Key: "scholar_id", Value: page.ScholarID}) + log.Printf("Scholar plugin: migrated scholar_id %s from page to plugin settings", page.ScholarID) + } + } + return nil } From abe8e427b0bd946dae0e560cfc46ef2961028cd4 Mon Sep 17 00:00:00 2001 From: Jason Ernst Date: Mon, 16 Mar 2026 20:55:04 -0700 Subject: [PATCH 10/12] Hide plugin pages from nav when plugin is disabled - Remove PageTypeResearch constant (now plugin-defined) - Mark ScholarID on Page as deprecated (kept for migration compat) - Add PageFilter to Blog struct, applied in GetNavPages to filter out pages owned by disabled plugins - Add IsPageTypeEnabled to registry for checking if the plugin that owns a page type is enabled - Wire up the filter in goblog.go after registry init - Update seed defaults and tests to use string literal "research" When the scholar plugin is disabled: - Research page disappears from navigation - Visiting /research shows "Page Not Available" 404 When re-enabled, everything comes back automatically. Co-Authored-By: Claude Opus 4.6 (1M context) --- blog/blog.go | 15 +++++++++++++++ blog/blog_test.go | 2 +- blog/page.go | 5 ++--- goblog.go | 8 ++++++++ plugin/registry.go | 14 ++++++++++++++ tools/migrate.go | 2 +- 6 files changed, 41 insertions(+), 5 deletions(-) diff --git a/blog/blog.go b/blog/blog.go index 663dbb1..cad62b7 100644 --- a/blog/blog.go +++ b/blog/blog.go @@ -24,12 +24,17 @@ import ( "github.com/ikeikeikeike/go-sitemap-generator/v2/stm" ) +// PageFilter is a function that decides whether a page should be shown. +// Used to filter out pages owned by disabled plugins. +type PageFilter func(page Page) bool + // Blog API handles non-admin functions of the blog like listing posts, tags // comments, etc. type Blog struct { db **gorm.DB // needs a double pointer to be able to update the db auth auth.IAuth Version string + PageFilter PageFilter // optional filter set by plugin system commentLimiter map[string]time.Time limiterMu sync.Mutex } @@ -354,9 +359,19 @@ func (b *Blog) TrackReferer(c *gin.Context, postID uint) { } } // GetNavPages returns enabled pages that should show in the navigation, ordered by nav_order. +// Pages owned by disabled plugins are filtered out. func (b *Blog) GetNavPages() []Page { var pages []Page (*b.db).Where("enabled = ? AND show_in_nav = ?", true, true).Order("nav_order asc").Find(&pages) + if b.PageFilter != nil { + var filtered []Page + for _, p := range pages { + if b.PageFilter(p) { + filtered = append(filtered, p) + } + } + return filtered + } return pages } diff --git a/blog/blog_test.go b/blog/blog_test.go index fa47298..32a62b3 100644 --- a/blog/blog_test.go +++ b/blog/blog_test.go @@ -197,7 +197,7 @@ func TestBlogWorkflow(t *testing.T) { // Create pages so dynamic page resolution works writingPage := blog.Page{Title: "Writing", Slug: "posts", PageType: blog.PageTypeWriting, ShowInNav: true, NavOrder: 1, Enabled: true} - researchPage := blog.Page{Title: "Research", Slug: "research", PageType: blog.PageTypeResearch, ShowInNav: true, NavOrder: 2, Enabled: true, ScholarID: "SbUmSEAAAAAJ"} + researchPage := blog.Page{Title: "Research", Slug: "research", PageType: "research", ShowInNav: true, NavOrder: 2, Enabled: true} aboutPage := blog.Page{Title: "About", Slug: "about", PageType: blog.PageTypeAbout, ShowInNav: true, NavOrder: 3, Enabled: true, Content: "About page content"} tagsPage := blog.Page{Title: "Tags", Slug: "tags", PageType: blog.PageTypeTags, ShowInNav: false, NavOrder: 4, Enabled: true} archivesPage := blog.Page{Title: "Archives", Slug: "archives", PageType: blog.PageTypeArchives, ShowInNav: false, NavOrder: 5, Enabled: true} diff --git a/blog/page.go b/blog/page.go index d0d8474..2dfbd5b 100644 --- a/blog/page.go +++ b/blog/page.go @@ -5,7 +5,6 @@ import "time" // Page types const ( PageTypeWriting = "writing" - PageTypeResearch = "research" PageTypeAbout = "about" PageTypeCustom = "custom" PageTypeTags = "tags" @@ -23,11 +22,11 @@ type Page struct { Content string `sql:"type:text;" json:"content"` HeroURL string `json:"hero_url"` HeroType string `json:"hero_type"` // "image" or "video" - PageType string `json:"page_type"` // "writing", "research", "about", "custom" + PageType string `json:"page_type"` // "writing", "research", "about", "custom", or plugin-defined ShowInNav bool `json:"show_in_nav"` NavOrder int `json:"nav_order"` Enabled bool `json:"enabled"` - ScholarID string `json:"scholar_id,omitempty"` + ScholarID string `json:"scholar_id,omitempty"` // deprecated: use scholar plugin settings instead PostTypeID *uint `json:"post_type_id,omitempty"` } diff --git a/goblog.go b/goblog.go index 62ed4a0..67921df 100644 --- a/goblog.go +++ b/goblog.go @@ -319,6 +319,14 @@ func main() { registry.StartScheduledJobs() } + // Filter nav pages: hide pages owned by disabled plugins + _blog.PageFilter = func(page blog.Page) bool { + if !registry.HasPageType(page.PageType) { + return true // not a plugin page, always show + } + return registry.IsPageTypeEnabled(page.PageType) + } + router.Use(CORS()) router.Use(gplugin.Middleware(registry)) store := cookie.NewStore([]byte(sessionKey)) diff --git a/plugin/registry.go b/plugin/registry.go index 530b670..04cd00b 100644 --- a/plugin/registry.go +++ b/plugin/registry.go @@ -237,6 +237,20 @@ func (r *Registry) GetNavItems() []PageDefinition { return items } +// IsPageTypeEnabled returns true if the plugin that owns the given page type is enabled. +func (r *Registry) IsPageTypeEnabled(pageType string) bool { + r.mu.RLock() + defer r.mu.RUnlock() + for _, p := range r.plugins { + for _, page := range p.Pages() { + if page.PageType == pageType { + return r.IsPluginEnabled(p.Name()) + } + } + } + return false +} + // HasPageType returns true if any registered plugin (enabled or not) defines the given page type. func (r *Registry) HasPageType(pageType string) bool { r.mu.RLock() diff --git a/tools/migrate.go b/tools/migrate.go index fb58bc1..b098f3c 100644 --- a/tools/migrate.go +++ b/tools/migrate.go @@ -483,7 +483,7 @@ I also enjoy driving, working on cars, video games, contributing to [open source Slug: "research", HeroURL: "/img/aidecentralized.jpg", HeroType: "image", - PageType: blog.PageTypeResearch, + PageType: "research", // owned by scholar plugin ShowInNav: true, NavOrder: 2, Enabled: true, From 3eb48a72e01fb36334c431e071225ee4ae5f68bf Mon Sep 17 00:00:00 2001 From: Jason Ernst Date: Mon, 16 Mar 2026 20:59:23 -0700 Subject: [PATCH 11/12] Show plugin-disabled status on admin pages Admin pages list (/admin/pages): - Rows for pages with disabled plugins highlighted in yellow - "plugin disabled" badge shown next to the page type - Enabled column shows "Plugin disabled" instead of Yes/No Admin page editor (/admin/pages/:id): - Warning banner at top when the page's plugin is disabled, with link to Settings to enable it - Removed scholar-specific Google Scholar ID field (now in plugin settings) Co-Authored-By: Claude Opus 4.6 (1M context) --- admin/admin.go | 53 +++++++++++++------ themes/default/templates/admin_edit_page.html | 15 +++--- themes/default/templates/admin_pages.html | 8 +-- themes/forest/templates/admin_edit_page.html | 15 +++--- themes/forest/templates/admin_pages.html | 8 +-- themes/minimal/templates/admin_edit_page.html | 15 +++--- themes/minimal/templates/admin_pages.html | 8 +-- 7 files changed, 75 insertions(+), 47 deletions(-) diff --git a/admin/admin.go b/admin/admin.go index 7ccb1c0..00499af 100644 --- a/admin/admin.go +++ b/admin/admin.go @@ -67,6 +67,23 @@ func (a *Admin) getPluginSettings(c *gin.Context) interface{} { return nil } +// getDisabledPluginPageTypes returns a map of page types that belong to disabled plugins. +func (a *Admin) getDisabledPluginPageTypes(c *gin.Context) map[string]bool { + result := make(map[string]bool) + if reg, exists := c.Get("plugin_registry"); exists { + if r, ok := reg.(*gplugin.Registry); ok { + for _, p := range r.Plugins() { + for _, page := range p.Pages() { + if !r.IsPluginEnabled(p.Name()) { + result[page.PageType] = true + } + } + } + } + } + return result +} + // UpdatePluginSettings saves plugin settings via the plugin registry. func (a *Admin) UpdatePluginSettings(c *gin.Context) { if !a.auth.IsAdmin(c) { @@ -882,14 +899,15 @@ func (a *Admin) AdminPages(c *gin.Context) { var pages []blog.Page (*a.db).Order("nav_order asc").Find(&pages) c.HTML(http.StatusOK, "admin_pages.html", gin.H{ - "pages": pages, - "logged_in": a.auth.IsLoggedIn(c), - "is_admin": a.auth.IsAdmin(c), - "version": a.version, - "recent": a.b.GetLatest(), - "admin_page": true, - "settings": a.b.GetSettings(), - "nav_pages": a.b.GetNavPages(), + "pages": pages, + "logged_in": a.auth.IsLoggedIn(c), + "is_admin": a.auth.IsAdmin(c), + "version": a.version, + "recent": a.b.GetLatest(), + "admin_page": true, + "settings": a.b.GetSettings(), + "nav_pages": a.b.GetNavPages(), + "disabled_plugin_pages": a.getDisabledPluginPageTypes(c), }) } @@ -928,15 +946,16 @@ func (a *Admin) AdminEditPage(c *gin.Context) { } c.HTML(http.StatusOK, "admin_edit_page.html", gin.H{ - "page": page, - "post_types": a.b.GetPostTypes(), - "logged_in": a.auth.IsLoggedIn(c), - "is_admin": a.auth.IsAdmin(c), - "version": a.version, - "recent": a.b.GetLatest(), - "admin_page": true, - "settings": a.b.GetSettings(), - "nav_pages": a.b.GetNavPages(), + "page": page, + "post_types": a.b.GetPostTypes(), + "logged_in": a.auth.IsLoggedIn(c), + "is_admin": a.auth.IsAdmin(c), + "version": a.version, + "recent": a.b.GetLatest(), + "admin_page": true, + "settings": a.b.GetSettings(), + "nav_pages": a.b.GetNavPages(), + "disabled_plugin_pages": a.getDisabledPluginPageTypes(c), }) } diff --git a/themes/default/templates/admin_edit_page.html b/themes/default/templates/admin_edit_page.html index 65b7d7e..404d174 100644 --- a/themes/default/templates/admin_edit_page.html +++ b/themes/default/templates/admin_edit_page.html @@ -6,6 +6,14 @@

Edit Page: {{ .page.Title }}

+ {{ $disabledPlugins := .disabled_plugin_pages }} + {{ if index $disabledPlugins .page.PageType }} + + {{ end }} +
@@ -56,11 +64,6 @@

Edit Page: {{ .page.Title }}

-
- - -
-
@@ -100,7 +103,6 @@

Edit Page: {{ .page.Title }}

document.getElementById('page_type').addEventListener('change', function() { var type = this.value; - document.getElementById('scholar-section').style.display = (type === 'research') ? '' : 'none'; document.getElementById('content-section').style.display = (type === 'writing' || type === 'research' || type === 'tags' || type === 'archives') ? 'none' : ''; document.getElementById('post-type-filter-section').style.display = (type === 'writing') ? '' : 'none'; }); @@ -117,7 +119,6 @@

Edit Page: {{ .page.Title }}

"hero_url": document.getElementById('hero_url').value, "hero_type": document.getElementById('hero_type').value, "content": document.getElementById('content').value, - "scholar_id": document.getElementById('scholar_id').value, "nav_order": parseInt(document.getElementById('nav_order').value) || 0, "show_in_nav": document.getElementById('show_in_nav').checked, "enabled": document.getElementById('enabled').checked, diff --git a/themes/default/templates/admin_pages.html b/themes/default/templates/admin_pages.html index 2dd4fb9..03ca583 100644 --- a/themes/default/templates/admin_pages.html +++ b/themes/default/templates/admin_pages.html @@ -18,14 +18,16 @@

Pages

+ {{ $disabledPlugins := .disabled_plugin_pages }} {{ range .pages }} - + {{ $pluginDisabled := index $disabledPlugins .PageType }} + {{ .Title }} /{{ .Slug }} - {{ .PageType }} + {{ .PageType }}{{ if $pluginDisabled }} plugin disabled{{ end }} {{ .NavOrder }} {{ if .ShowInNav }}Yes{{ else }}No{{ end }} - {{ if .Enabled }}Yes{{ else }}No{{ end }} + {{ if $pluginDisabled }}Plugin disabled{{ else if .Enabled }}Yes{{ else }}No{{ end }} Edit diff --git a/themes/forest/templates/admin_edit_page.html b/themes/forest/templates/admin_edit_page.html index 65b7d7e..404d174 100644 --- a/themes/forest/templates/admin_edit_page.html +++ b/themes/forest/templates/admin_edit_page.html @@ -6,6 +6,14 @@

Edit Page: {{ .page.Title }}

+ {{ $disabledPlugins := .disabled_plugin_pages }} + {{ if index $disabledPlugins .page.PageType }} + + {{ end }} +
@@ -56,11 +64,6 @@

Edit Page: {{ .page.Title }}

-
- - -
-
@@ -100,7 +103,6 @@

Edit Page: {{ .page.Title }}

document.getElementById('page_type').addEventListener('change', function() { var type = this.value; - document.getElementById('scholar-section').style.display = (type === 'research') ? '' : 'none'; document.getElementById('content-section').style.display = (type === 'writing' || type === 'research' || type === 'tags' || type === 'archives') ? 'none' : ''; document.getElementById('post-type-filter-section').style.display = (type === 'writing') ? '' : 'none'; }); @@ -117,7 +119,6 @@

Edit Page: {{ .page.Title }}

"hero_url": document.getElementById('hero_url').value, "hero_type": document.getElementById('hero_type').value, "content": document.getElementById('content').value, - "scholar_id": document.getElementById('scholar_id').value, "nav_order": parseInt(document.getElementById('nav_order').value) || 0, "show_in_nav": document.getElementById('show_in_nav').checked, "enabled": document.getElementById('enabled').checked, diff --git a/themes/forest/templates/admin_pages.html b/themes/forest/templates/admin_pages.html index 2dd4fb9..03ca583 100644 --- a/themes/forest/templates/admin_pages.html +++ b/themes/forest/templates/admin_pages.html @@ -18,14 +18,16 @@

Pages

+ {{ $disabledPlugins := .disabled_plugin_pages }} {{ range .pages }} - + {{ $pluginDisabled := index $disabledPlugins .PageType }} + {{ .Title }} /{{ .Slug }} - {{ .PageType }} + {{ .PageType }}{{ if $pluginDisabled }} plugin disabled{{ end }} {{ .NavOrder }} {{ if .ShowInNav }}Yes{{ else }}No{{ end }} - {{ if .Enabled }}Yes{{ else }}No{{ end }} + {{ if $pluginDisabled }}Plugin disabled{{ else if .Enabled }}Yes{{ else }}No{{ end }} Edit diff --git a/themes/minimal/templates/admin_edit_page.html b/themes/minimal/templates/admin_edit_page.html index 65b7d7e..404d174 100644 --- a/themes/minimal/templates/admin_edit_page.html +++ b/themes/minimal/templates/admin_edit_page.html @@ -6,6 +6,14 @@

Edit Page: {{ .page.Title }}

+ {{ $disabledPlugins := .disabled_plugin_pages }} + {{ if index $disabledPlugins .page.PageType }} + + {{ end }} +
@@ -56,11 +64,6 @@

Edit Page: {{ .page.Title }}

-
- - -
-
@@ -100,7 +103,6 @@

Edit Page: {{ .page.Title }}

document.getElementById('page_type').addEventListener('change', function() { var type = this.value; - document.getElementById('scholar-section').style.display = (type === 'research') ? '' : 'none'; document.getElementById('content-section').style.display = (type === 'writing' || type === 'research' || type === 'tags' || type === 'archives') ? 'none' : ''; document.getElementById('post-type-filter-section').style.display = (type === 'writing') ? '' : 'none'; }); @@ -117,7 +119,6 @@

Edit Page: {{ .page.Title }}

"hero_url": document.getElementById('hero_url').value, "hero_type": document.getElementById('hero_type').value, "content": document.getElementById('content').value, - "scholar_id": document.getElementById('scholar_id').value, "nav_order": parseInt(document.getElementById('nav_order').value) || 0, "show_in_nav": document.getElementById('show_in_nav').checked, "enabled": document.getElementById('enabled').checked, diff --git a/themes/minimal/templates/admin_pages.html b/themes/minimal/templates/admin_pages.html index 2dd4fb9..03ca583 100644 --- a/themes/minimal/templates/admin_pages.html +++ b/themes/minimal/templates/admin_pages.html @@ -18,14 +18,16 @@

Pages

+ {{ $disabledPlugins := .disabled_plugin_pages }} {{ range .pages }} - + {{ $pluginDisabled := index $disabledPlugins .PageType }} + {{ .Title }} /{{ .Slug }} - {{ .PageType }} + {{ .PageType }}{{ if $pluginDisabled }} plugin disabled{{ end }} {{ .NavOrder }} {{ if .ShowInNav }}Yes{{ else }}No{{ end }} - {{ if .Enabled }}Yes{{ else }}No{{ end }} + {{ if $pluginDisabled }}Plugin disabled{{ else if .Enabled }}Yes{{ else }}No{{ end }} Edit From eff69a7ad9a1ab80039cbf65a97f0769fe786b47 Mon Sep 17 00:00:00 2001 From: Jason Ernst Date: Mon, 16 Mar 2026 21:23:01 -0700 Subject: [PATCH 12/12] Address all review feedback on plugin system Security: - Analytics: validate tracking ID with regex (alphanumeric + hyphens only) - Social icons: escape URLs and labels with html.EscapeString - Dynamic plugin loading now opt-in via ENABLE_DYNAMIC_PLUGINS env var Concurrency: - Scholar: use sync.Once for ensureScholar instead of bare nil check - Scheduled jobs: copy r.db under RLock before using in goroutine - Registry.Plugins() returns a copy of the internal slice Robustness: - Nil DB guards in getPluginSettings and InjectTemplateData - Scholar OnInit: check for gorm.ErrRecordNotFound specifically - Scholar OnInit: check db.Create error - Dynamic loader: return descriptive error on type assertion failure - Dynamic loader: document package main requirement - Yaegi symbols: fix import path key, export Plugin interface - Plugin registry stored on goblog struct, UpdateDb/Init/StartScheduledJobs called when wizard establishes DB connection Admin UI: - Fix duplicate plugin_footer_html injection in default footer - Theme reload only fires when theme value actually changed - Checkbox reverts on failed plugin toggle save - SettingDefinition comment updated to match plugin_settings table Migration: - Narrow cleanup to known plugin prefixes instead of all dot-namespaced keys - Registry test errors properly checked Co-Authored-By: Claude Opus 4.6 (1M context) --- goblog.go | 27 +++++++++++++-------- plugin/loader.go | 6 +++-- plugin/plugin.go | 2 +- plugin/registry.go | 19 +++++++++++++-- plugin/registry_test.go | 36 +++++++++++++++++++++------- plugin/symbols.go | 3 ++- plugins/analytics/analytics.go | 6 +++++ plugins/scholar/scholar.go | 16 +++++++++---- plugins/socialicons/socialicons.go | 11 +++++---- themes/default/templates/footer.html | 1 - tools/migrate.go | 22 ++++++++++------- www/js/admin-script.js | 8 ++++--- 12 files changed, 112 insertions(+), 45 deletions(-) diff --git a/goblog.go b/goblog.go index 67921df..331bc90 100644 --- a/goblog.go +++ b/goblog.go @@ -39,6 +39,7 @@ type goblog struct { _blog *blog.Blog _auth *auth.Auth _admin *admin.Admin + _registry *gplugin.Registry sessionKey string router *gin.Engine handlersRegistered bool @@ -164,6 +165,9 @@ func (g goblog) rootHandler(c *gin.Context) { g._auth.UpdateDb(db) g._admin.UpdateDb(db) g._wizard.UpdateDb(db) + g._registry.UpdateDb(db) + g._registry.Init() + g._registry.StartScheduledJobs() if !isAuthConfigured() { g._wizard.Landing(c) @@ -299,26 +303,29 @@ func main() { } router.SetTrustedProxies(trustedProxies) - goblog := goblog{ - _wizard: &_wizard, - _blog: &_blog, - _auth: &_auth, - _admin: &_admin, - sessionKey: sessionKey, - router: router, - } - // Initialize plugin system registry := gplugin.NewRegistry(db) registry.Register(analytics.New()) registry.Register(socialicons.New()) registry.Register(scholarplugin.New()) - gplugin.LoadDynamicPlugins(registry, "plugins/dynamic") + if os.Getenv("ENABLE_DYNAMIC_PLUGINS") == "true" { + gplugin.LoadDynamicPlugins(registry, "plugins/dynamic") + } if db != nil { registry.Init() registry.StartScheduledJobs() } + goblog := goblog{ + _wizard: &_wizard, + _blog: &_blog, + _auth: &_auth, + _admin: &_admin, + _registry: registry, + sessionKey: sessionKey, + router: router, + } + // Filter nav pages: hide pages owned by disabled plugins _blog.PageFilter = func(page blog.Page) bool { if !registry.HasPageType(page.PageType) { diff --git a/plugin/loader.go b/plugin/loader.go index c47e525..db3c951 100644 --- a/plugin/loader.go +++ b/plugin/loader.go @@ -1,6 +1,7 @@ package plugin import ( + "fmt" "log" "os" "path/filepath" @@ -11,7 +12,8 @@ import ( ) // LoadDynamicPlugins scans a directory for .go plugin files and loads them -// using the Yaegi Go interpreter. Each file must define a function: +// using the Yaegi Go interpreter. Each file must use `package main` and +// define a function: // // func NewPlugin() plugin.Plugin // @@ -67,7 +69,7 @@ func loadPlugin(path string) (Plugin, error) { p, ok := v.Interface().(Plugin) if !ok { - return nil, err + return nil, fmt.Errorf("%s: NewPlugin() did not return a plugin.Plugin", path) } return p, nil diff --git a/plugin/plugin.go b/plugin/plugin.go index 8cfa1ab..d3be894 100644 --- a/plugin/plugin.go +++ b/plugin/plugin.go @@ -14,7 +14,7 @@ import ( ) // SettingDefinition describes a single setting that a plugin requires. -// Settings are stored in the blog's Setting table namespaced as "pluginname.key". +// Settings are stored in the plugin_settings table keyed by plugin name and setting key. type SettingDefinition struct { Key string // short key, e.g. "tracking_id" Type string // "text", "textarea", "file", "bool" diff --git a/plugin/registry.go b/plugin/registry.go index 04cd00b..21b0bd9 100644 --- a/plugin/registry.go +++ b/plugin/registry.go @@ -53,7 +53,9 @@ func (r *Registry) Register(p Plugin) { func (r *Registry) Plugins() []Plugin { r.mu.RLock() defer r.mu.RUnlock() - return r.plugins + result := make([]Plugin, len(r.plugins)) + copy(result, r.plugins) + return result } // Init seeds plugin settings and calls OnInit for all plugins. @@ -94,8 +96,14 @@ func (r *Registry) StartScheduledJobs() { for { select { case <-ticker.C: + r.mu.RLock() + db := r.db + r.mu.RUnlock() + if db == nil { + continue + } settings := r.getPluginSettings(p.Name()) - if err := job.Run(r.db, settings); err != nil { + if err := job.Run(db, settings); err != nil { log.Printf("Plugin %s job %s error: %v", p.Name(), job.Name, err) } case <-r.stopCh: @@ -114,6 +122,9 @@ func (r *Registry) Stop() { // getPluginSettings returns a plugin's settings as a simple key→value map. func (r *Registry) getPluginSettings(pluginName string) map[string]string { + if r.db == nil { + return make(map[string]string) + } var settings []PluginSetting r.db.Where("plugin_name = ?", pluginName).Find(&settings) result := make(map[string]string) @@ -130,6 +141,10 @@ func (r *Registry) InjectTemplateData(c *gin.Context, templateName string, data r.mu.RLock() defer r.mu.RUnlock() + if r.db == nil { + return data + } + pluginsData := gin.H{} headHTML := "" footerHTML := "" diff --git a/plugin/registry_test.go b/plugin/registry_test.go index 1d33475..f6c57d8 100644 --- a/plugin/registry_test.go +++ b/plugin/registry_test.go @@ -41,8 +41,13 @@ func (p *testPlugin) TemplateData(ctx *plugin.HookContext) gin.H { } func TestRegistryBasics(t *testing.T) { - db, _ := gorm.Open(sqlite.Open(":memory:")) - db.AutoMigrate(&plugin.PluginSetting{}) + db, err := gorm.Open(sqlite.Open(":memory:")) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&plugin.PluginSetting{}); err != nil { + t.Fatal(err) + } reg := plugin.NewRegistry(db) tp := &testPlugin{} @@ -57,8 +62,13 @@ func TestRegistryBasics(t *testing.T) { } func TestRegistryInit(t *testing.T) { - db, _ := gorm.Open(sqlite.Open(":memory:")) - db.AutoMigrate(&plugin.PluginSetting{}) + db, err := gorm.Open(sqlite.Open(":memory:")) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&plugin.PluginSetting{}); err != nil { + t.Fatal(err) + } reg := plugin.NewRegistry(db) reg.Register(&testPlugin{}) @@ -75,8 +85,13 @@ func TestRegistryInit(t *testing.T) { } func TestRegistryInjectTemplateData(t *testing.T) { - db, _ := gorm.Open(sqlite.Open(":memory:")) - db.AutoMigrate(&plugin.PluginSetting{}) + db, err := gorm.Open(sqlite.Open(":memory:")) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&plugin.PluginSetting{}); err != nil { + t.Fatal(err) + } reg := plugin.NewRegistry(db) reg.Register(&testPlugin{}) @@ -119,8 +134,13 @@ func TestRegistryInjectTemplateData(t *testing.T) { } func TestGetAllSettings(t *testing.T) { - db, _ := gorm.Open(sqlite.Open(":memory:")) - db.AutoMigrate(&plugin.PluginSetting{}) + db, err := gorm.Open(sqlite.Open(":memory:")) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&plugin.PluginSetting{}); err != nil { + t.Fatal(err) + } reg := plugin.NewRegistry(db) reg.Register(&testPlugin{}) diff --git a/plugin/symbols.go b/plugin/symbols.go index ce7943d..04d4bf2 100644 --- a/plugin/symbols.go +++ b/plugin/symbols.go @@ -5,7 +5,8 @@ import "reflect" // Symbols exports the plugin package types for the Yaegi interpreter, // allowing dynamic plugins to use plugin.BasePlugin, plugin.HookContext, etc. var Symbols = map[string]map[string]reflect.Value{ - "goblog/plugin/plugin": { + "goblog/plugin": { + "Plugin": reflect.ValueOf((*Plugin)(nil)), "BasePlugin": reflect.ValueOf((*BasePlugin)(nil)), "HookContext": reflect.ValueOf((*HookContext)(nil)), "SettingDefinition": reflect.ValueOf((*SettingDefinition)(nil)), diff --git a/plugins/analytics/analytics.go b/plugins/analytics/analytics.go index 366373c..3fa0d29 100644 --- a/plugins/analytics/analytics.go +++ b/plugins/analytics/analytics.go @@ -5,10 +5,13 @@ package analytics import ( "goblog/plugin" + "regexp" "gorm.io/gorm" ) +var validTrackingID = regexp.MustCompile(`^[A-Za-z0-9-]+$`) + // AnalyticsPlugin implements Google Analytics tracking. type AnalyticsPlugin struct { plugin.BasePlugin @@ -50,6 +53,9 @@ func (p *AnalyticsPlugin) TemplateHead(ctx *plugin.HookContext) string { if trackingID == "" || enabled != "true" { return "" } + if !validTrackingID.MatchString(trackingID) { + return "" + } return `