aris 1.4.2 → 1.5.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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +251 -0
  3. data/README.md +18 -0
  4. data/docs/ADAPTERS.md +478 -0
  5. data/docs/ARCHITECTURE.md +222 -0
  6. data/docs/CONTENT.md +967 -0
  7. data/docs/PERFORMANCE.md +492 -0
  8. data/docs/PLUGIN_DEVELOPMENT.md +688 -0
  9. data/docs/USAGE.md +4998 -0
  10. data/docs/plugins/API_KEY_AUTH.md +232 -0
  11. data/docs/plugins/BASIC_AUTH.md +582 -0
  12. data/docs/plugins/BEARER_AUTH.md +394 -0
  13. data/docs/plugins/CACHE.md +369 -0
  14. data/docs/plugins/COMPRESSION.md +216 -0
  15. data/docs/plugins/COOKIES.md +30 -0
  16. data/docs/plugins/CORS.md +283 -0
  17. data/docs/plugins/CSRF.md +751 -0
  18. data/docs/plugins/ETAG.md +308 -0
  19. data/docs/plugins/FORM_PARSER.md +193 -0
  20. data/docs/plugins/HEALTH_CHECK.md +469 -0
  21. data/docs/plugins/JSON.md +291 -0
  22. data/docs/plugins/MULTIPART.md +427 -0
  23. data/docs/plugins/RATE_LIMITER.md +368 -0
  24. data/docs/plugins/REQUEST_ID.md +369 -0
  25. data/docs/plugins/REQUEST_LOGGER.md +151 -0
  26. data/docs/plugins/SECURITY.md +193 -0
  27. data/docs/plugins/SESSION.md +98 -0
  28. data/lib/aris/adapters/rack/adapter.rb +17 -2
  29. data/lib/aris/adapters/rack/request.rb +29 -11
  30. data/lib/aris/plugins/basic_auth.rb +3 -1
  31. data/lib/aris/plugins/cookies.rb +4 -32
  32. data/lib/aris/plugins/cors.rb +8 -1
  33. data/lib/aris/plugins/csrf.rb +63 -22
  34. data/lib/aris/plugins/flash.rb +3 -1
  35. data/lib/aris/plugins/form_parser.rb +52 -31
  36. data/lib/aris/plugins/multipart.rb +22 -2
  37. data/lib/aris/plugins/request_logger.rb +8 -1
  38. data/lib/aris/plugins/security_headers.rb +8 -1
  39. data/lib/aris/plugins/session.rb +150 -99
  40. data/lib/aris/response_helpers.rb +41 -0
  41. data/lib/aris/version.rb +2 -2
  42. metadata +31 -3
@@ -1,48 +1,89 @@
1
+ # lib/aris/plugins/csrf.rb
2
+ #
3
+ # Session-backed CSRF protection (synchronizer token pattern).
4
+ #
5
+ # Requires the Session plugin earlier in the same `use:` list:
6
+ #
7
+ # use: [:session, :csrf]
8
+ #
9
+ # The generator makes sure the session holds a token and exposes it as
10
+ # `request.csrf_token`. The protection step rejects POST/PUT/PATCH/DELETE
11
+ # requests whose token (form field `_csrf`, or header `X-CSRF-Token`) does
12
+ # not match the one in the session.
13
+ #
14
+ # In a form: input type: "hidden", name: "_csrf", value: request.csrf_token
15
+ # With fetch: headers: { 'X-CSRF-Token': token }
1
16
  require 'securerandom'
17
+ require 'rack/utils'
18
+
2
19
  module Aris
3
20
  module Plugins
4
21
  module CsrfUtility
5
22
  extend self
23
+
24
+ SESSION_KEY = :_csrf_token
25
+ FIELD_NAME = '_csrf'
26
+ HEADER_NAME = 'HTTP_X_CSRF_TOKEN'
27
+
6
28
  def generate_token
7
29
  SecureRandom.urlsafe_base64(32)
8
30
  end
31
+
9
32
  def validate_token(expected, provided)
