slangerq 0.6.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/README.md +224 -0
- data/bin/slanger +137 -0
- data/lib/slanger/api.rb +5 -0
- data/lib/slanger/api/event.rb +17 -0
- data/lib/slanger/api/event_publisher.rb +24 -0
- data/lib/slanger/api/request_validation.rb +105 -0
- data/lib/slanger/api/server.rb +57 -0
- data/lib/slanger/channel.rb +106 -0
- data/lib/slanger/config.rb +27 -0
- data/lib/slanger/connection.rb +46 -0
- data/lib/slanger/handler.rb +121 -0
- data/lib/slanger/logger.rb +7 -0
- data/lib/slanger/presence_channel.rb +140 -0
- data/lib/slanger/presence_subscription.rb +33 -0
- data/lib/slanger/private_subscription.rb +9 -0
- data/lib/slanger/redis.rb +41 -0
- data/lib/slanger/service.rb +20 -0
- data/lib/slanger/subscription.rb +53 -0
- data/lib/slanger/version.rb +3 -0
- data/lib/slanger/web_socket_server.rb +37 -0
- data/lib/slanger/webhook.rb +31 -0
- data/slanger.rb +23 -0
- data/spec/have_attributes.rb +64 -0
- data/spec/integration/channel_spec.rb +114 -0
- data/spec/integration/integration_spec.rb +68 -0
- data/spec/integration/presence_channel_spec.rb +157 -0
- data/spec/integration/private_channel_spec.rb +79 -0
- data/spec/integration/replaced_handler_spec.rb +23 -0
- data/spec/integration/ssl_spec.rb +18 -0
- data/spec/server.crt +12 -0
- data/spec/server.key +15 -0
- data/spec/slanger_helper_methods.rb +109 -0
- data/spec/spec_helper.rb +43 -0
- data/spec/unit/channel_spec.rb +105 -0
- data/spec/unit/request_validation_spec.rb +72 -0
- data/spec/unit/webhook_spec.rb +43 -0
- metadata +392 -0
@@ -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,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
|