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

390 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-25 15:02 +0000

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 

31 

32 

33class ConfigType: 

34 """An abstract class for configurations. 

35 

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

37 """ 

38 _mapping = {} 

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

40 

41 _strict_mode = None 

42 """Internal strict mode for this instance. 

43 

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

45 

46 _parent = None 

47 """Parent config instance""" 

48 

49 def __init__(self, *, 

50 strict_mode: bool = None, 

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

52 super().__init__() 

53 self._strict_mode = strict_mode 

54 self._parent = parent 

55 

56 @property 

57 def _path(self): 

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

59 if self._parent is None: 

60 return "" 

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

62 

63 @property 

64 def strict_mode(self): 

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

66 

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

68 only attribute access is allowed. 

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

70 

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

72 of the parent. 

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

74 """ 

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

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

77 return self._strict_mode 

78 else: 

79 # no value set: inherit from the parent 

80 return self._parent.strict_mode 

81 

82 @strict_mode.setter 

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

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

85 

86 Does not affect other instances! 

87 """ 

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

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

90 self._strict_mode = value 

91 

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

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

94 

95 This method must not be called in strict mode! 

96 It can be overwritten to apply additional mapping. 

97 """ 

98 if key in self._mapping: 

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

100 warnings.warn( 

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

102 DeprecationWarning, 

103 stacklevel=3, 

104 ) 

105 return key 

106 

107 def items(self, 

108 full_path: bool = False, 

109 recursive: bool = True, 

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

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

112 

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

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

115 :return: 

116 """ 

117 for key in dir(self): 

118 if key.startswith("_"): 

119 # skip internals, like _parent and _strict_mode 

120 continue 

121 value = getattr(self, key) 

122 if recursive and isinstance(value, ConfigType): 

123 yield from value.items(full_path, recursive) 

124 elif key not in dir(ConfigType): 

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

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

127 else: 

128 yield key, value 

129 

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

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

132 

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

134 :param default: The fallback value. 

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

136 """ 

137 if self.strict_mode: 

138 raise SyntaxError( 

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

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

141 ) 

142 try: 

143 return getattr(self, key) 

144 except (KeyError, AttributeError): 

145 return default 

146 

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

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

149 

150 Not allowed in strict mode. 

151 """ 

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

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

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

155 DeprecationWarning, 

156 stacklevel=2) 

157 

158 if self.strict_mode: 

159 raise SyntaxError( 

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

161 f"with dict notation. " 

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

163 ) 

164 

165 return getattr(self, key) 

166 

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

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

169 

170 This method is mostly executed by __getitem__, by the 

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

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

173 """ 

174 if self.strict_mode: 

175 raise AttributeError( 

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

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

178 ) 

179 

180 key = self._resolve_mapping(key) 

181 

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

183 if "." in key: 

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

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

186 

187 return super().__getattribute__(key) 

188 

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

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

191 

192 Not allowed in strict mode. 

193 """ 

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

195 if self.strict_mode: 

196 raise SyntaxError( 

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

198 f"with dict notation. " 

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

200 ) 

201 

202 # TODO: re-enable?! 

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

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

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

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

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

208 # return 

209 

210 key = self._resolve_mapping(key) 

211 

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

213 if "." in key: 

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

215 if not hasattr(self, first): 

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

217 # This segment doesn't exist. Create it 

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

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

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

221 return 

222 

223 return setattr(self, key, value) 

224 

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

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

227 

228 In strict mode it does nothing except a super call 

229 for the default object behavior. 

230 """ 

231 if self.strict_mode: 

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

233 

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

235 key = self._resolve_mapping(key) 

236 

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

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

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

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

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

242 

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

244 

245 def __repr__(self) -> str: 

246 """Representation of this config""" 

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

248 

249 

250# Some values used more than once below 

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

252_app_version = os.getenv("GAE_VERSION") 

253 

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

255_project_base_path = Path().absolute() 

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

257 

258 

259class Admin(ConfigType): 

260 """Administration tool configuration""" 

261 

262 name: str = "ViUR" 

263 """Administration tool configuration""" 

264 

265 logo: str = "" 

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

267 

268 login_background: str = "" 

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

270 

271 login_logo: str = "" 

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

273 

274 color_primary: str = "#d00f1c" 

275 """primary color for viur-admin""" 

276 

277 color_secondary: str = "#333333" 

278 """secondary color for viur-admin""" 

279 

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

281 """Module Groups for the admin tool 

282 

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

284 

285 Example: 

286 conf.admin.module_groups = { 

287 "content": { 

288 "name": "Content", 

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

290 "sortindex": 10, 

291 }, 

292 "shop": { 

293 "name": "Shop", 

294 "icon": "cart-fill", 

295 "sortindex": 20, 

296 }, 

297 } 

298 

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

300 the admin_info of the module: 

301 "moduleGroup": "content", 

302 """ 

303 

304 _mapping: dict[str, str] = { 

305 "login.background": "login_background", 

306 "login.logo": "login_logo", 

307 "color.primary": "color_primary", 

308 "color.secondary": "color_secondary", 

309 } 

310 

311 

312class Database(ConfigType): 

313 query_external_limit: int = 100 

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

315 

316 query_default_limit: int = 30 

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

318 

319 memcache_client: Client | None = None 

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

321 

322 create_access_log: bool = True 

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

324 

325 transaction_attempts: int = 3 

326 """How often :func:`db.run_in_transaction <viur.core.db.transport.run_in_transaction>` runs 

327 a transaction before giving up on conflicts; must be at least 1. Retries back off exponentially 

328 (1s, 2s, 4s, ...).""" 

329 

330 name: str | None = os.getenv("VIUR_DB_NAME") or None 

331 """Named datastore to target instead of ``(default)``. 

332 

333 Env-sourced: the client is built at ``db.transport`` import time, before any 

334 runtime config could set it.""" 

335 

336 namespace: str | None = os.getenv("VIUR_DB_NAMESPACE") or None 

337 """Datastore namespace to scope to. Env-sourced like `name`.""" 

338 

339 

340class Security(ConfigType): 

341 """Security related settings""" 

342 

343 force_ssl: bool = True 

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

345 

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

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

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

349 

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

351 "enforce": { 

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

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

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

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

356 # Required for login with Google 

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

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

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

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

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

362 } 

363 } 

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

