bows 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.
Files changed (33) hide show
  1. checksums.yaml +7 -0
  2. data/lib/ribbon/intercom.rb +42 -0
  3. data/lib/ribbon/intercom/client.rb +61 -0
  4. data/lib/ribbon/intercom/client/mock_sdk.rb +13 -0
  5. data/lib/ribbon/intercom/client/sdk.rb +99 -0
  6. data/lib/ribbon/intercom/client/sdk/adapters.rb +10 -0
  7. data/lib/ribbon/intercom/client/sdk/adapters/adapter.rb +77 -0
  8. data/lib/ribbon/intercom/client/sdk/adapters/adapter/response.rb +13 -0
  9. data/lib/ribbon/intercom/client/sdk/adapters/http_adapter.rb +32 -0
  10. data/lib/ribbon/intercom/client/sdk/adapters/http_adapter/connection.rb +34 -0
  11. data/lib/ribbon/intercom/client/sdk/adapters/local_adapter.rb +55 -0
  12. data/lib/ribbon/intercom/client/sdk/adapters/mock_adapter.rb +40 -0
  13. data/lib/ribbon/intercom/errors.rb +66 -0
  14. data/lib/ribbon/intercom/package.rb +121 -0
  15. data/lib/ribbon/intercom/packageable.rb +6 -0
  16. data/lib/ribbon/intercom/packageable/mixin.rb +29 -0
  17. data/lib/ribbon/intercom/packet.rb +52 -0
  18. data/lib/ribbon/intercom/packet/method_queue.rb +28 -0
  19. data/lib/ribbon/intercom/railtie.rb +14 -0
  20. data/lib/ribbon/intercom/service.rb +273 -0
  21. data/lib/ribbon/intercom/service/channel.rb +203 -0
  22. data/lib/ribbon/intercom/service/channel/stores.rb +9 -0
  23. data/lib/ribbon/intercom/service/channel/stores/mock_store.rb +40 -0
  24. data/lib/ribbon/intercom/service/channel/stores/redis_store.rb +196 -0
  25. data/lib/ribbon/intercom/service/channel/stores/store.rb +31 -0
  26. data/lib/ribbon/intercom/utils.rb +72 -0
  27. data/lib/ribbon/intercom/utils/method_chain.rb +38 -0
  28. data/lib/ribbon/intercom/utils/mixins.rb +5 -0
  29. data/lib/ribbon/intercom/utils/mixins/mock_safe.rb +26 -0
  30. data/lib/ribbon/intercom/utils/signer.rb +71 -0
  31. data/lib/ribbon/intercom/version.rb +5 -0
  32. data/lib/tasks/intercom.rake +24 -0
  33. metadata +215 -0
