1# (c) 2005 Ian Bicking and contributors; written for Paste (http://pythonpaste.org)
2# Licensed under the MIT license: http://www.opensource.org/licenses/mit-license.php
3
4"""
5Error handler middleware
6"""
7import sys
8import traceback
9import cgi
10from six.moves import cStringIO as StringIO
11from paste.exceptions import formatter, collector, reporter
12from paste import wsgilib
13from paste import request
14import six
15
16__all__ = ['ErrorMiddleware', 'handle_exception']
17
18class _NoDefault(object):
19    def __repr__(self):
20        return '<NoDefault>'
21NoDefault = _NoDefault()
22
23class ErrorMiddleware(object):
24
25    """
26    Error handling middleware
27
28    Usage::
29
30        error_catching_wsgi_app = ErrorMiddleware(wsgi_app)
31
32    Settings:
33
34      ``debug``:
35          If true, then tracebacks will be shown in the browser.
36
37      ``error_email``:
38          an email address (or list of addresses) to send exception
39          reports to
40
41      ``error_log``:
42          a filename to append tracebacks to
43
44      ``show_exceptions_in_wsgi_errors``:
45          If true, then errors will be printed to ``wsgi.errors``
46          (frequently a server error log, or stderr).
47
48      ``from_address``, ``smtp_server``, ``error_subject_prefix``, ``smtp_username``, ``smtp_password``, ``smtp_use_tls``:
49          variables to control the emailed exception reports
50
51      ``error_message``:
52          When debug mode is off, the error message to show to users.
53
54      ``xmlhttp_key``:
55          When this key (default ``_``) is in the request GET variables
56          (not POST!), expect that this is an XMLHttpRequest, and the
57          response should be more minimal; it should not be a complete
58          HTML page.
59
60    Environment Configuration:
61
62      ``paste.throw_errors``:
63          If this setting in the request environment is true, then this
64          middleware is disabled. This can be useful in a testing situation
65          where you don't want errors to be caught and transformed.
66
67      ``paste.expected_exceptions``:
68          When this middleware encounters an exception listed in this
69          environment variable and when the ``start_response`` has not
70          yet occurred, the exception will be re-raised instead of being
71          caught.  This should generally be set by middleware that may
72          (but probably shouldn't be) installed above this middleware,
73          and wants to get certain exceptions.  Exceptions raised after
74          ``start_response`` have been called are always caught since
75          by definition they are no longer expected.
76
77    """
78
79    def __init__(self, application, global_conf=None,
80                 debug=NoDefault,
81                 error_email=None,
82                 error_log=None,
83                 show_exceptions_in_wsgi_errors=NoDefault,
84                 from_address=None,
85                 smtp_server=None,
86                 smtp_username=None,
87                 smtp_password=None,
88                 smtp_use_tls=False,
89                 error_subject_prefix=None,
90                 error_message=None,
91                 xmlhttp_key=None):
92        from paste.util import converters
93        self.application = application
94        # @@: global_conf should be handled elsewhere in a separate
95        # function for the entry point
96        if global_conf is None:
97            global_conf = {}
98        if debug is NoDefault:
99            debug = converters.asbool(global_conf.get('debug'))
100        if show_exceptions_in_wsgi_errors is NoDefault:
101            show_exceptions_in_wsgi_errors = converters.asbool(global_conf.get('show_exceptions_in_wsgi_errors'))
102        self.debug_mode = converters.asbool(debug)
103        if error_email is None:
104            error_email = (global_conf.get('error_email')
105                           or global_conf.get('admin_email')
106                           or global_conf.get('webmaster_email')
107                           or global_conf.get('sysadmin_email'))
108        self.error_email = converters.aslist(error_email)
109        self.error_log = error_log
110        self.show_exceptions_in_wsgi_errors = show_exceptions_in_wsgi_errors
111        if from_address is None:
112            from_address = global_conf.get('error_from_address', 'errors@localhost')
113        self.from_address = from_address
114        if smtp_server is None:
115            smtp_server = global_conf.get('smtp_server', 'localhost')
116        self.smtp_server = smtp_server
117        self.smtp_username = smtp_username or global_conf.get('smtp_username')
118        self.smtp_password = smtp_password or global_conf.get('smtp_password')
119        self.smtp_use_tls = smtp_use_tls or converters.asbool(global_conf.get('smtp_use_tls'))
120        self.error_subject_prefix = error_subject_prefix or ''
121        if error_message is None:
122            error_message = global_conf.get('error_message')
123        self.error_message = error_message
124        if xmlhttp_key is None:
125            xmlhttp_key = global_conf.get('xmlhttp_key', '_')
126        self.xmlhttp_key = xmlhttp_key
127
128    def __call__(self, environ, start_response):
129        """
130        The WSGI application interface.
131        """
132        # We want to be careful about not sending headers twice,
133        # and the content type that the app has committed to (if there
134        # is an exception in the iterator body of the response)
135        if environ.get('paste.throw_errors'):
136            return self.application(environ, start_response)
137        environ['paste.throw_errors'] = True
138
139        try:
140            __traceback_supplement__ = Supplement, self, environ
141            sr_checker = ResponseStartChecker(start_response)
142            app_iter = self.application(environ, sr_checker)
143            return self.make_catching_iter(app_iter, environ, sr_checker)
144        except:
145            exc_info = sys.exc_info()
146            try:
147                for expect in environ.get('paste.expected_exceptions', []):
148                    if isinstance(exc_info[1], expect):
149                        raise
150                start_response('500 Internal Server Error',
151                               [('content-type', 'text/html')],
152                               exc_info)
153                # @@: it would be nice to deal with bad content types here
154                response = self.exception_handler(exc_info, environ)
155                if six.PY3:
156                    response = response.encode('utf8')
157                return [response]
158            finally:
159                # clean up locals...
160                exc_info = None
161
162    def make_catching_iter(self, app_iter, environ, sr_checker):
163        if isinstance(app_iter, (list, tuple)):
164            # These don't raise
165            return app_iter
166        return CatchingIter(app_iter, environ, sr_checker, self)
167
168    def exception_handler(self, exc_info, environ):
169        simple_html_error = False
170        if self.xmlhttp_key:
171            get_vars = request.parse_querystring(environ)
172            if dict(get_vars).get(self.xmlhttp_key):
173                simple_html_error = True
174        return handle_exception(
175            exc_info, environ['wsgi.errors'],
176            html=True,
177            debug_mode=self.debug_mode,
178            error_email=self.error_email,
179            error_log=self.error_log,
180            show_exceptions_in_wsgi_errors=self.show_exceptions_in_wsgi_errors,
181            error_email_from=self.from_address,
182            smtp_server=self.smtp_server,
183            smtp_username=self.smtp_username,
184            smtp_password=self.smtp_password,
185            smtp_use_tls=self.smtp_use_tls,
186            error_subject_prefix=self.error_subject_prefix,
187            error_message=self.error_message,
188            simple_html_error=simple_html_error)
189
190class ResponseStartChecker(object):
191    def __init__(self, start_response):
192        self.start_response = start_response
193        self.response_started = False
194
195    def __call__(self, *args):
196        self.response_started = True
197        self.start_response(*args)
198
199class CatchingIter(object):
200
201    """
202    A wrapper around the application iterator that will catch
203    exceptions raised by the a generator, or by the close method, and
204    display or report as necessary.
205    """
206
207    def __init__(self, app_iter, environ, start_checker, error_middleware):
208        self.app_iterable = app_iter
209        self.app_iterator = iter(app_iter)
210        self.environ = environ
211        self.start_checker = start_checker
212        self.error_middleware = error_middleware
213        self.closed = False
214
215    def __iter__(self):
216        return self
217
218    def next(self):
219        __traceback_supplement__ = (
220            Supplement, self.error_middleware, self.environ)
221        if self.closed:
222            raise StopIteration
223        try:
224            return self.app_iterator.next()
225        except StopIteration:
226            self.closed = True
227            close_response = self._close()
228            if close_response is not None:
229                return close_response
230            else:
231                raise StopIteration
232        except:
233            self.closed = True
234            close_response = self._close()
235            exc_info = sys.exc_info()
236            response = self.error_middleware.exception_handler(
237                exc_info, self.environ)
238            if close_response is not None:
239                response += (
240                    '<hr noshade>Error in .close():<br>%s'
241                    % close_response)
242
243            if not self.start_checker.response_started:
244                self.start_checker('500 Internal Server Error',
245                               [('content-type', 'text/html')],
246                               exc_info)
247
248            if six.PY3:
249                response = response.encode('utf8')
250            return response
251    __next__ = next
252
253    def close(self):
254        # This should at least print something to stderr if the
255        # close method fails at this point
256        if not self.closed:
257            self._close()
258
259    def _close(self):
260        """Close and return any error message"""
261        if not hasattr(self.app_iterable, 'close'):
262            return None
263        try:
264            self.app_iterable.close()
265            return None
266        except:
267            close_response = self.error_middleware.exception_handler(
268                sys.exc_info(), self.environ)
269            return close_response
270
271
272class Supplement(object):
273
274    """
275    This is a supplement used to display standard WSGI information in
276    the traceback.
277    """
278
279    def __init__(self, middleware, environ):
280        self.middleware = middleware
281        self.environ = environ
282        self.source_url = request.construct_url(environ)
283
284    def extraData(self):
285        data = {}
286        cgi_vars = data[('extra', 'CGI Variables')] = {}
287        wsgi_vars = data[('extra', 'WSGI Variables')] = {}
288        hide_vars = ['paste.config', 'wsgi.errors', 'wsgi.input',
289                     'wsgi.multithread', 'wsgi.multiprocess',
290                     'wsgi.run_once', 'wsgi.version',
291                     'wsgi.url_scheme']
292        for name, value in self.environ.items():
293            if name.upper() == name:
294                if value:
295                    cgi_vars[name] = value
296            elif name not in hide_vars:
297                wsgi_vars[name] = value
298        if self.environ['wsgi.version'] != (1, 0):
299            wsgi_vars['wsgi.version'] = self.environ['wsgi.version']
300        proc_desc = tuple([int(bool(self.environ[key]))
301                           for key in ('wsgi.multiprocess',
302                                       'wsgi.multithread',
303                                       'wsgi.run_once')])
304        wsgi_vars['wsgi process'] = self.process_combos[proc_desc]
305        wsgi_vars['application'] = self.middleware.application
306        if 'paste.config' in self.environ:
307            data[('extra', 'Configuration')] = dict(self.environ['paste.config'])
308        return data
309
310    process_combos = {
311        # multiprocess, multithread, run_once
312        (0, 0, 0): 'Non-concurrent server',
313        (0, 1, 0): 'Multithreaded',
314        (1, 0, 0): 'Multiprocess',
315        (1, 1, 0): 'Multi process AND threads (?)',
316        (0, 0, 1): 'Non-concurrent CGI',
317        (0, 1, 1): 'Multithread CGI (?)',
318        (1, 0, 1): 'CGI',
319        (1, 1, 1): 'Multi thread/process CGI (?)',
320        }
321
322def handle_exception(exc_info, error_stream, html=True,
323                     debug_mode=False,
324                     error_email=None,
325                     error_log=None,
326                     show_exceptions_in_wsgi_errors=False,
327                     error_email_from='errors@localhost',
328                     smtp_server='localhost',
329                     smtp_username=None,
330                     smtp_password=None,
331                     smtp_use_tls=False,
332                     error_subject_prefix='',
333                     error_message=None,
334                     simple_html_error=False,
335                     ):
336    """
337    For exception handling outside of a web context
338
339    Use like::
340
341        import sys
342        from paste.exceptions.errormiddleware import handle_exception
343        try:
344            do stuff
345        except:
346            handle_exception(
347                sys.exc_info(), sys.stderr, html=False, ...other config...)
348
349    If you want to report, but not fully catch the exception, call
350    ``raise`` after ``handle_exception``, which (when given no argument)
351    will reraise the exception.
352    """
353    reported = False
354    exc_data = collector.collect_exception(*exc_info)
355    extra_data = ''
356    if error_email:
357        rep = reporter.EmailReporter(
358            to_addresses=error_email,
359            from_address=error_email_from,
360            smtp_server=smtp_server,
361            smtp_username=smtp_username,
362            smtp_password=smtp_password,
363            smtp_use_tls=smtp_use_tls,
364            subject_prefix=error_subject_prefix)
365        rep_err = send_report(rep, exc_data, html=html)
366        if rep_err:
367            extra_data += rep_err
368        else:
369            reported = True
370    if error_log:
371        rep = reporter.LogReporter(
372            filename=error_log)
373        rep_err = send_report(rep, exc_data, html=html)
374        if rep_err:
375            extra_data += rep_err
376        else:
377            reported = True
378    if show_exceptions_in_wsgi_errors:
379        rep = reporter.FileReporter(
380            file=error_stream)
381        rep_err = send_report(rep, exc_data, html=html)
382        if rep_err:
383            extra_data += rep_err
384        else:
385            reported = True
386    else:
387        line = ('Error - %s: %s\n'
388                % (exc_data.exception_type, exc_data.exception_value))
389        if six.PY3:
390            line = line.encode('utf8')
391        error_stream.write(line)
392    if html:
393        if debug_mode and simple_html_error:
394            return_error = formatter.format_html(
395                exc_data, include_hidden_frames=False,
396                include_reusable=False, show_extra_data=False)
397            reported = True
398        elif debug_mode and not simple_html_error:
399            error_html = formatter.format_html(
400                exc_data,
401                include_hidden_frames=True,
402                include_reusable=False)
403            head_html = formatter.error_css + formatter.hide_display_js
404            return_error = error_template(
405                head_html, error_html, extra_data)
406            extra_data = ''
407            reported = True
408        else:
409            msg = error_message or '''
410            An error occurred.  See the error logs for more information.
411            (Turn debug on to display exception reports here)
412            '''
413            return_error = error_template('', msg, '')
414    else:
415        return_error = None
416    if not reported and error_stream:
417        err_report = formatter.format_text(exc_data, show_hidden_frames=True)
418        err_report += '\n' + '-'*60 + '\n'
419        error_stream.write(err_report)
420    if extra_data:
421        error_stream.write(extra_data)
422    return return_error
423
424def send_report(rep, exc_data, html=True):
425    try:
426        rep.report(exc_data)
427    except:
428        output = StringIO()
429        traceback.print_exc(file=output)
430        if html:
431            return """
432            <p>Additionally an error occurred while sending the %s report:
433
434            <pre>%s</pre>
435            </p>""" % (
436                cgi.escape(str(rep)), output.getvalue())
437        else:
438            return (
439                "Additionally an error occurred while sending the "
440                "%s report:\n%s" % (str(rep), output.getvalue()))
441    else:
442        return ''
443
444def error_template(head_html, exception, extra):
445    return '''
446    <html>
447    <head>
448    <title>Server Error</title>
449    %s
450    </head>
451    <body>
452    <h1>Server Error</h1>
453    %s
454    %s
455    </body>
456    </html>''' % (head_html, exception, extra)
457
458def make_error_middleware(app, global_conf, **kw):
459    return ErrorMiddleware(app, global_conf=global_conf, **kw)
460
461doc_lines = ErrorMiddleware.__doc__.splitlines(True)
462for i in range(len(doc_lines)):
463    if doc_lines[i].strip().startswith('Settings'):
464        make_error_middleware.__doc__ = ''.join(doc_lines[i:])
465        break
466del i, doc_lines
467