feat: modernize error page (#2949)

* can't figure this out!

* raise here so it gets picked up by exceptions

* add few more info for error rendering

* add a few more checks for browser

not fail safe just one more layer

* cleaner error display

... hopefully

* add go to extension

* keep buttons add go to extension

* feat: identify extensions that are not installed

* fix: status code

* fix: full path

* add account/logout button if 401

prevent getting stuck

* fix: ext access

* fix user button

* fix: 404 page

* fix: json 404 response

* fix: dumb rendering

* fix: `/api` request always json

* fix: extension api path

* test: check regtest

* test: investgate

* something made ws slower?

* fix: change error code

---------

Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
Co-authored-by: dni  <office@dnilabs.com>
Co-authored-by: Arc <33088785+arcbtc@users.noreply.github.com>
This commit is contained in:
Tiago Vasconcelos 2025-02-23 00:54:43 +00:00 committed by GitHub
parent eeca7de10d
commit 4511891297
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 184 additions and 80 deletions

View file

@ -283,14 +283,14 @@ async def build_all_installed_extensions_list(
MUST be installed by default (see LNBITS_EXTENSIONS_DEFAULT_INSTALL). MUST be installed by default (see LNBITS_EXTENSIONS_DEFAULT_INSTALL).
""" """
installed_extensions = await get_installed_extensions() 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(): for ext_dir in Path(settings.lnbits_extensions_path, "extensions").iterdir():
try: try:
if not ext_dir.is_dir(): if not ext_dir.is_dir():
continue continue
ext_id = ext_dir.name ext_id = ext_dir.name
if ext_id in settings.lnbits_all_extensions_ids: if ext_id in settings.lnbits_installed_extensions_ids:
continue continue
ext_info = InstallableExtension.from_ext_dir(ext_id) ext_info = InstallableExtension.from_ext_dir(ext_id)
if not ext_info: if not ext_info:
@ -305,7 +305,7 @@ async def build_all_installed_extensions_list(
logger.warning(e) logger.warning(e)
for ext_id in settings.lnbits_extensions_default_install: 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 continue
ext_releases = await InstallableExtension.get_extension_releases(ext_id) ext_releases = await InstallableExtension.get_extension_releases(ext_id)

View file

@ -586,7 +586,6 @@ class InstallableExtension(BaseModel):
cls, cls,
) -> list[InstallableExtension]: ) -> list[InstallableExtension]:
extension_list: list[InstallableExtension] = [] extension_list: list[InstallableExtension] = []
extension_id_list: list[str] = []
for url in settings.lnbits_extensions_manifests: for url in settings.lnbits_extensions_manifests:
try: try:
@ -607,7 +606,6 @@ class InstallableExtension(BaseModel):
meta.featured = ext.id in manifest.featured meta.featured = ext.id in manifest.featured
ext.meta = meta ext.meta = meta
extension_list += [ext] extension_list += [ext]
extension_id_list += [ext.id]
for e in manifest.extensions: for e in manifest.extensions:
release = ExtensionRelease.from_explicit_release(url, e) release = ExtensionRelease.from_explicit_release(url, e)
@ -623,10 +621,10 @@ class InstallableExtension(BaseModel):
meta.featured = ext.id in manifest.featured meta.featured = ext.id in manifest.featured
ext.meta = meta ext.meta = meta
extension_list += [ext] extension_list += [ext]
extension_id_list += [e.id]
except Exception as e: except Exception as e:
logger.warning(f"Manifest {url} failed with '{e!s}'") logger.warning(f"Manifest {url} failed with '{e!s}'")
settings.lnbits_all_extensions_ids = {e.id for e in extension_list}
return extension_list return extension_list
@classmethod @classmethod

View file

@ -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.users import get_accounts
from lnbits.core.crud.wallets import get_wallets_count from lnbits.core.crud.wallets import get_wallets_count
from lnbits.core.models import AuditEntry, Payment from lnbits.core.models import AuditEntry, Payment
from lnbits.core.models.extensions import InstallableExtension
from lnbits.core.models.notifications import NotificationType from lnbits.core.models.notifications import NotificationType
from lnbits.core.services import ( from lnbits.core.services import (
send_payment_notification, send_payment_notification,
@ -62,6 +63,13 @@ async def run_by_the_minute_tasks():
except Exception as ex: except Exception as ex:
logger.error(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 minute_counter += 1
await asyncio.sleep(60) await asyncio.sleep(60)

View file

@ -50,12 +50,9 @@ async def home(request: Request, lightning: str = ""):
@generic_router.get("/first_install", response_class=HTMLResponse) @generic_router.get("/first_install", response_class=HTMLResponse)
async def first_install(request: Request): async def first_install(request: Request):
if not settings.first_install: if not settings.first_install:
return template_renderer().TemplateResponse( raise HTTPException(
request, status_code=HTTPStatus.BAD_REQUEST,
"error.html", detail="Super user account has already been configured.",
{
"err": "Super user account has already been configured.",
},
) )
return template_renderer().TemplateResponse( return template_renderer().TemplateResponse(
request, request,
@ -180,8 +177,9 @@ async def wallet(
wallet = user.wallets[0] wallet = user.wallets[0]
if not wallet or wallet.deleted: if not wallet or wallet.deleted:
return template_renderer().TemplateResponse( raise HTTPException(
request, "error.html", {"err": "Wallet not found"}, HTTPStatus.NOT_FOUND status_code=HTTPStatus.NOT_FOUND,
detail="Wallet not found",
) )
context = { context = {
"user": user.json(), "user": user.json(),

View file

@ -28,6 +28,7 @@ from lnbits.core.models import (
WalletTypeInfo, WalletTypeInfo,
) )
from lnbits.db import Connection, Filter, Filters, TFilterModel from lnbits.db import Connection, Filter, Filters, TFilterModel
from lnbits.helpers import path_segments
from lnbits.settings import AuthMethods, settings from lnbits.settings import AuthMethods, settings
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/v1/auth", auto_error=False) 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}'." 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) ext_ids = await get_user_active_extensions_ids(user_id, conn=conn)
if ext_id not in ext_ids: if ext_id not in ext_ids:
return SimpleStatus( 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") return SimpleStatus(success=True, message="OK")
async def _check_user_extension_access(user_id: str, path: str): 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) status = await check_user_extension_access(user_id, ext_id)
if not status.success: if not status.success:
raise HTTPException( raise HTTPException(
@ -331,16 +332,9 @@ async def _check_account_api_access(
if not acl: if not acl:
raise HTTPException(HTTPStatus.FORBIDDEN, "Invalid token id.") raise HTTPException(HTTPStatus.FORBIDDEN, "Invalid token id.")
path = "/" + "/".join(_path_segments(path)[:3]) path = "/" + "/".join(path_segments(path)[:3])
endpoint = acl.get_endpoint(path) endpoint = acl.get_endpoint(path)
if not endpoint: if not endpoint:
raise HTTPException(HTTPStatus.FORBIDDEN, "Path not allowed.") raise HTTPException(HTTPStatus.FORBIDDEN, "Path not allowed.")
if not endpoint.supports_method(method): if not endpoint.supports_method(method):
raise HTTPException(HTTPStatus.FORBIDDEN, "Method not allowed.") 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:]

View file

@ -8,7 +8,9 @@ from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse, RedirectResponse, Response from fastapi.responses import JSONResponse, RedirectResponse, Response
from loguru import logger from loguru import logger
from .helpers import template_renderer from lnbits.settings import settings
from .helpers import path_segments, template_renderer
class PaymentError(Exception): class PaymentError(Exception):
@ -26,9 +28,11 @@ class InvoiceError(Exception):
def render_html_error(request: Request, exc: Exception) -> Optional[Response]: def render_html_error(request: Request, exc: Exception) -> Optional[Response]:
# Only the browser sends "text/html" request # Only the browser sends "text/html" request
# not fail proof, but everything else get's a JSON response # not fail proof, but everything else get's a JSON response
if not request.headers: if not request.headers:
return None return None
if "text/html" not in request.headers.get("accept", ""):
if not _is_browser_request(request):
return None return None
if ( if (
@ -49,7 +53,14 @@ def render_html_error(request: Request, exc: Exception) -> Optional[Response]:
) )
return template_renderer().TemplateResponse( 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, status_code=520,
content={"detail": exc.message, "status": exc.status}, 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

View file

@ -10,6 +10,7 @@ from urllib.parse import urlparse
import jinja2 import jinja2
import jwt import jwt
import shortuuid import shortuuid
from fastapi import Request
from fastapi.routing import APIRoute from fastapi.routing import APIRoute
from packaging import version from packaging import version
from pydantic.schema import field_schema 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 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["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: if settings.lnbits_custom_logo:
t.env.globals["USE_CUSTOM_LOGO"] = 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: def normalize_path(path: Optional[str]) -> str:
path = path or "" path = path or ""
return "/" + "/".join(path_segments(path)) return "/" + "/".join(path_segments(path))
def normalized_path(request: Request) -> str:
return "/" + "/".join(path_segments(request.url.path))

View file

@ -82,7 +82,11 @@ class InstalledExtensionMiddleware:
return HTMLResponse( return HTMLResponse(
status_code=status_code, status_code=status_code,
content=template_renderer() content=template_renderer()
.TemplateResponse(Request(scope), "error.html", {"err": msg}) .TemplateResponse(
Request(scope),
"error.html",
{"err": msg, "status_code": status_code, "message": msg},
)
.body, .body,
) )
@ -114,7 +118,6 @@ class ExtensionsRedirectMiddleware:
class AuditMiddleware(BaseHTTPMiddleware): class AuditMiddleware(BaseHTTPMiddleware):
def __init__(self, app: ASGIApp, audit_queue: asyncio.Queue) -> None: def __init__(self, app: ASGIApp, audit_queue: asyncio.Queue) -> None:
super().__init__(app) super().__init__(app)
self.audit_queue = audit_queue self.audit_queue = audit_queue

View file

@ -149,7 +149,7 @@ class InstalledExtensionsSettings(LNbitsSettings):
lnbits_extensions_redirects: list[RedirectPath] = Field(default=[]) lnbits_extensions_redirects: list[RedirectPath] = Field(default=[])
# list of all extension ids # 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( def find_extension_redirect(
self, path: str, req_headers: list[tuple[bytes, bytes]] self, path: str, req_headers: list[tuple[bytes, bytes]]
@ -182,7 +182,7 @@ class InstalledExtensionsSettings(LNbitsSettings):
if ext_redirects: if ext_redirects:
self._activate_extension_redirects(ext_id, 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): def deactivate_extension_paths(self, ext_id: str):
self.lnbits_deactivated_extensions.add(ext_id) 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 # Remember the latest balance delta in order to compare with the current one
latest_balance_delta_sats: int = Field(default=None) latest_balance_delta_sats: int = Field(default=None)
lnbits_all_extensions_ids: set[str] = Field(default=[])
@classmethod @classmethod
def readonly_fields(cls): def readonly_fields(cls):
return [f for f in inspect.signature(cls).parameters if not f.startswith("_")] 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: def is_admin_extension(self, ext_id: str) -> bool:
return ext_id in self.lnbits_admin_extensions return ext_id in self.lnbits_admin_extensions
def is_extension_id(self, ext_id: str) -> bool: def is_installed_extension_id(self, ext_id: str) -> bool:
return ext_id in self.lnbits_all_extensions_ids return ext_id in self.lnbits_installed_extensions_ids
class SuperSettings(EditableSettings): class SuperSettings(EditableSettings):

View file

@ -1,57 +1,97 @@
{% extends "public.html" %} {% block page %} {% extends "public.html" %} {% block page_container %}
<div class="row q-col-gutter-md justify-center"> <q-page-container>
<div class="col-12 col-md-7 col-lg-6 q-gutter-y-md"> <q-page
<q-card class="q-pa-lg"> class="q-px-md q-py-lg content-center"
<q-card-section class="q-pa-none"> :class="{'q-px-lg': $q.screen.gt.xs}"
<center> >
<h3 class="q-my-none">Error</h3> {% block page %}
<br /> <div class="text-center q-pa-md flex flex-center">
<q-icon <div>
name="warning" <div class="error-code" v-text="statusCode"></div>
class="text-grey"
style="font-size: 20rem"
></q-icon>
<h5 class="q-my-none">{{ err }}</h5> <div class="error-message" v-text="message"></div>
<br /> <div
<div class="row"> class="q-mx-auto q-mt-lg justify-center"
<div class="col"> style="width: max-content"
>
<q-btn <q-btn
@click="goBack" v-if="isExtension"
color="grey" color="primary"
outline @click="goToExtension"
label="Back" label="Go To Extension"
style="width: 100%" class="q-mb-lg full-width"
></q-btn>
</div>
<div class="col">
<q-btn
@click="goHome"
color="grey"
outline
label="Home"
style="width: 100%"
></q-btn> ></q-btn>
<br />
<q-btn color="primary" href="/" label="Go Home"></q-btn>
<span class="q-mx-md">OR</span>
<q-btn color="primary" @click="goBack" label="Go Back"></q-btn>
</div> </div>
</div> </div>
</center>
</q-card-section>
</q-card>
</div> </div>
</div> {% endblock %}
</q-page>
</q-page-container>
{% endblock %} {% block scripts %} {% endblock %} {% block scripts %}
<style>
.error-code {
font-size: clamp(15vh, 20vw, 30vh);
}
.error-message {
font-size: clamp(1.5rem, calc(1.5 / 10 * 20vw), 3.75rem);
font-weight: 300;
opacity: 0.4;
}
</style>
<script> <script>
window.app = Vue.createApp({ window.app = Vue.createApp({
el: '#vue', el: '#vue',
mixins: [window.windowMixin], mixins: [window.windowMixin],
data() {
return {
err: null,
statusCode: null,
message: null
}
},
methods: { methods: {
goBack: function () { goBack: function () {
window.history.back() window.history.back()
}, },
goHome: function () { goHome: function () {
window.location.href = '/' window.location.href = '/'
},
goToExtension() {
window.location.href = `/extensions#${this.extension}`
},
logOut() {
LNbits.utils
.confirmDialog(
'Do you really want to logout?'
)
.onOk( async () => {
try {
await LNbits.api.logout()
window.location = '/'
} catch (e) {
LNbits.utils.notifyApiError(e)
}
})
},
},
computed: {
isExtension() {
if (this.statusCode != 403) return false
if (this.message.startsWith('Extension ')) return true
}
},
created() {
this.err = '{{ err }}'
this.statusCode = '{{ status_code }}' || 404
this.message = String({{ message | tojson }}) || 'Page not found'
if (this.isExtension) {
this.extension = this.message.match(/'([^']+)'/)[1]
} }
} }
}) })

View file

@ -38,9 +38,14 @@ async def test_pay_real_invoice(
assert len(invoice["payment_hash"]) == 64 assert len(invoice["payment_hash"]) == 64
assert len(invoice["checking_id"]) > 0 assert len(invoice["checking_id"]) > 0
while True:
data = from_wallet_ws.receive_json() data = from_wallet_ws.receive_json()
assert "wallet_balance" in data assert "wallet_balance" in data
assert "payment" in data
if data["payment"]["payment_hash"] == invoice["payment_hash"]:
payment = Payment(**data["payment"]) payment = Payment(**data["payment"])
break
assert payment.payment_hash == invoice["payment_hash"] assert payment.payment_hash == invoice["payment_hash"]
# check the payment status # check the payment status