hanami-action 3.0.0.rc1

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 (38) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +985 -0
  3. data/LICENSE +20 -0
  4. data/README.md +873 -0
  5. data/hanami-action.gemspec +39 -0
  6. data/lib/hanami/action/body_parser/json.rb +20 -0
  7. data/lib/hanami/action/body_parser/multipart_form.rb +22 -0
  8. data/lib/hanami/action/body_parser.rb +109 -0
  9. data/lib/hanami/action/cache/cache_control.rb +84 -0
  10. data/lib/hanami/action/cache/conditional_get.rb +101 -0
  11. data/lib/hanami/action/cache/directives.rb +126 -0
  12. data/lib/hanami/action/cache/expires.rb +84 -0
  13. data/lib/hanami/action/cache.rb +29 -0
  14. data/lib/hanami/action/config/formats.rb +256 -0
  15. data/lib/hanami/action/config.rb +172 -0
  16. data/lib/hanami/action/constants.rb +283 -0
  17. data/lib/hanami/action/cookie_jar.rb +214 -0
  18. data/lib/hanami/action/cookies.rb +27 -0
  19. data/lib/hanami/action/csrf_protection.rb +217 -0
  20. data/lib/hanami/action/errors.rb +109 -0
  21. data/lib/hanami/action/flash.rb +176 -0
  22. data/lib/hanami/action/halt.rb +18 -0
  23. data/lib/hanami/action/mime/request_mime_weight.rb +66 -0
  24. data/lib/hanami/action/mime.rb +438 -0
  25. data/lib/hanami/action/params.rb +342 -0
  26. data/lib/hanami/action/rack/file.rb +41 -0
  27. data/lib/hanami/action/rack_utils.rb +11 -0
  28. data/lib/hanami/action/request/session.rb +68 -0
  29. data/lib/hanami/action/request.rb +141 -0
  30. data/lib/hanami/action/response.rb +481 -0
  31. data/lib/hanami/action/session.rb +47 -0
  32. data/lib/hanami/action/validatable.rb +166 -0
  33. data/lib/hanami/action/version.rb +13 -0
  34. data/lib/hanami/action/view_name_inferrer.rb +56 -0
  35. data/lib/hanami/action.rb +672 -0
  36. data/lib/hanami/http/status.rb +149 -0
  37. data/lib/hanami-action.rb +3 -0
  38. metadata +153 -0
