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/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)
@@ -0,0 +1,6 @@
1
+ export default {
2
+ plugins: {
3
+ "@tailwindcss/postcss": {},
4
+ cssnano: {},
5
+ },
6
+ };