create-caspian-app 1.3.6 → 1.3.7
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 +5 -2
- package/dist/AGENTS.md +5 -1
- package/dist/index.js +1 -1
- package/dist/main.py +152 -156
- package/dist/public/js/pp-reactive-v2.min.js +1 -1
- package/dist/settings/_component_imports.py +1 -0
- package/dist/settings/_markup_equivalence.py +320 -0
- package/dist/settings/browser_log.py +13 -6
- package/dist/settings/build-static.py +5 -4
- package/dist/settings/check.py +6 -3
- package/dist/settings/check_templates.py +90 -21
- package/dist/settings/fix.py +23 -5
- package/dist/settings/format.py +721 -0
- package/dist/settings/serve-static.py +1 -2
- package/dist/src/app/index.py +67 -133
- package/dist/src/app/layout.py +15 -0
- package/dist/tests/README.md +119 -4
- package/dist/tests/conftest.py +1 -0
- package/dist/tests/test_health_route.py +1 -3
- package/dist/tests/test_main_helpers.py +4 -7
- package/package.json +1 -1
package/dist/main.py
CHANGED
|
@@ -130,12 +130,16 @@ def _build_mcp_cors_middleware() -> "Middleware":
|
|
|
130
130
|
origins = ["*"]
|
|
131
131
|
allow_credentials = False
|
|
132
132
|
|
|
133
|
-
methods = _csv_env("CORS_ALLOWED_METHODS") or [
|
|
134
|
-
"GET", "POST", "DELETE", "OPTIONS"]
|
|
133
|
+
methods = _csv_env("CORS_ALLOWED_METHODS") or ["GET", "POST", "DELETE", "OPTIONS"]
|
|
135
134
|
|
|
136
135
|
headers = _csv_env("CORS_ALLOWED_HEADERS")
|
|
137
|
-
for required in (
|
|
138
|
-
|
|
136
|
+
for required in (
|
|
137
|
+
"Content-Type",
|
|
138
|
+
"Accept",
|
|
139
|
+
"Authorization",
|
|
140
|
+
"mcp-session-id",
|
|
141
|
+
"mcp-protocol-version",
|
|
142
|
+
):
|
|
139
143
|
if required.lower() not in {h.lower() for h in headers}:
|
|
140
144
|
headers.append(required)
|
|
141
145
|
|
|
@@ -233,6 +237,7 @@ if cfg.mcp:
|
|
|
233
237
|
# Optional, feature-gated module: only generated when mcp is enabled in
|
|
234
238
|
# caspian.config.json, so suppress the static "module not found" check.
|
|
235
239
|
from src.lib.mcp.mcp_server import mcp # type: ignore[import-not-found]
|
|
240
|
+
|
|
236
241
|
# Inner path "/" so the mount prefix below is the full endpoint path.
|
|
237
242
|
# CORS is outermost so preflight is answered before the token check.
|
|
238
243
|
mcp_app = mcp.http_app(
|
|
@@ -312,6 +317,7 @@ async def combined_lifespan(app: FastAPI):
|
|
|
312
317
|
|
|
313
318
|
yield
|
|
314
319
|
|
|
320
|
+
|
|
315
321
|
app = FastAPI(
|
|
316
322
|
title=cfg.projectName,
|
|
317
323
|
version=cfg.version,
|
|
@@ -326,22 +332,23 @@ app = FastAPI(
|
|
|
326
332
|
async def healthcheck():
|
|
327
333
|
return {"status": "ok"}
|
|
328
334
|
|
|
335
|
+
|
|
329
336
|
# ====
|
|
330
337
|
# Configuration
|
|
331
338
|
# ====
|
|
332
|
-
SESSION_LIFETIME_HOURS = int(os.getenv(
|
|
333
|
-
MAX_CONTENT_LENGTH_MB = int(os.getenv(
|
|
334
|
-
CACHE_ENABLED = os.getenv(
|
|
335
|
-
DEFAULT_TTL = int(os.getenv(
|
|
339
|
+
SESSION_LIFETIME_HOURS = int(os.getenv("SESSION_LIFETIME_HOURS", 7))
|
|
340
|
+
MAX_CONTENT_LENGTH_MB = int(os.getenv("MAX_CONTENT_LENGTH_MB", 16))
|
|
341
|
+
CACHE_ENABLED = os.getenv("CACHE_ENABLED", "false").lower() == "true"
|
|
342
|
+
DEFAULT_TTL = int(os.getenv("CACHE_TTL", 600))
|
|
336
343
|
REQUEST_TIMEOUT_SECONDS = max(
|
|
337
344
|
1.0,
|
|
338
|
-
float(os.getenv(
|
|
345
|
+
float(os.getenv("CASPIAN_REQUEST_TIMEOUT_SECONDS", 20)),
|
|
339
346
|
)
|
|
340
347
|
# Path prefixes that serve long-lived streaming responses (SSE, etc.) and must
|
|
341
348
|
# not be subject to the per-request timeout. The MCP streamable-HTTP transport
|
|
342
349
|
# keeps GET /mcp/ open indefinitely; wrapping it in asyncio.wait_for cancels the
|
|
343
350
|
# stream mid-response and corrupts the ASGI message sequence.
|
|
344
|
-
STREAMING_PATH_PREFIXES = (
|
|
351
|
+
STREAMING_PATH_PREFIXES = ("/mcp",)
|
|
345
352
|
# Public assets: exempt from auth, request logging, and rate limiting, since one
|
|
346
353
|
# page load pulls many of them.
|
|
347
354
|
MAX_CONTENT_LENGTH_BYTES = max(1, MAX_CONTENT_LENGTH_MB) * 1024 * 1024
|
|
@@ -365,9 +372,7 @@ def _build_security_headers() -> dict[str, str]:
|
|
|
365
372
|
|
|
366
373
|
# The header set depends only on IS_PRODUCTION, so build it once at import time
|
|
367
374
|
# instead of allocating an identical dict on every single response.
|
|
368
|
-
SECURITY_HEADERS: tuple[tuple[str, str], ...] = tuple(
|
|
369
|
-
_build_security_headers().items()
|
|
370
|
-
)
|
|
375
|
+
SECURITY_HEADERS: tuple[tuple[str, str], ...] = tuple(_build_security_headers().items())
|
|
371
376
|
|
|
372
377
|
|
|
373
378
|
def _dev_cookie_scope() -> str:
|
|
@@ -382,15 +387,13 @@ def _dev_cookie_scope() -> str:
|
|
|
382
387
|
bs_config_path = Path("settings/bs-config.json")
|
|
383
388
|
if bs_config_path.exists():
|
|
384
389
|
try:
|
|
385
|
-
local_url = json.loads(
|
|
386
|
-
bs_config_path.read_text(encoding="utf-8")
|
|
387
|
-
).get("local", "")
|
|
390
|
+
local_url = json.loads(bs_config_path.read_text(encoding="utf-8")).get("local", "")
|
|
388
391
|
parsed_url = urlparse(local_url)
|
|
389
392
|
if parsed_url.hostname in {"localhost", "127.0.0.1"}:
|
|
390
393
|
scope = str(parsed_url.port or "")
|
|
391
394
|
else:
|
|
392
395
|
scope = ""
|
|
393
|
-
except
|
|
396
|
+
except OSError, json.JSONDecodeError:
|
|
394
397
|
scope = ""
|
|
395
398
|
|
|
396
399
|
return scope if scope and scope.isdigit() else ""
|
|
@@ -402,9 +405,7 @@ def _scoped_cookie_name(base_name: str) -> str:
|
|
|
402
405
|
|
|
403
406
|
|
|
404
407
|
CSRF_COOKIE_NAME = _scoped_cookie_name("pp_csrf")
|
|
405
|
-
SESSION_COOKIE_NAME = _scoped_cookie_name(
|
|
406
|
-
os.getenv('AUTH_COOKIE_NAME', 'session')
|
|
407
|
-
)
|
|
408
|
+
SESSION_COOKIE_NAME = _scoped_cookie_name(os.getenv("AUTH_COOKIE_NAME", "session"))
|
|
408
409
|
|
|
409
410
|
# ====
|
|
410
411
|
# Pure ASGI Middleware Classes
|
|
@@ -414,7 +415,8 @@ SESSION_COOKIE_NAME = _scoped_cookie_name(
|
|
|
414
415
|
class CSRFMiddleware:
|
|
415
416
|
"""CSRF middleware that properly handles session modifications."""
|
|
416
417
|
|
|
417
|
-
def __init__(self, app: ASGIApp):
|
|
418
|
+
def __init__(self, app: ASGIApp):
|
|
419
|
+
self.app = app
|
|
418
420
|
|
|
419
421
|
async def __call__(self, scope: Scope, receive: Receive, send: Send):
|
|
420
422
|
if scope["type"] != "http":
|
|
@@ -436,13 +438,15 @@ class CSRFMiddleware:
|
|
|
436
438
|
new_headers.append((b"set-cookie", cookie_value.encode()))
|
|
437
439
|
message = {**message, "headers": new_headers}
|
|
438
440
|
await send(message)
|
|
441
|
+
|
|
439
442
|
await self.app(scope, receive, send_wrapper)
|
|
440
443
|
|
|
441
444
|
|
|
442
445
|
class SecurityHeadersMiddleware:
|
|
443
446
|
"""Attach baseline browser security headers to HTTP responses."""
|
|
444
447
|
|
|
445
|
-
def __init__(self, app: ASGIApp):
|
|
448
|
+
def __init__(self, app: ASGIApp):
|
|
449
|
+
self.app = app
|
|
446
450
|
|
|
447
451
|
async def __call__(self, scope: Scope, receive: Receive, send: Send):
|
|
448
452
|
if scope["type"] != "http":
|
|
@@ -465,7 +469,8 @@ class SecurityHeadersMiddleware:
|
|
|
465
469
|
class BodySizeLimitMiddleware:
|
|
466
470
|
"""Reject oversized HTTP request bodies before route or RPC parsing."""
|
|
467
471
|
|
|
468
|
-
def __init__(self, app: ASGIApp):
|
|
472
|
+
def __init__(self, app: ASGIApp):
|
|
473
|
+
self.app = app
|
|
469
474
|
|
|
470
475
|
async def __call__(self, scope: Scope, receive: Receive, send: Send):
|
|
471
476
|
if scope["type"] != "http":
|
|
@@ -526,7 +531,8 @@ class BodySizeLimitMiddleware:
|
|
|
526
531
|
class AuthMiddleware:
|
|
527
532
|
"""Auth middleware using pure ASGI pattern for proper session handling."""
|
|
528
533
|
|
|
529
|
-
def __init__(self, app: ASGIApp):
|
|
534
|
+
def __init__(self, app: ASGIApp):
|
|
535
|
+
self.app = app
|
|
530
536
|
|
|
531
537
|
async def __call__(self, scope: Scope, receive: Receive, send: Send):
|
|
532
538
|
if scope["type"] != "http":
|
|
@@ -553,8 +559,7 @@ class AuthMiddleware:
|
|
|
553
559
|
if auth_inst.is_auth_route(path):
|
|
554
560
|
if is_authenticated:
|
|
555
561
|
await RedirectResponse(
|
|
556
|
-
url=auth_inst.settings.default_signin_redirect,
|
|
557
|
-
status_code=303
|
|
562
|
+
url=auth_inst.settings.default_signin_redirect, status_code=303
|
|
558
563
|
)(scope, receive, send)
|
|
559
564
|
return
|
|
560
565
|
await self.app(scope, receive, send)
|
|
@@ -570,7 +575,9 @@ class AuthMiddleware:
|
|
|
570
575
|
)(scope, receive, send)
|
|
571
576
|
return
|
|
572
577
|
if not auth_inst.check_role(auth_inst.get_payload(), required_roles):
|
|
573
|
-
await RedirectResponse(url=
|
|
578
|
+
await RedirectResponse(url="/unauthorized", status_code=303)(
|
|
579
|
+
scope, receive, send
|
|
580
|
+
)
|
|
574
581
|
return
|
|
575
582
|
|
|
576
583
|
if auth_inst.is_private_route(path):
|
|
@@ -587,24 +594,25 @@ class AuthMiddleware:
|
|
|
587
594
|
class RPCMiddleware:
|
|
588
595
|
"""RPC middleware using pure ASGI pattern."""
|
|
589
596
|
|
|
590
|
-
def __init__(self, app: ASGIApp):
|
|
597
|
+
def __init__(self, app: ASGIApp):
|
|
598
|
+
self.app = app
|
|
591
599
|
|
|
592
600
|
async def __call__(self, scope: Scope, receive: Receive, send: Send):
|
|
593
601
|
if scope["type"] != "http":
|
|
594
602
|
await self.app(scope, receive, send)
|
|
595
603
|
return
|
|
596
604
|
request = Request(scope, receive, send)
|
|
597
|
-
if request.headers.get(
|
|
605
|
+
if request.headers.get("X-PP-RPC") == "true" and request.method == "POST":
|
|
598
606
|
from casp.rpc import _handle_rpc_request
|
|
599
|
-
|
|
600
|
-
|
|
607
|
+
|
|
608
|
+
session = dict(request.session) if hasattr(request, "session") else {}
|
|
601
609
|
response = await _handle_rpc_request(request, session)
|
|
602
610
|
await response(scope, receive, send)
|
|
603
611
|
return
|
|
604
612
|
await self.app(scope, receive, send)
|
|
605
613
|
|
|
606
614
|
|
|
607
|
-
RATE_LIMIT_PAGES = os.getenv(
|
|
615
|
+
RATE_LIMIT_PAGES = os.getenv("RATE_LIMIT_PAGES", "200/minute")
|
|
608
616
|
|
|
609
617
|
|
|
610
618
|
def client_ip(request: Request) -> str:
|
|
@@ -616,13 +624,13 @@ def client_ip(request: Request) -> str:
|
|
|
616
624
|
`TRUST_FORWARDED_HEADERS`, because a client can otherwise set that header
|
|
617
625
|
itself and mint a fresh bucket per request.
|
|
618
626
|
"""
|
|
619
|
-
if _bool_env(
|
|
620
|
-
forwarded = request.headers.get(
|
|
621
|
-
first_hop = forwarded.split(
|
|
627
|
+
if _bool_env("TRUST_FORWARDED_HEADERS"):
|
|
628
|
+
forwarded = request.headers.get("x-forwarded-for", "")
|
|
629
|
+
first_hop = forwarded.split(",", 1)[0].strip()
|
|
622
630
|
if first_hop:
|
|
623
631
|
return first_hop
|
|
624
632
|
|
|
625
|
-
return request.client.host if request.client else
|
|
633
|
+
return request.client.host if request.client else "unknown"
|
|
626
634
|
|
|
627
635
|
|
|
628
636
|
class RateLimitMiddleware:
|
|
@@ -648,20 +656,17 @@ class RateLimitMiddleware:
|
|
|
648
656
|
return
|
|
649
657
|
|
|
650
658
|
path = scope.get("path", "")
|
|
651
|
-
if path ==
|
|
659
|
+
if path == "/health":
|
|
652
660
|
await self.app(scope, receive, send)
|
|
653
661
|
return
|
|
654
662
|
|
|
655
663
|
request = Request(scope, receive, send)
|
|
656
|
-
allowed, wait_seconds = rpc_limiter.check(
|
|
657
|
-
'__page__', client_ip(request), RATE_LIMIT_PAGES
|
|
658
|
-
)
|
|
664
|
+
allowed, wait_seconds = rpc_limiter.check("__page__", client_ip(request), RATE_LIMIT_PAGES)
|
|
659
665
|
|
|
660
666
|
if not allowed:
|
|
661
667
|
response = HTMLResponse(
|
|
662
668
|
content=(
|
|
663
|
-
"<h1>429 - Too Many Requests</h1>"
|
|
664
|
-
"<p>Please slow down and try again shortly.</p>"
|
|
669
|
+
"<h1>429 - Too Many Requests</h1><p>Please slow down and try again shortly.</p>"
|
|
665
670
|
),
|
|
666
671
|
status_code=429,
|
|
667
672
|
headers={"Retry-After": str(math.ceil(wait_seconds))},
|
|
@@ -675,7 +680,8 @@ class RateLimitMiddleware:
|
|
|
675
680
|
class RequestDiagnosticsMiddleware:
|
|
676
681
|
"""Log request start/end in dev and fail visibly when a route stalls."""
|
|
677
682
|
|
|
678
|
-
def __init__(self, app: ASGIApp):
|
|
683
|
+
def __init__(self, app: ASGIApp):
|
|
684
|
+
self.app = app
|
|
679
685
|
|
|
680
686
|
async def __call__(self, scope: Scope, receive: Receive, send: Send):
|
|
681
687
|
if scope["type"] != "http":
|
|
@@ -687,9 +693,10 @@ class RequestDiagnosticsMiddleware:
|
|
|
687
693
|
is_public_file = (
|
|
688
694
|
method in {"GET", "HEAD"}
|
|
689
695
|
and resolve_safe_public_path(
|
|
690
|
-
|
|
691
|
-
path.lstrip(
|
|
692
|
-
)
|
|
696
|
+
"public",
|
|
697
|
+
path.lstrip("/"),
|
|
698
|
+
)
|
|
699
|
+
is not None
|
|
693
700
|
)
|
|
694
701
|
should_log = not is_public_file
|
|
695
702
|
started = time.perf_counter()
|
|
@@ -728,14 +735,12 @@ class RequestDiagnosticsMiddleware:
|
|
|
728
735
|
except Exception:
|
|
729
736
|
if should_log and not IS_PRODUCTION:
|
|
730
737
|
elapsed_ms = int((time.perf_counter() - started) * 1000)
|
|
731
|
-
print(
|
|
732
|
-
f"[request:error] {method} {path} after {elapsed_ms}ms", flush=True)
|
|
738
|
+
print(f"[request:error] {method} {path} after {elapsed_ms}ms", flush=True)
|
|
733
739
|
raise
|
|
734
740
|
finally:
|
|
735
741
|
if should_log and not IS_PRODUCTION:
|
|
736
742
|
elapsed_ms = int((time.perf_counter() - started) * 1000)
|
|
737
|
-
print(
|
|
738
|
-
f"[request:end] {method} {path} {elapsed_ms}ms", flush=True)
|
|
743
|
+
print(f"[request:end] {method} {path} {elapsed_ms}ms", flush=True)
|
|
739
744
|
|
|
740
745
|
|
|
741
746
|
# ====
|
|
@@ -885,7 +890,7 @@ def is_request_cacheable(request: Request) -> bool:
|
|
|
885
890
|
know whether the page it just rendered contains per-user data, so the
|
|
886
891
|
presence of an authenticated session is the safe signal.
|
|
887
892
|
"""
|
|
888
|
-
if request.method !=
|
|
893
|
+
if request.method != "GET":
|
|
889
894
|
return False
|
|
890
895
|
|
|
891
896
|
try:
|
|
@@ -899,7 +904,7 @@ def register_routes():
|
|
|
899
904
|
idx = get_files_index()
|
|
900
905
|
for route in idx.routes:
|
|
901
906
|
base_path = f"src/app/{route.fs_dir}" if route.fs_dir else "src/app"
|
|
902
|
-
full_path = f"{base_path}/index.py".replace(
|
|
907
|
+
full_path = f"{base_path}/index.py".replace("//", "/")
|
|
903
908
|
register_single_route(route.fastapi_rule, full_path)
|
|
904
909
|
|
|
905
910
|
|
|
@@ -929,7 +934,7 @@ def register_single_route(url_pattern: str, file_path: str):
|
|
|
929
934
|
page_content_source = file_path
|
|
930
935
|
|
|
931
936
|
module = load_route_module(file_path)
|
|
932
|
-
if not hasattr(module,
|
|
937
|
+
if not hasattr(module, "page"):
|
|
933
938
|
raise AttributeError(f"Missing 'def page():' in {file_path}")
|
|
934
939
|
|
|
935
940
|
sig = get_page_signature(file_path, module.page)
|
|
@@ -938,8 +943,8 @@ def register_single_route(url_pattern: str, file_path: str):
|
|
|
938
943
|
|
|
939
944
|
if kwargs:
|
|
940
945
|
call_args.append(kwargs)
|
|
941
|
-
if
|
|
942
|
-
call_kwargs[
|
|
946
|
+
if "request" in sig.parameters:
|
|
947
|
+
call_kwargs["request"] = request
|
|
943
948
|
|
|
944
949
|
for name, param in sig.parameters.items():
|
|
945
950
|
if name in call_kwargs:
|
|
@@ -949,8 +954,7 @@ def register_single_route(url_pattern: str, file_path: str):
|
|
|
949
954
|
if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
|
|
950
955
|
continue
|
|
951
956
|
if name in request.query_params:
|
|
952
|
-
call_kwargs[name] = _coerce_query_param(
|
|
953
|
-
request, name, param)
|
|
957
|
+
call_kwargs[name] = _coerce_query_param(request, name, param)
|
|
954
958
|
|
|
955
959
|
if inspect.iscoroutinefunction(module.page):
|
|
956
960
|
result = await module.page(*call_args, **call_kwargs)
|
|
@@ -963,7 +967,7 @@ def register_single_route(url_pattern: str, file_path: str):
|
|
|
963
967
|
if inspect.isasyncgen(result) or inspect.isgenerator(result):
|
|
964
968
|
return SSE(cast("AsyncGenerator | Generator", result))
|
|
965
969
|
|
|
966
|
-
cache_settings = getattr(module,
|
|
970
|
+
cache_settings = getattr(module, "cache_settings", None)
|
|
967
971
|
if cache_settings:
|
|
968
972
|
req_should_cache = cache_settings.enabled
|
|
969
973
|
req_cache_ttl = cache_settings.ttl
|
|
@@ -971,25 +975,24 @@ def register_single_route(url_pattern: str, file_path: str):
|
|
|
971
975
|
if isinstance(result, tuple):
|
|
972
976
|
page_content = result[0]
|
|
973
977
|
content = str(page_content)
|
|
974
|
-
page_content_source = getattr(
|
|
975
|
-
page_content, 'source_path', file_path)
|
|
978
|
+
page_content_source = getattr(page_content, "source_path", file_path)
|
|
976
979
|
if len(result) >= 2 and isinstance(result[1], dict):
|
|
977
980
|
page_layout_props = result[1]
|
|
978
981
|
else:
|
|
979
982
|
content = str(result)
|
|
980
|
-
page_content_source = getattr(result,
|
|
983
|
+
page_content_source = getattr(result, "source_path", file_path)
|
|
981
984
|
|
|
982
985
|
dynamic_meta = _runtime_metadata.get()
|
|
983
|
-
static_meta = getattr(module,
|
|
986
|
+
static_meta = getattr(module, "metadata", None)
|
|
984
987
|
|
|
985
988
|
def extract_meta(obj):
|
|
986
989
|
d = {}
|
|
987
990
|
if not obj:
|
|
988
991
|
return d
|
|
989
992
|
if obj.title:
|
|
990
|
-
d[
|
|
993
|
+
d["title"] = obj.title
|
|
991
994
|
if obj.description:
|
|
992
|
-
d[
|
|
995
|
+
d["description"] = obj.description
|
|
993
996
|
if obj.extra:
|
|
994
997
|
d.update(obj.extra)
|
|
995
998
|
return d
|
|
@@ -1007,12 +1010,12 @@ def register_single_route(url_pattern: str, file_path: str):
|
|
|
1007
1010
|
context_data=full_context,
|
|
1008
1011
|
page_component_source=page_content_source,
|
|
1009
1012
|
control_mode=True,
|
|
1010
|
-
component_compiler=transform_components
|
|
1013
|
+
component_compiler=transform_components,
|
|
1011
1014
|
)
|
|
1012
1015
|
|
|
1013
1016
|
html_output = finalize_html(html_output)
|
|
1014
1017
|
response = HTMLResponse(content=html_output)
|
|
1015
|
-
response.headers[
|
|
1018
|
+
response.headers["X-PP-Root-Layout"] = root_layout_id
|
|
1016
1019
|
|
|
1017
1020
|
# Cache Save Logic
|
|
1018
1021
|
should_cache = False
|
|
@@ -1032,23 +1035,27 @@ def register_single_route(url_pattern: str, file_path: str):
|
|
|
1032
1035
|
|
|
1033
1036
|
return response
|
|
1034
1037
|
|
|
1035
|
-
endpoint =
|
|
1036
|
-
|
|
1038
|
+
endpoint = (
|
|
1039
|
+
file_path.replace("/", "_")
|
|
1040
|
+
.replace("\\", "_")
|
|
1041
|
+
.replace(".", "_")
|
|
1042
|
+
.replace("[", "")
|
|
1043
|
+
.replace("]", "")
|
|
1044
|
+
.replace("(", "")
|
|
1045
|
+
.replace(")", "")
|
|
1046
|
+
)
|
|
1037
1047
|
|
|
1038
|
-
route_methods = [
|
|
1048
|
+
route_methods = ["GET", "POST"]
|
|
1039
1049
|
module = load_route_module(file_path)
|
|
1040
|
-
declared_route_methods = getattr(module,
|
|
1050
|
+
declared_route_methods = getattr(module, "route_methods", None)
|
|
1041
1051
|
if isinstance(declared_route_methods, (list, tuple)) and declared_route_methods:
|
|
1042
1052
|
normalized_methods = [
|
|
1043
|
-
str(method).strip().upper()
|
|
1044
|
-
for method in declared_route_methods
|
|
1045
|
-
if str(method).strip()
|
|
1053
|
+
str(method).strip().upper() for method in declared_route_methods if str(method).strip()
|
|
1046
1054
|
]
|
|
1047
1055
|
if normalized_methods:
|
|
1048
1056
|
route_methods = list(dict.fromkeys(normalized_methods))
|
|
1049
1057
|
|
|
1050
|
-
app.add_api_route(url_pattern, make_handler,
|
|
1051
|
-
methods=route_methods, name=endpoint)
|
|
1058
|
+
app.add_api_route(url_pattern, make_handler, methods=route_methods, name=endpoint)
|
|
1052
1059
|
|
|
1053
1060
|
|
|
1054
1061
|
def defer_component_roots(html_output: str) -> str:
|
|
@@ -1068,7 +1075,7 @@ def defer_component_roots(html_output: str) -> str:
|
|
|
1068
1075
|
outer template is materialized, so morphing and RPC re-render still operate
|
|
1069
1076
|
on live ``[pp-component]`` DOM.
|
|
1070
1077
|
"""
|
|
1071
|
-
if
|
|
1078
|
+
if "pp-component" not in html_output:
|
|
1072
1079
|
return html_output
|
|
1073
1080
|
|
|
1074
1081
|
# Fast path: the render pipeline recorded the page subtree's exact
|
|
@@ -1079,11 +1086,7 @@ def defer_component_roots(html_output: str) -> str:
|
|
|
1079
1086
|
# the region string-level. Every mismatch falls back to the full parse.
|
|
1080
1087
|
if not _DEFER_FAST_DISABLED:
|
|
1081
1088
|
region = _finalize_page_region.get()
|
|
1082
|
-
if (
|
|
1083
|
-
region
|
|
1084
|
-
and len(region) >= _DEFER_REGION_MIN_BYTES
|
|
1085
|
-
and html_output.count(region) == 1
|
|
1086
|
-
):
|
|
1089
|
+
if region and len(region) >= _DEFER_REGION_MIN_BYTES and html_output.count(region) == 1:
|
|
1087
1090
|
deferred = _defer_with_verbatim_region(html_output, region)
|
|
1088
1091
|
if deferred is not None:
|
|
1089
1092
|
return deferred
|
|
@@ -1093,8 +1096,12 @@ def defer_component_roots(html_output: str) -> str:
|
|
|
1093
1096
|
return _defer_component_roots_in_soup(soup, placeholders, html_output)
|
|
1094
1097
|
|
|
1095
1098
|
|
|
1096
|
-
_DEFER_FAST_DISABLED = os.getenv(
|
|
1097
|
-
|
|
1099
|
+
_DEFER_FAST_DISABLED = os.getenv("CASP_DEFER_FAST", "").strip().lower() in {
|
|
1100
|
+
"0",
|
|
1101
|
+
"off",
|
|
1102
|
+
"false",
|
|
1103
|
+
"no",
|
|
1104
|
+
}
|
|
1098
1105
|
# Below this, masking the region saves less than the two extra scans it costs.
|
|
1099
1106
|
_DEFER_REGION_MIN_BYTES = 4096
|
|
1100
1107
|
|
|
@@ -1111,8 +1118,7 @@ def _protect_region_brace_entities(value: str) -> str:
|
|
|
1111
1118
|
"""
|
|
1112
1119
|
from casp.html_native import _ESCAPED_BRACE_ENTITY_RE
|
|
1113
1120
|
|
|
1114
|
-
return _ESCAPED_BRACE_ENTITY_RE.sub(
|
|
1115
|
-
lambda match: '&' + match.group(0)[1:], value)
|
|
1121
|
+
return _ESCAPED_BRACE_ENTITY_RE.sub(lambda match: "&" + match.group(0)[1:], value)
|
|
1116
1122
|
|
|
1117
1123
|
|
|
1118
1124
|
def _defer_with_verbatim_region(html_output: str, region: str) -> Optional[str]:
|
|
@@ -1130,10 +1136,9 @@ def _defer_with_verbatim_region(html_output: str, region: str) -> Optional[str]:
|
|
|
1130
1136
|
# stripped core.
|
|
1131
1137
|
region_root_key = None
|
|
1132
1138
|
region_core = region.strip()
|
|
1133
|
-
open_tag_end = region_core.find(
|
|
1134
|
-
if region_core.startswith(
|
|
1135
|
-
key_match = re.search(
|
|
1136
|
-
r'\spp-component="([^"]+)"', region_core[:open_tag_end + 1])
|
|
1139
|
+
open_tag_end = region_core.find(">")
|
|
1140
|
+
if region_core.startswith("<") and open_tag_end > 0:
|
|
1141
|
+
key_match = re.search(r'\spp-component="([^"]+)"', region_core[: open_tag_end + 1])
|
|
1137
1142
|
if key_match:
|
|
1138
1143
|
region_root_key = key_match.group(1)
|
|
1139
1144
|
|
|
@@ -1160,51 +1165,47 @@ def _defer_with_verbatim_region(html_output: str, region: str) -> Optional[str]:
|
|
|
1160
1165
|
# template (the outermost one gets wrapped below, or already is one), so
|
|
1161
1166
|
# its entities need the protection layer but no wrapper of its own.
|
|
1162
1167
|
region_enclosed = any(
|
|
1163
|
-
isinstance(parent, Tag) and parent.has_attr(
|
|
1164
|
-
for parent in token_node.parents
|
|
1168
|
+
isinstance(parent, Tag) and parent.has_attr("pp-component") for parent in token_node.parents
|
|
1165
1169
|
)
|
|
1166
1170
|
|
|
1167
|
-
if not region_enclosed and region_root_key is None and
|
|
1171
|
+
if not region_enclosed and region_root_key is None and "pp-component" in region:
|
|
1168
1172
|
# Boundaries live inside the region but its root is not one: they
|
|
1169
1173
|
# would need wrapping at arbitrary depth, which only the tree pass can
|
|
1170
1174
|
# locate.
|
|
1171
1175
|
return None
|
|
1172
1176
|
|
|
1173
1177
|
roots = []
|
|
1174
|
-
stack = [
|
|
1175
|
-
child for child in reversed(body.contents) if isinstance(child, Tag)
|
|
1176
|
-
]
|
|
1178
|
+
stack = [child for child in reversed(body.contents) if isinstance(child, Tag)]
|
|
1177
1179
|
while stack:
|
|
1178
1180
|
el = stack.pop()
|
|
1179
|
-
if el.has_attr(
|
|
1180
|
-
if el.name !=
|
|
1181
|
+
if el.has_attr("pp-component"):
|
|
1182
|
+
if el.name != "template":
|
|
1181
1183
|
roots.append(el)
|
|
1182
1184
|
continue
|
|
1183
|
-
stack.extend(
|
|
1184
|
-
child for child in reversed(el.contents) if isinstance(child, Tag)
|
|
1185
|
-
)
|
|
1185
|
+
stack.extend(child for child in reversed(el.contents) if isinstance(child, Tag))
|
|
1186
1186
|
|
|
1187
1187
|
for root in roots:
|
|
1188
|
-
key = root.get(
|
|
1188
|
+
key = root.get("pp-component")
|
|
1189
1189
|
if key is None:
|
|
1190
1190
|
continue
|
|
1191
|
-
template = soup.new_tag(
|
|
1192
|
-
template[
|
|
1191
|
+
template = soup.new_tag("template")
|
|
1192
|
+
template["pp-component"] = key
|
|
1193
1193
|
root.insert_before(template)
|
|
1194
1194
|
template.append(root.extract())
|
|
1195
1195
|
|
|
1196
1196
|
if placeholders:
|
|
1197
|
+
|
|
1197
1198
|
def protect_brace_entities(value: str) -> str:
|
|
1198
1199
|
return _ESCAPED_BRACE_PLACEHOLDER_RE.sub(
|
|
1199
1200
|
lambda match: placeholders.get(match.group(0), match.group(0)),
|
|
1200
1201
|
value,
|
|
1201
1202
|
)
|
|
1202
1203
|
|
|
1203
|
-
for template in body.select(
|
|
1204
|
+
for template in body.select("template[pp-component]"):
|
|
1204
1205
|
for node in list(template.descendants):
|
|
1205
1206
|
if isinstance(node, NavigableString):
|
|
1206
1207
|
original = str(node)
|
|
1207
|
-
if
|
|
1208
|
+
if "__PP_ESCAPED_BRACE_" not in original:
|
|
1208
1209
|
continue
|
|
1209
1210
|
content = protect_brace_entities(original)
|
|
1210
1211
|
if content != original:
|
|
@@ -1212,18 +1213,16 @@ def _defer_with_verbatim_region(html_output: str, region: str) -> Optional[str]:
|
|
|
1212
1213
|
elif isinstance(node, Tag):
|
|
1213
1214
|
for name, value in node.attrs.items():
|
|
1214
1215
|
if isinstance(value, str):
|
|
1215
|
-
if
|
|
1216
|
-
node.attrs[name] = protect_brace_entities(
|
|
1217
|
-
value)
|
|
1216
|
+
if "__PP_ESCAPED_BRACE_" in value:
|
|
1217
|
+
node.attrs[name] = protect_brace_entities(value)
|
|
1218
1218
|
elif isinstance(value, list):
|
|
1219
1219
|
for index, item in enumerate(value):
|
|
1220
1220
|
item = str(item)
|
|
1221
|
-
if
|
|
1221
|
+
if "__PP_ESCAPED_BRACE_" in item:
|
|
1222
1222
|
item = protect_brace_entities(item)
|
|
1223
1223
|
value[index] = item
|
|
1224
1224
|
|
|
1225
|
-
serialized = restore_escaped_brace_entities(
|
|
1226
|
-
serialize_fragment(soup), placeholders)
|
|
1225
|
+
serialized = restore_escaped_brace_entities(serialize_fragment(soup), placeholders)
|
|
1227
1226
|
if token not in serialized:
|
|
1228
1227
|
return None
|
|
1229
1228
|
|
|
@@ -1235,15 +1234,15 @@ def _defer_with_verbatim_region(html_output: str, region: str) -> Optional[str]:
|
|
|
1235
1234
|
# would be ambiguous to split off string-level, so leave that shape to
|
|
1236
1235
|
# the full parse.
|
|
1237
1236
|
core = region_core
|
|
1238
|
-
if not core.startswith(
|
|
1237
|
+
if not core.startswith("<") or core[1] in ("!", "?") or core.endswith("-->"):
|
|
1239
1238
|
return None
|
|
1240
|
-
prefix_len = region.find(
|
|
1239
|
+
prefix_len = region.find("<")
|
|
1241
1240
|
prefix = region[:prefix_len]
|
|
1242
|
-
suffix = region[prefix_len + len(core):]
|
|
1241
|
+
suffix = region[prefix_len + len(core) :]
|
|
1243
1242
|
region_out = (
|
|
1244
1243
|
f'{prefix}<template pp-component="{region_root_key}">'
|
|
1245
|
-
f
|
|
1246
|
-
f
|
|
1244
|
+
f"{_protect_region_brace_entities(core)}"
|
|
1245
|
+
f"</template>{suffix}"
|
|
1247
1246
|
)
|
|
1248
1247
|
else:
|
|
1249
1248
|
region_out = region
|
|
@@ -1261,6 +1260,7 @@ def _defer_component_roots_in_soup(
|
|
|
1261
1260
|
Split out so callers that already parsed the document can reuse the
|
|
1262
1261
|
component-deferral pass.
|
|
1263
1262
|
"""
|
|
1263
|
+
|
|
1264
1264
|
def unchanged() -> str:
|
|
1265
1265
|
return fallback_html
|
|
1266
1266
|
|
|
@@ -1277,27 +1277,23 @@ def _defer_component_roots_in_soup(
|
|
|
1277
1277
|
# element already inside a ``<template pp-component>`` stays untouched,
|
|
1278
1278
|
# matching the ancestor-check semantics.
|
|
1279
1279
|
roots = []
|
|
1280
|
-
stack = [
|
|
1281
|
-
child for child in reversed(body.contents) if isinstance(child, Tag)
|
|
1282
|
-
]
|
|
1280
|
+
stack = [child for child in reversed(body.contents) if isinstance(child, Tag)]
|
|
1283
1281
|
while stack:
|
|
1284
1282
|
el = stack.pop()
|
|
1285
|
-
if el.has_attr(
|
|
1286
|
-
if el.name !=
|
|
1283
|
+
if el.has_attr("pp-component"):
|
|
1284
|
+
if el.name != "template":
|
|
1287
1285
|
roots.append(el)
|
|
1288
1286
|
continue
|
|
1289
|
-
stack.extend(
|
|
1290
|
-
child for child in reversed(el.contents) if isinstance(child, Tag)
|
|
1291
|
-
)
|
|
1287
|
+
stack.extend(child for child in reversed(el.contents) if isinstance(child, Tag))
|
|
1292
1288
|
if not roots:
|
|
1293
1289
|
return unchanged()
|
|
1294
1290
|
|
|
1295
1291
|
for root in roots:
|
|
1296
|
-
key = root.get(
|
|
1292
|
+
key = root.get("pp-component")
|
|
1297
1293
|
if key is None:
|
|
1298
1294
|
continue
|
|
1299
|
-
template = soup.new_tag(
|
|
1300
|
-
template[
|
|
1295
|
+
template = soup.new_tag("template")
|
|
1296
|
+
template["pp-component"] = key
|
|
1301
1297
|
root.insert_before(template)
|
|
1302
1298
|
template.append(root.extract())
|
|
1303
1299
|
|
|
@@ -1321,11 +1317,11 @@ def _defer_component_roots_in_soup(
|
|
|
1321
1317
|
value,
|
|
1322
1318
|
)
|
|
1323
1319
|
|
|
1324
|
-
for template in body.select(
|
|
1320
|
+
for template in body.select("template[pp-component]"):
|
|
1325
1321
|
for node in list(template.descendants):
|
|
1326
1322
|
if isinstance(node, NavigableString):
|
|
1327
1323
|
original = str(node)
|
|
1328
|
-
if
|
|
1324
|
+
if "__PP_ESCAPED_BRACE_" not in original:
|
|
1329
1325
|
continue
|
|
1330
1326
|
content = protect_brace_entities(original)
|
|
1331
1327
|
if content != original:
|
|
@@ -1333,13 +1329,12 @@ def _defer_component_roots_in_soup(
|
|
|
1333
1329
|
elif isinstance(node, Tag):
|
|
1334
1330
|
for name, value in node.attrs.items():
|
|
1335
1331
|
if isinstance(value, str):
|
|
1336
|
-
if
|
|
1337
|
-
node.attrs[name] = protect_brace_entities(
|
|
1338
|
-
value)
|
|
1332
|
+
if "__PP_ESCAPED_BRACE_" in value:
|
|
1333
|
+
node.attrs[name] = protect_brace_entities(value)
|
|
1339
1334
|
elif isinstance(value, list):
|
|
1340
1335
|
for index, item in enumerate(value):
|
|
1341
1336
|
item = str(item)
|
|
1342
|
-
if
|
|
1337
|
+
if "__PP_ESCAPED_BRACE_" in item:
|
|
1343
1338
|
item = protect_brace_entities(item)
|
|
1344
1339
|
value[index] = item
|
|
1345
1340
|
|
|
@@ -1364,11 +1359,9 @@ _DEV_CONSOLE_BRIDGE_TAG = '<script src="/__pp-devlog.js"></script>'
|
|
|
1364
1359
|
def _inject_dev_console_bridge(html_output: str) -> str:
|
|
1365
1360
|
if not os.getenv("CASPIAN_BROWSER_SYNC_PORT"):
|
|
1366
1361
|
return html_output
|
|
1367
|
-
if
|
|
1362
|
+
if "</head>" not in html_output or "__pp-devlog.js" in html_output:
|
|
1368
1363
|
return html_output
|
|
1369
|
-
return html_output.replace(
|
|
1370
|
-
'</head>', f'{_DEV_CONSOLE_BRIDGE_TAG}</head>', 1
|
|
1371
|
-
)
|
|
1364
|
+
return html_output.replace("</head>", f"{_DEV_CONSOLE_BRIDGE_TAG}</head>", 1)
|
|
1372
1365
|
|
|
1373
1366
|
|
|
1374
1367
|
def finalize_html(html_output: str) -> str:
|
|
@@ -1462,19 +1455,19 @@ async def _render_special_page(
|
|
|
1462
1455
|
@app.exception_handler(StarletteHTTPException)
|
|
1463
1456
|
async def custom_404_handler(request: Request, exc: StarletteHTTPException):
|
|
1464
1457
|
if exc.status_code == 404:
|
|
1465
|
-
not_found_path = os.path.join(
|
|
1458
|
+
not_found_path = os.path.join("src", "app", "not_found.py")
|
|
1466
1459
|
if os.path.exists(not_found_path):
|
|
1467
1460
|
html_output, root_layout_id = await _render_special_page(
|
|
1468
1461
|
page_path=not_found_path,
|
|
1469
1462
|
request=request,
|
|
1470
1463
|
default_metadata={
|
|
1471
|
-
|
|
1472
|
-
|
|
1464
|
+
"title": "Page Not Found",
|
|
1465
|
+
"description": "The page you are looking for does not exist.",
|
|
1473
1466
|
},
|
|
1474
1467
|
context_data={},
|
|
1475
1468
|
)
|
|
1476
1469
|
resp = HTMLResponse(content=html_output, status_code=404)
|
|
1477
|
-
resp.headers[
|
|
1470
|
+
resp.headers["X-PP-Root-Layout"] = root_layout_id
|
|
1478
1471
|
return resp
|
|
1479
1472
|
return HTMLResponse(content=f"<h1>{exc.detail}</h1>", status_code=exc.status_code)
|
|
1480
1473
|
|
|
@@ -1486,30 +1479,33 @@ async def custom_general_exception_handler(request: Request, exc: Exception):
|
|
|
1486
1479
|
error_message = _client_error_message(exc)
|
|
1487
1480
|
error_trace = full_trace if not IS_PRODUCTION else None
|
|
1488
1481
|
|
|
1489
|
-
error_page_path = os.path.join(
|
|
1482
|
+
error_page_path = os.path.join("src", "app", "error.py")
|
|
1490
1483
|
if os.path.exists(error_page_path):
|
|
1491
|
-
context_data = {
|
|
1492
|
-
|
|
1484
|
+
context_data = {
|
|
1485
|
+
"request": request,
|
|
1486
|
+
"error_message": error_message,
|
|
1487
|
+
"error_trace": error_trace,
|
|
1488
|
+
}
|
|
1493
1489
|
try:
|
|
1494
1490
|
html_output, root_layout_id = await _render_special_page(
|
|
1495
1491
|
page_path=error_page_path,
|
|
1496
1492
|
request=request,
|
|
1497
1493
|
default_metadata={
|
|
1498
|
-
|
|
1499
|
-
|
|
1494
|
+
"title": "Application Error",
|
|
1495
|
+
"description": "An unexpected error occurred.",
|
|
1500
1496
|
},
|
|
1501
1497
|
context_data=context_data,
|
|
1502
1498
|
)
|
|
1503
1499
|
resp = HTMLResponse(content=html_output, status_code=500)
|
|
1504
|
-
resp.headers[
|
|
1500
|
+
resp.headers["X-PP-Root-Layout"] = root_layout_id
|
|
1505
1501
|
return resp
|
|
1506
1502
|
except Exception as render_exc:
|
|
1507
1503
|
print("Error rendering error.py:", render_exc)
|
|
1508
1504
|
return HTMLResponse(
|
|
1509
|
-
content=f"<h1>500 - Internal Server Error</h1><p>{error_message}</p>",
|
|
1510
|
-
status_code=500
|
|
1505
|
+
content=f"<h1>500 - Internal Server Error</h1><p>{error_message}</p>", status_code=500
|
|
1511
1506
|
)
|
|
1512
1507
|
|
|
1508
|
+
|
|
1513
1509
|
# ====
|
|
1514
1510
|
# Middleware Order (LAST added runs FIRST)
|
|
1515
1511
|
# ====
|
|
@@ -1522,9 +1518,9 @@ app.add_middleware(
|
|
|
1522
1518
|
secret_key=_get_session_secret(),
|
|
1523
1519
|
session_cookie=SESSION_COOKIE_NAME,
|
|
1524
1520
|
max_age=SESSION_LIFETIME_HOURS * 3600,
|
|
1525
|
-
same_site=
|
|
1521
|
+
same_site="lax",
|
|
1526
1522
|
https_only=IS_PRODUCTION,
|
|
1527
|
-
path=
|
|
1523
|
+
path="/",
|
|
1528
1524
|
)
|
|
1529
1525
|
app.add_middleware(BodySizeLimitMiddleware)
|
|
1530
1526
|
# Outermost of the security layers: reject flooding before any session
|
|
@@ -1535,9 +1531,9 @@ app.add_middleware(RateLimitMiddleware)
|
|
|
1535
1531
|
# in attachment mode unless its MIME type is explicitly safe to render inline.
|
|
1536
1532
|
app.add_middleware(
|
|
1537
1533
|
PublicFilesMiddleware,
|
|
1538
|
-
directory=
|
|
1534
|
+
directory="public",
|
|
1539
1535
|
inline_safe_subdirectories={
|
|
1540
|
-
|
|
1536
|
+
"uploads": INLINE_SAFE_UPLOAD_MEDIA_TYPES,
|
|
1541
1537
|
},
|
|
1542
1538
|
)
|
|
1543
1539
|
app.add_middleware(SecurityHeadersMiddleware)
|
|
@@ -1545,9 +1541,9 @@ app.add_middleware(SecurityHeadersMiddleware)
|
|
|
1545
1541
|
if not IS_PRODUCTION:
|
|
1546
1542
|
app.add_middleware(RequestDiagnosticsMiddleware)
|
|
1547
1543
|
|
|
1548
|
-
if __name__ ==
|
|
1549
|
-
port = int(os.getenv(
|
|
1550
|
-
workers = max(1, int(os.getenv(
|
|
1544
|
+
if __name__ == "__main__":
|
|
1545
|
+
port = int(os.getenv("PORT", 5091))
|
|
1546
|
+
workers = max(1, int(os.getenv("UVICORN_WORKERS", "1")))
|
|
1551
1547
|
uvicorn.run(
|
|
1552
1548
|
"main:app",
|
|
1553
1549
|
host="0.0.0.0",
|