plain 0.4.1__tar.gz → 0.6.0__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.4.1 → plain-0.6.0}/PKG-INFO +1 -1
- {plain-0.4.1 → plain-0.6.0}/plain/cli/cli.py +31 -2
- {plain-0.4.1 → plain-0.6.0}/plain/forms/fields.py +2 -3
- {plain-0.4.1 → plain-0.6.0}/plain/forms/forms.py +2 -1
- plain-0.6.0/plain/middleware/security.py +31 -0
- plain-0.6.0/plain/preflight/security/base.py +114 -0
- {plain-0.4.1 → plain-0.6.0}/plain/runtime/README.md +0 -1
- {plain-0.4.1 → plain-0.6.0}/plain/runtime/__init__.py +2 -2
- {plain-0.4.1 → plain-0.6.0}/plain/runtime/global_settings.py +16 -29
- plain-0.6.0/plain/runtime/user_settings.py +313 -0
- {plain-0.4.1 → plain-0.6.0}/plain/signing.py +5 -23
- {plain-0.4.1 → plain-0.6.0}/plain/utils/timezone.py +2 -23
- {plain-0.4.1 → plain-0.6.0}/pyproject.toml +1 -1
- plain-0.4.1/plain/middleware/clickjacking.py +0 -52
- plain-0.4.1/plain/middleware/security.py +0 -64
- plain-0.4.1/plain/preflight/security/base.py +0 -268
- plain-0.4.1/plain/runtime/user_settings.py +0 -304
- {plain-0.4.1 → plain-0.6.0}/LICENSE +0 -0
- {plain-0.4.1 → plain-0.6.0}/README.md +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/README.md +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/__main__.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/assets/README.md +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/assets/__init__.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/assets/compile.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/assets/finders.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/assets/fingerprints.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/assets/urls.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/assets/views.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/cli/README.md +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/cli/__init__.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/cli/formatting.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/cli/packages.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/cli/print.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/cli/startup.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/csrf/README.md +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/csrf/middleware.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/csrf/views.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/debug.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/exceptions.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/forms/README.md +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/forms/__init__.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/forms/boundfield.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/forms/exceptions.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/http/README.md +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/http/__init__.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/http/cookie.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/http/multipartparser.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/http/request.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/http/response.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/internal/__init__.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/internal/files/README.md +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/internal/files/__init__.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/internal/files/base.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/internal/files/locks.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/internal/files/move.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/internal/files/temp.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/internal/files/uploadedfile.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/internal/files/uploadhandler.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/internal/files/utils.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/internal/handlers/__init__.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/internal/handlers/base.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/internal/handlers/exception.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/internal/handlers/wsgi.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/json.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/logs/README.md +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/logs/__init__.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/logs/configure.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/logs/loggers.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/logs/utils.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/middleware/README.md +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/middleware/__init__.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/middleware/common.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/middleware/gzip.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/packages/README.md +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/packages/__init__.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/packages/config.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/packages/registry.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/paginator.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/preflight/README.md +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/preflight/__init__.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/preflight/files.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/preflight/messages.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/preflight/registry.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/preflight/security/__init__.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/preflight/security/csrf.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/preflight/urls.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/signals/README.md +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/signals/__init__.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/signals/dispatch/__init__.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/signals/dispatch/dispatcher.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/signals/dispatch/license.txt +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/templates/README.md +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/templates/__init__.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/templates/core.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/templates/jinja/README.md +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/templates/jinja/__init__.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/templates/jinja/defaults.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/templates/jinja/extensions.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/templates/jinja/filters.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/templates/jinja/globals.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/test/README.md +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/test/__init__.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/test/client.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/urls/README.md +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/urls/__init__.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/urls/base.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/urls/conf.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/urls/converters.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/urls/exceptions.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/urls/resolvers.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/utils/README.md +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/utils/__init__.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/utils/_os.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/utils/cache.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/utils/connection.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/utils/crypto.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/utils/datastructures.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/utils/dateformat.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/utils/dateparse.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/utils/dates.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/utils/deconstruct.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/utils/decorators.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/utils/deprecation.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/utils/duration.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/utils/email.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/utils/encoding.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/utils/functional.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/utils/hashable.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/utils/html.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/utils/http.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/utils/inspect.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/utils/ipv6.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/utils/itercompat.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/utils/module_loading.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/utils/regex_helper.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/utils/safestring.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/utils/termcolors.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/utils/text.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/utils/timesince.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/utils/tree.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/validators.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/views/README.md +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/views/__init__.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/views/base.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/views/csrf.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/views/errors.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/views/exceptions.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/views/forms.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/views/objects.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/views/redirect.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/views/templates.py +0 -0
- {plain-0.4.1 → plain-0.6.0}/plain/wsgi.py +0 -0
@@ -14,7 +14,9 @@ from click.core import Command, Context
|
|
14
14
|
import plain.runtime
|
15
15
|
from plain import preflight
|
16
16
|
from plain.assets.compile import compile_assets, get_compiled_path
|
17
|
+
from plain.exceptions import ImproperlyConfigured
|
17
18
|
from plain.packages import packages
|
19
|
+
from plain.utils.crypto import get_random_string
|
18
20
|
|
19
21
|
from .formatting import PlainContext
|
20
22
|
from .packages import EntryPointGroup, InstalledPackagesGroup
|
@@ -406,6 +408,18 @@ def setting(setting_name):
|
|
406
408
|
click.secho(f'Setting "{setting_name}" not found', fg="red")
|
407
409
|
|
408
410
|
|
411
|
+
@plain_cli.group()
|
412
|
+
def utils():
|
413
|
+
pass
|
414
|
+
|
415
|
+
|
416
|
+
@utils.command()
|
417
|
+
def generate_secret_key():
|
418
|
+
"""Generate a new secret key"""
|
419
|
+
new_secret_key = get_random_string(50)
|
420
|
+
click.echo(new_secret_key)
|
421
|
+
|
422
|
+
|
409
423
|
class AppCLIGroup(click.Group):
|
410
424
|
"""
|
411
425
|
Loads app.cli if it exists as `plain app`
|
@@ -459,16 +473,31 @@ class PlainCommandCollection(click.CommandCollection):
|
|
459
473
|
EntryPointGroup(),
|
460
474
|
plain_cli,
|
461
475
|
]
|
462
|
-
except
|
476
|
+
except ImproperlyConfigured as e:
|
463
477
|
click.secho(
|
464
|
-
|
478
|
+
str(e),
|
465
479
|
fg="red",
|
466
480
|
err=True,
|
467
481
|
)
|
482
|
+
|
483
|
+
# None of these require the app to be setup
|
484
|
+
sources = [
|
485
|
+
EntryPointGroup(),
|
486
|
+
AppCLIGroup(),
|
487
|
+
plain_cli,
|
488
|
+
]
|
489
|
+
except Exception as e:
|
468
490
|
print("---")
|
469
491
|
print(traceback.format_exc())
|
470
492
|
print("---")
|
471
493
|
|
494
|
+
click.secho(
|
495
|
+
f"Error: {e}",
|
496
|
+
fg="red",
|
497
|
+
err=True,
|
498
|
+
)
|
499
|
+
|
500
|
+
# None of these require the app to be setup
|
472
501
|
sources = [
|
473
502
|
EntryPointGroup(),
|
474
503
|
AppCLIGroup(),
|
@@ -14,7 +14,6 @@ from urllib.parse import urlsplit, urlunsplit
|
|
14
14
|
|
15
15
|
from plain import validators
|
16
16
|
from plain.exceptions import ValidationError
|
17
|
-
from plain.runtime import settings
|
18
17
|
from plain.utils import timezone
|
19
18
|
from plain.utils.dateparse import parse_datetime, parse_duration
|
20
19
|
from plain.utils.duration import duration_string
|
@@ -1001,7 +1000,7 @@ def from_current_timezone(value):
|
|
1001
1000
|
When time zone support is enabled, convert naive datetimes
|
1002
1001
|
entered in the current time zone to aware datetimes.
|
1003
1002
|
"""
|
1004
|
-
if
|
1003
|
+
if value is not None and timezone.is_naive(value):
|
1005
1004
|
current_timezone = timezone.get_current_timezone()
|
1006
1005
|
try:
|
1007
1006
|
if timezone._datetime_ambiguous_or_imaginary(value, current_timezone):
|
@@ -1025,6 +1024,6 @@ def to_current_timezone(value):
|
|
1025
1024
|
When time zone support is enabled, convert aware datetimes
|
1026
1025
|
to naive datetimes in the current time zone for display.
|
1027
1026
|
"""
|
1028
|
-
if
|
1027
|
+
if value is not None and timezone.is_aware(value):
|
1029
1028
|
return timezone.make_naive(value)
|
1030
1029
|
return value
|
@@ -203,7 +203,8 @@ class BaseForm:
|
|
203
203
|
self._errors[field].extend(error_list)
|
204
204
|
|
205
205
|
# The field had an error, so removed it from the final data
|
206
|
-
|
206
|
+
# (we use getattr here so errors can be added to uncleaned forms)
|
207
|
+
if field in getattr(self, "cleaned_data", {}):
|
207
208
|
del self.cleaned_data[field]
|
208
209
|
|
209
210
|
def full_clean(self):
|
@@ -0,0 +1,31 @@
|
|
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
|
@@ -0,0 +1,114 @@
|
|
1
|
+
from plain.exceptions import ImproperlyConfigured
|
2
|
+
from plain.runtime import settings
|
3
|
+
|
4
|
+
from .. import Warning, register
|
5
|
+
|
6
|
+
SECRET_KEY_INSECURE_PREFIX = "plain-insecure-"
|
7
|
+
SECRET_KEY_MIN_LENGTH = 50
|
8
|
+
SECRET_KEY_MIN_UNIQUE_CHARACTERS = 5
|
9
|
+
|
10
|
+
SECRET_KEY_WARNING_MSG = (
|
11
|
+
f"Your %s has less than {SECRET_KEY_MIN_LENGTH} characters, less than "
|
12
|
+
f"{SECRET_KEY_MIN_UNIQUE_CHARACTERS} unique characters, or it's prefixed "
|
13
|
+
f"with '{SECRET_KEY_INSECURE_PREFIX}' indicating that it was generated "
|
14
|
+
f"automatically by Plain. Please generate a long and random value, "
|
15
|
+
f"otherwise many of Plain's security-critical features will be "
|
16
|
+
f"vulnerable to attack."
|
17
|
+
)
|
18
|
+
|
19
|
+
W001 = Warning(
|
20
|
+
"You do not have 'plain.middleware.security.SecurityMiddleware' "
|
21
|
+
"in your MIDDLEWARE so the SECURE_HSTS_SECONDS, "
|
22
|
+
"SECURE_CONTENT_TYPE_NOSNIFF, SECURE_REFERRER_POLICY, "
|
23
|
+
"SECURE_CROSS_ORIGIN_OPENER_POLICY, and SECURE_SSL_REDIRECT settings will "
|
24
|
+
"have no effect.",
|
25
|
+
id="security.W001",
|
26
|
+
)
|
27
|
+
|
28
|
+
W008 = Warning(
|
29
|
+
"Your SECURE_SSL_REDIRECT setting is not set to True. "
|
30
|
+
"Unless your site should be available over both SSL and non-SSL "
|
31
|
+
"connections, you may want to either set this setting True "
|
32
|
+
"or configure a load balancer or reverse-proxy server "
|
33
|
+
"to redirect all connections to HTTPS.",
|
34
|
+
id="security.W008",
|
35
|
+
)
|
36
|
+
|
37
|
+
W009 = Warning(
|
38
|
+
SECRET_KEY_WARNING_MSG % "SECRET_KEY",
|
39
|
+
id="security.W009",
|
40
|
+
)
|
41
|
+
|
42
|
+
W018 = Warning(
|
43
|
+
"You should not have DEBUG set to True in deployment.",
|
44
|
+
id="security.W018",
|
45
|
+
)
|
46
|
+
|
47
|
+
W020 = Warning(
|
48
|
+
"ALLOWED_HOSTS must not be empty in deployment.",
|
49
|
+
id="security.W020",
|
50
|
+
)
|
51
|
+
|
52
|
+
W025 = Warning(SECRET_KEY_WARNING_MSG, id="security.W025")
|
53
|
+
|
54
|
+
|
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
|
+
def _check_secret_key(secret_key):
|
72
|
+
return (
|
73
|
+
len(set(secret_key)) >= SECRET_KEY_MIN_UNIQUE_CHARACTERS
|
74
|
+
and len(secret_key) >= SECRET_KEY_MIN_LENGTH
|
75
|
+
and not secret_key.startswith(SECRET_KEY_INSECURE_PREFIX)
|
76
|
+
)
|
77
|
+
|
78
|
+
|
79
|
+
@register(deploy=True)
|
80
|
+
def check_secret_key(package_configs, **kwargs):
|
81
|
+
try:
|
82
|
+
secret_key = settings.SECRET_KEY
|
83
|
+
except (ImproperlyConfigured, AttributeError):
|
84
|
+
passed_check = False
|
85
|
+
else:
|
86
|
+
passed_check = _check_secret_key(secret_key)
|
87
|
+
return [] if passed_check else [W009]
|
88
|
+
|
89
|
+
|
90
|
+
@register(deploy=True)
|
91
|
+
def check_secret_key_fallbacks(package_configs, **kwargs):
|
92
|
+
warnings = []
|
93
|
+
try:
|
94
|
+
fallbacks = settings.SECRET_KEY_FALLBACKS
|
95
|
+
except (ImproperlyConfigured, AttributeError):
|
96
|
+
warnings.append(Warning(W025.msg % "SECRET_KEY_FALLBACKS", id=W025.id))
|
97
|
+
else:
|
98
|
+
for index, key in enumerate(fallbacks):
|
99
|
+
if not _check_secret_key(key):
|
100
|
+
warnings.append(
|
101
|
+
Warning(W025.msg % f"SECRET_KEY_FALLBACKS[{index}]", id=W025.id)
|
102
|
+
)
|
103
|
+
return warnings
|
104
|
+
|
105
|
+
|
106
|
+
@register(deploy=True)
|
107
|
+
def check_debug(package_configs, **kwargs):
|
108
|
+
passed_check = not settings.DEBUG
|
109
|
+
return [] if passed_check else [W018]
|
110
|
+
|
111
|
+
|
112
|
+
@register(deploy=True)
|
113
|
+
def check_allowed_hosts(package_configs, **kwargs):
|
114
|
+
return [] if settings.ALLOWED_HOSTS else [W020]
|
@@ -5,7 +5,7 @@ from pathlib import Path
|
|
5
5
|
|
6
6
|
from dotenv import load_dotenv
|
7
7
|
|
8
|
-
from .user_settings import
|
8
|
+
from .user_settings import Settings
|
9
9
|
|
10
10
|
try:
|
11
11
|
__version__ = importlib.metadata.version("plain")
|
@@ -18,7 +18,7 @@ APP_PATH = Path.cwd() / "app"
|
|
18
18
|
|
19
19
|
|
20
20
|
# from plain.runtime import settings
|
21
|
-
settings =
|
21
|
+
settings = Settings()
|
22
22
|
|
23
23
|
|
24
24
|
class AppPathNotFound(RuntimeError):
|
@@ -20,12 +20,9 @@ ALLOWED_HOSTS: list[str] = []
|
|
20
20
|
|
21
21
|
# Local time zone for this installation. All choices can be found here:
|
22
22
|
# https://en.wikipedia.org/wiki/List_of_tz_zones_by_name (although not all
|
23
|
-
# systems may support all possibilities).
|
24
|
-
#
|
25
|
-
TIME_ZONE = "
|
26
|
-
|
27
|
-
# If you set this to True, Plain will use timezone-aware datetimes.
|
28
|
-
USE_TZ = True
|
23
|
+
# systems may support all possibilities). This is interpreted as the default
|
24
|
+
# user time zone.
|
25
|
+
TIME_ZONE: str = "UTC"
|
29
26
|
|
30
27
|
# Default charset to use for all Response objects, if a MIME type isn't
|
31
28
|
# manually specified. It's used to construct the Content-Type header.
|
@@ -75,19 +72,6 @@ DATA_UPLOAD_MAX_NUMBER_FILES = 100
|
|
75
72
|
# (i.e. "/tmp" on *nix systems).
|
76
73
|
FILE_UPLOAD_TEMP_DIR = None
|
77
74
|
|
78
|
-
# The numeric mode to set newly-uploaded files to. The value should be a mode
|
79
|
-
# you'd pass directly to os.chmod; see
|
80
|
-
# https://docs.python.org/library/os.html#files-and-directories.
|
81
|
-
FILE_UPLOAD_PERMISSIONS = 0o644
|
82
|
-
|
83
|
-
# The numeric mode to assign to newly-created directories, when uploading files.
|
84
|
-
# The value should be a mode as you'd pass to os.chmod;
|
85
|
-
# see https://docs.python.org/library/os.html#files-and-directories.
|
86
|
-
FILE_UPLOAD_DIRECTORY_PERMISSIONS = None
|
87
|
-
|
88
|
-
# Default X-Frame-Options header value
|
89
|
-
X_FRAME_OPTIONS = "DENY"
|
90
|
-
|
91
75
|
USE_X_FORWARDED_HOST = False
|
92
76
|
USE_X_FORWARDED_PORT = False
|
93
77
|
|
@@ -114,14 +98,13 @@ MIDDLEWARE = [
|
|
114
98
|
"plain.middleware.security.SecurityMiddleware",
|
115
99
|
"plain.middleware.common.CommonMiddleware",
|
116
100
|
"plain.csrf.middleware.CsrfViewMiddleware",
|
117
|
-
"plain.middleware.clickjacking.XFrameOptionsMiddleware",
|
118
101
|
]
|
119
102
|
|
120
103
|
###########
|
121
104
|
# SIGNING #
|
122
105
|
###########
|
123
106
|
|
124
|
-
|
107
|
+
COOKIE_SIGNING_BACKEND = "plain.signing.TimestampSigner"
|
125
108
|
|
126
109
|
########
|
127
110
|
# CSRF #
|
@@ -132,7 +115,7 @@ CSRF_COOKIE_NAME = "csrftoken"
|
|
132
115
|
CSRF_COOKIE_AGE = 60 * 60 * 24 * 7 * 52
|
133
116
|
CSRF_COOKIE_DOMAIN = None
|
134
117
|
CSRF_COOKIE_PATH = "/"
|
135
|
-
CSRF_COOKIE_SECURE =
|
118
|
+
CSRF_COOKIE_SECURE = True
|
136
119
|
CSRF_COOKIE_HTTPONLY = False
|
137
120
|
CSRF_COOKIE_SAMESITE = "Lax"
|
138
121
|
CSRF_HEADER_NAME = "HTTP_X_CSRFTOKEN"
|
@@ -170,15 +153,19 @@ SILENCED_PREFLIGHT_CHECKS = []
|
|
170
153
|
#######################
|
171
154
|
# SECURITY MIDDLEWARE #
|
172
155
|
#######################
|
173
|
-
SECURE_CONTENT_TYPE_NOSNIFF = True
|
174
|
-
SECURE_CROSS_ORIGIN_OPENER_POLICY = "same-origin"
|
175
|
-
SECURE_HSTS_INCLUDE_SUBDOMAINS = False
|
176
|
-
SECURE_HSTS_PRELOAD = False
|
177
|
-
SECURE_HSTS_SECONDS = 0
|
178
156
|
SECURE_REDIRECT_EXEMPT = []
|
179
|
-
SECURE_REFERRER_POLICY = "same-origin"
|
180
157
|
SECURE_SSL_HOST = None
|
181
|
-
SECURE_SSL_REDIRECT =
|
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
|
+
}
|
182
169
|
|
183
170
|
#############
|
184
171
|
# Templates #
|
@@ -0,0 +1,313 @@
|
|
1
|
+
import importlib
|
2
|
+
import json
|
3
|
+
import os
|
4
|
+
import time
|
5
|
+
import types
|
6
|
+
import typing
|
7
|
+
from pathlib import Path
|
8
|
+
|
9
|
+
from plain.exceptions import ImproperlyConfigured
|
10
|
+
from plain.packages import PackageConfig
|
11
|
+
|
12
|
+
ENVIRONMENT_VARIABLE = "PLAIN_SETTINGS_MODULE"
|
13
|
+
ENV_SETTINGS_PREFIX = "PLAIN_"
|
14
|
+
CUSTOM_SETTINGS_PREFIX = "APP_"
|
15
|
+
|
16
|
+
|
17
|
+
class Settings:
|
18
|
+
"""
|
19
|
+
Settings and configuration for Plain.
|
20
|
+
|
21
|
+
This class handles loading settings from the module specified by the
|
22
|
+
PLAIN_SETTINGS_MODULE environment variable, as well as from default settings,
|
23
|
+
environment variables, and explicit settings in the settings module.
|
24
|
+
|
25
|
+
Lazy initialization is implemented to defer loading until settings are first accessed.
|
26
|
+
"""
|
27
|
+
|
28
|
+
def __init__(self, settings_module=None):
|
29
|
+
self._settings_module = settings_module
|
30
|
+
self._settings = {}
|
31
|
+
self._errors = [] # Collect configuration errors
|
32
|
+
self.configured = False
|
33
|
+
|
34
|
+
def _setup(self):
|
35
|
+
if self.configured:
|
36
|
+
return
|
37
|
+
else:
|
38
|
+
self.configured = True
|
39
|
+
|
40
|
+
self._settings = {} # Maps setting names to SettingDefinition instances
|
41
|
+
|
42
|
+
# Determine the settings module
|
43
|
+
if self._settings_module is None:
|
44
|
+
self._settings_module = os.environ.get(ENVIRONMENT_VARIABLE, "settings")
|
45
|
+
|
46
|
+
# First load the global settings from plain
|
47
|
+
self._load_module_settings(
|
48
|
+
importlib.import_module("plain.runtime.global_settings")
|
49
|
+
)
|
50
|
+
|
51
|
+
# Import the user's settings module
|
52
|
+
try:
|
53
|
+
mod = importlib.import_module(self._settings_module)
|
54
|
+
except ImportError as e:
|
55
|
+
raise ImproperlyConfigured(
|
56
|
+
f"Could not import settings '{self._settings_module}': {e}"
|
57
|
+
)
|
58
|
+
|
59
|
+
# Keep a reference to the settings.py module path
|
60
|
+
self.path = Path(mod.__file__).resolve()
|
61
|
+
|
62
|
+
# Load default settings from installed packages
|
63
|
+
self._load_default_settings(mod)
|
64
|
+
# Load environment settings
|
65
|
+
self._load_env_settings()
|
66
|
+
# Load explicit settings from the settings module
|
67
|
+
self._load_explicit_settings(mod)
|
68
|
+
# Check for any required settings that are missing
|
69
|
+
self._check_required_settings()
|
70
|
+
# Check for any collected errors
|
71
|
+
self._raise_errors_if_any()
|
72
|
+
|
73
|
+
def _load_module_settings(self, module):
|
74
|
+
annotations = getattr(module, "__annotations__", {})
|
75
|
+
settings = dir(module)
|
76
|
+
|
77
|
+
for setting in settings:
|
78
|
+
if setting.isupper():
|
79
|
+
if setting in self._settings:
|
80
|
+
self._errors.append(f"Duplicate setting '{setting}'.")
|
81
|
+
continue
|
82
|
+
|
83
|
+
setting_value = getattr(module, setting)
|
84
|
+
self._settings[setting] = SettingDefinition(
|
85
|
+
name=setting,
|
86
|
+
default_value=setting_value,
|
87
|
+
annotation=annotations.get(setting, None),
|
88
|
+
module=module,
|
89
|
+
)
|
90
|
+
|
91
|
+
# Store any annotations that didn't have a value (these are required settings)
|
92
|
+
for setting, annotation in annotations.items():
|
93
|
+
if setting not in self._settings:
|
94
|
+
self._settings[setting] = SettingDefinition(
|
95
|
+
name=setting,
|
96
|
+
default_value=None,
|
97
|
+
annotation=annotation,
|
98
|
+
module=module,
|
99
|
+
required=True,
|
100
|
+
)
|
101
|
+
|
102
|
+
def _load_default_settings(self, settings_module):
|
103
|
+
for entry in getattr(settings_module, "INSTALLED_PACKAGES", []):
|
104
|
+
try:
|
105
|
+
if isinstance(entry, PackageConfig):
|
106
|
+
app_settings = entry.module.default_settings
|
107
|
+
else:
|
108
|
+
app_settings = importlib.import_module(f"{entry}.default_settings")
|
109
|
+
except ModuleNotFoundError:
|
110
|
+
continue
|
111
|
+
|
112
|
+
self._load_module_settings(app_settings)
|
113
|
+
|
114
|
+
def _load_env_settings(self):
|
115
|
+
env_settings = {
|
116
|
+
k[len(ENV_SETTINGS_PREFIX) :]: v
|
117
|
+
for k, v in os.environ.items()
|
118
|
+
if k.startswith(ENV_SETTINGS_PREFIX) and k.isupper()
|
119
|
+
}
|
120
|
+
for setting, value in env_settings.items():
|
121
|
+
if setting in self._settings:
|
122
|
+
setting_def = self._settings[setting]
|
123
|
+
try:
|
124
|
+
parsed_value = _parse_env_value(value, setting_def.annotation)
|
125
|
+
setting_def.set_value(parsed_value, "env")
|
126
|
+
except ImproperlyConfigured as e:
|
127
|
+
self._errors.append(str(e))
|
128
|
+
|
129
|
+
def _load_explicit_settings(self, settings_module):
|
130
|
+
for setting in dir(settings_module):
|
131
|
+
if setting.isupper():
|
132
|
+
setting_value = getattr(settings_module, setting)
|
133
|
+
|
134
|
+
if setting in self._settings:
|
135
|
+
setting_def = self._settings[setting]
|
136
|
+
try:
|
137
|
+
setting_def.set_value(setting_value, "explicit")
|
138
|
+
except ImproperlyConfigured as e:
|
139
|
+
self._errors.append(str(e))
|
140
|
+
continue
|
141
|
+
|
142
|
+
elif setting.startswith(CUSTOM_SETTINGS_PREFIX):
|
143
|
+
# Accept custom settings prefixed with '{CUSTOM_SETTINGS_PREFIX}'
|
144
|
+
setting_def = SettingDefinition(
|
145
|
+
name=setting,
|
146
|
+
default_value=None,
|
147
|
+
annotation=None,
|
148
|
+
required=False,
|
149
|
+
)
|
150
|
+
try:
|
151
|
+
setting_def.set_value(setting_value, "explicit")
|
152
|
+
except ImproperlyConfigured as e:
|
153
|
+
self._errors.append(str(e))
|
154
|
+
continue
|
155
|
+
self._settings[setting] = setting_def
|
156
|
+
else:
|
157
|
+
# Collect unrecognized settings individually
|
158
|
+
self._errors.append(
|
159
|
+
f"Unknown setting '{setting}'. Custom settings must start with '{CUSTOM_SETTINGS_PREFIX}'."
|
160
|
+
)
|
161
|
+
|
162
|
+
if hasattr(time, "tzset") and self.TIME_ZONE:
|
163
|
+
zoneinfo_root = Path("/usr/share/zoneinfo")
|
164
|
+
zone_info_file = zoneinfo_root.joinpath(*self.TIME_ZONE.split("/"))
|
165
|
+
if zoneinfo_root.exists() and not zone_info_file.exists():
|
166
|
+
self._errors.append(
|
167
|
+
f"Invalid TIME_ZONE setting '{self.TIME_ZONE}'. Timezone file not found."
|
168
|
+
)
|
169
|
+
else:
|
170
|
+
os.environ["TZ"] = self.TIME_ZONE
|
171
|
+
time.tzset()
|
172
|
+
|
173
|
+
def _check_required_settings(self):
|
174
|
+
missing = [k for k, v in self._settings.items() if v.required and not v.is_set]
|
175
|
+
if missing:
|
176
|
+
self._errors.append(f"Missing required setting(s): {', '.join(missing)}.")
|
177
|
+
|
178
|
+
def _raise_errors_if_any(self):
|
179
|
+
if self._errors:
|
180
|
+
errors = ["- " + e for e in self._errors]
|
181
|
+
raise ImproperlyConfigured(
|
182
|
+
"Settings configuration errors:\n" + "\n".join(errors)
|
183
|
+
)
|
184
|
+
|
185
|
+
def __getattr__(self, name):
|
186
|
+
# Avoid recursion by directly returning internal attributes
|
187
|
+
if not name.isupper():
|
188
|
+
return object.__getattribute__(self, name)
|
189
|
+
|
190
|
+
self._setup()
|
191
|
+
|
192
|
+
if name in self._settings:
|
193
|
+
return self._settings[name].value
|
194
|
+
else:
|
195
|
+
raise AttributeError(f"'Settings' object has no attribute '{name}'")
|
196
|
+
|
197
|
+
def __setattr__(self, name, value):
|
198
|
+
# Handle internal attributes without recursion
|
199
|
+
if not name.isupper():
|
200
|
+
object.__setattr__(self, name, value)
|
201
|
+
else:
|
202
|
+
if name in self._settings:
|
203
|
+
self._settings[name].set_value(value, "runtime")
|
204
|
+
self._raise_errors_if_any()
|
205
|
+
else:
|
206
|
+
object.__setattr__(self, name, value)
|
207
|
+
|
208
|
+
def __repr__(self):
|
209
|
+
if not self.configured:
|
210
|
+
return "<Settings [Unevaluated]>"
|
211
|
+
return f'<Settings "{self._settings_module}">'
|
212
|
+
|
213
|
+
|
214
|
+
def _parse_env_value(value, annotation):
|
215
|
+
if not annotation:
|
216
|
+
raise ImproperlyConfigured("Type hint required to set from environment.")
|
217
|
+
|
218
|
+
if annotation is bool:
|
219
|
+
# Special case for bools
|
220
|
+
return value.lower() in ("true", "1", "yes")
|
221
|
+
elif annotation is str:
|
222
|
+
return value
|
223
|
+
else:
|
224
|
+
# Parse other types using JSON
|
225
|
+
try:
|
226
|
+
return json.loads(value)
|
227
|
+
except json.JSONDecodeError as e:
|
228
|
+
raise ImproperlyConfigured(
|
229
|
+
f"Invalid JSON value for setting: {e.msg}"
|
230
|
+
) from e
|
231
|
+
|
232
|
+
|
233
|
+
class SettingDefinition:
|
234
|
+
"""Store detailed information about settings."""
|
235
|
+
|
236
|
+
def __init__(
|
237
|
+
self, name, default_value=None, annotation=None, module=None, required=False
|
238
|
+
):
|
239
|
+
self.name = name
|
240
|
+
self.default_value = default_value
|
241
|
+
self.annotation = annotation
|
242
|
+
self.module = module
|
243
|
+
self.required = required
|
244
|
+
self.value = default_value
|
245
|
+
self.source = "default" # 'default', 'env', 'explicit', or 'runtime'
|
246
|
+
self.is_set = False # Indicates if the value was set explicitly
|
247
|
+
|
248
|
+
def set_value(self, value, source):
|
249
|
+
self.check_type(value)
|
250
|
+
self.value = value
|
251
|
+
self.source = source
|
252
|
+
self.is_set = True
|
253
|
+
|
254
|
+
def check_type(self, obj):
|
255
|
+
if not self.annotation:
|
256
|
+
return
|
257
|
+
|
258
|
+
if not SettingDefinition._is_instance_of_type(obj, self.annotation):
|
259
|
+
raise ImproperlyConfigured(
|
260
|
+
f"'{self.name}': Expected type {self.annotation}, but got {type(obj)}."
|
261
|
+
)
|
262
|
+
|
263
|
+
@staticmethod
|
264
|
+
def _is_instance_of_type(value, type_hint) -> bool:
|
265
|
+
# Simple types
|
266
|
+
if isinstance(type_hint, type):
|
267
|
+
return isinstance(value, type_hint)
|
268
|
+
|
269
|
+
# Union types
|
270
|
+
if (
|
271
|
+
typing.get_origin(type_hint) is typing.Union
|
272
|
+
or typing.get_origin(type_hint) is types.UnionType
|
273
|
+
):
|
274
|
+
return any(
|
275
|
+
SettingDefinition._is_instance_of_type(value, arg)
|
276
|
+
for arg in typing.get_args(type_hint)
|
277
|
+
)
|
278
|
+
|
279
|
+
# List types
|
280
|
+
if typing.get_origin(type_hint) is list:
|
281
|
+
return isinstance(value, list) and all(
|
282
|
+
SettingDefinition._is_instance_of_type(
|
283
|
+
item, typing.get_args(type_hint)[0]
|
284
|
+
)
|
285
|
+
for item in value
|
286
|
+
)
|
287
|
+
|
288
|
+
# Tuple types
|
289
|
+
if typing.get_origin(type_hint) is tuple:
|
290
|
+
return isinstance(value, tuple) and all(
|
291
|
+
SettingDefinition._is_instance_of_type(
|
292
|
+
item, typing.get_args(type_hint)[i]
|
293
|
+
)
|
294
|
+
for i, item in enumerate(value)
|
295
|
+
)
|
296
|
+
|
297
|
+
raise ValueError(f"Unsupported type hint: {type_hint}")
|
298
|
+
|
299
|
+
def __str__(self):
|
300
|
+
return f"SettingDefinition(name={self.name}, value={self.value}, source={self.source})"
|
301
|
+
|
302
|
+
|
303
|
+
class SettingsReference(str):
|
304
|
+
"""
|
305
|
+
String subclass which references a current settings value. It's treated as
|
306
|
+
the value in memory but serializes to a settings.NAME attribute reference.
|
307
|
+
"""
|
308
|
+
|
309
|
+
def __new__(self, value, setting_name):
|
310
|
+
return str.__new__(self, value)
|
311
|
+
|
312
|
+
def __init__(self, value, setting_name):
|
313
|
+
self.setting_name = setting_name
|