Coverage for /home/runner/work/viur-core/viur-core/viur/src/viur/core/config.py: 89%

340 statements  

« prev     ^ index     » next       coverage.py v7.6.10, created at 2025-02-07 19:28 +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__ 

13 

14if t.TYPE_CHECKING: # pragma: no cover 

15 from viur.core.bones.text import HtmlBoneConfiguration 

16 from viur.core.email import EmailTransport 

17 from viur.core.skeleton import SkeletonInstance 

18 from viur.core.module import Module 

19 from viur.core.tasks import CustomEnvironmentHandler 

20 

21 

22# Construct an alias with a generic type to be able to write Multiple[str] 

23# TODO: Backward compatible implementation, refactor when viur-core 

24# becomes >= Python 3.12 with a type statement (PEP 695) 

25_T = t.TypeVar("_T") 

26Multiple: t.TypeAlias = list[_T] | tuple[_T] | set[_T] | frozenset[_T] # TODO: Refactor for Python 3.12 

27 

28 

29class CaptchaDefaultCredentialsType(t.TypedDict): 

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

31 sitekey: str 

32 secret: str 

33 

34 

35class ConfigType: 

36 """An abstract class for configurations. 

37 

38 It ensures nesting and backward compatibility for the viur-core config 

39 """ 

40 _mapping = {} 

41 """Mapping from old dict-key (must not be the entire key in case of nesting) to new attribute name""" 

42 

43 _strict_mode = None 

44 """Internal strict mode for this instance. 

45 

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

47 

48 _parent = None 

49 """Parent config instance""" 

50 

51 def __init__(self, *, 

52 strict_mode: bool = None, 

53 parent: t.Union["ConfigType", None] = None): 

54 super().__init__() 

55 self._strict_mode = strict_mode 

56 self._parent = parent 

57 

58 @property 

59 def _path(self): 

60 """Get the path in dot-Notation to the current config instance.""" 

61 if self._parent is None: 

62 return "" 

63 return f"{self._parent._path}{self.__class__.__name__.lower()}." 

64 

65 @property 

66 def strict_mode(self): 

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

68 

69 In strict mode, the dict-item-access backward compatibility is disabled, 

70 only attribute access is allowed. 

71 Alias mapping is also disabled. Only the real attribute names are allowed. 

72 

73 If self._strict_mode is None, it would inherit the value 

74 of the parent. 

75 If it's explicitly set to True or False, that value will be used. 

76 """ 

77 if self._strict_mode is not None or self._parent is None: 

78 # This config has an explicit value set or there's no parent 

79 return self._strict_mode 

80 else: 

81 # no value set: inherit from the parent 

82 return self._parent.strict_mode 

83 

84 @strict_mode.setter 

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

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

87 

88 Does not affect other instances! 

89 """ 

90 if not isinstance(value, (bool, type(None))): 

91 raise TypeError(f"Invalid {value=} for strict mode!") 

92 self._strict_mode = value 

93 

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

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

96 

97 This method must not be called in strict mode! 

98 It can be overwritten to apply additional mapping. 

99 """ 

100 if key in self._mapping: 

101 old, key = key, self._mapping[key] 

102 warnings.warn( 

103 f"Conf member {self._path}{old} is now {self._path}{key}!", 

104 DeprecationWarning, 

105 stacklevel=3, 

106 ) 

107 return key 

108 

109 def items(self, 

110 full_path: bool = False, 

111 recursive: bool = True, 

112 ) -> t.Iterator[tuple[str, t.Any]]: 

113 """Get all setting of this config as key-value mapping. 

114 

115 :param full_path: Show prefix oder only the key. 

116 :param recursive: Call .items() on ConfigType members (children)? 

117 :return: 

118 """ 

119 for key in dir(self): 

120 if key.startswith("_"): 

121 # skip internals, like _parent and _strict_mode 

122 continue 

123 value = getattr(self, key) 

124 if recursive and isinstance(value, ConfigType): 

125 yield from value.items(full_path, recursive) 

126 elif key not in dir(ConfigType): 

127 if full_path: 127 ↛ 128line 127 didn't jump to line 128 because the condition on line 127 was never true

128 yield f"{self._path}{key}", value 

129 else: 

130 yield key, value 

131 

132 def get(self, key: str, default: t.Any = None) -> t.Any: 

133 """Return an item from the config, if it doesn't exist `default` is returned. 

134 

135 :param key: The key for the attribute lookup. 

136 :param default: The fallback value. 

137 :return: The attribute value or the fallback value. 

138 """ 

139 if self.strict_mode: 

140 raise SyntaxError( 

141 "In strict mode, the config must not be accessed " 

142 "with .get(). Only attribute access is allowed." 

143 ) 

144 try: 

145 return getattr(self, key) 

146 except (KeyError, AttributeError): 

147 return default 

148 

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

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

151 

152 Not allowed in strict mode. 

153 """ 

154 new_path = f"{self._path}{self._resolve_mapping(key)}" 

155 warnings.warn(f"conf uses now attributes! " 

156 f"Use conf.{new_path} to access your option", 

157 DeprecationWarning, 

158 stacklevel=2) 

159 

160 if self.strict_mode: 

161 raise SyntaxError( 

162 f"In strict mode, the config must not be accessed " 

163 f"with dict notation. " 

164 f"Only attribute access (conf.{new_path}) is allowed." 

165 ) 

166 

167 return getattr(self, key) 

168 

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

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

171 

172 This method is mostly executed by __getitem__, by the 

173 old dict-like access or by attr(conf, "key"). 

174 In strict mode it does nothing except raising an AttributeError. 

175 """ 

176 if self.strict_mode: 

177 raise AttributeError( 

178 f"AttributeError: '{self.__class__.__name__}' object has no" 

179 f" attribute '{key}' (strict mode is enabled)" 

180 ) 

181 

182 key = self._resolve_mapping(key) 

183 

184 # Got an old dict-key and resolve the segment to the first dot (.) as attribute. 

185 if "." in key: 

186 first, remaining = key.split(".", 1) 

187 return getattr(getattr(self, first), remaining) 

188 

189 return super().__getattribute__(key) 

190 

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

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

193 

194 Not allowed in strict mode. 

195 """ 

196 new_path = f"{self._path}{self._resolve_mapping(key)}" 

197 if self.strict_mode: 

198 raise SyntaxError( 

199 f"In strict mode, the config must not be accessed " 

200 f"with dict notation. " 

201 f"Only attribute access (conf.{new_path}) is allowed." 

202 ) 

203 

204 # TODO: re-enable?! 

205 # Avoid to set conf values to something which is already the default 

206 # if key in self and self[key] == value: 

207 # msg = f"Setting conf[\"{key}\"] to {value!r} has no effect, as this value has already been set" 

208 # warnings.warn(msg, stacklevel=3) 

209 # logging.warning(msg, stacklevel=3) 

210 # return 

211 

212 key = self._resolve_mapping(key) 

213 

214 # Got an old dict-key and resolve the segment to the first dot (.) as attribute. 

215 if "." in key: 

216 first, remaining = key.split(".", 1) 

217 if not hasattr(self, first): 

218 # TODO: Compatibility, remove it in a future major release! 

219 # This segment doesn't exist. Create it 

220 logging.warning(f"Creating new type for {first}") 

221 setattr(self, first, type(first.capitalize(), (ConfigType,), {})()) 

222 getattr(self, first)[remaining] = value 

223 return 

224 

225 return setattr(self, key, value) 

226 

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

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

229 

230 In strict mode it does nothing except a super call 

231 for the default object behavior. 

232 """ 

233 if self.strict_mode: 

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

235 

236 if not self.strict_mode: 236 ↛ 240line 236 didn't jump to line 240 because the condition on line 236 was always true

237 key = self._resolve_mapping(key) 

238 

239 # Got an old dict-key and resolve the segment to the first dot (.) as attribute. 

240 if "." in key: 240 ↛ 242line 240 didn't jump to line 242 because the condition on line 240 was never true

241 # TODO: Shall we allow this in strict mode as well? 

242 first, remaining = key.split(".", 1) 

243 return setattr(getattr(self, first), remaining, value) 

244 

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

246 

247 def __repr__(self) -> str: 

248 """Representation of this config""" 

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

250 

251 

252# Some values used more than once below 

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

254_app_version = os.getenv("GAE_VERSION") 

255 

256# Determine our basePath (as os.getCWD is broken on appengine) 

257_project_base_path = Path().absolute() 

258_core_base_path = Path(__file__).parent.parent.parent # fixme: this points to site-packages!!! 

259 

260 

261class Admin(ConfigType): 

262 """Administration tool configuration""" 

263 

264 name: str = "ViUR" 

265 """Administration tool configuration""" 

266 

267 logo: str = "" 

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

269 

270 login_background: str = "" 

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

272 

273 login_logo: str = "" 

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

275 

276 color_primary: str = "#d00f1c" 

277 """primary color for viur-admin""" 

278 

279 color_secondary: str = "#333333" 

280 """secondary color for viur-admin""" 

281 

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

283 """Module Groups for the admin tool 

284 

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

286 

287 Example: 

288 conf.admin.module_groups = { 

289 "content": { 

290 "name": "Content", 

291 "icon": "file-text-fill", 

292 "sortindex": 10, 

293 }, 

294 "shop": { 

295 "name": "Shop", 

296 "icon": "cart-fill", 

297 "sortindex": 20, 

298 }, 

299 } 

300 

301 To add a module to one of these groups (e.g. content), add `moduleGroup` to 

302 the admin_info of the module: 

303 "moduleGroup": "content", 

304 """ 

305 

306 _mapping: dict[str, str] = { 

307 "login.background": "login_background", 

308 "login.logo": "login_logo", 

309 "color.primary": "color_primary", 

310 "color.secondary": "color_secondary", 

311 } 

312 

313 

314class Security(ConfigType): 

315 """Security related settings""" 

316 

317 force_ssl: bool = True 

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

319 

320 no_ssl_check_urls: Multiple[str] = ["/_tasks*", "/ah/*"] 

321 """List of URLs for which force_ssl is ignored. 

322 Add an asterisk to mark that entry as a prefix (exact match otherwise)""" 

323 

324 content_security_policy: t.Optional[dict[str, dict[str, list[str]]]] = { 

325 "enforce": { 

326 "style-src": ["self", "https://accounts.google.com/gsi/style"], 

327 "default-src": ["self"], 

328 "img-src": ["self", "storage.googleapis.com"], # Serving-URLs of file-Bones will point here 

329 "script-src": ["self", "https://accounts.google.com/gsi/client"], 

330 # Required for login with Google 

331 "frame-src": ["self", "www.google.com", "drive.google.com", "accounts.google.com"], 

332 "form-action": ["self"], 

333 "connect-src": ["self", "accounts.google.com"], 

334 "upgrade-insecure-requests": [], 

335 "object-src": ["none"], 

336 } 

337 } 

338 """If set, viur will emit a CSP http-header with each request. Use security.addCspRule to set this property""" 

339 

340 referrer_policy: str = "strict-origin" 

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

342 

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

344 """ 

345 

346 permissions_policy: dict[str, list[str]] = { 

347 "autoplay": ["self"], 

348 "camera": [], 

349 "display-capture": [], 

350 "document-domain": [], 

351 "encrypted-media": [], 

352 "fullscreen": [], 

353 "geolocation": [], 

354 "microphone": [], 

355 "publickey-credentials-get": [], 

356 "usb": [], 

357 } 

358 """Include a default permissions-policy. 

359 To use the camera or microphone, you'll have to call 

360 :meth: securityheaders.setPermissionPolicyDirective to include at least "self" 

361 """ 

362 

363 enable_coep: bool = False 

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

365 

366 enable_coop: t.Literal[ 

367 "unsafe-none", "same-origin-allow-popups", 

368 "same-origin", "same-origin-plus-COEP"] = "same-origin" 

369 """Emit a Cross-Origin-Opener-Policy Header? 

370 

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

372 """ 

373 

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

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

376 

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

378 """ 

379 

380 strict_transport_security: t.Optional[str] = "max-age=22118400" 

381 """If set, ViUR will emit a HSTS HTTP-header with each request. 

382 Use security.enableStrictTransportSecurity to set this property""" 

383 

384 x_frame_options: t.Optional[ 

385 tuple[t.Literal["deny", "sameorigin", "allow-from"], t.Optional[str]] 

386 ] = ("sameorigin", None) 

387 """If set, ViUR will emit an X-Frame-Options header 

388 

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

390 Otherwise, it can be None. 

391 """ 

392 

393 x_xss_protection: t.Optional[bool] = True 

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

395 

396 x_content_type_options: bool = True 

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

398 

399 x_permitted_cross_domain_policies: t.Optional[t.Literal["none", "master-only", "by-content-type", "all"]] = "none" 

400 """Unless set to logical none; ViUR will emit a X-Permitted-Cross-Domain-Policies with each request""" 

401 

402 captcha_default_credentials: t.Optional[CaptchaDefaultCredentialsType] = None 

403 """The default sitekey and secret to use for the :class:`CaptchaBone`. 

404 If set, must be a dictionary of "sitekey" and "secret". 

405 """ 

406 

407 captcha_enforce_always: bool = False 

408 """By default a captcha of the :class:`CaptchaBone` must not be solved on a local development server 

409 or by a root user. But for development it can be helpful to test the implementation 

410 on a local development server. Setting this flag to True, disables this behavior and 

411 enforces always a valid captcha. 

412 """ 

413 

414 password_recovery_key_length: int = 42 

415 """Length of the Password recovery key""" 

416 

417 closed_system: bool = False 

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

419 

420 admin_allowed_paths: t.Iterable[str] = [ 

421 "vi", 

422 "vi/skey", 

423 "vi/settings", 

424 "vi/user/auth_*", 

425 "vi/user/f2_*", 

426 "vi/user/getAuthMethods", # FIXME: deprecated, use `login` for this 

427 "vi/user/login", 

428 ] 

429 """Specifies admin tool paths which are being accessible without authenticated user.""" 

430 

431 closed_system_allowed_paths: t.Iterable[str] = admin_allowed_paths + [ 

432 "", # index site 

433 "json/skey", 

434 "json/user/auth_*", 

435 "json/user/f2_*", 

436 "json/user/getAuthMethods", # FIXME: deprecated, use `login` for this 

437 "json/user/login", 

438 "user/auth_*", 

439 "user/f2_*", 

440 "user/getAuthMethods", # FIXME: deprecated, use `login` for this 

441 "user/login", 

442 ] 

443 """Paths that are accessible without authentication in a closed system, see `closed_system` for details.""" 

444 

445 # CORS Settings 

446 

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

448 """Allowed origins 

449 Access-Control-Allow-Origin 

450 

451 Pattern should be case-insensitive, for example: 

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

453 """ # noqa 

454 

455 cors_origins_use_wildcard: bool = False 

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

457 

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

459 """Access-Control-Request-Method""" 

460 

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

462 """Access-Control-Request-Headers 

463 

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

465 

466 Pattern should be case-insensitive, for example: 

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

468 """ 

469 

470 cors_allow_credentials: bool = False 

471 """ 

472 Set Access-Control-Allow-Credentials to true 

473 to support fetch requests with credentials: include 

474 """ 

475 

476 cors_max_age: datetime.timedelta | None = None 

477 """Allow caching""" 

478 

479 _mapping = { 

480 "contentSecurityPolicy": "content_security_policy", 

481 "referrerPolicy": "referrer_policy", 

482 "permissionsPolicy": "permissions_policy", 

483 "enableCOEP": "enable_coep", 

484 "enableCOOP": "enable_coop", 

485 "enableCORP": "enable_corp", 

486 "strictTransportSecurity": "strict_transport_security", 

487 "xFrameOptions": "x_frame_options", 

488 "xXssProtection": "x_xss_protection", 

489 "xContentTypeOptions": "x_content_type_options", 

490 "xPermittedCrossDomainPolicies": "x_permitted_cross_domain_policies", 

491 "captcha_defaultCredentials": "captcha_default_credentials", 

492 "captcha.defaultCredentials": "captcha_default_credentials", 

493 } 

494 

495 

496class Debug(ConfigType): 

497 """Several debug flags""" 

498 

499 trace: bool = False 

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

501 

502 trace_exceptions: bool = False 

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

504 

505 trace_external_call_routing: bool = False 

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

507 

508 trace_internal_call_routing: bool = False 

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

510 

511 skeleton_from_client: bool = False 

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

513 

514 dev_server_cloud_logging: bool = False 

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

516 

517 disable_cache: bool = False 

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

519 

520 _mapping = { 

521 "skeleton.fromClient": "skeleton_from_client", 

522 "traceExceptions": "trace_exceptions", 

523 "traceExternalCallRouting": "trace_external_call_routing", 

524 "traceInternalCallRouting": "trace_internal_call_routing", 

525 "skeleton_fromClient": "skeleton_from_client", 

526 "disableCache": "disable_cache", 

527 } 

528 

529 

530class Email(ConfigType): 

531 """Email related settings.""" 

532 

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

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

535 

536 transport_class: "EmailTransport" = None 

537 """EmailTransport instance that actually delivers the email using the service provider 

538 of choice. See :module:`core.email` for more details 

539 """ 

540 

541 send_from_local_development_server: bool = False 

542 """If set, we'll enable sending emails from the local development server. 

543 Otherwise, they'll just be logged. 

544 """ 

545 

546 recipient_override: str | list[str] | t.Callable[[], str | list[str]] | t.Literal[False] = None 

547 """If set, all outgoing emails will be sent to this address 

548 (overriding the 'dests'-parameter in :meth:`core.email.send_email`) 

549 """ 

550 

551 sender_default: str = f"viur@{_project_id}.appspotmail.com" 

552 """This sender is used by default for emails. 

553 It can be overridden for a specific email by passing the `sender` argument 

554 to :meth:`core.email.send_email` or for all emails with :attr:`sender_override`. 

555 """ 

556 

557 sender_override: str | None = None 

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

559 

560 admin_recipients: str | list[str] | t.Callable[[], str | list[str]] = None 

561 """Sets recipients for mails send with :meth:`core.email.send_email_to_admins`. 

562 If not set, all root users will be used.""" 

563 

564 _mapping = { 

565 "logRetention": "log_retention", 

566 "transportClass": "transport_class", 

567 "sendFromLocalDevelopmentServer": "send_from_local_development_server", 

568 "recipientOverride": "recipient_override", 

569 "senderOverride": "sender_override", 

570 "sendInBlue.apiKey": "sendinblue_api_key", 

571 "sendInBlue.thresholds": "sendinblue_thresholds", 

572 } 

573 

574 

575class I18N(ConfigType): 

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

577 

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

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

580 

581 default_language: str = "en" 

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

583 

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

585 """Maps Domains to alternative default languages""" 

586 

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

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

589 

590 language_method: t.Literal["session", "url", "domain", "header"] = "session" 

591 """Defines how translations are applied: 

592 - session: Per Session 

593 - url: inject language prefix in url 

594 - domain: one domain per language 

595 - header: Per Http-Header 

596 """ 

597 

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

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

600 

601 @property 

602 def available_dialects(self) -> list[str]: 

603 """Main languages and language aliases""" 

604 # Use a dict to keep the order and remove duplicates 

605 res = dict.fromkeys(self.available_languages) 

606 res |= self.language_alias_map 

607 return list(res.keys()) 

608 

609 add_missing_translations: bool = False 

610 """Add missing translation into datastore. 

611 

612 If a key is not found in the translation table when a translation is 

613 rendered, a database entry is created with the key and hint and 

614 default value (if set) so that the translations 

615 can be entered in the administration. 

616 """ 

617 

618 

619class User(ConfigType): 

620 """User, session, login related settings""" 

621 

622 access_rights: Multiple[str] = [ 

623 "root", 

624 "admin", 

625 "scriptor", 

626 ] 

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

628 

629 There are three default flags: 

630 - `root` is allowed to view/add/edit/delete any module, regardless of role or other settings 

631 - `admin` is allowed to use the ViUR administration tool 

632 - `scriptor` is allowed to use the ViUR scripting features directly within the admin 

633 This does not affect scriptor actions which are configured for modules, as they allow for 

634 fine grained usage rule definitions. 

635 """ 

636 

637 roles: dict[str, str] = { 

638 "custom": "Custom", 

639 "user": "User", 

640 "viewer": "Viewer", 

641 "editor": "Editor", 

642 "admin": "Administrator", 

643 } 

644 """User roles available on this project. 

645 

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

647 

648 The default roles can be described as follows: 

649 

650 - `custom` for users with a custom-settings via the `User.access`-bone; includes root users. 

651 - `user` for users without any additonal rights. They can log-in and view themselves, or particular modules which 

652 just check for authenticated users. 

653 - `viewer` for users who should only view content. 

654 - `editor` for users who are allowed to edit particular content. They mostly can `view` and `edit`, but not `add` 

655 or `delete`. 

656 - `admin` for users with administration privileges. They can edit any data, but still aren't `root`. 

657 

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

659 """ 

660 

661 session_life_time: int = 60 * 60 

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

663 

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

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

666 

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

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

669 

670 max_password_length: int = 512 

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

672 

673 otp_issuer: t.Optional[str] = None 

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

675 

676 google_client_id: t.Optional[str] = None 

677 """OAuth Client ID for Google Login""" 

678 

679 google_gsuite_domains: list[str] = [] 

680 """A list of domains. When a user signs in for the first time with a 

681 Google account using Google OAuth sign-in, and the user's email address 

682 belongs to one of the listed domains, a user account (UserSkel) is created. 

683 If the user's email address belongs to any other domain, 

684 no account is created.""" 

685 

686 

687class Instance(ConfigType): 

688 """All app instance related settings information""" 

689 app_version: str = _app_version 

690 """Name of this version as deployed to the appengine""" 

691 

692 core_base_path: Path = _core_base_path 

693 """The base path of the core, can be used to find file in the core folder""" 

694 

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

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

697 

698 project_base_path: Path = _project_base_path 

699 """The base path of the project, can be used to find file in the project folder""" 

700 

701 project_id: str = _project_id 

702 """The instance's project ID""" 

703 

704 version_hash: str = hashlib.sha256(f"{_app_version}{project_id}".encode("UTF-8")).hexdigest()[:10] 

705 """Version hash that does not reveal the actual version name, can be used for cache-busting static resources""" 

706 

707 

708class Conf(ConfigType): 

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

710 deprecated keys or other special operations. 

711 """ 

712 

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

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

715 

716 bone_html_default_allow: "HtmlBoneConfiguration" = { 

717 "validTags": [ 

718 "a", 

719 "abbr", 

720 "b", 

721 "blockquote", 

722 "br", 

723 "div", 

724 "em", 

725 "h1", 

726 "h2", 

727 "h3", 

728 "h4", 

729 "h5", 

730 "h6", 

731 "hr", 

732 "i", 

733 "img", 

734 "li", 

735 "ol", 

736 "p", 

737 "span", 

738 "strong", 

739 "sub", 

740 "sup", 

741 "table", 

742 "tbody", 

743 "td", 

744 "tfoot", 

745 "th", 

746 "thead", 

747 "tr", 

748 "u", 

749 "ul", 

750 ], 

751 "validAttrs": { 

752 "a": [ 

753 "href", 

754 "target", 

755 "title", 

756 ], 

757 "abbr": [ 

758 "title", 

759 ], 

760 "blockquote": [ 

761 "cite", 

762 ], 

763 "img": [ 

764 "src", 

765 "alt", 

766 "title", 

767 ], 

768 "p": [ 

769 "data-indent", 

770 ], 

771 "span": [ 

772 "title", 

773 ], 

774 "td": [ 

775 "colspan", 

776 "rowspan", 

777 ], 

778 

779 }, 

780 "validStyles": [ 

781 "color", 

782 ], 

783 "validClasses": [ 

784 "vitxt-*", 

785 "viur-txt-*" 

786 ], 

787 "singleTags": [ 

788 "br", 

789 "hr", 

790 "img", 

791 ] 

792 } 

793 """ 

794 A dictionary containing default configurations for handling HTML content in TextBone instances. 

795 """ 

796 

797 cache_environment_key: t.Optional[t.Callable[[], str]] = None 

798 """If set, this function will be called for each cache-attempt 

799 and the result will be included in the computed cache-key""" 

800 

801 # FIXME VIUR4: REMOVE ALL COMPATIBILITY MODES! 

802 compatibility: Multiple[str] = [ 

803 "json.bone.structure.camelcasenames", # use camelCase attribute names (see #637 for details) 

804 "json.bone.structure.keytuples", # use classic structure notation: `"structure = [["key", {...}] ...]` (#649) 

805 "json.bone.structure.inlists", # dump skeleton structure with every JSON list response (#774 for details) 

806 "tasks.periodic.useminutes", # Interpret int/float values for @PeriodicTask as minutes 

807 # instead of seconds (#1133 for details) 

808 "bone.select.structure.values.keytuple", # render old-style tuple-list in SelectBone's values structure (#1203) 

809 ] 

810 """Backward compatibility flags; Remove to enforce new style.""" 

811 

812 db_engine: str = "viur.datastore" 

813 """Database engine module""" 

814 

815 error_handler: t.Callable[[Exception], str] | None = None 

816 """If set, ViUR calls this function instead of rendering the viur.errorTemplate if an exception occurs""" 

817 

818 error_logo: str = None 

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

820 

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

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

823 

824 file_hmac_key: str = None 

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

826 

827 # TODO: separate this type hints and use it in the File module as well 

828 file_derivations: dict[str, t.Callable[["SkeletonInstance", dict, dict], list[tuple[str, float, str, t.Any]]]] = {} 

829 """Call-Map for file pre-processors""" 

830 

831 file_thumbnailer_url: t.Optional[str] = None 

832 # TODO: """docstring""" 

833 

834 main_app: "Module" = None 

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

836 

837 main_resolver: dict[str, dict] = None 

838 """Dictionary for Resolving functions for URLs""" 

839 

840 max_post_params_count: int = 250 

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

842 

843 param_filter_function: t.Callable[[str, str], bool] = lambda _, key, value: key.startswith("_") 

844 """ 

845 Function which decides if a request parameter should be used or filtered out. 

846 Returning True means to filter out. 

847 """ 

848 

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

850 "icon": "gear-fill", 

851 "display": "hidden", 

852 } 

853 """Describing the internal ModuleConfig-module""" 

854 

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

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

857 "display": "hidden", 

858 } 

859 """Describing the Script module""" 

860 

861 render_html_download_url_expiration: t.Optional[float | int] = None 

862 """The default duration, for which downloadURLs generated by the html renderer will stay valid""" 

863 

864 render_json_download_url_expiration: t.Optional[float | int] = None 

865 """The default duration, for which downloadURLs generated by the json renderer will stay valid""" 

866 

867 request_preprocessor: t.Optional[t.Callable[[str], str]] = None 

868 """Allows the application to register a function that's called before the request gets routed""" 

869 

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

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

872 

873 skeleton_search_path: Multiple[str] = [ 

874 "/skeletons/", # skeletons of the project 

875 "/viur/core/", # system-defined skeletons of viur-core 

876 "/viur-core/core/" # system-defined skeletons of viur-core, only used by editable installation 

877 ] 

878 """Priority, in which skeletons are loaded""" 

879 

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

881 

882 @property 

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

884 """ 

885 Preserve additional environment in deferred tasks. 

886 

887 If set, it must be an instance of CustomEnvironmentHandler 

888 for serializing/restoring environment data. 

889 """ 

890 return self._tasks_custom_environment_handler 

891 

892 @tasks_custom_environment_handler.setter 

893 def tasks_custom_environment_handler(self, value: "CustomEnvironmentHandler") -> None: 

894 from .tasks import CustomEnvironmentHandler 

895 if isinstance(value, CustomEnvironmentHandler) or value is None: 

896 self._tasks_custom_environment_handler = value 

897 elif isinstance(value, tuple): 

898 if len(value) != 2: 

899 raise ValueError(f"Expected a (serialize_env_func, restore_env_func) pair") 

900 warnings.warn( 

901 f"tuple is deprecated, please provide a CustomEnvironmentHandler object!", 

902 DeprecationWarning, stacklevel=2, 

903 ) 

904 # Construct an CustomEnvironmentHandler class on the fly to be backward compatible 

905 cls = type("ProjectCustomEnvironmentHandler", (CustomEnvironmentHandler,), 

906 # serialize and restore will be bound methods. 

907 # Therefore, consume the self argument with lambda. 

908 {"serialize": lambda self: value[0](), 

909 "restore": lambda self, obj: value[1](obj)}) 

910 self._tasks_custom_environment_handler = cls() 

911 else: 

912 raise ValueError(f"Invalid type {type(value)}. Expected a CustomEnvironmentHandler object.") 

913 

914 valid_application_ids: list[str] = [] 

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

916 

917 version: tuple[int, int, int] = tuple(int(part) if part.isdigit() else part for part in __version__.split(".", 3)) 

918 """Semantic version number of viur-core as a tuple of 3 (major, minor, patch-level)""" 

919 

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

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

922 

923 def __init__(self, strict_mode: bool = False): 

924 super().__init__() 

925 self._strict_mode = strict_mode 

926 self.admin = Admin(parent=self) 

927 self.security = Security(parent=self) 

928 self.debug = Debug(parent=self) 

929 self.email = Email(parent=self) 

930 self.i18n = I18N(parent=self) 

931 self.user = User(parent=self) 

932 self.instance = Instance(parent=self) 

933 

934 _mapping = { 

935 # debug 

936 "viur.dev_server_cloud_logging": "debug.dev_server_cloud_logging", 

937 "viur.disable_cache": "debug.disable_cache", 

938 # i18n 

939 "viur.availableLanguages": "i18n.available_languages", 

940 "viur.defaultLanguage": "i18n.default_language", 

941 "viur.domainLanguageMapping": "i18n.domain_language_mapping", 

942 "viur.languageAliasMap": "i18n.language_alias_map", 

943 "viur.languageMethod": "i18n.language_method", 

944 "viur.languageModuleMap": "i18n.language_module_map", 

945 # user 

946 "viur.accessRights": "user.access_rights", 

947 "viur.maxPasswordLength": "user.max_password_length", 

948 "viur.otp.issuer": "user.otp_issuer", 

949 "viur.session.lifeTime": "user.session_life_time", 

950 "viur.session.persistentFieldsOnLogin": "user.session_persistent_fields_on_login", 

951 "viur.session.persistentFieldsOnLogout": "user.session_persistent_fields_on_logout", 

952 "viur.user.roles": "user.roles", 

953 "viur.user.google.clientID": "user.google_client_id", 

954 "viur.user.google.gsuiteDomains": "user.google_gsuite_domains", 

955 # instance 

956 "viur.instance.app_version": "instance.app_version", 

957 "viur.instance.core_base_path": "instance.core_base_path", 

958 "viur.instance.is_dev_server": "instance.is_dev_server", 

959 "viur.instance.project_base_path": "instance.project_base_path", 

960 "viur.instance.project_id": "instance.project_id", 

961 "viur.instance.version_hash": "instance.version_hash", 

962 # security 

963 "viur.forceSSL": "security.force_ssl", 

964 "viur.noSSLCheckUrls": "security.no_ssl_check_urls", 

965 # old viur-prefix 

966 "viur.cacheEnvironmentKey": "cache_environment_key", 

967 "viur.contentSecurityPolicy": "content_security_policy", 

968 "viur.bone.boolean.str2true": "bone_boolean_str2true", 

969 "viur.db.engine": "db_engine", 

970 "viur.errorHandler": "error_handler", 

971 "viur.static.embedSvg.path": "static_embed_svg_path", 

972 "viur.file.hmacKey": "file_hmac_key", 

973 "viur.file_hmacKey": "file_hmac_key", 

974 "viur.file.derivers": "file_derivations", 

975 "viur.file.thumbnailerURL": "file_thumbnailer_url", 

976 "viur.mainApp": "main_app", 

977 "viur.mainResolver": "main_resolver", 

978 "viur.maxPostParamsCount": "max_post_params_count", 

979 "viur.moduleconf.admin_info": "moduleconf_admin_info", 

980 "viur.script.admin_info": "script_admin_info", 

981 "viur.render.html.downloadUrlExpiration": "render_html_download_url_expiration", 

982 "viur.downloadUrlFor.expiration": "render_html_download_url_expiration", 

983 "viur.render.json.downloadUrlExpiration": "render_json_download_url_expiration", 

984 "viur.requestPreprocessor": "request_preprocessor", 

985 "viur.searchValidChars": "search_valid_chars", 

986 "viur.skeleton.searchPath": "skeleton_search_path", 

987 "viur.tasks.customEnvironmentHandler": "tasks_custom_environment_handler", 

988 "viur.validApplicationIDs": "valid_application_ids", 

989 "viur.viur2import.blobsource": "viur2import_blobsource", 

990 } 

991 

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

993 """Additional mapping for new sub confs.""" 

994 if key.startswith("viur.") and key not in self._mapping: 

995 key = key.removeprefix("viur.") 

996 return super()._resolve_mapping(key) 

997 

998 

999conf = Conf( 

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

1001)