viur-core 3.9.0.dev3__tar.gz → 3.9.0.dev4__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 (130) hide show
  1. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/PKG-INFO +1 -1
  2. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/__init__.py +3 -0
  3. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/bones/__init__.py +5 -0
  4. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/bones/base.py +25 -2
  5. viur_core-3.9.0.dev4/src/viur/core/bones/code.py +95 -0
  6. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/bones/email.py +4 -0
  7. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/bones/relational.py +1 -1
  8. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/bones/string.py +4 -3
  9. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/config.py +3 -0
  10. viur_core-3.9.0.dev4/src/viur/core/contrib/__init__.py +43 -0
  11. viur_core-3.9.0.dev4/src/viur/core/contrib/loginkey.py +115 -0
  12. viur_core-3.9.0.dev4/src/viur/core/contrib/ratelimit.py +123 -0
  13. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/db/cache.py +1 -3
  14. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/db/transport.py +33 -8
  15. viur_core-3.9.0.dev4/src/viur/core/modules/email.py +98 -0
  16. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/modules/file.py +2 -2
  17. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/modules/script.py +1 -3
  18. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/modules/user.py +3 -3
  19. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/render/vi/__init__.py +25 -2
  20. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/request.py +55 -0
  21. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/session.py +28 -0
  22. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/skeleton/skeleton.py +4 -0
  23. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/skeleton/tasks.py +2 -8
  24. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/utils/__init__.py +4 -1
  25. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/version.py +1 -1
  26. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur_core.egg-info/PKG-INFO +1 -1
  27. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur_core.egg-info/SOURCES.txt +7 -0
  28. viur_core-3.9.0.dev4/tests/test_decorators.py +271 -0
  29. viur_core-3.9.0.dev4/tests/test_errors.py +122 -0
  30. viur_core-3.9.0.dev4/tests/test_utils.py +299 -0
  31. viur_core-3.9.0.dev3/tests/test_utils.py +0 -156
  32. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/LICENSE +0 -0
  33. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/README.md +0 -0
  34. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/pyproject.toml +0 -0
  35. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/setup.cfg +0 -0
  36. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/bones/boolean.py +0 -0
  37. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/bones/captcha.py +0 -0
  38. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/bones/color.py +0 -0
  39. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/bones/credential.py +0 -0
  40. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/bones/date.py +0 -0
  41. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/bones/file.py +0 -0
  42. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/bones/image.py +0 -0
  43. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/bones/json.py +0 -0
  44. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/bones/key.py +0 -0
  45. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/bones/numeric.py +0 -0
  46. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/bones/password.py +0 -0
  47. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/bones/phone.py +0 -0
  48. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/bones/randomslice.py +0 -0
  49. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/bones/raw.py +0 -0
  50. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/bones/record.py +0 -0
  51. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/bones/select.py +0 -0
  52. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/bones/selectcountry.py +0 -0
  53. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/bones/sortindex.py +0 -0
  54. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/bones/spam.py +0 -0
  55. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/bones/spatial.py +0 -0
  56. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/bones/text.py +0 -0
  57. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/bones/treeleaf.py +0 -0
  58. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/bones/treenode.py +0 -0
  59. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/bones/uid.py +0 -0
  60. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/bones/uri.py +0 -0
  61. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/bones/user.py +0 -0
  62. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/cache.py +0 -0
  63. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/current.py +0 -0
  64. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/db/__init__.py +0 -0
  65. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/db/config.py +0 -0
  66. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/db/overrides.py +0 -0
  67. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/db/query.py +0 -0
  68. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/db/types.py +0 -0
  69. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/db/utils.py +0 -0
  70. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/decorators.py +0 -0
  71. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/email.py +0 -0
  72. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/errors.py +0 -0
  73. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/i18n.py +0 -0
  74. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/languages/__init__.py +0 -0
  75. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/languages/de.py +0 -0
  76. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/languages/en.py +0 -0
  77. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/logging.py +0 -0
  78. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/module.py +0 -0
  79. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/modules/__init__.py +0 -0
  80. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/modules/formmailer.py +0 -0
  81. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/modules/history.py +0 -0
  82. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/modules/moduleconf.py +0 -0
  83. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/modules/page.py +0 -0
  84. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/modules/site.py +0 -0
  85. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/modules/translation.py +0 -0
  86. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/pagination.py +0 -0
  87. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/prototypes/__init__.py +0 -0
  88. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/prototypes/instanced_module.py +0 -0
  89. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/prototypes/list.py +0 -0
  90. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/prototypes/singleton.py +0 -0
  91. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/prototypes/skelmodule.py +0 -0
  92. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/prototypes/tree.py +0 -0
  93. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/ratelimit.py +0 -0
  94. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/render/__init__.py +0 -0
  95. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/render/abstract.py +0 -0
  96. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/render/html/__init__.py +0 -0
  97. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/render/html/default.py +0 -0
  98. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/render/html/env/__init__.py +0 -0
  99. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/render/html/env/date.py +0 -0
  100. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/render/html/env/debug.py +0 -0
  101. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/render/html/env/regex.py +0 -0
  102. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/render/html/env/session.py +0 -0
  103. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/render/html/env/strings.py +0 -0
  104. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/render/html/env/tests.py +0 -0
  105. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/render/html/env/viur.py +0 -0
  106. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/render/html/utils.py +0 -0
  107. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/render/json/__init__.py +0 -0
  108. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/render/json/default.py +0 -0
  109. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/scripts/viur_migrate.py +0 -0
  110. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/secret.py +0 -0
  111. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/securityheaders.py +0 -0
  112. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/securitykey.py +0 -0
  113. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/skeleton/__init__.py +0 -0
  114. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/skeleton/adapter.py +0 -0
  115. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/skeleton/instance.py +0 -0
  116. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/skeleton/meta.py +0 -0
  117. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/skeleton/relskel.py +0 -0
  118. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/skeleton/utils.py +0 -0
  119. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/tasks.py +0 -0
  120. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/template/error.html +0 -0
  121. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/template/vi_user_google_login.html +0 -0
  122. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/utils/json.py +0 -0
  123. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/utils/parse.py +0 -0
  124. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur/core/utils/string.py +0 -0
  125. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur_core.egg-info/dependency_links.txt +0 -0
  126. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur_core.egg-info/entry_points.txt +0 -0
  127. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur_core.egg-info/requires.txt +0 -0
  128. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/src/viur_core.egg-info/top_level.txt +0 -0
  129. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/tests/test_config.py +0 -0
  130. {viur_core-3.9.0.dev3 → viur_core-3.9.0.dev4}/tests/test_db.py +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.dev4
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>
@@ -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,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
 
