create-caspian-app 0.0.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/README.md +418 -0
- package/dist/.prettierrc +10 -0
- package/dist/app-gitignore +28 -0
- package/dist/caspian.js +2 -0
- package/dist/index.js +2 -0
- package/dist/main.py +525 -0
- package/dist/postcss.config.js +6 -0
- package/dist/public/css/styles.css +1 -0
- package/dist/public/favicon.ico +0 -0
- package/dist/public/js/main.js +1 -0
- package/dist/public/js/pp-reactive-v1.js +1 -0
- package/dist/pyproject.toml +9 -0
- package/dist/settings/bs-config.json +7 -0
- package/dist/settings/bs-config.ts +291 -0
- package/dist/settings/build.ts +19 -0
- package/dist/settings/component-map.json +1361 -0
- package/dist/settings/component-map.ts +381 -0
- package/dist/settings/files-list.json +36 -0
- package/dist/settings/files-list.ts +49 -0
- package/dist/settings/project-name.ts +119 -0
- package/dist/settings/python-server.ts +173 -0
- package/dist/settings/utils.ts +239 -0
- package/dist/settings/vite-plugins/generate-global-types.ts +246 -0
- package/dist/src/app/error.html +130 -0
- package/dist/src/app/globals.css +24 -0
- package/dist/src/app/index.html +159 -0
- package/dist/src/app/layout.html +22 -0
- package/dist/src/app/not-found.html +18 -0
- package/dist/ts/global-functions.ts +35 -0
- package/dist/ts/main.ts +5 -0
- package/dist/tsconfig.json +111 -0
- package/dist/vite.config.ts +61 -0
- package/package.json +42 -0
- package/tsconfig.json +49 -0
package/dist/main.py
ADDED
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
from casp.components_compiler import transform_components
|
|
2
|
+
from casp.scripts_type import transform_scripts
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import importlib.util
|
|
6
|
+
import mimetypes
|
|
7
|
+
import secrets
|
|
8
|
+
import traceback
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from urllib.parse import urlparse
|
|
11
|
+
from fastapi import FastAPI, Request, Response
|
|
12
|
+
from fastapi.responses import RedirectResponse, FileResponse, HTMLResponse
|
|
13
|
+
from starlette.middleware.sessions import SessionMiddleware
|
|
14
|
+
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
15
|
+
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
16
|
+
from dotenv import load_dotenv
|
|
17
|
+
import uvicorn
|
|
18
|
+
from casp.state_manager import StateManager
|
|
19
|
+
from casp.cache_handler import CacheHandler
|
|
20
|
+
from casp.caspian_config import get_files_index, get_config
|
|
21
|
+
from casp.auth import (
|
|
22
|
+
Auth,
|
|
23
|
+
AuthSettings,
|
|
24
|
+
GoogleProvider,
|
|
25
|
+
GithubProvider,
|
|
26
|
+
configure_auth,
|
|
27
|
+
)
|
|
28
|
+
from casp.rpc import register_rpc_routes
|
|
29
|
+
from casp.layout import render_with_nested_layouts, string_env, load_layout, load_page
|
|
30
|
+
import hashlib
|
|
31
|
+
|
|
32
|
+
load_dotenv()
|
|
33
|
+
|
|
34
|
+
cfg = get_config()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ====
|
|
38
|
+
# AUTH CONFIGURATION (App behavior - customize here)
|
|
39
|
+
# ====
|
|
40
|
+
def setup_auth():
|
|
41
|
+
"""
|
|
42
|
+
Configure authentication behavior.
|
|
43
|
+
Secrets (AUTH_SECRET, OAUTH credentials) come from .env
|
|
44
|
+
"""
|
|
45
|
+
configure_auth(AuthSettings(
|
|
46
|
+
# Token settings
|
|
47
|
+
default_token_validity="7d",
|
|
48
|
+
token_auto_refresh=False,
|
|
49
|
+
|
|
50
|
+
# Route protection mode
|
|
51
|
+
# True = all routes private except public_routes and auth_routes
|
|
52
|
+
# False = only private_routes require auth
|
|
53
|
+
is_all_routes_private=False,
|
|
54
|
+
|
|
55
|
+
# Route lists
|
|
56
|
+
private_routes=[], # Used when is_all_routes_private=False
|
|
57
|
+
public_routes=["/"],
|
|
58
|
+
auth_routes=["/signin", "/signup"],
|
|
59
|
+
|
|
60
|
+
# Role-based access (optional)
|
|
61
|
+
is_role_based=False,
|
|
62
|
+
role_identifier="role",
|
|
63
|
+
role_based_routes={
|
|
64
|
+
# "/admin": [AuthRole.ADMIN],
|
|
65
|
+
# "/reports": [AuthRole.ADMIN, AuthRole.USER],
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
# Redirects
|
|
69
|
+
default_signin_redirect="/dashboard",
|
|
70
|
+
default_signout_redirect="/signin",
|
|
71
|
+
api_auth_prefix="/api/auth",
|
|
72
|
+
|
|
73
|
+
# Callbacks (optional hooks)
|
|
74
|
+
# lambda user: print(f"User signed in: {user.get('email')}")
|
|
75
|
+
on_sign_in=None,
|
|
76
|
+
on_sign_out=None, # lambda: print("User signed out")
|
|
77
|
+
# lambda req: RedirectResponse("/custom-login", 303)
|
|
78
|
+
on_auth_failure=None,
|
|
79
|
+
))
|
|
80
|
+
|
|
81
|
+
# Register OAuth providers (secrets from .env)
|
|
82
|
+
Auth.set_providers(
|
|
83
|
+
GithubProvider(), # Uses GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET from .env
|
|
84
|
+
# Uses GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_REDIRECT_URI from .env
|
|
85
|
+
GoogleProvider(),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# Initialize auth configuration
|
|
90
|
+
setup_auth()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
app = FastAPI(
|
|
94
|
+
title=cfg.projectName,
|
|
95
|
+
version=cfg.version,
|
|
96
|
+
docs_url="/docs" if cfg.backendOnly else None,
|
|
97
|
+
redoc_url="/redoc" if cfg.backendOnly else None,
|
|
98
|
+
openapi_url="/openapi.json" if cfg.backendOnly else None,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# ====
|
|
102
|
+
# Configuration
|
|
103
|
+
# ====
|
|
104
|
+
SESSION_LIFETIME_HOURS = int(os.getenv('SESSION_LIFETIME_HOURS', 7))
|
|
105
|
+
MAX_CONTENT_LENGTH_MB = int(os.getenv('MAX_CONTENT_LENGTH_MB', 16))
|
|
106
|
+
IS_PRODUCTION = os.getenv('APP_ENV') == 'production'
|
|
107
|
+
CACHE_ENABLED = os.getenv('CACHE_ENABLED', 'false').lower() == 'true'
|
|
108
|
+
DEFAULT_TTL = int(os.getenv('CACHE_TTL', 600))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ====
|
|
112
|
+
# Static File Routes
|
|
113
|
+
# ====
|
|
114
|
+
|
|
115
|
+
@app.get('/css/{filename:path}')
|
|
116
|
+
async def serve_css(filename: str):
|
|
117
|
+
file_path = Path('public/css') / filename
|
|
118
|
+
if not file_path.exists():
|
|
119
|
+
return Response(status_code=404)
|
|
120
|
+
return FileResponse(file_path, media_type='text/css')
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@app.get('/js/{filename:path}')
|
|
124
|
+
async def serve_js(filename: str):
|
|
125
|
+
file_path = Path('public/js') / filename
|
|
126
|
+
if not file_path.exists():
|
|
127
|
+
return Response(status_code=404)
|
|
128
|
+
return FileResponse(file_path, media_type='application/javascript')
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@app.get('/assets/{filename:path}')
|
|
132
|
+
async def serve_assets(filename: str):
|
|
133
|
+
file_path = Path('public/assets') / filename
|
|
134
|
+
if not file_path.exists():
|
|
135
|
+
return Response(status_code=404)
|
|
136
|
+
mime_type, _ = mimetypes.guess_type(str(file_path))
|
|
137
|
+
return FileResponse(file_path, media_type=mime_type or 'application/octet-stream')
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@app.get('/favicon.ico')
|
|
141
|
+
async def favicon():
|
|
142
|
+
file_path = Path('public/favicon.ico')
|
|
143
|
+
if not file_path.exists():
|
|
144
|
+
return Response(status_code=404)
|
|
145
|
+
return FileResponse(file_path, media_type='image/x-icon')
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# ====
|
|
149
|
+
# Pure ASGI Middleware Classes
|
|
150
|
+
# ====
|
|
151
|
+
|
|
152
|
+
class CSRFMiddleware:
|
|
153
|
+
"""CSRF middleware that properly handles session modifications."""
|
|
154
|
+
|
|
155
|
+
def __init__(self, app: ASGIApp):
|
|
156
|
+
self.app = app
|
|
157
|
+
|
|
158
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send):
|
|
159
|
+
if scope["type"] != "http":
|
|
160
|
+
await self.app(scope, receive, send)
|
|
161
|
+
return
|
|
162
|
+
|
|
163
|
+
request = Request(scope, receive, send)
|
|
164
|
+
|
|
165
|
+
csrf_token = request.session.get("csrf_token")
|
|
166
|
+
if not csrf_token:
|
|
167
|
+
csrf_token = secrets.token_hex(32)
|
|
168
|
+
request.session["csrf_token"] = csrf_token
|
|
169
|
+
|
|
170
|
+
async def send_wrapper(message):
|
|
171
|
+
if message["type"] == "http.response.start":
|
|
172
|
+
headers = dict(message.get("headers", []))
|
|
173
|
+
cookie_value = f"pp_csrf={csrf_token}; Path=/; SameSite=Lax"
|
|
174
|
+
if IS_PRODUCTION:
|
|
175
|
+
cookie_value += "; Secure"
|
|
176
|
+
|
|
177
|
+
new_headers = list(message.get("headers", []))
|
|
178
|
+
new_headers.append((b"set-cookie", cookie_value.encode()))
|
|
179
|
+
message = {**message, "headers": new_headers}
|
|
180
|
+
await send(message)
|
|
181
|
+
|
|
182
|
+
await self.app(scope, receive, send_wrapper)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class AuthMiddleware:
|
|
186
|
+
"""Auth middleware using pure ASGI pattern for proper session handling."""
|
|
187
|
+
|
|
188
|
+
def __init__(self, app: ASGIApp):
|
|
189
|
+
self.app = app
|
|
190
|
+
|
|
191
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send):
|
|
192
|
+
if scope["type"] != "http":
|
|
193
|
+
await self.app(scope, receive, send)
|
|
194
|
+
return
|
|
195
|
+
|
|
196
|
+
request = Request(scope, receive, send)
|
|
197
|
+
path = request.url.path
|
|
198
|
+
|
|
199
|
+
# Skip for static files
|
|
200
|
+
if path.startswith(('/css/', '/js/', '/assets/', '/favicon.ico')):
|
|
201
|
+
await self.app(scope, receive, send)
|
|
202
|
+
return
|
|
203
|
+
|
|
204
|
+
# Initialize state manager and auth context
|
|
205
|
+
StateManager.init(request)
|
|
206
|
+
Auth.set_request(request)
|
|
207
|
+
auth_inst = Auth.get_instance()
|
|
208
|
+
settings = auth_inst.settings
|
|
209
|
+
|
|
210
|
+
# Handle OAuth provider routes
|
|
211
|
+
providers = Auth.get_providers()
|
|
212
|
+
if providers:
|
|
213
|
+
oauth_response = auth_inst.auth_providers(*providers)
|
|
214
|
+
if oauth_response:
|
|
215
|
+
await oauth_response(scope, receive, send)
|
|
216
|
+
return
|
|
217
|
+
|
|
218
|
+
# Public routes - allow through
|
|
219
|
+
if auth_inst.is_public_route(path):
|
|
220
|
+
await self.app(scope, receive, send)
|
|
221
|
+
return
|
|
222
|
+
|
|
223
|
+
# Auth routes (signin/signup) - redirect if already authenticated
|
|
224
|
+
if auth_inst.is_auth_route(path):
|
|
225
|
+
if auth_inst.is_authenticated():
|
|
226
|
+
response = RedirectResponse(
|
|
227
|
+
url=settings.default_signin_redirect,
|
|
228
|
+
status_code=303
|
|
229
|
+
)
|
|
230
|
+
await response(scope, receive, send)
|
|
231
|
+
return
|
|
232
|
+
await self.app(scope, receive, send)
|
|
233
|
+
return
|
|
234
|
+
|
|
235
|
+
# Role-based route check
|
|
236
|
+
if settings.is_role_based:
|
|
237
|
+
required_roles = auth_inst.get_required_roles(path)
|
|
238
|
+
if required_roles:
|
|
239
|
+
if not auth_inst.is_authenticated():
|
|
240
|
+
response = RedirectResponse(
|
|
241
|
+
url=f'/signin?next={path}',
|
|
242
|
+
status_code=303
|
|
243
|
+
)
|
|
244
|
+
await response(scope, receive, send)
|
|
245
|
+
return
|
|
246
|
+
|
|
247
|
+
user = auth_inst.get_payload()
|
|
248
|
+
if not auth_inst.check_role(user, required_roles):
|
|
249
|
+
response = RedirectResponse(
|
|
250
|
+
url='/unauthorized',
|
|
251
|
+
status_code=303
|
|
252
|
+
)
|
|
253
|
+
await response(scope, receive, send)
|
|
254
|
+
return
|
|
255
|
+
|
|
256
|
+
# Private routes - redirect to signin if not authenticated
|
|
257
|
+
if auth_inst.is_private_route(path):
|
|
258
|
+
if not auth_inst.is_authenticated():
|
|
259
|
+
if settings.on_auth_failure:
|
|
260
|
+
response = settings.on_auth_failure(request)
|
|
261
|
+
await response(scope, receive, send)
|
|
262
|
+
return
|
|
263
|
+
response = RedirectResponse(
|
|
264
|
+
url=f'/signin?next={path}',
|
|
265
|
+
status_code=303
|
|
266
|
+
)
|
|
267
|
+
await response(scope, receive, send)
|
|
268
|
+
return
|
|
269
|
+
|
|
270
|
+
await self.app(scope, receive, send)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
class RPCMiddleware:
|
|
274
|
+
"""RPC middleware using pure ASGI pattern."""
|
|
275
|
+
|
|
276
|
+
def __init__(self, app: ASGIApp):
|
|
277
|
+
self.app = app
|
|
278
|
+
|
|
279
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send):
|
|
280
|
+
if scope["type"] != "http":
|
|
281
|
+
await self.app(scope, receive, send)
|
|
282
|
+
return
|
|
283
|
+
|
|
284
|
+
request = Request(scope, receive, send)
|
|
285
|
+
|
|
286
|
+
if request.headers.get('X-PP-RPC') == 'true' and request.method == 'POST':
|
|
287
|
+
from casp.rpc import _handle_rpc_request
|
|
288
|
+
session = dict(request.session) if hasattr(
|
|
289
|
+
request, 'session') else {}
|
|
290
|
+
response = await _handle_rpc_request(request, session)
|
|
291
|
+
await response(scope, receive, send)
|
|
292
|
+
return
|
|
293
|
+
|
|
294
|
+
await self.app(scope, receive, send)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
# ====
|
|
298
|
+
# Route Registration
|
|
299
|
+
# ====
|
|
300
|
+
|
|
301
|
+
def load_route_module(file_path: str):
|
|
302
|
+
# IMPROVEMENT 1: Generate a unique name for the module based on the file path.
|
|
303
|
+
# This ensures that tracebacks reference a unique ID instead of just "route_module".
|
|
304
|
+
unique_id = hashlib.md5(file_path.encode()).hexdigest()[:8]
|
|
305
|
+
module_name = f"page_{unique_id}"
|
|
306
|
+
|
|
307
|
+
spec = importlib.util.spec_from_file_location(module_name, file_path)
|
|
308
|
+
assert spec is not None and spec.loader is not None, f"Cannot load spec for {file_path}"
|
|
309
|
+
|
|
310
|
+
module = importlib.util.module_from_spec(spec)
|
|
311
|
+
spec.loader.exec_module(module)
|
|
312
|
+
setattr(module, 'load_page', load_page)
|
|
313
|
+
return module
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def register_routes():
|
|
317
|
+
idx = get_files_index()
|
|
318
|
+
for route in idx.routes:
|
|
319
|
+
base_path = f"src/app/{route.fs_dir}" if route.fs_dir else "src/app"
|
|
320
|
+
file_name = "index.py" if route.has_py else "index.html"
|
|
321
|
+
full_path = f"{base_path}/{file_name}".replace('//', '/')
|
|
322
|
+
register_single_route(route.fastapi_rule, full_path)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def register_single_route(url_pattern: str, file_path: str):
|
|
326
|
+
async def make_handler(request: Request):
|
|
327
|
+
kwargs = dict(request.path_params)
|
|
328
|
+
|
|
329
|
+
CacheHandler.is_cacheable = None
|
|
330
|
+
CacheHandler.ttl = 0
|
|
331
|
+
current_uri = request.url.path
|
|
332
|
+
|
|
333
|
+
if request.method == 'GET':
|
|
334
|
+
cached_resp = CacheHandler.serve_cache(current_uri, DEFAULT_TTL)
|
|
335
|
+
if cached_resp:
|
|
336
|
+
return HTMLResponse(content=cached_resp)
|
|
337
|
+
|
|
338
|
+
route_dir = os.path.dirname(file_path)
|
|
339
|
+
|
|
340
|
+
# IMPROVEMENT: Default to None.
|
|
341
|
+
# This allows layout.py to know if the page actually provided a value.
|
|
342
|
+
title = None
|
|
343
|
+
description = None
|
|
344
|
+
page_props = {}
|
|
345
|
+
content = ""
|
|
346
|
+
|
|
347
|
+
if file_path.endswith('.py'):
|
|
348
|
+
module = load_route_module(file_path)
|
|
349
|
+
|
|
350
|
+
if not hasattr(module, 'page'):
|
|
351
|
+
raise AttributeError(
|
|
352
|
+
f"The file '{file_path}' is missing the required 'def page():' function.")
|
|
353
|
+
|
|
354
|
+
result = module.page(kwargs) if kwargs else module.page()
|
|
355
|
+
|
|
356
|
+
if isinstance(result, Response):
|
|
357
|
+
return result
|
|
358
|
+
|
|
359
|
+
# 1. Try static module variables (returns None if missing)
|
|
360
|
+
title = getattr(module, 'title', None)
|
|
361
|
+
description = getattr(module, 'description', None)
|
|
362
|
+
|
|
363
|
+
revalidate = getattr(module, 'revalidate', 0)
|
|
364
|
+
if revalidate > 0:
|
|
365
|
+
CacheHandler.is_cacheable = True
|
|
366
|
+
CacheHandler.ttl = revalidate
|
|
367
|
+
elif hasattr(module, 'is_cacheable'):
|
|
368
|
+
CacheHandler.is_cacheable = module.is_cacheable
|
|
369
|
+
|
|
370
|
+
# 2. Handle Tuple Return (HTML, Props) - Dynamic Overrides
|
|
371
|
+
if isinstance(result, tuple):
|
|
372
|
+
content = str(result[0])
|
|
373
|
+
if len(result) >= 2 and isinstance(result[1], dict):
|
|
374
|
+
dynamic_props = result[1]
|
|
375
|
+
|
|
376
|
+
# Explicit dynamic props override static ones
|
|
377
|
+
if 'title' in dynamic_props:
|
|
378
|
+
title = dynamic_props.pop('title')
|
|
379
|
+
if 'description' in dynamic_props:
|
|
380
|
+
description = dynamic_props.pop('description')
|
|
381
|
+
|
|
382
|
+
page_props = dynamic_props
|
|
383
|
+
else:
|
|
384
|
+
content = str(result)
|
|
385
|
+
else:
|
|
386
|
+
content = load_layout(file_path)
|
|
387
|
+
|
|
388
|
+
# Transform components
|
|
389
|
+
content = transform_components(content, base_dir=route_dir)
|
|
390
|
+
full_context = {**kwargs, "request": request, **page_props}
|
|
391
|
+
|
|
392
|
+
# 3. Render Layouts (Passing None allows layouts to fill in gaps)
|
|
393
|
+
html_output, root_layout_id = render_with_nested_layouts(
|
|
394
|
+
content,
|
|
395
|
+
route_dir,
|
|
396
|
+
title,
|
|
397
|
+
description,
|
|
398
|
+
context_data=full_context
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
html_output = transform_scripts(html_output)
|
|
402
|
+
|
|
403
|
+
# ... [Rest of the function: Response, Caching, etc.] ...
|
|
404
|
+
response = HTMLResponse(content=html_output)
|
|
405
|
+
response.headers['X-PP-Root-Layout'] = root_layout_id
|
|
406
|
+
|
|
407
|
+
should_cache = False
|
|
408
|
+
if CacheHandler.is_cacheable == True:
|
|
409
|
+
should_cache = True
|
|
410
|
+
elif CacheHandler.is_cacheable == False:
|
|
411
|
+
should_cache = False
|
|
412
|
+
else:
|
|
413
|
+
should_cache = CACHE_ENABLED
|
|
414
|
+
|
|
415
|
+
if should_cache and request.method == 'GET':
|
|
416
|
+
ttl_to_save = CacheHandler.ttl if CacheHandler.ttl > 0 else DEFAULT_TTL
|
|
417
|
+
CacheHandler.save_cache(current_uri, html_output, ttl_to_save)
|
|
418
|
+
|
|
419
|
+
return response
|
|
420
|
+
|
|
421
|
+
endpoint = file_path.replace('/', '_').replace('\\', '_').replace(
|
|
422
|
+
'.', '_').replace('[', '').replace(']', '').replace('(', '').replace(')', '')
|
|
423
|
+
app.add_api_route(url_pattern, make_handler, methods=[
|
|
424
|
+
'GET', 'POST'], name=endpoint)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
# Register routes first
|
|
428
|
+
register_routes()
|
|
429
|
+
register_rpc_routes(app)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
# ====
|
|
433
|
+
# Custom Exception Handlers (404 & 500)
|
|
434
|
+
# ====
|
|
435
|
+
|
|
436
|
+
@app.exception_handler(StarletteHTTPException)
|
|
437
|
+
async def custom_404_handler(request: Request, exc: StarletteHTTPException):
|
|
438
|
+
if exc.status_code == 404:
|
|
439
|
+
not_found_path = os.path.join('src', 'app', 'not-found.html')
|
|
440
|
+
|
|
441
|
+
if os.path.exists(not_found_path):
|
|
442
|
+
with open(not_found_path, 'r', encoding='utf-8') as f:
|
|
443
|
+
content = f.read()
|
|
444
|
+
|
|
445
|
+
html_output, root_layout_id = render_with_nested_layouts(
|
|
446
|
+
children=content,
|
|
447
|
+
route_dir='src/app',
|
|
448
|
+
title='Page Not Found',
|
|
449
|
+
description='The page you are looking for does not exist.',
|
|
450
|
+
context_data={'request': request},
|
|
451
|
+
transform_fn=transform_scripts
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
response = HTMLResponse(content=html_output, status_code=404)
|
|
455
|
+
response.headers['X-PP-Root-Layout'] = root_layout_id
|
|
456
|
+
return response
|
|
457
|
+
|
|
458
|
+
return HTMLResponse(content=f"<h1>{exc.detail}</h1>", status_code=exc.status_code)
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
@app.exception_handler(Exception)
|
|
462
|
+
async def custom_general_exception_handler(request: Request, exc: Exception):
|
|
463
|
+
print(traceback.format_exc())
|
|
464
|
+
|
|
465
|
+
error_message = str(exc)
|
|
466
|
+
error_trace = traceback.format_exc() if not IS_PRODUCTION else None
|
|
467
|
+
|
|
468
|
+
error_page_path = os.path.join('src', 'app', 'error.html')
|
|
469
|
+
|
|
470
|
+
if os.path.exists(error_page_path):
|
|
471
|
+
with open(error_page_path, 'r', encoding='utf-8') as f:
|
|
472
|
+
raw_content = f.read()
|
|
473
|
+
|
|
474
|
+
context_data = {
|
|
475
|
+
'request': request,
|
|
476
|
+
'error_message': error_message,
|
|
477
|
+
'error_trace': error_trace
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
try:
|
|
481
|
+
rendered_content = string_env.from_string(
|
|
482
|
+
raw_content).render(**context_data)
|
|
483
|
+
|
|
484
|
+
html_output, root_layout_id = render_with_nested_layouts(
|
|
485
|
+
children=rendered_content,
|
|
486
|
+
route_dir='src/app',
|
|
487
|
+
title='Application Error',
|
|
488
|
+
description='An unexpected error occurred.',
|
|
489
|
+
context_data=context_data,
|
|
490
|
+
transform_fn=transform_scripts
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
response = HTMLResponse(content=html_output, status_code=500)
|
|
494
|
+
response.headers['X-PP-Root-Layout'] = root_layout_id
|
|
495
|
+
return response
|
|
496
|
+
except Exception as render_exc:
|
|
497
|
+
print("Error rendering error.html:", render_exc)
|
|
498
|
+
|
|
499
|
+
return HTMLResponse(
|
|
500
|
+
content=f"<h1>500 - Internal Server Error</h1><p>{error_message}</p>",
|
|
501
|
+
status_code=500
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
# ====
|
|
506
|
+
# Middleware Order (LAST added runs FIRST)
|
|
507
|
+
# ====
|
|
508
|
+
app.add_middleware(RPCMiddleware)
|
|
509
|
+
app.add_middleware(AuthMiddleware)
|
|
510
|
+
app.add_middleware(CSRFMiddleware)
|
|
511
|
+
|
|
512
|
+
app.add_middleware(
|
|
513
|
+
SessionMiddleware,
|
|
514
|
+
secret_key=os.getenv('AUTH_SECRET', 'change-me'),
|
|
515
|
+
session_cookie=os.getenv('AUTH_COOKIE_NAME', 'session'),
|
|
516
|
+
max_age=SESSION_LIFETIME_HOURS * 3600,
|
|
517
|
+
same_site='lax',
|
|
518
|
+
https_only=IS_PRODUCTION,
|
|
519
|
+
path='/',
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
if __name__ == '__main__':
|
|
524
|
+
port = int(os.getenv('PORT', 5091))
|
|
525
|
+
uvicorn.run("main:app", host="0.0.0.0", port=port, reload=False)
|