@@ -0,0 +1,40 @@
1
+ module Ribbon::Intercom
2
+ module Client::SDK::Adapters
3
+ class MockAdapter < LocalAdapter
4
+ attr_reader :store
5
+
6
+ def connect(service)
7
+ if service.is_a?(Class) && service < Service
8
+ service = service.new(store: Service::Channel::Stores::MockStore.new)
9
+ end
10
+
11
+ unless service.is_a?(Service)
12
+ raise ArgumentError, "Expected a service, got: #{service.inspect}"
13
+ end
14
+
15
+ unless service.store.is_a?(Service::Channel::Stores::MockStore)
16
+ raise ArgumentError, "Expected service to have a MockStore, got: #{service.store.inspect}"
17
+ end
18
+
19
+ super(service)
20
+ @store = service.store
21
+ end
22
+
23
+ def with_permissions(*perms, &block)
24
+ channel = store.open_channel(name: 'mock channel', may: perms)
25
+ secret = channel.rotate_secret!
26
+ with_channel(channel.token, secret, &block)
27
+ ensure
28
+ channel.close
29
+ end
30
+
31
+ def with_channel(token, secret)
32
+ token_prv, secret_prv = self.channel_token, self.channel_secret
33
+ self.channel_token, self.channel_secret = token, secret
34
+ yield
35
+ ensure
36
+ self.channel_token, self.channel_secret = token_prv, secret_prv
37
+ end
38
+ end # MockAdapter
39
+ end # Client::SDK::Adapters
40
+ end # Ribbon::Intercom
@@ -0,0 +1,66 @@
1
+ module Ribbon::Intercom
2
+ module Errors
3
+ class Error < StandardError; end
4
+
5
+ ##
6
+ # Packet errors
7
+ class PacketError < Error; end
8
+ class InvalidEncodingError < PacketError; end
9
+
10
+ #############
11
+ # Http Errors
12
+ class HttpError < Error; end
13
+
14
+ ##
15
+ # Request Errors
16
+
17
+ # 400 Error
18
+ class RequestError < HttpError; end
19
+ class UnsafeArgumentError < RequestError; end
20
+ class InvalidSubjectSignatureError < RequestError; end
21
+
22
+ # 401 Error
23
+ class AuthenticationError < RequestError; end
24
+
25
+ # 403 Error
26
+ class ForbiddenError < RequestError; end
27
+ class InsufficientPermissionsError < ForbiddenError; end
28
+
29
+ # 404 Not Found
30
+ class NotFoundError < RequestError; end
31
+ class InvalidMethodError < NotFoundError; end
32
+
33
+ # 405 Method Not Allowed
34
+ class MethodNotAllowedError < RequestError; end
35
+
36
+ ##
37
+ # Server Errors
38
+
39
+ # 500 Error
40
+ class ServerError < HttpError; end
41
+ class UnsafeResponseError < ServerError; end
42
+
43
+ # General Errors
44
+ class UnsafeValueError < Error; end
45
+
46
+ # Intercom Errors
47
+ class ServiceNotDefinedError < Error; end
48
+
49
+ # Service Errors
50
+ class NoPermissionsError < Error; end
51
+ class MissingStoreError < Error; end
52
+
53
+ # Channel Store Errors
54
+ class ChannelStoreError < Error; end
55
+ class InvalidStoreParamsError < ChannelStoreError; end
56
+ class InvalidChannelError < ChannelStoreError; end
57
+
58
+ # SDK Errors
59
+ class RequestFailureError < Error; end
60
+
61
+ # Channel Errors
62
+ class ChannelNameMissingError < Error; end
63
+ class ChannelTokenMissingError < Error; end
64
+ class ChannelSecretMissingError < Error; end
65
+ end # Errors
66
+ end # Ribbon::Intercom
@@ -0,0 +1,121 @@
1
+ require 'base64'
2
+
3
+ module Ribbon::Intercom
4
+ class Package
5
+ include Utils::Mixins::MockSafe
6
+
7
+ class << self
8
+ ##
9
+ # Package up any non-basic objects that include Packageable::Mixin.
10
+ def package(subject)
11
+ Utils.walk(subject) { |subject, context|
12
+ if Utils.basic_type?(subject)
13
+ subject
14
+ elsif context == :hash_key
15
+ # Hash keys must be basic types.
16
+ raise Errors::UnsafeResponseError, subject.inspect
17
+ elsif subject.is_a?(Packageable::Mixin)
18
+ _package_obj(subject, package(subject.package_data))
19
+ elsif subject.is_a?(Class) && subject < Packageable::Mixin
20
+ _package_obj(subject)
21
+ else
22
+ raise Errors::UnsafeResponseError, subject.inspect
23
+ end
24
+ }
25
+ end
26
+
27
+ def _package_obj(subject, data=nil)
28
+ new(encode_subject(subject), data)
29
+ end
30
+
31
+ def encode_subject(subject)
32
+ Marshal.dump(subject)
33
+ end
34
+
35
+ def decode_subject(encoded_subject)
36
+ Marshal.load(encoded_subject)
37
+ end
38
+
39
+ ##
40
+ # Walks the object and initializes all packages (i.e., sets them up to be
41
+ # accessed by the end-user on the client).
42
+ def init_packages(object, sdk)
43
+ Utils.walk(object) { |object|
44
+ object.send(:_init, sdk) if object.is_a?(Package)
45
+ object
46
+ }
47
+ end
48
+ end # Class Methods
49
+
50
+ attr_reader :sdk
51
+
52
+ def initialize(subject_data=nil, data=nil)
53
+ @_subject_data = subject_data
54
+ @_data = data
55
+ end
56
+
57
+ ##
58
+ # Begin a method chain (must be completed with `#end`).
59
+ #
60
+ # When `#end` is called on the method chain, the chain will be resolved
61
+ # remotely. This allows the chain of methods to be executed in one round-trip.
62
+ def begin
63
+ Utils::MethodChain.begin { |methods|
64
+ queue = Packet::MethodQueue.new
65
+ methods.each { |meth, *args| queue.enqueue(meth, *args) }
66
+ _send_method_queue(queue)
67
+ }
68
+ end
69
+
70
+ private
71
+
72
+ def method_missing(meth, *args, &block)
73
+ if @_data && @_data.key?(meth)
74
+ @_data[meth]
75
+ elsif sdk
76
+ _call(meth, *args)
77
+ else
78
+ super
79
+ end
80
+ end
81
+
82
+ ##
83
+ # Call a method on the subject remotely.
84
+ def _call(method_name, *args)
85
+ _send_method_queue(Packet::MethodQueue.new.enqueue(method_name, *args))
86
+ end
87
+
88
+ ##
89
+ # Initializes the package on the client-side.
90
+ def _init(sdk)
91
+ @sdk = sdk
92
+ self.class.init_packages(@_data, sdk)
93
+ mock_safe! if sdk.mock_safe?
94
+ end
95
+
96
+ def _send_method_queue(method_queue)
97
+ _process_response(
98
+ sdk.send_packet(
99
+ subject: @_subject_data,
100
+ method_queue: method_queue
101
+ )
102
+ )
103
+ end
104
+
105
+ def marshal_dump
106
+ [@_subject_data, @_data]
107
+ end
108
+
109
+ def marshal_load(array)
110
+ @_subject_data = array[0]
111
+ @_data = array[1]
112
+ end
113
+
114
+ def _process_response(packet)
115
+ @_subject_data = packet.subject
116
+ @_data = self.class.init_packages(packet.package_data, sdk)
117
+
118
+ packet.retval
119
+ end
120
+ end # Package
121
+ end # Ribbon::Intercom
@@ -0,0 +1,6 @@
1
+ module Ribbon::Intercom
2
+ class Packageable
3
+ autoload(:Mixin, 'ribbon/intercom/packageable/mixin')
4
+ include Mixin
5
+ end # Packageable
6
+ end # Ribbon::Intercom
@@ -0,0 +1,29 @@
1
+ require 'set'
2
+
3
+ module Ribbon::Intercom
4
+ class Packageable
5
+ module Mixin
6
+ class << self
7
+ def included(base)
8
+ base.extend(ClassMethods)
9
+ end
10
+ end
11
+
12
+ module ClassMethods
13
+ def package_with(*args)
14
+ args.map { |m| _package_with_methods << m.to_sym }
15
+ end
16
+
17
+ def _package_with_methods
18
+ @__package_with_methods ||= [].to_set
19
+ end
20
+ end # ClassMethods
21
+
22
+ ##
23
+ # Returns the package data for the instance as a hash.
24
+ def package_data
25
+ self.class._package_with_methods.map { |meth| [meth, public_send(meth)] }.to_h
26
+ end
27
+ end # Mixin
28
+ end # Service::Subject
29
+ end # Ribbon::Intercom
@@ -0,0 +1,52 @@
1
+ require 'ostruct'
2
+ require 'base64'
3
+
4
+ module Ribbon::Intercom
5
+ ##
6
+ # Represents a collection of data to be passed between the service and client.
7
+ class Packet < OpenStruct
8
+ autoload(:MethodQueue, 'ribbon/intercom/packet/method_queue')
9
+
10
+ class << self
11
+ def decode(encoded_packet)
12
+ hash = Marshal.load(Base64.strict_decode64(encoded_packet))
13
+ raise Errors::InvalidEncodingError, hash.inspect unless hash.is_a?(Hash)
14
+ new(hash)
15
+ end
16
+ end # Class Methods
17
+
18
+ def initialize(params={})
19
+ error = params.delete(:error)
20
+ super(params)
21
+ self.error = error if error
22
+ end
23
+
24
+ ##
25
+ # Encode (marshal) the error before saving it. This allows the error to be
26
+ # decoded on the client when requested, rather than decoded at the same time
27
+ # the packet is decoded, which could cause problems if the error class doesn't
28
+ # exist on the client.
29
+ def error=(err)
30
+ self._encoded_error = Marshal.dump(err)
31
+ end
32
+
33
+ ##
34
+ # Decode the error, which may not exist on the client.
35
+ # If the error class doesn't exist on the client, raise a ServerError.
36
+ def error
37
+ error? && Marshal.load(_encoded_error)
38
+ rescue
39
+ Errors::ServerError.new('unknown server error')
40
+ end
41
+
42
+ ##
43
+ # Whether the packet contains an error
44
+ def error?
45
+ !!_encoded_error
46
+ end
47
+
48
+ def encode
49
+ Base64.strict_encode64(Marshal.dump(to_h))
50
+ end
51
+ end # Packet
52
+ end # Ribbon::Intercom
@@ -0,0 +1,28 @@
1
+ module Ribbon::Intercom
2
+ class Packet
3
+ class MethodQueue
4
+ ##
5
+ # Enqueue a method with it's arguments.
6
+ # Supports method chaining.
7
+ def enqueue(name, *args)
8
+ self.tap { _queue << [name, *Utils.sanitize(args)] }
9
+ end
10
+
11
+ ##
12
+ # Iterate through the methods and arguments.
13
+ def each(&block)
14
+ _queue.each(&block)
15
+ end
16
+
17
+ def empty?
18
+ _queue.empty?
19
+ end
20
+
21
+ private
22
+
23
+ def _queue
24
+ @__queue ||= []
25
+ end
26
+ end # MethodQueue
27
+ end # Packet
28
+ end # Ribbon::Intercom
@@ -0,0 +1,14 @@
1
+ require 'ribbon/intercom'
2
+ require 'rails'
3
+
4
+ module Ribbon
5
+ module Intercom
6
+ class Railtie < Rails::Railtie
7
+ railtie_name :intercom
8
+
9
+ rake_tasks do
10
+ Intercom.load_tasks
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,273 @@
1
+ require 'base64'
2
+ require 'yaml'
3
+ require 'set'
4
+
5
+ module Ribbon::Intercom
6
+ class Service
7
+ autoload(:Channel, 'ribbon/intercom/service/channel')
8
+
9
+ # Used to signify an empty body
10
+ class EmptyResponse; end
11
+
12
+ class << self
13
+ def instance
14
+ @instance ||= new(store: _load_store)
15
+ end
16
+
17
+ def mock
18
+ Client::MockSDK.new(self)
19
+ end
20
+
21
+ def store(store_name, params={})
22
+ @_store_name = store_name
23
+ @_store_params = params
24
+ end
25
+
26
+ # The call method is needed here because Rails checks to see if a mounted
27
+ # Rack app can respond_to?(:call). Without it, the Service will not mount
28
+ def call(env)
29
+ instance.call(env)
30
+ end
31
+
32
+ def method_missing(meth, *args, &block)
33
+ instance.public_send(meth, *args, &block)
34
+ end
35
+
36
+ def _load_store
37
+ raise "Store name missing" unless (store_name = @_store_name.to_s)
38
+
39
+ store = Utils.classify(store_name) + "Store"
40
+ Intercom::Service::Channel::Stores.const_get(store).new(@_store_params)
41
+ end
42
+ end # Class methods
43
+
44
+ attr_reader :request
45
+ attr_reader :channel
46
+ attr_reader :subject
47
+ attr_reader :request_packet
48
+ attr_reader :env
49
+
50
+ def initialize(opts={})
51
+ @_opts = opts.dup
52
+ end
53
+
54
+ def store
55
+ @store ||= @_opts[:store] or raise Errors::MissingStoreError
56
+ end
57
+
58
+ def open_channel(params={})
59
+ # Accept either an array of permissions or a string
60
+ store.open_channel(params).tap { |channel|
61
+ channel.may(Utils.method_identifier(self, :rotate_secret))
62
+ }
63
+ end
64
+
65
+ def lookup_channel(token)
66
+ store.lookup_channel(token)
67
+ end
68
+
69
+ ##
70
+ # Check that the channel has sufficient permissions to call the method.
71
+ #
72
+ # The `send` method is forbidden because it breaks the encapsulation guaranteed
73
+ # by intercom (i.e., private methods can't be called).
74
+ #
75
+ # In addition to the permissions granted to the channel, all channels have
76
+ # implicit permission to call public methods on basic types.
77
+ def sufficient_permissions?(base, intercom_method)
78
+ intercom_method != :send && (
79
+ Utils.basic_type?(base) ||
80
+ channel.may?(Utils.method_identifier(base, intercom_method))
81
+ )
82
+ end
83
+
84
+ def call(env)
85
+ dup.call!(env)
86
+ end
87
+
88
+ def call!(env)
89
+ @env = env
90
+
91
+ response = catch(:response) {
92
+ begin
93
+ _process_request
94
+ rescue Exception => error
95
+ _respond_with_error!(error)
96
+ end
97
+ }
98
+
99
+ response.finish
100
+ end
101
+
102
+ def rotate_secret
103
+ channel.rotate_secret!
104
+ end
105
+
106
+ private
107
+
108
+ def _process_request
109
+ _init_request
110
+ _authenticate_request!
111
+ _load_request_packet
112
+ _load_subject
113
+ response_packet = _process_methods
114
+ _respond_with_packet(response_packet)
115
+ end
116
+
117
+ def _init_request
118
+ @request = Rack::Request.new(env)
119
+
120
+ unless request.put?
121
+ _error!(Errors::MethodNotAllowedError, 'only PUT allowed')
122
+ end
123
+ end
124
+
125
+ def _authenticate_request!
126
+ unless _request_authenticated?
127
+ _error!(Errors::AuthenticationError, "invalid channel credentials")
128
+ end
129
+ end
130
+
131
+ def _load_request_packet
132
+ @request_packet = Packet.decode(request.body.read)
133
+ end
134
+
135
+ def _load_subject
136
+ if (encoded_subject=request_packet.subject) && !encoded_subject.empty?
137
+ @subject = Package.decode_subject(encoded_subject)
138
+ _error!(Errors::InvalidSubjectSignatureError) unless @subject
139
+ else
140
+ @subject = self
141
+ end
142
+ end
143
+
144
+ ##
145
+ # Calls requested methods and returns a response packet for the client.
146
+ def _process_methods
147
+ retval = _call_methods
148
+ _prepare_response_packet(retval)
149
+ end
150
+
151
+ ##
152
+ # Call all the methods in the method queue.
153
+ def _call_methods
154
+ method_queue = _load_method_queue
155
+
156
+ intercom_method = nil
157
+ base = subject
158
+ method_queue.each { |meth, *args|
159
+ intercom_method = meth
160
+ _sufficient_permissions!(base, meth)
161
+ base = base.public_send(meth, *args)
162
+ }
163
+
164
+ Package.package(base)
165
+ rescue NoMethodError => error
166
+ if error.name == intercom_method
167
+ _error!(Errors::InvalidMethodError, intercom_method)
168
+ else
169
+ raise
170
+ end
171
+ end
172
+
173
+ def _load_method_queue
174
+ request_packet.method_queue.tap { |mq|
175
+ raise "No method queue given" unless mq
176
+ raise "Expected MethodQueue, got: #{mq.inspect}" unless mq.is_a?(Packet::MethodQueue)
177
+ raise "Empty MethodQueue" if mq.empty?
178
+ }
179
+ end
180
+
181
+ ##
182
+ # Creates a successful response Packet to be returned to the client.
183
+ def _prepare_response_packet(retval)
184
+ Packet.new.tap { |packet|
185
+ unless self == subject # Order matters here! See: issue#52
186
+ # Need to send subject back in case it was modified by the methods.
187
+ packet.subject = Package.encode_subject(subject)
188
+
189
+ if subject.is_a?(Packageable::Mixin)
190
+ # Need to send the package data back in case it changed, too.
191
+ packet.package_data = Package.package(subject.package_data)
192
+ end
193
+ end
194
+
195
+ packet.retval = retval
196
+ }
197
+ end
198
+
199
+ def _respond_with_packet(packet, status=200)
200
+ _respond!(status, {}, packet.encode)
201
+ end
202
+
203
+ def _request_authenticated?
204
+ auth = Rack::Auth::Basic::Request.new(env)
205
+
206
+ if auth.provided? && auth.basic?
207
+ token = auth.credentials[0]
208
+ secret = auth.credentials[1]
209
+
210
+ # Check if the request is authenticated
211
+ @channel = lookup_channel(token)
212
+ channel && channel.valid_secret?(secret)
213
+ end
214
+ end
215
+
216
+ def _sufficient_permissions!(base, intercom_method)
217
+ unless sufficient_permissions?(base, intercom_method)
218
+ required = Utils.method_identifier(base, intercom_method)
219
+ _error!(Errors::InsufficientPermissionsError, required)
220
+ end
221
+ end
222
+
223
+ def _response(status, headers={}, body=EmptyResponse)
224
+ body = body == EmptyResponse ? [] : [body]
225
+ headers = headers.merge("Content-Type" => "text/plain", "Transfer-Encoding" => "gzip")
226
+ Rack::Response.new(body, status, headers)
227
+ end
228
+
229
+ def _respond!(status, headers={}, body=EmptyResponse)
230
+ throw :response, _response(status, headers, body)
231
+ end
232
+
233
+ def _respond_with_error!(error, status=500)
234
+ _respond_with_packet(Packet.new(error: error), status)
235
+ end
236
+
237
+ def _error!(klass, message=nil)
238
+ error = message ? klass.new(message) : klass.new
239
+ _respond_with_error!(error, _error_to_http_code(error))
240
+ end
241
+
242
+ def _error_to_http_code(error)
243
+ case error
244
+ when Errors::MethodNotAllowedError
245
+ 405
246
+ when Errors::NotFoundError
247
+ 404
248
+ when Errors::ForbiddenError
249
+ 403
250
+ when Errors::AuthenticationError
251
+ 401
252
+ when Errors::RequestError
253
+ 400
254
+ when Errors::ServerError
255
+ 500
256
+ else
257
+ 500
258
+ end
259
+ end
260
+
261
+ ##
262
+ # Decodes the arguments.
263
+ #
264
+ # It's very important that this happens *after* channel authentication is
265
+ # performed. Since `args` comes from the client it could contain malicious
266
+ # marshalled data.
267
+ def _decode_args(args)
268
+ Utils.sanitize(Marshal.load(Base64.strict_decode64(args))).tap { |args|
269
+ raise Errors::UnsafeValueError unless args.is_a?(Array)
270
+ }
271
+ end
272
+ end # Service
273
+ end # Ribbon::Intercom