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.7, created at 2025-09-29 09:00 +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/select_authentication_provider", 

444 "vi/user/login", 

445 ] 

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

447 

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

449 "", # index site 

450 "json/skey", 

451 "json/user/auth_*", 

452 "json/user/f2_*", 

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

454 "json/user/login", 

455 "user/auth_*", 

456 "user/f2_*", 

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

458 "user/select_authentication_provider", 

459 "user/login", 

460 ] 

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

462 

463 # CORS Settings 

464 

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

466 """Allowed origins 

467 Access-Control-Allow-Origin 

468 

469 Pattern should be case-insensitive, for example: 

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

471 """ # noqa 

472 

473 cors_origins_use_wildcard: bool = False 

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

475 

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

477 """Access-Control-Request-Method""" 

478 

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

480 """Access-Control-Request-Headers 

481 

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

483 

484 Pattern should be case-insensitive, for example: 

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

486 """ 

487 

488 cors_allow_credentials: bool = False 

489 """ 

490 Set Access-Control-Allow-Credentials to true 

491 to support fetch requests with credentials: include 

492 """ 

493 

494 cors_max_age: datetime.timedelta | None = None 

495 """Allow caching""" 

496 

497 _mapping = { 

498 "contentSecurityPolicy": "content_security_policy", 

499 "referrerPolicy": "referrer_policy", 

500 "permissionsPolicy": "permissions_policy", 

501 "enableCOEP": "enable_coep", 

502 "enableCOOP": "enable_coop", 

503 "enableCORP": "enable_corp", 

504 "strictTransportSecurity": "strict_transport_security", 

505 "xFrameOptions": "x_frame_options", 

506 "xXssProtection": "x_xss_protection", 

507 "xContentTypeOptions": "x_content_type_options", 

508 "xPermittedCrossDomainPolicies": "x_permitted_cross_domain_policies", 

509 "captcha_defaultCredentials": "captcha_default_credentials", 

510 "captcha.defaultCredentials": "captcha_default_credentials", 

511 } 

512 

513 

514class Debug(ConfigType): 

515 """Several debug flags""" 

516 

517 trace: bool = False 

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

519 

520 trace_exceptions: bool = False 

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

522 

523 trace_external_call_routing: bool = False 

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

525 

526 trace_internal_call_routing: bool = False 

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

528 

529 trace_queries: bool = False 

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

531 

532 skeleton_from_client: bool = False 

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

534 

535 dev_server_cloud_logging: bool = False 

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

537 

538 disable_cache: bool = False 

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

540 

541 _mapping = { 

542 "skeleton.fromClient": "skeleton_from_client", 

543 "traceExceptions": "trace_exceptions", 

544 "traceExternalCallRouting": "trace_external_call_routing", 

545 "traceInternalCallRouting": "trace_internal_call_routing", 

546 "skeleton_fromClient": "skeleton_from_client", 

547 "disableCache": "disable_cache", 

548 } 

549 

550 

551class Email(ConfigType): 

552 """Email related settings.""" 

553 

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

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

556 

557 transport_class: "EmailTransport" = None 

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

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

560 """ 

561 

562 send_from_local_development_server: bool = False 

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

564 Otherwise, they'll just be logged. 

565 """ 

566 

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

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

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

570 """ 

571 

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

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

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

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

576 """ 

577 

578 sender_override: str | None = None 

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

580 

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

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

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

584 

585 _mapping = { 

586 "logRetention": "log_retention", 

587 "transportClass": "transport_class", 

588 "sendFromLocalDevelopmentServer": "send_from_local_development_server", 

589 "recipientOverride": "recipient_override", 

590 "senderOverride": "sender_override", 

591 "sendInBlue.apiKey": "sendinblue_api_key", 

592 "sendInBlue.thresholds": "sendinblue_thresholds", 

593 } 

594 

595 

596class History(ConfigType): 

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

598 """All history related settings.""" 

599 excluded_actions: Multiple[str] = [] 

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

601 excluded_kinds: Multiple[str] = [] 

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

603 

604 

605class I18N(ConfigType): 

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

607 

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

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

610 

611 default_language: str = "en" 

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

613 

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

615 """Maps Domains to alternative default languages""" 

616 

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

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

619 

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

621 """Defines how translations are applied: 

622 - session: Per Session 

623 - url: inject language prefix in url 

624 - domain: one domain per language 

625 - header: Per Http-Header 

626 """ 