10
- expected && provided && expected == provided
33
+ return false if expected.nil? || provided.nil?
34
+ expected = expected.to_s
35
+ provided = provided.to_s
36
+ return false if expected.empty? || provided.empty?
37
+ ::Rack::Utils.secure_compare(expected, provided)
38
+ end
39
+
40
+ # The token the client sent, wherever it put it.
41
+ def provided_token(request)
42
+ header = request.respond_to?(:env) && request.env.respond_to?(:[]) ? request.env[HEADER_NAME] : nil
43
+ header ||= request.headers[HEADER_NAME] if request.respond_to?(:headers) && request.headers.respond_to?(:[])
44
+ return header if header && !header.to_s.empty?
45
+
46
+ %i[form_params multipart_params params].each do |reader|
47
+ next unless request.respond_to?(reader)
48
+ value = request.public_send(reader)
49
+ token = value[FIELD_NAME] if value.respond_to?(:[])
50
+ return token if token && !token.to_s.empty?
51
+ end
52
+ nil
11
53
  end
12
54
  end
13
-
14
- CSRF_THREAD_KEY = :aris_csrf_token
55
+
15
56
  FORM_METHODS = %w[POST PUT PATCH DELETE].freeze
16
-
57
+
17
58
  class CsrfTokenGenerator
18
59
  def self.call(request, response)
19
- if request.method == 'GET' || request.method == 'HEAD'
20
- token = CsrfUtility.generate_token
21
- Thread.current[CSRF_THREAD_KEY] = token
60
+ unless request.respond_to?(:session)
61
+ raise ArgumentError, "CSRF protection needs the Session plugin before it: use: [:session, :csrf]"
22
62
  end
63
+ request.session[CsrfUtility::SESSION_KEY] ||= CsrfUtility.generate_token
64
+ token = request.session[CsrfUtility::SESSION_KEY]
65
+ request.define_singleton_method(:csrf_token) { token }
23
66
  nil # Continue pipeline
24
67
  end
25
68
  end
26
-
69
+
27
70
  class CsrfProtection
28
71
  def self.call(request, response)
29
- return nil unless FORM_METHODS.include?(request.method)
30
- expected = Thread.current[CSRF_THREAD_KEY]
31
- provided = request.headers['HTTP_X_CSRF_TOKEN']
32
- unless CsrfUtility.validate_token(expected, provided)
33
- response.status = 403
34
- response.headers['content-type'] = 'text/plain'
35
- response.body = ['CSRF token validation failed']
36
- return response
37
- end
38
-
39
- nil
72
+ return nil unless FORM_METHODS.include?(request.method.to_s.upcase)
73
+ expected = request.respond_to?(:session) ? request.session[CsrfUtility::SESSION_KEY] : nil
74
+ provided = CsrfUtility.provided_token(request)
75
+ return nil if CsrfUtility.validate_token(expected, provided)
76
+
77
+ response.status = 403
78
+ response.headers['content-type'] = 'text/plain'
79
+ response.body = ['CSRF token validation failed']
80
+ response
40
81
  end
41
82
  end
42
-
43
83
  end
44
84
  end
45
- Aris.register_plugin(:csrf,
85
+
86
+ Aris.register_plugin(:csrf,
46
87
  generator: Aris::Plugins::CsrfTokenGenerator,
47
88
  protection: Aris::Plugins::CsrfProtection
48
- )
89
+ )
@@ -121,4 +121,6 @@ end
121
121
  end
122
122
  end
123
123
  end
124
- end
124
+ end
125
+ # Self-register so `use: [:flash]` works without an explicit register_plugin call.
126
+ Aris.register_plugin(:flash, plugin_class: Aris::Plugins::Flash)
@@ -3,43 +3,64 @@ require 'rack/utils'
3
3
 
4
4
  module Aris
5
5
  module Plugins
6
+ # Parses application/x-www-form-urlencoded bodies.
7
+ #
8
+ # use: [:form_parser]
9
+ #
10
+ # Handlers read fields from `request.form_params` (form fields only) or
11
+ # `request.params` (query string merged with form fields).
6
12
  class FormParser
7
13
  attr_reader :config
