diff --git a/lnbits/app.py b/lnbits/app.py index 629d793a..3e0cdd5c 100644 --- a/lnbits/app.py +++ b/lnbits/app.py @@ -283,14 +283,14 @@ async def build_all_installed_extensions_list( MUST be installed by default (see LNBITS_EXTENSIONS_DEFAULT_INSTALL). """ installed_extensions = await get_installed_extensions() - settings.lnbits_all_extensions_ids = {e.id for e in installed_extensions} + settings.lnbits_installed_extensions_ids = {e.id for e in installed_extensions} for ext_dir in Path(settings.lnbits_extensions_path, "extensions").iterdir(): try: if not ext_dir.is_dir(): continue ext_id = ext_dir.name - if ext_id in settings.lnbits_all_extensions_ids: + if ext_id in settings.lnbits_installed_extensions_ids: continue ext_info = InstallableExtension.from_ext_dir(ext_id) if not ext_info: @@ -305,7 +305,7 @@ async def build_all_installed_extensions_list( logger.warning(e) for ext_id in settings.lnbits_extensions_default_install: - if ext_id in settings.lnbits_all_extensions_ids: + if ext_id in settings.lnbits_installed_extensions_ids: continue ext_releases = await InstallableExtension.get_extension_releases(ext_id) diff --git a/lnbits/core/models/extensions.py b/lnbits/core/models/extensions.py index f53daa47..223b2184 100644 --- a/lnbits/core/models/extensions.py +++ b/lnbits/core/models/extensions.py @@ -586,7 +586,6 @@ class InstallableExtension(BaseModel): cls, ) -> list[InstallableExtension]: extension_list: list[InstallableExtension] = [] - extension_id_list: list[str] = [] for url in settings.lnbits_extensions_manifests: try: @@ -607,7 +606,6 @@ class InstallableExtension(BaseModel): meta.featured = ext.id in manifest.featured ext.meta = meta extension_list += [ext] - extension_id_list += [ext.id] for e in manifest.extensions: release = ExtensionRelease.from_explicit_release(url, e) @@ -623,10 +621,10 @@ class InstallableExtension(BaseModel): meta.featured = ext.id in manifest.featured ext.meta = meta extension_list += [ext] - extension_id_list += [e.id] except Exception as e: logger.warning(f"Manifest {url} failed with '{e!s}'") + settings.lnbits_all_extensions_ids = {e.id for e in extension_list} return extension_list @classmethod diff --git a/lnbits/core/tasks.py b/lnbits/core/tasks.py index e285ac9b..170e3d08 100644 --- a/lnbits/core/tasks.py +++ b/lnbits/core/tasks.py @@ -16,6 +16,7 @@ from lnbits.core.crud.payments import get_payments_status_count from lnbits.core.crud.users import get_accounts from lnbits.core.crud.wallets import get_wallets_count from lnbits.core.models import AuditEntry, Payment +from lnbits.core.models.extensions import InstallableExtension from lnbits.core.models.notifications import NotificationType from lnbits.core.services import ( send_payment_notification, @@ -62,6 +63,13 @@ async def run_by_the_minute_tasks(): except Exception as ex: logger.error(ex) + if minute_counter % 60 == 0: + try: + # initialize the list of all extensions + await InstallableExtension.get_installable_extensions() + except Exception as ex: + logger.error(ex) + minute_counter += 1 await asyncio.sleep(60) diff --git a/lnbits/core/views/generic.py b/lnbits/core/views/generic.py index 55d38e65..4f79930b 100644 --- a/lnbits/core/views/generic.py +++ b/lnbits/core/views/generic.py @@ -50,12 +50,9 @@ async def home(request: Request, lightning: str = ""): @generic_router.get("/first_install", response_class=HTMLResponse) async def first_install(request: Request): if not settings.first_install: - return template_renderer().TemplateResponse( - request, - "error.html", - { - "err": "Super user account has already been configured.", - }, + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Super user account has already been configured.", ) return template_renderer().TemplateResponse( request, @@ -180,8 +177,9 @@ async def wallet( wallet = user.wallets[0] if not wallet or wallet.deleted: - return template_renderer().TemplateResponse( - request, "error.html", {"err": "Wallet not found"}, HTTPStatus.NOT_FOUND + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Wallet not found", ) context = { "user": user.json(), diff --git a/lnbits/decorators.py b/lnbits/decorators.py index e9bb211f..4a36ac5a 100644 --- a/lnbits/decorators.py +++ b/lnbits/decorators.py @@ -28,6 +28,7 @@ from lnbits.core.models import ( WalletTypeInfo, ) from lnbits.db import Connection, Filter, Filters, TFilterModel +from lnbits.helpers import path_segments from lnbits.settings import AuthMethods, settings oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/v1/auth", auto_error=False) @@ -261,18 +262,18 @@ async def check_user_extension_access( success=False, message=f"User not authorized for extension '{ext_id}'." ) - if settings.is_extension_id(ext_id): + if settings.is_installed_extension_id(ext_id): ext_ids = await get_user_active_extensions_ids(user_id, conn=conn) if ext_id not in ext_ids: return SimpleStatus( - success=False, message=f"User extension '{ext_id}' not enabled." + success=False, message=f"Extension '{ext_id}' not enabled." ) return SimpleStatus(success=True, message="OK") async def _check_user_extension_access(user_id: str, path: str): - ext_id = _path_segments(path)[0] + ext_id = path_segments(path)[0] status = await check_user_extension_access(user_id, ext_id) if not status.success: raise HTTPException( @@ -331,16 +332,9 @@ async def _check_account_api_access( if not acl: raise HTTPException(HTTPStatus.FORBIDDEN, "Invalid token id.") - path = "/" + "/".join(_path_segments(path)[:3]) + path = "/" + "/".join(path_segments(path)[:3]) endpoint = acl.get_endpoint(path) if not endpoint: raise HTTPException(HTTPStatus.FORBIDDEN, "Path not allowed.") if not endpoint.supports_method(method): raise HTTPException(HTTPStatus.FORBIDDEN, "Method not allowed.") - - -def _path_segments(path: str) -> list[str]: - segments = path.split("/") - if segments[1] == "upgrades": - return segments[3:] - return segments[1:] diff --git a/lnbits/exceptions.py b/lnbits/exceptions.py index 9d53806c..8e29b89b 100644 --- a/lnbits/exceptions.py +++ b/lnbits/exceptions.py @@ -8,7 +8,9 @@ from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse, RedirectResponse, Response from loguru import logger -from .helpers import template_renderer +from lnbits.settings import settings + +from .helpers import path_segments, template_renderer class PaymentError(Exception): @@ -26,9 +28,11 @@ class InvoiceError(Exception): def render_html_error(request: Request, exc: Exception) -> Optional[Response]: # Only the browser sends "text/html" request # not fail proof, but everything else get's a JSON response + if not request.headers: return None - if "text/html" not in request.headers.get("accept", ""): + + if not _is_browser_request(request): return None if ( @@ -49,7 +53,14 @@ def render_html_error(request: Request, exc: Exception) -> Optional[Response]: ) return template_renderer().TemplateResponse( - request, "error.html", {"err": f"Error: {exc!s}"}, status_code + request, + "error.html", + { + "err": f"Error: {exc!s}", + "status_code": int(status_code), + "message": str(exc).split(":")[-1].strip(), + }, + status_code, ) @@ -119,3 +130,43 @@ def register_exception_handlers(app: FastAPI): status_code=520, content={"detail": exc.message, "status": exc.status}, ) + + @app.exception_handler(404) + async def error_handler_404(request: Request, exc: HTTPException): + logger.error(f"404: {request.url.path} {exc.status_code}: {exc.detail}") + + if not _is_browser_request(request): + return JSONResponse( + status_code=exc.status_code, + content={"detail": exc.detail}, + ) + + path = path_segments(request.url.path)[0] + status_code = HTTPStatus.NOT_FOUND + message: str = "Page not found." + + if path in settings.lnbits_all_extensions_ids: + status_code = HTTPStatus.FORBIDDEN + message = f"Extension '{path}' not installed. Ask the admin to install it." + + return template_renderer().TemplateResponse( + request, + "error.html", + {"status_code": int(status_code), "message": message}, + int(status_code), + ) + + +def _is_browser_request(request: Request) -> bool: + # Check a few common browser agents, also not fail proof + if "api/v1" in request.url.path: + return False + + browser_agents = ["Mozilla", "Chrome", "Safari"] + if any(agent in request.headers.get("user-agent", "") for agent in browser_agents): + return True + + if "text/html" in request.headers.get("accept", ""): + return True + + return False diff --git a/lnbits/helpers.py b/lnbits/helpers.py index 4f692236..ab379418 100644 --- a/lnbits/helpers.py +++ b/lnbits/helpers.py @@ -10,6 +10,7 @@ from urllib.parse import urlparse import jinja2 import jwt import shortuuid +from fastapi import Request from fastapi.routing import APIRoute from packaging import version from pydantic.schema import field_schema @@ -98,7 +99,7 @@ def template_renderer(additional_folders: Optional[list] = None) -> Jinja2Templa settings.lnbits_node_ui and get_node_class() is not None ) t.env.globals["LNBITS_NODE_UI_AVAILABLE"] = get_node_class() is not None - t.env.globals["EXTENSIONS"] = list(settings.lnbits_all_extensions_ids) + t.env.globals["EXTENSIONS"] = list(settings.lnbits_installed_extensions_ids) if settings.lnbits_custom_logo: t.env.globals["USE_CUSTOM_LOGO"] = settings.lnbits_custom_logo @@ -327,3 +328,7 @@ def path_segments(path: str) -> list[str]: def normalize_path(path: Optional[str]) -> str: path = path or "" return "/" + "/".join(path_segments(path)) + + +def normalized_path(request: Request) -> str: + return "/" + "/".join(path_segments(request.url.path)) diff --git a/lnbits/middleware.py b/lnbits/middleware.py index 8962b9c3..cc861ede 100644 --- a/lnbits/middleware.py +++ b/lnbits/middleware.py @@ -82,7 +82,11 @@ class InstalledExtensionMiddleware: return HTMLResponse( status_code=status_code, content=template_renderer() - .TemplateResponse(Request(scope), "error.html", {"err": msg}) + .TemplateResponse( + Request(scope), + "error.html", + {"err": msg, "status_code": status_code, "message": msg}, + ) .body, ) @@ -114,7 +118,6 @@ class ExtensionsRedirectMiddleware: class AuditMiddleware(BaseHTTPMiddleware): - def __init__(self, app: ASGIApp, audit_queue: asyncio.Queue) -> None: super().__init__(app) self.audit_queue = audit_queue diff --git a/lnbits/settings.py b/lnbits/settings.py index 057bef82..c4cd0a85 100644 --- a/lnbits/settings.py +++ b/lnbits/settings.py @@ -149,7 +149,7 @@ class InstalledExtensionsSettings(LNbitsSettings): lnbits_extensions_redirects: list[RedirectPath] = Field(default=[]) # list of all extension ids - lnbits_all_extensions_ids: set[str] = Field(default=[]) + lnbits_installed_extensions_ids: set[str] = Field(default=[]) def find_extension_redirect( self, path: str, req_headers: list[tuple[bytes, bytes]] @@ -182,7 +182,7 @@ class InstalledExtensionsSettings(LNbitsSettings): if ext_redirects: self._activate_extension_redirects(ext_id, ext_redirects) - self.lnbits_all_extensions_ids.add(ext_id) + self.lnbits_installed_extensions_ids.add(ext_id) def deactivate_extension_paths(self, ext_id: str): self.lnbits_deactivated_extensions.add(ext_id) @@ -852,6 +852,8 @@ class TransientSettings(InstalledExtensionsSettings, ExchangeHistorySettings): # Remember the latest balance delta in order to compare with the current one latest_balance_delta_sats: int = Field(default=None) + lnbits_all_extensions_ids: set[str] = Field(default=[]) + @classmethod def readonly_fields(cls): return [f for f in inspect.signature(cls).parameters if not f.startswith("_")] @@ -907,8 +909,8 @@ class Settings(EditableSettings, ReadOnlySettings, TransientSettings, BaseSettin def is_admin_extension(self, ext_id: str) -> bool: return ext_id in self.lnbits_admin_extensions - def is_extension_id(self, ext_id: str) -> bool: - return ext_id in self.lnbits_all_extensions_ids + def is_installed_extension_id(self, ext_id: str) -> bool: + return ext_id in self.lnbits_installed_extensions_ids class SuperSettings(EditableSettings): diff --git a/lnbits/templates/error.html b/lnbits/templates/error.html index afa5b59d..6d5a9afa 100644 --- a/lnbits/templates/error.html +++ b/lnbits/templates/error.html @@ -1,57 +1,97 @@ -{% extends "public.html" %} {% block page %} -
-
- - -
-

Error

+{% extends "public.html" %} {% block page_container %} + + + {% block page %} +
+
+
+ +
+ +
+
- - -
{{ err }}
- -
-
-
- -
-
- -
-
-
-
-
-
-
- + + OR + + + + + {% endblock %} + + {% endblock %} {% block scripts %} +