simple-module-hosting 0.0.32__tar.gz → 0.0.34__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.32 → simple_module_hosting-0.0.34}/PKG-INFO +3 -3
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/pyproject.toml +3 -3
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/simple_module_hosting/__init__.py +2 -0
- simple_module_hosting-0.0.34/simple_module_hosting/_error_handlers.py +286 -0
- simple_module_hosting-0.0.34/simple_module_hosting/_favicon.py +94 -0
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/simple_module_hosting/_inertia_setup.py +24 -10
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/simple_module_hosting/_inertia_shared.py +26 -0
- simple_module_hosting-0.0.34/simple_module_hosting/_inertia_url.py +131 -0
- simple_module_hosting-0.0.34/simple_module_hosting/_lifespan.py +130 -0
- simple_module_hosting-0.0.34/simple_module_hosting/_module_routes.py +70 -0
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/simple_module_hosting/_phase_helpers.py +38 -38
- simple_module_hosting-0.0.34/simple_module_hosting/_preapp_config.py +227 -0
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/simple_module_hosting/_registrations.py +4 -0
- simple_module_hosting-0.0.34/simple_module_hosting/_secret_key.py +154 -0
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/simple_module_hosting/app_builder.py +32 -34
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/simple_module_hosting/bootstrap_settings.py +33 -70
- simple_module_hosting-0.0.34/simple_module_hosting/host_settings.py +133 -0
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/simple_module_hosting/i18n_manifest.py +37 -4
- simple_module_hosting-0.0.34/simple_module_hosting/maintenance.py +125 -0
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/simple_module_hosting/middleware.py +6 -1
- simple_module_hosting-0.0.34/simple_module_hosting/migrations.py +217 -0
- simple_module_hosting-0.0.34/simple_module_hosting/permissions.py +104 -0
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/simple_module_hosting/redirects.py +12 -10
- simple_module_hosting-0.0.34/simple_module_hosting/session.py +217 -0
- simple_module_hosting-0.0.34/simple_module_hosting/setup_gate.py +187 -0
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_auth_provider_setting.py +25 -0
- simple_module_hosting-0.0.34/tests/test_boot_drift_check.py +99 -0
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_branding_head.py +32 -6
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_check_migrations.py +22 -10
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_csp_module_sources.py +1 -0
- simple_module_hosting-0.0.34/tests/test_diagnostics_state.py +112 -0
- simple_module_hosting-0.0.34/tests/test_error_page_shared_props.py +216 -0
- simple_module_hosting-0.0.34/tests/test_error_status_coverage.py +229 -0
- simple_module_hosting-0.0.34/tests/test_favicon_default.py +72 -0
- simple_module_hosting-0.0.34/tests/test_host_settings_fields.py +96 -0
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_i18n_manifest.py +52 -1
- simple_module_hosting-0.0.34/tests/test_i18n_types_cover_installed_modules.py +66 -0
- simple_module_hosting-0.0.34/tests/test_inertia_relative_url.py +120 -0
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_lifespan_order.py +3 -1
- simple_module_hosting-0.0.34/tests/test_maintenance_mode.py +270 -0
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_middleware_order.py +23 -1
- simple_module_hosting-0.0.34/tests/test_migration_listing.py +106 -0
- simple_module_hosting-0.0.34/tests/test_migration_multihead.py +154 -0
- simple_module_hosting-0.0.34/tests/test_preapp_config.py +144 -0
- simple_module_hosting-0.0.34/tests/test_proxy_headers.py +224 -0
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_redirects.py +29 -0
- simple_module_hosting-0.0.34/tests/test_secret_key_bootstrap.py +93 -0
- simple_module_hosting-0.0.34/tests/test_session_cookie_window.py +252 -0
- simple_module_hosting-0.0.34/tests/test_setup_gate.py +230 -0
- simple_module_hosting-0.0.34/tests/test_setup_password_policy.py +69 -0
- simple_module_hosting-0.0.34/tests/test_setup_public_routes.py +68 -0
- simple_module_hosting-0.0.34/tests/test_setup_registry.py +102 -0
- simple_module_hosting-0.0.34/tests/test_setup_routes.py +137 -0
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_strict_discovery_wiring.py +4 -3
- simple_module_hosting-0.0.32/simple_module_hosting/_error_handlers.py +0 -159
- simple_module_hosting-0.0.32/simple_module_hosting/host_settings.py +0 -33
- simple_module_hosting-0.0.32/simple_module_hosting/migrations.py +0 -65
- simple_module_hosting-0.0.32/simple_module_hosting/permissions.py +0 -75
- simple_module_hosting-0.0.32/tests/test_error_page_shared_props.py +0 -80
- simple_module_hosting-0.0.32/tests/test_proxy_headers.py +0 -119
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/.gitignore +0 -0
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/LICENSE +0 -0
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/README.md +0 -0
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/simple_module_hosting/__main__.py +0 -0
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/simple_module_hosting/_db_health.py +0 -0
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/simple_module_hosting/_host_services.py +0 -0
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/simple_module_hosting/_hydrate_step.py +0 -0
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/simple_module_hosting/_inertia_cache.py +0 -0
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/simple_module_hosting/_inertia_json.py +0 -0
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/simple_module_hosting/_observability.py +0 -0
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/simple_module_hosting/assets.py +0 -0
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/simple_module_hosting/csrf.py +0 -0
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/simple_module_hosting/health.py +0 -0
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/simple_module_hosting/host_cli.py +0 -0
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/simple_module_hosting/i18n_deps.py +0 -0
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/simple_module_hosting/i18n_middleware.py +0 -0
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/simple_module_hosting/inertia_deps.py +0 -0
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/simple_module_hosting/inertia_utils.py +0 -0
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/simple_module_hosting/logging.py +0 -0
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/simple_module_hosting/manifest.py +0 -0
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/simple_module_hosting/py.typed +0 -0
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/simple_module_hosting/settings.py +0 -0
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/simple_module_hosting/shared_props.py +0 -0
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/simple_module_hosting/static_files.py +0 -0
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_api_error_negotiation.py +0 -0
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_app.py +0 -0
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_db_health_check.py +0 -0
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_design_packs_wiring.py +0 -0
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_health.py +0 -0
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_host_cli.py +0 -0
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_hosting_csrf.py +0 -0
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_hosting_permissions.py +0 -0
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_inertia_cache.py +0 -0
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_inertia_i18n_shared_props.py +0 -0
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_inertia_json_encoder.py +0 -0
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_inertia_manifest.py +0 -0
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_inertia_shared_providers.py +0 -0
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_locale_middleware.py +0 -0
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_logging.py +0 -0
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_manifest.py +0 -0
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_menu_urls_are_canonical.py +0 -0
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_precompressed_static.py +0 -0
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_response_compression.py +0 -0
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_session_cookie_security.py +0 -0
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_settings_i18n.py +0 -0
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_settings_secrets.py +0 -0
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_sqlite_path_anchor.py +0 -0
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_static_caching.py +0 -0
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/tests/test_tenant_middleware.py +0 -0
- {simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/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.34
|
|
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
|
|
@@ -27,8 +27,8 @@ Requires-Dist: fastapi>=0.115
|
|
|
27
27
|
Requires-Dist: httpx>=0.27
|
|
28
28
|
Requires-Dist: itsdangerous>=2.0
|
|
29
29
|
Requires-Dist: jinja2>=3.1
|
|
30
|
-
Requires-Dist: simple-module-core==0.0.
|
|
31
|
-
Requires-Dist: simple-module-db==0.0.
|
|
30
|
+
Requires-Dist: simple-module-core==0.0.34
|
|
31
|
+
Requires-Dist: simple-module-db==0.0.34
|
|
32
32
|
Requires-Dist: starlette>=0.44
|
|
33
33
|
Requires-Dist: tomlkit>=0.13
|
|
34
34
|
Requires-Dist: uvicorn[standard]>=0.34
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "simple_module_hosting"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.34"
|
|
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"
|
|
@@ -30,8 +30,8 @@ dependencies = [
|
|
|
30
30
|
# it directly — out-of-tree hosts have nothing else pulling it in.
|
|
31
31
|
"itsdangerous>=2.0",
|
|
32
32
|
"jinja2>=3.1",
|
|
33
|
-
"simple_module_core==0.0.
|
|
34
|
-
"simple_module_db==0.0.
|
|
33
|
+
"simple_module_core==0.0.34",
|
|
34
|
+
"simple_module_db==0.0.34",
|
|
35
35
|
"starlette>=0.44",
|
|
36
36
|
"tomlkit>=0.13",
|
|
37
37
|
"uvicorn[standard]>=0.34",
|
{simple_module_hosting-0.0.32 → simple_module_hosting-0.0.34}/simple_module_hosting/__init__.py
RENAMED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""SimpleModule Hosting - App builder, module loader, middleware pipeline."""
|
|
2
2
|
|
|
3
|
+
from simple_module_hosting._preapp_config import merge_host_settings
|
|
3
4
|
from simple_module_hosting.app_builder import create_app
|
|
4
5
|
from simple_module_hosting.logging import correlation_id, setup_logging
|
|
5
6
|
from simple_module_hosting.settings import Settings
|
|
@@ -13,6 +14,7 @@ __all__ = [
|
|
|
13
14
|
"SharedPropsProvider",
|
|
14
15
|
"correlation_id",
|
|
15
16
|
"create_app",
|
|
17
|
+
"merge_host_settings",
|
|
16
18
|
"register_inertia_shared_provider",
|
|
17
19
|
"setup_logging",
|
|
18
20
|
]
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""Framework-wide exception handlers that render Inertia error pages."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from http import HTTPStatus
|
|
8
|
+
|
|
9
|
+
from fastapi.encoders import jsonable_encoder
|
|
10
|
+
from fastapi.exceptions import RequestValidationError
|
|
11
|
+
from fastapi.responses import JSONResponse
|
|
12
|
+
from inertia import (
|
|
13
|
+
Inertia,
|
|
14
|
+
InertiaConfig,
|
|
15
|
+
InertiaVersionConflictException,
|
|
16
|
+
inertia_version_conflict_exception_handler,
|
|
17
|
+
)
|
|
18
|
+
from simple_module_core.exceptions import NotFoundError
|
|
19
|
+
from starlette.exceptions import HTTPException
|
|
20
|
+
from starlette.requests import Request
|
|
21
|
+
from starlette.responses import Response
|
|
22
|
+
|
|
23
|
+
from simple_module_hosting._inertia_shared import _INERTIA_HEADER
|
|
24
|
+
from simple_module_hosting._inertia_url import patch_relative_page_url
|
|
25
|
+
from simple_module_hosting.permissions import PERMISSION_DENIED_PREFIX
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
_INERTIA_ERROR_STATUSES = frozenset({401, 403, 404, 419, 422, 429, 500, 503})
|
|
30
|
+
|
|
31
|
+
# Statuses whose remedy is "sign in", so the page offers that as its primary
|
|
32
|
+
# action rather than sending the visitor to the landing page.
|
|
33
|
+
_SIGN_IN_STATUSES = frozenset({401, 419})
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _specific_detail(status_code: int, message: str) -> str:
|
|
37
|
+
"""The detail, unless it only restates the status.
|
|
38
|
+
|
|
39
|
+
Starlette defaults ``HTTPException.detail`` to ``HTTPStatus(code).phrase``,
|
|
40
|
+
so a plain ``HTTPException(404)`` reaches the page carrying "Not Found" and
|
|
41
|
+
a bare ``HTTPException(403)`` carrying "Forbidden". The page prefers a
|
|
42
|
+
server message over its own catalog description — right when the message
|
|
43
|
+
says something ("Administrator access required"), useless when it is the
|
|
44
|
+
title again in Title Case, and it made the page's own 404/403 sentences
|
|
45
|
+
unreachable on the very paths that produce them.
|
|
46
|
+
|
|
47
|
+
Only an exact, case-insensitive match is dropped: anything a caller
|
|
48
|
+
actually wrote survives, including a sentence that merely starts with the
|
|
49
|
+
phrase. Non-standard codes (419) have no phrase to collide with.
|
|
50
|
+
"""
|
|
51
|
+
try:
|
|
52
|
+
phrase = HTTPStatus(status_code).phrase
|
|
53
|
+
except ValueError:
|
|
54
|
+
return message
|
|
55
|
+
return "" if message.strip().casefold() == phrase.casefold() else message
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _required_permission(message: str) -> str | None:
|
|
59
|
+
"""The permission named by a ``RequiresPermission`` denial, if this is one.
|
|
60
|
+
|
|
61
|
+
The 403 page says "Your role doesn't include ``<perm>``. Ask an admin to
|
|
62
|
+
grant it." — it needs the bare permission name, not the sentence the guard
|
|
63
|
+
wrote for logs and API clients. Reading it back off the detail (using the
|
|
64
|
+
guard's own exported prefix) keeps ``RequiresPermission`` as the single
|
|
65
|
+
place that knows the name; the alternative, stashing it on
|
|
66
|
+
``request.state``, only works when the exception travels the one code path
|
|
67
|
+
that set it, and 403s are also raised by hand.
|
|
68
|
+
|
|
69
|
+
Anything else — a role-gated ``/admin`` 403, a hand-raised
|
|
70
|
+
``HTTPException(403, "Administrator access required")`` — yields ``None``,
|
|
71
|
+
and the page falls back to copy that names no permission.
|
|
72
|
+
"""
|
|
73
|
+
if not message.startswith(PERMISSION_DENIED_PREFIX):
|
|
74
|
+
return None
|
|
75
|
+
return message[len(PERMISSION_DENIED_PREFIX) :].strip() or None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _login_url(request: Request) -> str | None:
|
|
79
|
+
"""Best-effort login URL for the sign-in statuses.
|
|
80
|
+
|
|
81
|
+
Read off ``app.state`` rather than imported: the auth provider is a plugin
|
|
82
|
+
concern and ``SM009`` forbids framework code importing ``modules/*``. An
|
|
83
|
+
app with no auth provider installed simply gets no sign-in button.
|
|
84
|
+
"""
|
|
85
|
+
auth_state = getattr(request.app.state, "auth", None)
|
|
86
|
+
provider = getattr(auth_state, "auth_provider", None)
|
|
87
|
+
if provider is None:
|
|
88
|
+
return None
|
|
89
|
+
try:
|
|
90
|
+
return provider.get_login_url(request)
|
|
91
|
+
except Exception:
|
|
92
|
+
# A broken provider must not turn an error page into a second error.
|
|
93
|
+
logger.exception("Auth provider failed to supply a login URL")
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _explicit_accept_q(accept: str, media_type: str) -> float | None:
|
|
98
|
+
"""Quality the Accept header gives ``media_type`` by explicit listing, or
|
|
99
|
+
``None`` when the caller never named it at all.
|
|
100
|
+
|
|
101
|
+
Only exact entries count (no ``*/*`` / ``type/*`` wildcards) — the
|
|
102
|
+
negotiation below cares whether the caller *named* html or json, and at
|
|
103
|
+
what preference, and separately whether they named it *at all* (``None``
|
|
104
|
+
vs. an explicit ``q=0``). Malformed q-values fall back to 1.0 per RFC 9110.
|
|
105
|
+
"""
|
|
106
|
+
for part in accept.split(","):
|
|
107
|
+
media, _, params = part.partition(";")
|
|
108
|
+
if media.strip().lower() != media_type:
|
|
109
|
+
continue
|
|
110
|
+
q = 1.0
|
|
111
|
+
for param in params.split(";"):
|
|
112
|
+
name, _, value = param.partition("=")
|
|
113
|
+
if name.strip().lower() == "q":
|
|
114
|
+
try:
|
|
115
|
+
q = float(value.strip())
|
|
116
|
+
except ValueError:
|
|
117
|
+
q = 1.0
|
|
118
|
+
return q
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _wants_json(request: Request) -> bool:
|
|
123
|
+
"""API callers get JSON error bodies; browser-shaped requests get the page.
|
|
124
|
+
|
|
125
|
+
A request that explicitly accepts ``text/html`` is a browser navigation —
|
|
126
|
+
and those reach ``/api/*`` too (OAuth login links, file-download hrefs) —
|
|
127
|
+
so it always gets the rendered page, unless the caller *prefers* JSON via
|
|
128
|
+
q-values (``application/json, text/html;q=0.5`` is an API client keeping
|
|
129
|
+
an html fallback). So does an Inertia visit (``X-Inertia`` header),
|
|
130
|
+
whatever its Accept — an Inertia client can only consume Inertia-protocol
|
|
131
|
+
responses, never a bare JSON body. Otherwise ``/api/*``, the documented
|
|
132
|
+
prefix for every module's JSON surface (``ModuleMeta.route_prefix``),
|
|
133
|
+
gets JSON — a bare ``fetch()`` sends ``Accept: */*`` — as does an
|
|
134
|
+
explicit ``Accept: application/json`` anywhere else.
|
|
135
|
+
|
|
136
|
+
``text/html;q=0`` rules html out *everywhere*, not just under ``/api/*``:
|
|
137
|
+
once the caller has explicitly said they won't accept html, this never
|
|
138
|
+
chooses the html page. The (already-moot) html fallback survives only
|
|
139
|
+
when json wasn't mentioned either — there's nothing else to offer, so a
|
|
140
|
+
view path keeps its pre-existing default and an ``/api/*`` path keeps
|
|
141
|
+
getting JSON either way.
|
|
142
|
+
"""
|
|
143
|
+
if request.headers.get(_INERTIA_HEADER):
|
|
144
|
+
return False
|
|
145
|
+
accept = request.headers.get("accept", "")
|
|
146
|
+
html_q = _explicit_accept_q(accept, "text/html")
|
|
147
|
+
json_q = _explicit_accept_q(accept, "application/json")
|
|
148
|
+
if html_q is not None and html_q > 0 and html_q >= (json_q or 0.0):
|
|
149
|
+
return False
|
|
150
|
+
path = request.url.path
|
|
151
|
+
is_api = path == "/api" or path.startswith("/api/")
|
|
152
|
+
if html_q == 0:
|
|
153
|
+
return is_api if json_q is None else True
|
|
154
|
+
return is_api or bool(json_q)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
async def render_error_page(
|
|
158
|
+
request: Request,
|
|
159
|
+
status_code: int,
|
|
160
|
+
message: str,
|
|
161
|
+
headers: Mapping[str, str] | None = None,
|
|
162
|
+
) -> Response:
|
|
163
|
+
"""Render the Inertia error page for *status_code*.
|
|
164
|
+
|
|
165
|
+
``headers`` carries an ``HTTPException``'s own headers through to the
|
|
166
|
+
response. A rendered page is still the same status as the JSON body it
|
|
167
|
+
replaces, so ``WWW-Authenticate`` on a 401 and ``Retry-After`` on a
|
|
168
|
+
429/503 have to survive the switch — widening the set of statuses that
|
|
169
|
+
render a page must not quietly narrow what those responses carry.
|
|
170
|
+
"""
|
|
171
|
+
try:
|
|
172
|
+
# Inside the try, not above it: this lookup is exactly the kind of
|
|
173
|
+
# thing that is missing when the app is half-built, and an error page
|
|
174
|
+
# that raises while reporting an error leaves the caller with nothing.
|
|
175
|
+
config: InertiaConfig = request.app.state.sm.inertia_config
|
|
176
|
+
# Prefer the app's configured dependency over constructing Inertia
|
|
177
|
+
# directly: it carries the framework's wraps, and an error page built
|
|
178
|
+
# around them is an error page rendered differently from every other
|
|
179
|
+
# page. That is how the 404 kept throwing the cross-scheme `pushState`
|
|
180
|
+
# SecurityError after the page url was made relative everywhere else.
|
|
181
|
+
# The raw construction stays as the fallback for a half-built app —
|
|
182
|
+
# the case this whole handler exists to survive — but still goes
|
|
183
|
+
# through the same url-relativizing patch directly: a fallback that
|
|
184
|
+
# skipped it would reintroduce the exact bug this module exists to
|
|
185
|
+
# fix, just for the one request that hit it before setup finished.
|
|
186
|
+
inertia_dep = getattr(request.app.state, "inertia_dependency", None)
|
|
187
|
+
if inertia_dep is not None:
|
|
188
|
+
inertia = inertia_dep(request, None)
|
|
189
|
+
else:
|
|
190
|
+
inertia = patch_relative_page_url(Inertia(request, config))
|
|
191
|
+
# This does not go through get_inertia, so the share step has to be
|
|
192
|
+
# repeated here. Without it the error page renders raw translation keys
|
|
193
|
+
# (host.error.not_found_title) and loses auth/menus, so a signed-in
|
|
194
|
+
# user's 404 has no layout.
|
|
195
|
+
shared = getattr(request.state, "inertia_shared", None)
|
|
196
|
+
if shared:
|
|
197
|
+
inertia.share(**shared)
|
|
198
|
+
# The correlation id is the only handle a user has on their own failed
|
|
199
|
+
# request — without it a support report is just "it broke". It is already
|
|
200
|
+
# on every log line for this request, so quoting it back makes the page
|
|
201
|
+
# and the logs joinable.
|
|
202
|
+
response = await inertia.render(
|
|
203
|
+
"Error",
|
|
204
|
+
{
|
|
205
|
+
"status": status_code,
|
|
206
|
+
# Not the raw detail: a default one is just the status phrase,
|
|
207
|
+
# and the page renders any message it is given ahead of its own
|
|
208
|
+
# copy. See `_specific_detail`.
|
|
209
|
+
"message": _specific_detail(status_code, message),
|
|
210
|
+
"correlation_id": getattr(request.state, "correlation_id", "") or "",
|
|
211
|
+
# Always present, null when no permission is involved: the page
|
|
212
|
+
# branches on it, and an absent prop is indistinguishable there
|
|
213
|
+
# from one that was never wired.
|
|
214
|
+
"required_permission": _required_permission(message),
|
|
215
|
+
"login_url": (_login_url(request) if status_code in _SIGN_IN_STATUSES else None),
|
|
216
|
+
# Set by MaintenanceMiddleware. A planned outage reads very
|
|
217
|
+
# differently from the same status code arriving unbidden.
|
|
218
|
+
"maintenance": bool(getattr(request.state, "maintenance", False)),
|
|
219
|
+
},
|
|
220
|
+
)
|
|
221
|
+
response.status_code = status_code
|
|
222
|
+
_apply_headers(response, headers)
|
|
223
|
+
return response
|
|
224
|
+
except InertiaVersionConflictException as exc:
|
|
225
|
+
return await inertia_version_conflict_exception_handler(request, exc)
|
|
226
|
+
except Exception:
|
|
227
|
+
# Fallback if Inertia rendering itself fails (e.g. missing session)
|
|
228
|
+
logger.exception("Error page rendering failed, falling back to JSON")
|
|
229
|
+
return JSONResponse(
|
|
230
|
+
status_code=status_code,
|
|
231
|
+
content={"detail": message or "Internal Server Error"},
|
|
232
|
+
headers=dict(headers) if headers else None,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _apply_headers(response: Response, headers: Mapping[str, str] | None) -> None:
|
|
237
|
+
"""Copy exception headers onto an already-built response.
|
|
238
|
+
|
|
239
|
+
Set rather than appended: these are single-valued response headers, and
|
|
240
|
+
a duplicate ``Retry-After`` is worse than none.
|
|
241
|
+
"""
|
|
242
|
+
if not headers:
|
|
243
|
+
return
|
|
244
|
+
for key, value in headers.items():
|
|
245
|
+
response.headers[key] = value
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
async def http_exception_handler(request: Request, exc: HTTPException) -> Response:
|
|
249
|
+
# Preserve exception headers (WWW-Authenticate, Retry-After, ...) the way
|
|
250
|
+
# FastAPI's stock handler does — on the rendered page as well as the JSON
|
|
251
|
+
# body, since both answer with the same status.
|
|
252
|
+
headers = getattr(exc, "headers", None)
|
|
253
|
+
if exc.status_code in _INERTIA_ERROR_STATUSES and not _wants_json(request):
|
|
254
|
+
detail = str(exc.detail) if exc.detail else ""
|
|
255
|
+
return await render_error_page(request, exc.status_code, detail, headers)
|
|
256
|
+
return JSONResponse(
|
|
257
|
+
status_code=exc.status_code,
|
|
258
|
+
content={"detail": exc.detail},
|
|
259
|
+
headers=headers,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
async def not_found_error_handler(request: Request, exc: NotFoundError) -> Response:
|
|
264
|
+
if _wants_json(request):
|
|
265
|
+
return JSONResponse(status_code=404, content={"detail": str(exc)})
|
|
266
|
+
return await render_error_page(request, 404, str(exc))
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
async def request_validation_error_handler(
|
|
270
|
+
request: Request, exc: RequestValidationError
|
|
271
|
+
) -> Response:
|
|
272
|
+
"""Return an Inertia error page for browser requests with invalid params.
|
|
273
|
+
|
|
274
|
+
Same negotiation rule as 403/404/500 (``_wants_json``), so one request
|
|
275
|
+
never sees two different error shapes depending on the error class.
|
|
276
|
+
"""
|
|
277
|
+
if _wants_json(request):
|
|
278
|
+
return JSONResponse(status_code=422, content={"detail": jsonable_encoder(exc.errors())})
|
|
279
|
+
return await render_error_page(request, 422, "The requested URL contains invalid parameters.")
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
async def unhandled_exception_handler(request: Request, exc: Exception) -> Response:
|
|
283
|
+
logger.exception("Unhandled exception: %s", exc)
|
|
284
|
+
if _wants_json(request):
|
|
285
|
+
return JSONResponse(status_code=500, content={"detail": "Internal Server Error"})
|
|
286
|
+
return await render_error_page(request, 500, "")
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""The favicon an install has before anyone uploads one.
|
|
2
|
+
|
|
3
|
+
Without a default, ``index.html`` emitted no ``<link rel="icon">`` at all, so
|
|
4
|
+
every browser fell back to its implicit ``/favicon.ico`` request — which this
|
|
5
|
+
app does not route. Anonymous visitors got a 302 to the login page (an HTML
|
|
6
|
+
document offered as an image), authenticated ones a 404, and either way a
|
|
7
|
+
console error on every full page load, on a brand-new install that has done
|
|
8
|
+
nothing wrong.
|
|
9
|
+
|
|
10
|
+
Rendered as an SVG ``data:`` URI rather than a file so there is nothing to
|
|
11
|
+
mount, nothing to exempt from auth, and nothing to ship in the image: the
|
|
12
|
+
static mount is a build artifact (``host/static/dist`` is generated), and a
|
|
13
|
+
route would need its own public-route rule. ``img-src`` already allows
|
|
14
|
+
``data:``, so the strict CSP is untouched.
|
|
15
|
+
|
|
16
|
+
The mark mirrors ``BrandingMark``'s fallback badge — the app's initial on the
|
|
17
|
+
brand gradient — so the tab icon and the in-app logo are the same thing, for
|
|
18
|
+
whatever the app is named. An uploaded favicon still wins; this is only the
|
|
19
|
+
floor.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from functools import lru_cache
|
|
25
|
+
from urllib.parse import quote
|
|
26
|
+
from xml.sax.saxutils import escape as _xml_escape
|
|
27
|
+
|
|
28
|
+
#: sRGB for `--color-primary-600` / `--color-primary-800`
|
|
29
|
+
#: (`oklch(0.59 0.14 158)` / `oklch(0.42 0.09 175)`), the two stops of
|
|
30
|
+
#: `BRAND_ACCENT`. Hard-coded because a data URI cannot read a CSS custom
|
|
31
|
+
#: property — kept beside the tokens they mirror in `ui/styles/globals.css`.
|
|
32
|
+
_ACCENT_FROM = "#00955c"
|
|
33
|
+
_ACCENT_TO = "#005c4a"
|
|
34
|
+
|
|
35
|
+
_SVG = (
|
|
36
|
+
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">'
|
|
37
|
+
'<defs><linearGradient id="a" x1="0" y1="0" x2="1" y2="1">'
|
|
38
|
+
'<stop offset="0" stop-color="{from_}"/>'
|
|
39
|
+
'<stop offset="1" stop-color="{to}"/>'
|
|
40
|
+
"</linearGradient></defs>"
|
|
41
|
+
'<rect width="64" height="64" rx="14" fill="url(#a)"/>'
|
|
42
|
+
'<text x="32" y="45" text-anchor="middle" fill="#fff" font-size="34" '
|
|
43
|
+
'font-weight="700" font-family="system-ui,-apple-system,Segoe UI,'
|
|
44
|
+
'Roboto,sans-serif">{initial}</text>'
|
|
45
|
+
"</svg>"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@lru_cache(maxsize=64)
|
|
50
|
+
def default_favicon_data_uri(app_name: str, accent: str = "") -> str:
|
|
51
|
+
"""An SVG data URI showing ``app_name``'s initial on the brand gradient.
|
|
52
|
+
|
|
53
|
+
``accent`` is branding's configured primary colour: when set it replaces
|
|
54
|
+
both gradient stops with the flat brand colour, so a customised install's
|
|
55
|
+
tab icon matches the rest of its chrome. Already validated as a hex colour
|
|
56
|
+
by ``BrandingSettings``; anything else is ignored rather than trusted into
|
|
57
|
+
the markup.
|
|
58
|
+
|
|
59
|
+
Pure function of its two arguments, cached: ``branding_head`` calls it on
|
|
60
|
+
every full page render, and with no favicon uploaded — the state every
|
|
61
|
+
install starts in — that means every single page load rebuilds and
|
|
62
|
+
re-quotes the same SVG. The cache is keyed on ``(app_name, accent)``, both
|
|
63
|
+
of which only change when an admin edits branding settings.
|
|
64
|
+
"""
|
|
65
|
+
initial = _initial(app_name)
|
|
66
|
+
from_, to = (accent, accent) if _is_hex_colour(accent) else (_ACCENT_FROM, _ACCENT_TO)
|
|
67
|
+
svg = _SVG.format(from_=from_, to=to, initial=initial)
|
|
68
|
+
# Everything structural is encoded — `#` above all, or the browser reads
|
|
69
|
+
# the gradient reference as the URI's fragment and the shape loses its
|
|
70
|
+
# fill, but also `"`, `<`, `>` and spaces so the result stays a single
|
|
71
|
+
# valid attribute value without relying on the template's escaping. The
|
|
72
|
+
# safe set is only characters that are already legal unencoded in a URI.
|
|
73
|
+
return "data:image/svg+xml," + quote(svg, safe="/:=,;()-_.'")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _initial(app_name: str) -> str:
|
|
77
|
+
"""First character of the app name, XML-escaped, defaulting to ``S``.
|
|
78
|
+
|
|
79
|
+
Matches ``BrandingMark``'s ``appName.trim().charAt(0).toUpperCase() || 'S'``
|
|
80
|
+
so the tab and the sidebar badge never disagree.
|
|
81
|
+
"""
|
|
82
|
+
raw = (app_name or "").strip()[:1].upper() or "S"
|
|
83
|
+
return _xml_escape(raw)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _is_hex_colour(value: str) -> bool:
|
|
87
|
+
"""Whether ``value`` is a ``#rgb``/``#rrggbb`` literal safe to interpolate."""
|
|
88
|
+
if not value.startswith("#"):
|
|
89
|
+
return False
|
|
90
|
+
body = value[1:]
|
|
91
|
+
return len(body) in (3, 6) and all(c in "0123456789abcdefABCDEF" for c in body)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
__all__ = ["default_favicon_data_uri"]
|
|
@@ -12,7 +12,9 @@ from fastapi import FastAPI
|
|
|
12
12
|
from inertia import InertiaConfig, inertia_dependency_factory
|
|
13
13
|
from starlette.requests import Request
|
|
14
14
|
|
|
15
|
+
from simple_module_hosting._favicon import default_favicon_data_uri
|
|
15
16
|
from simple_module_hosting._inertia_json import json_safe_inertia_dependency
|
|
17
|
+
from simple_module_hosting._inertia_url import relative_page_url_dependency
|
|
16
18
|
from simple_module_hosting.settings import Settings
|
|
17
19
|
|
|
18
20
|
logger = logging.getLogger(__name__)
|
|
@@ -40,16 +42,22 @@ def branding_head(request: Request) -> dict:
|
|
|
40
42
|
The favicon URL is read from the module rather than assembled here: branding
|
|
41
43
|
owns its route shape, and framework code must not reach into a plugin
|
|
42
44
|
(SM009). ``BrandingHead`` still applies it client-side too, so a favicon
|
|
43
|
-
changed at runtime updates without a reload.
|
|
45
|
+
changed at runtime updates without a reload. When nothing has been uploaded
|
|
46
|
+
— the state every install starts in — it falls back to a generated mark
|
|
47
|
+
rather than ``None``, because omitting the tag sends the browser to
|
|
48
|
+
``/favicon.ico``, which this app does not serve.
|
|
44
49
|
"""
|
|
45
50
|
services = getattr(request.app.state, "branding", None)
|
|
46
51
|
settings = getattr(services, "settings", None)
|
|
47
|
-
|
|
48
|
-
|
|
52
|
+
app_name = getattr(settings, "app_name", "") or _DEFAULT_APP_NAME
|
|
53
|
+
accent = getattr(settings, "primary_color", "") or ""
|
|
54
|
+
favicon_url = getattr(services, "favicon_url", None) or default_favicon_data_uri(
|
|
55
|
+
app_name, accent
|
|
56
|
+
)
|
|
49
57
|
return {
|
|
50
|
-
"app_name":
|
|
51
|
-
"theme_color":
|
|
52
|
-
"favicon_url":
|
|
58
|
+
"app_name": app_name,
|
|
59
|
+
"theme_color": accent or None,
|
|
60
|
+
"favicon_url": favicon_url,
|
|
53
61
|
}
|
|
54
62
|
|
|
55
63
|
|
|
@@ -185,9 +193,15 @@ def setup_inertia(
|
|
|
185
193
|
use_flash_errors=True,
|
|
186
194
|
)
|
|
187
195
|
|
|
188
|
-
#
|
|
189
|
-
#
|
|
190
|
-
#
|
|
191
|
-
|
|
196
|
+
# Two wraps over the stock dependency, each closing a gap in upstream:
|
|
197
|
+
# * JSON branch builds a Starlette JSONResponse directly, so the encoder
|
|
198
|
+
# configured above only ever applies to full page loads — wrap it so a
|
|
199
|
+
# client-side visit encodes the same props the same way.
|
|
200
|
+
# * The page url is absolute, which the browser rejects for pushState
|
|
201
|
+
# behind a TLS-terminating proxy — wrap it back to the root-relative
|
|
202
|
+
# path the Inertia protocol specifies.
|
|
203
|
+
inertia_dep = relative_page_url_dependency(
|
|
204
|
+
json_safe_inertia_dependency(inertia_dependency_factory(inertia_config))
|
|
205
|
+
)
|
|
192
206
|
app.state.inertia_dependency = inertia_dep
|
|
193
207
|
return inertia_config
|
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import logging
|
|
6
|
+
from collections.abc import Callable
|
|
6
7
|
from typing import Any
|
|
7
8
|
|
|
9
|
+
from simple_module_core.i18n import Translator
|
|
8
10
|
from starlette.datastructures import Headers
|
|
9
11
|
from starlette.requests import Request
|
|
10
12
|
from starlette.types import Scope
|
|
@@ -77,6 +79,30 @@ def build_i18n_block(scope: Scope, request: Request, *, is_authenticated: bool =
|
|
|
77
79
|
}
|
|
78
80
|
|
|
79
81
|
|
|
82
|
+
def build_menu_translator(request: Request) -> Callable[[str], str] | None:
|
|
83
|
+
"""Return a locale-bound translator for menu labels, or None if unavailable.
|
|
84
|
+
|
|
85
|
+
Menu labels are translated on the server rather than in the client: the
|
|
86
|
+
payload then carries finished text, so the sidebar, topbar and command
|
|
87
|
+
palette all keep rendering ``item.label`` unchanged. It also sidesteps the
|
|
88
|
+
catalog audience split — an admin-audience module's labels never have to be
|
|
89
|
+
present in the anonymous snapshot to be readable.
|
|
90
|
+
|
|
91
|
+
Mirrors ``build_i18n_block``'s defensive lookups: test fixtures build apps
|
|
92
|
+
with a partial ``app.state.sm``, and a missing registry must degrade to the
|
|
93
|
+
literal labels rather than raise on every request.
|
|
94
|
+
"""
|
|
95
|
+
sm = getattr(request.app.state, "sm", None)
|
|
96
|
+
registry = getattr(sm, "i18n_registry", None) if sm is not None else None
|
|
97
|
+
locale = getattr(request.state, "locale", None)
|
|
98
|
+
if registry is None or locale is None:
|
|
99
|
+
return None
|
|
100
|
+
settings = getattr(sm, "settings", None)
|
|
101
|
+
default_locale = getattr(settings, "i18n_default_locale", "en") if settings else "en"
|
|
102
|
+
translator = Translator(registry, locale=locale, default_locale=default_locale)
|
|
103
|
+
return translator.t
|
|
104
|
+
|
|
105
|
+
|
|
80
106
|
def merge_shared_prop_providers(app: Any, request: Request, shared: dict) -> None:
|
|
81
107
|
"""Merge module-registered Inertia shared-prop providers into ``shared`` in place.
|
|
82
108
|
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Make Inertia's page ``url`` root-relative, as the protocol specifies.
|
|
2
|
+
|
|
3
|
+
``fastapi-inertia`` builds the page object with the *absolute* request url::
|
|
4
|
+
|
|
5
|
+
page_data = {
|
|
6
|
+
"component": self._component,
|
|
7
|
+
"props": await self._build_props(),
|
|
8
|
+
"url": str(self._request.url), # http://host:8000/dashboard/
|
|
9
|
+
"version": self._config.version,
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
The Inertia protocol says otherwise — every official adapter emits a
|
|
13
|
+
root-relative path (Laravel's ``$request->getRequestUri()``, Rails'
|
|
14
|
+
``request.original_fullpath``), and the documented page object is
|
|
15
|
+
``{"component": "Event", "props": {...}, "url": "/events/80", ...}``. The
|
|
16
|
+
client hands that value straight to ``history.pushState``/``replaceState``,
|
|
17
|
+
which resolves a relative url against the current document and so can never
|
|
18
|
+
disagree with it.
|
|
19
|
+
|
|
20
|
+
An absolute url can, and behind a TLS-terminating reverse proxy it always
|
|
21
|
+
does. The proxy speaks https to the browser and http to the container, so
|
|
22
|
+
``request.url`` is ``http://…`` while the document origin is ``https://…``,
|
|
23
|
+
and the browser rejects the state write::
|
|
24
|
+
|
|
25
|
+
SecurityError: Failed to execute 'pushState' on 'History': A history state
|
|
26
|
+
object with URL 'http://example.com/' cannot be created in a document with
|
|
27
|
+
origin 'https://example.com' and URL 'https://example.com/'
|
|
28
|
+
|
|
29
|
+
That fires on *every* page — the initial ``replaceState`` and every subsequent
|
|
30
|
+
visit — so history state is never written: back/forward navigation and scroll
|
|
31
|
+
restoration silently stop working, and the console fills with the error. The
|
|
32
|
+
throw escapes as an unhandled rejection, because the client's
|
|
33
|
+
``isHistoryThrottleError`` guard matches the string ``"history.pushState"``
|
|
34
|
+
while the browser's message reads ``"Failed to execute 'pushState'"``.
|
|
35
|
+
|
|
36
|
+
``SM_TRUSTED_PROXY`` fixes the scheme by trusting ``X-Forwarded-Proto``, and is
|
|
37
|
+
still worth setting — it is what makes request logs record the real client IP.
|
|
38
|
+
But an install should not need it merely to have working history, and trusting
|
|
39
|
+
forwarded headers is a security decision that must stay opt-in: the default
|
|
40
|
+
image is documented as runnable standalone, where a spoofed ``X-Forwarded-For``
|
|
41
|
+
would poison the audit log. Emitting the relative url the protocol asks for
|
|
42
|
+
removes the dependency entirely, whatever the deployment looks like.
|
|
43
|
+
|
|
44
|
+
Patched at ``_get_page_data`` because it is the single choke point: upstream's
|
|
45
|
+
SSR, JSON and full-page-load branches all build their payload from it. The
|
|
46
|
+
replacement binds to the instance, so the library's class is untouched.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
from __future__ import annotations
|
|
50
|
+
|
|
51
|
+
import logging
|
|
52
|
+
from typing import Any
|
|
53
|
+
from urllib.parse import urlsplit
|
|
54
|
+
|
|
55
|
+
logger = logging.getLogger(__name__)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def patch_relative_page_url(inertia: Any) -> Any:
|
|
59
|
+
"""Patch a single Inertia instance in place so its page ``url`` is
|
|
60
|
+
root-relative, and return it.
|
|
61
|
+
|
|
62
|
+
Shared by :func:`relative_page_url_dependency` (the normal per-request
|
|
63
|
+
path) and by callers that already hold a bare instance — the error
|
|
64
|
+
handler's half-built-app fallback — so there is exactly one place that
|
|
65
|
+
knows how to apply the patch rather than a second copy of this hasattr
|
|
66
|
+
check.
|
|
67
|
+
|
|
68
|
+
An instance that doesn't expose the hook is handed back untouched: the
|
|
69
|
+
failure mode is the absolute url that already ships, and refusing to boot
|
|
70
|
+
because an upstream attribute moved would be a worse trade.
|
|
71
|
+
"""
|
|
72
|
+
if hasattr(inertia, "_get_page_data"):
|
|
73
|
+
inertia._get_page_data = _page_data_for(inertia)
|
|
74
|
+
else: # pragma: no cover - upstream layout changed
|
|
75
|
+
logger.warning(
|
|
76
|
+
"Inertia instance has no _get_page_data; the page url stays "
|
|
77
|
+
"absolute and history writes will fail behind a TLS proxy"
|
|
78
|
+
)
|
|
79
|
+
return inertia
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def relative_page_url_dependency(inertia_dep: Any) -> Any:
|
|
83
|
+
"""Wrap an Inertia dependency so its page ``url`` is root-relative.
|
|
84
|
+
|
|
85
|
+
Keeps the ``(request, client)`` shape ``inertia_dependency_factory``
|
|
86
|
+
returns, and composes with the other wraps in either order — each patches a
|
|
87
|
+
different instance attribute, and the JSON wrap looks ``_get_page_data`` up
|
|
88
|
+
on the instance at call time.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
def dependency(request: Any, client: Any = None) -> Any:
|
|
92
|
+
return patch_relative_page_url(inertia_dep(request, client))
|
|
93
|
+
|
|
94
|
+
return dependency
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _page_data_for(inertia: Any) -> Any:
|
|
98
|
+
"""Build the instance's replacement ``_get_page_data``."""
|
|
99
|
+
stock = inertia._get_page_data
|
|
100
|
+
|
|
101
|
+
async def _get_page_data() -> dict:
|
|
102
|
+
page_data = await stock()
|
|
103
|
+
url = page_data.get("url")
|
|
104
|
+
if isinstance(url, str):
|
|
105
|
+
page_data["url"] = to_relative_url(url)
|
|
106
|
+
return page_data
|
|
107
|
+
|
|
108
|
+
return _get_page_data
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def to_relative_url(url: str) -> str:
|
|
112
|
+
"""Reduce an absolute url to the path-and-query the protocol wants.
|
|
113
|
+
|
|
114
|
+
A url that is already relative is returned unchanged, so this is safe to
|
|
115
|
+
apply twice and safe to apply to a payload upstream may one day fix.
|
|
116
|
+
Anything unparseable is passed through rather than mangled — a wrong url is
|
|
117
|
+
still better than a crash on the render path.
|
|
118
|
+
"""
|
|
119
|
+
try:
|
|
120
|
+
parts = urlsplit(url)
|
|
121
|
+
except ValueError: # pragma: no cover - urlsplit is near-total
|
|
122
|
+
return url
|
|
123
|
+
if not parts.scheme and not parts.netloc:
|
|
124
|
+
return url
|
|
125
|
+
relative = parts.path or "/"
|
|
126
|
+
if parts.query:
|
|
127
|
+
relative = f"{relative}?{parts.query}"
|
|
128
|
+
return relative
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
__all__ = ["patch_relative_page_url", "relative_page_url_dependency", "to_relative_url"]
|