Basic (experimental) plugin support with two plugin types (exec and middleware)

pull/34/head
Jan-Lukas Else 2 months ago committed by Jan-Lukas Else
parent d813e9579c
commit 2158b156c5
  1. 1
      Dockerfile
  2. 8
      activityPub.go
  3. 3
      app.go
  4. 2
      blogstats.go
  5. 10
      comments.go
  6. 8
      config.go
  7. 20
      database.go
  8. 8
      database_test.go
  9. 1
      go.mod
  10. 2
      go.sum
  11. 14
      http.go
  12. 12
      indieAuthServer.go
  13. 6
      main.go
  14. 10
      notifications.go
  15. 6
      persistentCache.go
  16. 59
      pkgs/plugins/plugin.go
  17. 80
      pkgs/plugins/plugins.go
  18. 25
      pkgs/plugins/types.go
  19. 51
      pkgs/plugintypes/types.go
  20. 153
      pkgs/yaegiwrappers/go_goblog_app-app-pkgs-plugintypes.go
  21. 11
      pkgs/yaegiwrappers/wrappers.go
  22. 52
      plugins.go
  23. 37
      plugins/demo/src/demoexec/demo.go
  24. 41
      plugins/demo/src/demomiddleware/demo.go
  25. 55
      plugins_test.go
  26. 22
      postsDb.go
  27. 4
      postsDb_test.go
  28. 8
      queue.go
  29. 4
      reactions.go
  30. 10
      sessions.go
  31. 10
      settingsDb.go
  32. 4
      shortPath.go
  33. 2
      shortPath_test.go
  34. 2
      sitemap.go
  35. 2
      taxonomies.go
  36. 16
      webmention.go

@ -12,6 +12,7 @@ ADD leaflet/ /app/leaflet/
ADD hlsjs/ /app/hlsjs/
ADD dbmigrations/ /app/dbmigrations/
ADD strings/ /app/strings/
ADD plugins/ /app/plugins/
FROM buildbase as build

