diff --git a/admin/admin.go b/admin/admin.go index 5d0c4ab..00499af 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,72 @@ 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 +} + +// 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) { + 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 } @@ -619,9 +686,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), }) } @@ -831,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), }) } @@ -877,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/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 7480297..cad62b7 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" @@ -25,24 +24,27 @@ 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 - scholar *scholar.Scholar + PageFilter PageFilter // optional filter set by plugin system 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 @@ -56,17 +58,19 @@ func (b *Blog) IsDbNil() bool { return (*b.db) == nil } -// 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 +// 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 articles[i].Month != articles[j].Month { - return articles[i].Month > articles[j].Month + if r, ok := reg.(injector); ok { + data = r.InjectTemplateData(c, templateName, data) } - return articles[i].Day > articles[j].Day - }) + } + c.HTML(code, templateName, data) } // Generic Functions (not JSON or HTML) @@ -355,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 } @@ -416,7 +430,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 +455,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, @@ -453,40 +467,8 @@ 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") - c.HTML(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 { - c.HTML(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: - 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 +483,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, @@ -516,8 +498,51 @@ func (b *Blog) DynamicPage(c *gin.Context, page *Page) { "settings": b.GetSettings(), "nav_pages": navPages, }) - default: // about, custom - c.HTML(http.StatusOK, "page_content.html", gin.H{ + default: + // Check if a plugin handles this page type + if reg, exists := c.Get("plugin_registry"); exists { + type pluginPageHandler interface { + RenderPluginPage(c *gin.Context, pageType string) (string, gin.H, bool) + HasPageType(pageType string) bool + } + if r, ok := reg.(pluginPageHandler); 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, + } + 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 + 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 +559,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 +571,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 +591,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 +608,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 +712,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 +735,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 +751,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 +768,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 +798,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 +824,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 +843,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 +854,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 +871,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 +884,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, @@ -871,43 +896,9 @@ 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") - c.HTML(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) - c.HTML(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) { - 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 +912,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 +928,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 +989,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 +1004,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 +1132,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/blog/blog_test.go b/blog/blog_test.go index 548a079..32a62b3 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() @@ -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} @@ -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/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/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..331bc90 100644 --- a/goblog.go +++ b/goblog.go @@ -4,11 +4,14 @@ 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" "gorm.io/driver/mysql" @@ -36,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 @@ -161,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) @@ -279,8 +286,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) @@ -297,16 +303,39 @@ func main() { } router.SetTrustedProxies(trustedProxies) + // Initialize plugin system + registry := gplugin.NewRegistry(db) + registry.Register(analytics.New()) + registry.Register(socialicons.New()) + registry.Register(scholarplugin.New()) + 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) { + 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)) hostname, err := os.Hostname() router.Use(sessions.Sessions(hostname, store)) @@ -375,6 +404,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/loader.go b/plugin/loader.go new file mode 100644 index 0000000..db3c951 --- /dev/null +++ b/plugin/loader.go @@ -0,0 +1,76 @@ +package plugin + +import ( + "fmt" + "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 use `package main` and +// 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, fmt.Errorf("%s: NewPlugin() did not return a plugin.Plugin", path) + } + + return p, nil +} diff --git a/plugin/plugin.go b/plugin/plugin.go new file mode 100644 index 0000000..d3be894 --- /dev/null +++ b/plugin/plugin.go @@ -0,0 +1,85 @@ +// 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 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" + 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) +} + +// 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 { + 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 + + // 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) 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 new file mode 100644 index 0000000..21b0bd9 --- /dev/null +++ b/plugin/registry.go @@ -0,0 +1,296 @@ +package plugin + +import ( + "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() + result := make([]Plugin, len(r.plugins)) + copy(result, r.plugins) + return result +} + +// 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 + } + // Create the plugin_settings table if it doesn't exist + r.db.AutoMigrate(&PluginSetting{}) + for _, p := range r.plugins { + for _, s := range p.Settings() { + setting := PluginSetting{ + PluginName: p.Name(), + Key: s.Key, + Value: s.DefaultValue, + } + 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) + 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: + r.mu.RLock() + db := r.db + r.mu.RUnlock() + if db == nil { + continue + } + settings := r.getPluginSettings(p.Name()) + if err := job.Run(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 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) + for _, s := range settings { + result[s.Key] = 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() + + if r.db == nil { + return data + } + + 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 +} + +// 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 +} + +// 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() + 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). + 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) { + 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..f6c57d8 --- /dev/null +++ b/plugin/registry_test.go @@ -0,0 +1,159 @@ +package plugin_test + +import ( + "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, 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{} + 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, 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{}) + if err := reg.Init(); err != nil { + t.Fatalf("Init failed: %v", err) + } + + // Check setting was seeded + 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) + } +} + +func TestRegistryInjectTemplateData(t *testing.T) { + 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{}) + 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, 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{}) + 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/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/plugin/symbols.go b/plugin/symbols.go new file mode 100644 index 0000000..04d4bf2 --- /dev/null +++ b/plugin/symbols.go @@ -0,0 +1,15 @@ +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": reflect.ValueOf((*Plugin)(nil)), + "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..3fa0d29 --- /dev/null +++ b/plugins/analytics/analytics.go @@ -0,0 +1,66 @@ +// 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" + "regexp" + + "gorm.io/gorm" +) + +var validTrackingID = regexp.MustCompile(`^[A-Za-z0-9-]+$`) + +// 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 "" + } + if !validTrackingID.MatchString(trackingID) { + return "" + } + return ` +` +} diff --git a/plugins/scholar/scholar.go b/plugins/scholar/scholar.go new file mode 100644 index 0000000..f88a5a1 --- /dev/null +++ b/plugins/scholar/scholar.go @@ -0,0 +1,182 @@ +// 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 ( + "errors" + "fmt" + "goblog/blog" + "log" + "sort" + "sync" + "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 + scholarOnce sync.Once +} + +// 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 { + // 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 { + if !errors.Is(result.Error, gorm.ErrRecordNotFound) { + return fmt.Errorf("scholar plugin: failed to query research page: %w", result.Error) + } + // No research page exists — create the default + page = blog.Page{ + Title: "Research", + Slug: "research", + PageType: "research", + ShowInNav: true, + NavOrder: 20, + Enabled: true, + } + if err := db.Create(&page).Error; err != nil { + return fmt.Errorf("scholar plugin: failed to create research page: %w", err) + } + 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 +} + +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) { + p.scholarOnce.Do(func() { + 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}, diff --git a/plugins/socialicons/socialicons.go b/plugins/socialicons/socialicons.go new file mode 100644 index 0000000..371bf60 --- /dev/null +++ b/plugins/socialicons/socialicons.go @@ -0,0 +1,100 @@ +// 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" + "html" + + "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 "" + } + out := `
` + for _, s := range socials { + url := ctx.Settings[s.key] + if url == "" { + continue + } + safeURL := html.EscapeString(url) + safeLabel := html.EscapeString(s.label) + out += `` + } + out += `
` + return out +} 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/default/templates/admin_settings.html b/themes/default/templates/admin_settings.html index d77ff9b..03f5737 100644 --- a/themes/default/templates/admin_settings.html +++ b/themes/default/templates/admin_settings.html @@ -2,31 +2,78 @@
{{ 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 }} - - {{ else }} - - {{ end }} - {{ end }} - - + + +
+ + + {{ range .plugin_settings }} + {{ $pluginName := .PluginName }} + {{ $values := .CurrentValues }} + {{ $enabled := index $values "enabled" }} +
+
+ Plugin: {{ .DisplayName }} ({{ .PluginName }}) +
+ + +
+
+
+
+
+ {{ range .Settings }} + {{ if ne .Key "enabled" }} +
+ + {{ if .Description }}{{ .Description }}{{ end }} + {{ if eq .Type "textarea" }} + + {{ else }} + + {{ end }} +
+ {{ end }} + {{ end }} + +
+
+
+
+ {{ end }} diff --git a/themes/default/templates/footer.html b/themes/default/templates/footer.html index d537848..69d7abe 100644 --- a/themes/default/templates/footer.html +++ b/themes/default/templates/footer.html @@ -1,36 +1,5 @@ @@ -54,4 +23,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 }} + + {{ range .plugin_settings }} + {{ $pluginName := .PluginName }} + {{ $values := .CurrentValues }} + {{ $enabled := index $values "enabled" }} +
+
+ Plugin: {{ .DisplayName }} ({{ .PluginName }}) +
+ + +
+
+
+
+
+ {{ range .Settings }} + {{ if ne .Key "enabled" }} +
+ + {{ if .Description }}{{ .Description }}{{ end }} + {{ if eq .Type "textarea" }} + + {{ else }} + + {{ end }} +
+ {{ end }} + {{ end }} + +
+
+
+
+ {{ end }} diff --git a/themes/forest/templates/footer.html b/themes/forest/templates/footer.html index cc27977..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 }}

diff --git a/themes/forest/templates/header.html b/themes/forest/templates/header.html index 654e082..5d33b88 100644 --- a/themes/forest/templates/header.html +++ b/themes/forest/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 }} + + {{ range .plugin_settings }} + {{ $pluginName := .PluginName }} + {{ $values := .CurrentValues }} + {{ $enabled := index $values "enabled" }} +
+
+ Plugin: {{ .DisplayName }} ({{ .PluginName }}) +
+ + +
+
+
+
+
+ {{ range .Settings }} + {{ if ne .Key "enabled" }} +
+ + {{ if .Description }}{{ .Description }}{{ end }} + {{ if eq .Type "textarea" }} + + {{ else }} + + {{ end }} +
+ {{ end }} + {{ end }} + +
+
+
+
+ {{ end }} diff --git a/themes/minimal/templates/footer.html b/themes/minimal/templates/footer.html index 6df8909..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 }}

diff --git a/themes/minimal/templates/header.html b/themes/minimal/templates/header.html index 1365f89..5191bbf 100644 --- a/themes/minimal/templates/header.html +++ b/themes/minimal/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 }}