8
-
14
+
9
15
  PARSEABLE_METHODS = %w[POST PUT PATCH].freeze
10
-
16
+ CONTENT_TYPE = 'application/x-www-form-urlencoded'.freeze
17
+
11
18
  def initialize(**config)
12
19
  @config = config
13
20
  end
14
-
15
- def self.call(request, response)
16
- return nil unless PARSEABLE_METHODS.include?(request.method)
17
-
18
- content_type = request.env['CONTENT_TYPE']
19
- return nil unless content_type&.include?('application/x-www-form-urlencoded')
20
-
21
- raw_body = request.body
22
- return nil if raw_body.nil? || raw_body.empty?
23
-
24
- begin
25
- data = ::Rack::Utils.parse_nested_query(raw_body)
26
- request.instance_variable_set(:@parsed_form_data, data)
27
-
28
- # Add clean accessor method
29
- request.define_singleton_method(:form_params) do
30
- @parsed_form_data || {}
31
- end
32
-
33
- rescue => e
34
- response.status = 400
35
- response.headers['content-type'] = 'text/plain'
36
- response.body = ['Invalid form data']
37
- return response
38
- end
39
-
40
- nil
41
- end
42
-
21
+
22
+ def self.call(request, response)
23
+ return nil unless PARSEABLE_METHODS.include?(request.method.to_s.upcase)
24
+ return nil unless form_request?(request)
25
+
26
+ raw_body = request.body
27
+ return nil if raw_body.nil? || raw_body.empty?
28
+
29
+ begin
30
+ data = ::Rack::Utils.parse_nested_query(raw_body)
31
+ rescue StandardError
32
+ response.status = 400
33
+ response.headers['content-type'] = 'text/plain'
34
+ response.body = ['Invalid form data']
35
+ return response
36
+ end
37
+
38
+ request.instance_variable_set(:@parsed_form_data, data)
39
+ request.define_singleton_method(:form_params) { @parsed_form_data || {} }
40
+
41
+ # Merge into params so `request.params['field']` works for forms too.
42
+ if request.respond_to?(:params)
43
+ merged = request.params.merge(data)
44
+ request.instance_variable_set(:@params, merged)
45
+ end
46
+
47
+ nil
48
+ end
49
+
50
+ # Works with both adapters: Rack keeps CONTENT_TYPE in env, the Mock
51
+ # adapter keeps it in headers.
52
+ def self.form_request?(request)
53
+ content_type = nil
54
+ content_type = request.env['CONTENT_TYPE'] if request.respond_to?(:env) && request.env.respond_to?(:[])
55
+ content_type ||= request.headers['CONTENT_TYPE'] if request.respond_to?(:headers) && request.headers.respond_to?(:[])
56
+ content_type.to_s.include?(CONTENT_TYPE)
57
+ end
58
+
59
+ # Instances (from .build) behave exactly like the class.
60
+ def call(request, response)
61
+ self.class.call(request, response)
62
+ end
63
+
43
64
  def self.build(**config)
44
65
  new(**config)
45
66
  end
@@ -16,10 +16,23 @@ module Aris
16
16
  @allowed_extensions = config[:allowed_extensions] # nil = all allowed
17
17
  end
18
18
 
19
+ # Works as a class (`use: [:multipart]`, defaults) or an instance
20
+ # (`use: [Multipart.build(max_file_size: ..., allowed_extensions: [...])]`).
21
+ def self.call(request, response)
22
+ (@default_instance ||= new).call(request, response)
23
+ end
24
+
25
+ def self.content_type_of(request)
26
+ content_type = nil
27
+ content_type = request.env['CONTENT_TYPE'] if request.respond_to?(:env) && request.env.respond_to?(:[])
28
+ content_type ||= request.headers['CONTENT_TYPE'] if request.respond_to?(:headers) && request.headers.respond_to?(:[])
29
+ content_type
30
+ end
31
+
19
32
  def call(request, response)
20
- return nil unless PARSEABLE_METHODS.include?(request.method)
33
+ return nil unless PARSEABLE_METHODS.include?(request.method.to_s.upcase)
21
34
 
