feat(plugins): add rudimentary plugin system

This commit is contained in:
kolaente
2025-07-24 12:14:19 +02:00
parent 813bdb58ff
commit b08b43953b
10 changed files with 297 additions and 0 deletions

2
.gitignore vendored
View File

@@ -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/

View File

@@ -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 <https://www.gnu.org/licenses/>.
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"
}

View File

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

View File

@@ -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()
},
}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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

35
pkg/plugins/interfaces.go Normal file
View File

@@ -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 <https://www.gnu.org/licenses/>.
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
}

119
pkg/plugins/manager.go Normal file
View File

@@ -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 <https://www.gnu.org/licenses/>.
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
}

48
pkg/plugins/registry.go Normal file
View File

@@ -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 <https://www.gnu.org/licenses/>.
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
}