caspian-utils 0.0.12__py3-none-any.whl
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.
- casp/__init__.py +0 -0
- casp/auth.py +537 -0
- casp/cache_handler.py +180 -0
- casp/caspian_config.py +441 -0
- casp/component_decorator.py +183 -0
- casp/components_compiler.py +293 -0
- casp/html_attrs.py +93 -0
- casp/layout.py +474 -0
- casp/loading.py +25 -0
- casp/rpc.py +230 -0
- casp/scripts_type.py +21 -0
- casp/state_manager.py +134 -0
- casp/string_helpers.py +18 -0
- casp/tw.py +31 -0
- casp/validate.py +747 -0
- caspian_utils-0.0.12.dist-info/METADATA +214 -0
- caspian_utils-0.0.12.dist-info/RECORD +19 -0
- caspian_utils-0.0.12.dist-info/WHEEL +5 -0
- caspian_utils-0.0.12.dist-info/top_level.txt +1 -0
casp/rpc.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import traceback
|
|
2
|
+
from datetime import timedelta
|
|
3
|
+
from .auth import auth
|
|
4
|
+
from fastapi import FastAPI, Request, Response
|
|
5
|
+
from fastapi.responses import JSONResponse
|
|
6
|
+
from slowapi import Limiter
|
|
7
|
+
from slowapi.util import get_remote_address
|
|
8
|
+
from slowapi.errors import RateLimitExceeded
|
|
9
|
+
from functools import wraps
|
|
10
|
+
from .caspian_config import get_files_index
|
|
11
|
+
from typing import Optional, Any
|
|
12
|
+
import inspect
|
|
13
|
+
import os
|
|
14
|
+
import json
|
|
15
|
+
import hmac
|
|
16
|
+
import dataclasses
|
|
17
|
+
from datetime import datetime, date
|
|
18
|
+
|
|
19
|
+
RPC_REGISTRY = {}
|
|
20
|
+
RPC_META = {}
|
|
21
|
+
|
|
22
|
+
CORS_ALLOWED_ORIGINS = os.getenv('CORS_ALLOWED_ORIGINS', '').split(
|
|
23
|
+
',') if os.getenv('CORS_ALLOWED_ORIGINS') else None
|
|
24
|
+
IS_PRODUCTION = os.getenv('APP_ENV') == 'production'
|
|
25
|
+
|
|
26
|
+
RATE_LIMIT_DEFAULT = os.getenv('RATE_LIMIT_DEFAULT', '200/minute')
|
|
27
|
+
RATE_LIMIT_RPC = os.getenv('RATE_LIMIT_RPC', '60/minute')
|
|
28
|
+
RATE_LIMIT_AUTH = os.getenv('RATE_LIMIT_AUTH', '10/minute')
|
|
29
|
+
|
|
30
|
+
limiter = Limiter(key_func=get_remote_address)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _serialize_result(obj: Any) -> Any:
|
|
34
|
+
"""Convert objects to JSON-serializable format."""
|
|
35
|
+
if obj is None:
|
|
36
|
+
return None
|
|
37
|
+
if isinstance(obj, (str, int, float, bool)):
|
|
38
|
+
return obj
|
|
39
|
+
if isinstance(obj, datetime):
|
|
40
|
+
return obj.isoformat()
|
|
41
|
+
if isinstance(obj, date):
|
|
42
|
+
return obj.isoformat()
|
|
43
|
+
if isinstance(obj, (list, tuple)):
|
|
44
|
+
return [_serialize_result(item) for item in obj]
|
|
45
|
+
if isinstance(obj, dict):
|
|
46
|
+
return {k: _serialize_result(v) for k, v in obj.items()}
|
|
47
|
+
if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
|
|
48
|
+
return {k: _serialize_result(v) for k, v in dataclasses.asdict(obj).items()}
|
|
49
|
+
# Pydantic v2
|
|
50
|
+
model_dump = getattr(obj, 'model_dump', None)
|
|
51
|
+
if callable(model_dump):
|
|
52
|
+
return model_dump()
|
|
53
|
+
# Fallback to __dict__
|
|
54
|
+
obj_dict = getattr(obj, '__dict__', None)
|
|
55
|
+
if obj_dict is not None:
|
|
56
|
+
return {k: _serialize_result(v) for k, v in obj_dict.items()}
|
|
57
|
+
return str(obj)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _filepath_to_route(filepath: str) -> str:
|
|
61
|
+
files_index = get_files_index()
|
|
62
|
+
filepath = filepath.replace('\\', '/')
|
|
63
|
+
for route_entry in files_index.routes:
|
|
64
|
+
if route_entry.fs_dir:
|
|
65
|
+
pattern = f'/src/app/{route_entry.fs_dir}/index.py'
|
|
66
|
+
if filepath.endswith(pattern) or pattern in filepath:
|
|
67
|
+
return route_entry.url_path
|
|
68
|
+
else:
|
|
69
|
+
if filepath.endswith('/src/app/index.py'):
|
|
70
|
+
return '/'
|
|
71
|
+
return '/'
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def rpc(require_auth: bool = False, allowed_roles: Optional[list[str]] = None):
|
|
75
|
+
def decorator(func):
|
|
76
|
+
frame = inspect.stack()[1]
|
|
77
|
+
filepath = frame.filename
|
|
78
|
+
route = _filepath_to_route(filepath)
|
|
79
|
+
|
|
80
|
+
key = f"{route}:{func.__name__}"
|
|
81
|
+
RPC_REGISTRY[key] = func
|
|
82
|
+
RPC_META[key] = {
|
|
83
|
+
'require_auth': require_auth,
|
|
84
|
+
'allowed_roles': allowed_roles or [],
|
|
85
|
+
'route': route
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
@wraps(func)
|
|
89
|
+
def wrapper(*args, **kwargs):
|
|
90
|
+
return func(*args, **kwargs)
|
|
91
|
+
return wrapper
|
|
92
|
+
return decorator
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _validate_origin(request: Request) -> Optional[JSONResponse]:
|
|
96
|
+
origin = request.headers.get('Origin')
|
|
97
|
+
if not origin:
|
|
98
|
+
return None
|
|
99
|
+
if not IS_PRODUCTION:
|
|
100
|
+
if origin.startswith(('http://localhost:', 'http://127.0.0.1:')):
|
|
101
|
+
return None
|
|
102
|
+
host_url = str(request.base_url).rstrip('/')
|
|
103
|
+
if CORS_ALLOWED_ORIGINS:
|
|
104
|
+
if origin not in CORS_ALLOWED_ORIGINS and origin != host_url:
|
|
105
|
+
return JSONResponse({'error': 'Invalid origin'}, status_code=403)
|
|
106
|
+
elif origin != host_url:
|
|
107
|
+
return JSONResponse({'error': 'Invalid origin'}, status_code=403)
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _validate_csrf(request: Request, session: dict) -> Optional[JSONResponse]:
|
|
112
|
+
csrf_header = request.headers.get('X-CSRF-Token')
|
|
113
|
+
session_token = session.get('csrf_token')
|
|
114
|
+
|
|
115
|
+
if not csrf_header or not session_token:
|
|
116
|
+
return JSONResponse({'error': 'Missing CSRF token'}, status_code=403)
|
|
117
|
+
|
|
118
|
+
if not hmac.compare_digest(csrf_header, session_token):
|
|
119
|
+
return JSONResponse({'error': 'Invalid CSRF token'}, status_code=403)
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _validate_content_type(request: Request) -> Optional[JSONResponse]:
|
|
124
|
+
content_type = request.headers.get('content-type', '')
|
|
125
|
+
if not content_type.startswith(('application/json', 'multipart/form-data')):
|
|
126
|
+
content_length = request.headers.get('content-length')
|
|
127
|
+
if content_length and int(content_length) > 0:
|
|
128
|
+
return JSONResponse({'error': 'Invalid content type'}, status_code=415)
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _get_registry_key(route: str, func_name: str) -> Optional[str]:
|
|
133
|
+
route = ('/' + route.strip('/')).rstrip('/') or '/'
|
|
134
|
+
key = f"{route}:{func_name}"
|
|
135
|
+
if key in RPC_REGISTRY:
|
|
136
|
+
return key
|
|
137
|
+
if func_name in RPC_REGISTRY:
|
|
138
|
+
return func_name
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
async def _handle_rpc_request(request: Request, session: dict) -> Response:
|
|
143
|
+
func_name = request.headers.get('X-PP-Function')
|
|
144
|
+
if not func_name:
|
|
145
|
+
return JSONResponse({'error': 'Missing function name'}, status_code=400)
|
|
146
|
+
|
|
147
|
+
route = request.url.path.rstrip('/') or '/'
|
|
148
|
+
registry_key = _get_registry_key(route, func_name)
|
|
149
|
+
if not registry_key:
|
|
150
|
+
return JSONResponse({'error': 'Function not found'}, status_code=404)
|
|
151
|
+
|
|
152
|
+
if error := _validate_origin(request):
|
|
153
|
+
return error
|
|
154
|
+
if error := _validate_content_type(request):
|
|
155
|
+
return error
|
|
156
|
+
if error := _validate_csrf(request, session):
|
|
157
|
+
return error
|
|
158
|
+
|
|
159
|
+
meta = RPC_META.get(registry_key, {})
|
|
160
|
+
|
|
161
|
+
if meta.get('require_auth') and not auth.is_authenticated():
|
|
162
|
+
return JSONResponse({'error': 'Authentication required'}, status_code=401)
|
|
163
|
+
|
|
164
|
+
allowed_roles = meta.get('allowed_roles', [])
|
|
165
|
+
if allowed_roles:
|
|
166
|
+
user = auth.get_payload()
|
|
167
|
+
if not auth.check_role(user, allowed_roles):
|
|
168
|
+
return JSONResponse({'error': 'Permission denied'}, status_code=403)
|
|
169
|
+
|
|
170
|
+
content_type = request.headers.get('content-type', '')
|
|
171
|
+
if content_type.startswith('multipart/form-data'):
|
|
172
|
+
form = await request.form()
|
|
173
|
+
data: dict[str, Any] = {}
|
|
174
|
+
for key in form:
|
|
175
|
+
value = form[key]
|
|
176
|
+
if isinstance(value, str):
|
|
177
|
+
try:
|
|
178
|
+
data[key] = json.loads(value)
|
|
179
|
+
except (json.JSONDecodeError, TypeError):
|
|
180
|
+
data[key] = value
|
|
181
|
+
else:
|
|
182
|
+
data[key] = value
|
|
183
|
+
else:
|
|
184
|
+
try:
|
|
185
|
+
data = await request.json()
|
|
186
|
+
except:
|
|
187
|
+
data = {}
|
|
188
|
+
|
|
189
|
+
try:
|
|
190
|
+
result = RPC_REGISTRY[registry_key](**data)
|
|
191
|
+
|
|
192
|
+
if isinstance(result, Response):
|
|
193
|
+
location = result.headers.get("Location")
|
|
194
|
+
status = result.status_code
|
|
195
|
+
if location and 300 <= status < 400:
|
|
196
|
+
resp = JSONResponse({"result": None})
|
|
197
|
+
resp.headers["X-PP-Redirect"] = location
|
|
198
|
+
resp.headers["X-PP-Redirect-Status"] = str(status)
|
|
199
|
+
return resp
|
|
200
|
+
return result
|
|
201
|
+
|
|
202
|
+
return JSONResponse({'result': _serialize_result(result)})
|
|
203
|
+
|
|
204
|
+
except PermissionError as e:
|
|
205
|
+
return JSONResponse({'error': str(e)}, status_code=403)
|
|
206
|
+
except ValueError as e:
|
|
207
|
+
return JSONResponse({'error': str(e)}, status_code=400)
|
|
208
|
+
except Exception as e:
|
|
209
|
+
print(f"[RPC Error] {registry_key}: {e}")
|
|
210
|
+
traceback.print_exc() # This prints the full stack trace
|
|
211
|
+
return JSONResponse({'error': 'Internal server error'}, status_code=500)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# Standalone middleware function (exported)
|
|
215
|
+
async def rpc_middleware(request: Request, call_next):
|
|
216
|
+
session = dict(request.session) if hasattr(request, 'session') else {}
|
|
217
|
+
|
|
218
|
+
if request.headers.get('X-PP-RPC') == 'true' and request.method == 'POST':
|
|
219
|
+
return await _handle_rpc_request(request, session)
|
|
220
|
+
|
|
221
|
+
response = await call_next(request)
|
|
222
|
+
return response
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def register_rpc_routes(app: FastAPI):
|
|
226
|
+
app.state.limiter = limiter
|
|
227
|
+
|
|
228
|
+
@app.exception_handler(RateLimitExceeded)
|
|
229
|
+
async def rate_limit_handler(request: Request, exc: RateLimitExceeded):
|
|
230
|
+
return JSONResponse({'error': 'Rate limit exceeded. Please slow down.'}, status_code=429)
|
casp/scripts_type.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from bs4 import BeautifulSoup
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def transform_scripts(html_content):
|
|
5
|
+
"""Add type='text/pp' to script tags without a type attribute (only in body)"""
|
|
6
|
+
has_doctype = html_content.strip().lower().startswith('<!doctype')
|
|
7
|
+
|
|
8
|
+
soup = BeautifulSoup(html_content, 'html.parser')
|
|
9
|
+
|
|
10
|
+
body = soup.find('body')
|
|
11
|
+
if body:
|
|
12
|
+
for script in body.find_all('script'):
|
|
13
|
+
if not script.has_attr('type'):
|
|
14
|
+
script['type'] = 'text/pp'
|
|
15
|
+
|
|
16
|
+
result = str(soup)
|
|
17
|
+
|
|
18
|
+
if has_doctype and not result.strip().lower().startswith('<!doctype'):
|
|
19
|
+
result = '<!DOCTYPE html>\n' + result
|
|
20
|
+
|
|
21
|
+
return result
|
casp/state_manager.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from contextvars import ContextVar
|
|
3
|
+
from typing import Any, Callable, Dict, Optional
|
|
4
|
+
from fastapi import Request
|
|
5
|
+
|
|
6
|
+
# Context variables for request-scoped state
|
|
7
|
+
_state_data: ContextVar[Dict] = ContextVar('state_data', default={})
|
|
8
|
+
_state_listeners: ContextVar[list] = ContextVar('state_listeners', default=[])
|
|
9
|
+
_current_request: ContextVar[Optional[Request]
|
|
10
|
+
] = ContextVar('current_request', default=None)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AttributeDict(dict):
|
|
14
|
+
def __getattr__(self, key):
|
|
15
|
+
try:
|
|
16
|
+
return self[key]
|
|
17
|
+
except KeyError:
|
|
18
|
+
raise AttributeError(
|
|
19
|
+
f"'AttributeDict' object has no attribute '{key}'")
|
|
20
|
+
|
|
21
|
+
def __setattr__(self, key, value):
|
|
22
|
+
self[key] = value
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class StateManager:
|
|
26
|
+
APP_STATE_KEY = 'app_state_cL7y4KirLp'
|
|
27
|
+
|
|
28
|
+
@staticmethod
|
|
29
|
+
def set_request(request: Request):
|
|
30
|
+
_current_request.set(request)
|
|
31
|
+
|
|
32
|
+
@staticmethod
|
|
33
|
+
def _get_session() -> dict:
|
|
34
|
+
request = _current_request.get()
|
|
35
|
+
if request and hasattr(request.state, 'session'):
|
|
36
|
+
return request.state.session
|
|
37
|
+
return {}
|
|
38
|
+
|
|
39
|
+
@staticmethod
|
|
40
|
+
def _set_session(key: str, value: Any):
|
|
41
|
+
request = _current_request.get()
|
|
42
|
+
if request:
|
|
43
|
+
if not hasattr(request.state, 'session'):
|
|
44
|
+
request.state.session = {}
|
|
45
|
+
request.state.session[key] = value
|
|
46
|
+
|
|
47
|
+
@staticmethod
|
|
48
|
+
def _get_state_data() -> Dict:
|
|
49
|
+
return _state_data.get()
|
|
50
|
+
|
|
51
|
+
@staticmethod
|
|
52
|
+
def _set_state_data(data: Dict) -> None:
|
|
53
|
+
_state_data.set(data)
|
|
54
|
+
|
|
55
|
+
@staticmethod
|
|
56
|
+
def _get_listeners() -> list:
|
|
57
|
+
return _state_listeners.get()
|
|
58
|
+
|
|
59
|
+
@staticmethod
|
|
60
|
+
def init(request: Request) -> None:
|
|
61
|
+
StateManager.set_request(request)
|
|
62
|
+
_state_data.set({})
|
|
63
|
+
_state_listeners.set([])
|
|
64
|
+
StateManager.load_state()
|
|
65
|
+
|
|
66
|
+
is_wire = request.headers.get('X-PulsePoint-Wire', False)
|
|
67
|
+
if not is_wire:
|
|
68
|
+
StateManager.reset_state()
|
|
69
|
+
|
|
70
|
+
@staticmethod
|
|
71
|
+
def get_state(key: Optional[str] = None, initial_value: Any = None) -> Any:
|
|
72
|
+
state = StateManager._get_state_data()
|
|
73
|
+
if key is None:
|
|
74
|
+
return AttributeDict(state)
|
|
75
|
+
value = state.get(key, initial_value)
|
|
76
|
+
return AttributeDict(value) if isinstance(value, dict) else value
|
|
77
|
+
|
|
78
|
+
@staticmethod
|
|
79
|
+
def set_state(key: str, value: Any = None) -> None:
|
|
80
|
+
state = StateManager._get_state_data()
|
|
81
|
+
state[key] = value
|
|
82
|
+
_state_data.set(state)
|
|
83
|
+
StateManager.notify_listeners()
|
|
84
|
+
StateManager.save_state()
|
|
85
|
+
|
|
86
|
+
@staticmethod
|
|
87
|
+
def subscribe(listener: Callable) -> Callable:
|
|
88
|
+
listeners = StateManager._get_listeners()
|
|
89
|
+
listeners.append(listener)
|
|
90
|
+
listener(StateManager._get_state_data())
|
|
91
|
+
|
|
92
|
+
def unsubscribe():
|
|
93
|
+
current_listeners = StateManager._get_listeners()
|
|
94
|
+
if listener in current_listeners:
|
|
95
|
+
current_listeners.remove(listener)
|
|
96
|
+
|
|
97
|
+
return unsubscribe
|
|
98
|
+
|
|
99
|
+
@staticmethod
|
|
100
|
+
def save_state() -> None:
|
|
101
|
+
state = StateManager._get_state_data()
|
|
102
|
+
StateManager._set_session(
|
|
103
|
+
StateManager.APP_STATE_KEY, json.dumps(state))
|
|
104
|
+
|
|
105
|
+
@staticmethod
|
|
106
|
+
def load_state() -> None:
|
|
107
|
+
session = StateManager._get_session()
|
|
108
|
+
if StateManager.APP_STATE_KEY in session:
|
|
109
|
+
try:
|
|
110
|
+
loaded_state = json.loads(session[StateManager.APP_STATE_KEY])
|
|
111
|
+
if isinstance(loaded_state, dict):
|
|
112
|
+
StateManager._set_state_data(loaded_state)
|
|
113
|
+
StateManager.notify_listeners()
|
|
114
|
+
except json.JSONDecodeError:
|
|
115
|
+
pass
|
|
116
|
+
|
|
117
|
+
@staticmethod
|
|
118
|
+
def reset_state(key: Optional[str] = None) -> None:
|
|
119
|
+
state = StateManager._get_state_data()
|
|
120
|
+
if key is not None:
|
|
121
|
+
if key in state:
|
|
122
|
+
state[key] = None
|
|
123
|
+
else:
|
|
124
|
+
state = {}
|
|
125
|
+
_state_data.set(state)
|
|
126
|
+
StateManager.notify_listeners()
|
|
127
|
+
StateManager.save_state()
|
|
128
|
+
|
|
129
|
+
@staticmethod
|
|
130
|
+
def notify_listeners() -> None:
|
|
131
|
+
listeners = StateManager._get_listeners()
|
|
132
|
+
state = StateManager._get_state_data()
|
|
133
|
+
for listener in listeners:
|
|
134
|
+
listener(state)
|
casp/string_helpers.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def camel_to_kebab(name):
|
|
5
|
+
return re.sub(r'([a-z])([A-Z])', r'\1-\2', name).lower()
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def kebab_to_camel(name):
|
|
9
|
+
parts = name.split('-')
|
|
10
|
+
return parts[0] + ''.join(p.capitalize() for p in parts[1:])
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def has_mustache(value):
|
|
14
|
+
if isinstance(value, str):
|
|
15
|
+
return bool(re.search(r'\{[^}]+\}', value))
|
|
16
|
+
if isinstance(value, list):
|
|
17
|
+
return any(has_mustache(v) for v in value)
|
|
18
|
+
return False
|
casp/tw.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Any
|
|
3
|
+
from tailwind_merge import TailwindMerge
|
|
4
|
+
|
|
5
|
+
_twm = TailwindMerge()
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def tw_merge(*values: Any) -> str:
|
|
9
|
+
"""
|
|
10
|
+
Merge Tailwind class strings (and optionally lists/tuples/sets) into a single
|
|
11
|
+
conflict-resolved class string using tailwind-merge.
|
|
12
|
+
"""
|
|
13
|
+
parts: list[str] = []
|
|
14
|
+
|
|
15
|
+
for v in values:
|
|
16
|
+
if not v:
|
|
17
|
+
continue
|
|
18
|
+
|
|
19
|
+
if isinstance(v, (list, tuple, set)):
|
|
20
|
+
chunk = " ".join(str(x) for x in v if x)
|
|
21
|
+
else:
|
|
22
|
+
chunk = str(v).strip()
|
|
23
|
+
|
|
24
|
+
if chunk:
|
|
25
|
+
parts.append(chunk)
|
|
26
|
+
|
|
27
|
+
if not parts:
|
|
28
|
+
return ""
|
|
29
|
+
|
|
30
|
+
# TailwindMerge.merge accepts multiple class strings. :contentReference[oaicite:2]{index=2}
|
|
31
|
+
return _twm.merge(*parts)
|