Skip to content

Commit

Permalink
Merge 49f2fc0 into 29d63d4
Browse files Browse the repository at this point in the history
  • Loading branch information
jugmac00 committed Sep 30, 2020
2 parents 29d63d4 + 49f2fc0 commit 0c52a91
Show file tree
Hide file tree
Showing 5 changed files with 95 additions and 2 deletions.
2 changes: 2 additions & 0 deletions CHANGES.rst
Expand Up @@ -11,6 +11,8 @@ https://github.com/zopefoundation/Zope/blob/4.x/CHANGES.rst
5.0a3 (unreleased)
------------------

- Fix export of files with non-latin-1 compatible names
(`#890 <https://github.com/zopefoundation/Zope/issues/890>`_)

- Add ``pyupgrade`` via ``pre-commit``
(`#859 <https://github.com/zopefoundation/Zope/issues/859>`_)
Expand Down
7 changes: 5 additions & 2 deletions src/OFS/ObjectManager.py
Expand Up @@ -61,6 +61,7 @@
from zope.interface.interfaces import ComponentLookupError
from zope.lifecycleevent import ObjectAddedEvent
from zope.lifecycleevent import ObjectRemovedEvent
from ZPublisher.HTTPResponse import make_content_disposition


# Constants: __replaceable__ flags:
Expand Down Expand Up @@ -611,8 +612,10 @@ def manage_exportObject(

if RESPONSE is not None:
RESPONSE.setHeader('Content-type', 'application/data')
RESPONSE.setHeader('Content-Disposition',
f'inline;filename={id}.{suffix}')
RESPONSE.setHeader(
'Content-Disposition',
make_content_disposition('inline', f'{id}.{suffix}')
)
return result

f = os.path.join(CONFIG.clienthome, f'{id}.{suffix}')
Expand Down
37 changes: 37 additions & 0 deletions src/Testing/ZopeTestCase/testZODBCompat.py
Expand Up @@ -32,6 +32,24 @@
cutpaste_permissions = [add_documents_images_and_files, delete_objects]


def make_request_response(environ=None):
from io import StringIO
from ZPublisher.HTTPRequest import HTTPRequest
from ZPublisher.HTTPResponse import HTTPResponse

if environ is None:
environ = {}

stdout = StringIO()
stdin = StringIO()
resp = HTTPResponse(stdout=stdout)
environ.setdefault('SERVER_NAME', 'foo')
environ.setdefault('SERVER_PORT', '80')
environ.setdefault('REQUEST_METHOD', 'GET')
req = HTTPRequest(stdin, environ, resp)
return req, resp


class DummyObject(SimpleItem):
id = 'dummy'
foo = None
Expand Down Expand Up @@ -96,6 +114,8 @@ class TestImportExport(ZopeTestCase.ZopeTestCase):
def afterSetUp(self):
self.setupLocalEnvironment()
self.folder.addDTMLMethod('doc', file='foo')
# please note the usage of the turkish i
self.folder.addDTMLMethod('ıq', file='foo')
# _p_oids are None until we create a savepoint
self.assertEqual(self.folder._p_oid, None)
transaction.savepoint(optimistic=True)
Expand All @@ -105,6 +125,23 @@ def testExport(self):
self.folder.manage_exportObject('doc')
self.assertTrue(os.path.exists(self.zexp_file))

def testExportNonLatinFileNames(self):
"""Test compatibility of the export with unicode characters.
Since Zope 4 also unicode ids can be used."""
_, response = make_request_response()
# please note the usage of a turkish `i`
self.folder.manage_exportObject(
'ıq', download=1, RESPONSE=response)

found = False
for header in response.listHeaders():
if header[0] == 'Content-Disposition':
# value needs to be `us-ascii` compatible
assert header[1].encode("us-ascii")
found = True
self.assertTrue(found)

def testImport(self):
self.folder.manage_exportObject('doc')
self.folder._delObject('doc')
Expand Down
24 changes: 24 additions & 0 deletions src/ZPublisher/HTTPResponse.py
Expand Up @@ -115,6 +115,30 @@ def build_http_date(when):
WEEKDAYNAME[wd], day, MONTHNAME[month], year, hh, mm, ss)


def make_content_disposition(disposition, file_name):
"""Create HTTP header for downloading a file with a UTF-8 filename.
See this and related answers: https://stackoverflow.com/a/8996249/2173868.
"""
header = f'{disposition}'
try:
file_name.encode('us-ascii')
except UnicodeEncodeError:
# the file cannot be encoded using the `us-ascii` encoding
# which is advocated by RFC 7230 - 7237
#
# a special header has to be crafted
# also see https://tools.ietf.org/html/rfc6266#appendix-D
encoded_file_name = file_name.encode('us-ascii', errors='ignore')
header += f'; filename="{encoded_file_name}"'
quoted_file_name = quote(file_name)
header += f'; filename*=UTF-8\'\'{quoted_file_name}'
return header
else:
header += f'; filename="{file_name}"'
return header


class HTTPBaseResponse(BaseResponse):
""" An object representation of an HTTP response.
Expand Down
27 changes: 27 additions & 0 deletions src/ZPublisher/tests/testHTTPResponse.py
Expand Up @@ -8,6 +8,7 @@
from zExceptions import NotFound
from zExceptions import ResourceLockedError
from zExceptions import Unauthorized
from ZPublisher.HTTPResponse import make_content_disposition


class HTTPResponseTests(unittest.TestCase):
Expand Down Expand Up @@ -1373,3 +1374,29 @@ def test_exception_500_text(self):
def test_isHTML_not_decodable_bytes(self):
response = self._makeOne()
self.assertFalse(response.isHTML('bïñårÿ'.encode('latin1')))


class MakeDispositionHeaderTests(unittest.TestCase):

def test_ascii(self):
self.assertEqual(
make_content_disposition('inline', 'iq.png'),
'inline; filename="iq.png"')

def test_latin_one(self):
self.assertEqual(
make_content_disposition('inline', 'Dänemark.png'),
'inline; filename="b\'Dnemark.png\'"; filename*=UTF-8\'\'D%C3%A4nemark.png' # noqa: E501
)

def test_unicode(self):
"""HTTP headers need to be latin-1 compatible
In order to offer file downloads which contain unicode file names,
the file name has to be treated in a special way, see
https://stackoverflow.com/questions/1361604 .
"""
self.assertEqual(
make_content_disposition('inline', 'ıq.png'),
'inline; filename="b\'q.png\'"; filename*=UTF-8\'\'%C4%B1q.png'
)

0 comments on commit 0c52a91

Please sign in to comment.