diff --git a/.golangci.yml b/.golangci.yml index 771c0e8..73e063f 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -47,9 +47,9 @@ issues: - deadcode - unused - varcheck - - path: server/configuration.go + - path: server/util/hash.go linters: - - unused + - gosec - path: _test\.go linters: - bodyclose diff --git a/README.md b/README.md index 7b951b6..8aff89d 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,10 @@ -# Plugin Starter Template [![CircleCI branch](https://img.shields.io/circleci/project/github/mattermost/mattermost-plugin-starter-template/master.svg)](https://circleci.com/gh/mattermost/mattermost-plugin-starter-template) +# Plugin Starter Template [![CircleCI branch](https://img.shields.io/circleci/project/github/mattermost/mattermost-plugin-twitter/master.svg)](https://circleci.com/gh/mattermost/mattermost-plugin-starter-template) -This plugin serves as a starting point for writing a Mattermost plugin. Feel free to base your own plugin off this repository. - -To learn more about plugins, see [our plugin documentation](https://developers.mattermost.com/extend/plugins/). +A Mattermost plugin to connect to twitter. ## Getting Started -Use GitHub's template feature to make a copy of this repository by clicking the "Use this template" button. - -Alternatively shallow clone the repository matching your plugin name: -``` -git clone --depth 1 https://github.com/mattermost/mattermost-plugin-starter-template com.example.my-plugin -``` - -Note that this project uses [Go modules](https://github.com/golang/go/wiki/Modules). Be sure to locate the project outside of `$GOPATH`. -Edit `plugin.json` with your `id`, `name`, and `description`: -``` -{ - "id": "com.example.my-plugin", - "name": "My Plugin", - "description": "A plugin to enhance Mattermost." -} -``` +To learn more about plugins, see [our plugin documentation](https://developers.mattermost.com/extend/plugins/). Build your plugin: ``` @@ -31,9 +14,27 @@ make This will produce a single plugin file (with support for multiple architectures) for upload to your Mattermost server: ``` -dist/com.example.my-plugin.tar.gz +dist/com.mattermost.twitter.tar.gz ``` +## Configuration + +Getting the Twitter Consumer Key (API Key) and Consumer Secret key is very simple, just follow the below 4 steps and you are ready to go. + +- Go to https://dev.twitter.com/apps/new and log in, if necessary +- Supply the necessary required fields, accept the Terms Of Service, and solve the CAPTCHA. +- Submit the form +- Go to the API Keys tab, there you will find your Consumer key and Consumer secret keys. +- Copy the consumer key (API key) and consumer secret from the screen into our application. + + +Enable the 3-legged OAuth. +- In your app settings page of the app you just created, select `Enable 3-legged OAuth`. +https://developer.twitter.com/en/portal/projects//apps//auth-settings + +- Set the callbackURL to `/plugins/com.mattermost.twitter/twitter/callback`. +- Set the Website URL to `your-mattermost-url`. + ## Development To avoid having to manually install your plugin, build and deploy your plugin using one of the following options. @@ -86,32 +87,3 @@ export MM_SERVICESETTINGS_SITEURL=http://localhost:8065 export MM_ADMIN_TOKEN=j44acwd8obn78cdcx7koid4jkr make deploy ``` - -## Q&A - -### How do I make a server-only or web app-only plugin? - -Simply delete the `server` or `webapp` folders and remove the corresponding sections from `plugin.json`. The build scripts will skip the missing portions automatically. - -### How do I include assets in the plugin bundle? - -Place them into the `assets` directory. To use an asset at runtime, build the path to your asset and open as a regular file: - -```go -bundlePath, err := p.API.GetBundlePath() -if err != nil { - return errors.Wrap(err, "failed to get bundle path") -} - -profileImage, err := ioutil.ReadFile(filepath.Join(bundlePath, "assets", "profile_image.png")) -if err != nil { - return errors.Wrap(err, "failed to read profile image") -} - -if appErr := p.API.SetProfileImage(userID, profileImage); appErr != nil { - return errors.Wrap(err, "failed to set profile image") -} -``` - -### How do I build the plugin with unminified JavaScript? -Setting the `MM_DEBUG` environment variable will invoke the debug builds. The simplist way to do this is to simply include this variable in your calls to `make` (e.g. `make dist MM_DEBUG=1`). diff --git a/assets/logo.svg b/assets/logo.svg new file mode 100755 index 0000000..0cd7667 --- /dev/null +++ b/assets/logo.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/templates/oauth1-complete.html b/assets/templates/oauth1-complete.html new file mode 100644 index 0000000..1c6b42b --- /dev/null +++ b/assets/templates/oauth1-complete.html @@ -0,0 +1,41 @@ + + + + + + mattermost-plugin-twitter + + +
+

+ + + + Mattermost user is now connected to Twitter. +

