simple-module-hosting 0.0.35__tar.gz → 0.0.36__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/PKG-INFO +6 -4
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/pyproject.toml +8 -4
- simple_module_hosting-0.0.36/simple_module_hosting/_body_limit.py +191 -0
- simple_module_hosting-0.0.36/simple_module_hosting/_boot_checks.py +47 -0
- simple_module_hosting-0.0.36/simple_module_hosting/_csp_dynamic.py +99 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/_dev_boot.py +3 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/_error_handlers.py +15 -3
- simple_module_hosting-0.0.36/simple_module_hosting/_i18n_build.py +58 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/_inertia_setup.py +5 -1
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/_inertia_shared.py +33 -4
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/_lifespan.py +54 -1
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/_module_routes.py +7 -1
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/_phase_helpers.py +32 -68
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/_preapp_config.py +1 -1
- simple_module_hosting-0.0.36/simple_module_hosting/_project_root.py +39 -0
- simple_module_hosting-0.0.36/simple_module_hosting/_rate_limit.py +187 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/_registrations.py +3 -0
- simple_module_hosting-0.0.36/simple_module_hosting/_request_guard_responses.py +50 -0
- simple_module_hosting-0.0.36/simple_module_hosting/_route_guard.py +100 -0
- simple_module_hosting-0.0.36/simple_module_hosting/_settings_registration.py +70 -0
- simple_module_hosting-0.0.36/simple_module_hosting/_tenant.py +197 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/app_builder.py +24 -40
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/assets.py +50 -1
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/bootstrap_settings.py +5 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/host_settings.py +35 -1
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/i18n_manifest.py +62 -39
- simple_module_hosting-0.0.36/simple_module_hosting/locales/en.json +24 -0
- simple_module_hosting-0.0.36/simple_module_hosting/locales/es.json +24 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/manifest.py +7 -2
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/middleware.py +36 -8
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/migrations.py +28 -0
- simple_module_hosting-0.0.36/simple_module_hosting/permissions.py +183 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/session.py +19 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/setup_gate.py +60 -7
- simple_module_hosting-0.0.36/simple_module_hosting/setup_wizard/__init__.py +72 -0
- simple_module_hosting-0.0.36/simple_module_hosting/setup_wizard/components/ConnectionList.tsx +84 -0
- simple_module_hosting-0.0.36/simple_module_hosting/setup_wizard/components/StepForm.tsx +132 -0
- simple_module_hosting-0.0.36/simple_module_hosting/setup_wizard/migrate.py +92 -0
- simple_module_hosting-0.0.36/simple_module_hosting/setup_wizard/pages/Wizard.tsx +131 -0
- simple_module_hosting-0.0.36/simple_module_hosting/setup_wizard/payloads.py +117 -0
- simple_module_hosting-0.0.36/simple_module_hosting/setup_wizard/routes.py +135 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/shared_props.py +4 -4
- simple_module_hosting-0.0.36/simple_module_hosting/tenancy.py +92 -0
- simple_module_hosting-0.0.36/simple_module_hosting/ui_locales/en.json +74 -0
- simple_module_hosting-0.0.36/simple_module_hosting/ui_locales/es.json +74 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_branding_head.py +16 -0
- simple_module_hosting-0.0.36/tests/test_check_migrations_expression_note.py +30 -0
- simple_module_hosting-0.0.36/tests/test_csp_providers.py +163 -0
- simple_module_hosting-0.0.36/tests/test_grant_sources.py +122 -0
- simple_module_hosting-0.0.36/tests/test_home_url_default.py +35 -0
- simple_module_hosting-0.0.36/tests/test_hosting_error_json_and_session_cookie.py +82 -0
- simple_module_hosting-0.0.36/tests/test_i18n_build_overrides.py +36 -0
- simple_module_hosting-0.0.36/tests/test_i18n_ui_namespace.py +15 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_inertia_i18n_shared_props.py +1 -1
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_inertia_shared_providers.py +12 -0
- simple_module_hosting-0.0.36/tests/test_lifespan_deferred_startup.py +94 -0
- simple_module_hosting-0.0.36/tests/test_menu_providers_wiring.py +95 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_middleware_order.py +19 -2
- simple_module_hosting-0.0.36/tests/test_principal_permissions_claim.py +91 -0
- simple_module_hosting-0.0.36/tests/test_request_guard_auth_parity.py +116 -0
- simple_module_hosting-0.0.36/tests/test_request_guard_body_limit.py +181 -0
- simple_module_hosting-0.0.36/tests/test_request_guard_body_limit_middleware_read.py +41 -0
- simple_module_hosting-0.0.36/tests/test_request_guard_body_limit_started.py +67 -0
- simple_module_hosting-0.0.36/tests/test_request_guard_rate_limit.py +293 -0
- simple_module_hosting-0.0.36/tests/test_route_guard.py +131 -0
- simple_module_hosting-0.0.36/tests/test_session_vary_dedupe.py +39 -0
- simple_module_hosting-0.0.36/tests/test_setup_gate_single_flight.py +137 -0
- simple_module_hosting-0.0.36/tests/test_setup_migrate_error_redaction.py +55 -0
- simple_module_hosting-0.0.36/tests/test_setup_routes.py +135 -0
- simple_module_hosting-0.0.36/tests/test_tenancy_api.py +168 -0
- simple_module_hosting-0.0.36/tests/test_tenant_resolver_diagnostic.py +47 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_tenant_scope_helpers.py +17 -0
- simple_module_hosting-0.0.36/tests/test_tenant_source.py +171 -0
- simple_module_hosting-0.0.36/tests/test_view_prefixes.py +39 -0
- simple_module_hosting-0.0.36/tsconfig.json +12 -0
- simple_module_hosting-0.0.35/simple_module_hosting/_tenant.py +0 -97
- simple_module_hosting-0.0.35/simple_module_hosting/permissions.py +0 -104
- simple_module_hosting-0.0.35/tests/test_setup_password_policy.py +0 -69
- simple_module_hosting-0.0.35/tests/test_setup_routes.py +0 -137
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/.gitignore +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/LICENSE +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/README.md +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/__init__.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/__main__.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/_db_health.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/_favicon.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/_host_services.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/_hydrate_step.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/_inertia_cache.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/_observability.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/_secret_key.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/csrf.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/health.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/host_cli.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/i18n_deps.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/i18n_middleware.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/inertia_deps.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/inertia_utils.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/logging.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/maintenance.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/page_globs.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/py.typed +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/redirects.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/settings.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/static_files.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_api_error_negotiation.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_app.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_auth_provider_setting.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_boot_drift_check.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_check_migrations.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_csp_module_sources.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_db_health_check.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_design_packs_wiring.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_diagnostics_state.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_error_page_shared_props.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_error_status_coverage.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_favicon_default.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_health.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_host_cli.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_host_settings_fields.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_hosting_csrf.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_hosting_permissions.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_i18n_manifest.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_i18n_types_cover_installed_modules.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_inertia_cache.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_invalidation_wiring.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_lifespan_order.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_locale_middleware.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_logging.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_maintenance_mode.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_manifest.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_menu_urls_are_canonical.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_migration_listing.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_migration_multihead.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_preapp_config.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_precompressed_static.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_proxy_headers.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_redirects.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_response_compression.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_secret_key_bootstrap.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_session_cookie_security.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_session_cookie_window.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_settings_i18n.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_settings_secrets.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_setup_gate.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_setup_public_routes.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_setup_registry.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_sqlite_path_anchor.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_static_caching.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_strict_discovery_wiring.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_tenant_middleware.py +0 -0
- {simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/tests/test_translator_dep.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: simple_module_hosting
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.36
|
|
4
4
|
Summary: FastAPI + Inertia.js host runtime for simple_module — app_builder, middleware stack, CLI (sm / simple-module), scaffolding
|
|
5
5
|
Project-URL: Homepage, https://github.com/antosubash/simple_module_python
|
|
6
6
|
Project-URL: Repository, https://github.com/antosubash/simple_module_python
|
|
@@ -26,12 +26,14 @@ Requires-Dist: fastapi>=0.115
|
|
|
26
26
|
Requires-Dist: httpx>=0.27
|
|
27
27
|
Requires-Dist: itsdangerous>=2.0
|
|
28
28
|
Requires-Dist: jinja2>=3.1
|
|
29
|
-
Requires-Dist: simple-module-core==0.0.
|
|
30
|
-
Requires-Dist: simple-module-db==0.0.
|
|
31
|
-
Requires-Dist: simple-module-inertia==0.0.
|
|
29
|
+
Requires-Dist: simple-module-core==0.0.36
|
|
30
|
+
Requires-Dist: simple-module-db==0.0.36
|
|
31
|
+
Requires-Dist: simple-module-inertia==0.0.36
|
|
32
32
|
Requires-Dist: starlette>=0.44
|
|
33
33
|
Requires-Dist: tomlkit>=0.13
|
|
34
34
|
Requires-Dist: uvicorn[standard]>=0.34
|
|
35
|
+
Provides-Extra: redis
|
|
36
|
+
Requires-Dist: redis>=5; extra == 'redis'
|
|
35
37
|
Description-Content-Type: text/markdown
|
|
36
38
|
|
|
37
39
|
# simple_module_hosting
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "simple_module_hosting"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.36"
|
|
4
4
|
description = "FastAPI + Inertia.js host runtime for simple_module — app_builder, middleware stack, CLI (sm / simple-module), scaffolding"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -29,14 +29,18 @@ dependencies = [
|
|
|
29
29
|
# it directly — out-of-tree hosts have nothing else pulling it in.
|
|
30
30
|
"itsdangerous>=2.0",
|
|
31
31
|
"jinja2>=3.1",
|
|
32
|
-
"simple_module_core==0.0.
|
|
33
|
-
"simple_module_db==0.0.
|
|
34
|
-
"simple_module_inertia==0.0.
|
|
32
|
+
"simple_module_core==0.0.36",
|
|
33
|
+
"simple_module_db==0.0.36",
|
|
34
|
+
"simple_module_inertia==0.0.36",
|
|
35
35
|
"starlette>=0.44",
|
|
36
36
|
"tomlkit>=0.13",
|
|
37
37
|
"uvicorn[standard]>=0.34",
|
|
38
38
|
]
|
|
39
39
|
|
|
40
|
+
[project.optional-dependencies]
|
|
41
|
+
# Shared rate-limiter store (SM_REDIS_URL). Without it the limiter counts per worker.
|
|
42
|
+
redis = ["redis>=5"]
|
|
43
|
+
|
|
40
44
|
[project.scripts]
|
|
41
45
|
sm-host = "simple_module_hosting.host_cli:app"
|
|
42
46
|
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Request-body size guard (GH #345).
|
|
2
|
+
|
|
3
|
+
Pure ASGI. Refuses an oversized body *before* a route reads it, so the limit
|
|
4
|
+
protects the process and not just the database:
|
|
5
|
+
|
|
6
|
+
* ``Content-Length`` over the ceiling is refused outright, body unread.
|
|
7
|
+
* No (or a lying) ``Content-Length`` — chunked transfer — is caught by counting
|
|
8
|
+
the bytes the app actually pulls through ``receive`` and aborting once the
|
|
9
|
+
ceiling is crossed.
|
|
10
|
+
|
|
11
|
+
The ceiling is the global ``max_request_body_bytes`` unless a module declared a
|
|
12
|
+
per-path override with ``register_body_limits``.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
|
|
19
|
+
from simple_module_core.body_limits import BodyLimitRegistry
|
|
20
|
+
from starlette.datastructures import Headers
|
|
21
|
+
from starlette.exceptions import HTTPException
|
|
22
|
+
from starlette.requests import Request
|
|
23
|
+
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
|
24
|
+
|
|
25
|
+
from simple_module_hosting._error_handlers import _wants_json
|
|
26
|
+
from simple_module_hosting._request_guard_responses import send_guard_response
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger("simple_module.request_guard")
|
|
29
|
+
|
|
30
|
+
_MESSAGE = "Request body is too large."
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _BodyTooLargeError(Exception):
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class BodyLimitMiddleware:
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
app: ASGIApp,
|
|
41
|
+
*,
|
|
42
|
+
max_bytes: int,
|
|
43
|
+
registry: BodyLimitRegistry | None = None,
|
|
44
|
+
) -> None:
|
|
45
|
+
self.app = app
|
|
46
|
+
self.max_bytes = max_bytes
|
|
47
|
+
self.registry = registry
|
|
48
|
+
|
|
49
|
+
def _ceiling(self, scope: Scope) -> int:
|
|
50
|
+
override = None
|
|
51
|
+
if self.registry is not None:
|
|
52
|
+
override = self.registry.limit_for(scope["method"], scope["path"], scope.get("app"))
|
|
53
|
+
return self.max_bytes if override is None else override
|
|
54
|
+
|
|
55
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
56
|
+
if scope["type"] != "http":
|
|
57
|
+
await self.app(scope, receive, send)
|
|
58
|
+
return
|
|
59
|
+
limit = self._ceiling(scope)
|
|
60
|
+
if limit <= 0:
|
|
61
|
+
await self.app(scope, receive, send)
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
declared = Headers(scope=scope).get("content-length")
|
|
65
|
+
too_big = (
|
|
66
|
+
declared is not None
|
|
67
|
+
and declared.isascii()
|
|
68
|
+
and declared.isdigit()
|
|
69
|
+
and int(declared) > limit
|
|
70
|
+
)
|
|
71
|
+
# A browser-shaped request is answered with the Inertia error page, which
|
|
72
|
+
# needs the session / locale / shared props the layers *inside* this one
|
|
73
|
+
# set up. So it is not refused here: the body read raises an
|
|
74
|
+
# ``HTTPException(413)`` that the app's own handler renders in full.
|
|
75
|
+
# (FastAPI re-raises HTTPException from body parsing untouched.) API
|
|
76
|
+
# callers are refused right here, before anything runs.
|
|
77
|
+
if not self._wants_json(scope):
|
|
78
|
+
await self._handle_page(scope, receive, send, limit, too_big)
|
|
79
|
+
return
|
|
80
|
+
if too_big:
|
|
81
|
+
self._log(scope, limit, int(declared))
|
|
82
|
+
await self._refuse(scope, receive, send, limit)
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
seen = 0
|
|
86
|
+
exceeded = False
|
|
87
|
+
refused = False
|
|
88
|
+
started = False
|
|
89
|
+
|
|
90
|
+
async def counting_receive() -> Message:
|
|
91
|
+
nonlocal seen, exceeded
|
|
92
|
+
message = await receive()
|
|
93
|
+
if message["type"] == "http.request":
|
|
94
|
+
seen += len(message.get("body", b""))
|
|
95
|
+
if seen > limit:
|
|
96
|
+
exceeded = True
|
|
97
|
+
raise _BodyTooLargeError
|
|
98
|
+
return message
|
|
99
|
+
|
|
100
|
+
async def guarded_send(message: Message) -> None:
|
|
101
|
+
nonlocal refused, started
|
|
102
|
+
if exceeded and not started:
|
|
103
|
+
# The app is answering a body we cut off (often with a 400 from
|
|
104
|
+
# its own parser). Swallow it; the 413 below is the real answer.
|
|
105
|
+
if not refused:
|
|
106
|
+
refused = True
|
|
107
|
+
self._log(scope, limit, seen)
|
|
108
|
+
await self._refuse(scope, receive, send, limit)
|
|
109
|
+
return
|
|
110
|
+
# Once the app's own response has started it is passed through whole:
|
|
111
|
+
# swallowing the rest would leave the client hanging on a half-sent
|
|
112
|
+
# response. (Any exception it raises for the cut-off body is re-raised
|
|
113
|
+
# below, which aborts the connection instead.)
|
|
114
|
+
if message["type"] == "http.response.start":
|
|
115
|
+
started = True
|
|
116
|
+
await send(message)
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
await self.app(scope, counting_receive, guarded_send)
|
|
120
|
+
except Exception:
|
|
121
|
+
if not exceeded or started:
|
|
122
|
+
if exceeded:
|
|
123
|
+
self._log(scope, limit, seen)
|
|
124
|
+
raise
|
|
125
|
+
if exceeded and not refused and not started:
|
|
126
|
+
self._log(scope, limit, seen)
|
|
127
|
+
await self._refuse(scope, receive, send, limit)
|
|
128
|
+
|
|
129
|
+
@staticmethod
|
|
130
|
+
def _wants_json(scope: Scope) -> bool:
|
|
131
|
+
return _wants_json(Request(scope))
|
|
132
|
+
|
|
133
|
+
async def _handle_page(
|
|
134
|
+
self, scope: Scope, receive: Receive, send: Send, limit: int, too_big: bool
|
|
135
|
+
) -> None:
|
|
136
|
+
seen = 0
|
|
137
|
+
|
|
138
|
+
async def receive_or_413() -> Message:
|
|
139
|
+
nonlocal seen
|
|
140
|
+
if too_big:
|
|
141
|
+
self._log(scope, limit, seen)
|
|
142
|
+
raise HTTPException(status_code=413, detail=_MESSAGE)
|
|
143
|
+
message = await receive()
|
|
144
|
+
if message["type"] == "http.request":
|
|
145
|
+
seen += len(message.get("body", b""))
|
|
146
|
+
if seen > limit:
|
|
147
|
+
self._log(scope, limit, seen)
|
|
148
|
+
raise HTTPException(status_code=413, detail=_MESSAGE)
|
|
149
|
+
return message
|
|
150
|
+
|
|
151
|
+
started = False
|
|
152
|
+
|
|
153
|
+
async def tracking_send(message: Message) -> None:
|
|
154
|
+
nonlocal started
|
|
155
|
+
if message["type"] == "http.response.start":
|
|
156
|
+
started = True
|
|
157
|
+
await send(message)
|
|
158
|
+
|
|
159
|
+
try:
|
|
160
|
+
await self.app(scope, receive_or_413, tracking_send)
|
|
161
|
+
except HTTPException as exc:
|
|
162
|
+
# Raised by a middleware that reads the body *outside* the router's
|
|
163
|
+
# exception handling (e.g. a form gate). Without this it would
|
|
164
|
+
# surface as a bare 500 from ServerErrorMiddleware.
|
|
165
|
+
if exc.status_code != 413 or started:
|
|
166
|
+
raise
|
|
167
|
+
await self._refuse(scope, receive, send, limit)
|
|
168
|
+
|
|
169
|
+
@staticmethod
|
|
170
|
+
def _log(scope: Scope, limit: int, size: int) -> None:
|
|
171
|
+
cid = scope.get("state", {}).get("correlation_id", "")
|
|
172
|
+
logger.warning(
|
|
173
|
+
"Request body over limit: %s %s (%d > %d bytes) correlation_id=%s",
|
|
174
|
+
scope["method"],
|
|
175
|
+
scope["path"],
|
|
176
|
+
size,
|
|
177
|
+
limit,
|
|
178
|
+
cid,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
@staticmethod
|
|
182
|
+
async def _refuse(scope: Scope, receive: Receive, send: Send, limit: int) -> None:
|
|
183
|
+
await send_guard_response(
|
|
184
|
+
scope,
|
|
185
|
+
receive,
|
|
186
|
+
send,
|
|
187
|
+
413,
|
|
188
|
+
_MESSAGE,
|
|
189
|
+
headers={"Connection": "close"},
|
|
190
|
+
extra={"max_bytes": limit},
|
|
191
|
+
)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Diagnostics that need the built app, so cannot run from ``make doctor``.
|
|
2
|
+
|
|
3
|
+
The module diagnostics in Phase 2 see only module classes and the source tree.
|
|
4
|
+
A check about what modules *registered* has to wait until after Phase 5, and
|
|
5
|
+
unlike the source-tree checks it is meaningful in production too.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from typing import TYPE_CHECKING, Any
|
|
12
|
+
|
|
13
|
+
from simple_module_core.diagnostics import Diagnostic, check_tenant_resolver, print_diagnostics
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from fastapi import FastAPI
|
|
17
|
+
from simple_module_core.services import DiagnosticsState
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _tenant_checks(app: FastAPI, settings: Any) -> list[Diagnostic]:
|
|
23
|
+
return check_tenant_resolver(
|
|
24
|
+
multi_tenant=bool(getattr(settings, "multi_tenant", False)),
|
|
25
|
+
resolver=getattr(app.state, "tenant_resolver", None),
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def report_tenant_resolution(
|
|
30
|
+
app: FastAPI, settings: Any, diagnostics_state: DiagnosticsState
|
|
31
|
+
) -> list[Diagnostic]:
|
|
32
|
+
"""SM025: report, and fold into the Doctor screen's results when it has a runner."""
|
|
33
|
+
found = _tenant_checks(app, settings)
|
|
34
|
+
if found:
|
|
35
|
+
if settings.is_development:
|
|
36
|
+
print_diagnostics(found)
|
|
37
|
+
else:
|
|
38
|
+
for diag in found:
|
|
39
|
+
logger.warning("%s", diag)
|
|
40
|
+
runner = diagnostics_state.runner
|
|
41
|
+
if runner is not None:
|
|
42
|
+
diagnostics_state.runner = lambda: [*runner(), *_tenant_checks(app, settings)]
|
|
43
|
+
diagnostics_state.results = [*diagnostics_state.results, *found]
|
|
44
|
+
return found
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
__all__ = ["report_tenant_resolution"]
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Per-response evaluation of module CSP providers (#434).
|
|
2
|
+
|
|
3
|
+
``register_csp_sources`` runs before DB settings hydrate, so origins that come
|
|
4
|
+
from a setting are declared as providers (``registry.add_provider(fn)``) and
|
|
5
|
+
evaluated here on each response. The finished policy is cached by the *value*
|
|
6
|
+
the providers return, so a settings edit changes the key on the next response
|
|
7
|
+
without any invalidation wiring.
|
|
8
|
+
|
|
9
|
+
Fail closed: a provider that raises, or a pair that fails the same validation
|
|
10
|
+
``CspSourceRegistry.add`` applies, contributes nothing — the static policy
|
|
11
|
+
still ships. Each distinct failure is logged once per process.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from simple_module_core.csp import CspSourceRegistry, is_valid_source
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
_CACHE_MAX = 32
|
|
24
|
+
_LOGGED_MAX = 256
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class DynamicCsp:
|
|
28
|
+
"""Builds the CSP for one response from a static base plus provider output."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, base: str, registry: CspSourceRegistry) -> None:
|
|
31
|
+
self.base = base
|
|
32
|
+
self._registry = registry
|
|
33
|
+
self._providers = registry.providers
|
|
34
|
+
self._cache: dict[frozenset[tuple[str, str]], str] = {}
|
|
35
|
+
self._logged: set[str] = set()
|
|
36
|
+
|
|
37
|
+
def policy_for(self, app: Any) -> str:
|
|
38
|
+
pairs = frozenset(self._collect(app))
|
|
39
|
+
if not pairs:
|
|
40
|
+
return self.base
|
|
41
|
+
policy = self._cache.get(pairs)
|
|
42
|
+
if policy is None:
|
|
43
|
+
if len(self._cache) >= _CACHE_MAX:
|
|
44
|
+
self._cache.clear()
|
|
45
|
+
# Sorted so the clause order is stable for a given set of pairs.
|
|
46
|
+
try:
|
|
47
|
+
policy = self._registry.extend_policy(self.base, extra=sorted(pairs))
|
|
48
|
+
except Exception as exc:
|
|
49
|
+
# Last line of the fail-closed guarantee: this runs inside the
|
|
50
|
+
# response for every page, so a bug here must cost the extra
|
|
51
|
+
# origins, never the response.
|
|
52
|
+
self._warn_once(
|
|
53
|
+
f"build:{type(exc).__name__}:{exc}",
|
|
54
|
+
"CSP provider policy build failed (%r); serving the static policy",
|
|
55
|
+
exc,
|
|
56
|
+
)
|
|
57
|
+
return self.base
|
|
58
|
+
self._cache[pairs] = policy
|
|
59
|
+
return policy
|
|
60
|
+
|
|
61
|
+
def _collect(self, app: Any) -> list[tuple[str, str]]:
|
|
62
|
+
out: list[tuple[str, str]] = []
|
|
63
|
+
for provider in self._providers:
|
|
64
|
+
name = getattr(provider, "__qualname__", repr(provider))
|
|
65
|
+
try:
|
|
66
|
+
pairs = list(provider(app))
|
|
67
|
+
except Exception as exc:
|
|
68
|
+
self._warn_once(
|
|
69
|
+
f"{name}:{type(exc).__name__}:{exc}",
|
|
70
|
+
"CSP provider %s raised %r; its sources are skipped",
|
|
71
|
+
name,
|
|
72
|
+
exc,
|
|
73
|
+
)
|
|
74
|
+
continue
|
|
75
|
+
for pair in pairs:
|
|
76
|
+
if (
|
|
77
|
+
isinstance(pair, tuple)
|
|
78
|
+
and len(pair) == 2
|
|
79
|
+
and isinstance(pair[0], str)
|
|
80
|
+
and isinstance(pair[1], str)
|
|
81
|
+
and is_valid_source(pair[0], pair[1])
|
|
82
|
+
):
|
|
83
|
+
out.append((pair[0], pair[1].strip()))
|
|
84
|
+
else:
|
|
85
|
+
self._warn_once(
|
|
86
|
+
f"{name}:{pair!r}",
|
|
87
|
+
"CSP provider %s returned invalid pair %r; dropped",
|
|
88
|
+
name,
|
|
89
|
+
pair,
|
|
90
|
+
)
|
|
91
|
+
return out
|
|
92
|
+
|
|
93
|
+
def _warn_once(self, key: str, msg: str, *args: object) -> None:
|
|
94
|
+
if key in self._logged:
|
|
95
|
+
return
|
|
96
|
+
if len(self._logged) >= _LOGGED_MAX:
|
|
97
|
+
self._logged.clear()
|
|
98
|
+
self._logged.add(key)
|
|
99
|
+
logger.warning(msg, *args)
|
{simple_module_hosting-0.0.35 → simple_module_hosting-0.0.36}/simple_module_hosting/_dev_boot.py
RENAMED
|
@@ -35,6 +35,7 @@ def run_dev_boot(
|
|
|
35
35
|
installed_modules: list[ModuleBase],
|
|
36
36
|
*,
|
|
37
37
|
i18n_extra,
|
|
38
|
+
i18n_unknown_overrides: dict[str, list[str]] | None = None,
|
|
38
39
|
diagnostics_state: DiagnosticsState,
|
|
39
40
|
project_root: Path,
|
|
40
41
|
) -> None:
|
|
@@ -54,6 +55,8 @@ def run_dev_boot(
|
|
|
54
55
|
i18n_supported_locales=settings.i18n_supported_locales,
|
|
55
56
|
i18n_default_locale=settings.i18n_default_locale,
|
|
56
57
|
i18n_extra_sources=i18n_extra,
|
|
58
|
+
i18n_unknown_overrides=i18n_unknown_overrides,
|
|
59
|
+
database_dialect=settings.database_url.split(":", 1)[0].split("+", 1)[0],
|
|
57
60
|
)
|
|
58
61
|
diagnostics = diagnostics_state.rerun()
|
|
59
62
|
errors = [d for d in diagnostics if d.level == DiagnosticLevel.ERROR]
|
|
@@ -21,11 +21,12 @@ from starlette.requests import Request
|
|
|
21
21
|
from starlette.responses import Response
|
|
22
22
|
|
|
23
23
|
from simple_module_hosting._inertia_shared import _INERTIA_HEADER
|
|
24
|
+
from simple_module_hosting._observability import CorrelationIdMiddleware
|
|
24
25
|
from simple_module_hosting.permissions import PERMISSION_DENIED_PREFIX
|
|
25
26
|
|
|
26
27
|
logger = logging.getLogger(__name__)
|
|
27
28
|
|
|
28
|
-
_INERTIA_ERROR_STATUSES = frozenset({401, 403, 404, 419, 422, 429, 500, 503})
|
|
29
|
+
_INERTIA_ERROR_STATUSES = frozenset({401, 403, 404, 413, 419, 422, 429, 500, 503})
|
|
29
30
|
|
|
30
31
|
# Statuses whose remedy is "sign in", so the page offers that as its primary
|
|
31
32
|
# action rather than sending the visitor to the landing page.
|
|
@@ -278,5 +279,16 @@ async def request_validation_error_handler(
|
|
|
278
279
|
async def unhandled_exception_handler(request: Request, exc: Exception) -> Response:
|
|
279
280
|
logger.exception("Unhandled exception: %s", exc)
|
|
280
281
|
if _wants_json(request):
|
|
281
|
-
|
|
282
|
-
|
|
282
|
+
response: Response = JSONResponse(
|
|
283
|
+
status_code=500, content={"detail": "Internal Server Error"}
|
|
284
|
+
)
|
|
285
|
+
else:
|
|
286
|
+
response = await render_error_page(request, 500, "")
|
|
287
|
+
# A catch-all `Exception` handler runs in Starlette's ServerErrorMiddleware,
|
|
288
|
+
# which wraps *outside* CorrelationIdMiddleware, so the response never
|
|
289
|
+
# passes through it. Without this the 500 — the one response a caller most
|
|
290
|
+
# needs to quote back to support — is the one without the header.
|
|
291
|
+
cid = getattr(request.state, "correlation_id", "")
|
|
292
|
+
if cid:
|
|
293
|
+
response.headers.setdefault(CorrelationIdMiddleware.HEADER, cid)
|
|
294
|
+
return response
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Construct the i18n registry from module, host and UI sources."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from simple_module_core import ModuleBase
|
|
8
|
+
from simple_module_core.i18n import I18nRegistry
|
|
9
|
+
|
|
10
|
+
from simple_module_hosting.settings import Settings
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def build_i18n_registry(
|
|
14
|
+
settings: Settings,
|
|
15
|
+
modules: list[ModuleBase],
|
|
16
|
+
project_root: Path,
|
|
17
|
+
) -> tuple[I18nRegistry, list[tuple[str, str, Path]]]:
|
|
18
|
+
"""Construct the i18n registry from module + host + UI sources.
|
|
19
|
+
|
|
20
|
+
Returns ``(registry, extra_sources)`` where ``extra_sources`` is the list
|
|
21
|
+
of ``(reporter_name, namespace, dir)`` triples the diagnostic runner
|
|
22
|
+
needs to validate non-module locale directories.
|
|
23
|
+
"""
|
|
24
|
+
registry = I18nRegistry(
|
|
25
|
+
default_locale=settings.i18n_default_locale,
|
|
26
|
+
supported_locales=settings.i18n_supported_locales,
|
|
27
|
+
)
|
|
28
|
+
extra_sources: list[tuple[str, str, Path]] = []
|
|
29
|
+
|
|
30
|
+
for mod in modules:
|
|
31
|
+
audience = getattr(mod.meta, "i18n_audience", "public")
|
|
32
|
+
for namespace, locale_dir in mod.locale_dirs().items():
|
|
33
|
+
registry.add_source(namespace, locale_dir, audience=audience)
|
|
34
|
+
|
|
35
|
+
# The framework's own catalog — the setup wizard ships with this package,
|
|
36
|
+
# so its strings must too, whatever host is serving it.
|
|
37
|
+
hosting_locales = Path(__file__).resolve().parent / "locales"
|
|
38
|
+
registry.add_source("hosting", hosting_locales)
|
|
39
|
+
extra_sources.append(("simple_module_hosting", "hosting", hosting_locales))
|
|
40
|
+
|
|
41
|
+
host_locales = project_root / "host" / "locales"
|
|
42
|
+
if host_locales.is_dir():
|
|
43
|
+
registry.add_source("host", host_locales)
|
|
44
|
+
extra_sources.append(("host", "host", host_locales))
|
|
45
|
+
|
|
46
|
+
overrides = host_locales / "overrides"
|
|
47
|
+
if overrides.is_dir():
|
|
48
|
+
registry.add_overrides(overrides)
|
|
49
|
+
|
|
50
|
+
# Shared UI strings ship inside this package (not ``packages/ui``) so a
|
|
51
|
+
# scaffolded app, which has no ``packages/ui`` checkout, still resolves
|
|
52
|
+
# every ``ui.*`` key.
|
|
53
|
+
ui_locales = Path(__file__).resolve().parent / "ui_locales"
|
|
54
|
+
registry.add_source("ui", ui_locales)
|
|
55
|
+
extra_sources.append(("simple_module_hosting/ui", "ui", ui_locales))
|
|
56
|
+
|
|
57
|
+
registry.load()
|
|
58
|
+
return registry, extra_sources
|
|
@@ -48,7 +48,11 @@ def branding_head(request: Request) -> dict:
|
|
|
48
48
|
rather than ``None``, because omitting the tag sends the browser to
|
|
49
49
|
``/favicon.ico``, which this app does not serve.
|
|
50
50
|
"""
|
|
51
|
-
|
|
51
|
+
# A per-request object (same duck shape) wins: branding resolves a tenant's
|
|
52
|
+
# own theme into ``request.state.branding`` from its shared-props provider.
|
|
53
|
+
services = getattr(getattr(request, "state", None), "branding", None) or getattr(
|
|
54
|
+
request.app.state, "branding", None
|
|
55
|
+
)
|
|
52
56
|
settings = getattr(services, "settings", None)
|
|
53
57
|
app_name = getattr(settings, "app_name", "") or _DEFAULT_APP_NAME
|
|
54
58
|
accent = getattr(settings, "primary_color", "") or ""
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import inspect
|
|
5
6
|
import logging
|
|
6
7
|
from collections.abc import Callable
|
|
7
8
|
from typing import Any
|
|
@@ -19,6 +20,10 @@ _INERTIA_HEADER = "x-inertia"
|
|
|
19
20
|
_INERTIA_HEADER_TRUE = "true"
|
|
20
21
|
|
|
21
22
|
|
|
23
|
+
def _accepts_html(scope: Scope) -> bool:
|
|
24
|
+
return "text/html" in Headers(scope=scope).get("accept", "").lower()
|
|
25
|
+
|
|
26
|
+
|
|
22
27
|
def build_i18n_block(scope: Scope, request: Request, *, is_authenticated: bool = True) -> dict:
|
|
23
28
|
"""Assemble the ``i18n`` shared-props block for the current request.
|
|
24
29
|
|
|
@@ -56,13 +61,21 @@ def build_i18n_block(scope: Scope, request: Request, *, is_authenticated: bool =
|
|
|
56
61
|
# skip the messages payload. Non-Inertia requests will always ship them
|
|
57
62
|
# regardless of the session state.
|
|
58
63
|
if session_dict is not None:
|
|
64
|
+
# Remembering the locale only pays off for a client that will come back
|
|
65
|
+
# with an Inertia XHR (to skip re-sending the catalog). A bare API or
|
|
66
|
+
# health-check call from a cookie-less client will not, and writing
|
|
67
|
+
# here would make Starlette's SessionMiddleware emit a two-week
|
|
68
|
+
# Set-Cookie on every anonymous response, which keeps shared caches
|
|
69
|
+
# from storing it (GH #349). So persist only when a session already
|
|
70
|
+
# exists (it has data) or the caller is page-shaped.
|
|
71
|
+
persist = bool(session_dict) or is_inertia or _accepts_html(scope)
|
|
59
72
|
last_locale = session_dict.get(_I18N_SESSION_LOCALE_KEY)
|
|
60
73
|
locale_changed = last_locale != locale
|
|
61
|
-
if locale_changed:
|
|
74
|
+
if locale_changed and persist:
|
|
62
75
|
session_dict[_I18N_SESSION_LOCALE_KEY] = locale
|
|
63
76
|
last_audience = session_dict.get(_I18N_SESSION_AUDIENCE_KEY)
|
|
64
77
|
audience_changed = last_audience != audience
|
|
65
|
-
if audience_changed:
|
|
78
|
+
if audience_changed and persist:
|
|
66
79
|
session_dict[_I18N_SESSION_AUDIENCE_KEY] = audience
|
|
67
80
|
else:
|
|
68
81
|
locale_changed = False
|
|
@@ -103,19 +116,22 @@ def build_menu_translator(request: Request) -> Callable[[str], str] | None:
|
|
|
103
116
|
return translator.t
|
|
104
117
|
|
|
105
118
|
|
|
106
|
-
def merge_shared_prop_providers(app: Any, request: Request, shared: dict) -> None:
|
|
119
|
+
async def merge_shared_prop_providers(app: Any, request: Request, shared: dict) -> None:
|
|
107
120
|
"""Merge module-registered Inertia shared-prop providers into ``shared`` in place.
|
|
108
121
|
|
|
109
122
|
Providers are read off ``app.state.inertia_shared_providers`` (never importing
|
|
110
123
|
the plugin — preserves SM009). A provider that raises is skipped and logged; a
|
|
111
124
|
provider may not clobber a framework-owned key (auth/menus/i18n) or an earlier
|
|
112
|
-
provider's key.
|
|
125
|
+
provider's key. A provider may be ``async`` (its result is awaited) — a
|
|
126
|
+
per-tenant lookup cannot be answered from a process-wide object.
|
|
113
127
|
"""
|
|
114
128
|
providers = getattr(app.state, "inertia_shared_providers", None) or ()
|
|
115
129
|
for provider in providers:
|
|
116
130
|
name = getattr(provider, "__name__", provider)
|
|
117
131
|
try:
|
|
118
132
|
extra = provider(request)
|
|
133
|
+
if inspect.isawaitable(extra):
|
|
134
|
+
extra = await extra
|
|
119
135
|
except Exception: # a bad provider must not break the page render
|
|
120
136
|
logger.warning("shared-prop provider %r raised; skipping", name, exc_info=True)
|
|
121
137
|
continue
|
|
@@ -128,3 +144,16 @@ def merge_shared_prop_providers(app: Any, request: Request, shared: dict) -> Non
|
|
|
128
144
|
)
|
|
129
145
|
continue
|
|
130
146
|
shared[key] = value
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def default_home_url(app: Any) -> str:
|
|
150
|
+
"""Where the layouts' "home" links point when no module publishes ``homeUrl``.
|
|
151
|
+
|
|
152
|
+
The users module publishes its configured landing URL and wins; this covers
|
|
153
|
+
installs without it (Keycloak). Dashboard is the conventional landing page,
|
|
154
|
+
named by string from ``sm.view_prefixes`` so nothing is imported (SM009);
|
|
155
|
+
``/`` is a host route in every app, so the link can never 404.
|
|
156
|
+
"""
|
|
157
|
+
sm = getattr(app.state, "sm", None)
|
|
158
|
+
prefix = getattr(sm, "view_prefixes", {}).get("Dashboard")
|
|
159
|
+
return f"{prefix}/" if prefix else "/"
|