365 

366 reporting_endpoints: dict[str, str] = {} 

367 """Named endpoints reports are being sent to, emitted as ``Reporting-Endpoints`` http-header. 

368 

369 Maps an endpoint name to the URL receiving the reports. Other headers reference these names, 

370 for example the CSP-directive ``report-to``, which supersedes the deprecated ``report-uri``. 

371 The name ``default`` is used by the browser for reports whose header cannot name an endpoint 

372 on its own (i.e. deprecation reports). 

373 

374 Use :func:`~viur.core.securityheaders.set_reporting_endpoint` to set this property. 

375 

376 See https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Reporting-Endpoints 

377 """ 

378 

379 referrer_policy: str = "strict-origin" 

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

381 

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

383 """ 

384 

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

386 "autoplay": ["self"], 

387 "camera": [], 

388 "display-capture": [], 

389 "document-domain": [], 

390 "encrypted-media": [], 

391 "fullscreen": [], 

392 "geolocation": [], 

393 "microphone": [], 

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

395 "usb": [], 

396 } 

397 """Include a default permissions-policy. 

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

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

400 """ 

401 

402 enable_coep: bool = False 

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

404 

405 enable_coop: t.Literal[ 

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

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

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

409 

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

411 """ 

412 

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

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

415 

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

417 """ 

418 

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

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

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

422 

423 x_frame_options: t.Optional[ 

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

425 ] = ("sameorigin", None) 

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

427 

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

429 Otherwise, it can be None. 

430 """ 

431 

432 x_xss_protection: t.Optional[bool] = True 

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

434 

