keepup-http 0.5.0__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.
Files changed (43) hide show
  1. keepup_http/__init__.py +38 -0
  2. keepup_http/api_docs.py +76 -0
  3. keepup_http/body_limit.py +156 -0
  4. keepup_http/events_api.py +237 -0
  5. keepup_http/factory.py +562 -0
  6. keepup_http/lock_routes.py +155 -0
  7. keepup_http/plugin.py +53 -0
  8. keepup_http/routes.py +549 -0
  9. keepup_http/static/css/aos.css +1 -0
  10. keepup_http/static/css/main_nebula.css +232 -0
  11. keepup_http/static/css/main_new.css +869 -0
  12. keepup_http/static/css/tailwind.css +1 -0
  13. keepup_http/static/index_nebula.html +282 -0
  14. keepup_http/static/index_new.html +275 -0
  15. keepup_http/static/js/aos.js +1 -0
  16. keepup_http/static/js/feather-icons.js +13 -0
  17. keepup_http/static/js/layout_bootstrap.js +29 -0
  18. keepup_http/static/js/main_new.js +2076 -0
  19. keepup_http/static/js/tailwind.js +83 -0
  20. keepup_http/static/modules/css/background_tasks.css +233 -0
  21. keepup_http/static/modules/css/cluster.css +16 -0
  22. keepup_http/static/modules/css/event_manager.css +386 -0
  23. keepup_http/static/modules/css/integration_logs.css +33 -0
  24. keepup_http/static/modules/css/metrics.css +115 -0
  25. keepup_http/static/modules/css/modules.css +189 -0
  26. keepup_http/static/modules/css/themes.css +563 -0
  27. keepup_http/static/modules/css/users.css +278 -0
  28. keepup_http/static/modules/js/background_tasks.js +695 -0
  29. keepup_http/static/modules/js/chart.js +14 -0
  30. keepup_http/static/modules/js/chartjs-adapter-date-fns.bundle.min.js +7 -0
  31. keepup_http/static/modules/js/cluster.js +374 -0
  32. keepup_http/static/modules/js/event_manager.js +1007 -0
  33. keepup_http/static/modules/js/integration_logs.js +795 -0
  34. keepup_http/static/modules/js/metrics.js +929 -0
  35. keepup_http/static/modules/js/modules.js +1128 -0
  36. keepup_http/static/modules/js/themes.js +684 -0
  37. keepup_http/static/modules/js/users.js +898 -0
  38. keepup_http/web.py +214 -0
  39. keepup_http-0.5.0.dist-info/METADATA +29 -0
  40. keepup_http-0.5.0.dist-info/RECORD +43 -0
  41. keepup_http-0.5.0.dist-info/WHEEL +5 -0
  42. keepup_http-0.5.0.dist-info/entry_points.txt +2 -0
  43. keepup_http-0.5.0.dist-info/top_level.txt +1 -0
keepup_http/factory.py ADDED
@@ -0,0 +1,562 @@
1
+ """Building the application.
2
+
3
+ Everything that used to happen while ``app/main.py`` was being imported happens
4
+ inside ``create_app`` instead: the application is created, its static
5
+ directories mounted, its middleware added and its own endpoints registered.
6
+ That is what makes a second application on this framework possible, and it is
7
+ also what makes the first one testable -- a test can build one with its own
8
+ settings instead of importing a module that builds it as a side effect.
9
+
10
+ The order of the start-up is not free, and two steps in it are the reason this
11
+ file exists rather than a list of calls in each application:
12
+
13
+ * the signing key is demanded before anything else, so a deployment that would
14
+ accept tokens anyone could sign never starts;
15
+ * ``on_startup`` runs before the plugins, because a plugin subscribes to the
16
+ application's bus while initialising and a bus started afterwards would have
17
+ no subscribers.
18
+ """
19
+
20
+ import asyncio
21
+ import logging
22
+ import os
23
+ from contextlib import asynccontextmanager
24
+
25
+ from fastapi import FastAPI
26
+ from fastapi.middleware.cors import CORSMiddleware
27
+ from fastapi.staticfiles import StaticFiles
28
+
29
+ from keepup_audit import audit
30
+ from keepup import notification_bus
31
+ from keepup_cluster import cluster
32
+ from keepup_http import web
33
+ from keepup_modules import catalogue as modules
34
+ from keepup_http.api_docs import register_api_documentation
35
+ from keepup.api_versions import ApiVersionMiddleware
36
+ from keepup_audit.audit import init_incoming_requests_table
37
+ from keepup_auth import dependencies as auth_dependencies
38
+ from keepup_auth import routes as auth_routes
39
+ from keepup_auth import panel_session, socket_sessions
40
+ from keepup_auth.oidc_routes import register_oidc_routes
41
+ from keepup_auth.identity import runtime as identity_runtime
42
+ from keepup_audit.events import init_event_manager
43
+ from keepup_http.events_api import register_event_api_routes
44
+ from keepup.instance import get_instance_id
45
+ from keepup_http.lock_routes import register_lock_routes
46
+ from keepup_metrics.api import register_metrics_routes
47
+ from keepup_modules.catalogue import register_module_routes
48
+ from keepup.plugins.registry import (
49
+ initialize_plugins,
50
+ register_plugin_admin_routes,
51
+ run_post_construct_processors,
52
+ )
53
+ from keepup_tasks.scheduler import register_scheduler_routes
54
+ from keepup.settings import KeepupSettings
55
+ from keepup_ui.themes import config_service, register_theme_routes
56
+ from keepup_http.web import register_web_routes
57
+
58
+ #: What an application may import from this module. Everything else is
59
+ #: internal and may change without notice -- see doc/keepup.md.
60
+ __all__ = [
61
+ "create_app",
62
+ "require_signing_key",
63
+ ]
64
+
65
+ logger = logging.getLogger(__name__)
66
+
67
+
68
+ def require_signing_key() -> None:
69
+ """Stop the start-up when the token signing key is not configured.
70
+
71
+ Here rather than at import time: importing a module to generate the API
72
+ documentation, or to reach one of its utilities, is not a deployment and
73
+ has no business demanding a production secret. Starting the server is, and
74
+ a server that came up with the placeholder key from the setup examples
75
+ would accept a token anybody could sign for any account.
76
+ """
77
+ from keepup_auth.signing_key import SigningKeyUnavailable, signing_key_problem
78
+
79
+ problem = signing_key_problem()
80
+ if problem:
81
+ logger.critical(problem)
82
+ raise SigningKeyUnavailable(problem)
83
+
84
+
85
+ class SecurityHeadersMiddleware:
86
+ """The response headers a browser needs in order to refuse things.
87
+
88
+ The panel is an ordinary page, so without X-Frame-Options anybody can frame
89
+ it and read a click as the administrator's. The framework carried none of
90
+ these headers at all, which for a package installed on a public address is
91
+ a default rather than an omission.
92
+
93
+ The Content-Security-Policy arrives here already written -- the framework's
94
+ own or the application's (``KeepupSettings.content_security_policy``,
95
+ keepup-93) -- and is sent as a policy or, with ``csp_report_only``, as a
96
+ report the browser does not act on. A response that carries a policy of its
97
+ own keeps it, like every other header here.
98
+
99
+ Plain ASGI: it only adds headers to the start of a response. Written on
100
+ BaseHTTPMiddleware it ran every request through a task and a body stream
101
+ of its own, the most expensive kind of middleware Starlette has (keepup-88).
102
+ A header the response already carries is left as it is.
103
+ """
104
+
105
+ def __init__(self, app, https=False, csp=None, csp_report_only=False):
106
+ self.app = app
107
+ headers = [(b"x-frame-options", b"SAMEORIGIN"),
108
+ (b"x-content-type-options", b"nosniff"),
109
+ (b"referrer-policy", b"same-origin")]
110
+ if https:
111
+ # Only over TLS: sent over plain HTTP it would be ignored, and a
112
+ # deployment on a local network without TLS must stay reachable.
113
+ headers.append((b"strict-transport-security", b"max-age=31536000"))
114
+ if csp:
115
+ name = (b"content-security-policy-report-only" if csp_report_only
116
+ else b"content-security-policy")
117
+ # Headers travel as latin-1 in ASGI; a policy is written in ASCII.
118
+ headers.append((name, csp.encode("latin-1", "replace")))
119
+ self.headers = headers
120
+
121
+ async def __call__(self, scope, receive, send):
122
+ if scope["type"] != "http":
123
+ await self.app(scope, receive, send)
124
+ return
125
+
126
+ async def send_with_headers(message):
127
+ if message["type"] == "http.response.start":
128
+ present = {name.lower() for name, _ in message.get("headers", [])}
129
+ missing = [(name, value) for name, value in self.headers if name not in present]
130
+ if missing:
131
+ message = {**message, "headers": list(message.get("headers", [])) + missing}
132
+ await send(message)
133
+
134
+ await self.app(scope, receive, send_with_headers)
135
+
136
+
137
+ def apply_performance(performance) -> None:
138
+ """The pool from ``KeepupSettings.performance`` (keepup-38).
139
+
140
+ The pool may already exist: applications touch the database before
141
+ create_app (their init_db). It is closed then, and the next query opens one
142
+ with the application's values -- safe at start-up, before any request.
143
+ """
144
+ from keepup_db import DatabaseManagerV2, db_config
145
+ if db_config.apply_pool(performance.db_pool_size, performance.db_pool_max_overflow,
146
+ performance.db_pool_timeout, performance.db_pool_recycle,
147
+ performance.db_pool_ping_after_idle):
148
+ DatabaseManagerV2.dispose()
149
+ from keepup import cache
150
+ cache.set_ttl(performance.catalogue_cache_seconds)
151
+
152
+
153
+ def apply_settings(settings: KeepupSettings) -> None:
154
+ """Hand each part of the framework the application's values.
155
+
156
+ Done in one place so that what the application supplies is visible as a
157
+ list, rather than as a configure() call hidden in each module's import.
158
+ """
159
+ performance = settings.performance
160
+ audit.configure(redaction=settings.audit_redaction,
161
+ flush_interval=performance.audit_flush_interval,
162
+ max_size=performance.audit_buffer_size)
163
+ apply_performance(performance)
164
+ web.configure(
165
+ static_dir=settings.static_dir,
166
+ client_page=settings.client_page,
167
+ version_file=settings.version_file,
168
+ favicon_file=settings.favicon_file,
169
+ )
170
+ auth_dependencies.configure_panel_gate(pending=settings.pending_documents)
171
+ if not settings.database_dialect:
172
+ # The framework's configuration is the default; naming the dialect lets
173
+ # the kernel choose a driver without importing a database (keepup-123).
174
+ from keepup_db import db_config as _db_config
175
+ settings.database_dialect = "postgresql" if _db_config.is_postgres() else "sqlite"
176
+ # Who is calling and what they may do, behind the two names the kernel owns
177
+ # (keepup-119). Until keepup-auth is a plugin of its own this is the
178
+ # framework's own sign-in; afterwards it is that plugin's register().
179
+ from keepup_auth.identity import access as identity_access
180
+ from keepup.kernel import observability, security
181
+ # What is written down about a call, behind the two names the kernel owns
182
+ # (keepup-111). Until keepup-audit is a plugin this is the framework's own,
183
+ # and it is built here rather than imported from the plugin file: the
184
+ # framework's own plugins are data of the package, not a module of it.
185
+ from keepup_audit.audit import IncomingRequestLogger, log_api_request
186
+ observability.set_recording(observability.Recording(
187
+ start=log_api_request, end=IncomingRequestLogger.end_request, name="keepup audit"))
188
+ security.set_identity(security.Identity(
189
+ subject_dependency=auth_dependencies.get_panel_user,
190
+ checker=identity_access.check,
191
+ name="keepup sign-in",
192
+ ))
193
+ auth_routes.configure(
194
+ password=settings.password_rule,
195
+ login_record=settings.record_login,
196
+ )
197
+ auth_routes.configure_public_config(settings.public_config)
198
+ # The panel's plugin report reads the deployment's own catalogue, wherever
199
+ # the application keeps it -- not the default path (keepup-93).
200
+ try:
201
+ from keepup_modules import decisions as plugins_admin
202
+
203
+ plugins_admin.configure(settings.plugins_config_path)
204
+ except ImportError:
205
+ # The decision about a plugin is the modules capability's (keepup-113);
206
+ # without it the declared file and the environment decide.
207
+ logger.info("keepup-modules is not installed: no plugin overrides to read")
208
+ # The catalogue of panel sections and their grants is seeded from the same
209
+ # file, which until keepup-98 was only the default one.
210
+ modules.configure(settings.plugins_config_path)
211
+ # Themes are declared here and not when the themes module is imported:
212
+ # there is no application at that point and nothing for it to declare --
213
+ # which is exactly why a declared theme never reached the database. The
214
+ # service brings itself up on this call.
215
+ config_service.register_built_in_themes(settings.built_in_themes)
216
+ config_service.initialize()
217
+
218
+
219
+ class GatedStaticFiles(StaticFiles):
220
+ """Static files the browser rechecks on every load, some of them gated.
221
+
222
+ Starlette sends ETag and Last-Modified but no Cache-Control. Without one a
223
+ browser falls back to heuristic freshness -- roughly a tenth of the file's
224
+ age -- and stops asking the server at all, so an edited module keeps
225
+ serving its old copy until a hard reload. `no-cache` still permits caching;
226
+ it only requires revalidation, which the ETag answers with an empty 304.
227
+
228
+ A gated page is served only while the plugin behind it runs: a page that
229
+ says a payment succeeded, reachable by its address on a deployment with no
230
+ payments, says something untrue.
231
+ """
232
+
233
+ def __init__(self, *args, gated_pages=None, plugin_manager=None, **kwargs):
234
+ super().__init__(*args, **kwargs)
235
+ self.gated_pages = gated_pages or {}
236
+ self.plugin_manager = plugin_manager
237
+
238
+ def file_response(self, *args, **kwargs):
239
+ response = super().file_response(*args, **kwargs)
240
+ response.headers.setdefault("Cache-Control", "no-cache")
241
+ return response
242
+
243
+ async def get_response(self, path, scope):
244
+ plugin_id = self.gated_pages.get(str(path).lstrip("/"))
245
+ if plugin_id and not self._plugin_running(plugin_id):
246
+ from starlette.exceptions import HTTPException as StarletteHTTPException
247
+ raise StarletteHTTPException(status_code=404)
248
+ return await super().get_response(path, scope)
249
+
250
+ def _plugin_running(self, plugin_id: str) -> bool:
251
+ plugin = self.plugin_manager.get_plugin(plugin_id) if self.plugin_manager else None
252
+ return bool(plugin and getattr(plugin, "initialized", False))
253
+
254
+
255
+ def _build_lifespan(settings: KeepupSettings):
256
+ """The start-up and shutdown of one application built from these settings."""
257
+
258
+ manager = settings.plugin_manager
259
+
260
+ @asynccontextmanager
261
+ async def lifespan(app: FastAPI):
262
+ require_signing_key()
263
+
264
+ # The plugin catalogue, raised beside the path this application already
265
+ # has (keepup-123). Everything the runtime does is additive: it resolves
266
+ # the catalogue and the profile, publishes the services a capability
267
+ # offers and creates the tables a capability declares. A deployment whose
268
+ # catalogue cannot start -- a required plugin missing, two providers of
269
+ # one service -- is reported and the application boots anyway, because
270
+ # 0.3.0 deployments reach this code with a catalogue that never existed.
271
+ from keepup.kernel import create_runtime
272
+
273
+ kernel = create_runtime(settings)
274
+ try:
275
+ await kernel.start()
276
+ except Exception as error: # noqa: BLE001 - a catalogue is not the application
277
+ logger.warning("The plugin catalogue did not start: %s", error)
278
+ kernel = None
279
+ app.state.kernel = kernel
280
+
281
+ # Shipping is loaded only for an application that names a collector
282
+ # (keepup/log_shipping.py, keepup-24).
283
+ # Table creation is a round of statements against the database; off the
284
+ # loop, where the tasks started above already run (keepup-54).
285
+ await asyncio.to_thread(init_incoming_requests_table)
286
+
287
+ # The catalogue the application named reaches the panel here as well as
288
+ # The catalogue is imported by the capability that owns it, on the
289
+ # freeze (keepup-113): it is the union of the framework's own sections,
290
+ # the application's file and what the running plugins declare, and the
291
+ # last of those is only known once every plugin has been loaded.
292
+ # The four loops the framework used to start here -- the audit buffer,
293
+ # the audit sweep, the event log's sweep and the socket sweep -- belong
294
+ # to the capabilities that own them and start with their plugins
295
+ # (keepup-109). The kernel starts none of them.
296
+ event_manager = await asyncio.to_thread(init_event_manager)
297
+ if event_manager:
298
+ logger.info("Event manager started successfully")
299
+ else:
300
+ logger.warning("Event manager failed to start")
301
+
302
+ register_event_api_routes(app, declared_event_types=settings.declared_event_types)
303
+
304
+ # Before on_startup and the plugins: both subscribe to the bus while
305
+ # initialising, and a bus created after them would have no subscribers.
306
+ bus = await _create_notification_bus(settings.notification_channel)
307
+
308
+ # Before the plugins: a plugin subscribes to the application's bus
309
+ # while initialising, and a bus created after it would have no
310
+ # subscribers.
311
+ if settings.on_startup is not None:
312
+ await settings.on_startup(app)
313
+
314
+ if manager is not None:
315
+ await initialize_plugins(app, manager, config_path=settings.plugins_config_path)
316
+
317
+ # The log handlers the running capabilities contribute -- the shipping
318
+ # one, when a collector is named (keepup-118). A deployment that
319
+ # installs none attaches nothing.
320
+ attached_handlers = _attach_log_handlers(kernel)
321
+
322
+ # The delivery between replicas is the database driver's (keepup-117):
323
+ # the bus existed before the plugins, and takes its transport once they
324
+ # have all registered the services they publish.
325
+ await _start_notification_bus(bus, kernel)
326
+
327
+ # The scheduler belongs to the tasks capability (keepup-109) and is
328
+ # running by the time this line is reached: it starts with its plugin
329
+ # (priority 80), before the cluster (85), which is the order a replica
330
+ # an administrator stopped needs -- it comes back stopped, and stopping
331
+ # pauses the scheduler.
332
+ # This replica's controller is the cluster capability's now (keepup-117):
333
+ # it is built and launched with that plugin, which starts after this
334
+ # line because it only wants the scheduler (80 against 85).
335
+ if manager is not None:
336
+ asyncio.create_task(run_post_construct_processors(manager))
337
+
338
+ # Whatever start-up read into the theme and section caches, it may have
339
+ # changed since: an application's one-off changesets run after the
340
+ # framework's schema and can switch the active theme or take a section
341
+ # from a role. The first request reads afresh.
342
+ from keepup import cache
343
+ cache.invalidate_all()
344
+
345
+ yield
346
+
347
+ _detach_log_handlers(attached_handlers)
348
+
349
+ if kernel is not None:
350
+ # The services a capability published are closed with the process
351
+ # that published them (keepup-106).
352
+ await kernel.stop()
353
+
354
+ if manager is not None:
355
+ await manager.cleanup_all()
356
+
357
+ if settings.on_shutdown is not None:
358
+ await settings.on_shutdown(app)
359
+
360
+ await identity_runtime.shutdown()
361
+
362
+ await _stop_notification_bus(bus)
363
+
364
+ return lifespan
365
+
366
+
367
+ def _attach_log_handlers(kernel):
368
+ """Attach what the running capabilities contribute to the application's log.
369
+
370
+ The contribution is the contract (keepup-118): the capability builds the
371
+ handler and says so, and the application -- which owns the root logger --
372
+ attaches it. Until `keepup-http` mounts contributions itself this is the
373
+ bridge, and it has the same shape as the routes one (keepup-121).
374
+ """
375
+ contributions = getattr(kernel, "contributions", None)
376
+ handlers = list(contributions.of("logging")) if contributions is not None else []
377
+ root = logging.getLogger()
378
+ for handler in handlers:
379
+ root.addHandler(handler)
380
+ if handlers:
381
+ logging.getLogger(__name__).info("Attached %s contributed log handler(s)",
382
+ len(handlers))
383
+ return handlers
384
+
385
+
386
+ def _detach_log_handlers(handlers):
387
+ """Take them off again: a second application in one process must not double-log."""
388
+ root = logging.getLogger()
389
+ for handler in handlers or []:
390
+ root.removeHandler(handler)
391
+
392
+
393
+ async def _create_notification_bus(channel):
394
+ """Install the replicas' bus on the application's channel, if it named one.
395
+
396
+ Created before the plugins and started after them: a plugin subscribes to the
397
+ bus while it initialises, and the transport that carries an envelope to the
398
+ other replicas is a service only the drivers publish (keepup-117).
399
+ """
400
+ if not channel:
401
+ return None
402
+ bus = notification_bus.NotificationBus(get_instance_id(), channel)
403
+ notification_bus.set_notification_bus(bus)
404
+ # The other replicas' changes to the theme and the section catalogue.
405
+ from keepup import cache
406
+ cache.attach_to_bus(bus)
407
+ socket_sessions.attach_to_bus(bus)
408
+ return bus
409
+
410
+
411
+ async def _start_notification_bus(bus, kernel=None):
412
+ """Give the bus the delivery its driver published, and start listening."""
413
+ if bus is None:
414
+ return None
415
+ services = getattr(kernel, "services", None)
416
+ if services is not None and services.has(notification_bus.SERVICE_NOTIFY_TRANSPORT):
417
+ bus.set_transport(services.require(notification_bus.SERVICE_NOTIFY_TRANSPORT))
418
+ await bus.start()
419
+ return bus
420
+
421
+
422
+ async def _stop_notification_bus(bus):
423
+ if bus is None:
424
+ return
425
+ await bus.stop()
426
+ socket_sessions.detach_from_bus()
427
+ if notification_bus.get_notification_bus() is bus:
428
+ notification_bus.set_notification_bus(None)
429
+
430
+
431
+
432
+
433
+
434
+
435
+
436
+
437
+ def create_app(settings: KeepupSettings = None) -> FastAPI:
438
+ """Build an application from these settings.
439
+
440
+ The plugin manager, when the settings carry one, is left on
441
+ ``app.state.plugin_manager``: the entry point re-exports it, because that
442
+ is the object plugins reach for.
443
+ """
444
+ settings = settings or KeepupSettings()
445
+ apply_settings(settings)
446
+ # Before any route exists: a provider that cannot be built stops the start
447
+ # here, and one that can is what the routes below reach (keepup-91).
448
+ identity_runtime.configure(settings.identity_provider)
449
+
450
+ # /docs belongs to the public gateway reference, so the interactive API docs
451
+ # live under /api. FastAPI's own schema and pages are switched off: the
452
+ # schema describes every route and FastAPI would hand it to anybody. The
453
+ # framework registers its own right here, ahead of any other route, which is
454
+ # the place FastAPI's constructor gave them (keepup/api_docs.py, keepup-96).
455
+ app = FastAPI(
456
+ title=settings.title,
457
+ lifespan=_build_lifespan(settings),
458
+ docs_url=None,
459
+ redoc_url=None,
460
+ openapi_url=None,
461
+ )
462
+ register_api_documentation(app, settings)
463
+ app.state.settings = settings
464
+ app.state.plugin_manager = settings.plugin_manager
465
+
466
+ for mount in settings.static_mounts:
467
+ app.mount(
468
+ mount.path,
469
+ GatedStaticFiles(
470
+ directory=mount.directory,
471
+ gated_pages=settings.gated_pages,
472
+ plugin_manager=settings.plugin_manager,
473
+ ),
474
+ name=mount.name,
475
+ )
476
+
477
+ # The package's own shell, always mounted and never from the working
478
+ # directory: an installed keepup renders its panel out of its own files,
479
+ # and an application that ships none still gets one (keepup-3).
480
+ app.mount(
481
+ settings.shell_mount,
482
+ GatedStaticFiles(
483
+ directory=web.SHELL_DIR,
484
+ gated_pages={},
485
+ plugin_manager=None,
486
+ ),
487
+ name="keepup-shell",
488
+ )
489
+
490
+ # Empty unless the application named origins: see KeepupSettings.
491
+ app.add_middleware(
492
+ CORSMiddleware,
493
+ allow_origins=list(settings.cors_origins),
494
+ # The panel session is a cookie now. With credentials allowed, Starlette
495
+ # answers a request carrying cookies with that request's own origin, and
496
+ # any site could read the panel's answers in the user's name.
497
+ allow_credentials=False,
498
+ allow_methods=["*"],
499
+ allow_headers=["*"],
500
+ )
501
+
502
+ if settings.security_headers:
503
+ app.add_middleware(SecurityHeadersMiddleware,
504
+ https=os.getenv("SSL_ENABLED", "false").lower() == "true",
505
+ csp=settings.content_security_policy,
506
+ csp_report_only=settings.csp_report_only)
507
+
508
+ # A panel signed in before the CSRF value was bound to the session gets the
509
+ # bound one on its next read (keepup/auth/panel_session.py, keepup-72).
510
+ app.add_middleware(panel_session.CsrfCookieRefresh)
511
+
512
+ # A replica an administrator stopped refuses the API (keepup/cluster.py).
513
+ # Added before the version middleware, so it runs inside it and sees the
514
+ # registered path.
515
+ app.add_middleware(cluster.StoppedReplicaGate)
516
+
517
+ # The largest body a route accepts, checked before the route reads it
518
+ # (keepup/body_limit.py). Inside the version middleware, so it matches the
519
+ # registered path; it finds the route itself, before routing, since a body
520
+ # read by the route is a body already received.
521
+ from keepup_http.body_limit import BodyLimitMiddleware
522
+ app.add_middleware(BodyLimitMiddleware, router_of=app,
523
+ default_limit=(settings.max_upload_bytes
524
+ if settings.max_upload_bytes is not None
525
+ else settings.max_json_bytes))
526
+
527
+ # Versioned paths (/api/v1, /ws/v1) reach the same handlers; added last, so it
528
+ # runs first and every middleware and route below sees the registered path.
529
+ app.add_middleware(ApiVersionMiddleware)
530
+
531
+ if os.getenv('SSL_ENABLED', 'false').lower() == 'true' \
532
+ and os.getenv('FORCE_HTTPS', 'false').lower() == 'true':
533
+ from fastapi.middleware.httpsredirect import HTTPSRedirectMiddleware
534
+
535
+ app.add_middleware(HTTPSRedirectMiddleware)
536
+ logger.info("HTTPS redirect middleware enabled - HTTP requests will be redirected to HTTPS")
537
+
538
+ register_web_routes(app)
539
+ register_module_routes(app)
540
+ # Registered only when the application configured a provider; without one
541
+ # the paths do not exist at all.
542
+ register_oidc_routes(app, settings)
543
+ # The transport that mounts what a capability declares is keepup-http, and
544
+ # until it exists the application registers this capability's routes itself
545
+ # -- the same bridge the other capabilities still travel (keepup-121).
546
+ register_metrics_routes(app, public=settings.metrics_public)
547
+ register_lock_routes(app)
548
+ register_scheduler_routes(app)
549
+ cluster.register_cluster_routes(app)
550
+ register_theme_routes(app, config_service)
551
+ # Sign-in is registered whatever the application: one without plugins used
552
+ # to get a panel nobody could sign into (keepup-89). What needs plugins --
553
+ # telling them an account was blocked -- has none to tell then.
554
+ auth_routes.register_auth_routes(app, settings.plugin_manager)
555
+ if settings.plugin_manager is not None:
556
+ try:
557
+ register_plugin_admin_routes(app, settings.plugin_manager)
558
+ except ImportError:
559
+ # The plugin report belongs to the modules capability (keepup-113).
560
+ logger.info("keepup-modules is not installed: no plugin report in the panel")
561
+
562
+ return app