Compare commits

..
Author SHA1 Message Date
Vlad Stan fd3ce06de5 fix: dependencies 2026-07-09 16:36:10 +03:00
Vlad Stan 88da66e4ee refactor: fix deps 2026-07-09 16:36:10 +03:00
Vlad Stan 36958d34f0 refactor: fix some imports 2026-07-09 16:36:10 +03:00
Vlad Stan 220be5cc0b refactor: fix imports 2026-07-09 16:36:10 +03:00
Vlad Stan 1b477f41ad refactor: extract import 2026-07-09 16:36:10 +03:00
Vlad Stan 8ac2cfbd1c refactor: extract permissions component 2026-07-09 16:36:10 +03:00
Vlad Stan 7425f9fa70 chore: clean-up i18n 2026-07-09 16:36:10 +03:00
Vlad Stan 442a272aa5 refactor: separate function 2026-07-09 16:36:10 +03:00
Vlad Stan ebebd781b8 refactor: move function 2026-07-09 16:36:10 +03:00
Vlad Stan 8824ae9fd4 refactor: extract 2026-07-09 16:36:10 +03:00
Vlad Stan ec1a9ac2f4 refactor: is_upgrade_extension 2026-07-09 16:36:10 +03:00
Vlad Stan ba4700b803 chore: make bundle 2026-07-09 16:36:10 +03:00
Vlad Stan 73229c9d0b feat: http.request permissions 2026-07-09 16:36:05 +03:00
Vlad Stan 5f483215d9 chore: lint 2026-07-09 16:36:05 +03:00
Vlad Stan e94a61c279 refactor: remove redundant check 2026-07-09 16:36:05 +03:00
Vlad Stan d9189e62af fix: policies 2026-07-09 16:36:05 +03:00
Vlad Stan 114229fb15 refactor: checks 2026-07-09 16:36:05 +03:00
Vlad Stan 137498740a refactor: move logic out from host.py 2026-07-09 16:36:05 +03:00
Vlad Stan 3ddce73834 fea: nicer permissions 2026-07-09 16:36:05 +03:00
Vlad Stan 597a65c45f feat: ui polish 2026-07-09 16:36:05 +03:00
Vlad Stan 3a5a6713c9 fix: allow no param 2026-07-09 16:36:05 +03:00
Vlad Stan eceb1eba61 fix: start/stop background work 2026-07-09 16:36:05 +03:00
Vlad Stan f186006329 refactor: structure 2026-07-09 16:36:05 +03:00
Vlad Stan 32c782e719 refactor: extract functions 2026-07-09 16:36:05 +03:00
Vlad Stan e45f0cf73d refactor: extract permissions logic 2026-07-09 16:35:53 +03:00
Vlad Stan b5506e7a0b refactor: extract function 2026-07-09 16:35:53 +03:00
Vlad Stan 77d52d3997 chore: clean-up 2026-07-09 16:35:40 +03:00
Vlad Stan 15282ebe23 fix: lint 2026-07-09 16:35:40 +03:00
Vlad Stan 39a148ef51 feat: add camera permissions 2026-07-09 16:35:40 +03:00
Vlad Stan 45a81fe22e feat: pay_invoice 2026-07-09 16:35:40 +03:00
Vlad Stan 4aaab5ca7c fix: icon 2026-07-09 16:35:40 +03:00
Vlad Stan 2add71cf0d refactor: components 2026-07-09 16:35:40 +03:00
Vlad Stan faf3611e2a refactor: better namespace 2026-07-09 16:35:40 +03:00
Vlad Stan 4bcc22d463 feat: add utils 2026-07-09 16:35:40 +03:00
Vlad Stan c094c820ef fix: access to extensions 2026-07-09 16:35:40 +03:00
Vlad Stan 6be509f8f0 feat: call extensions 2026-07-09 16:35:40 +03:00
Vlad Stan 2f74243f2a Revert "feat: public page http calls"
This reverts commit e7c359c1e0e21a8aeea391fd5b2cd6316526d42c.
2026-07-09 16:35:40 +03:00
Vlad Stan 2ff6fe5d3a feat: public page http calls 2026-07-09 16:35:40 +03:00
Vlad Stan 3f281e65f7 fix: user required 2026-07-09 16:35:40 +03:00
Vlad Stan 2286fee5c0 fix: loop issue 2026-07-09 16:35:40 +03:00
Vlad Stan af37caee1b refactor: better imports 2026-07-09 16:35:40 +03:00
Vlad Stan 31d343d220 refactor: split loader from routes 2026-07-09 16:35:40 +03:00
Vlad Stan 42f720cdba fix: typing 2026-07-09 16:35:39 +03:00
alanandVlad Stan 269035d853 chore: make bundle [skip ci] 2026-07-09 16:35:39 +03:00
Vlad Stan c8966d0eaa fix: simplify user check 2026-07-09 16:35:39 +03:00
Vlad Stan 1321c360cc chore: update poetry.lock 2026-07-09 16:35:39 +03:00
Vlad Stan 3ae05ba6e9 feat: ext http request 2026-07-09 16:35:39 +03:00
Vlad Stan 5e48676702 chore: make bundle 2026-07-09 16:35:39 +03:00
Vlad Stan 2cce2f86ef refactor: move code generator to tools 2026-07-09 16:35:34 +03:00
Vlad Stan bca10998bd refactor: remove unused param 2026-07-09 16:35:34 +03:00
Vlad Stan 6400ee985e refactor: move out dispatch_wasm_invoice_paid from tasks 2026-07-09 16:35:34 +03:00
Vlad Stan 61cdfd4662 refactor: remove extra param 2026-07-09 16:35:07 +03:00
Vlad Stan 81e8a08240 refactor: app usage 2026-07-09 16:34:52 +03:00
Vlad Stan 5375055bb7 refactor: simplify 2026-07-09 16:34:44 +03:00
Vlad Stan dbd1e20346 fix: i18n 2026-07-09 16:33:40 +03:00
Vlad Stan 948acb2b73 feat: extra extra 2026-07-09 16:33:39 +03:00
Vlad Stan b8a16e31e7 fix: i18n 2026-07-09 16:33:39 +03:00
Vlad Stan 0fce2481cb fix: owner id 2026-07-09 16:33:39 +03:00
Vlad Stan a2a12a25a5 fix: create row on event 2026-07-09 16:33:22 +03:00
Vlad Stan dab53a70e4 fix: create public invoice 2026-07-09 16:33:09 +03:00
Vlad Stan 003e2e4bb4 feat: create invoice public 2026-07-09 16:33:09 +03:00
Vlad Stan db275afddf feat: public get 2026-07-09 16:33:09 +03:00
Vlad Stan 9081324cda feat: finer grain permissions 2026-07-09 16:33:09 +03:00
Vlad Stan df04f5cc65 fix: permissions 2026-07-09 16:33:09 +03:00
Vlad Stan 21a6bbe73f fix: permissions 2026-07-09 16:33:09 +03:00
Vlad Stan 32c870d71d chore: clean-up 2026-07-09 16:33:09 +03:00
Vlad Stan 0ade5fdc70 fix: navigation 2026-07-09 16:33:09 +03:00
Vlad Stan df5543c280 fix: activate wasm extension 2026-07-09 16:33:09 +03:00
Vlad Stan ebdb95ab89 fix: ui 2026-07-09 16:33:09 +03:00
Vlad Stan e94480f82c feat: ask permission 2026-07-09 16:33:09 +03:00
Vlad Stan 2952e41fd3 feat: permissions 2026-07-09 16:33:09 +03:00
Vlad Stan 2bf50968ba feat: ws bridge 2026-07-09 16:30:18 +03:00
Vlad Stan 52134608f9 chore: clean-up 2026-07-09 16:30:18 +03:00
Vlad Stan 9cc24993ec fix: icons 2026-07-09 16:30:18 +03:00
Vlad Stan 3e30b387b2 fix: import lnbits resources 2026-07-09 16:30:18 +03:00
Vlad Stan d18f225757 feat: pagination 2026-07-09 16:30:18 +03:00
Vlad Stan 3d4202b495 feat: store data 2026-07-09 16:30:18 +03:00
Vlad Stan da76d288ae feat: db operations 2026-07-09 16:30:18 +03:00
Vlad Stan 7f13588705 fix: wallets 2026-07-09 16:30:18 +03:00
Vlad Stan faf436533e feat: create unsafe real invoice 2026-07-09 16:30:18 +03:00
Vlad Stan 15273383ef fix: user bound frame_token 2026-07-09 16:30:18 +03:00
Vlad Stan bdff354d0b fix: links 2026-07-09 16:30:18 +03:00
Vlad Stan 20e3b4c0b4 feat: add wallet list 2026-07-09 16:30:18 +03:00
Vlad Stan f2920e8eb1 fix: postMessage uses '*' 2026-07-09 16:30:18 +03:00
Vlad Stan eed3cb2509 fix: frame in frame 2026-07-09 16:30:18 +03:00
Vlad Stan be2dbd71e9 fix: stricter asset loading 2026-07-09 16:30:18 +03:00
Vlad Stan 3fd08ce223 fix: better error page 2026-07-09 16:30:18 +03:00
Vlad Stan 068cdffb42 fix: allow clipboard copy 2026-07-09 16:30:18 +03:00
Vlad Stan 2dffad917c fix: allow clipboard copy 2026-07-09 16:30:18 +03:00
Vlad Stan 285c0bc92f fix: external iframe access 2026-07-09 16:30:18 +03:00
Vlad Stan a175807188 chore: code format 2026-07-09 16:30:18 +03:00
Vlad Stan f8519c9c60 fix: stricter headers for non-iframe usage 2026-07-09 16:30:18 +03:00
Vlad Stan 951840f50b fix: iframe communication 2026-07-09 16:30:18 +03:00
Vlad Stan 4fccd57595 fix: iframe style 2026-07-09 16:30:18 +03:00
Vlad Stan 15d63fc35a fix: link 2026-07-09 16:30:18 +03:00
Vlad Stan 41db2b2819 feat: serve in iframe 2026-07-09 16:30:18 +03:00
Vlad Stan 4a38d4219c chore: lint 2026-07-09 16:30:18 +03:00
Vlad Stan 6c5bd6f2a1 perf: cache wasm component 2026-07-09 16:30:18 +03:00
Vlad Stan c5c5538a17 fix: REST paths 2026-07-09 16:30:18 +03:00
Vlad Stan 4557910a32 feat: call ext logic 2026-07-09 16:30:17 +03:00
Vlad Stan eec9f8a053 basic extension loading 2026-07-09 16:30:17 +03:00
Vlad Stan da6dbfdcf7 refactor: reorder 2026-07-09 16:30:17 +03:00
Vlad Stan cc4d33c91e fix: remove public context 2026-07-09 16:30:17 +03:00
Vlad Stan 1d7c74b8c1 feat: simpler permissions 2026-07-09 16:30:17 +03:00
Vlad Stan 4943343aa3 feat: dumb 2026-07-09 16:30:17 +03:00
Vlad StanandGitHub cabb58f8fe chore: make bundle (#4045) 2026-07-08 16:59:28 +03:00
aac04efd0e Merge commit from fork
* fix: don't show unsafe html in lnurlp notifications

* fix: lnurlp success aes decryption

* regenerate bundle

---------

Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-07-08 16:49:43 +03:00
fd9009f760 feat: create TaskManager singleton class (#3775)
Co-authored-by: dadofsambonzuki <dadofsambonzuki@users.noreply.github.com>
2026-07-08 14:07:38 +02:00
dni ⚡andGitHub 6ab413775a chore: update to v1.5.6 (#4044) 2026-07-08 13:54:35 +02:00
7eb9965205 feat: keep funding source on default reset, force seed backup (#4040)
Co-authored-by: alan <alan@lnbits.com>
2026-07-08 13:54:11 +02:00
4634ad5a5a feat: use lnbits price aggregator to save loads of request (#4026)
Co-authored-by: alan <alan@lnbits.com>
2026-07-06 09:21:55 +02:00
dni ⚡andGitHub 648aaa17c5 CI: appimage upload-urls with gh upload command (#4036) 2026-07-03 12:35:14 +01:00
blackcoffeexbtandGitHub 7db5c986b3 NWC funding source: Reduce polling (#4008) 2026-07-02 13:00:49 +01:00
74 changed files with 2521 additions and 2128 deletions
+1 -14
View File
@@ -7,10 +7,6 @@ on:
description: 'The tag name for the release'
required: true
type: string
upload_url:
description: 'The upload URL for the release'
required: true
type: string
workflow_dispatch:
inputs:
@@ -18,10 +14,6 @@ on:
description: 'The tag name for the release'
required: true
type: string
upload_url:
description: 'The upload URL for the release'
required: true
type: string
jobs:
build-linux-package:
@@ -113,11 +105,6 @@ jobs:
shell: bash
- name: Upload Linux Release Asset
uses: actions/upload-release-asset@v1
with:
upload_url: ${{ inputs.upload_url }}
asset_path: ${{ env.APPIMAGE_NAME }}
asset_name: ${{ env.APPIMAGE_NAME }}
asset_content_type: application/octet-stream
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release upload "${{ inputs.tag_name }}" "${{ env.APPIMAGE_NAME }}" --clobber
+1 -8
View File
@@ -64,13 +64,6 @@ jobs:
with:
make: openapi
test-wasm-e2e:
needs: [ lint ]
uses: ./.github/workflows/make.yml
with:
make: test-wasm-e2e
playwright-browser: chromium
regtest:
needs: [ lint ]
uses: ./.github/workflows/regtest.yml
@@ -102,5 +95,5 @@ jobs:
python-version: ${{ matrix.python-version }}
bundle:
needs: [ lint, test-api, test-wallets, test-unit, migration, openapi, test-wasm-e2e, regtest, jmeter ]
needs: [ lint, test-api, test-wallets, test-unit, migration, openapi, regtest, jmeter ]
uses: ./.github/workflows/bundle.yml
-7
View File
@@ -15,10 +15,6 @@ on:
description: "python version"
type: string
default: "3.12"
playwright-browser:
description: "Playwright browser to install before running make"
default: ""
type: string
jobs:
make:
@@ -35,7 +31,4 @@ jobs:
python-version: ${{ inputs.python-version }}
node-version: ${{ matrix.node-version }}
npm: ${{ inputs.npm }}
- name: Install Playwright browser
if: ${{ inputs.playwright-browser != '' }}
run: uv run playwright install --with-deps ${{ inputs.playwright-browser }}
- run: make ${{ inputs.make }}
-11
View File
@@ -12,8 +12,6 @@ jobs:
release:
runs-on: ubuntu-24.04
outputs:
upload_url: ${{ steps.get_upload_url.outputs.upload_url }}
steps:
- uses: actions/checkout@v4
- name: Create github pre-release
@@ -22,14 +20,6 @@ jobs:
tag: ${{ github.ref_name }}
run: |
gh release create "$tag" --prerelease --generate-notes --draft
- id: get_upload_url
name: Get upload url of Github release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ github.ref_name }}
run: |
upload_url=$(gh release view "$tag" --json uploadUrl -q ".uploadUrl")
echo "upload_url=$upload_url" >> "$GITHUB_OUTPUT"
docker:
if: github.repository == 'lnbits/lnbits'
@@ -74,4 +64,3 @@ jobs:
uses: ./.github/workflows/appimage.yml
with:
tag_name: ${{ github.ref_name }}
upload_url: ${{ needs.release.outputs.upload_url }}
-11
View File
@@ -13,8 +13,6 @@ jobs:
release:
runs-on: ubuntu-24.04
outputs:
upload_url: ${{ steps.get_upload_url.outputs.upload_url }}
steps:
- uses: actions/checkout@v4
- name: Create github release
@@ -23,14 +21,6 @@ jobs:
tag: ${{ github.ref_name }}
run: |
gh release create "$tag" --generate-notes --draft
- id: get_upload_url
name: Get upload url of Github release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ github.ref_name }}
run: |
upload_url=$(gh release view "$tag" --json uploadUrl -q ".uploadUrl")
echo "upload_url=$upload_url" >> "$GITHUB_OUTPUT"
docker:
if: github.repository == 'lnbits/lnbits'
@@ -85,4 +75,3 @@ jobs:
uses: ./.github/workflows/appimage.yml
with:
tag_name: ${{ github.ref_name }}
upload_url: ${{ needs.release.outputs.upload_url }}
-2
View File
@@ -16,5 +16,3 @@
flake.lock
.venv
tests/fixtures/lnbits-wasm-test-extension/static/html-like.js
-6
View File
@@ -62,12 +62,6 @@ test-api:
DEBUG=true \
uv run pytest tests/api
test-wasm-e2e:
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
PYTHONUNBUFFERED=1 \
DEBUG=true \
uv run pytest tests/wasm_ext --browser chromium
test-regtest:
LNBITS_DATA_FOLDER="./tests/data" \
PYTHONUNBUFFERED=1 \
+51 -38
View File
@@ -23,20 +23,30 @@ from lnbits.core.crud import (
get_installed_extensions,
update_installed_extension_state,
)
from lnbits.core.crud.audit import delete_expired_audit_entries
from lnbits.core.crud.extensions import create_installed_extension
from lnbits.core.helpers import migrate_extension_database
from lnbits.core.models.notifications import NotificationType
from lnbits.core.services.extensions import deactivate_extension, get_valid_extensions
from lnbits.core.services.notifications import enqueue_admin_notification
from lnbits.core.services.payments import check_pending_payments
from lnbits.core.services.funding_source import (
check_balance_delta_changed,
check_server_balance_against_node,
)
from lnbits.core.services.notifications import (
dispatch_payment_notification,
enqueue_admin_notification,
process_next_notification,
)
from lnbits.core.services.payments import (
check_pending_payments,
fundingsource_invoice_producer,
)
from lnbits.core.tasks import (
audit_queue,
collect_exchange_rates_data,
purge_audit_data,
run_by_the_minute_tasks,
wait_for_audit_data,
wait_for_paid_invoices,
wait_notification_messages,
notify_server_status,
process_next_audit_entry,
refresh_extension_cache,
)
from lnbits.core.wasm_ext.routes.register import register_wasm_extension
from lnbits.core.wasm_ext.wasm.events import dispatch_wasm_invoice_paid
@@ -47,11 +57,6 @@ from lnbits.exceptions import register_exception_handlers
from lnbits.helpers import version_parse
from lnbits.llms_txt import create_llms_txt_route
from lnbits.settings import settings
from lnbits.tasks import (
cancel_all_tasks,
create_permanent_task,
register_invoice_listener,
)
from lnbits.utils.cache import cache
from lnbits.utils.logger import (
configure_logger,
@@ -74,7 +79,7 @@ from .middleware import (
add_profiler_middleware,
add_ratelimit_middleware,
)
from .tasks import internal_invoice_listener, invoice_listener, run_interval
from .task_manager import task_manager
async def startup(app: FastAPI):
@@ -138,7 +143,7 @@ async def shutdown():
settings.lnbits_running = False
# shutdown event
cancel_all_tasks()
task_manager.cancel_all_tasks()
# wait a bit to allow them to finish, so that cleanup can run without problems
await asyncio.sleep(0.1)
@@ -490,34 +495,42 @@ async def check_and_register_extensions(app: FastAPI) -> None:
def register_async_tasks() -> None:
create_permanent_task(wait_for_audit_data)
create_permanent_task(wait_notification_messages)
task_manager.init()
create_permanent_task(
run_interval(
settings.lnbits_funding_source_pending_interval_seconds,
check_pending_payments,
)
# listen to all incoming payments and dispatch payment notifications
# note: should be the first in task list for a bit quicker notifications
task_manager.register_invoice_listener(dispatch_payment_notification, "core")
# periodic tasks
task_manager.create_permanent_task(cache.invalidate_cache, interval=10)
task_manager.create_permanent_task(delete_expired_audit_entries, interval=60 * 60)
task_manager.create_permanent_task(
check_pending_payments,
interval=settings.lnbits_funding_source_pending_interval_seconds,
)
create_permanent_task(invoice_listener)
create_permanent_task(internal_invoice_listener)
create_permanent_task(cache.invalidate_forever)
task_manager.create_permanent_task(
collect_exchange_rates_data,
interval=max(60, settings.lnbits_exchange_history_refresh_interval_seconds),
)
task_manager.create_permanent_task(check_balance_delta_changed, interval=60)
task_manager.create_permanent_task(
check_server_balance_against_node,
interval=60 * settings.lnbits_watchdog_interval_minutes,
)
task_manager.create_permanent_task(
notify_server_status,
interval=60 * 60 * settings.lnbits_notification_server_status_hours,
)
task_manager.create_permanent_task(refresh_extension_cache, interval=60)
# core invoice listener
invoice_queue: asyncio.Queue = asyncio.Queue()
register_invoice_listener(invoice_queue, "core")
async def dispatch_extension_invoice_paid(payment) -> None:
await dispatch_wasm_invoice_paid(payment)
core_app_extra.dispatch_extension_invoice_paid = dispatch_extension_invoice_paid
create_permanent_task(lambda: wait_for_paid_invoices(invoice_queue))
create_permanent_task(run_by_the_minute_tasks)
create_permanent_task(purge_audit_data)
create_permanent_task(collect_exchange_rates_data)
# permanent tasks run in a loop, will be restarted if they fail
task_manager.create_permanent_task(fundingsource_invoice_producer)
task_manager.create_permanent_task(process_next_notification)
task_manager.create_permanent_task(process_next_audit_entry)
# server logs for websocket
if settings.lnbits_admin_ui:
server_log_task = initialize_server_websocket_logger()
create_permanent_task(server_log_task)
task_manager.create_permanent_task(
server_log_task, name="server_websocket_logger"
)
+15 -10
View File
@@ -8,11 +8,18 @@ from lnbits.db import dict_to_model
from lnbits.settings import (
AdminSettings,
EditableSettings,
FundingSourcesSettings,
SettingsField,
SuperSettings,
settings,
)
RESET_PRESERVED_SETTINGS = (
"lnbits_webpush_pubkey",
"lnbits_webpush_privkey",
*FundingSourcesSettings.__fields__,
)
async def get_super_settings() -> SuperSettings | None:
data = await get_settings_by_tag("core")
@@ -69,16 +76,14 @@ async def delete_admin_settings(tag: str | None = "core") -> None:
async def reset_core_settings() -> None:
await db.execute(
"""
DELETE FROM system_settings WHERE tag = 'core'
AND id NOT IN (
'super_user',
'lnbits_webpush_pubkey',
'lnbits_webpush_privkey'
)
""",
)
core_settings = await get_settings_by_tag("core") or {}
super_user = await get_settings_field("super_user")
await delete_admin_settings()
if super_user:
await set_settings_field("super_user", super_user.value)
for field in RESET_PRESERVED_SETTINGS:
if field in core_settings:
await set_settings_field(field, core_settings[field])
async def create_admin_settings(super_user: str, new_settings: dict) -> SuperSettings:
+6
View File
@@ -27,7 +27,13 @@ async def migrate_extension_database(
if is_wasm_extension_id(ext.id):
await migrate_wasm_extension_database(ext, current_version)
return
else:
await migrate_py_extension_database(ext, current_version)
async def migrate_py_extension_database(
ext: InstallableExtension, current_version: DbVersion | None = None
):
try:
ext_migrations = importlib.import_module(f"{ext.module_name}.migrations")
ext_db = importlib.import_module(ext.module_name).db
+28 -22
View File
@@ -6,9 +6,7 @@ import json
import os
import shutil
import zipfile
from asyncio.tasks import create_task
from collections.abc import Mapping
from pathlib import Path, PurePosixPath
from pathlib import Path
from typing import Any
import httpx
@@ -22,6 +20,7 @@ from lnbits.helpers import (
version_parse,
)
from lnbits.settings import settings
from lnbits.task_manager import task_manager
from lnbits.utils.cache import cache
@@ -179,6 +178,8 @@ class Extension(BaseModel):
@property
def is_upgrade_extension(self) -> bool:
if self.is_wasm:
return False
return self.upgrade_hash != ""
@classmethod
@@ -189,19 +190,11 @@ class Extension(BaseModel):
is_wasm=ext_info.is_wasm,
name=ext_info.name,
short_description=ext_info.short_description,
tile=(
wasm_extension_icon_url(ext_info.id)
if ext_info.is_wasm
else ext_info.icon
),
tile=_extension_tile(ext_info),
upgrade_hash=ext_info.hash if ext_info.ext_upgrade_dir.is_dir() else "",
)
def wasm_extension_icon_url(ext_id: str) -> str:
return f"/ext-assets/{ext_id}/assets/icon.png"
class ExtensionRelease(BaseModel):
name: str
version: str
@@ -479,7 +472,7 @@ class InstallableExtension(BaseModel):
try:
with zipfile.ZipFile(self.zip_path, "r") as archive:
config_name = self._archive_config_name(archive.namelist())
config_name = _archive_config_name(archive.namelist())
if not config_name:
return {}
with archive.open(config_name) as config_file:
@@ -489,14 +482,6 @@ class InstallableExtension(BaseModel):
return config if isinstance(config, dict) else {}
@staticmethod
def _archive_config_name(names: list[str]) -> str | None:
for name in names:
path = PurePosixPath(name)
if len(path.parts) == 2 and path.name == "config.json":
return name
return None
def extract_archive(self):
logger.info(f"Extracting extension {self.name} ({self.installed_version}).")
Path(settings.lnbits_extensions_upgrade_path).mkdir(parents=True, exist_ok=True)
@@ -708,7 +693,10 @@ class InstallableExtension(BaseModel):
if cache_value.older_than(10 * 60) or post_refresh_cache:
# refresh cache in background if older than 10 minutes or requested
create_task(cls._refresh_installable_extensions_cache())
task_manager.create_task(
cls._refresh_installable_extensions_cache(),
"refresh_installable_extensions_cache",
)
extension_list = cache_value.value # type: ignore
return extension_list
@@ -919,3 +907,21 @@ def icon_to_github_url(source_repo: str, path: str | None) -> str:
_, _, *rest = path.split("/")
tail = "/".join(rest)
return f"https://github.com/{source_repo}/raw/main/{tail}"
def wasm_extension_icon_url(ext_id: str) -> str:
return f"/ext-assets/{ext_id}/assets/icon.png"
def _extension_tile(ext_info: InstallableExtension) -> str | None:
if ext_info.is_wasm:
return wasm_extension_icon_url(ext_info.id)
return ext_info.icon
def _archive_config_name(names: list[str]) -> str | None:
for name in names:
path = PurePosixPath(name)
if len(path.parts) == 2 and path.name == "config.json":
return name
return None
+1 -1
View File
@@ -71,7 +71,7 @@ async def install_extension(
await update_installed_extension(ext_info)
extension = Extension.from_installable_ext(ext_info)
if extension.is_upgrade_extension and not extension.is_wasm:
if extension.is_upgrade_extension:
# call stop while the old routes are still active
await stop_extension_background_work(ext_info.id)
+2 -5
View File
@@ -20,6 +20,7 @@ from lnbits.fiat.base import (
FiatPaymentSuccessStatus,
)
from lnbits.settings import settings
from lnbits.task_manager import task_manager
async def handle_fiat_payment_confirmation(
@@ -60,11 +61,7 @@ async def check_fiat_status(payment: Payment) -> FiatPaymentStatus:
payment.status = PaymentState.SUCCESS.value
await update_payment(payment)
await handle_fiat_payment_confirmation(payment)
# notify receivers asynchronously
from lnbits.tasks import internal_invoice_queue
await internal_invoice_queue.put(payment.checking_id)
task_manager.internal_invoice_queue.put_nowait(payment)
return fiat_status
+2
View File
@@ -66,6 +66,8 @@ async def check_server_balance_against_node():
async def check_balance_delta_changed():
if settings.notification_balance_delta_threshold_sats <= 0:
return
status = await get_balance_delta()
if settings.latest_balance_delta_sats is None:
settings.latest_balance_delta_sats = status.delta_sats
+9
View File
@@ -237,6 +237,15 @@ async def send_email(
return False
async def dispatch_payment_notification(payment: Payment) -> None:
"""
This worker dispatches the payment notifications.
"""
wallet = await get_wallet(payment.wallet_id)
if wallet:
await send_payment_notification(wallet, payment)
async def dispatch_webhook(payment: Payment):
"""
Dispatches the webhook to the webhook url.
+22 -11
View File
@@ -19,6 +19,7 @@ from lnbits.exceptions import InvoiceError, PaymentError, UnsupportedError
from lnbits.fiat import get_fiat_provider
from lnbits.helpers import check_callback_url
from lnbits.settings import settings
from lnbits.task_manager import task_manager
from lnbits.utils.crypto import fake_privkey, random_secret_and_hash, verify_preimage
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis, satoshis_amount_as_fiat
from lnbits.wallets import fake_wallet, get_funding_source
@@ -516,9 +517,7 @@ async def update_wallet_balance(
)
payment.status = PaymentState.SUCCESS
await update_payment(payment, conn=conn)
from lnbits.tasks import internal_invoice_queue_put
await internal_invoice_queue_put(payment.checking_id)
task_manager.internal_invoice_queue.put_nowait(payment)
async def check_wallet_limits(
@@ -789,10 +788,8 @@ async def _pay_internal_invoice(
) # notify the receiver
# notify receiver asynchronously (extension listeners)
from lnbits.tasks import internal_invoice_queue
logger.debug(f"enqueuing internal invoice {internal_payment.checking_id}")
await internal_invoice_queue.put(internal_payment.checking_id)
task_manager.internal_invoice_queue.put_nowait(internal_payment)
return payment
@@ -829,16 +826,15 @@ async def _pay_external_invoice(
fee_reserve_msat = fee_reserve(amount_msat, internal=False)
from lnbits.tasks import create_task
task = create_task(
_fundingsource_pay_invoice(checking_id, payment.bolt11, fee_reserve_msat)
task = task_manager.create_task(
_fundingsource_pay_invoice(checking_id, payment.bolt11, fee_reserve_msat),
f"fundingsource_pay_invoice_{checking_id}",
)
# make sure a hold invoice or deferred payment is not blocking the server
wait_time = max(1, settings.lnbits_funding_source_pay_invoice_wait_seconds)
try:
payment_response = await asyncio.wait_for(task, timeout=wait_time)
payment_response = await asyncio.wait_for(task.task, timeout=wait_time)
except asyncio.TimeoutError:
# return pending payment on timeout
logger.debug(
@@ -1108,3 +1104,18 @@ async def update_invoice_from_paid_invoices_stream(checking_id: str) -> Payment
payment = await update_payment(payment)
return payment
async def fundingsource_invoice_producer() -> None:
"""
will collect all invoices that come directly from the backend wallet.
Called registered in the app startup sequence and run by taskmanager.
"""
funding_source = get_funding_source()
async for checking_id in funding_source.paid_invoices_stream():
logger.info(f"got a payment notification {checking_id}")
payment = await update_invoice_from_paid_invoices_stream(checking_id)
if payment:
logger.success(f"fundingsource invoice {checking_id} settled")
task_manager.invoice_queue.put_nowait(payment)
+46 -124
View File
@@ -2,11 +2,7 @@ import asyncio
from loguru import logger
from lnbits.core.crud import (
create_audit_entry,
get_wallet,
)
from lnbits.core.crud.audit import delete_expired_audit_entries
from lnbits.core.crud import create_audit_entry
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
@@ -14,66 +10,36 @@ from lnbits.core.db import core_app_extra
from lnbits.core.models.audit import AuditEntry
from lnbits.core.models.extensions import InstallableExtension
from lnbits.core.models.notifications import NotificationType
from lnbits.core.services.funding_source import (
check_balance_delta_changed,
check_server_balance_against_node,
get_balance_delta,
)
from lnbits.core.services.funding_source import get_balance_delta
from lnbits.core.services.notifications import (
enqueue_admin_notification,
process_next_notification,
send_payment_notification,
)
from lnbits.db import Filters
from lnbits.settings import settings
from lnbits.utils.cache import cache
from lnbits.utils.exchange_rates import btc_rates
from lnbits.utils.exchange_rates import btc_price_from_aggregator, btc_rates
audit_queue: asyncio.Queue[AuditEntry] = asyncio.Queue()
async def run_by_the_minute_tasks() -> None:
minute_counter = 0
while settings.lnbits_running:
status_minutes = settings.lnbits_notification_server_status_hours * 60
if settings.notification_balance_delta_threshold_sats > 0:
try:
# runs by default every minute, the delta should not change that often
await check_balance_delta_changed()
except Exception as ex:
logger.error(ex)
if minute_counter % settings.lnbits_watchdog_interval_minutes == 0:
try:
await check_server_balance_against_node()
except Exception as ex:
logger.error(ex)
if minute_counter % status_minutes == 0:
try:
await _notify_server_status()
except Exception as ex:
logger.error(ex)
if minute_counter % 60 == 0:
try:
# initialize the list of all extensions
await InstallableExtension.get_installable_extensions(
post_refresh_cache=True
)
except Exception as ex:
logger.error(ex)
minute_counter += 1
await asyncio.sleep(60)
async def process_next_audit_entry() -> None:
"""
Waits for audit entries to be pushed to the queue.
Then it inserts the entries into the DB.
"""
data = await audit_queue.get()
await create_audit_entry(data)
async def _notify_server_status() -> None:
async def refresh_extension_cache() -> None:
# only refreshes every 10 minutes
await InstallableExtension.get_installable_extensions()
async def notify_server_status() -> None:
accounts = await get_accounts(filters=Filters(limit=0))
wallets_count = await get_wallets_count()
payments = await get_payments_status_count()
status = await get_balance_delta()
values = {
"up_time": settings.lnbits_server_up_time,
@@ -90,82 +56,38 @@ async def _notify_server_status() -> None:
enqueue_admin_notification(NotificationType.server_status, values)
async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue) -> None:
"""
This worker dispatches events to all extensions and dispatches webhooks.
"""
while settings.lnbits_running:
payment = await invoice_paid_queue.get()
logger.trace("received invoice paid event")
# payment notification
wallet = await get_wallet(payment.wallet_id)
if wallet:
await send_payment_notification(wallet, payment)
await core_app_extra.dispatch_extension_invoice_paid(payment)
async def wait_for_audit_data() -> None:
"""
Waits for audit entries to be pushed to the queue.
Then it inserts the entries into the DB.
"""
while settings.lnbits_running:
data = await audit_queue.get()
try:
await create_audit_entry(data)
except Exception as ex:
logger.warning(ex)
await asyncio.sleep(3)
async def wait_notification_messages() -> None:
while settings.lnbits_running:
try:
await process_next_notification()
except Exception as ex:
logger.warning("Payment notification error", ex)
await asyncio.sleep(3)
async def purge_audit_data() -> None:
"""
Remove audit entries which have passed their retention period.
"""
while settings.lnbits_running:
try:
await delete_expired_audit_entries()
except Exception as ex:
logger.warning(ex)
# clean every hour
await asyncio.sleep(60 * 60)
async def collect_exchange_rates_data() -> None:
"""
Collect exchange rates data. Used for monitoring only.
"""
while settings.lnbits_running:
currency = settings.lnbits_default_accounting_currency or "USD"
max_history_size = settings.lnbits_exchange_history_size
sleep_time = settings.lnbits_exchange_history_refresh_interval_seconds
if sleep_time > 0:
try:
rates = await btc_rates(currency)
if rates:
rates_values = [r[1] for r in rates]
lnbits_rate = sum(rates_values) / len(rates_values)
rates.append(("LNbits", lnbits_rate))
cache.set(
f"btc-price-{currency}",
lnbits_rate,
expiry=settings.lnbits_exchange_rate_cache_seconds,
)
settings.append_exchange_rate_datapoint(dict(rates), max_history_size)
except Exception as ex:
logger.warning(ex)
currency = settings.lnbits_default_accounting_currency or "USD"
max_history_size = settings.lnbits_exchange_history_size
try:
if (
settings.lnbits_price_aggregator_enabled
and settings.lnbits_price_aggregator_url
):
price = await btc_price_from_aggregator(currency)
if price:
cache.set(
f"btc-price-{currency}",
price,
expiry=settings.lnbits_exchange_rate_cache_seconds,
)
settings.append_exchange_rate_datapoint(
{"Aggregator": price}, max_history_size
)
else:
sleep_time = 60
await asyncio.sleep(sleep_time)
rates = await btc_rates(currency)
if rates:
rates_values = [r[1] for r in rates]
lnbits_rate = sum(rates_values) / len(rates_values)
rates.append(("LNbits", lnbits_rate))
cache.set(
f"btc-price-{currency}",
lnbits_rate,
expiry=settings.lnbits_exchange_rate_cache_seconds,
)
settings.append_exchange_rate_datapoint(dict(rates), max_history_size)
except Exception as ex:
logger.warning(ex)
+4 -5
View File
@@ -20,7 +20,7 @@ from lnbits.core.services.settings import dict_to_settings
from lnbits.decorators import check_admin, check_super_user
from lnbits.server import server_restart
from lnbits.settings import AdminSettings, Settings, UpdateSettings, settings
from lnbits.tasks import invoice_listeners
from lnbits.task_manager import PublicTask, task_manager
from .. import core_app_extra
from ..crud import get_admin_settings, reset_core_settings, update_admin_settings
@@ -44,11 +44,10 @@ async def api_auditor():
name="Monitor",
description="show the current listeners and other monitoring data",
dependencies=[Depends(check_admin)],
response_model=list[PublicTask],
)
async def api_monitor():
return {
"invoice_listeners": list(invoice_listeners.keys()),
}
async def api_monitor() -> list[PublicTask]:
return task_manager.get_public_tasks()
@admin_router.get(
+2 -2
View File
@@ -9,6 +9,7 @@ from typing import Any
from lnbits.helpers import sha256s
from ..client.extensions import send_extension_api_request
from ..storage.crud import (
storage_delete_row,
storage_get_paginated_rows,
@@ -68,7 +69,7 @@ class ExtensionHostAPI:
self.owner_id = sha256s(user_id) if user_id else owner_id
from .utils import ExtensionAPIUtils
self.utils = ExtensionAPIUtils(self)
self.utils = ExtensionAPIUtils(self.extension_id, self.permissions)
@extension_api_method(
method_id="storage.get",
@@ -425,7 +426,6 @@ class ExtensionHostAPI:
require_auth=True,
)
async def extension_api_request(self, request: ExtensionApiRequest) -> HttpResponse:
from ..client.extensions import send_extension_api_request
policies = self.permission_policies.get("extension.api.request") or []
return await send_extension_api_request(
+29 -23
View File
@@ -1,8 +1,19 @@
from __future__ import annotations
import time
from collections.abc import Iterable
from datetime import datetime
from typing import TYPE_CHECKING, Any
from typing import Any
from lnbits import bolt11
from lnbits.settings import settings
from lnbits.utils.crypto import random_secret_and_hash, verify_preimage
from lnbits.utils.exchange_rates import (
allowed_currencies,
fiat_amount_as_satoshis,
get_fiat_rate_and_price_satoshis,
satoshis_amount_as_fiat,
)
from .models import (
Bolt11Request,
@@ -30,21 +41,28 @@ from .models import (
)
from .registry import extension_api_method
if TYPE_CHECKING:
from .host import ExtensionHostAPI
class ExtensionAPIUtils:
def __init__(self, api: ExtensionHostAPI) -> None:
self.api = api
self.currencies = ExtensionCurrencyUtils(api)
self.server = ExtensionServerUtils(api)
self.lightning = ExtensionLightningUtils(api)
def __init__(self, extension_id: str, permissions: Iterable[str]) -> None:
permission_set = set(permissions)
self.currencies = ExtensionCurrencyUtils(extension_id, permission_set)
self.server = ExtensionServerUtils(extension_id, permission_set)
self.lightning = ExtensionLightningUtils(extension_id, permission_set)
class _ExtensionAPIUtilsGroup:
def __init__(self, api: ExtensionHostAPI) -> None:
self.api = api
def __init__(self, extension_id: str, permissions: Iterable[str]) -> None:
self.extension_id = extension_id
self.permissions = set(permissions)
def require_permission(self, permission: str | None) -> None:
if permission and permission not in self.permissions:
raise PermissionError(
f"Extension '{self.extension_id}' is missing permission '{permission}'."
)
def has_authenticated_context(self) -> bool:
return False
class ExtensionCurrencyUtils(_ExtensionAPIUtilsGroup):
@@ -60,7 +78,6 @@ class ExtensionCurrencyUtils(_ExtensionAPIUtilsGroup):
require_auth=False,
)
async def list(self, request: EmptyRequest) -> CurrencyListResponse:
from lnbits.utils.exchange_rates import allowed_currencies
return CurrencyListResponse(currencies=allowed_currencies())
@@ -76,7 +93,6 @@ class ExtensionCurrencyUtils(_ExtensionAPIUtilsGroup):
require_auth=False,
)
async def rate(self, request: CurrencyRateRequest) -> CurrencyRateResponse:
from lnbits.utils.exchange_rates import get_fiat_rate_and_price_satoshis
rate, price = await get_fiat_rate_and_price_satoshis(request.currency)
return CurrencyRateResponse(rate=rate, price=price)
@@ -93,10 +109,6 @@ class ExtensionCurrencyUtils(_ExtensionAPIUtilsGroup):
require_auth=False,
)
async def convert(self, request: CurrencyConvertRequest) -> CurrencyConvertResponse:
from lnbits.utils.exchange_rates import (
fiat_amount_as_satoshis,
satoshis_amount_as_fiat,
)
from_currency = request.from_currency
if from_currency == "sats":
@@ -135,7 +147,6 @@ class ExtensionCurrencyUtils(_ExtensionAPIUtilsGroup):
require_auth=False,
)
async def fiat_to_sats(self, request: FiatToSatsRequest) -> FiatToSatsResponse:
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
return FiatToSatsResponse(
amount_sat=await fiat_amount_as_satoshis(
@@ -156,7 +167,6 @@ class ExtensionCurrencyUtils(_ExtensionAPIUtilsGroup):
require_auth=False,
)
async def sats_to_fiat(self, request: SatsToFiatRequest) -> SatsToFiatResponse:
from lnbits.utils.exchange_rates import satoshis_amount_as_fiat
return SatsToFiatResponse(
amount=await satoshis_amount_as_fiat(request.amount, request.currency)
@@ -176,7 +186,6 @@ class ExtensionServerUtils(_ExtensionAPIUtilsGroup):
require_auth=False,
)
async def health(self, request: EmptyRequest) -> ServerHealthResponse:
from lnbits.settings import settings
return ServerHealthResponse(
server_time=int(time.time()),
@@ -298,7 +307,6 @@ class ExtensionLightningUtils(_ExtensionAPIUtilsGroup):
async def verify_preimage(
self, request: VerifyPreimageRequest
) -> VerifyPreimageResponse:
from lnbits.utils.crypto import verify_preimage
return VerifyPreimageResponse(
valid=verify_preimage(request.preimage, request.payment_hash)
@@ -318,7 +326,6 @@ class ExtensionLightningUtils(_ExtensionAPIUtilsGroup):
async def random_secret_and_hash(
self, request: RandomSecretAndHashRequest
) -> RandomSecretAndHashResponse:
from lnbits.utils.crypto import random_secret_and_hash
secret, payment_hash = random_secret_and_hash(request.length)
return RandomSecretAndHashResponse(secret=secret, hash=payment_hash)
@@ -333,7 +340,6 @@ def extension_api_utils_method_classes() -> dict[str, type[_ExtensionAPIUtilsGro
def _decode_bolt11(payment_request: str) -> Any:
from lnbits import bolt11
return bolt11.decode(payment_request)
+2 -8
View File
@@ -3,6 +3,8 @@ from __future__ import annotations
from functools import lru_cache
from typing import Any
from wasmtime import Config, Engine
from .loader import WasmExtension
@@ -12,14 +14,6 @@ def warm_wasm_extension(extension: WasmExtension) -> None:
@lru_cache(maxsize=1)
def _wasm_engine() -> Any:
try:
from wasmtime import Config, Engine
except ImportError as exc:
raise RuntimeError(
"WASM extension runtime is not installed. Install the 'wasmtime' "
"Python package to run WASM extensions."
) from exc
config = Config()
config.wasm_component_model = True
return Engine(config)
+2 -4
View File
@@ -4,6 +4,8 @@ from typing import Any
from loguru import logger
from lnbits.core.db import core_app_extra
from lnbits.core.wasm_ext.storage.crud import storage_get_row_owner_id
from lnbits.core.wasm_ext.wasm.invoke import invoke_wasm_extension_export
async def dispatch_wasm_invoice_paid(payment: Any) -> None:
@@ -27,8 +29,6 @@ async def dispatch_wasm_invoice_paid(payment: Any) -> None:
return
try:
from lnbits.core.wasm_ext.wasm.invoke import invoke_wasm_extension_export
await invoke_wasm_extension_export(
extension.id,
export_name,
@@ -58,8 +58,6 @@ async def _wasm_invoice_paid_owner_id(extension: Any, payment: Any) -> str | Non
if not source_id or not source_tables:
return None
from lnbits.core.wasm_ext.storage.crud import storage_get_row_owner_id
for source_table in source_tables:
owner_id = await storage_get_row_owner_id(extension.id, source_table, source_id)
if owner_id:
+2 -1
View File
@@ -5,6 +5,8 @@ import re
from collections.abc import Mapping
from typing import Any
from wasmtime import component
from ..api.models import EmptyRequest
from ..api.registry import list_extension_api_methods
from ..api.runtime import ExtensionAPIHost
@@ -73,7 +75,6 @@ def _component_payload_to_dict(value: Any) -> dict[str, Any]:
def _dict_to_component_record(value: Mapping[str, Any]) -> Any:
from wasmtime import component
record = component.Record()
for key, item in value.items():
+2 -7
View File
@@ -5,6 +5,8 @@ import json
from collections.abc import Mapping
from typing import Any
from wasmtime import Store, WasiConfig, component
from lnbits.core.crud.extensions import get_installed_extension
from lnbits.core.db import core_app_extra
@@ -54,13 +56,6 @@ def _invoke_wasm_extension_export_sync(
api: ExtensionHostAPI,
event_loop: asyncio.AbstractEventLoop,
) -> dict[str, Any]:
try:
from wasmtime import Store, WasiConfig, component
except ImportError as exc:
raise RuntimeError(
"WASM extension runtime is not installed. Install the 'wasmtime' "
"Python package to run WASM extensions."
) from exc
engine = _wasm_engine()
store = Store(engine)
+7 -1
View File
@@ -362,6 +362,8 @@ class ExchangeProvidersSettings(LNbitsSettings):
lnbits_exchange_rate_cache_seconds: int = Field(default=60, ge=0)
lnbits_exchange_history_size: int = Field(default=60, ge=0)
lnbits_exchange_history_refresh_interval_seconds: int = Field(default=300, ge=0)
lnbits_price_aggregator_enabled: bool = Field(default=True)
lnbits_price_aggregator_url: str = Field(default="https://price.lnbits.com")
lnbits_exchange_rate_providers: list[ExchangeRateProvider] = Field(
default=[
@@ -587,6 +589,7 @@ class PhoenixdFundingSource(LNbitsSettings):
phoenixd_api_password: str | None = Field(default=None)
phoenixd_data_dir: str | None = Field(default=None)
phoenixd_mnemonic: str | None = Field(default=None)
phoenixd_mnemonic_backup_confirmed: bool = Field(default=False)
class AlbyFundingSource(LNbitsSettings):
@@ -611,6 +614,7 @@ class SparkL2FundingSource(LNbitsSettings):
spark_l2_external_endpoint: str | None = Field(default="http://localhost:8765")
spark_l2_external_api_key: str | None = Field(default=None)
spark_l2_mnemonic: str | None = Field(default=None)
spark_l2_mnemonic_backup_confirmed: bool = Field(default=False)
spark_l2_pay_wait_ms: int = Field(default=4000, ge=0)
spark_l2_pay_poll_ms: int = Field(default=500, ge=0)
spark_l2_stream_keepalive_ms: int = Field(default=15000, ge=0)
@@ -648,6 +652,7 @@ class BoltzFundingSource(LNbitsSettings):
boltz_client_password: str = Field(default="")
boltz_client_cert: str | None = Field(default=None)
boltz_mnemonic: str | None = Field(default=None)
boltz_mnemonic_backup_confirmed: bool = Field(default=False)
class StrikeFundingSource(LNbitsSettings):
@@ -1072,11 +1077,12 @@ class EnvSettings(LNbitsSettings):
log_rotation: str = Field(default="100 MB")
log_retention: str = Field(default="3 months")
first_install_token: str | None = Field(default=None)
cleanup_wallets_days: int = Field(default=90, ge=0)
funding_source_max_retries: int = Field(default=4, ge=0)
lnbits_max_users: int = Field(default=0, ge=0)
lnbits_max_extensions: int = Field(default=0, ge=0)
task_heart_beat_verbose: bool = Field(default=False)
task_heart_beat_interval: int = Field(default=30)
@property
def has_default_extension_path(self) -> bool:
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
-17
View File
@@ -520,7 +520,6 @@ window.localisation.en = {
extension_cost: 'This release requires a payment of minimum {cost} sats.',
extension_paid_sats: 'You have already paid {paid_sats} sats.',
extension_permissions_title: 'Grant extension permissions',
extension_permissions_request: 'This extension requests these permissions:',
extension_permissions_grant_install: 'Grant and install',
extension_permissions_high_risk_warning:
'This extension requests permissions that can move funds.',
@@ -531,41 +530,25 @@ window.localisation.en = {
'Can spend funds from wallets available to your account.',
extension_permission_warning_extension_api_request_write:
'Can write data or trigger actions in approved extensions.',
extension_permission_warning_http_request:
'Can send data to external services.',
extension_permission_ext_storage_read: 'Read extension storage',
extension_permission_ext_storage_read_public: 'Read public extension storage',
extension_permission_ext_storage_write: 'Write extension storage',
extension_permission_ext_storage_read_write: 'Read & Write extension storage',
extension_permission_extension_api_request: 'Use other extensions',
extension_permission_extension_api_request_desc:
'Call approved installed extensions using your account permissions.',
extension_permission_extension_api_request_extensions: 'Allowed extensions',
extension_permission_access_read: 'Read',
extension_permission_access_write: 'Write',
extension_permission_http_request: 'Connect to external websites',
extension_permission_http_request_desc:
'Make HTTP requests to approved external hosts.',
extension_permission_http_request_hosts: 'Allowed hosts',
extension_permission_utils_basic: 'Use basic LNbits utilities',
extension_permission_utils_basic_desc:
'Use public currency conversion, server health, and Lightning invoice helper functions.',
extension_permission_ui_camera_scan_qr: 'Scan QR codes',
extension_permission_ui_camera_scan_qr_desc:
'Use the LNbits scanner to read QR codes when you choose to scan.',
extension_permission_payments_watch: 'Watch payments',
extension_permission_wallet_create_invoice: 'Create invoices',
extension_permission_wallet_create_invoice_public:
'Create Lightning invoices from public pages',
extension_permission_wallet_create_invoice_public_desc:
'Create incoming Lightning invoices from public pages.',
extension_permission_wallet_balance_read: 'View wallet balances',
extension_permission_wallet_balance_read_desc:
'Read balances of wallets available to your account.',
extension_permission_wallet_list: 'List wallets',
extension_permission_wallet_pay_invoice: 'Pay invoices',
extension_permission_wallet_pay_invoice_desc:
'Send Lightning payments from wallets available to your account.',
create_extension: 'Create Extension',
release_details_error: 'Cannot get the release details.',
pay_from_wallet: 'Pay from Wallet',
@@ -62,12 +62,6 @@ window.app.component('lnbits-admin-exchange-providers', {
mounted() {
this.getExchangeRateHistory()
},
created() {
const hash = window.location.hash.replace('#', '')
if (hash === 'exchange_providers') {
this.showExchangeProvidersTab(hash)
}
},
methods: {
getDefaultSetting(fieldName) {
LNbits.api.getDefaultSetting(fieldName).then(response => {
@@ -127,18 +121,21 @@ window.app.component('lnbits-admin-exchange-providers', {
this.exchangeData.showTickerConversion = true
},
initExchangeChart(data) {
if (this.exchangeRatesChart) {
this.exchangeRatesChart.destroy()
this.exchangeRatesChart = null
}
const xValues = data.map(d =>
this.utils.formatTimestamp(d.timestamp, 'HH:mm')
)
const exchanges = [
...this.formData.lnbits_exchange_rate_providers,
{name: 'LNbits'}
]
const exchanges = this.formData.lnbits_price_aggregator_enabled
? [{name: 'Aggregator'}]
: [...this.formData.lnbits_exchange_rate_providers, {name: 'LNbits'}]
const datasets = exchanges.map(exchange => ({
label: exchange.name,
data: data.map(d => d.rates[exchange.name]),
pointStyle: true,
borderWidth: exchange.name === 'LNbits' ? 4 : 1,
borderWidth: exchange.name === 'LNbits' ? 4 : 2,
tension: 0.4
}))
this.exchangeRatesChart = new Chart(
@@ -148,7 +145,11 @@ window.app.component('lnbits-admin-exchange-providers', {
options: {
plugins: {
legend: {
display: false
display: true
},
title: {
display: true,
text: 'Bitcoin Price History'
}
}
},
@@ -0,0 +1,151 @@
window.app.component('lnbits-admin-funding-seed-backup', {
props: ['active', 'is-super-user', 'form-data', 'settings'],
template: '#lnbits-admin-funding-seed-backup',
data() {
return {
dialog: {
show: false,
step: 1,
seed: '',
visible: false,
challenge: [],
answers: {},
error: '',
confirmField: ''
}
}
},
watch: {
active(isActive) {
if (isActive) {
this.openIfRequired()
}
},
'formData.lnbits_backend_wallet_class'(walletClass, previousWalletClass) {
const source = this.seedBackupSource(walletClass)
if (previousWalletClass && source && this.formData[source.seedField]) {
this.formData[source.confirmField] = false
}
this.openIfRequired()
},
'formData.boltz_mnemonic'() {
this.formData.boltz_mnemonic_backup_confirmed =
this.formData.boltz_mnemonic === this.settings.boltz_mnemonic
? this.settings.boltz_mnemonic_backup_confirmed
: false
this.openIfRequired()
},
'formData.phoenixd_mnemonic'() {
this.formData.phoenixd_mnemonic_backup_confirmed =
this.formData.phoenixd_mnemonic === this.settings.phoenixd_mnemonic
? this.settings.phoenixd_mnemonic_backup_confirmed
: false
this.openIfRequired()
},
'formData.spark_l2_mnemonic'() {
this.formData.spark_l2_mnemonic_backup_confirmed =
this.formData.spark_l2_mnemonic === this.settings.spark_l2_mnemonic
? this.settings.spark_l2_mnemonic_backup_confirmed
: false
this.openIfRequired()
}
},
computed: {
seedWords() {
return this.dialog.seed
.split(/\s+/)
.filter(Boolean)
.map((word, index) => ({index, word}))
}
},
created() {
this.openIfRequired()
},
methods: {
seedBackupSource(walletClass = this.formData.lnbits_backend_wallet_class) {
if (walletClass === 'BoltzWallet') {
return {
seedField: 'boltz_mnemonic',
confirmField: 'boltz_mnemonic_backup_confirmed'
}
}
if (walletClass === 'PhoenixdWallet') {
return {
seedField: 'phoenixd_mnemonic',
confirmField: 'phoenixd_mnemonic_backup_confirmed'
}
}
if (walletClass === 'SparkL2Wallet') {
return {
seedField: 'spark_l2_mnemonic',
confirmField: 'spark_l2_mnemonic_backup_confirmed'
}
}
},
openIfRequired() {
if (!this.active || !this.isSuperUser) return
const source = this.seedBackupSource()
if (!source) return
const seed = (this.formData[source.seedField] || '').trim()
const confirmed = this.formData[source.confirmField]
if (!seed || confirmed || this.dialog.show) return
this.dialog = {
show: true,
step: 1,
seed,
visible: false,
challenge: [],
answers: {},
error: '',
confirmField: source.confirmField
}
},
prepareChallenge() {
const words = this.dialog.seed.split(/\s+/).filter(Boolean)
const count = Math.min(4, words.length)
const indexes = _.shuffle([...Array(words.length).keys()]).slice(0, count)
this.dialog.challenge = indexes
.sort((a, b) => a - b)
.map(index => ({index, word: words[index]}))
this.dialog.answers = {}
this.dialog.error = ''
this.dialog.step = 2
},
submitChallenge() {
const isValid = this.dialog.challenge.every(({index, word}) => {
const answer = this.dialog.answers[index] || ''
return answer.trim().toLowerCase() === word.toLowerCase()
})
if (!isValid) {
this.dialog.error =
'One or more words are incorrect. Check your backup and try again.'
return
}
const field = this.dialog.confirmField
LNbits.api
.request(
'PATCH',
'/admin/api/v1/settings',
this.g.user.wallets[0].adminkey,
{
[field]: true
}
)
.then(() => {
this.formData[field] = true
this.settings[field] = true
this.dialog.show = false
Quasar.Notify.create({
type: 'positive',
message: 'Seed backup confirmed',
icon: 'check'
})
})
.catch(LNbits.utils.notifyApiError)
}
}
})
@@ -1,5 +1,5 @@
window.app.component('lnbits-admin-funding', {
props: ['is-super-user', 'form-data', 'settings'],
props: ['active', 'is-super-user', 'form-data', 'settings'],
template: '#lnbits-admin-funding',
data() {
return {
@@ -0,0 +1,324 @@
;(function () {
function translate(translateFn, key) {
return translateFn ? translateFn(key) : key
}
function permissionI18nKey(permission) {
return `extension_permission_${permission.id.replace(/[^A-Za-z0-9]/g, '_')}`
}
function permissionLabel(permission, translateFn) {
const key = permissionI18nKey(permission)
const label = translate(translateFn, key)
return label === key ? permission.id : label
}
function permissionManifestDescription(permission) {
return typeof permission.description === 'string'
? permission.description
: ''
}
function lowRisk(translateFn) {
return {
level: 'low',
color: 'grey-6',
label: translate(translateFn, 'extension_permission_risk_low'),
warning: ''
}
}
function mediumRisk(translateFn) {
return {
level: 'medium',
color: 'warning',
label: translate(translateFn, 'extension_permission_risk_medium'),
warning: ''
}
}
function highRisk(translateFn, warningKey) {
return {
level: 'high',
color: 'negative',
label: translate(translateFn, 'extension_permission_risk_high'),
warning: translate(translateFn, warningKey)
}
}
function extensionDisplayName(extensions, extensionId) {
const extension = (extensions || []).find(
extension => extension.id === extensionId
)
return extension?.name || extensionId
}
function extensionApiPermissionTargets(permission, extensions) {
const extensionPolicies = permission.policies
if (!Array.isArray(extensionPolicies)) return []
return extensionPolicies
.map(extension => {
const extensionId =
typeof extension === 'string' ? extension : extension?.id
if (!extensionId) return null
const access =
typeof extension === 'string'
? ['read']
: Array.isArray(extension.access) && extension.access.length
? extension.access
: ['read']
return {
id: extensionId,
name: extensionDisplayName(extensions, extensionId),
access
}
})
.filter(Boolean)
}
function permissionRiskForPermission(permission, extensions, translateFn) {
if (permission.id === 'wallet.pay_invoice') {
return highRisk(
translateFn,
'extension_permission_warning_wallet_pay_invoice'
)
}
if (permission.id === 'extension.api.request') {
const hasWriteAccess = extensionApiPermissionTargets(
permission,
extensions
).some(target => target.access.includes('write'))
return hasWriteAccess
? highRisk(
translateFn,
'extension_permission_warning_extension_api_request_write'
)
: mediumRisk(translateFn)
}
if (permission.id === 'http.request') {
return mediumRisk(translateFn)
}
if (
[
'wallet.list',
'wallet.balance.read',
'wallet.create_invoice_public',
'ext.storage.read_public',
'payments.watch'
].includes(permission.id)
) {
return mediumRisk(translateFn)
}
return lowRisk(translateFn)
}
function permissionRisk(permissions, extensions, translateFn) {
const risks = permissions.map(permission =>
permissionRiskForPermission(permission, extensions, translateFn)
)
const highestRisk = risks.find(risk => risk.level === 'high')
if (highestRisk) return highestRisk
return risks.find(risk => risk.level === 'medium') || lowRisk(translateFn)
}
function permissionOrderIndex(permissionId) {
const order = [
'wallet.pay_invoice',
'wallet.list',
'wallet.balance.read',
'extension.api.request',
'http.request',
'ui.camera.scan_qr',
'ext.storage.read',
'ext.storage.write',
'ext.storage.read_public',
'wallet.create_invoice_public',
'wallet.create_invoice',
'utils.basic'
]
const index = order.indexOf(permissionId)
return index === -1 ? order.length : index
}
function publicStorageFieldGroups(permission) {
const tables = permission.policies
if (!Array.isArray(tables)) return []
return tables
.map(table => {
const tableName =
typeof table === 'string' ? table : table?.table_name || ''
const fields =
typeof table === 'string' || !Array.isArray(table?.public_fields)
? []
: table.public_fields.filter(
field => typeof field === 'string' && field
)
return tableName ? {table: tableName, fields} : null
})
.filter(Boolean)
}
function httpRequestPermissionHosts(permission) {
const hosts = permission.policies
if (!Array.isArray(hosts)) return []
return hosts
.map(host => (typeof host === 'string' ? host : host?.host || ''))
.filter(host => typeof host === 'string' && host)
}
function publicInvoicePolicies(permission) {
const policies = permission.policies
if (!Array.isArray(policies)) return []
return policies
.map(policy => {
if (!policy || typeof policy !== 'object') return null
const table = policy.table
const walletField = policy.wallet_field
if (typeof table !== 'string' || !table) return null
if (typeof walletField !== 'string' || !walletField) return null
return {table, walletField}
})
.filter(Boolean)
}
function permissionDisplayItem(permissions, extensions, translateFn) {
const permission = permissions[0]
const isReadWriteStorage =
permissions.length === 2 &&
permissions.some(permission => permission.id === 'ext.storage.read') &&
permissions.some(permission => permission.id === 'ext.storage.write')
const descriptions = permissions
.map(permission => permissionManifestDescription(permission))
.filter(Boolean)
const item = {
id: isReadWriteStorage ? 'ext.storage.read_write' : permission.id,
label: isReadWriteStorage
? translate(translateFn, 'extension_permission_ext_storage_read_write')
: permissionLabel(permission, translateFn),
risk: permissionRisk(permissions, extensions, translateFn),
badges: [],
descriptions,
fieldGroups: [],
invoicePolicies: [],
extensionAccess: [],
httpHosts: []
}
if (permission.id === 'ext.storage.read_public') {
item.fieldGroups = publicStorageFieldGroups(permission)
item.badges = item.fieldGroups.map(group => ({
key: group.table,
label: group.table
}))
}
if (permission.id === 'extension.api.request') {
item.extensionAccess = extensionApiPermissionTargets(
permission,
extensions
)
item.badges = item.extensionAccess.map(target => ({
key: target.id,
label: target.name
}))
}
if (permission.id === 'http.request') {
item.httpHosts = httpRequestPermissionHosts(permission)
}
if (permission.id === 'wallet.create_invoice_public') {
item.invoicePolicies = publicInvoicePolicies(permission)
}
return item
}
function displayItems({permissions, extensions, translate}) {
const permissionList = permissions || []
const permissionsById = new Map(
permissionList.map(permission => [permission.id, permission])
)
const hasReadWriteStorage =
permissionsById.has('ext.storage.read') &&
permissionsById.has('ext.storage.write')
let addedReadWriteStorage = false
return permissionList
.map((permission, index) => {
if (
hasReadWriteStorage &&
['ext.storage.read', 'ext.storage.write'].includes(permission.id)
) {
if (addedReadWriteStorage) return null
addedReadWriteStorage = true
return {
index,
orderId: 'ext.storage.read',
permissions: [
permissionsById.get('ext.storage.read'),
permissionsById.get('ext.storage.write')
]
}
}
return {
index,
orderId: permission.id,
permissions: [permission]
}
})
.filter(Boolean)
.sort((left, right) => {
const leftOrder = permissionOrderIndex(left.orderId)
const rightOrder = permissionOrderIndex(right.orderId)
return leftOrder === rightOrder
? left.index - right.index
: leftOrder - rightOrder
})
.map(group =>
permissionDisplayItem(group.permissions, extensions || [], translate)
)
}
window.LNbitsExtensionPermissions = {
displayItems,
hasHighRisk({permissions, extensions, translate}) {
return displayItems({permissions, extensions, translate}).some(
permission => permission.risk.level === 'high'
)
}
}
window.app.component('lnbits-extension-permissions', {
template: '#lnbits-extension-permissions',
props: {
permissions: {
type: Array,
default: () => []
},
extensions: {
type: Array,
default: () => []
}
},
computed: {
displayItems() {
return window.LNbitsExtensionPermissions.displayItems({
permissions: this.permissions,
extensions: this.extensions,
translate: key => this.$t(key)
})
}
},
methods: {
publicInvoicePolicySentence(policy) {
return `Invoices will be created using ${policy.walletField} from ${policy.table}.`
},
permissionAccessLabel(access) {
const key = `extension_permission_access_${access}`
const label = this.$t(key)
return label === key ? access : label
}
}
})
})()
+5 -262
View File
@@ -725,268 +725,11 @@ window.PageExtensions = {
}
},
permissionGrantHasHighRisk() {
return this.permissionGrantDisplayItems().some(
permission => permission.risk.level === 'high'
)
},
permissionGrantDisplayItems() {
const permissions = this.permissionGrant.permissions || []
const permissionsById = new Map(
permissions.map(permission => [permission.id, permission])
)
const hasReadWriteStorage =
permissionsById.has('ext.storage.read') &&
permissionsById.has('ext.storage.write')
let addedReadWriteStorage = false
return permissions
.map((permission, index) => {
if (
hasReadWriteStorage &&
['ext.storage.read', 'ext.storage.write'].includes(permission.id)
) {
if (addedReadWriteStorage) return null
addedReadWriteStorage = true
return {
index,
orderId: 'ext.storage.read',
permissions: [
permissionsById.get('ext.storage.read'),
permissionsById.get('ext.storage.write')
]
}
}
return {
index,
orderId: permission.id,
permissions: [permission]
}
})
.filter(Boolean)
.sort((left, right) => {
const leftOrder = this.permissionOrderIndex(left.orderId)
const rightOrder = this.permissionOrderIndex(right.orderId)
return leftOrder === rightOrder
? left.index - right.index
: leftOrder - rightOrder
})
.map(group => this.permissionDisplayItem(group.permissions))
},
permissionDisplayItem(permissions) {
const permission = permissions[0]
const isReadWriteStorage =
permissions.length === 2 &&
permissions.some(permission => permission.id === 'ext.storage.read') &&
permissions.some(permission => permission.id === 'ext.storage.write')
const descriptions = permissions
.map(permission => this.permissionManifestDescription(permission))
.filter(Boolean)
const item = {
id: isReadWriteStorage ? 'ext.storage.read_write' : permission.id,
label: isReadWriteStorage
? this.$t('extension_permission_ext_storage_read_write')
: this.permissionLabel(permission),
risk: this.permissionRisk(permissions),
badges: [],
descriptions,
fieldGroups: [],
invoicePolicies: [],
extensionAccess: [],
httpHosts: []
}
if (permission.id === 'ext.storage.read_public') {
item.fieldGroups = this.publicStorageFieldGroups(permission)
item.badges = item.fieldGroups.map(group => ({
key: group.table,
label: group.table
}))
}
if (permission.id === 'extension.api.request') {
item.extensionAccess = this.extensionApiPermissionTargets(permission)
item.badges = item.extensionAccess.map(target => ({
key: target.id,
label: target.name
}))
}
if (permission.id === 'http.request') {
item.httpHosts = this.httpRequestPermissionHosts(permission)
}
if (permission.id === 'wallet.create_invoice_public') {
item.invoicePolicies = this.publicInvoicePolicies(permission)
}
return item
},
permissionRisk(permissions) {
const risks = permissions.map(permission =>
this.permissionRiskForPermission(permission)
)
const highestRisk = risks.find(risk => risk.level === 'high')
if (highestRisk) return highestRisk
return risks.find(risk => risk.level === 'medium') || this.lowRisk()
},
permissionRiskForPermission(permission) {
if (permission.id === 'wallet.pay_invoice') {
return this.highRisk('extension_permission_warning_wallet_pay_invoice')
}
if (permission.id === 'extension.api.request') {
const hasWriteAccess = this.extensionApiPermissionTargets(
permission
).some(target => target.access.includes('write'))
return hasWriteAccess
? this.highRisk(
'extension_permission_warning_extension_api_request_write'
)
: this.mediumRisk()
}
if (permission.id === 'http.request') {
return this.mediumRisk()
}
if (
[
'wallet.list',
'wallet.balance.read',
'wallet.create_invoice_public',
'ext.storage.read_public',
'payments.watch'
].includes(permission.id)
) {
return this.mediumRisk()
}
return this.lowRisk()
},
lowRisk() {
return {
level: 'low',
color: 'grey-6',
label: this.$t('extension_permission_risk_low'),
warning: ''
}
},
mediumRisk() {
return {
level: 'medium',
color: 'warning',
label: this.$t('extension_permission_risk_medium'),
warning: ''
}
},
highRisk(warningKey) {
return {
level: 'high',
color: 'negative',
label: this.$t('extension_permission_risk_high'),
warning: this.$t(warningKey)
}
},
permissionOrderIndex(permissionId) {
const order = [
'wallet.pay_invoice',
'wallet.list',
'wallet.balance.read',
'extension.api.request',
'http.request',
'ui.camera.scan_qr',
'ext.storage.read',
'ext.storage.write',
'ext.storage.read_public',
'wallet.create_invoice_public',
'wallet.create_invoice',
'utils.basic'
]
const index = order.indexOf(permissionId)
return index === -1 ? order.length : index
},
publicStorageFieldGroups(permission) {
const tables = permission.policies
if (!Array.isArray(tables)) return []
return tables
.map(table => {
const tableName =
typeof table === 'string' ? table : table?.table_name || ''
const fields =
typeof table === 'string' || !Array.isArray(table?.public_fields)
? []
: table.public_fields.filter(
field => typeof field === 'string' && field
)
return tableName ? {table: tableName, fields} : null
})
.filter(Boolean)
},
httpRequestPermissionHosts(permission) {
const hosts = permission.policies
if (!Array.isArray(hosts)) return []
return hosts
.map(host => (typeof host === 'string' ? host : host?.host || ''))
.filter(host => typeof host === 'string' && host)
},
publicInvoicePolicies(permission) {
const policies = permission.policies
if (!Array.isArray(policies)) return []
return policies
.map(policy => {
if (!policy || typeof policy !== 'object') return null
const table = policy.table
const walletField = policy.wallet_field
if (typeof table !== 'string' || !table) return null
if (typeof walletField !== 'string' || !walletField) return null
return {table, walletField}
})
.filter(Boolean)
},
publicInvoicePolicySentence(policy) {
return `Invoices will be created using ${policy.walletField} from ${policy.table}.`
},
extensionApiPermissionTargets(permission) {
const extensions = permission.policies
if (!Array.isArray(extensions)) return []
return extensions
.map(extension => {
const extensionId =
typeof extension === 'string' ? extension : extension?.id
if (!extensionId) return null
const access =
typeof extension === 'string'
? ['read']
: Array.isArray(extension.access) && extension.access.length
? extension.access
: ['read']
return {
id: extensionId,
name: this.extensionDisplayName(extensionId),
access
}
})
.filter(Boolean)
},
extensionDisplayName(extensionId) {
const extension = (this.extensions || []).find(
extension => extension.id === extensionId
)
return extension?.name || extensionId
},
permissionAccessLabel(access) {
const key = `extension_permission_access_${access}`
const label = this.$t(key)
return label === key ? access : label
},
permissionManifestDescription(permission) {
return typeof permission.description === 'string'
? permission.description
: ''
},
permissionI18nKey(permission) {
return `extension_permission_${permission.id.replace(/[^A-Za-z0-9]/g, '_')}`
},
permissionLabel(permission) {
const key = this.permissionI18nKey(permission)
const label = this.$t(key)
return label === key ? permission.id : label
return window.LNbitsExtensionPermissions.hasHighRisk({
permissions: this.permissionGrant.permissions,
extensions: this.extensions,
translate: key => this.$t(key)
})
},
async selectAllUpdatableExtensionss() {
this.updatableExtensions.forEach(e => (e.selectedForUpdate = true))
+33 -12
View File
@@ -414,12 +414,19 @@ window.PageWallet = {
switch (action.tag) {
case 'url':
Quasar.Notify.create({
message: `<a target="_blank" style="color: inherit" href="${action.url}">${action.url}</a>`,
message: action.url,
caption: action.description,
html: true,
html: false,
type: 'positive',
timeout: 0,
closeBtn: true
closeBtn: true,
actions: [
{
label: 'Open link',
color: 'white',
handler: () => this.utils.openUrlInNewTab(action.url)
}
]
})
break
case 'message':
@@ -431,15 +438,29 @@ window.PageWallet = {
})
break
case 'aes':
this.utils.decryptLnurlPayAES(action, response.data.preimage)
Quasar.Notify.create({
message: value,
caption: extra.success_action.description,
html: true,
type: 'positive',
timeout: 0,
closeBtn: true
})
this.utils
.decryptLnurlPayAES(action, response.data.preimage)
.then(value => {
Quasar.Notify.create({
message: value,
caption: action.description,
html: false,
type: 'positive',
timeout: 0,
closeBtn: true
})
})
.catch(error => {
Quasar.Notify.create({
message: action.description || 'Payment successful.',
caption: 'Could not decrypt success action.',
html: false,
type: 'warning',
timeout: 0,
closeBtn: true
})
})
break
}
}
})
+22
View File
@@ -365,5 +365,27 @@ window._lnbitsUtils = {
let decoder = new TextDecoder('utf-8')
return decoder.decode(valueb)
})
},
validateBrowsableUrl(urlString, allowLoopback = false) {
const url = new URL(urlString)
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new Error('Invalid protocol')
}
if (!allowLoopback) {
const host = url.hostname
if (
host === 'localhost' ||
host === '[::1]' ||
host === '::1' ||
host.startsWith('127.') ||
host.startsWith('::ffff:127.')
) {
throw new Error('Loopback addresses are not allowed')
}
}
},
openUrlInNewTab(urlString, allowLoopback = false) {
this.validateBrowsableUrl(urlString, allowLoopback)
window.open(urlString, '_blank', 'noopener,noreferrer')
}
}
+2
View File
@@ -58,6 +58,7 @@
"js/pages/users.js",
"js/pages/account.js",
"js/pages/admin.js",
"js/components/admin/lnbits-admin-funding-seed-backup.js",
"js/components/admin/lnbits-admin-funding.js",
"js/components/admin/lnbits-admin-funding-sources.js",
"js/components/admin/lnbits-admin-fiat-providers.js",
@@ -89,6 +90,7 @@
"js/components/lnbits-theme.js",
"js/components/lnbits-qrcode-scanner.js",
"js/components/lnbits-manage-extension-list.js",
"js/components/lnbits-extension-permissions.js",
"js/components/lnbits-manage-wallet-list.js",
"js/components/lnbits-language-dropdown.js",
"js/components/lnbits-payment-list.js",
+200
View File
@@ -0,0 +1,200 @@
import asyncio
import traceback
import uuid
from collections.abc import Callable, Coroutine
from datetime import datetime, timezone
from loguru import logger
from pydantic import BaseModel
from lnbits.core.models import Payment
from lnbits.settings import settings
class PublicTask(BaseModel):
"""Public model used to expose task information via the API."""
name: str
created_at: datetime
class Task:
"""Model used on the backend to keep track of background tasks."""
coro: Coroutine
name: str
created_at: datetime
task: asyncio.Task
invoice_queue: asyncio.Queue[Payment] | None = None
def __init__(
self,
coro: Coroutine,
name: str | None = None,
invoice_queue: asyncio.Queue | None = None,
) -> None:
self.coro = coro
self.name = name or f"task_{uuid.uuid4()}"
self.created_at = datetime.now(timezone.utc)
self.task = asyncio.create_task(self.coro, name=self.name)
self.invoice_queue = invoice_queue
class TaskManager:
"""Singleton class to manage background tasks."""
tasks: list[Task] = []
invoice_queue: asyncio.Queue[Payment] = asyncio.Queue()
internal_invoice_queue: asyncio.Queue[Payment] = asyncio.Queue()
def init(self) -> None:
self.create_permanent_task(
func=self._heart_beat,
interval=settings.task_heart_beat_interval,
)
self.create_permanent_task(self._invoice_listener_consumer)
self.create_permanent_task(self._internal_invoice_listener_consumer)
def get_task(self, name: str) -> Task | None:
"""Get a running task by name."""
for task in self.tasks:
if task.name == name:
return task
return None
def get_public_tasks(self) -> list[PublicTask]:
"""Get a list of public tasks."""
return [PublicTask(name=t.name, created_at=t.created_at) for t in self.tasks]
def cancel_task(self, task: Task) -> None:
"""Cancel a running task."""
self.tasks.remove(task)
try:
task.task.cancel()
except Exception as exc:
logger.warning(f"error while cancelling task `{task.name}`: {exc!s}")
def cancel_all_tasks(self) -> None:
"""Cancel all running tasks."""
for task in list(self.tasks):
self.cancel_task(task)
def create_task(
self,
coro: Coroutine,
name: str | None = None,
invoice_queue: asyncio.Queue | None = None,
) -> Task:
"""Create a task. If a task with the same name exists, it will be cancelled."""
if name:
task = self.get_task(name)
if task:
self.cancel_task(task)
task = Task(coro=coro, name=name, invoice_queue=invoice_queue)
self.tasks.append(task)
return task
def create_permanent_task(
self,
func: Callable[[], Coroutine],
invoice_queue: asyncio.Queue | None = None,
name: str | None = None,
interval: int = 0,
) -> Task:
"""Create a task that runs forever and restarts on failure."""
async def wrapper():
while settings.lnbits_running:
await self._catch_everything_and_restart(func)
if interval > 0:
await asyncio.sleep(interval)
return self.create_task(
coro=wrapper(), name=name or func.__name__, invoice_queue=invoice_queue
)
def register_invoice_listener(
self,
func: Callable[[Payment], Coroutine],
name: str | None = None,
) -> Task:
"""
A method intended for extensions to call when they want to be notified about
incoming payments. Will call provided Coroutine with the updated payment.
"""
name = f"{name or uuid.uuid4()}_invoice_listener"
queue: asyncio.Queue[Payment] = asyncio.Queue()
return self.create_permanent_task(
self._invoice_listener_worker(func, queue),
name=name,
invoice_queue=queue,
)
async def _heart_beat(self) -> None:
"""A heartbeat that removes done tasks logs the number of tasks."""
for task in self.tasks:
state = task.task._state if task.task else "NOT RUNNING"
if settings.task_heart_beat_verbose:
logger.debug(
f"Task Manager: `{task.name}` state: `{state}` "
f"created: {task.created_at.strftime('%Y-%m-%d %H:%M:%S')}`"
)
if task.task and task.task.done():
logger.debug(f"Task Manager: task `{task.name}` is done.")
self.cancel_task(task)
listeners_count = sum(1 for task in self.tasks if task.invoice_queue)
logger.debug(
f"Task Manager: {len(self.tasks) - listeners_count} tasks "
f"and {listeners_count} invoice listeners."
)
async def _catch_everything_and_restart(
self,
func: Callable[[], Coroutine],
restart_interval: int = 5,
) -> None:
"""Catches all exceptions from a function and restarts it after 5 seconds."""
while settings.lnbits_running:
try:
return await func()
except asyncio.CancelledError:
raise # because we must pass this up
except Exception as exc:
if not settings.lnbits_running:
return
logger.error(f"exception in background task `{func.__name__}`:", exc)
logger.error(traceback.format_exc())
logger.info(
f"`{func.__name__}` restarts in {restart_interval} seconds."
)
await asyncio.sleep(restart_interval)
def _invoice_listener_worker(
self, func: Callable[[Payment], Coroutine], queue: asyncio.Queue[Payment]
) -> Callable:
async def wrapper() -> None:
payment: Payment = await queue.get()
await func(payment)
return wrapper
def _invoice_dispatcher(self, payment: Payment) -> None:
"""Dispatches a payment to all registered invoice listeners."""
for task in self.tasks:
if not task.invoice_queue:
continue
logger.debug(f"Enqueing payment to task {task.name}")
task.invoice_queue.put_nowait(payment)
async def _invoice_listener_consumer(self) -> None:
payment = await self.invoice_queue.get()
logger.info(f"got a payment notification {payment.checking_id}")
self._invoice_dispatcher(payment)
async def _internal_invoice_listener_consumer(self) -> None:
payment = await self.internal_invoice_queue.get()
logger.info(f"got an internal payment notification {payment.checking_id}")
self._invoice_dispatcher(payment)
task_manager = TaskManager()
+33 -120
View File
@@ -1,146 +1,83 @@
import asyncio
import traceback
import uuid
from collections.abc import Callable, Coroutine
from loguru import logger
from lnbits.core.models import Payment
from lnbits.core.services.payments import (
get_standalone_payment,
update_invoice_from_paid_invoices_stream,
)
from lnbits.core.services.payments import get_standalone_payment
from lnbits.settings import settings
from lnbits.wallets import get_funding_source
tasks: list[asyncio.Task] = []
unique_tasks: dict[str, asyncio.Task] = {}
from lnbits.task_manager import task_manager
# DEPRECATED: use task_manager.create_task instead.
def create_task(coro: Coroutine) -> asyncio.Task:
task = asyncio.create_task(coro)
tasks.append(task)
return task
logger.debug("DEPRECATED: use task_manager.create_task instead.")
return task_manager.create_task(coro).task
# DEPRECATED: use task_manager.create_task with `name` kwarg.
def create_unique_task(name: str, coro: Coroutine) -> asyncio.Task:
if unique_tasks.get(name):
logger.warning(f"task `{name}` already exists, cancelling it")
try:
unique_tasks[name].cancel()
except Exception as exc:
logger.warning(f"error while cancelling task `{name}`: {exc!s}")
task = asyncio.create_task(coro)
unique_tasks[name] = task
return task
logger.debug("DEPRECATED: use task_manager.create_task instead.")
return task_manager.create_task(coro, name=name).task
# DEPRECATED: use task_manager.create_permanent_task instead.
def create_permanent_task(func: Callable[[], Coroutine]) -> asyncio.Task:
return create_task(catch_everything_and_restart(func))
logger.debug("DEPRECATED: use task_manager.create_permanent_task instead.")
return task_manager.create_permanent_task(func).task
# DEPRECATED: use task_manager.create_permanent_task with `name` argument instead.
def create_permanent_unique_task(
name: str, coro: Callable[[], Coroutine]
) -> asyncio.Task:
return create_unique_task(name, catch_everything_and_restart(coro, name))
def cancel_all_tasks() -> None:
for task in tasks:
try:
task.cancel()
except Exception as exc:
logger.warning(f"error while cancelling task: {exc!s}")
for name, task in unique_tasks.items():
try:
task.cancel()
except Exception as exc:
logger.warning(f"error while cancelling task `{name}`: {exc!s}")
# DEPRECATED don't use this, use task_manager.create_permanent_task instead.
async def catch_everything_and_restart(
func: Callable[[], Coroutine],
name: str = "unnamed",
) -> Coroutine:
try:
return await func()
except asyncio.CancelledError:
raise # because we must pass this up
except Exception as exc:
logger.error(f"exception in background task `{name}`:", exc)
logger.error(traceback.format_exc())
logger.error("will restart the task in 5 seconds.")
await asyncio.sleep(5)
return await catch_everything_and_restart(func, name)
) -> None:
_ = name
return await task_manager._catch_everything_and_restart(func)
invoice_listeners: dict[str, asyncio.Queue] = {}
# TODO: name should not be optional
# some extensions still dont use a name, but they should
def register_invoice_listener(send_chan: asyncio.Queue, name: str | None = None):
"""
A method intended for extensions (and core/tasks.py) to call when they want to be
notified about new invoice payments incoming. Will emit all incoming payments.
DEPRECATED: use task_manager.register_invoice_listener instead,
which also allows to pass a callback instead of a queue.
This method will still work but it is not recommended for new code.
"""
if not name:
# fallback to a random name if extension didn't provide one
name = f"no_name_{str(uuid.uuid4())[:8]}"
logger.debug("DEPRECATED: use task_manager.register_invoice_listener instead.")
name = f"forward_{name or str(uuid.uuid4())[:8]}"
if invoice_listeners.get(name):
logger.warning(f"invoice listener `{name}` already exists, replacing it")
# here we just forwarding the payments to the provided queue
async def forward_queue(payment: Payment):
send_chan.put_nowait(payment)
logger.trace(f"registering invoice listener `{name}`")
invoice_listeners[name] = send_chan
internal_invoice_queue: asyncio.Queue = asyncio.Queue(0)
task_manager.register_invoice_listener(forward_queue, name=name)
async def internal_invoice_queue_put(checking_id: str) -> None:
"""
DEPRECATED: use task_manager.internal_invoice_queue instead,
A method to call when it wants to notify about an internal invoice payment.
"""
await internal_invoice_queue.put(checking_id)
async def internal_invoice_listener() -> None:
"""
internal_invoice_queue will be filled directly in core/services.py
after the payment was deemed to be settled internally.
Called by the app startup sequence.
"""
while settings.lnbits_running:
checking_id = await internal_invoice_queue.get()
logger.info(f"got an internal payment notification {checking_id}")
payment = await get_standalone_payment(checking_id, incoming=True)
if payment:
logger.success(f"internal invoice {checking_id} settled")
await invoice_callback_dispatcher(payment)
async def invoice_listener() -> None:
"""
invoice_listener will collect all invoices that come directly
from the backend wallet.
Called by the app startup sequence.
"""
funding_source = get_funding_source()
async for checking_id in funding_source.paid_invoices_stream():
logger.info(f"got a payment notification {checking_id}")
payment = await update_invoice_from_paid_invoices_stream(checking_id)
if payment:
logger.success(f"fundingsource invoice {checking_id} settled")
await invoice_callback_dispatcher(payment)
payment = await get_standalone_payment(checking_id, incoming=True)
if not payment:
logger.warning(f"internal_invoice_queue_put: payment {checking_id} not found")
return
await task_manager.internal_invoice_queue.put(payment)
# DEPRECATED use task_manager.register_invoice_listener(coro, name="myext")
def wait_for_paid_invoices(
invoice_listener_name: str,
func: Callable[[Payment], Coroutine],
) -> Callable[[], Coroutine]:
logger.debug("DEPRECATED: use task_manager.register_invoice_listener instead.")
async def wrapper() -> None:
invoice_queue: asyncio.Queue = asyncio.Queue()
@@ -150,27 +87,3 @@ def wait_for_paid_invoices(
await func(payment)
return wrapper
def run_interval(
interval_seconds: int,
func: Callable[[], Coroutine],
) -> Callable[[], Coroutine]:
"""Run a function at a specified interval in seconds, while the server is running"""
async def wrapper() -> None:
while settings.lnbits_running:
try:
await func()
except Exception as e:
logger.error(f"Error occurred in interval task: {e}")
logger.warning(traceback.format_exc())
await asyncio.sleep(interval_seconds)
return wrapper
async def invoice_callback_dispatcher(payment: Payment):
for name, send_chan in invoice_listeners.items():
logger.trace(f"invoice listeners: sending to `{name}`")
await send_chan.put(payment)
+3 -1
View File
@@ -1,4 +1,5 @@
{% include('components/admin/funding.vue') %} {%
{% include('components/admin/funding_seed_backup.vue') %} {%
include('components/admin/funding.vue') %} {%
include('components/admin/funding_sources.vue') %} {%
include('components/admin/fiat_providers.vue') %} {%
include('components/admin/exchange_providers.vue') %} {%
@@ -19,6 +20,7 @@ include('components/lnbits-header-wallets.vue') %} {%
include('components/lnbits-drawer.vue') %} {%
include('components/lnbits-home-logos.vue') %} {%
include('components/lnbits-manage-extension-list.vue') %} {%
include('components/lnbits-extension-permissions.vue') %} {%
include('components/lnbits-manage-wallet-list.vue') %} {%
include('components/lnbits-language-dropdown.vue') %} {%
include('components/lnbits-payment-list.vue') %} {%
@@ -1,7 +1,46 @@
<template id="lnbits-admin-exchange-providers">
<h6 class="q-my-none q-mb-sm">
<span v-text="$t('exchange_providers')"></span>
</h6>
<h6 class="q-my-none q-mb-xs">LNbits Price Aggregator</h6>
<p class="q-mb-md text-caption text-grey">
A privacy-friendly, open-source Bitcoin price aggregator maintained by the
LNbits team. Aggregates prices from multiple exchanges and returns a median,
no API keys required.
<a href="https://price.lnbits.com" target="_blank" rel="noopener"
>price.lnbits.com</a
>
&mdash;
<a
href="https://github.com/lnbits/lnbits-price-aggregator"
target="_blank"
rel="noopener"
>GitHub</a
>
</p>
<div class="row q-mb-md items-start">
<div class="col-auto q-mr-md q-mt-sm">
<q-toggle
v-model="formData.lnbits_price_aggregator_enabled"
@update:model-value="formData.touch = null"
label="Use Price Aggregator"
>
</q-toggle>
</div>
<div class="col-12 col-md-7">
<q-input
filled
v-model="formData.lnbits_price_aggregator_url"
type="text"
label="Price Aggregator URL"
hint="Fetch BTC price from this aggregator instead of individual providers below."
:disable="!formData.lnbits_price_aggregator_enabled"
@update:model-value="formData.touch = null"
>
</q-input>
</div>
</div>
<q-separator class="q-my-md"></q-separator>
<h6 class="q-my-none q-mb-sm">Bitcoin Price History</h6>
<div class="row">
<div class="col-12 col-md-8">
@@ -53,6 +92,11 @@
</div>
</div>
<q-separator class="q-my-md"></q-separator>
<h6 class="q-my-none q-mb-sm">
<span v-text="$t('exchange_providers')"></span>
</h6>
<div class="row q-mt-md">
<div class="col-6">
<q-btn
@@ -60,6 +104,7 @@
label="Add Exchange Provider"
color="primary"
class="q-mb-md"
:disable="formData.lnbits_price_aggregator_enabled"
>
</q-btn>
</div>
@@ -70,12 +115,20 @@
:label="$t('reset_defaults')"
color="primary"
class="float-right"
:disable="formData.lnbits_price_aggregator_enabled"
>
</q-btn>
</div>
</div>
<div class="overflow-auto">
<div
class="overflow-auto"
:style="
formData.lnbits_price_aggregator_enabled
? 'opacity:0.4;pointer-events:none'
: ''
"
>
<q-table
row-key="name"
:rows="formData.lnbits_exchange_rate_providers"
@@ -301,5 +301,11 @@
</div>
</div>
</div>
<lnbits-admin-funding-seed-backup
:active="active"
:is-super-user="isSuperUser"
:form-data="formData"
:settings="settings"
></lnbits-admin-funding-seed-backup>
</q-card-section>
</template>
@@ -0,0 +1,136 @@
<template id="lnbits-admin-funding-seed-backup">
<q-dialog v-model="dialog.show">
<q-card style="width: 760px; max-width: 95vw; border-radius: 8px">
<q-card-section class="q-pb-md">
<div class="row q-col-gutter-sm">
<div class="col-6">
<q-chip
square
class="full-width"
icon="looks_one"
:color="dialog.step === 1 ? 'primary' : 'grey-9'"
text-color="white"
label="Backup"
></q-chip>
</div>
<div class="col-6">
<q-chip
square
class="full-width"
icon="looks_two"
:color="dialog.step === 2 ? 'primary' : 'grey-9'"
text-color="white"
label="Verify"
></q-chip>
</div>
</div>
</q-card-section>
<q-separator></q-separator>
<q-card-section v-if="dialog.step === 1">
<div class="row items-center justify-between q-mb-md">
<div>
<div
class="text-subtitle1"
v-text="`${seedWords.length}-word recovery phrase`"
></div>
<div
class="text-caption text-grey-5"
v-text="'Write these words down in order.'"
></div>
</div>
<q-btn
outline
no-caps
color="primary"
:icon="dialog.visible ? 'visibility_off' : 'visibility'"
:label="dialog.visible ? 'Hide words' : 'Show words'"
@click="dialog.visible = !dialog.visible"
></q-btn>
</div>
<div class="row q-col-gutter-sm">
<div
class="col-4 col-md-3"
v-for="word in seedWords"
:key="word.index"
>
<div
class="row items-center no-wrap rounded-borders"
style="
min-height: 42px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.035);
"
>
<div
class="text-caption text-grey-5 text-center"
style="
width: 42px;
border-right: 1px solid rgba(255, 255, 255, 0.1);
"
v-text="word.index + 1"
></div>
<div
class="text-body2 text-weight-medium q-px-sm"
style="min-width: 0; overflow-wrap: anywhere"
v-text="dialog.visible ? word.word : '••••••'"
></div>
</div>
</div>
</div>
<div class="row justify-end q-mt-lg">
<q-btn
color="primary"
no-caps
label="I have written it down"
@click="prepareChallenge"
></q-btn>
</div>
</q-card-section>
<q-card-section v-if="dialog.step === 2">
<div class="q-mb-md">
<div class="text-subtitle1" v-text="'Confirm your backup'"></div>
<div
class="text-caption text-grey-5"
v-text="
'Enter the requested words from your written recovery phrase.'
"
></div>
</div>
<div class="row q-col-gutter-md">
<div
class="col-12 col-sm-6"
v-for="word in dialog.challenge"
:key="word.index"
>
<q-input
v-model.trim="dialog.answers[word.index]"
filled
:label="`Word ${word.index + 1}`"
></q-input>
</div>
</div>
<div
class="text-negative q-mt-sm"
v-if="dialog.error"
v-text="dialog.error"
></div>
<div class="row justify-between q-mt-lg">
<q-btn flat no-caps label="Back" @click="dialog.step = 1"></q-btn>
<q-btn
color="primary"
icon="check"
no-caps
label="Confirm backup"
@click="submitChallenge"
></q-btn>
</div>
</q-card-section>
</q-card>
</q-dialog>
</template>
@@ -0,0 +1,105 @@
<template id="lnbits-extension-permissions">
<q-list bordered separator>
<q-expansion-item
v-for="permission of displayItems"
:key="permission.id"
dense
expand-separator
class="q-pt-xs"
>
<template v-slot:header>
<q-item-section>
<q-item-label class="text-weight-medium">
<span v-text="permission.label"></span>
</q-item-label>
</q-item-section>
<q-item-section
v-if="permission.risk.level !== 'low' || permission.badges.length"
side
top
>
<div class="row items-center justify-end q-gutter-xs">
<q-badge
v-for="badge of permission.badges"
:key="badge.key"
outline
color="primary"
v-text="badge.label"
></q-badge>
<q-badge
v-if="permission.risk.level !== 'low'"
:color="permission.risk.color"
v-text="permission.risk.label"
></q-badge>
</div>
</q-item-section>
</template>
<div class="q-px-md q-pb-sm">
<div
v-if="permission.risk.warning"
class="row items-center text-negative text-caption q-mb-xs"
>
<q-icon name="warning" size="16px" class="q-mr-xs"></q-icon>
<span v-text="permission.risk.warning"></span>
</div>
<p
v-for="description of permission.descriptions"
:key="description"
class="text-caption q-mb-xs"
v-text="description"
></p>
<p
v-for="policy of permission.invoicePolicies"
:key="policy.table + ':' + policy.walletField"
class="text-caption q-mb-xs"
v-text="publicInvoicePolicySentence(policy)"
></p>
<ul v-if="permission.fieldGroups.length" class="q-my-sm q-pl-md">
<li v-for="group of permission.fieldGroups" :key="group.table">
<span v-text="group.table"></span>
<ul v-if="group.fields.length" class="q-pl-md">
<li
v-for="field of group.fields"
:key="group.table + ':' + field"
v-text="field"
></li>
</ul>
</li>
</ul>
<div v-if="permission.extensionAccess.length" class="q-mt-sm">
<div
class="text-caption text-grey"
v-text="$t('extension_permission_extension_api_request_extensions')"
></div>
<div
v-for="target of permission.extensionAccess"
:key="target.id"
class="row items-center q-gutter-xs q-mt-xs"
>
<span class="text-caption" v-text="target.name"></span>
<q-badge
v-for="access of target.access"
:key="target.id + access"
color="grey-7"
v-text="permissionAccessLabel(access)"
></q-badge>
</div>
</div>
<div v-if="permission.httpHosts.length" class="q-mt-sm">
<div
class="text-caption text-grey"
v-text="$t('extension_permission_http_request_hosts')"
></div>
<ul class="q-my-sm q-pl-md">
<li
v-for="host of permission.httpHosts"
:key="host"
v-text="host"
></li>
</ul>
</div>
</div>
</q-expansion-item>
</q-list>
</template>
@@ -6,6 +6,9 @@
:content-inset-level="0.5"
>
<q-card-section>
<q-banner dense rounded class="bg-warning text-black q-mb-md">
These keys should be kept safe, sharing them could risk losing funds.
</q-banner>
<q-list>
<q-item dense class="q-pa-none">
<q-item-section>
+1
View File
@@ -199,6 +199,7 @@
>
<q-tab-panel name="funding">
<lnbits-admin-funding
:active="tab === 'funding'"
:is-super-user="isSuperUser"
:settings="settings"
:form-data="formData"
+5 -109
View File
@@ -463,10 +463,6 @@
<q-card v-if="permissionGrant.show" class="q-pa-md lnbits__dialog-card">
<q-card-section>
<div class="text-h6" v-text="$t('extension_permissions_title')"></div>
<div
class="text-body2 q-mt-sm"
v-text="$t('extension_permissions_request')"
></div>
<q-banner
v-if="permissionGrantHasHighRisk()"
dense
@@ -479,111 +475,11 @@
</q-banner>
</q-card-section>
<q-list bordered separator class="q-mt-md">
<q-expansion-item
v-for="permission of permissionGrantDisplayItems()"
:key="permission.id"
dense
expand-separator
class="q-pt-xs"
>
<template v-slot:header>
<q-item-section>
<q-item-label class="text-weight-medium">
<span v-text="permission.label"></span>
</q-item-label>
</q-item-section>
<q-item-section
v-if="permission.risk.level !== 'low' || permission.badges.length"
side
top
>
<div class="row items-center justify-end q-gutter-xs">
<q-badge
v-for="badge of permission.badges"
:key="badge.key"
outline
color="primary"
v-text="badge.label"
></q-badge>
<q-badge
v-if="permission.risk.level !== 'low'"
:color="permission.risk.color"
v-text="permission.risk.label"
></q-badge>
</div>
</q-item-section>
</template>
<div class="q-px-md q-pb-sm">
<div
v-if="permission.risk.warning"
class="row items-center text-negative text-caption q-mb-xs"
>
<q-icon name="warning" size="16px" class="q-mr-xs"></q-icon>
<span v-text="permission.risk.warning"></span>
</div>
<p
v-for="description of permission.descriptions"
:key="description"
class="text-caption q-mb-xs"
v-text="description"
></p>
<p
v-for="policy of permission.invoicePolicies"
:key="policy.table + ':' + policy.walletField"
class="text-caption q-mb-xs"
v-text="publicInvoicePolicySentence(policy)"
></p>
<ul v-if="permission.fieldGroups.length" class="q-my-sm q-pl-md">
<li v-for="group of permission.fieldGroups" :key="group.table">
<span v-text="group.table"></span>
<ul v-if="group.fields.length" class="q-pl-md">
<li
v-for="field of group.fields"
:key="group.table + ':' + field"
v-text="field"
></li>
</ul>
</li>
</ul>
<div v-if="permission.extensionAccess.length" class="q-mt-sm">
<div
class="text-caption text-grey"
v-text="
$t('extension_permission_extension_api_request_extensions')
"
></div>
<div
v-for="target of permission.extensionAccess"
:key="target.id"
class="row items-center q-gutter-xs q-mt-xs"
>
<span class="text-caption" v-text="target.name"></span>
<q-badge
v-for="access of target.access"
:key="target.id + access"
color="grey-7"
v-text="permissionAccessLabel(access)"
></q-badge>
</div>
</div>
<div v-if="permission.httpHosts.length" class="q-mt-sm">
<div
class="text-caption text-grey"
v-text="$t('extension_permission_http_request_hosts')"
></div>
<ul class="q-my-sm q-pl-md">
<li
v-for="host of permission.httpHosts"
:key="host"
v-text="host"
></li>
</ul>
</div>
</div>
</q-expansion-item>
</q-list>
<lnbits-extension-permissions
class="q-mt-md"
:permissions="permissionGrant.permissions"
:extensions="extensions"
></lnbits-extension-permissions>
<div class="row q-mt-lg">
<q-btn
+6 -17
View File
@@ -1,13 +1,8 @@
from __future__ import annotations
import asyncio
from time import time
from typing import Any, NamedTuple
from loguru import logger
from lnbits.settings import settings
class Cached(NamedTuple):
value: Any
@@ -22,8 +17,7 @@ class Cache:
Small caching utility providing simple get/set interface (very much like redis)
"""
def __init__(self, interval: float = 10) -> None:
self.interval = interval
def __init__(self) -> None:
self._values: dict[Any, Cached] = {}
def value(self, key: str) -> Cached | None:
@@ -59,16 +53,11 @@ class Cache:
self.set(key, value, expiry=expiry)
return value
async def invalidate_forever(self):
while settings.lnbits_running:
try:
await asyncio.sleep(self.interval)
ts = time()
expired = [k for k, v in self._values.items() if v.expiry < ts]
for k in expired:
self._values.pop(k)
except Exception:
logger.error("Error invalidating cache")
async def invalidate_cache(self):
ts = time()
expired = [k for k, v in self._values.items() if v.expiry < ts]
for k in expired:
self._values.pop(k)
cache = Cache()
+25
View File
@@ -289,7 +289,32 @@ async def btc_rates(currency: str) -> list[tuple[str, float]]:
return apply_trimmed_mean_filter(all_rates)
async def btc_price_from_aggregator(currency: str) -> float | None:
url = settings.lnbits_price_aggregator_url.rstrip("/")
try:
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
r = await client.get(f"{url}/rate/{currency.upper()}", timeout=3)
r.raise_for_status()
data = r.json()
median = data.get("rates", {}).get("median")
if median:
return float(median)
except Exception as e:
logger.warning(f"Failed to fetch price from aggregator {url}: {e}")
return None
async def btc_price(currency: str) -> float:
if (
settings.lnbits_price_aggregator_enabled
and settings.lnbits_price_aggregator_url
):
price = await btc_price_from_aggregator(currency)
if price:
return price
logger.warning("Price aggregator failed, falling back to exchange providers.")
rates = await btc_rates(currency)
if not rates:
logger.warning("Could not fetch any Bitcoin price.")
+4 -7
View File
@@ -41,19 +41,16 @@ def log_server_info():
def initialize_server_websocket_logger() -> Callable:
super_user_hash = sha256(settings.super_user.encode("utf-8")).hexdigest()
serverlog_queue: asyncio.Queue = asyncio.Queue()
async def update_websocket_serverlog():
while settings.lnbits_running:
msg = await serverlog_queue.get()
await websocket_updater(super_user_hash, msg)
logger.add(
lambda msg: serverlog_queue.put_nowait(msg),
format=Formatter().format,
)
async def update_websocket_serverlog():
msg = await serverlog_queue.get()
await websocket_updater(super_user_hash, msg)
return update_websocket_serverlog
+921 -138
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -111,6 +111,7 @@
"js/pages/users.js",
"js/pages/account.js",
"js/pages/admin.js",
"js/components/admin/lnbits-admin-funding-seed-backup.js",
"js/components/admin/lnbits-admin-funding.js",
"js/components/admin/lnbits-admin-funding-sources.js",
"js/components/admin/lnbits-admin-fiat-providers.js",
@@ -142,6 +143,7 @@
"js/components/lnbits-theme.js",
"js/components/lnbits-qrcode-scanner.js",
"js/components/lnbits-manage-extension-list.js",
"js/components/lnbits-extension-permissions.js",
"js/components/lnbits-manage-wallet-list.js",
"js/components/lnbits-language-dropdown.js",
"js/components/lnbits-payment-list.js",
Generated
+2 -109
View File
@@ -1633,7 +1633,7 @@ version = "3.3.2"
description = "Lightweight in-process concurrent programming"
optional = false
python-versions = ">=3.10"
groups = ["main", "dev"]
groups = ["main"]
files = [
{file = "greenlet-3.3.2-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:9bc885b89709d901859cf95179ec9f6bb67a3d2bb1f0e88456461bd4b7f8fd0d"},
{file = "greenlet-3.3.2-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b568183cf65b94919be4438dc28416b234b678c608cafac8874dfeeb2a9bbe13"},
@@ -2972,28 +2972,6 @@ files = [
{file = "platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934"},
]
[[package]]
name = "playwright"
version = "1.61.0"
description = "A high-level API to automate web browsers"
optional = false
python-versions = ">=3.10"
groups = ["dev"]
files = [
{file = "playwright-1.61.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:ff138c3a604f69911e9d42fd036e55c2a171e5616edf04c1e7f60a2a285540b0"},
{file = "playwright-1.61.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:009588c2a7e499bc5a8b425b61fa65490968bbda9cd69e0cf2cff10f8304659a"},
{file = "playwright-1.61.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:9f7de4536088d12037c13a52b7ea34b59270b78926bb56935070597ffac6b1af"},
{file = "playwright-1.61.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:54f3b39f6eab832e33458c1dd7da0b5682aedab3b09ae731b5c59fa12fd2024e"},
{file = "playwright-1.61.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93454322ade8c11d5d6c211bfd91bdfb9ffb4810e3e026371bcbc4bec1b7ee4c"},
{file = "playwright-1.61.0-py3-none-win32.whl", hash = "sha256:372d55a6f1248fa1dd47599686980cb8fb5bbe6fcda59eab793eb657c11d8a9b"},
{file = "playwright-1.61.0-py3-none-win_amd64.whl", hash = "sha256:35c6cc4589a5d00964a59d7b3e59641e0aac0c02f15479a7af77d20f6bc79597"},
{file = "playwright-1.61.0-py3-none-win_arm64.whl", hash = "sha256:e9fcbffcf557a8620fdedd92491eb59a32d18e23d6f3b4f6214b952be324fe51"},
]
[package.dependencies]
greenlet = ">=3.1.1,<4.0.0"
pyee = ">=13,<14"
[[package]]
name = "pluggy"
version = "1.6.0"
@@ -3408,24 +3386,6 @@ typing-extensions = ">=4.2.0"
dotenv = ["python-dotenv (>=0.10.4)"]
email = ["email-validator (>=1.0.3)"]
[[package]]
name = "pyee"
version = "13.0.1"
description = "A rough port of Node.js's EventEmitter to Python with a few tricks of its own"
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228"},
{file = "pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8"},
]
[package.dependencies]
typing-extensions = "*"
[package.extras]
dev = ["black", "build", "flake8", "flake8-black", "isort", "jupyter-console", "mkdocs", "mkdocs-include-markdown-plugin", "mkdocstrings[python]", "mypy", "pytest", "pytest-asyncio ; python_version >= \"3.4\"", "pytest-trio ; python_version >= \"3.7\"", "sphinx", "toml", "tox", "trio", "trio ; python_version > \"3.6\"", "trio-typing ; python_version > \"3.6\"", "twine", "twisted", "validate-pyproject[all]"]
[[package]]
name = "pygments"
version = "2.19.2"
@@ -3667,25 +3627,6 @@ tomli = {version = ">=1", markers = "python_version < \"3.11\""}
[package.extras]
dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"]
[[package]]
name = "pytest-base-url"
version = "2.1.0"
description = "pytest plugin for URL based testing"
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "pytest_base_url-2.1.0-py3-none-any.whl", hash = "sha256:3ad15611778764d451927b2a53240c1a7a591b521ea44cebfe45849d2d2812e6"},
{file = "pytest_base_url-2.1.0.tar.gz", hash = "sha256:02748589a54f9e63fcbe62301d6b0496da0d10231b753e950c63e03aee745d45"},
]
[package.dependencies]
pytest = ">=7.0.0"
requests = ">=2.9"
[package.extras]
test = ["black (>=22.1.0)", "flake8 (>=4.0.1)", "pre-commit (>=2.17.0)", "pytest-localserver (>=0.7.1)", "tox (>=3.24.5)"]
[[package]]
name = "pytest-cov"
version = "7.0.0"
@@ -3754,24 +3695,6 @@ pytest = ">=6.2.5"
[package.extras]
dev = ["pre-commit", "pytest-asyncio", "tox"]
[[package]]
name = "pytest-playwright"
version = "0.8.0"
description = "A pytest wrapper with fixtures for Playwright to automate web browsers"
optional = false
python-versions = ">=3.10"
groups = ["dev"]
files = [
{file = "pytest_playwright-0.8.0-py3-none-any.whl", hash = "sha256:856aae6efd4bc055f2ef229c647768760bcaad5cd3a5983c314ac260a974a933"},
{file = "pytest_playwright-0.8.0.tar.gz", hash = "sha256:7888d4a2443160c82e0c506c437076679f86b36d1910427250d90fbf1843a981"},
]
[package.dependencies]
playwright = ">=1.18"
pytest = ">=6.2.4,<10.0.0"
pytest-base-url = ">=1.0.0,<3.0.0"
python-slugify = ">=6.0.0,<9.0.0"
[[package]]
name = "python-crontab"
version = "3.3.0"
@@ -3835,24 +3758,6 @@ files = [
{file = "python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58"},
]
[[package]]
name = "python-slugify"
version = "8.0.4"
description = "A Python slugify application that also handles Unicode"
optional = false
python-versions = ">=3.7"
groups = ["dev"]
files = [
{file = "python-slugify-8.0.4.tar.gz", hash = "sha256:59202371d1d05b54a9e7720c5e038f928f45daaffe41dd10822f3907b937c856"},
{file = "python_slugify-8.0.4-py2.py3-none-any.whl", hash = "sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8"},
]
[package.dependencies]
text-unidecode = ">=1.3"
[package.extras]
unidecode = ["Unidecode (>=1.1.1)"]
[[package]]
name = "pytokens"
version = "0.4.1"
@@ -4445,18 +4350,6 @@ typing-extensions = {version = ">=4.10.0", markers = "python_version < \"3.13\""
[package.extras]
full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"]
[[package]]
name = "text-unidecode"
version = "1.3"
description = "The most basic Text::Unidecode port"
optional = false
python-versions = "*"
groups = ["dev"]
files = [
{file = "text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93"},
{file = "text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8"},
]
[[package]]
name = "tibs"
version = "0.5.7"
@@ -5255,4 +5148,4 @@ migration = ["psycopg2-binary"]
[metadata]
lock-version = "2.1"
python-versions = ">=3.10,<3.13"
content-hash = "09f0ddc9546d2ea7e6fb686200e3e15ab7e8db0f1ac33d49c94afe1cbd701fe8"
content-hash = "3097673d0cd279b0bf2b8fa59c1e523273f63d430f2c68ccc268a3cf232068af"
+1 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "lnbits"
version = "1.5.5"
version = "1.5.6"
requires-python = ">=3.10,<3.13"
description = "LNbits, free and open-source Lightning wallet and accounts system."
authors = [{ name = "Alan Bits", email = "alan@lnbits.com" }]
@@ -86,7 +86,6 @@ dev = [
"types-mock~=5.2.0.20250924",
"mock~=5.2.0",
"grpcio-tools~=1.76.0",
"pytest-playwright>=0.8.0",
]
[tool.uv]
+29 -1
View File
@@ -3,6 +3,7 @@ from pathlib import Path
import pytest
from httpx import AsyncClient
from lnbits.core.crud.settings import get_settings_field, set_settings_field
from lnbits.server import server_restart
from lnbits.settings import Settings
@@ -81,7 +82,8 @@ async def test_admin_audit_monitor_and_test_email(
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert monitor.status_code == 200
assert "invoice_listeners" in monitor.json()
task_names = [t["name"] for t in monitor.json()]
assert any("invoice_listener" in name for name in task_names)
test_email = await client.get(
"/admin/api/v1/testemail",
@@ -150,6 +152,15 @@ async def test_admin_partial_reset_restart_and_backup(
async def test_admin_delete_settings_requires_superuser(
client: AsyncClient, superuser_token: str
):
await set_settings_field("lnbits_site_title", "Reset me")
await set_settings_field("lnbits_backend_wallet_class", "BoltzWallet")
await set_settings_field("boltz_mnemonic", "keep boltz seed")
await set_settings_field("boltz_mnemonic_backup_confirmed", True)
await set_settings_field("phoenixd_mnemonic", "keep phoenixd seed")
await set_settings_field("phoenixd_mnemonic_backup_confirmed", True)
await set_settings_field("spark_l2_mnemonic", "keep spark seed")
await set_settings_field("spark_l2_mnemonic_backup_confirmed", True)
server_restart.clear()
response = await client.delete(
"/admin/api/v1/settings",
@@ -157,4 +168,21 @@ async def test_admin_delete_settings_requires_superuser(
)
assert response.status_code == 200
assert server_restart.is_set() is True
assert await get_settings_field("lnbits_site_title") is None
backend_wallet = await get_settings_field("lnbits_backend_wallet_class")
boltz_seed = await get_settings_field("boltz_mnemonic")
boltz_confirmed = await get_settings_field("boltz_mnemonic_backup_confirmed")
phoenixd_seed = await get_settings_field("phoenixd_mnemonic")
phoenixd_confirmed = await get_settings_field("phoenixd_mnemonic_backup_confirmed")
spark_l2_seed = await get_settings_field("spark_l2_mnemonic")
spark_l2_confirmed = await get_settings_field("spark_l2_mnemonic_backup_confirmed")
assert backend_wallet and backend_wallet.value == "BoltzWallet"
assert boltz_seed and boltz_seed.value == "keep boltz seed"
assert boltz_confirmed and boltz_confirmed.value is True
assert phoenixd_seed and phoenixd_seed.value == "keep phoenixd seed"
assert phoenixd_confirmed and phoenixd_confirmed.value is True
assert spark_l2_seed and spark_l2_seed.value == "keep spark seed"
assert spark_l2_confirmed and spark_l2_confirmed.value is True
server_restart.clear()
-170
View File
@@ -1,170 +0,0 @@
{
"id": "lnbits-wasm-test-extension",
"name": "WASM Test Extension",
"short_description": "Minimal WASM extension used by LNbits e2e tests.",
"version": "0.0.1",
"min_lnbits_version": "1.5.5",
"extension_type": "wasm",
"wasm": {
"module": "wasm/module.wasm",
"exports": [
{
"name": "list-wallets",
"visibility": "authenticated"
},
{
"name": "invoice-details",
"visibility": "authenticated"
},
{
"name": "get-selection",
"visibility": "authenticated"
},
{
"name": "save-selection",
"visibility": "authenticated"
},
{
"name": "pay-large-invoice",
"visibility": "authenticated"
},
{
"name": "create-tip-jar",
"visibility": "authenticated"
},
{
"name": "list-tip-jars",
"visibility": "authenticated"
},
{
"name": "get-public-tip-jar",
"visibility": "public"
},
{
"name": "create-tip-invoice",
"visibility": "public"
},
{
"name": "record-payment",
"visibility": "event"
}
]
},
"events": {
"onInvoicePaid": "record-payment"
},
"ui_routes": [
{
"path": "/lnbits-wasm-test-extension",
"entrypoint": "ui/admin.html",
"auth": "user"
},
{
"path": "/lnbits-wasm-test-extension/public/{item_id}",
"entrypoint": "ui/public.html",
"auth": "public",
"path_params": {
"item_id": "itemId"
}
}
],
"api_routes": [
{
"method": "GET",
"path": "/wallets",
"export": "list-wallets",
"auth": "user"
},
{
"method": "POST",
"path": "/payments",
"export": "pay-large-invoice",
"auth": "user"
},
{
"method": "GET",
"path": "/jars/{jar_id}",
"export": "get-public-tip-jar",
"auth": "public",
"path_params": {
"jar_id": "jarId"
}
},
{
"method": "POST",
"path": "/invoice",
"export": "create-tip-invoice",
"auth": "public"
}
],
"permissions": [
{
"id": "wallet.pay_invoice",
"description": "Pay Lightning invoices from selected wallets."
},
{
"id": "wallet.list",
"description": "List wallets available to the installing user."
},
{
"id": "wallet.balance.read",
"description": "Read wallet balances for payment selection."
},
{
"id": "extension.api.request",
"description": "Call APIs exposed by another installed extension.",
"policies": [
{
"id": "watchonly",
"access": ["read", "write"]
}
]
},
{
"id": "ui.camera.scan_qr",
"description": "Use the LNbits scanner to read QR codes when requested."
},
{
"id": "ext.storage.read",
"description": "Read extension storage rows."
},
{
"id": "ext.storage.write",
"description": "Write extension storage rows."
},
{
"id": "ext.storage.read_public",
"description": "Read public extension storage fields.",
"policies": [
{
"table_name": "tip_jars",
"public_fields": [
"id",
"title",
"description",
"currency",
"suggested_amounts"
]
}
]
},
{
"id": "wallet.create_invoice",
"description": "Create incoming Lightning invoices from authenticated pages."
},
{
"id": "wallet.create_invoice_public",
"description": "Create incoming Lightning invoices from public pages.",
"policies": [
{
"table": "tip_jars",
"wallet_field": "wallet_id"
}
]
},
{
"id": "utils.basic",
"description": "Use LNbits utility functions."
}
]
}
-49
View File
@@ -1,49 +0,0 @@
window.__lnbitsWasmTestExtensionLoaded = true
;(function () {
const channel = new MessageChannel()
const pending = new Map()
let counter = 0
const ready = new Promise(resolve => {
channel.port1.addEventListener('message', event => {
const message = event.data || {}
if (message.type === 'lnbits-extension:connected') {
resolve(true)
return
}
if (message.type !== 'lnbits-extension:response') return
const callback = pending.get(message.id)
if (!callback) return
pending.delete(message.id)
callback(message)
})
channel.port1.start()
window.parent.postMessage(
{type: 'lnbits-extension:connect', id: 'wasm-test-extension'},
'*',
[channel.port2]
)
})
window.lnbitsWasmTestBridge = {
ready() {
return ready
},
request(message) {
return ready.then(() => {
return new Promise(resolve => {
const id = `wasm-test-${++counter}`
pending.set(id, resolve)
channel.port1.postMessage({
type: 'lnbits-extension:request',
id,
...message
})
})
})
}
}
})()
@@ -1 +0,0 @@
{"unsafe": true}
@@ -1,4 +0,0 @@
<!doctype html>
<html>
<body>This must not be served as JavaScript.</body>
</html>
-11
View File
@@ -1,11 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>WASM Test Extension Admin</title>
<script src="/ext-assets/lnbits-wasm-test-extension/app.js"></script>
</head>
<body>
<main id="wasm-test-admin">WASM Test Admin</main>
</body>
</html>
@@ -1,11 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>WASM Test Extension Public</title>
<script src="/ext-assets/lnbits-wasm-test-extension/app.js"></script>
</head>
<body>
<main id="wasm-test-public">WASM Test Public</main>
</body>
</html>
Binary file not shown.
+9 -6
View File
@@ -13,10 +13,13 @@ from lnbits.core.services import (
fee_reserve_total,
get_balance_delta,
)
from lnbits.core.services.payments import pay_invoice, update_wallet_balance
from lnbits.core.services.payments import (
pay_invoice,
update_wallet_balance,
)
from lnbits.core.services.users import create_user_account
from lnbits.exceptions import PaymentError
from lnbits.tasks import create_task, wait_for_paid_invoices
from lnbits.task_manager import task_manager
from lnbits.wallets import get_funding_source
from ..helpers import is_fake, is_regtest
@@ -160,12 +163,11 @@ async def test_create_real_invoice(
assert not payment_status["paid"]
on_paid_mock = mocker.AsyncMock()
create_task(wait_for_paid_invoices("test_create_invoice", on_paid_mock)())
task_manager.register_invoice_listener(on_paid_mock, "test_create_invoice")
pay_real_invoice(invoice["bolt11"])
await asyncio.sleep(1)
assert on_paid_mock.call_count == 1
payment = on_paid_mock.call_args_list[0][0][0]
@@ -393,12 +395,11 @@ async def test_receive_real_invoice_set_pending_and_check_state(
assert not payment_status["paid"]
on_paid_mock = mocker.AsyncMock()
create_task(wait_for_paid_invoices("test_create_invoice", on_paid_mock)())
task_manager.register_invoice_listener(on_paid_mock, "test_create_invoice")
pay_real_invoice(invoice["bolt11"])
await asyncio.sleep(1)
assert on_paid_mock.call_count == 1
payment = on_paid_mock.call_args_list[0][0][0]
@@ -412,6 +413,8 @@ async def test_receive_real_invoice_set_pending_and_check_state(
payment_status = response.json()
assert payment_status["paid"]
assert payment
# set the incoming invoice to pending
payment.status = PaymentState.PENDING
await update_payment(payment)
+24 -14
View File
@@ -5,6 +5,7 @@ import pytest
from pytest_mock.plugin import MockerFixture
from lnbits.settings import Settings
from lnbits.task_manager import task_manager
from lnbits.utils.cache import Cache, Cached
key = "foo"
@@ -13,11 +14,10 @@ value = "bar"
@pytest.fixture
async def cache():
cache = Cache(interval=0.1)
task = asyncio.create_task(cache.invalidate_forever())
cache = Cache()
task = task_manager.create_permanent_task(cache.invalidate_cache, interval=1)
yield cache
task.cancel()
task_manager.cancel_task(task)
@pytest.mark.anyio
@@ -31,13 +31,13 @@ async def test_cache_get_set(cache):
@pytest.mark.anyio
async def test_cache_expiry(cache):
# gets expired by `get` call
cache.set(key, value, expiry=0.01)
await asyncio.sleep(0.02)
cache.set(key, value, expiry=1)
await asyncio.sleep(2)
assert not cache.get(key)
# gets expired by invalidation task
cache.set(key, value, expiry=0.1)
await asyncio.sleep(0.2)
cache.set(key, value, expiry=1)
await asyncio.sleep(2)
assert key not in cache._values
assert not cache.get(key)
@@ -94,23 +94,33 @@ async def test_cache_pop_expired_returns_default(cache):
async def test_invalidate_forever_logs_and_recovers_from_errors(
settings: Settings, mocker: MockerFixture
):
test_cache = Cache(interval=0)
logger_error = mocker.patch("lnbits.utils.cache.logger.error")
test_cache = Cache()
original_running = settings.lnbits_running
calls = 0
async def fake_sleep(_interval):
original_invalidate = test_cache.invalidate_cache
async def fake_invalidate():
nonlocal calls
calls += 1
if calls == 1:
raise RuntimeError("boom")
settings.lnbits_running = False
await original_invalidate()
mocker.patch.object(test_cache, "invalidate_cache", side_effect=fake_invalidate)
mocker.patch("lnbits.task_manager.asyncio.sleep")
logger_error = mocker.patch("lnbits.task_manager.logger.error")
bg_task = None
try:
settings.lnbits_running = True
mocker.patch("lnbits.utils.cache.asyncio.sleep", side_effect=fake_sleep)
await test_cache.invalidate_forever()
bg_task = task_manager.create_permanent_task(test_cache.invalidate_cache)
await bg_task.task
finally:
settings.lnbits_running = original_running
if bg_task:
task_manager.cancel_task(bg_task)
logger_error.assert_called_once_with("Error invalidating cache")
assert logger_error.called
assert calls == 2
+4
View File
@@ -275,6 +275,10 @@ async def test_btc_rates_skips_unsupported_and_failing_providers(
@pytest.mark.anyio
async def test_btc_price_handles_empty_single_and_multiple_rates(mocker: MockerFixture):
mocker.patch(
"lnbits.utils.exchange_rates.btc_price_from_aggregator",
AsyncMock(return_value=None),
)
mocker.patch("lnbits.utils.exchange_rates.btc_rates", AsyncMock(return_value=[]))
assert await btc_price("usd") == 0.0
+10 -5
View File
@@ -1717,7 +1717,9 @@ async def test_check_fiat_status_handles_internal_states(mocker: MockerFixture):
"lnbits.core.services.fiat_providers.get_fiat_provider",
AsyncMock(return_value=provider),
)
queue_put = mocker.patch("lnbits.tasks.internal_invoice_queue.put", AsyncMock())
queue_put = mocker.patch(
"lnbits.task_manager.task_manager.internal_invoice_queue.put_nowait"
)
success_status = await check_fiat_status(
Payment(
@@ -1734,7 +1736,8 @@ async def test_check_fiat_status_handles_internal_states(mocker: MockerFixture):
)
assert success_status.success is True
queue_put.assert_awaited_once_with("fiat_pending")
queue_put.assert_called_once()
assert queue_put.call_args[0][0].checking_id == "fiat_pending"
await check_fiat_status(
Payment(
@@ -1749,7 +1752,7 @@ async def test_check_fiat_status_handles_internal_states(mocker: MockerFixture):
extra={"fiat_checking_id": "stripe_checking_id"},
)
)
assert queue_put.await_count == 1
assert queue_put.call_count == 1
@pytest.mark.anyio
@@ -1786,7 +1789,9 @@ async def test_check_fiat_status_persists_successful_payment(
"lnbits.fiat.StripeWallet.get_invoice_status",
AsyncMock(return_value=FiatPaymentStatus(paid=True)),
)
queue_put = mocker.patch("lnbits.tasks.internal_invoice_queue.put", AsyncMock())
queue_put = mocker.patch(
"lnbits.task_manager.task_manager.internal_invoice_queue.put_nowait"
)
status = await check_fiat_status(payment)
@@ -1794,7 +1799,7 @@ async def test_check_fiat_status_persists_successful_payment(
assert payment.status == PaymentState.SUCCESS
updated_payment = await get_payment(payment.checking_id)
assert updated_payment.status == PaymentState.SUCCESS
queue_put.assert_awaited_once_with(payment.checking_id)
queue_put.assert_called_once_with(payment)
@pytest.mark.anyio
+17 -12
View File
@@ -12,15 +12,12 @@ from lnbits.core.crud import create_wallet, get_standalone_payment, get_wallet
from lnbits.core.crud.payments import get_payment, get_payments_paginated
from lnbits.core.models import PaymentState, Wallet
from lnbits.core.services import create_invoice, create_user_account, pay_invoice
from lnbits.core.services.payments import update_wallet_balance
from lnbits.core.services.payments import (
update_wallet_balance,
)
from lnbits.exceptions import InvoiceError, PaymentError
from lnbits.settings import Settings
from lnbits.tasks import (
create_task,
internal_invoice_listener,
internal_invoice_queue,
wait_for_paid_invoices,
)
from lnbits.task_manager import task_manager
from lnbits.wallets.base import PaymentResponse
from lnbits.wallets.fake import FakeWallet
@@ -237,24 +234,30 @@ async def test_notification_for_internal_payment(
test_name = "test_notification_for_internal_payment"
# Drain stale items left by session-scoped fixtures (e.g. update_wallet_balance)
while not internal_invoice_queue.empty():
while not task_manager.internal_invoice_queue.empty():
try:
internal_invoice_queue.get_nowait()
task_manager.internal_invoice_queue.get_nowait()
except asyncio.QueueEmpty:
break
on_paid_mock = mocker.AsyncMock()
create_task(internal_invoice_listener())
create_task(wait_for_paid_invoices(test_name, on_paid_mock)())
# create_task(internal_invoice_listener())
task_manager.register_invoice_listener(on_paid_mock, test_name)
payment = await create_invoice(
wallet_id=to_wallet.id,
amount=123,
memo=test_name,
webhook="http://test.404.lnbits.com",
)
await pay_invoice(
paid_payment = await pay_invoice(
wallet_id=to_wallet.id, payment_request=payment.bolt11, extra={"tag": "lnurlp"}
)
assert paid_payment.status == PaymentState.SUCCESS.value
assert paid_payment.bolt11 == payment.bolt11
assert paid_payment.amount == -123_000
await asyncio.sleep(1)
assert on_paid_mock.call_count == 1
@@ -264,6 +267,8 @@ async def test_notification_for_internal_payment(
assert _payment.status == PaymentState.SUCCESS.value
assert _payment.bolt11 == payment.bolt11
assert _payment.amount == 123_000
assert _payment.checking_id == payment.checking_id
updated_payment = await get_payment(_payment.checking_id)
assert (
updated_payment.webhook_status is not None
+3 -3
View File
@@ -197,8 +197,7 @@ async def test_update_wallet_balance_validates_credit_and_debit(
settings.lnbits_wallet_limit_max_balance = 0
queue_mock = mocker.patch(
"lnbits.tasks.internal_invoice_queue_put",
mocker.AsyncMock(),
"lnbits.task_manager.task_manager.internal_invoice_queue.put_nowait",
)
await update_wallet_balance(wallet, 5)
@@ -212,7 +211,8 @@ async def test_update_wallet_balance_validates_credit_and_debit(
]
assert credit_payments
assert credit_payments[0].status == PaymentState.SUCCESS
queue_mock.assert_awaited_once_with(credit_payments[0].checking_id)
queue_mock.assert_called_once()
assert queue_mock.call_args[0][0].checking_id == credit_payments[0].checking_id
@pytest.mark.anyio
@@ -1,70 +0,0 @@
from __future__ import annotations
import pytest
from lnbits.core.models.extensions import ExtensionPermission, InstallableExtension
from lnbits.core.wasm_ext.api.permissions import (
validate_extension_permissions,
validate_wasm_extension_permissions,
)
def test_validate_extension_permissions_rejects_unknown_permission() -> None:
permissions = [
ExtensionPermission(id="wallet.list"),
ExtensionPermission(id="unknown.permission"),
]
with pytest.raises(ValueError, match="unknown.permission"):
validate_extension_permissions("demo", permissions)
def test_validate_wasm_extension_permissions_requires_grant() -> None:
extension = InstallableExtension(id="demo", name="Demo", version="0.0.1")
config = {
"extension_type": "wasm",
"permissions": [{"id": "wallet.list"}],
}
with pytest.raises(ValueError, match="requires permission approval"):
validate_wasm_extension_permissions(extension, None, config)
def test_validate_wasm_extension_permissions_requires_exact_grants() -> None:
extension = InstallableExtension(id="demo", name="Demo", version="0.0.1")
config = {
"extension_type": "wasm",
"permissions": [{"id": "wallet.list"}, {"id": "utils.basic"}],
}
with pytest.raises(ValueError, match="was not granted all requested permissions"):
validate_wasm_extension_permissions(
extension,
[ExtensionPermission(id="wallet.list")],
config,
)
def test_validate_wasm_extension_permissions_returns_core_normalized_grants() -> None:
extension = InstallableExtension(id="demo", name="Demo", version="0.0.1")
config = {
"extension_type": "wasm",
"permissions": [
{
"id": "wallet.list",
"label": "Extension supplied label",
"description": "Show wallets.",
}
],
}
permissions = validate_wasm_extension_permissions(
extension,
[ExtensionPermission(id="wallet.list", label="Extension supplied label")],
config,
)
assert len(permissions) == 1
assert permissions[0].id == "wallet.list"
assert permissions[0].label is None
assert permissions[0].description == "Show wallets."
+127 -1
View File
@@ -1,3 +1,4 @@
import asyncio
import base64
import hashlib
import json
@@ -12,7 +13,7 @@ from Cryptodome.Util.Padding import pad, unpad
from websockets import ServerConnection
from websockets import serve as ws_serve
from lnbits.wallets.nwc import NWCWallet
from lnbits.wallets.nwc import NWCConnection, NWCWallet
from tests.wallets.helpers import (
WalletTest,
build_test_id,
@@ -99,6 +100,8 @@ async def handle( # noqa: C901
event,
)
await websocket.send(json.dumps(["EVENT", sub_id, event]))
elif 23195 in kinds:
assert sub_filter["authors"] == [mock_settings["service_public_key"]]
elif msg[0] == "EVENT":
event = msg[1]
decrypted_content = decrypt_content(
@@ -177,6 +180,129 @@ async def run(data: WalletTest):
await nwcwallet.cleanup()
@pytest.mark.anyio
async def test_nwc_rejects_event_from_unexpected_pubkey(mocker):
async def _noop(*args, **kwargs):
return None
mocker.patch("lnbits.wallets.nwc.NWCConnection._connect_to_relay", new=_noop)
mocker.patch("lnbits.wallets.nwc.NWCConnection._handle_timeouts", new=_noop)
service_private_key = PrivateKey()
service_public_key = service_private_key.public_key.format().hex()[2:]
attacker_private_key = PrivateKey()
attacker_public_key = attacker_private_key.public_key.format().hex()[2:]
account_private_key = PrivateKey()
conn = NWCConnection(
service_public_key,
account_private_key.secret.hex(),
"ws://127.0.0.1:8555",
)
try:
event = {
"kind": 23195,
"content": "{}",
"created_at": int(time.time()),
"tags": [["e", "request-event-id"]],
}
sign_event(attacker_public_key, attacker_private_key.secret.hex(), event)
with pytest.raises(Exception, match="Invalid event signature"):
await conn._on_event_message(["EVENT", "subid", event])
finally:
await conn.close()
@pytest.mark.anyio
async def test_nwc_marks_pending_invoice_settled_only_once():
wallet = NWCWallet.__new__(NWCWallet)
wallet.pending_invoice_details = {"checking-id": {"checking_id": "checking-id"}}
wallet.pending_invoices = ["checking-id"]
wallet.paid_invoices_queue = asyncio.Queue(0)
wallet._mark_invoice_settled("checking-id", source="notification")
wallet._mark_invoice_settled("checking-id", source="notification")
assert wallet.paid_invoices_queue.qsize() == 1
assert await wallet.paid_invoices_queue.get() == "checking-id"
@pytest.mark.anyio
async def test_nwc_registers_notification_subscriptions(mocker):
async def _noop(*args, **kwargs):
return None
mocker.patch("lnbits.wallets.nwc.NWCConnection._connect_to_relay", new=_noop)
mocker.patch("lnbits.wallets.nwc.NWCConnection._handle_timeouts", new=_noop)
service_private_key = PrivateKey()
service_public_key = service_private_key.public_key.format().hex()[2:]
account_private_key = PrivateKey()
conn = NWCConnection(
service_public_key,
account_private_key.secret.hex(),
"ws://127.0.0.1:8555",
)
send_mock = mocker.patch.object(conn, "_send", mocker.AsyncMock())
try:
await conn._subscribe_to_notifications()
assert len(conn.notification_subscription_ids) == 2
assert len(conn.subscriptions) == 2
assert set(conn.subscriptions.keys()) == conn.notification_subscription_ids
assert all(
subscription["method"] == "notification_sub"
and subscription["event_id"] == subscription["sub_id"]
for subscription in conn.subscriptions.values()
)
assert send_mock.await_count == 2
finally:
await conn.close()
@pytest.mark.anyio
async def test_nwc_spreads_fallback_lookups_with_cooldown(mocker):
def _schedule_next_lookup(
invoice: dict[str, object], now: float | None = None
) -> None:
assert now is not None
invoice["next_lookup_at"] = now + 1
wallet = NWCWallet.__new__(NWCWallet)
wallet.shutdown = False
wallet.pending_invoices = ["checking-1", "checking-2"]
wallet.pending_invoice_details = {
"checking-1": {
"checking_id": "checking-1",
"next_lookup_at": 0.0,
"lookup_attempts": 0,
},
"checking-2": {
"checking_id": "checking-2",
"next_lookup_at": 0.0,
"lookup_attempts": 0,
},
}
wallet.pending_invoices_lookup_cooldown = 1.0
wallet._is_shutting_down = lambda: False
wallet._payment_data_is_settled = lambda payment_data: False
wallet._cache_payment_data = lambda *args, **kwargs: None
wallet._schedule_next_lookup = _schedule_next_lookup
wallet.conn = mocker.Mock()
wallet.conn.get_info = mocker.AsyncMock()
wallet.conn.supports_method = mocker.Mock(return_value=True)
wallet.conn.call = mocker.AsyncMock(return_value={"settled_at": None})
sleep_mock = mocker.patch("lnbits.wallets.nwc.asyncio.sleep", mocker.AsyncMock())
await wallet._run_fallback_lookups(100.0)
assert wallet.conn.call.await_count == 2
sleep_mock.assert_awaited_once_with(1.0)
@pytest.mark.anyio
@pytest.mark.parametrize(
"test_data",
-1
View File
@@ -1 +0,0 @@
-151
View File
@@ -1,151 +0,0 @@
from __future__ import annotations
import os
import shutil
import socket
import subprocess
import sys
import time
from collections.abc import Iterator
from typing import Any
import httpx
import pytest
from tests.wasm_ext.helpers import (
EXTENSION_ID,
REPO_ROOT,
SERVER_HOST,
LiveLNbitsServer,
)
@pytest.fixture(scope="session")
def browser_name() -> str:
return "chromium"
@pytest.fixture(scope="session")
def lnbits_server(
tmp_path_factory: pytest.TempPathFactory,
) -> Iterator[LiveLNbitsServer]:
run_root = tmp_path_factory.mktemp("wasm_ext_e2e")
data_dir = run_root / "data"
extensions_root = run_root / "extensions-root"
extension_target = extensions_root / "extensions" / EXTENSION_ID
fixture_extension = REPO_ROOT / "tests" / "fixtures" / EXTENSION_ID
shutil.copytree(fixture_extension, extension_target)
port = _free_tcp_port()
base_url = f"http://{SERVER_HOST}:{port}"
env = {
**os.environ,
"AUTH_HTTPS_ONLY": "false",
"DEBUG": "true",
"HOST": SERVER_HOST,
"LNBITS_ADMIN_UI": "true",
"LNBITS_BACKEND_WALLET_CLASS": "FakeWallet",
"LNBITS_DATA_FOLDER": str(data_dir),
"LNBITS_EXTENSIONS_DEACTIVATE_ALL": "false",
"LNBITS_EXTENSIONS_PATH": str(extensions_root),
"LNBITS_PATH": str(REPO_ROOT),
"PORT": str(port),
"PYTHONUNBUFFERED": "1",
}
process = subprocess.Popen( # noqa: S603
[
sys.executable,
"-m",
"uvicorn",
"lnbits.__main__:app",
"--host",
SERVER_HOST,
"--port",
str(port),
"--loop",
"asyncio",
"--log-level",
"warning",
],
cwd=REPO_ROOT,
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
)
try:
_wait_for_first_install_page(process, base_url)
auth_cookies = _complete_first_install(base_url)
yield LiveLNbitsServer(base_url=base_url, auth_cookies=auth_cookies)
finally:
process.terminate()
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=10)
@pytest.fixture
def authenticated_page(page: Any, lnbits_server: LiveLNbitsServer) -> Any:
page.context.add_cookies(lnbits_server.auth_cookies)
return page
def _free_tcp_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind((SERVER_HOST, 0))
return int(sock.getsockname()[1])
def _wait_for_first_install_page(
process: subprocess.Popen[str],
base_url: str,
) -> None:
deadline = time.monotonic() + 60
last_error: Exception | None = None
with httpx.Client(follow_redirects=False, timeout=2) as client:
while time.monotonic() < deadline:
if process.poll() is not None:
output = process.stdout.read() if process.stdout else ""
raise RuntimeError(f"LNbits exited before startup:\n{output}")
try:
response = client.get(f"{base_url}/first_install")
if response.status_code == 200:
return
except httpx.HTTPError as exc:
last_error = exc
time.sleep(0.25)
output = process.stdout.read() if process.stdout else ""
raise TimeoutError(f"LNbits did not start: {last_error}\n{output}")
def _complete_first_install(base_url: str) -> list[dict[str, Any]]:
with httpx.Client(base_url=base_url, follow_redirects=False, timeout=10) as client:
response = client.put(
"/api/v1/auth/first_install",
json={
"username": "wasmtest-admin",
"password": "secret1234",
"password_repeat": "secret1234",
},
)
response.raise_for_status()
enable_response = client.put(f"/api/v1/extension/{EXTENSION_ID}/enable")
enable_response.raise_for_status()
return [
{
"name": cookie.name,
"value": cookie.value,
"url": base_url,
"secure": cookie.secure,
"sameSite": "Lax",
}
for cookie in client.cookies.jar
]
-16
View File
@@ -1,16 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any
EXTENSION_ID = "lnbits-wasm-test-extension"
REPO_ROOT = Path(__file__).resolve().parents[2]
SERVER_HOST = "127.0.0.1"
@dataclass(frozen=True)
class LiveLNbitsServer:
base_url: str
auth_cookies: list[dict[str, Any]]
extension_id: str = EXTENSION_ID
-385
View File
@@ -1,385 +0,0 @@
from __future__ import annotations
import json
import re
from re import Pattern
from typing import Any
from playwright.sync_api import Frame, Page, expect
from tests.wasm_ext.helpers import EXTENSION_ID, REPO_ROOT, LiveLNbitsServer
def test_permission_grant_dialog_logic_is_compact_and_explicit(page: Page) -> None:
permissions = _fixture_permissions()
page.goto("about:blank")
page.add_script_tag(path=str(REPO_ROOT / "lnbits/static/js/pages/extensions.js"))
result = page.evaluate(
"""
({permissions, translations}) => {
const methods = window.PageExtensions.methods
const context = {
extensions: [{id: 'watchonly', name: 'Watchonly'}],
permissionGrant: {show: false, permissions: [], resolve: null},
selectedExtension: {isWasm: true},
selectedRelease: null,
showManageExtensionDialog: false,
$t: key => translations[key] || key
}
Object.assign(context, methods)
const release = {extension_type: 'wasm', permissions}
const pendingGrant = context.resolveExtensionPermissionGrant(release)
const opened =
context.permissionGrant.show === true &&
context.showManageExtensionDialog === true
const items = context.permissionGrantDisplayItems()
context.grantExtensionPermissions()
return pendingGrant.then(grantedPermissions => ({
opened,
closed:
context.permissionGrant.show === false &&
context.showManageExtensionDialog === false,
grantedPermissionIds: grantedPermissions.map(permission => permission.id),
items
}))
}
""",
{"permissions": permissions, "translations": _permission_translations()},
)
assert result["opened"] is True
assert result["closed"] is True
assert result["grantedPermissionIds"] == [
permission["id"] for permission in permissions
]
items = result["items"]
assert [item["id"] for item in items] == [
"wallet.pay_invoice",
"wallet.list",
"wallet.balance.read",
"extension.api.request",
"ui.camera.scan_qr",
"ext.storage.read_write",
"ext.storage.read_public",
"wallet.create_invoice_public",
"wallet.create_invoice",
"utils.basic",
]
by_id = {item["id"]: item for item in items}
assert by_id["wallet.pay_invoice"]["risk"]["level"] == "high"
assert by_id["extension.api.request"]["risk"]["level"] == "high"
assert by_id["ext.storage.read_write"]["risk"]["level"] == "low"
assert by_id["wallet.create_invoice"]["risk"]["level"] == "low"
assert by_id["ui.camera.scan_qr"]["risk"]["level"] == "low"
assert by_id["ext.storage.read_write"]["label"] == (
"Read & Write extension storage"
)
assert by_id["ext.storage.read_public"]["badges"] == [
{"key": "tip_jars", "label": "tip_jars"}
]
assert by_id["ext.storage.read_public"]["fieldGroups"] == [
{
"table": "tip_jars",
"fields": [
"id",
"title",
"description",
"currency",
"suggested_amounts",
],
}
]
assert by_id["extension.api.request"]["badges"] == [
{"key": "watchonly", "label": "Watchonly"}
]
assert by_id["extension.api.request"]["extensionAccess"] == [
{"id": "watchonly", "name": "Watchonly", "access": ["read", "write"]}
]
assert by_id["wallet.create_invoice_public"]["invoicePolicies"] == [
{"table": "tip_jars", "walletField": "wallet_id"}
]
def test_public_wasm_page_loads_sandboxed_frame(
page: Page,
lnbits_server: LiveLNbitsServer,
) -> None:
public_url = (
f"{lnbits_server.base_url}/ext/{lnbits_server.extension_id}/public/item-123"
"?source=test"
)
frame_url_part = f"/ext-frame/{lnbits_server.extension_id}/1"
with page.expect_response(lambda response: frame_url_part in response.url) as info:
page.goto(public_url)
frame_response = info.value
assert frame_response.status == 200
assert "sandbox allow-scripts" in frame_response.headers["content-security-policy"]
assert frame_response.headers["cache-control"] == "no-store"
assert frame_response.headers["x-content-type-options"] == "nosniff"
frame = page.locator("iframe.wasm-extension-frame")
expect(frame).to_have_attribute("sandbox", "allow-scripts")
expect(frame).to_have_attribute("allow", "clipboard-write")
expect(frame).to_have_attribute("referrerpolicy", "no-referrer")
expect(frame).to_have_attribute("src", _frame_src_pattern(frame_url_part))
expect(
page.frame_locator("iframe.wasm-extension-frame").locator("body")
).to_contain_text("WASM Test Public")
def test_static_assets_are_strictly_whitelisted(
page: Page,
lnbits_server: LiveLNbitsServer,
) -> None:
assets_base = f"{lnbits_server.base_url}/ext-assets/{lnbits_server.extension_id}"
script = page.request.get(f"{assets_base}/app.js")
assert script.status == 200
assert script.headers["content-type"].startswith("text/javascript")
assert script.headers["x-content-type-options"] == "nosniff"
assert script.headers["cache-control"] == "no-store"
core_script = page.request.get(f"{assets_base}/_lnbits/vue.global.prod.js")
assert core_script.status == 200
assert core_script.headers["content-type"].startswith("text/javascript")
assert core_script.headers["x-content-type-options"] == "nosniff"
unsupported_extension = page.request.get(f"{assets_base}/data.json")
assert unsupported_extension.status == 404
html_like_javascript = page.request.get(f"{assets_base}/html-like.js")
assert html_like_javascript.status == 404
def test_frame_config_exposes_permissions_and_filters_public_routes(
authenticated_page: Page,
lnbits_server: LiveLNbitsServer,
) -> None:
public_config = authenticated_page.request.post(
_frame_config_url(lnbits_server),
data={
"path": f"/ext/{lnbits_server.extension_id}/public/item-123",
"query": {"source_id": "abc", "empty": None},
},
)
assert public_config.status == 200
public_bridge = public_config.json()["bridge"]
assert public_bridge["public"] is True
assert public_bridge["routeParams"] == {"itemId": "item-123"}
assert public_bridge["query"] == {"sourceId": "abc"}
assert {route["path"] for route in public_bridge["apiRoutes"]} == {
f"/api/v1/ext/{lnbits_server.extension_id}/jars/{{jar_id}}",
f"/api/v1/ext/{lnbits_server.extension_id}/invoice",
}
private_config = authenticated_page.request.post(
_frame_config_url(lnbits_server),
data={"path": f"/ext/{lnbits_server.extension_id}", "query": {}},
)
assert private_config.status == 200
private_bridge = private_config.json()["bridge"]
assert private_bridge["public"] is False
assert set(private_bridge["permissions"]) == {
permission["id"] for permission in _fixture_permissions()
}
assert {route["path"] for route in private_bridge["apiRoutes"]} == {
f"/api/v1/ext/{lnbits_server.extension_id}/wallets",
f"/api/v1/ext/{lnbits_server.extension_id}/payments",
f"/api/v1/ext/{lnbits_server.extension_id}/jars/{{jar_id}}",
f"/api/v1/ext/{lnbits_server.extension_id}/invoice",
}
def test_private_frame_config_and_api_routes_require_auth(
page: Page,
lnbits_server: LiveLNbitsServer,
) -> None:
private_config = page.request.post(
_frame_config_url(lnbits_server),
data={"path": f"/ext/{lnbits_server.extension_id}", "query": {}},
)
assert private_config.status == 401
private_api = page.request.get(
f"{lnbits_server.base_url}/api/v1/ext/{lnbits_server.extension_id}/wallets"
)
assert private_api.status == 401
public_config = page.request.post(
_frame_config_url(lnbits_server),
data={
"path": f"/ext/{lnbits_server.extension_id}/public/item-123",
"query": {},
},
)
assert public_config.status == 200
def test_bridge_context_and_denied_api_request(
page: Page,
lnbits_server: LiveLNbitsServer,
) -> None:
frame_url_part = f"/ext-frame/{lnbits_server.extension_id}/1"
with page.expect_response(lambda response: frame_url_part in response.url):
page.goto(
f"{lnbits_server.base_url}/ext/{lnbits_server.extension_id}"
"/public/item-123?source=test"
)
frame = _wasm_frame(page, frame_url_part)
assert frame.evaluate("() => window.lnbitsWasmTestBridge.ready()") is True
context_response = frame.evaluate("""
() => window.lnbitsWasmTestBridge.request({
action: 'context'
})
""")
assert context_response == {
"type": "lnbits-extension:response",
"id": "wasm-test-1",
"ok": True,
"data": {
"extensionId": EXTENSION_ID,
"public": True,
"routeParams": {"itemId": "item-123"},
"query": {"source": "test"},
},
}
denied_response = frame.evaluate("""
() => window.lnbitsWasmTestBridge.request({
action: 'api',
method: 'GET',
path: '/api/v1/wallets'
})
""")
assert denied_response["ok"] is False
assert denied_response["error"] == "Extension API route is not allowed."
unknown_response = frame.evaluate("""
() => window.lnbitsWasmTestBridge.request({
action: 'unknown'
})
""")
assert unknown_response["ok"] is False
assert unknown_response["error"] == "Unknown extension bridge action."
def test_frame_token_is_route_bound_required_and_single_use(
page: Page,
lnbits_server: LiveLNbitsServer,
) -> None:
frame_config = page.request.post(
f"{lnbits_server.base_url}/api/v1/ext/"
f"{lnbits_server.extension_id}/_ui/frame",
data={
"path": f"/ext/{lnbits_server.extension_id}/public/item-123",
"query": {"source": "test"},
},
)
assert frame_config.status == 200
frame_url = frame_config.json()["frameUrl"]
missing_token = page.request.get(
f"{lnbits_server.base_url}/ext-frame/{lnbits_server.extension_id}/1"
)
assert missing_token.status == 404
wrong_route = page.request.get(
f"{lnbits_server.base_url}{frame_url.replace('/1?', '/0?')}"
)
assert wrong_route.status == 404
first_use = page.request.get(f"{lnbits_server.base_url}{frame_url}")
assert first_use.status == 200
assert "form-action 'none'" in first_use.headers["content-security-policy"]
second_use = page.request.get(f"{lnbits_server.base_url}{frame_url}")
assert second_use.status == 404
def test_private_wasm_page_uses_lnbits_shell_and_private_frame(
authenticated_page: Page,
lnbits_server: LiveLNbitsServer,
) -> None:
page = authenticated_page
frame_url_part = f"/ext-frame/{lnbits_server.extension_id}/0"
with page.expect_response(lambda response: frame_url_part in response.url) as info:
page.goto(f"{lnbits_server.base_url}/ext/{lnbits_server.extension_id}")
assert info.value.status == 200
expect(page.locator("body")).to_contain_text("LNbits")
frame = page.locator("iframe.wasm-extension-frame")
expect(frame).to_have_attribute("sandbox", "allow-scripts")
expect(frame).to_have_attribute("src", _frame_src_pattern(frame_url_part))
expect(
page.frame_locator("iframe.wasm-extension-frame").locator("body")
).to_contain_text("WASM Test Admin")
def _frame_src_pattern(frame_url_part: str) -> Pattern[str]:
return re.compile(f"^{re.escape(frame_url_part)}\\?frame_token=[a-f0-9]{{32}}$")
def _frame_config_url(lnbits_server: LiveLNbitsServer) -> str:
return (
f"{lnbits_server.base_url}/api/v1/ext/"
f"{lnbits_server.extension_id}/_ui/frame"
)
def _fixture_permissions() -> list[dict[str, Any]]:
config = json.loads(
(REPO_ROOT / "tests/fixtures" / EXTENSION_ID / "config.json").read_text()
)
permissions = config["permissions"]
assert isinstance(permissions, list)
return permissions
def _permission_translations() -> dict[str, str]:
return {
"extension_permission_access_read": "Read",
"extension_permission_access_write": "Write",
"extension_permission_ext_storage_read_public": (
"Read public extension storage"
),
"extension_permission_ext_storage_read_write": (
"Read & Write extension storage"
),
"extension_permission_extension_api_request": "Use other extensions",
"extension_permission_risk_high": "High risk",
"extension_permission_risk_low": "Low risk",
"extension_permission_risk_medium": "Medium risk",
"extension_permission_ui_camera_scan_qr": "Scan QR codes",
"extension_permission_utils_basic": "Use basic LNbits utilities",
"extension_permission_wallet_balance_read": "View wallet balances",
"extension_permission_wallet_create_invoice": "Create invoices",
"extension_permission_wallet_create_invoice_public": (
"Create Lightning invoices from public pages"
),
"extension_permission_wallet_list": "List wallets",
"extension_permission_wallet_pay_invoice": "Pay invoices",
"extension_permission_warning_extension_api_request_write": (
"This extension can write to another extension."
),
"extension_permission_warning_wallet_pay_invoice": (
"This extension can spend from selected wallets."
),
}
def _wasm_frame(page: Page, frame_url_part: str) -> Frame:
frame = page.frame(url=lambda url: frame_url_part in url)
assert frame is not None
return frame
Generated
+1 -83
View File
@@ -1288,7 +1288,7 @@ wheels = [
[[package]]
name = "lnbits"
version = "1.5.5"
version = "1.5.6"
source = { editable = "." }
dependencies = [
{ name = "aiosqlite" },
@@ -1368,7 +1368,6 @@ dev = [
{ name = "pytest-httpserver" },
{ name = "pytest-md" },
{ name = "pytest-mock" },
{ name = "pytest-playwright" },
{ name = "ruff" },
{ name = "types-mock" },
{ name = "types-passlib" },
@@ -1447,7 +1446,6 @@ dev = [
{ name = "pytest-httpserver", specifier = "~=1.1.3" },
{ name = "pytest-md", specifier = "~=0.2.0" },
{ name = "pytest-mock", specifier = "~=3.15.1" },
{ name = "pytest-playwright", specifier = ">=0.8.0" },
{ name = "ruff", specifier = "~=0.14.10" },
{ name = "types-mock", specifier = "~=5.2.0.20250924" },
{ name = "types-passlib", specifier = "~=1.7.7.20250602" },
@@ -1834,25 +1832,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" },
]
[[package]]
name = "playwright"
version = "1.61.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "greenlet" },
{ name = "pyee" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/44/ee/31e4e0db36588b817a10b299a0285082545fde7d36543c2abe498bb3d61a/playwright-1.61.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:ff138c3a604f69911e9d42fd036e55c2a171e5616edf04c1e7f60a2a285540b0", size = 43421877, upload-time = "2026-06-29T10:32:48.428Z" },
{ url = "https://files.pythonhosted.org/packages/42/35/71395dd3ecc798965be4a3ef8c443217d4abca168e7cb34536304f9489e6/playwright-1.61.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:009588c2a7e499bc5a8b425b61fa65490968bbda9cd69e0cf2cff10f8304659a", size = 42205016, upload-time = "2026-06-29T10:32:52.104Z" },
{ url = "https://files.pythonhosted.org/packages/f4/44/323164cf5cd1647bdefce76ffce27651aadb959d089b48f53ea40918276e/playwright-1.61.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:9f7de4536088d12037c13a52b7ea34b59270b78926bb56935070597ffac6b1af", size = 43421884, upload-time = "2026-06-29T10:32:55.773Z" },
{ url = "https://files.pythonhosted.org/packages/ab/f8/a35bf179e4ba2522c1893635094a64e407572547bd61528820fc0abc87fe/playwright-1.61.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:54f3b39f6eab832e33458c1dd7da0b5682aedab3b09ae731b5c59fa12fd2024e", size = 47421381, upload-time = "2026-06-29T10:32:59.903Z" },
{ url = "https://files.pythonhosted.org/packages/b7/eb/e3f922348ec17c315f98c463f72faa1181a1c3de0bfe31a8d2edf6561723/playwright-1.61.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93454322ade8c11d5d6c211bfd91bdfb9ffb4810e3e026371bcbc4bec1b7ee4c", size = 47120545, upload-time = "2026-06-29T10:33:03.574Z" },
{ url = "https://files.pythonhosted.org/packages/c2/a6/5be4e52b40a9c0c8a073e7c5b0785c05cf5a9ea8f8a7b5b260e32d970342/playwright-1.61.0-py3-none-win32.whl", hash = "sha256:372d55a6f1248fa1dd47599686980cb8fb5bbe6fcda59eab793eb657c11d8a9b", size = 37844841, upload-time = "2026-06-29T10:33:07.361Z" },
{ url = "https://files.pythonhosted.org/packages/6c/fd/2b78036e5fbe9d5f5645bbe08a1eac7160c51243c0093963edbcf67c35d9/playwright-1.61.0-py3-none-win_amd64.whl", hash = "sha256:35c6cc4589a5d00964a59d7b3e59641e0aac0c02f15479a7af77d20f6bc79597", size = 37844846, upload-time = "2026-06-29T10:33:10.637Z" },
{ url = "https://files.pythonhosted.org/packages/27/0d/1b0f3c4ee4eb0514bc805b5c2f9a223e5b6de4f11a926f5235d51d0fc81b/playwright-1.61.0-py3-none-win_arm64.whl", hash = "sha256:e9fcbffcf557a8620fdedd92491eb59a32d18e23d6f3b4f6214b952be324fe51", size = 33955127, upload-time = "2026-06-29T10:33:14.008Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
@@ -2080,18 +2059,6 @@ email = [
{ name = "email-validator" },
]
[[package]]
name = "pyee"
version = "13.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
@@ -2234,19 +2201,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]
[[package]]
name = "pytest-base-url"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ae/1a/b64ac368de6b993135cb70ca4e5d958a5c268094a3a2a4cac6f0021b6c4f/pytest_base_url-2.1.0.tar.gz", hash = "sha256:02748589a54f9e63fcbe62301d6b0496da0d10231b753e950c63e03aee745d45", size = 6702, upload-time = "2024-01-31T22:43:00.81Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/98/1c/b00940ab9eb8ede7897443b771987f2f4a76f06be02f1b3f01eb7567e24a/pytest_base_url-2.1.0-py3-none-any.whl", hash = "sha256:3ad15611778764d451927b2a53240c1a7a591b521ea44cebfe45849d2d2812e6", size = 5302, upload-time = "2024-01-31T22:42:58.897Z" },
]
[[package]]
name = "pytest-cov"
version = "7.0.0"
@@ -2297,21 +2251,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" },
]
[[package]]
name = "pytest-playwright"
version = "0.8.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "playwright" },
{ name = "pytest" },
{ name = "pytest-base-url" },
{ name = "python-slugify" },
]
sdist = { url = "https://files.pythonhosted.org/packages/58/ef/172eb8e23c80491fc72f1401c72f9305663873649351306a38b18406b0c9/pytest_playwright-0.8.0.tar.gz", hash = "sha256:7888d4a2443160c82e0c506c437076679f86b36d1910427250d90fbf1843a981", size = 17132, upload-time = "2026-05-18T10:16:15.919Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fe/71/1c545fac6a9054b52b3771238fb2dc6e8f1d0ccec116e1c7786ec191887c/pytest_playwright-0.8.0-py3-none-any.whl", hash = "sha256:856aae6efd4bc055f2ef229c647768760bcaad5cd3a5983c314ac260a974a933", size = 17143, upload-time = "2026-05-18T10:16:18.226Z" },
]
[[package]]
name = "python-crontab"
version = "3.3.0"
@@ -2352,18 +2291,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5e/1e/7f7f299527a5a8ad90acd5f2f78dfa6c8495c6301a3205106ea68a84de96/python_multipart-0.0.31-py3-none-any.whl", hash = "sha256:8408153d68a9773291fc1da39a8b85a50044bddbabd2dd72e9229776b7b15e28", size = 29996, upload-time = "2026-06-04T08:27:47.804Z" },
]
[[package]]
name = "python-slugify"
version = "8.0.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "text-unidecode" },
]
sdist = { url = "https://files.pythonhosted.org/packages/87/c7/5e1547c44e31da50a460df93af11a535ace568ef89d7a811069ead340c4a/python-slugify-8.0.4.tar.gz", hash = "sha256:59202371d1d05b54a9e7720c5e038f928f45daaffe41dd10822f3907b937c856", size = 10921, upload-time = "2024-02-08T18:32:45.488Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a4/62/02da182e544a51a5c3ccf4b03ab79df279f9c60c5e82d5e8bec7ca26ac11/python_slugify-8.0.4-py2.py3-none-any.whl", hash = "sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8", size = 10051, upload-time = "2024-02-08T18:32:43.911Z" },
]
[[package]]
name = "pytokens"
version = "0.4.1"
@@ -2703,15 +2630,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/fd/901cfa59aaa5b30a99e16876f11abe38b59a1a2c51ffb3d7142bb6089069/starlette-0.47.3-py3-none-any.whl", hash = "sha256:89c0778ca62a76b826101e7c709e70680a1699ca7da6b44d38eb0a7e61fe4b51", size = 72991, upload-time = "2025-08-24T13:36:40.887Z" },
]
[[package]]
name = "text-unidecode"
version = "1.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ab/e2/e9a00f0ccb71718418230718b3d900e71a5d16e701a3dae079a21e9cd8f8/text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93", size = 76885, upload-time = "2019-08-30T21:36:45.405Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a6/a5/c0b6468d3824fe3fde30dbb5e1f687b291608f9473681bbf7dabbf5a87d7/text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8", size = 78154, upload-time = "2019-08-30T21:37:03.543Z" },
]
[[package]]
name = "tibs"
version = "0.5.7"