create-caspian-app 1.6.4 → 1.8.1
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.
- package/dist/.github/copilot-instructions.md +271 -271
- package/dist/AGENTS.md +470 -461
- package/dist/app-gitignore +2 -0
- package/dist/index.js +2 -2
- package/dist/main.py +424 -64
- package/dist/public/js/pp-reactive-v2.min.js +1 -1
- package/dist/settings/build-static.py +32 -2
- package/dist/settings/check.py +520 -476
- package/dist/src/app/error.py +94 -95
- package/dist/src/app/layout.py +43 -47
- package/package.json +1 -1
package/dist/main.py
CHANGED
|
@@ -41,7 +41,15 @@ from dotenv import load_dotenv
|
|
|
41
41
|
import uvicorn
|
|
42
42
|
from casp.state_manager import StateManager
|
|
43
43
|
from casp.cache_handler import CacheHandler
|
|
44
|
-
from casp.caspian_config import get_files_index, get_config
|
|
44
|
+
from casp.caspian_config import RouteEntry, get_files_index, get_config, route_priority
|
|
45
|
+
from casp.api_routes import (
|
|
46
|
+
csrf_required,
|
|
47
|
+
discover_handlers,
|
|
48
|
+
select_handler,
|
|
49
|
+
to_response,
|
|
50
|
+
verify_csrf,
|
|
51
|
+
)
|
|
52
|
+
from casp.errors import HttpError
|
|
45
53
|
from casp.auth import (
|
|
46
54
|
Auth,
|
|
47
55
|
GoogleProvider,
|
|
@@ -49,6 +57,43 @@ from casp.auth import (
|
|
|
49
57
|
configure_auth,
|
|
50
58
|
)
|
|
51
59
|
from casp.rpc import register_rpc_routes, rpc_limiter
|
|
60
|
+
from casp.errors import (
|
|
61
|
+
error_headers,
|
|
62
|
+
error_info,
|
|
63
|
+
error_response_headers,
|
|
64
|
+
fallback_html_response,
|
|
65
|
+
find_error_boundaries,
|
|
66
|
+
prefers_html,
|
|
67
|
+
problem_response,
|
|
68
|
+
rpc_error_response,
|
|
69
|
+
)
|
|
70
|
+
from casp.observability import (
|
|
71
|
+
PROBE_PATHS,
|
|
72
|
+
ObservabilitySettings,
|
|
73
|
+
RequestIdMiddleware,
|
|
74
|
+
RequestLogMiddleware,
|
|
75
|
+
add_readiness_check,
|
|
76
|
+
check_readiness,
|
|
77
|
+
record,
|
|
78
|
+
request_id_for,
|
|
79
|
+
)
|
|
80
|
+
from casp.observability import configure as configure_observability
|
|
81
|
+
from casp.maintenance import MaintenanceMiddleware, MaintenanceState
|
|
82
|
+
from casp.request_pipeline import RequestPipelineMiddleware, load_pipeline
|
|
83
|
+
from casp.jobs import jobs_lifespan, registered_jobs
|
|
84
|
+
from casp.schedule import schedule as app_schedule
|
|
85
|
+
from casp.schedule import scheduler_lifespan
|
|
86
|
+
from casp.i18n import LocaleMiddleware
|
|
87
|
+
from casp.i18n import current_locale as current_app_locale
|
|
88
|
+
from casp.i18n import enabled as i18n_enabled
|
|
89
|
+
from casp.seo import (
|
|
90
|
+
ROBOTS_PATH,
|
|
91
|
+
SITEMAP_PATH,
|
|
92
|
+
call_provider,
|
|
93
|
+
configured_base_url,
|
|
94
|
+
render_robots,
|
|
95
|
+
render_sitemap,
|
|
96
|
+
)
|
|
52
97
|
from casp.layout import (
|
|
53
98
|
render_with_nested_layouts,
|
|
54
99
|
_finalize_page_region,
|
|
@@ -136,6 +181,48 @@ if cfg.prisma:
|
|
|
136
181
|
# calendar is affected -- session expiry and cache TTLs stay on UTC by design.
|
|
137
182
|
APP_TIMEZONE = get_app_timezone()
|
|
138
183
|
|
|
184
|
+
APP_ROOT = Path(__file__).resolve().parent
|
|
185
|
+
PUBLIC_FRAMEWORK_PATHS: tuple[str, ...] = (*PROBE_PATHS, SITEMAP_PATH, ROBOTS_PATH)
|
|
186
|
+
|
|
187
|
+
# Structured request/event logs and /ready policy. Production defaults: JSON
|
|
188
|
+
# request records on stderr. See casp.observability.ObservabilitySettings.
|
|
189
|
+
configure_observability(
|
|
190
|
+
ObservabilitySettings.from_env(is_production=IS_PRODUCTION, service_name=cfg.projectName)
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
if prisma is not None:
|
|
194
|
+
# The generated client connects lazily, so this also proves a connection
|
|
195
|
+
# can be opened. Cheap and side-effect free, as readiness checks must be.
|
|
196
|
+
async def _database_ready() -> None:
|
|
197
|
+
await prisma.query_raw("SELECT 1")
|
|
198
|
+
|
|
199
|
+
# `replace=True`: the dev server runs this file as __main__ and uvicorn then
|
|
200
|
+
# imports it again as `main`, so this module body executes twice.
|
|
201
|
+
add_readiness_check("database", _database_ready, replace=True)
|
|
202
|
+
|
|
203
|
+
# App-owned readiness checks (`@readiness_check("name")`) register on import.
|
|
204
|
+
if (APP_ROOT / "src" / "lib" / "readiness.py").is_file():
|
|
205
|
+
importlib.import_module("src.lib.readiness")
|
|
206
|
+
|
|
207
|
+
# Authorization policies are app-owned and register themselves on
|
|
208
|
+
# `casp.authorization.gate` when imported. The file is optional.
|
|
209
|
+
if (APP_ROOT / "src" / "lib" / "auth" / "policies.py").is_file():
|
|
210
|
+
importlib.import_module("src.lib.auth.policies")
|
|
211
|
+
|
|
212
|
+
# Background jobs (`@job`) and the task schedule register themselves on import.
|
|
213
|
+
# Both files are optional; the schedule usually imports the jobs it dispatches.
|
|
214
|
+
for _optional_module in ("jobs", "schedule"):
|
|
215
|
+
if (APP_ROOT / "src" / "lib" / f"{_optional_module}.py").is_file():
|
|
216
|
+
importlib.import_module(f"src.lib.{_optional_module}")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def load_app_middleware() -> list[Any]:
|
|
220
|
+
"""Entries from the optional `src/middleware.py` (the application pipeline)."""
|
|
221
|
+
if not (APP_ROOT / "src" / "middleware.py").is_file():
|
|
222
|
+
return []
|
|
223
|
+
return load_pipeline(importlib.import_module("src.middleware"))
|
|
224
|
+
|
|
225
|
+
|
|
139
226
|
# ====
|
|
140
227
|
# CORS configuration (shared .env convention, mirrors casp.rpc origin checks)
|
|
141
228
|
# ====
|
|
@@ -368,6 +455,14 @@ def get_app_lifespans() -> list[LifespanFactory]:
|
|
|
368
455
|
if prisma is not None:
|
|
369
456
|
lifespans.append(prisma_lifespan)
|
|
370
457
|
|
|
458
|
+
# Job workers start after the database lifespan and drain before it exits;
|
|
459
|
+
# the scheduler starts after the workers and stops before they drain, so a
|
|
460
|
+
# timer can never enqueue into a queue that is already shutting down.
|
|
461
|
+
if registered_jobs():
|
|
462
|
+
lifespans.append(jobs_lifespan)
|
|
463
|
+
if app_schedule.tasks:
|
|
464
|
+
lifespans.append(scheduler_lifespan)
|
|
465
|
+
|
|
371
466
|
# MCP lifecycle
|
|
372
467
|
# FastMCP needs its lifespan running so the MCP session manager starts.
|
|
373
468
|
if mcp_app is not None:
|
|
@@ -409,7 +504,21 @@ app = FastAPI(
|
|
|
409
504
|
|
|
410
505
|
@app.get("/health")
|
|
411
506
|
async def healthcheck():
|
|
412
|
-
|
|
507
|
+
# Liveness: the process can serve HTTP. Deliberately checks no dependency --
|
|
508
|
+
# restarting a healthy process does not repair an unavailable database.
|
|
509
|
+
return JSONResponse({"status": "ok"}, headers={"Cache-Control": "no-store"})
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
@app.get("/ready")
|
|
513
|
+
async def readinesscheck():
|
|
514
|
+
# Readiness: every registered dependency check passes. Names and statuses
|
|
515
|
+
# only; failure details go to the log stream.
|
|
516
|
+
ready, checks = await check_readiness()
|
|
517
|
+
return JSONResponse(
|
|
518
|
+
{"status": "ok" if ready else "unavailable", "checks": checks},
|
|
519
|
+
status_code=200 if ready else 503,
|
|
520
|
+
headers={"Cache-Control": "no-store"},
|
|
521
|
+
)
|
|
413
522
|
|
|
414
523
|
|
|
415
524
|
# ====
|
|
@@ -662,7 +771,9 @@ class AuthMiddleware:
|
|
|
662
771
|
self.app = app
|
|
663
772
|
|
|
664
773
|
async def __call__(self, scope: Scope, receive: Receive, send: Send):
|
|
665
|
-
if scope["type"] != "http":
|
|
774
|
+
if scope["type"] != "http" or scope.get("path") in PUBLIC_FRAMEWORK_PATHS:
|
|
775
|
+
# Probes (for orchestrators) and sitemap/robots (for crawlers) are
|
|
776
|
+
# answered without a session, whatever the route-privacy policy says.
|
|
666
777
|
await self.app(scope, receive, send)
|
|
667
778
|
return
|
|
668
779
|
request = Request(scope, receive, send)
|
|
@@ -783,7 +894,7 @@ class RateLimitMiddleware:
|
|
|
783
894
|
return
|
|
784
895
|
|
|
785
896
|
path = scope.get("path", "")
|
|
786
|
-
if path
|
|
897
|
+
if path in PROBE_PATHS:
|
|
787
898
|
await self.app(scope, receive, send)
|
|
788
899
|
return
|
|
789
900
|
|
|
@@ -1027,22 +1138,115 @@ def is_request_cacheable(request: Request) -> bool:
|
|
|
1027
1138
|
return False
|
|
1028
1139
|
|
|
1029
1140
|
|
|
1030
|
-
def
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
base_path = f"src/app/{route.fs_dir}" if route.fs_dir else "src/app"
|
|
1034
|
-
full_path = f"{base_path}/index.py".replace("//", "/")
|
|
1035
|
-
register_single_route(route.fastapi_rule, full_path)
|
|
1141
|
+
def _route_file_path(fs_dir: str, filename: str) -> str:
|
|
1142
|
+
base_path = f"src/app/{fs_dir}" if fs_dir else "src/app"
|
|
1143
|
+
return f"{base_path}/{filename}".replace("//", "/")
|
|
1036
1144
|
|
|
1037
1145
|
|
|
1038
|
-
def
|
|
1039
|
-
|
|
1146
|
+
def register_route_entry(route: RouteEntry, *, api: bool = False) -> None:
|
|
1147
|
+
"""Register a page (`index.py`) or API endpoint (`route.py`).
|
|
1148
|
+
|
|
1149
|
+
An optional catch-all (`[[...slug]]`) registers its base URL with the
|
|
1150
|
+
parameter set to None, then the path form.
|
|
1151
|
+
"""
|
|
1152
|
+
for rule, default_params in route.fastapi_rules():
|
|
1153
|
+
if api:
|
|
1154
|
+
register_api_route(
|
|
1155
|
+
rule, _route_file_path(route.fs_dir, "route.py"), default_params=default_params
|
|
1156
|
+
)
|
|
1157
|
+
else:
|
|
1158
|
+
register_single_route(
|
|
1159
|
+
rule, _route_file_path(route.fs_dir, "index.py"), default_params=default_params
|
|
1160
|
+
)
|
|
1161
|
+
|
|
1162
|
+
|
|
1163
|
+
def register_routes():
|
|
1164
|
+
idx = get_files_index()
|
|
1165
|
+
# Pages and API routes share one URL space, so they are ordered together:
|
|
1166
|
+
# a catch-all page must never shadow a more specific API route.
|
|
1167
|
+
entries = [(route, False) for route in idx.routes]
|
|
1168
|
+
entries += [(route, True) for route in idx.api_routes]
|
|
1169
|
+
for route, api in sorted(entries, key=lambda entry: route_priority(entry[0])):
|
|
1170
|
+
register_route_entry(route, api=api)
|
|
1171
|
+
|
|
1172
|
+
|
|
1173
|
+
def _build_route_call(
|
|
1174
|
+
func: Callable[..., Any], request: Request, params: dict[str, Any]
|
|
1175
|
+
) -> tuple[list[Any], dict[str, Any]]:
|
|
1176
|
+
"""Arguments for a `route.py` handler, using the same contract as `page()`:
|
|
1177
|
+
path params as one positional dict, `request` by keyword, query params by name.
|
|
1178
|
+
"""
|
|
1179
|
+
signature = inspect.signature(func)
|
|
1180
|
+
call_args: list[Any] = [params] if params else []
|
|
1181
|
+
call_kwargs: dict[str, Any] = {}
|
|
1182
|
+
if "request" in signature.parameters:
|
|
1183
|
+
call_kwargs["request"] = request
|
|
1184
|
+
for name, param in signature.parameters.items():
|
|
1185
|
+
if name in call_kwargs or name == "kwargs":
|
|
1186
|
+
continue
|
|
1187
|
+
if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
|
|
1188
|
+
continue
|
|
1189
|
+
if name in request.query_params:
|
|
1190
|
+
call_kwargs[name] = _coerce_query_param(request, name, param)
|
|
1191
|
+
return call_args, call_kwargs
|
|
1192
|
+
|
|
1193
|
+
|
|
1194
|
+
def register_api_route(
|
|
1195
|
+
url_pattern: str,
|
|
1196
|
+
file_path: str,
|
|
1197
|
+
*,
|
|
1198
|
+
default_params: Optional[dict[str, Any]] = None,
|
|
1199
|
+
):
|
|
1200
|
+
"""Register a `route.py` API endpoint: one function per HTTP method, no layouts."""
|
|
1201
|
+
methods = list(discover_handlers(load_route_module(file_path)))
|
|
1202
|
+
# FastAPI's APIRoute does not add HEAD for GET the way a Starlette Route does.
|
|
1203
|
+
if "GET" in methods and "HEAD" not in methods:
|
|
1204
|
+
methods.append("HEAD")
|
|
1205
|
+
|
|
1206
|
+
async def api_handler(request: Request):
|
|
1207
|
+
try:
|
|
1208
|
+
module = load_route_module(file_path)
|
|
1209
|
+
handler = select_handler(discover_handlers(module), request.method)
|
|
1210
|
+
if handler is None:
|
|
1211
|
+
raise HttpError(405)
|
|
1212
|
+
if csrf_required(module):
|
|
1213
|
+
await verify_csrf(request, session_cookie=SESSION_COOKIE_NAME)
|
|
1214
|
+
|
|
1215
|
+
params = {**(default_params or {}), **request.path_params}
|
|
1216
|
+
call_args, call_kwargs = _build_route_call(handler, request, params)
|
|
1217
|
+
result = handler(*call_args, **call_kwargs)
|
|
1218
|
+
if inspect.isawaitable(result):
|
|
1219
|
+
result = await result
|
|
1220
|
+
return to_response(result)
|
|
1221
|
+
except Exception as exc:
|
|
1222
|
+
return await render_error_response(request, exc, APP_ROOT_DIR, is_api=True)
|
|
1223
|
+
|
|
1224
|
+
endpoint = re.sub(r"[^0-9A-Za-z_]", "_", file_path)
|
|
1225
|
+
app.add_api_route(url_pattern, api_handler, methods=methods, name=endpoint)
|
|
1226
|
+
|
|
1227
|
+
|
|
1228
|
+
def register_single_route(
|
|
1229
|
+
url_pattern: str,
|
|
1230
|
+
file_path: str,
|
|
1231
|
+
*,
|
|
1232
|
+
default_params: Optional[dict[str, Any]] = None,
|
|
1233
|
+
):
|
|
1234
|
+
boundary_dir = os.path.dirname(file_path)
|
|
1235
|
+
|
|
1236
|
+
async def render_page(request: Request):
|
|
1040
1237
|
_runtime_metadata.set(None)
|
|
1041
1238
|
_runtime_injections.set({"head": [], "body": []})
|
|
1042
1239
|
|
|
1043
|
-
kwargs =
|
|
1044
|
-
|
|
1240
|
+
kwargs = {**(default_params or {}), **request.path_params}
|
|
1241
|
+
# The page cache keys on path plus query string, so `/products?page=2`
|
|
1242
|
+
# never serves the cached `/products` document.
|
|
1243
|
+
current_uri = request.url.path + (f"?{request.url.query}" if request.url.query else "")
|
|
1045
1244
|
request_is_cacheable = is_request_cacheable(request)
|
|
1245
|
+
if i18n_enabled():
|
|
1246
|
+
# Each language is its own cached document; the marker sits in the
|
|
1247
|
+
# query part so revalidate_path("/x") still clears every language.
|
|
1248
|
+
separator = "&" if "?" in current_uri else "?"
|
|
1249
|
+
current_uri = f"{current_uri}{separator}__locale={current_app_locale()}"
|
|
1046
1250
|
|
|
1047
1251
|
# 1. Cache Check (Fast Path)
|
|
1048
1252
|
if CACHE_ENABLED and request_is_cacheable:
|
|
@@ -1057,6 +1261,7 @@ def register_single_route(url_pattern: str, file_path: str):
|
|
|
1057
1261
|
|
|
1058
1262
|
req_should_cache = None
|
|
1059
1263
|
req_cache_ttl = 0
|
|
1264
|
+
req_cache_tags: list[str] = []
|
|
1060
1265
|
|
|
1061
1266
|
page_content_source = file_path
|
|
1062
1267
|
|
|
@@ -1098,6 +1303,7 @@ def register_single_route(url_pattern: str, file_path: str):
|
|
|
1098
1303
|
if cache_settings:
|
|
1099
1304
|
req_should_cache = cache_settings.enabled
|
|
1100
1305
|
req_cache_ttl = cache_settings.ttl
|
|
1306
|
+
req_cache_tags = list(getattr(cache_settings, "tags", None) or [])
|
|
1101
1307
|
|
|
1102
1308
|
if isinstance(result, tuple):
|
|
1103
1309
|
page_content = result[0]
|
|
@@ -1113,16 +1319,7 @@ def register_single_route(url_pattern: str, file_path: str):
|
|
|
1113
1319
|
static_meta = getattr(module, "metadata", None)
|
|
1114
1320
|
|
|
1115
1321
|
def extract_meta(obj):
|
|
1116
|
-
|
|
1117
|
-
if not obj:
|
|
1118
|
-
return d
|
|
1119
|
-
if obj.title:
|
|
1120
|
-
d["title"] = obj.title
|
|
1121
|
-
if obj.description:
|
|
1122
|
-
d["description"] = obj.description
|
|
1123
|
-
if obj.extra:
|
|
1124
|
-
d.update(obj.extra)
|
|
1125
|
-
return d
|
|
1322
|
+
return dict(obj.to_dict()) if obj else {}
|
|
1126
1323
|
|
|
1127
1324
|
page_metadata.update(extract_meta(static_meta))
|
|
1128
1325
|
page_metadata.update(extract_meta(dynamic_meta))
|
|
@@ -1158,10 +1355,18 @@ def register_single_route(url_pattern: str, file_path: str):
|
|
|
1158
1355
|
# the shared, URI-keyed cache on disk.
|
|
1159
1356
|
if should_cache and request_is_cacheable:
|
|
1160
1357
|
ttl_to_save = req_cache_ttl if req_cache_ttl > 0 else DEFAULT_TTL
|
|
1161
|
-
CacheHandler.save_cache(current_uri, html_output, ttl_to_save)
|
|
1358
|
+
CacheHandler.save_cache(current_uri, html_output, ttl_to_save, tags=req_cache_tags)
|
|
1162
1359
|
|
|
1163
1360
|
return response
|
|
1164
1361
|
|
|
1362
|
+
async def make_handler(request: Request):
|
|
1363
|
+
# A failing page (or a layout below its nearest `error.py`) is rendered
|
|
1364
|
+
# by that boundary instead of escaping to the global 500 handler.
|
|
1365
|
+
try:
|
|
1366
|
+
return await render_page(request)
|
|
1367
|
+
except Exception as exc:
|
|
1368
|
+
return await render_error_response(request, exc, boundary_dir, is_page=True)
|
|
1369
|
+
|
|
1165
1370
|
endpoint = (
|
|
1166
1371
|
file_path.replace("/", "_")
|
|
1167
1372
|
.replace("\\", "_")
|
|
@@ -1182,6 +1387,11 @@ def register_single_route(url_pattern: str, file_path: str):
|
|
|
1182
1387
|
if normalized_methods:
|
|
1183
1388
|
route_methods = list(dict.fromkeys(normalized_methods))
|
|
1184
1389
|
|
|
1390
|
+
# FastAPI's APIRoute does not answer HEAD for GET the way a Starlette Route
|
|
1391
|
+
# does; crawlers, uptime monitors, and BrowserSync all send HEAD.
|
|
1392
|
+
if "GET" in route_methods and "HEAD" not in route_methods:
|
|
1393
|
+
route_methods.append("HEAD")
|
|
1394
|
+
|
|
1185
1395
|
app.add_api_route(url_pattern, make_handler, methods=route_methods, name=endpoint)
|
|
1186
1396
|
|
|
1187
1397
|
|
|
@@ -1511,6 +1721,57 @@ def finalize_html(html_output: str) -> str:
|
|
|
1511
1721
|
register_routes()
|
|
1512
1722
|
register_rpc_routes(app)
|
|
1513
1723
|
|
|
1724
|
+
|
|
1725
|
+
SEO_FILES: dict[str, tuple[str, str]] = {
|
|
1726
|
+
SITEMAP_PATH: ("sitemap.py", "sitemap"),
|
|
1727
|
+
ROBOTS_PATH: ("robots.py", "robots"),
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
|
|
1731
|
+
def _make_seo_handler(url: str, filename: str, func_name: str):
|
|
1732
|
+
page_path = os.path.join("src", "app", filename)
|
|
1733
|
+
|
|
1734
|
+
async def seo_handler(request: Request):
|
|
1735
|
+
if not os.path.exists(page_path):
|
|
1736
|
+
raise HttpError(404)
|
|
1737
|
+
provider = getattr(load_route_module(page_path), func_name, None)
|
|
1738
|
+
if not callable(provider):
|
|
1739
|
+
raise HttpError.internal(f"{page_path} must define {func_name}()")
|
|
1740
|
+
value = await call_provider(provider, request)
|
|
1741
|
+
base_url = configured_base_url() or str(request.base_url).rstrip("/")
|
|
1742
|
+
if url == SITEMAP_PATH:
|
|
1743
|
+
return Response(render_sitemap(value or [], base_url), media_type="application/xml")
|
|
1744
|
+
return PlainTextResponse(render_robots(value, base_url))
|
|
1745
|
+
|
|
1746
|
+
return seo_handler
|
|
1747
|
+
|
|
1748
|
+
|
|
1749
|
+
def register_seo_routes() -> list[str]:
|
|
1750
|
+
"""Serve `src/app/sitemap.py` at /sitemap.xml and `src/app/robots.py` at /robots.txt."""
|
|
1751
|
+
registered: list[str] = []
|
|
1752
|
+
for url, (filename, func_name) in SEO_FILES.items():
|
|
1753
|
+
if not os.path.exists(os.path.join("src", "app", filename)):
|
|
1754
|
+
continue
|
|
1755
|
+
if (APP_ROOT / "public" / url.lstrip("/")).is_file():
|
|
1756
|
+
print(
|
|
1757
|
+
f"[caspian] WARNING: public{url} exists and is served before src/app/{filename}; "
|
|
1758
|
+
"remove one of them.",
|
|
1759
|
+
file=sys.stderr,
|
|
1760
|
+
flush=True,
|
|
1761
|
+
)
|
|
1762
|
+
app.add_api_route(
|
|
1763
|
+
url,
|
|
1764
|
+
_make_seo_handler(url, filename, func_name),
|
|
1765
|
+
methods=["GET"],
|
|
1766
|
+
include_in_schema=False,
|
|
1767
|
+
name=f"seo_{func_name}",
|
|
1768
|
+
)
|
|
1769
|
+
registered.append(url)
|
|
1770
|
+
return registered
|
|
1771
|
+
|
|
1772
|
+
|
|
1773
|
+
SEO_ROUTES = register_seo_routes()
|
|
1774
|
+
|
|
1514
1775
|
# Mount the FastMCP app at /mcp so the endpoint is exactly /mcp.
|
|
1515
1776
|
if mcp_app is not None:
|
|
1516
1777
|
# Starlette's automatic mount redirect builds an absolute URL from the
|
|
@@ -1573,14 +1834,8 @@ async def _render_special_page(
|
|
|
1573
1834
|
|
|
1574
1835
|
page_metadata = default_metadata.copy()
|
|
1575
1836
|
for metadata_obj in (getattr(module, "metadata", None), _runtime_metadata.get()):
|
|
1576
|
-
if
|
|
1577
|
-
|
|
1578
|
-
if metadata_obj.title:
|
|
1579
|
-
page_metadata["title"] = metadata_obj.title
|
|
1580
|
-
if metadata_obj.description:
|
|
1581
|
-
page_metadata["description"] = metadata_obj.description
|
|
1582
|
-
if metadata_obj.extra:
|
|
1583
|
-
page_metadata.update(metadata_obj.extra)
|
|
1837
|
+
if metadata_obj:
|
|
1838
|
+
page_metadata.update(metadata_obj.to_dict())
|
|
1584
1839
|
|
|
1585
1840
|
page_source = getattr(page_content, "source_path", page_path)
|
|
1586
1841
|
html_output, root_layout_id = await render_with_nested_layouts(
|
|
@@ -1596,11 +1851,13 @@ async def _render_special_page(
|
|
|
1596
1851
|
return finalize_html(html_output), root_layout_id
|
|
1597
1852
|
|
|
1598
1853
|
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1854
|
+
APP_ROOT_DIR = "src/app"
|
|
1855
|
+
|
|
1856
|
+
|
|
1857
|
+
async def _render_not_found_response(request: Request, headers: dict[str, str]) -> Response:
|
|
1858
|
+
not_found_path = os.path.join("src", "app", "not_found.py")
|
|
1859
|
+
if os.path.exists(not_found_path):
|
|
1860
|
+
try:
|
|
1604
1861
|
html_output, root_layout_id = await _render_special_page(
|
|
1605
1862
|
page_path=not_found_path,
|
|
1606
1863
|
request=request,
|
|
@@ -1610,52 +1867,137 @@ async def custom_404_handler(request: Request, exc: StarletteHTTPException):
|
|
|
1610
1867
|
},
|
|
1611
1868
|
context_data={},
|
|
1612
1869
|
)
|
|
1613
|
-
|
|
1870
|
+
except Exception as render_exc:
|
|
1871
|
+
print(f"[error-boundary] {not_found_path} failed to render: {render_exc!r}", flush=True)
|
|
1872
|
+
else:
|
|
1873
|
+
resp = HTMLResponse(content=html_output, status_code=404, headers=headers)
|
|
1614
1874
|
resp.headers["X-PP-Root-Layout"] = root_layout_id
|
|
1615
1875
|
return resp
|
|
1616
|
-
return HTMLResponse(content=
|
|
1876
|
+
return HTMLResponse(content="<h1>404 - Not Found</h1>", status_code=404, headers=headers)
|
|
1617
1877
|
|
|
1618
1878
|
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1879
|
+
def _log_error(request: Request, exc: BaseException, status: int, trace: str) -> None:
|
|
1880
|
+
if status == 404:
|
|
1881
|
+
return
|
|
1882
|
+
summary = f"{status} {request.method} {request.url.path}"
|
|
1883
|
+
if status >= 500:
|
|
1884
|
+
# Server-side diagnostic; the client response is redacted separately.
|
|
1885
|
+
record("error", "http", summary, exception=type(exc).__name__, trace=trace)
|
|
1886
|
+
elif not IS_PRODUCTION:
|
|
1887
|
+
record("warn", "http", f"{summary}: {exc}")
|
|
1888
|
+
|
|
1889
|
+
|
|
1890
|
+
async def render_error_response(
|
|
1891
|
+
request: Request,
|
|
1892
|
+
exc: BaseException,
|
|
1893
|
+
route_dir: str,
|
|
1894
|
+
*,
|
|
1895
|
+
is_page: bool = False,
|
|
1896
|
+
is_api: bool = False,
|
|
1897
|
+
) -> Response:
|
|
1898
|
+
"""Turn a failure into the response shape its caller expects.
|
|
1899
|
+
|
|
1900
|
+
- RPC calls keep the PulsePoint wire (`{"error", "requestId"}`).
|
|
1901
|
+
- `route.py` endpoints (`is_api`) always get `application/problem+json`, as
|
|
1902
|
+
do API clients of other non-page endpoints.
|
|
1903
|
+
- Pages (and browsers) get the nearest `error.py` boundary above
|
|
1904
|
+
`route_dir`, wrapped by the layouts at and above that boundary. A
|
|
1905
|
+
boundary that itself fails yields to the next ancestor boundary.
|
|
1906
|
+
- A 404 renders the global `not_found.py`.
|
|
1907
|
+
|
|
1908
|
+
Client-visible text is redacted in production by `casp.errors`, and every
|
|
1909
|
+
error response is `no-store` with the request id attached.
|
|
1910
|
+
"""
|
|
1911
|
+
trace = "".join(traceback.format_exception(exc))
|
|
1912
|
+
info = error_info(
|
|
1913
|
+
exc,
|
|
1914
|
+
is_production=IS_PRODUCTION,
|
|
1915
|
+
request_id=request_id_for(request),
|
|
1916
|
+
trace=trace,
|
|
1917
|
+
)
|
|
1918
|
+
_log_error(request, exc, info.status, trace)
|
|
1919
|
+
extra_headers = error_headers(exc)
|
|
1920
|
+
|
|
1921
|
+
if request.headers.get("X-PP-RPC") == "true":
|
|
1922
|
+
return rpc_error_response(info, headers=extra_headers)
|
|
1923
|
+
if is_api or (not is_page and not prefers_html(request)):
|
|
1924
|
+
return problem_response(info, headers=extra_headers)
|
|
1925
|
+
|
|
1926
|
+
headers = error_response_headers(info, extra_headers)
|
|
1927
|
+
if info.status == 404:
|
|
1928
|
+
return await _render_not_found_response(request, headers)
|
|
1929
|
+
|
|
1930
|
+
for boundary_path in find_error_boundaries(route_dir):
|
|
1633
1931
|
try:
|
|
1634
1932
|
html_output, root_layout_id = await _render_special_page(
|
|
1635
|
-
page_path=
|
|
1933
|
+
page_path=boundary_path,
|
|
1636
1934
|
request=request,
|
|
1637
1935
|
default_metadata={
|
|
1638
|
-
"title":
|
|
1639
|
-
"description": "An
|
|
1936
|
+
"title": info.reason,
|
|
1937
|
+
"description": "An error occurred while loading this page.",
|
|
1938
|
+
},
|
|
1939
|
+
context_data={
|
|
1940
|
+
"error": info,
|
|
1941
|
+
# Legacy `page(error_message, error_trace)` signature.
|
|
1942
|
+
"error_message": info.message,
|
|
1943
|
+
"error_trace": info.trace,
|
|
1640
1944
|
},
|
|
1641
|
-
context_data=context_data,
|
|
1642
1945
|
)
|
|
1643
|
-
resp = HTMLResponse(content=html_output, status_code=500)
|
|
1644
|
-
resp.headers["X-PP-Root-Layout"] = root_layout_id
|
|
1645
|
-
return resp
|
|
1646
1946
|
except Exception as render_exc:
|
|
1647
|
-
print("
|
|
1648
|
-
|
|
1649
|
-
content=
|
|
1947
|
+
print(f"[error-boundary] {boundary_path} failed to render: {render_exc!r}", flush=True)
|
|
1948
|
+
continue
|
|
1949
|
+
response = HTMLResponse(content=html_output, status_code=info.status, headers=headers)
|
|
1950
|
+
response.headers["X-PP-Root-Layout"] = root_layout_id
|
|
1951
|
+
return response
|
|
1952
|
+
|
|
1953
|
+
return fallback_html_response(info, headers=extra_headers)
|
|
1954
|
+
|
|
1955
|
+
|
|
1956
|
+
@app.exception_handler(StarletteHTTPException)
|
|
1957
|
+
async def custom_404_handler(request: Request, exc: StarletteHTTPException):
|
|
1958
|
+
# 404 stays an HTML page for every client, as it always has.
|
|
1959
|
+
return await render_error_response(request, exc, APP_ROOT_DIR, is_page=exc.status_code == 404)
|
|
1960
|
+
|
|
1961
|
+
|
|
1962
|
+
@app.exception_handler(Exception)
|
|
1963
|
+
async def custom_general_exception_handler(request: Request, exc: Exception):
|
|
1964
|
+
return await render_error_response(request, exc, APP_ROOT_DIR)
|
|
1965
|
+
|
|
1966
|
+
|
|
1967
|
+
async def render_maintenance_page(request: Request, state: MaintenanceState) -> Optional[Response]:
|
|
1968
|
+
"""Render `src/app/maintenance.py` for browsers while maintenance mode is on.
|
|
1969
|
+
|
|
1970
|
+
It runs outside the session and auth layers, so layouts that need a session
|
|
1971
|
+
fail here; the middleware then serves its built-in page instead.
|
|
1972
|
+
"""
|
|
1973
|
+
page_path = os.path.join("src", "app", "maintenance.py")
|
|
1974
|
+
if not os.path.exists(page_path):
|
|
1975
|
+
return None
|
|
1976
|
+
html_output, root_layout_id = await _render_special_page(
|
|
1977
|
+
page_path=page_path,
|
|
1978
|
+
request=request,
|
|
1979
|
+
default_metadata={"title": "Maintenance", "description": state.message},
|
|
1980
|
+
context_data={"maintenance": state},
|
|
1650
1981
|
)
|
|
1982
|
+
response = HTMLResponse(content=html_output)
|
|
1983
|
+
response.headers["X-PP-Root-Layout"] = root_layout_id
|
|
1984
|
+
return response
|
|
1651
1985
|
|
|
1652
1986
|
|
|
1653
1987
|
# ====
|
|
1654
1988
|
# Middleware Order (LAST added runs FIRST)
|
|
1655
1989
|
# ====
|
|
1656
1990
|
app.add_middleware(RPCMiddleware)
|
|
1991
|
+
# The application pipeline (`src/middleware.py`): after auth, so it can read the
|
|
1992
|
+
# session and is never reached by refused requests; around pages, `route.py`,
|
|
1993
|
+
# and RPC. It skips public files, WebSockets, /health, /ready, and /mcp.
|
|
1994
|
+
app.add_middleware(RequestPipelineMiddleware, entries=load_app_middleware())
|
|
1657
1995
|
app.add_middleware(AuthMiddleware)
|
|
1658
1996
|
app.add_middleware(CSRFMiddleware)
|
|
1997
|
+
# Locale resolution (no-op without src/locales or APP_LOCALES): inside the
|
|
1998
|
+
# session so a saved choice is readable, outside auth so route privacy and
|
|
1999
|
+
# routing both see the path with any `/es` prefix removed.
|
|
2000
|
+
app.add_middleware(LocaleMiddleware)
|
|
1659
2001
|
|
|
1660
2002
|
app.add_middleware(
|
|
1661
2003
|
SessionMiddleware,
|
|
@@ -1673,6 +2015,16 @@ app.add_middleware(MissingPublicAssetMiddleware)
|
|
|
1673
2015
|
# Outermost of the security layers: reject flooding before any session
|
|
1674
2016
|
# decryption, template rendering, or database work is spent on the request.
|
|
1675
2017
|
app.add_middleware(RateLimitMiddleware)
|
|
2018
|
+
# Maintenance mode sits inside public-file serving (stylesheets and images keep
|
|
2019
|
+
# loading for the maintenance page) and outside everything that does real work.
|
|
2020
|
+
app.add_middleware(
|
|
2021
|
+
MaintenanceMiddleware,
|
|
2022
|
+
render=render_maintenance_page,
|
|
2023
|
+
secure_cookie=IS_PRODUCTION,
|
|
2024
|
+
)
|
|
2025
|
+
# Request records and counters: inside public-file serving (assets are not
|
|
2026
|
+
# application traffic), outside maintenance and rate limiting so refusals count.
|
|
2027
|
+
app.add_middleware(RequestLogMiddleware)
|
|
1676
2028
|
# The public directory is itself the URL contract: any existing nested file is
|
|
1677
2029
|
# served without adding a directory-specific route above. Keep upload content
|
|
1678
2030
|
# in attachment mode unless its MIME type is explicitly safe to render inline.
|
|
@@ -1688,6 +2040,14 @@ app.add_middleware(SecurityHeadersMiddleware)
|
|
|
1688
2040
|
if not IS_PRODUCTION:
|
|
1689
2041
|
app.add_middleware(RequestDiagnosticsMiddleware)
|
|
1690
2042
|
|
|
2043
|
+
# Outermost: every response, including timeouts, rate-limit refusals, and error
|
|
2044
|
+
# pages, carries an `X-Request-Id`. A valid incoming id from a trusted gateway is
|
|
2045
|
+
# kept unless REQUEST_ID_TRUST_INCOMING=false.
|
|
2046
|
+
app.add_middleware(
|
|
2047
|
+
RequestIdMiddleware,
|
|
2048
|
+
accept_incoming=_bool_env("REQUEST_ID_TRUST_INCOMING", True),
|
|
2049
|
+
)
|
|
2050
|
+
|
|
1691
2051
|
|
|
1692
2052
|
def _consume_dev_control_stream(
|
|
1693
2053
|
server: uvicorn.Server,
|