mirror of
https://github.com/m1k1o/neko.git
synced 2025-06-06 04:42:47 +02:00
post megre cleanup.
This commit is contained in:
parent
3e1def9041
commit
5b96fd5f5e
284 changed files with 7103 additions and 11307 deletions
23
server/internal/plugins/chat/config.go
Normal file
23
server/internal/plugins/chat/config.go
Normal file
|
@ -0,0 +1,23 @@
|
|||
package chat
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
func (Config) Init(cmd *cobra.Command) error {
|
||||
cmd.PersistentFlags().Bool("chat.enabled", true, "whether to enable chat plugin")
|
||||
if err := viper.BindPFlag("chat.enabled", cmd.PersistentFlags().Lookup("chat.enabled")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Config) Set() {
|
||||
s.Enabled = viper.GetBool("chat.enabled")
|
||||
}
|
162
server/internal/plugins/chat/manager.go
Normal file
162
server/internal/plugins/chat/manager.go
Normal file
|
@ -0,0 +1,162 @@
|
|||
package chat
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"github.com/demodesk/neko/pkg/auth"
|
||||
"github.com/demodesk/neko/pkg/types"
|
||||
"github.com/demodesk/neko/pkg/utils"
|
||||
)
|
||||
|
||||
func NewManager(
|
||||
sessions types.SessionManager,
|
||||
config *Config,
|
||||
) *Manager {
|
||||
logger := log.With().Str("module", "chat").Logger()
|
||||
|
||||
return &Manager{
|
||||
logger: logger,
|
||||
config: config,
|
||||
sessions: sessions,
|
||||
}
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
logger zerolog.Logger
|
||||
config *Config
|
||||
sessions types.SessionManager
|
||||
}
|
||||
|
||||
type Settings struct {
|
||||
CanSend bool `json:"can_send" mapstructure:"can_send"`
|
||||
CanReceive bool `json:"can_receive" mapstructure:"can_receive"`
|
||||
}
|
||||
|
||||
func (m *Manager) settingsForSession(session types.Session) (Settings, error) {
|
||||
settings := Settings{
|
||||
CanSend: true, // defaults to true
|
||||
CanReceive: true, // defaults to true
|
||||
}
|
||||
err := m.sessions.Settings().Plugins.Unmarshal(PluginName, &settings)
|
||||
if err != nil && !errors.Is(err, types.ErrPluginSettingsNotFound) {
|
||||
return Settings{}, fmt.Errorf("unable to unmarshal %s plugin settings from global settings: %w", PluginName, err)
|
||||
}
|
||||
|
||||
profile := Settings{
|
||||
CanSend: true, // defaults to true
|
||||
CanReceive: true, // defaults to true
|
||||
}
|
||||
|
||||
err = session.Profile().Plugins.Unmarshal(PluginName, &profile)
|
||||
if err != nil && !errors.Is(err, types.ErrPluginSettingsNotFound) {
|
||||
return Settings{}, fmt.Errorf("unable to unmarshal %s plugin settings from profile: %w", PluginName, err)
|
||||
}
|
||||
|
||||
return Settings{
|
||||
CanSend: m.config.Enabled && (settings.CanSend || session.Profile().IsAdmin) && profile.CanSend,
|
||||
CanReceive: m.config.Enabled && (settings.CanReceive || session.Profile().IsAdmin) && profile.CanReceive,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *Manager) sendMessage(session types.Session, content Content) {
|
||||
now := time.Now()
|
||||
|
||||
// get all sessions that have chat enabled
|
||||
var sessions []types.Session
|
||||
m.sessions.Range(func(s types.Session) bool {
|
||||
if settings, err := m.settingsForSession(s); err == nil && settings.CanReceive {
|
||||
sessions = append(sessions, s)
|
||||
}
|
||||
// continue iteration over all sessions
|
||||
return true
|
||||
})
|
||||
|
||||
// send content to all sessions
|
||||
for _, s := range sessions {
|
||||
s.Send(CHAT_MESSAGE, Message{
|
||||
ID: session.ID(),
|
||||
Created: now,
|
||||
Content: content,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) Start() error {
|
||||
// send init message once a user connects
|
||||
m.sessions.OnConnected(func(session types.Session) {
|
||||
session.Send(CHAT_INIT, Init{
|
||||
Enabled: m.config.Enabled,
|
||||
})
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) Shutdown() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) Route(r types.Router) {
|
||||
r.With(auth.AdminsOnly).Post("/", m.sendMessageHandler)
|
||||
}
|
||||
|
||||
func (m *Manager) WebSocketHandler(session types.Session, msg types.WebSocketMessage) bool {
|
||||
switch msg.Event {
|
||||
case CHAT_MESSAGE:
|
||||
var content Content
|
||||
if err := json.Unmarshal(msg.Payload, &content); err != nil {
|
||||
m.logger.Error().Err(err).Msg("failed to unmarshal chat message")
|
||||
// we processed the message, return true
|
||||
return true
|
||||
}
|
||||
|
||||
settings, err := m.settingsForSession(session)
|
||||
if err != nil {
|
||||
m.logger.Error().Err(err).Msg("error checking chat permissions for this session")
|
||||
// we processed the message, return true
|
||||
return true
|
||||
}
|
||||
if !settings.CanSend {
|
||||
m.logger.Warn().Msg("not allowed to send chat messages")
|
||||
// we processed the message, return true
|
||||
return true
|
||||
}
|
||||
|
||||
m.sendMessage(session, content)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *Manager) sendMessageHandler(w http.ResponseWriter, r *http.Request) error {
|
||||
session, ok := auth.GetSession(r)
|
||||
if !ok {
|
||||
return utils.HttpUnauthorized("session not found")
|
||||
}
|
||||
|
||||
settings, err := m.settingsForSession(session)
|
||||
if err != nil {
|
||||
return utils.HttpInternalServerError().
|
||||
WithInternalErr(err).
|
||||
Msg("error checking chat permissions for this session")
|
||||
}
|
||||
|
||||
if !settings.CanSend {
|
||||
return utils.HttpForbidden("not allowed to send chat messages")
|
||||
}
|
||||
|
||||
content := Content{}
|
||||
if err := utils.HttpJsonRequest(w, r, &content); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
m.sendMessage(session, content)
|
||||
return utils.HttpSuccess(w)
|
||||
}
|
35
server/internal/plugins/chat/plugin.go
Normal file
35
server/internal/plugins/chat/plugin.go
Normal file
|
@ -0,0 +1,35 @@
|
|||
package chat
|
||||
|
||||
import (
|
||||
"github.com/demodesk/neko/pkg/types"
|
||||
)
|
||||
|
||||
type Plugin struct {
|
||||
config *Config
|
||||
manager *Manager
|
||||
}
|
||||
|
||||
func NewPlugin() *Plugin {
|
||||
return &Plugin{
|
||||
config: &Config{},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plugin) Name() string {
|
||||
return PluginName
|
||||
}
|
||||
|
||||
func (p *Plugin) Config() types.PluginConfig {
|
||||
return p.config
|
||||
}
|
||||
|
||||
func (p *Plugin) Start(m types.PluginManagers) error {
|
||||
p.manager = NewManager(m.SessionManager, p.config)
|
||||
m.ApiManager.AddRouter("/chat", p.manager.Route)
|
||||
m.WebSocketManager.AddHandler(p.manager.WebSocketHandler)
|
||||
return p.manager.Start()
|
||||
}
|
||||
|
||||
func (p *Plugin) Shutdown() error {
|
||||
return p.manager.Shutdown()
|
||||
}
|
24
server/internal/plugins/chat/types.go
Normal file
24
server/internal/plugins/chat/types.go
Normal file
|
@ -0,0 +1,24 @@
|
|||
package chat
|
||||
|
||||
import "time"
|
||||
|
||||
const PluginName = "chat"
|
||||
|
||||
const (
|
||||
CHAT_INIT = "chat/init"
|
||||
CHAT_MESSAGE = "chat/message"
|
||||
)
|
||||
|
||||
type Init struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type Content struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
type Message struct {
|
||||
ID string `json:"id"`
|
||||
Created time.Time `json:"created"`
|
||||
Content Content `json:"content"`
|
||||
}
|
133
server/internal/plugins/dependency.go
Normal file
133
server/internal/plugins/dependency.go
Normal file
|
@ -0,0 +1,133 @@
|
|||
package plugins
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
|
||||
"github.com/demodesk/neko/pkg/types"
|
||||
)
|
||||
|
||||
type dependency struct {
|
||||
plugin types.Plugin
|
||||
dependsOn []*dependency
|
||||
invoked bool
|
||||
logger zerolog.Logger
|
||||
}
|
||||
|
||||
func (a *dependency) findPlugin(name string) (*dependency, bool) {
|
||||
if a == nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
if a.plugin.Name() == name {
|
||||
return a, true
|
||||
}
|
||||
|
||||
for _, dep := range a.dependsOn {
|
||||
plug, ok := dep.findPlugin(name)
|
||||
if ok {
|
||||
return plug, true
|
||||
}
|
||||
}
|
||||
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (a *dependency) startPlugin(pm types.PluginManagers) error {
|
||||
if a.invoked {
|
||||
return nil
|
||||
}
|
||||
|
||||
a.invoked = true
|
||||
|
||||
for _, do := range a.dependsOn {
|
||||
if err := do.startPlugin(pm); err != nil {
|
||||
return fmt.Errorf("plugin's '%s' dependency: %w", a.plugin.Name(), err)
|
||||
}
|
||||
}
|
||||
|
||||
err := a.plugin.Start(pm)
|
||||
if err != nil {
|
||||
return fmt.Errorf("plugin '%s' failed to start: %w", a.plugin.Name(), err)
|
||||
}
|
||||
|
||||
a.logger.Info().Str("plugin", a.plugin.Name()).Msg("plugin started")
|
||||
return nil
|
||||
}
|
||||
|
||||
type dependiencies struct {
|
||||
deps map[string]*dependency
|
||||
logger zerolog.Logger
|
||||
}
|
||||
|
||||
func (d *dependiencies) addPlugin(plugin types.Plugin) error {
|
||||
pluginName := plugin.Name()
|
||||
|
||||
plug, ok := d.deps[pluginName]
|
||||
if !ok {
|
||||
plug = &dependency{}
|
||||
} else if plug.plugin != nil {
|
||||
return fmt.Errorf("plugin '%s' already added", pluginName)
|
||||
}
|
||||
|
||||
plug.plugin = plugin
|
||||
plug.logger = d.logger
|
||||
d.deps[pluginName] = plug
|
||||
|
||||
dplug, ok := plugin.(types.DependablePlugin)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, depName := range dplug.DependsOn() {
|
||||
dependsOn, ok := d.deps[depName]
|
||||
if !ok {
|
||||
dependsOn = &dependency{}
|
||||
} else if dependsOn.plugin != nil {
|
||||
// if there is a cyclical dependency, break it and return error
|
||||
if tdep, ok := dependsOn.findPlugin(pluginName); ok {
|
||||
dependsOn.dependsOn = nil
|
||||
delete(d.deps, pluginName)
|
||||
return fmt.Errorf("cyclical dependency detected: '%s' <-> '%s'", pluginName, tdep.plugin.Name())
|
||||
}
|
||||
}
|
||||
|
||||
plug.dependsOn = append(plug.dependsOn, dependsOn)
|
||||
d.deps[depName] = dependsOn
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *dependiencies) findPlugin(name string) (*dependency, bool) {
|
||||
for _, dep := range d.deps {
|
||||
plug, ok := dep.findPlugin(name)
|
||||
if ok {
|
||||
return plug, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (d *dependiencies) start(pm types.PluginManagers) error {
|
||||
for _, dep := range d.deps {
|
||||
if err := dep.startPlugin(pm); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *dependiencies) forEach(f func(*dependency) error) error {
|
||||
for _, dep := range d.deps {
|
||||
if err := f(dep); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *dependiencies) len() int {
|
||||
return len(d.deps)
|
||||
}
|
630
server/internal/plugins/dependency_test.go
Normal file
630
server/internal/plugins/dependency_test.go
Normal file
|
@ -0,0 +1,630 @@
|
|||
package plugins
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/demodesk/neko/pkg/types"
|
||||
)
|
||||
|
||||
func Test_deps_addPlugin(t *testing.T) {
|
||||
type args struct {
|
||||
p []types.Plugin
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want map[string]*dependency
|
||||
skipRun bool
|
||||
wantErr1 bool
|
||||
wantErr2 bool
|
||||
}{
|
||||
{
|
||||
name: "three plugins - no dependencies",
|
||||
args: args{
|
||||
p: []types.Plugin{
|
||||
&dummyPlugin{name: "first"},
|
||||
&dummyPlugin{name: "second"},
|
||||
&dummyPlugin{name: "third"},
|
||||
},
|
||||
},
|
||||
want: map[string]*dependency{
|
||||
"first": {
|
||||
plugin: &dummyPlugin{name: "first", idx: 0},
|
||||
invoked: true,
|
||||
dependsOn: nil,
|
||||
},
|
||||
"second": {
|
||||
plugin: &dummyPlugin{name: "second", idx: 0},
|
||||
invoked: true,
|
||||
dependsOn: nil,
|
||||
},
|
||||
"third": {
|
||||
plugin: &dummyPlugin{name: "third", idx: 0},
|
||||
invoked: true,
|
||||
dependsOn: nil,
|
||||
},
|
||||
},
|
||||
}, {
|
||||
name: "three plugins - one dependency",
|
||||
args: args{
|
||||
p: []types.Plugin{
|
||||
&dummyPlugin{name: "third", dep: []string{"second"}},
|
||||
&dummyPlugin{name: "first"},
|
||||
&dummyPlugin{name: "second"},
|
||||
},
|
||||
},
|
||||
want: map[string]*dependency{
|
||||
"first": {
|
||||
plugin: &dummyPlugin{name: "first", idx: 0},
|
||||
invoked: true,
|
||||
dependsOn: nil,
|
||||
},
|
||||
"second": {
|
||||
plugin: &dummyPlugin{name: "second", idx: 0},
|
||||
invoked: true,
|
||||
dependsOn: nil,
|
||||
},
|
||||
"third": {
|
||||
plugin: &dummyPlugin{name: "third", dep: []string{"second"}, idx: 1},
|
||||
invoked: true,
|
||||
dependsOn: []*dependency{
|
||||
{
|
||||
plugin: &dummyPlugin{name: "second", idx: 0},
|
||||
invoked: true,
|
||||
dependsOn: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, {
|
||||
name: "three plugins - one double dependency",
|
||||
args: args{
|
||||
p: []types.Plugin{
|
||||
&dummyPlugin{name: "third", dep: []string{"first", "second"}},
|
||||
&dummyPlugin{name: "first"},
|
||||
&dummyPlugin{name: "second"},
|
||||
},
|
||||
},
|
||||
want: map[string]*dependency{
|
||||
"first": {
|
||||
plugin: &dummyPlugin{name: "first", idx: 0},
|
||||
invoked: true,
|
||||
dependsOn: nil,
|
||||
},
|
||||
"second": {
|
||||
plugin: &dummyPlugin{name: "second", idx: 0},
|
||||
invoked: true,
|
||||
dependsOn: nil,
|
||||
},
|
||||
"third": {
|
||||
plugin: &dummyPlugin{name: "third", dep: []string{"first", "second"}, idx: 1},
|
||||
invoked: true,
|
||||
dependsOn: []*dependency{
|
||||
{
|
||||
plugin: &dummyPlugin{name: "first", idx: 0},
|
||||
invoked: true,
|
||||
dependsOn: nil,
|
||||
},
|
||||
{
|
||||
plugin: &dummyPlugin{name: "second", idx: 0},
|
||||
invoked: true,
|
||||
dependsOn: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, {
|
||||
name: "three plugins - two dependencies",
|
||||
args: args{
|
||||
p: []types.Plugin{
|
||||
&dummyPlugin{name: "third", dep: []string{"first"}},
|
||||
&dummyPlugin{name: "first"},
|
||||
&dummyPlugin{name: "second", dep: []string{"first"}},
|
||||
},
|
||||
},
|
||||
want: map[string]*dependency{
|
||||
"first": {
|
||||
plugin: &dummyPlugin{name: "first"},
|
||||
invoked: false,
|
||||
dependsOn: nil,
|
||||
},
|
||||
"third": {
|
||||
plugin: &dummyPlugin{name: "third", dep: []string{"first"}},
|
||||
invoked: false,
|
||||
dependsOn: []*dependency{
|
||||
{
|
||||
plugin: &dummyPlugin{name: "first"},
|
||||
invoked: false,
|
||||
dependsOn: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
"second": {
|
||||
plugin: &dummyPlugin{name: "second", dep: []string{"first"}},
|
||||
invoked: false,
|
||||
dependsOn: []*dependency{
|
||||
{
|
||||
plugin: &dummyPlugin{name: "first"},
|
||||
invoked: false,
|
||||
dependsOn: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
skipRun: true,
|
||||
}, {
|
||||
name: "three plugins - three dependencies",
|
||||
args: args{
|
||||
p: []types.Plugin{
|
||||
&dummyPlugin{name: "third", dep: []string{"second"}},
|
||||
&dummyPlugin{name: "first"},
|
||||
&dummyPlugin{name: "second", dep: []string{"first"}},
|
||||
},
|
||||
},
|
||||
want: map[string]*dependency{
|
||||
"first": {
|
||||
plugin: &dummyPlugin{name: "first", idx: 0},
|
||||
invoked: true,
|
||||
dependsOn: nil,
|
||||
},
|
||||
"second": {
|
||||
plugin: &dummyPlugin{name: "second", dep: []string{"first"}, idx: 1},
|
||||
invoked: true,
|
||||
dependsOn: []*dependency{
|
||||
{
|
||||
plugin: &dummyPlugin{name: "first", idx: 0},
|
||||
invoked: true,
|
||||
dependsOn: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
"third": {
|
||||
plugin: &dummyPlugin{name: "third", dep: []string{"second"}, idx: 2},
|
||||
invoked: true,
|
||||
dependsOn: []*dependency{
|
||||
{
|
||||
plugin: &dummyPlugin{name: "second", dep: []string{"first"}, idx: 1},
|
||||
invoked: true,
|
||||
dependsOn: []*dependency{
|
||||
{
|
||||
plugin: &dummyPlugin{name: "first", idx: 0},
|
||||
invoked: true,
|
||||
dependsOn: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, {
|
||||
name: "four plugins - added in reverse order, with dependencies",
|
||||
args: args{
|
||||
p: []types.Plugin{
|
||||
&dummyPlugin{name: "forth", dep: []string{"third"}},
|
||||
&dummyPlugin{name: "third", dep: []string{"second"}},
|
||||
&dummyPlugin{name: "second", dep: []string{"first"}},
|
||||
&dummyPlugin{name: "first"},
|
||||
},
|
||||
},
|
||||
want: map[string]*dependency{
|
||||
"first": {
|
||||
plugin: &dummyPlugin{name: "first", idx: 0},
|
||||
invoked: false,
|
||||
dependsOn: nil,
|
||||
},
|
||||
"second": {
|
||||
plugin: &dummyPlugin{name: "second", dep: []string{"first"}, idx: 0},
|
||||
invoked: false,
|
||||
dependsOn: []*dependency{
|
||||
{
|
||||
plugin: &dummyPlugin{name: "first", idx: 0},
|
||||
invoked: false,
|
||||
dependsOn: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
"third": {
|
||||
plugin: &dummyPlugin{name: "third", dep: []string{"second"}, idx: 0},
|
||||
invoked: false,
|
||||
dependsOn: []*dependency{
|
||||
{
|
||||
plugin: &dummyPlugin{name: "second", dep: []string{"first"}, idx: 0},
|
||||
invoked: false,
|
||||
dependsOn: []*dependency{
|
||||
{
|
||||
plugin: &dummyPlugin{name: "first", idx: 0},
|
||||
invoked: false,
|
||||
dependsOn: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"forth": {
|
||||
plugin: &dummyPlugin{name: "forth", dep: []string{"third"}, idx: 0},
|
||||
invoked: false,
|
||||
dependsOn: []*dependency{
|
||||
{
|
||||
plugin: &dummyPlugin{name: "third", dep: []string{"second"}, idx: 0},
|
||||
invoked: false,
|
||||
dependsOn: []*dependency{
|
||||
{
|
||||
plugin: &dummyPlugin{name: "second", dep: []string{"first"}, idx: 0},
|
||||
invoked: false,
|
||||
dependsOn: []*dependency{
|
||||
{
|
||||
plugin: &dummyPlugin{name: "first", idx: 0},
|
||||
invoked: false,
|
||||
dependsOn: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
skipRun: true,
|
||||
}, {
|
||||
name: "four plugins - two double dependencies",
|
||||
args: args{
|
||||
p: []types.Plugin{
|
||||
&dummyPlugin{name: "forth", dep: []string{"first", "third"}},
|
||||
&dummyPlugin{name: "third", dep: []string{"first", "second"}},
|
||||
&dummyPlugin{name: "second"},
|
||||
&dummyPlugin{name: "first"},
|
||||
},
|
||||
},
|
||||
want: map[string]*dependency{
|
||||
"first": {
|
||||
plugin: &dummyPlugin{name: "first", idx: 0},
|
||||
invoked: true,
|
||||
dependsOn: nil,
|
||||
},
|
||||
"second": {
|
||||
plugin: &dummyPlugin{name: "second", idx: 0},
|
||||
invoked: true,
|
||||
dependsOn: nil,
|
||||
},
|
||||
"third": {
|
||||
plugin: &dummyPlugin{name: "third", dep: []string{"first", "second"}, idx: 1},
|
||||
invoked: true,
|
||||
dependsOn: []*dependency{
|
||||
{
|
||||
plugin: &dummyPlugin{name: "first", idx: 0},
|
||||
invoked: true,
|
||||
dependsOn: nil,
|
||||
},
|
||||
{
|
||||
plugin: &dummyPlugin{name: "second", idx: 0},
|
||||
invoked: true,
|
||||
dependsOn: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
"forth": {
|
||||
plugin: &dummyPlugin{name: "forth", dep: []string{"first", "third"}, idx: 2},
|
||||
invoked: true,
|
||||
dependsOn: []*dependency{
|
||||
{
|
||||
plugin: &dummyPlugin{name: "first", idx: 0},
|
||||
invoked: true,
|
||||
dependsOn: nil,
|
||||
},
|
||||
{
|
||||
plugin: &dummyPlugin{name: "third", dep: []string{"first", "second"}, idx: 1},
|
||||
invoked: true,
|
||||
dependsOn: []*dependency{
|
||||
{
|
||||
plugin: &dummyPlugin{name: "first", idx: 0},
|
||||
invoked: true,
|
||||
dependsOn: nil,
|
||||
},
|
||||
{
|
||||
plugin: &dummyPlugin{name: "second", idx: 0},
|
||||
invoked: true,
|
||||
dependsOn: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, {
|
||||
// So, when we have plugin A in the list and want to add plugin C we can't determine the proper order without
|
||||
// resolving their direct dependiencies first:
|
||||
//
|
||||
// Can be C->D->A->B if D depends on A
|
||||
//
|
||||
// So to do it properly I would imagine tht we need to resolve all direct dependiencies first and build multiple lists:
|
||||
//
|
||||
// i.e. A->B->C D F->G
|
||||
//
|
||||
// and then join these lists in any order.
|
||||
name: "add indirect dependency CDAB",
|
||||
args: args{
|
||||
p: []types.Plugin{
|
||||
&dummyPlugin{name: "A", dep: []string{"B"}},
|
||||
&dummyPlugin{name: "C", dep: []string{"D"}},
|
||||
&dummyPlugin{name: "B"},
|
||||
&dummyPlugin{name: "D", dep: []string{"A"}},
|
||||
},
|
||||
},
|
||||
want: map[string]*dependency{
|
||||
"B": {
|
||||
plugin: &dummyPlugin{name: "B", idx: 0},
|
||||
invoked: true,
|
||||
dependsOn: nil,
|
||||
},
|
||||
"A": {
|
||||
plugin: &dummyPlugin{name: "A", dep: []string{"B"}, idx: 1},
|
||||
invoked: true,
|
||||
dependsOn: []*dependency{
|
||||
{
|
||||
plugin: &dummyPlugin{name: "B", idx: 0},
|
||||
invoked: true,
|
||||
dependsOn: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
"D": {
|
||||
plugin: &dummyPlugin{name: "D", dep: []string{"A"}, idx: 2},
|
||||
invoked: true,
|
||||
dependsOn: []*dependency{
|
||||
{
|
||||
plugin: &dummyPlugin{name: "A", dep: []string{"B"}, idx: 1},
|
||||
invoked: true,
|
||||
dependsOn: []*dependency{
|
||||
{
|
||||
plugin: &dummyPlugin{name: "B", idx: 0},
|
||||
invoked: true,
|
||||
dependsOn: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"C": {
|
||||
plugin: &dummyPlugin{name: "C", dep: []string{"D"}, idx: 3},
|
||||
invoked: true,
|
||||
dependsOn: []*dependency{
|
||||
{
|
||||
plugin: &dummyPlugin{name: "D", dep: []string{"A"}, idx: 2},
|
||||
invoked: true,
|
||||
dependsOn: []*dependency{
|
||||
{
|
||||
plugin: &dummyPlugin{name: "A", dep: []string{"B"}, idx: 1},
|
||||
invoked: true,
|
||||
dependsOn: []*dependency{
|
||||
{
|
||||
plugin: &dummyPlugin{name: "B", idx: 0},
|
||||
invoked: true,
|
||||
dependsOn: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, {
|
||||
// So, when we have plugin A in the list and want to add plugin C we can't determine the proper order without
|
||||
// resolving their direct dependiencies first:
|
||||
//
|
||||
// Can be A->B->C->D (in this test) if B depends on C
|
||||
//
|
||||
// So to do it properly I would imagine tht we need to resolve all direct dependiencies first and build multiple lists:
|
||||
//
|
||||
// i.e. A->B->C D F->G
|
||||
//
|
||||
// and then join these lists in any order.
|
||||
name: "add indirect dependency ABCD",
|
||||
args: args{
|
||||
p: []types.Plugin{
|
||||
&dummyPlugin{name: "C", dep: []string{"D"}},
|
||||
&dummyPlugin{name: "D"},
|
||||
&dummyPlugin{name: "B", dep: []string{"C"}},
|
||||
&dummyPlugin{name: "A", dep: []string{"B"}},
|
||||
},
|
||||
},
|
||||
want: map[string]*dependency{
|
||||
"D": {
|
||||
plugin: &dummyPlugin{name: "D", idx: 0},
|
||||
invoked: true,
|
||||
dependsOn: nil,
|
||||
},
|
||||
"C": {
|
||||
plugin: &dummyPlugin{name: "C", dep: []string{"D"}, idx: 1},
|
||||
invoked: true,
|
||||
dependsOn: []*dependency{
|
||||
{
|
||||
plugin: &dummyPlugin{name: "D", idx: 0},
|
||||
invoked: true,
|
||||
dependsOn: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
"B": {
|
||||
plugin: &dummyPlugin{name: "B", dep: []string{"C"}, idx: 2},
|
||||
invoked: true,
|
||||
dependsOn: []*dependency{
|
||||
{
|
||||
plugin: &dummyPlugin{name: "C", dep: []string{"D"}, idx: 1},
|
||||
invoked: true,
|
||||
dependsOn: []*dependency{
|
||||
{
|
||||
plugin: &dummyPlugin{name: "D", idx: 0},
|
||||
invoked: true,
|
||||
dependsOn: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"A": {
|
||||
plugin: &dummyPlugin{name: "A", dep: []string{"B"}, idx: 3},
|
||||
invoked: true,
|
||||
dependsOn: []*dependency{
|
||||
{
|
||||
plugin: &dummyPlugin{name: "B", dep: []string{"C"}, idx: 2},
|
||||
invoked: true,
|
||||
dependsOn: []*dependency{
|
||||
{
|
||||
plugin: &dummyPlugin{name: "C", dep: []string{"D"}, idx: 1},
|
||||
invoked: true,
|
||||
dependsOn: []*dependency{
|
||||
{
|
||||
plugin: &dummyPlugin{name: "D", idx: 0},
|
||||
invoked: true,
|
||||
dependsOn: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, {
|
||||
name: "add duplicate plugin",
|
||||
args: args{
|
||||
p: []types.Plugin{
|
||||
&dummyPlugin{name: "first"},
|
||||
&dummyPlugin{name: "first"},
|
||||
},
|
||||
},
|
||||
want: map[string]*dependency{
|
||||
"first": {plugin: &dummyPlugin{name: "first", idx: 0}, invoked: true},
|
||||
},
|
||||
wantErr1: true,
|
||||
}, {
|
||||
name: "cyclical dependency",
|
||||
args: args{
|
||||
p: []types.Plugin{
|
||||
&dummyPlugin{name: "first", dep: []string{"second"}},
|
||||
&dummyPlugin{name: "second", dep: []string{"first"}},
|
||||
},
|
||||
},
|
||||
want: map[string]*dependency{
|
||||
"first": {
|
||||
plugin: &dummyPlugin{name: "first", dep: []string{"second"}, idx: 1},
|
||||
invoked: true,
|
||||
},
|
||||
},
|
||||
wantErr1: true,
|
||||
}, {
|
||||
name: "four plugins - cyclical transitive dependencies in reverse order",
|
||||
args: args{
|
||||
p: []types.Plugin{
|
||||
&dummyPlugin{name: "forth", dep: []string{"third"}},
|
||||
&dummyPlugin{name: "third", dep: []string{"second"}},
|
||||
&dummyPlugin{name: "second", dep: []string{"first"}},
|
||||
&dummyPlugin{name: "first", dep: []string{"forth"}},
|
||||
},
|
||||
},
|
||||
want: map[string]*dependency{
|
||||
"second": {
|
||||
plugin: &dummyPlugin{name: "second", dep: []string{"first"}, idx: 0},
|
||||
invoked: false,
|
||||
dependsOn: []*dependency{
|
||||
{
|
||||
plugin: &dummyPlugin{name: "first", dep: []string{"forth"}, idx: 0},
|
||||
invoked: false,
|
||||
dependsOn: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
"third": {
|
||||
plugin: &dummyPlugin{name: "third", dep: []string{"second"}, idx: 0},
|
||||
invoked: false,
|
||||
dependsOn: []*dependency{
|
||||
{
|
||||
plugin: &dummyPlugin{name: "second", dep: []string{"first"}, idx: 0},
|
||||
invoked: false,
|
||||
dependsOn: []*dependency{
|
||||
{
|
||||
plugin: &dummyPlugin{name: "first", dep: []string{"forth"}, idx: 0},
|
||||
invoked: false,
|
||||
dependsOn: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"forth": {
|
||||
plugin: &dummyPlugin{name: "forth", dep: []string{"third"}, idx: 0},
|
||||
invoked: false,
|
||||
dependsOn: nil,
|
||||
},
|
||||
},
|
||||
wantErr1: true,
|
||||
skipRun: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
d := &dependiencies{deps: make(map[string]*dependency)}
|
||||
|
||||
var (
|
||||
err error
|
||||
counter int
|
||||
)
|
||||
for _, p := range tt.args.p {
|
||||
if !tt.skipRun {
|
||||
p.(*dummyPlugin).counter = &counter
|
||||
}
|
||||
if err = d.addPlugin(p); err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err != nil != tt.wantErr1 {
|
||||
t.Errorf("dependiencies.addPlugin() error = %v, wantErr1 %v", err, tt.wantErr1)
|
||||
return
|
||||
}
|
||||
|
||||
if !tt.skipRun {
|
||||
if err := d.start(types.PluginManagers{}); (err != nil) != tt.wantErr2 {
|
||||
t.Errorf("dependiencies.start() error = %v, wantErr1 %v", err, tt.wantErr2)
|
||||
}
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(d.deps, tt.want) {
|
||||
t.Errorf("deps = %v, want %v", d.deps, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type dummyPlugin struct {
|
||||
name string
|
||||
dep []string
|
||||
idx int
|
||||
counter *int
|
||||
}
|
||||
|
||||
func (d dummyPlugin) Name() string {
|
||||
return d.name
|
||||
}
|
||||
|
||||
func (d dummyPlugin) DependsOn() []string {
|
||||
return d.dep
|
||||
}
|
||||
|
||||
func (d dummyPlugin) Config() types.PluginConfig {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *dummyPlugin) Start(types.PluginManagers) error {
|
||||
if len(d.dep) > 0 {
|
||||
*d.counter++
|
||||
d.idx = *d.counter
|
||||
}
|
||||
d.counter = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d dummyPlugin) Shutdown() error {
|
||||
return nil
|
||||
}
|
63
server/internal/plugins/filetransfer/config.go
Normal file
63
server/internal/plugins/filetransfer/config.go
Normal file
|
@ -0,0 +1,63 @@
|
|||
package filetransfer
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Enabled bool
|
||||
RootDir string
|
||||
RefreshInterval time.Duration
|
||||
}
|
||||
|
||||
func (Config) Init(cmd *cobra.Command) error {
|
||||
cmd.PersistentFlags().Bool("filetransfer.enabled", false, "whether file transfer is enabled")
|
||||
if err := viper.BindPFlag("filetransfer.enabled", cmd.PersistentFlags().Lookup("filetransfer.enabled")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().String("filetransfer.dir", "/home/neko/Downloads", "root directory for file transfer")
|
||||
if err := viper.BindPFlag("filetransfer.dir", cmd.PersistentFlags().Lookup("filetransfer.dir")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().Duration("filetransfer.refresh_interval", 30*time.Second, "interval to refresh file list")
|
||||
if err := viper.BindPFlag("filetransfer.refresh_interval", cmd.PersistentFlags().Lookup("filetransfer.refresh_interval")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// v2 config
|
||||
|
||||
cmd.PersistentFlags().Bool("file_transfer_enabled", false, "enable file transfer feature")
|
||||
if err := viper.BindPFlag("file_transfer_enabled", cmd.PersistentFlags().Lookup("file_transfer_enabled")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().String("file_transfer_path", "", "path to use for file transfer")
|
||||
if err := viper.BindPFlag("file_transfer_path", cmd.PersistentFlags().Lookup("file_transfer_path")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Config) Set() {
|
||||
s.Enabled = viper.GetBool("filetransfer.enabled")
|
||||
rootDir := viper.GetString("filetransfer.dir")
|
||||
s.RootDir = filepath.Clean(rootDir)
|
||||
s.RefreshInterval = viper.GetDuration("filetransfer.refresh_interval")
|
||||
|
||||
// v2 config
|
||||
|
||||
if viper.IsSet("file_transfer_enabled") {
|
||||
s.Enabled = viper.GetBool("file_transfer_enabled")
|
||||
}
|
||||
if viper.IsSet("file_transfer_path") {
|
||||
rootDir = viper.GetString("file_transfer_path")
|
||||
s.RootDir = filepath.Clean(rootDir)
|
||||
}
|
||||
}
|
332
server/internal/plugins/filetransfer/manager.go
Normal file
332
server/internal/plugins/filetransfer/manager.go
Normal file
|
@ -0,0 +1,332 @@
|
|||
package filetransfer
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/demodesk/neko/pkg/auth"
|
||||
"github.com/demodesk/neko/pkg/types"
|
||||
"github.com/demodesk/neko/pkg/utils"
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
const MULTIPART_FORM_MAX_MEMORY = 32 << 20
|
||||
|
||||
func NewManager(
|
||||
sessions types.SessionManager,
|
||||
config *Config,
|
||||
) *Manager {
|
||||
logger := log.With().Str("module", "filetransfer").Logger()
|
||||
|
||||
return &Manager{
|
||||
logger: logger,
|
||||
config: config,
|
||||
sessions: sessions,
|
||||
shutdown: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
logger zerolog.Logger
|
||||
config *Config
|
||||
sessions types.SessionManager
|
||||
shutdown chan struct{}
|
||||
mu sync.RWMutex
|
||||
fileList []Item
|
||||
}
|
||||
|
||||
func (m *Manager) isEnabledForSession(session types.Session) (bool, error) {
|
||||
settings := Settings{
|
||||
Enabled: true, // defaults to true
|
||||
}
|
||||
err := m.sessions.Settings().Plugins.Unmarshal(PluginName, &settings)
|
||||
if err != nil && !errors.Is(err, types.ErrPluginSettingsNotFound) {
|
||||
return false, fmt.Errorf("unable to unmarshal %s plugin settings from global settings: %w", PluginName, err)
|
||||
}
|
||||
|
||||
profile := Settings{
|
||||
Enabled: true, // defaults to true
|
||||
}
|
||||
|
||||
err = session.Profile().Plugins.Unmarshal(PluginName, &profile)
|
||||
if err != nil && !errors.Is(err, types.ErrPluginSettingsNotFound) {
|
||||
return false, fmt.Errorf("unable to unmarshal %s plugin settings from profile: %w", PluginName, err)
|
||||
}
|
||||
|
||||
return m.config.Enabled && (settings.Enabled || session.Profile().IsAdmin) && profile.Enabled, nil
|
||||
}
|
||||
|
||||
func (m *Manager) refresh() (error, bool) {
|
||||
// if file transfer is disabled, return immediately without refreshing
|
||||
if !m.config.Enabled {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
files, err := ListFiles(m.config.RootDir)
|
||||
if err != nil {
|
||||
return err, false
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// check if file list has changed (todo: use hash instead of comparing all fields)
|
||||
changed := false
|
||||
if len(files) == len(m.fileList) {
|
||||
for i, file := range files {
|
||||
if file.Name != m.fileList[i].Name || file.Size != m.fileList[i].Size {
|
||||
changed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
changed = true
|
||||
}
|
||||
|
||||
m.fileList = files
|
||||
return nil, changed
|
||||
}
|
||||
|
||||
func (m *Manager) broadcastUpdate() {
|
||||
m.mu.RLock()
|
||||
fileList := m.fileList
|
||||
m.mu.RUnlock()
|
||||
|
||||
m.sessions.Broadcast(FILETRANSFER_UPDATE, Message{
|
||||
Enabled: m.config.Enabled,
|
||||
RootDir: m.config.RootDir,
|
||||
Files: fileList,
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Manager) sendUpdate(session types.Session) {
|
||||
m.mu.RLock()
|
||||
fileList := m.fileList
|
||||
m.mu.RUnlock()
|
||||
|
||||
session.Send(FILETRANSFER_UPDATE, Message{
|
||||
Enabled: m.config.Enabled,
|
||||
RootDir: m.config.RootDir,
|
||||
Files: fileList,
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Manager) Start() error {
|
||||
// send init message once a user connects
|
||||
m.sessions.OnConnected(func(session types.Session) {
|
||||
m.sendUpdate(session)
|
||||
})
|
||||
|
||||
// if file transfer is disabled, return immediately without starting the watcher
|
||||
if !m.config.Enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := os.Stat(m.config.RootDir); os.IsNotExist(err) {
|
||||
err = os.Mkdir(m.config.RootDir, os.ModePerm)
|
||||
m.logger.Err(err).Msg("creating file transfer directory")
|
||||
}
|
||||
|
||||
watcher, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to start file transfer dir watcher: %w", err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer watcher.Close()
|
||||
|
||||
// periodically refresh file list
|
||||
ticker := time.NewTicker(m.config.RefreshInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-m.shutdown:
|
||||
m.logger.Info().Msg("shutting down file transfer manager")
|
||||
return
|
||||
case <-ticker.C:
|
||||
err, changed := m.refresh()
|
||||
if err != nil {
|
||||
m.logger.Err(err).Msg("unable to refresh file transfer list")
|
||||
}
|
||||
if changed {
|
||||
m.broadcastUpdate()
|
||||
}
|
||||
case e, ok := <-watcher.Events:
|
||||
if !ok {
|
||||
m.logger.Info().Msg("file transfer dir watcher closed")
|
||||
return
|
||||
}
|
||||
|
||||
if e.Has(fsnotify.Create) || e.Has(fsnotify.Remove) || e.Has(fsnotify.Rename) {
|
||||
m.logger.Debug().Str("event", e.String()).Msg("file transfer dir watcher event")
|
||||
|
||||
err, changed := m.refresh()
|
||||
if err != nil {
|
||||
m.logger.Err(err).Msg("unable to refresh file transfer list")
|
||||
}
|
||||
|
||||
if changed {
|
||||
m.broadcastUpdate()
|
||||
}
|
||||
}
|
||||
case err := <-watcher.Errors:
|
||||
m.logger.Err(err).Msg("error in file transfer dir watcher")
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
if err := watcher.Add(m.config.RootDir); err != nil {
|
||||
return fmt.Errorf("unable to watch file transfer dir: %w", err)
|
||||
}
|
||||
|
||||
// initial refresh
|
||||
err, changed := m.refresh()
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to refresh file transfer list: %w", err)
|
||||
}
|
||||
if changed {
|
||||
m.broadcastUpdate()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) Shutdown() error {
|
||||
close(m.shutdown)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) Route(r types.Router) {
|
||||
r.With(auth.AdminsOnly).Get("/", m.downloadFileHandler)
|
||||
r.With(auth.AdminsOnly).Post("/", m.uploadFileHandler)
|
||||
}
|
||||
|
||||
func (m *Manager) WebSocketHandler(session types.Session, msg types.WebSocketMessage) bool {
|
||||
switch msg.Event {
|
||||
case FILETRANSFER_UPDATE:
|
||||
err, changed := m.refresh()
|
||||
if err != nil {
|
||||
m.logger.Err(err).Msg("unable to refresh file transfer list")
|
||||
}
|
||||
|
||||
if changed {
|
||||
// broadcast update message to all clients
|
||||
m.broadcastUpdate()
|
||||
} else {
|
||||
// send update message to this client only
|
||||
m.sendUpdate(session)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// not handled by this plugin
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *Manager) downloadFileHandler(w http.ResponseWriter, r *http.Request) error {
|
||||
session, ok := auth.GetSession(r)
|
||||
if !ok {
|
||||
return utils.HttpUnauthorized("session not found")
|
||||
}
|
||||
|
||||
enabled, err := m.isEnabledForSession(session)
|
||||
if err != nil {
|
||||
return utils.HttpInternalServerError().
|
||||
WithInternalErr(err).
|
||||
Msg("error checking file transfer permissions")
|
||||
}
|
||||
|
||||
if !enabled {
|
||||
return utils.HttpForbidden("file transfer is disabled")
|
||||
}
|
||||
|
||||
filename := r.URL.Query().Get("filename")
|
||||
badChars, err := regexp.MatchString(`(?m)\.\.(?:\/|$)`, filename)
|
||||
if filename == "" || badChars || err != nil {
|
||||
return utils.HttpBadRequest().
|
||||
WithInternalErr(err).
|
||||
Msg("bad filename")
|
||||
}
|
||||
|
||||
// ensure filename is clean and only contains the basename
|
||||
filename = filepath.Clean(filename)
|
||||
filename = filepath.Base(filename)
|
||||
filePath := filepath.Join(m.config.RootDir, filename)
|
||||
|
||||
http.ServeFile(w, r, filePath)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) uploadFileHandler(w http.ResponseWriter, r *http.Request) error {
|
||||
session, ok := auth.GetSession(r)
|
||||
if !ok {
|
||||
return utils.HttpUnauthorized("session not found")
|
||||
}
|
||||
|
||||
enabled, err := m.isEnabledForSession(session)
|
||||
if err != nil {
|
||||
return utils.HttpInternalServerError().
|
||||
WithInternalErr(err).
|
||||
Msg("error checking file transfer permissions")
|
||||
}
|
||||
|
||||
if !enabled {
|
||||
return utils.HttpForbidden("file transfer is disabled")
|
||||
}
|
||||
|
||||
err = r.ParseMultipartForm(MULTIPART_FORM_MAX_MEMORY)
|
||||
if err != nil || r.MultipartForm == nil {
|
||||
return utils.HttpBadRequest().
|
||||
WithInternalErr(err).
|
||||
Msg("error parsing form")
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err = r.MultipartForm.RemoveAll()
|
||||
if err != nil {
|
||||
m.logger.Warn().Err(err).Msg("failed to clean up multipart form")
|
||||
}
|
||||
}()
|
||||
|
||||
for _, formheader := range r.MultipartForm.File["files"] {
|
||||
// ensure filename is clean and only contains the basename
|
||||
filename := filepath.Clean(formheader.Filename)
|
||||
filename = filepath.Base(filename)
|
||||
filePath := filepath.Join(m.config.RootDir, filename)
|
||||
|
||||
formfile, err := formheader.Open()
|
||||
if err != nil {
|
||||
return utils.HttpBadRequest().
|
||||
WithInternalErr(err).
|
||||
Msg("error opening formdata file")
|
||||
}
|
||||
defer formfile.Close()
|
||||
|
||||
f, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE, 0644)
|
||||
if err != nil {
|
||||
return utils.HttpInternalServerError().
|
||||
WithInternalErr(err).
|
||||
Msg("error opening file for writing")
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
_, err = io.Copy(f, formfile)
|
||||
if err != nil {
|
||||
return utils.HttpInternalServerError().
|
||||
WithInternalErr(err).
|
||||
Msg("error writing file")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
35
server/internal/plugins/filetransfer/plugin.go
Normal file
35
server/internal/plugins/filetransfer/plugin.go
Normal file
|
@ -0,0 +1,35 @@
|
|||
package filetransfer
|
||||
|
||||
import (
|
||||
"github.com/demodesk/neko/pkg/types"
|
||||
)
|
||||
|
||||
type Plugin struct {
|
||||
config *Config
|
||||
manager *Manager
|
||||
}
|
||||
|
||||
func NewPlugin() *Plugin {
|
||||
return &Plugin{
|
||||
config: &Config{},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plugin) Name() string {
|
||||
return PluginName
|
||||
}
|
||||
|
||||
func (p *Plugin) Config() types.PluginConfig {
|
||||
return p.config
|
||||
}
|
||||
|
||||
func (p *Plugin) Start(m types.PluginManagers) error {
|
||||
p.manager = NewManager(m.SessionManager, p.config)
|
||||
m.ApiManager.AddRouter("/filetransfer", p.manager.Route)
|
||||
m.WebSocketManager.AddHandler(p.manager.WebSocketHandler)
|
||||
return p.manager.Start()
|
||||
}
|
||||
|
||||
func (p *Plugin) Shutdown() error {
|
||||
return p.manager.Shutdown()
|
||||
}
|
30
server/internal/plugins/filetransfer/types.go
Normal file
30
server/internal/plugins/filetransfer/types.go
Normal file
|
@ -0,0 +1,30 @@
|
|||
package filetransfer
|
||||
|
||||
const PluginName = "filetransfer"
|
||||
|
||||
type Settings struct {
|
||||
Enabled bool `json:"enabled" mapstructure:"enabled"`
|
||||
}
|
||||
|
||||
const (
|
||||
FILETRANSFER_UPDATE = "filetransfer/update"
|
||||
)
|
||||
|
||||
type Message struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
RootDir string `json:"root_dir"`
|
||||
Files []Item `json:"files"`
|
||||
}
|
||||
|
||||
type ItemType string
|
||||
|
||||
const (
|
||||
ItemTypeFile ItemType = "file"
|
||||
ItemTypeDir ItemType = "dir"
|
||||
)
|
||||
|
||||
type Item struct {
|
||||
Name string `json:"name"`
|
||||
Type ItemType `json:"type"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
}
|
32
server/internal/plugins/filetransfer/utils.go
Normal file
32
server/internal/plugins/filetransfer/utils.go
Normal file
|
@ -0,0 +1,32 @@
|
|||
package filetransfer
|
||||
|
||||
import "os"
|
||||
|
||||
func ListFiles(path string) ([]Item, error) {
|
||||
items, err := os.ReadDir(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]Item, len(items))
|
||||
for i, item := range items {
|
||||
var itemType ItemType
|
||||
var size int64 = 0
|
||||
if item.IsDir() {
|
||||
itemType = ItemTypeDir
|
||||
} else {
|
||||
itemType = ItemTypeFile
|
||||
info, err := item.Info()
|
||||
if err == nil {
|
||||
size = info.Size()
|
||||
}
|
||||
}
|
||||
out[i] = Item{
|
||||
Name: item.Name(),
|
||||
Type: itemType,
|
||||
Size: size,
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
183
server/internal/plugins/manager.go
Normal file
183
server/internal/plugins/manager.go
Normal file
|
@ -0,0 +1,183 @@
|
|||
package plugins
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"plugin"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/demodesk/neko/internal/config"
|
||||
"github.com/demodesk/neko/internal/plugins/chat"
|
||||
"github.com/demodesk/neko/internal/plugins/filetransfer"
|
||||
"github.com/demodesk/neko/pkg/types"
|
||||
)
|
||||
|
||||
type ManagerCtx struct {
|
||||
logger zerolog.Logger
|
||||
config *config.Plugins
|
||||
plugins dependiencies
|
||||
}
|
||||
|
||||
func New(config *config.Plugins) *ManagerCtx {
|
||||
manager := &ManagerCtx{
|
||||
logger: log.With().Str("module", "plugins").Logger(),
|
||||
config: config,
|
||||
plugins: dependiencies{
|
||||
deps: make(map[string]*dependency),
|
||||
},
|
||||
}
|
||||
|
||||
manager.plugins.logger = manager.logger
|
||||
|
||||
if config.Enabled {
|
||||
err := manager.loadDir(config.Dir)
|
||||
|
||||
// only log error if plugin is not required
|
||||
if err != nil && config.Required {
|
||||
manager.logger.Fatal().Err(err).Msg("error loading plugins")
|
||||
}
|
||||
|
||||
manager.logger.Info().Msgf("loading finished, total %d plugins", manager.plugins.len())
|
||||
}
|
||||
|
||||
// add built-in plugins
|
||||
manager.plugins.addPlugin(filetransfer.NewPlugin())
|
||||
manager.plugins.addPlugin(chat.NewPlugin())
|
||||
|
||||
return manager
|
||||
}
|
||||
|
||||
func (manager *ManagerCtx) loadDir(dir string) error {
|
||||
return filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
err = manager.load(path)
|
||||
|
||||
// return error if plugin is required
|
||||
if err != nil && manager.config.Required {
|
||||
return err
|
||||
}
|
||||
|
||||
// otherwise only log error if plugin is not required
|
||||
manager.logger.Err(err).Str("plugin", path).Msg("loading a plugin")
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (manager *ManagerCtx) load(path string) error {
|
||||
pl, err := plugin.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sym, err := pl.Lookup("Plugin")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
p, ok := sym.(types.Plugin)
|
||||
if !ok {
|
||||
return fmt.Errorf("not a valid plugin")
|
||||
}
|
||||
|
||||
if err = manager.plugins.addPlugin(p); err != nil {
|
||||
return fmt.Errorf("failed to add plugin: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (manager *ManagerCtx) InitConfigs(cmd *cobra.Command) {
|
||||
_ = manager.plugins.forEach(func(d *dependency) error {
|
||||
if err := d.plugin.Config().Init(cmd); err != nil {
|
||||
log.Err(err).Str("plugin", d.plugin.Name()).Msg("unable to initialize configuration")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (manager *ManagerCtx) SetConfigs() {
|
||||
_ = manager.plugins.forEach(func(d *dependency) error {
|
||||
d.plugin.Config().Set()
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (manager *ManagerCtx) Start(
|
||||
sessionManager types.SessionManager,
|
||||
webSocketManager types.WebSocketManager,
|
||||
apiManager types.ApiManager,
|
||||
) {
|
||||
err := manager.plugins.start(types.PluginManagers{
|
||||
SessionManager: sessionManager,
|
||||
WebSocketManager: webSocketManager,
|
||||
ApiManager: apiManager,
|
||||
LoadServiceFromPlugin: manager.LookupService,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
if manager.config.Required {
|
||||
manager.logger.Fatal().Err(err).Msg("failed to start plugins, exiting...")
|
||||
} else {
|
||||
manager.logger.Err(err).Msg("failed to start plugins, skipping...")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *ManagerCtx) Shutdown() error {
|
||||
_ = manager.plugins.forEach(func(d *dependency) error {
|
||||
err := d.plugin.Shutdown()
|
||||
manager.logger.Err(err).Str("plugin", d.plugin.Name()).Msg("plugin shutdown")
|
||||
return nil
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (manager *ManagerCtx) LookupService(pluginName string) (any, error) {
|
||||
plug, ok := manager.plugins.findPlugin(pluginName)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("plugin '%s' not found", pluginName)
|
||||
}
|
||||
|
||||
expPlug, ok := plug.plugin.(types.ExposablePlugin)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("plugin '%s' is not exposable", pluginName)
|
||||
}
|
||||
|
||||
return expPlug.ExposeService(), nil
|
||||
}
|
||||
|
||||
func (manager *ManagerCtx) Metadata() []types.PluginMetadata {
|
||||
var plugins []types.PluginMetadata
|
||||
|
||||
_ = manager.plugins.forEach(func(d *dependency) error {
|
||||
dependsOn := make([]string, 0)
|
||||
deps, isDependalbe := d.plugin.(types.DependablePlugin)
|
||||
if isDependalbe {
|
||||
dependsOn = deps.DependsOn()
|
||||
}
|
||||
|
||||
_, isExposable := d.plugin.(types.ExposablePlugin)
|
||||
|
||||
plugins = append(plugins, types.PluginMetadata{
|
||||
Name: d.plugin.Name(),
|
||||
IsDependable: isDependalbe,
|
||||
IsExposable: isExposable,
|
||||
DependsOn: dependsOn,
|
||||
})
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return plugins
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue