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).
"""
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)

View file

@ -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

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.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)

View file

@ -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(),

View file

@ -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:]

View file

@ -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

View file

@ -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))

View file

@ -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

View file

@ -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):

View file

@ -1,57 +1,97 @@
{% extends "public.html" %} {% block page %}
<div class="row q-col-gutter-md justify-center">
<div class="col-12 col-md-7 col-lg-6 q-gutter-y-md">
<q-card class="q-pa-lg">
<q-card-section class="q-pa-none">
<center>
<h3 class="q-my-none">Error</h3>
{% extends "public.html" %} {% block page_container %}
<q-page-container>
<q-page
class="q-px-md q-py-lg content-center"
:class="{'q-px-lg': $q.screen.gt.xs}"
>
{% block page %}
<div class="text-center q-pa-md flex flex-center">
<div>
<div class="error-code" v-text="statusCode"></div>
<div class="error-message" v-text="message"></div>
<div
class="q-mx-auto q-mt-lg justify-center"
style="width: max-content"
>
<q-btn
v-if="isExtension"
color="primary"
@click="goToExtension"
label="Go To Extension"
class="q-mb-lg full-width"
></q-btn>
<br />
<q-icon
name="warning"
class="text-grey"
style="font-size: 20rem"
></q-icon>
<h5 class="q-my-none">{{ err }}</h5>
<br />
<div class="row">
<div class="col">
<q-btn
@click="goBack"
color="grey"
outline
label="Back"
style="width: 100%"
></q-btn>
</div>
<div class="col">
<q-btn
@click="goHome"
color="grey"
outline
label="Home"
style="width: 100%"
></q-btn>
</div>
</div>
</center>
</q-card-section>
</q-card>
</div>
</div>
<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>
{% endblock %}
</q-page>
</q-page-container>
{% 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>
window.app = Vue.createApp({
el: '#vue',
mixins: [window.windowMixin],
data() {
return {
err: null,
statusCode: null,
message: null
}
},
methods: {
goBack: function () {
window.history.back()
},
goHome: function () {
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["checking_id"]) > 0
data = from_wallet_ws.receive_json()
assert "wallet_balance" in data
payment = Payment(**data["payment"])
while True:
data = from_wallet_ws.receive_json()
assert "wallet_balance" in data
assert "payment" in data
if data["payment"]["payment_hash"] == invoice["payment_hash"]:
payment = Payment(**data["payment"])
break
assert payment.payment_hash == invoice["payment_hash"]
# check the payment status