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

1import datetime 

2import hashlib 

3import logging 

4import os 

5import re 

6import typing as t 

7import warnings 

8from pathlib import Path 

9 

10import google.auth 

11 

12from viur.core.version import __version__ 

13from viur.core.current import user as current_user 

14 

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 

22 

23 

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 

29 

30 

31class CaptchaDefaultCredentialsType(t.TypedDict): 

32 """Expected type of global captcha credential, see :attr:`Security.captcha_default_credentials`""" 

33 sitekey: str 

34 secret: str 

35 

36 

37class ConfigType: 

38 """An abstract class for configurations. 

39 

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""" 

44 

45 _strict_mode = None 

46 """Internal strict mode for this instance. 

47 

48 Use the property getter and setter to access it!""" 

49 

50 _parent = None 

51 """Parent config instance""" 

52 

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 

59 

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()}." 

66 

67 @property 

68 def strict_mode(self): 

69 """Determine if the config runs in strict mode. 

70 

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. 

74 

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 

85 

86 @strict_mode.setter 

87 def strict_mode(self, value: bool | None) -> None: 

88 """Setter for the strict mode of the current instance. 

89 

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 

95 

96 def _resolve_mapping(self, key: str) -> str: 

97 """Resolve the mapping old dict -> new attribute. 

98 

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 

110 

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. 

116 

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 

133 

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. 

136 

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 

150 

151 def __getitem__(self, key: str) -> t.Any: 

152 """Support the old dict-like syntax (getter). 

153 

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) 

161 

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 ) 

168 

169 return getattr(self, key) 

170 

171 def __getattr__(self, key: str) -> t.Any: 

172 """Resolve dot-notation and name mapping in not strict mode. 

173 

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 ) 

183 

184 key = self._resolve_mapping(key) 

185 

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) 

190 

191 return super().__getattribute__(key) 

192 

193 def __setitem__(self, key: str, value: t.Any) -> None: 

194 """Support the old dict-like syntax (setter). 

195 

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 ) 

205 

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 

213 

214 key = self._resolve_mapping(key) 

215 

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 

226 

227 return setattr(self, key, value) 

228 

229 def __setattr__(self, key: str, value: t.Any) -> None: 

230 """Set attributes after applying the old -> new mapping 

231 

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) 

237 

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) 

240 

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) 

246 

247 return super().__setattr__(key, value) 

248 

249 def __repr__(self) -> str: 

250 """Representation of this config""" 

251 return f"{self.__class__.__qualname__}({dict(self.items(False, False))})" 

252 

253 

254# Some values used more than once below 

255_project_id = google.auth.default()[1] 

256_app_version = os.getenv("GAE_VERSION") 

257 

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!!! 

261 

262 

263class Admin(ConfigType): 

264 """Administration tool configuration""" 

265 

266 name: str = "ViUR" 

267 """Administration tool configuration""" 

268 

269 logo: str = "" 

270 """URL for the Logo in the Topbar of the VI""" 

271 

272 login_background: str = "" 

273 """URL for the big Image in the background of the VI Login screen""" 

274 

275 login_logo: str = "" 

276 """URL for the Logo over the VI Login screen""" 

277 

278 color_primary: str = "#d00f1c" 

279 """primary color for viur-admin""" 

280 

281 color_secondary: str = "#333333" 

282 """secondary color for viur-admin""" 

283 

284 module_groups: dict[str, dict[t.Literal["name", "icon", "sortindex"], str | int]] = {} 

285 """Module Groups for the admin tool 

286 

287 Group modules in the sidebar in categories (groups). 

288 

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 } 

302 

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 """ 

307 

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 } 

314 

315 

316class Security(ConfigType): 

317 """Security related settings""" 

318 

319 force_ssl: bool = True 

320 """If true, all requests must be encrypted (ignored on development server)""" 

321 

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)""" 

325 

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""" 

341 

342 referrer_policy: str = "strict-origin" 

343 """Per default, we'll emit Referrer-Policy: strict-origin so no referrers leak to external services 

344 

345 See https://www.w3.org/TR/referrer-policy/ 

346 """ 

347 

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 """ 

364 

365 enable_coep: bool = False 

366 """Shall we emit Cross-Origin-Embedder-Policy: require-corp?""" 

367 

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? 

372 

373 See https://html.spec.whatwg.org/multipage/browsers.html#cross-origin-opener-policy-value 

374 """ 

375 

376 enable_corp: t.Literal["same-origin", "same-site", "cross-origin"] = "same-origin" 

377 """Emit a Cross-Origin-Resource-Policy Header? 

378 

379 See https://fetch.spec.whatwg.org/#cross-origin-resource-policy-header 

380 """ 

381 

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""" 

385 

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 

390 

391 In case of allow-from, the second parameters must be the host-url. 

392 Otherwise, it can be None. 

393 """ 

394 

395 x_xss_protection: t.Optional[bool] = True 

396 """ViUR will emit an X-XSS-Protection header if set (the default)""" 

397 

398 x_content_type_options: bool = True 

399 """ViUR will emit X-Content-Type-Options: nosniff Header unless set to False""" 

400 

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""" 

403 

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 """ 

408 

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 """ 

415 

416 password_recovery_key_length: int = 42 

417 """Length of the Password recovery key""" 

418 

419 closed_system: bool = False 

420 """If `True` it activates a mode in which only authenticated users can access all routes.""" 

421 

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.""" 

432 

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.""" 

446 

447 # CORS Settings 

448 

449 cors_origins: t.Iterable[str | re.Pattern] | t.Literal["*"] = [] 

450 """Allowed origins 

451 Access-Control-Allow-Origin 

452 

453 Pattern should be case-insensitive, for example: 

454 >>> re.compile(r"^http://localhost:(\d{4,5})/?$", flags=re.IGNORECASE) 

455 """ # noqa 

456 

457 cors_origins_use_wildcard: bool = False 

458 """Use * for Access-Control-Allow-Origin -- if possible""" 

459 

460 cors_methods: t.Iterable[str] = ["get", "head", "post", "options"] # , "put", "patch", "delete"] 

461 """Access-Control-Request-Method""" 

462 

463 cors_allow_headers: t.Iterable[str | re.Pattern] | t.Literal["*"] = [] 

464 """Access-Control-Request-Headers 

465 

466 Can also be set for specific @exposed methods with the @cors decorator. 

467 

468 Pattern should be case-insensitive, for example: 

469 >>> re.compile(r"^X-ViUR-.*$", flags=re.IGNORECASE) 

470 """ 

471 

472 cors_allow_credentials: bool = False 

473 """ 

474 Set Access-Control-Allow-Credentials to true 

475 to support fetch requests with credentials: include 

476 """ 

477 

478 cors_max_age: datetime.timedelta | None = None 

479 """Allow caching""" 

480 

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 } 

496 

497 

498class Debug(ConfigType): 

499 """Several debug flags""" 

500 

501 trace: bool = False 

502 """If enabled, trace any routing, HTTPExceptions and decorations for debugging and insight""" 

503 

504 trace_exceptions: bool = False 

505 """If enabled, user-generated exceptions from the viur.core.errors module won't be caught and handled""" 

506 

507 trace_external_call_routing: bool = False 

508 """If enabled, ViUR will log which (exposed) function are called from outside with what arguments""" 

509 

510 trace_internal_call_routing: bool = False 

511 """If enabled, ViUR will log which (internal-exposed) function are called from templates with what arguments""" 

512 

513 skeleton_from_client: bool = False 

514 """If enabled, log errors raises from skeleton.fromClient()""" 

515 

516 dev_server_cloud_logging: bool = False 

517 """If disabled the local logging will not send with requestLogger to the cloud""" 

518 

519 disable_cache: bool = False 

520 """If set to true, the decorator @enableCache from viur.core.cache has no effect""" 

521 

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 } 

530 

531 

532class Email(ConfigType): 

533 """Email related settings.""" 

534 

535 log_retention: datetime.timedelta = datetime.timedelta(days=30) 

536 """For how long we'll keep successfully send emails in the viur-emails table""" 

537 

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 """ 

542 

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 """ 

547 

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 """ 

552 

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 """ 

558 

559 sender_override: str | None = None 

560 """If set, this sender will be used, regardless of what the templates advertise as sender""" 

561 

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.""" 

565 

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 } 

575 

576 

577class I18N(ConfigType): 

578 """All i18n, multilang related settings.""" 

579 

580 available_languages: Multiple[str] = ["en"] 

581 """List of language-codes, which are valid for this application""" 

582 

583 default_language: str = "en" 

584 """Unless overridden by the Project: Use english as default language""" 

585 

586 domain_language_mapping: dict[str, str] = {} 

587 """Maps Domains to alternative default languages""" 

588 

589 language_alias_map: dict[str, str] = {} 

590 """Allows mapping of certain languages to one translation (i.e. us->en)""" 

591 

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 """ 

599 

600 language_module_map: dict[str, dict[str, str]] = {} 

601 """Maps modules to their translation (if set)""" 

602 

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()) 

610 

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. 

614 

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. 

619 

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 """ 

625 

626 def _dump_can_view(self, _key): 

627 return bool(current_user.get()) 

628 

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. 

631 

632 This logic is omitted for translations flagged public.""" 

633 

634 

635class User(ConfigType): 

636 """User, session, login related settings""" 

637 

638 access_rights: Multiple[str] = [ 

639 "root", 

640 "admin", 

641 "scriptor", 

642 ] 

643 """Additional access flags available for users on this project. 

644 

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 """ 

652 

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. 

661 

662 The roles can be individually defined per module, see `Module.roles`. 

663 

664 The default roles can be described as follows: 

665 

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`. 

673 

674 The preset roles are for guidiance, and already fit to most projects. 

675 """ 

676 

677 session_life_time: int = 60 * 60 

678 """Default is 60 minutes lifetime for ViUR sessions""" 

679 

680 session_persistent_fields_on_login: Multiple[str] = ["language"] 

681 """If set, these Fields will survive the session.reset() called on user/login""" 

682 

683 session_persistent_fields_on_logout: Multiple[str] = ["language"] 

684 """If set, these Fields will survive the session.reset() called on user/logout""" 

685 

686 max_password_length: int = 512 

687 """Prevent Denial of Service attacks using large inputs for pbkdf2""" 

688 

689 otp_issuer: t.Optional[str] = None 

690 """The name of the issuer for the opt token""" 

691 

692 google_client_id: t.Optional[str] = None 

693 """OAuth Client ID for Google Login""" 

694 

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.""" 

701 

702 

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""" 

707 

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""" 

710 

711 is_dev_server: bool = os.getenv("GAE_ENV") == "localdev" 

712 """Determine whether instance is running on a local development server""" 

713 

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""" 

716 

717 project_id: str = _project_id 

718 """The instance's project ID""" 

719 

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""" 

722 

723 

724class Conf(ConfigType): 

725 """Conf class wraps the conf dict and allows to handle 

726 deprecated keys or other special operations. 

727 """ 

728 

729 bone_boolean_str2true: Multiple[str | int] = ("true", "yes", "1") 

730 """Allowed values that define a str to evaluate to true""" 

731 

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 ], 

794 

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 """ 

812 

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""" 

816 

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.""" 

827 

828 db_engine: str = "viur.datastore" 

829 """Database engine module""" 

830 

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""" 

833 

834 error_logo: str = None 

835 """Path to a logo (static file). Will be used for the default error template""" 

836 

837 static_embed_svg_path: str = "/static/svgs/" 

838 """Path to the static SVGs folder. Will be used by the jinja-renderer-method: embedSvg""" 

839 

840 file_hmac_key: str = None 

841 """Hmac-Key used to sign download urls - set automatically""" 

842 

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""" 

846 

847 file_thumbnailer_url: t.Optional[str] = None 

848 # TODO: """docstring""" 

849 

850 main_app: "Module" = None 

851 """Reference to our pre-build Application-Instance""" 

852 

853 main_resolver: dict[str, dict] = None 

854 """Dictionary for Resolving functions for URLs""" 

855 

856 max_post_params_count: int = 250 

857 """Upper limit of the amount of parameters we accept per request. Prevents Hash-Collision-Attacks""" 

858 

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 """ 

864 

865 moduleconf_admin_info: dict[str, t.Any] = { 

866 "icon": "gear-fill", 

867 "display": "hidden", 

868 } 

869 """Describing the internal ModuleConfig-module""" 

870 

871 script_admin_info: dict[str, t.Any] = { 

872 "icon": "file-code-fill", 

873 "display": "hidden", 

874 } 

875 """Describing the Script module""" 

876 

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""" 

879 

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""" 

882 

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""" 

885 

886 search_valid_chars: str = "abcdefghijklmnopqrstuvwxyzäöüß0123456789" 

887 """Characters valid for the internal search functionality (all other chars are ignored)""" 

888 

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""" 

895 

896 _tasks_custom_environment_handler: t.Optional["CustomEnvironmentHandler"] = None 

897 

898 @property 

899 def tasks_custom_environment_handler(self) -> t.Optional["CustomEnvironmentHandler"]: 

900 """ 

901 Preserve additional environment in deferred tasks. 

902 

903 If set, it must be an instance of CustomEnvironmentHandler 

904 for serializing/restoring environment data. 

905 """ 

906 return self._tasks_custom_environment_handler 

907 

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.") 

929 

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 """ 

946 

947 valid_application_ids: list[str] = [] 

948 """Which application-ids we're supposed to run on""" 

949 

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)""" 

952 

953 viur2import_blobsource: t.Optional[dict[t.Literal["infoURL", "gsdir"], str]] = None 

954 """Configuration to import file blobs from ViUR2""" 

955 

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) 

966 

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 } 

1024 

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) 

1030 

1031 

1032conf = Conf( 

1033 strict_mode=os.getenv("VIUR_CORE_CONFIG_STRICT_MODE", "").lower() == "true", 

1034)