slangerq 0.6.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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