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.7, created at 2025-09-29 09:00 +0000
« prev ^ index » next coverage.py v7.10.7, created at 2025-09-29 09:00 +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/select_authentication_provider",
444 "vi/user/login",
445 ]
446 """Specifies admin tool paths which are being accessible without authenticated user."""
448 closed_system_allowed_paths: t.Iterable[str] = admin_allowed_paths + [
449 "", # index site
450 "json/skey",
451 "json/user/auth_*",
452 "json/user/f2_*",
453 "json/user/getAuthMethods", # FIXME: deprecated, use `login` for this
454 "json/user/login",
455 "user/auth_*",
456 "user/f2_*",
457 "user/getAuthMethods", # FIXME: deprecated, use `login` for this
458 "user/select_authentication_provider",
459 "user/login",
460 ]
461 """Paths that are accessible without authentication in a closed system, see `closed_system` for details."""
463 # CORS Settings
465 cors_origins: t.Iterable[str | re.Pattern] | t.Literal["*"] = []
466 """Allowed origins
467 Access-Control-Allow-Origin
469 Pattern should be case-insensitive, for example:
470 >>> re.compile(r"^http://localhost:(\d{4,5})/?$", flags=re.IGNORECASE)
471 """ # noqa
473 cors_origins_use_wildcard: bool = False
474 """Use * for Access-Control-Allow-Origin -- if possible"""
476 cors_methods: t.Iterable[str] = ["get", "head", "post", "options"] # , "put", "patch", "delete"]
477 """Access-Control-Request-Method"""
479 cors_allow_headers: t.Iterable[str | re.Pattern] | t.Literal["*"] = []
480 """Access-Control-Request-Headers
482 Can also be set for specific @exposed methods with the @cors decorator.
484 Pattern should be case-insensitive, for example:
485 >>> re.compile(r"^X-ViUR-.*$", flags=re.IGNORECASE)
486 """
488 cors_allow_credentials: bool = False
489 """
490 Set Access-Control-Allow-Credentials to true
491 to support fetch requests with credentials: include
492 """
494 cors_max_age: datetime.timedelta | None = None
495 """Allow caching"""
497 _mapping = {
498 "contentSecurityPolicy": "content_security_policy",
499 "referrerPolicy": "referrer_policy",
500 "permissionsPolicy": "permissions_policy",
501 "enableCOEP": "enable_coep",
502 "enableCOOP": "enable_coop",
503 "enableCORP": "enable_corp",
504 "strictTransportSecurity": "strict_transport_security",
505 "xFrameOptions": "x_frame_options",
506 "xXssProtection": "x_xss_protection",
507 "xContentTypeOptions": "x_content_type_options",
508 "xPermittedCrossDomainPolicies": "x_permitted_cross_domain_policies",
509 "captcha_defaultCredentials": "captcha_default_credentials",
510 "captcha.defaultCredentials": "captcha_default_credentials",
511 }
514class Debug(ConfigType):
515 """Several debug flags"""
517 trace: bool = False
518 """If enabled, trace any routing, HTTPExceptions and decorations for debugging and insight"""
520 trace_exceptions: bool = False
521 """If enabled, user-generated exceptions from the viur.core.errors module won't be caught and handled"""
523 trace_external_call_routing: bool = False
524 """If enabled, ViUR will log which (exposed) function are called from outside with what arguments"""
526 trace_internal_call_routing: bool = False
527 """If enabled, ViUR will log which (internal-exposed) function are called from templates with what arguments"""
529 trace_queries: bool = False
530 """If enabled, ViUR will log each query that run"""
532 skeleton_from_client: bool = False
533 """If enabled, log errors raises from skeleton.fromClient()"""
535 dev_server_cloud_logging: bool = False
536 """If disabled the local logging will not send with requestLogger to the cloud"""
538 disable_cache: bool = False
539 """If set to true, the decorator @enableCache from viur.core.cache has no effect"""
541 _mapping = {
542 "skeleton.fromClient": "skeleton_from_client",
543 "traceExceptions": "trace_exceptions",
544 "traceExternalCallRouting": "trace_external_call_routing",
545 "traceInternalCallRouting": "trace_internal_call_routing",
546 "skeleton_fromClient": "skeleton_from_client",
547 "disableCache": "disable_cache",
548 }
551class Email(ConfigType):
552 """Email related settings."""
554 log_retention: datetime.timedelta = datetime.timedelta(days=30)
555 """For how long we'll keep successfully send emails in the viur-emails table"""
557 transport_class: "EmailTransport" = None
558 """EmailTransport instance that actually delivers the email using the service provider
559 of choice. See :module:`core.email` for more details
560 """
562 send_from_local_development_server: bool = False
563 """If set, we'll enable sending emails from the local development server.
564 Otherwise, they'll just be logged.
565 """
567 recipient_override: str | list[str] | t.Callable[[], str | list[str]] | t.Literal[False] = None
568 """If set, all outgoing emails will be sent to this address
569 (overriding the 'dests'-parameter in :meth:`core.email.send_email`)
570 """
572 sender_default: str = f"viur@{_project_id}.appspotmail.com"
573 """This sender is used by default for emails.
574 It can be overridden for a specific email by passing the `sender` argument
575 to :meth:`core.email.send_email` or for all emails with :attr:`sender_override`.
576 """
578 sender_override: str | None = None
579 """If set, this sender will be used, regardless of what the templates advertise as sender"""
581 admin_recipients: str | list[str] | t.Callable[[], str | list[str]] = None
582 """Sets recipients for mails send with :meth:`core.email.send_email_to_admins`.
583 If not set, all root users will be used."""
585 _mapping = {
586 "logRetention": "log_retention",
587 "transportClass": "transport_class",
588 "sendFromLocalDevelopmentServer": "send_from_local_development_server",
589 "recipientOverride": "recipient_override",
590 "senderOverride": "sender_override",
591 "sendInBlue.apiKey": "sendinblue_api_key",
592 "sendInBlue.thresholds": "sendinblue_thresholds",
593 }
596class History(ConfigType):
597 databases: Multiple[str] = ["viur"]
598 """All history related settings."""
599 excluded_actions: Multiple[str] = []
600 """List of all action that are should not be logged."""
601 excluded_kinds: Multiple[str] = []
602 """List of all kinds that should be logged."""
605class I18N(ConfigType):
606 """All i18n, multilang related settings."""
608 available_languages: Multiple[str] = ["en"]
609 """List of language-codes, which are valid for this application"""
611 default_language: str = "en"
612 """Unless overridden by the Project: Use english as default language"""
614 domain_language_mapping: dict[str, str] = {}
615 """Maps Domains to alternative default languages"""
617 language_alias_map: dict[str, str] = {}
618 """Allows mapping of certain languages to one translation (i.e. us->en)"""
620 language_method: t.Literal["session", "url", "domain", "header"] = "session"
621 """Defines how translations are applied:
622 - session: Per Session
623 - url: inject language prefix in url
624 - domain: one domain per language
625 - header: Per Http-Header
626 """
628 language_module_map: dict[str, dict[str, str]] = {}
629 """Maps modules to their translation (if set)"""
631 auto_translate_bones: bool = True
632 """Defines whether bone descr and categories should be automatically translated via i18n.translate-objects."""
634 @property
635 def available_dialects(self) -> list[str]:
636 """Main languages and language aliases"""
637 # Use a dict to keep the order and remove duplicates
638 res = dict.fromkeys(self.available_languages)
639 res |= self.language_alias_map
640 return list(res.keys())
642 add_missing_translations: (bool | str | t.Iterable[str] | "i18n.AddMissing"
643 | t.Callable[["i18n.translate"], t.Union[bool, "i18n.AddMissing"]]) = False
644 """Add missing translation into datastore, optionally with given fnmatch-patterns.
646 If a key is not found in the translation table when a translation is
647 rendered, a database entry is created with the key and hint and
648 default value (if set) so that the translations
649 can be entered in the administration.
651 Instead of setting add_missing_translations to a boolean, it can also be set to
652 a pattern or iterable of fnmatch-patterns; Only translation keys matching these
653 patterns will be automatically added.
654 If a callable is provided, it will be called with the translation object to make a complex decision.
655 """
657 def _dump_can_view(self, _key):
658 return bool(current_user.get())
660 dump_can_view: t.Callable[[t.Self, str], bool] = _dump_can_view
661 """Customizable callback for translation.dump() to verify if a specific translation key can be queried.
663 This logic is omitted for translations flagged public."""
666class User(ConfigType):
667 """User, session, login related settings"""
669 access_rights: Multiple[str] = [
670 "root",
671 "admin",
672 "scriptor",
673 ]
674 """Additional access flags available for users on this project.
676 There are three default flags:
677 - `root` is allowed to view/add/edit/delete any module, regardless of role or other settings
678 - `admin` is allowed to use the ViUR administration tool
679 - `scriptor` is allowed to use the ViUR scripting features directly within the admin
680 This does not affect scriptor actions which are configured for modules, as they allow for
681 fine grained usage rule definitions.
682 """
684 roles: dict[str, str] = {
685 "custom": "Custom",
686 "user": "User",
687 "viewer": "Viewer",
688 "editor": "Editor",
689 "admin": "Administrator",
690 }
691 """User roles available on this project.
693 The roles can be individually defined per module, see `Module.roles`.
695 The default roles can be described as follows:
697 - `custom` for users with a custom-settings via the `User.access`-bone; includes root users.
698 - `user` for users without any additonal rights. They can log-in and view themselves, or particular modules which
699 just check for authenticated users.
700 - `viewer` for users who should only view content.
701 - `editor` for users who are allowed to edit particular content. They mostly can `view` and `edit`, but not `add`
702 or `delete`.
703 - `admin` for users with administration privileges. They can edit any data, but still aren't `root`.
705 The preset roles are for guidiance, and already fit to most projects.
706 """
708 session_life_time: datetime.timedelta = datetime.timedelta(hours=1)
709 """Default is 60 minutes lifetime for ViUR sessions"""
711 session_persistent_fields_on_login: Multiple[str] = ["language"]
712 """If set, these Fields will survive the session.reset() called on user/login"""
714 session_persistent_fields_on_logout: Multiple[str] = ["language"]
715 """If set, these Fields will survive the session.reset() called on user/logout"""
717 max_password_length: int = 512
718 """Prevent Denial of Service attacks using large inputs for pbkdf2"""
720 otp_issuer: t.Optional[str] = None
721 """The name of the issuer for the opt token"""
723 google_client_id: t.Optional[str] = None
724 """OAuth Client ID for Google Login"""
726 google_gsuite_domains: list[str] = []
727 """A list of domains. When a user signs in for the first time with a
728 Google account using Google OAuth sign-in, and the user's email address
729 belongs to one of the listed domains, a user account (UserSkel) is created.
730 If the user's email address belongs to any other domain,
731 no account is created."""
733 def __setattr__(self, name: str, value: t.Any) -> None:
734 if name == "session_life_time": 734 ↛ 735line 734 didn't jump to line 735 because the condition on line 734 was never true
735 if not isinstance(value, datetime.timedelta):
736 from viur.core import utils
737 warnings.warn(
738 "Please use timedelta to set session_life_time.",
739 DeprecationWarning, stacklevel=2,
740 )
741 value = utils.parse.timedelta(value)
742 super().__setattr__(name, value)
745class Instance(ConfigType):
746 """All app instance related settings information"""
747 app_version: str = _app_version
748 """Name of this version as deployed to the appengine"""
750 core_base_path: Path = _core_base_path
751 """The base path of the core, can be used to find file in the core folder"""
753 is_dev_server: bool = os.getenv("GAE_ENV") == "localdev"
754 """Determine whether instance is running on a local development server"""
756 project_base_path: Path = _project_base_path
757 """The base path of the project, can be used to find file in the project folder"""
759 project_id: str = _project_id
760 """The instance's project ID"""
762 version_hash: str = hashlib.sha256(f"{_app_version}{project_id}".encode("UTF-8")).hexdigest()[:10]
763 """Version hash that does not reveal the actual version name, can be used for cache-busting static resources"""
766class Conf(ConfigType):
767 """Conf class wraps the conf dict and allows to handle
768 deprecated keys or other special operations.
769 """
771 bone_boolean_str2true: Multiple[str | int] = ("true", "yes", "1")
772 """Allowed values that define a str to evaluate to true"""
774 bone_html_default_allow: "HtmlBoneConfiguration" = {
775 "validTags": [
776 "a",
777 "abbr",
778 "b",
779 "blockquote",
780 "br",
781 "div",
782 "em",
783 "h1",
784 "h2",
785 "h3",
786 "h4",
787 "h5",
788 "h6",
789 "hr",
790 "i",
791 "img",
792 "li",
793 "ol",
794 "p",
795 "span",
796 "strong",
797 "sub",
798 "sup",
799 "table",
800 "tbody",
801 "td",
802 "tfoot",
803 "th",
804 "thead",
805 "tr",
806 "u",
807 "ul",
808 ],
809 "validAttrs": {
810 "a": [
811 "href",
812 "target",
813 "title",
814 ],
815 "abbr": [
816 "title",
817 ],
818 "blockquote": [
819 "cite",
820 ],
821 "img": [
822 "src",
823 "alt",
824 "title",
825 ],
826 "p": [
827 "data-indent",
828 ],
829 "span": [
830 "title",
831 ],
832 "td": [
833 "colspan",
834 "rowspan",
835 ],
837 },
838 "validStyles": [
839 "color",
840 ],
841 "validClasses": [
842 "vitxt-*",
843 "viur-txt-*"
844 ],
845 "singleTags": [
846 "br",
847 "hr",
848 "img",
849 ]
850 }
851 """
852 A dictionary containing default configurations for handling HTML content in TextBone instances.
853 """
855 cache_environment_key: t.Optional[t.Callable[[], str]] = None
856 """If set, this function will be called for each cache-attempt
857 and the result will be included in the computed cache-key"""
859 # FIXME VIUR4: REMOVE ALL COMPATIBILITY MODES!
860 compatibility: Multiple[str] = [
861 # "json.bone.structure.camelcasenames", # use camelCase attribute names (see #637 for details)
862 # "json.bone.structure.keytuples", # use classic structure notation: `"structure = [["key", {...}] ...]` (#649)
863 # "json.bone.structure.inlists", # dump skeleton structure with every JSON list response (#774 for details)
864 # "tasks.periodic.useminutes", # Interpret int/float values for @PeriodicTask as minutes
865 # # instead of seconds (#1133 for details)
866 # "bone.select.structure.values.keytuple", # render old-style tuple-list in SelectBone's
867 # values structure (#1203)
868 ]
869 """Backward compatibility flags; Remove to enforce new style."""
871 error_handler: t.Callable[[Exception], str] | None = None
872 """If set, ViUR calls this function instead of rendering the viur.errorTemplate if an exception occurs"""
874 error_logo: str = None
875 """Path to a logo (static file). Will be used for the default error template"""
877 static_embed_svg_path: str = "/static/svgs/"
878 """Path to the static SVGs folder. Will be used by the jinja-renderer-method: embedSvg"""
880 file_hmac_key: str = None
881 """Hmac-Key used to sign download urls - set automatically"""
883 # TODO: separate this type hints and use it in the File module as well
884 file_derivations: dict[str, t.Callable[["SkeletonInstance", dict, dict], list[tuple[str, float, str, t.Any]]]] = {}
885 """Call-Map for file pre-processors"""
887 file_thumbnailer_url: t.Optional[str] = None
888 # TODO: """docstring"""
890 main_app: "Module" = None
891 """Reference to our pre-build Application-Instance"""
893 main_resolver: dict[str, dict] = None
894 """Dictionary for Resolving functions for URLs"""
896 max_post_params_count: int = 250
897 """Upper limit of the amount of parameters we accept per request. Prevents Hash-Collision-Attacks"""
899 param_filter_function: t.Callable[[str, str], bool] = lambda _, key, value: key.startswith("_")
900 """
901 Function which decides if a request parameter should be used or filtered out.
902 Returning True means to filter out.
903 """
905 moduleconf_admin_info: dict[str, t.Any] = {
906 "icon": "gear-fill",
907 "display": "hidden",
908 }
909 """Describing the internal ModuleConfig-module"""
911 script_admin_info: dict[str, t.Any] = {
912 "icon": "file-code-fill",
913 "display": "hidden",
914 }
915 """Describing the Script module"""
917 render_html_download_url_expiration: t.Optional[float | int] = None
918 """The default duration, for which downloadURLs generated by the html renderer will stay valid"""
920 render_json_download_url_expiration: t.Optional[float | int] = None
921 """The default duration, for which downloadURLs generated by the json renderer will stay valid"""
923 request_preprocessor: t.Optional[t.Callable[[str], str]] = None
924 """Allows the application to register a function that's called before the request gets routed"""
926 search_valid_chars: str = "abcdefghijklmnopqrstuvwxyzäöüß0123456789"
927 """Characters valid for the internal search functionality (all other chars are ignored)"""
929 skeleton_search_path: Multiple[str] = [
930 "/skeletons/", # skeletons of the project
931 "/viur/core/", # system-defined skeletons of viur-core
932 "/viur/src/viur/core/", # fixme: test suite
933 "/viur-core/core/" # system-defined skeletons of viur-core, only used by editable installation
934 ]
935 """Priority, in which skeletons are loaded"""
937 _tasks_custom_environment_handler: t.Optional["CustomEnvironmentHandler"] = None
939 @property
940 def tasks_custom_environment_handler(self) -> t.Optional["CustomEnvironmentHandler"]:
941 """
942 Preserve additional environment in deferred tasks.
944 If set, it must be an instance of CustomEnvironmentHandler
945 for serializing/restoring environment data.
946 """
947 return self._tasks_custom_environment_handler
949 @tasks_custom_environment_handler.setter
950 def tasks_custom_environment_handler(self, value: "CustomEnvironmentHandler") -> None:
951 from .tasks import CustomEnvironmentHandler
952 if isinstance(value, CustomEnvironmentHandler) or value is None:
953 self._tasks_custom_environment_handler = value
954 elif isinstance(value, tuple):
955 if len(value) != 2:
956 raise ValueError(f"Expected a (serialize_env_func, restore_env_func) pair")
957 warnings.warn(
958 f"tuple is deprecated, please provide a CustomEnvironmentHandler object!",
959 DeprecationWarning, stacklevel=2,
960 )
961 # Construct an CustomEnvironmentHandler class on the fly to be backward compatible
962 cls = type("ProjectCustomEnvironmentHandler", (CustomEnvironmentHandler,),
963 # serialize and restore will be bound methods.
964 # Therefore, consume the self argument with lambda.
965 {"serialize": lambda self: value[0](),
966 "restore": lambda self, obj: value[1](obj)})
967 self._tasks_custom_environment_handler = cls()
968 else:
969 raise ValueError(f"Invalid type {type(value)}. Expected a CustomEnvironmentHandler object.")
971 tasks_default_queues: dict[str, str] = {
972 "__default__": "default",
973 }
974 """
975 @CallDeferred tasks run in the Cloud Tasks Queue "default" by default.
976 One way to run them in a different task queue is to use the `_queue` parameter
977 when calling the task.
978 However, as this is not possible for existing or low-hanging calls,
979 default values can be defined here for each task.
980 To do this, the task path must be mapped to the queue name:
981 ```
982 conf.tasks_default_queues["update_relations.viur.core.skeleton"] = "update_relations"
983 ```
984 The queue (in the example: `"update_relations"`) must exist.
985 The default queue can be changed by overwriting `"__default__"`.
986 """
988 valid_application_ids: list[str] = []
989 """Which application-ids we're supposed to run on"""
991 version: tuple[int, int, int] = tuple(int(part) if part.isdigit() else part for part in __version__.split(".", 3))
992 """Semantic version number of viur-core as a tuple of 3 (major, minor, patch-level)"""
994 viur2import_blobsource: t.Optional[dict[t.Literal["infoURL", "gsdir"], str]] = None
995 """Configuration to import file blobs from ViUR2"""
997 def __init__(self, strict_mode: bool = False):
998 super().__init__()
999 self._strict_mode = strict_mode
1000 self.admin = Admin(parent=self)
1001 self.db = Database(parent=self)
1002 self.security = Security(parent=self)
1003 self.debug = Debug(parent=self)
1004 self.email = Email(parent=self)
1005 self.i18n = I18N(parent=self)
1006 self.user = User(parent=self)
1007 self.instance = Instance(parent=self)
1008 self.history = History(parent=self)
1010 _mapping = {
1011 # debug
1012 "viur.dev_server_cloud_logging": "debug.dev_server_cloud_logging",
1013 "viur.disable_cache": "debug.disable_cache",
1014 # i18n
1015 "viur.availableLanguages": "i18n.available_languages",
1016 "viur.defaultLanguage": "i18n.default_language",
1017 "viur.domainLanguageMapping": "i18n.domain_language_mapping",
1018 "viur.languageAliasMap": "i18n.language_alias_map",
1019 "viur.languageMethod": "i18n.language_method",
1020 "viur.languageModuleMap": "i18n.language_module_map",
1021 # user
1022 "viur.accessRights": "user.access_rights",
1023 "viur.maxPasswordLength": "user.max_password_length",
1024 "viur.otp.issuer": "user.otp_issuer",
1025 "viur.session.lifeTime": "user.session_life_time",
1026 "viur.session.persistentFieldsOnLogin": "user.session_persistent_fields_on_login",
1027 "viur.session.persistentFieldsOnLogout": "user.session_persistent_fields_on_logout",
1028 "viur.user.roles": "user.roles",
1029 "viur.user.google.clientID": "user.google_client_id",
1030 "viur.user.google.gsuiteDomains": "user.google_gsuite_domains",
1031 # instance
1032 "viur.instance.app_version": "instance.app_version",
1033 "viur.instance.core_base_path": "instance.core_base_path",
1034 "viur.instance.is_dev_server": "instance.is_dev_server",
1035 "viur.instance.project_base_path": "instance.project_base_path",
1036 "viur.instance.project_id": "instance.project_id",
1037 "viur.instance.version_hash": "instance.version_hash",
1038 # security
1039 "viur.forceSSL": "security.force_ssl",
1040 "viur.noSSLCheckUrls": "security.no_ssl_check_urls",
1041 # old viur-prefix
1042 "viur.cacheEnvironmentKey": "cache_environment_key",
1043 "viur.contentSecurityPolicy": "content_security_policy",
1044 "viur.bone.boolean.str2true": "bone_boolean_str2true",
1045 "viur.errorHandler": "error_handler",
1046 "viur.static.embedSvg.path": "static_embed_svg_path",
1047 "viur.file.hmacKey": "file_hmac_key",
1048 "viur.file_hmacKey": "file_hmac_key",
1049 "viur.file.derivers": "file_derivations",
1050 "viur.file.thumbnailerURL": "file_thumbnailer_url",
1051 "viur.mainApp": "main_app",
1052 "viur.mainResolver": "main_resolver",
1053 "viur.maxPostParamsCount": "max_post_params_count",
1054 "viur.moduleconf.admin_info": "moduleconf_admin_info",
1055 "viur.script.admin_info": "script_admin_info",
1056 "viur.render.html.downloadUrlExpiration": "render_html_download_url_expiration",
1057 "viur.downloadUrlFor.expiration": "render_html_download_url_expiration",
1058 "viur.render.json.downloadUrlExpiration": "render_json_download_url_expiration",
1059 "viur.requestPreprocessor": "request_preprocessor",
1060 "viur.searchValidChars": "search_valid_chars",
1061 "viur.skeleton.searchPath": "skeleton_search_path",
1062 "viur.tasks.customEnvironmentHandler": "tasks_custom_environment_handler",
1063 "viur.validApplicationIDs": "valid_application_ids",
1064 "viur.viur2import.blobsource": "viur2import_blobsource",
1065 }
1067 def _resolve_mapping(self, key: str) -> str:
1068 """Additional mapping for new sub confs."""
1069 if key.startswith("viur.") and key not in self._mapping:
1070 key = key.removeprefix("viur.")
1071 return super()._resolve_mapping(key)
1074conf = Conf(
1075 strict_mode=os.getenv("VIUR_CORE_CONFIG_STRICT_MODE", "").lower() != "false",
1076)