better_errors 2.7.1 → 2.9.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.
@@ -3,6 +3,7 @@ source "https://rubygems.org"
3
3
  gem "rails", "~> 6.0.0"
4
4
  gem "haml"
5
5
 
6
- gem 'coveralls', require: false
6
+ gem 'simplecov', require: false
7
+ gem 'simplecov-lcov', require: false
7
8
 
8
9
  gemspec path: "../"
@@ -3,6 +3,7 @@ require "erubi"
3
3
  require "coderay"
4
4
  require "uri"
5
5
 
6
+ require "better_errors/version"
6
7
  require "better_errors/code_formatter"
7
8
  require "better_errors/inspectable_value"
8
9
  require "better_errors/error_page"
@@ -10,21 +11,9 @@ require "better_errors/middleware"
10
11
  require "better_errors/raised_exception"
11
12
  require "better_errors/repl"
12
13
  require "better_errors/stack_frame"
13
- require "better_errors/version"
14
+ require "better_errors/editor"
14
15
 
15
16
  module BetterErrors
16
- POSSIBLE_EDITOR_PRESETS = [
17
- { symbols: [:emacs, :emacsclient], sniff: /emacs/i, url: "emacs://open?url=file://%{file}&line=%{line}" },
18
- { symbols: [:macvim, :mvim], sniff: /vim/i, url: proc { |file, line| "mvim://open?url=file://#{file}&line=#{line}" } },
19
- { symbols: [:sublime, :subl, :st], sniff: /subl/i, url: "subl://open?url=file://%{file}&line=%{line}" },
20
- { symbols: [:textmate, :txmt, :tm], sniff: /mate/i, url: "txmt://open?url=file://%{file}&line=%{line}" },
21
- { symbols: [:idea], sniff: /idea/i, url: "idea://open?file=%{file}&line=%{line}" },
22
- { symbols: [:rubymine], sniff: /mine/i, url: "x-mine://open?file=%{file}&line=%{line}" },
23
- { symbols: [:vscode, :code], sniff: /code/i, url: "vscode://file/%{file}:%{line}" },
24
- { symbols: [:vscodium, :codium], sniff: /codium/i, url: "vscodium://file/%{file}:%{line}" },
25
- { symbols: [:atom], sniff: /atom/i, url: "atom://core/open/file?filename=%{file}&line=%{line}" },
26
- ]
27
-
28
17
  class << self
29
18
  # The path to the root of the application. Better Errors uses this property
30
19
  # to determine if a file in a backtrace should be considered an application
@@ -64,17 +53,18 @@ module BetterErrors
64
53
  @maximum_variable_inspect_size = 100_000
65
54
  @ignored_classes = ['ActionDispatch::Request', 'ActionDispatch::Response']
66
55
 
67
- # Returns a proc, which when called with a filename and line number argument,
56
+ # Returns an object which responds to #url, which when called with
57
+ # a filename and line number argument,
68
58
  # returns a URL to open the filename and line in the selected editor.
69
59
  #
70
60
  # Generates TextMate URLs by default.
71
61
  #
72
- # BetterErrors.editor["/some/file", 123]
62
+ # BetterErrors.editor.url("/some/file", 123)
73
63
  # # => txmt://open?url=file:///some/file&line=123
74
64
  #
75
65
  # @return [Proc]
76
66
  def self.editor
77
- @editor
67
+ @editor ||= default_editor
78
68
  end
79
69
 
80
70
  # Configures how Better Errors generates open-in-editor URLs.
@@ -115,20 +105,15 @@ module BetterErrors
115
105
  # @param [Proc] proc
116
106
  #
117
107
  def self.editor=(editor)
118
- POSSIBLE_EDITOR_PRESETS.each do |config|
119
- if config[:symbols].include?(editor)
120
- return self.editor = config[:url]
121
- end
122
- end
123
-
124
- if editor.is_a? String
125
- self.editor = proc { |file, line| editor % { file: URI.encode_www_form_component(file), line: line } }
108
+ if editor.is_a? Symbol
109
+ @editor = Editor.for_symbol(editor)
110
+ raise(ArgumentError, "Symbol #{editor} is not a symbol in the list of supported errors.") unless editor
111
+ elsif editor.is_a? String
112
+ @editor = Editor.for_formatting_string(editor)
113
+ elsif editor.respond_to? :call
114
+ @editor = Editor.for_proc(editor)
126
115
  else
