Coverage for /home/runner/work/viur-core/viur-core/viur/src/viur/core/config.py: 89%
390 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-25 15:02 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-25 15:02 +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
33class ConfigType:
34 """An abstract class for configurations.
36 It ensures nesting and backward compatibility for the viur-core config
37 """
38 _mapping = {}
39 """Mapping from old dict-key (must not be the entire key in case of nesting) to new attribute name"""
41 _strict_mode = None
42 """Internal strict mode for this instance.
44 Use the property getter and setter to access it!"""
46 _parent = None
47 """Parent config instance"""
49 def __init__(self, *,
50 strict_mode: bool = None,
51 parent: t.Union["ConfigType", None] = None):
52 super().__init__()
53 self._strict_mode = strict_mode
54 self._parent = parent
56 @property
57 def _path(self):
58 """Get the path in dot-Notation to the current config instance."""
59 if self._parent is None:
60 return ""
61 return f"{self._parent._path}{self.__class__.__name__.lower()}."
63 @property
64 def strict_mode(self):
65 """Determine if the config runs in strict mode.
67 In strict mode, the dict-item-access backward compatibility is disabled,
68 only attribute access is allowed.
69 Alias mapping is also disabled. Only the real attribute names are allowed.
71 If self._strict_mode is None, it would inherit the value
72 of the parent.
73 If it's explicitly set to True or False, that value will be used.
74 """
75 if self._strict_mode is not None or self._parent is None:
76 # This config has an explicit value set or there's no parent
77 return self._strict_mode
78 else:
79 # no value set: inherit from the parent
80 return self._parent.strict_mode
82 @strict_mode.setter
83 def strict_mode(self, value: bool | None) -> None:
84 """Setter for the strict mode of the current instance.
86 Does not affect other instances!
87 """
88 if not isinstance(value, (bool, type(None))):
89 raise TypeError(f"Invalid {value=} for strict mode!")
90 self._strict_mode = value
92 def _resolve_mapping(self, key: str) -> str:
93 """Resolve the mapping old dict -> new attribute.
95 This method must not be called in strict mode!
96 It can be overwritten to apply additional mapping.
97 """
98 if key in self._mapping:
99 old, key = key, self._mapping[key]
100 warnings.warn(
101 f"Conf member {self._path}{old} is now {self._path}{key}!",
102 DeprecationWarning,
103 stacklevel=3,
104 )
105 return key
107 def items(self,
108 full_path: bool = False,
109 recursive: bool = True,
110 ) -> t.Iterator[tuple[str, t.Any]]:
111 """Get all setting of this config as key-value mapping.
113 :param full_path: Show prefix oder only the key.
114 :param recursive: Call .items() on ConfigType members (children)?
115 :return:
116 """
117 for key in dir(self):
118 if key.startswith("_"):
119 # skip internals, like _parent and _strict_mode
120 continue
121 value = getattr(self, key)
122 if recursive and isinstance(value, ConfigType):
123 yield from value.items(full_path, recursive)
124 elif key not in dir(ConfigType):
125 if full_path: 125 ↛ 126line 125 didn't jump to line 126 because the condition on line 125 was never true
126 yield f"{self._path}{key}", value
127 else:
128 yield key, value
130 def get(self, key: str, default: t.Any = None) -> t.Any:
131 """Return an item from the config, if it doesn't exist `default` is returned.
133 :param key: The key for the attribute lookup.
134 :param default: The fallback value.
135 :return: The attribute value or the fallback value.
136 """
137 if self.strict_mode:
138 raise SyntaxError(
139 "In strict mode, the config must not be accessed "
140 "with .get(). Only attribute access is allowed."
141 )
142 try:
143 return getattr(self, key)
144 except (KeyError, AttributeError):
145 return default
147 def __getitem__(self, key: str) -> t.Any:
148 """Support the old dict-like syntax (getter).
150 Not allowed in strict mode.
151 """
152 new_path = f"{self._path}{self._resolve_mapping(key)}"
153 warnings.warn(f"conf uses now attributes! "
154 f"Use conf.{new_path} to access your option",
155 DeprecationWarning,
156 stacklevel=2)
158 if self.strict_mode:
159 raise SyntaxError(
160 f"In strict mode, the config must not be accessed "
161 f"with dict notation. "
162 f"Only attribute access (conf.{new_path}) is allowed."
163 )
165 return getattr(self, key)
167 def __getattr__(self, key: str) -> t.Any:
168 """Resolve dot-notation and name mapping in not strict mode.
170 This method is mostly executed by __getitem__, by the
171 old dict-like access or by attr(conf, "key").
172 In strict mode it does nothing except raising an AttributeError.
173 """
174 if self.strict_mode:
175 raise AttributeError(
176 f"AttributeError: '{self.__class__.__name__}' object has no"
177 f" attribute '{key}' (strict mode is enabled)"
178 )
180 key = self._resolve_mapping(key)
182 # Got an old dict-key and resolve the segment to the first dot (.) as attribute.
183 if "." in key:
184 first, remaining = key.split(".", 1)
185 return getattr(getattr(self, first), remaining)
187 return super().__getattribute__(key)
189 def __setitem__(self, key: str, value: t.Any) -> None:
190 """Support the old dict-like syntax (setter).
192 Not allowed in strict mode.
193 """
194 new_path = f"{self._path}{self._resolve_mapping(key)}"
195 if self.strict_mode:
196 raise SyntaxError(
197 f"In strict mode, the config must not be accessed "
198 f"with dict notation. "
199 f"Only attribute access (conf.{new_path}) is allowed."
200 )
202 # TODO: re-enable?!
203 # Avoid to set conf values to something which is already the default
204 # if key in self and self[key] == value:
205 # msg = f"Setting conf[\"{key}\"] to {value!r} has no effect, as this value has already been set"
206 # warnings.warn(msg, stacklevel=3)
207 # logging.warning(msg, stacklevel=3)
208 # return
210 key = self._resolve_mapping(key)
212 # Got an old dict-key and resolve the segment to the first dot (.) as attribute.
213 if "." in key:
214 first, remaining = key.split(".", 1)
215 if not hasattr(self, first):
216 # TODO: Compatibility, remove it in a future major release!
217 # This segment doesn't exist. Create it
218 logging.warning(f"Creating new type for {first}")
219 setattr(self, first, type(first.capitalize(), (ConfigType,), {})())
220 getattr(self, first)[remaining] = value
221 return
223 return setattr(self, key, value)
225 def __setattr__(self, key: str, value: t.Any) -> None:
226 """Set attributes after applying the old -> new mapping
228 In strict mode it does nothing except a super call
229 for the default object behavior.
230 """
231 if self.strict_mode:
232 return super().__setattr__(key, value)
234 if not self.strict_mode: 234 ↛ 238line 234 didn't jump to line 238 because the condition on line 234 was always true
235 key = self._resolve_mapping(key)
237 # Got an old dict-key and resolve the segment to the first dot (.) as attribute.
238 if "." in key: 238 ↛ 240line 238 didn't jump to line 240 because the condition on line 238 was never true
239 # TODO: Shall we allow this in strict mode as well?
240 first, remaining = key.split(".", 1)
241 return setattr(getattr(self, first), remaining, value)
243 return super().__setattr__(key, value)
245 def __repr__(self) -> str:
246 """Representation of this config"""
247 return f"{self.__class__.__qualname__}({dict(self.items(False, False))})"
250# Some values used more than once below
251_project_id = google.auth.default()[1]
252_app_version = os.getenv("GAE_VERSION")
254# Determine our basePath (as os.getCWD is broken on appengine)
255_project_base_path = Path().absolute()
256_core_base_path = Path(__file__).parent.parent.parent # fixme: this points to site-packages!!!
259class Admin(ConfigType):
260 """Administration tool configuration"""
262 name: str = "ViUR"
263 """Administration tool configuration"""
265 logo: str = ""
266 """URL for the Logo in the Topbar of the VI"""
268 login_background: str = ""
269 """URL for the big Image in the background of the VI Login screen"""
271 login_logo: str = ""
272 """URL for the Logo over the VI Login screen"""
274 color_primary: str = "#d00f1c"
275 """primary color for viur-admin"""
277 color_secondary: str = "#333333"
278 """secondary color for viur-admin"""
280 module_groups: dict[str, dict[t.Literal["name", "icon", "sortindex"], str | int]] = {}
281 """Module Groups for the admin tool
283 Group modules in the sidebar in categories (groups).
285 Example:
286 conf.admin.module_groups = {
287 "content": {
288 "name": "Content",
289 "icon": "file-text-fill",
290 "sortindex": 10,
291 },
292 "shop": {
293 "name": "Shop",
294 "icon": "cart-fill",
295 "sortindex": 20,
296 },
297 }
299 To add a module to one of these groups (e.g. content), add `moduleGroup` to
300 the admin_info of the module:
301 "moduleGroup": "content",
302 """
304 _mapping: dict[str, str] = {
305 "login.background": "login_background",
306 "login.logo": "login_logo",
307 "color.primary": "color_primary",
308 "color.secondary": "color_secondary",
309 }
312class Database(ConfigType):
313 query_external_limit: int = 100
314 """Sets the maximum query limit allowed by external filters."""
316 query_default_limit: int = 30
317 """Sets the default query limit for all queries."""
319 memcache_client: Client | None = None
320 """If set, ViUR cache data for the db.get in the Memcache for faster access."""
322 create_access_log: bool = True
323 """If False no access log will be created. But then the caching is disabled too."""
325 transaction_attempts: int = 3
326 """How often :func:`db.run_in_transaction <viur.core.db.transport.run_in_transaction>` runs
327 a transaction before giving up on conflicts; must be at least 1. Retries back off exponentially
328 (1s, 2s, 4s, ...)."""
330 name: str | None = os.getenv("VIUR_DB_NAME") or None
331 """Named datastore to target instead of ``(default)``.
333 Env-sourced: the client is built at ``db.transport`` import time, before any
334 runtime config could set it."""
336 namespace: str | None = os.getenv("VIUR_DB_NAMESPACE") or None
337 """Datastore namespace to scope to. Env-sourced like `name`."""
340class Security(ConfigType):
341 """Security related settings"""
343 force_ssl: bool = True
344 """If true, all requests must be encrypted (ignored on development server)"""
346 no_ssl_check_urls: Multiple[str] = ["/_tasks*", "/ah/*"]
347 """List of URLs for which force_ssl is ignored.
348 Add an asterisk to mark that entry as a prefix (exact match otherwise)"""
350 content_security_policy: t.Optional[dict[str, dict[str, list[str]]]] = {
351 "enforce": {
352 "style-src": ["self", "https://accounts.google.com/gsi/style"],
353 "default-src": ["self"],
354 "img-src": ["self", "storage.googleapis.com"], # Serving-URLs of file-Bones will point here
355 "script-src": ["self", "https://accounts.google.com/gsi/client"],
356 # Required for login with Google
357 "frame-src": ["self", "www.google.com", "drive.google.com", "accounts.google.com"],
358 "form-action": ["self"],
359 "connect-src": ["self", "accounts.google.com"],
360 "upgrade-insecure-requests": [],
361 "object-src": ["none"],
362 }
363 }
364 """If set, viur will emit a CSP http-header with each request. Use security.addCspRule to set this property"""
366 reporting_endpoints: dict[str, str] = {}
367 """Named endpoints reports are being sent to, emitted as ``Reporting-Endpoints`` http-header.
369 Maps an endpoint name to the URL receiving the reports. Other headers reference these names,
370 for example the CSP-directive ``report-to``, which supersedes the deprecated ``report-uri``.
371 The name ``default`` is used by the browser for reports whose header cannot name an endpoint
372 on its own (i.e. deprecation reports).
374 Use :func:`~viur.core.securityheaders.set_reporting_endpoint` to set this property.
376 See https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Reporting-Endpoints
377 """
379 referrer_policy: str = "strict-origin"
380 """Per default, we'll emit Referrer-Policy: strict-origin so no referrers leak to external services
382 See https://www.w3.org/TR/referrer-policy/
383 """
385 permissions_policy: dict[str, list[str]] = {
386 "autoplay": ["self"],
387 "camera": [],
388 "display-capture": [],
389 "document-domain": [],
390 "encrypted-media": [],
391 "fullscreen": [],
392 "geolocation": [],
393 "microphone": [],
394 "publickey-credentials-get": [],
395 "usb": [],
396 }
397 """Include a default permissions-policy.
398 To use the camera or microphone, you'll have to call
399 :meth: securityheaders.setPermissionPolicyDirective to include at least "self"
400 """
402 enable_coep: bool = False
403 """Shall we emit Cross-Origin-Embedder-Policy: require-corp?"""
405 enable_coop: t.Literal[
406 "unsafe-none", "same-origin-allow-popups",
407 "same-origin", "same-origin-plus-COEP"] = "same-origin"
408 """Emit a Cross-Origin-Opener-Policy Header?
410 See https://html.spec.whatwg.org/multipage/browsers.html#cross-origin-opener-policy-value
411 """
413 enable_corp: t.Literal["same-origin", "same-site", "cross-origin"] = "same-origin"
414 """Emit a Cross-Origin-Resource-Policy Header?
416 See https://fetch.spec.whatwg.org/#cross-origin-resource-policy-header
417 """
419 strict_transport_security: t.Optional[str] = "max-age=22118400"
420 """If set, ViUR will emit a HSTS HTTP-header with each request.
421 Use security.enableStrictTransportSecurity to set this property"""
423 x_frame_options: t.Optional[
424 tuple[t.Literal["deny", "sameorigin", "allow-from"], t.Optional[str]]
425 ] = ("sameorigin", None)
426 """If set, ViUR will emit an X-Frame-Options header
428 In case of allow-from, the second parameters must be the host-url.
429 Otherwise, it can be None.
430 """
432 x_xss_protection: t.Optional[bool] = True
433 """ViUR will emit an X-XSS-Protection header if set (the default)"""
435 x_content_type_options: bool = True
436 """ViUR will emit X-Content-Type-Options: nosniff Header unless set to False"""
438 x_permitted_cross_domain_policies: t.Optional[t.Literal["none", "master-only", "by-content-type", "all"]] = "none"
439 """Unless set to logical none; ViUR will emit a X-Permitted-Cross-Domain-Policies with each request"""
441 captcha_default_public_key: t.Optional[str] = None
442 """The default sitekey and secret to use for the :class:`CaptchaBone`.
443 If set, must be a dictionary of "sitekey" and "secret".
444 """
446 captcha_enforce_always: bool = False
447 """By default a captcha of the :class:`CaptchaBone` must not be solved on a local development server
448 or by a root user. But for development it can be helpful to test the implementation
449 on a local development server. Setting this flag to True, disables this behavior and
450 enforces always a valid captcha.
451 """
453 password_recovery_key_length: int = 42
454 """Length of the Password recovery key"""
456 closed_system: bool = False
457 """If `True` it activates a mode in which only authenticated users can access all routes."""
459 admin_allowed_paths: t.Iterable[str] = [
460 "vi",
461 "vi/config",
462 "vi/skey",
463 "vi/user/auth_*",
464 "vi/user/f2_*",
465 "vi/user/login",
466 "vi/user/select_authentication_provider",
467 # DEPRECATED:
468 "vi/settings", # FIXME: Deprecated; vi-admin 4.x backward compatiblity
469 "vi/user/getAuthMethods", # FIXME: Deprecated; vi-admin 4.x backward compatiblity
470 ]
471 """Specifies admin tool paths which are being accessible without authenticated user."""
473 closed_system_allowed_paths: t.Iterable[str] = admin_allowed_paths + [
474 "", # index site
475 "json/skey",
476 "json/user/auth_*",
477 "json/user/f2_*",
478 "json/user/getAuthMethods", # FIXME: deprecated, use `login` for this
479 "json/user/login",
480 "user/auth_*",
481 "user/f2_*",
482 "user/getAuthMethods", # FIXME: deprecated, use `login` for this
483 "user/select_authentication_provider",
484 "user/login",
485 ]
486 """Paths that are accessible without authentication in a closed system, see `closed_system` for details."""
488 # CORS Settings
490 cors_origins: t.Iterable[str | re.Pattern] | t.Literal["*"] = []
491 r"""Allowed origins
492 Access-Control-Allow-Origin
494 Pattern should be case-insensitive, for example:
495 >>> re.compile(r"^http://localhost:(\d{4,5})/?$", flags=re.IGNORECASE)
496 """ # noqa
498 cors_origins_use_wildcard: bool = False
499 """Use * for Access-Control-Allow-Origin -- if possible"""
501 cors_methods: t.Iterable[str] = ["get", "head", "post", "options"] # , "put", "patch", "delete"]
502 """Access-Control-Request-Method"""
504 cors_allow_headers: t.Iterable[str | re.Pattern] | t.Literal["*"] = []
505 """Access-Control-Request-Headers
507 Can also be set for specific @exposed methods with the @cors decorator.
509 Pattern should be case-insensitive, for example:
510 >>> re.compile(r"^X-ViUR-.*$", flags=re.IGNORECASE)
511 """
513 cors_allow_credentials: bool = False
514 """
515 Set Access-Control-Allow-Credentials to true
516 to support fetch requests with credentials: include
517 """
519 cors_max_age: datetime.timedelta | None = None
520 """Allow caching"""
522 _mapping = {
523 "contentSecurityPolicy": "content_security_policy",
524 "referrerPolicy": "referrer_policy",
525 "permissionsPolicy": "permissions_policy",
526 "enableCOEP": "enable_coep",
527 "enableCOOP": "enable_coop",
528 "enableCORP": "enable_corp",
529 "strictTransportSecurity": "strict_transport_security",
530 "xFrameOptions": "x_frame_options",
531 "xXssProtection": "x_xss_protection",
532 "xContentTypeOptions": "x_content_type_options",
533 "xPermittedCrossDomainPolicies": "x_permitted_cross_domain_policies",
534 }
537class Debug(ConfigType):
538 """Several debug flags"""
540 trace: bool = False
541 """If enabled, trace any routing, HTTPExceptions and decorations for debugging and insight"""
543 trace_exceptions: bool = False
544 """If enabled, user-generated exceptions from the viur.core.errors module won't be caught and handled"""
546 trace_external_call_routing: bool = False
547 """If enabled, ViUR will log which (exposed) function are called from outside with what arguments"""
549 trace_internal_call_routing: bool = False
550 """If enabled, ViUR will log which (internal-exposed) function are called from templates with what arguments"""
552 trace_queries: bool = False
553 """If enabled, ViUR will log each query that run"""
555 skeleton_from_client: bool = False
556 """If enabled, log errors raises from skeleton.fromClient()"""
558 dev_server_cloud_logging: bool = False
559 """If disabled the local logging will not send with requestLogger to the cloud"""
561 disable_cache: bool = False
562 """If set to true, the decorator @enableCache from viur.core.cache has no effect"""
564 _mapping = {
565 "skeleton.fromClient": "skeleton_from_client",
566 "traceExceptions": "trace_exceptions",
567 "traceExternalCallRouting": "trace_external_call_routing",
568 "traceInternalCallRouting": "trace_internal_call_routing",
569 "skeleton_fromClient": "skeleton_from_client",
570 "disableCache": "disable_cache",
571 }
574class Email(ConfigType):
575 """Email related settings."""
577 log_retention: datetime.timedelta = datetime.timedelta(days=30)
578 """For how long we'll keep successfully send emails in the viur-emails table"""
580 transport_class: "EmailTransport" = None
581 """EmailTransport instance that actually delivers the email using the service provider
582 of choice. See :module:`core.email` for more details
583 """
585 send_from_local_development_server: bool = False
586 """If set, we'll enable sending emails from the local development server.
587 Otherwise, they'll just be logged.
588 """
590 recipient_override: str | list[str] | t.Callable[[], str | list[str]] | t.Literal[False] = None
591 """If set, all outgoing emails will be sent to this address
592 (overriding the 'dests'-parameter in :meth:`core.email.send_email`)
593 """
595 sender_default: str = f"viur@{_project_id}.appspotmail.com"
596 """This sender is used by default for emails.
597 It can be overridden for a specific email by passing the `sender` argument
598 to :meth:`core.email.send_email` or for all emails with :attr:`sender_override`.
599 """
601 sender_override: str | None = None
602 """If set, this sender will be used, regardless of what the templates advertise as sender"""
604 admin_recipients: str | list[str] | t.Callable[[], str | list[str]] = None
605 """Sets recipients for mails send with :meth:`core.email.send_email_to_admins`.
606 If not set, all root users will be used."""
608 _mapping = {
609 "logRetention": "log_retention",
610 "transportClass": "transport_class",
611 "sendFromLocalDevelopmentServer": "send_from_local_development_server",
612 "recipientOverride": "recipient_override",
613 "senderOverride": "sender_override",
614 "sendInBlue.apiKey": "sendinblue_api_key",
615 "sendInBlue.thresholds": "sendinblue_thresholds",
616 }
619class History(ConfigType):
620 databases: Multiple[str] = ["viur"]
621 """All history related settings."""
622 excluded_actions: Multiple[str] = []
623 """List of all action that are should not be logged."""
624 excluded_kinds: Multiple[str] = []
625 """List of all kinds that should be logged."""
628class I18N(ConfigType):
629 """All i18n, multilang related settings."""
631 available_languages: Multiple[str] = ["en"]
632 """List of language-codes, which are valid for this application"""
634 default_language: str = "en"
635 """Unless overridden by the Project: Use english as default language"""
637 domain_language_mapping: dict[str, str] = {}
638 """Maps Domains to alternative default languages"""
640 language_alias_map: dict[str, str] = {}
641 """Allows mapping of certain languages to one translation (i.e. us->en)"""
643 fallback_languages: Multiple[str] = []
644 """Languages tried in order when the requested language has no translation"""
646 sources: Multiple["i18n.TranslationSource"] = None
647 """Translation sources, loaded in order; None uses i18n.DEFAULT_TRANSLATION_SOURCES"""
649 language_method: t.Literal["session", "url", "domain", "header"] = "session"
650 """Defines how translations are applied:
651 - session: Per Session
652 - url: inject language prefix in url
653 - domain: one domain per language
654 - header: Per Http-Header
655 """
657 language_module_map: dict[str, dict[str, str]] = {}
658 """Maps modules to their translation (if set)"""
660 auto_translate_bones: bool = True
661 """Defines whether bone descr and categories should be automatically translated via i18n.translate-objects."""
663 @property
664 def available_dialects(self) -> list[str]:
665 """Main languages and language aliases"""
666 # Use a dict to keep the order and remove duplicates
667 res = dict.fromkeys(self.available_languages)
668 res |= self.language_alias_map
669 return list(res.keys())
671 add_missing_translations: (bool | str | t.Iterable[str] | "i18n.AddMissing"
672 | t.Callable[["i18n.translate"], t.Union[bool, "i18n.AddMissing"]]) = False
673 """Add missing translation into datastore, optionally with given fnmatch-patterns.
675 If a key is not found in the translation table when a translation is
676 rendered, a database entry is created with the key and hint and
677 default value (if set) so that the translations
678 can be entered in the administration.
680 Instead of setting add_missing_translations to a boolean, it can also be set to
681 a pattern or iterable of fnmatch-patterns; Only translation keys matching these
682 patterns will be automatically added.
683 If a callable is provided, it will be called with the translation object to make a complex decision.
684 """
686 def _dump_can_view(self, _key):
687 return bool(current_user.get())
689 dump_can_view: t.Callable[[t.Self, str], bool] = _dump_can_view
690 """Customizable callback for translation.dump() to verify if a specific translation key can be queried.
692 This logic is omitted for translations flagged public."""
695class User(ConfigType):
696 """User, session, login related settings"""
698 access_rights: Multiple[str] = [
699 "root",
700 "admin",
701 "scriptor",
702 ]
703 """Additional access flags available for users on this project.
705 There are three default flags:
706 - `root` is allowed to view/add/edit/delete any module, regardless of role or other settings
707 - `admin` is allowed to use the ViUR administration tool
708 - `scriptor` is allowed to use the ViUR scripting features directly within the admin
709 This does not affect scriptor actions which are configured for modules, as they allow for
710 fine grained usage rule definitions.
711 """
713 roles: dict[str, str] = {
714 "custom": "Custom",
715 "user": "User",
716 "viewer": "Viewer",
717 "editor": "Editor",
718 "admin": "Administrator",
719 }
720 """User roles available on this project.
722 The roles can be individually defined per module, see `Module.roles`.
724 The default roles can be described as follows:
726 - `custom` for users with a custom-settings via the `User.access`-bone; includes root users.
727 - `user` for users without any additonal rights. They can log-in and view themselves, or particular modules which
728 just check for authenticated users.
729 - `viewer` for users who should only view content.
730 - `editor` for users who are allowed to edit particular content. They mostly can `view` and `edit`, but not `add`
731 or `delete`.
732 - `admin` for users with administration privileges. They can edit any data, but still aren't `root`.
734 The preset roles are for guidiance, and already fit to most projects.
735 """
737 session_life_time: datetime.timedelta = datetime.timedelta(hours=1)
738 """Default is 60 minutes lifetime for ViUR sessions"""
740 session_persistent_fields_on_login: Multiple[str] = ["language"]
741 """If set, these Fields will survive the session.reset() called on user/login"""
743 session_persistent_fields_on_logout: Multiple[str] = ["language"]
744 """If set, these Fields will survive the session.reset() called on user/logout"""
746 max_password_length: int = 512
747 """Prevent Denial of Service attacks using large inputs for pbkdf2"""
749 otp_issuer: t.Optional[str] = None
750 """The name of the issuer for the opt token"""
752 google_client_id: t.Optional[str] = None
753 """OAuth Client ID for Google Login"""
755 google_gsuite_domains: list[str] = []
756 """A list of domains. When a user signs in for the first time with a
757 Google account using Google OAuth sign-in, and the user's email address
758 belongs to one of the listed domains, a user account (UserSkel) is created.
759 If the user's email address belongs to any other domain,
760 no account is created."""
762 redirect_whitelist: list[str] | t.Callable[[], list[str]] = (
763 lambda _: ["http://localhost:*", f"https://*{_project_id}.appspot.com*"]
764 )
765 """Allowed redirect_to patterns for get_cookie_for_app (matched via :func:`fnmatch.fnmatch`).
767 The default is a callable that permits only ``http://localhost:*`` and any URL
768 containing the current GCP project-ID — a safe built-in policy.
769 A zero-argument callable is supported and evaluated on every request.
770 Use ``["*"]`` to disable the restriction entirely.
772 Examples::
774 conf.user.redirect_whitelist = [
775 "http://localhost:*",
776 "https://*.myapp.appspot.com*",
777 ]
779 # dynamic / lazily evaluated
780 conf.user.redirect_whitelist = lambda: load_whitelist_from_db()
781 """
783 def __setattr__(self, name: str, value: t.Any) -> None:
784 if name == "session_life_time": 784 ↛ 785line 784 didn't jump to line 785 because the condition on line 784 was never true
785 if not isinstance(value, datetime.timedelta):
786 from viur.core import utils
787 warnings.warn(
788 "Please use timedelta to set session_life_time.",
789 DeprecationWarning, stacklevel=2,
790 )
791 value = utils.parse.timedelta(value)
792 super().__setattr__(name, value)
795class Instance(ConfigType):
796 """All app instance related settings information"""
797 app_version: str = _app_version
798 """Name of this version as deployed to the appengine"""
800 core_base_path: Path = _core_base_path
801 """The base path of the core, can be used to find file in the core folder"""
803 is_dev_server: bool = os.getenv("GAE_ENV") == "localdev"
804 """Determine whether instance is running on a local development server"""
806 project_base_path: Path = _project_base_path
807 """The base path of the project, can be used to find file in the project folder"""
809 project_id: str = _project_id
810 """The instance's project ID"""
812 version_hash: str = hashlib.sha256(f"{_app_version}{project_id}".encode("UTF-8")).hexdigest()[:10]
813 """Version hash that does not reveal the actual version name, can be used for cache-busting static resources"""
816class Conf(ConfigType):
817 """Conf class wraps the conf dict and allows to handle
818 deprecated keys or other special operations.
819 """
821 bone_boolean_str2true: Multiple[str | int] = ("true", "yes", "1")
822 """Allowed values that define a str to evaluate to true"""
824 bone_string_escape_html: bool = True
825 """Default escape_html setting for StringBone. Set to False to disable HTML escaping globally."""
827 bone_strict_mode: bool = os.getenv("VIUR_CORE_BONE_STRICT_MODE", "").lower() != "false"
828 """If enabled (the default), setting an *unknown* attribute on a bone after its construction
829 raises an AttributeError instead of silently creating it -- this catches typos like ``readonly``
830 instead of ``readOnly``. Disable via ``conf.bone_strict_mode = False`` or the environment
831 variable ``VIUR_CORE_BONE_STRICT_MODE=false``."""
833 bone_html_default_allow: "HtmlBoneConfiguration" = {
834 "validTags": [
835 "a",
836 "abbr",
837 "b",
838 "blockquote",
839 "br",
840 "div",
841 "em",
842 "h1",
843 "h2",
844 "h3",
845 "h4",
846 "h5",
847 "h6",
848 "hr",
849 "i",
850 "img",
851 "li",
852 "ol",
853 "p",
854 "span",
855 "strong",
856 "sub",
857 "sup",
858 "table",
859 "tbody",
860 "td",
861 "tfoot",
862 "th",
863 "thead",
864 "tr",
865 "u",
866 "ul",
867 ],
868 "validAttrs": {
869 "a": [
870 "href",
871 "target",
872 "title",
873 ],
874 "abbr": [
875 "title",
876 ],
877 "blockquote": [
878 "cite",
879 ],
880 "img": [
881 "src",
882 "alt",
883 "title",
884 ],
885 "p": [
886 "data-indent",
887 ],
888 "span": [
889 "title",
890 ],
891 "td": [
892 "colspan",
893 "rowspan",
894 ],
896 },
897 "validStyles": [
898 "color",
899 ],
900 "validClasses": [
901 "vitxt-*",
902 "viur-txt-*"
903 ],
904 "singleTags": [
905 "br",
906 "hr",
907 "img",
908 ]
909 }
910 """
911 A dictionary containing default configurations for handling HTML content in TextBone instances.
912 """
914 cache_environment_key: t.Optional[t.Callable[[], str]] = None
915 """If set, this function will be called for each cache-attempt
916 and the result will be included in the computed cache-key"""
918 # FIXME VIUR4: REMOVE ALL COMPATIBILITY MODES!
919 compatibility: Multiple[str] = [
920 # "json.bone.structure.camelcasenames", # use camelCase attribute names (see #637 for details)
921 # "json.bone.structure.keytuples", # use classic structure notation: `"structure = [["key", {...}] ...]` (#649)
922 # "json.bone.structure.inlists", # dump skeleton structure with every JSON list response (#774 for details)
923 # "tasks.periodic.useminutes", # Interpret int/float values for @PeriodicTask as minutes
924 # # instead of seconds (#1133 for details)
925 # "bone.select.structure.values.keytuple", # render old-style tuple-list in SelectBone's
926 # values structure (#1203)
927 ]
928 """Backward compatibility flags; Remove to enforce new style."""
930 error_handler: t.Callable[[Exception], str] | None = None
931 """If set, ViUR calls this function instead of rendering the viur.errorTemplate if an exception occurs"""
933 error_logo: str = None
934 """Path to a logo (static file). Will be used for the default error template"""
936 static_embed_svg_path: str = "/static/svgs/"
937 """Path to the static SVGs folder. Will be used by the jinja-renderer-method: embedSvg"""
939 file_hmac_key: str = None
940 """Hmac-Key used to sign download urls - set automatically"""
942 # TODO: separate this type hints and use it in the File module as well
943 file_derivations: dict[str, t.Callable[["SkeletonInstance", dict, dict], list[tuple[str, float, str, t.Any]]]] = {}
944 """Call-Map for file pre-processors"""
946 file_thumbnailer_url: t.Optional[str] = None
947 # TODO: """docstring"""
949 main_app: "Module" = None
950 """Reference to our pre-build Application-Instance"""
952 main_resolver: dict[str, dict] = None
953 """Dictionary for Resolving functions for URLs"""
955 max_post_params_count: int = 250
956 """Upper limit of the amount of parameters we accept per request. Prevents Hash-Collision-Attacks"""
958 param_filter_function: t.Callable[[str, str], bool] = lambda _, key, value: key.startswith("_")
959 """
960 Function which decides if a request parameter should be used or filtered out.
961 Returning True means to filter out.
962 """
964 moduleconf_admin_info: dict[str, t.Any] = {
965 "icon": "gear-fill",
966 "display": "hidden",
967 }
968 """Describing the internal ModuleConfig-module"""
970 script_admin_info: dict[str, t.Any] = {
971 "icon": "file-code-fill",
972 "display": "hidden",
973 }
974 """Describing the Script module"""
976 render_html_download_url_expiration: t.Optional[float | int] = None
977 """The default duration, for which downloadURLs generated by the html renderer will stay valid"""
979 render_json_download_url_expiration: t.Optional[float | int] = None
980 """The default duration, for which downloadURLs generated by the json renderer will stay valid"""
982 request_preprocessor: t.Optional[t.Callable[[str], str]] = None
983 """Allows the application to register a function that's called before the request gets routed"""
985 search_valid_chars: str = "abcdefghijklmnopqrstuvwxyzäöüß0123456789"
986 """Characters valid for the internal search functionality (all other chars are ignored)"""
988 skeleton_search_path: Multiple[str] = [
989 "/skeletons/", # skeletons of the project
990 "/viur/core/", # system-defined skeletons of viur-core
991 "/viur/src/viur/core/", # fixme: test suite
992 "/viur-core/core/" # system-defined skeletons of viur-core, only used by editable installation
993 ]
994 """Priority, in which skeletons are loaded"""
996 _tasks_custom_environment_handler: t.Optional["CustomEnvironmentHandler"] = None
998 @property
999 def tasks_custom_environment_handler(self) -> t.Optional["CustomEnvironmentHandler"]:
1000 """
1001 Preserve additional environment in deferred tasks.
1003 If set, it must be an instance of CustomEnvironmentHandler
1004 for serializing/restoring environment data.
1005 """
1006 return self._tasks_custom_environment_handler
1008 @tasks_custom_environment_handler.setter
1009 def tasks_custom_environment_handler(self, value: "CustomEnvironmentHandler") -> None:
1010 from .tasks import CustomEnvironmentHandler
1011 if isinstance(value, CustomEnvironmentHandler) or value is None:
1012 self._tasks_custom_environment_handler = value
1013 elif isinstance(value, tuple):
1014 if len(value) != 2:
1015 raise ValueError(f"Expected a (serialize_env_func, restore_env_func) pair")
1016 warnings.warn(
1017 f"tuple is deprecated, please provide a CustomEnvironmentHandler object!",
1018 DeprecationWarning, stacklevel=2,
1019 )
1020 # Construct an CustomEnvironmentHandler class on the fly to be backward compatible
1021 cls = type("ProjectCustomEnvironmentHandler", (CustomEnvironmentHandler,),
1022 # serialize and restore will be bound methods.
1023 # Therefore, consume the self argument with lambda.
1024 {"serialize": lambda self: value[0](),
1025 "restore": lambda self, obj: value[1](obj)})
1026 self._tasks_custom_environment_handler = cls()
1027 else:
1028 raise ValueError(f"Invalid type {type(value)}. Expected a CustomEnvironmentHandler object.")
1030 tasks_default_queues: dict[str, str] = {
1031 "__default__": "default",
1032 }
1033 """
1034 @CallDeferred tasks run in the Cloud Tasks Queue "default" by default.
1035 One way to run them in a different task queue is to use the `_queue` parameter
1036 when calling the task.
1037 However, as this is not possible for existing or low-hanging calls,
1038 default values can be defined here for each task.
1039 To do this, the task path must be mapped to the queue name:
1040 ```
1041 conf.tasks_default_queues["update_relations.viur.core.skeleton"] = "update_relations"
1042 ```
1043 The queue (in the example: `"update_relations"`) must exist.
1044 The default queue can be changed by overwriting `"__default__"`.
1045 """
1047 valid_application_ids: list[str] = ["*"]
1048 """Which application-ids we're supposed to run on"""
1050 version: tuple[int, int, int] = tuple(int(part) if part.isdigit() else part for part in __version__.split(".", 3))
1051 """Semantic version number of viur-core as a tuple of 3 (major, minor, patch-level)"""
1053 viur2import_blobsource: t.Optional[dict[t.Literal["infoURL", "gsdir"], str]] = None
1054 """Configuration to import file blobs from ViUR2"""
1056 def __init__(self, strict_mode: bool = False):
1057 super().__init__()
1058 self._strict_mode = strict_mode
1059 self.admin = Admin(parent=self)
1060 self.db = Database(parent=self)
1061 self.security = Security(parent=self)
1062 self.debug = Debug(parent=self)
1063 self.email = Email(parent=self)
1064 self.i18n = I18N(parent=self)
1065 self.user = User(parent=self)
1066 self.instance = Instance(parent=self)
1067 self.history = History(parent=self)
1069 _mapping = {
1070 # debug
1071 "viur.dev_server_cloud_logging": "debug.dev_server_cloud_logging",
1072 "viur.disable_cache": "debug.disable_cache",
1073 # i18n
1074 "viur.availableLanguages": "i18n.available_languages",
1075 "viur.defaultLanguage": "i18n.default_language",
1076 "viur.domainLanguageMapping": "i18n.domain_language_mapping",
1077 "viur.languageAliasMap": "i18n.language_alias_map",
1078 "viur.languageMethod": "i18n.language_method",
1079 "viur.languageModuleMap": "i18n.language_module_map",
1080 # user
1081 "viur.accessRights": "user.access_rights",
1082 "viur.maxPasswordLength": "user.max_password_length",
1083 "viur.otp.issuer": "user.otp_issuer",
1084 "viur.session.lifeTime": "user.session_life_time",
1085 "viur.session.persistentFieldsOnLogin": "user.session_persistent_fields_on_login",
1086 "viur.session.persistentFieldsOnLogout": "user.session_persistent_fields_on_logout",
1087 "viur.user.roles": "user.roles",
1088 "viur.user.google.clientID": "user.google_client_id",
1089 "viur.user.google.gsuiteDomains": "user.google_gsuite_domains",
1090 # instance
1091 "viur.instance.app_version": "instance.app_version",
1092 "viur.instance.core_base_path": "instance.core_base_path",
1093 "viur.instance.is_dev_server": "instance.is_dev_server",
1094 "viur.instance.project_base_path": "instance.project_base_path",
1095 "viur.instance.project_id": "instance.project_id",
1096 "viur.instance.version_hash": "instance.version_hash",
1097 # security
1098 "viur.forceSSL": "security.force_ssl",
1099 "viur.noSSLCheckUrls": "security.no_ssl_check_urls",
1100 # old viur-prefix
1101 "viur.cacheEnvironmentKey": "cache_environment_key",
1102 "viur.contentSecurityPolicy": "content_security_policy",
1103 "viur.bone.boolean.str2true": "bone_boolean_str2true",
1104 "viur.errorHandler": "error_handler",
1105 "viur.static.embedSvg.path": "static_embed_svg_path",
1106 "viur.file.hmacKey": "file_hmac_key",
1107 "viur.file_hmacKey": "file_hmac_key",
1108 "viur.file.derivers": "file_derivations",
1109 "viur.file.thumbnailerURL": "file_thumbnailer_url",
1110 "viur.mainApp": "main_app",
1111 "viur.mainResolver": "main_resolver",
1112 "viur.maxPostParamsCount": "max_post_params_count",
1113 "viur.moduleconf.admin_info": "moduleconf_admin_info",
1114 "viur.script.admin_info": "script_admin_info",
1115 "viur.render.html.downloadUrlExpiration": "render_html_download_url_expiration",
1116 "viur.downloadUrlFor.expiration": "render_html_download_url_expiration",
1117 "viur.render.json.downloadUrlExpiration": "render_json_download_url_expiration",
1118 "viur.requestPreprocessor": "request_preprocessor",
1119 "viur.searchValidChars": "search_valid_chars",
1120 "viur.skeleton.searchPath": "skeleton_search_path",
1121 "viur.tasks.customEnvironmentHandler": "tasks_custom_environment_handler",
1122 "viur.validApplicationIDs": "valid_application_ids",
1123 "viur.viur2import.blobsource": "viur2import_blobsource",
1124 }
1126 def _resolve_mapping(self, key: str) -> str:
1127 """Additional mapping for new sub confs."""
1128 if key.startswith("viur.") and key not in self._mapping:
1129 key = key.removeprefix("viur.")
1130 return super()._resolve_mapping(key)
1133conf = Conf(
1134 strict_mode=os.getenv("VIUR_CORE_CONFIG_STRICT_MODE", "").lower() != "false",
1135)