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.
Files changed (152) hide show
  1. {plain-0.4.1 → plain-0.6.0}/PKG-INFO +1 -1
  2. {plain-0.4.1 → plain-0.6.0}/plain/cli/cli.py +31 -2
  3. {plain-0.4.1 → plain-0.6.0}/plain/forms/fields.py +2 -3
  4. {plain-0.4.1 → plain-0.6.0}/plain/forms/forms.py +2 -1
  5. plain-0.6.0/plain/middleware/security.py +31 -0
  6. plain-0.6.0/plain/preflight/security/base.py +114 -0
  7. {plain-0.4.1 → plain-0.6.0}/plain/runtime/README.md +0 -1
  8. {plain-0.4.1 → plain-0.6.0}/plain/runtime/__init__.py +2 -2
  9. {plain-0.4.1 → plain-0.6.0}/plain/runtime/global_settings.py +16 -29
  10. plain-0.6.0/plain/runtime/user_settings.py +313 -0
  11. {plain-0.4.1 → plain-0.6.0}/plain/signing.py +5 -23
  12. {plain-0.4.1 → plain-0.6.0}/plain/utils/timezone.py +2 -23
  13. {plain-0.4.1 → plain-0.6.0}/pyproject.toml +1 -1
  14. plain-0.4.1/plain/middleware/clickjacking.py +0 -52
  15. plain-0.4.1/plain/middleware/security.py +0 -64
  16. plain-0.4.1/plain/preflight/security/base.py +0 -268
  17. plain-0.4.1/plain/runtime/user_settings.py +0 -304
  18. {plain-0.4.1 → plain-0.6.0}/LICENSE +0 -0
  19. {plain-0.4.1 → plain-0.6.0}/README.md +0 -0
  20. {plain-0.4.1 → plain-0.6.0}/plain/README.md +0 -0
  21. {plain-0.4.1 → plain-0.6.0}/plain/__main__.py +0 -0
  22. {plain-0.4.1 → plain-0.6.0}/plain/assets/README.md +0 -0
  23. {plain-0.4.1 → plain-0.6.0}/plain/assets/__init__.py +0 -0
  24. {plain-0.4.1 → plain-0.6.0}/plain/assets/compile.py +0 -0
  25. {plain-0.4.1 → plain-0.6.0}/plain/assets/finders.py +0 -0
  26. {plain-0.4.1 → plain-0.6.0}/plain/assets/fingerprints.py +0 -0
  27. {plain-0.4.1 → plain-0.6.0}/plain/assets/urls.py +0 -0
  28. {plain-0.4.1 → plain-0.6.0}/plain/assets/views.py +0 -0
  29. {plain-0.4.1 → plain-0.6.0}/plain/cli/README.md +0 -0
  30. {plain-0.4.1 → plain-0.6.0}/plain/cli/__init__.py +0 -0
  31. {plain-0.4.1 → plain-0.6.0}/plain/cli/formatting.py +0 -0
  32. {plain-0.4.1 → plain-0.6.0}/plain/cli/packages.py +0 -0
  33. {plain-0.4.1 → plain-0.6.0}/plain/cli/print.py +0 -0
  34. {plain-0.4.1 → plain-0.6.0}/plain/cli/startup.py +0 -0
  35. {plain-0.4.1 → plain-0.6.0}/plain/csrf/README.md +0 -0
  36. {plain-0.4.1 → plain-0.6.0}/plain/csrf/middleware.py +0 -0
  37. {plain-0.4.1 → plain-0.6.0}/plain/csrf/views.py +0 -0
  38. {plain-0.4.1 → plain-0.6.0}/plain/debug.py +0 -0
  39. {plain-0.4.1 → plain-0.6.0}/plain/exceptions.py +0 -0
  40. {plain-0.4.1 → plain-0.6.0}/plain/forms/README.md +0 -0
  41. {plain-0.4.1 → plain-0.6.0}/plain/forms/__init__.py +0 -0
  42. {plain-0.4.1 → plain-0.6.0}/plain/forms/boundfield.py +0 -0
  43. {plain-0.4.1 → plain-0.6.0}/plain/forms/exceptions.py +0 -0
  44. {plain-0.4.1 → plain-0.6.0}/plain/http/README.md +0 -0
  45. {plain-0.4.1 → plain-0.6.0}/plain/http/__init__.py +0 -0
  46. {plain-0.4.1 → plain-0.6.0}/plain/http/cookie.py +0 -0
  47. {plain-0.4.1 → plain-0.6.0}/plain/http/multipartparser.py +0 -0
  48. {plain-0.4.1 → plain-0.6.0}/plain/http/request.py +0 -0
  49. {plain-0.4.1 → plain-0.6.0}/plain/http/response.py +0 -0
  50. {plain-0.4.1 → plain-0.6.0}/plain/internal/__init__.py +0 -0
  51. {plain-0.4.1 → plain-0.6.0}/plain/internal/files/README.md +0 -0
  52. {plain-0.4.1 → plain-0.6.0}/plain/internal/files/__init__.py +0 -0
  53. {plain-0.4.1 → plain-0.6.0}/plain/internal/files/base.py +0 -0
  54. {plain-0.4.1 → plain-0.6.0}/plain/internal/files/locks.py +0 -0
  55. {plain-0.4.1 → plain-0.6.0}/plain/internal/files/move.py +0 -0
  56. {plain-0.4.1 → plain-0.6.0}/plain/internal/files/temp.py +0 -0
  57. {plain-0.4.1 → plain-0.6.0}/plain/internal/files/uploadedfile.py +0 -0
  58. {plain-0.4.1 → plain-0.6.0}/plain/internal/files/uploadhandler.py +0 -0
  59. {plain-0.4.1 → plain-0.6.0}/plain/internal/files/utils.py +0 -0
  60. {plain-0.4.1 → plain-0.6.0}/plain/internal/handlers/__init__.py +0 -0
  61. {plain-0.4.1 → plain-0.6.0}/plain/internal/handlers/base.py +0 -0
  62. {plain-0.4.1 → plain-0.6.0}/plain/internal/handlers/exception.py +0 -0
  63. {plain-0.4.1 → plain-0.6.0}/plain/internal/handlers/wsgi.py +0 -0
  64. {plain-0.4.1 → plain-0.6.0}/plain/json.py +0 -0
  65. {plain-0.4.1 → plain-0.6.0}/plain/logs/README.md +0 -0
  66. {plain-0.4.1 → plain-0.6.0}/plain/logs/__init__.py +0 -0
  67. {plain-0.4.1 → plain-0.6.0}/plain/logs/configure.py +0 -0
  68. {plain-0.4.1 → plain-0.6.0}/plain/logs/loggers.py +0 -0
  69. {plain-0.4.1 → plain-0.6.0}/plain/logs/utils.py +0 -0
  70. {plain-0.4.1 → plain-0.6.0}/plain/middleware/README.md +0 -0
  71. {plain-0.4.1 → plain-0.6.0}/plain/middleware/__init__.py +0 -0
  72. {plain-0.4.1 → plain-0.6.0}/plain/middleware/common.py +0 -0
  73. {plain-0.4.1 → plain-0.6.0}/plain/middleware/gzip.py +0 -0
  74. {plain-0.4.1 → plain-0.6.0}/plain/packages/README.md +0 -0
  75. {plain-0.4.1 → plain-0.6.0}/plain/packages/__init__.py +0 -0
  76. {plain-0.4.1 → plain-0.6.0}/plain/packages/config.py +0 -0
  77. {plain-0.4.1 → plain-0.6.0}/plain/packages/registry.py +0 -0
  78. {plain-0.4.1 → plain-0.6.0}/plain/paginator.py +0 -0
  79. {plain-0.4.1 → plain-0.6.0}/plain/preflight/README.md +0 -0
  80. {plain-0.4.1 → plain-0.6.0}/plain/preflight/__init__.py +0 -0
  81. {plain-0.4.1 → plain-0.6.0}/plain/preflight/files.py +0 -0
  82. {plain-0.4.1 → plain-0.6.0}/plain/preflight/messages.py +0 -0
  83. {plain-0.4.1 → plain-0.6.0}/plain/preflight/registry.py +0 -0
  84. {plain-0.4.1 → plain-0.6.0}/plain/preflight/security/__init__.py +0 -0
  85. {plain-0.4.1 → plain-0.6.0}/plain/preflight/security/csrf.py +0 -0
  86. {plain-0.4.1 → plain-0.6.0}/plain/preflight/urls.py +0 -0
  87. {plain-0.4.1 → plain-0.6.0}/plain/signals/README.md +0 -0
  88. {plain-0.4.1 → plain-0.6.0}/plain/signals/__init__.py +0 -0
  89. {plain-0.4.1 → plain-0.6.0}/plain/signals/dispatch/__init__.py +0 -0
  90. {plain-0.4.1 → plain-0.6.0}/plain/signals/dispatch/dispatcher.py +0 -0
  91. {plain-0.4.1 → plain-0.6.0}/plain/signals/dispatch/license.txt +0 -0
  92. {plain-0.4.1 → plain-0.6.0}/plain/templates/README.md +0 -0
  93. {plain-0.4.1 → plain-0.6.0}/plain/templates/__init__.py +0 -0
  94. {plain-0.4.1 → plain-0.6.0}/plain/templates/core.py +0 -0
  95. {plain-0.4.1 → plain-0.6.0}/plain/templates/jinja/README.md +0 -0
  96. {plain-0.4.1 → plain-0.6.0}/plain/templates/jinja/__init__.py +0 -0
  97. {plain-0.4.1 → plain-0.6.0}/plain/templates/jinja/defaults.py +0 -0
  98. {plain-0.4.1 → plain-0.6.0}/plain/templates/jinja/extensions.py +0 -0
  99. {plain-0.4.1 → plain-0.6.0}/plain/templates/jinja/filters.py +0 -0
  100. {plain-0.4.1 → plain-0.6.0}/plain/templates/jinja/globals.py +0 -0
  101. {plain-0.4.1 → plain-0.6.0}/plain/test/README.md +0 -0
  102. {plain-0.4.1 → plain-0.6.0}/plain/test/__init__.py +0 -0
  103. {plain-0.4.1 → plain-0.6.0}/plain/test/client.py +0 -0
  104. {plain-0.4.1 → plain-0.6.0}/plain/urls/README.md +0 -0
  105. {plain-0.4.1 → plain-0.6.0}/plain/urls/__init__.py +0 -0
  106. {plain-0.4.1 → plain-0.6.0}/plain/urls/base.py +0 -0
  107. {plain-0.4.1 → plain-0.6.0}/plain/urls/conf.py +0 -0
  108. {plain-0.4.1 → plain-0.6.0}/plain/urls/converters.py +0 -0
  109. {plain-0.4.1 → plain-0.6.0}/plain/urls/exceptions.py +0 -0
  110. {plain-0.4.1 → plain-0.6.0}/plain/urls/resolvers.py +0 -0
  111. {plain-0.4.1 → plain-0.6.0}/plain/utils/README.md +0 -0
  112. {plain-0.4.1 → plain-0.6.0}/plain/utils/__init__.py +0 -0
  113. {plain-0.4.1 → plain-0.6.0}/plain/utils/_os.py +0 -0
  114. {plain-0.4.1 → plain-0.6.0}/plain/utils/cache.py +0 -0
  115. {plain-0.4.1 → plain-0.6.0}/plain/utils/connection.py +0 -0
  116. {plain-0.4.1 → plain-0.6.0}/plain/utils/crypto.py +0 -0
  117. {plain-0.4.1 → plain-0.6.0}/plain/utils/datastructures.py +0 -0
  118. {plain-0.4.1 → plain-0.6.0}/plain/utils/dateformat.py +0 -0
  119. {plain-0.4.1 → plain-0.6.0}/plain/utils/dateparse.py +0 -0
  120. {plain-0.4.1 → plain-0.6.0}/plain/utils/dates.py +0 -0
  121. {plain-0.4.1 → plain-0.6.0}/plain/utils/deconstruct.py +0 -0
  122. {plain-0.4.1 → plain-0.6.0}/plain/utils/decorators.py +0 -0
  123. {plain-0.4.1 → plain-0.6.0}/plain/utils/deprecation.py +0 -0
  124. {plain-0.4.1 → plain-0.6.0}/plain/utils/duration.py +0 -0
  125. {plain-0.4.1 → plain-0.6.0}/plain/utils/email.py +0 -0
  126. {plain-0.4.1 → plain-0.6.0}/plain/utils/encoding.py +0 -0
  127. {plain-0.4.1 → plain-0.6.0}/plain/utils/functional.py +0 -0
  128. {plain-0.4.1 → plain-0.6.0}/plain/utils/hashable.py +0 -0
  129. {plain-0.4.1 → plain-0.6.0}/plain/utils/html.py +0 -0
  130. {plain-0.4.1 → plain-0.6.0}/plain/utils/http.py +0 -0
  131. {plain-0.4.1 → plain-0.6.0}/plain/utils/inspect.py +0 -0
  132. {plain-0.4.1 → plain-0.6.0}/plain/utils/ipv6.py +0 -0
  133. {plain-0.4.1 → plain-0.6.0}/plain/utils/itercompat.py +0 -0
  134. {plain-0.4.1 → plain-0.6.0}/plain/utils/module_loading.py +0 -0
  135. {plain-0.4.1 → plain-0.6.0}/plain/utils/regex_helper.py +0 -0
  136. {plain-0.4.1 → plain-0.6.0}/plain/utils/safestring.py +0 -0
  137. {plain-0.4.1 → plain-0.6.0}/plain/utils/termcolors.py +0 -0
  138. {plain-0.4.1 → plain-0.6.0}/plain/utils/text.py +0 -0
  139. {plain-0.4.1 → plain-0.6.0}/plain/utils/timesince.py +0 -0
  140. {plain-0.4.1 → plain-0.6.0}/plain/utils/tree.py +0 -0
  141. {plain-0.4.1 → plain-0.6.0}/plain/validators.py +0 -0
  142. {plain-0.4.1 → plain-0.6.0}/plain/views/README.md +0 -0
  143. {plain-0.4.1 → plain-0.6.0}/plain/views/__init__.py +0 -0
  144. {plain-0.4.1 → plain-0.6.0}/plain/views/base.py +0 -0
  145. {plain-0.4.1 → plain-0.6.0}/plain/views/csrf.py +0 -0
  146. {plain-0.4.1 → plain-0.6.0}/plain/views/errors.py +0 -0
  147. {plain-0.4.1 → plain-0.6.0}/plain/views/exceptions.py +0 -0
  148. {plain-0.4.1 → plain-0.6.0}/plain/views/forms.py +0 -0
  149. {plain-0.4.1 → plain-0.6.0}/plain/views/objects.py +0 -0
  150. {plain-0.4.1 → plain-0.6.0}/plain/views/redirect.py +0 -0
  151. {plain-0.4.1 → plain-0.6.0}/plain/views/templates.py +0 -0
  152. {plain-0.4.1 → plain-0.6.0}/plain/wsgi.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: plain
