diff --git a/assets/templates/api/v1/oauth2/complete.html b/assets/templates/api/v1/oauth2/complete.html new file mode 100644 index 00000000..6622a665 --- /dev/null +++ b/assets/templates/api/v1/oauth2/complete.html @@ -0,0 +1,74 @@ + + + + + + + +
+

+ + + + Mattermost user is now connected to Confluence +

+
+
Mattermost account: {{ .MattermostDisplayName }}
+
Confluence account: {{ .ConfluenceDisplayName }}
+
+
+

You may now safely close this tab.

+ +
+
+ + diff --git a/assets/templates/other/index.css b/assets/templates/other/index.css new file mode 100644 index 00000000..e13f9c27 --- /dev/null +++ b/assets/templates/other/index.css @@ -0,0 +1,284 @@ +html, +body, +p, +div, +h1, +h2, +h3, +h4, +h5, +h6, +ul, +ol, +dl, +img, +pre, +form, +fieldset { + margin: 0; + padding: 0; +} +img, +fieldset { + border: 0; +} +body, +html { + height: 100%; + width: 100%; +} +body { + background-color: #FFF; + color: #172B4D; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 1.42857142857143; + letter-spacing: -0.005em; + -ms-overflow-style: -ms-autohiding-scrollbar; + text-decoration-skip: ink; +} +p, +ul, +ol, +dl, +h1, +h2, +h3, +h4, +h5, +h6, +blockquote, +pre, +form, +table { + margin: 12px 0 0 0; +} +a { + color: #0052CC; + text-decoration: none; +} +a:hover { + color: #0065FF; + text-decoration: underline; +} +a:active { + color: #0747A6; +} +a:focus { + outline: 2px solid #4C9AFF; + outline-offset: 2px; +} +h1 { + font-size: 2.07142857em; + font-style: inherit; + font-weight: 600; + letter-spacing: -0.01em; + line-height: 1.10344828; + margin-top: 40px; +} +h2 { + font-size: 1.71428571em; + font-style: inherit; + font-weight: 500; + letter-spacing: -0.01em; + line-height: 1.16666667; + margin-top: 28px; +} +h3 { + font-size: 1.42857143em; + font-style: inherit; + font-weight: 500; + letter-spacing: -0.008em; + line-height: 1.2; + margin-top: 28px; +} +h4 { + font-size: 1.14285714em; + font-style: inherit; + font-weight: 600; + line-height: 1.25; + letter-spacing: -0.006em; + margin-top: 24px; +} +h5 { + font-size: 1em; + font-style: inherit; + font-weight: 600; + line-height: 1.14285714; + letter-spacing: -0.003em; + margin-top: 16px; +} +h6 { + color: #5E6C84; + font-size: 0.85714286em; + font-weight: 600; + line-height: 1.33333333; + margin-top: 20px; + text-transform: uppercase; +} +ul, +ol, +dl { + padding-left: 40px; +} +[dir="rtl"]ul, +[dir="rtl"]ol, +[dir="rtl"]dl { + padding-left: 0; + padding-right: 40px; +} +dd, +dd + dt, +li + li { + margin-top: 4px; +} +ul ul:not(:first-child), +ol ul:not(:first-child), +ul ol:not(:first-child), +ol ol:not(:first-child) { + margin-top: 4px; +} +p:first-child, +ul:first-child, +ol:first-child, +dl:first-child, +h1:first-child, +h2:first-child, +h3:first-child, +h4:first-child, +h5:first-child, +h6:first-child, +blockquote:first-child, +pre:first-child, +form:first-child, +table:first-child { + margin-top: 0; +} +blockquote, +q { + color: inherit; +} +blockquote { + border: none; + padding-left: 40px; +} +[dir="rtl"] blockquote { + padding-left: 0; + padding-right: 40px; +} +blockquote::before, +q:before { + content: "\201C"; +} +blockquote::after, +q::after { + content: "\201D"; +} +blockquote::before { + float: left; + margin-left: -1em; + text-align: right; + width: 1em; +} +[dir="rtl"] blockquote::before { + float: right; + margin-right: -1em; + text-align: left; +} +blockquote > :last-child { + display: inline-block; +} +small { + color: #5E6C84; + font-size: 0.85714286em; + font-weight: normal; + line-height: 1.33333333; + margin-top: 16px; +} +code, +kbd { + font-family: "SFMono-Medium", "SF Mono", "Segoe UI Mono", "Roboto Mono", "Ubuntu Mono", Menlo, Courier, monospace; +} +var, +address, +dfn, +cite { + font-style: italic; +} +abbr { + border-bottom: 1px #ccc dotted; + cursor: help; +} +table { + border-collapse: collapse; + width: 100%; +} +thead, +tbody, +tfoot { + border-bottom: 2px solid #DFE1E6; +} +td, +th { + border: none; + padding: 4px 8px; + text-align: left; +} +th { + vertical-align: top; +} +td:first-child, +th:first-child { + padding-left: 0; +} +td:last-child, +th:last-child { + padding-right: 0; +} +caption { + font-size: 1.42857143em; + font-style: inherit; + font-weight: 500; + letter-spacing: -0.008em; + line-height: 1.2; + margin-top: 28px; + margin-bottom: 8px; + text-align: left; +} +template { + display: none; +} +article, +aside, +details, +figcaption, +figure, +footer, +header, +hgroup, +main, +menu, +nav, +section { + display: block; +} +@-moz-document url-prefix() { + img { + font-size: 0; + } + img:-moz-broken { + font-size: inherit; + } +} +.assistive { + border: 0 !important; + clip: rect(1px, 1px, 1px, 1px) !important; + height: 1px !important; + overflow: hidden !important; + padding: 0 !important; + position: absolute !important; + width: 1px !important; + white-space: nowrap !important; +} \ No newline at end of file diff --git a/assets/templates/other/message.html b/assets/templates/other/message.html new file mode 100644 index 00000000..f93ace88 --- /dev/null +++ b/assets/templates/other/message.html @@ -0,0 +1,31 @@ + + + + + + + +
+

+ {{ .Header}} +

+
+
{{ .Message }}
+
+
+ + diff --git a/go.mod b/go.mod index 41ce28f1..c6da67ef 100644 --- a/go.mod +++ b/go.mod @@ -7,10 +7,13 @@ toolchain go1.22.8 require ( bou.ke/monkey v1.0.2 github.com/gorilla/mux v1.8.1 + github.com/levigross/exp-html v0.0.0-20120902181939-8df60c69a8f5 github.com/mattermost/mattermost/server/public v0.1.9 github.com/pkg/errors v0.9.1 github.com/stretchr/testify v1.10.0 + github.com/thoas/go-funk v0.9.3 go.uber.org/atomic v1.11.0 + golang.org/x/oauth2 v0.21.0 ) require ( diff --git a/go.sum b/go.sum index d16695e5..94085a26 100644 --- a/go.sum +++ b/go.sum @@ -96,6 +96,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.3/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/levigross/exp-html v0.0.0-20120902181939-8df60c69a8f5 h1:W7p+m/AECTL3s/YR5RpQ4hz5SjNeKzZBl1q36ws12s0= +github.com/levigross/exp-html v0.0.0-20120902181939-8df60c69a8f5/go.mod h1:QMe2wuKJ0o7zIVE8AqiT8rd8epmm6WDIZ2wyuBqYPzM= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= @@ -179,12 +181,15 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA= +github.com/thoas/go-funk v0.9.3 h1:7+nAEx3kn5ZJcnDm2Bh23N2yOtweO14bi//dvRtgLpw= +github.com/thoas/go-funk v0.9.3/go.mod h1:+IWnUfUmFO1+WVYQWQtIJHeRRdaIyyYglZN7xzUPe4Q= github.com/tinylib/msgp v1.2.0 h1:0uKB/662twsVBpYUPbokj4sTSKhWFKB7LopO2kWK8lY= github.com/tinylib/msgp v1.2.0/go.mod h1:2vIGs3lcUo8izAATNobrCHevYZC/LMsJtw4JPiYPHro= github.com/viant/assertly v0.4.8/go.mod h1:aGifi++jvCrUaklKEKT0BU95igDNaqkvz+49uaYMPRU= @@ -226,6 +231,8 @@ golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAG 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/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= +golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/perf v0.0.0-20180704124530-6e6d33e29852/go.mod h1:JLpeXjPJfIyPr5TlbXLkXWLhP8nz10XfvxElABhCtcw= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/plugin.json b/plugin.json index 16719c6a..3373cdc3 100644 --- a/plugin.json +++ b/plugin.json @@ -27,7 +27,14 @@ "help_text": "The secret used to authenticate the webhook to Mattermost.", "regenerate_help_text": "Regenerates the secret for the webhook URL endpoint. Regenerating the secret invalidates your existing Confluence integrations.", "secret": true - } + }, + { + "key": "EncryptionKey", + "display_name": "Encryption Key:", + "type": "generated", + "help_text": "The encryption key used to encrypt the authorization token.", + "regenerate_help_text": "Regenerates the encryption key for encrypting the authorization token. Regenerating the authorization invalidates your existing Confluence connection." + } ] } } diff --git a/server/atlassian_connect.go b/server/atlassian_connect.go index c86fe9c1..a6813c14 100644 --- a/server/atlassian_connect.go +++ b/server/atlassian_connect.go @@ -18,7 +18,7 @@ var atlassianConnectJSON = &Endpoint{ RequiresAdmin: false, } -func renderAtlassianConnectJSON(w http.ResponseWriter, r *http.Request) { +func renderAtlassianConnectJSON(w http.ResponseWriter, r *http.Request, _ *Plugin) { conf := config.GetConfig() if status, err := verifyHTTPSecret(conf.Secret, r.FormValue("secret")); err != nil { http.Error(w, err.Error(), status) diff --git a/server/auth_token.go b/server/auth_token.go new file mode 100644 index 00000000..b1344d38 --- /dev/null +++ b/server/auth_token.go @@ -0,0 +1,131 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License for license information. + +package main + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "encoding/json" + "io" + + "github.com/pkg/errors" + "golang.org/x/oauth2" + + "github.com/mattermost/mattermost-plugin-confluence/server/config" +) + +type AuthToken struct { + Token *oauth2.Token `json:"token,omitempty"` +} + +func (p *Plugin) NewEncodedAuthToken(token *oauth2.Token) (encodedToken string, returnErr error) { + encryptionSecret := config.GetConfig().EncryptionKey + + t := AuthToken{ + Token: token, + } + + jsonBytes, err := json.Marshal(t) + if err != nil { + return "", err + } + + encrypted, err := encrypt(jsonBytes, []byte(encryptionSecret)) + if err != nil { + return "", err + } + + return encode(encrypted), nil +} + +func (p *Plugin) ParseAuthToken(encoded string) (token *oauth2.Token, returnErr error) { + t := AuthToken{} + encryptionSecret := config.GetConfig().EncryptionKey + + decoded, err := decode(encoded) + if err != nil { + return nil, err + } + + jsonBytes, err := decrypt(decoded, []byte(encryptionSecret)) + if err != nil { + return nil, err + } + + if err = json.Unmarshal(jsonBytes, &t); err != nil { + return nil, err + } + + return t.Token, nil +} + +func encode(encrypted []byte) string { + encoded := make([]byte, base64.URLEncoding.EncodedLen(len(encrypted))) + base64.URLEncoding.Encode(encoded, encrypted) + return string(encoded) +} + +func encrypt(plain, secret []byte) ([]byte, error) { + if len(secret) == 0 { + return plain, nil + } + + block, err := aes.NewCipher(secret) + if err != nil { + return nil, err + } + + aesgcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + nonce := make([]byte, aesgcm.NonceSize()) + if _, err = io.ReadFull(rand.Reader, nonce); err != nil { + return nil, err + } + + sealed := aesgcm.Seal(nil, nonce, plain, nil) + return append(nonce, sealed...), nil +} + +func decode(encoded string) ([]byte, error) { + decoded := make([]byte, base64.URLEncoding.DecodedLen(len(encoded))) + n, err := base64.URLEncoding.Decode(decoded, []byte(encoded)) + if err != nil { + return nil, err + } + return decoded[:n], nil +} + +func decrypt(encrypted, secret []byte) ([]byte, error) { + if len(secret) == 0 { + return encrypted, nil + } + + block, err := aes.NewCipher(secret) + if err != nil { + return nil, err + } + + aesgcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + nonceSize := aesgcm.NonceSize() + if len(encrypted) < nonceSize { + return nil, errors.New("token too short") + } + + nonce, encrypted := encrypted[:nonceSize], encrypted[nonceSize:] + plain, err := aesgcm.Open(nil, nonce, encrypted, nil) + if err != nil { + return nil, err + } + + return plain, nil +} diff --git a/server/client.go b/server/client.go new file mode 100644 index 00000000..73c77b01 --- /dev/null +++ b/server/client.go @@ -0,0 +1,18 @@ +package main + +import "github.com/mattermost/mattermost-plugin-confluence/server/util/types" + +// Client is the combined interface for all upstream APIs and convenience methods. +type Client interface { + RESTService +} + +// RESTService is the low-level interface for invoking the upstream service. +// Endpoint can be a "short" API URL path, including the version desired, like "v3/user", +// or a fully-qualified URL, with a non-empty scheme. +type RESTService interface { + GetSelf() (*types.ConfluenceUser, error) + GetSpaceData(string) (*SpaceResponse, error) + GetPageData(int) (*PageResponse, error) + GetSpaceKeyFromSpaceID(int64) (string, error) +} diff --git a/server/client_server.go b/server/client_server.go new file mode 100644 index 00000000..2de331b6 --- /dev/null +++ b/server/client_server.go @@ -0,0 +1,225 @@ +package main + +import ( + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/pkg/errors" + + "github.com/mattermost/mattermost-plugin-confluence/server/serializer" + "github.com/mattermost/mattermost-plugin-confluence/server/service" + "github.com/mattermost/mattermost-plugin-confluence/server/util" + "github.com/mattermost/mattermost-plugin-confluence/server/util/types" +) + +const ( + PathCurrentUser = "/rest/api/user/current" + PathContentData = "/rest/api/content/" + PathSpaceData = "/rest/api/space/" + PathAdminData = "/rest/api/audit" +) + +const ( + Comment = "comment" + Space = "space" + Page = "page" +) + +const pageSize = 10 + +type confluenceServerClient struct { + URL string + HTTPClient *http.Client +} + +type ConfluenceServerUser struct { + UserKey string `json:"userKey"` + Username string `json:"username"` + DisplayName string `json:"displayName"` + Type string `json:"type"` +} + +type AdminData struct { + Number int `json:"number"` + Units string `json:"units"` +} + +type SpaceResponse struct { + ID int64 `json:"id"` + Key string `json:"key"` + Name string `json:"name"` + Links Links `json:"_links"` +} + +type CommentContainer struct { + ID string `json:"id"` + Type string `json:"type"` + Title string `json:"title"` + Links Links `json:"_links"` +} + +type Links struct { + Self string `json:"webui"` +} + +type View struct { + Value string `json:"value"` +} + +type Body struct { + View View `json:"view"` +} + +type CreatedBy struct { + Username string `json:"username"` +} + +type History struct { + CreatedBy CreatedBy `json:"createdBy"` +} + +type CommentResponse struct { + ID string `json:"id"` + Title string `json:"title"` + Space SpaceResponse `json:"space"` + Container CommentContainer `json:"container"` + Body Body `json:"body"` + Links Links `json:"_links"` + History History `json:"history"` +} + +type PageResponse struct { + ID string `json:"id"` + Title string `json:"title"` + Space SpaceResponse `json:"space"` + Body Body `json:"body"` + Links Links `json:"_links"` + History History `json:"history"` +} + +type ConfluenceServerEvent struct { + Comment *CommentResponse + Page *PageResponse + Space *SpaceResponse + BaseURL string +} + +func newServerClient(url string, httpClient *http.Client) Client { + return &confluenceServerClient{ + URL: url, + HTTPClient: httpClient, + } +} + +func (csc *confluenceServerClient) GetSelf() (*types.ConfluenceUser, error) { + confluenceServerUser := &ConfluenceServerUser{} + if _, _, err := service.CallJSONWithURL(csc.URL, PathCurrentUser, http.MethodGet, nil, confluenceServerUser, csc.HTTPClient); err != nil { + return nil, errors.Wrap(err, "Confluence GetSelf. Error getting the current user") + } + + confluenceUser := &types.ConfluenceUser{ + AccountID: confluenceServerUser.UserKey, + Name: confluenceServerUser.Username, + DisplayName: confluenceServerUser.DisplayName, + } + + return confluenceUser, nil +} + +func (csc *confluenceServerClient) GetEventData(webhookPayload *serializer.ConfluenceServerWebhookPayload) (*ConfluenceServerEvent, error) { + var confluenceServerEvent ConfluenceServerEvent + var err error + + if strings.Contains(webhookPayload.Event, Comment) { + confluenceServerEvent.Comment, err = csc.GetCommentData(webhookPayload) + if err != nil { + return nil, errors.Errorf("error getting comment data for the event. CommentID %d. Error: %v", webhookPayload.Comment.ID, err) + } + } + + if strings.Contains(webhookPayload.Event, Page) { + confluenceServerEvent.Page, err = csc.GetPageData(int(webhookPayload.Page.ID)) + if err != nil { + return nil, errors.Errorf("error getting page data for the event. PageID %d. Error: %v", webhookPayload.Page.ID, err) + } + } + + if strings.Contains(webhookPayload.Event, Space) { + confluenceServerEvent.Space, err = csc.GetSpaceData(webhookPayload.Space.SpaceKey) + if err != nil { + return nil, errors.Errorf("error getting space data for the event. SpaceKey %s. Error: %v", webhookPayload.Space.SpaceKey, err) + } + } + + return &confluenceServerEvent, nil +} + +func (csc *confluenceServerClient) GetCommentData(webhookPayload *serializer.ConfluenceServerWebhookPayload) (*CommentResponse, error) { + commentResponse := &CommentResponse{} + if _, _, err := service.CallJSONWithURL(csc.URL, fmt.Sprintf("%s%s?expand=body.view,container,space,history", PathContentData, strconv.FormatInt(webhookPayload.Comment.ID, 10)), http.MethodGet, nil, commentResponse, csc.HTTPClient); err != nil { + return nil, err + } + + commentResponse.Body.View.Value = util.GetBodyForExcerpt(commentResponse.Body.View.Value) + + return commentResponse, nil +} + +func (csc *confluenceServerClient) GetPageData(pageID int) (*PageResponse, error) { + pageResponse := &PageResponse{} + if _, _, err := service.CallJSONWithURL(csc.URL, fmt.Sprintf("%s%s?status=any&expand=body.view,container,space,history", PathContentData, strconv.Itoa(pageID)), http.MethodGet, nil, pageResponse, csc.HTTPClient); err != nil { + return nil, err + } + + pageResponse.Body.View.Value = util.GetBodyForExcerpt(pageResponse.Body.View.Value) + + return pageResponse, nil +} + +func (csc *confluenceServerClient) GetSpaceData(spaceKey string) (*SpaceResponse, error) { + spaceResponse := &SpaceResponse{} + if _, _, err := service.CallJSONWithURL(csc.URL, fmt.Sprintf("%s%s?status=any", PathSpaceData, spaceKey), http.MethodGet, nil, spaceResponse, csc.HTTPClient); err != nil { + return nil, err + } + + return spaceResponse, nil +} + +type apiResponse struct { + Results []struct { + ID int64 `json:"id"` + Key string `json:"key"` + Name string `json:"name"` + } `json:"results"` + Size int `json:"size"` +} + +func (csc *confluenceServerClient) GetSpaceKeyFromSpaceID(spaceID int64) (string, error) { + start := 0 + + for { + path := fmt.Sprintf("%s?start=%d&limit=%d", PathSpaceData, start, pageSize) + + response := &apiResponse{} + + if _, _, err := service.CallJSONWithURL(csc.URL, path, http.MethodGet, nil, response, csc.HTTPClient); err != nil { + return "", errors.Wrap(err, "confluence GetSpaceKeyFromSpaceID") + } + + for _, space := range response.Results { + if space.ID == spaceID { + return space.Key, nil + } + } + + if len(response.Results) < pageSize { + break + } + + start += pageSize + } + + return "", fmt.Errorf("confluence GetSpaceKeyFromSpaceID: no space key found for the space ID") +} diff --git a/server/command.go b/server/command.go index 1172d84c..1c0f9096 100644 --- a/server/command.go +++ b/server/command.go @@ -11,6 +11,7 @@ import ( "github.com/mattermost/mattermost-plugin-confluence/server/config" "github.com/mattermost/mattermost-plugin-confluence/server/serializer" "github.com/mattermost/mattermost-plugin-confluence/server/service" + "github.com/mattermost/mattermost-plugin-confluence/server/store" "github.com/mattermost/mattermost-plugin-confluence/server/util" ) @@ -43,6 +44,7 @@ const ( invalidCommand = "Invalid command." installOnlySystemAdmin = "`/confluence install` can only be run by a system administrator." commandsOnlySystemAdmin = "`/confluence` commands can only be run by a system administrator." + oauth2ConnectPath = "%s/oauth2/connect" ) const ( @@ -62,6 +64,8 @@ var ConfluenceCommandHandler = Handler{ "unsubscribe": deleteSubscription, "install/cloud": showInstallCloudHelp, "install/server": showInstallServerHelp, + "connect": executeConnect, + "disconnect": executeDisconnect, "help": confluenceHelpCommand, }, defaultHandler: executeConfluenceDefault, @@ -115,12 +119,19 @@ func getAutoCompleteData() *model.AutocompleteData { help := model.NewAutocompleteData("help", "", "Show confluence slash command help") confluence.AddCommand(help) + + connect := model.NewAutocompleteData("connect", "", "Connect your Mattermost account to your Confluence account") + confluence.AddCommand(connect) + + disconnect := model.NewAutocompleteData("disconnect", "", "Disconnect your Mattermost account from your Confluence account") + confluence.AddCommand(disconnect) + return confluence } -func executeConfluenceDefault(p *Plugin, context *model.CommandArgs, args ...string) *model.CommandResponse { +func executeConfluenceDefault(_ *Plugin, context *model.CommandArgs, args ...string) *model.CommandResponse { out := invalidCommand + "\n\n" - out += getFullHelpText(p, context, args...) + out += getFullHelpText(context, args...) return &model.CommandResponse{ ResponseType: model.CommandResponseTypeEphemeral, @@ -155,7 +166,52 @@ func (ch Handler) Handle(p *Plugin, context *model.CommandArgs, args ...string) return ch.defaultHandler(p, context, args...) } -func showInstallCloudHelp(p *Plugin, context *model.CommandArgs, args ...string) *model.CommandResponse { +func (p *Plugin) responsef(commandArgs *model.CommandArgs, format string, args ...interface{}) *model.CommandResponse { + postCommandResponse(commandArgs, fmt.Sprintf(format, args...)) + return &model.CommandResponse{} +} + +func executeConnect(p *Plugin, context *model.CommandArgs, args ...string) *model.CommandResponse { + isAdmin := util.IsSystemAdmin(context.UserId) + + pluginConfig := config.GetConfig() + if pluginConfig.ConfluenceURL == "" || !pluginConfig.IsOAuthConfigured() { + if isAdmin { + return p.responsef(context, "OAuth config not set for confluence plugin. Please run `/confluence install server`") + } + return p.responsef(context, "OAuth config not set for confluence plugin. Please ask the admin to setup OAuth for the plugin") + } + confluenceURL := pluginConfig.GetConfluenceBaseURL() + confluenceURL = strings.TrimSuffix(confluenceURL, "/") + + conn, err := store.LoadConnection(confluenceURL, context.UserId) + if err == nil && len(conn.ConfluenceAccountID()) != 0 { + return p.responsef(context, + "You already have a Confluence account linked to your Mattermost account. Please use `/confluence disconnect` to disconnect.") + } + + link := fmt.Sprintf(oauth2ConnectPath, util.GetPluginURL()) + return p.responsef(context, "[Click here to link your Confluence account](%s)", link) +} + +func executeDisconnect(p *Plugin, commArgs *model.CommandArgs, args ...string) *model.CommandResponse { + user, err := store.LoadUser(commArgs.UserId) + if err != nil { + return p.responsef(commArgs, "Could not complete the **disconnection** request. Error: %v", err) + } + confluenceURL := user.InstanceURL + + disconnected, err := p.DisconnectUser(confluenceURL, commArgs.UserId) + if errors.Cause(err) == store.ErrNotFound { + return p.responsef(commArgs, "Your account is not connected to Confluence. Please use `/confluence connect` to connect your account.") + } + if err != nil { + return p.responsef(commArgs, "Could not complete the **disconnection** request. Error: %v", err) + } + return p.responsef(commArgs, "You have successfully disconnected your Confluence account (**%s**).", disconnected.DisplayName) +} + +func showInstallCloudHelp(_ *Plugin, context *model.CommandArgs, args ...string) *model.CommandResponse { if !util.IsSystemAdmin(context.UserId) { postCommandResponse(context, installOnlySystemAdmin) return &model.CommandResponse{} @@ -182,7 +238,7 @@ func showInstallServerHelp(p *Plugin, context *model.CommandArgs, args ...string } } -func deleteSubscription(p *Plugin, context *model.CommandArgs, args ...string) *model.CommandResponse { +func deleteSubscription(_ *Plugin, context *model.CommandArgs, args ...string) *model.CommandResponse { if len(args) == 0 { postCommandResponse(context, specifyAlias) return &model.CommandResponse{} @@ -196,7 +252,7 @@ func deleteSubscription(p *Plugin, context *model.CommandArgs, args ...string) * return &model.CommandResponse{} } -func listChannelSubscription(p *Plugin, context *model.CommandArgs, args ...string) *model.CommandResponse { +func listChannelSubscription(_ *Plugin, context *model.CommandArgs, args ...string) *model.CommandResponse { channelSubscriptions, gErr := service.GetSubscriptionsByChannelID(context.ChannelId) if gErr != nil { postCommandResponse(context, gErr.Error()) @@ -212,14 +268,14 @@ func listChannelSubscription(p *Plugin, context *model.CommandArgs, args ...stri return &model.CommandResponse{} } -func confluenceHelpCommand(p *Plugin, context *model.CommandArgs, args ...string) *model.CommandResponse { - helpText := getFullHelpText(p, context, args...) +func confluenceHelpCommand(_ *Plugin, context *model.CommandArgs, args ...string) *model.CommandResponse { + helpText := getFullHelpText(context, args...) postCommandResponse(context, helpText) return &model.CommandResponse{} } -func getFullHelpText(_ *Plugin, context *model.CommandArgs, _ ...string) string { +func getFullHelpText(context *model.CommandArgs, _ ...string) string { helpText := commonHelpText if util.IsSystemAdmin(context.UserId) { helpText += sysAdminHelpText diff --git a/server/config/main.go b/server/config/main.go index 6e36462b..7c1b7ac5 100644 --- a/server/config/main.go +++ b/server/config/main.go @@ -21,6 +21,7 @@ var ( type Configuration struct { Secret string + EncryptionKey string ConfluenceOAuthClientID string ConfluenceOAuthClientSecret string ConfluenceURL string @@ -45,6 +46,10 @@ func (c *Configuration) IsValid() error { return errors.New("please provide the Webhook Secret") } + if c.EncryptionKey == "" { + return errors.New("please provide the Encryption Key") + } + return nil } @@ -73,3 +78,7 @@ func (c *Configuration) ToMap() (map[string]interface{}, error) { return out, nil } + +func (c *Configuration) GetConfluenceBaseURL() string { + return c.ConfluenceURL +} diff --git a/server/confluence_cloud.go b/server/confluence_cloud.go index ffa79a5e..bbe8bd2c 100644 --- a/server/confluence_cloud.go +++ b/server/confluence_cloud.go @@ -17,7 +17,7 @@ var confluenceCloudWebhook = &Endpoint{ Execute: handleConfluenceCloudWebhook, } -func handleConfluenceCloudWebhook(w http.ResponseWriter, r *http.Request) { +func handleConfluenceCloudWebhook(w http.ResponseWriter, r *http.Request, _ *Plugin) { config.Mattermost.LogInfo("Received confluence cloud event.") if status, err := verifyHTTPSecret(config.GetConfig().Secret, r.FormValue("secret")); err != nil { diff --git a/server/confluence_server.go b/server/confluence_server.go index 97c3b526..76236a8b 100644 --- a/server/confluence_server.go +++ b/server/confluence_server.go @@ -1,11 +1,15 @@ package main import ( + "encoding/json" + "io" "net/http" + "strings" "github.com/mattermost/mattermost-plugin-confluence/server/config" "github.com/mattermost/mattermost-plugin-confluence/server/serializer" "github.com/mattermost/mattermost-plugin-confluence/server/service" + "github.com/mattermost/mattermost-plugin-confluence/server/store" ) var confluenceServerWebhook = &Endpoint{ @@ -15,7 +19,7 @@ var confluenceServerWebhook = &Endpoint{ RequiresAdmin: false, } -func handleConfluenceServerWebhook(w http.ResponseWriter, r *http.Request) { +func handleConfluenceServerWebhook(w http.ResponseWriter, r *http.Request, p *Plugin) { config.Mattermost.LogInfo("Received confluence server event.") if status, err := verifyHTTPSecret(config.GetConfig().Secret, r.FormValue("secret")); err != nil { @@ -23,9 +27,76 @@ func handleConfluenceServerWebhook(w http.ResponseWriter, r *http.Request) { return } - event := serializer.ConfluenceServerEventFromJSON(r.Body) - go service.SendConfluenceNotifications(event, event.Event) + if p.serverVersionGreaterthan9 { + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + var event *serializer.ConfluenceServerWebhookPayload + err = json.Unmarshal(body, &event) + if err != nil { + config.Mattermost.LogError("Error occurred while unmarshalling Confluence server webhook payload.", "Error", err.Error()) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + pluginConfig := config.GetConfig() + instanceID := pluginConfig.ConfluenceURL + + mmUserID, err := store.GetMattermostUserIDFromConfluenceID(instanceID, event.UserKey) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + connection, err := store.LoadConnection(instanceID, *mmUserID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + client, err := p.GetServerClient(instanceID, connection) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + var spaceKey string + if strings.Contains(event.Event, Space) { + spaceKey, err = client.(*confluenceServerClient).GetSpaceKeyFromSpaceID(event.Space.ID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + event.Space.SpaceKey = spaceKey + } + + eventData, err := p.GetEventData(event, client) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + eventData.BaseURL = pluginConfig.ConfluenceURL + + notification := p.getNotification() + + notification.SendConfluenceNotifications(eventData, event.Event, p.BotUserID, *mmUserID) + } else { + event := serializer.ConfluenceServerEventFromJSON(r.Body) + go service.SendConfluenceNotifications(event, event.Event) + } w.Header().Set("Content-Type", "application/json") ReturnStatusOK(w) } + +func (p *Plugin) GetEventData(webhookPayload *serializer.ConfluenceServerWebhookPayload, client Client) (*ConfluenceServerEvent, error) { + eventData, err := client.(*confluenceServerClient).GetEventData(webhookPayload) + if err != nil { + p.API.LogError("Error occurred while fetching event data.", "Error", err.Error()) + return nil, err + } + + return eventData, nil +} diff --git a/server/confluence_server_v2.go b/server/confluence_server_v2.go new file mode 100644 index 00000000..7d0902f2 --- /dev/null +++ b/server/confluence_server_v2.go @@ -0,0 +1,179 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/mattermost/mattermost/server/public/model" + + "github.com/mattermost/mattermost-plugin-confluence/server/serializer" + "github.com/mattermost/mattermost-plugin-confluence/server/util" +) + +const ( + ConfluencePageCreatedMessage = "%s published a new page in %s." + ConfluencePageCreatedWithoutBodyMessage = "%s published a new page %s in %s." + ConfluencePageUpdatedMessage = "%s updated %s in %s." + ConfluencePageTrashedMessage = "%s trashed %s in %s." + ConfluencePageRestoredMessage = "%s restored %s in %s." + ConfluenceCommentCreatedMessage = "%s commented on %s in %s." + ConfluenceEmptyCommentCreatedMessage = "%s [commented](%s) on %s in %s." + ConfluenceCommentUpdatedMessage = "%s updated a comment on %s in %s." + ConfluenceEmptyCommentUpdatedMessage = "%s updated a [comment](%s) on %s in %s." + ConfluenceSpaceUpdatedMessage = "A space titled [%s](%s) was updated." +) + +func (e ConfluenceServerEvent) GetSpaceKey() string { + return e.Space.Key +} + +func (e ConfluenceServerEvent) GetURL() string { + return e.BaseURL +} + +func (e ConfluenceServerEvent) GetCommentSpaceKey() string { + return e.Comment.Space.Key +} + +func (e ConfluenceServerEvent) GetCommentContainerID() string { + return e.Comment.Container.ID +} + +func (e ConfluenceServerEvent) GetPageSpaceKey() string { + return e.Page.Space.Key +} + +func (e ConfluenceServerEvent) GetPageID() string { + return e.Page.ID +} + +func (e *ConfluenceServerEvent) GetUserDisplayNameForCommentEvents() string { + return util.GetUsernameOrAnonymousName(e.Comment.History.CreatedBy.Username) +} + +func (e *ConfluenceServerEvent) GetUserDisplayNameForPageEvents() string { + return util.GetUsernameOrAnonymousName(e.Page.History.CreatedBy.Username) +} + +func (e *ConfluenceServerEvent) GetSpaceDisplayNameForCommentEvents(baseURL string) string { + name := e.Comment.Space.Key + if strings.TrimSpace(e.Comment.Space.Name) != "" { + name = strings.TrimSpace(e.Comment.Space.Name) + } + if e.Comment.Space.Links.Self != "" { + name = fmt.Sprintf("[%s](%s/%s)", name, baseURL, e.Comment.Space.Links.Self) + } + return name +} + +func (e *ConfluenceServerEvent) GetSpaceDisplayNameForPageEvents(baseURL string) string { + name := e.Page.Space.Key + if strings.TrimSpace(e.Page.Space.Name) != "" { + name = strings.TrimSpace(e.Page.Space.Name) + } + if e.Page.Space.Links.Self != "" { + name = fmt.Sprintf("[%s](%s/%s)", name, baseURL, e.Page.Space.Links.Self) + } + return name +} + +func (e *ConfluenceServerEvent) GetPageDisplayNameForPageEvents(baseURL string) string { + if e.Page.Title == "" { + return "" + } + + name := e.Page.Title + if e.Page.Links.Self != "" { + name = fmt.Sprintf("[%s](%s/%s)", name, baseURL, e.Page.Links.Self) + } + return name +} + +func (e *ConfluenceServerEvent) GetPageDisplayNameForCommentEvents(baseURL string) string { + if e.Comment.Container.Title == "" { + return "" + } + + name := e.Comment.Container.Title + if e.Comment.Container.Links.Self != "" { + name = fmt.Sprintf("[%s](%s/%s)", name, baseURL, e.Comment.Container.Links.Self) + } + return name +} + +func (e ConfluenceServerEvent) GetNotificationPost(eventType, baseURL, botUserID string) *model.Post { + var attachment *model.SlackAttachment + post := &model.Post{ + UserId: botUserID, + } + + switch eventType { + case serializer.PageCreatedEvent: + message := fmt.Sprintf(ConfluencePageCreatedMessage, e.GetUserDisplayNameForPageEvents(), e.GetSpaceDisplayNameForPageEvents(baseURL)) + if strings.TrimSpace(e.Page.Body.View.Value) != "" { + attachment = &model.SlackAttachment{ + Fallback: message, + Pretext: message, + Title: e.Page.Title, + TitleLink: fmt.Sprintf("%s/%s", baseURL, e.Page.Links.Self), + Text: fmt.Sprintf("%s\n\n[**View in Confluence**](%s)", strings.TrimSpace(e.Page.Body.View.Value), fmt.Sprintf("%s/%s", baseURL, e.Page.Links.Self)), + } + } else { + post.Message = fmt.Sprintf(ConfluencePageCreatedWithoutBodyMessage, e.GetUserDisplayNameForPageEvents(), e.GetPageDisplayNameForPageEvents(baseURL), e.GetSpaceDisplayNameForPageEvents(baseURL)) + } + + case serializer.PageUpdatedEvent: + message := fmt.Sprintf(ConfluencePageUpdatedMessage, e.GetUserDisplayNameForPageEvents(), e.GetPageDisplayNameForPageEvents(baseURL), e.GetSpaceDisplayNameForPageEvents(baseURL)) + if strings.TrimSpace(e.Page.Body.View.Value) != "" { + attachment = &model.SlackAttachment{ + Fallback: message, + Pretext: message, + Text: fmt.Sprintf("**What’s Changed?**\n> %s\n\n[**View in Confluence**](%s)", strings.TrimSpace(e.Page.Body.View.Value), fmt.Sprintf("%s/%s", baseURL, e.Page.Links.Self)), + } + } else { + post.Message = message + } + + case serializer.PageTrashedEvent: + post.Message = fmt.Sprintf(ConfluencePageTrashedMessage, e.GetUserDisplayNameForPageEvents(), e.GetPageDisplayNameForPageEvents(baseURL), e.GetSpaceDisplayNameForPageEvents(baseURL)) + + case serializer.PageRestoredEvent: + post.Message = fmt.Sprintf(ConfluencePageRestoredMessage, e.GetUserDisplayNameForPageEvents(), e.GetPageDisplayNameForPageEvents(baseURL), e.GetSpaceDisplayNameForPageEvents(baseURL)) + + case serializer.CommentCreatedEvent: + message := fmt.Sprintf(ConfluenceCommentCreatedMessage, e.GetUserDisplayNameForCommentEvents(), e.GetPageDisplayNameForCommentEvents(baseURL), e.GetSpaceDisplayNameForCommentEvents(baseURL)) + text := "" + if strings.TrimSpace(e.Comment.Body.View.Value) != "" { + text = fmt.Sprintf("**%s wrote:**\n> %s\n\n", e.GetUserDisplayNameForCommentEvents(), strings.TrimSpace(e.Comment.Body.View.Value)) + attachment = &model.SlackAttachment{ + Fallback: message, + Pretext: message, + Text: fmt.Sprintf("%s\n\n[**View in Confluence**](%s)", text, fmt.Sprintf("%s/%s", baseURL, e.Comment.Links.Self)), + } + } else { + post.Message = fmt.Sprintf(ConfluenceEmptyCommentCreatedMessage, e.GetUserDisplayNameForCommentEvents(), fmt.Sprintf("%s/%s", baseURL, e.Comment.Links.Self), e.GetPageDisplayNameForCommentEvents(baseURL), e.GetSpaceDisplayNameForCommentEvents(baseURL)) + } + + case serializer.CommentUpdatedEvent: + message := fmt.Sprintf(ConfluenceCommentUpdatedMessage, e.GetUserDisplayNameForCommentEvents(), e.GetPageDisplayNameForCommentEvents(baseURL), e.GetSpaceDisplayNameForCommentEvents(baseURL)) + if strings.TrimSpace(e.Comment.Body.View.Value) != "" { + attachment = &model.SlackAttachment{ + Fallback: message, + Pretext: message, + Text: fmt.Sprintf("**Updated Comment:**\n> %s\n\n[**View in Confluence**](%s)", strings.TrimSpace(e.Comment.Body.View.Value), fmt.Sprintf("%s/%s", baseURL, e.Comment.Links.Self)), + } + } else { + post.Message = fmt.Sprintf(ConfluenceEmptyCommentUpdatedMessage, e.GetUserDisplayNameForCommentEvents(), fmt.Sprintf("%s/%s", baseURL, e.Comment.Links.Self), e.GetPageDisplayNameForCommentEvents(baseURL), e.GetSpaceDisplayNameForCommentEvents(baseURL)) + } + + case serializer.SpaceUpdatedEvent: + post.Message = fmt.Sprintf(ConfluenceSpaceUpdatedMessage, e.Space.Key, fmt.Sprintf("%s/%s", baseURL, e.Space.Links.Self)) + default: + return nil + } + + if attachment != nil { + model.ParseSlackAttachment(post, []*model.SlackAttachment{attachment}) + } + return post +} diff --git a/server/controller.go b/server/controller.go index 903a25b9..f67edc19 100644 --- a/server/controller.go +++ b/server/controller.go @@ -18,21 +18,22 @@ import ( type Endpoint struct { Path string Method string - Execute func(w http.ResponseWriter, r *http.Request) + Execute func(w http.ResponseWriter, r *http.Request, p *Plugin) RequiresAdmin bool } // Endpoints is a map of endpoint key to endpoint object // Usage: getEndpointKey(GetMetadata): GetMetadata var Endpoints = map[string]*Endpoint{ - getEndpointKey(atlassianConnectJSON): atlassianConnectJSON, - getEndpointKey(confluenceCloudWebhook): confluenceCloudWebhook, - getEndpointKey(saveChannelSubscription): saveChannelSubscription, - getEndpointKey(editChannelSubscription): editChannelSubscription, - getEndpointKey(confluenceServerWebhook): confluenceServerWebhook, - getEndpointKey(getChannelSubscription): getChannelSubscription, - + getEndpointKey(atlassianConnectJSON): atlassianConnectJSON, + getEndpointKey(confluenceCloudWebhook): confluenceCloudWebhook, + getEndpointKey(saveChannelSubscription): saveChannelSubscription, + getEndpointKey(editChannelSubscription): editChannelSubscription, + getEndpointKey(confluenceServerWebhook): confluenceServerWebhook, + getEndpointKey(getChannelSubscription): getChannelSubscription, getEndpointKey(autocompleteGetChannelSubscriptions): autocompleteGetChannelSubscriptions, + getEndpointKey(userConnect): userConnect, + getEndpointKey(userConnectComplete): userConnectComplete, } // Uniquely identifies an endpoint using path and method @@ -51,12 +52,20 @@ func (p *Plugin) InitAPI() *mux.Router { if endpoint.RequiresAdmin { handler = handleAdminRequired(endpoint) } - s.HandleFunc(endpoint.Path, handler).Methods(endpoint.Method) + + s.HandleFunc(endpoint.Path, p.wrapHandler(handler)).Methods(endpoint.Method) } return r } +// wrapHandler ensures the plugin is passed to the handler +func (p *Plugin) wrapHandler(handler func(http.ResponseWriter, *http.Request, *Plugin)) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + handler(w, r, p) + } +} + // handleStaticFiles handles the static files under the assets directory. func handleStaticFiles(r *mux.Router) { bundlePath, err := config.Mattermost.GetBundlePath() @@ -69,10 +78,10 @@ func handleStaticFiles(r *mux.Router) { r.PathPrefix("/static/").Handler(http.StripPrefix("/static/", http.FileServer(http.Dir(filepath.Join(bundlePath, "assets"))))) } -func handleAdminRequired(endpoint *Endpoint) func(w http.ResponseWriter, r *http.Request) { - return func(w http.ResponseWriter, r *http.Request) { +func handleAdminRequired(endpoint *Endpoint) func(w http.ResponseWriter, r *http.Request, p *Plugin) { + return func(w http.ResponseWriter, r *http.Request, p *Plugin) { if IsAdmin(w, r) { - endpoint.Execute(w, r) + endpoint.Execute(w, r, p) } } } diff --git a/server/edit_subscription.go b/server/edit_subscription.go index 5a7d4418..aaaa6194 100644 --- a/server/edit_subscription.go +++ b/server/edit_subscription.go @@ -20,7 +20,7 @@ var editChannelSubscription = &Endpoint{ const subscriptionEditSuccess = "Your subscription has been edited successfully." -func handleEditChannelSubscription(w http.ResponseWriter, r *http.Request) { +func handleEditChannelSubscription(w http.ResponseWriter, r *http.Request, _ *Plugin) { params := mux.Vars(r) channelID := params["channelID"] subscriptionType := params["type"] diff --git a/server/flow.go b/server/flow.go index 1586a1a2..aeafb583 100644 --- a/server/flow.go +++ b/server/flow.go @@ -18,14 +18,18 @@ import ( type FlowManager struct { client *pluginapi.Client + plugin *Plugin pluginID string botUserID string router *mux.Router getConfiguration func() *config.Configuration - webhookURL string MMSiteURL string + GetRedirectURL func() string + webhookURL string confluenceBaseURL string setupFlow *flow.Flow + completionFlow *flow.Flow + announcementFlow *flow.Flow } func (p *Plugin) NewFlowManager() (*FlowManager, error) { @@ -33,19 +37,20 @@ func (p *Plugin) NewFlowManager() (*FlowManager, error) { fm := &FlowManager{ client: p.client, + plugin: p, pluginID: manifest.Id, botUserID: p.BotUserID, router: p.Router, - MMSiteURL: util.GetSiteURL(), webhookURL: webhookURL, getConfiguration: config.GetConfig, + MMSiteURL: util.GetSiteURL(), + GetRedirectURL: p.GetRedirectURL, } setupFlow, err := fm.newFlow("setup") if err != nil { return nil, err } - setupFlow.WithSteps( fm.stepWelcome(), fm.stepInstanceURL(), @@ -54,11 +59,36 @@ func (p *Plugin) NewFlowManager() (*FlowManager, error) { fm.stepCSversionLessthan9(), fm.stepOAuthInput(), fm.stepOAuthConnect(), + fm.stepAnnouncementQuestion(), + fm.stepAnnouncementConfirmation(), fm.stepDone(), fm.stepCancel("setup"), ) fm.setupFlow = setupFlow + completionFlow, err := fm.newFlow("completion") + if err != nil { + return nil, err + } + completionFlow.WithSteps( + fm.stepWebhookInstructions(), + fm.stepDone(), + fm.stepCancel("completion"), + ) + fm.completionFlow = completionFlow + + announcementFlow, err := fm.newFlow("announcement") + if err != nil { + return nil, err + } + announcementFlow.WithSteps( + fm.stepAnnouncementQuestion(), + fm.stepAnnouncementConfirmation().Terminal(), + + fm.stepCancel("setup announcement"), + ) + fm.announcementFlow = announcementFlow + return fm, nil } @@ -85,6 +115,7 @@ const ( stepOAuthInput flow.Name = "oauth-input" stepCSversionLessthan9 flow.Name = "server-version-less-than-9" stepCSversionGreaterthan9 flow.Name = "server-version-greater-than-9" + stepWebhookInstructions flow.Name = "webhook-instruction" stepAnnouncementQuestion flow.Name = "announcement-question" stepAnnouncementConfirmation flow.Name = "announcement-confirmation" stepDone flow.Name = "done" @@ -93,7 +124,6 @@ const ( keyConfluenceURL = "ConfluenceURL" keyIsOAuthConfigured = "IsOAuthConfigured" - redirectURL = "dummyRedirectURL" // will be added in oauth PR ) func cancelButton() flow.Button { @@ -107,7 +137,7 @@ func cancelButton() flow.Button { func (fm *FlowManager) stepCancel(command string) flow.Step { return flow.NewStep(stepCancel). Terminal(). - WithText(fmt.Sprintf("Confluence integration setup has stopped. Restart setup later by running `/confluence %s`. Learn more about the plugin [here](https://mattermost.gitbook.io/plugin-confluence/).", command)). + WithText(fmt.Sprintf("Confluence integration setup has stopped. Restart setup later by running `/confluence %s`. Learn more about the plugin [here](%s).", command, documentationURL)). WithColor(flow.ColorDanger) } @@ -127,7 +157,7 @@ func (fm *FlowManager) getBaseState() flow.State { config := fm.getConfiguration() isOAuthConfigured := config.ConfluenceOAuthClientID != "" || config.ConfluenceOAuthClientSecret != "" return flow.State{ - keyConfluenceURL: config.ConfluenceURL, + keyConfluenceURL: config.GetConfluenceBaseURL(), keyIsOAuthConfigured: isOAuthConfigured, } } @@ -145,8 +175,20 @@ func (fm *FlowManager) StartSetupWizard(userID string, delegatedFrom string) err return nil } +func (fm *FlowManager) StartCompletionWizard(userID string) error { + state := fm.getBaseState() + + if err := fm.completionFlow.ForUser(userID).Start(state); err != nil { + return err + } + + fm.client.Log.Debug("Started setup wizard", "userID", userID) + + return nil +} + func (fm *FlowManager) stepWelcome() flow.Step { - welcomeText := ":wave: Welcome to your Confluence integration! [Learn more](https://github.com/mattermost-community/mattermost-plugin-confluence#readme)" + welcomeText := fmt.Sprintf(":wave: Welcome to your Confluence integration! [Learn more](%s)", documentationURL) welcomePretext := "Just a few configuration steps to go!" return flow.NewStep(stepWelcome). @@ -160,9 +202,12 @@ func (fm *FlowManager) stepServerVersionQuestion() flow.Step { return flow.NewStep(stepServerVersionQuestion). WithText(delegateQuestionText). WithButton(flow.Button{ - Name: "Yes", - Color: flow.ColorPrimary, - OnClick: flow.Goto(stepCSversionGreaterthan9), + Name: "Yes", + Color: flow.ColorPrimary, + OnClick: func(f *flow.Flow) (flow.Name, flow.State, error) { + fm.plugin.serverVersionGreaterthan9 = true + return stepCSversionGreaterthan9, nil, nil + }, }). WithButton(flow.Button{ Name: "No", @@ -178,7 +223,7 @@ func (fm *FlowManager) stepCSversionGreaterthan9() flow.Step { WithText( fmt.Sprintf( "%s has been successfully added. To finish the configuration, add an Application Link in your Confluence instance following these steps:\n", - fm.confluenceBaseURL, + fm.getConfluenceBaseURL(), ) + "1. Go to [**Settings > Applications > Application Links**]({{ .ConfluenceURL }}/plugins/servlet/applinks/listApplicationLinks)\n" + " ![image](https://user-images.githubusercontent.com/90389917/202149868-a3044351-37bc-43c0-9671-aba169706917.png)\n" + @@ -186,14 +231,29 @@ func (fm *FlowManager) stepCSversionGreaterthan9() flow.Step { "3. On the **Create Link** screen, select **External Application** and **Incoming** as `Application type` and `Direction` respectively. Select **Continue**.\n" + "4. On the **Link Applications** screen, set the following values:\n" + " - **Name**: `Mattermost`\n" + - fmt.Sprintf(" - **Redirect URL**: `%s`\n", redirectURL) + + fmt.Sprintf(" - **Redirect URL**: `%s`\n", fm.GetRedirectURL()) + " - **Application Permissions**: `Admin`\n" + " Select **Continue**.\n" + - "5. Copy the `clientID` and `clientSecret` from **Settings**, and paste them into the modal in Mattermost which can be opened by using the `/confluence config add` slash command.", + "5. Copy the `clientID` and `clientSecret` from **Settings**.", ). WithButton(continueButton(stepOAuthInput)) } +func (fm *FlowManager) stepWebhookInstructions() flow.Step { + return flow.NewStep(stepWebhookInstructions). + WithText( + "You have successfully connected your Mattermost acoount to Confluence server. To finish the configuration, add a Webhook in your Confluence server following these steps:\n" + + "1. Go to [**Settings > Plugins > Servlet > Webhooks**]({{ .ConfluenceURL }}/plugins/servlet/webhooks/)\n" + + "2. Select **Create Webhook**.\n" + + "4. On the **Create Webhook** screen, set the following values:\n" + + " - **Name**: `Mattermost Webhook`\n" + + fmt.Sprintf(" - **URL**: `%s`\n", fm.webhookURL) + + " - Select all the Events in the list\n" + + " Select **Save**.\n", + ). + WithButton(continueButton(stepDone)) +} + func (fm *FlowManager) stepCSversionLessthan9() flow.Step { return flow.NewStep(stepCSversionLessthan9). WithText(fmt.Sprintf(` @@ -248,8 +308,7 @@ func (fm *FlowManager) submitConfluenceURL(f *flow.Flow, submitted map[string]in return "", nil, nil, errors.New("confluence_url is not a string") } - _, err := service.CheckConfluenceURL(fm.MMSiteURL, confluenceURL, false) - if err != nil { + if _, err := service.CheckConfluenceURL(fm.MMSiteURL, confluenceURL, false); err != nil { errorList["confluence_url"] = err.Error() } @@ -266,15 +325,14 @@ func (fm *FlowManager) submitConfluenceURL(f *flow.Flow, submitted map[string]in return "", nil, nil, err } - err = fm.client.Configuration.SavePluginConfig(configMap) - if err != nil { + if err = fm.client.Configuration.SavePluginConfig(configMap); err != nil { return "", nil, nil, errors.Wrap(err, "failed to save plugin config") } fm.confluenceBaseURL = confluenceURL return stepServerVersionQuestion, flow.State{ - keyConfluenceURL: config.ConfluenceURL, + keyConfluenceURL: config.GetConfluenceBaseURL(), }, nil, nil } @@ -324,8 +382,8 @@ func (fm *FlowManager) submitOAuthConfig(f *flow.Flow, submitted map[string]inte clientID = strings.TrimSpace(clientID) - if len(clientID) < 64 { - errorList["client_id"] = "Client ID should be at least 64 characters long" + if len(clientID) < 32 { + errorList["client_id"] = "Client ID should be at least 32 characters long" } clientSecretRaw, ok := submitted["client_secret"] @@ -366,15 +424,114 @@ func (fm *FlowManager) submitOAuthConfig(f *flow.Flow, submitted map[string]inte func (fm *FlowManager) stepOAuthConnect() flow.Step { connectPretext := "##### :white_check_mark: Connect your Confluence account" - connectURL := fmt.Sprintf("%s/oauth/connect", util.GetPluginURL()) + connectURL := fmt.Sprintf(oauth2ConnectPath, util.GetPluginURL()) connectText := fmt.Sprintf("Go [here](%s) to connect your account.", connectURL) return flow.NewStep(stepOAuthConnect). WithText(connectText). WithPretext(connectPretext) } +func (fm *FlowManager) StartAnnouncementWizard(userID string) error { + state := fm.getBaseState() + + err := fm.announcementFlow.ForUser(userID).Start(state) + if err != nil { + return err + } + + return nil +} + +func (fm *FlowManager) stepAnnouncementQuestion() flow.Step { + defaultMessage := fmt.Sprintf("Hi team,\n"+ + "\n"+ + "We've set up the Mattermost Confluence plugin to enable notifications from Confluence in Mattermost. To get started, run the `/confluence connect` slash command from any channel within Mattermost to connect that channel with Confluence. See the [documentation](%s) for details on using the Confluence plugin.", documentationURL) + + return flow.NewStep(stepAnnouncementQuestion). + WithText("Want to let your team know?"). + WithButton(flow.Button{ + Name: "Send Message", + Color: flow.ColorPrimary, + Dialog: &model.Dialog{ + Title: "Notify your team", + SubmitLabel: "Send message", + Elements: []model.DialogElement{ + { + DisplayName: "To", + Name: "channel_id", + Type: "select", + Placeholder: "Select channel", + DataSource: "channels", + }, + { + DisplayName: "Message", + Name: "message", + Type: "textarea", + Default: defaultMessage, + HelpText: "You can edit this message before sending it.", + }, + }, + }, + OnDialogSubmit: fm.submitChannelAnnouncement, + }). + WithButton(flow.Button{ + Name: "Not now", + Color: flow.ColorDefault, + OnClick: flow.Goto(stepWebhookInstructions), + }) +} + +func (fm *FlowManager) stepAnnouncementConfirmation() flow.Step { + return flow.NewStep(stepAnnouncementConfirmation). + WithText("Message to ~{{ .ChannelName }} was sent."). + Next(stepDone) +} + +func (fm *FlowManager) submitChannelAnnouncement(f *flow.Flow, submitted map[string]interface{}) (flow.Name, flow.State, map[string]string, error) { + channelIDRaw, ok := submitted["channel_id"] + if !ok { + return "", nil, nil, errors.New("channel_id missing") + } + channelID, ok := channelIDRaw.(string) + if !ok { + return "", nil, nil, errors.New("channel_id is not a string") + } + + channel, err := fm.client.Channel.Get(channelID) + if err != nil { + return "", nil, nil, errors.Wrap(err, "failed to get channel") + } + + messageRaw, ok := submitted["message"] + if !ok { + return "", nil, nil, errors.New("message is not a string") + } + message, ok := messageRaw.(string) + if !ok { + return "", nil, nil, errors.New("message is not a string") + } + + post := &model.Post{ + UserId: f.UserID, + ChannelId: channel.Id, + Message: message, + } + err = fm.client.Post.CreatePost(post) + if err != nil { + return "", nil, nil, errors.Wrap(err, "failed to create announcement post") + } + + return stepAnnouncementConfirmation, flow.State{ + "ChannelName": channel.Name, + }, nil, nil +} + func (fm *FlowManager) stepDone() flow.Step { return flow.NewStep(stepDone). Terminal(). WithText(":tada: You successfully installed Confluence.") } + +func (fm *FlowManager) getConfluenceBaseURL() string { + return fm.confluenceBaseURL +} diff --git a/server/get_subscription.go b/server/get_subscription.go index c4fd7087..dfc68616 100644 --- a/server/get_subscription.go +++ b/server/get_subscription.go @@ -16,7 +16,7 @@ var getChannelSubscription = &Endpoint{ Execute: handleGetChannelSubscription, } -func handleGetChannelSubscription(w http.ResponseWriter, r *http.Request) { +func handleGetChannelSubscription(w http.ResponseWriter, r *http.Request, _ *Plugin) { params := mux.Vars(r) channelID := params["channelID"] alias := r.FormValue("alias") diff --git a/server/get_subscriptions.go b/server/get_subscriptions.go index 874c9f8c..8b3de7e4 100644 --- a/server/get_subscriptions.go +++ b/server/get_subscriptions.go @@ -16,7 +16,7 @@ var autocompleteGetChannelSubscriptions = &Endpoint{ Execute: handleGetChannelSubscriptions, } -func handleGetChannelSubscriptions(w http.ResponseWriter, r *http.Request) { +func handleGetChannelSubscriptions(w http.ResponseWriter, r *http.Request, _ *Plugin) { channelID := r.FormValue("channel_id") subscriptions, err := service.GetSubscriptionsByChannelID(channelID) if err != nil { diff --git a/server/http.go b/server/http.go new file mode 100644 index 00000000..7a328e23 --- /dev/null +++ b/server/http.go @@ -0,0 +1,99 @@ +package main + +import ( + "net/http" + "os" + "path/filepath" + "strings" + "text/template" + + "github.com/pkg/errors" +) + +const ( + routePrefixInstance = "instance" +) + +func respondErr(w http.ResponseWriter, code int, err error) (int, error) { + http.Error(w, err.Error(), code) + return code, err +} + +func (p *Plugin) loadTemplates(dir string) (map[string]*template.Template, error) { + dir = filepath.Clean(dir) + templates := make(map[string]*template.Template) + err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + template, err := template.ParseFiles(path) + if err != nil { + p.errorf("OnActivate: failed to parse template %s: %v", path, err) + return nil + } + key := path[len(dir):] + templates[key] = template + p.debugf("loaded template %s", key) + return nil + }) + if err != nil { + return nil, errors.WithMessage(err, "OnActivate: failed to load templates") + } + return templates, nil +} + +// splitInstancePath extracts the instance ID from a given route and returns the instance URL along with the remaining path +// if the route does not contain a valid instance ID, the original route is returned. +func splitInstancePath(route string) (instanceURL string, remainingPath string) { + leadingSlash := "" + ss := strings.Split(route, "/") + + // Remove leading slash if present + if len(ss) > 1 && ss[0] == "" { + leadingSlash = "/" + ss = ss[1:] + } + + // If there's not enough parts in the path, return the original route + if len(ss) < 2 { + return "", route + } + + // Remove API version prefix if present (e.g., "api/v1") + if ss[0] == "api" && strings.Contains(ss[1], "v") { + ss = ss[2:] + } + + // If the first segment is not the expected instance prefix, return the route as is + if ss[0] != routePrefixInstance { + return route, route + } + + // Try to decode the instance ID + id, err := decode(ss[1]) + if err != nil { + return "", route + } + + // Return the decoded instance ID and the remaining path + return string(id), leadingSlash + strings.Join(ss[2:], "/") +} + +func (p *Plugin) respondTemplate(w http.ResponseWriter, key string, r *http.Request, status int, contentType string, values interface{}) (int, error) { + if key == "" { + _, key = splitInstancePath(r.URL.Path) // Extract key from URL if not provided + } + + w.Header().Set("Content-Type", contentType) + t := p.templates[key] + if t == nil { + return respondErr(w, http.StatusInternalServerError, errors.New("no template found for "+key)) + } + if err := t.Execute(w, values); err != nil { + return http.StatusInternalServerError, errors.WithMessage(err, "failed to write response") + } + return status, nil +} diff --git a/server/instance_server.go b/server/instance_server.go new file mode 100644 index 00000000..52555a3b --- /dev/null +++ b/server/instance_server.go @@ -0,0 +1,74 @@ +package main + +import ( + "context" + "fmt" + + "github.com/pkg/errors" + "golang.org/x/oauth2" + + "github.com/mattermost/mattermost-plugin-confluence/server/config" + "github.com/mattermost/mattermost-plugin-confluence/server/service" + "github.com/mattermost/mattermost-plugin-confluence/server/util" + "github.com/mattermost/mattermost-plugin-confluence/server/util/types" +) + +func (p *Plugin) GetServerOAuth2Config(instanceURL string, isAdmin bool) (*oauth2.Config, error) { + config := config.GetConfig() + if config == nil { + return nil, errors.New("error getting plugin configurations") + } + + var scopes []string + if isAdmin { + scopes = []string{ + "ADMIN", + } + } else { + scopes = []string{ + "READ", + "WRITE", + } + } + return &oauth2.Config{ + ClientID: config.ConfluenceOAuthClientID, + ClientSecret: config.ConfluenceOAuthClientSecret, + RedirectURL: fmt.Sprintf("%s%s", util.GetPluginURL(), routeUserComplete), + Scopes: scopes, + Endpoint: oauth2.Endpoint{ + AuthURL: fmt.Sprintf("%s/rest/oauth2/latest/authorize", instanceURL), + TokenURL: fmt.Sprintf("%s/rest/oauth2/latest/token", instanceURL), + }, + }, nil +} + +func (p *Plugin) GetServerClient(instanceID string, connection *types.Connection) (Client, error) { + oconf, err := p.GetServerOAuth2Config(instanceID, connection.IsAdmin) + if err != nil { + return nil, err + } + + token, err := p.refreshAndStoreToken(connection, instanceID, oconf) + if err != nil { + return nil, err + } + httpClient := oconf.Client(context.Background(), token) + + return newServerClient(instanceID, httpClient), nil +} + +func (p *Plugin) GetRedirectURL() string { + return fmt.Sprintf("%s%s", util.GetPluginURL(), routeUserComplete) +} + +func (p *Plugin) ResolveWebhookInstanceURL(instanceURL string) (string, error) { + var err error + if instanceURL != "" { + instanceURL, err = service.NormalizeConfluenceURL(instanceURL) + if err != nil { + return "", err + } + } + + return instanceURL, nil +} diff --git a/server/notification.go b/server/notification.go new file mode 100644 index 00000000..d7c28766 --- /dev/null +++ b/server/notification.go @@ -0,0 +1,112 @@ +package main + +import ( + "strings" + + "github.com/thoas/go-funk" + + "github.com/mattermost/mattermost-plugin-confluence/server/serializer" + "github.com/mattermost/mattermost-plugin-confluence/server/service" + "github.com/mattermost/mattermost-plugin-confluence/server/util" +) + +type notification struct { + *Plugin +} + +func (p *Plugin) getNotification() *notification { + return ¬ification{ + p, + } +} + +func (n *notification) SendConfluenceNotifications(event serializer.ConfluenceEventV2, eventType, botUserID, userID string) { + url := event.GetURL() + if url == "" { + return + } + + spaceKey, pageID := n.extractSpaceKeyAndPageID(event, eventType) + if spaceKey == "" || pageID == "" { + return + } + + post := event.GetNotificationPost(eventType, url, botUserID) + if post == nil { + return + } + + subscriptionChannelIDs := n.getNotificationChannelIDs(url, spaceKey, pageID, eventType, userID) + for _, channelID := range subscriptionChannelIDs { + post.ChannelId = channelID + if _, err := n.API.CreatePost(post); err != nil { + n.API.LogError("Unable to create Post in Mattermost", "Error", err.Error()) + } + } +} + +func (n *notification) extractSpaceKeyAndPageID(event serializer.ConfluenceEventV2, eventType string) (string, string) { + var spaceKey, pageID string + + switch { + case strings.Contains(eventType, Comment): + if e, ok := event.(*ConfluenceServerEvent); ok { + spaceKey = e.GetCommentSpaceKey() + pageID = e.GetCommentContainerID() + } + case strings.Contains(eventType, Page): + if e, ok := event.(*ConfluenceServerEvent); ok { + spaceKey = e.GetPageSpaceKey() + pageID = event.GetPageID() + } + case strings.Contains(eventType, Space): + spaceKey = event.GetSpaceKey() + if spaceKey != "" { + pageID = "" + } + } + + return spaceKey, pageID +} + +func (n *notification) getNotificationChannelIDs(url, spaceKey, pageID, eventType, userID string) []string { + urlSpaceKeySubscriptions, err := service.GetSubscriptionsByURLSpaceKey(url, spaceKey) + if err != nil { + n.API.LogError("Unable to get subscribed channels for spaceKey", "SpaceKey", spaceKey, "Error", err.Error()) + return nil + } + urlPageIDSubscriptions, err := service.GetSubscriptionsByURLPageID(url, pageID) + if err != nil { + n.API.LogError("Unable to get subscribed channels for page", "PageID", pageID, "Error", err.Error()) + return nil + } + + urlPageIDSubscriptionChannelIDs := GetURLSubscriptionChannelIDs(urlPageIDSubscriptions, eventType, userID) + urlSpaceKeySubscriptionChannelIDs := GetURLSubscriptionChannelIDs(urlSpaceKeySubscriptions, eventType, userID) + + return util.Deduplicate(append(urlSpaceKeySubscriptionChannelIDs, urlPageIDSubscriptionChannelIDs...)) +} + +func GetURLSubscriptionChannelIDs(urlSubscriptions serializer.StringArrayMap, eventType, userID string) []string { + var urlSubscriptionChannelIDs []string + + for channelID, events := range urlSubscriptions { + if funk.Contains(events, eventType) { + urlSubscriptionChannelIDs = append(urlSubscriptionChannelIDs, channelID) + } + } + + return urlSubscriptionChannelIDs +} + +func GetURLSubscriptionUserIDs(urlSubscriptions serializer.StringArrayMap, eventType string) []string { + var userIDs []string + + for id, events := range urlSubscriptions { + if funk.Contains(events, eventType) { + userIDs = append(userIDs, id) + } + } + + return userIDs +} diff --git a/server/oauth2.go b/server/oauth2.go new file mode 100644 index 00000000..40d3c5f1 --- /dev/null +++ b/server/oauth2.go @@ -0,0 +1,22 @@ +package main + +import "net/http" + +const ( + routeUserConnect = "/oauth2/connect" + routeUserComplete = "/oauth2/complete.html" +) + +var userConnect = &Endpoint{ + Path: routeUserConnect, + Method: http.MethodGet, + Execute: httpOAuth2Connect, + RequiresAdmin: false, +} + +var userConnectComplete = &Endpoint{ + Path: routeUserComplete, + Method: http.MethodGet, + Execute: httpOAuth2Complete, + RequiresAdmin: false, +} diff --git a/server/plugin.go b/server/plugin.go index 395052e1..a1dc7516 100644 --- a/server/plugin.go +++ b/server/plugin.go @@ -1,9 +1,11 @@ package main import ( + "fmt" "net/http" "os" "path/filepath" + "text/template" "github.com/gorilla/mux" "github.com/pkg/errors" @@ -20,6 +22,8 @@ const ( botUserName = "confluence" botDisplayName = "Confluence" botDescription = "Bot for confluence plugin." + + documentationURL = "https://github.com/mattermost-community/mattermost-plugin-confluence#readme" ) type Plugin struct { @@ -31,6 +35,11 @@ type Plugin struct { Router *mux.Router flowManager *FlowManager + + // templates are loaded on startup + templates map[string]*template.Template + + serverVersionGreaterthan9 bool } func (p *Plugin) OnActivate() error { @@ -48,6 +57,17 @@ func (p *Plugin) OnActivate() error { return err } + bundlePath, err := p.API.GetBundlePath() + if err != nil { + return errors.Wrap(err, "couldn't get bundle path") + } + + templates, err := p.loadTemplates(filepath.Join(bundlePath, "assets", "templates")) + if err != nil { + return err + } + p.templates = templates + flowManager, err := p.NewFlowManager() if err != nil { return errors.Wrap(err, "failed to create flow manager") @@ -146,6 +166,14 @@ func (p *Plugin) ServeHTTP(c *plugin.Context, w http.ResponseWriter, r *http.Req p.Router.ServeHTTP(w, r) } +func (p *Plugin) debugf(f string, args ...interface{}) { + p.API.LogDebug(fmt.Sprintf(f, args...)) +} + +func (p *Plugin) errorf(f string, args ...interface{}) { + p.API.LogError(fmt.Sprintf(f, args...)) +} + func main() { plugin.ClientMain(&Plugin{}) } diff --git a/server/save_subscription.go b/server/save_subscription.go index 1eccf0ef..e627d5f7 100644 --- a/server/save_subscription.go +++ b/server/save_subscription.go @@ -21,7 +21,7 @@ var saveChannelSubscription = &Endpoint{ Execute: handleSaveSubscription, } -func handleSaveSubscription(w http.ResponseWriter, r *http.Request) { +func handleSaveSubscription(w http.ResponseWriter, r *http.Request, _ *Plugin) { params := mux.Vars(r) channelID := params["channelID"] subscriptionType := params["type"] diff --git a/server/serializer/channel_subscription.go b/server/serializer/channel_subscription.go index 87cf0244..f4d85daa 100644 --- a/server/serializer/channel_subscription.go +++ b/server/serializer/channel_subscription.go @@ -16,6 +16,7 @@ const ( PageRestoredEvent = "page_restored" PageRemovedEvent = "page_removed" SubscriptionTypeSpace = "space_subscription" + SpaceUpdatedEvent = "space_updated" SubscriptionTypePage = "page_subscription" aliasAlreadyExist = "a subscription with the same name already exists in this channel" @@ -58,14 +59,14 @@ type StringArrayMap map[string][]string type Subscriptions struct { ByChannelID map[string]StringSubscription - ByURLPagID map[string]StringArrayMap + ByURLPageID map[string]StringArrayMap ByURLSpaceKey map[string]StringArrayMap } func NewSubscriptions() *Subscriptions { return &Subscriptions{ ByChannelID: map[string]StringSubscription{}, - ByURLPagID: map[string]StringArrayMap{}, + ByURLPageID: map[string]StringArrayMap{}, ByURLSpaceKey: map[string]StringArrayMap{}, } } diff --git a/server/serializer/confluence_event.go b/server/serializer/confluence_event.go index 365bd85e..23fe5002 100644 --- a/server/serializer/confluence_event.go +++ b/server/serializer/confluence_event.go @@ -10,3 +10,11 @@ type ConfluenceEvent interface { GetSpaceKey() string GetPageID() string } + +// for handling of confluence server version greater than 9 notifications +type ConfluenceEventV2 interface { + GetNotificationPost(string, string, string) *model.Post + GetURL() string + GetSpaceKey() string + GetPageID() string +} diff --git a/server/serializer/confluence_server.go b/server/serializer/confluence_server.go index c310bb5b..b4847fed 100644 --- a/server/serializer/confluence_server.go +++ b/server/serializer/confluence_server.go @@ -152,6 +152,28 @@ type ConfluenceServerEvent struct { Timestamp int64 `json:"timestamp"` } +type CommentPayload struct { + ID int64 `json:"id"` +} + +type PagePayload struct { + ID int64 `json:"id"` +} + +type SpacePayload struct { + ID int64 `json:"id"` + SpaceKey string `json:"spaceKey"` +} + +type ConfluenceServerWebhookPayload struct { + Timestamp int64 `json:"timestamp"` + Event string `json:"event"` + UserKey string `json:"userKey"` + Comment CommentPayload `json:"comment"` + Page PagePayload `json:"page"` + Space SpacePayload `json:"space"` +} + func ConfluenceServerEventFromJSON(data io.Reader) *ConfluenceServerEvent { var confluenceServerEvent ConfluenceServerEvent if err := json.NewDecoder(data).Decode(&confluenceServerEvent); err != nil { diff --git a/server/serializer/page_subscription.go b/server/serializer/page_subscription.go index 8e050f95..21e54fc1 100644 --- a/server/serializer/page_subscription.go +++ b/server/serializer/page_subscription.go @@ -22,16 +22,16 @@ func (ps PageSubscription) Add(s *Subscriptions) { } s.ByChannelID[ps.ChannelID][ps.Alias] = ps key := store.GetURLPageIDCombinationKey(ps.BaseURL, ps.PageID) - if _, ok := s.ByURLPagID[key]; !ok { - s.ByURLPagID[key] = make(map[string][]string) + if _, ok := s.ByURLPageID[key]; !ok { + s.ByURLPageID[key] = make(map[string][]string) } - s.ByURLPagID[key][ps.ChannelID] = ps.Events + s.ByURLPageID[key][ps.ChannelID] = ps.Events } func (ps PageSubscription) Remove(s *Subscriptions) { delete(s.ByChannelID[ps.ChannelID], ps.Alias) key := store.GetURLPageIDCombinationKey(ps.BaseURL, ps.PageID) - delete(s.ByURLPagID[key], ps.ChannelID) + delete(s.ByURLPageID[key], ps.ChannelID) } func (ps PageSubscription) Edit(s *Subscriptions) { @@ -90,7 +90,7 @@ func (ps PageSubscription) ValidateSubscription(subs *Subscriptions) error { } } key := store.GetURLPageIDCombinationKey(ps.BaseURL, ps.PageID) - if urlPageIDSubscriptions, valid := subs.ByURLPagID[key]; valid { + if urlPageIDSubscriptions, valid := subs.ByURLPageID[key]; valid { if _, ok := urlPageIDSubscriptions[ps.ChannelID]; ok { return errors.New(urlPageIDAlreadyExist) } diff --git a/server/service/delete_subscription_test.go b/server/service/delete_subscription_test.go index 5c783180..6ca79f69 100644 --- a/server/service/delete_subscription_test.go +++ b/server/service/delete_subscription_test.go @@ -84,7 +84,7 @@ func TestDeleteSubscription(t *testing.T) { "testtesttesttest": {serializer.CommentRemovedEvent, serializer.CommentUpdatedEvent}, }, }, - ByURLPagID: map[string]serializer.StringArrayMap{ + ByURLPageID: map[string]serializer.StringArrayMap{ "testKey1": { "testtesttesttes1": {serializer.CommentCreatedEvent, serializer.CommentUpdatedEvent}, }, diff --git a/server/service/get_subscription_list.go b/server/service/get_subscription_list.go index 96d648e1..9485d0cb 100644 --- a/server/service/get_subscription_list.go +++ b/server/service/get_subscription_list.go @@ -46,5 +46,5 @@ func GetSubscriptionsByURLPageID(url, pageID string) (serializer.StringArrayMap, if err != nil { return nil, err } - return subscriptions.ByURLPagID[key], nil + return subscriptions.ByURLPageID[key], nil } diff --git a/server/service/get_subscription_list_test.go b/server/service/get_subscription_list_test.go index fdb29ae1..967f644c 100644 --- a/server/service/get_subscription_list_test.go +++ b/server/service/get_subscription_list_test.go @@ -98,7 +98,7 @@ func TestGetSubscriptionsByChannelID(t *testing.T) { "testtesttesttes1": {serializer.CommentRemovedEvent, serializer.CommentUpdatedEvent}, }, }, - ByURLPagID: map[string]serializer.StringArrayMap{ + ByURLPageID: map[string]serializer.StringArrayMap{ "confluence_subs/test.com/1234": { "testtesttesttes1": {serializer.CommentCreatedEvent, serializer.CommentUpdatedEvent}, }, @@ -201,7 +201,7 @@ func TestGetSubscriptionsByURLPageID(t *testing.T) { "testtesttesttes1": {serializer.CommentRemovedEvent, serializer.CommentUpdatedEvent}, }, }, - ByURLPagID: map[string]serializer.StringArrayMap{ + ByURLPageID: map[string]serializer.StringArrayMap{ "confluence_subs/test.com/1234": { "testtesttesttes1": {serializer.CommentCreatedEvent, serializer.CommentUpdatedEvent}, }, @@ -320,7 +320,7 @@ func TestGetSubscriptionsByURLSpaceKey(t *testing.T) { "testtesttesttes1": {serializer.CommentRemovedEvent, serializer.CommentUpdatedEvent}, }, }, - ByURLPagID: map[string]serializer.StringArrayMap{ + ByURLPageID: map[string]serializer.StringArrayMap{ "confluence_subs/test.com/1234": { "testtesttesttes1": {serializer.CommentCreatedEvent, serializer.CommentUpdatedEvent}, }, diff --git a/server/service/get_subscription_test.go b/server/service/get_subscription_test.go index 056de0ee..0a8965e0 100644 --- a/server/service/get_subscription_test.go +++ b/server/service/get_subscription_test.go @@ -63,7 +63,7 @@ func TestGetChannelSubscription(t *testing.T) { "testtesttesttest": {serializer.CommentRemovedEvent, serializer.CommentUpdatedEvent}, }, }, - ByURLPagID: map[string]serializer.StringArrayMap{ + ByURLPageID: map[string]serializer.StringArrayMap{ "confluence_subs/test.com/1234": { "testtesttesttes1": {serializer.CommentCreatedEvent, serializer.CommentUpdatedEvent}, }, diff --git a/server/service/save_subscription_test.go b/server/service/save_subscription_test.go index f586c5e7..160ed36e 100644 --- a/server/service/save_subscription_test.go +++ b/server/service/save_subscription_test.go @@ -102,7 +102,7 @@ func TestSaveSpaceSubscription(t *testing.T) { "testtesttesttest": {serializer.CommentRemovedEvent, serializer.CommentUpdatedEvent}, }, }, - ByURLPagID: map[string]serializer.StringArrayMap{ + ByURLPageID: map[string]serializer.StringArrayMap{ "confluence_subs/test.com/1234": { "testtesttesttes1": {serializer.CommentCreatedEvent, serializer.CommentUpdatedEvent}, }, @@ -214,7 +214,7 @@ func TestSavePageSubscription(t *testing.T) { "testtesttesttest": {serializer.CommentRemovedEvent, serializer.CommentUpdatedEvent}, }, }, - ByURLPagID: map[string]serializer.StringArrayMap{ + ByURLPageID: map[string]serializer.StringArrayMap{ "confluence_subs/test.com/1234": { "testtesttesttes1": {serializer.CommentCreatedEvent, serializer.CommentUpdatedEvent}, }, diff --git a/server/store/store.go b/server/store/store.go index 81a54e8c..dfe62ad9 100644 --- a/server/store/store.go +++ b/server/store/store.go @@ -1,7 +1,8 @@ package store import ( - "bytes" + "bytes" // #nosec G501 + "encoding/json" "fmt" url2 "net/url" "time" @@ -11,9 +12,25 @@ import ( "github.com/mattermost/mattermost-plugin-confluence/server/config" "github.com/mattermost/mattermost-plugin-confluence/server/util" + "github.com/mattermost/mattermost-plugin-confluence/server/util/types" ) -const ConfluenceSubscriptionKeyPrefix = "confluence_subs" +const ( + prefixOneTimeSecret = "ots_" // + unique key that will be deleted after the first verification + ConfluenceSubscriptionKeyPrefix = "confluence_subs" + expiryStoreTimeoutSeconds = 15 * 60 + keyTokenSecret = "token_secret" + keyRSAKey = "rsa_key" + prefixUser = "user_" + AdminMattermostUserID = "admin" +) + +var ErrNotFound = errors.New("not found") + +// lint is suggesting to rename the function names from `storeConnection` to `Connection` so that when the function is accessed from any other package +// it looks like `store.Connnection, but this reduces the readibility within the function` + +// revive:disable:exported func GetURLSpaceKeyCombinationKey(url, spaceKey string) string { u, _ := url2.Parse(url) @@ -78,3 +95,158 @@ func AtomicModify(key string, modify func(initialValue []byte) ([]byte, error)) return nil } + +func keyWithInstanceID(instanceID, key string) string { + return fmt.Sprintf("%s_%s", instanceID, key) +} + +func hashkey(prefix, key string) string { + return fmt.Sprintf("%s_%s", prefix, key) +} + +func get(key string, v interface{}) (returnErr error) { + data, appErr := config.Mattermost.KVGet(key) + if appErr != nil { + return appErr + } + if data == nil { + return ErrNotFound + } + + if err := json.Unmarshal(data, v); err != nil { + return err + } + + return nil +} + +func set(key string, v interface{}) (returnErr error) { + data, err := json.Marshal(v) + if err != nil { + return err + } + + if appErr := config.Mattermost.KVSet(key, data); appErr != nil { + return appErr + } + return nil +} + +func Load(key string) ([]byte, error) { + data, appErr := config.Mattermost.KVGet(key) + if appErr != nil { + return nil, errors.WithMessage(appErr, "failed plugin KVGet") + } + if data == nil { + return nil, errors.Wrap(ErrNotFound, key) + } + return data, nil +} + +func StoreOAuth2State(state string) error { + if appErr := config.Mattermost.KVSetWithExpiry(hashkey(prefixOneTimeSecret, state), []byte(state), expiryStoreTimeoutSeconds); appErr != nil { + return errors.WithMessage(appErr, "failed to store state "+state) + } + return nil +} + +func VerifyOAuth2State(state string) error { + data, appErr := config.Mattermost.KVGet(hashkey(prefixOneTimeSecret, state)) + if appErr != nil { + return errors.WithMessage(appErr, "failed to load state "+state) + } + + if string(data) != state { + return errors.New("invalid oauth state, please try again") + } + + return nil +} + +func StoreConnection(instanceID, mattermostUserID string, connection *types.Connection) (returnErr error) { + if err := set(keyWithInstanceID(instanceID, mattermostUserID), connection); err != nil { + return err + } + + if err := set(keyWithInstanceID(instanceID, connection.ConfluenceAccountID()), mattermostUserID); err != nil { + return err + } + + // Also store AccountID -> mattermostUserID because Confluence Cloud is deprecating the name field + // https://developer.atlassian.com/cloud/Confluence/platform/api-changes-for-user-privacy-announcement/ + if err := set(keyWithInstanceID(instanceID, connection.ConfluenceAccountID()), mattermostUserID); err != nil { + return err + } + + config.Mattermost.LogDebug("Stored: connection, keys:\n\t%s (%s): %+v\n\t%s (%s): %s", + keyWithInstanceID(instanceID, mattermostUserID), mattermostUserID, connection, + keyWithInstanceID(instanceID, connection.ConfluenceAccountID()), connection.ConfluenceAccountID(), mattermostUserID) + + return nil +} + +func GetMattermostUserIDFromConfluenceID(instanceID, confluenceAccountID string) (*string, error) { + var mmUserID string + + if err := get(keyWithInstanceID(instanceID, confluenceAccountID), &mmUserID); err != nil { + return nil, err + } + + return &mmUserID, nil +} + +func LoadConnection(instanceID, mattermostUserID string) (*types.Connection, error) { + c := &types.Connection{} + if err := get(keyWithInstanceID(instanceID, mattermostUserID), c); err != nil { + return nil, errors.Wrapf(err, + "failed to load connection for Mattermost user ID:%q, Confluence:%q", mattermostUserID, instanceID) + } + return c, nil +} + +func DeleteConnection(instanceID, mattermostUserID string) (returnErr error) { + c, err := LoadConnection(instanceID, mattermostUserID) + if err != nil { + return err + } + + if err = DeleteConnectionFromKVStore(instanceID, mattermostUserID, c); err != nil { + return err + } + + return nil +} + +func DeleteConnectionFromKVStore(instanceID, mattermostUserID string, c *types.Connection) error { + if appErr := config.Mattermost.KVDelete(keyWithInstanceID(instanceID, mattermostUserID)); appErr != nil { + return appErr + } + + if appErr := config.Mattermost.KVDelete(keyWithInstanceID(instanceID, c.ConfluenceAccountID())); appErr != nil { + return appErr + } + + config.Mattermost.LogDebug("Deleted: user, keys: %s(%s), %s(%s)", + mattermostUserID, keyWithInstanceID(instanceID, mattermostUserID), + c.ConfluenceAccountID(), keyWithInstanceID(instanceID, c.ConfluenceAccountID())) + return nil +} + +func LoadUser(mattermostUserID string) (*types.User, error) { + user := types.NewUser(mattermostUserID) + key := hashkey(prefixUser, mattermostUserID) + if err := get(key, user); err != nil { + return nil, errors.WithMessage(err, fmt.Sprintf("failed to load confluence user for mattermostUserId:%s", mattermostUserID)) + } + return user, nil +} + +func StoreUser(user *types.User) (returnErr error) { + key := hashkey(prefixUser, user.MattermostUserID) + if err := set(key, user); err != nil { + return err + } + + config.Mattermost.LogDebug("Stored: user %s key:%s: connected to:%q", user.MattermostUserID, key, user.InstanceURL) + return nil +} diff --git a/server/user.go b/server/user.go new file mode 100644 index 00000000..327a1e9b --- /dev/null +++ b/server/user.go @@ -0,0 +1,304 @@ +package main + +import ( + "context" + "fmt" + "net/http" + "strings" + "time" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/pkg/errors" + "golang.org/x/oauth2" + + "github.com/mattermost/mattermost-plugin-confluence/server/config" + "github.com/mattermost/mattermost-plugin-confluence/server/store" + + "github.com/mattermost/mattermost-plugin-confluence/server/util/types" +) + +const ( + AdminMattermostUserID = "admin" +) + +func httpOAuth2Connect(w http.ResponseWriter, r *http.Request, p *Plugin) { + if r.Method != http.MethodGet { + _, _ = respondErr(w, http.StatusMethodNotAllowed, + errors.New("method "+r.Method+" is not allowed, must be GET")) + return + } + + isAdmin := IsAdmin(w, r) + + mattermostUserID := r.Header.Get("Mattermost-User-Id") + if mattermostUserID == "" { + _, _ = respondErr(w, http.StatusUnauthorized, + errors.New("not authorized")) + return + } + + instanceURL := config.GetConfig().GetConfluenceBaseURL() + if instanceURL == "" { + http.Error(w, "missing Confluence base url. Please run `/confluence install server`", http.StatusInternalServerError) + return + } + + connection, err := store.LoadConnection(instanceURL, mattermostUserID) + if err == nil && len(connection.ConfluenceAccountID()) != 0 { + _, _ = respondErr(w, http.StatusBadRequest, + errors.New("you already have a Confluence account linked to your Mattermost account. Please use `/confluence disconnect` to disconnect")) + return + } + + redirectURL, err := p.getUserConnectURL(instanceURL, mattermostUserID, isAdmin) + if err != nil { + _, _ = respondErr(w, http.StatusInternalServerError, err) + return + } + + http.Redirect(w, r, redirectURL, http.StatusFound) +} + +func httpOAuth2Complete(w http.ResponseWriter, r *http.Request, p *Plugin) { + var err error + var status int + // Prettify error output + defer func() { + if err == nil { + return + } + + errText := err.Error() + if len(errText) > 0 { + errText = strings.ToUpper(errText[:1]) + errText[1:] + } + status, err = p.respondTemplate(w, "/other/message.html", nil, status, "text/html", struct { + Header string + Message string + }{ + Header: "Failed to connect to Confluence.", + Message: errText, + }) + }() + + code := r.URL.Query().Get("code") + if code == "" { + http.Error(w, "missing authorization code", http.StatusBadRequest) + return + } + + state := r.URL.Query().Get("state") + if state == "" { + http.Error(w, "missing authorization state", http.StatusBadRequest) + return + } + + mattermostUserID := r.Header.Get(config.HeaderMattermostUserID) + if mattermostUserID == "" { + http.Error(w, "not authorized", http.StatusUnauthorized) + return + } + + instanceURL := config.GetConfig().GetConfluenceBaseURL() + if instanceURL == "" { + http.Error(w, "missing confluence base url", http.StatusInternalServerError) + return + } + + isAdmin := IsAdmin(w, r) + + cuser, mmuser, err := p.CompleteOAuth2(mattermostUserID, code, state, instanceURL, isAdmin) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + _, _ = p.respondTemplate(w, "", r, http.StatusOK, "text/html", struct { + MattermostDisplayName string + ConfluenceDisplayName string + }{ + ConfluenceDisplayName: cuser.DisplayName + " (" + cuser.Name + ")", + MattermostDisplayName: mmuser.GetDisplayName(model.ShowNicknameFullName), + }) +} + +func (p *Plugin) CompleteOAuth2(mattermostUserID, code, state string, instanceID string, isAdmin bool) (*types.ConfluenceUser, *model.User, error) { + if mattermostUserID == "" || code == "" || state == "" { + return nil, nil, errors.New("missing user, code or state") + } + + if err := store.VerifyOAuth2State(state); err != nil { + return nil, nil, errors.WithMessage(err, "missing stored state") + } + + mmuser, appErr := p.API.GetUser(mattermostUserID) + if appErr != nil { + return nil, nil, fmt.Errorf("failed to load user %s", mattermostUserID) + } + + oconf, err := p.GetServerOAuth2Config(instanceID, isAdmin) + if err != nil { + return nil, nil, err + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + tok, err := oconf.Exchange(ctx, code) + if err != nil { + return nil, nil, err + } + + encryptedToken, err := p.NewEncodedAuthToken(tok) + if err != nil { + return nil, nil, err + } + + connection := &types.Connection{ + OAuth2Token: encryptedToken, + IsAdmin: isAdmin, + MattermostUserID: mattermostUserID, + } + + client, err := p.GetServerClient(instanceID, connection) + if err != nil { + return nil, nil, err + } + + confluenceUser, err := client.GetSelf() + if err != nil { + return nil, nil, err + } + connection.ConfluenceUser = *confluenceUser + + err = p.connectUser(instanceID, mattermostUserID, connection) + if err != nil { + return nil, nil, err + } + + return &connection.ConfluenceUser, mmuser, nil +} + +func (p *Plugin) getUserConnectURL(instanceID string, mattermostUserID string, isAdmin bool) (string, error) { + conf, err := p.GetServerOAuth2Config(instanceID, isAdmin) + if err != nil { + return "", err + } + state := fmt.Sprintf("%v_%v", model.NewId()[0:15], mattermostUserID) + if isAdmin { + state = fmt.Sprintf("%v_%v", state, AdminMattermostUserID) + } + if err = store.StoreOAuth2State(state); err != nil { + return "", err + } + + return conf.AuthCodeURL(state, oauth2.AccessTypeOffline), nil +} + +func (p *Plugin) DisconnectUser(instanceURL string, mattermostUserID string) (*types.Connection, error) { + user, err := store.LoadUser(mattermostUserID) + if err != nil { + return nil, err + } + + return p.disconnectUser(instanceURL, user) +} + +func (p *Plugin) disconnectUser(instanceID string, user *types.User) (*types.Connection, error) { + if user.InstanceURL != instanceID { + return nil, errors.Wrapf(store.ErrNotFound, "user is not connected to %q", instanceID) + } + + conn, err := store.LoadConnection(instanceID, user.MattermostUserID) + if err != nil { + return nil, err + } + + if user.InstanceURL == instanceID { + user.InstanceURL = "" + } + + if err = store.DeleteConnection(instanceID, user.MattermostUserID); err != nil && errors.Cause(err) != store.ErrNotFound { + return nil, err + } + + if err = store.StoreUser(user); err != nil { + return nil, err + } + + return conn, nil +} + +func (p *Plugin) connectUser(instanceID, mattermostUserID string, connection *types.Connection) error { + user, err := store.LoadUser(mattermostUserID) + if err != nil { + if errors.Cause(err) != store.ErrNotFound { + return err + } + user = types.NewUser(mattermostUserID) + } + user.InstanceURL = instanceID + + if err = store.StoreConnection(instanceID, mattermostUserID, connection); err != nil { + return err + } + + if err = store.StoreConnection(instanceID, mattermostUserID, connection); err != nil { + return err + } + + if err = store.StoreConnection(instanceID, AdminMattermostUserID, connection); err != nil { + return err + } + + if err = store.StoreUser(user); err != nil { + return err + } + + if err = p.flowManager.StartCompletionWizard(mattermostUserID); err != nil { + return err + } + + return nil +} + +// refreshAndStoreToken checks whether the current access token is expired or not. If it is, +// then it refreshes the token and stores the new pair of access and refresh tokens in kv store. +func (p *Plugin) refreshAndStoreToken(connection *types.Connection, instanceID string, oconf *oauth2.Config) (*oauth2.Token, error) { + token, err := p.ParseAuthToken(connection.OAuth2Token) + if err != nil { + return nil, err + } + + // If there is only one minute left for the token to expire, we are refreshing the token. + // We don't want the token to expire between the time when we decide that the old token is valid + // and the time at which we create the request. We are handling that by not letting the token expire. + if time.Until(token.Expiry) > 1*time.Minute { + return token, nil + } + + src := oconf.TokenSource(context.Background(), token) + newToken, err := src.Token() // this actually goes and renews the tokens + if err != nil { + return nil, errors.Wrap(err, "unable to get the new refreshed token") + } + if newToken.AccessToken != token.AccessToken { + encryptedToken, err := p.NewEncodedAuthToken(newToken) + if err != nil { + return nil, err + } + connection.OAuth2Token = encryptedToken + + if err = store.StoreConnection(instanceID, connection.MattermostUserID, connection); err != nil { + return nil, err + } + + if connection.IsAdmin { + if err = store.StoreConnection(instanceID, AdminMattermostUserID, connection); err != nil { + return nil, err + } + } + return newToken, nil + } + + return token, nil +} diff --git a/server/util/types/connection.go b/server/util/types/connection.go new file mode 100644 index 00000000..a6b43319 --- /dev/null +++ b/server/util/types/connection.go @@ -0,0 +1,49 @@ +package types + +type User struct { + MattermostUserID string `json:"mattermost_user_id"` + InstanceURL string `json:"instance_url,omitempty"` +} + +type ConfluenceUser struct { + AccountID string `json:"accountId,omitempty"` + Name string `json:"username,omitempty"` + DisplayName string `json:"displayName,omitempty"` +} + +type UserGroups struct { + Groups []*UserGroup `json:"results,omitempty"` +} + +type UserGroup struct { + Name string `json:"name"` +} + +type Connection struct { + ConfluenceUser + OAuth2Token string `json:"token,omitempty"` + DefaultProjectKey string `json:"default_project_key,omitempty"` + IsAdmin bool `json:"is_admin,omitempty"` + MattermostUserID string `json:"mattermost_user_id,omitempty"` +} + +func (c *Connection) ConfluenceAccountID() string { + if c.AccountID != "" { + return c.AccountID + } + + return c.Name +} + +func NewUser(mattermostUserID string) *User { + return &User{ + MattermostUserID: mattermostUserID, + } +} + +func (user *User) AsConfigMap() map[string]interface{} { + return map[string]interface{}{ + "mattermost_user_id": user.MattermostUserID, + "instance_url": user.InstanceURL, + } +} diff --git a/server/util/util.go b/server/util/util.go index f798f308..545cc077 100644 --- a/server/util/util.go +++ b/server/util/util.go @@ -4,15 +4,22 @@ import ( "crypto/sha256" "encoding/base64" "errors" + "fmt" "net/url" "regexp" "strings" + html "github.com/levigross/exp-html" "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost-plugin-confluence/server/config" ) +const ( + Script = "script" + Style = "style" +) + // GetKeyHash can be used to create a hash from a string func GetKeyHash(key string) string { hash := sha256.New() @@ -107,3 +114,34 @@ func Deduplicate(a []string) []string { return result } + +func GetBodyForExcerpt(htmlBodyValue string) string { + var str string + domDoc := html.NewTokenizer(strings.NewReader(htmlBodyValue)) + var previousStartToken html.Token + + for { + tt := domDoc.Next() + switch tt { + case html.ErrorToken: + return str // End of the document, return extracted text + case html.StartTagToken: + previousStartToken = domDoc.Token() + case html.TextToken: + if previousStartToken.Data == Script || previousStartToken.Data == Style { + continue + } + textContent := strings.TrimSpace(html.UnescapeString(string(domDoc.Text()))) + if len(textContent) > 0 { + str = fmt.Sprintf("%s\n%s", str, textContent) + } + } + } +} + +func GetUsernameOrAnonymousName(username string) string { + if username == "" { + return "Someone" + } + return username +}