627 

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

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

630 

631 auto_translate_bones: bool = True 

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

633 

634 @property 

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

636 """Main languages and language aliases""" 

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

638 res = dict.fromkeys(self.available_languages) 

639 res |= self.language_alias_map 

640 return list(res.keys()) 

641 

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

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

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

645 

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

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

648 default value (if set) so that the translations 

649 can be entered in the administration. 

650 

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

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

653 patterns will be automatically added. 

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

655 """ 

656 

657 def _dump_can_view(self, _key): 

658 return bool(current_user.get()) 

659 

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

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

662 

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

664 

665 

666class User(ConfigType): 

667 """User, session, login related settings""" 

668 

669 access_rights: Multiple[str] = [ 

670 "root", 

671 "admin", 

672 "scriptor", 

673 ] 

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

675 

676 There are three default flags: 

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

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

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

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

681 fine grained usage rule definitions. 

682 """ 

683 

684 roles: dict[str, str] = { 

685 "custom": "Custom", 

686 "user": "User", 

687 "viewer": "Viewer", 

688 "editor": "Editor", 

689 "admin": "Administrator", 

690 } 

691 """User roles available on this project. 

692 

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

694 

695 The default roles can be described as follows: 

696 

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

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

699 just check for authenticated users. 

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

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

702 or `delete`. 

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

704 

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

706 """ 

707 

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

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

710 

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

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

713 

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

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

716 

717 max_password_length: int = 512 

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

719 

720 otp_issuer: t.Optional[str] = None 

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

722 

723 google_client_id: t.Optional[str] = None 

724 """OAuth Client ID for Google Login""" 

725 

726 google_gsuite_domains: list[str] = [] 

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

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

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

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

731 no account is created.""" 

732 

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

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

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

736 from viur.core import utils 

737 warnings.warn( 

738 "Please use timedelta to set session_life_time.", 

739 DeprecationWarning, stacklevel=2, 

740 ) 

741 value = utils.parse.timedelta(value) 

742 super().__setattr__(name, value) 

743 

744 

745class Instance(ConfigType): 

746 """All app instance related settings information""" 

747 app_version: str = _app_version 

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

749 

750 core_base_path: Path = _core_base_path 

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

752 

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

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

755 

756 project_base_path: Path = _project_base_path 

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

758 

759 project_id: str = _project_id 

760 """The instance's project ID""" 

761 

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

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

764 

765 

766class Conf(ConfigType): 

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

768 deprecated keys or other special operations. 

769 """ 

770 

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

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

773 

774 bone_html_default_allow: "HtmlBoneConfiguration" = { 

775 "validTags": [ 

776 "a", 

777 "abbr", 

778 "b", 

779 "blockquote", 

780 "br", 

781 "div", 

782 "em", 

783 "h1", 

784 "h2", 

785 "h3", 

786 "h4", 

787 "h5", 

788 "h6", 

789 "hr", 

790 "i", 

791 "img", 

792 "li", 

793 "ol", 

794 "p", 

795 "span", 

796 "strong", 

797 "sub", 

798 "sup", 

799 "table", 

800 "tbody", 

801 "td", 

802 "tfoot", 

803 "th", 

804 "thead", 

805 "tr", 

806 "u", 

807 "ul", 

808 ], 

809 "validAttrs": { 

810 "a": [ 

811 "href", 

812 "target", 

813 "title", 

814 ], 

815 "abbr": [ 

816 "title", 

817 ], 

818 "blockquote": [ 

819 "cite", 

820 ], 

821 "img": [ 

822 "src", 

823 "alt", 

824 "title", 

825 ], 

826 "p": [ 

827 "data-indent", 

828 ], 

829 "span": [ 

830 "title", 

831 ], 

832 "td": [ 

833 "colspan", 

834 "rowspan", 

835 ], 

836 

837 }, 

838 "validStyles": [ 

839 "color", 

840 ], 

841 "validClasses": [ 

842 "vitxt-*", 

843 "viur-txt-*" 

844 ], 

845 "singleTags": [ 

846 "br", 

847 "hr", 

848 "img", 

849 ] 

850 } 

851 """ 

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