435 x_content_type_options: bool = True 

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

437 

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

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

440 

441 captcha_default_public_key: t.Optional[str] = None 

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

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

444 """ 

445 

446 captcha_enforce_always: bool = False 

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

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

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

450 enforces always a valid captcha. 

451 """ 

452 

453 password_recovery_key_length: int = 42 

454 """Length of the Password recovery key""" 

455 

456 closed_system: bool = False 

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

458 

459 admin_allowed_paths: t.Iterable[str] = [ 

460 "vi", 

461 "vi/config", 

462 "vi/skey", 

463 "vi/user/auth_*", 

464 "vi/user/f2_*", 

465 "vi/user/login", 

466 "vi/user/select_authentication_provider", 

467 # DEPRECATED: 

468 "vi/settings", # FIXME: Deprecated; vi-admin 4.x backward compatiblity 

469 "vi/user/getAuthMethods", # FIXME: Deprecated; vi-admin 4.x backward compatiblity 

470 ] 

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

472 

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

474 "", # index site 

475 "json/skey", 

476 "json/user/auth_*", 

477 "json/user/f2_*", 

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

479 "json/user/login", 

480 "user/auth_*", 

481 "user/f2_*", 

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

483 "user/select_authentication_provider", 

484 "user/login", 

485 ] 

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

487 

488 # CORS Settings 

489 

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

491 r"""Allowed origins 

492 Access-Control-Allow-Origin 

493 

494 Pattern should be case-insensitive, for example: 

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

496 """ # noqa 

497 

498 cors_origins_use_wildcard: bool = False 

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

500 

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

502 """Access-Control-Request-Method""" 

503 

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

505 """Access-Control-Request-Headers 

506 

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

508 

509 Pattern should be case-insensitive, for example: 

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

511 """ 

512 

513 cors_allow_credentials: bool = False 

514 """ 

515 Set Access-Control-Allow-Credentials to true 

516 to support fetch requests with credentials: include 

517 """ 

518 

519 cors_max_age: datetime.timedelta | None = None 

520 """Allow caching""" 

521 

522 _mapping = { 

523 "contentSecurityPolicy": "content_security_policy", 

524 "referrerPolicy": "referrer_policy", 

525 "permissionsPolicy": "permissions_policy", 

526 "enableCOEP": "enable_coep", 

527 "enableCOOP": "enable_coop", 

528 "enableCORP": "enable_corp", 

529 "strictTransportSecurity": "strict_transport_security", 

530 "xFrameOptions": "x_frame_options", 

531 "xXssProtection": "x_xss_protection", 

532 "xContentTypeOptions": "x_content_type_options", 

533 "xPermittedCrossDomainPolicies": "x_permitted_cross_domain_policies", 

534 } 

535 

536 

537class Debug(ConfigType): 

538 """Several debug flags""" 

539 

540 trace: bool = False 

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

542 

543 trace_exceptions: bool = False 

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

545 

546 trace_external_call_routing: bool = False 

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

548 

549 trace_internal_call_routing: bool = False 

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

551 

552 trace_queries: bool = False 

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

554 

555 skeleton_from_client: bool = False 

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

557 

558 dev_server_cloud_logging: bool = False 

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

560 

561 disable_cache: bool = False 

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

563 

564 _mapping = { 

565 "skeleton.fromClient": "skeleton_from_client", 

566 "traceExceptions": "trace_exceptions", 

567 "traceExternalCallRouting": "trace_external_call_routing", 

568 "traceInternalCallRouting": "trace_internal_call_routing", 

569 "skeleton_fromClient": "skeleton_from_client", 

570 "disableCache": "disable_cache", 

571 } 

572 

573 

574class Email(ConfigType): 

575 """Email related settings.""" 

576 

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

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

579 

580 transport_class: "EmailTransport" = None 

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

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

583 """ 

584 

585 send_from_local_development_server: bool = False 

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

587 Otherwise, they'll just be logged. 

588 """ 

589 

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

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

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

