uploadshield 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- uploadshield/__init__.py +49 -0
- uploadshield/__main__.py +17 -0
- uploadshield/checks/__init__.py +65 -0
- uploadshield/checks/archive.py +316 -0
- uploadshield/checks/basic.py +476 -0
- uploadshield/checks/image.py +107 -0
- uploadshield/checks/magika.py +129 -0
- uploadshield/checks/markup.py +181 -0
- uploadshield/checks/office.py +174 -0
- uploadshield/checks/pdf.py +277 -0
- uploadshield/checks/scanners.py +205 -0
- uploadshield/checks/xml.py +123 -0
- uploadshield/cli/__init__.py +276 -0
- uploadshield/cli/_commands.py +401 -0
- uploadshield/cli/_json.py +80 -0
- uploadshield/cli/_render.py +319 -0
- uploadshield/cli/_scan.py +106 -0
- uploadshield/cli/_style.py +95 -0
- uploadshield/cli/_verdicts.py +363 -0
- uploadshield/cli/_walk.py +159 -0
- uploadshield/core/__init__.py +4 -0
- uploadshield/core/_deconstruct.py +39 -0
- uploadshield/core/check.py +79 -0
- uploadshield/core/containers.py +131 -0
- uploadshield/core/result.py +63 -0
- uploadshield/core/sniffing.py +432 -0
- uploadshield/core/types.py +319 -0
- uploadshield/core/upload.py +192 -0
- uploadshield/core/violations.py +79 -0
- uploadshield/django/__init__.py +36 -0
- uploadshield/django/apps.py +13 -0
- uploadshield/django/checks.py +148 -0
- uploadshield/django/cli.py +36 -0
- uploadshield/django/conf.py +189 -0
- uploadshield/django/decorators.py +204 -0
- uploadshield/django/exceptions.py +39 -0
- uploadshield/django/forms.py +40 -0
- uploadshield/django/handlers.py +322 -0
- uploadshield/django/responses.py +92 -0
- uploadshield/django/service.py +473 -0
- uploadshield/django/signals.py +16 -0
- uploadshield/django/storage.py +204 -0
- uploadshield/django/templates/uploadshield/rejected.html +15 -0
- uploadshield/django/validators.py +48 -0
- uploadshield/django/views.py +40 -0
- uploadshield/engine.py +230 -0
- uploadshield/fastapi/__init__.py +23 -0
- uploadshield/fastapi/shield.py +299 -0
- uploadshield/flask/__init__.py +42 -0
- uploadshield/flask/core.py +359 -0
- uploadshield/flask/decorators.py +69 -0
- uploadshield/flask/wtforms.py +58 -0
- uploadshield/policy.py +137 -0
- uploadshield/presets.py +65 -0
- uploadshield/py.typed +0 -0
- uploadshield/sanitizers.py +132 -0
- uploadshield/web.py +218 -0
- uploadshield-2.0.0.dist-info/METADATA +239 -0
- uploadshield-2.0.0.dist-info/RECORD +62 -0
- uploadshield-2.0.0.dist-info/WHEEL +4 -0
- uploadshield-2.0.0.dist-info/entry_points.txt +3 -0
- uploadshield-2.0.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""Validate every uploaded file while Django parses the request body.
|
|
2
|
+
|
|
3
|
+
Enable it as the only upload handler::
|
|
4
|
+
|
|
5
|
+
FILE_UPLOAD_HANDLERS = ["uploadshield.django.handlers.ValidatingUploadHandler"]
|
|
6
|
+
|
|
7
|
+
Every parser stores files through upload handlers: Django's ``request.POST``/
|
|
8
|
+
``request.FILES`` (views, forms, the admin) and DRF's ``MultiPartParser`` and
|
|
9
|
+
``FileUploadParser`` (POST, PUT, PATCH). This one setting therefore covers them all,
|
|
10
|
+
and it only does work when something actually parses a body. For each file:
|
|
11
|
+
|
|
12
|
+
1. ``new_file``: the name is checked before a single byte is stored.
|
|
13
|
+
2. ``receive_data_chunk``: size limits are enforced as data arrives, and as soon as
|
|
14
|
+
the first 8 KiB are in, the sniffed type is checked against the allow-list. A
|
|
15
|
+
refused file is never stored in full.
|
|
16
|
+
3. ``file_complete``: the complete policy runs (format-specific checks, sanitizers)
|
|
17
|
+
and the application receives the sanitized file.
|
|
18
|
+
|
|
19
|
+
A refused upload raises :class:`~uploadshield.django.exceptions.UploadRejected`
|
|
20
|
+
(Django answers 400 via ``handler400``), unless the view reports errors inline
|
|
21
|
+
(``on_reject="field"``) or the mode is ``report``. Incoming data is stored by the
|
|
22
|
+
handlers in ``UPLOADSHIELD["UPLOAD_HANDLERS"]`` (Django's defaults).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import dataclasses
|
|
28
|
+
from typing import IO, TYPE_CHECKING, Any
|
|
29
|
+
|
|
30
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
31
|
+
from django.core.files.uploadhandler import FileUploadHandler, StopFutureHandlers, load_handler
|
|
32
|
+
|
|
33
|
+
from uploadshield.core.result import ValidationResult
|
|
34
|
+
from uploadshield.core.types import format_size
|
|
35
|
+
from uploadshield.core.upload import HEAD_SIZE
|
|
36
|
+
from uploadshield.core.violations import Code, Violation
|
|
37
|
+
from uploadshield.engine import preflight
|
|
38
|
+
|
|
39
|
+
from .conf import get_config
|
|
40
|
+
from .exceptions import UploadRejected
|
|
41
|
+
from .service import (
|
|
42
|
+
FileOutcome,
|
|
43
|
+
RejectedUpload,
|
|
44
|
+
UploadValidation,
|
|
45
|
+
ViewSpec,
|
|
46
|
+
accept,
|
|
47
|
+
finish,
|
|
48
|
+
spec_for_request,
|
|
49
|
+
validate_file,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
if TYPE_CHECKING:
|
|
53
|
+
from collections.abc import Sequence
|
|
54
|
+
|
|
55
|
+
from django.core.files.uploadedfile import UploadedFile
|
|
56
|
+
from django.http import HttpRequest
|
|
57
|
+
|
|
58
|
+
from uploadshield.policy import Policy
|
|
59
|
+
|
|
60
|
+
__all__ = ["ValidatingUploadHandler"]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _resolve(request: HttpRequest) -> Any:
|
|
64
|
+
from django.urls import Resolver404, resolve
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
return resolve(request.path_info, getattr(request, "urlconf", None))
|
|
68
|
+
except Resolver404:
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class ValidatingUploadHandler(FileUploadHandler):
|
|
73
|
+
"""Django instantiates it from ``FILE_UPLOAD_HANDLERS`` with the request only.
|
|
74
|
+
|
|
75
|
+
``inner`` (the handlers that store the data) and ``spec`` (the policy) are for
|
|
76
|
+
:func:`~uploadshield.django.validate_uploads`, which installs one per request when
|
|
77
|
+
the handler is not configured globally.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def __init__(
|
|
81
|
+
self,
|
|
82
|
+
request: HttpRequest | None = None,
|
|
83
|
+
*,
|
|
84
|
+
inner: Sequence[FileUploadHandler] | None = None,
|
|
85
|
+
spec: ViewSpec | None = None,
|
|
86
|
+
) -> None:
|
|
87
|
+
super().__init__(request)
|
|
88
|
+
self.config = get_config()
|
|
89
|
+
if inner is None:
|
|
90
|
+
inner = [load_handler(path, request) for path in self.config.upload_handlers]
|
|
91
|
+
self.inner = list(inner)
|
|
92
|
+
if sizes := [h.chunk_size for h in self.inner if h.chunk_size]:
|
|
93
|
+
self.chunk_size = min(sizes)
|
|
94
|
+
self._spec: ViewSpec | None = spec
|
|
95
|
+
self.outcomes: list[FileOutcome] = []
|
|
96
|
+
self._per_field: dict[str, int] = {}
|
|
97
|
+
self._start_file(None)
|
|
98
|
+
|
|
99
|
+
# -- configuration for this request ------------------------------------------------
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def spec(self) -> ViewSpec:
|
|
103
|
+
if self._spec is None:
|
|
104
|
+
if self.request is None:
|
|
105
|
+
self._spec = ViewSpec(policy=self.config.default_policy)
|
|
106
|
+
else:
|
|
107
|
+
# Parsed before URL routing (e.g. from MIDDLEWARE): route it ourselves, so
|
|
108
|
+
# the view's decorator and VIEW_POLICIES still decide.
|
|
109
|
+
match = getattr(self.request, "resolver_match", None) or _resolve(self.request)
|
|
110
|
+
view = match.func if match is not None else None
|
|
111
|
+
self._spec = spec_for_request(self.request, view, self.config, match=match)
|
|
112
|
+
return self._spec
|
|
113
|
+
|
|
114
|
+
def use_spec(self, spec: ViewSpec) -> None:
|
|
115
|
+
"""Validate with ``spec``: set by ``@validate_uploads`` before the body is parsed,
|
|
116
|
+
also where routing cannot see the decorator (DRF ``@api_view``, ``method_decorator``)."""
|
|
117
|
+
self._spec = spec
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def mode(self) -> str:
|
|
121
|
+
return self.spec.effective_mode(self.config)
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def inline(self) -> bool:
|
|
125
|
+
return self.mode == "enforce" and self.spec.inline_errors(self.config)
|
|
126
|
+
|
|
127
|
+
# -- Django's upload handler protocol ----------------------------------------------
|
|
128
|
+
|
|
129
|
+
def handle_raw_input(
|
|
130
|
+
self,
|
|
131
|
+
input_data: IO[bytes],
|
|
132
|
+
META: dict[str, str], # noqa: N803 - Django's parameter name
|
|
133
|
+
content_length: int,
|
|
134
|
+
boundary: str,
|
|
135
|
+
encoding: str | None = None,
|
|
136
|
+
) -> None:
|
|
137
|
+
for handler in self.inner:
|
|
138
|
+
if handler.handle_raw_input(input_data, META, content_length, boundary, encoding):
|
|
139
|
+
raise ImproperlyConfigured(
|
|
140
|
+
f"{type(handler).__name__} parses the whole request itself, so its "
|
|
141
|
+
"files cannot be validated; remove it from UPLOAD_HANDLERS"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
def new_file( # noqa: PLR0917 - Django's signature
|
|
145
|
+
self,
|
|
146
|
+
field_name: str, # None for DRF's FileUploadParser (raw single-file bodies)
|
|
147
|
+
file_name: str,
|
|
148
|
+
content_type: str,
|
|
149
|
+
content_length: int | None,
|
|
150
|
+
charset: str | None = None,
|
|
151
|
+
content_type_extra: dict[str, bytes] | None = None,
|
|
152
|
+
) -> None:
|
|
153
|
+
super().new_file(
|
|
154
|
+
field_name, file_name, content_type, content_length, charset, content_type_extra
|
|
155
|
+
)
|
|
156
|
+
# Raw single-file bodies (DRF's FileUploadParser) arrive as request.FILES["file"].
|
|
157
|
+
field = field_name or "file"
|
|
158
|
+
policy = self.spec.policy_for(field, self.config) if self.mode != "off" else None
|
|
159
|
+
self._start_file(policy)
|
|
160
|
+
if policy is not None and self.mode == "enforce":
|
|
161
|
+
self._check(preflight(policy, stages={"name"}, **self._facts()))
|
|
162
|
+
if self.early is not None:
|
|
163
|
+
return # refused by name: nothing is ever stored
|
|
164
|
+
self.inner_started = True
|
|
165
|
+
for handler in self.inner:
|
|
166
|
+
try:
|
|
167
|
+
handler.new_file(
|
|
168
|
+
field_name,
|
|
169
|
+
file_name,
|
|
170
|
+
content_type,
|
|
171
|
+
content_length,
|
|
172
|
+
charset,
|
|
173
|
+
content_type_extra,
|
|
174
|
+
)
|
|
175
|
+
except StopFutureHandlers:
|
|
176
|
+
break
|
|
177
|
+
|
|
178
|
+
def receive_data_chunk(self, raw_data: bytes, start: int) -> None:
|
|
179
|
+
self.received += len(raw_data)
|
|
180
|
+
self._enforce_limits()
|
|
181
|
+
if self.early is not None:
|
|
182
|
+
return # refused; drop the rest of this file
|
|
183
|
+
if self.policy is not None and self.mode == "enforce" and not self.prechecked:
|
|
184
|
+
self.head += raw_data[: HEAD_SIZE - len(self.head)]
|
|
185
|
+
if len(self.head) >= HEAD_SIZE:
|
|
186
|
+
self.prechecked = True
|
|
187
|
+
self._check(preflight(policy=self.policy, stages={"type"}, **self._facts()))
|
|
188
|
+
if self.early is not None:
|
|
189
|
+
return
|
|
190
|
+
chunk: bytes | None = raw_data
|
|
191
|
+
for i, handler in enumerate(self.inner):
|
|
192
|
+
length = len(chunk)
|
|
193
|
+
chunk = handler.receive_data_chunk(chunk, self.counters[i])
|
|
194
|
+
self.counters[i] += length
|
|
195
|
+
if chunk is None:
|
|
196
|
+
break
|
|
197
|
+
return
|
|
198
|
+
|
|
199
|
+
def file_complete(self, file_size: int) -> UploadedFile | None:
|
|
200
|
+
file_obj = self._inner_complete()
|
|
201
|
+
field = self.field_name or "file"
|
|
202
|
+
if self.early is not None:
|
|
203
|
+
if file_obj is not None:
|
|
204
|
+
file_obj.close() # partial data of a refused file
|
|
205
|
+
return RejectedUpload(self.early, field_name=self.field_name, config=self.config)
|
|
206
|
+
if file_obj is None or self.policy is None:
|
|
207
|
+
return file_obj
|
|
208
|
+
result = validate_file(file_obj, self.policy, field=field)
|
|
209
|
+
if result is None: # pragma: no cover - handlers always return new uploads
|
|
210
|
+
return file_obj
|
|
211
|
+
outcome = FileOutcome(field, self._index(field), result, self.policy.name, file_obj)
|
|
212
|
+
self.outcomes.append(outcome)
|
|
213
|
+
if not result.ok and self.mode == "enforce" and not self.inline:
|
|
214
|
+
self._abort(file_obj)
|
|
215
|
+
delivered = accept(outcome, self.policy, self.config, inline=self.inline)
|
|
216
|
+
if delivered is not file_obj:
|
|
217
|
+
# Django only closes what ends up in request.FILES, and not at all when the
|
|
218
|
+
# parse fails later: hash the replaced original for the logs, close it now.
|
|
219
|
+
self.outcomes[-1] = dataclasses.replace(outcome, digest=outcome.sha256())
|
|
220
|
+
file_obj.close()
|
|
221
|
+
if self.field_name is None:
|
|
222
|
+
self._finish() # raw single-file body (DRF FileUploadParser): no upload_complete
|
|
223
|
+
return delivered
|
|
224
|
+
|
|
225
|
+
def upload_complete(self) -> None:
|
|
226
|
+
for handler in self.inner:
|
|
227
|
+
handler.upload_complete()
|
|
228
|
+
self._finish()
|
|
229
|
+
|
|
230
|
+
def upload_interrupted(self) -> None:
|
|
231
|
+
for handler in self.inner:
|
|
232
|
+
handler.upload_interrupted()
|
|
233
|
+
|
|
234
|
+
# -- internals ---------------------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
def _start_file(self, policy: Policy | None) -> None:
|
|
237
|
+
self.policy = policy
|
|
238
|
+
self.received = 0
|
|
239
|
+
self.head = bytearray()
|
|
240
|
+
self.prechecked = False
|
|
241
|
+
self.early: ValidationResult | None = None
|
|
242
|
+
self.inner_started = False
|
|
243
|
+
self.counters = [0] * len(self.inner)
|
|
244
|
+
|
|
245
|
+
def _facts(self) -> dict[str, Any]:
|
|
246
|
+
return {
|
|
247
|
+
"name": self.file_name or "",
|
|
248
|
+
"head": bytes(self.head),
|
|
249
|
+
"size": self.received,
|
|
250
|
+
"declared_type": self.content_type,
|
|
251
|
+
"field": self.field_name,
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
def _index(self, field: str) -> int:
|
|
255
|
+
index = self._per_field.get(field, 0)
|
|
256
|
+
self._per_field[field] = index + 1
|
|
257
|
+
return index
|
|
258
|
+
|
|
259
|
+
def _enforce_limits(self) -> None:
|
|
260
|
+
hard_cap = self.config.max_upload_size
|
|
261
|
+
if hard_cap is not None and self.received > hard_cap:
|
|
262
|
+
self._refuse(self._too_large(hard_cap), force_abort=True)
|
|
263
|
+
policy = self.policy
|
|
264
|
+
if (
|
|
265
|
+
policy is not None
|
|
266
|
+
and self.mode == "enforce"
|
|
267
|
+
and policy.max_size is not None
|
|
268
|
+
and self.received > policy.max_size
|
|
269
|
+
and self.early is None
|
|
270
|
+
):
|
|
271
|
+
self._check(preflight(policy, stages={"size"}, **self._facts()))
|
|
272
|
+
|
|
273
|
+
def _too_large(self, limit: int) -> ValidationResult:
|
|
274
|
+
violation = Violation(
|
|
275
|
+
code=Code.FILE_TOO_LARGE,
|
|
276
|
+
message=f"The file is too large (maximum {format_size(limit)}).",
|
|
277
|
+
check="size",
|
|
278
|
+
details={"size": self.received, "limit": limit, "aborted_upload": True},
|
|
279
|
+
)
|
|
280
|
+
return ValidationResult(
|
|
281
|
+
name=self.file_name or "",
|
|
282
|
+
field=self.field_name,
|
|
283
|
+
size=self.received,
|
|
284
|
+
file_type=None,
|
|
285
|
+
declared_type=self.content_type,
|
|
286
|
+
violations=(violation,),
|
|
287
|
+
checks_run=("size",),
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
def _check(self, result: ValidationResult) -> None:
|
|
291
|
+
if not result.ok:
|
|
292
|
+
self._refuse(result)
|
|
293
|
+
|
|
294
|
+
def _refuse(self, result: ValidationResult, *, force_abort: bool = False) -> None:
|
|
295
|
+
field = self.field_name or "file"
|
|
296
|
+
name = self.policy.name if self.policy is not None else "upload_limit"
|
|
297
|
+
self.outcomes.append(FileOutcome(field, self._index(field), result, name))
|
|
298
|
+
if self.inline and not force_abort:
|
|
299
|
+
self.early = result
|
|
300
|
+
return
|
|
301
|
+
self._abort()
|
|
302
|
+
|
|
303
|
+
def _inner_complete(self) -> UploadedFile | None:
|
|
304
|
+
if not self.inner_started:
|
|
305
|
+
return None
|
|
306
|
+
for i, handler in enumerate(self.inner):
|
|
307
|
+
if (file_obj := handler.file_complete(self.counters[i])) is not None:
|
|
308
|
+
return file_obj
|
|
309
|
+
return None
|
|
310
|
+
|
|
311
|
+
def _abort(self, rejected: UploadedFile | None = None) -> None:
|
|
312
|
+
validation = UploadValidation(tuple(self.outcomes), "enforce")
|
|
313
|
+
if self.request is not None:
|
|
314
|
+
finish(self.request, validation, self.spec) # logs, with the file's hash
|
|
315
|
+
if rejected is not None:
|
|
316
|
+
rejected.close() # never reaches request.FILES, so Django would not close it
|
|
317
|
+
self.upload_interrupted() # lets the storage handlers delete temporary files
|
|
318
|
+
raise UploadRejected(validation)
|
|
319
|
+
|
|
320
|
+
def _finish(self) -> None:
|
|
321
|
+
if self.request is not None and self.mode != "off":
|
|
322
|
+
finish(self.request, UploadValidation(tuple(self.outcomes), self.mode), self.spec)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Responses for rejected uploads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
from django.contrib import messages
|
|
8
|
+
from django.http import HttpResponse, HttpResponseRedirect, JsonResponse
|
|
9
|
+
from django.shortcuts import resolve_url
|
|
10
|
+
from django.template import TemplateDoesNotExist
|
|
11
|
+
from django.template.loader import render_to_string
|
|
12
|
+
from django.utils.translation import gettext as _
|
|
13
|
+
|
|
14
|
+
from uploadshield import web
|
|
15
|
+
|
|
16
|
+
from .conf import get_config
|
|
17
|
+
from .service import message_for
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from collections.abc import Callable
|
|
21
|
+
|
|
22
|
+
from django.http import HttpRequest
|
|
23
|
+
|
|
24
|
+
from .service import UploadValidation
|
|
25
|
+
|
|
26
|
+
__all__ = ["redirect_with_messages", "reject", "rejection_payload", "status_code_for"]
|
|
27
|
+
|
|
28
|
+
TEMPLATE_NAME = "uploadshield/rejected.html"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def status_code_for(validation: UploadValidation) -> int:
|
|
32
|
+
"""413 if every problem is size, 415 if every problem is the type, else 400."""
|
|
33
|
+
return web.status_code_for(o.result for o in validation.rejected)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def rejection_payload(
|
|
37
|
+
validation: UploadValidation, *, include_details: bool = False
|
|
38
|
+
) -> dict[str, Any]:
|
|
39
|
+
"""A JSON-serialisable description of the rejected files."""
|
|
40
|
+
return web.rejection_payload(
|
|
41
|
+
(o.result for o in validation.rejected),
|
|
42
|
+
include_details=include_details,
|
|
43
|
+
messages=get_config().messages,
|
|
44
|
+
message=_("The upload was rejected."),
|
|
45
|
+
fields=[o.field for o in validation.rejected],
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _wants_json(request: HttpRequest) -> bool:
|
|
50
|
+
return web.wants_json(request.headers.get("Accept"), request.headers.get("X-Requested-With"))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def reject(request: HttpRequest, validation: UploadValidation) -> HttpResponse:
|
|
54
|
+
"""Default ``REJECTION_HANDLER``: JSON for API clients, HTML otherwise.
|
|
55
|
+
|
|
56
|
+
The HTML uses the overridable template ``uploadshield/rejected.html``.
|
|
57
|
+
Diagnostics are only included when ``EXPOSE_DETAILS`` is enabled.
|
|
58
|
+
"""
|
|
59
|
+
payload = rejection_payload(validation, include_details=get_config().expose_details)
|
|
60
|
+
status = status_code_for(validation)
|
|
61
|
+
if _wants_json(request):
|
|
62
|
+
return JsonResponse(payload, status=status)
|
|
63
|
+
try:
|
|
64
|
+
body = render_to_string(TEMPLATE_NAME, {"rejection": payload}, request=request)
|
|
65
|
+
except TemplateDoesNotExist:
|
|
66
|
+
body = web.html_rejection(payload)
|
|
67
|
+
return HttpResponse(body, status=status)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def redirect_with_messages(
|
|
71
|
+
to: str, *, fallback_message: str | None = None
|
|
72
|
+
) -> Callable[[HttpRequest, UploadValidation], HttpResponse]:
|
|
73
|
+
"""Build a handler that redirects (URL name or path) and flashes each error.
|
|
74
|
+
|
|
75
|
+
Ideal for classic form views::
|
|
76
|
+
|
|
77
|
+
@validate_uploads("images", on_reject=redirect_with_messages("gallery"))
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def handler(request: HttpRequest, validation: UploadValidation) -> HttpResponse:
|
|
81
|
+
config = get_config()
|
|
82
|
+
flashed = False
|
|
83
|
+
for outcome in validation.rejected:
|
|
84
|
+
for violation in outcome.result.violations:
|
|
85
|
+
text = f"{outcome.result.name}: {message_for(violation, config)}"
|
|
86
|
+
messages.error(request, text, fail_silently=True)
|
|
87
|
+
flashed = True
|
|
88
|
+
if not flashed and fallback_message:
|
|
89
|
+
messages.error(request, fallback_message, fail_silently=True)
|
|
90
|
+
return HttpResponseRedirect(resolve_url(to))
|
|
91
|
+
|
|
92
|
+
return handler
|