better_errors 2.4.0 → 2.9.1

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 +5 -5
  2. data/.github/workflows/ci.yml +130 -0
  3. data/.github/workflows/release.yml +64 -0
  4. data/.ruby-version +1 -0
  5. data/CHANGELOG.md +1 -1
  6. data/Gemfile +6 -0
  7. data/README.md +40 -8
  8. data/better_errors.gemspec +13 -2
  9. data/gemfiles/pry010.gemfile +2 -1
  10. data/gemfiles/pry011.gemfile +2 -1
  11. data/gemfiles/pry09.gemfile +2 -1
  12. data/gemfiles/rack.gemfile +2 -1
  13. data/gemfiles/rack_boc.gemfile +2 -1
  14. data/gemfiles/rails42.gemfile +3 -1
  15. data/gemfiles/rails42_boc.gemfile +3 -1
  16. data/gemfiles/rails42_haml.gemfile +3 -1
  17. data/gemfiles/rails50.gemfile +3 -1
  18. data/gemfiles/rails50_boc.gemfile +3 -1
  19. data/gemfiles/rails50_haml.gemfile +3 -1
  20. data/gemfiles/rails51.gemfile +3 -1
  21. data/gemfiles/rails51_boc.gemfile +3 -1
  22. data/gemfiles/rails51_haml.gemfile +3 -1
  23. data/gemfiles/rails52.gemfile +9 -0
  24. data/gemfiles/rails52_boc.gemfile +10 -0
  25. data/gemfiles/rails52_haml.gemfile +10 -0
  26. data/gemfiles/rails60.gemfile +8 -0
  27. data/gemfiles/rails60_boc.gemfile +9 -0
  28. data/gemfiles/rails60_haml.gemfile +9 -0
  29. data/lib/better_errors.rb +23 -32
  30. data/lib/better_errors/editor.rb +99 -0
  31. data/lib/better_errors/error_page.rb +37 -24
  32. data/lib/better_errors/exception_hint.rb +29 -0
  33. data/lib/better_errors/inspectable_value.rb +45 -0
  34. data/lib/better_errors/middleware.rb +59 -12
  35. data/lib/better_errors/raised_exception.rb +25 -4
  36. data/lib/better_errors/stack_frame.rb +25 -7
  37. data/lib/better_errors/templates/main.erb +78 -24
  38. data/lib/better_errors/templates/text.erb +5 -2
  39. data/lib/better_errors/templates/variable_info.erb +9 -2
  40. data/lib/better_errors/version.rb +1 -1
  41. metadata +37 -10
  42. data/.travis.yml +0 -68
@@ -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
@@ -0,0 +1,45 @@
1
+ require "cgi"
2
+ require "objspace" rescue nil
3
+
4
+ module BetterErrors
5
+ class ValueLargerThanConfiguredMaximum < StandardError; end
6
+
7
+ class InspectableValue
8
+ def initialize(value)
9
+ @original_value = value
10
+ end
11
+
12
+ def to_html
13
+ raise ValueLargerThanConfiguredMaximum unless value_small_enough_to_inspect?
14
+ value_as_html
15
+ end
16
+
17
+ private
18
+
19
+ attr_reader :original_value
20
+
21
+ def value_as_html
22
+ @value_as_html ||= CGI.escapeHTML(value)
23
+ end
24
+
25
+ def value
26
+ @value ||= begin
27
+ if original_value.respond_to? :inspect
28
+ original_value.inspect
29
+ else
30
+ original_value
31
+ end
32
+ end
33
+ end
34
+
35
+ def value_small_enough_to_inspect?
36
+ return true if BetterErrors.maximum_variable_inspect_size.nil?
37
+
38
+ if defined?(ObjectSpace) && defined?(ObjectSpace.memsize_of) && ObjectSpace.memsize_of(value)
39
+ ObjectSpace.memsize_of(value) <= BetterErrors.maximum_variable_inspect_size
40
+ else
41
+ value_as_html.length <= BetterErrors.maximum_variable_inspect_size
42
+ end
43
+ end
44
+ end
45
+ 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
 