22
- content_type = request.env['CONTENT_TYPE']
35
+ content_type = self.class.content_type_of(request)
23
36
  return nil unless content_type&.include?('multipart/form-data')
24
37
 
25
38
  # Extract boundary from content type
@@ -69,6 +82,13 @@ module Aris
69
82
 
70
83
  # Attach parsed data to request
71
84
  request.instance_variable_set(:@multipart_data, parts)
85
+ fields = parts.each_with_object({}) { |p, h| h[p[:name]] = p[:data] if p[:type] == :field }
86
+ request.define_singleton_method(:multipart_data) { @multipart_data || [] }
87
+ request.define_singleton_method(:multipart_params) { fields }
88
+ request.define_singleton_method(:multipart_files) { (@multipart_data || []).select { |p| p[:type] == :file } }
89
+ if request.respond_to?(:params)
90
+ request.instance_variable_set(:@params, request.params.merge(fields))
91
+ end
72
92
 
73
93
  rescue => e
74
94
  response.status = 400
@@ -38,6 +38,13 @@ module Aris
38
38
  def self.build(**config)
39
39
  new(**config)
40
40
  end
41
+
42
+ # `use: [:request_logger]` (the bare symbol) runs an instance with default options.
43
+ def self.call(request, response)
44
+ (@default_instance ||= new).call(request, response)
45
+ end
41
46
  end
42
47
  end
43
- end
48
+ end
49
+ # Self-register so `use: [:request_logger]` works without an explicit register_plugin call.
50
+ Aris.register_plugin(:request_logger, plugin_class: Aris::Plugins::RequestLogger)
@@ -30,6 +30,11 @@ module Aris
30
30
  def self.build(**config)
31
31
  new(**config)
32
32
  end
33
+
34
+ # `use: [:security_headers]` (the bare symbol) runs an instance with default options.
35
+ def self.call(request, response)
36
+ (@default_instance ||= new).call(request, response)
37
+ end
33
38
 
34
39
  private
35
40
 
@@ -96,4 +101,6 @@ def build_headers(config)
96
101
  end
97
102
  end
98
103
  end
99
- end
104
+ end
105
+ # Self-register so `use: [:security_headers]` works without an explicit register_plugin call.
106
+ Aris.register_plugin(:security_headers, plugin_class: Aris::Plugins::SecurityHeaders)
@@ -1,168 +1,219 @@
1
1
  # lib/aris/plugins/session.rb
2
+ #
3
+ # Cookie sessions that can carry authentication.
4
+ #
5
+ # The cookie holds an AES-256-GCM encrypted, authenticated blob keyed from
6
+ # Aris::Config.secret_key_base. A client cannot read the session contents
7
+ # and cannot forge or modify them: any tampering, wrong key, or expired
8
+ # session simply loads as an empty session.
9
+ #
10
+ # Aris.configure { |c| c.secret_key_base = ENV.fetch('SECRET_KEY_BASE') }
11
+ #
12
+ # Aris.routes({
13
+ # "example.com": {
14
+ # use: [:session],
15
+ # "/login": { post: { to: ->(req, res, prm) { req.session[:user_id] = 1; res.redirect('/') } } },
16
+ # "/logout": { post: { to: ->(req, res, prm) { req.session.destroy; res.redirect('/') } } },
17
+ # }
18
+ # })
19
+ #
20
+ # Options (Session.default_config, or Session.build(**opts) for a per-scope
21
+ # instance): key, expire_after (seconds), secret, secure, same_site, path.
2
22
  require 'json'
3
23
  require 'base64'
4
24
  require 'openssl'
25
+ require 'securerandom'
5
26
 
6
27
  module Aris
7
28
  module Plugins
8
29
  class Session
30
+ CIPHER = 'aes-256-gcm'
31
+ IV_BYTES = 12
32
+ TAG_BYTES = 16
33
+ MIN_SECRET_BYTES = 32
34
+
9
35
  @default_config = {
10
36
  enabled: true,
11
37
  store: :cookie,
12
38
  key: '_aris_session',
13
39
  expire_after: 14 * 24 * 3600, # 2 weeks in seconds
14
- secret: nil
40
+ secret: nil,
41
+ secure: nil, # nil = Config.cookie_options[:secure], or true in production
42
+ same_site: :lax,
43
+ path: '/'
15
44
  }
