├── Gemfile ├── lib ├── pusher │ ├── version.rb │ ├── resource.rb │ ├── request.rb │ ├── webhook.rb │ ├── channel.rb │ └── client.rb └── pusher.rb ├── Rakefile ├── pull_request_template.md ├── .gitignore ├── .github ├── workflows │ ├── publish.yml │ ├── test.yml │ ├── gh-release.yml │ └── release.yml └── stale.yml ├── spec ├── spec_helper.rb ├── web_hook_spec.rb ├── channel_spec.rb └── client_spec.rb ├── examples ├── async_message.rb └── presence_channels │ ├── public │ └── presence_channels.html │ └── presence_channels.rb ├── LICENSE ├── pusher.gemspec ├── CHANGELOG.md └── README.md /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | gemspec 3 | -------------------------------------------------------------------------------- /lib/pusher/version.rb: -------------------------------------------------------------------------------- 1 | module Pusher 2 | VERSION = '2.0.3' 3 | end 4 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler' 2 | Bundler::GemHelper.install_tasks 3 | 4 | require "rspec/core/rake_task" 5 | 6 | RSpec::Core::RakeTask.new(:spec) do |s| 7 | s.pattern = 'spec/**/*.rb' 8 | end 9 | 10 | task :default => :spec 11 | task :test => :spec 12 | -------------------------------------------------------------------------------- /pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | Add a short description of the change. If this is related to an issue, please add a reference to the issue. 4 | 5 | ## CHANGELOG 6 | 7 | * [CHANGED] Describe your change here. Look at CHANGELOG.md to see the format. 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## MAC OS 2 | .DS_Store 3 | 4 | ## TEXTMATE 5 | *.tmproj 6 | tmtags 7 | 8 | ## EMACS 9 | *~ 10 | \#* 11 | .\#* 12 | 13 | ## VIM 14 | *.swp 15 | 16 | ## PROJECT::GENERAL 17 | coverage 18 | rdoc 19 | pkg 20 | .yardoc 21 | Gemfile.lock 22 | 23 | ## PROJECT::SPECIFIC 24 | .bundle 25 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: RubyGems release 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v1 13 | 14 | - name: Publish gem 15 | uses: dawidd6/action-publish-gem@v1 16 | with: 17 | api_key: ${{secrets.RUBYGEMS_API_KEY}} 18 | -------------------------------------------------------------------------------- /.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 | ruby: ['2.6', '2.7', '3.0'] 15 | 16 | name: Ruby ${{ matrix.ruby }} Test 17 | 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v2 21 | 22 | - name: Setup Ruby 23 | uses: ruby/setup-ruby@v1 24 | with: 25 | ruby-version: ${{ matrix.ruby }} 26 | 27 | - name: Install dependencies 28 | run: bundle install 29 | 30 | - name: Run test suite 31 | run: bundle exec rake 32 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | begin 2 | require 'bundler/setup' 3 | rescue LoadError 4 | puts 'although not required, it is recommended that you use bundler when running the tests' 5 | end 6 | 7 | ENV['PUSHER_URL']= 'http://some:secret@api.secret.pusherapp.com:441/apps/54' 8 | 9 | require 'rspec' 10 | require 'em-http' # As of webmock 1.4.0, em-http must be loaded first 11 | require 'webmock/rspec' 12 | 13 | require 'pusher' 14 | require 'eventmachine' 15 | 16 | RSpec.configure do |config| 17 | config.before(:each) do 18 | WebMock.reset! 19 | WebMock.disable_net_connect! 20 | end 21 | end 22 | 23 | def hmac(key, data) 24 | digest = OpenSSL::Digest::SHA256.new 25 | OpenSSL::HMAC.hexdigest(digest, key, data) 26 | end 27 | -------------------------------------------------------------------------------- /examples/async_message.rb: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'pusher' 3 | require 'eventmachine' 4 | require 'em-http-request' 5 | 6 | # To get these values: 7 | # - Go to https://app.pusherapp.com/ 8 | # - Click on Choose App. 9 | # - Click on one of your apps 10 | # - Click API Access 11 | Pusher.app_id = 'your_app_id' 12 | Pusher.key = 'your_key' 13 | Pusher.secret = 'your_secret' 14 | 15 | 16 | EM.run { 17 | deferrable = Pusher['test_channel'].trigger_async('my_event', 'hi') 18 | 19 | deferrable.callback { # called on success 20 | puts "Message sent successfully." 21 | EM.stop 22 | } 23 | deferrable.errback { |error| # called on error 24 | puts "Message could not be sent." 25 | puts error 26 | EM.stop 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /lib/pusher/resource.rb: -------------------------------------------------------------------------------- 1 | module Pusher 2 | class Resource 3 | def initialize(client, path) 4 | @client = client 5 | @path = path 6 | end 7 | 8 | def get(params) 9 | create_request(:get, params).send_sync 10 | end 11 | 12 | def get_async(params) 13 | create_request(:get, params).send_async 14 | end 15 | 16 | def post(params) 17 | body = MultiJson.encode(params) 18 | create_request(:post, {}, body).send_sync 19 | end 20 | 21 | def post_async(params) 22 | body = MultiJson.encode(params) 23 | create_request(:post, {}, body).send_async 24 | end 25 | 26 | private 27 | 28 | def create_request(verb, params, body = nil) 29 | Request.new(@client, verb, url, params, body) 30 | end 31 | 32 | def url 33 | @_url ||= @client.url(@path) 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /examples/presence_channels/public/presence_channels.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Pusher Test 4 | 5 | 21 | 22 | 23 |

Pusher Test

24 |

25 | Try publishing an event to channel presence-channel-test 26 | with event name test-event. 27 |

