keepup-http 0.5.0__tar.gz
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.
- keepup_http-0.5.0/PKG-INFO +29 -0
- keepup_http-0.5.0/README.md +10 -0
- keepup_http-0.5.0/keepup_http/__init__.py +38 -0
- keepup_http-0.5.0/keepup_http/api_docs.py +76 -0
- keepup_http-0.5.0/keepup_http/body_limit.py +156 -0
- keepup_http-0.5.0/keepup_http/events_api.py +237 -0
- keepup_http-0.5.0/keepup_http/factory.py +562 -0
- keepup_http-0.5.0/keepup_http/lock_routes.py +155 -0
- keepup_http-0.5.0/keepup_http/plugin.py +53 -0
- keepup_http-0.5.0/keepup_http/routes.py +549 -0
- keepup_http-0.5.0/keepup_http/static/css/aos.css +1 -0
- keepup_http-0.5.0/keepup_http/static/css/main_nebula.css +232 -0
- keepup_http-0.5.0/keepup_http/static/css/main_new.css +869 -0
- keepup_http-0.5.0/keepup_http/static/css/tailwind.css +1 -0
- keepup_http-0.5.0/keepup_http/static/index_nebula.html +282 -0
- keepup_http-0.5.0/keepup_http/static/index_new.html +275 -0
- keepup_http-0.5.0/keepup_http/static/js/aos.js +1 -0
- keepup_http-0.5.0/keepup_http/static/js/feather-icons.js +13 -0
- keepup_http-0.5.0/keepup_http/static/js/layout_bootstrap.js +29 -0
- keepup_http-0.5.0/keepup_http/static/js/main_new.js +2076 -0
- keepup_http-0.5.0/keepup_http/static/js/tailwind.js +83 -0
- keepup_http-0.5.0/keepup_http/static/modules/css/background_tasks.css +233 -0
- keepup_http-0.5.0/keepup_http/static/modules/css/cluster.css +16 -0
- keepup_http-0.5.0/keepup_http/static/modules/css/event_manager.css +386 -0
- keepup_http-0.5.0/keepup_http/static/modules/css/integration_logs.css +33 -0
- keepup_http-0.5.0/keepup_http/static/modules/css/metrics.css +115 -0
- keepup_http-0.5.0/keepup_http/static/modules/css/modules.css +189 -0
- keepup_http-0.5.0/keepup_http/static/modules/css/themes.css +563 -0
- keepup_http-0.5.0/keepup_http/static/modules/css/users.css +278 -0
- keepup_http-0.5.0/keepup_http/static/modules/js/background_tasks.js +695 -0
- keepup_http-0.5.0/keepup_http/static/modules/js/chart.js +14 -0
- keepup_http-0.5.0/keepup_http/static/modules/js/chartjs-adapter-date-fns.bundle.min.js +7 -0
- keepup_http-0.5.0/keepup_http/static/modules/js/cluster.js +374 -0
- keepup_http-0.5.0/keepup_http/static/modules/js/event_manager.js +1007 -0
- keepup_http-0.5.0/keepup_http/static/modules/js/integration_logs.js +795 -0
- keepup_http-0.5.0/keepup_http/static/modules/js/metrics.js +929 -0
- keepup_http-0.5.0/keepup_http/static/modules/js/modules.js +1128 -0
- keepup_http-0.5.0/keepup_http/static/modules/js/themes.js +684 -0
- keepup_http-0.5.0/keepup_http/static/modules/js/users.js +898 -0
- keepup_http-0.5.0/keepup_http/web.py +214 -0
- keepup_http-0.5.0/keepup_http.egg-info/PKG-INFO +29 -0
- keepup_http-0.5.0/keepup_http.egg-info/SOURCES.txt +46 -0
- keepup_http-0.5.0/keepup_http.egg-info/dependency_links.txt +1 -0
- keepup_http-0.5.0/keepup_http.egg-info/entry_points.txt +2 -0
- keepup_http-0.5.0/keepup_http.egg-info/requires.txt +12 -0
- keepup_http-0.5.0/keepup_http.egg-info/top_level.txt +1 -0
- keepup_http-0.5.0/pyproject.toml +46 -0
- keepup_http-0.5.0/setup.cfg +4 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: keepup-http
|
|
3
|
+
Version: 0.5.0
|
|
4
|
+
Summary: The way into the application over HTTP: the application, its routes and its middleware.
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: keepup-admin>=0.4.0
|
|
8
|
+
Requires-Dist: keepup-db>=0.4.0
|
|
9
|
+
Requires-Dist: keepup-auth>=0.4.0
|
|
10
|
+
Requires-Dist: keepup-audit>=0.4.0
|
|
11
|
+
Requires-Dist: keepup-cluster>=0.4.0
|
|
12
|
+
Requires-Dist: keepup-modules>=0.4.0
|
|
13
|
+
Requires-Dist: keepup-metrics>=0.4.0
|
|
14
|
+
Requires-Dist: keepup-tasks>=0.4.0
|
|
15
|
+
Requires-Dist: keepup-ui>=0.4.0
|
|
16
|
+
Requires-Dist: fastapi<1,>=0.128
|
|
17
|
+
Requires-Dist: starlette<2,>=1.3.1
|
|
18
|
+
Requires-Dist: pydantic<3,>=2.11
|
|
19
|
+
|
|
20
|
+
# keepup-http
|
|
21
|
+
|
|
22
|
+
The way into the application over HTTP: the application, its routes and its middleware.
|
|
23
|
+
|
|
24
|
+
One capability of [keepup](https://github.com/FedorSergeev/keepup-admin), the framework over FastAPI for admin
|
|
25
|
+
panels: the base is installed as `keepup-admin` and imported as `keepup`, and
|
|
26
|
+
this distribution carries one part of it. It is installed on its own
|
|
27
|
+
(`pip install keepup-http`) or together with the base (`pip install "keepup-admin[panel]"`),
|
|
28
|
+
and it declares itself to the kernel through its
|
|
29
|
+
`[project.entry-points."keepup.plugins"]` entry point.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# keepup-http
|
|
2
|
+
|
|
3
|
+
The way into the application over HTTP: the application, its routes and its middleware.
|
|
4
|
+
|
|
5
|
+
One capability of [keepup](https://github.com/FedorSergeev/keepup-admin), the framework over FastAPI for admin
|
|
6
|
+
panels: the base is installed as `keepup-admin` and imported as `keepup`, and
|
|
7
|
+
this distribution carries one part of it. It is installed on its own
|
|
8
|
+
(`pip install keepup-http`) or together with the base (`pip install "keepup-admin[panel]"`),
|
|
9
|
+
and it declares itself to the kernel through its
|
|
10
|
+
`[project.entry-points."keepup.plugins"]` entry point.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""The way into the application over HTTP (keepup-121).
|
|
2
|
+
|
|
3
|
+
This distribution carries what the kernel does not need: the ASGI application,
|
|
4
|
+
the routes, the middleware, the panel shell and the documentation pages. It
|
|
5
|
+
declares one plugin, `http`, of the transport kind, and depends on `keepup-admin`
|
|
6
|
+
for the constructor it is loaded by -- the base itself imports no web framework.
|
|
7
|
+
|
|
8
|
+
The modules here were the base's `api_docs.py`, `events_api.py`, `web.py` and the
|
|
9
|
+
route wrapper; they move in one slice at a time, and the base answers their old
|
|
10
|
+
names through the compatibility layer for one release (keepup-122).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
__version__ = "0.5.0"
|
|
14
|
+
|
|
15
|
+
__all__ = ["HttpPlugin", "SERVICE_HTTP", "register_api_documentation",
|
|
16
|
+
"register_event_api_routes"]
|
|
17
|
+
|
|
18
|
+
#: What a caller asks for to reach the application the transport builds.
|
|
19
|
+
SERVICE_HTTP = "http"
|
|
20
|
+
|
|
21
|
+
_FROM_PLUGIN = {"HttpPlugin", "SERVICE_HTTP"}
|
|
22
|
+
_FROM_MODULES = {
|
|
23
|
+
"register_api_documentation": "api_docs",
|
|
24
|
+
"register_event_api_routes": "events_api",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def __getattr__(name):
|
|
29
|
+
if name in _FROM_PLUGIN:
|
|
30
|
+
from keepup_http import plugin
|
|
31
|
+
|
|
32
|
+
return getattr(plugin, name)
|
|
33
|
+
if name in _FROM_MODULES:
|
|
34
|
+
import importlib
|
|
35
|
+
|
|
36
|
+
module = importlib.import_module(f"keepup_http.{_FROM_MODULES[name]}")
|
|
37
|
+
return getattr(module, name)
|
|
38
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""The API schema and the documentation pages built from it (keepup-96).
|
|
2
|
+
|
|
3
|
+
FastAPI serves its OpenAPI schema at /openapi.json to anybody, and the schema
|
|
4
|
+
describes every route of the application -- the administrative ones with their
|
|
5
|
+
parameters and bodies. For somebody outside that is a map of where to knock.
|
|
6
|
+
FastAPI has no notion of who is asking, so the framework takes both routes over:
|
|
7
|
+
|
|
8
|
+
- the schema answers an administrator only, like every other administrative
|
|
9
|
+
route of the framework, unless the application opens it on purpose
|
|
10
|
+
(``KeepupSettings.openapi_public``) or removes it (``openapi_url=None``);
|
|
11
|
+
- the documentation pages stay public shells: they fetch the schema from the
|
|
12
|
+
browser, with the panel's cookie, so an administrator sees the API and anybody
|
|
13
|
+
else sees the refusal.
|
|
14
|
+
|
|
15
|
+
Building the schema in code -- ``app.openapi()``, which ci/generate_openapi.py
|
|
16
|
+
calls -- is untouched: it never went through the route.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from fastapi import Depends
|
|
20
|
+
from fastapi.openapi.docs import (
|
|
21
|
+
get_redoc_html,
|
|
22
|
+
get_swagger_ui_html,
|
|
23
|
+
get_swagger_ui_oauth2_redirect_html,
|
|
24
|
+
)
|
|
25
|
+
from fastapi.responses import JSONResponse
|
|
26
|
+
|
|
27
|
+
from keepup_auth.dependencies import get_current_admin
|
|
28
|
+
|
|
29
|
+
#: What an application may import from this module. Everything else is
|
|
30
|
+
#: internal and may change without notice -- see doc/keepup.md.
|
|
31
|
+
__all__ = ["OAUTH2_REDIRECT_URL", "register_api_documentation"]
|
|
32
|
+
|
|
33
|
+
#: Where Swagger UI returns from an OAuth2 sign-in; FastAPI's own default.
|
|
34
|
+
OAUTH2_REDIRECT_URL = "/docs/oauth2-redirect"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def register_api_documentation(app, settings) -> None:
|
|
38
|
+
"""The schema route and the documentation pages, as the settings ask.
|
|
39
|
+
|
|
40
|
+
Called right after the application is created: FastAPI used to register
|
|
41
|
+
these in its constructor, ahead of any route of the application, and a page
|
|
42
|
+
registered later could be shadowed by a route that happens to match it.
|
|
43
|
+
"""
|
|
44
|
+
schema_url = settings.openapi_url
|
|
45
|
+
if not schema_url:
|
|
46
|
+
# No schema, no pages: they would have nothing to show.
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
if settings.openapi_public:
|
|
50
|
+
async def api_schema():
|
|
51
|
+
return JSONResponse(app.openapi())
|
|
52
|
+
else:
|
|
53
|
+
async def api_schema(admin: dict = Depends(get_current_admin)):
|
|
54
|
+
return JSONResponse(app.openapi())
|
|
55
|
+
|
|
56
|
+
app.add_api_route(schema_url, api_schema, methods=["GET"], include_in_schema=False)
|
|
57
|
+
|
|
58
|
+
if settings.docs_url:
|
|
59
|
+
async def swagger_page():
|
|
60
|
+
return get_swagger_ui_html(openapi_url=schema_url, title=f"{app.title} - API",
|
|
61
|
+
oauth2_redirect_url=OAUTH2_REDIRECT_URL)
|
|
62
|
+
|
|
63
|
+
async def swagger_redirect():
|
|
64
|
+
return get_swagger_ui_oauth2_redirect_html()
|
|
65
|
+
|
|
66
|
+
app.add_api_route(settings.docs_url, swagger_page, methods=["GET"],
|
|
67
|
+
include_in_schema=False)
|
|
68
|
+
app.add_api_route(OAUTH2_REDIRECT_URL, swagger_redirect, methods=["GET"],
|
|
69
|
+
include_in_schema=False)
|
|
70
|
+
|
|
71
|
+
if settings.redoc_url:
|
|
72
|
+
async def redoc_page():
|
|
73
|
+
return get_redoc_html(openapi_url=schema_url, title=f"{app.title} - API")
|
|
74
|
+
|
|
75
|
+
app.add_api_route(settings.redoc_url, redoc_page, methods=["GET"],
|
|
76
|
+
include_in_schema=False)
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""How large a request body a route accepts, checked before the body is read.
|
|
2
|
+
|
|
3
|
+
``KeepupSettings.max_upload_bytes`` was declared and enforced nowhere: every
|
|
4
|
+
route read whatever it was sent. It is enforced here, as ASGI middleware, and
|
|
5
|
+
per route (keepup-49):
|
|
6
|
+
|
|
7
|
+
- **A route that declares ``max_body_bytes`` gets exactly that** -- a number of
|
|
8
|
+
bytes, or ``None`` for no limit. That is how a route that legitimately takes
|
|
9
|
+
gigabytes (a machine image) says so, and how one that takes a few kilobytes
|
|
10
|
+
asks for less.
|
|
11
|
+
- **A route whose body the framework reads** -- a plain write route, whose JSON
|
|
12
|
+
is parsed before the handler runs, and every route of the framework itself --
|
|
13
|
+
gets the application's ``max_upload_bytes`` when it declares nothing.
|
|
14
|
+
- **A route that reads its own body** (``is_upload``, ``raw_request``) and
|
|
15
|
+
declares nothing gets no limit from the framework. Such routes stream files
|
|
16
|
+
and proxy other people's traffic; a general number applied to them the day
|
|
17
|
+
the framework starts enforcing would cut off an upload nobody had sized, so
|
|
18
|
+
each of them states its own.
|
|
19
|
+
|
|
20
|
+
The check comes before the body is read. A request whose ``Content-Length``
|
|
21
|
+
already says too much is answered 413 without the application seeing it. A
|
|
22
|
+
body without one (chunked) is counted as it arrives: once it passes the limit
|
|
23
|
+
the application is told the client went away, and whatever it answers is
|
|
24
|
+
replaced with the 413 -- the handler may have run with a partial read, which is
|
|
25
|
+
why a declared length is the path that matters.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
import logging
|
|
30
|
+
from typing import Optional
|
|
31
|
+
|
|
32
|
+
from starlette.routing import Match
|
|
33
|
+
|
|
34
|
+
from keepup.logging_setup import for_log
|
|
35
|
+
|
|
36
|
+
#: What an application may import from this module. Everything else is
|
|
37
|
+
#: internal and may change without notice -- see doc/keepup.md.
|
|
38
|
+
__all__ = [
|
|
39
|
+
"BodyLimitMiddleware",
|
|
40
|
+
"ROUTE_LIMIT_ATTRIBUTE",
|
|
41
|
+
"declare_limit",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
logger = logging.getLogger(__name__)
|
|
45
|
+
|
|
46
|
+
#: The attribute a route's endpoint carries when the route declared a limit.
|
|
47
|
+
ROUTE_LIMIT_ATTRIBUTE = "keepup_max_body_bytes"
|
|
48
|
+
#: The attribute of an endpoint that reads its own body.
|
|
49
|
+
READS_OWN_BODY_ATTRIBUTE = "keepup_reads_own_body"
|
|
50
|
+
|
|
51
|
+
_NOT_DECLARED = object()
|
|
52
|
+
#: Methods whose requests are not checked. Everything else is: a route declared
|
|
53
|
+
#: with OPTIONS or a custom method beside a write reads its body as well.
|
|
54
|
+
_BODYLESS_METHODS = {"GET", "HEAD"}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def declare_limit(endpoint, route: dict) -> None:
|
|
58
|
+
"""Mark a plugin route's endpoint with what its declaration says about bodies."""
|
|
59
|
+
limit = route.get('max_body_bytes', _NOT_DECLARED)
|
|
60
|
+
if limit is not _NOT_DECLARED:
|
|
61
|
+
if limit is not None and (not isinstance(limit, int) or isinstance(limit, bool)
|
|
62
|
+
or limit <= 0):
|
|
63
|
+
raise ValueError(f"{route.get('path')}: max_body_bytes must be a positive "
|
|
64
|
+
f"number of bytes or None, not {limit!r}")
|
|
65
|
+
setattr(endpoint, ROUTE_LIMIT_ATTRIBUTE, limit)
|
|
66
|
+
if route.get("is_upload") or route.get("raw_request"):
|
|
67
|
+
setattr(endpoint, READS_OWN_BODY_ATTRIBUTE, True)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class BodyLimitMiddleware:
|
|
71
|
+
"""Refuses a body larger than its route accepts, before the route reads it."""
|
|
72
|
+
|
|
73
|
+
def __init__(self, app, router_of=None, default_limit: Optional[int] = None):
|
|
74
|
+
self.app = app
|
|
75
|
+
self.router_of = router_of
|
|
76
|
+
self.default_limit = default_limit
|
|
77
|
+
|
|
78
|
+
def limit_for(self, scope) -> Optional[int]:
|
|
79
|
+
"""The limit of the route this request goes to; None for none."""
|
|
80
|
+
router = getattr(self.router_of, "router", None)
|
|
81
|
+
for route in getattr(router, "routes", ()):
|
|
82
|
+
match, _ = route.matches(scope)
|
|
83
|
+
if match == Match.FULL:
|
|
84
|
+
endpoint = getattr(route, "endpoint", None)
|
|
85
|
+
declared = getattr(endpoint, ROUTE_LIMIT_ATTRIBUTE, _NOT_DECLARED)
|
|
86
|
+
if declared is not _NOT_DECLARED:
|
|
87
|
+
return declared
|
|
88
|
+
if getattr(endpoint, READS_OWN_BODY_ATTRIBUTE, False):
|
|
89
|
+
return None
|
|
90
|
+
return self.default_limit
|
|
91
|
+
return self.default_limit
|
|
92
|
+
|
|
93
|
+
async def __call__(self, scope, receive, send):
|
|
94
|
+
if scope["type"] != "http" or scope.get("method") in _BODYLESS_METHODS:
|
|
95
|
+
return await self.app(scope, receive, send)
|
|
96
|
+
limit = self.limit_for(scope)
|
|
97
|
+
if limit is None:
|
|
98
|
+
return await self.app(scope, receive, send)
|
|
99
|
+
|
|
100
|
+
declared = _content_length(scope)
|
|
101
|
+
if declared is not None and declared > limit:
|
|
102
|
+
logger.warning("Request body refused: %s %s declares %s bytes, the route "
|
|
103
|
+
"accepts %s", scope.get("method"), for_log(scope.get("path")),
|
|
104
|
+
declared, limit)
|
|
105
|
+
return await _answer_too_large(send, limit)
|
|
106
|
+
|
|
107
|
+
state = {"received": 0, "over": False, "started": False}
|
|
108
|
+
|
|
109
|
+
async def counted_receive():
|
|
110
|
+
if state["over"]:
|
|
111
|
+
return {"type": "http.disconnect"}
|
|
112
|
+
message = await receive()
|
|
113
|
+
if message["type"] == "http.request":
|
|
114
|
+
state["received"] += len(message.get("body", b""))
|
|
115
|
+
if state["received"] > limit:
|
|
116
|
+
state["over"] = True
|
|
117
|
+
logger.warning("Request body refused: %s %s passed %s bytes without "
|
|
118
|
+
"declaring its length", scope.get("method"),
|
|
119
|
+
for_log(scope.get("path")), limit)
|
|
120
|
+
return {"type": "http.disconnect"}
|
|
121
|
+
return message
|
|
122
|
+
|
|
123
|
+
async def guarded_send(message):
|
|
124
|
+
if state["over"]:
|
|
125
|
+
return
|
|
126
|
+
if message["type"] == "http.response.start":
|
|
127
|
+
state["started"] = True
|
|
128
|
+
await send(message)
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
await self.app(scope, counted_receive, guarded_send)
|
|
132
|
+
except Exception:
|
|
133
|
+
if not state["over"]:
|
|
134
|
+
raise
|
|
135
|
+
if state["over"] and not state["started"]:
|
|
136
|
+
await _answer_too_large(send, limit)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _content_length(scope) -> Optional[int]:
|
|
140
|
+
for name, value in scope.get("headers", ()):
|
|
141
|
+
if name == b"content-length":
|
|
142
|
+
try:
|
|
143
|
+
return int(value)
|
|
144
|
+
except ValueError:
|
|
145
|
+
return None
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
async def _answer_too_large(send, limit: int) -> None:
|
|
150
|
+
body = json.dumps({"detail": f"Request body too large: this route accepts "
|
|
151
|
+
f"at most {limit} bytes"}).encode()
|
|
152
|
+
await send({"type": "http.response.start", "status": 413,
|
|
153
|
+
"headers": [(b"content-type", b"application/json"),
|
|
154
|
+
(b"content-length", str(len(body)).encode()),
|
|
155
|
+
(b"connection", b"close")]})
|
|
156
|
+
await send({"type": "http.response.body", "body": body})
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""The HTTP routes of the application's event log.
|
|
2
|
+
|
|
3
|
+
The journal itself -- the table, writing, reading, retention -- is
|
|
4
|
+
`keepup/events.py`; these routes are one reader of it (keepup-23). Every route
|
|
5
|
+
is for administrators: the journal holds sign-ins, agent connections and
|
|
6
|
+
console entries of every user. See `doc/event_manager.md`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from typing import Any, Dict, List, Optional
|
|
12
|
+
|
|
13
|
+
from fastapi import Depends, HTTPException, Query
|
|
14
|
+
from pydantic import BaseModel, Field
|
|
15
|
+
|
|
16
|
+
from keepup_auth.dependencies import get_current_admin
|
|
17
|
+
from keepup_audit import events as events
|
|
18
|
+
from keepup_audit import trail as admin_trail
|
|
19
|
+
from keepup.logging_setup import for_log
|
|
20
|
+
|
|
21
|
+
#: What an application may import from this module. Everything else is
|
|
22
|
+
#: internal and may change without notice -- see doc/keepup.md.
|
|
23
|
+
__all__ = [
|
|
24
|
+
"register_event_api_routes",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
# The journal's manager is looked up on its module at each call
|
|
28
|
+
# (``events.event_manager``), not bound here: whoever replaces the journal's
|
|
29
|
+
# manager -- an application, a test -- replaces it for the routes too.
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
#: What a failed route answers. The exception's own text -- a database message
|
|
34
|
+
#: naming tables, values and sometimes a query -- went to the client and to the
|
|
35
|
+
#: audit as it was (keepup-76); it goes to the application log instead.
|
|
36
|
+
INTERNAL_ERROR = "Internal server error; see the application log"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class AppEvent(BaseModel):
|
|
40
|
+
"""Application event."""
|
|
41
|
+
id: Optional[int] = None
|
|
42
|
+
event_type: str = Field(..., description="Event type, for example 'user_login', 'payment_success', 'error'")
|
|
43
|
+
event_text: str = Field(..., description="Event text")
|
|
44
|
+
event_data: Optional[Dict[str, Any]] = Field(None, description="Extra event payload, as JSON")
|
|
45
|
+
instance_id: Optional[str] = Field(None, description="Id of the instance that produced the event")
|
|
46
|
+
instance_name: Optional[str] = Field(None, description="Name of the instance that produced the event")
|
|
47
|
+
created_at: Optional[datetime] = Field(None, description="When the event was created")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class EventCreate(BaseModel):
|
|
51
|
+
"""Payload for creating an event."""
|
|
52
|
+
event_type: str = Field(..., min_length=1, max_length=100, description="Event type")
|
|
53
|
+
event_text: str = Field(..., min_length=1, description="Event text")
|
|
54
|
+
event_data: Optional[Dict[str, Any]] = Field(None, description="Extra payload")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class EventListResponse(BaseModel):
|
|
58
|
+
"""Response carrying a list of events."""
|
|
59
|
+
events: List[Dict[str, Any]]
|
|
60
|
+
total: int
|
|
61
|
+
page: int
|
|
62
|
+
page_size: int
|
|
63
|
+
total_pages: int
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def register_event_api_routes(app, declared_event_types=None):
|
|
68
|
+
"""Register the event API routes on the application.
|
|
69
|
+
|
|
70
|
+
``declared_event_types`` is supplied by the application: a callable
|
|
71
|
+
returning the event types it declares in code but may never have emitted.
|
|
72
|
+
The framework has no catalogue of its own -- which types exist is a
|
|
73
|
+
property of the application, and reaching into it from here would point
|
|
74
|
+
the dependency the wrong way.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
@app.post("/api/events", response_model=Dict[str, Any])
|
|
78
|
+
async def create_event_endpoint(
|
|
79
|
+
event: EventCreate,
|
|
80
|
+
current_user: dict = Depends(get_current_admin)
|
|
81
|
+
):
|
|
82
|
+
"""Create an event by hand.
|
|
83
|
+
|
|
84
|
+
Administrators only. Audit events are written by the server itself at
|
|
85
|
+
the point the action happens (the application's audit helper), never
|
|
86
|
+
accepted over HTTP: a journal the watched party can fill is not
|
|
87
|
+
evidence. So a type the application declares, or one the framework
|
|
88
|
+
writes itself, is refused here, and what is accepted carries who wrote
|
|
89
|
+
it by hand (keepup-67). The event is attached to the current instance.
|
|
90
|
+
"""
|
|
91
|
+
reserved = set(admin_trail.ADMIN_EVENT_TYPES)
|
|
92
|
+
if declared_event_types:
|
|
93
|
+
reserved.update(declared_event_types() or ())
|
|
94
|
+
if event.event_type in reserved:
|
|
95
|
+
raise HTTPException(
|
|
96
|
+
status_code=400,
|
|
97
|
+
detail="This event type is written by the application itself and "
|
|
98
|
+
"cannot be created by hand.")
|
|
99
|
+
data = dict(event.event_data or {})
|
|
100
|
+
data["manual"] = {"user_id": current_user.get("id"),
|
|
101
|
+
"username": current_user.get("username")}
|
|
102
|
+
try:
|
|
103
|
+
event_id = await events.event_manager.create_event(
|
|
104
|
+
event_type=event.event_type,
|
|
105
|
+
event_text=event.event_text,
|
|
106
|
+
event_data=data
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
"success": True,
|
|
111
|
+
"event_id": event_id,
|
|
112
|
+
"message": "Event created successfully",
|
|
113
|
+
"instance": {
|
|
114
|
+
"id": events.event_manager.instance_id,
|
|
115
|
+
"name": events.event_manager.instance_name
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
except Exception as e:
|
|
119
|
+
logger.error("Error in create_event_endpoint: %s", for_log(e))
|
|
120
|
+
raise HTTPException(status_code=500, detail=INTERNAL_ERROR)
|
|
121
|
+
|
|
122
|
+
@app.get("/api/events", response_model=EventListResponse)
|
|
123
|
+
async def get_events_endpoint(
|
|
124
|
+
event_type: Optional[str] = Query(None, description="Filter by event type"),
|
|
125
|
+
instance_id: Optional[str] = Query(None, description="Filter by instance id"),
|
|
126
|
+
instance_name: Optional[str] = Query(None, description="Filter by instance name"),
|
|
127
|
+
start_date: Optional[datetime] = Query(None, description="Lower date bound, ISO format"),
|
|
128
|
+
end_date: Optional[datetime] = Query(None, description="Upper date bound, ISO format"),
|
|
129
|
+
page: int = Query(1, ge=1, description="Page number"),
|
|
130
|
+
page_size: int = Query(50, ge=1, le=500, description="Page size"),
|
|
131
|
+
current_user: dict = Depends(get_current_admin)
|
|
132
|
+
):
|
|
133
|
+
"""Return events, filtered and paginated.
|
|
134
|
+
|
|
135
|
+
Administrators only: the journal holds logins, agent connections and
|
|
136
|
+
console entries of every user, which is the administrator's view of
|
|
137
|
+
the platform and not the user's own.
|
|
138
|
+
"""
|
|
139
|
+
try:
|
|
140
|
+
result = await events.event_manager.get_events(
|
|
141
|
+
event_type=event_type,
|
|
142
|
+
instance_id=instance_id,
|
|
143
|
+
instance_name=instance_name,
|
|
144
|
+
start_date=start_date,
|
|
145
|
+
end_date=end_date,
|
|
146
|
+
page=page,
|
|
147
|
+
page_size=page_size
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
return result
|
|
151
|
+
except Exception as e:
|
|
152
|
+
logger.error("Error in get_events_endpoint: %s", for_log(e))
|
|
153
|
+
raise HTTPException(status_code=500, detail=INTERNAL_ERROR)
|
|
154
|
+
|
|
155
|
+
@app.get("/api/events/types")
|
|
156
|
+
async def get_event_types_endpoint(
|
|
157
|
+
current_user: dict = Depends(get_current_admin)
|
|
158
|
+
):
|
|
159
|
+
"""Every type the filter may offer: seen in the journal, or declared.
|
|
160
|
+
|
|
161
|
+
A list built only from what the table holds cannot offer a type that
|
|
162
|
+
has never occurred -- which is exactly the one an administrator asks
|
|
163
|
+
for when checking whether something happened at all. The declared
|
|
164
|
+
audit types are therefore added to the observed ones.
|
|
165
|
+
|
|
166
|
+
The declared types are supplied by the application through
|
|
167
|
+
``register_event_api_routes``; the framework does not import the
|
|
168
|
+
application's audit catalogue.
|
|
169
|
+
"""
|
|
170
|
+
try:
|
|
171
|
+
declared = list(declared_event_types() or ()) if declared_event_types else []
|
|
172
|
+
|
|
173
|
+
observed = await events.event_manager.get_event_types()
|
|
174
|
+
event_types = sorted(set(observed) | set(declared))
|
|
175
|
+
return {
|
|
176
|
+
"event_types": event_types,
|
|
177
|
+
"count": len(event_types)
|
|
178
|
+
}
|
|
179
|
+
except Exception as e:
|
|
180
|
+
logger.error("Error in get_event_types_endpoint: %s", for_log(e))
|
|
181
|
+
raise HTTPException(status_code=500, detail=INTERNAL_ERROR)
|
|
182
|
+
|
|
183
|
+
@app.get("/api/events/instances")
|
|
184
|
+
async def get_instances_endpoint(
|
|
185
|
+
current_user: dict = Depends(get_current_admin)
|
|
186
|
+
):
|
|
187
|
+
"""Return every instance that has produced events (administrators only)."""
|
|
188
|
+
try:
|
|
189
|
+
instances = await events.event_manager.get_instances()
|
|
190
|
+
return {
|
|
191
|
+
"instances": instances,
|
|
192
|
+
"count": len(instances),
|
|
193
|
+
"current_instance": {
|
|
194
|
+
"id": events.event_manager.instance_id,
|
|
195
|
+
"name": events.event_manager.instance_name
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
except Exception as e:
|
|
199
|
+
logger.error("Error in get_instances_endpoint: %s", for_log(e))
|
|
200
|
+
raise HTTPException(status_code=500, detail=INTERNAL_ERROR)
|
|
201
|
+
|
|
202
|
+
@app.delete("/api/events/cleanup")
|
|
203
|
+
async def cleanup_old_events_endpoint(
|
|
204
|
+
days: int = Query(30, ge=1, le=365, description="Delete events older than N days"),
|
|
205
|
+
current_user: dict = Depends(get_current_admin)
|
|
206
|
+
):
|
|
207
|
+
"""Delete old events (administrators only).
|
|
208
|
+
|
|
209
|
+
The purge itself is written into the log afterwards, so what it
|
|
210
|
+
removed is not all that is left of it (keepup-67).
|
|
211
|
+
"""
|
|
212
|
+
try:
|
|
213
|
+
deleted_count = await events.event_manager.delete_old_events(days)
|
|
214
|
+
await admin_trail.record(
|
|
215
|
+
admin_trail.EVENTS_PURGED, current_user,
|
|
216
|
+
f"Deleted {deleted_count} events older than {days} days",
|
|
217
|
+
days=days, deleted_count=deleted_count)
|
|
218
|
+
return {
|
|
219
|
+
"success": True,
|
|
220
|
+
"deleted_count": deleted_count,
|
|
221
|
+
"message": f"Deleted {deleted_count} events older than {days} days"
|
|
222
|
+
}
|
|
223
|
+
except Exception as e:
|
|
224
|
+
logger.error("Error in cleanup_old_events_endpoint: %s", for_log(e))
|
|
225
|
+
raise HTTPException(status_code=500, detail=INTERNAL_ERROR)
|
|
226
|
+
|
|
227
|
+
@app.get("/api/events/stats")
|
|
228
|
+
async def get_events_stats_endpoint(
|
|
229
|
+
current_user: dict = Depends(get_current_admin)
|
|
230
|
+
):
|
|
231
|
+
"""Return extended event statistics (administrators only)."""
|
|
232
|
+
try:
|
|
233
|
+
stats = await events.event_manager.get_events_stats()
|
|
234
|
+
return stats
|
|
235
|
+
except Exception as e:
|
|
236
|
+
logger.error("Error in get_events_stats_endpoint: %s", for_log(e))
|
|
237
|
+
raise HTTPException(status_code=500, detail=INTERNAL_ERROR)
|