colossus 0.0.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 9ea35d4325a20e16ba03e449605e9afb9fef70cb
4
+ data.tar.gz: 4624e6e7bea9aab4bc858cd81663e7f5f4e28b42
5
+ SHA512:
6
+ metadata.gz: e4d5d0f2cb89a7a033ab391d5d93c865bc743d5a19934227a134442d25508018249229334559ee48fedd57cc0d15a3fe59e781f3ac39618819ffd841682a3ea3
7
+ data.tar.gz: ab60db5e7d318ef1f32934dba3e0076773e2b50fafc01f2e3ba689ccdbc7fd5841dab8cedd7e1bc557d80e64534e1fb23328f945571afbec002368d97e3a4e1a
data/lib/colossus.rb ADDED
@@ -0,0 +1,95 @@
1
+ require 'observer'
2
+ require 'openssl'
3
+
4
+ require 'colossus/configuration'
5
+ require 'colossus/verifier'
6
+ require 'colossus/writer_client'
7
+
8
+ require 'colossus/engines/memory/memory'
9
+ require 'colossus/engines/memory/client_session'
10
+ require 'colossus/engines/memory/client_session_store'
11
+
12
+ require 'colossus/faye/extension'
13
+
14
+ # Top Level Class.
15
+ # The public API of the Gem.
16
+ class Colossus
17
+ include Observable
18
+
19
+ attr_reader :engine, :verifier
20
+
21
+ # Initialize Colossus
22
+ #
23
+ # @param ttl [Integer] the seconds before a user without emitting heartbeat
24
+ # is considered disconnected
25
+ #
26
+ # @param engine [Engine] the engine which implements the needed methods
27
+ # to work with Colossus
28
+ #
29
+ # @return [Colossus]
30
+ def initialize(ttl = Colossus.config.ttl,
31
+ engine = Colossus.config.engine,
32
+ secret = Colossus.config.secret_key,
33
+ writer_token = Colossus.config.writer_token)
34
+ @engine = engine.new(ttl.to_i)
35
+ @engine.add_observer(self)
36
+ @verifier = Colossus::Verifier.new(secret, writer_token)
37
+ end
38
+
39
+ # Set the status of a user on a specificed client. A client could be
40
+ # a Websocket session (if the user has 2 tabs opened) or anything else.
41
+ #
42
+ # @param user_id [#to_s] The unique identifier of a user
43
+ # @param client_id [#to_s] The unique identifier of a client
44
+ # @param status [#to_s] The status of a the user, it can be active,
45
+ # away or disconnected.
46
+ #
47
+ # @return [Boolean] Return true if the status has changed if not false.
48
+ def set(user_id, client_id, status)
49
+ engine.set(user_id.to_s, client_id.to_s, status.to_s)
50
+ end
51
+
52
+ # Get the status of a specified user, it analyzes the status
53
+ # of all the sessions.
54
+ # It returns :
55
+ #
56
+ # - active if one or more clients are active.
57
+ # - away if one or more clients are away.
58
+ # - disconnected.
59
+ #
60
+ # @param user_id [#to_s] The unique identifier of a user
61
+ #
62
+ # @return status [String]
63
+ def get(user_id)
64
+ engine.get(user_id.to_s)
65
+ end
66
+
67
+ # @param user_ids [Array<#to_s>] An array of user ids
68
+ # (see #get)
69
+ def get_multi(*user_ids)
70
+ engine.get_multi(user_ids.map(&:to_s))
71
+ end
72
+
73
+ # @return Hash{String => String} User_id keys, statuses values
74
+ def get_all
75
+ engine.get_all
76
+ end
77
+
78
+ # Reset all the data (useful for specs)
79
+ def reset!
80
+ engine.reset!
81
+ end
82
+
83
+ # Generate a token for the given user_id
84
+ def generate_user_token(user_id)
85
+ verifier.generate_user_token(user_id)
86
+ end
87
+
88
+ # Method used when the engine notify a change
89
+ #
90
+ # @!visibility private
91
+ def update(user_id, status)
92
+ changed
93
+ notify_observers(user_id, status)
94
+ end
95
+ end
@@ -0,0 +1,45 @@
1
+ # encoding: utf-8
2
+ # Main Colossus module
3
+ class Colossus
4
+ # Handles all the configuration
5
+ class Configuration
6
+ attr_accessor :ttl, :seconds_before_ttl_check, :engine,
7
+ :secret_key, :writer_token
8
+
9
+ def initialize
10
+ @ttl = 10
11
+ @seconds_before_ttl_check = 2
12
+ @engine = Colossus::Engine::Memory
13
+ @secret_key = ''
14
+ @writer_token = ''
15
+ end
16
+ end # class Configuration
17
+
18
+ def self.configure
19
+ yield(configuration) if block_given?
20
+ configuration
21
+ end
22
+
23
+ def self.configuration
24
+ @configuration ||= Configuration.new
25
+ end
26
+
27
+ def self.config
28
+ configuration
29
+ end
30
+
31
+ # Help ?
32
+ # rubocop:disable TrivialAccessors
33
+ def self.configuration=(configuration)
34
+ @configuration = configuration
35
+ end
36
+ # rubocop:enable
37
+
38
+ def self.config=(configuration)
39
+ self.configuration = configuration
40
+ end
41
+
42
+ def self.reset_configuration
43
+ @configuration = Configuration.new
44
+ end
45
+ end # Colossus
@@ -0,0 +1,20 @@
1
+ class Colossus
2
+ module Engine
3
+ class Memory
4
+ # Represent the status and the information of a given user.
5
+ class ClientSession
6
+ attr_reader :status, :last_seen
7
+
8
+ def initialize
9
+ @status = 'disconnected'
10
+ @last_seen = Time.now
11
+ end
12
+
13
+ def status=(given_status)
14
+ @last_seen = Time.now
15
+ @status = given_status
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,55 @@
1
+ class Colossus
2
+ module Engine
3
+ class Memory
4
+ # Represents all the different sessions of a user. It can find
5
+ # the global status of the user given all the different status.
6
+ class ClientSessionStore
7
+ attr_reader :last_status
8
+ attr_accessor :sessions
9
+
10
+ def initialize
11
+ @sessions = Hash.new do |hash, key|
12
+ hash[key] = Colossus::Engine::Memory::ClientSession.new
13
+ end
14
+ @last_status = 'disconnected'
15
+ end
16
+
17
+ def status
18
+ sessions.values.reduce('disconnected') do |memo, session|
19
+ case session.status
20
+ when 'active'
21
+ session.status
22
+ when 'away'
23
+ memo == 'active' ? memo : session.status
24
+ else
25
+ memo
26
+ end
27
+ end
28
+ end
29
+
30
+ def last_seen
31
+ sessions.values.reduce(Time.new(0)) do |memo, session|
32
+ session.last_seen > memo ? session.last_seen : memo
33
+ end
34
+ end
35
+
36
+ def status_changed?
37
+ last_status != status
38
+ end
39
+
40
+ def [](session_id)
41
+ sessions[session_id]
42
+ end
43
+
44
+ def []=(session_id, session_status)
45
+ @last_status = status
46
+ sessions[session_id].status = session_status
47
+ end
48
+
49
+ def delete(session_id)
50
+ sessions.delete(session_id)
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,96 @@
1
+ class Colossus
2
+ module Engine
3
+ # The Memory Engine is a non-distributed engine.
4
+ # Based on EventMachine in order to provide the ttl to
5
+ # disconnect clients.
6
+ class Memory
7
+ include Observable
8
+
9
+ attr_reader :client_sessions, :ttl, :mutex
10
+
11
+ def initialize(ttl)
12
+ @client_sessions = Hash.new do |hash, key|
13
+ hash[key] = Colossus::Engine::Memory::ClientSessionStore.new
14
+ end
15
+ @ttl = ttl
16
+ @mutex = Mutex.new
17
+ end
18
+
19
+ def user_changed(user_id, status)
20
+ changed
21
+ notify_observers(user_id, status)
22
+ end
23
+
24
+ def set(user_id, client_id, given_status)
25
+ mutex.synchronize do
26
+ if given_status == 'disconnected'
27
+ client_sessions[user_id].delete(client_id)
28
+ else
29
+ client_sessions[user_id][client_id] = given_status
30
+ end
31
+ end
32
+
33
+ if client_sessions[user_id].status_changed?
34
+ status = client_sessions[user_id].status
35
+ delete(user_id) if status == 'disconnected'
36
+ user_changed(user_id, status)
37
+ return true
38
+ end
39
+
40
+ false
41
+ end
42
+
43
+ def get(user_id)
44
+ client_sessions[user_id]
45
+ end
46
+
47
+ def get_multi(*user_ids)
48
+ user_ids.map { |user_id| get(user_id) }
49
+ end
50
+
51
+ def get_all
52
+ statuses = {}
53
+ client_sessions.each_pair do |user_id, session_store|
54
+ statuses[user_id] = session_store.status
55
+ end
56
+ statuses
57
+ end
58
+
59
+ def delete(user_id)
60
+ mutex.synchronize do
61
+ client_sessions.delete(user_id)
62
+ end
63
+ end
64
+
65
+ def reset!
66
+ mutex.synchronize do
67
+ @client_sessions = Hash.new do |hash, key|
68
+ hash[key] = Colossus::Engine::Memory::ClientSessionStore.new
69
+ end
70
+ end
71
+ end
72
+
73
+ def new_periodic_ttl
74
+ secs_ttl = Colossus.config.seconds_before_ttl_check
75
+ @periodic_ttl = EM::Synchrony.add_periodic_timer(secs_ttl) do
76
+ client_sessions.each_pair do |user_id, session_store|
77
+ sessions_dupped = session_store.sessions.dup
78
+
79
+ session_store.sessions.each_pair do |session_id, session|
80
+ if (session.last_seen + ttl) < Time.now
81
+ sessions_dupped.delete(session_id)
82
+ end
83
+ end
84
+
85
+ mutex.synchronize { session_store.sessions = sessions_dupped }
86
+
87
+ if (session_store.last_seen + ttl) < Time.now
88
+ delete(user_id)
89
+ user_changed(user_id, 'disconnected')
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,162 @@
1
+ class Colossus
2
+ module Faye
3
+ # Faye extension implementing all the presence, authorization,
4
+ # authentification and push logic.
5
+ class Extension
6
+ attr_reader :colossus, :faye, :faye_client
7
+
8
+ VALID_STATUSES = %w(disconnected away active).freeze
9
+
10
+ def initialize(faye, colossus = Colossus.new)
11
+ @colossus = colossus
12
+ @faye = faye
13
+ faye.add_extension(self)
14
+ @faye_client = faye.get_client
15
+ colossus_faye_extension = Colossus::WriterClient::FayeExtension.
16
+ new(colossus.verifier.writer_token)
17
+ faye_client.add_extension(colossus_faye_extension)
18
+ end
19
+
20
+ def incoming(message, _request, callback)
21
+ if !acceptable?(message)
22
+ handle_invalid_token(message)
23
+ message.delete('data')
24
+ message.delete('ext')
25
+ elsif message['ext']['user_token']
26
+ handle_user_action(message)
27
+ message.delete('data')
28
+ message.delete('ext')
29
+ elsif message['ext']['writer_token']
30
+ handle_server_action(message)
31
+ message.delete('ext')
32
+ end
33
+
34
+ callback.call(message)
35
+ end
36
+
37
+ def acceptable?(message)
38
+ message['ext'] &&
39
+ (message['ext']['user_token'] ||
40
+ message['ext']['writer_token'])
41
+ end
42
+
43
+ def handle_user_action(message)
44
+ if message['channel'] == '/meta/subscribe'
45
+ handle_subscribe(message)
46
+ elsif message['channel'].start_with?('/meta/')
47
+ message
48
+ elsif message['channel'].start_with?('/users/')
49
+ handle_set_status(message)
50
+ else
51
+ message['error'] = 'Unknown Action'
52
+ end
53
+
54
+ message
55
+ end
56
+
57
+ def handle_server_action(message)
58
+ token = message['ext'] && message['ext']['writer_token']
59
+
60
+ unless token && colossus.verifier.verify_writer_token(token)
61
+ message['error'] = 'Invalid Token'
62
+ message.delete('data')
63
+ return message
64
+ end
65
+
66
+ if message['channel'].start_with?('/meta/')
67
+ message.delete('data')
68
+ message
69
+ elsif message['channel'].start_with?('/presences/request/')
70
+ handle_presence_request(message)
71
+ elsif message['channel'].start_with?('/presences/response/')
72
+ handle_presence_response(message)
73
+ elsif message['channel'].start_with?('/users/')
74
+ handle_publish(message)
75
+ else
76
+ message['error'] = 'Unknown Action'
77
+ message.delete('data')
78
+ end
79
+
80
+ message
81
+ end
82
+
83
+ def handle_presence_request(message)
84
+ user_ids = message['data'] && message['data']['user_ids']
85
+ presence_id = message['channel'].partition('/presences/request/').last
86
+
87
+ if user_ids && user_ids.is_a?(Array)
88
+ statuses = colossus.get_multi(user_ids)
89
+ message['data'].delete('user_ids')
90
+ faye_client.publish("/presences/response/#{presence_id}", { 'statuses' => Hash[user_ids.zip(statuses)] })
91
+ message
92
+ elsif user_ids == nil
93
+ statuses = colossus.get_all
94
+ faye_client.publish("/presences/response/#{presence_id}", { 'statuses' => statuses })
95
+ message
96
+ else
97
+ message.delete('data')
98
+ message['error'] = 'Invalid user_ids data'
99
+ message
100
+ end
101
+ end
102
+
103
+ def handle_presence_response(message)
104
+ message
105
+ end
106
+
107
+ def handle_publish(message)
108
+ message
109
+ end
110
+
111
+ def handle_invalid_token(message)
112
+ message['error'] = 'Invalid Token'
113
+ message
114
+ end
115
+
116
+ def handle_subscribe(message)
117
+ token = message['ext']['user_token']
118
+ user_id = message['subscription'].partition('/users/').last
119
+
120
+ if invalid_user_channel?(user_id)
121
+ message['error'] = 'The only accepted channel_name is users/:user_id'
122
+ return message
123
+ end
124
+
125
+ unless colossus.verifier.verify_token(token, user_id)
126
+ message['error'] = 'Invalid Token'
127
+ end
128
+
129
+ message
130
+ end
131
+
132
+ def handle_set_status(message)
133
+ token = message['ext']['user_token']
134
+ user_id = message['channel'].partition('/users/').last
135
+ status = message['data'] && message['data']['status']
136
+
137
+ if invalid_user_channel?(user_id)
138
+ message['error'] = 'The only accepted channel_name is users/:user_id'
139
+ return message
140
+ end
141
+
142
+ unless status && VALID_STATUSES.include?(status)
143
+ message['error'] = 'Invalid Status'
144
+ return message
145
+ end
146
+
147
+ unless colossus.verifier.verify_token(token, user_id)
148
+ message['error'] = 'Invalid Token'
149
+ return message
150
+ end
151
+
152
+ colossus.set(user_id, message['clientId'], status)
153
+
154
+ message
155
+ end
156
+
157
+ def invalid_user_channel?(user_id)
158
+ user_id.empty? || user_id.include?('*') || user_id.include?('/')
159
+ end
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,31 @@
1
+ class Colossus
2
+ # Implements the verification logic based on SHA1 in order
3
+ # to avoid timing attacks. (cf Faye doc)
4
+ class Verifier
5
+ attr_reader :sha1, :secret, :writer_token
6
+
7
+ def initialize(secret = Colossus.config.secret_key,
8
+ writer_token = Colossus.config.writer_token)
9
+ @sha1 = OpenSSL::Digest.new('sha1')
10
+ @secret = secret
11
+ @writer_token = writer_token
12
+ end
13
+
14
+ def verify_token(token_given, user_id)
15
+ expected_token = generate_user_token(user_id)
16
+ expected_hash = Digest::SHA1.hexdigest(expected_token)
17
+ actual_hash = Digest::SHA1.hexdigest(token_given)
18
+ expected_hash == actual_hash
19
+ end
20
+
21
+ def verify_writer_token(token_given)
22
+ expected_hash = Digest::SHA1.hexdigest(writer_token)
23
+ actual_hash = Digest::SHA1.hexdigest(token_given)
24
+ expected_hash == actual_hash
25
+ end
26
+
27
+ def generate_user_token(user_id)
28
+ OpenSSL::HMAC.hexdigest(sha1, secret, user_id)
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,4 @@
1
+ # The Version
2
+ class Colossus
3
+ VERSION = '0.0.1'
4
+ end
@@ -0,0 +1,62 @@
1
+ class Colossus
2
+ class WriterClient
3
+ attr_reader :url, :writer_token, :time_out
4
+
5
+ def initialize(url, writer_token, time_out = 2)
6
+ @url = url
7
+ @writer_token = writer_token
8
+ @time_out = time_out
9
+ end
10
+
11
+ def get_presences(optional_user_ids = nil)
12
+ user_ids = Array(optional_user_ids) if optional_user_ids
13
+ unique_token = generate_unique_token
14
+ EM.synchrony do
15
+ EM::Synchrony.add_timer(time_out) { raise "Presence request timed out" }
16
+ EM::Synchrony.sync(faye_client.subscribe("/presences/response/#{unique_token}") { |message| return message['statuses'] })
17
+ EM::Synchrony.sync(faye_client.publish("/presences/request/#{unique_token}", user_ids))
18
+ end
19
+ end
20
+
21
+ def push_message(user_ids, message)
22
+ user_ids = Array(user_ids)
23
+ EM.synchrony do
24
+ user_ids.each do |user_id|
25
+ EM::Synchrony.sync(faye_client.publish("/users/#{user_id}", message))
26
+ end
27
+ EM.stop
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def faye_client
34
+ faye_client = ::Faye::Client.new(url)
35
+ faye_client.add_extension(FayeExtension.new(writer_token))
36
+ faye_client
37
+ end
38
+
39
+ def generate_unique_token
40
+ SecureRandom.hex(8)
41
+ end
42
+
43
+ class FayeExtension
44
+ attr_reader :token
45
+
46
+ def initialize(token)
47
+ @token = token
48
+ end
49
+
50
+ def incoming(message, callback)
51
+ callback.call(message)
52
+ end
53
+
54
+ def outgoing(message, callback)
55
+ message['ext'] ||= {}
56
+ message['ext']['writer_token'] = 'WRITER_TOKEN'
57
+
58
+ callback.call(message)
59
+ end
60
+ end
61
+ end
62
+ end
metadata ADDED
@@ -0,0 +1,81 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: colossus
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - antoinelyset
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2014-11-27 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: faye
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: em-synchrony
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.0'
41
+ description: Colossus is a Push and Presence pure Ruby server. It uses Faye internally.
42
+ email:
43
+ - antoinelyset+github@gmail.com
44
+ executables: []
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - lib/colossus.rb
49
+ - lib/colossus/configuration.rb
50
+ - lib/colossus/engines/memory/client_session.rb
51
+ - lib/colossus/engines/memory/client_session_store.rb
52
+ - lib/colossus/engines/memory/memory.rb
53
+ - lib/colossus/faye/extension.rb
54
+ - lib/colossus/verifier.rb
55
+ - lib/colossus/version.rb
56
+ - lib/colossus/writer_client.rb
57
+ homepage: https://github.com/antoinelyset/colossus
58
+ licenses: []
59
+ metadata: {}
60
+ post_install_message:
61
+ rdoc_options: []
62
+ require_paths:
63
+ - lib
64
+ required_ruby_version: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ required_rubygems_version: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - ">="
72
+ - !ruby/object:Gem::Version
73
+ version: '0'
74
+ requirements: []
75
+ rubyforge_project:
76
+ rubygems_version: 2.2.2
77
+ signing_key:
78
+ specification_version: 4
79
+ summary: Colossus, Web Push & Presence made easy.
80
+ test_files: []
81
+ has_rdoc: