oceanex-slanger 0.7.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,56 @@
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 ||= begin
50
+ request_body ||= request.body.read.tap { |s| s.force_encoding("utf-8") }
51
+ RequestValidation.new(request_body, params, env["PATH_INFO"])
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,104 @@
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
+ # Send a client event to the EventMachine channel.
75
+ # Only events to channels requiring authentication (private or presence)
76
+ # are accepted. Public channels only get events from the API.
77
+ def send_client_message(message)
78
+ Slanger::Redis.publish(message["channel"], Oj.dump(message, mode: :compat)) if authenticated?
79
+ end
80
+
81
+ # Send an event received from Redis to the EventMachine channel
82
+ # which will send it to subscribed clients.
83
+ def dispatch(message, channel)
84
+ push(Oj.dump(message, mode: :compat)) unless channel =~ /\Aslanger:/
85
+
86
+ perform_client_webhook!(message)
87
+ end
88
+
89
+ def authenticated?
90
+ channel_id =~ /\Aprivate-/ || channel_id =~ /\Apresence-/
91
+ end
92
+
93
+ private
94
+
95
+ def perform_client_webhook!(message)
96
+ if (message["event"].start_with?("client-"))
97
+ event = message.merge({ "name" => "client_event" })
98
+ event["data"] = Oj.dump(event["data"])
99
+
100
+ Slanger::Webhook.post(event)
101
+ end
102
+ end
103
+ end
104
+ 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.strict_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,117 @@
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
+ attr_accessor :connection
14
+ delegate :error, :send_payload, to: :connection
15
+
16
+ def initialize(socket, handshake)
17
+ @socket = socket
18
+ @handshake = handshake
19
+ @connection = Connection.new(@socket)
20
+ @subscriptions = {}
21
+ authenticate
22
+ end
23
+
24
+ # Dispatches message handling to method with same name as
25
+ # the event name
26
+ def onmessage(msg)
27
+ msg = Oj.strict_load(msg)
28
+
29
+ msg["data"] = Oj.strict_load(msg["data"]) if msg["data"].is_a? String
30
+
31
+ event = msg["event"].gsub(/\Apusher:/, "pusher_")
32
+
33
+ if event =~ /\Aclient-/
34
+ msg["socket_id"] = connection.socket_id
35
+ Channel.send_client_message msg
36
+ elsif respond_to? event, true
37
+ send event, msg
38
+ end
39
+ rescue JSON::ParserError
40
+ error({ code: 5001, message: "Invalid JSON" })
41
+ rescue Exception => e
42
+ error({ code: 500, message: "#{e.message}\n #{e.backtrace.join "\n"}" })
43
+ end
44
+
45
+ def onclose
46
+ subscriptions = @subscriptions.select { |k, v| k && v }
47
+
48
+ subscriptions.each_key do |channel_id|
49
+ subscription_id = subscriptions[channel_id]
50
+ Channel.unsubscribe channel_id, subscription_id
51
+ end
52
+ end
53
+
54
+ def authenticate
55
+ if !valid_app_key? app_key
56
+ error({ code: 4001, message: "Could not find app by key #{app_key}" })
57
+ @socket.close_websocket
58
+ elsif !valid_protocol_version?
59
+ error({ code: 4007, message: "Unsupported protocol version" })
60
+ @socket.close_websocket
61
+ else
62
+ return connection.establish
63
+ end
64
+ end
65
+
66
+ def valid_protocol_version?
67
+ protocol_version.between?(3, 7)
68
+ end
69
+
70
+ def pusher_ping(msg)
71
+ send_payload nil, "pusher:pong"
72
+ end
73
+
74
+ def pusher_pong(msg); end
75
+
76
+ def pusher_subscribe(msg)
77
+ channel_id = msg["data"]["channel"]
78
+ klass = subscription_klass channel_id
79
+
80
+ if @subscriptions[channel_id]
81
+ error({ code: nil, message: "Existing subscription to #{channel_id}" })
82
+ else
83
+ @subscriptions[channel_id] = klass.new(connection.socket, connection.socket_id, msg).subscribe
84
+ end
85
+ end
86
+
87
+ def pusher_unsubscribe(msg)
88
+ channel_id = msg["data"]["channel"]
89
+ subscription_id = @subscriptions.delete(channel_id)
90
+
91
+ Channel.unsubscribe channel_id, subscription_id
92
+ end
93
+
94
+ private
95
+
96
+ def app_key
97
+ @handshake.path.split(/\W/)[2]
98
+ end
99
+
100
+ def protocol_version
101
+ @query_string ||= Rack::Utils.parse_nested_query(@handshake.query_string)
102
+ @query_string["protocol"].to_i || -1
103
+ end
104
+
105
+ def valid_app_key?(app_key)
106
+ Slanger::Config.app_key == app_key
107
+ end
108
+
109
+ def subscription_klass(channel_id)
110
+ klass = channel_id.match(/\A(private|presence)-/) do |match|
111
+ Slanger.const_get "#{match[1]}_subscription".classify
112
+ end
113
+
114
+ klass || Slanger::Subscription
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,8 @@
1
+ module Slanger
2
+ module Logger
3
+ def log(msg)
4
+ end
5
+
6
+ extend self
7
+ end
8
+ 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.strict_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