127
- if editor.respond_to? :call
128
- @editor = editor
129
- else
130
- raise TypeError, "Expected editor to be a valid editor key, a format string or a callable."
131
- end
116
+ raise ArgumentError, "Expected editor to be a valid editor key, a format string or a callable."
132
117
  end
133
118
  end
134
119
 
@@ -145,12 +130,8 @@ module BetterErrors
145
130
  #
146
131
  # @return [Symbol]
147
132
  def self.default_editor
148
- POSSIBLE_EDITOR_PRESETS.detect(-> { {} }) { |config|
149
- ENV["EDITOR"] =~ config[:sniff]
150
- }[:url] || :textmate
133
+ Editor.default_editor
151
134
  end
152
-
153
- BetterErrors.editor = default_editor
154
135
  end
155
136
 
156
137
  begin
@@ -0,0 +1,99 @@
1
+ require "uri"
2
+
3
+ module BetterErrors
4
+ class Editor
5
+ KNOWN_EDITORS = [
6
+ { symbols: [:atom], sniff: /atom/i, url: "atom://core/open/file?filename=%{file}&line=%{line}" },
7
+ { symbols: [:emacs, :emacsclient], sniff: /emacs/i, url: "emacs://open?url=file://%{file}&line=%{line}" },
8
+ { symbols: [:idea], sniff: /idea/i, url: "idea://open?file=%{file}&line=%{line}" },
9
+ { symbols: [:macvim, :mvim], sniff: /vim/i, url: "mvim://open?url=file://%{file_unencoded}&line=%{line}" },
10
+ { symbols: [:rubymine], sniff: /mine/i, url: "x-mine://open?file=%{file}&line=%{line}" },
11
+ { symbols: [:sublime, :subl, :st], sniff: /subl/i, url: "subl://open?url=file://%{file}&line=%{line}" },
12
+ { symbols: [:textmate, :txmt, :tm], sniff: /mate/i, url: "txmt://open?url=file://%{file}&line=%{line}" },
13
+ { symbols: [:vscode, :code], sniff: /code/i, url: "vscode://file/%{file}:%{line}" },
14
+ { symbols: [:vscodium, :codium], sniff: /codium/i, url: "vscodium://file/%{file}:%{line}" },
15
+ ]
16
+
17
+ def self.for_formatting_string(formatting_string)
18
+ new proc { |file, line|
19
+ formatting_string % { file: URI.encode_www_form_component(file), file_unencoded: file, line: line }
20
+ }
21
+ end
22
+
23
+ def self.for_proc(url_proc)
24
+ new url_proc
25
+ end
26
+
27
+ # Automatically sniffs a default editor preset based on
28
+ # environment variables.
29
+ #
30
+ # @return [Symbol]
31
+ def self.default_editor
32
+ editor_from_environment_formatting_string ||
33
+ editor_from_environment_editor ||
34
+ editor_from_symbol(:textmate)
35
+ end
36
+
37
+ def self.editor_from_environment_editor
38
+ if ENV["BETTER_ERRORS_EDITOR"]
39
+ editor = editor_from_command(ENV["BETTER_ERRORS_EDITOR"])
40
+ return editor if editor
41
+ puts "BETTER_ERRORS_EDITOR environment variable is not recognized as a supported Better Errors editor."
42
+ end
43
+ if ENV["EDITOR"]
44
+ editor = editor_from_command(ENV["EDITOR"])
45
+ return editor if editor
46
+ puts "EDITOR environment variable is not recognized as a supported Better Errors editor. Using TextMate by default."
47
+ else
48
+ puts "Since there is no EDITOR or BETTER_ERRORS_EDITOR environment variable, using Textmate by default."
49
+ end
50
+ end
51
+
52
+ def self.editor_from_command(editor_command)
53
+ env_preset = KNOWN_EDITORS.find { |preset| editor_command =~ preset[:sniff] }
54
+ for_formatting_string(env_preset[:url]) if env_preset
55
+ end
56
+
57
+ def self.editor_from_environment_formatting_string
58
+ return unless ENV['BETTER_ERRORS_EDITOR_URL']
59
+
60
+ for_formatting_string(ENV['BETTER_ERRORS_EDITOR_URL'])
61
+ end
62
+
63
+ def self.editor_from_symbol(symbol)
64
+ KNOWN_EDITORS.each do |preset|
65
+ return for_formatting_string(preset[:url]) if preset[:symbols].include?(symbol)
66
+ end
67
+ end
68
+
69
+ def initialize(url_proc)
70
+ @url_proc = url_proc
71
+ end
72
+
73
+ def url(raw_path, line)
74
+ if virtual_path && raw_path.start_with?(virtual_path)
75
+ if host_path
76
+ file = raw_path.sub(%r{\A#{virtual_path}}, host_path)
77
+ else
78
+ file = raw_path.sub(%r{\A#{virtual_path}/}, '')
79
+ end
80
+ else
81
+ file = raw_path
82
+ end
83
+
84
+ url_proc.call(file, line)
85
+ end
86
+
87
+ private
88
+
89
+ attr_reader :url_proc
90
+
91
+ def virtual_path
92
+ @virtual_path ||= ENV['BETTER_ERRORS_VIRTUAL_PATH']
93
+ end
94
+
95
+ def host_path
96
+ @host_path ||= ENV['BETTER_ERRORS_HOST_PATH']
97
+ end
98
+ end
99
+ end
@@ -26,8 +26,13 @@ module BetterErrors
26
26
  @id ||= SecureRandom.hex(8)
27
27
  end
28
28
 
29
- def render(template_name = "main")
29
+ def render(template_name = "main", csrf_token = nil)
30
30
  binding.eval(self.class.template(template_name).src)
31
+ rescue => e
32
+ # Fix the backtrace, which doesn't identify the template that failed (within Better Errors).
33
+ # We don't know the line number, so just injecting the template path has to be enough.
34
+ e.backtrace.unshift "#{self.class.template_path(template_name)}:0"
35
+ raise
31
36
  end
32
37
 
33
38
  def do_variables(opts)
@@ -59,7 +64,23 @@ module BetterErrors
59
64
  end
60
65
 
61
66
  def exception_message
62
- exception.message.lstrip
67
+ exception.message.strip.gsub(/(\r?\n\s*\r?\n)+/, "\n")
68
+ end
69
+
70
+ def exception_hint
71
+ exception.hint
72
+ end
73
+
74
+ def active_support_actions
75
+ return [] unless defined?(ActiveSupport::ActionableError)
76
+
77
+ ActiveSupport::ActionableError.actions(exception.type)
78
+ end
79
+
80
+ def action_dispatch_action_endpoint
81
+ return unless defined?(ActionDispatch::ActionableExceptions)
82
+
83
+ ActionDispatch::ActionableExceptions.endpoint
63
84
  end
64
85
 
65
86
  def application_frames
@@ -73,7 +94,7 @@ module BetterErrors
73
94
  private
74
95
 
75
96
  def editor_url(frame)
76
- BetterErrors.editor[frame.filename, frame.line]
97
+ BetterErrors.editor.url(frame.filename, frame.line)
77
98
  end
78
99
 
79
100
  def rack_session
@@ -0,0 +1,29 @@
1
+ module BetterErrors
2
+ class ExceptionHint
3
+ def initialize(exception)
4
+ @exception = exception
5
+ end
6
+
7
+ def hint
8
+ case exception
9
+ when NoMethodError
10
+ /\Aundefined method `(?<method>[^']+)' for (?<val>[^:]+):(?<klass>\w+)/.match(exception.message) do |match|
11
+ if match[:val] == "nil"
12
+ return "Something is `nil` when it probably shouldn't be."
13
+ elsif !match[:klass].start_with? '0x'
14
+ return "`#{match[:method]}` is being called on a `#{match[:klass]}` object, "\
15
+ "which might not be the type of object you were expecting."
16
+ end
17
+ end
18
+ when NameError
19
+ /\Aundefined local variable or method `(?<method>[^']+)' for/.match(exception.message) do |match|
20
+ return "`#{match[:method]}` is probably misspelled."
21
+ end
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ attr_reader :exception
28
+ end
29
+ end
@@ -1,5 +1,6 @@
1
1
  require "json"
2
2
  require "ipaddr"
3
+ require "securerandom"
3
4
  require "set"
4
5
  require "rack"
5
6
 
@@ -39,6 +40,8 @@ module BetterErrors
39
40
  allow_ip! "127.0.0.0/8"
40
41
  allow_ip! "::1/128" rescue nil # windows ruby doesn't have ipv6 support
41
42
 
43
+ CSRF_TOKEN_COOKIE_NAME = "BetterErrors-#{BetterErrors::VERSION}-CSRF-Token"
44
+
42
45
  # A new instance of BetterErrors::Middleware
43
46
  #
44
47
  # @param app The Rack app/middleware to wrap with Better Errors
@@ -72,7 +75,7 @@ module BetterErrors
72
75
  def better_errors_call(env)
73
76
  case env["PATH_INFO"]
74
77
  when %r{/__better_errors/(?<id>.+?)/(?<method>\w+)\z}
75
- internal_call env, $~
78
+ internal_call(env, $~[:id], $~[:method])
76
79
  when %r{/__better_errors/?\z}
77
80
  show_error_page env
78
81
  else
@@ -89,11 +92,14 @@ module BetterErrors
89
92
  end
90
93
 
91
94
  def show_error_page(env, exception=nil)
95
+ request = Rack::Request.new(env)
96
+ csrf_token = request.cookies[CSRF_TOKEN_COOKIE_NAME] || SecureRandom.uuid
97
+
92
98
  type, content = if @error_page
93
99
  if text?(env)
94
100
  [ 'plain', @error_page.render('text') ]
95
101
  else
96
- [ 'html', @error_page.render ]
102
+ [ 'html', @error_page.render('main', csrf_token) ]
97
103
  end
98
104
  else
99
105
  [ 'html', no_errors_page ]
@@ -104,12 +110,22 @@ module BetterErrors
104
110
  status_code = ActionDispatch::ExceptionWrapper.new(env, exception).status_code
105
111
  end
106
112
 
107
- [status_code, { "Content-Type" => "text/#{type}; charset=utf-8" }, [content]]
113
+ response = Rack::Response.new(content, status_code, { "Content-Type" => "text/#{type}; charset=utf-8" })
114
+
115
+ unless request.cookies[CSRF_TOKEN_COOKIE_NAME]
116
+ response.set_cookie(CSRF_TOKEN_COOKIE_NAME, value: csrf_token, path: "/", httponly: true, same_site: :strict)
117
+ end
118
+
119
+ # In older versions of Rack, the body returned here is actually a Rack::BodyProxy which seems to be a bug.
120
+ # (It contains status, headers and body and does not act like an array of strings.)
121
+ # Since we already have status code and body here, there's no need to use the ones in the Rack::Response.
122
+ (_status_code, headers, _body) = response.finish
123
+ [status_code, headers, [content]]
108
124
  end
109
125
 
110
126
  def text?(env)
111
127
  env["HTTP_X_REQUESTED_WITH"] == "XMLHttpRequest" ||
112
- !env["HTTP_ACCEPT"].to_s.include?('html')
128
+ !env["HTTP_ACCEPT"].to_s.include?('html')
113
129
  end
114
130
 
115
131
  def log_exception
@@ -129,13 +145,22 @@ module BetterErrors
129
145
  end
130
146
  end
131
147
 
132
- def internal_call(env, opts)
148
+ def internal_call(env, id, method)
149
+ return not_found_json_response unless %w[variables eval].include?(method)
133
150
  return no_errors_json_response unless @error_page
134
- return invalid_error_json_response if opts[:id] != @error_page.id
151
+ return invalid_error_json_response if id != @error_page.id
152
+
153
+ request = Rack::Request.new(env)
154
+ return invalid_csrf_token_json_response unless request.cookies[CSRF_TOKEN_COOKIE_NAME]
155
+
156
+ request.body.rewind
157
+ body = JSON.parse(request.body.read)
158
+ return invalid_csrf_token_json_response unless request.cookies[CSRF_TOKEN_COOKIE_NAME] == body['csrfToken']
159
+
160
+ return not_acceptable_json_response unless request.content_type == 'application/json'
135
161
 
136
- env["rack.input"].rewind
137
- response = @error_page.send("do_#{opts[:method]}", JSON.parse(env["rack.input"].read))
138
- [200, { "Content-Type" => "text/plain; charset=utf-8" }, [JSON.dump(response)]]
162
+ response = @error_page.send("do_#{method}", body)
163
+ [200, { "Content-Type" => "application/json; charset=utf-8" }, [JSON.dump(response)]]
139
164
  end
140
165
 
141
166
  def no_errors_page
@@ -157,18 +182,40 @@ module BetterErrors
157
182
  "The application has been restarted since this page loaded, " +
158
183
  "or the framework is reloading all gems before each request "
159
184
  end
160
- [200, { "Content-Type" => "text/plain; charset=utf-8" }, [JSON.dump(
185
+ [200, { "Content-Type" => "application/json; charset=utf-8" }, [JSON.dump(
161
186
  error: 'No exception information available',
162
187
  explanation: explanation,
163
188
  )]]
164
189
  end
165
190
 
166
191
  def invalid_error_json_response
167
- [200, { "Content-Type" => "text/plain; charset=utf-8" }, [JSON.dump(
192
+ [200, { "Content-Type" => "application/json; charset=utf-8" }, [JSON.dump(
168
193
  error: "Session expired",
169
194
  explanation: "This page was likely opened from a previous exception, " +
170
195
  "and the exception is no longer available in memory.",
171
196
  )]]
172
197
  end
198
+
199
+ def invalid_csrf_token_json_response
200
+ [200, { "Content-Type" => "application/json; charset=utf-8" }, [JSON.dump(
201
+ error: "Invalid CSRF Token",
202
+ explanation: "The browser session might have been cleared, " +
203
+ "or something went wrong.",
204
+ )]]
205
+ end
206
+
207
+ def not_found_json_response
208
+ [404, { "Content-Type" => "application/json; charset=utf-8" }, [JSON.dump(
209
+ error: "Not found",
210
+ explanation: "Not a recognized internal call.",
211
+ )]]
212
+ end
213
+
214
+ def not_acceptable_json_response
215
+ [406, { "Content-Type" => "application/json; charset=utf-8" }, [JSON.dump(
216
+ error: "Request not acceptable",
217
+ explanation: "The internal request did not match an acceptable content type.",
218
+ )]]
219
+ end
173
220
  end
174
221
  end
@@ -1,11 +1,23 @@
1
+ require 'better_errors/exception_hint'
2
+
1
3
  # @private
2
4
  module BetterErrors
3
5
  class RaisedException
4
- attr_reader :exception, :message, :backtrace
6
+ attr_reader :exception, :message, :backtrace, :hint
5
7
 
6
8
  def initialize(exception)
7
- if exception.respond_to?(:original_exception) && exception.original_exception
8
- # This supports some specific Rails exceptions, and is not intended to act the same as `#cause`.
9
+ if exception.class.name == "ActionView::Template::Error" && exception.respond_to?(:cause)
10
+ # Rails 6+ exceptions of this type wrap the "real" exception, and the real exception
11
+ # is actually more useful than the ActionView-provided wrapper. Once Better Errors
12
+ # supports showing all exceptions in the cause stack, this should go away. Or perhaps
13
+ # this can be changed to provide guidance by showing the second error in the cause stack
14
+ # under this condition.
15
+ exception = exception.cause if exception.cause
16
+ elsif exception.respond_to?(:original_exception) && exception.original_exception
17
+ # This supports some specific Rails exceptions, and this is not intended to act the same as
18
+ # the Ruby's {Exception#cause}.
19
+ # It's possible this should only support ActionView::Template::Error, but by not changing
20
+ # this we're preserving longstanding behavior of Better Errors with Rails < 6.
9
21
  exception = exception.original_exception
10
22
  end
11
23
 
@@ -13,6 +25,7 @@ module BetterErrors
13
25
  @message = exception.message
14
26
 
15
27
  setup_backtrace
28
+ setup_hint
16
29
  massage_syntax_error
17
30
  end
18
31
 
@@ -57,10 +70,6 @@ module BetterErrors
57
70
 
58
71
  def massage_syntax_error
59
72
  case exception.class.to_s
60
- when "ActionView::Template::Error"
61
- if exception.respond_to?(:file_name) && exception.respond_to?(:line_number)
62
- backtrace.unshift(StackFrame.new(exception.file_name, exception.line_number.to_i, "view template"))
63
- end
64
73
  when "Haml::SyntaxError", "Sprockets::Coffeelint::Error"
65
74
  if /\A(.+?):(\d+)/ =~ exception.backtrace.first
66
75
  backtrace.unshift(StackFrame.new($1, $2.to_i, ""))
@@ -72,5 +81,9 @@ module BetterErrors
72
81
  end
73
82
  end
74
83
  end
84
+
85
+ def setup_hint
86
+ @hint = ExceptionHint.new(exception).hint
87
+ end
75
88
  end
76
89
  end