From b08b43953b973426aa15153189d949a5298eb1e7 Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 24 Jul 2025 12:14:19 +0200 Subject: [PATCH] feat(plugins): add rudimentary plugin system --- .gitignore | 2 + examples/plugins/example/main.go | 52 ++++++++++++++ magefile.go | 24 +++++++ pkg/cmd/web.go | 2 + pkg/config/config.go | 6 ++ pkg/initialize/init.go | 4 ++ pkg/migration/migration.go | 5 ++ pkg/plugins/interfaces.go | 35 +++++++++ pkg/plugins/manager.go | 119 +++++++++++++++++++++++++++++++ pkg/plugins/registry.go | 48 +++++++++++++ 10 files changed, 297 insertions(+) create mode 100644 examples/plugins/example/main.go create mode 100644 pkg/plugins/interfaces.go create mode 100644 pkg/plugins/manager.go create mode 100644 pkg/plugins/registry.go diff --git a/.gitignore b/.gitignore index ed86e56d9..60940b624 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ os-packages/ mage_output_file.go mage-static .DS_Store +/plugins/* # Devenv .devenv* @@ -44,3 +45,4 @@ devenv.local.nix /.claude/ PLAN.md /.crush/ + diff --git a/examples/plugins/example/main.go b/examples/plugins/example/main.go new file mode 100644 index 000000000..694162c1b --- /dev/null +++ b/examples/plugins/example/main.go @@ -0,0 +1,52 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package main + +import ( + "code.vikunja.io/api/pkg/events" + "code.vikunja.io/api/pkg/log" + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/plugins" + + "github.com/ThreeDotsLabs/watermill/message" +) + +type ExamplePlugin struct{} + +func (p *ExamplePlugin) Name() string { return "example" } +func (p *ExamplePlugin) Version() string { return "1.0.0" } +func (p *ExamplePlugin) Init() error { + log.Infof("example plugin initialized") + + events.RegisterListener((&models.TaskCreatedEvent{}).Name(), &TestListener{}) + + return nil +} +func (p *ExamplePlugin) Shutdown() error { return nil } + +func NewPlugin() plugins.Plugin { return &ExamplePlugin{} } + +type TestListener struct{} + +func (t *TestListener) Handle(msg *message.Message) error { + log.Infof("TestListener received message: %s", string(msg.Payload)) + return nil +} + +func (t *TestListener) Name() string { + return "TestListener" +} diff --git a/magefile.go b/magefile.go index ce34a526e..0a2ea1b70 100644 --- a/magefile.go +++ b/magefile.go @@ -73,6 +73,7 @@ var ( "dev:make-event": Dev.MakeEvent, "dev:make-listener": Dev.MakeListener, "dev:make-notification": Dev.MakeNotification, + "plugins:build": Plugins.Build, "lint": Check.Golangci, "lint:fix": Check.GolangciFix, "generate:config-yaml": Generate.ConfigYAML, @@ -1393,3 +1394,26 @@ func generateConfigYAMLFromJSON(yamlPath string, commented bool) { func (Generate) ConfigYAML(commented bool) { generateConfigYAMLFromJSON(DefaultConfigYAMLSamplePath, commented) } + +type Plugins mg.Namespace + +// Build compiles a Go plugin at the provided path. +func (Plugins) Build(pathToSourceFiles string) error { + mg.Deps(initVars) + if pathToSourceFiles == "" { + return fmt.Errorf("please provide a plugin path") + } + + // Convert relative path to absolute path + if !strings.HasPrefix(pathToSourceFiles, "/") { + absPath, err := filepath.Abs(pathToSourceFiles) + if err != nil { + return fmt.Errorf("failed to resolve absolute path: %v", err) + } + pathToSourceFiles = absPath + } + + out := filepath.Join(RootPath, "plugins", filepath.Base(pathToSourceFiles)+".so") + runAndStreamOutput("go", "build", "-buildmode=plugin", "-o", out, pathToSourceFiles) + return nil +} diff --git a/pkg/cmd/web.go b/pkg/cmd/web.go index 51dbd83bd..d99bda142 100644 --- a/pkg/cmd/web.go +++ b/pkg/cmd/web.go @@ -30,6 +30,7 @@ import ( "code.vikunja.io/api/pkg/cron" "code.vikunja.io/api/pkg/initialize" "code.vikunja.io/api/pkg/log" + "code.vikunja.io/api/pkg/plugins" "code.vikunja.io/api/pkg/routes" "code.vikunja.io/api/pkg/utils" "code.vikunja.io/api/pkg/version" @@ -160,5 +161,6 @@ var webCmd = &cobra.Command{ e.Logger.Fatal(err) } cron.Stop() + plugins.Shutdown() }, } diff --git a/pkg/config/config.go b/pkg/config/config.go index 6cd6cd084..88aef340d 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -207,6 +207,9 @@ const ( AutoTLSEnabled Key = `autotls.enabled` AutoTLSEmail Key = `autotls.email` AutoTLSRenewBefore Key = `autotls.renewbefore` + + PluginsEnabled Key = `plugins.enabled` + PluginsDir Key = `plugins.dir` ) // GetString returns a string config value @@ -446,6 +449,9 @@ func InitDefaultConfig() { WebhooksTimeoutSeconds.setDefault(30) // AutoTLS AutoTLSRenewBefore.setDefault("720h") // 30days in hours + // Plugins + PluginsEnabled.setDefault(false) + PluginsDir.setDefault(filepath.Join(ServiceRootpath.GetString(), "plugins")) } func GetConfigValueFromFile(configKey string) string { diff --git a/pkg/initialize/init.go b/pkg/initialize/init.go index 04bbc9b0e..9629df886 100644 --- a/pkg/initialize/init.go +++ b/pkg/initialize/init.go @@ -33,6 +33,7 @@ import ( "code.vikunja.io/api/pkg/modules/auth/openid" "code.vikunja.io/api/pkg/modules/keyvalue" migrationHandler "code.vikunja.io/api/pkg/modules/migration/handler" + "code.vikunja.io/api/pkg/plugins" "code.vikunja.io/api/pkg/red" "code.vikunja.io/api/pkg/user" ) @@ -98,6 +99,9 @@ func FullInitWithoutAsync() { // Load translations i18n.Init() + + // Initialize plugins + plugins.Initialize() } // FullInit initializes all kinds of things in the right order diff --git a/pkg/migration/migration.go b/pkg/migration/migration.go index 8da4303c6..8aa6dff6f 100644 --- a/pkg/migration/migration.go +++ b/pkg/migration/migration.go @@ -39,6 +39,11 @@ import ( var migrations []*xormigrate.Migration +// AddPluginMigrations adds migrations provided by plugins to the global list. +func AddPluginMigrations(ms []*xormigrate.Migration) { + migrations = append(migrations, ms...) +} + // A helper function because we need a migration in various places which we can't really solve with an init() function. func initMigration(x *xorm.Engine) *xormigrate.Xormigrate { // Get our own xorm engine if we don't have one diff --git a/pkg/plugins/interfaces.go b/pkg/plugins/interfaces.go new file mode 100644 index 000000000..597cec3eb --- /dev/null +++ b/pkg/plugins/interfaces.go @@ -0,0 +1,35 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package plugins + +import ( + "src.techknowlogick.com/xormigrate" +) + +// Plugin is the base interface all plugins need to implement. +type Plugin interface { + Name() string + Version() string + Init() error + Shutdown() error +} + +// MigrationPlugin lets a plugin provide database migrations. +type MigrationPlugin interface { + Plugin + Migrations() []*xormigrate.Migration +} diff --git a/pkg/plugins/manager.go b/pkg/plugins/manager.go new file mode 100644 index 000000000..d5c2337b1 --- /dev/null +++ b/pkg/plugins/manager.go @@ -0,0 +1,119 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package plugins + +import ( + "errors" + "os" + "path/filepath" + goplugin "plugin" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/log" + "code.vikunja.io/api/pkg/migration" +) + +// Manager handles loading and managing plugins. +type Manager struct { + plugins []Plugin + migrationPlugs []MigrationPlugin +} + +var manager = &Manager{} + +// ManagerInstance returns the global plugin manager. +func ManagerInstance() *Manager { return manager } + +// Initialize loads plugins and runs their migrations and init functions. +func Initialize() { + if !config.PluginsEnabled.GetBool() { + return + } + + paths := []string{config.PluginsDir.GetString()} + if err := manager.loadPlugins(paths); err != nil { + log.Fatalf("Loading plugins failed: %v", err) + } + + // Run plugin migrations after core migrations + if len(manager.migrationPlugs) > 0 { + migration.Migrate(nil) + } + + for _, p := range manager.plugins { + if err := p.Init(); err != nil { + log.Errorf("Plugin %s failed to init: %s", p.Name(), err) + } + } +} + +// Shutdown calls Shutdown on all loaded plugins. +func Shutdown() { + for _, p := range manager.plugins { + if err := p.Shutdown(); err != nil { + log.Errorf("Plugin %s shutdown failed: %s", p.Name(), err) + } + } +} + +func (m *Manager) loadPlugins(paths []string) error { + for _, p := range paths { + entries, err := os.ReadDir(p) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + continue + } + return err + } + for _, e := range entries { + if filepath.Ext(e.Name()) != ".so" { + continue + } + full := filepath.Join(p, e.Name()) + if err := m.loadPlugin(full); err != nil { + log.Errorf("Failed to load plugin %s: %s", e.Name(), err) + } + } + } + return nil +} + +func (m *Manager) loadPlugin(path string) error { + pl, err := goplugin.Open(path) + if err != nil { + return err + } + sym, err := pl.Lookup("NewPlugin") + if err != nil { + return err + } + newPlugin, ok := sym.(func() Plugin) + if !ok { + return errors.New("invalid plugin entry point") + } + p := newPlugin() + m.plugins = append(m.plugins, p) + + if mp, ok := p.(MigrationPlugin); ok { + m.migrationPlugs = append(m.migrationPlugs, mp) + migration.AddPluginMigrations(mp.Migrations()) + } + + log.Infof("Loaded plugin %s", p.Name()) + + return nil +} diff --git a/pkg/plugins/registry.go b/pkg/plugins/registry.go new file mode 100644 index 000000000..735fa1a2d --- /dev/null +++ b/pkg/plugins/registry.go @@ -0,0 +1,48 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package plugins + +import "sync" + +// Registry keeps track of loaded plugins. +type Registry struct { + mu sync.RWMutex + plugins map[string]Plugin +} + +// NewRegistry creates a new Registry. +func NewRegistry() *Registry { + return &Registry{plugins: make(map[string]Plugin)} +} + +// Add registers a plugin. +func (r *Registry) Add(p Plugin) { + r.mu.Lock() + defer r.mu.Unlock() + r.plugins[p.Name()] = p +} + +// All returns all registered plugins. +func (r *Registry) All() []Plugin { + r.mu.RLock() + defer r.mu.RUnlock() + res := make([]Plugin, 0, len(r.plugins)) + for _, p := range r.plugins { + res = append(res, p) + } + return res +}