colossus 0.0.1

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