853 """ 

854 

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

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

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

858 

859 # FIXME VIUR4: REMOVE ALL COMPATIBILITY MODES! 

860 compatibility: Multiple[str] = [ 

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

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

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

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

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

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

867 # values structure (#1203) 

868 ] 

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

870 

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

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

873 

874 error_logo: str = None 

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

876 

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

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

879 

880 file_hmac_key: str = None 

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

882 

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

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

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

886 

887 file_thumbnailer_url: t.Optional[str] = None 

888 # TODO: """docstring""" 

889 

890 main_app: "Module" = None 

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

892 

893 main_resolver: dict[str, dict] = None 

894 """Dictionary for Resolving functions for URLs""" 

895 

896 max_post_params_count: int = 250 

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

898 

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

900 """ 

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

902 Returning True means to filter out. 

903 """ 

904 

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

906 "icon": "gear-fill", 

907 "display": "hidden", 

908 } 

909 """Describing the internal ModuleConfig-module""" 

910 

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

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

913 "display": "hidden", 

914 } 

915 """Describing the Script module""" 

916 

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

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

919 

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

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

922 

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

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

925 

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

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

928 

929 skeleton_search_path: Multiple[str] = [ 

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

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

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

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

934 ] 

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

936 

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

938 

939 @property 

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

941 """ 

942 Preserve additional environment in deferred tasks. 

943 

944 If set, it must be an instance of CustomEnvironmentHandler 

945 for serializing/restoring environment data. 

946 """ 

947 return self._tasks_custom_environment_handler 

948 

949 @tasks_custom_environment_handler.setter 

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

951 from .tasks import CustomEnvironmentHandler 

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

953 self._tasks_custom_environment_handler = value 

954 elif isinstance(value, tuple): 

955 if len(value) != 2: 

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

957 warnings.warn( 

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

959 DeprecationWarning, stacklevel=2, 

960 ) 

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

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

963 # serialize and restore will be bound methods. 

964 # Therefore, consume the self argument with lambda. 

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

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

967 self._tasks_custom_environment_handler = cls() 

968 else: 

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

970 

971 tasks_default_queues: dict[str, str] = { 

972 "__default__": "default", 

973 } 

974 """ 

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

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

977 when calling the task. 

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

979 default values can be defined here for each task. 

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

981 ``` 

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

983 ``` 

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

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

986 """ 

987 

988 valid_application_ids: list[str] = [] 

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

990 

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

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

993 

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

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

996 

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

998 super().__init__() 

999 self._strict_mode = strict_mode 

1000 self.admin = Admin(parent=self) 

1001 self.db = Database(parent=self) 

1002 self.security = Security(parent=self) 

1003 self.debug = Debug(parent=self) 

1004 self.email = Email(parent=self) 

1005 self.i18n = I18N(parent=self) 

1006 self.user = User(parent=self) 

1007 self.instance = Instance(parent=self) 

1008 self.history = History(parent=self) 

1009 

1010 _mapping = { 

1011 # debug 

1012 "viur.dev_server_cloud_logging": "debug.dev_server_cloud_logging", 

1013 "viur.disable_cache": "debug.disable_cache", 

1014 # i18n 

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

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

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

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

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

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

1021 # user 

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

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

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

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

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

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

1028 "viur.user.roles": "user.roles", 

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

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

1031 # instance 

1032 "viur.instance.app_version": "instance.app_version", 

1033 "viur.instance.core_base_path": "instance.core_base_path", 

1034 "viur.instance.is_dev_server": "instance.is_dev_server", 

1035 "viur.instance.project_base_path": "instance.project_base_path", 

1036 "viur.instance.project_id": "instance.project_id", 

1037 "viur.instance.version_hash": "instance.version_hash", 

1038 # security 

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

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

1041 # old viur-prefix 

1042 "viur.cacheEnvironmentKey": "cache_environment_key", 

1043 "viur.contentSecurityPolicy": "content_security_policy", 

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

1045 "viur.errorHandler": "error_handler", 

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

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

1048 "viur.file_hmacKey": "file_hmac_key", 

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

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

1051 "viur.mainApp": "main_app", 

1052 "viur.mainResolver": "main_resolver", 

1053 "viur.maxPostParamsCount": "max_post_params_count", 

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

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

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

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

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

1059 "viur.requestPreprocessor": "request_preprocessor", 

1060 "viur.searchValidChars": "search_valid_chars", 

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

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

1063 "viur.validApplicationIDs": "valid_application_ids", 

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

1065 } 

1066 

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

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

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

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

1071 return super()._resolve_mapping(key) 

1072 

1073 

1074conf = Conf( 

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

1076)