16
-
45
+
17
46
  class << self
18
47
  attr_accessor :default_config
19
-
48
+
49
+ # Plugin protocol (class used directly: `use: [:session]`)
20
50
  def call(request, response)
21
- load_session(request)
51
+ load_session(request, effective_config)
22
52
  nil
23
53
  end
24
-
54
+
25
55
  def call_response(request, response)
26
56
  return unless request.respond_to?(:session)
27
-
28
- store_session(request, response)
57
+ store_session(request, response, effective_config)
29
58
  end
30
-
59
+
60
+ # `use: [Aris::Plugins::Session.build(key: '_myapp', expire_after: 3600)]`
31
61
  def build(**config)
32
- config = default_config.merge(config)
33
- config[:secret] ||= Aris::Config.secret_key_base
34
- new(config)
35
- end
36
-
37
- private
38
-
39
- def load_session(request)
40
- session_data = load_from_store(request)
41
-
42
- request.define_singleton_method(:session) do
43
- @session ||= SessionData.new(session_data)
44
- end
62
+ new(default_config.merge(config))
45
63
  end
46
-
47
- def store_session(request, response)
48
- return unless request.session.changed? || request.session.destroyed?
49
-
50
- if request.session.destroyed?
51
- clear_from_store(request, response)
52
- else
53
- save_to_store(request, response, request.session.to_hash)
54
- end
64
+
65
+ def effective_config
66
+ default_config
67
+ end
68
+
69
+ # --- implementation shared by the class and instances ---------------
70
+
71
+ def load_session(request, config)
72
+ data = load_from_cookie(request, config)
73
+ request.define_singleton_method(:session) { @session ||= SessionData.new(data) }
55
74
  end
56
-
57
- def load_from_store(request)
58
- case default_config[:store]
59
- when :cookie
60
- load_from_cookie(request)
75
+
76
+ def store_session(request, response, config)
77
+ session = request.session
78
+ return unless session.changed? || session.destroyed?
79
+ if session.destroyed? || session.to_hash.empty?
80
+ response.delete_cookie(config[:key], path: config[:path])
61
81
  else
62
- {} # Default empty session
82
+ response.set_cookie(config[:key], encrypt(session.to_hash, config), cookie_options(config))
63
83
  end
64
84
  end
65
-
66
- def save_to_store(request, response, data)
67
- case default_config[:store]
68
- when :cookie
69
- save_to_cookie(request, response, data)
70
- end
85
+
86
+ def load_from_cookie(request, config)
87
+ return {} unless request.respond_to?(:cookies)
88
+ raw = request.cookies[config[:key]]
89
+ return {} if raw.nil? || raw.empty?
90
+ decrypt(raw, config) || {}
71
91
  end
72
-
73
- def clear_from_store(request, response)
74
- case default_config[:store]
75
- when :cookie
76
- clear_cookie(response)
77
- end
92
+
93
+ def cookie_options(config)
94
+ secure = config[:secure]
95
+ secure = (Aris::Config.cookie_options || {})[:secure] if secure.nil?
96
+ secure = (ENV['RACK_ENV'] == 'production') if secure.nil?
97
+ {
98
+ httponly: true,
99
+ secure: secure ? true : false,
100
+ same_site: config[:same_site],
101
+ path: config[:path],
102
+ max_age: config[:expire_after]
103
+ }
78
104
  end
79
-
80
- def load_from_cookie(request)
81
- return {} unless request.respond_to?(:cookies)
82
-
83
- cookie_value = request.cookies[default_config[:key]]
84
- return {} unless cookie_value
85
-
86
- begin
87
- # For encrypted sessions
88
- decrypt_session(cookie_value)
89
- rescue
90
- {} # Invalid session, start fresh
105
+
106
+ def secret_for(config)
107
+ secret = config[:secret] || Aris::Config.secret_key_base
108
+ if secret.nil? || secret.to_s.bytesize < MIN_SECRET_BYTES
109
+ raise ArgumentError,
110
+ "Aris sessions need Aris::Config.secret_key_base (or session secret:) of at least #{MIN_SECRET_BYTES} bytes"
91
111
  end
