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
+}