28 | 29 | -------------------------------------------------------------------------------- /.github/workflows/gh-release.yml: -------------------------------------------------------------------------------- 1 | name: Github Release 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | 7 | jobs: 8 | create-release: 9 | name: Create Release 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout code 13 | uses: actions/checkout@v2 14 | - name: Setup git 15 | run: | 16 | git config user.email "pusher-ci@pusher.com" 17 | git config user.name "Pusher CI" 18 | - name: Prepare description 19 | run: | 20 | csplit -s CHANGELOG.md "/##/" {1} 21 | cat xx01 > CHANGELOG.tmp 22 | - name: Prepare tag 23 | run: | 24 | export TAG=$(head -1 CHANGELOG.tmp | cut -d' ' -f2) 25 | echo "TAG=$TAG" >> $GITHUB_ENV 26 | - name: Create Release 27 | uses: actions/create-release@v1 28 | env: 29 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 30 | with: 31 | tag_name: ${{ env.TAG }} 32 | release_name: ${{ env.TAG }} 33 | body_path: CHANGELOG.tmp 34 | draft: false 35 | prerelease: false 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010-2013 Pusher 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /pusher.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | require File.expand_path('../lib/pusher/version', __FILE__) 4 | 5 | Gem::Specification.new do |s| 6 | s.name = "pusher" 7 | s.version = Pusher::VERSION 8 | s.platform = Gem::Platform::RUBY 9 | s.authors = ["Pusher"] 10 | s.email = ["support@pusher.com"] 11 | s.homepage = "http://github.com/pusher/pusher-http-ruby" 12 | s.summary = %q{Pusher Channels API client} 13 | s.description = %q{Wrapper for Pusher Channels REST api: : https://pusher.com/channels} 14 | s.license = "MIT" 15 | 16 | s.required_ruby_version = ">= 2.6" 17 | 18 | s.add_dependency "multi_json", "~> 1.15" 19 | s.add_dependency 'pusher-signature', "~> 0.1.8" 20 | s.add_dependency "httpclient", "~> 2.8" 21 | s.add_dependency "jruby-openssl" if defined?(JRUBY_VERSION) 22 | 23 | s.add_development_dependency "rspec", "~> 3.9" 24 | s.add_development_dependency "webmock", "~> 3.9" 25 | s.add_development_dependency "em-http-request", "~> 1.1" 26 | s.add_development_dependency "addressable", "~> 2.7" 27 | s.add_development_dependency "rake", "~> 13.0" 28 | s.add_development_dependency "rack", "~> 2.2" 29 | s.add_development_dependency "json", "~> 2.3" 30 | s.add_development_dependency "rbnacl", "~> 7.1" 31 | 32 | s.files = Dir["lib/**/*"] + %w[CHANGELOG.md LICENSE README.md] 33 | s.require_paths = ["lib"] 34 | end 35 | -------------------------------------------------------------------------------- /examples/presence_channels/presence_channels.rb: -------------------------------------------------------------------------------- 1 | require 'sinatra' 2 | require 'sinatra/cookies' 3 | require 'sinatra/json' 4 | require 'pusher' 5 | 6 | # You can get these variables from http://dashboard.pusher.com 7 | pusher = Pusher::Client.new( 8 | app_id: 'your-app-id', 9 | key: 'your-app-key', 10 | secret: 'your-app-secret', 11 | cluster: 'your-app-cluster' 12 | ) 13 | 14 | set :public_folder, 'public' 15 | 16 | get '/' do 17 | redirect '/presence_channels.html' 18 | end 19 | 20 | # Emulate rails behaviour where this information would be stored in session 21 | get '/signin' do 22 | cookies[:user_id] = 'example_cookie' 23 | 'Ok' 24 | end 25 | 26 | # Auth endpoint: https://pusher.com/docs/channels/server_api/authenticating-users 27 | post '/pusher/auth' do 28 | channel_data = { 29 | user_id: 'example_user', 30 | user_info: { 31 | name: 'example_name', 32 | email: 'example_email' 33 | } 34 | } 35 | 36 | if cookies[:user_id] == 'example_cookie' 37 | response = pusher.authenticate(params[:channel_name], params[:socket_id], channel_data) 38 | json response 39 | else 40 | status 403 41 | end 42 | end 43 | 44 | get '/pusher_trigger' do 45 | channels = ['presence-channel-test']; 46 | 47 | begin 48 | pusher.trigger(channels, 'test-event', { 49 | message: 'hello world' 50 | }) 51 | rescue Pusher::Error => e 52 | # For example, Pusher::AuthenticationError, Pusher::HTTPError, or Pusher::Error 53 | end 54 | 55 | 'Triggered!' 56 | end 57 | -------------------------------------------------------------------------------- /lib/pusher.rb: -------------------------------------------------------------------------------- 1 | autoload 'Logger', 'logger' 2 | require 'uri' 3 | require 'forwardable' 4 | 5 | require 'pusher/client' 6 | 7 | # Used for configuring API credentials and creating Channel objects 8 | # 9 | module Pusher 10 | # All errors descend from this class so they can be easily rescued 11 | # 12 | # @example 13 | # begin 14 | # Pusher.trigger('channel_name', 'event_name, {:some => 'data'}) 15 | # rescue Pusher::Error => e 16 | # # Do something on error 17 | # end 18 | class Error < RuntimeError; end 19 | class AuthenticationError < Error; end 20 | class ConfigurationError < Error 21 | def initialize(key) 22 | super "missing key `#{key}' in the client configuration" 23 | end 24 | end 25 | class HTTPError < Error; attr_accessor :original_error; end 26 | 27 | class << self 28 | extend Forwardable 29 | 30 | def_delegators :default_client, :scheme, :host, :port, :app_id, :key, 31 | :secret, :http_proxy, :encryption_master_key_base64 32 | def_delegators :default_client, :scheme=, :host=, :port=, :app_id=, :key=, 33 | :secret=, :http_proxy=, :encryption_master_key_base64= 34 | 35 | def_delegators :default_client, :authentication_token, :url, :cluster 36 | def_delegators :default_client, :encrypted=, :url=, :cluster= 37 | def_delegators :default_client, :timeout=, :connect_timeout=, :send_timeout=, :receive_timeout=, :keep_alive_timeout= 38 | 39 | def_delegators :default_client, :get, :get_async, :post, :post_async 40 | def_delegators :default_client, :channels, :channel_info, :channel_users 41 | def_delegators :default_client, :trigger, :trigger_batch, :trigger_async, :trigger_batch_async 42 | def_delegators :default_client, :authenticate, :webhook, :channel, :[] 43 | def_delegators :default_client, :notify 44 | 45 | attr_writer :logger 46 | 47 | def logger 48 | @logger ||= begin 49 | log = Logger.new($stdout) 50 | log.level = Logger::INFO 51 | log 52 | end 53 | end 54 | 55 | def default_client 56 | @default_client ||= begin 57 | cli = Pusher::Client 58 | ENV['PUSHER_URL'] ? cli.from_env : cli.new 59 | end 60 | end 61 | end 62 | end 63 | 64 | require 'pusher/version' 65 | require 'pusher/channel' 66 | require 'pusher/request' 67 | require 'pusher/resource' 68 | require 'pusher/webhook' 69 | -------------------------------------------------------------------------------- /.github/workflows/release.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 | 14 | steps: 15 | - name: Set major release 16 | if: ${{ github.event.label.name == 'release-major' }} 17 | run: echo "RELEASE=major" >> $GITHUB_ENV 18 | - name: Set minor release 19 | if: ${{ github.event.label.name == 'release-minor' }} 20 | run: echo "RELEASE=minor" >> $GITHUB_ENV 21 | - name: Set patch release 22 | if: ${{ github.event.label.name == 'release-patch' }} 23 | run: echo "RELEASE=patch" >> $GITHUB_ENV 24 | - name: Check release env 25 | run: | 26 | if [[ -z "${{ env.RELEASE }}" ]]; 27 | then 28 | echo "You need to set a release label on PRs to the main branch" 29 | exit 1 30 | else 31 | exit 0 32 | fi 33 | - name: Install semver-tool 34 | run: | 35 | export DIR=$(mktemp -d) 36 | cd $DIR 37 | curl https://github.com/fsaintjacques/semver-tool/archive/3.2.0.tar.gz -L -o semver.tar.gz 38 | tar -xvf semver.tar.gz 39 | sudo cp semver-tool-3.2.0/src/semver /usr/local/bin 40 | - name: Bump version 41 | run: | 42 | export CURRENT=$(gem info pusher --remote --exact | grep -o "pusher ([0-9]*\.[0-9]*\.[0-9]*)" | awk -F '[()]' '{print $2}') 43 | export NEW_VERSION=$(semver bump ${{ env.RELEASE }} $CURRENT) 44 | echo "VERSION=$NEW_VERSION" >> $GITHUB_ENV 45 | - name: Checkout code 46 | uses: actions/checkout@v2 47 | - name: Setup git 48 | run: | 49 | git config user.email "pusher-ci@pusher.com" 50 | git config user.name "Pusher CI" 51 | git fetch 52 | git checkout ${{ github.event.pull_request.head.ref }} 53 | - name: Prepare CHANGELOG 54 | run: | 55 | echo "${{ github.event.pull_request.body }}" | csplit -s - "/##/" 56 | echo "# Changelog 57 | 58 | ## ${{ env.VERSION }} 59 | " >> CHANGELOG.tmp 60 | grep "^*" xx01 >> CHANGELOG.tmp 61 | grep -v "^# " CHANGELOG.md >> CHANGELOG.tmp 62 | cp CHANGELOG.tmp CHANGELOG.md 63 | - name: Prepare version.rb 64 | run: | 65 | sed -i "s|VERSION = '[^']*'|VERSION = '${{ env.VERSION }}'|" lib/pusher/version.rb 66 | - name: Commit changes 67 | run: | 68 | git add CHANGELOG.md lib/pusher/version.rb 69 | git commit -m "Bump to version ${{ env.VERSION }}" 70 | - name: Push 71 | run: git push 72 | -------------------------------------------------------------------------------- /lib/pusher/request.rb: -------------------------------------------------------------------------------- 1 | require 'pusher-signature' 2 | require 'digest/md5' 3 | require 'multi_json' 4 | 5 | module Pusher 6 | class Request 7 | attr_reader :body, :params 8 | 9 | def initialize(client, verb, uri, params, body = nil) 10 | @client, @verb, @uri = client, verb, uri 11 | @head = { 12 | 'X-Pusher-Library' => 'pusher-http-ruby ' + Pusher::VERSION 13 | } 14 | 15 | @body = body 16 | if body 17 | params[:body_md5] = Digest::MD5.hexdigest(body) 18 | @head['Content-Type'] = 'application/json' 19 | end 20 | 21 | request = Pusher::Signature::Request.new(verb.to_s.upcase, uri.path, params) 22 | request.sign(client.authentication_token) 23 | @params = request.signed_params 24 | end 25 | 26 | def send_sync 27 | http = @client.sync_http_client 28 | 29 | begin 30 | response = http.request(@verb, @uri, @params, @body, @head) 31 | rescue HTTPClient::BadResponseError, HTTPClient::TimeoutError, 32 | SocketError, Errno::ECONNREFUSED => e 33 | error = Pusher::HTTPError.new("#{e.message} (#{e.class})") 34 | error.original_error = e 35 | raise error 36 | end 37 | 38 | body = response.body ? response.body.chomp : nil 39 | 40 | return handle_response(response.code.to_i, body) 41 | end 42 | 43 | def send_async 44 | if defined?(EventMachine) && EventMachine.reactor_running? 45 | http_client = @client.em_http_client(@uri) 46 | df = EM::DefaultDeferrable.new 47 | 48 | http = case @verb 49 | when :post 50 | http_client.post({ 51 | :query => @params, :body => @body, :head => @head 52 | }) 53 | when :get 54 | http_client.get({ 55 | :query => @params, :head => @head 56 | }) 57 | else 58 | raise "Unsupported verb" 59 | end 60 | http.callback { 61 | begin 62 | df.succeed(handle_response(http.response_header.status, http.response.chomp)) 63 | rescue => e 64 | df.fail(e) 65 | end 66 | } 67 | http.errback { |e| 68 | message = "Network error connecting to pusher (#{http.error})" 69 | Pusher.logger.debug(message) 70 | df.fail(Error.new(message)) 71 | } 72 | 73 | return df 74 | else 75 | http = @client.sync_http_client 76 | 77 | return http.request_async(@verb, @uri, @params, @body, @head) 78 | end 79 | end 80 | 81 | private 82 | 83 | def handle_response(status_code, body) 84 | case status_code 85 | when 200 86 | return symbolize_first_level(MultiJson.decode(body)) 87 | when 202 88 | return body.empty? ? true : symbolize_first_level(MultiJson.decode(body)) 89 | when 400 90 | raise Error, "Bad request: #{body}" 91 | when 401 92 | raise AuthenticationError, body 93 | when 404 94 | raise Error, "404 Not found (#{@uri.path})" 95 | when 407 96 | raise Error, "Proxy Authentication Required" 97 | when 413 98 | raise Error, "Payload Too Large > 10KB" 99 | else 100 | raise Error, "Unknown error (status code #{status_code}): #{body}" 101 | end 102 | end 103 | 104 | def symbolize_first_level(hash) 105 | hash.inject({}) do |result, (key, value)| 106 | result[key.to_sym] = value 107 | result 108 | end 109 | end 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /lib/pusher/webhook.rb: -------------------------------------------------------------------------------- 1 | require 'multi_json' 2 | require 'openssl' 3 | 4 | module Pusher 5 | # Used to parse and authenticate WebHooks 6 | # 7 | # @example Sinatra 8 | # post '/webhooks' do 9 | # webhook = Pusher::WebHook.new(request) 10 | # if webhook.valid? 11 | # webhook.events.each do |event| 12 | # case event["name"] 13 | # when 'channel_occupied' 14 | # puts "Channel occupied: #{event["channel"]}" 15 | # when 'channel_vacated' 16 | # puts "Channel vacated: #{event["channel"]}" 17 | # end 18 | # end 19 | # else 20 | # status 401 21 | # end 22 | # return 23 | # end 24 | # 25 | class WebHook 26 | attr_reader :key, :signature 27 | 28 | # Provide either a Rack::Request or a Hash containing :key, :signature, 29 | # :body, and :content_type (optional) 30 | # 31 | def initialize(request, client = Pusher) 32 | @client = client 33 | # For Rack::Request and ActionDispatch::Request 34 | if request.respond_to?(:env) && request.respond_to?(:content_type) 35 | @key = request.env['HTTP_X_PUSHER_KEY'] 36 | @signature = request.env["HTTP_X_PUSHER_SIGNATURE"] 37 | @content_type = request.content_type 38 | 39 | request.body.rewind 40 | @body = request.body.read 41 | request.body.rewind 42 | else 43 | @key, @signature, @body = request.values_at(:key, :signature, :body) 44 | @content_type = request[:content_type] || 'application/json' 45 | end 46 | end 47 | 48 | # Returns whether the WebHook is valid by checking that the signature 49 | # matches the configured key & secret. In the case that the webhook is 50 | # invalid, the reason is logged 51 | # 52 | # @param extra_tokens [Hash] If you have extra tokens for your Pusher app, you can specify them so that they're used to attempt validation. 53 | # 54 | def valid?(extra_tokens = nil) 55 | extra_tokens = [extra_tokens] if extra_tokens.kind_of?(Hash) 56 | if @key == @client.key 57 | return check_signature(@client.secret) 58 | elsif extra_tokens 59 | extra_tokens.each do |token| 60 | return check_signature(token[:secret]) if @key == token[:key] 61 | end 62 | end 63 | Pusher.logger.warn "Received webhook with unknown key: #{key}" 64 | return false 65 | end 66 | 67 | # Array of events (as Hashes) contained inside the webhook 68 | # 69 | def events 70 | data["events"] 71 | end 72 | 73 | # The time at which the WebHook was initially triggered by Pusher, i.e. 74 | # when the event occurred 75 | # 76 | # @return [Time] 77 | # 78 | def time 79 | Time.at(data["time_ms"].to_f/1000) 80 | end 81 | 82 | # Access the parsed WebHook body 83 | # 84 | def data 85 | @data ||= begin 86 | case @content_type 87 | when 'application/json' 88 | MultiJson.decode(@body) 89 | else 90 | raise "Unknown Content-Type (#{@content_type})" 91 | end 92 | end 93 | end 94 | 95 | private 96 | 97 | # Checks signature against secret and returns boolean 98 | # 99 | def check_signature(secret) 100 | digest = OpenSSL::Digest::SHA256.new 101 | expected = OpenSSL::HMAC.hexdigest(digest, secret, @body) 102 | if @signature == expected 103 | return true 104 | else 105 | Pusher.logger.warn "Received WebHook with invalid signature: got #{@signature}, expected #{expected}" 106 | return false 107 | end 108 | end 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /spec/web_hook_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | require 'rack' 4 | require 'stringio' 5 | 6 | describe Pusher::WebHook do 7 | before :each do 8 | @hook_data = { 9 | "time_ms" => 123456, 10 | "events" => [ 11 | {"name" => 'foo'} 12 | ] 13 | } 14 | end 15 | 16 | describe "initialization" do 17 | it "can be initialized with Rack::Request" do 18 | request = Rack::Request.new({ 19 | 'HTTP_X_PUSHER_KEY' => '1234', 20 | 'HTTP_X_PUSHER_SIGNATURE' => 'asdf', 21 | 'CONTENT_TYPE' => 'application/json', 22 | 'rack.input' => StringIO.new(MultiJson.encode(@hook_data)) 23 | }) 24 | wh = Pusher::WebHook.new(request) 25 | expect(wh.key).to eq('1234') 26 | expect(wh.signature).to eq('asdf') 27 | expect(wh.data).to eq(@hook_data) 28 | end 29 | 30 | it "can be initialized with a hash" do 31 | request = { 32 | :key => '1234', 33 | :signature => 'asdf', 34 | :content_type => 'application/json', 35 | :body => MultiJson.encode(@hook_data), 36 | } 37 | wh = Pusher::WebHook.new(request) 38 | expect(wh.key).to eq('1234') 39 | expect(wh.signature).to eq('asdf') 40 | expect(wh.data).to eq(@hook_data) 41 | end 42 | end 43 | 44 | describe "after initialization" do 45 | before :each do 46 | @body = MultiJson.encode(@hook_data) 47 | request = { 48 | :key => '1234', 49 | :signature => hmac('asdf', @body), 50 | :content_type => 'application/json', 51 | :body => @body 52 | } 53 | 54 | @client = Pusher::Client.new 55 | @wh = Pusher::WebHook.new(request, @client) 56 | end 57 | 58 | it "should validate" do 59 | @client.key = '1234' 60 | @client.secret = 'asdf' 61 | expect(@wh).to be_valid 62 | end 63 | 64 | it "should not validate if key is wrong" do 65 | @client.key = '12345' 66 | @client.secret = 'asdf' 67 | expect(Pusher.logger).to receive(:warn).with("Received webhook with unknown key: 1234") 68 | expect(@wh).not_to be_valid 69 | end 70 | 71 | it "should not validate if secret is wrong" do 72 | @client.key = '1234' 73 | @client.secret = 'asdfxxx' 74 | digest = OpenSSL::Digest::SHA256.new 75 | expected = OpenSSL::HMAC.hexdigest(digest, @client.secret, @body) 76 | expect(Pusher.logger).to receive(:warn).with("Received WebHook with invalid signature: got #{@wh.signature}, expected #{expected}") 77 | expect(@wh).not_to be_valid 78 | end 79 | 80 | it "should validate with an extra token" do 81 | @client.key = '12345' 82 | @client.secret = 'xxx' 83 | expect(@wh.valid?({:key => '1234', :secret => 'asdf'})).to be_truthy 84 | end 85 | 86 | it "should validate with an array of extra tokens" do 87 | @client.key = '123456' 88 | @client.secret = 'xxx' 89 | expect(@wh.valid?([ 90 | {:key => '12345', :secret => 'wtf'}, 91 | {:key => '1234', :secret => 'asdf'} 92 | ])).to be_truthy 93 | end 94 | 95 | it "should not validate if all keys are wrong with extra tokens" do 96 | @client.key = '123456' 97 | @client.secret = 'asdf' 98 | expect(Pusher.logger).to receive(:warn).with("Received webhook with unknown key: 1234") 99 | expect(@wh.valid?({:key => '12345', :secret => 'asdf'})).to be_falsey 100 | end 101 | 102 | it "should not validate if secret is wrong with extra tokens" do 103 | @client.key = '123456' 104 | @client.secret = 'asdfxxx' 105 | expect(Pusher.logger).to receive(:warn).with(/Received WebHook with invalid signature/) 106 | expect(@wh.valid?({:key => '1234', :secret => 'wtf'})).to be_falsey 107 | end 108 | 109 | it "should expose events" do 110 | expect(@wh.events).to eq(@hook_data["events"]) 111 | end 112 | 113 | it "should expose time" do 114 | expect(@wh.time).to eq(Time.at(123.456)) 115 | end 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 2.0.3 4 | 5 | * [FIXED] Corrected the channels limit when publishing events. Upped from 10 to 100. 6 | 7 | ## 2.0.2 8 | 9 | * [CHANGED] made encryption_master_key_base64 globally configurable 10 | 11 | ## 2.0.1 12 | 13 | * [CHANGED] Only include lib and essential docs in gem. 14 | 15 | ## 2.0.0 16 | 17 | * [CHANGED] Use TLS by default. 18 | * [REMOVED] Support for Ruby 2.4 and 2.5. 19 | * [FIXED] Handle empty or nil configuration. 20 | * [REMOVED] Legacy Push Notification integration. 21 | * [ADDED] Stalebot and Github actions. 22 | 23 | ## 1.4.3 24 | 25 | * [FIXED] Remove newline from end of base64 encoded strings, some decoders don't like 26 | them. 27 | 28 | ## 1.4.2 29 | ================== 30 | 31 | * [FIXED] Return `shared_secret` to support authenticating encrypted channels. Thanks 32 | @Benjaminpjacobs 33 | 34 | ## 1.4.1 35 | 36 | * [CHANGED] Remove rbnacl from dependencies so we don't get errors when it isn't 37 | required. Thanks @y-yagi! 38 | 39 | ## 1.4.0 40 | 41 | * [ADDED] Support for end-to-end encryption. 42 | 43 | ## 1.3.3 44 | 45 | * [CHANGED] Rewording to clarify "Pusher Channels" or simply "Channels" product name. 46 | 47 | ## 1.3.2 48 | 49 | * [FIXED] Return a specific error for "Request Entity Too Large" (body over 10KB). 50 | * [ADDED] Add a `use_tls` option for SSL (defaults to false). 51 | * [ADDED] Add a `from_url` client method (in addition to existing `from_env` option). 52 | * [CHANGED] Improved documentation and fixed typos. 53 | * [ADDED] Add Ruby 2.4 to test matrix. 54 | 55 | ## 1.3.1 56 | 57 | * [FIXED] Added missing client batch methods to default client delegations 58 | * [CHANGED] Document raised exception in the `authenticate` method 59 | * [FIXED] Fixes em-http-request from using v2.5.0 of `addressable` breaking builds. 60 | 61 | ## 1.3.0 62 | 63 | * [ADDED] Add support for sending push notifications on up to 10 interests. 64 | 65 | ## 1.2.1 66 | 67 | * [FIXED] Fixes Rails 5 compatibility. Use duck-typing to detect request object 68 | 69 | ## 1.2.0 70 | 71 | * [CHANGED] Minor release for Native notifications 72 | 73 | ## 1.2.0.rc1 74 | 75 | * [ADDED] Add support for Native notifications 76 | 77 | ## 1.1.0 78 | 79 | * [ADDED] Add support for batch events 80 | 81 | ## 1.0.0 82 | 83 | * [CHANGED] No breaking changes, this release is just to follow semver and show that we 84 | are stable. 85 | 86 | ## 0.18.0 87 | 88 | * [ADDED] Introduce `Pusher::Client.from_env` 89 | * [FIXED] Improve error handling on missing config 90 | 91 | ## 0.17.0 92 | 93 | * [ADDED] Introduce the `cluster` option. 94 | 95 | ## 0.16.0 96 | 97 | * [CHANGED] Bump httpclient version to 2.7 98 | * [REMOVED] Ruby 1.8.7 is not supported anymore. 99 | 100 | ## 0.15.2 101 | 102 | * [CHANGED] Documented `Pusher.channel_info`, `Pusher.channels` 103 | * [ADDED] Added `Pusher.channel_users` 104 | 105 | ## 0.15.1 106 | 107 | * [FIXED] Fixed a bug where the `authenticate` method added in 0.15.0 wasn't exposed on the Pusher class. 108 | 109 | ## 0.15.0 110 | 111 | * [ADDED] Added `Pusher.authenticate` method for authenticating private and presence channels. 112 | This is prefered over the older `Pusher['a_channel'].authenticate(...)` style. 113 | 114 | ## 0.14.6 115 | 116 | * [CHANGED] Updated to use the `pusher-signature` gem instead of `signature`. 117 | This resolves namespace related issues. 118 | 119 | ## 0.14.5 120 | 121 | * [SECURITY] Prevent auth delegation trough crafted socket IDs 122 | 123 | ## 0.14.4 124 | 125 | * [SECURITY] Prevent timing attack, update signature to v0.1.8 126 | * [SECURITY] Prevent POODLE. Disable SSLv3, update httpclient to v2.5 127 | * [FIXED] Fix channel name character limit. 128 | * [ADDED] Adds support for listing users on a presence channel 129 | 130 | ## 0.14.2 131 | 132 | * [CHANGED] Bump httpclient to v2.4. See #62 (POODLE SSL) 133 | * [CHANGED] Fix limited channel count at README.md. Thanks @tricknotes 134 | -------------------------------------------------------------------------------- /spec/channel_spec.rb: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | require 'spec_helper' 3 | 4 | describe Pusher::Channel do 5 | before do 6 | @client = Pusher::Client.new({ 7 | :app_id => '20', 8 | :key => '12345678900000001', 9 | :secret => '12345678900000001', 10 | :host => 'api.pusherapp.com', 11 | :port => 80, 12 | }) 13 | @channel = @client['test_channel'] 14 | end 15 | 16 | let(:pusher_url_regexp) { %r{/apps/20/events} } 17 | 18 | def stub_post(status, body = nil) 19 | options = {:status => status} 20 | options.merge!({:body => body}) if body 21 | 22 | stub_request(:post, pusher_url_regexp).to_return(options) 23 | end 24 | 25 | def stub_post_to_raise(e) 26 | stub_request(:post, pusher_url_regexp).to_raise(e) 27 | end 28 | 29 | describe '#trigger!' do 30 | it "should use @client.trigger internally" do 31 | expect(@client).to receive(:trigger) 32 | @channel.trigger!('new_event', 'Some data') 33 | end 34 | end 35 | 36 | describe '#trigger' do 37 | it "should log failure if error raised in http call" do 38 | stub_post_to_raise(HTTPClient::BadResponseError) 39 | 40 | expect(Pusher.logger).to receive(:error).with("Exception from WebMock (HTTPClient::BadResponseError) (Pusher::HTTPError)") 41 | expect(Pusher.logger).to receive(:debug) #backtrace 42 | @channel.trigger('new_event', 'Some data') 43 | end 44 | 45 | it "should log failure if Pusher returns an error response" do 46 | stub_post 401, "some signature info" 47 | expect(Pusher.logger).to receive(:error).with("some signature info (Pusher::AuthenticationError)") 48 | expect(Pusher.logger).to receive(:debug) #backtrace 49 | @channel.trigger('new_event', 'Some data') 50 | end 51 | end 52 | 53 | describe "#initialization" do 54 | it "should not be too long" do 55 | expect { @client['b'*201] }.to raise_error(Pusher::Error) 56 | end 57 | 58 | it "should not use bad characters" do 59 | expect { @client['*^!±`/""'] }.to raise_error(Pusher::Error) 60 | end 61 | end 62 | 63 | describe "#trigger_async" do 64 | it "should use @client.trigger_async internally" do 65 | expect(@client).to receive(:trigger_async) 66 | @channel.trigger_async('new_event', 'Some data') 67 | end 68 | end 69 | 70 | describe '#info' do 71 | it "should call the Client#channel_info" do 72 | expect(@client).to receive(:get) 73 | .with("/channels/mychannel", anything) 74 | .and_return({:occupied => true, :subscription_count => 12}) 75 | @channel = @client['mychannel'] 76 | @channel.info 77 | end 78 | 79 | it "should assemble the requested attributes into the info option" do 80 | expect(@client).to receive(:get) 81 | .with(anything, {:info => "user_count,connection_count"}) 82 | .and_return({:occupied => true, :subscription_count => 12, :user_count => 12}) 83 | @channel = @client['presence-foo'] 84 | @channel.info(%w{user_count connection_count}) 85 | end 86 | end 87 | 88 | describe '#users' do 89 | it "should call the Client#channel_users" do 90 | expect(@client).to receive(:get).with("/channels/presence-mychannel/users", {}).and_return({:users => {'id' => '4'}}) 91 | @channel = @client['presence-mychannel'] 92 | @channel.users 93 | end 94 | end 95 | 96 | describe "#authentication_string" do 97 | def authentication_string(*data) 98 | lambda { @channel.authentication_string(*data) } 99 | end 100 | 101 | it "should return an authentication string given a socket id" do 102 | auth = @channel.authentication_string('1.1') 103 | 104 | expect(auth).to eq('12345678900000001:02259dff9a2a3f71ea8ab29ac0c0c0ef7996c8f3fd3702be5533f30da7d7fed4') 105 | end 106 | 107 | it "should raise error if authentication is invalid" do 108 | [nil, ''].each do |invalid| 109 | expect(authentication_string(invalid)).to raise_error Pusher::Error 110 | end 111 | end 112 | 113 | describe 'with extra string argument' do 114 | it 'should be a string or nil' do 115 | expect(authentication_string('1.1', 123)).to raise_error Pusher::Error 116 | expect(authentication_string('1.1', {})).to raise_error Pusher::Error 117 | 118 | expect(authentication_string('1.1', 'boom')).not_to raise_error 119 | expect(authentication_string('1.1', nil)).not_to raise_error 120 | end 121 | 122 | it "should return an authentication string given a socket id and custom args" do 123 | auth = @channel.authentication_string('1.1', 'foobar') 124 | 125 | expect(auth).to eq("12345678900000001:#{hmac(@client.secret, "1.1:test_channel:foobar")}") 126 | end 127 | end 128 | end 129 | 130 | describe '#authenticate' do 131 | before :each do 132 | @custom_data = {:uid => 123, :info => {:name => 'Foo'}} 133 | end 134 | 135 | it 'should return a hash with signature including custom data and data as json string' do 136 | allow(MultiJson).to receive(:encode).with(@custom_data).and_return 'a json string' 137 | 138 | response = @channel.authenticate('1.1', @custom_data) 139 | 140 | expect(response).to eq({ 141 | :auth => "12345678900000001:#{hmac(@client.secret, "1.1:test_channel:a json string")}", 142 | :channel_data => 'a json string' 143 | }) 144 | end 145 | 146 | it 'should fail on invalid socket_ids' do 147 | expect { 148 | @channel.authenticate('1.1:') 149 | }.to raise_error Pusher::Error 150 | 151 | expect { 152 | @channel.authenticate('1.1foo', 'channel') 153 | }.to raise_error Pusher::Error 154 | 155 | expect { 156 | @channel.authenticate(':1.1') 157 | }.to raise_error Pusher::Error 158 | 159 | expect { 160 | @channel.authenticate('foo1.1', 'channel') 161 | }.to raise_error Pusher::Error 162 | 163 | expect { 164 | @channel.authenticate('foo', 'channel') 165 | }.to raise_error Pusher::Error 166 | end 167 | end 168 | 169 | describe `#shared_secret` do 170 | before(:each) do 171 | @channel.instance_variable_set(:@name, 'private-encrypted-1') 172 | end 173 | 174 | it 'should return a shared_secret based on the channel name and encryption master key' do 175 | key = '3W1pfB/Etr+ZIlfMWwZP3gz8jEeCt4s2pe6Vpr+2c3M=' 176 | shared_secret = @channel.shared_secret(key) 177 | expect(Base64.strict_encode64(shared_secret)).to eq( 178 | "6zeEp/chneRPS1cbK/hGeG860UhHomxSN6hTgzwT20I=" 179 | ) 180 | end 181 | 182 | it 'should return nil if missing encryption master key' do 183 | shared_secret = @channel.shared_secret(nil) 184 | expect(shared_secret).to be_nil 185 | end 186 | end 187 | end 188 | -------------------------------------------------------------------------------- /lib/pusher/channel.rb: -------------------------------------------------------------------------------- 1 | require 'openssl' 2 | require 'multi_json' 3 | 4 | module Pusher 5 | # Delegates operations for a specific channel from a client 6 | class Channel 7 | attr_reader :name 8 | INVALID_CHANNEL_REGEX = /[^A-Za-z0-9_\-=@,.;]/ 9 | 10 | def initialize(_, name, client = Pusher) 11 | if Pusher::Channel::INVALID_CHANNEL_REGEX.match(name) 12 | raise Pusher::Error, "Illegal channel name '#{name}'" 13 | elsif name.length > 200 14 | raise Pusher::Error, "Channel name too long (limit 164 characters) '#{name}'" 15 | end 16 | @name = name 17 | @client = client 18 | end 19 | 20 | # Trigger event asynchronously using EventMachine::HttpRequest 21 | # 22 | # [Deprecated] This method will be removed in a future gem version. Please 23 | # switch to Pusher.trigger_async or Pusher::Client#trigger_async instead 24 | # 25 | # @param (see #trigger!) 26 | # @return [EM::DefaultDeferrable] 27 | # Attach a callback to be notified of success (with no parameters). 28 | # Attach an errback to be notified of failure (with an error parameter 29 | # which includes the HTTP status code returned) 30 | # @raise [LoadError] unless em-http-request gem is available 31 | # @raise [Pusher::Error] unless the eventmachine reactor is running. You 32 | # probably want to run your application inside a server such as thin 33 | # 34 | def trigger_async(event_name, data, socket_id = nil) 35 | params = {} 36 | if socket_id 37 | validate_socket_id(socket_id) 38 | params[:socket_id] = socket_id 39 | end 40 | @client.trigger_async(name, event_name, data, params) 41 | end 42 | 43 | # Trigger event 44 | # 45 | # [Deprecated] This method will be removed in a future gem version. Please 46 | # switch to Pusher.trigger or Pusher::Client#trigger instead 47 | # 48 | # @example 49 | # begin 50 | # Pusher['my-channel'].trigger!('an_event', {:some => 'data'}) 51 | # rescue Pusher::Error => e 52 | # # Do something on error 53 | # end 54 | # 55 | # @param data [Object] Event data to be triggered in javascript. 56 | # Objects other than strings will be converted to JSON 57 | # @param socket_id Allows excluding a given socket_id from receiving the 58 | # event - see http://pusher.com/docs/publisher_api_guide/publisher_excluding_recipients for more info 59 | # 60 | # @raise [Pusher::Error] on invalid Pusher response - see the error message for more details 61 | # @raise [Pusher::HTTPError] on any error raised inside http client - the original error is available in the original_error attribute 62 | # 63 | def trigger!(event_name, data, socket_id = nil) 64 | params = {} 65 | if socket_id 66 | validate_socket_id(socket_id) 67 | params[:socket_id] = socket_id 68 | end 69 | @client.trigger(name, event_name, data, params) 70 | end 71 | 72 | # Trigger event, catching and logging any errors. 73 | # 74 | # [Deprecated] This method will be removed in a future gem version. Please 75 | # switch to Pusher.trigger or Pusher::Client#trigger instead 76 | # 77 | # @note CAUTION! No exceptions will be raised on failure 78 | # @param (see #trigger!) 79 | # 80 | def trigger(event_name, data, socket_id = nil) 81 | trigger!(event_name, data, socket_id) 82 | rescue Pusher::Error => e 83 | Pusher.logger.error("#{e.message} (#{e.class})") 84 | Pusher.logger.debug(e.backtrace.join("\n")) 85 | end 86 | 87 | # Request info for a channel 88 | # 89 | # @example Response 90 | # [{:occupied=>true, :subscription_count => 12}] 91 | # 92 | # @param info [Array] Array of attributes required (as lowercase strings) 93 | # @return [Hash] Hash of requested attributes for this channel 94 | # @raise [Pusher::Error] on invalid Pusher response - see the error message for more details 95 | # @raise [Pusher::HTTPError] on any error raised inside http client - the original error is available in the original_error attribute 96 | # 97 | def info(attributes = []) 98 | @client.channel_info(name, :info => attributes.join(',')) 99 | end 100 | 101 | # Request users for a presence channel 102 | # Only works on presence channels (see: http://pusher.com/docs/client_api_guide/client_presence_channels and https://pusher.com/docs/rest_api) 103 | # 104 | # @example Response 105 | # [{:id=>"4"}] 106 | # 107 | # @param params [Hash] Hash of parameters for the API - see REST API docs 108 | # @return [Hash] Array of user hashes for this channel 109 | # @raise [Pusher::Error] on invalid Pusher response - see the error message for more details 110 | # @raise [Pusher::HTTPError] on any error raised inside Net::HTTP - the original error is available in the original_error attribute 111 | # 112 | def users(params = {}) 113 | @client.channel_users(name, params)[:users] 114 | end 115 | 116 | # Compute authentication string required as part of the authentication 117 | # endpoint response. Generally the authenticate method should be used in 118 | # preference to this one 119 | # 120 | # @param socket_id [String] Each Pusher socket connection receives a 121 | # unique socket_id. This is sent from pusher.js to your server when 122 | # channel authentication is required. 123 | # @param custom_string [String] Allows signing additional data 124 | # @return [String] 125 | # 126 | # @raise [Pusher::Error] if socket_id or custom_string invalid 127 | # 128 | def authentication_string(socket_id, custom_string = nil) 129 | validate_socket_id(socket_id) 130 | 131 | unless custom_string.nil? || custom_string.kind_of?(String) 132 | raise Error, 'Custom argument must be a string' 133 | end 134 | 135 | string_to_sign = [socket_id, name, custom_string]. 136 | compact.map(&:to_s).join(':') 137 | Pusher.logger.debug "Signing #{string_to_sign}" 138 | token = @client.authentication_token 139 | digest = OpenSSL::Digest::SHA256.new 140 | signature = OpenSSL::HMAC.hexdigest(digest, token.secret, string_to_sign) 141 | 142 | return "#{token.key}:#{signature}" 143 | end 144 | 145 | # Generate the expected response for an authentication endpoint. 146 | # See http://pusher.com/docs/authenticating_users for details. 147 | # 148 | # @example Private channels 149 | # render :json => Pusher['private-my_channel'].authenticate(params[:socket_id]) 150 | # 151 | # @example Presence channels 152 | # render :json => Pusher['presence-my_channel'].authenticate(params[:socket_id], { 153 | # :user_id => current_user.id, # => required 154 | # :user_info => { # => optional - for example 155 | # :name => current_user.name, 156 | # :email => current_user.email 157 | # } 158 | # }) 159 | # 160 | # @param socket_id [String] 161 | # @param custom_data [Hash] used for example by private channels 162 | # 163 | # @return [Hash] 164 | # 165 | # @raise [Pusher::Error] if socket_id or custom_data is invalid 166 | # 167 | # @private Custom data is sent to server as JSON-encoded string 168 | # 169 | def authenticate(socket_id, custom_data = nil) 170 | custom_data = MultiJson.encode(custom_data) if custom_data 171 | auth = authentication_string(socket_id, custom_data) 172 | r = {:auth => auth} 173 | r[:channel_data] = custom_data if custom_data 174 | r 175 | end 176 | 177 | def shared_secret(encryption_master_key) 178 | return unless encryption_master_key 179 | 180 | secret_string = @name + encryption_master_key 181 | digest = OpenSSL::Digest::SHA256.new 182 | digest << secret_string 183 | digest.digest 184 | end 185 | 186 | private 187 | 188 | def validate_socket_id(socket_id) 189 | unless socket_id && /\A\d+\.\d+\z/.match(socket_id) 190 | raise Pusher::Error, "Invalid socket ID #{socket_id.inspect}" 191 | end 192 | end 193 | end 194 | end 195 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Gem for Pusher Channels 2 | 3 | This Gem provides a Ruby interface to [the Pusher HTTP API for Pusher Channels](https://pusher.com/docs/channels/library_auth_reference/rest-api). 4 | 5 | [![Build Status](https://github.com/pusher/pusher-http-ruby/workflows/Tests/badge.svg)](https://github.com/pusher/pusher-http-ruby/actions?query=workflow%3ATests+branch%3Amaster) [![Gem](https://img.shields.io/gem/v/pusher)](https://rubygems.org/gems/pusher) [![Gem](https://img.shields.io/gem/dt/pusher)](https://rubygems.org/gems/pusher) 6 | 7 | ## Supported Platforms 8 | 9 | * Ruby - supports **Ruby 2.6 or greater**. 10 | 11 | ## Installation and Configuration 12 | 13 | Add `pusher` to your Gemfile, and then run `bundle install` 14 | 15 | ``` ruby 16 | gem 'pusher' 17 | ``` 18 | 19 | or install via gem 20 | 21 | ``` bash 22 | gem install pusher 23 | ``` 24 | 25 | After registering at [Pusher](https://dashboard.pusher.com/accounts/sign_up), configure your Channels app with the security credentials. 26 | 27 | ### Instantiating a Pusher Channels client 28 | 29 | Creating a new Pusher Channels `client` can be done as follows. 30 | 31 | ``` ruby 32 | require 'pusher' 33 | 34 | pusher = Pusher::Client.new( 35 | app_id: 'your-app-id', 36 | key: 'your-app-key', 37 | secret: 'your-app-secret', 38 | cluster: 'your-app-cluster', 39 | use_tls: true 40 | ) 41 | ``` 42 | 43 | The `cluster` value will set the `host` to `api-.pusher.com`. The `use_tls` value is optional and defaults to `true`. It will set the `scheme` and `port`. A custom `port` value takes precendence over `use_tls`. 44 | 45 | If you want to set a custom `host` value for your client then you can do so when instantiating a Pusher Channels client like so: 46 | 47 | ``` ruby 48 | require 'pusher' 49 | 50 | pusher = Pusher::Client.new( 51 | app_id: 'your-app-id', 52 | key: 'your-app-key', 53 | secret: 'your-app-secret', 54 | host: 'your-app-host' 55 | ) 56 | ``` 57 | 58 | If you pass both `host` and `cluster` options, the `host` will take precendence and `cluster` will be ignored. 59 | 60 | Finally, if you have the configuration set in an `PUSHER_URL` environment 61 | variable, you can use: 62 | 63 | ``` ruby 64 | pusher = Pusher::Client.from_env 65 | ``` 66 | 67 | ### Global configuration 68 | 69 | The library can also be configured globally on the `Pusher` class. 70 | 71 | ``` ruby 72 | Pusher.app_id = 'your-app-id' 73 | Pusher.key = 'your-app-key' 74 | Pusher.secret = 'your-app-secret' 75 | Pusher.cluster = 'your-app-cluster' 76 | ``` 77 | 78 | Global configuration will automatically be set from the `PUSHER_URL` environment variable if it exists. This should be in the form `http://KEY:SECRET@HOST/apps/APP_ID`. On Heroku this environment variable will already be set. 79 | 80 | If you need to make requests via a HTTP proxy then it can be configured 81 | 82 | ``` ruby 83 | Pusher.http_proxy = 'http://(user):(password)@(host):(port)' 84 | ``` 85 | 86 | By default API requests are made over HTTPS. HTTP can be used by setting `use_tls` to `false`. 87 | Issuing this command is going to reset `port` value if it was previously specified. 88 | 89 | ``` ruby 90 | Pusher.use_tls = false 91 | ``` 92 | 93 | As of version 0.12, SSL certificates are verified when using the synchronous http client. If you need to disable this behaviour for any reason use: 94 | 95 | ``` ruby 96 | Pusher.default_client.sync_http_client.ssl_config.verify_mode = OpenSSL::SSL::VERIFY_NONE 97 | ``` 98 | 99 | ## Interacting with the Channels HTTP API 100 | 101 | The `pusher` gem contains a number of helpers for interacting with the API. As a general rule, the library adheres to a set of conventions that we have aimed to make universal. 102 | 103 | ### Handling errors 104 | 105 | Handle errors by rescuing `Pusher::Error` (all errors are descendants of this error) 106 | 107 | ``` ruby 108 | begin 109 | pusher.trigger('a_channel', 'an_event', :some => 'data') 110 | rescue Pusher::Error => e 111 | # (Pusher::AuthenticationError, Pusher::HTTPError, or Pusher::Error) 112 | end 113 | ``` 114 | 115 | ### Logging 116 | 117 | Errors are logged to `Pusher.logger`. It will by default log at info level to STDOUT using `Logger` from the standard library, however you can assign any logger: 118 | 119 | ``` ruby 120 | Pusher.logger = Rails.logger 121 | ``` 122 | 123 | ### Publishing events 124 | 125 | An event can be published to one or more channels (limited to 10) in one API call: 126 | 127 | ``` ruby 128 | pusher.trigger('channel', 'event', foo: 'bar') 129 | pusher.trigger(['channel_1', 'channel_2'], 'event_name', foo: 'bar') 130 | ``` 131 | 132 | An optional fourth argument may be used to send additional parameters to the API, for example to [exclude a single connection from receiving the event](https://pusher.com/docs/channels/server_api/excluding-event-recipients). 133 | 134 | ``` ruby 135 | pusher.trigger('channel', 'event', {foo: 'bar'}, {socket_id: '123.456'}) 136 | ``` 137 | 138 | #### Batches 139 | 140 | It's also possible to send multiple events with a single API call (max 10 141 | events per call on multi-tenant clusters): 142 | 143 | ``` ruby 144 | pusher.trigger_batch([ 145 | {channel: 'channel_1', name: 'event_name', data: { foo: 'bar' }}, 146 | {channel: 'channel_1', name: 'event_name', data: { hello: 'world' }} 147 | ]) 148 | ``` 149 | 150 | #### Deprecated publisher API 151 | 152 | Most examples and documentation will refer to the following syntax for triggering an event: 153 | 154 | ``` ruby 155 | Pusher['a_channel'].trigger('an_event', :some => 'data') 156 | ``` 157 | 158 | This will continue to work, but has been replaced by `pusher.trigger` which supports one or multiple channels. 159 | 160 | ### Getting information about the channels in your Pusher Channels app 161 | 162 | This gem provides methods for accessing information from the [Channels HTTP API](https://pusher.com/docs/channels/library_auth_reference/rest-api). The documentation also shows an example of the responses from each of the API endpoints. 163 | 164 | The following methods are provided by the gem. 165 | 166 | - `pusher.channel_info('channel_name', {info:"user_count,subscription_count"})` returns a hash describing the state of the channel([docs](https://pusher.com/docs/channels/library_auth_reference/rest-api#get-channels-fetch-info-for-multiple-channels-)). 167 | 168 | - `pusher.channel_users('presence-channel_name')` returns a list of all the users subscribed to the channel (only for Presence Channels) ([docs](https://pusher.com/docs/channels/library_auth_reference/rest-api#get-channels-fetch-info-for-multiple-channels-)). 169 | 170 | - `pusher.channels({filter_by_prefix: 'presence-', info: 'user_count'})` returns a hash of occupied channels (optionally filtered by prefix, f.i. `presence-`), and optionally attributes for these channels ([docs](https://pusher.com/docs/channels/library_auth_reference/rest-api#get-channels-fetch-info-for-multiple-channels-)). 171 | 172 | ### Asynchronous requests 173 | 174 | There are two main reasons for using the `_async` methods: 175 | 176 | * In a web application where the response from the Channels HTTP API is not used, but you'd like to avoid a blocking call in the request-response cycle 177 | * Your application is running in an event loop and you need to avoid blocking the reactor 178 | 179 | Asynchronous calls are supported either by using an event loop (eventmachine, preferred), or via a thread. 180 | 181 | The following methods are available (in each case the calling interface matches the non-async version): 182 | 183 | * `pusher.get_async` 184 | * `pusher.post_async` 185 | * `pusher.trigger_async` 186 | 187 | It is of course also possible to make calls to the Channels HTTP API via a job queue. This approach is recommended if you're sending a large number of events. 188 | 189 | #### With EventMachine 190 | 191 | * Add the `em-http-request` gem to your Gemfile (it's not a gem dependency). 192 | * Run the EventMachine reactor (either using `EM.run` or by running inside an evented server such as Thin). 193 | 194 | The `_async` methods return an `EM::Deferrable` which you can bind callbacks to: 195 | 196 | ``` ruby 197 | pusher.get_async("/channels").callback { |response| 198 | # use reponse[:channels] 199 | }.errback { |error| 200 | # error is an instance of Pusher::Error 201 | } 202 | ``` 203 | 204 | A HTTP error or an error response from Channels will cause the errback to be called with an appropriate error object. 205 | 206 | #### Without EventMachine 207 | 208 | If the EventMachine reactor is not running, async requests will be made using threads (managed by the httpclient gem). 209 | 210 | An `HTTPClient::Connection` object is returned immediately which can be [interrogated](http://rubydoc.info/gems/httpclient/HTTPClient/Connection) to discover the status of the request. The usual response checking and processing is not done when the request completes, and frankly this method is most useful when you're not interested in waiting for the response. 211 | 212 | 213 | ## Authenticating subscription requests 214 | 215 | It's possible to use the gem to authenticate subscription requests to private or presence channels. The `authenticate` method is available on a channel object for this purpose and returns a JSON object that can be returned to the client that made the request. More information on this authentication scheme can be found in the docs on 216 | 217 | ### Private channels 218 | 219 | ``` ruby 220 | pusher.authenticate('private-my_channel', params[:socket_id]) 221 | ``` 222 | 223 | ### Presence channels 224 | 225 | These work in a very similar way, but require a unique identifier for the user being authenticated, and optionally some attributes that are provided to clients via presence events: 226 | 227 | ``` ruby 228 | pusher.authenticate('presence-my_channel', params[:socket_id], 229 | user_id: 'user_id', 230 | user_info: {} # optional 231 | ) 232 | ``` 233 | 234 | ## Receiving WebHooks 235 | 236 | A WebHook object may be created to validate received WebHooks against your app credentials, and to extract events. It should be created with the `Rack::Request` object (available as `request` in Rails controllers or Sinatra handlers for example). 237 | 238 | ``` ruby 239 | webhook = pusher.webhook(request) 240 | if webhook.valid? 241 | webhook.events.each do |event| 242 | case event["name"] 243 | when 'channel_occupied' 244 | puts "Channel occupied: #{event["channel"]}" 245 | when 'channel_vacated' 246 | puts "Channel vacated: #{event["channel"]}" 247 | end 248 | end 249 | render text: 'ok' 250 | else 251 | render text: 'invalid', status: 401 252 | end 253 | ``` 254 | 255 | ### End-to-end encryption 256 | 257 | This library supports [end-to-end encrypted channels](https://pusher.com/docs/channels/using_channels/encrypted-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: 258 | 259 | 1. Add the `rbnacl` gem to your Gemfile (it's not a gem dependency). 260 | 261 | 2. Install [Libsodium](https://github.com/jedisct1/libsodium), which we rely on to do the heavy lifting. [Follow the installation instructions for your platform.](https://github.com/RubyCrypto/rbnacl/wiki/Installing-libsodium) 262 | 263 | 3. Encrypted channel subscriptions must be authenticated in the exact same way as private channels. You should therefore [create an authentication endpoint on your server](https://pusher.com/docs/authenticating_users). 264 | 265 | 4. Next, generate your 32 byte master encryption key, encode it as base64 and pass it to the Pusher constructor. 266 | 267 | This is secret and you should never share this with anyone. 268 | Not even Pusher. 269 | 270 | ```bash 271 | openssl rand -base64 32 272 | ``` 273 | 274 | ```rb 275 | pusher = new Pusher::Client.new({ 276 | app_id: 'your-app-id', 277 | key: 'your-app-key', 278 | secret: 'your-app-secret', 279 | cluster: 'your-app-cluster', 280 | use_tls: true 281 | encryption_master_key_base64: '', 282 | }); 283 | ``` 284 | 285 | 5. Channels where you wish to use end-to-end encryption should be prefixed with `private-encrypted-`. 286 | 287 | 6. 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/](dashboard) and seeing the scrambled ciphertext. 288 | 289 | **Important note: This will __not__ encrypt messages on channels that are not prefixed by `private-encrypted-`.** 290 | 291 | **Limitation**: you cannot trigger a single event on multiple channels in a call to `trigger`, e.g. 292 | 293 | ```rb 294 | pusher.trigger( 295 | ['channel-1', 'private-encrypted-channel-2'], 296 | 'test_event', 297 | { message: 'hello world' }, 298 | ) 299 | ``` 300 | 301 | Rationale: the methods in this library map directly to individual Channels HTTP API requests. If we allowed triggering a single event on multiple channels (some encrypted, some unencrypted), then it would require two API requests: one where the event is encrypted to the encrypted channels, and one where the event is unencrypted for unencrypted channels. 302 | -------------------------------------------------------------------------------- /lib/pusher/client.rb: -------------------------------------------------------------------------------- 1 | require 'base64' 2 | require 'pusher-signature' 3 | 4 | module Pusher 5 | class Client 6 | attr_accessor :scheme, :host, :port, :app_id, :key, :secret, :encryption_master_key 7 | attr_reader :http_proxy, :proxy 8 | attr_writer :connect_timeout, :send_timeout, :receive_timeout, 9 | :keep_alive_timeout 10 | 11 | ## CONFIGURATION ## 12 | DEFAULT_CONNECT_TIMEOUT = 5 13 | DEFAULT_SEND_TIMEOUT = 5 14 | DEFAULT_RECEIVE_TIMEOUT = 5 15 | DEFAULT_KEEP_ALIVE_TIMEOUT = 30 16 | DEFAULT_CLUSTER = "mt1" 17 | 18 | # Loads the configuration from an url in the environment 19 | def self.from_env(key = 'PUSHER_URL') 20 | url = ENV[key] || raise(ConfigurationError, key) 21 | from_url(url) 22 | end 23 | 24 | # Loads the configuration from a url 25 | def self.from_url(url) 26 | client = new 27 | client.url = url 28 | client 29 | end 30 | 31 | def initialize(options = {}) 32 | @scheme = "https" 33 | @port = options[:port] || 443 34 | 35 | if options.key?(:encrypted) 36 | warn "[DEPRECATION] `encrypted` is deprecated and will be removed in the next major version. Use `use_tls` instead." 37 | end 38 | 39 | if options[:use_tls] == false || options[:encrypted] == false 40 | @scheme = "http" 41 | @port = options[:port] || 80 42 | end 43 | 44 | @app_id = options[:app_id] 45 | @key = options[:key] 46 | @secret = options[:secret] 47 | 48 | @host = options[:host] 49 | @host ||= "api-#{options[:cluster]}.pusher.com" unless options[:cluster].nil? || options[:cluster].empty? 50 | @host ||= "api-#{DEFAULT_CLUSTER}.pusher.com" 51 | 52 | @encryption_master_key = Base64.strict_decode64(options[:encryption_master_key_base64]) if options[:encryption_master_key_base64] 53 | 54 | @http_proxy = options[:http_proxy] 55 | 56 | # Default timeouts 57 | @connect_timeout = DEFAULT_CONNECT_TIMEOUT 58 | @send_timeout = DEFAULT_SEND_TIMEOUT 59 | @receive_timeout = DEFAULT_RECEIVE_TIMEOUT 60 | @keep_alive_timeout = DEFAULT_KEEP_ALIVE_TIMEOUT 61 | end 62 | 63 | # @private Returns the authentication token for the client 64 | def authentication_token 65 | raise ConfigurationError, :key unless @key 66 | raise ConfigurationError, :secret unless @secret 67 | Pusher::Signature::Token.new(@key, @secret) 68 | end 69 | 70 | # @private Builds a url for this app, optionally appending a path 71 | def url(path = nil) 72 | raise ConfigurationError, :app_id unless @app_id 73 | URI::Generic.build({ 74 | scheme: @scheme, 75 | host: @host, 76 | port: @port, 77 | path: "/apps/#{@app_id}#{path}" 78 | }) 79 | end 80 | 81 | # Configure Pusher connection by providing a url rather than specifying 82 | # scheme, key, secret, and app_id separately. 83 | # 84 | # @example 85 | # Pusher.url = http://KEY:SECRET@api.pusherapp.com/apps/APP_ID 86 | # 87 | def url=(url) 88 | uri = URI.parse(url) 89 | @scheme = uri.scheme 90 | @app_id = uri.path.split('/').last 91 | @key = uri.user 92 | @secret = uri.password 93 | @host = uri.host 94 | @port = uri.port 95 | end 96 | 97 | def http_proxy=(http_proxy) 98 | @http_proxy = http_proxy 99 | uri = URI.parse(http_proxy) 100 | @proxy = { 101 | scheme: uri.scheme, 102 | host: uri.host, 103 | port: uri.port, 104 | user: uri.user, 105 | password: uri.password 106 | } 107 | end 108 | 109 | # Configure whether Pusher API calls should be made over SSL 110 | # (default false) 111 | # 112 | # @example 113 | # Pusher.encrypted = true 114 | # 115 | def encrypted=(boolean) 116 | @scheme = boolean ? 'https' : 'http' 117 | # Configure port if it hasn't already been configured 118 | @port = boolean ? 443 : 80 119 | end 120 | 121 | def encrypted? 122 | @scheme == 'https' 123 | end 124 | 125 | def cluster=(cluster) 126 | cluster = DEFAULT_CLUSTER if cluster.nil? || cluster.empty? 127 | 128 | @host = "api-#{cluster}.pusher.com" 129 | end 130 | 131 | # Convenience method to set all timeouts to the same value (in seconds). 132 | # For more control, use the individual writers. 133 | def timeout=(value) 134 | @connect_timeout, @send_timeout, @receive_timeout = value, value, value 135 | end 136 | 137 | # Set an encryption_master_key to use with private-encrypted channels from 138 | # a base64 encoded string. 139 | def encryption_master_key_base64=(s) 140 | @encryption_master_key = s ? Base64.strict_decode64(s) : nil 141 | end 142 | 143 | ## INTERACT WITH THE API ## 144 | 145 | def resource(path) 146 | Resource.new(self, path) 147 | end 148 | 149 | # GET arbitrary REST API resource using a synchronous http client. 150 | # All request signing is handled automatically. 151 | # 152 | # @example 153 | # begin 154 | # Pusher.get('/channels', filter_by_prefix: 'private-') 155 | # rescue Pusher::Error => e 156 | # # Handle error 157 | # end 158 | # 159 | # @param path [String] Path excluding /apps/APP_ID 160 | # @param params [Hash] API params (see http://pusher.com/docs/rest_api) 161 | # 162 | # @return [Hash] See Pusher API docs 163 | # 164 | # @raise [Pusher::Error] Unsuccessful response - see the error message 165 | # @raise [Pusher::HTTPError] Error raised inside http client. The original error is wrapped in error.original_error 166 | # 167 | def get(path, params = {}) 168 | resource(path).get(params) 169 | end 170 | 171 | # GET arbitrary REST API resource using an asynchronous http client. 172 | # All request signing is handled automatically. 173 | # 174 | # When the eventmachine reactor is running, the em-http-request gem is used; 175 | # otherwise an async request is made using httpclient. See README for 176 | # details and examples. 177 | # 178 | # @param path [String] Path excluding /apps/APP_ID 179 | # @param params [Hash] API params (see http://pusher.com/docs/rest_api) 180 | # 181 | # @return Either an EM::DefaultDeferrable or a HTTPClient::Connection 182 | # 183 | def get_async(path, params = {}) 184 | resource(path).get_async(params) 185 | end 186 | 187 | # POST arbitrary REST API resource using a synchronous http client. 188 | # Works identially to get method, but posts params as JSON in post body. 189 | def post(path, params = {}) 190 | resource(path).post(params) 191 | end 192 | 193 | # POST arbitrary REST API resource using an asynchronous http client. 194 | # Works identially to get_async method, but posts params as JSON in post 195 | # body. 196 | def post_async(path, params = {}) 197 | resource(path).post_async(params) 198 | end 199 | 200 | ## HELPER METHODS ## 201 | 202 | # Convenience method for creating a new WebHook instance for validating 203 | # and extracting info from a received WebHook 204 | # 205 | # @param request [Rack::Request] Either a Rack::Request or a Hash containing :key, :signature, :body, and optionally :content_type. 206 | # 207 | def webhook(request) 208 | WebHook.new(request, self) 209 | end 210 | 211 | # Return a convenience channel object by name that delegates operations 212 | # on a channel. No API request is made. 213 | # 214 | # @example 215 | # Pusher['my-channel'] 216 | # @return [Channel] 217 | # @raise [Pusher::Error] if the channel name is invalid. 218 | # Channel names should be less than 200 characters, and 219 | # should not contain anything other than letters, numbers, or the 220 | # characters "_\-=@,.;" 221 | def channel(channel_name) 222 | Channel.new(nil, channel_name, self) 223 | end 224 | 225 | alias :[] :channel 226 | 227 | # Request a list of occupied channels from the API 228 | # 229 | # GET /apps/[id]/channels 230 | # 231 | # @param params [Hash] Hash of parameters for the API - see REST API docs 232 | # 233 | # @return [Hash] See Pusher API docs 234 | # 235 | # @raise [Pusher::Error] Unsuccessful response - see the error message 236 | # @raise [Pusher::HTTPError] Error raised inside http client. The original error is wrapped in error.original_error 237 | # 238 | def channels(params = {}) 239 | get('/channels', params) 240 | end 241 | 242 | # Request info for a specific channel 243 | # 244 | # GET /apps/[id]/channels/[channel_name] 245 | # 246 | # @param channel_name [String] Channel name (max 200 characters) 247 | # @param params [Hash] Hash of parameters for the API - see REST API docs 248 | # 249 | # @return [Hash] See Pusher API docs 250 | # 251 | # @raise [Pusher::Error] Unsuccessful response - see the error message 252 | # @raise [Pusher::HTTPError] Error raised inside http client. The original error is wrapped in error.original_error 253 | # 254 | def channel_info(channel_name, params = {}) 255 | get("/channels/#{channel_name}", params) 256 | end 257 | 258 | # Request info for users of a presence channel 259 | # 260 | # GET /apps/[id]/channels/[channel_name]/users 261 | # 262 | # @param channel_name [String] Channel name (max 200 characters) 263 | # @param params [Hash] Hash of parameters for the API - see REST API docs 264 | # 265 | # @return [Hash] See Pusher API docs 266 | # 267 | # @raise [Pusher::Error] Unsuccessful response - see the error message 268 | # @raise [Pusher::HTTPError] Error raised inside http client. The original error is wrapped in error.original_error 269 | # 270 | def channel_users(channel_name, params = {}) 271 | get("/channels/#{channel_name}/users", params) 272 | end 273 | 274 | # Trigger an event on one or more channels 275 | # 276 | # POST /apps/[app_id]/events 277 | # 278 | # @param channels [String or Array] 1-10 channel names 279 | # @param event_name [String] 280 | # @param data [Object] Event data to be triggered in javascript. 281 | # Objects other than strings will be converted to JSON 282 | # @param params [Hash] Additional parameters to send to api, e.g socket_id 283 | # 284 | # @return [Hash] See Pusher API docs 285 | # 286 | # @raise [Pusher::Error] Unsuccessful response - see the error message 287 | # @raise [Pusher::HTTPError] Error raised inside http client. The original error is wrapped in error.original_error 288 | # 289 | def trigger(channels, event_name, data, params = {}) 290 | post('/events', trigger_params(channels, event_name, data, params)) 291 | end 292 | 293 | # Trigger multiple events at the same time 294 | # 295 | # POST /apps/[app_id]/batch_events 296 | # 297 | # @param events [Array] List of events to publish 298 | # 299 | # @return [Hash] See Pusher API docs 300 | # 301 | # @raise [Pusher::Error] Unsuccessful response - see the error message 302 | # @raise [Pusher::HTTPError] Error raised inside http client. The original error is wrapped in error.original_error 303 | # 304 | def trigger_batch(*events) 305 | post('/batch_events', trigger_batch_params(events.flatten)) 306 | end 307 | 308 | # Trigger an event on one or more channels asynchronously. 309 | # For parameters see #trigger 310 | # 311 | def trigger_async(channels, event_name, data, params = {}) 312 | post_async('/events', trigger_params(channels, event_name, data, params)) 313 | end 314 | 315 | # Trigger multiple events asynchronously. 316 | # For parameters see #trigger_batch 317 | # 318 | def trigger_batch_async(*events) 319 | post_async('/batch_events', trigger_batch_params(events.flatten)) 320 | end 321 | 322 | 323 | # Generate the expected response for an authentication endpoint. 324 | # See http://pusher.com/docs/authenticating_users for details. 325 | # 326 | # @example Private channels 327 | # render :json => Pusher.authenticate('private-my_channel', params[:socket_id]) 328 | # 329 | # @example Presence channels 330 | # render :json => Pusher.authenticate('presence-my_channel', params[:socket_id], { 331 | # :user_id => current_user.id, # => required 332 | # :user_info => { # => optional - for example 333 | # :name => current_user.name, 334 | # :email => current_user.email 335 | # } 336 | # }) 337 | # 338 | # @param socket_id [String] 339 | # @param custom_data [Hash] used for example by private channels 340 | # 341 | # @return [Hash] 342 | # 343 | # @raise [Pusher::Error] if channel_name or socket_id are invalid 344 | # 345 | # @private Custom data is sent to server as JSON-encoded string 346 | # 347 | def authenticate(channel_name, socket_id, custom_data = nil) 348 | channel_instance = channel(channel_name) 349 | r = channel_instance.authenticate(socket_id, custom_data) 350 | if channel_name.match(/^private-encrypted-/) 351 | r[:shared_secret] = Base64.strict_encode64( 352 | channel_instance.shared_secret(encryption_master_key) 353 | ) 354 | end 355 | r 356 | end 357 | 358 | # @private Construct a net/http http client 359 | def sync_http_client 360 | require 'httpclient' 361 | 362 | @client ||= begin 363 | HTTPClient.new(@http_proxy).tap do |c| 364 | c.connect_timeout = @connect_timeout 365 | c.send_timeout = @send_timeout 366 | c.receive_timeout = @receive_timeout 367 | c.keep_alive_timeout = @keep_alive_timeout 368 | end 369 | end 370 | end 371 | 372 | # @private Construct an em-http-request http client 373 | def em_http_client(uri) 374 | begin 375 | unless defined?(EventMachine) && EventMachine.reactor_running? 376 | raise Error, "In order to use async calling you must be running inside an eventmachine loop" 377 | end 378 | require 'em-http' unless defined?(EventMachine::HttpRequest) 379 | 380 | connection_opts = { 381 | connect_timeout: @connect_timeout, 382 | inactivity_timeout: @receive_timeout, 383 | } 384 | 385 | if defined?(@proxy) 386 | proxy_opts = { 387 | host: @proxy[:host], 388 | port: @proxy[:port] 389 | } 390 | if @proxy[:user] 391 | proxy_opts[:authorization] = [@proxy[:user], @proxy[:password]] 392 | end 393 | connection_opts[:proxy] = proxy_opts 394 | end 395 | 396 | EventMachine::HttpRequest.new(uri, connection_opts) 397 | end 398 | end 399 | 400 | private 401 | 402 | def trigger_params(channels, event_name, data, params) 403 | channels = Array(channels).map(&:to_s) 404 | raise Pusher::Error, "Too many channels (#{channels.length}), max 100" if channels.length > 100 405 | 406 | encoded_data = if channels.any?{ |c| c.match(/^private-encrypted-/) } then 407 | raise Pusher::Error, "Cannot trigger to multiple channels if any are encrypted" if channels.length > 1 408 | encrypt(channels[0], encode_data(data)) 409 | else 410 | encode_data(data) 411 | end 412 | 413 | params.merge({ 414 | name: event_name, 415 | channels: channels, 416 | data: encoded_data, 417 | }) 418 | end 419 | 420 | def trigger_batch_params(events) 421 | { 422 | batch: events.map do |event| 423 | event.dup.tap do |e| 424 | e[:data] = if e[:channel].match(/^private-encrypted-/) then 425 | encrypt(e[:channel], encode_data(e[:data])) 426 | else 427 | encode_data(e[:data]) 428 | end 429 | end 430 | end 431 | } 432 | end 433 | 434 | # JSON-encode the data if it's not a string 435 | def encode_data(data) 436 | return data if data.is_a? String 437 | MultiJson.encode(data) 438 | end 439 | 440 | # Encrypts a message with a key derived from the master key and channel 441 | # name 442 | def encrypt(channel_name, encoded_data) 443 | raise ConfigurationError, :encryption_master_key unless @encryption_master_key 444 | 445 | # Only now load rbnacl, so that people that aren't using it don't need to 446 | # install libsodium 447 | require_rbnacl 448 | 449 | secret_box = RbNaCl::SecretBox.new( 450 | channel(channel_name).shared_secret(@encryption_master_key) 451 | ) 452 | 453 | nonce = RbNaCl::Random.random_bytes(secret_box.nonce_bytes) 454 | ciphertext = secret_box.encrypt(nonce, encoded_data) 455 | 456 | MultiJson.encode({ 457 | "nonce" => Base64::strict_encode64(nonce), 458 | "ciphertext" => Base64::strict_encode64(ciphertext), 459 | }) 460 | end 461 | 462 | def configured? 463 | host && scheme && key && secret && app_id 464 | end 465 | 466 | def require_rbnacl 467 | require 'rbnacl' 468 | rescue LoadError => e 469 | $stderr.puts "You don't have rbnacl installed in your application. Please add it to your Gemfile and run bundle install" 470 | raise e 471 | end 472 | end 473 | end 474 | -------------------------------------------------------------------------------- /spec/client_spec.rb: -------------------------------------------------------------------------------- 1 | require 'base64' 2 | 3 | require 'rbnacl' 4 | require 'em-http' 5 | 6 | require 'spec_helper' 7 | 8 | encryption_master_key = RbNaCl::Random.random_bytes(32) 9 | 10 | describe Pusher do 11 | # The behaviour should be the same when using the Client object, or the 12 | # 'global' client delegated through the Pusher class 13 | [lambda { Pusher }, lambda { Pusher::Client.new }].each do |client_gen| 14 | before :each do 15 | @client = client_gen.call 16 | end 17 | 18 | describe 'default configuration' do 19 | it 'should be preconfigured for api host' do 20 | expect(@client.host).to eq('api-mt1.pusher.com') 21 | end 22 | 23 | it 'should be preconfigured for port 443' do 24 | expect(@client.port).to eq(443) 25 | end 26 | 27 | it 'should use standard logger if no other logger if defined' do 28 | Pusher.logger.debug('foo') 29 | expect(Pusher.logger).to be_kind_of(Logger) 30 | end 31 | end 32 | 33 | describe 'logging configuration' do 34 | it "can be configured to use any logger" do 35 | logger = double("ALogger") 36 | expect(logger).to receive(:debug).with('foo') 37 | Pusher.logger = logger 38 | Pusher.logger.debug('foo') 39 | Pusher.logger = nil 40 | end 41 | end 42 | 43 | describe "configuration using url" do 44 | it "should be possible to configure everything by setting the url" do 45 | @client.url = "test://somekey:somesecret@api.staging.pusherapp.com:8080/apps/87" 46 | 47 | expect(@client.scheme).to eq('test') 48 | expect(@client.host).to eq('api.staging.pusherapp.com') 49 | expect(@client.port).to eq(8080) 50 | expect(@client.key).to eq('somekey') 51 | expect(@client.secret).to eq('somesecret') 52 | expect(@client.app_id).to eq('87') 53 | end 54 | 55 | it "should override scheme and port when setting encrypted=true after url" do 56 | @client.url = "http://somekey:somesecret@api.staging.pusherapp.com:8080/apps/87" 57 | @client.encrypted = true 58 | 59 | expect(@client.scheme).to eq('https') 60 | expect(@client.port).to eq(443) 61 | end 62 | 63 | it "should fail on bad urls" do 64 | expect { @client.url = "gopher/somekey:somesecret@://api.staging.pusherapp.co://m:8080\apps\87" }.to raise_error(URI::InvalidURIError) 65 | end 66 | 67 | it "should raise exception if app_id is not configured" do 68 | @client.app_id = nil 69 | expect { 70 | @client.url 71 | }.to raise_error(Pusher::ConfigurationError) 72 | end 73 | 74 | end 75 | 76 | describe 'configuring the cluster' do 77 | it 'should set a new default host' do 78 | @client.cluster = 'eu' 79 | expect(@client.host).to eq('api-eu.pusher.com') 80 | end 81 | 82 | it 'should handle nil gracefully' do 83 | @client.cluster = nil 84 | expect(@client.host).to eq('api-mt1.pusher.com') 85 | end 86 | 87 | it 'should handle empty string' do 88 | @client.cluster = "" 89 | expect(@client.host).to eq('api-mt1.pusher.com') 90 | end 91 | 92 | it 'should be overridden by host if it comes after' do 93 | @client.cluster = 'eu' 94 | @client.host = 'api.staging.pusher.com' 95 | expect(@client.host).to eq('api.staging.pusher.com') 96 | end 97 | 98 | it 'should be overridden by url if it comes after' do 99 | @client.cluster = 'eu' 100 | @client.url = "http://somekey:somesecret@api.staging.pusherapp.com:8080/apps/87" 101 | 102 | expect(@client.host).to eq('api.staging.pusherapp.com') 103 | end 104 | 105 | it 'should override the url configuration if it comes after' do 106 | @client.url = "http://somekey:somesecret@api.staging.pusherapp.com:8080/apps/87" 107 | @client.cluster = 'eu' 108 | expect(@client.host).to eq('api-eu.pusher.com') 109 | end 110 | 111 | it 'should override the host configuration if it comes after' do 112 | @client.host = 'api.staging.pusher.com' 113 | @client.cluster = 'eu' 114 | expect(@client.host).to eq('api-eu.pusher.com') 115 | end 116 | end 117 | 118 | describe 'configuring TLS' do 119 | it 'should set port and scheme if "use_tls" disabled' do 120 | client = Pusher::Client.new({ 121 | :use_tls => false, 122 | }) 123 | expect(client.scheme).to eq('http') 124 | expect(client.port).to eq(80) 125 | end 126 | 127 | it 'should set port and scheme if "encrypted" disabled' do 128 | client = Pusher::Client.new({ 129 | :encrypted => false, 130 | }) 131 | expect(client.scheme).to eq('http') 132 | expect(client.port).to eq(80) 133 | end 134 | 135 | it 'should use TLS port and scheme if "encrypted" or "use_tls" are not set' do 136 | client = Pusher::Client.new 137 | expect(client.scheme).to eq('https') 138 | expect(client.port).to eq(443) 139 | end 140 | 141 | it 'should override port if "use_tls" option set but a different port is specified' do 142 | client = Pusher::Client.new({ 143 | :use_tls => true, 144 | :port => 8443 145 | }) 146 | expect(client.scheme).to eq('https') 147 | expect(client.port).to eq(8443) 148 | end 149 | 150 | it 'should override port if "use_tls" option set but a different port is specified' do 151 | client = Pusher::Client.new({ 152 | :use_tls => false, 153 | :port => 8000 154 | }) 155 | expect(client.scheme).to eq('http') 156 | expect(client.port).to eq(8000) 157 | end 158 | 159 | end 160 | 161 | describe 'configuring a http proxy' do 162 | it "should be possible to configure everything by setting the http_proxy" do 163 | @client.http_proxy = 'http://someuser:somepassword@proxy.host.com:8080' 164 | 165 | expect(@client.proxy).to eq({:scheme => 'http', :host => 'proxy.host.com', :port => 8080, :user => 'someuser', :password => 'somepassword'}) 166 | end 167 | end 168 | 169 | describe 'configuring from env' do 170 | after do 171 | ENV['PUSHER_URL'] = nil 172 | end 173 | 174 | it "works" do 175 | url = "http://somekey:somesecret@api.staging.pusherapp.com:8080/apps/87" 176 | ENV['PUSHER_URL'] = url 177 | 178 | client = Pusher::Client.from_env 179 | expect(client.key).to eq("somekey") 180 | expect(client.secret).to eq("somesecret") 181 | expect(client.app_id).to eq("87") 182 | expect(client.url.to_s).to eq("http://api.staging.pusherapp.com:8080/apps/87") 183 | end 184 | end 185 | 186 | describe 'configuring from url' do 187 | it "works" do 188 | url = "http://somekey:somesecret@api.staging.pusherapp.com:8080/apps/87" 189 | 190 | client = Pusher::Client.from_url(url) 191 | expect(client.key).to eq("somekey") 192 | expect(client.secret).to eq("somesecret") 193 | expect(client.app_id).to eq("87") 194 | expect(client.url.to_s).to eq("http://api.staging.pusherapp.com:8080/apps/87") 195 | end 196 | end 197 | 198 | describe 'can set encryption_master_key_base64' do 199 | it "sets encryption_master_key" do 200 | @client.encryption_master_key_base64 = 201 | Base64.strict_encode64(encryption_master_key) 202 | 203 | expect(@client.encryption_master_key).to eq(encryption_master_key) 204 | end 205 | end 206 | 207 | describe 'when configured' do 208 | before :each do 209 | @client.app_id = '20' 210 | @client.key = '12345678900000001' 211 | @client.secret = '12345678900000001' 212 | @client.encryption_master_key_base64 = 213 | Base64.strict_encode64(encryption_master_key) 214 | end 215 | 216 | describe '#[]' do 217 | before do 218 | @channel = @client['test_channel'] 219 | end 220 | 221 | it 'should return a channel' do 222 | expect(@channel).to be_kind_of(Pusher::Channel) 223 | end 224 | 225 | it "should raise exception if app_id is not configured" do 226 | @client.app_id = nil 227 | expect { 228 | @channel.trigger!('foo', 'bar') 229 | }.to raise_error(Pusher::ConfigurationError) 230 | end 231 | end 232 | 233 | describe '#channels' do 234 | it "should call the correct URL and symbolise response correctly" do 235 | api_path = %r{/apps/20/channels} 236 | stub_request(:get, api_path).to_return({ 237 | :status => 200, 238 | :body => MultiJson.encode('channels' => { 239 | "channel1" => {}, 240 | "channel2" => {} 241 | }) 242 | }) 243 | expect(@client.channels).to eq({ 244 | :channels => { 245 | "channel1" => {}, 246 | "channel2" => {} 247 | } 248 | }) 249 | end 250 | end 251 | 252 | describe '#channel_info' do 253 | it "should call correct URL and symbolise response" do 254 | api_path = %r{/apps/20/channels/mychannel} 255 | stub_request(:get, api_path).to_return({ 256 | :status => 200, 257 | :body => MultiJson.encode({ 258 | 'occupied' => false, 259 | }) 260 | }) 261 | expect(@client.channel_info('mychannel')).to eq({ 262 | :occupied => false, 263 | }) 264 | end 265 | end 266 | 267 | describe '#channel_users' do 268 | it "should call correct URL and symbolise response" do 269 | api_path = %r{/apps/20/channels/mychannel/users} 270 | stub_request(:get, api_path).to_return({ 271 | :status => 200, 272 | :body => MultiJson.encode({ 273 | 'users' => [{ 'id' => 1 }] 274 | }) 275 | }) 276 | expect(@client.channel_users('mychannel')).to eq({ 277 | :users => [{ 'id' => 1}] 278 | }) 279 | end 280 | end 281 | 282 | describe '#authenticate' do 283 | before :each do 284 | @custom_data = {:uid => 123, :info => {:name => 'Foo'}} 285 | end 286 | 287 | it 'should return a hash with signature including custom data and data as json string' do 288 | allow(MultiJson).to receive(:encode).with(@custom_data).and_return 'a json string' 289 | 290 | response = @client.authenticate('test_channel', '1.1', @custom_data) 291 | 292 | expect(response).to eq({ 293 | :auth => "12345678900000001:#{hmac(@client.secret, "1.1:test_channel:a json string")}", 294 | :channel_data => 'a json string' 295 | }) 296 | end 297 | 298 | it 'should include a shared_secret if the private-encrypted channel' do 299 | allow(MultiJson).to receive(:encode).with(@custom_data).and_return 'a json string' 300 | @client.instance_variable_set(:@encryption_master_key, '3W1pfB/Etr+ZIlfMWwZP3gz8jEeCt4s2pe6Vpr+2c3M=') 301 | 302 | response = @client.authenticate('private-encrypted-test_channel', '1.1', @custom_data) 303 | 304 | expect(response).to eq({ 305 | :auth => "12345678900000001:#{hmac(@client.secret, "1.1:private-encrypted-test_channel:a json string")}", 306 | :shared_secret => "o0L3QnIovCeRC8KTD8KBRlmi31dGzHVS2M93uryqDdw=", 307 | :channel_data => 'a json string' 308 | }) 309 | end 310 | 311 | end 312 | 313 | describe '#trigger' do 314 | before :each do 315 | @api_path = %r{/apps/20/events} 316 | stub_request(:post, @api_path).to_return({ 317 | :status => 200, 318 | :body => MultiJson.encode({}) 319 | }) 320 | end 321 | 322 | it "should call correct URL" do 323 | expect(@client.trigger(['mychannel'], 'event', {'some' => 'data'})). 324 | to eq({}) 325 | end 326 | 327 | it "should not allow too many channels" do 328 | expect { 329 | @client.trigger((0..101).map{|i| 'mychannel#{i}'}, 330 | 'event', {'some' => 'data'}, { 331 | :socket_id => "12.34" 332 | })}.to raise_error(Pusher::Error) 333 | end 334 | 335 | it "should pass any parameters in the body of the request" do 336 | @client.trigger(['mychannel', 'c2'], 'event', {'some' => 'data'}, { 337 | :socket_id => "12.34" 338 | }) 339 | expect(WebMock).to have_requested(:post, @api_path).with { |req| 340 | parsed = MultiJson.decode(req.body) 341 | expect(parsed["name"]).to eq('event') 342 | expect(parsed["channels"]).to eq(["mychannel", "c2"]) 343 | expect(parsed["socket_id"]).to eq('12.34') 344 | } 345 | end 346 | 347 | it "should convert non string data to JSON before posting" do 348 | @client.trigger(['mychannel'], 'event', {'some' => 'data'}) 349 | expect(WebMock).to have_requested(:post, @api_path).with { |req| 350 | expect(MultiJson.decode(req.body)["data"]).to eq('{"some":"data"}') 351 | } 352 | end 353 | 354 | it "should accept a single channel as well as an array" do 355 | @client.trigger('mychannel', 'event', {'some' => 'data'}) 356 | expect(WebMock).to have_requested(:post, @api_path).with { |req| 357 | expect(MultiJson.decode(req.body)["channels"]).to eq(['mychannel']) 358 | } 359 | end 360 | 361 | %w[app_id key secret].each do |key| 362 | it "should fail in missing #{key}" do 363 | @client.public_send("#{key}=", nil) 364 | expect { 365 | @client.trigger('mychannel', 'event', {'some' => 'data'}) 366 | }.to raise_error(Pusher::ConfigurationError) 367 | expect(WebMock).not_to have_requested(:post, @api_path).with { |req| 368 | expect(MultiJson.decode(req.body)["channels"]).to eq(['mychannel']) 369 | } 370 | end 371 | end 372 | 373 | it "should fail to publish to encrypted channels when missing key" do 374 | @client.encryption_master_key_base64 = nil 375 | expect { 376 | @client.trigger('private-encrypted-channel', 'event', {'some' => 'data'}) 377 | }.to raise_error(Pusher::ConfigurationError) 378 | expect(WebMock).not_to have_requested(:post, @api_path) 379 | end 380 | 381 | it "should fail to publish to multiple channels if one is encrypted" do 382 | expect { 383 | @client.trigger( 384 | ['private-encrypted-channel', 'some-other-channel'], 385 | 'event', 386 | {'some' => 'data'}, 387 | ) 388 | }.to raise_error(Pusher::Error) 389 | expect(WebMock).not_to have_requested(:post, @api_path) 390 | end 391 | 392 | it "should encrypt publishes to encrypted channels" do 393 | @client.trigger( 394 | 'private-encrypted-channel', 395 | 'event', 396 | {'some' => 'data'}, 397 | ) 398 | 399 | expect(WebMock).to have_requested(:post, @api_path).with { |req| 400 | data = MultiJson.decode(MultiJson.decode(req.body)["data"]) 401 | 402 | key = RbNaCl::Hash.sha256( 403 | 'private-encrypted-channel' + encryption_master_key 404 | ) 405 | 406 | expect(MultiJson.decode(RbNaCl::SecretBox.new(key).decrypt( 407 | Base64.strict_decode64(data["nonce"]), 408 | Base64.strict_decode64(data["ciphertext"]), 409 | ))).to eq({ 'some' => 'data' }) 410 | } 411 | end 412 | end 413 | 414 | describe '#trigger_batch' do 415 | before :each do 416 | @api_path = %r{/apps/20/batch_events} 417 | stub_request(:post, @api_path).to_return({ 418 | :status => 200, 419 | :body => MultiJson.encode({}) 420 | }) 421 | end 422 | 423 | it "should call correct URL" do 424 | expect(@client.trigger_batch(channel: 'mychannel', name: 'event', data: {'some' => 'data'})). 425 | to eq({}) 426 | end 427 | 428 | it "should convert non string data to JSON before posting" do 429 | @client.trigger_batch( 430 | {channel: 'mychannel', name: 'event', data: {'some' => 'data'}}, 431 | {channel: 'mychannel', name: 'event', data: 'already encoded'}, 432 | ) 433 | expect(WebMock).to have_requested(:post, @api_path).with { |req| 434 | parsed = MultiJson.decode(req.body) 435 | expect(parsed).to eq( 436 | "batch" => [ 437 | { "channel" => "mychannel", "name" => "event", "data" => "{\"some\":\"data\"}"}, 438 | { "channel" => "mychannel", "name" => "event", "data" => "already encoded"} 439 | ] 440 | ) 441 | } 442 | end 443 | 444 | it "should fail to publish to encrypted channels when missing key" do 445 | @client.encryption_master_key_base64 = nil 446 | expect { 447 | @client.trigger_batch( 448 | { 449 | channel: 'private-encrypted-channel', 450 | name: 'event', 451 | data: {'some' => 'data'}, 452 | }, 453 | {channel: 'mychannel', name: 'event', data: 'already encoded'}, 454 | ) 455 | }.to raise_error(Pusher::ConfigurationError) 456 | expect(WebMock).not_to have_requested(:post, @api_path) 457 | end 458 | 459 | it "should encrypt publishes to encrypted channels" do 460 | @client.trigger_batch( 461 | { 462 | channel: 'private-encrypted-channel', 463 | name: 'event', 464 | data: {'some' => 'data'}, 465 | }, 466 | {channel: 'mychannel', name: 'event', data: 'already encoded'}, 467 | ) 468 | 469 | expect(WebMock).to have_requested(:post, @api_path).with { |req| 470 | batch = MultiJson.decode(req.body)["batch"] 471 | expect(batch.length).to eq(2) 472 | 473 | expect(batch[0]["channel"]).to eq("private-encrypted-channel") 474 | expect(batch[0]["name"]).to eq("event") 475 | 476 | data = MultiJson.decode(batch[0]["data"]) 477 | 478 | key = RbNaCl::Hash.sha256( 479 | 'private-encrypted-channel' + encryption_master_key 480 | ) 481 | 482 | expect(MultiJson.decode(RbNaCl::SecretBox.new(key).decrypt( 483 | Base64.strict_decode64(data["nonce"]), 484 | Base64.strict_decode64(data["ciphertext"]), 485 | ))).to eq({ 'some' => 'data' }) 486 | 487 | expect(batch[1]["channel"]).to eq("mychannel") 488 | expect(batch[1]["name"]).to eq("event") 489 | expect(batch[1]["data"]).to eq("already encoded") 490 | } 491 | end 492 | end 493 | 494 | describe '#trigger_async' do 495 | before :each do 496 | @api_path = %r{/apps/20/events} 497 | stub_request(:post, @api_path).to_return({ 498 | :status => 200, 499 | :body => MultiJson.encode({}) 500 | }) 501 | end 502 | 503 | it "should call correct URL" do 504 | EM.run { 505 | @client.trigger_async('mychannel', 'event', {'some' => 'data'}).callback { |r| 506 | expect(r).to eq({}) 507 | EM.stop 508 | } 509 | } 510 | end 511 | 512 | it "should pass any parameters in the body of the request" do 513 | EM.run { 514 | @client.trigger_async('mychannel', 'event', {'some' => 'data'}, { 515 | :socket_id => "12.34" 516 | }).callback { 517 | expect(WebMock).to have_requested(:post, @api_path).with { |req| 518 | expect(MultiJson.decode(req.body)["socket_id"]).to eq('12.34') 519 | } 520 | EM.stop 521 | } 522 | } 523 | end 524 | 525 | it "should convert non string data to JSON before posting" do 526 | EM.run { 527 | @client.trigger_async('mychannel', 'event', {'some' => 'data'}).callback { 528 | expect(WebMock).to have_requested(:post, @api_path).with { |req| 529 | expect(MultiJson.decode(req.body)["data"]).to eq('{"some":"data"}') 530 | } 531 | EM.stop 532 | } 533 | } 534 | end 535 | end 536 | 537 | [:get, :post].each do |verb| 538 | describe "##{verb}" do 539 | before :each do 540 | @url_regexp = %r{api-mt1.pusher.com} 541 | stub_request(verb, @url_regexp). 542 | to_return(:status => 200, :body => "{}") 543 | end 544 | 545 | let(:call_api) { @client.send(verb, '/path') } 546 | 547 | it "should use https by default" do 548 | call_api 549 | expect(WebMock).to have_requested(verb, %r{https://api-mt1.pusher.com/apps/20/path}) 550 | end 551 | 552 | it "should use https if configured" do 553 | @client.encrypted = false 554 | call_api 555 | expect(WebMock).to have_requested(verb, %r{http://api-mt1.pusher.com}) 556 | end 557 | 558 | it "should format the respose hash with symbols at first level" do 559 | stub_request(verb, @url_regexp).to_return({ 560 | :status => 200, 561 | :body => MultiJson.encode({'something' => {'a' => 'hash'}}) 562 | }) 563 | expect(call_api).to eq({ 564 | :something => {'a' => 'hash'} 565 | }) 566 | end 567 | 568 | it "should catch all http exceptions and raise a Pusher::HTTPError wrapping the original error" do 569 | stub_request(verb, @url_regexp).to_raise(HTTPClient::TimeoutError) 570 | 571 | error = nil 572 | begin 573 | call_api 574 | rescue => e 575 | error = e 576 | end 577 | 578 | expect(error.class).to eq(Pusher::HTTPError) 579 | expect(error).to be_kind_of(Pusher::Error) 580 | expect(error.message).to eq('Exception from WebMock (HTTPClient::TimeoutError)') 581 | expect(error.original_error.class).to eq(HTTPClient::TimeoutError) 582 | end 583 | 584 | it "should raise Pusher::Error if call returns 400" do 585 | stub_request(verb, @url_regexp).to_return({:status => 400}) 586 | expect { call_api }.to raise_error(Pusher::Error) 587 | end 588 | 589 | it "should raise AuthenticationError if pusher returns 401" do 590 | stub_request(verb, @url_regexp).to_return({:status => 401}) 591 | expect { call_api }.to raise_error(Pusher::AuthenticationError) 592 | end 593 | 594 | it "should raise Pusher::Error if pusher returns 404" do 595 | stub_request(verb, @url_regexp).to_return({:status => 404}) 596 | expect { call_api }.to raise_error(Pusher::Error, '404 Not found (/apps/20/path)') 597 | end 598 | 599 | it "should raise Pusher::Error if pusher returns 407" do 600 | stub_request(verb, @url_regexp).to_return({:status => 407}) 601 | expect { call_api }.to raise_error(Pusher::Error, 'Proxy Authentication Required') 602 | end 603 | 604 | it "should raise Pusher::Error if pusher returns 413" do 605 | stub_request(verb, @url_regexp).to_return({:status => 413}) 606 | expect { call_api }.to raise_error(Pusher::Error, 'Payload Too Large > 10KB') 607 | end 608 | 609 | it "should raise Pusher::Error if pusher returns 500" do 610 | stub_request(verb, @url_regexp).to_return({:status => 500, :body => "some error"}) 611 | expect { call_api }.to raise_error(Pusher::Error, 'Unknown error (status code 500): some error') 612 | end 613 | end 614 | end 615 | 616 | describe "async calling without eventmachine" do 617 | [[:get, :get_async], [:post, :post_async]].each do |verb, method| 618 | describe "##{method}" do 619 | before :each do 620 | @url_regexp = %r{api-mt1.pusher.com} 621 | stub_request(verb, @url_regexp). 622 | to_return(:status => 200, :body => "{}") 623 | end 624 | 625 | let(:call_api) { 626 | @client.send(method, '/path').tap { |c| 627 | # Allow the async thread (inside httpclient) to run 628 | while !c.finished? 629 | sleep 0.01 630 | end 631 | } 632 | } 633 | 634 | it "should use https by default" do 635 | call_api 636 | expect(WebMock).to have_requested(verb, %r{https://api-mt1.pusher.com/apps/20/path}) 637 | end 638 | 639 | it "should use http if configured" do 640 | @client.encrypted = false 641 | call_api 642 | expect(WebMock).to have_requested(verb, %r{http://api-mt1.pusher.com}) 643 | end 644 | 645 | # Note that the raw httpclient connection object is returned and 646 | # the response isn't handled (by handle_response) in the normal way. 647 | it "should return a httpclient connection object" do 648 | connection = call_api 649 | expect(connection.finished?).to be_truthy 650 | response = connection.pop 651 | expect(response.status).to eq(200) 652 | expect(response.body.read).to eq("{}") 653 | end 654 | end 655 | end 656 | end 657 | 658 | describe "async calling with eventmachine" do 659 | [[:get, :get_async], [:post, :post_async]].each do |verb, method| 660 | describe "##{method}" do 661 | before :each do 662 | @url_regexp = %r{api-mt1.pusher.com} 663 | stub_request(verb, @url_regexp). 664 | to_return(:status => 200, :body => "{}") 665 | end 666 | 667 | let(:call_api) { @client.send(method, '/path') } 668 | 669 | it "should use https by default" do 670 | EM.run { 671 | call_api.callback { 672 | expect(WebMock).to have_requested(verb, %r{https://api-mt1.pusher.com/apps/20/path}) 673 | EM.stop 674 | } 675 | } 676 | end 677 | 678 | it "should use http if configured" do 679 | EM.run { 680 | @client.encrypted = false 681 | call_api.callback { 682 | expect(WebMock).to have_requested(verb, %r{http://api-mt1.pusher.com}) 683 | EM.stop 684 | } 685 | } 686 | end 687 | 688 | it "should format the respose hash with symbols at first level" do 689 | EM.run { 690 | stub_request(verb, @url_regexp).to_return({ 691 | :status => 200, 692 | :body => MultiJson.encode({'something' => {'a' => 'hash'}}) 693 | }) 694 | call_api.callback { |response| 695 | expect(response).to eq({ 696 | :something => {'a' => 'hash'} 697 | }) 698 | EM.stop 699 | } 700 | } 701 | end 702 | 703 | it "should errback with Pusher::Error on unsuccessful response" do 704 | EM.run { 705 | stub_request(verb, @url_regexp).to_return({:status => 400}) 706 | 707 | call_api.errback { |e| 708 | expect(e.class).to eq(Pusher::Error) 709 | EM.stop 710 | }.callback { 711 | fail 712 | } 713 | } 714 | end 715 | end 716 | end 717 | end 718 | end 719 | end 720 | 721 | describe 'configuring cluster' do 722 | it 'should allow clients to specify the cluster only with the default host' do 723 | client = Pusher::Client.new({ 724 | :scheme => 'http', 725 | :cluster => 'eu', 726 | :port => 80 727 | }) 728 | expect(client.host).to eq('api-eu.pusher.com') 729 | end 730 | 731 | it 'should always have host override any supplied cluster value' do 732 | client = Pusher::Client.new({ 733 | :scheme => 'http', 734 | :host => 'api.staging.pusherapp.com', 735 | :cluster => 'eu', 736 | :port => 80 737 | }) 738 | expect(client.host).to eq('api.staging.pusherapp.com') 739 | end 740 | end 741 | end 742 | --------------------------------------------------------------------------------