3
- Version: 0.4.1
3
+ Version: 0.6.0
4
4
  Summary: A web framework for building products with Python.
5
5
  Author: Dave Gaeddert
6
6
  Author-email: dave.gaeddert@dropseed.dev
@@ -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 Exception as e:
476
+ except ImproperlyConfigured as e:
463
477
  click.secho(
464
- f"Error setting up Plain CLI\n{e}",
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 settings.USE_TZ and value is not None and timezone.is_naive(value):
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 settings.USE_TZ and value is not None and timezone.is_aware(value):
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
- if field in self.cleaned_data:
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]
@@ -59,7 +59,6 @@ MIDDLEWARE = [
59
59
  "plain.middleware.common.CommonMiddleware",
60
60
  "plain.csrf.middleware.CsrfViewMiddleware",
61
61
  "plain.auth.middleware.AuthenticationMiddleware",
62
- "plain.middleware.clickjacking.XFrameOptionsMiddleware",
63
62
  ]
64
63
 
65
64
  if DEBUG:
@@ -5,7 +5,7 @@ from pathlib import Path
5
5
 
6
6
  from dotenv import load_dotenv
7
7
 
8
- from .user_settings import LazySettings
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 = LazySettings()
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). When USE_TZ is True, this is
24
- # interpreted as the default user time zone.
25
- TIME_ZONE = "America/Chicago"
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
- SIGNING_BACKEND = "plain.signing.TimestampSigner"
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 = False
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 = False
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