593 """ 

594 

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

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

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

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

599 """ 

600 

601 sender_override: str | None = None 

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

603 

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

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

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

607 

608 _mapping = { 

609 "logRetention": "log_retention", 

610 "transportClass": "transport_class", 

611 "sendFromLocalDevelopmentServer": "send_from_local_development_server", 

612 "recipientOverride": "recipient_override", 

613 "senderOverride": "sender_override", 

614 "sendInBlue.apiKey": "sendinblue_api_key", 

615 "sendInBlue.thresholds": "sendinblue_thresholds", 

616 } 

617 

618 

619class History(ConfigType): 

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

621 """All history related settings.""" 

622 excluded_actions: Multiple[str] = [] 

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

624 excluded_kinds: Multiple[str] = [] 

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

626 

627 

628class I18N(ConfigType): 

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

630 

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

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

633 

634 default_language: str = "en" 

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

636 

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

638 """Maps Domains to alternative default languages""" 

639 

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

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

642 

643 fallback_languages: Multiple[str] = [] 

644 """Languages tried in order when the requested language has no translation""" 

645 

646 sources: Multiple["i18n.TranslationSource"] = None 

647 """Translation sources, loaded in order; None uses i18n.DEFAULT_TRANSLATION_SOURCES""" 

648 

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

650 """Defines how translations are applied: 

651 - session: Per Session 

652 - url: inject language prefix in url 

653 - domain: one domain per language 

654 - header: Per Http-Header 

655 """ 

656 

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

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

659 

660 auto_translate_bones: bool = True 

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

662 

663 @property 

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

665 """Main languages and language aliases""" 

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

667 res = dict.fromkeys(self.available_languages) 

668 res |= self.language_alias_map 

669 return list(res.keys()) 

670 

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

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

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

674 

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

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

677 default value (if set) so that the translations 

678 can be entered in the administration. 

679 

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

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

682 patterns will be automatically added. 

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

684 """ 

685 

686 def _dump_can_view(self, _key): 

687 return bool(current_user.get()) 

688 

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

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

691 

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

693 

694 

695class User(ConfigType): 

696 """User, session, login related settings""" 

697 

698 access_rights: Multiple[str] = [ 

699 "root", 

700 "admin", 

701 "scriptor", 

702 ] 

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

704 

705 There are three default flags: 

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

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

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

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

710 fine grained usage rule definitions. 

711 """ 

712 

713 roles: dict[str, str] = { 

714 "custom": "Custom", 

715 "user": "User", 

716 "viewer": "Viewer", 

717 "editor": "Editor", 

718 "admin": "Administrator", 

719 } 

720 """User roles available on this project. 

721 

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

723 

724 The default roles can be described as follows: 

725 

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

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

728 just check for authenticated users. 

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

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

731 or `delete`. 

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

733 

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

735 """ 

736 

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

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

739 

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

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

742 

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

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

745 

746 max_password_length: int = 512 

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

748 

749 otp_issuer: t.Optional[str] = None 

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

751 

752 google_client_id: t.Optional[str] = None 

753 """OAuth Client ID for Google Login""" 

754 

755 google_gsuite_domains: list[str] = [] 

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

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

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

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

760 no account is created.""" 

761 

762 redirect_whitelist: list[str] | t.Callable[[], list[str]] = ( 

763 lambda _: ["http://localhost:*", f"https://*{_project_id}.appspot.com*"] 

764 ) 

765 """Allowed redirect_to patterns for get_cookie_for_app (matched via :func:`fnmatch.fnmatch`). 

766 

767 The default is a callable that permits only ``http://localhost:*`` and any URL 

768 containing the current GCP project-ID — a safe built-in policy. 

769 A zero-argument callable is supported and evaluated on every request. 

770 Use ``["*"]`` to disable the restriction entirely. 

771 

772 Examples:: 

773 

774 conf.user.redirect_whitelist = [ 

775 "http://localhost:*", 

776 "https://*.myapp.appspot.com*", 

777 ] 

