Coverage for /home/runner/work/viur-core/viur-core/viur/src/viur/core/config.py: 89%
347 statements
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-26 11:31 +0000
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-26 11:31 +0000
1import datetime
2import hashlib
3import logging
4import os
5import re
6import typing as t
7import warnings
8from pathlib import Path
10import google.auth
12from viur.core.version import __version__
13from viur.core.current import user as current_user
15if t.TYPE_CHECKING: # pragma: no cover
16 from viur.core.bones.text import HtmlBoneConfiguration
17 from viur.core.email import EmailTransport
18 from viur.core.skeleton import SkeletonInstance
19 from viur.core.module import Module
20 from viur.core.tasks import CustomEnvironmentHandler
21 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 Security(ConfigType):
317 """Security related settings"""
319 force_ssl: bool = True
320 """If true, all requests must be encrypted (ignored on development server)"""
322 no_ssl_check_urls: Multiple[str] = ["/_tasks*", "/ah/*"]
323 """List of URLs for which force_ssl is ignored.
324 Add an asterisk to mark that entry as a prefix (exact match otherwise)"""
326 content_security_policy: t.Optional[dict[str, dict[str, list[str]]]] = {
327 "enforce": {
328 "style-src": ["self", "https://accounts.google.com/gsi/style"],
329 "default-src": ["self"],
330 "img-src": ["self", "storage.googleapis.com"], # Serving-URLs of file-Bones will point here
331 "script-src": ["self", "https://accounts.google.com/gsi/client"],
332 # Required for login with Google
333 "frame-src": ["self", "www.google.com", "drive.google.com", "accounts.google.com"],
334 "form-action": ["self"],
335 "connect-src": ["self", "accounts.google.com"],
336 "upgrade-insecure-requests": [],
337 "object-src": ["none"],
338 }
339 }
340 """If set, viur will emit a CSP http-header with each request. Use security.addCspRule to set this property"""
342 referrer_policy: str = "strict-origin"
343 """Per default, we'll emit Referrer-Policy: strict-origin so no referrers leak to external services
345 See https://www.w3.org/TR/referrer-policy/
346 """
348 permissions_policy: dict[str, list[str]] = {
349 "autoplay": ["self"],
350 "camera": [],
351 "display-capture": [],
352 "document-domain": [],
353 "encrypted-media": [],
354 "fullscreen": [],
355 "geolocation": [],
356 "microphone": [],
357 "publickey-credentials-get": [],
358 "usb": [],
359 }
360 """Include a default permissions-policy.
361 To use the camera or microphone, you'll have to call
362 :meth: securityheaders.setPermissionPolicyDirective to include at least "self"
363 """
365 enable_coep: bool = False
366 """Shall we emit Cross-Origin-Embedder-Policy: require-corp?"""
368 enable_coop: t.Literal[
369 "unsafe-none", "same-origin-allow-popups",
370 "same-origin", "same-origin-plus-COEP"] = "same-origin"
371 """Emit a Cross-Origin-Opener-Policy Header?
373 See https://html.spec.whatwg.org/multipage/browsers.html#cross-origin-opener-policy-value
374 """
376 enable_corp: t.Literal["same-origin", "same-site", "cross-origin"] = "same-origin"
377 """Emit a Cross-Origin-Resource-Policy Header?
379 See https://fetch.spec.whatwg.org/#cross-origin-resource-policy-header
380 """
382 strict_transport_security: t.Optional[str] = "max-age=22118400"
383 """If set, ViUR will emit a HSTS HTTP-header with each request.
384 Use security.enableStrictTransportSecurity to set this property"""
386 x_frame_options: t.Optional[
387 tuple[t.Literal["deny", "sameorigin", "allow-from"], t.Optional[str]]
388 ] = ("sameorigin", None)
389 """If set, ViUR will emit an X-Frame-Options header
391 In case of allow-from, the second parameters must be the host-url.
392 Otherwise, it can be None.
393 """
395 x_xss_protection: t.Optional[bool] = True
396 """ViUR will emit an X-XSS-Protection header if set (the default)"""
398 x_content_type_options: bool = True
399 """ViUR will emit X-Content-Type-Options: nosniff Header unless set to False"""
401 x_permitted_cross_domain_policies: t.Optional[t.Literal["none", "master-only", "by-content-type", "all"]] = "none"
402 """Unless set to logical none; ViUR will emit a X-Permitted-Cross-Domain-Policies with each request"""
404 captcha_default_credentials: t.Optional[CaptchaDefaultCredentialsType] = None
405 """The default sitekey and secret to use for the :class:`CaptchaBone`.
406 If set, must be a dictionary of "sitekey" and "secret".
407 """
409 captcha_enforce_always: bool = False
410 """By default a captcha of the :class:`CaptchaBone` must not be solved on a local development server
411 or by a root user. But for development it can be helpful to test the implementation
412 on a local development server. Setting this flag to True, disables this behavior and
413 enforces always a valid captcha.
414 """
416 password_recovery_key_length: int = 42
417 """Length of the Password recovery key"""
419 closed_system: bool = False
420 """If `True` it activates a mode in which only authenticated users can access all routes."""
422 admin_allowed_paths: t.Iterable[str] = [
423 "vi",
424 "vi/skey",
425 "vi/settings",
426 "vi/user/auth_*",
427 "vi/user/f2_*",
428 "vi/user/getAuthMethods", # FIXME: deprecated, use `login` for this
429 "vi/user/login",
430 ]
431 """Specifies admin tool paths which are being accessible without authenticated user."""
433 closed_system_allowed_paths: t.Iterable[str] = admin_allowed_paths + [
434 "", # index site
435 "json/skey",
436 "json/user/auth_*",
437 "json/user/f2_*",
438 "json/user/getAuthMethods", # FIXME: deprecated, use `login` for this
439 "json/user/login",
440 "user/auth_*",
441 "user/f2_*",
442 "user/getAuthMethods", # FIXME: deprecated, use `login` for this
443 "user/login",
444 ]
445 """Paths that are accessible without authentication in a closed system, see `closed_system` for details."""
447 # CORS Settings
449 cors_origins: t.Iterable[str | re.Pattern] | t.Literal["*"] = []
450 """Allowed origins
451 Access-Control-Allow-Origin
453 Pattern should be case-insensitive, for example:
454 >>> re.compile(r"^http://localhost:(\d{4,5})/?$", flags=re.IGNORECASE)
455 """ # noqa
457 cors_origins_use_wildcard: bool = False
458 """Use * for Access-Control-Allow-Origin -- if possible"""
460 cors_methods: t.Iterable[str] = ["get", "head", "post", "options"] # , "put", "patch", "delete"]
461 """Access-Control-Request-Method"""
463 cors_allow_headers: t.Iterable[str | re.Pattern] | t.Literal["*"] = []
464 """Access-Control-Request-Headers
466 Can also be set for specific @exposed methods with the @cors decorator.
468 Pattern should be case-insensitive, for example:
469 >>> re.compile(r"^X-ViUR-.*$", flags=re.IGNORECASE)
470 """
472 cors_allow_credentials: bool = False
473 """
474 Set Access-Control-Allow-Credentials to true
475 to support fetch requests with credentials: include
476 """
478 cors_max_age: datetime.timedelta | None = None
479 """Allow caching"""
481 _mapping = {
482 "contentSecurityPolicy": "content_security_policy",
483 "referrerPolicy": "referrer_policy",
484 "permissionsPolicy": "permissions_policy",
485 "enableCOEP": "enable_coep",
486 "enableCOOP": "enable_coop",
487 "enableCORP": "enable_corp",
488 "strictTransportSecurity": "strict_transport_security",
489 "xFrameOptions": "x_frame_options",
490 "xXssProtection": "x_xss_protection",
491 "xContentTypeOptions": "x_content_type_options",
492 "xPermittedCrossDomainPolicies": "x_permitted_cross_domain_policies",
493 "captcha_defaultCredentials": "captcha_default_credentials",
494 "captcha.defaultCredentials": "captcha_default_credentials",
495 }
498class Debug(ConfigType):
499 """Several debug flags"""
501 trace: bool = False
502 """If enabled, trace any routing, HTTPExceptions and decorations for debugging and insight"""
504 trace_exceptions: bool = False
505 """If enabled, user-generated exceptions from the viur.core.errors module won't be caught and handled"""
507 trace_external_call_routing: bool = False
508 """If enabled, ViUR will log which (exposed) function are called from outside with what arguments"""
510 trace_internal_call_routing: bool = False
511 """If enabled, ViUR will log which (internal-exposed) function are called from templates with what arguments"""
513 skeleton_from_client: bool = False
514 """If enabled, log errors raises from skeleton.fromClient()"""
516 dev_server_cloud_logging: bool = False
517 """If disabled the local logging will not send with requestLogger to the cloud"""
519 disable_cache: bool = False
520 """If set to true, the decorator @enableCache from viur.core.cache has no effect"""
522 _mapping = {
523 "skeleton.fromClient": "skeleton_from_client",
524 "traceExceptions": "trace_exceptions",
525 "traceExternalCallRouting": "trace_external_call_routing",
526 "traceInternalCallRouting": "trace_internal_call_routing",
527 "skeleton_fromClient": "skeleton_from_client",
528 "disableCache": "disable_cache",
529 }
532class Email(ConfigType):
533 """Email related settings."""
535 log_retention: datetime.timedelta = datetime.timedelta(days=30)
536 """For how long we'll keep successfully send emails in the viur-emails table"""
538 transport_class: "EmailTransport" = None
539 """EmailTransport instance that actually delivers the email using the service provider
540 of choice. See :module:`core.email` for more details
541 """
543 send_from_local_development_server: bool = False
544 """If set, we'll enable sending emails from the local development server.
545 Otherwise, they'll just be logged.
546 """
548 recipient_override: str | list[str] | t.Callable[[], str | list[str]] | t.Literal[False] = None
549 """If set, all outgoing emails will be sent to this address
550 (overriding the 'dests'-parameter in :meth:`core.email.send_email`)
551 """
553 sender_default: str = f"viur@{_project_id}.appspotmail.com"
554 """This sender is used by default for emails.
555 It can be overridden for a specific email by passing the `sender` argument
556 to :meth:`core.email.send_email` or for all emails with :attr:`sender_override`.
557 """
559 sender_override: str | None = None
560 """If set, this sender will be used, regardless of what the templates advertise as sender"""
562 admin_recipients: str | list[str] | t.Callable[[], str | list[str]] = None
563 """Sets recipients for mails send with :meth:`core.email.send_email_to_admins`.
564 If not set, all root users will be used."""
566 _mapping = {
567 "logRetention": "log_retention",
568 "transportClass": "transport_class",
569 "sendFromLocalDevelopmentServer": "send_from_local_development_server",
570 "recipientOverride": "recipient_override",
571 "senderOverride": "sender_override",
572 "sendInBlue.apiKey": "sendinblue_api_key",
573 "sendInBlue.thresholds": "sendinblue_thresholds",
574 }
577class I18N(ConfigType):
578 """All i18n, multilang related settings."""
580 available_languages: Multiple[str] = ["en"]
581 """List of language-codes, which are valid for this application"""
583 default_language: str = "en"
584 """Unless overridden by the Project: Use english as default language"""
586 domain_language_mapping: dict[str, str] = {}
587 """Maps Domains to alternative default languages"""
589 language_alias_map: dict[str, str] = {}
590 """Allows mapping of certain languages to one translation (i.e. us->en)"""
592 language_method: t.Literal["session", "url", "domain", "header"] = "session"
593 """Defines how translations are applied:
594 - session: Per Session
595 - url: inject language prefix in url
596 - domain: one domain per language
597 - header: Per Http-Header
598 """
600 language_module_map: dict[str, dict[str, str]] = {}
601 """Maps modules to their translation (if set)"""
603 @property
604 def available_dialects(self) -> list[str]:
605 """Main languages and language aliases"""
606 # Use a dict to keep the order and remove duplicates
607 res = dict.fromkeys(self.available_languages)
608 res |= self.language_alias_map
609 return list(res.keys())
611 add_missing_translations: (bool | str | t.Iterable[str] | "i18n.AddMissing"
612 | t.Callable[["i18n.translate"], t.Union[bool, "i18n.AddMissing"]]) = False
613 """Add missing translation into datastore, optionally with given fnmatch-patterns.
615 If a key is not found in the translation table when a translation is
616 rendered, a database entry is created with the key and hint and
617 default value (if set) so that the translations
618 can be entered in the administration.
620 Instead of setting add_missing_translations to a boolean, it can also be set to
621 a pattern or iterable of fnmatch-patterns; Only translation keys matching these
622 patterns will be automatically added.
623 If a callable is provided, it will be called with the translation object to make a complex decision.
624 """
626 def _dump_can_view(self, _key):
627 return bool(current_user.get())
629 dump_can_view: t.Callable[[t.Self, str], bool] = _dump_can_view
630 """Customizable callback for translation.dump() to verify if a specific translation key can be queried.
632 This logic is omitted for translations flagged public."""
635class User(ConfigType):
636 """User, session, login related settings"""
638 access_rights: Multiple[str] = [
639 "root",
640 "admin",
641 "scriptor",
642 ]
643 """Additional access flags available for users on this project.
645 There are three default flags:
646 - `root` is allowed to view/add/edit/delete any module, regardless of role or other settings
647 - `admin` is allowed to use the ViUR administration tool
648 - `scriptor` is allowed to use the ViUR scripting features directly within the admin
649 This does not affect scriptor actions which are configured for modules, as they allow for
650 fine grained usage rule definitions.
651 """
653 roles: dict[str, str] = {
654 "custom": "Custom",
655 "user": "User",
656 "viewer": "Viewer",
657 "editor": "Editor",
658 "admin": "Administrator",
659 }
660 """User roles available on this project.
662 The roles can be individually defined per module, see `Module.roles`.
664 The default roles can be described as follows:
666 - `custom` for users with a custom-settings via the `User.access`-bone; includes root users.
667 - `user` for users without any additonal rights. They can log-in and view themselves, or particular modules which
668 just check for authenticated users.
669 - `viewer` for users who should only view content.
670 - `editor` for users who are allowed to edit particular content. They mostly can `view` and `edit`, but not `add`
671 or `delete`.
672 - `admin` for users with administration privileges. They can edit any data, but still aren't `root`.
674 The preset roles are for guidiance, and already fit to most projects.
675 """
677 session_life_time: int = 60 * 60
678 """Default is 60 minutes lifetime for ViUR sessions"""
680 session_persistent_fields_on_login: Multiple[str] = ["language"]
681 """If set, these Fields will survive the session.reset() called on user/login"""
683 session_persistent_fields_on_logout: Multiple[str] = ["language"]
684 """If set, these Fields will survive the session.reset() called on user/logout"""
686 max_password_length: int = 512
687 """Prevent Denial of Service attacks using large inputs for pbkdf2"""
689 otp_issuer: t.Optional[str] = None
690 """The name of the issuer for the opt token"""
692 google_client_id: t.Optional[str] = None
693 """OAuth Client ID for Google Login"""
695 google_gsuite_domains: list[str] = []
696 """A list of domains. When a user signs in for the first time with a
697 Google account using Google OAuth sign-in, and the user's email address
698 belongs to one of the listed domains, a user account (UserSkel) is created.
699 If the user's email address belongs to any other domain,
700 no account is created."""
703class Instance(ConfigType):
704 """All app instance related settings information"""
705 app_version: str = _app_version
706 """Name of this version as deployed to the appengine"""
708 core_base_path: Path = _core_base_path
709 """The base path of the core, can be used to find file in the core folder"""
711 is_dev_server: bool = os.getenv("GAE_ENV") == "localdev"
712 """Determine whether instance is running on a local development server"""
714 project_base_path: Path = _project_base_path
715 """The base path of the project, can be used to find file in the project folder"""
717 project_id: str = _project_id
718 """The instance's project ID"""
720 version_hash: str = hashlib.sha256(f"{_app_version}{project_id}".encode("UTF-8")).hexdigest()[:10]
721 """Version hash that does not reveal the actual version name, can be used for cache-busting static resources"""
724class Conf(ConfigType):
725 """Conf class wraps the conf dict and allows to handle
726 deprecated keys or other special operations.
727 """
729 bone_boolean_str2true: Multiple[str | int] = ("true", "yes", "1")
730 """Allowed values that define a str to evaluate to true"""
732 bone_html_default_allow: "HtmlBoneConfiguration" = {
733 "validTags": [
734 "a",
735 "abbr",
736 "b",
737 "blockquote",
738 "br",
739 "div",
740 "em",
741 "h1",
742 "h2",
743 "h3",
744 "h4",
745 "h5",
746 "h6",
747 "hr",
748 "i",
749 "img",
750 "li",
751 "ol",
752 "p",
753 "span",
754 "strong",
755 "sub",
756 "sup",
757 "table",
758 "tbody",
759 "td",
760 "tfoot",
761 "th",
762 "thead",
763 "tr",
764 "u",
765 "ul",
766 ],
767 "validAttrs": {
768 "a": [
769 "href",
770 "target",
771 "title",
772 ],
773 "abbr": [
774 "title",
775 ],
776 "blockquote": [
777 "cite",
778 ],
779 "img": [
780 "src",
781 "alt",
782 "title",
783 ],
784 "p": [
785 "data-indent",
786 ],
787 "span": [
788 "title",
789 ],
790 "td": [
791 "colspan",
792 "rowspan",
793 ],
795 },
796 "validStyles": [
797 "color",
798 ],
799 "validClasses": [
800 "vitxt-*",
801 "viur-txt-*"
802 ],
803 "singleTags": [
804 "br",
805 "hr",
806 "img",
807 ]
808 }
809 """
810 A dictionary containing default configurations for handling HTML content in TextBone instances.
811 """
813 cache_environment_key: t.Optional[t.Callable[[], str]] = None
814 """If set, this function will be called for each cache-attempt
815 and the result will be included in the computed cache-key"""
817 # FIXME VIUR4: REMOVE ALL COMPATIBILITY MODES!
818 compatibility: Multiple[str] = [
819 "json.bone.structure.camelcasenames", # use camelCase attribute names (see #637 for details)
820 "json.bone.structure.keytuples", # use classic structure notation: `"structure = [["key", {...}] ...]` (#649)
821 "json.bone.structure.inlists", # dump skeleton structure with every JSON list response (#774 for details)
822 "tasks.periodic.useminutes", # Interpret int/float values for @PeriodicTask as minutes
823 # instead of seconds (#1133 for details)
824 "bone.select.structure.values.keytuple", # render old-style tuple-list in SelectBone's values structure (#1203)
825 ]
826 """Backward compatibility flags; Remove to enforce new style."""
828 db_engine: str = "viur.datastore"
829 """Database engine module"""
831 error_handler: t.Callable[[Exception], str] | None = None
832 """If set, ViUR calls this function instead of rendering the viur.errorTemplate if an exception occurs"""
834 error_logo: str = None
835 """Path to a logo (static file). Will be used for the default error template"""
837 static_embed_svg_path: str = "/static/svgs/"
838 """Path to the static SVGs folder. Will be used by the jinja-renderer-method: embedSvg"""
840 file_hmac_key: str = None
841 """Hmac-Key used to sign download urls - set automatically"""
843 # TODO: separate this type hints and use it in the File module as well
844 file_derivations: dict[str, t.Callable[["SkeletonInstance", dict, dict], list[tuple[str, float, str, t.Any]]]] = {}
845 """Call-Map for file pre-processors"""
847 file_thumbnailer_url: t.Optional[str] = None
848 # TODO: """docstring"""
850 main_app: "Module" = None
851 """Reference to our pre-build Application-Instance"""
853 main_resolver: dict[str, dict] = None
854 """Dictionary for Resolving functions for URLs"""
856 max_post_params_count: int = 250
857 """Upper limit of the amount of parameters we accept per request. Prevents Hash-Collision-Attacks"""
859 param_filter_function: t.Callable[[str, str], bool] = lambda _, key, value: key.startswith("_")
860 """
861 Function which decides if a request parameter should be used or filtered out.
862 Returning True means to filter out.
863 """
865 moduleconf_admin_info: dict[str, t.Any] = {
866 "icon": "gear-fill",
867 "display": "hidden",
868 }
869 """Describing the internal ModuleConfig-module"""
871 script_admin_info: dict[str, t.Any] = {
872 "icon": "file-code-fill",
873 "display": "hidden",
874 }
875 """Describing the Script module"""
877 render_html_download_url_expiration: t.Optional[float | int] = None
878 """The default duration, for which downloadURLs generated by the html renderer will stay valid"""
880 render_json_download_url_expiration: t.Optional[float | int] = None
881 """The default duration, for which downloadURLs generated by the json renderer will stay valid"""
883 request_preprocessor: t.Optional[t.Callable[[str], str]] = None
884 """Allows the application to register a function that's called before the request gets routed"""
886 search_valid_chars: str = "abcdefghijklmnopqrstuvwxyzäöüß0123456789"
887 """Characters valid for the internal search functionality (all other chars are ignored)"""
889 skeleton_search_path: Multiple[str] = [
890 "/skeletons/", # skeletons of the project
891 "/viur/core/", # system-defined skeletons of viur-core
892 "/viur-core/core/" # system-defined skeletons of viur-core, only used by editable installation
893 ]
894 """Priority, in which skeletons are loaded"""
896 _tasks_custom_environment_handler: t.Optional["CustomEnvironmentHandler"] = None
898 @property
899 def tasks_custom_environment_handler(self) -> t.Optional["CustomEnvironmentHandler"]:
900 """
901 Preserve additional environment in deferred tasks.
903 If set, it must be an instance of CustomEnvironmentHandler
904 for serializing/restoring environment data.
905 """
906 return self._tasks_custom_environment_handler
908 @tasks_custom_environment_handler.setter
909 def tasks_custom_environment_handler(self, value: "CustomEnvironmentHandler") -> None:
910 from .tasks import CustomEnvironmentHandler
911 if isinstance(value, CustomEnvironmentHandler) or value is None:
912 self._tasks_custom_environment_handler = value
913 elif isinstance(value, tuple):
914 if len(value) != 2:
915 raise ValueError(f"Expected a (serialize_env_func, restore_env_func) pair")
916 warnings.warn(
917 f"tuple is deprecated, please provide a CustomEnvironmentHandler object!",
918 DeprecationWarning, stacklevel=2,
919 )
920 # Construct an CustomEnvironmentHandler class on the fly to be backward compatible
921 cls = type("ProjectCustomEnvironmentHandler", (CustomEnvironmentHandler,),
922 # serialize and restore will be bound methods.
923 # Therefore, consume the self argument with lambda.
924 {"serialize": lambda self: value[0](),
925 "restore": lambda self, obj: value[1](obj)})
926 self._tasks_custom_environment_handler = cls()
927 else:
928 raise ValueError(f"Invalid type {type(value)}. Expected a CustomEnvironmentHandler object.")
930 tasks_default_queues: dict[str, str] = {
931 "__default__": "default",
932 }
933 """
934 @CallDeferred tasks run in the Cloud Tasks Queue "default" by default.
935 One way to run them in a different task queue is to use the `_queue` parameter
936 when calling the task.
937 However, as this is not possible for existing or low-hanging calls,
938 default values can be defined here for each task.
939 To do this, the task path must be mapped to the queue name:
940 ```
941 conf.tasks_default_queues["updateRelations.viur.core.skeleton"] = "update_relations"
942 ```
943 The queue (in the example: `"update_relations"`) must exist.
944 The default queue can be changed by overwriting `"__default__"`.
945 """
947 valid_application_ids: list[str] = []
948 """Which application-ids we're supposed to run on"""
950 version: tuple[int, int, int] = tuple(int(part) if part.isdigit() else part for part in __version__.split(".", 3))
951 """Semantic version number of viur-core as a tuple of 3 (major, minor, patch-level)"""
953 viur2import_blobsource: t.Optional[dict[t.Literal["infoURL", "gsdir"], str]] = None
954 """Configuration to import file blobs from ViUR2"""
956 def __init__(self, strict_mode: bool = False):
957 super().__init__()
958 self._strict_mode = strict_mode
959 self.admin = Admin(parent=self)
960 self.security = Security(parent=self)
961 self.debug = Debug(parent=self)
962 self.email = Email(parent=self)
963 self.i18n = I18N(parent=self)
964 self.user = User(parent=self)
965 self.instance = Instance(parent=self)
967 _mapping = {
968 # debug
969 "viur.dev_server_cloud_logging": "debug.dev_server_cloud_logging",
970 "viur.disable_cache": "debug.disable_cache",
971 # i18n
972 "viur.availableLanguages": "i18n.available_languages",
973 "viur.defaultLanguage": "i18n.default_language",
974 "viur.domainLanguageMapping": "i18n.domain_language_mapping",
975 "viur.languageAliasMap": "i18n.language_alias_map",
976 "viur.languageMethod": "i18n.language_method",
977 "viur.languageModuleMap": "i18n.language_module_map",
978 # user
979 "viur.accessRights": "user.access_rights",
980 "viur.maxPasswordLength": "user.max_password_length",
981 "viur.otp.issuer": "user.otp_issuer",
982 "viur.session.lifeTime": "user.session_life_time",
983 "viur.session.persistentFieldsOnLogin": "user.session_persistent_fields_on_login",
984 "viur.session.persistentFieldsOnLogout": "user.session_persistent_fields_on_logout",
985 "viur.user.roles": "user.roles",
986 "viur.user.google.clientID": "user.google_client_id",
987 "viur.user.google.gsuiteDomains": "user.google_gsuite_domains",
988 # instance
989 "viur.instance.app_version": "instance.app_version",
990 "viur.instance.core_base_path": "instance.core_base_path",
991 "viur.instance.is_dev_server": "instance.is_dev_server",
992 "viur.instance.project_base_path": "instance.project_base_path",
993 "viur.instance.project_id": "instance.project_id",
994 "viur.instance.version_hash": "instance.version_hash",
995 # security
996 "viur.forceSSL": "security.force_ssl",
997 "viur.noSSLCheckUrls": "security.no_ssl_check_urls",
998 # old viur-prefix
999 "viur.cacheEnvironmentKey": "cache_environment_key",
1000 "viur.contentSecurityPolicy": "content_security_policy",
1001 "viur.bone.boolean.str2true": "bone_boolean_str2true",
1002 "viur.db.engine": "db_engine",
1003 "viur.errorHandler": "error_handler",
1004 "viur.static.embedSvg.path": "static_embed_svg_path",
1005 "viur.file.hmacKey": "file_hmac_key",
1006 "viur.file_hmacKey": "file_hmac_key",
1007 "viur.file.derivers": "file_derivations",
1008 "viur.file.thumbnailerURL": "file_thumbnailer_url",
1009 "viur.mainApp": "main_app",
1010 "viur.mainResolver": "main_resolver",
1011 "viur.maxPostParamsCount": "max_post_params_count",
1012 "viur.moduleconf.admin_info": "moduleconf_admin_info",
1013 "viur.script.admin_info": "script_admin_info",
1014 "viur.render.html.downloadUrlExpiration": "render_html_download_url_expiration",
1015 "viur.downloadUrlFor.expiration": "render_html_download_url_expiration",
1016 "viur.render.json.downloadUrlExpiration": "render_json_download_url_expiration",
1017 "viur.requestPreprocessor": "request_preprocessor",
1018 "viur.searchValidChars": "search_valid_chars",
1019 "viur.skeleton.searchPath": "skeleton_search_path",
1020 "viur.tasks.customEnvironmentHandler": "tasks_custom_environment_handler",
1021 "viur.validApplicationIDs": "valid_application_ids",
1022 "viur.viur2import.blobsource": "viur2import_blobsource",
1023 }
1025 def _resolve_mapping(self, key: str) -> str:
1026 """Additional mapping for new sub confs."""
1027 if key.startswith("viur.") and key not in self._mapping:
1028 key = key.removeprefix("viur.")
1029 return super()._resolve_mapping(key)
1032conf = Conf(
1033 strict_mode=os.getenv("VIUR_CORE_CONFIG_STRICT_MODE", "").lower() == "true",
1034)