+
+
Mattermost account: {{ .MattermostDisplayName }}
+
Twitter account: {{ .TwitterDisplayName }}
+
+ You can use `/twitter disconnect` to disconnect your account. It is now safe to close this browser window. +
+ + diff --git a/assets/twitter.png b/assets/twitter.png new file mode 100755 index 0000000..af44ca5 Binary files /dev/null and b/assets/twitter.png differ diff --git a/go.mod b/go.mod index 9966bd6..267e404 100644 --- a/go.mod +++ b/go.mod @@ -1,9 +1,13 @@ -module github.com/mattermost/mattermost-plugin-starter-template +module github.com/mattermost/mattermost-plugin-twitter go 1.12 require ( + github.com/dghubble/go-twitter v0.0.0-20201011215211-4b180d0cc78d + github.com/dghubble/oauth1 v0.6.0 + github.com/gorilla/mux v1.7.4 + github.com/mattermost/mattermost-plugin-api v0.0.12 github.com/mattermost/mattermost-server/v5 v5.26.2 github.com/pkg/errors v0.9.1 - github.com/stretchr/testify v1.6.1 + go.uber.org/atomic v1.6.0 ) diff --git a/go.sum b/go.sum index 603bd8a..83e4727 100644 --- a/go.sum +++ b/go.sum @@ -13,6 +13,7 @@ git.apache.org/thrift.git v0.12.0/go.mod h1:fPE2ZNJGynbRyZ4dJvy6G277gSllfV2HJqbl github.com/AndreasBriese/bbloom v0.0.0-20190306092124-e2d15f34fcf9/go.mod h1:bOvUY6CB00SOBii9/FifXqc0awNKxLFCL/+pkDPuyl8= github.com/Azure/azure-sdk-for-go v26.5.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= github.com/Azure/go-autorest v11.5.2+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24= +github.com/BurntSushi/toml v0.3.0/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/CloudyKit/fastprinter v0.0.0-20170127035650-74b38d55f37a/go.mod h1:EFZQ978U7x8IRnstaskI3IysnWY5Ao3QgZUKOXlsAdw= @@ -68,6 +69,8 @@ github.com/blevesearch/zap/v14 v14.0.0/go.mod h1:sUc/gPGJlFbSQ2ZUh/wGRYwkKx+Dg/5 github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= github.com/bradfitz/go-smtpd v0.0.0-20170404230938-deb6d6237625/go.mod h1:HYsPBTaaSFSlLx/70C2HPIMNZpVV8+vt/A+FMnYP11g= github.com/buger/jsonparser v0.0.0-20181115193947-bf1c66bbce23/go.mod h1:bbYlZJ7hK1yFx9hf58LP0zeX7UjIGs20ufpu3evjr+s= +github.com/cenkalti/backoff v2.1.1+incompatible h1:tKJnvO2kl0zmb/jA5UKAt4VoEVw1qxKWjE/Bpp46npY= +github.com/cenkalti/backoff v2.1.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= github.com/census-instrumentation/opencensus-proto v0.2.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= @@ -98,6 +101,12 @@ github.com/cznic/strutil v0.0.0-20181122101858-275e90344537/go.mod h1:AHHPPPXTw0 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dghubble/go-twitter v0.0.0-20201011215211-4b180d0cc78d h1:sBKr0A8iQ1qAOozedZ8Aox+Jpv+TeP1Qv7dcQyW8V+M= +github.com/dghubble/go-twitter v0.0.0-20201011215211-4b180d0cc78d/go.mod h1:xfg4uS5LEzOj8PgZV7SQYRHbG7jPUnelEiaAVJxmhJE= +github.com/dghubble/oauth1 v0.6.0 h1:m1yC01Ohc/eF38jwZ8JUjL1a+XHHXtGQgK+MxQbmSx0= +github.com/dghubble/oauth1 v0.6.0/go.mod h1:8pFdfPkv/jr8mkChVbNVuJ0suiHe278BtWI4Tk1ujxk= +github.com/dghubble/sling v1.3.0 h1:pZHjCJq4zJvc6qVQ5wN1jo5oNZlNE0+8T/h0XeXBUKU= +github.com/dghubble/sling v1.3.0/go.mod h1:XXShWaBWKzNLhu2OxikSNFrlsvowtz4kyRuXUG7oQKY= github.com/dgraph-io/badger v1.6.0/go.mod h1:zwt7syl517jmP8s94KqSxTlM6IMsdhYy6psNgSztDR4= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/dgryski/dgoogauth v0.0.0-20190221195224-5a805980a5f3/go.mod h1:hEfFauPHz7+NnjR/yHJGhrKo1Za+zStgwUETx3yzqgY= @@ -153,6 +162,7 @@ github.com/go-asn1-ber/asn1-ber v1.5.1 h1:pDbRAunXzIUXfx4CB2QJFv5IuPiuoW+sWvr/Us github.com/go-asn1-ber/asn1-ber v1.5.1/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98= github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= +github.com/go-gorp/gorp v2.0.0+incompatible/go.mod h1:7IfkAQnO7jfT/9IQ3R9wL1dFhukN6aQxzKTHnkxzA/E= github.com/go-gorp/gorp v2.2.0+incompatible/go.mod h1:7IfkAQnO7jfT/9IQ3R9wL1dFhukN6aQxzKTHnkxzA/E= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= @@ -160,6 +170,7 @@ github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9 github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-martini/martini v0.0.0-20170121215854-22fa46961aab/go.mod h1:/P9AEU963A2AYjv4d1V5eVL1CQbEJq6aCNHDDjibzu8= github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= +github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs= github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo= @@ -175,6 +186,7 @@ github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4er github.com/golang/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1 h1:YF8+flBXS5eO826T4nzqPrxfhQThhXl0YzfuUPu4SBg= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -205,6 +217,7 @@ github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.0 h1:/QaMHBdZ26BB3SSst0Iwl10Epc+xhTquomWX0oZEB6w= github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ= +github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= @@ -221,6 +234,7 @@ github.com/gopherjs/gopherjs v0.0.0-20190910122728-9d188e94fb99/go.mod h1:wJfORR github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= github.com/gorilla/handlers v1.4.2/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ= github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= +github.com/gorilla/mux v1.7.4 h1:VuZ8uybHlWmqV03+zRzdwKL4tUnIp1MAQtp1mIFE1bc= github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/schema v1.1.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU= github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= @@ -307,6 +321,7 @@ github.com/klauspost/cpuid v1.2.3/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgo github.com/klauspost/cpuid v1.3.0/go.mod h1:bYW4mA6ZgKPob1/Dlai2LviZJO7KGI3uoWLd42rAQw4= github.com/kljensen/snowball v0.6.0/go.mod h1:27N7E8fVU5H68RlUmnWwZCfxgt4POBJfENGMvNRhldw= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.3 h1:CE8S1cTafDpPvMhIxNJKvHsGVBgn1xWYf1NbHQhywc8= github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= @@ -323,6 +338,7 @@ github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtB github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk= github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw= github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.7.0 h1:h93mCPfUSkaul3Ka/VG8uZdmW1uMHDGxzu0NWHuJmHY= github.com/lib/pq v1.7.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= @@ -338,6 +354,9 @@ github.com/mattermost/ldap v0.0.0-20191128190019-9f62ba4b8d4d h1:2DV7VIlEv6J5R5o github.com/mattermost/ldap v0.0.0-20191128190019-9f62ba4b8d4d/go.mod h1:HLbgMEI5K131jpxGazJ97AxfPDt31osq36YS1oxFQPQ= github.com/mattermost/logr v1.0.5 h1:TST38xROPguNh8o90BfDHpp1bz6HfTdFYX5Btw/oLwM= github.com/mattermost/logr v1.0.5/go.mod h1:YzldchiJXgF789YNDFGXVoCHTQOTrCKwWft9Fwev1iI= +github.com/mattermost/mattermost-plugin-api v0.0.12 h1:k4AMBHZGKLZp8kLWia2JLGvlneNEgPbpsOGa11YfMyE= +github.com/mattermost/mattermost-plugin-api v0.0.12/go.mod h1:2P5T6ixjcDquVrhVdPZ1ASBWiilsxbdK6yYaqdKN/dI= +github.com/mattermost/mattermost-server/v5 v5.3.2-0.20200731154015-c5c6a5ce5399/go.mod h1:1udHoNFxLFYZuS9g6/NkJkNQniAcQYVqVEbDPHSumE0= github.com/mattermost/mattermost-server/v5 v5.26.2 h1:2QUO4cMxaGO3hD/+iytiWoK612taDf6A+g3C2yNvobE= github.com/mattermost/mattermost-server/v5 v5.26.2/go.mod h1:TVLwNQLSPNIkFOLoGHCGjZbSc2JEQf5PHUbQvneUSGM= github.com/mattermost/rsc v0.0.0-20160330161541-bbaefb05eaa0/go.mod h1:nV5bfVpT//+B1RPD2JvRnxbkLmJEYXmRaaVl15fsXjs= @@ -397,6 +416,7 @@ github.com/neelance/sourcemap v0.0.0-20151028013722-8c68805598ab/go.mod h1:Qr6/a github.com/nelsam/hel/v2 v2.3.2/go.mod h1:1ZTGfU2PFTOd5mx22i5O0Lc2GY933lQ2wb/ggy+rL3w= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= github.com/ngdinhtoan/glide-cleanup v0.2.0/go.mod h1:UQzsmiDOb8YV3nOsCxK/c9zPpCZVNoHScRE3EO9pVMM= +github.com/nicksnyder/go-i18n/v2 v2.0.3/go.mod h1:oDab7q8XCYMRlcrBnaY/7B1eOectbvj6B1UPBT+p5jo= github.com/oklog/run v1.0.0 h1:Ru7dDtJNOyC66gQ5dQmaCa0qIsAUFY3sFpK1Xk8igrw= github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= @@ -413,6 +433,7 @@ github.com/onsi/gomega v1.4.3 h1:RE1xgDvH7imwFD45h+u2SgIfERHlS2yNG4DObb5BSKU= github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= github.com/onsi/gomega v1.9.0/go.mod h1:Ho0h+IUsWyvy1OpqCwxlQ/21gkhVunqlU8fDGcoTdcA= +github.com/oov/psd v0.0.0-20200705094106-99303fb2511f/go.mod h1:GHI1bnmAcbp96z6LNfBJvtrjxhaXGkbsk967utPlvL8= github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= github.com/openzipkin/zipkin-go v0.1.1/go.mod h1:NtoC/o8u3JlF1lSlyPNswIbeQH9bJTmOf0Erfk+hxe8= github.com/openzipkin/zipkin-go v0.1.3/go.mod h1:NtoC/o8u3JlF1lSlyPNswIbeQH9bJTmOf0Erfk+hxe8= @@ -468,6 +489,7 @@ github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsT github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= +github.com/proullon/ramsql v0.0.0-20181213202341-817cee58a244/go.mod h1:jG8oAQG0ZPHPyxg5QlMERS31airDC+ZuqiAe8DUvFVo= github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/rcrowley/go-metrics v0.0.0-20190826022208-cac0b30c2563/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/remyoudompheng/bigfft v0.0.0-20190728182440-6a916e37a237/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= @@ -511,6 +533,7 @@ github.com/shurcooL/webdavfs v0.0.0-20170829043945-18c3829fa133/go.mod h1:hKmq5k github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.5.0/go.mod h1:+F7Ogzej0PZc/94MaYx/nvG9jOFMD2osvC3s+Squfpo= +github.com/sirupsen/logrus v1.6.0 h1:UBcNElsrwanuuMsnGSlYmtmgbb23qDR5dG+6X6Oo89I= github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/assertions v1.0.0/go.mod h1:kHHU4qYBaI3q23Pp3VPrmWhuIUrLW/7eUrw0BU5VaoM= @@ -622,6 +645,7 @@ golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16/go.mod h1:6SG95UA2DQfeDnf golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190313024323-a1f597ede03a/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190506204251-e1dfcc566284/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190513172903-22d7a77e9e5f/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= @@ -681,6 +705,7 @@ golang.org/x/oauth2 v0.0.0-20181017192945-9dcd33a902f4/go.mod h1:N/0e6XlmueqKjAG 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.0.0-20190319182350-c85d3e98c914/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 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= @@ -704,6 +729,7 @@ golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190316082340-a2f829d7f35f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -721,6 +747,7 @@ golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae h1:Ih9Yo4hSPImZOpfGuA4bR/ORKTAbhZo2AbWNRCnevdo= golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= @@ -743,6 +770,8 @@ golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3 golang.org/x/tools v0.0.0-20190327201419-c70d86f8b7cf/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190424220101-1e8e1cfdf96b/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= @@ -771,6 +800,7 @@ google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7 google.golang.org/appengine v1.5.0 h1:KxkO13IPW4Lslp2bz+KHP2E3gtFlrIGNThxkZQ3g+4c= google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.6 h1:lMO5rYAqUxkmaj76jAkRUvt5JZgFymx/+Q5Mzfivuhc= google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/genproto v0.0.0-20170818010345-ee236bd376b0/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= @@ -856,6 +886,8 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= honnef.co/go/tools v0.0.1-2020.1.3 h1:sXmLre5bzIR6ypkjXCDI3jHPssRhc8KD/Ome589sc3U= honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= +rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= sourcegraph.com/sourcegraph/go-diff v0.5.0/go.mod h1:kuch7UrkMzY0X+p9CRK03kfuPQ2zzQcaEFbx8wA8rck= sourcegraph.com/sqs/pbtypes v0.0.0-20180604144634-d3ebe8f20ae4/go.mod h1:ketZ/q3QxT9HOBeFhu6RdvsftgpsbFHBF5Cas6cDKZ0= willnorris.com/go/gifresize v1.0.0/go.mod h1:eBM8gogBGCcaH603vxSpnfjwXIpq6nmnj/jauBDKtAk= diff --git a/plugin.json b/plugin.json index b5b9a76..aa41006 100644 --- a/plugin.json +++ b/plugin.json @@ -1,7 +1,7 @@ { - "id": "com.mattermost.plugin-starter-template", - "name": "Plugin Starter Template", - "description": "This plugin serves as a starting point for writing a Mattermost plugin.", + "id": "com.mattermost.twitter", + "name": "Twitter", + "description": "A Matermost plugin to connect to Twitter.", "version": "0.1.0", "min_server_version": "5.12.0", "server": { @@ -17,6 +17,19 @@ "settings_schema": { "header": "", "footer": "", - "settings": [] + "settings": [ + { + "key": "OAuthClientID", + "display_name": "OAuth Client ID:", + "type": "text", + "help_text": "The client ID for the OAuth app registered with Twitter." + }, + { + "key": "OAuthClientSecret", + "display_name": "OAuth Client Secret:", + "type": "text", + "help_text": "The client secret for the OAuth app registered with Twitter." + } + ] } } diff --git a/server/api/main.go b/server/api/main.go new file mode 100644 index 0000000..e5862d3 --- /dev/null +++ b/server/api/main.go @@ -0,0 +1,91 @@ +package api + +import ( + "net/http" + "path/filepath" + "runtime/debug" + + "github.com/gorilla/mux" + "github.com/mattermost/mattermost-server/v5/model" + "github.com/mattermost/mattermost-server/v5/plugin" + + "github.com/mattermost/mattermost-plugin-twitter/server/constant" + "github.com/mattermost/mattermost-plugin-twitter/server/store" +) + +const ( + HeaderMattermostUserID = "Mattermost-User-Id" +) + +type Controller struct { + api plugin.API + helpers plugin.Helpers + manifest *model.Manifest + store *store.Store +} + +func NewController(api plugin.API, helpers plugin.Helpers, manifest *model.Manifest, store *store.Store) *Controller { + return &Controller{ + api, + helpers, + manifest, + store, + } +} + +// InitAPI initializes the REST API +func (c *Controller) InitAPI() *mux.Router { + r := mux.NewRouter() + r.Use(c.withRecovery) + + c.handleStaticFiles(r) + s := r.PathPrefix("/api/v1").Subrouter() + + // Add the custom plugin routes here + s.HandleFunc(constant.PathTwitterOAuth1Callback, handleAuthRequired(c.twitterLoginCallback)).Methods(http.MethodGet) + + // 404 handler + r.Handle("{anything:.*}", http.NotFoundHandler()) + return r +} + +// From: https://github.com/mattermost/mattermost-plugin-github/blob/42185ff874963bed1efd8bc84c81462184d7cca8/server/plugin/api.go#L135 +func (c *Controller) withRecovery(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if x := recover(); x != nil { + c.api.LogError("Recovered from a panic", + "url", r.URL.String(), + "error", x, + "stack", string(debug.Stack())) + } + }() + + next.ServeHTTP(w, r) + }) +} + +// handleStaticFiles handles the static files under the assets directory. +func (c *Controller) handleStaticFiles(r *mux.Router) { + bundlePath, err := c.api.GetBundlePath() + if err != nil { + c.api.LogWarn("Failed to get bundle path.", "Error", err.Error()) + return + } + + // This will serve static files from the 'assets' directory under '/static/' + r.PathPrefix("/static/").Handler(http.StripPrefix("/static/", http.FileServer(http.Dir(filepath.Join(bundlePath, "assets"))))) +} + +// handleAuthRequired verifies if provided request is performed by a logged-in Mattermost user. +func handleAuthRequired(handleFunc func(w http.ResponseWriter, r *http.Request)) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + userID := r.Header.Get(HeaderMattermostUserID) + if userID == "" { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + handleFunc(w, r) + } +} diff --git a/server/api/oauth1.go b/server/api/oauth1.go new file mode 100644 index 0000000..d9822e0 --- /dev/null +++ b/server/api/oauth1.go @@ -0,0 +1,93 @@ +package api + +import ( + "fmt" + "net/http" + + "github.com/dghubble/go-twitter/twitter" + "github.com/dghubble/oauth1" + "github.com/mattermost/mattermost-server/v5/model" + + "github.com/mattermost/mattermost-plugin-twitter/server/serializers" + "github.com/mattermost/mattermost-plugin-twitter/server/util" +) + +func (c *Controller) twitterLoginCallback(w http.ResponseWriter, r *http.Request) { + requestToken, verifier, err := oauth1.ParseAuthorizationCallback(r) + if err != nil { + c.api.LogError("twitterLoginCallback: Failed to parse authorisation callback.", "Error", err.Error()) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + mmUserID := r.Header.Get("Mattermost-User-ID") + if mmUserID == "" { + c.api.LogError("twitterLoginCallback: Failed to get mattermost userID.") + http.Error(w, "not authorized", http.StatusUnauthorized) + return + } + + mmUser, appErr := c.api.GetUser(mmUserID) + if appErr != nil { + c.api.LogError("twitterLoginCallback: Failed to get mattermost user.", "Error", appErr.Error()) + http.Error(w, appErr.Error(), http.StatusInternalServerError) + return + } + + var oauthTmpCredentials serializers.OAuth1aTemporaryCredentials + if storeErr := c.store.LoadOneTimeSecretJSON(mmUserID, &oauthTmpCredentials); storeErr != nil || len(oauthTmpCredentials.Token) == 0 { + c.api.LogError(fmt.Sprintf("twitterLoginCallback: Failed to load oauth one-time secret. Error: %v", storeErr)) + http.Error(w, fmt.Sprintf("temporary credentials for %s not found or expired, try to connect again", mmUserID), http.StatusInternalServerError) + return + } + + if oauthTmpCredentials.Token != requestToken { + c.api.LogError("twitterLoginCallback: saved OAuth credentials and request token do not match.") + http.Error(w, "request token mismatch", http.StatusBadRequest) + return + } + + twitterOAuth1Config := util.GetTwitterOAuth1Config(c.api, c.manifest) + + // Twitter ignores the oauth_signature on the access token request. The user + // to which the request (temporary) token corresponds is already known on the + // server. The request for a request token earlier was validated signed by + // the consumer. Consumer applications can avoid keeping request token state + // between authorization granting and callback handling. + accessToken, accessSecret, err := twitterOAuth1Config.AccessToken(requestToken, "", verifier) + if err != nil { + c.api.LogError("twitterLoginCallback: Failed to get AccessToken from request.", "Error", err.Error()) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // Twitter client + client := util.GetTwitterClient(accessToken, accessSecret) + twAccount, resp, err := client.Accounts.VerifyCredentials(&twitter.AccountVerifyParams{}) + if err != nil { + c.api.LogError("twitterLoginCallback: Failed to verify twitter credentials for connected user.", "Error", err.Error()) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if resp != nil { + defer resp.Body.Close() + } + + twUser := &serializers.TwitterUser{ + Name: twAccount.Name, + Username: twAccount.ScreenName, + AccessToken: accessToken, + AccessSecret: accessSecret, + } + + if err := c.store.SaveTwitterUser(mmUserID, twUser); err != nil { + c.api.LogError("twitterLoginCallback: Failed to save twitter client to KVStore.", "Error", err.Error()) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + c.renderTemplate(w, "oauth1-complete.html", "text/html", map[string]string{ + "TwitterDisplayName": twUser.GetDisplayName(), + "MattermostDisplayName": mmUser.GetDisplayName(model.SHOW_NICKNAME_FULLNAME), + }) +} diff --git a/server/api/utils.go b/server/api/utils.go new file mode 100644 index 0000000..4bf18cf --- /dev/null +++ b/server/api/utils.go @@ -0,0 +1,31 @@ +package api + +import ( + "html/template" + "net/http" + "path" + "path/filepath" +) + +func (c *Controller) renderTemplate(w http.ResponseWriter, templateName, contentType string, values interface{}) { + bundlePath, err := c.api.GetBundlePath() + if err != nil { + c.api.LogWarn("Failed to get bundle path.", "Error", err.Error()) + return + } + + templateDir := filepath.Join(bundlePath, "assets", "templates") + tmplPath := path.Join(templateDir, templateName) + + tmpl, err := template.ParseFiles(tmplPath) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", contentType) + if err = tmpl.Execute(w, values); err != nil { + http.Error(w, "failed to write response: "+err.Error(), http.StatusInternalServerError) + return + } +} diff --git a/server/command/command.go b/server/command/command.go new file mode 100644 index 0000000..778b2e5 --- /dev/null +++ b/server/command/command.go @@ -0,0 +1,49 @@ +package command + +import ( + "strings" + + "github.com/mattermost/mattermost-server/v5/model" + "github.com/mattermost/mattermost-server/v5/plugin" + + "github.com/mattermost/mattermost-plugin-twitter/server/store" +) + +// Context includes the context in which the slash command is executed and allows access to +// plugin API, helpers and services +type Context struct { + *model.CommandArgs + context *plugin.Context + api plugin.API + helpers plugin.Helpers + manifest *model.Manifest + store *store.Store +} + +func NewContext(args *model.CommandArgs, context *plugin.Context, api plugin.API, helpers plugin.Helpers, manifest *model.Manifest, store *store.Store) *Context { + return &Context{ + args, + context, + api, + helpers, + manifest, + store, + } +} + +type HandlerFunc func(context *Context, args ...string) (*model.CommandResponse, *model.AppError) + +type Handler struct { + handlers map[string]HandlerFunc + defaultHandler HandlerFunc +} + +func (ch Handler) Handle(context *Context, args ...string) (*model.CommandResponse, *model.AppError) { + for n := len(args); n > 0; n-- { + h := ch.handlers[strings.Join(args[:n], "/")] + if h != nil { + return h(context, args[n:]...) + } + } + return ch.defaultHandler(context, args...) +} diff --git a/server/command/twitter.go b/server/command/twitter.go new file mode 100644 index 0000000..b6d423e --- /dev/null +++ b/server/command/twitter.go @@ -0,0 +1,95 @@ +package command + +import ( + "fmt" + + "github.com/mattermost/mattermost-server/v5/model" + + "github.com/mattermost/mattermost-plugin-twitter/server/serializers" + "github.com/mattermost/mattermost-plugin-twitter/server/util" +) + +const ( + invalidCommand = "Invalid command parameters. Please use `/twitter help` for more information." + + helpText = "###### Twitter - Slash Command Help\n\n" + + "* `/twitter connect` - Connect to your twitter account.\n" + + "* `/twitter disconnect` - Disconnect your twitter account.\n" +) + +func GetCommand(iconData string) *model.Command { + return &model.Command{ + Trigger: "twitter", + AutoComplete: true, + AutoCompleteDesc: "Available commands: connect, disconnect, help.", + AutoCompleteHint: "[command]", + AutocompleteData: getAutoCompleteData(), + AutocompleteIconData: iconData, + } +} + +func getAutoCompleteData() *model.AutocompleteData { + twitter := model.NewAutocompleteData("twitter", "[command]", "Available commands: connect, disconnect, help.") + + connect := model.NewAutocompleteData("connect", "", "Connect to your twitter account.") + twitter.AddCommand(connect) + + disconnect := model.NewAutocompleteData("disconnect", "", "Disconnect your twitter account.") + twitter.AddCommand(disconnect) + + help := model.NewAutocompleteData("help", "", "Show twitter slash command help") + twitter.AddCommand(help) + + return twitter +} + +var TwitterCommandHandler = Handler{ + handlers: map[string]HandlerFunc{ + "connect": twitterConnect, + "disconnect": twitterDisconnect, + "help": twitterHelpCommand, + }, + defaultHandler: func(context *Context, args ...string) (*model.CommandResponse, *model.AppError) { + return util.SendEphemeralCommandResponse(invalidCommand) + }, +} + +func twitterConnect(ctx *Context, args ...string) (*model.CommandResponse, *model.AppError) { + // If the user is already connected to twitter. + if twUser, err := ctx.store.GetTwitterUser(ctx.UserId); err == nil && twUser != nil { + return util.SendEphemeralCommandResponse(fmt.Sprintf("You are already connected as twitter user: %s.\nUse `/twitter disconnect` to disconnect your account.", twUser.GetDisplayName())) + } + + twitterOAuth1Config := util.GetTwitterOAuth1Config(ctx.api, ctx.manifest) + token, secret, err := twitterOAuth1Config.RequestToken() + if err != nil { + ctx.api.LogError("Failed to connect to twitter. Unable to obtain Request token and secret (temporary credentials).", "userID", ctx.UserId, "Error", err.Error()) + return util.SendEphemeralCommandResponse("Failed to connect to twitter. If the problem persists, contact your system administrator.") + } + + err = ctx.store.StoreOneTimeSecretJSON(ctx.UserId, &serializers.OAuth1aTemporaryCredentials{Token: token, Secret: secret}) + if err != nil { + ctx.api.LogError("Failed to connect to twitter. Unable to store temporary credentials to KVStore.", "userID", ctx.UserId, "Error", err.Error()) + return util.SendEphemeralCommandResponse("Failed to connect to twitter. If the problem persists, contact your system administrator.") + } + + authURL, err := twitterOAuth1Config.AuthorizationURL(token) + if err != nil { + ctx.api.LogError("Failed to connect to twitter. Unable to obtain authorization URL.", "userID", ctx.UserId, "Error", err.Error()) + return util.SendEphemeralCommandResponse("Failed to connect to twitter. If the problem persists, contact your system administrator.") + } + + return util.SendEphemeralCommandResponse(fmt.Sprintf("Click [here](%s) to connect to your twitter account.", authURL)) +} + +func twitterDisconnect(ctx *Context, args ...string) (*model.CommandResponse, *model.AppError) { + if err := ctx.store.DeleteTwitterUser(ctx.UserId); err != nil { + ctx.api.LogError("Failed to disconnect user.", "userID", ctx.UserId, "Error", err.Error()) + return util.SendEphemeralCommandResponse("Failed to disconnect your twitter account. If the problem persists, contact your system administrator.") + } + return util.SendEphemeralCommandResponse("Successfully disconnected from your twitter account.") +} + +func twitterHelpCommand(_ *Context, _ ...string) (*model.CommandResponse, *model.AppError) { + return util.SendEphemeralCommandResponse(helpText) +} diff --git a/server/config/main.go b/server/config/main.go new file mode 100644 index 0000000..4793fa8 --- /dev/null +++ b/server/config/main.go @@ -0,0 +1,70 @@ +package config + +import ( + "encoding/json" + + "github.com/pkg/errors" + "go.uber.org/atomic" +) + +var ( + config atomic.Value +) + +// Configuration captures the plugin's external configuration as exposed in the Mattermost server +// configuration, as well as values computed from the configuration. Any public fields will be +// deserialized from the Mattermost server configuration in OnConfigurationChange. +// +// As plugins are inherently concurrent (hooks being called asynchronously), and the plugin +// configuration can change at any time, access to the configuration must be synchronized. The +// strategy used in this plugin is to guard a pointer to the configuration, and clone the entire +// struct whenever it changes. You may replace this with whatever strategy you choose. +// +// If you add non-reference types to your configuration struct, be sure to rewrite Clone as a deep +// copy appropriate for your types. +type Configuration struct { + OAuthClientID string + OAuthClientSecret string + EncryptionKey string +} + +// Clone shallow copies the configuration. Your implementation may require a deep copy if +// your configuration has reference types. +func (c *Configuration) Clone() *Configuration { + var clone = *c + return &clone +} + +// GetConfig retrieves the active configuration. +func GetConfig() *Configuration { + return config.Load().(*Configuration) +} + +// SetConfig replaces the active configuration. +func SetConfig(c *Configuration) { + config.Store(c) +} + +// IsValid checks if all needed fields are set. +func (c *Configuration) IsValid() error { + if c.OAuthClientID == "" { + return errors.New("must have a twitter oauth client id") + } + + if c.OAuthClientSecret == "" { + return errors.New("must have a twitter oauth client secret") + } + + if c.EncryptionKey == "" { + return errors.New("must have an encryption key") + } + + return nil +} + +func (c *Configuration) Serialize() map[string]interface{} { + out := make(map[string]interface{}) + b, _ := json.Marshal(c) + _ = json.Unmarshal(b, &out) + return out +} diff --git a/server/configuration.go b/server/configuration.go deleted file mode 100644 index 05aaf5b..0000000 --- a/server/configuration.go +++ /dev/null @@ -1,83 +0,0 @@ -package main - -import ( - "reflect" - - "github.com/pkg/errors" -) - -// configuration captures the plugin's external configuration as exposed in the Mattermost server -// configuration, as well as values computed from the configuration. Any public fields will be -// deserialized from the Mattermost server configuration in OnConfigurationChange. -// -// As plugins are inherently concurrent (hooks being called asynchronously), and the plugin -// configuration can change at any time, access to the configuration must be synchronized. The -// strategy used in this plugin is to guard a pointer to the configuration, and clone the entire -// struct whenever it changes. You may replace this with whatever strategy you choose. -// -// If you add non-reference types to your configuration struct, be sure to rewrite Clone as a deep -// copy appropriate for your types. -type configuration struct { -} - -// Clone shallow copies the configuration. Your implementation may require a deep copy if -// your configuration has reference types. -func (c *configuration) Clone() *configuration { - var clone = *c - return &clone -} - -// getConfiguration retrieves the active configuration under lock, making it safe to use -// concurrently. The active configuration may change underneath the client of this method, but -// the struct returned by this API call is considered immutable. -func (p *Plugin) getConfiguration() *configuration { - p.configurationLock.RLock() - defer p.configurationLock.RUnlock() - - if p.configuration == nil { - return &configuration{} - } - - return p.configuration -} - -// setConfiguration replaces the active configuration under lock. -// -// Do not call setConfiguration while holding the configurationLock, as sync.Mutex is not -// reentrant. In particular, avoid using the plugin API entirely, as this may in turn trigger a -// hook back into the plugin. If that hook attempts to acquire this lock, a deadlock may occur. -// -// This method panics if setConfiguration is called with the existing configuration. This almost -// certainly means that the configuration was modified without being cloned and may result in -// an unsafe access. -func (p *Plugin) setConfiguration(configuration *configuration) { - p.configurationLock.Lock() - defer p.configurationLock.Unlock() - - if configuration != nil && p.configuration == configuration { - // Ignore assignment if the configuration struct is empty. Go will optimize the - // allocation for same to point at the same memory address, breaking the check - // above. - if reflect.ValueOf(*configuration).NumField() == 0 { - return - } - - panic("setConfiguration called with the existing configuration") - } - - p.configuration = configuration -} - -// OnConfigurationChange is invoked when configuration changes may have been made. -func (p *Plugin) OnConfigurationChange() error { - var configuration = new(configuration) - - // Load the public configuration fields from the Mattermost server configuration. - if err := p.API.LoadPluginConfiguration(configuration); err != nil { - return errors.Wrap(err, "failed to load plugin configuration") - } - - p.setConfiguration(configuration) - - return nil -} diff --git a/server/constant/constants.go b/server/constant/constants.go new file mode 100644 index 0000000..96d9d59 --- /dev/null +++ b/server/constant/constants.go @@ -0,0 +1,15 @@ +package constant + +const ( + // TODO: use manifest.id instead + PluginName = "com.mattermost.twitter" + + URLPluginBase = "/plugins/" + PluginName + URLStaticBase = URLPluginBase + "/static" + + BotUsername = "twitter" + BotDisplayName = "Twitter" + BotIconURL = URLStaticBase + "/twitter.png" + + PathTwitterOAuth1Callback = "/twitter/callback" +) diff --git a/server/manifest.go b/server/manifest.go index 011a692..b4c3b69 100644 --- a/server/manifest.go +++ b/server/manifest.go @@ -12,11 +12,11 @@ var manifest *model.Manifest const manifestStr = ` { - "id": "com.mattermost.plugin-starter-template", - "name": "Plugin Starter Template", - "description": "This plugin serves as a starting point for writing a Mattermost plugin.", + "id": "com.mattermost.twitter", + "name": "Twitter", + "description": "A Matermost plugin to connect to Twitter.", "version": "0.1.0", - "min_server_version": "5.12.0", + "min_server_version": "5.27.0", "server": { "executables": { "linux-amd64": "server/dist/plugin-linux-amd64", @@ -25,13 +25,35 @@ const manifestStr = ` }, "executable": "" }, - "webapp": { - "bundle_path": "webapp/dist/main.js" - }, "settings_schema": { "header": "", "footer": "", - "settings": [] + "settings": [ + { + "key": "OAuthClientID", + "display_name": "OAuth Client ID:", + "type": "text", + "help_text": "The client ID for the OAuth app registered with Twitter.", + "placeholder": "", + "default": "WkEzuWVPGHSmO4vm0yvdF8bq4" + }, + { + "key": "OAuthClientSecret", + "display_name": "OAuth Client Secret:", + "type": "text", + "help_text": "The client secret for the OAuth app registered with Twitter.", + "placeholder": "", + "default": "LwAgBVckO0EQlI2zka55ZFAN16xMRY8T0zvzVI4KbVlCK6tBN1" + }, + { + "key": "EncryptionKey", + "display_name": "At Rest Encryption Key", + "type": "generated", + "help_text": "The AES encryption key used to encrypt stored access tokens.", + "placeholder": "", + "default": null + } + ] } } ` diff --git a/server/plugin.go b/server/plugin.go index 29f249a..1fb63bd 100644 --- a/server/plugin.go +++ b/server/plugin.go @@ -3,26 +3,114 @@ package main import ( "fmt" "net/http" - "sync" + "runtime/debug" + "strings" + "github.com/gorilla/mux" + cmd2 "github.com/mattermost/mattermost-plugin-api/experimental/command" + "github.com/mattermost/mattermost-server/v5/model" "github.com/mattermost/mattermost-server/v5/plugin" + "github.com/pkg/errors" + + "github.com/mattermost/mattermost-plugin-twitter/server/api" + "github.com/mattermost/mattermost-plugin-twitter/server/command" + "github.com/mattermost/mattermost-plugin-twitter/server/config" + "github.com/mattermost/mattermost-plugin-twitter/server/store" + "github.com/mattermost/mattermost-plugin-twitter/server/util" ) // Plugin implements the interface expected by the Mattermost server to communicate between the server and plugin processes. +// See https://developers.mattermost.com/extend/plugins/server/reference/ type Plugin struct { plugin.MattermostPlugin - // configurationLock synchronizes access to the configuration. - configurationLock sync.RWMutex + router *mux.Router + store *store.Store +} + +func (p *Plugin) OnActivate() error { + if err := p.registerCommand(); err != nil { + p.API.LogError(err.Error()) + return err + } - // configuration is the active plugin configuration. Consult getConfiguration and - // setConfiguration for usage. - configuration *configuration + p.store = store.NewStore(p.API, p.Helpers) + p.router = api.NewController(p.API, p.Helpers, manifest, p.store).InitAPI() + return nil } -// ServeHTTP demonstrates a plugin that handles HTTP requests by greeting the world. -func (p *Plugin) ServeHTTP(c *plugin.Context, w http.ResponseWriter, r *http.Request) { - fmt.Fprint(w, "Hello, world!") +// OnConfigurationChange is invoked when configuration changes may have been made. +func (p *Plugin) OnConfigurationChange() error { + var configuration config.Configuration + + // Load the public configuration fields from the Mattermost server configuration. + if err := p.API.LoadPluginConfiguration(&configuration); err != nil { + return errors.Wrap(err, "failed to load plugin configuration") + } + + if err := configuration.IsValid(); err != nil { + return errors.Wrap(err, "failed to validate plugin configuration") + } + + config.SetConfig(&configuration) + return nil } -// See https://developers.mattermost.com/extend/plugins/server/reference/ +func (p *Plugin) registerCommand() error { + iconData, err := cmd2.GetIconData(p.API, "assets/logo.svg") + if err != nil { + return errors.Wrap(err, "failed to get icon data") + } + + cmd := command.GetCommand(iconData) + if err := p.API.RegisterCommand(cmd); err != nil { + return errors.Wrap(err, "failed to register slash command: "+cmd.Trigger) + } + + return nil +} + +func (p *Plugin) ExecuteCommand(c *plugin.Context, args *model.CommandArgs) (*model.CommandResponse, *model.AppError) { + defer func() { + if x := recover(); x != nil { + p.API.LogError("Recovered from a panic while executing slash command.", + "commandArgs", fmt.Sprintf("%v", args), + "error", x, + "stack", string(debug.Stack())) + } + }() + + split, argErr := util.SplitArgs(args.Command) + if argErr != nil { + return util.SendEphemeralCommandResponse(argErr.Error()) + } + + cmdName := split[0][1:] + var params []string + + if len(split) > 1 { + params = split[1:] + } + + cmd := command.GetCommand("") + if cmd.Trigger != cmdName { + return util.SendEphemeralCommandResponse("Unknown command: [" + cmdName + "] encountered") + } + + p.API.LogDebug("Executing command: " + cmdName + " with params: [" + strings.Join(params, ", ") + "]") + cmdContext := command.NewContext(args, c, p.API, p.Helpers, manifest, p.store) + return command.TwitterCommandHandler.Handle(cmdContext, params...) +} + +// ServeHTTP handles HTTP requests for the plugin. +func (p *Plugin) ServeHTTP(c *plugin.Context, w http.ResponseWriter, r *http.Request) { + p.API.LogDebug("New request:", "Host", r.Host, "RequestURI", r.RequestURI, "Method", r.Method) + + if err := config.GetConfig().IsValid(); err != nil { + p.API.LogError("This plugin is not configured.", "Error", err.Error()) + http.Error(w, "This plugin is not configured.", http.StatusNotImplemented) + return + } + + p.router.ServeHTTP(w, r) +} diff --git a/server/plugin_test.go b/server/plugin_test.go deleted file mode 100644 index 1d3a474..0000000 --- a/server/plugin_test.go +++ /dev/null @@ -1,28 +0,0 @@ -package main - -import ( - "io/ioutil" - "net/http" - "net/http/httptest" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestServeHTTP(t *testing.T) { - assert := assert.New(t) - plugin := Plugin{} - w := httptest.NewRecorder() - r := httptest.NewRequest(http.MethodGet, "/", nil) - - plugin.ServeHTTP(nil, w, r) - - result := w.Result() - assert.NotNil(result) - defer result.Body.Close() - bodyBytes, err := ioutil.ReadAll(result.Body) - assert.Nil(err) - bodyString := string(bodyBytes) - - assert.Equal("Hello, world!", bodyString) -} diff --git a/server/serializers/oauth1.go b/server/serializers/oauth1.go new file mode 100644 index 0000000..2abb26d --- /dev/null +++ b/server/serializers/oauth1.go @@ -0,0 +1,6 @@ +package serializers + +type OAuth1aTemporaryCredentials struct { + Token string + Secret string +} diff --git a/server/serializers/twitter-user.go b/server/serializers/twitter-user.go new file mode 100644 index 0000000..88b765c --- /dev/null +++ b/server/serializers/twitter-user.go @@ -0,0 +1,16 @@ +package serializers + +import ( + "fmt" +) + +type TwitterUser struct { + Name string + Username string + AccessToken string + AccessSecret string +} + +func (u *TwitterUser) GetDisplayName() string { + return fmt.Sprintf("%s (@%s)", u.Name, u.Username) +} diff --git a/server/store/main.go b/server/store/main.go new file mode 100644 index 0000000..ea44b94 --- /dev/null +++ b/server/store/main.go @@ -0,0 +1,67 @@ +// Package store ... +// Loosely adapted from: https://github.com/mattermost/mattermost-plugin-mscalendar/blob/7d80765da31ac3483354b99197f099d805c3b4a9/server/utils/kvstore/plugin_store.go +package store + +import ( + "github.com/mattermost/mattermost-server/v5/model" + "github.com/mattermost/mattermost-server/v5/plugin" + + "github.com/pkg/errors" +) + +var ErrNotFound = errors.New("not found") + +type Store struct { + api plugin.API + helpers plugin.Helpers +} + +func NewStore(api plugin.API, helpers plugin.Helpers) *Store { + return &Store{ + api, + helpers, + } +} + +func (s Store) Load(key string) ([]byte, error) { + data, appErr := s.api.KVGet(key) + if appErr != nil { + return nil, errors.WithMessage(appErr, "failed plugin KVGet") + } + if data == nil { + return nil, ErrNotFound + } + return data, nil +} + +func (s Store) Store(key string, data []byte) error { + var appErr *model.AppError + if appErr = s.api.KVSet(key, data); appErr != nil { + return errors.WithMessagef(appErr, "failed plugin KVSet %q", key) + } + return nil +} + +func (s Store) StoreTTL(key string, data []byte, ttlSeconds int64) error { + appErr := s.api.KVSetWithExpiry(key, data, ttlSeconds) + if appErr != nil { + return errors.WithMessagef(appErr, "failed plugin KVSet (ttl: %vs) %q", ttlSeconds, key) + } + return nil +} + +func (s Store) StoreWithOptions(key string, value []byte, opts model.PluginKVSetOptions) (bool, error) { + success, appErr := s.api.KVSetWithOptions(key, value, opts) + if appErr != nil { + return false, errors.WithMessagef(appErr, "failed plugin KVSet (ttl: %vs) %q", opts.ExpireInSeconds, key) + } + return success, nil +} + +func (s Store) Delete(key string) error { + appErr := s.api.KVDelete(key) + if appErr != nil { + return errors.WithMessagef(appErr, "failed plugin KVdelete %q", key) + } + return nil +} diff --git a/server/store/ots.go b/server/store/ots.go new file mode 100644 index 0000000..dbb23d1 --- /dev/null +++ b/server/store/ots.go @@ -0,0 +1,51 @@ +// Package store ... +// Loosely adapted from: https://github.com/mattermost/mattermost-plugin-mscalendar/blob/7d80765da31ac3483354b99197f099d805c3b4a9/server/utils/kvstore/ots.go +package store + +import ( + "encoding/json" + + "github.com/mattermost/mattermost-plugin-twitter/server/util" +) + +const ( + prefixOneTimeSecret = "ots_" // + unique key that will be deleted after the first verification + + // Expire in 15 minutes + otsExpiration = 15 * 60 +) + +func (s *Store) StoreOneTimeSecret(token, secret string) error { + return s.StoreTTL(util.HashKey(prefixOneTimeSecret, token), []byte(secret), otsExpiration) +} + +func (s *Store) LoadOneTimeSecret(key string) (data []byte, returnErr error) { + data, err := s.Load(util.HashKey(prefixOneTimeSecret, key)) + if len(data) != 0 { + _ = s.Delete(util.HashKey(prefixOneTimeSecret, key)) + } + return data, err +} + +func (s *Store) StoreOneTimeSecretJSON(token string, v interface{}) error { + data, err := json.Marshal(v) + if err != nil { + return err + } + return s.StoreTTL(util.HashKey(prefixOneTimeSecret, token), data, otsExpiration) +} + +func (s *Store) LoadOneTimeSecretJSON(key string, v interface{}) (returnErr error) { + data, err := s.Load(util.HashKey(prefixOneTimeSecret, key)) + if err != nil { + return err + } + + // If the key expired, appErr is nil, but the data is also nil + if len(data) == 0 { + return ErrNotFound + } + + _ = s.Delete(util.HashKey(prefixOneTimeSecret, key)) + return json.Unmarshal(data, v) +} diff --git a/server/store/twitter-user.go b/server/store/twitter-user.go new file mode 100644 index 0000000..1830de3 --- /dev/null +++ b/server/store/twitter-user.go @@ -0,0 +1,30 @@ +package store + +import ( + "github.com/mattermost/mattermost-plugin-twitter/server/serializers" + "github.com/mattermost/mattermost-plugin-twitter/server/util" +) + +const ( + twitterUserPrefix = "twitter-user-" +) + +func (s Store) SaveTwitterUser(mmUserID string, user *serializers.TwitterUser) error { + return s.StoreJSON(util.HashKey(twitterUserPrefix, mmUserID), user) +} + +func (s Store) GetTwitterUser(mmUserID string) (*serializers.TwitterUser, error) { + var user serializers.TwitterUser + err := s.LoadJSON(util.HashKey(twitterUserPrefix, mmUserID), &user) + if err != nil { + if err != ErrNotFound { + s.api.LogError("Failed to get connected twitter user.", "userID", mmUserID, "error", err.Error()) + } + return nil, err + } + return &user, nil +} + +func (s Store) DeleteTwitterUser(mmUserID string) error { + return s.Delete(util.HashKey(twitterUserPrefix, mmUserID)) +} diff --git a/server/store/utils.go b/server/store/utils.go new file mode 100644 index 0000000..313a2b2 --- /dev/null +++ b/server/store/utils.go @@ -0,0 +1,116 @@ +// Package store ... +// Loosely adapted from: https://github.com/mattermost/mattermost-plugin-mscalendar/blob/7d80765da31ac3483354b99197f099d805c3b4a9/server/utils/kvstore/kvstore.go +package store + +import ( + "bytes" + "encoding/json" + "time" + + "github.com/mattermost/mattermost-server/v5/model" + "github.com/pkg/errors" +) + +const ( + atomicRetryLimit = 5 + atomicRetryWait = 30 * time.Millisecond +) + +// Ensure makes sure the initial value for a key is set to the value provided, if it does not already exists +// Returns the value set for the key in kv-store and error +func (s *Store) Ensure(key string, newValue []byte) ([]byte, error) { + value, err := s.Load(key) + switch err { + case nil: + return value, nil + case ErrNotFound: + break + default: + return nil, err + } + + err = s.Store(key, newValue) + if err != nil { + return nil, err + } + + // Load again in case we lost the race to another server + value, err = s.Load(key) + if err != nil { + return newValue, nil + } + return value, nil +} + +// LoadJSON loads a json value stored in the KVStore using StoreJSON +// unmarshalling it to an interface using json.Unmarshal +func (s *Store) LoadJSON(key string, v interface{}) (returnErr error) { + data, err := s.Load(key) + if err != nil { + return err + } + return json.Unmarshal(data, v) +} + +// StoreJSON stores a json value from an interface to KVStore after marshaling it using json.Marshal +func (s *Store) StoreJSON(key string, v interface{}) (returnErr error) { + data, err := json.Marshal(v) + if err != nil { + return err + } + return s.Store(key, data) +} + +// AtomicModifyWithOptions modifies the value for a key in KVStore, only if the initial value is not changed while attempting to modify it. +// To avoid race conditions, we retry the modification multiple times after waiting for a fixed wait interval. +// input: kv store key and a modify function which takes initial value and returns final value with PluginKVSetOptions and error. +// returns: nil for a successful update and error if the update is unsuccessful or the retry limit reached. +func (s *Store) AtomicModifyWithOptions(key string, modify func(initialValue []byte) ([]byte, *model.PluginKVSetOptions, error)) error { + currentAttempt := 0 + for { + initialBytes, appErr := s.Load(key) + if appErr != nil && appErr != ErrNotFound { + return errors.Wrap(appErr, "unable to read initial value") + } + + newValue, opts, err := modify(initialBytes) + if err != nil { + return errors.Wrap(err, "modification error") + } + + // No modifications have been done. No reason to hit the plugin API. + if bytes.Equal(initialBytes, newValue) { + return nil + } + + if opts == nil { + opts = &model.PluginKVSetOptions{} + } + opts.Atomic = true + opts.OldValue = initialBytes + success, setError := s.StoreWithOptions(key, newValue, *opts) + if setError != nil { + return errors.Wrap(setError, "problem writing value") + } + if success { + return nil + } + + currentAttempt++ + if currentAttempt >= atomicRetryLimit { + return errors.New("reached write attempt limit") + } + + time.Sleep(atomicRetryWait) + } +} + +// AtomicModify calls AtomicModifyWithOptions with nil PluginKVSetOptions +// to atomically modify a value in KVStore and set it for an indefinite time +// See AtomicModifyWithOptions for more info +func (s *Store) AtomicModify(key string, modify func(initialValue []byte) ([]byte, error)) error { + return s.AtomicModifyWithOptions(key, func(initialValue []byte) ([]byte, *model.PluginKVSetOptions, error) { + b, err := modify(initialValue) + return b, nil, err + }) +} diff --git a/server/util/command.go b/server/util/command.go new file mode 100644 index 0000000..aeb3350 --- /dev/null +++ b/server/util/command.go @@ -0,0 +1,77 @@ +package util + +import ( + "regexp" + "strings" + + "github.com/mattermost/mattermost-server/v5/model" + "github.com/pkg/errors" + + "github.com/mattermost/mattermost-plugin-twitter/server/constant" +) + +// Min - since math.Min is for floats and casting to and from floats is dangerous. +func Min(a, b int) int { + if a < b { + return a + } + return b +} + +// SplitArgs is used to split a string to an array of arguments with separators: "(quotes) and spaces +// We cant use strings.split as it includes empty string if deliminator is the last character in input string +func SplitArgs(s string) ([]string, error) { + indexes := regexp.MustCompile("\"").FindAllStringIndex(s, -1) + if len(indexes)%2 != 0 { + return []string{}, errors.New("quotes not closed") + } + + indexes = append([][]int{{0, 0}}, indexes...) + + if indexes[len(indexes)-1][1] < len(s) { + indexes = append(indexes, [][]int{{len(s), 0}}...) + } + + var args []string + for i := 0; i < len(indexes)-1; i++ { + start := indexes[i][1] + end := Min(len(s), indexes[i+1][0]) + + if i%2 == 0 { + args = append(args, strings.Split(strings.Trim(s[start:end], " "), " ")...) + } else { + args = append(args, s[start:end]) + } + } + + cleanedArgs := make([]string, len(args)) + count := 0 + + for _, arg := range args { + if arg != "" { + cleanedArgs[count] = strings.TrimSpace(arg) + count++ + } + } + + return cleanedArgs[0:count], nil +} + +// SendEphemeralCommandResponse can be used to return an ephemeral message as the response for a slash command +func SendEphemeralCommandResponse(message string) (*model.CommandResponse, *model.AppError) { + return &model.CommandResponse{ + Type: model.COMMAND_RESPONSE_TYPE_EPHEMERAL, + Text: message, + Username: constant.BotUsername, + IconURL: constant.BotIconURL, + }, nil +} + +// BaseCommandResponse returns the base slash command response to post in channel +func BaseCommandResponse() *model.CommandResponse { + return &model.CommandResponse{ + Type: model.COMMAND_RESPONSE_TYPE_IN_CHANNEL, + Username: constant.BotUsername, + IconURL: constant.BotIconURL, + } +} diff --git a/server/util/hash.go b/server/util/hash.go new file mode 100644 index 0000000..d8c0a91 --- /dev/null +++ b/server/util/hash.go @@ -0,0 +1,19 @@ +package util + +import ( + "crypto/md5" + "fmt" +) + +// HashKey returns the kvstore kev by appending prefix with the hash of the input key +// From: https://github.com/mattermost/mattermost-plugin-mscalendar/blob/26fe3c5ea965a435e76dfc5b23e7f66fa9e9b592/server/utils/kvstore/hashed_key.go#L47 +// TODO: use a more secure hash primitive +func HashKey(prefix, key string) string { + if key == "" { + return prefix + } + + h := md5.New() + _, _ = h.Write([]byte(key)) + return fmt.Sprintf("%s%x", prefix, h.Sum(nil)) +} diff --git a/server/util/post.go b/server/util/post.go new file mode 100644 index 0000000..4a23ca1 --- /dev/null +++ b/server/util/post.go @@ -0,0 +1,20 @@ +package util + +import ( + "github.com/mattermost/mattermost-server/v5/model" + + "github.com/mattermost/mattermost-plugin-twitter/server/constant" +) + +func EphemeralPost(channelID, message string) *model.Post { + post := &model.Post{ + ChannelId: channelID, + Message: message, + } + post.SetProps(model.StringInterface{ + "from_webhook": "true", + "override_username": constant.BotUsername, + "override_icon_url": constant.BotIconURL, + }) + return post +} diff --git a/server/util/utils.go b/server/util/utils.go new file mode 100644 index 0000000..c2ddd1e --- /dev/null +++ b/server/util/utils.go @@ -0,0 +1,51 @@ +package util + +import ( + "strings" + + "github.com/dghubble/go-twitter/twitter" + "github.com/dghubble/oauth1" + twAuth "github.com/dghubble/oauth1/twitter" + "github.com/mattermost/mattermost-server/v5/model" + "github.com/mattermost/mattermost-server/v5/plugin" + + "github.com/mattermost/mattermost-plugin-twitter/server/config" + "github.com/mattermost/mattermost-plugin-twitter/server/constant" +) + +func GetSiteURL(api plugin.API) string { + return *api.GetConfig().ServiceSettings.SiteURL +} + +func GetPluginURLPath(manifest *model.Manifest) string { + return "/plugins/" + manifest.Id +} + +func GetPluginURL(api plugin.API, manifest *model.Manifest) string { + return strings.TrimRight(GetSiteURL(api), "/") + GetPluginURLPath(manifest) +} + +func GetPluginAPIURL(api plugin.API, manifest *model.Manifest) string { + return GetPluginURL(api, manifest) + "/api/v1" +} + +func GetTwitterOAuth1Config(api plugin.API, manifest *model.Manifest) oauth1.Config { + conf := config.GetConfig() + + return oauth1.Config{ + ConsumerKey: conf.OAuthClientID, + ConsumerSecret: conf.OAuthClientSecret, + CallbackURL: GetPluginAPIURL(api, manifest) + constant.PathTwitterOAuth1Callback, + Endpoint: twAuth.AuthorizeEndpoint, + } +} + +func GetTwitterClient(accessToken, accessSecret string) *twitter.Client { + conf := config.GetConfig() + oauth1Config := oauth1.NewConfig(conf.OAuthClientID, conf.OAuthClientSecret) + token := oauth1.NewToken(accessToken, accessSecret) + httpClient := oauth1Config.Client(oauth1.NoContext, token) + + // Twitter client + return twitter.NewClient(httpClient) +}