From e5b24f4c92525ce67bab460528cec646cfe1be01 Mon Sep 17 00:00:00 2001 From: eternal-flame-AD Date: Sun, 3 Feb 2019 23:53:41 +0800 Subject: [PATCH] Add plugin feature Fixed database migration Added a plugin system based on the go plugin package --- Makefile | 9 +- api/application.go | 7 +- api/application_test.go | 20 +- api/client.go | 11 +- api/client_test.go | 5 +- api/message_test.go | 5 +- api/plugin.go | 395 +++++++++++ api/plugin_test.go | 660 ++++++++++++++++++ api/stream/stream.go | 3 +- api/stream/stream_test.go | 12 +- api/user.go | 49 +- api/user_test.go | 70 +- app.go | 7 +- auth/authentication.go | 1 + auth/authentication_test.go | 6 +- auth/token.go | 16 + auth/token_test.go | 16 + config.example.yml | 3 +- config/config.go | 1 + config/config_test.go | 2 + database/database.go | 13 +- database/database_test.go | 42 +- database/migration_test.go | 63 ++ database/plugin.go | 67 ++ database/plugin_test.go | 40 ++ database/user.go | 3 + database/user_test.go | 26 +- docs/spec.json | 490 +++++++++++++ error/handler_test.go | 2 +- go.mod | 22 +- go.sum | 175 ++++- model/application.go | 6 + model/pluginconf.go | 69 ++ model/user.go | 1 + plugin/compat/instance.go | 91 +++ plugin/compat/plugin.go | 33 + plugin/compat/plugin_test.go | 18 + plugin/compat/v1.go | 183 +++++ plugin/compat/v1_test.go | 160 +++++ plugin/compat/wrap.go | 35 + plugin/compat/wrap_test.go | 163 +++++ plugin/compat/wrap_test_norace.go | 5 + plugin/compat/wrap_test_race.go | 5 + plugin/example/clock/main.go | 63 ++ plugin/example/echo/echo.go | 120 ++++ plugin/example/minimal/main.go | 35 + plugin/manager.go | 382 ++++++++++ plugin/manager_test.go | 452 ++++++++++++ plugin/manager_test_norace.go | 5 + plugin/manager_test_race.go | 5 + plugin/messagehandler.go | 36 + plugin/pluginenabled.go | 15 + plugin/pluginenabled_test.go | 45 ++ plugin/storagehandler.go | 16 + plugin/testing/broken/cantinstantiate/main.go | 36 + .../broken/malformedconstructor/main.go | 34 + plugin/testing/broken/noinstance/main.go | 16 + plugin/testing/broken/nothing/main.go | 5 + plugin/testing/broken/unknowninfo/main.go | 10 + plugin/testing/mock/mock.go | 175 +++++ router/router.go | 36 +- router/router_test.go | 21 +- test/asserts.go | 13 + test/asserts_test.go | 6 + test/auth.go | 1 + test/filepath.go | 29 + test/filepath_test.go | 48 ++ test/{ => testdb}/database.go | 57 +- test/{ => testdb}/database_test.go | 34 +- test/tmpdir.go | 28 + test/tmpdir_test.go | 19 + ui/package-lock.json | 410 +++++++++-- ui/package.json | 6 +- ui/src/application/AddApplicationDialog.tsx | 2 +- ui/src/application/AppStore.ts | 7 + ui/src/application/Applications.tsx | 67 +- .../application/UpdateApplicationDialog.tsx | 93 +++ ui/src/index.tsx | 5 +- ui/src/inject.tsx | 2 + ui/src/layout/Header.tsx | 7 + ui/src/layout/Layout.tsx | 4 + ui/src/plugin/PluginDetailView.tsx | 282 ++++++++ ui/src/plugin/PluginStore.ts | 55 ++ ui/src/plugin/Plugins.tsx | 100 +++ ui/src/tests/application.test.ts | 54 +- ui/src/tests/plugin.test.ts | 190 +++++ ui/src/tests/setup.ts | 42 +- ui/src/types.ts | 13 + 88 files changed, 5869 insertions(+), 222 deletions(-) create mode 100644 api/plugin.go create mode 100644 api/plugin_test.go create mode 100644 database/migration_test.go create mode 100644 database/plugin.go create mode 100644 database/plugin_test.go create mode 100644 model/pluginconf.go create mode 100644 plugin/compat/instance.go create mode 100644 plugin/compat/plugin.go create mode 100644 plugin/compat/plugin_test.go create mode 100644 plugin/compat/v1.go create mode 100644 plugin/compat/v1_test.go create mode 100644 plugin/compat/wrap.go create mode 100644 plugin/compat/wrap_test.go create mode 100644 plugin/compat/wrap_test_norace.go create mode 100644 plugin/compat/wrap_test_race.go create mode 100644 plugin/example/clock/main.go create mode 100644 plugin/example/echo/echo.go create mode 100644 plugin/example/minimal/main.go create mode 100644 plugin/manager.go create mode 100644 plugin/manager_test.go create mode 100644 plugin/manager_test_norace.go create mode 100644 plugin/manager_test_race.go create mode 100644 plugin/messagehandler.go create mode 100644 plugin/pluginenabled.go create mode 100644 plugin/pluginenabled_test.go create mode 100644 plugin/storagehandler.go create mode 100644 plugin/testing/broken/cantinstantiate/main.go create mode 100644 plugin/testing/broken/malformedconstructor/main.go create mode 100644 plugin/testing/broken/noinstance/main.go create mode 100644 plugin/testing/broken/nothing/main.go create mode 100644 plugin/testing/broken/unknowninfo/main.go create mode 100644 plugin/testing/mock/mock.go create mode 100644 test/filepath.go create mode 100644 test/filepath_test.go rename test/{ => testdb}/database.go (71%) rename test/{ => testdb}/database_test.go (75%) create mode 100644 test/tmpdir.go create mode 100644 test/tmpdir_test.go create mode 100644 ui/src/application/UpdateApplicationDialog.tsx create mode 100644 ui/src/plugin/PluginDetailView.tsx create mode 100644 ui/src/plugin/PluginStore.ts create mode 100644 ui/src/plugin/Plugins.tsx create mode 100644 ui/src/tests/plugin.test.ts diff --git a/Makefile b/Makefile index a6f10fa7c..e254ed871 100644 --- a/Makefile +++ b/Makefile @@ -14,14 +14,7 @@ test-race: go test -v -race ./... test-coverage: - echo "" > coverage.txt - for d in $(shell go list ./... | grep -v vendor); do \ - go test -v -coverprofile=profile.out -covermode=atomic $$d ; \ - if [ -f profile.out ]; then \ - cat profile.out >> coverage.txt ; \ - rm profile.out ; \ - fi \ - done + go test -v -coverprofile=coverage.txt -covermode=atomic ./... format: goimports -w $(shell find . -type f -name '*.go' -not -path "./vendor/*") diff --git a/api/application.go b/api/application.go index df5f18761..fb23a7dac 100644 --- a/api/application.go +++ b/api/application.go @@ -66,8 +66,9 @@ type ApplicationAPI struct { func (a *ApplicationAPI) CreateApplication(ctx *gin.Context) { app := model.Application{} if err := ctx.Bind(&app); err == nil { - app.Token = generateNotExistingToken(auth.GenerateApplicationToken, a.applicationExists) + app.Token = auth.GenerateNotExistingToken(auth.GenerateApplicationToken, a.applicationExists) app.UserID = auth.GetUserID(ctx) + app.Internal = false a.DB.CreateApplication(&app) ctx.JSON(200, withAbsoluteURL(ctx, &app)) } @@ -143,6 +144,10 @@ func (a *ApplicationAPI) GetApplications(ctx *gin.Context) { func (a *ApplicationAPI) DeleteApplication(ctx *gin.Context) { withID(ctx, "id", func(id uint) { if app := a.DB.GetApplicationByID(id); app != nil && app.UserID == auth.GetUserID(ctx) { + if app.Internal { + ctx.AbortWithError(400, errors.New("cannot delete internal application")) + return + } a.DB.DeleteApplicationByID(id) if app.Image != "" { os.Remove(a.ImageDir + app.Image) diff --git a/api/application_test.go b/api/application_test.go index 225049306..9341ad1d9 100644 --- a/api/application_test.go +++ b/api/application_test.go @@ -16,6 +16,7 @@ import ( "github.com/gotify/server/mode" "github.com/gotify/server/model" "github.com/gotify/server/test" + "github.com/gotify/server/test/testdb" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" ) @@ -31,7 +32,7 @@ func TestApplicationSuite(t *testing.T) { type ApplicationSuite struct { suite.Suite - db *test.Database + db *testdb.Database a *ApplicationAPI ctx *gin.Context recorder *httptest.ResponseRecorder @@ -41,7 +42,7 @@ func (s *ApplicationSuite) BeforeTest(suiteName, testName string) { mode.Set(mode.TestDev) rand.Seed(50) s.recorder = httptest.NewRecorder() - s.db = test.NewDB(s.T()) + s.db = testdb.NewDB(s.T()) s.ctx, _ = gin.CreateTestContext(s.recorder) withURL(s.ctx, "http", "example.com") s.a = &ApplicationAPI{DB: s.db} @@ -76,8 +77,9 @@ func (s *ApplicationSuite) Test_ensureApplicationHasCorrectJsonRepresentation() Name: "myapp", Description: "mydesc", Image: "asd", + Internal: true, } - test.JSONEquals(s.T(), actual, `{"id":1,"token":"Aasdasfgeeg","name":"myapp","description":"mydesc", "image": "asd"}`) + test.JSONEquals(s.T(), actual, `{"id":1,"token":"Aasdasfgeeg","name":"myapp","description":"mydesc", "image": "asd", "internal":true}`) } func (s *ApplicationSuite) Test_CreateApplication_expectBadRequestOnEmptyName() { s.db.User(5) @@ -183,6 +185,18 @@ func (s *ApplicationSuite) Test_GetApplications_WithImage() { test.BodyEquals(s.T(), []*model.Application{first, second}, s.recorder) } +func (s *ApplicationSuite) Test_DeleteApplication_internal_expectBadRequest() { + s.db.User(5).InternalApp(10) + + test.WithUser(s.ctx, 5) + s.ctx.Request = httptest.NewRequest("DELETE", "/token/"+firstApplicationToken, nil) + s.ctx.Params = gin.Params{{Key: "id", Value: "10"}} + + s.a.DeleteApplication(s.ctx) + + assert.Equal(s.T(), 400, s.recorder.Code) +} + func (s *ApplicationSuite) Test_DeleteApplication_expectNotFound() { s.db.User(5) diff --git a/api/client.go b/api/client.go index 428f47a16..3f6fb7c52 100644 --- a/api/client.go +++ b/api/client.go @@ -60,7 +60,7 @@ type ClientAPI struct { func (a *ClientAPI) CreateClient(ctx *gin.Context) { client := model.Client{} if err := ctx.Bind(&client); err == nil { - client.Token = generateNotExistingToken(auth.GenerateClientToken, a.clientExists) + client.Token = auth.GenerateNotExistingToken(auth.GenerateClientToken, a.clientExists) client.UserID = auth.GetUserID(ctx) a.DB.CreateClient(&client) ctx.JSON(200, client) @@ -145,12 +145,3 @@ func (a *ClientAPI) DeleteClient(ctx *gin.Context) { func (a *ClientAPI) clientExists(token string) bool { return a.DB.GetClientByToken(token) != nil } - -func generateNotExistingToken(generateToken func() string, tokenExists func(token string) bool) string { - for { - token := generateToken() - if !tokenExists(token) { - return token - } - } -} diff --git a/api/client_test.go b/api/client_test.go index b9d743870..9640121c4 100644 --- a/api/client_test.go +++ b/api/client_test.go @@ -11,6 +11,7 @@ import ( "github.com/gotify/server/mode" "github.com/gotify/server/model" "github.com/gotify/server/test" + "github.com/gotify/server/test/testdb" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" ) @@ -26,7 +27,7 @@ func TestClientSuite(t *testing.T) { type ClientSuite struct { suite.Suite - db *test.Database + db *testdb.Database a *ClientAPI ctx *gin.Context recorder *httptest.ResponseRecorder @@ -37,7 +38,7 @@ func (s *ClientSuite) BeforeTest(suiteName, testName string) { mode.Set(mode.TestDev) rand.Seed(50) s.recorder = httptest.NewRecorder() - s.db = test.NewDB(s.T()) + s.db = testdb.NewDB(s.T()) s.ctx, _ = gin.CreateTestContext(s.recorder) withURL(s.ctx, "http", "example.com") s.notified = false diff --git a/api/message_test.go b/api/message_test.go index 70b7c09c0..e1de091a1 100644 --- a/api/message_test.go +++ b/api/message_test.go @@ -12,6 +12,7 @@ import ( "github.com/gotify/server/mode" "github.com/gotify/server/model" "github.com/gotify/server/test" + "github.com/gotify/server/test/testdb" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" ) @@ -22,7 +23,7 @@ func TestMessageSuite(t *testing.T) { type MessageSuite struct { suite.Suite - db *test.Database + db *testdb.Database a *MessageAPI ctx *gin.Context recorder *httptest.ResponseRecorder @@ -34,7 +35,7 @@ func (s *MessageSuite) BeforeTest(suiteName, testName string) { s.recorder = httptest.NewRecorder() s.ctx, _ = gin.CreateTestContext(s.recorder) s.ctx.Request = httptest.NewRequest("GET", "/irrelevant", nil) - s.db = test.NewDB(s.T()) + s.db = testdb.NewDB(s.T()) s.notifiedMessage = nil s.a = &MessageAPI{DB: s.db, Notifier: s} } diff --git a/api/plugin.go b/api/plugin.go new file mode 100644 index 000000000..758ad5a34 --- /dev/null +++ b/api/plugin.go @@ -0,0 +1,395 @@ +package api + +import ( + "errors" + "fmt" + "io/ioutil" + + "github.com/gotify/location" + + "github.com/gin-gonic/gin" + "github.com/go-yaml/yaml" + "github.com/gotify/server/auth" + "github.com/gotify/server/model" + "github.com/gotify/server/plugin" + "github.com/gotify/server/plugin/compat" +) + +// The PluginDatabase interface for encapsulating database access. +type PluginDatabase interface { + GetPluginConfByUser(userid uint) []*model.PluginConf + UpdatePluginConf(p *model.PluginConf) error + GetPluginConfByID(id uint) *model.PluginConf +} + +// The PluginAPI provides handlers for managing plugins. +type PluginAPI struct { + Notifier Notifier + Manager *plugin.Manager + DB PluginDatabase +} + +// GetPlugins returns all plugins a user has. +// swagger:operation GET /plugin plugin getPlugins +// +// Return all plugins. +// +// --- +// consumes: [application/json] +// produces: [application/json] +// security: [clientTokenHeader: [], clientTokenQuery: [], basicAuth: []] +// responses: +// 200: +// description: Ok +// schema: +// type: array +// items: +// $ref: "#/definitions/PluginConf" +// 401: +// description: Unauthorized +// schema: +// $ref: "#/definitions/Error" +// 403: +// description: Forbidden +// schema: +// $ref: "#/definitions/Error" +// 404: +// description: Not Found +// schema: +// $ref: "#/definitions/Error" +// 500: +// description: Internal Server Error +// schema: +// $ref: "#/definitions/Error" +func (c *PluginAPI) GetPlugins(ctx *gin.Context) { + userID := auth.GetUserID(ctx) + plugins := c.DB.GetPluginConfByUser(userID) + result := make([]model.PluginConfExternal, 0) + for _, conf := range plugins { + if inst, err := c.Manager.Instance(conf.ID); err == nil { + info := c.Manager.PluginInfo(conf.ModulePath) + result = append(result, model.PluginConfExternal{ + ID: conf.ID, + Name: info.String(), + Token: conf.Token, + ModulePath: conf.ModulePath, + Author: info.Author, + Website: info.Website, + License: info.License, + Enabled: conf.Enabled, + Capabilities: inst.Supports().Strings(), + }) + } + } + ctx.JSON(200, result) +} + +// EnablePlugin enables a plugin. +// swagger:operation POST /plugin/:id/enable plugin enablePlugin +// +// Enable a plugin. +// +// --- +// consumes: [application/json] +// produces: [application/json] +// parameters: +// - name: id +// in: path +// description: the plugin id +// required: true +// type: integer +// security: [clientTokenHeader: [], clientTokenQuery: [], basicAuth: []] +// responses: +// 200: +// description: Ok +// 401: +// description: Unauthorized +// schema: +// $ref: "#/definitions/Error" +// 403: +// description: Forbidden +// schema: +// $ref: "#/definitions/Error" +// 404: +// description: Not Found +// schema: +// $ref: "#/definitions/Error" +// 500: +// description: Internal Server Error +// schema: +// $ref: "#/definitions/Error" +func (c *PluginAPI) EnablePlugin(ctx *gin.Context) { + withID(ctx, "id", func(id uint) { + conf := c.DB.GetPluginConfByID(id) + if conf == nil || !isPluginOwner(ctx, conf) { + ctx.AbortWithError(404, errors.New("unknown plugin")) + return + } + _, err := c.Manager.Instance(id) + if err != nil { + ctx.AbortWithError(404, errors.New("plugin instance not found")) + return + } + if err := c.Manager.SetPluginEnabled(id, true); err == plugin.ErrAlreadyEnabledOrDisabled { + ctx.AbortWithError(400, err) + } else if err != nil { + ctx.AbortWithError(500, err) + } + }) +} + +// DisablePlugin disables a plugin. +// swagger:operation POST /plugin/:id/disable plugin disablePlugin +// +// Disable a plugin. +// +// --- +// consumes: [application/json] +// produces: [application/json] +// parameters: +// - name: id +// in: path +// description: the plugin id +// required: true +// type: integer +// security: [clientTokenHeader: [], clientTokenQuery: [], basicAuth: []] +// responses: +// 200: +// description: Ok +// 401: +// description: Unauthorized +// schema: +// $ref: "#/definitions/Error" +// 403: +// description: Forbidden +// schema: +// $ref: "#/definitions/Error" +// 404: +// description: Not Found +// schema: +// $ref: "#/definitions/Error" +// 500: +// description: Internal Server Error +// schema: +// $ref: "#/definitions/Error" +func (c *PluginAPI) DisablePlugin(ctx *gin.Context) { + withID(ctx, "id", func(id uint) { + conf := c.DB.GetPluginConfByID(id) + if conf == nil || !isPluginOwner(ctx, conf) { + ctx.AbortWithError(404, errors.New("unknown plugin")) + return + } + _, err := c.Manager.Instance(id) + if err != nil { + ctx.AbortWithError(404, errors.New("plugin instance not found")) + return + } + if err := c.Manager.SetPluginEnabled(id, false); err == plugin.ErrAlreadyEnabledOrDisabled { + ctx.AbortWithError(400, err) + } else if err != nil { + ctx.AbortWithError(500, err) + } + }) +} + +// GetDisplay get display info for Displayer plugin. +// swagger:operation GET /plugin/:id/display plugin getPluginDisplay +// +// Get display info for a Displayer plugin. +// +// --- +// consumes: [application/json] +// produces: [application/json] +// parameters: +// - name: id +// in: path +// description: the plugin id +// required: true +// type: integer +// security: [clientTokenHeader: [], clientTokenQuery: [], basicAuth: []] +// responses: +// 200: +// description: Ok +// schema: +// type: string +// 401: +// description: Unauthorized +// schema: +// $ref: "#/definitions/Error" +// 403: +// description: Forbidden +// schema: +// $ref: "#/definitions/Error" +// 404: +// description: Not Found +// schema: +// $ref: "#/definitions/Error" +// 500: +// description: Internal Server Error +// schema: +// $ref: "#/definitions/Error" +func (c *PluginAPI) GetDisplay(ctx *gin.Context) { + withID(ctx, "id", func(id uint) { + conf := c.DB.GetPluginConfByID(id) + if conf == nil || !isPluginOwner(ctx, conf) { + ctx.AbortWithError(404, errors.New("unknown plugin")) + return + } + instance, err := c.Manager.Instance(id) + if err != nil { + ctx.AbortWithError(404, errors.New("plugin instance not found")) + return + } + ctx.JSON(200, instance.GetDisplay(location.Get(ctx))) + }) +} + +// GetConfig returns Configurer plugin configuration in YAML format. +// swagger:operation GET /plugin/:id/config plugin getPluginConfig +// +// Get YAML configuration for Configurer plugin. +// +// --- +// consumes: [application/json] +// produces: [application/x-yaml] +// parameters: +// - name: id +// in: path +// description: the plugin id +// required: true +// type: integer +// security: [clientTokenHeader: [], clientTokenQuery: [], basicAuth: []] +// responses: +// 200: +// description: Ok +// schema: +// type: object +// description: plugin configuration +// 400: +// description: Bad Request +// schema: +// $ref: "#/definitions/Error" +// 401: +// description: Unauthorized +// schema: +// $ref: "#/definitions/Error" +// 403: +// description: Forbidden +// schema: +// $ref: "#/definitions/Error" +// 404: +// description: Not Found +// schema: +// $ref: "#/definitions/Error" +// 500: +// description: Internal Server Error +// schema: +// $ref: "#/definitions/Error" +func (c *PluginAPI) GetConfig(ctx *gin.Context) { + withID(ctx, "id", func(id uint) { + conf := c.DB.GetPluginConfByID(id) + if conf == nil || !isPluginOwner(ctx, conf) { + ctx.AbortWithError(404, errors.New("unknown plugin")) + return + } + instance, err := c.Manager.Instance(id) + if err != nil { + ctx.AbortWithError(404, errors.New("plugin instance not found")) + return + } + + if aborted := supportOrAbort(ctx, instance, compat.Configurer); aborted { + return + } + + ctx.Header("content-type", "application/x-yaml") + ctx.Writer.Write(conf.Config) + }) + +} + +// UpdateConfig updates Configurer plugin configuration in YAML format. +// swagger:operation POST /plugin/:id/config plugin updatePluginConfig +// +// Update YAML configuration for Configurer plugin. +// +// --- +// consumes: [application/x-yaml] +// produces: [application/json] +// parameters: +// - name: id +// in: path +// description: the plugin id +// required: true +// type: integer +// security: [clientTokenHeader: [], clientTokenQuery: [], basicAuth: []] +// responses: +// 200: +// description: Ok +// 400: +// description: Bad Request +// schema: +// $ref: "#/definitions/Error" +// 401: +// description: Unauthorized +// schema: +// $ref: "#/definitions/Error" +// 403: +// description: Forbidden +// schema: +// $ref: "#/definitions/Error" +// 404: +// description: Not Found +// schema: +// $ref: "#/definitions/Error" +// 500: +// description: Internal Server Error +// schema: +// $ref: "#/definitions/Error" +func (c *PluginAPI) UpdateConfig(ctx *gin.Context) { + withID(ctx, "id", func(id uint) { + conf := c.DB.GetPluginConfByID(id) + if conf == nil || !isPluginOwner(ctx, conf) { + ctx.AbortWithError(404, errors.New("unknown plugin")) + return + } + instance, err := c.Manager.Instance(id) + if err != nil { + ctx.AbortWithError(404, errors.New("plugin instance not found")) + return + } + + if aborted := supportOrAbort(ctx, instance, compat.Configurer); aborted { + return + } + + newConf := instance.DefaultConfig() + newconfBytes, err := ioutil.ReadAll(ctx.Request.Body) + if err != nil { + ctx.AbortWithError(500, err) + return + } + if err := yaml.Unmarshal(newconfBytes, newConf); err != nil { + ctx.AbortWithError(400, err) + return + } + if err := instance.ValidateAndSetConfig(newConf); err != nil { + ctx.AbortWithError(400, err) + return + } + conf.Config = newconfBytes + c.DB.UpdatePluginConf(conf) + }) +} + +func isPluginOwner(ctx *gin.Context, conf *model.PluginConf) bool { + return conf.UserID == auth.GetUserID(ctx) +} + +func supportOrAbort(ctx *gin.Context, instance compat.PluginInstance, module compat.Capability) (aborted bool) { + if compat.HasSupport(instance, module) { + return false + } + ctx.AbortWithError(400, fmt.Errorf("plugin does not support %s", module)) + return true +} diff --git a/api/plugin_test.go b/api/plugin_test.go new file mode 100644 index 000000000..36eff0a96 --- /dev/null +++ b/api/plugin_test.go @@ -0,0 +1,660 @@ +package api + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "math/rand" + "net/http/httptest" + "testing" + + "github.com/go-yaml/yaml" + + "github.com/gotify/server/mode" + "github.com/gotify/server/model" + "github.com/gotify/server/plugin" + "github.com/gotify/server/plugin/compat" + "github.com/gotify/server/plugin/testing/mock" + "github.com/gotify/server/test" + "github.com/gotify/server/test/testdb" + + "github.com/gin-gonic/gin" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" +) + +func TestPluginSuite(t *testing.T) { + suite.Run(t, new(PluginSuite)) +} + +type PluginSuite struct { + suite.Suite + db *testdb.Database + a *PluginAPI + ctx *gin.Context + recorder *httptest.ResponseRecorder + manager *plugin.Manager + notified bool +} + +func (s *PluginSuite) BeforeTest(suiteName, testName string) { + mode.Set(mode.TestDev) + rand.Seed(50) + s.db = testdb.NewDB(s.T()) + s.resetRecorder() + manager, err := plugin.NewManager(s.db, "", nil, s) + assert.Nil(s.T(), err) + s.manager = manager + withURL(s.ctx, "http", "example.com") + s.a = &PluginAPI{DB: s.db, Manager: manager, Notifier: s} + + mockPluginCompat := new(mock.Plugin) + assert.Nil(s.T(), s.manager.LoadPlugin(mockPluginCompat)) + + s.db.User(1) + assert.Nil(s.T(), s.manager.InitializeForUserID(1)) + s.db.User(2) + assert.Nil(s.T(), s.manager.InitializeForUserID(2)) + + s.db.CreatePluginConf(&model.PluginConf{ + UserID: 1, + ModulePath: "github.com/gotify/server/plugin/example/removed", + Token: "P1234", + Enabled: false, + }) +} + +func (s *PluginSuite) getDanglingConf(uid uint) *model.PluginConf { + return s.db.GetPluginConfByUserAndPath(uid, "github.com/gotify/server/plugin/example/removed") +} + +func (s *PluginSuite) resetRecorder() { + s.recorder = httptest.NewRecorder() + s.ctx, _ = gin.CreateTestContext(s.recorder) +} + +func (s *PluginSuite) AfterTest(suiteName, testName string) { + s.db.Close() +} + +func (s *PluginSuite) Notify(userID uint, msg *model.MessageExternal) { + s.notified = true +} + +func (s *PluginSuite) Test_GetPlugins() { + test.WithUser(s.ctx, 1) + + s.ctx.Request = httptest.NewRequest("GET", "/plugin", nil) + s.a.GetPlugins(s.ctx) + + assert.Equal(s.T(), 200, s.recorder.Code) + + pluginConfs := make([]model.PluginConfExternal, 0) + assert.Nil(s.T(), json.Unmarshal(s.recorder.Body.Bytes(), &pluginConfs)) + + assert.Equal(s.T(), mock.Name, pluginConfs[0].Name) + assert.Equal(s.T(), mock.ModulePath, pluginConfs[0].ModulePath) + + assert.False(s.T(), pluginConfs[0].Enabled, "Plugins should be disabled by default") +} + +func (s *PluginSuite) Test_EnableDisablePlugin() { + + { + test.WithUser(s.ctx, 1) + + s.ctx.Request = httptest.NewRequest("POST", "/plugin/1/enable", nil) + s.ctx.Params = gin.Params{{Key: "id", Value: "1"}} + s.a.EnablePlugin(s.ctx) + + assert.Equal(s.T(), 200, s.recorder.Code) + + assert.True(s.T(), s.db.GetPluginConfByUserAndPath(1, mock.ModulePath).Enabled) + s.resetRecorder() + } + + { + test.WithUser(s.ctx, 1) + + s.ctx.Request = httptest.NewRequest("POST", "/plugin/1/enable", nil) + s.ctx.Params = gin.Params{{Key: "id", Value: "1"}} + s.a.EnablePlugin(s.ctx) + + assert.Equal(s.T(), 400, s.recorder.Code) + + assert.True(s.T(), s.db.GetPluginConfByUserAndPath(1, mock.ModulePath).Enabled) + s.resetRecorder() + } + + { + test.WithUser(s.ctx, 1) + + s.ctx.Request = httptest.NewRequest("POST", "/plugin/1/disable", nil) + s.ctx.Params = gin.Params{{Key: "id", Value: "1"}} + s.a.DisablePlugin(s.ctx) + + assert.Equal(s.T(), 200, s.recorder.Code) + + assert.False(s.T(), s.db.GetPluginConfByUserAndPath(1, mock.ModulePath).Enabled) + s.resetRecorder() + } + + { + test.WithUser(s.ctx, 1) + + s.ctx.Request = httptest.NewRequest("POST", "/plugin/1/disable", nil) + s.ctx.Params = gin.Params{{Key: "id", Value: "1"}} + s.a.DisablePlugin(s.ctx) + + assert.Equal(s.T(), 400, s.recorder.Code) + + assert.False(s.T(), s.db.GetPluginConfByUserAndPath(1, mock.ModulePath).Enabled) + s.resetRecorder() + } + +} + +func (s *PluginSuite) Test_EnableDisablePlugin_EnableReturnsError_expect500() { + s.db.User(16) + assert.Nil(s.T(), s.manager.InitializeForUserID(16)) + mock.ReturnErrorOnEnableForUser(16, errors.New("test error")) + conf := s.db.GetPluginConfByUserAndPath(16, mock.ModulePath) + + { + test.WithUser(s.ctx, 16) + s.ctx.Request = httptest.NewRequest("POST", fmt.Sprintf("/plugin/%d/enable", conf.ID), nil) + s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}} + s.a.EnablePlugin(s.ctx) + + assert.Equal(s.T(), 500, s.recorder.Code) + + assert.False(s.T(), s.db.GetPluginConfByUserAndPath(1, mock.ModulePath).Enabled) + s.resetRecorder() + } +} + +func (s *PluginSuite) Test_EnableDisablePlugin_DisableReturnsError_expect500() { + s.db.User(17) + assert.Nil(s.T(), s.manager.InitializeForUserID(17)) + mock.ReturnErrorOnDisableForUser(17, errors.New("test error")) + conf := s.db.GetPluginConfByUserAndPath(17, mock.ModulePath) + s.manager.SetPluginEnabled(conf.ID, true) + + { + test.WithUser(s.ctx, 17) + s.ctx.Request = httptest.NewRequest("POST", fmt.Sprintf("/plugin/%d/disable", conf.ID), nil) + s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}} + s.a.DisablePlugin(s.ctx) + + assert.Equal(s.T(), 500, s.recorder.Code) + + assert.False(s.T(), s.db.GetPluginConfByUserAndPath(1, mock.ModulePath).Enabled) + s.resetRecorder() + } +} + +func (s *PluginSuite) Test_EnableDisablePlugin_incorrectUser_expectNotFound() { + { + test.WithUser(s.ctx, 2) + + s.ctx.Request = httptest.NewRequest("POST", "/plugin/1/enable", nil) + s.ctx.Params = gin.Params{{Key: "id", Value: "1"}} + s.a.EnablePlugin(s.ctx) + + assert.Equal(s.T(), 404, s.recorder.Code) + + assert.False(s.T(), s.db.GetPluginConfByUserAndPath(1, mock.ModulePath).Enabled) + s.resetRecorder() + } + + { + test.WithUser(s.ctx, 2) + + s.ctx.Request = httptest.NewRequest("POST", "/plugin/1/disable", nil) + s.ctx.Params = gin.Params{{Key: "id", Value: "1"}} + s.a.DisablePlugin(s.ctx) + + assert.Equal(s.T(), 404, s.recorder.Code) + + assert.False(s.T(), s.db.GetPluginConfByUserAndPath(1, mock.ModulePath).Enabled) + s.resetRecorder() + } + +} + +func (s *PluginSuite) Test_EnableDisablePlugin_nonExistPlugin_expectNotFound() { + { + test.WithUser(s.ctx, 1) + + s.ctx.Request = httptest.NewRequest("POST", "/plugin/99/enable", nil) + s.ctx.Params = gin.Params{{Key: "id", Value: "99"}} + s.a.EnablePlugin(s.ctx) + + assert.Equal(s.T(), 404, s.recorder.Code) + s.resetRecorder() + } + + { + test.WithUser(s.ctx, 1) + + s.ctx.Request = httptest.NewRequest("POST", "/plugin/99/disable", nil) + s.ctx.Params = gin.Params{{Key: "id", Value: "99"}} + s.a.DisablePlugin(s.ctx) + + assert.Equal(s.T(), 404, s.recorder.Code) + s.resetRecorder() + } + +} + +func (s *PluginSuite) Test_EnableDisablePlugin_danglingConf_expectNotFound() { + conf := s.getDanglingConf(1) + + { + test.WithUser(s.ctx, 1) + + s.ctx.Request = httptest.NewRequest("POST", fmt.Sprintf("/plugin/%d/enable", conf.ID), nil) + s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}} + s.a.EnablePlugin(s.ctx) + + assert.Equal(s.T(), 404, s.recorder.Code) + s.resetRecorder() + } + + { + test.WithUser(s.ctx, 1) + + s.ctx.Request = httptest.NewRequest("POST", fmt.Sprintf("/plugin/%d/disable", conf.ID), nil) + s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}} + s.a.DisablePlugin(s.ctx) + + assert.Equal(s.T(), 404, s.recorder.Code) + s.resetRecorder() + } +} + +func (s *PluginSuite) Test_GetDisplay() { + conf := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath) + inst, err := s.manager.Instance(conf.ID) + assert.Nil(s.T(), err) + mockInst := inst.(*mock.PluginInstance) + + mockInst.DisplayString = "test string" + + { + test.WithUser(s.ctx, 1) + + s.ctx.Request = httptest.NewRequest("GET", fmt.Sprintf("/plugin/%d/display", conf.ID), nil) + s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}} + s.a.GetDisplay(s.ctx) + + assert.Equal(s.T(), 200, s.recorder.Code) + test.JSONEquals(s.T(), mockInst.DisplayString, s.recorder.Body.String()) + } +} + +func (s *PluginSuite) Test_GetDisplay_NotImplemented_expectEmptyString() { + conf := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath) + inst, err := s.manager.Instance(conf.ID) + assert.Nil(s.T(), err) + mockInst := inst.(*mock.PluginInstance) + + mockInst.SetCapability(compat.Displayer, false) + defer mockInst.SetCapability(compat.Displayer, true) + + { + test.WithUser(s.ctx, 1) + + s.ctx.Request = httptest.NewRequest("GET", fmt.Sprintf("/plugin/%d/display", conf.ID), nil) + s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}} + s.a.GetDisplay(s.ctx) + + assert.Equal(s.T(), 200, s.recorder.Code) + test.JSONEquals(s.T(), "", s.recorder.Body.String()) + } +} + +func (s *PluginSuite) Test_GetDisplay_incorrectUser_expectNotFound() { + conf := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath) + inst, err := s.manager.Instance(conf.ID) + assert.Nil(s.T(), err) + mockInst := inst.(*mock.PluginInstance) + + mockInst.DisplayString = "test string" + + { + test.WithUser(s.ctx, 2) + + s.ctx.Request = httptest.NewRequest("GET", fmt.Sprintf("/plugin/%d/display", conf.ID), nil) + s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}} + s.a.GetDisplay(s.ctx) + + assert.Equal(s.T(), 404, s.recorder.Code) + } +} + +func (s *PluginSuite) Test_GetDisplay_danglingConf_expectNotFound() { + conf := s.getDanglingConf(1) + + { + test.WithUser(s.ctx, 1) + + s.ctx.Request = httptest.NewRequest("GET", fmt.Sprintf("/plugin/%d/display", conf.ID), nil) + s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}} + s.a.GetDisplay(s.ctx) + + assert.Equal(s.T(), 404, s.recorder.Code) + } +} + +func (s *PluginSuite) Test_GetDisplay_nonExistPlugin_expectNotFound() { + { + test.WithUser(s.ctx, 1) + + s.ctx.Request = httptest.NewRequest("GET", "/plugin/99/display", nil) + s.ctx.Params = gin.Params{{Key: "id", Value: "99"}} + s.a.GetDisplay(s.ctx) + + assert.Equal(s.T(), 404, s.recorder.Code) + } +} + +func (s *PluginSuite) Test_GetConfig() { + conf := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath) + inst, err := s.manager.Instance(conf.ID) + assert.Nil(s.T(), err) + mockInst := inst.(*mock.PluginInstance) + + assert.Equal(s.T(), mockInst.DefaultConfig(), mockInst.Config, "Initial config should be default config") + { + test.WithUser(s.ctx, 1) + + s.ctx.Request = httptest.NewRequest("GET", fmt.Sprintf("/plugin/%d/config", conf.ID), nil) + s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}} + s.a.GetConfig(s.ctx) + + assert.Equal(s.T(), 200, s.recorder.Code) + returnedConfig := new(mock.PluginConfig) + assert.Nil(s.T(), yaml.Unmarshal(s.recorder.Body.Bytes(), returnedConfig)) + assert.Equal(s.T(), mockInst.Config, returnedConfig) + } +} + +func (s *PluginSuite) Test_GetConfg_notImplemeted_expect400() { + conf := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath) + inst, err := s.manager.Instance(conf.ID) + assert.Nil(s.T(), err) + mockInst := inst.(*mock.PluginInstance) + + mockInst.SetCapability(compat.Configurer, false) + defer mockInst.SetCapability(compat.Configurer, true) + + { + test.WithUser(s.ctx, 1) + + s.ctx.Request = httptest.NewRequest("GET", fmt.Sprintf("/plugin/%d/config", conf.ID), nil) + s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}} + s.a.GetConfig(s.ctx) + + assert.Equal(s.T(), 400, s.recorder.Code) + } +} + +func (s *PluginSuite) Test_GetConfig_incorrectUser_expectNotFound() { + conf := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath) + + { + test.WithUser(s.ctx, 2) + + s.ctx.Request = httptest.NewRequest("GET", fmt.Sprintf("/plugin/%d/config", conf.ID), nil) + s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}} + s.a.GetConfig(s.ctx) + + assert.Equal(s.T(), 404, s.recorder.Code) + } +} + +func (s *PluginSuite) Test_GetConfig_danglingConf_expectNotFound() { + conf := s.getDanglingConf(1) + + { + test.WithUser(s.ctx, 1) + + s.ctx.Request = httptest.NewRequest("GET", fmt.Sprintf("/plugin/%d/config", conf.ID), nil) + s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}} + s.a.GetConfig(s.ctx) + + assert.Equal(s.T(), 404, s.recorder.Code) + } +} + +func (s *PluginSuite) Test_GetConfig_nonExistPlugin_expectNotFound() { + { + test.WithUser(s.ctx, 1) + + s.ctx.Request = httptest.NewRequest("GET", "/plugin/99/config", nil) + s.ctx.Params = gin.Params{{Key: "id", Value: "99"}} + s.a.GetConfig(s.ctx) + + assert.Equal(s.T(), 404, s.recorder.Code) + } +} + +func (s *PluginSuite) Test_UpdateConfig() { + conf := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath) + inst, err := s.manager.Instance(conf.ID) + assert.Nil(s.T(), err) + mockInst := inst.(*mock.PluginInstance) + + newConfig := &mock.PluginConfig{ + TestKey: "test__new__config", + } + newConfigYAML, err := yaml.Marshal(newConfig) + assert.Nil(s.T(), err) + + { + test.WithUser(s.ctx, 1) + + s.ctx.Request = httptest.NewRequest("POST", fmt.Sprintf("/plugin/%d/config", conf.ID), bytes.NewReader(newConfigYAML)) + s.ctx.Header("Content-Type", "application/x-yaml") + s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}} + s.a.UpdateConfig(s.ctx) + + assert.Equal(s.T(), 200, s.recorder.Code) + assert.Equal(s.T(), newConfig, mockInst.Config, "config should be received by plugin") + + pluginFromDBBytes := s.db.GetPluginConfByID(conf.ID).Config + pluginFromDB := new(mock.PluginConfig) + err := yaml.Unmarshal(pluginFromDBBytes, pluginFromDB) + assert.Nil(s.T(), err) + assert.Equal(s.T(), newConfig, pluginFromDB, "config should be updated in database") + } +} + +func (s *PluginSuite) Test_UpdateConfig_invalidConfig_expect400() { + conf := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath) + inst, err := s.manager.Instance(conf.ID) + assert.Nil(s.T(), err) + mockInst := inst.(*mock.PluginInstance) + origConfig := mockInst.Config + + newConfig := &mock.PluginConfig{ + TestKey: "test__new__config__invalid", + IsNotValid: true, + } + newConfigYAML, err := yaml.Marshal(newConfig) + assert.Nil(s.T(), err) + + { + test.WithUser(s.ctx, 1) + + s.ctx.Request = httptest.NewRequest("POST", fmt.Sprintf("/plugin/%d/config", conf.ID), bytes.NewReader(newConfigYAML)) + s.ctx.Header("Content-Type", "application/x-yaml") + s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}} + s.a.UpdateConfig(s.ctx) + + assert.Equal(s.T(), 400, s.recorder.Code) + assert.Equal(s.T(), origConfig, mockInst.Config, "config should not be received by plugin") + + pluginFromDBBytes := s.db.GetPluginConfByID(conf.ID).Config + pluginFromDB := new(mock.PluginConfig) + err := yaml.Unmarshal(pluginFromDBBytes, pluginFromDB) + assert.Nil(s.T(), err) + assert.Equal(s.T(), origConfig, pluginFromDB, "config should not be updated in database") + } +} + +func (s *PluginSuite) Test_UpdateConfig_malformedYAML_expect400() { + conf := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath) + inst, err := s.manager.Instance(conf.ID) + assert.Nil(s.T(), err) + mockInst := inst.(*mock.PluginInstance) + origConfig := mockInst.Config + + newConfigYAML := []byte(`--- "rg e""`) + + { + test.WithUser(s.ctx, 1) + + s.ctx.Request = httptest.NewRequest("POST", fmt.Sprintf("/plugin/%d/config", conf.ID), bytes.NewReader(newConfigYAML)) + s.ctx.Header("Content-Type", "application/x-yaml") + s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}} + s.a.UpdateConfig(s.ctx) + + assert.Equal(s.T(), 400, s.recorder.Code) + assert.Equal(s.T(), origConfig, mockInst.Config, "config should not be received by plugin") + + pluginFromDBBytes := s.db.GetPluginConfByID(conf.ID).Config + pluginFromDB := new(mock.PluginConfig) + err := yaml.Unmarshal(pluginFromDBBytes, pluginFromDB) + assert.Nil(s.T(), err) + assert.Equal(s.T(), origConfig, pluginFromDB, "config should not be updated in database") + } +} + +func (s *PluginSuite) Test_UpdateConfig_ioError_expect500() { + conf := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath) + inst, err := s.manager.Instance(conf.ID) + assert.Nil(s.T(), err) + mockInst := inst.(*mock.PluginInstance) + origConfig := mockInst.Config + + { + test.WithUser(s.ctx, 1) + + s.ctx.Request = httptest.NewRequest("POST", fmt.Sprintf("/plugin/%d/config", conf.ID), test.UnreadableReader()) + s.ctx.Header("Content-Type", "application/x-yaml") + s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}} + s.a.UpdateConfig(s.ctx) + + assert.Equal(s.T(), 500, s.recorder.Code) + assert.Equal(s.T(), origConfig, mockInst.Config, "config should not be received by plugin") + + pluginFromDBBytes := s.db.GetPluginConfByID(conf.ID).Config + pluginFromDB := new(mock.PluginConfig) + err := yaml.Unmarshal(pluginFromDBBytes, pluginFromDB) + assert.Nil(s.T(), err) + assert.Equal(s.T(), origConfig, pluginFromDB, "config should not be updated in database") + } +} + +func (s *PluginSuite) Test_UpdateConfig_notImplemented_expect400() { + conf := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath) + inst, err := s.manager.Instance(conf.ID) + assert.Nil(s.T(), err) + mockInst := inst.(*mock.PluginInstance) + + newConfig := &mock.PluginConfig{ + TestKey: "test__new__config", + } + newConfigYAML, err := yaml.Marshal(newConfig) + assert.Nil(s.T(), err) + + mockInst.SetCapability(compat.Configurer, false) + defer mockInst.SetCapability(compat.Configurer, true) + + { + test.WithUser(s.ctx, 1) + + s.ctx.Request = httptest.NewRequest("POST", fmt.Sprintf("/plugin/%d/config", conf.ID), bytes.NewReader(newConfigYAML)) + s.ctx.Header("Content-Type", "application/x-yaml") + s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}} + s.a.UpdateConfig(s.ctx) + + assert.Equal(s.T(), 400, s.recorder.Code) + } +} + +func (s *PluginSuite) Test_UpdateConfig_incorrectUser_expectNotFound() { + conf := s.db.GetPluginConfByUserAndPath(1, mock.ModulePath) + inst, err := s.manager.Instance(conf.ID) + assert.Nil(s.T(), err) + mockInst := inst.(*mock.PluginInstance) + origConfig := mockInst.Config + + newConfig := &mock.PluginConfig{ + TestKey: "test__new__config", + } + newConfigYAML, err := yaml.Marshal(newConfig) + assert.Nil(s.T(), err) + + { + test.WithUser(s.ctx, 2) + + s.ctx.Request = httptest.NewRequest("POST", fmt.Sprintf("/plugin/%d/config", conf.ID), bytes.NewReader(newConfigYAML)) + s.ctx.Header("Content-Type", "application/x-yaml") + s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}} + s.a.UpdateConfig(s.ctx) + + assert.Equal(s.T(), 404, s.recorder.Code) + assert.Equal(s.T(), origConfig, mockInst.Config, "config should not be received by plugin") + + pluginFromDBBytes := s.db.GetPluginConfByID(conf.ID).Config + pluginFromDB := new(mock.PluginConfig) + err := yaml.Unmarshal(pluginFromDBBytes, pluginFromDB) + assert.Nil(s.T(), err) + assert.Equal(s.T(), origConfig, pluginFromDB, "config should not be updated in database") + } +} + +func (s *PluginSuite) Test_UpdateConfig_danglingConf_expectNotFound() { + conf := s.getDanglingConf(1) + + newConfig := &mock.PluginConfig{ + TestKey: "test__new__config", + } + newConfigYAML, err := yaml.Marshal(newConfig) + assert.Nil(s.T(), err) + + { + test.WithUser(s.ctx, 1) + + s.ctx.Request = httptest.NewRequest("POST", fmt.Sprintf("/plugin/%d/config", conf.ID), bytes.NewReader(newConfigYAML)) + s.ctx.Header("Content-Type", "application/x-yaml") + s.ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(conf.ID)}} + s.a.UpdateConfig(s.ctx) + + assert.Equal(s.T(), 404, s.recorder.Code) + } +} + +func (s *PluginSuite) Test_UpdateConfig_nonExistPlugin_expectNotFound() { + newConfig := &mock.PluginConfig{ + TestKey: "test__new__config", + } + newConfigYAML, err := yaml.Marshal(newConfig) + assert.Nil(s.T(), err) + + { + test.WithUser(s.ctx, 1) + + s.ctx.Request = httptest.NewRequest("POST", "/plugin/99/config", bytes.NewReader(newConfigYAML)) + s.ctx.Header("Content-Type", "application/x-yaml") + s.ctx.Params = gin.Params{{Key: "id", Value: "99"}} + s.a.UpdateConfig(s.ctx) + + assert.Equal(s.T(), 404, s.recorder.Code) + } +} diff --git a/api/stream/stream.go b/api/stream/stream.go index 42b0d752d..a43d22406 100644 --- a/api/stream/stream.go +++ b/api/stream/stream.go @@ -37,7 +37,7 @@ func New(pingPeriod, pongTimeout time.Duration, allowedWebSocketOrigins []string } // NotifyDeletedUser closes existing connections for the given user. -func (a *API) NotifyDeletedUser(userID uint) { +func (a *API) NotifyDeletedUser(userID uint) error { a.lock.Lock() defer a.lock.Unlock() if clients, ok := a.clients[userID]; ok { @@ -46,6 +46,7 @@ func (a *API) NotifyDeletedUser(userID uint) { } delete(a.clients, userID) } + return nil } // NotifyDeletedClient closes existing connections with the given token. diff --git a/api/stream/stream_test.go b/api/stream/stream_test.go index 21f95512d..acc159e0a 100644 --- a/api/stream/stream_test.go +++ b/api/stream/stream_test.go @@ -92,7 +92,7 @@ func TestWritePingFails(t *testing.T) { assert.NotEmpty(t, clients) - time.Sleep(5 * time.Second) // waiting for ping + time.Sleep(api.pingPeriod) // waiting for ping api.Notify(1, &model.MessageExternal{Message: "HI"}) user.expectNoMessage() @@ -123,7 +123,7 @@ func TestPing(t *testing.T) { expectNoMessage(user) select { - case <-time.After(5 * time.Second): + case <-time.After(2 * time.Second): assert.Fail(t, "Expected ping but there was one :(") case <-ping: // expected @@ -151,7 +151,7 @@ func TestCloseClientOnNotReading(t *testing.T) { time.Sleep(100 * time.Millisecond) assert.NotEmpty(t, clients(api, 1)) - time.Sleep(7 * time.Second) + time.Sleep(api.pingPeriod + api.pongTimeout) assert.Empty(t, clients(api, 1)) } @@ -363,7 +363,7 @@ func TestMultipleClients(t *testing.T) { expectNoMessage(userThree...) api.Notify(1, &model.MessageExternal{ID: 1, Message: "hello"}) - time.Sleep(1 * time.Second) + time.Sleep(500 * time.Millisecond) expectMessage(&model.MessageExternal{ID: 1, Message: "hello"}, userOne...) expectNoMessage(userTwo...) expectNoMessage(userThree...) @@ -536,8 +536,8 @@ func (c *testingClient) expectNoMessage() { func bootTestServer(handlerFunc gin.HandlerFunc) (*httptest.Server, *API) { r := gin.New() r.Use(handlerFunc) - // all 4 seconds a ping, and the client has 1 second to respond - api := New(4*time.Second, 1*time.Second, []string{}) + // ping every 500 ms, and the client has 500 ms to respond + api := New(500*time.Millisecond, 500*time.Millisecond, []string{}) r.GET("/", api.Handle) server := httptest.NewServer(r) diff --git a/api/user.go b/api/user.go index e609cb1db..0432b679f 100644 --- a/api/user.go +++ b/api/user.go @@ -19,11 +19,45 @@ type UserDatabase interface { CreateUser(user *model.User) error } +// UserChangeNotifier notifies listeners for user changes. +type UserChangeNotifier struct { + userDeletedCallbacks []func(uid uint) error + userAddedCallbacks []func(uid uint) error +} + +// OnUserDeleted is called on user deletion. +func (c *UserChangeNotifier) OnUserDeleted(cb func(uid uint) error) { + c.userDeletedCallbacks = append(c.userDeletedCallbacks, cb) +} + +// OnUserAdded is called on user creation. +func (c *UserChangeNotifier) OnUserAdded(cb func(uid uint) error) { + c.userAddedCallbacks = append(c.userAddedCallbacks, cb) +} + +func (c *UserChangeNotifier) fireUserDeleted(uid uint) error { + for _, cb := range c.userDeletedCallbacks { + if err := cb(uid); err != nil { + return err + } + } + return nil +} + +func (c *UserChangeNotifier) fireUserAdded(uid uint) error { + for _, cb := range c.userAddedCallbacks { + if err := cb(uid); err != nil { + return err + } + } + return nil +} + // The UserAPI provides handlers for managing users. type UserAPI struct { - DB UserDatabase - PasswordStrength int - NotifyDeleted func(uint) + DB UserDatabase + PasswordStrength int + UserChangeNotifier *UserChangeNotifier } // GetUsers returns all the users @@ -125,6 +159,10 @@ func (a *UserAPI) CreateUser(ctx *gin.Context) { internal := a.toInternalUser(&user, []byte{}) if a.DB.GetUserByName(internal.Name) == nil { a.DB.CreateUser(internal) + if err := a.UserChangeNotifier.fireUserAdded(internal.ID); err != nil { + ctx.AbortWithError(500, err) + return + } ctx.JSON(200, toExternalUser(internal)) } else { ctx.AbortWithError(400, errors.New("username already exists")) @@ -214,7 +252,10 @@ func (a *UserAPI) GetUserByID(ctx *gin.Context) { func (a *UserAPI) DeleteUserByID(ctx *gin.Context) { withID(ctx, "id", func(id uint) { if user := a.DB.GetUserByID(id); user != nil { - a.NotifyDeleted(id) + if err := a.UserChangeNotifier.fireUserDeleted(id); err != nil { + ctx.AbortWithError(500, err) + return + } a.DB.DeleteUserByID(id) } else { ctx.AbortWithError(404, errors.New("user does not exist")) diff --git a/api/user_test.go b/api/user_test.go index 04bbd14ff..029a7092e 100644 --- a/api/user_test.go +++ b/api/user_test.go @@ -1,6 +1,7 @@ package api import ( + "errors" "net/http/httptest" "strings" "testing" @@ -10,6 +11,7 @@ import ( "github.com/gotify/server/mode" "github.com/gotify/server/model" "github.com/gotify/server/test" + "github.com/gotify/server/test/testdb" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" ) @@ -20,26 +22,31 @@ func TestUserSuite(t *testing.T) { type UserSuite struct { suite.Suite - db *test.Database - a *UserAPI - ctx *gin.Context - recorder *httptest.ResponseRecorder - notified bool + db *testdb.Database + a *UserAPI + ctx *gin.Context + recorder *httptest.ResponseRecorder + notifiedAdd bool + notifiedDelete bool + notifier *UserChangeNotifier } func (s *UserSuite) BeforeTest(suiteName, testName string) { mode.Set(mode.TestDev) s.recorder = httptest.NewRecorder() s.ctx, _ = gin.CreateTestContext(s.recorder) - s.db = test.NewDB(s.T()) - s.notified = false - s.a = &UserAPI{DB: s.db, NotifyDeleted: s.notify} + s.db = testdb.NewDB(s.T()) + s.notifier = new(UserChangeNotifier) + s.notifier.OnUserDeleted(func(uint) error { + s.notifiedDelete = true + return nil + }) + s.notifier.OnUserAdded(func(uint) error { + s.notifiedAdd = true + return nil + }) + s.a = &UserAPI{DB: s.db, UserChangeNotifier: s.notifier} } - -func (s *UserSuite) notify(uint) { - s.notified = true -} - func (s *UserSuite) AfterTest(suiteName, testName string) { s.db.Close() } @@ -113,7 +120,7 @@ func (s *UserSuite) Test_DeleteUserByID_UnknownUser() { } func (s *UserSuite) Test_DeleteUserByID() { - assert.False(s.T(), s.notified) + assert.False(s.T(), s.notifiedDelete) s.db.User(2) @@ -123,10 +130,27 @@ func (s *UserSuite) Test_DeleteUserByID() { assert.Equal(s.T(), 200, s.recorder.Code) s.db.AssertUserNotExist(2) - assert.True(s.T(), s.notified) + assert.True(s.T(), s.notifiedDelete) +} + +func (s *UserSuite) Test_DeleteUserByID_NotifyFail() { + s.db.User(5) + s.notifier.OnUserDeleted(func(id uint) error { + if id == 5 { + return errors.New("some error") + } + return nil + }) + + s.ctx.Params = gin.Params{{Key: "id", Value: "5"}} + + s.a.DeleteUserByID(s.ctx) + + assert.Equal(s.T(), 500, s.recorder.Code) } func (s *UserSuite) Test_CreateUser() { + assert.False(s.T(), s.notifiedAdd) s.ctx.Request = httptest.NewRequest("POST", "/user", strings.NewReader(`{"name": "tom", "pass": "mylittlepony", "admin": true}`)) s.ctx.Request.Header.Set("Content-Type", "application/json") @@ -139,6 +163,22 @@ func (s *UserSuite) Test_CreateUser() { created := s.db.GetUserByName("tom") assert.NotNil(s.T(), created) assert.True(s.T(), password.ComparePassword(created.Pass, []byte("mylittlepony"))) + assert.True(s.T(), s.notifiedAdd) +} + +func (s *UserSuite) Test_CreateUser_NotifyFail() { + s.notifier.OnUserAdded(func(id uint) error { + if s.db.GetUserByID(id).Name == "eva" { + return errors.New("some error") + } + return nil + }) + s.ctx.Request = httptest.NewRequest("POST", "/user", strings.NewReader(`{"name": "eva", "pass": "mylittlepony", "admin": true}`)) + s.ctx.Request.Header.Set("Content-Type", "application/json") + + s.a.CreateUser(s.ctx) + + assert.Equal(s.T(), 500, s.recorder.Code) } func (s *UserSuite) Test_CreateUser_NoPassword() { diff --git a/app.go b/app.go index 970596e80..cf704037a 100644 --- a/app.go +++ b/app.go @@ -33,7 +33,12 @@ func main() { rand.Seed(time.Now().UnixNano()) conf := config.Get() - if err := os.MkdirAll(conf.UploadedImagesDir, 0777); err != nil { + if conf.PluginsDir != "" { + if err := os.MkdirAll(conf.PluginsDir, 0755); err != nil { + panic(err) + } + } + if err := os.MkdirAll(conf.UploadedImagesDir, 0755); err != nil { panic(err) } diff --git a/auth/authentication.go b/auth/authentication.go index 2f09a5393..b6f48a48e 100644 --- a/auth/authentication.go +++ b/auth/authentication.go @@ -16,6 +16,7 @@ const ( type Database interface { GetApplicationByToken(token string) *model.Application GetClientByToken(token string) *model.Client + GetPluginConfByToken(token string) *model.PluginConf GetUserByName(name string) *model.User GetUserByID(id uint) *model.User } diff --git a/auth/authentication_test.go b/auth/authentication_test.go index 282d047b9..0ad7ed6fb 100644 --- a/auth/authentication_test.go +++ b/auth/authentication_test.go @@ -11,7 +11,7 @@ import ( "github.com/gotify/server/auth/password" "github.com/gotify/server/mode" "github.com/gotify/server/model" - "github.com/gotify/server/test" + "github.com/gotify/server/test/testdb" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" ) @@ -23,12 +23,12 @@ func TestSuite(t *testing.T) { type AuthenticationSuite struct { suite.Suite auth *Auth - DB *test.Database + DB *testdb.Database } func (s *AuthenticationSuite) SetupSuite() { mode.Set(mode.TestDev) - s.DB = test.NewDB(s.T()) + s.DB = testdb.NewDB(s.T()) s.auth = &Auth{s.DB} s.DB.CreateUser(&model.User{ diff --git a/auth/token.go b/auth/token.go index 4812d017e..16cd3ff9a 100644 --- a/auth/token.go +++ b/auth/token.go @@ -9,8 +9,19 @@ var ( randomTokenLength = 14 applicationPrefix = "A" clientPrefix = "C" + pluginPrefix = "P" ) +// GenerateNotExistingToken receives a token generation func and a func to check whether the token exists, returns a unique token. +func GenerateNotExistingToken(generateToken func() string, tokenExists func(token string) bool) string { + for { + token := generateToken() + if !tokenExists(token) { + return token + } + } +} + // GenerateApplicationToken generates an application token. func GenerateApplicationToken() string { return generateRandomToken(applicationPrefix) @@ -21,6 +32,11 @@ func GenerateClientToken() string { return generateRandomToken(clientPrefix) } +// GeneratePluginToken generates a plugin token. +func GeneratePluginToken() string { + return generateRandomToken(pluginPrefix) +} + // GenerateImageName generates an image name. func GenerateImageName() string { return generateRandomString(25) diff --git a/auth/token_test.go b/auth/token_test.go index 93817aa98..99b157bbf 100644 --- a/auth/token_test.go +++ b/auth/token_test.go @@ -1,6 +1,7 @@ package auth import ( + "fmt" "strings" "testing" @@ -11,6 +12,21 @@ func TestTokenHavePrefix(t *testing.T) { for i := 0; i < 50; i++ { assert.True(t, strings.HasPrefix(GenerateApplicationToken(), "A")) assert.True(t, strings.HasPrefix(GenerateClientToken(), "C")) + assert.True(t, strings.HasPrefix(GeneratePluginToken(), "P")) assert.NotEmpty(t, GenerateImageName()) } } + +func TestGenerateNotExistingToken(t *testing.T) { + count := 5 + token := GenerateNotExistingToken(func() string { + return fmt.Sprint(count) + }, func(token string) bool { + count-- + if token == "0" { + return false + } + return true + }) + assert.Equal(t, "0", token) +} diff --git a/config.example.yml b/config.example.yml index 9ea005a9d..6fb254575 100644 --- a/config.example.yml +++ b/config.example.yml @@ -35,4 +35,5 @@ defaultuser: # on database creation, gotify creates an admin user name: admin # the username of the default user pass: admin # the password of the default user passstrength: 10 # the bcrypt password strength (higher = better but also slower) -uploadedimagesdir: data/images # the directory for storing uploaded images \ No newline at end of file +uploadedimagesdir: data/images # the directory for storing uploaded images +pluginsdir: data/plugins # the directory where plugin resides diff --git a/config/config.go b/config/config.go index abf71566e..4dc071b76 100644 --- a/config/config.go +++ b/config/config.go @@ -39,6 +39,7 @@ type Configuration struct { } PassStrength int `default:"10"` UploadedImagesDir string `default:"data/images"` + PluginsDir string `default:"data/plugins"` } // Get returns the configuration extracted from env variables or config file. diff --git a/config/config_test.go b/config/config_test.go index 2dcf1940b..f15fd7d01 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -89,6 +89,7 @@ database: defaultuser: name: nicories pass: 12345 +pluginsdir: data/plugins `) file.Close() assert.Nil(t, err) @@ -103,6 +104,7 @@ defaultuser: assert.Equal(t, "*", conf.Server.ResponseHeaders["Access-Control-Allow-Origin"]) assert.Equal(t, "GET,POST", conf.Server.ResponseHeaders["Access-Control-Allow-Methods"]) assert.Equal(t, []string{".+.example.com", "otherdomain.com"}, conf.Server.Stream.AllowedOrigins) + assert.Equal(t, "data/plugins", conf.PluginsDir) assert.Nil(t, os.Remove("config.yml")) } diff --git a/database/database.go b/database/database.go index d3d3c68bd..6fc517443 100644 --- a/database/database.go +++ b/database/database.go @@ -15,7 +15,7 @@ import ( var mkdirAll = os.MkdirAll // New creates a new wrapper for the gorm database framework. -func New(dialect, connection, defaultUser, defaultPass string, strength int, createDefaultUser bool) (*GormDatabase, error) { +func New(dialect, connection, defaultUser, defaultPass string, strength int, createDefaultUserIfNotExist bool) (*GormDatabase, error) { createDirectoryIfSqlite(dialect, connection) db, err := gorm.Open(dialect, connection) @@ -35,12 +35,11 @@ func New(dialect, connection, defaultUser, defaultPass string, strength int, cre db.DB().SetMaxOpenConns(1) } - if !db.HasTable(new(model.User)) && !db.HasTable(new(model.Message)) && - !db.HasTable(new(model.Client)) && !db.HasTable(new(model.Application)) { - db.AutoMigrate(new(model.User), new(model.Application), new(model.Message), new(model.Client)) - if createDefaultUser { - db.Create(&model.User{Name: defaultUser, Pass: password.CreatePassword(defaultPass, strength), Admin: true}) - } + db.AutoMigrate(new(model.User), new(model.Application), new(model.Message), new(model.Client), new(model.PluginConf)) + userCount := 0 + db.Find(new(model.User)).Count(&userCount) + if createDefaultUserIfNotExist && userCount == 0 { + db.Create(&model.User{Name: defaultUser, Pass: password.CreatePassword(defaultPass, strength), Admin: true}) } return &GormDatabase{DB: db}, nil diff --git a/database/database_test.go b/database/database_test.go index 9cb852982..e30ebb09a 100644 --- a/database/database_test.go +++ b/database/database_test.go @@ -5,6 +5,8 @@ import ( "os" "testing" + "github.com/gotify/server/test" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" ) @@ -15,56 +17,56 @@ func TestDatabaseSuite(t *testing.T) { type DatabaseSuite struct { suite.Suite - db *GormDatabase + db *GormDatabase + tmpDir test.TmpDir } func (s *DatabaseSuite) BeforeTest(suiteName, testName string) { - db, err := New("sqlite3", "testdb.db", "defaultUser", "defaultPass", 5, true) + s.tmpDir = test.NewTmpDir("gotify_databasesuite") + db, err := New("sqlite3", s.tmpDir.Path("testdb.db"), "defaultUser", "defaultPass", 5, true) assert.Nil(s.T(), err) s.db = db } func (s *DatabaseSuite) AfterTest(suiteName, testName string) { s.db.Close() - assert.Nil(s.T(), os.Remove("testdb.db")) + assert.Nil(s.T(), s.tmpDir.Clean()) } func TestInvalidDialect(t *testing.T) { - _, err := New("asdf", "testdb.db", "defaultUser", "defaultPass", 5, true) - assert.NotNil(t, err) + tmpDir := test.NewTmpDir("gotify_testinvaliddialect") + defer tmpDir.Clean() + _, err := New("asdf", tmpDir.Path("testdb.db"), "defaultUser", "defaultPass", 5, true) + assert.Error(t, err) } func TestCreateSqliteFolder(t *testing.T) { - // ensure path not exists - os.RemoveAll("somepath") + tmpDir := test.NewTmpDir("gotify_testcreatesqlitefolder") + defer tmpDir.Clean() - db, err := New("sqlite3", "somepath/testdb.db", "defaultUser", "defaultPass", 5, true) + db, err := New("sqlite3", tmpDir.Path("somepath/testdb.db"), "defaultUser", "defaultPass", 5, true) assert.Nil(t, err) - assert.DirExists(t, "somepath") + assert.DirExists(t, tmpDir.Path("somepath")) db.Close() - - assert.Nil(t, os.RemoveAll("somepath")) } func TestWithAlreadyExistingSqliteFolder(t *testing.T) { - // ensure path not exists - os.RemoveAll("somepath") - os.MkdirAll("somepath", 0777) + tmpDir := test.NewTmpDir("gotify_testwithexistingfolder") + defer tmpDir.Clean() - db, err := New("sqlite3", "somepath/testdb.db", "defaultUser", "defaultPass", 5, true) + db, err := New("sqlite3", tmpDir.Path("somepath/testdb.db"), "defaultUser", "defaultPass", 5, true) assert.Nil(t, err) - assert.DirExists(t, "somepath") + assert.DirExists(t, tmpDir.Path("somepath")) db.Close() - - assert.Nil(t, os.RemoveAll("somepath")) } func TestPanicsOnMkdirError(t *testing.T) { - os.RemoveAll("somepath") + tmpDir := test.NewTmpDir("gotify_testpanicsonmkdirerror") + defer tmpDir.Clean() mkdirAll = func(path string, perm os.FileMode) error { return errors.New("ERROR") } assert.Panics(t, func() { - New("sqlite3", "somepath/test.db", "defaultUser", "defaultPass", 5, true) + New("sqlite3", tmpDir.Path("somepath/test.db"), "defaultUser", "defaultPass", 5, true) }) } diff --git a/database/migration_test.go b/database/migration_test.go new file mode 100644 index 000000000..a198a2f39 --- /dev/null +++ b/database/migration_test.go @@ -0,0 +1,63 @@ +package database + +import ( + "testing" + + "github.com/gotify/server/model" + "github.com/gotify/server/test" + "github.com/jinzhu/gorm" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" +) + +func TestMigration(t *testing.T) { + suite.Run(t, &MigrationSuite{}) +} + +type MigrationSuite struct { + suite.Suite + tmpDir test.TmpDir +} + +func (s *MigrationSuite) BeforeTest(suiteName, testName string) { + s.tmpDir = test.NewTmpDir("gotify_migrationsuite") + db, err := gorm.Open("sqlite3", s.tmpDir.Path("test_obsolete.db")) + assert.Nil(s.T(), err) + defer db.Close() + + assert.Nil(s.T(), db.CreateTable(new(model.User)).Error) + assert.Nil(s.T(), db.Create(&model.User{ + Name: "test_user", + Admin: true, + }).Error) + + // we should not be able to create applications by now + assert.False(s.T(), db.HasTable(new(model.Application))) +} + +func (s *MigrationSuite) AfterTest(suiteName, testName string) { + assert.Nil(s.T(), s.tmpDir.Clean()) +} + +func (s *MigrationSuite) TestMigration() { + db, err := New("sqlite3", s.tmpDir.Path("test_obsolete.db"), "admin", "admin", 6, true) + assert.Nil(s.T(), err) + defer db.Close() + + assert.True(s.T(), db.DB.HasTable(new(model.Application))) + + // a user already exist, not adding a new user + assert.Nil(s.T(), db.GetUserByName("admin")) + + // the old user should persist + assert.Equal(s.T(), true, db.GetUserByName("test_user").Admin) + + // we should be able to create applications + assert.Nil(s.T(), db.CreateApplication(&model.Application{ + Token: "A1234", + UserID: db.GetUserByName("test_user").ID, + Description: "this is a test application", + Name: "test application", + })) + assert.Equal(s.T(), "test application", db.GetApplicationByToken("A1234").Name) +} diff --git a/database/plugin.go b/database/plugin.go new file mode 100644 index 000000000..689b194b1 --- /dev/null +++ b/database/plugin.go @@ -0,0 +1,67 @@ +package database + +import ( + "github.com/gotify/server/model" +) + +// GetPluginConfByUser gets plugin configurations from a user +func (d *GormDatabase) GetPluginConfByUser(userid uint) []*model.PluginConf { + var plugins []*model.PluginConf + d.DB.Where("user_id = ?", userid).Find(&plugins) + return plugins +} + +// GetPluginConfByUserAndPath gets plugin configuration by user and file name +func (d *GormDatabase) GetPluginConfByUserAndPath(userid uint, path string) *model.PluginConf { + plugin := new(model.PluginConf) + d.DB.Where("user_id = ? AND module_path = ?", userid, path).First(plugin) + if plugin.ModulePath == path { + return plugin + } + return nil +} + +// GetPluginConfByApplicationID gets plugin configuration by its internal appid. +func (d *GormDatabase) GetPluginConfByApplicationID(appid uint) *model.PluginConf { + plugin := new(model.PluginConf) + d.DB.Where("application_id = ?", appid).First(plugin) + if plugin.ApplicationID == appid { + return plugin + } + return nil +} + +// CreatePluginConf creates a new plugin configuration +func (d *GormDatabase) CreatePluginConf(p *model.PluginConf) error { + return d.DB.Create(p).Error +} + +// GetPluginConfByToken gets plugin configuration by plugin token +func (d *GormDatabase) GetPluginConfByToken(token string) *model.PluginConf { + plugin := new(model.PluginConf) + d.DB.Where("token = ?", token).First(plugin) + if plugin.Token == token { + return plugin + } + return nil +} + +// GetPluginConfByID gets plugin configuration by plugin ID +func (d *GormDatabase) GetPluginConfByID(id uint) *model.PluginConf { + plugin := new(model.PluginConf) + d.DB.Where("id = ?", id).First(plugin) + if plugin.ID == id { + return plugin + } + return nil +} + +// UpdatePluginConf updates plugin configuration +func (d *GormDatabase) UpdatePluginConf(p *model.PluginConf) error { + return d.DB.Save(p).Error +} + +// DeletePluginConfByID deletes a plugin configuration by its id. +func (d *GormDatabase) DeletePluginConfByID(id uint) error { + return d.DB.Where("id = ?", id).Delete(&model.PluginConf{}).Error +} diff --git a/database/plugin_test.go b/database/plugin_test.go new file mode 100644 index 000000000..516b3d04b --- /dev/null +++ b/database/plugin_test.go @@ -0,0 +1,40 @@ +package database + +import ( + "github.com/gotify/server/model" + "github.com/stretchr/testify/assert" +) + +func (s *DatabaseSuite) TestPluginConf() { + plugin := model.PluginConf{ + ModulePath: "github.com/gotify/example-plugin", + Token: "Pabc", + UserID: 1, + Enabled: true, + Config: nil, + ApplicationID: 2, + } + + assert.Nil(s.T(), s.db.CreatePluginConf(&plugin)) + + assert.Equal(s.T(), uint(1), plugin.ID) + assert.Equal(s.T(), "Pabc", s.db.GetPluginConfByUserAndPath(1, "github.com/gotify/example-plugin").Token) + assert.Equal(s.T(), true, s.db.GetPluginConfByToken("Pabc").Enabled) + assert.Equal(s.T(), "Pabc", s.db.GetPluginConfByApplicationID(2).Token) + assert.Equal(s.T(), "github.com/gotify/example-plugin", s.db.GetPluginConfByID(1).ModulePath) + + assert.Nil(s.T(), s.db.GetPluginConfByToken("Pnotexist")) + assert.Nil(s.T(), s.db.GetPluginConfByID(12)) + assert.Nil(s.T(), s.db.GetPluginConfByUserAndPath(1, "not/exist")) + assert.Nil(s.T(), s.db.GetPluginConfByApplicationID(99)) + + assert.Len(s.T(), s.db.GetPluginConfByUser(1), 1) + assert.Len(s.T(), s.db.GetPluginConfByUser(0), 0) + + testConf := `{"test_config_key":"hello"}` + plugin.Enabled = false + plugin.Config = []byte(testConf) + assert.Nil(s.T(), s.db.UpdatePluginConf(&plugin)) + assert.Equal(s.T(), false, s.db.GetPluginConfByToken("Pabc").Enabled) + assert.Equal(s.T(), testConf, string(s.db.GetPluginConfByToken("Pabc").Config)) +} diff --git a/database/user.go b/database/user.go index d14271b98..9790f4b80 100644 --- a/database/user.go +++ b/database/user.go @@ -39,6 +39,9 @@ func (d *GormDatabase) DeleteUserByID(id uint) error { for _, client := range d.GetClientsByUser(id) { d.DeleteClientByID(client.ID) } + for _, conf := range d.GetPluginConfByUser(id) { + d.DeletePluginConfByID(conf.ID) + } return d.DB.Where("id = ?", id).Delete(&model.User{}).Error } diff --git a/database/user_test.go b/database/user_test.go index d40437f0e..2ff88c32f 100644 --- a/database/user_test.go +++ b/database/user_test.go @@ -46,16 +46,38 @@ func (s *DatabaseSuite) TestUser() { assert.Empty(s.T(), users) } -func (s *DatabaseSuite) TestDeleteUserDeletesApplicationsAndClients() { +func (s *DatabaseSuite) TestUserPlugins() { + s.db.CreateUser(&model.User{Name: "geek", ID: 16}) + s.db.CreatePluginConf(&model.PluginConf{ + UserID: s.db.GetUserByName("geek").ID, + ModulePath: "github.com/gotify/example-plugin", + Token: "P1234", + Enabled: true, + }) + s.db.CreatePluginConf(&model.PluginConf{ + UserID: s.db.GetUserByName("geek").ID, + ModulePath: "github.com/gotify/example-plugin/v2", + Token: "P5678", + Enabled: true, + }) + + assert.Len(s.T(), s.db.GetPluginConfByUser(s.db.GetUserByName("geek").ID), 2) + assert.Equal(s.T(), "github.com/gotify/example-plugin", s.db.GetPluginConfByToken("P1234").ModulePath) + +} + +func (s *DatabaseSuite) TestDeleteUserDeletesApplicationsAndClientsAndPluginConfs() { s.db.CreateUser(&model.User{Name: "nicories", ID: 10}) s.db.CreateApplication(&model.Application{ID: 100, Token: "apptoken", UserID: 10}) s.db.CreateMessage(&model.Message{ID: 1000, ApplicationID: 100}) s.db.CreateClient(&model.Client{ID: 10000, Token: "clienttoken", UserID: 10}) + s.db.CreatePluginConf(&model.PluginConf{ID: 1000, Token: "plugintoken", UserID: 10}) s.db.CreateUser(&model.User{Name: "nicories2", ID: 20}) s.db.CreateApplication(&model.Application{ID: 200, Token: "apptoken2", UserID: 20}) s.db.CreateMessage(&model.Message{ID: 2000, ApplicationID: 200}) s.db.CreateClient(&model.Client{ID: 20000, Token: "clienttoken2", UserID: 20}) + s.db.CreatePluginConf(&model.PluginConf{ID: 2000, Token: "plugintoken2", UserID: 20}) s.db.DeleteUserByID(10) @@ -65,12 +87,14 @@ func (s *DatabaseSuite) TestDeleteUserDeletesApplicationsAndClients() { assert.Empty(s.T(), s.db.GetApplicationsByUser(10)) assert.Empty(s.T(), s.db.GetMessagesByApplication(100)) assert.Empty(s.T(), s.db.GetMessagesByUser(10)) + assert.Empty(s.T(), s.db.GetPluginConfByUser(10)) assert.Nil(s.T(), s.db.GetMessageByID(1000)) assert.NotNil(s.T(), s.db.GetApplicationByToken("apptoken2")) assert.NotNil(s.T(), s.db.GetClientByToken("clienttoken2")) assert.NotEmpty(s.T(), s.db.GetClientsByUser(20)) assert.NotEmpty(s.T(), s.db.GetApplicationsByUser(20)) + assert.NotEmpty(s.T(), s.db.GetPluginConfByUser(20)) assert.NotEmpty(s.T(), s.db.GetMessagesByApplication(200)) assert.NotEmpty(s.T(), s.db.GetMessagesByUser(20)) assert.NotNil(s.T(), s.db.GetMessageByID(2000)) diff --git a/docs/spec.json b/docs/spec.json index a6701e7f2..fdc455517 100644 --- a/docs/spec.json +++ b/docs/spec.json @@ -990,6 +990,404 @@ } } }, + "/plugin": { + "get": { + "security": [ + { + "clientTokenHeader": [] + }, + { + "clientTokenQuery": [] + }, + { + "basicAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "plugin" + ], + "summary": "Return all plugins.", + "operationId": "getPlugins", + "responses": { + "200": { + "description": "Ok", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/PluginConf" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/Error" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/Error" + } + } + } + } + }, + "/plugin/:id/config": { + "get": { + "security": [ + { + "clientTokenHeader": [] + }, + { + "clientTokenQuery": [] + }, + { + "basicAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/x-yaml" + ], + "tags": [ + "plugin" + ], + "summary": "Get YAML configuration for Configurer plugin.", + "operationId": "getPluginConfig", + "parameters": [ + { + "type": "integer", + "description": "the plugin id", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Ok", + "schema": { + "description": "plugin configuration", + "type": "object" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/Error" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/Error" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/Error" + } + } + } + }, + "post": { + "security": [ + { + "clientTokenHeader": [] + }, + { + "clientTokenQuery": [] + }, + { + "basicAuth": [] + } + ], + "consumes": [ + "application/x-yaml" + ], + "produces": [ + "application/json" + ], + "tags": [ + "plugin" + ], + "summary": "Update YAML configuration for Configurer plugin.", + "operationId": "updatePluginConfig", + "parameters": [ + { + "type": "integer", + "description": "the plugin id", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Ok" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/Error" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/Error" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/Error" + } + } + } + } + }, + "/plugin/:id/disable": { + "post": { + "security": [ + { + "clientTokenHeader": [] + }, + { + "clientTokenQuery": [] + }, + { + "basicAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "plugin" + ], + "summary": "Disable a plugin.", + "operationId": "disablePlugin", + "parameters": [ + { + "type": "integer", + "description": "the plugin id", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Ok" + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/Error" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/Error" + } + } + } + } + }, + "/plugin/:id/display": { + "get": { + "security": [ + { + "clientTokenHeader": [] + }, + { + "clientTokenQuery": [] + }, + { + "basicAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "plugin" + ], + "summary": "Get display info for a Displayer plugin.", + "operationId": "getPluginDisplay", + "parameters": [ + { + "type": "integer", + "description": "the plugin id", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Ok", + "schema": { + "type": "string" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/Error" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/Error" + } + } + } + } + }, + "/plugin/:id/enable": { + "post": { + "security": [ + { + "clientTokenHeader": [] + }, + { + "clientTokenQuery": [] + }, + { + "basicAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "plugin" + ], + "summary": "Enable a plugin.", + "operationId": "enablePlugin", + "parameters": [ + { + "type": "integer", + "description": "the plugin id", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Ok" + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/Error" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/Error" + } + } + } + } + }, "/stream": { "get": { "security": [ @@ -1383,6 +1781,7 @@ "token", "name", "description", + "internal", "image" ], "properties": { @@ -1407,6 +1806,13 @@ "readOnly": true, "example": "https://example.com/image.jpeg" }, + "internal": { + "description": "Whether the application is an internal application. Internal applications should not be deleted.", + "type": "boolean", + "x-go-name": "Internal", + "readOnly": true, + "example": false + }, "name": { "description": "The application name. This is how the application should be displayed to the user.", "type": "string", @@ -1634,6 +2040,90 @@ }, "x-go-package": "github.com/gotify/server/model" }, + "PluginConf": { + "description": "Holds information about a plugin instance for one user.", + "type": "object", + "title": "PluginConfExternal Model", + "required": [ + "id", + "name", + "token", + "modulePath", + "enabled", + "capabilities" + ], + "properties": { + "author": { + "description": "The author of the plugin.", + "type": "string", + "x-go-name": "Author", + "readOnly": true, + "example": "jmattheis" + }, + "capabilities": { + "description": "Capabilities the plugin provides", + "type": "array", + "items": { + "type": "string" + }, + "x-go-name": "Capabilities", + "example": [ + "webhook", + "display" + ] + }, + "enabled": { + "description": "Whether the plugin instance is enabled.", + "type": "boolean", + "x-go-name": "Enabled", + "example": true + }, + "id": { + "description": "The plugin id.", + "type": "integer", + "format": "uint64", + "x-go-name": "ID", + "readOnly": true, + "example": 25 + }, + "license": { + "description": "The license of the plugin.", + "type": "string", + "x-go-name": "License", + "readOnly": true, + "example": "MIT" + }, + "modulePath": { + "description": "The module path of the plugin.", + "type": "string", + "x-go-name": "ModulePath", + "readOnly": true, + "example": "github.com/gotify/server/plugin/example/echo" + }, + "name": { + "description": "The plugin name.", + "type": "string", + "x-go-name": "Name", + "readOnly": true, + "example": "RSS poller" + }, + "token": { + "description": "The user name. For login.", + "type": "string", + "x-go-name": "Token", + "example": "P1234" + }, + "website": { + "description": "The website of the plugin.", + "type": "string", + "x-go-name": "Website", + "readOnly": true, + "example": "gotify.net" + } + }, + "x-go-name": "PluginConfExternal", + "x-go-package": "github.com/gotify/server/model" + }, "User": { "description": "The User holds information about permission and other stuff.", "type": "object", diff --git a/error/handler_test.go b/error/handler_test.go index ab060a788..571368f89 100644 --- a/error/handler_test.go +++ b/error/handler_test.go @@ -59,7 +59,7 @@ func TestValidationError(t *testing.T) { ctx, _ := gin.CreateTestContext(rec) ctx.Request = httptest.NewRequest("GET", "/uri", nil) - assert.NotNil(t, ctx.Bind(&testValidate{Age: 150, Limit: 20})) + assert.Error(t, ctx.Bind(&testValidate{Age: 150, Limit: 20})) Handler()(ctx) err := new(model.Error) diff --git a/go.mod b/go.mod index 99b930efb..0eac13ad3 100644 --- a/go.mod +++ b/go.mod @@ -1,34 +1,28 @@ module github.com/gotify/server require ( - cloud.google.com/go v0.34.0 // indirect + cloud.google.com/go v0.35.1 // indirect github.com/Southclaws/configor v1.0.0 // indirect - github.com/denisenkom/go-mssqldb v0.0.0-20190111225525-2fea367d496d // indirect + github.com/denisenkom/go-mssqldb v0.0.0-20190121005146-b04fd42d9952 // indirect github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 // indirect github.com/fortytw2/leaktest v1.3.0 - github.com/gin-contrib/sse v0.0.0-20170109093832-22d885f9ecc7 // indirect github.com/gin-gonic/gin v1.3.0 github.com/go-sql-driver/mysql v1.4.1 // indirect - github.com/gobuffalo/packr v1.21.9 - github.com/google/go-cmp v0.2.0 // indirect + github.com/go-yaml/yaml v2.1.0+incompatible + github.com/gobuffalo/packr v1.22.0 github.com/gorilla/websocket v1.4.0 github.com/gotify/configor v1.0.2-0.20190112111140-7d9c7c7e6233 github.com/gotify/location v0.0.0-20170722210143-03bc4ad20437 - github.com/h2non/filetype v1.0.5 + github.com/gotify/plugin-api v1.0.0 + github.com/h2non/filetype v1.0.6 github.com/jinzhu/gorm v1.9.2 github.com/jinzhu/inflection v0.0.0-20180308033659-04140366298a // indirect github.com/jinzhu/now v0.0.0-20181116074157-8ec929ed50c3 // indirect - github.com/json-iterator/go v1.1.5 // indirect github.com/lib/pq v1.0.0 // indirect - github.com/mattn/go-isatty v0.0.4 // indirect github.com/mattn/go-sqlite3 v1.10.0 // indirect - github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect - github.com/modern-go/reflect2 v1.0.1 // indirect - github.com/pkg/errors v0.8.1 // indirect + github.com/robfig/cron v0.0.0-20180505203441-b41be1df6967 github.com/stretchr/testify v1.3.0 - golang.org/x/crypto v0.0.0-20190103213133-ff983b9c42bc + golang.org/x/crypto v0.0.0-20190131182504-b8fe1690c613 google.golang.org/appengine v1.4.0 // indirect - gopkg.in/go-playground/assert.v1 v1.2.1 // indirect gopkg.in/go-playground/validator.v8 v8.18.2 - gopkg.in/h2non/filetype.v1 v1.0.5 // indirect ) diff --git a/go.sum b/go.sum index 88dd655bb..a976cc718 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,13 @@ -cloud.google.com/go v0.34.0 h1:eOI3/cP2VTU6uZLDYAoic+eyzzB9YyGmJ7eIjl8rOPg= -cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.31.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.35.1 h1:LMe/Btq0Eijsc97JyBwMc0KMXOe0orqAMdg7/EkywN8= +cloud.google.com/go v0.35.1/go.mod h1:wfjPZNvXCBYESy3fIynybskMP48KVPrjSPCnXiK7Prg= +dmitri.shuralyov.com/app/changes v0.0.0-20180602232624-0a106ad413e3/go.mod h1:Yl+fi1br7+Rr3LqpNJf1/uxUdtRUV+Tnj0o93V2B9MU= +dmitri.shuralyov.com/html/belt v0.0.0-20180602232347-f7d459c86be0/go.mod h1:JLBrvjyP0v+ecvNYvCpyZgu5/xkfAUhi6wJj28eUfSU= +dmitri.shuralyov.com/service/change v0.0.0-20181023043359-a85b471d5412/go.mod h1:a1inKt/atXimZ4Mv927x+r7UpyzRUf4emIoiiSC2TN4= +dmitri.shuralyov.com/state v0.0.0-20180228185332-28bcc343414c/go.mod h1:0PRwlb0D6DFvNNtx+9ybjezNCa8XF0xaYcETyp6rHWU= +git.apache.org/thrift.git v0.0.0-20180902110319-2566ecd5d999/go.mod h1:fPE2ZNJGynbRyZ4dJvy6G277gSllfV2HJqblrnkyeyg= +github.com/BurntSushi/toml v0.3.0 h1:e1/Ivsx3Z0FVTV0NSOv/aVgbUWyQuzj7DDnFblkRvsY= github.com/BurntSushi/toml v0.3.0/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= @@ -7,18 +15,23 @@ github.com/Masterminds/semver v1.4.2/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF0 github.com/Southclaws/configor v1.0.0 h1:0bt6XsYs0q3GlK1gOqdjoM4VJj9ePdd/GcNNjzH567A= github.com/Southclaws/configor v1.0.0/go.mod h1:LVoYKxkifbFIINnnXwmqeiH4ciRalQNDMwQETyFomTs= github.com/ajg/form v0.0.0-20160822230020-523a5da1a92f/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= +github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/bradfitz/go-smtpd v0.0.0-20170404230938-deb6d6237625/go.mod h1:HYsPBTaaSFSlLx/70C2HPIMNZpVV8+vt/A+FMnYP11g= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= github.com/cockroachdb/cockroach-go v0.0.0-20181001143604-e0a95dfd547c/go.mod h1:XGLbWH/ujMcbPbhZq52Nv6UrCghb1yGn//133kEsvDk= github.com/codegangsta/negroni v1.0.0/go.mod h1:v0y3T5G7Y1UlFfyxFn/QLRU4a2EuNau2iZY63YTKWo0= github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-systemd v0.0.0-20181012123002-c6f51f82210d/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/denisenkom/go-mssqldb v0.0.0-20190111225525-2fea367d496d h1:M0bjbJ5PZPl4iKkt0FSvhfSCJI9NisDDda29jXN9i0c= -github.com/denisenkom/go-mssqldb v0.0.0-20190111225525-2fea367d496d/go.mod h1:xN/JuLBIz4bjkxNmByTiV1IbhfnYb6oo99phBn4Eqhc= +github.com/denisenkom/go-mssqldb v0.0.0-20190121005146-b04fd42d9952 h1:b5OnbZD49x9g+/FcYbs/vukEt8C/jUbGhCJ3uduQmu8= +github.com/denisenkom/go-mssqldb v0.0.0-20190121005146-b04fd42d9952/go.mod h1:xN/JuLBIz4bjkxNmByTiV1IbhfnYb6oo99phBn4Eqhc= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/dustin/go-humanize v0.0.0-20180713052910-9f541cc9db5d/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= @@ -27,13 +40,16 @@ github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/structs v1.0.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= +github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/gin-contrib/sse v0.0.0-20170109093832-22d885f9ecc7 h1:AzN37oI0cOS+cougNAV9szl6CVoj2RYwzS3DpUQNtlY= github.com/gin-contrib/sse v0.0.0-20170109093832-22d885f9ecc7/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s= github.com/gin-gonic/gin v1.3.0 h1:kCmZyPklC0gVdL728E6Aj20uYBJV93nj/TkwBTKhFbs= github.com/gin-gonic/gin v1.3.0/go.mod h1:7cKuhb5qV2ggCFctp2fJQ+ErvciLZrIeoOSOm6mUr7Y= +github.com/gliderlabs/ssh v0.1.1/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0= github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA= github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= @@ -52,6 +68,9 @@ github.com/gobuffalo/buffalo-plugins v1.6.9/go.mod h1:yYlYTrPdMCz+6/+UaXg5Jm4gN3 github.com/gobuffalo/buffalo-plugins v1.6.11/go.mod h1:eAA6xJIL8OuynJZ8amXjRmHND6YiusVAaJdHDN1Lu8Q= github.com/gobuffalo/buffalo-plugins v1.8.2/go.mod h1:9te6/VjEQ7pKp7lXlDIMqzxgGpjlKoAcAANdCgoR960= github.com/gobuffalo/buffalo-plugins v1.8.3/go.mod h1:IAWq6vjZJVXebIq2qGTLOdlXzmpyTZ5iJG5b59fza5U= +github.com/gobuffalo/buffalo-plugins v1.9.4/go.mod h1:grCV6DGsQlVzQwk6XdgcL3ZPgLm9BVxlBmXPMF8oBHI= +github.com/gobuffalo/buffalo-plugins v1.10.0/go.mod h1:4osg8d9s60txLuGwXnqH+RCjPHj9K466cDFRl3PErHI= +github.com/gobuffalo/buffalo-plugins v1.11.0/go.mod h1:rtIvAYRjYibgmWhnjKmo7OadtnxuMG5ZQLr25ozAzjg= github.com/gobuffalo/buffalo-pop v1.0.5/go.mod h1:Fw/LfFDnSmB/vvQXPvcXEjzP98Tc+AudyNWUBWKCwQ8= github.com/gobuffalo/envy v1.6.4/go.mod h1:Abh+Jfw475/NWtYMEt+hnJWRiC8INKWibIMyNt1w2Mc= github.com/gobuffalo/envy v1.6.5/go.mod h1:N+GkhhZ/93bGZc6ZKhJLP6+m+tCNPKwgSpH9kaifseQ= @@ -60,8 +79,9 @@ github.com/gobuffalo/envy v1.6.7/go.mod h1:N+GkhhZ/93bGZc6ZKhJLP6+m+tCNPKwgSpH9k github.com/gobuffalo/envy v1.6.8/go.mod h1:N+GkhhZ/93bGZc6ZKhJLP6+m+tCNPKwgSpH9kaifseQ= github.com/gobuffalo/envy v1.6.9/go.mod h1:N+GkhhZ/93bGZc6ZKhJLP6+m+tCNPKwgSpH9kaifseQ= github.com/gobuffalo/envy v1.6.10/go.mod h1:X0CFllQjTV5ogsnUrg+Oks2yTI+PU2dGYBJOEI2D1Uo= -github.com/gobuffalo/envy v1.6.11 h1:dCKSFypLRvqaaUtyzkfKKF2j35ce5agsqfyIrRmm02E= github.com/gobuffalo/envy v1.6.11/go.mod h1:Fiq52W7nrHGDggFPhn2ZCcHw4u/rqXkqo+i7FB6EAcg= +github.com/gobuffalo/envy v1.6.12 h1:zkhss8DXz/pty2HAyA8BnvWMTYxo4gjd4+WCnYovoxY= +github.com/gobuffalo/envy v1.6.12/go.mod h1:qJNrJhKkZpEW0glh5xP2syQHH5kgdmgsKss2Kk8PTP0= github.com/gobuffalo/events v1.0.3/go.mod h1:Txo8WmqScapa7zimEQIwgiJBvMECMe9gJjsKNPN3uZw= github.com/gobuffalo/events v1.0.7/go.mod h1:z8txf6H9jWhQ5Scr7YPLWg/cgXBRj8Q4uYI+rsVCCSQ= github.com/gobuffalo/events v1.0.8/go.mod h1:A5KyqT1sA+3GJiBE4QKZibse9mtOcI9nw8gGrDdqYGs= @@ -70,6 +90,7 @@ github.com/gobuffalo/events v1.1.4/go.mod h1:09/YRRgZHEOts5Isov+g9X2xajxdvOAcUuA github.com/gobuffalo/events v1.1.5/go.mod h1:3YUSzgHfYctSjEjLCWbkXP6djH2M+MLaVRzb4ymbAK0= github.com/gobuffalo/events v1.1.7/go.mod h1:6fGqxH2ing5XMb3EYRq9LEkVlyPGs4oO/eLzh+S8CxY= github.com/gobuffalo/events v1.1.8/go.mod h1:UFy+W6X6VbCWS8k2iT81HYX65dMtiuVycMy04cplt/8= +github.com/gobuffalo/events v1.1.9/go.mod h1:/0nf8lMtP5TkgNbzYxR6Bl4GzBy5s5TebgNTdRfRbPM= github.com/gobuffalo/fizz v1.0.12/go.mod h1:C0sltPxpYK8Ftvf64kbsQa2yiCZY4RZviurNxXdAKwc= github.com/gobuffalo/flect v0.0.0-20180907193754-dc14d8acaf9f/go.mod h1:rCiQgmAE4axgBNl3jZWzS5rETRYTGOsrixTRaCPzNdA= github.com/gobuffalo/flect v0.0.0-20181002182613-4571df4b1daf/go.mod h1:rCiQgmAE4axgBNl3jZWzS5rETRYTGOsrixTRaCPzNdA= @@ -79,6 +100,9 @@ github.com/gobuffalo/flect v0.0.0-20181019110701-3d6f0b585514/go.mod h1:rCiQgmAE github.com/gobuffalo/flect v0.0.0-20181024204909-8f6be1a8c6c2/go.mod h1:rCiQgmAE4axgBNl3jZWzS5rETRYTGOsrixTRaCPzNdA= github.com/gobuffalo/flect v0.0.0-20181104133451-1f6e9779237a/go.mod h1:rCiQgmAE4axgBNl3jZWzS5rETRYTGOsrixTRaCPzNdA= github.com/gobuffalo/flect v0.0.0-20181114183036-47375f6d8328/go.mod h1:0HvNbHdfh+WOvDSIASqJOSxTOWSxCCUF++k/Y53v9rI= +github.com/gobuffalo/flect v0.0.0-20181210151238-24a2b68e0316/go.mod h1:en58vff74S9b99Eg42Dr+/9yPu437QjlNsO/hBYPuOk= +github.com/gobuffalo/flect v0.0.0-20190104192022-4af577e09bf2/go.mod h1:en58vff74S9b99Eg42Dr+/9yPu437QjlNsO/hBYPuOk= +github.com/gobuffalo/flect v0.0.0-20190117212819-a62e61d96794/go.mod h1:397QT6v05LkZkn07oJXXT6y9FCfwC8Pug0WA2/2mE9k= github.com/gobuffalo/genny v0.0.0-20180924032338-7af3a40f2252/go.mod h1:tUTQOogrr7tAQnhajMSH6rv1BVev34H2sa1xNHMy94g= github.com/gobuffalo/genny v0.0.0-20181003150629-3786a0744c5d/go.mod h1:WAd8HmjMVrnkAZbmfgH5dLBUchsZfqzp/WS5sQz+uTM= github.com/gobuffalo/genny v0.0.0-20181005145118-318a41a134cc/go.mod h1:WAd8HmjMVrnkAZbmfgH5dLBUchsZfqzp/WS5sQz+uTM= @@ -100,6 +124,8 @@ github.com/gobuffalo/genny v0.0.0-20181203201232-849d2c9534ea/go.mod h1:wpNSANu9 github.com/gobuffalo/genny v0.0.0-20181206121324-d6fb8a0dbe36/go.mod h1:wpNSANu9UErftfiaAlz1pDZclrYzLtO5lALifODyjuM= github.com/gobuffalo/genny v0.0.0-20181207164119-84844398a37d/go.mod h1:y0ysCHGGQf2T3vOhCrGHheYN54Y/REj0ayd0Suf4C/8= github.com/gobuffalo/genny v0.0.0-20181211165820-e26c8466f14d/go.mod h1:sHnK+ZSU4e2feXP3PA29ouij6PUEiN+RCwECjCTB3yM= +github.com/gobuffalo/genny v0.0.0-20190104222617-a71664fc38e7/go.mod h1:QPsQ1FnhEsiU8f+O0qKWXz2RE4TiDqLVChWkBuh1WaY= +github.com/gobuffalo/genny v0.0.0-20190112155932-f31a84fcacf5/go.mod h1:CIaHCrSIuJ4il6ka3Hub4DR4adDrGoXGEEt2FbBxoIo= github.com/gobuffalo/github_flavored_markdown v1.0.4/go.mod h1:uRowCdK+q8d/RF0Kt3/DSalaIXbb0De/dmTqMQdkQ4I= github.com/gobuffalo/github_flavored_markdown v1.0.5/go.mod h1:U0643QShPF+OF2tJvYNiYDLDGDuQmJZXsf/bHOJPsMY= github.com/gobuffalo/github_flavored_markdown v1.0.7/go.mod h1:w93Pd9Lz6LvyQXEG6DktTPHkOtCbr+arAD5mkwMzXLI= @@ -110,6 +136,7 @@ github.com/gobuffalo/licenser v0.0.0-20181027200154-58051a75da95/go.mod h1:Bzhaa github.com/gobuffalo/licenser v0.0.0-20181109171355-91a2a7aac9a7/go.mod h1:m+Ygox92pi9bdg+gVaycvqE8RVSjZp7mWw75+K5NPHk= github.com/gobuffalo/licenser v0.0.0-20181128165715-cc7305f8abed/go.mod h1:oU9F9UCE+AzI/MueCKZamsezGOOHfSirltllOVeRTAE= github.com/gobuffalo/licenser v0.0.0-20181203160806-fe900bbede07/go.mod h1:ph6VDNvOzt1CdfaWC+9XwcBnlSTBz2j49PBwum6RFaU= +github.com/gobuffalo/licenser v0.0.0-20181211173111-f8a311c51159/go.mod h1:ve/Ue99DRuvnTaLq2zKa6F4KtHiYf7W046tDjuGYPfM= github.com/gobuffalo/logger v0.0.0-20181022175615-46cfb361fc27/go.mod h1:8sQkgyhWipz1mIctHF4jTxmJh1Vxhp7mP8IqbljgJZo= github.com/gobuffalo/logger v0.0.0-20181027144941-73d08d2bb969/go.mod h1:7uGg2duHKpWnN4+YmyKBdLXfhopkAdVM6H3nKbyFbz8= github.com/gobuffalo/logger v0.0.0-20181027193913-9cf4dd0efe46/go.mod h1:7uGg2duHKpWnN4+YmyKBdLXfhopkAdVM6H3nKbyFbz8= @@ -124,6 +151,7 @@ github.com/gobuffalo/meta v0.0.0-20181018192820-8c6cef77dab3/go.mod h1:E94EPzx9N github.com/gobuffalo/meta v0.0.0-20181025145500-3a985a084b0a/go.mod h1:YDAKBud2FP7NZdruCSlmTmDOZbVSa6bpK7LJ/A/nlKg= github.com/gobuffalo/meta v0.0.0-20181114191255-b130ebedd2f7/go.mod h1:K6cRZ29ozr4Btvsqkjvg5nDFTLOgTqf03KA70Ks0ypE= github.com/gobuffalo/meta v0.0.0-20181127070345-0d7e59dd540b/go.mod h1:RLO7tMvE0IAKAM8wny1aN12pvEKn7EtkBLkUZR00Qf8= +github.com/gobuffalo/meta v0.0.0-20190120163247-50bbb1fa260d/go.mod h1:KKsH44nIK2gA8p0PJmRT9GvWJUdphkDUA8AJEvFWiqM= github.com/gobuffalo/mw-basicauth v1.0.3/go.mod h1:dg7+ilMZOKnQFHDefUzUHufNyTswVUviCBgF244C1+0= github.com/gobuffalo/mw-contenttype v0.0.0-20180802152300-74f5a47f4d56/go.mod h1:7EvcmzBbeCvFtQm5GqF9ys6QnCxz2UM1x0moiWLq1No= github.com/gobuffalo/mw-csrf v0.0.0-20180802151833-446ff26e108b/go.mod h1:sbGtb8DmDZuDUQoxjr8hG1ZbLtZboD9xsn6p77ppcHo= @@ -148,14 +176,16 @@ github.com/gobuffalo/packr v1.15.1/go.mod h1:IeqicJ7jm8182yrVmNbM6PR4g79SjN9tZLH github.com/gobuffalo/packr v1.19.0/go.mod h1:MstrNkfCQhd5o+Ct4IJ0skWlxN8emOq8DsoT1G98VIU= github.com/gobuffalo/packr v1.20.0/go.mod h1:JDytk1t2gP+my1ig7iI4NcVaXr886+N0ecUga6884zw= github.com/gobuffalo/packr v1.21.0/go.mod h1:H00jGfj1qFKxscFJSw8wcL4hpQtPe1PfU2wa6sg/SR0= -github.com/gobuffalo/packr v1.21.9 h1:zBaEhCmJpYy/UdHGAGIC3vO5Uh7RW091le41+Ydcg4E= -github.com/gobuffalo/packr v1.21.9/go.mod h1:GC76q6nMzRtR+AEN/VV4w0z2/4q7SOaEmXh3Ooa8sOE= +github.com/gobuffalo/packr v1.22.0 h1:/YVd/GRGsu0QuoCJtlcWSVllobs4q3Xvx3nqxTvPyN0= +github.com/gobuffalo/packr v1.22.0/go.mod h1:Qr3Wtxr3+HuQEwWqlLnNW4t1oTvK+7Gc/Rnoi/lDFvA= github.com/gobuffalo/packr/v2 v2.0.0-rc.8/go.mod h1:y60QCdzwuMwO2R49fdQhsjCPv7tLQFR0ayzxxla9zes= github.com/gobuffalo/packr/v2 v2.0.0-rc.9/go.mod h1:fQqADRfZpEsgkc7c/K7aMew3n4aF1Kji7+lIZeR98Fc= github.com/gobuffalo/packr/v2 v2.0.0-rc.10/go.mod h1:4CWWn4I5T3v4c1OsJ55HbHlUEKNWMITG5iIkdr4Px4w= github.com/gobuffalo/packr/v2 v2.0.0-rc.11/go.mod h1:JoieH/3h3U4UmatmV93QmqyPUdf4wVM9HELaHEu+3fk= github.com/gobuffalo/packr/v2 v2.0.0-rc.12/go.mod h1:FV1zZTsVFi1DSCboO36Xgs4pzCZBjB/tDV9Cz/lSaR8= github.com/gobuffalo/packr/v2 v2.0.0-rc.13/go.mod h1:2Mp7GhBFMdJlOK8vGfl7SYtfMP3+5roE39ejlfjw0rA= +github.com/gobuffalo/packr/v2 v2.0.0-rc.14/go.mod h1:06otbrNvDKO1eNQ3b8hst+1010UooI2MFg+B2Ze4MV8= +github.com/gobuffalo/packr/v2 v2.0.0-rc.15/go.mod h1:IMe7H2nJvcKXSF90y4X1rjYIRlNMJYCxEhssBXNZwWs= github.com/gobuffalo/plush v3.7.16+incompatible/go.mod h1:rQ4zdtUUyZNqULlc6bqd5scsPfLKfT0+TGMChgduDvI= github.com/gobuffalo/plush v3.7.20+incompatible/go.mod h1:rQ4zdtUUyZNqULlc6bqd5scsPfLKfT0+TGMChgduDvI= github.com/gobuffalo/plush v3.7.21+incompatible/go.mod h1:rQ4zdtUUyZNqULlc6bqd5scsPfLKfT0+TGMChgduDvI= @@ -167,6 +197,7 @@ github.com/gobuffalo/plush v3.7.32+incompatible/go.mod h1:rQ4zdtUUyZNqULlc6bqd5s github.com/gobuffalo/plushgen v0.0.0-20181128164830-d29dcb966cb2/go.mod h1:r9QwptTFnuvSaSRjpSp4S2/4e2D3tJhARYbvEBcKSb4= github.com/gobuffalo/plushgen v0.0.0-20181203163832-9fc4964505c2/go.mod h1:opEdT33AA2HdrIwK1aibqnTJDVVKXC02Bar/GT1YRVs= github.com/gobuffalo/plushgen v0.0.0-20181207152837-eedb135bd51b/go.mod h1:Lcw7HQbEVm09sAQrCLzIxuhFbB3nAgp4c55E+UlynR0= +github.com/gobuffalo/plushgen v0.0.0-20190104222512-177cd2b872b3/go.mod h1:tYxCozi8X62bpZyKXYHw1ncx2ZtT2nFvG42kuLwYjoc= github.com/gobuffalo/pop v4.8.2+incompatible/go.mod h1:DwBz3SD5SsHpTZiTubcsFWcVDpJWGsxjVjMPnkiThWg= github.com/gobuffalo/pop v4.8.3+incompatible/go.mod h1:DwBz3SD5SsHpTZiTubcsFWcVDpJWGsxjVjMPnkiThWg= github.com/gobuffalo/pop v4.8.4+incompatible/go.mod h1:DwBz3SD5SsHpTZiTubcsFWcVDpJWGsxjVjMPnkiThWg= @@ -180,25 +211,41 @@ github.com/gobuffalo/release v1.0.61/go.mod h1:mfIO38ujUNVDlBziIYqXquYfBF+8FDHUj github.com/gobuffalo/release v1.0.72/go.mod h1:NP5NXgg/IX3M5XmHmWR99D687/3Dt9qZtTK/Lbwc1hU= github.com/gobuffalo/release v1.1.1/go.mod h1:Sluak1Xd6kcp6snkluR1jeXAogdJZpFFRzTYRs/2uwg= github.com/gobuffalo/release v1.1.3/go.mod h1:CuXc5/m+4zuq8idoDt1l4va0AXAn/OSs08uHOfMVr8E= +github.com/gobuffalo/release v1.1.6/go.mod h1:18naWa3kBsqO0cItXZNJuefCKOENpbbUIqRL1g+p6z0= github.com/gobuffalo/shoulders v1.0.1/go.mod h1:V33CcVmaQ4gRUmHKwq1fiTXuf8Gp/qjQBUL5tHPmvbA= github.com/gobuffalo/syncx v0.0.0-20181120191700-98333ab04150/go.mod h1:HhnNqWY95UYwwW3uSASeV7vtgYkT2t16hJgV3AEPUpw= github.com/gobuffalo/syncx v0.0.0-20181120194010-558ac7de985f h1:S5EeH1reN93KR0L6TQvkRpu9YggCYXrUqFh1iEgvdC0= github.com/gobuffalo/syncx v0.0.0-20181120194010-558ac7de985f/go.mod h1:HhnNqWY95UYwwW3uSASeV7vtgYkT2t16hJgV3AEPUpw= github.com/gobuffalo/tags v2.0.11+incompatible/go.mod h1:9XmhOkyaB7UzvuY4UoZO4s67q8/xRMVJEaakauVQYeY= github.com/gobuffalo/tags v2.0.14+incompatible/go.mod h1:9XmhOkyaB7UzvuY4UoZO4s67q8/xRMVJEaakauVQYeY= +github.com/gobuffalo/tags v2.0.15+incompatible/go.mod h1:9XmhOkyaB7UzvuY4UoZO4s67q8/xRMVJEaakauVQYeY= github.com/gobuffalo/uuid v2.0.3+incompatible/go.mod h1:ErhIzkRhm0FtRuiE/PeORqcw4cVi1RtSpnwYrxuvkfE= github.com/gobuffalo/uuid v2.0.4+incompatible/go.mod h1:ErhIzkRhm0FtRuiE/PeORqcw4cVi1RtSpnwYrxuvkfE= github.com/gobuffalo/uuid v2.0.5+incompatible/go.mod h1:ErhIzkRhm0FtRuiE/PeORqcw4cVi1RtSpnwYrxuvkfE= github.com/gobuffalo/validate v2.0.3+incompatible/go.mod h1:N+EtDe0J8252BgfzQUChBgfd6L93m9weay53EWFVsMM= github.com/gobuffalo/x v0.0.0-20181003152136-452098b06085/go.mod h1:WevpGD+5YOreDJznWevcn8NTmQEW5STSBgIkpkjzqXc= github.com/gobuffalo/x v0.0.0-20181007152206-913e47c59ca7/go.mod h1:9rDPXaB3kXdKWzMc4odGQQdG2e2DIEmANy5aSJ9yesY= -github.com/gofrs/uuid v3.1.0+incompatible h1:q2rtkjaKT4YEr6E1kamy0Ha4RtepWlQBedyHx0uzKwA= github.com/gofrs/uuid v3.1.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/gofrs/uuid v3.2.0+incompatible h1:y12jRkkFxsd7GpqdSZ+/KCs/fJbqpEXSGd4+jfEaewE= +github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/protobuf v1.1.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/go-cmp v0.2.0 h1:+dTQ8DZQJz0Mb/HjFlkptS1FeQ4cWSnN941F8aEG4SQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ= +github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= +github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= +github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/googleapis/gax-go v2.0.0+incompatible/go.mod h1:SFVmujtThgffbyetf+mdk2eWhX2bMyUtNHzFKcPA9HY= +github.com/googleapis/gax-go/v2 v2.0.3/go.mod h1:LLvjysVCY1JZeum8Z6l8qUty8fiNwE08qbEPm1M08qg= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/pat v0.0.0-20180118222023-199c85a7f6d1/go.mod h1:YeAe0gNeiNT5hoiZRI4yiOky6jVdNvfO2N6Kav/HmxY= @@ -211,13 +258,18 @@ github.com/gotify/configor v1.0.2-0.20190112111140-7d9c7c7e6233 h1:ZFYA2/LqyzFP2 github.com/gotify/configor v1.0.2-0.20190112111140-7d9c7c7e6233/go.mod h1:Sclq5yNfX/DJHu0TR2k0+Hi34YxsuKTacgrY3z83HoU= github.com/gotify/location v0.0.0-20170722210143-03bc4ad20437 h1:4qMhogAexRcnvdoY9O1RoCuuuNEhDF25jtbGIWPtcms= github.com/gotify/location v0.0.0-20170722210143-03bc4ad20437/go.mod h1:5JgfyQg+71Ck3uXX/4FBHc4YxdKZ9shU8gs2AUj7Nj0= -github.com/h2non/filetype v1.0.5 h1:Esu2EFM5vrzNynnGQpj0nxhCkzVQh2HRY7AXUh/dyJM= -github.com/h2non/filetype v1.0.5/go.mod h1:isekKqOuhMj+s/7r3rIeTErIRy4Rub5uBWHfvMusLMU= +github.com/gotify/plugin-api v1.0.0 h1:kab40p2TEPLzjmcafOc7JOz75aTsYQyS2PXtElH8xmI= +github.com/gotify/plugin-api v1.0.0/go.mod h1:xZfEyqVK/Zvu3RwA/CtpuiwFmzFDxifrrqMaH9BHnyU= +github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= +github.com/grpc-ecosystem/grpc-gateway v1.5.0/go.mod h1:RSKVYQBd5MCa4OVpNdGskqpgL2+G+NZTnrVHpWWfpdw= +github.com/h2non/filetype v1.0.6 h1:g84/+gdkAT1hnYO+tHpCLoikm13Ju55OkN4KCb1uGEQ= +github.com/h2non/filetype v1.0.6/go.mod h1:isekKqOuhMj+s/7r3rIeTErIRy4Rub5uBWHfvMusLMU= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/jackc/fake v0.0.0-20150926172116-812a484cc733/go.mod h1:WrMFNQdiFJ80sQsxDoMokWK1W5TQtxBFNpzWTD84ibQ= github.com/jackc/pgx v3.2.0+incompatible/go.mod h1:0ZGrqGqkRlliWnWB4zKnWtjbSWbGkVEFm4TeybAXq+I= +github.com/jellevandenhooff/dkim v0.0.0-20150330215556-f50fe3d243e1/go.mod h1:E0B/fFc00Y+Rasa88328GlI/XbtyysCtTHZS8h7IrBU= github.com/jinzhu/gorm v1.9.2 h1:lCvgEaqe/HVE+tjAR2mt4HbbHAZsQOv3XAZiEZV37iw= github.com/jinzhu/gorm v1.9.2/go.mod h1:Vla75njaFJ8clLU1W44h34PjIkijhjHIYnZxMqCdxqo= github.com/jinzhu/inflection v0.0.0-20180308033659-04140366298a h1:eeaG9XMUvRBYXJi4pg1ZKM7nxc5AfXfojeLLW7O5J3k= @@ -232,7 +284,9 @@ github.com/json-iterator/go v1.1.5 h1:gL2yXlmiIo4+t+y32d4WGwOjKGYcGOuyrg46vadswD github.com/json-iterator/go v1.1.5/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/karrick/godirwalk v1.7.5/go.mod h1:2c9FRhkDxdIbgkOnCEvnSWs71Bhugbl46shStcFDJ34= github.com/karrick/godirwalk v1.7.7/go.mod h1:2c9FRhkDxdIbgkOnCEvnSWs71Bhugbl46shStcFDJ34= +github.com/karrick/godirwalk v1.7.8/go.mod h1:2c9FRhkDxdIbgkOnCEvnSWs71Bhugbl46shStcFDJ34= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/konsorten/go-windows-terminal-sequences v0.0.0-20180402223658-b729f2633dfe/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= @@ -256,7 +310,6 @@ github.com/markbates/inflect v1.0.4/go.mod h1:1fR9+pO2KHEO9ZRtto13gDwwZaAKstQzfe github.com/markbates/oncer v0.0.0-20180924031910-e862a676800b/go.mod h1:Ld9puTsIW75CHf65OeIOkyKbteujpZVXDpWK6YGZbxE= github.com/markbates/oncer v0.0.0-20180924034138-723ad0170a46/go.mod h1:Ld9puTsIW75CHf65OeIOkyKbteujpZVXDpWK6YGZbxE= github.com/markbates/oncer v0.0.0-20181014194634-05fccaae8fc4/go.mod h1:Ld9puTsIW75CHf65OeIOkyKbteujpZVXDpWK6YGZbxE= -github.com/markbates/oncer v0.0.0-20181203154359-bf2de49a0be2 h1:JgVTCPf0uBVcUSWpyXmGpgOc62nK5HWUBKAGc3Qqa5k= github.com/markbates/oncer v0.0.0-20181203154359-bf2de49a0be2/go.mod h1:Ld9puTsIW75CHf65OeIOkyKbteujpZVXDpWK6YGZbxE= github.com/markbates/refresh v1.4.10/go.mod h1:NDPHvotuZmTmesXxr95C9bjlw1/0frJwtME2dzcVKhc= github.com/markbates/safe v1.0.0/go.mod h1:nAqgmRi7cY2nqMc92/bSEeQA+R4OheNU2T1kNSCBdG0= @@ -269,7 +322,9 @@ github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNx github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= github.com/mattn/go-sqlite3 v1.10.0 h1:jbhqpg7tQe4SupckyijYiy0mJJ/pRyHvXf7JdWK860o= github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/microcosm-cc/bluemonday v1.0.1/go.mod h1:hsXNsILzKxV+sX77C5b8FSuKF00vh2OMYv+xgHpAMF4= +github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc= github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/mapstructure v1.0.0/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= @@ -278,37 +333,69 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/monoculum/formam v0.0.0-20180901015400-4e68be1d79ba/go.mod h1:RKgILGEJq24YyJ2ban8EO0RUVSJlF1pGsEvoLEACr/Q= +github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJEU3ofeGjhHklVoIGuVj85JJwZ6kWPaJwCIxgnFmo= +github.com/neelance/sourcemap v0.0.0-20151028013722-8c68805598ab/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM= github.com/nicksnyder/go-i18n v1.10.0/go.mod h1:HrK7VCrbOvQoUAQ7Vpy7i87N7JZZZ7R2xBGjv0j365Q= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/gomega v1.4.1/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= github.com/onsi/gomega v1.4.2/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/openzipkin/zipkin-go v0.1.1/go.mod h1:NtoC/o8u3JlF1lSlyPNswIbeQH9bJTmOf0Erfk+hxe8= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= +github.com/pkg/errors v0.8.0 h1:WdK/asTD0HN+q6hsWO3/vpuAkAr+tw6aNJNDFFf0+qw= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.0.0 h1:o4VLZ5jqHE+HahLT6drNtSGTrrUA3wPBmtpgqtdbClo= +github.com/prometheus/client_golang v0.8.0/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/common v0.0.0-20180801064454-c7de2306084e/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= +github.com/prometheus/procfs v0.0.0-20180725123919-05ee40e3a273/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/robfig/cron v0.0.0-20180505203441-b41be1df6967 h1:x7xEyJDP7Hv3LVgvWhzioQqbC/KtuUhTigKlH/8ehhE= +github.com/robfig/cron v0.0.0-20180505203441-b41be1df6967/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k= github.com/rogpeppe/go-internal v1.0.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.1.0 h1:g0fH8RicVgNl+zVZDCDfbdWxAWoAEJyI7I3TZYXFiig= +github.com/rogpeppe/go-internal v1.1.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= github.com/serenize/snaker v0.0.0-20171204205717-a683aaf2d516/go.mod h1:Yow6lPLSAXx2ifx470yD/nUe22Dv5vBvxK/UK9UUTVs= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= +github.com/shurcooL/component v0.0.0-20170202220835-f88ec8f54cc4/go.mod h1:XhFIlyj5a1fBNx5aJTbKoIq0mNaPvOagO+HjB3EtxrY= +github.com/shurcooL/events v0.0.0-20181021180414-410e4ca65f48/go.mod h1:5u70Mqkb5O5cxEA8nxTsgrgLehJeAw6Oc4Ab1c/P1HM= +github.com/shurcooL/github_flavored_markdown v0.0.0-20181002035957-2122de532470/go.mod h1:2dOwnU2uBioM+SGy2aZoq1f/Sd1l9OkAeAUvjSyvgU0= github.com/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk= github.com/shurcooL/go-goon v0.0.0-20170922171312-37c2f522c041/go.mod h1:N5mDOmsrJOB+vfqUK+7DmDyjhSLIIBnXo9lvZJj3MWQ= +github.com/shurcooL/gofontwoff v0.0.0-20180329035133-29b52fc0a18d/go.mod h1:05UtEgK5zq39gLST6uB0cf3NEHjETfB4Fgr3Gx5R9Vw= +github.com/shurcooL/gopherjslib v0.0.0-20160914041154-feb6d3990c2c/go.mod h1:8d3azKNyqcHP1GaQE/c6dDgjkgSx2BZ4IoEi4F1reUI= github.com/shurcooL/highlight_diff v0.0.0-20170515013008-09bb4053de1b/go.mod h1:ZpfEhSmds4ytuByIcDnOLkTHGUI6KNqRNPDLHDk+mUU= github.com/shurcooL/highlight_go v0.0.0-20170515013102-78fb10f4a5f8/go.mod h1:UDKB5a1T23gOMUJrI+uSuH0VRDStOiUVSjBTRDVBVag= +github.com/shurcooL/highlight_go v0.0.0-20181028180052-98c3abbbae20/go.mod h1:UDKB5a1T23gOMUJrI+uSuH0VRDStOiUVSjBTRDVBVag= +github.com/shurcooL/home v0.0.0-20181020052607-80b7ffcb30f9/go.mod h1:+rgNQw2P9ARFAs37qieuu7ohDNQ3gds9msbT2yn85sg= +github.com/shurcooL/htmlg v0.0.0-20170918183704-d01228ac9e50/go.mod h1:zPn1wHpTIePGnXSHpsVPWEktKXHr6+SS6x/IKRb7cpw= +github.com/shurcooL/httperror v0.0.0-20170206035902-86b7830d14cc/go.mod h1:aYMfkZ6DWSJPJ6c4Wwz3QtW22G7mf/PEgaB9k/ik5+Y= +github.com/shurcooL/httpfs v0.0.0-20171119174359-809beceb2371/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg= +github.com/shurcooL/httpgzip v0.0.0-20180522190206-b1c53ac65af9/go.mod h1:919LwcH0M7/W4fcZ0/jy0qGght1GIhqyS/EgWGH2j5Q= +github.com/shurcooL/issues v0.0.0-20181008053335-6292fdc1e191/go.mod h1:e2qWDig5bLteJ4fwvDAc2NHzqFEthkqn7aOZAOpj+PQ= +github.com/shurcooL/issuesapp v0.0.0-20180602232740-048589ce2241/go.mod h1:NPpHK2TI7iSaM0buivtFUc9offApnI0Alt/K8hcHy0I= +github.com/shurcooL/notifications v0.0.0-20181007000457-627ab5aea122/go.mod h1:b5uSkrEVM1jQUspwbixRBhaIjIzL2xazXp6kntxYle0= github.com/shurcooL/octicon v0.0.0-20180602230221-c42b0e3b24d9/go.mod h1:eWdoE5JD4R5UVWDucdOPg1g2fqQRq78IQa9zlOV1vpQ= +github.com/shurcooL/octicon v0.0.0-20181028054416-fa4f57f9efb2/go.mod h1:eWdoE5JD4R5UVWDucdOPg1g2fqQRq78IQa9zlOV1vpQ= +github.com/shurcooL/reactions v0.0.0-20181006231557-f2e0b4ca5b82/go.mod h1:TCR1lToEk4d2s07G3XGfz2QrgHXg4RJBvjrOozvoWfk= github.com/shurcooL/sanitized_anchor_name v0.0.0-20170918181015-86672fcb3f95/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/shurcooL/users v0.0.0-20180125191416-49c67e49c537/go.mod h1:QJTqeLYEDaXHZDBsXlPCDqdhQuJkuw4NOtaxYe3xii4= +github.com/shurcooL/webdavfs v0.0.0-20170829043945-18c3829fa133/go.mod h1:hKmq5kWdCj2z2KEozexVbfEZIWiTjhE0+UjmZgPqehw= github.com/sirupsen/logrus v1.0.6/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc= github.com/sirupsen/logrus v1.1.0/go.mod h1:zrgwTnHtNr00buQ1vSptGe8m1f/BbgsPukg8qsT7A+A= github.com/sirupsen/logrus v1.1.1/go.mod h1:zrgwTnHtNr00buQ1vSptGe8m1f/BbgsPukg8qsT7A+A= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.3.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d/go.mod h1:UdhH50NIW0fCiwBSr0co2m7BnFLdv4fQTgdqdJTHFeE= github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e/go.mod h1:HuIsMU8RRBOtsCgI77wP899iHVBQpCmg4ErYMZB+2IA= github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= +github.com/spf13/afero v1.2.0/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= github.com/spf13/cast v1.2.0/go.mod h1:r2rcYCSwa1IExKTDiTfzaxqT2FNHs8hODu4LnUfgKEg= github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= @@ -322,11 +409,16 @@ github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8 h1:3SVOIvH7Ae1KRYyQWRjXWJEA9sS/c/pjvH++55Gr648= +github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA= github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= +github.com/ugorji/go/codec v0.0.0-20181209151446-772ced7fd4c2 h1:EICbibRW4JNKMcY+LsWmuwob+CRS1BmdRdjphAm9mH4= +github.com/ugorji/go/codec v0.0.0-20181209151446-772ced7fd4c2/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= github.com/unrolled/secure v0.0.0-20180918153822-f340ee86eb8b/go.mod h1:mnPT77IAdsi/kV7+Es7y+pXALeV3h7G6dQF6mNYjcLA= github.com/unrolled/secure v0.0.0-20181005190816-ff9db2ff917f/go.mod h1:mnPT77IAdsi/kV7+Es7y+pXALeV3h7G6dQF6mNYjcLA= github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= +go.opencensus.io v0.18.0/go.mod h1:vKdFvxhtzZ9onBp9VKHK8z/sRpBMnKAsufL7wlDrCOA= +go4.org v0.0.0-20180809161055-417644f6feb5/go.mod h1:MkTOUMDaeVYJUOUsaDXIhWPZYa1yOyC1qaOBpL57BhE= +golang.org/x/build v0.0.0-20190111050920-041ab4dc3f9d/go.mod h1:OWs+y06UdEOHN4y+MfF/py+xQ/tYqIWW03b70/CG9Rw= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20180910181607-0e37d006457b/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20181001203147-e3636079e1a4/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= @@ -335,14 +427,20 @@ golang.org/x/crypto v0.0.0-20181015023909-0c41d7ab0a0e/go.mod h1:6SG95UA2DQfeDnf golang.org/x/crypto v0.0.0-20181024171144-74cb1d3d52f4/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20181025113841-85e1b3f9139a/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20181025213731-e84da0312774/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20181106171534-e4dc69e5b2fd/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20181112202954-3d3f9f413869/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20181127143415-eb0de9b17e85/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20190103213133-ff983b9c42bc h1:F5tKCVGp+MUAHhKp5MZtGqAlGX3+oCsiL1Q629FL90M= +golang.org/x/crypto v0.0.0-20190102171810-8d7daa0c54b3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190103213133-ff983b9c42bc/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190131182504-b8fe1690c613 h1:MQ/ZZiDsUapFFiMS+vzwXkCTeEKaum+Do5rINYJDmxc= +golang.org/x/crypto v0.0.0-20190131182504-b8fe1690c613/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180816102801-aaf60122140d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180921000356-2f5d2388922f/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180926154720-4dfa2610cdf3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -350,15 +448,24 @@ golang.org/x/net v0.0.0-20181005035420-146acd28ed58/go.mod h1:mL1N/T3taQHkDXs73r golang.org/x/net v0.0.0-20181011144130-49bb7cea24b1/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181017193950-04a2e542c03f/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181029044818-c44066c5c816/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181102091132-c10e9556a7bc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181106065722-10aee1819953/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181207154023-610586996380 h1:zPQexyRtNYBc7bcHmehl1dH6TB3qn8zytv8cBGLDNY0= golang.org/x/net v0.0.0-20181207154023-610586996380/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190110200230-915654e7eabc h1:Yx9JGxI1SBhVLFjpAkWMaO1TF+xyqtHLjZpvQboJGiM= +golang.org/x/net v0.0.0-20190110200230-915654e7eabc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20181017192945-9dcd33a902f4/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20181203162652-d668ce993890/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/perf v0.0.0-20180704124530-6e6d33e29852/go.mod h1:JLpeXjPJfIyPr5TlbXLkXWLhP8nz10XfvxElABhCtcw= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181108010431-42b317875d0f h1:Bl/8QSvNqXvPGPGXa2z5xUTmV7VDcZyvRZ+QQXkXTZQ= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4 h1:YUO/7uOKsKeq9UokNS62b8FYywz3ker1l1vDZRCRefw= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180816055513-1c9583448a9c/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180906133057-8cf3aee42992/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -371,12 +478,20 @@ golang.org/x/sys v0.0.0-20181024145615-5cd93ef61a7c/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20181025063200-d989b31c8746/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181026064943-731415f00dce/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181029174526-d69651ed3497/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181106135930-3a76605856fd/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181206074257-70b957f3b65e h1:njOxP/wVblhCLIUhjHXf6X+dzTt5OQ3vMQo9mkOIKIo= golang.org/x/sys v0.0.0-20181206074257-70b957f3b65e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190102155601-82a175fd1598/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190109145017-48ac38b7c8cb/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190116161447-11f53e031339 h1:g/Jesu8+QLnA0CPzF3E1pURg0Byr7i6jLoX5sqjcAh0= +golang.org/x/sys v0.0.0-20190116161447-11f53e031339/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20181003024731-2f84ea8ef872/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20181006002542-f60d9635b16a/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -385,6 +500,7 @@ golang.org/x/tools v0.0.0-20181013182035-5e66757b835f/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20181017214349-06f26fdaaa28/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20181024171208-a2dc47679d30/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20181026183834-f60e5f99f081/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20181030000716-a0a13e073c7b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20181105230042-78dc5bac0cac/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20181107215632-34b416bd17b3/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20181114190951-94339b83286c/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -396,9 +512,26 @@ golang.org/x/tools v0.0.0-20181205224935-3576414c54a4/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20181206194817-bcd4e47d0288/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20181207183836-8bc39b988060/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20181212172921-837e80568c09/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190102213336-ca9055ed7d04/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190104182027-498d95493402/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190111214448-fc1d57b08d7b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190118193359-16909d206f00/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +google.golang.org/api v0.0.0-20180910000450-7ca32eb868bf/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= +google.golang.org/api v0.0.0-20181030000543-1d582fd0359e/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= +google.golang.org/api v0.1.0/go.mod h1:UGEZY7KEX120AnNLIHFMKIo4obdJhkp2tPbaPlQx13Y= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20180831171423-11092d34479b/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20181029155118-b69ba1387ce2/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20181202183823-bd91e49a0898/go.mod h1:7Ep/1NZk928CDR8SjdVbjWNpdIf6nzjE3BTgJDr2Atg= +google.golang.org/genproto v0.0.0-20190122154452-ba6ebe99b011/go.mod h1:7Ep/1NZk928CDR8SjdVbjWNpdIf6nzjE3BTgJDr2Atg= +google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= +google.golang.org/grpc v1.16.0/go.mod h1:0JHn/cJsOMiMfNA9+DeHDlAU7KAAB5GDlYFpa9MZMio= +google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= gopkg.in/airbrake/gobrake.v2 v2.0.9/go.mod h1:/h5ZAUhDkGaJfjzjKLSjv6zCL6O0LLBxU4K+aSYdM/U= gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -412,10 +545,14 @@ gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8 gopkg.in/go-playground/validator.v8 v8.18.2 h1:lFB4DoMU6B626w8ny76MV7VX6W2VHct2GVOI3xgiMrQ= gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y= gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw= -gopkg.in/h2non/filetype.v1 v1.0.5 h1:CC1jjJjoEhNVbMhXYalmGBhOBK2V70Q1N850wt/98/Y= -gopkg.in/h2non/filetype.v1 v1.0.5/go.mod h1:M0yem4rwSX5lLVrkEuRRp2/NinFMD5vgJ4DlAhZcfNo= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/mail.v2 v2.0.0-20180731213649-a0242b2233b4/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +grpc.go4.org v0.0.0-20170609214715-11d0a25b4919/go.mod h1:77eQGdRu53HpSqPFJFmuJdjuHRquDANNeA4x7B8WQ9o= +honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +sourcegraph.com/sourcegraph/go-diff v0.5.0/go.mod h1:kuch7UrkMzY0X+p9CRK03kfuPQ2zzQcaEFbx8wA8rck= +sourcegraph.com/sqs/pbtypes v0.0.0-20180604144634-d3ebe8f20ae4/go.mod h1:ketZ/q3QxT9HOBeFhu6RdvsftgpsbFHBF5Cas6cDKZ0= diff --git a/model/application.go b/model/application.go index e8c4e4c3c..4c74ec324 100644 --- a/model/application.go +++ b/model/application.go @@ -29,6 +29,12 @@ type Application struct { // required: true // example: Backup server for the interwebs Description string `form:"description" query:"description" json:"description"` + // Whether the application is an internal application. Internal applications should not be deleted. + // + // read only: true + // required: true + // example: false + Internal bool `form:"internal" query:"internal" json:"internal"` // The image of the application. // // read only: true diff --git a/model/pluginconf.go b/model/pluginconf.go new file mode 100644 index 000000000..22dc4ee43 --- /dev/null +++ b/model/pluginconf.go @@ -0,0 +1,69 @@ +package model + +// PluginConf holds information about the plugin +type PluginConf struct { + ID uint `gorm:"primary_key;AUTO_INCREMENT;index"` + UserID uint + ModulePath string + Token string `gorm:"unique_index"` + ApplicationID uint + Enabled bool + Config []byte + Storage []byte +} + +// PluginConfExternal Model +// +// Holds information about a plugin instance for one user. +// +// swagger:model PluginConf +type PluginConfExternal struct { + // The plugin id. + // + // read only: true + // required: true + // example: 25 + ID uint `json:"id"` + // The plugin name. + // + // read only: true + // required: true + // example: RSS poller + Name string `json:"name"` + // The user name. For login. + // + // required: true + // example: P1234 + Token string `binding:"required" json:"token" query:"token" form:"token"` + // The module path of the plugin. + // + // example: github.com/gotify/server/plugin/example/echo + // read only: true + // required: true + ModulePath string `json:"modulePath" form:"modulePath" query:"modulePath"` + // The author of the plugin. + // + // example: jmattheis + // read only: true + Author string `json:"author,omitempty" form:"author" query:"author"` + // The website of the plugin. + // + // example: gotify.net + // read only: true + Website string `json:"website,omitempty" form:"website" query:"website"` + // The license of the plugin. + // + // example: MIT + // read only: true + License string `json:"license,omitempty" form:"license" query:"license"` + // Whether the plugin instance is enabled. + // + // example: true + // required: true + Enabled bool `json:"enabled"` + // Capabilities the plugin provides + // + // example: ["webhook","display"] + // required: true + Capabilities []string `json:"capabilities"` +} diff --git a/model/user.go b/model/user.go index 2da823ee1..39cfc3e78 100644 --- a/model/user.go +++ b/model/user.go @@ -8,6 +8,7 @@ type User struct { Admin bool Applications []Application Clients []Client + Plugins []PluginConf } // UserExternal Model diff --git a/plugin/compat/instance.go b/plugin/compat/instance.go new file mode 100644 index 000000000..3cefda2e3 --- /dev/null +++ b/plugin/compat/instance.go @@ -0,0 +1,91 @@ +package compat + +import ( + "net/url" + + "github.com/gin-gonic/gin" +) + +// Capability is a capability the plugin provides +type Capability string + +const ( + // Messenger sends notifications + Messenger = Capability("messenger") + // Configurer are consigurables + Configurer = Capability("configurer") + // Storager stores data + Storager = Capability("storager") + // Webhooker registers webhooks + Webhooker = Capability("webhooker") + // Displayer displays instructions + Displayer = Capability("displayer") +) + +// PluginInstance is an encapsulation layer of plugin instances of different backends +type PluginInstance interface { + Enable() error + Disable() error + + // GetDisplay see Displayer + GetDisplay(location *url.URL) string + + // DefaultConfig see Configurer + DefaultConfig() interface{} + // ValidateAndSetConfig see Configurer + ValidateAndSetConfig(c interface{}) error + + // SetMessageHandler see Messenger#SetMessageHandler + SetMessageHandler(h MessageHandler) + + // RegisterWebhook see Webhooker#RegisterWebhook + RegisterWebhook(basePath string, mux *gin.RouterGroup) + + // SetStorageHandler see Storager#SetStorageHandler. + SetStorageHandler(handler StorageHandler) + + // Returns the supported modules, f.ex. storager + Supports() Capabilities +} + +// HasSupport tests a PluginInstance for a capability +func HasSupport(p PluginInstance, toCheck Capability) bool { + for _, module := range p.Supports() { + if module == toCheck { + return true + } + } + return false +} + +// Capabilities is a slice of module +type Capabilities []Capability + +// Strings converts []Module to []string +func (m Capabilities) Strings() []string { + var result []string + for _, module := range m { + result = append(result, string(module)) + } + return result +} + +// MessageHandler see plugin.MessageHandler. +type MessageHandler interface { + // SendMessage see plugin.MessageHandler + SendMessage(msg Message) error +} + +// StorageHandler see plugin.StorageHandler. +type StorageHandler interface { + Save(b []byte) error + Load() ([]byte, error) +} + +// Message describes a message to be send by MessageHandler#SendMessage. +type Message struct { + Message string + Title string + Priority int + Extras map[string]interface{} +} diff --git a/plugin/compat/plugin.go b/plugin/compat/plugin.go new file mode 100644 index 000000000..b5613f94d --- /dev/null +++ b/plugin/compat/plugin.go @@ -0,0 +1,33 @@ +package compat + +// Plugin is an abstraction of plugin handler +type Plugin interface { + PluginInfo() Info + NewPluginInstance(ctx UserContext) PluginInstance + APIVersion() string +} + +// Info is the plugin info +type Info struct { + Version string + Author string + Name string + Website string + Description string + License string + ModulePath string +} + +func (c Info) String() string { + if c.Name != "" { + return c.Name + } + return c.ModulePath +} + +// UserContext is the user context used to create plugin instance. +type UserContext struct { + ID uint + Name string + Admin bool +} diff --git a/plugin/compat/plugin_test.go b/plugin/compat/plugin_test.go new file mode 100644 index 000000000..255160707 --- /dev/null +++ b/plugin/compat/plugin_test.go @@ -0,0 +1,18 @@ +package compat + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +const examplePluginPath = "github.com/gotify/server/plugin/example/echo" + +func TestPluginInfoStringer(t *testing.T) { + info := Info{ + ModulePath: examplePluginPath, + } + assert.Equal(t, examplePluginPath, info.String()) + info.Name = "test name" + assert.Equal(t, "test name", info.String()) +} diff --git a/plugin/compat/v1.go b/plugin/compat/v1.go new file mode 100644 index 000000000..e8297718b --- /dev/null +++ b/plugin/compat/v1.go @@ -0,0 +1,183 @@ +package compat + +import ( + "net/url" + + "github.com/gin-gonic/gin" + papiv1 "github.com/gotify/plugin-api" +) + +// PluginV1 is an abstraction of a plugin written in the v1 plugin API. Exported for testing purposes only. +type PluginV1 struct { + Info papiv1.Info + Constructor func(ctx papiv1.UserContext) papiv1.Plugin +} + +// APIVersion returns the API version +func (c PluginV1) APIVersion() string { + return "v1" +} + +// PluginInfo implements conpat/Plugin +func (c PluginV1) PluginInfo() Info { + return Info{ + Version: c.Info.Version, + Author: c.Info.Author, + Name: c.Info.Name, + Website: c.Info.Website, + Description: c.Info.Description, + License: c.Info.License, + ModulePath: c.Info.ModulePath, + } +} + +// NewPluginInstance implements compat/Plugin +func (c PluginV1) NewPluginInstance(ctx UserContext) PluginInstance { + instance := c.Constructor(papiv1.UserContext{ + ID: ctx.ID, + Name: ctx.Name, + Admin: ctx.Admin, + }) + + compat := &PluginV1Instance{ + instance: instance, + } + + if displayer, ok := instance.(papiv1.Displayer); ok { + compat.displayer = displayer + } + + if messenger, ok := instance.(papiv1.Messenger); ok { + compat.messenger = messenger + } + + if configurer, ok := instance.(papiv1.Configurer); ok { + compat.configurer = configurer + } + + if storager, ok := instance.(papiv1.Storager); ok { + compat.storager = storager + } + + if webhooker, ok := instance.(papiv1.Webhooker); ok { + compat.webhooker = webhooker + } + + return compat +} + +// PluginV1Instance is an adapter for plugin using v1 API +type PluginV1Instance struct { + instance papiv1.Plugin + messenger papiv1.Messenger + configurer papiv1.Configurer + storager papiv1.Storager + webhooker papiv1.Webhooker + displayer papiv1.Displayer +} + +// DefaultConfig see papiv1.Configurer +func (c *PluginV1Instance) DefaultConfig() interface{} { + if c.configurer != nil { + return c.configurer.DefaultConfig() + } + return struct{}{} +} + +// ValidateAndSetConfig see papiv1.Configurer +func (c *PluginV1Instance) ValidateAndSetConfig(config interface{}) error { + if c.configurer != nil { + return c.configurer.ValidateAndSetConfig(config) + } + return nil +} + +// GetDisplay see papiv1.Displayer +func (c *PluginV1Instance) GetDisplay(location *url.URL) string { + if c.displayer != nil { + return c.displayer.GetDisplay(location) + } + return "" +} + +// SetMessageHandler see papiv1.Messenger +func (c *PluginV1Instance) SetMessageHandler(h MessageHandler) { + if c.messenger != nil { + c.messenger.SetMessageHandler(&PluginV1MessageHandler{WrapperHandler: h}) + } +} + +// RegisterWebhook see papiv1.Webhooker +func (c *PluginV1Instance) RegisterWebhook(basePath string, mux *gin.RouterGroup) { + if c.webhooker != nil { + c.webhooker.RegisterWebhook(basePath, mux) + } +} + +// SetStorageHandler see papiv1.Storager +func (c *PluginV1Instance) SetStorageHandler(handler StorageHandler) { + if c.storager != nil { + c.storager.SetStorageHandler(&PluginV1StorageHandler{WrapperHandler: handler}) + } +} + +// Supports returns a slice of capabilities the plugin instance provides +func (c *PluginV1Instance) Supports() Capabilities { + modules := Capabilities{} + if c.configurer != nil { + modules = append(modules, Configurer) + } + if c.displayer != nil { + modules = append(modules, Displayer) + } + if c.messenger != nil { + modules = append(modules, Messenger) + } + if c.storager != nil { + modules = append(modules, Storager) + } + if c.webhooker != nil { + modules = append(modules, Webhooker) + } + return modules +} + +// PluginV1MessageHandler is an adapter for messenger plugin handler using v1 API +type PluginV1MessageHandler struct { + WrapperHandler MessageHandler +} + +// SendMessage implements papiv1.MessageHandler +func (c *PluginV1MessageHandler) SendMessage(msg papiv1.Message) error { + return c.WrapperHandler.SendMessage(Message{ + Message: msg.Message, + Priority: msg.Priority, + Title: msg.Title, + Extras: msg.Extras, + }) +} + +// Enable implements wrapper.Plugin +func (c *PluginV1Instance) Enable() error { + return c.instance.Enable() +} + +// Disable implements wrapper.Plugin +func (c *PluginV1Instance) Disable() error { + return c.instance.Disable() +} + +// PluginV1StorageHandler is a wrapper for v1 storage handler +type PluginV1StorageHandler struct { + WrapperHandler StorageHandler +} + +// Save implements wrapper.Storager +func (c *PluginV1StorageHandler) Save(b []byte) error { + return c.WrapperHandler.Save(b) +} + +// Load implements wrapper.Storager +func (c *PluginV1StorageHandler) Load() ([]byte, error) { + return c.WrapperHandler.Load() +} diff --git a/plugin/compat/v1_test.go b/plugin/compat/v1_test.go new file mode 100644 index 000000000..67cb09baa --- /dev/null +++ b/plugin/compat/v1_test.go @@ -0,0 +1,160 @@ +package compat + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + + papiv1 "github.com/gotify/plugin-api" +) + +type v1MockInstance struct { + Enabled bool +} + +func (c *v1MockInstance) Enable() error { + c.Enabled = true + return nil +} + +func (c *v1MockInstance) Disable() error { + c.Enabled = false + return nil +} + +type V1WrapperSuite struct { + suite.Suite + i PluginV1Instance +} + +func (s *V1WrapperSuite) SetupSuite() { + inst := new(v1MockInstance) + s.i.instance = inst +} + +func (s *V1WrapperSuite) TestConfigurer_notSupported_expectEmpty() { + assert.Equal(s.T(), struct{}{}, s.i.DefaultConfig()) + assert.Nil(s.T(), s.i.ValidateAndSetConfig(struct{}{})) +} + +func (s *V1WrapperSuite) TestDisplayer_notSupported_expectEmpty() { + assert.Equal(s.T(), "", s.i.GetDisplay(nil)) +} + +type v1StorageHandler struct { + storage []byte +} + +func (c *v1StorageHandler) Save(b []byte) error { + c.storage = b + return nil +} + +func (c *v1StorageHandler) Load() ([]byte, error) { + return c.storage, nil +} + +type v1Storager struct { + handler papiv1.StorageHandler +} + +func (c *v1Storager) Enable() error { + return nil +} + +func (c *v1Storager) Disable() error { + return nil +} + +func (c *v1Storager) SetStorageHandler(h papiv1.StorageHandler) { + c.handler = h +} + +func (s *V1WrapperSuite) TestStorager() { + storager := new(v1Storager) + s.i.storager = storager + + s.i.SetStorageHandler(new(v1StorageHandler)) + + assert.Nil(s.T(), storager.handler.Save([]byte("test"))) + storage, err := storager.handler.Load() + assert.Nil(s.T(), err) + assert.Equal(s.T(), "test", string(storage)) +} + +type v1MessengerHandler struct { + msgSent Message +} + +func (c *v1MessengerHandler) SendMessage(msg Message) error { + c.msgSent = msg + return nil +} + +type v1Messenger struct { + handler papiv1.MessageHandler +} + +func (c *v1Messenger) Enable() error { + return nil +} + +func (c *v1Messenger) Disable() error { + return nil +} + +func (c *v1Messenger) SetMessageHandler(h papiv1.MessageHandler) { + c.handler = h +} + +func (s *V1WrapperSuite) TestMessenger_sendMessageWithExtras() { + messenger := new(v1Messenger) + s.i.messenger = messenger + + handler := new(v1MessengerHandler) + s.i.SetMessageHandler(handler) + + msg := papiv1.Message{ + Title: "test message", + Message: "test", + Priority: 2, + Extras: map[string]interface{}{ + "test::string": "test", + }, + } + assert.Nil(s.T(), messenger.handler.SendMessage(msg)) + assert.Equal(s.T(), Message{ + Title: "test message", + Message: "test", + Priority: 2, + Extras: map[string]interface{}{ + "test::string": "test", + }, + }, handler.msgSent) +} + +func (s *V1WrapperSuite) TestMessenger_sendMessageWithoutExtras() { + messenger := new(v1Messenger) + s.i.messenger = messenger + + handler := new(v1MessengerHandler) + s.i.SetMessageHandler(handler) + + msg := papiv1.Message{ + Title: "test message", + Message: "test", + Priority: 2, + Extras: nil, + } + assert.Nil(s.T(), messenger.handler.SendMessage(msg)) + assert.Equal(s.T(), Message{ + Title: "test message", + Message: "test", + Priority: 2, + Extras: nil, + }, handler.msgSent) +} +func TestV1Wrapper(t *testing.T) { + suite.Run(t, new(V1WrapperSuite)) +} diff --git a/plugin/compat/wrap.go b/plugin/compat/wrap.go new file mode 100644 index 000000000..f131d6bdf --- /dev/null +++ b/plugin/compat/wrap.go @@ -0,0 +1,35 @@ +package compat + +import ( + "errors" + "fmt" + "plugin" + + papiv1 "github.com/gotify/plugin-api" +) + +// Wrap wraps around a raw go plugin to provide typesafe access. +func Wrap(p *plugin.Plugin) (Plugin, error) { + getInfoHandle, err := p.Lookup("GetGotifyPluginInfo") + if err != nil { + return nil, errors.New("missing GetGotifyPluginInfo symbol") + } + switch getInfoHandle := getInfoHandle.(type) { + case func() papiv1.Info: + v1 := PluginV1{} + + v1.Info = getInfoHandle() + newInstanceHandle, err := p.Lookup("NewGotifyPluginInstance") + if err != nil { + return nil, errors.New("missing NewGotifyPluginInstance symbol") + } + constructor, ok := newInstanceHandle.(func(ctx papiv1.UserContext) papiv1.Plugin) + if !ok { + return nil, fmt.Errorf("NewGotifyPluginInstance signature mismatch, func(ctx plugin.UserContext) plugin.Plugin expected, got %T", newInstanceHandle) + } + v1.Constructor = constructor + return v1, nil + default: + return nil, fmt.Errorf("unknown plugin version (unrecogninzed GetGotifyPluginInfo signature %T)", getInfoHandle) + } +} diff --git a/plugin/compat/wrap_test.go b/plugin/compat/wrap_test.go new file mode 100644 index 000000000..9565d4120 --- /dev/null +++ b/plugin/compat/wrap_test.go @@ -0,0 +1,163 @@ +// +build linux darwin + +package compat + +import ( + "fmt" + "os" + "os/exec" + "path" + "plugin" + "testing" + + "github.com/gotify/server/test" + + "github.com/gin-gonic/gin" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" +) + +type CompatSuite struct { + suite.Suite + + p Plugin + tmpDir test.TmpDir +} + +func (s *CompatSuite) SetupSuite() { + s.tmpDir = test.NewTmpDir("gotify_compatsuite") + + test.WithWd(path.Join(test.GetProjectDir(), "./plugin/example/echo"), func(origWd string) { + exec.Command("go", "get", "-d").Run() + goBuildFlags := []string{"build", "-buildmode=plugin", "-o=" + s.tmpDir.Path("echo.so")} + + for _, extraFlag := range extraGoBuildFlags { + goBuildFlags = append(goBuildFlags, extraFlag) + } + + cmd := exec.Command("go", goBuildFlags...) + cmd.Stderr = os.Stderr + assert.Nil(s.T(), cmd.Run()) + }) + + plugin, err := plugin.Open(s.tmpDir.Path("echo.so")) + assert.Nil(s.T(), err) + wrappedPlugin, err := Wrap(plugin) + assert.Nil(s.T(), err) + + s.p = wrappedPlugin +} + +func (s *CompatSuite) TearDownSuite() { + assert.Nil(s.T(), s.tmpDir.Clean()) +} + +func (s *CompatSuite) TestGetPluginAPIVersion() { + assert.Equal(s.T(), "v1", s.p.APIVersion()) +} + +func (s *CompatSuite) TestGetPluginInfo() { + info := s.p.PluginInfo() + + assert.Equal(s.T(), examplePluginPath, info.ModulePath) + assert.True(s.T(), info.String() != "") +} + +func (s *CompatSuite) TestInstantiatePlugin() { + inst := s.p.NewPluginInstance(UserContext{ + ID: 1, + Name: "test", + }) + + assert.NotNil(s.T(), inst) +} + +func (s *CompatSuite) TestGetCapabilities() { + inst := s.p.NewPluginInstance(UserContext{ + ID: 2, + Name: "test2", + }) + + c := inst.Supports() + + assert.Contains(s.T(), c, Webhooker) + assert.Contains(s.T(), c.Strings(), string(Webhooker)) + assert.True(s.T(), HasSupport(inst, Webhooker)) + assert.False(s.T(), HasSupport(inst, "not_exist")) +} + +func (s *CompatSuite) TestSetConfig() { + inst := s.p.NewPluginInstance(UserContext{ + ID: 3, + Name: "test3", + }) + + defaultConfig := inst.DefaultConfig() + assert.Nil(s.T(), inst.ValidateAndSetConfig(defaultConfig)) +} + +func (s *CompatSuite) TestRegisterWebhook() { + inst := s.p.NewPluginInstance(UserContext{ + ID: 4, + Name: "test4", + }) + + e := gin.New() + g := e.Group("/") + assert.NotPanics(s.T(), func() { + inst.RegisterWebhook("/plugin/4/custom/Pabcd/", g) + }) +} +func (s *CompatSuite) TestEnableDisable() { + inst := s.p.NewPluginInstance(UserContext{ + ID: 5, + Name: "test5", + }) + assert.Nil(s.T(), inst.Enable()) + assert.Nil(s.T(), inst.Disable()) +} + +func (s *CompatSuite) TestGetDisplay() { + inst := s.p.NewPluginInstance(UserContext{ + ID: 6, + Name: "test6", + }) + + assert.NotEqual(s.T(), "", inst.GetDisplay(nil)) +} + +func TestCompatSuite(t *testing.T) { + suite.Run(t, new(CompatSuite)) +} + +func TestWrapIncompatiblePlugins(t *testing.T) { + tmpDir := test.NewTmpDir("gotify_testwrapincompatibleplugins") + defer tmpDir.Clean() + for i, modulePath := range []string{ + "github.com/gotify/server/plugin/testing/broken/noinstance", + "github.com/gotify/server/plugin/testing/broken/nothing", + "github.com/gotify/server/plugin/testing/broken/unknowninfo", + "github.com/gotify/server/plugin/testing/broken/malformedconstructor", + } { + fName := tmpDir.Path(fmt.Sprintf("broken_%d.so", i)) + exec.Command("go", "get", "-d").Run() + goBuildFlags := []string{"build", "-buildmode=plugin", "-o=" + fName} + + for _, extraFlag := range extraGoBuildFlags { + goBuildFlags = append(goBuildFlags, extraFlag) + } + + goBuildFlags = append(goBuildFlags, modulePath) + + cmd := exec.Command("go", goBuildFlags...) + cmd.Stderr = os.Stderr + assert.Nil(t, cmd.Run()) + + plugin, err := plugin.Open(fName) + assert.Nil(t, err) + _, err = Wrap(plugin) + assert.Error(t, err) + os.Remove(fName) + } +} diff --git a/plugin/compat/wrap_test_norace.go b/plugin/compat/wrap_test_norace.go new file mode 100644 index 000000000..237663c8a --- /dev/null +++ b/plugin/compat/wrap_test_norace.go @@ -0,0 +1,5 @@ +// +build !race + +package compat + +var extraGoBuildFlags = []string{} diff --git a/plugin/compat/wrap_test_race.go b/plugin/compat/wrap_test_race.go new file mode 100644 index 000000000..0bf9c39a7 --- /dev/null +++ b/plugin/compat/wrap_test_race.go @@ -0,0 +1,5 @@ +// +build race + +package compat + +var extraGoBuildFlags = []string{"-race"} diff --git a/plugin/example/clock/main.go b/plugin/example/clock/main.go new file mode 100644 index 000000000..dfe63291c --- /dev/null +++ b/plugin/example/clock/main.go @@ -0,0 +1,63 @@ +package main + +import ( + "time" + + "github.com/gotify/plugin-api" + "github.com/robfig/cron" +) + +// GetGotifyPluginInfo returns gotify plugin info +func GetGotifyPluginInfo() plugin.Info { + return plugin.Info{ + Name: "clock", + Description: "Sends an hourly reminder", + ModulePath: "github.com/gotify/server/example/clock", + } +} + +// Plugin is plugin instance +type Plugin struct { + msgHandler plugin.MessageHandler + enabled bool + cronHandler *cron.Cron +} + +// Enable implements plugin.Plugin +func (c *Plugin) Enable() error { + c.enabled = true + c.cronHandler = cron.New() + c.cronHandler.AddFunc("0 0 * * *", func() { + c.msgHandler.SendMessage(plugin.Message{ + Title: "Tick Tock!", + Message: time.Now().Format("It is 15:04:05 now."), + }) + }) + c.cronHandler.Start() + return nil +} + +// Disable implements plugin.Plugin +func (c *Plugin) Disable() error { + if c.cronHandler != nil { + c.cronHandler.Stop() + } + c.enabled = false + return nil +} + +// SetMessageHandler implements plugin.Messenger. +func (c *Plugin) SetMessageHandler(h plugin.MessageHandler) { + c.msgHandler = h +} + +// NewGotifyPluginInstance creates a plugin instance for a user context. +func NewGotifyPluginInstance(ctx plugin.UserContext) plugin.Plugin { + p := &Plugin{} + + return p +} + +func main() { + panic("this should be built as go plugin") +} diff --git a/plugin/example/echo/echo.go b/plugin/example/echo/echo.go new file mode 100644 index 000000000..62a2f6cf1 --- /dev/null +++ b/plugin/example/echo/echo.go @@ -0,0 +1,120 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "net/url" + + "github.com/gin-gonic/gin" + "github.com/gotify/plugin-api" +) + +// GetGotifyPluginInfo returns gotify plugin info. +func GetGotifyPluginInfo() plugin.Info { + return plugin.Info{ + ModulePath: "github.com/gotify/server/plugin/example/echo", + Name: "test plugin", + } +} + +// EchoPlugin is the gotify plugin instance. +type EchoPlugin struct { + msgHandler plugin.MessageHandler + storageHandler plugin.StorageHandler + config *Config + basePath string +} + +// SetStorageHandler implements plugin.Storager +func (c *EchoPlugin) SetStorageHandler(h plugin.StorageHandler) { + c.storageHandler = h +} + +// SetMessageHandler implements plugin.Messenger. +func (c *EchoPlugin) SetMessageHandler(h plugin.MessageHandler) { + c.msgHandler = h +} + +// Storage defines the plugin storage scheme +type Storage struct { + CalledTimes int `json:"called_times"` +} + +// Config defines the plugin config scheme +type Config struct { + MagicString string `yaml:"magic_string"` +} + +// DefaultConfig implements plugin.Configurer +func (c *EchoPlugin) DefaultConfig() interface{} { + return &Config{ + MagicString: "hello world", + } +} + +// ValidateAndSetConfig implements plugin.Configurer +func (c *EchoPlugin) ValidateAndSetConfig(config interface{}) error { + c.config = config.(*Config) + return nil +} + +// Enable enables the plugin. +func (c *EchoPlugin) Enable() error { + log.Println("echo plugin enabled") + return nil +} + +// Disable disables the plugin. +func (c *EchoPlugin) Disable() error { + log.Println("echo plugin disbled") + return nil +} + +// RegisterWebhook implements plugin.Webhooker. +func (c *EchoPlugin) RegisterWebhook(baseURL string, g *gin.RouterGroup) { + c.basePath = baseURL + g.GET("/echo", func(ctx *gin.Context) { + + storage, _ := c.storageHandler.Load() + conf := new(Storage) + json.Unmarshal(storage, conf) + conf.CalledTimes++ + newStorage, _ := json.Marshal(conf) + c.storageHandler.Save(newStorage) + + c.msgHandler.SendMessage(plugin.Message{ + Title: "Hello received", + Message: fmt.Sprintf("echo server received a hello message %d times", conf.CalledTimes), + Priority: 2, + Extras: map[string]interface{}{ + "plugin::name": "echo", + }, + }) + ctx.Writer.WriteString(fmt.Sprintf("Magic string is: %s\r\nEcho server running at %secho", c.config.MagicString, c.basePath)) + }) +} + +// GetDisplay implements plugin.Displayer. +func (c *EchoPlugin) GetDisplay(location *url.URL) string { + loc := &url.URL{ + Path: c.basePath, + } + if location != nil { + loc.Scheme = location.Scheme + loc.Host = location.Host + } + loc = loc.ResolveReference(&url.URL{ + Path: "echo", + }) + return "Echo plugin running at: " + loc.String() +} + +// NewGotifyPluginInstance creates a plugin instance for a user context. +func NewGotifyPluginInstance(ctx plugin.UserContext) plugin.Plugin { + return &EchoPlugin{} +} + +func main() { + panic("this should be built as go plugin") +} diff --git a/plugin/example/minimal/main.go b/plugin/example/minimal/main.go new file mode 100644 index 000000000..dbc35537f --- /dev/null +++ b/plugin/example/minimal/main.go @@ -0,0 +1,35 @@ +package main + +import ( + "github.com/gotify/plugin-api" +) + +// GetGotifyPluginInfo returns gotify plugin info +func GetGotifyPluginInfo() plugin.Info { + return plugin.Info{ + Name: "minimal plugin", + ModulePath: "github.com/gotify/server/example/minimal", + } +} + +// Plugin is plugin instance +type Plugin struct{} + +// Enable implements plugin.Plugin +func (c *Plugin) Enable() error { + return nil +} + +// Disable implements plugin.Plugin +func (c *Plugin) Disable() error { + return nil +} + +// NewGotifyPluginInstance creates a plugin instance for a user context. +func NewGotifyPluginInstance(ctx plugin.UserContext) plugin.Plugin { + return &Plugin{} +} + +func main() { + panic("this should be built as go plugin") +} diff --git a/plugin/manager.go b/plugin/manager.go new file mode 100644 index 000000000..82375dc9c --- /dev/null +++ b/plugin/manager.go @@ -0,0 +1,382 @@ +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/go-yaml/yaml" + "github.com/gotify/server/auth" + "github.com/gotify/server/model" + "github.com/gotify/server/plugin/compat" +) + +// The Database interface for encapsulating database access. +type Database interface { + GetUsers() []*model.User + GetPluginConfByUserAndPath(userid uint, path string) *model.PluginConf + CreatePluginConf(p *model.PluginConf) error + GetPluginConfByApplicationID(appid uint) *model.PluginConf + UpdatePluginConf(p *model.PluginConf) error + CreateMessage(message *model.Message) error + GetPluginConfByID(id uint) *model.PluginConf + GetPluginConfByToken(token string) *model.PluginConf + GetUserByID(id uint) *model.User + CreateApplication(application *model.Application) error + UpdateApplication(app *model.Application) error + GetApplicationsByUser(userID uint) []*model.Application + GetApplicationByToken(token string) *model.Application +} + +// Notifier notifies when a new message was created. +type Notifier interface { + Notify(userID uint, message *model.MessageExternal) +} + +// Manager is an encapusulating 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 + } + + for _, user := range manager.db.GetUsers() { + 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 { + return m.db.GetApplicationByToken(token) != nil +} + +func (m *Manager) pluginConfExists(token string) bool { + return m.db.GetPluginConfByToken(token) != 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 := m.db.GetPluginConfByID(pluginID) + + 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 + } + 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 := m.db.GetPluginConfByUserAndPath(userID, p.PluginInfo().ModulePath) + 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()) + 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 := m.db.GetUserByID(userID) + 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 + } + } + + for _, app := range m.db.GetApplicationsByUser(user.ID) { + if conf := m.db.GetPluginConfByApplicationID(app.ID); 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 := m.db.GetPluginConfByUserAndPath(userID, info.ModulePath) + + 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 +} diff --git a/plugin/manager_test.go b/plugin/manager_test.go new file mode 100644 index 000000000..c989481d8 --- /dev/null +++ b/plugin/manager_test.go @@ -0,0 +1,452 @@ +// +build linux darwin +// +build !race + +package plugin + +import ( + "errors" + "fmt" + "net/http/httptest" + "os" + "os/exec" + "path" + "testing" + "time" + + "github.com/gotify/server/auth" + "github.com/gotify/server/model" + "github.com/gotify/server/plugin/compat" + "github.com/gotify/server/plugin/testing/mock" + "github.com/gotify/server/test" + "github.com/gotify/server/test/testdb" + + "github.com/jinzhu/gorm" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + + "github.com/gin-gonic/gin" +) + +const examplePluginPath = "github.com/gotify/server/plugin/example/echo" +const mockPluginPath = mock.ModulePath +const danglingPluginPath = "github.com/gotify/server/plugin/testing/removed" + +type ManagerSuite struct { + suite.Suite + db *testdb.Database + manager *Manager + e *gin.Engine + g *gin.RouterGroup + msgReceiver chan MessageWithUserID + + tmpDir test.TmpDir +} + +func (s *ManagerSuite) Notify(uid uint, message *model.MessageExternal) { + s.msgReceiver <- MessageWithUserID{ + Message: *message, + UserID: uid, + } +} + +func (s *ManagerSuite) SetupSuite() { + s.tmpDir = test.NewTmpDir("gotify_managersuite") + + test.WithWd(path.Join(test.GetProjectDir(), "./plugin/example/echo"), func(origWd string) { + exec.Command("go", "get", "-d").Run() + goBuildFlags := []string{"build", "-buildmode=plugin", "-o=" + s.tmpDir.Path("echo.so")} + + for _, extraFlag := range extraGoBuildFlags { + goBuildFlags = append(goBuildFlags, extraFlag) + } + + cmd := exec.Command("go", goBuildFlags...) + cmd.Stderr = os.Stderr + assert.Nil(s.T(), cmd.Run()) + }) + + s.db = testdb.NewDBWithDefaultUser(s.T()) + s.makeDanglingPluginConf(1) + + e := gin.New() + manager, err := NewManager(s.db.GormDatabase, s.tmpDir.Path(), e.Group("/plugin/:id/custom/"), s) + s.e = e + assert.Nil(s.T(), err) + + p := new(mock.Plugin) + assert.Nil(s.T(), manager.LoadPlugin(p)) + assert.Nil(s.T(), manager.initializeSingleUserPlugin(compat.UserContext{ + ID: 1, + Admin: true, + }, p)) + + s.manager = manager + s.msgReceiver = make(chan MessageWithUserID) + + assert.Contains(s.T(), s.manager.plugins, examplePluginPath) + assert.NotNil(s.T(), s.db.GetPluginConfByUserAndPath(1, examplePluginPath)) +} + +func (s *ManagerSuite) TearDownSuite() { + assert.Nil(s.T(), s.tmpDir.Clean()) +} + +func (s *ManagerSuite) getConfForExamplePlugin(uid uint) *model.PluginConf { + return s.db.GetPluginConfByUserAndPath(uid, examplePluginPath) +} + +func (s *ManagerSuite) getConfForMockPlugin(uid uint) *model.PluginConf { + return s.db.GetPluginConfByUserAndPath(uid, mockPluginPath) +} + +func (s *ManagerSuite) getMockPluginInstance(uid uint) *mock.PluginInstance { + pid := s.getConfForMockPlugin(uid).ID + return s.manager.instances[pid].(*mock.PluginInstance) +} + +func (s *ManagerSuite) makeDanglingPluginConf(uid uint) *model.PluginConf { + conf := &model.PluginConf{ + UserID: uid, + ModulePath: danglingPluginPath, + Token: auth.GeneratePluginToken(), + Enabled: true, + } + s.db.CreatePluginConf(conf) + return conf +} + +func (s *ManagerSuite) TestWebhook_blockedIfDisabled() { + conf := s.getConfForExamplePlugin(1) + t := httptest.NewRequest("GET", fmt.Sprintf("/plugin/%d/custom/%s/echo", conf.ID, conf.Token), nil) + + r := httptest.NewRecorder() + s.e.ServeHTTP(r, t) + + assert.Equal(s.T(), 400, r.Code) +} + +func (s *ManagerSuite) TestWebhook_successIfEnabled() { + conf := s.getConfForExamplePlugin(1) + + assert.Nil(s.T(), s.manager.SetPluginEnabled(conf.ID, true)) + defer func() { assert.Nil(s.T(), s.manager.SetPluginEnabled(conf.ID, false)) }() + assert.True(s.T(), s.getConfForExamplePlugin(1).Enabled) + + t := httptest.NewRequest("GET", fmt.Sprintf("/plugin/%d/custom/%s/echo", conf.ID, conf.Token), nil) + + r := httptest.NewRecorder() + s.e.ServeHTTP(r, t) + + assert.Equal(s.T(), 200, r.Code) +} + +func (s *ManagerSuite) TestInitializePlugin_noOpIfEmpty() { + assert.Nil(s.T(), s.manager.loadPlugins("")) +} +func (s *ManagerSuite) TestInitializePlugin_directoryInvalid_expectError() { + assert.Error(s.T(), s.manager.loadPlugins("<<")) +} + +func (s *ManagerSuite) TestInitializePlugin_invalidPlugin_expectError() { + assert.Error(s.T(), s.manager.loadPlugins(test.GetProjectDir())) +} + +func (s *ManagerSuite) TestInitializePlugin_brokenPlugin_expectError() { + tmpDir := test.NewTmpDir("gotify_testbrokenplugin") + defer tmpDir.Clean() + test.WithWd(path.Join(test.GetProjectDir(), "./plugin/testing/broken/nothing"), func(origWd string) { + exec.Command("go", "get", "-d").Run() + goBuildFlags := []string{"build", "-buildmode=plugin", "-o=" + tmpDir.Path("empty.so")} + + for _, extraFlag := range extraGoBuildFlags { + goBuildFlags = append(goBuildFlags, extraFlag) + } + + cmd := exec.Command("go", goBuildFlags...) + cmd.Stderr = os.Stderr + assert.Nil(s.T(), cmd.Run()) + }) + assert.Error(s.T(), s.manager.loadPlugins(tmpDir.Path())) +} + +func (s *ManagerSuite) TestInitializePlugin_alreadyLoaded_expectError() { + assert.Error(s.T(), s.manager.loadPlugins(s.tmpDir.Path())) +} + +func (s *ManagerSuite) TestInitializePlugin_alreadyEnabledInConf_expectAutoEnable() { + s.db.User(2) + s.db.CreatePluginConf(&model.PluginConf{ + UserID: 2, + ModulePath: mockPluginPath, + Token: "P1234", + Enabled: true, + }) + + assert.Nil(s.T(), s.manager.InitializeForUserID(2)) + inst := s.getMockPluginInstance(2) + assert.True(s.T(), inst.Enabled) + +} + +func (s *ManagerSuite) TestInitializePlugin_alreadyEnabledInConf_failedToLoadConfig_disableAutomatically() { + s.db.User(3) + s.db.CreatePluginConf(&model.PluginConf{ + UserID: 3, + ModulePath: mockPluginPath, + Token: "Ptttt", + Enabled: true, + Config: []byte(`invalid: """`), + }) + + assert.Nil(s.T(), s.manager.InitializeForUserID(3)) + inst := s.getMockPluginInstance(3) + assert.False(s.T(), inst.Enabled) + +} + +func (s *ManagerSuite) TestInitializePlugin_alreadyEnabled_cannotEnable_disabledAutomatically() { + s.db.NewUserWithName(4, "enable_fail_2") + mock.ReturnErrorOnEnableForUser(4, errors.New("test error")) + s.db.CreatePluginConf(&model.PluginConf{ + UserID: 4, + ModulePath: mockPluginPath, + Token: "P5478", + Enabled: true, + }) + + assert.Nil(s.T(), s.manager.InitializeForUserID(4)) + inst := s.getMockPluginInstance(4) + assert.False(s.T(), inst.Enabled) + assert.False(s.T(), s.getConfForMockPlugin(4).Enabled) +} + +func (s *ManagerSuite) TestInitializePlugin_userIDNotExist_expectError() { + assert.Error(s.T(), s.manager.InitializeForUserID(99)) +} + +func (s *ManagerSuite) TestSetPluginEnabled() { + pid := s.getConfForMockPlugin(1).ID + assert.Nil(s.T(), s.manager.SetPluginEnabled(pid, true)) + assert.Error(s.T(), s.manager.SetPluginEnabled(pid, true)) + assert.Nil(s.T(), s.manager.SetPluginEnabled(pid, false)) +} + +func (s *ManagerSuite) TestSetPluginEnabled_EnableReturnsError_cannotEnable() { + s.db.NewUserWithName(5, "enable_fail") + errExpected := errors.New("test error") + mock.ReturnErrorOnEnableForUser(5, errExpected) + + assert.Nil(s.T(), s.manager.InitializeForUserID(5)) + + pid := s.getConfForMockPlugin(5).ID + assert.Error(s.T(), s.manager.SetPluginEnabled(pid, false)) + assert.EqualError(s.T(), s.manager.SetPluginEnabled(pid, true), errExpected.Error()) + + assert.False(s.T(), s.getConfForMockPlugin(5).Enabled) +} + +func (s *ManagerSuite) TestSetPluginEnabled_DisableReturnsError_cannotDisable() { + s.db.NewUserWithName(6, "disable_fail") + errExpected := errors.New("test error") + mock.ReturnErrorOnDisableForUser(6, errExpected) + + assert.Nil(s.T(), s.manager.InitializeForUserID(6)) + + pid := s.getConfForMockPlugin(6).ID + assert.Nil(s.T(), s.manager.SetPluginEnabled(pid, true)) + assert.EqualError(s.T(), s.manager.SetPluginEnabled(pid, false), errExpected.Error()) + + assert.True(s.T(), s.getConfForMockPlugin(6).Enabled) +} + +func (s *ManagerSuite) TestAddRemoveNewUser() { + s.db.User(7) + s.makeDanglingPluginConf(7) + + assert.Nil(s.T(), s.manager.InitializeForUserID(7)) + pid := s.getConfForExamplePlugin(7).ID + assert.True(s.T(), s.manager.HasInstance(pid)) + + assert.Nil(s.T(), s.manager.SetPluginEnabled(s.getConfForMockPlugin(7).ID, true)) + + assert.Nil(s.T(), s.manager.RemoveUser(7)) + assert.False(s.T(), s.manager.HasInstance(pid)) +} + +func (s *ManagerSuite) TestRemoveUser_DisableFail_cannotRemove() { + s.manager.initializeForUser(*s.db.NewUserWithName(8, "disable_fail_2")) + errExpected := errors.New("test error") + mock.ReturnErrorOnDisableForUser(8, errExpected) + s.manager.SetPluginEnabled(s.getConfForMockPlugin(8).ID, true) + + assert.EqualError(s.T(), s.manager.RemoveUser(8), errExpected.Error()) +} + +func (s *ManagerSuite) TestRemoveUser_danglingConf_expectSuccess() { + // make a dangling conf for this instance + s.db.User(9) + s.db.CreatePluginConf(&model.PluginConf{ + ModulePath: mockPluginPath, + Enabled: true, + UserID: 9, + Token: auth.GenerateNotExistingToken(auth.GeneratePluginToken, s.manager.pluginConfExists), + }) + s.db.CreatePluginConf(&model.PluginConf{ + ModulePath: examplePluginPath, + Enabled: true, + UserID: 9, + Token: auth.GenerateNotExistingToken(auth.GeneratePluginToken, s.manager.pluginConfExists), + }) + assert.Nil(s.T(), s.manager.RemoveUser(9)) +} + +func (s *ManagerSuite) TestTriggerMessage() { + inst := s.getMockPluginInstance(1) + inst.TriggerMessage() + select { + case msg := <-s.msgReceiver: + assert.Equal(s.T(), uint(1), msg.UserID) + assert.NotEmpty(s.T(), msg.Message.Extras) + case <-time.After(1 * time.Second): + assert.Fail(s.T(), "read message time out") + } +} + +func (s *ManagerSuite) TestStorage() { + inst := s.getMockPluginInstance(1) + + assert.Nil(s.T(), inst.SetStorage([]byte("test"))) + storage, err := inst.GetStorage() + assert.Nil(s.T(), err) + assert.Equal(s.T(), "test", string(storage)) +} + +func (s *ManagerSuite) TestGetPluginInfo() { + assert.Equal(s.T(), mock.Name, s.manager.PluginInfo(mock.ModulePath).Name) +} + +func (s *ManagerSuite) TestGetPluginInfo_notFound_doNotPanic() { + assert.NotPanics(s.T(), func() { + s.manager.PluginInfo("not/exist") + }) +} + +func (s *ManagerSuite) TestSetPluginEnabled_expectNotFound() { + assert.Error(s.T(), s.manager.SetPluginEnabled(99, true)) +} + +func TestManagerSuite(t *testing.T) { + suite.Run(t, new(ManagerSuite)) +} + +func TestNewManager_CannotLoadDirectory_expectError(t *testing.T) { + _, err := NewManager(nil, "<>", nil, nil) + assert.Error(t, err) +} + +func TestNewManager_NonPluginFile_expectError(t *testing.T) { + _, err := NewManager(nil, path.Join(test.GetProjectDir(), "test/assets/"), nil, nil) + assert.Error(t, err) +} + +func TestNewManager_FaultyDB_expectError(t *testing.T) { + tmpDir := test.NewTmpDir("gotify_testnewmanager_faultydb") + defer tmpDir.Clean() + for _, suite := range []struct { + pkg string + faultyTable string + name string + }{{"plugin/example/minimal/", "plugin_confs", "minimal"}, {"plugin/example/clock/", "applications", "clock"}} { + test.WithWd(path.Join(test.GetProjectDir(), suite.pkg), func(origWd string) { + exec.Command("go", "get", "-d").Run() + goBuildFlags := []string{"build", "-buildmode=plugin", "-o=" + tmpDir.Path(fmt.Sprintf("%s.so", suite.name))} + + for _, extraFlag := range extraGoBuildFlags { + goBuildFlags = append(goBuildFlags, extraFlag) + } + + cmd := exec.Command("go", goBuildFlags...) + cmd.Stderr = os.Stderr + assert.Nil(t, cmd.Run()) + }) + db := testdb.NewDBWithDefaultUser(t) + db.GormDatabase.DB.Callback().Create().Register("no_create", func(s *gorm.Scope) { + if s.TableName() == suite.faultyTable { + s.Err(errors.New("database failed")) + } + }) + _, err := NewManager(db, tmpDir.Path(), nil, nil) + assert.Error(t, err) + os.Remove(tmpDir.Path(fmt.Sprintf("%s.so", suite.name))) + } +} + +func TestNewManager_InternalApplicationManagement(t *testing.T) { + db := testdb.NewDBWithDefaultUser(t) + + { + // Application exist, no plugin conf + db.CreateApplication(&model.Application{ + Token: "Ainternal_obsolete", + Internal: true, + Name: "obsolete plugin application", + UserID: 1, + }) + + assert.True(t, db.GetApplicationByToken("Ainternal_obsolete").Internal) + _, err := NewManager(db, "", nil, nil) + assert.Nil(t, err) + assert.False(t, db.GetApplicationByToken("Ainternal_obsolete").Internal) + } + { + // Application exist, conf exist, no compat + db.CreateApplication(&model.Application{ + Token: "Ainternal_not_loaded", + Internal: true, + Name: "not loaded plugin application", + UserID: 1, + }) + db.CreatePluginConf(&model.PluginConf{ + ApplicationID: db.GetApplicationByToken("Ainternal_not_loaded").ID, + UserID: 1, + Enabled: true, + Token: auth.GeneratePluginToken(), + }) + + assert.True(t, db.GetApplicationByToken("Ainternal_not_loaded").Internal) + _, err := NewManager(db, "", nil, nil) + assert.Nil(t, err) + assert.False(t, db.GetApplicationByToken("Ainternal_not_loaded").Internal) + } + { + // Application exist, conf exist, has compat + db.CreateApplication(&model.Application{ + Token: "Ainternal_loaded", + Internal: false, + Name: "not loaded plugin application", + UserID: 1, + }) + db.CreatePluginConf(&model.PluginConf{ + ApplicationID: db.GetApplicationByToken("Ainternal_loaded").ID, + UserID: 1, + Enabled: true, + ModulePath: mock.ModulePath, + Token: auth.GeneratePluginToken(), + }) + + assert.False(t, db.GetApplicationByToken("Ainternal_loaded").Internal) + manager, err := NewManager(db, "", nil, nil) + assert.Nil(t, err) + assert.Nil(t, manager.LoadPlugin(new(mock.Plugin))) + assert.Nil(t, manager.InitializeForUserID(1)) + assert.True(t, db.GetApplicationByToken("Ainternal_loaded").Internal) + } +} + +func TestPluginFileLoadError(t *testing.T) { + err := pluginFileLoadError{Filename: "test.so", UnderlyingError: errors.New("test error")} + assert.Error(t, err) + assert.Contains(t, err.Error(), "test.so") + assert.Contains(t, err.Error(), "test error") +} diff --git a/plugin/manager_test_norace.go b/plugin/manager_test_norace.go new file mode 100644 index 000000000..805fe39f6 --- /dev/null +++ b/plugin/manager_test_norace.go @@ -0,0 +1,5 @@ +// +build !race + +package plugin + +var extraGoBuildFlags = []string{} diff --git a/plugin/manager_test_race.go b/plugin/manager_test_race.go new file mode 100644 index 000000000..4715167da --- /dev/null +++ b/plugin/manager_test_race.go @@ -0,0 +1,5 @@ +// +build race + +package plugin + +var extraGoBuildFlags = []string{"-race"} diff --git a/plugin/messagehandler.go b/plugin/messagehandler.go new file mode 100644 index 000000000..5b9ee0371 --- /dev/null +++ b/plugin/messagehandler.go @@ -0,0 +1,36 @@ +package plugin + +import ( + "time" + + "github.com/gotify/server/model" + "github.com/gotify/server/plugin/compat" +) + +type redirectToChannel struct { + ApplicationID uint + UserID uint + Messages chan MessageWithUserID +} + +// MessageWithUserID encapsulates a message with a given user ID +type MessageWithUserID struct { + Message model.MessageExternal + UserID uint +} + +// SendMessage sends a message to the underlying message channel +func (c redirectToChannel) SendMessage(msg compat.Message) error { + c.Messages <- MessageWithUserID{ + Message: model.MessageExternal{ + ApplicationID: c.ApplicationID, + Message: msg.Message, + Title: msg.Title, + Priority: msg.Priority, + Date: time.Now(), + Extras: msg.Extras, + }, + UserID: c.UserID, + } + return nil +} diff --git a/plugin/pluginenabled.go b/plugin/pluginenabled.go new file mode 100644 index 000000000..826c49bcf --- /dev/null +++ b/plugin/pluginenabled.go @@ -0,0 +1,15 @@ +package plugin + +import ( + "errors" + + "github.com/gin-gonic/gin" +) + +func requirePluginEnabled(id uint, db Database) gin.HandlerFunc { + return func(c *gin.Context) { + if conf := db.GetPluginConfByID(id); conf == nil || !conf.Enabled { + c.AbortWithError(400, errors.New("plugin is disabled")) + } + } +} diff --git a/plugin/pluginenabled_test.go b/plugin/pluginenabled_test.go new file mode 100644 index 000000000..d7f549ed9 --- /dev/null +++ b/plugin/pluginenabled_test.go @@ -0,0 +1,45 @@ +package plugin + +import ( + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/gotify/server/model" + "github.com/gotify/server/test/testdb" + + "github.com/gin-gonic/gin" +) + +func TestRequirePluginEnabled(t *testing.T) { + + db := testdb.NewDBWithDefaultUser(t) + conf := &model.PluginConf{ + ID: 1, + UserID: 1, + Enabled: true, + } + db.CreatePluginConf(conf) + + g := gin.New() + + mux := g.Group("/", requirePluginEnabled(1, db)) + + mux.GET("/", func(c *gin.Context) { + c.Status(200) + }) + + getCode := func() int { + r := httptest.NewRequest("GET", "/", nil) + w := httptest.NewRecorder() + g.ServeHTTP(w, r) + return w.Code + } + + assert.Equal(t, 200, getCode()) + + conf.Enabled = false + db.UpdatePluginConf(conf) + assert.Equal(t, 400, getCode()) +} diff --git a/plugin/storagehandler.go b/plugin/storagehandler.go new file mode 100644 index 000000000..00b7e2ff3 --- /dev/null +++ b/plugin/storagehandler.go @@ -0,0 +1,16 @@ +package plugin + +type dbStorageHandler struct { + pluginID uint + db Database +} + +func (c dbStorageHandler) Save(b []byte) error { + conf := c.db.GetPluginConfByID(c.pluginID) + conf.Storage = b + return c.db.UpdatePluginConf(conf) +} + +func (c dbStorageHandler) Load() ([]byte, error) { + return c.db.GetPluginConfByID(c.pluginID).Storage, nil +} diff --git a/plugin/testing/broken/cantinstantiate/main.go b/plugin/testing/broken/cantinstantiate/main.go new file mode 100644 index 000000000..d02501156 --- /dev/null +++ b/plugin/testing/broken/cantinstantiate/main.go @@ -0,0 +1,36 @@ +package main + +import ( + "errors" + + "github.com/gotify/plugin-api" +) + +// GetGotifyPluginInfo returns gotify plugin info +func GetGotifyPluginInfo() plugin.Info { + return plugin.Info{ + ModulePath: "github.com/gotify/server/plugin/testing/broken/noinstance", + } +} + +// Plugin is plugin instance +type Plugin struct{} + +// Enable implements plugin.Plugin +func (c *Plugin) Enable() error { + return errors.New("cannot instantiate") +} + +// Disable implements plugin.Plugin +func (c *Plugin) Disable() error { + return nil +} + +// NewGotifyPluginInstance creates a plugin instance for a user context. +func NewGotifyPluginInstance(ctx plugin.UserContext) plugin.Plugin { + return &Plugin{} +} + +func main() { + panic("this is a broken plugin for testing purposes") +} diff --git a/plugin/testing/broken/malformedconstructor/main.go b/plugin/testing/broken/malformedconstructor/main.go new file mode 100644 index 000000000..c1022e923 --- /dev/null +++ b/plugin/testing/broken/malformedconstructor/main.go @@ -0,0 +1,34 @@ +package main + +import ( + "github.com/gotify/plugin-api" +) + +// GetGotifyPluginInfo returns gotify plugin info +func GetGotifyPluginInfo() plugin.Info { + return plugin.Info{ + ModulePath: "github.com/gotify/server/plugin/testing/broken/malformedconstructor", + } +} + +// Plugin is plugin instance +type Plugin struct{} + +// Enable implements plugin.Plugin +func (c *Plugin) Enable() error { + return nil +} + +// Disable implements plugin.Plugin +func (c *Plugin) Disable() error { + return nil +} + +// NewGotifyPluginInstance creates a plugin instance for a user context. +func NewGotifyPluginInstance(ctx plugin.UserContext) interface{} { + return &Plugin{} +} + +func main() { + panic("this is a broken plugin for testing purposes") +} diff --git a/plugin/testing/broken/noinstance/main.go b/plugin/testing/broken/noinstance/main.go new file mode 100644 index 000000000..2385d9a9d --- /dev/null +++ b/plugin/testing/broken/noinstance/main.go @@ -0,0 +1,16 @@ +package main + +import ( + "github.com/gotify/plugin-api" +) + +// GetGotifyPluginInfo returns gotify plugin info +func GetGotifyPluginInfo() plugin.Info { + return plugin.Info{ + ModulePath: "github.com/gotify/server/plugin/testing/broken/noinstance", + } +} + +func main() { + panic("this is a broken plugin for testing purposes") +} diff --git a/plugin/testing/broken/nothing/main.go b/plugin/testing/broken/nothing/main.go new file mode 100644 index 000000000..288e40fb9 --- /dev/null +++ b/plugin/testing/broken/nothing/main.go @@ -0,0 +1,5 @@ +package main + +func main() { + panic("this is a broken plugin for testing purposes") +} diff --git a/plugin/testing/broken/unknowninfo/main.go b/plugin/testing/broken/unknowninfo/main.go new file mode 100644 index 000000000..58d5c9a40 --- /dev/null +++ b/plugin/testing/broken/unknowninfo/main.go @@ -0,0 +1,10 @@ +package main + +// GetGotifyPluginInfo returns gotify plugin info +func GetGotifyPluginInfo() string { + return "github.com/gotify/server/plugin/testing/broken/unknowninfo" +} + +func main() { + panic("this is a broken plugin for testing purposes") +} diff --git a/plugin/testing/mock/mock.go b/plugin/testing/mock/mock.go new file mode 100644 index 000000000..941e320f8 --- /dev/null +++ b/plugin/testing/mock/mock.go @@ -0,0 +1,175 @@ +package mock + +import ( + "errors" + "net/url" + + "github.com/gin-gonic/gin" + + "github.com/gotify/server/plugin/compat" +) + +// ModulePath is for convenient access of the module path of this mock plugin +const ModulePath = "github.com/gotify/server/plugin/testing/mock" + +// Name is for convenient access of the module path of the name of this mock plugin +const Name = "mock plugin" + +// Plugin is a mock plugin. +type Plugin struct { + Instances []PluginInstance +} + +// PluginInfo implements loader.PluginCompat +func (c *Plugin) PluginInfo() compat.Info { + return compat.Info{ + ModulePath: ModulePath, + Name: Name, + } +} + +// NewPluginInstance implements loader.PluginCompat +func (c *Plugin) NewPluginInstance(ctx compat.UserContext) compat.PluginInstance { + inst := PluginInstance{UserCtx: ctx, capabilities: compat.Capabilities{compat.Configurer, compat.Storager, compat.Messenger, compat.Displayer}} + c.Instances = append(c.Instances, inst) + return &inst +} + +// APIVersion implements loader.PluginCompat +func (c *Plugin) APIVersion() string { + return "v1" +} + +// PluginInstance is a mock plugin instance +type PluginInstance struct { + UserCtx compat.UserContext + Enabled bool + DisplayString string + Config *PluginConfig + storageHandler compat.StorageHandler + messageHandler compat.MessageHandler + capabilities compat.Capabilities + BasePath string +} + +// PluginConfig is a mock plugin config struct +type PluginConfig struct { + TestKey string + IsNotValid bool +} + +var disableFailUsers = make(map[uint]error) +var enableFailUsers = make(map[uint]error) + +// ReturnErrorOnEnableForUser registers a uid which will throw an error on enabling. +func ReturnErrorOnEnableForUser(uid uint, err error) { + enableFailUsers[uid] = err +} + +// ReturnErrorOnDisableForUser registers a uid which will throw an error on disabling. +func ReturnErrorOnDisableForUser(uid uint, err error) { + disableFailUsers[uid] = err +} + +// Enable implements compat.PluginInstance +func (c *PluginInstance) Enable() error { + if err, ok := enableFailUsers[c.UserCtx.ID]; ok { + return err + } + c.Enabled = true + return nil +} + +// Disable implements compat.PluginInstance +func (c *PluginInstance) Disable() error { + if err, ok := disableFailUsers[c.UserCtx.ID]; ok { + return err + } + c.Enabled = false + return nil +} + +// SetMessageHandler implements compat.Messenger +func (c *PluginInstance) SetMessageHandler(h compat.MessageHandler) { + c.messageHandler = h +} + +// SetStorageHandler implements compat.Storager +func (c *PluginInstance) SetStorageHandler(handler compat.StorageHandler) { + c.storageHandler = handler +} + +// SetStorage sets current storage +func (c *PluginInstance) SetStorage(b []byte) error { + return c.storageHandler.Save(b) +} + +// GetStorage sets current storage +func (c *PluginInstance) GetStorage() ([]byte, error) { + return c.storageHandler.Load() +} + +// RegisterWebhook implements compat.Webhooker +func (c *PluginInstance) RegisterWebhook(basePath string, mux *gin.RouterGroup) { + c.BasePath = basePath +} + +// SetCapability changes the capability of this plugin +func (c *PluginInstance) SetCapability(p compat.Capability, enable bool) { + if enable { + for _, cap := range c.capabilities { + if cap == p { + return + } + } + c.capabilities = append(c.capabilities, p) + } else { + newCap := make(compat.Capabilities, 0) + for _, cap := range c.capabilities { + if cap == p { + continue + } + newCap = append(newCap, cap) + } + c.capabilities = newCap + } +} + +// Supports implements compat.PluginInstance +func (c *PluginInstance) Supports() compat.Capabilities { + return c.capabilities +} + +// DefaultConfig implements compat.Configuror +func (c *PluginInstance) DefaultConfig() interface{} { + return &PluginConfig{ + TestKey: "default", + IsNotValid: false, + } +} + +// ValidateAndSetConfig implements compat.Configuror +func (c *PluginInstance) ValidateAndSetConfig(config interface{}) error { + if (config.(*PluginConfig)).IsNotValid { + return errors.New("conf is not valid") + } + c.Config = config.(*PluginConfig) + return nil +} + +// GetDisplay implements compat.Displayer +func (c *PluginInstance) GetDisplay(url *url.URL) string { + return c.DisplayString +} + +// TriggerMessage triggers a test message +func (c *PluginInstance) TriggerMessage() { + c.messageHandler.SendMessage(compat.Message{ + Title: "test message", + Message: "test", + Priority: 2, + Extras: map[string]interface{}{ + "test::string": "test", + }, + }) +} diff --git a/router/router.go b/router/router.go index 5a56fff1b..252115fc7 100644 --- a/router/router.go +++ b/router/router.go @@ -14,11 +14,17 @@ import ( "github.com/gotify/server/error" "github.com/gotify/server/mode" "github.com/gotify/server/model" + "github.com/gotify/server/plugin" "github.com/gotify/server/ui" ) // Create creates the gin engine with all routes. func Create(db *database.GormDatabase, vInfo *model.VersionInfo, conf *config.Configuration) (*gin.Engine, func()) { + g := gin.New() + + g.Use(gin.Logger(), gin.Recovery(), error.Handler(), location.Default()) + g.NoRoute(error.NotFound()) + streamHandler := stream.New(200*time.Second, 15*time.Second, conf.Server.Stream.AllowedOrigins) authentication := auth.Auth{DB: db} messageHandler := api.MessageAPI{Notifier: streamHandler, DB: db} @@ -31,12 +37,22 @@ func Create(db *database.GormDatabase, vInfo *model.VersionInfo, conf *config.Co DB: db, ImageDir: conf.UploadedImagesDir, } - userHandler := api.UserAPI{DB: db, PasswordStrength: conf.PassStrength, NotifyDeleted: streamHandler.NotifyDeletedUser} + userChangeNotifier := new(api.UserChangeNotifier) + userHandler := api.UserAPI{DB: db, PasswordStrength: conf.PassStrength, UserChangeNotifier: userChangeNotifier} - g := gin.New() + pluginManager, err := plugin.NewManager(db, conf.PluginsDir, g.Group("/plugin/:id/custom/"), streamHandler) + if err != nil { + panic(err) + } + pluginHandler := api.PluginAPI{ + Manager: pluginManager, + Notifier: streamHandler, + DB: db, + } - g.Use(gin.Logger(), gin.Recovery(), error.Handler(), location.Default()) - g.NoRoute(error.NotFound()) + userChangeNotifier.OnUserDeleted(streamHandler.NotifyDeletedUser) + userChangeNotifier.OnUserDeleted(pluginManager.RemoveUser) + userChangeNotifier.OnUserAdded(pluginManager.InitializeForUserID) ui.Register(g) @@ -58,6 +74,18 @@ func Create(db *database.GormDatabase, vInfo *model.VersionInfo, conf *config.Co } }) + { + g.GET("/plugin", authentication.RequireClient(), pluginHandler.GetPlugins) + pluginRoute := g.Group("/plugin/", authentication.RequireClient()) + { + pluginRoute.GET("/:id/config", pluginHandler.GetConfig) + pluginRoute.POST("/:id/config", pluginHandler.UpdateConfig) + pluginRoute.GET("/:id/display", pluginHandler.GetDisplay) + pluginRoute.POST("/:id/enable", pluginHandler.EnablePlugin) + pluginRoute.POST("/:id/disable", pluginHandler.DisablePlugin) + } + } + g.OPTIONS("/*any") // swagger:operation GET /version version getVersion diff --git a/router/router_test.go b/router/router_test.go index c0e4be09a..9d889c91e 100644 --- a/router/router_test.go +++ b/router/router_test.go @@ -2,17 +2,17 @@ package router import ( "bytes" + "encoding/json" "fmt" "net/http" "net/http/httptest" "strings" "testing" - "github.com/gin-gonic/gin/json" "github.com/gotify/server/config" "github.com/gotify/server/mode" "github.com/gotify/server/model" - "github.com/gotify/server/test" + "github.com/gotify/server/test/testdb" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" ) @@ -28,7 +28,7 @@ func TestIntegrationSuite(t *testing.T) { type IntegrationSuite struct { suite.Suite - db *test.Database + db *testdb.Database server *httptest.Server closable func() } @@ -36,7 +36,7 @@ type IntegrationSuite struct { func (s *IntegrationSuite) BeforeTest(string, string) { mode.Set(mode.TestDev) var err error - s.db = test.NewDBWithDefaultUser(s.T()) + s.db = testdb.NewDBWithDefaultUser(s.T()) assert.Nil(s.T(), err) g, closable := Create(s.db.GormDatabase, &model.VersionInfo{Version: "1.0.0", BuildDate: "2018-02-20-17:30:47", Commit: "asdasds"}, @@ -78,7 +78,7 @@ func (s *IntegrationSuite) TestHeaderInProd() { func TestHeadersFromConfiguration(t *testing.T) { mode.Set(mode.Prod) - db := test.NewDBWithDefaultUser(t) + db := testdb.NewDBWithDefaultUser(t) defer db.Close() config := config.Configuration{PassStrength: 5} @@ -148,6 +148,17 @@ func (s *IntegrationSuite) TestSendMessage() { assert.Equal(s.T(), token.ID, msg.ApplicationID) } +func (s *IntegrationSuite) TestPluginLoadFail_expectPanic() { + db := testdb.NewDBWithDefaultUser(s.T()) + defer db.Close() + + assert.Panics(s.T(), func() { + Create(db.GormDatabase, new(model.VersionInfo), &config.Configuration{ + PluginsDir: "", + }) + }) +} + func (s *IntegrationSuite) TestAuthentication() { req := s.newRequest("GET", "current/user", "") req.SetBasicAuth("admin", "pw") diff --git a/test/asserts.go b/test/asserts.go index 0614a6eb6..775378f52 100644 --- a/test/asserts.go +++ b/test/asserts.go @@ -2,6 +2,8 @@ package test import ( "encoding/json" + "errors" + "io" "io/ioutil" "net/http/httptest" @@ -25,3 +27,14 @@ func JSONEquals(t assert.TestingT, obj interface{}, expected string) { assert.JSONEq(t, expected, objJSON) } + +type unreadableReader struct{} + +func (c unreadableReader) Read([]byte) (int, error) { + return 0, errors.New("this reader cannot be read") +} + +// UnreadableReader returns an unreadadbe reader, used to mock IO issues. +func UnreadableReader() io.Reader { + return unreadableReader{} +} diff --git a/test/asserts_test.go b/test/asserts_test.go index fe72927c5..74bd02862 100644 --- a/test/asserts_test.go +++ b/test/asserts_test.go @@ -1,6 +1,7 @@ package test_test import ( + "io/ioutil" "net/http/httptest" "testing" @@ -40,3 +41,8 @@ func Test_BodyEquals_failing(t *testing.T) { test.BodyEquals(fakeTesting, &obj{ID: 2, Test: "asd"}, recorder) assert.True(t, fakeTesting.hasErrors) } + +func Test_UnreaableReader(t *testing.T) { + _, err := ioutil.ReadAll(test.UnreadableReader()) + assert.Error(t, err) +} diff --git a/test/auth.go b/test/auth.go index 8ce94d264..f2886a369 100644 --- a/test/auth.go +++ b/test/auth.go @@ -8,4 +8,5 @@ import ( // WithUser fake an authentication for testing. func WithUser(ctx *gin.Context, userID uint) { ctx.Set("user", &model.User{ID: userID}) + ctx.Set("userid", userID) } diff --git a/test/filepath.go b/test/filepath.go new file mode 100644 index 000000000..11afd3762 --- /dev/null +++ b/test/filepath.go @@ -0,0 +1,29 @@ +package test + +import ( + "os" + "path" + "path/filepath" + "runtime" +) + +// GetProjectDir returns the currect apsolute path of this project +func GetProjectDir() string { + _, f, _, _ := runtime.Caller(0) + projectDir, _ := filepath.Abs(path.Join(filepath.Dir(f), "../")) + return projectDir +} + +// WithWd executes a function with the specified working directory +func WithWd(chDir string, f func(origWd string)) { + wd, err := os.Getwd() + if err != nil { + panic(err) + } + + if err := os.Chdir(chDir); err != nil { + panic(err) + } + defer os.Chdir(wd) + f(wd) +} diff --git a/test/filepath_test.go b/test/filepath_test.go new file mode 100644 index 000000000..cb0ddee85 --- /dev/null +++ b/test/filepath_test.go @@ -0,0 +1,48 @@ +package test + +import ( + "os" + "path" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestProjectPath(t *testing.T) { + _, err := os.Stat(path.Join(GetProjectDir(), "./README.md")) + assert.Nil(t, err) +} + +func TestWithWd(t *testing.T) { + wd1, _ := os.Getwd() + tmpDir := NewTmpDir("gotify_withwd") + defer tmpDir.Clean() + var wd2 string + WithWd(tmpDir.Path(), func(origWd string) { + assert.Equal(t, wd1, origWd) + wd2, _ = os.Getwd() + }) + wd3, _ := os.Getwd() + assert.Equal(t, wd1, wd3) + assert.Equal(t, tmpDir.Path(), wd2) + assert.Nil(t, os.RemoveAll(tmpDir.Path())) + + assert.Panics(t, func() { + WithWd("non_exist", func(string) {}) + }) + + assert.Nil(t, os.Mkdir(tmpDir.Path(), 0644)) + assert.Panics(t, func() { + WithWd(tmpDir.Path(), func(string) {}) + }) + assert.Nil(t, os.Remove(tmpDir.Path())) + + assert.Nil(t, os.Mkdir(tmpDir.Path(), 0755)) + assert.Panics(t, func() { + WithWd(tmpDir.Path(), func(string) { + assert.Nil(t, os.RemoveAll(tmpDir.Path())) + WithWd(".", func(string) {}) + }) + }) + +} diff --git a/test/database.go b/test/testdb/database.go similarity index 71% rename from test/database.go rename to test/testdb/database.go index 0b48ae3ba..7a80b2972 100644 --- a/test/database.go +++ b/test/testdb/database.go @@ -1,4 +1,4 @@ -package test +package testdb import ( "fmt" @@ -64,31 +64,76 @@ func (d *Database) NewUserWithName(id uint, name string) *model.User { // App creates an application and returns a message builder. func (ab *AppClientBuilder) App(id uint) *MessageBuilder { - return ab.AppWithToken(id, "app"+fmt.Sprint(id)) + return ab.app(id, false) +} + +// InternalApp creates an internal application and returns a message builder. +func (ab *AppClientBuilder) InternalApp(id uint) *MessageBuilder { + return ab.app(id, true) +} + +func (ab *AppClientBuilder) app(id uint, internal bool) *MessageBuilder { + return ab.appWithToken(id, "app"+fmt.Sprint(id), internal) } // AppWithToken creates an application with a token and returns a message builder. func (ab *AppClientBuilder) AppWithToken(id uint, token string) *MessageBuilder { - ab.NewAppWithToken(id, token) + return ab.appWithToken(id, token, false) +} + +// InternalAppWithToken creates an internal application with a token and returns a message builder. +func (ab *AppClientBuilder) InternalAppWithToken(id uint, token string) *MessageBuilder { + return ab.appWithToken(id, token, true) +} + +func (ab *AppClientBuilder) appWithToken(id uint, token string, internal bool) *MessageBuilder { + ab.newAppWithToken(id, token, internal) return &MessageBuilder{db: ab.db, appID: id} } // NewAppWithToken creates an application with a token and returns the app. func (ab *AppClientBuilder) NewAppWithToken(id uint, token string) *model.Application { - application := &model.Application{ID: id, UserID: ab.userID, Token: token} + return ab.newAppWithToken(id, token, false) +} + +// NewInternalAppWithToken creates an internal application with a token and returns the app. +func (ab *AppClientBuilder) NewInternalAppWithToken(id uint, token string) *model.Application { + return ab.newAppWithToken(id, token, true) +} + +func (ab *AppClientBuilder) newAppWithToken(id uint, token string, internal bool) *model.Application { + application := &model.Application{ID: id, UserID: ab.userID, Token: token, Internal: internal} ab.db.CreateApplication(application) return application } // AppWithTokenAndName creates an application with a token and name and returns a message builder. func (ab *AppClientBuilder) AppWithTokenAndName(id uint, token, name string) *MessageBuilder { - ab.NewAppWithTokenAndName(id, token, name) + return ab.appWithTokenAndName(id, token, name, false) +} + +// InternalAppWithTokenAndName creates an internal application with a token and name and returns a message builder. +func (ab *AppClientBuilder) InternalAppWithTokenAndName(id uint, token, name string) *MessageBuilder { + return ab.appWithTokenAndName(id, token, name, true) +} + +func (ab *AppClientBuilder) appWithTokenAndName(id uint, token, name string, internal bool) *MessageBuilder { + ab.newAppWithTokenAndName(id, token, name, internal) return &MessageBuilder{db: ab.db, appID: id} } // NewAppWithTokenAndName creates an application with a token and name and returns the app. func (ab *AppClientBuilder) NewAppWithTokenAndName(id uint, token, name string) *model.Application { - application := &model.Application{ID: id, UserID: ab.userID, Token: token, Name: name} + return ab.newAppWithTokenAndName(id, token, name, false) +} + +// NewInternalAppWithTokenAndName creates an internal application with a token and name and returns the app. +func (ab *AppClientBuilder) NewInternalAppWithTokenAndName(id uint, token, name string) *model.Application { + return ab.newAppWithTokenAndName(id, token, name, true) +} + +func (ab *AppClientBuilder) newAppWithTokenAndName(id uint, token, name string, internal bool) *model.Application { + application := &model.Application{ID: id, UserID: ab.userID, Token: token, Name: name, Internal: internal} ab.db.CreateApplication(application) return application } diff --git a/test/database_test.go b/test/testdb/database_test.go similarity index 75% rename from test/database_test.go rename to test/testdb/database_test.go index 9bc4b78db..84a37b463 100644 --- a/test/database_test.go +++ b/test/testdb/database_test.go @@ -1,17 +1,17 @@ -package test_test +package testdb_test import ( "testing" "github.com/gotify/server/mode" "github.com/gotify/server/model" - "github.com/gotify/server/test" + "github.com/gotify/server/test/testdb" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" ) func Test_WithDefault(t *testing.T) { - db := test.NewDBWithDefaultUser(t) + db := testdb.NewDBWithDefaultUser(t) assert.NotNil(t, db.GetUserByName("admin")) db.Close() } @@ -22,12 +22,12 @@ func TestDatabaseSuite(t *testing.T) { type DatabaseSuite struct { suite.Suite - db *test.Database + db *testdb.Database } func (s *DatabaseSuite) BeforeTest(suiteName, testName string) { mode.Set(mode.TestDev) - s.db = test.NewDB(s.T()) + s.db = testdb.NewDB(s.T()) } func (s *DatabaseSuite) AfterTest(suiteName, testName string) { @@ -88,32 +88,46 @@ func (s *DatabaseSuite) Test_Apps() { userBuilder := s.db.User(1) userBuilder.App(1) newAppActual := userBuilder.NewAppWithToken(2, "asdf") + newInternalAppActual := userBuilder.NewInternalAppWithToken(3, "qwer") - s.db.User(2).App(5) + s.db.User(2).InternalApp(5) newAppExpected := &model.Application{ID: 2, Token: "asdf", UserID: 1} + newInternalAppExpected := &model.Application{ID: 3, Token: "qwer", UserID: 1, Internal: true} assert.Equal(s.T(), newAppExpected, newAppActual) + assert.Equal(s.T(), newInternalAppExpected, newInternalAppActual) - userOneExpected := []*model.Application{{ID: 1, Token: "app1", UserID: 1}, {ID: 2, Token: "asdf", UserID: 1}} + userOneExpected := []*model.Application{{ID: 1, Token: "app1", UserID: 1}, {ID: 2, Token: "asdf", UserID: 1}, {ID: 3, Token: "qwer", UserID: 1, Internal: true}} assert.Equal(s.T(), userOneExpected, s.db.GetApplicationsByUser(1)) - userTwoExpected := []*model.Application{{ID: 5, Token: "app5", UserID: 2}} + userTwoExpected := []*model.Application{{ID: 5, Token: "app5", UserID: 2, Internal: true}} assert.Equal(s.T(), userTwoExpected, s.db.GetApplicationsByUser(2)) newAppWithName := userBuilder.NewAppWithTokenAndName(7, "test-token", "app name") newAppWithNameExpected := &model.Application{ID: 7, Token: "test-token", UserID: 1, Name: "app name"} assert.Equal(s.T(), newAppWithNameExpected, newAppWithName) - userBuilder.AppWithTokenAndName(8, "test-token-2", "app name") + newInternalAppWithName := userBuilder.NewInternalAppWithTokenAndName(8, "test-tokeni", "app name") + newInternalAppWithNameExpected := &model.Application{ID: 8, Token: "test-tokeni", UserID: 1, Name: "app name", Internal: true} + assert.Equal(s.T(), newInternalAppWithNameExpected, newInternalAppWithName) + + userBuilder.AppWithTokenAndName(9, "test-token-2", "app name") + userBuilder.InternalAppWithTokenAndName(10, "test-tokeni-2", "app name") + userBuilder.AppWithToken(11, "test-token-3") + userBuilder.InternalAppWithToken(12, "test-tokeni-3") s.db.AssertAppExist(1) s.db.AssertAppExist(2) - s.db.AssertAppNotExist(3) + s.db.AssertAppExist(3) s.db.AssertAppNotExist(4) s.db.AssertAppExist(5) s.db.AssertAppNotExist(6) s.db.AssertAppExist(7) s.db.AssertAppExist(8) + s.db.AssertAppExist(9) + s.db.AssertAppExist(10) + s.db.AssertAppExist(11) + s.db.AssertAppExist(12) s.db.DeleteApplicationByID(2) diff --git a/test/tmpdir.go b/test/tmpdir.go new file mode 100644 index 000000000..5fbac7413 --- /dev/null +++ b/test/tmpdir.go @@ -0,0 +1,28 @@ +package test + +import ( + "io/ioutil" + "os" + "path" +) + +// TmpDir is a handler to temporary directory +type TmpDir struct { + path string +} + +// Path returns the path to the temporary directory joined by the elements provided +func (c TmpDir) Path(elem ...string) string { + return path.Join(append([]string{c.path}, elem...)...) +} + +// Clean removes the TmpDir +func (c TmpDir) Clean() error { + return os.RemoveAll(c.path) +} + +// NewTmpDir returns a new handle to a tmp dir +func NewTmpDir(prefix string) TmpDir { + dir, _ := ioutil.TempDir("", prefix) + return TmpDir{dir} +} diff --git a/test/tmpdir_test.go b/test/tmpdir_test.go new file mode 100644 index 000000000..daa777f86 --- /dev/null +++ b/test/tmpdir_test.go @@ -0,0 +1,19 @@ +package test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestTmpDir(t *testing.T) { + dir := NewTmpDir("test_prefix") + assert.NotEmpty(t, dir) + + assert.Contains(t, dir.Path(), "test_prefix") + testFilePath := dir.Path("testfile.txt") + assert.Contains(t, testFilePath, "test_prefix") + assert.Contains(t, testFilePath, "testfile.txt") + assert.True(t, strings.HasPrefix(testFilePath, dir.Path())) +} diff --git a/ui/package-lock.json b/ui/package-lock.json index d049495e6..82c12b292 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -174,12 +174,27 @@ } } }, + "@types/codemirror": { + "version": "0.0.71", + "resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-0.0.71.tgz", + "integrity": "sha512-b2oEEnno1LIGKMR7uBEsr40al1UijF1HEpRn0+Yf1xOLl24iQgB7DBpZVMM7y54G5wCNoclDrRO65E6KHPNO2w==", + "dev": true, + "requires": { + "@types/tern": "*" + } + }, "@types/detect-browser": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@types/detect-browser/-/detect-browser-2.0.1.tgz", "integrity": "sha512-n9jH0zq0DGOlu/B9tSpK+DKwaW9uozF96hy3zYibvxVqQDX5KOJJn6qns/G+k3UwfEQVYZuV3Rz+Z2fXMQ0ang==", "dev": true }, + "@types/estree": { + "version": "0.0.39", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz", + "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==", + "dev": true + }, "@types/events": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@types/events/-/events-1.2.0.tgz", @@ -333,6 +348,15 @@ "@types/node": "*" } }, + "@types/tern": { + "version": "0.22.1", + "resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.22.1.tgz", + "integrity": "sha512-CRzPRkg8hYLwunsj61r+rqPJQbiCIEQqlMMY/0k7krgIsoSaFgGg1ZH2f9qaR1YpenaMl6PnlTtUkCbNH/uo+A==", + "dev": true, + "requires": { + "@types/estree": "*" + } + }, "abab": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.0.tgz", @@ -1717,6 +1741,11 @@ "integrity": "sha512-q/UEjfGJ2Cm3oKV71DJz9d25TPnq5rhBVL2Q4fA5wcC3jcrdn7+SssEybFIxwAvvP+YCsCYNKughoF33GxgycQ==", "dev": true }, + "bail": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bail/-/bail-1.0.3.tgz", + "integrity": "sha512-1X8CnjFVQ+a+KW36uBNMTU5s8+v5FzeqrP7hTG5aTb4aPreSbZJlhwPon9VKMuEVgV++JM+SQrALY3kr7eswdg==" + }, "balanced-match": { "version": "0.4.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-0.4.2.tgz", @@ -2319,6 +2348,21 @@ "resolved": "https://registry.npmjs.org/change-emitter/-/change-emitter-0.1.6.tgz", "integrity": "sha1-6LL+PX8at9aaMhma/5HqaTFAlRU=" }, + "character-entities": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-1.2.2.tgz", + "integrity": "sha512-sMoHX6/nBiy3KKfC78dnEalnpn0Az0oSNvqUWYTtYrhRI5iUIYsROU48G+E+kMFQzqXaJ8kHJZ85n7y6/PHgwQ==" + }, + "character-entities-legacy": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-1.1.2.tgz", + "integrity": "sha512-9NB2VbXtXYWdXzqrvAHykE/f0QJxzaKIpZ5QzNZrrgQ7Iyxr2vnfS8fCBNVW9nUEZE0lo57nxKRqnzY/dKrwlA==" + }, + "character-reference-invalid": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-1.1.2.tgz", + "integrity": "sha512-7I/xceXfKyUJmSAn/jw8ve/9DyOP7XxufNYLI9Px7CmsKgEUaZLUTax6nZxGQtaoiZCjpu6cHPj20xC/vqRReQ==" + }, "chardet": { "version": "0.4.2", "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.4.2.tgz", @@ -2477,6 +2521,16 @@ "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=", "dev": true }, + "codemirror": { + "version": "5.43.0", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.43.0.tgz", + "integrity": "sha512-mljwQWUaWIf85I7QwTBryF2ASaIvmYAL4s5UCanCJFfKeXOKhrqdHWdHiZWAMNT+hjLTCnVx2S/SYTORIgxsgA==" + }, + "collapse-white-space": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/collapse-white-space/-/collapse-white-space-1.0.4.tgz", + "integrity": "sha512-YfQ1tAUZm561vpYD+5eyWN8+UsceQbSrqqlc/6zDY2gtAE+uZLSdkkovhnGpmCThsvKBFakq4EdY/FF93E8XIw==" + }, "collection-visit": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/collection-visit/-/collection-visit-1.0.0.tgz", @@ -3421,7 +3475,6 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.1.0.tgz", "integrity": "sha1-BzxpdUbOB4DOI75KKOKT5AvDDII=", - "dev": true, "requires": { "domelementtype": "~1.1.1", "entities": "~1.1.1" @@ -3430,8 +3483,7 @@ "domelementtype": { "version": "1.1.3", "resolved": "http://registry.npmjs.org/domelementtype/-/domelementtype-1.1.3.tgz", - "integrity": "sha1-vSh3PiZCiBrsUVRJJCmcXNgiGFs=", - "dev": true + "integrity": "sha1-vSh3PiZCiBrsUVRJJCmcXNgiGFs=" } } }, @@ -3453,8 +3505,7 @@ "domelementtype": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.0.tgz", - "integrity": "sha1-sXrtguirWeUt2cGbF1bg/BhyBMI=", - "dev": true + "integrity": "sha1-sXrtguirWeUt2cGbF1bg/BhyBMI=" }, "domexception": { "version": "1.0.1", @@ -3478,7 +3529,6 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/domutils/-/domutils-1.5.1.tgz", "integrity": "sha1-3NhIiib1Y9YQeeSMn3t+Mjc2gs8=", - "dev": true, "requires": { "dom-serializer": "0", "domelementtype": "1" @@ -3611,8 +3661,7 @@ "entities": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/entities/-/entities-1.1.1.tgz", - "integrity": "sha1-blwtClYhtdra7O+AuQ7ftc13cvA=", - "dev": true + "integrity": "sha1-blwtClYhtdra7O+AuQ7ftc13cvA=" }, "enzyme-adapter-react-16": { "version": "1.1.1", @@ -3773,8 +3822,7 @@ "escape-string-regexp": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", - "dev": true + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=" }, "escodegen": { "version": "1.11.0", @@ -4089,8 +4137,7 @@ "extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "dev": true + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" }, "extend-shallow": { "version": "3.0.2", @@ -4550,7 +4597,8 @@ "ansi-regex": { "version": "2.1.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "aproba": { "version": "1.2.0", @@ -4571,12 +4619,14 @@ "balanced-match": { "version": "1.0.0", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, "dev": true, + "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -4591,17 +4641,20 @@ "code-point-at": { "version": "1.1.0", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "concat-map": { "version": "0.0.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "console-control-strings": { "version": "1.1.0", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "core-util-is": { "version": "1.0.2", @@ -4718,7 +4771,8 @@ "inherits": { "version": "2.0.3", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "ini": { "version": "1.3.5", @@ -4730,6 +4784,7 @@ "version": "1.0.0", "bundled": true, "dev": true, + "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -4744,6 +4799,7 @@ "version": "3.0.4", "bundled": true, "dev": true, + "optional": true, "requires": { "brace-expansion": "^1.1.7" } @@ -4751,12 +4807,14 @@ "minimist": { "version": "0.0.8", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "minipass": { "version": "2.2.4", "bundled": true, "dev": true, + "optional": true, "requires": { "safe-buffer": "^5.1.1", "yallist": "^3.0.0" @@ -4775,6 +4833,7 @@ "version": "0.5.1", "bundled": true, "dev": true, + "optional": true, "requires": { "minimist": "0.0.8" } @@ -4855,7 +4914,8 @@ "number-is-nan": { "version": "1.0.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "object-assign": { "version": "4.1.1", @@ -4867,6 +4927,7 @@ "version": "1.4.0", "bundled": true, "dev": true, + "optional": true, "requires": { "wrappy": "1" } @@ -4952,7 +5013,8 @@ "safe-buffer": { "version": "5.1.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "safer-buffer": { "version": "2.1.2", @@ -4988,6 +5050,7 @@ "version": "1.0.2", "bundled": true, "dev": true, + "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -5007,6 +5070,7 @@ "version": "3.0.1", "bundled": true, "dev": true, + "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -5050,12 +5114,14 @@ "wrappy": { "version": "1.0.2", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "yallist": { "version": "3.0.2", "bundled": true, - "dev": true + "dev": true, + "optional": true } } }, @@ -5526,6 +5592,51 @@ "uglify-js": "3.4.x" } }, + "html-to-react": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/html-to-react/-/html-to-react-1.3.4.tgz", + "integrity": "sha512-/tWDdb/8Koi/QEP5YUY1653PcDpBnnMblXRhotnTuhFDjI1Fc6Wzox5d4sw73Xk5rM2OdM5np4AYjT/US/Wj7Q==", + "requires": { + "domhandler": "^2.4.2", + "escape-string-regexp": "^1.0.5", + "htmlparser2": "^3.10.0", + "lodash.camelcase": "^4.3.0", + "ramda": "^0.26" + }, + "dependencies": { + "domhandler": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-2.4.2.tgz", + "integrity": "sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA==", + "requires": { + "domelementtype": "1" + } + }, + "htmlparser2": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.10.0.tgz", + "integrity": "sha512-J1nEUGv+MkXS0weHNWVKJJ+UrLfePxRWpN3C9bEi9fLxL2+ggW94DQvgYVXsaT30PGwYRIZKNZXuyMhp3Di4bQ==", + "requires": { + "domelementtype": "^1.3.0", + "domhandler": "^2.3.0", + "domutils": "^1.5.1", + "entities": "^1.1.1", + "inherits": "^2.0.1", + "readable-stream": "^3.0.6" + } + }, + "readable-stream": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.1.1.tgz", + "integrity": "sha512-DkN66hPyqDhnIQ6Jcsvx9bFjhw214O4poMBcIMgPVpQvNy9a0e0Uhg5SqySyDKAmUlwt8LonTBz1ezOnM8pUdA==", + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + } + } + }, "html-webpack-plugin": { "version": "2.29.0", "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-2.29.0.tgz", @@ -5918,8 +6029,7 @@ "inherits": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", - "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=", - "dev": true + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=" }, "ini": { "version": "1.3.5", @@ -6044,6 +6154,20 @@ } } }, + "is-alphabetical": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-1.0.2.tgz", + "integrity": "sha512-V0xN4BYezDHcBSKb1QHUFMlR4as/XEuCZBzMJUU4n7+Cbt33SmUnSol+pnXFvLxSHNq2CemUXNdaXV6Flg7+xg==" + }, + "is-alphanumerical": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-1.0.2.tgz", + "integrity": "sha512-pyfU/0kHdISIgslFfZN9nfY1Gk3MquQgUm1mJTjdkEPpkAKNWuBTSqFwewOpR7N351VkErCiyV71zX7mlQQqsg==", + "requires": { + "is-alphabetical": "^1.0.0", + "is-decimal": "^1.0.0" + } + }, "is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -6112,6 +6236,11 @@ "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.1.tgz", "integrity": "sha1-mqIOtq7rv/d/vTPnTKAbM1gdOhY=" }, + "is-decimal": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-1.0.2.tgz", + "integrity": "sha512-TRzl7mOCchnhchN+f3ICUCzYvL9ul7R+TYOsZ8xia++knyZAJfv/uA1FvQXsAnYIl1T3B2X5E/J7Wb1QXiIBXg==" + }, "is-descriptor": { "version": "0.1.6", "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz", @@ -6199,6 +6328,11 @@ "is-extglob": "^1.0.0" } }, + "is-hexadecimal": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-1.0.2.tgz", + "integrity": "sha512-but/G3sapV3MNyqiDBLrOi4x8uCIw0RY3o/Vb5GT0sMFHrVV7731wFSVy41T5FO1og7G0gXLJh0MkgPRouko/A==" + }, "is-in-browser": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/is-in-browser/-/is-in-browser-1.1.3.tgz", @@ -6273,8 +6407,7 @@ "is-plain-obj": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", - "integrity": "sha1-caUMhCnfync8kqOQpKA7OfzVHT4=", - "dev": true + "integrity": "sha1-caUMhCnfync8kqOQpKA7OfzVHT4=" }, "is-plain-object": { "version": "2.0.4", @@ -6359,12 +6492,22 @@ "integrity": "sha1-Sw2hRCEE0bM2NA6AeX6GXPOffXI=", "dev": true }, + "is-whitespace-character": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-whitespace-character/-/is-whitespace-character-1.0.2.tgz", + "integrity": "sha512-SzM+T5GKUCtLhlHFKt2SDAX2RFzfS6joT91F2/WSi9LxgFdsnhfPK/UIA+JhRR2xuyLdrCys2PiFDrtn1fU5hQ==" + }, "is-windows": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", "dev": true }, + "is-word-character": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-word-character/-/is-word-character-1.0.2.tgz", + "integrity": "sha512-T3FlsX8rCHAH8e7RE7PfOPZVFQlcV3XRF9eOOBQ1uf70OxO7CjjSOjeImMPCADBdYWcStAbVbYvJ1m2D3tb+EA==" + }, "is-wsl": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-1.1.0.tgz", @@ -7731,8 +7874,7 @@ "lodash.camelcase": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", - "integrity": "sha1-soqmKIorn8ZRA1x3EfZathkDMaY=", - "dev": true + "integrity": "sha1-soqmKIorn8ZRA1x3EfZathkDMaY=" }, "lodash.debounce": { "version": "4.0.8", @@ -7916,6 +8058,11 @@ "object-visit": "^1.0.0" } }, + "markdown-escapes": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/markdown-escapes/-/markdown-escapes-1.0.2.tgz", + "integrity": "sha512-lbRZ2mE3Q9RtLjxZBZ9+IMl68DKIXaVAhwvwn9pmjnPLS0h/6kyBMgNhqi1xFJ/2yv6cSyv0jbiZavZv93JkkA==" + }, "math-expression-evaluator": { "version": "1.2.17", "resolved": "https://registry.npmjs.org/math-expression-evaluator/-/math-expression-evaluator-1.2.17.tgz", @@ -7938,6 +8085,14 @@ "inherits": "^2.0.1" } }, + "mdast-add-list-metadata": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mdast-add-list-metadata/-/mdast-add-list-metadata-1.0.1.tgz", + "integrity": "sha512-fB/VP4MJ0LaRsog7hGPxgOrSL3gE/2uEdZyDuSEnKCv/8IkYHiDkIQSbChiJoHyxZZXZ9bzckyRk+vNxFzh8rA==", + "requires": { + "unist-util-visit-parents": "1.1.2" + } + }, "media-typer": { "version": "0.3.0", "resolved": "http://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -8737,6 +8892,19 @@ "pbkdf2": "^3.0.3" } }, + "parse-entities": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-1.2.0.tgz", + "integrity": "sha512-XXtDdOPLSB0sHecbEapQi6/58U/ODj/KWfIXmmMCJF/eRn8laX6LZbOyioMoETOOJoWRW8/qTSl5VQkUIfKM5g==", + "requires": { + "character-entities": "^1.0.0", + "character-entities-legacy": "^1.0.0", + "character-reference-invalid": "^1.0.0", + "is-alphanumerical": "^1.0.0", + "is-decimal": "^1.0.0", + "is-hexadecimal": "^1.0.0" + } + }, "parse-glob": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/parse-glob/-/parse-glob-3.0.4.tgz", @@ -8929,7 +9097,7 @@ "dependencies": { "async": { "version": "1.5.2", - "resolved": "http://registry.npmjs.org/async/-/async-1.5.2.tgz", + "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz", "integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=", "dev": true } @@ -10492,6 +10660,11 @@ "performance-now": "^2.1.0" } }, + "ramda": { + "version": "0.26.1", + "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.26.1.tgz", + "integrity": "sha512-hLWjpy7EnsDBb0p+Z3B7rPi3GDeRG5ZtiI33kJhTt+ORCd38AbAIjB/9zRIUoeTbE/AVX5ZkU7m6bznsvrf8eQ==" + }, "randomatic": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/randomatic/-/randomatic-3.1.0.tgz", @@ -10611,6 +10784,11 @@ "prop-types": "^15.6.0" } }, + "react-codemirror2": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/react-codemirror2/-/react-codemirror2-5.1.0.tgz", + "integrity": "sha512-Cksbgbviuf2mJfMyrKmcu7ycK6zX/ukuQO8dvRZdFWqATf5joalhjFc6etnBdGCcPA2LbhIwz+OPnQxLN/j1Fw==" + }, "react-dev-utils": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-5.0.2.tgz", @@ -10714,6 +10892,20 @@ "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==" }, + "react-markdown": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-4.0.6.tgz", + "integrity": "sha512-E1d/q+OBk5eumId42oYqVrJRB/+whrZdk+YHqUBCCNeWxqeV+Qzt+yLTsft9+4HRDj89Od7eAbUPQBYq8ZwShQ==", + "requires": { + "html-to-react": "^1.3.4", + "mdast-add-list-metadata": "1.0.1", + "prop-types": "^15.6.1", + "remark-parse": "^5.0.0", + "unified": "^6.1.5", + "unist-util-visit": "^1.3.0", + "xtend": "^4.0.1" + } + }, "react-reconciler": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.7.0.tgz", @@ -11090,7 +11282,7 @@ "dependencies": { "jsesc": { "version": "0.5.0", - "resolved": "http://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz", "integrity": "sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0=", "dev": true } @@ -11102,6 +11294,28 @@ "integrity": "sha1-VNvzd+UUQKypCkzSdGANP/LYiKk=", "dev": true }, + "remark-parse": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-5.0.0.tgz", + "integrity": "sha512-b3iXszZLH1TLoyUzrATcTQUZrwNl1rE70rVdSruJFlDaJ9z5aMkhrG43Pp68OgfHndL/ADz6V69Zow8cTQu+JA==", + "requires": { + "collapse-white-space": "^1.0.2", + "is-alphabetical": "^1.0.0", + "is-decimal": "^1.0.0", + "is-whitespace-character": "^1.0.0", + "is-word-character": "^1.0.0", + "markdown-escapes": "^1.0.0", + "parse-entities": "^1.1.0", + "repeat-string": "^1.5.4", + "state-toggle": "^1.0.0", + "trim": "0.0.1", + "trim-trailing-lines": "^1.0.0", + "unherit": "^1.0.4", + "unist-util-remove-position": "^1.0.0", + "vfile-location": "^2.0.0", + "xtend": "^4.0.1" + } + }, "remove-trailing-separator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz", @@ -11138,8 +11352,7 @@ "repeat-string": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", - "integrity": "sha1-jcrkcOHIirwtYA//Sndihtp15jc=", - "dev": true + "integrity": "sha1-jcrkcOHIirwtYA//Sndihtp15jc=" }, "repeating": { "version": "2.0.1", @@ -11150,6 +11363,11 @@ "is-finite": "^1.0.0" } }, + "replace-ext": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/replace-ext/-/replace-ext-1.0.0.tgz", + "integrity": "sha1-3mMSg3P8v3w8z6TeWkgMRaZ5WOs=" + }, "request": { "version": "2.88.0", "resolved": "https://registry.npmjs.org/request/-/request-2.88.0.tgz", @@ -11359,8 +11577,7 @@ "safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" }, "safe-regex": { "version": "1.1.0", @@ -11956,6 +12173,11 @@ "integrity": "sha1-1PM6tU6OOHeLDKXP07OvsS22hiA=", "dev": true }, + "state-toggle": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/state-toggle/-/state-toggle-1.0.1.tgz", + "integrity": "sha512-Qe8QntFrrpWTnHwvwj2FZTgv+PKIsp0B9VxLzLLbSpPXWOgRgc5LVj/aTiSfK1RqIeF9jeC1UeOH8Q8y60A7og==" + }, "static-extend": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/static-extend/-/static-extend-0.1.2.tgz", @@ -12092,7 +12314,6 @@ "version": "1.1.1", "resolved": "http://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, "requires": { "safe-buffer": "~5.1.0" } @@ -12448,6 +12669,11 @@ "integrity": "sha512-DlX6dR0lOIRDFxI0mjL9IYg6OTncLm/Zt+JiBhE5OlFcAR8yc9S7FFXU9so0oda47frdM/JFsk7UjNt9vscKcg==", "dev": true }, + "trim": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/trim/-/trim-0.0.1.tgz", + "integrity": "sha1-WFhUf2spB1fulczMZm+1AITEYN0=" + }, "trim-newlines": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-1.0.0.tgz", @@ -12460,6 +12686,16 @@ "integrity": "sha1-yy4SAwZ+DI3h9hQJS5/kVwTqYAM=", "dev": true }, + "trim-trailing-lines": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/trim-trailing-lines/-/trim-trailing-lines-1.1.1.tgz", + "integrity": "sha512-bWLv9BbWbbd7mlqqs2oQYnLD/U/ZqeJeJwbO0FG2zA1aTq+HTvxfHNKFa/HGCVyJpDiioUYaBhfiT6rgk+l4mg==" + }, + "trough": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/trough/-/trough-1.0.3.tgz", + "integrity": "sha512-fwkLWH+DimvA4YCy+/nvJd61nWQQ2liO/nF/RjkTpiOGi+zxZzVkhb1mvbHIIW4b/8nDsYI8uTmAlc0nNkRMOw==" + }, "ts-jest": { "version": "22.0.1", "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-22.0.1.tgz", @@ -12540,7 +12776,7 @@ "dependencies": { "json5": { "version": "1.0.1", - "resolved": "http://registry.npmjs.org/json5/-/json5-1.0.1.tgz", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz", "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==", "dev": true, "requires": { @@ -12549,7 +12785,7 @@ }, "minimist": { "version": "1.2.0", - "resolved": "http://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=", "dev": true }, @@ -12812,6 +13048,28 @@ } } }, + "unherit": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/unherit/-/unherit-1.1.1.tgz", + "integrity": "sha512-+XZuV691Cn4zHsK0vkKYwBEwB74T3IZIcxrgn2E4rKwTfFyI1zCh7X7grwh9Re08fdPlarIdyWgI8aVB3F5A5g==", + "requires": { + "inherits": "^2.0.1", + "xtend": "^4.0.1" + } + }, + "unified": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/unified/-/unified-6.2.0.tgz", + "integrity": "sha512-1k+KPhlVtqmG99RaTbAv/usu85fcSRu3wY8X+vnsEhIxNP5VbVIDiXnLqyKIG+UMdyTg0ZX9EI6k2AfjJkHPtA==", + "requires": { + "bail": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^1.1.0", + "trough": "^1.0.0", + "vfile": "^2.0.0", + "x-is-string": "^0.1.0" + } + }, "union-value": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.0.tgz", @@ -12886,6 +13144,47 @@ "crypto-random-string": "^1.0.0" } }, + "unist-util-is": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-2.1.2.tgz", + "integrity": "sha512-YkXBK/H9raAmG7KXck+UUpnKiNmUdB+aBGrknfQ4EreE1banuzrKABx3jP6Z5Z3fMSPMQQmeXBlKpCbMwBkxVw==" + }, + "unist-util-remove-position": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-1.1.2.tgz", + "integrity": "sha512-XxoNOBvq1WXRKXxgnSYbtCF76TJrRoe5++pD4cCBsssSiWSnPEktyFrFLE8LTk3JW5mt9hB0Sk5zn4x/JeWY7Q==", + "requires": { + "unist-util-visit": "^1.1.0" + } + }, + "unist-util-stringify-position": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-1.1.2.tgz", + "integrity": "sha512-pNCVrk64LZv1kElr0N1wPiHEUoXNVFERp+mlTg/s9R5Lwg87f9bM/3sQB99w+N9D/qnM9ar3+AKDBwo/gm/iQQ==" + }, + "unist-util-visit": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-1.4.0.tgz", + "integrity": "sha512-FiGu34ziNsZA3ZUteZxSFaczIjGmksfSgdKqBfOejrrfzyUy5b7YrlzT1Bcvi+djkYDituJDy2XB7tGTeBieKw==", + "requires": { + "unist-util-visit-parents": "^2.0.0" + }, + "dependencies": { + "unist-util-visit-parents": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-2.0.1.tgz", + "integrity": "sha512-6B0UTiMfdWql4cQ03gDTCSns+64Zkfo2OCbK31Ov0uMizEz+CJeAp0cgZVb5Fhmcd7Bct2iRNywejT0orpbqUA==", + "requires": { + "unist-util-is": "^2.1.2" + } + } + } + }, + "unist-util-visit-parents": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-1.1.2.tgz", + "integrity": "sha512-yvo+MMLjEwdc3RhhPYSximset7rwjMrdt9E41Smmvg25UQIenzrN83cRnF1JMzoMi9zZOQeYXHSDf7p+IQkW3Q==" + }, "universalify": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", @@ -13074,8 +13373,7 @@ "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", - "dev": true + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" }, "util.promisify": { "version": "1.0.0", @@ -13143,6 +13441,30 @@ "extsprintf": "^1.2.0" } }, + "vfile": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-2.3.0.tgz", + "integrity": "sha512-ASt4mBUHcTpMKD/l5Q+WJXNtshlWxOogYyGYYrg4lt/vuRjC1EFQtlAofL5VmtVNIZJzWYFJjzGWZ0Gw8pzW1w==", + "requires": { + "is-buffer": "^1.1.4", + "replace-ext": "1.0.0", + "unist-util-stringify-position": "^1.0.0", + "vfile-message": "^1.0.0" + } + }, + "vfile-location": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-2.0.4.tgz", + "integrity": "sha512-KRL5uXQPoUKu+NGvQVL4XLORw45W62v4U4gxJ3vRlDfI9QsT4ZN1PNXn/zQpKUulqGDpYuT0XDfp5q9O87/y/w==" + }, + "vfile-message": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-1.1.1.tgz", + "integrity": "sha512-1WmsopSGhWt5laNir+633LszXvZ+Z/lxveBf6yhGsqnQIhlhzooZae7zV6YVM1Sdkw68dtAW3ow0pOdPANugvA==", + "requires": { + "unist-util-stringify-position": "^1.1.1" + } + }, "vm-browserify": { "version": "0.0.4", "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-0.0.4.tgz", @@ -13983,6 +14305,11 @@ "async-limiter": "~1.0.0" } }, + "x-is-string": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/x-is-string/-/x-is-string-0.1.0.tgz", + "integrity": "sha1-R0tQhlrzpJqcRlfwWs0UVFj3fYI=" + }, "xdg-basedir": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-3.0.0.tgz", @@ -13998,8 +14325,7 @@ "xtend": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.1.tgz", - "integrity": "sha1-pcbVMr5lbiPbgg77lDofBJmNY68=", - "dev": true + "integrity": "sha1-pcbVMr5lbiPbgg77lDofBJmNY68=" }, "y18n": { "version": "3.2.1", diff --git a/ui/package.json b/ui/package.json index eb64b2f8b..8964f55f3 100644 --- a/ui/package.json +++ b/ui/package.json @@ -6,6 +6,7 @@ "@material-ui/core": "^1.5.1", "@material-ui/icons": "^2.0.3", "axios": "^0.18.0", + "codemirror": "^5.43.0", "detect-browser": "^3.0.0", "mobx": "^5.1.1", "mobx-react": "^5.2.8", @@ -13,8 +14,10 @@ "notifyjs": "^3.0.0", "prop-types": "^15.6.2", "react": "^16.4.2", + "react-codemirror2": "^5.1.0", "react-dom": "^16.4.2", "react-infinite": "^0.13.0", + "react-markdown": "^4.0.6", "react-router": "^4.3.1", "react-router-dom": "^4.3.1", "react-timeago": "^4.1.9", @@ -32,6 +35,7 @@ "testformat": "prettier src/**/*.{ts,tsx} --list-different" }, "devDependencies": { + "@types/codemirror": "0.0.71", "@types/detect-browser": "^2.0.1", "@types/get-port": "^4.0.0", "@types/jest": "^23.3.1", @@ -46,8 +50,8 @@ "get-port": "^4.0.0", "prettier": "^1.14.2", "puppeteer": "^1.8.0", - "rimraf": "^2.6.2", "react-scripts-ts": "2.17.0", + "rimraf": "^2.6.2", "tree-kill": "^1.2.0", "tslint-sonarts": "^1.7.0", "typescript": "^3.0.1", diff --git a/ui/src/application/AddApplicationDialog.tsx b/ui/src/application/AddApplicationDialog.tsx index 9fbb39ab4..a9e60a03f 100644 --- a/ui/src/application/AddApplicationDialog.tsx +++ b/ui/src/application/AddApplicationDialog.tsx @@ -45,7 +45,7 @@ export default class AddDialog extends Component { margin="dense" className="name" label="Name *" - type="email" + type="text" value={name} onChange={this.handleChange.bind(this, 'name')} fullWidth diff --git a/ui/src/application/AppStore.ts b/ui/src/application/AppStore.ts index f58cbfac3..e4ae5a4a1 100644 --- a/ui/src/application/AppStore.ts +++ b/ui/src/application/AppStore.ts @@ -35,6 +35,13 @@ export class AppStore extends BaseStore { this.snack('Application image updated'); }; + @action + public update = async (id: number, name: string, description: string): Promise => { + await axios.put(`${config.get('url')}application/${id}`, {name, description}); + await this.refresh(); + this.snack('Application updated'); + }; + @action public create = async (name: string, description: string): Promise => { await axios.post(`${config.get('url')}application`, {name, description}); diff --git a/ui/src/application/Applications.tsx b/ui/src/application/Applications.tsx index 273717b48..656dc02f4 100644 --- a/ui/src/application/Applications.tsx +++ b/ui/src/application/Applications.tsx @@ -9,6 +9,7 @@ import TableHead from '@material-ui/core/TableHead'; import TableRow from '@material-ui/core/TableRow'; import Delete from '@material-ui/icons/Delete'; import Edit from '@material-ui/icons/Edit'; +import CloudUpload from '@material-ui/icons/CloudUpload'; import React, {ChangeEvent, Component, SFC} from 'react'; import ConfirmDialog from '../common/ConfirmDialog'; import DefaultPage from '../common/DefaultPage'; @@ -17,12 +18,15 @@ import AddApplicationDialog from './AddApplicationDialog'; import {observer} from 'mobx-react'; import {observable} from 'mobx'; import {inject, Stores} from '../inject'; +import UpdateDialog from './UpdateApplicationDialog'; @observer class Applications extends Component> { @observable private deleteId: number | false = false; @observable + private updateId: number | false = false; + @observable private createDialog = false; private uploadId = -1; @@ -34,6 +38,7 @@ class Applications extends Component> { const { createDialog, deleteId, + updateId, props: {appStore}, } = this; const apps = appStore.getItems(); @@ -54,6 +59,7 @@ class Applications extends Component> { Token Description + @@ -67,6 +73,8 @@ class Applications extends Component> { value={app.token} fUpload={() => this.uploadImage(app.id)} fDelete={() => (this.deleteId = app.id)} + fEdit={() => (this.updateId = app.id)} + noDelete={app.internal} /> ); })} @@ -86,6 +94,16 @@ class Applications extends Component> { fOnSubmit={appStore.create} /> )} + {updateId !== false && ( + (this.updateId = false)} + fOnSubmit={(name, description) => + appStore.update(updateId, name, description) + } + initialDescription={appStore.getByID(updateId).description} + initialName={appStore.getByID(updateId).name} + /> + )} {deleteId !== false && ( > { interface IRowProps { name: string; value: string; + noDelete: boolean; description: string; fUpload: VoidFunction; image: string; fDelete: VoidFunction; + fEdit: VoidFunction; } -const Row: SFC = observer(({name, value, description, fDelete, fUpload, image}) => ( - - -
- - +const Row: SFC = observer( + ({name, value, noDelete, description, fDelete, fUpload, image, fEdit}) => ( + + +
+ + + + +
+
+ {name} + + + + {description} + + -
-
- {name} - - - - {description} - - - - - -
-)); +
+ + + + + + + ) +); export default inject('appStore')(Applications); diff --git a/ui/src/application/UpdateApplicationDialog.tsx b/ui/src/application/UpdateApplicationDialog.tsx new file mode 100644 index 000000000..45bfa7a9b --- /dev/null +++ b/ui/src/application/UpdateApplicationDialog.tsx @@ -0,0 +1,93 @@ +import Button from '@material-ui/core/Button'; +import Dialog from '@material-ui/core/Dialog'; +import DialogActions from '@material-ui/core/DialogActions'; +import DialogContent from '@material-ui/core/DialogContent'; +import DialogContentText from '@material-ui/core/DialogContentText'; +import DialogTitle from '@material-ui/core/DialogTitle'; +import TextField from '@material-ui/core/TextField'; +import Tooltip from '@material-ui/core/Tooltip'; +import React, {Component} from 'react'; + +interface IProps { + fClose: VoidFunction; + fOnSubmit: (name: string, description: string) => void; + initialName: string; + initialDescription: string; +} + +interface IState { + name: string; + description: string; +} + +export default class UpdateDialog extends Component { + public state = {name: '', description: ''}; + + public componentWillMount() { + this.setState({name: this.props.initialName, description: this.props.initialDescription}); + } + + public render() { + const {fClose, fOnSubmit} = this.props; + const {name, description} = this.state; + const submitEnabled = this.state.name.length !== 0; + const submitAndClose = () => { + fOnSubmit(name, description); + fClose(); + }; + return ( + + Update an application + + + An application is allowed to send messages. + + + + + + + +
+ +
+
+
+
+ ); + } + + private handleChange(propertyName: string, event: React.ChangeEvent) { + const state = this.state; + state[propertyName] = event.target.value; + this.setState(state); + } +} diff --git a/ui/src/index.tsx b/ui/src/index.tsx index dc23c6643..7406a5f0b 100644 --- a/ui/src/index.tsx +++ b/ui/src/index.tsx @@ -15,6 +15,7 @@ import {InjectProvider, StoreMapping} from './inject'; import {UserStore} from './user/UserStore'; import {MessagesStore} from './message/MessagesStore'; import {ClientStore} from './client/ClientStore'; +import {PluginStore} from './plugin/PluginStore'; const defaultDevConfig = { url: 'http://localhost:80/', @@ -22,7 +23,7 @@ const defaultDevConfig = { const {port, hostname, protocol} = window.location; const slashes = protocol.concat('//'); -const url = slashes.concat(hostname.concat(':', port)); +const url = slashes.concat(port ? hostname.concat(':', port) : hostname); const urlWithSlash = url.endsWith('/') ? url : url.concat('/'); const defaultProdConfig = { @@ -44,6 +45,7 @@ const initStores = (): StoreMapping => { const currentUser = new CurrentUser(snackManager.snack); const clientStore = new ClientStore(snackManager.snack); const wsStore = new WebSocketStore(snackManager.snack, currentUser); + const pluginStore = new PluginStore(snackManager.snack); appStore.onDelete = () => messagesStore.clearAll(); return { @@ -54,6 +56,7 @@ const initStores = (): StoreMapping => { currentUser, clientStore, wsStore, + pluginStore, }; }; diff --git a/ui/src/inject.tsx b/ui/src/inject.tsx index 86efb5f64..a8b68fcd1 100644 --- a/ui/src/inject.tsx +++ b/ui/src/inject.tsx @@ -7,6 +7,7 @@ import {ClientStore} from './client/ClientStore'; import {AppStore} from './application/AppStore'; import {inject as mobxInject, Provider} from 'mobx-react'; import {WebSocketStore} from './message/WebSocketStore'; +import {PluginStore} from './plugin/PluginStore'; export interface StoreMapping { userStore: UserStore; @@ -15,6 +16,7 @@ export interface StoreMapping { currentUser: CurrentUser; clientStore: ClientStore; appStore: AppStore; + pluginStore: PluginStore; wsStore: WebSocketStore; } diff --git a/ui/src/layout/Header.tsx b/ui/src/layout/Header.tsx index afec630e5..54f5f5596 100644 --- a/ui/src/layout/Header.tsx +++ b/ui/src/layout/Header.tsx @@ -9,6 +9,7 @@ import Chat from '@material-ui/icons/Chat'; import DevicesOther from '@material-ui/icons/DevicesOther'; import ExitToApp from '@material-ui/icons/ExitToApp'; import Highlight from '@material-ui/icons/Highlight'; +import Apps from '@material-ui/icons/Apps'; import SupervisorAccount from '@material-ui/icons/SupervisorAccount'; import React, {Component} from 'react'; import {Link} from 'react-router-dom'; @@ -104,6 +105,12 @@ class Header extends Component {  clients + + + + ) : null} + + {description ? ( + + {description} + + ) : null} +
+
+ {children} +
+ + ); +}; + +interface IConfigurerPanelProps { + pluginInfo: IPlugin; + initialConfig: string; + save: (newConfig: string) => Promise; +} +class ConfigurerPanel extends Component { + public state = {unsavedChanges: null}; + + public render() { + return ( +
+ { + let newConf: string | null = value; + if (value === this.props.initialConfig) { + newConf = null; + } + this.setState({unsavedChanges: newConf}); + }} + /> +
+ +
+ ); + } +} + +interface IDisplayerPanelProps { + pluginInfo: IPlugin; + displayText: string; +} +const DisplayerPanel: React.SFC = ({pluginInfo, displayText}) => ( + + + +); + +class PluginInfo extends Component<{pluginInfo: IPlugin}> { + public render() { + const { + props: { + pluginInfo: {name, author, modulePath, website, license, capabilities, id, token}, + }, + } = this; + return ( +
+ {name ? ( + + Name: {name} + + ) : null} + {author ? ( + + Author: {author} + + ) : null} + + Module Path: {modulePath} + + {website ? ( + + Website: {website} + + ) : null} + {license ? ( + + License: {license} + + ) : null} + + Capabilities: {capabilities.join(', ')} + + {capabilities.indexOf('webhooker') !== -1 ? ( + + Custom Route Prefix:{' '} + {((url) => ( + + {url} + + ))(`${config.get('url')}plugin/${id}/custom/${token}/`)} + + ) : null} +
+ ); + } +} + +export default inject('pluginStore')(PluginDetailView); diff --git a/ui/src/plugin/PluginStore.ts b/ui/src/plugin/PluginStore.ts new file mode 100644 index 000000000..28ec968d7 --- /dev/null +++ b/ui/src/plugin/PluginStore.ts @@ -0,0 +1,55 @@ +import axios from 'axios'; +import {action} from 'mobx'; +import {BaseStore} from '../common/BaseStore'; +import * as config from '../config'; +import {SnackReporter} from '../snack/SnackManager'; + +export class PluginStore extends BaseStore { + public onDelete: () => void = () => {}; + + public constructor(private readonly snack: SnackReporter) { + super(); + } + + public requestConfig = (id: number): Promise => { + return axios + .get(`${config.get('url')}plugin/${id}/config`) + .then((response) => response.data); + }; + + public requestDisplay = (id: number): Promise => { + return axios + .get(`${config.get('url')}plugin/${id}/display`) + .then((response) => response.data); + }; + + protected requestItems = (): Promise => { + return axios.get(`${config.get('url')}plugin`).then((response) => response.data); + }; + + protected requestDelete = (id: number): Promise => { + this.snack('Cannot delete plugin'); + throw new Error('Cannot delete plugin'); + }; + + public getName = (id: number): string => { + const plugin = this.getByIDOrUndefined(id); + return id === -1 ? 'All Plugins' : plugin !== undefined ? plugin.name : 'unknown'; + }; + + @action + public changeConfig = async (id: number, newConfig: string): Promise => { + await axios.post(`${config.get('url')}plugin/${id}/config`, newConfig, { + headers: {'content-type': 'application/x-yaml'}, + }); + this.snack(`Plugin config updated`); + await this.refresh(); + }; + + @action + public changeEnabledState = async (id: number, enabled: boolean): Promise => { + await axios.post(`${config.get('url')}plugin/${id}/${enabled ? 'enable' : 'disable'}`); + this.snack(`Plugin ${enabled ? 'enabled' : 'disabled'}`); + await this.refresh(); + }; +} diff --git a/ui/src/plugin/Plugins.tsx b/ui/src/plugin/Plugins.tsx new file mode 100644 index 000000000..62a7efbf9 --- /dev/null +++ b/ui/src/plugin/Plugins.tsx @@ -0,0 +1,100 @@ +import React, {Component, SFC} from 'react'; +import {Link} from 'react-router-dom'; +import Grid from '@material-ui/core/Grid'; +import Paper from '@material-ui/core/Paper'; +import Table from '@material-ui/core/Table'; +import TableBody from '@material-ui/core/TableBody'; +import TableCell from '@material-ui/core/TableCell'; +import TableHead from '@material-ui/core/TableHead'; +import TableRow from '@material-ui/core/TableRow'; +import Settings from '@material-ui/icons/Settings'; +import {Switch, Button} from '@material-ui/core'; +import DefaultPage from '../common/DefaultPage'; +import ToggleVisibility from '../common/ToggleVisibility'; +import {observer} from 'mobx-react'; +import {inject, Stores} from '../inject'; + +@observer +class Plugins extends Component> { + public componentDidMount = () => this.props.pluginStore.refresh(); + + public render() { + const { + props: {pluginStore}, + } = this; + const plugins = pluginStore.getItems(); + return ( + + + + + + + ID + Enabled + Name + Token + Details + + + + {plugins.map((plugin: IPlugin) => { + return ( + + this.props.pluginStore.changeEnabledState( + plugin.id, + !plugin.enabled + ) + } + /> + ); + })} + +
+
+
+
+ ); + } +} + +interface IRowProps { + id: number; + name: string; + token: string; + enabled: boolean; + fToggleStatus: VoidFunction; +} + +const Row: SFC = observer(({name, id, token, enabled, fToggleStatus}) => ( + + {id} + + + + {name} + + + + + + + + + +)); + +export default inject('pluginStore')(Plugins); diff --git a/ui/src/tests/application.test.ts b/ui/src/tests/application.test.ts index a00c82cdc..1e6655a00 100644 --- a/ui/src/tests/application.test.ts +++ b/ui/src/tests/application.test.ts @@ -1,6 +1,6 @@ import {Page} from 'puppeteer'; import {newTest, GotifyTest} from './setup'; -import {count, innerText, waitForExists, waitToDisappear} from './utils'; +import {count, innerText, waitForExists, waitToDisappear, clearField} from './utils'; import * as auth from './authentication'; import * as selector from './selector'; @@ -17,7 +17,8 @@ enum Col { Name = 2, Token = 3, Description = 4, - EditDelete = 5, + EditUpdate = 5, + EditDelete = 6, } const hiddenToken = '•••••••••••••••'; @@ -25,6 +26,36 @@ const hiddenToken = '•••••••••••••••'; const $table = selector.table('#app-table'); const $dialog = selector.form('#app-dialog'); +const hasApp = (name: string, description: string, row: number): (() => Promise) => { + return async () => { + expect(await innerText(page, $table.cell(row, Col.Name))).toBe(name); + expect(await innerText(page, $table.cell(row, Col.Token))).toBe(hiddenToken); + expect(await innerText(page, $table.cell(row, Col.Description))).toBe(description); + }; +}; + +export const updateApp = ( + id: number, + data: {name?: string; description?: string} +): (() => Promise) => { + return async () => { + await page.click($table.cell(id, Col.EditUpdate, '.edit')); + await page.waitForSelector($dialog.selector()); + if (data.name) { + const nameSelector = $dialog.input('.name'); + await clearField(page, nameSelector); + await page.type(nameSelector, data.name); + } + if (data.description) { + const descSelector = $dialog.textarea('.description'); + await clearField(page, descSelector); + await page.type(descSelector, data.description); + } + await page.click($dialog.button('.update')); + await waitToDisappear(page, $dialog.selector()); + }; +}; + export const createApp = (name: string, description: string): (() => Promise) => { return async () => { await page.click('#create-app'); @@ -53,14 +84,6 @@ describe('Application', () => { it('raspberry', createApp('raspberry', '#3')); }); describe('has created apps', () => { - const hasApp = (name: string, description: string, row: number): (() => Promise) => { - return async () => { - expect(await innerText(page, $table.cell(row, Col.Name))).toBe(name); - expect(await innerText(page, $table.cell(row, Col.Token))).toBe(hiddenToken); - expect(await innerText(page, $table.cell(row, Col.Description))).toBe(description); - }; - }; - it('has three apps', async () => { await page.waitForSelector($table.row(3)); expect(await count(page, $table.rows())).toBe(3); @@ -72,8 +95,19 @@ describe('Application', () => { await page.click($table.cell(3, Col.Token, '.toggle-visibility')); const token = await innerText(page, $table.cell(3, Col.Token)); expect(token.startsWith('A')).toBeTruthy(); + await page.click($table.cell(3, Col.Token, '.toggle-visibility')); }); }); + it('updates application', async () => { + await updateApp(1, {name: 'server_linux'})(); + await updateApp(2, {description: 'kitchen_computer'})(); + await updateApp(3, {name: 'raspberry_pi', description: 'home_pi'})(); + }); + it('has updated application', async () => { + await hasApp('server_linux', '#1', 1)(); + await hasApp('desktop', 'kitchen_computer', 2)(); + await hasApp('raspberry_pi', 'home_pi', 3)(); + }); it('deletes application', async () => { await page.click($table.cell(2, Col.EditDelete, '.delete')); diff --git a/ui/src/tests/plugin.test.ts b/ui/src/tests/plugin.test.ts new file mode 100644 index 000000000..65f3ee48c --- /dev/null +++ b/ui/src/tests/plugin.test.ts @@ -0,0 +1,190 @@ +import * as os from 'os'; +import {Page} from 'puppeteer'; +import axios from 'axios'; + +import * as auth from './authentication'; +import * as selector from './selector'; +import {GotifyTest, newTest, newPluginDir} from './setup'; +import {count, innerText, waitForExists} from './utils'; + +const pluginSupported = ['linux', 'darwin'].indexOf(os.platform()) !== -1; + +let page: Page; +let gotify: GotifyTest; + +beforeAll(async () => { + const gotifyPluginDir = pluginSupported + ? await newPluginDir(['github.com/gotify/server/plugin/example/echo']) + : ''; + gotify = await newTest(gotifyPluginDir); + page = gotify.page; +}); + +afterAll(async () => await gotify.close()); + +enum Col { + ID = 1, + SetEnabled = 2, + Name = 3, + Token = 4, + Details = 5, +} + +const hiddenToken = '•••••••••••••••'; + +const $table = selector.table('#plugin-table'); + +const switchSelctor = (id: number) => $table.cell(id, Col.SetEnabled, '[data-enabled]'); + +const enabledState = async (id: number) => + (await page.$eval(switchSelctor(id), (el) => el.getAttribute('data-enabled'))) === 'true'; + +const toggleEnabled = async (id: number) => { + const origEnabled = await enabledState(id).toString(); + await page.click(switchSelctor(id)); + await page.waitForFunction( + `document.querySelector("${switchSelctor( + id + )}").getAttribute("data-enabled") !== "${origEnabled}"` + ); +}; + +const pluginInfo = async (className: string) => { + return await innerText(page, `.plugin-info .${className} > span`); +}; + +const getDisplayer = async () => { + return await innerText(page, '.displayer'); +}; + +const hasReceivedMessage = async (title: RegExp, content: RegExp) => { + await page.click('#message-navigation a'); + await waitForExists(page, selector.heading(), 'All Messages'); + + expect(await innerText(page, '.title')).toMatch(title); + expect(await innerText(page, '.content')).toMatch(content); + + await page.click('#navigate-plugins'); + await waitForExists(page, selector.heading(), 'Plugins'); +}; + +const inDetailPage = async (id: number, callback: (() => Promise)) => { + const name = await innerText(page, $table.cell(id, Col.Name)); + await page.click($table.cell(id, Col.Details)); + await waitForExists(page, '.plugin-info .name > span', name); + + await callback(); + + await page.click('#navigate-plugins'); + await waitForExists(page, selector.heading(), 'Plugins'); + await page.waitForSelector($table.selector()); +}; + +describe('plugin', () => { + describe('navigation', () => { + it('does login', async () => await auth.login(page)); + it('navigates to plugins', async () => { + await page.click('#navigate-plugins'); + await waitForExists(page, selector.heading(), 'Plugins'); + }); + }); + if (!pluginSupported) { + return; + } + describe('functionality test', () => { + describe('initial status', () => { + it('has echo plugin', async () => { + expect(await count(page, $table.rows())).toBe(1); + expect(await innerText(page, $table.cell(1, Col.Name))).toEqual('test plugin'); + expect(await innerText(page, $table.cell(1, Col.Token))).toBe(hiddenToken); + expect(parseInt(await innerText(page, $table.cell(1, Col.ID)), 10)).toBeGreaterThan( + 0 + ); + }); + it('is disabled by default', async () => { + expect(await enabledState(1)).toBe(false); + }); + }); + describe('enable and disable plugin', () => { + it('enable', async () => { + await toggleEnabled(1); + expect(await enabledState(1)).toBe(true); + }); + + it('disable', async () => { + await toggleEnabled(1); + expect(await enabledState(1)).toBe(false); + }); + }); + describe('details page', () => { + it('has plugin info', async () => { + await inDetailPage(1, async () => { + expect(await pluginInfo('module-path')).toBe( + 'github.com/gotify/server/plugin/example/echo' + ); + }); + }); + it('has displayer', async () => { + await inDetailPage(1, async () => { + expect(await getDisplayer()).toBeTruthy(); + }); + }); + it('has configurer', async () => { + await inDetailPage(1, async () => { + expect(await page.$('.configurer')).toBeTruthy(); + }); + }); + it('updates configurer', async () => { + await inDetailPage(1, async () => { + expect( + await (await (await page.$('.config-save'))!.getProperty( + 'disabled' + )).jsonValue() + ).toBe(true); + await page.waitForSelector('.CodeMirror .CodeMirror-code'); + await page.waitForFunction( + 'document.querySelector(".CodeMirror .CodeMirror-code").innerText.toLowerCase().indexOf("loading")<0' + ); + await page.click('.CodeMirror .CodeMirror-code > div'); + await page.keyboard.press('x'); + await page.waitForFunction( + 'document.querySelector(".config-save") && !document.querySelector(".config-save").disabled' + ); + await page.click('.config-save'); + await page.waitForFunction('document.querySelector(".config-save").disabled'); + }); + }); + it('configurer updated', async () => { + await inDetailPage(1, async () => { + expect( + await (await (await page.$('.config-save'))!.getProperty( + 'disabled' + )).jsonValue() + ).toBe(true); + await page.waitForSelector('.CodeMirror .CodeMirror-code > div'); + await page.waitForFunction( + 'document.querySelector(".CodeMirror .CodeMirror-code > div").innerText.toLowerCase().indexOf("loading")<0' + ); + expect(await innerText(page, '.CodeMirror .CodeMirror-code > div')).toMatch( + /x$/ + ); + }); + }); + it('sends messages', async () => { + if (!(await enabledState(1))) { + await toggleEnabled(1); + } + await inDetailPage(1, async () => { + const hook = await page.$eval('.displayer a', (el) => el.getAttribute('href')); + await axios.get(hook!); + }); + }); + it('has received message', async () => { + await hasReceivedMessage( + /^.+received$/, + /^echo server received a hello message \d+ times$/ + ); + }); + }); + }); +}); diff --git a/ui/src/tests/setup.ts b/ui/src/tests/setup.ts index 182505257..32289ea27 100644 --- a/ui/src/tests/setup.ts +++ b/ui/src/tests/setup.ts @@ -19,14 +19,22 @@ const windowsPrefix = process.platform === 'win32' ? '.exe' : ''; const appDotGo = path.join(__dirname, '..', '..', '..', 'app.go'); const testBuildPath = path.join(__dirname, 'build'); -export const newTest = async (): Promise => { +export const newPluginDir = async (plugins: string[]): Promise => { + const {dir, generator} = testPluginDir(); + for (const pluginName of plugins) { + await buildGoPlugin(generator(), pluginName); + } + return dir; +}; + +export const newTest = async (pluginsDir = ''): Promise => { const port = await getPort(); const gotifyFile = testFilePath(); await buildGoExecutable(gotifyFile); - const gotifyInstance = startGotify(gotifyFile, port); + const gotifyInstance = startGotify(gotifyFile, port, pluginsDir); const gotifyURL = 'http://localhost:' + port; await waitForGotify('http-get://localhost:' + port); @@ -52,6 +60,26 @@ export const newTest = async (): Promise => { }; }; +const testPluginDir = (): {dir: string; generator: (() => string)} => { + const random = Math.random() + .toString(36) + .substring(2, 15); + const dirName = 'gotifyplugin_' + random; + const dir = path.join(testBuildPath, dirName); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, 0o755); + } + return { + dir, + generator: () => { + const randomFn = Math.random() + .toString(36) + .substring(2, 15); + return path.join(dir, randomFn + '.so'); + }, + }; +}; + const testFilePath = (): string => { const random = Math.random() .toString(36) @@ -73,6 +101,13 @@ const waitForGotify = (url: string): Promise => { }); }; +const buildGoPlugin = (filename: string, pluginPath: string): Promise => { + process.stdout.write(`### Building Plugin ${pluginPath}\n`); + return new Promise((resolve) => + exec(`go build -o ${filename} -buildmode=plugin ${pluginPath}`, () => resolve()) + ); +}; + const buildGoExecutable = (filename: string): Promise => { const envGotify = process.env.GOTIFY_EXE; if (envGotify) { @@ -90,11 +125,12 @@ const buildGoExecutable = (filename: string): Promise => { } }; -const startGotify = (filename: string, port: number): ChildProcess => { +const startGotify = (filename: string, port: number, pluginDir: string): ChildProcess => { const gotify = spawn(filename, [], { env: { GOTIFY_SERVER_PORT: '' + port, GOTIFY_DATABASE_CONNECTION: 'file::memory:?mode=memory&cache=shared', + GOTIFY_PLUGINSDIR: pluginDir, }, }); gotify.stdout.pipe(process.stdout); diff --git a/ui/src/types.ts b/ui/src/types.ts index 23be73357..e53b8bb0f 100644 --- a/ui/src/types.ts +++ b/ui/src/types.ts @@ -4,6 +4,7 @@ interface IApplication { name: string; description: string; image: string; + internal: boolean; } interface IClient { @@ -12,6 +13,18 @@ interface IClient { name: string; } +interface IPlugin { + id: number; + token: string; + name: string; + modulePath: string; + enabled: boolean; + author?: string; + website?: string; + license?: string; + capabilities: Array<'webhooker' | 'displayer' | 'configurer' | 'messenger' | 'storager'>; +} + interface IMessage { id: number; appid: number;