├── .github ├── PULL_REQUEST_TEMPLATE.md ├── workflows │ ├── test.yml │ ├── release_pr.yml │ └── release.yml └── stale.yml ├── go.mod ├── LICENSE.md ├── webhook.go ├── doc.go ├── request.go ├── go.sum ├── request_url.go ├── channel_authentication_test.go ├── util_test.go ├── webhook_test.go ├── util.go ├── response_parsing_test.go ├── response_parsing.go ├── encoder.go ├── crypto.go ├── CHANGELOG.md ├── crypto_test.go ├── request_url_test.go ├── README.md ├── client.go └── client_test.go /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## What does this PR do? 2 | 3 | [Description here] 4 | 5 | ## CHANGELOG 6 | 7 | - [CHANGED] Describe your change here. Look at CHANGELOG.md to see the format. 8 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/pusher/pusher-http-go/v5 2 | 3 | go 1.14 4 | 5 | require ( 6 | github.com/davecgh/go-spew v1.1.0 // indirect 7 | github.com/pmezard/go-difflib v1.0.0 // indirect 8 | golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899 9 | gopkg.in/stretchr/testify.v1 v1.2.2 10 | ) 11 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: [ master, main ] 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-20.04 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | go: ['1.18', '1.19'] 15 | 16 | name: Go ${{ matrix.go }} Test 17 | 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v2 21 | 22 | - name: Setup Go 23 | uses: actions/setup-go@v2 24 | with: 25 | go-version: ${{ matrix.go }} 26 | 27 | - name: Run test suite 28 | run: | 29 | go test -coverprofile=profile.cov 30 | 31 | - name: Send coverage 32 | uses: shogo82148/actions-goveralls@v1 33 | with: 34 | path-to-profile: profile.cov 35 | flag-name: Go-${{ matrix.go }} 36 | parallel: true 37 | 38 | finish: 39 | needs: test 40 | runs-on: ubuntu-20.04 41 | steps: 42 | - uses: shogo82148/actions-goveralls@v1 43 | with: 44 | parallel-finished: true 45 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Pusher Ltd. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. -------------------------------------------------------------------------------- /.github/workflows/release_pr.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | pull_request: 5 | types: [ labeled ] 6 | branches: 7 | - master 8 | 9 | jobs: 10 | prepare-release: 11 | name: Prepare release 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Get current version 16 | shell: bash 17 | run: | 18 | CURRENT_VERSION=$(awk '/libraryVersion =/ { gsub("\"",""); print $3 }' client.go) 19 | echo "CURRENT_VERSION=$CURRENT_VERSION" >> $GITHUB_ENV 20 | - uses: actions/checkout@v2 21 | with: 22 | repository: pusher/public_actions 23 | path: .github/actions 24 | - uses: ./.github/actions/prepare-version-bump 25 | id: bump 26 | with: 27 | current_version: ${{ env.CURRENT_VERSION }} 28 | - name: Push 29 | shell: bash 30 | run: | 31 | sed -i'' -e 's/${{env.CURRENT_VERSION}}/${{steps.bump.outputs.new_version}}/' client.go 32 | 33 | git add client.go CHANGELOG.md 34 | git commit -m "Bump to version ${{ steps.bump.outputs.new_version }}" 35 | git push 36 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Configuration for probot-stale - https://github.com/probot/stale 2 | 3 | # Number of days of inactivity before an Issue or Pull Request becomes stale 4 | daysUntilStale: 90 5 | 6 | # Number of days of inactivity before an Issue or Pull Request with the stale label is closed. 7 | # Set to false to disable. If disabled, issues still need to be closed manually, but will remain marked as stale. 8 | daysUntilClose: 7 9 | 10 | # Only issues or pull requests with all of these labels are check if stale. Defaults to `[]` (disabled) 11 | onlyLabels: [] 12 | 13 | # Issues or Pull Requests with these labels will never be considered stale. Set to `[]` to disable 14 | exemptLabels: 15 | - pinned 16 | - security 17 | 18 | # Set to true to ignore issues with an assignee (defaults to false) 19 | exemptAssignees: true 20 | 21 | # Comment to post when marking as stale. Set to `false` to disable 22 | markComment: > 23 | This issue has been automatically marked as stale because it has not had 24 | recent activity. It will be closed if no further activity occurs. If you'd 25 | like this issue to stay open please leave a comment indicating how this issue 26 | is affecting you. Thank you. 27 | -------------------------------------------------------------------------------- /webhook.go: -------------------------------------------------------------------------------- 1 | package pusher 2 | 3 | import ( 4 | "encoding/json" 5 | ) 6 | 7 | // Webhook is the parsed form of a valid webhook received by the server. 8 | type Webhook struct { 9 | TimeMs int `json:"time_ms"` // the timestamp of the request 10 | Events []WebhookEvent `json:"events"` // the events associated with the webhook 11 | } 12 | 13 | // WebhookEvent is the parsed form of a valid webhook event received by the 14 | // server. 15 | type WebhookEvent struct { 16 | Name string `json:"name"` // the type of the event 17 | Channel string `json:"channel"` // the channel on which it was sent 18 | Event string `json:"event,omitempty"` // the name of the event 19 | Data string `json:"data,omitempty"` // the data associated with the event 20 | SocketID string `json:"socket_id,omitempty"` // the socket_id of the sending socket 21 | UserID string `json:"user_id,omitempty"` // the user_id of a member who has joined or vacated a presence-channel 22 | } 23 | 24 | func unmarshalledWebhook(requestBody []byte) (*Webhook, error) { 25 | webhook := &Webhook{} 26 | err := json.Unmarshal(requestBody, &webhook) 27 | if err != nil { 28 | return nil, err 29 | } 30 | return webhook, nil 31 | } 32 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package pusher is the Golang library for interacting with the Pusher HTTP API. 3 | 4 | This package lets you trigger events to your client and query the state 5 | of your Pusher channels. When used with a server, you can validate Pusher 6 | webhooks and authenticate private- or presence-channels. 7 | 8 | In order to use this library, you need to have a free account 9 | on http://pusher.com. After registering, you will need the application 10 | credentials for your app. 11 | 12 | Getting Started 13 | 14 | To create a new client, pass in your application credentials to a `pusher.Client` struct: 15 | 16 | pusherClient := pusher.Client{ 17 | AppID: "your_app_id", 18 | Key: "your_app_key", 19 | Secret: "your_app_secret", 20 | } 21 | 22 | To start triggering events on a channel, we call `pusherClient.Trigger`: 23 | 24 | data := map[string]string{"message": "hello world"} 25 | 26 | // trigger an event on a channel, along with a data payload 27 | pusherClient.Trigger("test_channel", "event", data) 28 | 29 | Read on to see what more you can do with this library, such as 30 | authenticating private- and presence-channels, validating Pusher webhooks, 31 | and querying the HTTP API to get information about your channels. 32 | 33 | Author: Jamie Patel, Pusher 34 | */ 35 | package pusher 36 | -------------------------------------------------------------------------------- /request.go: -------------------------------------------------------------------------------- 1 | package pusher 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "io/ioutil" 8 | "net/http" 9 | "strconv" 10 | ) 11 | 12 | const ( 13 | contentTypeHeaderKey = "Content-Type" 14 | contentTypeHeaderValue = "application/json" 15 | ) 16 | 17 | var headers = map[string]string{ 18 | "Content-Type": "application/json", 19 | "X-Pusher-Library": fmt.Sprintf("%s %s", libraryName, libraryVersion), 20 | } 21 | 22 | // change timeout to time.Duration 23 | func request(client *http.Client, method, url string, body []byte) ([]byte, error) { 24 | req, err := http.NewRequest(method, url, bytes.NewBuffer(body)) 25 | 26 | for key, val := range headers { 27 | req.Header.Set(http.CanonicalHeaderKey(key), val) 28 | } 29 | 30 | resp, err := client.Do(req) 31 | if err != nil { 32 | return nil, err 33 | } 34 | defer resp.Body.Close() 35 | return processResponse(resp) 36 | } 37 | 38 | func processResponse(response *http.Response) ([]byte, error) { 39 | responseBody, err := ioutil.ReadAll(response.Body) 40 | if err != nil { 41 | return nil, err 42 | } 43 | if response.StatusCode >= 200 && response.StatusCode < 300 { 44 | return responseBody, nil 45 | } 46 | message := fmt.Sprintf("Status Code: %s - %s", strconv.Itoa(response.StatusCode), string(responseBody)) 47 | err = errors.New(message) 48 | return nil, err 49 | } 50 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 2 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 4 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 5 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 6 | golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899 h1:DZhuSZLsGlFL4CmhA8BcRA0mnthyA/nZ00AqCUo7vHg= 7 | golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 8 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 9 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 10 | golang.org/x/sys v0.0.0-20190412213103-97732733099d h1:+R4KGOnez64A81RvjARKc4UT5/tI9ujCIVX+P5KiHuI= 11 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 12 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 13 | gopkg.in/stretchr/testify.v1 v1.2.2 h1:yhQC6Uy5CqibAIlk1wlusa/MJ3iAN49/BsR/dCCKz3M= 14 | gopkg.in/stretchr/testify.v1 v1.2.2/go.mod h1:QI5V/q6UbPmuhtm10CaFZxED9NreB8PnFYN9JcR6TxU= 15 | -------------------------------------------------------------------------------- /request_url.go: -------------------------------------------------------------------------------- 1 | package pusher 2 | 3 | import ( 4 | "net/url" 5 | "strings" 6 | ) 7 | 8 | const authVersion = "1.0" 9 | 10 | func unsignedParams(key, timestamp string, body []byte, parameters map[string]string) url.Values { 11 | params := url.Values{ 12 | "auth_key": {key}, 13 | "auth_timestamp": {timestamp}, 14 | "auth_version": {authVersion}, 15 | } 16 | 17 | if body != nil { 18 | params.Add("body_md5", md5Signature(body)) 19 | } 20 | 21 | if parameters != nil { 22 | for key, values := range parameters { 23 | params.Add(key, values) 24 | } 25 | } 26 | 27 | return params 28 | 29 | } 30 | 31 | func unescapeURL(_url url.Values) string { 32 | unesc, _ := url.QueryUnescape(_url.Encode()) 33 | return unesc 34 | } 35 | 36 | func createRequestURL(method, host, path, key, secret, timestamp string, secure bool, body []byte, parameters map[string]string, cluster string) (string, error) { 37 | params := unsignedParams(key, timestamp, body, parameters) 38 | 39 | stringToSign := strings.Join([]string{method, path, unescapeURL(params)}, "\n") 40 | 41 | authSignature := hmacSignature(stringToSign, secret) 42 | 43 | params.Add("auth_signature", authSignature) 44 | 45 | if host == "" { 46 | if cluster != "" { 47 | host = "api-" + cluster + ".pusher.com" 48 | } else { 49 | host = "api.pusherapp.com" 50 | } 51 | } 52 | var base string 53 | if secure { 54 | base = "https://" 55 | } else { 56 | base = "http://" 57 | } 58 | base += host 59 | 60 | endpoint, err := url.ParseRequestURI(base + path) 61 | if err != nil { 62 | return "", err 63 | } 64 | endpoint.RawQuery = unescapeURL(params) 65 | 66 | return endpoint.String(), nil 67 | } 68 | -------------------------------------------------------------------------------- /channel_authentication_test.go: -------------------------------------------------------------------------------- 1 | package pusher 2 | 3 | import ( 4 | "testing" 5 | 6 | "gopkg.in/stretchr/testify.v1/assert" 7 | ) 8 | 9 | func setUpAuthClient() Client { 10 | return Client{ 11 | Key: "278d425bdf160c739803", 12 | Secret: "7ad3773142a6692b25b8", 13 | } 14 | } 15 | 16 | func TestPrivateChannelAuthentication(t *testing.T) { 17 | client := setUpAuthClient() 18 | postParams := []byte("channel_name=private-foobar&socket_id=1234.1234") 19 | expected := `{"auth":"278d425bdf160c739803:58df8b0c36d6982b82c3ecf6b4662e34fe8c25bba48f5369f135bf843651c3a4"}` 20 | result, err := client.AuthenticatePrivateChannel(postParams) 21 | assert.Equal(t, expected, string(result)) 22 | assert.NoError(t, err) 23 | } 24 | 25 | func TestPrivateChannelAuthenticationWrongParams(t *testing.T) { 26 | client := setUpAuthClient() 27 | postParams := []byte("hello=hi&two=3") 28 | _, err := client.AuthenticatePrivateChannel(postParams) 29 | assert.Error(t, err) 30 | } 31 | 32 | func TestPresenceChannelAuthentication(t *testing.T) { 33 | client := setUpAuthClient() 34 | postParams := []byte("channel_name=presence-foobar&socket_id=1234.1234") 35 | presenceData := MemberData{UserID: "10", UserInfo: map[string]string{"name": "Mr. Pusher"}} 36 | expected := `{"auth":"278d425bdf160c739803:48dac51d2d7569e1e9c0f48c227d4b26f238fa68e5c0bb04222c966909c4f7c4","channel_data":"{\"user_id\":\"10\",\"user_info\":{\"name\":\"Mr. Pusher\"}}"}` 37 | result, err := client.AuthenticatePresenceChannel(postParams, presenceData) 38 | assert.Equal(t, expected, string(result)) 39 | assert.NoError(t, err) 40 | } 41 | 42 | func TestAuthSocketIDValidation(t *testing.T) { 43 | client := setUpAuthClient() 44 | postParams := []byte("channel_name=private-foobar&socket_id=12341234") 45 | result, err := client.AuthenticatePrivateChannel(postParams) 46 | assert.Nil(t, result) 47 | assert.Error(t, err) 48 | } 49 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: [ master ] 4 | 5 | jobs: 6 | check-release-tag: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checkout code 10 | uses: actions/checkout@v2 11 | with: 12 | fetch-depth: 0 13 | - name: Prepare tag 14 | id: prepare_tag 15 | continue-on-error: true 16 | run: | 17 | export TAG=v$(awk '/libraryVersion =/ { gsub("\"",""); print $3 }' client.go) 18 | echo "TAG=$TAG" >> $GITHUB_ENV 19 | 20 | export CHECK_TAG=$(git tag | grep $TAG) 21 | if [[ $CHECK_TAG ]]; then 22 | echo "Skipping because release tag already exists" 23 | exit 1 24 | fi 25 | - name: Output 26 | id: release_output 27 | if: ${{ steps.prepare_tag.outcome == 'success' }} 28 | run: | 29 | echo "::set-output name=tag::${{ env.TAG }}" 30 | outputs: 31 | tag: ${{ steps.release_output.outputs.tag }} 32 | 33 | create-github-release: 34 | runs-on: ubuntu-latest 35 | needs: check-release-tag 36 | if: ${{ needs.check-release-tag.outputs.tag }} 37 | steps: 38 | - uses: actions/checkout@v2 39 | - name: Prepare tag 40 | run: | 41 | export TAG=v$(awk '/libraryVersion =/ { gsub("\"",""); print $3 }' client.go) 42 | echo "TAG=$TAG" >> $GITHUB_ENV 43 | - name: Setup git 44 | run: | 45 | git config user.email "pusher-ci@pusher.com" 46 | git config user.name "Pusher CI" 47 | - name: Prepare description 48 | run: | 49 | csplit -s CHANGELOG.md "/##/" {1} 50 | cat xx01 > CHANGELOG.tmp 51 | - name: Create Release 52 | uses: actions/create-release@v1 53 | env: 54 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 55 | with: 56 | tag_name: ${{ env.TAG }} 57 | release_name: ${{ env.TAG }} 58 | body_path: CHANGELOG.tmp 59 | draft: false 60 | prerelease: false 61 | -------------------------------------------------------------------------------- /util_test.go: -------------------------------------------------------------------------------- 1 | package pusher 2 | 3 | import ( 4 | "testing" 5 | 6 | "gopkg.in/stretchr/testify.v1/assert" 7 | ) 8 | 9 | func TestParseUserAuthenticationRequestParamsNoSock(t *testing.T) { 10 | params := "abc=hello" 11 | _, result := parseUserAuthenticationRequestParams([]byte(params)) 12 | assert.Error(t, result) 13 | assert.EqualError(t, result, "socket_id not found") 14 | } 15 | 16 | func TestInvalidUserAuthenticationParams(t *testing.T) { 17 | params := "%$@£$${}$£%|$^%$^|" 18 | _, result := parseUserAuthenticationRequestParams([]byte(params)) 19 | assert.Error(t, result) 20 | } 21 | 22 | func TestUserAuthenticationParamsSuccess(t *testing.T) { 23 | params := "socket_id=123" 24 | socket_id, result := parseUserAuthenticationRequestParams([]byte(params)) 25 | assert.Equal(t, socket_id, "123") 26 | assert.NoError(t, result) 27 | } 28 | 29 | func TestParseChannelAuthorizationRequestParamsNoSock(t *testing.T) { 30 | params := "channel_name=hello" 31 | _, _, result := parseChannelAuthorizationRequestParams([]byte(params)) 32 | assert.Error(t, result) 33 | assert.EqualError(t, result, "socket_id not found") 34 | } 35 | 36 | func TestParseChannelAuthorizationRequestParamsNoChan(t *testing.T) { 37 | params := "socket_id=45.3" 38 | _, _, result := parseChannelAuthorizationRequestParams([]byte(params)) 39 | assert.Error(t, result) 40 | assert.EqualError(t, result, "channel_name not found") 41 | } 42 | 43 | func TestInvalidChannelAuthorizationParams(t *testing.T) { 44 | params := "%$@£$${}$£%|$^%$^|" 45 | _, _, result := parseChannelAuthorizationRequestParams([]byte(params)) 46 | assert.Error(t, result) 47 | } 48 | 49 | func TestValidateUserDataSuccess(t *testing.T) { 50 | m := map[string]interface{}{ 51 | "id": "12345", 52 | "email": "test@test.com", 53 | } 54 | err := validateUserData(m) 55 | assert.NoError(t, err) 56 | } 57 | 58 | func TestValidateUserDataNoId(t *testing.T) { 59 | m := map[string]interface{}{ 60 | "email": "test@test.com", 61 | } 62 | err := validateUserData(m) 63 | assert.EqualError(t, err, "Missing id in user data") 64 | } 65 | 66 | func TestValidateUserDataIdIsNotString(t *testing.T) { 67 | m := map[string]interface{}{ 68 | "id": 123, 69 | "email": "test@test.com", 70 | } 71 | err := validateUserData(m) 72 | assert.EqualError(t, err, "id field in user data is not a string") 73 | } 74 | 75 | func TestValidateUserDataInvalidId(t *testing.T) { 76 | m := map[string]interface{}{ 77 | "id": "", 78 | "email": "test@test.com", 79 | } 80 | err := validateUserData(m) 81 | assert.EqualError(t, err, "Invalid id in user data: ''") 82 | } 83 | -------------------------------------------------------------------------------- /webhook_test.go: -------------------------------------------------------------------------------- 1 | package pusher 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | 7 | "gopkg.in/stretchr/testify.v1/assert" 8 | ) 9 | 10 | func setUpClient() Client { 11 | return Client{AppID: "id", Key: "key", Secret: "secret"} 12 | } 13 | 14 | func TestClientWebhookValidation(t *testing.T) { 15 | client := setUpClient() 16 | header := make(http.Header) 17 | header["X-Pusher-Key"] = []string{"key"} 18 | header["X-Pusher-Signature"] = []string{"2677ad3e7c090b2fa2c0fb13020d66d5420879b8316eb356a2d60fb9073bc778"} 19 | body := []byte(`{"hello":"world"}`) 20 | webhook, err := client.Webhook(header, body) 21 | assert.NotNil(t, webhook) 22 | assert.Nil(t, err) 23 | } 24 | 25 | func TestWebhookImproperKeyCase(t *testing.T) { 26 | client := setUpClient() 27 | badHeader := make(http.Header) 28 | badHeader["X-Pusher-Key"] = []string{"narr you're going down!"} 29 | badHeader["X-Pusher-Signature"] = []string{"2677ad3e7c090b2fa2c0fb13020d66d5420879b8316eb356a2d60fb9073bc778"} 30 | badBody := []byte(`{"hello":"world"}`) 31 | 32 | badWebhook, err := client.Webhook(badHeader, badBody) 33 | assert.Nil(t, badWebhook) 34 | assert.Error(t, err) 35 | } 36 | 37 | func TestWebhookImproperSignatureCase(t *testing.T) { 38 | client := setUpClient() 39 | badHeader := make(http.Header) 40 | badHeader["X-Pusher-Key"] = []string{"key"} 41 | badHeader["X-Pusher-Signature"] = []string{"2677ad3e7c090i'mgonnagetyaeb356a2d60fb9073bc778"} 42 | badBody := []byte(`{"hello":"world"}`) 43 | 44 | badWebhook, err := client.Webhook(badHeader, badBody) 45 | assert.Nil(t, badWebhook) 46 | assert.Error(t, err) 47 | } 48 | 49 | func TestWebhookNoSignature(t *testing.T) { 50 | client := setUpClient() 51 | badHeader := make(http.Header) 52 | badHeader["X-Pusher-Key"] = []string{"key"} 53 | badBody := []byte(`{"hello":"world"}`) 54 | 55 | badWebhook, err := client.Webhook(badHeader, badBody) 56 | assert.Nil(t, badWebhook) 57 | assert.Error(t, err) 58 | } 59 | 60 | func TestWebhookUnmarshalling(t *testing.T) { 61 | body := []byte(`{"time_ms":1427233518933,"events":[{"name":"client_event","channel":"private-channel","event":"client-yolo","data":"{\"yolo\":\"woot\"}","socket_id":"44610.7511910"}]}`) 62 | result, err := unmarshalledWebhook(body) 63 | expected := &Webhook{ 64 | TimeMs: 1427233518933, 65 | Events: []WebhookEvent{ 66 | WebhookEvent{ 67 | Name: "client_event", 68 | Channel: "private-channel", 69 | Event: "client-yolo", 70 | Data: "{\"yolo\":\"woot\"}", 71 | SocketID: "44610.7511910", 72 | }, 73 | }, 74 | } 75 | 76 | assert.Equal(t, expected, result) 77 | assert.NoError(t, err) 78 | } 79 | -------------------------------------------------------------------------------- /util.go: -------------------------------------------------------------------------------- 1 | package pusher 2 | 3 | import ( 4 | "errors" 5 | "encoding/json" 6 | "fmt" 7 | "net/url" 8 | "regexp" 9 | "strconv" 10 | "strings" 11 | "time" 12 | ) 13 | 14 | var channelValidationRegex = regexp.MustCompile("^[-a-zA-Z0-9_=@,.;]+$") 15 | var socketIDValidationRegex = regexp.MustCompile(`\A\d+\.\d+\z`) 16 | var maxChannelNameSize = 200 17 | 18 | func jsonMarshalToString(data interface{}) (result string, err error) { 19 | var _result []byte 20 | _result, err = json.Marshal(data) 21 | if err != nil { 22 | return 23 | } 24 | return string(_result), err 25 | } 26 | 27 | func authTimestamp() string { 28 | return strconv.FormatInt(time.Now().Unix(), 10) 29 | } 30 | 31 | func parseUserAuthenticationRequestParams(_params []byte) (socketID string, err error) { 32 | params, err := url.ParseQuery(string(_params)) 33 | if err != nil { 34 | return 35 | } 36 | if _, ok := params["socket_id"]; !ok { 37 | return "", errors.New("socket_id not found") 38 | } 39 | return params["socket_id"][0], nil 40 | } 41 | 42 | func parseChannelAuthorizationRequestParams(_params []byte) (channelName string, socketID string, err error) { 43 | params, err := url.ParseQuery(string(_params)) 44 | if err != nil { 45 | return 46 | } 47 | if _, ok := params["channel_name"]; !ok { 48 | return "", "", errors.New("channel_name not found") 49 | } 50 | if _, ok := params["socket_id"]; !ok { 51 | return "", "", errors.New("socket_id not found") 52 | } 53 | return params["channel_name"][0], params["socket_id"][0], nil 54 | } 55 | 56 | func validUserId(userId string) bool { 57 | length := len(userId) 58 | return length > 0 && length < maxChannelNameSize 59 | } 60 | 61 | func validChannel(channel string) bool { 62 | if len(channel) > maxChannelNameSize || !channelValidationRegex.MatchString(channel) { 63 | return false 64 | } 65 | return true 66 | } 67 | 68 | func channelsAreValid(channels []string) bool { 69 | for _, channel := range channels { 70 | if !validChannel(channel) { 71 | return false 72 | } 73 | } 74 | return true 75 | } 76 | 77 | func isEncryptedChannel(channel string) bool { 78 | if strings.HasPrefix(channel, "private-encrypted-") { 79 | return true 80 | } 81 | return false 82 | } 83 | 84 | func validateUserData(userData map[string]interface{}) (err error) { 85 | _id, ok := userData["id"] 86 | if !ok || _id == nil { 87 | return errors.New("Missing id in user data") 88 | } 89 | var id string 90 | id, ok = _id.(string) 91 | if !ok { 92 | return errors.New("id field in user data is not a string") 93 | } 94 | if !validUserId(id) { 95 | return fmt.Errorf("Invalid id in user data: '%s'", id) 96 | } 97 | return 98 | } 99 | 100 | func validateSocketID(socketID *string) (err error) { 101 | if (socketID == nil) || socketIDValidationRegex.MatchString(*socketID) { 102 | return 103 | } 104 | return errors.New("socket_id invalid") 105 | } 106 | -------------------------------------------------------------------------------- /response_parsing_test.go: -------------------------------------------------------------------------------- 1 | package pusher 2 | 3 | import ( 4 | "testing" 5 | 6 | "gopkg.in/stretchr/testify.v1/assert" 7 | ) 8 | 9 | func TestParsingTriggerChannelsList(t *testing.T) { 10 | testJSON := []byte(`{"channels":{"presence-session-d41a439c438a100756f5-4bf35003e819bb138249-5cbTiUiPNGI":{},"presence-session-d41a439c438a100756f5-4bf35003e819bb138249-PbZ5E1pP8uF":{"user_count":1},"presence-session-d41a439c438a100756f5-4bf35003e819bb138249-oz6iqpSxMwG":{"user_count":2,"subscription_count":3}}}`) 11 | expectedUserCount1 := 1 12 | expectedUserCount2 := 2 13 | expectedSubscriptionCount2 := 3 14 | expected := &TriggerChannelsList{ 15 | Channels: map[string]TriggerChannelListItem{ 16 | "presence-session-d41a439c438a100756f5-4bf35003e819bb138249-5cbTiUiPNGI": TriggerChannelListItem{}, 17 | "presence-session-d41a439c438a100756f5-4bf35003e819bb138249-PbZ5E1pP8uF": TriggerChannelListItem{UserCount: &expectedUserCount1}, 18 | "presence-session-d41a439c438a100756f5-4bf35003e819bb138249-oz6iqpSxMwG": TriggerChannelListItem{UserCount: &expectedUserCount2, SubscriptionCount: &expectedSubscriptionCount2}, 19 | }, 20 | } 21 | result, err := unmarshalledTriggerChannelsList(testJSON) 22 | assert.Equal(t, expected, result) 23 | assert.NoError(t, err) 24 | } 25 | 26 | func TestParsingChannelsList(t *testing.T) { 27 | testJSON := []byte(`{"channels":{"presence-session-d41a439c438a100756f5-4bf35003e819bb138249-5cbTiUiPNGI":{"user_count":1},"presence-session-d41a439c438a100756f5-4bf35003e819bb138249-PbZ5E1pP8uF":{"user_count":1},"presence-session-d41a439c438a100756f5-4bf35003e819bb138249-oz6iqpSxMwG":{"user_count":1}}}`) 28 | expected := &ChannelsList{ 29 | Channels: map[string]ChannelListItem{ 30 | "presence-session-d41a439c438a100756f5-4bf35003e819bb138249-5cbTiUiPNGI": ChannelListItem{UserCount: 1}, 31 | "presence-session-d41a439c438a100756f5-4bf35003e819bb138249-PbZ5E1pP8uF": ChannelListItem{UserCount: 1}, 32 | "presence-session-d41a439c438a100756f5-4bf35003e819bb138249-oz6iqpSxMwG": ChannelListItem{UserCount: 1}, 33 | }, 34 | } 35 | result, err := unmarshalledChannelsList(testJSON) 36 | assert.Equal(t, expected, result) 37 | assert.NoError(t, err) 38 | } 39 | 40 | func TestParsingChannel(t *testing.T) { 41 | testJSON := []byte(`{"user_count":1,"occupied":true,"subscription_count":1}`) 42 | channelName := "test" 43 | expected := &Channel{ 44 | Name: channelName, 45 | Occupied: true, 46 | UserCount: 1, 47 | SubscriptionCount: 1, 48 | } 49 | result, err := unmarshalledChannel(testJSON, channelName) 50 | assert.Equal(t, expected, result) 51 | assert.NoError(t, err) 52 | 53 | } 54 | 55 | func TestParsingChannelUsers(t *testing.T) { 56 | testJSON := []byte(`{"users":[{"id":"red"},{"id":"blue"}]}`) 57 | expected := &Users{ 58 | List: []User{User{ID: "red"}, User{ID: "blue"}}, 59 | } 60 | result, err := unmarshalledChannelUsers(testJSON) 61 | assert.Equal(t, expected, result) 62 | assert.NoError(t, err) 63 | 64 | } 65 | 66 | func TestParserError(t *testing.T) { 67 | testJSON := []byte("[];;[[p{{}}{{{}[][][]@£$@") 68 | _, err := unmarshalledChannelsList(testJSON) 69 | assert.Error(t, err) 70 | } 71 | -------------------------------------------------------------------------------- /response_parsing.go: -------------------------------------------------------------------------------- 1 | package pusher 2 | 3 | import ( 4 | "encoding/json" 5 | ) 6 | 7 | // Channel represents the information about a channel from the Pusher API. 8 | type Channel struct { 9 | Name string 10 | Occupied bool `json:"occupied,omitempty"` 11 | UserCount int `json:"user_count,omitempty"` 12 | SubscriptionCount int `json:"subscription_count,omitempty"` 13 | } 14 | 15 | // ChannelsList represents a list of channels received by the Pusher API. 16 | type ChannelsList struct { 17 | Channels map[string]ChannelListItem `json:"channels"` 18 | } 19 | 20 | // ChannelListItem represents an item within ChannelsList 21 | type ChannelListItem struct { 22 | UserCount int `json:"user_count"` 23 | } 24 | 25 | type TriggerChannelsList struct { 26 | Channels map[string]TriggerChannelListItem `json:"channels"` 27 | } 28 | 29 | type TriggerChannelListItem struct { 30 | UserCount *int `json:"user_count,omitempty"` 31 | SubscriptionCount *int `json:"subscription_count,omitempty"` 32 | } 33 | 34 | type TriggerBatchChannelsList struct { 35 | Batch []TriggerBatchChannelListItem `json:"batch"` 36 | } 37 | 38 | type TriggerBatchChannelListItem struct { 39 | UserCount *int `json:"user_count,omitempty"` 40 | SubscriptionCount *int `json:"subscription_count,omitempty"` 41 | } 42 | 43 | // Users represents a list of users in a presence-channel 44 | type Users struct { 45 | List []User `json:"users"` 46 | } 47 | 48 | // User represents a user and contains their ID. 49 | type User struct { 50 | ID string `json:"id"` 51 | } 52 | 53 | /* 54 | MemberData represents what to assign to a channel member, consisting of a 55 | `UserID` and any custom `UserInfo`. 56 | */ 57 | type MemberData struct { 58 | UserID string `json:"user_id"` 59 | UserInfo map[string]string `json:"user_info,omitempty"` 60 | } 61 | 62 | func unmarshalledTriggerChannelsList(response []byte) (*TriggerChannelsList, error) { 63 | channels := &TriggerChannelsList{} 64 | err := json.Unmarshal(response, channels) 65 | 66 | if err != nil { 67 | return nil, err 68 | } 69 | 70 | return channels, nil 71 | } 72 | 73 | func unmarshalledTriggerBatchChannelsList(response []byte) (*TriggerBatchChannelsList, error) { 74 | channels := &TriggerBatchChannelsList{} 75 | err := json.Unmarshal(response, channels) 76 | 77 | if err != nil { 78 | return nil, err 79 | } 80 | 81 | return channels, nil 82 | } 83 | 84 | func unmarshalledChannelsList(response []byte) (*ChannelsList, error) { 85 | channels := &ChannelsList{} 86 | err := json.Unmarshal(response, channels) 87 | 88 | if err != nil { 89 | return nil, err 90 | } 91 | 92 | return channels, nil 93 | } 94 | 95 | func unmarshalledChannel(response []byte, name string) (*Channel, error) { 96 | channel := &Channel{Name: name} 97 | err := json.Unmarshal(response, channel) 98 | 99 | if err != nil { 100 | return nil, err 101 | } 102 | 103 | return channel, nil 104 | } 105 | 106 | func unmarshalledChannelUsers(response []byte) (*Users, error) { 107 | users := &Users{} 108 | err := json.Unmarshal(response, users) 109 | 110 | if err != nil { 111 | return nil, err 112 | } 113 | 114 | return users, nil 115 | } 116 | -------------------------------------------------------------------------------- /encoder.go: -------------------------------------------------------------------------------- 1 | package pusher 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | ) 8 | 9 | // defaultMaxEventPayloadSizeKB indicates the max size allowed for the data content 10 | // (payload) of each event, unless an override is present in the client 11 | const defaultMaxEventPayloadSizeKB = 10 12 | 13 | type batchEvent struct { 14 | Channel string `json:"channel"` 15 | Name string `json:"name"` 16 | Data string `json:"data"` 17 | SocketID *string `json:"socket_id,omitempty"` 18 | Info *string `json:"info,omitempty"` 19 | } 20 | type batchPayload struct { 21 | Batch []batchEvent `json:"batch"` 22 | } 23 | 24 | func encodeTriggerBody( 25 | channels []string, 26 | event string, 27 | data interface{}, 28 | params map[string]string, 29 | encryptionKey []byte, 30 | overrideMaxMessagePayloadKB int, 31 | ) ([]byte, error) { 32 | dataBytes, err := encodeEventData(data) 33 | if err != nil { 34 | return nil, err 35 | } 36 | var payloadData string 37 | if isEncryptedChannel(channels[0]) { 38 | payloadData = encrypt(channels[0], dataBytes, encryptionKey) 39 | } else { 40 | payloadData = string(dataBytes) 41 | } 42 | 43 | eventExceedsMaximumSize := false 44 | if overrideMaxMessagePayloadKB == 0 { 45 | eventExceedsMaximumSize = len(payloadData) > defaultMaxEventPayloadSizeKB*1024 46 | } else { 47 | eventExceedsMaximumSize = len(payloadData) > overrideMaxMessagePayloadKB*1024 48 | } 49 | if eventExceedsMaximumSize { 50 | return nil, errors.New(fmt.Sprintf("Event payload exceeded maximum size (%d bytes is too much)", len(payloadData))) 51 | } 52 | eventPayload := map[string]interface{}{ 53 | "name": event, 54 | "channels": channels, 55 | "data": payloadData, 56 | } 57 | for k, v := range params { 58 | if _, ok := eventPayload[k]; ok { 59 | return nil, errors.New(fmt.Sprintf("Paramater %s specified multiple times", k)) 60 | } 61 | eventPayload[k] = v 62 | } 63 | return json.Marshal(eventPayload) 64 | } 65 | 66 | func encodeTriggerBatchBody( 67 | batch []Event, 68 | encryptionKey []byte, 69 | overrideMaxMessagePayloadKB int, 70 | ) ([]byte, error) { 71 | batchEvents := make([]batchEvent, len(batch)) 72 | for idx, e := range batch { 73 | var stringifyedDataBytes string 74 | dataBytes, err := encodeEventData(e.Data) 75 | if err != nil { 76 | return nil, err 77 | } 78 | if isEncryptedChannel(e.Channel) { 79 | stringifyedDataBytes = encrypt(e.Channel, dataBytes, encryptionKey) 80 | } else { 81 | stringifyedDataBytes = string(dataBytes) 82 | } 83 | eventExceedsMaximumSize := false 84 | if overrideMaxMessagePayloadKB == 0 { 85 | eventExceedsMaximumSize = len(stringifyedDataBytes) > defaultMaxEventPayloadSizeKB*1024 86 | } else { 87 | eventExceedsMaximumSize = len(stringifyedDataBytes) > overrideMaxMessagePayloadKB*1024 88 | } 89 | if eventExceedsMaximumSize { 90 | return nil, fmt.Errorf("Data of the event #%d in batch, exceeded maximum size (%d bytes is too much)", idx, len(stringifyedDataBytes)) 91 | } 92 | newBatchEvent := batchEvent{ 93 | Channel: e.Channel, 94 | Name: e.Name, 95 | Data: stringifyedDataBytes, 96 | SocketID: e.SocketID, 97 | Info: e.Info, 98 | } 99 | batchEvents[idx] = newBatchEvent 100 | } 101 | return json.Marshal(&batchPayload{batchEvents}) 102 | } 103 | 104 | func encodeEventData(data interface{}) ([]byte, error) { 105 | var dataBytes []byte 106 | var err error 107 | 108 | switch d := data.(type) { 109 | case []byte: 110 | dataBytes = d 111 | case string: 112 | dataBytes = []byte(d) 113 | default: 114 | dataBytes, err = json.Marshal(data) 115 | if err != nil { 116 | return nil, err 117 | } 118 | } 119 | return dataBytes, nil 120 | } 121 | -------------------------------------------------------------------------------- /crypto.go: -------------------------------------------------------------------------------- 1 | package pusher 2 | 3 | import ( 4 | "crypto/hmac" 5 | "crypto/md5" 6 | "crypto/rand" 7 | "crypto/sha256" 8 | "encoding/base64" 9 | "encoding/hex" 10 | "encoding/json" 11 | "errors" 12 | "io" 13 | "strings" 14 | 15 | "golang.org/x/crypto/nacl/secretbox" 16 | ) 17 | 18 | // EncryptedMessage contains an encrypted message 19 | type EncryptedMessage struct { 20 | Nonce string `json:"nonce"` 21 | Ciphertext string `json:"ciphertext"` 22 | } 23 | 24 | func hmacSignature(toSign, secret string) string { 25 | return hex.EncodeToString(hmacBytes([]byte(toSign), []byte(secret))) 26 | } 27 | 28 | func hmacBytes(toSign, secret []byte) []byte { 29 | _authSignature := hmac.New(sha256.New, secret) 30 | _authSignature.Write(toSign) 31 | return _authSignature.Sum(nil) 32 | } 33 | 34 | func checkSignature(result, secret string, body []byte) bool { 35 | expected := hmacBytes(body, []byte(secret)) 36 | resultBytes, err := hex.DecodeString(result) 37 | if err != nil { 38 | return false 39 | } 40 | return hmac.Equal(expected, resultBytes) 41 | } 42 | 43 | func createAuthMap(key, secret, stringToSign string, sharedSecret string) map[string]string { 44 | authSignature := hmacSignature(stringToSign, secret) 45 | authString := strings.Join([]string{key, authSignature}, ":") 46 | if sharedSecret != "" { 47 | return map[string]string{"auth": authString, "shared_secret": sharedSecret} 48 | } 49 | return map[string]string{"auth": authString} 50 | } 51 | 52 | func md5Signature(body []byte) string { 53 | _bodyMD5 := md5.New() 54 | _bodyMD5.Write([]byte(body)) 55 | return hex.EncodeToString(_bodyMD5.Sum(nil)) 56 | } 57 | 58 | func encrypt(channel string, data []byte, encryptionKey []byte) string { 59 | sharedSecret := generateSharedSecret(channel, encryptionKey) 60 | nonce := generateNonce() 61 | nonceB64 := base64.StdEncoding.EncodeToString(nonce[:]) 62 | cipherText := secretbox.Seal([]byte{}, data, &nonce, &sharedSecret) 63 | cipherTextB64 := base64.StdEncoding.EncodeToString(cipherText) 64 | return formatMessage(nonceB64, cipherTextB64) 65 | } 66 | 67 | func formatMessage(nonce string, cipherText string) string { 68 | encryptedMessage := &EncryptedMessage{ 69 | Nonce: nonce, 70 | Ciphertext: cipherText, 71 | } 72 | json, err := json.Marshal(encryptedMessage) 73 | if err != nil { 74 | panic(err) 75 | } 76 | 77 | return string(json) 78 | } 79 | 80 | func generateNonce() [24]byte { 81 | var nonce [24]byte 82 | //Trick ReadFull into thinking nonce is a slice 83 | if _, err := io.ReadFull(rand.Reader, nonce[:]); err != nil { 84 | panic(err) 85 | } 86 | return nonce 87 | } 88 | 89 | func generateSharedSecret(channel string, encryptionKey []byte) [32]byte { 90 | return sha256.Sum256(append([]byte(channel), encryptionKey...)) 91 | } 92 | 93 | func decryptEvents(webhookData Webhook, encryptionKey []byte) (*Webhook, error) { 94 | decryptedWebhooks := &Webhook{} 95 | decryptedWebhooks.TimeMs = webhookData.TimeMs 96 | for _, event := range webhookData.Events { 97 | if isEncryptedChannel(event.Channel) { 98 | var encryptedMessage EncryptedMessage 99 | json.Unmarshal([]byte(event.Data), &encryptedMessage) 100 | cipherTextBytes, decodePayloadErr := base64.StdEncoding.DecodeString(encryptedMessage.Ciphertext) 101 | if decodePayloadErr != nil { 102 | return decryptedWebhooks, decodePayloadErr 103 | } 104 | nonceBytes, decodeNonceErr := base64.StdEncoding.DecodeString(encryptedMessage.Nonce) 105 | if decodeNonceErr != nil { 106 | return decryptedWebhooks, decodeNonceErr 107 | } 108 | // Convert slice to fixed length array for secretbox 109 | var nonce [24]byte 110 | copy(nonce[:], []byte(nonceBytes[:])) 111 | 112 | sharedSecret := generateSharedSecret(event.Channel, encryptionKey) 113 | box := []byte(cipherTextBytes) 114 | decryptedBox, ok := secretbox.Open([]byte{}, box, &nonce, &sharedSecret) 115 | if !ok { 116 | return decryptedWebhooks, errors.New("Failed to decrypt event, possibly wrong key?") 117 | } 118 | event.Data = string(decryptedBox) 119 | } 120 | decryptedWebhooks.Events = append(decryptedWebhooks.Events, event) 121 | } 122 | return decryptedWebhooks, nil 123 | } 124 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 5.1.1 4 | 5 | - [CHANGED] readme example for user authentication 6 | 7 | ## 5.1.0 8 | 9 | * [ADDED] SendToUser method 10 | * [ADDED] AuthenticateUser method 11 | * [ADDED] AuthorizePrivateChannel method 12 | * [ADDED] AuthorizePresenceChannel method 13 | * [CHANGED] AuthenticatePrivateChannel method deprecated 14 | * [CHANGED] AuthenticatePresenceChannel method deprecated 15 | 16 | ## 5.0.0 / 2021-02-19 17 | 18 | * Breaking change: `TriggerBatch` now returns `(*TriggerBatchChannelsList, error)` instead of `error` 19 | * Breaking change: `Channels` takes `ChannelsParams` as a parameter instead of `map[string]string` 20 | * Breaking change: `Channel` takes `ChannelParams` as a parameter instead of `map[string]string` 21 | * Breaking change: switches to go modules using option 1. described in https://github.com/golang/go/wiki/Modules#releasing-modules-v2-or-higher - this will cause problems for legacy package managers like `dep` 22 | * Added `TriggerWithParams` and `TriggerMultiWithParams` - they provide support for requesting channel attributes by specifying an `Info` field 23 | * Added a `Info` field to the `Event` type passed to `TriggerBatch` 24 | * Deprecated `TriggerExclusive` and `TriggerMultiExclusive` (use `TriggerWithParams` and `TriggerMultiWithParams` instead) 25 | 26 | ## 4.0.4 / 2020-09-02 27 | 28 | * Allow message size to be overridden for dedicate cluster customers (PR [#63](https://github.com/pusher/pusher-http-go/pull/71)) 29 | 30 | ## 4.0.3 / 2020-07-28 31 | 32 | * Added library name and version in HTTP Header (PR [#62](https://github.com/pusher/pusher-http-go/pull/62)) 33 | * Changed: allow larger (10KB -> 20KB) requests as we sometimes do on dedicated clusters (PR [#66](https://github.com/pusher/pusher-http-go/pull/66)) 34 | 35 | ## 4.0.2 / 2020-07-28 36 | 37 | * Added `go.mod` for managing the library as a Go module 38 | * Changed `github.com/stretchr/testify/assert` with a stable `gopkg.in/stretchr/testify.v1/assert` 39 | 40 | ## 4.0.1 / 2020-04-01 41 | 42 | 43 | * Added EncryptionMasterKeyBase64 parameter 44 | * Deprecated EncryptionMasterKey parameter 45 | 46 | ## 4.0.0 / 2019-05-31 47 | 48 | * This release modifies the entire repo to respect the go linter. This is a significant API breaking change and will likely require you to correct references to the names that were changed in your code. All future releases will respect the linter. A summary of the changes: 49 | * Rename AppId > AppID 50 | * Rename UserId > UserID 51 | * Rename SocketId > SocketID 52 | * Rename Id > ID 53 | * Rename HttpClient > HTTPClient 54 | * Improved comments and tabbing 55 | 56 | ## 3.0.0 / 2019-05-31 57 | 58 | * This release removes the `*BufferedEvents` return from calls to `trigger` is it never did anything. Our documentation elsewhere conflicted with this, and it made the library more complex than it needed to be, so we removed it. 59 | 60 | ## 2.0.0 / 2019-05-31 61 | 62 | * This release removes support for Push Notifications. Check out https://pusher.com/beams for our new, improved Push Notification offering! 63 | 64 | ## 1.3.0 / 2018-08-13 65 | 66 | * This release adds support for end to end encrypted channels, a new feature for Channels. Read more [in our docs](https://pusher.com/docs/client_api_guide/client_encrypted_channels). 67 | 68 | ## 1.2.0 / 2016-05-24 69 | 70 | * Add support for batch events 71 | 72 | ## 1.1.0 / 2016-02-22 73 | 74 | * Introduce a `Cluster` option for the Pusher initializer. 75 | 76 | ## 1.0.0 / 2015-05-14 77 | 78 | * Users can pass in a `http.Client` instance to the Pusher initializer. They can configure this instance directly to have specific options e.g. timeouts. 79 | * Therefore, the `Timeout` field on `pusher.Client` is deprecated. 80 | * `HttpClient()` function is no longer public. HTTP Client configuration is now done on the `HttpClient` **property** of `pusher.Client`. Read [here](https://github.com/pusher/pusher-http-go#request-timeouts) for more details. 81 | * If no `HttpClient` is specified, the library creates one with a default timeout of 5 seconds. 82 | * The library is now GAE compatible. Read [here](https://github.com/pusher/pusher-http-go#google-app-engine) for more details. 83 | 84 | ## 0.2.2 / 2015-05-12 85 | 86 | * Socket_ids are now validated upon Trigger*Exclusive and channel authentication. 87 | 88 | ## 0.2.1 / 2015-04-30 89 | 90 | * Webhook validation uses hmac.Equals to guard against timing attacks. 91 | 92 | ## 0.2.0 / 2015-03-30 93 | 94 | * A HTTP client is shared between requests to allow configuration. If none is set by the user, the library supplies a default. Allows for pipelining or to change the transport. 95 | 96 | ## 0.1.0 / 2015-03-26 97 | 98 | * Instantiation of client from credentials, URL or environment variables. 99 | * User can trigger Pusher events on single channels, multiple channels, and exclude recipients 100 | * Authentication of private and presence channels 101 | * Pusher webhook validation 102 | * Querying application state 103 | * Cluster configuration, HTTPS support, timeout configuration. 104 | -------------------------------------------------------------------------------- /crypto_test.go: -------------------------------------------------------------------------------- 1 | package pusher 2 | 3 | import ( 4 | "encoding/hex" 5 | "testing" 6 | 7 | "gopkg.in/stretchr/testify.v1/assert" 8 | ) 9 | 10 | func TestHmacSignature(t *testing.T) { 11 | expected := "64e3f44166575febbc5de88c9476325ea7d4b3684752158d9fdb31fce34b980d" 12 | toSign := "Hello!" 13 | secret := "supersecret" 14 | hmac := hmacSignature(toSign, secret) 15 | assert.Equal(t, expected, hmac) 16 | } 17 | 18 | func TestHmacBytes(t *testing.T) { 19 | expectedHex := "64e3f44166575febbc5de88c9476325ea7d4b3684752158d9fdb31fce34b980d" 20 | expectedBytes, _ := hex.DecodeString(expectedHex) 21 | toSign := "Hello!" 22 | secret := "supersecret" 23 | hmacBytes := hmacBytes([]byte(toSign), []byte(secret)) 24 | assert.Equal(t, expectedBytes, hmacBytes) 25 | } 26 | 27 | func TestCheckValidSignature(t *testing.T) { 28 | signature := "64e3f44166575febbc5de88c9476325ea7d4b3684752158d9fdb31fce34b980d" 29 | secret := "supersecret" 30 | body := "Hello!" 31 | validSignature := checkSignature(signature, secret, []byte(body)) 32 | assert.Equal(t, true, validSignature) 33 | } 34 | 35 | func TestCheckInvalidSignature(t *testing.T) { 36 | signature := "no" 37 | secret := "supersecret" 38 | body := "Hello!" 39 | validSignature := checkSignature(signature, secret, []byte(body)) 40 | assert.Equal(t, false, validSignature) 41 | } 42 | 43 | func TestCreateAuthMapNoE2E(t *testing.T) { 44 | signature := "64e3f44166575febbc5de88c9476325ea7d4b3684752158d9fdb31fce34b980d" 45 | key := "key" 46 | secret := "supersecret" 47 | stringToSign := "Hello!" 48 | sharedSecret := "" 49 | authMap := createAuthMap(key, secret, stringToSign, sharedSecret) 50 | // The [4:] here removes the prefix of key: from the string. 51 | assert.Equal(t, signature, authMap["auth"][4:]) 52 | assert.Equal(t, "", authMap["shared_secret"]) 53 | } 54 | 55 | func TestCreateAuthMapE2E(t *testing.T) { 56 | signature := "64e3f44166575febbc5de88c9476325ea7d4b3684752158d9fdb31fce34b980d" 57 | key := "key" 58 | secret := "supersecret" 59 | stringToSign := "Hello!" 60 | sharedSecret := "This is a string that is 32 chars" 61 | authMap := createAuthMap(key, secret, stringToSign, sharedSecret) 62 | // The [4:] here removes the prefix of key: from the string. 63 | assert.Equal(t, signature, authMap["auth"][4:]) 64 | assert.Equal(t, sharedSecret, authMap["shared_secret"]) 65 | } 66 | 67 | func TestMD5Signature(t *testing.T) { 68 | expected := "952d2c56d0485958336747bcdd98590d" 69 | actual := md5Signature([]byte("Hello!")) 70 | assert.Equal(t, expected, actual) 71 | } 72 | 73 | func TestEncrypt(t *testing.T) { 74 | channel := "private-encrypted-bla" 75 | body := []byte("Hello!") 76 | encryptionKey := []byte("This is a string that is 32 chars") 77 | cipherText := encrypt(channel, body, encryptionKey) 78 | assert.NotNil(t, cipherText) 79 | assert.NotEqual(t, body, cipherText) 80 | } 81 | 82 | func TestFormatMessage(t *testing.T) { 83 | nonce := "a" 84 | cipherText := "b" 85 | formatted := formatMessage(nonce, cipherText) 86 | assert.Equal(t, `{"nonce":"a","ciphertext":"b"}`, formatted) 87 | } 88 | 89 | func TestGenerateSharedSecret(t *testing.T) { 90 | channel := "private-encrypted-bla" 91 | encryptionKey := []byte("This is a string that is 32 chars") 92 | sharedSecret := generateSharedSecret(channel, encryptionKey) 93 | t.Log(hex.EncodeToString(sharedSecret[:])) 94 | expected := "004831f99d2a4e86723e893caded3a2897deeddbed9514fe9497dcddc52bd50b" 95 | assert.Equal(t, expected, hex.EncodeToString(sharedSecret[:])) 96 | } 97 | 98 | func TestDecryptValidKey(t *testing.T) { 99 | channel := "private-encrypted-bla" 100 | plaintext := "Hello!" 101 | cipherText := `{"nonce":"sjklahvpWWQgAjTx5FfYHCCxd2AmaL9T","ciphertext":"zoDEe8dA3nDXKsybAWce/hXGW4szJw=="}` 102 | encryptionKey := []byte("This is a string that is 32 chars") 103 | 104 | encryptedWebhookData := &Webhook{ 105 | TimeMs: 1, 106 | Events: []WebhookEvent{ 107 | WebhookEvent{ 108 | Name: "event", 109 | Channel: channel, 110 | Event: "event", 111 | Data: cipherText, 112 | SocketID: "44610.7511910", 113 | }, 114 | }, 115 | } 116 | 117 | expectedWebhookData := &Webhook{ 118 | TimeMs: 1, 119 | Events: []WebhookEvent{ 120 | WebhookEvent{ 121 | Name: "event", 122 | Channel: channel, 123 | Event: "event", 124 | Data: plaintext, 125 | SocketID: "44610.7511910", 126 | }, 127 | }, 128 | } 129 | decryptedWebhooks, _ := decryptEvents(*encryptedWebhookData, encryptionKey) 130 | assert.Equal(t, expectedWebhookData, decryptedWebhooks) 131 | } 132 | 133 | func TestDecryptInvalidKey(t *testing.T) { 134 | channel := "private-encrypted-bla" 135 | cipherText := `{"nonce":"sjklahvpWWQgAjTx5FfYHCCxd2AmaL9T","ciphertext":"zoDEe8dA3nDXKsybAWce/hXGW4szJw=="}` 136 | encryptionKey := []byte("This is an invalid key 32 chars!!") 137 | 138 | encryptedWebhookData := &Webhook{ 139 | TimeMs: 1, 140 | Events: []WebhookEvent{ 141 | WebhookEvent{ 142 | Name: "event", 143 | Channel: channel, 144 | Event: "event", 145 | Data: cipherText, 146 | SocketID: "44610.7511910", 147 | }, 148 | }, 149 | } 150 | decryptedWebhooks, err := decryptEvents(*encryptedWebhookData, encryptionKey) 151 | assert.Equal(t, []WebhookEvent(nil), decryptedWebhooks.Events) 152 | assert.EqualError(t, err, "Failed to decrypt event, possibly wrong key?") 153 | } 154 | -------------------------------------------------------------------------------- /request_url_test.go: -------------------------------------------------------------------------------- 1 | package pusher 2 | 3 | import ( 4 | "testing" 5 | 6 | "gopkg.in/stretchr/testify.v1/assert" 7 | ) 8 | 9 | func TestTriggerRequestUrl(t *testing.T) { 10 | expected := "http://api.pusherapp.com/apps/3/events?auth_key=278d425bdf160c739803&auth_signature=da454824c97ba181a32ccc17a72625ba02771f50b50e1e7430e47a1f3f457e6c&auth_timestamp=1353088179&auth_version=1.0&body_md5=ec365a775a4cd0599faeb73354201b6f" 11 | payload := []byte(`{"name":"foo","channels":["project-3"],"data":"{\"some\":\"data\"}"}`) 12 | result, _ := createRequestURL("POST", "", "/apps/3/events", "278d425bdf160c739803", "7ad3773142a6692b25b8", "1353088179", false, payload, nil, "") 13 | assert.Equal(t, expected, result) 14 | } 15 | 16 | func TestBuildClusterTriggerUrl(t *testing.T) { 17 | expected := "http://api-eu.pusher.com/apps/3/events?auth_key=278d425bdf160c739803&auth_signature=da454824c97ba181a32ccc17a72625ba02771f50b50e1e7430e47a1f3f457e6c&auth_timestamp=1353088179&auth_version=1.0&body_md5=ec365a775a4cd0599faeb73354201b6f" 18 | payload := []byte(`{"name":"foo","channels":["project-3"],"data":"{\"some\":\"data\"}"}`) 19 | result, _ := createRequestURL("POST", "", "/apps/3/events", "278d425bdf160c739803", "7ad3773142a6692b25b8", "1353088179", false, payload, nil, "eu") 20 | assert.Equal(t, expected, result) 21 | } 22 | 23 | func TestBuildCustomHostTriggerUrl(t *testing.T) { 24 | expected := "http://my.server.com/apps/3/events?auth_key=278d425bdf160c739803&auth_signature=da454824c97ba181a32ccc17a72625ba02771f50b50e1e7430e47a1f3f457e6c&auth_timestamp=1353088179&auth_version=1.0&body_md5=ec365a775a4cd0599faeb73354201b6f" 25 | payload := []byte(`{"name":"foo","channels":["project-3"],"data":"{\"some\":\"data\"}"}`) 26 | result, _ := createRequestURL("POST", "my.server.com", "/apps/3/events", "278d425bdf160c739803", "7ad3773142a6692b25b8", "1353088179", false, payload, nil, "") 27 | assert.Equal(t, expected, result) 28 | } 29 | 30 | func TestTriggerSecureRequestUrl(t *testing.T) { 31 | expected := "https://api.pusherapp.com/apps/3/events?auth_key=278d425bdf160c739803&auth_signature=da454824c97ba181a32ccc17a72625ba02771f50b50e1e7430e47a1f3f457e6c&auth_timestamp=1353088179&auth_version=1.0&body_md5=ec365a775a4cd0599faeb73354201b6f" 32 | payload := []byte(`{"name":"foo","channels":["project-3"],"data":"{\"some\":\"data\"}"}`) 33 | result, _ := createRequestURL("POST", "", "/apps/3/events", "278d425bdf160c739803", "7ad3773142a6692b25b8", "1353088179", true, payload, nil, "") 34 | assert.Equal(t, expected, result) 35 | } 36 | 37 | func TestGetAllChannelsUrl(t *testing.T) { 38 | expected := "http://api.pusherapp.com/apps/102015/channels?auth_key=d41a439c438a100756f5&auth_signature=4d8a02edcc8a758b0162cd6da690a9a45fb8ae326a276dca1e06a0bc42796c11&auth_timestamp=1427034994&auth_version=1.0&filter_by_prefix=presence-&info=user_count" 39 | additionalQueries := map[string]string{"filter_by_prefix": "presence-", "info": "user_count"} 40 | result, _ := createRequestURL("GET", "", "/apps/102015/channels", "d41a439c438a100756f5", "4bf35003e819bb138249", "1427034994", false, nil, additionalQueries, "") 41 | assert.Equal(t, expected, result) 42 | } 43 | 44 | func TestGetAllChannelsWithOneAdditionalParamUrl(t *testing.T) { 45 | expected := "http://api.pusherapp.com/apps/102015/channels?auth_key=d41a439c438a100756f5&auth_signature=b540383af4582af5fbb5df7be5472d54bd0838c9c2021c7743062568839e6f97&auth_timestamp=1427036577&auth_version=1.0&filter_by_prefix=presence-" 46 | additionalQueries := map[string]string{"filter_by_prefix": "presence-"} 47 | result, _ := createRequestURL("GET", "", "/apps/102015/channels", "d41a439c438a100756f5", "4bf35003e819bb138249", "1427036577", false, nil, additionalQueries, "") 48 | assert.Equal(t, expected, result) 49 | } 50 | 51 | func TestGetAllChannelsWithNoParamsUrl(t *testing.T) { 52 | expected := "http://api.pusherapp.com/apps/102015/channels?auth_key=d41a439c438a100756f5&auth_signature=df89248f87f6e6d028925e0b04d60f316527a865992ace6936afa91281d8bef0&auth_timestamp=1427036787&auth_version=1.0" 53 | additionalQueries := map[string]string{} 54 | result, _ := createRequestURL("GET", "", "/apps/102015/channels", "d41a439c438a100756f5", "4bf35003e819bb138249", "1427036787", false, nil, additionalQueries, "") 55 | assert.Equal(t, expected, result) 56 | } 57 | 58 | func TestGetChannelUrl(t *testing.T) { 59 | expected := "http://api.pusherapp.com/apps/102015/channels/presence-session-d41a439c438a100756f5-4bf35003e819bb138249-ROpCFmgFhXY?auth_key=d41a439c438a100756f5&auth_signature=f93ceb31f396aef336226efe512aaf339bd5e39c7c2c04b81cc8681dc16ee785&auth_timestamp=1427053326&auth_version=1.0&info=user_count,subscription_count" 60 | additionalQueries := map[string]string{"info": "user_count,subscription_count"} 61 | result, _ := createRequestURL("GET", "", "/apps/102015/channels/presence-session-d41a439c438a100756f5-4bf35003e819bb138249-ROpCFmgFhXY", "d41a439c438a100756f5", "4bf35003e819bb138249", "1427053326", false, nil, additionalQueries, "") 62 | assert.Equal(t, expected, result) 63 | } 64 | 65 | func TestGetUsersUrl(t *testing.T) { 66 | expected := "http://api.pusherapp.com/apps/102015/channels/presence-session-d41a439c438a100756f5-4bf35003e819bb138249-nYJLy67qh52/users?auth_key=d41a439c438a100756f5&auth_signature=207feaf4e8efeb24e5f148011704251bf90e2059a5f97a3eb52d06178b11feca&auth_timestamp=1427053709&auth_version=1.0" 67 | result, _ := createRequestURL("GET", "", "/apps/102015/channels/presence-session-d41a439c438a100756f5-4bf35003e819bb138249-nYJLy67qh52/users", "d41a439c438a100756f5", "4bf35003e819bb138249", "1427053709", false, nil, nil, "") 68 | assert.Equal(t, expected, result) 69 | } 70 | 71 | func TestBrokenUrl(t *testing.T) { 72 | result, err := createRequestURL("GET", "", "#%$)(!foo", "d41a439c438a100756f5", "4bf35003e819bb138249", "1427053709", false, nil, nil, "") 73 | assert.Error(t, err) 74 | assert.Equal(t, "", result) 75 | } 76 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pusher Channels HTTP Go Library 2 | 3 | [![Build Status](https://github.com/pusher/pusher-http-go/workflows/Tests/badge.svg)](https://github.com/pusher/pusher-http-go/actions?query=workflow%3ATests+branch%3Amaster) [![Coverage Status](https://coveralls.io/repos/github/pusher/pusher-http-go/badge.svg?branch=master)](https://coveralls.io/github/pusher/pusher-http-go?branch=master) [![Go Reference](https://pkg.go.dev/badge/github.com/pusher/pusher-http-go/v5.svg)](https://pkg.go.dev/github.com/pusher/pusher-http-go/v5) 4 | 5 | The Golang library for interacting with the Pusher Channels HTTP API. 6 | 7 | This package lets you trigger events to your client and query the state of your Pusher channels. When used with a server, you can validate Pusher Channels webhooks and authorize `private-` or `presence-` channels. 8 | 9 | Register for free at and use the application credentials within your app as shown below. 10 | 11 | ## Supported Platforms 12 | 13 | * Go - supports **Go 1.11 or greater**. 14 | 15 | ## Table of Contents 16 | 17 | - [Installation](#installation) 18 | - [Getting Started](#getting-started) 19 | - [Configuration](#configuration) 20 | - [Additional options](#additional-options) 21 | - [Google App Engine](#google-app-engine) 22 | - [Usage](#usage) 23 | - [Triggering events](#triggering-events) 24 | - [Authenticating Users](#authenticating-users) 25 | - [Authorizing Channels](#authorizing-channels) 26 | - [Application state](#application-state) 27 | - [Webhook validation](#webhook-validation) 28 | - [Feature Support](#feature-support) 29 | - [Developing the Library](#developing-the-library) 30 | - [Running the tests](#running-the-tests) 31 | - [License](#license) 32 | 33 | ## Installation 34 | 35 | ```sh 36 | $ go get github.com/pusher/pusher-http-go/v5 37 | ``` 38 | 39 | ## Getting Started 40 | 41 | ```go 42 | package main 43 | 44 | import ( 45 | "github.com/pusher/pusher-http-go/v5" 46 | ) 47 | 48 | func main(){ 49 | // instantiate a client 50 | pusherClient := pusher.Client{ 51 | AppID: "APP_ID", 52 | Key: "APP_KEY", 53 | Secret: "APP_SECRET", 54 | Cluster: "APP_CLUSTER", 55 | } 56 | 57 | data := map[string]string{"message": "hello world"} 58 | 59 | // trigger an event on a channel, along with a data payload 60 | err := pusherClient.Trigger("my-channel", "my_event", data) 61 | 62 | // All trigger methods return an error object, it's worth at least logging this! 63 | if err != nil { 64 | panic(err) 65 | } 66 | } 67 | ``` 68 | 69 | ## Configuration 70 | 71 | The easiest way to configure the library is by creating a new `Pusher` instance: 72 | 73 | ```go 74 | pusherClient := pusher.Client{ 75 | AppID: "APP_ID", 76 | Key: "APP_KEY", 77 | Secret: "APP_SECRET", 78 | Cluster: "APP_CLUSTER", 79 | } 80 | ``` 81 | 82 | ### Additional options 83 | 84 | #### Instantiation From URL 85 | 86 | ```go 87 | pusherClient := pusher.ClientFromURL("http://:@api-.pusher.com/apps/app_id") 88 | ``` 89 | 90 | Note: the API URL differs depending on the cluster your app was created in: 91 | 92 | ``` 93 | http://key:secret@api-eu.pusher.com/apps/app_id 94 | http://key:secret@api-ap1.pusher.com/apps/app_id 95 | ``` 96 | 97 | #### Instantiation From Environment Variable 98 | 99 | ```go 100 | pusherClient := pusher.ClientFromEnv("PUSHER_URL") 101 | ``` 102 | 103 | This is particularly relevant if you are using Pusher Channels as a Heroku add-on, which stores credentials in a `"PUSHER_URL"` environment variable. 104 | 105 | #### HTTPS 106 | 107 | To ensure requests occur over HTTPS, set the `Secure` property of a `pusher.Client` to `true`. 108 | 109 | ```go 110 | pusherClient.Secure = true 111 | ``` 112 | 113 | This is `false` by default. 114 | 115 | #### Request Timeouts 116 | 117 | If you wish to set a time-limit for each HTTP request, create a `http.Client` instance with your specified `Timeout` field and set it as the Pusher Channels instance's `Client`: 118 | 119 | ```go 120 | httpClient := &http.Client{Timeout: time.Second * 3} 121 | 122 | pusherClient.HTTPClient = httpClient 123 | ``` 124 | 125 | If you do not specifically set a HTTP client, a default one is created with a timeout of 5 seconds. 126 | 127 | #### Changing Host 128 | 129 | Changing the `pusher.Client`'s `Host` property will make sure requests are sent to your specified host. 130 | 131 | ```go 132 | pusherClient.Host = "foo.bar.com" 133 | ``` 134 | 135 | By default, this is `"api.pusherapp.com"`. 136 | 137 | #### Changing the Cluster 138 | 139 | Setting the `pusher.Client`'s `Cluster` property will make sure requests are sent to the cluster where you created your app. 140 | 141 | *NOTE! If `Host` is set then `Cluster` will be ignored.* 142 | 143 | ```go 144 | pusherClient.Cluster = "eu" // in this case requests will be made to api-eu.pusher.com. 145 | ``` 146 | #### End to End Encryption 147 | 148 | This library supports end to end encryption of your private channels. This means that only you and your connected clients will be able to read your messages. Pusher cannot decrypt them. You can enable this feature by following these steps: 149 | 150 | 1. You should first set up Private channels. This involves [creating an authorization endpoint on your server](https://pusher.com/docs/authorizing_users). 151 | 152 | 2. Next, generate a 32 byte master encryption key, base64 encode it and store 153 | it securely. 154 | 155 | This is secret and you should never share this with anyone. Not even Pusher. 156 | 157 | To generate a suitable key from a secure random source, you could use: 158 | 159 | ```bash 160 | openssl rand -base64 32 161 | ``` 162 | 163 | 3. Pass the encoded key when constructing your pusher.Client 164 | 165 | ```go 166 | pusherClient := pusher.Client{ 167 | AppID: "APP_ID", 168 | Key: "APP_KEY", 169 | Secret: "APP_SECRET", 170 | Cluster: "APP_CLUSTER", 171 | EncryptionMasterKeyBase64 "", 172 | } 173 | ``` 174 | 4. Channels where you wish to use end to end encryption should be prefixed with `private-encrypted-`. 175 | 176 | 5. Subscribe to these channels in your client, and you're done! You can verify it is working by checking out the debug console on the https://dashboard.pusher.com/ and seeing the scrambled ciphertext. 177 | 178 | **Important note: This will not encrypt messages on channels that are not prefixed by private-encrypted-.** 179 | 180 | ### Google App Engine 181 | 182 | As of version 1.0.0, this library is compatible with Google App Engine's urlfetch library. Pass in the HTTP client returned by `urlfetch.Client` to your Pusher Channels initialization struct. 183 | 184 | ```go 185 | package helloworldapp 186 | 187 | import ( 188 | "appengine" 189 | "appengine/urlfetch" 190 | "fmt" 191 | "github.com/pusher/pusher-http-go/v5" 192 | "net/http" 193 | ) 194 | 195 | func init() { 196 | http.HandleFunc("/", handler) 197 | } 198 | 199 | func handler(w http.ResponseWriter, r *http.Request) { 200 | c := appengine.NewContext(r) 201 | urlfetchClient := urlfetch.Client(c) 202 | 203 | pusherClient := pusher.Client{ 204 | AppID: "APP_ID", 205 | Key: "APP_KEY", 206 | Secret: "APP_SECRET", 207 | HTTPClient: urlfetchClient, 208 | } 209 | 210 | pusherClient.Trigger("my-channel", "my_event", map[string]string{"message": "hello world"}) 211 | 212 | fmt.Fprint(w, "Hello, world!") 213 | } 214 | ``` 215 | 216 | ## Usage 217 | 218 | ### Triggering events 219 | 220 | It is possible to trigger an event on one or more channels. Channel names can contain only characters which are alphanumeric, `_` or `-` and have to be at most 200 characters long. Event name can be at most 200 characters long too. 221 | 222 | #### Custom Types 223 | 224 | **pusher.Event** 225 | 226 | ```go 227 | type TriggerParams struct { 228 | SocketID *string 229 | Info *string 230 | } 231 | ``` 232 | 233 | Note: `Info` is part of an [experimental feature](https://pusher.com/docs/lab#experimental-program). 234 | 235 | #### Single channel 236 | 237 | ##### `func (c *Client) Trigger` 238 | 239 | | Argument |Description | 240 | | :-: | :-: | 241 | | channel `string` | The name of the channel you wish to trigger on. | 242 | | event `string` | The name of the event you wish to trigger. | 243 | | data `interface{}` | The payload you wish to send. Must be marshallable into JSON. | 244 | 245 | ###### Example 246 | 247 | ```go 248 | data := map[string]string{"hello": "world"} 249 | pusherClient.Trigger("greeting_channel", "say_hello", data) 250 | ``` 251 | 252 | ##### `func (c *Client) TriggerWithParams` 253 | 254 | Allows additional parameters to be included as part of the request body. 255 | The complete list of parameters are documented [here](https://pusher.com/docs/channels/library_auth_reference/rest-api#request). 256 | 257 | | Argument |Description | 258 | | :-: | :-: | 259 | | channel `string` | The name of the channel you wish to trigger on. | 260 | | event `string` | The name of the event you wish to trigger. | 261 | | data `interface{}` | The payload you wish to send. Must be marshallable into JSON. | 262 | | params `TriggerParams` | Any additional parameters. | 263 | 264 | | Return Value | Description | 265 | | :-: | :-: | 266 | | channels `TriggerChannelsList` | A struct representing channel attributes for the requested `TriggerParams.Info` | 267 | | err `error` | Any errors encountered| 268 | 269 | ###### Example 270 | 271 | ```go 272 | data := map[string]string{"hello": "world"} 273 | socketID := "1234.12" 274 | attributes := "user_count" 275 | params := pusher.TriggerParams{SocketID: &socketID, Info: &attributes} 276 | channels, err := pusherClient.TriggerWithParams("presence-chatroom", "say_hello", data, params) 277 | 278 | // channels => &{Channels:map[presence-chatroom:{UserCount:4}]} 279 | ``` 280 | 281 | #### Multiple channels 282 | 283 | ##### `func (c. *Client) TriggerMulti` 284 | 285 | | Argument | Description | 286 | | :-: | :-: | 287 | | channels `[]string` | A slice of channel names you wish to send an event on. The maximum length is 10. | 288 | | event `string` | As above. | 289 | | data `interface{}` | As above. | 290 | 291 | ###### Example 292 | 293 | ```go 294 | pusherClient.TriggerMulti([]string{"a_channel", "another_channel"}, "event", data) 295 | ``` 296 | 297 | ##### `func (c. *Client) TriggerMultiWithParams` 298 | 299 | | Argument | Description | 300 | | :-: | :-: | 301 | | channels `[]string` | A slice of channel names you wish to send an event on. The maximum length is 10. | 302 | | event `string` | As above. | 303 | | data `interface{}` | As above. | 304 | | params `TriggerParams` | As above. | 305 | 306 | | Return Value | Description | 307 | | :-: | :-: | 308 | | channels `TriggerChannelsList` | A struct representing channel attributes for the requested `TriggerParams.Info` | 309 | | err `error` | Any errors encountered| 310 | 311 | ###### Example 312 | 313 | ```go 314 | data := map[string]string{"hello": "world"} 315 | socketID := "1234.12" 316 | attributes := "user_count" 317 | params := pusher.TriggerParams{SocketID: &socketID, Info: &attributes} 318 | channels, err := pusherClient.TriggerMultiWithParams([]string{"presence-chatroom", "presence-notifications"}, "event", data, params) 319 | 320 | // channels => &{Channels:map[presence-chatroom:{UserCount:4} presence-notifications:{UserCount:31}]} 321 | ``` 322 | 323 | #### Batches 324 | 325 | ##### `func (c. *Client) TriggerBatch` 326 | 327 | | Argument | Description | 328 | | :-: | :-: | 329 | | batch `[]Event` | A list of events to publish | 330 | 331 | | Return Value | Description | 332 | | :-: | :-: | 333 | | batch `TriggerBatchChannelsList` | A struct representing channel attributes for the requested `TriggerParams.Info` | 334 | | err `error` | Any errors encountered| 335 | 336 | ###### Custom Types 337 | 338 | **pusher.Event** 339 | 340 | ```go 341 | type Event struct { 342 | Channel string 343 | Name string 344 | Data interface{} 345 | SocketID *string 346 | Info *string 347 | } 348 | ``` 349 | 350 | Note: `Info` is part of an [experimental feature](https://pusher.com/docs/lab#experimental-program). 351 | 352 | ###### Example 353 | 354 | ```go 355 | socketID := "1234.12" 356 | attributes := "user_count" 357 | batch := []pusher.Event{ 358 | { Channel: "a-channel", Name: "event", Data: "hello world" }, 359 | { Channel: "presence-b-channel", Name: "event", Data: "hi my name is bob", SocketID: &socketID, Info: &attributes }, 360 | } 361 | response, err := pusherClient.TriggerBatch(batch) 362 | 363 | for i, attributes := range response.Batch { 364 | if attributes.UserCount != nil { 365 | fmt.Printf("channel: %s, name: %s, user_count: %d\n", batch[i].Channel, batch[i].Name, *attributes.UserCount) 366 | } else { 367 | fmt.Printf("channel: %s, name: %s\n", batch[i].Channel, batch[i].Name) 368 | } 369 | } 370 | 371 | // channel: a-channel, name: event 372 | // channel: presence-b-channel, name: event, user_count: 4 373 | ``` 374 | 375 | #### Send to user 376 | 377 | ##### `func (c *Client) SendToUser` 378 | 379 | | Argument |Description | 380 | | :-: | :-: | 381 | | userId `string` | The id of the user who should receive the event. | 382 | | event `string` | The name of the event you wish to trigger. | 383 | | data `interface{}` | The payload you wish to send. Must be marshallable into JSON. | 384 | 385 | ###### Example 386 | 387 | ```go 388 | data := map[string]string{"hello": "world"} 389 | pusherClient.SendToUser("user123", "say_hello", data) 390 | ``` 391 | 392 | ### Authenticating Users 393 | 394 | Pusher Channels provides a mechanism for authenticating users. This can be used to send messages to specific users based on user id and to terminate misbehaving user connections, for example. 395 | 396 | For more information see our [docs](http://pusher.com/docs/authenticating_users). 397 | 398 | #### `func (c *Client) AuthenticateUser` 399 | 400 | | Argument | Description | 401 | | :-: | :-: | 402 | | params `[]byte` | The request body sent by the client | 403 | | userData `map[string]interface{}` | The map containing arbitrary user data. It must contain at least an `id` field with the user's id as a string. See below. | 404 | 405 | ###### Arbitrary User Data 406 | 407 | ```go 408 | userData := map[string]interface{} { "id": "1234", "twitter": "jamiepatel" } 409 | ``` 410 | 411 | | Return Value | Description | 412 | | :-: | :-: | 413 | | response `[]byte` | The response to send back to the client, carrying an authentication signature | 414 | | err `error` | Any errors generated | 415 | 416 | ###### Example 417 | 418 | ```go 419 | func pusherUserAuth(res http.ResponseWriter, req *http.Request) { 420 | params, _ := ioutil.ReadAll(req.Body) 421 | userData := map[string]interface{} { "id": "1234", "twitter": "jamiepatel" } 422 | response, err := pusherClient.AuthenticateUser(params, userData) 423 | if err != nil { 424 | panic(err) 425 | } 426 | 427 | fmt.Fprintf(res, string(response)) 428 | } 429 | 430 | func main() { 431 | http.HandleFunc("/pusher/user-auth", pusherUserAuth) 432 | http.ListenAndServe(":5000", nil) 433 | } 434 | ``` 435 | 436 | ### Authorizing Channels 437 | 438 | Application security is very important so Pusher Channels provides a mechanism for authorizing a user’s access to a channel at the point of subscription. 439 | 440 | This can be used both to restrict access to private channels, and in the case of presence channels notify subscribers of who else is also subscribed via presence events. 441 | 442 | This library provides a mechanism for generating an authorization signature to send back to the client and authorize them. 443 | 444 | For more information see our [docs](http://pusher.com/docs/authorizing_users). 445 | 446 | #### Private channels 447 | 448 | ##### `func (c *Client) AuthorizePrivateChannel` 449 | 450 | | Argument | Description | 451 | | :-: | :-: | 452 | | params `[]byte` | The request body sent by the client | 453 | 454 | | Return Value | Description | 455 | | :-: | :-: | 456 | | response `[]byte` | The response to send back to the client, carrying an authorization signature | 457 | | err `error` | Any errors generated | 458 | 459 | ###### Example 460 | 461 | ```go 462 | func pusherAuth(res http.ResponseWriter, req *http.Request) { 463 | params, _ := ioutil.ReadAll(req.Body) 464 | response, err := pusherClient.AuthorizePrivateChannel(params) 465 | if err != nil { 466 | panic(err) 467 | } 468 | 469 | fmt.Fprintf(res, string(response)) 470 | } 471 | 472 | func main() { 473 | http.HandleFunc("/pusher/auth", pusherAuth) 474 | http.ListenAndServe(":5000", nil) 475 | } 476 | ``` 477 | 478 | ###### Example (JSONP) 479 | 480 | ```go 481 | func pusherJsonpAuth(res http.ResponseWriter, req *http.Request) { 482 | var ( 483 | callback, params string 484 | ) 485 | 486 | { 487 | q := r.URL.Query() 488 | callback = q.Get("callback") 489 | if callback == "" { 490 | panic("callback missing") 491 | } 492 | q.Del("callback") 493 | params = []byte(q.Encode()) 494 | } 495 | 496 | response, err := pusherClient.AuthorizePrivateChannel(params) 497 | if err != nil { 498 | panic(err) 499 | } 500 | 501 | res.Header().Set("Content-Type", "application/javascript; charset=utf-8") 502 | fmt.Fprintf(res, "%s(%s);", callback, string(response)) 503 | } 504 | 505 | func main() { 506 | http.HandleFunc("/pusher/auth", pusherJsonpAuth) 507 | http.ListenAndServe(":5000", nil) 508 | } 509 | ``` 510 | 511 | #### Authorizing presence channels 512 | 513 | Using presence channels is similar to private channels, but in order to identify a user, clients are sent a user_id and, optionally, custom data. 514 | 515 | ##### `func (c *Client) AuthorizePresenceChannel` 516 | 517 | | Argument | Description | 518 | | :-: | :-: | 519 | | params `[]byte` | The request body sent by the client | 520 | | member `pusher.MemberData` | A struct representing what to assign to a channel member, consisting of a `UserID` and any custom `UserInfo`. See below | 521 | 522 | ###### Custom Types 523 | 524 | **pusher.MemberData** 525 | 526 | ```go 527 | type MemberData struct { 528 | UserID string 529 | UserInfo map[string]string 530 | } 531 | ``` 532 | 533 | ###### Example 534 | 535 | ```go 536 | params, _ := ioutil.ReadAll(req.Body) 537 | 538 | presenceData := pusher.MemberData{ 539 | UserID: "1", 540 | UserInfo: map[string]string{ 541 | "twitter": "jamiepatel", 542 | }, 543 | } 544 | 545 | response, err := pusherClient.AuthorizePresenceChannel(params, presenceData) 546 | 547 | if err != nil { 548 | panic(err) 549 | } 550 | 551 | fmt.Fprintf(res, response) 552 | ``` 553 | 554 | ### Application state 555 | 556 | This library allows you to query our API to retrieve information about your application's channels, their individual properties, and, for presence-channels, the users currently subscribed to them. 557 | 558 | #### Get the list of channels in an application 559 | 560 | ##### `func (c *Client) Channels` 561 | 562 | | Argument | Description | 563 | | :-: | :-: | 564 | | params `ChannelsParams` | The query options. The field `FilterByPrefix` will filter the returned channels. To get the number of users subscribed to a presence-channel, specify an the `Info` field with value `"user_count"`. Pass in `nil` if you do not wish to specify any query attributes. | 565 | 566 | | Return Value | Description | 567 | | :-: | :-: | 568 | | channels `ChannelsList` | A struct representing the list of channels. See below. | 569 | | err `error` | Any errors encountered| 570 | 571 | ###### Custom Types 572 | 573 | **pusher.ChannelsParams** 574 | 575 | ```go 576 | type ChannelsParams struct { 577 | FilterByPrefix *string 578 | Info *string 579 | } 580 | ``` 581 | 582 | **pusher.ChannelsList** 583 | 584 | ```go 585 | type ChannelsList struct { 586 | Channels map[string]ChannelListItem 587 | } 588 | ``` 589 | 590 | **pusher.ChannelsListItem** 591 | 592 | ```go 593 | type ChannelListItem struct { 594 | UserCount int 595 | } 596 | ``` 597 | 598 | ###### Example 599 | 600 | ```go 601 | prefixFilter := "presence-" 602 | attributes := "user_count" 603 | params := pusher.ChannelsParams{FilterByPrefix: &prefixFilter, Info: &attributes} 604 | channels, err := pusherClient.Channels(params) 605 | 606 | // channels => &{Channels:map[presence-chatroom:{UserCount:4} presence-notifications:{UserCount:31}]} 607 | ``` 608 | 609 | #### Get the state of a single channel 610 | 611 | ##### `func (c *Client) Channel` 612 | 613 | | Argument | Description | 614 | | :-: | :-: | 615 | | name `string` | The name of the channel | 616 | | params `ChannelParams` | The query options. The field `Info` can have comma-separated values of `"user_count"`, for presence-channels, and `"subscription_count"`, for all-channels. To use the `"subscription_count"` value, first check the "Enable subscription counting" checkbox in your App Settings on [your Pusher Channels dashboard](https://dashboard.pusher.com). Pass in `nil` if you do not wish to specify any query attributes. | 617 | 618 | | Return Value | Description | 619 | | :-: | :-: | 620 | | channel `Channel` | A struct representing a channel. See below. | 621 | | err `error` | Any errors encountered | 622 | 623 | ###### Custom Types 624 | 625 | **pusher.ChannelParams** 626 | 627 | ```go 628 | type Channel struct { 629 | Info *string 630 | } 631 | ``` 632 | 633 | **pusher.Channel** 634 | 635 | ```go 636 | type Channel struct { 637 | Name string 638 | Occupied bool 639 | UserCount int 640 | SubscriptionCount int 641 | } 642 | ``` 643 | 644 | ###### Example 645 | 646 | ```go 647 | attributes := "user_count,subscription_count" 648 | params := pusher.ChannelParams{Info: &attributes} 649 | channel, err := client.Channel("presence-chatroom", params) 650 | 651 | // channel => &{Name:presence-chatroom Occupied:true UserCount:42 SubscriptionCount:42} 652 | ``` 653 | 654 | #### Get a list of users in a presence channel 655 | 656 | ##### `func (c *Client) GetChannelUsers` 657 | 658 | | Argument | Description | 659 | | :-: | :-: | 660 | | name `string` | The channel name | 661 | 662 | | Return Value | Description | 663 | | :-: | :-: | 664 | | users `Users` | A struct representing a list of the users subscribed to the presence-channel. See below | 665 | | err `error` | Any errors encountered. | 666 | 667 | ###### Custom Types 668 | 669 | **pusher.Users** 670 | 671 | ```go 672 | type Users struct { 673 | List []User 674 | } 675 | ``` 676 | 677 | **pusher.User** 678 | 679 | ```go 680 | type User struct { 681 | ID string 682 | } 683 | ``` 684 | 685 | ###### Example 686 | 687 | ```go 688 | users, err := pusherClient.GetChannelUsers("presence-chatroom") 689 | 690 | // users => &{List:[{ID:13} {ID:90}]} 691 | ``` 692 | 693 | ### Webhook validation 694 | 695 | On your [dashboard](http://app.pusher.com), you can set up webhooks to POST a payload to your server after certain events. Such events include channels being occupied or vacated, members being added or removed in presence-channels, or after client-originated events. For more information see . 696 | 697 | This library provides a mechanism for checking that these POST requests are indeed from Pusher, by checking the token and authentication signature in the header of the request. 698 | 699 | ##### `func (c *Client) Webhook` 700 | 701 | | Argument | Description | 702 | | :-: | :-: | 703 | | header `http.Header` | The header of the request to verify | 704 | | body `[]byte` | The body of the request | 705 | 706 | | Return Value | Description | 707 | | :-: | :-: | 708 | | webhook `*pusher.Webhook` | If the webhook is valid, this method will return a representation of that webhook that includes its timestamp and associated events. If invalid, this value will be `nil`. | 709 | | err `error` | If the webhook is invalid, an error value will be passed. | 710 | 711 | ###### Custom Types 712 | 713 | **pusher.Webhook** 714 | 715 | ```go 716 | type Webhook struct { 717 | TimeMs int 718 | Events []WebhookEvent 719 | } 720 | ``` 721 | 722 | **pusher.WebhookEvent** 723 | 724 | ```go 725 | type WebhookEvent struct { 726 | Name string 727 | Channel string 728 | Event string 729 | Data string 730 | SocketID string 731 | } 732 | ``` 733 | 734 | ###### Example 735 | 736 | ```go 737 | func pusherWebhook(res http.ResponseWriter, req *http.Request) { 738 | body, _ := ioutil.ReadAll(req.Body) 739 | webhook, err := pusherClient.Webhook(req.Header, body) 740 | if err != nil { 741 | fmt.Println("Webhook is invalid :(") 742 | } else { 743 | fmt.Printf("%+v\n", webhook.Events) 744 | } 745 | } 746 | ``` 747 | 748 | ## Feature Support 749 | 750 | Feature | Supported 751 | -------------------------------------------| :-------: 752 | Trigger event on single channel | *✔* 753 | Trigger event on multiple channels | *✔* 754 | Trigger events in batches | *✔* 755 | Excluding recipients from events | *✔* 756 | Fetching info on trigger | *✔* 757 | Send to user | *✔* 758 | Authenticating users | *✔* 759 | Authorizing private channels | *✔* 760 | Authorizing presence channels | *✔* 761 | Get the list of channels in an application | *✔* 762 | Get the state of a single channel | *✔* 763 | Get a list of users in a presence channel | *✔* 764 | WebHook validation | *✔* 765 | Heroku add-on support | *✔* 766 | Debugging & Logging | *✔* 767 | Cluster configuration | *✔* 768 | Timeouts | *✔* 769 | HTTPS | *✔* 770 | HTTP Proxy configuration | *✘* 771 | HTTP KeepAlive | *✘* 772 | 773 | ## Helper Functionality 774 | 775 | These are helpers that have been implemented to to ensure interactions with the HTTP API only occur if they will not be rejected e.g. [channel naming conventions](https://pusher.com/docs/channels/using_channels/channels#channel-naming-conventions). 776 | 777 | Helper Functionality | Supported 778 | ----------------------------------------- | :-------: 779 | Channel name validation | ✔ 780 | Limit to 10 channels per trigger | ✔ 781 | Limit event name length to 200 chars | ✔ 782 | 783 | ## Developing the Library 784 | 785 | Feel more than free to fork this repo, improve it in any way you'd prefer, and send us a pull request :) 786 | 787 | ### Running the tests 788 | 789 | ```sh 790 | $ go test 791 | ``` 792 | 793 | ## License 794 | 795 | This code is free to use under the terms of the MIT license. 796 | -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | package pusher 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "net/http" 9 | "net/url" 10 | "os" 11 | "regexp" 12 | "strings" 13 | "time" 14 | ) 15 | 16 | var pusherPathRegex = regexp.MustCompile("^/apps/([0-9]+)$") 17 | var maxTriggerableChannels = 100 18 | 19 | const ( 20 | libraryVersion = "5.1.1" 21 | libraryName = "pusher-http-go" 22 | ) 23 | 24 | /* 25 | Client to the HTTP API of Pusher. 26 | 27 | There easiest way to configure the library is by creating a `Pusher` instance: 28 | 29 | client := pusher.Client{ 30 | AppID: "your_app_id", 31 | Key: "your_app_key", 32 | Secret: "your_app_secret", 33 | } 34 | 35 | To ensure requests occur over HTTPS, set the `Secure` property of a 36 | `pusher.Client` to `true`. 37 | 38 | client.Secure = true // false by default 39 | 40 | If you wish to set a time-limit for each HTTP request, set the `Timeout` 41 | property to an instance of `time.Duration`, for example: 42 | 43 | client.Timeout = time.Second * 3 // 5 seconds by default 44 | 45 | Changing the `pusher.Client`'s `Host` property will make sure requests are sent 46 | to your specified host. 47 | 48 | client.Host = "foo.bar.com" // by default this is "api.pusherapp.com". 49 | */ 50 | type Client struct { 51 | AppID string 52 | Key string 53 | Secret string 54 | Host string // host or host:port pair 55 | Secure bool // true for HTTPS 56 | Cluster string 57 | HTTPClient *http.Client 58 | EncryptionMasterKey string // deprecated 59 | EncryptionMasterKeyBase64 string // for E2E 60 | OverrideMaxMessagePayloadKB int // set the agreed Pusher message limit increase 61 | validatedEncryptionMasterKey *[]byte // parsed key for use 62 | } 63 | 64 | /* 65 | ClientFromURL allows client instantiation from a specially-crafted Pusher URL. 66 | 67 | c := pusher.ClientFromURL("http://key:secret@api.pusherapp.com/apps/app_id") 68 | */ 69 | func ClientFromURL(serverURL string) (*Client, error) { 70 | url2, err := url.Parse(serverURL) 71 | if err != nil { 72 | return nil, err 73 | } 74 | 75 | c := Client{ 76 | Host: url2.Host, 77 | } 78 | 79 | matches := pusherPathRegex.FindStringSubmatch(url2.Path) 80 | if len(matches) == 0 { 81 | return nil, errors.New("No app ID found") 82 | } 83 | c.AppID = matches[1] 84 | 85 | if url2.User == nil { 86 | return nil, errors.New("Missing :") 87 | } 88 | c.Key = url2.User.Username() 89 | var isSet bool 90 | c.Secret, isSet = url2.User.Password() 91 | if !isSet { 92 | return nil, errors.New("Missing ") 93 | } 94 | 95 | if url2.Scheme == "https" { 96 | c.Secure = true 97 | } 98 | 99 | return &c, nil 100 | } 101 | 102 | /* 103 | ClientFromEnv allows instantiation of a client from an environment variable. 104 | This is particularly relevant if you are using Pusher as a Heroku add-on, 105 | which stores credentials in a `"PUSHER_URL"` environment variable. For example: 106 | 107 | client := pusher.ClientFromEnv("PUSHER_URL") 108 | */ 109 | func ClientFromEnv(key string) (*Client, error) { 110 | url := os.Getenv(key) 111 | return ClientFromURL(url) 112 | } 113 | 114 | /* 115 | Returns the underlying HTTP client. 116 | Useful to set custom properties to it. 117 | */ 118 | func (c *Client) requestClient() *http.Client { 119 | if c.HTTPClient == nil { 120 | c.HTTPClient = &http.Client{Timeout: time.Second * 5} 121 | } 122 | 123 | return c.HTTPClient 124 | } 125 | 126 | func (c *Client) request(method, url string, body []byte) ([]byte, error) { 127 | return request(c.requestClient(), method, url, body) 128 | } 129 | 130 | /* 131 | Trigger triggers an event to the Pusher API. 132 | It is possible to trigger an event on one or more channels. Channel names can 133 | contain only characters which are alphanumeric, `_` or `-`` and have 134 | to be at most 200 characters long. Event name can be at most 200 characters long too. 135 | 136 | Pass in the channel's name, the event's name, and a data payload. The data payload must 137 | be marshallable into JSON. 138 | 139 | data := map[string]string{"hello": "world"} 140 | client.Trigger("greeting_channel", "say_hello", data) 141 | */ 142 | func (c *Client) Trigger(channel string, eventName string, data interface{}) error { 143 | _, err := c.validateChannelsAndTrigger([]string{channel}, eventName, data, TriggerParams{}) 144 | return err 145 | } 146 | 147 | /* 148 | ChannelsParams are any parameters than can be sent with a 149 | TriggerWithParams or TriggerMultiWithParams requests. 150 | */ 151 | type TriggerParams struct { 152 | // SocketID excludes a recipient whose connection has the `socket_id` 153 | // specified here. You can read more here: 154 | // http://pusher.com/docs/duplicates. 155 | SocketID *string 156 | // Info is comma-separated vales of `"user_count"`, for 157 | // presence-channels, and `"subscription_count"`, for all-channels. 158 | // Note that the subscription count is not allowed by default. Please 159 | // contact us at http://support.pusher.com if you wish to enable this. 160 | // Pass in `nil` if you do not wish to specify any query attributes. 161 | // This is part of an [experimental feature](https://pusher.com/docs/lab#experimental-program). 162 | Info *string 163 | } 164 | 165 | func (params TriggerParams) toMap() map[string]string { 166 | m := make(map[string]string) 167 | if params.SocketID != nil { 168 | m["socket_id"] = *params.SocketID 169 | } 170 | if params.Info != nil { 171 | m["info"] = *params.Info 172 | } 173 | return m 174 | } 175 | 176 | /* 177 | TriggerWithParams is the same as `client.Trigger`, except it allows additional 178 | parameters to be passed in. See: 179 | https://pusher.com/docs/channels/library_auth_reference/rest-api#request 180 | for a complete list. 181 | 182 | data := map[string]string{"hello": "world"} 183 | socketID := "1234.12" 184 | attributes := "user_count" 185 | params := pusher.TriggerParams{SocketID: &socketID, Info: &attributes} 186 | channels, err := client.Trigger("greeting_channel", "say_hello", data, params) 187 | 188 | //channels=> &{Channels:map[presence-chatroom:{UserCount:4} presence-notifications:{UserCount:31}]} 189 | */ 190 | func (c *Client) TriggerWithParams( 191 | channel string, 192 | eventName string, 193 | data interface{}, 194 | params TriggerParams, 195 | ) (*TriggerChannelsList, error) { 196 | return c.validateChannelsAndTrigger([]string{channel}, eventName, data, params) 197 | } 198 | 199 | /* 200 | TriggerMulti is the same as `client.Trigger`, except one passes in a slice of 201 | `channels` as the first parameter. The maximum length of channels is 100. 202 | 203 | client.TriggerMulti([]string{"a_channel", "another_channel"}, "event", data) 204 | */ 205 | func (c *Client) TriggerMulti(channels []string, eventName string, data interface{}) error { 206 | _, err := c.validateChannelsAndTrigger(channels, eventName, data, TriggerParams{}) 207 | return err 208 | } 209 | 210 | /* 211 | TriggerMultiWithParams is the same as `client.TriggerMulti`, except it 212 | allows additional parameters to be specified in the same way as 213 | `client.TriggerWithParams`. 214 | */ 215 | func (c *Client) TriggerMultiWithParams( 216 | channels []string, 217 | eventName string, 218 | data interface{}, 219 | params TriggerParams, 220 | ) (*TriggerChannelsList, error) { 221 | return c.validateChannelsAndTrigger(channels, eventName, data, params) 222 | } 223 | 224 | /* 225 | TriggerExclusive triggers an event excluding a recipient whose connection has 226 | the `socket_id` you specify here from receiving the event. 227 | You can read more here: http://pusher.com/docs/duplicates. 228 | 229 | client.TriggerExclusive("a_channel", "event", data, "123.12") 230 | 231 | Deprecated: use TriggerWithParams instead. 232 | */ 233 | func (c *Client) TriggerExclusive(channel string, eventName string, data interface{}, socketID string) error { 234 | params := TriggerParams{SocketID: &socketID} 235 | _, err := c.validateChannelsAndTrigger([]string{channel}, eventName, data, params) 236 | return err 237 | } 238 | 239 | /* 240 | TriggerMultiExclusive triggers an event to multiple channels excluding a 241 | recipient whose connection has the `socket_id` you specify here from receiving 242 | the event on any of the channels. 243 | 244 | client.TriggerMultiExclusive([]string{"a_channel", "another_channel"}, "event", data, "123.12") 245 | 246 | Deprecated: use TriggerMultiWithParams instead. 247 | */ 248 | func (c *Client) TriggerMultiExclusive(channels []string, eventName string, data interface{}, socketID string) error { 249 | params := TriggerParams{SocketID: &socketID} 250 | _, err := c.validateChannelsAndTrigger(channels, eventName, data, params) 251 | return err 252 | } 253 | 254 | /* 255 | SendToUser triggers an event to a specific user. 256 | Pass in the user id, the event's name, and a data payload. The data payload must 257 | be marshallable into JSON. 258 | 259 | data := map[string]string{"hello": "world"} 260 | client.SendToUser("user123", "say_hello", data) 261 | */ 262 | func (c *Client) SendToUser(userId string, eventName string, data interface{}) error { 263 | if !validUserId(userId) { 264 | return fmt.Errorf("User id '%s' is invalid", userId) 265 | } 266 | _, err := c.trigger([]string{"#server-to-user-" + userId}, eventName, data, TriggerParams{}) 267 | return err 268 | } 269 | 270 | func (c *Client) validateChannelsAndTrigger(channels []string, eventName string, data interface{}, params TriggerParams) (*TriggerChannelsList, error) { 271 | if len(channels) > maxTriggerableChannels { 272 | return nil, fmt.Errorf("You cannot trigger on more than %d channels at once", maxTriggerableChannels) 273 | } 274 | if !channelsAreValid(channels) { 275 | return nil, errors.New("At least one of your channels' names are invalid") 276 | } 277 | return c.trigger(channels, eventName, data, params) 278 | } 279 | 280 | func (c *Client) trigger(channels []string, eventName string, data interface{}, params TriggerParams) (*TriggerChannelsList, error) { 281 | hasEncryptedChannel := false 282 | for _, channel := range channels { 283 | if isEncryptedChannel(channel) { 284 | hasEncryptedChannel = true 285 | } 286 | } 287 | if hasEncryptedChannel && len(channels) > 1 { 288 | // For rationale, see limitations of end-to-end encryption in the README 289 | return nil, errors.New("You cannot trigger to multiple channels when using encrypted channels") 290 | } 291 | masterKey, keyErr := c.encryptionMasterKey() 292 | if hasEncryptedChannel && keyErr != nil { 293 | return nil, keyErr 294 | } 295 | 296 | if err := validateSocketID(params.SocketID); err != nil { 297 | return nil, err 298 | } 299 | 300 | payload, err := encodeTriggerBody(channels, eventName, data, params.toMap(), masterKey, c.OverrideMaxMessagePayloadKB) 301 | if err != nil { 302 | return nil, err 303 | } 304 | path := fmt.Sprintf("/apps/%s/events", c.AppID) 305 | triggerURL, err := createRequestURL("POST", c.Host, path, c.Key, c.Secret, authTimestamp(), c.Secure, payload, nil, c.Cluster) 306 | if err != nil { 307 | return nil, err 308 | } 309 | response, err := c.request("POST", triggerURL, payload) 310 | if err != nil { 311 | return nil, err 312 | } 313 | 314 | return unmarshalledTriggerChannelsList(response) 315 | } 316 | 317 | /* 318 | Event stores all the data for one Event that can be triggered. 319 | */ 320 | type Event struct { 321 | Channel string 322 | Name string 323 | Data interface{} 324 | SocketID *string 325 | // Info is part of an [experimental feature](https://pusher.com/docs/lab#experimental-program). 326 | Info *string 327 | } 328 | 329 | /* 330 | TriggerBatch triggers multiple events on multiple channels in a single call: 331 | 332 | info := "subscription_count" 333 | socketID := "1234.12" 334 | client.TriggerBatch([]pusher.Event{ 335 | { Channel: "donut-1", Name: "ev1", Data: "d1", SocketID: socketID, Info: &info }, 336 | { Channel: "private-encrypted-secretdonut", Name: "ev2", Data: "d2", SocketID: socketID, Info: &info }, 337 | }) 338 | */ 339 | func (c *Client) TriggerBatch(batch []Event) (*TriggerBatchChannelsList, error) { 340 | hasEncryptedChannel := false 341 | // validate every channel name and every sockedID (if present) in batch 342 | for _, event := range batch { 343 | if !validChannel(event.Channel) { 344 | return nil, fmt.Errorf("The channel named %s has a non-valid name", event.Channel) 345 | } 346 | if err := validateSocketID(event.SocketID); err != nil { 347 | return nil, err 348 | } 349 | if isEncryptedChannel(event.Channel) { 350 | hasEncryptedChannel = true 351 | } 352 | } 353 | masterKey, keyErr := c.encryptionMasterKey() 354 | if hasEncryptedChannel && keyErr != nil { 355 | return nil, keyErr 356 | } 357 | 358 | payload, err := encodeTriggerBatchBody(batch, masterKey, c.OverrideMaxMessagePayloadKB) 359 | if err != nil { 360 | return nil, err 361 | } 362 | path := fmt.Sprintf("/apps/%s/batch_events", c.AppID) 363 | triggerURL, err := createRequestURL("POST", c.Host, path, c.Key, c.Secret, authTimestamp(), c.Secure, payload, nil, c.Cluster) 364 | if err != nil { 365 | return nil, err 366 | } 367 | response, err := c.request("POST", triggerURL, payload) 368 | if err != nil { 369 | return nil, err 370 | } 371 | 372 | return unmarshalledTriggerBatchChannelsList(response) 373 | } 374 | 375 | /* 376 | ChannelsParams are any parameters than can be sent with a Channels request. 377 | */ 378 | type ChannelsParams struct { 379 | // FilterByPrefix will filter the returned channels. 380 | FilterByPrefix *string 381 | // Info should be specified with a value of "user_count" to get number 382 | // of users subscribed to a presence-channel. Pass in `nil` if you do 383 | // not wish to specify any query attributes. 384 | Info *string 385 | } 386 | 387 | func (params ChannelsParams) toMap() map[string]string { 388 | m := make(map[string]string) 389 | if params.FilterByPrefix != nil { 390 | m["filter_by_prefix"] = *params.FilterByPrefix 391 | } 392 | if params.Info != nil { 393 | m["info"] = *params.Info 394 | } 395 | return m 396 | } 397 | 398 | /* 399 | Channels returns a list of all the channels in an application. 400 | 401 | prefixFilter := "presence-" 402 | attributes := "user_count" 403 | params := pusher.ChannelsParams{FilterByPrefix: &prefixFilter, Info: &attributes} 404 | channels, err := client.Channels(params) 405 | 406 | //channels=> &{Channels:map[presence-chatroom:{UserCount:4} presence-notifications:{UserCount:31} ]} 407 | */ 408 | func (c *Client) Channels(params ChannelsParams) (*ChannelsList, error) { 409 | path := fmt.Sprintf("/apps/%s/channels", c.AppID) 410 | u, err := createRequestURL("GET", c.Host, path, c.Key, c.Secret, authTimestamp(), c.Secure, nil, params.toMap(), c.Cluster) 411 | if err != nil { 412 | return nil, err 413 | } 414 | response, err := c.request("GET", u, nil) 415 | if err != nil { 416 | return nil, err 417 | } 418 | return unmarshalledChannelsList(response) 419 | } 420 | 421 | /* 422 | ChannelParams are any parameters than can be sent with a Channel request. 423 | */ 424 | type ChannelParams struct { 425 | // Info is comma-separated vales of `"user_count"`, for 426 | // presence-channels, and `"subscription_count"`, for all-channels. 427 | // Note that the subscription count is not allowed by default. Please 428 | // contact us at http://support.pusher.com if you wish to enable this. 429 | // Pass in `nil` if you do not wish to specify any query attributes. 430 | Info *string 431 | } 432 | 433 | func (params ChannelParams) toMap() map[string]string { 434 | m := make(map[string]string) 435 | if params.Info != nil { 436 | m["info"] = *params.Info 437 | } 438 | return m 439 | } 440 | 441 | /* 442 | Channel allows you to get the state of a single channel. 443 | 444 | attributes := "user_count,subscription_count" 445 | params := pusher.ChannelParams{Info: &attributes} 446 | channel, err := client.Channel("presence-chatroom", params) 447 | 448 | //channel=> &{Name:presence-chatroom Occupied:true UserCount:42 SubscriptionCount:42} 449 | */ 450 | func (c *Client) Channel(name string, params ChannelParams) (*Channel, error) { 451 | path := fmt.Sprintf("/apps/%s/channels/%s", c.AppID, name) 452 | u, err := createRequestURL("GET", c.Host, path, c.Key, c.Secret, authTimestamp(), c.Secure, nil, params.toMap(), c.Cluster) 453 | if err != nil { 454 | return nil, err 455 | } 456 | response, err := c.request("GET", u, nil) 457 | if err != nil { 458 | return nil, err 459 | } 460 | return unmarshalledChannel(response, name) 461 | } 462 | 463 | /* 464 | GetChannelUsers returns a list of users in a presence-channel by passing to this 465 | method the channel name. 466 | 467 | users, err := client.GetChannelUsers("presence-chatroom") 468 | 469 | //users=> &{List:[{ID:13} {ID:90}]} 470 | */ 471 | func (c *Client) GetChannelUsers(name string) (*Users, error) { 472 | path := fmt.Sprintf("/apps/%s/channels/%s/users", c.AppID, name) 473 | u, err := createRequestURL("GET", c.Host, path, c.Key, c.Secret, authTimestamp(), c.Secure, nil, nil, c.Cluster) 474 | if err != nil { 475 | return nil, err 476 | } 477 | response, err := c.request("GET", u, nil) 478 | if err != nil { 479 | return nil, err 480 | } 481 | return unmarshalledChannelUsers(response) 482 | } 483 | 484 | /* 485 | AuthenticateUser allows you to authenticate a user's connection. 486 | It returns an authentication signature to send back to the client 487 | and authenticate them. In order to identify a user, this method acceps a map containing 488 | arbitrary user data. It must contain at least an id field with the user's id as a string. 489 | 490 | For more information see our docs: http://pusher.com/docs/authenticating_users. 491 | 492 | This is an example of authenticating a user, using the built-in 493 | Golang HTTP library to start a server. 494 | 495 | In order to authenticate a client, one must read the response into type `[]byte` 496 | and pass it in. This will return a signature in the form of a `[]byte` for you 497 | to send back to the client. 498 | 499 | func pusherUserAuth(res http.ResponseWriter, req *http.Request) { 500 | 501 | params, _ := ioutil.ReadAll(req.Body) 502 | userData := map[string]interface{} { "id": "1234", "twitter": "jamiepatel" } 503 | response, err := client.AuthenticateUser(params, userData) 504 | if err != nil { 505 | panic(err) 506 | } 507 | 508 | fmt.Fprintf(res, string(response)) 509 | } 510 | 511 | func main() { 512 | http.HandleFunc("/pusher/user-auth", pusherUserAuth) 513 | http.ListenAndServe(":5000", nil) 514 | } 515 | */ 516 | func (c *Client) AuthenticateUser(params []byte, userData map[string]interface{}) (response []byte, err error) { 517 | socketID, err := parseUserAuthenticationRequestParams(params) 518 | if err != nil { 519 | return 520 | } 521 | 522 | if err = validateSocketID(&socketID); err != nil { 523 | return 524 | } 525 | 526 | if err = validateUserData(userData); err != nil { 527 | return 528 | } 529 | 530 | var jsonUserData string 531 | if jsonUserData, err = jsonMarshalToString(userData); err != nil { 532 | return 533 | } 534 | stringToSign := strings.Join([]string{socketID, "user", jsonUserData}, "::") 535 | 536 | _response := createAuthMap(c.Key, c.Secret, stringToSign, "") 537 | _response["user_data"] = jsonUserData 538 | 539 | response, err = json.Marshal(_response) 540 | return 541 | } 542 | 543 | /* 544 | AuthorizePrivateChannel allows you to authorize a users subscription to a 545 | private channel. It returns an authorization signature to send back to the client 546 | and authorize them. 547 | 548 | For more information see our docs: http://pusher.com/docs/authorizing_users. 549 | 550 | This is an example of authorizing a private-channel, using the built-in 551 | Golang HTTP library to start a server. 552 | 553 | In order to authorize a client, one must read the response into type `[]byte` 554 | and pass it in. This will return a signature in the form of a `[]byte` for you 555 | to send back to the client. 556 | 557 | func pusherAuth(res http.ResponseWriter, req *http.Request) { 558 | 559 | params, _ := ioutil.ReadAll(req.Body) 560 | response, err := client.AuthorizePrivateChannel(params) 561 | if err != nil { 562 | panic(err) 563 | } 564 | 565 | fmt.Fprintf(res, string(response)) 566 | } 567 | 568 | func main() { 569 | http.HandleFunc("/pusher/auth", pusherAuth) 570 | http.ListenAndServe(":5000", nil) 571 | } 572 | */ 573 | func (c *Client) AuthorizePrivateChannel(params []byte) (response []byte, err error) { 574 | return c.authorizeChannel(params, nil) 575 | } 576 | 577 | /* 578 | AuthenticatePrivateChannel allows you to authorize a users subscription to a 579 | private channel. It returns an authorization signature to send back to the client 580 | and authorize them. 581 | 582 | Deprecated: use AuthorizePrivateChannel instead. 583 | */ 584 | func (c *Client) AuthenticatePrivateChannel(params []byte) (response []byte, err error) { 585 | return c.authorizeChannel(params, nil) 586 | } 587 | 588 | /* 589 | AuthorizePresenceChannel allows you to authorize a users subscription to a 590 | presence channel. It returns an authorization signature to send back to the client 591 | and authorize them. In order to identify a user, clients are sent a user_id and, 592 | optionally, custom data. 593 | 594 | In this library, one does this by passing a `pusher.MemberData` instance. 595 | 596 | params, _ := ioutil.ReadAll(req.Body) 597 | 598 | presenceData := pusher.MemberData{ 599 | UserID: "1", 600 | UserInfo: map[string]string{ 601 | "twitter": "jamiepatel", 602 | }, 603 | } 604 | 605 | response, err := client.AuthorizePresenceChannel(params, presenceData) 606 | if err != nil { 607 | panic(err) 608 | } 609 | 610 | fmt.Fprintf(res, response) 611 | */ 612 | func (c *Client) AuthorizePresenceChannel(params []byte, member MemberData) (response []byte, err error) { 613 | return c.authorizeChannel(params, &member) 614 | } 615 | 616 | /* 617 | AuthenticatePresenceChannel allows you to authorize a users subscription to a 618 | presence channel. It returns an authorization signature to send back to the client 619 | and authorize them. In order to identify a user, clients are sent a user_id and, 620 | optionally, custom data. 621 | 622 | Deprecated: use AuthorizePresenceChannel instead. 623 | */ 624 | func (c *Client) AuthenticatePresenceChannel(params []byte, member MemberData) (response []byte, err error) { 625 | return c.authorizeChannel(params, &member) 626 | } 627 | 628 | func (c *Client) authorizeChannel(params []byte, member *MemberData) (response []byte, err error) { 629 | channelName, socketID, err := parseChannelAuthorizationRequestParams(params) 630 | if err != nil { 631 | return 632 | } 633 | 634 | if err = validateSocketID(&socketID); err != nil { 635 | return 636 | } 637 | 638 | stringToSign := strings.Join([]string{socketID, channelName}, ":") 639 | 640 | var jsonUserData string 641 | 642 | if member != nil { 643 | var _jsonUserData []byte 644 | _jsonUserData, err = json.Marshal(member) 645 | if err != nil { 646 | return 647 | } 648 | 649 | jsonUserData = string(_jsonUserData) 650 | stringToSign = strings.Join([]string{stringToSign, jsonUserData}, ":") 651 | } 652 | 653 | var _response map[string]string 654 | 655 | if isEncryptedChannel(channelName) { 656 | masterKey, err := c.encryptionMasterKey() 657 | if err != nil { 658 | return nil, err 659 | } 660 | sharedSecret := generateSharedSecret(channelName, masterKey) 661 | sharedSecretB64 := base64.StdEncoding.EncodeToString(sharedSecret[:]) 662 | _response = createAuthMap(c.Key, c.Secret, stringToSign, sharedSecretB64) 663 | } else { 664 | _response = createAuthMap(c.Key, c.Secret, stringToSign, "") 665 | } 666 | 667 | if member != nil { 668 | _response["channel_data"] = jsonUserData 669 | } 670 | 671 | response, err = json.Marshal(_response) 672 | return 673 | } 674 | 675 | /* 676 | Webhook allows you to check that a Webhook you receive is indeed from Pusher, by 677 | checking the token and authentication signature in the header of the request. On 678 | your dashboard at http://app.pusher.com, you can set up webhooks to POST a 679 | payload to your server after certain events. Such events include channels being 680 | occupied or vacated, members being added or removed in presence-channels, or 681 | after client-originated events. For more information see 682 | https://pusher.com/docs/webhooks. 683 | 684 | If the webhook is valid, a `*pusher.Webhook* will be returned, and the `err` 685 | value will be nil. If it is invalid, the first return value will be nil, and an 686 | error will be passed. 687 | 688 | func pusherWebhook(res http.ResponseWriter, req *http.Request) { 689 | 690 | body, _ := ioutil.ReadAll(req.Body) 691 | webhook, err := client.Webhook(req.Header, body) 692 | if err != nil { 693 | fmt.Println("Webhook is invalid :(") 694 | } else { 695 | fmt.Printf("%+v\n", webhook.Events) 696 | } 697 | 698 | } 699 | */ 700 | func (c *Client) Webhook(header http.Header, body []byte) (*Webhook, error) { 701 | for _, token := range header["X-Pusher-Key"] { 702 | if token == c.Key && checkSignature(header.Get("X-Pusher-Signature"), c.Secret, body) { 703 | unmarshalledWebhooks, err := unmarshalledWebhook(body) 704 | if err != nil { 705 | return nil, err 706 | } 707 | 708 | hasEncryptedChannel := false 709 | for _, event := range unmarshalledWebhooks.Events { 710 | if isEncryptedChannel(event.Channel) { 711 | hasEncryptedChannel = true 712 | } 713 | } 714 | masterKey, keyErr := c.encryptionMasterKey() 715 | if hasEncryptedChannel && keyErr != nil { 716 | return nil, keyErr 717 | } 718 | 719 | return decryptEvents(*unmarshalledWebhooks, masterKey) 720 | } 721 | } 722 | return nil, errors.New("Invalid webhook") 723 | } 724 | 725 | func (c *Client) encryptionMasterKey() ([]byte, error) { 726 | if c.validatedEncryptionMasterKey != nil { 727 | return *(c.validatedEncryptionMasterKey), nil 728 | } 729 | 730 | if c.EncryptionMasterKey != "" && c.EncryptionMasterKeyBase64 != "" { 731 | return nil, errors.New("Do not specify both EncryptionMasterKey and EncryptionMasterKeyBase64. EncryptionMasterKey is deprecated, specify only EncryptionMasterKeyBase64") 732 | } 733 | 734 | if c.EncryptionMasterKey != "" { 735 | if len(c.EncryptionMasterKey) != 32 { 736 | return nil, errors.New("EncryptionMasterKey must be 32 bytes. It is also deprecated, use EncryptionMasterKeyBase64") 737 | } 738 | 739 | keyBytes := []byte(c.EncryptionMasterKey) 740 | c.validatedEncryptionMasterKey = &keyBytes 741 | return keyBytes, nil 742 | } 743 | 744 | if c.EncryptionMasterKeyBase64 != "" { 745 | keyBytes, err := base64.StdEncoding.DecodeString(c.EncryptionMasterKeyBase64) 746 | if err != nil { 747 | return nil, errors.New("EncryptionMasterKeyBase64 must be valid base64") 748 | } 749 | if len(keyBytes) != 32 { 750 | return nil, errors.New("EncryptionMasterKeyBase64 must encode 32 bytes") 751 | } 752 | 753 | c.validatedEncryptionMasterKey = &keyBytes 754 | return keyBytes, nil 755 | } 756 | 757 | return nil, errors.New("No master encryption key supplied") 758 | } 759 | -------------------------------------------------------------------------------- /client_test.go: -------------------------------------------------------------------------------- 1 | package pusher 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | "net/http" 8 | "net/http/httptest" 9 | "net/url" 10 | "os" 11 | "strings" 12 | "testing" 13 | "time" 14 | 15 | "gopkg.in/stretchr/testify.v1/assert" 16 | ) 17 | 18 | func TestSendToUserSuccessCase(t *testing.T) { 19 | server := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 20 | res.WriteHeader(200) 21 | fmt.Fprintf(res, "{}") 22 | assert.Equal(t, "POST", req.Method) 23 | 24 | expectedBody := map[string]interface{}{"name": "test", "channels": []interface{}{"#server-to-user-123456"}, "data": "yolo"} 25 | bodyDecoder := json.NewDecoder(req.Body) 26 | var actualBody map[string]interface{} 27 | err := bodyDecoder.Decode(&actualBody) 28 | assert.NoError(t, err) 29 | assert.Equal(t, expectedBody, actualBody) 30 | 31 | assert.Equal(t, "application/json", req.Header["Content-Type"][0]) 32 | lib := fmt.Sprintf("%s %s", libraryName, libraryVersion) 33 | assert.Equal(t, lib, req.Header["X-Pusher-Library"][0]) 34 | assert.NoError(t, err) 35 | })) 36 | defer server.Close() 37 | 38 | u, _ := url.Parse(server.URL) 39 | client := Client{AppID: "id", Key: "key", Secret: "secret", Host: u.Host} 40 | err := client.SendToUser("123456", "test", "yolo") 41 | assert.NoError(t, err) 42 | } 43 | 44 | func TestSendToUserRejected(t *testing.T) { 45 | server := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 46 | t.Fatal("No request should reach the API") 47 | })) 48 | defer server.Close() 49 | 50 | u, _ := url.Parse(server.URL) 51 | client := Client{AppID: "id", Key: "key", Secret: "secret", Host: u.Host} 52 | err := client.SendToUser("", "test", "yolo") 53 | assert.Error(t, err) 54 | assert.Contains(t, err.Error(), "User id '' is invalid") 55 | } 56 | 57 | func TestTriggerSuccessCase(t *testing.T) { 58 | server := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 59 | res.WriteHeader(200) 60 | fmt.Fprintf(res, "{}") 61 | assert.Equal(t, "POST", req.Method) 62 | 63 | expectedBody := map[string]interface{}{"name": "test", "channels": []interface{}{"test_channel"}, "data": "yolo"} 64 | bodyDecoder := json.NewDecoder(req.Body) 65 | var actualBody map[string]interface{} 66 | err := bodyDecoder.Decode(&actualBody) 67 | assert.NoError(t, err) 68 | assert.Equal(t, expectedBody, actualBody) 69 | 70 | assert.Equal(t, "application/json", req.Header["Content-Type"][0]) 71 | lib := fmt.Sprintf("%s %s", libraryName, libraryVersion) 72 | assert.Equal(t, lib, req.Header["X-Pusher-Library"][0]) 73 | assert.NoError(t, err) 74 | })) 75 | defer server.Close() 76 | 77 | u, _ := url.Parse(server.URL) 78 | client := Client{AppID: "id", Key: "key", Secret: "secret", Host: u.Host} 79 | err := client.Trigger("test_channel", "test", "yolo") 80 | assert.NoError(t, err) 81 | } 82 | 83 | func TestTriggerWithStructSuccessCase(t *testing.T) { 84 | server := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 85 | res.WriteHeader(200) 86 | fmt.Fprintf(res, "{}") 87 | assert.Equal(t, "POST", req.Method) 88 | 89 | expectedBody := map[string]interface{}{"name": "test", "channels": []interface{}{"test_channel"}, "data": `{"Key":"value"}`} 90 | bodyDecoder := json.NewDecoder(req.Body) 91 | var actualBody map[string]interface{} 92 | err := bodyDecoder.Decode(&actualBody) 93 | assert.NoError(t, err) 94 | assert.Equal(t, expectedBody, actualBody) 95 | 96 | assert.Equal(t, "application/json", req.Header["Content-Type"][0]) 97 | lib := fmt.Sprintf("%s %s", libraryName, libraryVersion) 98 | assert.Equal(t, lib, req.Header["X-Pusher-Library"][0]) 99 | assert.NoError(t, err) 100 | })) 101 | defer server.Close() 102 | 103 | u, _ := url.Parse(server.URL) 104 | client := Client{AppID: "id", Key: "key", Secret: "secret", Host: u.Host} 105 | err := client.Trigger("test_channel", "test", struct{ Key string }{Key: "value"}) 106 | assert.NoError(t, err) 107 | } 108 | 109 | // Tests that when the "info" param is not specified, we get a nil Channels map in the returned TriggerChannelsList 110 | func TestTriggerWithParamsSuccessCase(t *testing.T) { 111 | server := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 112 | res.WriteHeader(200) 113 | testJSON := "{}" 114 | fmt.Fprintf(res, testJSON) 115 | assert.Equal(t, "POST", req.Method) 116 | 117 | expectedBody := map[string]interface{}{"name": "test", "channels": []interface{}{"test_channel"}, "data": "yolo"} 118 | bodyDecoder := json.NewDecoder(req.Body) 119 | var actualBody map[string]interface{} 120 | err := bodyDecoder.Decode(&actualBody) 121 | assert.NoError(t, err) 122 | assert.Equal(t, expectedBody, actualBody) 123 | 124 | assert.Equal(t, "application/json", req.Header["Content-Type"][0]) 125 | lib := fmt.Sprintf("%s %s", libraryName, libraryVersion) 126 | assert.Equal(t, lib, req.Header["X-Pusher-Library"][0]) 127 | assert.NoError(t, err) 128 | })) 129 | defer server.Close() 130 | 131 | u, _ := url.Parse(server.URL) 132 | client := Client{AppID: "id", Key: "key", Secret: "secret", Host: u.Host} 133 | // Empty parameters 134 | channels, err := client.TriggerWithParams("test_channel", "test", "yolo", TriggerParams{}) 135 | assert.NoError(t, err) 136 | 137 | expected := &TriggerChannelsList{ 138 | Channels: nil, 139 | } 140 | assert.Equal(t, expected, channels) 141 | } 142 | 143 | func TestTriggerWithParamsInfoSuccessCase(t *testing.T) { 144 | server := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 145 | res.WriteHeader(200) 146 | testJSON := "{\"channels\":{\"test_channel\":{\"subscription_count\":1}}}" 147 | fmt.Fprintf(res, testJSON) 148 | assert.Equal(t, "POST", req.Method) 149 | 150 | expectedBody := map[string]interface{}{"name": "test", "channels": []interface{}{"test_channel"}, "data": "yolo", "info": "subscription_count"} 151 | bodyDecoder := json.NewDecoder(req.Body) 152 | var actualBody map[string]interface{} 153 | err := bodyDecoder.Decode(&actualBody) 154 | assert.NoError(t, err) 155 | assert.Equal(t, expectedBody, actualBody) 156 | 157 | assert.Equal(t, "application/json", req.Header["Content-Type"][0]) 158 | lib := fmt.Sprintf("%s %s", libraryName, libraryVersion) 159 | assert.Equal(t, lib, req.Header["X-Pusher-Library"][0]) 160 | assert.NoError(t, err) 161 | })) 162 | defer server.Close() 163 | 164 | u, _ := url.Parse(server.URL) 165 | client := Client{AppID: "id", Key: "key", Secret: "secret", Host: u.Host} 166 | attributes := "subscription_count" 167 | channels, err := client.TriggerWithParams("test_channel", "test", "yolo", TriggerParams{Info: &attributes}) 168 | assert.NoError(t, err) 169 | 170 | expectedSubscriptionCount := 1 171 | expected := &TriggerChannelsList{ 172 | Channels: map[string]TriggerChannelListItem{ 173 | "test_channel": {SubscriptionCount: &expectedSubscriptionCount}, 174 | }, 175 | } 176 | assert.Equal(t, expected, channels) 177 | } 178 | 179 | func TestTriggerMultiSuccessCase(t *testing.T) { 180 | server := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 181 | res.WriteHeader(200) 182 | fmt.Fprintf(res, "{}") 183 | assert.Equal(t, "POST", req.Method) 184 | 185 | expectedBody := map[string]interface{}{"name": "test", "channels": []interface{}{"test_channel", "other_channel"}, "data": "yolo"} 186 | bodyDecoder := json.NewDecoder(req.Body) 187 | var actualBody map[string]interface{} 188 | err := bodyDecoder.Decode(&actualBody) 189 | assert.NoError(t, err) 190 | assert.Equal(t, expectedBody, actualBody) 191 | 192 | assert.Equal(t, "application/json", req.Header["Content-Type"][0]) 193 | assert.NoError(t, err) 194 | })) 195 | defer server.Close() 196 | 197 | u, _ := url.Parse(server.URL) 198 | client := Client{AppID: "id", Key: "key", Secret: "secret", Host: u.Host} 199 | err := client.TriggerMulti([]string{"test_channel", "other_channel"}, "test", "yolo") 200 | assert.NoError(t, err) 201 | } 202 | 203 | func TestTriggerMultiEncryptedRejected(t *testing.T) { 204 | server := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 205 | t.Fatal("No request should reach the API") 206 | })) 207 | defer server.Close() 208 | 209 | u, _ := url.Parse(server.URL) 210 | client := Client{ 211 | AppID: "id", 212 | Key: "key", 213 | Secret: "secret", 214 | Host: u.Host, 215 | EncryptionMasterKeyBase64: "ZUhQVldIZzduRkdZVkJzS2pPRkRYV1JyaWJJUjJiMGI=", 216 | } 217 | err := client.TriggerMulti([]string{"test_channel", "private-encrypted-other_channel"}, "test", "yolo") 218 | assert.Error(t, err) 219 | assert.Contains(t, err.Error(), "multiple channels") 220 | assert.Contains(t, err.Error(), "encrypted channels") 221 | } 222 | 223 | func TestTriggerMultiWithParamsInfoSuccessCase(t *testing.T) { 224 | server := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 225 | res.WriteHeader(200) 226 | testJSON := "{\"channels\":{\"presence-test_channel\":{\"subscription_count\":2,\"user_count\":1},\"test_channel\":{\"subscription_count\":3}}}" 227 | fmt.Fprintf(res, testJSON) 228 | assert.Equal(t, "POST", req.Method) 229 | 230 | expectedBody := map[string]interface{}{"name": "test", "channels": []interface{}{"presence-test_channel", "test_channel"}, "data": "yolo", "info": "user_count,subscription_count"} 231 | bodyDecoder := json.NewDecoder(req.Body) 232 | var actualBody map[string]interface{} 233 | err := bodyDecoder.Decode(&actualBody) 234 | assert.NoError(t, err) 235 | assert.Equal(t, expectedBody, actualBody) 236 | 237 | assert.Equal(t, "application/json", req.Header["Content-Type"][0]) 238 | lib := fmt.Sprintf("%s %s", libraryName, libraryVersion) 239 | assert.Equal(t, lib, req.Header["X-Pusher-Library"][0]) 240 | assert.NoError(t, err) 241 | })) 242 | defer server.Close() 243 | 244 | u, _ := url.Parse(server.URL) 245 | client := Client{AppID: "id", Key: "key", Secret: "secret", Host: u.Host} 246 | attributes := "user_count,subscription_count" 247 | channels, err := client.TriggerMultiWithParams([]string{"presence-test_channel", "test_channel"}, "test", "yolo", TriggerParams{Info: &attributes}) 248 | assert.NoError(t, err) 249 | 250 | presenceExpectedUserCount := 1 251 | presenceExpectedSubscriptionCount := 2 252 | expectedSubscriptionCount := 3 253 | expected := &TriggerChannelsList{ 254 | Channels: map[string]TriggerChannelListItem{ 255 | "presence-test_channel": {UserCount: &presenceExpectedUserCount, SubscriptionCount: &presenceExpectedSubscriptionCount}, 256 | "test_channel": {SubscriptionCount: &expectedSubscriptionCount}, 257 | }, 258 | } 259 | assert.Equal(t, expected, channels) 260 | } 261 | 262 | func TestGetChannelsSuccessCase(t *testing.T) { 263 | server := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 264 | res.WriteHeader(200) 265 | testJSON := "{\"channels\":{\"presence-session-d41a439c438a100756f5-4bf35003e819bb138249-5cbTiUiPNGI\":{\"user_count\":1},\"presence-session-d41a439c438a100756f5-4bf35003e819bb138249-PbZ5E1pP8uF\":{\"user_count\":1},\"presence-session-d41a439c438a100756f5-4bf35003e819bb138249-oz6iqpSxMwG\":{\"user_count\":1}}}" 266 | 267 | fmt.Fprintf(res, testJSON) 268 | assert.Equal(t, "GET", req.Method) 269 | 270 | })) 271 | defer server.Close() 272 | 273 | u, _ := url.Parse(server.URL) 274 | client := Client{AppID: "id", Key: "key", Secret: "secret", Host: u.Host} 275 | channels, err := client.Channels(ChannelsParams{}) 276 | assert.NoError(t, err) 277 | 278 | expected := &ChannelsList{ 279 | Channels: map[string]ChannelListItem{ 280 | "presence-session-d41a439c438a100756f5-4bf35003e819bb138249-5cbTiUiPNGI": ChannelListItem{UserCount: 1}, 281 | "presence-session-d41a439c438a100756f5-4bf35003e819bb138249-PbZ5E1pP8uF": ChannelListItem{UserCount: 1}, 282 | "presence-session-d41a439c438a100756f5-4bf35003e819bb138249-oz6iqpSxMwG": ChannelListItem{UserCount: 1}, 283 | }, 284 | } 285 | assert.Equal(t, expected, channels) 286 | } 287 | 288 | func TestGetChannelSuccess(t *testing.T) { 289 | server := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 290 | res.WriteHeader(200) 291 | testJSON := "{\"user_count\":1,\"occupied\":true,\"subscription_count\":1}" 292 | fmt.Fprintf(res, testJSON) 293 | 294 | assert.Equal(t, "GET", req.Method) 295 | })) 296 | defer server.Close() 297 | 298 | u, _ := url.Parse(server.URL) 299 | client := Client{AppID: "id", Key: "key", Secret: "secret", Host: u.Host} 300 | channel, err := client.Channel("test_channel", ChannelParams{}) 301 | assert.NoError(t, err) 302 | 303 | expected := &Channel{ 304 | Name: "test_channel", 305 | Occupied: true, 306 | UserCount: 1, 307 | SubscriptionCount: 1, 308 | } 309 | assert.Equal(t, expected, channel) 310 | } 311 | 312 | func TestGetChannelUserSuccess(t *testing.T) { 313 | server := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 314 | res.WriteHeader(200) 315 | testJSON := "{\"users\":[{\"id\":\"red\"},{\"id\":\"blue\"}]}" 316 | fmt.Fprintf(res, testJSON) 317 | 318 | assert.Equal(t, "GET", req.Method) 319 | })) 320 | defer server.Close() 321 | 322 | u, _ := url.Parse(server.URL) 323 | client := Client{AppID: "id", Key: "key", Secret: "secret", Host: u.Host} 324 | users, err := client.GetChannelUsers("test_channel") 325 | assert.NoError(t, err) 326 | 327 | expected := &Users{ 328 | List: []User{User{ID: "red"}, User{ID: "blue"}}, 329 | } 330 | assert.Equal(t, expected, users) 331 | } 332 | 333 | func TestTriggerWithSocketID(t *testing.T) { 334 | server := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 335 | res.WriteHeader(200) 336 | expectedBody := map[string]interface{}{"name": "test", "channels": []interface{}{"test_channel"}, "data": "yolo", "socket_id": "1234.12"} 337 | bodyDecoder := json.NewDecoder(req.Body) 338 | var actualBody map[string]interface{} 339 | err := bodyDecoder.Decode(&actualBody) 340 | assert.NoError(t, err) 341 | assert.Equal(t, expectedBody, actualBody) 342 | })) 343 | defer server.Close() 344 | 345 | u, _ := url.Parse(server.URL) 346 | client := Client{AppID: "id", Key: "key", Secret: "secret", Host: u.Host} 347 | client.TriggerExclusive("test_channel", "test", "yolo", "1234.12") 348 | } 349 | 350 | func TestTriggerSocketIDValidation(t *testing.T) { 351 | client := Client{AppID: "id", Key: "key", Secret: "secret"} 352 | err := client.TriggerExclusive("test_channel", "test", "yolo", "1234.12:lalala") 353 | assert.Error(t, err) 354 | } 355 | 356 | func TestTriggerBatchSuccess(t *testing.T) { 357 | expectedBody := `{"batch":[{"channel":"test_channel","name":"test","data":"yolo1"},{"channel":"test_channel","name":"test","data":"yolo2"}]}` 358 | server := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 359 | res.WriteHeader(200) 360 | fmt.Fprintf(res, "{}") 361 | assert.Equal(t, "POST", req.Method) 362 | 363 | actualBody, err := ioutil.ReadAll(req.Body) 364 | assert.Equal(t, expectedBody, string(actualBody)) 365 | assert.Equal(t, "application/json", req.Header["Content-Type"][0]) 366 | assert.Equal(t, "/apps/appid/batch_events", req.URL.Path) 367 | assert.NoError(t, err) 368 | })) 369 | defer server.Close() 370 | 371 | u, _ := url.Parse(server.URL) 372 | client := Client{AppID: "appid", Key: "key", Secret: "secret", Host: u.Host} 373 | response, err := client.TriggerBatch([]Event{ 374 | {Channel: "test_channel", Name: "test", Data: "yolo1"}, 375 | {Channel: "test_channel", Name: "test", Data: "yolo2"}, 376 | }) 377 | 378 | assert.NoError(t, err) 379 | assert.Equal(t, &TriggerBatchChannelsList{}, response) 380 | } 381 | 382 | func TestTriggerBatchInfoSuccess(t *testing.T) { 383 | expectedBody := `{"batch":[{"channel":"presence-test_channel","name":"test","data":"yolo1","info":"user_count,subscription_count"},{"channel":"test_channel","name":"test","data":"yolo2","info":"subscription_count"}]}` 384 | server := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 385 | res.WriteHeader(200) 386 | testJSON := "{\"batch\":[{\"subscription_count\":2,\"user_count\":1},{\"subscription_count\":3}]}" 387 | fmt.Fprintf(res, testJSON) 388 | assert.Equal(t, "POST", req.Method) 389 | 390 | actualBody, err := ioutil.ReadAll(req.Body) 391 | assert.Equal(t, expectedBody, string(actualBody)) 392 | assert.Equal(t, "application/json", req.Header["Content-Type"][0]) 393 | assert.Equal(t, "/apps/appid/batch_events", req.URL.Path) 394 | assert.NoError(t, err) 395 | })) 396 | defer server.Close() 397 | 398 | u, _ := url.Parse(server.URL) 399 | client := Client{AppID: "appid", Key: "key", Secret: "secret", Host: u.Host} 400 | presenceChannelInfo := "user_count,subscription_count" 401 | channelInfo := "subscription_count" 402 | channels, err := client.TriggerBatch([]Event{ 403 | {Channel: "presence-test_channel", Name: "test", Data: "yolo1", Info: &presenceChannelInfo}, 404 | {Channel: "test_channel", Name: "test", Data: "yolo2", Info: &channelInfo}, 405 | }) 406 | 407 | assert.NoError(t, err) 408 | 409 | presenceExpectedUserCount := 1 410 | presenceExpectedSubscriptionCount := 2 411 | expectedSubscriptionCount := 3 412 | expected := &TriggerBatchChannelsList{ 413 | Batch: []TriggerBatchChannelListItem{ 414 | {UserCount: &presenceExpectedUserCount, SubscriptionCount: &presenceExpectedSubscriptionCount}, 415 | {SubscriptionCount: &expectedSubscriptionCount}, 416 | }, 417 | } 418 | assert.Equal(t, expected, channels) 419 | } 420 | 421 | func TestTriggerBatchWithEncryptionMasterKeyNoEncryptedChanSuccess(t *testing.T) { 422 | expectedBody := `{"batch":[{"channel":"test_channel","name":"test","data":"yolo1"},{"channel":"test_channel","name":"test","data":"yolo2"}]}` 423 | server := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 424 | res.WriteHeader(200) 425 | fmt.Fprintf(res, "{}") 426 | assert.Equal(t, "POST", req.Method) 427 | 428 | actualBody, err := ioutil.ReadAll(req.Body) 429 | assert.Equal(t, expectedBody, string(actualBody)) 430 | assert.Equal(t, "application/json", req.Header["Content-Type"][0]) 431 | assert.Equal(t, "/apps/appid/batch_events", req.URL.Path) 432 | assert.NoError(t, err) 433 | })) 434 | defer server.Close() 435 | u, _ := url.Parse(server.URL) 436 | client := Client{AppID: "appid", Key: "key", Secret: "secret", EncryptionMasterKeyBase64: "ZUhQVldIZzduRkdZVkJzS2pPRkRYV1JyaWJJUjJiMGI=", Host: u.Host} 437 | response, err := client.TriggerBatch([]Event{ 438 | {Channel: "test_channel", Name: "test", Data: "yolo1"}, 439 | {Channel: "test_channel", Name: "test", Data: "yolo2"}, 440 | }) 441 | 442 | assert.NoError(t, err) 443 | assert.Equal(t, &TriggerBatchChannelsList{}, response) 444 | } 445 | 446 | func TestTriggerBatchNoEncryptionMasterKeyWithEncryptedChanFailure(t *testing.T) { 447 | server := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 448 | t.Fatal("No request should have reached the API") 449 | })) 450 | defer server.Close() 451 | 452 | u, _ := url.Parse(server.URL) 453 | client := Client{AppID: "appid", Key: "key", Secret: "secret", Host: u.Host} 454 | _, err := client.TriggerBatch([]Event{ 455 | {Channel: "test_channel", Name: "test", Data: "yolo1"}, 456 | {Channel: "private-encrypted-test_channel", Name: "test", Data: "yolo2"}, 457 | }) 458 | 459 | assert.Error(t, err) 460 | assert.Contains(t, err.Error(), "master encryption key") 461 | } 462 | 463 | func TestTriggerWithEncryptedChanSuccess(t *testing.T) { 464 | server := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 465 | res.WriteHeader(200) 466 | fmt.Fprintf(res, "{}") 467 | assert.Equal(t, "POST", req.Method) 468 | 469 | actualBody, err := ioutil.ReadAll(req.Body) 470 | assert.Contains(t, string(actualBody), "ciphertext") 471 | assert.Contains(t, string(actualBody), "nonce") 472 | assert.Equal(t, "application/json", req.Header["Content-Type"][0]) 473 | assert.Equal(t, "/apps/appid/events", req.URL.Path) 474 | assert.NoError(t, err) 475 | })) 476 | defer server.Close() 477 | 478 | u, _ := url.Parse(server.URL) 479 | client := Client{AppID: "appid", Key: "key", Secret: "secret", EncryptionMasterKeyBase64: "ZUhQVldIZzduRkdZVkJzS2pPRkRYV1JyaWJJUjJiMGI=", Host: u.Host} 480 | err := client.Trigger("private-encrypted-test_channel", "test", "yolo1") 481 | assert.NoError(t, err) 482 | } 483 | 484 | func TestTriggerBatchWithEncryptedChanSuccess(t *testing.T) { 485 | server := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 486 | res.WriteHeader(200) 487 | fmt.Fprintf(res, "{}") 488 | assert.Equal(t, "POST", req.Method) 489 | 490 | _, err := ioutil.ReadAll(req.Body) 491 | assert.Equal(t, "application/json", req.Header["Content-Type"][0]) 492 | assert.Equal(t, "/apps/appid/batch_events", req.URL.Path) 493 | assert.NoError(t, err) 494 | })) 495 | defer server.Close() 496 | 497 | u, _ := url.Parse(server.URL) 498 | client := Client{AppID: "appid", Key: "key", Secret: "secret", EncryptionMasterKeyBase64: "ZUhQVldIZzduRkdZVkJzS2pPRkRYV1JyaWJJUjJiMGI=", Host: u.Host} 499 | response, err := client.TriggerBatch([]Event{ 500 | {Channel: "test_channel", Name: "test", Data: "yolo1"}, 501 | {Channel: "private-encrypted-test_channel", Name: "test", Data: "yolo2"}, 502 | }) 503 | assert.NoError(t, err) 504 | assert.Equal(t, &TriggerBatchChannelsList{}, response) 505 | } 506 | 507 | func TestTriggerInvalidMasterKey(t *testing.T) { 508 | server := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 509 | t.Fatal("No HTTP request should have been made") 510 | })) 511 | defer server.Close() 512 | u, _ := url.Parse(server.URL) 513 | 514 | // too short (deprecated) 515 | client := Client{ 516 | AppID: "appid", 517 | Key: "key", 518 | Secret: "secret", 519 | Host: u.Host, 520 | EncryptionMasterKey: "this is 31 bytes 12345678901234", 521 | } 522 | err := client.Trigger("private-encrypted-test_channel", "test", "yolo1") 523 | assert.Error(t, err) 524 | assert.Contains(t, err.Error(), "32 bytes") 525 | 526 | // too long (deprecated) 527 | client = Client{ 528 | AppID: "appid", 529 | Key: "key", 530 | Secret: "secret", 531 | Host: u.Host, 532 | EncryptionMasterKey: "this is 33 bytes 1234567890123456", 533 | } 534 | err = client.Trigger("private-encrypted-test_channel", "test", "yolo1") 535 | assert.Error(t, err) 536 | assert.Contains(t, err.Error(), "32 bytes") 537 | 538 | // both provided 539 | client = Client{ 540 | AppID: "appid", 541 | Key: "key", 542 | Secret: "secret", 543 | Host: u.Host, 544 | EncryptionMasterKey: "this is 32 bytes 123456789012345", 545 | EncryptionMasterKeyBase64: "dGhpcyBpcyAzMiBieXRlcyAxMjM0NTY3ODkwMTIzNDU=", 546 | } 547 | err = client.Trigger("private-encrypted-test_channel", "test", "yolo1") 548 | assert.Error(t, err) 549 | assert.Contains(t, err.Error(), "both") 550 | 551 | // too short 552 | client = Client{ 553 | AppID: "appid", 554 | Key: "key", 555 | Secret: "secret", 556 | Host: u.Host, 557 | EncryptionMasterKeyBase64: "dGhpcyBpcyAzMSBieXRlcyAxMjM0NTY3ODkwMTIzNA==", 558 | } 559 | err = client.Trigger("private-encrypted-test_channel", "test", "yolo1") 560 | assert.Error(t, err) 561 | assert.Contains(t, err.Error(), "32 bytes") 562 | 563 | // too long 564 | client = Client{ 565 | AppID: "appid", 566 | Key: "key", 567 | Secret: "secret", 568 | Host: u.Host, 569 | EncryptionMasterKeyBase64: "dGhpcyBpcyAzMiBieXRlcyAxMjM0NTY3ODkwMTIzNDU2", 570 | } 571 | err = client.Trigger("private-encrypted-test_channel", "test", "yolo1") 572 | assert.Error(t, err) 573 | assert.Contains(t, err.Error(), "32 bytes") 574 | 575 | // invalid base64 576 | client = Client{ 577 | AppID: "appid", 578 | Key: "key", 579 | Secret: "secret", 580 | Host: u.Host, 581 | EncryptionMasterKeyBase64: "dGhp!yBpcyAzMiBieXRlcy#xMjM0NTY3ODkwMTIzNDU=", 582 | } 583 | err = client.Trigger("private-encrypted-test_channel", "test", "yolo1") 584 | assert.Error(t, err) 585 | assert.Contains(t, err.Error(), "valid base64") 586 | } 587 | 588 | func TestAuthenticateUser(t *testing.T) { 589 | server := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 590 | t.Fatal("No HTTP request should have been made") 591 | })) 592 | defer server.Close() 593 | u, _ := url.Parse(server.URL) 594 | 595 | client := Client{ 596 | AppID: "appid", 597 | Key: "key", 598 | Secret: "secret", 599 | Host: u.Host, 600 | } 601 | 602 | var params []byte 603 | var userData map[string]interface{} 604 | 605 | params = []byte("socket_id=12345.12345") 606 | userData = map[string]interface{} {} 607 | _, err := client.AuthenticateUser(params, userData) 608 | assert.Error(t, err) 609 | assert.Contains(t, err.Error(), "Missing id in user data") 610 | 611 | params = []byte("not_socket_id=12345.12345") 612 | userData = map[string]interface{} { "id": "1234" } 613 | _, err = client.AuthenticateUser(params, userData) 614 | assert.Error(t, err) 615 | assert.Contains(t, err.Error(), "socket_id not found") 616 | 617 | params = []byte("socket_id=12345.12345") 618 | userData = map[string]interface{} { "id": "1234" } 619 | var response []byte 620 | response, err = client.AuthenticateUser(params, userData) 621 | assert.NoError(t, err) 622 | assert.Equal(t, string(response), "{\"auth\":\"key:e4c63b82c1e1d0955901f6a29ca51b244155bafda93968bc5664010f5ba54a41\",\"user_data\":\"{\\\"id\\\":\\\"1234\\\"}\"}") 623 | } 624 | 625 | func TestAuthorizeInvalidMasterKey(t *testing.T) { 626 | server := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 627 | t.Fatal("No HTTP request should have been made") 628 | })) 629 | defer server.Close() 630 | u, _ := url.Parse(server.URL) 631 | 632 | params := []byte("channel_name=private-encrypted-test_channel&socket_id=12345.12345") 633 | 634 | // too short (deprecated) 635 | client := Client{ 636 | AppID: "appid", 637 | Key: "key", 638 | Secret: "secret", 639 | Host: u.Host, 640 | EncryptionMasterKey: "this is 31 bytes 12345678901234", 641 | } 642 | _, err := client.AuthorizePrivateChannel(params) 643 | assert.Error(t, err) 644 | assert.Contains(t, err.Error(), "32 bytes") 645 | 646 | // too long (deprecated) 647 | client = Client{ 648 | AppID: "appid", 649 | Key: "key", 650 | Secret: "secret", 651 | Host: u.Host, 652 | EncryptionMasterKey: "this is 33 bytes 1234567890123456", 653 | } 654 | _, err = client.AuthorizePrivateChannel(params) 655 | assert.Error(t, err) 656 | assert.Contains(t, err.Error(), "32 bytes") 657 | 658 | // both provided 659 | client = Client{ 660 | AppID: "appid", 661 | Key: "key", 662 | Secret: "secret", 663 | Host: u.Host, 664 | EncryptionMasterKey: "this is 32 bytes 123456789012345", 665 | EncryptionMasterKeyBase64: "dGhpcyBpcyAzMiBieXRlcyAxMjM0NTY3ODkwMTIzNDU=", 666 | } 667 | _, err = client.AuthorizePrivateChannel(params) 668 | assert.Error(t, err) 669 | assert.Contains(t, err.Error(), "both") 670 | 671 | // too short 672 | client = Client{ 673 | AppID: "appid", 674 | Key: "key", 675 | Secret: "secret", 676 | Host: u.Host, 677 | EncryptionMasterKeyBase64: "dGhpcyBpcyAzMSBieXRlcyAxMjM0NTY3ODkwMTIzNA==", 678 | } 679 | _, err = client.AuthorizePrivateChannel(params) 680 | assert.Error(t, err) 681 | assert.Contains(t, err.Error(), "32 bytes") 682 | 683 | // too long 684 | client = Client{ 685 | AppID: "appid", 686 | Key: "key", 687 | Secret: "secret", 688 | Host: u.Host, 689 | EncryptionMasterKeyBase64: "dGhpcyBpcyAzMiBieXRlcyAxMjM0NTY3ODkwMTIzNDU2", 690 | } 691 | _, err = client.AuthorizePrivateChannel(params) 692 | assert.Error(t, err) 693 | assert.Contains(t, err.Error(), "32 bytes") 694 | 695 | // invalid base64 696 | client = Client{ 697 | AppID: "appid", 698 | Key: "key", 699 | Secret: "secret", 700 | Host: u.Host, 701 | EncryptionMasterKeyBase64: "dGhp!yBpcyAzMiBieXRlcy#xMjM0NTY3ODkwMTIzNDU=", 702 | } 703 | _, err = client.AuthorizePrivateChannel(params) 704 | assert.Error(t, err) 705 | assert.Contains(t, err.Error(), "valid base64") 706 | } 707 | 708 | func TestErrorResponseHandler(t *testing.T) { 709 | server := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 710 | res.WriteHeader(400) 711 | fmt.Fprintf(res, "Cannot retrieve the user count unless the channel is a presence channel") 712 | 713 | })) 714 | defer server.Close() 715 | 716 | u, _ := url.Parse(server.URL) 717 | client := Client{AppID: "id", Key: "key", Secret: "secret", Host: u.Host} 718 | attributes := "user_count,subscription_count" 719 | channel, err := client.Channel("this_is_not_a_presence_channel", ChannelParams{Info: &attributes}) 720 | 721 | assert.Error(t, err) 722 | assert.EqualError(t, err, "Status Code: 400 - Cannot retrieve the user count unless the channel is a presence channel") 723 | assert.Nil(t, channel) 724 | } 725 | 726 | func TestRequestTimeouts(t *testing.T) { 727 | server := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 728 | time.Sleep(time.Second * 1) 729 | // res.WriteHeader(200) 730 | fmt.Fprintf(res, "{}") 731 | })) 732 | defer server.Close() 733 | 734 | u, _ := url.Parse(server.URL) 735 | client := Client{AppID: "id", Key: "key", Secret: "secret", Host: u.Host, HTTPClient: &http.Client{Timeout: time.Millisecond * 100}} 736 | err := client.Trigger("test_channel", "test", "yolo") 737 | 738 | assert.Error(t, err) 739 | } 740 | 741 | func TestChannelLengthValidation(t *testing.T) { 742 | channels := []string{ 743 | "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", 744 | "12", "13", "14", "15", "16", "17", "18", "19", "20", "21", 745 | "22", "23", "24", "25", "26", "27", "28", "29", "30", "31", 746 | "32", "33", "34", "35", "36", "37", "38", "39", "40", "41", 747 | "42", "43", "44", "45", "46", "47", "48", "49", "50", "51", 748 | "52", "53", "54", "55", "56", "57", "58", "59", "60", "61", 749 | "62", "63", "64", "65", "66", "67", "68", "69", "70", "71", 750 | "72", "73", "74", "75", "76", "77", "78", "79", "80", "81", 751 | "82", "83", "84", "85", "86", "87", "88", "89", "90", "91", 752 | "92", "93", "94", "95", "96", "97", "98", "99", "100", "101", 753 | } 754 | 755 | client := Client{AppID: "id", Key: "key", Secret: "secret"} 756 | err := client.TriggerMulti(channels, "yolo", "woot") 757 | 758 | assert.EqualError(t, err, "You cannot trigger on more than 100 channels at once") 759 | } 760 | 761 | func TestChannelFormatValidation(t *testing.T) { 762 | channel1 := "w000^$$£@@@" 763 | var channel2 string 764 | for i := 0; i <= 202; i++ { 765 | channel2 += "a" 766 | } 767 | client := Client{AppID: "id", Key: "key", Secret: "secret"} 768 | err1 := client.Trigger(channel1, "yolo", "w00t") 769 | 770 | err2 := client.Trigger(channel2, "yolo", "not 19 forever") 771 | 772 | assert.EqualError(t, err1, "At least one of your channels' names are invalid") 773 | 774 | assert.EqualError(t, err2, "At least one of your channels' names are invalid") 775 | 776 | } 777 | 778 | func TestDataSizeValidation(t *testing.T) { 779 | client := Client{AppID: "id", Key: "key", Secret: "secret"} 780 | data := strings.Repeat("a", 20481) 781 | err := client.Trigger("channel", "event", data) 782 | 783 | assert.EqualError(t, err, "Event payload exceeded maximum size (20481 bytes is too much)") 784 | 785 | _, err = client.TriggerBatch([]Event{ 786 | {Channel: "channel", Name: "event", Data: data}, 787 | }) 788 | assert.EqualError(t, err, "Data of the event #0 in batch, exceeded maximum size (20481 bytes is too much)") 789 | } 790 | 791 | func TestDataSizeOverridenValidation(t *testing.T) { 792 | client := Client{AppID: "id", Key: "key", Secret: "secret", OverrideMaxMessagePayloadKB: 80} 793 | data := strings.Repeat("a", 81920) 794 | err := client.Trigger("channel", "event", data) 795 | assert.NotContains(t, err.Error(), "\"Event payload exceeded maximum size (81920 bytes is too much)") 796 | _, err = client.TriggerBatch([]Event{ 797 | {Channel: "channel", Name: "event", Data: data}, 798 | }) 799 | assert.NotContains(t, err.Error(), "Data of the event #0 in batch, exceeded maximum size (81920 bytes is too much)") 800 | 801 | data = strings.Repeat("a", 81921) 802 | err = client.Trigger("channel", "event", data) 803 | assert.EqualError(t, err, "Event payload exceeded maximum size (81921 bytes is too much)") 804 | 805 | _, err = client.TriggerBatch([]Event{ 806 | {Channel: "channel", Name: "event", Data: data}, 807 | }) 808 | assert.EqualError(t, err, "Data of the event #0 in batch, exceeded maximum size (81921 bytes is too much)") 809 | } 810 | 811 | func TestInitialisationFromURL(t *testing.T) { 812 | url := "http://feaf18a411d3cb9216ee:fec81108d90e1898e17a@api.pusherapp.com/apps/104060" 813 | client, _ := ClientFromURL(url) 814 | expectedClient := &Client{Key: "feaf18a411d3cb9216ee", Secret: "fec81108d90e1898e17a", AppID: "104060", Host: "api.pusherapp.com"} 815 | assert.Equal(t, expectedClient, client) 816 | } 817 | 818 | func TestURLInitErrorNoSecret(t *testing.T) { 819 | url := "http://fec81108d90e1898e17a@api.pusherapp.com/apps" 820 | client, err := ClientFromURL(url) 821 | assert.Nil(t, client) 822 | assert.Error(t, err) 823 | } 824 | 825 | func TestURLInitHTTPS(t *testing.T) { 826 | url := "https://key:secret@api.pusherapp.com/apps/104060" 827 | client, _ := ClientFromURL(url) 828 | assert.True(t, client.Secure) 829 | } 830 | 831 | func TestURLInitErrorNoID(t *testing.T) { 832 | url := "http://fec81108d90e1898e17a@api.pusherapp.com/apps" 833 | client, err := ClientFromURL(url) 834 | assert.Nil(t, client) 835 | assert.Error(t, err) 836 | } 837 | 838 | func TestInitialisationFromENV(t *testing.T) { 839 | os.Setenv("PUSHER_URL", "http://feaf18a411d3cb9216ee:fec81108d90e1898e17a@api.pusherapp.com/apps/104060") 840 | client, _ := ClientFromEnv("PUSHER_URL") 841 | expectedClient := &Client{Key: "feaf18a411d3cb9216ee", Secret: "fec81108d90e1898e17a", AppID: "104060", Host: "api.pusherapp.com"} 842 | assert.Equal(t, expectedClient, client) 843 | } 844 | --------------------------------------------------------------------------------