@ -291,7 +291,7 @@ func (a *goBlog) apGetRemoteActor(iri string) (*asPerson, int, error) {
}
func (db *database) apGetAllInboxes(blog string) (inboxes []string, err error) {
rows, err := db.query("select distinct inbox from activitypub_followers where blog = @blog", sql.Named("blog", blog))
rows, err := db.Query("select distinct inbox from activitypub_followers where blog = @blog", sql.Named("blog", blog))
if err != nil {
return nil, err
}
@ -307,17 +307,17 @@ func (db *database) apGetAllInboxes(blog string) (inboxes []string, err error) {
}
func (db *database) apAddFollower(blog, follower, inbox string) error {
_, err := db.exec("insert or replace into activitypub_followers (blog, follower, inbox) values (@blog, @follower, @inbox)", sql.Named("blog", blog), sql.Named("follower", follower), sql.Named("inbox", inbox))
_, err := db.Exec("insert or replace into activitypub_followers (blog, follower, inbox) values (@blog, @follower, @inbox)", sql.Named("blog", blog), sql.Named("follower", follower), sql.Named("inbox", inbox))
return err
}
func (db *database) apRemoveFollower(blog, follower string) error {
_, err := db.exec("delete from activitypub_followers where blog = @blog and follower = @follower", sql.Named("blog", blog), sql.Named("follower", follower))
_, err := db.Exec("delete from activitypub_followers where blog = @blog and follower = @follower", sql.Named("blog", blog), sql.Named("follower", follower))
return err
}
func (db *database) apRemoveInbox(inbox string) error {
_, err := db.exec("delete from activitypub_followers where inbox = @inbox", sql.Named("inbox", inbox))
_, err := db.Exec("delete from activitypub_followers where inbox = @inbox", sql.Named("inbox", inbox))
return err
}

@ -14,6 +14,7 @@ import (
rotatelogs "github.com/lestrrat-go/file-rotatelogs"
"github.com/yuin/goldmark"
"go.goblog.app/app/pkgs/minify"
"go.goblog.app/app/pkgs/plugins"
"golang.org/x/crypto/acme/autocert"
"golang.org/x/sync/singleflight"
"tailscale.com/tsnet"
@ -75,6 +76,8 @@ type goBlog struct {
mediaStorage mediaStorage
// Minify
min minify.Minifier
// Plugins
pluginHost *plugins.PluginHost
// Reactions
reactionsInit sync.Once
reactionsCache *ristretto.Cache

@ -154,7 +154,7 @@ func (db *database) getBlogStats(blog string) (data *blogStatsData, err error) {
Months: map[string][]blogStatsRow{},
}
// Query and scan
rows, err := db.query(blogStatsSql, sql.Named("status", statusPublished), sql.Named("blog", blog))
rows, err := db.Query(blogStatsSql, sql.Named("status", statusPublished), sql.Named("blog", blog))
if err != nil {
return nil, err
}

@ -28,7 +28,7 @@ func (a *goBlog) serveComment(w http.ResponseWriter, r *http.Request) {
a.serveError(w, r, err.Error(), http.StatusBadRequest)
return
}
row, err := a.db.queryRow("select id, target, name, website, comment from comments where id = @id", sql.Named("id", id))
row, err := a.db.QueryRow("select id, target, name, website, comment from comments where id = @id", sql.Named("id", id))
if err != nil {
a.serveError(w, r, err.Error(), http.StatusInternalServerError)
return
@ -63,7 +63,7 @@ func (a *goBlog) createComment(w http.ResponseWriter, r *http.Request) {
name := defaultIfEmpty(cleanHTMLText(r.FormValue("name")), "Anonymous")
website := cleanHTMLText(r.FormValue("website"))
// Insert
result, err := a.db.exec("insert into comments (target, comment, name, website) values (@target, @comment, @name, @website)", sql.Named("target", target), sql.Named("comment", comment), sql.Named("name", name), sql.Named("website", website))
result, err := a.db.Exec("insert into comments (target, comment, name, website) values (@target, @comment, @name, @website)", sql.Named("target", target), sql.Named("comment", comment), sql.Named("name", name), sql.Named("website", website))
if err != nil {
a.serveError(w, r, err.Error(), http.StatusInternalServerError)
return
@ -116,7 +116,7 @@ func buildCommentsQuery(config *commentsRequestConfig) (query string, args []any
func (db *database) getComments(config *commentsRequestConfig) ([]*comment, error) {
comments := []*comment{}
query, args := buildCommentsQuery(config)
rows, err := db.query(query, args...)
rows, err := db.Query(query, args...)
if err != nil {
return nil, err
}
@ -134,7 +134,7 @@ func (db *database) getComments(config *commentsRequestConfig) ([]*comment, erro
func (db *database) countComments(config *commentsRequestConfig) (count int, err error) {
query, params := buildCommentsQuery(config)
query = "select count(*) from (" + query + ")"
row, err := db.queryRow(query, params...)
row, err := db.QueryRow(query, params...)
if err != nil {
return
}
@ -143,6 +143,6 @@ func (db *database) countComments(config *commentsRequestConfig) (count int, err
}
func (db *database) deleteComment(id int) error {
_, err := db.exec("delete from comments where id = @id", sql.Named("id", id))
_, err := db.Exec("delete from comments where id = @id", sql.Named("id", id))
return err
}

@ -19,6 +19,7 @@ type config struct {
Blogs map[string]*configBlog `mapstructure:"blogs"`
User *configUser `mapstructure:"user"`
Hooks *configHooks `mapstructure:"hooks"`
Plugins []*configPlugin `mapstructure:"plugins"`
Micropub *configMicropub `mapstructure:"micropub"`
PathRedirects []*configRegexRedirect `mapstructure:"pathRedirects"`
ActivityPub *configActivityPub `mapstructure:"activityPub"`
@ -322,6 +323,13 @@ type configPprof struct {
Address string `mapstructure:"address"`
}
type configPlugin struct {
Path string `mapstructure:"path"`
Type string `mapstructure:"type"`
Import string `mapstructure:"import"`
Config map[string]any `mapstructure:"config"`
}
func (a *goBlog) loadConfigFile(file string) error {
// Use viper to load the config file
v := viper.New()

@ -211,11 +211,11 @@ func (db *database) prepare(query string, args ...any) (*sql.Stmt, []any, error)
const dbNoCache = "nocache"
func (db *database) exec(query string, args ...any) (sql.Result, error) {
return db.execContext(context.Background(), query, args...)
func (db *database) Exec(query string, args ...any) (sql.Result, error) {
return db.ExecContext(context.Background(), query, args...)
}
func (db *database) execContext(c context.Context, query string, args ...any) (sql.Result, error) {
func (db *database) ExecContext(c context.Context, query string, args ...any) (sql.Result, error) {
if db == nil || db.db == nil {
return nil, errors.New("database not initialized")
}
@ -234,11 +234,11 @@ func (db *database) execContext(c context.Context, query string, args ...any) (s
return db.db.ExecContext(ctx, query, args...)
}
func (db *database) query(query string, args ...any) (*sql.Rows, error) {
return db.queryContext(context.Background(), query, args...)
func (db *database) Query(query string, args ...any) (*sql.Rows, error) {
return db.QueryContext(context.Background(), query, args...)
}
func (db *database) queryContext(c context.Context, query string, args ...any) (rows *sql.Rows, err error) {
func (db *database) QueryContext(c context.Context, query string, args ...any) (rows *sql.Rows, err error) {
if db == nil || db.db == nil {
return nil, errors.New("database not initialized")
}
@ -257,11 +257,11 @@ func (db *database) queryContext(c context.Context, query string, args ...any) (
return
}
func (db *database) queryRow(query string, args ...any) (*sql.Row, error) {
return db.queryRowContext(context.Background(), query, args...)
func (db *database) QueryRow(query string, args ...any) (*sql.Row, error) {
return db.QueryRowContext(context.Background(), query, args...)
}
func (db *database) queryRowContext(c context.Context, query string, args ...any) (row *sql.Row, err error) {
func (db *database) QueryRowContext(c context.Context, query string, args ...any) (row *sql.Row, err error) {
if db == nil || db.db == nil {
return nil, errors.New("database not initialized")
}
@ -283,5 +283,5 @@ func (db *database) queryRowContext(c context.Context, query string, args ...any
// Other things
func (d *database) rebuildFTSIndex() {
_, _ = d.exec("insert into posts_fts(posts_fts) values ('rebuild')")
_, _ = d.Exec("insert into posts_fts(posts_fts) values ('rebuild')")
}

@ -15,17 +15,17 @@ func Test_database(t *testing.T) {
t.Fatalf("Error: %v", err)
}
_, err = db.exec("create table test(test text);")
_, err = db.Exec("create table test(test text);")
if err != nil {
t.Fatalf("Error: %v", err)
}
_, err = db.exec("insert into test (test) values ('Test')")
_, err = db.Exec("insert into test (test) values ('Test')")
if err != nil {
t.Fatalf("Error: %v", err)
}
row, err := db.queryRow("select count(test) from test")
row, err := db.QueryRow("select count(test) from test")
if err != nil {
t.Fatalf("Error: %v", err)
}
@ -38,7 +38,7 @@ func Test_database(t *testing.T) {
t.Error("Wrong result")
}
rows, err := db.query("select count(test), test from test")
rows, err := db.Query("select count(test), test from test")
if err != nil {
t.Fatalf("Error: %v", err)
}

@ -53,6 +53,7 @@ require (
// master
github.com/tkrajina/gpxgo v1.2.2-0.20220217201249-321f19554eec
github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80
github.com/traefik/yaegi v0.14.1
github.com/vcraescu/go-paginator v1.0.1-0.20201114172518-2cfc59fe05c2
github.com/yuin/goldmark v1.4.13
// master

@ -481,6 +481,8 @@ github.com/tkrajina/gpxgo v1.2.2-0.20220217201249-321f19554eec h1:o5aL1yX+/xzvK4
github.com/tkrajina/gpxgo v1.2.2-0.20220217201249-321f19554eec/go.mod h1:795sjVRFo5wWyN6oOZp0RYienGGBJjpAlgOz2nCngA0=
github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80 h1:nrZ3ySNYwJbSpD6ce9duiP+QkD3JuLCcWkdaehUS/3Y=
github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80/go.mod h1:iFyPdL66DjUD96XmzVL3ZntbzcflLnznH0fr99w5VqE=
github.com/traefik/yaegi v0.14.1 h1:t0ssyzeZCWTFGd/JnVuDxH/slMQfYg+2CDD4dLW/rU0=
github.com/traefik/yaegi v0.14.1/go.mod h1:AVRxhaI2G+nUsaM1zyktzwXn69G3t/AuTDrCiTds9p0=
github.com/u-root/uio v0.0.0-20210528114334-82958018845c/go.mod h1:LpEX5FO/cB+WF4TYGY1V5qktpaZLkKkSegbr0V4eYXA=
github.com/u-root/uio v0.0.0-20210528151154-e40b768296a7 h1:XMAtQHwKjWHIRwg+8Nj/rzUomQY1q6cM3ncA0wP8GU4=
github.com/u-root/uio v0.0.0-20210528151154-e40b768296a7/go.mod h1:LpEX5FO/cB+WF4TYGY1V5qktpaZLkKkSegbr0V4eYXA=

@ -7,6 +7,7 @@ import (
"log"
"net"
"net/http"
"sort"
"strconv"
"time"
@ -17,6 +18,7 @@ import (
"github.com/klauspost/compress/flate"
"go.goblog.app/app/pkgs/httpcompress"
"go.goblog.app/app/pkgs/maprouter"
"go.goblog.app/app/pkgs/plugintypes"
"golang.org/x/net/context"
)
@ -43,6 +45,16 @@ func (a *goBlog) startServer() (err error) {
if a.httpsConfigured(false) {
h = h.Append(a.securityHeaders)
}
// Add plugin middlewares
middlewarePlugins := getPluginsForType[plugintypes.Middleware](a, "middleware")
sort.Slice(middlewarePlugins, func(i, j int) bool {
// Sort with descending prio
return middlewarePlugins[i].Prio() > middlewarePlugins[j].Prio()
})
for _, plugin := range middlewarePlugins {
h = h.Append(plugin.Handler)
}
// Finally...
finalHandler := h.ThenFunc(func(w http.ResponseWriter, r *http.Request) {
a.d.ServeHTTP(w, r)
})
@ -245,7 +257,7 @@ func (a *goBlog) servePostsAliasesRedirects() http.HandlerFunc {
}
// Check if post or alias
path := r.URL.Path
row, err := a.db.queryRow(`
row, err := a.db.QueryRow(`
-- normal posts
select 'post', status, 200 from posts where path = @path
union all

@ -182,7 +182,7 @@ func (db *database) indieAuthSaveAuthRequest(data *indieauth.AuthenticationReque
// Generate a code to identify the request
code := uuid.NewString()
// Save the request
_, err := db.exec(
_, err := db.Exec(
"insert into indieauthauth (time, code, client, redirect, scope, challenge, challengemethod) values (?, ?, ?, ?, ?, ?, ?)",
time.Now().UTC().Unix(), code, data.ClientID, data.RedirectURI, strings.Join(data.Scopes, " "), data.CodeChallenge, data.CodeChallengeMethod,
)
@ -194,7 +194,7 @@ func (db *database) indieAuthGetAuthRequest(code string) (data *indieauth.Authen
// code valid for 10 minutes
maxAge := time.Now().UTC().Add(-10 * time.Minute).Unix()
// Query the database
row, err := db.queryRow("select client, redirect, scope, challenge, challengemethod from indieauthauth where time >= ? and code = ?", maxAge, code)
row, err := db.QueryRow("select client, redirect, scope, challenge, challengemethod from indieauthauth where time >= ? and code = ?", maxAge, code)
if err != nil {
return nil, err
}
@ -210,7 +210,7 @@ func (db *database) indieAuthGetAuthRequest(code string) (data *indieauth.Authen
data.Scopes = strings.Split(scope, " ")
}
// Delete the auth code and expired auth codes
_, _ = db.exec("delete from indieauthauth where code = ? or time < ?", code, maxAge)
_, _ = db.Exec("delete from indieauthauth where code = ? or time < ?", code, maxAge)
return data, nil
}
@ -251,7 +251,7 @@ func (a *goBlog) indieAuthTokenVerification(w http.ResponseWriter, r *http.Reque
func (db *database) indieAuthVerifyToken(token string) (data *indieauth.AuthenticationRequest, err error) {
token = strings.ReplaceAll(token, "Bearer ", "")
data = &indieauth.AuthenticationRequest{Scopes: []string{}}
row, err := db.queryRow("select client, scope from indieauthtoken where token = @token", sql.Named("token", token))
row, err := db.QueryRow("select client, scope from indieauthtoken where token = @token", sql.Named("token", token))
if err != nil {
return nil, err
}
@ -271,13 +271,13 @@ func (db *database) indieAuthVerifyToken(token string) (data *indieauth.Authenti
// Save a new token to the database
func (db *database) indieAuthSaveToken(data *indieauth.AuthenticationRequest) (string, error) {
token := uuid.NewString()
_, err := db.exec("insert into indieauthtoken (time, token, client, scope) values (?, ?, ?, ?)", time.Now().UTC().Unix(), token, data.ClientID, strings.Join(data.Scopes, " "))
_, err := db.Exec("insert into indieauthtoken (time, token, client, scope) values (?, ?, ?, ?)", time.Now().UTC().Unix(), token, data.ClientID, strings.Join(data.Scopes, " "))
return token, err
}
// Revoke and delete the token from the database
func (db *database) indieAuthRevokeToken(token string) {
if token != "" {
_, _ = db.exec("delete from indieauthtoken where token=?", token)
_, _ = db.Exec("delete from indieauthtoken where token=?", token)
}
}

@ -67,6 +67,12 @@ func main() {
return
}
// Initialize plugins
if err = app.initPlugins(); err != nil {
app.logErrAndQuit("Failed to init plugins:", err.Error())
return
}
// Healthcheck tool
if len(os.Args) >= 2 && os.Args[1] == "healthcheck" {
// Connect to public address + "/ping" and exit with 0 when successful

@ -41,19 +41,19 @@ func (a *goBlog) sendNotification(text string) {
}
func (db *database) saveNotification(n *notification) error {
if _, err := db.exec("insert into notifications (time, text) values (@time, @text)", sql.Named("time", n.Time), sql.Named("text", n.Text)); err != nil {
if _, err := db.Exec("insert into notifications (time, text) values (@time, @text)", sql.Named("time", n.Time), sql.Named("text", n.Text)); err != nil {
return err
}
return nil
}
func (db *database) deleteNotification(id int) error {
_, err := db.exec("delete from notifications where id = @id", sql.Named("id", id))
_, err := db.Exec("delete from notifications where id = @id", sql.Named("id", id))
return err
}
func (db *database) deleteAllNotifications() error {
_, err := db.exec("delete from notifications")
_, err := db.Exec("delete from notifications")
return err
}
@ -75,7 +75,7 @@ func buildNotificationsQuery(config *notificationsRequestConfig) (query string,
func (db *database) getNotifications(config *notificationsRequestConfig) ([]*notification, error) {
notifications := []*notification{}
query, args := buildNotificationsQuery(config)
rows, err := db.query(query, args...)
rows, err := db.Query(query, args...)
if err != nil {
return nil, err
}
@ -93,7 +93,7 @@ func (db *database) getNotifications(config *notificationsRequestConfig) ([]*not
func (db *database) countNotifications(config *notificationsRequestConfig) (count int, err error) {
query, params := buildNotificationsQuery(config)
query = "select count(*) from (" + query + ")"
row, err := db.queryRow(query, params...)
row, err := db.QueryRow(query, params...)
if err != nil {
return
}

@ -14,7 +14,7 @@ func (db *database) cachePersistentlyContext(ctx context.Context, key string, da
if db == nil {
return errors.New("database is nil")
}
_, err := db.execContext(ctx, "insert or replace into persistent_cache(key, data, date) values(@key, @data, @date)", sql.Named("key", key), sql.Named("data", data), sql.Named("date", utcNowString()))
_, err := db.ExecContext(ctx, "insert or replace into persistent_cache(key, data, date) values(@key, @data, @date)", sql.Named("key", key), sql.Named("data", data), sql.Named("date", utcNowString()))
return err
}
@ -27,7 +27,7 @@ func (db *database) retrievePersistentCacheContext(c context.Context, key string
return nil, errors.New("database is nil")
}
d, err, _ := db.pc.Do(key, func() (any, error) {
if row, err := db.queryRowContext(c, "select data from persistent_cache where key = @key", sql.Named("key", key)); err != nil {
if row, err := db.QueryRowContext(c, "select data from persistent_cache where key = @key", sql.Named("key", key)); err != nil {
return nil, err
} else {
err = row.Scan(&data)
@ -51,6 +51,6 @@ func (db *database) clearPersistentCache(pattern string) error {
}
func (db *database) clearPersistentCacheContext(c context.Context, pattern string) error {
_, err := db.execContext(c, "delete from persistent_cache where key like @pattern", sql.Named("pattern", pattern))
_, err := db.ExecContext(c, "delete from persistent_cache where key like @pattern", sql.Named("pattern", pattern))
return err
}

@ -0,0 +1,59 @@
package plugins
import (
"fmt"
"path/filepath"
"reflect"
"github.com/traefik/yaegi/interp"
"github.com/traefik/yaegi/stdlib"
)
type plugin struct {
Config *PluginConfig
plugin reflect.Value
}
// PluginConfig is the configuration of the plugin.
type PluginConfig struct {
// Path is the storage path of the plugin.
Path string
// ImportPath is the module path i.e. "github.com/user/module".
ImportPath string
// PluginType is the type of plugin, this plugin is checked against that type.
// The available types are specified by the implementor of this package.
PluginType string
}
func (p *plugin) initPlugin(host *PluginHost) error {
const errText = "initPlugin: %w"
interpreter := interp.New(interp.Options{
GoPath: p.Config.Path,
})
if err := interpreter.Use(stdlib.Symbols); err != nil {
return fmt.Errorf(errText, err)
}
if err := interpreter.Use(host.Symbols); err != nil {
return fmt.Errorf(errText, err)
}
if _, err := interpreter.Eval(fmt.Sprintf(`import "%s"`, p.Config.ImportPath)); err != nil {
return fmt.Errorf(errText, err)
}
v, err := interpreter.Eval(filepath.Base(p.Config.ImportPath) + ".GetPlugin")
if err != nil {
return fmt.Errorf(errText, err)
}
result := v.Call([]reflect.Value{})
if len(result) > 1 {
return fmt.Errorf(errText+": function GetPlugin has more than one return value", ErrValidatingPlugin)
}
p.plugin = result[0]
return nil
}

@ -0,0 +1,80 @@
package plugins
import (
"fmt"
"reflect"
"github.com/traefik/yaegi/interp"
)
// NewPluginHost initializes a PluginHost.
func NewPluginHost(symbols interp.Exports) *PluginHost {
return &PluginHost{
Plugins: []*plugin{},
PluginTypes: map[string]reflect.Type{},
Symbols: symbols,
}
}
// AddPluginType adds a plugin type to the list.
// The interface for the pluginType parameter should be a nil of the plugin type interface:
//
// (*PluginInterface)(nil)
func (h *PluginHost) AddPluginType(name string, pluginType interface{}) {
h.PluginTypes[name] = reflect.TypeOf(pluginType).Elem()
}
// LoadPlugin loads a new plugin to the host.
func (h *PluginHost) LoadPlugin(config *PluginConfig) (any, error) {
p := &plugin{
Config: config,
}
err := p.initPlugin(h)
if err != nil {
return nil, err
}
err = h.validatePlugin(p)
if err != nil {
return nil, err
}
h.Plugins = append(h.Plugins, p)
return p.plugin.Interface(), nil
}
func (h *PluginHost) validatePlugin(p *plugin) error {
pType := reflect.TypeOf(p.plugin.Interface())
if _, ok := h.PluginTypes[p.Config.PluginType]; !ok {
return fmt.Errorf("validatePlugin: %v: %w", p.Config.PluginType, ErrInvalidType)
}
if !pType.Implements(h.PluginTypes[p.Config.PluginType]) {
return fmt.Errorf("validatePlugin:%v: %w %v", p, ErrValidatingPlugin, p.Config.PluginType)
}
return nil
}
// GetPlugins returns a list of all plugins.
func (h *PluginHost) GetPlugins() (list []any) {
for _, p := range h.Plugins {
list = append(list, p.plugin.Interface())
}
return
}
// GetPluginsForType returns all the plugins that are of type pluginType or empty if the pluginType doesn't exist.
func GetPluginsForType[T any](h *PluginHost, pluginType string) (list []T) {
if _, ok := h.PluginTypes[pluginType]; !ok {
return
}
for _, p := range h.Plugins {
if p.Config.PluginType != pluginType {
continue
}
if t, ok := p.plugin.Interface().(T); ok {
list = append(list, t)
}
}
return
}

@ -0,0 +1,25 @@
package plugins
import (
"errors"
"reflect"
"github.com/traefik/yaegi/interp"
)
// PluginHost manages the plugins.
type PluginHost struct {
// Plugins contains a list of the plugins.
Plugins []*plugin
// PluginTypes is a list of plugins types that plugins have to use at least one of.
PluginTypes map[string]reflect.Type
// Symbols is the map of symbols generated by yaegi extract.
Symbols interp.Exports
}
var (
// ErrInvalidType is returned when the plugin type specified by the plugin is invalid.
ErrInvalidType = errors.New("invalid plugin type")
// ErrValidatingPlugin is returned when the plugin fails to fully implement the interface of the plugin type.
ErrValidatingPlugin = errors.New("plugin does not implement type")
)

@ -0,0 +1,51 @@
package plugintypes
import (
"context"
"database/sql"
"net/http"
)
// Interface to GoBlog
// App is used to access GoBlog's app instance.
type App interface {
GetDatabase() Database
}
// Database is used to provide access to GoBlog's database.
type Database interface {
Exec(string, ...any) (sql.Result, error)
ExecContext(context.Context, string, ...any) (sql.Result, error)
Query(string, ...any) (*sql.Rows, error)
QueryContext(context.Context, string, ...any) (*sql.Rows, error)
QueryRow(string, ...any) (*sql.Row, error)
QueryRowContext(context.Context, string, ...any) (*sql.Row, error)
}
// Plugin types
// SetApp is used in all plugin types to allow
// GoBlog set it's app instance to be accessible by the plugin.
type SetApp interface {
SetApp(App)
}
// SetConfig is used in all plugin types to allow
// GoBlog set plugin configuration.
type SetConfig interface {
SetConfig(map[string]any)
}
type Exec interface {
SetApp
SetConfig
Exec()
}
type Middleware interface {
SetApp
SetConfig
Handler(http.Handler) http.Handler
Prio() int
}

@ -0,0 +1,153 @@
// Code generated by 'yaegi extract go.goblog.app/app/pkgs/plugintypes'. DO NOT EDIT.
// MIT License
//
// Copyright (c) 2020 - 2022 Jan-Lukas Else
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package yaegiwrappers
import (
"context"
"database/sql"
"go.goblog.app/app/pkgs/plugintypes"
"net/http"
"reflect"
)
func init() {
Symbols["go.goblog.app/app/pkgs/plugintypes/plugintypes"] = map[string]reflect.Value{
// type definitions
"App": reflect.ValueOf((*plugintypes.App)(nil)),
"Database": reflect.ValueOf((*plugintypes.Database)(nil)),
"Exec": reflect.ValueOf((*plugintypes.Exec)(nil)),
"Middleware": reflect.ValueOf((*plugintypes.Middleware)(nil)),
"SetApp": reflect.ValueOf((*plugintypes.SetApp)(nil)),
"SetConfig": reflect.ValueOf((*plugintypes.SetConfig)(nil)),
// interface wrapper definitions
"_App": reflect.ValueOf((*_go_goblog_app_app_pkgs_plugintypes_App)(nil)),
"_Database": reflect.ValueOf((*_go_goblog_app_app_pkgs_plugintypes_Database)(nil)),
"_Exec": reflect.ValueOf((*_go_goblog_app_app_pkgs_plugintypes_Exec)(nil)),
"_Middleware": reflect.ValueOf((*_go_goblog_app_app_pkgs_plugintypes_Middleware)(nil)),
"_SetApp": reflect.ValueOf((*_go_goblog_app_app_pkgs_plugintypes_SetApp)(nil)),
"_SetConfig": reflect.ValueOf((*_go_goblog_app_app_pkgs_plugintypes_SetConfig)(nil)),
}
}
// _go_goblog_app_app_pkgs_plugintypes_App is an interface wrapper for App type
type _go_goblog_app_app_pkgs_plugintypes_App struct {
IValue interface{}
WGetDatabase func() plugintypes.Database
}
func (W _go_goblog_app_app_pkgs_plugintypes_App) GetDatabase() plugintypes.Database {
return W.WGetDatabase()
}
// _go_goblog_app_app_pkgs_plugintypes_Database is an interface wrapper for Database type
type _go_goblog_app_app_pkgs_plugintypes_Database struct {
IValue interface{}
WExec func(a0 string, a1 ...any) (sql.Result, error)
WExecContext func(a0 context.Context, a1 string, a2 ...any) (sql.Result, error)
WQuery func(a0 string, a1 ...any) (*sql.Rows, error)
WQueryContext func(a0 context.Context, a1 string, a2 ...any) (*sql.Rows, error)
WQueryRow func(a0 string, a1 ...any) (*sql.Row, error)
WQueryRowContext func(a0 context.Context, a1 string, a2 ...any) (*sql.Row, error)
}
func (W _go_goblog_app_app_pkgs_plugintypes_Database) Exec(a0 string, a1 ...any) (sql.Result, error) {
return W.WExec(a0, a1...)
}
func (W _go_goblog_app_app_pkgs_plugintypes_Database) ExecContext(a0 context.Context, a1 string, a2 ...any) (sql.Result, error) {
return W.WExecContext(a0, a1, a2...)
}
func (W _go_goblog_app_app_pkgs_plugintypes_Database) Query(a0 string, a1 ...any) (*sql.Rows, error) {
return W.WQuery(a0, a1...)
}
func (W _go_goblog_app_app_pkgs_plugintypes_Database) QueryContext(a0 context.Context, a1 string, a2 ...any) (*sql.Rows, error) {
return W.WQueryContext(a0, a1, a2...)
}
func (W _go_goblog_app_app_pkgs_plugintypes_Database) QueryRow(a0 string, a1 ...any) (*sql.Row, error) {
return W.WQueryRow(a0, a1...)
}
func (W _go_goblog_app_app_pkgs_plugintypes_Database) QueryRowContext(a0 context.Context, a1 string, a2 ...any) (*sql.Row, error) {
return W.WQueryRowContext(a0, a1, a2...)
}
// _go_goblog_app_app_pkgs_plugintypes_Exec is an interface wrapper for Exec type
type _go_goblog_app_app_pkgs_plugintypes_Exec struct {
IValue interface{}
WExec func()
WSetApp func(a0 plugintypes.App)
WSetConfig func(a0 map[string]any)
}
func (W _go_goblog_app_app_pkgs_plugintypes_Exec) Exec() {
W.WExec()
}
func (W _go_goblog_app_app_pkgs_plugintypes_Exec) SetApp(a0 plugintypes.App) {
W.WSetApp(a0)
}
func (W _go_goblog_app_app_pkgs_plugintypes_Exec) SetConfig(a0 map[string]any) {
W.WSetConfig(a0)
}
// _go_goblog_app_app_pkgs_plugintypes_Middleware is an interface wrapper for Middleware type
type _go_goblog_app_app_pkgs_plugintypes_Middleware struct {
IValue interface{}
WHandler func(a0 http.Handler) http.Handler
WPrio func() int
WSetApp func(a0 plugintypes.App)
WSetConfig func(a0 map[string]any)
}
func (W _go_goblog_app_app_pkgs_plugintypes_Middleware) Handler(a0 http.Handler) http.Handler {
return W.WHandler(a0)
}
func (W _go_goblog_app_app_pkgs_plugintypes_Middleware) Prio() int {
return W.WPrio()
}
func (W _go_goblog_app_app_pkgs_plugintypes_Middleware) SetApp(a0 plugintypes.App) {
W.WSetApp(a0)
}
func (W _go_goblog_app_app_pkgs_plugintypes_Middleware) SetConfig(a0 map[string]any) {
W.WSetConfig(a0)
}
// _go_goblog_app_app_pkgs_plugintypes_SetApp is an interface wrapper for SetApp type
type _go_goblog_app_app_pkgs_plugintypes_SetApp struct {
IValue interface{}
WSetApp func(a0 plugintypes.App)
}
func (W _go_goblog_app_app_pkgs_plugintypes_SetApp) SetApp(a0 plugintypes.App) {
W.WSetApp(a0)
}
// _go_goblog_app_app_pkgs_plugintypes_SetConfig is an interface wrapper for SetConfig type
type _go_goblog_app_app_pkgs_plugintypes_SetConfig struct {
IValue interface{}
WSetConfig func(a0 map[string]any)
}
func (W _go_goblog_app_app_pkgs_plugintypes_SetConfig) SetConfig(a0 map[string]any) {
W.WSetConfig(a0)
}

@ -0,0 +1,11 @@
package yaegiwrappers
import (
"reflect"
)
var (
Symbols = make(map[string]map[string]reflect.Value)
)
//go:generate yaegi extract -license ../../LICENSE -name yaegiwrappers go.goblog.app/app/pkgs/plugintypes

@ -0,0 +1,52 @@
package main
import (
"go.goblog.app/app/pkgs/plugins"
"go.goblog.app/app/pkgs/plugintypes"
"go.goblog.app/app/pkgs/yaegiwrappers"
)
func (a *goBlog) initPlugins() error {
a.pluginHost = plugins.NewPluginHost(yaegiwrappers.Symbols)
a.pluginHost.AddPluginType("exec", (*plugintypes.Exec)(nil))
a.pluginHost.AddPluginType("middleware", (*plugintypes.Middleware)(nil))
for _, pc := range a.cfg.Plugins {
if pluginInterface, err := a.pluginHost.LoadPlugin(&plugins.PluginConfig{
Path: pc.Path,
ImportPath: pc.Import,
PluginType: pc.Type,
}); err != nil {
return err
} else if pluginInterface != nil {
if setAppPlugin, ok := pluginInterface.(plugintypes.SetApp); ok {
setAppPlugin.SetApp(a)
}
if setConfigPlugin, ok := pluginInterface.(plugintypes.SetConfig); ok {
setConfigPlugin.SetConfig(pc.Config)
}
}
}
execs := getPluginsForType[plugintypes.Exec](a, "exec")
for _, p := range execs {
go p.Exec()
}
return nil
}
func getPluginsForType[T any](a *goBlog, pluginType string) (list []T) {
return plugins.GetPluginsForType[T](a.pluginHost, pluginType)
}
// Implement all needed interfaces for goblog
var _ plugintypes.App = &goBlog{}
func (a *goBlog) GetDatabase() plugintypes.Database {
return a.db
}
var _ plugintypes.Database = &database{}

@ -0,0 +1,37 @@
package demoexec
import (
"fmt"
"go.goblog.app/app/pkgs/plugintypes"
)
func GetPlugin() plugintypes.Exec {
return &plugin{}
}
type plugin struct {
app plugintypes.App
}
func (p *plugin) SetApp(app plugintypes.App) {
p.app = app
}
func (*plugin) SetConfig(_ map[string]any) {
// Ignore
}
func (p *plugin) Exec() {
fmt.Println("Hello World from the demo plugin!")
row, _ := p.app.GetDatabase().QueryRow("select count (*) from posts")
var count int
if err := row.Scan(&count); err != nil {
fmt.Println(fmt.Errorf("failed to count posts: %w", err))
return
}
fmt.Printf("Number of posts in database: %d", count)
fmt.Println()
}

@ -0,0 +1,41 @@
package demomiddleware
import (
"fmt"
"net/http"
"go.goblog.app/app/pkgs/plugintypes"
)
func GetPlugin() plugintypes.Middleware {
return &plugin{}
}
type plugin struct {
app plugintypes.App
config map[string]any
}
func (p *plugin) SetApp(app plugintypes.App) {
p.app = app
}
func (p *plugin) SetConfig(config map[string]any) {
p.config = config
}
func (p *plugin) Prio() int {
if prioAny, ok := p.config["prio"]; ok {
if prio, ok := prioAny.(int); ok {
return prio
}
}
return 100
}
func (p *plugin) Handler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Demo", fmt.Sprintf("This is from the demo middleware with prio %d", p.Prio()))
next.ServeHTTP(w, r)
})
}

@ -0,0 +1,55 @@
package main
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.goblog.app/app/pkgs/plugintypes"
)
func TestExecPlugin(t *testing.T) {
app := &goBlog{
cfg: createDefaultTestConfig(t),
}
app.cfg.Plugins = []*configPlugin{
{
Path: "./plugins/demo",
Type: "exec",
Import: "demoexec",
},
}
err := app.initConfig(false)
require.NoError(t, err)
err = app.initPlugins()
require.NoError(t, err)
}
func TestMiddlewarePlugin(t *testing.T) {
app := &goBlog{
cfg: createDefaultTestConfig(t),
}
app.cfg.Plugins = []*configPlugin{
{
Path: "./plugins/demo",
Type: "middleware",
Import: "demomiddleware",
Config: map[string]any{
"prio": 99,
},
},
}
err := app.initConfig(false)
require.NoError(t, err)
err = app.initPlugins()
require.NoError(t, err)
middlewarePlugins := getPluginsForType[plugintypes.Middleware](app, "middleware")
if assert.Len(t, middlewarePlugins, 1) {
mdw := middlewarePlugins[0]
assert.Equal(t, 99, mdw.Prio())
}
}

@ -203,7 +203,7 @@ func (db *database) savePost(p *post, o *postCreationOptions) error {
// Commit transaction
sqlBuilder.WriteString("commit;")
// Execute
if _, err := db.exec(sqlBuilder.String(), sqlArgs...); err != nil {
if _, err := db.Exec(sqlBuilder.String(), sqlArgs...); err != nil {
if strings.Contains(err.Error(), "UNIQUE constraint failed: posts.path") {
return errors.New("post already exists at given path")
}
@ -229,7 +229,7 @@ func (a *goBlog) deletePost(path string) error {
// Post exists, check if it's already marked as deleted
if strings.HasSuffix(string(p.Status), statusDeletedSuffix) {
// Post is already marked as deleted, delete it from database
if _, err = a.db.exec(
if _, err = a.db.Exec(
`begin; delete from posts where path = ?; insert or ignore into deleted (path) values (?); commit;`,
dbNoCache, p.Path, p.Path, p.Path,
); err != nil {
@ -250,7 +250,7 @@ func (a *goBlog) deletePost(path string) error {
}
p.Parameters["deleted"] = []string{deletedTime}
// Mark post as deleted
if _, err = a.db.exec(
if _, err = a.db.Exec(
`begin; update posts set status = ? where path = ?; delete from post_parameters where path = ? and parameter = 'deleted'; insert into post_parameters (path, parameter, value) values (?, 'deleted', ?); commit;`,
dbNoCache, p.Status, p.Path, p.Path, p.Path, deletedTime,
); err != nil {
@ -283,7 +283,7 @@ func (a *goBlog) undeletePost(path string) error {
// Remove parameter
p.Parameters["deleted"] = nil
// Update database
if _, err = a.db.exec(
if _, err = a.db.Exec(
`begin; update posts set status = ? where path = ?; delete from post_parameters where path = ? and parameter = 'deleted'; commit;`,
dbNoCache, p.Status, p.Path, p.Path,
); err != nil {
@ -320,7 +320,7 @@ func (db *database) replacePostParam(path, param string, values []string) error
// Commit transaction
sqlBuilder.WriteString("commit;")
// Execute
_, err := db.exec(sqlBuilder.String(), sqlArgs...)
_, err := db.Exec(sqlBuilder.String(), sqlArgs...)
bufferpool.Put(sqlBuilder)
if err != nil {
return err
@ -514,7 +514,7 @@ func (d *database) loadPostParameters(posts []*post, parameters ...string) (err
// Order
queryBuilder.WriteString(" order by id")
// Query
rows, err := d.query(queryBuilder.String(), sqlArgs...)
rows, err := d.Query(queryBuilder.String(), sqlArgs...)
if err != nil {
return err
}
@ -542,7 +542,7 @@ func (d *database) loadPostParameters(posts []*post, parameters ...string) (err
func (a *goBlog) getPosts(config *postsRequestConfig) (posts []*post, err error) {
// Query posts
query, queryParams := buildPostsQuery(config, "path, coalesce(content, ''), coalesce(published, ''), coalesce(updated, ''), blog, coalesce(section, ''), status, priority")
rows, err := a.db.query(query, queryParams...)
rows, err := a.db.Query(query, queryParams...)
if err != nil {
return nil, err
}
@ -595,7 +595,7 @@ func (a *goBlog) getPost(path string) (*post, error) {
func (d *database) countPosts(config *postsRequestConfig) (count int, err error) {
query, params := buildPostsQuery(config, "path")
row, err := d.queryRow("select count(distinct path) from ("+query+")", params...)
row, err := d.QueryRow("select count(distinct path) from ("+query+")", params...)
if err != nil {
return
}
@ -606,7 +606,7 @@ func (d *database) countPosts(config *postsRequestConfig) (count int, err error)
func (a *goBlog) getRandomPostPath(blog string) (path string, err error) {
sections := lo.Keys(a.cfg.Blogs[blog].Sections)
query, params := buildPostsQuery(&postsRequestConfig{randomOrder: true, limit: 1, blog: blog, sections: sections}, "path")
row, err := a.db.queryRow(query, params...)
row, err := a.db.QueryRow(query, params...)
if err != nil {
return
}
@ -621,7 +621,7 @@ func (a *goBlog) getRandomPostPath(blog string) (path string, err error) {
func (d *database) allTaxonomyValues(blog string, taxonomy string) ([]string, error) {
// TODO: Query posts the normal way
rows, err := d.query("select distinct value from post_parameters where parameter = @tax and length(coalesce(value, '')) > 0 and path in (select path from posts where blog = @blog and status = @status) order by value", sql.Named("tax", taxonomy), sql.Named("blog", blog), sql.Named("status", statusPublished))
rows, err := d.Query("select distinct value from post_parameters where parameter = @tax and length(coalesce(value, '')) > 0 and path in (select path from posts where blog = @blog and status = @status) order by value", sql.Named("tax", taxonomy), sql.Named("blog", blog), sql.Named("status", statusPublished))
if err != nil {
return nil, err
}
@ -663,7 +663,7 @@ func (db *database) usesOfMediaFile(names ...string) (counts []int, err error) {
nameValues.WriteByte(')')
sqlArgs = append(sqlArgs, sql.Named(named, n))
}
rows, err := db.query(fmt.Sprintf(mediaUseSql, nameValues.String()), sqlArgs...)
rows, err := db.Query(fmt.Sprintf(mediaUseSql, nameValues.String()), sqlArgs...)
bufferpool.Put(nameValues)
if err != nil {
return nil, err

@ -402,7 +402,7 @@ func Test_postDeletesParams(t *testing.T) {
err = app.deletePost("/test/abc")
require.NoError(t, err)
row, err := app.db.queryRow("select count(*) from post_parameters where path = ? and parameter = ?", "/test/abc", "test")
row, err := app.db.QueryRow("select count(*) from post_parameters where path = ? and parameter = ?", "/test/abc", "test")
require.NoError(t, err)
var count int
@ -415,7 +415,7 @@ func Test_postDeletesParams(t *testing.T) {
err = app.deletePost("/test/abc")
require.NoError(t, err)
row, err = app.db.queryRow("select count(*) from post_parameters where path = ? and parameter = ?", "/test/abc", "test")
row, err = app.db.QueryRow("select count(*) from post_parameters where path = ? and parameter = ?", "/test/abc", "test")
require.NoError(t, err)
err = row.Scan(&count)

@ -22,7 +22,7 @@ func (a *goBlog) enqueue(name string, content []byte, schedule time.Time) error
if len(content) == 0 {
return errors.New("empty content")
}
_, err := a.db.exec(
_, err := a.db.Exec(
"insert into queue (name, content, schedule) values (@name, @content, @schedule)",
sql.Named("name", name),
sql.Named("content", content),
@ -35,7 +35,7 @@ func (a *goBlog) enqueue(name string, content []byte, schedule time.Time) error
}
func (a *goBlog) reschedule(qi *queueItem, dur time.Duration) error {
_, err := a.db.exec(
_, err := a.db.Exec(
"update queue set schedule = @schedule, content = @content where id = @id",
sql.Named("schedule", qi.schedule.Add(dur).UTC().Format(time.RFC3339Nano)),
sql.Named("content", qi.content),
@ -45,12 +45,12 @@ func (a *goBlog) reschedule(qi *queueItem, dur time.Duration) error {
}
func (a *goBlog) dequeue(qi *queueItem) error {
_, err := a.db.exec("delete from queue where id = @id", sql.Named("id", qi.id))
_, err := a.db.Exec("delete from queue where id = @id", sql.Named("id", qi.id))
return err
}
func (a *goBlog) peekQueue(ctx context.Context, name string) (*queueItem, error) {
row, err := a.db.queryRowContext(
row, err := a.db.QueryRowContext(
ctx,
"select id, name, content, schedule from queue where schedule <= @schedule and name = @name order by schedule asc limit 1",
sql.Named("name", name),

@ -77,7 +77,7 @@ func (a *goBlog) saveReaction(reaction, path string) error {
defer a.reactionsSfg.Forget(path)
defer a.reactionsCache.Del(path)
// Insert reaction
_, err := a.db.exec("insert into reactions (path, reaction, count) values (?, ?, 1) on conflict (path, reaction) do update set count=count+1", path, reaction)
_, err := a.db.Exec("insert into reactions (path, reaction, count) values (?, ?, 1) on conflict (path, reaction) do update set count=count+1", path, reaction)
return err
}
@ -125,7 +125,7 @@ func (a *goBlog) getReactionsFromDatabase(path string) (map[string]int, error) {
sqlBuf.WriteString(") and path not in (select path from post_parameters where parameter=? and value=?)")
sqlArgs = append(sqlArgs, reactionsPostParam, "false")
// Execute query
rows, err := a.db.query(sqlBuf.String(), sqlArgs...)
rows, err := a.db.Query(sqlBuf.String(), sqlArgs...)
if err != nil {
return nil, err
}

@ -23,7 +23,7 @@ const (
func (a *goBlog) initSessions() {
deleteExpiredSessions := func() {
if _, err := a.db.exec(