cent 4.0.0 → 5.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.
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cent
4
+ # Payloads - data, info, meta and the like - are JSON values: a Hash, Array,
5
+ # String, Integer, Float, true, false or nil, Symbols allowed as strings and
6
+ # Hash keys. Raw JSON - text you already have, or encoded with rules of your
7
+ # own - goes in a +JSON::Fragment+, sent as it is:
8
+ #
9
+ # client.publish(channel: "news", data: { text: "hi" })
10
+ # client.publish(channel: "news", data: JSON::Fragment.new(row["payload_json"]))
11
+ #
12
+ # A String is a JSON string, never raw JSON. Replies give payloads as
13
+ # +JSON.parse+ gives them - Hashes with String keys - which go back unchanged
14
+ # as the same JSON value.
15
+ #
16
+ # @api private
17
+ module Payload
18
+ # Deeper than this, a payload is refused: Centrifugo's decoder stops at
19
+ # 10000 levels, and a value which contains itself never ends.
20
+ MAX_DEPTH = 1000
21
+
22
+ module_function
23
+
24
+ # Checks that a payload is a JSON value, raising InvalidRequestError naming
25
+ # where it is not. The checked value is sent as it is.
26
+ #
27
+ # @return [Object] value
28
+ def check!(value, where)
29
+ # Iterative, not recursive: a deep value must not overflow the stack,
30
+ # which is small on some platforms - a thread's on Windows.
31
+ stack = [[value, where, 0]]
32
+ until stack.empty?
33
+ v, path, depth = stack.pop
34
+ raise InvalidRequestError, "#{path} is nested too deeply, or contains itself" if depth > MAX_DEPTH
35
+
36
+ case v
37
+ when Hash
38
+ v.each do |k, item|
39
+ key!(k, path)
40
+ stack << [item, "#{path}.#{k}", depth + 1]
41
+ end
42
+ when Array then v.each_with_index { |item, i| stack << [item, "#{path}[#{i}]", depth + 1] }
43
+ else scalar!(v, path)
44
+ end
45
+ end
46
+ value
47
+ end
48
+
49
+ def key!(k, path)
50
+ case k
51
+ when String then string!(k, "#{path} key #{k.inspect}")
52
+ when Symbol then nil
53
+ else raise InvalidRequestError, "#{path} has a #{k.class} key, #{k.inspect}: JSON object keys are strings"
54
+ end
55
+ end
56
+
57
+ def scalar!(v, path)
58
+ case v
59
+ when String then string!(v, path)
60
+ when Float
61
+ raise InvalidRequestError, "#{path} is #{v}, which is not a JSON number" unless v.finite?
62
+ when Integer, true, false, nil, Symbol then nil
63
+ when JSON::Fragment then fragment!(v, path)
64
+ else
65
+ raise InvalidRequestError,
66
+ "#{path} is a #{v.class}, which is not a JSON value: convert it - " \
67
+ "or encode the payload yourself and pass the JSON as JSON::Fragment.new(json)"
68
+ end
69
+ end
70
+
71
+ def string!(s, where)
72
+ if s.encoding == Encoding::BINARY
73
+ raise InvalidRequestError,
74
+ "#{where} is a binary (ASCII-8BIT) string: force_encoding(\"UTF-8\") text read as bytes, " \
75
+ "or send binary data base64-encoded in a b64 field, such as b64data"
76
+ end
77
+ raise InvalidRequestError, "#{where} is not valid #{s.encoding}" unless s.valid_encoding?
78
+ end
79
+
80
+ # Raw JSON is checked to be valid JSON, NaN and infinities refused.
81
+ def fragment!(f, where)
82
+ json = f.json
83
+ unless json.is_a?(String)
84
+ raise InvalidRequestError, "#{where} is a JSON::Fragment of a #{json.class}: it holds JSON text"
85
+ end
86
+
87
+ string!(json, where)
88
+ JSON.parse(json, allow_nan: false, max_nesting: false)
89
+ rescue JSON::ParserError => e
90
+ raise InvalidRequestError, "#{where} is a JSON::Fragment which is not valid JSON: #{e.message}"
91
+ end
92
+ end
93
+ end
data/lib/cent/proxy.rb ADDED
@@ -0,0 +1,254 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cent
4
+ # The application side of Centrifugo's HTTP proxy: the requests Centrifugo
5
+ # sends to the backend when a client connects, subscribes, publishes or calls
6
+ # an RPC, the results the backend answers with, and a handler for each proxy
7
+ # type which decodes the request, calls a block and encodes its answer. Each
8
+ # handler is a {Handler}: a Rack app, with a framework-free core.
9
+ #
10
+ # connect = Cent::Proxy::ConnectHandler.new(on_error: ->(_http, err) { logger.error(err) }) do |req, http|
11
+ # user = session_user(http.headers["cookie"]) # passed on with http_headers
12
+ # raise Cent::Proxy::Disconnect.new(4501, "unauthorized") unless user
13
+ #
14
+ # Cent::Proxy::ConnectResult.new(user:) # or a Hash: { user: }
15
+ # end
16
+ # mount connect, at: "/centrifugo/connect" # config/routes.rb
17
+ #
18
+ # and in Centrifugo's configuration:
19
+ #
20
+ # "client": {"proxy": {"connect": {"enabled": true,
21
+ # "endpoint": "http://backend:3000/centrifugo/connect", "http_headers": ["Cookie"]}}}
22
+ #
23
+ # See https://centrifugal.dev/docs/server/proxy.
24
+ #
25
+ # The block gets the decoded request and the {HTTPRequest}, and returns a
26
+ # result - the result type, or a Hash of its fields - to accept the request,
27
+ # or raises:
28
+ #
29
+ # - {ProxyError} rejects the request with that error, which the client
30
+ # receives: use codes in [400, 1999]. The connect, subscribe, publish, RPC,
31
+ # map publish and map remove proxies pass it to the client; the shared poll
32
+ # refresh and channel state proxies take it as a failure.
33
+ # - {Disconnect} closes the client's connection with that code: use codes in
34
+ # [4000, 4999] - [4500, 4999] to tell the client not to reconnect. Only the
35
+ # proxies which pass a ProxyError to the client honour it.
36
+ # - Any other error, or one a proxy cannot carry, fails the request with HTTP
37
+ # status 500, and is passed to +on_error+ for logging - without +on_error+,
38
+ # it is logged nowhere. Centrifugo then
39
+ # answers the client with its temporary internal error; for refresh and sub
40
+ # refresh it keeps the connection or subscription for another minute and
41
+ # asks again.
42
+ #
43
+ # A ProxyError or Disconnect which is the cause of another error counts too.
44
+ # The refresh and sub refresh proxies end a connection or subscription
45
+ # through their result's expired, not an error. A handler answers with
46
+ # exactly one of result, error and disconnect - a block returning nil fails
47
+ # with 500 too. Centrifugo would take a response with none of them as
48
+ # accepting a subscribe, publish, RPC, map publish or map remove as the
49
+ # client sent it; as refusing a connect, unless anonymous connections are
50
+ # allowed; and as a refresh or sub refresh which expired.
51
+ #
52
+ # Payloads - data, info, meta - arrive as JSON.parse gives them, and a result
53
+ # takes them back as they are, or as JSON::Fragment. A proxy with
54
+ # binary_encoding enabled sends them base64-encoded in the b64 fields, with
55
+ # encoding "binary"; a result may answer with either field.
56
+ #
57
+ # Security: Centrifugo trusts what a proxy endpoint answers, and the endpoint
58
+ # trusts what Centrifugo sends - a SubscribeRequest's user is the user
59
+ # Centrifugo authenticated. Anyone else able to reach the endpoint could claim
60
+ # any user. Keep proxy endpoints reachable by Centrifugo only: on a network
61
+ # only Centrifugo reaches; behind a secret header Centrifugo adds to every
62
+ # proxy request - the proxy's static_headers option - which the application
63
+ # checks before the handler; with TLS where the traffic leaves a trusted
64
+ # network.
65
+ #
66
+ # Centrifugo's GRPC proxy is not covered: generate GRPC code from proxy.proto
67
+ # for it.
68
+ module Proxy
69
+ # The default bound of a request's body, 16 MiB: far above what
70
+ # Centrifugo's own limits on client messages let through, it only stops a
71
+ # request which is not Centrifugo's from taking the memory it likes.
72
+ DEFAULT_MAX_BODY = 16 << 20
73
+
74
+ # Raised by a proxy block to reject the client's request with this error:
75
+ # code in [400, 1999], which Centrifugo leaves to applications. temporary
76
+ # tells the client that retrying may succeed.
77
+ class ProxyError < StandardError
78
+ # @return [Integer]
79
+ attr_reader :code
80
+ # @return [String]
81
+ attr_reader :reason
82
+
83
+ # @param code [Integer]
84
+ # @param reason [String] the error's message, sent to the client
85
+ # @param temporary [Boolean]
86
+ def initialize(code, reason = "", temporary: false)
87
+ @code = code
88
+ @reason = reason
89
+ @temporary = temporary
90
+ super("#{reason} (code #{code})")
91
+ end
92
+
93
+ # @return [Boolean]
94
+ def temporary? = @temporary
95
+ end
96
+
97
+ # Raised by a proxy block to close the client's connection: code in [4000,
98
+ # 4999] - [4000, 4499] tells the client to reconnect, [4500, 4999] not to.
99
+ # reason is a short description, at most 32 bytes.
100
+ class Disconnect < StandardError
101
+ # @return [Integer]
102
+ attr_reader :code
103
+ # @return [String]
104
+ attr_reader :reason
105
+
106
+ # @param code [Integer]
107
+ # @param reason [String]
108
+ def initialize(code, reason = "")
109
+ @code = code
110
+ @reason = reason
111
+ super("disconnect: #{reason} (code #{code})")
112
+ end
113
+ end
114
+
115
+ # @api private
116
+ class ProxyHandler < Handler
117
+ # @param on_error [#call, nil] told of every 500 and 400, with the
118
+ # {HTTPRequest} and the error; it must not raise. Pass one: without it,
119
+ # an error in the block is answered with 500 and logged nowhere.
120
+ # @param max_body [Integer] the largest request body read, in bytes
121
+ def initialize(on_error: nil, max_body: DEFAULT_MAX_BODY, &) = super(max_body, on_error, &)
122
+
123
+ private
124
+
125
+ def start(req, body)
126
+ return HTTPResponse.allow("POST") unless req.method == "POST"
127
+
128
+ name, request_class, = self.class::SPEC
129
+ [request_class.from_wire(JSON.parse(body, allow_nan: false, max_nesting: false), "#{name} request")]
130
+ rescue JSON::ParserError, EncodingError, DecodeError => e
131
+ failure(req, 400, ArgumentError.new("proxy: malformed #{name} request: #{e.message}"))
132
+ end
133
+
134
+ def finish(req, _args, out, error)
135
+ name, _, result_class, answers = self.class::SPEC
136
+ if error.nil?
137
+ begin
138
+ wire = { "result" => Wire.message(result_class, out, "result").to_wire("result.") }
139
+ rescue InvalidRequestError => e
140
+ return failure(req, 500, TypeError.new("proxy: the #{name} block returned an invalid result: #{e.message}"))
141
+ end
142
+ else
143
+ d = find(error, Disconnect)
144
+ e = find(error, ProxyError)
145
+ if d && answers == :disconnect
146
+ wire = { "disconnect" => { "code" => d.code, "reason" => d.reason } }
147
+ elsif e && answers != :result
148
+ wire = { "error" => { "code" => e.code, "message" => e.reason } }
149
+ wire["error"]["temporary"] = true if e.temporary?
150
+ elsif d || e
151
+ # Centrifugo would ignore it in this proxy's response.
152
+ return failure(req, 500, ArgumentError.new("proxy: the #{name} proxy cannot answer with #{error.inspect}"))
153
+ else
154
+ return failure(req, 500, error)
155
+ end
156
+ end
157
+ HTTPResponse.new(status: 200, headers: { "content-type" => "application/json" }, body: Client.encode(wire))
158
+ rescue InvalidRequestError => e
159
+ # A payload which is not JSON, say: Centrifugo could not use the
160
+ # response.
161
+ failure(req, 500, e)
162
+ end
163
+ end
164
+
165
+ # The connect proxy: the block authenticates a connecting client and
166
+ # returns its user and initial state. ProxyError rejects the connection;
167
+ # Disconnect closes it with that code.
168
+ class ConnectHandler < ProxyHandler
169
+ # @api private
170
+ SPEC = ["connect", ConnectRequest, ConnectResult, :disconnect].freeze
171
+ end
172
+
173
+ # The refresh proxy: the block decides whether a connection about to expire
174
+ # lives on, and until when. To end it, return a result with expired set.
175
+ # Any error fails the request, and Centrifugo keeps the connection for
176
+ # another minute before asking again.
177
+ class RefreshHandler < ProxyHandler
178
+ # @api private
179
+ SPEC = ["refresh", RefreshRequest, RefreshResult, :result].freeze
180
+ end
181
+
182
+ # The subscribe proxy: the block decides whether a client may subscribe to
183
+ # a channel. ProxyError rejects the subscription; Disconnect closes the
184
+ # connection.
185
+ class SubscribeHandler < ProxyHandler
186
+ # @api private
187
+ SPEC = ["subscribe", SubscribeRequest, SubscribeResult, :disconnect].freeze
188
+ end
189
+
190
+ # The publish proxy: the block decides whether a client may publish into a
191
+ # channel, and may change the publication. ProxyError rejects it;
192
+ # Disconnect closes the connection.
193
+ class PublishHandler < ProxyHandler
194
+ # @api private
195
+ SPEC = ["publish", PublishRequest, PublishResult, :disconnect].freeze
196
+ end
197
+
198
+ # The RPC proxy: the block answers a client's RPC call. ProxyError fails the
199
+ # call; Disconnect closes the connection.
200
+ class RPCHandler < ProxyHandler
201
+ # @api private
202
+ SPEC = ["rpc", RPCRequest, RPCResult, :disconnect].freeze
203
+ end
204
+
205
+ # The sub refresh proxy: the block decides whether a subscription about to
206
+ # expire lives on, and until when. To end it, return a result with expired
207
+ # set. Any error fails the request, and Centrifugo keeps the subscription
208
+ # for another minute before asking again.
209
+ class SubRefreshHandler < ProxyHandler
210
+ # @api private
211
+ SPEC = ["sub refresh", SubRefreshRequest, SubRefreshResult, :result].freeze
212
+ end
213
+
214
+ # The map publish proxy: the block decides whether a client may publish a
215
+ # key into a map channel, and may change the publication.
216
+ class MapPublishHandler < ProxyHandler
217
+ # @api private
218
+ SPEC = ["map publish", MapPublishRequest, MapPublishResult, :disconnect].freeze
219
+ end
220
+
221
+ # The map remove proxy: the block decides whether a client may remove a key
222
+ # from a map channel.
223
+ class MapRemoveHandler < ProxyHandler
224
+ # @api private
225
+ SPEC = ["map remove", MapRemoveRequest, MapRemoveResult, :disconnect].freeze
226
+ end
227
+
228
+ # The shared poll refresh proxy: the block returns the current data of the
229
+ # items tracked in a shared poll channel. ProxyError, or any other error,
230
+ # fails the refresh: Centrifugo tries again at the next interval.
231
+ class SharedPollRefreshHandler < ProxyHandler
232
+ # @api private
233
+ SPEC = ["shared poll refresh", SharedPollRefreshRequest, SharedPollRefreshResult, :error].freeze
234
+ end
235
+
236
+ # The cache empty proxy of Centrifugo PRO: the block learns that a client
237
+ # subscribed to a channel in cache recovery mode with nothing to recover,
238
+ # and may publish the current state - then it returns a result with
239
+ # populated set, for Centrifugo to recover once more. Any error fails the
240
+ # request, and the client which subscribed is disconnected to reconnect.
241
+ class NotifyCacheEmptyHandler < ProxyHandler
242
+ # @api private
243
+ SPEC = ["cache empty", NotifyCacheEmptyRequest, NotifyCacheEmptyResult, :result].freeze
244
+ end
245
+
246
+ # The channel state proxy of Centrifugo PRO, in preview: the block receives
247
+ # channels becoming occupied or vacated. ProxyError, or any other error,
248
+ # fails the request.
249
+ class NotifyChannelStateHandler < ProxyHandler
250
+ # @api private
251
+ SPEC = ["channel state", NotifyChannelStateRequest, NotifyChannelStateResult, :error].freeze
252
+ end
253
+ end
254
+ end
data/lib/cent/reply.rb ADDED
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cent
4
+ # Reading Centrifugo's replies, and the rules checked before sending.
5
+ #
6
+ # @api private
7
+ module Reply
8
+ # Statuses Centrifugo's transport error mode maps errors to.
9
+ TRANSPORT_MODE_STATUSES = [400, 404, 409, 416, 429, 500].freeze
10
+ MIN_ERROR_CODE = 100
11
+ # nil is "no data", as for every keyword: how to publish JSON null.
12
+ NULL_HINT = " (to publish JSON null, pass data: JSON::Fragment.new(\"null\"))"
13
+
14
+ module_function
15
+
16
+ def parse(body, method)
17
+ JSON.parse(body, allow_nan: false, max_nesting: false)
18
+ rescue JSON::ParserError, EncodingError => e
19
+ raise DecodeError, "#{method}: the reply is not JSON: #{e.message}"
20
+ end
21
+
22
+ # The decoded reply of a method: [its result, its error].
23
+ def envelope(body, method)
24
+ d = parse(body, method)
25
+ raise DecodeError, "#{method}: the reply is not a JSON object" unless d.is_a?(Hash)
26
+ return [{}, api_error(d["error"], "error")] unless d["error"].nil?
27
+
28
+ result = d["result"]
29
+ return [{}, nil] if result.nil?
30
+ raise DecodeError, "#{method}: result: expected an object" unless result.is_a?(Hash)
31
+
32
+ [result, nil]
33
+ end
34
+
35
+ # The replies of a batch of n commands.
36
+ def batch(body, n)
37
+ d = parse(body, "batch")
38
+ raise DecodeError, "batch: the reply is not a JSON object" unless d.is_a?(Hash)
39
+ raise api_error(d["error"], "error") unless d["error"].nil?
40
+
41
+ replies = d["replies"] || []
42
+ unless replies.is_a?(Array) && replies.all?(Hash)
43
+ raise DecodeError, "batch: replies: expected an array of objects"
44
+ end
45
+ raise DecodeError, "batch: got #{replies.size} replies for #{n} commands" if replies.size != n
46
+
47
+ replies
48
+ end
49
+
50
+ # The ApiError of an error object in a reply.
51
+ def api_error(v, where)
52
+ d = Wire.obj(v, where)
53
+ ApiError.for_code(Wire.get(d, "code", where, :int), Wire.get(d, "message", where, :str),
54
+ temporary: Wire.get(d, "temporary", where, :bool))
55
+ end
56
+
57
+ # The error of a batch command's reply, if it has one. A PRO method's code
58
+ # 104 hints that the server may be Centrifugo OSS.
59
+ def command_error(reply, i, method)
60
+ return nil if reply["error"].nil?
61
+
62
+ err = api_error(reply["error"], "replies[#{i}].error")
63
+ err.pro_method!(method) if err.code == NotFoundError::CODE && API_METHODS[method]
64
+ err
65
+ end
66
+
67
+ # The error of a reply with a status other than 200: a Centrifugo error in
68
+ # the transport error mode, an HTTPError otherwise.
69
+ def http_error(method, status, body, headers)
70
+ if TRANSPORT_MODE_STATUSES.include?(status)
71
+ d = begin
72
+ JSON.parse(body)
73
+ rescue JSON::ParserError, EncodingError
74
+ nil
75
+ end
76
+ # A body whose code is an HTTP error status - {"code": 429} from a rate
77
+ # limiting gateway - is a gateway's, not Centrifugo's.
78
+ code = d["code"] if d.is_a?(Hash)
79
+ if code.is_a?(Integer) && code >= MIN_ERROR_CODE && !(400..599).cover?(code)
80
+ begin
81
+ return api_error(d, "error")
82
+ rescue DecodeError
83
+ nil
84
+ end
85
+ end
86
+ end
87
+ start = body.byteslice(0, HTTP::MAX_ERROR_BODY_BYTES).strip
88
+ err = (status == 401 ? UnauthorizedError : HTTPError).new(status, start, headers)
89
+ err.pro_method!(method) if API_METHODS[method]
90
+ err
91
+ end
92
+
93
+ # The rules every Centrifugo holds a request to, checked before sending:
94
+ # the fields the proto marks required, and publish's and broadcast's data.
95
+ # Everything else Centrifugo checks itself.
96
+ def validate(method, w)
97
+ Wire.check_required(w, method)
98
+ case method
99
+ when "publish"
100
+ raise InvalidRequestError, "data or b64data is required#{NULL_HINT}" unless w.key?("data") || w["b64data"]
101
+ when "broadcast"
102
+ raise InvalidRequestError, "data or b64data is required#{NULL_HINT}" unless w.key?("data") || w["b64data"]
103
+
104
+ w["channels"].each_with_index { |ch, i| raise InvalidRequestError, "channels[#{i}] is empty" if ch.empty? }
105
+ end
106
+ w
107
+ end
108
+ end
109
+ end
data/lib/cent/retry.rb ADDED
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cent
4
+ # The wait, in seconds, before retrying after a failed attempt, attempt 1
5
+ # being the first failure: +base+ after it, twice as long after each next one,
6
+ # up to +limit+. Each wait is a random time between half of it and all of it,
7
+ # so that clients which failed together do not all retry at the same moment. A
8
+ # negative base counts as zero, a limit below base as base, and an attempt
9
+ # below 1 as 1.
10
+ #
11
+ # In a retry loop of your own - a publication retried with the same
12
+ # idempotency_key:
13
+ #
14
+ # backoff = Cent.exponential_backoff(0.1, 2.0)
15
+ # attempt = 0
16
+ # begin
17
+ # attempt += 1
18
+ # client.publish(channel: "orders:42", data:, idempotency_key: "order-42-paid")
19
+ # rescue Cent::Error => e
20
+ # raise unless Cent.retryable?(e) && attempt < 3
21
+ #
22
+ # sleep(backoff.(attempt))
23
+ # retry
24
+ # end
25
+ #
26
+ # Retry a broadcast with broadcast_with_retry instead: repeating it resends to
27
+ # the channels which already got it.
28
+ #
29
+ # @param base [Numeric] seconds
30
+ # @param limit [Numeric] seconds
31
+ # @return [Proc] taking the attempt, returning seconds
32
+ def self.exponential_backoff(base, limit)
33
+ base = [base.to_f, 0.0].max
34
+ limit = [limit.to_f, base].max
35
+ lambda do |attempt|
36
+ d = base
37
+ (1...[attempt, 1].max).each do
38
+ # A zero base never grows: no need to count the attempts out.
39
+ break if d >= limit || d.zero?
40
+
41
+ d *= 2
42
+ end
43
+ d = [d, limit].min
44
+ (d / 2) + (rand * (d / 2))
45
+ end
46
+ end
47
+
48
+ DEFAULT_BACKOFF = exponential_backoff(0.1, 5.0)
49
+ private_constant :DEFAULT_BACKOFF
50
+
51
+ # @api private
52
+ def self.default_delay(attempt) = DEFAULT_BACKOFF.call(attempt)
53
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module Cent
6
+ # Signatures for shared poll subscriptions. See
7
+ # https://centrifugal.dev/docs/server/shared_poll. Shared poll subscriptions
8
+ # are experimental in Centrifugo, and this module follows their changes.
9
+ module SharedPoll
10
+ module_function
11
+
12
+ # The signature authorizing +user+ to track +keys+ in +channel+ until
13
+ # +expires_at+, as Centrifugo verifies it: "iat:exp:hmac", iat now and exp
14
+ # expires_at in Unix seconds. +expires_at+ is required, as a token's expiry
15
+ # is: a Time, or +:never+ for a signature which never expires - an Integer
16
+ # or a Duration, such as +1.hour+, is refused. +secret+ is Centrifugo's
17
+ # shared_poll.hmac_secret_key; +user+ is the ID of the connection's user,
18
+ # empty for an anonymous one.
19
+ #
20
+ # The signature covers exactly +keys+, in their order: a client tracking
21
+ # other keys, or the same in another order, is refused.
22
+ #
23
+ # Cent::SharedPoll.sign(secret, user: "42", channel: "poll:prices", keys: ["AAPL"], expires_at: Time.now + 3600)
24
+ #
25
+ # @param secret [String]
26
+ # @param user [String]
27
+ # @param channel [String]
28
+ # @param keys [Array<String>]
29
+ # @param expires_at [Time, :never]
30
+ # @return [String]
31
+ def sign(secret, user:, channel:, keys:, expires_at:)
32
+ sign_at(Time.now, secret, user:, channel:, keys:, expires_at:)
33
+ end
34
+
35
+ # sign, at the time +now+: for the spec's test vectors.
36
+ def sign_at(now, secret, user:, channel:, keys:, expires_at:)
37
+ raise Error, "shared poll: the secret must be a non-empty String" unless secret.is_a?(String) && !secret.empty?
38
+
39
+ iat = now.to_i.to_s
40
+ exp = expires_at == :never ? "0" : unix(expires_at).to_s
41
+ keys_hash = OpenSSL::Digest::SHA256.hexdigest(keys.join("\0"))
42
+ # NUL-separated, so that a colon in a user or channel cannot move between
43
+ # fields: ("alice", "news:tech") and ("alice:news", "tech") sign
44
+ # differently.
45
+ msg = [iat, exp, user, channel, keys_hash].join("\0")
46
+ "#{iat}:#{exp}:#{OpenSSL::HMAC.hexdigest("SHA256", secret, msg)}"
47
+ end
48
+
49
+ def unix(t)
50
+ t = t.to_time if !t.is_a?(Time) && t.respond_to?(:to_time)
51
+ unless t.is_a?(Time)
52
+ raise Error, "shared poll: expires_at must be a Time, or :never for a signature which never expires - " \
53
+ "not #{t.class}"
54
+ end
55
+
56
+ t.to_i
57
+ end
58
+
59
+ private_class_method :sign_at, :unix
60
+ end
61
+ end