112
+ secret.to_s
92
113
  end
93
-
94
- def save_to_cookie(request, response, data)
95
- return if data.empty?
96
-
97
- encrypted_data = encrypt_session(data)
98
- response.set_cookie(default_config[:key], encrypted_data, {
99
- httponly: true,
100
- secure: (ENV['RACK_ENV'] == 'production'),
101
- path: '/',
102
- max_age: default_config[:expire_after]
103
- })
104
- end
105
-
106
- def clear_cookie(response)
107
- response.delete_cookie(default_config[:key])
108
- end
109
-
110
- def encrypt_session(data)
111
- # Simple encryption for demo - use proper encryption in production
112
- json_data = data.to_json
113
- Base64.urlsafe_encode64(json_data)
114
- end
115
-
116
- def decrypt_session(encrypted_data)
117
- json_data = Base64.urlsafe_decode64(encrypted_data)
118
- JSON.parse(json_data, symbolize_names: true)
114
+
115
+ def derived_key(config)
116
+ OpenSSL::HMAC.digest('SHA256', secret_for(config), 'aris.session.v1')
117
+ end
118
+
119
+ def encrypt(data, config)
120
+ payload = data.merge('_exp' => Time.now.to_i + config[:expire_after].to_i)
121
+ cipher = OpenSSL::Cipher.new(CIPHER).encrypt
122
+ cipher.key = derived_key(config)
123
+ iv = cipher.random_iv
124
+ cipher.auth_data = ''
125
+ ciphertext = cipher.update(JSON.generate(payload)) + cipher.final
126
+ Base64.urlsafe_encode64(iv + cipher.auth_tag + ciphertext, padding: false)
127
+ end
128
+
129
+ def decrypt(raw, config)
130
+ blob = Base64.urlsafe_decode64(raw)
131
+ return nil if blob.bytesize <= IV_BYTES + TAG_BYTES
132
+ iv = blob.byteslice(0, IV_BYTES)
133
+ tag = blob.byteslice(IV_BYTES, TAG_BYTES)
134
+ ciphertext = blob.byteslice(IV_BYTES + TAG_BYTES, blob.bytesize)
135
+ cipher = OpenSSL::Cipher.new(CIPHER).decrypt
136
+ cipher.key = derived_key(config)
137
+ cipher.iv = iv
138
+ cipher.auth_tag = tag
139
+ cipher.auth_data = ''
140
+ json = cipher.update(ciphertext) + cipher.final
141
+ payload = JSON.parse(json, symbolize_names: true)
142
+ return nil unless payload.is_a?(Hash)
143
+ exp = payload.delete(:_exp)
144
+ return nil if exp && Time.now.to_i > exp.to_i
145
+ payload
146
+ rescue ArgumentError, OpenSSL::Cipher::CipherError, JSON::ParserError, TypeError
147
+ nil # bad base64, tampered, wrong key, or garbage: start a fresh session
119
148
  end
120
149
  end
121
-
150
+
122
151
  def initialize(config)
123
152
  @config = config
124
153
  end
125
-
154
+
155
+ attr_reader :config
156
+
157
+ # Plugin protocol (instance from Session.build)
158
+ def call(request, response)
159
+ self.class.load_session(request, @config)
160
+ nil
161
+ end
162
+
163
+ def call_response(request, response)
164
+ return unless request.respond_to?(:session)
165
+ self.class.store_session(request, response, @config)
166
+ end
167
+
126
168
  # Session data container
127
169
  class SessionData
128
170
  def initialize(initial_data = {})
129
- @data = initial_data || {}
171
+ @data = (initial_data || {}).transform_keys { |k| k.to_sym }
130
172
  @changed = false
131
173
  @destroyed = false
132
174
  end
133
-
175
+
134
176
  def [](key)
135
177
  @data[key.to_sym]
136
178
  end
