Coverage for /home/runner/work/viur-core/viur-core/viur/src/viur/core/config.py: 88%
375 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-13 11:04 +0000
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-13 11:04 +0000
1import datetime
2import hashlib
3import logging
4import os
5import re
6import typing as t
7import warnings
8from pathlib import Path
10import google.auth
11from google.appengine.api.memcache import Client
13from viur.core.version import __version__
14from viur.core.current import user as current_user
16if t.TYPE_CHECKING: # pragma: no cover
17 from viur.core.bones.text import HtmlBoneConfiguration
18 from viur.core.email import EmailTransport
19 from viur.core.skeleton import SkeletonInstance
20 from viur.core.module import Module
21 from viur.core.tasks import CustomEnvironmentHandler
22 from viur.core import i18n
24# Construct an alias with a generic type to be able to write Multiple[str]
25# TODO: Backward compatible implementation, refactor when viur-core
26# becomes >= Python 3.12 with a type statement (PEP 695)
27_T = t.TypeVar("_T")
28Multiple: t.TypeAlias = list[_T] | tuple[_T] | set[_T] | frozenset[_T] # TODO: Refactor for Python 3.12
31class CaptchaDefaultCredentialsType(t.TypedDict):
32 """Expected type of global captcha credential, see :attr:`Security.captcha_default_credentials`"""
33 sitekey: str
34 secret: str
37class ConfigType:
38 """An abstract class for configurations.
40 It ensures nesting and backward compatibility for the viur-core config
41 """
42 _mapping = {}
43 """Mapping from old dict-key (must not be the entire key in case of nesting) to new attribute name"""
45 _strict_mode = None
46 """Internal strict mode for this instance.
48 Use the property getter and setter to access it!"""
50 _parent = None
51 """Parent config instance"""
53 def __init__(self, *,
54 strict_mode: bool = None,
55 parent: t.Union["ConfigType", None] = None):
56 super().__init__()
57 self._strict_mode = strict_mode
58 self._parent = parent
60 @property
61 def _path(self):
62 """Get the path in dot-Notation to the current config instance."""
63 if self._parent is None:
64 return ""
65 return f"{self._parent._path}{self.__class__.__name__.lower()}."
67 @property
68 def strict_mode(self):
69 """Determine if the config runs in strict mode.
71 In strict mode, the dict-item-access backward compatibility is disabled,
72 only attribute access is allowed.
73 Alias mapping is also disabled. Only the real attribute names are allowed.
75 If self._strict_mode is None, it would inherit the value
76 of the parent.
77 If it's explicitly set to True or False, that value will be used.
78 """
79 if self._strict_mode is not None or self._parent is None:
80 # This config has an explicit value set or there's no parent
81 return self._strict_mode
82 else:
83 # no value set: inherit from the parent
84 return self._parent.strict_mode
86 @strict_mode.setter
87 def strict_mode(self, value: bool | None) -> None:
88 """Setter for the strict mode of the current instance.
90 Does not affect other instances!
91 """
92 if not isinstance(value, (bool, type(None))):
93 raise TypeError(f"Invalid {value=} for strict mode!")
94 self._strict_mode = value
96 def _resolve_mapping(self, key: str) -> str:
97 """Resolve the mapping old dict -> new attribute.
99 This method must not be called in strict mode!
100 It can be overwritten to apply additional mapping.
101 """
102 if key in self._mapping:
103 old, key = key, self._mapping[key]
104 warnings.warn(
105 f"Conf member {self._path}{old} is now {self._path}{key}!",
106 DeprecationWarning,
107 stacklevel=3,
108 )
109 return key
111 def items(self,
112 full_path: bool = False,
113 recursive: bool = True,
114 ) -> t.Iterator[tuple[str, t.Any]]:
115 """Get all setting of this config as key-value mapping.
117 :param full_path: Show prefix oder only the key.
118 :param recursive: Call .items() on ConfigType members (children)?
119 :return:
120 """
121 for key in dir(self):
122 if key.startswith("_"):
123 # skip internals, like _parent and _strict_mode
124 continue
125 value = getattr(self, key)
126 if recursive and isinstance(value, ConfigType):
127 yield from value.items(full_path, recursive)
128 elif key not in dir(ConfigType):
129 if full_path: 129 ↛ 130line 129 didn't jump to line 130 because the condition on line 129 was never true
130 yield f"{self._path}{key}", value
131 else:
132 yield key, value
134 def get(self, key: str, default: t.Any = None) -> t.Any:
135 """Return an item from the config, if it doesn't exist `default` is returned.
137 :param key: The key for the attribute lookup.
138 :param default: The fallback value.
139 :return: The attribute value or the fallback value.
140 """
141 if self.strict_mode:
142 raise SyntaxError(
143 "In strict mode, the config must not be accessed "
144 "with .get(). Only attribute access is allowed."
145 )
146 try:
147 return getattr(self, key)
148 except (KeyError, AttributeError):
149 return default
151 def __getitem__(self, key: str) -> t.Any:
152 """Support the old dict-like syntax (getter).
154 Not allowed in strict mode.
155 """
156 new_path = f"{self._path}{self._resolve_mapping(key)}"
157 warnings.warn(f"conf uses now attributes! "
158 f"Use conf.{new_path} to access your option",
159 DeprecationWarning,
160 stacklevel=2)
162 if self.strict_mode:
163 raise SyntaxError(
164 f"In strict mode, the config must not be accessed "
165 f"with dict notation. "
166 f"Only attribute access (conf.{new_path}) is allowed."
167 )
169 return getattr(self, key)
171 def __getattr__(self, key: str) -> t.Any:
172 """Resolve dot-notation and name mapping in not strict mode.
174 This method is mostly executed by __getitem__, by the
175 old dict-like access or by attr(conf, "key").
176 In strict mode it does nothing except raising an AttributeError.
177 """
178 if self.strict_mode:
179 raise AttributeError(
180 f"AttributeError: '{self.__class__.__name__}' object has no"
181 f" attribute '{key}' (strict mode is enabled)"
182 )
184 key = self._resolve_mapping(key)
186 # Got an old dict-key and resolve the segment to the first dot (.) as attribute.
187 if "." in key:
188 first, remaining = key.split(".", 1)
189 return getattr(getattr(self, first), remaining)
191 return super().__getattribute__(key)
193 def __setitem__(self, key: str, value: t.Any) -> None:
194 """Support the old dict-like syntax (setter).
196 Not allowed in strict mode.
197 """
198 new_path = f"{self._path}{self._resolve_mapping(key)}"
199 if self.strict_mode:
200 raise SyntaxError(
201 f"In strict mode, the config must not be accessed "
202 f"with dict notation. "
203 f"Only attribute access (conf.{new_path}) is allowed."
204 )
206 # TODO: re-enable?!
207 # Avoid to set conf values to something which is already the default
208 # if key in self and self[key] == value:
209 # msg = f"Setting conf[\"{key}\"] to {value!r} has no effect, as this value has already been set"
210 # warnings.warn(msg, stacklevel=3)
211 # logging.warning(msg, stacklevel=3)
212 # return
214 key = self._resolve_mapping(key)
216 # Got an old dict-key and resolve the segment to the first dot (.) as attribute.
217 if "." in key:
218 first, remaining = key.split(".", 1)
219 if not hasattr(self, first):
220 # TODO: Compatibility, remove it in a future major release!
221 # This segment doesn't exist. Create it
222 logging.warning(f"Creating new type for {first}")
223 setattr(self, first, type(first.capitalize(), (ConfigType,), {})())
224 getattr(self, first)[remaining] = value
225 return
227 return setattr(self, key, value)
229 def __setattr__(self, key: str, value: t.Any) -> None:
230 """Set attributes after applying the old -> new mapping
232 In strict mode it does nothing except a super call
233 for the default object behavior.
234 """
235 if self.strict_mode:
236 return super().__setattr__(key, value)
238 if not self.strict_mode: 238 ↛ 242line 238 didn't jump to line 242 because the condition on line 238 was always true
239 key = self._resolve_mapping(key)
241 # Got an old dict-key and resolve the segment to the first dot (.) as attribute.
242 if "." in key: 242 ↛ 244line 242 didn't jump to line 244 because the condition on line 242 was never true
243 # TODO: Shall we allow this in strict mode as well?
244 first, remaining = key.split(".", 1)
245 return setattr(getattr(self, first), remaining, value)
247 return super().__setattr__(key, value)
249 def __repr__(self) -> str:
250 """Representation of this config"""
251 return f"{self.__class__.__qualname__}({dict(self.items(False, False))})"
254# Some values used more than once below
255_project_id = google.auth.default()[1]
256_app_version = os.getenv("GAE_VERSION")
258# Determine our basePath (as os.getCWD is broken on appengine)
259_project_base_path = Path().absolute()
260_core_base_path = Path(__file__).parent.parent.parent # fixme: this points to site-packages!!!
263class Admin(ConfigType):
264 """Administration tool configuration"""
266 name: str = "ViUR"
267 """Administration tool configuration"""
269 logo: str = ""
270 """URL for the Logo in the Topbar of the VI"""
272 login_background: str = ""
273 """URL for the big Image in the background of the VI Login screen"""
275 login_logo: str = ""
276 """URL for the Logo over the VI Login screen"""
278 color_primary: str = "#d00f1c"
279 """primary color for viur-admin"""
281 color_secondary: str = "#333333"
282 """secondary color for viur-admin"""
284 module_groups: dict[str, dict[t.Literal["name", "icon", "sortindex"], str | int]] = {}
285 """Module Groups for the admin tool
287 Group modules in the sidebar in categories (groups).
289 Example:
290 conf.admin.module_groups = {
291 "content": {
292 "name": "Content",
293 "icon": "file-text-fill",
294 "sortindex": 10,
295 },
296 "shop": {
297 "name": "Shop",
298 "icon": "cart-fill",
299 "sortindex": 20,
300 },
301 }
303 To add a module to one of these groups (e.g. content), add `moduleGroup` to
304 the admin_info of the module:
305 "moduleGroup": "content",
306 """
308 _mapping: dict[str, str] = {
309 "login.background": "login_background",
310 "login.logo": "login_logo",
311 "color.primary": "color_primary",
312 "color.secondary": "color_secondary",
313 }
316class Database(ConfigType):
317 query_external_limit: int = 100
318 """Sets the maximum query limit allowed by external filters."""
320 query_default_limit: int = 30
321 """Sets the default query limit for all queries."""
323 memcache_client: Client | None = None
324 """If set, ViUR cache data for the db.get in the Memcache for faster access."""
326 create_access_log: bool = True
327 """If False no access log will be created. But then the caching is disabled too."""
330class Security(ConfigType):
331 """Security related settings"""
333 force_ssl: bool = True
334 """If true, all requests must be encrypted (ignored on development server)"""
336 no_ssl_check_urls: Multiple[str] = ["/_tasks*", "/ah/*"]
337 """List of URLs for which force_ssl is ignored.
338 Add an asterisk to mark that entry as a prefix (exact match otherwise)"""
340 content_security_policy: t.Optional[dict[str, dict[str, list[str]]]] = {
341 "enforce": {
342 "style-src": ["self", "https://accounts.google.com/gsi/style"],
343 "default-src": ["self"],
344 "img-src": ["self", "storage.googleapis.com"], # Serving-URLs of file-Bones will point here
345 "script-src": ["self", "https://accounts.google.com/gsi/client"],
346 # Required for login with Google
347 "frame-src": ["self", "www.google.com", "drive.google.com", "accounts.google.com"],
348 "form-action": ["self"],
349 "connect-src": ["self", "accounts.google.com"],
350 "upgrade-insecure-requests": [],
351 "object-src": ["none"],
352 }
353 }
354 """If set, viur will emit a CSP http-header with each request. Use security.addCspRule to set this property"""
356 referrer_policy: str = "strict-origin"
357 """Per default, we'll emit Referrer-Policy: strict-origin so no referrers leak to external services
359 See https://www.w3.org/TR/referrer-policy/
360 """
362 permissions_policy: dict[str, list[str]] = {
363 "autoplay": ["self"],
364 "camera": [],
365 "display-capture": [],
366 "document-domain": [],
367 "encrypted-media": [],
368 "fullscreen": [],
369 "geolocation": [],
370 "microphone": [],
371 "publickey-credentials-get": [],
372 "usb": [],
373 }
374 """Include a default permissions-policy.
375 To use the camera or microphone, you'll have to call
376 :meth: securityheaders.setPermissionPolicyDirective to include at least "self"
377 """
379 enable_coep: bool = False
380 """Shall we emit Cross-Origin-Embedder-Policy: require-corp?"""
382 enable_coop: t.Literal[
383 "unsafe-none", "same-origin-allow-popups",
384 "same-origin", "same-origin-plus-COEP"] = "same-origin"
385 """Emit a Cross-Origin-Opener-Policy Header?
387 See https://html.spec.whatwg.org/multipage/browsers.html#cross-origin-opener-policy-value
388 """
390 enable_corp: t.Literal["same-origin", "same-site", "cross-origin"] = "same-origin"
391 """Emit a Cross-Origin-Resource-Policy Header?
393 See https://fetch.spec.whatwg.org/#cross-origin-resource-policy-header
394 """
396 strict_transport_security: t.Optional[str] = "max-age=22118400"
397 """If set, ViUR will emit a HSTS HTTP-header with each request.
398 Use security.enableStrictTransportSecurity to set this property"""
400 x_frame_options: t.Optional[
401 tuple[t.Literal["deny", "sameorigin", "allow-from"], t.Optional[str]]
402 ] = ("sameorigin", None)
403 """If set, ViUR will emit an X-Frame-Options header
405 In case of allow-from, the second parameters must be the host-url.
406 Otherwise, it can be None.
407 """
409 x_xss_protection: t.Optional[bool] = True
410 """ViUR will emit an X-XSS-Protection header if set (the default)"""
412 x_content_type_options: bool = True
413 """ViUR will emit X-Content-Type-Options: nosniff Header unless set to False"""
415 x_permitted_cross_domain_policies: t.Optional[t.Literal["none", "master-only", "by-content-type", "all"]] = "none"
416 """Unless set to logical none; ViUR will emit a X-Permitted-Cross-Domain-Policies with each request"""
418 captcha_default_credentials: t.Optional[CaptchaDefaultCredentialsType] = None
419 """The default sitekey and secret to use for the :class:`CaptchaBone`.
420 If set, must be a dictionary of "sitekey" and "secret".
421 """
423 captcha_enforce_always: bool = False
424 """By default a captcha of the :class:`CaptchaBone` must not be solved on a local development server
425 or by a root user. But for development it can be helpful to test the implementation
426 on a local development server. Setting this flag to True, disables this behavior and
427 enforces always a valid captcha.
428 """
430 password_recovery_key_length: int = 42
431 """Length of the Password recovery key"""
433 closed_system: bool = False
434 """If `True` it activates a mode in which only authenticated users can access all routes."""
436 admin_allowed_paths: t.Iterable[str] = [
437 "vi",
438 "vi/skey",
439 "vi/settings",
440 "vi/user/auth_*",
441 "vi/user/f2_*",
442 "vi/user/getAuthMethods", # FIXME: deprecated, use `login` for this
443 "vi/user/login",
444 ]
445 """Specifies admin tool paths which are being accessible without authenticated user."""
447 closed_system_allowed_paths: t.Iterable[str] = admin_allowed_paths + [
448 "", # index site
449 "json/skey",
450 "json/user/auth_*",
451 "json/user/f2_*",
452 "json/user/getAuthMethods", # FIXME: deprecated, use `login` for this
453 "json/user/login",
454 "user/auth_*",
455 "user/f2_*",
456 "user/getAuthMethods", # FIXME: deprecated, use `login` for this
457 "user/login",
458 ]
459 """Paths that are accessible without authentication in a closed system, see `closed_system` for details."""
461 # CORS Settings
463 cors_origins: t.Iterable[str | re.Pattern] | t.Literal["*"] = []
464 """Allowed origins
465 Access-Control-Allow-Origin
467 Pattern should be case-insensitive, for example:
468 >>> re.compile(r"^http://localhost:(\d{4,5})/?$", flags=re.IGNORECASE)
469 """ # noqa
471 cors_origins_use_wildcard: bool = False
472 """Use * for Access-Control-Allow-Origin -- if possible"""
474 cors_methods: t.Iterable[str] = ["get", "head", "post", "options"] # , "put", "patch", "delete"]
475 """Access-Control-Request-Method"""
477 cors_allow_headers: t.Iterable[str | re.Pattern] | t.Literal["*"] = []
478 """Access-Control-Request-Headers
480 Can also be set for specific @exposed methods with the @cors decorator.
482 Pattern should be case-insensitive, for example:
483 >>> re.compile(r"^X-ViUR-.*$", flags=re.IGNORECASE)
484 """
486 cors_allow_credentials: bool = False
487 """
488 Set Access-Control-Allow-Credentials to true
489 to support fetch requests with credentials: include
490 """
492 cors_max_age: datetime.timedelta | None = None
493 """Allow caching"""
495 _mapping = {
496 "contentSecurityPolicy": "content_security_policy",
497 "referrerPolicy": "referrer_policy",
498 "permissionsPolicy": "permissions_policy",
499 "enableCOEP": "enable_coep",
500 "enableCOOP": "enable_coop",
501 "enableCORP": "enable_corp",
502 "strictTransportSecurity": "strict_transport_security",
503 "xFrameOptions": "x_frame_options",
504 "xXssProtection": "x_xss_protection",
505 "xContentTypeOptions": "x_content_type_options",
506 "xPermittedCrossDomainPolicies": "x_permitted_cross_domain_policies",
507 "captcha_defaultCredentials": "captcha_default_credentials",
508 "captcha.defaultCredentials": "captcha_default_credentials",
509 }
512class Debug(ConfigType):
513 """Several debug flags"""
515 trace: bool = False
516 """If enabled, trace any routing, HTTPExceptions and decorations for debugging and insight"""
518 trace_exceptions: bool = False
519 """If enabled, user-generated exceptions from the viur.core.errors module won't be caught and handled"""
521 trace_external_call_routing: bool = False
522 """If enabled, ViUR will log which (exposed) function are called from outside with what arguments"""
524 trace_internal_call_routing: bool = False
525 """If enabled, ViUR will log which (internal-exposed) function are called from templates with what arguments"""
527 trace_queries: bool = False
528 """If enabled, ViUR will log each query that run"""
530 skeleton_from_client: bool = False
531 """If enabled, log errors raises from skeleton.fromClient()"""
533 dev_server_cloud_logging: bool = False
534 """If disabled the local logging will not send with requestLogger to the cloud"""
536 disable_cache: bool = False
537 """If set to true, the decorator @enableCache from viur.core.cache has no effect"""
539 _mapping = {
540 "skeleton.fromClient": "skeleton_from_client",
541 "traceExceptions": "trace_exceptions",
542 "traceExternalCallRouting": "trace_external_call_routing",
543 "traceInternalCallRouting": "trace_internal_call_routing",
544 "skeleton_fromClient": "skeleton_from_client",
545 "disableCache": "disable_cache",
546 }
549class Email(ConfigType):
550 """Email related settings."""
552 log_retention: datetime.timedelta = datetime.timedelta(days=30)
553 """For how long we'll keep successfully send emails in the viur-emails table"""
555 transport_class: "EmailTransport" = None
556 """EmailTransport instance that actually delivers the email using the service provider
557 of choice. See :module:`core.email` for more details
558 """
560 send_from_local_development_server: bool = False
561 """If set, we'll enable sending emails from the local development server.
562 Otherwise, they'll just be logged.
563 """
565 recipient_override: str | list[str] | t.Callable[[], str | list[str]] | t.Literal[False] = None
566 """If set, all outgoing emails will be sent to this address
567 (overriding the 'dests'-parameter in :meth:`core.email.send_email`)
568 """
570 sender_default: str = f"viur@{_project_id}.appspotmail.com"
571 """This sender is used by default for emails.
572 It can be overridden for a specific email by passing the `sender` argument
573 to :meth:`core.email.send_email` or for all emails with :attr:`sender_override`.
574 """
576 sender_override: str | None = None
577 """If set, this sender will be used, regardless of what the templates advertise as sender"""
579 admin_recipients: str | list[str] | t.Callable[[], str | list[str]] = None
580 """Sets recipients for mails send with :meth:`core.email.send_email_to_admins`.
581 If not set, all root users will be used."""
583 _mapping = {
584 "logRetention": "log_retention",
585 "transportClass": "transport_class",
586 "sendFromLocalDevelopmentServer": "send_from_local_development_server",
587 "recipientOverride": "recipient_override",
588 "senderOverride": "sender_override",
589 "sendInBlue.apiKey": "sendinblue_api_key",
590 "sendInBlue.thresholds": "sendinblue_thresholds",
591 }
594class History(ConfigType):
595 databases: Multiple[str] = ["viur"]
596 """All history related settings."""
597 excluded_actions: Multiple[str] = []
598 """List of all action that are should not be logged."""
599 excluded_kinds: Multiple[str] = []
600 """List of all kinds that should be logged."""
603class I18N(ConfigType):
604 """All i18n, multilang related settings."""
606 available_languages: Multiple[str] = ["en"]
607 """List of language-codes, which are valid for this application"""
609 default_language: str = "en"
610 """Unless overridden by the Project: Use english as default language"""
612 domain_language_mapping: dict[str, str] = {}
613 """Maps Domains to alternative default languages"""
615 language_alias_map: dict[str, str] = {}
616 """Allows mapping of certain languages to one translation (i.e. us->en)"""
618 language_method: t.Literal["session", "url", "domain", "header"] = "session"
619 """Defines how translations are applied:
620 - session: Per Session
621 - url: inject language prefix in url
622 - domain: one domain per language
623 - header: Per Http-Header
624 """
626 language_module_map: dict[str, dict[str, str]] = {}
627 """Maps modules to their translation (if set)"""
629 auto_translate_bones: bool = True
630 """Defines whether bone descr and categories should be automatically translated via i18n.translate-objects."""
632 @property
633 def available_dialects(self) -> list[str]:
634 """Main languages and language aliases"""
635 # Use a dict to keep the order and remove duplicates
636 res = dict.fromkeys(self.available_languages)
637 res |= self.language_alias_map
638 return list(res.keys())
640 add_missing_translations: (bool | str | t.Iterable[str] | "i18n.AddMissing"
641 | t.Callable[["i18n.translate"], t.Union[bool, "i18n.AddMissing"]]) = False
642 """Add missing translation into datastore, optionally with given fnmatch-patterns.
644 If a key is not found in the translation table when a translation is
645 rendered, a database entry is created with the key and hint and
646 default value (if set) so that the translations
647 can be entered in the administration.
649 Instead of setting add_missing_translations to a boolean, it can also be set to
650 a pattern or iterable of fnmatch-patterns; Only translation keys matching these
651 patterns will be automatically added.
652 If a callable is provided, it will be called with the translation object to make a complex decision.
653 """
655 def _dump_can_view(self, _key):
656 return bool(current_user.get())
658 dump_can_view: t.Callable[[t.Self, str], bool] = _dump_can_view
659 """Customizable callback for translation.dump() to verify if a specific translation key can be queried.
661 This logic is omitted for translations flagged public."""
664class User(ConfigType):
665 """User, session, login related settings"""
667 access_rights: Multiple[str] = [
668 "root",
669 "admin",
670 "scriptor",
671 ]
672 """Additional access flags available for users on this project.
674 There are three default flags:
675 - `root` is allowed to view/add/edit/delete any module, regardless of role or other settings
676 - `admin` is allowed to use the ViUR administration tool
677 - `scriptor` is allowed to use the ViUR scripting features directly within the admin
678 This does not affect scriptor actions which are configured for modules, as they allow for
679 fine grained usage rule definitions.
680 """
682 roles: dict[str, str] = {
683 "custom": "Custom",
684 "user": "User",
685 "viewer": "Viewer",
686 "editor": "Editor",
687 "admin": "Administrator",
688 }
689 """User roles available on this project.
691 The roles can be individually defined per module, see `Module.roles`.
693 The default roles can be described as follows:
695 - `custom` for users with a custom-settings via the `User.access`-bone; includes root users.
696 - `user` for users without any additonal rights. They can log-in and view themselves, or particular modules which
697 just check for authenticated users.
698 - `viewer` for users who should only view content.
699 - `editor` for users who are allowed to edit particular content. They mostly can `view` and `edit`, but not `add`
700 or `delete`.
701 - `admin` for users with administration privileges. They can edit any data, but still aren't `root`.
703 The preset roles are for guidiance, and already fit to most projects.
704 """
706 session_life_time: datetime.timedelta = datetime.timedelta(hours=1)
707 """Default is 60 minutes lifetime for ViUR sessions"""
709 session_persistent_fields_on_login: Multiple[str] = ["language"]
710 """If set, these Fields will survive the session.reset() called on user/login"""
712 session_persistent_fields_on_logout: Multiple[str] = ["language"]
713 """If set, these Fields will survive the session.reset() called on user/logout"""
715 max_password_length: int = 512
716 """Prevent Denial of Service attacks using large inputs for pbkdf2"""
718 otp_issuer: t.Optional[str] = None
719 """The name of the issuer for the opt token"""
721 google_client_id: t.Optional[str] = None
722 """OAuth Client ID for Google Login"""
724 google_gsuite_domains: list[str] = []
725 """A list of domains. When a user signs in for the first time with a
726 Google account using Google OAuth sign-in, and the user's email address
727 belongs to one of the listed domains, a user account (UserSkel) is created.
728 If the user's email address belongs to any other domain,
729 no account is created."""
731 def __setattr__(self, name: str, value: t.Any) -> None:
732 if name == "session_life_time": 732 ↛ 733line 732 didn't jump to line 733 because the condition on line 732 was never true
733 if not isinstance(value, datetime.timedelta):
734 from viur.core import utils
735 warnings.warn(
736 "Please use timedelta to set session_life_time.",
737 DeprecationWarning, stacklevel=2,
738 )
739 value = utils.parse.timedelta(value)
740 super().__setattr__(name, value)
743class Instance(ConfigType):
744 """All app instance related settings information"""
745 app_version: str = _app_version
746 """Name of this version as deployed to the appengine"""
748 core_base_path: Path = _core_base_path
749 """The base path of the core, can be used to find file in the core folder"""
751 is_dev_server: bool = os.getenv("GAE_ENV") == "localdev"
752 """Determine whether instance is running on a local development server"""
754 project_base_path: Path = _project_base_path
755 """The base path of the project, can be used to find file in the project folder"""
757 project_id: str = _project_id
758 """The instance's project ID"""
760 version_hash: str = hashlib.sha256(f"{_app_version}{project_id}".encode("UTF-8")).hexdigest()[:10]
761 """Version hash that does not reveal the actual version name, can be used for cache-busting static resources"""
764class Conf(ConfigType):
765 """Conf class wraps the conf dict and allows to handle
766 deprecated keys or other special operations.
767 """
769 bone_boolean_str2true: Multiple[str | int] = ("true", "yes", "1")
770 """Allowed values that define a str to evaluate to true"""
772 bone_html_default_allow: "HtmlBoneConfiguration" = {
773 "validTags": [
774 "a",
775 "abbr",
776 "b",
777 "blockquote",
778 "br",
779 "div",
780 "em",
781 "h1",
782 "h2",
783 "h3",
784 "h4",
785 "h5",
786 "h6",
787 "hr",
788 "i",
789 "img",
790 "li",
791 "ol",
792 "p",
793 "span",
794 "strong",
795 "sub",
796 "sup",
797 "table",
798 "tbody",
799 "td",
800 "tfoot",
801 "th",
802 "thead",
803 "tr",
804 "u",
805 "ul",
806 ],
807 "validAttrs": {
808 "a": [
809 "href",
810 "target",
811 "title",
812 ],
813 "abbr": [
814 "title",
815 ],
816 "blockquote": [
817 "cite",
818 ],
819 "img": [
820 "src",
821 "alt",
822 "title",
823 ],
824 "p": [
825 "data-indent",
826 ],
827 "span": [
828 "title",
829 ],
830 "td": [
831 "colspan",
832 "rowspan",
833 ],
835 },
836 "validStyles": [
837 "color",
838 ],
839 "validClasses": [
840 "vitxt-*",
841 "viur-txt-*"
842 ],
843 "singleTags": [
844 "br",
845 "hr",
846 "img",
847 ]
848 }
849 """
850 A dictionary containing default configurations for handling HTML content in TextBone instances.
851 """
853 cache_environment_key: t.Optional[t.Callable[[], str]] = None
854 """If set, this function will be called for each cache-attempt
855 and the result will be included in the computed cache-key"""
857 # FIXME VIUR4: REMOVE ALL COMPATIBILITY MODES!
858 compatibility: Multiple[str] = [
859 # "json.bone.structure.camelcasenames", # use camelCase attribute names (see #637 for details)
860 # "json.bone.structure.keytuples", # use classic structure notation: `"structure = [["key", {...}] ...]` (#649)
861 # "json.bone.structure.inlists", # dump skeleton structure with every JSON list response (#774 for details)
862 # "tasks.periodic.useminutes", # Interpret int/float values for @PeriodicTask as minutes
863 # # instead of seconds (#1133 for details)
864 # "bone.select.structure.values.keytuple", # render old-style tuple-list in SelectBone's
865 # values structure (#1203)
866 ]
867 """Backward compatibility flags; Remove to enforce new style."""
869 error_handler: t.Callable[[Exception], str] | None = None
870 """If set, ViUR calls this function instead of rendering the viur.errorTemplate if an exception occurs"""
872 error_logo: str = None
873 """Path to a logo (static file). Will be used for the default error template"""
875 static_embed_svg_path: str = "/static/svgs/"
876 """Path to the static SVGs folder. Will be used by the jinja-renderer-method: embedSvg"""
878 file_hmac_key: str = None
879 """Hmac-Key used to sign download urls - set automatically"""
881 # TODO: separate this type hints and use it in the File module as well
882 file_derivations: dict[str, t.Callable[["SkeletonInstance", dict, dict], list[tuple[str, float, str, t.Any]]]] = {}
883 """Call-Map for file pre-processors"""
885 file_thumbnailer_url: t.Optional[str] = None
886 # TODO: """docstring"""
888 main_app: "Module" = None
889 """Reference to our pre-build Application-Instance"""
891 main_resolver: dict[str, dict] = None
892 """Dictionary for Resolving functions for URLs"""
894 max_post_params_count: int = 250
895 """Upper limit of the amount of parameters we accept per request. Prevents Hash-Collision-Attacks"""
897 param_filter_function: t.Callable[[str, str], bool] = lambda _, key, value: key.startswith("_")
898 """
899 Function which decides if a request parameter should be used or filtered out.
900 Returning True means to filter out.
901 """
903 moduleconf_admin_info: dict[str, t.Any] = {
904 "icon": "gear-fill",
905 "display": "hidden",
906 }
907 """Describing the internal ModuleConfig-module"""
909 script_admin_info: dict[str, t.Any] = {
910 "icon": "file-code-fill",
911 "display": "hidden",
912 }
913 """Describing the Script module"""
915 render_html_download_url_expiration: t.Optional[float | int] = None
916 """The default duration, for which downloadURLs generated by the html renderer will stay valid"""
918 render_json_download_url_expiration: t.Optional[float | int] = None
919 """The default duration, for which downloadURLs generated by the json renderer will stay valid"""
921 request_preprocessor: t.Optional[t.Callable[[str], str]] = None
922 """Allows the application to register a function that's called before the request gets routed"""
924 search_valid_chars: str = "abcdefghijklmnopqrstuvwxyzäöüß0123456789"
925 """Characters valid for the internal search functionality (all other chars are ignored)"""
927 skeleton_search_path: Multiple[str] = [
928 "/skeletons/", # skeletons of the project
929 "/viur/core/", # system-defined skeletons of viur-core
930 "/viur/src/viur/core/", # fixme: test suite
931 "/viur-core/core/" # system-defined skeletons of viur-core, only used by editable installation
932 ]
933 """Priority, in which skeletons are loaded"""
935 _tasks_custom_environment_handler: t.Optional["CustomEnvironmentHandler"] = None
937 @property
938 def tasks_custom_environment_handler(self) -> t.Optional["CustomEnvironmentHandler"]:
939 """
940 Preserve additional environment in deferred tasks.
942 If set, it must be an instance of CustomEnvironmentHandler
943 for serializing/restoring environment data.
944 """
945 return self._tasks_custom_environment_handler
947 @tasks_custom_environment_handler.setter
948 def tasks_custom_environment_handler(self, value: "CustomEnvironmentHandler") -> None:
949 from .tasks import CustomEnvironmentHandler
950 if isinstance(value, CustomEnvironmentHandler) or value is None:
951 self._tasks_custom_environment_handler = value
952 elif isinstance(value, tuple):
953 if len(value) != 2:
954 raise ValueError(f"Expected a (serialize_env_func, restore_env_func) pair")
955 warnings.warn(
956 f"tuple is deprecated, please provide a CustomEnvironmentHandler object!",
957 DeprecationWarning, stacklevel=2,
958 )
959 # Construct an CustomEnvironmentHandler class on the fly to be backward compatible
960 cls = type("ProjectCustomEnvironmentHandler", (CustomEnvironmentHandler,),
961 # serialize and restore will be bound methods.
962 # Therefore, consume the self argument with lambda.
963 {"serialize": lambda self: value[0](),
964 "restore": lambda self, obj: value[1](obj)})
965 self._tasks_custom_environment_handler = cls()
966 else:
967 raise ValueError(f"Invalid type {type(value)}. Expected a CustomEnvironmentHandler object.")
969 tasks_default_queues: dict[str, str] = {
970 "__default__": "default",
971 }
972 """
973 @CallDeferred tasks run in the Cloud Tasks Queue "default" by default.
974 One way to run them in a different task queue is to use the `_queue` parameter
975 when calling the task.
976 However, as this is not possible for existing or low-hanging calls,
977 default values can be defined here for each task.
978 To do this, the task path must be mapped to the queue name:
979 ```
980 conf.tasks_default_queues["update_relations.viur.core.skeleton"] = "update_relations"
981 ```
982 The queue (in the example: `"update_relations"`) must exist.
983 The default queue can be changed by overwriting `"__default__"`.
984 """
986 valid_application_ids: list[str] = []
987 """Which application-ids we're supposed to run on"""
989 version: tuple[int, int, int] = tuple(int(part) if part.isdigit() else part for part in __version__.split(".", 3))
990 """Semantic version number of viur-core as a tuple of 3 (major, minor, patch-level)"""
992 viur2import_blobsource: t.Optional[dict[t.Literal["infoURL", "gsdir"], str]] = None
993 """Configuration to import file blobs from ViUR2"""
995 def __init__(self, strict_mode: bool = False):
996 super().__init__()
997 self._strict_mode = strict_mode
998 self.admin = Admin(parent=self)
999 self.db = Database(parent=self)
1000 self.security = Security(parent=self)
1001 self.debug = Debug(parent=self)
1002 self.email = Email(parent=self)
1003 self.i18n = I18N(parent=self)
1004 self.user = User(parent=self)
1005 self.instance = Instance(parent=self)
1006 self.history = History(parent=self)
1008 _mapping = {
1009 # debug
1010 "viur.dev_server_cloud_logging": "debug.dev_server_cloud_logging",
1011 "viur.disable_cache": "debug.disable_cache",
1012 # i18n
1013 "viur.availableLanguages": "i18n.available_languages",
1014 "viur.defaultLanguage": "i18n.default_language",
1015 "viur.domainLanguageMapping": "i18n.domain_language_mapping",
1016 "viur.languageAliasMap": "i18n.language_alias_map",
1017 "viur.languageMethod": "i18n.language_method",
1018 "viur.languageModuleMap": "i18n.language_module_map",
1019 # user
1020 "viur.accessRights": "user.access_rights",
1021 "viur.maxPasswordLength": "user.max_password_length",
1022 "viur.otp.issuer": "user.otp_issuer",
1023 "viur.session.lifeTime": "user.session_life_time",
1024 "viur.session.persistentFieldsOnLogin": "user.session_persistent_fields_on_login",
1025 "viur.session.persistentFieldsOnLogout": "user.session_persistent_fields_on_logout",
1026 "viur.user.roles": "user.roles",
1027 "viur.user.google.clientID": "user.google_client_id",
1028 "viur.user.google.gsuiteDomains": "user.google_gsuite_domains",
1029 # instance
1030 "viur.instance.app_version": "instance.app_version",
1031 "viur.instance.core_base_path": "instance.core_base_path",
1032 "viur.instance.is_dev_server": "instance.is_dev_server",
1033 "viur.instance.project_base_path": "instance.project_base_path",
1034 "viur.instance.project_id": "instance.project_id",
1035 "viur.instance.version_hash": "instance.version_hash",
1036 # security
1037 "viur.forceSSL": "security.force_ssl",
1038 "viur.noSSLCheckUrls": "security.no_ssl_check_urls",
1039 # old viur-prefix
1040 "viur.cacheEnvironmentKey": "cache_environment_key",
1041 "viur.contentSecurityPolicy": "content_security_policy",
1042 "viur.bone.boolean.str2true": "bone_boolean_str2true",
1043 "viur.errorHandler": "error_handler",
1044 "viur.static.embedSvg.path": "static_embed_svg_path",
1045 "viur.file.hmacKey": "file_hmac_key",
1046 "viur.file_hmacKey": "file_hmac_key",
1047 "viur.file.derivers": "file_derivations",
1048 "viur.file.thumbnailerURL": "file_thumbnailer_url",
1049 "viur.mainApp": "main_app",
1050 "viur.mainResolver": "main_resolver",
1051 "viur.maxPostParamsCount": "max_post_params_count",
1052 "viur.moduleconf.admin_info": "moduleconf_admin_info",
1053 "viur.script.admin_info": "script_admin_info",
1054 "viur.render.html.downloadUrlExpiration": "render_html_download_url_expiration",
1055 "viur.downloadUrlFor.expiration": "render_html_download_url_expiration",
1056 "viur.render.json.downloadUrlExpiration": "render_json_download_url_expiration",
1057 "viur.requestPreprocessor": "request_preprocessor",
1058 "viur.searchValidChars": "search_valid_chars",
1059 "viur.skeleton.searchPath": "skeleton_search_path",
1060 "viur.tasks.customEnvironmentHandler": "tasks_custom_environment_handler",
1061 "viur.validApplicationIDs": "valid_application_ids",
1062 "viur.viur2import.blobsource": "viur2import_blobsource",
1063 }
1065 def _resolve_mapping(self, key: str) -> str:
1066 """Additional mapping for new sub confs."""
1067 if key.startswith("viur.") and key not in self._mapping:
1068 key = key.removeprefix("viur.")
1069 return super()._resolve_mapping(key)
1072conf = Conf(
1073 strict_mode=os.getenv("VIUR_CORE_CONFIG_STRICT_MODE", "").lower() != "false",
1074)