• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

zopefoundation / Zope / 3956162881

pending completion
3956162881

push

github

Michael Howitz
Update to deprecation warning free releases.

4401 of 7036 branches covered (62.55%)

Branch coverage included in aggregate %.

27161 of 31488 relevant lines covered (86.26%)

0.86 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

87.25
/src/ZPublisher/HTTPResponse.py
1
##############################################################################
2
#
3
# Copyright (c) 2001-2009 Zope Foundation and Contributors.
4
#
5
# This software is subject to the provisions of the Zope Public License,
6
# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
7
# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
8
# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
9
# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
10
# FOR A PARTICULAR PURPOSE.
11
#
12
##############################################################################
13
""" CGI Response Output formatter
1✔
14
"""
15
import html
1✔
16
import os
1✔
17
import re
1✔
18
import struct
1✔
19
import sys
1✔
20
import time
1✔
21
import zlib
1✔
22
from email.header import Header
1✔
23
from email.message import _parseparam
1✔
24
from email.utils import encode_rfc2231
1✔
25
from io import BytesIO
1✔
26
from io import IOBase
1✔
27
from urllib.parse import quote
1✔
28
from urllib.parse import urlparse
1✔
29
from urllib.parse import urlunparse
1✔
30

31
from zExceptions import BadRequest
1✔
32
from zExceptions import HTTPRedirection
1✔
33
from zExceptions import InternalError
1✔
34
from zExceptions import NotFound
1✔
35
from zExceptions import Redirect
1✔
36
from zExceptions import Unauthorized
1✔
37
from zExceptions import status_reasons
1✔
38
from zExceptions.ExceptionFormatter import format_exception
1✔
39
from zope.event import notify
1✔
40
from ZPublisher import pubevents
1✔
41
from ZPublisher.BaseResponse import BaseResponse
1✔
42
from ZPublisher.Iterators import IStreamIterator
1✔
43
from ZPublisher.Iterators import IUnboundStreamIterator
1✔
44

45
from .cookie import convertCookieParameter
1✔
46
from .cookie import getCookieParamPolicy
1✔
47
from .cookie import getCookieValuePolicy
1✔
48

49

50
# This may get overwritten during configuration
51
default_encoding = 'utf-8'
1✔
52

53
# Enable APPEND_TRACEBACKS to make Zope append tracebacks like it used to,
54
# but a better solution is to make standard_error_message display error_tb.
55
APPEND_TRACEBACKS = 0
1✔
56

57
status_codes = {}
1✔
58
# Add mappings for builtin exceptions and
59
# provide text -> error code lookups.
60
for key, val in status_reasons.items():
1✔
61
    status_codes[''.join(val.split(' ')).lower()] = key
1✔
62
    status_codes[val.lower()] = key
1✔
63
    status_codes[key] = key
1✔
64
    status_codes[str(key)] = key
1✔
65
en = [n for n in dir(__builtins__) if n[-5:] == 'Error']
1✔
66
for name in en:
1!
67
    status_codes[name.lower()] = 500
×
68
status_codes['nameerror'] = 503
1✔
69
status_codes['keyerror'] = 503
1✔
70
status_codes['redirect'] = 302
1✔
71
status_codes['resourcelockederror'] = 423
1✔
72

73

74
start_of_header_search = re.compile('(<head[^>]*>)', re.IGNORECASE).search
1✔
75
base_re_search = re.compile('(<base.*?>)', re.I).search
1✔
76
bogus_str_search = re.compile(b" 0x[a-fA-F0-9]+>$").search
1✔
77
charset_re_str = (r'(?:application|text)/[-+0-9a-z]+\s*;\s*'
1✔
78
                  r'charset=([-_0-9a-z]+)(?:(?:\s*;)|\Z)')
79
charset_re_match = re.compile(charset_re_str, re.IGNORECASE).match
1✔
80
absuri_match = re.compile(r'\w+://[\w\.]+').match
1✔
81
tag_search = re.compile('[a-zA-Z]>').search
1✔
82

83
_gzip_header = (b"\037\213"  # magic
1✔
84
                b"\010"  # compression method
85
                b"\000"  # flags
86
                b"\000\000\000\000"  # time
87
                b"\002"
88
                b"\377")
89

90
# these mime major types should not be gzip content encoded
91
uncompressableMimeMajorTypes = ('image',)
1✔
92

93
# The environment variable DONT_GZIP_MAJOR_MIME_TYPES can be set to a list
94
# of comma separated mime major types which should also not be compressed
95

96
otherTypes = os.environ.get('DONT_GZIP_MAJOR_MIME_TYPES', '').lower()
1✔
97
if otherTypes:
1!
98
    uncompressableMimeMajorTypes += tuple(otherTypes.split(','))
×
99

100
_CRLF = re.compile(r'[\r\n]')
1✔
101

102

103
def _scrubHeader(name, value):
1✔
104
    if isinstance(value, bytes):
1✔
105
        # handle ``bytes`` values correctly
106
        # we assume that the provider knows that HTTP 1.1 stipulates ISO-8859-1
107
        value = value.decode('ISO-8859-1')
1✔
108
    elif not isinstance(value, str):
1✔
109
        value = str(value)
1✔
110
    return ''.join(_CRLF.split(str(name))), ''.join(_CRLF.split(value))
1✔
111

112

