Files
gotify-server/plugin/manager.go
chrispruitt 72bd8c8ba6 Add default message priority for applications
Co-authored-by: Jannis Mattheis <contact@jmattheis.de>
2023-07-19 18:15:01 +00:00

417 lines
11 KiB
Go

package plugin
import (
"bufio"
"bytes"
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"log"
"path/filepath"
"plugin"
"strconv"
"strings"
"sync"
"github.com/gin-gonic/gin"
"github.com/gotify/server/v2/auth"
"github.com/gotify/server/v2/model"
"github.com/gotify/server/v2/plugin/compat"
"gopkg.in/yaml.v2"
)
// The Database interface for encapsulating database access.
type Database interface {
GetUsers() ([]*model.User, error)
GetPluginConfByUserAndPath(userid uint, path string) (*model.PluginConf, error)
CreatePluginConf(p *model.PluginConf) error
GetPluginConfByApplicationID(appid uint) (*model.PluginConf, error)
UpdatePluginConf(p *model.PluginConf) error
CreateMessage(message *model.Message) error
GetPluginConfByID(id uint) (*model.PluginConf, error)
GetPluginConfByToken(token string) (*model.PluginConf, error)
GetUserByID(id uint) (*model.User, error)
CreateApplication(application *model.Application) error
UpdateApplication(app *model.Application) error
GetApplicationsByUser(userID uint) ([]*model.Application, error)
GetApplicationByToken(token string) (*model.Application, error)
}
// Notifier notifies when a new message was created.
type Notifier interface {
Notify(userID uint, message *model.MessageExternal)
}
// Manager is an encapsulating layer for plugins and manages all plugins and its instances.
type Manager struct {
mutex *sync.RWMutex
instances map[uint]compat.PluginInstance
plugins map[string]compat.Plugin
messages chan MessageWithUserID
db Database
mux *gin.RouterGroup
}
// NewManager created a Manager from configurations.
func NewManager(db Database, directory string, mux *gin.RouterGroup, notifier Notifier) (*Manager, error) {
manager := &Manager{
mutex: &sync.RWMutex{},
instances: map[uint]compat.PluginInstance{},
plugins: map[string]compat.Plugin{},
messages: make(chan MessageWithUserID),
db: db,
mux: mux,
}
go func() {
for {
message := <-manager.messages
internalMsg := &model.Message{
ApplicationID: message.Message.ApplicationID,
Title: message.Message.Title,
Priority: *message.Message.Priority,
Date: message.Message.Date,
Message: message.Message.Message,
}
if message.Message.Extras != nil {
internalMsg.Extras, _ = json.Marshal(message.Message.Extras)
}
db.CreateMessage(internalMsg)
message.Message.ID = internalMsg.ID
notifier.Notify(message.UserID, &message.Message)
}
}()
if err := manager.loadPlugins(directory); err != nil {
return nil, err
}
users, err := manager.db.GetUsers()
if err != nil {
return nil, err
}
for _, user := range users {
if err := manager.initializeForUser(*user); err != nil {
return nil, err
}
}
return manager, nil
}
// ErrAlreadyEnabledOrDisabled is returned on SetPluginEnabled call when a plugin is already enabled or disabled.
var ErrAlreadyEnabledOrDisabled = errors.New("config is already enabled/disabled")
func (m *Manager) applicationExists(token string) bool {
app, _ := m.db.GetApplicationByToken(token)
return app != nil
}
func (m *Manager) pluginConfExists(token string) bool {
pluginConf, _ := m.db.GetPluginConfByToken(token)
return pluginConf != nil
}
// SetPluginEnabled sets the plugins enabled state.
func (m *Manager) SetPluginEnabled(pluginID uint, enabled bool) error {
instance, err := m.Instance(pluginID)
if err != nil {
return errors.New("instance not found")
}
conf, err := m.db.GetPluginConfByID(pluginID)
if err != nil {
return err
}
if conf.Enabled == enabled {
return ErrAlreadyEnabledOrDisabled
}
m.mutex.Lock()
defer m.mutex.Unlock()
if enabled {
err = instance.Enable()
} else {
err = instance.Disable()
}
if err != nil {
return err
}
if newConf, err := m.db.GetPluginConfByID(pluginID); /* conf might be updated by instance */ err == nil {
conf = newConf
}
conf.Enabled = enabled
return m.db.UpdatePluginConf(conf)
}
// PluginInfo returns plugin info.
func (m *Manager) PluginInfo(modulePath string) compat.Info {
m.mutex.RLock()
defer m.mutex.RUnlock()
if p, ok := m.plugins[modulePath]; ok {
return p.PluginInfo()
}
fmt.Println("Could not get plugin info for", modulePath)
return compat.Info{
Name: "UNKNOWN",
ModulePath: modulePath,
Description: "Oops something went wrong",
}
}
// Instance returns an instance with the given ID.
func (m *Manager) Instance(pluginID uint) (compat.PluginInstance, error) {
m.mutex.RLock()
defer m.mutex.RUnlock()
if instance, ok := m.instances[pluginID]; ok {
return instance, nil
}
return nil, errors.New("instance not found")
}
// HasInstance returns whether the given plugin ID has a corresponding instance.
func (m *Manager) HasInstance(pluginID uint) bool {
instance, err := m.Instance(pluginID)
return err == nil && instance != nil
}
// RemoveUser disabled all plugins of a user when the user is disabled.
func (m *Manager) RemoveUser(userID uint) error {
for _, p := range m.plugins {
pluginConf, err := m.db.GetPluginConfByUserAndPath(userID, p.PluginInfo().ModulePath)
if err != nil {
return err
}
if pluginConf == nil {
continue
}
if pluginConf.Enabled {
inst, err := m.Instance(pluginConf.ID)
if err != nil {
continue
}
m.mutex.Lock()
err = inst.Disable()
m.mutex.Unlock()
if err != nil {
return err
}
}
delete(m.instances, pluginConf.ID)
}
return nil
}
type pluginFileLoadError struct {
Filename string
UnderlyingError error
}
func (c pluginFileLoadError) Error() string {
return fmt.Sprintf("error while loading plugin %s: %s", c.Filename, c.UnderlyingError)
}
func (m *Manager) loadPlugins(directory string) error {
if directory == "" {
return nil
}
pluginFiles, err := ioutil.ReadDir(directory)
if err != nil {
return fmt.Errorf("error while reading directory %s", err)
}
for _, f := range pluginFiles {
pluginPath := filepath.Join(directory, "./", f.Name())
fmt.Println("Loading plugin", pluginPath)
pRaw, err := plugin.Open(pluginPath)
if err != nil {
return pluginFileLoadError{f.Name(), err}
}
compatPlugin, err := compat.Wrap(pRaw)
if err != nil {
return pluginFileLoadError{f.Name(), err}
}
if err := m.LoadPlugin(compatPlugin); err != nil {
return pluginFileLoadError{f.Name(), err}
}
}
return nil
}
// LoadPlugin loads a compat plugin, exported to sideload plugins for testing purposes.
func (m *Manager) LoadPlugin(compatPlugin compat.Plugin) error {
modulePath := compatPlugin.PluginInfo().ModulePath
if _, ok := m.plugins[modulePath]; ok {
return fmt.Errorf("plugin with module path %s is present at least twice", modulePath)
}
m.plugins[modulePath] = compatPlugin
return nil
}
// InitializeForUserID initializes all plugin instances for a given user.
func (m *Manager) InitializeForUserID(userID uint) error {
m.mutex.Lock()
defer m.mutex.Unlock()
user, err := m.db.GetUserByID(userID)
if err != nil {
return err
}
if user != nil {
return m.initializeForUser(*user)
}
return fmt.Errorf("user with id %d not found", userID)
}
func (m *Manager) initializeForUser(user model.User) error {
userCtx := compat.UserContext{
ID: user.ID,
Name: user.Name,
Admin: user.Admin,
}
for _, p := range m.plugins {
if err := m.initializeSingleUserPlugin(userCtx, p); err != nil {
return err
}
}
apps, err := m.db.GetApplicationsByUser(user.ID)
if err != nil {
return err
}
for _, app := range apps {
conf, err := m.db.GetPluginConfByApplicationID(app.ID)
if err != nil {
return err
}
if conf != nil {
_, compatExist := m.plugins[conf.ModulePath]
app.Internal = compatExist
} else {
app.Internal = false
}
m.db.UpdateApplication(app)
}
return nil
}
func (m *Manager) initializeSingleUserPlugin(userCtx compat.UserContext, p compat.Plugin) error {
info := p.PluginInfo()
instance := p.NewPluginInstance(userCtx)
userID := userCtx.ID
pluginConf, err := m.db.GetPluginConfByUserAndPath(userID, info.ModulePath)
if err != nil {
return err
}
if pluginConf == nil {
var err error
pluginConf, err = m.createPluginConf(instance, info, userID)
if err != nil {
return err
}
}
m.instances[pluginConf.ID] = instance
if compat.HasSupport(instance, compat.Messenger) {
instance.SetMessageHandler(redirectToChannel{
ApplicationID: pluginConf.ApplicationID,
UserID: pluginConf.UserID,
Messages: m.messages,
})
}
if compat.HasSupport(instance, compat.Storager) {
instance.SetStorageHandler(dbStorageHandler{pluginConf.ID, m.db})
}
if compat.HasSupport(instance, compat.Configurer) {
m.initializeConfigurerForSingleUserPlugin(instance, pluginConf)
}
if compat.HasSupport(instance, compat.Webhooker) {
id := pluginConf.ID
g := m.mux.Group(pluginConf.Token+"/", requirePluginEnabled(id, m.db))
instance.RegisterWebhook(strings.Replace(g.BasePath(), ":id", strconv.Itoa(int(id)), 1), g)
}
if pluginConf.Enabled {
err := instance.Enable()
if err != nil {
// Single user plugin cannot be enabled
// Don't panic, disable for now and wait for user to update config
log.Printf("Plugin initialize failed for user %s: %s. Disabling now...", userCtx.Name, err.Error())
pluginConf.Enabled = false
m.db.UpdatePluginConf(pluginConf)
}
}
return nil
}
func (m *Manager) initializeConfigurerForSingleUserPlugin(instance compat.PluginInstance, pluginConf *model.PluginConf) {
if len(pluginConf.Config) == 0 {
// The Configurer is newly implemented
// Use the default config
pluginConf.Config, _ = yaml.Marshal(instance.DefaultConfig())
m.db.UpdatePluginConf(pluginConf)
}
c := instance.DefaultConfig()
if yaml.Unmarshal(pluginConf.Config, c) != nil || instance.ValidateAndSetConfig(c) != nil {
pluginConf.Enabled = false
log.Printf("Plugin %s for user %d failed to initialize because it rejected the current config. It might be outdated. A default config is used and the user would need to enable it again.", pluginConf.ModulePath, pluginConf.UserID)
newConf := bytes.NewBufferString("# Plugin initialization failed because it rejected the current config. It might be outdated.\r\n# A default plugin configuration is used:\r\n")
d, _ := yaml.Marshal(c)
newConf.Write(d)
newConf.WriteString("\r\n")
newConf.WriteString("# The original configuration: \r\n")
oldConf := bufio.NewScanner(bytes.NewReader(pluginConf.Config))
for oldConf.Scan() {
newConf.WriteString("# ")
newConf.WriteString(oldConf.Text())
newConf.WriteString("\r\n")
}
pluginConf.Config = newConf.Bytes()
m.db.UpdatePluginConf(pluginConf)
instance.ValidateAndSetConfig(instance.DefaultConfig())
}
}
func (m *Manager) createPluginConf(instance compat.PluginInstance, info compat.Info, userID uint) (*model.PluginConf, error) {
pluginConf := &model.PluginConf{
UserID: userID,
ModulePath: info.ModulePath,
Token: auth.GenerateNotExistingToken(auth.GeneratePluginToken, m.pluginConfExists),
}
if compat.HasSupport(instance, compat.Configurer) {
pluginConf.Config, _ = yaml.Marshal(instance.DefaultConfig())
}
if compat.HasSupport(instance, compat.Messenger) {
app := &model.Application{
Token: auth.GenerateNotExistingToken(auth.GenerateApplicationToken, m.applicationExists),
Name: info.String(),
UserID: userID,
Internal: true,
Description: fmt.Sprintf("auto generated application for %s", info.ModulePath),
}
if err := m.db.CreateApplication(app); err != nil {
return nil, err
}
pluginConf.ApplicationID = app.ID
}
if err := m.db.CreatePluginConf(pluginConf); err != nil {
return nil, err
}
return pluginConf, nil
}