From 8c82b7dc7fa60641d4fcacdff9b38763fb648538 Mon Sep 17 00:00:00 2001 From: Christopher Speller Date: Mon, 3 Feb 2020 10:30:14 -0800 Subject: [PATCH] MM-17818 Adding API for adding/replacing links. (#85) * Adding API for adding/replacing links. * link -> autolink * Adding an autolink client * Renaming and content type. * Moving to using headers to transmit plugin id. * check for "no change" (#88) * check for "no change" * fixed an error treating empty UserID * minor Co-authored-by: Lev <1187448+levb@users.noreply.github.com> --- go.mod | 1 + go.sum | 2 + server/api/api.go | 138 +++++++++++++ server/api/api_test.go | 187 ++++++++++++++++++ server/{link.go => autolink/autolink.go} | 51 +++-- .../autolink_test.go} | 94 ++++++--- server/autolinkclient/client.go | 65 ++++++ server/autolinkclient/client_test.go | 54 +++++ server/{ => autolinkplugin}/command.go | 9 +- server/{ => autolinkplugin}/config.go | 29 ++- server/{ => autolinkplugin}/plugin.go | 50 ++++- server/{ => autolinkplugin}/plugin_test.go | 84 ++++++-- server/main.go | 3 +- server/util.go | 26 --- 14 files changed, 691 insertions(+), 102 deletions(-) create mode 100644 server/api/api.go create mode 100644 server/api/api_test.go rename server/{link.go => autolink/autolink.go} (72%) mode change 100755 => 100644 rename server/{link_test.go => autolink/autolink_test.go} (93%) create mode 100644 server/autolinkclient/client.go create mode 100644 server/autolinkclient/client_test.go rename server/{ => autolinkplugin}/command.go (97%) rename server/{ => autolinkplugin}/config.go (73%) mode change 100755 => 100644 rename server/{ => autolinkplugin}/plugin.go (72%) rename server/{ => autolinkplugin}/plugin_test.go (79%) mode change 100755 => 100644 server/main.go delete mode 100644 server/util.go diff --git a/go.mod b/go.mod index c58a9289..232ced76 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/mattermost/mattermost-plugin-autolink go 1.12 require ( + github.com/gorilla/mux v1.7.3 github.com/mattermost/mattermost-server/v5 v5.18.0 github.com/pkg/errors v0.8.1 github.com/stretchr/testify v1.4.0 diff --git a/go.sum b/go.sum index 2753dbad..acc31854 100644 --- a/go.sum +++ b/go.sum @@ -91,6 +91,7 @@ github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekf 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 h1:28o5sBqPkBsMGnC6b4MvE2TzSr5/AT4c/1fLqVGIwlk= github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -120,6 +121,7 @@ github.com/gopherjs/gopherjs v0.0.0-20190430165422-3e4dfb77656c/go.mod h1:wJfORR github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= github.com/gorilla/handlers v1.4.2/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ= github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= +github.com/gorilla/mux v1.7.3 h1:gnP5JzjVOuiZD07fKKToCAOjS0yOpj/qPETTXCCS6hw= github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/schema v1.1.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU= github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM= diff --git a/server/api/api.go b/server/api/api.go new file mode 100644 index 00000000..d3d6195c --- /dev/null +++ b/server/api/api.go @@ -0,0 +1,138 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/gorilla/mux" + "github.com/mattermost/mattermost-plugin-autolink/server/autolink" +) + +type Store interface { + GetLinks() []autolink.Autolink + SaveLinks([]autolink.Autolink) error +} + +type Authorization interface { + IsAuthorizedAdmin(userID string) (bool, error) +} + +type Handler struct { + root *mux.Router + store Store + authorization Authorization +} + +func NewHandler(store Store, authorization Authorization) *Handler { + h := &Handler{ + store: store, + authorization: authorization, + } + + root := mux.NewRouter() + api := root.PathPrefix("/api/v1").Subrouter() + api.Use(h.adminOrPluginRequired) + api.HandleFunc("/link", h.setLink).Methods("POST") + + api.Handle("{anything:.*}", http.NotFoundHandler()) + + h.root = root + + return h +} + +func (h *Handler) handleError(w http.ResponseWriter, err error) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + b, _ := json.Marshal(struct { + Error string `json:"error"` + Details string `json:"details"` + }{ + Error: "An internal error has occurred. Check app server logs for details.", + Details: err.Error(), + }) + _, _ = w.Write(b) +} + +func (h *Handler) handleErrorWithCode(w http.ResponseWriter, code int, errTitle string, err error) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + b, _ := json.Marshal(struct { + Error string `json:"error"` + Details string `json:"details"` + }{ + Error: errTitle, + Details: err.Error(), + }) + _, _ = w.Write(b) +} + +func (h *Handler) adminOrPluginRequired(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var err error + authorized := false + pluginId := r.Header.Get("Mattermost-Plugin-ID") + if pluginId != "" { + // All other plugins are allowed + authorized = true + } + + userID := r.Header.Get("Mattermost-User-ID") + if !authorized && userID != "" { + authorized, err = h.authorization.IsAuthorizedAdmin(userID) + if err != nil { + http.Error(w, "Not authorized", http.StatusUnauthorized) + return + } + } + + if !authorized { + http.Error(w, "Not authorized", http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) +} + +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + h.root.ServeHTTP(w, r) +} + +func (h *Handler) setLink(w http.ResponseWriter, r *http.Request) { + var newLink autolink.Autolink + if err := json.NewDecoder(r.Body).Decode(&newLink); err != nil { + h.handleError(w, fmt.Errorf("Unable to decode body: %w", err)) + return + } + + links := h.store.GetLinks() + found := false + changed := false + for i := range links { + if links[i].Name == newLink.Name || links[i].Pattern == newLink.Pattern { + if !links[i].Equals(newLink) { + links[i] = newLink + changed = true + } + found = true + break + } + } + if !found { + links = append(h.store.GetLinks(), newLink) + changed = true + } + status := http.StatusNotModified + if changed { + if err := h.store.SaveLinks(links); err != nil { + h.handleError(w, fmt.Errorf("Unable to save link: %w", err)) + return + } + status = http.StatusOK + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = w.Write([]byte(`{"status": "OK"}`)) +} diff --git a/server/api/api_test.go b/server/api/api_test.go new file mode 100644 index 00000000..1c299f35 --- /dev/null +++ b/server/api/api_test.go @@ -0,0 +1,187 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/mattermost/mattermost-plugin-autolink/server/autolink" + "github.com/stretchr/testify/require" +) + +type authorizeAll struct{} + +func (authorizeAll) IsAuthorizedAdmin(string) (bool, error) { + return true, nil +} + +type linkStore struct { + prev []autolink.Autolink + saveCalled *bool + saved *[]autolink.Autolink +} + +func (s *linkStore) GetLinks() []autolink.Autolink { + return s.prev +} + +func (s *linkStore) SaveLinks(links []autolink.Autolink) error { + *s.saved = links + *s.saveCalled = true + return nil +} + +func TestSetLink(t *testing.T) { + for _, tc := range []struct { + name string + method string + prevLinks []autolink.Autolink + link autolink.Autolink + expectSaveCalled bool + expectSaved []autolink.Autolink + expectStatus int + }{ + { + name: "happy simple", + link: autolink.Autolink{ + Name: "test", + }, + expectStatus: http.StatusOK, + expectSaveCalled: true, + expectSaved: []autolink.Autolink{ + autolink.Autolink{ + Name: "test", + }, + }, + }, + { + name: "add new link", + link: autolink.Autolink{ + Name: "test1", + Pattern: ".*1", + Template: "test1", + }, + prevLinks: []autolink.Autolink{ + autolink.Autolink{ + Name: "test2", + Pattern: ".*2", + Template: "test2", + }, + }, + expectStatus: http.StatusOK, + expectSaveCalled: true, + expectSaved: []autolink.Autolink{ + autolink.Autolink{ + Name: "test2", + Pattern: ".*2", + Template: "test2", + }, + autolink.Autolink{ + Name: "test1", + Pattern: ".*1", + Template: "test1", + }, + }, + }, { + name: "replace link", + link: autolink.Autolink{ + Name: "test2", + Pattern: ".*2", + Template: "new template", + }, + prevLinks: []autolink.Autolink{ + autolink.Autolink{ + Name: "test1", + Pattern: ".*1", + Template: "test1", + }, + autolink.Autolink{ + Name: "test2", + Pattern: ".*2", + Template: "test2", + }, + autolink.Autolink{ + Name: "test3", + Pattern: ".*3", + Template: "test3", + }, + }, + expectStatus: http.StatusOK, + expectSaveCalled: true, + expectSaved: []autolink.Autolink{ + autolink.Autolink{ + Name: "test1", + Pattern: ".*1", + Template: "test1", + }, + autolink.Autolink{ + Name: "test2", + Pattern: ".*2", + Template: "new template", + }, + autolink.Autolink{ + Name: "test3", + Pattern: ".*3", + Template: "test3", + }, + }, + }, + { + name: "no change", + link: autolink.Autolink{ + Name: "test2", + Pattern: ".*2", + Template: "test2", + }, + prevLinks: []autolink.Autolink{ + autolink.Autolink{ + Name: "test1", + Pattern: ".*1", + Template: "test1", + }, + autolink.Autolink{ + Name: "test2", + Pattern: ".*2", + Template: "test2", + }, + }, + expectStatus: http.StatusNotModified, + expectSaveCalled: false, + }, + } { + t.Run(tc.name, func(t *testing.T) { + var saved []autolink.Autolink + var saveCalled bool + + h := NewHandler( + &linkStore{ + prev: tc.prevLinks, + saveCalled: &saveCalled, + saved: &saved, + }, + authorizeAll{}, + ) + + body, err := json.Marshal(tc.link) + require.NoError(t, err) + + w := httptest.NewRecorder() + method := "POST" + if tc.method != "" { + method = tc.method + } + r, err := http.NewRequest(method, "/api/v1/link", bytes.NewReader(body)) + require.NoError(t, err) + + r.Header.Set("Mattermost-Plugin-ID", "testfrom") + r.Header.Set("Mattermost-User-ID", "testuser") + + h.ServeHTTP(w, r) + require.Equal(t, tc.expectStatus, w.Code) + require.Equal(t, tc.expectSaveCalled, saveCalled) + require.Equal(t, tc.expectSaved, saved) + }) + } +} diff --git a/server/link.go b/server/autolink/autolink.go old mode 100755 new mode 100644 similarity index 72% rename from server/link.go rename to server/autolink/autolink.go index 3fe73b97..1f5d1c84 --- a/server/link.go +++ b/server/autolink/autolink.go @@ -1,28 +1,47 @@ -package main +package autolink import ( "fmt" "regexp" ) -// Link represents a pattern to autolink. -type Link struct { - Name string - Disabled bool - Pattern string - Template string - Scope []string - WordMatch bool - DisableNonWordPrefix bool - DisableNonWordSuffix bool +// Autolink represents a pattern to autolink. +type Autolink struct { + Name string `json:"name"` + Disabled bool `json:"disabled"` + Pattern string `json:"pattern"` + Template string `json:"template"` + Scope []string `json:"scope"` + WordMatch bool `json:"wordmatch"` + DisableNonWordPrefix bool `json:"disable_non_word_prefix"` + DisableNonWordSuffix bool `json:"disable_non_word_suffix"` template string re *regexp.Regexp canReplaceAll bool } +func (l Autolink) Equals(x Autolink) bool { + if l.Disabled != x.Disabled || + l.DisableNonWordPrefix != x.DisableNonWordPrefix || + l.DisableNonWordSuffix != x.DisableNonWordSuffix || + l.Name != x.Name || + l.Pattern != x.Pattern || + len(l.Scope) != len(x.Scope) || + l.Template != x.Template || + l.WordMatch != x.WordMatch { + return false + } + for i, scope := range l.Scope { + if scope != x.Scope[i] { + return false + } + } + return true +} + // DisplayName returns a display name for the link. -func (l Link) DisplayName() string { +func (l Autolink) DisplayName() string { if l.Name != "" { return l.Name } @@ -30,7 +49,7 @@ func (l Link) DisplayName() string { } // Compile compiles the link's regular expression -func (l *Link) Compile() error { +func (l *Autolink) Compile() error { if l.Disabled || len(l.Pattern) == 0 || len(l.Template) == 0 { return nil } @@ -71,7 +90,7 @@ func (l *Link) Compile() error { } // Replace will subsitute the regex's with the supplied links -func (l Link) Replace(message string) string { +func (l Autolink) Replace(message string) string { if l.re == nil { return message } @@ -98,7 +117,7 @@ func (l Link) Replace(message string) string { } // ToMarkdown prints a Link as a markdown list element -func (l Link) ToMarkdown(i int) string { +func (l Autolink) ToMarkdown(i int) string { text := "- " if i > 0 { text += fmt.Sprintf("%v: ", i) @@ -136,7 +155,7 @@ func (l Link) ToMarkdown(i int) string { // ToConfig returns a JSON-encodable Link represented solely with map[string] // interface and []string types, compatible with gob/RPC, to be used in // SavePluginConfig -func (l Link) ToConfig() map[string]interface{} { +func (l Autolink) ToConfig() map[string]interface{} { return map[string]interface{}{ "Name": l.Name, "Pattern": l.Pattern, diff --git a/server/link_test.go b/server/autolink/autolink_test.go similarity index 93% rename from server/link_test.go rename to server/autolink/autolink_test.go index 0f2b5ee9..8459c402 100644 --- a/server/link_test.go +++ b/server/autolink/autolink_test.go @@ -1,10 +1,12 @@ -package main +package autolink_test import ( "fmt" "regexp" "testing" + "github.com/mattermost/mattermost-plugin-autolink/server/autolink" + "github.com/mattermost/mattermost-plugin-autolink/server/autolinkplugin" "github.com/mattermost/mattermost-server/v5/model" "github.com/mattermost/mattermost-server/v5/plugin/plugintest" "github.com/stretchr/testify/assert" @@ -12,8 +14,8 @@ import ( "github.com/stretchr/testify/require" ) -func setupTestPlugin(t *testing.T, link Link) *Plugin { - p := &Plugin{} +func setupTestPlugin(t *testing.T, l autolink.Autolink) *autolinkplugin.Plugin { + p := &autolinkplugin.Plugin{} api := &plugintest.API{} api.On("GetChannel", mock.AnythingOfType("string")).Run(func(args mock.Arguments) { @@ -25,9 +27,11 @@ func setupTestPlugin(t *testing.T, link Link) *Plugin { }, (*model.AppError)(nil)) p.SetAPI(api) - err := link.Compile() + err := l.Compile() require.Nil(t, err) - p.conf.Links = []Link{link} + p.UpdateConfig(func(conf *autolinkplugin.Config) { + conf.Links = []autolink.Autolink{l} + }) return p } @@ -152,13 +156,13 @@ func TestSSNRegex(t *testing.T) { func TestCreditCard(t *testing.T) { var tests = []struct { Name string - Link Link + Link autolink.Autolink inputMessage string expectedMessage string }{ { "VISA happy", - Link{ + autolink.Autolink{ Pattern: reVISA, Template: replaceVISA, }, @@ -166,7 +170,7 @@ func TestCreditCard(t *testing.T) { "A credit card VISA XXXX-XXXX-XXXX-1234 mentioned", }, { "VISA", - Link{ + autolink.Autolink{ Pattern: reVISA, Template: replaceVISA, DisableNonWordPrefix: true, @@ -176,7 +180,7 @@ func TestCreditCard(t *testing.T) { "A credit cardVISA XXXX-XXXX-XXXX-3333mentioned", }, { "Multiple VISA replacements", - Link{ + autolink.Autolink{ Pattern: reVISA, Template: replaceVISA, }, @@ -197,13 +201,13 @@ func TestCreditCard(t *testing.T) { func TestLink(t *testing.T) { for _, tc := range []struct { Name string - Link Link + Link autolink.Autolink Message string ExpectedMessage string }{ { "Simple pattern", - Link{ + autolink.Autolink{ Pattern: "(Mattermost)", Template: "[Mattermost](https://mattermost.com)", }, @@ -211,7 +215,7 @@ func TestLink(t *testing.T) { "Welcome to [Mattermost](https://mattermost.com)!", }, { "Pattern with variable name accessed using $variable", - Link{ + autolink.Autolink{ Pattern: "(?PMattermost)", Template: "[$key](https://mattermost.com)", }, @@ -219,7 +223,7 @@ func TestLink(t *testing.T) { "Welcome to [Mattermost](https://mattermost.com)!", }, { "Multiple replacments", - Link{ + autolink.Autolink{ Pattern: "(?PMattermost)", Template: "[$key](https://mattermost.com)", }, @@ -227,7 +231,7 @@ func TestLink(t *testing.T) { "Welcome to [Mattermost](https://mattermost.com) and have fun with [Mattermost](https://mattermost.com)!", }, { "Pattern with variable name accessed using ${variable}", - Link{ + autolink.Autolink{ Pattern: "(?PMattermost)", Template: "[${key}](https://mattermost.com)", }, @@ -235,7 +239,7 @@ func TestLink(t *testing.T) { "Welcome to [Mattermost](https://mattermost.com)!", }, { "Jira example", - Link{ + autolink.Autolink{ Pattern: "(MM)(-)(?P\\d+)", Template: "[MM-$jira_id](https://mattermost.atlassian.net/browse/MM-$jira_id)", }, @@ -243,7 +247,7 @@ func TestLink(t *testing.T) { "Welcome [MM-12345](https://mattermost.atlassian.net/browse/MM-12345) should link!", }, { "Jira example 2 (within a ())", - Link{ + autolink.Autolink{ Pattern: "(MM)(-)(?P\\d+)", Template: "[MM-$jira_id](https://mattermost.atlassian.net/browse/MM-$jira_id)", }, @@ -251,7 +255,7 @@ func TestLink(t *testing.T) { "Link in brackets should link (see [MM-12345](https://mattermost.atlassian.net/browse/MM-12345))", }, { "Jira example 3 (before ,)", - Link{ + autolink.Autolink{ Pattern: "(MM)(-)(?P\\d+)", Template: "[MM-$jira_id](https://mattermost.atlassian.net/browse/MM-$jira_id)", }, @@ -259,7 +263,7 @@ func TestLink(t *testing.T) { "Link a ticket [MM-12345](https://mattermost.atlassian.net/browse/MM-12345), before a comma", }, { "Jira example 3 (at begin of the message)", - Link{ + autolink.Autolink{ Pattern: "(MM)(-)(?P\\d+)", Template: "[MM-$jira_id](https://mattermost.atlassian.net/browse/MM-$jira_id)", }, @@ -267,7 +271,7 @@ func TestLink(t *testing.T) { "[MM-12345](https://mattermost.atlassian.net/browse/MM-12345) should link!", }, { "Pattern word prefix and suffix disabled", - Link{ + autolink.Autolink{ Pattern: "(?P^|\\s)(MM)(-)(?P\\d+)", Template: "${previous}[MM-$jira_id](https://mattermost.atlassian.net/browse/MM-$jira_id)", DisableNonWordPrefix: true, @@ -277,7 +281,7 @@ func TestLink(t *testing.T) { "Welcome [MM-12345](https://mattermost.atlassian.net/browse/MM-12345) should link!", }, { "Pattern word prefix and suffix disabled (at begin of the message)", - Link{ + autolink.Autolink{ Pattern: "(?P^|\\s)(MM)(-)(?P\\d+)", Template: "${previous}[MM-$jira_id](https://mattermost.atlassian.net/browse/MM-$jira_id)", DisableNonWordPrefix: true, @@ -287,7 +291,7 @@ func TestLink(t *testing.T) { "[MM-12345](https://mattermost.atlassian.net/browse/MM-12345) should link!", }, { "Pattern word prefix and suffix enable (in the middle of other text)", - Link{ + autolink.Autolink{ Pattern: "(MM)(-)(?P\\d+)", Template: "[MM-$jira_id](https://mattermost.atlassian.net/browse/MM-$jira_id)", }, @@ -295,7 +299,7 @@ func TestLink(t *testing.T) { "WelcomeMM-12345should not link!", }, { "Pattern word prefix and suffix disabled (in the middle of other text)", - Link{ + autolink.Autolink{ Pattern: "(MM)(-)(?P\\d+)", Template: "[MM-$jira_id](https://mattermost.atlassian.net/browse/MM-$jira_id)", DisableNonWordPrefix: true, @@ -305,7 +309,7 @@ func TestLink(t *testing.T) { "Welcome[MM-12345](https://mattermost.atlassian.net/browse/MM-12345)should link!", }, { "Not relinking", - Link{ + autolink.Autolink{ Pattern: "(MM)(-)(?P\\d+)", Template: "[MM-$jira_id](https://mattermost.atlassian.net/browse/MM-$jira_id)", }, @@ -313,7 +317,7 @@ func TestLink(t *testing.T) { "Welcome [MM-12345](https://mattermost.atlassian.net/browse/MM-12345) should not re-link!", }, { "Url replacement", - Link{ + autolink.Autolink{ Pattern: "(https://mattermost.atlassian.net/browse/)(MM)(-)(?P\\d+)", Template: "[MM-$jira_id](https://mattermost.atlassian.net/browse/MM-$jira_id)", }, @@ -321,7 +325,7 @@ func TestLink(t *testing.T) { "Welcome [MM-12345](https://mattermost.atlassian.net/browse/MM-12345) should link!", }, { "Url replacement multiple times", - Link{ + autolink.Autolink{ Pattern: "(https://mattermost.atlassian.net/browse/)(MM)(-)(?P\\d+)", Template: "[MM-$jira_id](https://mattermost.atlassian.net/browse/MM-$jira_id)", }, @@ -329,7 +333,7 @@ func TestLink(t *testing.T) { "Welcome [MM-12345](https://mattermost.atlassian.net/browse/MM-12345). should link [MM-12346](https://mattermost.atlassian.net/browse/MM-12346) !", }, { "Url replacement multiple times and at beginning", - Link{ + autolink.Autolink{ Pattern: "(https:\\/\\/mattermost.atlassian.net\\/browse\\/)(MM)(-)(?P\\d+)", Template: "[MM-$jira_id](https://mattermost.atlassian.net/browse/MM-$jira_id)", }, @@ -337,7 +341,7 @@ func TestLink(t *testing.T) { "[MM-12345](https://mattermost.atlassian.net/browse/MM-12345) [MM-12345](https://mattermost.atlassian.net/browse/MM-12345)", }, { "Url replacement at end", - Link{ + autolink.Autolink{ Pattern: "(https://mattermost.atlassian.net/browse/)(MM)(-)(?P\\d+)", Template: "[MM-$jira_id](https://mattermost.atlassian.net/browse/MM-$jira_id)", }, @@ -364,7 +368,7 @@ func TestLegacyWordBoundaries(t *testing.T) { const ID = "12345" const markdown = "[KEY-12345](someurl/KEY-12345)" - var defaultLink = Link{ + var defaultLink = autolink.Autolink{ Pattern: pattern, Template: template, } @@ -382,7 +386,7 @@ func TestLegacyWordBoundaries(t *testing.T) { for _, tc := range []struct { Name string Sep string - Link Link + Link autolink.Autolink Prefix string Suffix string ExpectFail bool @@ -465,7 +469,7 @@ func TestWordMatch(t *testing.T) { const ID = "12345" const markdown = "[KEY-12345](someurl/KEY-12345)" - var defaultLink = Link{ + var defaultLink = autolink.Autolink{ Pattern: pattern, Template: template, WordMatch: true, @@ -484,7 +488,7 @@ func TestWordMatch(t *testing.T) { for _, tc := range []struct { Name string Sep string - Link Link + Link autolink.Autolink Prefix string Suffix string ExpectFail bool @@ -553,3 +557,31 @@ func TestWordMatch(t *testing.T) { }) } } +func TestEquals(t *testing.T) { + for _, tc := range []struct { + l1, l2 autolink.Autolink + expectEqual bool + }{ + { + l1: autolink.Autolink{ + Name: "test", + }, + expectEqual: false, + }, + { + l1: autolink.Autolink{ + Name: "test", + }, + l2: autolink.Autolink{ + Name: "test", + }, + expectEqual: true, + }, + } { + + t.Run(tc.l1.Name+"-"+tc.l2.Name, func(t *testing.T) { + eq := tc.l1.Equals(tc.l2) + assert.Equal(t, tc.expectEqual, eq) + }) + } +} diff --git a/server/autolinkclient/client.go b/server/autolinkclient/client.go new file mode 100644 index 00000000..f6bc85d3 --- /dev/null +++ b/server/autolinkclient/client.go @@ -0,0 +1,65 @@ +package autolinkclient + +import ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + + "github.com/mattermost/mattermost-plugin-autolink/server/autolink" +) + +const autolinkPluginId = "mattermost-autolink" + +type PluginAPI interface { + PluginHTTP(*http.Request) *http.Response +} + +type Client struct { + http.Client +} + +type pluginAPIRoundTripper struct { + api PluginAPI +} + +func (p *pluginAPIRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + resp := p.api.PluginHTTP(req) + if resp == nil { + return nil, fmt.Errorf("Failed to make interplugin request") + } + return resp, nil +} + +func NewClientPlugin(api PluginAPI) *Client { + client := &Client{} + client.Transport = &pluginAPIRoundTripper{api} + return client +} + +func (c *Client) Add(links ...autolink.Autolink) error { + for _, link := range links { + linkBytes, err := json.Marshal(&link) + if err != nil { + return err + } + + req, err := http.NewRequest("POST", "/"+autolinkPluginId+"/api/v1/link", bytes.NewReader(linkBytes)) + if err != nil { + return err + } + + resp, err := c.Do(req) + if err != nil { + return err + } + resp.Body.Close() + if resp == nil || resp.StatusCode != http.StatusOK { + respBody, _ := ioutil.ReadAll(resp.Body) + return fmt.Errorf("Unable to install autolink. Error: %v, %v", resp.StatusCode, string(respBody)) + } + } + + return nil +} diff --git a/server/autolinkclient/client_test.go b/server/autolinkclient/client_test.go new file mode 100644 index 00000000..bcd5862a --- /dev/null +++ b/server/autolinkclient/client_test.go @@ -0,0 +1,54 @@ +package autolinkclient + +import ( + "net/http" + "testing" + + "github.com/mattermost/mattermost-plugin-autolink/server/autolink" + "github.com/mattermost/mattermost-server/v5/plugin/plugintest" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestRoundTripper(t *testing.T) { + mockPluginAPI := &plugintest.API{} + + mockPluginAPI.On("PluginHTTP", mock.AnythingOfType("*http.Request")).Return(&http.Response{StatusCode: http.StatusOK}) + + roundTripper := pluginAPIRoundTripper{api: mockPluginAPI} + req, err := http.NewRequest("POST", "url", nil) + require.Nil(t, err) + resp, err := roundTripper.RoundTrip(req) + require.Nil(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + + mockPluginAPI2 := &plugintest.API{} + mockPluginAPI2.On("PluginHTTP", mock.AnythingOfType("*http.Request")).Return(nil) + + roundTripper2 := pluginAPIRoundTripper{api: mockPluginAPI2} + req2, err := http.NewRequest("POST", "url", nil) + require.Nil(t, err) + resp2, err := roundTripper2.RoundTrip(req2) + require.Nil(t, resp2) + require.Error(t, err) +} + +func TestAddAutolinks(t *testing.T) { + mockPluginAPI := &plugintest.API{} + + mockPluginAPI.On("PluginHTTP", mock.AnythingOfType("*http.Request")).Return(&http.Response{StatusCode: http.StatusOK, Body: http.NoBody}) + + client := NewClientPlugin(mockPluginAPI) + err := client.Add(autolink.Autolink{}) + require.Nil(t, err) +} + +func TestAddAutolinksErr(t *testing.T) { + mockPluginAPI := &plugintest.API{} + + mockPluginAPI.On("PluginHTTP", mock.AnythingOfType("*http.Request")).Return(nil) + + client := NewClientPlugin(mockPluginAPI) + err := client.Add(autolink.Autolink{}) + require.Error(t, err) +} diff --git a/server/command.go b/server/autolinkplugin/command.go similarity index 97% rename from server/command.go rename to server/autolinkplugin/command.go index e3702b11..386df5cc 100644 --- a/server/command.go +++ b/server/autolinkplugin/command.go @@ -1,10 +1,11 @@ -package main +package autolinkplugin import ( "fmt" "strconv" "strings" + "github.com/mattermost/mattermost-plugin-autolink/server/autolink" "github.com/mattermost/mattermost-server/v5/model" "github.com/mattermost/mattermost-server/v5/plugin" "github.com/pkg/errors" @@ -261,7 +262,7 @@ func executeAdd(p *Plugin, c *plugin.Context, header *model.CommandArgs, args .. name = args[0] } - err := saveConfigLinks(p, append(p.getConfig().Links, Link{ + err := saveConfigLinks(p, append(p.getConfig().Links, autolink.Autolink{ Name: name, })) if err != nil { @@ -286,7 +287,7 @@ func responsef(format string, args ...interface{}) *model.CommandResponse { } } -func parseLinkRef(p *Plugin, requireUnique bool, args ...string) ([]Link, []int, error) { +func parseLinkRef(p *Plugin, requireUnique bool, args ...string) ([]autolink.Autolink, []int, error) { links := p.getConfig().Sorted().Links if len(args) == 0 { if requireUnique { @@ -334,7 +335,7 @@ func parseBoolArg(arg string) (bool, error) { return false, errors.Errorf("Not a bool, %q", arg) } -func saveConfigLinks(p *Plugin, links []Link) error { +func saveConfigLinks(p *Plugin, links []autolink.Autolink) error { conf := p.getConfig() conf.Links = links appErr := p.API.SavePluginConfig(conf.ToConfig()) diff --git a/server/config.go b/server/autolinkplugin/config.go old mode 100755 new mode 100644 similarity index 73% rename from server/config.go rename to server/autolinkplugin/config.go index d4c12f95..9140e074 --- a/server/config.go +++ b/server/autolinkplugin/config.go @@ -1,10 +1,11 @@ -package main +package autolinkplugin import ( "fmt" "sort" "strings" + "github.com/mattermost/mattermost-plugin-autolink/server/autolink" "github.com/mattermost/mattermost-server/v5/mlog" "github.com/mattermost/mattermost-server/v5/model" ) @@ -13,7 +14,7 @@ import ( type Config struct { EnableAdminCommand bool EnableOnUpdate bool - Links []Link + Links []autolink.Autolink } // OnConfigurationChange is invoked when configuration changes may have been made. @@ -31,7 +32,7 @@ func (p *Plugin) OnConfigurationChange() error { } } - p.updateConfig(func(conf *Config) { + p.UpdateConfig(func(conf *Config) { *conf = c }) @@ -58,7 +59,25 @@ func (p *Plugin) getConfig() Config { return p.conf } -func (p *Plugin) updateConfig(f func(conf *Config)) Config { +func (p *Plugin) GetLinks() []autolink.Autolink { + p.confLock.RLock() + defer p.confLock.RUnlock() + return p.conf.Links +} + +func (p *Plugin) SaveLinks(links []autolink.Autolink) error { + p.UpdateConfig(func(conf *Config) { + conf.Links = links + }) + appErr := p.API.SavePluginConfig(p.getConfig().ToConfig()) + if appErr != nil { + return fmt.Errorf("Unable to save links: %w", appErr) + } + + return nil +} + +func (p *Plugin) UpdateConfig(f func(conf *Config)) Config { p.confLock.Lock() defer p.confLock.Unlock() @@ -83,7 +102,7 @@ func (conf Config) ToConfig() map[string]interface{} { // Sorted returns a clone of the Config, with links sorted alphabetically func (conf Config) Sorted() Config { sorted := conf - sorted.Links = append([]Link{}, conf.Links...) + sorted.Links = append([]autolink.Autolink{}, conf.Links...) sort.Slice(conf.Links, func(i, j int) bool { return strings.Compare(conf.Links[i].DisplayName(), conf.Links[j].DisplayName()) < 0 }) diff --git a/server/plugin.go b/server/autolinkplugin/plugin.go similarity index 72% rename from server/plugin.go rename to server/autolinkplugin/plugin.go index a66b66b9..63d93463 100644 --- a/server/plugin.go +++ b/server/autolinkplugin/plugin.go @@ -1,11 +1,14 @@ -package main +package autolinkplugin import ( "fmt" + "net/http" + "strings" "sync" - "github.com/mattermost/mattermost-server/v5/mlog" + "github.com/mattermost/mattermost-plugin-autolink/server/api" + "github.com/mattermost/mattermost-server/v5/mlog" "github.com/mattermost/mattermost-server/v5/model" "github.com/mattermost/mattermost-server/v5/plugin" "github.com/mattermost/mattermost-server/v5/utils/markdown" @@ -15,11 +18,49 @@ import ( type Plugin struct { plugin.MattermostPlugin + handler *api.Handler + // configuration and a muttex to control concurrent access conf Config confLock sync.RWMutex } +func (p *Plugin) OnActivate() error { + p.handler = api.NewHandler(p, p) + + return nil +} + +func (p *Plugin) IsAuthorizedAdmin(mattermostID string) (bool, error) { + user, err := p.API.GetUser(mattermostID) + if err != nil { + return false, err + } + if strings.Contains(user.Roles, "system_admin") { + return true, nil + } + return false, nil +} + +func contains(team string, channel string, list []string) bool { + for _, channelTeam := range list { + channelTeamSplit := strings.Split(channelTeam, "/") + if len(channelTeamSplit) == 2 { + if strings.EqualFold(channelTeamSplit[0], team) && strings.EqualFold(channelTeamSplit[1], channel) { + return true + } + } else if len(channelTeamSplit) == 1 { + if strings.EqualFold(channelTeamSplit[0], team) { + return true + } + } else { + mlog.Error("error splitting channel & team combination.") + } + + } + return false +} + func (p *Plugin) ProcessPost(c *plugin.Context, post *model.Post) (*model.Post, string) { conf := p.getConfig() postText := post.Message @@ -97,6 +138,11 @@ func (p *Plugin) ProcessPost(c *plugin.Context, post *model.Post) (*model.Post, return post, "" } +func (p *Plugin) ServeHTTP(c *plugin.Context, w http.ResponseWriter, r *http.Request) { + r.Header.Add("Mattermost-Plugin-ID", c.SourcePluginId) + p.handler.ServeHTTP(w, r) +} + // MessageWillBePosted is invoked when a message is posted by a user before it is committed // to the database. func (p *Plugin) MessageWillBePosted(c *plugin.Context, post *model.Post) (*model.Post, string) { diff --git a/server/plugin_test.go b/server/autolinkplugin/plugin_test.go similarity index 79% rename from server/plugin_test.go rename to server/autolinkplugin/plugin_test.go index 236a7108..4c925d9f 100644 --- a/server/plugin_test.go +++ b/server/autolinkplugin/plugin_test.go @@ -1,19 +1,25 @@ -package main +package autolinkplugin import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" "testing" + "github.com/mattermost/mattermost-plugin-autolink/server/autolink" "github.com/mattermost/mattermost-server/v5/model" "github.com/mattermost/mattermost-server/v5/plugin" "github.com/mattermost/mattermost-server/v5/plugin/plugintest" "github.com/mattermost/mattermost-server/v5/plugin/plugintest/mock" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestPlugin(t *testing.T) { conf := Config{ - Links: []Link{ - Link{ + Links: []autolink.Autolink{ + autolink.Autolink{ Pattern: "(Mattermost)", Template: "[Mattermost](https://mattermost.com)", }, @@ -30,7 +36,7 @@ func TestPlugin(t *testing.T) { api := &plugintest.API{} - api.On("LoadPluginConfiguration", mock.AnythingOfType("*main.Config")).Return(func(dest interface{}) error { + api.On("LoadPluginConfiguration", mock.AnythingOfType("*autolinkplugin.Config")).Return(func(dest interface{}) error { *dest.(*Config) = conf return nil }) @@ -50,27 +56,27 @@ func TestPlugin(t *testing.T) { } func TestSpecialCases(t *testing.T) { - links := make([]Link, 0) - links = append(links, Link{ + links := make([]autolink.Autolink, 0) + links = append(links, autolink.Autolink{ Pattern: "(Mattermost)", Template: "[Mattermost](https://mattermost.com)", - }, Link{ + }, autolink.Autolink{ Pattern: "(Example)", Template: "[Example](https://example.com)", - }, Link{ + }, autolink.Autolink{ Pattern: "MM-(?P\\d+)", Template: "[MM-$jira_id](https://mattermost.atlassian.net/browse/MM-$jira_id)", - }, Link{ + }, autolink.Autolink{ Pattern: "https://mattermost.atlassian.net/browse/MM-(?P\\d+)", Template: "[MM-$jira_id](https://mattermost.atlassian.net/browse/MM-$jira_id)", - }, Link{ + }, autolink.Autolink{ Pattern: "(foo!bar)", Template: "fb", - }, Link{ + }, autolink.Autolink{ Pattern: "(example)", Template: "test", Scope: []string{"team/off-topic"}, - }, Link{ + }, autolink.Autolink{ Pattern: "(example)", Template: "test", Scope: []string{"other-team/town-square"}, @@ -91,7 +97,7 @@ func TestSpecialCases(t *testing.T) { api := &plugintest.API{} - api.On("LoadPluginConfiguration", mock.AnythingOfType("*main.Config")).Return(func(dest interface{}) error { + api.On("LoadPluginConfiguration", mock.AnythingOfType("*autolinkplugin.Config")).Return(func(dest interface{}) error { *dest.(*Config) = validConfig return nil }) @@ -275,12 +281,12 @@ func TestSpecialCases(t *testing.T) { func TestHashtags(t *testing.T) { conf := Config{ - Links: []Link{ - Link{ + Links: []autolink.Autolink{ + autolink.Autolink{ Pattern: "foo", Template: "#bar", }, - Link{ + autolink.Autolink{ Pattern: "hash tags", Template: "#hash #tags", }, @@ -297,7 +303,7 @@ func TestHashtags(t *testing.T) { api := &plugintest.API{} - api.On("LoadPluginConfiguration", mock.AnythingOfType("*main.Config")).Return(func(dest interface{}) error { + api.On("LoadPluginConfiguration", mock.AnythingOfType("*autolinkplugin.Config")).Return(func(dest interface{}) error { *dest.(*Config) = conf return nil }) @@ -320,3 +326,47 @@ func TestHashtags(t *testing.T) { assert.Equal(t, "#hash #tags", rpost.Hashtags) } + +func TestAPI(t *testing.T) { + conf := Config{ + Links: []autolink.Autolink{ + autolink.Autolink{ + Name: "existing", + Pattern: "thing", + Template: "otherthing", + }, + }, + } + + testChannel := model.Channel{ + Name: "TestChanel", + } + + testTeam := model.Team{ + Name: "TestTeam", + } + + api := &plugintest.API{} + api.On("LoadPluginConfiguration", mock.AnythingOfType("*autolinkplugin.Config")).Return(func(dest interface{}) error { + *dest.(*Config) = conf + return nil + }) + api.On("UnregisterCommand", mock.AnythingOfType("string"), mock.AnythingOfType("string")).Return((*model.AppError)(nil)) + api.On("GetChannel", mock.AnythingOfType("string")).Return(&testChannel, nil) + api.On("GetTeam", mock.AnythingOfType("string")).Return(&testTeam, nil) + api.On("SavePluginConfig", mock.AnythingOfType("map[string]interface {}")).Return(nil) + + p := Plugin{} + p.SetAPI(api) + p.OnConfigurationChange() + p.OnActivate() + + jbyte, _ := json.Marshal(&autolink.Autolink{Name: "new", Pattern: "newpat", Template: "newtemp"}) + recorder := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/api/v1/link", bytes.NewReader(jbyte)) + p.ServeHTTP(&plugin.Context{SourcePluginId: "somthing"}, recorder, req) + resp := recorder.Result() + assert.Equal(t, http.StatusOK, resp.StatusCode) + require.Len(t, p.conf.Links, 2) + assert.Equal(t, "new", p.conf.Links[1].Name) +} diff --git a/server/main.go b/server/main.go old mode 100755 new mode 100644 index d980982c..e6b75df1 --- a/server/main.go +++ b/server/main.go @@ -1,9 +1,10 @@ package main import ( + "github.com/mattermost/mattermost-plugin-autolink/server/autolinkplugin" "github.com/mattermost/mattermost-server/v5/plugin" ) func main() { - plugin.ClientMain(&Plugin{}) + plugin.ClientMain(&autolinkplugin.Plugin{}) } diff --git a/server/util.go b/server/util.go deleted file mode 100644 index 0fd361ba..00000000 --- a/server/util.go +++ /dev/null @@ -1,26 +0,0 @@ -package main - -import ( - "strings" - - "github.com/mattermost/mattermost-server/v5/mlog" -) - -func contains(team string, channel string, list []string) bool { - for _, channelTeam := range list { - channelTeamSplit := strings.Split(channelTeam, "/") - if len(channelTeamSplit) == 2 { - if strings.EqualFold(channelTeamSplit[0], team) && strings.EqualFold(channelTeamSplit[1], channel) { - return true - } - } else if len(channelTeamSplit) == 1 { - if strings.EqualFold(channelTeamSplit[0], team) { - return true - } - } else { - mlog.Error("error splitting channel & team combination.") - } - - } - return false -}