slangerq 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,57 @@
1
+ # encoding: utf-8
2
+ require 'sinatra/base'
3
+ require 'signature'
4
+ require 'json'
5
+ require 'active_support/core_ext/hash'
6
+ require 'eventmachine'
7
+ require 'em-hiredis'
8
+ require 'rack'
9
+ require 'fiber'
10
+ require 'rack/fiber_pool'
11
+ require 'oj'
12
+
13
+ module Slanger
14
+ module Api
15
+ class Server < Sinatra::Base
16
+ use Rack::FiberPool
17
+ set :raise_errors, lambda { false }
18
+ set :show_exceptions, false
19
+
20
+ error(Signature::AuthenticationError) { |e| halt 401, "401 UNAUTHORIZED" }
21
+ error(Slanger::Api::InvalidRequest) { |c| halt 400, "400 Bad Request" }
22
+
23
+ before do
24
+ valid_request
25
+ end
26
+
27
+ post '/apps/:app_id/events' do
28
+ socket_id = valid_request.socket_id
29
+ body = valid_request.body
30
+
31
+ event = Slanger::Api::Event.new(body["name"], body["data"], socket_id)
32
+ EventPublisher.publish(valid_request.channels, event)
33
+
34
+ status 202
35
+ return Oj.dump({}, mode: :compat)
36
+ end
37
+
38
+ post '/apps/:app_id/channels/:channel_id/events' do
39
+ params = valid_request.params
40
+
41
+ event = Event.new(params["name"], valid_request.body, valid_request.socket_id)
42
+ EventPublisher.publish(valid_request.channels, event)
43
+
44
+ status 202
45
+ return Oj.dump({}, mode: :compat)
46
+ end
47
+
48
+ def valid_request
49
+ @valid_request ||=
50
+ begin
51
+ request_body ||= request.body.read.tap{|s| s.force_encoding("utf-8")}
52
+ RequestValidation.new(request_body, params, env["PATH_INFO"])
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,106 @@
1
+ # Channel class.
2
+ #
3
+ # Uses an EventMachine channel to let clients interact with the
4
+ # Pusher channel. Relay events received from Redis into the
5
+ # EM channel.
6
+ #
7
+
8
+ require 'eventmachine'
9
+ require 'forwardable'
10
+ require 'oj'
11
+
12
+ module Slanger
13
+ class Channel
14
+ extend Forwardable
15
+
16
+ def_delegators :channel, :push
17
+ attr_reader :channel_id
18
+
19
+ class << self
20
+ def from channel_id
21
+ klass = channel_id[/\Apresence-/] ? PresenceChannel : Channel
22
+
23
+ klass.lookup(channel_id) || klass.create(channel_id: channel_id)
24
+ end
25
+
26
+ def lookup(channel_id)
27
+ all.detect { |o| o.channel_id == channel_id }
28
+ end
29
+
30
+ def create(params = {})
31
+ new(params).tap { |r| all << r }
32
+ end
33
+
34
+ def all
35
+ @all ||= []
36
+ end
37
+
38
+ def unsubscribe channel_id, subscription_id
39
+ from(channel_id).try :unsubscribe, subscription_id
40
+ end
41
+
42
+ def send_client_message msg
43
+ from(msg['channel']).try :send_client_message, msg
44
+ end
45
+ end
46
+
47
+ def initialize(attrs)
48
+ @channel_id = attrs.with_indifferent_access[:channel_id]
49
+ Slanger::Redis.subscribe channel_id
50
+ end
51
+
52
+ def channel
53
+ @channel ||= EM::Channel.new
54
+ end
55
+
56
+ def subscribe *a, &blk
57
+ Slanger::Redis.hincrby('channel_subscriber_count', channel_id, 1).
58
+ callback do |value|
59
+ Slanger::Webhook.post name: 'channel_occupied', channel: channel_id if value == 1
60
+ end
61
+
62
+ channel.subscribe *a, &blk
63
+ end
64
+
65
+ def unsubscribe *a, &blk
66
+ Slanger::Redis.hincrby('channel_subscriber_count', channel_id, -1).
67
+ callback do |value|
68
+ Slanger::Webhook.post name: 'channel_vacated', channel: channel_id if value == 0
69
+ end
70
+
71
+ channel.unsubscribe *a, &blk
72
+ end
73
+
74
+
75
+ # Send a client event to the EventMachine channel.
76
+ # Only events to channels requiring authentication (private or presence)
77
+ # are accepted. Public channels only get events from the API.
78
+ def send_client_message(message)
79
+ Slanger::Redis.publish(message['channel'], Oj.dump(message, mode: :compat)) if authenticated?
80
+ end
81
+
82
+ # Send an event received from Redis to the EventMachine channel
83
+ # which will send it to subscribed clients.
84
+ def dispatch(message, channel)
85
+ push(Oj.dump(message, mode: :compat)) unless channel =~ /\Aslanger:/
86
+
87
+ perform_client_webhook!(message)
88
+ end
89
+
90
+ def authenticated?
91
+ channel_id =~ /\Aprivate-/ || channel_id =~ /\Apresence-/
92
+ end
93
+
94
+ private
95
+
96
+ def perform_client_webhook!(message)
97
+ if (message['event'].start_with?('client-')) then
98
+
99
+ event = message.merge({'name' => 'client_event'})
100
+ event['data'] = Oj.dump(event['data'])
101
+
102
+ Slanger::Webhook.post(event)
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,27 @@
1
+ # Config singleton holding the configuration.
2
+
3
+ module Slanger
4
+ module Config
5
+ def load(opts={})
6
+ options.update opts
7
+ end
8
+
9
+ def [](key)
10
+ options[key]
11
+ end
12
+
13
+ def options
14
+ @options ||= {
15
+ api_host: '0.0.0.0', api_port: '4567', websocket_host: '0.0.0.0',
16
+ websocket_port: '8080', debug: false, redis_address: 'redis://0.0.0.0:6379/0',
17
+ socket_handler: Slanger::Handler, require: [], activity_timeout: 120
18
+ }
19
+ end
20
+
21
+ def method_missing(meth, *args, &blk)
22
+ options[meth]
23
+ end
24
+
25
+ extend self
26
+ end
27
+ end
@@ -0,0 +1,46 @@
1
+ require 'oj'
2
+
3
+ module Slanger
4
+ class Connection
5
+ attr_accessor :socket, :socket_id
6
+
7
+ def initialize socket, socket_id=nil
8
+ @socket, @socket_id = socket, socket_id
9
+ end
10
+
11
+ def send_message m
12
+ msg = Oj.load m
13
+ s = msg.delete 'socket_id'
14
+ socket.send Oj.dump(msg, mode: :compat) unless s == socket_id
15
+ end
16
+
17
+ def send_payload *args
18
+ socket.send format(*args)
19
+ end
20
+
21
+ def error e
22
+ begin
23
+ send_payload nil, 'pusher:error', e
24
+ rescue EventMachine::WebSocket::WebSocketError
25
+ # Raised if connecection already closed. Only seen with Thor load testing tool
26
+ end
27
+ end
28
+
29
+ def establish
30
+ @socket_id = "%d.%d" % [Process.pid, SecureRandom.random_number(10 ** 6)]
31
+
32
+ send_payload nil, 'pusher:connection_established', {
33
+ socket_id: @socket_id,
34
+ activity_timeout: Slanger::Config.activity_timeout
35
+ }
36
+ end
37
+
38
+ private
39
+
40
+ def format(channel_id, event_name, payload = {})
41
+ body = { event: event_name, data: Oj.dump(payload, mode: :compat) }
42
+ body[:channel] = channel_id if channel_id
43
+ Oj.dump(body, mode: :compat)
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,121 @@
1
+ # Handler class.
2
+ # Handles a client connected via a websocket connection.
3
+
4
+ require 'active_support/core_ext/hash'
5
+ require 'securerandom'
6
+ require 'signature'
7
+ require 'fiber'
8
+ require 'rack'
9
+ require 'oj'
10
+
11
+ module Slanger
12
+ class Handler
13
+
14
+ attr_accessor :connection
15
+ delegate :error, :send_payload, to: :connection
16
+
17
+ def initialize(socket, handshake)
18
+ @socket = socket
19
+ @handshake = handshake
20
+ @connection = Connection.new(@socket)
21
+ @subscriptions = {}
22
+ authenticate
23
+ end
24
+
25
+ # Dispatches message handling to method with same name as
26
+ # the event name
27
+ def onmessage(msg)
28
+ msg = Oj.load(msg)
29
+
30
+ msg['data'] = Oj.load(msg['data']) if msg['data'].is_a? String
31
+
32
+ event = msg['event'].gsub(/\Apusher:/, 'pusher_')
33
+
34
+ if event =~ /\Aclient-/
35
+ msg['socket_id'] = connection.socket_id
36
+ Channel.send_client_message msg
37
+ elsif respond_to? event, true
38
+ send event, msg
39
+ end
40
+
41
+ rescue JSON::ParserError
42
+ error({ code: 5001, message: "Invalid JSON" })
43
+ rescue Exception => e
44
+ error({ code: 500, message: "#{e.message}\n #{e.backtrace.join "\n"}" })
45
+ end
46
+
47
+ def onclose
48
+
49
+ subscriptions = @subscriptions.select { |k,v| k && v }
50
+
51
+ subscriptions.each_key do |channel_id|
52
+ subscription_id = subscriptions[channel_id]
53
+ Channel.unsubscribe channel_id, subscription_id
54
+ end
55
+
56
+ end
57
+
58
+ def authenticate
59
+ if !valid_app_key? app_key
60
+ error({ code: 4001, message: "Could not find app by key #{app_key}" })
61
+ @socket.close_websocket
62
+ elsif !valid_protocol_version?
63
+ error({ code: 4007, message: "Unsupported protocol version" })
64
+ @socket.close_websocket
65
+ else
66
+ return connection.establish
67
+ end
68
+ end
69
+
70
+ def valid_protocol_version?
71
+ protocol_version.between?(3, 7)
72
+ end
73
+
74
+ def pusher_ping(msg)
75
+ send_payload nil, 'pusher:pong'
76
+ end
77
+
78
+ def pusher_pong msg; end
79
+
80
+ def pusher_subscribe(msg)
81
+ channel_id = msg['data']['channel']
82
+ klass = subscription_klass channel_id
83
+
84
+ if @subscriptions[channel_id]
85
+ error({ code: nil, message: "Existing subscription to #{channel_id}" })
86
+ else
87
+ @subscriptions[channel_id] = klass.new(connection.socket, connection.socket_id, msg).subscribe
88
+ end
89
+ end
90
+
91
+ def pusher_unsubscribe(msg)
92
+ channel_id = msg['data']['channel']
93
+ subscription_id = @subscriptions.delete(channel_id)
94
+
95
+ Channel.unsubscribe channel_id, subscription_id
96
+ end
97
+
98
+ private
99
+
100
+ def app_key
101
+ @handshake.path.split(/\W/)[2]
102
+ end
103
+
104
+ def protocol_version
105
+ @query_string ||= Rack::Utils.parse_nested_query(@handshake.query_string)
106
+ @query_string["protocol"].to_i || -1
107
+ end
108
+
109
+ def valid_app_key? app_key
110
+ Slanger::Config.app_key == app_key
111
+ end
112
+
113
+ def subscription_klass channel_id
114
+ klass = channel_id.match(/\A(private|presence)-/) do |match|
115
+ Slanger.const_get "#{match[1]}_subscription".classify
116
+ end
117
+
118
+ klass || Slanger::Subscription
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,7 @@
1
+ module Slanger
2
+ module Logger
3
+ def log(msg)
4
+ end
5
+ extend self
6
+ end
7
+ end
@@ -0,0 +1,140 @@
1
+ # PresenceChannel class.
2
+ #
3
+ # Uses an EventMachine channel to let handlers interact with the
4
+ # Pusher channel. Relay events received from Redis into the
5
+ # EM channel. Keeps data on the subscribers to send it to clients.
6
+ #
7
+
8
+ require 'eventmachine'
9
+ require 'forwardable'
10
+ require 'fiber'
11
+ require 'oj'
12
+
13
+ module Slanger
14
+ class PresenceChannel < Channel
15
+ def_delegators :channel, :push
16
+
17
+ # Send an event received from Redis to the EventMachine channel
18
+ def dispatch(message, channel)
19
+ if channel =~ /\Aslanger:/
20
+ # Messages received from the Redis channel slanger:* carry info on
21
+ # subscriptions. Update our subscribers accordingly.
22
+ update_subscribers message
23
+ else
24
+ push Oj.dump(message, mode: :compat)
25
+ end
26
+ end
27
+
28
+ def initialize(attrs)
29
+ super
30
+ # Also subscribe the slanger daemon to a Redis channel used for events concerning subscriptions.
31
+ Slanger::Redis.subscribe 'slanger:connection_notification'
32
+ end
33
+
34
+ def subscribe(msg, callback, &blk)
35
+ channel_data = Oj.load msg['data']['channel_data']
36
+ public_subscription_id = SecureRandom.uuid
37
+
38
+ # Send event about the new subscription to the Redis slanger:connection_notification Channel.
39
+ publisher = publish_connection_notification subscription_id: public_subscription_id, online: true,
40
+ channel_data: channel_data, channel: channel_id
41
+
42
+ # Associate the subscription data to the public id in Redis.
43
+ roster_add public_subscription_id, channel_data
44
+
45
+ # fuuuuuuuuuccccccck!
46
+ publisher.callback do
47
+ EM.next_tick do
48
+ # The Subscription event has been sent to Redis successfully.
49
+ # Call the provided callback.
50
+ callback.call
51
+ # Add the subscription to our table.
52
+ internal_subscription_table[public_subscription_id] = channel.subscribe &blk
53
+ end
54
+ end
55
+
56
+ public_subscription_id
57
+ end
58
+
59
+ def ids
60
+ subscriptions.map { |_,v| v['user_id'] }
61
+ end
62
+
63
+ def subscribers
64
+ Hash[subscriptions.map { |_,v| [v['user_id'], v['user_info']] }]
65
+ end
66
+
67
+ def unsubscribe(public_subscription_id)
68
+ # Unsubcribe from EM::Channel
69
+ channel.unsubscribe(internal_subscription_table.delete(public_subscription_id)) # if internal_subscription_table[public_subscription_id]
70
+ # Remove subscription data from Redis
71
+ roster_remove public_subscription_id
72
+ # Notify all instances
73
+ publish_connection_notification subscription_id: public_subscription_id, online: false, channel: channel_id
74
+ end
75
+
76
+ private
77
+
78
+ def get_roster
79
+ # Read subscription infos from Redis.
80
+ Fiber.new do
81
+ f = Fiber.current
82
+ Slanger::Redis.hgetall(channel_id).
83
+ callback { |res| f.resume res }
84
+ Fiber.yield
85
+ end.resume
86
+ end
87
+
88
+ def roster_add(key, value)
89
+ # Add subscription info to Redis.
90
+ Slanger::Redis.hset(channel_id, key, value)
91
+ end
92
+
93
+ def roster_remove(key)
94
+ # Remove subscription info from Redis.
95
+ Slanger::Redis.hdel(channel_id, key)
96
+ end
97
+
98
+ def publish_connection_notification(payload, retry_count=0)
99
+ # Send a subscription notification to the global slanger:connection_notification
100
+ # channel.
101
+ Slanger::Redis.publish('slanger:connection_notification', Oj.dump(payload, mode: :compat)).
102
+ tap { |r| r.errback { publish_connection_notification payload, retry_count.succ unless retry_count == 5 } }
103
+ end
104
+
105
+ # This is the state of the presence channel across the system. kept in sync
106
+ # with redis pubsub
107
+ def subscriptions
108
+ @subscriptions ||= get_roster || {}
109
+ end
110
+
111
+ # This is used map public subscription ids to em channel subscription ids.
112
+ # em channel subscription ids are incremented integers, so they cannot
113
+ # be used as keys in distributed system because they will not be unique
114
+ def internal_subscription_table
115
+ @internal_subscription_table ||= {}
116
+ end
117
+
118
+ def update_subscribers(message)
119
+ if message['online']
120
+ # Don't tell the channel subscriptions a new member has been added if the subscriber data
121
+ # is already present in the subscriptions hash, i.e. multiple browser windows open.
122
+ unless subscriptions.has_value? message['channel_data']
123
+ push payload('pusher_internal:member_added', message['channel_data'])
124
+ end
125
+ subscriptions[message['subscription_id']] = message['channel_data']
126
+ else
127
+ # Don't tell the channel subscriptions the member has been removed if the subscriber data
128
+ # still remains in the subscriptions hash, i.e. multiple browser windows open.
129
+ subscriber = subscriptions.delete message['subscription_id']
130
+ if subscriber && !subscriptions.has_value?(subscriber)
131
+ push payload('pusher_internal:member_removed', { user_id: subscriber['user_id'] })
132
+ end
133
+ end
134
+ end
135
+
136
+ def payload(event_name, payload = {})
137
+ Oj.dump({ channel: channel_id, event: event_name, data: payload }, mode: :compat)
138
+ end
139
+ end
140
+ end