forked from jazzband/django-downloadview
-
Notifications
You must be signed in to change notification settings - Fork 0
/
response.py
248 lines (192 loc) · 8.22 KB
/
response.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
""":py:class:`django.http.HttpResponse` subclasses."""
import mimetypes
import os
import re
import unicodedata
from urllib.parse import quote
from django.conf import settings
from django.http import HttpResponse, StreamingHttpResponse
from django.utils.encoding import force_str
def encode_basename_ascii(value):
"""Return US-ASCII encoded ``value`` for Content-Disposition header.
>>> print(encode_basename_ascii(u'éà'))
ea
Spaces are converted to underscores.
>>> print(encode_basename_ascii(' '))
_
Of course, ASCII values are not modified.
>>> print(encode_basename_ascii('ea'))
ea
>>> print(encode_basename_ascii(b'ea'))
ea
"""
if isinstance(value, bytes):
value = value.decode("utf-8")
ascii_basename = str(value)
ascii_basename = unicodedata.normalize("NFKD", ascii_basename)
ascii_basename = ascii_basename.encode("ascii", "ignore")
ascii_basename = ascii_basename.decode("ascii")
ascii_basename = re.sub(r"[\s]", "_", ascii_basename)
return ascii_basename
def encode_basename_utf8(value):
"""Return UTF-8 encoded ``value`` for use in Content-Disposition header.
>>> print(encode_basename_utf8(u' .txt'))
%20.txt
>>> print(encode_basename_utf8(u'éà'))
%C3%A9%C3%A0
"""
return quote(force_str(value))
def content_disposition(filename):
"""Return value of ``Content-Disposition`` header with 'attachment'.
>>> print(content_disposition('demo.txt'))
attachment; filename="demo.txt"
If filename is empty, only "attachment" is returned.
>>> print(content_disposition(''))
attachment
If filename contains non US-ASCII characters, the returned value contains
UTF-8 encoded filename and US-ASCII fallback.
>>> print(content_disposition(u'é.txt'))
attachment; filename="e.txt"; filename*=UTF-8''%C3%A9.txt
"""
if not filename:
return "attachment"
# ASCII filenames are quoted and must ensure escape sequences
# in the filename won't break out of the quoted header value
# which can permit a reflected file download attack. The UTF-8
# version is immune because it's not quoted.
ascii_filename = (
encode_basename_ascii(filename).replace("\\", "\\\\").replace('"', r"\"")
)
utf8_filename = encode_basename_utf8(filename)
if ascii_filename == utf8_filename: # ASCII only.
return f'attachment; filename="{ascii_filename}"'
else:
return (
f'attachment; filename="{ascii_filename}"; '
f"filename*=UTF-8''{utf8_filename}"
)
class DownloadResponse(StreamingHttpResponse):
"""File download response (Django serves file, client downloads it).
This is a specialization of :class:`django.http.StreamingHttpResponse`
where :attr:`~django.http.StreamingHttpResponse.streaming_content` is a
file wrapper.
Constructor differs a bit from :class:`~django.http.response.HttpResponse`.
Here are some highlights to understand internal mechanisms and motivations:
* Let's start by quoting :pep:`3333` (WSGI specification):
For large files, or for specialized uses of HTTP streaming,
applications will usually return an iterator (often a
generator-iterator) that produces the output in a block-by-block
fashion.
* Django WSGI handler (application implementation) returns response object
(see :mod:`django.core.handlers.wsgi`).
* :class:`django.http.HttpResponse` and subclasses are iterators.
* In :class:`~django.http.StreamingHttpResponse`, the
:meth:`~container.__iter__` implementation proxies to
:attr:`~django.http.StreamingHttpResponse.streaming_content`.
* In :class:`DownloadResponse` and subclasses, :attr:`streaming_content`
is a :doc:`file wrapper </files>`. File wrapper is itself an iterator
over actual file content, and it also encapsulates access to file
attributes (size, name, ...).
"""
def __init__(
self,
file_instance,
attachment=True,
basename=None,
status=200,
content_type=None,
file_mimetype=None,
file_encoding=None,
):
"""Constructor.
:param content_type: Value for ``Content-Type`` header.
If ``None``, then mime-type and encoding will be
populated by the response (default implementation
uses :mod:`mimetypes`, based on file name).
"""
#: A :doc:`file wrapper instance </files>`, such as
#: :class:`~django.core.files.base.File`.
self.file = file_instance
super().__init__(
streaming_content=self.file, status=status, content_type=content_type
)
#: Client-side name of the file to stream.
#: Only used if ``attachment`` is ``True``.
#: Affects ``Content-Disposition`` header.
self.basename = basename
#: Whether to return the file as attachment or not.
#: Affects ``Content-Disposition`` header.
self.attachment = attachment
if not content_type:
del self["Content-Type"] # Will be set later.
#: Value for file's mimetype.
#: If ``None`` (the default), then the file's mimetype will be guessed
#: via Python's :mod:`mimetypes`. See :meth:`get_mime_type`.
self.file_mimetype = file_mimetype
#: Value for file's encoding. If ``None`` (the default), then the
#: file's encoding will be guessed via Python's :mod:`mimetypes`. See
#: :meth:`get_encoding`.
self.file_encoding = file_encoding
# Apply default headers.
for header, value in self.default_headers.items():
if header not in self:
self[header] = value # Does self support setdefault?
@property
def default_headers(self):
"""Return dictionary of automatically-computed headers.
Uses an internal ``_default_headers`` cache.
Default values are computed if only cache hasn't been set.
``Content-Disposition`` header is encoded according to `RFC 5987
<http://tools.ietf.org/html/rfc5987>`_. See also
http://stackoverflow.com/questions/93551/.
"""
try:
return self._default_headers
except AttributeError:
headers = {}
headers["Content-Type"] = self.get_content_type()
try:
headers["Content-Length"] = self.file.size
except (AttributeError, NotImplementedError):
pass # Generated files.
if self.attachment:
basename = self.get_basename()
headers["Content-Disposition"] = content_disposition(basename)
self._default_headers = headers
return self._default_headers
def get_basename(self):
"""Return basename."""
if self.basename:
return self.basename
else:
return os.path.basename(self.file.name)
def get_content_type(self):
"""Return a suitable "Content-Type" header for ``self.file``."""
try:
return self.file.content_type
except AttributeError:
return f"{self.get_mime_type()}; charset={self.get_charset()}"
def get_mime_type(self):
"""Return mime-type of the file."""
if self.file_mimetype is not None:
return self.file_mimetype
default_mime_type = "application/octet-stream"
basename = self.get_basename()
mime_type, encoding = mimetypes.guess_type(basename)
return mime_type or default_mime_type
def get_encoding(self):
"""Return encoding of the file to serve."""
if self.file_encoding is not None:
return self.file_encoding
basename = self.get_basename()
mime_type, encoding = mimetypes.guess_type(basename)
return encoding
def get_charset(self):
"""Return the charset of the file to serve."""
return settings.DEFAULT_CHARSET
class ProxiedDownloadResponse(HttpResponse):
"""Base class for internal redirect download responses.
This base class makes it possible to identify several types of specific
responses such as
:py:class:`~django_downloadview.nginx.response.XAccelRedirectResponse`.
"""