137
-
179
+
138
180
  def []=(key, value)
139
181
  @data[key.to_sym] = value
140
182
  @changed = true
141
183
  end
142
-
184
+
185
+ def fetch(key, *default, &block)
186
+ @data.fetch(key.to_sym, *default, &block)
187
+ end
188
+
189
+ def key?(key)
190
+ @data.key?(key.to_sym)
191
+ end
192
+
143
193
  def delete(key)
144
- @data.delete(key.to_sym)
145
194
  @changed = true
195
+ @data.delete(key.to_sym)
146
196
  end
147
-
197
+
148
198
  def clear
149
199
  @data.clear
150
200
  @changed = true
151
201
  end
152
-
202
+
153
203
  def destroy
154
204
  clear
155
205
  @destroyed = true
156
206
  end
157
-
207
+
158
208
  def to_hash
159
209
  @data.dup
160
210
  end
161
-
211
+ alias to_h to_hash
212
+
162
213
  def changed?
163
214
  @changed
164
215
  end
165
-
216
+
166
217
  def destroyed?
167
218
  @destroyed
168
219
  end
@@ -172,4 +223,4 @@ module Aris
172
223
  end
173
224
 
174
225
  # Register the plugin
175
- Aris.register_plugin(:session, plugin_class: Aris::Plugins::Session)
226
+ Aris.register_plugin(:session, plugin_class: Aris::Plugins::Session)
@@ -1,5 +1,6 @@
1
1
  # lib/aris/response_helpers.rb
2
2
  require 'json'
3
+ require 'time'
3
4
 
4
5
  module Aris
5
6
  module ResponseHelpers
@@ -73,6 +74,37 @@ module Aris
73
74
  redirect(path, status: status)
74
75
  end
75
76
 
77
+ # --- Cookies -----------------------------------------------------------
78
+ # Available on every response (Rack and Mock). Emits Rack 3 compliant
79
+ # headers: the key is lowercase `set-cookie`, and several cookies become
80
+ # an Array of values instead of a comma-joined string (which browsers
81
+ # cannot parse).
82
+ #
83
+ # res.set_cookie('theme', 'dark', max_age: 3600)
84
+ # res.set_cookie('sid', token, httponly: true, secure: true, same_site: :lax)
85
+ # res.delete_cookie('sid')
86
+ #
87
+ # Defaults come from Aris::Config.cookie_options.
88
+ def set_cookie(name, value, options = {})
89
+ merged = (Aris::Config.cookie_options || {}).merge(options)
90
+ parts = ["#{name}=#{value}"]
91
+ parts << "Domain=#{merged[:domain]}" if merged[:domain]
92
+ parts << "Path=#{merged[:path]}" if merged[:path]
93
+ parts << "Max-Age=#{merged[:max_age]}" if merged.key?(:max_age) && !merged[:max_age].nil?
94
+ parts << "Expires=#{merged[:expires].httpdate}" if merged[:expires].respond_to?(:httpdate)
95
+ parts << "HttpOnly" if merged[:httponly]
96
+ parts << "Secure" if merged[:secure]
97
+ if merged[:same_site]
98
+ parts << "SameSite=#{merged[:same_site].to_s.capitalize}"
99
+ end
100
+ add_set_cookie_header(parts.join('; '))
101
+ self
102
+ end
103
+
104
+ def delete_cookie(name, options = {})
105
+ set_cookie(name, '', options.merge(max_age: 0, expires: Time.at(0).utc))
106
+ end
107
+
76
108
  # No content response
77
109
  def no_content
78
110
  self.status = 204
@@ -115,6 +147,15 @@ module Aris
115
147
  end
116
148
 
117
149
  private
150
+
151
+ def add_set_cookie_header(cookie_string)
152
+ existing = headers['set-cookie']
153
+ headers['set-cookie'] = case existing
154
+ when nil then cookie_string
155
+ when Array then existing + [cookie_string]
156
+ else [existing, cookie_string]
157
+ end
158
+ end
118
159
 
119
160
  def detect_content_type(file_path)
120
161
  # Handle Tempfile paths that may have random extensions