plain 0.7.0__tar.gz → 0.8.1__tar.gz
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.
- {plain-0.7.0 → plain-0.8.1}/PKG-INFO +2 -1
- {plain-0.7.0 → plain-0.8.1}/plain/cli/cli.py +1 -1
- {plain-0.7.0 → plain-0.8.1}/plain/csrf/middleware.py +23 -40
- {plain-0.7.0 → plain-0.8.1}/plain/http/request.py +2 -2
- {plain-0.7.0 → plain-0.8.1}/plain/internal/handlers/base.py +13 -1
- plain-0.8.1/plain/internal/middleware/headers.py +19 -0
- plain-0.8.1/plain/internal/middleware/https.py +36 -0
- plain-0.7.0/plain/middleware/common.py → plain-0.8.1/plain/internal/middleware/slash.py +2 -25
- {plain-0.7.0 → plain-0.8.1}/plain/preflight/security/base.py +4 -19
- {plain-0.7.0 → plain-0.8.1}/plain/preflight/security/csrf.py +1 -5
- {plain-0.7.0 → plain-0.8.1}/plain/runtime/README.md +0 -3
- {plain-0.7.0 → plain-0.8.1}/plain/runtime/global_settings.py +19 -25
- {plain-0.7.0 → plain-0.8.1}/plain/test/client.py +17 -17
- {plain-0.7.0 → plain-0.8.1}/plain/views/base.py +4 -0
- {plain-0.7.0 → plain-0.8.1}/pyproject.toml +1 -1
- plain-0.7.0/plain/middleware/README.md +0 -3
- plain-0.7.0/plain/middleware/gzip.py +0 -64
- plain-0.7.0/plain/middleware/security.py +0 -31
- {plain-0.7.0 → plain-0.8.1}/LICENSE +0 -0
- {plain-0.7.0 → plain-0.8.1}/README.md +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/README.md +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/__main__.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/assets/README.md +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/assets/__init__.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/assets/compile.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/assets/finders.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/assets/fingerprints.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/assets/urls.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/assets/views.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/cli/README.md +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/cli/__init__.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/cli/formatting.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/cli/packages.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/cli/print.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/cli/startup.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/csrf/README.md +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/csrf/views.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/debug.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/exceptions.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/forms/README.md +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/forms/__init__.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/forms/boundfield.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/forms/exceptions.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/forms/fields.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/forms/forms.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/http/README.md +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/http/__init__.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/http/cookie.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/http/multipartparser.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/http/response.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/internal/__init__.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/internal/files/README.md +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/internal/files/__init__.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/internal/files/base.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/internal/files/locks.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/internal/files/move.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/internal/files/temp.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/internal/files/uploadedfile.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/internal/files/uploadhandler.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/internal/files/utils.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/internal/handlers/__init__.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/internal/handlers/exception.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/internal/handlers/wsgi.py +0 -0
- {plain-0.7.0/plain → plain-0.8.1/plain/internal}/middleware/__init__.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/json.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/logs/README.md +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/logs/__init__.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/logs/configure.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/logs/loggers.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/logs/utils.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/packages/README.md +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/packages/__init__.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/packages/config.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/packages/registry.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/paginator.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/preflight/README.md +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/preflight/__init__.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/preflight/files.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/preflight/messages.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/preflight/registry.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/preflight/security/__init__.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/preflight/urls.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/runtime/__init__.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/runtime/user_settings.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/signals/README.md +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/signals/__init__.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/signals/dispatch/__init__.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/signals/dispatch/dispatcher.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/signals/dispatch/license.txt +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/signing.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/templates/README.md +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/templates/__init__.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/templates/core.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/templates/jinja/README.md +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/templates/jinja/__init__.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/templates/jinja/defaults.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/templates/jinja/extensions.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/templates/jinja/filters.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/templates/jinja/globals.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/test/README.md +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/test/__init__.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/urls/README.md +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/urls/__init__.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/urls/base.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/urls/conf.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/urls/converters.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/urls/exceptions.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/urls/resolvers.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/utils/README.md +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/utils/__init__.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/utils/_os.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/utils/cache.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/utils/connection.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/utils/crypto.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/utils/datastructures.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/utils/dateformat.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/utils/dateparse.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/utils/dates.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/utils/deconstruct.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/utils/decorators.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/utils/deprecation.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/utils/duration.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/utils/email.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/utils/encoding.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/utils/functional.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/utils/hashable.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/utils/html.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/utils/http.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/utils/inspect.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/utils/ipv6.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/utils/itercompat.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/utils/module_loading.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/utils/regex_helper.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/utils/safestring.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/utils/termcolors.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/utils/text.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/utils/timesince.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/utils/timezone.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/utils/tree.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/validators.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/views/README.md +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/views/__init__.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/views/csrf.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/views/errors.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/views/exceptions.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/views/forms.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/views/objects.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/views/redirect.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/views/templates.py +0 -0
- {plain-0.7.0 → plain-0.8.1}/plain/wsgi.py +0 -0
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: plain
|
3
|
-
Version: 0.
|
3
|
+
Version: 0.8.1
|
4
4
|
Summary: A web framework for building products with Python.
|
5
5
|
Author: Dave Gaeddert
|
6
6
|
Author-email: dave.gaeddert@dropseed.dev
|
@@ -8,6 +8,7 @@ Requires-Python: >=3.11,<4.0
|
|
8
8
|
Classifier: Programming Language :: Python :: 3
|
9
9
|
Classifier: Programming Language :: Python :: 3.11
|
10
10
|
Classifier: Programming Language :: Python :: 3.12
|
11
|
+
Classifier: Programming Language :: Python :: 3.13
|
11
12
|
Requires-Dist: click (>=8.0.0)
|
12
13
|
Requires-Dist: jinja2 (>=3.1.2,<4.0.0)
|
13
14
|
Requires-Dist: python-dotenv (>=1.0.0,<2.0.0)
|
@@ -251,7 +251,7 @@ def preflight_checks(package_label, deploy, fail_level, databases):
|
|
251
251
|
msg = header + body + footer
|
252
252
|
click.echo(msg, err=True)
|
253
253
|
else:
|
254
|
-
click.
|
254
|
+
click.secho("✔ Preflight check identified no issues.", err=True, fg="green")
|
255
255
|
|
256
256
|
|
257
257
|
@plain_cli.command()
|
@@ -9,7 +9,7 @@ import string
|
|
9
9
|
from collections import defaultdict
|
10
10
|
from urllib.parse import urlparse
|
11
11
|
|
12
|
-
from plain.exceptions import DisallowedHost
|
12
|
+
from plain.exceptions import DisallowedHost
|
13
13
|
from plain.http import HttpHeaders, UnreadablePostError
|
14
14
|
from plain.logs import log_response
|
15
15
|
from plain.runtime import settings
|
@@ -242,44 +242,31 @@ class CsrfViewMiddleware:
|
|
242
242
|
If the CSRF_USE_SESSIONS setting is false, raises InvalidTokenFormat if
|
243
243
|
the request's secret has invalid characters or an invalid length.
|
244
244
|
"""
|
245
|
-
|
246
|
-
|
247
|
-
|
248
|
-
|
249
|
-
raise ImproperlyConfigured(
|
250
|
-
"CSRF_USE_SESSIONS is enabled, but request.session is not "
|
251
|
-
"set. SessionMiddleware must appear before CsrfViewMiddleware "
|
252
|
-
"in MIDDLEWARE."
|
253
|
-
)
|
245
|
+
try:
|
246
|
+
csrf_secret = request.COOKIES[settings.CSRF_COOKIE_NAME]
|
247
|
+
except KeyError:
|
248
|
+
csrf_secret = None
|
254
249
|
else:
|
255
|
-
|
256
|
-
|
257
|
-
|
258
|
-
csrf_secret = None
|
259
|
-
else:
|
260
|
-
# This can raise InvalidTokenFormat.
|
261
|
-
_check_token_format(csrf_secret)
|
250
|
+
# This can raise InvalidTokenFormat.
|
251
|
+
_check_token_format(csrf_secret)
|
252
|
+
|
262
253
|
if csrf_secret is None:
|
263
254
|
return None
|
264
255
|
return csrf_secret
|
265
256
|
|
266
257
|
def _set_csrf_cookie(self, request, response):
|
267
|
-
|
268
|
-
|
269
|
-
|
270
|
-
|
271
|
-
|
272
|
-
|
273
|
-
|
274
|
-
|
275
|
-
|
276
|
-
|
277
|
-
|
278
|
-
|
279
|
-
samesite=settings.CSRF_COOKIE_SAMESITE,
|
280
|
-
)
|
281
|
-
# Set the Vary header since content varies with the CSRF cookie.
|
282
|
-
patch_vary_headers(response, ("Cookie",))
|
258
|
+
response.set_cookie(
|
259
|
+
settings.CSRF_COOKIE_NAME,
|
260
|
+
request.META["CSRF_COOKIE"],
|
261
|
+
max_age=settings.CSRF_COOKIE_AGE,
|
262
|
+
domain=settings.CSRF_COOKIE_DOMAIN,
|
263
|
+
path=settings.CSRF_COOKIE_PATH,
|
264
|
+
secure=settings.CSRF_COOKIE_SECURE,
|
265
|
+
httponly=settings.CSRF_COOKIE_HTTPONLY,
|
266
|
+
samesite=settings.CSRF_COOKIE_SAMESITE,
|
267
|
+
)
|
268
|
+
# Set the Vary header since content varies with the CSRF cookie.
|
269
|
+
patch_vary_headers(response, ("Cookie",))
|
283
270
|
|
284
271
|
def _origin_verified(self, request):
|
285
272
|
request_origin = request.META["HTTP_ORIGIN"]
|
@@ -289,7 +276,7 @@ class CsrfViewMiddleware:
|
|
289
276
|
pass
|
290
277
|
else:
|
291
278
|
good_origin = "{}://{}".format(
|
292
|
-
"https" if request.
|
279
|
+
"https" if request.is_https() else "http",
|
293
280
|
good_host,
|
294
281
|
)
|
295
282
|
if request_origin == good_origin:
|
@@ -331,11 +318,7 @@ class CsrfViewMiddleware:
|
|
331
318
|
):
|
332
319
|
return
|
333
320
|
# Allow matching the configured cookie domain.
|
334
|
-
good_referer =
|
335
|
-
settings.SESSION_COOKIE_DOMAIN
|
336
|
-
if settings.CSRF_USE_SESSIONS
|
337
|
-
else settings.CSRF_COOKIE_DOMAIN
|
338
|
-
)
|
321
|
+
good_referer = settings.CSRF_COOKIE_DOMAIN
|
339
322
|
if good_referer is None:
|
340
323
|
# If no cookie domain is configured, allow matching the current
|
341
324
|
# host:port exactly if it's permitted by ALLOWED_HOSTS.
|
@@ -435,7 +418,7 @@ class CsrfViewMiddleware:
|
|
435
418
|
return self._reject(
|
436
419
|
request, REASON_BAD_ORIGIN % request.META["HTTP_ORIGIN"]
|
437
420
|
)
|
438
|
-
elif request.
|
421
|
+
elif request.is_https():
|
439
422
|
# If the Origin header wasn't provided, reject HTTPS requests if
|
440
423
|
# the Referer header doesn't match an allowed value.
|
441
424
|
#
|
@@ -140,7 +140,7 @@ class HttpRequest:
|
|
140
140
|
# Reconstruct the host using the algorithm from PEP 333.
|
141
141
|
host = self.META["SERVER_NAME"]
|
142
142
|
server_port = self.get_port()
|
143
|
-
if server_port != ("443" if self.
|
143
|
+
if server_port != ("443" if self.is_https() else "80"):
|
144
144
|
host = f"{host}:{server_port}"
|
145
145
|
return host
|
146
146
|
|
@@ -281,7 +281,7 @@ class HttpRequest:
|
|
281
281
|
return "https" if header_value.strip() == secure_value else "http"
|
282
282
|
return self._get_scheme()
|
283
283
|
|
284
|
-
def
|
284
|
+
def is_https(self):
|
285
285
|
return self.scheme == "https"
|
286
286
|
|
287
287
|
@property
|
@@ -13,6 +13,15 @@ from .exception import convert_exception_to_response
|
|
13
13
|
logger = logging.getLogger("plain.request")
|
14
14
|
|
15
15
|
|
16
|
+
# These middleware classes are always used by Plain.
|
17
|
+
BUILTIN_MIDDLEWARE = [
|
18
|
+
"plain.internal.middleware.headers.DefaultHeadersMiddleware",
|
19
|
+
"plain.internal.middleware.https.HttpsRedirectMiddleware",
|
20
|
+
"plain.internal.middleware.slash.RedirectSlashMiddleware",
|
21
|
+
"plain.csrf.middleware.CsrfViewMiddleware",
|
22
|
+
]
|
23
|
+
|
24
|
+
|
16
25
|
class BaseHandler:
|
17
26
|
_view_middleware = None
|
18
27
|
_middleware_chain = None
|
@@ -27,7 +36,10 @@ class BaseHandler:
|
|
27
36
|
|
28
37
|
get_response = self._get_response
|
29
38
|
handler = convert_exception_to_response(get_response)
|
30
|
-
|
39
|
+
|
40
|
+
middlewares = reversed(BUILTIN_MIDDLEWARE + settings.MIDDLEWARE)
|
41
|
+
|
42
|
+
for middleware_path in middlewares:
|
31
43
|
middleware = import_string(middleware_path)
|
32
44
|
mw_instance = middleware(handler)
|
33
45
|
|
@@ -0,0 +1,19 @@
|
|
1
|
+
from plain.runtime import settings
|
2
|
+
|
3
|
+
|
4
|
+
class DefaultHeadersMiddleware:
|
5
|
+
def __init__(self, get_response):
|
6
|
+
self.get_response = get_response
|
7
|
+
|
8
|
+
def __call__(self, request):
|
9
|
+
response = self.get_response(request)
|
10
|
+
|
11
|
+
for header, value in settings.DEFAULT_RESPONSE_HEADERS.items():
|
12
|
+
response.headers.setdefault(header, value)
|
13
|
+
|
14
|
+
# Add the Content-Length header to non-streaming responses if not
|
15
|
+
# already set.
|
16
|
+
if not response.streaming and not response.has_header("Content-Length"):
|
17
|
+
response.headers["Content-Length"] = str(len(response.content))
|
18
|
+
|
19
|
+
return response
|
@@ -0,0 +1,36 @@
|
|
1
|
+
import re
|
2
|
+
|
3
|
+
from plain.http import ResponsePermanentRedirect
|
4
|
+
from plain.runtime import settings
|
5
|
+
|
6
|
+
|
7
|
+
class HttpsRedirectMiddleware:
|
8
|
+
def __init__(self, get_response):
|
9
|
+
self.get_response = get_response
|
10
|
+
|
11
|
+
# Settings for https (compile regexes once)
|
12
|
+
self.https_redirect_enabled = settings.HTTPS_REDIRECT_ENABLED
|
13
|
+
self.https_redirect_host = settings.HTTPS_REDIRECT_HOST
|
14
|
+
self.https_redirect_exempt = [
|
15
|
+
re.compile(r) for r in settings.HTTPS_REDIRECT_EXEMPT
|
16
|
+
]
|
17
|
+
|
18
|
+
def __call__(self, request):
|
19
|
+
"""
|
20
|
+
Rewrite the URL based on settings.APPEND_SLASH
|
21
|
+
"""
|
22
|
+
|
23
|
+
if redirect_response := self.maybe_https_redirect(request):
|
24
|
+
return redirect_response
|
25
|
+
|
26
|
+
return self.get_response(request)
|
27
|
+
|
28
|
+
def maybe_https_redirect(self, request):
|
29
|
+
path = request.path.lstrip("/")
|
30
|
+
if (
|
31
|
+
self.https_redirect_enabled
|
32
|
+
and not request.is_https()
|
33
|
+
and not any(pattern.search(path) for pattern in self.https_redirect_exempt)
|
34
|
+
):
|
35
|
+
host = self.https_redirect_host or request.get_host()
|
36
|
+
return ResponsePermanentRedirect(f"https://{host}{request.get_full_path()}")
|
@@ -4,25 +4,7 @@ from plain.urls import is_valid_path
|
|
4
4
|
from plain.utils.http import escape_leading_slashes
|
5
5
|
|
6
6
|
|
7
|
-
class
|
8
|
-
"""
|
9
|
-
"Common" middleware for taking care of some basic operations:
|
10
|
-
|
11
|
-
- URL rewriting: Based on the APPEND_SLASH setting,
|
12
|
-
append missing slashes.
|
13
|
-
|
14
|
-
- If APPEND_SLASH is set and the initial URL doesn't end with a
|
15
|
-
slash, and it is not found in urlpatterns, form a new URL by
|
16
|
-
appending a slash at the end. If this new URL is found in
|
17
|
-
urlpatterns, return an HTTP redirect to this new URL; otherwise
|
18
|
-
process the initial URL as usual.
|
19
|
-
|
20
|
-
This behavior can be customized by subclassing CommonMiddleware and
|
21
|
-
overriding the response_redirect_class attribute.
|
22
|
-
"""
|
23
|
-
|
24
|
-
response_redirect_class = ResponsePermanentRedirect
|
25
|
-
|
7
|
+
class RedirectSlashMiddleware:
|
26
8
|
def __init__(self, get_response):
|
27
9
|
self.get_response = get_response
|
28
10
|
|
@@ -40,12 +22,7 @@ class CommonMiddleware:
|
|
40
22
|
# If the given URL is "Not Found", then check if we should redirect to
|
41
23
|
# a path with a slash appended.
|
42
24
|
if response.status_code == 404 and self.should_redirect_with_slash(request):
|
43
|
-
return
|
44
|
-
|
45
|
-
# Add the Content-Length header to non-streaming responses if not
|
46
|
-
# already set.
|
47
|
-
if not response.streaming and not response.has_header("Content-Length"):
|
48
|
-
response.headers["Content-Length"] = str(len(response.content))
|
25
|
+
return ResponsePermanentRedirect(self.get_full_path_with_slash(request))
|
49
26
|
|
50
27
|
return response
|
51
28
|
|
@@ -16,17 +16,18 @@ SECRET_KEY_WARNING_MSG = (
|
|
16
16
|
f"vulnerable to attack."
|
17
17
|
)
|
18
18
|
|
19
|
+
# TODO
|
19
20
|
W001 = Warning(
|
20
|
-
"You do not have 'plain.middleware.
|
21
|
+
"You do not have 'plain.middleware.https.HttpsRedirectMiddleware' "
|
21
22
|
"in your MIDDLEWARE so the SECURE_HSTS_SECONDS, "
|
22
23
|
"SECURE_CONTENT_TYPE_NOSNIFF, SECURE_REFERRER_POLICY, "
|
23
|
-
"SECURE_CROSS_ORIGIN_OPENER_POLICY, and
|
24
|
+
"SECURE_CROSS_ORIGIN_OPENER_POLICY, and HTTPS_REDIRECT_ENABLED settings will "
|
24
25
|
"have no effect.",
|
25
26
|
id="security.W001",
|
26
27
|
)
|
27
28
|
|
28
29
|
W008 = Warning(
|
29
|
-
"Your
|
30
|
+
"Your HTTPS_REDIRECT_ENABLED setting is not set to True. "
|
30
31
|
"Unless your site should be available over both SSL and non-SSL "
|
31
32
|
"connections, you may want to either set this setting True "
|
32
33
|
"or configure a load balancer or reverse-proxy server "
|
@@ -52,22 +53,6 @@ W020 = Warning(
|
|
52
53
|
W025 = Warning(SECRET_KEY_WARNING_MSG, id="security.W025")
|
53
54
|
|
54
55
|
|
55
|
-
def _security_middleware():
|
56
|
-
return "plain.middleware.security.SecurityMiddleware" in settings.MIDDLEWARE
|
57
|
-
|
58
|
-
|
59
|
-
@register(deploy=True)
|
60
|
-
def check_security_middleware(package_configs, **kwargs):
|
61
|
-
passed_check = _security_middleware()
|
62
|
-
return [] if passed_check else [W001]
|
63
|
-
|
64
|
-
|
65
|
-
@register(deploy=True)
|
66
|
-
def check_ssl_redirect(package_configs, **kwargs):
|
67
|
-
passed_check = not _security_middleware() or settings.SECURE_SSL_REDIRECT is True
|
68
|
-
return [] if passed_check else [W008]
|
69
|
-
|
70
|
-
|
71
56
|
def _check_secret_key(secret_key):
|
72
57
|
return (
|
73
58
|
len(set(secret_key)) >= SECRET_KEY_MIN_UNIQUE_CHARACTERS
|
@@ -32,9 +32,5 @@ def check_csrf_middleware(package_configs, **kwargs):
|
|
32
32
|
|
33
33
|
@register(deploy=True)
|
34
34
|
def check_csrf_cookie_secure(package_configs, **kwargs):
|
35
|
-
passed_check = (
|
36
|
-
settings.CSRF_USE_SESSIONS
|
37
|
-
or not _csrf_middleware()
|
38
|
-
or settings.CSRF_COOKIE_SECURE is True
|
39
|
-
)
|
35
|
+
passed_check = not _csrf_middleware() or settings.CSRF_COOKIE_SECURE is True
|
40
36
|
return [] if passed_check else [W016]
|
@@ -54,10 +54,7 @@ SECRET_KEY = environ["SECRET_KEY"]
|
|
54
54
|
DEBUG = environ.get("DEBUG", "false").lower() in ("true", "1", "yes")
|
55
55
|
|
56
56
|
MIDDLEWARE = [
|
57
|
-
"plain.middleware.security.SecurityMiddleware",
|
58
57
|
"plain.sessions.middleware.SessionMiddleware",
|
59
|
-
"plain.middleware.common.CommonMiddleware",
|
60
|
-
"plain.csrf.middleware.CsrfViewMiddleware",
|
61
58
|
"plain.auth.middleware.AuthenticationMiddleware",
|
62
59
|
]
|
63
60
|
|
@@ -29,11 +29,27 @@ TIME_ZONE: str = "UTC"
|
|
29
29
|
DEFAULT_CHARSET = "utf-8"
|
30
30
|
|
31
31
|
# List of strings representing installed packages.
|
32
|
-
INSTALLED_PACKAGES: list = []
|
32
|
+
INSTALLED_PACKAGES: list[str] = []
|
33
33
|
|
34
34
|
# Whether to append trailing slashes to URLs.
|
35
35
|
APPEND_SLASH = True
|
36
36
|
|
37
|
+
# Default headers for all responses.
|
38
|
+
DEFAULT_RESPONSE_HEADERS = {
|
39
|
+
# "Content-Security-Policy": "default-src 'self'",
|
40
|
+
# https://hstspreload.org/
|
41
|
+
# "Strict-Transport-Security": "max-age=31536000; includeSubDomains; preload",
|
42
|
+
"Cross-Origin-Opener-Policy": "same-origin",
|
43
|
+
"Referrer-Policy": "same-origin",
|
44
|
+
"X-Content-Type-Options": "nosniff",
|
45
|
+
"X-Frame-Options": "DENY",
|
46
|
+
}
|
47
|
+
|
48
|
+
# Whether to redirect all non-HTTPS requests to HTTPS.
|
49
|
+
HTTPS_REDIRECT_ENABLED = True
|
50
|
+
HTTPS_REDIRECT_EXEMPT = []
|
51
|
+
HTTPS_REDIRECT_HOST = None
|
52
|
+
|
37
53
|
# A secret key for this particular Plain installation. Used in secret-key
|
38
54
|
# hashing algorithms. Set this in your settings, or Plain will complain
|
39
55
|
# loudly.
|
@@ -82,7 +98,7 @@ HTTP_ERROR_VIEWS: dict[int] = {}
|
|
82
98
|
# connections, AND that proxy ensures that user-submitted headers with the
|
83
99
|
# same name are ignored (so that people can't spoof it), set this value to
|
84
100
|
# a tuple of (header_name, header_value). For any requests that come in with
|
85
|
-
# that header/value, request.
|
101
|
+
# that header/value, request.is_https() will return True.
|
86
102
|
# WARNING! Only set this if you fully understand what you're doing. Otherwise,
|
87
103
|
# you may be opening yourself up to a security risk.
|
88
104
|
SECURE_PROXY_SSL_HEADER = None
|
@@ -94,11 +110,7 @@ SECURE_PROXY_SSL_HEADER = None
|
|
94
110
|
# List of middleware to use. Order is important; in the request phase, these
|
95
111
|
# middleware will be applied in the order given, and in the response
|
96
112
|
# phase the middleware will be applied in reverse order.
|
97
|
-
MIDDLEWARE = [
|
98
|
-
"plain.middleware.security.SecurityMiddleware",
|
99
|
-
"plain.middleware.common.CommonMiddleware",
|
100
|
-
"plain.csrf.middleware.CsrfViewMiddleware",
|
101
|
-
]
|
113
|
+
MIDDLEWARE: list[str] = []
|
102
114
|
|
103
115
|
###########
|
104
116
|
# SIGNING #
|
@@ -120,7 +132,6 @@ CSRF_COOKIE_HTTPONLY = False
|
|
120
132
|
CSRF_COOKIE_SAMESITE = "Lax"
|
121
133
|
CSRF_HEADER_NAME = "HTTP_X_CSRFTOKEN"
|
122
134
|
CSRF_TRUSTED_ORIGINS: list[str] = []
|
123
|
-
CSRF_USE_SESSIONS = False
|
124
135
|
|
125
136
|
###########
|
126
137
|
# LOGGING #
|
@@ -150,23 +161,6 @@ ASSETS_BASE_URL: str = ""
|
|
150
161
|
# message, but Plain will not stop you from e.g. running server.
|
151
162
|
SILENCED_PREFLIGHT_CHECKS = []
|
152
163
|
|
153
|
-
#######################
|
154
|
-
# SECURITY MIDDLEWARE #
|
155
|
-
#######################
|
156
|
-
SECURE_REDIRECT_EXEMPT = []
|
157
|
-
SECURE_SSL_HOST = None
|
158
|
-
SECURE_SSL_REDIRECT = True
|
159
|
-
|
160
|
-
SECURE_DEFAULT_HEADERS = {
|
161
|
-
# "Content-Security-Policy": "default-src 'self'",
|
162
|
-
# https://hstspreload.org/
|
163
|
-
# "Strict-Transport-Security": "max-age=31536000; includeSubDomains; preload",
|
164
|
-
"Cross-Origin-Opener-Policy": "same-origin",
|
165
|
-
"Referrer-Policy": "same-origin",
|
166
|
-
"X-Content-Type-Options": "nosniff",
|
167
|
-
"X-Frame-Options": "DENY",
|
168
|
-
}
|
169
|
-
|
170
164
|
#############
|
171
165
|
# Templates #
|
172
166
|
#############
|
@@ -379,7 +379,7 @@ class RequestFactory:
|
|
379
379
|
# Refs comment in `get_bytes_from_wsgi()`.
|
380
380
|
return path.decode("iso-8859-1")
|
381
381
|
|
382
|
-
def get(self, path, data=None, secure=
|
382
|
+
def get(self, path, data=None, secure=True, *, headers=None, **extra):
|
383
383
|
"""Construct a GET request."""
|
384
384
|
data = {} if data is None else data
|
385
385
|
return self.generic(
|
@@ -398,7 +398,7 @@ class RequestFactory:
|
|
398
398
|
path,
|
399
399
|
data=None,
|
400
400
|
content_type=MULTIPART_CONTENT,
|
401
|
-
secure=
|
401
|
+
secure=True,
|
402
402
|
*,
|
403
403
|
headers=None,
|
404
404
|
**extra,
|
@@ -417,7 +417,7 @@ class RequestFactory:
|
|
417
417
|
**extra,
|
418
418
|
)
|
419
419
|
|
420
|
-
def head(self, path, data=None, secure=
|
420
|
+
def head(self, path, data=None, secure=True, *, headers=None, **extra):
|
421
421
|
"""Construct a HEAD request."""
|
422
422
|
data = {} if data is None else data
|
423
423
|
return self.generic(
|
@@ -431,7 +431,7 @@ class RequestFactory:
|
|
431
431
|
},
|
432
432
|
)
|
433
433
|
|
434
|
-
def trace(self, path, secure=
|
434
|
+
def trace(self, path, secure=True, *, headers=None, **extra):
|
435
435
|
"""Construct a TRACE request."""
|
436
436
|
return self.generic("TRACE", path, secure=secure, headers=headers, **extra)
|
437
437
|
|
@@ -440,7 +440,7 @@ class RequestFactory:
|
|
440
440
|
path,
|
441
441
|
data="",
|
442
442
|
content_type="application/octet-stream",
|
443
|
-
secure=
|
443
|
+
secure=True,
|
444
444
|
*,
|
445
445
|
headers=None,
|
446
446
|
**extra,
|
@@ -455,7 +455,7 @@ class RequestFactory:
|
|
455
455
|
path,
|
456
456
|
data="",
|
457
457
|
content_type="application/octet-stream",
|
458
|
-
secure=
|
458
|
+
secure=True,
|
459
459
|
*,
|
460
460
|
headers=None,
|
461
461
|
**extra,
|
@@ -471,7 +471,7 @@ class RequestFactory:
|
|
471
471
|
path,
|
472
472
|
data="",
|
473
473
|
content_type="application/octet-stream",
|
474
|
-
secure=
|
474
|
+
secure=True,
|
475
475
|
*,
|
476
476
|
headers=None,
|
477
477
|
**extra,
|
@@ -487,7 +487,7 @@ class RequestFactory:
|
|
487
487
|
path,
|
488
488
|
data="",
|
489
489
|
content_type="application/octet-stream",
|
490
|
-
secure=
|
490
|
+
secure=True,
|
491
491
|
*,
|
492
492
|
headers=None,
|
493
493
|
**extra,
|
@@ -504,7 +504,7 @@ class RequestFactory:
|
|
504
504
|
path,
|
505
505
|
data="",
|
506
506
|
content_type="application/octet-stream",
|
507
|
-
secure=
|
507
|
+
secure=True,
|
508
508
|
*,
|
509
509
|
headers=None,
|
510
510
|
**extra,
|
@@ -704,7 +704,7 @@ class Client(ClientMixin, RequestFactory):
|
|
704
704
|
path,
|
705
705
|
data=None,
|
706
706
|
follow=False,
|
707
|
-
secure=
|
707
|
+
secure=True,
|
708
708
|
*,
|
709
709
|
headers=None,
|
710
710
|
**extra,
|
@@ -725,7 +725,7 @@ class Client(ClientMixin, RequestFactory):
|
|
725
725
|
data=None,
|
726
726
|
content_type=MULTIPART_CONTENT,
|
727
727
|
follow=False,
|
728
|
-
secure=
|
728
|
+
secure=True,
|
729
729
|
*,
|
730
730
|
headers=None,
|
731
731
|
**extra,
|
@@ -752,7 +752,7 @@ class Client(ClientMixin, RequestFactory):
|
|
752
752
|
path,
|
753
753
|
data=None,
|
754
754
|
follow=False,
|
755
|
-
secure=
|
755
|
+
secure=True,
|
756
756
|
*,
|
757
757
|
headers=None,
|
758
758
|
**extra,
|
@@ -775,7 +775,7 @@ class Client(ClientMixin, RequestFactory):
|
|
775
775
|
data="",
|
776
776
|
content_type="application/octet-stream",
|
777
777
|
follow=False,
|
778
|
-
secure=
|
778
|
+
secure=True,
|
779
779
|
*,
|
780
780
|
headers=None,
|
781
781
|
**extra,
|
@@ -803,7 +803,7 @@ class Client(ClientMixin, RequestFactory):
|
|
803
803
|
data="",
|
804
804
|
content_type="application/octet-stream",
|
805
805
|
follow=False,
|
806
|
-
secure=
|
806
|
+
secure=True,
|
807
807
|
*,
|
808
808
|
headers=None,
|
809
809
|
**extra,
|
@@ -831,7 +831,7 @@ class Client(ClientMixin, RequestFactory):
|
|
831
831
|
data="",
|
832
832
|
content_type="application/octet-stream",
|
833
833
|
follow=False,
|
834
|
-
secure=
|
834
|
+
secure=True,
|
835
835
|
*,
|
836
836
|
headers=None,
|
837
837
|
**extra,
|
@@ -859,7 +859,7 @@ class Client(ClientMixin, RequestFactory):
|
|
859
859
|
data="",
|
860
860
|
content_type="application/octet-stream",
|
861
861
|
follow=False,
|
862
|
-
secure=
|
862
|
+
secure=True,
|
863
863
|
*,
|
864
864
|
headers=None,
|
865
865
|
**extra,
|
@@ -886,7 +886,7 @@ class Client(ClientMixin, RequestFactory):
|
|
886
886
|
path,
|
887
887
|
data="",
|
888
888
|
follow=False,
|
889
|
-
secure=
|
889
|
+
secure=True,
|
890
890
|
*,
|
891
891
|
headers=None,
|
892
892
|
**extra,
|
@@ -15,6 +15,10 @@ logger = logging.getLogger("plain.request")
|
|
15
15
|
|
16
16
|
|
17
17
|
class View:
|
18
|
+
request: HttpRequest
|
19
|
+
url_args: tuple
|
20
|
+
url_kwargs: dict
|
21
|
+
|
18
22
|
def __init__(self, *args, **kwargs) -> None:
|
19
23
|
# Views can customize their init, which receives
|
20
24
|
# the args and kwargs from as_view()
|
@@ -1,64 +0,0 @@
|
|
1
|
-
from plain.utils.cache import patch_vary_headers
|
2
|
-
from plain.utils.regex_helper import _lazy_re_compile
|
3
|
-
from plain.utils.text import compress_sequence, compress_string
|
4
|
-
|
5
|
-
re_accepts_gzip = _lazy_re_compile(r"\bgzip\b")
|
6
|
-
|
7
|
-
|
8
|
-
class GZipMiddleware:
|
9
|
-
"""
|
10
|
-
Compress content if the browser allows gzip compression.
|
11
|
-
Set the Vary header accordingly, so that caches will base their storage
|
12
|
-
on the Accept-Encoding header.
|
13
|
-
"""
|
14
|
-
|
15
|
-
max_random_bytes = 100
|
16
|
-
|
17
|
-
def __init__(self, get_response):
|
18
|
-
self.get_response = get_response
|
19
|
-
|
20
|
-
def __call__(self, request):
|
21
|
-
response = self.get_response(request)
|
22
|
-
|
23
|
-
# It's not worth attempting to compress really short responses.
|
24
|
-
if not response.streaming and len(response.content) < 200:
|
25
|
-
return response
|
26
|
-
|
27
|
-
# Avoid gzipping if we've already got a content-encoding.
|
28
|
-
if response.has_header("Content-Encoding"):
|
29
|
-
return response
|
30
|
-
|
31
|
-
patch_vary_headers(response, ("Accept-Encoding",))
|
32
|
-
|
33
|
-
ae = request.META.get("HTTP_ACCEPT_ENCODING", "")
|
34
|
-
if not re_accepts_gzip.search(ae):
|
35
|
-
return response
|
36
|
-
|
37
|
-
if response.streaming:
|
38
|
-
response.streaming_content = compress_sequence(
|
39
|
-
response.streaming_content,
|
40
|
-
max_random_bytes=self.max_random_bytes,
|
41
|
-
)
|
42
|
-
# Delete the `Content-Length` header for streaming content, because
|
43
|
-
# we won't know the compressed size until we stream it.
|
44
|
-
del response.headers["Content-Length"]
|
45
|
-
else:
|
46
|
-
# Return the compressed content only if it's actually shorter.
|
47
|
-
compressed_content = compress_string(
|
48
|
-
response.content,
|
49
|
-
max_random_bytes=self.max_random_bytes,
|
50
|
-
)
|
51
|
-
if len(compressed_content) >= len(response.content):
|
52
|
-
return response
|
53
|
-
response.content = compressed_content
|
54
|
-
response.headers["Content-Length"] = str(len(response.content))
|
55
|
-
|
56
|
-
# If there is a strong ETag, make it weak to fulfill the requirements
|
57
|
-
# of RFC 9110 Section 8.8.1 while also allowing conditional request
|
58
|
-
# matches on ETags.
|
59
|
-
etag = response.get("ETag")
|
60
|
-
if etag and etag.startswith('"'):
|
61
|
-
response.headers["ETag"] = "W/" + etag
|
62
|
-
response.headers["Content-Encoding"] = "gzip"
|
63
|
-
|
64
|
-
return response
|
@@ -1,31 +0,0 @@
|
|
1
|
-
import re
|
2
|
-
|
3
|
-
from plain.http import ResponsePermanentRedirect
|
4
|
-
from plain.runtime import settings
|
5
|
-
|
6
|
-
|
7
|
-
class SecurityMiddleware:
|
8
|
-
def __init__(self, get_response):
|
9
|
-
self.get_response = get_response
|
10
|
-
self.redirect = settings.SECURE_SSL_REDIRECT
|
11
|
-
self.redirect_host = settings.SECURE_SSL_HOST
|
12
|
-
self.redirect_exempt = [re.compile(r) for r in settings.SECURE_REDIRECT_EXEMPT]
|
13
|
-
|
14
|
-
self.default_headers = settings.SECURE_DEFAULT_HEADERS
|
15
|
-
|
16
|
-
def __call__(self, request):
|
17
|
-
path = request.path.lstrip("/")
|
18
|
-
if (
|
19
|
-
self.redirect
|
20
|
-
and not request.is_secure()
|
21
|
-
and not any(pattern.search(path) for pattern in self.redirect_exempt)
|
22
|
-
):
|
23
|
-
host = self.redirect_host or request.get_host()
|
24
|
-
return ResponsePermanentRedirect(f"https://{host}{request.get_full_path()}")
|
25
|
-
|
26
|
-
response = self.get_response(request)
|
27
|
-
|
28
|
-
for header, value in self.default_headers.items():
|
29
|
-
response.headers.setdefault(header, value)
|
30
|
-
|
31
|
-
return response
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|