viur-core 3.9.0.dev3__tar.gz → 3.9.0.dev5__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 (132) hide show
  1. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/PKG-INFO +2 -1
  2. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/pyproject.toml +1 -0
  3. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/__init__.py +3 -0
  4. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/bones/__init__.py +5 -0
  5. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/bones/base.py +25 -2
  6. viur_core-3.9.0.dev5/src/viur/core/bones/captcha.py +177 -0
  7. viur_core-3.9.0.dev5/src/viur/core/bones/code.py +95 -0
  8. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/bones/email.py +4 -0
  9. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/bones/randomslice.py +2 -2
  10. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/bones/relational.py +17 -10
  11. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/bones/spatial.py +12 -6
  12. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/bones/string.py +4 -3
  13. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/bones/text.py +1 -1
  14. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/config.py +4 -7
  15. viur_core-3.9.0.dev5/src/viur/core/contrib/__init__.py +43 -0
  16. viur_core-3.9.0.dev5/src/viur/core/contrib/loginkey.py +115 -0
  17. viur_core-3.9.0.dev5/src/viur/core/contrib/ratelimit.py +123 -0
  18. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/db/__init__.py +2 -0
  19. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/db/cache.py +1 -3
  20. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/db/query.py +38 -25
  21. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/db/transport.py +37 -14
  22. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/db/types.py +7 -1
  23. viur_core-3.9.0.dev5/src/viur/core/modules/email.py +98 -0
  24. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/modules/file.py +4 -3
  25. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/modules/script.py +8 -5
  26. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/modules/translation.py +2 -2
  27. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/modules/user.py +6 -6
  28. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/prototypes/skelmodule.py +8 -10
  29. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/render/vi/__init__.py +25 -2
  30. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/request.py +55 -0
  31. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/securityheaders.py +14 -3
  32. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/session.py +28 -0
  33. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/skeleton/__init__.py +2 -1
  34. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/skeleton/instance.py +59 -7
  35. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/skeleton/meta.py +36 -1
  36. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/skeleton/skeleton.py +18 -5
  37. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/skeleton/tasks.py +2 -8
  38. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/skeleton/utils.py +20 -10
  39. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/tasks.py +1 -1
  40. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/utils/__init__.py +4 -1
  41. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/version.py +1 -1
  42. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur_core.egg-info/PKG-INFO +2 -1
  43. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur_core.egg-info/SOURCES.txt +7 -0
  44. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur_core.egg-info/requires.txt +1 -0
  45. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/tests/test_config.py +0 -1
  46. viur_core-3.9.0.dev5/tests/test_db.py +81 -0
  47. viur_core-3.9.0.dev5/tests/test_decorators.py +271 -0
  48. viur_core-3.9.0.dev5/tests/test_errors.py +122 -0
  49. viur_core-3.9.0.dev5/tests/test_utils.py +299 -0
  50. viur_core-3.9.0.dev3/src/viur/core/bones/captcha.py +0 -130
  51. viur_core-3.9.0.dev3/tests/test_db.py +0 -29
  52. viur_core-3.9.0.dev3/tests/test_utils.py +0 -156
  53. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/LICENSE +0 -0
  54. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/README.md +0 -0
  55. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/setup.cfg +0 -0
  56. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/bones/boolean.py +0 -0
  57. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/bones/color.py +0 -0
  58. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/bones/credential.py +0 -0
  59. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/bones/date.py +0 -0
  60. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/bones/file.py +0 -0
  61. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/bones/image.py +0 -0
  62. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/bones/json.py +0 -0
  63. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/bones/key.py +0 -0
  64. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/bones/numeric.py +0 -0
  65. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/bones/password.py +0 -0
  66. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/bones/phone.py +0 -0
  67. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/bones/raw.py +0 -0
  68. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/bones/record.py +0 -0
  69. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/bones/select.py +0 -0
  70. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/bones/selectcountry.py +0 -0
  71. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/bones/sortindex.py +0 -0
  72. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/bones/spam.py +0 -0
  73. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/bones/treeleaf.py +0 -0
  74. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/bones/treenode.py +0 -0
  75. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/bones/uid.py +0 -0
  76. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/bones/uri.py +0 -0
  77. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/bones/user.py +0 -0
  78. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/cache.py +0 -0
  79. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/current.py +0 -0
  80. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/db/config.py +0 -0
  81. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/db/overrides.py +0 -0
  82. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/db/utils.py +0 -0
  83. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/decorators.py +0 -0
  84. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/email.py +0 -0
  85. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/errors.py +0 -0
  86. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/i18n.py +0 -0
  87. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/languages/__init__.py +0 -0
  88. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/languages/de.py +0 -0
  89. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/languages/en.py +0 -0
  90. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/logging.py +0 -0
  91. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/module.py +0 -0
  92. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/modules/__init__.py +0 -0
  93. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/modules/formmailer.py +0 -0
  94. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/modules/history.py +0 -0
  95. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/modules/moduleconf.py +0 -0
  96. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/modules/page.py +0 -0
  97. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/modules/site.py +0 -0
  98. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/pagination.py +0 -0
  99. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/prototypes/__init__.py +0 -0
  100. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/prototypes/instanced_module.py +0 -0
  101. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/prototypes/list.py +0 -0
  102. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/prototypes/singleton.py +0 -0
  103. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/prototypes/tree.py +0 -0
  104. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/ratelimit.py +0 -0
  105. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/render/__init__.py +0 -0
  106. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/render/abstract.py +0 -0
  107. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/render/html/__init__.py +0 -0
  108. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/render/html/default.py +0 -0
  109. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/render/html/env/__init__.py +0 -0
  110. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/render/html/env/date.py +0 -0
  111. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/render/html/env/debug.py +0 -0
  112. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/render/html/env/regex.py +0 -0
  113. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/render/html/env/session.py +0 -0
  114. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/render/html/env/strings.py +0 -0
  115. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/render/html/env/tests.py +0 -0
  116. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/render/html/env/viur.py +0 -0
  117. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/render/html/utils.py +0 -0
  118. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/render/json/__init__.py +0 -0
  119. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/render/json/default.py +0 -0
  120. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/scripts/viur_migrate.py +0 -0
  121. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/secret.py +0 -0
  122. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/securitykey.py +0 -0
  123. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/skeleton/adapter.py +0 -0
  124. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/skeleton/relskel.py +0 -0
  125. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/template/error.html +0 -0
  126. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/template/vi_user_google_login.html +0 -0
  127. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/utils/json.py +0 -0
  128. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/utils/parse.py +0 -0
  129. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur/core/utils/string.py +0 -0
  130. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur_core.egg-info/dependency_links.txt +0 -0
  131. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur_core.egg-info/entry_points.txt +0 -0
  132. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev5}/src/viur_core.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: viur-core
