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"""
4Exception-catching middleware that allows interactive debugging.
5
6This middleware catches all unexpected exceptions.  A normal
7traceback, like produced by
8``paste.exceptions.errormiddleware.ErrorMiddleware`` is given, plus
9controls to see local variables and evaluate expressions in a local
10context.
11
12This can only be used in single-process environments, because
13subsequent requests must go back to the same process that the
14exception originally occurred in.  Threaded or non-concurrent
15environments both work.
16
17This shouldn't be used in production in any way.  That would just be
18silly.
19
20If calling from an XMLHttpRequest call, if the GET variable ``_`` is
21given then it will make the response more compact (and less
22Javascripty), since if you use innerHTML it'll kill your browser.  You
23can look for the header X-Debug-URL in your 500 responses if you want
24to see the full debuggable traceback.  Also, this URL is printed to
25``wsgi.errors``, so you can open it up in another browser window.
26"""
27
28from __future__ import print_function
29
30import sys
31import os
32import cgi
33import traceback
34import six
35from six.moves import cStringIO as StringIO
36import pprint
37import itertools
38import time
39import re
40from paste.exceptions import errormiddleware, formatter, collector
41from paste import wsgilib
42from paste import urlparser
43from paste import httpexceptions
44from paste import registry
45from paste import request
46from paste import response
47from paste.evalexception import evalcontext
48
49limit = 200
50
51def html_quote(v):
52    """
53    Escape HTML characters, plus translate None to ''
54    """
55    if v is None:
56        return ''
57    return cgi.escape(str(v), 1)
58
59def preserve_whitespace(v, quote=True):
60    """
61    Quote a value for HTML, preserving whitespace (translating
62    newlines to ``<br>`` and multiple spaces to use ``&nbsp;``).
63
64    If ``quote`` is true, then the value will be HTML quoted first.
65    """
66    if quote:
67        v = html_quote(v)
68    v = v.replace('\n', '<br>\n')
69    v = re.sub(r'()(  +)', _repl_nbsp, v)
70    v = re.sub(r'(\n)( +)', _repl_nbsp, v)
71    v = re.sub(r'^()( +)', _repl_nbsp, v)
72    return '<code>%s</code>' % v
73
74def _repl_nbsp(match):
75    if len(match.group(2)) == 1:
76        return '&nbsp;'
77    return match.group(1) + '&nbsp;' * (len(match.group(2))-1) + ' '
78
79def simplecatcher(application):
80    """
81    A simple middleware that catches errors and turns them into simple
82    tracebacks.
83    """
84    def simplecatcher_app(environ, start_response):
85        try:
86            return application(environ, start_response)
87        except:
88            out = StringIO()
89            traceback.print_exc(file=out)
90            start_response('500 Server Error',
91                           [('content-type', 'text/html')],
92                           sys.exc_info())
93            res = out.getvalue()
94            return ['<h3>Error</h3><pre>%s</pre>'
95                    % html_quote(res)]
96    return simplecatcher_app
97
98def wsgiapp():
99    """
100    Turns a function or method into a WSGI application.
101    """
102    def decorator(func):
103        def wsgiapp_wrapper(*args):
104            # we get 3 args when this is a method, two when it is
105            # a function :(
106            if len(args) == 3:
107                environ = args[1]
108                start_response = args[2]
109                args = [args[0]]
110            else:
111                environ, start_response = args
112                args = []
113            def application(environ, start_response):
114                form = wsgilib.parse_formvars(environ,
115                                              include_get_vars=True)
116                headers = response.HeaderDict(
117                    {'content-type': 'text/html',
118                     'status': '200 OK'})
119                form['environ'] = environ
120                form['headers'] = headers
121                res = func(*args, **form.mixed())
122                status = headers.pop('status')
123                start_response(status, headers.headeritems())
124                return [res]
125            app = httpexceptions.make_middleware(application)
126            app = simplecatcher(app)
127            return app(environ, start_response)
128        wsgiapp_wrapper.exposed = True
129        return wsgiapp_wrapper
130    return decorator
131
132def get_debug_info(func):
133    """
134    A decorator (meant to be used under ``wsgiapp()``) that resolves
135    the ``debugcount`` variable to a ``DebugInfo`` object (or gives an
136    error if it can't be found).
137    """
138    def debug_info_replacement(self, **form):
139        try:
140            if 'debugcount' not in form:
141                raise ValueError('You must provide a debugcount parameter')
142            debugcount = form.pop('debugcount')
143            try:
144                debugcount = int(debugcount)
145            except ValueError:
146                raise ValueError('Bad value for debugcount')
147            if debugcount not in self.debug_infos:
148                raise ValueError(
149                    'Debug %s no longer found (maybe it has expired?)'
150                    % debugcount)
151            debug_info = self.debug_infos[debugcount]
152            return func(self, debug_info=debug_info, **form)
153        except ValueError as e:
154            form['headers']['status'] = '500 Server Error'
155            return '<html>There was an error: %s</html>' % html_quote(e)
156    return debug_info_replacement
157
158debug_counter = itertools.count(int(time.time()))
159def get_debug_count(environ):
160    """
161    Return the unique debug count for the current request
162    """
163    if 'paste.evalexception.debug_count' in environ:
164        return environ['paste.evalexception.debug_count']
165    else:
166        environ['paste.evalexception.debug_count'] = next = six.next(debug_counter)
167        return next
168
169class EvalException(object):
170
171    def __init__(self, application, global_conf=None,
172                 xmlhttp_key=None):
173        self.application = application
174        self.debug_infos = {}
175        if xmlhttp_key is None:
176            if global_conf is None:
177                xmlhttp_key = '_'
178            else:
179                xmlhttp_key = global_conf.get('xmlhttp_key', '_')
180        self.xmlhttp_key = xmlhttp_key
181
182    def __call__(self, environ, start_response):
183        assert not environ['wsgi.multiprocess'], (
184            "The EvalException middleware is not usable in a "
185            "multi-process environment")
186        environ['paste.evalexception'] = self
187        if environ.get('PATH_INFO', '').startswith('/_debug/'):
188            return self.debug(environ, start_response)
189        else:
190            return self.respond(environ, start_response)
191
192    def debug(self, environ, start_response):
193        assert request.path_info_pop(environ) == '_debug'
194        next_part = request.path_info_pop(environ)
195        method = getattr(self, next_part, None)
196        if not method:
197            exc = httpexceptions.HTTPNotFound(
198                '%r not found when parsing %r'
199                % (next_part, wsgilib.construct_url(environ)))
200            return exc.wsgi_application(environ, start_response)
201        if not getattr(method, 'exposed', False):
202            exc = httpexceptions.HTTPForbidden(
203                '%r not allowed' % next_part)
204            return exc.wsgi_application(environ, start_response)
205        return method(environ, start_response)
206
207    def media(self, environ, start_response):
208        """
209        Static path where images and other files live
210        """
211        app = urlparser.StaticURLParser(
212            os.path.join(os.path.dirname(__file__), 'media'))
213        return app(environ, start_response)
214    media.exposed = True
215
216    def mochikit(self, environ, start_response):
217        """
218        Static path where MochiKit lives
219        """
220        app = urlparser.StaticURLParser(
221            os.path.join(os.path.dirname(__file__), 'mochikit'))
222        return app(environ, start_response)
223    mochikit.exposed = True
224
225    def summary(self, environ, start_response):
226        """
227        Returns a JSON-format summary of all the cached
228        exception reports
229        """
230        start_response('200 OK', [('Content-type', 'text/x-json')])
231        data = [];
232        items = self.debug_infos.values()
233        items.sort(lambda a, b: cmp(a.created, b.created))
234        data = [item.json() for item in items]
235        return [repr(data)]
236    summary.exposed = True
237
238    def view(self, environ, start_response):
239        """
240        View old exception reports
241        """
242        id = int(request.path_info_pop(environ))
243        if id not in self.debug_infos:
244            start_response(
245                '500 Server Error',
246                [('Content-type', 'text/html')])
247            return [
248                "Traceback by id %s does not exist (maybe "
249                "the server has been restarted?)"
250                % id]
251        debug_info = self.debug_infos[id]
252        return debug_info.wsgi_application(environ, start_response)
253    view.exposed = True
254
255    def make_view_url(self, environ, base_path, count):
256        return base_path + '/_debug/view/%s' % count
257
258    #@wsgiapp()
259    #@get_debug_info
260    def show_frame(self, tbid, debug_info, **kw):
261        frame = debug_info.frame(int(tbid))
262        vars = frame.tb_frame.f_locals
263        if vars:
264            registry.restorer.restoration_begin(debug_info.counter)
265            local_vars = make_table(vars)
266            registry.restorer.restoration_end()
267        else:
268            local_vars = 'No local vars'
269        return input_form(tbid, debug_info) + local_vars
270
271    show_frame = wsgiapp()(get_debug_info(show_frame))
272
273    #@wsgiapp()
274    #@get_debug_info
275    def exec_input(self, tbid, debug_info, input, **kw):
276        if not input.strip():
277            return ''
278        input = input.rstrip() + '\n'
279        frame = debug_info.frame(int(tbid))
280        vars = frame.tb_frame.f_locals
281        glob_vars = frame.tb_frame.f_globals
282        context = evalcontext.EvalContext(vars, glob_vars)
283        registry.restorer.restoration_begin(debug_info.counter)
284        output = context.exec_expr(input)
285        registry.restorer.restoration_end()
286        input_html = formatter.str2html(input)
287        return ('<code style="color: #060">&gt;&gt;&gt;</code> '
288                '<code>%s</code><br>\n%s'
289                % (preserve_whitespace(input_html, quote=False),
290                   preserve_whitespace(output)))
291
292    exec_input = wsgiapp()(get_debug_info(exec_input))
293
294    def respond(self, environ, start_response):
295        if environ.get('paste.throw_errors'):
296            return self.application(environ, start_response)
297        base_path = request.construct_url(environ, with_path_info=False,
298                                          with_query_string=False)
299        environ['paste.throw_errors'] = True
300        started = []
301        def detect_start_response(status, headers, exc_info=None):
302            try:
303                return start_response(status, headers, exc_info)
304            except:
305                raise
306            else:
307                started.append(True)
308        try:
309            __traceback_supplement__ = errormiddleware.Supplement, self, environ
310            app_iter = self.application(environ, detect_start_response)
311            try:
312                return_iter = list(app_iter)
313                return return_iter
314            finally:
315                if hasattr(app_iter, 'close'):
316                    app_iter.close()
317        except:
318            exc_info = sys.exc_info()
319            for expected in environ.get('paste.expected_exceptions', []):
320                if isinstance(exc_info[1], expected):
321                    raise
322
323            # Tell the Registry to save its StackedObjectProxies current state
324            # for later restoration
325            registry.restorer.save_registry_state(environ)
326
327            count = get_debug_count(environ)
328            view_uri = self.make_view_url(environ, base_path, count)
329            if not started:
330                headers = [('content-type', 'text/html')]
331                headers.append(('X-Debug-URL', view_uri))
332                start_response('500 Internal Server Error',
333                               headers,
334                               exc_info)
335            msg = 'Debug at: %s\n' % view_uri
336            if six.PY3:
337                msg = msg.encode('utf8')
338            environ['wsgi.errors'].write(msg)
339
340            exc_data = collector.collect_exception(*exc_info)
341            debug_info = DebugInfo(count, exc_info, exc_data, base_path,
342                                   environ, view_uri)
343            assert count not in self.debug_infos
344            self.debug_infos[count] = debug_info
345
346            if self.xmlhttp_key:
347                get_vars = request.parse_querystring(environ)
348                if dict(get_vars).get(self.xmlhttp_key):
349                    exc_data = collector.collect_exception(*exc_info)
350                    html = formatter.format_html(
351                        exc_data, include_hidden_frames=False,
352                        include_reusable=False, show_extra_data=False)
353                    return [html]
354
355            # @@: it would be nice to deal with bad content types here
356            return debug_info.content()
357
358    def exception_handler(self, exc_info, environ):
359        simple_html_error = False
360        if self.xmlhttp_key:
361            get_vars = request.parse_querystring(environ)
362            if dict(get_vars).get(self.xmlhttp_key):
363                simple_html_error = True
364        return errormiddleware.handle_exception(
365            exc_info, environ['wsgi.errors'],
366            html=True,
367            debug_mode=True,
368            simple_html_error=simple_html_error)
369
370class DebugInfo(object):
371
372    def __init__(self, counter, exc_info, exc_data, base_path,
373                 environ, view_uri):
374        self.counter = counter
375        self.exc_data = exc_data
376        self.base_path = base_path
377        self.environ = environ
378        self.view_uri = view_uri
379        self.created = time.time()
380        self.exc_type, self.exc_value, self.tb = exc_info
381        __exception_formatter__ = 1
382        self.frames = []
383        n = 0
384        tb = self.tb
385        while tb is not None and (limit is None or n < limit):
386            if tb.tb_frame.f_locals.get('__exception_formatter__'):
387                # Stop recursion. @@: should make a fake ExceptionFrame
388                break
389            self.frames.append(tb)
390            tb = tb.tb_next
391            n += 1
392
393    def json(self):
394        """Return the JSON-able representation of this object"""
395        return {
396            'uri': self.view_uri,
397            'created': time.strftime('%c', time.gmtime(self.created)),
398            'created_timestamp': self.created,
399            'exception_type': str(self.exc_type),
400            'exception': str(self.exc_value),
401            }
402
403    def frame(self, tbid):
404        for frame in self.frames:
405            if id(frame) == tbid:
406                return frame
407        else:
408            raise ValueError("No frame by id %s found from %r" % (tbid, self.frames))
409
410    def wsgi_application(self, environ, start_response):
411        start_response('200 OK', [('content-type', 'text/html')])
412        return self.content()
413
414    def content(self):
415        html = format_eval_html(self.exc_data, self.base_path, self.counter)
416        head_html = (formatter.error_css + formatter.hide_display_js)
417        head_html += self.eval_javascript()
418        repost_button = make_repost_button(self.environ)
419        page = error_template % {
420            'repost_button': repost_button or '',
421            'head_html': head_html,
422            'body': html}
423        if six.PY3:
424            page = page.encode('utf8')
425        return [page]
426
427    def eval_javascript(self):
428        base_path = self.base_path + '/_debug'
429        return (
430            '<script type="text/javascript" src="%s/media/MochiKit.packed.js">'
431            '</script>\n'
432            '<script type="text/javascript" src="%s/media/debug.js">'
433            '</script>\n'
434            '<script type="text/javascript">\n'
435            'debug_base = %r;\n'
436            'debug_count = %r;\n'
437            '</script>\n'
438            % (base_path, base_path, base_path, self.counter))
439
440class EvalHTMLFormatter(formatter.HTMLFormatter):
441
442    def __init__(self, base_path, counter, **kw):
443        super(EvalHTMLFormatter, self).__init__(**kw)
444        self.base_path = base_path
445        self.counter = counter
446
447    def format_source_line(self, filename, frame):
448        line = formatter.HTMLFormatter.format_source_line(
449            self, filename, frame)
450        return (line +
451                '  <a href="#" class="switch_source" '
452                'tbid="%s" onClick="return showFrame(this)">&nbsp; &nbsp; '
453                '<img src="%s/_debug/media/plus.jpg" border=0 width=9 '
454                'height=9> &nbsp; &nbsp;</a>'
455                % (frame.tbid, self.base_path))
456
457def make_table(items):
458    if isinstance(items, dict):
459        items = items.items()
460        items.sort()
461    rows = []
462    i = 0
463    for name, value in items:
464        i += 1
465        out = StringIO()
466        try:
467            pprint.pprint(value, out)
468        except Exception as e:
469            print('Error: %s' % e, file=out)
470        value = html_quote(out.getvalue())
471        if len(value) > 100:
472            # @@: This can actually break the HTML :(
473            # should I truncate before quoting?
474            orig_value = value
475            value = value[:100]
476            value += '<a class="switch_source" style="background-color: #999" href="#" onclick="return expandLong(this)">...</a>'
477            value += '<span style="display: none">%s</span>' % orig_value[100:]
478        value = formatter.make_wrappable(value)
479        if i % 2:
480            attr = ' class="even"'
481        else:
482            attr = ' class="odd"'
483        rows.append('<tr%s style="vertical-align: top;"><td>'
484                    '<b>%s</b></td><td style="overflow: auto">%s<td></tr>'
485                    % (attr, html_quote(name),
486                       preserve_whitespace(value, quote=False)))
487    return '<table>%s</table>' % (
488        '\n'.join(rows))
489
490def format_eval_html(exc_data, base_path, counter):
491    short_formatter = EvalHTMLFormatter(
492        base_path=base_path,
493        counter=counter,
494        include_reusable=False)
495    short_er = short_formatter.format_collected_data(exc_data)
496    long_formatter = EvalHTMLFormatter(
497        base_path=base_path,
498        counter=counter,
499        show_hidden_frames=True,
500        show_extra_data=False,
501        include_reusable=False)
502    long_er = long_formatter.format_collected_data(exc_data)
503    text_er = formatter.format_text(exc_data, show_hidden_frames=True)
504    if short_formatter.filter_frames(exc_data.frames) != \
505        long_formatter.filter_frames(exc_data.frames):
506        # Only display the full traceback when it differs from the
507        # short version
508        full_traceback_html = """
509    <br>
510    <script type="text/javascript">
511    show_button('full_traceback', 'full traceback')
512    </script>
513    <div id="full_traceback" class="hidden-data">
514    %s
515    </div>
516        """ % long_er
517    else:
518        full_traceback_html = ''
519
520    return """
521    %s
522    %s
523    <br>
524    <script type="text/javascript">
525    show_button('text_version', 'text version')
526    </script>
527    <div id="text_version" class="hidden-data">
528    <textarea style="width: 100%%" rows=10 cols=60>%s</textarea>
529    </div>
530    """ % (short_er, full_traceback_html, cgi.escape(text_er))
531
532def make_repost_button(environ):
533    url = request.construct_url(environ)
534    if environ['REQUEST_METHOD'] == 'GET':
535        return ('<button onclick="window.location.href=%r">'
536                'Re-GET Page</button><br>' % url)
537    else:
538        # @@: I'd like to reconstruct this, but I can't because
539        # the POST body is probably lost at this point, and
540        # I can't get it back :(
541        return None
542    # @@: Use or lose the following code block
543    """
544    fields = []
545    for name, value in wsgilib.parse_formvars(
546        environ, include_get_vars=False).items():
547        if hasattr(value, 'filename'):
548            # @@: Arg, we'll just submit the body, and leave out
549            # the filename :(
550            value = value.value
551        fields.append(
552            '<input type="hidden" name="%s" value="%s">'
553            % (html_quote(name), html_quote(value)))
554    return '''
555<form action="%s" method="POST">
556%s
557<input type="submit" value="Re-POST Page">
558</form>''' % (url, '\n'.join(fields))
559"""
560
561
562def input_form(tbid, debug_info):
563    return '''
564<form action="#" method="POST"
565 onsubmit="return submitInput($(\'submit_%(tbid)s\'), %(tbid)s)">
566<div id="exec-output-%(tbid)s" style="width: 95%%;
567 padding: 5px; margin: 5px; border: 2px solid #000;
568 display: none"></div>
569<input type="text" name="input" id="debug_input_%(tbid)s"
570 style="width: 100%%"
571 autocomplete="off" onkeypress="upArrow(this, event)"><br>
572<input type="submit" value="Execute" name="submitbutton"
573 onclick="return submitInput(this, %(tbid)s)"
574 id="submit_%(tbid)s"
575 input-from="debug_input_%(tbid)s"
576 output-to="exec-output-%(tbid)s">
577<input type="submit" value="Expand"
578 onclick="return expandInput(this)">
579</form>
580 ''' % {'tbid': tbid}
581
582error_template = '''
583<html>
584<head>
585 <title>Server Error</title>
586 %(head_html)s
587</head>
588<body>
589
590<div id="error-area" style="display: none; background-color: #600; color: #fff; border: 2px solid black">
591<div id="error-container"></div>
592<button onclick="return clearError()">clear this</button>
593</div>
594
595%(repost_button)s
596
597%(body)s
598
599</body>
600</html>
601'''
602
603def make_eval_exception(app, global_conf, xmlhttp_key=None):
604    """
605    Wraps the application in an interactive debugger.
606
607    This debugger is a major security hole, and should only be
608    used during development.
609
610    xmlhttp_key is a string that, if present in QUERY_STRING,
611    indicates that the request is an XMLHttp request, and the
612    Javascript/interactive debugger should not be returned.  (If you
613    try to put the debugger somewhere with innerHTML, you will often
614    crash the browser)
615    """
616    if xmlhttp_key is None:
617        xmlhttp_key = global_conf.get('xmlhttp_key', '_')
618    return EvalException(app, xmlhttp_key=xmlhttp_key)
619