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 @@
+
+
+
+
+
+
+
+
+
+
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" +
" \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
+}