Add plugin feature

Fixed database migration
Added a plugin system based on the go plugin package
This commit is contained in:
eternal-flame-AD 2019-02-03 23:53:41 +08:00 committed by Jannis Mattheis
parent 06d13d2bee
commit e5b24f4c92
88 changed files with 5869 additions and 222 deletions

View File

@ -14,14 +14,7 @@ test-race:
go test -v -race ./... go test -v -race ./...
test-coverage: test-coverage:
echo "" > coverage.txt go test -v -coverprofile=coverage.txt -covermode=atomic ./...
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
format: format:
goimports -w $(shell find . -type f -name '*.go' -not -path "./vendor/*") goimports -w $(shell find . -type f -name '*.go' -not -path "./vendor/*")

View File

@ -66,8 +66,9 @@ type ApplicationAPI struct {
func (a *ApplicationAPI) CreateApplication(ctx *gin.Context) { func (a *ApplicationAPI) CreateApplication(ctx *gin.Context) {
app := model.Application{} app := model.Application{}
if err := ctx.Bind(&app); err == nil { 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.UserID = auth.GetUserID(ctx)
app.Internal = false
a.DB.CreateApplication(&app) a.DB.CreateApplication(&app)
ctx.JSON(200, withAbsoluteURL(ctx, &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) { func (a *ApplicationAPI) DeleteApplication(ctx *gin.Context) {
withID(ctx, "id", func(id uint) { withID(ctx, "id", func(id uint) {
if app := a.DB.GetApplicationByID(id); app != nil && app.UserID == auth.GetUserID(ctx) { 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) a.DB.DeleteApplicationByID(id)
if app.Image != "" { if app.Image != "" {
os.Remove(a.ImageDir + app.Image) os.Remove(a.ImageDir + app.Image)

View File

@ -16,6 +16,7 @@ import (
"github.com/gotify/server/mode" "github.com/gotify/server/mode"
"github.com/gotify/server/model" "github.com/gotify/server/model"
"github.com/gotify/server/test" "github.com/gotify/server/test"
"github.com/gotify/server/test/testdb"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite" "github.com/stretchr/testify/suite"
) )
@ -31,7 +32,7 @@ func TestApplicationSuite(t *testing.T) {
type ApplicationSuite struct { type ApplicationSuite struct {
suite.Suite suite.Suite
db *test.Database db *testdb.Database
a *ApplicationAPI a *ApplicationAPI
ctx *gin.Context ctx *gin.Context
recorder *httptest.ResponseRecorder recorder *httptest.ResponseRecorder
@ -41,7 +42,7 @@ func (s *ApplicationSuite) BeforeTest(suiteName, testName string) {
mode.Set(mode.TestDev) mode.Set(mode.TestDev)
rand.Seed(50) rand.Seed(50)
s.recorder = httptest.NewRecorder() s.recorder = httptest.NewRecorder()
s.db = test.NewDB(s.T()) s.db = testdb.NewDB(s.T())
s.ctx, _ = gin.CreateTestContext(s.recorder) s.ctx, _ = gin.CreateTestContext(s.recorder)
withURL(s.ctx, "http", "example.com") withURL(s.ctx, "http", "example.com")
s.a = &ApplicationAPI{DB: s.db} s.a = &ApplicationAPI{DB: s.db}
@ -76,8 +77,9 @@ func (s *ApplicationSuite) Test_ensureApplicationHasCorrectJsonRepresentation()
Name: "myapp", Name: "myapp",
Description: "mydesc", Description: "mydesc",
Image: "asd", 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() { func (s *ApplicationSuite) Test_CreateApplication_expectBadRequestOnEmptyName() {
s.db.User(5) s.db.User(5)
@ -183,6 +185,18 @@ func (s *ApplicationSuite) Test_GetApplications_WithImage() {
test.BodyEquals(s.T(), []*model.Application{first, second}, s.recorder) 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() { func (s *ApplicationSuite) Test_DeleteApplication_expectNotFound() {
s.db.User(5) s.db.User(5)

View File

@ -60,7 +60,7 @@ type ClientAPI struct {
func (a *ClientAPI) CreateClient(ctx *gin.Context) { func (a *ClientAPI) CreateClient(ctx *gin.Context) {
client := model.Client{} client := model.Client{}
if err := ctx.Bind(&client); err == nil { 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) client.UserID = auth.GetUserID(ctx)
a.DB.CreateClient(&client) a.DB.CreateClient(&client)
ctx.JSON(200, client) ctx.JSON(200, client)
@ -145,12 +145,3 @@ func (a *ClientAPI) DeleteClient(ctx *gin.Context) {
func (a *ClientAPI) clientExists(token string) bool { func (a *ClientAPI) clientExists(token string) bool {
return a.DB.GetClientByToken(token) != nil 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
}
}
}

View File

@ -11,6 +11,7 @@ import (
"github.com/gotify/server/mode" "github.com/gotify/server/mode"
"github.com/gotify/server/model" "github.com/gotify/server/model"
"github.com/gotify/server/test" "github.com/gotify/server/test"
"github.com/gotify/server/test/testdb"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite" "github.com/stretchr/testify/suite"
) )
@ -26,7 +27,7 @@ func TestClientSuite(t *testing.T) {
type ClientSuite struct { type ClientSuite struct {
suite.Suite suite.Suite
db *test.Database db *testdb.Database
a *ClientAPI a *ClientAPI
ctx *gin.Context ctx *gin.Context
recorder *httptest.ResponseRecorder recorder *httptest.ResponseRecorder
@ -37,7 +38,7 @@ func (s *ClientSuite) BeforeTest(suiteName, testName string) {
mode.Set(mode.TestDev) mode.Set(mode.TestDev)
rand.Seed(50) rand.Seed(50)
s.recorder = httptest.NewRecorder() s.recorder = httptest.NewRecorder()
s.db = test.NewDB(s.T()) s.db = testdb.NewDB(s.T())
s.ctx, _ = gin.CreateTestContext(s.recorder) s.ctx, _ = gin.CreateTestContext(s.recorder)
withURL(s.ctx, "http", "example.com") withURL(s.ctx, "http", "example.com")
s.notified = false s.notified = false

View File

@ -12,6 +12,7 @@ import (
"github.com/gotify/server/mode" "github.com/gotify/server/mode"
"github.com/gotify/server/model" "github.com/gotify/server/model"
"github.com/gotify/server/test" "github.com/gotify/server/test"
"github.com/gotify/server/test/testdb"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite" "github.com/stretchr/testify/suite"
) )
@ -22,7 +23,7 @@ func TestMessageSuite(t *testing.T) {
type MessageSuite struct { type MessageSuite struct {
suite.Suite suite.Suite
db *test.Database db *testdb.Database
a *MessageAPI a *MessageAPI
ctx *gin.Context ctx *gin.Context
recorder *httptest.ResponseRecorder recorder *httptest.ResponseRecorder
@ -34,7 +35,7 @@ func (s *MessageSuite) BeforeTest(suiteName, testName string) {
s.recorder = httptest.NewRecorder() s.recorder = httptest.NewRecorder()
s.ctx, _ = gin.CreateTestContext(s.recorder) s.ctx, _ = gin.CreateTestContext(s.recorder)
s.ctx.Request = httptest.NewRequest("GET", "/irrelevant", nil) s.ctx.Request = httptest.NewRequest("GET", "/irrelevant", nil)
s.db = test.NewDB(s.T()) s.db = testdb.NewDB(s.T())
s.notifiedMessage = nil s.notifiedMessage = nil
s.a = &MessageAPI{DB: s.db, Notifier: s} s.a = &MessageAPI{DB: s.db, Notifier: s}
} }

395
api/plugin.go Normal file
View File

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

660
api/plugin_test.go Normal file
View File

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

View File

@ -37,7 +37,7 @@ func New(pingPeriod, pongTimeout time.Duration, allowedWebSocketOrigins []string
} }
// NotifyDeletedUser closes existing connections for the given user. // NotifyDeletedUser closes existing connections for the given user.
func (a *API) NotifyDeletedUser(userID uint) { func (a *API) NotifyDeletedUser(userID uint) error {
a.lock.Lock() a.lock.Lock()
defer a.lock.Unlock() defer a.lock.Unlock()
if clients, ok := a.clients[userID]; ok { if clients, ok := a.clients[userID]; ok {
@ -46,6 +46,7 @@ func (a *API) NotifyDeletedUser(userID uint) {
} }
delete(a.clients, userID) delete(a.clients, userID)
} }
return nil
} }
// NotifyDeletedClient closes existing connections with the given token. // NotifyDeletedClient closes existing connections with the given token.

View File

@ -92,7 +92,7 @@ func TestWritePingFails(t *testing.T) {
assert.NotEmpty(t, clients) 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"}) api.Notify(1, &model.MessageExternal{Message: "HI"})
user.expectNoMessage() user.expectNoMessage()
@ -123,7 +123,7 @@ func TestPing(t *testing.T) {
expectNoMessage(user) expectNoMessage(user)
select { select {
case <-time.After(5 * time.Second): case <-time.After(2 * time.Second):
assert.Fail(t, "Expected ping but there was one :(") assert.Fail(t, "Expected ping but there was one :(")
case <-ping: case <-ping:
// expected // expected
@ -151,7 +151,7 @@ func TestCloseClientOnNotReading(t *testing.T) {
time.Sleep(100 * time.Millisecond) time.Sleep(100 * time.Millisecond)
assert.NotEmpty(t, clients(api, 1)) assert.NotEmpty(t, clients(api, 1))
time.Sleep(7 * time.Second) time.Sleep(api.pingPeriod + api.pongTimeout)
assert.Empty(t, clients(api, 1)) assert.Empty(t, clients(api, 1))
} }
@ -363,7 +363,7 @@ func TestMultipleClients(t *testing.T) {
expectNoMessage(userThree...) expectNoMessage(userThree...)
api.Notify(1, &model.MessageExternal{ID: 1, Message: "hello"}) 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...) expectMessage(&model.MessageExternal{ID: 1, Message: "hello"}, userOne...)
expectNoMessage(userTwo...) expectNoMessage(userTwo...)
expectNoMessage(userThree...) expectNoMessage(userThree...)
@ -536,8 +536,8 @@ func (c *testingClient) expectNoMessage() {
func bootTestServer(handlerFunc gin.HandlerFunc) (*httptest.Server, *API) { func bootTestServer(handlerFunc gin.HandlerFunc) (*httptest.Server, *API) {
r := gin.New() r := gin.New()
r.Use(handlerFunc) r.Use(handlerFunc)
// all 4 seconds a ping, and the client has 1 second to respond // ping every 500 ms, and the client has 500 ms to respond
api := New(4*time.Second, 1*time.Second, []string{}) api := New(500*time.Millisecond, 500*time.Millisecond, []string{})
r.GET("/", api.Handle) r.GET("/", api.Handle)
server := httptest.NewServer(r) server := httptest.NewServer(r)

View File

@ -19,11 +19,45 @@ type UserDatabase interface {
CreateUser(user *model.User) error 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. // The UserAPI provides handlers for managing users.
type UserAPI struct { type UserAPI struct {
DB UserDatabase DB UserDatabase
PasswordStrength int PasswordStrength int
NotifyDeleted func(uint) UserChangeNotifier *UserChangeNotifier
} }
// GetUsers returns all the users // GetUsers returns all the users
@ -125,6 +159,10 @@ func (a *UserAPI) CreateUser(ctx *gin.Context) {
internal := a.toInternalUser(&user, []byte{}) internal := a.toInternalUser(&user, []byte{})
if a.DB.GetUserByName(internal.Name) == nil { if a.DB.GetUserByName(internal.Name) == nil {
a.DB.CreateUser(internal) a.DB.CreateUser(internal)
if err := a.UserChangeNotifier.fireUserAdded(internal.ID); err != nil {
ctx.AbortWithError(500, err)
return
}
ctx.JSON(200, toExternalUser(internal)) ctx.JSON(200, toExternalUser(internal))
} else { } else {
ctx.AbortWithError(400, errors.New("username already exists")) 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) { func (a *UserAPI) DeleteUserByID(ctx *gin.Context) {
withID(ctx, "id", func(id uint) { withID(ctx, "id", func(id uint) {
if user := a.DB.GetUserByID(id); user != nil { 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) a.DB.DeleteUserByID(id)
} else { } else {
ctx.AbortWithError(404, errors.New("user does not exist")) ctx.AbortWithError(404, errors.New("user does not exist"))

View File

@ -1,6 +1,7 @@
package api package api
import ( import (
"errors"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
"testing" "testing"
@ -10,6 +11,7 @@ import (
"github.com/gotify/server/mode" "github.com/gotify/server/mode"
"github.com/gotify/server/model" "github.com/gotify/server/model"
"github.com/gotify/server/test" "github.com/gotify/server/test"
"github.com/gotify/server/test/testdb"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite" "github.com/stretchr/testify/suite"
) )
@ -20,26 +22,31 @@ func TestUserSuite(t *testing.T) {
type UserSuite struct { type UserSuite struct {
suite.Suite suite.Suite
db *test.Database db *testdb.Database
a *UserAPI a *UserAPI
ctx *gin.Context ctx *gin.Context
recorder *httptest.ResponseRecorder recorder *httptest.ResponseRecorder
notified bool notifiedAdd bool
notifiedDelete bool
notifier *UserChangeNotifier
} }
func (s *UserSuite) BeforeTest(suiteName, testName string) { func (s *UserSuite) BeforeTest(suiteName, testName string) {
mode.Set(mode.TestDev) mode.Set(mode.TestDev)
s.recorder = httptest.NewRecorder() s.recorder = httptest.NewRecorder()
s.ctx, _ = gin.CreateTestContext(s.recorder) s.ctx, _ = gin.CreateTestContext(s.recorder)
s.db = test.NewDB(s.T()) s.db = testdb.NewDB(s.T())
s.notified = false s.notifier = new(UserChangeNotifier)
s.a = &UserAPI{DB: s.db, NotifyDeleted: s.notify} 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) { func (s *UserSuite) AfterTest(suiteName, testName string) {
s.db.Close() s.db.Close()
} }
@ -113,7 +120,7 @@ func (s *UserSuite) Test_DeleteUserByID_UnknownUser() {
} }
func (s *UserSuite) Test_DeleteUserByID() { func (s *UserSuite) Test_DeleteUserByID() {
assert.False(s.T(), s.notified) assert.False(s.T(), s.notifiedDelete)
s.db.User(2) s.db.User(2)
@ -123,10 +130,27 @@ func (s *UserSuite) Test_DeleteUserByID() {
assert.Equal(s.T(), 200, s.recorder.Code) assert.Equal(s.T(), 200, s.recorder.Code)
s.db.AssertUserNotExist(2) 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() { 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 = httptest.NewRequest("POST", "/user", strings.NewReader(`{"name": "tom", "pass": "mylittlepony", "admin": true}`))
s.ctx.Request.Header.Set("Content-Type", "application/json") s.ctx.Request.Header.Set("Content-Type", "application/json")
@ -139,6 +163,22 @@ func (s *UserSuite) Test_CreateUser() {
created := s.db.GetUserByName("tom") created := s.db.GetUserByName("tom")
assert.NotNil(s.T(), created) assert.NotNil(s.T(), created)
assert.True(s.T(), password.ComparePassword(created.Pass, []byte("mylittlepony"))) 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() { func (s *UserSuite) Test_CreateUser_NoPassword() {

7
app.go
View File

@ -33,7 +33,12 @@ func main() {
rand.Seed(time.Now().UnixNano()) rand.Seed(time.Now().UnixNano())
conf := config.Get() 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) panic(err)
} }

View File

@ -16,6 +16,7 @@ const (
type Database interface { type Database interface {
GetApplicationByToken(token string) *model.Application GetApplicationByToken(token string) *model.Application
GetClientByToken(token string) *model.Client GetClientByToken(token string) *model.Client
GetPluginConfByToken(token string) *model.PluginConf
GetUserByName(name string) *model.User GetUserByName(name string) *model.User
GetUserByID(id uint) *model.User GetUserByID(id uint) *model.User
} }

View File

@ -11,7 +11,7 @@ import (
"github.com/gotify/server/auth/password" "github.com/gotify/server/auth/password"
"github.com/gotify/server/mode" "github.com/gotify/server/mode"
"github.com/gotify/server/model" "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/assert"
"github.com/stretchr/testify/suite" "github.com/stretchr/testify/suite"
) )
@ -23,12 +23,12 @@ func TestSuite(t *testing.T) {
type AuthenticationSuite struct { type AuthenticationSuite struct {
suite.Suite suite.Suite
auth *Auth auth *Auth
DB *test.Database DB *testdb.Database
} }
func (s *AuthenticationSuite) SetupSuite() { func (s *AuthenticationSuite) SetupSuite() {
mode.Set(mode.TestDev) mode.Set(mode.TestDev)
s.DB = test.NewDB(s.T()) s.DB = testdb.NewDB(s.T())
s.auth = &Auth{s.DB} s.auth = &Auth{s.DB}
s.DB.CreateUser(&model.User{ s.DB.CreateUser(&model.User{

View File

@ -9,8 +9,19 @@ var (
randomTokenLength = 14 randomTokenLength = 14
applicationPrefix = "A" applicationPrefix = "A"
clientPrefix = "C" 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. // GenerateApplicationToken generates an application token.
func GenerateApplicationToken() string { func GenerateApplicationToken() string {
return generateRandomToken(applicationPrefix) return generateRandomToken(applicationPrefix)
@ -21,6 +32,11 @@ func GenerateClientToken() string {
return generateRandomToken(clientPrefix) return generateRandomToken(clientPrefix)
} }
// GeneratePluginToken generates a plugin token.
func GeneratePluginToken() string {
return generateRandomToken(pluginPrefix)
}
// GenerateImageName generates an image name. // GenerateImageName generates an image name.
func GenerateImageName() string { func GenerateImageName() string {
return generateRandomString(25) return generateRandomString(25)

View File

@ -1,6 +1,7 @@
package auth package auth
import ( import (
"fmt"
"strings" "strings"
"testing" "testing"
@ -11,6 +12,21 @@ func TestTokenHavePrefix(t *testing.T) {
for i := 0; i < 50; i++ { for i := 0; i < 50; i++ {
assert.True(t, strings.HasPrefix(GenerateApplicationToken(), "A")) assert.True(t, strings.HasPrefix(GenerateApplicationToken(), "A"))
assert.True(t, strings.HasPrefix(GenerateClientToken(), "C")) assert.True(t, strings.HasPrefix(GenerateClientToken(), "C"))
assert.True(t, strings.HasPrefix(GeneratePluginToken(), "P"))
assert.NotEmpty(t, GenerateImageName()) 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)
}

View File

@ -35,4 +35,5 @@ defaultuser: # on database creation, gotify creates an admin user
name: admin # the username of the default user name: admin # the username of the default user
pass: admin # the password of the default user pass: admin # the password of the default user
passstrength: 10 # the bcrypt password strength (higher = better but also slower) passstrength: 10 # the bcrypt password strength (higher = better but also slower)
uploadedimagesdir: data/images # the directory for storing uploaded images uploadedimagesdir: data/images # the directory for storing uploaded images
pluginsdir: data/plugins # the directory where plugin resides

View File

@ -39,6 +39,7 @@ type Configuration struct {
} }
PassStrength int `default:"10"` PassStrength int `default:"10"`
UploadedImagesDir string `default:"data/images"` UploadedImagesDir string `default:"data/images"`
PluginsDir string `default:"data/plugins"`
} }
// Get returns the configuration extracted from env variables or config file. // Get returns the configuration extracted from env variables or config file.

View File

@ -89,6 +89,7 @@ database:
defaultuser: defaultuser:
name: nicories name: nicories
pass: 12345 pass: 12345
pluginsdir: data/plugins
`) `)
file.Close() file.Close()
assert.Nil(t, err) assert.Nil(t, err)
@ -103,6 +104,7 @@ defaultuser:
assert.Equal(t, "*", conf.Server.ResponseHeaders["Access-Control-Allow-Origin"]) 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, "GET,POST", conf.Server.ResponseHeaders["Access-Control-Allow-Methods"])
assert.Equal(t, []string{".+.example.com", "otherdomain.com"}, conf.Server.Stream.AllowedOrigins) 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")) assert.Nil(t, os.Remove("config.yml"))
} }

View File

@ -15,7 +15,7 @@ import (
var mkdirAll = os.MkdirAll var mkdirAll = os.MkdirAll
// New creates a new wrapper for the gorm database framework. // 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) createDirectoryIfSqlite(dialect, connection)
db, err := gorm.Open(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) db.DB().SetMaxOpenConns(1)
} }
if !db.HasTable(new(model.User)) && !db.HasTable(new(model.Message)) && db.AutoMigrate(new(model.User), new(model.Application), new(model.Message), new(model.Client), new(model.PluginConf))
!db.HasTable(new(model.Client)) && !db.HasTable(new(model.Application)) { userCount := 0
db.AutoMigrate(new(model.User), new(model.Application), new(model.Message), new(model.Client)) db.Find(new(model.User)).Count(&userCount)
if createDefaultUser { if createDefaultUserIfNotExist && userCount == 0 {
db.Create(&model.User{Name: defaultUser, Pass: password.CreatePassword(defaultPass, strength), Admin: true}) db.Create(&model.User{Name: defaultUser, Pass: password.CreatePassword(defaultPass, strength), Admin: true})
}
} }
return &GormDatabase{DB: db}, nil return &GormDatabase{DB: db}, nil

View File

@ -5,6 +5,8 @@ import (
"os" "os"
"testing" "testing"
"github.com/gotify/server/test"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite" "github.com/stretchr/testify/suite"
) )
@ -15,56 +17,56 @@ func TestDatabaseSuite(t *testing.T) {
type DatabaseSuite struct { type DatabaseSuite struct {
suite.Suite suite.Suite
db *GormDatabase db *GormDatabase
tmpDir test.TmpDir
} }
func (s *DatabaseSuite) BeforeTest(suiteName, testName string) { 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) assert.Nil(s.T(), err)
s.db = db s.db = db
} }
func (s *DatabaseSuite) AfterTest(suiteName, testName string) { func (s *DatabaseSuite) AfterTest(suiteName, testName string) {
s.db.Close() s.db.Close()
assert.Nil(s.T(), os.Remove("testdb.db")) assert.Nil(s.T(), s.tmpDir.Clean())
} }
func TestInvalidDialect(t *testing.T) { func TestInvalidDialect(t *testing.T) {
_, err := New("asdf", "testdb.db", "defaultUser", "defaultPass", 5, true) tmpDir := test.NewTmpDir("gotify_testinvaliddialect")
assert.NotNil(t, err) defer tmpDir.Clean()
_, err := New("asdf", tmpDir.Path("testdb.db"), "defaultUser", "defaultPass", 5, true)
assert.Error(t, err)
} }
func TestCreateSqliteFolder(t *testing.T) { func TestCreateSqliteFolder(t *testing.T) {
// ensure path not exists tmpDir := test.NewTmpDir("gotify_testcreatesqlitefolder")
os.RemoveAll("somepath") 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.Nil(t, err)
assert.DirExists(t, "somepath") assert.DirExists(t, tmpDir.Path("somepath"))
db.Close() db.Close()
assert.Nil(t, os.RemoveAll("somepath"))
} }
func TestWithAlreadyExistingSqliteFolder(t *testing.T) { func TestWithAlreadyExistingSqliteFolder(t *testing.T) {
// ensure path not exists tmpDir := test.NewTmpDir("gotify_testwithexistingfolder")
os.RemoveAll("somepath") defer tmpDir.Clean()
os.MkdirAll("somepath", 0777)
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.Nil(t, err)
assert.DirExists(t, "somepath") assert.DirExists(t, tmpDir.Path("somepath"))
db.Close() db.Close()
assert.Nil(t, os.RemoveAll("somepath"))
} }
func TestPanicsOnMkdirError(t *testing.T) { func TestPanicsOnMkdirError(t *testing.T) {
os.RemoveAll("somepath") tmpDir := test.NewTmpDir("gotify_testpanicsonmkdirerror")
defer tmpDir.Clean()
mkdirAll = func(path string, perm os.FileMode) error { mkdirAll = func(path string, perm os.FileMode) error {
return errors.New("ERROR") return errors.New("ERROR")
} }
assert.Panics(t, func() { assert.Panics(t, func() {
New("sqlite3", "somepath/test.db", "defaultUser", "defaultPass", 5, true) New("sqlite3", tmpDir.Path("somepath/test.db"), "defaultUser", "defaultPass", 5, true)
}) })
} }

View File

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

67
database/plugin.go Normal file
View File

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

40
database/plugin_test.go Normal file
View File

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

View File

@ -39,6 +39,9 @@ func (d *GormDatabase) DeleteUserByID(id uint) error {
for _, client := range d.GetClientsByUser(id) { for _, client := range d.GetClientsByUser(id) {
d.DeleteClientByID(client.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 return d.DB.Where("id = ?", id).Delete(&model.User{}).Error
} }

View File

@ -46,16 +46,38 @@ func (s *DatabaseSuite) TestUser() {
assert.Empty(s.T(), users) 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.CreateUser(&model.User{Name: "nicories", ID: 10})
s.db.CreateApplication(&model.Application{ID: 100, Token: "apptoken", UserID: 10}) s.db.CreateApplication(&model.Application{ID: 100, Token: "apptoken", UserID: 10})
s.db.CreateMessage(&model.Message{ID: 1000, ApplicationID: 100}) s.db.CreateMessage(&model.Message{ID: 1000, ApplicationID: 100})
s.db.CreateClient(&model.Client{ID: 10000, Token: "clienttoken", UserID: 10}) 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.CreateUser(&model.User{Name: "nicories2", ID: 20})
s.db.CreateApplication(&model.Application{ID: 200, Token: "apptoken2", UserID: 20}) s.db.CreateApplication(&model.Application{ID: 200, Token: "apptoken2", UserID: 20})
s.db.CreateMessage(&model.Message{ID: 2000, ApplicationID: 200}) s.db.CreateMessage(&model.Message{ID: 2000, ApplicationID: 200})
s.db.CreateClient(&model.Client{ID: 20000, Token: "clienttoken2", UserID: 20}) 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) 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.GetApplicationsByUser(10))
assert.Empty(s.T(), s.db.GetMessagesByApplication(100)) assert.Empty(s.T(), s.db.GetMessagesByApplication(100))
assert.Empty(s.T(), s.db.GetMessagesByUser(10)) 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.Nil(s.T(), s.db.GetMessageByID(1000))
assert.NotNil(s.T(), s.db.GetApplicationByToken("apptoken2")) assert.NotNil(s.T(), s.db.GetApplicationByToken("apptoken2"))
assert.NotNil(s.T(), s.db.GetClientByToken("clienttoken2")) assert.NotNil(s.T(), s.db.GetClientByToken("clienttoken2"))
assert.NotEmpty(s.T(), s.db.GetClientsByUser(20)) assert.NotEmpty(s.T(), s.db.GetClientsByUser(20))
assert.NotEmpty(s.T(), s.db.GetApplicationsByUser(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.GetMessagesByApplication(200))
assert.NotEmpty(s.T(), s.db.GetMessagesByUser(20)) assert.NotEmpty(s.T(), s.db.GetMessagesByUser(20))
assert.NotNil(s.T(), s.db.GetMessageByID(2000)) assert.NotNil(s.T(), s.db.GetMessageByID(2000))

View File

@ -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": { "/stream": {
"get": { "get": {
"security": [ "security": [
@ -1383,6 +1781,7 @@
"token", "token",
"name", "name",
"description", "description",
"internal",
"image" "image"
], ],
"properties": { "properties": {
@ -1407,6 +1806,13 @@
"readOnly": true, "readOnly": true,
"example": "https://example.com/image.jpeg" "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": { "name": {
"description": "The application name. This is how the application should be displayed to the user.", "description": "The application name. This is how the application should be displayed to the user.",
"type": "string", "type": "string",
@ -1634,6 +2040,90 @@
}, },
"x-go-package": "github.com/gotify/server/model" "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": { "User": {
"description": "The User holds information about permission and other stuff.", "description": "The User holds information about permission and other stuff.",
"type": "object", "type": "object",

View File

@ -59,7 +59,7 @@ func TestValidationError(t *testing.T) {
ctx, _ := gin.CreateTestContext(rec) ctx, _ := gin.CreateTestContext(rec)
ctx.Request = httptest.NewRequest("GET", "/uri", nil) 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) Handler()(ctx)
err := new(model.Error) err := new(model.Error)

22
go.mod
View File

@ -1,34 +1,28 @@
module github.com/gotify/server module github.com/gotify/server
require ( 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/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/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 // indirect
github.com/fortytw2/leaktest v1.3.0 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/gin-gonic/gin v1.3.0
github.com/go-sql-driver/mysql v1.4.1 // indirect github.com/go-sql-driver/mysql v1.4.1 // indirect
github.com/gobuffalo/packr v1.21.9 github.com/go-yaml/yaml v2.1.0+incompatible
github.com/google/go-cmp v0.2.0 // indirect github.com/gobuffalo/packr v1.22.0
github.com/gorilla/websocket v1.4.0 github.com/gorilla/websocket v1.4.0
github.com/gotify/configor v1.0.2-0.20190112111140-7d9c7c7e6233 github.com/gotify/configor v1.0.2-0.20190112111140-7d9c7c7e6233
github.com/gotify/location v0.0.0-20170722210143-03bc4ad20437 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/gorm v1.9.2
github.com/jinzhu/inflection v0.0.0-20180308033659-04140366298a // indirect github.com/jinzhu/inflection v0.0.0-20180308033659-04140366298a // indirect
github.com/jinzhu/now v0.0.0-20181116074157-8ec929ed50c3 // 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/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/mattn/go-sqlite3 v1.10.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/robfig/cron v0.0.0-20180505203441-b41be1df6967
github.com/modern-go/reflect2 v1.0.1 // indirect
github.com/pkg/errors v0.8.1 // indirect
github.com/stretchr/testify v1.3.0 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 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/go-playground/validator.v8 v8.18.2
gopkg.in/h2non/filetype.v1 v1.0.5 // indirect
) )

175
go.sum
View File

@ -1,5 +1,13 @@
cloud.google.com/go v0.34.0 h1:eOI3/cP2VTU6uZLDYAoic+eyzzB9YyGmJ7eIjl8rOPg= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.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.0/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 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 h1:0bt6XsYs0q3GlK1gOqdjoM4VJj9ePdd/GcNNjzH567A=
github.com/Southclaws/configor v1.0.0/go.mod h1:LVoYKxkifbFIINnnXwmqeiH4ciRalQNDMwQETyFomTs= 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/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/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/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/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/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/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-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-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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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-20190121005146-b04fd42d9952 h1:b5OnbZD49x9g+/FcYbs/vukEt8C/jUbGhCJ3uduQmu8=
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/go.mod h1:xN/JuLBIz4bjkxNmByTiV1IbhfnYb6oo99phBn4Eqhc=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= 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 v0.0.0-20180713052910-9f541cc9db5d/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/dustin/go-humanize v1.0.0/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/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.0.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
github.com/fatih/structs v1.1.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 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw=
github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= 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/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 h1:AzN37oI0cOS+cougNAV9szl6CVoj2RYwzS3DpUQNtlY=
github.com/gin-contrib/sse v0.0.0-20170109093832-22d885f9ecc7/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s= 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 h1:kCmZyPklC0gVdL728E6Aj20uYBJV93nj/TkwBTKhFbs=
github.com/gin-gonic/gin v1.3.0/go.mod h1:7cKuhb5qV2ggCFctp2fJQ+ErvciLZrIeoOSOm6mUr7Y= 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.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 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA=
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= 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.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.2/go.mod h1:9te6/VjEQ7pKp7lXlDIMqzxgGpjlKoAcAANdCgoR960=
github.com/gobuffalo/buffalo-plugins v1.8.3/go.mod h1:IAWq6vjZJVXebIq2qGTLOdlXzmpyTZ5iJG5b59fza5U= 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/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.4/go.mod h1:Abh+Jfw475/NWtYMEt+hnJWRiC8INKWibIMyNt1w2Mc=
github.com/gobuffalo/envy v1.6.5/go.mod h1:N+GkhhZ/93bGZc6ZKhJLP6+m+tCNPKwgSpH9kaifseQ= 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.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.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.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.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.3/go.mod h1:Txo8WmqScapa7zimEQIwgiJBvMECMe9gJjsKNPN3uZw=
github.com/gobuffalo/events v1.0.7/go.mod h1:z8txf6H9jWhQ5Scr7YPLWg/cgXBRj8Q4uYI+rsVCCSQ= 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= 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.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.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.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/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-20180907193754-dc14d8acaf9f/go.mod h1:rCiQgmAE4axgBNl3jZWzS5rETRYTGOsrixTRaCPzNdA=
github.com/gobuffalo/flect v0.0.0-20181002182613-4571df4b1daf/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-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-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-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-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-20181003150629-3786a0744c5d/go.mod h1:WAd8HmjMVrnkAZbmfgH5dLBUchsZfqzp/WS5sQz+uTM=
github.com/gobuffalo/genny v0.0.0-20181005145118-318a41a134cc/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-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-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-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.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.5/go.mod h1:U0643QShPF+OF2tJvYNiYDLDGDuQmJZXsf/bHOJPsMY=
github.com/gobuffalo/github_flavored_markdown v1.0.7/go.mod h1:w93Pd9Lz6LvyQXEG6DktTPHkOtCbr+arAD5mkwMzXLI= 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-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-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-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-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-20181027144941-73d08d2bb969/go.mod h1:7uGg2duHKpWnN4+YmyKBdLXfhopkAdVM6H3nKbyFbz8=
github.com/gobuffalo/logger v0.0.0-20181027193913-9cf4dd0efe46/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-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-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-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-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-contenttype v0.0.0-20180802152300-74f5a47f4d56/go.mod h1:7EvcmzBbeCvFtQm5GqF9ys6QnCxz2UM1x0moiWLq1No=
github.com/gobuffalo/mw-csrf v0.0.0-20180802151833-446ff26e108b/go.mod h1:sbGtb8DmDZuDUQoxjr8hG1ZbLtZboD9xsn6p77ppcHo= 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.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.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.0/go.mod h1:H00jGfj1qFKxscFJSw8wcL4hpQtPe1PfU2wa6sg/SR0=
github.com/gobuffalo/packr v1.21.9 h1:zBaEhCmJpYy/UdHGAGIC3vO5Uh7RW091le41+Ydcg4E= github.com/gobuffalo/packr v1.22.0 h1:/YVd/GRGsu0QuoCJtlcWSVllobs4q3Xvx3nqxTvPyN0=
github.com/gobuffalo/packr v1.21.9/go.mod h1:GC76q6nMzRtR+AEN/VV4w0z2/4q7SOaEmXh3Ooa8sOE= 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.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.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.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.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.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.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.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.20+incompatible/go.mod h1:rQ4zdtUUyZNqULlc6bqd5scsPfLKfT0+TGMChgduDvI=
github.com/gobuffalo/plush v3.7.21+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-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-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-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.2+incompatible/go.mod h1:DwBz3SD5SsHpTZiTubcsFWcVDpJWGsxjVjMPnkiThWg=
github.com/gobuffalo/pop v4.8.3+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= 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.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.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.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/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-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 h1:S5EeH1reN93KR0L6TQvkRpu9YggCYXrUqFh1iEgvdC0=
github.com/gobuffalo/syncx v0.0.0-20181120194010-558ac7de985f/go.mod h1:HhnNqWY95UYwwW3uSASeV7vtgYkT2t16hJgV3AEPUpw= 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.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.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.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.4+incompatible/go.mod h1:ErhIzkRhm0FtRuiE/PeORqcw4cVi1RtSpnwYrxuvkfE=
github.com/gobuffalo/uuid v2.0.5+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/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-20181003152136-452098b06085/go.mod h1:WevpGD+5YOreDJznWevcn8NTmQEW5STSBgIkpkjzqXc=
github.com/gobuffalo/x v0.0.0-20181007152206-913e47c59ca7/go.mod h1:9rDPXaB3kXdKWzMc4odGQQdG2e2DIEmANy5aSJ9yesY= 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.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.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 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 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 h1:+dTQ8DZQJz0Mb/HjFlkptS1FeQ4cWSnN941F8aEG4SQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= 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/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/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= 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/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 h1:4qMhogAexRcnvdoY9O1RoCuuuNEhDF25jtbGIWPtcms=
github.com/gotify/location v0.0.0-20170722210143-03bc4ad20437/go.mod h1:5JgfyQg+71Ck3uXX/4FBHc4YxdKZ9shU8gs2AUj7Nj0= 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/gotify/plugin-api v1.0.0 h1:kab40p2TEPLzjmcafOc7JOz75aTsYQyS2PXtElH8xmI=
github.com/h2non/filetype v1.0.5/go.mod h1:isekKqOuhMj+s/7r3rIeTErIRy4Rub5uBWHfvMusLMU= 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/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/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= 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/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/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 h1:lCvgEaqe/HVE+tjAR2mt4HbbHAZsQOv3XAZiEZV37iw=
github.com/jinzhu/gorm v1.9.2/go.mod h1:Vla75njaFJ8clLU1W44h34PjIkijhjHIYnZxMqCdxqo= github.com/jinzhu/gorm v1.9.2/go.mod h1:Vla75njaFJ8clLU1W44h34PjIkijhjHIYnZxMqCdxqo=
github.com/jinzhu/inflection v0.0.0-20180308033659-04140366298a h1:eeaG9XMUvRBYXJi4pg1ZKM7nxc5AfXfojeLLW7O5J3k= 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/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.5/go.mod h1:2c9FRhkDxdIbgkOnCEvnSWs71Bhugbl46shStcFDJ34=
github.com/karrick/godirwalk v1.7.7/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/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 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/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= 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-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-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-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/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/refresh v1.4.10/go.mod h1:NDPHvotuZmTmesXxr95C9bjlw1/0frJwtME2dzcVKhc=
github.com/markbates/safe v1.0.0/go.mod h1:nAqgmRi7cY2nqMc92/bSEeQA+R4OheNU2T1kNSCBdG0= 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.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 h1:jbhqpg7tQe4SupckyijYiy0mJJ/pRyHvXf7JdWK860o=
github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= 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.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/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.0.0/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/mitchellh/mapstructure v1.1.2/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 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= 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/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/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.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.7.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.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.2/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/onsi/gomega v1.4.3/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/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.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 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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.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/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/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/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/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 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/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_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-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-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/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.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.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.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.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/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/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.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.2.0/go.mod h1:r2rcYCSwa1IExKTDiTfzaxqT2FNHs8hODu4LnUfgKEg=
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= 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.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 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-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-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/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= 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-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-20180910181607-0e37d006457b/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20181001203147-e3636079e1a4/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-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-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-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-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-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-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-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-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-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-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-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-20180921000356-2f5d2388922f/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180926154720-4dfa2610cdf3/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-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-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-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-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-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-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-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-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-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-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-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-20180906133057-8cf3aee42992/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/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-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-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-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-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-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-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-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.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-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-20181003024731-2f84ea8ef872/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20181006002542-f60d9635b16a/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-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-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-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-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-20181107215632-34b416bd17b3/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20181114190951-94339b83286c/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-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-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-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.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 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 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/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/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= 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 h1:lFB4DoMU6B626w8ny76MV7VX6W2VHct2GVOI3xgiMrQ=
gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y= 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/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/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/h2non/filetype.v1 v1.0.5/go.mod h1:M0yem4rwSX5lLVrkEuRRp2/NinFMD5vgJ4DlAhZcfNo=
gopkg.in/mail.v2 v2.0.0-20180731213649-a0242b2233b4/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw= 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/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.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 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 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=

View File

@ -29,6 +29,12 @@ type Application struct {
// required: true // required: true
// example: Backup server for the interwebs // example: Backup server for the interwebs
Description string `form:"description" query:"description" json:"description"` 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. // The image of the application.
// //
// read only: true // read only: true

69
model/pluginconf.go Normal file
View File

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

View File

@ -8,6 +8,7 @@ type User struct {
Admin bool Admin bool
Applications []Application Applications []Application
Clients []Client Clients []Client
Plugins []PluginConf
} }
// UserExternal Model // UserExternal Model

91
plugin/compat/instance.go Normal file
View File

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

33
plugin/compat/plugin.go Normal file
View File

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

View File

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

183
plugin/compat/v1.go Normal file
View File

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

160
plugin/compat/v1_test.go Normal file
View File

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

35
plugin/compat/wrap.go Normal file
View File

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

163
plugin/compat/wrap_test.go Normal file
View File

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

View File

@ -0,0 +1,5 @@
// +build !race
package compat
var extraGoBuildFlags = []string{}

View File

@ -0,0 +1,5 @@
// +build race
package compat
var extraGoBuildFlags = []string{"-race"}

View File

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

120
plugin/example/echo/echo.go Normal file
View File

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

View File

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

382
plugin/manager.go Normal file
View File

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

452
plugin/manager_test.go Normal file
View File

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

View File

@ -0,0 +1,5 @@
// +build !race
package plugin
var extraGoBuildFlags = []string{}

View File

@ -0,0 +1,5 @@
// +build race
package plugin
var extraGoBuildFlags = []string{"-race"}

36
plugin/messagehandler.go Normal file
View File

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

15
plugin/pluginenabled.go Normal file
View File

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

View File

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

16
plugin/storagehandler.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,5 @@
package main
func main() {
panic("this is a broken plugin for testing purposes")
}

View File

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

175
plugin/testing/mock/mock.go Normal file
View File

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

View File

@ -14,11 +14,17 @@ import (
"github.com/gotify/server/error" "github.com/gotify/server/error"
"github.com/gotify/server/mode" "github.com/gotify/server/mode"
"github.com/gotify/server/model" "github.com/gotify/server/model"
"github.com/gotify/server/plugin"
"github.com/gotify/server/ui" "github.com/gotify/server/ui"
) )
// Create creates the gin engine with all routes. // Create creates the gin engine with all routes.
func Create(db *database.GormDatabase, vInfo *model.VersionInfo, conf *config.Configuration) (*gin.Engine, func()) { 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) streamHandler := stream.New(200*time.Second, 15*time.Second, conf.Server.Stream.AllowedOrigins)
authentication := auth.Auth{DB: db} authentication := auth.Auth{DB: db}
messageHandler := api.MessageAPI{Notifier: streamHandler, 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, DB: db,
ImageDir: conf.UploadedImagesDir, 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()) userChangeNotifier.OnUserDeleted(streamHandler.NotifyDeletedUser)
g.NoRoute(error.NotFound()) userChangeNotifier.OnUserDeleted(pluginManager.RemoveUser)
userChangeNotifier.OnUserAdded(pluginManager.InitializeForUserID)
ui.Register(g) 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") g.OPTIONS("/*any")
// swagger:operation GET /version version getVersion // swagger:operation GET /version version getVersion

View File

@ -2,17 +2,17 @@ package router
import ( import (
"bytes" "bytes"
"encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
"testing" "testing"
"github.com/gin-gonic/gin/json"
"github.com/gotify/server/config" "github.com/gotify/server/config"
"github.com/gotify/server/mode" "github.com/gotify/server/mode"
"github.com/gotify/server/model" "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/assert"
"github.com/stretchr/testify/suite" "github.com/stretchr/testify/suite"
) )
@ -28,7 +28,7 @@ func TestIntegrationSuite(t *testing.T) {
type IntegrationSuite struct { type IntegrationSuite struct {
suite.Suite suite.Suite
db *test.Database db *testdb.Database
server *httptest.Server server *httptest.Server
closable func() closable func()
} }
@ -36,7 +36,7 @@ type IntegrationSuite struct {
func (s *IntegrationSuite) BeforeTest(string, string) { func (s *IntegrationSuite) BeforeTest(string, string) {
mode.Set(mode.TestDev) mode.Set(mode.TestDev)
var err error var err error
s.db = test.NewDBWithDefaultUser(s.T()) s.db = testdb.NewDBWithDefaultUser(s.T())
assert.Nil(s.T(), err) assert.Nil(s.T(), err)
g, closable := Create(s.db.GormDatabase, g, closable := Create(s.db.GormDatabase,
&model.VersionInfo{Version: "1.0.0", BuildDate: "2018-02-20-17:30:47", Commit: "asdasds"}, &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) { func TestHeadersFromConfiguration(t *testing.T) {
mode.Set(mode.Prod) mode.Set(mode.Prod)
db := test.NewDBWithDefaultUser(t) db := testdb.NewDBWithDefaultUser(t)
defer db.Close() defer db.Close()
config := config.Configuration{PassStrength: 5} config := config.Configuration{PassStrength: 5}
@ -148,6 +148,17 @@ func (s *IntegrationSuite) TestSendMessage() {
assert.Equal(s.T(), token.ID, msg.ApplicationID) 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: "<THIS_PATH_IS_MALFORMED>",
})
})
}
func (s *IntegrationSuite) TestAuthentication() { func (s *IntegrationSuite) TestAuthentication() {
req := s.newRequest("GET", "current/user", "") req := s.newRequest("GET", "current/user", "")
req.SetBasicAuth("admin", "pw") req.SetBasicAuth("admin", "pw")

View File

@ -2,6 +2,8 @@ package test
import ( import (
"encoding/json" "encoding/json"
"errors"
"io"
"io/ioutil" "io/ioutil"
"net/http/httptest" "net/http/httptest"
@ -25,3 +27,14 @@ func JSONEquals(t assert.TestingT, obj interface{}, expected string) {
assert.JSONEq(t, expected, objJSON) 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{}
}

View File

@ -1,6 +1,7 @@
package test_test package test_test
import ( import (
"io/ioutil"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
@ -40,3 +41,8 @@ func Test_BodyEquals_failing(t *testing.T) {
test.BodyEquals(fakeTesting, &obj{ID: 2, Test: "asd"}, recorder) test.BodyEquals(fakeTesting, &obj{ID: 2, Test: "asd"}, recorder)
assert.True(t, fakeTesting.hasErrors) assert.True(t, fakeTesting.hasErrors)
} }
func Test_UnreaableReader(t *testing.T) {
_, err := ioutil.ReadAll(test.UnreadableReader())
assert.Error(t, err)
}

View File

@ -8,4 +8,5 @@ import (
// WithUser fake an authentication for testing. // WithUser fake an authentication for testing.
func WithUser(ctx *gin.Context, userID uint) { func WithUser(ctx *gin.Context, userID uint) {
ctx.Set("user", &model.User{ID: userID}) ctx.Set("user", &model.User{ID: userID})
ctx.Set("userid", userID)
} }

29
test/filepath.go Normal file
View File

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

48
test/filepath_test.go Normal file
View File

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

View File

@ -1,4 +1,4 @@
package test package testdb
import ( import (
"fmt" "fmt"
@ -64,31 +64,76 @@ func (d *Database) NewUserWithName(id uint, name string) *model.User {
// App creates an application and returns a message builder. // App creates an application and returns a message builder.
func (ab *AppClientBuilder) App(id uint) *MessageBuilder { 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. // AppWithToken creates an application with a token and returns a message builder.
func (ab *AppClientBuilder) AppWithToken(id uint, token string) *MessageBuilder { 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} return &MessageBuilder{db: ab.db, appID: id}
} }
// NewAppWithToken creates an application with a token and returns the app. // NewAppWithToken creates an application with a token and returns the app.
func (ab *AppClientBuilder) NewAppWithToken(id uint, token string) *model.Application { 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) ab.db.CreateApplication(application)
return application return application
} }
// AppWithTokenAndName creates an application with a token and name and returns a message builder. // AppWithTokenAndName creates an application with a token and name and returns a message builder.
func (ab *AppClientBuilder) AppWithTokenAndName(id uint, token, name string) *MessageBuilder { 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} return &MessageBuilder{db: ab.db, appID: id}
} }
// NewAppWithTokenAndName creates an application with a token and name and returns the app. // NewAppWithTokenAndName creates an application with a token and name and returns the app.
func (ab *AppClientBuilder) NewAppWithTokenAndName(id uint, token, name string) *model.Application { 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) ab.db.CreateApplication(application)
return application return application
} }

View File

@ -1,17 +1,17 @@
package test_test package testdb_test
import ( import (
"testing" "testing"
"github.com/gotify/server/mode" "github.com/gotify/server/mode"
"github.com/gotify/server/model" "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/assert"
"github.com/stretchr/testify/suite" "github.com/stretchr/testify/suite"
) )
func Test_WithDefault(t *testing.T) { func Test_WithDefault(t *testing.T) {
db := test.NewDBWithDefaultUser(t) db := testdb.NewDBWithDefaultUser(t)
assert.NotNil(t, db.GetUserByName("admin")) assert.NotNil(t, db.GetUserByName("admin"))
db.Close() db.Close()
} }
@ -22,12 +22,12 @@ func TestDatabaseSuite(t *testing.T) {
type DatabaseSuite struct { type DatabaseSuite struct {
suite.Suite suite.Suite
db *test.Database db *testdb.Database
} }
func (s *DatabaseSuite) BeforeTest(suiteName, testName string) { func (s *DatabaseSuite) BeforeTest(suiteName, testName string) {
mode.Set(mode.TestDev) mode.Set(mode.TestDev)
s.db = test.NewDB(s.T()) s.db = testdb.NewDB(s.T())
} }
func (s *DatabaseSuite) AfterTest(suiteName, testName string) { func (s *DatabaseSuite) AfterTest(suiteName, testName string) {
@ -88,32 +88,46 @@ func (s *DatabaseSuite) Test_Apps() {
userBuilder := s.db.User(1) userBuilder := s.db.User(1)
userBuilder.App(1) userBuilder.App(1)
newAppActual := userBuilder.NewAppWithToken(2, "asdf") 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} 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(), 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)) 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)) assert.Equal(s.T(), userTwoExpected, s.db.GetApplicationsByUser(2))
newAppWithName := userBuilder.NewAppWithTokenAndName(7, "test-token", "app name") newAppWithName := userBuilder.NewAppWithTokenAndName(7, "test-token", "app name")
newAppWithNameExpected := &model.Application{ID: 7, Token: "test-token", UserID: 1, Name: "app name"} newAppWithNameExpected := &model.Application{ID: 7, Token: "test-token", UserID: 1, Name: "app name"}
assert.Equal(s.T(), newAppWithNameExpected, newAppWithName) 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(1)
s.db.AssertAppExist(2) s.db.AssertAppExist(2)
s.db.AssertAppNotExist(3) s.db.AssertAppExist(3)
s.db.AssertAppNotExist(4) s.db.AssertAppNotExist(4)
s.db.AssertAppExist(5) s.db.AssertAppExist(5)
s.db.AssertAppNotExist(6) s.db.AssertAppNotExist(6)
s.db.AssertAppExist(7) s.db.AssertAppExist(7)
s.db.AssertAppExist(8) s.db.AssertAppExist(8)
s.db.AssertAppExist(9)
s.db.AssertAppExist(10)
s.db.AssertAppExist(11)
s.db.AssertAppExist(12)
s.db.DeleteApplicationByID(2) s.db.DeleteApplicationByID(2)

28
test/tmpdir.go Normal file
View File

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

19
test/tmpdir_test.go Normal file
View File

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

410
ui/package-lock.json generated
View File

@ -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": { "@types/detect-browser": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/@types/detect-browser/-/detect-browser-2.0.1.tgz", "resolved": "https://registry.npmjs.org/@types/detect-browser/-/detect-browser-2.0.1.tgz",
"integrity": "sha512-n9jH0zq0DGOlu/B9tSpK+DKwaW9uozF96hy3zYibvxVqQDX5KOJJn6qns/G+k3UwfEQVYZuV3Rz+Z2fXMQ0ang==", "integrity": "sha512-n9jH0zq0DGOlu/B9tSpK+DKwaW9uozF96hy3zYibvxVqQDX5KOJJn6qns/G+k3UwfEQVYZuV3Rz+Z2fXMQ0ang==",
"dev": true "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": { "@types/events": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/@types/events/-/events-1.2.0.tgz", "resolved": "https://registry.npmjs.org/@types/events/-/events-1.2.0.tgz",
@ -333,6 +348,15 @@
"@types/node": "*" "@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": { "abab": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/abab/-/abab-2.0.0.tgz", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.0.tgz",
@ -1717,6 +1741,11 @@
"integrity": "sha512-q/UEjfGJ2Cm3oKV71DJz9d25TPnq5rhBVL2Q4fA5wcC3jcrdn7+SssEybFIxwAvvP+YCsCYNKughoF33GxgycQ==", "integrity": "sha512-q/UEjfGJ2Cm3oKV71DJz9d25TPnq5rhBVL2Q4fA5wcC3jcrdn7+SssEybFIxwAvvP+YCsCYNKughoF33GxgycQ==",
"dev": true "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": { "balanced-match": {
"version": "0.4.2", "version": "0.4.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-0.4.2.tgz", "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", "resolved": "https://registry.npmjs.org/change-emitter/-/change-emitter-0.1.6.tgz",
"integrity": "sha1-6LL+PX8at9aaMhma/5HqaTFAlRU=" "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": { "chardet": {
"version": "0.4.2", "version": "0.4.2",
"resolved": "https://registry.npmjs.org/chardet/-/chardet-0.4.2.tgz", "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.4.2.tgz",
@ -2477,6 +2521,16 @@
"integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=", "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=",
"dev": true "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": { "collection-visit": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/collection-visit/-/collection-visit-1.0.0.tgz", "resolved": "https://registry.npmjs.org/collection-visit/-/collection-visit-1.0.0.tgz",
@ -3421,7 +3475,6 @@
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.1.0.tgz", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.1.0.tgz",
"integrity": "sha1-BzxpdUbOB4DOI75KKOKT5AvDDII=", "integrity": "sha1-BzxpdUbOB4DOI75KKOKT5AvDDII=",
"dev": true,
"requires": { "requires": {
"domelementtype": "~1.1.1", "domelementtype": "~1.1.1",
"entities": "~1.1.1" "entities": "~1.1.1"
@ -3430,8 +3483,7 @@
"domelementtype": { "domelementtype": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "http://registry.npmjs.org/domelementtype/-/domelementtype-1.1.3.tgz", "resolved": "http://registry.npmjs.org/domelementtype/-/domelementtype-1.1.3.tgz",
"integrity": "sha1-vSh3PiZCiBrsUVRJJCmcXNgiGFs=", "integrity": "sha1-vSh3PiZCiBrsUVRJJCmcXNgiGFs="
"dev": true
} }
} }
}, },
@ -3453,8 +3505,7 @@
"domelementtype": { "domelementtype": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.0.tgz", "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.0.tgz",
"integrity": "sha1-sXrtguirWeUt2cGbF1bg/BhyBMI=", "integrity": "sha1-sXrtguirWeUt2cGbF1bg/BhyBMI="
"dev": true
}, },
"domexception": { "domexception": {
"version": "1.0.1", "version": "1.0.1",
@ -3478,7 +3529,6 @@
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-1.5.1.tgz", "resolved": "https://registry.npmjs.org/domutils/-/domutils-1.5.1.tgz",
"integrity": "sha1-3NhIiib1Y9YQeeSMn3t+Mjc2gs8=", "integrity": "sha1-3NhIiib1Y9YQeeSMn3t+Mjc2gs8=",
"dev": true,
"requires": { "requires": {
"dom-serializer": "0", "dom-serializer": "0",
"domelementtype": "1" "domelementtype": "1"
@ -3611,8 +3661,7 @@
"entities": { "entities": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-1.1.1.tgz", "resolved": "https://registry.npmjs.org/entities/-/entities-1.1.1.tgz",
"integrity": "sha1-blwtClYhtdra7O+AuQ7ftc13cvA=", "integrity": "sha1-blwtClYhtdra7O+AuQ7ftc13cvA="
"dev": true
}, },
"enzyme-adapter-react-16": { "enzyme-adapter-react-16": {
"version": "1.1.1", "version": "1.1.1",
@ -3773,8 +3822,7 @@
"escape-string-regexp": { "escape-string-regexp": {
"version": "1.0.5", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
"integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ="
"dev": true
}, },
"escodegen": { "escodegen": {
"version": "1.11.0", "version": "1.11.0",
@ -4089,8 +4137,7 @@
"extend": { "extend": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="
"dev": true
}, },
"extend-shallow": { "extend-shallow": {
"version": "3.0.2", "version": "3.0.2",
@ -4550,7 +4597,8 @@
"ansi-regex": { "ansi-regex": {
"version": "2.1.1", "version": "2.1.1",
"bundled": true, "bundled": true,
"dev": true "dev": true,
"optional": true
}, },
"aproba": { "aproba": {
"version": "1.2.0", "version": "1.2.0",
@ -4571,12 +4619,14 @@
"balanced-match": { "balanced-match": {
"version": "1.0.0", "version": "1.0.0",
"bundled": true, "bundled": true,
"dev": true "dev": true,
"optional": true
}, },
"brace-expansion": { "brace-expansion": {
"version": "1.1.11", "version": "1.1.11",
"bundled": true, "bundled": true,
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"balanced-match": "^1.0.0", "balanced-match": "^1.0.0",
"concat-map": "0.0.1" "concat-map": "0.0.1"
@ -4591,17 +4641,20 @@
"code-point-at": { "code-point-at": {
"version": "1.1.0", "version": "1.1.0",
"bundled": true, "bundled": true,
"dev": true "dev": true,
"optional": true
}, },
"concat-map": { "concat-map": {
"version": "0.0.1", "version": "0.0.1",
"bundled": true, "bundled": true,
"dev": true "dev": true,
"optional": true
}, },
"console-control-strings": { "console-control-strings": {
"version": "1.1.0", "version": "1.1.0",
"bundled": true, "bundled": true,
"dev": true "dev": true,
"optional": true
}, },
"core-util-is": { "core-util-is": {
"version": "1.0.2", "version": "1.0.2",
@ -4718,7 +4771,8 @@
"inherits": { "inherits": {
"version": "2.0.3", "version": "2.0.3",
"bundled": true, "bundled": true,
"dev": true "dev": true,
"optional": true
}, },
"ini": { "ini": {
"version": "1.3.5", "version": "1.3.5",
@ -4730,6 +4784,7 @@
"version": "1.0.0", "version": "1.0.0",
"bundled": true, "bundled": true,
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"number-is-nan": "^1.0.0" "number-is-nan": "^1.0.0"
} }
@ -4744,6 +4799,7 @@
"version": "3.0.4", "version": "3.0.4",
"bundled": true, "bundled": true,
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"brace-expansion": "^1.1.7" "brace-expansion": "^1.1.7"
} }
@ -4751,12 +4807,14 @@
"minimist": { "minimist": {
"version": "0.0.8", "version": "0.0.8",
"bundled": true, "bundled": true,
"dev": true "dev": true,
"optional": true
}, },
"minipass": { "minipass": {
"version": "2.2.4", "version": "2.2.4",
"bundled": true, "bundled": true,
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"safe-buffer": "^5.1.1", "safe-buffer": "^5.1.1",
"yallist": "^3.0.0" "yallist": "^3.0.0"
@ -4775,6 +4833,7 @@
"version": "0.5.1", "version": "0.5.1",
"bundled": true, "bundled": true,
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"minimist": "0.0.8" "minimist": "0.0.8"
} }
@ -4855,7 +4914,8 @@
"number-is-nan": { "number-is-nan": {
"version": "1.0.1", "version": "1.0.1",
"bundled": true, "bundled": true,
"dev": true "dev": true,
"optional": true
}, },
"object-assign": { "object-assign": {
"version": "4.1.1", "version": "4.1.1",
@ -4867,6 +4927,7 @@
"version": "1.4.0", "version": "1.4.0",
"bundled": true, "bundled": true,
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"wrappy": "1" "wrappy": "1"
} }
@ -4952,7 +5013,8 @@
"safe-buffer": { "safe-buffer": {
"version": "5.1.1", "version": "5.1.1",
"bundled": true, "bundled": true,
"dev": true "dev": true,
"optional": true
}, },
"safer-buffer": { "safer-buffer": {
"version": "2.1.2", "version": "2.1.2",
@ -4988,6 +5050,7 @@
"version": "1.0.2", "version": "1.0.2",
"bundled": true, "bundled": true,
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"code-point-at": "^1.0.0", "code-point-at": "^1.0.0",
"is-fullwidth-code-point": "^1.0.0", "is-fullwidth-code-point": "^1.0.0",
@ -5007,6 +5070,7 @@
"version": "3.0.1", "version": "3.0.1",
"bundled": true, "bundled": true,
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"ansi-regex": "^2.0.0" "ansi-regex": "^2.0.0"
} }
@ -5050,12 +5114,14 @@
"wrappy": { "wrappy": {
"version": "1.0.2", "version": "1.0.2",
"bundled": true, "bundled": true,
"dev": true "dev": true,
"optional": true
}, },
"yallist": { "yallist": {
"version": "3.0.2", "version": "3.0.2",
"bundled": true, "bundled": true,
"dev": true "dev": true,
"optional": true
} }
} }
}, },
@ -5526,6 +5592,51 @@
"uglify-js": "3.4.x" "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": { "html-webpack-plugin": {
"version": "2.29.0", "version": "2.29.0",
"resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-2.29.0.tgz", "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-2.29.0.tgz",
@ -5918,8 +6029,7 @@
"inherits": { "inherits": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
"integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=", "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4="
"dev": true
}, },
"ini": { "ini": {
"version": "1.3.5", "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": { "is-arrayish": {
"version": "0.2.1", "version": "0.2.1",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "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", "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.1.tgz",
"integrity": "sha1-mqIOtq7rv/d/vTPnTKAbM1gdOhY=" "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": { "is-descriptor": {
"version": "0.1.6", "version": "0.1.6",
"resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz", "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz",
@ -6199,6 +6328,11 @@
"is-extglob": "^1.0.0" "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": { "is-in-browser": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/is-in-browser/-/is-in-browser-1.1.3.tgz", "resolved": "https://registry.npmjs.org/is-in-browser/-/is-in-browser-1.1.3.tgz",
@ -6273,8 +6407,7 @@
"is-plain-obj": { "is-plain-obj": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz",
"integrity": "sha1-caUMhCnfync8kqOQpKA7OfzVHT4=", "integrity": "sha1-caUMhCnfync8kqOQpKA7OfzVHT4="
"dev": true
}, },
"is-plain-object": { "is-plain-object": {
"version": "2.0.4", "version": "2.0.4",
@ -6359,12 +6492,22 @@
"integrity": "sha1-Sw2hRCEE0bM2NA6AeX6GXPOffXI=", "integrity": "sha1-Sw2hRCEE0bM2NA6AeX6GXPOffXI=",
"dev": true "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": { "is-windows": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz",
"integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==",
"dev": true "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": { "is-wsl": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-1.1.0.tgz", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-1.1.0.tgz",
@ -7731,8 +7874,7 @@
"lodash.camelcase": { "lodash.camelcase": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
"integrity": "sha1-soqmKIorn8ZRA1x3EfZathkDMaY=", "integrity": "sha1-soqmKIorn8ZRA1x3EfZathkDMaY="
"dev": true
}, },
"lodash.debounce": { "lodash.debounce": {
"version": "4.0.8", "version": "4.0.8",
@ -7916,6 +8058,11 @@
"object-visit": "^1.0.0" "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": { "math-expression-evaluator": {
"version": "1.2.17", "version": "1.2.17",
"resolved": "https://registry.npmjs.org/math-expression-evaluator/-/math-expression-evaluator-1.2.17.tgz", "resolved": "https://registry.npmjs.org/math-expression-evaluator/-/math-expression-evaluator-1.2.17.tgz",
@ -7938,6 +8085,14 @@
"inherits": "^2.0.1" "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": { "media-typer": {
"version": "0.3.0", "version": "0.3.0",
"resolved": "http://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", "resolved": "http://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
@ -8737,6 +8892,19 @@
"pbkdf2": "^3.0.3" "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": { "parse-glob": {
"version": "3.0.4", "version": "3.0.4",
"resolved": "https://registry.npmjs.org/parse-glob/-/parse-glob-3.0.4.tgz", "resolved": "https://registry.npmjs.org/parse-glob/-/parse-glob-3.0.4.tgz",
@ -8929,7 +9097,7 @@
"dependencies": { "dependencies": {
"async": { "async": {
"version": "1.5.2", "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=", "integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=",
"dev": true "dev": true
} }
@ -10492,6 +10660,11 @@
"performance-now": "^2.1.0" "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": { "randomatic": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/randomatic/-/randomatic-3.1.0.tgz", "resolved": "https://registry.npmjs.org/randomatic/-/randomatic-3.1.0.tgz",
@ -10611,6 +10784,11 @@
"prop-types": "^15.6.0" "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": { "react-dev-utils": {
"version": "5.0.2", "version": "5.0.2",
"resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-5.0.2.tgz", "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", "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==" "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": { "react-reconciler": {
"version": "0.7.0", "version": "0.7.0",
"resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.7.0.tgz", "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.7.0.tgz",
@ -11090,7 +11282,7 @@
"dependencies": { "dependencies": {
"jsesc": { "jsesc": {
"version": "0.5.0", "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=", "integrity": "sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0=",
"dev": true "dev": true
} }
@ -11102,6 +11294,28 @@
"integrity": "sha1-VNvzd+UUQKypCkzSdGANP/LYiKk=", "integrity": "sha1-VNvzd+UUQKypCkzSdGANP/LYiKk=",
"dev": true "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": { "remove-trailing-separator": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz", "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz",
@ -11138,8 +11352,7 @@
"repeat-string": { "repeat-string": {
"version": "1.6.1", "version": "1.6.1",
"resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz",
"integrity": "sha1-jcrkcOHIirwtYA//Sndihtp15jc=", "integrity": "sha1-jcrkcOHIirwtYA//Sndihtp15jc="
"dev": true
}, },
"repeating": { "repeating": {
"version": "2.0.1", "version": "2.0.1",
@ -11150,6 +11363,11 @@
"is-finite": "^1.0.0" "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": { "request": {
"version": "2.88.0", "version": "2.88.0",
"resolved": "https://registry.npmjs.org/request/-/request-2.88.0.tgz", "resolved": "https://registry.npmjs.org/request/-/request-2.88.0.tgz",
@ -11359,8 +11577,7 @@
"safe-buffer": { "safe-buffer": {
"version": "5.1.2", "version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
"dev": true
}, },
"safe-regex": { "safe-regex": {
"version": "1.1.0", "version": "1.1.0",
@ -11956,6 +12173,11 @@
"integrity": "sha1-1PM6tU6OOHeLDKXP07OvsS22hiA=", "integrity": "sha1-1PM6tU6OOHeLDKXP07OvsS22hiA=",
"dev": true "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": { "static-extend": {
"version": "0.1.2", "version": "0.1.2",
"resolved": "https://registry.npmjs.org/static-extend/-/static-extend-0.1.2.tgz", "resolved": "https://registry.npmjs.org/static-extend/-/static-extend-0.1.2.tgz",
@ -12092,7 +12314,6 @@
"version": "1.1.1", "version": "1.1.1",
"resolved": "http://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", "resolved": "http://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"dev": true,
"requires": { "requires": {
"safe-buffer": "~5.1.0" "safe-buffer": "~5.1.0"
} }
@ -12448,6 +12669,11 @@
"integrity": "sha512-DlX6dR0lOIRDFxI0mjL9IYg6OTncLm/Zt+JiBhE5OlFcAR8yc9S7FFXU9so0oda47frdM/JFsk7UjNt9vscKcg==", "integrity": "sha512-DlX6dR0lOIRDFxI0mjL9IYg6OTncLm/Zt+JiBhE5OlFcAR8yc9S7FFXU9so0oda47frdM/JFsk7UjNt9vscKcg==",
"dev": true "dev": true
}, },
"trim": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/trim/-/trim-0.0.1.tgz",
"integrity": "sha1-WFhUf2spB1fulczMZm+1AITEYN0="
},
"trim-newlines": { "trim-newlines": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-1.0.0.tgz", "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-1.0.0.tgz",
@ -12460,6 +12686,16 @@
"integrity": "sha1-yy4SAwZ+DI3h9hQJS5/kVwTqYAM=", "integrity": "sha1-yy4SAwZ+DI3h9hQJS5/kVwTqYAM=",
"dev": true "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": { "ts-jest": {
"version": "22.0.1", "version": "22.0.1",
"resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-22.0.1.tgz", "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-22.0.1.tgz",
@ -12540,7 +12776,7 @@
"dependencies": { "dependencies": {
"json5": { "json5": {
"version": "1.0.1", "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==", "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
"dev": true, "dev": true,
"requires": { "requires": {
@ -12549,7 +12785,7 @@
}, },
"minimist": { "minimist": {
"version": "1.2.0", "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=", "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=",
"dev": true "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": { "union-value": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.0.tgz", "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.0.tgz",
@ -12886,6 +13144,47 @@
"crypto-random-string": "^1.0.0" "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": { "universalify": {
"version": "0.1.2", "version": "0.1.2",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
@ -13074,8 +13373,7 @@
"util-deprecate": { "util-deprecate": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8="
"dev": true
}, },
"util.promisify": { "util.promisify": {
"version": "1.0.0", "version": "1.0.0",
@ -13143,6 +13441,30 @@
"extsprintf": "^1.2.0" "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": { "vm-browserify": {
"version": "0.0.4", "version": "0.0.4",
"resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-0.0.4.tgz", "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-0.0.4.tgz",
@ -13983,6 +14305,11 @@
"async-limiter": "~1.0.0" "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": { "xdg-basedir": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-3.0.0.tgz", "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-3.0.0.tgz",
@ -13998,8 +14325,7 @@
"xtend": { "xtend": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.1.tgz", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.1.tgz",
"integrity": "sha1-pcbVMr5lbiPbgg77lDofBJmNY68=", "integrity": "sha1-pcbVMr5lbiPbgg77lDofBJmNY68="
"dev": true
}, },
"y18n": { "y18n": {
"version": "3.2.1", "version": "3.2.1",

View File

@ -6,6 +6,7 @@
"@material-ui/core": "^1.5.1", "@material-ui/core": "^1.5.1",
"@material-ui/icons": "^2.0.3", "@material-ui/icons": "^2.0.3",
"axios": "^0.18.0", "axios": "^0.18.0",
"codemirror": "^5.43.0",
"detect-browser": "^3.0.0", "detect-browser": "^3.0.0",
"mobx": "^5.1.1", "mobx": "^5.1.1",
"mobx-react": "^5.2.8", "mobx-react": "^5.2.8",
@ -13,8 +14,10 @@
"notifyjs": "^3.0.0", "notifyjs": "^3.0.0",
"prop-types": "^15.6.2", "prop-types": "^15.6.2",
"react": "^16.4.2", "react": "^16.4.2",
"react-codemirror2": "^5.1.0",
"react-dom": "^16.4.2", "react-dom": "^16.4.2",
"react-infinite": "^0.13.0", "react-infinite": "^0.13.0",
"react-markdown": "^4.0.6",
"react-router": "^4.3.1", "react-router": "^4.3.1",
"react-router-dom": "^4.3.1", "react-router-dom": "^4.3.1",
"react-timeago": "^4.1.9", "react-timeago": "^4.1.9",
@ -32,6 +35,7 @@
"testformat": "prettier src/**/*.{ts,tsx} --list-different" "testformat": "prettier src/**/*.{ts,tsx} --list-different"
}, },
"devDependencies": { "devDependencies": {
"@types/codemirror": "0.0.71",
"@types/detect-browser": "^2.0.1", "@types/detect-browser": "^2.0.1",
"@types/get-port": "^4.0.0", "@types/get-port": "^4.0.0",
"@types/jest": "^23.3.1", "@types/jest": "^23.3.1",
@ -46,8 +50,8 @@
"get-port": "^4.0.0", "get-port": "^4.0.0",
"prettier": "^1.14.2", "prettier": "^1.14.2",
"puppeteer": "^1.8.0", "puppeteer": "^1.8.0",
"rimraf": "^2.6.2",
"react-scripts-ts": "2.17.0", "react-scripts-ts": "2.17.0",
"rimraf": "^2.6.2",
"tree-kill": "^1.2.0", "tree-kill": "^1.2.0",
"tslint-sonarts": "^1.7.0", "tslint-sonarts": "^1.7.0",
"typescript": "^3.0.1", "typescript": "^3.0.1",

View File

@ -45,7 +45,7 @@ export default class AddDialog extends Component<IProps, IState> {
margin="dense" margin="dense"
className="name" className="name"
label="Name *" label="Name *"
type="email" type="text"
value={name} value={name}
onChange={this.handleChange.bind(this, 'name')} onChange={this.handleChange.bind(this, 'name')}
fullWidth fullWidth

View File

@ -35,6 +35,13 @@ export class AppStore extends BaseStore<IApplication> {
this.snack('Application image updated'); this.snack('Application image updated');
}; };
@action
public update = async (id: number, name: string, description: string): Promise<void> => {
await axios.put(`${config.get('url')}application/${id}`, {name, description});
await this.refresh();
this.snack('Application updated');
};
@action @action
public create = async (name: string, description: string): Promise<void> => { public create = async (name: string, description: string): Promise<void> => {
await axios.post(`${config.get('url')}application`, {name, description}); await axios.post(`${config.get('url')}application`, {name, description});

View File

@ -9,6 +9,7 @@ import TableHead from '@material-ui/core/TableHead';
import TableRow from '@material-ui/core/TableRow'; import TableRow from '@material-ui/core/TableRow';
import Delete from '@material-ui/icons/Delete'; import Delete from '@material-ui/icons/Delete';
import Edit from '@material-ui/icons/Edit'; import Edit from '@material-ui/icons/Edit';
import CloudUpload from '@material-ui/icons/CloudUpload';
import React, {ChangeEvent, Component, SFC} from 'react'; import React, {ChangeEvent, Component, SFC} from 'react';
import ConfirmDialog from '../common/ConfirmDialog'; import ConfirmDialog from '../common/ConfirmDialog';
import DefaultPage from '../common/DefaultPage'; import DefaultPage from '../common/DefaultPage';
@ -17,12 +18,15 @@ import AddApplicationDialog from './AddApplicationDialog';
import {observer} from 'mobx-react'; import {observer} from 'mobx-react';
import {observable} from 'mobx'; import {observable} from 'mobx';
import {inject, Stores} from '../inject'; import {inject, Stores} from '../inject';
import UpdateDialog from './UpdateApplicationDialog';
@observer @observer
class Applications extends Component<Stores<'appStore'>> { class Applications extends Component<Stores<'appStore'>> {
@observable @observable
private deleteId: number | false = false; private deleteId: number | false = false;
@observable @observable
private updateId: number | false = false;
@observable
private createDialog = false; private createDialog = false;
private uploadId = -1; private uploadId = -1;
@ -34,6 +38,7 @@ class Applications extends Component<Stores<'appStore'>> {
const { const {
createDialog, createDialog,
deleteId, deleteId,
updateId,
props: {appStore}, props: {appStore},
} = this; } = this;
const apps = appStore.getItems(); const apps = appStore.getItems();
@ -54,6 +59,7 @@ class Applications extends Component<Stores<'appStore'>> {
<TableCell>Token</TableCell> <TableCell>Token</TableCell>
<TableCell>Description</TableCell> <TableCell>Description</TableCell>
<TableCell /> <TableCell />
<TableCell />
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
@ -67,6 +73,8 @@ class Applications extends Component<Stores<'appStore'>> {
value={app.token} value={app.token}
fUpload={() => this.uploadImage(app.id)} fUpload={() => this.uploadImage(app.id)}
fDelete={() => (this.deleteId = app.id)} fDelete={() => (this.deleteId = app.id)}
fEdit={() => (this.updateId = app.id)}
noDelete={app.internal}
/> />
); );
})} })}
@ -86,6 +94,16 @@ class Applications extends Component<Stores<'appStore'>> {
fOnSubmit={appStore.create} fOnSubmit={appStore.create}
/> />
)} )}
{updateId !== false && (
<UpdateDialog
fClose={() => (this.updateId = false)}
fOnSubmit={(name, description) =>
appStore.update(updateId, name, description)
}
initialDescription={appStore.getByID(updateId).description}
initialName={appStore.getByID(updateId).name}
/>
)}
{deleteId !== false && ( {deleteId !== false && (
<ConfirmDialog <ConfirmDialog
title="Confirm Delete" title="Confirm Delete"
@ -121,33 +139,42 @@ class Applications extends Component<Stores<'appStore'>> {
interface IRowProps { interface IRowProps {
name: string; name: string;
value: string; value: string;
noDelete: boolean;
description: string; description: string;
fUpload: VoidFunction; fUpload: VoidFunction;
image: string; image: string;
fDelete: VoidFunction; fDelete: VoidFunction;
fEdit: VoidFunction;
} }
const Row: SFC<IRowProps> = observer(({name, value, description, fDelete, fUpload, image}) => ( const Row: SFC<IRowProps> = observer(
<TableRow> ({name, value, noDelete, description, fDelete, fUpload, image, fEdit}) => (
<TableCell padding="checkbox"> <TableRow>
<div style={{display: 'flex'}}> <TableCell padding="checkbox">
<Avatar src={image} /> <div style={{display: 'flex'}}>
<IconButton onClick={fUpload} style={{height: 40}}> <Avatar src={image} />
<IconButton onClick={fUpload} style={{height: 40}}>
<CloudUpload />
</IconButton>
</div>
</TableCell>
<TableCell>{name}</TableCell>
<TableCell>
<ToggleVisibility value={value} style={{display: 'flex', alignItems: 'center'}} />
</TableCell>
<TableCell>{description}</TableCell>
<TableCell numeric padding="none">
<IconButton onClick={fEdit} className="edit">
<Edit /> <Edit />
</IconButton> </IconButton>
</div> </TableCell>
</TableCell> <TableCell numeric padding="none">
<TableCell>{name}</TableCell> <IconButton onClick={fDelete} className="delete" disabled={noDelete}>
<TableCell> <Delete />
<ToggleVisibility value={value} style={{display: 'flex', alignItems: 'center'}} /> </IconButton>
</TableCell> </TableCell>
<TableCell>{description}</TableCell> </TableRow>
<TableCell numeric padding="none"> )
<IconButton onClick={fDelete} className="delete"> );
<Delete />
</IconButton>
</TableCell>
</TableRow>
));
export default inject('appStore')(Applications); export default inject('appStore')(Applications);

View File

@ -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<IProps, IState> {
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 (
<Dialog
open={true}
onClose={fClose}
aria-labelledby="form-dialog-title"
id="app-dialog">
<DialogTitle id="form-dialog-title">Update an application</DialogTitle>
<DialogContent>
<DialogContentText>
An application is allowed to send messages.
</DialogContentText>
<TextField
autoFocus
margin="dense"
className="name"
label="Name *"
type="text"
value={name}
onChange={this.handleChange.bind(this, 'name')}
fullWidth
/>
<TextField
margin="dense"
className="description"
label="Short Description"
value={description}
onChange={this.handleChange.bind(this, 'description')}
fullWidth
multiline
/>
</DialogContent>
<DialogActions>
<Button onClick={fClose}>Cancel</Button>
<Tooltip title={submitEnabled ? '' : 'name is required'}>
<div>
<Button
className="update"
disabled={!submitEnabled}
onClick={submitAndClose}
color="primary"
variant="raised">
Update
</Button>
</div>
</Tooltip>
</DialogActions>
</Dialog>
);
}
private handleChange(propertyName: string, event: React.ChangeEvent<HTMLInputElement>) {
const state = this.state;
state[propertyName] = event.target.value;
this.setState(state);
}
}

View File

@ -15,6 +15,7 @@ import {InjectProvider, StoreMapping} from './inject';
import {UserStore} from './user/UserStore'; import {UserStore} from './user/UserStore';
import {MessagesStore} from './message/MessagesStore'; import {MessagesStore} from './message/MessagesStore';
import {ClientStore} from './client/ClientStore'; import {ClientStore} from './client/ClientStore';
import {PluginStore} from './plugin/PluginStore';
const defaultDevConfig = { const defaultDevConfig = {
url: 'http://localhost:80/', url: 'http://localhost:80/',
@ -22,7 +23,7 @@ const defaultDevConfig = {
const {port, hostname, protocol} = window.location; const {port, hostname, protocol} = window.location;
const slashes = protocol.concat('//'); 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 urlWithSlash = url.endsWith('/') ? url : url.concat('/');
const defaultProdConfig = { const defaultProdConfig = {
@ -44,6 +45,7 @@ const initStores = (): StoreMapping => {
const currentUser = new CurrentUser(snackManager.snack); const currentUser = new CurrentUser(snackManager.snack);
const clientStore = new ClientStore(snackManager.snack); const clientStore = new ClientStore(snackManager.snack);
const wsStore = new WebSocketStore(snackManager.snack, currentUser); const wsStore = new WebSocketStore(snackManager.snack, currentUser);
const pluginStore = new PluginStore(snackManager.snack);
appStore.onDelete = () => messagesStore.clearAll(); appStore.onDelete = () => messagesStore.clearAll();
return { return {
@ -54,6 +56,7 @@ const initStores = (): StoreMapping => {
currentUser, currentUser,
clientStore, clientStore,
wsStore, wsStore,
pluginStore,
}; };
}; };

View File

@ -7,6 +7,7 @@ import {ClientStore} from './client/ClientStore';
import {AppStore} from './application/AppStore'; import {AppStore} from './application/AppStore';
import {inject as mobxInject, Provider} from 'mobx-react'; import {inject as mobxInject, Provider} from 'mobx-react';
import {WebSocketStore} from './message/WebSocketStore'; import {WebSocketStore} from './message/WebSocketStore';
import {PluginStore} from './plugin/PluginStore';
export interface StoreMapping { export interface StoreMapping {
userStore: UserStore; userStore: UserStore;
@ -15,6 +16,7 @@ export interface StoreMapping {
currentUser: CurrentUser; currentUser: CurrentUser;
clientStore: ClientStore; clientStore: ClientStore;
appStore: AppStore; appStore: AppStore;
pluginStore: PluginStore;
wsStore: WebSocketStore; wsStore: WebSocketStore;
} }

View File

@ -9,6 +9,7 @@ import Chat from '@material-ui/icons/Chat';
import DevicesOther from '@material-ui/icons/DevicesOther'; import DevicesOther from '@material-ui/icons/DevicesOther';
import ExitToApp from '@material-ui/icons/ExitToApp'; import ExitToApp from '@material-ui/icons/ExitToApp';
import Highlight from '@material-ui/icons/Highlight'; import Highlight from '@material-ui/icons/Highlight';
import Apps from '@material-ui/icons/Apps';
import SupervisorAccount from '@material-ui/icons/SupervisorAccount'; import SupervisorAccount from '@material-ui/icons/SupervisorAccount';
import React, {Component} from 'react'; import React, {Component} from 'react';
import {Link} from 'react-router-dom'; import {Link} from 'react-router-dom';
@ -104,6 +105,12 @@ class Header extends Component<IProps & Styles> {
&nbsp;clients &nbsp;clients
</Button> </Button>
</Link> </Link>
<Link className={classes.link} to="/plugins" id="navigate-plugins">
<Button color="inherit">
<Apps />
&nbsp;plugins
</Button>
</Link>
<Button color="inherit" onClick={showSettings} id="changepw"> <Button color="inherit" onClick={showSettings} id="changepw">
<AccountCircle /> <AccountCircle />
&nbsp; &nbsp;

View File

@ -12,6 +12,8 @@ import SnackBarHandler from '../snack/SnackBarHandler';
import * as config from '../config'; import * as config from '../config';
import Applications from '../application/Applications'; import Applications from '../application/Applications';
import Clients from '../client/Clients'; import Clients from '../client/Clients';
import Plugins from '../plugin/Plugins';
import PluginDetailView from '../plugin/PluginDetailView';
import Login from '../user/Login'; import Login from '../user/Login';
import Messages from '../message/Messages'; import Messages from '../message/Messages';
import Users from '../user/Users'; import Users from '../user/Users';
@ -101,6 +103,8 @@ class Layout extends React.Component<WithStyles<'content'> & Stores<'currentUser
<Route exact path="/applications" component={Applications} /> <Route exact path="/applications" component={Applications} />
<Route exact path="/clients" component={Clients} /> <Route exact path="/clients" component={Clients} />
<Route exact path="/users" component={Users} /> <Route exact path="/users" component={Users} />
<Route exact path="/plugins" component={Plugins} />
<Route exact path="/plugins/:id" component={PluginDetailView} />
</Switch> </Switch>
</main> </main>
{showSettings && ( {showSettings && (

View File

@ -0,0 +1,282 @@
import React, {Component} from 'react';
import {RouteComponentProps} from 'react-router';
import ReactMarkDown from 'react-markdown';
import {UnControlled as CodeMirror} from 'react-codemirror2';
import 'codemirror/lib/codemirror.css';
import 'codemirror/theme/material.css';
import 'codemirror/mode/yaml/yaml';
import Info from '@material-ui/icons/Info';
import Build from '@material-ui/icons/Build';
import Subject from '@material-ui/icons/Subject';
import Refresh from '@material-ui/icons/Refresh';
import Button from '@material-ui/core/Button';
import Typography from '@material-ui/core/Typography';
import DefaultPage from '../common/DefaultPage';
import * as config from '../config';
import Container from '../common/Container';
import {inject, Stores} from '../inject';
interface IProps extends RouteComponentProps<{id: string}> {}
interface IState {
displayText: string | null;
currentConfig: string | null;
}
class PluginDetailView extends Component<IProps & Stores<'pluginStore'>, IState> {
private pluginID: number = parseInt(this.props.match.params.id, 10);
private pluginInfo = () => this.props.pluginStore.getByID(this.pluginID);
public state: IState = {
displayText: null,
currentConfig: null,
};
public componentWillMount() {
this.refreshFeatures();
}
public componentWillReceiveProps(nextProps: IProps & Stores<'pluginStore'>) {
this.pluginID = parseInt(nextProps.match.params.id, 10);
this.refreshFeatures();
}
private refreshFeatures() {
return Promise.all([this.refreshConfigurer(), this.refreshDisplayer()]);
}
private async refreshConfigurer() {
const {
props: {pluginStore},
} = this;
if (this.pluginInfo().capabilities.indexOf('configurer') !== -1) {
const response = await pluginStore.requestConfig(this.pluginID);
this.setState({currentConfig: response});
}
}
private async refreshDisplayer() {
const {
props: {pluginStore},
} = this;
if (this.pluginInfo().capabilities.indexOf('displayer') !== -1) {
const response = await pluginStore.requestDisplay(this.pluginID);
this.setState({displayText: response});
}
}
public render() {
const pluginInfo = this.pluginInfo();
const {name, capabilities} = pluginInfo;
return (
<DefaultPage title={name} hideButton={true} maxWidth={1000}>
<PanelWrapper name={'Plugin Info'} icon={Info}>
<PluginInfo pluginInfo={pluginInfo} />
</PanelWrapper>
{capabilities.indexOf('configurer') !== -1 ? (
<PanelWrapper
name={'Configurer'}
description={'This is the configuration panel for this plugin.'}
icon={Build}
refresh={this.refreshConfigurer.bind(this)}>
<ConfigurerPanel
pluginInfo={pluginInfo}
initialConfig={
this.state.currentConfig !== null
? this.state.currentConfig
: 'Loading...'
}
save={async (newConfig) => {
await this.props.pluginStore.changeConfig(this.pluginID, newConfig);
await this.refreshFeatures();
}}
/>
</PanelWrapper>
) : null}{' '}
{capabilities.indexOf('displayer') !== -1 ? (
<PanelWrapper
name={'Displayer'}
description={'This is the information generated by the plugin.'}
refresh={this.refreshDisplayer.bind(this)}
icon={Subject}>
<DisplayerPanel
pluginInfo={pluginInfo}
displayText={
this.state.displayText !== null
? this.state.displayText
: 'Loading...'
}
/>
</PanelWrapper>
) : null}
</DefaultPage>
);
}
}
interface IPanelWrapperProps {
name: string;
description?: string;
refresh?: () => Promise<void>;
icon?: React.ComponentType;
}
const PanelWrapper: React.SFC<IPanelWrapperProps> = ({
name,
description,
refresh,
icon,
children,
}) => {
const Icon = icon;
return (
<Container style={{display: 'block', width: '100%', margin: '20px', color: 'white'}}>
<Typography variant="headline">
{Icon ? (
<span>
<Icon />
&nbsp;
</span>
) : null}
{name}
{refresh ? (
<Button
style={{float: 'right'}}
onClick={() => {
refresh();
}}>
<Refresh />
</Button>
) : null}
</Typography>
{description ? (
<Typography variant="subheading" style={{color: 'grey'}}>
{description}
</Typography>
) : null}
<hr />
<div
className={name
.toLowerCase()
.trim()
.replace(/ /g, '-')}>
{children}
</div>
</Container>
);
};
interface IConfigurerPanelProps {
pluginInfo: IPlugin;
initialConfig: string;
save: (newConfig: string) => Promise<void>;
}
class ConfigurerPanel extends Component<IConfigurerPanelProps, {unsavedChanges: string | null}> {
public state = {unsavedChanges: null};
public render() {
return (
<div>
<CodeMirror
value={this.props.initialConfig}
options={{
mode: 'yaml',
theme: 'material',
lineNumbers: true,
}}
onChange={(instance, data, value) => {
let newConf: string | null = value;
if (value === this.props.initialConfig) {
newConf = null;
}
this.setState({unsavedChanges: newConf});
}}
/>
<br />
<Button
variant="contained"
color="primary"
fullWidth={true}
disabled={
this.state.unsavedChanges === null ||
this.state.unsavedChanges === this.props.initialConfig
}
className="config-save"
onClick={() => {
const newConfig = this.state.unsavedChanges;
this.props.save(newConfig!).then(() => {
this.setState({unsavedChanges: ''});
});
}}>
<Typography variant="button">Save</Typography>
</Button>
</div>
);
}
}
interface IDisplayerPanelProps {
pluginInfo: IPlugin;
displayText: string;
}
const DisplayerPanel: React.SFC<IDisplayerPanelProps> = ({pluginInfo, displayText}) => (
<Typography variant="body2">
<ReactMarkDown source={displayText} />
</Typography>
);
class PluginInfo extends Component<{pluginInfo: IPlugin}> {
public render() {
const {
props: {
pluginInfo: {name, author, modulePath, website, license, capabilities, id, token},
},
} = this;
return (
<div>
{name ? (
<Typography variant="body2" className="name">
Name: <span>{name}</span>
</Typography>
) : null}
{author ? (
<Typography variant="body2" className="author">
Author: <span>{author}</span>
</Typography>
) : null}
<Typography variant="body2" className="module-path">
Module Path: <span>{modulePath}</span>
</Typography>
{website ? (
<Typography variant="body2" className="website">
Website: <span>{website}</span>
</Typography>
) : null}
{license ? (
<Typography variant="body2" className="license">
License: <span>{license}</span>
</Typography>
) : null}
<Typography variant="body2" className="capabilities">
Capabilities: <span>{capabilities.join(', ')}</span>
</Typography>
{capabilities.indexOf('webhooker') !== -1 ? (
<Typography variant="body2">
Custom Route Prefix:{' '}
{((url) => (
<a
href={url}
target="_blank"
style={{color: 'white'}}
className="custom-route">
{url}
</a>
))(`${config.get('url')}plugin/${id}/custom/${token}/`)}
</Typography>
) : null}
</div>
);
}
}
export default inject('pluginStore')(PluginDetailView);

View File

@ -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<IPlugin> {
public onDelete: () => void = () => {};
public constructor(private readonly snack: SnackReporter) {
super();
}
public requestConfig = (id: number): Promise<string> => {
return axios
.get(`${config.get('url')}plugin/${id}/config`)
.then((response) => response.data);
};
public requestDisplay = (id: number): Promise<string> => {
return axios
.get(`${config.get('url')}plugin/${id}/display`)
.then((response) => response.data);
};
protected requestItems = (): Promise<IPlugin[]> => {
return axios.get<IPlugin[]>(`${config.get('url')}plugin`).then((response) => response.data);
};
protected requestDelete = (id: number): Promise<void> => {
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<void> => {
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<void> => {
await axios.post(`${config.get('url')}plugin/${id}/${enabled ? 'enable' : 'disable'}`);
this.snack(`Plugin ${enabled ? 'enabled' : 'disabled'}`);
await this.refresh();
};
}

100
ui/src/plugin/Plugins.tsx Normal file
View File

@ -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<Stores<'pluginStore'>> {
public componentDidMount = () => this.props.pluginStore.refresh();
public render() {
const {
props: {pluginStore},
} = this;
const plugins = pluginStore.getItems();
return (
<DefaultPage title="Plugins" hideButton={true} maxWidth={1000}>
<Grid item xs={12}>
<Paper elevation={6}>
<Table id="plugin-table">
<TableHead>
<TableRow>
<TableCell>ID</TableCell>
<TableCell>Enabled</TableCell>
<TableCell>Name</TableCell>
<TableCell>Token</TableCell>
<TableCell>Details</TableCell>
</TableRow>
</TableHead>
<TableBody>
{plugins.map((plugin: IPlugin) => {
return (
<Row
key={plugin.token}
id={plugin.id}
token={plugin.token}
name={plugin.name}
enabled={plugin.enabled}
fToggleStatus={() =>
this.props.pluginStore.changeEnabledState(
plugin.id,
!plugin.enabled
)
}
/>
);
})}
</TableBody>
</Table>
</Paper>
</Grid>
</DefaultPage>
);
}
}
interface IRowProps {
id: number;
name: string;
token: string;
enabled: boolean;
fToggleStatus: VoidFunction;
}
const Row: SFC<IRowProps> = observer(({name, id, token, enabled, fToggleStatus}) => (
<TableRow>
<TableCell>{id}</TableCell>
<TableCell>
<Switch
checked={enabled}
onClick={fToggleStatus}
className="switch"
data-enabled={enabled}
/>
</TableCell>
<TableCell>{name}</TableCell>
<TableCell>
<ToggleVisibility value={token} style={{display: 'flex', alignItems: 'center'}} />
</TableCell>
<TableCell numeric padding="none">
<Link to={'/plugins/' + id}>
<Button>
<Settings />
</Button>
</Link>
</TableCell>
</TableRow>
));
export default inject('pluginStore')(Plugins);

View File

@ -1,6 +1,6 @@
import {Page} from 'puppeteer'; import {Page} from 'puppeteer';
import {newTest, GotifyTest} from './setup'; 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 auth from './authentication';
import * as selector from './selector'; import * as selector from './selector';
@ -17,7 +17,8 @@ enum Col {
Name = 2, Name = 2,
Token = 3, Token = 3,
Description = 4, Description = 4,
EditDelete = 5, EditUpdate = 5,
EditDelete = 6,
} }
const hiddenToken = '•••••••••••••••'; const hiddenToken = '•••••••••••••••';
@ -25,6 +26,36 @@ const hiddenToken = '•••••••••••••••';
const $table = selector.table('#app-table'); const $table = selector.table('#app-table');
const $dialog = selector.form('#app-dialog'); const $dialog = selector.form('#app-dialog');
const hasApp = (name: string, description: string, row: number): (() => Promise<void>) => {
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<void>) => {
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<void>) => { export const createApp = (name: string, description: string): (() => Promise<void>) => {
return async () => { return async () => {
await page.click('#create-app'); await page.click('#create-app');
@ -53,14 +84,6 @@ describe('Application', () => {
it('raspberry', createApp('raspberry', '#3')); it('raspberry', createApp('raspberry', '#3'));
}); });
describe('has created apps', () => { describe('has created apps', () => {
const hasApp = (name: string, description: string, row: number): (() => Promise<void>) => {
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 () => { it('has three apps', async () => {
await page.waitForSelector($table.row(3)); await page.waitForSelector($table.row(3));
expect(await count(page, $table.rows())).toBe(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')); await page.click($table.cell(3, Col.Token, '.toggle-visibility'));
const token = await innerText(page, $table.cell(3, Col.Token)); const token = await innerText(page, $table.cell(3, Col.Token));
expect(token.startsWith('A')).toBeTruthy(); 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 () => { it('deletes application', async () => {
await page.click($table.cell(2, Col.EditDelete, '.delete')); await page.click($table.cell(2, Col.EditDelete, '.delete'));

190
ui/src/tests/plugin.test.ts Normal file
View File

@ -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<void>)) => {
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$/
);
});
});
});
});

View File

@ -19,14 +19,22 @@ const windowsPrefix = process.platform === 'win32' ? '.exe' : '';
const appDotGo = path.join(__dirname, '..', '..', '..', 'app.go'); const appDotGo = path.join(__dirname, '..', '..', '..', 'app.go');
const testBuildPath = path.join(__dirname, 'build'); const testBuildPath = path.join(__dirname, 'build');
export const newTest = async (): Promise<GotifyTest> => { export const newPluginDir = async (plugins: string[]): Promise<string> => {
const {dir, generator} = testPluginDir();
for (const pluginName of plugins) {
await buildGoPlugin(generator(), pluginName);
}
return dir;
};
export const newTest = async (pluginsDir = ''): Promise<GotifyTest> => {
const port = await getPort(); const port = await getPort();
const gotifyFile = testFilePath(); const gotifyFile = testFilePath();
await buildGoExecutable(gotifyFile); await buildGoExecutable(gotifyFile);
const gotifyInstance = startGotify(gotifyFile, port); const gotifyInstance = startGotify(gotifyFile, port, pluginsDir);
const gotifyURL = 'http://localhost:' + port; const gotifyURL = 'http://localhost:' + port;
await waitForGotify('http-get://localhost:' + port); await waitForGotify('http-get://localhost:' + port);
@ -52,6 +60,26 @@ export const newTest = async (): Promise<GotifyTest> => {
}; };
}; };
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 testFilePath = (): string => {
const random = Math.random() const random = Math.random()
.toString(36) .toString(36)
@ -73,6 +101,13 @@ const waitForGotify = (url: string): Promise<void> => {
}); });
}; };
const buildGoPlugin = (filename: string, pluginPath: string): Promise<void> => {
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<void> => { const buildGoExecutable = (filename: string): Promise<void> => {
const envGotify = process.env.GOTIFY_EXE; const envGotify = process.env.GOTIFY_EXE;
if (envGotify) { if (envGotify) {
@ -90,11 +125,12 @@ const buildGoExecutable = (filename: string): Promise<void> => {
} }
}; };
const startGotify = (filename: string, port: number): ChildProcess => { const startGotify = (filename: string, port: number, pluginDir: string): ChildProcess => {
const gotify = spawn(filename, [], { const gotify = spawn(filename, [], {
env: { env: {
GOTIFY_SERVER_PORT: '' + port, GOTIFY_SERVER_PORT: '' + port,
GOTIFY_DATABASE_CONNECTION: 'file::memory:?mode=memory&cache=shared', GOTIFY_DATABASE_CONNECTION: 'file::memory:?mode=memory&cache=shared',
GOTIFY_PLUGINSDIR: pluginDir,
}, },
}); });
gotify.stdout.pipe(process.stdout); gotify.stdout.pipe(process.stdout);

View File

@ -4,6 +4,7 @@ interface IApplication {
name: string; name: string;
description: string; description: string;
image: string; image: string;
internal: boolean;
} }
interface IClient { interface IClient {
@ -12,6 +13,18 @@ interface IClient {
name: string; 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 { interface IMessage {
id: number; id: number;
appid: number; appid: number;