@@ -1107,7 +1107,7 @@ class RelationalBone(BaseBone):
1107
1107
 
1108
1108
  ref_skel_cache, using_skel_cache = self._getSkels()
1109
1109
  for idx, lang, value in self.iter_bone_value(skel, name):
1110
- if value is None:
1110
+ if not value:
1111
1111
  continue
1112
1112
  if value["dest"]:
1113
1113
  get_values(ref_skel_cache, value["dest"])
@@ -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)
@@ -794,6 +794,9 @@ class Conf(ConfigType):
794
794
  bone_boolean_str2true: Multiple[str | int] = ("true", "yes", "1")
795
795
  """Allowed values that define a str to evaluate to true"""
796
796
 
797
+ bone_string_escape_html: bool = True
798
+ """Default escape_html setting for StringBone. Set to False to disable HTML escaping globally."""
799
+
797
800
  bone_html_default_allow: "HtmlBoneConfiguration" = {
798
801
  "validTags": [
799
802
  "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
+ """
@@ -0,0 +1,115 @@
1
+ """
2
+ Token-based ("magic link") primary authentication for ViUR user modules.
3
+
4
+ ``LoginKey`` authenticates a user by a secret token stored as an indexed
5
+ ``CredentialBone`` on the user skeleton. The caller submits the token as a
6
+ POST parameter; the handler looks up the matching user, validates the account
7
+ state, and completes the authentication flow.
8
+
9
+ Typical use cases include magic-link email logins, CLI tool authentication,
10
+ and service-to-service auth where a shared secret is acceptable.
11
+
12
+ Usage::
13
+
14
+ from viur.core.modules.user import User
15
+ from viur.core.contrib.loginkey import LoginKey
16
+
17
+ class MyUser(User):
18
+ authenticationProviders = [LoginKey, ...]
19
+
20
+ .. warning::
21
+ An indexed :class:`~viur.core.bones.CredentialBone` allows any caller
22
+ with Datastore read access to enumerate users by key. Only deploy this
23
+ in environments where that access is appropriately restricted, and always
24
+ use long (≥ 32 char), randomly generated tokens.
25
+ """
26
+ import logging
27
+
28
+ from viur.core import current, errors
29
+ from viur.core.bones import CredentialBone
30
+ from viur.core.decorators import exposed, force_post, force_ssl, skey
31
+ from viur.core.modules.user import Status, UserPrimaryAuthentication
32
+ from viur.core.ratelimit import RateLimit
33
+ from viur.core.skeleton import SkeletonInstance
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+
38
+ class IndexedCredentialBone(CredentialBone):
39
+ """A :class:`~viur.core.bones.CredentialBone` that is always Datastore-indexed.
40
+
41
+ Regular ``CredentialBone`` values are excluded from indexes for security.
42
+ This subclass forces indexing so that the value can be used as a filter
43
+ criterion (e.g. ``filter("login_key =", token)``).
44
+
45
+ .. note::
46
+ Accepting an indexed credential is a deliberate trade-off: it enables
47
+ server-side token lookup at the cost of exposing the value to anyone
48
+ with Datastore read access. Only use this when that trade-off is
49
+ explicitly acceptable.
50
+ """
51
+
52
+ def serialize(self, skel: "SkeletonInstance", name: str, parentIndexed: bool) -> bool:
53
+ skel.dbEntity.exclude_from_indexes.discard(name) # force index even though it's a credential
54
+ if name in skel.accessedValues and skel.accessedValues[name]:
55
+ skel.dbEntity[name] = skel.accessedValues[name]
56
+ return True
57
+ return False
58
+
59
+
60
+ class LoginKey(UserPrimaryAuthentication):
61
+ """Primary authentication via a secret login token.
62
+
63
+ The token is stored in a ``login_key`` bone on the user skeleton
64
+ (added automatically by :meth:`patch_user_skel`). Failed attempts are
65
+ rate-limited per IP address; successful logins are *not* counted against
66
+ the quota.
67
+
68
+ :cvar METHOD_NAME: HTTP header name used to identify this auth method.
69
+ :cvar loginRateLimit: Allows 12 failed attempts per minute per IP.
70
+ """
71
+
72
+ METHOD_NAME = "X-AUTH-LOGINKEY"
73
+ NAME = "LoginKey"
74
+
75
+ # 12 failed attempts per minute, IP-based
76
+ loginRateLimit = RateLimit("user.loginkey", 12, 1, "ip")
77
+
78
+ @classmethod
79
+ def patch_user_skel(cls, skel_cls):
80
+ skel_cls.login_key = IndexedCredentialBone(
81
+ descr="LoginKey",
82
+ params={"category": "Authentication"},
83
+ min_length=32,
84
+ )
85
+
86
+ @exposed
87
+ @force_ssl
88
+ @force_post
89
+ @skey()
90
+ def login(self, *, key: str, **kwargs):
91
+ if current.user.get():
92
+ return self._user_module.render.loginSucceeded()
93
+
94
+ self.loginRateLimit.assertQuotaIsAvailable()
95
+
96
+ user_skel = self._user_module.baseSkel()
97
+ user_skel = user_skel.all().filter("login_key =", key).getSkel()
98
+
99
+ is_okay = user_skel is not None
100
+ logger.debug(f"user found: {is_okay=}")
101
+
102
+ is_okay = is_okay and (user_skel["status"] or 0) >= Status.ACTIVE.value
103
+ logger.debug(f"account active: {is_okay=}")
104
+
105
+ is_okay = is_okay and len(str(user_skel.dbEntity["login_key"])) >= 32
106
+ logger.debug(f"key length ok: {is_okay=}")
107
+
108
+ is_okay = is_okay and ("root" not in user_skel["access"])
109
+ logger.debug(f"not root: {is_okay=}")
110
+
111
+ if not is_okay:
112
+ self.loginRateLimit.decrementQuota() # only failed attempts count
113
+ raise errors.Unauthorized()
114
+
115
+ return self.next_or_finish(user_skel)
@@ -0,0 +1,123 @@
1
+ """
2
+ Request-level rate limiter using App Engine Memcache.
3
+
4
+ Registers as a :class:`~viur.core.request.RequestValidator` and therefore
5
+ runs *before* any session, routing, or handler logic — making it the
6
+ earliest possible place to shed excess traffic.
7
+
8
+ Guests are identified by their IP address (IPv6 addresses are bucketed into
9
+ /64 prefixes so that a single host cannot trivially rotate around the limit).
10
+ Authenticated users are identified by their Datastore user key.
11
+
12
+ Usage::
13
+
14
+ from viur.core.request import Router
15
+ from viur.core.contrib.ratelimit import RequestRateLimit, TimeWindow
16
+
17
+ Router.requestValidators.append(
18
+ RequestRateLimit(
19
+ rate_for_guests=TimeWindow(limit=200, time_window=60),
20
+ rate_for_users=TimeWindow(limit=500, time_window=60),
21
+ )
22
+ )
23
+ """
24
+ import dataclasses
25
+ import ipaddress
26
+ import logging
27
+ import time
28
+ import typing as t
29
+
30
+ from google.appengine.api.memcache import Client
31
+
32
+ from viur.core import current
33
+ from viur.core.request import RequestValidator, Router
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+ _memcache = Client()
38
+
39
+
40
+ @dataclasses.dataclass(frozen=True)
41
+ class TimeWindow:
42
+ """Rate-limit budget for a single time window.
43
+
44
+ :param limit: Maximum number of requests allowed within *time_window*.
45
+ :param time_window: Length of the window in seconds.
46
+ """
47
+ limit: int
48
+ time_window: int
49
+
50
+
51
+ class RequestRateLimit(RequestValidator):
52
+ """Global HTTP request rate limiter.
53
+
54
+ Enforces separate budgets for anonymous (guest) and authenticated
55
+ requests. When the budget is exceeded the validator returns HTTP 429
56
+ and sets the ``Retry-After`` header so clients know when to retry.
57
+
58
+ :param rate_for_guests: Budget applied to unauthenticated requests.
59
+ :param rate_for_users: Budget applied to authenticated requests.
60
+ :param namespace: Memcache namespace used for all rate-limit keys.
61
+ """
62
+
63
+ name = "RequestRateLimit"
64
+
65
+ def __init__(
66
+ self,
67
+ rate_for_guests: TimeWindow = TimeWindow(limit=1000, time_window=60),
68
+ rate_for_users: TimeWindow = TimeWindow(limit=2000, time_window=60),
69
+ namespace: str = "viur_rate_limit",
70
+ ):
71
+ self.rate_for_guests = rate_for_guests
72
+ self.rate_for_users = rate_for_users
73
+ self.namespace = namespace
74
+
75
+ def validate(self, request: "Router") -> t.Optional[tuple[int, str, str]]:
76
+ if request.is_deferred:
77
+ return None # Task Queue requests are always allowed
78
+
79
+ if user := current.user.get():
80
+ client_id = str(user["key"])
81
+ rate = self.rate_for_users
82
+ else:
83
+ client_id = self._get_request_ip()
84
+ rate = self.rate_for_guests
85
+
86
+ current_time = time.time() / rate.time_window
87
+ key = f"rate_limit:{client_id}:{int(current_time)}"
88
+
89
+ count = _memcache.get(key, namespace=self.namespace)
90
+ logger.debug(f"rate limit check: {client_id=} {count=} limit={rate.limit}")
91
+
92
+ if count is None:
93
+ _memcache.add(key, 1, time=rate.time_window, namespace=self.namespace)
94
+ return None
95
+
96
+ if count < rate.limit:
97
+ _memcache.incr(key, initial_value=1, namespace=self.namespace)
98
+ return None
99
+
100
+ # Budget exhausted — tell the client when the current window expires.
101
+ seconds_into_window = (current_time - int(current_time)) * rate.time_window
102
+ retry_after = int(rate.time_window - seconds_into_window)
103
+ request.response.headers["Retry-After"] = str(retry_after)
104
+ return 429, "Too Many Requests", "Too Many Requests. Please try again later."
105
+
106
+ @staticmethod
107
+ def _get_request_ip() -> str:
108
+ """Return a stable client identifier derived from the remote address.
109
+
110
+ IPv4 addresses are returned as-is. For IPv6 the /64 network prefix
111
+ is returned so that a single host cannot trivially rotate its
112
+ interface identifier to bypass the limit.
113
+ """
114
+ raw = current.request.get().request.remote_addr
115
+ ip = ipaddress.ip_address(raw)
116
+
117
+ if isinstance(ip, ipaddress.IPv4Address):
118
+ return str(ip)
119
+
120
+ if isinstance(ip, ipaddress.IPv6Address):
121
+ return str(ipaddress.IPv6Network((ip, 64), strict=False))
122
+
123
+ raise NotImplementedError(f"Unsupported IP version: {ip!r}")
@@ -1,7 +1,4 @@
1
1
  import datetime
2
-
3
- from google.appengine.ext.testbed import Testbed
4
-
5
2
  import logging
6
3
  import sys
7
4
  import typing as t
@@ -169,6 +166,7 @@ def check_for_memcache() -> bool:
169
166
  def init_testbed() -> None:
170
167
  global TESTBED
171
168
  if TESTBED is None and conf.instance.is_dev_server and conf.db.memcache_client:
169
+ from google.appengine.ext.testbed import Testbed
172
170
  TESTBED = Testbed()
173
171
  TESTBED.activate()
174
172
  TESTBED.init_memcache_stub()
@@ -49,9 +49,15 @@ def get(keys: t.Union[Key, t.List[Key]]) -> t.Union[t.List[Entity], Entity, None
49
49
  if isinstance(keys, (list, set, tuple)):
50
50
  res_list = list(__client__.get_multi(keys))
51
51
  res_list.sort(key=lambda k: keys.index(k.key) if k else -1)
52
+ if conf.debug.trace_queries:
53
+ found = sum(1 for r in res_list if r is not None)
54
+ logging.info(f"db.get: {found}/{len(keys)} entities found")
52
55
  return res_list
53
56
 
54
- return __client__.get(keys)
57
+ res = __client__.get(keys)
58
+ if conf.debug.trace_queries:
59
+ logging.info(f"db.get({keys}): {'found' if res is not None else 'not found'}")
60
+ return res
55
61
 
56
62
 
57
63
  @deprecated(version="3.8.0", reason="Use 'db.get' instead")
@@ -67,9 +73,15 @@ def put(entities: t.Union[Entity, t.List[Entity]]):
67
73
  """
68
74
  _write_to_access_log(entities)
69
75
  if isinstance(entities, Entity):
70
- return __client__.put(entities)
71
-
72
- return __client__.put_multi(entities=entities)
76
+ res = __client__.put(entities)
77
+ if conf.debug.trace_queries:
78
+ logging.info(f"db.put: saved {entities.key}")
79
+ return res
80
+
81
+ res = __client__.put_multi(entities=entities)
82
+ if conf.debug.trace_queries:
83
+ logging.info(f"db.put: saved {len(entities)} entities")
84
+ return res
73
85
 
74
86
 
75
87
  @deprecated(version="3.8.0", reason="Use 'db.put' instead")
@@ -82,12 +94,17 @@ def delete(keys: t.Union[Entity, t.List[Entity], Key, t.List[Key]]):
82
94
  Deletes the entities with the given key(s) from the datastore.
83
95
  :param keys: A Key (or a t.List of Keys) to delete
84
96
  """
85
-
86
97
  _write_to_access_log(keys)
87
98
  if not isinstance(keys, (set, list, tuple)):
88
- return __client__.delete(keys)
89
-
90
- return __client__.delete_multi(keys)
99
+ res = __client__.delete(keys)
100
+ if conf.debug.trace_queries:
101
+ logging.info(f"db.delete: deleted {keys}")
102
+ return res
103
+
104
+ res = __client__.delete_multi(keys)
105
+ if conf.debug.trace_queries:
106
+ logging.info(f"db.delete: deleted {len(keys)} keys")
107
+ return res
91
108
 
92
109
 
93
110
  @deprecated(version="3.8.0", reason="Use 'db.delete' instead")
@@ -212,6 +229,14 @@ def run_single_filter(query: QueryDefinition, limit: int, keys_only: bool) -> t.
212
229
  query.currentCursor = qryRes.next_page_token
213
230
  if hasInvertedOrderings:
214
231
  res.reverse()
232
+
233
+ if conf.debug.trace_queries:
234
+ distinct_on = f" distinct on {query.distinct}" if query.distinct else ""
235
+ logging.debug(
236
+ f"Queried {query.kind} with filter {query.filters} and orders {query.orders}{distinct_on}."
237
+ f" Returned {len(res)} results"
238
+ )
239
+
215
240
  return res
216
241
 
217
242