viur-core 3.9.0.dev4__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 (131) hide show
  1. {viur_core-3.9.0.dev4/src/viur_core.egg-info → viur_core-3.9.0.dev5}/PKG-INFO +2 -1
  2. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/pyproject.toml +1 -0
  3. viur_core-3.9.0.dev5/src/viur/core/bones/captcha.py +177 -0
  4. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/bones/randomslice.py +2 -2
  5. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/bones/relational.py +16 -9
  6. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/bones/spatial.py +12 -6
  7. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/bones/text.py +1 -1
  8. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/config.py +1 -7
  9. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/db/__init__.py +2 -0
  10. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/db/query.py +38 -25
  11. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/db/transport.py +4 -6
  12. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/db/types.py +7 -1
  13. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/modules/file.py +2 -1
  14. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/modules/script.py +7 -2
  15. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/modules/translation.py +2 -2
  16. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/modules/user.py +3 -3
  17. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/prototypes/skelmodule.py +8 -10
  18. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/securityheaders.py +14 -3
  19. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/skeleton/__init__.py +2 -1
  20. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/skeleton/instance.py +59 -7
  21. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/skeleton/meta.py +36 -1
  22. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/skeleton/skeleton.py +14 -5
  23. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/skeleton/utils.py +20 -10
  24. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/tasks.py +1 -1
  25. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/version.py +1 -1
  26. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5/src/viur_core.egg-info}/PKG-INFO +2 -1
  27. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur_core.egg-info/requires.txt +1 -0
  28. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/tests/test_config.py +0 -1
  29. viur_core-3.9.0.dev5/tests/test_db.py +81 -0
  30. viur_core-3.9.0.dev4/src/viur/core/bones/captcha.py +0 -130
  31. viur_core-3.9.0.dev4/tests/test_db.py +0 -29
  32. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/LICENSE +0 -0
  33. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/README.md +0 -0
  34. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/setup.cfg +0 -0
  35. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/__init__.py +0 -0
  36. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/bones/__init__.py +0 -0
  37. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/bones/base.py +0 -0
  38. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/bones/boolean.py +0 -0
  39. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/bones/code.py +0 -0
  40. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/bones/color.py +0 -0
  41. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/bones/credential.py +0 -0
  42. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/bones/date.py +0 -0
  43. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/bones/email.py +0 -0
  44. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/bones/file.py +0 -0
  45. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/bones/image.py +0 -0
  46. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/bones/json.py +0 -0
  47. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/bones/key.py +0 -0
  48. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/bones/numeric.py +0 -0
  49. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/bones/password.py +0 -0
  50. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/bones/phone.py +0 -0
  51. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/bones/raw.py +0 -0
  52. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/bones/record.py +0 -0
  53. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/bones/select.py +0 -0
  54. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/bones/selectcountry.py +0 -0
  55. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/bones/sortindex.py +0 -0
  56. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/bones/spam.py +0 -0
  57. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/bones/string.py +0 -0
  58. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/bones/treeleaf.py +0 -0
  59. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/bones/treenode.py +0 -0
  60. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/bones/uid.py +0 -0
  61. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/bones/uri.py +0 -0
  62. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/bones/user.py +0 -0
  63. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/cache.py +0 -0
  64. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/contrib/__init__.py +0 -0
  65. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/contrib/loginkey.py +0 -0
  66. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/contrib/ratelimit.py +0 -0
  67. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/current.py +0 -0
  68. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/db/cache.py +0 -0
  69. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/db/config.py +0 -0
  70. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/db/overrides.py +0 -0
  71. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/db/utils.py +0 -0
  72. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/decorators.py +0 -0
  73. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/email.py +0 -0
  74. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/errors.py +0 -0
  75. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/i18n.py +0 -0
  76. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/languages/__init__.py +0 -0
  77. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/languages/de.py +0 -0
  78. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/languages/en.py +0 -0
  79. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/logging.py +0 -0
  80. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/module.py +0 -0
  81. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/modules/__init__.py +0 -0
  82. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/modules/email.py +0 -0
  83. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/modules/formmailer.py +0 -0
  84. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/modules/history.py +0 -0
  85. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/modules/moduleconf.py +0 -0
  86. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/modules/page.py +0 -0
  87. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/modules/site.py +0 -0
  88. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/pagination.py +0 -0
  89. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/prototypes/__init__.py +0 -0
  90. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/prototypes/instanced_module.py +0 -0
  91. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/prototypes/list.py +0 -0
  92. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/prototypes/singleton.py +0 -0
  93. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/prototypes/tree.py +0 -0
  94. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/ratelimit.py +0 -0
  95. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/render/__init__.py +0 -0
  96. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/render/abstract.py +0 -0
  97. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/render/html/__init__.py +0 -0
  98. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/render/html/default.py +0 -0
  99. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/render/html/env/__init__.py +0 -0
  100. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/render/html/env/date.py +0 -0
  101. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/render/html/env/debug.py +0 -0
  102. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/render/html/env/regex.py +0 -0
  103. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/render/html/env/session.py +0 -0
  104. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/render/html/env/strings.py +0 -0
  105. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/render/html/env/tests.py +0 -0
  106. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/render/html/env/viur.py +0 -0
  107. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/render/html/utils.py +0 -0
  108. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/render/json/__init__.py +0 -0
  109. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/render/json/default.py +0 -0
  110. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/render/vi/__init__.py +0 -0
  111. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/request.py +0 -0
  112. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/scripts/viur_migrate.py +0 -0
  113. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/secret.py +0 -0
  114. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/securitykey.py +0 -0
  115. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/session.py +0 -0
  116. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/skeleton/adapter.py +0 -0
  117. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/skeleton/relskel.py +0 -0
  118. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/skeleton/tasks.py +0 -0
  119. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/template/error.html +0 -0
  120. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/template/vi_user_google_login.html +0 -0
  121. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/utils/__init__.py +0 -0
  122. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/utils/json.py +0 -0
  123. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/utils/parse.py +0 -0
  124. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur/core/utils/string.py +0 -0
  125. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur_core.egg-info/SOURCES.txt +0 -0
  126. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur_core.egg-info/dependency_links.txt +0 -0
  127. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur_core.egg-info/entry_points.txt +0 -0
  128. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/src/viur_core.egg-info/top_level.txt +0 -0
  129. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/tests/test_decorators.py +0 -0
  130. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/tests/test_errors.py +0 -0
  131. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev5}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: viur-core
3
- Version: 3.9.0.dev4
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",
@@ -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
+ }
@@ -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
@@ -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
 
@@ -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
 
@@ -27,6 +27,7 @@ from .types import (
27
27
  Key,
28
28
  KeyType,
29
29
  QueryDefinition,
30
+ QueryOrder,
30
31
  SortOrder,
31
32
  )
32
33
  from .utils import (
@@ -51,6 +52,7 @@ __all__ = [
51
52
  "KEY_SPECIAL_PROPERTY",
52
53
  "DATASTORE_BASE_TYPES",
53
54
  "SortOrder",
55
+ "QueryOrder",
54
56
  "Entity",
55
57
  "QueryDefinition",
56
58
  "Key",
@@ -13,6 +13,7 @@ from .types import (
13
13
  Entity,
14
14
  KEY_SPECIAL_PROPERTY,
15
15
  QueryDefinition,
16
+ QueryOrder,
16
17
  SortOrder,
17
18
  TFilters,
18
19
  TOrders,
@@ -282,14 +283,14 @@ class Query(object):
282
283
  if op in {"<", "<=", ">", ">="}:
283
284
  if isinstance(self.queries, list):
284
285
  for queryObj in self.queries:
285
- if not queryObj.orders or queryObj.orders[0][0] != field:
286
- queryObj.orders = [(field, SortOrder.Ascending)] + (queryObj.orders or [])
286
+ if not queryObj.orders or queryObj.orders[0].name != field:
287
+ queryObj.orders = [QueryOrder(field)] + (queryObj.orders or [])
287
288
  else:
288
- if not self.queries.orders or self.queries.orders[0][0] != field:
289
- self.queries.orders = [(field, SortOrder.Ascending)] + (self.queries.orders or [])
289
+ if not self.queries.orders or self.queries.orders[0].name != field:
290
+ self.queries.orders = [QueryOrder(field)] + (self.queries.orders or [])
290
291
  return self
291
292
 
292
- def order(self, *orderings: t.Tuple[str, SortOrder]) -> t.Self:
293
+ def order(self, *orderings: QueryOrder | t.Tuple[str, SortOrder] | str) -> t.Self:
293
294
  """
294
295
  Specify a query sorting.
295
296
 
@@ -301,7 +302,10 @@ class Query(object):
301
302
  .. code-block:: python
302
303
 
303
304
  query = Query("Person")
304
- query.order(("bday" db.SortOrder.Ascending), ("age", db.SortOrder.Descending))
305
+ query.order(
306
+ db.QueryOrder("bday"),
307
+ db.QueryOrder("age", db.SortOrder.Descending),
308
+ )
305
309
 
306
310
  sorts every Person in order of their birthday, starting with January 1.
307
311
  People with the same birthday are sorted by age, oldest to youngest.
@@ -311,7 +315,7 @@ class Query(object):
311
315
  from scratch.
312
316
 
313
317
  If an inequality filter exists in this Query it must be the first property
314
- passed to ``order()``. t.Any number of sort orders may be used after the
318
+ passed to ``order()``. Any number of sort orders may be used after the
315
319
  inequality filter property. Without inequality filters, any number of
316
320
  filters with different orders may be specified.
317
321
 
@@ -328,8 +332,9 @@ class Query(object):
328
332
  made to compare property values across types.
329
333
 
330
334
 
331
- :param orderings: The properties to sort by, in sort order.
332
- Each argument must be a (name, direction) 2-tuple.
335
+ :param orderings: The properties to sort by, in sort order. Each argument may be a
336
+ :class:`QueryOrder`, a ``(name, direction)`` tuple, or a plain ``str`` (implies
337
+ ``SortOrder.Ascending``).
333
338
  :returns: Returns the query itself for chaining.
334
339
  """
335
340
  if self.queries is None:
@@ -340,12 +345,20 @@ class Query(object):
340
345
  orders = []
341
346
  for order in orderings:
342
347
  if isinstance(order, str):
343
- order = (order, SortOrder.Ascending)
344
-
345
- if not (isinstance(order[0], str) and isinstance(order[1], SortOrder)):
348
+ order = QueryOrder(order)
349
+ elif isinstance(order, QueryOrder):
350
+ pass
351
+ elif (
352
+ isinstance(order, (tuple, list)) and
353
+ len(order) == 2 and
354
+ isinstance(order[0], str) and isinstance(order[1], SortOrder)
355
+ ):
356
+ order = QueryOrder(order[0], order[1])
357
+ else:
346
358
  raise TypeError(
347
- f"Invalid ordering {order}, it has to be a tuple. Try: `(\"{order}\", SortOrder.Ascending)`")
348
-
359
+ f"Invalid ordering {order!r}, expected a (str, SortOrder) tuple or QueryOrder."
360
+ f' Try: `QueryOrder("{order}")`'
361
+ )
349
362
  orders.append(order)
350
363
 
351
364
  if self._orderHook is not None:
@@ -445,7 +458,7 @@ class Query(object):
445
458
  q = self.queries[0]
446
459
  return base64.urlsafe_b64encode(q.currentCursor).decode("ASCII") if q.currentCursor else None
447
460
 
448
- def get_orders(self) -> t.List[t.Tuple[str, SortOrder]] | None:
461
+ def get_orders(self) -> t.List[QueryOrder] | None:
449
462
  """
450
463
  Get the orders from this query.
451
464
 
@@ -502,10 +515,10 @@ class Query(object):
502
515
  return self._resort_result(res, {}, self.queries[0].orders)
503
516
 
504
517
  def _resort_result(
505
- self,
506
- entities: t.List[Entity],
507
- filters: t.Dict[str, DATASTORE_BASE_TYPES],
508
- orders: t.List[t.Tuple[str, SortOrder]],
518
+ self,
519
+ entities: t.List[Entity],
520
+ filters: t.Dict[str, DATASTORE_BASE_TYPES],
521
+ orders: t.List[QueryOrder],
509
522
  ) -> t.List[Entity]:
510
523
  """
511
524
  Internal helper that takes a (deduplicated) list of entities that has been fetched from different internal
@@ -550,8 +563,8 @@ class Query(object):
550
563
  if "<" in end or ">" in end:
551
564
  ineqFilter = k.split(" ")[0]
552
565
  break
553
- if ineqFilter and (not orders or not orders[0][0] == ineqFilter):
554
- orders = [(ineqFilter, SortOrder.Ascending)] + (orders or [])
566
+ if ineqFilter and (not orders or not orders[0].name == ineqFilter):
567
+ orders = [QueryOrder(ineqFilter)] + (orders or [])
555
568
 
556
569
  for orderField, direction in orders[::-1]:
557
570
  if orderField == KEY_SPECIAL_PROPERTY:
@@ -572,10 +585,10 @@ class Query(object):
572
585
  """
573
586
  resultList = list(resultList)
574
587
  if (
575
- resultList
576
- and resultList[0].key.kind != self.origKind
577
- and resultList[0].key.parent
578
- and resultList[0].key.parent.kind == self.origKind
588
+ resultList
589
+ and resultList[0].key.kind != self.origKind
590
+ and resultList[0].key.parent
591
+ and resultList[0].key.parent.kind == self.origKind
579
592
  ):
580
593
  return list(get(list(dict.fromkeys([x.key.parent for x in resultList]))))
581
594
 
@@ -207,14 +207,12 @@ def run_single_filter(query: QueryDefinition, limit: int, keys_only: bool) -> t.
207
207
 
208
208
  if query.orders:
209
209
  hasInvertedOrderings = any(
210
- [
211
- x[1] in [SortOrder.InvertedAscending, SortOrder.InvertedDescending]
212
- for x in query.orders
213
- ]
210
+ order.order in (SortOrder.InvertedAscending, SortOrder.InvertedDescending)
211
+ for order in query.orders
214
212
  )
215
213
  qry.order = [
216
- x[0] if x[1] in [SortOrder.Ascending, SortOrder.InvertedDescending] else f"-{x[0]}"
217
- for x in query.orders
214
+ order.name if order.order in (SortOrder.Ascending, SortOrder.InvertedDescending) else f"-{order.name}"
215
+ for order in query.orders
218
216
  ]
219
217
 
220
218
  if query.distinct:
@@ -36,6 +36,12 @@ class SortOrder(enum.Enum):
36
36
  """Fetch A->Z, then flip the results (useful in pagination)"""
37
37
 
38
38
 
39
+ class QueryOrder(t.NamedTuple):
40
+ """A named tuple describing a single sort order for a datastore query."""
41
+ name: str
42
+ order: SortOrder = SortOrder.Ascending
43
+
44
+
39
45
  class Key(Datastore_key):
40
46
  """
41
47
  The python representation of one datastore key. Unlike the original implementation, we don't store a
@@ -165,7 +171,7 @@ KeyType: t.TypeAlias = Key | str | int
165
171
  Alias that describes a key-type.
166
172
  """
167
173
 
168
- TOrders: t.TypeAlias = list[tuple[str, SortOrder]]
174
+ TOrders: t.TypeAlias = list[QueryOrder]
169
175
  TFilters: t.TypeAlias = dict[str, DATASTORE_BASE_TYPES | list[DATASTORE_BASE_TYPES]]
170
176
 
171
177
 
@@ -748,7 +748,8 @@ class File(Tree):
748
748
  return ""
749
749
 
750
750
  if isinstance(file, str):
751
- file = db.Query("file").filter("dlkey =", file).order(("creationdate", db.SortOrder.Ascending)).getEntry()
751
+ file = db.Query("file").filter("dlkey =", file).order(
752
+ db.QueryOrder("creationdate")).getEntry()
752
753
 
753
754
  if not file:
754
755
  return ""
@@ -120,6 +120,11 @@ class Script(Tree):
120
120
  super().onEdit(skelType, skel)
121
121
 
122
122
  def onEdited(self, skelType, skel):
123
+ old_path = skel["path"]
124
+ self.update_path(skel)
125
+ if skel["path"] != old_path:
126
+ skel.patch({"path": skel["path"]})
127
+
123
128
  if skelType == "node":
124
129
  self.update_path_recursive("node", skel["path"], skel["key"])
125
130
  self.update_path_recursive("leaf", skel["path"], skel["key"])
@@ -158,9 +163,9 @@ class Script(Tree):
158
163
  if not parent_skel.read(key) or parent_skel["key"] == skel["parentrepo"]:
159
164
  break
160
165
 
161
- path.insert(0, parent_skel["name"])
166
+ if parent_skel["name"]:
167
+ path.insert(0, parent_skel["name"])
162
168
  key = parent_skel["parententry"]
163
-
164
169
  skel["path"] = "/".join(path)
165
170
 
166
171
  @exposed
@@ -10,7 +10,7 @@ from viur.core.decorators import exposed
10
10
  from viur.core.bones import *
11
11
  from viur.core.i18n import KINDNAME, initializeTranslations, systemTranslations, translate
12
12
  from viur.core.prototypes.list import List
13
- from viur.core.skeleton import Skeleton, ViurTagsSearchAdapter
13
+ from viur.core.skeleton import Skeleton, SkeletonInstance, ViurTagsSearchAdapter
14
14
 
15
15
 
16
16
  class Creator(enum.Enum):
@@ -211,7 +211,7 @@ class Translation(List):
211
211
  "admin": "*",
212
212
  }
213
213
 
214
- def addSkel(self):
214
+ def addSkel(self) -> SkeletonInstance["TranslationSkel"]:
215
215
  """
216
216
  Returns a custom TranslationSkel where the name is editable.
217
217
  The name becomes part of the key.
@@ -557,7 +557,7 @@ class UserPassword(UserPrimaryAuthentication):
557
557
  def canAdd(self) -> bool:
558
558
  return self.registrationEnabled
559
559
 
560
- def addSkel(self):
560
+ def addSkel(self) -> skeleton.SkeletonInstance["UserSkel"]:
561
561
  """
562
562
  Prepare the add-Skel for rendering.
563
563
  Currently only calls self._user_module.addSkel() and sets skel["status"] depending on
@@ -1350,7 +1350,7 @@ class User(List):
1350
1350
 
1351
1351
  return ret
1352
1352
 
1353
- def addSkel(self):
1353
+ def addSkel(self) -> skeleton.SkeletonInstance["UserSkel"]:
1354
1354
  skel = super().addSkel().clone()
1355
1355
 
1356
1356
  if self.is_admin(current.user.get()):
@@ -1377,7 +1377,7 @@ class User(List):
1377
1377
  skel.name.readOnly = False # Don't enforce readonly name in user/add
1378
1378
  return skel
1379
1379
 
1380
- def editSkel(self, *args, **kwargs):
1380
+ def editSkel(self, *args, **kwargs) -> skeleton.SkeletonInstance["UserSkel"]:
1381
1381
  skel = super().editSkel().clone()
1382
1382
 
1383
1383
  if "password" in skel: