Compare commits
147
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd3ce06de5 | ||
|
|
88da66e4ee | ||
|
|
36958d34f0 | ||
|
|
220be5cc0b | ||
|
|
1b477f41ad | ||
|
|
8ac2cfbd1c | ||
|
|
7425f9fa70 | ||
|
|
442a272aa5 | ||
|
|
ebebd781b8 | ||
|
|
8824ae9fd4 | ||
|
|
ec1a9ac2f4 | ||
|
|
ba4700b803 | ||
|
|
73229c9d0b | ||
|
|
5f483215d9 | ||
|
|
e94a61c279 | ||
|
|
d9189e62af | ||
|
|
114229fb15 | ||
|
|
137498740a | ||
|
|
3ddce73834 | ||
|
|
597a65c45f | ||
|
|
3a5a6713c9 | ||
|
|
eceb1eba61 | ||
|
|
f186006329 | ||
|
|
32c782e719 | ||
|
|
e45f0cf73d | ||
|
|
b5506e7a0b | ||
|
|
77d52d3997 | ||
|
|
15282ebe23 | ||
|
|
39a148ef51 | ||
|
|
45a81fe22e | ||
|
|
4aaab5ca7c | ||
|
|
2add71cf0d | ||
|
|
faf3611e2a | ||
|
|
4bcc22d463 | ||
|
|
c094c820ef | ||
|
|
6be509f8f0 | ||
|
|
2f74243f2a | ||
|
|
2ff6fe5d3a | ||
|
|
3f281e65f7 | ||
|
|
2286fee5c0 | ||
|
|
af37caee1b | ||
|
|
31d343d220 | ||
|
|
42f720cdba | ||
|
|
269035d853 | ||
|
|
c8966d0eaa | ||
|
|
1321c360cc | ||
|
|
3ae05ba6e9 | ||
|
|
5e48676702 | ||
|
|
2cce2f86ef | ||
|
|
bca10998bd | ||
|
|
6400ee985e | ||
|
|
61cdfd4662 | ||
|
|
81e8a08240 | ||
|
|
5375055bb7 | ||
|
|
dbd1e20346 | ||
|
|
948acb2b73 | ||
|
|
b8a16e31e7 | ||
|
|
0fce2481cb | ||
|
|
a2a12a25a5 | ||
|
|
dab53a70e4 | ||
|
|
003e2e4bb4 | ||
|
|
db275afddf | ||
|
|
9081324cda | ||
|
|
df04f5cc65 | ||
|
|
21a6bbe73f | ||
|
|
32c870d71d | ||
|
|
0ade5fdc70 | ||
|
|
df5543c280 | ||
|
|
ebdb95ab89 | ||
|
|
e94480f82c | ||
|
|
2952e41fd3 | ||
|
|
2bf50968ba | ||
|
|
52134608f9 | ||
|
|
9cc24993ec | ||
|
|
3e30b387b2 | ||
|
|
d18f225757 | ||
|
|
3d4202b495 | ||
|
|
da76d288ae | ||
|
|
7f13588705 | ||
|
|
faf436533e | ||
|
|
15273383ef | ||
|
|
bdff354d0b | ||
|
|
20e3b4c0b4 | ||
|
|
f2920e8eb1 | ||
|
|
eed3cb2509 | ||
|
|
be2dbd71e9 | ||
|
|
3fd08ce223 | ||
|
|
068cdffb42 | ||
|
|
2dffad917c | ||
|
|
285c0bc92f | ||
|
|
a175807188 | ||
|
|
f8519c9c60 | ||
|
|
951840f50b | ||
|
|
4fccd57595 | ||
|
|
15d63fc35a | ||
|
|
41db2b2819 | ||
|
|
4a38d4219c | ||
|
|
6c5bd6f2a1 | ||
|
|
c5c5538a17 | ||
|
|
4557910a32 | ||
|
|
eec9f8a053 | ||
|
|
da6dbfdcf7 | ||
|
|
cc4d33c91e | ||
|
|
1d7c74b8c1 | ||
|
|
4943343aa3 | ||
|
|
cabb58f8fe | ||
|
|
aac04efd0e | ||
|
|
fd9009f760 | ||
|
|
6ab413775a | ||
|
|
7eb9965205 | ||
|
|
4634ad5a5a | ||
|
|
648aaa17c5 | ||
|
|
7db5c986b3 | ||
|
|
26c31b626d | ||
|
|
e39cb20525 | ||
|
|
3cea9a90d1 | ||
|
|
3224e2e774 | ||
|
|
cfc9874517 | ||
|
|
62cd151fd6 | ||
|
|
a4d0aa5db8 | ||
|
|
77630781d7 | ||
|
|
59da350cc9 | ||
|
|
29e980dd67 | ||
|
|
9d9ce63c82 | ||
|
|
748f458b8b | ||
|
|
ce177a73b1 | ||
|
|
2885e71be2 | ||
|
|
4b6f43d274 | ||
|
|
86b5cf9421 | ||
|
|
8f5b7d85aa | ||
|
|
4d51e63924 | ||
|
|
0ce2501e1a | ||
|
|
f2351145f0 | ||
|
|
2eb7d67b2a | ||
|
|
a82093b7ec | ||
|
|
ee595eede1 | ||
|
|
dfdce54e57 | ||
|
|
1367480ec6 | ||
|
|
e2d83b516a | ||
|
|
83699289fc | ||
|
|
f04e88d8bf | ||
|
|
564edfc447 | ||
|
|
b98515df14 | ||
|
|
1e0fc84586 | ||
|
|
a1d94834ae | ||
|
|
5de4239f3c | ||
|
|
c404666d7f |
@@ -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
|
||||
|
||||
@@ -10,15 +10,16 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.head_ref }}
|
||||
ref: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.head_ref || github.event.pull_request.head.sha }}
|
||||
- uses: lnbits/lnbits/.github/actions/prepare@dev
|
||||
with:
|
||||
python-version: "3.10"
|
||||
node-version: "24.x"
|
||||
npm: true
|
||||
- run: make bundle
|
||||
- name: Commit and push bundle changes
|
||||
- name: Build and commit bundle (same-repo PR)
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||
run: |
|
||||
make bundle
|
||||
git config user.name "alan"
|
||||
git config user.email "alan@lnbits.com"
|
||||
git add lnbits/static
|
||||
@@ -27,3 +28,6 @@ jobs:
|
||||
fi
|
||||
git commit -m "chore: make bundle [skip ci]"
|
||||
git push
|
||||
- name: Check bundle is up-to-date (fork PR)
|
||||
if: github.event.pull_request.head.repo.full_name != github.repository
|
||||
run: make checkbundle
|
||||
|
||||
@@ -66,6 +66,7 @@ jobs:
|
||||
BOLTZ_MNEMONIC: abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about
|
||||
LNBITS_MAX_OUTGOING_PAYMENT_AMOUNT_SATS: 1000000000
|
||||
LNBITS_MAX_INCOMING_PAYMENT_AMOUNT_SATS: 1000000000
|
||||
LNBITS_FUNDING_SOURCE_PAY_INVOICE_WAIT_SECONDS: ${{ inputs.backend-wallet-class == 'CoreLightningRestWallet' && 60 || 5 }}
|
||||
ECLAIR_PASS: lnbits
|
||||
PYTHONUNBUFFERED: 1
|
||||
DEBUG: true
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -51,7 +51,7 @@ nav_order: 1
|
||||
sudo apt-get install jq libfuse2
|
||||
wget $(curl -s https://api.github.com/repos/lnbits/lnbits/releases/latest | jq -r '.assets[] | select(.name | endswith(".AppImage")) | .browser_download_url') -O LNbits-latest.AppImage
|
||||
chmod +x LNbits-latest.AppImage
|
||||
LNBITS_ADMIN_UI=true HOST=0.0.0.0 PORT=5000 ./LNbits-latest.AppImage # most system settings are now in the admin UI, but pass additional .env variables here
|
||||
LNBITS_ADMIN_UI=true HOST=0.0.0.0 PORT=5000 AUTH_HTTPS_ONLY=false ./LNbits-latest.AppImage # most system settings are now in the admin UI, but pass additional .env variables here
|
||||
```
|
||||
|
||||
- LNbits will create a folder for DB and extension files **in the same directory** as the AppImage.
|
||||
@@ -285,10 +285,7 @@ but you can also set the env variables or pass command line arguments:
|
||||
|
||||
```sh
|
||||
# .env variables are currently passed when running, but LNbits can be managed with the admin UI.
|
||||
LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000 --host 0.0.0.0
|
||||
|
||||
# Once you have created a user, you can set as the super_user
|
||||
SUPER_USER=be54db7f245346c8833eaa430e1e0405 LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000
|
||||
LNBITS_ADMIN_UI=true AUTH_HTTPS_ONLY=false ./result/bin/lnbits --port 9000 --host 0.0.0.0
|
||||
```
|
||||
|
||||
> 
|
||||
|
||||
+73
-73
@@ -23,29 +23,40 @@ 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 get_extension_type, migrate_extension_database
|
||||
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
|
||||
from lnbits.core.wasm_ext.wasm.loader import (
|
||||
is_wasm_extension_id,
|
||||
)
|
||||
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,
|
||||
@@ -68,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):
|
||||
@@ -102,6 +113,9 @@ async def startup(app: FastAPI):
|
||||
# register core routes
|
||||
init_core_routers(app)
|
||||
|
||||
# register llms.txt endpoint for AI agents
|
||||
create_llms_txt_route(app)
|
||||
|
||||
# initialize tasks
|
||||
register_async_tasks()
|
||||
|
||||
@@ -129,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)
|
||||
@@ -162,6 +176,7 @@ def create_app() -> FastAPI:
|
||||
|
||||
# Allow registering new extensions routes without direct access to the `app` object
|
||||
core_app_extra.register_new_ext_routes = register_new_ext_routes(app)
|
||||
core_app_extra.register_new_wasm_ext_routes = register_new_wasm_ext_routes(app)
|
||||
core_app_extra.register_new_ratelimiter = register_new_ratelimiter(app)
|
||||
|
||||
# register static files
|
||||
@@ -408,6 +423,13 @@ def register_new_ext_routes(app: FastAPI) -> Callable:
|
||||
return register_new_ext_routes_fn
|
||||
|
||||
|
||||
def register_new_wasm_ext_routes(app: FastAPI) -> Callable:
|
||||
def register_new_wasm_ext_routes_fn(ext_id: str):
|
||||
register_wasm_extension(app, ext_id)
|
||||
|
||||
return register_new_wasm_ext_routes_fn
|
||||
|
||||
|
||||
def register_new_ratelimiter(app: FastAPI) -> Callable:
|
||||
def register_new_ratelimiter_fn():
|
||||
limiter = Limiter(
|
||||
@@ -423,8 +445,6 @@ def register_new_ratelimiter(app: FastAPI) -> Callable:
|
||||
|
||||
def register_ext_tasks(ext: Extension) -> None:
|
||||
"""Register extension async tasks."""
|
||||
if ext.extension_type == "wasm":
|
||||
return
|
||||
ext_module = importlib.import_module(ext.module_name)
|
||||
|
||||
if hasattr(ext_module, f"{ext.code}_start"):
|
||||
@@ -434,43 +454,6 @@ def register_ext_tasks(ext: Extension) -> None:
|
||||
|
||||
def register_ext_routes(app: FastAPI, ext: Extension) -> None:
|
||||
"""Register FastAPI routes for extension."""
|
||||
if ext.extension_type != "wasm":
|
||||
ext.extension_type = get_extension_type(ext.code) or ext.extension_type
|
||||
if ext.extension_type == "wasm":
|
||||
settings.activate_extension_paths(ext.code, ext.upgrade_hash, [])
|
||||
try:
|
||||
register_wasm_ext_routes = None
|
||||
for module_name in (
|
||||
"wasm.wasm_host.extension_host",
|
||||
"lnbits.extensions.wasm.wasm_host.extension_host",
|
||||
):
|
||||
try:
|
||||
module = importlib.import_module(module_name)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
f"Could not import module {module_name} for wasm extension "
|
||||
f"{ext.code}."
|
||||
)
|
||||
continue
|
||||
register_wasm_ext_routes = getattr(
|
||||
module, "register_wasm_ext_routes", None
|
||||
)
|
||||
if register_wasm_ext_routes is not None:
|
||||
break
|
||||
except Exception: # pragma: no cover - optional parent extension
|
||||
logger.error(
|
||||
"WASM host extension not installed; cannot register wasm extension "
|
||||
f"{ext.code}."
|
||||
)
|
||||
return
|
||||
if register_wasm_ext_routes is None:
|
||||
logger.error(
|
||||
"WASM host extension missing register_wasm_ext_routes; cannot "
|
||||
f"register wasm extension {ext.code}."
|
||||
)
|
||||
return
|
||||
register_wasm_ext_routes(app, ext)
|
||||
return
|
||||
ext_module = importlib.import_module(ext.module_name)
|
||||
|
||||
ext_route = getattr(ext_module, f"{ext.code}_ext")
|
||||
@@ -500,37 +483,54 @@ async def check_and_register_extensions(app: FastAPI) -> None:
|
||||
await check_installed_extensions(app)
|
||||
for ext in await get_valid_extensions(False):
|
||||
try:
|
||||
if is_wasm_extension_id(ext.code):
|
||||
register_wasm_extension(app, ext.code)
|
||||
continue
|
||||
register_ext_routes(app, ext)
|
||||
register_ext_tasks(ext)
|
||||
except Exception as exc:
|
||||
logger.error(f"Could not load extension `{ext.code}`: {exc!s}")
|
||||
await update_installed_extension_state(ext_id=ext.code, active=False)
|
||||
|
||||
|
||||
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")
|
||||
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"
|
||||
)
|
||||
|
||||
@@ -290,6 +290,7 @@ async def create_payment(
|
||||
webhook=data.webhook,
|
||||
fee=-abs(data.fee),
|
||||
tag=extra.get("tag", None),
|
||||
extension=data.extension,
|
||||
extra=extra,
|
||||
labels=data.labels or [],
|
||||
external_id=data.external_id,
|
||||
|
||||
@@ -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:
|
||||
|
||||
+10
-60
@@ -1,7 +1,5 @@
|
||||
import importlib
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
from uuid import UUID
|
||||
@@ -17,6 +15,8 @@ from lnbits.core.crud import (
|
||||
from lnbits.core.db import db as core_db
|
||||
from lnbits.core.models import DbVersion
|
||||
from lnbits.core.models.extensions import InstallableExtension
|
||||
from lnbits.core.wasm_ext.storage.crud import migrate_wasm_extension_database
|
||||
from lnbits.core.wasm_ext.wasm.loader import is_wasm_extension_id
|
||||
from lnbits.db import COCKROACH, POSTGRES, SQLITE, Connection
|
||||
from lnbits.settings import settings
|
||||
|
||||
@@ -24,9 +24,16 @@ from lnbits.settings import settings
|
||||
async def migrate_extension_database(
|
||||
ext: InstallableExtension, current_version: DbVersion | None = None
|
||||
):
|
||||
if _is_wasm_extension(ext):
|
||||
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
|
||||
@@ -38,63 +45,6 @@ async def migrate_extension_database(
|
||||
await run_migration(ext_conn, ext_migrations, ext.id, current_version)
|
||||
|
||||
|
||||
def get_extension_type(ext_id: str, ext_dir: Path | None = None) -> str | None:
|
||||
candidate_dirs = []
|
||||
if ext_dir:
|
||||
candidate_dirs.append(ext_dir)
|
||||
candidate_dirs.extend(
|
||||
[
|
||||
Path(settings.lnbits_extensions_path, "extensions", ext_id),
|
||||
Path(settings.lnbits_extensions_path, ext_id),
|
||||
Path(settings.lnbits_path, "lnbits", "extensions", ext_id),
|
||||
Path(settings.lnbits_path, "extensions", ext_id),
|
||||
Path.cwd() / "lnbits" / "extensions" / ext_id,
|
||||
Path.cwd() / "extensions" / ext_id,
|
||||
]
|
||||
)
|
||||
|
||||
for base in candidate_dirs:
|
||||
try:
|
||||
conf_path = base / "config.json"
|
||||
if not conf_path.is_file():
|
||||
continue
|
||||
with open(conf_path) as json_file:
|
||||
config_json = json.load(json_file)
|
||||
ext_type = config_json.get("extension_type")
|
||||
if ext_type:
|
||||
return ext_type
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"Failed to read extension config.json for '{}' in '{}': {}",
|
||||
ext_id,
|
||||
base,
|
||||
exc,
|
||||
)
|
||||
|
||||
for base in candidate_dirs:
|
||||
try:
|
||||
wasm_dir = base / "wasm"
|
||||
if (wasm_dir / "module.wasm").is_file() or (
|
||||
wasm_dir / "module.wat"
|
||||
).is_file():
|
||||
return "wasm"
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"Failed to probe wasm files for '{}' in '{}': {}",
|
||||
ext_id,
|
||||
base,
|
||||
exc,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _is_wasm_extension(ext: InstallableExtension) -> bool:
|
||||
if ext.meta and getattr(ext.meta, "extension_type", None) == "wasm":
|
||||
return True
|
||||
|
||||
return get_extension_type(ext.id, ext.ext_dir) == "wasm"
|
||||
|
||||
|
||||
async def run_migration(
|
||||
db: Connection,
|
||||
migrations_module: Any,
|
||||
|
||||
@@ -815,3 +815,12 @@ async def m045_add_external_id_to_payments(db: Connection):
|
||||
CREATE INDEX IF NOT EXISTS idx_payments_external_id
|
||||
ON apipayments (external_id);
|
||||
""")
|
||||
|
||||
|
||||
async def m046_add_permissions_to_installed_extensions(db: Connection):
|
||||
"""
|
||||
Adds granted permissions to installed extensions.
|
||||
"""
|
||||
await db.execute(
|
||||
"ALTER TABLE installed_extensions ADD COLUMN permissions TEXT DEFAULT '[]'"
|
||||
)
|
||||
|
||||
@@ -6,7 +6,6 @@ import json
|
||||
import os
|
||||
import shutil
|
||||
import zipfile
|
||||
from asyncio.tasks import create_task
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -21,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
|
||||
|
||||
|
||||
@@ -55,9 +55,10 @@ class GitHubRelease(BaseModel):
|
||||
|
||||
|
||||
class Manifest(BaseModel):
|
||||
featured: list[str] = []
|
||||
extensions: list[ExplicitRelease] = []
|
||||
repos: list[GitHubRelease] = []
|
||||
featured: list[str] = []
|
||||
categories: dict[str, list[str]] = {}
|
||||
|
||||
|
||||
class GitHubRepoRelease(BaseModel):
|
||||
@@ -76,6 +77,21 @@ class GitHubRepo(BaseModel):
|
||||
default_branch: str
|
||||
|
||||
|
||||
class ExtensionPermission(BaseModel):
|
||||
id: str
|
||||
label: str | None = None
|
||||
description: str | None = None
|
||||
policies: list[Any] | None = None
|
||||
|
||||
@staticmethod
|
||||
def list_from_config(config_json: Mapping[str, Any]) -> list[ExtensionPermission]:
|
||||
return [
|
||||
ExtensionPermission.parse_obj(permission)
|
||||
for permission in config_json.get("permissions") or []
|
||||
if isinstance(permission, dict) and permission.get("id")
|
||||
]
|
||||
|
||||
|
||||
class ExtensionConfig(BaseModel):
|
||||
name: str
|
||||
short_description: str
|
||||
@@ -83,6 +99,8 @@ class ExtensionConfig(BaseModel):
|
||||
warning: str | None = ""
|
||||
min_lnbits_version: str | None
|
||||
max_lnbits_version: str | None
|
||||
extension_type: str | None = None
|
||||
permissions: list[ExtensionPermission] = []
|
||||
|
||||
def is_version_compatible(self) -> bool:
|
||||
return is_lnbits_version_ok(self.min_lnbits_version, self.max_lnbits_version)
|
||||
@@ -115,8 +133,6 @@ class PayToEnableInfo(BaseModel):
|
||||
class UserExtensionInfo(BaseModel):
|
||||
paid_to_enable: bool | None = False
|
||||
payment_hash_to_enable: str | None = None
|
||||
granted_permissions: list[str] | None = None
|
||||
granted_payment_tags: list[str] | None = None
|
||||
|
||||
|
||||
class UserExtension(BaseModel):
|
||||
@@ -145,11 +161,11 @@ class UserExtension(BaseModel):
|
||||
class Extension(BaseModel):
|
||||
code: str
|
||||
is_valid: bool
|
||||
is_wasm: bool = False
|
||||
name: str | None = None
|
||||
short_description: str | None = None
|
||||
tile: str | None = None
|
||||
upgrade_hash: str | None = ""
|
||||
extension_type: str | None = None
|
||||
|
||||
@property
|
||||
def module_name(self) -> str:
|
||||
@@ -162,6 +178,8 @@ class Extension(BaseModel):
|
||||
|
||||
@property
|
||||
def is_upgrade_extension(self) -> bool:
|
||||
if self.is_wasm:
|
||||
return False
|
||||
return self.upgrade_hash != ""
|
||||
|
||||
@classmethod
|
||||
@@ -169,9 +187,10 @@ class Extension(BaseModel):
|
||||
return Extension(
|
||||
code=ext_info.id,
|
||||
is_valid=True,
|
||||
is_wasm=ext_info.is_wasm,
|
||||
name=ext_info.name,
|
||||
short_description=ext_info.short_description,
|
||||
tile=ext_info.icon,
|
||||
tile=_extension_tile(ext_info),
|
||||
upgrade_hash=ext_info.hash if ext_info.ext_upgrade_dir.is_dir() else "",
|
||||
)
|
||||
|
||||
@@ -311,7 +330,6 @@ class ExtensionRelease(BaseModel):
|
||||
|
||||
@classmethod
|
||||
async def fetch_release_details(cls, details_link: str) -> dict | None:
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(details_link)
|
||||
@@ -336,6 +354,7 @@ class ExtensionMeta(BaseModel):
|
||||
dependencies: list[str] = []
|
||||
archive: str | None = None
|
||||
featured: bool = False
|
||||
categories: list[str] = []
|
||||
paid_features: str | None = None
|
||||
has_paid_release: bool = False
|
||||
has_free_release: bool = False
|
||||
@@ -350,6 +369,7 @@ class InstallableExtension(BaseModel):
|
||||
icon: str | None = None
|
||||
stars: int = 0
|
||||
meta: ExtensionMeta | None = None
|
||||
permissions: list[ExtensionPermission] = []
|
||||
|
||||
@property
|
||||
def hash(self) -> str:
|
||||
@@ -402,6 +422,18 @@ class InstallableExtension(BaseModel):
|
||||
return False
|
||||
return self.meta.pay_to_enable.required is True
|
||||
|
||||
@property
|
||||
def is_wasm(self) -> bool:
|
||||
config_path = Path(self.ext_dir, "config.json")
|
||||
if not config_path.is_file():
|
||||
return False
|
||||
try:
|
||||
with open(config_path, encoding="utf-8") as json_file:
|
||||
config_json = json.load(json_file)
|
||||
except Exception:
|
||||
return False
|
||||
return config_json.get("extension_type") == "wasm"
|
||||
|
||||
async def download_archive(self):
|
||||
logger.info(f"Downloading extension {self.name} ({self.installed_version}).")
|
||||
ext_zip_file = self.zip_path
|
||||
@@ -434,6 +466,22 @@ class InstallableExtension(BaseModel):
|
||||
os.remove(ext_zip_file)
|
||||
raise AssertionError("File hash missmatch. Will not install.")
|
||||
|
||||
def load_archive_config(self) -> dict[str, Any]:
|
||||
if not self.zip_path.is_file():
|
||||
return {}
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(self.zip_path, "r") as archive:
|
||||
config_name = _archive_config_name(archive.namelist())
|
||||
if not config_name:
|
||||
return {}
|
||||
with archive.open(config_name) as config_file:
|
||||
config = json.load(config_file)
|
||||
except Exception as exc:
|
||||
raise ValueError(f"Cannot read extension config for '{self.id}'.") from exc
|
||||
|
||||
return config if isinstance(config, dict) else {}
|
||||
|
||||
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)
|
||||
@@ -612,6 +660,7 @@ class InstallableExtension(BaseModel):
|
||||
version=version,
|
||||
short_description=config_json.get("short_description"),
|
||||
icon=config_json.get("tile"),
|
||||
permissions=ExtensionPermission.list_from_config(config_json),
|
||||
meta=ExtensionMeta(
|
||||
installed_release=ExtensionRelease(
|
||||
name=ext_id,
|
||||
@@ -644,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
|
||||
@@ -683,6 +735,11 @@ class InstallableExtension(BaseModel):
|
||||
|
||||
meta = ext.meta or ExtensionMeta()
|
||||
meta.featured = ext.id in manifest.featured
|
||||
meta.categories = [
|
||||
category
|
||||
for category, ext_ids in manifest.categories.items()
|
||||
if ext.id in ext_ids
|
||||
]
|
||||
ext.meta = meta
|
||||
extension_list += [ext]
|
||||
|
||||
@@ -698,6 +755,11 @@ class InstallableExtension(BaseModel):
|
||||
ext.check_release_updates(release)
|
||||
meta = ext.meta or ExtensionMeta()
|
||||
meta.featured = ext.id in manifest.featured
|
||||
meta.categories = [
|
||||
category
|
||||
for category, ext_ids in manifest.categories.items()
|
||||
if ext.id in ext_ids
|
||||
]
|
||||
ext.meta = meta
|
||||
extension_list += [ext]
|
||||
except Exception as e:
|
||||
@@ -792,6 +854,7 @@ class CreateExtension(BaseModel):
|
||||
version: str
|
||||
cost_sats: int | None = 0
|
||||
payment_hash: str | None = None
|
||||
permissions: list[ExtensionPermission] = []
|
||||
|
||||
|
||||
class ExtensionDetailsRequest(BaseModel):
|
||||
@@ -844,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,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -9,9 +10,34 @@ def _do_nothing(*_):
|
||||
pass
|
||||
|
||||
|
||||
async def _do_nothing_async(_: Any) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class CoreAppExtra:
|
||||
register_new_ext_routes: Callable = _do_nothing
|
||||
register_new_wasm_ext_routes: Callable = _do_nothing
|
||||
register_new_ratelimiter: Callable
|
||||
dispatch_extension_invoice_paid: Callable[[Any], Awaitable[None]] = (
|
||||
_do_nothing_async
|
||||
)
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.wasm_extension_registry = WasmExtensionRegistry()
|
||||
|
||||
|
||||
class WasmExtensionRegistry:
|
||||
def __init__(self) -> None:
|
||||
self._extensions: dict[str, Any] = {}
|
||||
|
||||
def register(self, extension: Any) -> None:
|
||||
self._extensions[extension.id] = extension
|
||||
|
||||
def get(self, ext_id: str) -> Any | None:
|
||||
return self._extensions.get(ext_id)
|
||||
|
||||
def list(self) -> list[Any]:
|
||||
return list(self._extensions.values())
|
||||
|
||||
|
||||
class ConversionData(BaseModel):
|
||||
|
||||
@@ -54,6 +54,7 @@ class CreatePayment(BaseModel):
|
||||
amount_msat: int
|
||||
memo: str
|
||||
extra: dict | None = {}
|
||||
extension: str | None = None
|
||||
preimage: str | None = None
|
||||
expiry: datetime | None = None
|
||||
webhook: str | None = None
|
||||
@@ -140,22 +141,18 @@ class Payment(BaseModel):
|
||||
)
|
||||
|
||||
# DEPRECATED: in v1.5.0, use service check_payment_status instead
|
||||
async def check_status(
|
||||
self, skip_internal_payment_notifications: bool | None = False
|
||||
) -> PaymentStatus:
|
||||
async def check_status(self) -> PaymentStatus:
|
||||
logger.warning("payment.check_status() is deprecated.")
|
||||
from lnbits.core.services.payments import check_payment_status
|
||||
|
||||
return await check_payment_status(self, skip_internal_payment_notifications)
|
||||
return await check_payment_status(self)
|
||||
|
||||
# DEPRECATED: in v1.5.0, use service check_payment_status instead
|
||||
async def check_fiat_status(
|
||||
self, skip_internal_payment_notifications: bool | None = False
|
||||
) -> FiatPaymentStatus:
|
||||
async def check_fiat_status(self) -> FiatPaymentStatus:
|
||||
logger.warning("payment.check_fiat_status() is deprecated.")
|
||||
from lnbits.core.services.fiat_providers import check_fiat_status
|
||||
|
||||
return await check_fiat_status(self, skip_internal_payment_notifications)
|
||||
return await check_fiat_status(self)
|
||||
|
||||
|
||||
class PaymentFilters(FilterModel):
|
||||
@@ -263,6 +260,7 @@ class CreateInvoice(BaseModel):
|
||||
)
|
||||
expiry: int | None = None
|
||||
extra: dict | None = None
|
||||
extension: str | None = None
|
||||
webhook: str | None = None
|
||||
bolt11: str | None = None
|
||||
lnurl_withdraw: LnurlWithdrawResponse | None = None
|
||||
|
||||
@@ -17,14 +17,23 @@ from lnbits.core.crud.extensions import (
|
||||
update_installed_extension,
|
||||
)
|
||||
from lnbits.core.helpers import migrate_extension_database
|
||||
from lnbits.core.wasm_ext.api.permissions import validate_wasm_extension_permissions
|
||||
from lnbits.core.wasm_ext.wasm.loader import is_wasm_extension_id
|
||||
from lnbits.db import Connection
|
||||
from lnbits.settings import settings
|
||||
|
||||
from ..models.extensions import Extension, ExtensionMeta, InstallableExtension
|
||||
from ..models.extensions import (
|
||||
Extension,
|
||||
ExtensionMeta,
|
||||
ExtensionPermission,
|
||||
InstallableExtension,
|
||||
)
|
||||
|
||||
|
||||
async def install_extension(
|
||||
ext_info: InstallableExtension, skip_download: bool | None = False
|
||||
ext_info: InstallableExtension,
|
||||
skip_download: bool | None = False,
|
||||
granted_permissions: list[ExtensionPermission] | None = None,
|
||||
) -> Extension:
|
||||
|
||||
ext_info.meta = ext_info.meta or ExtensionMeta()
|
||||
@@ -44,6 +53,11 @@ async def install_extension(
|
||||
if not skip_download:
|
||||
await ext_info.download_archive()
|
||||
|
||||
extension_config = ext_info.load_archive_config()
|
||||
ext_info.permissions = validate_wasm_extension_permissions(
|
||||
ext_info, granted_permissions, extension_config
|
||||
)
|
||||
|
||||
ext_info.extract_archive()
|
||||
|
||||
db_version = await get_db_version(ext_info.id)
|
||||
@@ -61,7 +75,8 @@ async def install_extension(
|
||||
# call stop while the old routes are still active
|
||||
await stop_extension_background_work(ext_info.id)
|
||||
|
||||
await start_extension_background_work(ext_info.id)
|
||||
if not extension.is_wasm:
|
||||
await start_extension_background_work(ext_info.id)
|
||||
|
||||
return extension
|
||||
|
||||
@@ -87,6 +102,11 @@ async def uninstall_extension(ext_id: str):
|
||||
|
||||
|
||||
async def activate_extension(ext: Extension):
|
||||
if ext.is_wasm:
|
||||
core_app_extra.register_new_wasm_ext_routes(ext.code)
|
||||
await update_installed_extension_state(ext_id=ext.code, active=True)
|
||||
return
|
||||
|
||||
core_app_extra.register_new_ext_routes(ext)
|
||||
await update_installed_extension_state(ext_id=ext.code, active=True)
|
||||
await start_extension_background_work(ext.code)
|
||||
@@ -103,6 +123,9 @@ async def stop_extension_background_work(ext_id: str) -> bool:
|
||||
Stop background work for extension (like asyncio.Tasks, WebSockets, etc).
|
||||
Extension must expose a `myextension_stop()` function if it is starting tasks.
|
||||
"""
|
||||
if is_wasm_extension_id(ext_id):
|
||||
return True
|
||||
|
||||
upgrade_hash = settings.extension_upgrade_hash(ext_id)
|
||||
ext = Extension(code=ext_id, is_valid=True, upgrade_hash=upgrade_hash)
|
||||
|
||||
@@ -135,6 +158,9 @@ async def start_extension_background_work(ext_id: str) -> bool:
|
||||
Extension CAN expose a `myextension_start()` function if it is starting tasks.
|
||||
Extension MUST expose a `myextension_stop()` in that case.
|
||||
"""
|
||||
if is_wasm_extension_id(ext_id):
|
||||
return False
|
||||
|
||||
upgrade_hash = settings.extension_upgrade_hash(ext_id)
|
||||
ext = Extension(code=ext_id, is_valid=True, upgrade_hash=upgrade_hash)
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import httpx
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.crud import get_wallet
|
||||
from lnbits.core.crud.payments import create_payment
|
||||
from lnbits.core.crud.payments import create_payment, update_payment
|
||||
from lnbits.core.models import CreatePayment, Payment, PaymentState
|
||||
from lnbits.core.models.misc import SimpleStatus
|
||||
from lnbits.db import Connection
|
||||
@@ -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(
|
||||
@@ -36,9 +37,7 @@ async def handle_fiat_payment_confirmation(
|
||||
logger.warning(e)
|
||||
|
||||
|
||||
async def check_fiat_status(
|
||||
payment: Payment, skip_internal_payment_notifications: bool | None = False
|
||||
) -> FiatPaymentStatus:
|
||||
async def check_fiat_status(payment: Payment) -> FiatPaymentStatus:
|
||||
if not payment.is_internal:
|
||||
return FiatPaymentPendingStatus()
|
||||
if payment.success:
|
||||
@@ -58,14 +57,11 @@ async def check_fiat_status(
|
||||
return FiatPaymentPendingStatus()
|
||||
fiat_status = await fiat_provider.get_invoice_status(checking_id)
|
||||
|
||||
if skip_internal_payment_notifications:
|
||||
return fiat_status
|
||||
|
||||
if fiat_status.success:
|
||||
# notify receivers asynchronously
|
||||
from lnbits.tasks import internal_invoice_queue
|
||||
|
||||
await internal_invoice_queue.put(payment.checking_id)
|
||||
payment.status = PaymentState.SUCCESS.value
|
||||
await update_payment(payment)
|
||||
await handle_fiat_payment_confirmation(payment)
|
||||
task_manager.internal_invoice_queue.put_nowait(payment)
|
||||
|
||||
return fiat_status
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -74,7 +74,7 @@ async def send_admin_notification(
|
||||
message: str,
|
||||
message_type: str | None = None,
|
||||
) -> None:
|
||||
return await send_notification(
|
||||
return await send_notification_in_background(
|
||||
settings.lnbits_telegram_notifications_chat_id,
|
||||
settings.lnbits_nostr_notifications_identifiers,
|
||||
settings.lnbits_email_notifications_to_emails,
|
||||
@@ -97,7 +97,7 @@ async def send_user_notification(
|
||||
if user_notifications.nostr_identifier
|
||||
else []
|
||||
)
|
||||
return await send_notification(
|
||||
return await send_notification_in_background(
|
||||
user_notifications.telegram_chat_id,
|
||||
nostr_identifiers,
|
||||
email_address,
|
||||
@@ -222,12 +222,29 @@ async def send_email(
|
||||
msg["Subject"] = subject
|
||||
msg.attach(MIMEText(message, "plain"))
|
||||
username = username if len(username) > 0 else from_email
|
||||
with smtplib.SMTP(server, port) as smtp_server:
|
||||
smtp_server.starttls()
|
||||
smtp_server.login(username, password)
|
||||
smtp_server.sendmail(from_email, to_emails, msg.as_string())
|
||||
|
||||
def _send() -> bool:
|
||||
with smtplib.SMTP(server, port) as smtp_server:
|
||||
smtp_server.starttls()
|
||||
smtp_server.login(username, password)
|
||||
smtp_server.sendmail(from_email, to_emails, msg.as_string())
|
||||
return True
|
||||
|
||||
try:
|
||||
return await asyncio.to_thread(_send)
|
||||
except Exception as e:
|
||||
logger.warning(f"Sending Email failed. {e!s}")
|
||||
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):
|
||||
"""
|
||||
@@ -294,6 +311,27 @@ def send_payment_notification_in_background(wallet: Wallet, payment: Payment):
|
||||
logger.warning(f"Error sending payment notification: {e}")
|
||||
|
||||
|
||||
async def send_notification_in_background(
|
||||
telegram_chat_id: str | None,
|
||||
nostr_identifiers: list[str] | None,
|
||||
email_addresses: list[str] | None,
|
||||
message: str,
|
||||
message_type: str | None = None,
|
||||
):
|
||||
try:
|
||||
create_task(
|
||||
send_notification(
|
||||
telegram_chat_id,
|
||||
nostr_identifiers,
|
||||
email_addresses,
|
||||
message,
|
||||
message_type,
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error sending notification in background: {e}")
|
||||
|
||||
|
||||
async def send_ws_payment_notification(wallet: Wallet, payment: Payment):
|
||||
# TODO: websocket message should be a clean payment model
|
||||
# await websocket_manager.send(wallet.inkey, payment.json())
|
||||
|
||||
@@ -13,13 +13,13 @@ from lnbits.core.crud.payments import get_daily_stats
|
||||
from lnbits.core.db import db
|
||||
from lnbits.core.models import PaymentDailyStats, PaymentFilters
|
||||
from lnbits.core.models.payments import CreateInvoice
|
||||
from lnbits.core.services.fiat_providers import handle_fiat_payment_confirmation
|
||||
from lnbits.db import Connection, Filters
|
||||
from lnbits.decorators import check_user_extension_access
|
||||
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
|
||||
@@ -215,6 +215,7 @@ async def create_wallet_invoice(wallet_id: str, data: CreateInvoice) -> Payment:
|
||||
unhashed_description=unhashed_description,
|
||||
expiry=data.expiry,
|
||||
extra=data.extra,
|
||||
extension=data.extension,
|
||||
webhook=data.webhook,
|
||||
internal=data.internal,
|
||||
payment_hash=data.payment_hash,
|
||||
@@ -260,6 +261,7 @@ async def create_invoice(
|
||||
webhook: str | None = None,
|
||||
internal: bool | None = False,
|
||||
payment_hash: str | None = None,
|
||||
extension: str | None = None,
|
||||
labels: list[str] | None = None,
|
||||
external_id: str | None = None,
|
||||
conn: Connection | None = None,
|
||||
@@ -343,6 +345,7 @@ async def create_invoice(
|
||||
expiry=invoice.expiry_date,
|
||||
memo=memo,
|
||||
extra=extra,
|
||||
extension=extension,
|
||||
webhook=webhook,
|
||||
fee=invoice_response.fee_msat or 0,
|
||||
labels=labels,
|
||||
@@ -514,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(
|
||||
@@ -630,18 +631,14 @@ async def check_transaction_status(
|
||||
return await check_payment_status(payment)
|
||||
|
||||
|
||||
async def check_payment_status(
|
||||
payment: Payment, skip_internal_payment_notifications: bool | None = False
|
||||
) -> PaymentStatus:
|
||||
async def check_payment_status(payment: Payment) -> PaymentStatus:
|
||||
if payment.is_internal:
|
||||
if payment.success:
|
||||
return PaymentSuccessStatus()
|
||||
if payment.failed:
|
||||
return PaymentFailedStatus()
|
||||
if payment.is_in and payment.fiat_provider:
|
||||
fiat_status = await check_fiat_status(
|
||||
payment, skip_internal_payment_notifications
|
||||
)
|
||||
fiat_status = await check_fiat_status(payment)
|
||||
return PaymentStatus(paid=fiat_status.paid)
|
||||
return PaymentPendingStatus()
|
||||
funding_source = get_funding_source()
|
||||
@@ -783,13 +780,16 @@ async def _pay_internal_invoice(
|
||||
await update_payment(internal_payment, conn=conn)
|
||||
logger.success(f"internal payment successful {internal_payment.checking_id}")
|
||||
|
||||
await _send_payment_notification_in_background(wallet.id, payment, conn=conn)
|
||||
|
||||
# notify receiver asynchronously
|
||||
from lnbits.tasks import internal_invoice_queue
|
||||
await _send_payment_notification_in_background(
|
||||
wallet.id, payment, conn=conn
|
||||
) # notify the sender
|
||||
await _send_payment_notification_in_background(
|
||||
internal_payment.wallet_id, internal_payment, conn=conn
|
||||
) # notify the receiver
|
||||
|
||||
# notify receiver asynchronously (extension listeners)
|
||||
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
|
||||
|
||||
@@ -826,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(
|
||||
@@ -1079,29 +1078,44 @@ async def _send_payment_notification_in_background(
|
||||
send_payment_notification_in_background(wallet, payment)
|
||||
|
||||
|
||||
async def update_invoice_callback(checking_id: str) -> Payment | None:
|
||||
async def update_invoice_from_paid_invoices_stream(checking_id: str) -> Payment | None:
|
||||
"""
|
||||
Takes a checking_id of an incoming payment, from either paid_invoices_stream()
|
||||
or internal_invoice_queue. Checks its status, updates and returns it.
|
||||
returns None if no payment was found or it not and incoming payment.
|
||||
Takes a checking_id of an incoming payment from paid_invoices_stream()
|
||||
Checks its status, updates its status and returns it.
|
||||
returns None if no incoming payment was found or the status is not successful
|
||||
"""
|
||||
payment = await get_standalone_payment(checking_id, incoming=True)
|
||||
if not payment:
|
||||
logger.warning(f"No payment found for '{checking_id}'.")
|
||||
logger.warning(f"No incoming payment found for '{checking_id}'.")
|
||||
return None
|
||||
if not payment.is_in:
|
||||
logger.warning(f"Payment '{checking_id}' is not incoming, skipping.")
|
||||
|
||||
status = await check_payment_status(payment)
|
||||
|
||||
if not status.success:
|
||||
logger.error(
|
||||
"Unexpected status response from paid_invoices_stream. Skipping update."
|
||||
)
|
||||
return None
|
||||
|
||||
status = await check_payment_status(
|
||||
payment, skip_internal_payment_notifications=True
|
||||
)
|
||||
payment.fee = status.fee_msat or payment.fee
|
||||
# only overwrite preimage if status.preimage provides it
|
||||
payment.preimage = status.preimage or payment.preimage
|
||||
|
||||
payment.status = PaymentState.SUCCESS
|
||||
payment = await update_payment(payment)
|
||||
if payment.fiat_provider:
|
||||
await handle_fiat_payment_confirmation(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)
|
||||
|
||||
+48
-118
@@ -2,76 +2,44 @@ 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
|
||||
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.exchange_rates import btc_rates
|
||||
from lnbits.utils.cache import cache
|
||||
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,
|
||||
@@ -88,76 +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)
|
||||
|
||||
|
||||
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))
|
||||
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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -36,6 +36,7 @@ from lnbits.decorators import (
|
||||
check_account_exists,
|
||||
check_admin,
|
||||
check_user_exists,
|
||||
optional_user_id,
|
||||
)
|
||||
from lnbits.helpers import (
|
||||
create_access_token,
|
||||
@@ -320,7 +321,10 @@ async def api_delete_user_api_token(
|
||||
|
||||
@auth_router.get("/{provider}", description="SSO Provider")
|
||||
async def login_with_sso_provider(
|
||||
request: Request, provider: str, user_id: str | None = None
|
||||
request: Request,
|
||||
provider: str,
|
||||
user_id: str | None,
|
||||
auth_user_id: str | None = Depends(optional_user_id),
|
||||
):
|
||||
provider_sso = _new_sso(provider)
|
||||
if not provider_sso:
|
||||
@@ -328,6 +332,8 @@ async def login_with_sso_provider(
|
||||
HTTPStatus.FORBIDDEN,
|
||||
f"Login by '{provider}' not allowed.",
|
||||
)
|
||||
if user_id and user_id != auth_user_id:
|
||||
raise HTTPException(HTTPStatus.FORBIDDEN, "User ID mismatch.")
|
||||
|
||||
provider_sso.redirect_uri = str(request.base_url) + f"api/v1/auth/{provider}/token"
|
||||
with provider_sso:
|
||||
|
||||
@@ -357,16 +357,20 @@ async def handle_revolut_event(event: dict):
|
||||
return
|
||||
|
||||
payment = await get_standalone_payment(f"fiat_revolut_order_{order_id}")
|
||||
if not payment:
|
||||
if payment:
|
||||
await check_fiat_status(payment)
|
||||
return
|
||||
|
||||
if event_type == "ORDER_COMPLETED":
|
||||
logger.warning(f"No payment found for Revolut order: '{order_id}'.")
|
||||
await _handle_revolut_subscription_order_paid(order_id)
|
||||
return
|
||||
|
||||
await check_fiat_status(payment)
|
||||
logger.info(f"Ignoring Revolut authorised order without payment: '{order_id}'.")
|
||||
return
|
||||
|
||||
if event_type == "SUBSCRIPTION_INITIATED":
|
||||
await _handle_revolut_subscription_initiated(event)
|
||||
logger.info("Revolut subscription initiated event received.")
|
||||
return
|
||||
|
||||
if event_type in [
|
||||
@@ -380,23 +384,6 @@ async def handle_revolut_event(event: dict):
|
||||
logger.warning(f"Unhandled Revolut event type: '{event_type}'.")
|
||||
|
||||
|
||||
async def _handle_revolut_subscription_initiated(event: dict):
|
||||
subscription_id = event.get("subscription_id")
|
||||
if not subscription_id:
|
||||
subscription_id = event.get("id")
|
||||
|
||||
if not subscription_id:
|
||||
logger.warning("Revolut subscription event missing subscription_id.")
|
||||
return
|
||||
|
||||
fiat_provider = await _get_revolut_provider()
|
||||
if not fiat_provider:
|
||||
return
|
||||
|
||||
subscription = await fiat_provider.get_subscription(subscription_id)
|
||||
await _handle_revolut_subscription(subscription, fiat_provider)
|
||||
|
||||
|
||||
async def _get_revolut_provider() -> RevolutWallet | None:
|
||||
fiat_provider = await get_fiat_provider("revolut")
|
||||
if not isinstance(fiat_provider, RevolutWallet):
|
||||
@@ -406,7 +393,10 @@ async def _get_revolut_provider() -> RevolutWallet | None:
|
||||
|
||||
|
||||
async def _handle_revolut_subscription(
|
||||
subscription: dict, fiat_provider: RevolutWallet
|
||||
subscription: dict,
|
||||
fiat_provider: RevolutWallet,
|
||||
order_id: str | None = None,
|
||||
order: dict | None = None,
|
||||
):
|
||||
subscription_id = subscription.get("id")
|
||||
if not subscription_id:
|
||||
@@ -420,16 +410,17 @@ async def _handle_revolut_subscription(
|
||||
logger.warning("Revolut subscription event missing LNbits metadata.")
|
||||
return
|
||||
|
||||
cycle_id = subscription.get("current_cycle_id")
|
||||
if not cycle_id:
|
||||
logger.warning("Revolut subscription missing current_cycle_id.")
|
||||
return
|
||||
|
||||
cycle = await fiat_provider.get_subscription_cycle(subscription_id, cycle_id)
|
||||
order_id = cycle.get("order_id")
|
||||
if not order_id:
|
||||
logger.warning("Revolut subscription cycle missing order_id.")
|
||||
return
|
||||
cycle_id = subscription.get("current_cycle_id")
|
||||
if not cycle_id:
|
||||
logger.warning("Revolut subscription missing current_cycle_id.")
|
||||
return
|
||||
|
||||
cycle = await fiat_provider.get_subscription_cycle(subscription_id, cycle_id)
|
||||
order_id = cycle.get("order_id")
|
||||
if not order_id:
|
||||
logger.warning("Revolut subscription cycle missing order_id.")
|
||||
return
|
||||
|
||||
existing_payment = await get_standalone_payment(f"fiat_revolut_order_{order_id}")
|
||||
if existing_payment:
|
||||
@@ -439,7 +430,8 @@ async def _handle_revolut_subscription(
|
||||
await check_fiat_status(existing_payment)
|
||||
return
|
||||
|
||||
order = await fiat_provider.get_order(order_id)
|
||||
if not order:
|
||||
order = await fiat_provider.get_order(order_id)
|
||||
amount_minor = order.get("amount")
|
||||
currency = (order.get("currency") or "").upper()
|
||||
if amount_minor is None or not currency:
|
||||
@@ -447,7 +439,7 @@ async def _handle_revolut_subscription(
|
||||
|
||||
extra = {
|
||||
**(reference.extra or {}),
|
||||
"subscription_request_id": reference.subscription_request_id,
|
||||
"subscription_request_id": subscription_id,
|
||||
"fiat_method": "subscription",
|
||||
"tag": reference.tag,
|
||||
"subscription": {
|
||||
@@ -475,7 +467,9 @@ async def _handle_revolut_subscription_order_paid(order_id: str):
|
||||
return
|
||||
|
||||
order = await fiat_provider.get_order(order_id)
|
||||
if order.get("type") != "payment" or order.get("state") != "completed":
|
||||
order_type = (order.get("type") or "").lower()
|
||||
order_state = (order.get("state") or "").upper()
|
||||
if order_type != "payment" or order_state != "COMPLETED":
|
||||
logger.warning(f"Revolut order is not a completed payment: '{order_id}'.")
|
||||
return
|
||||
|
||||
@@ -490,7 +484,9 @@ async def _handle_revolut_subscription_order_paid(order_id: str):
|
||||
logger.warning(f"Revolut subscription is not active: '{subscription_id}'.")
|
||||
return
|
||||
|
||||
await _handle_revolut_subscription_initiated(subscription)
|
||||
await _handle_revolut_subscription(
|
||||
subscription, fiat_provider, order_id=order_id, order=order
|
||||
)
|
||||
|
||||
|
||||
async def _create_revolut_subscription_payment(
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import json
|
||||
import sys
|
||||
import traceback
|
||||
from http import HTTPStatus
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from bolt11 import decode as bolt11_decode
|
||||
@@ -13,7 +11,6 @@ from loguru import logger
|
||||
from lnbits.core.crud.extensions import get_user_extensions
|
||||
from lnbits.core.crud.wallets import get_wallets_ids
|
||||
from lnbits.core.db import db
|
||||
from lnbits.core.helpers import get_extension_type
|
||||
from lnbits.core.models import (
|
||||
SimpleStatus,
|
||||
)
|
||||
@@ -32,6 +29,7 @@ from lnbits.core.models.extensions import (
|
||||
ReleasePaymentInfo,
|
||||
UserExtension,
|
||||
UserExtensionInfo,
|
||||
wasm_extension_icon_url,
|
||||
)
|
||||
from lnbits.core.models.users import Account, AccountId
|
||||
from lnbits.core.services import check_transaction_status, create_invoice
|
||||
@@ -43,6 +41,7 @@ from lnbits.core.services.extensions import (
|
||||
install_extension,
|
||||
uninstall_extension,
|
||||
)
|
||||
from lnbits.core.wasm_ext.api.permissions import validate_extension_permissions
|
||||
from lnbits.db import Page
|
||||
from lnbits.decorators import (
|
||||
check_account_exists,
|
||||
@@ -70,53 +69,6 @@ extension_router = APIRouter(
|
||||
)
|
||||
|
||||
|
||||
def _load_wasm_extension_config(ext_id: str) -> dict:
|
||||
candidate_dirs = [
|
||||
Path(settings.lnbits_extensions_path, "extensions", ext_id),
|
||||
Path(settings.lnbits_path, "lnbits", "extensions", ext_id),
|
||||
]
|
||||
for base in candidate_dirs:
|
||||
conf_path = base / "config.json"
|
||||
if not conf_path.is_file():
|
||||
continue
|
||||
try:
|
||||
with open(conf_path) as json_file:
|
||||
config = json.load(json_file)
|
||||
return config if isinstance(config, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
return {}
|
||||
|
||||
|
||||
def _ensure_wasm_permissions_saved(ext_id: str, user_ext: UserExtension) -> None:
|
||||
if get_extension_type(ext_id) != "wasm":
|
||||
return
|
||||
config = _load_wasm_extension_config(ext_id)
|
||||
required = [
|
||||
permission.get("id")
|
||||
for permission in config.get("permissions", [])
|
||||
if isinstance(permission, dict) and permission.get("id")
|
||||
]
|
||||
granted = user_ext.extra.granted_permissions if user_ext.extra else []
|
||||
missing = [
|
||||
permission for permission in required if permission not in (granted or [])
|
||||
]
|
||||
if missing:
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
"Save WASM permissions before enabling this extension.",
|
||||
)
|
||||
|
||||
payment_tags = config.get("payment_tags", [])
|
||||
granted_tags = user_ext.extra.granted_payment_tags if user_ext.extra else []
|
||||
has_granted_payment_tag = any(tag in (granted_tags or []) for tag in payment_tags)
|
||||
if payment_tags and not has_granted_payment_tag:
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
"Select at least one WASM payment tag before enabling this extension.",
|
||||
)
|
||||
|
||||
|
||||
@extension_router.post("", dependencies=[Depends(check_admin)])
|
||||
async def api_install_extension(data: CreateExtension):
|
||||
release = await InstallableExtension.get_extension_release(
|
||||
@@ -139,7 +91,9 @@ async def api_install_extension(data: CreateExtension):
|
||||
)
|
||||
|
||||
try:
|
||||
extension = await install_extension(ext_info)
|
||||
extension = await install_extension(
|
||||
ext_info, granted_permissions=data.permissions
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
@@ -247,8 +201,6 @@ async def api_enable_extension(
|
||||
user_ext = UserExtension(user=account_id.id, extension=ext_id, active=False)
|
||||
await create_user_extension(user_ext)
|
||||
|
||||
_ensure_wasm_permissions_saved(ext_id, user_ext)
|
||||
|
||||
if account_id.is_admin_id or not ext.requires_payment:
|
||||
user_ext.active = True
|
||||
await update_user_extension(user_ext)
|
||||
@@ -344,7 +296,6 @@ async def api_deactivate_extension(ext_id: str) -> SimpleStatus:
|
||||
|
||||
@extension_router.delete("/{ext_id}", dependencies=[Depends(check_admin)])
|
||||
async def api_uninstall_extension(ext_id: str) -> SimpleStatus:
|
||||
|
||||
extension = await get_installed_extension(ext_id)
|
||||
if not extension:
|
||||
raise HTTPException(
|
||||
@@ -512,11 +463,19 @@ async def get_extension_release(org: str, repo: str, tag_name: str):
|
||||
if not config:
|
||||
return {}
|
||||
|
||||
permissions = validate_extension_permissions(config.name, config.permissions)
|
||||
|
||||
return {
|
||||
"min_lnbits_version": config.min_lnbits_version,
|
||||
"is_version_compatible": config.is_version_compatible(),
|
||||
"warning": config.warning,
|
||||
"extension_type": config.extension_type,
|
||||
"permissions": [dict(permission) for permission in permissions],
|
||||
}
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(exc)
|
||||
@@ -588,9 +547,10 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
|
||||
)
|
||||
installable_exts_ids = [e.id for e in installable_exts]
|
||||
installable_exts += [e for e in installed_exts if e.id not in installable_exts_ids]
|
||||
installed_exts_by_id = {e.id: e for e in installed_exts}
|
||||
|
||||
for e in installable_exts:
|
||||
installed_ext = next((ie for ie in installed_exts if e.id == ie.id), None)
|
||||
installed_ext = installed_exts_by_id.get(e.id)
|
||||
if installed_ext and installed_ext.meta:
|
||||
installed_release = installed_ext.meta.installed_release
|
||||
if installed_ext.meta.pay_to_enable and not account_id.is_admin_id:
|
||||
@@ -611,47 +571,60 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
|
||||
e.short_description = installed_ext.short_description
|
||||
e.icon = installed_ext.icon
|
||||
|
||||
extension_data = [
|
||||
{
|
||||
"id": ext.id,
|
||||
"name": ext.name,
|
||||
"icon": ext.icon,
|
||||
"shortDescription": ext.short_description,
|
||||
"stars": ext.stars,
|
||||
"isFeatured": ext.meta.featured if ext.meta else False,
|
||||
"dependencies": ext.meta.dependencies if ext.meta else "",
|
||||
"isInstalled": ext.id in installed_exts_ids,
|
||||
"hasDatabaseTables": next(
|
||||
(True for version in db_versions if version.db == ext.id), False
|
||||
),
|
||||
"isAvailable": ext.id in all_ext_ids,
|
||||
"isAdminOnly": ext.id in settings.lnbits_admin_extensions,
|
||||
"isActive": ext.id not in inactive_extensions,
|
||||
"latestRelease": (
|
||||
dict(ext.meta.latest_release)
|
||||
if ext.meta and ext.meta.latest_release
|
||||
else None
|
||||
),
|
||||
"hasPaidRelease": ext.meta.has_paid_release if ext.meta else False,
|
||||
"hasFreeRelease": ext.meta.has_free_release if ext.meta else False,
|
||||
"paidFeatures": ext.meta.paid_features if ext.meta else False,
|
||||
"installedRelease": (
|
||||
dict(ext.meta.installed_release)
|
||||
if ext.meta and ext.meta.installed_release
|
||||
else None
|
||||
),
|
||||
"payToEnable": (
|
||||
dict(ext.meta.pay_to_enable)
|
||||
if ext.meta and ext.meta.pay_to_enable
|
||||
else {}
|
||||
),
|
||||
"isPaymentRequired": ext.requires_payment,
|
||||
"inProgress": False,
|
||||
"selectedForUpdate": False,
|
||||
"extensionType": get_extension_type(ext.id) or "python",
|
||||
}
|
||||
for ext in installable_exts
|
||||
]
|
||||
extension_data = []
|
||||
for ext in installable_exts:
|
||||
installed_ext = installed_exts_by_id.get(ext.id)
|
||||
is_wasm = installed_ext.is_wasm if installed_ext else ext.is_wasm
|
||||
icon = wasm_extension_icon_url(ext.id) if is_wasm else ext.icon
|
||||
permissions = (
|
||||
validate_extension_permissions(
|
||||
installed_ext.id, installed_ext.permissions, strict=False
|
||||
)
|
||||
if installed_ext
|
||||
else []
|
||||
)
|
||||
extension_data.append(
|
||||
{
|
||||
"id": ext.id,
|
||||
"name": ext.name,
|
||||
"icon": icon,
|
||||
"shortDescription": ext.short_description,
|
||||
"stars": ext.stars,
|
||||
"isFeatured": ext.meta.featured if ext.meta else False,
|
||||
"categories": ext.meta.categories if ext.meta else [],
|
||||
"dependencies": ext.meta.dependencies if ext.meta else "",
|
||||
"isInstalled": ext.id in installed_exts_ids,
|
||||
"hasDatabaseTables": next(
|
||||
(True for version in db_versions if version.db == ext.id), False
|
||||
),
|
||||
"isAvailable": ext.id in all_ext_ids,
|
||||
"isAdminOnly": ext.id in settings.lnbits_admin_extensions,
|
||||
"isActive": ext.id not in inactive_extensions,
|
||||
"latestRelease": (
|
||||
dict(ext.meta.latest_release)
|
||||
if ext.meta and ext.meta.latest_release
|
||||
else None
|
||||
),
|
||||
"hasPaidRelease": ext.meta.has_paid_release if ext.meta else False,
|
||||
"hasFreeRelease": ext.meta.has_free_release if ext.meta else False,
|
||||
"paidFeatures": ext.meta.paid_features if ext.meta else False,
|
||||
"installedRelease": (
|
||||
dict(ext.meta.installed_release)
|
||||
if ext.meta and ext.meta.installed_release
|
||||
else None
|
||||
),
|
||||
"payToEnable": (
|
||||
dict(ext.meta.pay_to_enable)
|
||||
if ext.meta and ext.meta.pay_to_enable
|
||||
else {}
|
||||
),
|
||||
"isPaymentRequired": ext.requires_payment,
|
||||
"isWasm": is_wasm,
|
||||
"permissions": [dict(permission) for permission in permissions],
|
||||
"inProgress": False,
|
||||
"selectedForUpdate": False,
|
||||
}
|
||||
)
|
||||
return extension_data
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
from .api.host import ExtensionHostAPI
|
||||
from .api.models import ExtensionAPIMethod, ExtensionAPIMethodExport
|
||||
from .api.registry import (
|
||||
extension_api_contract,
|
||||
extension_api_method,
|
||||
extension_api_permission_ids,
|
||||
get_extension_api_method,
|
||||
list_extension_api_methods,
|
||||
)
|
||||
from .api.runtime import ExtensionAPIHost
|
||||
from .wasm.loader import WasmExtension
|
||||
|
||||
__all__ = [
|
||||
"ExtensionAPIHost",
|
||||
"ExtensionAPIMethod",
|
||||
"ExtensionAPIMethodExport",
|
||||
"ExtensionHostAPI",
|
||||
"WasmExtension",
|
||||
"extension_api_contract",
|
||||
"extension_api_method",
|
||||
"extension_api_permission_ids",
|
||||
"get_extension_api_method",
|
||||
"list_extension_api_methods",
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
from .host import ExtensionHostAPI
|
||||
from .models import ExtensionAPIMethod, ExtensionAPIMethodExport
|
||||
from .registry import (
|
||||
extension_api_contract,
|
||||
extension_api_method,
|
||||
extension_api_permission_ids,
|
||||
get_extension_api_method,
|
||||
list_extension_api_methods,
|
||||
)
|
||||
from .runtime import ExtensionAPIHost
|
||||
|
||||
__all__ = [
|
||||
"ExtensionAPIHost",
|
||||
"ExtensionAPIMethod",
|
||||
"ExtensionAPIMethodExport",
|
||||
"ExtensionHostAPI",
|
||||
"extension_api_contract",
|
||||
"extension_api_method",
|
||||
"extension_api_permission_ids",
|
||||
"get_extension_api_method",
|
||||
"list_extension_api_methods",
|
||||
]
|
||||
@@ -0,0 +1,582 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
from collections.abc import Iterable, Mapping
|
||||
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,
|
||||
storage_get_public_row,
|
||||
storage_get_row,
|
||||
storage_set_row,
|
||||
)
|
||||
from .models import (
|
||||
CreateInvoicePublicRequest,
|
||||
CreateInvoiceRequest,
|
||||
CreateInvoiceResponse,
|
||||
EmptyRequest,
|
||||
ExtensionApiRequest,
|
||||
HttpRequest,
|
||||
HttpResponse,
|
||||
ListUserWalletsResponse,
|
||||
LogRequest,
|
||||
LogResponse,
|
||||
NowResponse,
|
||||
PayInvoiceRequest,
|
||||
PayInvoiceResponse,
|
||||
RandomIdRequest,
|
||||
RandomIdResponse,
|
||||
StorageDeleteRequest,
|
||||
StorageDeleteResponse,
|
||||
StorageGetRequest,
|
||||
StorageGetResponse,
|
||||
StoragePaginatedRequest,
|
||||
StoragePaginatedResponse,
|
||||
StorageSetRequest,
|
||||
StorageSetResponse,
|
||||
UserWalletSummary,
|
||||
WalletBalanceRequest,
|
||||
WalletBalanceResponse,
|
||||
)
|
||||
from .registry import extension_api_method
|
||||
|
||||
logger = logging.getLogger("lnbits.extensions")
|
||||
|
||||
|
||||
class ExtensionHostAPI:
|
||||
def __init__(
|
||||
self,
|
||||
extension_id: str,
|
||||
permissions: Iterable[Any],
|
||||
*,
|
||||
user_id: str | None = None,
|
||||
access_token: str | None = None,
|
||||
context: str = "user",
|
||||
owner_id: str | None = None,
|
||||
) -> None:
|
||||
self.extension_id = extension_id
|
||||
self.permissions, self.permission_policies = self._permission_data(permissions)
|
||||
self.user_id = user_id
|
||||
self.access_token = access_token
|
||||
self.context = context
|
||||
self.owner_id = sha256s(user_id) if user_id else owner_id
|
||||
from .utils import ExtensionAPIUtils
|
||||
|
||||
self.utils = ExtensionAPIUtils(self.extension_id, self.permissions)
|
||||
|
||||
@extension_api_method(
|
||||
method_id="storage.get",
|
||||
namespace="storage",
|
||||
name="Get storage row",
|
||||
host_name="storage_get",
|
||||
sdk_name="get",
|
||||
description="Read one row from an extension storage table.",
|
||||
required_permission="ext.storage.read",
|
||||
require_auth=True,
|
||||
)
|
||||
async def storage_get(self, request: StorageGetRequest) -> StorageGetResponse:
|
||||
row = await storage_get_row(
|
||||
self.extension_id,
|
||||
request.table,
|
||||
request.id,
|
||||
self._require_owner_id(),
|
||||
)
|
||||
return StorageGetResponse(data_json=json.dumps(row) if row else None)
|
||||
|
||||
@extension_api_method(
|
||||
method_id="storage.get_public",
|
||||
namespace="storage",
|
||||
name="Get public storage row",
|
||||
host_name="storage_get_public",
|
||||
sdk_name="getPublic",
|
||||
description="Read one public row from an extension storage table.",
|
||||
required_permission="ext.storage.read_public",
|
||||
require_auth=False,
|
||||
)
|
||||
async def storage_get_public(
|
||||
self, request: StorageGetRequest
|
||||
) -> StorageGetResponse:
|
||||
public_fields = self._public_storage_fields(request.table)
|
||||
row = await storage_get_public_row(self.extension_id, request.table, request.id)
|
||||
if not row:
|
||||
return StorageGetResponse()
|
||||
public_row = {
|
||||
field_name: value
|
||||
for field_name, value in row.items()
|
||||
if field_name in public_fields
|
||||
}
|
||||
# todo: check public fields filtering
|
||||
return StorageGetResponse(data_json=json.dumps(public_row))
|
||||
|
||||
@extension_api_method(
|
||||
method_id="storage.set",
|
||||
namespace="storage",
|
||||
name="Set storage row",
|
||||
host_name="storage_set",
|
||||
sdk_name="set",
|
||||
description="Create or update one row in an extension storage table.",
|
||||
required_permission="ext.storage.write",
|
||||
require_auth=True,
|
||||
)
|
||||
async def storage_set(self, request: StorageSetRequest) -> StorageSetResponse:
|
||||
await storage_set_row(
|
||||
self.extension_id,
|
||||
request.table,
|
||||
request.data,
|
||||
self._require_owner_id(),
|
||||
)
|
||||
return StorageSetResponse()
|
||||
|
||||
@extension_api_method(
|
||||
method_id="storage.get_paginated",
|
||||
namespace="storage",
|
||||
name="Get paginated storage rows",
|
||||
host_name="storage_get_paginated",
|
||||
sdk_name="getPaginated",
|
||||
description="Get filtered, searched, sorted, paginated storage rows.",
|
||||
required_permission="ext.storage.read",
|
||||
require_auth=True,
|
||||
)
|
||||
async def storage_get_paginated(
|
||||
self, request: StoragePaginatedRequest
|
||||
) -> StoragePaginatedResponse:
|
||||
page = await storage_get_paginated_rows(
|
||||
self.extension_id,
|
||||
request.table,
|
||||
request.filters,
|
||||
owner_id=self._require_owner_id(),
|
||||
search=request.search,
|
||||
search_fields=request.search_fields,
|
||||
sort_by=request.sort_by,
|
||||
descending=request.descending,
|
||||
limit=request.limit,
|
||||
offset=request.offset,
|
||||
)
|
||||
return StoragePaginatedResponse(
|
||||
rows_json=json.dumps(page["data"]),
|
||||
total=page["total"],
|
||||
)
|
||||
|
||||
@extension_api_method(
|
||||
method_id="storage.delete",
|
||||
namespace="storage",
|
||||
name="Delete storage row",
|
||||
host_name="storage_delete",
|
||||
sdk_name="delete",
|
||||
description="Delete one row from an extension storage table.",
|
||||
required_permission="ext.storage.write",
|
||||
require_auth=True,
|
||||
)
|
||||
async def storage_delete(
|
||||
self, request: StorageDeleteRequest
|
||||
) -> StorageDeleteResponse:
|
||||
await storage_delete_row(
|
||||
self.extension_id,
|
||||
request.table,
|
||||
request.id,
|
||||
self._require_owner_id(),
|
||||
)
|
||||
return StorageDeleteResponse()
|
||||
|
||||
@extension_api_method(
|
||||
method_id="wallet.create_invoice",
|
||||
namespace="wallet",
|
||||
name="Create invoice",
|
||||
host_name="create_invoice",
|
||||
sdk_name="createInvoice",
|
||||
description="Create an incoming Lightning invoice for an allowed wallet.",
|
||||
required_permission="wallet.create_invoice",
|
||||
require_auth=True,
|
||||
)
|
||||
async def wallet_create_invoice(
|
||||
self, request: CreateInvoiceRequest
|
||||
) -> CreateInvoiceResponse:
|
||||
from lnbits.core.crud.wallets import get_wallet
|
||||
from lnbits.core.models.payments import CreateInvoice
|
||||
from lnbits.core.services.payments import create_payment_request
|
||||
|
||||
if not self.user_id:
|
||||
raise PermissionError(
|
||||
"Creating an invoice for this wallet requires an "
|
||||
"authenticated user context."
|
||||
)
|
||||
wallet = await get_wallet(request.wallet_id)
|
||||
if wallet is None or wallet.user != self.user_id:
|
||||
raise PermissionError("Not your wallet.")
|
||||
|
||||
payment = await create_payment_request(
|
||||
request.wallet_id,
|
||||
CreateInvoice(
|
||||
amount=request.amount,
|
||||
unit=request.currency,
|
||||
memo=request.memo,
|
||||
extra=request.extra,
|
||||
extension=self.extension_id,
|
||||
),
|
||||
)
|
||||
return CreateInvoiceResponse(
|
||||
payment_hash=payment.payment_hash,
|
||||
payment_request=payment.payment_request or payment.bolt11,
|
||||
checking_id=payment.checking_id,
|
||||
)
|
||||
|
||||
@extension_api_method(
|
||||
method_id="wallet.create_invoice_public",
|
||||
namespace="wallet",
|
||||
name="Create public invoice",
|
||||
host_name="create_invoice_public",
|
||||
sdk_name="createInvoicePublic",
|
||||
description="Create a public incoming Lightning invoice.",
|
||||
required_permission="wallet.create_invoice_public",
|
||||
require_auth=False,
|
||||
)
|
||||
async def wallet_create_invoice_public(
|
||||
self, request: CreateInvoicePublicRequest
|
||||
) -> CreateInvoiceResponse:
|
||||
from lnbits.core.models.payments import CreateInvoice
|
||||
from lnbits.core.services.payments import create_payment_request
|
||||
|
||||
row: dict[str, Any] | None = None
|
||||
wallet_field = ""
|
||||
for policy in self._public_invoice_wallet_sources():
|
||||
row = await storage_get_public_row(
|
||||
self.extension_id,
|
||||
policy["table"],
|
||||
request.source_id,
|
||||
)
|
||||
if row:
|
||||
wallet_field = policy["wallet_field"]
|
||||
break
|
||||
|
||||
if not row:
|
||||
raise PermissionError("Public invoice source was not found.")
|
||||
|
||||
wallet_id = row.get(wallet_field)
|
||||
if not isinstance(wallet_id, str) or not wallet_id:
|
||||
raise PermissionError("Public invoice source has no valid wallet.")
|
||||
|
||||
payment = await create_payment_request(
|
||||
wallet_id,
|
||||
CreateInvoice(
|
||||
amount=request.amount,
|
||||
unit=request.currency,
|
||||
memo=request.memo,
|
||||
extra={
|
||||
"tag": self.extension_id,
|
||||
"source_id": request.source_id,
|
||||
f"extra_{self.extension_id}": request.extra,
|
||||
},
|
||||
extension=self.extension_id,
|
||||
),
|
||||
)
|
||||
return CreateInvoiceResponse(
|
||||
payment_hash=payment.payment_hash,
|
||||
payment_request=payment.payment_request or payment.bolt11,
|
||||
checking_id=payment.checking_id,
|
||||
)
|
||||
|
||||
@extension_api_method(
|
||||
method_id="wallet.list_user_wallets",
|
||||
namespace="wallet",
|
||||
name="List user wallets",
|
||||
host_name="list_user_wallets",
|
||||
sdk_name="listUserWallets",
|
||||
description="List wallets available to the authenticated extension user.",
|
||||
required_permission="wallet.list",
|
||||
)
|
||||
async def wallet_list_user_wallets(
|
||||
self, request: EmptyRequest
|
||||
) -> ListUserWalletsResponse:
|
||||
if not self.user_id:
|
||||
raise PermissionError(
|
||||
"Listing user wallets requires an authenticated user context."
|
||||
)
|
||||
|
||||
from lnbits.core.crud.wallets import get_wallets
|
||||
|
||||
user_wallets = await get_wallets(self.user_id)
|
||||
if user_wallets is None:
|
||||
raise PermissionError(
|
||||
"Listing user wallets requires an authenticated user context."
|
||||
)
|
||||
return ListUserWalletsResponse(
|
||||
wallets=[
|
||||
UserWalletSummary(id=w.id, name=w.name, currency=w.currency)
|
||||
for w in user_wallets
|
||||
]
|
||||
)
|
||||
|
||||
@extension_api_method(
|
||||
method_id="wallet.balance",
|
||||
namespace="wallet",
|
||||
name="Read wallet balance",
|
||||
host_name="wallet_balance",
|
||||
sdk_name="balance",
|
||||
description="Read the balance of a wallet available to the user.",
|
||||
required_permission="wallet.balance.read",
|
||||
)
|
||||
async def wallet_balance(
|
||||
self, request: WalletBalanceRequest
|
||||
) -> WalletBalanceResponse:
|
||||
from lnbits.core.crud.wallets import get_wallet
|
||||
|
||||
if not self.user_id:
|
||||
raise PermissionError(
|
||||
"Reading a wallet balance requires an authenticated user context."
|
||||
)
|
||||
|
||||
wallet = await get_wallet(request.wallet_id)
|
||||
if wallet is None or wallet.user != self.user_id:
|
||||
raise PermissionError("Reading this wallet balance is not allowed.")
|
||||
|
||||
withdrawable_msat = max(wallet.withdrawable_balance, 0)
|
||||
fee_reserve_msat = max(wallet.balance_msat - withdrawable_msat, 0)
|
||||
return WalletBalanceResponse(
|
||||
wallet_id=wallet.id,
|
||||
name=wallet.name,
|
||||
currency=wallet.currency,
|
||||
balance_msat=wallet.balance_msat,
|
||||
balance_sat=wallet.balance,
|
||||
withdrawable_msat=withdrawable_msat,
|
||||
withdrawable_sat=withdrawable_msat // 1000,
|
||||
fee_reserve_msat=fee_reserve_msat,
|
||||
fee_reserve_sat=fee_reserve_msat // 1000,
|
||||
can_send_payments=wallet.can_send_payments,
|
||||
)
|
||||
|
||||
@extension_api_method(
|
||||
method_id="wallet.pay_invoice",
|
||||
namespace="wallet",
|
||||
name="Pay invoice",
|
||||
host_name="pay_invoice",
|
||||
sdk_name="payInvoice",
|
||||
description="Pay a Lightning invoice from a wallet available to the user.",
|
||||
required_permission="wallet.pay_invoice",
|
||||
)
|
||||
async def wallet_pay_invoice(
|
||||
self, request: PayInvoiceRequest
|
||||
) -> PayInvoiceResponse:
|
||||
from lnbits.core.crud.wallets import get_wallet
|
||||
from lnbits.core.services.payments import pay_invoice
|
||||
from lnbits.exceptions import PaymentError
|
||||
|
||||
if not self.user_id:
|
||||
raise PermissionError(
|
||||
"Paying an invoice requires an authenticated user context."
|
||||
)
|
||||
|
||||
wallet = await get_wallet(request.wallet_id)
|
||||
if wallet is None or wallet.user != self.user_id:
|
||||
raise PermissionError("Paying invoices from this wallet is not allowed.")
|
||||
|
||||
try:
|
||||
payment = await pay_invoice(
|
||||
wallet_id=request.wallet_id,
|
||||
payment_request=request.payment_request,
|
||||
max_sat=request.max_sat,
|
||||
extra={"tag": self.extension_id, **request.extra},
|
||||
description=request.description,
|
||||
tag=self.extension_id,
|
||||
)
|
||||
except (PaymentError, ValueError) as exc:
|
||||
return PayInvoiceResponse(ok=False, error=str(exc))
|
||||
|
||||
return PayInvoiceResponse(
|
||||
ok=True,
|
||||
checking_id=payment.checking_id,
|
||||
payment_hash=payment.payment_hash,
|
||||
status=payment.status,
|
||||
amount_msat=abs(payment.amount),
|
||||
fee_msat=abs(payment.fee),
|
||||
pending=payment.pending,
|
||||
success=payment.success,
|
||||
)
|
||||
|
||||
@extension_api_method(
|
||||
method_id="http.request",
|
||||
namespace="http",
|
||||
name="HTTP request",
|
||||
host_name="http_request",
|
||||
sdk_name="request",
|
||||
description="Make an outbound HTTP request to an allowed host.",
|
||||
required_permission="http.request",
|
||||
require_auth=True,
|
||||
)
|
||||
async def http_request(self, request: HttpRequest) -> HttpResponse:
|
||||
from ..client.http import send_extension_http_request
|
||||
|
||||
policies = self.permission_policies.get("http.request") or []
|
||||
return await send_extension_http_request(self.extension_id, policies, request)
|
||||
|
||||
@extension_api_method(
|
||||
method_id="extension.api.request",
|
||||
namespace="extension",
|
||||
name="Extension API request",
|
||||
host_name="extension_api_request",
|
||||
sdk_name="request",
|
||||
description="Call an allowed installed extension API.",
|
||||
required_permission="extension.api.request",
|
||||
require_auth=True,
|
||||
)
|
||||
async def extension_api_request(self, request: ExtensionApiRequest) -> HttpResponse:
|
||||
|
||||
policies = self.permission_policies.get("extension.api.request") or []
|
||||
return await send_extension_api_request(
|
||||
self.extension_id,
|
||||
policies,
|
||||
self.user_id,
|
||||
self.access_token,
|
||||
request,
|
||||
)
|
||||
|
||||
@extension_api_method(
|
||||
method_id="system.random_id",
|
||||
namespace="system",
|
||||
name="Random ID",
|
||||
host_name="random_id",
|
||||
sdk_name="id",
|
||||
description="Create a random extension-local identifier.",
|
||||
require_auth=False,
|
||||
)
|
||||
async def system_random_id(self, request: RandomIdRequest) -> RandomIdResponse:
|
||||
return RandomIdResponse(
|
||||
id=f"{request.prefix}_{secrets.token_urlsafe(12).replace('-', '_')}"
|
||||
)
|
||||
|
||||
@extension_api_method(
|
||||
method_id="system.now",
|
||||
namespace="system",
|
||||
name="Current timestamp",
|
||||
host_name="now",
|
||||
sdk_name="now",
|
||||
description="Return the current Unix timestamp.",
|
||||
require_auth=False,
|
||||
)
|
||||
async def system_now(self, request: EmptyRequest) -> NowResponse:
|
||||
return NowResponse(timestamp=int(time.time()))
|
||||
|
||||
@extension_api_method(
|
||||
method_id="system.log",
|
||||
namespace="system",
|
||||
name="Log message",
|
||||
host_name="log",
|
||||
sdk_name="log",
|
||||
description="Write a bounded message to the extension log.",
|
||||
require_auth=False,
|
||||
)
|
||||
async def system_log(self, request: LogRequest) -> LogResponse:
|
||||
log = getattr(logger, request.level)
|
||||
log("extension:%s %s", self.extension_id, request.message)
|
||||
return LogResponse()
|
||||
|
||||
@staticmethod
|
||||
def _permission_data(
|
||||
permissions: Iterable[Any],
|
||||
) -> tuple[set[str], dict[str, list[Any]]]:
|
||||
permission_ids: set[str] = set()
|
||||
policies: dict[str, list[Any]] = {}
|
||||
|
||||
for permission in permissions:
|
||||
if isinstance(permission, str):
|
||||
permission_ids.add(permission)
|
||||
continue
|
||||
|
||||
permission_id: str | None = None
|
||||
permission_policies: Any = None
|
||||
if isinstance(permission, Mapping):
|
||||
permission_id = permission.get("id") # type: ignore[assignment]
|
||||
permission_policies = permission.get("policies")
|
||||
else:
|
||||
permission_id = getattr(permission, "id", None)
|
||||
permission_policies = getattr(permission, "policies", None)
|
||||
|
||||
if not permission_id:
|
||||
continue
|
||||
permission_ids.add(permission_id)
|
||||
if isinstance(permission_policies, list):
|
||||
policies[permission_id] = permission_policies
|
||||
|
||||
return permission_ids, policies
|
||||
|
||||
def _public_storage_fields(self, table: str) -> set[str]:
|
||||
tables = self.permission_policies.get("ext.storage.read_public")
|
||||
if not isinstance(tables, list) or not tables:
|
||||
raise PermissionError(
|
||||
"Public storage reads require policies for "
|
||||
"'ext.storage.read_public'."
|
||||
)
|
||||
|
||||
for table_policy in tables:
|
||||
if not isinstance(table_policy, dict):
|
||||
continue
|
||||
if table_policy.get("table_name") != table:
|
||||
continue
|
||||
public_fields = table_policy.get("public_fields")
|
||||
if not isinstance(public_fields, list) or not all(
|
||||
isinstance(field, str) and field for field in public_fields
|
||||
):
|
||||
raise PermissionError(
|
||||
f"Public storage table '{table}' has no valid public fields."
|
||||
)
|
||||
return set(public_fields)
|
||||
|
||||
raise PermissionError(f"Storage table '{table}' is not publicly readable.")
|
||||
|
||||
def _public_invoice_wallet_sources(self) -> list[dict[str, str]]:
|
||||
policies = self.permission_policies.get("wallet.create_invoice_public")
|
||||
if not isinstance(policies, list) or not policies:
|
||||
raise PermissionError("Public invoice creation requires a policies list.")
|
||||
|
||||
sources: list[dict[str, str]] = []
|
||||
for source_policy in policies:
|
||||
if not isinstance(source_policy, dict):
|
||||
raise PermissionError(
|
||||
"Public invoice creation policies must be objects."
|
||||
)
|
||||
table = source_policy.get("table")
|
||||
wallet_field = source_policy.get("wallet_field")
|
||||
if not isinstance(table, str) or not table:
|
||||
raise PermissionError(
|
||||
"Public invoice creation requires a storage table policy."
|
||||
)
|
||||
if not isinstance(wallet_field, str) or not wallet_field:
|
||||
raise PermissionError(
|
||||
"Public invoice creation requires a wallet field policy."
|
||||
)
|
||||
sources.append({"table": table, "wallet_field": wallet_field})
|
||||
|
||||
if not sources:
|
||||
raise PermissionError(
|
||||
"Public invoice creation requires at least one valid policy."
|
||||
)
|
||||
return sources
|
||||
|
||||
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 bool(self.user_id) or self.context == "event"
|
||||
|
||||
def _require_owner_id(self) -> str:
|
||||
if not self.owner_id:
|
||||
raise PermissionError("Extension API method requires an owner context.")
|
||||
return self.owner_id
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
"ExtensionHostAPI("
|
||||
f"extension_id={self.extension_id!r}, "
|
||||
f"context={self.context!r}, "
|
||||
f"owner_id={self.owner_id!r}"
|
||||
")"
|
||||
)
|
||||
@@ -0,0 +1,360 @@
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field, root_validator
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExtensionAPIMethodExport:
|
||||
method_id: str
|
||||
namespace: str
|
||||
name: str
|
||||
host_interface: str
|
||||
host_name: str
|
||||
sdk_name: str
|
||||
description: str
|
||||
required_permission: str | None = None
|
||||
require_auth: bool = True
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExtensionAPIMethod:
|
||||
method_id: str
|
||||
namespace: str
|
||||
name: str
|
||||
python_name: str
|
||||
host_interface: str
|
||||
host_name: str
|
||||
sdk_name: str
|
||||
description: str
|
||||
request_model: type[BaseModel]
|
||||
response_model: type[BaseModel]
|
||||
required_permission: str | None = None
|
||||
require_auth: bool = True
|
||||
|
||||
@property
|
||||
def sdk_qualified_name(self) -> str:
|
||||
return f"{self.namespace}.{self.sdk_name}"
|
||||
|
||||
|
||||
class EmptyRequest(BaseModel):
|
||||
pass
|
||||
|
||||
|
||||
class StorageGetRequest(BaseModel):
|
||||
table: str = Field(..., min_length=1, max_length=128)
|
||||
id: str = Field(..., min_length=1, max_length=512)
|
||||
|
||||
|
||||
class StorageGetResponse(BaseModel):
|
||||
data_json: str | None = None
|
||||
|
||||
|
||||
class StorageSetRequest(BaseModel):
|
||||
table: str = Field(..., min_length=1, max_length=128)
|
||||
data: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
@root_validator(pre=True)
|
||||
def parse_data_json(cls, values: dict[str, Any]) -> dict[str, Any]:
|
||||
data_json = values.get("data_json")
|
||||
if data_json is not None and "data" not in values:
|
||||
values["data"] = json.loads(data_json)
|
||||
return values
|
||||
|
||||
|
||||
class StorageSetResponse(BaseModel):
|
||||
ok: bool = True
|
||||
|
||||
|
||||
class StoragePaginatedRequest(BaseModel):
|
||||
table: str = Field(..., min_length=1, max_length=128)
|
||||
filters: dict[str, Any] = Field(default_factory=dict)
|
||||
search: str | None = Field(None, max_length=256)
|
||||
search_fields: list[str] = Field(default_factory=list)
|
||||
sort_by: str | None = Field(None, min_length=1, max_length=128)
|
||||
descending: bool = False
|
||||
limit: int = Field(25, ge=1, le=1000)
|
||||
offset: int = Field(0, ge=0)
|
||||
|
||||
@root_validator(pre=True)
|
||||
def parse_json_fields(cls, values: dict[str, Any]) -> dict[str, Any]:
|
||||
filters_json = values.get("filters_json")
|
||||
if filters_json is not None and "filters" not in values:
|
||||
values["filters"] = json.loads(filters_json)
|
||||
|
||||
search_fields_json = values.get("search_fields_json")
|
||||
if search_fields_json is not None and "search_fields" not in values:
|
||||
values["search_fields"] = json.loads(search_fields_json)
|
||||
|
||||
if values.get("sort_by") == "":
|
||||
values["sort_by"] = None
|
||||
return values
|
||||
|
||||
|
||||
class StoragePaginatedResponse(BaseModel):
|
||||
rows_json: str = "[]"
|
||||
total: int = 0
|
||||
|
||||
|
||||
class StorageDeleteRequest(BaseModel):
|
||||
table: str = Field(..., min_length=1, max_length=128)
|
||||
id: str = Field(..., min_length=1, max_length=512)
|
||||
|
||||
|
||||
class StorageDeleteResponse(BaseModel):
|
||||
ok: bool = True
|
||||
|
||||
|
||||
class CreateInvoiceRequest(BaseModel):
|
||||
wallet_id: str = Field(..., min_length=1, max_length=128)
|
||||
amount: float = Field(..., gt=0)
|
||||
currency: str = Field("sat", min_length=1, max_length=8)
|
||||
memo: str = Field(..., max_length=512)
|
||||
tag: str = Field(..., min_length=1, max_length=64)
|
||||
extra: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class CreateInvoicePublicRequest(BaseModel):
|
||||
source_id: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
max_length=512,
|
||||
description="The source ID (entry id) of the wallet to create the invoice for.",
|
||||
)
|
||||
amount: float = Field(..., gt=0)
|
||||
currency: str = Field(..., min_length=1, max_length=8)
|
||||
memo: str = Field("", max_length=512)
|
||||
extra: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
@root_validator
|
||||
def validate_extra_size(cls, values: dict[str, Any]) -> dict[str, Any]:
|
||||
extra = values.get("extra") or {}
|
||||
try:
|
||||
encoded = json.dumps(extra, separators=(",", ":"))
|
||||
except TypeError as exc:
|
||||
raise ValueError("extra must be JSON serializable.") from exc
|
||||
if len(encoded.encode()) > 4096:
|
||||
raise ValueError("extra must not exceed 4096 bytes.")
|
||||
values["extra"] = extra
|
||||
return values
|
||||
|
||||
|
||||
class CreateInvoiceResponse(BaseModel):
|
||||
payment_hash: str
|
||||
payment_request: str
|
||||
checking_id: str
|
||||
|
||||
|
||||
class UserWalletSummary(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
currency: str | None = None
|
||||
|
||||
|
||||
class ListUserWalletsResponse(BaseModel):
|
||||
wallets: list[UserWalletSummary] = Field(default_factory=list)
|
||||
|
||||
|
||||
class WalletBalanceRequest(BaseModel):
|
||||
wallet_id: str = Field(..., min_length=1, max_length=128)
|
||||
|
||||
|
||||
class WalletBalanceResponse(BaseModel):
|
||||
wallet_id: str
|
||||
name: str
|
||||
currency: str | None = None
|
||||
balance_msat: int
|
||||
balance_sat: int
|
||||
withdrawable_msat: int
|
||||
withdrawable_sat: int
|
||||
fee_reserve_msat: int
|
||||
fee_reserve_sat: int
|
||||
can_send_payments: bool
|
||||
|
||||
|
||||
class PayInvoiceRequest(BaseModel):
|
||||
wallet_id: str = Field(..., min_length=1, max_length=128)
|
||||
payment_request: str = Field(..., min_length=1, max_length=8192)
|
||||
max_sat: int | None = Field(None, gt=0)
|
||||
description: str = Field("", max_length=512)
|
||||
extra: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class PayInvoiceResponse(BaseModel):
|
||||
ok: bool = True
|
||||
error: str | None = None
|
||||
checking_id: str | None = None
|
||||
payment_hash: str | None = None
|
||||
status: str | None = None
|
||||
amount_msat: int = 0
|
||||
fee_msat: int = 0
|
||||
pending: bool = False
|
||||
success: bool = False
|
||||
|
||||
|
||||
class HttpRequest(BaseModel):
|
||||
method: Literal["DELETE", "GET", "HEAD", "PATCH", "POST", "PUT"] = "GET"
|
||||
url: str = Field(..., min_length=1, max_length=2048)
|
||||
headers: dict[str, str] = Field(default_factory=dict)
|
||||
body: str | None = Field(None, max_length=65536)
|
||||
|
||||
@root_validator(pre=True)
|
||||
def normalize_method(cls, values: dict[str, Any]) -> dict[str, Any]:
|
||||
method = values.get("method")
|
||||
if isinstance(method, str):
|
||||
values["method"] = method.upper()
|
||||
return values
|
||||
|
||||
@root_validator
|
||||
def validate_headers_size(cls, values: dict[str, Any]) -> dict[str, Any]:
|
||||
headers = values.get("headers") or {}
|
||||
if len(headers) > 32:
|
||||
raise ValueError("headers must not contain more than 32 entries.")
|
||||
for key, value in headers.items():
|
||||
if len(key) > 128 or len(value) > 4096:
|
||||
raise ValueError("headers are too large.")
|
||||
values["headers"] = headers
|
||||
return values
|
||||
|
||||
|
||||
class HttpResponse(BaseModel):
|
||||
status_code: int
|
||||
headers: dict[str, str] = Field(default_factory=dict)
|
||||
body: str = ""
|
||||
|
||||
|
||||
class ExtensionApiRequest(BaseModel):
|
||||
extension_id: str = Field(..., min_length=1, max_length=128)
|
||||
method: Literal["DELETE", "GET", "HEAD", "PATCH", "POST", "PUT"] = "GET"
|
||||
path: str = Field(..., min_length=1, max_length=2048)
|
||||
body: str | None = Field(None, max_length=65536)
|
||||
|
||||
@root_validator(pre=True)
|
||||
def normalize_method(cls, values: dict[str, Any]) -> dict[str, Any]:
|
||||
method = values.get("method")
|
||||
if isinstance(method, str):
|
||||
values["method"] = method.upper()
|
||||
return values
|
||||
|
||||
|
||||
class CurrencyListResponse(BaseModel):
|
||||
currencies: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CurrencyRateRequest(BaseModel):
|
||||
currency: str = Field(..., min_length=1, max_length=8)
|
||||
|
||||
|
||||
class CurrencyRateResponse(BaseModel):
|
||||
rate: float
|
||||
price: float
|
||||
|
||||
|
||||
class CurrencyConvertRequest(BaseModel):
|
||||
amount: float = Field(..., gt=0)
|
||||
from_currency: str = Field(..., alias="from", min_length=1, max_length=8)
|
||||
to: str = Field(..., min_length=1, max_length=256)
|
||||
|
||||
class Config:
|
||||
allow_population_by_field_name = True
|
||||
|
||||
|
||||
class CurrencyConvertResponse(BaseModel):
|
||||
amounts: list[tuple[str, float]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class FiatToSatsRequest(BaseModel):
|
||||
amount: float = Field(..., gt=0)
|
||||
currency: str = Field(..., min_length=1, max_length=8)
|
||||
|
||||
|
||||
class FiatToSatsResponse(BaseModel):
|
||||
amount_sat: int
|
||||
|
||||
|
||||
class SatsToFiatRequest(BaseModel):
|
||||
amount: float = Field(..., gt=0)
|
||||
currency: str = Field(..., min_length=1, max_length=8)
|
||||
|
||||
|
||||
class SatsToFiatResponse(BaseModel):
|
||||
amount: float
|
||||
|
||||
|
||||
class ServerHealthResponse(BaseModel):
|
||||
server_time: int
|
||||
up_time: str
|
||||
|
||||
|
||||
class Bolt11Request(BaseModel):
|
||||
bolt11: str = Field(..., min_length=1, max_length=8192)
|
||||
|
||||
|
||||
class DecodeInvoiceResponse(BaseModel):
|
||||
valid: bool = True
|
||||
payment_hash: str | None = None
|
||||
amount_msat: int | None = None
|
||||
expiry: int | None = None
|
||||
expires_at: int | None = None
|
||||
memo: str | None = None
|
||||
|
||||
|
||||
class ValidateInvoiceResponse(BaseModel):
|
||||
valid: bool
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class InvoicePaymentHashResponse(BaseModel):
|
||||
payment_hash: str
|
||||
|
||||
|
||||
class InvoiceAmountMsatResponse(BaseModel):
|
||||
amount_msat: int | None = None
|
||||
|
||||
|
||||
class InvoiceExpiryResponse(BaseModel):
|
||||
expires_at: int | None = None
|
||||
|
||||
|
||||
class InvoiceMemoResponse(BaseModel):
|
||||
memo: str | None = None
|
||||
|
||||
|
||||
class VerifyPreimageRequest(BaseModel):
|
||||
preimage: str = Field(..., min_length=64, max_length=64)
|
||||
payment_hash: str = Field(..., min_length=64, max_length=64)
|
||||
|
||||
|
||||
class VerifyPreimageResponse(BaseModel):
|
||||
valid: bool
|
||||
|
||||
|
||||
class RandomSecretAndHashRequest(BaseModel):
|
||||
length: int = Field(32, ge=16, le=64)
|
||||
|
||||
|
||||
class RandomSecretAndHashResponse(BaseModel):
|
||||
secret: str
|
||||
hash: str
|
||||
|
||||
|
||||
class RandomIdRequest(BaseModel):
|
||||
prefix: str = Field(..., min_length=1, max_length=32)
|
||||
|
||||
|
||||
class RandomIdResponse(BaseModel):
|
||||
id: str
|
||||
|
||||
|
||||
class NowResponse(BaseModel):
|
||||
timestamp: int
|
||||
|
||||
|
||||
class LogRequest(BaseModel):
|
||||
level: Literal["debug", "info", "warning", "error"] = "info"
|
||||
message: str = Field(..., min_length=1, max_length=2048)
|
||||
|
||||
|
||||
class LogResponse(BaseModel):
|
||||
ok: bool = True
|
||||
@@ -0,0 +1,59 @@
|
||||
from collections.abc import Iterable
|
||||
from typing import Any
|
||||
|
||||
from lnbits.core.models.extensions import ExtensionPermission, InstallableExtension
|
||||
from lnbits.core.wasm_ext.api.registry import extension_api_permission_ids
|
||||
|
||||
|
||||
def validate_extension_permissions(
|
||||
ext_id: str,
|
||||
permissions: Iterable[ExtensionPermission],
|
||||
*,
|
||||
strict: bool = True,
|
||||
) -> list[ExtensionPermission]:
|
||||
known_permission_ids = extension_api_permission_ids()
|
||||
normalized_permissions: list[ExtensionPermission] = []
|
||||
unknown_ids: list[str] = []
|
||||
|
||||
for permission in permissions:
|
||||
if permission.id not in known_permission_ids:
|
||||
unknown_ids.append(permission.id)
|
||||
if strict:
|
||||
continue
|
||||
normalized_permissions.append(permission.copy(update={"label": None}))
|
||||
|
||||
if unknown_ids and strict:
|
||||
raise ValueError(
|
||||
f"Extension '{ext_id}' requests unknown permissions: "
|
||||
+ ", ".join(sorted(set(unknown_ids)))
|
||||
)
|
||||
|
||||
return normalized_permissions
|
||||
|
||||
|
||||
def validate_wasm_extension_permissions(
|
||||
ext_info: InstallableExtension,
|
||||
granted_permissions: list[ExtensionPermission] | None,
|
||||
extension_config: dict[str, Any],
|
||||
) -> list[ExtensionPermission]:
|
||||
if extension_config.get("extension_type") != "wasm":
|
||||
return []
|
||||
|
||||
requested_permissions = validate_extension_permissions(
|
||||
ext_info.id,
|
||||
ExtensionPermission.list_from_config(extension_config),
|
||||
)
|
||||
if not requested_permissions:
|
||||
return []
|
||||
|
||||
if granted_permissions is None:
|
||||
raise ValueError(f"Extension '{ext_info.id}' requires permission approval.")
|
||||
|
||||
requested_ids = {permission.id for permission in requested_permissions}
|
||||
granted_ids = {permission.id for permission in granted_permissions}
|
||||
if requested_ids != granted_ids:
|
||||
raise ValueError(
|
||||
f"Extension '{ext_info.id}' was not granted all requested permissions."
|
||||
)
|
||||
|
||||
return requested_permissions
|
||||
@@ -0,0 +1,199 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
from collections.abc import Awaitable, Callable
|
||||
from functools import wraps
|
||||
from typing import Any, TypeVar, cast, get_type_hints
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .models import ExtensionAPIMethod, ExtensionAPIMethodExport
|
||||
|
||||
_EXTENSION_API_METHOD_ATTR = "__lnbits_extension_api_method__"
|
||||
_EXTENSION_RUNTIME_PERMISSION_IDS = {"ui.camera.scan_qr"}
|
||||
_RequestModel = TypeVar("_RequestModel", bound=BaseModel)
|
||||
_ResponseModel = TypeVar("_ResponseModel", bound=BaseModel)
|
||||
|
||||
|
||||
def extension_api_method(
|
||||
*,
|
||||
method_id: str,
|
||||
namespace: str,
|
||||
name: str,
|
||||
host_name: str,
|
||||
sdk_name: str,
|
||||
description: str,
|
||||
host_interface: str = "host",
|
||||
required_permission: str | None = None,
|
||||
require_auth: bool = True,
|
||||
) -> Callable[
|
||||
[Callable[[Any, _RequestModel], Awaitable[_ResponseModel]]],
|
||||
Callable[[Any, _RequestModel], Awaitable[_ResponseModel]],
|
||||
]:
|
||||
export = ExtensionAPIMethodExport(
|
||||
method_id=method_id,
|
||||
namespace=namespace,
|
||||
name=name,
|
||||
host_interface=host_interface,
|
||||
host_name=host_name,
|
||||
sdk_name=sdk_name,
|
||||
description=description,
|
||||
required_permission=required_permission,
|
||||
require_auth=require_auth,
|
||||
)
|
||||
|
||||
def decorator(
|
||||
function: Callable[[Any, _RequestModel], Awaitable[_ResponseModel]],
|
||||
) -> Callable[[Any, _RequestModel], Awaitable[_ResponseModel]]:
|
||||
@wraps(function)
|
||||
async def wrapper(self: Any, request: _RequestModel) -> _ResponseModel:
|
||||
api = getattr(self, "api", self)
|
||||
if require_auth and not api.has_authenticated_context():
|
||||
raise PermissionError(
|
||||
f"Extension API method '{method_id}' requires authentication."
|
||||
)
|
||||
api.require_permission(required_permission)
|
||||
return await function(self, request)
|
||||
|
||||
setattr(wrapper, _EXTENSION_API_METHOD_ATTR, export)
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def list_extension_api_methods(
|
||||
api_cls: type[Any] | None = None,
|
||||
) -> list[ExtensionAPIMethod]:
|
||||
api_cls = _default_api_cls(api_cls)
|
||||
methods: list[ExtensionAPIMethod] = []
|
||||
|
||||
for prefix, method_cls in _extension_api_method_sources(api_cls):
|
||||
for python_name, function in inspect.getmembers(method_cls, inspect.isfunction):
|
||||
export = getattr(function, _EXTENSION_API_METHOD_ATTR, None)
|
||||
if not export:
|
||||
continue
|
||||
|
||||
request_model, response_model = _get_method_models(function)
|
||||
methods.append(
|
||||
ExtensionAPIMethod(
|
||||
method_id=export.method_id,
|
||||
namespace=export.namespace,
|
||||
name=export.name,
|
||||
python_name=f"{prefix}.{python_name}" if prefix else python_name,
|
||||
host_interface=export.host_interface,
|
||||
host_name=export.host_name,
|
||||
sdk_name=export.sdk_name,
|
||||
description=export.description,
|
||||
request_model=request_model,
|
||||
response_model=response_model,
|
||||
required_permission=export.required_permission,
|
||||
require_auth=export.require_auth,
|
||||
)
|
||||
)
|
||||
|
||||
return sorted(methods, key=lambda method: method.method_id)
|
||||
|
||||
|
||||
def extension_api_permission_ids(api_cls: type[Any] | None = None) -> set[str]:
|
||||
permissions = {
|
||||
method.required_permission
|
||||
for method in list_extension_api_methods(api_cls)
|
||||
if method.required_permission
|
||||
}
|
||||
permissions.update(_EXTENSION_RUNTIME_PERMISSION_IDS)
|
||||
return permissions
|
||||
|
||||
|
||||
def get_extension_api_method(
|
||||
method_id: str,
|
||||
api_cls: type[Any] | None = None,
|
||||
) -> ExtensionAPIMethod:
|
||||
for method in list_extension_api_methods(api_cls):
|
||||
if method.method_id == method_id:
|
||||
return method
|
||||
raise KeyError(f"Unknown extension API method '{method_id}'.")
|
||||
|
||||
|
||||
def extension_api_contract(api_cls: type[Any] | None = None) -> dict[str, object]:
|
||||
return {
|
||||
"version": 1,
|
||||
"methods": [
|
||||
{
|
||||
"id": method.method_id,
|
||||
"namespace": method.namespace,
|
||||
"name": method.name,
|
||||
"python_name": method.python_name,
|
||||
"host_interface": method.host_interface,
|
||||
"host_name": method.host_name,
|
||||
"sdk_name": method.sdk_name,
|
||||
"sdk_qualified_name": method.sdk_qualified_name,
|
||||
"description": method.description,
|
||||
"required_permission": method.required_permission,
|
||||
"require_auth": method.require_auth,
|
||||
"request_schema": method.request_model.schema(
|
||||
ref_template="#/definitions/{model}"
|
||||
),
|
||||
"response_schema": method.response_model.schema(
|
||||
ref_template="#/definitions/{model}"
|
||||
),
|
||||
}
|
||||
for method in list_extension_api_methods(api_cls)
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _default_api_cls(api_cls: type[Any] | None) -> type[Any]:
|
||||
if api_cls is not None:
|
||||
return api_cls
|
||||
|
||||
from .host import ExtensionHostAPI
|
||||
|
||||
return ExtensionHostAPI
|
||||
|
||||
|
||||
def _extension_api_method_sources(
|
||||
api_cls: type[Any],
|
||||
) -> list[tuple[str, type[Any]]]:
|
||||
sources: list[tuple[str, type[Any]]] = [("", api_cls)]
|
||||
|
||||
from .host import ExtensionHostAPI
|
||||
|
||||
if issubclass(api_cls, ExtensionHostAPI):
|
||||
from .utils import extension_api_utils_method_classes
|
||||
|
||||
sources.extend(extension_api_utils_method_classes().items())
|
||||
return sources
|
||||
|
||||
|
||||
def _get_method_models(
|
||||
function: Callable[..., object],
|
||||
) -> tuple[type[BaseModel], type[BaseModel]]:
|
||||
signature = inspect.signature(function)
|
||||
request_parameters = [
|
||||
parameter
|
||||
for parameter in signature.parameters.values()
|
||||
if parameter.name != "self"
|
||||
]
|
||||
if len(request_parameters) != 1:
|
||||
raise TypeError(
|
||||
f"Extension API method '{function.__name__}' must accept one request model."
|
||||
)
|
||||
|
||||
hints = get_type_hints(function)
|
||||
request_model = hints.get(request_parameters[0].name)
|
||||
response_model = hints.get("return")
|
||||
|
||||
if not _is_pydantic_model(request_model):
|
||||
raise TypeError(
|
||||
f"Extension API method '{function.__name__}' request must be a BaseModel."
|
||||
)
|
||||
if not _is_pydantic_model(response_model):
|
||||
raise TypeError(
|
||||
f"Extension API method '{function.__name__}' response must be a BaseModel."
|
||||
)
|
||||
|
||||
return cast(type[BaseModel], request_model), cast(type[BaseModel], response_model)
|
||||
|
||||
|
||||
def _is_pydantic_model(value: object) -> bool:
|
||||
return isinstance(value, type) and issubclass(value, BaseModel)
|
||||
@@ -0,0 +1,138 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import re
|
||||
from collections.abc import Awaitable, Callable, Mapping
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .host import ExtensionHostAPI
|
||||
from .models import ExtensionAPIMethod
|
||||
from .registry import list_extension_api_methods
|
||||
|
||||
HostImport = Callable[..., Awaitable[dict[str, Any]]]
|
||||
|
||||
|
||||
class ExtensionAPIHost:
|
||||
def __init__(
|
||||
self,
|
||||
api: ExtensionHostAPI,
|
||||
*,
|
||||
api_cls: type[ExtensionHostAPI] = ExtensionHostAPI,
|
||||
) -> None:
|
||||
self.api = api
|
||||
self.methods = list_extension_api_methods(api_cls)
|
||||
self._methods_by_host_name = self._index_methods(self.methods)
|
||||
|
||||
async def invoke(
|
||||
self,
|
||||
host_name: str,
|
||||
payload: Mapping[str, Any] | BaseModel | None = None,
|
||||
) -> dict[str, Any]:
|
||||
method = self._require_method(host_name)
|
||||
request = self._request_model(method, payload)
|
||||
handler = _resolve_attr_path(self.api, method.python_name)
|
||||
response = handler(request)
|
||||
if inspect.isawaitable(response):
|
||||
response = await response
|
||||
return self._response_payload(method, response)
|
||||
|
||||
def imports(self) -> dict[str, HostImport]:
|
||||
return self.imports_for_interface("host")
|
||||
|
||||
def import_object(self) -> dict[str, dict[str, HostImport]]:
|
||||
interfaces = sorted({method.host_interface for method in self.methods})
|
||||
return {
|
||||
f"lnbits:extension/{interface}": self.imports_for_interface(interface)
|
||||
for interface in interfaces
|
||||
}
|
||||
|
||||
def imports_for_interface(self, host_interface: str) -> dict[str, HostImport]:
|
||||
return {
|
||||
_snake_to_camel(method.host_name): self._make_import(method)
|
||||
for method in self.methods
|
||||
if method.host_interface == host_interface
|
||||
}
|
||||
|
||||
def _make_import(self, method: ExtensionAPIMethod) -> HostImport:
|
||||
async def host_import(
|
||||
payload: Mapping[str, Any] | BaseModel | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return await self.invoke(method.method_id, payload)
|
||||
|
||||
return host_import
|
||||
|
||||
def _require_method(self, host_name: str) -> ExtensionAPIMethod:
|
||||
method = self._methods_by_host_name.get(host_name)
|
||||
if not method:
|
||||
raise KeyError(f"Unknown extension host function '{host_name}'.")
|
||||
return method
|
||||
|
||||
@staticmethod
|
||||
def _index_methods(
|
||||
methods: list[ExtensionAPIMethod],
|
||||
) -> dict[str, ExtensionAPIMethod]:
|
||||
index: dict[str, ExtensionAPIMethod] = {}
|
||||
for method in methods:
|
||||
for host_name in {
|
||||
method.method_id,
|
||||
f"{method.host_interface}:{method.host_name}",
|
||||
method.host_name,
|
||||
_snake_to_camel(method.host_name),
|
||||
method.host_name.replace("_", "-"),
|
||||
}:
|
||||
index[host_name] = method
|
||||
return index
|
||||
|
||||
@staticmethod
|
||||
def _request_model(
|
||||
method: ExtensionAPIMethod,
|
||||
payload: Mapping[str, Any] | BaseModel | None,
|
||||
) -> BaseModel:
|
||||
if isinstance(payload, method.request_model):
|
||||
return payload
|
||||
if isinstance(payload, BaseModel):
|
||||
payload = payload.dict()
|
||||
if payload is None:
|
||||
payload = {}
|
||||
if not isinstance(payload, Mapping):
|
||||
raise TypeError(
|
||||
f"Host function '{method.host_name}' expects an object payload."
|
||||
)
|
||||
data = {_to_snake(key): value for key, value in payload.items()}
|
||||
if isinstance(data.get("extra"), list):
|
||||
data["extra"] = dict(data["extra"])
|
||||
if isinstance(data.get("headers"), list):
|
||||
data["headers"] = dict(data["headers"])
|
||||
return method.request_model.parse_obj(data)
|
||||
|
||||
@staticmethod
|
||||
def _response_payload(
|
||||
method: ExtensionAPIMethod,
|
||||
response: Any,
|
||||
) -> dict[str, Any]:
|
||||
if not isinstance(response, method.response_model):
|
||||
response = method.response_model.parse_obj(response)
|
||||
payload = response.dict()
|
||||
if method.method_id in {"http.request", "extension.api.request"} and isinstance(
|
||||
payload.get("headers"), Mapping
|
||||
):
|
||||
payload["headers"] = list(payload["headers"].items())
|
||||
return {_snake_to_camel(key): value for key, value in payload.items()}
|
||||
|
||||
|
||||
def _snake_to_camel(value: str) -> str:
|
||||
head, *tail = value.split("_")
|
||||
return head + "".join(part.capitalize() for part in tail)
|
||||
|
||||
|
||||
def _to_snake(value: str) -> str:
|
||||
value = value.replace("-", "_")
|
||||
return re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", value).lower()
|
||||
|
||||
|
||||
def _resolve_attr_path(value: Any, path: str) -> Any:
|
||||
for part in path.split("."):
|
||||
value = getattr(value, part)
|
||||
return value
|
||||
@@ -0,0 +1,387 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections.abc import Iterable
|
||||
from datetime import datetime
|
||||
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,
|
||||
CurrencyConvertRequest,
|
||||
CurrencyConvertResponse,
|
||||
CurrencyListResponse,
|
||||
CurrencyRateRequest,
|
||||
CurrencyRateResponse,
|
||||
DecodeInvoiceResponse,
|
||||
EmptyRequest,
|
||||
FiatToSatsRequest,
|
||||
FiatToSatsResponse,
|
||||
InvoiceAmountMsatResponse,
|
||||
InvoiceExpiryResponse,
|
||||
InvoiceMemoResponse,
|
||||
InvoicePaymentHashResponse,
|
||||
RandomSecretAndHashRequest,
|
||||
RandomSecretAndHashResponse,
|
||||
SatsToFiatRequest,
|
||||
SatsToFiatResponse,
|
||||
ServerHealthResponse,
|
||||
ValidateInvoiceResponse,
|
||||
VerifyPreimageRequest,
|
||||
VerifyPreimageResponse,
|
||||
)
|
||||
from .registry import extension_api_method
|
||||
|
||||
|
||||
class ExtensionAPIUtils:
|
||||
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, 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):
|
||||
@extension_api_method(
|
||||
method_id="utils.currencies.list",
|
||||
namespace="utils.currencies",
|
||||
name="List currencies",
|
||||
host_interface="utils-currencies",
|
||||
host_name="list_currencies",
|
||||
sdk_name="list",
|
||||
description="List currencies supported by LNbits exchange-rate conversion.",
|
||||
required_permission="utils.basic",
|
||||
require_auth=False,
|
||||
)
|
||||
async def list(self, request: EmptyRequest) -> CurrencyListResponse:
|
||||
|
||||
return CurrencyListResponse(currencies=allowed_currencies())
|
||||
|
||||
@extension_api_method(
|
||||
method_id="utils.currencies.rate",
|
||||
namespace="utils.currencies",
|
||||
name="Get currency rate",
|
||||
host_interface="utils-currencies",
|
||||
host_name="rate",
|
||||
sdk_name="rate",
|
||||
description="Get sats-per-fiat and BTC price for a currency.",
|
||||
required_permission="utils.basic",
|
||||
require_auth=False,
|
||||
)
|
||||
async def rate(self, request: CurrencyRateRequest) -> CurrencyRateResponse:
|
||||
|
||||
rate, price = await get_fiat_rate_and_price_satoshis(request.currency)
|
||||
return CurrencyRateResponse(rate=rate, price=price)
|
||||
|
||||
@extension_api_method(
|
||||
method_id="utils.currencies.convert",
|
||||
namespace="utils.currencies",
|
||||
name="Convert currency amount",
|
||||
host_interface="utils-currencies",
|
||||
host_name="convert",
|
||||
sdk_name="convert",
|
||||
description="Convert between sats, BTC, and supported fiat currencies.",
|
||||
required_permission="utils.basic",
|
||||
require_auth=False,
|
||||
)
|
||||
async def convert(self, request: CurrencyConvertRequest) -> CurrencyConvertResponse:
|
||||
|
||||
from_currency = request.from_currency
|
||||
if from_currency == "sats":
|
||||
from_currency = "sat"
|
||||
|
||||
amounts: list[tuple[str, float]] = []
|
||||
if from_currency == "sat":
|
||||
sats = int(request.amount)
|
||||
amounts.append(("BTC", sats / 100_000_000))
|
||||
amounts.append(("sats", sats))
|
||||
for currency in request.to.split(","):
|
||||
currency = currency.strip()
|
||||
if currency:
|
||||
amounts.append(
|
||||
(
|
||||
currency.upper(),
|
||||
await satoshis_amount_as_fiat(sats, currency),
|
||||
)
|
||||
)
|
||||
else:
|
||||
sats = await fiat_amount_as_satoshis(request.amount, from_currency)
|
||||
amounts.append((from_currency.upper(), request.amount))
|
||||
amounts.append(("sats", sats))
|
||||
amounts.append(("BTC", sats / 100_000_000))
|
||||
return CurrencyConvertResponse(amounts=amounts)
|
||||
|
||||
@extension_api_method(
|
||||
method_id="utils.currencies.fiat_to_sats",
|
||||
namespace="utils.currencies",
|
||||
name="Convert fiat to sats",
|
||||
host_interface="utils-currencies",
|
||||
host_name="fiat_to_sats",
|
||||
sdk_name="fiatToSats",
|
||||
description="Convert a fiat amount to sats.",
|
||||
required_permission="utils.basic",
|
||||
require_auth=False,
|
||||
)
|
||||
async def fiat_to_sats(self, request: FiatToSatsRequest) -> FiatToSatsResponse:
|
||||
|
||||
return FiatToSatsResponse(
|
||||
amount_sat=await fiat_amount_as_satoshis(
|
||||
request.amount,
|
||||
request.currency,
|
||||
)
|
||||
)
|
||||
|
||||
@extension_api_method(
|
||||
method_id="utils.currencies.sats_to_fiat",
|
||||
namespace="utils.currencies",
|
||||
name="Convert sats to fiat",
|
||||
host_interface="utils-currencies",
|
||||
host_name="sats_to_fiat",
|
||||
sdk_name="satsToFiat",
|
||||
description="Convert a sats amount to fiat.",
|
||||
required_permission="utils.basic",
|
||||
require_auth=False,
|
||||
)
|
||||
async def sats_to_fiat(self, request: SatsToFiatRequest) -> SatsToFiatResponse:
|
||||
|
||||
return SatsToFiatResponse(
|
||||
amount=await satoshis_amount_as_fiat(request.amount, request.currency)
|
||||
)
|
||||
|
||||
|
||||
class ExtensionServerUtils(_ExtensionAPIUtilsGroup):
|
||||
@extension_api_method(
|
||||
method_id="utils.server.health",
|
||||
namespace="utils.server",
|
||||
name="Server health",
|
||||
host_interface="utils-server",
|
||||
host_name="health",
|
||||
sdk_name="health",
|
||||
description="Return basic public LNbits server health data.",
|
||||
required_permission="utils.basic",
|
||||
require_auth=False,
|
||||
)
|
||||
async def health(self, request: EmptyRequest) -> ServerHealthResponse:
|
||||
|
||||
return ServerHealthResponse(
|
||||
server_time=int(time.time()),
|
||||
up_time=settings.lnbits_server_up_time,
|
||||
)
|
||||
|
||||
|
||||
class ExtensionLightningUtils(_ExtensionAPIUtilsGroup):
|
||||
@extension_api_method(
|
||||
method_id="utils.lightning.decode_invoice",
|
||||
namespace="utils.lightning",
|
||||
name="Decode Lightning invoice",
|
||||
host_interface="utils-lightning",
|
||||
host_name="decode_invoice",
|
||||
sdk_name="decodeInvoice",
|
||||
description="Decode a BOLT11 Lightning invoice.",
|
||||
required_permission="utils.basic",
|
||||
require_auth=False,
|
||||
)
|
||||
async def decode_invoice(self, request: Bolt11Request) -> DecodeInvoiceResponse:
|
||||
invoice = _decode_bolt11(request.bolt11)
|
||||
return _decoded_invoice_response(invoice)
|
||||
|
||||
@extension_api_method(
|
||||
method_id="utils.lightning.validate_invoice",
|
||||
namespace="utils.lightning",
|
||||
name="Validate Lightning invoice",
|
||||
host_interface="utils-lightning",
|
||||
host_name="validate_invoice",
|
||||
sdk_name="validateInvoice",
|
||||
description="Validate whether a string is a BOLT11 Lightning invoice.",
|
||||
required_permission="utils.basic",
|
||||
require_auth=False,
|
||||
)
|
||||
async def validate_invoice(self, request: Bolt11Request) -> ValidateInvoiceResponse:
|
||||
try:
|
||||
_decode_bolt11(request.bolt11)
|
||||
return ValidateInvoiceResponse(valid=True)
|
||||
except Exception as exc:
|
||||
return ValidateInvoiceResponse(valid=False, error=str(exc))
|
||||
|
||||
@extension_api_method(
|
||||
method_id="utils.lightning.invoice_payment_hash",
|
||||
namespace="utils.lightning",
|
||||
name="Get Lightning invoice payment hash",
|
||||
host_interface="utils-lightning",
|
||||
host_name="invoice_payment_hash",
|
||||
sdk_name="invoicePaymentHash",
|
||||
description="Get the payment hash from a BOLT11 Lightning invoice.",
|
||||
required_permission="utils.basic",
|
||||
require_auth=False,
|
||||
)
|
||||
async def invoice_payment_hash(
|
||||
self, request: Bolt11Request
|
||||
) -> InvoicePaymentHashResponse:
|
||||
return InvoicePaymentHashResponse(
|
||||
payment_hash=str(_decode_bolt11(request.bolt11).payment_hash)
|
||||
)
|
||||
|
||||
@extension_api_method(
|
||||
method_id="utils.lightning.invoice_amount_msat",
|
||||
namespace="utils.lightning",
|
||||
name="Get Lightning invoice amount",
|
||||
host_interface="utils-lightning",
|
||||
host_name="invoice_amount_msat",
|
||||
sdk_name="invoiceAmountMsat",
|
||||
description="Get the amount in msat from a BOLT11 Lightning invoice.",
|
||||
required_permission="utils.basic",
|
||||
require_auth=False,
|
||||
)
|
||||
async def invoice_amount_msat(
|
||||
self, request: Bolt11Request
|
||||
) -> InvoiceAmountMsatResponse:
|
||||
return InvoiceAmountMsatResponse(
|
||||
amount_msat=_invoice_amount_msat(_decode_bolt11(request.bolt11))
|
||||
)
|
||||
|
||||
@extension_api_method(
|
||||
method_id="utils.lightning.invoice_expiry",
|
||||
namespace="utils.lightning",
|
||||
name="Get Lightning invoice expiry",
|
||||
host_interface="utils-lightning",
|
||||
host_name="invoice_expiry",
|
||||
sdk_name="invoiceExpiry",
|
||||
description="Get the expiry timestamp from a BOLT11 Lightning invoice.",
|
||||
required_permission="utils.basic",
|
||||
require_auth=False,
|
||||
)
|
||||
async def invoice_expiry(self, request: Bolt11Request) -> InvoiceExpiryResponse:
|
||||
return InvoiceExpiryResponse(
|
||||
expires_at=_invoice_expires_at(_decode_bolt11(request.bolt11))
|
||||
)
|
||||
|
||||
@extension_api_method(
|
||||
method_id="utils.lightning.invoice_memo",
|
||||
namespace="utils.lightning",
|
||||
name="Get Lightning invoice memo",
|
||||
host_interface="utils-lightning",
|
||||
host_name="invoice_memo",
|
||||
sdk_name="invoiceMemo",
|
||||
description="Get the memo from a BOLT11 Lightning invoice.",
|
||||
required_permission="utils.basic",
|
||||
require_auth=False,
|
||||
)
|
||||
async def invoice_memo(self, request: Bolt11Request) -> InvoiceMemoResponse:
|
||||
return InvoiceMemoResponse(memo=_invoice_memo(_decode_bolt11(request.bolt11)))
|
||||
|
||||
@extension_api_method(
|
||||
method_id="utils.lightning.verify_preimage",
|
||||
namespace="utils.lightning",
|
||||
name="Verify Lightning preimage",
|
||||
host_interface="utils-lightning",
|
||||
host_name="verify_preimage",
|
||||
sdk_name="verifyPreimage",
|
||||
description="Verify that a preimage matches a payment hash.",
|
||||
required_permission="utils.basic",
|
||||
require_auth=False,
|
||||
)
|
||||
async def verify_preimage(
|
||||
self, request: VerifyPreimageRequest
|
||||
) -> VerifyPreimageResponse:
|
||||
|
||||
return VerifyPreimageResponse(
|
||||
valid=verify_preimage(request.preimage, request.payment_hash)
|
||||
)
|
||||
|
||||
@extension_api_method(
|
||||
method_id="utils.lightning.random_secret_and_hash",
|
||||
namespace="utils.lightning",
|
||||
name="Random Lightning secret and hash",
|
||||
host_interface="utils-lightning",
|
||||
host_name="random_secret_and_hash",
|
||||
sdk_name="randomSecretAndHash",
|
||||
description="Create a random secret and matching SHA256 hash.",
|
||||
required_permission="utils.basic",
|
||||
require_auth=False,
|
||||
)
|
||||
async def random_secret_and_hash(
|
||||
self, request: RandomSecretAndHashRequest
|
||||
) -> RandomSecretAndHashResponse:
|
||||
|
||||
secret, payment_hash = random_secret_and_hash(request.length)
|
||||
return RandomSecretAndHashResponse(secret=secret, hash=payment_hash)
|
||||
|
||||
|
||||
def extension_api_utils_method_classes() -> dict[str, type[_ExtensionAPIUtilsGroup]]:
|
||||
return {
|
||||
"utils.currencies": ExtensionCurrencyUtils,
|
||||
"utils.server": ExtensionServerUtils,
|
||||
"utils.lightning": ExtensionLightningUtils,
|
||||
}
|
||||
|
||||
|
||||
def _decode_bolt11(payment_request: str) -> Any:
|
||||
|
||||
return bolt11.decode(payment_request)
|
||||
|
||||
|
||||
def _decoded_invoice_response(invoice: Any) -> DecodeInvoiceResponse:
|
||||
return DecodeInvoiceResponse(
|
||||
payment_hash=str(getattr(invoice, "payment_hash", "")) or None,
|
||||
amount_msat=_invoice_amount_msat(invoice),
|
||||
expiry=_invoice_expiry(invoice),
|
||||
expires_at=_invoice_expires_at(invoice),
|
||||
memo=_invoice_memo(invoice),
|
||||
)
|
||||
|
||||
|
||||
def _invoice_amount_msat(invoice: Any) -> int | None:
|
||||
amount_msat = getattr(invoice, "amount_msat", None)
|
||||
if amount_msat is None:
|
||||
return None
|
||||
return int(amount_msat)
|
||||
|
||||
|
||||
def _invoice_expiry(invoice: Any) -> int | None:
|
||||
expiry = getattr(invoice, "expiry", None)
|
||||
if expiry is None:
|
||||
return None
|
||||
return int(expiry)
|
||||
|
||||
|
||||
def _invoice_expires_at(invoice: Any) -> int | None:
|
||||
expiry_date = getattr(invoice, "expiry_date", None)
|
||||
if isinstance(expiry_date, datetime):
|
||||
return int(expiry_date.timestamp())
|
||||
|
||||
date = getattr(invoice, "date", None)
|
||||
expiry = getattr(invoice, "expiry", None)
|
||||
if isinstance(date, datetime) and expiry is not None:
|
||||
return int(date.timestamp() + int(expiry))
|
||||
if isinstance(date, (int, float)) and expiry is not None:
|
||||
return int(date + int(expiry))
|
||||
return None
|
||||
|
||||
|
||||
def _invoice_memo(invoice: Any) -> str | None:
|
||||
memo = getattr(invoice, "description", None)
|
||||
return str(memo) if memo is not None else None
|
||||
@@ -0,0 +1,4 @@
|
||||
from .extensions import send_extension_api_request
|
||||
from .http import send_extension_http_request
|
||||
|
||||
__all__ = ["send_extension_api_request", "send_extension_http_request"]
|
||||
@@ -0,0 +1,196 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import posixpath
|
||||
import re
|
||||
from typing import Any
|
||||
from urllib.parse import unquote, urlsplit, urlunsplit
|
||||
|
||||
import httpx
|
||||
|
||||
from lnbits.core.crud.extensions import (
|
||||
get_installed_extension,
|
||||
get_user_active_extensions_ids,
|
||||
)
|
||||
from lnbits.settings import settings
|
||||
|
||||
from ..api.models import ExtensionApiRequest, HttpResponse
|
||||
|
||||
EXTENSION_API_TIMEOUT_SECONDS = 10.0
|
||||
EXTENSION_API_MAX_RESPONSE_BYTES = 262_144
|
||||
|
||||
_READ_METHODS = {"GET", "HEAD"}
|
||||
_WRITE_METHODS = {"DELETE", "PATCH", "POST", "PUT"}
|
||||
_EXTENSION_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$")
|
||||
_FORBIDDEN_RESPONSE_HEADERS = {
|
||||
"connection",
|
||||
"content-length",
|
||||
"set-cookie",
|
||||
"transfer-encoding",
|
||||
}
|
||||
|
||||
|
||||
async def send_extension_api_request(
|
||||
caller_extension_id: str,
|
||||
policies: list[Any],
|
||||
user_id: str | None,
|
||||
access_token: str | None,
|
||||
request: ExtensionApiRequest,
|
||||
) -> HttpResponse:
|
||||
if not user_id:
|
||||
raise PermissionError("Extension API requests require authentication.")
|
||||
if not access_token:
|
||||
raise PermissionError("Extension API requests require an account access token.")
|
||||
|
||||
target_extension_id = _target_extension_id(request.extension_id)
|
||||
access = _target_extension_access(policies, target_extension_id)
|
||||
_require_method_access(caller_extension_id, target_extension_id, access, request)
|
||||
await _require_enabled_extension(target_extension_id, user_id)
|
||||
|
||||
path = _extension_api_path(request.path)
|
||||
body = request.body.encode() if request.body is not None else b""
|
||||
if len(body) > 65_536:
|
||||
raise ValueError("Extension API request body is too large.")
|
||||
|
||||
url = f"http://{settings.host}:{settings.port}/{target_extension_id}{path}"
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
follow_redirects=False,
|
||||
timeout=EXTENSION_API_TIMEOUT_SECONDS,
|
||||
trust_env=False,
|
||||
) as client:
|
||||
async with client.stream(
|
||||
request.method,
|
||||
url,
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
content=body,
|
||||
) as response:
|
||||
response_body = await _read_limited_response(response)
|
||||
return HttpResponse(
|
||||
status_code=response.status_code,
|
||||
headers=_response_headers(dict(response.headers)),
|
||||
body=response_body.decode(response.encoding or "utf-8", "replace"),
|
||||
)
|
||||
except httpx.RequestError as exc:
|
||||
raise ValueError("Extension API request failed.") from exc
|
||||
|
||||
|
||||
def _target_extension_id(extension_id: str) -> str:
|
||||
target = extension_id.strip()
|
||||
if not target or not _EXTENSION_ID_RE.match(target):
|
||||
raise PermissionError("Extension API request has an invalid target extension.")
|
||||
return target
|
||||
|
||||
|
||||
def _target_extension_access(policies: list[Any], target_extension_id: str) -> set[str]:
|
||||
if not isinstance(policies, list) or not policies:
|
||||
raise PermissionError(
|
||||
"Extension API requests require a non-empty extensions policy."
|
||||
)
|
||||
|
||||
for extension in policies:
|
||||
if isinstance(extension, str):
|
||||
extension_id = extension
|
||||
access = ["read"]
|
||||
elif isinstance(extension, dict):
|
||||
raw_extension_id = extension.get("id")
|
||||
raw_access = extension.get("access")
|
||||
if not isinstance(raw_extension_id, str):
|
||||
continue
|
||||
if not isinstance(raw_access, list):
|
||||
raise PermissionError(
|
||||
f"Extension API target '{target_extension_id}' "
|
||||
"has no access policy."
|
||||
)
|
||||
extension_id = raw_extension_id
|
||||
access = raw_access
|
||||
else:
|
||||
continue
|
||||
|
||||
if extension_id != target_extension_id:
|
||||
continue
|
||||
clean_access = {
|
||||
item
|
||||
for item in access
|
||||
if isinstance(item, str) and item in {"read", "write"}
|
||||
}
|
||||
if clean_access:
|
||||
return clean_access
|
||||
break
|
||||
|
||||
raise PermissionError(
|
||||
f"Extension API target '{target_extension_id}' is not allowed."
|
||||
)
|
||||
|
||||
|
||||
def _require_method_access(
|
||||
caller_extension_id: str,
|
||||
target_extension_id: str,
|
||||
access: set[str],
|
||||
request: ExtensionApiRequest,
|
||||
) -> None:
|
||||
if request.method in _READ_METHODS:
|
||||
required_access = "read"
|
||||
elif request.method in _WRITE_METHODS:
|
||||
required_access = "write"
|
||||
else:
|
||||
raise PermissionError("Extension API request method is not allowed.")
|
||||
|
||||
if required_access not in access:
|
||||
raise PermissionError(
|
||||
f"Extension '{caller_extension_id}' cannot {required_access} "
|
||||
f"extension '{target_extension_id}'."
|
||||
)
|
||||
|
||||
|
||||
async def _require_enabled_extension(target_extension_id: str, user_id: str) -> None:
|
||||
extension = await get_installed_extension(target_extension_id)
|
||||
if not extension or not extension.active:
|
||||
raise PermissionError(
|
||||
f"Target extension '{target_extension_id}' is not installed or enabled."
|
||||
)
|
||||
|
||||
active_extensions = await get_user_active_extensions_ids(user_id)
|
||||
if target_extension_id not in active_extensions:
|
||||
raise PermissionError(
|
||||
f"Target extension '{target_extension_id}' is not active for this user."
|
||||
)
|
||||
|
||||
|
||||
def _extension_api_path(path: str) -> str:
|
||||
parts = urlsplit(path)
|
||||
if parts.scheme or parts.netloc:
|
||||
raise PermissionError("Extension API request path must be relative.")
|
||||
if parts.fragment:
|
||||
raise PermissionError("Extension API request path cannot include a fragment.")
|
||||
if not parts.path.startswith("/api/"):
|
||||
raise PermissionError("Extension API request path must start with '/api/'.")
|
||||
|
||||
decoded_path = unquote(parts.path)
|
||||
path_parts = decoded_path.split("/")
|
||||
if any(part == ".." for part in path_parts):
|
||||
raise PermissionError("Extension API request path cannot traverse directories.")
|
||||
|
||||
normalized = posixpath.normpath(decoded_path)
|
||||
if normalized != decoded_path.rstrip("/") or not normalized.startswith("/api/"):
|
||||
raise PermissionError("Extension API request path is invalid.")
|
||||
|
||||
return urlunsplit(("", "", parts.path, parts.query, ""))
|
||||
|
||||
|
||||
async def _read_limited_response(response: httpx.Response) -> bytes:
|
||||
chunks: list[bytes] = []
|
||||
size = 0
|
||||
async for chunk in response.aiter_bytes():
|
||||
size += len(chunk)
|
||||
if size > EXTENSION_API_MAX_RESPONSE_BYTES:
|
||||
raise ValueError("Extension API response is too large.")
|
||||
chunks.append(chunk)
|
||||
return b"".join(chunks)
|
||||
|
||||
|
||||
def _response_headers(headers: dict[str, str]) -> dict[str, str]:
|
||||
return {
|
||||
key: value
|
||||
for key, value in headers.items()
|
||||
if key.lower() not in _FORBIDDEN_RESPONSE_HEADERS
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import socket
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
from ..api.models import HttpRequest, HttpResponse
|
||||
|
||||
HTTP_REQUEST_TIMEOUT_SECONDS = 10.0
|
||||
HTTP_MAX_RESPONSE_BYTES = 262_144
|
||||
|
||||
_FORBIDDEN_REQUEST_HEADERS = {
|
||||
"connection",
|
||||
"content-length",
|
||||
"cookie",
|
||||
"host",
|
||||
"proxy-authorization",
|
||||
"transfer-encoding",
|
||||
}
|
||||
_FORBIDDEN_RESPONSE_HEADERS = {
|
||||
"connection",
|
||||
"content-length",
|
||||
"set-cookie",
|
||||
"transfer-encoding",
|
||||
}
|
||||
|
||||
|
||||
async def send_extension_http_request(
|
||||
extension_id: str,
|
||||
policies: list[Any],
|
||||
request: HttpRequest,
|
||||
) -> HttpResponse:
|
||||
allowed_origins = _allowed_origins(policies)
|
||||
origin = _request_origin(request.url)
|
||||
if origin not in allowed_origins:
|
||||
raise PermissionError(
|
||||
f"Extension '{extension_id}' is not allowed to request '{origin}'."
|
||||
)
|
||||
|
||||
await _reject_internal_host(request.url)
|
||||
headers = _request_headers(request.headers)
|
||||
body = request.body.encode() if request.body is not None else b""
|
||||
if len(body) > 65_536:
|
||||
raise ValueError("HTTP request body is too large.")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
follow_redirects=False,
|
||||
timeout=HTTP_REQUEST_TIMEOUT_SECONDS,
|
||||
trust_env=False,
|
||||
) as client:
|
||||
async with client.stream(
|
||||
request.method,
|
||||
request.url,
|
||||
headers=headers,
|
||||
content=body,
|
||||
) as response:
|
||||
response_body = await _read_limited_response(response)
|
||||
return HttpResponse(
|
||||
status_code=response.status_code,
|
||||
headers=_response_headers(dict(response.headers)),
|
||||
body=response_body.decode(response.encoding or "utf-8", "replace"),
|
||||
)
|
||||
except httpx.RequestError as exc:
|
||||
raise ValueError("HTTP request failed.") from exc
|
||||
|
||||
|
||||
def _allowed_origins(policies: list[Any]) -> set[str]:
|
||||
if not isinstance(policies, list) or not policies:
|
||||
raise PermissionError("HTTP requests require a non-empty hosts policy.")
|
||||
|
||||
origins: set[str] = set()
|
||||
for policy in policies:
|
||||
host = policy.get("host") if isinstance(policy, dict) else policy
|
||||
if not isinstance(host, str) or not host:
|
||||
continue
|
||||
origins.add(_request_origin(host))
|
||||
if not origins:
|
||||
raise PermissionError("HTTP requests require at least one valid host.")
|
||||
return origins
|
||||
|
||||
|
||||
def _request_origin(url: str) -> str:
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme != "https":
|
||||
raise PermissionError("HTTP requests require https URLs.")
|
||||
if parsed.username or parsed.password:
|
||||
raise PermissionError("HTTP requests cannot include credentials in URLs.")
|
||||
if not parsed.hostname:
|
||||
raise PermissionError("HTTP requests require a hostname.")
|
||||
|
||||
hostname = parsed.hostname.lower()
|
||||
port = _url_port(parsed)
|
||||
if port is None or port == 443:
|
||||
return f"https://{hostname}"
|
||||
return f"https://{hostname}:{port}"
|
||||
|
||||
|
||||
def _url_port(parsed: Any) -> int | None:
|
||||
try:
|
||||
return parsed.port
|
||||
except ValueError as exc:
|
||||
raise PermissionError("HTTP request URL has an invalid port.") from exc
|
||||
|
||||
|
||||
async def _reject_internal_host(url: str) -> None:
|
||||
parsed = urlparse(url)
|
||||
hostname = parsed.hostname
|
||||
if not hostname:
|
||||
raise PermissionError("HTTP requests require a hostname.")
|
||||
if hostname == "localhost" or hostname.endswith(".localhost"):
|
||||
raise PermissionError("HTTP requests cannot target localhost.")
|
||||
|
||||
try:
|
||||
address = ipaddress.ip_address(hostname)
|
||||
_reject_internal_address(address)
|
||||
return
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
for address in await _resolve_host(hostname):
|
||||
_reject_internal_address(address)
|
||||
|
||||
|
||||
async def _resolve_host(
|
||||
hostname: str,
|
||||
) -> list[ipaddress.IPv4Address | ipaddress.IPv6Address]:
|
||||
import asyncio
|
||||
|
||||
def resolve() -> list[ipaddress.IPv4Address | ipaddress.IPv6Address]:
|
||||
try:
|
||||
infos = socket.getaddrinfo(hostname, None, type=socket.SOCK_STREAM)
|
||||
except socket.gaierror as exc:
|
||||
raise PermissionError("HTTP request host could not be resolved.") from exc
|
||||
|
||||
addresses: list[ipaddress.IPv4Address | ipaddress.IPv6Address] = []
|
||||
for info in infos:
|
||||
sockaddr = info[4]
|
||||
addresses.append(ipaddress.ip_address(sockaddr[0]))
|
||||
return addresses
|
||||
|
||||
return await asyncio.to_thread(resolve)
|
||||
|
||||
|
||||
def _reject_internal_address(
|
||||
address: ipaddress.IPv4Address | ipaddress.IPv6Address,
|
||||
) -> None:
|
||||
if not address.is_global:
|
||||
raise PermissionError("HTTP requests cannot target internal network addresses.")
|
||||
|
||||
|
||||
def _request_headers(headers: dict[str, str]) -> dict[str, str]:
|
||||
clean: dict[str, str] = {}
|
||||
for key, value in headers.items():
|
||||
header = key.strip()
|
||||
if not header:
|
||||
continue
|
||||
if header.lower() in _FORBIDDEN_REQUEST_HEADERS:
|
||||
continue
|
||||
clean[header] = value
|
||||
return clean
|
||||
|
||||
|
||||
async def _read_limited_response(response: httpx.Response) -> bytes:
|
||||
chunks: list[bytes] = []
|
||||
size = 0
|
||||
async for chunk in response.aiter_bytes():
|
||||
size += len(chunk)
|
||||
if size > HTTP_MAX_RESPONSE_BYTES:
|
||||
raise ValueError("HTTP response is too large.")
|
||||
chunks.append(chunk)
|
||||
return b"".join(chunks)
|
||||
|
||||
|
||||
def _response_headers(headers: dict[str, str]) -> dict[str, str]:
|
||||
return {
|
||||
key: value
|
||||
for key, value in headers.items()
|
||||
if key.lower() not in _FORBIDDEN_RESPONSE_HEADERS
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
from .register import register_wasm_extension
|
||||
|
||||
__all__ = ["register_wasm_extension"]
|
||||
@@ -0,0 +1,168 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request
|
||||
|
||||
from lnbits.core.models import Account
|
||||
from lnbits.decorators import check_access_token, check_account_exists
|
||||
|
||||
from ..wasm.invoke import invoke_wasm_extension_export
|
||||
from ..wasm.loader import WasmExtension
|
||||
|
||||
|
||||
def register_wasm_extension_api_routes(app: FastAPI, extension: WasmExtension) -> None:
|
||||
for route_config in extension.config.get("api_routes") or []:
|
||||
_add_wasm_extension_api_route(app, extension, route_config)
|
||||
|
||||
|
||||
def _add_wasm_extension_api_route(
|
||||
app: FastAPI,
|
||||
extension: WasmExtension,
|
||||
route_config: dict[str, Any],
|
||||
) -> None:
|
||||
method = _wasm_extension_api_method(extension, route_config.get("method"))
|
||||
route_path = _wasm_extension_api_path(extension, route_config.get("path"))
|
||||
export_name = _wasm_extension_api_export(extension, route_config.get("export"))
|
||||
path_params = route_config.get("path_params") or {}
|
||||
auth = _wasm_extension_route_auth(extension, route_config.get("auth"))
|
||||
|
||||
if _has_route(app, route_path, method):
|
||||
return
|
||||
|
||||
async def invoke_wasm_api_request(
|
||||
request: Request,
|
||||
account: Account | None = None,
|
||||
access_token: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
payload = await _read_api_payload(request, path_params)
|
||||
return await invoke_wasm_extension_export(
|
||||
extension.id,
|
||||
export_name,
|
||||
payload,
|
||||
user=account,
|
||||
access_token=access_token,
|
||||
)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
async def invoke_private_wasm_extension_export(
|
||||
request: Request,
|
||||
access_token: Annotated[str | None, Depends(check_access_token)],
|
||||
account: Account = Depends(check_account_exists),
|
||||
) -> dict[str, Any]:
|
||||
return await invoke_wasm_api_request(request, account, access_token)
|
||||
|
||||
async def invoke_public_wasm_extension_export(request: Request) -> dict[str, Any]:
|
||||
return await invoke_wasm_api_request(request)
|
||||
|
||||
app.add_api_route(
|
||||
route_path,
|
||||
(
|
||||
invoke_public_wasm_extension_export
|
||||
if auth == "public"
|
||||
else invoke_private_wasm_extension_export
|
||||
),
|
||||
methods=[method],
|
||||
name=f"{extension.id}:{method}:{route_path}",
|
||||
include_in_schema=False,
|
||||
)
|
||||
|
||||
|
||||
async def _read_api_payload(
|
||||
request: Request,
|
||||
path_params: dict[str, str],
|
||||
) -> dict[str, Any]:
|
||||
payload = _read_api_path_params(request, path_params)
|
||||
payload.update(_read_api_query_params(request))
|
||||
if request.method in {"POST", "PUT", "PATCH"}:
|
||||
payload.update(await _read_json_object(request))
|
||||
return payload
|
||||
|
||||
|
||||
async def _read_json_object(request: Request) -> dict[str, Any]:
|
||||
body = await request.body()
|
||||
if not body:
|
||||
return {}
|
||||
value = json.loads(body)
|
||||
if not isinstance(value, dict):
|
||||
raise TypeError("WASM extension API payload must be a JSON object.")
|
||||
return value
|
||||
|
||||
|
||||
def _read_api_path_params(
|
||||
request: Request,
|
||||
path_params: dict[str, str],
|
||||
) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {}
|
||||
for key, value in request.path_params.items():
|
||||
target = path_params.get(key) or _snake_to_camel(key)
|
||||
payload[target] = value
|
||||
return payload
|
||||
|
||||
|
||||
def _read_api_query_params(request: Request) -> dict[str, Any]:
|
||||
return {_snake_to_camel(key): value for key, value in request.query_params.items()}
|
||||
|
||||
|
||||
def _wasm_extension_api_export(extension: WasmExtension, export_name: Any) -> str:
|
||||
if not isinstance(export_name, str) or not export_name:
|
||||
raise ValueError(f"Invalid API export for WASM extension '{extension.id}'.")
|
||||
|
||||
for export in extension.exports:
|
||||
if export.get("name") != export_name:
|
||||
continue
|
||||
if export.get("visibility") in {"public", "authenticated"}:
|
||||
return export_name
|
||||
raise PermissionError(f"WASM export '{export_name}' is not callable over HTTP.")
|
||||
raise KeyError(f"WASM extension '{extension.id}' has no export '{export_name}'.")
|
||||
|
||||
|
||||
def _wasm_extension_api_method(extension: WasmExtension, method: Any) -> str:
|
||||
if not isinstance(method, str):
|
||||
raise ValueError(f"Invalid API method for WASM extension '{extension.id}'.")
|
||||
method = method.upper()
|
||||
if method not in {"GET", "POST", "PUT", "PATCH", "DELETE"}:
|
||||
raise ValueError(f"Unsupported API method for WASM extension '{extension.id}'.")
|
||||
return method
|
||||
|
||||
|
||||
def _wasm_extension_api_path(extension: WasmExtension, path: Any) -> str:
|
||||
if not isinstance(path, str) or not path.startswith("/"):
|
||||
raise ValueError(f"Invalid API path for WASM extension '{extension.id}'.")
|
||||
if path == "/":
|
||||
return f"/api/v1/ext/{extension.id}"
|
||||
return f"/api/v1/ext/{extension.id}{path}"
|
||||
|
||||
|
||||
def _wasm_extension_route_auth(extension: WasmExtension, auth: Any) -> str:
|
||||
if auth in {"public", "user"}:
|
||||
return auth
|
||||
raise ValueError(f"Invalid route auth for WASM extension '{extension.id}'.")
|
||||
|
||||
|
||||
def _has_route(app: FastAPI, route_path: str, method: str) -> bool:
|
||||
for route in app.routes:
|
||||
if getattr(route, "path", None) != route_path:
|
||||
continue
|
||||
methods = getattr(route, "methods", set()) or set()
|
||||
if method in methods:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _snake_to_camel(value: str) -> str:
|
||||
head, *tail = value.split("_")
|
||||
return head + "".join(part.capitalize() for part in tail)
|
||||
|
||||
|
||||
def _path_template_pattern(path: str) -> str:
|
||||
pattern = re.sub(r"\\{[^/{}]+\\}", r"[^/]+", re.escape(path))
|
||||
return f"^{pattern}$"
|
||||
@@ -0,0 +1,139 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.responses import FileResponse, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.staticfiles import PathLike as StaticFilesPathLike
|
||||
from starlette.types import Scope
|
||||
|
||||
from lnbits.settings import settings
|
||||
|
||||
from ..wasm.loader import WasmExtension
|
||||
|
||||
WASM_EXTENSION_CORE_ASSET_PREFIX = "_lnbits"
|
||||
WASM_EXTENSION_CORE_STATIC_ASSETS = {
|
||||
"bundle.min.css": ("static/bundle.min.css", "text/css; charset=utf-8"),
|
||||
"material-icons-v50.woff2": (
|
||||
"static/fonts/material-icons-v50.woff2",
|
||||
"font/woff2",
|
||||
),
|
||||
"quasar.css": ("static/vendor/quasar.css", "text/css; charset=utf-8"),
|
||||
"quasar.umd.prod.js": (
|
||||
"static/vendor/quasar.umd.prod.js",
|
||||
"text/javascript; charset=utf-8",
|
||||
),
|
||||
"qrcode.vue.browser.js": (
|
||||
"static/vendor/qrcode.vue.browser.js",
|
||||
"text/javascript; charset=utf-8",
|
||||
),
|
||||
"vue.global.prod.js": (
|
||||
"static/vendor/vue.global.prod.js",
|
||||
"text/javascript; charset=utf-8",
|
||||
),
|
||||
}
|
||||
WASM_EXTENSION_GENERATED_CORE_ASSETS = {
|
||||
"material-icons.css": (
|
||||
"""
|
||||
@font-face {
|
||||
font-family: 'Material Icons';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url('./material-icons-v50.woff2') format('woff2');
|
||||
}
|
||||
""",
|
||||
"text/css; charset=utf-8",
|
||||
)
|
||||
}
|
||||
WASM_EXTENSION_STATIC_MIME_TYPES = {
|
||||
".css": "text/css; charset=utf-8",
|
||||
".gif": "image/gif",
|
||||
".ico": "image/x-icon",
|
||||
".jpeg": "image/jpeg",
|
||||
".jpg": "image/jpeg",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".png": "image/png",
|
||||
".webp": "image/webp",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
}
|
||||
WASM_EXTENSION_TEXT_STATIC_EXTENSIONS = {".css", ".js"}
|
||||
WASM_EXTENSION_HTML_PREFIXES = (b"<!doctype", b"<html", b"<script")
|
||||
|
||||
|
||||
class GuardedWasmExtensionStaticFiles(StaticFiles):
|
||||
async def get_response(self, path: str, scope: Scope) -> Response:
|
||||
if path.startswith(f"{WASM_EXTENSION_CORE_ASSET_PREFIX}/"):
|
||||
return _wasm_extension_core_asset_response(path)
|
||||
if Path(path).suffix.lower() not in WASM_EXTENSION_STATIC_MIME_TYPES:
|
||||
raise HTTPException(status_code=404)
|
||||
return await super().get_response(path, scope)
|
||||
|
||||
def file_response(
|
||||
self,
|
||||
full_path: StaticFilesPathLike,
|
||||
stat_result: os.stat_result,
|
||||
scope: Scope,
|
||||
status_code: int = 200,
|
||||
) -> Response:
|
||||
suffix = Path(full_path).suffix.lower()
|
||||
if suffix in WASM_EXTENSION_TEXT_STATIC_EXTENSIONS:
|
||||
_reject_html_like_wasm_static_asset(Path(full_path))
|
||||
|
||||
response = super().file_response(full_path, stat_result, scope, status_code)
|
||||
response.headers["Content-Type"] = WASM_EXTENSION_STATIC_MIME_TYPES[suffix]
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
|
||||
|
||||
def mount_wasm_extension_static(app: FastAPI, extension: WasmExtension) -> None:
|
||||
static_path = extension.root_path / "static"
|
||||
|
||||
mount_path = f"/ext-assets/{extension.id}"
|
||||
if any(getattr(route, "path", None) == mount_path for route in app.routes):
|
||||
return
|
||||
|
||||
app.mount(
|
||||
mount_path,
|
||||
GuardedWasmExtensionStaticFiles(directory=static_path, check_dir=False),
|
||||
name=f"{extension.id}-static",
|
||||
)
|
||||
|
||||
|
||||
def _reject_html_like_wasm_static_asset(path: Path) -> None:
|
||||
with path.open("rb") as asset_file:
|
||||
prefix = asset_file.read(512).lstrip().lower()
|
||||
if prefix.startswith(WASM_EXTENSION_HTML_PREFIXES):
|
||||
raise HTTPException(status_code=404)
|
||||
|
||||
|
||||
def _wasm_extension_core_asset_response(path: str) -> Response:
|
||||
asset_name = path.removeprefix(f"{WASM_EXTENSION_CORE_ASSET_PREFIX}/")
|
||||
if not asset_name or "/" in asset_name or "\\" in asset_name:
|
||||
raise HTTPException(status_code=404)
|
||||
|
||||
generated_asset = WASM_EXTENSION_GENERATED_CORE_ASSETS.get(asset_name)
|
||||
if generated_asset:
|
||||
content, content_type = generated_asset
|
||||
response = Response(content=content, media_type=content_type)
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
|
||||
asset_config = WASM_EXTENSION_CORE_STATIC_ASSETS.get(asset_name)
|
||||
if not asset_config:
|
||||
raise HTTPException(status_code=404)
|
||||
|
||||
relative_path, content_type = asset_config
|
||||
asset_path = Path(settings.lnbits_path, relative_path)
|
||||
if not asset_path.is_file():
|
||||
raise HTTPException(status_code=404)
|
||||
|
||||
response = FileResponse(asset_path)
|
||||
response.headers["Content-Type"] = content_type
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
@@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import FastAPI
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.db import core_app_extra
|
||||
from lnbits.settings import settings
|
||||
|
||||
from ..wasm.component import warm_wasm_extension
|
||||
from ..wasm.loader import WasmExtension, load_wasm_extension
|
||||
from .api import register_wasm_extension_api_routes
|
||||
from .assets import mount_wasm_extension_static
|
||||
from .ui import register_wasm_extension_ui_routes
|
||||
|
||||
|
||||
def register_wasm_extension(app: FastAPI, ext_id: str) -> WasmExtension:
|
||||
loaded = load_wasm_extension(ext_id)
|
||||
|
||||
warm_wasm_extension(loaded)
|
||||
mount_wasm_extension_static(app, loaded)
|
||||
register_wasm_extension_ui_routes(app, loaded)
|
||||
register_wasm_extension_api_routes(app, loaded)
|
||||
|
||||
core_app_extra.wasm_extension_registry.register(loaded)
|
||||
|
||||
settings.activate_extension_paths(ext_id, "", [])
|
||||
logger.info(
|
||||
f"Loaded WASM extension '{loaded.id}' "
|
||||
f"({loaded.module_path.stat().st_size} bytes)."
|
||||
)
|
||||
return loaded
|
||||
@@ -0,0 +1,127 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, NoReturn
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.helpers import template_renderer
|
||||
from lnbits.utils.cache import cache
|
||||
|
||||
from ..wasm.loader import WasmExtension
|
||||
|
||||
WASM_FRAME_TOKEN_EXPIRY_SECONDS = 60
|
||||
|
||||
|
||||
def wasm_extension_wrapper_response(
|
||||
request: Request,
|
||||
extension: WasmExtension,
|
||||
auth: str,
|
||||
user_json: str | None,
|
||||
) -> Any:
|
||||
public = auth == "public"
|
||||
response = template_renderer().TemplateResponse(
|
||||
request,
|
||||
"wasm_extension.html",
|
||||
{
|
||||
"extension": extension,
|
||||
"public": public,
|
||||
"user": user_json,
|
||||
},
|
||||
)
|
||||
response.headers["Content-Security-Policy"] = "frame-ancestors 'self'"
|
||||
response.headers["X-Frame-Options"] = "SAMEORIGIN"
|
||||
return response
|
||||
|
||||
|
||||
def wasm_extension_frame_csp(request: Request, extension: WasmExtension) -> str:
|
||||
origin = str(request.base_url).rstrip("/")
|
||||
extension_assets = f"{origin}/ext-assets/{extension.id}/"
|
||||
return (
|
||||
"sandbox allow-scripts; "
|
||||
"default-src 'none'; "
|
||||
f"script-src {extension_assets}; "
|
||||
"script-src-attr 'none'; "
|
||||
f"style-src {extension_assets}; "
|
||||
"style-src-attr 'none'; "
|
||||
f"img-src {extension_assets} data:; "
|
||||
f"font-src {extension_assets}; "
|
||||
"connect-src 'none'; "
|
||||
"form-action 'none'; "
|
||||
"object-src 'none'; "
|
||||
"base-uri 'none'; "
|
||||
"frame-src 'none'; "
|
||||
"worker-src 'none'; "
|
||||
"media-src 'none'; "
|
||||
"manifest-src 'none'; "
|
||||
"frame-ancestors 'self'"
|
||||
)
|
||||
|
||||
|
||||
def wasm_extension_frame_url(
|
||||
extension: WasmExtension, frame_path: str, user_id: str | None
|
||||
) -> str:
|
||||
token = _create_wasm_extension_frame_token(extension, frame_path, user_id)
|
||||
return f"{frame_path}?frame_token={token}"
|
||||
|
||||
|
||||
def consume_wasm_extension_frame_token(
|
||||
request: Request,
|
||||
extension: WasmExtension,
|
||||
frame_path: str,
|
||||
user_id: str | None,
|
||||
) -> None:
|
||||
token = request.query_params.get("frame_token")
|
||||
if not token:
|
||||
_raise_wasm_extension_frame_not_found(extension, frame_path, "missing")
|
||||
|
||||
cache_key = _wasm_extension_frame_token_cache_key(token)
|
||||
token_data = cache.get(cache_key)
|
||||
if (
|
||||
not isinstance(token_data, dict)
|
||||
or token_data.get("extension_id") != extension.id
|
||||
or token_data.get("frame_path") != frame_path
|
||||
):
|
||||
_raise_wasm_extension_frame_not_found(
|
||||
extension, frame_path, "unknown or expired"
|
||||
)
|
||||
|
||||
token_user_id = token_data.get("user_id")
|
||||
if token_user_id and token_user_id != user_id:
|
||||
_raise_wasm_extension_frame_not_found(extension, frame_path, "wrong user")
|
||||
|
||||
cache.pop(cache_key)
|
||||
|
||||
|
||||
def _create_wasm_extension_frame_token(
|
||||
extension: WasmExtension,
|
||||
frame_path: str,
|
||||
user_id: str | None,
|
||||
) -> str:
|
||||
token = uuid4().hex
|
||||
cache.set(
|
||||
_wasm_extension_frame_token_cache_key(token),
|
||||
{
|
||||
"extension_id": extension.id,
|
||||
"frame_path": frame_path,
|
||||
"user_id": user_id,
|
||||
},
|
||||
expiry=WASM_FRAME_TOKEN_EXPIRY_SECONDS,
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
def _wasm_extension_frame_token_cache_key(token: str) -> str:
|
||||
return f"wasm-frame-token:{token}"
|
||||
|
||||
|
||||
def _raise_wasm_extension_frame_not_found(
|
||||
extension: WasmExtension,
|
||||
frame_path: str,
|
||||
reason: str,
|
||||
) -> NoReturn:
|
||||
logger.warning(
|
||||
f"WASM frame token {reason} for extension '{extension.id}' at '{frame_path}'."
|
||||
)
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
@@ -0,0 +1,368 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import UUID4
|
||||
|
||||
from lnbits.core.crud import get_installed_extension, get_user_from_account
|
||||
from lnbits.core.models import Account
|
||||
from lnbits.decorators import (
|
||||
check_access_token,
|
||||
check_account_exists,
|
||||
optional_user_id,
|
||||
)
|
||||
|
||||
from ..wasm.loader import WasmExtension
|
||||
from .api import (
|
||||
_has_route,
|
||||
_path_template_pattern,
|
||||
_read_json_object,
|
||||
_snake_to_camel,
|
||||
_wasm_extension_api_export,
|
||||
_wasm_extension_api_method,
|
||||
_wasm_extension_api_path,
|
||||
_wasm_extension_route_auth,
|
||||
)
|
||||
from .security import (
|
||||
consume_wasm_extension_frame_token,
|
||||
wasm_extension_frame_csp,
|
||||
wasm_extension_frame_url,
|
||||
wasm_extension_wrapper_response,
|
||||
)
|
||||
|
||||
|
||||
def register_wasm_extension_ui_routes(app: FastAPI, extension: WasmExtension) -> None:
|
||||
_add_wasm_extension_frame_config_route(app, extension)
|
||||
|
||||
for route_index, route_config in enumerate(extension.config.get("ui_routes") or []):
|
||||
route_path = _wasm_extension_ui_route_path(extension, route_config.get("path"))
|
||||
entrypoint = _wasm_extension_entrypoint(
|
||||
extension, route_config.get("entrypoint")
|
||||
)
|
||||
frame_path = f"/ext-frame/{extension.id}/{route_index}"
|
||||
auth = _wasm_extension_route_auth(extension, route_config.get("auth"))
|
||||
_add_wasm_extension_frame_route(app, extension, frame_path, entrypoint)
|
||||
_add_wasm_extension_wrapper_route(
|
||||
app,
|
||||
extension,
|
||||
route_path,
|
||||
auth,
|
||||
)
|
||||
|
||||
|
||||
def _add_wasm_extension_frame_config_route(
|
||||
app: FastAPI,
|
||||
extension: WasmExtension,
|
||||
) -> None:
|
||||
route_path = _wasm_extension_frame_config_path(extension)
|
||||
if _has_route(app, route_path, "POST"):
|
||||
return
|
||||
|
||||
async def create_wasm_extension_frame_config(
|
||||
request: Request,
|
||||
access_token: Annotated[str | None, Depends(check_access_token)],
|
||||
usr: UUID4 | None = None,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
body = await _read_json_object(request)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
ui_route = _match_wasm_extension_ui_route(extension, body.get("path"))
|
||||
auth = ui_route["auth"]
|
||||
|
||||
if auth == "user":
|
||||
account = await check_account_exists(request, access_token, usr)
|
||||
user_id: str | None = account.id
|
||||
else:
|
||||
user_id = await _optional_wasm_user_id(request, access_token, usr)
|
||||
|
||||
granted_permission_ids = await _wasm_extension_granted_permission_ids(extension)
|
||||
|
||||
return _wasm_extension_frame_config(
|
||||
extension,
|
||||
ui_route["frame_path"],
|
||||
auth,
|
||||
ui_route["path_params"],
|
||||
ui_route["route_params"],
|
||||
_read_wasm_extension_route_query(body.get("query")),
|
||||
user_id,
|
||||
granted_permission_ids,
|
||||
)
|
||||
|
||||
app.add_api_route(
|
||||
route_path,
|
||||
create_wasm_extension_frame_config,
|
||||
methods=["POST"],
|
||||
name=f"{extension.id}:frame-config",
|
||||
include_in_schema=False,
|
||||
)
|
||||
|
||||
|
||||
def _add_wasm_extension_wrapper_route(
|
||||
app: FastAPI,
|
||||
extension: WasmExtension,
|
||||
route_path: str,
|
||||
auth: str,
|
||||
) -> None:
|
||||
if _has_route(app, route_path, "GET"):
|
||||
return
|
||||
|
||||
async def serve_private_wasm_extension_page(
|
||||
request: Request,
|
||||
account: Account = Depends(check_account_exists),
|
||||
) -> Any:
|
||||
user = await get_user_from_account(account)
|
||||
return wasm_extension_wrapper_response(
|
||||
request,
|
||||
extension,
|
||||
auth,
|
||||
user.json() if user else None,
|
||||
)
|
||||
|
||||
async def serve_public_wasm_extension_page(request: Request) -> Any:
|
||||
return wasm_extension_wrapper_response(
|
||||
request,
|
||||
extension,
|
||||
auth,
|
||||
None,
|
||||
)
|
||||
|
||||
app.add_api_route(
|
||||
route_path,
|
||||
(
|
||||
serve_public_wasm_extension_page
|
||||
if auth == "public"
|
||||
else serve_private_wasm_extension_page
|
||||
),
|
||||
methods=["GET"],
|
||||
name=f"{extension.id}:{route_path}",
|
||||
include_in_schema=False,
|
||||
)
|
||||
|
||||
|
||||
def _add_wasm_extension_frame_route(
|
||||
app: FastAPI,
|
||||
extension: WasmExtension,
|
||||
frame_path: str,
|
||||
entrypoint: Path,
|
||||
) -> None:
|
||||
if _has_route(app, frame_path, "GET"):
|
||||
return
|
||||
|
||||
async def serve_wasm_extension_frame(
|
||||
request: Request,
|
||||
user_id: str | None = Depends(_optional_wasm_user_id),
|
||||
) -> FileResponse:
|
||||
consume_wasm_extension_frame_token(request, extension, frame_path, user_id)
|
||||
response = FileResponse(entrypoint)
|
||||
response.headers["Content-Security-Policy"] = wasm_extension_frame_csp(
|
||||
request, extension
|
||||
)
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
response.headers["Cross-Origin-Opener-Policy"] = "same-origin"
|
||||
response.headers["Cross-Origin-Resource-Policy"] = "same-origin"
|
||||
# Extension access goes through the parent bridge.
|
||||
response.headers["Permissions-Policy"] = (
|
||||
"camera=(), microphone=(), geolocation=(), payment=(), "
|
||||
"clipboard-read=(), usb=()"
|
||||
)
|
||||
response.headers["Referrer-Policy"] = "no-referrer"
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
return response
|
||||
|
||||
app.add_api_route(
|
||||
frame_path,
|
||||
serve_wasm_extension_frame,
|
||||
methods=["GET"],
|
||||
name=f"{extension.id}:frame:{frame_path}",
|
||||
include_in_schema=False,
|
||||
)
|
||||
|
||||
|
||||
def _wasm_extension_bridge_api_routes(
|
||||
extension: WasmExtension,
|
||||
public: bool,
|
||||
) -> list[dict[str, str]]:
|
||||
routes: list[dict[str, str]] = []
|
||||
for route_config in extension.config.get("api_routes") or []:
|
||||
auth = _wasm_extension_route_auth(extension, route_config.get("auth"))
|
||||
if public and auth != "public":
|
||||
continue
|
||||
method = _wasm_extension_api_method(extension, route_config.get("method"))
|
||||
path = _wasm_extension_api_path(extension, route_config.get("path"))
|
||||
_wasm_extension_api_export(extension, route_config.get("export"))
|
||||
routes.append(
|
||||
{
|
||||
"method": method,
|
||||
"path": path,
|
||||
"pattern": _path_template_pattern(path),
|
||||
}
|
||||
)
|
||||
return routes
|
||||
|
||||
|
||||
def _wasm_extension_frame_config_path(extension: WasmExtension) -> str:
|
||||
return f"/api/v1/ext/{extension.id}/_ui/frame"
|
||||
|
||||
|
||||
def _match_wasm_extension_ui_route(
|
||||
extension: WasmExtension,
|
||||
path: Any,
|
||||
) -> dict[str, Any]:
|
||||
if not isinstance(path, str) or not path.startswith("/"):
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
|
||||
for route_index, route_config in enumerate(extension.config.get("ui_routes") or []):
|
||||
route_path = _wasm_extension_ui_route_path(extension, route_config.get("path"))
|
||||
route_params = _path_template_params(route_path, path)
|
||||
if route_params is None:
|
||||
continue
|
||||
|
||||
return {
|
||||
"frame_path": f"/ext-frame/{extension.id}/{route_index}",
|
||||
"auth": _wasm_extension_route_auth(extension, route_config.get("auth")),
|
||||
"path_params": route_config.get("path_params") or {},
|
||||
"route_params": route_params,
|
||||
}
|
||||
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
|
||||
|
||||
def _path_template_params(template: str, path: str) -> dict[str, str] | None:
|
||||
template_parts = _path_parts(template)
|
||||
path_parts = _path_parts(path)
|
||||
if len(template_parts) != len(path_parts):
|
||||
return None
|
||||
|
||||
params: dict[str, str] = {}
|
||||
for template_part, path_part in zip(template_parts, path_parts, strict=False):
|
||||
if template_part.startswith("{") and template_part.endswith("}"):
|
||||
param_name = template_part[1:-1]
|
||||
if not param_name:
|
||||
return None
|
||||
params[param_name] = path_part
|
||||
continue
|
||||
|
||||
if template_part != path_part:
|
||||
return None
|
||||
|
||||
return params
|
||||
|
||||
|
||||
def _path_parts(path: str) -> list[str]:
|
||||
return [part for part in path.strip("/").split("/") if part]
|
||||
|
||||
|
||||
def _wasm_extension_frame_config(
|
||||
extension: WasmExtension,
|
||||
frame_path: str,
|
||||
auth: str,
|
||||
path_params: dict[str, str],
|
||||
route_params: dict[str, str],
|
||||
query: dict[str, Any],
|
||||
user_id: str | None,
|
||||
permissions: set[str],
|
||||
) -> dict[str, Any]:
|
||||
public = auth == "public"
|
||||
return {
|
||||
"extension": {
|
||||
"id": extension.id,
|
||||
"name": extension.name,
|
||||
},
|
||||
"frameUrl": wasm_extension_frame_url(extension, frame_path, user_id),
|
||||
"bridge": {
|
||||
"extensionId": extension.id,
|
||||
"public": public,
|
||||
"routeParams": _map_wasm_extension_route_params(route_params, path_params),
|
||||
"query": query,
|
||||
"permissions": sorted(permissions),
|
||||
"apiRoutes": _wasm_extension_bridge_api_routes(extension, public),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def _wasm_extension_granted_permission_ids(
|
||||
extension: WasmExtension,
|
||||
) -> set[str]:
|
||||
installed_extension = await get_installed_extension(extension.id)
|
||||
if not installed_extension:
|
||||
return set()
|
||||
return {permission.id for permission in installed_extension.permissions}
|
||||
|
||||
|
||||
def _map_wasm_extension_route_params(
|
||||
route_params: dict[str, str],
|
||||
path_params: dict[str, str],
|
||||
) -> dict[str, str]:
|
||||
payload: dict[str, str] = {}
|
||||
for key, value in route_params.items():
|
||||
target = path_params.get(key) or _snake_to_camel(key)
|
||||
payload[target] = value
|
||||
return payload
|
||||
|
||||
|
||||
def _read_wasm_extension_route_query(query: Any) -> dict[str, Any]:
|
||||
if not isinstance(query, dict):
|
||||
return {}
|
||||
|
||||
payload: dict[str, Any] = {}
|
||||
for key, value in query.items():
|
||||
if value is None:
|
||||
continue
|
||||
payload[_snake_to_camel(str(key))] = value
|
||||
return payload
|
||||
|
||||
|
||||
async def _optional_wasm_user_id(
|
||||
request: Request,
|
||||
access_token: Annotated[str | None, Depends(check_access_token)],
|
||||
usr: UUID4 | None = None,
|
||||
) -> str | None:
|
||||
try:
|
||||
return await optional_user_id(request, access_token, usr)
|
||||
except HTTPException:
|
||||
return None
|
||||
|
||||
|
||||
def _wasm_extension_ui_route_path(extension: WasmExtension, path: Any) -> str:
|
||||
if not isinstance(path, str) or not path.startswith("/"):
|
||||
raise ValueError(f"Invalid route path for WASM extension '{extension.id}'.")
|
||||
if path == "/":
|
||||
return "/ext"
|
||||
return f"/ext{path}"
|
||||
|
||||
|
||||
def _wasm_extension_entrypoint(extension: WasmExtension, entrypoint: Any) -> Path:
|
||||
if not isinstance(entrypoint, str) or not entrypoint:
|
||||
raise ValueError(
|
||||
f"Invalid route entrypoint for WASM extension '{extension.id}'."
|
||||
)
|
||||
if entrypoint.startswith("/"):
|
||||
raise ValueError(
|
||||
f"Route entrypoint for WASM extension '{extension.id}' must be a "
|
||||
"relative extension path."
|
||||
)
|
||||
|
||||
path = (extension.root_path / entrypoint).resolve()
|
||||
root_path = extension.root_path.resolve()
|
||||
if path != root_path and root_path not in path.parents:
|
||||
raise ValueError(f"Route entrypoint escapes extension root: {entrypoint}")
|
||||
|
||||
static_path = (extension.root_path / "static").resolve()
|
||||
if path == static_path or static_path in path.parents:
|
||||
raise ValueError(
|
||||
f"Route entrypoint for WASM extension '{extension.id}' must not be "
|
||||
"inside the static asset directory."
|
||||
)
|
||||
if path.suffix.lower() != ".html":
|
||||
raise ValueError(
|
||||
f"Route entrypoint for WASM extension '{extension.id}' must be "
|
||||
"an HTML file."
|
||||
)
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"Route entrypoint not found: {path}")
|
||||
return path
|
||||
@@ -0,0 +1,19 @@
|
||||
from .crud import (
|
||||
migrate_wasm_extension_database,
|
||||
storage_delete_row,
|
||||
storage_get_paginated_rows,
|
||||
storage_get_public_row,
|
||||
storage_get_row,
|
||||
storage_get_row_owner_id,
|
||||
storage_set_row,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"migrate_wasm_extension_database",
|
||||
"storage_delete_row",
|
||||
"storage_get_paginated_rows",
|
||||
"storage_get_public_row",
|
||||
"storage_get_row",
|
||||
"storage_get_row_owner_id",
|
||||
"storage_set_row",
|
||||
]
|
||||
@@ -0,0 +1,627 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.crud import update_migration_version
|
||||
from lnbits.core.db import db as core_db
|
||||
from lnbits.core.models import DbVersion
|
||||
from lnbits.core.models.extensions import InstallableExtension
|
||||
from lnbits.db import POSTGRES, SQLITE, Connection, Database
|
||||
from lnbits.settings import settings
|
||||
|
||||
_MIGRATION_FILE_RE = re.compile(r"^(\d+)_.*\.json$")
|
||||
_SQL_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||||
OWNER_ID_FIELD = "__lnbits_owner_id__"
|
||||
|
||||
|
||||
async def storage_get_row(
|
||||
ext_id: str,
|
||||
table: str,
|
||||
row_id: str,
|
||||
owner_id: str,
|
||||
) -> dict[str, Any] | None:
|
||||
table_schema = _load_table_schema(ext_id, table)
|
||||
query = f"""
|
||||
SELECT * FROM {_table_ref_for_schema(ext_id, table)}
|
||||
WHERE id = :id AND {OWNER_ID_FIELD} = :owner_id
|
||||
""" # noqa: S608
|
||||
async with Database(f"ext_{ext_id}").connect() as conn:
|
||||
row = await conn.fetchone(query, {"id": row_id, "owner_id": owner_id})
|
||||
return _row_from_db(table_schema, row) if row else None
|
||||
|
||||
|
||||
async def storage_get_public_row(
|
||||
ext_id: str,
|
||||
table: str,
|
||||
row_id: str,
|
||||
) -> dict[str, Any] | None:
|
||||
table_schema = _load_table_schema(ext_id, table)
|
||||
query = f"""
|
||||
SELECT * FROM {_table_ref_for_schema(ext_id, table)}
|
||||
WHERE id = :id
|
||||
""" # noqa: S608
|
||||
async with Database(f"ext_{ext_id}").connect() as conn:
|
||||
row = await conn.fetchone(query, {"id": row_id})
|
||||
return _row_from_db(table_schema, row) if row else None
|
||||
|
||||
|
||||
async def storage_get_row_owner_id(
|
||||
ext_id: str,
|
||||
table: str,
|
||||
row_id: str,
|
||||
) -> str | None:
|
||||
_load_table_schema(ext_id, table)
|
||||
query = f"""
|
||||
SELECT {OWNER_ID_FIELD} FROM {_table_ref_for_schema(ext_id, table)}
|
||||
WHERE id = :id
|
||||
""" # noqa: S608
|
||||
async with Database(f"ext_{ext_id}").connect() as conn:
|
||||
row = await conn.fetchone(query, {"id": row_id})
|
||||
|
||||
owner_id = row[OWNER_ID_FIELD] if row else None
|
||||
return owner_id if isinstance(owner_id, str) and owner_id else None
|
||||
|
||||
|
||||
async def storage_set_row(
|
||||
ext_id: str,
|
||||
table: str,
|
||||
data: dict[str, Any],
|
||||
owner_id: str,
|
||||
) -> None:
|
||||
table_schema = _load_table_schema(ext_id, table)
|
||||
clean_data = _data_to_db(table_schema, data, require_id=True)
|
||||
clean_data[OWNER_ID_FIELD] = owner_id
|
||||
columns = list(clean_data.keys())
|
||||
placeholders = [f":{column}" for column in columns]
|
||||
updates = [
|
||||
f"{column} = excluded.{column}"
|
||||
for column in columns
|
||||
if column not in ("id", OWNER_ID_FIELD)
|
||||
]
|
||||
conflict_sql = (
|
||||
"DO UPDATE SET "
|
||||
+ ", ".join(updates)
|
||||
+ f" WHERE {OWNER_ID_FIELD} = :{OWNER_ID_FIELD}"
|
||||
if updates
|
||||
else "DO NOTHING"
|
||||
)
|
||||
query = f"""
|
||||
INSERT INTO {_table_ref_for_schema(ext_id, table)}
|
||||
({", ".join(columns)})
|
||||
VALUES
|
||||
({", ".join(placeholders)})
|
||||
ON CONFLICT (id) {conflict_sql}
|
||||
""" # noqa: S608
|
||||
|
||||
async with Database(f"ext_{ext_id}").connect() as conn:
|
||||
await conn.execute(query, clean_data)
|
||||
|
||||
|
||||
async def storage_get_paginated_rows(
|
||||
ext_id: str,
|
||||
table: str,
|
||||
filters: dict[str, Any],
|
||||
*,
|
||||
owner_id: str,
|
||||
search: str | None,
|
||||
search_fields: list[str],
|
||||
sort_by: str | None,
|
||||
descending: bool,
|
||||
limit: int,
|
||||
offset: int,
|
||||
) -> dict[str, Any]:
|
||||
table_schema = _load_table_schema(ext_id, table)
|
||||
where_sql, values = _where_sql(table_schema, filters, search, search_fields)
|
||||
where_sql = _append_owner_where_sql(where_sql)
|
||||
values[OWNER_ID_FIELD] = owner_id
|
||||
order_sql = _order_sql(table_schema, sort_by, descending)
|
||||
count_values = dict(values)
|
||||
values.update({"limit": min(limit, 1000), "offset": offset})
|
||||
|
||||
table_ref = _table_ref_for_schema(ext_id, table)
|
||||
rows_query = f"""
|
||||
SELECT * FROM {table_ref}
|
||||
{where_sql}
|
||||
{order_sql}
|
||||
LIMIT :limit
|
||||
OFFSET :offset
|
||||
""" # noqa: S608
|
||||
count_query = f"""
|
||||
SELECT COUNT(*) AS count FROM {table_ref}
|
||||
{where_sql}
|
||||
""" # noqa: S608
|
||||
|
||||
async with Database(f"ext_{ext_id}").connect() as conn:
|
||||
rows = await conn.fetchall(rows_query, values)
|
||||
count_row = await conn.fetchone(count_query, count_values)
|
||||
|
||||
return {
|
||||
"data": [_row_from_db(table_schema, row) for row in rows],
|
||||
"total": int(count_row["count"]) if count_row else 0,
|
||||
}
|
||||
|
||||
|
||||
async def storage_delete_row(
|
||||
ext_id: str,
|
||||
table: str,
|
||||
row_id: str,
|
||||
owner_id: str,
|
||||
) -> None:
|
||||
_load_table_schema(ext_id, table)
|
||||
query = f"""
|
||||
DELETE FROM {_table_ref_for_schema(ext_id, table)}
|
||||
WHERE id = :id AND {OWNER_ID_FIELD} = :owner_id
|
||||
""" # noqa: S608
|
||||
async with Database(f"ext_{ext_id}").connect() as conn:
|
||||
await conn.execute(query, {"id": row_id, "owner_id": owner_id})
|
||||
|
||||
|
||||
async def migrate_wasm_extension_database(
|
||||
ext: InstallableExtension,
|
||||
current_version: DbVersion | None = None,
|
||||
) -> None:
|
||||
migrations_dir = ext.ext_dir / "storage" / "migrations"
|
||||
migration_files = _migration_files(migrations_dir)
|
||||
if not migration_files:
|
||||
logger.debug(f"No storage migrations for WASM extension '{ext.id}'.")
|
||||
return
|
||||
|
||||
ext_db = Database(f"ext_{ext.id}")
|
||||
async with ext_db.connect() as conn:
|
||||
for version, path in migration_files:
|
||||
if current_version and version <= current_version.version:
|
||||
continue
|
||||
logger.debug(f"running WASM storage migration {ext.id}.{version}")
|
||||
print(f"running migration {ext.id}.{version}")
|
||||
await _run_storage_migration(conn, path)
|
||||
await _update_wasm_migration_version(conn, ext.id, version)
|
||||
|
||||
|
||||
def _migration_files(migrations_dir: Path) -> list[tuple[int, Path]]:
|
||||
if not migrations_dir.is_dir():
|
||||
return []
|
||||
|
||||
files: list[tuple[int, Path]] = []
|
||||
for path in migrations_dir.glob("*.json"):
|
||||
match = _MIGRATION_FILE_RE.match(path.name)
|
||||
if not match:
|
||||
raise ValueError(f"Invalid WASM storage migration filename: {path.name}")
|
||||
files.append((int(match.group(1)), path))
|
||||
return sorted(files)
|
||||
|
||||
|
||||
async def _run_storage_migration(db: Connection, path: Path) -> None:
|
||||
migration = _load_json(path)
|
||||
operations = migration.get("operations")
|
||||
if not isinstance(operations, list):
|
||||
raise ValueError(f"WASM storage migration '{path}' has no operations list.")
|
||||
|
||||
for operation in operations:
|
||||
if not isinstance(operation, dict):
|
||||
raise ValueError(f"WASM storage migration '{path}' has invalid operation.")
|
||||
sql = _operation_sql(db, operation)
|
||||
await db.execute(sql)
|
||||
|
||||
|
||||
def _operation_sql(db: Connection, operation: dict[str, Any]) -> str:
|
||||
op = operation.get("op")
|
||||
if op == "create_table":
|
||||
return _create_table_sql(db, operation)
|
||||
if op == "add_field":
|
||||
return _add_field_sql(db, operation)
|
||||
if op == "create_index":
|
||||
return _create_index_sql(db, operation)
|
||||
raise ValueError(f"Unsupported WASM storage migration operation: {op}")
|
||||
|
||||
|
||||
def _create_table_sql(db: Connection, operation: dict[str, Any]) -> str:
|
||||
table = _require_identifier(operation, "table")
|
||||
fields = _require_fields(operation)
|
||||
if not any(field.get("name") == "id" for field in fields):
|
||||
raise ValueError(f"WASM storage table '{table}' must define an id field.")
|
||||
if any(field.get("name") == OWNER_ID_FIELD for field in fields):
|
||||
raise ValueError(
|
||||
f"WASM storage table '{table}' defines reserved field '{OWNER_ID_FIELD}'."
|
||||
)
|
||||
|
||||
columns = [
|
||||
_column_sql(db, field, primary_key=field.get("name") == "id")
|
||||
for field in fields
|
||||
]
|
||||
columns.append(f"{OWNER_ID_FIELD} TEXT NOT NULL")
|
||||
return f"""
|
||||
CREATE TABLE IF NOT EXISTS {_table_ref(db, table)} (
|
||||
{", ".join(columns)}
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
def _add_field_sql(db: Connection, operation: dict[str, Any]) -> str:
|
||||
table = _require_identifier(operation, "table")
|
||||
field = _field_from_add_field_operation(operation)
|
||||
if field["name"] == OWNER_ID_FIELD:
|
||||
raise ValueError(
|
||||
f"WASM storage table '{table}' cannot add reserved field "
|
||||
f"'{OWNER_ID_FIELD}'."
|
||||
)
|
||||
return f"""
|
||||
ALTER TABLE {_table_ref(db, table)}
|
||||
ADD COLUMN {_column_sql(db, field)};
|
||||
"""
|
||||
|
||||
|
||||
def _create_index_sql(db: Connection, operation: dict[str, Any]) -> str:
|
||||
table = _require_identifier(operation, "table")
|
||||
name = _require_identifier(operation, "name")
|
||||
field = _require_identifier(operation, "field")
|
||||
if field == OWNER_ID_FIELD:
|
||||
raise ValueError(
|
||||
f"WASM storage table '{table}' cannot index reserved field "
|
||||
f"'{OWNER_ID_FIELD}'."
|
||||
)
|
||||
|
||||
if db.type == SQLITE and db.schema:
|
||||
return f"""
|
||||
CREATE INDEX IF NOT EXISTS {_schema_ref(db, name)}
|
||||
ON {table} ({field});
|
||||
"""
|
||||
|
||||
return f"""
|
||||
CREATE INDEX IF NOT EXISTS {name}
|
||||
ON {_table_ref(db, table)} ({field});
|
||||
"""
|
||||
|
||||
|
||||
def _column_sql(
|
||||
db: Connection,
|
||||
field: dict[str, Any],
|
||||
*,
|
||||
primary_key: bool = False,
|
||||
) -> str:
|
||||
name = _require_identifier(field, "name")
|
||||
column_type = _field_type_sql(db, field)
|
||||
parts = [name, column_type]
|
||||
|
||||
if primary_key:
|
||||
parts.append("PRIMARY KEY")
|
||||
elif not field.get("nullable", False):
|
||||
parts.append("NOT NULL")
|
||||
|
||||
if "default" in field:
|
||||
parts.append(f"DEFAULT {_default_sql(field['default'])}")
|
||||
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def _field_type_sql(db: Connection, field: dict[str, Any]) -> str:
|
||||
if field.get("list") is True:
|
||||
return "TEXT"
|
||||
|
||||
field_type = field.get("type")
|
||||
if field_type == "string":
|
||||
return "TEXT"
|
||||
if field_type == "integer":
|
||||
return db.big_int
|
||||
if field_type == "number":
|
||||
return "DOUBLE PRECISION" if db.type == POSTGRES else "REAL"
|
||||
if field_type == "boolean":
|
||||
return "BOOLEAN"
|
||||
if field_type == "datetime":
|
||||
return "TIMESTAMP"
|
||||
raise ValueError(f"Unsupported WASM storage field type: {field_type}")
|
||||
|
||||
|
||||
def _load_table_schema(ext_id: str, table: str) -> dict[str, Any]:
|
||||
schema = _load_storage_schema(ext_id)
|
||||
tables = schema.get("tables")
|
||||
if not isinstance(tables, dict):
|
||||
raise ValueError(f"WASM extension '{ext_id}' has no storage tables schema.")
|
||||
|
||||
_require_identifier({"table": table}, "table")
|
||||
table_schema = tables.get(table)
|
||||
if not isinstance(table_schema, dict):
|
||||
raise ValueError(f"WASM extension '{ext_id}' has no storage table '{table}'.")
|
||||
|
||||
fields = table_schema.get("fields")
|
||||
if not isinstance(fields, list) or not fields:
|
||||
raise ValueError(f"WASM storage table '{table}' has no fields schema.")
|
||||
|
||||
for field in fields:
|
||||
if not isinstance(field, dict):
|
||||
raise ValueError(f"WASM storage table '{table}' has invalid field schema.")
|
||||
_require_identifier(field, "name")
|
||||
if field["name"] == OWNER_ID_FIELD:
|
||||
raise ValueError(
|
||||
f"WASM storage table '{table}' defines reserved field "
|
||||
f"'{OWNER_ID_FIELD}'."
|
||||
)
|
||||
return table_schema
|
||||
|
||||
|
||||
def _load_storage_schema(ext_id: str) -> dict[str, Any]:
|
||||
schema_path = (
|
||||
Path(settings.lnbits_extensions_path)
|
||||
/ "extensions"
|
||||
/ ext_id
|
||||
/ "storage"
|
||||
/ "schema.json"
|
||||
)
|
||||
if not schema_path.is_file():
|
||||
raise ValueError(f"WASM extension '{ext_id}' has no storage schema.")
|
||||
return _load_json(schema_path)
|
||||
|
||||
|
||||
def _data_to_db(
|
||||
table_schema: dict[str, Any],
|
||||
data: dict[str, Any],
|
||||
*,
|
||||
require_id: bool,
|
||||
) -> dict[str, Any]:
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("WASM storage row data must be an object.")
|
||||
if require_id and not data.get("id"):
|
||||
raise ValueError("WASM storage row data must include an id.")
|
||||
_reject_reserved_owner_field(data, "row")
|
||||
|
||||
fields = _fields_by_name(table_schema)
|
||||
unknown_fields = sorted(set(data) - set(fields))
|
||||
if unknown_fields:
|
||||
raise ValueError(
|
||||
"WASM storage row has unknown fields: " + ", ".join(unknown_fields)
|
||||
)
|
||||
|
||||
return {
|
||||
field_name: _value_to_db(fields[field_name], value)
|
||||
for field_name, value in data.items()
|
||||
}
|
||||
|
||||
|
||||
def _filters_to_db(
|
||||
table_schema: dict[str, Any],
|
||||
filters: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
if not isinstance(filters, dict):
|
||||
raise ValueError("WASM storage filters must be an object.")
|
||||
_reject_reserved_owner_field(filters, "filters")
|
||||
|
||||
fields = _fields_by_name(table_schema)
|
||||
unknown_fields = sorted(set(filters) - set(fields))
|
||||
if unknown_fields:
|
||||
raise ValueError(
|
||||
"WASM storage filters have unknown fields: " + ", ".join(unknown_fields)
|
||||
)
|
||||
|
||||
return {
|
||||
field_name: _value_to_db(fields[field_name], value)
|
||||
for field_name, value in filters.items()
|
||||
}
|
||||
|
||||
|
||||
def _where_sql(
|
||||
table_schema: dict[str, Any],
|
||||
filters: dict[str, Any],
|
||||
search: str | None,
|
||||
search_fields: list[str],
|
||||
) -> tuple[str, dict[str, Any]]:
|
||||
clean_filters = _filters_to_db(table_schema, filters)
|
||||
clauses = [f"{field} = :filter_{field}" for field in clean_filters]
|
||||
values = {f"filter_{field}": value for field, value in clean_filters.items()}
|
||||
|
||||
clean_search = search.strip().lower() if search else ""
|
||||
if clean_search:
|
||||
fields = _fields_by_name(table_schema)
|
||||
invalid_fields = sorted(set(search_fields) - set(fields))
|
||||
if invalid_fields:
|
||||
raise ValueError(
|
||||
"WASM storage search has unknown fields: " + ", ".join(invalid_fields)
|
||||
)
|
||||
if search_fields:
|
||||
search_clause = " OR ".join(
|
||||
f"LOWER(CAST({field} AS TEXT)) LIKE :search" for field in search_fields
|
||||
)
|
||||
clauses.append(f"({search_clause})")
|
||||
values["search"] = f"%{clean_search}%"
|
||||
|
||||
return ("WHERE " + " AND ".join(clauses), values) if clauses else ("", values)
|
||||
|
||||
|
||||
def _append_owner_where_sql(where_sql: str) -> str:
|
||||
owner_clause = f"{OWNER_ID_FIELD} = :{OWNER_ID_FIELD}"
|
||||
if where_sql:
|
||||
return f"{where_sql} AND {owner_clause}"
|
||||
return f"WHERE {owner_clause}"
|
||||
|
||||
|
||||
def _order_sql(
|
||||
table_schema: dict[str, Any],
|
||||
sort_by: str | None,
|
||||
descending: bool,
|
||||
) -> str:
|
||||
if not sort_by:
|
||||
return ""
|
||||
fields = _fields_by_name(table_schema)
|
||||
if sort_by not in fields:
|
||||
raise ValueError(f"WASM storage sort field is unknown: {sort_by}")
|
||||
direction = "DESC" if descending else "ASC"
|
||||
return f"ORDER BY {sort_by} {direction}"
|
||||
|
||||
|
||||
def _row_from_db(
|
||||
table_schema: dict[str, Any],
|
||||
row: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
fields = _fields_by_name(table_schema)
|
||||
return {
|
||||
field_name: _value_from_db(fields[field_name], value)
|
||||
for field_name, value in dict(row).items()
|
||||
if field_name in fields
|
||||
}
|
||||
|
||||
|
||||
def _fields_by_name(table_schema: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
||||
fields = table_schema.get("fields")
|
||||
if not isinstance(fields, list):
|
||||
raise ValueError("WASM storage table schema fields must be a list.")
|
||||
return {field["name"]: field for field in fields}
|
||||
|
||||
|
||||
def _reject_reserved_owner_field(data: dict[str, Any], value_name: str) -> None:
|
||||
if OWNER_ID_FIELD in data:
|
||||
raise ValueError(f"WASM storage {value_name} includes a reserved owner field.")
|
||||
|
||||
|
||||
def _value_to_db(field: dict[str, Any], value: Any) -> Any: # noqa: C901
|
||||
if value is None:
|
||||
if field.get("nullable", False):
|
||||
return None
|
||||
raise ValueError(f"WASM storage field '{field['name']}' cannot be null.")
|
||||
|
||||
if field.get("list") is True:
|
||||
if not isinstance(value, list):
|
||||
raise ValueError(f"WASM storage field '{field['name']}' must be a list.")
|
||||
return json.dumps(value)
|
||||
|
||||
field_type = field.get("type")
|
||||
if field_type == "string":
|
||||
if not isinstance(value, str):
|
||||
raise ValueError(f"WASM storage field '{field['name']}' must be a string.")
|
||||
return value
|
||||
if field_type == "integer":
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise ValueError(
|
||||
f"WASM storage field '{field['name']}' must be an integer."
|
||||
)
|
||||
return value
|
||||
if field_type == "number":
|
||||
if isinstance(value, bool) or not isinstance(value, int | float):
|
||||
raise ValueError(f"WASM storage field '{field['name']}' must be a number.")
|
||||
return value
|
||||
if field_type == "boolean":
|
||||
if not isinstance(value, bool):
|
||||
raise ValueError(f"WASM storage field '{field['name']}' must be a boolean.")
|
||||
return value
|
||||
if field_type == "datetime":
|
||||
if isinstance(value, int | float):
|
||||
return datetime.fromtimestamp(value, tz=timezone.utc)
|
||||
if isinstance(value, datetime):
|
||||
return value
|
||||
raise ValueError(
|
||||
f"WASM storage field '{field['name']}' must be a Unix timestamp."
|
||||
)
|
||||
raise ValueError(f"Unsupported WASM storage field type: {field_type}")
|
||||
|
||||
|
||||
def _value_from_db(field: dict[str, Any], value: Any) -> Any:
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
if field.get("list") is True:
|
||||
if isinstance(value, str):
|
||||
return json.loads(value)
|
||||
return value
|
||||
|
||||
field_type = field.get("type")
|
||||
if field_type == "boolean":
|
||||
return bool(value)
|
||||
if field_type == "datetime":
|
||||
if isinstance(value, datetime):
|
||||
return int(value.replace(tzinfo=timezone.utc).timestamp())
|
||||
if isinstance(value, int | float):
|
||||
return int(value)
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return int(datetime.fromisoformat(value).timestamp())
|
||||
except ValueError:
|
||||
return value
|
||||
return value
|
||||
|
||||
|
||||
def _default_sql(value: Any) -> str:
|
||||
if value is None:
|
||||
return "NULL"
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
if isinstance(value, int | float):
|
||||
return str(value)
|
||||
if isinstance(value, str):
|
||||
return _quote_sql_string(value)
|
||||
if isinstance(value, list | dict):
|
||||
return _quote_sql_string(json.dumps(value))
|
||||
raise ValueError(f"Unsupported WASM storage default value: {value}")
|
||||
|
||||
|
||||
def _field_from_add_field_operation(operation: dict[str, Any]) -> dict[str, Any]:
|
||||
field = {
|
||||
"name": operation.get("field"),
|
||||
"type": operation.get("type"),
|
||||
}
|
||||
for key in ("default", "list", "nullable"):
|
||||
if key in operation:
|
||||
field[key] = operation[key]
|
||||
return field
|
||||
|
||||
|
||||
def _require_fields(operation: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
fields = operation.get("fields")
|
||||
if not isinstance(fields, list) or not fields:
|
||||
raise ValueError("WASM storage create_table operation requires fields.")
|
||||
if not all(isinstance(field, dict) for field in fields):
|
||||
raise ValueError("WASM storage fields must be objects.")
|
||||
return fields
|
||||
|
||||
|
||||
def _require_identifier(data: dict[str, Any], key: str) -> str:
|
||||
value = data.get(key)
|
||||
if not isinstance(value, str) or not _SQL_IDENTIFIER_RE.match(value):
|
||||
raise ValueError(f"Invalid WASM storage SQL identifier for '{key}': {value}")
|
||||
return value
|
||||
|
||||
|
||||
def _table_ref(db: Connection, table: str) -> str:
|
||||
if db.schema:
|
||||
return f"{_schema_ref(db, table)}"
|
||||
return table
|
||||
|
||||
|
||||
def _table_ref_for_schema(ext_id: str, table: str) -> str:
|
||||
_require_identifier({"schema": ext_id}, "schema")
|
||||
_require_identifier({"table": table}, "table")
|
||||
return f"{ext_id}.{table}"
|
||||
|
||||
|
||||
def _schema_ref(db: Connection, name: str) -> str:
|
||||
if not db.schema:
|
||||
return name
|
||||
if not _SQL_IDENTIFIER_RE.match(db.schema):
|
||||
raise ValueError(f"Invalid WASM extension storage schema: {db.schema}")
|
||||
return f"{db.schema}.{name}"
|
||||
|
||||
|
||||
def _quote_sql_string(value: str) -> str:
|
||||
return "'" + value.replace("'", "''") + "'"
|
||||
|
||||
|
||||
def _load_json(path: Path) -> dict[str, Any]:
|
||||
with open(path, encoding="utf-8") as json_file:
|
||||
data = json.load(json_file)
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError(f"WASM storage migration '{path}' must be a JSON object.")
|
||||
return data
|
||||
|
||||
|
||||
async def _update_wasm_migration_version(
|
||||
db: Connection,
|
||||
ext_id: str,
|
||||
version: int,
|
||||
) -> None:
|
||||
if db.schema is None:
|
||||
await update_migration_version(db, ext_id, version)
|
||||
else:
|
||||
async with core_db.connect() as conn:
|
||||
await update_migration_version(conn, ext_id, version)
|
||||
@@ -0,0 +1,13 @@
|
||||
from .component import warm_wasm_extension
|
||||
from .events import dispatch_wasm_invoice_paid
|
||||
from .invoke import invoke_wasm_extension_export
|
||||
from .loader import WasmExtension, is_wasm_extension_dir, is_wasm_extension_id
|
||||
|
||||
__all__ = [
|
||||
"WasmExtension",
|
||||
"dispatch_wasm_invoice_paid",
|
||||
"invoke_wasm_extension_export",
|
||||
"is_wasm_extension_dir",
|
||||
"is_wasm_extension_id",
|
||||
"warm_wasm_extension",
|
||||
]
|
||||
@@ -0,0 +1,39 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import Any
|
||||
|
||||
from wasmtime import Config, Engine
|
||||
|
||||
from .loader import WasmExtension
|
||||
|
||||
|
||||
def warm_wasm_extension(extension: WasmExtension) -> None:
|
||||
_wasm_component(extension)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _wasm_engine() -> Any:
|
||||
config = Config()
|
||||
config.wasm_component_model = True
|
||||
return Engine(config)
|
||||
|
||||
|
||||
def _wasm_component(extension: WasmExtension) -> Any:
|
||||
stat = extension.module_path.stat()
|
||||
return _cached_wasm_component(
|
||||
str(extension.module_path),
|
||||
stat.st_mtime_ns,
|
||||
stat.st_size,
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=32)
|
||||
def _cached_wasm_component(
|
||||
module_path: str,
|
||||
mtime_ns: int,
|
||||
size: int,
|
||||
) -> Any:
|
||||
from wasmtime import component
|
||||
|
||||
return component.Component.from_file(_wasm_engine(), module_path)
|
||||
@@ -0,0 +1,122 @@
|
||||
import json
|
||||
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:
|
||||
extension_id = _payment_extension_id(payment)
|
||||
if not extension_id:
|
||||
return
|
||||
|
||||
extension = core_app_extra.wasm_extension_registry.get(extension_id)
|
||||
if not extension:
|
||||
return
|
||||
|
||||
export_name = _wasm_invoice_paid_export(extension.config)
|
||||
if not export_name:
|
||||
return
|
||||
|
||||
if not _is_wasm_event_export(extension, export_name):
|
||||
logger.warning(
|
||||
f"WASM extension '{extension.id}' declares invalid onInvoicePaid "
|
||||
f"export '{export_name}'."
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
await invoke_wasm_extension_export(
|
||||
extension.id,
|
||||
export_name,
|
||||
_wasm_invoice_paid_payload(payment),
|
||||
context="event",
|
||||
owner_id=await _wasm_invoice_paid_owner_id(extension, payment),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
f"WASM extension '{extension.id}' failed to handle paid invoice "
|
||||
f"'{payment.payment_hash}': {exc!s}"
|
||||
)
|
||||
|
||||
|
||||
def _payment_extension_id(payment: Any) -> str | None:
|
||||
if isinstance(payment.extension, str) and payment.extension:
|
||||
return payment.extension
|
||||
|
||||
extra = payment.extra or {}
|
||||
tag = extra.get("tag") or payment.tag
|
||||
return tag if isinstance(tag, str) and tag else None
|
||||
|
||||
|
||||
async def _wasm_invoice_paid_owner_id(extension: Any, payment: Any) -> str | None:
|
||||
source_id = _payment_source_id(payment)
|
||||
source_tables = _wasm_public_invoice_source_tables(extension.config)
|
||||
if not source_id or not source_tables:
|
||||
return None
|
||||
|
||||
for source_table in source_tables:
|
||||
owner_id = await storage_get_row_owner_id(extension.id, source_table, source_id)
|
||||
if owner_id:
|
||||
return owner_id
|
||||
return None
|
||||
|
||||
|
||||
def _payment_source_id(payment: Any) -> str | None:
|
||||
extra = payment.extra or {}
|
||||
source_id = extra.get("source_id")
|
||||
return source_id if isinstance(source_id, str) and source_id else None
|
||||
|
||||
|
||||
def _wasm_public_invoice_source_tables(config: dict[str, Any]) -> list[str]:
|
||||
permissions = config.get("permissions") or []
|
||||
for permission in permissions:
|
||||
if not isinstance(permission, dict):
|
||||
continue
|
||||
if permission.get("id") != "wallet.create_invoice_public":
|
||||
continue
|
||||
policies = permission.get("policies")
|
||||
if not isinstance(policies, list):
|
||||
return []
|
||||
return [
|
||||
source_policy["table"]
|
||||
for source_policy in policies
|
||||
if isinstance(source_policy, dict)
|
||||
and isinstance(source_policy.get("table"), str)
|
||||
and source_policy["table"]
|
||||
]
|
||||
return []
|
||||
|
||||
|
||||
def _wasm_invoice_paid_export(config: dict[str, Any]) -> str | None:
|
||||
events = config.get("events") or {}
|
||||
export_name = events.get("onInvoicePaid")
|
||||
return export_name if isinstance(export_name, str) and export_name else None
|
||||
|
||||
|
||||
def _is_wasm_event_export(extension: Any, export_name: str) -> bool:
|
||||
for export in extension.exports:
|
||||
if export.get("name") == export_name:
|
||||
return export.get("visibility") == "event"
|
||||
return False
|
||||
|
||||
|
||||
def _wasm_invoice_paid_payload(payment: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"checkingId": payment.checking_id,
|
||||
"paymentHash": payment.payment_hash,
|
||||
"walletId": payment.wallet_id,
|
||||
"amount": payment.amount,
|
||||
"fee": payment.fee,
|
||||
"bolt11": payment.bolt11,
|
||||
"memo": payment.memo,
|
||||
"pending": payment.pending,
|
||||
"status": payment.status,
|
||||
"tag": payment.tag,
|
||||
"extension": payment.extension,
|
||||
"extra": payment.extra or {},
|
||||
"payment": json.loads(payment.json()),
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
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
|
||||
|
||||
|
||||
def add_extension_host_imports(
|
||||
linker: Any,
|
||||
api_host: ExtensionAPIHost,
|
||||
event_loop: asyncio.AbstractEventLoop,
|
||||
) -> None:
|
||||
with linker.root() as root:
|
||||
methods_by_interface: dict[str, list[Any]] = {}
|
||||
for method in list_extension_api_methods():
|
||||
methods_by_interface.setdefault(method.host_interface, []).append(method)
|
||||
|
||||
for host_interface, methods in methods_by_interface.items():
|
||||
with root.add_instance(f"lnbits:extension/{host_interface}") as host:
|
||||
for method in methods:
|
||||
host.add_func(
|
||||
method.host_name.replace("_", "-"),
|
||||
_make_host_import(
|
||||
api_host,
|
||||
method.method_id,
|
||||
method.request_model is EmptyRequest,
|
||||
event_loop,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _make_host_import(
|
||||
api_host: ExtensionAPIHost,
|
||||
host_name: str,
|
||||
empty_request: bool,
|
||||
event_loop: asyncio.AbstractEventLoop,
|
||||
) -> Any:
|
||||
if empty_request:
|
||||
|
||||
def empty_host_import(_store: Any) -> Any:
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
api_host.invoke(host_name), event_loop
|
||||
)
|
||||
response = future.result()
|
||||
return _dict_to_component_record(response)
|
||||
|
||||
return empty_host_import
|
||||
|
||||
def host_import(_store: Any, request: Any = None) -> Any:
|
||||
payload = _component_payload_to_dict(request)
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
api_host.invoke(host_name, payload), event_loop
|
||||
)
|
||||
response = future.result()
|
||||
return _dict_to_component_record(response)
|
||||
|
||||
return host_import
|
||||
|
||||
|
||||
def _component_payload_to_dict(value: Any) -> dict[str, Any]:
|
||||
if value is None:
|
||||
return {}
|
||||
if hasattr(value, "__dict__"):
|
||||
return dict(value.__dict__)
|
||||
if isinstance(value, Mapping):
|
||||
return dict(value)
|
||||
raise TypeError("WASM host function payload must be a record.")
|
||||
|
||||
|
||||
def _dict_to_component_record(value: Mapping[str, Any]) -> Any:
|
||||
|
||||
record = component.Record()
|
||||
for key, item in value.items():
|
||||
setattr(record, _camel_to_kebab(key), _to_component_value(item))
|
||||
return record
|
||||
|
||||
|
||||
def _to_component_value(value: Any) -> Any:
|
||||
if isinstance(value, Mapping):
|
||||
return _dict_to_component_record(value)
|
||||
if isinstance(value, list):
|
||||
return [_to_component_value(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def _camel_to_kebab(value: str) -> str:
|
||||
return re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", value).replace("_", "-").lower()
|
||||
@@ -0,0 +1,120 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
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
|
||||
|
||||
from ..api.host import ExtensionHostAPI
|
||||
from ..api.runtime import ExtensionAPIHost
|
||||
from .component import _wasm_component, _wasm_engine
|
||||
from .host import add_extension_host_imports
|
||||
from .loader import WasmExtension
|
||||
|
||||
|
||||
async def invoke_wasm_extension_export(
|
||||
ext_id: str,
|
||||
export_name: str,
|
||||
payload: Mapping[str, Any] | None = None,
|
||||
*,
|
||||
user: Any | None = None,
|
||||
access_token: str | None = None,
|
||||
context: str = "user",
|
||||
owner_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
extension = _get_registered_extension(ext_id)
|
||||
permissions = await _extension_permissions(extension)
|
||||
api = ExtensionHostAPI(
|
||||
extension.id,
|
||||
permissions,
|
||||
user_id=_user_id(user),
|
||||
access_token=access_token,
|
||||
context=context,
|
||||
owner_id=owner_id,
|
||||
)
|
||||
event_loop = asyncio.get_running_loop()
|
||||
|
||||
return await asyncio.to_thread(
|
||||
_invoke_wasm_extension_export_sync,
|
||||
extension,
|
||||
export_name,
|
||||
payload or {},
|
||||
api,
|
||||
event_loop,
|
||||
)
|
||||
|
||||
|
||||
def _invoke_wasm_extension_export_sync(
|
||||
extension: WasmExtension,
|
||||
export_name: str,
|
||||
payload: Mapping[str, Any],
|
||||
api: ExtensionHostAPI,
|
||||
event_loop: asyncio.AbstractEventLoop,
|
||||
) -> dict[str, Any]:
|
||||
|
||||
engine = _wasm_engine()
|
||||
store = Store(engine)
|
||||
store.set_wasi(WasiConfig())
|
||||
|
||||
linker = component.Linker(engine)
|
||||
linker.add_wasip2()
|
||||
add_extension_host_imports(linker, ExtensionAPIHost(api), event_loop)
|
||||
|
||||
wasm_component = _wasm_component(extension)
|
||||
instance = linker.instantiate(store, wasm_component)
|
||||
function = instance.get_func(store, export_name)
|
||||
if not function:
|
||||
raise KeyError(
|
||||
f"WASM extension '{extension.id}' has no export '{export_name}'."
|
||||
)
|
||||
|
||||
result = function(store, json.dumps(payload))
|
||||
function.post_return(store)
|
||||
return _parse_wasm_export_result(extension, result)
|
||||
|
||||
|
||||
def _parse_wasm_export_result(extension: WasmExtension, value: Any) -> dict[str, Any]:
|
||||
if isinstance(value, bytes):
|
||||
value = value.decode()
|
||||
if not isinstance(value, str):
|
||||
return {"ok": True, "data": value}
|
||||
|
||||
max_response_bytes = (
|
||||
(extension.config.get("wasm") or {})
|
||||
.get("resource_limits", {})
|
||||
.get("max_response_bytes")
|
||||
)
|
||||
if isinstance(max_response_bytes, int):
|
||||
response_size = len(value.encode())
|
||||
if response_size > max_response_bytes:
|
||||
raise ValueError(
|
||||
f"WASM extension response is too large: {response_size} bytes."
|
||||
)
|
||||
|
||||
parsed = json.loads(value)
|
||||
if isinstance(parsed, dict):
|
||||
return parsed
|
||||
return {"ok": True, "data": parsed}
|
||||
|
||||
|
||||
def _get_registered_extension(ext_id: str) -> WasmExtension:
|
||||
extension = core_app_extra.wasm_extension_registry.get(ext_id)
|
||||
if extension:
|
||||
return extension
|
||||
raise RuntimeError(f"WASM extension '{ext_id}' is not registered.")
|
||||
|
||||
|
||||
async def _extension_permissions(extension: WasmExtension) -> list[Any]:
|
||||
installed_extension = await get_installed_extension(extension.id)
|
||||
if not installed_extension:
|
||||
return []
|
||||
return installed_extension.permissions
|
||||
|
||||
|
||||
def _user_id(user: Any | None) -> str | None:
|
||||
return getattr(user, "id", None) if user else None
|
||||
@@ -0,0 +1,100 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from lnbits.settings import settings
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WasmExtension:
|
||||
id: str
|
||||
name: str
|
||||
version: str
|
||||
root_path: Path
|
||||
module_path: Path
|
||||
wit_path: Path | None
|
||||
world: str
|
||||
host_api: str
|
||||
exports: list[dict[str, Any]]
|
||||
config: dict[str, Any]
|
||||
|
||||
|
||||
def is_wasm_extension_id(ext_id: str) -> bool:
|
||||
config = load_wasm_extension_config(ext_id)
|
||||
return bool(config and config.get("extension_type") == "wasm")
|
||||
|
||||
|
||||
def is_wasm_extension_dir(ext_dir: Path) -> bool:
|
||||
config = _load_json(ext_dir / "config.json")
|
||||
return bool(config and config.get("extension_type") == "wasm")
|
||||
|
||||
|
||||
def load_wasm_extension_config(ext_id: str) -> dict[str, Any] | None:
|
||||
ext_dir = Path(settings.lnbits_extensions_path, "extensions", ext_id)
|
||||
return _load_json(ext_dir / "config.json")
|
||||
|
||||
|
||||
def load_wasm_extension(ext_id: str) -> WasmExtension:
|
||||
ext_dir = Path(settings.lnbits_extensions_path, "extensions", ext_id)
|
||||
config = load_wasm_extension_config(ext_id)
|
||||
if not config:
|
||||
raise FileNotFoundError(f"Missing WASM extension config for '{ext_id}'.")
|
||||
if config.get("extension_type") != "wasm":
|
||||
raise ValueError(f"Extension '{ext_id}' is not a WASM extension.")
|
||||
|
||||
wasm_config = config.get("wasm") or {}
|
||||
module_path = _extension_path(ext_dir, wasm_config.get("module"))
|
||||
wit_path = _optional_extension_path(ext_dir, wasm_config.get("wit"))
|
||||
_check_wasm_module(module_path)
|
||||
if wit_path and not wit_path.is_file():
|
||||
raise FileNotFoundError(f"WIT file not found: {wit_path}")
|
||||
|
||||
return WasmExtension(
|
||||
id=config.get("id") or ext_id,
|
||||
name=config.get("name") or ext_id,
|
||||
version=config.get("version") or "0.0",
|
||||
root_path=ext_dir,
|
||||
module_path=module_path,
|
||||
wit_path=wit_path,
|
||||
world=wasm_config.get("world") or "",
|
||||
host_api=wasm_config.get("host_api") or "lnbits.core.wasm_ext.ExtensionHostAPI",
|
||||
exports=wasm_config.get("exports") or [],
|
||||
config=config,
|
||||
)
|
||||
|
||||
|
||||
def _load_json(path: Path) -> dict[str, Any] | None:
|
||||
if not path.is_file():
|
||||
return None
|
||||
with path.open("r", encoding="utf-8") as config_file:
|
||||
value = json.load(config_file)
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError(f"Expected JSON object in '{path}'.")
|
||||
return value
|
||||
|
||||
|
||||
def _extension_path(ext_dir: Path, value: Any) -> Path:
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ValueError(f"Missing relative path for extension '{ext_dir.name}'.")
|
||||
path = (ext_dir / value).resolve()
|
||||
if ext_dir.resolve() not in path.parents:
|
||||
raise ValueError(f"Extension path escapes extension root: {value}")
|
||||
return path
|
||||
|
||||
|
||||
def _optional_extension_path(ext_dir: Path, value: Any) -> Path | None:
|
||||
if value is None:
|
||||
return None
|
||||
return _extension_path(ext_dir, value)
|
||||
|
||||
|
||||
def _check_wasm_module(path: Path) -> None:
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"WASM module not found: {path}")
|
||||
with path.open("rb") as wasm_file:
|
||||
magic = wasm_file.read(4)
|
||||
if magic != b"\0asm":
|
||||
raise ValueError(f"Invalid WASM module: {path}")
|
||||
+10
-1
@@ -448,7 +448,7 @@ async def _check_user_access(r: Request, user_id: str, conn: Connection | None =
|
||||
async def _check_user_extension_access(
|
||||
user_id: str, path: str, conn: Connection | None = None
|
||||
):
|
||||
ext_id = path_segments(path)[0]
|
||||
ext_id = _extension_id_from_request_path(path)
|
||||
status = await check_user_extension_access(user_id, ext_id, conn=conn)
|
||||
if not status.success:
|
||||
raise HTTPException(
|
||||
@@ -457,6 +457,15 @@ async def _check_user_extension_access(
|
||||
)
|
||||
|
||||
|
||||
def _extension_id_from_request_path(path: str) -> str:
|
||||
segments = path_segments(path)
|
||||
if len(segments) >= 2 and segments[0] == "ext":
|
||||
return segments[1]
|
||||
if len(segments) >= 4 and segments[:3] == ["api", "v1", "ext"]:
|
||||
return segments[3]
|
||||
return segments[0]
|
||||
|
||||
|
||||
async def _get_account_from_token(
|
||||
access_token: str, path: str, method: str, conn: Connection | None = None
|
||||
) -> Account | None:
|
||||
|
||||
+3
-3
@@ -131,15 +131,15 @@ class FiatSubscriptionResponse(BaseModel):
|
||||
|
||||
|
||||
class FiatPaymentSuccessStatus(FiatPaymentStatus):
|
||||
paid = True
|
||||
paid = True # type: ignore[reportIncompatibleVariableOverride]
|
||||
|
||||
|
||||
class FiatPaymentFailedStatus(FiatPaymentStatus):
|
||||
paid = False
|
||||
paid = False # type: ignore[reportIncompatibleVariableOverride]
|
||||
|
||||
|
||||
class FiatPaymentPendingStatus(FiatPaymentStatus):
|
||||
paid = None
|
||||
paid = None # type: ignore[reportIncompatibleVariableOverride]
|
||||
|
||||
|
||||
class FiatProvider(ABC):
|
||||
|
||||
+10
-1
@@ -283,7 +283,7 @@ class RevolutWallet(FiatProvider):
|
||||
return FiatSubscriptionResponse(
|
||||
ok=True,
|
||||
checkout_session_url=checkout_url,
|
||||
subscription_request_id=payment_options.subscription_request_id,
|
||||
subscription_request_id=revolut_subscription_id,
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
return FiatSubscriptionResponse(
|
||||
@@ -302,6 +302,15 @@ class RevolutWallet(FiatProvider):
|
||||
**kwargs,
|
||||
) -> FiatSubscriptionResponse:
|
||||
try:
|
||||
subscription = await self.get_subscription(subscription_id)
|
||||
reference = self.deserialize_subscription_reference(
|
||||
subscription.get("external_reference")
|
||||
)
|
||||
if not reference or reference.wallet_id != correlation_id:
|
||||
return FiatSubscriptionResponse(
|
||||
ok=False, error_message="Subscription not found."
|
||||
)
|
||||
|
||||
r = await self.client.post(
|
||||
f"/api/subscriptions/{subscription_id}/cancel",
|
||||
timeout=REVOLUT_REQUEST_TIMEOUT,
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Generate llms.txt markdown from FastAPI OpenAPI schema for AI agents."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import PlainTextResponse
|
||||
|
||||
|
||||
def generate_llms_txt(app: FastAPI) -> str:
|
||||
"""Convert an OpenAPI schema to llms.txt markdown format."""
|
||||
openapi_schema = app.openapi()
|
||||
lines: list[str] = []
|
||||
|
||||
# H1: API Title
|
||||
info = openapi_schema.get("info", {})
|
||||
title = info.get("title", "API")
|
||||
lines.append(f"# {title}")
|
||||
lines.append("")
|
||||
|
||||
# Blockquote: Description
|
||||
description = info.get("description")
|
||||
if description:
|
||||
for line in description.strip().split("\n"):
|
||||
lines.append(f"> {line}")
|
||||
lines.append("")
|
||||
|
||||
# Group endpoints by tag
|
||||
paths = openapi_schema.get("paths", {})
|
||||
endpoints_by_tag: dict[str, list[dict[str, Any]]] = {}
|
||||
|
||||
for path, path_item in paths.items():
|
||||
for method in ["get", "post", "put", "patch", "delete", "head", "options"]:
|
||||
if method not in path_item:
|
||||
continue
|
||||
operation = path_item[method]
|
||||
tags = operation.get("tags", ["Endpoints"])
|
||||
tag = tags[0] if tags else "Endpoints"
|
||||
if tag not in endpoints_by_tag:
|
||||
endpoints_by_tag[tag] = []
|
||||
endpoints_by_tag[tag].append(
|
||||
{
|
||||
"path": path,
|
||||
"method": method.upper(),
|
||||
"operation": operation,
|
||||
}
|
||||
)
|
||||
|
||||
# Generate sections by tag
|
||||
for tag, endpoints in endpoints_by_tag.items():
|
||||
lines.append(f"## {tag}")
|
||||
lines.append("")
|
||||
for endpoint in endpoints:
|
||||
method = endpoint["method"]
|
||||
path = endpoint["path"]
|
||||
operation = endpoint["operation"]
|
||||
summary = operation.get("summary", "")
|
||||
if summary:
|
||||
lines.append(f"### `{method} {path}` - {summary}")
|
||||
else:
|
||||
lines.append(f"### `{method} {path}`")
|
||||
lines.append("")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines).strip() + "\n"
|
||||
|
||||
|
||||
def create_llms_txt_route(app: FastAPI) -> None:
|
||||
"""Add a /llms.txt endpoint to the app."""
|
||||
|
||||
@app.get(
|
||||
"/llms.txt",
|
||||
response_class=PlainTextResponse,
|
||||
include_in_schema=False,
|
||||
summary="Get LLM-friendly API documentation",
|
||||
)
|
||||
async def get_llms_txt() -> str:
|
||||
"""Return the API documentation in llms.txt markdown format."""
|
||||
return generate_llms_txt(app)
|
||||
+11
-5
@@ -359,9 +359,11 @@ class FeeSettings(LNbitsSettings):
|
||||
|
||||
|
||||
class ExchangeProvidersSettings(LNbitsSettings):
|
||||
lnbits_exchange_rate_cache_seconds: int = Field(default=30, ge=0)
|
||||
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):
|
||||
@@ -1047,7 +1052,7 @@ class EditableSettings(
|
||||
|
||||
|
||||
class UpdateSettings(EditableSettings):
|
||||
class Config:
|
||||
class Config(EditableSettings.Config):
|
||||
extra = Extra.forbid
|
||||
|
||||
|
||||
@@ -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:
|
||||
@@ -1198,11 +1204,11 @@ class ReadOnlySettings(
|
||||
|
||||
|
||||
class Settings(EditableSettings, ReadOnlySettings, TransientSettings, BaseSettings):
|
||||
class Config:
|
||||
class Config(EditableSettings.Config, BaseSettings.Config): # type: ignore[misc]
|
||||
env_file = ".env"
|
||||
env_file_encoding = "utf-8"
|
||||
case_sensitive = False
|
||||
json_loads = list_parse_fallback
|
||||
json_loads = list_parse_fallback # type: ignore[assignment]
|
||||
|
||||
def is_user_allowed(self, user_id: str) -> bool:
|
||||
return (
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+10
-10
File diff suppressed because one or more lines are too long
@@ -212,12 +212,15 @@ body.bg-image .q-page-container {
|
||||
backdrop-filter: none; /* Ensure the page content is not affected */
|
||||
}
|
||||
|
||||
body.body--dark .q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark),
|
||||
body.body--dark .q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark) {
|
||||
--q-dark: rgba(29, 29, 29, 0.3);
|
||||
background-color: var(--q-dark);
|
||||
}
|
||||
body.body--dark .q-header,
|
||||
body.body--dark .q-drawer {
|
||||
--q-dark: rgba(29, 29, 29, 0.3);
|
||||
background-color: var(--q-dark);
|
||||
backdrop-filter: blur(6px) brightness(0.8);
|
||||
backdrop-filter: brightness(0.8);
|
||||
}
|
||||
|
||||
body.rounded-ui .q-card,
|
||||
@@ -388,11 +391,11 @@ body[data-theme=salvador].card-gradient.body--dark .q-drawer {
|
||||
}
|
||||
|
||||
body.card-shadow .q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark) {
|
||||
filter: drop-shadow(0 10px 24px rgba(0, 0, 0, 0.18));
|
||||
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
body.card-shadow.body--dark .q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark) {
|
||||
filter: drop-shadow(0 12px 28px rgba(0, 0, 0, 0.45));
|
||||
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
body.no-burger-background .q-drawer {
|
||||
|
||||
@@ -184,6 +184,7 @@ window.localisation.en = {
|
||||
release_notes: 'Release Notes',
|
||||
activate_extension_details: 'Make extension available/unavailable for users',
|
||||
featured: 'Featured',
|
||||
categories: 'Categories',
|
||||
all: 'All',
|
||||
only_admins_can_install: '(Only admin accounts can install extensions)',
|
||||
only_admins_can_create_extensions:
|
||||
@@ -518,6 +519,36 @@ window.localisation.en = {
|
||||
admin_settings: 'Admin Settings',
|
||||
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_grant_install: 'Grant and install',
|
||||
extension_permissions_high_risk_warning:
|
||||
'This extension requests permissions that can move funds.',
|
||||
extension_permission_risk_low: 'Low risk',
|
||||
extension_permission_risk_medium: 'Medium risk',
|
||||
extension_permission_risk_high: 'High risk',
|
||||
extension_permission_warning_wallet_pay_invoice:
|
||||
'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_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_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_hosts: 'Allowed hosts',
|
||||
extension_permission_utils_basic: 'Use basic LNbits utilities',
|
||||
extension_permission_ui_camera_scan_qr: 'Scan QR codes',
|
||||
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_balance_read: 'View wallet balances',
|
||||
extension_permission_wallet_list: 'List wallets',
|
||||
extension_permission_wallet_pay_invoice: 'Pay invoices',
|
||||
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
|
||||
}
|
||||
}
|
||||
})
|
||||
})()
|
||||
@@ -35,6 +35,18 @@ window.app.component('lnbits-manage-extension-list', {
|
||||
.toLocaleLowerCase()
|
||||
.includes(this.searchTerm.toLocaleLowerCase())
|
||||
})
|
||||
},
|
||||
extensionUrl(extension) {
|
||||
if (extension.is_wasm) {
|
||||
return `/ext/${extension.code}`
|
||||
}
|
||||
return `/${extension.code}/`
|
||||
},
|
||||
extensionActive(extension) {
|
||||
const extensionPath = extension.is_wasm
|
||||
? `/ext/${extension.code}`
|
||||
: `/${extension.code}`
|
||||
return this.$route.path.startsWith(extensionPath)
|
||||
}
|
||||
},
|
||||
async created() {
|
||||
|
||||
@@ -360,18 +360,37 @@ window.app.component('lnbits-payment-list', {
|
||||
paymentTableRowKey(row) {
|
||||
return row.payment_hash + row.amount
|
||||
},
|
||||
exportCSV(detailed = false) {
|
||||
async exportCSV(detailed = false) {
|
||||
// status is important for export but it is not in paymentsTable
|
||||
// because it is manually added with payment detail link and icons
|
||||
// and would cause duplication in the list
|
||||
const pagination = this.paymentsTable.pagination
|
||||
const query = {
|
||||
sortby: pagination.sortBy ?? 'time',
|
||||
direction: pagination.descending ? 'desc' : 'asc'
|
||||
}
|
||||
const params = new URLSearchParams(query)
|
||||
LNbits.api.getPayments(this.wallet, params).then(response => {
|
||||
let payments = response.data.data.map(this.mapPayment)
|
||||
const maxPages = 100
|
||||
const limit = 1000
|
||||
let payments = []
|
||||
|
||||
this.paymentsCSV.loading = true
|
||||
try {
|
||||
for (let page = 0; page < maxPages; page++) {
|
||||
const query = {
|
||||
sortby: pagination.sortBy ?? 'time',
|
||||
direction: pagination.descending ? 'desc' : 'asc',
|
||||
limit,
|
||||
offset: page * limit
|
||||
}
|
||||
const params = new URLSearchParams(query)
|
||||
const response = await LNbits.api.getPayments(this.wallet, params)
|
||||
const pagePayments = response.data.data || []
|
||||
payments = payments.concat(pagePayments.map(this.mapPayment))
|
||||
|
||||
if (
|
||||
pagePayments.length < limit ||
|
||||
payments.length >= response.data.total
|
||||
) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
let columns = this.paymentsCSV.columns
|
||||
|
||||
if (detailed) {
|
||||
@@ -400,7 +419,11 @@ window.app.component('lnbits-payment-list', {
|
||||
payments,
|
||||
this.wallet.name + '-payments'
|
||||
)
|
||||
})
|
||||
} catch (err) {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
} finally {
|
||||
this.paymentsCSV.loading = false
|
||||
}
|
||||
},
|
||||
addFilterTag() {
|
||||
if (!this.exportTagName) return
|
||||
|
||||
@@ -8,6 +8,10 @@ window.app.component('lnbits-qrcode-lnurl', {
|
||||
prefix: {
|
||||
type: String,
|
||||
default: 'lnurlp'
|
||||
},
|
||||
href: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
data() {
|
||||
@@ -21,7 +25,10 @@ window.app.component('lnbits-qrcode-lnurl', {
|
||||
if (this.tab == 'bech32') {
|
||||
const bytes = new TextEncoder().encode(this.url)
|
||||
const bech32 = NostrTools.nip19.encodeBytes('lnurl', bytes)
|
||||
this.lnurl = `lightning:${bech32.toUpperCase()}`
|
||||
this.lnurl =
|
||||
this.href && this.href.trim() !== ''
|
||||
? `${this.href}?lightning=${bech32.toUpperCase()}`
|
||||
: `lightning:${bech32.toUpperCase()}`
|
||||
} else if (this.tab == 'lud17') {
|
||||
if (this.url.startsWith('http://')) {
|
||||
this.lnurl = this.url.replace('http://', this.prefix + '://')
|
||||
|
||||
@@ -85,6 +85,9 @@ window.app.component('lnbits-qrcode', {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
return false
|
||||
} else if (this.href && this.href.startsWith('http')) {
|
||||
window.open(this.href, '_blank')
|
||||
event.preventDefault()
|
||||
}
|
||||
},
|
||||
async writeNfcTag() {
|
||||
|
||||
@@ -27,6 +27,14 @@ const DynamicComponent = {
|
||||
name: r.name,
|
||||
component: async () => {
|
||||
await LNbits.utils.loadTemplate(r.template)
|
||||
if (r.i18n) {
|
||||
const locale =
|
||||
window.i18n?.global?.locale?.value ??
|
||||
window.i18n?.global?.locale ??
|
||||
window.g.locale ??
|
||||
'en'
|
||||
await LNbits.utils.loadExtI18n(r.i18n, locale)
|
||||
}
|
||||
await LNbits.utils.loadScript(r.component)
|
||||
return window[r.name]
|
||||
}
|
||||
@@ -131,6 +139,16 @@ const routes = [
|
||||
name: 'PageError',
|
||||
component: PageError
|
||||
},
|
||||
{
|
||||
path: '/ext/:extId',
|
||||
name: 'WasmExtensionRoot',
|
||||
component: window.WasmExtensionComponent
|
||||
},
|
||||
{
|
||||
path: '/ext/:extId/:pathMatch(.*)*',
|
||||
name: 'WasmExtension',
|
||||
component: window.WasmExtensionComponent
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'DynamicComponent',
|
||||
@@ -151,6 +169,30 @@ window.i18n = new VueI18n.createI18n({
|
||||
fallbackLocale: 'en',
|
||||
messages: window.localisation
|
||||
})
|
||||
;(function () {
|
||||
let _applying = false
|
||||
let _target = null
|
||||
Vue.watch(
|
||||
() => window.i18n.global.locale,
|
||||
async (locale, prevLocale) => {
|
||||
if (_applying || !LNbits.utils._extI18nDirs.size) return
|
||||
_target = locale
|
||||
_applying = true
|
||||
window.i18n.global.locale = prevLocale
|
||||
_applying = false
|
||||
await Promise.all(
|
||||
[...LNbits.utils._extI18nDirs].map(dir =>
|
||||
LNbits.utils.loadExtI18n(dir, locale)
|
||||
)
|
||||
)
|
||||
if (_target !== locale) return
|
||||
_applying = true
|
||||
window.i18n.global.locale = locale
|
||||
_applying = false
|
||||
},
|
||||
{flush: 'sync'}
|
||||
)
|
||||
})()
|
||||
|
||||
window.app.mixin({
|
||||
data() {
|
||||
|
||||
@@ -10,6 +10,7 @@ window.PageExtensions = {
|
||||
tab: 'installed',
|
||||
manageExtensionTab: 'releases',
|
||||
filteredExtensions: [],
|
||||
categories: new Set(),
|
||||
updatableExtensions: [],
|
||||
showUninstallDialog: false,
|
||||
showManageExtensionDialog: false,
|
||||
@@ -23,19 +24,16 @@ window.PageExtensions = {
|
||||
selectedExtensionDetails: null,
|
||||
selectedExtensionRepos: null,
|
||||
selectedRelease: null,
|
||||
permissionGrant: {
|
||||
show: false,
|
||||
permissions: [],
|
||||
resolve: null
|
||||
},
|
||||
uninstallAndDropDb: false,
|
||||
maxStars: 5,
|
||||
paylinkWebsocket: null,
|
||||
searchToggle: false,
|
||||
reviewsUrl: null,
|
||||
permissionsDialog: {
|
||||
show: false,
|
||||
extension: null,
|
||||
checked: [],
|
||||
missing: [],
|
||||
tags: [],
|
||||
tagOptions: []
|
||||
},
|
||||
reviewsDialog: {
|
||||
show: false,
|
||||
extension: null,
|
||||
@@ -100,20 +98,6 @@ window.PageExtensions = {
|
||||
this.filterExtensions(this.searchTerm, val)
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
permissionsAllChecked() {
|
||||
const ext = this.permissionsDialog.extension
|
||||
if (!ext || !Array.isArray(ext.permissions)) return true
|
||||
const required = ext.permissions.map(p => p.id)
|
||||
return required.every(p => this.permissionsDialog.checked.includes(p))
|
||||
},
|
||||
permissionsHasMissingEndpoints() {
|
||||
return (
|
||||
this.permissionsDialog.missing &&
|
||||
this.permissionsDialog.missing.length > 0
|
||||
)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
filterExtensions(term, tab) {
|
||||
// Filter the extensions list
|
||||
@@ -128,6 +112,10 @@ window.PageExtensions = {
|
||||
}
|
||||
}
|
||||
|
||||
const isCategoryTab = !['installed', 'all', 'featured'].includes(tab)
|
||||
const isInSelectedCategory = extension =>
|
||||
extension.categories?.includes(tab) ?? false
|
||||
|
||||
this.filteredExtensions = this.extensions
|
||||
.filter(e => (tab === 'all' ? !e.isInstalled : true))
|
||||
.filter(e => (tab === 'installed' ? e.isInstalled : true))
|
||||
@@ -135,6 +123,7 @@ window.PageExtensions = {
|
||||
tab === 'installed' ? (e.isActive ? true : !!this.g.user.admin) : true
|
||||
)
|
||||
.filter(e => (tab === 'featured' ? e.isFeatured : true))
|
||||
.filter(e => (isCategoryTab ? isInSelectedCategory(e) : true))
|
||||
.filter(extensionNameContains(term))
|
||||
.map(e => ({
|
||||
...e,
|
||||
@@ -148,6 +137,12 @@ window.PageExtensions = {
|
||||
// the install logic has been triggered one way or another
|
||||
this.unsubscribeFromPaylinkWs()
|
||||
|
||||
const grantedPermissions =
|
||||
await this.resolveExtensionPermissionGrant(release)
|
||||
if (grantedPermissions === null) {
|
||||
return
|
||||
}
|
||||
|
||||
this.selectedExtension.inProgress = true
|
||||
this.showManageExtensionDialog = false
|
||||
release.payment_hash =
|
||||
@@ -159,7 +154,8 @@ window.PageExtensions = {
|
||||
archive: release.archive,
|
||||
source_repo: release.source_repo,
|
||||
payment_hash: release.payment_hash,
|
||||
version: release.version
|
||||
version: release.version,
|
||||
permissions: grantedPermissions
|
||||
})
|
||||
.then(response => {
|
||||
this.selectedExtension.inProgress = false
|
||||
@@ -168,6 +164,7 @@ window.PageExtensions = {
|
||||
)
|
||||
extension.isAvailable = true
|
||||
extension.isInstalled = true
|
||||
extension.icon = response.data.icon || extension.icon
|
||||
extension.installedRelease = release
|
||||
this.toggleExtension(extension)
|
||||
extension.inProgress = false
|
||||
@@ -262,81 +259,7 @@ window.PageExtensions = {
|
||||
extension.inProgress = false
|
||||
})
|
||||
},
|
||||
async loadWasmCapabilities(extension) {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
`/wasm/api/v1/extensions/${extension.id}/capabilities`,
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
const capabilities = data || {}
|
||||
extension.permissions = capabilities.permissions || []
|
||||
extension.paymentTags = capabilities.payment_tags || []
|
||||
extension.grantedPermissions = capabilities.granted_permissions || []
|
||||
extension.grantedPaymentTags = capabilities.granted_payment_tags || []
|
||||
extension._grantedPermissions = extension.grantedPermissions.slice()
|
||||
extension._grantedPaymentTags = extension.grantedPaymentTags.slice()
|
||||
return capabilities
|
||||
},
|
||||
showWasmPermissionsDialog(extension, data = {}) {
|
||||
this.permissionsDialog.extension = extension
|
||||
this.permissionsDialog.checked = (
|
||||
data.granted_permissions ||
|
||||
extension._grantedPermissions ||
|
||||
extension.grantedPermissions ||
|
||||
[]
|
||||
).slice()
|
||||
this.permissionsDialog.missing = data.missing_permissions || []
|
||||
this.permissionsDialog.tags = (
|
||||
data.granted_payment_tags ||
|
||||
extension._grantedPaymentTags ||
|
||||
extension.grantedPaymentTags ||
|
||||
[]
|
||||
).slice()
|
||||
this.permissionsDialog.tagOptions =
|
||||
data.payment_tags || extension.paymentTags || []
|
||||
this.permissionsDialog.show = true
|
||||
},
|
||||
async ensureWasmPermissionsReady(extension) {
|
||||
if (extension.extensionType !== 'wasm') return true
|
||||
const wasmHost = this.extensions.find(ext => ext.id === 'wasm')
|
||||
if (!wasmHost || !wasmHost.isInstalled || !wasmHost.isActive) {
|
||||
Quasar.Notify.create({
|
||||
type: 'warning',
|
||||
message:
|
||||
'Enable the WASM! host extension before using this extension.'
|
||||
})
|
||||
return false
|
||||
}
|
||||
let data = {}
|
||||
try {
|
||||
data = await this.loadWasmCapabilities(extension)
|
||||
} catch (err) {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
return false
|
||||
}
|
||||
const required = (data.permissions || []).map(p => p.id).filter(Boolean)
|
||||
const missing = required.filter(
|
||||
permission => !extension.grantedPermissions.includes(permission)
|
||||
)
|
||||
const tags = data.payment_tags || []
|
||||
const missingTags =
|
||||
tags.length &&
|
||||
!tags.some(tag => extension.grantedPaymentTags.includes(tag))
|
||||
if (!missing.length && !missingTags) return true
|
||||
|
||||
Quasar.Notify.create({
|
||||
type: 'warning',
|
||||
message: 'Save WASM permissions before using this extension.'
|
||||
})
|
||||
this.showWasmPermissionsDialog(extension, data)
|
||||
return false
|
||||
},
|
||||
async openExtension(extension) {
|
||||
if (!(await this.ensureWasmPermissionsReady(extension))) return
|
||||
window.location.href = `${extension.id}/`
|
||||
},
|
||||
async enableExtensionForUser(extension) {
|
||||
if (!(await this.ensureWasmPermissionsReady(extension))) return
|
||||
if (extension.isPaymentRequired) {
|
||||
this.showPayToEnable(extension)
|
||||
return
|
||||
@@ -390,67 +313,6 @@ window.PageExtensions = {
|
||||
this.selectedExtension.payToEnable.showQRCode = false
|
||||
this.showPayToEnableDialog = true
|
||||
},
|
||||
cancelPermissionsDialog() {
|
||||
this.permissionsDialog.show = false
|
||||
this.permissionsDialog.extension = null
|
||||
this.permissionsDialog.checked = []
|
||||
this.permissionsDialog.missing = []
|
||||
this.permissionsDialog.tags = []
|
||||
this.permissionsDialog.tagOptions = []
|
||||
},
|
||||
async openPermissionsForExtension(extension) {
|
||||
if (extension.extensionType !== 'wasm') {
|
||||
Quasar.Notify.create({
|
||||
type: 'warning',
|
||||
message: 'This extension does not use WASM permissions.'
|
||||
})
|
||||
return
|
||||
}
|
||||
try {
|
||||
const data = await this.loadWasmCapabilities(extension)
|
||||
this.showWasmPermissionsDialog(extension, data)
|
||||
} catch (err) {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
}
|
||||
},
|
||||
async confirmPermissionsDialog() {
|
||||
const ext = this.permissionsDialog.extension
|
||||
const granted = this.permissionsDialog.checked.slice()
|
||||
const tags = this.permissionsDialog.tags.slice()
|
||||
this.permissionsDialog.show = false
|
||||
this.permissionsDialog.extension = null
|
||||
this.permissionsDialog.checked = []
|
||||
const missing = this.permissionsDialog.missing || []
|
||||
this.permissionsDialog.missing = []
|
||||
this.permissionsDialog.tags = []
|
||||
this.permissionsDialog.tagOptions = []
|
||||
if (missing.length) {
|
||||
Quasar.Notify.create({
|
||||
type: 'negative',
|
||||
message: 'Missing API endpoints for one or more permissions.'
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!ext) return
|
||||
ext._grantedPermissions = granted
|
||||
ext.grantedPermissions = granted
|
||||
ext._grantedPaymentTags = tags
|
||||
ext.grantedPaymentTags = tags
|
||||
try {
|
||||
await LNbits.api.request(
|
||||
'PUT',
|
||||
`/wasm/api/v1/extensions/${ext.id}/permissions`,
|
||||
this.g.user.wallets[0].adminkey,
|
||||
{permissions: granted, payment_tags: tags}
|
||||
)
|
||||
Quasar.Notify.create({
|
||||
type: 'positive',
|
||||
message: 'Permissions saved.'
|
||||
})
|
||||
} catch (err) {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
}
|
||||
},
|
||||
updatePayToInstallData(extension) {
|
||||
LNbits.api
|
||||
.request(
|
||||
@@ -557,6 +419,10 @@ window.PageExtensions = {
|
||||
},
|
||||
async payAndInstall(release) {
|
||||
try {
|
||||
if ((await this.resolveExtensionPermissionGrant(release)) === null) {
|
||||
return
|
||||
}
|
||||
|
||||
this.selectedExtension.inProgress = true
|
||||
this.showManageExtensionDialog = false
|
||||
const paymentInfo = await this.requestPaymentForInstall(
|
||||
@@ -602,6 +468,10 @@ window.PageExtensions = {
|
||||
}
|
||||
},
|
||||
async showInstallQRCode(release) {
|
||||
if ((await this.resolveExtensionPermissionGrant(release)) === null) {
|
||||
return
|
||||
}
|
||||
|
||||
this.selectedRelease = release
|
||||
|
||||
try {
|
||||
@@ -764,6 +634,9 @@ window.PageExtensions = {
|
||||
|
||||
return ''
|
||||
},
|
||||
extensionOpenUrl(extension) {
|
||||
return extension.isWasm ? `/ext/${extension.id}` : `/${extension.id}`
|
||||
},
|
||||
async getGitHubReleaseDetails(release) {
|
||||
if (!release.is_github_release || release.loaded) {
|
||||
return
|
||||
@@ -779,6 +652,8 @@ window.PageExtensions = {
|
||||
release.is_version_compatible = data.is_version_compatible
|
||||
release.min_lnbits_version = data.min_lnbits_version
|
||||
release.warning = data.warning
|
||||
release.extension_type = data.extension_type
|
||||
release.permissions = data.permissions || []
|
||||
} catch (error) {
|
||||
console.warn(error)
|
||||
release.error = error
|
||||
@@ -787,6 +662,75 @@ window.PageExtensions = {
|
||||
release.inProgress = false
|
||||
}
|
||||
},
|
||||
async resolveExtensionPermissionGrant(release) {
|
||||
const permissions = this.extensionPermissionsForRelease(release)
|
||||
if (
|
||||
!this.releaseRequiresPermissionGrant(release) ||
|
||||
!permissions.length
|
||||
) {
|
||||
return []
|
||||
}
|
||||
if (release.grantedPermissions) {
|
||||
return release.grantedPermissions
|
||||
}
|
||||
const grantedPermissions =
|
||||
await this.confirmExtensionPermissions(permissions)
|
||||
if (!grantedPermissions) {
|
||||
return null
|
||||
}
|
||||
release.grantedPermissions = grantedPermissions
|
||||
return grantedPermissions
|
||||
},
|
||||
extensionPermissionsForRelease(release) {
|
||||
return release.permissions || this.selectedExtension?.permissions || []
|
||||
},
|
||||
releaseRequiresPermissionGrant(release) {
|
||||
return (
|
||||
release.extension_type === 'wasm' ||
|
||||
this.selectedExtension?.isWasm === true
|
||||
)
|
||||
},
|
||||
confirmExtensionPermissions(permissions) {
|
||||
return new Promise(resolve => {
|
||||
this.selectedRelease = null
|
||||
this.permissionGrant = {
|
||||
show: true,
|
||||
permissions,
|
||||
resolve
|
||||
}
|
||||
this.showManageExtensionDialog = true
|
||||
})
|
||||
},
|
||||
grantExtensionPermissions() {
|
||||
this.resolveExtensionPermissionDialog(this.permissionGrant.permissions)
|
||||
},
|
||||
cancelExtensionPermissions() {
|
||||
this.resolveExtensionPermissionDialog(null)
|
||||
},
|
||||
onManageExtensionDialogHide() {
|
||||
if (this.permissionGrant.show) {
|
||||
this.resolveExtensionPermissionDialog(null)
|
||||
}
|
||||
},
|
||||
resolveExtensionPermissionDialog(grantedPermissions) {
|
||||
const resolve = this.permissionGrant.resolve
|
||||
this.permissionGrant = {
|
||||
show: false,
|
||||
permissions: [],
|
||||
resolve: null
|
||||
}
|
||||
this.showManageExtensionDialog = false
|
||||
if (resolve) {
|
||||
resolve(grantedPermissions)
|
||||
}
|
||||
},
|
||||
permissionGrantHasHighRisk() {
|
||||
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))
|
||||
},
|
||||
@@ -797,6 +741,13 @@ window.PageExtensions = {
|
||||
if (!ext.selectedForUpdate) {
|
||||
continue
|
||||
}
|
||||
if (ext.isWasm) {
|
||||
Quasar.Notify.create({
|
||||
type: 'warning',
|
||||
message: `Skipping ${ext.id}; this extension update requires permission approval.`
|
||||
})
|
||||
continue
|
||||
}
|
||||
ext.inProgress = true
|
||||
await LNbits.api.request('POST', `/api/v1/extension`, null, {
|
||||
ext_id: ext.id,
|
||||
@@ -989,6 +940,9 @@ window.PageExtensions = {
|
||||
async fetchAllExtensions() {
|
||||
try {
|
||||
const {data} = await LNbits.api.request('GET', `/api/v1/extension/all`)
|
||||
data.forEach(ext => {
|
||||
ext.categories?.forEach(category => this.categories.add(category))
|
||||
})
|
||||
return data
|
||||
} catch (error) {
|
||||
console.warn(error)
|
||||
|
||||
@@ -223,6 +223,12 @@ window.PageWallet = {
|
||||
if (data.tag === 'payRequest') {
|
||||
this.parse.lnurlpay = Object.freeze(data)
|
||||
this.parse.data.amount = data.minSendable / 1000
|
||||
this.receive.units = [
|
||||
'sats',
|
||||
...(this.g.allowedCurrencies.length > 0
|
||||
? this.g.allowedCurrencies
|
||||
: this.g.currencies)
|
||||
]
|
||||
} else if (data.tag === 'login') {
|
||||
this.parse.lnurlauth = Object.freeze(data)
|
||||
} else if (data.tag === 'withdrawRequest') {
|
||||
@@ -408,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':
|
||||
@@ -425,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
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -323,6 +323,20 @@ window._lnbitsUtils = {
|
||||
converter.setOption('simpleLineBreaks', true)
|
||||
return converter.makeHtml(text)
|
||||
},
|
||||
_extI18nDirs: new Set(),
|
||||
_extI18nLoaded: {},
|
||||
loadExtI18n(dir, locale) {
|
||||
this._extI18nDirs.add(dir)
|
||||
const loaded = (this._extI18nLoaded[dir] ??= {})
|
||||
if (loaded[locale]) return loaded[locale]
|
||||
loaded[locale] = this.loadScript(`${dir}/${locale}.js`).catch(() => {
|
||||
if (locale !== 'en') {
|
||||
loaded['en'] ??= this.loadScript(`${dir}/en.js`).catch(() => {})
|
||||
return loaded['en']
|
||||
}
|
||||
})
|
||||
return loaded[locale]
|
||||
},
|
||||
async decryptLnurlPayAES(success_action, preimage) {
|
||||
let keyb = new Uint8Array(
|
||||
preimage.match(/[\da-f]{2}/gi).map(h => parseInt(h, 16))
|
||||
@@ -351,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')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,566 @@
|
||||
window.WasmExtensionComponent = {
|
||||
template: `
|
||||
<div class="wasm-extension-page relative-position">
|
||||
<q-inner-loading :showing="loading && !frameUrl">
|
||||
<q-spinner-dots size="40px"></q-spinner-dots>
|
||||
</q-inner-loading>
|
||||
<q-banner v-if="error" class="q-ma-md bg-negative text-white">
|
||||
{{ error }}
|
||||
</q-banner>
|
||||
<iframe
|
||||
v-else-if="frameUrl"
|
||||
ref="frame"
|
||||
:key="frameUrl"
|
||||
class="wasm-extension-frame"
|
||||
:src="frameUrl"
|
||||
:title="extensionName || 'Extension'"
|
||||
sandbox="allow-scripts"
|
||||
allow="clipboard-write"
|
||||
referrerpolicy="no-referrer"
|
||||
></iframe>
|
||||
<q-dialog v-model="cameraPrompt.show" persistent>
|
||||
<q-card style="width: min(520px, calc(100vw - 32px)); max-width: 520px">
|
||||
<q-card-section>
|
||||
<div class="text-h6">Camera access</div>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pt-none">
|
||||
{{ cameraPrompt.extensionName }} wants to access the camera to scan a QR code.
|
||||
</q-card-section>
|
||||
<q-card-actions align="right">
|
||||
<q-btn
|
||||
flat
|
||||
color="negative"
|
||||
label="Deny"
|
||||
@click="resolveCameraPrompt('deny')"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
color="primary"
|
||||
label="Allow"
|
||||
@click="resolveCameraPrompt('allow')"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
label="Allow and Remember"
|
||||
@click="resolveCameraPrompt('allow_remember')"
|
||||
></q-btn>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
`,
|
||||
data() {
|
||||
return {
|
||||
allowedPaymentHashes: new Set(),
|
||||
bridge: {
|
||||
apiRoutes: [],
|
||||
extensionId: '',
|
||||
permissions: [],
|
||||
public: false,
|
||||
query: {},
|
||||
routeParams: {}
|
||||
},
|
||||
bridgePort: null,
|
||||
cameraPrompt: {
|
||||
extensionName: '',
|
||||
reject: null,
|
||||
resolve: null,
|
||||
show: false
|
||||
},
|
||||
error: '',
|
||||
extensionName: '',
|
||||
frameUrl: '',
|
||||
handleWindowMessage: null,
|
||||
loading: false,
|
||||
loadId: 0,
|
||||
paymentSubscriptions: new Map()
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.handleWindowMessage = event => this.onWindowMessage(event)
|
||||
window.addEventListener('message', this.handleWindowMessage)
|
||||
},
|
||||
unmounted() {
|
||||
window.removeEventListener('message', this.handleWindowMessage)
|
||||
this.rejectCameraPrompt('Camera scan cancelled.')
|
||||
this.closeBridgePort()
|
||||
},
|
||||
watch: {
|
||||
'$route.fullPath': {
|
||||
immediate: true,
|
||||
handler() {
|
||||
this.loadFrameConfig()
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
emptyBridge() {
|
||||
return {
|
||||
apiRoutes: [],
|
||||
extensionId: '',
|
||||
permissions: [],
|
||||
public: false,
|
||||
query: {},
|
||||
routeParams: {}
|
||||
}
|
||||
},
|
||||
plainBridgeContext() {
|
||||
return {
|
||||
extensionId: String(this.bridge.extensionId || ''),
|
||||
public: Boolean(this.bridge.public),
|
||||
routeParams: this.plainValue(this.bridge.routeParams || {}),
|
||||
query: this.plainValue(this.bridge.query || {})
|
||||
}
|
||||
},
|
||||
hasBridgePermission(permission) {
|
||||
return (this.bridge.permissions || []).includes(permission)
|
||||
},
|
||||
cameraPromptStorageKey() {
|
||||
return `lnbits.ext.permissions.${this.bridge.extensionId}.ui.camera.scan_qr`
|
||||
},
|
||||
emptyCameraPrompt() {
|
||||
return {
|
||||
extensionName: '',
|
||||
reject: null,
|
||||
resolve: null,
|
||||
show: false
|
||||
}
|
||||
},
|
||||
plainValue(value) {
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(value))
|
||||
} catch (_error) {
|
||||
return {}
|
||||
}
|
||||
},
|
||||
async loadFrameConfig() {
|
||||
const extId = String(this.$route.params.extId || '')
|
||||
const loadId = ++this.loadId
|
||||
this.loading = true
|
||||
this.error = ''
|
||||
this.frameUrl = ''
|
||||
this.bridge = this.emptyBridge()
|
||||
this.allowedPaymentHashes.clear()
|
||||
this.rejectCameraPrompt('Camera scan cancelled.')
|
||||
this.closeBridgePort()
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/v1/ext/${encodeURIComponent(extId)}/_ui/frame`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {'content-type': 'application/json'},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({
|
||||
path: this.$route.path,
|
||||
query: this.$route.query || {}
|
||||
})
|
||||
}
|
||||
)
|
||||
const text = await response.text()
|
||||
let data = {}
|
||||
if (text) {
|
||||
try {
|
||||
data = JSON.parse(text)
|
||||
} catch (_error) {
|
||||
data = {detail: text}
|
||||
}
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(data?.detail || 'Failed to load extension page.')
|
||||
}
|
||||
if (loadId !== this.loadId) return
|
||||
|
||||
this.bridge = data.bridge || this.emptyBridge()
|
||||
this.extensionName = data.extension?.name || extId
|
||||
this.frameUrl = data.frameUrl
|
||||
} catch (error) {
|
||||
if (loadId !== this.loadId) return
|
||||
console.error('[lnbits wasm extension] Failed to load frame.', error)
|
||||
this.error = error instanceof Error ? error.message : String(error)
|
||||
} finally {
|
||||
if (loadId === this.loadId) {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
},
|
||||
extensionFrameWindow() {
|
||||
return this.$refs.frame?.contentWindow
|
||||
},
|
||||
sendResponse(reply, id, payload) {
|
||||
reply({
|
||||
type: 'lnbits-extension:response',
|
||||
id,
|
||||
...payload
|
||||
})
|
||||
},
|
||||
allowedApiRoute(method, path) {
|
||||
let url
|
||||
try {
|
||||
url = new URL(path, window.location.origin)
|
||||
} catch (_error) {
|
||||
return false
|
||||
}
|
||||
if (url.origin !== window.location.origin) return false
|
||||
|
||||
method = String(method || 'GET').toUpperCase()
|
||||
return (this.bridge.apiRoutes || []).some(route => {
|
||||
return (
|
||||
route.method === method &&
|
||||
new RegExp(route.pattern).test(url.pathname)
|
||||
)
|
||||
})
|
||||
},
|
||||
async callApi(message) {
|
||||
const method = String(message.method || 'GET').toUpperCase()
|
||||
const path = String(message.path || '')
|
||||
if (!this.allowedApiRoute(method, path)) {
|
||||
throw new Error('Extension API route is not allowed.')
|
||||
}
|
||||
|
||||
const options = {
|
||||
method,
|
||||
headers: {},
|
||||
credentials: 'same-origin'
|
||||
}
|
||||
if (message.body !== undefined && message.body !== null) {
|
||||
options.headers['content-type'] = 'application/json'
|
||||
options.body = JSON.stringify(message.body)
|
||||
}
|
||||
|
||||
const response = await fetch(path, options)
|
||||
const text = await response.text()
|
||||
let data = text
|
||||
if (text) {
|
||||
try {
|
||||
data = JSON.parse(text)
|
||||
} catch (_error) {
|
||||
data = text
|
||||
}
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
typeof data === 'object' && data.detail ? data.detail : text
|
||||
)
|
||||
}
|
||||
this.rememberPaymentHashes(data)
|
||||
return data
|
||||
},
|
||||
notify(message) {
|
||||
const level = ['positive', 'negative', 'warning', 'info'].includes(
|
||||
message.level
|
||||
)
|
||||
? message.level
|
||||
: 'info'
|
||||
if (window.Quasar?.Notify) {
|
||||
window.Quasar.Notify.create({
|
||||
color: level,
|
||||
message: String(message.message || '')
|
||||
})
|
||||
}
|
||||
},
|
||||
async scanQrCode() {
|
||||
if (!this.hasBridgePermission('ui.camera.scan_qr')) {
|
||||
throw new Error('Extension is missing scanner permission.')
|
||||
}
|
||||
if (!this.g) {
|
||||
throw new Error('LNbits scanner is not available.')
|
||||
}
|
||||
if (this.g.scanner) {
|
||||
throw new Error('A scanner is already active.')
|
||||
}
|
||||
await this.requireCameraScanApproval()
|
||||
if (this.g.scanner) {
|
||||
throw new Error('A scanner is already active.')
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let completed = false
|
||||
|
||||
const cleanup = () => {
|
||||
window.clearTimeout(timeout)
|
||||
window.clearInterval(cancelPoll)
|
||||
if (this.g.scanner === onScan) {
|
||||
this.g.scanner = null
|
||||
}
|
||||
}
|
||||
|
||||
const complete = callback => value => {
|
||||
if (completed) return
|
||||
completed = true
|
||||
cleanup()
|
||||
callback(value)
|
||||
}
|
||||
|
||||
const onScan = value => {
|
||||
complete(resolve)({value: String(value || '')})
|
||||
}
|
||||
|
||||
const timeout = window.setTimeout(() => {
|
||||
complete(reject)(new Error('QR scan timed out.'))
|
||||
}, 120000)
|
||||
|
||||
const cancelPoll = window.setInterval(() => {
|
||||
if (!completed && this.g.scanner !== onScan) {
|
||||
complete(reject)(new Error('QR scan cancelled.'))
|
||||
}
|
||||
}, 250)
|
||||
|
||||
this.g.scanner = onScan
|
||||
})
|
||||
},
|
||||
requireCameraScanApproval() {
|
||||
if (this.isCameraScanRemembered()) return Promise.resolve()
|
||||
if (this.cameraPrompt.show) {
|
||||
return Promise.reject(
|
||||
new Error('Camera access prompt is already open.')
|
||||
)
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.cameraPrompt = {
|
||||
extensionName:
|
||||
this.extensionName || this.bridge.extensionId || 'This extension',
|
||||
reject,
|
||||
resolve,
|
||||
show: true
|
||||
}
|
||||
})
|
||||
},
|
||||
isCameraScanRemembered() {
|
||||
try {
|
||||
return (
|
||||
this.$q.localStorage.getItem(this.cameraPromptStorageKey()) ===
|
||||
'allow'
|
||||
)
|
||||
} catch (_error) {
|
||||
return false
|
||||
}
|
||||
},
|
||||
rememberCameraScanApproval() {
|
||||
try {
|
||||
this.$q.localStorage.set(this.cameraPromptStorageKey(), 'allow')
|
||||
} catch (_error) {}
|
||||
},
|
||||
resolveCameraPrompt(decision) {
|
||||
const resolve = this.cameraPrompt.resolve
|
||||
const reject = this.cameraPrompt.reject
|
||||
this.cameraPrompt = this.emptyCameraPrompt()
|
||||
|
||||
if (decision === 'allow_remember') {
|
||||
this.rememberCameraScanApproval()
|
||||
resolve?.()
|
||||
return
|
||||
}
|
||||
if (decision === 'allow') {
|
||||
resolve?.()
|
||||
return
|
||||
}
|
||||
reject?.(new Error('Camera scan denied by user.'))
|
||||
},
|
||||
rejectCameraPrompt(message) {
|
||||
const reject = this.cameraPrompt.reject
|
||||
this.cameraPrompt = this.emptyCameraPrompt()
|
||||
reject?.(new Error(message))
|
||||
},
|
||||
rememberPaymentHashes(value) {
|
||||
if (!value || typeof value !== 'object') return
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(item => this.rememberPaymentHashes(item))
|
||||
return
|
||||
}
|
||||
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
if (
|
||||
['paymentHash', 'payment_hash'].includes(key) &&
|
||||
this.isPaymentHash(item)
|
||||
) {
|
||||
this.allowedPaymentHashes.add(item)
|
||||
}
|
||||
this.rememberPaymentHashes(item)
|
||||
}
|
||||
},
|
||||
isPaymentHash(value) {
|
||||
return typeof value === 'string' && /^[a-f0-9]{64}$/i.test(value)
|
||||
},
|
||||
websocketUrl(path) {
|
||||
const url = new URL(window.location.href)
|
||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
url.pathname = path
|
||||
url.search = ''
|
||||
url.hash = ''
|
||||
return url.toString()
|
||||
},
|
||||
sendBridgeEvent(message) {
|
||||
if (!this.bridgePort) return
|
||||
this.bridgePort.postMessage({
|
||||
type: 'lnbits-extension:event',
|
||||
...message
|
||||
})
|
||||
},
|
||||
closePaymentSubscription(subscriptionId) {
|
||||
const subscription = this.paymentSubscriptions.get(subscriptionId)
|
||||
if (!subscription) return
|
||||
this.paymentSubscriptions.delete(subscriptionId)
|
||||
try {
|
||||
subscription.socket.close()
|
||||
} catch (_error) {}
|
||||
},
|
||||
closePaymentSubscriptions() {
|
||||
for (const subscriptionId of Array.from(
|
||||
this.paymentSubscriptions.keys()
|
||||
)) {
|
||||
this.closePaymentSubscription(subscriptionId)
|
||||
}
|
||||
},
|
||||
closeBridgePort() {
|
||||
this.closePaymentSubscriptions()
|
||||
this.bridgePort?.close()
|
||||
this.bridgePort = null
|
||||
},
|
||||
subscribePayment(message) {
|
||||
const subscriptionId = String(message.subscriptionId || '')
|
||||
const paymentHash = String(message.paymentHash || '')
|
||||
|
||||
if (!subscriptionId || !this.isPaymentHash(paymentHash)) {
|
||||
throw new Error('Invalid payment subscription.')
|
||||
}
|
||||
if (!this.allowedPaymentHashes.has(paymentHash)) {
|
||||
throw new Error('Payment subscription is not allowed.')
|
||||
}
|
||||
|
||||
this.closePaymentSubscription(subscriptionId)
|
||||
|
||||
const socket = new WebSocket(
|
||||
this.websocketUrl(`/api/v1/ws/${encodeURIComponent(paymentHash)}`)
|
||||
)
|
||||
this.paymentSubscriptions.set(subscriptionId, {paymentHash, socket})
|
||||
|
||||
socket.addEventListener('message', event => {
|
||||
let data = event.data
|
||||
try {
|
||||
data = JSON.parse(event.data)
|
||||
} catch (_error) {}
|
||||
|
||||
this.sendBridgeEvent({
|
||||
event: 'payment.update',
|
||||
subscriptionId,
|
||||
paymentHash,
|
||||
data
|
||||
})
|
||||
|
||||
if (
|
||||
data &&
|
||||
typeof data === 'object' &&
|
||||
(data.pending === false ||
|
||||
['success', 'settled', 'paid'].includes(String(data.status || '')))
|
||||
) {
|
||||
this.sendBridgeEvent({
|
||||
event: 'payment.settled',
|
||||
subscriptionId,
|
||||
paymentHash,
|
||||
data
|
||||
})
|
||||
this.closePaymentSubscription(subscriptionId)
|
||||
}
|
||||
})
|
||||
socket.addEventListener('error', () => {
|
||||
this.sendBridgeEvent({
|
||||
event: 'payment.error',
|
||||
subscriptionId,
|
||||
paymentHash
|
||||
})
|
||||
this.closePaymentSubscription(subscriptionId)
|
||||
})
|
||||
socket.addEventListener('close', () => {
|
||||
this.paymentSubscriptions.delete(subscriptionId)
|
||||
})
|
||||
},
|
||||
async handleBridgeRequest(message, reply) {
|
||||
if (!message || message.type !== 'lnbits-extension:request') return
|
||||
|
||||
try {
|
||||
if (message.action === 'context') {
|
||||
this.sendResponse(reply, message.id, {
|
||||
ok: true,
|
||||
data: this.plainBridgeContext()
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (message.action === 'api') {
|
||||
this.sendResponse(reply, message.id, {
|
||||
ok: true,
|
||||
data: await this.callApi(message)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (message.action === 'ui.notify') {
|
||||
this.notify(message)
|
||||
this.sendResponse(reply, message.id, {
|
||||
ok: true,
|
||||
data: {ok: true}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (message.action === 'ui.scan_qr') {
|
||||
this.sendResponse(reply, message.id, {
|
||||
ok: true,
|
||||
data: await this.scanQrCode()
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (message.action === 'payment.subscribe') {
|
||||
this.subscribePayment(message)
|
||||
this.sendResponse(reply, message.id, {
|
||||
ok: true,
|
||||
data: {ok: true}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (message.action === 'payment.unsubscribe') {
|
||||
this.closePaymentSubscription(String(message.subscriptionId || ''))
|
||||
this.sendResponse(reply, message.id, {
|
||||
ok: true,
|
||||
data: {ok: true}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
throw new Error('Unknown extension bridge action.')
|
||||
} catch (error) {
|
||||
this.sendResponse(reply, message.id, {
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
}
|
||||
},
|
||||
onWindowMessage(event) {
|
||||
if (event.source !== this.extensionFrameWindow()) return
|
||||
const message = event.data
|
||||
if (!message || message.type !== 'lnbits-extension:connect') return
|
||||
|
||||
const port = event.ports?.[0]
|
||||
if (!port) return
|
||||
|
||||
this.closeBridgePort()
|
||||
this.bridgePort = port
|
||||
this.bridgePort.addEventListener('message', portEvent => {
|
||||
this.handleBridgeRequest(portEvent.data, response => {
|
||||
port.postMessage(response)
|
||||
})
|
||||
})
|
||||
this.bridgePort.start()
|
||||
this.bridgePort.postMessage({
|
||||
type: 'lnbits-extension:connected',
|
||||
id: message.id
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,11 +58,15 @@ body.bg-image {
|
||||
}
|
||||
// transparent background for specific elements
|
||||
body.body--dark {
|
||||
.q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark),
|
||||
.q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark) {
|
||||
--q-dark: #{color.adjust(#1d1d1d, $alpha: -0.7)};
|
||||
background-color: var(--q-dark);
|
||||
}
|
||||
|
||||
.q-header,
|
||||
.q-drawer {
|
||||
--q-dark: #{color.adjust(#1d1d1d, $alpha: -0.7)};
|
||||
background-color: var(--q-dark);
|
||||
backdrop-filter: blur(6px) brightness(0.8);
|
||||
backdrop-filter: brightness(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,13 +61,13 @@ body.rounded-ui {
|
||||
|
||||
body.card-shadow {
|
||||
.q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark) {
|
||||
filter: drop-shadow(0 10px 24px rgba(0, 0, 0, 0.18));
|
||||
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
}
|
||||
|
||||
body.card-shadow.body--dark {
|
||||
.q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark) {
|
||||
filter: drop-shadow(0 12px 28px rgba(0, 0, 0, 0.45));
|
||||
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
@@ -96,6 +98,7 @@
|
||||
"js/components/extension-settings.js",
|
||||
"js/components/data-fields.js",
|
||||
"js/components.js",
|
||||
"js/wasm-extension-component.js",
|
||||
"js/init-app.js"
|
||||
],
|
||||
"css": ["vendor/quasar.css", "css/base.css"]
|
||||
|
||||
@@ -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
-122
@@ -1,148 +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 update_invoice_callback
|
||||
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
|
||||
|
||||
|
||||
def unregister_invoice_listener(name: str) -> None:
|
||||
if name in invoice_listeners:
|
||||
invoice_listeners.pop(name, None)
|
||||
|
||||
|
||||
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 update_invoice_callback(checking_id)
|
||||
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_callback(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()
|
||||
@@ -152,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)
|
||||
|
||||
@@ -16,6 +16,18 @@
|
||||
src: url("{{ static_url_for('static', 'fonts/material-icons-v50.woff2') }}")
|
||||
format('woff2');
|
||||
}
|
||||
|
||||
.wasm-extension-page,
|
||||
.wasm-extension-frame {
|
||||
height: calc(100vh - 56px);
|
||||
min-height: calc(100vh - 56px);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.wasm-extension-frame {
|
||||
border: 0;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
<title>{% block title %}{{ SITE_TITLE }}{% endblock %}</title>
|
||||
<meta charset="utf-8" />
|
||||
@@ -44,12 +56,14 @@
|
||||
<lnbits-drawer v-if="g.user && !g.isPublicPage"></lnbits-drawer>
|
||||
{% block page_container %}
|
||||
<q-page-container>
|
||||
<q-page class="q-px-md q-py-lg" :class="{'q-px-lg': $q.screen.gt.xs}">
|
||||
<q-page
|
||||
:class="$route.path.startsWith('/ext/') ? 'q-pa-none' : ['q-px-md', 'q-py-lg', {'q-px-lg': $q.screen.gt.xs}]"
|
||||
>
|
||||
<lnbits-wallet-new
|
||||
v-if="g.user && !g.isPublicPage"
|
||||
v-if="g.user && !g.isPublicPage && !$route.path.startsWith('/ext/')"
|
||||
></lnbits-wallet-new>
|
||||
<lnbits-header-wallets
|
||||
v-if="g.user && !g.isPublicPage"
|
||||
v-if="g.user && !g.isPublicPage && !$route.path.startsWith('/ext/')"
|
||||
></lnbits-header-wallets>
|
||||
<!-- block page content from static extensions -->
|
||||
<div
|
||||
|
||||
@@ -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
|
||||
>
|
||||
—
|
||||
<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>
|
||||
@@ -18,13 +18,18 @@
|
||||
<q-item
|
||||
v-for="extension in userExtensions"
|
||||
clickable
|
||||
:active="$route.path.startsWith('/' + extension.code)"
|
||||
:active="extensionActive(extension)"
|
||||
tag="a"
|
||||
:to="'/' + extension.code + '/'"
|
||||
:to="extensionUrl(extension)"
|
||||
>
|
||||
<q-item-section side>
|
||||
<q-avatar size="md">
|
||||
<q-img :src="extension.tile" style="max-width: 20px"></q-img>
|
||||
<img
|
||||
v-if="extension.tile"
|
||||
:src="extension.tile"
|
||||
style="width: 20px; height: 20px; object-fit: contain"
|
||||
/>
|
||||
<q-icon v-else name="extension" size="20px"></q-icon>
|
||||
</q-avatar>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
@@ -32,10 +37,7 @@
|
||||
><span v-text="extension.name"></span>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section
|
||||
side
|
||||
v-show="$route.path.startsWith('/' + extension.code)"
|
||||
>
|
||||
<q-item-section side v-show="extensionActive(extension)">
|
||||
<q-icon name="chevron_right" color="grey-5" size="md"></q-icon>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -199,6 +199,7 @@
|
||||
>
|
||||
<q-tab-panel name="funding">
|
||||
<lnbits-admin-funding
|
||||
:active="tab === 'funding'"
|
||||
:is-super-user="isSuperUser"
|
||||
:settings="settings"
|
||||
:form-data="formData"
|
||||
|
||||
@@ -7,7 +7,29 @@
|
||||
<q-tabs v-model="tab" active-color="primary" align="left">
|
||||
<q-tab name="installed" :label="$t('installed')"></q-tab>
|
||||
<q-tab name="all" :label="$t('all')"></q-tab>
|
||||
<q-tab name="featured" :label="$t('featured')"></q-tab>
|
||||
<q-tab
|
||||
v-show="$q.screen.gt.xs"
|
||||
name="featured"
|
||||
:label="$t('featured')"
|
||||
></q-tab>
|
||||
<q-btn-dropdown auto-close stretch flat :label="$t('categories')">
|
||||
<q-list>
|
||||
<q-item
|
||||
clickable
|
||||
v-close-popup
|
||||
v-for="category in categories"
|
||||
@click="tab = category"
|
||||
:key="category"
|
||||
>
|
||||
<q-item-section>
|
||||
<q-item-label
|
||||
class="text-capitalize"
|
||||
v-text="category"
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-btn-dropdown>
|
||||
<i
|
||||
v-if="!g.user.admin && tab != 'installed'"
|
||||
v-text="$t('only_admins_can_install')"
|
||||
@@ -291,7 +313,8 @@
|
||||
"
|
||||
flat
|
||||
color="primary"
|
||||
@click="openExtension(extension)"
|
||||
type="a"
|
||||
:href="extensionOpenUrl(extension)"
|
||||
:label="$t('open')"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
@@ -328,18 +351,6 @@
|
||||
<span v-text="$t('enable_extension_details')">
|
||||
</span> </q-tooltip
|
||||
></q-btn>
|
||||
<q-btn
|
||||
v-if="
|
||||
extension.isInstalled &&
|
||||
extension.isActive &&
|
||||
!g.user.extensions.includes(extension.id) &&
|
||||
extension.extensionType === 'wasm'
|
||||
"
|
||||
flat
|
||||
color="grey-5"
|
||||
@click="openPermissionsForExtension(extension)"
|
||||
label="Permissions"
|
||||
></q-btn>
|
||||
|
||||
<q-btn
|
||||
@click="showManageExtension(extension)"
|
||||
@@ -444,8 +455,49 @@
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="showManageExtensionDialog" position="top">
|
||||
<q-card v-if="selectedRelease" class="q-pa-lg lnbits__dialog-card">
|
||||
<q-dialog
|
||||
v-model="showManageExtensionDialog"
|
||||
position="top"
|
||||
@hide="onManageExtensionDialogHide"
|
||||
>
|
||||
<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>
|
||||
<q-banner
|
||||
v-if="permissionGrantHasHighRisk()"
|
||||
dense
|
||||
class="bg-red-1 text-red-10 q-mt-md"
|
||||
>
|
||||
<template v-slot:avatar>
|
||||
<q-icon name="warning" color="negative"></q-icon>
|
||||
</template>
|
||||
<span v-text="$t('extension_permissions_high_risk_warning')"></span>
|
||||
</q-banner>
|
||||
</q-card-section>
|
||||
|
||||
<lnbits-extension-permissions
|
||||
class="q-mt-md"
|
||||
:permissions="permissionGrant.permissions"
|
||||
:extensions="extensions"
|
||||
></lnbits-extension-permissions>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
flat
|
||||
color="grey"
|
||||
v-text="$t('cancel')"
|
||||
@click="cancelExtensionPermissions"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
color="primary"
|
||||
class="q-ml-auto"
|
||||
v-text="$t('extension_permissions_grant_install')"
|
||||
@click="grantExtensionPermissions"
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-card>
|
||||
<q-card v-else-if="selectedRelease" class="q-pa-lg lnbits__dialog-card">
|
||||
<q-card-section>
|
||||
<div v-if="selectedRelease.paymentRequest">
|
||||
<lnbits-qrcode
|
||||
@@ -932,91 +984,6 @@
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="permissionsDialog.show" position="top">
|
||||
<q-card class="q-pa-md" style="min-width: 360px; max-width: 90vw">
|
||||
<q-card-section>
|
||||
<div class="text-h6">Permissions required</div>
|
||||
<div class="text-caption text-grey">This extension can:</div>
|
||||
<q-list v-if="permissionsDialog.extension">
|
||||
<q-item
|
||||
v-for="perm in permissionsDialog.extension.permissions"
|
||||
:key="perm.id || perm"
|
||||
clickable
|
||||
>
|
||||
<q-item-section>
|
||||
<q-item-label v-text="perm.label || perm"></q-item-label>
|
||||
<q-item-label
|
||||
caption
|
||||
v-if="perm.description"
|
||||
v-text="perm.description"
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-checkbox
|
||||
v-model="permissionsDialog.checked"
|
||||
:val="perm.id || perm"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
<div
|
||||
v-if="
|
||||
permissionsDialog.tagOptions && permissionsDialog.tagOptions.length
|
||||
"
|
||||
class="q-mt-md"
|
||||
>
|
||||
<div class="text-caption text-grey">
|
||||
Allow this extension to listen for payment tags:
|
||||
</div>
|
||||
<q-list>
|
||||
<q-item
|
||||
v-for="tag in permissionsDialog.tagOptions"
|
||||
:key="tag"
|
||||
clickable
|
||||
>
|
||||
<q-item-section>
|
||||
<q-item-label v-text="tag"></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-checkbox v-model="permissionsDialog.tags" :val="tag" />
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</div>
|
||||
<div
|
||||
v-if="permissionsDialog.missing && permissionsDialog.missing.length"
|
||||
class="q-mt-md text-negative"
|
||||
>
|
||||
<div class="text-caption">
|
||||
Missing API endpoints required by this extension:
|
||||
</div>
|
||||
<q-chip
|
||||
v-for="perm in permissionsDialog.missing"
|
||||
:key="perm"
|
||||
:label="perm"
|
||||
color="red-2"
|
||||
text-color="black"
|
||||
class="q-mr-xs q-mt-xs"
|
||||
/>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-card-actions align="right">
|
||||
<q-btn
|
||||
flat
|
||||
color="grey"
|
||||
v-text="$t('cancel')"
|
||||
@click="cancelPermissionsDialog"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
color="primary"
|
||||
:disable="!permissionsAllChecked || permissionsHasMissingEndpoints"
|
||||
label="Save"
|
||||
@click="confirmPermissionsDialog"
|
||||
></q-btn>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="showExtensionDetailsDialog" position="top">
|
||||
<q-card
|
||||
v-if="selectedExtensionDetails"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{% extends "base.html" %}
|
||||
+6
-17
@@ -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()
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
"""
|
||||
Electrum protocol client (https://github.com/spesmilo/electrum-protocol).
|
||||
|
||||
JSON-RPC 2.0 over TCP / SSL (newline-delimited), with request/response
|
||||
correlation, subscription dispatch, and automatic keepalive pings.
|
||||
server.version is sent automatically on connect as required by the spec.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import itertools
|
||||
import json
|
||||
import ssl
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ElectrumError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def scripthash_from_scriptpubkey(scriptpubkey: bytes) -> str:
|
||||
"""Electrum script hash: SHA-256 of scriptPubKey, byte-reversed to hex."""
|
||||
return hashlib.sha256(scriptpubkey).digest()[::-1].hex()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Response models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class Balance(BaseModel):
|
||||
confirmed: int
|
||||
unconfirmed: int
|
||||
|
||||
|
||||
class HistoryEntry(BaseModel):
|
||||
tx_hash: str
|
||||
height: int
|
||||
fee: int | None = None # present for mempool entries
|
||||
|
||||
|
||||
class MempoolEntry(BaseModel):
|
||||
tx_hash: str
|
||||
height: int
|
||||
fee: int
|
||||
|
||||
|
||||
class UTXO(BaseModel):
|
||||
tx_hash: str
|
||||
tx_pos: int
|
||||
height: int
|
||||
value: int # satoshis
|
||||
|
||||
|
||||
class BlockHeader(BaseModel):
|
||||
height: int
|
||||
hex: str
|
||||
|
||||
|
||||
class BlockHeaderProof(BaseModel):
|
||||
"""Returned by get_block_header when cp_height > 0."""
|
||||
|
||||
branch: list[str]
|
||||
header: str
|
||||
root: str
|
||||
|
||||
|
||||
class BlockHeaders(BaseModel):
|
||||
count: int
|
||||
hex: str
|
||||
max: int
|
||||
|
||||
|
||||
class MerkleProof(BaseModel):
|
||||
block_height: int
|
||||
merkle: list[str]
|
||||
pos: int
|
||||
|
||||
|
||||
class TxIdWithMerkle(BaseModel):
|
||||
tx_hash: str
|
||||
merkle: list[str]
|
||||
|
||||
|
||||
class FeeHistogramEntry(BaseModel):
|
||||
fee_rate: float
|
||||
vsize: float
|
||||
|
||||
|
||||
class ServerFeatures(BaseModel):
|
||||
class Config:
|
||||
extra = "allow"
|
||||
|
||||
genesis_hash: str = ""
|
||||
protocol_max: str = ""
|
||||
protocol_min: str = ""
|
||||
server_version: str = ""
|
||||
pruning: int | None = None
|
||||
hash_function: str = "sha256d"
|
||||
hosts: dict[str, Any] = {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Client
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ElectrumClient:
|
||||
"""
|
||||
Async Electrum protocol client over plain TCP or SSL.
|
||||
|
||||
Messages are newline-terminated JSON-RPC 2.0, as required by the spec.
|
||||
Handles request/response correlation by id, routes push notifications to
|
||||
registered callbacks, and sends periodic pings to keep the connection alive.
|
||||
|
||||
Usage::
|
||||
|
||||
# Plain TCP
|
||||
async with ElectrumClient("tcp://blockstream.info:110") as client:
|
||||
height = await client.get_height()
|
||||
|
||||
# SSL
|
||||
async with ElectrumClient("ssl://electrum.blockstream.info:50002") as c:
|
||||
height = await c.get_height()
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: str,
|
||||
client_name: str = "lnbits",
|
||||
protocol_version: str = "1.4",
|
||||
ping_interval: float = 60.0,
|
||||
) -> None:
|
||||
parsed = urlparse(url)
|
||||
self.host = parsed.hostname or ""
|
||||
self.port = parsed.port or (
|
||||
50002 if parsed.scheme in ("ssl", "https") else 50001
|
||||
)
|
||||
self.use_ssl = parsed.scheme in ("ssl", "https")
|
||||
self.client_name = client_name
|
||||
self.protocol_version = protocol_version
|
||||
self.ping_interval = ping_interval
|
||||
self._counter = itertools.count(1)
|
||||
self._pending: dict[int, asyncio.Future[Any]] = {}
|
||||
self._subscriptions: dict[str, list[Callable[[list[Any]], Any]]] = {}
|
||||
self._recv_task: asyncio.Task[None] | None = None
|
||||
self._ping_task: asyncio.Task[None] | None = None
|
||||
self._reader: asyncio.StreamReader | None = None
|
||||
self._writer: asyncio.StreamWriter | None = None
|
||||
self.server_version: str = ""
|
||||
self.negotiated_protocol: str = ""
|
||||
|
||||
async def connect(self, timeout: float = 10.0) -> None:
|
||||
ssl_ctx: ssl.SSLContext | None = None
|
||||
if self.use_ssl:
|
||||
ssl_ctx = ssl.create_default_context()
|
||||
self._reader, self._writer = await asyncio.wait_for(
|
||||
asyncio.open_connection(
|
||||
self.host, self.port, ssl=ssl_ctx, limit=4 * 1024 * 1024
|
||||
),
|
||||
timeout=timeout,
|
||||
)
|
||||
self._recv_task = asyncio.create_task(self._recv_loop())
|
||||
result = await self._call(
|
||||
"server.version", [self.client_name, self.protocol_version], timeout=timeout
|
||||
)
|
||||
self.server_version, self.negotiated_protocol = result[0], result[1]
|
||||
logger.debug(
|
||||
f"Electrum connected: server={self.server_version}"
|
||||
f" protocol={self.negotiated_protocol}"
|
||||
)
|
||||
if self.ping_interval > 0:
|
||||
self._ping_task = asyncio.create_task(self._ping_loop())
|
||||
|
||||
async def close(self) -> None:
|
||||
for task in (self._ping_task, self._recv_task):
|
||||
if task:
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception:
|
||||
logger.debug("Electrum: error while cancelling task")
|
||||
self._ping_task = None
|
||||
self._recv_task = None
|
||||
if self._writer:
|
||||
self._writer.close()
|
||||
try:
|
||||
await asyncio.wait_for(self._writer.wait_closed(), timeout=5.0)
|
||||
except Exception:
|
||||
logger.debug("Electrum: error while closing writer")
|
||||
self._reader = None
|
||||
self._writer = None
|
||||
|
||||
async def __aenter__(self) -> "ElectrumClient":
|
||||
try:
|
||||
await self.connect()
|
||||
except BaseException:
|
||||
await self.close()
|
||||
raise
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_: Any) -> None:
|
||||
await self.close()
|
||||
|
||||
# ---- internal plumbing ----
|
||||
|
||||
async def _call(
|
||||
self,
|
||||
method: str,
|
||||
params: list[Any] | dict[str, Any] | None = None,
|
||||
timeout: float = 30.0,
|
||||
) -> Any:
|
||||
if not self._writer:
|
||||
raise ElectrumError("Not connected")
|
||||
req_id = next(self._counter)
|
||||
fut: asyncio.Future[Any] = asyncio.get_running_loop().create_future()
|
||||
self._pending[req_id] = fut
|
||||
self._writer.write(
|
||||
json.dumps(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": req_id,
|
||||
"method": method,
|
||||
"params": params if params is not None else [],
|
||||
}
|
||||
).encode()
|
||||
+ b"\n"
|
||||
)
|
||||
await self._writer.drain()
|
||||
try:
|
||||
return await asyncio.wait_for(asyncio.shield(fut), timeout=timeout)
|
||||
except asyncio.TimeoutError as exc:
|
||||
self._pending.pop(req_id, None)
|
||||
raise ElectrumError(f"Timeout waiting for response to {method!r}") from exc
|
||||
|
||||
def _dispatch(self, msg: dict[str, Any]) -> None:
|
||||
msg_id = msg.get("id")
|
||||
if msg_id is not None:
|
||||
fut = self._pending.pop(msg_id, None)
|
||||
if fut and not fut.done():
|
||||
err = msg.get("error")
|
||||
if err:
|
||||
fut.set_exception(ElectrumError(err))
|
||||
else:
|
||||
fut.set_result(msg.get("result"))
|
||||
else:
|
||||
method = msg.get("method", "")
|
||||
params = msg.get("params", [])
|
||||
for cb in list(self._subscriptions.get(method, [])):
|
||||
try:
|
||||
result = cb(params)
|
||||
if asyncio.iscoroutine(result):
|
||||
self._bg_tasks.add(asyncio.create_task(result))
|
||||
except Exception:
|
||||
logger.exception(f"Electrum: callback error for {method!r}")
|
||||
|
||||
async def _recv_loop(self) -> None:
|
||||
assert self._reader
|
||||
self._bg_tasks: set[asyncio.Task[Any]] = set()
|
||||
buf = b""
|
||||
try:
|
||||
while True:
|
||||
chunk = await self._reader.read(65536)
|
||||
if not chunk:
|
||||
break
|
||||
buf += chunk
|
||||
while b"\n" in buf:
|
||||
line, buf = buf.split(b"\n", 1)
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
msg: dict[str, Any] = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(f"Electrum: invalid JSON: {line!r}")
|
||||
continue
|
||||
self._dispatch(msg)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception:
|
||||
logger.exception("Electrum: recv loop error")
|
||||
finally:
|
||||
for fut in self._pending.values():
|
||||
if not fut.done():
|
||||
fut.set_exception(ElectrumError("Connection closed"))
|
||||
self._pending.clear()
|
||||
|
||||
async def _ping_loop(self) -> None:
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(self.ping_interval)
|
||||
await self._call("server.ping")
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception:
|
||||
logger.exception("Electrum: ping loop error")
|
||||
|
||||
# ---- subscription management ----
|
||||
|
||||
def on(self, method: str, callback: Callable[[list[Any]], Any]) -> None:
|
||||
"""Register a notification callback for a subscription method."""
|
||||
self._subscriptions.setdefault(method, []).append(callback)
|
||||
|
||||
def off(self, method: str, callback: Callable[[list[Any]], Any]) -> None:
|
||||
"""Remove a previously registered notification callback."""
|
||||
cbs = self._subscriptions.get(method)
|
||||
if cbs and callback in cbs:
|
||||
cbs.remove(callback)
|
||||
|
||||
# ---- server methods ----
|
||||
|
||||
async def server_ping(self) -> None:
|
||||
await self._call("server.ping")
|
||||
|
||||
async def server_banner(self) -> str:
|
||||
return await self._call("server.banner")
|
||||
|
||||
async def server_features(self) -> ServerFeatures:
|
||||
data = await self._call("server.features")
|
||||
return ServerFeatures.parse_obj(data)
|
||||
|
||||
async def server_peers(self) -> list[Any]:
|
||||
return await self._call("server.peers.subscribe")
|
||||
|
||||
# ---- scripthash methods ----
|
||||
|
||||
async def get_balance(self, scripthash: str) -> Balance:
|
||||
data = await self._call("blockchain.scripthash.get_balance", [scripthash])
|
||||
return Balance.parse_obj(data)
|
||||
|
||||
async def get_history(self, scripthash: str) -> list[HistoryEntry]:
|
||||
data = await self._call("blockchain.scripthash.get_history", [scripthash])
|
||||
return [HistoryEntry.parse_obj(e) for e in data]
|
||||
|
||||
async def get_mempool(self, scripthash: str) -> list[MempoolEntry]:
|
||||
data = await self._call("blockchain.scripthash.get_mempool", [scripthash])
|
||||
return [MempoolEntry.parse_obj(e) for e in data]
|
||||
|
||||
async def listunspent(self, scripthash: str) -> list[UTXO]:
|
||||
data = await self._call("blockchain.scripthash.listunspent", [scripthash])
|
||||
return [UTXO.parse_obj(e) for e in data]
|
||||
|
||||
async def subscribe_scripthash(
|
||||
self,
|
||||
scripthash: str,
|
||||
callback: Callable[[list[Any]], Any] | None = None,
|
||||
) -> str | None:
|
||||
"""Subscribe to status changes; returns current status hash or None."""
|
||||
if callback:
|
||||
self.on("blockchain.scripthash.subscribe", callback)
|
||||
return await self._call("blockchain.scripthash.subscribe", [scripthash])
|
||||
|
||||
async def unsubscribe_scripthash(
|
||||
self,
|
||||
scripthash: str,
|
||||
callback: Callable[[list[Any]], Any] | None = None,
|
||||
) -> bool:
|
||||
if callback:
|
||||
self.off("blockchain.scripthash.subscribe", callback)
|
||||
return await self._call("blockchain.scripthash.unsubscribe", [scripthash])
|
||||
|
||||
async def subscribe_headers(
|
||||
self,
|
||||
callback: Callable[[list[Any]], Any] | None = None,
|
||||
) -> BlockHeader:
|
||||
"""Subscribe to new block headers; returns current tip."""
|
||||
if callback:
|
||||
self.on("blockchain.headers.subscribe", callback)
|
||||
data = await self._call("blockchain.headers.subscribe")
|
||||
return BlockHeader.parse_obj(data)
|
||||
|
||||
# ---- transaction methods ----
|
||||
|
||||
async def broadcast(self, raw_tx: str) -> str:
|
||||
"""Broadcast a raw transaction hex; returns txid on success."""
|
||||
return await self._call("blockchain.transaction.broadcast", [raw_tx])
|
||||
|
||||
async def get_transaction(
|
||||
self, txid: str, verbose: bool = False
|
||||
) -> str | dict[str, Any]:
|
||||
return await self._call("blockchain.transaction.get", [txid, verbose])
|
||||
|
||||
async def get_merkle(self, txid: str, height: int) -> MerkleProof:
|
||||
data = await self._call("blockchain.transaction.get_merkle", [txid, height])
|
||||
return MerkleProof.parse_obj(data)
|
||||
|
||||
async def get_tx_id_from_pos(
|
||||
self, height: int, tx_pos: int, merkle: bool = False
|
||||
) -> str | TxIdWithMerkle:
|
||||
data = await self._call(
|
||||
"blockchain.transaction.id_from_pos", [height, tx_pos, merkle]
|
||||
)
|
||||
if isinstance(data, dict):
|
||||
return TxIdWithMerkle.parse_obj(data)
|
||||
return data
|
||||
|
||||
# ---- block methods ----
|
||||
|
||||
async def get_tip(self) -> BlockHeader:
|
||||
"""Returns current chain tip."""
|
||||
data = await self._call("blockchain.headers.subscribe")
|
||||
return BlockHeader.parse_obj(data)
|
||||
|
||||
async def get_height(self) -> int:
|
||||
"""Returns the current best block height."""
|
||||
return (await self.get_tip()).height
|
||||
|
||||
async def get_block_header(
|
||||
self, height: int, cp_height: int = 0
|
||||
) -> str | BlockHeaderProof:
|
||||
data = await self._call("blockchain.block.header", [height, cp_height])
|
||||
if isinstance(data, dict):
|
||||
return BlockHeaderProof.parse_obj(data)
|
||||
return data
|
||||
|
||||
async def get_block_headers(
|
||||
self, start_height: int, count: int, cp_height: int = 0
|
||||
) -> BlockHeaders:
|
||||
data = await self._call(
|
||||
"blockchain.block.headers", [start_height, count, cp_height]
|
||||
)
|
||||
return BlockHeaders.parse_obj(data)
|
||||
|
||||
# ---- fee methods ----
|
||||
|
||||
async def estimate_fee(self, num_blocks: int) -> float:
|
||||
"""Returns estimated fee rate in BTC/kB for confirmation within num_blocks."""
|
||||
return await self._call("blockchain.estimatefee", [num_blocks])
|
||||
|
||||
async def fee_histogram(self) -> list[FeeHistogramEntry]:
|
||||
"""Returns mempool fee histogram as FeeHistogramEntry(fee_rate, vsize) list."""
|
||||
data = await self._call("mempool.get_fee_histogram")
|
||||
return [FeeHistogramEntry(fee_rate=r[0], vsize=r[1]) for r in data]
|
||||
@@ -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.")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -94,15 +94,15 @@ class PaymentStatus(NamedTuple):
|
||||
|
||||
|
||||
class PaymentSuccessStatus(PaymentStatus):
|
||||
paid = True
|
||||
paid = True # type: ignore[reportIncompatibleVariableOverride]
|
||||
|
||||
|
||||
class PaymentFailedStatus(PaymentStatus):
|
||||
paid = False
|
||||
paid = False # type: ignore[reportIncompatibleVariableOverride]
|
||||
|
||||
|
||||
class PaymentPendingStatus(PaymentStatus):
|
||||
paid = None
|
||||
paid = None # type: ignore[reportIncompatibleVariableOverride]
|
||||
|
||||
|
||||
class Wallet(ABC):
|
||||
|
||||
@@ -8,7 +8,7 @@ from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
from websockets import Subprotocol, connect
|
||||
|
||||
from lnbits import bolt11
|
||||
from lnbits import bolt11 as bolt11_lib
|
||||
from lnbits.helpers import normalize_endpoint
|
||||
from lnbits.settings import settings
|
||||
|
||||
@@ -164,15 +164,13 @@ class BlinkWallet(Wallet):
|
||||
ok=False, error_message=f"Unable to connect to {self.endpoint}."
|
||||
)
|
||||
|
||||
async def pay_invoice(
|
||||
self, bolt11_invoice: str, fee_limit_msat: int
|
||||
) -> PaymentResponse:
|
||||
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
||||
# https://dev.blink.sv/api/btc-ln-send
|
||||
# Future: add check fee estimate is < fee_limit_msat before paying invoice
|
||||
|
||||
payment_variables = {
|
||||
"input": {
|
||||
"paymentRequest": bolt11_invoice,
|
||||
"paymentRequest": bolt11,
|
||||
"walletId": self.wallet_id,
|
||||
"memo": "Payment memo",
|
||||
}
|
||||
@@ -190,7 +188,7 @@ class BlinkWallet(Wallet):
|
||||
error_message = errors[0].get("message")
|
||||
return PaymentResponse(ok=False, error_message=error_message)
|
||||
|
||||
checking_id = bolt11.decode(bolt11_invoice).payment_hash
|
||||
checking_id = bolt11_lib.decode(bolt11).payment_hash
|
||||
|
||||
payment_status = await self.get_payment_status(checking_id)
|
||||
fee_msat = payment_status.fee_msat
|
||||
@@ -199,7 +197,7 @@ class BlinkWallet(Wallet):
|
||||
ok=True, checking_id=checking_id, fee_msat=fee_msat, preimage=preimage
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.info(f"Failed to pay invoice {bolt11_invoice}")
|
||||
logger.info(f"Failed to pay invoice {bolt11}")
|
||||
logger.warning(exc)
|
||||
return PaymentResponse(
|
||||
error_message=f"Unable to connect to {self.endpoint}."
|
||||
|
||||
@@ -19,7 +19,7 @@ else:
|
||||
|
||||
from bolt11 import Bolt11Exception
|
||||
from bolt11 import decode as bolt11_decode
|
||||
from breez_sdk import (
|
||||
from breez_sdk import ( # type: ignore[reportMissingImports]
|
||||
BreezEvent,
|
||||
ConnectRequest,
|
||||
EnvironmentType,
|
||||
@@ -39,7 +39,9 @@ else:
|
||||
default_config,
|
||||
mnemonic_to_seed,
|
||||
)
|
||||
from breez_sdk import PaymentStatus as BreezPaymentStatus
|
||||
from breez_sdk import (
|
||||
PaymentStatus as BreezPaymentStatus, # type: ignore[reportMissingImports]
|
||||
)
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.settings import settings
|
||||
|
||||
@@ -18,7 +18,7 @@ else:
|
||||
from pathlib import Path
|
||||
|
||||
from bolt11 import decode as bolt11_decode
|
||||
from breez_sdk_liquid import (
|
||||
from breez_sdk_liquid import ( # type: ignore[reportMissingImports]
|
||||
ConnectRequest,
|
||||
EventListener,
|
||||
GetInfoResponse,
|
||||
|
||||
@@ -103,7 +103,7 @@ class FakeWallet(Wallet):
|
||||
preimage=preimage.hex(),
|
||||
)
|
||||
|
||||
async def pay_invoice(self, bolt11: str, _: int) -> PaymentResponse:
|
||||
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
||||
try:
|
||||
invoice = decode(bolt11)
|
||||
except Bolt11Exception as exc:
|
||||
@@ -130,7 +130,7 @@ class FakeWallet(Wallet):
|
||||
return PaymentPendingStatus()
|
||||
return PaymentFailedStatus()
|
||||
|
||||
async def get_payment_status(self, _: str) -> PaymentStatus:
|
||||
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
||||
return PaymentPendingStatus()
|
||||
|
||||
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
||||
|
||||
@@ -118,7 +118,7 @@ class LndWallet(Wallet):
|
||||
|
||||
cert = open(cert_path, "rb").read()
|
||||
creds = grpc.ssl_channel_credentials(cert)
|
||||
auth_creds = grpc.metadata_call_credentials(self.metadata_callback)
|
||||
auth_creds = grpc.metadata_call_credentials(self.metadata_callback) # type: ignore[reportArgumentType]
|
||||
composite_creds = grpc.composite_channel_credentials(creds, auth_creds)
|
||||
channel = grpc.aio.secure_channel(
|
||||
f"{self.endpoint}:{self.port}", composite_creds
|
||||
@@ -192,6 +192,8 @@ class LndWallet(Wallet):
|
||||
fee_limit_msat=fee_limit_msat,
|
||||
timeout_seconds=30,
|
||||
no_inflight_updates=True,
|
||||
max_parts=16,
|
||||
time_pref=0.9,
|
||||
)
|
||||
try:
|
||||
res: Payment = await self.router_rpc.SendPaymentV2(req).read()
|
||||
|
||||
+41
-40
@@ -3,6 +3,7 @@ import base64
|
||||
import hashlib
|
||||
import json
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
@@ -123,8 +124,8 @@ class LndRestWallet(Wallet):
|
||||
hashlib.sha256(unhashed_description).digest()
|
||||
).decode("ascii")
|
||||
|
||||
preimage, _payment_hash = random_secret_and_hash()
|
||||
_data["r_hash"] = base64.b64encode(bytes.fromhex(_payment_hash)).decode()
|
||||
preimage, payment_hash = random_secret_and_hash()
|
||||
_data["r_hash"] = base64.b64encode(bytes.fromhex(payment_hash)).decode()
|
||||
_data["r_preimage"] = base64.b64encode(bytes.fromhex(preimage)).decode()
|
||||
|
||||
try:
|
||||
@@ -132,34 +133,7 @@ class LndRestWallet(Wallet):
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
|
||||
if len(data) == 0:
|
||||
return InvoiceResponse(ok=False, error_message="no data")
|
||||
|
||||
if "error" in data:
|
||||
return InvoiceResponse(
|
||||
ok=False, error_message=f"""Server error: '{data["error"]}'"""
|
||||
)
|
||||
|
||||
if r.is_error:
|
||||
return InvoiceResponse(
|
||||
ok=False, error_message=f"Server error: '{r.text}'"
|
||||
)
|
||||
|
||||
if "payment_request" not in data or "r_hash" not in data:
|
||||
return InvoiceResponse(
|
||||
ok=False, error_message="Server error: 'missing required fields'"
|
||||
)
|
||||
|
||||
payment_request = data["payment_request"]
|
||||
payment_hash = base64.b64decode(data["r_hash"]).hex()
|
||||
checking_id = payment_hash
|
||||
return InvoiceResponse(
|
||||
ok=True,
|
||||
checking_id=checking_id,
|
||||
payment_request=payment_request,
|
||||
preimage=preimage,
|
||||
)
|
||||
|
||||
return self._parse_create_invoice_response(r, data, preimage)
|
||||
except json.JSONDecodeError:
|
||||
return InvoiceResponse(
|
||||
ok=False, error_message="Server error: 'invalid json response'"
|
||||
@@ -242,12 +216,13 @@ class LndRestWallet(Wallet):
|
||||
logger.warning(f"Error getting invoice status: {e}")
|
||||
return PaymentPendingStatus()
|
||||
|
||||
if r.is_error or data.get("settled") is None:
|
||||
if r.is_error or data.get("state") is None:
|
||||
# this must also work when checking_id is not a hex recognizable by lnd
|
||||
# it will return an error and no "settled" attribute on the object
|
||||
# it will return an error and no "state" attribute on the object
|
||||
logger.warning(f"Error checking invoice from LND REST API: {r.text}")
|
||||
return PaymentPendingStatus()
|
||||
|
||||
if data.get("settled") is True:
|
||||
if data.get("state") == "SETTLED":
|
||||
return PaymentSuccessStatus()
|
||||
|
||||
if data.get("state") == "CANCELED":
|
||||
@@ -264,10 +239,11 @@ class LndRestWallet(Wallet):
|
||||
"ascii"
|
||||
)
|
||||
except ValueError:
|
||||
logger.warning("Invalid checking_id format, must be hex: {checking_id}")
|
||||
return PaymentPendingStatus()
|
||||
|
||||
url = f"/v2/router/track/{checking_id}"
|
||||
async with self.client.stream("GET", url, timeout=None) as r:
|
||||
async with self.client.stream("GET", url, timeout=30) as r:
|
||||
async for json_line in r.aiter_lines():
|
||||
try:
|
||||
line = json.loads(json_line)
|
||||
@@ -298,7 +274,7 @@ class LndRestWallet(Wallet):
|
||||
return PaymentFailedStatus()
|
||||
elif status == "IN_FLIGHT":
|
||||
logger.info(f"LNDRest Payment in flight: {checking_id}")
|
||||
return PaymentPendingStatus()
|
||||
continue
|
||||
|
||||
logger.info(f"LNDRest Payment non-existent: {checking_id}")
|
||||
return PaymentPendingStatus()
|
||||
@@ -311,13 +287,12 @@ class LndRestWallet(Wallet):
|
||||
async for line in r.aiter_lines():
|
||||
try:
|
||||
inv = json.loads(line)["result"]
|
||||
if not inv["settled"]:
|
||||
if not inv.get("state") == "SETTLED":
|
||||
continue
|
||||
payment_hash = base64.b64decode(inv.get("r_hash")).hex()
|
||||
except Exception as exc:
|
||||
logger.debug(exc)
|
||||
continue
|
||||
|
||||
payment_hash = base64.b64decode(inv["r_hash"]).hex()
|
||||
yield payment_hash
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
@@ -363,8 +338,6 @@ class LndRestWallet(Wallet):
|
||||
return InvoiceResponse(ok=False, error_message=str(exc))
|
||||
|
||||
payment_request = data["payment_request"]
|
||||
payment_hash = base64.b64encode(bytes.fromhex(payment_hash)).decode("ascii")
|
||||
|
||||
return InvoiceResponse(
|
||||
ok=True, checking_id=payment_hash, payment_request=payment_request
|
||||
)
|
||||
@@ -399,3 +372,31 @@ class LndRestWallet(Wallet):
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
return InvoiceResponse(ok=False, error_message=str(exc))
|
||||
|
||||
def _parse_create_invoice_response(
|
||||
self, r: Any, data: dict, preimage: str
|
||||
) -> InvoiceResponse:
|
||||
if not data:
|
||||
return InvoiceResponse(ok=False, error_message="no data")
|
||||
if "error" in data:
|
||||
return InvoiceResponse(
|
||||
ok=False, error_message=f"Server error: '{data['error']}'"
|
||||
)
|
||||
if r.is_error:
|
||||
return InvoiceResponse(ok=False, error_message=f"Server error: '{r.text}'")
|
||||
if "payment_request" not in data or "r_hash" not in data:
|
||||
return InvoiceResponse(
|
||||
ok=False, error_message="Server error: 'missing required fields'"
|
||||
)
|
||||
try:
|
||||
payment_hash = base64.b64decode(data["r_hash"]).hex()
|
||||
except Exception:
|
||||
return InvoiceResponse(
|
||||
ok=False, error_message=f"Unable to b64decode to {data['r_hash']}."
|
||||
)
|
||||
return InvoiceResponse(
|
||||
ok=True,
|
||||
checking_id=payment_hash,
|
||||
payment_request=data["payment_request"],
|
||||
preimage=preimage,
|
||||
)
|
||||
|
||||
+921
-138
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user