113
_NOW = None  # overwrite for testing
1✔
114
MONTHNAME = [None, 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
1✔
115
             'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
116
WEEKDAYNAME = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
1✔
117

118

119
def _now():
1✔
120
    if _NOW is not None:
1✔
121
        return _NOW
1✔
122
    return time.time()
1✔
123

124

125
def build_http_date(when):
1✔
126
    year, month, day, hh, mm, ss, wd, y, z = time.gmtime(when)
1✔
127
    return "%s, %02d %3s %4d %02d:%02d:%02d GMT" % (
1✔
128
        WEEKDAYNAME[wd], day, MONTHNAME[month], year, hh, mm, ss)
129

130

131
def make_content_disposition(disposition, file_name):
1✔
132
    """Create HTTP header for downloading a file with a UTF-8 filename.
133

134
    See this and related answers: https://stackoverflow.com/a/8996249/2173868.
135
    """
136
    header = f'{disposition}'
1✔
137
    try:
1✔
138
        file_name.encode('us-ascii')
1✔
139
    except UnicodeEncodeError:
1✔
140
        # the file cannot be encoded using the `us-ascii` encoding
141
        # which is advocated by RFC 7230 - 7237
142
        #
143
        # a special header has to be crafted
144
        # also see https://tools.ietf.org/html/rfc6266#appendix-D
145
        encoded_file_name = file_name.encode('us-ascii', errors='ignore')
1✔
146
        header += f'; filename="{encoded_file_name}"'
1✔
147
        quoted_file_name = quote(file_name)
1✔
148
        header += f'; filename*=UTF-8\'\'{quoted_file_name}'
1✔
149
        return header
1✔
150
    else:
151
        header += f'; filename="{file_name}"'
1✔
152
        return header
1✔
153

154

155
class HTTPBaseResponse(BaseResponse):
1✔
156
    """ An object representation of an HTTP response.
157

158
    The Response type encapsulates all possible responses to HTTP
159
    requests.  Responses are normally created by the object publisher.
160
    A published object may receive the response object as an argument
161
    named 'RESPONSE'.  A published object may also create it's own
162
    response object.  Normally, published objects use response objects
163
    to:
164

165
    - Provide specific control over output headers,
166

167
    - Set cookies, or
168

169
    - Provide stream-oriented output.
170

171
    If stream oriented output is used, then the response object
172
    passed into the object must be used.
173
    """
174

175
    body = b''
1✔
176
    base = ''
1✔
177
    charset = default_encoding
1✔
178
    realm = 'Zope'
1✔
179
    _error_format = 'text/html'
1✔
180
    _locked_status = 0
1✔
181
    _locked_body = 0
1✔
182

183
    # Indicate if setBody should content-compress output.
184
    # 0 - no compression
185
    # 1 - compress if accept-encoding ok
186
    # 2 - ignore accept-encoding (i.e. force)
187
    use_HTTP_content_compression = 0
1✔
188

189
    def __init__(self,
1✔
190
                 body=b'',
191
                 status=200,
192
                 headers=None,
193
                 stdout=None,
194
                 stderr=None):
195
        """ Create a new response using the given values.
196
        """
197
        self.accumulated_headers = []
1✔
198
        self.cookies = {}
1✔
199
        self.headers = {}
1✔
200

201
        if headers is not None:
1✔
202
            for key, value in headers.items():
1✔
203
                self.setHeader(key, value)
1✔
204

205
        self.setStatus(status)
1✔
206

207
        if stdout is None:
1✔
208
            stdout = BytesIO()
1✔
209
        self.stdout = stdout
1✔
210

211
        if stderr is None:
1✔
212
            stderr = BytesIO()
1✔
213
        self.stderr = stderr
1✔
214

215
        if body:
1✔
216
            self.setBody(body)
1✔
217

218
    @property
1✔
219
    def text(self):
1✔
220
        return self.body.decode(self.charset)
1✔
221

222
    @text.setter
1✔
223
    def text(self, value):
1✔
224
        self.body = value.encode(self.charset)
1✔
225

226
    def redirect(self, location, status=302, lock=0):
1✔
227
        """Cause a redirection without raising an error"""
228
        if isinstance(location, HTTPRedirection):
1✔
229
            status = location.getStatus()
1✔
230
            location = location.headers['Location']
1✔
231

232
        if isinstance(location, bytes):
1✔
233
            location = location.decode(self.charset)
1✔
234

235
        # To be entirely correct, we must make sure that all non-ASCII
236
        # characters are quoted correctly.
237
        parsed = list(urlparse(location))
1✔
238
        rfc2396_unreserved = "-_.!~*'()"  # RFC 2396 section 2.3
1✔
239
        for idx, idx_safe in (
1✔
240
                # authority
241
                (1, ";:@?/&=+$,"),  # RFC 2396 section 3.2, 3.2.1, 3.2.3
242
                # path
243
                (2, "/;:@&=+$,"),  # RFC 2396 section 3.3
244
                # params - actually part of path; empty in Python 3
245
                (3, "/;:@&=+$,"),  # RFC 2396 section 3.3
246
                # query
247
                (4, ";/?:@&=+,$"),  # RFC 2396 section 3.4
248
                # fragment
249
                (5, ";/?:@&=+$,"),  # RFC 2396 section 4
250
        ):
251
            # Make a hacky guess whether the component is already
252
            # URL-encoded by checking for %. If it is, we don't touch it.
253
            if '%' not in parsed[idx]:
1✔
254
                parsed[idx] = quote(parsed[idx],
1✔
255
                                    safe=rfc2396_unreserved + idx_safe)
256
        location = urlunparse(parsed)
1✔
257

258
        self.setStatus(status, lock=lock)
1✔
259
        self.setHeader('Location', location)
1✔
260
        return location
1✔
261

262
    def retry(self):
1✔
263
        """ Return a cloned response object to be used in a retry attempt.
264
        """
265
        # This implementation is a bit lame, because it assumes that
266
        # only stdout stderr were passed to the constructor. OTOH, I
267
        # think that that's all that is ever passed.
268
        return self.__class__(stdout=self.stdout, stderr=self.stderr)
1✔
269

270
    def setStatus(self, status, reason=None, lock=None):
1✔
271
        """ Set the HTTP status code of the response
272

273
        o The argument may either be an integer or a string from the
274
          'status_reasons' dict values:  status messages will be converted
275
          to the correct integer value.
276
        """
277
        if self._locked_status:
1!
278
            # Don't change the response status.
279
            # It has already been determined.
280
            return
×
281

282
        if isinstance(status, type) and issubclass(status, Exception):
1✔
283
            status = status.__name__
1✔
284

285
        if isinstance(status, str):
1✔
286
            status = status.lower()
1✔
287

288
        if status in status_codes:
1✔
289
            status = status_codes[status]
1✔
290
        else:
291
            status = 500
1✔
292

293
        self.status = status
1✔
294

295
        if reason is None:
1!
296
            if status in status_reasons:
1!
297
                reason = status_reasons[status]
1✔
298
            else:
299
                reason = 'Unknown'
×
300

301
        self.errmsg = reason
1✔
302
        # lock the status if we're told to
303
        if lock:
1✔
304
            self._locked_status = 1
1✔
305

306
    def setCookie(self, name, value, quoted=True, **kw):
1✔
307
        """ Set an HTTP cookie.
308

309
        The response will include an HTTP header that sets a cookie on
310
        cookie-enabled browsers with a key "name" and value
311
        "value".
312

313
        This value overwrites any previously set value for the
314
        cookie in the Response object.
315

316
        `name` has to be text in Python 3.
317

318
        `value` may be text or bytes. The default encoding of respective python
319
        version is used.
320
        """
321
        cookie = {}
1✔
322
        for k, v in kw.items():
1✔
323
            k, v = convertCookieParameter(k, v)
1✔
324
            cookie[k] = v
1✔
325
        cookie['value'] = value
1✔
326
        # RFC6265 makes quoting obsolete
327
        # cookie['quoted'] = quoted
328
        getCookieParamPolicy().check_consistency(name, cookie)
1✔
329

330
        # update ``self.cookies`` only if there have been no exceptions
331
        cookies = self.cookies
1✔
332
        if name in cookies:
1✔
333
            cookies[name].update(cookie)
1✔
334
        else:
335
            cookies[name] = cookie
1✔
336

337
    def appendCookie(self, name, value):
1✔
338
        """ Set an HTTP cookie.
339

340
        Returns an HTTP header that sets a cookie on cookie-enabled
341
        browsers with a key "name" and value "value". If a value for the
342
        cookie has previously been set in the response object, the new
343
        value is appended to the old one separated by a colon.
344

345
        `name` has to be text in Python 3.
346

347
        `value` may be text or bytes. The default encoding of respective python
348
        version is used.
349
        """
350
        cookies = self.cookies
1✔
351
        if name in cookies:
1✔
352
            cookie = cookies[name]
1✔
353
        else:
354
            cookie = cookies[name] = {}
1✔
355
        if 'value' in cookie:
1✔
356
            cookie['value'] = f'{cookie["value"]}:{value}'
1✔
357
        else:
358
            cookie['value'] = value
1✔
359

360
    def expireCookie(self, name, **kw):
1✔
361
        """ Clear an HTTP cookie.
362

363
        The response will include an HTTP header that will remove the cookie
364
        corresponding to "name" on the client, if one exists. This is
365
        accomplished by sending a new cookie with an expiration date
366
        that has already passed. Note that some clients require a path
367
        to be specified - this path must exactly match the path given
368
        when creating the cookie. The path can be specified as a keyword
369
        argument.
370

371
        `name` has to be text in Python 3.
372
        """
373
        d = kw.copy()
1✔
374
        if 'value' in d:
1!
375
            d.pop('value')
×
376
        d['max_age'] = 0
1✔
377
        d['expires'] = 'Wed, 31 Dec 1997 23:59:59 GMT'
1✔
378

379
        self.setCookie(name, value='deleted', **d)
1✔
380

381
    def getHeader(self, name, literal=0):
1✔
382
        """ Get a previously set header value.
383

384
        Return the value associated with a HTTP return header, or
385
        None if no such header has been set in the response
386
        yet.
387

388
        If the 'literal' flag is true, preserve the case of the header name;
389
        otherwise lower-case the header name before looking up the value.
390
        """
391
        key = literal and name or name.lower()
1✔
392
        return self.headers.get(key, None)
1✔
393

394
    def setHeader(self, name, value, literal=0, scrubbed=False):
1✔
395
        """ Set an HTTP return header on the response.
396

397
        Replay any existing value set for the header.
398

399
        If the 'literal' flag is true, preserve the case of the header name;
400
        otherwise the header name will be lowercased.
401

402
        'scrubbed' is for internal use, to indicate that another API has
403
        already removed any CRLF from the name and value.
404
        """
405
        if not scrubbed:
1✔
406
            name, value = _scrubHeader(name, value)
1✔
407
        key = name.lower()
1✔
408

409
        if key == 'content-type':
1✔
410
            if 'charset' in value:
1✔
411
                # Update self.charset with the charset from the header
412
                match = charset_re_match(value)
1✔
413
                if match:
1✔
414
                    self.charset = match.group(1)
1✔
415
            else:
416
                # Update the header value with self.charset
417
                if value.startswith('text/'):
1✔
418
                    value = value + '; charset=' + self.charset
1✔
419

420
        name = literal and name or key
1✔
421
        self.headers[name] = value
1✔
422

423
    def appendHeader(self, name, value, delimiter=", "):
1✔
424
        """ Append a value to an HTTP return header.
425

426
        Set an HTTP return header "name" with value "value",
427
        appending it following a comma if there was a previous value
428
        set for the header.
429

430
        'name' is always lowercased before use.
431
        """
432
        name, value = _scrubHeader(name, value)
1✔
433
        name = name.lower()
1✔
434

435
        headers = self.headers
1✔
436
        if name in headers:
1✔
437
            h = headers[name]
1✔
438
            h = f"{h}{delimiter}{value}"
1✔
439
        else:
440
            h = value
1✔
441
        self.setHeader(name, h, scrubbed=True)
1✔
442

443
    def addHeader(self, name, value):
1✔
444
        """ Set a new HTTP return header with the given value,
445

446
        Retain any previously set headers with the same name.
447

448
        Note that this API appends to the 'accumulated_headers' attribute;
449
        it does not update the 'headers' mapping.
450
        """
451
        name, value = _scrubHeader(name, value)
1✔
452
        self.accumulated_headers.append((name, value))
1✔
453

454
    __setitem__ = setHeader
1✔
455

456
    def setBase(self, base):
1✔
457
        """Set the base URL for the returned document.
458

459
        If base is None, set to the empty string.
460

461
        If base is not None, ensure that it has a trailing slash.
462
        """
463
        if base is None:
1✔
464
            base = ''
1✔
465
        elif not base.endswith('/'):
1✔
466
            base = base + '/'
1✔
467

468
        self.base = str(base)
1✔
469

470
    def insertBase(self):
1✔
471
        # Only insert a base tag if content appears to be HTML.
472
        content_type = self.headers.get('content-type', '').split(';')[0]
1✔
473
        if content_type and (content_type != 'text/html'):
1✔
474
            return
1✔
475

476
        if self.base and self.body:
1✔
477
            text = self.text
1✔
478
            match = start_of_header_search(text)
1✔
479
            if match is not None:
1✔
480
                index = match.start(0) + len(match.group(0))
1✔
481
                ibase = base_re_search(text)
1✔
482
                if ibase is None:
1!
483
                    text = (text[:index] + '\n<base href="'
1✔
484
                            + html.escape(self.base, True) + '" />\n'
485
                            + text[index:])
486
                    self.text = text
1✔
487
                    self.setHeader('content-length', len(self.body))
1✔
488

489
    def isHTML(self, text):
1✔
490
        if isinstance(text, bytes):
1✔
491
            try:
1✔
492
                text = text.decode(self.charset)
1✔
493
            except UnicodeDecodeError:
1✔
494
                return False
1✔
495
        text = text.lstrip()
1✔
496
        # Note that the string can be big, so text.lower().startswith()
497
        # is more expensive than s[:n].lower().
498
        if text[:6].lower() == '<html>' or \
1!
499
           text[:14].lower() == '<!doctype html':
500
            return True
1✔
501
        if text.find('</') > 0:
×
502
            return True
×
503
        return False
×
504

505
    def setBody(self, body, title='', is_error=False, lock=None):
1✔
506
        """ Set the body of the response
507

508
        Sets the return body equal to the (string) argument "body". Also
509
        updates the "content-length" return header.
510

511
        If the body is already locked via a previous call, do nothing and
512
        return None.
513

514
        You can also specify a title, in which case the title and body
515
        will be wrapped up in html, head, title, and body tags.
516

517
        If body has an 'asHTML' method, replace it by the result of that
518
        method.
519

520
        If body is now bytes, bytearray or memoryview, convert it to bytes.
521
        Else, either try to convert it to bytes or use an intermediate string
522
        representation which is then converted to bytes, depending on the
523
        content type.
524

525
        If is_error is true, format the HTML as a Zope error message instead
526
        of a generic HTML page.
527

528
        Return 'self' (XXX as a true value?).
529
        """
530
        # allow locking of the body in the same way as the status
531
        if self._locked_body:
1✔
532
            return
1✔
533
        elif lock:
1✔
534
            self._locked_body = 1
1✔
535

536
        if not body:
1✔
537
            return self
1✔
538

539
        content_type = self.headers.get('content-type')
1✔
540

541
        if hasattr(body, 'asHTML'):
1✔
542
            body = body.asHTML()
1✔
543
            if content_type is None:
1!
544
                content_type = 'text/html'
1✔
545

546
        if isinstance(body, (bytes, bytearray, memoryview)):
1✔
547
            body = bytes(body)
1✔
548
        elif content_type is not None and not content_type.startswith('text/'):
1✔
549
            try:
1✔
550
                body = bytes(body)
1✔
551
            except (TypeError, UnicodeError):
1✔
552
                pass
1✔
553
        if not isinstance(body, bytes):
1✔
554
            body = self._encode_unicode(str(body))
1✔
555

556
        # At this point body is always binary
557
        b_len = len(body)
1✔
558
        if b_len < 200 and \
1✔
559
           body[:1] == b'<' and \
560
           body.find(b'>') == b_len - 1 and \
561
           bogus_str_search(body) is not None:
562
            self.notFoundError(body[1:-1].decode(self.charset))
1✔
563
        else:
564
            if title:
1✔
565
                title = str(title)
1✔
566
                content_type = 'text/html'
1✔
567
                if not is_error:
1!
568
                    self.body = body = self._html(
×
569
                        title, body.decode(self.charset)).encode(self.charset)
570
                else:
571
                    self.body = body = self._error_html(
1✔
572
                        title, body.decode(self.charset)).encode(self.charset)
573
            else:
574
                self.body = body
1✔
575

576
        if content_type is None:
1✔
577
            content_type = f'text/plain; charset={self.charset}'
1✔
578
            self.setHeader('content-type', content_type)
1✔
579
        else:
580
            if content_type.startswith('text/') and \
1✔
581
               'charset=' not in content_type:
582
                content_type = f'{content_type}; charset={self.charset}'
1✔
583
                self.setHeader('content-type', content_type)
1✔
584

585
        self.setHeader('content-length', len(self.body))
1✔
586

587
        self.insertBase()
1✔
588

589
        if self.use_HTTP_content_compression and \
1✔
590
           self.headers.get('content-encoding', 'gzip') == 'gzip':
591
            # use HTTP content encoding to compress body contents unless
592
            # this response already has another type of content encoding
593
            if content_type.split('/')[0] not in uncompressableMimeMajorTypes:
1✔
594
                # only compress if not listed as uncompressable
595
                body = self.body
1✔
596
                startlen = len(body)
1✔
597
                co = zlib.compressobj(6, zlib.DEFLATED, -zlib.MAX_WBITS,
1✔
598
                                      zlib.DEF_MEM_LEVEL, 0)
599
                chunks = [_gzip_header, co.compress(body),
1✔
600
                          co.flush(),
601
                          struct.pack("<LL",
602
                                      zlib.crc32(body) & 0xffffffff,
603
                                      startlen)]
604
                z = b''.join(chunks)
1✔
605
                newlen = len(z)
1✔
606
                if newlen < startlen:
1✔
607
                    self.body = z
1✔
608
                    self.setHeader('content-length', newlen)
1✔
609
                    self.setHeader('content-encoding', 'gzip')
1✔
610
                    if self.use_HTTP_content_compression == 1:
1✔
611
                        # use_HTTP_content_compression == 1 if force was
612
                        # NOT used in enableHTTPCompression().
613
                        # If we forced it, then Accept-Encoding
614
                        # was ignored anyway, so cache should not
615
                        # vary on it. Otherwise if not forced, cache should
616
                        # respect Accept-Encoding client header
617
                        vary = self.getHeader('Vary')
1✔
618
                        if vary is None or 'Accept-Encoding' not in vary:
1✔
619
                            self.appendHeader('Vary', 'Accept-Encoding')
1✔
620
        return self
1✔
621

622
    def enableHTTPCompression(self, REQUEST={}, force=0, disable=0, query=0):
1✔
623
        """Enable HTTP Content Encoding with gzip compression if possible
624

625
           REQUEST -- used to check if client can accept compression
626
           force   -- set true to ignore REQUEST headers
627
           disable -- set true to disable compression
628
           query   -- just return if compression has been previously requested
629

630
           returns -- 1 if compression will be attempted, 2 if compression
631
                      is forced, 0 if no compression
632

633
           The HTTP specification allows for transfer encoding and content
634
           encoding. Unfortunately many web browsers still do not support
635
           transfer encoding, but they all seem to support content encoding.
636

637
           This function is designed to be called on each request to specify
638
           on a request-by-request basis that the response content should
639
           be compressed.
640

641
           The REQUEST headers are used to determine if the client accepts
642
           gzip content encoding. The force parameter can force the use
643
           of gzip encoding regardless of REQUEST, and the disable parameter
644
           can be used to "turn off" previously enabled encoding (but note
645
           that any existing content-encoding header will not be changed).
646
           The query parameter can be used to determine the if compression
647
           has been previously requested.
648

649
           In setBody, the major mime type is used to determine if content
650
           encoding should actually be performed.
651

652
           By default, image types are not compressed.
653
           Additional major mime types can be specified by setting the
654
           environment variable DONT_GZIP_MAJOR_MIME_TYPES to a comma-seperated
655
           list of major mime types that should also not be gzip compressed.
656
        """
657
        if query:
1!
658
            return self.use_HTTP_content_compression
×
659

660
        elif disable:
1!
661
            # in the future, a gzip cache manager will need to ensure that
662
            # compression is off
663
            self.use_HTTP_content_compression = 0
×
664

665
        elif force or 'gzip' in REQUEST.get('HTTP_ACCEPT_ENCODING', ''):
1!
666
            if force:
1✔
667
                self.use_HTTP_content_compression = 2
1✔
668
            else:
669
                self.use_HTTP_content_compression = 1
1✔
670

671
        return self.use_HTTP_content_compression
1✔
672

673
    def _encode_unicode(self, text):
1✔
674
        # Fixes the encoding in the XML preamble according
675
        # to the charset specified in the content-type header.
676
        if text.startswith('<?xml'):
1✔
677
            pos_right = text.find('?>')  # right end of the XML preamble
1✔
678
            text = ('<?xml version="1.0" encoding="' + self.charset
1✔
679
                    + '" ?>' + text[pos_right + 2:])
680

681
        # Encode the text data using the response charset
682
        text = text.encode(self.charset, 'replace')
1✔
683
        return text
1✔
684

685
    def _cookie_list(self):
1✔
686
        cookie_list = []
1✔
687
        dump = getCookieValuePolicy().dump
1✔
688
        parameters = getCookieParamPolicy().parameters
1✔
689
        for name, attrs in self.cookies.items():
1✔
690
            params = []
1✔
691
            for pn, pv in parameters(name, attrs):
1✔
692
                if pv is None:
1✔
693
                    continue
1✔
694
                params.append(pn if pv is True else f"{pn}={pv}")
1✔
695
            cookie = "{}={}".format(name, dump(name, attrs["value"]))
1✔
696
            if params:
1✔
697
                cookie += "; " + "; ".join(params)
1✔
698
            # Should really check size of cookies here!
699
            cookie_list.append(('Set-Cookie', cookie))
1✔
700
        return cookie_list
1✔
701

702
    def listHeaders(self):
1✔
703
        """ Return a list of (key, value) pairs for our headers.
704

705
        o Do appropriate case normalization.
706

707
        o Encode header values via `header_encoding_registry`
708
        """
709

710
        result = [
1✔
711
            ('X-Powered-By', 'Zope (www.zope.dev), Python (www.python.org)')
712
        ]
713

714
        encode = header_encoding_registry.encode
1✔
715
        for key, value in self.headers.items():
1✔
716
            if key.lower() == key:
1✔
717
                # only change non-literal header names
718
                key = '-'.join([x.capitalize() for x in key.split('-')])
1✔
719
            result.append((key, encode(key, value)))
1✔
720

721
        result.extend(self._cookie_list())
1✔
722
        for key, value in self.accumulated_headers:
1✔
723
            result.append((key, encode(key, value)))
1✔
724
        return result
1✔
725

726
    def _unauthorized(self):
1✔
727
        realm = self.realm
1✔
728
        if realm:
1✔
729
            self.setHeader(
1✔
730
                'WWW-Authenticate',
731
                'basic realm="%s", charset="UTF-8"' % realm,
732
                1)
733

734
    @staticmethod
1✔
735
    def _html(title, body):
1✔
736
        return ("<html>\n"
×
737
                "<head>\n<title>%s</title>\n</head>\n"
738
                "<body>\n%s\n</body>\n"
739
                "</html>\n" % (title, body))
740

741

742
class HTTPResponse(HTTPBaseResponse):
1✔
743

744
    _wrote = None
1✔
745

746
    def __bytes__(self):
1✔
747
        if self._wrote:
1✔
748
            return b''  # Streaming output was used.
1✔
749

750
        status, headers = self.finalize()
1✔
751
        body = self.body
1✔
752

753
        chunks = []
1✔
754

755
        # status header must come first.
756
        chunks.append(b"Status: " + status.encode('ascii'))
1✔
757

758
        for key, value in headers:
1✔
759
            chunks.append(key.encode('ascii') + b': ' + value.encode('ascii'))
1✔
760
        # RFC 2616 mandates empty line between headers and payload
761
        chunks.append(b'')
1✔
762
        chunks.append(body)
1✔
763
        return b'\r\n'.join(chunks)
1✔
764

765
    # deprecated
766
    def quoteHTML(self, text):
1✔
767
        return html.escape(text, 1)
1✔
768

769
    def _traceback(self, t, v, tb, as_html=1):
1✔
770
        tb = format_exception(t, v, tb, as_html=as_html)
1✔
771
        return '\n'.join(tb)
1✔
772

773
    def _error_html(self, title, body):
1✔
774
        return ("""<!DOCTYPE html><html>
1✔
775
  <head><title>Site Error</title><meta charset="utf-8" /></head>
776
  <body bgcolor="#FFFFFF">
777
  <h2>Site Error</h2>
778
  <p>An error was encountered while publishing this resource.
779
  </p>
780
  <p><strong>""" + title + """</strong></p>
781

782
  """ + body + """
783
  <hr noshade="noshade"/>
784

785
  <p>Troubleshooting Suggestions</p>
786

787
  <ul>
788
  <li>The URL may be incorrect.</li>
789
  <li>The parameters passed to this resource may be incorrect.</li>
790
  <li>A resource that this resource relies on may be
791
      encountering an error.</li>
792
  </ul>
793

794
  <p>If the error persists please contact the site maintainer.
795
  Thank you for your patience.
796
  </p></body></html>""")
797

798
    def notFoundError(self, entry='Unknown'):
1✔
799
        self.setStatus(404)
1✔
800
        raise NotFound(self._error_html(
1✔
801
            "Resource not found",
802
            ("Sorry, the requested resource does not exist."
803
             "<p>Check the URL and try again.</p>"
804
             "<p><b>Resource:</b> " + html.escape(entry) + "</p>")))
805

806
    # If a resource is forbidden, why reveal that it exists?
807
    forbiddenError = notFoundError
1✔
808

809
    def debugError(self, entry):
1✔
810
        raise NotFound(self._error_html(
1✔
811
            "Debugging Notice",
812
            (
813
                "Zope has encountered a problem publishing your object. "
814
                "<p>%s</p>" % repr(entry)
815
            )
816
        ))
817

818
    def badRequestError(self, name):
1✔
819
        self.setStatus(400)
1✔
820
        if re.match('^[A-Z_0-9]+$', name):
1✔
821
            raise InternalError(self._error_html(
1✔
822
                "Internal Error",
823
                "Sorry, an internal error occurred in this resource."))
824

825
        raise BadRequest(self._error_html(
1✔
826
            "Invalid request",
827
            ("The parameter, <em>" + name + "</em>, "
828
             "was omitted from the request.<p>"
829
             "Make sure to specify all required parameters, "
830
             "and try the request again.</p>")))
831

832
    def unauthorized(self):
1✔
833
        m = "You are not authorized to access this resource."
1✔
834
        if self.debug_mode:
1✔
835
            if self._auth:
1✔
836
                m = m + '\nUsername and password are not correct.'
1✔
837
            else:
838
                m = m + '\nNo Authorization header found.'
1✔
839
        raise Unauthorized(m)
1✔
840

841
    def exception(self, fatal=0, info=None, abort=1):
1✔
842
        if isinstance(info, tuple) and len(info) == 3:
1!
843
            t, v, tb = info
×
844
        else:
845
            t, v, tb = sys.exc_info()
1✔
846

847
        if issubclass(t, Unauthorized):
1!
848
            self._unauthorized()
×
849

850
        self.setStatus(t)
1✔
851
        if 300 <= self.status < 400:
1!
852
            if isinstance(v, str) and absuri_match(v) is not None:
×
853
                if self.status == 300:
×
854
                    self.setStatus(302)
×
855
                self.setHeader('location', v)
×
856
                tb = None  # just one path covered
×
857
                return self
×
858
            elif isinstance(v, Redirect):
×
859
                if self.status == 300:
×
860
                    self.setStatus(302)
×
861
                self.setHeader('location', v.args[0])
×
862
                self.setBody(b'')
×
863
                tb = None
×
864
                return self
×
865
            else:
866
                try:
×
867
                    l, b = v
×
868
                    if isinstance(l, str) and absuri_match(l) is not None:
×
869
                        if self.status == 300:
×
870
                            self.setStatus(302)
×
871
                        self.setHeader('location', l)
×
872
                        self.setBody(b)
×
873
                        tb = None  # one more patch covered
×
874
                        return self
×
875
                except Exception:
×
876
                    pass  # tb is not cleared in this case
×
877

878
        b = v
1✔
879
        if isinstance(b, Exception):
1!
880
            try:
1✔
881
                try:
1✔
882
                    b = str(b)
1✔
883
                except UnicodeDecodeError:
×
884
                    b = self._encode_unicode(str(b)).decode(self.charset)
×
885
            except Exception:
×
886
                b = '<unprintable %s object>' % type(b).__name__
×
887

888
        if fatal and t is SystemExit and v.code == 0:
1!
889
            self.setHeader('content-type', 'text/html')
×
890
            body = self.setBody(
×
891
                'Zope has exited normally.<p>'
892
                + self._traceback(t, v, tb) + '</p>',
893
                title=t,
894
                is_error=True)
895
        else:
896
            try:
1✔
897
                match = tag_search(b)
1✔
898
            except TypeError:
×
899
                match = None
×
900

901
            if match is None:
1✔
902
                self.setHeader('content-type', 'text/html')
1✔
903
                body = self.setBody(
1✔
904
                    'Sorry, a site error occurred.<p>'
905
                    + self._traceback(t, v, tb) + '</p>',
906
                    title=t,
907
                    is_error=True)
908
            elif self.isHTML(b):
1!
909
                # error is an HTML document, not just a snippet of html
910
                if APPEND_TRACEBACKS:
1!
911
                    body = self.setBody(b + self._traceback(
×
912
                        t, '(see above)', tb), is_error=True)
913
                else:
914
                    body = self.setBody(b, is_error=True)
1✔
915
            else:
916
                body = self.setBody(
×
917
                    b + self._traceback(t, '(see above)', tb, 0),
918
                    title=t,
919
                    is_error=True)
920
        del tb
1✔
921
        return body
1✔
922

923
    def finalize(self):
1✔
924
        """ Set headers required by various parts of protocol.
925
        """
926
        body = self.body
1✔
927
        if 'content-length' not in self.headers and \
1✔
928
           'transfer-encoding' not in self.headers:
929
            self.setHeader('content-length', len(body))
1✔
930
        return "%d %s" % (self.status, self.errmsg), self.listHeaders()
1✔
931

932
    def write(self, data):
1✔
933
        """
934
        Return data as a stream
935

936
        HTML data may be returned using a stream-oriented interface.
937
        This allows the browser to display partial results while
938
        computation of a response to proceed.
939

940
        The published object should first set any output headers or
941
        cookies on the response object.
942

943
        Note that published objects must not generate any errors
944
        after beginning stream-oriented output.
945
        """
946
        if not self._wrote:
1✔
947
            notify(pubevents.PubBeforeStreaming(self))
1✔
948

949
            self.outputBody()
1✔
950
            self._wrote = 1
1✔
951
            self.stdout.flush()
1✔
952

953
        self.stdout.write(data)
1✔
954

955

956
class WSGIResponse(HTTPBaseResponse):
1✔
957
    """A response object for WSGI
958
    """
959
    _streaming = 0
1✔
960
    _http_version = None
1✔
961
    _server_version = None
1✔
962

963
    # Append any "cleanup" functions to this list.
964
    after_list = ()
1✔
965

966
    def notFoundError(self, entry='Unknown'):
1✔
967
        self.setStatus(404)
1✔
968
        exc = NotFound(entry)
1✔
969
        exc.title = 'Resource not found'
1✔
970
        exc.detail = (
1✔
971
            'Sorry, the requested resource does not exist.'
972
            '<p>Check the URL and try again.</p>'
973
            '<p><b>Resource:</b> %s</p>' % html.escape(entry, True))
974
        raise exc
1✔
975

976
    # If a resource is forbidden, why reveal that it exists?
977
    forbiddenError = notFoundError
1✔
978

979
    def debugError(self, entry):
1✔
980
        self.setStatus(404)
1✔
981
        exc = NotFound(entry)
1✔
982
        exc.title = 'Debugging Notice'
1✔
983
        exc.detail = (
1✔
984
            "Zope has encountered a problem publishing your object. "
985
            "<p>%s</p>" % repr(entry)
986
        )
987
        raise exc
1✔
988

989
    def badRequestError(self, name):
1✔
990
        if re.match('^[A-Z_0-9]+$', name):
×
991
            self.setStatus(500)
×
992
            exc = InternalError(name)
×
993
            exc.title = 'Internal Error'
×
994
            exc.detail = 'Sorry, an internal error occurred in this resource.'
×
995
            raise exc
×
996

997
        self.setStatus(400)
×
998
        exc = BadRequest(name)
×
999
        exc.title = 'Invalid request'
×
1000
        exc.detail = (
×
1001
            'The parameter, <em>%s</em>, '
1002
            'was omitted from the request.<p>'
1003
            'Make sure to specify all required parameters, '
1004
            'and try the request again.</p>' % name)
1005
        raise exc
×
1006

1007
    def unauthorized(self):
1✔
1008
        message = 'You are not authorized to access this resource.'
1✔
1009
        exc = Unauthorized(message)
1✔
1010
        exc.title = message
1✔
1011
        if self.debug_mode:
1!
1012
            if self._auth:
×
1013
                exc.detail = 'Username and password are not correct.'
×
1014
            else:
1015
                exc.detail = 'No Authorization header found.'
×
1016
        raise exc
1✔
1017

1018
    def exception(self, fatal=0, info=None, abort=1):
1✔
1019
        if isinstance(info, tuple) and len(info) == 3:
1!
1020
            t, v, tb = info
1✔
1021
        else:
1022
            t, v, tb = sys.exc_info()
×
1023

1024
        if issubclass(t, Unauthorized):
1!
1025
            self._unauthorized()
1✔
1026

1027
        raise v.with_traceback(tb)
1✔
1028

1029
    def finalize(self):
1✔
1030
        # Set 204 (no content) status if 200 and response is empty
1031
        # and not streaming.
1032
        if 'content-type' not in self.headers and \
1✔
1033
           'content-length' not in self.headers and \
1034
           not self._streaming and \
1035
           self.status == 200:
1036
            self.setStatus('nocontent')
1✔
1037

1038
        # Add content length if not streaming.
1039
        content_length = self.headers.get('content-length')
1✔
1040
        if content_length is None and not self._streaming:
1✔
1041
            self.setHeader('content-length', len(self.body))
1✔
1042

1043
        return f'{self.status} {self.errmsg}', self.listHeaders()
1✔
1044

1045
    def listHeaders(self):
1✔
1046
        result = []
1✔
1047
        if self._server_version:
1✔
1048
            result.append(('Server', self._server_version))
1✔
1049

1050
        result.append(('Date', build_http_date(_now())))
1✔
1051
        result.extend(super().listHeaders())
1✔
1052
        return result
1✔
1053

1054
    def write(self, data):
1✔
1055
        """Add data to our output stream.
1056

1057
        HTML data may be returned using a stream-oriented interface.
1058
        This allows the browser to display partial results while
1059
        computation of a response proceeds.
1060
        """
1061
        if not self._streaming:
1✔
1062
            notify(pubevents.PubBeforeStreaming(self))
1✔
1063
            self._streaming = 1
1✔
1064
            self.stdout.flush()
1✔
1065

1066
        self.stdout.write(data)
1✔
1067

1068
    def setBody(self, body, title='', is_error=False, lock=None):
1✔
1069
        # allow locking of the body in the same way as the status
1070
        if self._locked_body:
1✔
1071
            return
1✔
1072

1073
        if isinstance(body, IOBase):
1✔
1074
            body.seek(0, 2)
1✔
1075
            length = body.tell()
1✔
1076
            body.seek(0)
1✔
1077
            self.setHeader('Content-Length', '%d' % length)
1✔
1078
            self.body = body
1✔
1079
        elif IStreamIterator.providedBy(body):
1✔
1080
            self.body = body
1✔
1081
            super().setBody(b'', title, is_error)
1✔
1082
        elif IUnboundStreamIterator.providedBy(body):
1✔
1083
            self.body = body
1✔
1084
            self._streaming = 1
1✔
1085
            super().setBody(b'', title, is_error)
1✔
1086
        else:
1087
            super().setBody(body, title, is_error)
1✔
1088

1089
        # Have to apply the lock at the end in case the super class setBody
1090
        # is called, which will observe the lock and do nothing
1091
        if lock:
1✔
1092
            self._locked_body = 1
1✔
1093

1094
    def __bytes__(self):
1✔
1095
        raise NotImplementedError
1096

1097
    def __str__(self):
1✔
1098
        raise NotImplementedError
1099

1100

1101
# HTTP header encoding
1102
class HeaderEncodingRegistry(dict):
1✔
1103
    """Encode HTTP headers.
1104

1105
    HTTP/1.1 uses `ISO-8859-1` as charset for its headers
1106
    (the modern spec (RFC 7230-7235) has deprecated non ASCII characters
1107
    but for the sake of older browsers we still use `ISO-8859-1`).
1108
    Header values need encoding if they contain characters
1109
    not expressible in this charset.
1110

1111
    HTTP/1.1 is based on MIME
1112
    ("Multimedia Internet Mail Extensions" RFC 2045-2049).
1113
    MIME knows about 2 header encodings:
1114
     - one for parameter values (RFC 2231)
1115
     - and one word words as part of text, phrase or comment (RFC 2047)
1116
    For use with HTTP/1.1 MIME's parameter value encoding (RFC 2231)
1117
    was specialized and simplified via RFC 5987 and RFC 8187.
1118

1119
    For efficiency reasons and because HTTP is an extensible
1120
    protocol (an application can use headers not specified
1121
    by HTTP), we use an encoding registry to guide the header encoding.
1122
    An application can register an encoding for specific keys and/or
1123
    a default encoding to be used for keys without specific registration.
1124
    If there is neither a specific encoding nor a default encoding,
1125
    a header value remains unencoded.
1126
    Header values are encoded only if they contain non `ISO-8859-1` characters.
1127
    """
1128

1129
    def register(self, header, encoder, **kw):
1✔
1130
        """register *encoder* as encoder for header *header*.
1131

1132
        If *encoder* is `None`, this indicates that *header* should not
1133
        get encoded.
1134

1135
        If *header* is `None`, this indicates that *encoder* is defined
1136
        as the default encoder.
1137

1138
        When encoding is necessary, *encoder* is called with
1139
        the header value and the keywords specified by *kw*.
1140
        """
1141
        if header is not None:
1✔
1142
            header = header.lower()
1✔
1143
        self[header] = encoder, kw
1✔
1144

1145
    def unregister(self, header):
1✔
1146
        """remove any registration for *header*.
1147

1148
        *header* can be either a header name or `None`.
1149
        In the latter case, a default registration is removed.
1150
        """
1151
        if header is not None:
1!
1152
            header = header.lower()
1✔
1153
        if header in self:
1✔
1154
            del self[header]
1✔
1155

1156
    def encode(self, header, value):
1✔
1157
        """encode *value* as specified for *header*.
1158

1159
        encoding takes only place if *value* contains non ISO-8859-1 chars.
1160
        """
1161
        if not isinstance(value, str):
1!
1162
            return value
×
1163
        header = header.lower()
1✔
1164
        reg = self.get(header) or self.get(None)
1✔
1165
        if reg is None or reg[0] is None or non_latin_1(value) is None:
1✔
1166
            return value
1✔
1167
        return reg[0](value, **reg[1])
1✔
1168

1169

1170
non_latin_1 = re.compile(r"[^\x00-\xff]").search
1✔
1171

1172

1173
def encode_words(value):
1✔
1174
    """RFC 2047 word encoding.
1175

1176
    Note: treats *value* as unstructured data
1177
    and therefore must not be applied for headers with
1178
    a structured value (unless the structure is garanteed
1179
    to only contain ISO-8859-1 chars).
1180
    """
1181
    return Header(value, 'utf-8', 1000000).encode()
1✔
1182

1183

1184
def encode_params(value):
1✔
1185
    """RFC 5987(8187) (specialized from RFC 2231) parameter encoding.
1186

1187
    This encodes parameters as specified by RFC 5987 using
1188
    fixed `UTF-8` encoding (as required by RFC 8187).
1189
    However, all parameters with non latin-1 values are
1190
    automatically transformed and a `*` suffixed parameter is added
1191
    (RFC 8187 allows this only for parameters explicitly specified
1192
    to have this behavior).
1193

1194
    Many HTTP headers use `,` separated lists. For simplicity,
1195
    such headers are not supported (we would need to recognize
1196
    `,` inside quoted strings as special).
1197
    """
1198
    params = []
1✔
1199
    for p in _parseparam(";" + value):
1✔
1200
        p = p.strip()
1✔
1201
        if not p:
1✔
1202
            continue
1✔
1203
        params.append([s.strip() for s in p.split("=", 1)])
1✔
1204
    known_params = {p[0] for p in params}
1✔
1205
    for p in params[:]:
1✔
1206
        if len(p) == 2 and non_latin_1(p[1]):  # need encoding
1✔
1207
            pn = p[0]
1✔
1208
            pnc = pn + "*"
1✔
1209
            pv = p[1]
1✔
1210
            if pnc not in known_params:
1✔
1211
                if pv.startswith('"'):
1✔
1212
                    pv = pv[1:-1]  # remove quotes
1✔
1213
                params.append((pnc, encode_rfc2231(pv, "utf-8", None)))
1✔
1214
            # backward compatibility for clients not understanding RFC 5987
1215
            p[1] = p[1].encode("iso-8859-1", "replace").decode("iso-8859-1")
1✔
1216
    return "; ".join("=".join(p) for p in params)
1✔
1217

1218

1219
header_encoding_registry = HeaderEncodingRegistry()
1✔
1220
header_encoding_registry.register("content-type", encode_params)
1✔
1221
header_encoding_registry.register("content-disposition", encode_params)
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc