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

375 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-09-13 11:04 +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 

11from google.appengine.api.memcache import Client 

12 

13from viur.core.version import __version__ 

14from viur.core.current import user as current_user 

15 

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 

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 Database(ConfigType): 

317 query_external_limit: int = 100 

318 """Sets the maximum query limit allowed by external filters.""" 

319 

320 query_default_limit: int = 30 

321 """Sets the default query limit for all queries.""" 

322 

323 memcache_client: Client | None = None 

324 """If set, ViUR cache data for the db.get in the Memcache for faster access.""" 

325 

326 create_access_log: bool = True 

327 """If False no access log will be created. But then the caching is disabled too.""" 

328 

329 

330class Security(ConfigType): 

331 """Security related settings""" 

332 

333 force_ssl: bool = True 

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

335 

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

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

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

339 

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

341 "enforce": { 

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

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

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

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

346 # Required for login with Google 

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

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

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

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

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

352 } 

353 } 

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

355 

356 referrer_policy: str = "strict-origin" 

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

358 

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

360 """ 

361 

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

363 "autoplay": ["self"], 

364 "camera": [], 

365 "display-capture": [], 

366 "document-domain": [], 

367 "encrypted-media": [], 

368 "fullscreen": [], 

369 "geolocation": [], 

370 "microphone": [], 

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

372 "usb": [], 

373 } 

374 """Include a default permissions-policy. 

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

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

377 """ 

378 

379 enable_coep: bool = False 

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

381 

382 enable_coop: t.Literal[ 

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

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

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

386 

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

388 """ 

389 

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

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

392 

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

394 """ 

395 

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

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

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

399 

400 x_frame_options: t.Optional[ 

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

402 ] = ("sameorigin", None) 

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

404 

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

406 Otherwise, it can be None. 

407 """ 

408 

409 x_xss_protection: t.Optional[bool] = True 

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

411 

412 x_content_type_options: bool = True 

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

414 

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

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

417 

418 captcha_default_credentials: t.Optional[CaptchaDefaultCredentialsType] = None 

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

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

421 """ 

422 

423 captcha_enforce_always: bool = False 

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

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

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

427 enforces always a valid captcha. 

428 """ 

429 

430 password_recovery_key_length: int = 42 

431 """Length of the Password recovery key""" 

432 

433 closed_system: bool = False 

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

435 

436 admin_allowed_paths: t.Iterable[str] = [ 

437 "vi", 

438 "vi/skey", 

439 "vi/settings", 

440 "vi/user/auth_*", 

441 "vi/user/f2_*", 

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

443 "vi/user/login", 

444 ] 

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

446 

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

448 "", # index site 

449 "json/skey", 

450 "json/user/auth_*", 

451 "json/user/f2_*", 

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

453 "json/user/login", 

454 "user/auth_*", 

455 "user/f2_*", 

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

457 "user/login", 

458 ] 

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

460 

461 # CORS Settings 

462 

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

464 """Allowed origins 

465 Access-Control-Allow-Origin 

466 

467 Pattern should be case-insensitive, for example: 

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

469 """ # noqa 

470 

471 cors_origins_use_wildcard: bool = False 

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

473 

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

475 """Access-Control-Request-Method""" 

476 

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

478 """Access-Control-Request-Headers 

479 

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

481 

482 Pattern should be case-insensitive, for example: 

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

484 """ 

485 

486 cors_allow_credentials: bool = False 

487 """ 

488 Set Access-Control-Allow-Credentials to true 

489 to support fetch requests with credentials: include 

490 """ 

491 

492 cors_max_age: datetime.timedelta | None = None 

493 """Allow caching""" 

494 

495 _mapping = { 

496 "contentSecurityPolicy": "content_security_policy", 

497 "referrerPolicy": "referrer_policy", 

498 "permissionsPolicy": "permissions_policy", 

499 "enableCOEP": "enable_coep", 

500 "enableCOOP": "enable_coop", 

501 "enableCORP": "enable_corp", 

502 "strictTransportSecurity": "strict_transport_security", 

503 "xFrameOptions": "x_frame_options", 

504 "xXssProtection": "x_xss_protection", 

505 "xContentTypeOptions": "x_content_type_options", 

506 "xPermittedCrossDomainPolicies": "x_permitted_cross_domain_policies", 

507 "captcha_defaultCredentials": "captcha_default_credentials", 

508 "captcha.defaultCredentials": "captcha_default_credentials", 

509 } 

510 

511 

512class Debug(ConfigType): 

513 """Several debug flags""" 

514 

515 trace: bool = False 

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

517 

518 trace_exceptions: bool = False 

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

520 

521 trace_external_call_routing: bool = False 

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

523 

524 trace_internal_call_routing: bool = False 

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

526 

527 trace_queries: bool = False 

528 """If enabled, ViUR will log each query that run""" 

529 

530 skeleton_from_client: bool = False 

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

532 

533 dev_server_cloud_logging: bool = False 

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

535 

536 disable_cache: bool = False 

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

538 

539 _mapping = { 

540 "skeleton.fromClient": "skeleton_from_client", 

541 "traceExceptions": "trace_exceptions", 

542 "traceExternalCallRouting": "trace_external_call_routing", 

543 "traceInternalCallRouting": "trace_internal_call_routing", 

544 "skeleton_fromClient": "skeleton_from_client", 

545 "disableCache": "disable_cache", 

546 } 

547 

548 

549class Email(ConfigType): 

550 """Email related settings.""" 

551 

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

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

554 

555 transport_class: "EmailTransport" = None 

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

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

558 """ 

559 

560 send_from_local_development_server: bool = False 

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

562 Otherwise, they'll just be logged. 

563 """ 

564 

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

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

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

568 """ 

569 

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

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

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

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

574 """ 

575 

576 sender_override: str | None = None 

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

578 

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

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

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

582 

583 _mapping = { 

584 "logRetention": "log_retention", 

585 "transportClass": "transport_class", 

586 "sendFromLocalDevelopmentServer": "send_from_local_development_server", 

587 "recipientOverride": "recipient_override", 

588 "senderOverride": "sender_override", 

589 "sendInBlue.apiKey": "sendinblue_api_key", 

590 "sendInBlue.thresholds": "sendinblue_thresholds", 

591 } 

592 

593 

594class History(ConfigType): 

595 databases: Multiple[str] = ["viur"] 

596 """All history related settings.""" 

597 excluded_actions: Multiple[str] = [] 

598 """List of all action that are should not be logged.""" 

599 excluded_kinds: Multiple[str] = [] 

600 """List of all kinds that should be logged.""" 

601 

602 

603class I18N(ConfigType): 

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

605 

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

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

608 

609 default_language: str = "en" 

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

611 

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

613 """Maps Domains to alternative default languages""" 

614 

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

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

617 

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

619 """Defines how translations are applied: 

620 - session: Per Session 

621 - url: inject language prefix in url 

622 - domain: one domain per language 

623 - header: Per Http-Header 

624 """ 

625 

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

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

628 

629 auto_translate_bones: bool = True 

630 """Defines whether bone descr and categories should be automatically translated via i18n.translate-objects.""" 

631 

632 @property 

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

634 """Main languages and language aliases""" 

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

636 res = dict.fromkeys(self.available_languages) 

637 res |= self.language_alias_map 

638 return list(res.keys()) 

639 

640 add_missing_translations: (bool | str | t.Iterable[str] | "i18n.AddMissing" 

641 | t.Callable[["i18n.translate"], t.Union[bool, "i18n.AddMissing"]]) = False 

642 """Add missing translation into datastore, optionally with given fnmatch-patterns. 

643 

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

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

646 default value (if set) so that the translations 

647 can be entered in the administration. 

648 

649 Instead of setting add_missing_translations to a boolean, it can also be set to 

650 a pattern or iterable of fnmatch-patterns; Only translation keys matching these 

651 patterns will be automatically added. 

652 If a callable is provided, it will be called with the translation object to make a complex decision. 

653 """ 

654 

655 def _dump_can_view(self, _key): 

656 return bool(current_user.get()) 

657 

658 dump_can_view: t.Callable[[t.Self, str], bool] = _dump_can_view 

659 """Customizable callback for translation.dump() to verify if a specific translation key can be queried. 

660 

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

662 

663 

664class User(ConfigType): 

665 """User, session, login related settings""" 

666 

667 access_rights: Multiple[str] = [ 

668 "root", 

669 "admin", 

670 "scriptor", 

671 ] 

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

673 

674 There are three default flags: 

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

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

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

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

679 fine grained usage rule definitions. 

680 """ 

681 

682 roles: dict[str, str] = { 

683 "custom": "Custom", 

684 "user": "User", 

685 "viewer": "Viewer", 

686 "editor": "Editor", 

687 "admin": "Administrator", 

688 } 

689 """User roles available on this project. 

690 

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

692 

693 The default roles can be described as follows: 

694 

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

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

697 just check for authenticated users. 

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

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

700 or `delete`. 

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

702 

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

704 """ 

705 

706 session_life_time: datetime.timedelta = datetime.timedelta(hours=1) 

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

708 

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

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

711 

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

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

714 

715 max_password_length: int = 512 

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

717 

718 otp_issuer: t.Optional[str] = None 

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

720 

721 google_client_id: t.Optional[str] = None 

722 """OAuth Client ID for Google Login""" 

723 

724 google_gsuite_domains: list[str] = [] 

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

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

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

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

729 no account is created.""" 

730 

731 def __setattr__(self, name: str, value: t.Any) -> None: 

732 if name == "session_life_time": 732 ↛ 733line 732 didn't jump to line 733 because the condition on line 732 was never true

733 if not isinstance(value, datetime.timedelta): 

734 from viur.core import utils 

735 warnings.warn( 

736 "Please use timedelta to set session_life_time.", 

737 DeprecationWarning, stacklevel=2, 

738 ) 

739 value = utils.parse.timedelta(value) 

740 super().__setattr__(name, value) 

741 

742 

743class Instance(ConfigType): 

744 """All app instance related settings information""" 

745 app_version: str = _app_version 

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

747 

748 core_base_path: Path = _core_base_path 

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

750 

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

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

753 

754 project_base_path: Path = _project_base_path 

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

756 

757 project_id: str = _project_id 

758 """The instance's project ID""" 

759 

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

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

762 

763 

764class Conf(ConfigType): 

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

766 deprecated keys or other special operations. 

767 """ 

768 

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

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

771 

772 bone_html_default_allow: "HtmlBoneConfiguration" = { 

773 "validTags": [ 

774 "a", 

775 "abbr", 

776 "b", 

777 "blockquote", 

778 "br", 

779 "div", 

780 "em", 

781 "h1", 

782 "h2", 

783 "h3", 

784 "h4", 

785 "h5", 

786 "h6", 

787 "hr", 

788 "i", 

789 "img", 

790 "li", 

791 "ol", 

792 "p", 

793 "span", 

794 "strong", 

795 "sub", 

796 "sup", 

797 "table", 

798 "tbody", 

799 "td", 

800 "tfoot", 

801 "th", 

802 "thead", 

803 "tr", 

804 "u", 

805 "ul", 

806 ], 

807 "validAttrs": { 

808 "a": [ 

809 "href", 

810 "target", 

811 "title", 

812 ], 

813 "abbr": [ 

814 "title", 

815 ], 

816 "blockquote": [ 

817 "cite", 

818 ], 

819 "img": [ 

820 "src", 

821 "alt", 

822 "title", 

823 ], 

824 "p": [ 

825 "data-indent", 

826 ], 

827 "span": [ 

828 "title", 

829 ], 

830 "td": [ 

831 "colspan", 

832 "rowspan", 

833 ], 

834 

835 }, 

836 "validStyles": [ 

837 "color", 

838 ], 

839 "validClasses": [ 

840 "vitxt-*", 

841 "viur-txt-*" 

842 ], 

843 "singleTags": [ 

844 "br", 

845 "hr", 

846 "img", 

847 ] 

848 } 

849 """ 

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

851 """ 

852 

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

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

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

856 

857 # FIXME VIUR4: REMOVE ALL COMPATIBILITY MODES! 

858 compatibility: Multiple[str] = [ 

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

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

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

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

863 # # instead of seconds (#1133 for details) 

864 # "bone.select.structure.values.keytuple", # render old-style tuple-list in SelectBone's 

865 # values structure (#1203) 

866 ] 

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

868 

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

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

871 

872 error_logo: str = None 

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

874 

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

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

877 

878 file_hmac_key: str = None 

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

880 

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

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

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

884 

885 file_thumbnailer_url: t.Optional[str] = None 

886 # TODO: """docstring""" 

887 

888 main_app: "Module" = None 

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

890 

891 main_resolver: dict[str, dict] = None 

892 """Dictionary for Resolving functions for URLs""" 

893 

894 max_post_params_count: int = 250 

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

896 

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

898 """ 

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

900 Returning True means to filter out. 

901 """ 

902 

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

904 "icon": "gear-fill", 

905 "display": "hidden", 

906 } 

907 """Describing the internal ModuleConfig-module""" 

908 

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

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

911 "display": "hidden", 

912 } 

913 """Describing the Script module""" 

914 

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

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

917 

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

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

920 

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

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

923 

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

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

926 

927 skeleton_search_path: Multiple[str] = [ 

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

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

930 "/viur/src/viur/core/", # fixme: test suite 

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

932 ] 

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

934 

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

936 

937 @property 

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

939 """ 

940 Preserve additional environment in deferred tasks. 

941 

942 If set, it must be an instance of CustomEnvironmentHandler 

943 for serializing/restoring environment data. 

944 """ 

945 return self._tasks_custom_environment_handler 

946 

947 @tasks_custom_environment_handler.setter 

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

949 from .tasks import CustomEnvironmentHandler 

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

951 self._tasks_custom_environment_handler = value 

952 elif isinstance(value, tuple): 

953 if len(value) != 2: 

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

955 warnings.warn( 

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

957 DeprecationWarning, stacklevel=2, 

958 ) 

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

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

961 # serialize and restore will be bound methods. 

962 # Therefore, consume the self argument with lambda. 

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

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

965 self._tasks_custom_environment_handler = cls() 

966 else: 

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

968 

969 tasks_default_queues: dict[str, str] = { 

970 "__default__": "default", 

971 } 

972 """ 

973 @CallDeferred tasks run in the Cloud Tasks Queue "default" by default. 

974 One way to run them in a different task queue is to use the `_queue` parameter 

975 when calling the task. 

976 However, as this is not possible for existing or low-hanging calls, 

977 default values can be defined here for each task. 

978 To do this, the task path must be mapped to the queue name: 

979 ``` 

980 conf.tasks_default_queues["update_relations.viur.core.skeleton"] = "update_relations" 

981 ``` 

982 The queue (in the example: `"update_relations"`) must exist. 

983 The default queue can be changed by overwriting `"__default__"`. 

984 """ 

985 

986 valid_application_ids: list[str] = [] 

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

988 

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

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

991 

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

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

994 

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

996 super().__init__() 

997 self._strict_mode = strict_mode 

998 self.admin = Admin(parent=self) 

999 self.db = Database(parent=self) 

1000 self.security = Security(parent=self) 

1001 self.debug = Debug(parent=self) 

1002 self.email = Email(parent=self) 

1003 self.i18n = I18N(parent=self) 

1004 self.user = User(parent=self) 

1005 self.instance = Instance(parent=self) 

1006 self.history = History(parent=self) 

1007 

1008 _mapping = { 

1009 # debug 

1010 "viur.dev_server_cloud_logging": "debug.dev_server_cloud_logging", 

1011 "viur.disable_cache": "debug.disable_cache", 

1012 # i18n 

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

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

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

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

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

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

1019 # user 

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

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

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

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

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

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

1026 "viur.user.roles": "user.roles", 

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

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

1029 # instance 

1030 "viur.instance.app_version": "instance.app_version", 

1031 "viur.instance.core_base_path": "instance.core_base_path", 

1032 "viur.instance.is_dev_server": "instance.is_dev_server", 

1033 "viur.instance.project_base_path": "instance.project_base_path", 

1034 "viur.instance.project_id": "instance.project_id", 

1035 "viur.instance.version_hash": "instance.version_hash", 

1036 # security 

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

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

1039 # old viur-prefix 

1040 "viur.cacheEnvironmentKey": "cache_environment_key", 

1041 "viur.contentSecurityPolicy": "content_security_policy", 

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

1043 "viur.errorHandler": "error_handler", 

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

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

1046 "viur.file_hmacKey": "file_hmac_key", 

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

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

1049 "viur.mainApp": "main_app", 

1050 "viur.mainResolver": "main_resolver", 

1051 "viur.maxPostParamsCount": "max_post_params_count", 

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

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

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

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

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

1057 "viur.requestPreprocessor": "request_preprocessor", 

1058 "viur.searchValidChars": "search_valid_chars", 

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

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

1061 "viur.validApplicationIDs": "valid_application_ids", 

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

1063 } 

1064 

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

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

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

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

1069 return super()._resolve_mapping(key) 

1070 

1071 

1072conf = Conf( 

1073 strict_mode=os.getenv("VIUR_CORE_CONFIG_STRICT_MODE", "").lower() != "false", 

1074)