sockudo 1.0.0
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 +7 -0
- data/CHANGELOG.md +137 -0
- data/LICENSE +20 -0
- data/README.md +289 -0
- data/lib/sockudo/channel.rb +179 -0
- data/lib/sockudo/client.rb +593 -0
- data/lib/sockudo/request.rb +112 -0
- data/lib/sockudo/resource.rb +36 -0
- data/lib/sockudo/utils.rb +34 -0
- data/lib/sockudo/version.rb +3 -0
- data/lib/sockudo/webhook.rb +110 -0
- data/lib/sockudo.rb +77 -0
- metadata +208 -0
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
require 'base64'
|
|
2
|
+
require 'securerandom'
|
|
3
|
+
require 'pusher-signature'
|
|
4
|
+
|
|
5
|
+
module Sockudo
|
|
6
|
+
class Client
|
|
7
|
+
attr_accessor :scheme, :host, :port, :app_id, :key, :secret, :encryption_master_key
|
|
8
|
+
attr_reader :http_proxy, :proxy, :base_id, :publish_serial
|
|
9
|
+
attr_writer :connect_timeout, :send_timeout, :receive_timeout,
|
|
10
|
+
:keep_alive_timeout
|
|
11
|
+
|
|
12
|
+
## CONFIGURATION ##
|
|
13
|
+
DEFAULT_CONNECT_TIMEOUT = 5
|
|
14
|
+
DEFAULT_SEND_TIMEOUT = 5
|
|
15
|
+
DEFAULT_RECEIVE_TIMEOUT = 5
|
|
16
|
+
DEFAULT_KEEP_ALIVE_TIMEOUT = 30
|
|
17
|
+
DEFAULT_CLUSTER = "mt1"
|
|
18
|
+
|
|
19
|
+
# Loads the configuration from an url in the environment
|
|
20
|
+
def self.from_env(key = 'SOCKUDO_URL')
|
|
21
|
+
url = ENV[key] || raise(ConfigurationError, key)
|
|
22
|
+
from_url(url)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Loads the configuration from a url
|
|
26
|
+
def self.from_url(url)
|
|
27
|
+
client = new
|
|
28
|
+
client.url = url
|
|
29
|
+
client
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def initialize(options = {})
|
|
33
|
+
@scheme = "https"
|
|
34
|
+
@port = options[:port] || 443
|
|
35
|
+
|
|
36
|
+
if options.key?(:encrypted)
|
|
37
|
+
warn "[DEPRECATION] `encrypted` is deprecated and will be removed in the next major version. Use `use_tls` instead."
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
if options[:use_tls] == false || options[:encrypted] == false
|
|
41
|
+
@scheme = "http"
|
|
42
|
+
@port = options[:port] || 80
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
@app_id = options[:app_id]
|
|
46
|
+
@key = options[:key]
|
|
47
|
+
@secret = options[:secret]
|
|
48
|
+
@auto_idempotency_key = options.fetch(:auto_idempotency_key, true)
|
|
49
|
+
@base_id = Base64.urlsafe_encode64(SecureRandom.random_bytes(12), padding: false)
|
|
50
|
+
@publish_serial = 0
|
|
51
|
+
@max_retries = 3
|
|
52
|
+
|
|
53
|
+
@host = options[:host]
|
|
54
|
+
@host ||= "api-#{options[:cluster]}.sockudo.com" unless options[:cluster].nil? || options[:cluster].empty?
|
|
55
|
+
@host ||= "api-#{DEFAULT_CLUSTER}.sockudo.com"
|
|
56
|
+
|
|
57
|
+
@encryption_master_key = Base64.strict_decode64(options[:encryption_master_key_base64]) if options[:encryption_master_key_base64]
|
|
58
|
+
|
|
59
|
+
@http_proxy = options[:http_proxy]
|
|
60
|
+
|
|
61
|
+
# Default timeouts
|
|
62
|
+
@connect_timeout = DEFAULT_CONNECT_TIMEOUT
|
|
63
|
+
@send_timeout = DEFAULT_SEND_TIMEOUT
|
|
64
|
+
@receive_timeout = DEFAULT_RECEIVE_TIMEOUT
|
|
65
|
+
@keep_alive_timeout = DEFAULT_KEEP_ALIVE_TIMEOUT
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# @private Returns the authentication token for the client
|
|
69
|
+
def authentication_token
|
|
70
|
+
raise ConfigurationError, :key unless @key
|
|
71
|
+
raise ConfigurationError, :secret unless @secret
|
|
72
|
+
Sockudo::Signature::Token.new(@key, @secret)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @private Builds a url for this app, optionally appending a path
|
|
76
|
+
def url(path = nil)
|
|
77
|
+
raise ConfigurationError, :app_id unless @app_id
|
|
78
|
+
URI::Generic.build({
|
|
79
|
+
scheme: @scheme,
|
|
80
|
+
host: @host,
|
|
81
|
+
port: @port,
|
|
82
|
+
path: "/apps/#{@app_id}#{path}"
|
|
83
|
+
})
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Configure Sockudo connection by providing a url rather than specifying
|
|
87
|
+
# scheme, key, secret, and app_id separately.
|
|
88
|
+
#
|
|
89
|
+
# @example
|
|
90
|
+
# Sockudo.url = http://KEY:SECRET@localhost/apps/APP_ID
|
|
91
|
+
#
|
|
92
|
+
def url=(url)
|
|
93
|
+
uri = URI.parse(url)
|
|
94
|
+
@scheme = uri.scheme
|
|
95
|
+
@app_id = uri.path.split('/').last
|
|
96
|
+
@key = uri.user
|
|
97
|
+
@secret = uri.password
|
|
98
|
+
@host = uri.host
|
|
99
|
+
@port = uri.port
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def http_proxy=(http_proxy)
|
|
103
|
+
@http_proxy = http_proxy
|
|
104
|
+
uri = URI.parse(http_proxy)
|
|
105
|
+
@proxy = {
|
|
106
|
+
scheme: uri.scheme,
|
|
107
|
+
host: uri.host,
|
|
108
|
+
port: uri.port,
|
|
109
|
+
user: uri.user,
|
|
110
|
+
password: uri.password
|
|
111
|
+
}
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Configure whether Sockudo API calls should be made over SSL
|
|
115
|
+
# (default false)
|
|
116
|
+
#
|
|
117
|
+
# @example
|
|
118
|
+
# Sockudo.encrypted = true
|
|
119
|
+
#
|
|
120
|
+
def encrypted=(boolean)
|
|
121
|
+
@scheme = boolean ? 'https' : 'http'
|
|
122
|
+
# Configure port if it hasn't already been configured
|
|
123
|
+
@port = boolean ? 443 : 80
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def encrypted?
|
|
127
|
+
@scheme == 'https'
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def cluster=(cluster)
|
|
131
|
+
cluster = DEFAULT_CLUSTER if cluster.nil? || cluster.empty?
|
|
132
|
+
|
|
133
|
+
@host = "api-#{cluster}.sockudo.com"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Convenience method to set all timeouts to the same value (in seconds).
|
|
137
|
+
# For more control, use the individual writers.
|
|
138
|
+
def timeout=(value)
|
|
139
|
+
@connect_timeout, @send_timeout, @receive_timeout = value, value, value
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Set an encryption_master_key to use with private-encrypted channels from
|
|
143
|
+
# a base64 encoded string.
|
|
144
|
+
def encryption_master_key_base64=(s)
|
|
145
|
+
@encryption_master_key = s ? Base64.strict_decode64(s) : nil
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
## INTERACT WITH THE API ##
|
|
149
|
+
|
|
150
|
+
def resource(path)
|
|
151
|
+
Resource.new(self, path)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# GET arbitrary REST API resource using a synchronous http client.
|
|
155
|
+
# All request signing is handled automatically.
|
|
156
|
+
#
|
|
157
|
+
# @example
|
|
158
|
+
# begin
|
|
159
|
+
# Sockudo.get('/channels', filter_by_prefix: 'private-')
|
|
160
|
+
# rescue Sockudo::Error => e
|
|
161
|
+
# # Handle error
|
|
162
|
+
# end
|
|
163
|
+
#
|
|
164
|
+
# @param path [String] Path excluding /apps/APP_ID
|
|
165
|
+
# @param params [Hash] API params (see http://sockudo.com/docs/rest_api)
|
|
166
|
+
#
|
|
167
|
+
# @return [Hash] See Sockudo API docs
|
|
168
|
+
#
|
|
169
|
+
# @raise [Sockudo::Error] Unsuccessful response - see the error message
|
|
170
|
+
# @raise [Sockudo::HTTPError] Error raised inside http client. The original error is wrapped in error.original_error
|
|
171
|
+
#
|
|
172
|
+
def get(path, params = {})
|
|
173
|
+
resource(path).get(params)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# GET arbitrary REST API resource using an asynchronous http client.
|
|
177
|
+
# All request signing is handled automatically.
|
|
178
|
+
#
|
|
179
|
+
# When the eventmachine reactor is running, the em-http-request gem is used;
|
|
180
|
+
# otherwise an async request is made using httpclient. See README for
|
|
181
|
+
# details and examples.
|
|
182
|
+
#
|
|
183
|
+
# @param path [String] Path excluding /apps/APP_ID
|
|
184
|
+
# @param params [Hash] API params (see http://sockudo.com/docs/rest_api)
|
|
185
|
+
#
|
|
186
|
+
# @return Either an EM::DefaultDeferrable or a HTTPClient::Connection
|
|
187
|
+
#
|
|
188
|
+
def get_async(path, params = {})
|
|
189
|
+
resource(path).get_async(params)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# POST arbitrary REST API resource using a synchronous http client.
|
|
193
|
+
# Works identially to get method, but posts params as JSON in post body.
|
|
194
|
+
def post(path, params = {}, headers = {})
|
|
195
|
+
resource(path).post(params, headers)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# POST arbitrary REST API resource using an asynchronous http client.
|
|
199
|
+
# Works identially to get_async method, but posts params as JSON in post
|
|
200
|
+
# body.
|
|
201
|
+
def post_async(path, params = {}, headers = {})
|
|
202
|
+
resource(path).post_async(params, headers)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
## HELPER METHODS ##
|
|
206
|
+
|
|
207
|
+
# Convenience method for creating a new WebHook instance for validating
|
|
208
|
+
# and extracting info from a received WebHook
|
|
209
|
+
#
|
|
210
|
+
# @param request [Rack::Request] Either a Rack::Request or a Hash containing :key, :signature, :body, and optionally :content_type.
|
|
211
|
+
#
|
|
212
|
+
def webhook(request)
|
|
213
|
+
WebHook.new(request, self)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Return a convenience channel object by name that delegates operations
|
|
217
|
+
# on a channel. No API request is made.
|
|
218
|
+
#
|
|
219
|
+
# @example
|
|
220
|
+
# Sockudo['my-channel']
|
|
221
|
+
# @return [Channel]
|
|
222
|
+
# @raise [Sockudo::Error] if the channel name is invalid.
|
|
223
|
+
# Channel names should be less than 200 characters, and
|
|
224
|
+
# should not contain anything other than letters, numbers, or the
|
|
225
|
+
# characters "_\-=@,.;"
|
|
226
|
+
def channel(channel_name)
|
|
227
|
+
Channel.new(nil, channel_name, self)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
alias :[] :channel
|
|
231
|
+
|
|
232
|
+
# Request a list of occupied channels from the API
|
|
233
|
+
#
|
|
234
|
+
# GET /apps/[id]/channels
|
|
235
|
+
#
|
|
236
|
+
# @param params [Hash] Hash of parameters for the API - see REST API docs
|
|
237
|
+
#
|
|
238
|
+
# @return [Hash] See Sockudo API docs
|
|
239
|
+
#
|
|
240
|
+
# @raise [Sockudo::Error] Unsuccessful response - see the error message
|
|
241
|
+
# @raise [Sockudo::HTTPError] Error raised inside http client. The original error is wrapped in error.original_error
|
|
242
|
+
#
|
|
243
|
+
def channels(params = {})
|
|
244
|
+
get('/channels', params)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Request info for a specific channel
|
|
248
|
+
#
|
|
249
|
+
# GET /apps/[id]/channels/[channel_name]
|
|
250
|
+
#
|
|
251
|
+
# @param channel_name [String] Channel name (max 200 characters)
|
|
252
|
+
# @param params [Hash] Hash of parameters for the API - see REST API docs
|
|
253
|
+
#
|
|
254
|
+
# @return [Hash] See Sockudo API docs
|
|
255
|
+
#
|
|
256
|
+
# @raise [Sockudo::Error] Unsuccessful response - see the error message
|
|
257
|
+
# @raise [Sockudo::HTTPError] Error raised inside http client. The original error is wrapped in error.original_error
|
|
258
|
+
#
|
|
259
|
+
def channel_info(channel_name, params = {})
|
|
260
|
+
get("/channels/#{channel_name}", params)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Request info for users of a presence channel
|
|
264
|
+
#
|
|
265
|
+
# GET /apps/[id]/channels/[channel_name]/users
|
|
266
|
+
#
|
|
267
|
+
# @param channel_name [String] Channel name (max 200 characters)
|
|
268
|
+
# @param params [Hash] Hash of parameters for the API - see REST API docs
|
|
269
|
+
#
|
|
270
|
+
# @return [Hash] See Sockudo API docs
|
|
271
|
+
#
|
|
272
|
+
# @raise [Sockudo::Error] Unsuccessful response - see the error message
|
|
273
|
+
# @raise [Sockudo::HTTPError] Error raised inside http client. The original error is wrapped in error.original_error
|
|
274
|
+
#
|
|
275
|
+
def channel_users(channel_name, params = {})
|
|
276
|
+
get("/channels/#{channel_name}/users", params)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Trigger an event on one or more channels
|
|
280
|
+
#
|
|
281
|
+
# POST /apps/[app_id]/events
|
|
282
|
+
#
|
|
283
|
+
# @param channels [String or Array] 1-10 channel names
|
|
284
|
+
# @param event_name [String]
|
|
285
|
+
# @param data [Object] Event data to be triggered in javascript.
|
|
286
|
+
# Objects other than strings will be converted to JSON
|
|
287
|
+
# @param params [Hash] Additional parameters to send to api, e.g socket_id.
|
|
288
|
+
# May include :extras => { headers: Hash, ephemeral: Boolean, idempotency_key: String, echo: Boolean }
|
|
289
|
+
#
|
|
290
|
+
# @return [Hash] See Sockudo API docs
|
|
291
|
+
#
|
|
292
|
+
# @raise [Sockudo::Error] Unsuccessful response - see the error message
|
|
293
|
+
# @raise [Sockudo::HTTPError] Error raised inside http client. The original error is wrapped in error.original_error
|
|
294
|
+
#
|
|
295
|
+
def trigger(channels, event_name, data, params = {})
|
|
296
|
+
params = inject_auto_idempotency_key(params)
|
|
297
|
+
body, headers = trigger_params_with_headers(channels, event_name, data, params)
|
|
298
|
+
post_with_retry('/events', body, headers)
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# Trigger multiple events at the same time
|
|
302
|
+
#
|
|
303
|
+
# POST /apps/[app_id]/batch_events
|
|
304
|
+
#
|
|
305
|
+
# @param events [Array] List of events to publish. Each event hash may
|
|
306
|
+
# include an :idempotency_key field for at-most-once delivery.
|
|
307
|
+
#
|
|
308
|
+
# @return [Hash] See Sockudo API docs
|
|
309
|
+
#
|
|
310
|
+
# @raise [Sockudo::Error] Unsuccessful response - see the error message
|
|
311
|
+
# @raise [Sockudo::HTTPError] Error raised inside http client. The original error is wrapped in error.original_error
|
|
312
|
+
#
|
|
313
|
+
def trigger_batch(*events)
|
|
314
|
+
flat_events = events.flatten
|
|
315
|
+
inject_auto_idempotency_keys_batch!(flat_events)
|
|
316
|
+
post_with_retry('/batch_events', trigger_batch_params(flat_events))
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# Trigger an event on one or more channels asynchronously.
|
|
320
|
+
# For parameters see #trigger
|
|
321
|
+
#
|
|
322
|
+
def trigger_async(channels, event_name, data, params = {})
|
|
323
|
+
params = inject_auto_idempotency_key(params)
|
|
324
|
+
body, headers = trigger_params_with_headers(channels, event_name, data, params)
|
|
325
|
+
post_async('/events', body, headers)
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# Trigger multiple events asynchronously.
|
|
329
|
+
# For parameters see #trigger_batch
|
|
330
|
+
#
|
|
331
|
+
def trigger_batch_async(*events)
|
|
332
|
+
flat_events = events.flatten
|
|
333
|
+
inject_auto_idempotency_keys_batch!(flat_events)
|
|
334
|
+
post_async('/batch_events', trigger_batch_params(flat_events))
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
# Generate the expected response for an authentication endpoint.
|
|
339
|
+
# See https://sockudo.com/docs/channels/server_api/authorizing-users for details.
|
|
340
|
+
#
|
|
341
|
+
# @example Private channels
|
|
342
|
+
# render :json => Sockudo.authenticate('private-my_channel', params[:socket_id])
|
|
343
|
+
#
|
|
344
|
+
# @example Presence channels
|
|
345
|
+
# render :json => Sockudo.authenticate('presence-my_channel', params[:socket_id], {
|
|
346
|
+
# :user_id => current_user.id, # => required
|
|
347
|
+
# :user_info => { # => optional - for example
|
|
348
|
+
# :name => current_user.name,
|
|
349
|
+
# :email => current_user.email
|
|
350
|
+
# }
|
|
351
|
+
# })
|
|
352
|
+
#
|
|
353
|
+
# @param socket_id [String]
|
|
354
|
+
# @param custom_data [Hash] used for example by private channels
|
|
355
|
+
#
|
|
356
|
+
# @return [Hash]
|
|
357
|
+
#
|
|
358
|
+
# @raise [Sockudo::Error] if channel_name or socket_id are invalid
|
|
359
|
+
#
|
|
360
|
+
# @private Custom data is sent to server as JSON-encoded string
|
|
361
|
+
#
|
|
362
|
+
def authenticate(channel_name, socket_id, custom_data = nil)
|
|
363
|
+
channel_instance = channel(channel_name)
|
|
364
|
+
r = channel_instance.authenticate(socket_id, custom_data)
|
|
365
|
+
if channel_name.match(/^private-encrypted-/)
|
|
366
|
+
r[:shared_secret] = Base64.strict_encode64(
|
|
367
|
+
channel_instance.shared_secret(encryption_master_key)
|
|
368
|
+
)
|
|
369
|
+
end
|
|
370
|
+
r
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# Generate the expected response for a user authentication endpoint.
|
|
374
|
+
# See https://sockudo.com/docs/authenticating_users for details.
|
|
375
|
+
#
|
|
376
|
+
# @example
|
|
377
|
+
# user_data = { id: current_user.id.to_s, company_id: current_user.company_id }
|
|
378
|
+
# render :json => Sockudo.authenticate_user(params[:socket_id], user_data)
|
|
379
|
+
#
|
|
380
|
+
# @param socket_id [String]
|
|
381
|
+
# @param user_data [Hash] user's properties (id is required and must be a string)
|
|
382
|
+
#
|
|
383
|
+
# @return [Hash]
|
|
384
|
+
#
|
|
385
|
+
# @raise [Sockudo::Error] if socket_id or user_data is invalid
|
|
386
|
+
#
|
|
387
|
+
# @private Custom data is sent to server as JSON-encoded string
|
|
388
|
+
#
|
|
389
|
+
def authenticate_user(socket_id, user_data)
|
|
390
|
+
validate_user_data(user_data)
|
|
391
|
+
|
|
392
|
+
custom_data = MultiJson.encode(user_data)
|
|
393
|
+
auth = authentication_string(socket_id, custom_data)
|
|
394
|
+
|
|
395
|
+
{ auth: auth, user_data: custom_data }
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# @private Construct a net/http http client
|
|
399
|
+
def sync_http_client
|
|
400
|
+
require 'httpclient'
|
|
401
|
+
|
|
402
|
+
@client ||= begin
|
|
403
|
+
HTTPClient.new(@http_proxy).tap do |c|
|
|
404
|
+
c.connect_timeout = @connect_timeout
|
|
405
|
+
c.send_timeout = @send_timeout
|
|
406
|
+
c.receive_timeout = @receive_timeout
|
|
407
|
+
c.keep_alive_timeout = @keep_alive_timeout
|
|
408
|
+
end
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
# @private Construct an em-http-request http client
|
|
413
|
+
def em_http_client(uri)
|
|
414
|
+
begin
|
|
415
|
+
unless defined?(EventMachine) && EventMachine.reactor_running?
|
|
416
|
+
raise Error, "In order to use async calling you must be running inside an eventmachine loop"
|
|
417
|
+
end
|
|
418
|
+
require 'em-http' unless defined?(EventMachine::HttpRequest)
|
|
419
|
+
|
|
420
|
+
connection_opts = {
|
|
421
|
+
connect_timeout: @connect_timeout,
|
|
422
|
+
inactivity_timeout: @receive_timeout,
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if defined?(@proxy)
|
|
426
|
+
proxy_opts = {
|
|
427
|
+
host: @proxy[:host],
|
|
428
|
+
port: @proxy[:port]
|
|
429
|
+
}
|
|
430
|
+
if @proxy[:user]
|
|
431
|
+
proxy_opts[:authorization] = [@proxy[:user], @proxy[:password]]
|
|
432
|
+
end
|
|
433
|
+
connection_opts[:proxy] = proxy_opts
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
EventMachine::HttpRequest.new(uri, connection_opts)
|
|
437
|
+
end
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
private
|
|
441
|
+
|
|
442
|
+
include Sockudo::Utils
|
|
443
|
+
|
|
444
|
+
def inject_auto_idempotency_key(params)
|
|
445
|
+
return params if params.key?(:idempotency_key) || !@auto_idempotency_key
|
|
446
|
+
serial = (@publish_serial += 1)
|
|
447
|
+
params.merge(idempotency_key: "#{@base_id}:#{serial}")
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
def inject_auto_idempotency_keys_batch!(events)
|
|
451
|
+
return false unless @auto_idempotency_key
|
|
452
|
+
serial = (@publish_serial += 1)
|
|
453
|
+
injected = false
|
|
454
|
+
events.each_with_index do |event, index|
|
|
455
|
+
next if event.key?(:idempotency_key)
|
|
456
|
+
event[:idempotency_key] = "#{@base_id}:#{serial}:#{index}"
|
|
457
|
+
injected = true
|
|
458
|
+
end
|
|
459
|
+
injected
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
def post_with_retry(path, body, headers = {})
|
|
463
|
+
last_error = nil
|
|
464
|
+
@max_retries.times do |attempt|
|
|
465
|
+
begin
|
|
466
|
+
return post(path, body, headers)
|
|
467
|
+
rescue Sockudo::HTTPError => e
|
|
468
|
+
last_error = e
|
|
469
|
+
raise unless attempt < @max_retries - 1
|
|
470
|
+
rescue Sockudo::Error => e
|
|
471
|
+
if e.respond_to?(:status) && e.status.is_a?(Integer) && e.status >= 500 && e.status < 600
|
|
472
|
+
last_error = e
|
|
473
|
+
raise unless attempt < @max_retries - 1
|
|
474
|
+
else
|
|
475
|
+
raise
|
|
476
|
+
end
|
|
477
|
+
end
|
|
478
|
+
end
|
|
479
|
+
raise last_error
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
def trigger_params(channels, event_name, data, params)
|
|
483
|
+
channels = Array(channels).map(&:to_s)
|
|
484
|
+
raise Sockudo::Error, "Too many channels (#{channels.length}), max 100" if channels.length > 100
|
|
485
|
+
|
|
486
|
+
encoded_data = if channels.any?{ |c| c.match(/^private-encrypted-/) } then
|
|
487
|
+
raise Sockudo::Error, "Cannot trigger to multiple channels if any are encrypted" if channels.length > 1
|
|
488
|
+
encrypt(channels[0], encode_data(data))
|
|
489
|
+
else
|
|
490
|
+
encode_data(data)
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
params.merge({
|
|
494
|
+
name: event_name,
|
|
495
|
+
channels: channels,
|
|
496
|
+
data: encoded_data,
|
|
497
|
+
})
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
def trigger_params_with_headers(channels, event_name, data, params)
|
|
501
|
+
params = params.dup
|
|
502
|
+
idempotency_key = params.delete(:idempotency_key)
|
|
503
|
+
body = trigger_params(channels, event_name, data, params)
|
|
504
|
+
headers = {}
|
|
505
|
+
if idempotency_key
|
|
506
|
+
body[:idempotency_key] = idempotency_key
|
|
507
|
+
headers['X-Idempotency-Key'] = idempotency_key
|
|
508
|
+
end
|
|
509
|
+
[body, headers]
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
def trigger_batch_params(events)
|
|
513
|
+
{
|
|
514
|
+
batch: events.map do |event|
|
|
515
|
+
event.dup.tap do |e|
|
|
516
|
+
e[:data] = if e[:channel].match(/^private-encrypted-/) then
|
|
517
|
+
encrypt(e[:channel], encode_data(e[:data]))
|
|
518
|
+
else
|
|
519
|
+
encode_data(e[:data])
|
|
520
|
+
end
|
|
521
|
+
end
|
|
522
|
+
end
|
|
523
|
+
}
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
# JSON-encode the data if it's not a string
|
|
527
|
+
def encode_data(data)
|
|
528
|
+
return data if data.is_a? String
|
|
529
|
+
MultiJson.encode(data)
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
# Encrypts a message with a key derived from the master key and channel
|
|
533
|
+
# name
|
|
534
|
+
def encrypt(channel_name, encoded_data)
|
|
535
|
+
raise ConfigurationError, :encryption_master_key unless @encryption_master_key
|
|
536
|
+
|
|
537
|
+
# Only now load rbnacl, so that people that aren't using it don't need to
|
|
538
|
+
# install libsodium
|
|
539
|
+
require_rbnacl
|
|
540
|
+
|
|
541
|
+
secret_box = RbNaCl::SecretBox.new(
|
|
542
|
+
channel(channel_name).shared_secret(@encryption_master_key)
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
nonce = RbNaCl::Random.random_bytes(secret_box.nonce_bytes)
|
|
546
|
+
ciphertext = secret_box.encrypt(nonce, encoded_data)
|
|
547
|
+
|
|
548
|
+
MultiJson.encode({
|
|
549
|
+
"nonce" => Base64::strict_encode64(nonce),
|
|
550
|
+
"ciphertext" => Base64::strict_encode64(ciphertext),
|
|
551
|
+
})
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
def configured?
|
|
555
|
+
host && scheme && key && secret && app_id
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
def require_rbnacl
|
|
559
|
+
require 'rbnacl'
|
|
560
|
+
rescue LoadError => e
|
|
561
|
+
$stderr.puts "You don't have rbnacl installed in your application. Please add it to your Gemfile and run bundle install"
|
|
562
|
+
raise e
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
# Compute authentication string required as part of the user authentication
|
|
566
|
+
# endpoint response. Generally the authenticate method should be used in
|
|
567
|
+
# preference to this one.
|
|
568
|
+
#
|
|
569
|
+
# @param socket_id [String] Each Sockudo socket connection receives a
|
|
570
|
+
# unique socket_id. This is sent from sockudo.js to your server when
|
|
571
|
+
# channel authentication is required.
|
|
572
|
+
# @param custom_string [String] Allows signing additional data
|
|
573
|
+
# @return [String]
|
|
574
|
+
#
|
|
575
|
+
# @raise [Sockudo::Error] if socket_id or custom_string invalid
|
|
576
|
+
#
|
|
577
|
+
def authentication_string(socket_id, custom_string = nil)
|
|
578
|
+
string_to_sign = [socket_id, 'user', custom_string].compact.map(&:to_s).join('::')
|
|
579
|
+
|
|
580
|
+
_authentication_string(socket_id, string_to_sign, authentication_token, string_to_sign)
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
def validate_user_data(user_data)
|
|
584
|
+
return if user_data_valid?(user_data)
|
|
585
|
+
|
|
586
|
+
raise Sockudo::Error, "Invalid user data #{user_data.inspect}"
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
def user_data_valid?(data)
|
|
590
|
+
data.is_a?(Hash) && data.key?(:id) && !data[:id].empty? && data[:id].is_a?(String)
|
|
591
|
+
end
|
|
592
|
+
end
|
|
593
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
require 'pusher-signature'
|
|
2
|
+
require 'digest/md5'
|
|
3
|
+
require 'multi_json'
|
|
4
|
+
|
|
5
|
+
module Sockudo
|
|
6
|
+
class Request
|
|
7
|
+
attr_reader :body, :params
|
|
8
|
+
|
|
9
|
+
def initialize(client, verb, uri, params, body = nil, extra_headers = {})
|
|
10
|
+
@client, @verb, @uri = client, verb, uri
|
|
11
|
+
@head = {
|
|
12
|
+
'X-Pusher-Library' => 'sockudo-http-ruby ' + Sockudo::VERSION
|
|
13
|
+
}
|
|
14
|
+
@head.merge!(extra_headers) if extra_headers && !extra_headers.empty?
|
|
15
|
+
|
|
16
|
+
@body = body
|
|
17
|
+
if body
|
|
18
|
+
params[:body_md5] = Digest::MD5.hexdigest(body)
|
|
19
|
+
@head['Content-Type'] = 'application/json'
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
request = Sockudo::Signature::Request.new(verb.to_s.upcase, uri.path, params)
|
|
23
|
+
request.sign(client.authentication_token)
|
|
24
|
+
@params = request.signed_params
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def send_sync
|
|
28
|
+
http = @client.sync_http_client
|
|
29
|
+
|
|
30
|
+
begin
|
|
31
|
+
response = http.request(@verb, @uri, @params, @body, @head)
|
|
32
|
+
rescue HTTPClient::BadResponseError, HTTPClient::TimeoutError,
|
|
33
|
+
SocketError, Errno::ECONNREFUSED => e
|
|
34
|
+
error = Sockudo::HTTPError.new("#{e.message} (#{e.class})")
|
|
35
|
+
error.original_error = e
|
|
36
|
+
raise error
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
body = response.body ? response.body.chomp : nil
|
|
40
|
+
|
|
41
|
+
return handle_response(response.code.to_i, body)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def send_async
|
|
45
|
+
if defined?(EventMachine) && EventMachine.reactor_running?
|
|
46
|
+
http_client = @client.em_http_client(@uri)
|
|
47
|
+
df = EM::DefaultDeferrable.new
|
|
48
|
+
|
|
49
|
+
http = case @verb
|
|
50
|
+
when :post
|
|
51
|
+
http_client.post({
|
|
52
|
+
:query => @params, :body => @body, :head => @head
|
|
53
|
+
})
|
|
54
|
+
when :get
|
|
55
|
+
http_client.get({
|
|
56
|
+
:query => @params, :head => @head
|
|
57
|
+
})
|
|
58
|
+
else
|
|
59
|
+
raise "Unsupported verb"
|
|
60
|
+
end
|
|
61
|
+
http.callback {
|
|
62
|
+
begin
|
|
63
|
+
df.succeed(handle_response(http.response_header.status, http.response.chomp))
|
|
64
|
+
rescue => e
|
|
65
|
+
df.fail(e)
|
|
66
|
+
end
|
|
67
|
+
}
|
|
68
|
+
http.errback { |e|
|
|
69
|
+
message = "Network error connecting to sockudo (#{http.error})"
|
|
70
|
+
Sockudo.logger.debug(message)
|
|
71
|
+
df.fail(Error.new(message))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return df
|
|
75
|
+
else
|
|
76
|
+
http = @client.sync_http_client
|
|
77
|
+
|
|
78
|
+
return http.request_async(@verb, @uri, @params, @body, @head)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def handle_response(status_code, body)
|
|
85
|
+
case status_code
|
|
86
|
+
when 200
|
|
87
|
+
return symbolize_first_level(MultiJson.decode(body))
|
|
88
|
+
when 202
|
|
89
|
+
return body.empty? ? true : symbolize_first_level(MultiJson.decode(body))
|
|
90
|
+
when 400
|
|
91
|
+
raise Error, "Bad request: #{body}"
|
|
92
|
+
when 401
|
|
93
|
+
raise AuthenticationError, body
|
|
94
|
+
when 404
|
|
95
|
+
raise Error, "404 Not found (#{@uri.path})"
|
|
96
|
+
when 407
|
|
97
|
+
raise Error, "Proxy Authentication Required"
|
|
98
|
+
when 413
|
|
99
|
+
raise Error, "Payload Too Large > 10KB"
|
|
100
|
+
else
|
|
101
|
+
raise Error, "Unknown error (status code #{status_code}): #{body}"
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def symbolize_first_level(hash)
|
|
106
|
+
hash.inject({}) do |result, (key, value)|
|
|
107
|
+
result[key.to_sym] = value
|
|
108
|
+
result
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|