create-caspian-app 0.3.0-rc.2 → 0.3.0-rc.21
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 +33 -3
- package/dist/AGENTS.md +17 -4
- package/dist/caspian.js +1 -1
- package/dist/index.js +1 -1
- package/dist/main-websocket.py +1001 -0
- package/dist/main.py +86 -6
- package/dist/public/js/pp-reactive-v2.js +1 -1
- package/dist/pyproject.toml +1 -1
- package/dist/settings/bs-config.json +6 -6
- package/dist/settings/bs-config.ts +132 -62
- package/dist/settings/component-map.ts +6 -6
- package/dist/settings/python-server.ts +8 -18
- package/dist/settings/utils.ts +3 -3
- package/dist/src/lib/auth/auth_config.py +1 -1
- package/dist/src/lib/mcp/mcp_server.py +8 -8
- package/dist/src/lib/websocket/websocket_security.py +84 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1001 @@
|
|
|
1
|
+
from casp.components_compiler import transform_components
|
|
2
|
+
from casp.scripts_type import transform_scripts
|
|
3
|
+
import asyncio
|
|
4
|
+
import inspect
|
|
5
|
+
import os
|
|
6
|
+
import importlib.util
|
|
7
|
+
import secrets
|
|
8
|
+
import traceback
|
|
9
|
+
import json
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from fastapi import FastAPI, Request, Response, WebSocket, WebSocketDisconnect, status
|
|
13
|
+
from fastapi.responses import RedirectResponse, FileResponse, HTMLResponse
|
|
14
|
+
from starlette.datastructures import MutableHeaders
|
|
15
|
+
from starlette.middleware.sessions import SessionMiddleware
|
|
16
|
+
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
17
|
+
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
18
|
+
from dotenv import load_dotenv
|
|
19
|
+
import uvicorn
|
|
20
|
+
from casp.state_manager import StateManager
|
|
21
|
+
from casp.cache_handler import CacheHandler
|
|
22
|
+
from casp.caspian_config import get_files_index, get_config
|
|
23
|
+
from casp.auth import (
|
|
24
|
+
Auth,
|
|
25
|
+
GoogleProvider,
|
|
26
|
+
GithubProvider,
|
|
27
|
+
configure_auth,
|
|
28
|
+
auth,
|
|
29
|
+
)
|
|
30
|
+
from casp.rpc import register_rpc_routes
|
|
31
|
+
from casp.layout import (
|
|
32
|
+
render_with_nested_layouts,
|
|
33
|
+
compile_template,
|
|
34
|
+
load_template_file,
|
|
35
|
+
render_page,
|
|
36
|
+
_runtime_injections,
|
|
37
|
+
_runtime_metadata,
|
|
38
|
+
)
|
|
39
|
+
import hashlib
|
|
40
|
+
from casp.streaming import SSE
|
|
41
|
+
from typing import Any, Optional, get_args, get_origin, Union
|
|
42
|
+
from urllib.parse import urlparse
|
|
43
|
+
from src.lib.auth.auth_config import build_auth_settings
|
|
44
|
+
from casp.runtime_security import (
|
|
45
|
+
build_security_headers,
|
|
46
|
+
client_error_message,
|
|
47
|
+
get_session_secret,
|
|
48
|
+
public_file_response,
|
|
49
|
+
)
|
|
50
|
+
from src.lib.websocket.websocket_security import (
|
|
51
|
+
get_authenticated_payload_from_session,
|
|
52
|
+
get_websocket_session,
|
|
53
|
+
public_websocket_connections,
|
|
54
|
+
websocket_connections,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
load_dotenv()
|
|
58
|
+
cfg = get_config()
|
|
59
|
+
|
|
60
|
+
# ====
|
|
61
|
+
# AUTH CONFIGURATION (App behavior - customize here)
|
|
62
|
+
# ====
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def setup_auth():
|
|
66
|
+
configure_auth(build_auth_settings())
|
|
67
|
+
Auth.set_providers(GithubProvider(), GoogleProvider())
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
setup_auth()
|
|
71
|
+
|
|
72
|
+
app = FastAPI(
|
|
73
|
+
title=cfg.projectName,
|
|
74
|
+
version=cfg.version,
|
|
75
|
+
docs_url="/docs" if cfg.backendOnly else None,
|
|
76
|
+
redoc_url="/redoc" if cfg.backendOnly else None,
|
|
77
|
+
openapi_url="/openapi.json" if cfg.backendOnly else None,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@app.get("/health")
|
|
82
|
+
async def healthcheck():
|
|
83
|
+
return {"status": "ok"}
|
|
84
|
+
|
|
85
|
+
# ====
|
|
86
|
+
# Configuration
|
|
87
|
+
# ====
|
|
88
|
+
SESSION_LIFETIME_HOURS = int(os.getenv('SESSION_LIFETIME_HOURS', 7))
|
|
89
|
+
MAX_CONTENT_LENGTH_MB = int(os.getenv('MAX_CONTENT_LENGTH_MB', 16))
|
|
90
|
+
IS_PRODUCTION = os.getenv('APP_ENV') == 'production'
|
|
91
|
+
CACHE_ENABLED = os.getenv('CACHE_ENABLED', 'false').lower() == 'true'
|
|
92
|
+
DEFAULT_TTL = int(os.getenv('CACHE_TTL', 600))
|
|
93
|
+
REQUEST_TIMEOUT_SECONDS = max(
|
|
94
|
+
1.0,
|
|
95
|
+
float(os.getenv('CASPIAN_REQUEST_TIMEOUT_SECONDS', 20)),
|
|
96
|
+
)
|
|
97
|
+
MAX_CONTENT_LENGTH_BYTES = max(1, MAX_CONTENT_LENGTH_MB) * 1024 * 1024
|
|
98
|
+
WEBSOCKET_PATH = "/ws/live"
|
|
99
|
+
PUBLIC_WEBSOCKET_PATH = "/ws/public"
|
|
100
|
+
WEBSOCKET_IDLE_TIMEOUT_SECONDS = max(
|
|
101
|
+
10,
|
|
102
|
+
int(os.getenv('WEBSOCKET_IDLE_TIMEOUT_SECONDS', 120)),
|
|
103
|
+
)
|
|
104
|
+
MAX_WEBSOCKET_MESSAGE_BYTES = max(
|
|
105
|
+
256,
|
|
106
|
+
int(os.getenv('MAX_WEBSOCKET_MESSAGE_BYTES', 4096)),
|
|
107
|
+
)
|
|
108
|
+
SHOW_ERROR_TRACES = (
|
|
109
|
+
not IS_PRODUCTION
|
|
110
|
+
and os.getenv('SHOW_ERRORS', 'false').lower() in {'1', 'true', 'yes', 'on'}
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class RequestBodyTooLarge(Exception):
|
|
115
|
+
pass
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _client_error_message(exc: Exception) -> str:
|
|
119
|
+
return client_error_message(exc, is_production=IS_PRODUCTION)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _get_session_secret() -> str:
|
|
123
|
+
return get_session_secret(is_production=IS_PRODUCTION)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _build_security_headers() -> dict[str, str]:
|
|
127
|
+
return build_security_headers(is_production=IS_PRODUCTION)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _request_is_local(request: Request) -> bool:
|
|
131
|
+
client_host = request.client.host if request.client else ""
|
|
132
|
+
return client_host in {"127.0.0.1", "::1", "localhost"}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _should_show_error_trace(request: Request) -> bool:
|
|
136
|
+
return SHOW_ERROR_TRACES and _request_is_local(request)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _dev_cookie_scope() -> str:
|
|
140
|
+
if IS_PRODUCTION:
|
|
141
|
+
return ""
|
|
142
|
+
|
|
143
|
+
scope = os.getenv("CASPIAN_BROWSER_SYNC_PORT")
|
|
144
|
+
if scope and scope.isdigit():
|
|
145
|
+
return scope
|
|
146
|
+
|
|
147
|
+
if not scope:
|
|
148
|
+
bs_config_path = Path("settings/bs-config.json")
|
|
149
|
+
if bs_config_path.exists():
|
|
150
|
+
try:
|
|
151
|
+
local_url = json.loads(
|
|
152
|
+
bs_config_path.read_text(encoding="utf-8")
|
|
153
|
+
).get("local", "")
|
|
154
|
+
parsed_url = urlparse(local_url)
|
|
155
|
+
if parsed_url.hostname in {"localhost", "127.0.0.1"}:
|
|
156
|
+
scope = str(parsed_url.port or "")
|
|
157
|
+
else:
|
|
158
|
+
scope = ""
|
|
159
|
+
except (OSError, json.JSONDecodeError):
|
|
160
|
+
scope = ""
|
|
161
|
+
|
|
162
|
+
return scope if scope and scope.isdigit() else ""
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _scoped_cookie_name(base_name: str) -> str:
|
|
166
|
+
scope = _dev_cookie_scope()
|
|
167
|
+
return f"{base_name}_{scope}" if scope else base_name
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _normalized_origin(value: str) -> str:
|
|
171
|
+
return (value or "").strip().rstrip("/")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _configured_websocket_origins() -> set[str]:
|
|
175
|
+
raw_values = []
|
|
176
|
+
for env_name in (
|
|
177
|
+
"WEBSOCKET_ALLOWED_ORIGINS",
|
|
178
|
+
"CORS_ALLOWED_ORIGINS",
|
|
179
|
+
"APP_BASE_URL",
|
|
180
|
+
"PUBLIC_BASE_URL",
|
|
181
|
+
"SITE_URL",
|
|
182
|
+
):
|
|
183
|
+
raw_values.extend(os.getenv(env_name, "").split(","))
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
_normalized_origin(origin)
|
|
187
|
+
for origin in raw_values
|
|
188
|
+
if _normalized_origin(origin)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _websocket_same_origin(websocket: WebSocket) -> str:
|
|
193
|
+
scheme = "https" if websocket.url.scheme == "wss" else "http"
|
|
194
|
+
return _normalized_origin(f"{scheme}://{websocket.url.netloc}")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _is_websocket_origin_allowed(websocket: WebSocket) -> bool:
|
|
198
|
+
origin = _normalized_origin(websocket.headers.get("origin", ""))
|
|
199
|
+
if not origin:
|
|
200
|
+
return not IS_PRODUCTION
|
|
201
|
+
|
|
202
|
+
parsed_origin = urlparse(origin)
|
|
203
|
+
if not parsed_origin.scheme or not parsed_origin.netloc:
|
|
204
|
+
return False
|
|
205
|
+
|
|
206
|
+
if not IS_PRODUCTION and parsed_origin.hostname in {"localhost", "127.0.0.1"}:
|
|
207
|
+
return parsed_origin.scheme == "http"
|
|
208
|
+
|
|
209
|
+
allowed_origins = _configured_websocket_origins()
|
|
210
|
+
allowed_origins.add(_websocket_same_origin(websocket))
|
|
211
|
+
return origin in allowed_origins
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
CSRF_COOKIE_NAME = _scoped_cookie_name("pp_csrf")
|
|
215
|
+
SESSION_COOKIE_NAME = _scoped_cookie_name(
|
|
216
|
+
os.getenv('AUTH_COOKIE_NAME', 'session')
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
# ====
|
|
220
|
+
# Static File Routes
|
|
221
|
+
# ====
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@app.get('/css/{filename:path}')
|
|
225
|
+
async def serve_css(filename: str):
|
|
226
|
+
return public_file_response('public/css', filename, media_type='text/css')
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@app.get('/js/{filename:path}')
|
|
230
|
+
async def serve_js(filename: str):
|
|
231
|
+
return public_file_response(
|
|
232
|
+
'public/js',
|
|
233
|
+
filename,
|
|
234
|
+
media_type='application/javascript',
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@app.get('/assets/{filename:path}')
|
|
239
|
+
async def serve_assets(filename: str):
|
|
240
|
+
return public_file_response('public/assets', filename)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@app.get('/uploads/{filename:path}')
|
|
244
|
+
async def serve_uploads(filename: str):
|
|
245
|
+
return public_file_response('public/uploads', filename)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@app.get('/favicon.ico')
|
|
249
|
+
async def favicon():
|
|
250
|
+
file_path = Path('public/favicon.ico')
|
|
251
|
+
if not file_path.exists():
|
|
252
|
+
return Response(status_code=404)
|
|
253
|
+
return FileResponse(file_path, media_type='image/x-icon')
|
|
254
|
+
|
|
255
|
+
# ====
|
|
256
|
+
# Pure ASGI Middleware Classes
|
|
257
|
+
# ====
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class CSRFMiddleware:
|
|
261
|
+
"""CSRF middleware that properly handles session modifications."""
|
|
262
|
+
|
|
263
|
+
def __init__(self, app: ASGIApp): self.app = app
|
|
264
|
+
|
|
265
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send):
|
|
266
|
+
if scope["type"] != "http":
|
|
267
|
+
await self.app(scope, receive, send)
|
|
268
|
+
return
|
|
269
|
+
request = Request(scope, receive, send)
|
|
270
|
+
csrf_token = request.session.get("csrf_token")
|
|
271
|
+
if not csrf_token:
|
|
272
|
+
csrf_token = secrets.token_hex(32)
|
|
273
|
+
request.session["csrf_token"] = csrf_token
|
|
274
|
+
|
|
275
|
+
async def send_wrapper(message):
|
|
276
|
+
if message["type"] == "http.response.start":
|
|
277
|
+
cookie_value = f"{CSRF_COOKIE_NAME}={csrf_token}; Path=/; SameSite=Lax"
|
|
278
|
+
if IS_PRODUCTION:
|
|
279
|
+
cookie_value += "; Secure"
|
|
280
|
+
new_headers = list(message.get("headers", []))
|
|
281
|
+
new_headers.append((b"set-cookie", cookie_value.encode()))
|
|
282
|
+
message = {**message, "headers": new_headers}
|
|
283
|
+
await send(message)
|
|
284
|
+
await self.app(scope, receive, send_wrapper)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
class SecurityHeadersMiddleware:
|
|
288
|
+
"""Attach baseline browser security headers to HTTP responses."""
|
|
289
|
+
|
|
290
|
+
def __init__(self, app: ASGIApp): self.app = app
|
|
291
|
+
|
|
292
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send):
|
|
293
|
+
if scope["type"] != "http":
|
|
294
|
+
await self.app(scope, receive, send)
|
|
295
|
+
return
|
|
296
|
+
|
|
297
|
+
async def send_wrapper(message):
|
|
298
|
+
if message["type"] == "http.response.start":
|
|
299
|
+
raw_headers = list(message.get("headers", []))
|
|
300
|
+
headers = MutableHeaders(raw=raw_headers)
|
|
301
|
+
for name, value in _build_security_headers().items():
|
|
302
|
+
if headers.get(name) is None:
|
|
303
|
+
headers[name] = value
|
|
304
|
+
message = {**message, "headers": raw_headers}
|
|
305
|
+
await send(message)
|
|
306
|
+
|
|
307
|
+
await self.app(scope, receive, send_wrapper)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
class BodySizeLimitMiddleware:
|
|
311
|
+
"""Reject oversized HTTP request bodies before route or RPC parsing."""
|
|
312
|
+
|
|
313
|
+
def __init__(self, app: ASGIApp): self.app = app
|
|
314
|
+
|
|
315
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send):
|
|
316
|
+
if scope["type"] != "http":
|
|
317
|
+
await self.app(scope, receive, send)
|
|
318
|
+
return
|
|
319
|
+
|
|
320
|
+
headers = MutableHeaders(scope=scope)
|
|
321
|
+
content_length = headers.get("content-length")
|
|
322
|
+
if content_length:
|
|
323
|
+
try:
|
|
324
|
+
if int(content_length) > MAX_CONTENT_LENGTH_BYTES:
|
|
325
|
+
await self._send_too_large(send)
|
|
326
|
+
return
|
|
327
|
+
except ValueError:
|
|
328
|
+
pass
|
|
329
|
+
|
|
330
|
+
received = 0
|
|
331
|
+
response_started = False
|
|
332
|
+
|
|
333
|
+
async def limited_receive():
|
|
334
|
+
nonlocal received
|
|
335
|
+
message = await receive()
|
|
336
|
+
if message["type"] == "http.request":
|
|
337
|
+
received += len(message.get("body", b""))
|
|
338
|
+
if received > MAX_CONTENT_LENGTH_BYTES:
|
|
339
|
+
raise RequestBodyTooLarge()
|
|
340
|
+
return message
|
|
341
|
+
|
|
342
|
+
async def send_wrapper(message):
|
|
343
|
+
nonlocal response_started
|
|
344
|
+
if message["type"] == "http.response.start":
|
|
345
|
+
response_started = True
|
|
346
|
+
await send(message)
|
|
347
|
+
|
|
348
|
+
try:
|
|
349
|
+
await self.app(scope, limited_receive, send_wrapper)
|
|
350
|
+
except RequestBodyTooLarge:
|
|
351
|
+
if not response_started:
|
|
352
|
+
await self._send_too_large(send)
|
|
353
|
+
|
|
354
|
+
async def _send_too_large(self, send: Send):
|
|
355
|
+
response = Response(
|
|
356
|
+
content="Request body too large.",
|
|
357
|
+
status_code=413,
|
|
358
|
+
media_type="text/plain",
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
async def receive_empty_body():
|
|
362
|
+
return {"type": "http.request", "body": b"", "more_body": False}
|
|
363
|
+
|
|
364
|
+
await response(
|
|
365
|
+
{"type": "http", "method": "POST", "path": "/", "headers": []},
|
|
366
|
+
receive=receive_empty_body,
|
|
367
|
+
send=send,
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
class AuthMiddleware:
|
|
372
|
+
"""Auth middleware using pure ASGI pattern for proper session handling."""
|
|
373
|
+
|
|
374
|
+
def __init__(self, app: ASGIApp): self.app = app
|
|
375
|
+
|
|
376
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send):
|
|
377
|
+
if scope["type"] != "http":
|
|
378
|
+
await self.app(scope, receive, send)
|
|
379
|
+
return
|
|
380
|
+
request = Request(scope, receive, send)
|
|
381
|
+
path = request.url.path
|
|
382
|
+
if path.startswith(('/css/', '/js/', '/assets/', '/favicon.ico')):
|
|
383
|
+
await self.app(scope, receive, send)
|
|
384
|
+
return
|
|
385
|
+
StateManager.init(request)
|
|
386
|
+
Auth.set_request(request)
|
|
387
|
+
auth_inst = Auth.get_instance()
|
|
388
|
+
providers = Auth.get_providers()
|
|
389
|
+
|
|
390
|
+
if providers:
|
|
391
|
+
oauth_response = await auth_inst.auth_providers(*providers)
|
|
392
|
+
if oauth_response:
|
|
393
|
+
await oauth_response(scope, receive, send)
|
|
394
|
+
return
|
|
395
|
+
is_authenticated = auth_inst.is_authenticated()
|
|
396
|
+
if is_authenticated:
|
|
397
|
+
auth_inst.refresh_session()
|
|
398
|
+
if auth_inst.is_public_route(path):
|
|
399
|
+
await self.app(scope, receive, send)
|
|
400
|
+
return
|
|
401
|
+
if auth_inst.is_auth_route(path):
|
|
402
|
+
if is_authenticated:
|
|
403
|
+
await RedirectResponse(
|
|
404
|
+
url=auth_inst.settings.default_signin_redirect,
|
|
405
|
+
status_code=303
|
|
406
|
+
)(scope, receive, send)
|
|
407
|
+
return
|
|
408
|
+
await self.app(scope, receive, send)
|
|
409
|
+
return
|
|
410
|
+
|
|
411
|
+
if auth_inst.settings.is_role_based:
|
|
412
|
+
required_roles = auth_inst.get_required_roles(path)
|
|
413
|
+
if required_roles:
|
|
414
|
+
if not is_authenticated:
|
|
415
|
+
await RedirectResponse(url=f'/signin?next={path}', status_code=303)(scope, receive, send)
|
|
416
|
+
return
|
|
417
|
+
if not auth_inst.check_role(auth_inst.get_payload(), required_roles):
|
|
418
|
+
await RedirectResponse(url='/unauthorized', status_code=303)(scope, receive, send)
|
|
419
|
+
return
|
|
420
|
+
|
|
421
|
+
if auth_inst.is_private_route(path):
|
|
422
|
+
if not is_authenticated:
|
|
423
|
+
await RedirectResponse(url=f'/signin?next={path}', status_code=303)(scope, receive, send)
|
|
424
|
+
return
|
|
425
|
+
|
|
426
|
+
await self.app(scope, receive, send)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
class RPCMiddleware:
|
|
430
|
+
"""RPC middleware using pure ASGI pattern."""
|
|
431
|
+
|
|
432
|
+
def __init__(self, app: ASGIApp): self.app = app
|
|
433
|
+
|
|
434
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send):
|
|
435
|
+
if scope["type"] != "http":
|
|
436
|
+
await self.app(scope, receive, send)
|
|
437
|
+
return
|
|
438
|
+
request = Request(scope, receive, send)
|
|
439
|
+
if request.headers.get('X-PP-RPC') == 'true' and request.method == 'POST':
|
|
440
|
+
from casp.rpc import _handle_rpc_request
|
|
441
|
+
session = dict(request.session) if hasattr(
|
|
442
|
+
request, 'session') else {}
|
|
443
|
+
response = await _handle_rpc_request(request, session)
|
|
444
|
+
await response(scope, receive, send)
|
|
445
|
+
return
|
|
446
|
+
await self.app(scope, receive, send)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
class RequestDiagnosticsMiddleware:
|
|
450
|
+
"""Log request start/end in dev and fail visibly when a route stalls."""
|
|
451
|
+
|
|
452
|
+
def __init__(self, app: ASGIApp): self.app = app
|
|
453
|
+
|
|
454
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send):
|
|
455
|
+
if scope["type"] != "http":
|
|
456
|
+
await self.app(scope, receive, send)
|
|
457
|
+
return
|
|
458
|
+
|
|
459
|
+
method = scope.get("method", "GET")
|
|
460
|
+
path = scope.get("path", "")
|
|
461
|
+
should_log = not path.startswith(
|
|
462
|
+
('/css/', '/js/', '/assets/', '/favicon.ico'))
|
|
463
|
+
started = time.perf_counter()
|
|
464
|
+
|
|
465
|
+
if should_log and not IS_PRODUCTION:
|
|
466
|
+
print(f"[request:start] {method} {path}", flush=True)
|
|
467
|
+
|
|
468
|
+
try:
|
|
469
|
+
await asyncio.wait_for(
|
|
470
|
+
self.app(scope, receive, send),
|
|
471
|
+
timeout=REQUEST_TIMEOUT_SECONDS,
|
|
472
|
+
)
|
|
473
|
+
except asyncio.TimeoutError:
|
|
474
|
+
elapsed_ms = int((time.perf_counter() - started) * 1000)
|
|
475
|
+
print(
|
|
476
|
+
f"[request:timeout] {method} {path} exceeded "
|
|
477
|
+
f"{REQUEST_TIMEOUT_SECONDS:g}s after {elapsed_ms}ms",
|
|
478
|
+
flush=True,
|
|
479
|
+
)
|
|
480
|
+
response = HTMLResponse(
|
|
481
|
+
content=(
|
|
482
|
+
"<h1>504 - Request Timeout</h1>"
|
|
483
|
+
"<p>The route took too long to respond. "
|
|
484
|
+
"Check the development terminal for the stalled path.</p>"
|
|
485
|
+
),
|
|
486
|
+
status_code=504,
|
|
487
|
+
)
|
|
488
|
+
await response(scope, receive, send)
|
|
489
|
+
return
|
|
490
|
+
except Exception:
|
|
491
|
+
if should_log and not IS_PRODUCTION:
|
|
492
|
+
elapsed_ms = int((time.perf_counter() - started) * 1000)
|
|
493
|
+
print(
|
|
494
|
+
f"[request:error] {method} {path} after {elapsed_ms}ms", flush=True)
|
|
495
|
+
raise
|
|
496
|
+
finally:
|
|
497
|
+
if should_log and not IS_PRODUCTION:
|
|
498
|
+
elapsed_ms = int((time.perf_counter() - started) * 1000)
|
|
499
|
+
print(
|
|
500
|
+
f"[request:end] {method} {path} {elapsed_ms}ms", flush=True)
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
# ====
|
|
504
|
+
# WebSocket Routes
|
|
505
|
+
# ====
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
async def _run_websocket_channel(
|
|
509
|
+
websocket: WebSocket,
|
|
510
|
+
manager: Any,
|
|
511
|
+
payload: dict[str, Any] | None,
|
|
512
|
+
ready_message: str,
|
|
513
|
+
):
|
|
514
|
+
await manager.connect(websocket)
|
|
515
|
+
ready_payload: dict[str, Any] = {
|
|
516
|
+
"type": "ready",
|
|
517
|
+
"message": ready_message,
|
|
518
|
+
}
|
|
519
|
+
if payload is not None:
|
|
520
|
+
ready_payload["payload"] = payload
|
|
521
|
+
await websocket.send_json(ready_payload)
|
|
522
|
+
|
|
523
|
+
try:
|
|
524
|
+
while True:
|
|
525
|
+
try:
|
|
526
|
+
raw_message = await asyncio.wait_for(
|
|
527
|
+
websocket.receive_text(),
|
|
528
|
+
timeout=WEBSOCKET_IDLE_TIMEOUT_SECONDS,
|
|
529
|
+
)
|
|
530
|
+
except asyncio.TimeoutError:
|
|
531
|
+
await websocket.close(code=status.WS_1000_NORMAL_CLOSURE)
|
|
532
|
+
return
|
|
533
|
+
|
|
534
|
+
if len(raw_message.encode("utf-8")) > MAX_WEBSOCKET_MESSAGE_BYTES:
|
|
535
|
+
await websocket.close(code=status.WS_1009_MESSAGE_TOO_BIG)
|
|
536
|
+
return
|
|
537
|
+
|
|
538
|
+
try:
|
|
539
|
+
message = json.loads(raw_message)
|
|
540
|
+
except json.JSONDecodeError:
|
|
541
|
+
await websocket.send_json({
|
|
542
|
+
"type": "error",
|
|
543
|
+
"message": "Messages must be valid JSON.",
|
|
544
|
+
})
|
|
545
|
+
continue
|
|
546
|
+
|
|
547
|
+
if not isinstance(message, dict):
|
|
548
|
+
await websocket.send_json({
|
|
549
|
+
"type": "error",
|
|
550
|
+
"message": "Messages must be JSON objects.",
|
|
551
|
+
})
|
|
552
|
+
continue
|
|
553
|
+
|
|
554
|
+
message_type = str(message.get("type", "message"))
|
|
555
|
+
if message_type == "ping":
|
|
556
|
+
await websocket.send_json({"type": "pong", "time": int(time.time())})
|
|
557
|
+
continue
|
|
558
|
+
|
|
559
|
+
text = str(message.get("text", "")).strip()
|
|
560
|
+
if not text:
|
|
561
|
+
await websocket.send_json({
|
|
562
|
+
"type": "error",
|
|
563
|
+
"message": "Message text is required.",
|
|
564
|
+
})
|
|
565
|
+
continue
|
|
566
|
+
|
|
567
|
+
outgoing_payload: dict[str, Any] = {
|
|
568
|
+
"type": "message",
|
|
569
|
+
"text": text[:1000],
|
|
570
|
+
"time": int(time.time()),
|
|
571
|
+
}
|
|
572
|
+
if payload is not None:
|
|
573
|
+
outgoing_payload["payload"] = payload
|
|
574
|
+
await manager.broadcast_json(outgoing_payload)
|
|
575
|
+
except WebSocketDisconnect:
|
|
576
|
+
return
|
|
577
|
+
finally:
|
|
578
|
+
manager.disconnect(websocket)
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
@app.websocket(WEBSOCKET_PATH)
|
|
582
|
+
async def websocket_live_endpoint(websocket: WebSocket):
|
|
583
|
+
if not _is_websocket_origin_allowed(websocket):
|
|
584
|
+
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
|
|
585
|
+
return
|
|
586
|
+
|
|
587
|
+
session = get_websocket_session(websocket)
|
|
588
|
+
user_payload = get_authenticated_payload_from_session(
|
|
589
|
+
session,
|
|
590
|
+
Auth.get_instance(),
|
|
591
|
+
)
|
|
592
|
+
if user_payload is None:
|
|
593
|
+
await websocket.accept()
|
|
594
|
+
await websocket.send_json({
|
|
595
|
+
"type": "error",
|
|
596
|
+
"message": "Authentication required.",
|
|
597
|
+
})
|
|
598
|
+
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
|
|
599
|
+
return
|
|
600
|
+
|
|
601
|
+
await _run_websocket_channel(
|
|
602
|
+
websocket,
|
|
603
|
+
websocket_connections,
|
|
604
|
+
None,
|
|
605
|
+
"Private WebSocket connected.",
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
@app.websocket(PUBLIC_WEBSOCKET_PATH)
|
|
609
|
+
async def websocket_public_endpoint(websocket: WebSocket):
|
|
610
|
+
if not _is_websocket_origin_allowed(websocket):
|
|
611
|
+
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
|
|
612
|
+
return
|
|
613
|
+
|
|
614
|
+
await _run_websocket_channel(
|
|
615
|
+
websocket,
|
|
616
|
+
public_websocket_connections,
|
|
617
|
+
{"guest": True, "scope": "public"},
|
|
618
|
+
"Public WebSocket connected.",
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
# ====
|
|
622
|
+
# Route Registration
|
|
623
|
+
# ====
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
_route_module_cache = {}
|
|
627
|
+
_route_signature_cache = {}
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def load_route_module(file_path: str):
|
|
631
|
+
abs_path = os.path.abspath(file_path)
|
|
632
|
+
try:
|
|
633
|
+
mtime_ns = os.stat(abs_path).st_mtime_ns
|
|
634
|
+
except OSError:
|
|
635
|
+
raise FileNotFoundError(f"Route module not found: {abs_path}")
|
|
636
|
+
|
|
637
|
+
cached = _route_module_cache.get(abs_path)
|
|
638
|
+
if cached is not None and cached[0] == mtime_ns:
|
|
639
|
+
return cached[1]
|
|
640
|
+
|
|
641
|
+
unique_id = hashlib.md5(abs_path.encode()).hexdigest()[:8]
|
|
642
|
+
module_name = f"page_{unique_id}"
|
|
643
|
+
spec = importlib.util.spec_from_file_location(module_name, abs_path)
|
|
644
|
+
assert spec is not None and spec.loader is not None, f"Cannot load spec for {file_path}"
|
|
645
|
+
module = importlib.util.module_from_spec(spec)
|
|
646
|
+
spec.loader.exec_module(module)
|
|
647
|
+
setattr(module, 'render_page', render_page)
|
|
648
|
+
_route_module_cache[abs_path] = (mtime_ns, module)
|
|
649
|
+
_route_signature_cache.pop(abs_path, None)
|
|
650
|
+
return module
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def get_page_signature(file_path: str, page_func):
|
|
654
|
+
abs_path = os.path.abspath(file_path)
|
|
655
|
+
cached = _route_signature_cache.get(abs_path)
|
|
656
|
+
if cached is not None and cached[0] is page_func:
|
|
657
|
+
return cached[1]
|
|
658
|
+
|
|
659
|
+
sig = inspect.signature(page_func)
|
|
660
|
+
_route_signature_cache[abs_path] = (page_func, sig)
|
|
661
|
+
return sig
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def _unwrap_optional(annotation: Any) -> Any:
|
|
665
|
+
"""
|
|
666
|
+
Optional[T] is Union[T, NoneType]. Return T when applicable.
|
|
667
|
+
"""
|
|
668
|
+
origin = get_origin(annotation)
|
|
669
|
+
if origin is Union:
|
|
670
|
+
args = [a for a in get_args(annotation) if a is not type(None)]
|
|
671
|
+
if len(args) == 1:
|
|
672
|
+
return args[0]
|
|
673
|
+
return annotation
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def _coerce_scalar(value: Optional[str], annotation: Any) -> Any:
|
|
677
|
+
"""
|
|
678
|
+
Coerce a single query value based on annotation (best-effort).
|
|
679
|
+
If value is None -> returns None.
|
|
680
|
+
If coercion fails -> returns original string.
|
|
681
|
+
"""
|
|
682
|
+
if value is None:
|
|
683
|
+
return None
|
|
684
|
+
|
|
685
|
+
ann = _unwrap_optional(annotation)
|
|
686
|
+
|
|
687
|
+
try:
|
|
688
|
+
if ann is inspect._empty or ann is str or ann is Any:
|
|
689
|
+
return value
|
|
690
|
+
if ann is int:
|
|
691
|
+
return int(value)
|
|
692
|
+
if ann is float:
|
|
693
|
+
return float(value)
|
|
694
|
+
if ann is bool:
|
|
695
|
+
v = value.strip().lower()
|
|
696
|
+
if v in ("1", "true", "t", "yes", "y", "on"):
|
|
697
|
+
return True
|
|
698
|
+
if v in ("0", "false", "f", "no", "n", "off"):
|
|
699
|
+
return False
|
|
700
|
+
return bool(value)
|
|
701
|
+
return value
|
|
702
|
+
except Exception:
|
|
703
|
+
return value
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def _coerce_query_param(request: Request, name: str, param: inspect.Parameter) -> Any:
|
|
707
|
+
"""
|
|
708
|
+
Supports:
|
|
709
|
+
- scalar types: str/int/float/bool/Optional[...]
|
|
710
|
+
- list types: list[str], list[int], etc. via ?x=a&x=b
|
|
711
|
+
- Optional[list[T]]
|
|
712
|
+
"""
|
|
713
|
+
ann = param.annotation
|
|
714
|
+
origin = get_origin(ann)
|
|
715
|
+
|
|
716
|
+
# list[T]
|
|
717
|
+
if origin is list:
|
|
718
|
+
inner = get_args(ann)[0] if get_args(ann) else str
|
|
719
|
+
values = request.query_params.getlist(name)
|
|
720
|
+
return [_coerce_scalar(v, inner) for v in values]
|
|
721
|
+
|
|
722
|
+
# Optional[list[T]] -> Union[list[T], None]
|
|
723
|
+
unwrapped = _unwrap_optional(ann)
|
|
724
|
+
if get_origin(unwrapped) is list:
|
|
725
|
+
inner = get_args(unwrapped)[0] if get_args(unwrapped) else str
|
|
726
|
+
values = request.query_params.getlist(name)
|
|
727
|
+
return [_coerce_scalar(v, inner) for v in values]
|
|
728
|
+
|
|
729
|
+
# scalar
|
|
730
|
+
return _coerce_scalar(request.query_params.get(name), ann)
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def register_routes():
|
|
734
|
+
idx = get_files_index()
|
|
735
|
+
for route in idx.routes:
|
|
736
|
+
base_path = f"src/app/{route.fs_dir}" if route.fs_dir else "src/app"
|
|
737
|
+
file_name = "index.py" if route.has_py else "index.html"
|
|
738
|
+
full_path = f"{base_path}/{file_name}".replace('//', '/')
|
|
739
|
+
register_single_route(route.fastapi_rule, full_path)
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def register_single_route(url_pattern: str, file_path: str):
|
|
743
|
+
async def make_handler(request: Request):
|
|
744
|
+
_runtime_metadata.set(None)
|
|
745
|
+
_runtime_injections.set({"head": [], "body": []})
|
|
746
|
+
|
|
747
|
+
kwargs = dict(request.path_params)
|
|
748
|
+
current_uri = request.url.path
|
|
749
|
+
|
|
750
|
+
# 1. Cache Check (Fast Path)
|
|
751
|
+
if CACHE_ENABLED and request.method == 'GET':
|
|
752
|
+
cached_resp = CacheHandler.serve_cache(current_uri, DEFAULT_TTL)
|
|
753
|
+
if cached_resp:
|
|
754
|
+
return HTMLResponse(content=cached_resp)
|
|
755
|
+
|
|
756
|
+
route_dir = os.path.dirname(file_path)
|
|
757
|
+
page_metadata = {}
|
|
758
|
+
page_layout_props = {}
|
|
759
|
+
content = ""
|
|
760
|
+
|
|
761
|
+
req_should_cache = None
|
|
762
|
+
req_cache_ttl = 0
|
|
763
|
+
|
|
764
|
+
page_content_source = file_path
|
|
765
|
+
|
|
766
|
+
if file_path.endswith('.py'):
|
|
767
|
+
module = load_route_module(file_path)
|
|
768
|
+
if not hasattr(module, 'page'):
|
|
769
|
+
raise AttributeError(f"Missing 'def page():' in {file_path}")
|
|
770
|
+
|
|
771
|
+
sig = get_page_signature(file_path, module.page)
|
|
772
|
+
call_kwargs = {}
|
|
773
|
+
call_args = []
|
|
774
|
+
|
|
775
|
+
if kwargs:
|
|
776
|
+
call_args.append(kwargs)
|
|
777
|
+
if 'request' in sig.parameters:
|
|
778
|
+
call_kwargs['request'] = request
|
|
779
|
+
|
|
780
|
+
for name, param in sig.parameters.items():
|
|
781
|
+
if name in call_kwargs:
|
|
782
|
+
continue
|
|
783
|
+
if name in ("kwargs",):
|
|
784
|
+
continue
|
|
785
|
+
if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
|
|
786
|
+
continue
|
|
787
|
+
if name in request.query_params:
|
|
788
|
+
call_kwargs[name] = _coerce_query_param(
|
|
789
|
+
request, name, param)
|
|
790
|
+
|
|
791
|
+
if inspect.iscoroutinefunction(module.page):
|
|
792
|
+
result = await module.page(*call_args, **call_kwargs)
|
|
793
|
+
else:
|
|
794
|
+
result = module.page(*call_args, **call_kwargs)
|
|
795
|
+
|
|
796
|
+
if isinstance(result, Response):
|
|
797
|
+
return result
|
|
798
|
+
|
|
799
|
+
if inspect.isasyncgen(result) or inspect.isgenerator(result):
|
|
800
|
+
return SSE(result)
|
|
801
|
+
|
|
802
|
+
cache_settings = getattr(module, 'cache_settings', None)
|
|
803
|
+
if cache_settings:
|
|
804
|
+
req_should_cache = cache_settings.enabled
|
|
805
|
+
req_cache_ttl = cache_settings.ttl
|
|
806
|
+
|
|
807
|
+
if isinstance(result, tuple):
|
|
808
|
+
page_content = result[0]
|
|
809
|
+
content = str(page_content)
|
|
810
|
+
page_content_source = getattr(
|
|
811
|
+
page_content, 'source_path', file_path)
|
|
812
|
+
if len(result) >= 2 and isinstance(result[1], dict):
|
|
813
|
+
page_layout_props = result[1]
|
|
814
|
+
else:
|
|
815
|
+
content = str(result)
|
|
816
|
+
page_content_source = getattr(result, 'source_path', file_path)
|
|
817
|
+
|
|
818
|
+
dynamic_meta = _runtime_metadata.get()
|
|
819
|
+
static_meta = getattr(module, 'metadata', None)
|
|
820
|
+
|
|
821
|
+
def extract_meta(obj):
|
|
822
|
+
d = {}
|
|
823
|
+
if not obj:
|
|
824
|
+
return d
|
|
825
|
+
if obj.title:
|
|
826
|
+
d['title'] = obj.title
|
|
827
|
+
if obj.description:
|
|
828
|
+
d['description'] = obj.description
|
|
829
|
+
if obj.extra:
|
|
830
|
+
d.update(obj.extra)
|
|
831
|
+
return d
|
|
832
|
+
|
|
833
|
+
page_metadata.update(extract_meta(static_meta))
|
|
834
|
+
page_metadata.update(extract_meta(dynamic_meta))
|
|
835
|
+
else:
|
|
836
|
+
content = load_template_file(file_path)
|
|
837
|
+
|
|
838
|
+
content = await transform_components(content, base_dir=route_dir)
|
|
839
|
+
full_context = {**kwargs, "request": request, **page_layout_props}
|
|
840
|
+
|
|
841
|
+
html_output, root_layout_id = await render_with_nested_layouts(
|
|
842
|
+
children=content,
|
|
843
|
+
route_dir=route_dir,
|
|
844
|
+
page_metadata=page_metadata,
|
|
845
|
+
page_layout_props=page_layout_props,
|
|
846
|
+
context_data=full_context,
|
|
847
|
+
page_component_source=page_content_source,
|
|
848
|
+
control_mode=True,
|
|
849
|
+
component_compiler=transform_components
|
|
850
|
+
)
|
|
851
|
+
|
|
852
|
+
html_output = transform_scripts(html_output)
|
|
853
|
+
response = HTMLResponse(content=html_output)
|
|
854
|
+
response.headers['X-PP-Root-Layout'] = root_layout_id
|
|
855
|
+
|
|
856
|
+
# Cache Save Logic
|
|
857
|
+
should_cache = False
|
|
858
|
+
if req_should_cache is True:
|
|
859
|
+
should_cache = True
|
|
860
|
+
elif req_should_cache is False:
|
|
861
|
+
should_cache = False
|
|
862
|
+
else:
|
|
863
|
+
should_cache = CACHE_ENABLED
|
|
864
|
+
|
|
865
|
+
if should_cache and request.method == 'GET':
|
|
866
|
+
ttl_to_save = req_cache_ttl if req_cache_ttl > 0 else DEFAULT_TTL
|
|
867
|
+
CacheHandler.save_cache(current_uri, html_output, ttl_to_save)
|
|
868
|
+
|
|
869
|
+
return response
|
|
870
|
+
|
|
871
|
+
endpoint = file_path.replace('/', '_').replace('\\', '_').replace(
|
|
872
|
+
'.', '_').replace('[', '').replace(']', '').replace('(', '').replace(')', '')
|
|
873
|
+
|
|
874
|
+
route_methods = ['GET', 'POST']
|
|
875
|
+
if file_path.endswith('.py'):
|
|
876
|
+
module = load_route_module(file_path)
|
|
877
|
+
declared_route_methods = getattr(module, 'route_methods', None)
|
|
878
|
+
if isinstance(declared_route_methods, (list, tuple)) and declared_route_methods:
|
|
879
|
+
normalized_methods = [
|
|
880
|
+
str(method).strip().upper()
|
|
881
|
+
for method in declared_route_methods
|
|
882
|
+
if str(method).strip()
|
|
883
|
+
]
|
|
884
|
+
if normalized_methods:
|
|
885
|
+
route_methods = list(dict.fromkeys(normalized_methods))
|
|
886
|
+
|
|
887
|
+
app.add_api_route(url_pattern, make_handler,
|
|
888
|
+
methods=route_methods, name=endpoint)
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
register_routes()
|
|
892
|
+
register_rpc_routes(app)
|
|
893
|
+
|
|
894
|
+
# ====
|
|
895
|
+
# Custom Exception Handlers (404 & 500)
|
|
896
|
+
# ====
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
@app.exception_handler(StarletteHTTPException)
|
|
900
|
+
async def custom_404_handler(request: Request, exc: StarletteHTTPException):
|
|
901
|
+
if exc.status_code == 404:
|
|
902
|
+
not_found_path = os.path.join('src', 'app', 'not-found.html')
|
|
903
|
+
if os.path.exists(not_found_path):
|
|
904
|
+
with open(not_found_path, 'r', encoding='utf-8') as f:
|
|
905
|
+
content = f.read()
|
|
906
|
+
html_output, root_layout_id = await render_with_nested_layouts(
|
|
907
|
+
children=content,
|
|
908
|
+
route_dir='src/app',
|
|
909
|
+
page_metadata={
|
|
910
|
+
'title': "Page Not Found",
|
|
911
|
+
'description': "The page you are looking for does not exist."
|
|
912
|
+
},
|
|
913
|
+
page_layout_props=None,
|
|
914
|
+
context_data={'request': request},
|
|
915
|
+
page_component_source=not_found_path,
|
|
916
|
+
control_mode=True,
|
|
917
|
+
transform_fn=transform_scripts
|
|
918
|
+
)
|
|
919
|
+
resp = HTMLResponse(content=html_output, status_code=404)
|
|
920
|
+
resp.headers['X-PP-Root-Layout'] = root_layout_id
|
|
921
|
+
return resp
|
|
922
|
+
return HTMLResponse(content=f"<h1>{exc.detail}</h1>", status_code=exc.status_code)
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
@app.exception_handler(Exception)
|
|
926
|
+
async def custom_general_exception_handler(request: Request, exc: Exception):
|
|
927
|
+
full_trace = traceback.format_exc()
|
|
928
|
+
if IS_PRODUCTION:
|
|
929
|
+
print(
|
|
930
|
+
f"[error] {request.method} {request.url.path}: {exc.__class__.__name__}",
|
|
931
|
+
flush=True,
|
|
932
|
+
)
|
|
933
|
+
else:
|
|
934
|
+
print(full_trace)
|
|
935
|
+
error_message = _client_error_message(exc)
|
|
936
|
+
error_trace = full_trace if _should_show_error_trace(request) else None
|
|
937
|
+
|
|
938
|
+
error_page_path = os.path.join('src', 'app', 'error.html')
|
|
939
|
+
if os.path.exists(error_page_path):
|
|
940
|
+
with open(error_page_path, 'r', encoding='utf-8') as f:
|
|
941
|
+
raw_content = f.read()
|
|
942
|
+
context_data = {'request': request,
|
|
943
|
+
'error_message': error_message, 'error_trace': error_trace}
|
|
944
|
+
try:
|
|
945
|
+
rendered_content = compile_template(
|
|
946
|
+
raw_content).render(**context_data)
|
|
947
|
+
html_output, root_layout_id = await render_with_nested_layouts(
|
|
948
|
+
children=rendered_content,
|
|
949
|
+
route_dir='src/app',
|
|
950
|
+
page_metadata={
|
|
951
|
+
'title': 'Application Error',
|
|
952
|
+
'description': 'An unexpected error occurred.'
|
|
953
|
+
},
|
|
954
|
+
page_layout_props=None,
|
|
955
|
+
context_data=context_data,
|
|
956
|
+
page_component_source=error_page_path,
|
|
957
|
+
control_mode=True,
|
|
958
|
+
transform_fn=transform_scripts
|
|
959
|
+
)
|
|
960
|
+
resp = HTMLResponse(content=html_output, status_code=500)
|
|
961
|
+
resp.headers['X-PP-Root-Layout'] = root_layout_id
|
|
962
|
+
return resp
|
|
963
|
+
except Exception as render_exc:
|
|
964
|
+
print("Error rendering error.html:", render_exc)
|
|
965
|
+
return HTMLResponse(
|
|
966
|
+
content=f"<h1>500 - Internal Server Error</h1><p>{error_message}</p>",
|
|
967
|
+
status_code=500
|
|
968
|
+
)
|
|
969
|
+
|
|
970
|
+
# ====
|
|
971
|
+
# Middleware Order (LAST added runs FIRST)
|
|
972
|
+
# ====
|
|
973
|
+
app.add_middleware(RPCMiddleware)
|
|
974
|
+
app.add_middleware(AuthMiddleware)
|
|
975
|
+
app.add_middleware(CSRFMiddleware)
|
|
976
|
+
|
|
977
|
+
app.add_middleware(
|
|
978
|
+
SessionMiddleware,
|
|
979
|
+
secret_key=_get_session_secret(),
|
|
980
|
+
session_cookie=SESSION_COOKIE_NAME,
|
|
981
|
+
max_age=SESSION_LIFETIME_HOURS * 3600,
|
|
982
|
+
same_site='lax',
|
|
983
|
+
https_only=IS_PRODUCTION,
|
|
984
|
+
path='/',
|
|
985
|
+
)
|
|
986
|
+
app.add_middleware(BodySizeLimitMiddleware)
|
|
987
|
+
app.add_middleware(SecurityHeadersMiddleware)
|
|
988
|
+
|
|
989
|
+
if not IS_PRODUCTION:
|
|
990
|
+
app.add_middleware(RequestDiagnosticsMiddleware)
|
|
991
|
+
|
|
992
|
+
if __name__ == '__main__':
|
|
993
|
+
port = int(os.getenv('PORT', 5091))
|
|
994
|
+
workers = max(1, int(os.getenv('UVICORN_WORKERS', '1')))
|
|
995
|
+
uvicorn.run(
|
|
996
|
+
"main:app",
|
|
997
|
+
host="0.0.0.0",
|
|
998
|
+
port=port,
|
|
999
|
+
reload=False,
|
|
1000
|
+
workers=workers,
|
|
1001
|
+
)
|