778 

779 # dynamic / lazily evaluated 

780 conf.user.redirect_whitelist = lambda: load_whitelist_from_db() 

781 """ 

782 

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

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

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

786 from viur.core import utils 

787 warnings.warn( 

788 "Please use timedelta to set session_life_time.", 

789 DeprecationWarning, stacklevel=2, 

790 ) 

791 value = utils.parse.timedelta(value) 

792 super().__setattr__(name, value) 

793 

794 

795class Instance(ConfigType): 

796 """All app instance related settings information""" 

797 app_version: str = _app_version 

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

799 

800 core_base_path: Path = _core_base_path 

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

802 

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

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

805 

806 project_base_path: Path = _project_base_path 

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

808 

809 project_id: str = _project_id 

810 """The instance's project ID""" 

811 

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

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

814 

815 

816class Conf(ConfigType): 

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

818 deprecated keys or other special operations. 

819 """ 

820 

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

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

823 

824 bone_string_escape_html: bool = True 

825 """Default escape_html setting for StringBone. Set to False to disable HTML escaping globally.""" 

826 

827 bone_strict_mode: bool = os.getenv("VIUR_CORE_BONE_STRICT_MODE", "").lower() != "false" 

828 """If enabled (the default), setting an *unknown* attribute on a bone after its construction 

829 raises an AttributeError instead of silently creating it -- this catches typos like ``readonly`` 

830 instead of ``readOnly``. Disable via ``conf.bone_strict_mode = False`` or the environment 

831 variable ``VIUR_CORE_BONE_STRICT_MODE=false``.""" 

832 

833 bone_html_default_allow: "HtmlBoneConfiguration" = { 

834 "validTags": [ 

835 "a", 

836 "abbr", 

837 "b", 

838 "blockquote", 

839 "br", 

840 "div", 

841 "em", 

842 "h1", 

843 "h2", 

844 "h3", 

845 "h4", 

846 "h5", 

847 "h6", 

848 "hr", 

849 "i", 

850 "img", 

851 "li", 

852 "ol", 

853 "p", 

854 "span", 

855 "strong", 

856 "sub", 

857 "sup", 

858 "table", 

859 "tbody", 

860 "td", 

861 "tfoot", 

862 "th", 

863 "thead", 

864 "tr", 

865 "u", 

866 "ul", 

867 ], 

868 "validAttrs": { 

869 "a": [ 

870 "href", 

871 "target", 

872 "title", 

873 ], 

874 "abbr": [ 

875 "title", 

876 ], 

877 "blockquote": [ 

878 "cite", 

879 ], 

880 "img": [ 

881 "src", 

882 "alt", 

883 "title", 

884 ], 

885 "p": [ 

886 "data-indent", 

887 ], 

888 "span": [ 

889 "title", 

890 ], 

891 "td": [ 

892 "colspan", 

893 "rowspan", 

894 ], 

895 

896 }, 

897 "validStyles": [ 

898 "color", 

899 ], 

900 "validClasses": [ 

901 "vitxt-*", 

902 "viur-txt-*" 

903 ], 

904 "singleTags": [ 

905 "br", 

906 "hr", 

907 "img", 

908 ] 

909 } 

910 """ 

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

912 """ 

913 

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

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

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

917 

918 # FIXME VIUR4: REMOVE ALL COMPATIBILITY MODES! 

919 compatibility: Multiple[str] = [ 

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

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

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

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

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

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

926 # values structure (#1203) 

927 ] 

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

929 

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

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

932 

933 error_logo: str = None 

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

935 

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

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

938 

939 file_hmac_key: str = None 

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

941 

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

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

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

945 

946 file_thumbnailer_url: t.Optional[str] = None 

947 # TODO: """docstring""" 

948 

949 main_app: "Module" = None 

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

951 

952 main_resolver: dict[str, dict] = None 

953 """Dictionary for Resolving functions for URLs""" 

954 

955 max_post_params_count: int = 250 

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

957 

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

959 """ 

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

