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:
parent
eeca7de10d
commit
4511891297
11 changed files with 184 additions and 80 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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:]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<br />
|
||||
<q-icon
|
||||
name="warning"
|
||||
class="text-grey"
|
||||
style="font-size: 20rem"
|
||||
></q-icon>
|
||||
{% 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>
|
||||
|
||||
<h5 class="q-my-none">{{ err }}</h5>
|
||||
<div class="error-message" v-text="message"></div>
|
||||
|
||||
<br />
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div
|
||||
class="q-mx-auto q-mt-lg justify-center"
|
||||
style="width: max-content"
|
||||
>
|
||||
<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%"
|
||||
v-if="isExtension"
|
||||
color="primary"
|
||||
@click="goToExtension"
|
||||
label="Go To Extension"
|
||||
class="q-mb-lg full-width"
|
||||
></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>
|
||||
</center>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</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]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -38,9 +38,14 @@ async def test_pay_real_invoice(
|
|||
assert len(invoice["payment_hash"]) == 64
|
||||
assert len(invoice["checking_id"]) > 0
|
||||
|
||||
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue