2023-02-22 10:12:16 +01:00
|
|
|
from http import HTTPStatus
|
2023-04-05 17:54:54 +02:00
|
|
|
from typing import Any, List, Tuple, Union
|
2023-04-05 16:40:08 +02:00
|
|
|
from urllib.parse import parse_qs
|
2023-02-22 10:12:16 +01:00
|
|
|
|
2023-04-05 16:40:08 +02:00
|
|
|
from fastapi.responses import HTMLResponse, JSONResponse
|
2023-02-22 10:12:16 +01:00
|
|
|
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
|
|
|
2023-04-05 16:40:08 +02:00
|
|
|
from lnbits.helpers import template_renderer
|
2023-02-22 10:12:16 +01:00
|
|
|
from lnbits.settings import settings
|
|
|
|
|
|
|
|
|
|
|
|
class InstalledExtensionMiddleware:
|
|
|
|
# This middleware class intercepts calls made to the extensions API and:
|
|
|
|
# - it blocks the calls if the extension has been disabled or uninstalled.
|
|
|
|
# - it redirects the calls to the latest version of the extension if the extension has been upgraded.
|
|
|
|
# - otherwise it has no effect
|
|
|
|
def __init__(self, app: ASGIApp) -> None:
|
|
|
|
self.app = app
|
|
|
|
|
|
|
|
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
|
|
if "path" not in scope:
|
|
|
|
await self.app(scope, receive, send)
|
|
|
|
return
|
|
|
|
|
|
|
|
path_elements = scope["path"].split("/")
|
|
|
|
if len(path_elements) > 2:
|
|
|
|
_, path_name, path_type, *rest = path_elements
|
|
|
|
else:
|
|
|
|
_, path_name = path_elements
|
|
|
|
path_type = None
|
2023-02-27 16:20:47 +01:00
|
|
|
rest = []
|
2023-02-22 10:12:16 +01:00
|
|
|
|
2023-04-05 17:54:54 +02:00
|
|
|
headers = scope.get("headers", [])
|
|
|
|
|
2023-02-22 10:12:16 +01:00
|
|
|
# block path for all users if the extension is disabled
|
|
|
|
if path_name in settings.lnbits_deactivated_extensions:
|
2023-04-05 17:54:54 +02:00
|
|
|
response = self._response_by_accepted_type(
|
|
|
|
headers, f"Extension '{path_name}' disabled", HTTPStatus.NOT_FOUND
|
2023-02-22 10:12:16 +01:00
|
|
|
)
|
|
|
|
await response(scope, receive, send)
|
|
|
|
return
|
|
|
|
|
2023-04-05 16:40:08 +02:00
|
|
|
if not self._user_allowed_to_extension(path_name, scope):
|
2023-04-05 17:54:54 +02:00
|
|
|
response = self._response_by_accepted_type(
|
|
|
|
headers, "User not authorized.", HTTPStatus.FORBIDDEN
|
2023-02-22 10:12:16 +01:00
|
|
|
)
|
|
|
|
await response(scope, receive, send)
|
|
|
|
return
|
|
|
|
|
|
|
|
# re-route API trafic if the extension has been upgraded
|
|
|
|
if path_type == "api":
|
|
|
|
upgraded_extensions = list(
|
|
|
|
filter(
|
|
|
|
lambda ext: ext.endswith(f"/{path_name}"),
|
|
|
|
settings.lnbits_upgraded_extensions,
|
|
|
|
)
|
|
|
|
)
|
|
|
|
if len(upgraded_extensions) != 0:
|
|
|
|
upgrade_path = upgraded_extensions[0]
|
|
|
|
tail = "/".join(rest)
|
|
|
|
scope["path"] = f"/upgrades/{upgrade_path}/{path_type}/{tail}"
|
|
|
|
|
|
|
|
await self.app(scope, receive, send)
|
|
|
|
|
2023-04-05 16:40:08 +02:00
|
|
|
def _user_allowed_to_extension(self, ext_name: str, scope) -> bool:
|
|
|
|
if ext_name not in settings.lnbits_admin_extensions:
|
|
|
|
return True
|
|
|
|
if "query_string" not in scope:
|
|
|
|
return True
|
|
|
|
|
2023-04-06 11:32:23 +02:00
|
|
|
# parse the URL query string into a `dict`
|
2023-04-05 16:40:08 +02:00
|
|
|
q = parse_qs(scope["query_string"].decode("UTF-8"))
|
2023-04-05 18:03:14 +02:00
|
|
|
user = q.get("usr", [""])[0]
|
2023-04-05 16:40:08 +02:00
|
|
|
if not user:
|
|
|
|
return True
|
|
|
|
|
|
|
|
if user == settings.super_user or user in settings.lnbits_admin_users:
|
|
|
|
return True
|
|
|
|
|
|
|
|
return False
|
|
|
|
|
2023-04-05 17:54:54 +02:00
|
|
|
def _response_by_accepted_type(
|
|
|
|
self, headers: List[Any], msg: str, status_code: HTTPStatus
|
|
|
|
) -> Union[HTMLResponse, JSONResponse]:
|
2023-04-06 11:32:23 +02:00
|
|
|
"""
|
|
|
|
Build an HTTP response containing the `msg` as HTTP body and the `status_code` as HTTP code.
|
|
|
|
If the `accept` HTTP header is present int the request and contains the value of `text/html`
|
|
|
|
then return an `HTMLResponse`, otherwise return an `JSONResponse`.
|
|
|
|
"""
|
2023-04-05 17:54:54 +02:00
|
|
|
accept_header: str = next(
|
|
|
|
(
|
|
|
|
h[1].decode("UTF-8")
|
|
|
|
for h in headers
|
|
|
|
if len(h) >= 2 and h[0].decode("UTF-8") == "accept"
|
|
|
|
),
|
|
|
|
"",
|
|
|
|
)
|
|
|
|
|
|
|
|
if "text/html" in [a for a in accept_header.split(",")]:
|
|
|
|
return HTMLResponse(
|
|
|
|
status_code=status_code,
|
|
|
|
content=template_renderer()
|
|
|
|
.TemplateResponse("error.html", {"request": {}, "err": msg})
|
|
|
|
.body,
|
|
|
|
)
|
|
|
|
|
|
|
|
return JSONResponse(
|
|
|
|
status_code=status_code,
|
|
|
|
content={"detail": msg},
|
|
|
|
)
|
|
|
|
|
2023-02-22 10:12:16 +01:00
|
|
|
|
|
|
|
class ExtensionsRedirectMiddleware:
|
|
|
|
# Extensions are allowed to specify redirect paths.
|
|
|
|
# A call to a path outside the scope of the extension can be redirected to one of the extension's endpoints.
|
|
|
|
# Eg: redirect `GET /.well-known` to `GET /lnurlp/api/v1/well-known`
|
|
|
|
|
|
|
|
def __init__(self, app: ASGIApp) -> None:
|
|
|
|
self.app = app
|
|
|
|
|
|
|
|
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
|
|
if "path" not in scope:
|
|
|
|
await self.app(scope, receive, send)
|
|
|
|
return
|
|
|
|
|
|
|
|
req_headers = scope["headers"] if "headers" in scope else []
|
|
|
|
redirect = self._find_redirect(scope["path"], req_headers)
|
|
|
|
if redirect:
|
|
|
|
scope["path"] = self._new_path(redirect, scope["path"])
|
|
|
|
|
|
|
|
await self.app(scope, receive, send)
|
|
|
|
|
|
|
|
def _find_redirect(self, path: str, req_headers: List[Tuple[bytes, bytes]]):
|
|
|
|
return next(
|
|
|
|
(
|
|
|
|
r
|
|
|
|
for r in settings.lnbits_extensions_redirects
|
|
|
|
if self._redirect_matches(r, path, req_headers)
|
|
|
|
),
|
|
|
|
None,
|
|
|
|
)
|
|
|
|
|
|
|
|
def _redirect_matches(
|
|
|
|
self, redirect: dict, path: str, req_headers: List[Tuple[bytes, bytes]]
|
|
|
|
) -> bool:
|
|
|
|
if "from_path" not in redirect:
|
|
|
|
return False
|
|
|
|
header_filters = (
|
2023-02-27 16:20:47 +01:00
|
|
|
redirect["header_filters"] if "header_filters" in redirect else {}
|
2023-02-22 10:12:16 +01:00
|
|
|
)
|
|
|
|
return self._has_common_path(redirect["from_path"], path) and self._has_headers(
|
|
|
|
header_filters, req_headers
|
|
|
|
)
|
|
|
|
|
|
|
|
def _has_headers(
|
|
|
|
self, filter_headers: dict, req_headers: List[Tuple[bytes, bytes]]
|
|
|
|
) -> bool:
|
|
|
|
for h in filter_headers:
|
|
|
|
if not self._has_header(req_headers, (str(h), str(filter_headers[h]))):
|
|
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
|
|
def _has_header(
|
|
|
|
self, req_headers: List[Tuple[bytes, bytes]], header: Tuple[str, str]
|
|
|
|
) -> bool:
|
|
|
|
for h in req_headers:
|
|
|
|
if (
|
|
|
|
h[0].decode().lower() == header[0].lower()
|
|
|
|
and h[1].decode() == header[1]
|
|
|
|
):
|
|
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
|
|
def _has_common_path(self, redirect_path: str, req_path: str) -> bool:
|
|
|
|
redirect_path_elements = redirect_path.split("/")
|
|
|
|
req_path_elements = req_path.split("/")
|
|
|
|
if len(redirect_path) > len(req_path):
|
|
|
|
return False
|
|
|
|
sub_path = req_path_elements[: len(redirect_path_elements)]
|
|
|
|
return redirect_path == "/".join(sub_path)
|
|
|
|
|
|
|
|
def _new_path(self, redirect: dict, req_path: str) -> str:
|
|
|
|
from_path = redirect["from_path"].split("/")
|
|
|
|
redirect_to = redirect["redirect_to_path"].split("/")
|
|
|
|
req_tail_path = req_path.split("/")[len(from_path) :]
|
|
|
|
|
|
|
|
elements = [
|
|
|
|
e for e in ([redirect["ext_id"]] + redirect_to + req_tail_path) if e != ""
|
|
|
|
]
|
|
|
|
|
|
|
|
return "/" + "/".join(elements)
|