3
- Version: 3.9.0.dev3
3
+ Version: 3.9.0.dev5
4
4
  Summary: The core component of ViUR, a development framework for Google App Engine
5
5
  Author-email: Mausbrand Informationssysteme GmbH <devs@viur.dev>
6
6
  Maintainer-email: Jan Max Meyer <jm@mausbrand.de>
@@ -48,6 +48,7 @@ Requires-Dist: google-cloud-bigquery~=3.0
48
48
  Requires-Dist: google-cloud-datastore~=2.0
49
49
  Requires-Dist: google-cloud-iam~=2.0
50
50
  Requires-Dist: google-cloud-logging~=3.0
51
+ Requires-Dist: google-cloud-recaptcha-enterprise~=1.0
51
52
  Requires-Dist: google-cloud-secret-manager~=2.0
52
53
  Requires-Dist: google-cloud-storage~=2.0
53
54
  Requires-Dist: google-cloud-tasks~=2.0
@@ -14,6 +14,7 @@ dependencies = [
14
14
  "google-cloud-datastore~=2.0",
15
15
  "google-cloud-iam~=2.0",
16
16
  "google-cloud-logging~=3.0",
17
+ "google-cloud-recaptcha-enterprise~=1.0",
17
18
  "google-cloud-secret-manager~=2.0",
18
19
  "google-cloud-storage~=2.0",
19
20
  "google-cloud-tasks~=2.0",
@@ -18,6 +18,7 @@ from types import ModuleType
18
18
  from viur.core import i18n, request, utils
19
19
  from viur.core.config import conf
20
20
  from viur.core.decorators import access, exposed, force_post, force_ssl, internal_exposed, skey
21
+ from viur.core.request import before_request, after_request
21
22
  from viur.core.i18n import translate
22
23
  from viur.core.module import Method, Module
23
24
  import inspect
@@ -60,6 +61,8 @@ __all__ = [
60
61
  "PeriodicTask",
61
62
  # Decorators
62
63
  "access",
64
+ "after_request",
65
+ "before_request",
63
66
  "exposed",
64
67
  "force_post",
65
68
  "force_ssl",
@@ -13,6 +13,7 @@ from .base import (
13
13
  UniqueValue,
14
14
  )
15
15
  from .boolean import BooleanBone
16
+ from .code import CodeBone, JinjaBone, LogicsBone, PythonBone
16
17
  from .captcha import CaptchaBone
17
18
  from .color import ColorBone
18
19
  from .credential import CredentialBone
@@ -51,6 +52,10 @@ __all = [
51
52
  "BaseBone",
52
53
  "BooleanBone",
53
54
  "CaptchaBone",
55
+ "CodeBone",
56
+ "JinjaBone",
57
+ "LogicsBone",
58
+ "PythonBone",
54
59
  "CloneBehavior",
55
60
  "CloneStrategy",
56
61
  "ColorBone",
@@ -798,18 +798,23 @@ class BaseBone(object):
798
798
  if self.languages and isinstance(self.required, (list, tuple)):
799
799
  missing = set(self.required).difference(filled_languages)
800
800
  if missing:
801
- return [
801
+ result_errors = [
802
802
  ReadFromClientError(ReadFromClientErrorSeverity.Empty, fieldPath=[lang])
803
803
  for lang in missing
804
804
  ]
805
+ self.after_from_client(skel, name, result_errors)
806
+ return result_errors or None
805
807
 
806
808
  if isEmpty:
807
- return [ReadFromClientError(ReadFromClientErrorSeverity.Empty)]
809
+ result_errors = [ReadFromClientError(ReadFromClientErrorSeverity.Empty)]
810
+ self.after_from_client(skel, name, result_errors)
811
+ return result_errors or None
808
812
 
809
813
  # Check multiple constraints on demand
810
814
  if self.multiple and isinstance(self.multiple, MultipleConstraints):
811
815
  errors.extend(self._validate_multiple_contraints(self.multiple, skel, name))
812
816
 
817
+ self.after_from_client(skel, name, errors)
813
818
  return errors or None
814
819
 
815
820
  def _get_single_destinct_hash(self, value) -> t.Any:
@@ -1387,6 +1392,24 @@ class BaseBone(object):
1387
1392
  """
1388
1393
  pass
1389
1394
 
1395
+ def after_from_client(self, skel: "SkeletonInstance", name: str, errors: list[ReadFromClientError]) -> None:
1396
+ """
1397
+ Called at the end of :meth:`fromClient` after ``skel[name]`` has been set and all
1398
+ validation (including multiple-constraints) has run.
1399
+
1400
+ Override to post-process or normalize ``skel[name]`` in-place, or to add/remove
1401
+ entries from ``errors``. Always called when the field was part of the submitted data
1402
+ (i.e. ``skel[name]`` has been written), regardless of whether errors occurred.
1403
+ The ``NotSet`` early-return (field absent from request) is the only case where this
1404
+ hook is *not* called.
1405
+
1406
+ :param skel: The skeleton instance whose bone value was just read.
1407
+ :param name: The attribute name of this bone within the skeleton.
1408
+ :param errors: Mutable list of :class:`ReadFromClientError` collected so far.
1409
+ Changes here affect the return value of :meth:`fromClient`.
1410
+ """
1411
+ pass
1412
+
1390
1413
  def clone_value(self, skel: "SkeletonInstance", src_skel: "SkeletonInstance", bone_name: str) -> None:
1391
1414
  """Clone / Set the value for this bone depending on :attr:`clone_behavior`"""
1392
1415
  match self.clone_behavior.strategy:
@@ -0,0 +1,177 @@
1
+ import logging
2
+ import typing as t
3
+ import warnings
4
+
5
+ from viur.core import conf, current
6
+ from viur.core.bones.base import BaseBone, ReadFromClientError, ReadFromClientErrorSeverity
7
+
8
+ from google.cloud import recaptchaenterprise_v1
9
+ from google.cloud.recaptchaenterprise_v1 import Assessment
10
+
11
+ if t.TYPE_CHECKING:
12
+ from viur.core.skeleton import SkeletonInstance
13
+
14
+
15
+ class CaptchaBone(BaseBone):
16
+ r"""
17
+ The CaptchaBone validates reCAPTCHA Enterprise tokens to protect forms from bots.
18
+
19
+ It uses the Google reCAPTCHA Enterprise API and supports both invisible (v3-style score-based)
20
+ and visible (checkbox widget) challenges via the ``render_challenge`` parameter.
21
+
22
+ The token is submitted by the client as the bone's field value and verified server-side
23
+ against the configured site key. A configurable score threshold determines whether
24
+ invisible challenges pass.
25
+
26
+ .. seealso::
27
+
28
+ `Google reCAPTCHA Enterprise setup
29
+ <https://cloud.google.com/recaptcha/docs/set-up-non-google-cloud-environments-api-keys>`
30
+ for creating a site key and enabling the API.
31
+
32
+ Option :attr:`core.config.Security.captcha_default_public_key`
33
+ for global security settings.
34
+
35
+ Option :attr:`core.config.Security.captcha_enforce_always`
36
+ to enforce validation even on development servers.
37
+ """
38
+
39
+ type = "captcha"
40
+
41
+ def __init__(
42
+ self,
43
+ *,
44
+ public_key: str = None,
45
+ score_threshold: float = 0.5,
46
+ render_challenge: bool = False,
47
+ recaptcha_action: str = "",
48
+ **kwargs: t.Any
49
+ ):
50
+ """
51
+ Initializes a new CaptchaBone.
52
+
53
+ :param public_key: The reCAPTCHA Enterprise site key shown to the client.
54
+ Can be omitted if set globally via :attr:`core.config.Security.captcha_default_public_key`.
55
+ :param score_threshold: Minimum score (0–1) required for invisible challenges to pass.
56
+ Ignored when ``render_challenge`` is ``True``.
57
+ :param render_challenge: If ``True``, renders a visible checkbox widget instead of
58
+ running an invisible background check.
59
+ :param recaptcha_action: The action name passed to reCAPTCHA for analytics and scoring.
60
+ Should match the action used on the client side.
61
+ """
62
+ if "publicKey" in kwargs:
63
+ warnings.warn("publicKey parameter is deprecated, please use public_key",
64
+ DeprecationWarning, stacklevel=2)
65
+ public_key = kwargs.pop("publicKey")
66
+ super().__init__(**kwargs)
67
+ if not public_key and conf.security.captcha_default_public_key:
68
+ public_key = conf.security.captcha_default_public_key
69
+ if not public_key:
70
+ raise ValueError("CaptchaBone requires either a public_key or conf.security.captcha_default_public_key")
71
+
72
+ self.public_key = public_key
73
+
74
+ if not (0 < score_threshold <= 1):
75
+ raise ValueError("score_threshold must be between 0 and 1.")
76
+ self.render_challenge = render_challenge
77
+ self.recaptcha_action = recaptcha_action
78
+ self.score_threshold = score_threshold
79
+ self.required = True
80
+
81
+ def serialize(self, skel: "SkeletonInstance", name: str, parentIndexed: bool) -> bool:
82
+ """
83
+ Serializing the Captcha bone is not possible so it return False
84
+ """
85
+ return False
86
+
87
+ def unserialize(self, skel: "SkeletonInstance", name) -> t.Literal[True]:
88
+ """
89
+ Stores the public_key in the SkeletonInstance
90
+
91
+ :param skel: The target :class:`SkeletonInstance`.
92
+ :param name: The name of the CaptchaBone in the :class:`SkeletonInstance`.
93
+
94
+ :returns: boolean, that is true, as the Captcha bone is always unserialized successfully.
95
+ """
96
+ skel.accessedValues[name] = self.public_key
97
+ return True
98
+
99
+ def fromClient(self, skel: "SkeletonInstance", name: str, data: dict) -> None | list[ReadFromClientError]:
100
+ """
101
+ Load the reCAPTCHA token from the provided data and validate it with the help of the API.
102
+
103
+ reCAPTCHA provides the token via callback usually as "g-recaptcha-response",
104
+ but to fit into the skeleton logic, we support both names.
105
+ So the token can be provided as "g-recaptcha-response" or the name of the CaptchaBone in the Skeleton.
106
+ While the latter one is the preferred name.
107
+ """
108
+
109
+ if not conf.security.captcha_enforce_always and conf.instance.is_dev_server:
110
+ logging.info("Skipping captcha validation on development server")
111
+ return None
112
+ if not conf.security.captcha_enforce_always and (user := current.user.get()) and "root" in user["access"]:
113
+ logging.info("Skipping captcha validation for root user")
114
+ return None # Don't bother trusted users with this (not supported by admin/vi anyway)
115
+
116
+ client = recaptchaenterprise_v1.RecaptchaEnterpriseServiceClient()
117
+
118
+ # Set the attributes of the event to be tracked.
119
+ event = recaptchaenterprise_v1.Event()
120
+ event.site_key = self.public_key
121
+ if name in data:
122
+ event.token = data[name]
123
+ else:
124
+ return [ReadFromClientError(
125
+ ReadFromClientErrorSeverity.NotSet,
126
+ "Token not set"
127
+ )]
128
+ assessment = recaptchaenterprise_v1.Assessment()
129
+ assessment.event = event
130
+
131
+ project_name = f"projects/{conf.instance.project_id}"
132
+
133
+ # Create the assessment request.
134
+ request = recaptchaenterprise_v1.CreateAssessmentRequest()
135
+ request.assessment = assessment
136
+ request.parent = project_name
137
+
138
+ response = client.create_assessment(request)
139
+
140
+ if not response.token_properties.valid:
141
+ logging.info(
142
+ "The CreateAssessment call failed because the token was "
143
+ + "invalid for the following reasons: "
144
+ + str(response.token_properties.invalid_reason)
145
+ )
146
+ return [ReadFromClientError(
147
+ ReadFromClientErrorSeverity.Invalid,
148
+ "Invalid Token"
149
+ )]
150
+
151
+ # Check if the expected action was executed.
152
+ if response.token_properties.action != self.recaptcha_action:
153
+ logging.info(
154
+ "The action attribute in your reCAPTCHA tag does not match the action you are expecting to score"
155
+ )
156
+ return [ReadFromClientError(
157
+ ReadFromClientErrorSeverity.Invalid,
158
+ f"Invalid Action: {self.recaptcha_action}"
159
+ )]
160
+ else:
161
+ # Retrieve the risk score and reasons.
162
+ # For more information on interpreting the assessment, see:
163
+ # https://cloud.google.com/recaptcha/docs/interpret-assessment
164
+ if response.risk_analysis.score < self.score_threshold:
165
+ return [ReadFromClientError(
166
+ ReadFromClientErrorSeverity.Invalid,
167
+ f"Invalid Captcha: {response.risk_analysis.score}"
168
+ )]
169
+
170
+ return None
171
+
172
+ def structure(self) -> dict:
173
+ return super().structure() | {
174
+ "public_key": self.public_key,
175
+ "render_challenge": self.render_challenge,
176
+ "action": self.recaptcha_action
177
+ }
@@ -0,0 +1,95 @@
1
+ import ast
2
+ import jinja2
3
+ import logics
4
+ from viur.core.bones.raw import RawBone
5
+
6
+
7
+ class CodeBone(RawBone):
8
+ """
9
+ Stores source code with optional language-specific syntax validation.
10
+
11
+ The ``syntax`` parameter sets the type suffix used by the frontend for syntax
12
+ highlighting, e.g. ``syntax="python"`` yields ``type = "raw.code.python"``.
13
+ ``type_suffix`` can override this to an arbitrary suffix.
14
+
15
+ Neither ``multiple`` nor ``languages`` are supported.
16
+ Setting ``validate=False`` disables any syntax validation in subclasses.
17
+ """
18
+
19
+ type = "raw.code"
20
+
21
+ def __init__(
22
+ self,
23
+ *,
24
+ indexed: bool = False,
25
+ languages=None,
26
+ multiple: bool = False,
27
+ syntax: str | None = None,
28
+ type_suffix: str = "",
29
+ validate: bool = True,
30
+ **kwargs,
31
+ ):
32
+ assert not multiple, "CodeBone does not support multiple values"
33
+ assert not languages, "CodeBone does not support language variants"
34
+ self.syntax = syntax
35
+ self.validate = validate
36
+ if self.syntax:
37
+ type_suffix = type_suffix or syntax
38
+ super().__init__(indexed=indexed, type_suffix=type_suffix, **kwargs)
39
+
40
+
41
+ class LogicsBone(CodeBone):
42
+ """
43
+ Validates its value as a Logics expression (https://github.com/viur-framework/logics).
44
+ Uses Python syntax highlighting in the frontend.
45
+ """
46
+
47
+ def __init__(self, *, syntax: str = "logics", **kwargs):
48
+ super().__init__(syntax=syntax, type_suffix="python", **kwargs)
49
+
50
+ def singleValueFromClient(self, value, skel, bone_name, client_data):
51
+ if value := str(value or "").strip():
52
+ value += "\n"
53
+ return super().singleValueFromClient(value, skel, bone_name, client_data)
54
+
55
+ def isInvalid(self, value):
56
+ if self.validate and value:
57
+ try:
58
+ logics.Logics(value)
59
+ except logics.ParseException as e:
60
+ return str(e).replace("&eof", "end-of-expression")
61
+
62
+
63
+ class JinjaBone(CodeBone):
64
+ """
65
+ Validates its value as a Jinja2 template.
66
+ """
67
+
68
+ def __init__(self, *, syntax: str = "jinja2", **kwargs):
69
+ super().__init__(syntax=syntax, **kwargs)
70
+
71
+ def isInvalid(self, value):
72
+ if self.validate and value:
73
+ env = jinja2.Environment()
74
+ try:
75
+ env.parse(value)
76
+ except jinja2.TemplateSyntaxError as e:
77
+ return f"Syntax error in line {e.lineno}: {e.message}"
78
+ except jinja2.TemplateError as e:
79
+ return f"General error: {e}"
80
+
81
+
82
+ class PythonBone(CodeBone):
83
+ """
84
+ Validates its value as Python source code.
85
+ """
86
+
87
+ def __init__(self, *, syntax: str = "python", **kwargs):
88
+ super().__init__(syntax=syntax, **kwargs)
89
+
90
+ def isInvalid(self, value):
91
+ if self.validate and value:
92
+ try:
93
+ ast.parse(value)
94
+ except SyntaxError as e:
95
+ return f"Syntax error in line {e.lineno}: {e.msg}"
@@ -45,6 +45,10 @@ class EmailBone(StringBone):
45
45
  assert account and subDomain and tld
46
46
  assert subDomain[0] != "."
47
47
  assert len(account) <= 64
48
+ # RFC 5321: local part must not start/end with a dot or contain consecutive dots
49
+ assert not account.startswith(".")
50
+ assert not account.endswith(".")
51
+ assert ".." not in account
48
52
  except (ValueError, AssertionError):
49
53
  is_valid = False
50
54
 
@@ -112,13 +112,13 @@ class RandomSliceBone(BaseBone):
112
112
  q = db.QueryDefinition(origKind, {}, [])
113
113
  property, value = applyFilterHook(dbFilter, f"{name} <=", rndVal)
114
114
  q.filters[property] = value
115
- q.orders = [(name, db.SortOrder.Descending)]
115
+ q.orders = [db.QueryOrder(name, db.SortOrder.Descending)]
116
116
  queries.append(q)
117
117
  # Left Side
118
118
  q = db.QueryDefinition(origKind, {}, [])
119
119
  property, value = applyFilterHook(dbFilter, f"{name} >", rndVal)
120
120
  q.filters[property] = value
121
- q.orders = [(name, db.SortOrder.Ascending)]
121
+ q.orders = [db.QueryOrder(name)]
122
122
  queries.append(q)
123
123
  dbFilter.queries = queries
124
124
  # Map the original filter back in
@@ -965,7 +965,12 @@ class RelationalBone(BaseBone):
965
965
  raise RuntimeError()
966
966
  return f"src.{param}", value
967
967
 
968
- def orderHook(self, name: str, query: db.Query, orderings): # FIXME
968
+ def orderHook(
969
+ self,
970
+ name: str,
971
+ query: db.Query,
972
+ orderings: list[db.QueryOrder | str] | tuple[db.QueryOrder | str, ...],
973
+ ) -> list[db.QueryOrder | str] | tuple[db.QueryOrder | str]:
969
974
  """
970
975
  Hook installed by buildDbFilter that rewrites orderings added to the query to match the layout of the
971
976
  viur-relations index and performs sanity checks on the query.
@@ -977,21 +982,23 @@ class RelationalBone(BaseBone):
977
982
  :param name: The name of the bone.
978
983
  :param query: The datastore query to be modified.
979
984
  :param orderings: A list or tuple of orderings to be checked and potentially modified.
980
- :type orderings: List[Union[str, Tuple[str, db.SortOrder]]] or Tuple[Union[str, Tuple[str, db.SortOrder]]]
981
985
 
982
986
  :return: A list of modified orderings that are compatible with the viur-relations index.
983
- :rtype: List[Union[str, Tuple[str, db.SortOrder]]]
984
987
 
985
988
  :raises RuntimeError: If the ordering is invalid, e.g., using properties not in 'refKeys' or 'parentKeys'.
986
989
  """
987
990
  res = []
988
- if not isinstance(orderings, list) and not isinstance(orderings, tuple):
991
+ if isinstance(orderings, (str, db.QueryOrder)):
989
992
  orderings = [orderings]
993
+ elif not isinstance(orderings, list):
994
+ orderings = list(orderings)
990
995
  for order in orderings:
991
- if isinstance(order, tuple):
992
- orderKey = order[0]
993
- else:
996
+ if isinstance(order, db.QueryOrder):
997
+ orderKey = order.name
998
+ elif isinstance(order, str):
994
999
  orderKey = order
1000
+ else:
1001
+ raise TypeError(f"Invalid ordering {order!r} in orderHook")
995
1002
  if orderKey.startswith("dest.") or orderKey.startswith("rel.") or orderKey.startswith("src."):
996
1003
  # This is already valid for our relational index
997
1004
  res.append(order)
@@ -1005,7 +1012,7 @@ class RelationalBone(BaseBone):
1005
1012
  res.append(order)
1006
1013
  else:
1007
1014
  if isinstance(order, tuple):
1008
- res.append((f"dest.{k}", order[1]))
1015
+ res.append(db.QueryOrder(f"dest.{k}", order[1]))
1009
1016
  else:
1010
1017
  res.append(f"dest.{k}")
1011
1018
  else:
@@ -1019,7 +1026,7 @@ class RelationalBone(BaseBone):
1019
1026
  f"Invalid ordering! {orderKey} is not in parentKeys of RelationalBone {name}!")
1020
1027
  raise RuntimeError()
1021
1028
  if isinstance(order, tuple):
1022
- res.append((f"src.{orderKey}", order[1]))
1029
+ res.append(db.QueryOrder(f"src.{orderKey}", order[1]))
1023
1030
  else:
1024
1031
  res.append(f"src.{orderKey}")
1025
1032
  return res
@@ -1107,7 +1114,7 @@ class RelationalBone(BaseBone):
1107
1114
 
1108
1115
  ref_skel_cache, using_skel_cache = self._getSkels()
1109
1116
  for idx, lang, value in self.iter_bone_value(skel, name):
1110
- if value is None:
1117
+ if not value:
1111
1118
  continue
1112
1119
  if value["dest"]:
1113
1120
  get_values(ref_skel_cache, value["dest"])
@@ -281,22 +281,22 @@ class SpatialBone(BaseBone):
281
281
  q1 = deepcopy(origQuery)
282
282
  q1.filters[name + ".coordinates.lat >="] = lat
283
283
  q1.filters[name + ".tiles.lat ="] = tileLat
284
- q1.orders = [(name + ".coordinates.lat", db.SortOrder.Ascending)]
284
+ q1.orders = [db.QueryOrder(name + ".coordinates.lat")]
285
285
  # Lat - Left Side
286
286
  q2 = deepcopy(origQuery)
287
287
  q2.filters[name + ".coordinates.lat <"] = lat
288
288
  q2.filters[name + ".tiles.lat ="] = tileLat
289
- q2.orders = [(name + ".coordinates.lat", db.SortOrder.Descending)]
289
+ q2.orders = [db.QueryOrder(name + ".coordinates.lat", db.SortOrder.Descending)]
290
290
  # Lng - Down
291
291
  q3 = deepcopy(origQuery)
292
292
  q3.filters[name + ".coordinates.lng >="] = lng
293
293
  q3.filters[name + ".tiles.lng ="] = tileLng
294
- q3.orders = [(name + ".coordinates.lng", db.SortOrder.Ascending)]
294
+ q3.orders = [db.QueryOrder(name + ".coordinates.lng")]
295
295
  # Lng - Top
296
296
  q4 = deepcopy(origQuery)
297
297
  q4.filters[name + ".coordinates.lng <"] = lng
298
298
  q4.filters[name + ".tiles.lng ="] = tileLng
299
- q4.orders = [(name + ".coordinates.lng", db.SortOrder.Descending)]
299
+ q4.orders = [db.QueryOrder(name + ".coordinates.lng", db.SortOrder.Descending)]
300
300
  dbFilter.queries = [q1, q2, q3, q4]
301
301
  dbFilter._customMultiQueryMerge = lambda *args, **kwargs: self.customMultiQueryMerge(name, lat, lng, *args,
302
302
  **kwargs)
@@ -378,7 +378,8 @@ class SpatialBone(BaseBone):
378
378
 
379
379
  :param skel: Dictionary with the current values from the skeleton the bone belongs to
380
380
  :param boneName: The name of the bone that should be modified
381
- :param value: The value that should be assigned. Its type depends on the type of the bone
381
+ :param value: The value that should be assigned. Either a tuple/list of (lat, lng) or a dict
382
+ with keys ``lat`` and ``lng``
382
383
  :param append: If True, the given value will be appended to the existing bone values instead of
383
384
  replacing them. Only supported on bones with multiple=True
384
385
  :param language: Optional, the language of the value if the bone is language-aware
@@ -387,7 +388,12 @@ class SpatialBone(BaseBone):
387
388
  """
388
389
  if append:
389
390
  raise ValueError(f"append is not possible on {self.type} bones")
390
- if not isinstance(value, (tuple, list)) and len(value) == 2:
391
+ if isinstance(value, dict):
392
+ try:
393
+ value = (float(value["lat"]), float(value["lng"]))
394
+ except (KeyError, TypeError, ValueError):
395
+ raise ValueError("Value dict must contain 'lat' and 'lng' keys as floats")
396
+ if not isinstance(value, (tuple, list)) or len(value) != 2:
391
397
  raise ValueError("Value must be a tuple or a list of (lat, lng)")
392
398
  skel[boneName] = tuple(value)
393
399
 
@@ -6,7 +6,7 @@ import typing as t
6
6
  import warnings
7
7
  from numbers import Number
8
8
 
9
- from viur.core import current, db, utils
9
+ from viur.core import conf, current, db, utils
10
10
  from .base import ReadFromClientError, ReadFromClientErrorSeverity
11
11
  from .raw import RawBone
12
12
 
@@ -29,7 +29,7 @@ class StringBone(RawBone):
29
29
  max_length: int | None = 254,
30
30
  min_length: int | None = None,
31
31
  natural_sorting: bool | t.Callable = False,
32
- escape_html: bool = True,
32
+ escape_html: bool | None = None,
33
33
  **kwargs
34
34
  ):
35
35
  """
@@ -46,6 +46,7 @@ class StringBone(RawBone):
46
46
  that creates the value for the index property.
47
47
  :param escape_html: Replace some characters in the string with HTML-safe sequences with
48
48
  using :meth:`utils.string.escape` for safe use in HTML.
49
+ Defaults to :attr:`conf.bone_string_escape_html` if not set explicitly.
49
50
  :param kwargs: Inherited arguments from the BaseBone.
50
51
  """
51
52
  # fixme: Remove in viur-core >= 4
@@ -71,7 +72,7 @@ class StringBone(RawBone):
71
72
  elif not natural_sorting:
72
73
  self.natural_sorting = None
73
74
  # else: keep self.natural_sorting as is
74
- self.escape_html = escape_html
75
+ self.escape_html = conf.bone_string_escape_html if escape_html is None else escape_html
75
76
 
76
77
  def type_coerce_single_value(self, value: t.Any) -> str:
77
78
  """Convert a value to a string (if not already)
@@ -414,7 +414,7 @@ class TextBone(RawBone):
414
414
  from viur.core.bones.file import ensureDerived
415
415
  for blob_key in blob_keys:
416
416
  file_obj = db.Query("file").filter("dlkey =", blob_key) \
417
- .order(("creationdate", db.SortOrder.Ascending)).getEntry()
417
+ .order(db.QueryOrder("creationdate")).getEntry()
418
418
  if file_obj:
419
419
  ensureDerived(file_obj.key, f"{skel.kindName}_{name}", derive_dict, skel["key"])
420
420
 
@@ -28,10 +28,6 @@ _T = t.TypeVar("_T")
28
28
  Multiple: t.TypeAlias = list[_T] | tuple[_T] | set[_T] | frozenset[_T] # TODO: Refactor for Python 3.12
29
29
 
30
30
 
31
- class CaptchaDefaultCredentialsType(t.TypedDict):
32
- """Expected type of global captcha credential, see :attr:`Security.captcha_default_credentials`"""
33
- sitekey: str
34
- secret: str
35
31
 
36
32
 
37
33
  class ConfigType:
@@ -415,7 +411,7 @@ class Security(ConfigType):
415
411
  x_permitted_cross_domain_policies: t.Optional[t.Literal["none", "master-only", "by-content-type", "all"]] = "none"
416
412
  """Unless set to logical none; ViUR will emit a X-Permitted-Cross-Domain-Policies with each request"""
417
413
 
418
- captcha_default_credentials: t.Optional[CaptchaDefaultCredentialsType] = None
414
+ captcha_default_public_key: t.Optional[str] = None
419
415
  """The default sitekey and secret to use for the :class:`CaptchaBone`.
420
416
  If set, must be a dictionary of "sitekey" and "secret".
421
417
  """
@@ -508,8 +504,6 @@ class Security(ConfigType):
508
504
  "xXssProtection": "x_xss_protection",
509
505
  "xContentTypeOptions": "x_content_type_options",
510
506
  "xPermittedCrossDomainPolicies": "x_permitted_cross_domain_policies",
511
- "captcha_defaultCredentials": "captcha_default_credentials",
512
- "captcha.defaultCredentials": "captcha_default_credentials",
513
507
  }
514
508
 
515
509
 
@@ -794,6 +788,9 @@ class Conf(ConfigType):
794
788
  bone_boolean_str2true: Multiple[str | int] = ("true", "yes", "1")
795
789
  """Allowed values that define a str to evaluate to true"""
796
790
 
791
+ bone_string_escape_html: bool = True
792
+ """Default escape_html setting for StringBone. Set to False to disable HTML escaping globally."""
793
+
797
794
  bone_html_default_allow: "HtmlBoneConfiguration" = {
798
795
  "validTags": [
799
796
  "a",
@@ -0,0 +1,43 @@
1
+ """
2
+ viur.core.contrib — optional, reusable application-level components.
3
+
4
+ This package contains self-contained components that are commonly needed
5
+ but *not* required to run a ViUR application. Components are opt-in;
6
+ import only what you use.
7
+
8
+ Available modules
9
+ -----------------
10
+ loginkey
11
+ :class:`~viur.core.contrib.loginkey.IndexedCredentialBone` and
12
+ :class:`~viur.core.contrib.loginkey.LoginKey` — a
13
+ :class:`~viur.core.modules.user.UserPrimaryAuthentication` that
14
+ authenticates users via a secret token stored in a Datastore-indexed
15
+ :class:`~viur.core.bones.CredentialBone`. Suitable for "magic link"
16
+ style logins or machine-to-machine auth.
17
+
18
+ Usage example::
19
+
20
+ from viur.core.modules.user import User
21
+ from viur.core.contrib.loginkey import LoginKey
22
+
23
+ class MyUser(User):
24
+ authenticationProviders = [LoginKey, ...]
25
+
26
+ ratelimit
27
+ :class:`~viur.core.contrib.ratelimit.RequestRateLimit` — a
28
+ :class:`~viur.core.request.RequestValidator` that enforces per-IP /
29
+ per-user request-rate limits using App Engine Memcache. Suitable for
30
+ global rate-limiting and basic DDoS mitigation at the WSGI boundary.
31
+
32
+ Usage example::
33
+
34
+ from viur.core.request import Router
35
+ from viur.core.contrib.ratelimit import RequestRateLimit, TimeWindow
36
+
37
+ Router.requestValidators.append(
38
+ RequestRateLimit(
39
+ rate_for_guests=TimeWindow(limit=200, time_window=60),
40
+ rate_for_users=TimeWindow(limit=500, time_window=60),
41
+ )
42
+ )
43
+ """