961 Returning True means to filter out. 

962 """ 

963 

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

965 "icon": "gear-fill", 

966 "display": "hidden", 

967 } 

968 """Describing the internal ModuleConfig-module""" 

969 

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

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

972 "display": "hidden", 

973 } 

974 """Describing the Script module""" 

975 

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

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

978 

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

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

981 

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

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

984 

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

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

987 

988 skeleton_search_path: Multiple[str] = [ 

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

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

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

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

993 ] 

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

995 

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

997 

998 @property 

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

1000 """ 

1001 Preserve additional environment in deferred tasks. 

1002 

1003 If set, it must be an instance of CustomEnvironmentHandler 

1004 for serializing/restoring environment data. 

1005 """ 

1006 return self._tasks_custom_environment_handler 

1007 

1008 @tasks_custom_environment_handler.setter 

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

1010 from .tasks import CustomEnvironmentHandler 

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

1012 self._tasks_custom_environment_handler = value 

1013 elif isinstance(value, tuple): 

1014 if len(value) != 2: 

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

1016 warnings.warn( 

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

1018 DeprecationWarning, stacklevel=2, 

1019 ) 

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

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

1022 # serialize and restore will be bound methods. 

1023 # Therefore, consume the self argument with lambda. 

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

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

1026 self._tasks_custom_environment_handler = cls() 

1027 else: 

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

1029 

1030 tasks_default_queues: dict[str, str] = { 

1031 "__default__": "default", 

1032 } 

1033 """ 

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

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

1036 when calling the task. 

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

1038 default values can be defined here for each task. 

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

1040 ``` 

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

1042 ``` 

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

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

1045 """ 

1046 

1047 valid_application_ids: list[str] = ["*"] 

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

1049 

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

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

1052 

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

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

1055 

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

1057 super().__init__() 

1058 self._strict_mode = strict_mode 

1059 self.admin = Admin(parent=self) 

1060 self.db = Database(parent=self) 

1061 self.security = Security(parent=self) 

1062 self.debug = Debug(parent=self) 

1063 self.email = Email(parent=self) 

1064 self.i18n = I18N(parent=self) 

1065 self.user = User(parent=self) 

1066 self.instance = Instance(parent=self) 

1067 self.history = History(parent=self) 

1068 

1069 _mapping = { 

1070 # debug 

1071 "viur.dev_server_cloud_logging": "debug.dev_server_cloud_logging", 

1072 "viur.disable_cache": "debug.disable_cache", 

1073 # i18n 

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

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

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

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

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

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

1080 # user 

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

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

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

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

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

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

1087 "viur.user.roles": "user.roles", 

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

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

1090 # instance 

1091 "viur.instance.app_version": "instance.app_version", 

1092 "viur.instance.core_base_path": "instance.core_base_path", 

1093 "viur.instance.is_dev_server": "instance.is_dev_server", 

1094 "viur.instance.project_base_path": "instance.project_base_path", 

1095 "viur.instance.project_id": "instance.project_id", 

1096 "viur.instance.version_hash": "instance.version_hash", 

1097 # security 

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

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

1100 # old viur-prefix 

1101 "viur.cacheEnvironmentKey": "cache_environment_key", 

1102 "viur.contentSecurityPolicy": "content_security_policy", 

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

1104 "viur.errorHandler": "error_handler", 

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

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

1107 "viur.file_hmacKey": "file_hmac_key", 

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

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

1110 "viur.mainApp": "main_app", 

1111 "viur.mainResolver": "main_resolver", 

1112 "viur.maxPostParamsCount": "max_post_params_count", 

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

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

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

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

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

1118 "viur.requestPreprocessor": "request_preprocessor", 

1119 "viur.searchValidChars": "search_valid_chars", 

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

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

1122 "viur.validApplicationIDs": "valid_application_ids", 

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

1124 } 

1125 

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

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

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

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

1130 return super()._resolve_mapping(key) 

1131 

1132 

1133conf = Conf( 

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

1135)