File Coverage

blib/lib/Dancer2/Plugin/LogReport.pm
Criterion Covered Total %
statement 88 101 87.1
branch 23 36 63.8
condition 26 42 61.9
subroutine 23 31 74.1
pod 1 1 100.0
total 161 211 76.3


line stmt bran cond sub pod time code
1             # This code is part of Perl distribution Dancer2-Plugin-LogReport version 2.02.
2             # The POD got stripped from this file by OODoc version 3.05.
3             # For contributors see file ChangeLog.
4              
5             # This software is copyright (c) 2015-2025 by Mark Overmeer.
6              
7             # This is free software; you can redistribute it and/or modify it under
8             # the same terms as the Perl 5 programming language system itself.
9             # SPDX-License-Identifier: Artistic-1.0-Perl OR GPL-1.0-or-later
10              
11             #oodist: *** DO NOT USE THIS VERSION FOR PRODUCTION ***
12             #oodist: This file contains OODoc-style documentation which will get stripped
13             #oodist: during its release in the distribution. You can use this file for
14             #oodist: testing, however the code of this development version may be broken!
15              
16             package Dancer2::Plugin::LogReport;{
17             our $VERSION = '2.02';
18             }
19              
20              
21 2     2   1346486 use warnings;
  2         20  
  2         133  
22 2     2   13 use strict;
  2         6  
  2         48  
23 2     2   11 use version;
  2         4  
  2         18  
24              
25 2     2   864 BEGIN { use Log::Report () } # require very early
  2     0   103961  
  2         94  
  0         0  
26              
27 2     2   1378 use Dancer2::Plugin; # register
  2         34715  
  2         21  
28 2     2   41267 use Dancer2::Plugin::LogReport::Message ();
  2         14  
  2         66  
29              
30 2         19 use Log::Report 'dancer2-plugin-logreport',
31             syntax => 'REPORT',
32 2     2   13 message_class => 'Dancer2::Plugin::LogReport::Message';
  2         3  
33              
34 2     2   624 use Scalar::Util qw/blessed refaddr/;
  2         4  
  2         2691  
