rage-rb 1.3.0 → 1.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cef03d55f96279c7e6d451dc7ce0abce04024a7ca30d07e9c48c0d054c530bb6
4
- data.tar.gz: c399b5b3d060aee5367c2e3064435649bbb8eba7dcb2bf2bd215730a698f69d8
3
+ metadata.gz: 43393d351828980659e1a5e574887b818727744bf679bca2c1e0e0a164848496
4
+ data.tar.gz: d732652d4966c9cdcdd1825ec692b6c88286e467c229a2e4acdb7658e2b0a455
5
5
  SHA512:
6
- metadata.gz: cc5578e1e51d557a8f30729b0eff9a41d4a651e62c837af53aff3b3f50caee2b827be47a239aeadd47f2e9ff15ff8263f35a032634c1fdb738cb2b2c71787780
7
- data.tar.gz: 28a70f5c33752ea33dd1474bc4e481ba7b56fd76541544f838226c9b5ace3758b80d9a93b5947c1ea8cbf9374cbae204e9c0e6c5ff40f2adcbc40be3208f5597
6
+ metadata.gz: e23ca7a0309a24e665850c6c7798a3a4d7cb5dd5d8f239346907bc2be0ae3c6446530e5299e2f5a6b08f773281a8e3c570dc19b9ebe1f6cbe1b0712b643ed688
7
+ data.tar.gz: 9578c4f912c7ce713639acd0c043de9f4b757ff816e60dd5ae9637434699190d8c84af29a0a42fd7c0f0f32ae6724b710429cde6fab20011c23fd79bade36d13
data/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.4.0] - 2024-05-01
4
+
5
+ ### Added
6
+
7
+ - Support cookies and sessions (#69).
8
+
9
+ ### Fixed
10
+
11
+ - Improve compatibility with ActiveRecord 7.1 (#80).
12
+
3
13
  ## [1.3.0] - 2024-04-17
4
14
 
5
15
  ### Added
data/Gemfile CHANGED
@@ -14,3 +14,6 @@ gem "yard"
14
14
  gem "pg"
15
15
  gem "mysql2"
16
16
  gem "connection_pool", "~> 2.0"
17
+
18
+ gem "rbnacl"
19
+ gem "domain_name"
@@ -15,6 +15,14 @@
15
15
  #
16
16
  # > Defines the verbosity of the Rage logger. This option defaults to `:debug` for all environments except production, where it defaults to `:info`. The available log levels are: `:debug`, `:info`, `:warn`, `:error`, `:fatal`, and `:unknown`.
17
17
  #
18
+ # • _config.secret_key_base_
19
+ #
20
+ # > The `secret_key_base` is used as the input secret to the application's key generator, which is used to encrypt cookies. Rage will fall back to the `SECRET_KEY_BASE` environment variable if this is not set.
21
+ #
22
+ # • _config.fallback_secret_key_base_
23
+ #
24
+ # > Defines one or several old secrets that need to be rotated. Can accept a single key or an array of keys. Rage will fall back to the `FALLBACK_SECRET_KEY_BASE` environment variable if this is not set.
25
+ #
18
26
  # # Middleware Configuration
19
27
  #
20
28
  # • _config.middleware.use_
@@ -100,6 +108,7 @@
100
108
  class Rage::Configuration
101
109
  attr_accessor :logger
102
110
  attr_reader :log_formatter, :log_level
111
+ attr_writer :secret_key_base, :fallback_secret_key_base
103
112
 
104
113
  # used in DSL
105
114
  def config = self
@@ -113,6 +122,14 @@ class Rage::Configuration
113
122
  @log_level = level.is_a?(Symbol) ? Logger.const_get(level.to_s.upcase) : level
114
123
  end
115
124
 
125
+ def secret_key_base
126
+ @secret_key_base || ENV["SECRET_KEY_BASE"]
127
+ end
128
+
129
+ def fallback_secret_key_base
130
+ Array(@fallback_secret_key_base || ENV["FALLBACK_SECRET_KEY_BASE"])
131
+ end
132
+
116
133
  def server
117
134
  @server ||= Server.new
118
135
  end
@@ -324,6 +324,18 @@ class RageController::API
324
324
  @response ||= Rage::Response.new(@__headers, @__body)
325
325
  end
326
326
 
327
+ # Get the cookie object. See {Rage::Cookies}.
328
+ # @return [Rage::Cookies]
329
+ def cookies
330
+ @cookies ||= Rage::Cookies.new(@__env, self)
331
+ end
332
+
333
+ # Get the session object. See {Rage::Session}.
334
+ # @return [Rage::Session]
335
+ def session
336
+ @session ||= Rage::Session.new(self)
337
+ end
338
+
327
339
  # Send a response to the client.
328
340
  #
329
341
  # @param json [String, Object] send a json response to the client; objects like arrays will be serialized automatically
@@ -477,4 +489,9 @@ class RageController::API
477
489
  # def append_info_to_payload(payload)
478
490
  # payload[:response] = response.body
479
491
  # end
492
+
493
+ # Reset the entire session. See {Rage::Session}.
494
+ def reset_session
495
+ session.clear
496
+ end
480
497
  end
@@ -0,0 +1,241 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "time"
5
+
6
+ if !defined?(DomainName)
7
+ fail <<~ERR
8
+
9
+ rage-rb depends on domain_name to specify the domain name for cookies. Add the following line to your Gemfile:
10
+ gem "domain_name"
11
+
12
+ ERR
13
+ end
14
+
15
+ class Rage::Cookies
16
+ # @private
17
+ def initialize(env, controller)
18
+ @env = env
19
+ @headers = controller.headers
20
+ @request_cookies = {}
21
+ @parsed = false
22
+
23
+ @jar = SimpleJar
24
+ end
25
+
26
+ # Read a cookie.
27
+ #
28
+ # @param key [Symbol]
29
+ # @return [String]
30
+ def [](key)
31
+ value = request_cookies[key]
32
+ @jar.load(value) if value
33
+ end
34
+
35
+ # Get the number of cookies.
36
+ #
37
+ # @return [Integer]
38
+ def size
39
+ request_cookies.count { |_, v| !v.nil? }
40
+ end
41
+
42
+ # Delete a cookie.
43
+ #
44
+ # @param key [Symbol]
45
+ # @param path [String]
46
+ # @param domain [String]
47
+ def delete(key, path: "/", domain: nil)
48
+ @headers.compare_by_identity
49
+
50
+ @request_cookies[key] = nil
51
+ @headers[set_cookie_key(key)] = Rack::Utils.add_cookie_to_header(nil, key, {
52
+ value: "", expires: Time.at(0), path: path, domain: domain
53
+ })
54
+ end
55
+
56
+ # Returns a jar that'll automatically encrypt cookie values before sending them to the client and will decrypt them
57
+ # for read. If the cookie was tampered with by the user (or a 3rd party), `nil` will be returned.
58
+ #
59
+ # This jar requires that you set a suitable secret for the verification on your app's `secret_key_base`.
60
+ #
61
+ # @example
62
+ # cookies.encrypted[:user_id] = current_user.id
63
+ def encrypted
64
+ dup.tap { |c| c.jar = EncryptedJar }
65
+ end
66
+
67
+ # Returns a jar that'll automatically set the assigned cookies to have an expiration date 20 years from now.
68
+ #
69
+ # @example
70
+ # cookies.permanent[:user_id] = current_user.id
71
+ def permanent
72
+ dup.tap { |c| c.expires = Time.now + 20 * 365 * 24 * 60 * 60 }
73
+ end
74
+
75
+ # Set a cookie.
76
+ #
77
+ # @param key [Symbol]
78
+ # @param value [String, Hash]
79
+ # @option value [String] :path
80
+ # @option value [Boolean] :secure
81
+ # @option value [Boolean] :httponly
82
+ # @option value [nil, :none, :lax, :strict] :same_site
83
+ # @option value [Time] :expires
84
+ # @option value [String, Array<String>, :all] :domain
85
+ # @option value [String] :value
86
+ # @example
87
+ # cookie[:user_id] = current_user.id
88
+ # @example
89
+ # cookie[:user_id] = { value: current_user.id, httponly: true, secure: true }
90
+ def []=(key, value)
91
+ @headers.compare_by_identity
92
+
93
+ unless value.is_a?(Hash)
94
+ serialized_value = @jar.dump(value)
95
+ @request_cookies[key] = serialized_value
96
+ @headers[set_cookie_key(key)] = Rack::Utils.add_cookie_to_header(nil, key, { value: serialized_value, expires: @expires })
97
+ return
98
+ end
99
+
100
+ if domain = value[:domain]
101
+ host = @env["HTTP_HOST"]
102
+
103
+ _domain = if domain.is_a?(String)
104
+ domain
105
+ elsif domain == :all
106
+ DomainName(host).domain
107
+ elsif domain.is_a?(Array)
108
+ host if domain.include?(host)
109
+ end
110
+ end
111
+
112
+ serialized_value = @jar.dump(value[:value])
113
+ cookie = Rack::Utils.add_cookie_to_header(nil, key, {
114
+ path: value[:path],
115
+ secure: value[:secure],
116
+ expires: value[:expires] || @expires,
117
+ httponly: value[:httponly],
118
+ same_site: value[:same_site],
119
+ value: serialized_value,
120
+ domain: _domain
121
+ })
122
+
123
+ @request_cookies[key] = serialized_value
124
+ @headers[set_cookie_key(key)] = cookie
125
+ end
126
+
127
+ def inspect
128
+ cookies = request_cookies.transform_values do |v|
129
+ decoded = Base64.urlsafe_decode64(v) rescue nil
130
+ is_encrypted = decoded&.start_with?(EncryptedJar::PADDING)
131
+
132
+ is_encrypted ? "<encrypted>" : v
133
+ end
134
+
135
+ "#<#{self.class.name} @request_cookies=#{cookies.inspect}"
136
+ end
137
+
138
+ private
139
+
140
+ def request_cookies
141
+ return @request_cookies if @parsed
142
+
143
+ @parsed = true
144
+ if cookie_header = @env["HTTP_COOKIE"]
145
+ cookie_header.split(/; */n).each do |cookie|
146
+ next if cookie.empty?
147
+ key, value = cookie.split("=", 2).yield_self { |k, _| [k.to_sym, _] }
148
+ unless @request_cookies.has_key?(key)
149
+ @request_cookies[key] = (Rack::Utils.unescape(value, Encoding::UTF_8) rescue value)
150
+ end
151
+ end
152
+ end
153
+
154
+ @request_cookies
155
+ end
156
+
157
+ def set_cookie_key(key)
158
+ @set_cookie_keys ||= Hash.new { |hash, key| hash[key] = "Set-Cookie".dup }
159
+ @set_cookie_keys[key]
160
+ end
161
+
162
+ protected
163
+
164
+ attr_writer :jar, :expires
165
+
166
+ ####################
167
+ #
168
+ # Cookie Jars
169
+ #
170
+ ####################
171
+
172
+ class SimpleJar
173
+ def self.load(_)
174
+ _
175
+ end
176
+
177
+ def self.dump(value)
178
+ value.to_s
179
+ end
180
+ end
181
+
182
+ class EncryptedJar
183
+ SALT = "encrypted cookie"
184
+ PADDING = "00"
185
+
186
+ class << self
187
+ def load(value)
188
+ box = primary_box
189
+
190
+ begin
191
+ box.decrypt(Base64.urlsafe_decode64(value).byteslice(2..))
192
+ rescue ArgumentError
193
+ nil
194
+ rescue RbNaCl::CryptoError
195
+ i ||= 0
196
+ if box = fallback_boxes[i]
197
+ i += 1
198
+ retry
199
+ end
200
+ end
201
+ end
202
+
203
+ def dump(value)
204
+ # add two bytes to hold meta information, e.g. in case we
205
+ # need to change the encryption algorithm in the future
206
+ Base64.urlsafe_encode64(PADDING + primary_box.encrypt(value.to_s))
207
+ end
208
+
209
+ private
210
+
211
+ def primary_box
212
+ @primary_box ||= begin
213
+ if !defined?(RbNaCl) || !(Gem::Version.create(RbNaCl::VERSION) >= Gem::Version.create("3.3.0") && Gem::Version.create(RbNaCl::VERSION) < Gem::Version.create("8.0.0"))
214
+ fail <<~ERR
215
+
216
+ rage-rb depends on rbnacl [>= 3.3, < 8.0] to encrypt cookies. Add the following line to your Gemfile:
217
+ gem "rbnacl"
218
+
219
+ ERR
220
+ end
221
+
222
+ unless Rage.config.secret_key_base
223
+ raise "Rage.config.secret_key_base should be set to use encrypted cookies"
224
+ end
225
+
226
+ RbNaCl::SimpleBox.from_secret_key(
227
+ RbNaCl::Hash.blake2b(Rage.config.secret_key_base, digest_size: 32, salt: SALT)
228
+ )
229
+ end
230
+ end
231
+
232
+ def fallback_boxes
233
+ @fallback_boxes ||= begin
234
+ Rage.config.fallback_secret_key_base.map do |key|
235
+ RbNaCl::SimpleBox.from_secret_key(RbNaCl::Hash.blake2b(key, digest_size: 32, salt: SALT))
236
+ end
237
+ end
238
+ end
239
+ end # class << self
240
+ end
241
+ end
@@ -4,7 +4,7 @@ if defined?(ActiveSupport::IsolatedExecutionState)
4
4
  end
5
5
 
6
6
  # release ActiveRecord connections on yield
7
- if defined?(ActiveRecord)
7
+ if defined?(ActiveRecord) && ActiveRecord.version < Gem::Version.create("7.1.0")
8
8
  class Fiber
9
9
  def self.defer
10
10
  res = Fiber.yield
data/lib/rage/fiber.rb CHANGED
@@ -91,7 +91,7 @@ class Fiber
91
91
 
92
92
  # @private
93
93
  # under normal circumstances, the method is a copy of `yield`, but it can be overriden to perform
94
- # additional steps on yielding, e.g. releasing AR connections; see "lib/rage/rails.rb"
94
+ # additional steps on yielding, e.g. releasing AR connections; see "lib/rage/ext/setup.rb"
95
95
  class << self
96
96
  alias_method :defer, :yield
97
97
  end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ class Rage::Session
6
+ # @private
7
+ KEY = Rack::RACK_SESSION.to_sym
8
+
9
+ # @private
10
+ def initialize(controller)
11
+ @cookies = controller.cookies.encrypted
12
+ end
13
+
14
+ # Writes the value to the session.
15
+ #
16
+ # @param key [Symbol]
17
+ # @param value [String]
18
+ def []=(key, value)
19
+ write_session(add: { key => value })
20
+ end
21
+
22
+ # Returns the value of the key stored in the session or `nil` if the given key is not found.
23
+ #
24
+ # @param key [Symbol]
25
+ def [](key)
26
+ read_session[key]
27
+ end
28
+
29
+ # Returns the value of the given key from the session, or raises `KeyError` if the given key is not found
30
+ # and no default value is set. Returns the default value if specified.
31
+ #
32
+ # @param key [Symbol]
33
+ def fetch(key, default = nil, &block)
34
+ if default.nil?
35
+ read_session.fetch(key, &block)
36
+ else
37
+ read_session.fetch(key, default, &block)
38
+ end
39
+ end
40
+
41
+ # Deletes the given key from the session.
42
+ #
43
+ # @param key [Symbol]
44
+ def delete(key)
45
+ write_session(remove: key)
46
+ end
47
+
48
+ # Clears the session.
49
+ def clear
50
+ write_session(clear: true)
51
+ end
52
+
53
+ # Returns the session as Hash.
54
+ def to_hash
55
+ read_session
56
+ end
57
+
58
+ alias_method :to_h, :to_hash
59
+
60
+ def empty?
61
+ read_session.empty?
62
+ end
63
+
64
+ # Returns `true` if the given key is present in the session.
65
+ def has_key?(key)
66
+ read_session.has_key?(key)
67
+ end
68
+
69
+ alias_method :key?, :has_key?
70
+ alias_method :include?, :has_key?
71
+
72
+ def each(&block)
73
+ read_session.each(&block)
74
+ end
75
+
76
+ def dig(*keys)
77
+ read_session.dig(*keys)
78
+ end
79
+
80
+ def inspect
81
+ "#<#{self.class.name} @session=#{to_h.inspect}"
82
+ end
83
+
84
+ private
85
+
86
+ def write_session(add: nil, remove: nil, clear: nil)
87
+ if add
88
+ read_session.merge!(add)
89
+ elsif remove && read_session.has_key?(remove)
90
+ read_session.reject! { |k, _| k == remove }
91
+ elsif clear
92
+ read_session.clear
93
+ end
94
+
95
+ @cookies[KEY] = { httponly: true, same_site: :lax, value: read_session.to_json }
96
+ end
97
+
98
+ def read_session
99
+ @session ||= begin
100
+ JSON.parse(@cookies[KEY] || "{}", symbolize_names: true)
101
+ rescue JSON::ParserError
102
+ {}
103
+ end
104
+ end
105
+ end
data/lib/rage/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rage
4
- VERSION = "1.3.0"
4
+ VERSION = "1.4.0"
5
5
  end
data/lib/rage-rb.rb CHANGED
@@ -102,6 +102,9 @@ module Rage
102
102
  autoload :ConnectionPool, "rage/ext/active_record/connection_pool"
103
103
  end
104
104
  end
105
+
106
+ autoload :Cookies, "rage/cookies"
107
+ autoload :Session, "rage/session"
105
108
  end
106
109
 
107
110
  module RageController
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rage-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.0
4
+ version: 1.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Roman Samoilov
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-04-17 00:00:00.000000000 Z
11
+ date: 2024-05-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: thor
@@ -105,6 +105,7 @@ files:
105
105
  - lib/rage/code_loader.rb
106
106
  - lib/rage/configuration.rb
107
107
  - lib/rage/controller/api.rb
108
+ - lib/rage/cookies.rb
108
109
  - lib/rage/env.rb
109
110
  - lib/rage/errors.rb
110
111
  - lib/rage/ext/active_record/connection_pool.rb
@@ -130,6 +131,7 @@ files:
130
131
  - lib/rage/router/strategies/host.rb
131
132
  - lib/rage/router/util.rb
132
133
  - lib/rage/rspec.rb
134
+ - lib/rage/session.rb
133
135
  - lib/rage/setup.rb
134
136
  - lib/rage/sidekiq_session.rb
135
137
  - lib/rage/templates/Gemfile