keepup-http 0.5.0__tar.gz → 0.7.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 → keepup_http-0.7.0}/PKG-INFO +1 -1
- {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/__init__.py +1 -1
- {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/factory.py +19 -0
- {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/routes.py +219 -89
- {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/static/css/main_nebula.css +22 -0
- {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/static/css/main_new.css +173 -0
- {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/static/index_nebula.html +31 -14
- {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/static/index_new.html +31 -14
- {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/static/js/main_new.js +542 -84
- keepup_http-0.7.0/keepup_http/static/js/mfa_panel.js +342 -0
- {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http.egg-info/PKG-INFO +1 -1
- {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http.egg-info/SOURCES.txt +1 -0
- {keepup_http-0.5.0 → keepup_http-0.7.0}/pyproject.toml +1 -1
- {keepup_http-0.5.0 → keepup_http-0.7.0}/README.md +0 -0
- {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/api_docs.py +0 -0
- {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/body_limit.py +0 -0
- {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/events_api.py +0 -0
- {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/lock_routes.py +0 -0
- {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/plugin.py +0 -0
- {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/static/css/aos.css +0 -0
- {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/static/css/tailwind.css +0 -0
- {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/static/js/aos.js +0 -0
- {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/static/js/feather-icons.js +0 -0
- {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/static/js/layout_bootstrap.js +0 -0
- {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/static/js/tailwind.js +0 -0
- {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/static/modules/css/background_tasks.css +0 -0
- {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/static/modules/css/cluster.css +0 -0
- {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/static/modules/css/event_manager.css +0 -0
- {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/static/modules/css/integration_logs.css +0 -0
- {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/static/modules/css/metrics.css +0 -0
- {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/static/modules/css/modules.css +0 -0
- {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/static/modules/css/themes.css +0 -0
- {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/static/modules/css/users.css +0 -0
- {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/static/modules/js/background_tasks.js +0 -0
- {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/static/modules/js/chart.js +0 -0
- {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/static/modules/js/chartjs-adapter-date-fns.bundle.min.js +0 -0
- {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/static/modules/js/cluster.js +0 -0
- {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/static/modules/js/event_manager.js +0 -0
- {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/static/modules/js/integration_logs.js +0 -0
- {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/static/modules/js/metrics.js +0 -0
- {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/static/modules/js/modules.js +0 -0
- {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/static/modules/js/themes.js +0 -0
- {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/static/modules/js/users.js +0 -0
- {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http/web.py +0 -0
- {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http.egg-info/dependency_links.txt +0 -0
- {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http.egg-info/entry_points.txt +0 -0
- {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http.egg-info/requires.txt +0 -0
- {keepup_http-0.5.0 → keepup_http-0.7.0}/keepup_http.egg-info/top_level.txt +0 -0
- {keepup_http-0.5.0 → keepup_http-0.7.0}/setup.cfg +0 -0
|
@@ -10,7 +10,7 @@ route wrapper; they move in one slice at a time, and the base answers their old
|
|
|
10
10
|
names through the compatibility layer for one release (keepup-122).
|
|
11
11
|
"""
|
|
12
12
|
|
|
13
|
-
__version__ = "0.
|
|
13
|
+
__version__ = "0.7.0"
|
|
14
14
|
|
|
15
15
|
__all__ = ["HttpPlugin", "SERVICE_HTTP", "register_api_documentation",
|
|
16
16
|
"register_event_api_routes"]
|
|
@@ -43,6 +43,7 @@ from keepup_audit.events import init_event_manager
|
|
|
43
43
|
from keepup_http.events_api import register_event_api_routes
|
|
44
44
|
from keepup.instance import get_instance_id
|
|
45
45
|
from keepup_http.lock_routes import register_lock_routes
|
|
46
|
+
from keepup_http.routes import register_declared_routes, register_declared_sockets
|
|
46
47
|
from keepup_metrics.api import register_metrics_routes
|
|
47
48
|
from keepup_modules.catalogue import register_module_routes
|
|
48
49
|
from keepup.plugins.registry import (
|
|
@@ -168,6 +169,11 @@ def apply_settings(settings: KeepupSettings) -> None:
|
|
|
168
169
|
favicon_file=settings.favicon_file,
|
|
169
170
|
)
|
|
170
171
|
auth_dependencies.configure_panel_gate(pending=settings.pending_documents)
|
|
172
|
+
# The panel's cookies carry the application's name, so several keepup
|
|
173
|
+
# applications on one host do not overwrite each other's session (0.7.0).
|
|
174
|
+
panel_session.configure(
|
|
175
|
+
settings.cookie_prefix if settings.cookie_prefix is not None
|
|
176
|
+
else settings.project_name)
|
|
171
177
|
if not settings.database_dialect:
|
|
172
178
|
# The framework's configuration is the default; naming the dialect lets
|
|
173
179
|
# the kernel choose a driver without importing a database (keepup-123).
|
|
@@ -314,6 +320,19 @@ def _build_lifespan(settings: KeepupSettings):
|
|
|
314
320
|
if manager is not None:
|
|
315
321
|
await initialize_plugins(app, manager, config_path=settings.plugins_config_path)
|
|
316
322
|
|
|
323
|
+
# What the kernel's plugins declared is bound here (keepup-121): an
|
|
324
|
+
# application that named only `plugins_dir` used to get no route of its
|
|
325
|
+
# own, because only a PluginManager of the 0.3.0 path ever bound them.
|
|
326
|
+
# The manager above registered its declarations a moment ago; a route the
|
|
327
|
+
# application already answers is skipped rather than answered twice.
|
|
328
|
+
if kernel is not None:
|
|
329
|
+
mounted = kernel.mount({
|
|
330
|
+
"routes": lambda items, runtime=None: register_declared_routes(app, items),
|
|
331
|
+
"sockets": lambda items, runtime=None: register_declared_sockets(app, items),
|
|
332
|
+
})
|
|
333
|
+
if mounted:
|
|
334
|
+
logger.info("Mounted what the plugins declared: %s", mounted)
|
|
335
|
+
|
|
317
336
|
# The log handlers the running capabilities contribute -- the shipping
|
|
318
337
|
# one, when a collector is named (keepup-118). A deployment that
|
|
319
338
|
# installs none attaches nothing.
|
|
@@ -40,6 +40,8 @@ from keepup.plugins import route_mask
|
|
|
40
40
|
__all__ = [
|
|
41
41
|
"accepted_params",
|
|
42
42
|
"raw_request_wrapper",
|
|
43
|
+
"register_declared_routes",
|
|
44
|
+
"register_declared_sockets",
|
|
43
45
|
"register_plugin_routes",
|
|
44
46
|
]
|
|
45
47
|
|
|
@@ -335,10 +337,9 @@ def create_wrapper(handler, path, methods, require_auth: bool = True,
|
|
|
335
337
|
|
|
336
338
|
async def call(request: Request, current_user):
|
|
337
339
|
"""One call of a plugin route, from the audit's first row to its last."""
|
|
338
|
-
request_data
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
}
|
|
340
|
+
# The actor is not part of request_data: that is redacted whole, and a
|
|
341
|
+
# row that cannot name who called is not an audit (keepup-172).
|
|
342
|
+
request_data = {}
|
|
342
343
|
if has_path_params:
|
|
343
344
|
request_data['path_params'] = dict(request.path_params)
|
|
344
345
|
request_data['query_params'] = dict(request.query_params)
|
|
@@ -353,7 +354,8 @@ def create_wrapper(handler, path, methods, require_auth: bool = True,
|
|
|
353
354
|
method=method_name,
|
|
354
355
|
endpoint=path,
|
|
355
356
|
host='plugin_api',
|
|
356
|
-
request_data=request_data
|
|
357
|
+
request_data=request_data,
|
|
358
|
+
actor=current_user
|
|
357
359
|
) as request_id:
|
|
358
360
|
try:
|
|
359
361
|
response = await invoke(request, current_user)
|
|
@@ -413,95 +415,223 @@ async def register_plugin_routes(app, manager):
|
|
|
413
415
|
logger.info(f"Found {len(routes)} plugin routes to register")
|
|
414
416
|
|
|
415
417
|
for route in routes:
|
|
416
|
-
|
|
417
|
-
path = route['path']
|
|
418
|
-
methods = route['methods']
|
|
419
|
-
require_auth = route.get('require_auth', True)
|
|
420
|
-
is_upload = route.get('is_upload', False)
|
|
421
|
-
permission = route.get('permission')
|
|
422
|
-
audit = route.get('audit', True)
|
|
423
|
-
response_media_type = route.get('response_media_type')
|
|
424
|
-
if response_media_type is not None and (
|
|
425
|
-
not isinstance(response_media_type, str) or not response_media_type.strip()):
|
|
426
|
-
raise ValueError(
|
|
427
|
-
f"{path}: response_media_type is a media type, like 'text/plain'")
|
|
428
|
-
if response_media_type is not None and route.get('raw_request'):
|
|
429
|
-
raise ValueError(
|
|
430
|
-
f"{path}: a raw_request route answers with a Response of its own; "
|
|
431
|
-
"response_media_type would be ignored. Drop one of the two.")
|
|
432
|
-
if permission is not None:
|
|
433
|
-
# A right needs somebody to hold it: on a route nobody signs in to,
|
|
434
|
-
# or one that signs itself in, it would be a check that never runs.
|
|
435
|
-
if not isinstance(permission, str) or not permission.strip():
|
|
436
|
-
raise ValueError(f"{path}: permission must be a non-empty string")
|
|
437
|
-
if route.get('raw_request') or not require_auth:
|
|
438
|
-
raise ValueError(
|
|
439
|
-
f"{path}: a route that declares a permission must be signed in by "
|
|
440
|
-
"the framework -- drop require_auth: false or raw_request, or the "
|
|
441
|
-
"permission")
|
|
442
|
-
|
|
443
|
-
logger.info(
|
|
444
|
-
f"Registering route {path} - methods: {methods}, auth: {require_auth}, upload: {is_upload}")
|
|
445
|
-
|
|
446
|
-
# The kernel parsed and checked the mask when it collected the route,
|
|
447
|
-
# for every transport there will ever be (keepup-102); a route that
|
|
448
|
-
# reaches here without one was declared by something else, and is
|
|
449
|
-
# checked the same way rather than trusted.
|
|
450
|
-
mask = route.get('_mask')
|
|
451
|
-
if mask is None:
|
|
452
|
-
try:
|
|
453
|
-
mask = route_mask.parse(route.get(route_mask.MASK_FIELD), path)
|
|
454
|
-
route_mask.check_signature(handler, mask)
|
|
455
|
-
except route_mask.MaskError as error:
|
|
456
|
-
# The same loudness as the raw_request check below: a mask naming
|
|
457
|
-
# a parameter the handler does not take is a typo, and finding it
|
|
458
|
-
# on a request would mean hearing about it from a user.
|
|
459
|
-
raise ValueError(f"{path}: {error}")
|
|
460
|
-
|
|
461
|
-
if route.get('raw_request'):
|
|
462
|
-
# A raw route authenticates inside the handler -- it is handed the
|
|
463
|
-
# Request and nothing else. Declaring require_auth on it therefore
|
|
464
|
-
# promises a check the framework does not make, and the start-up
|
|
465
|
-
# line used to print auth=True over an open route (keepup-12).
|
|
466
|
-
if route.get('require_auth') is True:
|
|
467
|
-
raise ValueError(
|
|
468
|
-
f"{path}: a raw_request route cannot also declare "
|
|
469
|
-
"require_auth -- the handler receives the Request and "
|
|
470
|
-
"authenticates itself. Drop one of the two.")
|
|
471
|
-
require_auth = False
|
|
472
|
-
wrapper = raw_request_wrapper(handler)
|
|
473
|
-
else:
|
|
474
|
-
wrapper = create_wrapper(handler, path, methods, require_auth, is_upload, mask,
|
|
475
|
-
permission, audit=audit,
|
|
476
|
-
response_media_type=response_media_type)
|
|
477
|
-
|
|
478
|
-
# What the route says about its body: its own limit, or that it reads
|
|
479
|
-
# the body itself (keepup/body_limit.py).
|
|
480
|
-
declare_limit(wrapper, route)
|
|
481
|
-
|
|
482
|
-
app.add_api_route(
|
|
483
|
-
path,
|
|
484
|
-
wrapper,
|
|
485
|
-
methods=methods,
|
|
486
|
-
include_in_schema=route.get('include_in_schema', True)
|
|
487
|
-
)
|
|
488
|
-
|
|
489
|
-
logger.info(
|
|
490
|
-
f"Registered plugin route: {path} - {methods} (auth={require_auth}, upload={is_upload})")
|
|
418
|
+
_mount_route(app, route)
|
|
491
419
|
|
|
492
420
|
websocket_routes = manager.get_all_websocket_routes()
|
|
493
421
|
logger.info(f"Found {len(websocket_routes)} WebSocket plugin routes to register")
|
|
494
422
|
|
|
495
423
|
for ws_route in websocket_routes:
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
424
|
+
_mount_socket(app, ws_route)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def register_declared_routes(app, declarations) -> int:
|
|
428
|
+
"""Mount the routes the kernel collected from the plugins (keepup-121).
|
|
429
|
+
|
|
430
|
+
The kernel resolves, loads and initialises the plugins, and what they
|
|
431
|
+
declared is the runtime's, transport-neutral: this is the HTTP half of that
|
|
432
|
+
seam, the call ``runtime.mount`` hands to a registrar. Until it existed an
|
|
433
|
+
application that named only ``plugins_dir`` got no route of its own, because
|
|
434
|
+
only a PluginManager of the 0.3.0 path ever bound them.
|
|
435
|
+
|
|
436
|
+
What the application already answers is left alone: the framework's own
|
|
437
|
+
routes are registered before the kernel starts, and an application that
|
|
438
|
+
passes a manager has its declarations bound by `register_plugin_routes` --
|
|
439
|
+
so one declaration never becomes two endpoints.
|
|
440
|
+
|
|
441
|
+
Args:
|
|
442
|
+
app: the FastAPI application.
|
|
443
|
+
declarations: the route declarations the kernel collected, each tagged
|
|
444
|
+
with ``_plugin_id`` and, because the collector does it once for every
|
|
445
|
+
transport, a parsed ``_mask``.
|
|
446
|
+
|
|
447
|
+
Returns:
|
|
448
|
+
How many declarations were mounted.
|
|
449
|
+
"""
|
|
450
|
+
mounted = 0
|
|
451
|
+
for declaration in declarations:
|
|
452
|
+
path = declaration["path"]
|
|
453
|
+
methods = tuple(declaration.get("methods") or ())
|
|
454
|
+
if _already_mounted(app, path, methods):
|
|
455
|
+
logger.info("Route %s %s already answers: the kernel's declaration is "
|
|
456
|
+
"not mounted twice", path, list(methods))
|
|
457
|
+
continue
|
|
458
|
+
_mount_route(app, declaration)
|
|
459
|
+
mounted += 1
|
|
460
|
+
return mounted
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def register_declared_sockets(app, declarations) -> int:
|
|
464
|
+
"""Mount the sockets the kernel collected from the plugins (keepup-121).
|
|
465
|
+
|
|
466
|
+
The same seam as `register_declared_routes`, for the other thing a plugin
|
|
467
|
+
declares. A socket the application already answers is left alone.
|
|
468
|
+
|
|
469
|
+
Args:
|
|
470
|
+
app: the FastAPI application.
|
|
471
|
+
declarations: the socket declarations the kernel collected.
|
|
472
|
+
|
|
473
|
+
Returns:
|
|
474
|
+
How many declarations were mounted.
|
|
475
|
+
"""
|
|
476
|
+
mounted = 0
|
|
477
|
+
for declaration in declarations:
|
|
478
|
+
path = declaration["path"]
|
|
479
|
+
if _already_mounted(app, path):
|
|
480
|
+
logger.info("Socket %s already answers: the kernel's declaration is not "
|
|
481
|
+
"mounted twice", path)
|
|
482
|
+
continue
|
|
483
|
+
_mount_socket(app, declaration)
|
|
484
|
+
mounted += 1
|
|
485
|
+
return mounted
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _mount_route(app, declaration):
|
|
489
|
+
"""Turn one declared route into an endpoint, having checked it first.
|
|
490
|
+
|
|
491
|
+
One implementation for every declaration, whatever collected it -- the
|
|
492
|
+
manager of the 0.3.0 path or the kernel's catalogue (keepup-21, keepup-121).
|
|
493
|
+
A declaration the route runtime refuses -- a permission on a route nobody
|
|
494
|
+
signs in to, a mask naming a parameter the handler does not take -- is a
|
|
495
|
+
mistake found at the start, rather than a route that quietly goes missing
|
|
496
|
+
(keepup-102).
|
|
497
|
+
|
|
498
|
+
Args:
|
|
499
|
+
app: the FastAPI application.
|
|
500
|
+
declaration: the route as the plugin declared it. The kernel's collector
|
|
501
|
+
leaves a parsed ``_mask`` on it; a declaration from anywhere else is
|
|
502
|
+
checked here instead of trusted.
|
|
503
|
+
|
|
504
|
+
Raises:
|
|
505
|
+
ValueError: when the declaration is one the runtime refuses.
|
|
506
|
+
"""
|
|
507
|
+
route = declaration
|
|
508
|
+
handler = route['handler']
|
|
509
|
+
path = route['path']
|
|
510
|
+
methods = route['methods']
|
|
511
|
+
require_auth = route.get('require_auth', True)
|
|
512
|
+
is_upload = route.get('is_upload', False)
|
|
513
|
+
permission = route.get('permission')
|
|
514
|
+
audit = route.get('audit', True)
|
|
515
|
+
response_media_type = route.get('response_media_type')
|
|
516
|
+
if response_media_type is not None and (
|
|
517
|
+
not isinstance(response_media_type, str) or not response_media_type.strip()):
|
|
518
|
+
raise ValueError(
|
|
519
|
+
f"{path}: response_media_type is a media type, like 'text/plain'")
|
|
520
|
+
if response_media_type is not None and route.get('raw_request'):
|
|
521
|
+
raise ValueError(
|
|
522
|
+
f"{path}: a raw_request route answers with a Response of its own; "
|
|
523
|
+
"response_media_type would be ignored. Drop one of the two.")
|
|
524
|
+
if permission is not None:
|
|
525
|
+
# A right needs somebody to hold it: on a route nobody signs in to,
|
|
526
|
+
# or one that signs itself in, it would be a check that never runs.
|
|
527
|
+
if not isinstance(permission, str) or not permission.strip():
|
|
528
|
+
raise ValueError(f"{path}: permission must be a non-empty string")
|
|
529
|
+
if route.get('raw_request') or not require_auth:
|
|
530
|
+
raise ValueError(
|
|
531
|
+
f"{path}: a route that declares a permission must be signed in by "
|
|
532
|
+
"the framework -- drop require_auth: false or raw_request, or the "
|
|
533
|
+
"permission")
|
|
534
|
+
|
|
535
|
+
logger.info(
|
|
536
|
+
f"Registering route {path} - methods: {methods}, auth: {require_auth}, upload: {is_upload}")
|
|
537
|
+
|
|
538
|
+
# The kernel parsed and checked the mask when it collected the route,
|
|
539
|
+
# for every transport there will ever be (keepup-102); a route that
|
|
540
|
+
# reaches here without one was declared by something else, and is
|
|
541
|
+
# checked the same way rather than trusted.
|
|
542
|
+
mask = route.get('_mask')
|
|
543
|
+
if mask is None:
|
|
544
|
+
try:
|
|
545
|
+
mask = route_mask.parse(route.get(route_mask.MASK_FIELD), path)
|
|
546
|
+
route_mask.check_signature(handler, mask)
|
|
547
|
+
except route_mask.MaskError as error:
|
|
548
|
+
# The same loudness as the raw_request check below: a mask naming
|
|
549
|
+
# a parameter the handler does not take is a typo, and finding it
|
|
550
|
+
# on a request would mean hearing about it from a user.
|
|
551
|
+
raise ValueError(f"{path}: {error}")
|
|
552
|
+
|
|
553
|
+
if route.get('raw_request'):
|
|
554
|
+
# A raw route authenticates inside the handler -- it is handed the
|
|
555
|
+
# Request and nothing else. Declaring require_auth on it therefore
|
|
556
|
+
# promises a check the framework does not make, and the start-up
|
|
557
|
+
# line used to print auth=True over an open route (keepup-12).
|
|
558
|
+
if route.get('require_auth') is True:
|
|
559
|
+
raise ValueError(
|
|
560
|
+
f"{path}: a raw_request route cannot also declare "
|
|
561
|
+
"require_auth -- the handler receives the Request and "
|
|
562
|
+
"authenticates itself. Drop one of the two.")
|
|
563
|
+
require_auth = False
|
|
564
|
+
wrapper = raw_request_wrapper(handler)
|
|
565
|
+
else:
|
|
566
|
+
wrapper = create_wrapper(handler, path, methods, require_auth, is_upload, mask,
|
|
567
|
+
permission, audit=audit,
|
|
568
|
+
response_media_type=response_media_type)
|
|
569
|
+
|
|
570
|
+
# What the route says about its body: its own limit, or that it reads
|
|
571
|
+
# the body itself (keepup/body_limit.py).
|
|
572
|
+
declare_limit(wrapper, route)
|
|
573
|
+
|
|
574
|
+
app.add_api_route(
|
|
575
|
+
path,
|
|
576
|
+
wrapper,
|
|
577
|
+
methods=methods,
|
|
578
|
+
include_in_schema=route.get('include_in_schema', True)
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
logger.info(
|
|
582
|
+
f"Registered plugin route: {path} - {methods} (auth={require_auth}, upload={is_upload})")
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _mount_socket(app, declaration):
|
|
586
|
+
"""Turn one declared socket into a route on the application.
|
|
587
|
+
|
|
588
|
+
The other half of the same seam: the kernel collects what a plugin declared,
|
|
589
|
+
and this is where HTTP binds it. Kept beside `_mount_route` because one
|
|
590
|
+
declaration should have one way in, whatever collected it.
|
|
591
|
+
|
|
592
|
+
Args:
|
|
593
|
+
app: the FastAPI application.
|
|
594
|
+
declaration: the socket as the plugin declared it.
|
|
595
|
+
"""
|
|
596
|
+
ws_route = declaration
|
|
597
|
+
path = ws_route['path']
|
|
598
|
+
handler = ws_route['handler']
|
|
599
|
+
# Off by default, unlike HTTP routes: sockets of agents and guests carry
|
|
600
|
+
# tokens of their own and would be refused by the panel's sign-in rule.
|
|
601
|
+
if ws_route.get('require_auth', False):
|
|
602
|
+
handler = signed_in_websocket(handler)
|
|
603
|
+
_register_websocket(app, path, handler)
|
|
604
|
+
logger.info(f"Registered WebSocket plugin route: {path} "
|
|
605
|
+
f"(auth={bool(ws_route.get('require_auth', False))})")
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def _already_mounted(app, path, methods=None) -> bool:
|
|
609
|
+
"""Whether the application already answers this address, and these verbs.
|
|
610
|
+
|
|
611
|
+
Asked before a kernel-collected declaration is mounted: the framework's own
|
|
612
|
+
routes and the ones a PluginManager bound are registered first, and a path
|
|
613
|
+
registered twice is an application that answers twice. A socket is asked
|
|
614
|
+
about by path alone -- it has no methods of its own.
|
|
615
|
+
|
|
616
|
+
Args:
|
|
617
|
+
app: the FastAPI application.
|
|
618
|
+
path: the address.
|
|
619
|
+
methods: the verbs, or None for a socket.
|
|
620
|
+
|
|
621
|
+
Returns:
|
|
622
|
+
True when the application already has it.
|
|
623
|
+
"""
|
|
624
|
+
for route in getattr(app, "routes", ()):
|
|
625
|
+
if getattr(route, "path", None) != path:
|
|
626
|
+
continue
|
|
627
|
+
existing = getattr(route, "methods", None)
|
|
628
|
+
if methods is None:
|
|
629
|
+
if not existing:
|
|
630
|
+
return True
|
|
631
|
+
continue
|
|
632
|
+
if tuple(sorted(existing or ())) == tuple(sorted(methods)):
|
|
633
|
+
return True
|
|
634
|
+
return False
|
|
505
635
|
|
|
506
636
|
|
|
507
637
|
def _register_websocket(app, path, handler):
|
|
@@ -47,6 +47,28 @@
|
|
|
47
47
|
--nav-item-bg-active: rgba(6, 182, 212, 0.24);
|
|
48
48
|
--nav-item-icon-gap: 0.75rem;
|
|
49
49
|
|
|
50
|
+
/* The menu's search (keepup-179). A translucent field on a translucent
|
|
51
|
+
sidebar reads as a hole in it, so this theme gives the field a border of
|
|
52
|
+
its own and the focus ring the accent colour of the buttons. */
|
|
53
|
+
--nav-search-bg: rgba(255, 255, 255, 0.05);
|
|
54
|
+
--nav-search-bg-focus: rgba(6, 182, 212, 0.12);
|
|
55
|
+
--nav-search-border: rgba(148, 163, 184, 0.22);
|
|
56
|
+
--nav-search-border-focus: rgba(6, 182, 212, 0.6);
|
|
57
|
+
--nav-search-radius: 10px;
|
|
58
|
+
--nav-search-fg: var(--nebula-text-dim);
|
|
59
|
+
--nav-search-value: #ffffff;
|
|
60
|
+
--nav-search-placeholder: var(--nebula-text-dim);
|
|
61
|
+
--nav-item-search-base: 1.25rem;
|
|
62
|
+
--nav-item-search-step: 1rem;
|
|
63
|
+
|
|
64
|
+
/* The match and the keyboard's row (keepup-181). The mark borrows the
|
|
65
|
+
theme's cyan rather than the default amber: on this sidebar the amber
|
|
66
|
+
reads as a warning, and the cyan is what a control here looks like. */
|
|
67
|
+
--nav-search-hit-bg: rgba(6, 182, 212, 0.9);
|
|
68
|
+
--nav-search-hit-fg: #04121c;
|
|
69
|
+
--nav-search-current-bg: rgba(6, 182, 212, 0.2);
|
|
70
|
+
--nav-search-current-ring: rgba(6, 182, 212, 0.7);
|
|
71
|
+
|
|
50
72
|
--modules-nav-max-height: calc(100vh - 200px);
|
|
51
73
|
--modules-nav-padding: 8px 10px 16px;
|
|
52
74
|
--sidebar-scrollbar-track: rgba(255, 255, 255, 0.04);
|
|
@@ -35,6 +35,29 @@
|
|
|
35
35
|
--nav-item-bg-active: #1e40af;
|
|
36
36
|
--nav-item-icon-gap: 0.75rem;
|
|
37
37
|
|
|
38
|
+
/* The search of the menu (keepup-179). A field sits above the entries and
|
|
39
|
+
a match that is a submodule is pushed right by its depth, so a result
|
|
40
|
+
shown without its parent still reads as a sub-item. */
|
|
41
|
+
--nav-search-bg: rgba(255, 255, 255, 0.12);
|
|
42
|
+
--nav-search-bg-focus: rgba(255, 255, 255, 0.2);
|
|
43
|
+
--nav-search-border: rgba(255, 255, 255, 0.16);
|
|
44
|
+
--nav-search-border-focus: rgba(255, 255, 255, 0.5);
|
|
45
|
+
--nav-search-radius: 10px;
|
|
46
|
+
--nav-search-fg: rgba(255, 255, 255, 0.75);
|
|
47
|
+
--nav-search-value: #ffffff;
|
|
48
|
+
--nav-search-placeholder: rgba(255, 255, 255, 0.6);
|
|
49
|
+
--nav-item-search-base: 1.5rem;
|
|
50
|
+
--nav-item-search-step: 1.1rem;
|
|
51
|
+
|
|
52
|
+
/* The match inside a result, and the row the arrow keys are on
|
|
53
|
+
(keepup-181). The mark is a colour pair of its own so it reads on the
|
|
54
|
+
sidebar; the current row is a ring rather than a fill, because hover has
|
|
55
|
+
already claimed the fill. */
|
|
56
|
+
--nav-search-hit-bg: #fde68a;
|
|
57
|
+
--nav-search-hit-fg: #1e3a8a;
|
|
58
|
+
--nav-search-current-bg: rgba(255, 255, 255, 0.2);
|
|
59
|
+
--nav-search-current-ring: rgba(255, 255, 255, 0.55);
|
|
60
|
+
|
|
38
61
|
/* Adaptive layout. --layout-breakpoint is the single source of the
|
|
39
62
|
narrow/wide boundary: main_new.js reads it off :root, drives a
|
|
40
63
|
matchMedia off it and keeps the .is-narrow class on <html>, and the
|
|
@@ -224,6 +247,148 @@
|
|
|
224
247
|
transition: margin 0.3s ease;
|
|
225
248
|
}
|
|
226
249
|
|
|
250
|
+
/* The field above the menu (keepup-179). Chrome, so it is declared here and a
|
|
251
|
+
section must not restate it. It sits outside #modules-nav-container on
|
|
252
|
+
purpose: the entries scroll under it, the field stays where it was put. */
|
|
253
|
+
.nav-search {
|
|
254
|
+
display: flex;
|
|
255
|
+
align-items: center;
|
|
256
|
+
margin: 0.25rem 0.75rem 0.5rem;
|
|
257
|
+
padding: 0 0.75rem;
|
|
258
|
+
border: 1px solid var(--nav-search-border, rgba(255, 255, 255, 0.16));
|
|
259
|
+
border-radius: var(--nav-search-radius, 10px);
|
|
260
|
+
background-color: var(--nav-search-bg, rgba(255, 255, 255, 0.12));
|
|
261
|
+
transition: var(--chrome-transition, all 0.3s ease);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
.nav-search:focus-within {
|
|
265
|
+
border-color: var(--nav-search-border-focus, rgba(255, 255, 255, 0.5));
|
|
266
|
+
background-color: var(--nav-search-bg-focus, rgba(255, 255, 255, 0.2));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
.nav-search-icon {
|
|
270
|
+
flex: 0 0 auto;
|
|
271
|
+
width: 1rem;
|
|
272
|
+
height: 1rem;
|
|
273
|
+
color: var(--nav-search-fg, rgba(255, 255, 255, 0.75));
|
|
274
|
+
stroke-width: 2;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
.nav-search-input {
|
|
278
|
+
flex: 1 1 auto;
|
|
279
|
+
min-width: 0;
|
|
280
|
+
width: 100%;
|
|
281
|
+
padding: 0.55rem 0.5rem;
|
|
282
|
+
border: 0;
|
|
283
|
+
background: transparent;
|
|
284
|
+
color: var(--nav-search-value, #ffffff);
|
|
285
|
+
font: inherit;
|
|
286
|
+
font-size: 0.875rem;
|
|
287
|
+
outline: none;
|
|
288
|
+
-webkit-appearance: none;
|
|
289
|
+
appearance: none;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
.nav-search-input::placeholder {
|
|
293
|
+
color: var(--nav-search-placeholder, rgba(255, 255, 255, 0.6));
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/* The field carries its own clear button: the browser's would be a second one
|
|
297
|
+
with a different look, and on some engines it cannot be styled at all. */
|
|
298
|
+
.nav-search-input::-webkit-search-cancel-button,
|
|
299
|
+
.nav-search-input::-webkit-search-decoration {
|
|
300
|
+
-webkit-appearance: none;
|
|
301
|
+
appearance: none;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
.nav-search-clear {
|
|
305
|
+
flex: 0 0 auto;
|
|
306
|
+
display: inline-flex;
|
|
307
|
+
align-items: center;
|
|
308
|
+
justify-content: center;
|
|
309
|
+
width: 1.5rem;
|
|
310
|
+
height: 1.5rem;
|
|
311
|
+
padding: 0;
|
|
312
|
+
border: 0;
|
|
313
|
+
border-radius: 9999px;
|
|
314
|
+
background: transparent;
|
|
315
|
+
color: var(--nav-search-fg, rgba(255, 255, 255, 0.75));
|
|
316
|
+
cursor: pointer;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
.nav-search-clear:hover,
|
|
320
|
+
.nav-search-clear:focus-visible {
|
|
321
|
+
color: var(--nav-search-value, #ffffff);
|
|
322
|
+
background-color: var(--nav-search-bg-focus, rgba(255, 255, 255, 0.2));
|
|
323
|
+
outline: none;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
.nav-search-clear[hidden] {
|
|
327
|
+
display: none;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
.nav-search-clear-icon {
|
|
331
|
+
width: 0.85rem;
|
|
332
|
+
height: 0.85rem;
|
|
333
|
+
stroke-width: 2.5;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
.nav-search-empty {
|
|
337
|
+
margin: 0.25rem 1.5rem 0.5rem;
|
|
338
|
+
color: var(--nav-search-placeholder, rgba(255, 255, 255, 0.6));
|
|
339
|
+
font-size: 0.8rem;
|
|
340
|
+
line-height: 1.35;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
.nav-search-empty[hidden] {
|
|
344
|
+
display: none;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/* What the search leaves on screen: an entry that does not match goes, an entry
|
|
348
|
+
the match lives inside stays so the result keeps its path. */
|
|
349
|
+
.nav-item.nav-search-hidden {
|
|
350
|
+
display: none !important;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/* A matched submodule is indented by its depth: shown without its parent it
|
|
354
|
+
would otherwise look like a section of its own. The dot is the second cue, so
|
|
355
|
+
the depth reads even when the two items sit far from the menu's edge. */
|
|
356
|
+
.nav-item.nav-search-match {
|
|
357
|
+
padding-left: calc(
|
|
358
|
+
var(--nav-item-search-base, 1.5rem)
|
|
359
|
+
+ var(--nav-search-depth, 0) * var(--nav-item-search-step, 1.1rem)
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
.nav-item.nav-search-nested::before {
|
|
364
|
+
content: "";
|
|
365
|
+
flex: 0 0 auto;
|
|
366
|
+
width: 4px;
|
|
367
|
+
height: 4px;
|
|
368
|
+
margin-right: 0.5rem;
|
|
369
|
+
border-radius: 9999px;
|
|
370
|
+
background-color: currentColor;
|
|
371
|
+
opacity: 0.55;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/* The substring that matched is marked in place, so the eye lands on it before
|
|
375
|
+
it reads the rest of the label. The mark's colours are the theme's: on a
|
|
376
|
+
saturated sidebar the text alone is not enough of a cue. */
|
|
377
|
+
.nav-search-hit {
|
|
378
|
+
padding: 0 2px;
|
|
379
|
+
border-radius: 3px;
|
|
380
|
+
background-color: var(--nav-search-hit-bg, #fde68a);
|
|
381
|
+
color: var(--nav-search-hit-fg, #1e3a8a);
|
|
382
|
+
font-weight: 600;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/* Where the arrow keys are. It is not hover -- the pointer may be elsewhere --
|
|
386
|
+
so it gets a ring that survives the row's own hover and active colours. */
|
|
387
|
+
.nav-item.nav-search-current {
|
|
388
|
+
background-color: var(--nav-search-current-bg, rgba(255, 255, 255, 0.2));
|
|
389
|
+
box-shadow: inset 0 0 0 1px var(--nav-search-current-ring, rgba(255, 255, 255, 0.55));
|
|
390
|
+
}
|
|
391
|
+
|
|
227
392
|
/* Collapsed sidebar. Declared once, for the whole panel, and only for the wide
|
|
228
393
|
layout: below the breakpoint the panel is a drawer, so a collapsed flag saved
|
|
229
394
|
on a desktop would narrow the drawer and shift the content 64px to the right
|
|
@@ -238,6 +403,14 @@
|
|
|
238
403
|
display: none;
|
|
239
404
|
}
|
|
240
405
|
|
|
406
|
+
/* The field belongs to the wide menu: collapsed, there is no room for it and
|
|
407
|
+
nothing to type into. The script clears the filter on the way down, so no
|
|
408
|
+
section is left hidden behind a field nobody can see. */
|
|
409
|
+
:root:not(.is-narrow) .sidebar-collapsed .nav-search,
|
|
410
|
+
:root:not(.is-narrow) .sidebar-collapsed .nav-search-empty {
|
|
411
|
+
display: none;
|
|
412
|
+
}
|
|
413
|
+
|
|
241
414
|
:root:not(.is-narrow) .sidebar-collapsed .logo-text {
|
|
242
415
|
display: none;
|
|
243
416
|
}
|