35              
36             my %_all_dsls; # The DSLs for each app within the Dancer application
37             my $_settings;
38              
39             #--------------------
40              
41             # "use" import
42             sub import
43 2     2   20 { my $class = shift;
44              
45             # Import Log::Report into the caller. Import options get passed through
46 2 50       127 my $level = version->parse($Dancer2::Plugin::VERSION) > 0.166001 ? '+1' : '+2';
47 2         22 Log::Report->import($level, @_, syntax => 'LONG');
48              
49             # Ensure the overridden import method is called (from Exporter::Tiny)
50             # note this does not (currently) pass options through.
51 2         618 my $caller = caller;
52 2         30 $class->SUPER::import( {into => $caller} );
53             }
54              
55             my %session_messages;
56             # The default reasons that a message will be displayed to the end user
57             my @default_reasons = qw/NOTICE WARNING MISTAKE ERROR FAULT ALERT FAILURE PANIC/;
58             my $hide_real_message; # Used to hide the real message to the end user
59             my $messages_variable = $_settings->{messages_key} || 'messages';
60              
61              
62             # Dancer2 import
63             on_plugin_import
64             { # The DSL for the particular app that is loading the plugin
65             my $dsl = shift; # capture global singleton
66             $_all_dsls{refaddr($dsl->app)} = $dsl;
67              
68             my $settings = $_settings = plugin_setting;
69              
70             # Any exceptions in routes should not be happening. Therefore,
71             # raise to PANIC.
72             $dsl->app->add_hook(
73             Dancer2::Core::Hook->new(
74             name => 'core.app.route_exception',
75             code => sub {
76             my ($app, $error) = @_;
77              
78             # If there is no request object then we are in an early hook
79             # and Dancer will not handle an exception cleanly (which will
80             # result in a stacktrace to the browser, a potential security
81             # vulnerability). Therefore in this case do not raise as fatal.
82             # Note: Dancer2 always seem to have a request at this point
83             # now, so this has probably been overtaken by events. Also, the
84             # hook_exception below is now used instead to avoid any stack
85             # information in the browser.
86             my $is_fatal = $app->request ? 1 : 0;
87              
88             # Use a flag to avoid the panic here throwing a second panic in
89             # the exception hook below.
90             $app->request->var(_lr_panic_thrown => 1)
91             if $app->request;
92              
93             report {is_fatal => $is_fatal}, 'PANIC' => $error;
94             },
95             ),
96             );
97              
98             $dsl->app->add_hook(
99             Dancer2::Core::Hook->new(
100             name => 'core.app.hook_exception',
101             code => sub {
102             my ($app, $error, $hook_name) = @_;
103             my $fatal_error_message = _fatal_error_message();
104              
105             # If we are after the request then we need to override the
106             # content now (which will likely be an ugly exception message).
107             # This is because no further changes are made to the content
108             # after the request (unlike at other times of the response
109             # cycle)
110             if ($hook_name =~ /after_request/)
111             { $app->response->content($fatal_error_message);
112              
113             # Prevent dancer throwing in its own ugly messages
114             $app->response->halt;
115             }
116              
117             # See comment above about not throwing same panic twice
118             $app->request->var('_lr_panic_thrown')
119             or report PANIC => $error;
120             },
121             ),
122             ) if version->parse($Dancer2::Plugin::VERSION) >= 2;
123              
124             if($settings->{handle_http_errors})
125             { # Need after_error for HTTP errors (eg 404) so as to
126             # be able to change the forwarding location
127             $dsl->app->add_hook(Dancer2::Core::Hook->new(
128             name => 'after_error',
129             code => sub {
130             my $error = shift;
131             my $msg = __($error->status . ": " . Dancer2::Core::HTTP->status_message($error->status));
132              
133             #XXX This doesn't work at the moment. The DSL at this point
134             # doesn't seem to respond to changes in the session or
135             # forward requests
136             _forward_home($msg);
137             },
138             ));
139             }
140              
141             $dsl->app->add_hook(Dancer2::Core::Hook->new(
142             name => 'after_layout_render',
143             code => sub {
144             my $session = $dsl->app->session;
145             $session->write($messages_variable => []);
146             },
147             ));
148              
149             # Define which messages are saved to the session for later display
150             # to the user. This can be configured in the config file, or we
151             # choose some sensible defaults.
152             my $sm = $settings->{session_messages} // \@default_reasons;
153             $session_messages{$_} = 1
154             for ref $sm eq 'ARRAY' ? @$sm : $sm;
155              
156             if(my $forward_template = $settings->{forward_template})
157             { # Add a route for the specified template
158             $dsl->app->add_route(
159             method => 'get',
160             regexp => qr!^/\Q$forward_template\E$!,,
161             code => sub { shift->app->template($forward_template) }
162             );
163             # Forward to that new route
164             $settings->{forward_url} = $forward_template;
165             }
166              
167             # This is so that all messages go into the session, to be displayed
168             # on the web page (if required)
169             dispatcher CALLBACK => 'error_handler', callback => \&_error_handler, mode => 'DEBUG'
170             unless dispatcher find => 'error_handler';
171              
172             Log::Report::Dispatcher->addSkipStack( sub { $_[0][0] =~
173             m/ ^ Dancer2\:\:(?:Plugin|Logger)
174             | ^ Dancer2\:\:Core\:\:Role\:\:DSL
175             | ^ Dancer2\:\:Core\:\:App
176             /x
177             });
178              
179             }; # ";" required!
180              
181              
182             sub process($$)
183 1     1 1 10070 { my ($dsl, $coderef) = @_;
184 1 50       29 ref $coderef eq 'CODE' or report PANIC => "plugin process() requires a CODE";
185 1     1   12 try { $coderef->() } hide => 'ALL', on_die => 'PANIC';
  1         655  
186 1         5391 my $e = $@; # fragile
187 1         6 $e->reportAll(is_fatal => 0);
188 1 50       17 $e->success || 0;
189             }
190              
191             register process => \&process;
192              
193              
194             my @user_fatal_handlers;
195              
196             plugin_keywords fatal_handler => sub {
197             my ($plugin, $sub) = @_;
198             push @user_fatal_handlers, $sub;
199             };
200              
201             sub _get_dsl()
202             { # Similar trick to Log::Report::Dispatcher::collectStack(), this time to
203             # work out which Dancer app we were called from. We then use that app's
204             # DSL. If we use the wrong DSL, then the request object will not be
205             # available and we won't be able to forward if needed
206              
207             package DB;
208 2     2   18 use Scalar::Util qw/blessed refaddr/;
  2         3  
  2         3085  
209              
210 29     29   88 my (@ret, $ref, $i);
211              
212 29   100     54 do { @ret = caller ++$i }
  260   66     3218  
      66        
      66        
      33        
      66        
213             until !@ret
214             || (blessed $DB::args[0] && blessed $DB::args[0] eq 'Dancer2::Core::App' && ( $ref = refaddr $DB::args[0] ))
215             || (blessed $DB::args[1] && blessed $DB::args[1] eq 'Dancer2::Core::App' && ( $ref = refaddr $DB::args[1] ));
216              
217 29 50       190 $ref ? $_all_dsls{$ref} : undef;
218             }
219              
220             sub _fatal_error_message
221             {
222             # In a production server, we don't want the end user seeing (unexpected)
223             # exception messages, for both security and usability. If we detect
224             # that this is a production server (show_errors is 0), then we change
225             # the specific error to a generic error, when displayed to the user.
226             # The message can be customised in the config file.
227             # We evaluate this each message to allow show_errors to be set in the
228             # application (specifically makes testing a lot easier)
229 14     14   36 my $dsl = _get_dsl();
230             !$dsl->app->config->{show_errors}
231 14 100 50     560 && ($_settings->{fatal_error_message} // "An unexpected error has occurred");
232             }
233              
234             sub _message_add($)
235 11     11   31 { my $msg = shift;
236              
237 11 100 66     45 $session_messages{$msg->reason} && ! $msg->inClass('no_session')
238             or return;
239              
240             # Get the DSL, only now that we know it's needed
241 10         270 my $dsl = _get_dsl();
242              
243 10 50       54 if (!$dsl)
244 0         0 { report +{ to => 'default' }, NOTICE => "Unable to write message $msg to the session. "
245             . "Have you loaded Dancer2::Plugin::LogReport to all your separate Dancer apps?";
246 0         0 return;
247             }
248              
249 10         51 my $app = $dsl->app;
250              
251             # Check that we can write to the session before continuing. We can't
252             # check $app->session as that can be true regardless. Instead, we check
253             # for request(), which is used to access the cookies of a session.
254 10 50       49 $app->request or return;
255              
256 10         30 my $fatal_error_message = _fatal_error_message();
257              
258             $hide_real_message->{$_} = $fatal_error_message
259 10         231 for qw/FAULT ALERT FAILURE PANIC/;
260              
261 10         42 my $r = $msg->reason;
262 10 100       39 if(my $newm = $hide_real_message->{$r})
263 5         25 { $msg = __$newm;
264 5         309 $msg->reason($r);
265             }
266              
267 10         314 my $session = $app->session;
268 10         18320 my $msgs = $session->read($messages_variable);
269 10         404 push @$msgs, $msg;
270 10         44 $session->write($messages_variable => $msgs);
271              
272 10   50     977 ($dsl || undef, $msg);
273             }
274              
275             #--------------------
276              
277             sub _forward_home($)
278 8     8   32 { my ($dsl, $msg) = _message_add(shift);
279 8   33     37 $dsl ||= _get_dsl();
280              
281 8   50     51 my $page = $_settings->{forward_url} || '/';
282              
283             # Don't forward if it's a GET request to the error page, as it will cause a
284             # recursive loop. In this case, return the fatal error message as plain
285             # text to render that instead. If we can't do that because it's too early
286             # in the request, then let Dancer handle this with its default error
287             # handling
288 8 50       55 my $req = $dsl->app->request or return;
289              
290 8 100 66     42 $req->uri eq $page && $req->is_get
291             ? $dsl->send_as(plain => "$msg")
292             : $dsl->redirect($page);
293             }
294              
295             sub _error_handler($$$$)
296 13     13   1817 { my ($disp, $options, $reason, $message) = @_;
297              
298             my $default_handler = sub {
299              
300             # Check whether this fatal message has been caught, in which case we
301             # don't want to redirect
302             return _message_add($message)
303 9 100 100 9   61 if exists $options->{is_fatal} && !$options->{is_fatal};
304              
305 8         34 _forward_home($message);
306 13         94 };
307              
308             my $user_fatal_handler = sub {
309 3     3   8 my $return;
310 3         11 foreach my $ufh (@user_fatal_handlers)
311 5 50       81 { last if $return = $ufh->(_get_dsl, $message, $reason);
312             }
313 1 50       28 $default_handler->($message) if !$return;
314 13         85 };
315              
316 13 100       51 my $fatal_handler = @user_fatal_handlers ? $user_fatal_handler : $default_handler;
317 13         90 $message->reason($reason);
318              
319             my %handler =
320             ( # Default do nothing for the moment (TRACE|ASSERT|INFO)
321 2     2   8 default => sub { _message_add $message },
322              
323             # A user-created error condition that is not recoverable.
324             # This could have already been caught by the process
325             # subroutine, in which case we should continue running
326             # of the program. In all other cases, we should bail,
327             # out.
328 13         132 ERROR => $fatal_handler,
329              
330             # All these are fatal errors.
331             FAULT => $fatal_handler,
332             ALERT => $fatal_handler,
333             FAILURE => $fatal_handler,
334             PANIC => $fatal_handler,
335             );
336              
337 13   66     58 my $call = $handler{$reason} || $handler{default};
338 13         36 $call->();
339             }
340              
341             sub _report($@) {
342 2     2   9 my ($reason, $dsl) = (shift, shift);
343              
344 2 50 33     22 my $msg = blessed $_[0] && $_[0]->isa('Log::Report::Message')
345             ? $_[0] : Dancer2::Core::Role::Logger::_serialize(@_);
346              
347 2 50       35 if ($reason eq 'SUCCESS')
348 0 0       0 { $msg = __$msg unless blessed $msg;
349 0         0 $msg = $msg->clone(_class => 'success');
350 0         0 $reason = 'NOTICE';
351             }
352 2         18 report uc($reason) => $msg;
353             }
354              
355 1     1   31065 register trace => sub { _report(TRACE => @_) };
356 0     0   0 register assert => sub { _report(ASSERT => @_) };
357 1     1   244558 register notice => sub { _report(NOTICE => @_) };
358 0     0     register mistake => sub { _report(MISTAKE => @_) };
359 0     0     register panic => sub { _report(PANIC => @_) };
360 0     0     register alert => sub { _report(ALERT => @_) };
361 0     0     register fault => sub { _report(FAULT => @_) };
362 0     0     register failure => sub { _report(FAILURE => @_) };
363              
364 0     0     register success => sub { _report(SUCCESS => @_) };
365              
366             register_plugin for_versions => ['2'];
367              
368             #--------------------
369              
370             1;