simple-module-hosting 0.0.33__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.33 → simple_module_hosting-0.0.34}/PKG-INFO +3 -3
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/pyproject.toml +3 -3
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/_error_handlers.py +20 -5
- simple_module_hosting-0.0.34/simple_module_hosting/_favicon.py +94 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/_inertia_setup.py +24 -10
- simple_module_hosting-0.0.34/simple_module_hosting/_inertia_url.py +131 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/_phase_helpers.py +4 -2
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/host_settings.py +9 -2
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/i18n_manifest.py +20 -5
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/permissions.py +36 -15
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/session.py +35 -10
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_branding_head.py +32 -6
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_error_page_shared_props.py +33 -0
- simple_module_hosting-0.0.34/tests/test_favicon_default.py +72 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_host_settings_fields.py +2 -1
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_i18n_manifest.py +52 -1
- simple_module_hosting-0.0.34/tests/test_inertia_relative_url.py +120 -0
- simple_module_hosting-0.0.34/tests/test_proxy_headers.py +224 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_session_cookie_window.py +37 -0
- simple_module_hosting-0.0.33/tests/test_proxy_headers.py +0 -122
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/.gitignore +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/LICENSE +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/README.md +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/__init__.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/__main__.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/_db_health.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/_host_services.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/_hydrate_step.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/_inertia_cache.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/_inertia_json.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/_inertia_shared.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/_lifespan.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/_module_routes.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/_observability.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/_preapp_config.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/_registrations.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/_secret_key.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/app_builder.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/assets.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/bootstrap_settings.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/csrf.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/health.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/host_cli.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/i18n_deps.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/i18n_middleware.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/inertia_deps.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/inertia_utils.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/logging.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/maintenance.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/manifest.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/middleware.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/migrations.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/py.typed +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/redirects.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/settings.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/setup_gate.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/shared_props.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/static_files.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_api_error_negotiation.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_app.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_auth_provider_setting.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_boot_drift_check.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_check_migrations.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_csp_module_sources.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_db_health_check.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_design_packs_wiring.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_diagnostics_state.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_error_status_coverage.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_health.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_host_cli.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_hosting_csrf.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_hosting_permissions.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_i18n_types_cover_installed_modules.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_inertia_cache.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_inertia_i18n_shared_props.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_inertia_json_encoder.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_inertia_manifest.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_inertia_shared_providers.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_lifespan_order.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_locale_middleware.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_logging.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_maintenance_mode.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_manifest.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_menu_urls_are_canonical.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_middleware_order.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_migration_listing.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_migration_multihead.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_preapp_config.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_precompressed_static.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_redirects.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_response_compression.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_secret_key_bootstrap.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_session_cookie_security.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_settings_i18n.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_settings_secrets.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_setup_gate.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_setup_password_policy.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_setup_public_routes.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_setup_registry.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_setup_routes.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_sqlite_path_anchor.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_static_caching.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_strict_discovery_wiring.py +0 -0
- {simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/tests/test_tenant_middleware.py +0 -0
- {simple_module_hosting-0.0.33 → 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",
|
|
@@ -21,6 +21,7 @@ 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._inertia_url import patch_relative_page_url
|
|
24
25
|
from simple_module_hosting.permissions import PERMISSION_DENIED_PREFIX
|
|
25
26
|
|
|
26
27
|
logger = logging.getLogger(__name__)
|
|
@@ -172,11 +173,25 @@ async def render_error_page(
|
|
|
172
173
|
# thing that is missing when the app is half-built, and an error page
|
|
173
174
|
# that raises while reporting an error leaves the caller with nothing.
|
|
174
175
|
config: InertiaConfig = request.app.state.sm.inertia_config
|
|
175
|
-
|
|
176
|
-
#
|
|
177
|
-
#
|
|
178
|
-
#
|
|
179
|
-
#
|
|
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.
|
|
180
195
|
shared = getattr(request.state, "inertia_shared", None)
|
|
181
196
|
if shared:
|
|
182
197
|
inertia.share(**shared)
|
|
@@ -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
|
|
@@ -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"]
|
|
@@ -167,8 +167,10 @@ def install_middleware(
|
|
|
167
167
|
app.add_middleware(RequestLoggingMiddleware)
|
|
168
168
|
app.add_middleware(CorrelationIdMiddleware)
|
|
169
169
|
# Outermost: rewrite scheme/client from X-Forwarded-* before anything else
|
|
170
|
-
# reads them, so request logs see the real client IP
|
|
171
|
-
#
|
|
170
|
+
# reads them, so request logs see the real client IP rather than the
|
|
171
|
+
# proxy's. Inertia no longer needs this: its page url is
|
|
172
|
+
# root-relative, so pushState can't see a cross-scheme url either way (it
|
|
173
|
+
# used to throw a SecurityError on every page — GH #223). Gated on an
|
|
172
174
|
# explicit trust setting — never trust forwarded headers by default.
|
|
173
175
|
if settings.trusted_proxy:
|
|
174
176
|
app.add_middleware(ProxyHeadersMiddleware, trusted_hosts=settings.trusted_proxy)
|
{simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/host_settings.py
RENAMED
|
@@ -58,8 +58,15 @@ class HostSettings(BaseSettings):
|
|
|
58
58
|
# the container is only reachable through a single proxy), or a comma-
|
|
59
59
|
# separated list of proxy IPs / CIDRs. Drives uvicorn's
|
|
60
60
|
# ProxyHeadersMiddleware so request.url.scheme reflects X-Forwarded-Proto
|
|
61
|
-
#
|
|
62
|
-
#
|
|
61
|
+
# and request logs record the real client IP rather than the proxy's.
|
|
62
|
+
# Recommended behind a TLS-terminating proxy, not required for
|
|
63
|
+
# Inertia: the page url is root-relative, so pushState can't see a
|
|
64
|
+
# cross-scheme url regardless (it used to throw a SecurityError on every
|
|
65
|
+
# page — GH #223). Still required for anything else that reads
|
|
66
|
+
# request.url.scheme or calls request.url_for(...) to build an absolute
|
|
67
|
+
# url, e.g. OAuth's callback_url and the locale cookie's `secure` flag —
|
|
68
|
+
# left unset behind such a proxy, an OAuth redirect_uri ships as http://
|
|
69
|
+
# and most providers reject it.
|
|
63
70
|
trusted_proxy: str | None = None
|
|
64
71
|
|
|
65
72
|
auth_provider: str = DEFAULT_AUTH_PROVIDER
|
{simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/i18n_manifest.py
RENAMED
|
@@ -66,6 +66,8 @@ def emit_frontend_types_for_modules(
|
|
|
66
66
|
settings: Settings,
|
|
67
67
|
installed_modules: list[ModuleBase],
|
|
68
68
|
project_root: Path,
|
|
69
|
+
*,
|
|
70
|
+
strict: bool = False,
|
|
69
71
|
) -> None:
|
|
70
72
|
"""Emit the TS key union covering every *installed* module.
|
|
71
73
|
|
|
@@ -77,20 +79,33 @@ def emit_frontend_types_for_modules(
|
|
|
77
79
|
inactive module's strings are typed, never served.
|
|
78
80
|
"""
|
|
79
81
|
registry, _ = build_i18n_registry(settings, installed_modules, project_root)
|
|
80
|
-
emit_frontend_types(registry, project_root)
|
|
82
|
+
emit_frontend_types(registry, project_root, strict=strict)
|
|
81
83
|
|
|
82
84
|
|
|
83
|
-
def emit_frontend_types(
|
|
85
|
+
def emit_frontend_types(
|
|
86
|
+
registry: I18nRegistry, project_root: Path, *, strict: bool = False
|
|
87
|
+
) -> None:
|
|
84
88
|
"""Write the TS augmentation files into @simple-module-py/i18n if present.
|
|
85
89
|
|
|
86
90
|
Logs but does not raise on failure — stale types are preferable to a
|
|
87
91
|
broken boot. Dev-loop only; callers should gate on ``is_development``.
|
|
92
|
+
|
|
93
|
+
``strict=True`` re-raises instead, for a tool whose whole job is this
|
|
94
|
+
regeneration (``make gen-i18n``). There a swallowed failure only defers the
|
|
95
|
+
error to a ``tsc`` run several steps removed from the cause. Under strict a
|
|
96
|
+
missing package directory is an error too, where on the boot path it is the
|
|
97
|
+
normal shape of a wheel-installed app that ships no i18n workspace.
|
|
88
98
|
"""
|
|
99
|
+
pkg_src = project_root / "packages" / "i18n" / "src"
|
|
100
|
+
if not pkg_src.is_dir():
|
|
101
|
+
if strict:
|
|
102
|
+
raise FileNotFoundError(f"{pkg_src} does not exist — nothing to regenerate")
|
|
103
|
+
return
|
|
89
104
|
try:
|
|
90
|
-
pkg_src
|
|
91
|
-
if pkg_src.is_dir():
|
|
92
|
-
write_generated_resources(registry, pkg_src)
|
|
105
|
+
write_generated_resources(registry, pkg_src)
|
|
93
106
|
except Exception:
|
|
107
|
+
if strict:
|
|
108
|
+
raise
|
|
94
109
|
logger.exception("Failed to write generated-resources.ts — frontend types will be stale")
|
|
95
110
|
|
|
96
111
|
|
{simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/permissions.py
RENAMED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
from fastapi import HTTPException, Request
|
|
6
|
-
from simple_module_core.permissions import DEFAULT_ROLE_PERMISSIONS, WILDCARD
|
|
6
|
+
from simple_module_core.permissions import DEFAULT_ROLE_PERMISSIONS, WILDCARD, grants
|
|
7
7
|
|
|
8
8
|
__all__ = [
|
|
9
9
|
"DEFAULT_ROLE_PERMISSIONS",
|
|
@@ -12,6 +12,7 @@ __all__ = [
|
|
|
12
12
|
"RequiresPermission",
|
|
13
13
|
"expand_permissions",
|
|
14
14
|
"resolve_permissions",
|
|
15
|
+
"resolved_permissions_for",
|
|
15
16
|
]
|
|
16
17
|
|
|
17
18
|
# How a denial spells the missing permission in ``HTTPException.detail``.
|
|
@@ -45,6 +46,39 @@ def expand_permissions(
|
|
|
45
46
|
return sorted(resolved)
|
|
46
47
|
|
|
47
48
|
|
|
49
|
+
def resolved_permissions_for(request: Request) -> set[str]:
|
|
50
|
+
"""The permission set this request's principal holds, wildcard included.
|
|
51
|
+
|
|
52
|
+
Prefers what ``InertiaLayoutDataMiddleware`` already resolved and cached on
|
|
53
|
+
``request.state``; falls back to resolving from the registry's role map when
|
|
54
|
+
the middleware is not in the stack (a bare router under a test transport),
|
|
55
|
+
caching the result so a route with several gates resolves once.
|
|
56
|
+
|
|
57
|
+
Anonymous requests hold nothing. Exposed because a handler sometimes has to
|
|
58
|
+
gate part of its *response* rather than the route — the audit log's entity
|
|
59
|
+
labels, for one — and that decision must read the same permission set the
|
|
60
|
+
door did.
|
|
61
|
+
|
|
62
|
+
Role-derived only, matching ``RequiresPermission``: the ``permissions``
|
|
63
|
+
module's direct per-user grants are its own dependency's business, and a
|
|
64
|
+
caller wanting those consults ``permissions.deps.RequiresPermission``.
|
|
65
|
+
"""
|
|
66
|
+
cached: set[str] | None = getattr(request.state, "resolved_permissions", None)
|
|
67
|
+
if cached is not None:
|
|
68
|
+
return cached
|
|
69
|
+
|
|
70
|
+
user = getattr(request.state, "user", None)
|
|
71
|
+
if user is None:
|
|
72
|
+
return set()
|
|
73
|
+
|
|
74
|
+
sm = getattr(getattr(request.app, "state", None), "sm", None)
|
|
75
|
+
perm_registry = getattr(sm, "permissions", None) if sm is not None else None
|
|
76
|
+
role_map = perm_registry.role_map if perm_registry is not None else None
|
|
77
|
+
permissions = resolve_permissions(user.roles, role_map=role_map)
|
|
78
|
+
request.state.resolved_permissions = permissions
|
|
79
|
+
return permissions
|
|
80
|
+
|
|
81
|
+
|
|
48
82
|
class RequiresPermission:
|
|
49
83
|
"""FastAPI dependency that enforces a specific permission.
|
|
50
84
|
|
|
@@ -63,20 +97,7 @@ class RequiresPermission:
|
|
|
63
97
|
if user is None:
|
|
64
98
|
raise HTTPException(status_code=401, detail="Authentication required")
|
|
65
99
|
|
|
66
|
-
|
|
67
|
-
permissions: set[str] | None = getattr(request.state, "resolved_permissions", None)
|
|
68
|
-
if permissions is None:
|
|
69
|
-
# Fallback: middleware did not run — consult registry role_map if available
|
|
70
|
-
sm = getattr(getattr(request.app, "state", None), "sm", None)
|
|
71
|
-
perm_registry = getattr(sm, "permissions", None) if sm is not None else None
|
|
72
|
-
role_map = perm_registry.role_map if perm_registry is not None else None
|
|
73
|
-
permissions = resolve_permissions(user.roles, role_map=role_map)
|
|
74
|
-
request.state.resolved_permissions = permissions
|
|
75
|
-
|
|
76
|
-
if WILDCARD in permissions:
|
|
77
|
-
return
|
|
78
|
-
|
|
79
|
-
if self.permission not in permissions:
|
|
100
|
+
if not grants(resolved_permissions_for(request), self.permission):
|
|
80
101
|
raise HTTPException(
|
|
81
102
|
status_code=403,
|
|
82
103
|
detail=f"{PERMISSION_DENIED_PREFIX}{self.permission}",
|
{simple_module_hosting-0.0.33 → simple_module_hosting-0.0.34}/simple_module_hosting/session.py
RENAMED
|
@@ -61,9 +61,11 @@ off disk. Recording the deadline in the payload puts the ceiling back per
|
|
|
61
61
|
session: the auth provider treats a session past it as signed out, whatever
|
|
62
62
|
the signer is still willing to verify.
|
|
63
63
|
|
|
64
|
-
Written by whoever signs the user in, via :func:`stamp_session_expiry`.
|
|
65
|
-
|
|
66
|
-
|
|
64
|
+
Written by whoever signs the user in, via :func:`stamp_session_expiry`. A
|
|
65
|
+
session minted before this existed carries no key; the auth provider stamps one
|
|
66
|
+
on first sight (:func:`ensure_session_expiry`) rather than either rejecting it —
|
|
67
|
+
which would sign the entire user base out on deploy — or accepting it forever,
|
|
68
|
+
which would leave the hole this key was added to close.
|
|
67
69
|
"""
|
|
68
70
|
|
|
69
71
|
SESSION_COOKIE_MAX_AGE_KEY = "session_cookie_max_age"
|
|
@@ -176,17 +178,40 @@ def stamp_session_expiry(session: MutableMapping[str, Any], max_age_seconds: int
|
|
|
176
178
|
return deadline
|
|
177
179
|
|
|
178
180
|
|
|
181
|
+
def ensure_session_expiry(session: MutableMapping[str, Any], max_age_seconds: int) -> int:
|
|
182
|
+
"""Return this session's deadline, stamping one if it has none.
|
|
183
|
+
|
|
184
|
+
The migration path for sessions minted before :func:`stamp_session_expiry`
|
|
185
|
+
existed. Failing closed on absence would sign the whole user base out at
|
|
186
|
+
deploy time; accepting absence forever leaves open the hole the deadline was
|
|
187
|
+
added to close. Stamping on first sight does neither — the session keeps
|
|
188
|
+
working, and from that request on it carries the same bound as one minted
|
|
189
|
+
today.
|
|
190
|
+
|
|
191
|
+
It cannot widen anything: an existing deadline is returned untouched, and a
|
|
192
|
+
stamp written now is still capped by the signature window, which the signer
|
|
193
|
+
enforces from the session's *original* mint time. So a legacy session's real
|
|
194
|
+
ceiling stays what it always was, and one signature window after this ships
|
|
195
|
+
there are no unstamped sessions left for :func:`session_has_expired` to
|
|
196
|
+
decide about.
|
|
197
|
+
"""
|
|
198
|
+
recorded = session.get(SESSION_EXPIRES_AT_KEY) if isinstance(session, Mapping) else None
|
|
199
|
+
if not isinstance(recorded, bool) and isinstance(recorded, (int, float)):
|
|
200
|
+
return int(recorded)
|
|
201
|
+
return stamp_session_expiry(session, max_age_seconds)
|
|
202
|
+
|
|
203
|
+
|
|
179
204
|
def session_has_expired(session: Any) -> bool:
|
|
180
|
-
"""True
|
|
205
|
+
"""True if the session has no deadline, or records one that has passed.
|
|
181
206
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
207
|
+
Fails **closed** on a missing or unreadable key. That is safe because the
|
|
208
|
+
only caller stamps first — see :func:`ensure_session_expiry` — so absence
|
|
209
|
+
here means the session never went through that path, which is not something
|
|
210
|
+
to give the benefit of the doubt to.
|
|
186
211
|
"""
|
|
187
212
|
if not isinstance(session, Mapping):
|
|
188
|
-
return
|
|
213
|
+
return True
|
|
189
214
|
recorded = session.get(SESSION_EXPIRES_AT_KEY)
|
|
190
215
|
if isinstance(recorded, bool) or not isinstance(recorded, (int, float)):
|
|
191
|
-
return
|
|
216
|
+
return True
|
|
192
217
|
return recorded <= time.time()
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from types import SimpleNamespace
|
|
6
6
|
|
|
7
|
+
from simple_module_hosting._favicon import default_favicon_data_uri
|
|
7
8
|
from simple_module_hosting._inertia_setup import branding_head
|
|
8
9
|
|
|
9
10
|
|
|
@@ -16,7 +17,11 @@ def _request(branding: object | None) -> SimpleNamespace:
|
|
|
16
17
|
|
|
17
18
|
def test_defaults_when_branding_not_installed() -> None:
|
|
18
19
|
meta = branding_head(_request(None))
|
|
19
|
-
assert meta == {
|
|
20
|
+
assert meta == {
|
|
21
|
+
"app_name": "SimpleModule",
|
|
22
|
+
"theme_color": None,
|
|
23
|
+
"favicon_url": default_favicon_data_uri("SimpleModule"),
|
|
24
|
+
}
|
|
20
25
|
|
|
21
26
|
|
|
22
27
|
def test_reads_app_name_and_theme_color() -> None:
|
|
@@ -29,7 +34,11 @@ def test_reads_app_name_and_theme_color() -> None:
|
|
|
29
34
|
def test_blank_values_fall_back() -> None:
|
|
30
35
|
settings = SimpleNamespace(app_name="", primary_color="")
|
|
31
36
|
meta = branding_head(_request(SimpleNamespace(settings=settings)))
|
|
32
|
-
assert meta == {
|
|
37
|
+
assert meta == {
|
|
38
|
+
"app_name": "SimpleModule",
|
|
39
|
+
"theme_color": None,
|
|
40
|
+
"favicon_url": default_favicon_data_uri("SimpleModule"),
|
|
41
|
+
}
|
|
33
42
|
|
|
34
43
|
|
|
35
44
|
def test_favicon_url_comes_from_the_module_not_from_here() -> None:
|
|
@@ -42,8 +51,25 @@ def test_favicon_url_comes_from_the_module_not_from_here() -> None:
|
|
|
42
51
|
assert branding_head(_request(services))["favicon_url"] == "/api/branding/favicon?v=abc"
|
|
43
52
|
|
|
44
53
|
|
|
45
|
-
def
|
|
46
|
-
|
|
47
|
-
|
|
54
|
+
def test_an_uploaded_favicon_always_wins_over_the_default() -> None:
|
|
55
|
+
services = SimpleNamespace(
|
|
56
|
+
settings=SimpleNamespace(app_name="Acme", primary_color="#1a7dd1"),
|
|
57
|
+
favicon_url="/api/branding/favicon?v=abc",
|
|
58
|
+
)
|
|
59
|
+
assert branding_head(_request(services))["favicon_url"] == "/api/branding/favicon?v=abc"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_a_host_without_that_attribute_still_gets_a_favicon() -> None:
|
|
63
|
+
# An older branding release exposes no favicon_url. Omitting the link tag
|
|
64
|
+
# sends the browser to /favicon.ico, which this app does not route — a 404
|
|
65
|
+
# (or a 302 to the login page) in the console on every full page load — so
|
|
66
|
+
# the generated mark stands in.
|
|
48
67
|
services = SimpleNamespace(settings=SimpleNamespace(app_name="Acme", primary_color=""))
|
|
49
|
-
assert branding_head(_request(services))["favicon_url"]
|
|
68
|
+
assert branding_head(_request(services))["favicon_url"] == default_favicon_data_uri("Acme")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_the_default_follows_the_configured_brand_colour() -> None:
|
|
72
|
+
services = SimpleNamespace(settings=SimpleNamespace(app_name="Acme", primary_color="#1a7dd1"))
|
|
73
|
+
favicon = branding_head(_request(services))["favicon_url"]
|
|
74
|
+
assert favicon == default_favicon_data_uri("Acme", "#1a7dd1")
|
|
75
|
+
assert favicon != default_favicon_data_uri("Acme")
|