@@ -0,0 +1,214 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack/utils"
4
+ require "hanami/utils/hash"
5
+
6
+ module Hanami
7
+ class Action
8
+ # A set of HTTP Cookies
9
+ #
10
+ # It acts as an Hash
11
+ #
12
+ # @since 0.1.0
13
+ #
14
+ # @see Hanami::Action::Cookies#cookies
15
+ class CookieJar
16
+ # @since 0.4.5
17
+ # @api private
18
+ COOKIE_SEPARATOR = ";,"
19
+
20
+ # Initialize the CookieJar
21
+ #
22
+ # @param env [Hash] a raw Rack env
23
+ # @param headers [Hash] the response headers
24
+ #
25
+ # @return [CookieJar]
26
+ #
27
+ # @since 0.1.0
28
+ def initialize(env, headers, default_options)
29
+ @_headers = headers
30
+ @cookies = Utils::Hash.deep_symbolize(extract(env))
31
+ @default_options = default_options
32
+ end
33
+
34
+ # Finalize itself, by setting the proper headers to add and remove
35
+ # cookies, before the response is returned to the webserver.
36
+ #
37
+ # @return [void]
38
+ #
39
+ # @since 0.1.0
40
+ #
41
+ # @see Hanami::Action::Cookies#finish
42
+ def finish
43
+ @cookies.delete(Action::RACK_SESSION)
44
+ if changed?
45
+ @cookies.each do |k, v|
46
+ next unless changed?(k)
47
+
48
+ v.nil? ? delete_cookie(k) : set_cookie(k, _merge_default_values(v))
49
+ end
50
+ end
51
+ end
52
+
53
+ # Returns the object associated with the given key
54
+ #
55
+ # @param key [Symbol] the key
56
+ #
57
+ # @return [Object,nil] return the associated object, if found
58
+ #
59
+ # @since 0.2.0
60
+ def [](key)
61
+ @cookies[key]
62
+ end
63
+
64
+ # Associate the given value with the given key and store them
65
+ #
66
+ # @param key [Symbol] the key
67
+ # @param value [#to_s,Hash] value that can be serialized as a string or
68
+ # expressed as a Hash
69
+ # @option value [String] :value - Value of the cookie
70
+ # @option value [String] :domain - The domain
71
+ # @option value [String] :path - The path
72
+ # @option value [Integer] :max_age - Duration expressed in seconds
73
+ # @option value [Time] :expires - Expiration time
74
+ # @option value [TrueClass,FalseClass] :secure - Restrict cookie to secure
75
+ # connections
76
+ # @option value [TrueClass,FalseClass] :httponly - Restrict JavaScript
77
+ # access
78
+ #
79
+ # @return [void]
80
+ #
81
+ # @since 0.2.0
82
+ #
83
+ # @see http://en.wikipedia.org/wiki/HTTP_cookie
84
+ def []=(key, value)
85
+ changes << key
86
+ @cookies[key] = value
87
+ end
88
+
89
+ # Iterates cookies
90
+ #
91
+ # @param blk [Proc] the block to be yielded
92
+ # @yield [key, value] the key/value pair for each cookie
93
+ #
94
+ # @return [void]
95
+ #
96
+ # @since 1.1.0
97
+ #
98
+ # @example
99
+ # require "hanami/action"
100
+ # class MyAction < Hanami::Action
101
+ # include Hanami::Action::Cookies
102
+ #
103
+ # def handle(req, res)
104
+ # # read cookies
105
+ # req.cookies.each do |key, value|
106
+ # # ...
107
+ # end
108
+ # end
109
+ # end
110
+ def each(&blk)
111
+ @cookies.each(&blk)
112
+ end
113
+
114
+ private
115
+
116
+ # Keep track of changed keys
117
+ #
118
+ # @since 0.7.0
119
+ # @api private
120
+ def changes
121
+ @changes ||= Set.new
122
+ end
123
+
124
+ # Check if the entire set of cookies has changed within the current request.
125
+ # If <tt>key</tt> is given, it checks the associated cookie has changed.
126
+ #
127
+ # @since 0.7.0
128
+ # @api private
129
+ def changed?(key = nil)
130
+ if key.nil?
131
+ changes.any?
132
+ else
133
+ changes.include?(key)
134
+ end
135
+ end
136
+
137
+ # Merge default cookies options with values provided by user
138
+ #
139
+ # Cookies values provided by user are respected
140
+ #
141
+ # @since 0.4.0
142
+ # @api private
143
+ def _merge_default_values(value)
144
+ cookies_options = if value.is_a?(::Hash)
145
+ value.merge! _add_expires_option(value)
146
+ else
147
+ {value: value}
148
+ end
149
+ @default_options.merge cookies_options
150
+ end
151
+
152
+ # Add expires option to cookies if :max_age presents
153
+ #
154
+ # @since 0.4.3
155
+ # @api private
156
+ def _add_expires_option(value)
157
+ if value.key?(:max_age) && !value.key?(:expires)
158
+ {expires: (Time.now + value[:max_age])}
159
+ else
160
+ {}
161
+ end
162
+ end
163
+
164
+ # Extract the cookies from the raw Rack env.
165
+ #
166
+ # This implementation is borrowed from Rack::Request#cookies.
167
+ #
168
+ # @since 0.1.0
169
+ # @api private
170
+ def extract(env)
171
+ hash = env[Action::COOKIE_HASH_KEY] ||= {}
172
+ string = env[Action::HTTP_COOKIE]
173
+
174
+ return hash if string == env[Action::COOKIE_STRING_KEY]
175
+
176
+ # TODO: Next Rack 1.7.x ?? version will have ::Rack::Utils.parse_cookies
177
+ # We can then replace the following lines.
178
+ hash.clear
179
+
180
+ # According to RFC 2109:
181
+ # If multiple cookies satisfy the criteria above, they are ordered in
182
+ # the Cookie header such that those with more specific Path attributes
183
+ # precede those with less specific. Ordering with respect to other
184
+ # attributes (e.g., Domain) is unspecified.
185
+ cookies = ::Rack::Utils.parse_query(string, COOKIE_SEPARATOR) { |s|
186
+ begin
187
+ ::Rack::Utils.unescape(s)
188
+ rescue StandardError
189
+ s
190
+ end
191
+ }
192
+ cookies.each { |k, v| hash[k] = v.is_a?(Array) ? v.first : v }
193
+ env[Action::COOKIE_STRING_KEY] = string
194
+ hash
195
+ end
196
+
197
+ # Set a cookie in the headers
198
+ #
199
+ # @since 0.1.0
200
+ # @api private
201
+ def set_cookie(key, value)
202
+ ::Rack::Utils.set_cookie_header!(@_headers, key, value)
203
+ end
204
+
205
+ # Remove a cookie from the headers
206
+ #
207
+ # @since 0.1.0
208
+ # @api private
209
+ def delete_cookie(key)
210
+ ::Rack::Utils.delete_cookie_header!(@_headers, key, {})
211
+ end
212
+ end
213
+ end
214
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hanami
4
+ class Action
5
+ # Cookies API
6
+ #
7
+ # This module isn't included by default.
8
+ #
9
+ # @since 0.1.0
10
+ #
11
+ # @see Hanami::Action::Cookies#cookies
12
+ module Cookies
13
+ private
14
+
15
+ # Finalize the response by flushing cookies into the response
16
+ #
17
+ # @since 0.1.0
18
+ # @api private
19
+ #
20
+ # @see Hanami::Action#finish
21
+ def finish(req, res, *)
22
+ res.cookies.finish
23
+ super
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "hanami/utils/blank"
4
+ require "rack/utils"
5
+ require "securerandom"
6
+ require_relative "errors"
7
+
8
+ module Hanami
9
+ # @api private
10
+ class Action
11
+ # CSRF Protection
12
+ #
13
+ # This security mechanism is enabled automatically if sessions are turned on.
14
+ #
15
+ # It stores a "challenge" token in session. For each "state changing request"
16
+ # (eg. <tt>POST</tt>, <tt>PATCH</tt> etc..), we should send a special param
17
+ # <tt>_csrf_token</tt> or header <tt>X-CSRF-Token</tt> which contain the "challenge"
18
+ # token.
19
+ #
20
+ # If the request token matches with the challenge token, the flow can continue.
21
+ # Otherwise the application detects an attack attempt, it reset the session
22
+ # and <tt>Hanami::Action::InvalidCSRFTokenError</tt> is raised.
23
+ #
24
+ # We can specify a custom handling strategy, by overriding <tt>#handle_invalid_csrf_token</tt>.
25
+ #
26
+ # Form helper (<tt>#form_for</tt>) automatically sets a hidden field with the
27
+ # correct token. A special view method (<tt>#csrf_token</tt>) is available in
28
+ # case the form markup is manually crafted.
29
+ #
30
+ # We can disable this check on action basis, by overriding <tt>#verify_csrf_token?</tt>.
31
+ #
32
+ # @since 0.4.0
33
+ #
34
+ # @see https://www.owasp.org/index.php/Cross-Site_Request_Forgery_%28CSRF%29
35
+ # @see https://www.owasp.org/index.php/Cross-Site_Request_Forgery_(CSRF)_Prevention_Cheat_Sheet
36
+ #
37
+ # @example Custom Handling
38
+ # module Web::Controllers::Books
39
+ # class Create < Web::Action
40
+ # def handl(*)
41
+ # # ...
42
+ # end
43
+ #
44
+ # private
45
+ #
46
+ # def handle_invalid_csrf_token
47
+ # Web::Logger.warn "CSRF attack: expected #{ session[:_csrf_token] }, was #{ params[:_csrf_token] }"
48
+ # # manual handling
49
+ # end
50
+ # end
51
+ # end
52
+ #
53
+ # @example Bypass Security Check
54
+ # module Web::Controllers::Books
55
+ # class Create < Web::Action
56
+ # def handle(*)
57
+ # # ...
58
+ # end
59
+ #
60
+ # private
61
+ #
62
+ # def verify_csrf_token?(req, res)
63
+ # false
64
+ # end
65
+ # end
66
+ # end
67
+ module CSRFProtection
68
+ # Session and params key for CSRF token.
69
+ #
70
+ # This key is shared with <tt>hanami-action</tt> and <tt>hanami-helpers</tt>
71
+ #
72
+ # @since 0.4.0
73
+ # @api private
74
+ CSRF_TOKEN = :_csrf_token
75
+
76
+ # Idempotent HTTP methods
77
+ #
78
+ # By default, the check isn't performed if the request method is included
79
+ # in this list.
80
+ #
81
+ # @since 0.4.0
82
+ # @api private
83
+ IDEMPOTENT_HTTP_METHODS = Hash[
84
+ Action::GET => true,
85
+ Action::HEAD => true,
86
+ Action::TRACE => true,
87
+ Action::OPTIONS => true
88
+ ].freeze
89
+
90
+ # @since 0.4.0
91
+ # @api private
92
+ def self.included(action)
93
+ unless Hanami.respond_to?(:env?) && Hanami.env?(:test)
94
+ action.include Hanami::Action::Session
95
+ action.class_eval do
96
+ before :set_csrf_token, :verify_csrf_token
97
+ end
98
+ end
99
+ end
100
+
101
+ private
102
+
103
+ # Set CSRF Token in session
104
+ #
105
+ # @since 0.4.0
106
+ # @api private
107
+ def set_csrf_token(_req, res)
108
+ res.session[CSRF_TOKEN] ||= generate_csrf_token
109
+ end
110
+
111
+ # Get CSRF Token in request.
112
+ #
113
+ # Retreives the CSRF token from the request param <tt>_csrf_token</tt> or the request header
114
+ # <tt>X-CSRF-Token</tt>.
115
+ #
116
+ # @api private
117
+ def request_csrf_token(req)
118
+ req.params.raw[CSRF_TOKEN.to_s] || req.get_header("HTTP_X_CSRF_TOKEN")
119
+ end
120
+
121
+ # Verify if CSRF token from params, matches the one stored in session.
122
+ # If not, it raises an error.
123
+ #
124
+ # Don't override this method.
125
+ #
126
+ # To bypass the security check, please override <tt>#verify_csrf_token?</tt>.
127
+ # For custom handling of an attack, please override <tt>#handle_invalid_csrf_token</tt>.
128
+ #
129
+ # @since 0.4.0
130
+ # @api private
131
+ def verify_csrf_token(req, res)
132
+ handle_invalid_csrf_token(req, res) if invalid_csrf_token?(req, res)
133
+ end
134
+
135
+ # Verify if CSRF token from params, matches the one stored in session.
136
+ #
137
+ # Don't override this method.
138
+ #
139
+ # @since 0.4.0
140
+ # @api private
141
+ def invalid_csrf_token?(req, res)
142
+ return false unless verify_csrf_token?(req, res)
143
+
144
+ missing_csrf_token?(req, res) ||
145
+ !::Rack::Utils.secure_compare(req.session[CSRF_TOKEN], request_csrf_token(req))
146
+ end
147
+
148
+ # Verify the CSRF token was passed in params.
149
+ #
150
+ # @api private
151
+ def missing_csrf_token?(req, *)
152
+ Hanami::Utils::Blank.blank?(request_csrf_token(req))
153
+ end
154
+
155
+ # Generates a random CSRF Token
156
+ #
157
+ # @since 0.4.0
158
+ # @api private
159
+ def generate_csrf_token
160
+ SecureRandom.hex(32)
161
+ end
162
+
163
+ # Decide if perform the check or not.
164
+ #
165
+ # Override and return <tt>false</tt> if you want to bypass security check.
166
+ #
167
+ # @since 0.4.0
168
+ #
169
+ # @example
170
+ # module Web::Controllers::Books
171
+ # class Create < Web::Action
172
+ # def call(*)
173
+ # # ...
174
+ # end
175
+ #
176
+ # private
177
+ #
178
+ # def verify_csrf_token?(req, res)
179
+ # false
180
+ # end
181
+ # end
182
+ # end
183
+ def verify_csrf_token?(req, *)
184
+ !IDEMPOTENT_HTTP_METHODS[req.request_method]
185
+ end
186
+
187
+ # Handle CSRF attack.
188
+ #
189
+ # The default policy resets the session and raises an exception.
190
+ #
191
+ # Override this method, for custom handling.
192
+ #
193
+ # @raise [Hanami::Action::InvalidCSRFTokenError]
194
+ #
195
+ # @since 0.4.0
196
+ #
197
+ # @example
198
+ # module Web::Controllers::Books
199
+ # class Create < Web::Action
200
+ # def call(*)
201
+ # # ...
202
+ # end
203
+ #
204
+ # private
205
+ #
206
+ # def handle_invalid_csrf_token(req, res)
207
+ # # custom invalid CSRF management goes here
208
+ # end
209
+ # end
210
+ # end
211
+ def handle_invalid_csrf_token(*, res)
212
+ res.session.clear
213
+ raise InvalidCSRFTokenError
214
+ end
215
+ end
216
+ end
217
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hanami
4
+ class Action
5
+ # Base class for all Action errors.
6
+ #
7
+ # @api public
8
+ # @since 2.0.0
9
+ class Error < ::StandardError
10
+ end
11
+
12
+ # Unknown status HTTP Status error
13
+ #
14
+ # @since 2.0.2
15
+ #
16
+ # @see Hanami::Action::Response#status=
17
+ # @see https://guides.hanamirb.org/v2.0/actions/status-codes/
18
+ class UnknownHttpStatusError < Error
19
+ # @since 2.0.2
20
+ # @api private
21
+ def initialize(code)
22
+ super("unknown HTTP status: `#{code.inspect}'")
23
+ end
24
+ end
25
+
26
+ # Unknown format error
27
+ #
28
+ # This error is raised when a action sets a format that it isn't recognized
29
+ # both by `Hanami::Action::Configuration` and the list of Rack mime types
30
+ #
31
+ # @since 2.0.0
32
+ #
33
+ # @see Hanami::Action::Mime#format=
34
+ class UnknownFormatError < Error
35
+ # @since 2.0.0
36
+ # @api private
37
+ def initialize(format)
38
+ message = <<~MSG
39
+ Cannot find a corresponding MIME type for format `#{format.inspect}'.
40
+ MSG
41
+
42
+ unless blank?(format)
43
+ message += <<~MSG
44
+
45
+ Configure one via: `config.actions.formats.add(:#{format}, "MIME_TYPE_HERE")' in `config/app.rb' to share between actions of a Hanami app.
46
+
47
+ Or make it available only in the current action: `config.formats.add(:#{format}, "MIME_TYPE_HERE")'.
48
+ MSG
49
+ end
50
+
51
+ super(message)
52
+ end
53
+
54
+ private
55
+
56
+ def blank?(format)
57
+ format.to_s.match(/\A[[:space:]]*\z/)
58
+ end
59
+ end
60
+
61
+ # Error raised when body parsing fails.
62
+ #
63
+ # @api public
64
+ # @since x.x.x
65
+ class BodyParsingError < Error
66
+ end
67
+
68
+ # Error raised when session is accessed but not enabled.
69
+ #
70
+ # This error is raised when `session` or `flash` is accessed/set on request/response objects
71
+ # in actions which do not include `Hanami::Action::Session`.
72
+ #
73
+ # @see Hanami::Action::Session
74
+ # @see Hanami::Action::Request#session
75
+ # @see Hanami::Action::Response#session
76
+ # @see Hanami::Action::Response#flash
77
+ #
78
+ # @api public
79
+ # @since 2.0.0
80
+ class MissingSessionError < Error
81
+ # @api private
82
+ # @since 2.0.0
83
+ def initialize(session_method)
84
+ super(<<~TEXT)
85
+ Sessions are not enabled. To use `#{session_method}`:
86
+
87
+ Configure sessions in your Hanami app, e.g.
88
+
89
+ module MyApp
90
+ class App < Hanami::App
91
+ # See Rack::Session::Cookie for options
92
+ config.actions.sessions = :cookie, {**cookie_session_options}
93
+ end
94
+ end
95
+
96
+ Or include session support directly in your action class:
97
+
98
+ include Hanami::Action::Session
99
+ TEXT
100
+ end
101
+ end
102
+
103
+ # Invalid CSRF Token
104
+ #
105
+ # @since 0.4.0
106
+ class InvalidCSRFTokenError < Error
107
+ end
108
+ end
109
+ end