@@ -33,12 +34,14 @@ module BetterErrors
33
34
  # Adds an address to the set of IP addresses allowed to access Better
34
35
  # Errors.
35
36
  def self.allow_ip!(addr)
36
- ALLOWED_IPS << IPAddr.new(addr)
37
+ ALLOWED_IPS << (addr.is_a?(IPAddr) ? addr : IPAddr.new(addr))
37
38
  end
38
39
 
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,12 +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?(: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.
8
15
  exception = exception.cause if exception.cause
9
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.
10
21
  exception = exception.original_exception
11
22
  end
12
23
 
@@ -14,6 +25,7 @@ module BetterErrors
14
25
  @message = exception.message
15
26
 
16
27
  setup_backtrace
28
+ setup_hint
17
29
  massage_syntax_error
18
30
  end
19
31
 
@@ -36,8 +48,13 @@ module BetterErrors
36
48
 
37
49
  def setup_backtrace_from_bindings
38
50
  @backtrace = exception.__better_errors_bindings_stack.map { |binding|
39
- file = binding.eval "__FILE__"
40
- line = binding.eval "__LINE__"
51
+ if binding.respond_to?(:source_location) # Ruby >= 2.6
52
+ file = binding.source_location[0]
53
+ line = binding.source_location[1]
54
+ else
55
+ file = binding.eval "__FILE__"
56
+ line = binding.eval "__LINE__"
57
+ end
41
58
  name = binding.frame_description
42
59
  StackFrame.new(file, line, name, binding)
43
60
  }
@@ -64,5 +81,9 @@ module BetterErrors
64
81
  end
65
82
  end
66
83
  end
84
+
85
+ def setup_hint
86
+ @hint = ExceptionHint.new(exception).hint
87
+ end
67
88
  end
68
89
  end
@@ -69,21 +69,26 @@ module BetterErrors
69
69
  def local_variables
70
70
  return {} unless frame_binding
71
71
 
72
- frame_binding.eval("local_variables").each_with_object({}) do |name, hash|
72
+ lv = frame_binding.eval("local_variables")
73
+ return {} unless lv
74
+
75
+ lv.each_with_object({}) do |name, hash|
73
76
  # Ruby 2.2's local_variables will include the hidden #$! variable if
74
77
  # called from within a rescue context. This is not a valid variable name,
75
78
  # so the local_variable_get method complains. This should probably be
76
79
  # considered a bug in Ruby itself, but we need to work around it.
77
80
  next if name == :"\#$!"
78
81
 
79
- if defined?(frame_binding.local_variable_get)
80
- hash[name] = frame_binding.local_variable_get(name)
81
- else
82
- hash[name] = frame_binding.eval(name.to_s)
83
- end
82
+ hash[name] = local_variable(name)
84
83
  end
85
84
  end
86
85
 
86
+ def local_variable(name)
87
+ get_local_variable(name) || eval_local_variable(name)
88
+ rescue NameError => ex
89
+ "#{ex.class}: #{ex.message}"
90
+ end
91
+
87
92
  def instance_variables
88
93
  return {} unless frame_binding
89
94
  Hash[visible_instance_variables.map { |x|
@@ -92,7 +97,10 @@ module BetterErrors
92
97
  end
93
98
 
94
99
  def visible_instance_variables
95
- frame_binding.eval("instance_variables") - BetterErrors.ignored_instance_variables
100
+ iv = frame_binding.eval("instance_variables")
101
+ return {} unless iv
102
+
103
+ iv - BetterErrors.ignored_instance_variables
96
104
  end
97
105
 
98
106
  def to_s
@@ -114,5 +122,15 @@ module BetterErrors
114
122
  @method_name = "##{method_name}"
115
123
  end
116
124
  end
125
+
126
+ def get_local_variable(name)
127
+ if defined?(frame_binding.local_variable_get)
128
+ frame_binding.local_variable_get(name)
129
+ end
130
+ end
131
+
132
+ def eval_local_variable(name)
133
+ frame_binding.eval(name.to_s)
134
+ end
117
135
  end
118
136
  end
@@ -90,7 +90,7 @@
90
90
  nav.sidebar,
91
91
  .frame_info {
92
92
  position: fixed;
93
- top: 95px;
93
+ top: 102px;
94
94
  bottom: 0;
95
95
 
96
96
  box-sizing: border-box;
@@ -102,7 +102,7 @@
102
102
  nav.sidebar {
103
103
  width: 40%;
104
104
  left: 20px;
105
- top: 115px;
105
+ top: 122px;
106
106
  bottom: 20px;
107
107
  }
108
108
 
@@ -131,7 +131,7 @@
131
131
  header.exception {
132
132
  padding: 18px 20px;
133
133
 
134
- height: 59px;
134
+ height: 66px;
135
135
  min-height: 59px;
136
136
 
137
137
  overflow: hidden;
@@ -146,6 +146,14 @@
146
146
  }
147
147
 
148
148
  /* Heading */
149
+ header.exception .fix-actions {
150
+ margin-top: .5em;
151
+ }
152
+
153
+ header.exception .fix-actions input[type=submit] {
154
+ font-weight: bold;
155
+ }
156
+
149
157
  header.exception h2 {
150
158
  font-weight: 200;
151
159
  font-size: 11pt;
@@ -153,7 +161,7 @@
153
161
 
154
162
  header.exception h2,
155
163
  header.exception p {
156
- line-height: 1.4em;
164
+ line-height: 1.5em;
157
165
  overflow: hidden;
158
166
  white-space: pre;
159
167
  text-overflow: ellipsis;
@@ -166,7 +174,7 @@
166
174
 
167
175
  header.exception p {
168
176
  font-weight: 200;
169
- font-size: 20pt;
177
+ font-size: 17pt;
170
178
  color: white;
171
179
  }
172
180
 
@@ -587,6 +595,9 @@
587
595
  color: #8080a0;
588
596
  padding-left: 20px;
589
597
  }
598
+ .console-has-been-used .live-console-hint {
599
+ display: none;
600
+ }
590
601
 
591
602
  .hint:before {
592
603
  content: '\25b2';
@@ -603,17 +614,6 @@
603
614
  margin: 10px 0;
604
615
  }
605
616
 
606
- .sub:before {
607
- content: '';
608
- display: block;
609
- width: 100%;
610
- height: 4px;
611
-
612
- border-radius: 2px;
613
- background: rgba(0, 150, 200, 0.05);
614
- box-shadow: 1px 1px 0 rgba(255, 255, 255, 0.7), inset 0 0 0 1px rgba(0, 0, 0, 0.04), inset 2px 2px 2px rgba(0, 0, 0, 0.07);
615
- }
616
-
617
617
  .sub h3 {
618
618
  color: #39a;
619
619
  font-size: 1.1em;
@@ -721,12 +721,22 @@
721
721
  if(document.styleSheets[i].href)
722
722
  document.styleSheets[i].disabled = true;
723
723
  }
724
- document.addEventListener("page:restore", function restoreCSS(e) {
725
- for(var i=0; i < document.styleSheets.length; i++) {
726
- document.styleSheets[i].disabled = false;
727
- }
728
- document.removeEventListener("page:restore", restoreCSS, false);
729
- });
724
+ if (window.Turbolinks.controller) {
725
+ // Turbolinks > 5 (see https://github.com/turbolinks/turbolinks/issues/6)
726
+ document.addEventListener("turbolinks:load", function restoreCSS(e) {
727
+ for(var i=0; i < document.styleSheets.length; i++) {
728
+ document.styleSheets[i].disabled = false;
729
+ }
730
+ document.removeEventListener("turbolinks:load", restoreCSS, false);
731
+ });
732
+ } else {
733
+ document.addEventListener("page:restore", function restoreCSS(e) {
734
+ for(var i=0; i < document.styleSheets.length; i++) {
735
+ document.styleSheets[i].disabled = false;
736
+ }
737
+ document.removeEventListener("page:restore", restoreCSS, false);
738
+ });
739
+ }
730
740
  }
731
741
  </script>
732
742
 
@@ -734,6 +744,21 @@
734
744
  <header class="exception">
735
745
  <h2><strong><%= exception_type %></strong> <span>at <%= request_path %></span></h2>
736
746
  <p><%= exception_message %></p>
747
+ <% unless active_support_actions.empty? %>
748
+ <div class='fix-actions'>
749
+ <% active_support_actions.each do |action, _| %>
750
+ <form class="button_to" method="post" action="<%= action_dispatch_action_endpoint %>">
751
+ <input type="submit" value="<%= action %>">
752
+ <input type="hidden" name="action" value="<%= action %>">
753
+ <input type="hidden" name="error" value="<%= exception_type %>">
754
+ <input type="hidden" name="location" value="<%= request_path %>">
755
+ </form>
756
+ <% end %>
757
+ </div>
758
+ <% end %>
759
+ <% if exception_hint %>
760
+ <h2>Hint: <%= exception_hint %></h2>
761
+ <% end %>
737
762
  </header>
738
763
  </div>
739
764
 
@@ -770,6 +795,7 @@
770
795
  (function() {
771
796
 
772
797
  var OID = "<%= id %>";
798
+ var csrfToken = "<%= csrf_token %>";
773
799
 
774
800
  var previousFrame = null;
775
801
  var previousFrameInfo = null;
@@ -780,6 +806,7 @@
780
806
  var req = new XMLHttpRequest();
781
807
  req.open("POST", "//" + window.location.host + <%== uri_prefix.gsub("<", "&lt;").inspect %> + "/__better_errors/" + OID + "/" + method, true);
782
808
  req.setRequestHeader("Content-Type", "application/json");
809
+ opts.csrfToken = csrfToken;
783
810
  req.send(JSON.stringify(opts));
784
811
  req.onreadystatechange = function() {
785
812
  if(req.readyState == 4) {
@@ -793,6 +820,28 @@
793
820
  return html.replace(/&/, "&amp;").replace(/</g, "&lt;");
794
821
  }
795
822
 
823
+ function hasConsoleBeenUsedPreviously() {
824
+ return !!document.cookie.split('; ').find(function(cookie) {
825
+ return cookie.startsWith('BetterErrors-has-used-console=');
826
+ });
827
+ }
828
+
829
+ var consoleHasBeenUsed = hasConsoleBeenUsedPreviously();
830
+
831
+ function consoleWasJustUsed() {
832
+ if (consoleHasBeenUsed) {
833
+ return;
834
+ }
835
+
836
+ hideConsoleHint();
837
+ consoleHasBeenUsed = true;
838
+ document.cookie = "BetterErrors-has-used-console=true;path=/;max-age=31536000;samesite"
839
+ }
840
+
841
+ function hideConsoleHint() {
842
+ document.querySelector('body').className += " console-has-been-used";
843
+ }
844
+
796
845
  function REPL(index) {
797
846
  this.index = index;
798
847
 
@@ -814,15 +863,20 @@
814
863
  this.inputElement = this.container.querySelector("input");
815
864
  this.outputElement = this.container.querySelector("pre");
816
865
 
866
+ if (consoleHasBeenUsed) {
867
+ hideConsoleHint();
868
+ }
869
+
817
870
  var self = this;
818
871
  this.inputElement.onkeydown = function(ev) {
819
872
  self.onKeyDown(ev);
873
+ consoleWasJustUsed();
820
874
  };
821
875
 
822
876
  this.setPrompt(">>");
823
877
 
824
878
  REPL.all[this.index] = this;
825
- }
879
+ };
826
880
 
827
881
  REPL.prototype.focus = function() {
828
882
  this.inputElement.focus();
@@ -943,7 +997,7 @@
943
997
  if(response.explanation) {
944
998
  el.innerHTML += "<p class='explanation'>" + escapeHTML(response.explanation) + "</p>";
945
999
  }
946
- el.innerHTML += "<p><a target='_new' href='https://github.com/charliesome/better_errors'>More about Better Errors</a></p>";
1000
+ el.innerHTML += "<p><a target='_new' href='https://github.com/BetterErrors/better_errors'>More about Better Errors</a></p>";
947
1001
  } else {
948
1002
  el.innerHTML = response.html;
949
1003