py-app-runner 0.5.49.dev0__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.
- py_app_runner/__init__.py +11 -0
- py_app_runner/audit/__init__.py +29 -0
- py_app_runner/audit/_service.py +91 -0
- py_app_runner/audit/_service_args.py +44 -0
- py_app_runner/audit/audit.py +319 -0
- py_app_runner/audit/commands.py +151 -0
- py_app_runner/audit/diff.py +202 -0
- py_app_runner/audit/errors.py +8 -0
- py_app_runner/audit/event.py +130 -0
- py_app_runner/audit/store.py +134 -0
- py_app_runner/bridge/__init__.py +0 -0
- py_app_runner/bridge/_service.py +265 -0
- py_app_runner/bridge/_service_args.py +24 -0
- py_app_runner/bridge/api.py +138 -0
- py_app_runner/bridge/encoders/__init__.py +5 -0
- py_app_runner/bridge/encoders/base.py +24 -0
- py_app_runner/bridge/encoders/json_encoder.py +26 -0
- py_app_runner/bridge/encoders/msgpack_encoder.py +58 -0
- py_app_runner/bridge/web_app.py +31 -0
- py_app_runner/bridge/websocket.py +313 -0
- py_app_runner/colors.py +73 -0
- py_app_runner/config.py +132 -0
- py_app_runner/crypto/__init__.py +14 -0
- py_app_runner/crypto/_service.py +75 -0
- py_app_runner/crypto/_service_args.py +54 -0
- py_app_runner/crypto/commands.py +164 -0
- py_app_runner/crypto/envelope.py +144 -0
- py_app_runner/crypto/errors.py +8 -0
- py_app_runner/crypto/fields.py +300 -0
- py_app_runner/crypto/passwords.py +66 -0
- py_app_runner/db_pools.py +20 -0
- py_app_runner/http_exception.py +31 -0
- py_app_runner/logger_handlers.py +167 -0
- py_app_runner/migrations/__init__.py +5 -0
- py_app_runner/migrations/_service.py +296 -0
- py_app_runner/migrations/_service_args.py +91 -0
- py_app_runner/migrations/commands.py +386 -0
- py_app_runner/migrations/discovery.py +108 -0
- py_app_runner/migrations/states.py +63 -0
- py_app_runner/migrations/tracker.py +141 -0
- py_app_runner/py.typed +0 -0
- py_app_runner/pybridge.py +64 -0
- py_app_runner/queue/__init__.py +25 -0
- py_app_runner/queue/_service.py +231 -0
- py_app_runner/queue/_service_args.py +67 -0
- py_app_runner/queue/commands.py +180 -0
- py_app_runner/queue/driver_pg.py +464 -0
- py_app_runner/queue/driver_redis.py +613 -0
- py_app_runner/queue/handler.py +90 -0
- py_app_runner/queue/interface.py +63 -0
- py_app_runner/queue/job.py +46 -0
- py_app_runner/queue/worker.py +221 -0
- py_app_runner/registry.py +54 -0
- py_app_runner/request_handler/__init__.py +0 -0
- py_app_runner/request_handler/auth_service.py +123 -0
- py_app_runner/request_handler/decorators.py +304 -0
- py_app_runner/request_handler/handlers.py +604 -0
- py_app_runner/request_handler/pagination.py +24 -0
- py_app_runner/return_model.py +78 -0
- py_app_runner/runner.py +182 -0
- py_app_runner/throttle/__init__.py +5 -0
- py_app_runner/throttle/throttle.py +217 -0
- py_app_runner/tick_service.py +308 -0
- py_app_runner/timer.py +289 -0
- py_app_runner/utils.py +346 -0
- py_app_runner/wbcm/__init__.py +0 -0
- py_app_runner/wbcm/device_connections.py +89 -0
- py_app_runner/wbcm/factory.py +113 -0
- py_app_runner/wbcm/wb_connection_manager.py +333 -0
- py_app_runner/wbcm/ws_interface.py +56 -0
- py_app_runner-0.5.49.dev0.dist-info/METADATA +134 -0
- py_app_runner-0.5.49.dev0.dist-info/RECORD +75 -0
- py_app_runner-0.5.49.dev0.dist-info/WHEEL +5 -0
- py_app_runner-0.5.49.dev0.dist-info/licenses/LICENSE +21 -0
- py_app_runner-0.5.49.dev0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import hashlib
|
|
3
|
+
import hmac
|
|
4
|
+
import inspect
|
|
5
|
+
import logging
|
|
6
|
+
from asyncio import Future
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from time import time
|
|
9
|
+
from typing import Any, Literal
|
|
10
|
+
from uuid import UUID
|
|
11
|
+
|
|
12
|
+
from database_wrapper_pgsql import DBWrapperPgsqlAsync, PgConnectionTypeAsync, PgCursorTypeAsync
|
|
13
|
+
from redis.asyncio import Redis as RedisClientAsync
|
|
14
|
+
from tornado import httputil, web
|
|
15
|
+
|
|
16
|
+
from py_app_runner.registry import AppRegistry
|
|
17
|
+
|
|
18
|
+
from ..db_pools import DbPools
|
|
19
|
+
from ..http_exception import HTTPException
|
|
20
|
+
from ..pybridge import PyBridge
|
|
21
|
+
from ..return_model import MessageModel, ReturnModel, StatusModel
|
|
22
|
+
from ..timer import Timer
|
|
23
|
+
from ..utils import (
|
|
24
|
+
format_body_for_log,
|
|
25
|
+
format_response_for_log,
|
|
26
|
+
json_decode,
|
|
27
|
+
json_encode_bytes,
|
|
28
|
+
redact_headers,
|
|
29
|
+
safe_decode,
|
|
30
|
+
sha1_prefix,
|
|
31
|
+
)
|
|
32
|
+
from ..wbcm.wb_connection_manager import WbConnectionManager
|
|
33
|
+
from .auth_service import AuthService
|
|
34
|
+
|
|
35
|
+
BridgeRequestData = dict[str, Any]
|
|
36
|
+
BridgeRequest = Callable[[str, BridgeRequestData, "RequestHandlerBase"], Any]
|
|
37
|
+
RequiresApiKey = bool | None
|
|
38
|
+
RequestParams = dict[str, Any]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class WebApplicationBase(web.Application):
|
|
42
|
+
"""Tornado web application expansion to initialize stuff like database connections and/or sessions"""
|
|
43
|
+
|
|
44
|
+
pybridge: PyBridge
|
|
45
|
+
db_pools: DbPools
|
|
46
|
+
wb_connection_manager: WbConnectionManager
|
|
47
|
+
|
|
48
|
+
def set_pybridge(self, pybridge: PyBridge) -> None:
|
|
49
|
+
"""Set pybridge"""
|
|
50
|
+
self.pybridge = pybridge
|
|
51
|
+
|
|
52
|
+
def set_db_pools(self, db_pools: DbPools) -> None:
|
|
53
|
+
"""Set database pools"""
|
|
54
|
+
self.db_pools = db_pools
|
|
55
|
+
|
|
56
|
+
def set_wb_connection_manager(self, wb_connection_manager: WbConnectionManager) -> None:
|
|
57
|
+
"""Set WebBridge connection manager"""
|
|
58
|
+
self.wb_connection_manager = wb_connection_manager
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class RequestHandlerBase(web.RequestHandler):
|
|
62
|
+
"""Base request handler"""
|
|
63
|
+
|
|
64
|
+
####################
|
|
65
|
+
### Properties #####
|
|
66
|
+
####################
|
|
67
|
+
|
|
68
|
+
# Instance variables
|
|
69
|
+
logger_name: str
|
|
70
|
+
logger: logging.Logger
|
|
71
|
+
timer: Timer
|
|
72
|
+
|
|
73
|
+
# Temporary variables
|
|
74
|
+
_current_user_obj: Any | None
|
|
75
|
+
_impersonator_obj: Any | None
|
|
76
|
+
json_args: dict[str, Any] | None
|
|
77
|
+
require_json_response: bool
|
|
78
|
+
module = None
|
|
79
|
+
uid: str | None
|
|
80
|
+
auth_token: str | None
|
|
81
|
+
|
|
82
|
+
_context: dict[str, Any]
|
|
83
|
+
|
|
84
|
+
auth_service: AuthService
|
|
85
|
+
|
|
86
|
+
# Overrides
|
|
87
|
+
application: WebApplicationBase # type: ignore[assignment]
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def pybridge(self) -> PyBridge:
|
|
91
|
+
return self.application.pybridge
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def db_pools(self) -> DbPools:
|
|
95
|
+
return self.application.db_pools
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def wb_connection_manager(self) -> WbConnectionManager:
|
|
99
|
+
return self.application.wb_connection_manager
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def context(self) -> RequestParams:
|
|
103
|
+
raise NotImplementedError
|
|
104
|
+
|
|
105
|
+
#########################
|
|
106
|
+
### Class lifecycle #####
|
|
107
|
+
#########################
|
|
108
|
+
|
|
109
|
+
def __init__(
|
|
110
|
+
self,
|
|
111
|
+
application: WebApplicationBase,
|
|
112
|
+
request: httputil.HTTPServerRequest,
|
|
113
|
+
**kwargs: Any,
|
|
114
|
+
):
|
|
115
|
+
self.logger_name = f"{__name__}.{self.__class__.__name__}"
|
|
116
|
+
self.logger = logging.getLogger(self.logger_name)
|
|
117
|
+
self.timer = Timer("WebHandlerBase")
|
|
118
|
+
self.require_json_response = False
|
|
119
|
+
self.json_args = None
|
|
120
|
+
self.uid = None
|
|
121
|
+
self.auth_token = None
|
|
122
|
+
self.auth_service = AuthService(self.logger)
|
|
123
|
+
|
|
124
|
+
super().__init__(application, request, **kwargs)
|
|
125
|
+
|
|
126
|
+
#################
|
|
127
|
+
### Helpers #####
|
|
128
|
+
#################
|
|
129
|
+
|
|
130
|
+
def find_service(self, service_name: str | None) -> ReturnModel[tuple[BridgeRequest | None, RequiresApiKey | None]]:
|
|
131
|
+
if service_name is None:
|
|
132
|
+
return ReturnModel(error=MessageModel(text="No service was provided.", code=-1000))
|
|
133
|
+
|
|
134
|
+
service_name = service_name.replace("-", "_")
|
|
135
|
+
if service_name not in self.pybridge.services_placeholder:
|
|
136
|
+
return ReturnModel(error=MessageModel(text=f'Module "{service_name}" not found.', code=-1010))
|
|
137
|
+
|
|
138
|
+
service = self.pybridge.services_placeholder[service_name]
|
|
139
|
+
bridge_request = getattr(service, "bridge_request", None)
|
|
140
|
+
if not callable(bridge_request):
|
|
141
|
+
return ReturnModel(
|
|
142
|
+
error=MessageModel(text=f'"{service_name}" does not support bridge requests', code=-1020)
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
# Check if there is an API key required method
|
|
146
|
+
requires_api_key = getattr(service, "requires_api_key", None)
|
|
147
|
+
|
|
148
|
+
return ReturnModel(
|
|
149
|
+
result=(
|
|
150
|
+
bridge_request,
|
|
151
|
+
requires_api_key,
|
|
152
|
+
),
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
def get_request_data(self, force_action: str | None = None) -> dict[str, Any]:
|
|
156
|
+
query_params = self.request.query_arguments
|
|
157
|
+
decoded_params: dict[str, Any] = {}
|
|
158
|
+
|
|
159
|
+
for k, v in query_params.items():
|
|
160
|
+
# Check if there's only one item in the list
|
|
161
|
+
if len(v) == 1:
|
|
162
|
+
decoded_params[k] = v[0].decode("utf-8")
|
|
163
|
+
else:
|
|
164
|
+
# Decode each item in the list
|
|
165
|
+
decoded_params[k] = [item.decode("utf-8") for item in v]
|
|
166
|
+
|
|
167
|
+
request_body = {}
|
|
168
|
+
if hasattr(self, "json_args") and self.json_args:
|
|
169
|
+
request_body = self.json_args
|
|
170
|
+
|
|
171
|
+
if force_action:
|
|
172
|
+
request_body["action"] = force_action
|
|
173
|
+
|
|
174
|
+
return {**decoded_params, **request_body}
|
|
175
|
+
|
|
176
|
+
def error(
|
|
177
|
+
self,
|
|
178
|
+
msg: str | HTTPException,
|
|
179
|
+
code: int = -1,
|
|
180
|
+
http_status: int = 400,
|
|
181
|
+
) -> None: ...
|
|
182
|
+
|
|
183
|
+
def log_request(
|
|
184
|
+
self,
|
|
185
|
+
*,
|
|
186
|
+
response: Any | None = None,
|
|
187
|
+
error: Exception | None = None,
|
|
188
|
+
level: str = "info",
|
|
189
|
+
include_body: bool = True,
|
|
190
|
+
max_field_chars: int = 4000,
|
|
191
|
+
shrink_strings_to: int = 256,
|
|
192
|
+
cap_list_items: int = 50,
|
|
193
|
+
) -> None:
|
|
194
|
+
"""
|
|
195
|
+
Unified request logger.
|
|
196
|
+
|
|
197
|
+
- If `error` provided -> logs at WARNING by default (override with `level`).
|
|
198
|
+
- If `response` provided -> logs a trimmed response.
|
|
199
|
+
- No field-name assumptions; generic shrink/trim for huge payloads.
|
|
200
|
+
- Set `include_body=False` to avoid logging potentially massive bodies (keeps only a summary).
|
|
201
|
+
"""
|
|
202
|
+
logger: logging.Logger = getattr(self, "logger", logging.getLogger(__name__))
|
|
203
|
+
|
|
204
|
+
# choose level
|
|
205
|
+
level = (level or "").lower()
|
|
206
|
+
if error and level == "info":
|
|
207
|
+
level = "warning"
|
|
208
|
+
log = getattr(logger, level, logger.info)
|
|
209
|
+
|
|
210
|
+
req = getattr(self, "request", None)
|
|
211
|
+
method = getattr(req, "method", "?")
|
|
212
|
+
path = getattr(req, "path", getattr(req, "uri", "?"))
|
|
213
|
+
headers = redact_headers({str(k): v for k, v in dict(getattr(req, "headers", {}) or {}).items()})
|
|
214
|
+
args = getattr(req, "arguments", None)
|
|
215
|
+
body_raw = getattr(req, "body", b"")
|
|
216
|
+
|
|
217
|
+
# quick body size + hash (without rendering the whole thing)
|
|
218
|
+
if isinstance(body_raw, (bytes, bytearray)):
|
|
219
|
+
body_len = len(body_raw)
|
|
220
|
+
body_short = f"<{body_len} bytes sha1={sha1_prefix(bytes(body_raw))}>"
|
|
221
|
+
else:
|
|
222
|
+
body_text = safe_decode(body_raw)
|
|
223
|
+
body_len = len(body_text)
|
|
224
|
+
body_short = f"<{body_len} chars>"
|
|
225
|
+
|
|
226
|
+
if error:
|
|
227
|
+
log("Got error while processing request: %s", error)
|
|
228
|
+
|
|
229
|
+
# concise one-liner
|
|
230
|
+
log(
|
|
231
|
+
"Request %s %s | len(body)=%s | args=%s",
|
|
232
|
+
method,
|
|
233
|
+
path,
|
|
234
|
+
body_len,
|
|
235
|
+
args if isinstance(args, dict) else "<n/a>", # type: ignore
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
# details go at DEBUG unless we're already warning/error, then use same level
|
|
239
|
+
detail_log = logger.debug if level in ("info", "debug") else log
|
|
240
|
+
|
|
241
|
+
detail_log("Headers: %s", headers)
|
|
242
|
+
|
|
243
|
+
if include_body:
|
|
244
|
+
body_for_log = format_body_for_log(
|
|
245
|
+
body_raw,
|
|
246
|
+
max_chars=max_field_chars,
|
|
247
|
+
shrink_strings_to=shrink_strings_to,
|
|
248
|
+
cap_list_items=cap_list_items,
|
|
249
|
+
)
|
|
250
|
+
detail_log("Body: %s", body_for_log)
|
|
251
|
+
else:
|
|
252
|
+
detail_log("Body: %s", body_short)
|
|
253
|
+
|
|
254
|
+
if response is not None:
|
|
255
|
+
resp_for_log = format_response_for_log(
|
|
256
|
+
response,
|
|
257
|
+
max_chars=max_field_chars,
|
|
258
|
+
shrink_strings_to=shrink_strings_to,
|
|
259
|
+
cap_list_items=cap_list_items,
|
|
260
|
+
)
|
|
261
|
+
detail_log("Response: %s", resp_for_log)
|
|
262
|
+
|
|
263
|
+
###############
|
|
264
|
+
### Prepare ###
|
|
265
|
+
###############
|
|
266
|
+
|
|
267
|
+
async def prepare(self):
|
|
268
|
+
res = super().prepare()
|
|
269
|
+
if inspect.isawaitable(res):
|
|
270
|
+
await res
|
|
271
|
+
|
|
272
|
+
# Debug
|
|
273
|
+
headers = dict(self.request.headers)
|
|
274
|
+
headers.pop("Authorization", None)
|
|
275
|
+
|
|
276
|
+
self.logger.debug(f"Request: {self.request}")
|
|
277
|
+
self.logger.debug(f"Headers: {headers}")
|
|
278
|
+
self.logger.debug(f"Body: {self.request.body!r}")
|
|
279
|
+
self.logger.debug(f"Arguments: {self.request.arguments.keys()}")
|
|
280
|
+
|
|
281
|
+
self._ensure_content_type()
|
|
282
|
+
await self._ensure_current_user()
|
|
283
|
+
|
|
284
|
+
def _ensure_content_type(self):
|
|
285
|
+
# Content-Type
|
|
286
|
+
content_type = self.request.headers.get("Content-Type", "") # type: ignore
|
|
287
|
+
if (
|
|
288
|
+
content_type.startswith("application/json") or content_type.startswith("application/x-json")
|
|
289
|
+
) and self.request.body:
|
|
290
|
+
try:
|
|
291
|
+
self.json_args = json_decode(self.request.body)
|
|
292
|
+
|
|
293
|
+
except Exception as e:
|
|
294
|
+
self.log_request(error=e)
|
|
295
|
+
self.error("Invalid JSON body", 400, http_status=400)
|
|
296
|
+
self.finish()
|
|
297
|
+
return
|
|
298
|
+
|
|
299
|
+
# What to expect in return
|
|
300
|
+
accept = self.request.headers.get("Accept", "application/json") # type: ignore
|
|
301
|
+
if accept == "application/json":
|
|
302
|
+
self.set_header("Content-Type", "application/json; charset=UTF-8")
|
|
303
|
+
self.require_json_response = True
|
|
304
|
+
else:
|
|
305
|
+
self.set_header("Content-Type", "text/html; charset=UTF-8")
|
|
306
|
+
|
|
307
|
+
def _clear_current_user(self) -> None:
|
|
308
|
+
"""Drop the request's user and, for WebSockets, the connection's user binding.
|
|
309
|
+
|
|
310
|
+
Leaving the binding in place would keep an unauthenticated (or re-authenticated)
|
|
311
|
+
socket in the previous user's fan-out list.
|
|
312
|
+
"""
|
|
313
|
+
self._current_user_obj = None
|
|
314
|
+
|
|
315
|
+
uid = getattr(self, "uid", None)
|
|
316
|
+
if uid:
|
|
317
|
+
self.wb_connection_manager.user_connections.unassign_connection(uid)
|
|
318
|
+
|
|
319
|
+
async def _ensure_current_user(self):
|
|
320
|
+
# Run BEFORE get/post/etc.
|
|
321
|
+
self._impersonator_obj = None
|
|
322
|
+
|
|
323
|
+
# Clear Tornado's cached current_user so the property re-evaluates.
|
|
324
|
+
# Critical for WebSocket handlers where the same instance handles
|
|
325
|
+
# multiple messages and the token can change between them.
|
|
326
|
+
if hasattr(self, "_current_user"):
|
|
327
|
+
del self._current_user
|
|
328
|
+
|
|
329
|
+
token = self.auth_token
|
|
330
|
+
if not token:
|
|
331
|
+
token = self.request.headers.get("Authorization", "")
|
|
332
|
+
if token.startswith("Bearer "):
|
|
333
|
+
token = token.removeprefix("Bearer ").strip()
|
|
334
|
+
else:
|
|
335
|
+
token = None
|
|
336
|
+
|
|
337
|
+
if not token:
|
|
338
|
+
self._clear_current_user()
|
|
339
|
+
return
|
|
340
|
+
|
|
341
|
+
self.auth_token = token
|
|
342
|
+
|
|
343
|
+
# Try normal user token first
|
|
344
|
+
payload = self.auth_service.verify_access_jwt(token, expected_type="user")
|
|
345
|
+
is_impersonation = False
|
|
346
|
+
|
|
347
|
+
if not payload:
|
|
348
|
+
# Try impersonation token
|
|
349
|
+
payload = self.auth_service.verify_access_jwt(token, expected_type="impersonation")
|
|
350
|
+
if payload:
|
|
351
|
+
is_impersonation = True
|
|
352
|
+
|
|
353
|
+
if not payload:
|
|
354
|
+
self._clear_current_user()
|
|
355
|
+
return
|
|
356
|
+
|
|
357
|
+
user_public_id = payload["sub"]
|
|
358
|
+
|
|
359
|
+
# Defensive UUID validation
|
|
360
|
+
try:
|
|
361
|
+
UUID(user_public_id)
|
|
362
|
+
except ValueError:
|
|
363
|
+
self._clear_current_user()
|
|
364
|
+
return
|
|
365
|
+
|
|
366
|
+
UsersModel = AppRegistry.users_model()
|
|
367
|
+
async with self.db_pools.main_db_pool as (pg_conn, pg_cur):
|
|
368
|
+
if not pg_cur or not pg_conn:
|
|
369
|
+
raise HTTPException("Database is not initialized", 500, http_status=500)
|
|
370
|
+
|
|
371
|
+
user = await UsersModel.get_by_public_id(user_public_id, pg_cur)
|
|
372
|
+
|
|
373
|
+
if is_impersonation:
|
|
374
|
+
imp_public_id = payload.get("imp")
|
|
375
|
+
if imp_public_id:
|
|
376
|
+
impersonator = await UsersModel.get_by_public_id(imp_public_id, pg_cur)
|
|
377
|
+
if impersonator and not impersonator.disabled_at and not impersonator.deleted_at:
|
|
378
|
+
self._impersonator_obj = impersonator
|
|
379
|
+
|
|
380
|
+
if not user or user.disabled_at or user.deleted_at:
|
|
381
|
+
self._clear_current_user()
|
|
382
|
+
return
|
|
383
|
+
|
|
384
|
+
# Cache it for this request
|
|
385
|
+
self._current_user_obj = user
|
|
386
|
+
|
|
387
|
+
# Tag WS connections with the user id so per-user pushes can reach them
|
|
388
|
+
# without every project re-wiring this in a subclass. HTTP handlers have
|
|
389
|
+
# no `uid`, so this is a no-op for them.
|
|
390
|
+
uid = getattr(self, "uid", None)
|
|
391
|
+
if uid:
|
|
392
|
+
self.wb_connection_manager.user_connections.assign_user_id(uid, str(user.id))
|
|
393
|
+
|
|
394
|
+
@property
|
|
395
|
+
def impersonator(self) -> Any | None:
|
|
396
|
+
"""Returns the superadmin identity if current request uses an impersonation token."""
|
|
397
|
+
return getattr(self, "_impersonator_obj", None)
|
|
398
|
+
|
|
399
|
+
def get_current_user(self):
|
|
400
|
+
# Must be sync in Tornado; just return cached value.
|
|
401
|
+
return self._current_user_obj
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
class RequestHandlerApiKeys(RequestHandlerBase):
|
|
405
|
+
#########################
|
|
406
|
+
### Validate api keys ###
|
|
407
|
+
#########################
|
|
408
|
+
|
|
409
|
+
def verify_api_key(self, api_key_secret: str, stored_hash: str) -> bool:
|
|
410
|
+
h = hashlib.sha256()
|
|
411
|
+
h.update(AppRegistry.config()["api_key_pepper"].encode())
|
|
412
|
+
h.update(api_key_secret.encode())
|
|
413
|
+
computed = h.hexdigest()
|
|
414
|
+
|
|
415
|
+
return hmac.compare_digest(computed, stored_hash)
|
|
416
|
+
|
|
417
|
+
async def has_valid_key_db(self, api_key: str, db_cur: PgCursorTypeAsync) -> str | Literal[True]:
|
|
418
|
+
api_key_split = api_key.split(".", 1)
|
|
419
|
+
if len(api_key_split) != 2:
|
|
420
|
+
return "Invalid API KEY"
|
|
421
|
+
|
|
422
|
+
api_key_prefix, api_key_secret = api_key_split
|
|
423
|
+
|
|
424
|
+
# Load from db
|
|
425
|
+
async with self.timer.aenter("request_handler.has_valid_key_db.load_from_db"):
|
|
426
|
+
db_wrapper = DBWrapperPgsqlAsync(db_cur)
|
|
427
|
+
api_key_model = AppRegistry.api_keys_model()
|
|
428
|
+
api_key_record = await db_wrapper.get_by_key(api_key_model(), id_key="key_prefix", id_value=api_key_prefix)
|
|
429
|
+
|
|
430
|
+
if not api_key_record:
|
|
431
|
+
return "Invalid API Key"
|
|
432
|
+
|
|
433
|
+
if api_key_record.disabled_at:
|
|
434
|
+
return "API key is disabled"
|
|
435
|
+
|
|
436
|
+
if not self.verify_api_key(api_key_secret, api_key_record.secret_hash):
|
|
437
|
+
return "Invalid API key"
|
|
438
|
+
|
|
439
|
+
async with self.timer.aenter("request_handler.has_valid_key_db.use_key"):
|
|
440
|
+
await api_key_record.use_key(db_cur)
|
|
441
|
+
|
|
442
|
+
return True
|
|
443
|
+
|
|
444
|
+
def has_valid_key(self, api_key: str) -> str | Literal[True]:
|
|
445
|
+
configured_key = AppRegistry.config().get("api", {}).get("key")
|
|
446
|
+
if not configured_key:
|
|
447
|
+
return "API key not configured"
|
|
448
|
+
|
|
449
|
+
# Compare as bytes: compare_digest rejects non-ASCII str, and the key comes
|
|
450
|
+
# straight from the client.
|
|
451
|
+
supplied = api_key.encode("utf-8", errors="replace")
|
|
452
|
+
candidates = configured_key if isinstance(configured_key, list) else [configured_key]
|
|
453
|
+
for candidate in candidates:
|
|
454
|
+
if hmac.compare_digest(supplied, str(candidate).encode("utf-8", errors="replace")):
|
|
455
|
+
return True
|
|
456
|
+
|
|
457
|
+
return "Invalid API key"
|
|
458
|
+
|
|
459
|
+
async def has_valid_api_key(self, custom_api_key: str | None = None) -> str | Literal[True]:
|
|
460
|
+
api_key: str | None = custom_api_key or self.request.headers.get("X-API-Key", None) # type: ignore
|
|
461
|
+
if api_key is None:
|
|
462
|
+
api_key = self.get_argument("api_key", None)
|
|
463
|
+
|
|
464
|
+
if api_key is None:
|
|
465
|
+
return "API key is missing"
|
|
466
|
+
|
|
467
|
+
api_key = str(api_key)
|
|
468
|
+
|
|
469
|
+
if AppRegistry.api_key_use_db():
|
|
470
|
+
async with self.db_pools.main_db_pool as (pg_conn, pg_cur):
|
|
471
|
+
if not pg_cur or not pg_conn:
|
|
472
|
+
raise HTTPException("Database is not initialized")
|
|
473
|
+
async with pg_conn.transaction():
|
|
474
|
+
return await self.has_valid_key_db(api_key, pg_cur)
|
|
475
|
+
|
|
476
|
+
return self.has_valid_key(api_key)
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
class WebHandlerBase(RequestHandlerApiKeys):
|
|
480
|
+
"""Web request handler base"""
|
|
481
|
+
|
|
482
|
+
##################
|
|
483
|
+
### Properties ###
|
|
484
|
+
##################
|
|
485
|
+
|
|
486
|
+
@property
|
|
487
|
+
def context(self) -> RequestParams:
|
|
488
|
+
if not hasattr(self, "_context"):
|
|
489
|
+
self._context = {
|
|
490
|
+
"base_uri": "/",
|
|
491
|
+
"module": self.module,
|
|
492
|
+
"config": AppRegistry.config(),
|
|
493
|
+
"timestamp": int(time()),
|
|
494
|
+
"now": datetime.datetime.now(),
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
return self._context
|
|
498
|
+
|
|
499
|
+
###########################
|
|
500
|
+
### Tornado overrides #####
|
|
501
|
+
###########################
|
|
502
|
+
|
|
503
|
+
def render(self, template_name: str, **kwargs: Any) -> Future[Any]:
|
|
504
|
+
self.context.update(kwargs)
|
|
505
|
+
|
|
506
|
+
if "/" in template_name:
|
|
507
|
+
template_name = f"{self.application.settings['routes_path']}/{template_name}"
|
|
508
|
+
|
|
509
|
+
return super().render(template_name, **self.context)
|
|
510
|
+
|
|
511
|
+
def render_string(self, template_name: str, **kwargs: Any) -> bytes:
|
|
512
|
+
self.context.update(kwargs)
|
|
513
|
+
|
|
514
|
+
return super().render_string(template_name, **self.context)
|
|
515
|
+
|
|
516
|
+
##########################
|
|
517
|
+
### Request handling #####
|
|
518
|
+
##########################
|
|
519
|
+
def write(self, chunk: str | bytes | dict[str, Any], wrap_in_data: bool = True):
|
|
520
|
+
if isinstance(chunk, HTTPException):
|
|
521
|
+
self.error(chunk)
|
|
522
|
+
return
|
|
523
|
+
|
|
524
|
+
if isinstance(chunk, StatusModel) or isinstance(chunk, MessageModel) or isinstance(chunk, ReturnModel):
|
|
525
|
+
chunk = chunk.to_dict()
|
|
526
|
+
|
|
527
|
+
if wrap_in_data:
|
|
528
|
+
chunk = {"data": chunk}
|
|
529
|
+
|
|
530
|
+
if isinstance(chunk, dict) or isinstance(chunk, list):
|
|
531
|
+
response = json_encode_bytes(chunk, pretty=self.application.settings.get("debug", False))
|
|
532
|
+
|
|
533
|
+
self.set_header("Content-Type", "application/json; charset=UTF-8")
|
|
534
|
+
super().write(response) # type: ignore
|
|
535
|
+
else:
|
|
536
|
+
super().write(chunk) # type: ignore
|
|
537
|
+
|
|
538
|
+
def error(
|
|
539
|
+
self,
|
|
540
|
+
msg: str | HTTPException,
|
|
541
|
+
code: int = -1,
|
|
542
|
+
http_status: int = 400,
|
|
543
|
+
) -> None:
|
|
544
|
+
# `data` is the sole payload container - errors are wrapped like every other
|
|
545
|
+
# response, so HTTP and WebSocket clients parse the same shape.
|
|
546
|
+
if isinstance(msg, HTTPException):
|
|
547
|
+
self.set_status(msg.http_status)
|
|
548
|
+
self.write({"error": msg.to_dict()})
|
|
549
|
+
return
|
|
550
|
+
|
|
551
|
+
if http_status:
|
|
552
|
+
self.set_status(http_status)
|
|
553
|
+
|
|
554
|
+
self.write({"error": {"code": code, "msg": msg}})
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
class RequestHandlerHelper:
|
|
558
|
+
_action_name: str
|
|
559
|
+
|
|
560
|
+
bridge_handler: RequestHandlerBase
|
|
561
|
+
|
|
562
|
+
pg_conn: PgConnectionTypeAsync
|
|
563
|
+
pg_cur: PgCursorTypeAsync
|
|
564
|
+
db_wrapper: DBWrapperPgsqlAsync
|
|
565
|
+
redis_con: RedisClientAsync
|
|
566
|
+
|
|
567
|
+
# Cache for registered actions per class to avoid re-scanning
|
|
568
|
+
_action_registry: dict[type, dict[str, Callable[..., Any]]] = {}
|
|
569
|
+
|
|
570
|
+
@classmethod
|
|
571
|
+
def _get_actions(cls) -> dict[str, Callable[..., Any]]:
|
|
572
|
+
"""Lazy load and cache actions for the class"""
|
|
573
|
+
if cls not in cls._action_registry:
|
|
574
|
+
registry = {}
|
|
575
|
+
# Inspect all members of the class
|
|
576
|
+
for attr_name in dir(cls):
|
|
577
|
+
method = getattr(cls, attr_name)
|
|
578
|
+
# Check if it has our decorator tag
|
|
579
|
+
if hasattr(method, "_action_name"):
|
|
580
|
+
registry[method._action_name] = method
|
|
581
|
+
cls._action_registry[cls] = registry
|
|
582
|
+
return cls._action_registry[cls]
|
|
583
|
+
|
|
584
|
+
async def handle_request(
|
|
585
|
+
self,
|
|
586
|
+
action: str,
|
|
587
|
+
input_data: dict[str, Any],
|
|
588
|
+
bridge_handler: RequestHandlerBase,
|
|
589
|
+
) -> Any:
|
|
590
|
+
"""
|
|
591
|
+
Generic dispatcher that looks up the action in the registry.
|
|
592
|
+
"""
|
|
593
|
+
self.bridge_handler = bridge_handler
|
|
594
|
+
registry = self._get_actions()
|
|
595
|
+
handler_method = registry.get(action)
|
|
596
|
+
|
|
597
|
+
if not handler_method:
|
|
598
|
+
handler_method = registry.get("default")
|
|
599
|
+
|
|
600
|
+
if not handler_method:
|
|
601
|
+
raise HTTPException(f"Unknown action `{action}`", 1234)
|
|
602
|
+
|
|
603
|
+
# Call the method
|
|
604
|
+
return await handler_method(self, input_data)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
DEFAULT_PAGE_LIMIT = 50
|
|
4
|
+
MAX_PAGE_LIMIT = 100
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def parse_pagination(input_data: dict[str, Any] | None, default_limit: int = DEFAULT_PAGE_LIMIT) -> tuple[int, int]:
|
|
8
|
+
"""Extract and clamp limit/offset from client input."""
|
|
9
|
+
if not input_data:
|
|
10
|
+
return default_limit, 0
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
limit = int(input_data.get("limit", default_limit))
|
|
14
|
+
except (TypeError, ValueError):
|
|
15
|
+
limit = default_limit
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
offset = int(input_data.get("offset", 0))
|
|
19
|
+
except (TypeError, ValueError):
|
|
20
|
+
offset = 0
|
|
21
|
+
|
|
22
|
+
limit = max(1, min(limit, MAX_PAGE_LIMIT))
|
|
23
|
+
offset = max(0, offset)
|
|
24
|
+
return limit, offset
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from typing import Any, Generic, TypeVar
|
|
2
|
+
|
|
3
|
+
T = TypeVar("T")
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class StatusModel:
|
|
7
|
+
status: str
|
|
8
|
+
message: str | None
|
|
9
|
+
|
|
10
|
+
def __init__(self, status: str = "ok", message: str | None = None):
|
|
11
|
+
self.status = status
|
|
12
|
+
self.message = message
|
|
13
|
+
|
|
14
|
+
def __str__(self) -> str:
|
|
15
|
+
return str(self.to_dict())
|
|
16
|
+
|
|
17
|
+
def __repr__(self) -> str:
|
|
18
|
+
return self.__str__()
|
|
19
|
+
|
|
20
|
+
def to_dict(self) -> dict[str, Any]:
|
|
21
|
+
newDict = {"status": self.status}
|
|
22
|
+
if self.message:
|
|
23
|
+
newDict["message"] = self.message
|
|
24
|
+
return newDict
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class MessageModel:
|
|
28
|
+
"""
|
|
29
|
+
MessageModel
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
code: int
|
|
33
|
+
text: str
|
|
34
|
+
|
|
35
|
+
def __init__(self, text: str, code: int = 0):
|
|
36
|
+
self.text = text
|
|
37
|
+
self.code = code
|
|
38
|
+
|
|
39
|
+
def __str__(self) -> str:
|
|
40
|
+
return f"code: {self.code} | message: {self.text}"
|
|
41
|
+
|
|
42
|
+
def to_dict(self) -> dict[str, Any]:
|
|
43
|
+
return {
|
|
44
|
+
"code": self.code,
|
|
45
|
+
"text": self.text,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ReturnModel(Generic[T]):
|
|
50
|
+
"""
|
|
51
|
+
ReturnModel
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
result: T | None
|
|
55
|
+
error: MessageModel | None
|
|
56
|
+
|
|
57
|
+
# Additional info
|
|
58
|
+
info: Any | None
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
result: T | None = None,
|
|
63
|
+
error: MessageModel | None = None,
|
|
64
|
+
info: Any | None = None,
|
|
65
|
+
):
|
|
66
|
+
self.result = result
|
|
67
|
+
self.error = error
|
|
68
|
+
self.info = info
|
|
69
|
+
|
|
70
|
+
def __str__(self) -> str:
|
|
71
|
+
return f"result: {self.result} | error message: {self.error} | info: {self.info}"
|
|
72
|
+
|
|
73
|
+
def to_dict(self) -> dict[str, Any]:
|
|
74
|
+
return {
|
|
75
|
+
"result": self.result,
|
|
76
|
+
"error": self.error.to_dict() if self.error else None,
|
|
77
|
+
"info": self.info,
|
|
78
|
+
}
|