Compare commits

...
Author SHA1 Message Date
Vlad Stan 54e8dbcd8d chore: make bundle 2026-07-16 15:31:55 +03:00
Vlad Stan b678773728 fix: stricter public reads 2026-07-16 15:29:56 +03:00
Vlad Stan 58ccdb7212 chore: make bundle 2026-07-16 11:36:29 +03:00
Vlad Stan 44f627ffda test: fix 2026-07-16 11:35:04 +03:00
Vlad Stan a25cadb66d feat: partial implementation 2026-07-16 11:35:04 +03:00
Vlad Stan 9f317c5edf feat: append public row 2026-07-16 11:35:02 +03:00
blackcoffeexbtandGitHub 09c6e7239d fix: Infinite invoice listener loop with VoidWallet (#4061) 2026-07-15 21:33:48 +03:00
blackcoffeexbtandGitHub 53a1ae5e53 fix: UX disable "Send" button after clicking when paying LUD16 (#4034) 2026-07-15 17:41:31 +01:00
61ed636df0 feat: Extensions for all (#4021)
Co-authored-by: alan <alan@lnbits.com>
Co-authored-by: Tiago Vasconcelos <talvasconcelos@gmail.com>
2026-07-15 13:50:19 +03:00
43900dd6da feat: add emailConfigured to PublicSettings (#4047)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-07-13 15:28:28 +02:00
Riccardo BalboandGitHub 06c553219a Merge commit from fork
* fix: daily withdrawal limit

* feat: unit tests for cumulative daily withdrawal
2026-07-13 11:59:49 +03:00
fc73d83bd9 feat: optimise Uvicorn Settings for heavy web socket use (#4028)
Co-authored-by: dadofsambonzuki <dadofsambonzuki@users.noreply.github.com>
Co-authored-by: dni <office@dnilabs.com>
2026-07-13 10:28:34 +02:00
Riccardo BalboandGitHub 4cb743d952 Merge commit from fork 2026-07-13 11:25:08 +03:00
dni ⚡andGitHub 3b6e87b060 fix: docker image dont rebuild after restart (#4057) 2026-07-13 11:17:28 +03:00
dni ⚡andGitHub 09e44f18e5 test: fix revolut test timestamp exceeded (#4054) 2026-07-13 10:53:17 +03:00
Vlad StanandGitHub cabb58f8fe chore: make bundle (#4045) 2026-07-08 16:59:28 +03:00
aac04efd0e Merge commit from fork
* fix: don't show unsafe html in lnurlp notifications

* fix: lnurlp success aes decryption

* regenerate bundle

---------

Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-07-08 16:49:43 +03:00
fd9009f760 feat: create TaskManager singleton class (#3775)
Co-authored-by: dadofsambonzuki <dadofsambonzuki@users.noreply.github.com>
2026-07-08 14:07:38 +02:00
dni ⚡andGitHub 6ab413775a chore: update to v1.5.6 (#4044) 2026-07-08 13:54:35 +02:00
7eb9965205 feat: keep funding source on default reset, force seed backup (#4040)
Co-authored-by: alan <alan@lnbits.com>
2026-07-08 13:54:11 +02:00
4634ad5a5a feat: use lnbits price aggregator to save loads of request (#4026)
Co-authored-by: alan <alan@lnbits.com>
2026-07-06 09:21:55 +02:00
dni ⚡andGitHub 648aaa17c5 CI: appimage upload-urls with gh upload command (#4036) 2026-07-03 12:35:14 +01:00
blackcoffeexbtandGitHub 7db5c986b3 NWC funding source: Reduce polling (#4008) 2026-07-02 13:00:49 +01:00
Vlad StanandGitHub 26c31b626d refactor: set extension field for payments (#4031) 2026-07-01 16:55:15 +03:00
Vlad StanandGitHub e39cb20525 chore: version bump 1.5.5 (#4029) 2026-07-01 12:06:55 +03:00
ArcandGitHub 3cea9a90d1 fix: nix install guide (#4032) 2026-06-30 14:08:49 +01:00
ArcandGitHub 3224e2e774 fix: appimage install (#4030) 2026-06-30 15:37:13 +03:00
dni ⚡andGitHub cfc9874517 chore: update to version 1.5.5-rc3 (#4024) 2026-06-24 12:24:41 +02:00
62cd151fd6 feat: add i18n for DynamicComponent (#4018)
Co-authored-by: alan <alan@lnbits.com>
2026-06-23 12:37:54 +02:00
a4d0aa5db8 fix(phoenixd): use descriptionHash when unhashed_description is provided (#3913)
Co-authored-by: Richard Taylor <RT@MacBookPro.lan>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-23 12:16:54 +03:00
dni ⚡andGitHub 77630781d7 fix: failing make bundle ci on forks. (#4019) 2026-06-23 08:15:13 +03:00
Vlad StanandGitHub 59da350cc9 fix: fiat payment status update (#4017) 2026-06-22 16:13:02 +03:00
29e980dd67 fix: non-blocking relay publishes in send_nostr_dm (#3925)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-06-18 15:28:28 +02:00
9d9ce63c82 feat: add llms.txt endpoint for AI agents (#3877)
Co-authored-by: Storm Knight <storm-knight@openclaw.ai>
Co-authored-by: ThomsenDrake <drake@verqor.me>
Co-authored-by: Arc <33088785+arcbtc@users.noreply.github.com>
Co-authored-by: dni  <office@dnilabs.com>
2026-06-18 15:15:01 +02:00
748f458b8b feat: add extension profiles (#3914)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-06-18 15:27:33 +03:00
Ben WeeksandGitHub ce177a73b1 fix: exponential backoff for IN_FLIGHT payment polling (#3918) 2026-06-18 14:47:03 +03:00
2885e71be2 fix: populate unit selector when opening send/pay dialog (#3953)
Co-authored-by: dni  <office@dnilabs.com>
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-06-18 11:50:27 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
4b6f43d274 chore(deps): bump python-multipart from 0.0.29 to 0.0.31 (#4010)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-18 10:37:54 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
86b5cf9421 chore(deps): bump tornado from 6.5.6 to 6.5.7 (#4011)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-18 10:37:25 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
8f5b7d85aa chore(deps): bump aiohttp from 3.13.5 to 3.14.1 (#4012)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-18 10:32:12 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
4d51e63924 chore(deps): bump cryptography from 48.0.0 to 48.0.1 (#4013)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-18 10:30:50 +02:00
Nathan DayandGitHub 0ce2501e1a fix: run blocking SMTP calls in a thread to avoid blocking the event loop (#3988) 2026-06-17 17:59:21 +03:00
f2351145f0 fix: stop updating internal invoices inside the listener unlike external one (#3984)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-06-17 16:47:02 +03:00
2eb7d67b2a feat: use cached rate from exchange_rate_history_setting (#4001)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-06-17 15:16:38 +03:00
dni ⚡andGitHub a82093b7ec feat: electrum util for onchain data (#3999) 2026-06-17 13:16:52 +02:00
Vlad StanandGitHub ee595eede1 fix: revolut unsubscribe (#4009) 2026-06-16 11:48:32 +03:00
137 changed files with 18951 additions and 915 deletions
+1 -14
View File
@@ -7,10 +7,6 @@ on:
description: 'The tag name for the release'
required: true
type: string
upload_url:
description: 'The upload URL for the release'
required: true
type: string
workflow_dispatch:
inputs:
@@ -18,10 +14,6 @@ on:
description: 'The tag name for the release'
required: true
type: string
upload_url:
description: 'The upload URL for the release'
required: true
type: string
jobs:
build-linux-package:
@@ -113,11 +105,6 @@ jobs:
shell: bash
- name: Upload Linux Release Asset
uses: actions/upload-release-asset@v1
with:
upload_url: ${{ inputs.upload_url }}
asset_path: ${{ env.APPIMAGE_NAME }}
asset_name: ${{ env.APPIMAGE_NAME }}
asset_content_type: application/octet-stream
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release upload "${{ inputs.tag_name }}" "${{ env.APPIMAGE_NAME }}" --clobber
+7 -3
View File
@@ -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
+1
View File
@@ -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
-11
View File
@@ -12,8 +12,6 @@ jobs:
release:
runs-on: ubuntu-24.04
outputs:
upload_url: ${{ steps.get_upload_url.outputs.upload_url }}
steps:
- uses: actions/checkout@v4
- name: Create github pre-release
@@ -22,14 +20,6 @@ jobs:
tag: ${{ github.ref_name }}
run: |
gh release create "$tag" --prerelease --generate-notes --draft
- id: get_upload_url
name: Get upload url of Github release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ github.ref_name }}
run: |
upload_url=$(gh release view "$tag" --json uploadUrl -q ".uploadUrl")
echo "upload_url=$upload_url" >> "$GITHUB_OUTPUT"
docker:
if: github.repository == 'lnbits/lnbits'
@@ -74,4 +64,3 @@ jobs:
uses: ./.github/workflows/appimage.yml
with:
tag_name: ${{ github.ref_name }}
upload_url: ${{ needs.release.outputs.upload_url }}
-11
View File
@@ -13,8 +13,6 @@ jobs:
release:
runs-on: ubuntu-24.04
outputs:
upload_url: ${{ steps.get_upload_url.outputs.upload_url }}
steps:
- uses: actions/checkout@v4
- name: Create github release
@@ -23,14 +21,6 @@ jobs:
tag: ${{ github.ref_name }}
run: |
gh release create "$tag" --generate-notes --draft
- id: get_upload_url
name: Get upload url of Github release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ github.ref_name }}
run: |
upload_url=$(gh release view "$tag" --json uploadUrl -q ".uploadUrl")
echo "upload_url=$upload_url" >> "$GITHUB_OUTPUT"
docker:
if: github.repository == 'lnbits/lnbits'
@@ -85,4 +75,3 @@ jobs:
uses: ./.github/workflows/appimage.yml
with:
tag_name: ${{ github.ref_name }}
upload_url: ${{ needs.release.outputs.upload_url }}
+1 -1
View File
@@ -43,4 +43,4 @@ ENV LNBITS_HOST="0.0.0.0"
EXPOSE 5000
CMD ["sh", "-c", "uv --offline run lnbits --port $LNBITS_PORT --host $LNBITS_HOST --forwarded-allow-ips='*'"]
CMD ["sh", "-c", "uv --offline run --no-sync lnbits --port $LNBITS_PORT --host $LNBITS_HOST --forwarded-allow-ips='*'"]
+2 -5
View File
@@ -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
```
> ![NOTE](https://img.shields.io/badge/NOTE-3b82f6?labelColor=494949)
+76 -32
View File
@@ -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 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(
@@ -461,37 +483,59 @@ 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))
# 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)
create_permanent_task(run_by_the_minute_tasks)
create_permanent_task(purge_audit_data)
create_permanent_task(collect_exchange_rates_data)
async def dispatch_extension_invoice_paid(payment) -> None:
await dispatch_wasm_invoice_paid(payment)
task_manager.register_invoice_listener(dispatch_extension_invoice_paid, "core_wasm")
# 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"
)
+2
View File
@@ -18,6 +18,7 @@ from .extensions import (
get_user_extensions,
update_installed_extension,
update_installed_extension_state,
update_installed_extension_wasm_runtime_limits,
update_user_extension,
)
from .payments import (
@@ -156,6 +157,7 @@ __all__ = [
"update_admin_settings",
"update_installed_extension",
"update_installed_extension_state",
"update_installed_extension_wasm_runtime_limits",
"update_migration_version",
"update_payment",
"update_payment_checking_id",
+197
View File
@@ -1,7 +1,12 @@
import json
from datetime import datetime, timedelta, timezone
from lnbits.core.db import db
from lnbits.core.models.extensions import (
InstallableExtension,
UserExtension,
WasmInvocation,
WasmInvocationStats,
)
from lnbits.db import Connection, Database
@@ -11,6 +16,11 @@ async def create_installed_extension(
conn: Connection | None = None,
) -> None:
await (conn or db).insert("installed_extensions", ext)
await update_installed_extension_wasm_runtime_limits(
ext_id=ext.id,
limits=ext.wasm_runtime_limits,
conn=conn,
)
async def update_installed_extension(
@@ -18,6 +28,11 @@ async def update_installed_extension(
conn: Connection | None = None,
) -> None:
await (conn or db).update("installed_extensions", ext)
await update_installed_extension_wasm_runtime_limits(
ext_id=ext.id,
limits=ext.wasm_runtime_limits,
conn=conn,
)
async def update_installed_extension_state(
@@ -31,6 +46,33 @@ async def update_installed_extension_state(
)
async def update_installed_extension_wasm_runtime_limits(
*, ext_id: str, limits: dict, conn: Connection | None = None
) -> None:
if not await _has_installed_extension_wasm_runtime_limits_column(conn=conn):
return
await (conn or db).execute(
"""
UPDATE installed_extensions
SET wasm_runtime_limits = :limits
WHERE id = :id
""",
{"id": ext_id, "limits": json.dumps(limits)},
)
async def _has_installed_extension_wasm_runtime_limits_column(
conn: Connection | None = None,
) -> bool:
row: dict | None = await (conn or db).fetchone(
"SELECT version FROM dbversions WHERE db = 'core'"
)
if not row:
return False
return int(row["version"] or 0) >= 48
async def delete_installed_extension(
*, ext_id: str, conn: Connection | None = None
) -> None:
@@ -144,3 +186,158 @@ async def get_user_active_extensions_ids(
UserExtension,
)
return [ext.extension for ext in exts]
async def create_wasm_invocation(
invocation: WasmInvocation,
conn: Connection | None = None,
) -> None:
await (conn or db).insert("wasm_invocations", invocation)
async def update_wasm_invocation(
invocation: WasmInvocation,
conn: Connection | None = None,
) -> None:
await (conn or db).update("wasm_invocations", invocation)
async def get_wasm_invocation(
invocation_id: str,
conn: Connection | None = None,
) -> WasmInvocation | None:
return await (conn or db).fetchone(
"SELECT * FROM wasm_invocations WHERE id = :id",
{"id": invocation_id},
model=WasmInvocation,
)
async def get_wasm_invocations(
*,
extension_id: str | None = None,
status: str | None = None,
limit: int = 100,
offset: int = 0,
conn: Connection | None = None,
) -> list[WasmInvocation]:
where: list[str] = []
values: dict = {
"limit": max(1, min(limit, 500)),
"offset": max(offset, 0),
}
if extension_id:
where.append("extension_id = :extension_id")
values["extension_id"] = extension_id
if status:
where.append("status = :status")
values["status"] = status
query = "SELECT * FROM wasm_invocations"
if where:
query += f" WHERE {' AND '.join(where)}"
query += " ORDER BY started_at DESC LIMIT :limit OFFSET :offset"
return await (conn or db).fetchall(query, values, model=WasmInvocation)
async def get_running_wasm_invocations(
conn: Connection | None = None,
) -> list[WasmInvocation]:
return await get_wasm_invocations(status="running", conn=conn)
async def get_wasm_invocation_stats(
*,
extension_id: str | None = None,
since: datetime | None = None,
conn: Connection | None = None,
) -> WasmInvocationStats:
database = conn or db
where: list[str] = []
values: dict = {}
if extension_id:
where.append("extension_id = :extension_id")
values["extension_id"] = extension_id
if since:
where.append(f"started_at >= {database.timestamp_placeholder('since')}")
values["since"] = since
query = """
SELECT
COUNT(*) AS total,
COALESCE(SUM(CASE WHEN status = 'running' THEN 1 ELSE 0 END), 0)
AS running,
COALESCE(SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END), 0)
AS completed,
COALESCE(SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END), 0)
AS failed,
COALESCE(SUM(CASE WHEN status = 'stopped' THEN 1 ELSE 0 END), 0)
AS stopped,
COALESCE(SUM(CASE WHEN status = 'timeout' THEN 1 ELSE 0 END), 0)
AS timeout,
COALESCE(AVG(duration_ms), 0) AS avg_duration_ms,
COALESCE(MAX(duration_ms), 0) AS max_duration_ms,
COALESCE(SUM(host_call_count), 0) AS host_call_count,
COALESCE(SUM(http_call_count), 0) AS http_call_count,
COALESCE(SUM(storage_call_count), 0) AS storage_call_count,
COALESCE(SUM(wallet_call_count), 0) AS wallet_call_count
FROM wasm_invocations
"""
if where:
query += f" WHERE {' AND '.join(where)}"
row: dict | None = await (conn or db).fetchone(query, values)
if not row:
return WasmInvocationStats()
return WasmInvocationStats(
total=int(row["total"] or 0),
running=int(row["running"] or 0),
completed=int(row["completed"] or 0),
failed=int(row["failed"] or 0),
stopped=int(row["stopped"] or 0),
timeout=int(row["timeout"] or 0),
avg_duration_ms=float(row["avg_duration_ms"] or 0),
max_duration_ms=int(row["max_duration_ms"] or 0),
host_call_count=int(row["host_call_count"] or 0),
http_call_count=int(row["http_call_count"] or 0),
storage_call_count=int(row["storage_call_count"] or 0),
wallet_call_count=int(row["wallet_call_count"] or 0),
)
async def delete_old_wasm_invocations(
retention_days: int,
conn: Connection | None = None,
) -> int:
if retention_days <= 0:
return 0
cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days)
database = conn or db
result = await database.execute(
f"""
DELETE FROM wasm_invocations
WHERE status != 'running'
AND started_at < {database.timestamp_placeholder("cutoff")}
""", # noqa: S608
{"cutoff": cutoff},
)
return int(result.rowcount or 0)
async def mark_stale_wasm_invocations(
conn: Connection | None = None,
) -> None:
database = conn or db
await database.execute(
f"""
UPDATE wasm_invocations
SET status = 'abandoned',
finished_at = {database.timestamp_placeholder("finished_at")},
stop_reason = 'Server restarted before invocation finished.'
WHERE status = 'running'
""", # noqa: S608
{"finished_at": datetime.now(timezone.utc)},
)
+1
View File
@@ -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,
+15 -10
View File
@@ -8,11 +8,18 @@ from lnbits.db import dict_to_model
from lnbits.settings import (
AdminSettings,
EditableSettings,
FundingSourcesSettings,
SettingsField,
SuperSettings,
settings,
)
RESET_PRESERVED_SETTINGS = (
"lnbits_webpush_pubkey",
"lnbits_webpush_privkey",
*FundingSourcesSettings.__fields__,
)
async def get_super_settings() -> SuperSettings | None:
data = await get_settings_by_tag("core")
@@ -69,16 +76,14 @@ async def delete_admin_settings(tag: str | None = "core") -> None:
async def reset_core_settings() -> None:
await db.execute(
"""
DELETE FROM system_settings WHERE tag = 'core'
AND id NOT IN (
'super_user',
'lnbits_webpush_pubkey',
'lnbits_webpush_privkey'
)
""",
)
core_settings = await get_settings_by_tag("core") or {}
super_user = await get_settings_field("super_user")
await delete_admin_settings()
if super_user:
await set_settings_field("super_user", super_user.value)
for field in RESET_PRESERVED_SETTINGS:
if field in core_settings:
await set_settings_field(field, core_settings[field])
async def create_admin_settings(super_user: str, new_settings: dict) -> SuperSettings:
+11
View File
@@ -15,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
@@ -22,7 +24,16 @@ from lnbits.settings import settings
async def migrate_extension_database(
ext: InstallableExtension, current_version: DbVersion | None = None
):
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
+75
View File
@@ -815,3 +815,78 @@ 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 '[]'"
)
async def m047_create_wasm_invocations_table(db: Connection):
"""
Tracks WASM extension invocations for runtime monitoring and controls.
"""
await db.execute(f"""
CREATE TABLE IF NOT EXISTS wasm_invocations (
id TEXT PRIMARY KEY,
extension_id TEXT NOT NULL,
export_name TEXT NOT NULL,
trigger_type TEXT NOT NULL DEFAULT 'unknown',
status TEXT NOT NULL DEFAULT 'running',
started_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
finished_at TIMESTAMP,
duration_ms INT,
user_id TEXT,
wallet_id TEXT,
request_id TEXT,
method TEXT,
path TEXT,
event_type TEXT,
payment_hash TEXT,
checking_id TEXT,
memory_peak_bytes INT,
request_bytes INT,
response_bytes INT,
host_call_count INT NOT NULL DEFAULT 0,
http_call_count INT NOT NULL DEFAULT 0,
storage_call_count INT NOT NULL DEFAULT 0,
wallet_call_count INT NOT NULL DEFAULT 0,
error_type TEXT,
error_message TEXT,
stop_reason TEXT,
"context" TEXT NOT NULL DEFAULT '{{}}'
);
""")
await db.execute("""
CREATE INDEX IF NOT EXISTS idx_wasm_invocations_extension_started
ON wasm_invocations (extension_id, started_at);
""")
await db.execute("""
CREATE INDEX IF NOT EXISTS idx_wasm_invocations_status
ON wasm_invocations (status);
""")
await db.execute("""
CREATE INDEX IF NOT EXISTS idx_wasm_invocations_started
ON wasm_invocations (started_at);
""")
async def m048_add_wasm_runtime_limits_to_installed_extensions(db: Connection):
"""
Adds per-extension WASM runtime limit overrides.
"""
await db.execute(
"ALTER TABLE installed_extensions "
"ADD COLUMN wasm_runtime_limits TEXT DEFAULT '{}'"
)
async def m049_add_permissions_to_user_extensions(db: Connection):
"""
Adds user-level extension permission grants.
"""
await db.execute("ALTER TABLE extensions ADD COLUMN permissions TEXT DEFAULT '{}'")
+227 -7
View File
@@ -6,13 +6,16 @@ import json
import os
import shutil
import zipfile
from asyncio.tasks import create_task
from pathlib import Path
from collections.abc import Mapping
from datetime import datetime, timezone
from enum import Enum
from pathlib import Path, PurePosixPath
from typing import Any
from uuid import uuid4
import httpx
from loguru import logger
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, StrictStr
from lnbits.helpers import (
download_url,
@@ -21,6 +24,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 +59,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 +81,23 @@ class GitHubRepo(BaseModel):
default_branch: str
class ExtensionPermission(BaseModel):
id: StrictStr
description: StrictStr | None = None
policies: list[Any] | None = None
class Config:
extra = "ignore"
@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 +105,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)
@@ -117,11 +141,83 @@ class UserExtensionInfo(BaseModel):
payment_hash_to_enable: str | None = None
class ExtensionBackgroundPaymentDestinationPolicy(str, Enum):
OWN_WALLETS_ONLY = "own_wallets_only"
EXTERNAL_ALLOWED = "external_allowed"
class ExtensionBackgroundPaymentGrant(BaseModel):
id: StrictStr = Field(..., min_length=1, max_length=128)
wallet_id: str = Field(..., min_length=1, max_length=128)
enabled: bool = True
max_amount: int = Field(..., gt=0)
destination_policy: ExtensionBackgroundPaymentDestinationPolicy
class ExtensionBackgroundPaymentGrantRequest(BaseModel):
wallet_id: str = Field(..., min_length=1, max_length=128)
max_amount: int = Field(..., gt=0)
destination_policy: ExtensionBackgroundPaymentDestinationPolicy
def to_grant(self, grant_id: str | None = None) -> ExtensionBackgroundPaymentGrant:
return ExtensionBackgroundPaymentGrant(
id=grant_id or str(uuid4()),
wallet_id=self.wallet_id,
enabled=True,
max_amount=self.max_amount,
destination_policy=self.destination_policy,
)
class ExtensionWalletPaymentsWatchGrant(BaseModel):
id: StrictStr = Field(..., min_length=1, max_length=128)
wallet_id: str = Field(..., min_length=1, max_length=128)
enabled: bool = True
class ExtensionWalletPaymentsWatchGrantRequest(BaseModel):
wallet_id: str = Field(..., min_length=1, max_length=128)
def to_grant(
self, grant_id: str | None = None
) -> ExtensionWalletPaymentsWatchGrant:
return ExtensionWalletPaymentsWatchGrant(
id=grant_id or str(uuid4()),
wallet_id=self.wallet_id,
enabled=True,
)
class ExtensionPermissionCheckItem(BaseModel):
id: StrictStr
grant: dict[str, Any] = Field(default_factory=dict)
class ExtensionPermissionCheckRequest(BaseModel):
permissions: list[ExtensionPermissionCheckItem] = Field(default_factory=list)
class ExtensionPermissionCheckResult(BaseModel):
id: StrictStr
approved: bool
grant: dict[str, Any] = Field(default_factory=dict)
class ExtensionPermissionCheckResponse(BaseModel):
permissions: list[ExtensionPermissionCheckResult] = Field(default_factory=list)
class ExtensionPermissionsResponse(BaseModel):
extension_permissions: list[ExtensionPermission] = Field(default_factory=list)
user_permissions: dict[str, list[dict[str, Any]]] = Field(default_factory=dict)
class UserExtension(BaseModel):
user: str
extension: str
active: bool
extra: UserExtensionInfo | None = None
permissions: dict = Field(default_factory=dict)
@property
def is_paid(self) -> bool:
@@ -143,6 +239,7 @@ 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
@@ -159,6 +256,8 @@ class Extension(BaseModel):
@property
def is_upgrade_extension(self) -> bool:
if self.is_wasm:
return False
return self.upgrade_hash != ""
@classmethod
@@ -166,13 +265,71 @@ 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 "",
)
class WasmInvocation(BaseModel):
id: str
extension_id: str
export_name: str
trigger_type: str = "unknown"
status: str = "running"
started_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
finished_at: datetime | None = None
duration_ms: int | None = None
user_id: str | None = None
wallet_id: str | None = None
request_id: str | None = None
method: str | None = None
path: str | None = None
event_type: str | None = None
payment_hash: str | None = None
checking_id: str | None = None
memory_peak_bytes: int | None = None
request_bytes: int | None = None
response_bytes: int | None = None
host_call_count: int = 0
http_call_count: int = 0
storage_call_count: int = 0
wallet_call_count: int = 0
error_type: str | None = None
error_message: str | None = None
stop_reason: str | None = None
context: dict = Field(default_factory=dict)
class WasmInvocationStats(BaseModel):
total: int = 0
running: int = 0
completed: int = 0
failed: int = 0
stopped: int = 0
timeout: int = 0
avg_duration_ms: float = 0
max_duration_ms: int = 0
host_call_count: int = 0
http_call_count: int = 0
storage_call_count: int = 0
wallet_call_count: int = 0
class WasmRuntimeLimitsUpdate(BaseModel):
limits: dict[str, Any] = Field(default_factory=dict)
class WasmRuntimeLimitsInfo(BaseModel):
id: str
name: str
active: bool | None = False
wasm_runtime_limits: dict[str, int] = Field(default_factory=dict)
effective_wasm_runtime_limits: dict[str, int] = Field(default_factory=dict)
class ExtensionRelease(BaseModel):
name: str
version: str
@@ -308,7 +465,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)
@@ -333,6 +489,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
@@ -347,6 +504,8 @@ class InstallableExtension(BaseModel):
icon: str | None = None
stars: int = 0
meta: ExtensionMeta | None = None
permissions: list[ExtensionPermission] = []
wasm_runtime_limits: dict = Field(default_factory=dict, no_database=True)
@property
def hash(self) -> str:
@@ -399,6 +558,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
@@ -431,6 +602,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)
@@ -609,6 +796,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,
@@ -641,7 +829,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
@@ -680,6 +871,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]
@@ -695,6 +891,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:
@@ -789,6 +990,7 @@ class CreateExtension(BaseModel):
version: str
cost_sats: int | None = 0
payment_hash: str | None = None
permissions: list[ExtensionPermission] = []
class ExtensionDetailsRequest(BaseModel):
@@ -841,3 +1043,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
+35
View File
@@ -1,6 +1,8 @@
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
from typing import Any
from pydantic import BaseModel
@@ -11,8 +13,41 @@ def _do_nothing(*_):
class CoreAppExtra:
register_new_ext_routes: Callable = _do_nothing
register_new_wasm_ext_routes: Callable = _do_nothing
register_new_ratelimiter: Callable
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.require_available(extension)
self._extensions[extension.id] = extension
def require_available(self, extension: Any) -> None:
existing = self._extensions.get(extension.id)
if existing and not _same_wasm_extension_registration(existing, extension):
raise ValueError(
f"WASM extension id '{extension.id}' is already registered."
)
def get(self, ext_id: str) -> Any | None:
return self._extensions.get(ext_id)
def list(self) -> list[Any]:
return list(self._extensions.values())
def _same_wasm_extension_registration(left: Any, right: Any) -> bool:
try:
return Path(left.root_path).resolve() == Path(right.root_path).resolve()
except (AttributeError, TypeError):
return left is right
class ConversionData(BaseModel):
from_: str = "sat"
+6 -8
View File
@@ -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
+575 -4
View File
@@ -1,5 +1,12 @@
import asyncio
import importlib
import re
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from threading import RLock
from typing import Any
from uuid import uuid4
from loguru import logger
@@ -13,18 +20,173 @@ from lnbits.core.crud import (
update_installed_extension_state,
)
from lnbits.core.crud.extensions import (
create_wasm_invocation,
delete_old_wasm_invocations,
get_installed_extensions,
get_wasm_invocation,
mark_stale_wasm_invocations,
update_installed_extension,
update_installed_extension_wasm_runtime_limits,
update_wasm_invocation,
)
from lnbits.core.crud.extensions import (
get_wasm_invocation_stats as get_wasm_invocation_stats_crud,
)
from lnbits.core.crud.extensions import (
get_wasm_invocations as get_wasm_invocations_crud,
)
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 lnbits.settings import WasmRuntimeLimits, settings
from ..models.extensions import Extension, ExtensionMeta, InstallableExtension
from ..models.extensions import (
Extension,
ExtensionMeta,
ExtensionPermission,
InstallableExtension,
WasmInvocation,
WasmInvocationStats,
)
_WASM_INVOCATION_CLEANUP_INTERVAL = timedelta(hours=1)
WASM_RUNTIME_LIMIT_FIELDS = tuple(WasmRuntimeLimits.__fields__.keys())
@dataclass
class WasmInvocationHandle:
invocation: WasmInvocation
engine: Any | None = None
store: Any | None = None
runtime_limits: dict[str, int] | None = None
stop_requested: bool = False
stop_reason: str | None = None
_wasm_invocation_lock = RLock()
_wasm_invocation_ready_lock = asyncio.Lock()
_wasm_invocation_handles: dict[str, WasmInvocationHandle] = {}
_wasm_invocations_marked_stale = False
_wasm_invocations_last_cleanup_at: datetime | None = None
def wasm_runtime_limit_defaults() -> dict[str, int]:
return {field: int(getattr(settings, field)) for field in WASM_RUNTIME_LIMIT_FIELDS}
def validate_wasm_runtime_limit_overrides(
limits: Mapping[str, Any] | None,
*,
strict: bool = True,
) -> dict[str, int]:
if not limits:
return {}
validated: dict[str, int] = {}
for field, raw_value in limits.items():
if field not in WASM_RUNTIME_LIMIT_FIELDS:
if strict:
raise ValueError(f"Unknown WASM runtime limit field '{field}'.")
continue
value = _validate_wasm_runtime_limit_value(field, raw_value, strict=strict)
if value is None:
continue
validated[field] = value
return validated
def _validate_wasm_runtime_limit_value(
field: str,
raw_value: Any,
*,
strict: bool,
) -> int | None:
if raw_value is None or raw_value == "":
return None
if isinstance(raw_value, bool):
return _invalid_wasm_runtime_limit(field, strict, "must be an integer")
if isinstance(raw_value, str):
raw_value = raw_value.strip()
if raw_value == "":
return None
if not raw_value.isdecimal():
return _invalid_wasm_runtime_limit(field, strict, "must be an integer")
if isinstance(raw_value, float) and not raw_value.is_integer():
return _invalid_wasm_runtime_limit(field, strict, "must be an integer")
try:
value = int(raw_value)
except (TypeError, ValueError) as exc:
return _invalid_wasm_runtime_limit(
field,
strict,
"must be an integer",
exc=exc,
)
if value < 0:
return _invalid_wasm_runtime_limit(field, strict, "cannot be negative")
return value
def _invalid_wasm_runtime_limit(
field: str,
strict: bool,
message: str,
*,
exc: Exception | None = None,
) -> int | None:
if not strict:
return None
error = ValueError(f"WASM runtime limit '{field}' {message}.")
if exc:
raise error from exc
raise error
def resolve_wasm_runtime_limits(
installed_extension: InstallableExtension | None = None,
) -> dict[str, int]:
limits = wasm_runtime_limit_defaults()
if installed_extension:
limits.update(
validate_wasm_runtime_limit_overrides(
installed_extension.wasm_runtime_limits,
strict=False,
)
)
return limits
async def get_wasm_runtime_limits_for_extension(ext_id: str) -> dict[str, int]:
installed_extension = await get_installed_extension(ext_id)
return resolve_wasm_runtime_limits(installed_extension)
async def update_wasm_extension_runtime_limits(
ext_id: str,
limits: Mapping[str, Any] | None,
) -> dict[str, int]:
installed_extension = await get_installed_extension(ext_id)
if not installed_extension:
raise ValueError(f"Extension '{ext_id}' is not installed.")
if not installed_extension.is_wasm:
raise ValueError(f"Extension '{ext_id}' is not a WASM extension.")
validated_limits = validate_wasm_runtime_limit_overrides(limits)
await update_installed_extension_wasm_runtime_limits(
ext_id=ext_id,
limits=validated_limits,
)
return validated_limits
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()
@@ -38,12 +200,19 @@ async def install_extension(
installed_ext = await get_installed_extension(ext_info.id)
if installed_ext and installed_ext.meta:
ext_info.meta.payments = installed_ext.meta.payments
if installed_ext:
ext_info.wasm_runtime_limits = installed_ext.wasm_runtime_limits
await check_extensions_limit(installed_ext)
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 +230,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
@@ -75,6 +245,394 @@ async def check_extensions_limit(installed_ext: InstallableExtension | None = No
raise ValueError("Max amount of extensions have been installed")
async def ensure_wasm_invocation_monitoring_ready() -> None:
global _wasm_invocations_last_cleanup_at, _wasm_invocations_marked_stale
async with _wasm_invocation_ready_lock:
now = _now()
if not _wasm_invocations_marked_stale:
await mark_stale_wasm_invocations()
_wasm_invocations_marked_stale = True
if (
_wasm_invocations_last_cleanup_at is None
or now - _wasm_invocations_last_cleanup_at
>= _WASM_INVOCATION_CLEANUP_INTERVAL
):
_wasm_invocations_last_cleanup_at = now
await delete_old_wasm_invocations(
settings.lnbits_wasm_invocation_retention_days
)
async def start_wasm_invocation(
*,
extension_id: str,
export_name: str,
trigger_type: str = "unknown",
user_id: str | None = None,
wallet_id: str | None = None,
request_id: str | None = None,
method: str | None = None,
path: str | None = None,
event_type: str | None = None,
payment_hash: str | None = None,
checking_id: str | None = None,
request_bytes: int | None = None,
context: dict | None = None,
runtime_limits: dict[str, int] | None = None,
) -> WasmInvocation:
await ensure_wasm_invocation_monitoring_ready()
_check_wasm_invocation_concurrency(
extension_id=extension_id,
user_id=user_id,
limits=runtime_limits,
)
invocation = WasmInvocation(
id=uuid4().hex,
extension_id=extension_id,
export_name=export_name,
trigger_type=trigger_type,
user_id=user_id,
wallet_id=wallet_id,
request_id=request_id,
method=method,
path=path,
event_type=event_type,
payment_hash=payment_hash,
checking_id=checking_id,
request_bytes=request_bytes,
context=_safe_wasm_invocation_context(context or {}),
)
await create_wasm_invocation(invocation)
with _wasm_invocation_lock:
_wasm_invocation_handles[invocation.id] = WasmInvocationHandle(
invocation,
runtime_limits=runtime_limits,
)
return invocation
def attach_wasm_invocation_runtime(
invocation_id: str,
*,
engine: Any,
store: Any,
) -> None:
with _wasm_invocation_lock:
handle = _wasm_invocation_handles.get(invocation_id)
if not handle:
return
handle.engine = engine
handle.store = store
if handle.stop_requested:
_interrupt_wasm_invocation(handle)
def record_wasm_invocation_host_call(
invocation_id: str | None,
method_id: str,
) -> None:
if not invocation_id:
return
with _wasm_invocation_lock:
handle = _wasm_invocation_handles.get(invocation_id)
if not handle:
return
invocation = handle.invocation
invocation.host_call_count += 1
category = _wasm_host_call_category(method_id)
if category == "http":
invocation.http_call_count += 1
elif category == "storage":
invocation.storage_call_count += 1
elif category == "wallet":
invocation.wallet_call_count += 1
_check_wasm_host_call_limit(invocation, category, handle.runtime_limits)
async def stop_wasm_invocation(
invocation_id: str,
*,
reason: str = "Stopped by admin.",
) -> bool:
interrupted = False
with _wasm_invocation_lock:
handle = _wasm_invocation_handles.get(invocation_id)
if handle:
handle.stop_requested = True
handle.stop_reason = reason
handle.invocation.stop_reason = reason
interrupted = _interrupt_wasm_invocation(handle)
invocation = await get_wasm_invocation(invocation_id)
if invocation and invocation.status == "running":
invocation.stop_reason = reason
await update_wasm_invocation(invocation)
return interrupted
async def stop_wasm_extension_invocations(
extension_id: str,
*,
reason: str = "Extension deactivated.",
) -> int:
with _wasm_invocation_lock:
invocation_ids = [
invocation_id
for invocation_id, handle in _wasm_invocation_handles.items()
if handle.invocation.extension_id == extension_id
]
for invocation_id in invocation_ids:
await stop_wasm_invocation(invocation_id, reason=reason)
return len(invocation_ids)
def wasm_invocation_stop_requested(invocation_id: str) -> bool:
with _wasm_invocation_lock:
handle = _wasm_invocation_handles.get(invocation_id)
return bool(handle and handle.stop_requested)
def get_wasm_invocation_stop_reason(invocation_id: str) -> str | None:
with _wasm_invocation_lock:
handle = _wasm_invocation_handles.get(invocation_id)
return handle.stop_reason if handle else None
async def finish_wasm_invocation(
invocation_id: str,
*,
status: str,
response_bytes: int | None = None,
memory_peak_bytes: int | None = None,
error_type: str | None = None,
error_message: str | None = None,
stop_reason: str | None = None,
) -> None:
with _wasm_invocation_lock:
handle = _wasm_invocation_handles.pop(invocation_id, None)
invocation = (
handle.invocation if handle else await get_wasm_invocation(invocation_id)
)
if not invocation:
return
reason = stop_reason or (handle.stop_reason if handle else None)
if handle and handle.stop_requested and status == "failed":
status = "stopped"
reason = reason or "Stopped by admin."
finished_at = _now()
invocation.status = status
invocation.finished_at = finished_at
invocation.duration_ms = max(
0, int((finished_at - invocation.started_at).total_seconds() * 1000)
)
invocation.response_bytes = response_bytes
invocation.memory_peak_bytes = memory_peak_bytes
invocation.error_type = error_type
invocation.error_message = _safe_wasm_error_message(error_message)
invocation.stop_reason = reason
await update_wasm_invocation(invocation)
def get_current_wasm_invocations(
extension_id: str | None = None,
) -> list[WasmInvocation]:
with _wasm_invocation_lock:
invocations = []
for handle in _wasm_invocation_handles.values():
if extension_id and handle.invocation.extension_id != extension_id:
continue
invocation = handle.invocation.copy(deep=True)
if handle.stop_requested and invocation.status == "running":
invocation.status = "stopping"
invocation.stop_reason = handle.stop_reason
invocations.append(invocation)
return sorted(
invocations, key=lambda invocation: invocation.started_at, reverse=True
)
def _check_wasm_invocation_concurrency(
*,
extension_id: str,
user_id: str | None,
limits: dict[str, int] | None,
) -> None:
if not limits:
return
with _wasm_invocation_lock:
handles = list(_wasm_invocation_handles.values())
if _wasm_limit_exceeded(
limits["wasm_runtime_max_concurrent_invocations"],
len(handles) + 1,
):
raise ValueError("WASM runtime has too many active invocations.")
extension_invocations = sum(
1 for handle in handles if handle.invocation.extension_id == extension_id
)
if _wasm_limit_exceeded(
limits["wasm_runtime_max_concurrent_invocations_per_extension"],
extension_invocations + 1,
):
raise ValueError(
f"WASM extension '{extension_id}' has too many active invocations."
)
if not user_id:
return
user_invocations = sum(
1 for handle in handles if handle.invocation.user_id == user_id
)
if _wasm_limit_exceeded(
limits["wasm_runtime_max_concurrent_invocations_per_user"],
user_invocations + 1,
):
raise ValueError("WASM user has too many active invocations.")
def _check_wasm_host_call_limit(
invocation: WasmInvocation,
category: str,
limits: dict[str, int] | None,
) -> None:
if not limits:
return
if _wasm_limit_exceeded(
limits["wasm_runtime_max_host_calls"],
invocation.host_call_count,
):
raise ValueError("WASM host call limit exceeded.")
category_limits = {
"http": (
limits["wasm_runtime_max_http_calls"],
invocation.http_call_count,
),
"storage": (
limits["wasm_runtime_max_storage_calls"],
invocation.storage_call_count,
),
"wallet": (
limits["wasm_runtime_max_wallet_calls"],
invocation.wallet_call_count,
),
}
category_limit = category_limits.get(category)
if category_limit and _wasm_limit_exceeded(*category_limit):
raise ValueError(f"WASM {category} host call limit exceeded.")
def _wasm_limit_exceeded(limit: int, value: int) -> bool:
return limit > 0 and value > limit
async def get_wasm_invocation_history(
*,
extension_id: str | None = None,
status: str | None = None,
limit: int = 100,
offset: int = 0,
) -> list[WasmInvocation]:
await ensure_wasm_invocation_monitoring_ready()
return await get_wasm_invocations_crud(
extension_id=extension_id,
status=status,
limit=limit,
offset=offset,
)
async def get_wasm_invocation_summary(
*,
extension_id: str | None = None,
hours: int = 24,
) -> WasmInvocationStats:
await ensure_wasm_invocation_monitoring_ready()
since = _now() - timedelta(hours=max(1, min(hours, 24 * 30)))
return await get_wasm_invocation_stats_crud(
extension_id=extension_id,
since=since,
)
def _interrupt_wasm_invocation(handle: WasmInvocationHandle) -> bool:
if not handle.store or not handle.engine:
return False
try:
handle.store.set_epoch_deadline(1)
handle.engine.increment_epoch()
return True
except Exception as exc:
logger.warning(
f"Failed to interrupt WASM invocation '{handle.invocation.id}': {exc}"
)
return False
def _wasm_host_call_category(method_id: str) -> str:
if method_id.startswith("http.") or method_id.startswith("extension.api."):
return "http"
if method_id.startswith("storage."):
return "storage"
if method_id.startswith("wallet."):
return "wallet"
return "host"
def _safe_wasm_invocation_context(context: dict) -> dict:
safe_context: dict = {}
for key, value in context.items():
if not isinstance(key, str):
continue
if value is None or isinstance(value, (bool, int, float)):
safe_context[key[:64]] = value
elif isinstance(value, str):
safe_context[key[:64]] = value[:256]
return safe_context
def _safe_wasm_error_message(message: str | None) -> str | None:
if not message:
return None
safe_message = message[:500]
redactions = [
(
r"(?i)(api[-_ ]?key|token|authorization|password|secret|preimage)"
r"\s*[:=]\s*[^\s,;]+",
r"\1=[redacted]",
),
(r"(?i)bearer\s+[A-Za-z0-9._~+/=-]+", "Bearer [redacted]"),
(r"\b[a-fA-F0-9]{64}\b", "[redacted-hex]"),
]
for pattern, replacement in redactions:
safe_message = re.sub(pattern, replacement, safe_message)
return safe_message
def _now() -> datetime:
return datetime.now(timezone.utc)
async def uninstall_extension(ext_id: str):
await stop_extension_background_work(ext_id)
@@ -87,12 +645,19 @@ 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)
async def deactivate_extension(ext_id: str):
if is_wasm_extension_id(ext_id):
await stop_wasm_extension_invocations(ext_id, reason="Extension deactivated.")
settings.deactivate_extension_paths(ext_id)
await update_installed_extension_state(ext_id=ext_id, active=False)
await stop_extension_background_work(ext_id)
@@ -103,6 +668,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 +703,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)
+7 -11
View File
@@ -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
+2
View File
@@ -66,6 +66,8 @@ async def check_server_balance_against_node():
async def check_balance_delta_changed():
if settings.notification_balance_delta_threshold_sats <= 0:
return
status = await get_balance_delta()
if settings.latest_balance_delta_sats is None:
settings.latest_balance_delta_sats = status.delta_sats
+44 -6
View File
@@ -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())
+49 -36
View File
@@ -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.helpers import check_callback_url, daystart_timestamp
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(
@@ -556,10 +557,9 @@ async def check_wallet_daily_withdraw_limit(
raise ValueError("It is not allowed to spend funds from this server.")
payments = await get_payments(
since=int(time.time()) - 60 * 60 * 24,
since=daystart_timestamp(),
outgoing=True,
wallet_id=wallet_id,
limit=1,
conn=conn,
)
if len(payments) == 0:
@@ -630,18 +630,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 +779,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 +825,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 +1077,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)
+47 -118
View File
@@ -2,76 +2,43 @@ 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.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 +55,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)
+4 -5
View File
@@ -20,7 +20,7 @@ from lnbits.core.services.settings import dict_to_settings
from lnbits.decorators import check_admin, check_super_user
from lnbits.server import server_restart
from lnbits.settings import AdminSettings, Settings, UpdateSettings, settings
from lnbits.tasks import invoice_listeners
from lnbits.task_manager import PublicTask, task_manager
from .. import core_app_extra
from ..crud import get_admin_settings, reset_core_settings, update_admin_settings
@@ -44,11 +44,10 @@ async def api_auditor():
name="Monitor",
description="show the current listeners and other monitoring data",
dependencies=[Depends(check_admin)],
response_model=list[PublicTask],
)
async def api_monitor():
return {
"invoice_listeners": list(invoice_listeners.keys()),
}
async def api_monitor() -> list[PublicTask]:
return task_manager.get_public_tasks()
@admin_router.get(
+1 -1
View File
@@ -439,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": {
+576 -44
View File
@@ -1,3 +1,4 @@
import json
import sys
import traceback
from http import HTTPStatus
@@ -9,7 +10,7 @@ from fastapi.requests import Request
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.crud.wallets import get_wallet, get_wallets_ids
from lnbits.core.db import db
from lnbits.core.models import (
SimpleStatus,
@@ -18,28 +19,50 @@ from lnbits.core.models.extensions import (
CreateExtension,
CreateExtensionReview,
Extension,
ExtensionBackgroundPaymentDestinationPolicy,
ExtensionBackgroundPaymentGrant,
ExtensionBackgroundPaymentGrantRequest,
ExtensionConfig,
ExtensionMeta,
ExtensionPermissionCheckRequest,
ExtensionPermissionCheckResponse,
ExtensionPermissionCheckResult,
ExtensionPermissionsResponse,
ExtensionRelease,
ExtensionReview,
ExtensionReviewPaymentRequest,
ExtensionReviewsStatus,
ExtensionWalletPaymentsWatchGrant,
ExtensionWalletPaymentsWatchGrantRequest,
InstallableExtension,
PayToEnableInfo,
ReleasePaymentInfo,
UserExtension,
UserExtensionInfo,
WasmInvocation,
WasmInvocationStats,
WasmRuntimeLimitsInfo,
WasmRuntimeLimitsUpdate,
wasm_extension_icon_url,
)
from lnbits.core.models.users import Account, AccountId
from lnbits.core.services import check_transaction_status, create_invoice
from lnbits.core.services.extensions import (
activate_extension,
deactivate_extension,
get_current_wasm_invocations,
get_valid_extension,
get_valid_extensions,
get_wasm_invocation_history,
get_wasm_invocation_summary,
install_extension,
resolve_wasm_runtime_limits,
stop_wasm_invocation,
uninstall_extension,
update_wasm_extension_runtime_limits,
validate_wasm_runtime_limit_overrides,
)
from lnbits.core.wasm_ext.api.permissions import validate_extension_permissions
from lnbits.db import Page
from lnbits.decorators import (
check_account_exists,
@@ -66,6 +89,9 @@ extension_router = APIRouter(
prefix="/api/v1/extension",
)
WALLET_PAY_INVOICE_BACKGROUND_PERMISSION = "wallet.pay_invoice_background"
WALLET_PAYMENTS_WATCH_PERMISSION = "wallet.payments.watch"
@extension_router.post("", dependencies=[Depends(check_admin)])
async def api_install_extension(data: CreateExtension):
@@ -89,7 +115,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)
@@ -128,6 +156,106 @@ async def api_install_extension(data: CreateExtension):
) from exc
@extension_router.get(
"/wasm/invocations/current",
dependencies=[Depends(check_admin)],
)
async def api_get_current_wasm_invocations(
extension_id: str | None = None,
) -> list[WasmInvocation]:
return get_current_wasm_invocations(extension_id=extension_id)
@extension_router.get(
"/wasm/invocations",
dependencies=[Depends(check_admin)],
)
async def api_get_wasm_invocations(
extension_id: str | None = None,
status: str | None = None,
limit: int = 100,
offset: int = 0,
) -> list[WasmInvocation]:
return await get_wasm_invocation_history(
extension_id=extension_id,
status=status,
limit=limit,
offset=offset,
)
@extension_router.get(
"/wasm/invocations/stats",
dependencies=[Depends(check_admin)],
)
async def api_get_wasm_invocation_stats(
extension_id: str | None = None,
hours: int = 24,
) -> WasmInvocationStats:
return await get_wasm_invocation_summary(extension_id=extension_id, hours=hours)
@extension_router.post(
"/wasm/invocations/{invocation_id}/stop",
dependencies=[Depends(check_admin)],
)
async def api_stop_wasm_invocation(invocation_id: str) -> SimpleStatus:
await stop_wasm_invocation(invocation_id, reason="Stopped by admin.")
return SimpleStatus(success=True, message="WASM invocation stop requested.")
@extension_router.get(
"/wasm/runtime-limits/extensions",
dependencies=[Depends(check_admin)],
)
async def api_get_wasm_runtime_limit_extensions() -> list[WasmRuntimeLimitsInfo]:
installed_extensions = await get_installed_extensions()
return [
WasmRuntimeLimitsInfo(
id=extension.id,
name=extension.name,
active=extension.active,
wasm_runtime_limits=validate_wasm_runtime_limit_overrides(
extension.wasm_runtime_limits,
strict=False,
),
effective_wasm_runtime_limits=resolve_wasm_runtime_limits(extension),
)
for extension in installed_extensions
if extension.is_wasm
]
@extension_router.put(
"/wasm/runtime-limits/{ext_id}",
dependencies=[Depends(check_admin)],
)
async def api_update_wasm_runtime_limits(
ext_id: str,
data: WasmRuntimeLimitsUpdate,
) -> WasmRuntimeLimitsInfo:
try:
wasm_runtime_limits = await update_wasm_extension_runtime_limits(
ext_id, data.limits
)
extension = await get_installed_extension(ext_id)
if not extension:
raise ValueError(f"Extension '{ext_id}' is not installed.")
extension.wasm_runtime_limits = wasm_runtime_limits
return WasmRuntimeLimitsInfo(
id=extension.id,
name=extension.name,
active=extension.active,
wasm_runtime_limits=wasm_runtime_limits,
effective_wasm_runtime_limits=resolve_wasm_runtime_limits(extension),
)
except ValueError as exc:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=str(exc),
) from exc
@extension_router.get("/{ext_id}/details")
async def api_extension_details(
ext_id: str,
@@ -251,6 +379,213 @@ async def api_disable_extension(
return SimpleStatus(success=True, message=f"Extension '{ext_id}' disabled.")
@extension_router.post("/{ext_id}/permissions/background-payment")
async def api_grant_background_payment_permission(
ext_id: str,
data: ExtensionBackgroundPaymentGrantRequest,
account_id: AccountId = Depends(check_account_id_exists),
) -> dict:
installed_ext = await get_installed_extension(ext_id)
if not installed_ext or not installed_ext.active:
raise HTTPException(
HTTPStatus.NOT_FOUND, f"Extension '{ext_id}' is not active."
)
installed_permission_ids = {
permission.id for permission in installed_ext.permissions or []
}
if WALLET_PAY_INVOICE_BACKGROUND_PERMISSION not in installed_permission_ids:
raise HTTPException(
HTTPStatus.FORBIDDEN,
f"Extension '{ext_id}' cannot request background payments.",
)
user_ext = await get_user_extension(account_id.id, ext_id)
if not user_ext or not user_ext.active:
raise HTTPException(
HTTPStatus.FORBIDDEN,
f"Extension '{ext_id}' is not enabled for this user.",
)
wallet = await get_wallet(data.wallet_id)
if not wallet or wallet.user != account_id.id:
raise HTTPException(HTTPStatus.FORBIDDEN, "Not your wallet.")
if wallet.is_lightning_shared_wallet:
raise HTTPException(
HTTPStatus.BAD_REQUEST,
"Background payments are not allowed from shared wallets.",
)
if not wallet.can_send_payments:
raise HTTPException(
HTTPStatus.BAD_REQUEST,
"This wallet cannot send payments.",
)
permissions = user_ext.permissions or {}
grant = data.to_grant(
_user_permission_grant_id_for_wallet(
permissions,
WALLET_PAY_INVOICE_BACKGROUND_PERMISSION,
data.wallet_id,
)
)
background_grants = [
existing
for existing in permissions.get(WALLET_PAY_INVOICE_BACKGROUND_PERMISSION, [])
if isinstance(existing, dict) and existing.get("wallet_id") != grant.wallet_id
]
background_grants.append(json.loads(grant.json()))
permissions[WALLET_PAY_INVOICE_BACKGROUND_PERMISSION] = background_grants
user_ext.permissions = permissions
await update_user_extension(user_ext)
return {"permission": WALLET_PAY_INVOICE_BACKGROUND_PERMISSION, "grant": grant}
@extension_router.post("/{ext_id}/permissions/wallet-payments-watch")
async def api_grant_wallet_payments_watch_permission(
ext_id: str,
data: ExtensionWalletPaymentsWatchGrantRequest,
account_id: AccountId = Depends(check_account_id_exists),
) -> dict:
installed_ext = await get_installed_extension(ext_id)
if not installed_ext or not installed_ext.active:
raise HTTPException(
HTTPStatus.NOT_FOUND, f"Extension '{ext_id}' is not active."
)
installed_permission_ids = {
permission.id for permission in installed_ext.permissions or []
}
if WALLET_PAYMENTS_WATCH_PERMISSION not in installed_permission_ids:
raise HTTPException(
HTTPStatus.FORBIDDEN,
f"Extension '{ext_id}' cannot request wallet payment watch access.",
)
user_ext = await get_user_extension(account_id.id, ext_id)
if not user_ext or not user_ext.active:
raise HTTPException(
HTTPStatus.FORBIDDEN,
f"Extension '{ext_id}' is not enabled for this user.",
)
wallet = await get_wallet(data.wallet_id)
if not wallet or wallet.user != account_id.id:
raise HTTPException(HTTPStatus.FORBIDDEN, "Not your wallet.")
permissions = user_ext.permissions or {}
grant = data.to_grant(
_user_permission_grant_id_for_wallet(
permissions,
WALLET_PAYMENTS_WATCH_PERMISSION,
data.wallet_id,
)
)
watch_grants = [
existing
for existing in permissions.get(WALLET_PAYMENTS_WATCH_PERMISSION, [])
if isinstance(existing, dict) and existing.get("wallet_id") != grant.wallet_id
]
watch_grants.append(json.loads(grant.json()))
permissions[WALLET_PAYMENTS_WATCH_PERMISSION] = watch_grants
user_ext.permissions = permissions
await update_user_extension(user_ext)
return {"permission": WALLET_PAYMENTS_WATCH_PERMISSION, "grant": grant}
@extension_router.get("/{ext_id}/permissions")
async def api_get_extension_permissions(
ext_id: str,
account_id: AccountId = Depends(check_account_id_exists),
) -> ExtensionPermissionsResponse:
installed_ext = await _require_active_wasm_extension(ext_id)
extension_permissions = validate_extension_permissions(
installed_ext.id, installed_ext.permissions, strict=False
)
user_ext = await get_user_extension(account_id.id, ext_id)
return ExtensionPermissionsResponse(
extension_permissions=extension_permissions,
user_permissions=_safe_user_extension_permissions(
user_ext.permissions if user_ext else {}
),
)
@extension_router.delete("/{ext_id}/permissions/user/{grant_id}")
async def api_delete_user_extension_permission(
ext_id: str,
grant_id: str,
account_id: AccountId = Depends(check_account_id_exists),
) -> SimpleStatus:
await _require_active_wasm_extension(ext_id)
user_ext = await get_user_extension(account_id.id, ext_id)
if not user_ext:
return SimpleStatus(success=True, message="Permission grant removed.")
user_ext.permissions = _remove_user_permission_grant(
user_ext.permissions or {}, grant_id
)
await update_user_extension(user_ext)
return SimpleStatus(success=True, message="Permission grant removed.")
@extension_router.post("/{ext_id}/permissions/check")
async def api_check_extension_permissions(
ext_id: str,
data: ExtensionPermissionCheckRequest,
account_id: AccountId = Depends(check_account_id_exists),
) -> ExtensionPermissionCheckResponse:
installed_ext = await get_installed_extension(ext_id)
if not installed_ext or not installed_ext.active:
raise HTTPException(
HTTPStatus.NOT_FOUND, f"Extension '{ext_id}' is not active."
)
installed_permission_ids = {
permission.id for permission in installed_ext.permissions or []
}
user_ext = await get_user_extension(account_id.id, ext_id)
if not user_ext or not user_ext.active:
raise HTTPException(
HTTPStatus.FORBIDDEN,
f"Extension '{ext_id}' is not enabled for this user.",
)
results: list[ExtensionPermissionCheckResult] = []
for permission in data.permissions:
if permission.id not in installed_permission_ids:
raise HTTPException(
HTTPStatus.FORBIDDEN,
f"Extension '{ext_id}' cannot request '{permission.id}'.",
)
if permission.id == WALLET_PAY_INVOICE_BACKGROUND_PERMISSION:
results.append(
await _check_background_payment_permission(
account_id.id,
user_ext.permissions or {},
permission.grant,
)
)
continue
if permission.id == WALLET_PAYMENTS_WATCH_PERMISSION:
results.append(
await _check_wallet_payments_watch_permission(
account_id.id,
user_ext.permissions or {},
permission.grant,
)
)
continue
raise HTTPException(
HTTPStatus.BAD_REQUEST,
f"Unsupported permission check '{permission.id}'.",
)
return ExtensionPermissionCheckResponse(permissions=results)
@extension_router.put("/{ext_id}/activate", dependencies=[Depends(check_admin)])
async def api_activate_extension(ext_id: str) -> SimpleStatus:
try:
@@ -292,7 +627,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(
@@ -460,11 +794,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)
@@ -536,9 +878,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:
@@ -559,46 +902,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,
}
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
@@ -646,3 +1003,178 @@ async def create_extension_review(
resp.raise_for_status()
payment_request = resp.json()
return ExtensionReviewPaymentRequest(**payment_request)
async def _require_active_wasm_extension(ext_id: str) -> InstallableExtension:
installed_ext = await get_installed_extension(ext_id)
if not installed_ext or not installed_ext.active:
raise HTTPException(
HTTPStatus.NOT_FOUND, f"Extension '{ext_id}' is not active."
)
if not installed_ext.is_wasm:
raise HTTPException(
HTTPStatus.BAD_REQUEST, f"Extension '{ext_id}' is not a WASM extension."
)
return installed_ext
def _safe_user_extension_permissions(permissions: dict | None) -> dict:
safe_permissions: dict[str, list[dict]] = {}
for permission_id, grants in (permissions or {}).items():
if not isinstance(permission_id, str) or not isinstance(grants, list):
continue
safe_grants = [
grant
for grant in grants
if isinstance(grant, dict) and isinstance(grant.get("id"), str)
]
if safe_grants:
safe_permissions[permission_id] = safe_grants
return safe_permissions
def _user_permission_grant_id_for_wallet(
permissions: dict, permission_id: str, wallet_id: str
) -> str | None:
grants = permissions.get(permission_id)
if not isinstance(grants, list):
return None
for grant in grants:
if not isinstance(grant, dict) or grant.get("wallet_id") != wallet_id:
continue
grant_id = grant.get("id")
return grant_id if isinstance(grant_id, str) and grant_id else None
return None
def _remove_user_permission_grant(permissions: dict, grant_id: str) -> dict:
updated_permissions = dict(permissions or {})
for permission_id, grants in list(updated_permissions.items()):
if not isinstance(grants, list):
continue
remaining_grants = [
grant
for grant in grants
if not isinstance(grant, dict) or grant.get("id") != grant_id
]
if remaining_grants:
updated_permissions[permission_id] = remaining_grants
else:
updated_permissions.pop(permission_id, None)
return updated_permissions
async def _check_background_payment_permission(
account_id: str,
permissions: dict,
grant_data: dict,
) -> ExtensionPermissionCheckResult:
try:
data = ExtensionBackgroundPaymentGrantRequest.parse_obj(grant_data)
except ValueError as exc:
raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc
wallet = await get_wallet(data.wallet_id)
if not wallet or wallet.user != account_id:
raise HTTPException(HTTPStatus.FORBIDDEN, "Not your wallet.")
if wallet.is_lightning_shared_wallet:
raise HTTPException(
HTTPStatus.BAD_REQUEST,
"Background payments are not allowed from shared wallets.",
)
if not wallet.can_send_payments:
raise HTTPException(
HTTPStatus.BAD_REQUEST,
"This wallet cannot send payments.",
)
requested_grant = data.to_grant()
existing_grant = _find_background_payment_grant(
permissions, requested_grant.wallet_id
)
covered = (
existing_grant is not None
and existing_grant.enabled
and existing_grant.max_amount >= requested_grant.max_amount
and _background_destination_policy_covers(
existing_grant.destination_policy, requested_grant.destination_policy
)
)
grant = existing_grant if covered and existing_grant else requested_grant
return ExtensionPermissionCheckResult(
id=WALLET_PAY_INVOICE_BACKGROUND_PERMISSION,
approved=covered,
grant=json.loads(grant.json()),
)
async def _check_wallet_payments_watch_permission(
account_id: str,
permissions: dict,
grant_data: dict,
) -> ExtensionPermissionCheckResult:
try:
data = ExtensionWalletPaymentsWatchGrantRequest.parse_obj(grant_data)
except ValueError as exc:
raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc
wallet = await get_wallet(data.wallet_id)
if not wallet or wallet.user != account_id:
raise HTTPException(HTTPStatus.FORBIDDEN, "Not your wallet.")
existing_grant = _find_wallet_payments_watch_grant(permissions, data.wallet_id)
covered = bool(existing_grant and existing_grant.enabled)
grant = existing_grant if covered and existing_grant else data.to_grant()
return ExtensionPermissionCheckResult(
id=WALLET_PAYMENTS_WATCH_PERMISSION,
approved=covered,
grant=json.loads(grant.json()),
)
def _find_background_payment_grant(
permissions: dict, wallet_id: str
) -> ExtensionBackgroundPaymentGrant | None:
grants = permissions.get(WALLET_PAY_INVOICE_BACKGROUND_PERMISSION)
if not isinstance(grants, list):
return None
for grant_data in grants:
if not isinstance(grant_data, dict):
continue
try:
grant = ExtensionBackgroundPaymentGrant.parse_obj(grant_data)
except ValueError:
continue
if grant.wallet_id == wallet_id:
return grant
return None
def _find_wallet_payments_watch_grant(
permissions: dict, wallet_id: str
) -> ExtensionWalletPaymentsWatchGrant | None:
grants = permissions.get(WALLET_PAYMENTS_WATCH_PERMISSION)
if not isinstance(grants, list):
return None
for grant_data in grants:
if not isinstance(grant_data, dict):
continue
try:
grant = ExtensionWalletPaymentsWatchGrant.parse_obj(grant_data)
except ValueError:
continue
if grant.wallet_id == wallet_id:
return grant
return None
def _background_destination_policy_covers(
existing: ExtensionBackgroundPaymentDestinationPolicy,
requested: ExtensionBackgroundPaymentDestinationPolicy,
) -> bool:
return (
existing == requested
or existing == ExtensionBackgroundPaymentDestinationPolicy.EXTERNAL_ALLOWED
)
+6 -1
View File
@@ -189,6 +189,9 @@ admin_ui_checks = [Depends(check_admin), Depends(check_admin_ui)]
@generic_router.get("/audit", dependencies=admin_ui_checks)
@generic_router.get("/node", dependencies=admin_ui_checks)
@generic_router.get("/admin", dependencies=admin_ui_checks)
@generic_router.get("/admin/extensions/wasm", dependencies=admin_ui_checks)
@generic_router.get("/admin/extensions/wasm/limits", dependencies=admin_ui_checks)
@generic_router.get("/admin/extensions/wasm/{ext_id}", dependencies=admin_ui_checks)
@generic_router.get(
"/extensions/builder", dependencies=[Depends(check_extension_builder)]
)
@@ -196,7 +199,9 @@ admin_ui_checks = [Depends(check_admin), Depends(check_admin_ui)]
"/extensions/builder/preview", dependencies=[Depends(check_extension_builder)]
)
async def index(
request: Request, user: User = Depends(check_user_exists)
request: Request,
ext_id: str | None = None,
user: User = Depends(check_user_exists),
) -> HTMLResponse:
return template_renderer().TemplateResponse(
request,
+5 -4
View File
@@ -158,10 +158,6 @@ async def api_update_user(
async def api_users_delete_user(
user_id: str, account: Account = Depends(check_admin)
) -> SimpleStatus:
wallets = await get_wallets(user_id, deleted=False)
for wallet in wallets:
await delete_wallet_by_id(wallet.id)
if user_id == settings.super_user:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
@@ -173,6 +169,11 @@ async def api_users_delete_user(
status_code=HTTPStatus.BAD_REQUEST,
detail="Only super_user can delete admin user.",
)
wallets = await get_wallets(user_id, deleted=False)
for wallet in wallets:
await delete_wallet_by_id(wallet.id)
await delete_account(user_id)
return SimpleStatus(success=True, message="User deleted.")
+24
View File
@@ -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",
]
+22
View File
@@ -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,124 @@
from __future__ import annotations
from typing import Any, NoReturn
from bolt11 import decode as bolt11_decode
from loguru import logger
from lnbits.core.crud.extensions import get_user_extension
from lnbits.core.crud.payments import check_internal
from lnbits.core.crud.wallets import get_wallet
from lnbits.core.models.extensions import (
ExtensionBackgroundPaymentDestinationPolicy,
ExtensionBackgroundPaymentGrant,
)
from lnbits.core.models.wallets import Wallet
WALLET_PAY_INVOICE_BACKGROUND_PERMISSION = "wallet.pay_invoice_background"
async def background_payment_extra(
*,
extension_id: str,
wallet: Wallet,
payment_request: str,
amount_msat: int,
) -> dict[str, Any]:
grant = await _background_payment_grant(extension_id, wallet, amount_msat)
await _check_destination_policy(extension_id, wallet, grant, payment_request)
return {
"tag": extension_id,
"extension": extension_id,
"background_payment": True,
"background_permission": WALLET_PAY_INVOICE_BACKGROUND_PERMISSION,
"background_wallet_id": wallet.source_wallet_id,
"background_destination_policy": grant.destination_policy.value,
}
def invoice_amount_msat(payment_request: str) -> int:
invoice = bolt11_decode(payment_request)
amount_msat = int(invoice.amount_msat or 0)
if amount_msat <= 0:
raise PermissionError("Background payments require an invoice amount.")
return amount_msat
async def _background_payment_grant(
extension_id: str,
wallet: Wallet,
amount_msat: int,
) -> ExtensionBackgroundPaymentGrant:
if wallet.is_lightning_shared_wallet:
_deny(extension_id, wallet, amount_msat, "shared wallet")
if not wallet.can_send_payments:
_deny(extension_id, wallet, amount_msat, "wallet cannot send payments")
user_extension = await get_user_extension(wallet.user, extension_id)
if not user_extension or not user_extension.active:
_deny(extension_id, wallet, amount_msat, "extension disabled for user")
permissions = user_extension.permissions or {}
grants = permissions.get(WALLET_PAY_INVOICE_BACKGROUND_PERMISSION)
if not isinstance(grants, list):
_deny(extension_id, wallet, amount_msat, "missing background payment grant")
grant = _find_wallet_grant(grants, wallet.id)
if not grant:
_deny(extension_id, wallet, amount_msat, "missing wallet background grant")
if not grant.enabled:
_deny(extension_id, wallet, amount_msat, "background grant disabled")
if amount_msat > grant.max_amount * 1000:
_deny(extension_id, wallet, amount_msat, "payment exceeds max amount")
return grant
def _find_wallet_grant(
grants: list[Any], wallet_id: str
) -> ExtensionBackgroundPaymentGrant | None:
for grant_data in grants:
if not isinstance(grant_data, dict):
continue
try:
grant = ExtensionBackgroundPaymentGrant.parse_obj(grant_data)
except ValueError:
continue
if grant.wallet_id == wallet_id:
return grant
return None
async def _check_destination_policy(
extension_id: str,
wallet: Wallet,
grant: ExtensionBackgroundPaymentGrant,
payment_request: str,
) -> None:
if (
grant.destination_policy
== ExtensionBackgroundPaymentDestinationPolicy.EXTERNAL_ALLOWED
):
return
payment_hash = bolt11_decode(payment_request).payment_hash
internal_payment = await check_internal(payment_hash)
if not internal_payment:
_deny(extension_id, wallet, 0, "external destination not allowed")
destination_wallet = await get_wallet(internal_payment.wallet_id)
if not destination_wallet or destination_wallet.user != wallet.user:
_deny(extension_id, wallet, 0, "destination wallet is not owned by user")
def _deny(extension_id: str, wallet: Wallet, amount_msat: int, reason: str) -> NoReturn:
logger.warning(
"WASM extension '{}' denied background payment from wallet '{}', "
"user '{}', amount_msat '{}': {}.",
extension_id,
wallet.id,
wallet.user,
amount_msat,
reason,
)
raise PermissionError(reason)
+975
View File
@@ -0,0 +1,975 @@
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 (
OWNER_ID_FIELD,
storage_append_public_row,
storage_count_rows,
storage_delete_row,
storage_get_paginated_rows,
storage_get_public_paginated_rows,
storage_get_public_row,
storage_get_row,
storage_get_row_owner_id,
storage_set_row,
)
from .background_payments import (
WALLET_PAY_INVOICE_BACKGROUND_PERMISSION,
background_payment_extra,
invoice_amount_msat,
)
from .models import (
CreateInvoicePublicRequest,
CreateInvoiceRequest,
CreateInvoiceResponse,
EmptyRequest,
ExtensionApiRequest,
HttpRequest,
HttpResponse,
ListUserWalletsResponse,
LogRequest,
LogResponse,
NowResponse,
PayInvoiceRequest,
PayInvoiceResponse,
PayLnurlRequest,
RandomIdRequest,
RandomIdResponse,
StorageAppendPublicRequest,
StorageAppendPublicResponse,
StorageDeleteRequest,
StorageDeleteResponse,
StorageGetRequest,
StorageGetResponse,
StoragePaginatedRequest,
StoragePaginatedResponse,
StoragePublicPaginatedRequest,
StorageSetRequest,
StorageSetResponse,
UserWalletSummary,
WalletBalanceRequest,
WalletBalanceResponse,
WebsocketPublishRequest,
WebsocketPublishResponse,
)
from .registry import extension_api_method
from .websockets import scoped_websocket_item_id
logger = logging.getLogger("lnbits.extensions")
PUBLIC_APPEND_DEFAULT_MAX_ROWS_PER_SOURCE = 10_000
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,
invocation_id: str | None = None,
runtime_limits: dict[str, int] | 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
self.invocation_id = invocation_id
self.runtime_limits = runtime_limits or {}
from .utils import ExtensionAPIUtils
self.utils = ExtensionAPIUtils(
self.extension_id,
self.permissions,
authenticated=self.has_authenticated_context(),
)
@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_policy(request.table)["public_fields"]
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.append_public",
namespace="storage",
name="Append public storage row",
host_name="storage_append_public",
sdk_name="appendPublic",
description="Append one public row to an extension storage table.",
required_permission="ext.storage.append_public",
require_auth=False,
)
async def storage_append_public(
self, request: StorageAppendPublicRequest
) -> StorageAppendPublicResponse:
policy, owner_id = await self._public_storage_append_policy(
request.table, request.source_id
)
data = dict(request.data)
self._validate_public_append_data(policy, data)
source_id_field = policy["source_id_field"]
current_rows = await storage_count_rows(
self.extension_id,
request.table,
{source_id_field: request.source_id},
owner_id=owner_id,
)
if current_rows >= policy["max_rows_per_source"]:
raise PermissionError(
f"Public storage append limit reached for '{request.table}'."
)
row_id = await storage_append_public_row(
self.extension_id,
request.table,
{**data, source_id_field: request.source_id},
owner_id,
)
return StorageAppendPublicResponse(id=row_id)
@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.get_public_paginated",
namespace="storage",
name="Get paginated public storage rows",
host_name="storage_get_public_paginated",
sdk_name="getPublicPaginated",
description="Get filtered, searched, sorted, paginated public storage rows.",
required_permission="ext.storage.read_public",
require_auth=False,
)
async def storage_get_public_paginated(
self, request: StoragePublicPaginatedRequest
) -> StoragePaginatedResponse:
policy = self._public_storage_policy(request.table)
public_fields = policy["public_fields"]
source_id_field = policy["source_id_field"]
if not isinstance(source_id_field, str) or not source_id_field:
raise PermissionError(
"Public paginated storage reads require a source ID field policy."
)
filters = self._public_storage_paginated_filters(
request, public_fields, source_id_field
)
page = await storage_get_public_paginated_rows(
self.extension_id,
request.table,
filters,
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(
[
{
field_name: value
for field_name, value in row.items()
if field_name in public_fields
}
for row in 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="websocket.publish",
namespace="websocket",
name="Publish websocket message",
host_name="websocket_publish",
sdk_name="publish",
description="Publish a JSON message on an extension-local websocket channel.",
required_permission="websocket.publish",
require_auth=False,
)
async def websocket_publish(
self, request: WebsocketPublishRequest
) -> WebsocketPublishResponse:
from lnbits.core.services import websocket_manager
item_id = scoped_websocket_item_id(self.extension_id, request.item_id)
await websocket_manager.send(item_id, request.data_json)
return WebsocketPublishResponse()
@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.",
)
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
wallet = await get_wallet(request.wallet_id)
if wallet is None:
raise PermissionError("Paying invoices from this wallet is not allowed.")
try:
if self.user_id:
self.require_permission("wallet.pay_invoice")
if wallet.user != self.user_id:
raise PermissionError(
"Paying invoices from this wallet is not allowed."
)
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,
)
else:
self.require_permission(WALLET_PAY_INVOICE_BACKGROUND_PERMISSION)
amount_msat = invoice_amount_msat(request.payment_request)
extra = await background_payment_extra(
extension_id=self.extension_id,
wallet=wallet,
payment_request=request.payment_request,
amount_msat=amount_msat,
)
payment = await pay_invoice(
wallet_id=request.wallet_id,
payment_request=request.payment_request,
max_sat=request.max_sat,
extra={**request.extra, **extra},
description=request.description,
tag=self.extension_id,
)
except (PaymentError, PermissionError, ValueError) as exc:
return PayInvoiceResponse(ok=False, error=str(exc))
return _pay_invoice_response(payment)
@extension_api_method(
method_id="wallet.pay_lnurl",
namespace="wallet",
name="Pay LNURL",
host_name="pay_lnurl",
sdk_name="payLnurl",
description="Pay a Lightning Address or LNURL-pay request from a wallet.",
)
async def wallet_pay_lnurl(self, request: PayLnurlRequest) -> PayInvoiceResponse:
from lnurl import LnAddressError, LnurlResponseException
from lnbits.core.crud.wallets import get_wallet
from lnbits.core.models.lnurl import CreateLnurlPayment
from lnbits.core.services.lnurl import fetch_lnurl_pay_request
from lnbits.core.services.payments import pay_invoice
from lnbits.exceptions import PaymentError
from .lnurl import (
lnurl_for_core,
lnurl_pay_response_text,
lnurl_payment_amount_for_core,
lnurl_payment_unit_for_core,
)
wallet = await get_wallet(request.wallet_id)
if wallet is None:
raise PermissionError("Paying from this wallet is not allowed.")
try:
if self.user_id:
self.require_permission("wallet.pay_invoice")
if wallet.user != self.user_id:
raise PermissionError("Paying from this wallet is not allowed.")
else:
self.require_permission(WALLET_PAY_INVOICE_BACKGROUND_PERMISSION)
unit = lnurl_payment_unit_for_core(request.currency)
res, action = await fetch_lnurl_pay_request(
data=CreateLnurlPayment(
lnurl=lnurl_for_core(request.lnurl),
amount=lnurl_payment_amount_for_core(request.amount),
unit=unit,
comment=request.comment,
internal_memo=request.description or None,
),
wallet=None,
)
extra = {"tag": self.extension_id, **request.extra}
if action.successAction:
extra["success_action"] = action.successAction.json()
if request.comment:
extra["comment"] = request.comment
if unit != "sat":
extra["fiat_currency"] = unit
extra["fiat_amount"] = str(request.amount)
if not self.user_id:
amount_msat = invoice_amount_msat(str(action.pr))
extra = {
**extra,
**(
await background_payment_extra(
extension_id=self.extension_id,
wallet=wallet,
payment_request=str(action.pr),
amount_msat=amount_msat,
)
),
}
payment = await pay_invoice(
wallet_id=request.wallet_id,
payment_request=str(action.pr),
max_sat=request.max_sat,
extra=extra,
description=request.description or lnurl_pay_response_text(res),
tag=self.extension_id,
)
except (
LnAddressError,
LnurlResponseException,
PaymentError,
PermissionError,
ValueError,
) as exc:
return PayInvoiceResponse(ok=False, error=str(exc))
return _pay_invoice_response(payment)
@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,
timeout_ms=self.runtime_limits.get("wasm_runtime_http_timeout_ms"),
max_response_bytes=self.runtime_limits.get(
"wasm_runtime_max_http_response_bytes"
),
)
@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,
timeout_ms=self.runtime_limits.get("wasm_runtime_http_timeout_ms"),
max_response_bytes=self.runtime_limits.get(
"wasm_runtime_max_http_response_bytes"
),
)
@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_policy(self, table: str) -> dict[str, Any]:
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."
)
source_id_field = table_policy.get("source_id_field")
if source_id_field is not None and (
not isinstance(source_id_field, str) or not source_id_field
):
raise PermissionError(
f"Public storage table '{table}' has no valid source ID field."
)
return {
"public_fields": set(public_fields),
"source_id_field": source_id_field,
}
raise PermissionError(f"Storage table '{table}' is not publicly readable.")
def _validate_public_storage_query_fields(
self,
request: StoragePaginatedRequest,
public_fields: set[str],
allowed_private_fields: set[str] | None = None,
) -> None:
allowed_private_fields = allowed_private_fields or set()
query_fields = set(request.filters)
query_fields.update(request.search_fields)
if request.sort_by:
query_fields.add(request.sort_by)
private_fields = sorted(query_fields - public_fields - allowed_private_fields)
if private_fields:
raise PermissionError(
"Public storage query uses non-public fields: "
+ ", ".join(private_fields)
)
def _public_storage_paginated_filters(
self,
request: StoragePublicPaginatedRequest,
public_fields: set[str],
source_id_field: str,
) -> dict[str, Any]:
self._validate_public_storage_query_fields(
request, public_fields, {source_id_field}
)
filters = dict(request.filters)
requested_source_id = filters.get(source_id_field)
if requested_source_id is not None and requested_source_id != request.source_id:
raise PermissionError(
"Public storage source filter does not match source_id."
)
filters[source_id_field] = request.source_id
return filters
async def _public_storage_append_policy(
self, table: str, source_id: str
) -> tuple[dict[str, Any], str]:
policies = self.permission_policies.get("ext.storage.append_public")
if not isinstance(policies, list) or not policies:
raise PermissionError(
"Public storage appends require policies for "
"'ext.storage.append_public'."
)
source_not_found = False
for raw_policy in policies:
policy = self._normalize_public_storage_append_policy(raw_policy)
if policy["table"] != table:
continue
owner_id = await storage_get_row_owner_id(
self.extension_id,
policy["source_table"],
source_id,
)
if not owner_id:
source_not_found = True
continue
return policy, owner_id
if source_not_found:
raise PermissionError("Public storage append source was not found.")
raise PermissionError(f"Storage table '{table}' is not publicly appendable.")
def _normalize_public_storage_append_policy(self, policy: Any) -> dict[str, Any]:
if not isinstance(policy, dict):
raise PermissionError("Public storage append policies must be objects.")
table = policy.get("table")
source_table = policy.get("source_table")
source_id_field = policy.get("source_id_field")
allowed_fields = policy.get("allowed_fields")
max_rows_per_source = policy.get(
"max_rows_per_source", PUBLIC_APPEND_DEFAULT_MAX_ROWS_PER_SOURCE
)
if not isinstance(table, str) or not table:
raise PermissionError("Public storage append requires a table policy.")
if not isinstance(source_table, str) or not source_table:
raise PermissionError(
"Public storage append requires a source table policy."
)
if not isinstance(source_id_field, str) or not source_id_field:
raise PermissionError(
"Public storage append requires a source ID field policy."
)
if source_id_field == "id":
raise PermissionError("Public storage append source field cannot be 'id'.")
if (
not isinstance(allowed_fields, list)
or not all(isinstance(field, str) and field for field in allowed_fields)
or "id" in allowed_fields
or OWNER_ID_FIELD in allowed_fields
or source_id_field in allowed_fields
):
raise PermissionError(
"Public storage append requires valid allowed fields."
)
if (
isinstance(max_rows_per_source, bool)
or not isinstance(max_rows_per_source, int)
or max_rows_per_source <= 0
):
raise PermissionError(
"Public storage append requires a positive row limit."
)
return {
"table": table,
"source_table": source_table,
"source_id_field": source_id_field,
"allowed_fields": set(allowed_fields),
"max_rows_per_source": max_rows_per_source,
}
def _validate_public_append_data(
self, policy: dict[str, Any], data: dict[str, Any]
) -> None:
if not isinstance(data, dict):
raise PermissionError("Public storage append data must be an object.")
unknown_fields = sorted(set(data) - policy["allowed_fields"])
if unknown_fields:
raise PermissionError(
"Public storage append contains disallowed fields: "
+ ", ".join(unknown_fields)
)
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}"
")"
)
def _pay_invoice_response(payment: Any) -> PayInvoiceResponse:
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,
)
+70
View File
@@ -0,0 +1,70 @@
from __future__ import annotations
import json
from typing import Any
from lnurl import LnAddress, Lnurl
def normalize_lnurl(value: str) -> str:
normalized = value.strip()
if normalized.lower().startswith("lightning:"):
normalized = normalized[len("lightning:") :]
if "@" in normalized:
normalized = normalized.lower()
if not normalized:
raise ValueError("LNURL is required.")
return normalized
def lnurl_for_core(value: str) -> Lnurl | LnAddress:
normalized = normalize_lnurl(value)
if "@" in normalized:
return LnAddress(normalized)
return Lnurl(normalized)
def lnurl_payment_amount_for_core(amount: float) -> int:
if amount <= 0:
raise ValueError("Amount must be greater than zero.")
return round(amount * 1000)
def lnurl_payment_unit_for_core(currency: str) -> str:
unit = currency.strip().lower()
if not unit:
raise ValueError("Currency is required.")
if unit in {"sat", "sats"}:
return "sat"
return unit.upper()
def lnurl_pay_response_metadata_json(response: Any) -> str:
metadata = getattr(response, "metadata", None)
if metadata is None:
return "[]"
metadata_list = getattr(metadata, "list", None)
try:
if callable(metadata_list):
return json.dumps(metadata_list())
return json.dumps(metadata)
except TypeError:
return json.dumps(str(metadata))
def lnurl_pay_response_text(response: Any) -> str:
description = getattr(response, "description", None)
if description is not None:
return str(description)
metadata = getattr(response, "metadata", None)
text = getattr(metadata, "text", None)
return str(text) if text is not None else ""
def lnurl_pay_response_int(response: Any, snake_name: str, camel_name: str) -> int:
value = getattr(response, snake_name, None)
if value is None:
value = getattr(response, camel_name, 0)
return int(value or 0)
+440
View File
@@ -0,0 +1,440 @@
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 StorageAppendPublicRequest(BaseModel):
table: str = Field(..., min_length=1, max_length=128)
source_id: str = Field(..., min_length=1, max_length=512)
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 StorageAppendPublicResponse(BaseModel):
id: str
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 StoragePublicPaginatedRequest(StoragePaginatedRequest):
source_id: str = Field(..., min_length=1, max_length=512)
class StoragePaginatedResponse(BaseModel):
rows_json: str = "[]"
total: int = 0
class WebsocketPublishRequest(BaseModel):
item_id: str = Field(..., min_length=1, max_length=128)
data: 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
@root_validator
def validate_data_size(cls, values: dict[str, Any]) -> dict[str, Any]:
data = values.get("data")
try:
encoded = json.dumps(data, separators=(",", ":"))
except TypeError as exc:
raise ValueError("websocket data must be JSON serializable.") from exc
if len(encoded.encode()) > 65536:
raise ValueError("websocket data must not exceed 65536 bytes.")
values["data"] = data
return values
@property
def data_json(self) -> str:
return json.dumps(self.data, separators=(",", ":"))
class WebsocketPublishResponse(BaseModel):
sent: bool = True
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 PayLnurlRequest(BaseModel):
wallet_id: str = Field(..., min_length=1, max_length=128)
lnurl: str = Field(..., min_length=1, max_length=2048)
amount: float = Field(..., gt=0)
currency: str = Field("sat", min_length=1, max_length=8)
comment: str | None = Field(None, max_length=512)
description: str = Field("", max_length=512)
max_sat: int | None = Field(None, gt=0)
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 LnurlResolveRequest(BaseModel):
lnurl: str = Field(..., min_length=1, max_length=2048)
class LnurlResolveResponse(BaseModel):
lnurl: str
domain: str | None = None
description: str = ""
min_sendable_msat: int
max_sendable_msat: int
comment_allowed: int = 0
fixed: bool = False
image: str | None = None
metadata_json: str = "[]"
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
+333
View File
@@ -0,0 +1,333 @@
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
from lnbits.core.wasm_ext.client.http import _request_origin
from lnbits.core.wasm_ext.wasm.config import (
WasmExtensionConfig,
parse_wasm_extension_config,
)
_POLICY_AWARE_PERMISSION_IDS = {
"ext.storage.append_public",
"ext.storage.read_public",
"extension.api.request",
"http.request",
"wallet.create_invoice_public",
}
_OWNER_ID_FIELD = "__lnbits_owner_id__"
_PUBLIC_APPEND_DEFAULT_MAX_ROWS_PER_SOURCE = 10_000
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())
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] | WasmExtensionConfig,
) -> list[ExtensionPermission]:
if isinstance(extension_config, WasmExtensionConfig):
config = extension_config
elif extension_config.get("extension_type") != "wasm":
return []
else:
config = parse_wasm_extension_config(ext_info.id, extension_config)
requested_permissions = validate_extension_permissions(
ext_info.id, config.permissions
)
if not requested_permissions:
return []
if granted_permissions is None:
raise ValueError(f"Extension '{ext_info.id}' requires permission approval.")
granted_permissions = validate_extension_permissions(
ext_info.id,
granted_permissions,
)
requested_by_id = _permission_index(ext_info.id, requested_permissions, "requested")
granted_by_id = _permission_index(ext_info.id, granted_permissions, "granted")
extra_granted_ids = sorted(set(granted_by_id) - set(requested_by_id))
if extra_granted_ids:
raise ValueError(
f"Extension '{ext_info.id}' was granted unrequested permissions: "
+ ", ".join(extra_granted_ids)
)
effective_permissions: list[ExtensionPermission] = []
for permission_id, granted_permission in granted_by_id.items():
requested_permission = requested_by_id[permission_id]
if not _permission_grant_is_subset(requested_permission, granted_permission):
raise ValueError(
f"Extension '{ext_info.id}' was granted broader policies for "
f"permission '{permission_id}'."
)
effective_permissions.append(
requested_permission.copy(update={"policies": granted_permission.policies})
)
return effective_permissions
def _permission_index(
ext_id: str,
permissions: Iterable[ExtensionPermission],
source: str,
) -> dict[str, ExtensionPermission]:
indexed: dict[str, ExtensionPermission] = {}
duplicate_ids: list[str] = []
for permission in permissions:
if permission.id in indexed:
duplicate_ids.append(permission.id)
continue
indexed[permission.id] = permission
if duplicate_ids:
raise ValueError(
f"Extension '{ext_id}' has duplicate {source} permissions: "
+ ", ".join(sorted(set(duplicate_ids)))
)
return indexed
def _permission_grant_is_subset(
requested: ExtensionPermission,
granted: ExtensionPermission,
) -> bool:
if requested.id != granted.id:
return False
if requested.id not in _POLICY_AWARE_PERMISSION_IDS:
return True
if requested.id == "http.request":
return _http_request_grant_is_subset(requested.policies, granted.policies)
if requested.id == "extension.api.request":
return _extension_api_grant_is_subset(requested.policies, granted.policies)
if requested.id == "ext.storage.append_public":
return _public_storage_append_grant_is_subset(
requested.policies, granted.policies
)
if requested.id == "ext.storage.read_public":
return _public_storage_grant_is_subset(requested.policies, granted.policies)
if requested.id == "wallet.create_invoice_public":
return _public_invoice_grant_is_subset(requested.policies, granted.policies)
return False
def _policy_list(policies: list[Any] | None) -> list[Any]:
return policies if isinstance(policies, list) else []
def _http_request_grant_is_subset(
requested_policies: list[Any] | None,
granted_policies: list[Any] | None,
) -> bool:
return _http_origins(granted_policies).issubset(_http_origins(requested_policies))
def _http_origins(policies: list[Any] | None) -> set[str]:
origins: set[str] = set()
for policy in _policy_list(policies):
host = policy.get("host") if isinstance(policy, dict) else policy
if not isinstance(host, str) or not host:
continue
try:
origins.add(_request_origin(host))
except PermissionError:
continue
return origins
def _extension_api_grant_is_subset(
requested_policies: list[Any] | None,
granted_policies: list[Any] | None,
) -> bool:
requested_targets = _extension_api_targets(requested_policies)
granted_targets = _extension_api_targets(granted_policies)
for extension_id, granted_access in granted_targets.items():
requested_access = requested_targets.get(extension_id)
if requested_access is None or not granted_access.issubset(requested_access):
return False
return True
def _extension_api_targets(policies: list[Any] | None) -> dict[str, set[str]]:
targets: dict[str, set[str]] = {}
for policy in _policy_list(policies):
extension_id: str | None = None
access: list[Any] = []
if isinstance(policy, str):
extension_id = policy
access = ["read"]
elif isinstance(policy, dict):
raw_extension_id = policy.get("id")
raw_access = policy.get("access")
if isinstance(raw_extension_id, str) and isinstance(raw_access, list):
extension_id = raw_extension_id
access = raw_access
if not extension_id or extension_id in targets:
continue
clean_access = {
item
for item in access
if isinstance(item, str) and item in {"read", "write"}
}
if clean_access:
targets[extension_id] = clean_access
return targets
def _public_storage_grant_is_subset(
requested_policies: list[Any] | None,
granted_policies: list[Any] | None,
) -> bool:
requested_tables = _public_storage_tables(requested_policies)
granted_tables = _public_storage_tables(granted_policies)
for table_name, granted_policy in granted_tables.items():
requested_policy = requested_tables.get(table_name)
if requested_policy is None:
return False
if not granted_policy["public_fields"].issubset(
requested_policy["public_fields"]
):
return False
requested_source_id_field = requested_policy["source_id_field"]
if (
requested_source_id_field
and granted_policy["source_id_field"] != requested_source_id_field
):
return False
return True
def _public_storage_tables(policies: list[Any] | None) -> dict[str, dict[str, Any]]:
tables: dict[str, dict[str, Any]] = {}
for policy in _policy_list(policies):
if not isinstance(policy, dict):
continue
table_name = policy.get("table_name")
public_fields = policy.get("public_fields")
source_id_field = policy.get("source_id_field")
if (
not isinstance(table_name, str)
or table_name in tables
or not isinstance(public_fields, list)
or (
source_id_field is not None
and (not isinstance(source_id_field, str) or not source_id_field)
)
):
continue
fields = {field for field in public_fields if isinstance(field, str) and field}
if fields:
tables[table_name] = {
"public_fields": fields,
"source_id_field": source_id_field,
}
return tables
def _public_storage_append_grant_is_subset(
requested_policies: list[Any] | None,
granted_policies: list[Any] | None,
) -> bool:
requested_targets = _public_storage_append_targets(requested_policies)
granted_targets = _public_storage_append_targets(granted_policies)
for target, granted_policy in granted_targets.items():
requested_policy = requested_targets.get(target)
if requested_policy is None:
return False
if not granted_policy["allowed_fields"].issubset(
requested_policy["allowed_fields"]
):
return False
if (
granted_policy["max_rows_per_source"]
> requested_policy["max_rows_per_source"]
):
return False
return True
def _public_storage_append_targets(policies: list[Any] | None) -> dict[Any, dict]:
targets: dict[Any, dict] = {}
for policy in _policy_list(policies):
if not isinstance(policy, dict):
continue
table = policy.get("table")
source_table = policy.get("source_table")
source_id_field = policy.get("source_id_field")
allowed_fields = policy.get("allowed_fields")
max_rows_per_source = policy.get(
"max_rows_per_source", _PUBLIC_APPEND_DEFAULT_MAX_ROWS_PER_SOURCE
)
if (
not isinstance(table, str)
or not table
or not isinstance(source_table, str)
or not source_table
or not isinstance(source_id_field, str)
or not source_id_field
or source_id_field == "id"
or not isinstance(allowed_fields, list)
or isinstance(max_rows_per_source, bool)
or not isinstance(max_rows_per_source, int)
or max_rows_per_source <= 0
):
continue
fields = {field for field in allowed_fields if isinstance(field, str) and field}
if "id" in fields or _OWNER_ID_FIELD in fields or source_id_field in fields:
continue
targets[(table, source_table, source_id_field)] = {
"allowed_fields": fields,
"max_rows_per_source": max_rows_per_source,
}
return targets
def _public_invoice_grant_is_subset(
requested_policies: list[Any] | None,
granted_policies: list[Any] | None,
) -> bool:
return _public_invoice_sources(granted_policies).issubset(
_public_invoice_sources(requested_policies)
)
def _public_invoice_sources(policies: list[Any] | None) -> set[tuple[str, str]]:
sources: set[tuple[str, str]] = set()
for policy in _policy_list(policies):
if not isinstance(policy, dict):
continue
table = policy.get("table")
wallet_field = policy.get("wallet_field")
if isinstance(table, str) and table and isinstance(wallet_field, str):
sources.add((table, wallet_field))
return sources
+205
View File
@@ -0,0 +1,205 @@
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",
"wallet.pay_invoice",
"wallet.pay_invoice_background",
"wallet.payments.watch",
"websocket.subscribe",
}
_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)
+141
View File
@@ -0,0 +1,141 @@
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)
from lnbits.core.services.extensions import record_wasm_invocation_host_call
record_wasm_invocation_host_call(self.api.invocation_id, method.method_id)
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
+475
View File
@@ -0,0 +1,475 @@
from __future__ import annotations
import time
from collections.abc import Iterable
from datetime import datetime
from typing import Any
from lnurl import LnurlErrorResponse, LnurlPayResponse, LnurlResponseException
from lnurl import handle as lnurl_handle
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 .lnurl import (
lnurl_pay_response_int,
lnurl_pay_response_metadata_json,
lnurl_pay_response_text,
normalize_lnurl,
)
from .models import (
Bolt11Request,
CurrencyConvertRequest,
CurrencyConvertResponse,
CurrencyListResponse,
CurrencyRateRequest,
CurrencyRateResponse,
DecodeInvoiceResponse,
EmptyRequest,
FiatToSatsRequest,
FiatToSatsResponse,
InvoiceAmountMsatResponse,
InvoiceExpiryResponse,
InvoiceMemoResponse,
InvoicePaymentHashResponse,
LnurlResolveRequest,
LnurlResolveResponse,
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],
*,
authenticated: bool = False,
) -> None:
permission_set = set(permissions)
self.currencies = ExtensionCurrencyUtils(
extension_id, permission_set, authenticated=authenticated
)
self.server = ExtensionServerUtils(
extension_id, permission_set, authenticated=authenticated
)
self.lightning = ExtensionLightningUtils(
extension_id, permission_set, authenticated=authenticated
)
self.lnurl = ExtensionLnurlUtils(
extension_id, permission_set, authenticated=authenticated
)
class _ExtensionAPIUtilsGroup:
def __init__(
self,
extension_id: str,
permissions: Iterable[str],
*,
authenticated: bool = False,
) -> None:
self.extension_id = extension_id
self.permissions = set(permissions)
self.authenticated = authenticated
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 self.authenticated
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 ExtensionLnurlUtils(_ExtensionAPIUtilsGroup):
@extension_api_method(
method_id="utils.lnurl.resolve",
namespace="utils.lnurl",
name="Resolve LNURL-pay",
host_interface="utils-lnurl",
host_name="resolve",
sdk_name="resolve",
description="Resolve a Lightning Address or LNURL-pay request.",
required_permission="wallet.pay_invoice",
require_auth=True,
)
async def resolve(self, request: LnurlResolveRequest) -> LnurlResolveResponse:
normalized_lnurl = normalize_lnurl(request.lnurl)
response = await lnurl_handle(
normalized_lnurl,
user_agent=settings.user_agent,
timeout=5,
)
if isinstance(response, LnurlErrorResponse):
raise LnurlResponseException(response.reason)
if not isinstance(response, LnurlPayResponse):
raise LnurlResponseException(
"Invalid LNURL response. Expected LnurlPayResponse."
)
min_sendable_msat = lnurl_pay_response_int(
response, "min_sendable", "minSendable"
)
max_sendable_msat = lnurl_pay_response_int(
response, "max_sendable", "maxSendable"
)
image = getattr(response, "image", None)
return LnurlResolveResponse(
lnurl=normalized_lnurl,
domain=getattr(response, "domain", None),
description=lnurl_pay_response_text(response),
min_sendable_msat=min_sendable_msat,
max_sendable_msat=max_sendable_msat,
comment_allowed=lnurl_pay_response_int(
response, "comment_allowed", "commentAllowed"
),
fixed=bool(
getattr(
response,
"fixed",
min_sendable_msat == max_sendable_msat,
)
),
image=str(image) if image is not None else None,
metadata_json=lnurl_pay_response_metadata_json(response),
)
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.lnurl": ExtensionLnurlUtils,
"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
+17
View File
@@ -0,0 +1,17 @@
from __future__ import annotations
import re
_EXTENSION_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,128}$")
_LOCAL_ITEM_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9:_-]{0,127}$")
def scoped_websocket_item_id(extension_id: str, item_id: str) -> str:
if not _EXTENSION_ID_RE.fullmatch(extension_id):
raise ValueError("Extension websocket namespace is invalid.")
if not _LOCAL_ITEM_ID_RE.fullmatch(item_id):
raise ValueError(
"Extension websocket item ID must be 1-128 characters and contain "
"only letters, numbers, colon, underscore, or dash."
)
return f"ext:{extension_id}:{item_id}"
+4
View File
@@ -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"]
+219
View File
@@ -0,0 +1,219 @@
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,
*,
timeout_ms: int | None = None,
max_response_bytes: int | None = None,
) -> 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=_timeout_seconds(timeout_ms, 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,
max_response_bytes=max_response_bytes,
)
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,
*,
max_response_bytes: int | None = None,
) -> bytes:
limit = (
EXTENSION_API_MAX_RESPONSE_BYTES
if max_response_bytes is None
else max_response_bytes
)
chunks: list[bytes] = []
size = 0
async for chunk in response.aiter_bytes():
size += len(chunk)
if limit > 0 and size > limit:
raise ValueError("Extension API response is too large.")
chunks.append(chunk)
return b"".join(chunks)
def _timeout_seconds(timeout_ms: int | None, default: float) -> float | None:
if timeout_ms is None:
return default
if timeout_ms <= 0:
return None
return timeout_ms / 1000
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
}
+204
View File
@@ -0,0 +1,204 @@
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,
*,
timeout_ms: int | None = None,
max_response_bytes: int | None = None,
) -> 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=_timeout_seconds(timeout_ms, 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,
max_response_bytes=max_response_bytes,
)
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,
*,
max_response_bytes: int | None = None,
) -> bytes:
limit = (
HTTP_MAX_RESPONSE_BYTES if max_response_bytes is None else max_response_bytes
)
chunks: list[bytes] = []
size = 0
async for chunk in response.aiter_bytes():
size += len(chunk)
if limit > 0 and size > limit:
raise ValueError("HTTP response is too large.")
chunks.append(chunk)
return b"".join(chunks)
def _timeout_seconds(timeout_ms: int | None, default: float) -> float | None:
if timeout_ms is None:
return default
if timeout_ms <= 0:
return None
return timeout_ms / 1000
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
}
+3
View File
@@ -0,0 +1,3 @@
from .register import register_wasm_extension
__all__ = ["register_wasm_extension"]
+261
View File
@@ -0,0 +1,261 @@
from __future__ import annotations
import json
import re
from dataclasses import dataclass
from typing import Annotated, Any
from fastapi import Depends, FastAPI, HTTPException, Request
from lnbits.core.models import Account
from lnbits.core.services.extensions import get_wasm_runtime_limits_for_extension
from lnbits.decorators import check_access_token, check_account_exists
from lnbits.settings import settings
from ..wasm.config import WasmAPIRouteConfig
from ..wasm.invoke import invoke_wasm_extension_export
from ..wasm.loader import WasmExtension
class WasmRequestBodyTooLargeError(ValueError):
pass
@dataclass(frozen=True)
class WasmRoutePayload:
data: dict[str, Any]
request_bytes: int | None
def register_wasm_extension_api_routes(app: FastAPI, extension: WasmExtension) -> None:
for route_config in extension.config.api_routes:
_add_wasm_extension_api_route(app, extension, route_config)
def _add_wasm_extension_api_route(
app: FastAPI,
extension: WasmExtension,
route_config: WasmAPIRouteConfig,
) -> None:
method = _wasm_extension_api_method(extension, route_config.method)
route_path = _wasm_extension_api_path(extension, route_config.path)
export_name = _wasm_extension_api_export(extension, route_config.export)
path_params = route_config.path_params
auth = _wasm_extension_route_auth(extension, route_config.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:
limits = await get_wasm_runtime_limits_for_extension(extension.id)
payload = await _read_api_payload(
request,
path_params,
max_body_bytes=limits["wasm_runtime_max_request_bytes"],
)
return await invoke_wasm_extension_export(
extension.id,
export_name,
payload.data,
user=account,
access_token=access_token,
trigger_type="http",
method=request.method,
path=request.url.path,
request_id=request.headers.get("x-request-id"),
request_bytes=payload.request_bytes,
context_data={"origin": _request_origin(request)},
)
except WasmRequestBodyTooLargeError as exc:
raise HTTPException(status_code=413, detail=str(exc)) from exc
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],
*,
max_body_bytes: int,
) -> WasmRoutePayload:
payload = _read_api_path_params(request, path_params)
payload.update(_read_api_query_params(request))
request_bytes: int | None = None
if request.method in {"POST", "PUT", "PATCH"}:
body, request_bytes = await _read_json_object_with_size(
request,
max_body_bytes=max_body_bytes,
)
payload.update(body)
return WasmRoutePayload(payload, request_bytes)
async def _read_json_object(
request: Request,
*,
max_body_bytes: int | None = None,
) -> dict[str, Any]:
body, _ = await _read_json_object_with_size(
request,
max_body_bytes=(
settings.wasm_runtime_max_request_bytes
if max_body_bytes is None
else max_body_bytes
),
)
return body
async def _read_json_object_with_size(
request: Request,
*,
max_body_bytes: int,
) -> tuple[dict[str, Any], int]:
body = await _read_limited_body(request, max_body_bytes=max_body_bytes)
if not body:
return {}, 0
value = json.loads(body)
if not isinstance(value, dict):
raise TypeError("WASM extension API payload must be a JSON object.")
return value, len(body)
async def _read_limited_body(request: Request, *, max_body_bytes: int) -> bytes:
content_length = _request_content_length(request)
if _wasm_request_too_large(content_length, max_body_bytes):
raise WasmRequestBodyTooLargeError(
f"WASM extension request is too large: {content_length} bytes."
)
chunks: list[bytes] = []
size = 0
async for chunk in request.stream():
if not chunk:
continue
size += len(chunk)
if _wasm_request_too_large(size, max_body_bytes):
raise WasmRequestBodyTooLargeError(
f"WASM extension request is too large: {size} bytes."
)
chunks.append(chunk)
return b"".join(chunks)
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 _request_content_length(request: Request) -> int | None:
content_length = request.headers.get("content-length")
if content_length and content_length.isdigit():
return int(content_length)
return None
def _wasm_request_too_large(size: int | None, max_body_bytes: int) -> bool:
return size is not None and max_body_bytes > 0 and size > max_body_bytes
def _request_origin(request: Request) -> str | None:
origin = request.headers.get("origin")
if not origin:
return None
return origin[:256]
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.name != export_name:
continue
if export.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}$"
+139
View File
@@ -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
+32
View File
@@ -0,0 +1,32 @@
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)
core_app_extra.wasm_extension_registry.require_available(loaded)
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
+127
View File
@@ -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")
+369
View File
@@ -0,0 +1,369 @@
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 (
WasmRequestBodyTooLargeError,
_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.ui_routes):
route_path = _wasm_extension_ui_route_path(extension, route_config.path)
entrypoint = _wasm_extension_entrypoint(extension, route_config.entrypoint)
frame_path = f"/ext-frame/{extension.id}/{route_index}"
auth = _wasm_extension_route_auth(extension, route_config.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 WasmRequestBodyTooLargeError as exc:
raise HTTPException(status_code=413, detail=str(exc)) from exc
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.api_routes:
auth = _wasm_extension_route_auth(extension, route_config.auth)
if public and auth != "public":
continue
method = _wasm_extension_api_method(extension, route_config.method)
path = _wasm_extension_api_path(extension, route_config.path)
_wasm_extension_api_export(extension, route_config.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.ui_routes):
route_path = _wasm_extension_ui_route_path(extension, route_config.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.auth),
"path_params": route_config.path_params,
"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
+25
View File
@@ -0,0 +1,25 @@
from .crud import (
migrate_wasm_extension_database,
storage_append_public_row,
storage_count_rows,
storage_delete_row,
storage_get_paginated_rows,
storage_get_public_paginated_rows,
storage_get_public_row,
storage_get_row,
storage_get_row_owner_id,
storage_set_row,
)
__all__ = [
"migrate_wasm_extension_database",
"storage_append_public_row",
"storage_count_rows",
"storage_delete_row",
"storage_get_paginated_rows",
"storage_get_public_paginated_rows",
"storage_get_public_row",
"storage_get_row",
"storage_get_row_owner_id",
"storage_set_row",
]
+726
View File
@@ -0,0 +1,726 @@
from __future__ import annotations
import json
import re
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from uuid import uuid4
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, Compat, 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)
columns = list(clean_data.keys())
database = Database(f"ext_{ext_id}")
fields = _fields_by_name(table_schema)
placeholders = [
_value_placeholder(database, fields[column], column) for column in columns
]
clean_data[OWNER_ID_FIELD] = owner_id
columns.append(OWNER_ID_FIELD)
placeholders.append(f":{OWNER_ID_FIELD}")
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 storage_row.{OWNER_ID_FIELD} = :{OWNER_ID_FIELD}"
if updates
else "DO NOTHING"
)
query = f"""
INSERT INTO {_table_ref_for_schema(ext_id, table)} AS storage_row
({", ".join(columns)})
VALUES
({", ".join(placeholders)})
ON CONFLICT (id) {conflict_sql}
""" # noqa: S608
async with database.connect() as conn:
await conn.execute(query, clean_data)
async def storage_append_public_row(
ext_id: str,
table: str,
data: dict[str, Any],
owner_id: str,
) -> str:
row_id = uuid4().hex
await storage_set_row(ext_id, table, {**data, "id": row_id}, owner_id)
return row_id
async def storage_count_rows(
ext_id: str,
table: str,
filters: dict[str, Any],
*,
owner_id: str,
) -> int:
table_schema = _load_table_schema(ext_id, table)
database = Database(f"ext_{ext_id}")
where_sql, values = _where_sql(database, table_schema, filters, None, [])
where_sql = _append_owner_where_sql(where_sql)
values[OWNER_ID_FIELD] = owner_id
query = f"""
SELECT COUNT(*) AS count FROM {_table_ref_for_schema(ext_id, table)}
{where_sql}
""" # noqa: S608
async with database.connect() as conn:
row = await conn.fetchone(query, values)
return int(row["count"]) if row else 0
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)
database = Database(f"ext_{ext_id}")
where_sql, values = _where_sql(
database, 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.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_get_public_paginated_rows(
ext_id: str,
table: str,
filters: dict[str, Any],
*,
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)
database = Database(f"ext_{ext_id}")
where_sql, values = _where_sql(
database, table_schema, filters, search, search_fields
)
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.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 _value_placeholder(db: Compat, field: dict[str, Any], key: str) -> str:
if field.get("type") == "datetime" and not field.get("list"):
return db.timestamp_placeholder(key)
return f":{key}"
def _where_sql(
db: Compat,
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)
fields = _fields_by_name(table_schema)
clauses = [
f"{field} = {_value_placeholder(db, fields[field], f'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)
+13
View File
@@ -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",
]
+62
View File
@@ -0,0 +1,62 @@
from __future__ import annotations
from functools import lru_cache
from typing import Any
from wasmtime import Config, Engine
from lnbits.settings import settings
from .loader import WasmExtension
def warm_wasm_extension(extension: WasmExtension) -> None:
_wasm_component(extension)
@lru_cache(maxsize=8)
def _wasm_engine(max_wasm_stack_bytes: int | None = None) -> Any:
config = Config()
config.wasm_component_model = True
config.epoch_interruption = True
config.consume_fuel = True
stack_limit = (
settings.wasm_runtime_max_wasm_stack_bytes
if max_wasm_stack_bytes is None
else max_wasm_stack_bytes
)
if stack_limit > 0:
config.max_wasm_stack = stack_limit
return Engine(config)
def _wasm_component(
extension: WasmExtension,
limits: dict[str, int] | None = None,
) -> Any:
stat = extension.module_path.stat()
max_wasm_stack_bytes = (
limits["wasm_runtime_max_wasm_stack_bytes"]
if limits
else settings.wasm_runtime_max_wasm_stack_bytes
)
return _cached_wasm_component(
str(extension.module_path),
stat.st_mtime_ns,
stat.st_size,
max_wasm_stack_bytes,
)
@lru_cache(maxsize=32)
def _cached_wasm_component(
module_path: str,
mtime_ns: int,
size: int,
max_wasm_stack_bytes: int,
) -> Any:
from wasmtime import component
return component.Component.from_file(
_wasm_engine(max_wasm_stack_bytes), module_path
)
+115
View File
@@ -0,0 +1,115 @@
from __future__ import annotations
import re
from typing import Any, Literal
from pydantic import (
BaseModel,
Field,
StrictBool,
StrictStr,
ValidationError,
)
from lnbits.core.models.extensions import ExtensionPermission
_EXTENSION_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$")
class _StrictWasmModel(BaseModel):
class Config:
extra = "ignore"
allow_population_by_field_name = True
class WasmExtensionExport(_StrictWasmModel):
name: StrictStr
visibility: Literal["authenticated", "event", "public"]
class WasmRuntimeConfig(_StrictWasmModel):
module: StrictStr
wit: StrictStr | None = None
world: StrictStr = ""
exports: list[WasmExtensionExport] = Field(default_factory=list)
class WasmUIConfig(_StrictWasmModel):
entrypoint: StrictStr | None = None
sandbox: StrictBool | None = None
class WasmSDKConfig(_StrictWasmModel):
frontend_js: StrictStr | None = None
class WasmUIRouteConfig(_StrictWasmModel):
path: StrictStr
entrypoint: StrictStr
auth: Literal["public", "user"]
path_params: dict[str, StrictStr] = Field(default_factory=dict)
class WasmAPIRouteConfig(_StrictWasmModel):
method: Literal["DELETE", "GET", "PATCH", "POST", "PUT"]
path: StrictStr
export: StrictStr
auth: Literal["public", "user"]
path_params: dict[str, StrictStr] = Field(default_factory=dict)
class WasmEventsConfig(_StrictWasmModel):
on_invoice_paid: StrictStr | None = Field(None, alias="onInvoicePaid")
class WasmExtensionConfig(_StrictWasmModel):
id: StrictStr
name: StrictStr
short_description: StrictStr
tile: StrictStr | None = None
version: StrictStr
min_lnbits_version: StrictStr | None = None
max_lnbits_version: StrictStr | None = None
extension_type: Literal["wasm"]
wasm: WasmRuntimeConfig
events: WasmEventsConfig = Field(
default_factory=lambda: WasmEventsConfig.parse_obj({})
)
ui: WasmUIConfig | None = None
sdk: WasmSDKConfig | None = None
ui_routes: list[WasmUIRouteConfig] = Field(default_factory=list)
api_routes: list[WasmAPIRouteConfig] = Field(default_factory=list)
permissions: list[ExtensionPermission] = Field(default_factory=list)
def parse_wasm_extension_config(
ext_id: str,
config: dict[str, Any],
) -> WasmExtensionConfig:
validate_wasm_extension_config_id(ext_id, config)
try:
return WasmExtensionConfig.parse_obj(config)
except ValidationError as exc:
raise ValueError(
f"Invalid WASM extension config for '{ext_id}': {exc}"
) from exc
def validate_wasm_extension_config_id(
ext_id: str,
config: dict[str, Any] | WasmExtensionConfig,
) -> str:
if not _EXTENSION_ID_RE.fullmatch(ext_id):
raise ValueError(f"Invalid WASM extension id '{ext_id}'.")
config_id = (
config.id if isinstance(config, WasmExtensionConfig) else config.get("id")
)
if not isinstance(config_id, str) or not config_id:
raise ValueError(f"WASM extension '{ext_id}' config must define id.")
if config_id != ext_id:
raise ValueError(
f"WASM extension id mismatch: installed as '{ext_id}' "
f"but config declares '{config_id}'."
)
return config_id
+256
View File
@@ -0,0 +1,256 @@
import json
from collections.abc import Iterable
from typing import Any
from loguru import logger
from lnbits.core.crud.extensions import get_installed_extension, get_user_extensions
from lnbits.core.crud.wallets import get_wallet
from lnbits.core.db import core_app_extra
from lnbits.core.models.extensions import ExtensionWalletPaymentsWatchGrant
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
from lnbits.helpers import sha256s
WALLET_PAYMENTS_WATCH_PERMISSION = "wallet.payments.watch"
async def dispatch_wasm_invoice_paid(payment: Any) -> None:
targets: dict[str, tuple[Any, str | None]] = {}
extension_id = _payment_extension_id(payment)
if extension_id:
extension = core_app_extra.wasm_extension_registry.get(extension_id)
if extension:
targets[extension_id] = (
extension,
await _wasm_invoice_paid_owner_id(extension, payment),
)
wallet = await _payment_wallet(payment)
if wallet:
wallet_owner_id = sha256s(wallet.user)
for watch_extension_id in await _wallet_watch_extension_ids(
wallet.user, wallet.id
):
extension = core_app_extra.wasm_extension_registry.get(watch_extension_id)
if not extension:
continue
if watch_extension_id in targets:
existing_extension, existing_owner_id = targets[watch_extension_id]
targets[watch_extension_id] = (
existing_extension,
existing_owner_id or wallet_owner_id,
)
continue
targets[watch_extension_id] = (extension, wallet_owner_id)
for extension, owner_id in targets.values():
await _dispatch_wasm_invoice_paid_to_extension(extension, payment, owner_id)
async def _dispatch_wasm_invoice_paid_to_extension(
extension: Any, payment: Any, owner_id: str | None
) -> None:
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=owner_id,
trigger_type="event",
event_type="invoice_paid",
wallet_id=payment.wallet_id,
payment_hash=payment.payment_hash,
checking_id=payment.checking_id,
)
except Exception as exc:
logger.warning(
f"WASM extension '{extension.id}' failed to handle paid invoice "
f"'{payment.payment_hash}': {exc!s}"
)
async def _payment_wallet(payment: Any) -> Any | None:
wallet_id = getattr(payment, "wallet_id", None)
if not isinstance(wallet_id, str) or not wallet_id:
return None
try:
return await get_wallet(wallet_id)
except Exception as exc:
logger.warning(f"Could not fetch wallet '{wallet_id}' for WASM event: {exc!s}")
return None
async def _wallet_watch_extension_ids(user_id: str, wallet_id: str) -> list[str]:
try:
user_extensions = await get_user_extensions(user_id)
except Exception as exc:
logger.warning(
f"Could not fetch extensions for wallet payment watch user "
f"'{user_id}': {exc!s}"
)
return []
extension_ids: list[str] = []
for user_extension in user_extensions:
if not user_extension.active:
continue
if not _has_wallet_watch_grant(user_extension, wallet_id):
continue
if not core_app_extra.wasm_extension_registry.get(user_extension.extension):
continue
try:
installed_extension = await get_installed_extension(
user_extension.extension
)
except Exception as exc:
logger.warning(
f"Could not fetch installed extension '{user_extension.extension}' "
f"for wallet payment watch: {exc!s}"
)
continue
if not installed_extension or not installed_extension.active:
continue
if not _extension_has_permission(
installed_extension, WALLET_PAYMENTS_WATCH_PERMISSION
):
continue
extension_ids.append(user_extension.extension)
return extension_ids
def _has_wallet_watch_grant(user_extension: Any, wallet_id: str) -> bool:
permissions = user_extension.permissions or {}
grants = permissions.get(WALLET_PAYMENTS_WATCH_PERMISSION)
if not isinstance(grants, list):
return False
for grant_data in grants:
if not isinstance(grant_data, dict):
continue
try:
grant = ExtensionWalletPaymentsWatchGrant.parse_obj(grant_data)
except ValueError:
continue
if grant.enabled and grant.wallet_id == wallet_id:
return True
return False
def _extension_has_permission(extension: Any, permission_id: str) -> bool:
return any(
(
permission.get("id")
if isinstance(permission, dict)
else getattr(permission, "id", None)
)
== permission_id
for permission in (extension.permissions or [])
)
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 = await _wasm_public_invoice_source_tables(extension.id)
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
async def _wasm_public_invoice_source_tables(extension_id: str) -> list[str]:
installed_extension = await get_installed_extension(extension_id)
if not installed_extension:
return []
return _wasm_public_invoice_source_tables_from_permissions(
installed_extension.permissions
)
def _wasm_public_invoice_source_tables_from_permissions(
permissions: Iterable[Any],
) -> list[str]:
for permission in permissions:
permission_id = (
permission.get("id")
if isinstance(permission, dict)
else getattr(permission, "id", None)
)
if permission_id != "wallet.create_invoice_public":
continue
policies = (
permission.get("policies")
if isinstance(permission, dict)
else getattr(permission, "policies", None)
)
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: Any) -> str | None:
return config.events.on_invoice_paid
def _is_wasm_event_export(extension: Any, export_name: str) -> bool:
for export in extension.exports:
if export.name == export_name:
return export.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()),
}
+94
View File
@@ -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()
+279
View File
@@ -0,0 +1,279 @@
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 lnbits.settings import settings
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
_WASM_EPOCH_DEADLINE_TICKS = 1_000_000_000
_WASM_UNLIMITED_FUEL = 2**63 - 1
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,
trigger_type: str = "unknown",
request_id: str | None = None,
method: str | None = None,
path: str | None = None,
event_type: str | None = None,
wallet_id: str | None = None,
payment_hash: str | None = None,
checking_id: str | None = None,
request_bytes: int | None = None,
context_data: dict | None = None,
) -> dict[str, Any]:
from lnbits.core.services.extensions import (
finish_wasm_invocation,
get_wasm_invocation_stop_reason,
resolve_wasm_runtime_limits,
start_wasm_invocation,
stop_wasm_invocation,
wasm_invocation_stop_requested,
)
extension = _get_registered_extension(ext_id)
installed_extension = await _active_installed_extension(extension)
permissions = installed_extension.permissions
limits = resolve_wasm_runtime_limits(installed_extension)
payload = payload or {}
payload_size = _json_size(payload)
effective_request_bytes = (
request_bytes if request_bytes is not None else payload_size
)
_check_wasm_request_size(effective_request_bytes, limits)
invocation = await start_wasm_invocation(
extension_id=extension.id,
export_name=export_name,
trigger_type=trigger_type,
user_id=_user_id(user) or owner_id,
wallet_id=wallet_id,
request_id=request_id,
method=method,
path=path,
event_type=event_type,
payment_hash=payment_hash,
checking_id=checking_id,
request_bytes=effective_request_bytes,
context={"host_context": context, **(context_data or {})},
runtime_limits=limits,
)
api = ExtensionHostAPI(
extension.id,
permissions,
user_id=_user_id(user),
access_token=access_token,
context=context,
owner_id=owner_id,
invocation_id=invocation.id,
runtime_limits=limits,
)
event_loop = asyncio.get_running_loop()
thread_task = asyncio.create_task(
asyncio.to_thread(
_invoke_wasm_extension_export_sync,
extension,
export_name,
payload,
api,
event_loop,
invocation.id,
limits,
)
)
max_execution_ms = limits["wasm_runtime_max_execution_ms"]
timed_out = False
finished = False
try:
try:
if max_execution_ms > 0:
result = await asyncio.wait_for(
asyncio.shield(thread_task),
timeout=max_execution_ms / 1000,
)
else:
result = await thread_task
except asyncio.TimeoutError as exc:
timed_out = True
stop_reason = "WASM execution time limit exceeded."
await stop_wasm_invocation(invocation.id, reason=stop_reason)
try:
result = await asyncio.wait_for(
asyncio.shield(thread_task),
timeout=2,
)
except asyncio.TimeoutError:
await finish_wasm_invocation(
invocation.id,
status="timeout",
error_type="TimeoutError",
error_message=stop_reason,
stop_reason=stop_reason,
)
finished = True
raise TimeoutError(stop_reason) from exc
status = (
"timeout"
if timed_out
else (
"stopped"
if wasm_invocation_stop_requested(invocation.id)
else "completed"
)
)
await finish_wasm_invocation(
invocation.id,
status=status,
response_bytes=_json_size(result),
stop_reason=get_wasm_invocation_stop_reason(invocation.id),
)
finished = True
return result
except Exception as exc:
if not finished:
await finish_wasm_invocation(
invocation.id,
status=(
"timeout"
if timed_out
else (
"stopped"
if wasm_invocation_stop_requested(invocation.id)
else "failed"
)
),
error_type=exc.__class__.__name__,
error_message=str(exc),
stop_reason=get_wasm_invocation_stop_reason(invocation.id),
)
raise
def _invoke_wasm_extension_export_sync(
extension: WasmExtension,
export_name: str,
payload: Mapping[str, Any],
api: ExtensionHostAPI,
event_loop: asyncio.AbstractEventLoop,
invocation_id: str,
limits: dict[str, int],
) -> dict[str, Any]:
from lnbits.core.services.extensions import attach_wasm_invocation_runtime
engine = _wasm_engine(limits["wasm_runtime_max_wasm_stack_bytes"])
store = Store(engine)
_set_store_limits(store, limits)
_set_store_fuel(store, limits)
store.set_epoch_deadline(_WASM_EPOCH_DEADLINE_TICKS)
attach_wasm_invocation_runtime(invocation_id, engine=engine, store=store)
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, limits)
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(result, limits)
def _parse_wasm_export_result(value: Any, limits: dict[str, int]) -> dict[str, Any]:
if isinstance(value, bytes):
value = value.decode()
if not isinstance(value, str):
return {"ok": True, "data": value}
max_response_bytes = limits["wasm_runtime_max_response_bytes"]
if max_response_bytes > 0:
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 _active_installed_extension(extension: WasmExtension) -> Any:
installed_extension = await get_installed_extension(extension.id)
if (
not installed_extension
or settings.lnbits_extensions_deactivate_all
or not installed_extension.active
):
raise PermissionError(f"WASM extension '{extension.id}' is deactivated.")
return installed_extension
def _user_id(user: Any | None) -> str | None:
return getattr(user, "id", None) if user else None
def _set_store_limits(store: Any, limits: dict[str, int]) -> None:
store.set_limits(
memory_size=_wasm_limit(limits["wasm_runtime_max_memory_bytes"]),
table_elements=_wasm_limit(limits["wasm_runtime_max_table_elements"]),
instances=_wasm_limit(limits["wasm_runtime_max_instances"]),
tables=_wasm_limit(limits["wasm_runtime_max_tables"]),
memories=_wasm_limit(limits["wasm_runtime_max_memories"]),
)
def _set_store_fuel(store: Any, limits: dict[str, int]) -> None:
store.set_fuel(
limits["wasm_runtime_max_fuel"]
if limits["wasm_runtime_max_fuel"] > 0
else _WASM_UNLIMITED_FUEL
)
def _check_wasm_request_size(request_bytes: int, limits: dict[str, int]) -> None:
max_request_bytes = limits["wasm_runtime_max_request_bytes"]
if max_request_bytes > 0 and request_bytes > max_request_bytes:
raise ValueError(f"WASM extension request is too large: {request_bytes} bytes.")
def _wasm_limit(value: int) -> int:
return value if value > 0 else -1
def _json_size(value: Any) -> int:
return len(json.dumps(value, default=str).encode())
+107
View File
@@ -0,0 +1,107 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from lnbits.core.wasm_ext.wasm.config import (
WasmExtensionConfig,
WasmExtensionExport,
parse_wasm_extension_config,
)
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
exports: list[WasmExtensionExport]
config: WasmExtensionConfig
def is_wasm_extension_id(ext_id: str) -> bool:
ext_dir = Path(settings.lnbits_extensions_path, "extensions", ext_id)
config = _load_json(ext_dir / "config.json")
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) -> WasmExtensionConfig | None:
ext_dir = Path(settings.lnbits_extensions_path, "extensions", ext_id)
config = _load_json(ext_dir / "config.json")
if not config or config.get("extension_type") != "wasm":
return None
return parse_wasm_extension_config(ext_id, config)
def load_wasm_extension(ext_id: str) -> WasmExtension:
ext_dir = Path(settings.lnbits_extensions_path, "extensions", ext_id)
raw_config = _load_json(ext_dir / "config.json")
if not raw_config:
raise FileNotFoundError(f"Missing WASM extension config for '{ext_id}'.")
if raw_config.get("extension_type") != "wasm":
raise ValueError(f"Extension '{ext_id}' is not a WASM extension.")
config = parse_wasm_extension_config(ext_id, raw_config)
module_path = _extension_path(ext_dir, config.wasm.module)
wit_path = _optional_extension_path(ext_dir, config.wasm.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.id,
name=config.name,
version=config.version,
root_path=ext_dir,
module_path=module_path,
wit_path=wit_path,
world=config.wasm.world,
exports=config.wasm.exports,
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}")
+11 -4
View File
@@ -725,20 +725,20 @@ def dict_to_model(_row: dict, model: type[TModel]) -> TModel: # noqa: C901
if get_origin(outertype_) is list:
_items = _safe_load_json(value) if isinstance(value, str) else value
_dict[key] = [
dict_to_submodel(type_, v) if issubclass(type_, BaseModel) else v
dict_to_submodel(type_, v) if _is_subclass(type_, BaseModel) else v
for v in _items
]
continue
if issubclass(type_, bool):
if _is_subclass(type_, bool):
_dict[key] = bool(value)
continue
if issubclass(type_, datetime):
if _is_subclass(type_, datetime):
if DB_TYPE == SQLITE:
_dict[key] = datetime.fromtimestamp(value, timezone.utc)
else:
_dict[key] = value.replace(tzinfo=timezone.utc)
continue
if issubclass(type_, BaseModel):
if _is_subclass(type_, BaseModel):
_dict[key] = dict_to_submodel(type_, value)
continue
# TODO: remove this when all sub models are migrated to Pydantic
@@ -763,6 +763,13 @@ def _safe_load_json(value: str) -> dict:
return {}
def _is_subclass(type_: Any, class_or_tuple: type | tuple[type, ...]) -> bool:
try:
return issubclass(type_, class_or_tuple)
except TypeError:
return False
def _valid_sql_name(name: str) -> bool:
"""Check if a SQL name is valid (alphanumeric and underscores only)"""
return (
+10 -1
View File
@@ -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:
+10 -1
View File
@@ -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,
+10
View File
@@ -372,3 +372,13 @@ def sha256s(value: str) -> str:
Returns the hex as a string.
"""
return hashlib.sha256(value.encode("utf-8")).hexdigest()
def daystart_timestamp(dt: datetime | None = None) -> int:
"""
Returns the timestamp of the start of the day for the given
datetime (or now in UTC if not provided).
"""
dt = dt or datetime.now(timezone.utc)
day_start = dt.replace(hour=0, minute=0, second=0, microsecond=0)
return int(day_start.timestamp())
+78
View File
@@ -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)
+6
View File
@@ -27,6 +27,8 @@ from lnbits.settings import set_cli_settings, settings
@click.option(
"--reload", is_flag=True, default=False, help="Enable auto-reload for development"
)
@click.option("--ws-max-queue", default=128, help="Websocket max queue size")
@click.option("--ws-ping-timeout", default=60.0, help="Websocket ping timeout")
def main(
port: int,
host: str,
@@ -34,6 +36,8 @@ def main(
ssl_keyfile: str,
ssl_certfile: str,
reload: bool,
ws_max_queue: int,
ws_ping_timeout: float,
):
"""Launched with `uv run lnbits` at root level"""
@@ -58,6 +62,8 @@ def main(
ssl_keyfile=ssl_keyfile,
ssl_certfile=ssl_certfile,
reload=reload or False,
ws_ping_timeout=ws_ping_timeout,
ws_max_queue=ws_max_queue,
)
server = uvicorn.Server(config=config)
+44 -2
View File
@@ -60,6 +60,7 @@ class ExtensionsSettings(LNbitsSettings):
lnbits_user_default_extensions: list[str] = Field(default=[])
lnbits_extensions_deactivate_all: bool = Field(default=False)
lnbits_extensions_builder_activate_non_admins: bool = Field(default=False)
lnbits_wasm_invocation_retention_days: int = Field(default=7, ge=0)
lnbits_extensions_reviews_url: str = Field(
default="https://demo.lnbits.com/paidreviews/api/v1/AdFzLjzuKFLsdk4Bcnff6r",
description="""
@@ -81,6 +82,33 @@ class ExtensionsSettings(LNbitsSettings):
return Path(settings.lnbits_data_folder, "extensions_builder")
class WasmRuntimeLimits(LNbitsSettings):
# 0 disables the limit. Installed WASM extensions may override these defaults.
wasm_runtime_max_memory_bytes: int = Field(default=64 * 1024 * 1024, ge=0)
wasm_runtime_max_execution_ms: int = Field(default=5_000, ge=0)
wasm_runtime_max_fuel: int = Field(default=100_000_000, ge=0)
wasm_runtime_max_response_bytes: int = Field(default=1024 * 1024, ge=0)
wasm_runtime_max_request_bytes: int = Field(default=1024 * 1024, ge=0)
wasm_runtime_max_wasm_stack_bytes: int = Field(default=1024 * 1024, ge=0)
wasm_runtime_max_table_elements: int = Field(default=10_000, ge=0)
wasm_runtime_max_instances: int = Field(default=8, ge=0)
wasm_runtime_max_tables: int = Field(default=10, ge=0)
wasm_runtime_max_memories: int = Field(default=1, ge=0)
wasm_runtime_max_concurrent_invocations: int = Field(default=16, ge=0)
wasm_runtime_max_concurrent_invocations_per_extension: int = Field(default=4, ge=0)
wasm_runtime_max_concurrent_invocations_per_user: int = Field(default=4, ge=0)
wasm_runtime_max_host_calls: int = Field(default=1_000, ge=0)
wasm_runtime_max_http_calls: int = Field(default=20, ge=0)
wasm_runtime_max_storage_calls: int = Field(default=100, ge=0)
wasm_runtime_max_wallet_calls: int = Field(default=20, ge=0)
wasm_runtime_http_timeout_ms: int = Field(default=5_000, ge=0)
wasm_runtime_max_http_response_bytes: int = Field(default=1024 * 1024, ge=0)
class ExtensionsInstallSettings(LNbitsSettings):
lnbits_extensions_default_install: list[str] = Field(default=[])
# required due to GitHUb rate-limit
@@ -359,9 +387,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=[
@@ -494,6 +524,11 @@ class NotificationsSettings(LNbitsSettings):
and self.lnbits_telegram_notifications_access_token is not None
)
def is_email_notifications_configured(self) -> bool:
return self.lnbits_email_notifications_enabled and bool(
self.lnbits_email_notifications_email
)
class FakeWalletFundingSource(LNbitsSettings):
fake_wallet_secret: str = Field(default="ToTheMoon1")
@@ -587,6 +622,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 +647,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 +685,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):
@@ -1001,6 +1039,7 @@ class AuditSettings(LNbitsSettings):
class EditableSettings(
UsersSettings,
ExtensionsSettings,
WasmRuntimeLimits,
ThemesSettings,
OpsSettings,
AssetSettings,
@@ -1072,11 +1111,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:
@@ -1289,6 +1329,7 @@ class PublicSettings(BaseModel):
extensions_reviews_url: str = Field(alias="extensionsReviewsUrl")
ext_builder: bool = Field(alias="extBuilder")
nostr_configured: bool = Field(alias="nostrConfigured")
email_configured: bool = Field(alias="emailConfigured")
telegram_configured: bool = Field(alias="telegramConfigured")
wallet_featured_button_label: str | None = Field(alias="walletFeaturedButtonLabel")
wallet_featured_button_url: str | None = Field(alias="walletFeaturedButtonUrl")
@@ -1353,6 +1394,7 @@ class PublicSettings(BaseModel):
extensionsReviewsUrl=settings.lnbits_extensions_reviews_url,
extBuilder=settings.lnbits_extensions_builder_activate_non_admins,
nostrConfigured=settings.is_nostr_notifications_configured(),
emailConfigured=settings.is_email_notifications_configured(),
telegramConfigured=settings.is_telegram_notifications_configured(),
walletFeaturedButtonLabel=settings.lnbits_wallet_featured_button_label,
walletFeaturedButtonUrl=settings.lnbits_wallet_featured_button_url,
File diff suppressed because one or more lines are too long
+10 -10
View File
File diff suppressed because one or more lines are too long
+55
View File
@@ -116,6 +116,7 @@ window.localisation.en = {
read: 'Read',
write: 'Write',
pay: 'Pay',
sending: 'Sending',
memo: 'Memo',
date: 'Date',
path: 'Path',
@@ -184,6 +185,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 +520,59 @@ 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_tab: 'Extension Permissions',
user_permissions_tab: 'My Grants',
extension_permissions_none: 'This extension has no install-time permissions.',
user_permissions_none:
'You have not granted any permissions for this extension.',
user_permissions_max_amount: 'Max payment amount',
user_permissions_destination_policy: 'Allowed destinations',
user_permissions_no_editable_settings: 'This grant has no editable settings.',
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_wallet_pay_invoice_background:
'Can spend funds later from approved wallets without an active click.',
extension_permission_warning_wallet_payments_watch:
'Can read payment metadata for approved wallets.',
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_append_public:
'Append public extension storage',
extension_permission_ext_storage_append_public_sources:
'Allowed append targets',
extension_permission_ext_storage_read_public: 'Read public extension storage',
extension_permission_ext_storage_read_public_source_required:
'required to read',
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_websocket: 'Use extension websockets',
extension_permission_websocket_publish: 'Publish websocket messages',
extension_permission_websocket_subscribe: 'Subscribe to websocket messages',
extension_permission_wallet_payments_watch: 'Watch wallet 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',
extension_permission_wallet_pay_invoice_background:
'Make background payments',
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,380 @@
window.app.component('lnbits-admin-wasm-limit-config', {
props: ['form-data'],
template: '#lnbits-admin-wasm-limit-config',
data() {
return {
selectedWasmExtensionId: null,
wasmExtensionLimitDraft: {},
wasmExtensionLimitsSaving: false,
wasmRuntimeLimitExtensions: [],
wasmRuntimeLimitExtensionsLoading: false,
wasmLimitInfoDialog: {
show: false,
title: '',
details: ''
},
wasmRuntimeLimitGroups: [
{
title: 'Execution',
fields: [
{
name: 'wasm_runtime_max_execution_ms',
label: 'Max execution time (ms)',
description:
'Maximum wall-clock time allowed for one WASM invocation.',
details:
'This is the elapsed time from starting the invocation until the export returns. When the limit is reached LNbits requests an interrupt and records the invocation as timed out if it cannot finish quickly. Use this to stop long sleeps, slow host calls, and CPU loops that run for too long.'
},
{
name: 'wasm_runtime_max_fuel',
label: 'Max fuel',
description:
'Maximum Wasmtime instruction budget for one invocation.',
details:
'Fuel is Wasmtime instruction budgeting. It is more deterministic than wall-clock time for CPU-heavy loops because each executed instruction consumes budget. Set this low enough to stop busy loops, but high enough for legitimate extension startup and JSON processing.'
},
{
name: 'wasm_runtime_max_wasm_stack_bytes',
label: 'Max WASM stack (bytes)',
description: 'Maximum stack space for WASM calls and recursion.',
details:
'This limits stack used by WebAssembly function calls. It protects the server from deep recursion or very large call chains in extension code. If legitimate extensions fail with stack overflow traps, raise this carefully.'
}
]
},
{
title: 'Memory and Data Size',
fields: [
{
name: 'wasm_runtime_max_memory_bytes',
label: 'Max memory (bytes)',
description: 'Maximum WASM linear memory per invocation.',
details:
'This caps the linear memory visible to the WASM module. It limits memory.grow and can make instantiation fail if the module asks for too much memory up front. This does not include every byte used by the Python process or Wasmtime engine internals.'
},
{
name: 'wasm_runtime_max_request_bytes',
label: 'Max request size (bytes)',
description:
'Maximum serialized input payload accepted before execution.',
details:
'This caps the serialized payload passed into a WASM export before execution starts. It protects against huge HTTP bodies, oversized event data, and expensive JSON parsing. Requests above this limit should be rejected before invoking the extension.'
},
{
name: 'wasm_runtime_max_response_bytes',
label: 'Max response size (bytes)',
description:
'Maximum serialized response returned by a WASM export.',
details:
'This caps the JSON or string response returned by the WASM export. It prevents extensions from returning huge responses that consume memory, slow down API calls, or overload the browser. Responses above this limit are treated as invalid.'
}
]
},
{
title: 'Wasmtime Objects',
fields: [
{
name: 'wasm_runtime_max_table_elements',
label: 'Max table elements',
description: 'Maximum total elements allowed in WASM tables.',
details:
'Tables store references used by WebAssembly, commonly function references. Limiting table elements prevents a module from allocating very large reference tables. Each table element also has host memory overhead.'
},
{
name: 'wasm_runtime_max_instances',
label: 'Max instances',
description:
'Maximum WebAssembly instances allowed inside one store.',
details:
'This limits how many WebAssembly instances can be created inside one Wasmtime store. LNbits normally needs one instance per invocation, so a low value is expected. Raising it should only be needed if the runtime starts supporting modules that instantiate other modules.'
},
{
name: 'wasm_runtime_max_tables',
label: 'Max tables',
description:
'Maximum WebAssembly tables allowed inside one store.',
details:
'This limits the number of WebAssembly tables in the store. It is separate from table elements: one setting limits the number of tables, the other limits their total size. Keep this small unless a component model module legitimately needs more tables.'
},
{
name: 'wasm_runtime_max_memories',
label: 'Max memories',
description:
'Maximum WebAssembly linear memories allowed inside one store.',
details:
'This limits how many separate linear memories a module can create. Most extensions should need only one memory. Keep this small to reduce memory accounting complexity and prevent multi-memory abuse.'
}
]
},
{
title: 'Concurrency',
fields: [
{
name: 'wasm_runtime_max_concurrent_invocations',
label: 'Max concurrent invocations',
description:
'Maximum running WASM invocations across the server.',
details:
'This is the global cap for running WASM invocations across all extensions and users. It protects the LNbits process from thread exhaustion, CPU pressure, and too many simultaneous stores. New invocations should be rejected or queued once this is reached.'
},
{
name: 'wasm_runtime_max_concurrent_invocations_per_extension',
label: 'Max concurrent per extension',
description:
'Maximum running WASM invocations for one extension.',
details:
'This caps how many invocations a single extension can run at once. It prevents one malicious or buggy extension from consuming the whole global concurrency budget. Set it lower than the global limit.'
},
{
name: 'wasm_runtime_max_concurrent_invocations_per_user',
label: 'Max concurrent per user',
description: 'Maximum running WASM invocations for one user.',
details:
'This caps concurrent invocations attributed to one user. It helps protect against a user repeatedly clicking, refreshing, or scripting extension calls. Invocations without a user can still be governed by the global and per-extension limits.'
}
]
},
{
title: 'Host Calls',
fields: [
{
name: 'wasm_runtime_max_host_calls',
label: 'Max host calls',
description:
'Maximum total calls from WASM into LNbits host APIs.',
details:
'This is the total budget for calls from the WASM module into LNbits host APIs during one invocation. It should count all categories together. It limits chatty extensions and prevents tight loops that repeatedly call back into Python.'
},
{
name: 'wasm_runtime_max_http_calls',
label: 'Max HTTP calls',
description: 'Maximum outbound HTTP host calls per invocation.',
details:
'This caps outbound HTTP requests made through the host API during one invocation. It reduces SSRF blast radius, protects network resources, and limits slow external dependencies. It should be enforced together with HTTP timeout and response-size limits.'
},
{
name: 'wasm_runtime_max_storage_calls',
label: 'Max storage calls',
description: 'Maximum storage host calls per invocation.',
details:
'This caps extension storage operations during one invocation. It protects the database from excessive reads and writes triggered by malicious loops. Use it with storage payload-size limits if those are added later.'
},
{
name: 'wasm_runtime_max_wallet_calls',
label: 'Max wallet calls',
description: 'Maximum wallet/payment host calls per invocation.',
details:
'This caps wallet and payment-related host calls during one invocation. These calls are security-sensitive and may touch balances, invoices, or payments. Keep this conservative and rely on explicit permissions for what the extension is allowed to do.'
}
]
},
{
title: 'HTTP',
fields: [
{
name: 'wasm_runtime_http_timeout_ms',
label: 'HTTP timeout (ms)',
description: 'Maximum time allowed for one WASM HTTP request.',
details:
'This is the per-request timeout for HTTP calls made through the WASM host API. It prevents a slow remote server from holding an invocation open indefinitely. The total invocation timeout still applies across all work.'
},
{
name: 'wasm_runtime_max_http_response_bytes',
label: 'Max HTTP response size (bytes)',
description:
'Maximum response body size accepted from one WASM HTTP request.',
details:
'This caps the response body accepted from each HTTP call made by an extension. It protects memory and parsing time when a remote server returns a very large body. Responses above the limit should fail the host call.'
}
]
}
]
}
},
computed: {
adminKey() {
return this.g.user.wallets[0].adminkey
},
isExtensionLimitRoute() {
return this.$route.path.startsWith('/admin/extensions/wasm/limits/')
},
routeWasmExtensionId() {
return this.isExtensionLimitRoute ? this.$route.params.extId : null
},
backRoute() {
return this.isExtensionLimitRoute
? '/admin/extensions/wasm/limits'
: '/admin#extensions'
},
backTooltip() {
return this.isExtensionLimitRoute
? 'Wasm Limit Config'
: 'Extensions Settings'
},
pageDescription() {
if (this.isExtensionLimitRoute) {
return 'Customize limits for one installed WASM extension.'
}
return 'These values are global defaults. Use 0 to disable a global limit.'
},
wasmRuntimeLimitExtensionOptions() {
return this.wasmRuntimeLimitExtensions.map(extension => ({
label: `${extension.name || extension.id} (${extension.id})`,
value: extension.id
}))
},
selectedWasmRuntimeLimitExtension() {
return (
this.wasmRuntimeLimitExtensions.find(
extension => extension.id === this.selectedWasmExtensionId
) || null
)
},
customWasmLimitCount() {
const extension = this.selectedWasmRuntimeLimitExtension
if (!extension || !extension.wasm_runtime_limits) {
return 0
}
return Object.keys(extension.wasm_runtime_limits).length
}
},
watch: {
selectedWasmExtensionId() {
this.loadSelectedWasmRuntimeLimitExtension()
},
routeWasmExtensionId() {
this.syncWasmExtensionLimitRoute()
}
},
created() {
this.fetchWasmRuntimeLimitExtensions()
},
methods: {
async fetchWasmRuntimeLimitExtensions() {
this.wasmRuntimeLimitExtensionsLoading = true
try {
const {data} = await LNbits.api.request(
'GET',
'/api/v1/extension/wasm/runtime-limits/extensions',
this.adminKey
)
this.wasmRuntimeLimitExtensions = data || []
if (
this.selectedWasmExtensionId &&
!this.wasmRuntimeLimitExtensions.some(
extension => extension.id === this.selectedWasmExtensionId
)
) {
this.selectedWasmExtensionId = null
}
this.syncWasmExtensionLimitRoute()
} catch (error) {
LNbits.utils.notifyApiError(error)
} finally {
this.wasmRuntimeLimitExtensionsLoading = false
}
},
syncWasmExtensionLimitRoute() {
this.selectedWasmExtensionId = this.routeWasmExtensionId
this.loadSelectedWasmRuntimeLimitExtension()
},
openWasmExtensionLimit(extensionId) {
this.$router.push(
`/admin/extensions/wasm/limits/${encodeURIComponent(extensionId)}`
)
},
loadSelectedWasmRuntimeLimitExtension() {
const extension = this.selectedWasmRuntimeLimitExtension
this.wasmExtensionLimitDraft = extension
? {...(extension.wasm_runtime_limits || {})}
: {}
},
wasmExtensionLimitHint(field) {
const globalValue = this.formData[field.name]
return `Inherited global value: ${globalValue}. ${field.description}`
},
wasmExtensionLimitPlaceholder(field) {
const globalValue = this.formData[field.name]
return globalValue === undefined || globalValue === null
? ''
: String(globalValue)
},
normalizedWasmExtensionLimitDraft() {
const limits = {}
this.wasmRuntimeLimitGroups.forEach(group => {
group.fields.forEach(field => {
const value = this.wasmExtensionLimitDraft[field.name]
const cleanValue = typeof value === 'string' ? value.trim() : value
if (
cleanValue === '' ||
cleanValue === null ||
cleanValue === undefined
) {
return
}
const numericValue = Number(cleanValue)
if (
!Number.isFinite(numericValue) ||
!Number.isInteger(numericValue) ||
numericValue < 0
) {
throw new Error(`${field.label} must be a non-negative integer.`)
}
limits[field.name] = numericValue
})
})
return limits
},
async clearWasmExtensionLimits() {
this.wasmExtensionLimitDraft = {}
await this.saveWasmExtensionLimits()
},
async saveWasmExtensionLimits() {
if (!this.selectedWasmExtensionId) {
return
}
this.wasmExtensionLimitsSaving = true
try {
const {data} = await LNbits.api.request(
'PUT',
`/api/v1/extension/wasm/runtime-limits/${encodeURIComponent(
this.selectedWasmExtensionId
)}`,
this.adminKey,
{
limits: this.normalizedWasmExtensionLimitDraft()
}
)
const index = this.wasmRuntimeLimitExtensions.findIndex(
extension => extension.id === data.id
)
if (index >= 0) {
this.wasmRuntimeLimitExtensions.splice(index, 1, data)
}
this.loadSelectedWasmRuntimeLimitExtension()
Quasar.Notify.create({
type: 'positive',
message: 'WASM extension limits saved.'
})
} catch (error) {
if (error instanceof Error && !error.response) {
Quasar.Notify.create({
type: 'negative',
message: error.message
})
} else {
LNbits.utils.notifyApiError(error)
}
} finally {
this.wasmExtensionLimitsSaving = false
}
},
showWasmLimitInfo(field) {
this.wasmLimitInfoDialog = {
show: true,
title: field.label,
details: field.details
}
}
}
})
@@ -0,0 +1,380 @@
window.app.component('lnbits-admin-wasm-runtime', {
props: ['form-data'],
template: '#lnbits-admin-wasm-runtime',
data() {
return {
wasmRuntimeLoading: false,
wasmHistoryLoading: false,
wasmRuntimeTimer: null,
wasmStats: {},
wasmCurrentInvocations: [],
wasmInvocationHistory: [],
wasmStatItems: [
{key: 'total', label: 'Total', icon: 'data_usage', color: 'primary'},
{key: 'running', label: 'Running', icon: 'play_circle', color: 'green'},
{key: 'completed', label: 'Completed', icon: 'task_alt', color: 'teal'},
{key: 'failed', label: 'Failed', icon: 'error', color: 'red'},
{
key: 'stopped',
label: 'Stopped',
icon: 'stop_circle',
color: 'orange'
},
{key: 'timeout', label: 'Timeouts', icon: 'timer_off', color: 'purple'}
],
wasmCurrentColumns: [
{
name: 'extension_id',
label: 'Extension',
field: 'extension_id',
align: 'left',
sortable: true
},
{
name: 'export_name',
label: 'Export',
field: 'export_name',
align: 'left',
sortable: true
},
{
name: 'trigger_type',
label: 'Trigger',
field: 'trigger_type',
align: 'left',
sortable: true
},
{
name: 'status',
label: 'Status',
field: 'status',
align: 'left',
sortable: true
},
{
name: 'user_id',
label: 'User',
field: 'user_id',
align: 'left',
sortable: true
},
{
name: 'started_at',
label: 'Started',
field: 'started_at',
align: 'left',
sortable: true
},
{
name: 'duration_ms',
label: 'Duration',
field: row => row.duration_ms || 0,
align: 'right',
sortable: true
},
{
name: 'context',
label: 'Context',
field: row => this.wasmContextValue(row),
align: 'left',
sortable: true
},
{name: 'actions', label: '', field: 'actions', align: 'right'}
],
wasmHistoryColumns: [
{
name: 'extension_id',
label: 'Extension',
field: 'extension_id',
align: 'left',
sortable: true
},
{
name: 'export_name',
label: 'Export',
field: 'export_name',
align: 'left',
sortable: true
},
{
name: 'trigger_type',
label: 'Trigger',
field: 'trigger_type',
align: 'left',
sortable: true
},
{
name: 'status',
label: 'Status',
field: 'status',
align: 'left',
sortable: true
},
{
name: 'user_id',
label: 'User',
field: 'user_id',
align: 'left',
sortable: true
},
{
name: 'started_at',
label: 'Started',
field: 'started_at',
align: 'left',
sortable: true
},
{
name: 'duration_ms',
label: 'Duration',
field: row => row.duration_ms || 0,
align: 'right',
sortable: true
},
{
name: 'calls',
label: 'Calls',
field: row => this.wasmCallCount(row),
align: 'left',
sortable: true
},
{
name: 'context',
label: 'Context',
field: row => this.wasmContextValue(row),
align: 'left',
sortable: true
},
{
name: 'error_message',
label: 'Error/Stop Reason',
field: row => row.error_message || row.stop_reason || '',
align: 'left',
sortable: true
}
]
}
},
computed: {
adminKey() {
return this.g.user.wallets[0].adminkey
},
wasmExtensionId() {
return this.$route.params.extId || null
},
wasmExtensionQuery() {
if (!this.wasmExtensionId) {
return ''
}
return `extension_id=${encodeURIComponent(this.wasmExtensionId)}`
}
},
watch: {
wasmExtensionId() {
this.fetchWasmRuntime()
}
},
methods: {
async fetchWasmRuntime() {
await Promise.all([
this.fetchWasmCurrentInvocations(),
this.fetchWasmInvocationHistory(),
this.fetchWasmInvocationStats()
])
},
async fetchWasmCurrentInvocations() {
this.wasmRuntimeLoading = true
try {
const query = this.wasmExtensionQuery
? `?${this.wasmExtensionQuery}`
: ''
const {data} = await LNbits.api.request(
'GET',
`/api/v1/extension/wasm/invocations/current${query}`,
this.adminKey
)
this.wasmCurrentInvocations = data || []
} catch (error) {
LNbits.utils.notifyApiError(error)
} finally {
this.wasmRuntimeLoading = false
}
},
async fetchWasmInvocationHistory() {
this.wasmHistoryLoading = true
try {
const params = ['limit=50']
if (this.wasmExtensionQuery) {
params.push(this.wasmExtensionQuery)
}
const {data} = await LNbits.api.request(
'GET',
`/api/v1/extension/wasm/invocations?${params.join('&')}`,
this.adminKey
)
this.wasmInvocationHistory = data || []
} catch (error) {
LNbits.utils.notifyApiError(error)
} finally {
this.wasmHistoryLoading = false
}
},
async fetchWasmInvocationStats() {
try {
const params = ['hours=24']
if (this.wasmExtensionQuery) {
params.push(this.wasmExtensionQuery)
}
const {data} = await LNbits.api.request(
'GET',
`/api/v1/extension/wasm/invocations/stats?${params.join('&')}`,
this.adminKey
)
this.wasmStats = data || {}
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
async stopWasmInvocation(invocationId) {
try {
await LNbits.api.request(
'POST',
`/api/v1/extension/wasm/invocations/${encodeURIComponent(invocationId)}/stop`,
this.adminKey
)
Quasar.Notify.create({
type: 'positive',
message: 'WASM invocation stop requested.'
})
await this.fetchWasmRuntime()
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
deactivateWasmExtension(extensionId) {
LNbits.utils
.confirmDialog(
`Deactivate extension '${extensionId}'?`,
'Deactivate Extension'
)
.onOk(async () => {
try {
await LNbits.api.request(
'PUT',
`/api/v1/extension/${encodeURIComponent(extensionId)}/deactivate`,
this.adminKey
)
Quasar.Notify.create({
type: 'positive',
message: `Extension '${extensionId}' deactivated.`
})
await this.fetchWasmRuntime()
} catch (error) {
LNbits.utils.notifyApiError(error)
}
})
},
formatWasmStat(key) {
const value = this.wasmStats[key]
return value === undefined || value === null ? '0' : String(value)
},
formatWasmDate(value) {
return value ? this.utils.formatDate(value) : ''
},
wasmStatusColor(status) {
return (
{
running: 'green',
stopping: 'orange',
completed: 'teal',
failed: 'red',
stopped: 'orange',
timeout: 'purple',
abandoned: 'grey'
}[status] || 'grey'
)
},
wasmTriggerColor(triggerType) {
return (
{
http: 'primary',
event: 'purple'
}[triggerType] || 'grey'
)
},
formatWasmDuration(row) {
let duration = row.duration_ms
if (
(row.status === 'running' || row.status === 'stopping') &&
row.started_at
) {
duration = Date.now() - new Date(row.started_at).getTime()
}
if (duration === undefined || duration === null) {
return ''
}
if (duration >= 1000) {
return `${(duration / 1000).toFixed(1)}s`
}
return `${duration}ms`
},
formatWasmCalls(row) {
return [
`host ${row.host_call_count || 0}`,
`http ${row.http_call_count || 0}`,
`storage ${row.storage_call_count || 0}`,
`wallet ${row.wallet_call_count || 0}`
].join(' / ')
},
wasmCallCount(row) {
return (
(row.host_call_count || 0) +
(row.http_call_count || 0) +
(row.storage_call_count || 0) +
(row.wallet_call_count || 0)
)
},
wasmContextValue(row) {
return [
row.method,
row.path,
row.event_type,
row.wallet_id,
row.payment_hash
]
.filter(Boolean)
.join(' ')
},
formatWasmContext(row) {
const items = [
row.method,
row.path,
row.event_type,
row.wallet_id ? `wallet ${row.wallet_id}` : '',
row.payment_hash ? `payment ${row.payment_hash.slice(0, 12)}...` : ''
].filter(Boolean)
return items.join(' | ')
},
formatWasmUserId(userId) {
if (!userId) {
return '-'
}
const value = String(userId)
if (value.length <= 12) {
return value
}
return `${value.slice(0, 3)}...${value.slice(-3)}`
}
},
created() {
this.fetchWasmRuntime()
this.wasmRuntimeTimer = setInterval(() => {
this.fetchWasmCurrentInvocations()
}, 5000)
},
unmounted() {
if (this.wasmRuntimeTimer) {
clearInterval(this.wasmRuntimeTimer)
}
}
})
@@ -0,0 +1,425 @@
;(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, warningKey) {
return {
level: 'medium',
color: 'warning',
label: translate(translateFn, 'extension_permission_risk_medium'),
warning: warningKey ? translate(translateFn, warningKey) : ''
}
}
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 (
['wallet.pay_invoice', 'wallet.pay_invoice_background'].includes(
permission.id
)
) {
return highRisk(
translateFn,
permission.id === 'wallet.pay_invoice_background'
? 'extension_permission_warning_wallet_pay_invoice_background'
: '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 (permission.id === 'wallet.payments.watch') {
return mediumRisk(
translateFn,
'extension_permission_warning_wallet_payments_watch'
)
}
if (['websocket.publish', 'websocket.subscribe'].includes(permission.id)) {
return mediumRisk(translateFn)
}
if (
[
'wallet.list',
'wallet.balance.read',
'wallet.create_invoice_public',
'ext.storage.append_public',
'ext.storage.read_public'
].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.pay_invoice_background',
'wallet.payments.watch',
'wallet.list',
'wallet.balance.read',
'extension.api.request',
'http.request',
'ui.camera.scan_qr',
'websocket',
'websocket.publish',
'websocket.subscribe',
'ext.storage.read',
'ext.storage.write',
'ext.storage.read_public',
'ext.storage.append_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
)
const sourceIdField =
typeof table === 'string' ||
typeof table?.source_id_field !== 'string'
? ''
: table.source_id_field
return tableName ? {table: tableName, fields, sourceIdField} : 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 publicAppendPolicies(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 sourceTable = policy.source_table
const sourceIdField = policy.source_id_field
const allowedFields = Array.isArray(policy.allowed_fields)
? policy.allowed_fields.filter(
field => typeof field === 'string' && field
)
: []
const maxRowsPerSource = Number.isInteger(policy.max_rows_per_source)
? policy.max_rows_per_source
: 10000
if (typeof table !== 'string' || !table) return null
if (typeof sourceTable !== 'string' || !sourceTable) return null
if (typeof sourceIdField !== 'string' || !sourceIdField) return null
return {
table,
sourceTable,
sourceIdField,
allowedFields,
maxRowsPerSource
}
})
.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 isWebsocket =
permissions.every(permission =>
['websocket.publish', 'websocket.subscribe'].includes(permission.id)
) &&
permissions.some(permission => permission.id === 'websocket.publish') &&
permissions.some(permission => permission.id === 'websocket.subscribe')
const descriptions = permissions
.map(permission => permissionManifestDescription(permission))
.filter(Boolean)
const item = {
id: isReadWriteStorage
? 'ext.storage.read_write'
: isWebsocket
? 'websocket'
: permission.id,
label: isReadWriteStorage
? translate(translateFn, 'extension_permission_ext_storage_read_write')
: isWebsocket
? translate(translateFn, 'extension_permission_websocket')
: permissionLabel(permission, translateFn),
risk: permissionRisk(permissions, extensions, translateFn),
badges: [],
descriptions,
fieldGroups: [],
appendPolicies: [],
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)
}
if (permission.id === 'ext.storage.append_public') {
item.appendPolicies = publicAppendPolicies(permission)
item.badges = item.appendPolicies.map(policy => ({
key:
policy.table + ':' + policy.sourceTable + ':' + policy.sourceIdField,
label: policy.table
}))
}
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')
const hasWebsocket =
permissionsById.has('websocket.publish') &&
permissionsById.has('websocket.subscribe')
let addedReadWriteStorage = false
let addedWebsocket = 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')
]
}
}
if (
hasWebsocket &&
['websocket.publish', 'websocket.subscribe'].includes(permission.id)
) {
if (addedWebsocket) return null
addedWebsocket = true
return {
index,
orderId: 'websocket',
permissions: [
permissionsById.get('websocket.publish'),
permissionsById.get('websocket.subscribe')
]
}
}
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}.`
},
publicAppendPolicySentence(policy) {
return `${policy.table} rows can be appended for ${policy.sourceTable} using ${policy.sourceIdField}. Limit: ${policy.maxRowsPerSource} rows per source.`
},
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() {
+62
View File
@@ -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]
}
@@ -91,6 +99,26 @@ const routes = [
name: 'Users',
component: PageUsers
},
{
path: '/admin/extensions/wasm',
name: 'AdminWasmRuntime',
component: PageAdmin
},
{
path: '/admin/extensions/wasm/limits',
name: 'AdminWasmLimitConfig',
component: PageAdmin
},
{
path: '/admin/extensions/wasm/limits/:extId',
name: 'AdminWasmLimitConfigDetail',
component: PageAdmin
},
{
path: '/admin/extensions/wasm/:extId',
name: 'AdminWasmRuntimeDetail',
component: PageAdmin
},
{
path: '/admin',
name: 'Admin',
@@ -131,6 +159,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 +189,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() {
+35 -6
View File
@@ -16,18 +16,26 @@ window.PageAdmin = {
},
watch: {
tab(tab) {
this.$router.push(`/admin#${tab}`)
if (
['wasm-runtime', 'wasm-limit-config'].includes(tab) &&
this.$route.path.startsWith('/admin/extensions/wasm')
) {
return
}
const target = this.adminRouteForTab(tab)
if (this.$route.fullPath !== target) {
this.$router.push(target)
}
},
$route(to) {
if (to.hash.length > 1) {
this.tab = to.hash.replace('#', '')
const tab = this.adminTabFromRoute(to)
if (this.tab !== tab) {
this.tab = tab
}
}
},
async created() {
if (this.$route.hash.length > 1) {
this.tab = this.$route.hash.replace('#', '')
}
this.tab = this.adminTabFromRoute(this.$route)
await this.getSettings()
},
computed: {
@@ -36,6 +44,27 @@ window.PageAdmin = {
}
},
methods: {
adminTabFromRoute(route) {
if (route.path.startsWith('/admin/extensions/wasm/limits')) {
return 'wasm-limit-config'
}
if (route.path.startsWith('/admin/extensions/wasm')) {
return 'wasm-runtime'
}
if (route.hash.length > 1) {
return route.hash.replace('#', '')
}
return 'funding'
},
adminRouteForTab(tab) {
if (tab === 'wasm-runtime') {
return '/admin/extensions/wasm'
}
if (tab === 'wasm-limit-config') {
return '/admin/extensions/wasm/limits'
}
return `/admin#${tab}`
},
getDefaultSetting(fieldName) {
LNbits.api.getDefaultSetting(fieldName).then(response => {
this.formData[fieldName] = response.data.default_value
+355 -2
View File
@@ -10,6 +10,7 @@ window.PageExtensions = {
tab: 'installed',
manageExtensionTab: 'releases',
filteredExtensions: [],
categories: new Set(),
updatableExtensions: [],
showUninstallDialog: false,
showManageExtensionDialog: false,
@@ -23,6 +24,28 @@ window.PageExtensions = {
selectedExtensionDetails: null,
selectedExtensionRepos: null,
selectedRelease: null,
permissionGrant: {
show: false,
permissions: [],
resolve: null
},
managedExtensionPermissions: {
loading: false,
extensionPermissions: [],
userPermissions: {},
savingKey: '',
deletingKey: ''
},
backgroundPaymentDestinationOptions: [
{
label: 'Only transfers to my wallets',
value: 'own_wallets_only'
},
{
label: 'Allow external payments',
value: 'external_allowed'
}
],
uninstallAndDropDb: false,
maxStars: 5,
paylinkWebsocket: null,
@@ -92,6 +115,32 @@ window.PageExtensions = {
this.filterExtensions(this.searchTerm, val)
}
},
computed: {
managedUserPermissionRows() {
const rows = []
const userPermissions =
this.managedExtensionPermissions.userPermissions || {}
Object.entries(userPermissions).forEach(([permissionId, grants]) => {
if (!Array.isArray(grants)) return
grants.forEach(grant => {
if (!grant || typeof grant !== 'object') return
const grantId = String(grant.id || '')
const walletId = String(grant.wallet_id || '')
if (!grantId || !walletId) return
rows.push({
key: grantId,
permissionId,
label: this.permissionLabelById(permissionId),
grantId,
walletId,
walletName: this.walletName(walletId),
grant
})
})
})
return rows
}
},
methods: {
filterExtensions(term, tab) {
// Filter the extensions list
@@ -106,6 +155,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))
@@ -113,6 +166,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,
@@ -126,6 +180,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 =
@@ -137,7 +197,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
@@ -146,6 +207,12 @@ window.PageExtensions = {
)
extension.isAvailable = true
extension.isInstalled = true
extension.isWasm =
response.data.is_wasm === true ||
response.data.isWasm === true ||
release.extension_type === 'wasm' ||
extension.isWasm === true
extension.icon = response.data.icon || extension.icon
extension.installedRelease = release
this.toggleExtension(extension)
extension.inProgress = false
@@ -333,8 +400,18 @@ window.PageExtensions = {
this.selectedExtension = extension
this.selectedRelease = null
this.selectedExtensionRepos = null
this.manageExtensionTab = 'releases'
this.resetManagedExtensionPermissions()
this.manageExtensionTab = this.g.user.admin
? 'releases'
: 'extension-permissions'
this.showManageExtensionDialog = true
if (this.canManageExtensionPermissions(extension)) {
this.loadManagedExtensionPermissions(extension)
}
if (!this.g.user.admin) {
return
}
try {
const {data} = await LNbits.api.request(
@@ -372,6 +449,61 @@ window.PageExtensions = {
extension.inProgress = false
}
},
canShowManageExtensionButton(extension) {
return (
this.g.user.admin ||
(extension?.isWasm === true && extension?.isInstalled === true)
)
},
canManageExtensionPermissions(extension = this.selectedExtension) {
return extension?.isWasm === true && extension?.isInstalled === true
},
canShowAdminManageTabs() {
return this.g.user.admin === true
},
resetManagedExtensionPermissions() {
this.managedExtensionPermissions = {
loading: false,
extensionPermissions: [],
userPermissions: {},
savingKey: '',
deletingKey: ''
}
},
async loadManagedExtensionPermissions(extension = this.selectedExtension) {
if (!this.canManageExtensionPermissions(extension)) return
this.managedExtensionPermissions.loading = true
try {
const {data} = await LNbits.api.request(
'GET',
`/api/v1/extension/${extension.id}/permissions`
)
this.managedExtensionPermissions.extensionPermissions =
data.extension_permissions || []
this.managedExtensionPermissions.userPermissions =
this.cloneUserPermissions(data.user_permissions || {})
} catch (error) {
console.warn(error)
LNbits.utils.notifyApiError(error)
} finally {
this.managedExtensionPermissions.loading = false
}
},
cloneUserPermissions(userPermissions) {
const permissions = {}
Object.entries(userPermissions || {}).forEach(
([permissionId, grants]) => {
if (!Array.isArray(grants)) return
permissions[permissionId] = grants
.filter(grant => grant && typeof grant === 'object')
.map(grant => ({
...grant,
_original: {...grant}
}))
}
)
return permissions
},
async showExtensionDetails(extId, detailsLink) {
if (!detailsLink) {
@@ -400,6 +532,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(
@@ -445,6 +581,10 @@ window.PageExtensions = {
}
},
async showInstallQRCode(release) {
if ((await this.resolveExtensionPermissionGrant(release)) === null) {
return
}
this.selectedRelease = release
try {
@@ -607,6 +747,138 @@ window.PageExtensions = {
return ''
},
extensionOpenUrl(extension) {
return extension.isWasm ? `/ext/${extension.id}` : `/${extension.id}`
},
permissionLabelById(permissionId) {
const key = `extension_permission_${String(permissionId).replace(
/[^A-Za-z0-9]/g,
'_'
)}`
const label = this.$t(key)
return label === key ? permissionId : label
},
walletName(walletId) {
const wallet = (this.g.user.wallets || []).find(
wallet => wallet.id === walletId
)
return wallet ? wallet.name || wallet.id : walletId
},
userPermissionRowCaption(row) {
return `${row.walletName} (${row.walletId.slice(0, 8)}...)`
},
isBackgroundPaymentPermission(row) {
return row.permissionId === 'wallet.pay_invoice_background'
},
userPermissionGrantPayload(row) {
return {
wallet_id: row.walletId,
max_amount: this.positiveInteger(row.grant.max_amount, 0),
destination_policy: this.backgroundPaymentDestinationPolicy(
row.grant.destination_policy
)
}
},
positiveInteger(value, fallback) {
const number = Number(value)
if (!Number.isFinite(number) || number <= 0) return fallback
return Math.floor(number)
},
backgroundPaymentDestinationPolicy(value) {
return value === 'external_allowed'
? 'external_allowed'
: 'own_wallets_only'
},
backgroundPaymentGrantIncreased(row, payload) {
const original = row.grant._original || {}
const originalAmount = this.positiveInteger(original.max_amount, 0)
const originalPolicy = this.backgroundPaymentDestinationPolicy(
original.destination_policy
)
return (
payload.max_amount > originalAmount ||
(originalPolicy === 'own_wallets_only' &&
payload.destination_policy === 'external_allowed')
)
},
confirmUserPermissionIncrease() {
return new Promise(resolve => {
let resolved = false
const finish = value => {
if (resolved) return
resolved = true
resolve(value)
}
LNbits.utils
.confirmDialog(
'This increases what the extension can do with this wallet. Continue?'
)
.onOk(() => finish(true))
.onCancel(() => finish(false))
.onDismiss(() => finish(false))
})
},
async saveUserPermissionGrant(row) {
if (!this.isBackgroundPaymentPermission(row)) return
const payload = this.userPermissionGrantPayload(row)
if (!payload.max_amount) {
Quasar.Notify.create({
type: 'negative',
message: 'Max payment amount must be greater than zero.'
})
return
}
if (
this.backgroundPaymentGrantIncreased(row, payload) &&
!(await this.confirmUserPermissionIncrease())
) {
return
}
this.managedExtensionPermissions.savingKey = row.key
try {
await LNbits.api.request(
'POST',
`/api/v1/extension/${this.selectedExtension.id}/permissions/background-payment`,
null,
payload
)
Quasar.Notify.create({
type: 'positive',
message: 'Permission updated.'
})
await this.loadManagedExtensionPermissions()
} catch (error) {
console.warn(error)
LNbits.utils.notifyApiError(error)
} finally {
this.managedExtensionPermissions.savingKey = ''
}
},
deleteUserPermissionGrant(row) {
LNbits.utils
.confirmDialog('Remove this permission grant?')
.onOk(async () => {
this.managedExtensionPermissions.deletingKey = row.key
try {
const grantId = encodeURIComponent(row.grantId)
await LNbits.api.request(
'DELETE',
`/api/v1/extension/${this.selectedExtension.id}/permissions/user/${grantId}`
)
Quasar.Notify.create({
type: 'positive',
message: 'Permission removed.'
})
await this.loadManagedExtensionPermissions()
} catch (error) {
console.warn(error)
LNbits.utils.notifyApiError(error)
} finally {
this.managedExtensionPermissions.deletingKey = ''
}
})
},
async getGitHubReleaseDetails(release) {
if (!release.is_github_release || release.loaded) {
return
@@ -622,6 +894,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
@@ -630,6 +904,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))
},
@@ -640,6 +983,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,
@@ -832,6 +1182,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)
+54 -14
View File
@@ -7,6 +7,7 @@ window.PageWallet = {
invoice: null,
lnurlpay: null,
lnurlauth: null,
sending: false,
data: {
request: '',
amount: 0,
@@ -131,6 +132,7 @@ window.PageWallet = {
this.parse.data.request = ''
this.parse.data.comment = ''
this.parse.data.internalMemo = null
this.parse.sending = false
this.parse.data.paymentChecker = null
this.parse.camera.show = false
},
@@ -223,6 +225,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') {
@@ -356,6 +364,9 @@ window.PageWallet = {
this.parse.invoice = Object.freeze(cleanInvoice)
},
payInvoice() {
if (this.parse.sending) return
this.parse.sending = true
const dismissPaymentMsg = Quasar.Notify.create({
timeout: 0,
message: this.$t('payment_processing')
@@ -368,6 +379,7 @@ window.PageWallet = {
this.parse.data.internalMemo
)
.then(response => {
this.parse.sending = false
dismissPaymentMsg()
this.g.updatePayments = !this.g.updatePayments
this.parse.show = false
@@ -385,13 +397,16 @@ window.PageWallet = {
}
})
.catch(err => {
this.parse.sending = false
dismissPaymentMsg()
LNbits.utils.notifyApiError(err)
this.g.updatePayments = !this.g.updatePayments
this.parse.show = false
})
},
payLnurl() {
if (this.parse.sending) return
this.parse.sending = true
LNbits.api
.request('post', '/api/v1/payments/lnurl', this.g.wallet.adminkey, {
res: this.parse.lnurlpay,
@@ -402,18 +417,26 @@ window.PageWallet = {
internalMemo: this.parse.data.internalMemo
})
.then(response => {
this.parse.sending = false
this.parse.show = false
if (response.data.extra.success_action) {
const action = JSON.parse(response.data.extra.success_action)
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,19 +448,36 @@ 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
}
}
})
.catch(LNbits.utils.notifyApiError)
.catch(err => {
this.parse.sending = false
LNbits.utils.notifyApiError(err)
})
},
authLnurl() {
const dismissAuthMsg = Quasar.Notify.create({
+36
View File
@@ -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')
}
}
File diff suppressed because it is too large Load Diff
+5
View File
@@ -58,6 +58,7 @@
"js/pages/users.js",
"js/pages/account.js",
"js/pages/admin.js",
"js/components/admin/lnbits-admin-funding-seed-backup.js",
"js/components/admin/lnbits-admin-funding.js",
"js/components/admin/lnbits-admin-funding-sources.js",
"js/components/admin/lnbits-admin-fiat-providers.js",
@@ -66,6 +67,8 @@
"js/components/admin/lnbits-admin-users.js",
"js/components/admin/lnbits-admin-server.js",
"js/components/admin/lnbits-admin-extensions.js",
"js/components/admin/lnbits-admin-wasm-runtime.js",
"js/components/admin/lnbits-admin-wasm-limit-config.js",
"js/components/admin/lnbits-admin-notifications.js",
"js/components/admin/lnbits-admin-site-customisation.js",
"js/components/admin/lnbits-admin-assets-config.js",
@@ -89,6 +92,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 +100,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"]
+200
View File
@@ -0,0 +1,200 @@
import asyncio
import traceback
import uuid
from collections.abc import Callable, Coroutine
from datetime import datetime, timezone
from loguru import logger
from pydantic import BaseModel
from lnbits.core.models import Payment
from lnbits.settings import settings
class PublicTask(BaseModel):
"""Public model used to expose task information via the API."""
name: str
created_at: datetime
class Task:
"""Model used on the backend to keep track of background tasks."""
coro: Coroutine
name: str
created_at: datetime
task: asyncio.Task
invoice_queue: asyncio.Queue[Payment] | None = None
def __init__(
self,
coro: Coroutine,
name: str | None = None,
invoice_queue: asyncio.Queue | None = None,
) -> None:
self.coro = coro
self.name = name or f"task_{uuid.uuid4()}"
self.created_at = datetime.now(timezone.utc)
self.task = asyncio.create_task(self.coro, name=self.name)
self.invoice_queue = invoice_queue
class TaskManager:
"""Singleton class to manage background tasks."""
tasks: list[Task] = []
invoice_queue: asyncio.Queue[Payment] = asyncio.Queue()
internal_invoice_queue: asyncio.Queue[Payment] = asyncio.Queue()
def init(self) -> None:
self.create_permanent_task(
func=self._heart_beat,
interval=settings.task_heart_beat_interval,
)
self.create_permanent_task(self._invoice_listener_consumer)
self.create_permanent_task(self._internal_invoice_listener_consumer)
def get_task(self, name: str) -> Task | None:
"""Get a running task by name."""
for task in self.tasks:
if task.name == name:
return task
return None
def get_public_tasks(self) -> list[PublicTask]:
"""Get a list of public tasks."""
return [PublicTask(name=t.name, created_at=t.created_at) for t in self.tasks]
def cancel_task(self, task: Task) -> None:
"""Cancel a running task."""
self.tasks.remove(task)
try:
task.task.cancel()
except Exception as exc:
logger.warning(f"error while cancelling task `{task.name}`: {exc!s}")
def cancel_all_tasks(self) -> None:
"""Cancel all running tasks."""
for task in list(self.tasks):
self.cancel_task(task)
def create_task(
self,
coro: Coroutine,
name: str | None = None,
invoice_queue: asyncio.Queue | None = None,
) -> Task:
"""Create a task. If a task with the same name exists, it will be cancelled."""
if name:
task = self.get_task(name)
if task:
self.cancel_task(task)
task = Task(coro=coro, name=name, invoice_queue=invoice_queue)
self.tasks.append(task)
return task
def create_permanent_task(
self,
func: Callable[[], Coroutine],
invoice_queue: asyncio.Queue | None = None,
name: str | None = None,
interval: int = 0,
) -> Task:
"""Create a task that runs forever and restarts on failure."""
async def wrapper():
while settings.lnbits_running:
await self._catch_everything_and_restart(func)
if interval > 0:
await asyncio.sleep(interval)
return self.create_task(
coro=wrapper(), name=name or func.__name__, invoice_queue=invoice_queue
)
def register_invoice_listener(
self,
func: Callable[[Payment], Coroutine],
name: str | None = None,
) -> Task:
"""
A method intended for extensions to call when they want to be notified about
incoming payments. Will call provided Coroutine with the updated payment.
"""
name = f"{name or uuid.uuid4()}_invoice_listener"
queue: asyncio.Queue[Payment] = asyncio.Queue()
return self.create_permanent_task(
self._invoice_listener_worker(func, queue),
name=name,
invoice_queue=queue,
)
async def _heart_beat(self) -> None:
"""A heartbeat that removes done tasks logs the number of tasks."""
for task in self.tasks:
state = task.task._state if task.task else "NOT RUNNING"
if settings.task_heart_beat_verbose:
logger.debug(
f"Task Manager: `{task.name}` state: `{state}` "
f"created: {task.created_at.strftime('%Y-%m-%d %H:%M:%S')}`"
)
if task.task and task.task.done():
logger.debug(f"Task Manager: task `{task.name}` is done.")
self.cancel_task(task)
listeners_count = sum(1 for task in self.tasks if task.invoice_queue)
logger.debug(
f"Task Manager: {len(self.tasks) - listeners_count} tasks "
f"and {listeners_count} invoice listeners."
)
async def _catch_everything_and_restart(
self,
func: Callable[[], Coroutine],
restart_interval: int = 5,
) -> None:
"""Catches all exceptions from a function and restarts it after 5 seconds."""
while settings.lnbits_running:
try:
return await func()
except asyncio.CancelledError:
raise # because we must pass this up
except Exception as exc:
if not settings.lnbits_running:
return
logger.error(f"exception in background task `{func.__name__}`:", exc)
logger.error(traceback.format_exc())
logger.info(
f"`{func.__name__}` restarts in {restart_interval} seconds."
)
await asyncio.sleep(restart_interval)
def _invoice_listener_worker(
self, func: Callable[[Payment], Coroutine], queue: asyncio.Queue[Payment]
) -> Callable:
async def wrapper() -> None:
payment: Payment = await queue.get()
await func(payment)
return wrapper
def _invoice_dispatcher(self, payment: Payment) -> None:
"""Dispatches a payment to all registered invoice listeners."""
for task in self.tasks:
if not task.invoice_queue:
continue
logger.debug(f"Enqueing payment to task {task.name}")
task.invoice_queue.put_nowait(payment)
async def _invoice_listener_consumer(self) -> None:
payment = await self.invoice_queue.get()
logger.info(f"got a payment notification {payment.checking_id}")
self._invoice_dispatcher(payment)
async def _internal_invoice_listener_consumer(self) -> None:
payment = await self.internal_invoice_queue.get()
logger.info(f"got an internal payment notification {payment.checking_id}")
self._invoice_dispatcher(payment)
task_manager = TaskManager()
+33 -117
View File
@@ -1,143 +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
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()
@@ -147,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)
+17 -3
View File
@@ -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
+5 -1
View File
@@ -1,4 +1,5 @@
{% include('components/admin/funding.vue') %} {%
{% include('components/admin/funding_seed_backup.vue') %} {%
include('components/admin/funding.vue') %} {%
include('components/admin/funding_sources.vue') %} {%
include('components/admin/fiat_providers.vue') %} {%
include('components/admin/exchange_providers.vue') %} {%
@@ -7,6 +8,8 @@ include('components/admin/users.vue') %} {%
include('components/admin/site_customisation.vue') %} {%
include('components/admin/audit.vue') %} {%
include('components/admin/extensions.vue') %} {%
include('components/admin/wasm-runtime.vue') %} {%
include('components/admin/wasm-limit-config.vue') %} {%
include('components/admin/assets-config.vue') %} {%
include('components/admin/notifications.vue') %} {%
include('components/admin/server.vue') %} {%
@@ -19,6 +22,7 @@ include('components/lnbits-header-wallets.vue') %} {%
include('components/lnbits-drawer.vue') %} {%
include('components/lnbits-home-logos.vue') %} {%
include('components/lnbits-manage-extension-list.vue') %} {%
include('components/lnbits-extension-permissions.vue') %} {%
include('components/lnbits-manage-wallet-list.vue') %} {%
include('components/lnbits-language-dropdown.vue') %} {%
include('components/lnbits-payment-list.vue') %} {%
@@ -1,7 +1,46 @@
<template id="lnbits-admin-exchange-providers">
<h6 class="q-my-none q-mb-sm">
<span v-text="$t('exchange_providers')"></span>
</h6>
<h6 class="q-my-none q-mb-xs">LNbits Price Aggregator</h6>
<p class="q-mb-md text-caption text-grey">
A privacy-friendly, open-source Bitcoin price aggregator maintained by the
LNbits team. Aggregates prices from multiple exchanges and returns a median,
no API keys required.
<a href="https://price.lnbits.com" target="_blank" rel="noopener"
>price.lnbits.com</a
>
&mdash;
<a
href="https://github.com/lnbits/lnbits-price-aggregator"
target="_blank"
rel="noopener"
>GitHub</a
>
</p>
<div class="row q-mb-md items-start">
<div class="col-auto q-mr-md q-mt-sm">
<q-toggle
v-model="formData.lnbits_price_aggregator_enabled"
@update:model-value="formData.touch = null"
label="Use Price Aggregator"
>
</q-toggle>
</div>
<div class="col-12 col-md-7">
<q-input
filled
v-model="formData.lnbits_price_aggregator_url"
type="text"
label="Price Aggregator URL"
hint="Fetch BTC price from this aggregator instead of individual providers below."
:disable="!formData.lnbits_price_aggregator_enabled"
@update:model-value="formData.touch = null"
>
</q-input>
</div>
</div>
<q-separator class="q-my-md"></q-separator>
<h6 class="q-my-none q-mb-sm">Bitcoin Price History</h6>
<div class="row">
<div class="col-12 col-md-8">
@@ -53,6 +92,11 @@
</div>
</div>
<q-separator class="q-my-md"></q-separator>
<h6 class="q-my-none q-mb-sm">
<span v-text="$t('exchange_providers')"></span>
</h6>
<div class="row q-mt-md">
<div class="col-6">
<q-btn
@@ -60,6 +104,7 @@
label="Add Exchange Provider"
color="primary"
class="q-mb-md"
:disable="formData.lnbits_price_aggregator_enabled"
>
</q-btn>
</div>
@@ -70,12 +115,20 @@
:label="$t('reset_defaults')"
color="primary"
class="float-right"
:disable="formData.lnbits_price_aggregator_enabled"
>
</q-btn>
</div>
</div>
<div class="overflow-auto">
<div
class="overflow-auto"
:style="
formData.lnbits_price_aggregator_enabled
? 'opacity:0.4;pointer-events:none'
: ''
"
>
<q-table
row-key="name"
:rows="formData.lnbits_exchange_rate_providers"
@@ -1,9 +1,11 @@
<template id="lnbits-admin-extensions">
<q-card-section class="q-pa-none">
<div>
<h6 class="q-my-none">
<span v-text="$t('extensions')"></span>
</h6>
<div class="row items-center justify-between q-mb-md">
<h6 class="q-my-none">
<span v-text="$t('extensions')"></span>
</h6>
</div>
<div class="row q-col-gutter-md">
<div class="col-12 q-mb-md">
<p>
@@ -33,6 +35,27 @@
</div>
</div>
</div>
<div class="row q-col-gutter-md">
<div class="col-12 q-mb-md">
<p>Wasm Extension</p>
<div class="row q-gutter-sm">
<q-btn
unelevated
color="primary"
icon="memory"
label="WASM Runtime"
to="/admin/extensions/wasm"
></q-btn>
<q-btn
unelevated
color="primary"
icon="tune"
label="Wasm Limit Config"
to="/admin/extensions/wasm/limits"
></q-btn>
</div>
</div>
</div>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<p>
@@ -124,6 +147,15 @@
/>
</q-item-section>
</q-item>
<q-input
class="q-mt-md"
filled
v-model.number="formData.lnbits_wasm_invocation_retention_days"
type="number"
min="0"
label="WASM invocation retention days"
hint="Set to 0 to disable automatic cleanup."
></q-input>
<br />
</div>
<div class="col-12 col-md-6">
@@ -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,242 @@
<template id="lnbits-admin-wasm-limit-config">
<q-card-section class="q-pa-none">
<div>
<div class="row items-center justify-between q-mb-md q-col-gutter-sm">
<div class="row items-center q-gutter-sm">
<q-btn flat dense round icon="arrow_back" :to="backRoute">
<q-tooltip v-text="backTooltip"></q-tooltip>
</q-btn>
<div>
<h6 class="q-my-none">Wasm Limit Config</h6>
<div
class="text-caption text-grey-7"
v-text="pageDescription"
></div>
</div>
</div>
<q-btn-dropdown
unelevated
color="primary"
icon="tune"
label="Custom Extension Limit"
:loading="wasmRuntimeLimitExtensionsLoading"
>
<q-list style="min-width: 280px; max-width: min(420px, 90vw)">
<q-item-label header>
Select an extension from the list to customize
</q-item-label>
<q-item
v-if="
!wasmRuntimeLimitExtensionsLoading &&
wasmRuntimeLimitExtensionOptions.length === 0
"
>
<q-item-section>
<q-item-label>No installed WASM extensions found.</q-item-label>
</q-item-section>
</q-item>
<q-item
v-for="extension in wasmRuntimeLimitExtensionOptions"
:key="extension.value"
clickable
v-close-popup
@click="openWasmExtensionLimit(extension.value)"
>
<q-item-section>
<q-item-label v-text="extension.label"></q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
</div>
<template v-if="!isExtensionLimitRoute">
<div
v-for="(group, index) in wasmRuntimeLimitGroups"
:key="group.title"
class="q-mb-md"
>
<q-expansion-item
expand-separator
:default-opened="group.title === 'Execution'"
:label="group.title"
header-class="text-subtitle2 text-weight-medium"
>
<div class="row q-col-gutter-md q-pt-md">
<div
v-for="field in group.fields"
:key="field.name"
class="col-12 col-md-6"
>
<q-input
filled
dense
type="number"
min="0"
v-model.number="formData[field.name]"
:label="field.label"
:hint="field.description"
>
<template v-slot:append>
<q-btn
dense
flat
round
icon="info"
color="primary"
@click.stop.prevent="showWasmLimitInfo(field)"
>
<q-tooltip max-width="360px">
<span v-text="field.details"></span>
</q-tooltip>
</q-btn>
</template>
</q-input>
</div>
</div>
</q-expansion-item>
<q-separator
v-if="index < wasmRuntimeLimitGroups.length - 1"
class="q-mt-md"
></q-separator>
</div>
</template>
<div
v-if="isExtensionLimitRoute"
class="row items-center justify-between q-mb-md"
>
<div>
<div class="text-subtitle1 text-weight-medium">
Extension overrides
</div>
<div class="text-caption text-grey-7">
Empty values inherit the global defaults. A saved 0 disables that
limit for the selected extension.
</div>
</div>
<div v-if="selectedWasmRuntimeLimitExtension" class="col-12 col-md-7">
<div class="row items-center q-gutter-sm full-height">
<q-chip
dense
:color="
selectedWasmRuntimeLimitExtension.active ? 'positive' : 'grey-7'
"
text-color="white"
:label="
selectedWasmRuntimeLimitExtension.active ? 'Active' : 'Inactive'
"
></q-chip>
<q-chip
dense
outline
color="primary"
:label="customWasmLimitCount + ' custom overrides'"
></q-chip>
</div>
</div>
</div>
<q-banner
v-if="
isExtensionLimitRoute &&
!wasmRuntimeLimitExtensionsLoading &&
!selectedWasmRuntimeLimitExtension
"
rounded
class="bg-grey-2 text-grey-8 q-mb-lg"
>
WASM extension not found.
</q-banner>
<div v-if="isExtensionLimitRoute && selectedWasmRuntimeLimitExtension">
<div
v-for="(group, index) in wasmRuntimeLimitGroups"
:key="'extension-' + group.title"
class="q-mb-md"
>
<q-expansion-item
expand-separator
:default-opened="group.title === 'Execution'"
:label="group.title"
header-class="text-subtitle2 text-weight-medium"
>
<div class="row q-col-gutter-md q-pt-md">
<div
v-for="field in group.fields"
:key="'extension-' + field.name"
class="col-12 col-md-6"
>
<q-input
filled
dense
type="number"
min="0"
v-model="wasmExtensionLimitDraft[field.name]"
:label="field.label"
:hint="wasmExtensionLimitHint(field)"
:placeholder="wasmExtensionLimitPlaceholder(field)"
>
<template v-slot:append>
<q-btn
dense
flat
round
icon="info"
color="primary"
@click.stop.prevent="showWasmLimitInfo(field)"
>
<q-tooltip max-width="360px">
<span v-text="field.details"></span>
</q-tooltip>
</q-btn>
</template>
</q-input>
</div>
</div>
</q-expansion-item>
<q-separator
v-if="index < wasmRuntimeLimitGroups.length - 1"
class="q-mt-md"
></q-separator>
</div>
<div class="row justify-end q-gutter-sm q-mt-md">
<q-btn
flat
color="primary"
icon="restart_alt"
label="Clear Overrides"
:disable="wasmExtensionLimitsSaving"
@click="clearWasmExtensionLimits"
></q-btn>
<q-btn
unelevated
color="primary"
icon="save"
label="Save Extension Limits"
:loading="wasmExtensionLimitsSaving"
@click="saveWasmExtensionLimits"
></q-btn>
</div>
</div>
<q-dialog v-model="wasmLimitInfoDialog.show">
<q-card style="width: min(560px, calc(100vw - 32px)); max-width: 560px">
<q-card-section class="row items-center q-pb-none">
<div class="text-h6" v-text="wasmLimitInfoDialog.title"></div>
<q-space></q-space>
<q-btn v-close-popup flat round dense icon="close"></q-btn>
</q-card-section>
<q-card-section>
<div
class="text-body1"
style="line-height: 1.6"
v-text="wasmLimitInfoDialog.details"
></div>
</q-card-section>
</q-card>
</q-dialog>
</div>
</q-card-section>
</template>
@@ -0,0 +1,245 @@
<template id="lnbits-admin-wasm-runtime">
<q-card-section class="q-pa-none">
<div>
<div class="row items-center justify-between q-mb-md">
<div class="row items-center q-gutter-sm">
<q-btn
flat
dense
round
icon="arrow_back"
:to="
wasmExtensionId ? '/admin/extensions/wasm' : '/admin#extensions'
"
>
<q-tooltip
v-text="
wasmExtensionId ? 'Global WASM Runtime' : 'Extensions Settings'
"
></q-tooltip>
</q-btn>
<div>
<h6 class="q-my-none">WASM Runtime</h6>
<div
v-if="wasmExtensionId"
class="text-caption text-grey-7"
v-text="`Extension: ${wasmExtensionId}`"
></div>
</div>
</div>
<div>
<q-btn
flat
dense
icon="refresh"
:loading="wasmRuntimeLoading"
@click="fetchWasmRuntime"
>
<q-tooltip>Refresh runtime data</q-tooltip>
</q-btn>
</div>
</div>
<div class="row q-col-gutter-md q-mb-md">
<div
v-for="item in wasmStatItems"
:key="item.key"
class="col-6 col-sm-4 col-md-2"
>
<q-card flat bordered class="full-height">
<q-card-section class="q-pa-sm">
<div class="row items-center no-wrap q-gutter-sm">
<q-avatar
rounded
size="34px"
:color="item.color"
text-color="white"
:icon="item.icon"
></q-avatar>
<div class="col">
<div
class="text-caption text-grey-7"
v-text="item.label"
></div>
<div
class="text-h6 text-weight-bold"
v-text="formatWasmStat(item.key)"
></div>
</div>
</div>
</q-card-section>
</q-card>
</div>
</div>
<q-table
class="q-mb-lg"
dense
flat
wrap-cells
:rows="wasmCurrentInvocations"
:columns="wasmCurrentColumns"
row-key="id"
:loading="wasmRuntimeLoading"
:pagination="{rowsPerPage: 5}"
table-style="table-layout: fixed; width: 100%"
title="Current Invocations"
>
<template v-slot:body-cell-extension_id="props">
<q-td :props="props">
<q-btn
dense
flat
no-caps
color="primary"
:label="props.row.extension_id"
:to="`/admin/extensions/wasm/${encodeURIComponent(props.row.extension_id)}`"
></q-btn>
</q-td>
</template>
<template v-slot:body-cell-trigger_type="props">
<q-td :props="props">
<q-badge
outline
:color="wasmTriggerColor(props.row.trigger_type)"
v-text="props.row.trigger_type"
></q-badge>
</q-td>
</template>
<template v-slot:body-cell-status="props">
<q-td :props="props">
<q-badge
:color="wasmStatusColor(props.row.status)"
v-text="props.row.status"
></q-badge>
</q-td>
</template>
<template v-slot:body-cell-user_id="props">
<q-td :props="props">
<span v-if="props.row.user_id">
<span v-text="formatWasmUserId(props.row.user_id)"></span>
<q-tooltip v-text="props.row.user_id"></q-tooltip>
</span>
<span v-else>-</span>
</q-td>
</template>
<template v-slot:body-cell-started_at="props">
<q-td
:props="props"
v-text="formatWasmDate(props.row.started_at)"
></q-td>
</template>
<template v-slot:body-cell-duration_ms="props">
<q-td :props="props" v-text="formatWasmDuration(props.row)"></q-td>
</template>
<template v-slot:body-cell-context="props">
<q-td :props="props">
<div
style="white-space: normal; word-break: break-word"
v-text="formatWasmContext(props.row)"
></div>
</q-td>
</template>
<template v-slot:body-cell-actions="props">
<q-td :props="props">
<div class="row justify-end q-gutter-xs">
<q-btn
dense
flat
color="negative"
icon="stop_circle"
@click="stopWasmInvocation(props.row.id)"
>
<q-tooltip>Stop Invocation</q-tooltip>
</q-btn>
<q-btn
dense
unelevated
color="negative"
label="Deactivate Extension"
@click="deactivateWasmExtension(props.row.extension_id)"
></q-btn>
</div>
</q-td>
</template>
</q-table>
<q-table
dense
flat
wrap-cells
:rows="wasmInvocationHistory"
:columns="wasmHistoryColumns"
row-key="id"
:loading="wasmHistoryLoading"
:pagination="{rowsPerPage: 10}"
table-style="table-layout: fixed; width: 100%"
title="Recent Invocations"
>
<template v-slot:body-cell-extension_id="props">
<q-td :props="props">
<q-btn
dense
flat
no-caps
color="primary"
:label="props.row.extension_id"
:to="`/admin/extensions/wasm/${encodeURIComponent(props.row.extension_id)}`"
></q-btn>
</q-td>
</template>
<template v-slot:body-cell-trigger_type="props">
<q-td :props="props">
<q-badge
outline
:color="wasmTriggerColor(props.row.trigger_type)"
v-text="props.row.trigger_type"
></q-badge>
</q-td>
</template>
<template v-slot:body-cell-status="props">
<q-td :props="props">
<q-badge
:color="wasmStatusColor(props.row.status)"
v-text="props.row.status"
></q-badge>
</q-td>
</template>
<template v-slot:body-cell-user_id="props">
<q-td :props="props">
<span v-if="props.row.user_id">
<span v-text="formatWasmUserId(props.row.user_id)"></span>
<q-tooltip v-text="props.row.user_id"></q-tooltip>
</span>
<span v-else>-</span>
</q-td>
</template>
<template v-slot:body-cell-started_at="props">
<q-td
:props="props"
v-text="formatWasmDate(props.row.started_at)"
></q-td>
</template>
<template v-slot:body-cell-duration_ms="props">
<q-td :props="props" v-text="formatWasmDuration(props.row)"></q-td>
</template>
<template v-slot:body-cell-calls="props">
<q-td :props="props" v-text="formatWasmCalls(props.row)"></q-td>
</template>
<template v-slot:body-cell-context="props">
<q-td :props="props">
<div
style="white-space: normal; word-break: break-word"
v-text="formatWasmContext(props.row)"
></div>
</q-td>
</template>
<template v-slot:body-cell-error_message="props">
<q-td :props="props">
<span
style="white-space: normal; word-break: break-word"
v-text="props.row.error_message || props.row.stop_reason || ''"
></span>
</q-td>
</template>
</q-table>
</div>
</q-card-section>
</template>
@@ -0,0 +1,154 @@
<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>
<div class="row items-center q-col-gutter-x-md q-row-gutter-sm">
<q-item-label
class="text-weight-medium col-auto"
style="max-width: 100%"
>
<span v-text="permission.label"></span>
</q-item-label>
<div
v-if="permission.risk.level !== 'low' || permission.badges.length"
class="row items-center q-gutter-xs col-auto q-mb-xs"
style="max-width: 100%"
>
<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>
</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">
<div class="row items-center q-gutter-xs">
<span v-text="group.table"></span>
<template v-if="group.sourceIdField">
<q-badge
color="warning"
text-color="dark"
v-text="group.sourceIdField"
></q-badge>
<span
class="text-caption text-grey"
v-text="
$t(
'extension_permission_ext_storage_read_public_source_required'
)
"
></span>
</template>
</div>
<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.appendPolicies.length" class="q-mt-sm">
<div
class="text-caption text-grey"
v-text="
$t('extension_permission_ext_storage_append_public_sources')
"
></div>
<ul class="q-my-sm q-pl-md">
<li
v-for="policy of permission.appendPolicies"
:key="
policy.table +
':' +
policy.sourceTable +
':' +
policy.sourceIdField
"
>
<span v-text="publicAppendPolicySentence(policy)"></span>
<ul v-if="policy.allowedFields.length" class="q-pl-md">
<li
v-for="field of policy.allowedFields"
:key="policy.table + ':' + field"
v-text="field"
></li>
</ul>
</li>
</ul>
</div>
<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>
+14 -1
View File
@@ -74,7 +74,13 @@
<div class="col q-gutter-y-md">
<q-card>
<!-- Mobile: Dropdown menu at top -->
<div v-if="$q.screen.lt.md" class="q-px-md q-pt-md">
<div
v-if="
$q.screen.lt.md &&
!['wasm-runtime', 'wasm-limit-config'].includes(tab)
"
class="q-px-md q-pt-md"
>
<q-select
v-model="tab"
:options="[
@@ -199,6 +205,7 @@
>
<q-tab-panel name="funding">
<lnbits-admin-funding
:active="tab === 'funding'"
:is-super-user="isSuperUser"
:settings="settings"
:form-data="formData"
@@ -219,6 +226,12 @@
<q-tab-panel name="extensions">
<lnbits-admin-extensions :form-data="formData" />
</q-tab-panel>
<q-tab-panel name="wasm-runtime">
<lnbits-admin-wasm-runtime :form-data="formData" />
</q-tab-panel>
<q-tab-panel name="wasm-limit-config">
<lnbits-admin-wasm-limit-config :form-data="formData" />
</q-tab-panel>
<q-tab-panel name="notifications">
<lnbits-admin-notifications :form-data="formData" />
</q-tab-panel>
+234 -10
View File
@@ -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')"
@@ -292,7 +314,7 @@
flat
color="primary"
type="a"
:href="extension.id + '/'"
:href="extensionOpenUrl(extension)"
:label="$t('open')"
></q-btn>
<q-btn
@@ -334,7 +356,7 @@
@click="showManageExtension(extension)"
flat
color="primary"
v-if="g.user.admin"
v-if="canShowManageExtensionButton(extension)"
:label="$t('manage')"
><q-tooltip
><span v-text="$t('manage_extension_details')"></span
@@ -433,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
@@ -468,20 +531,43 @@
</div>
</div>
</q-card>
<q-card v-else class="q-pa-lg lnbits__dialog-card">
<q-card
v-else
class="q-pa-lg lnbits__dialog-card"
style="width: min(95vw, 920px); max-width: 95vw"
>
<q-tabs
v-model="manageExtensionTab"
active-color="primary"
align="justify"
>
<q-tab
v-if="canShowAdminManageTabs()"
name="releases"
:label="$t('releases')"
@update="val => (manageExtensionTab = val.name)"
></q-tab>
<q-tab
v-if="selectedExtension && selectedExtension.isInstalled"
v-if="canManageExtensionPermissions()"
name="extension-permissions"
:label="$t('extension_permissions_tab')"
@update="val => (manageExtensionTab = val.name)"
></q-tab>
<q-tab
v-if="canManageExtensionPermissions()"
name="user-permissions"
:label="$t('user_permissions_tab')"
@update="val => (manageExtensionTab = val.name)"
></q-tab>
<q-tab
v-if="
canShowAdminManageTabs() &&
selectedExtension &&
selectedExtension.isInstalled
"
name="sell"
:label="$t('sell')"
@update="val => (manageExtensionTab = val.name)"
@@ -491,7 +577,7 @@
<div
v-show="manageExtensionTab === 'releases'"
class="col-12 col-md-5 q-gutter-y-md q-mt-md"
v-if="selectedExtensionRepos"
v-if="canShowAdminManageTabs() && selectedExtensionRepos"
>
<q-card
flat
@@ -752,9 +838,147 @@
></q-btn>
</div>
</div>
<q-spinner-bars v-else color="primary" size="2.55em"></q-spinner-bars>
<q-spinner-bars
v-else-if="
canShowAdminManageTabs() && manageExtensionTab === 'releases'
"
color="primary"
size="2.55em"
></q-spinner-bars>
<div
v-if="selectedExtension"
v-if="canManageExtensionPermissions()"
v-show="manageExtensionTab === 'extension-permissions'"
class="col-12 col-md-5 q-gutter-y-md q-mt-md"
>
<q-spinner-bars
v-if="managedExtensionPermissions.loading"
color="primary"
size="2.55em"
></q-spinner-bars>
<lnbits-extension-permissions
v-else-if="managedExtensionPermissions.extensionPermissions.length"
:permissions="managedExtensionPermissions.extensionPermissions"
:extensions="extensions"
></lnbits-extension-permissions>
<q-banner v-else rounded class="bg-grey-2 text-grey-8">
<span v-text="$t('extension_permissions_none')"></span>
</q-banner>
<div class="row q-mt-lg">
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
v-text="$t('close')"
></q-btn>
</div>
</div>
<div
v-if="canManageExtensionPermissions()"
v-show="manageExtensionTab === 'user-permissions'"
class="col-12 col-md-5 q-gutter-y-md q-mt-md"
>
<q-spinner-bars
v-if="managedExtensionPermissions.loading"
color="primary"
size="2.55em"
></q-spinner-bars>
<q-banner
v-else-if="!managedUserPermissionRows.length"
rounded
class="bg-grey-2 text-grey-8"
>
<span v-text="$t('user_permissions_none')"></span>
</q-banner>
<q-list v-else bordered separator>
<q-expansion-item
v-for="row in managedUserPermissionRows"
:key="row.key"
>
<template v-slot:header>
<q-item-section>
<q-item-label class="text-weight-medium">
<span v-text="row.label"></span>
</q-item-label>
<q-item-label caption>
<span v-text="userPermissionRowCaption(row)"></span>
</q-item-label>
</q-item-section>
<q-item-section side>
<q-btn
flat
dense
round
color="negative"
icon="delete"
:loading="managedExtensionPermissions.deletingKey === row.key"
@click.stop="deleteUserPermissionGrant(row)"
>
<q-tooltip>
<span v-text="$t('delete')"></span>
</q-tooltip>
</q-btn>
</q-item-section>
</template>
<div class="q-pa-md">
<div
v-if="isBackgroundPaymentPermission(row)"
class="row q-col-gutter-sm"
>
<div class="col-12 col-sm-5">
<q-input
dense
outlined
type="number"
min="1"
v-model.number="row.grant.max_amount"
:label="$t('user_permissions_max_amount')"
suffix="sat"
></q-input>
</div>
<div class="col-12 col-sm-7">
<q-select
dense
outlined
emit-value
map-options
v-model="row.grant.destination_policy"
:options="backgroundPaymentDestinationOptions"
:label="$t('user_permissions_destination_policy')"
></q-select>
</div>
<div class="col-12">
<q-btn
unelevated
color="primary"
icon="save"
:loading="managedExtensionPermissions.savingKey === row.key"
@click="saveUserPermissionGrant(row)"
>
<span v-text="$t('save')"></span>
</q-btn>
</div>
</div>
<q-item-label v-else caption>
<span
v-text="$t('user_permissions_no_editable_settings')"
></span>
</q-item-label>
</div>
</q-expansion-item>
</q-list>
<div class="row q-mt-lg">
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
v-text="$t('close')"
></q-btn>
</div>
</div>
<div
v-if="canShowAdminManageTabs() && selectedExtension"
v-show="manageExtensionTab === 'sell'"
class="col-12 col-md-5 q-gutter-y-md q-mt-md"
>
+9 -2
View File
@@ -664,7 +664,8 @@
unelevated
color="primary"
@click="payInvoice"
:label="$t('pay')"
:disable="parse.sending"
:label="parse.sending ? $t('sending') + '...' : $t('pay')"
></q-btn>
<q-btn
v-close-popup
@@ -836,7 +837,13 @@
</div>
</div>
<div class="row q-mt-lg">
<q-btn unelevated color="primary" type="submit">Send</q-btn>
<q-btn
unelevated
color="primary"
type="submit"
:disable="parse.sending"
:label="parse.sending ? $t('sending') + '...' : $t('send')"
></q-btn>
<q-btn
:label="$t('cancel')"
v-close-popup
+1
View File
@@ -0,0 +1 @@
{% extends "base.html" %}
+6 -17
View File
@@ -1,13 +1,8 @@
from __future__ import annotations
import asyncio
from time import time
from typing import Any, NamedTuple
from loguru import logger
from lnbits.settings import settings
class Cached(NamedTuple):
value: Any
@@ -22,8 +17,7 @@ class Cache:
Small caching utility providing simple get/set interface (very much like redis)
"""
def __init__(self, interval: float = 10) -> None:
self.interval = interval
def __init__(self) -> None:
self._values: dict[Any, Cached] = {}
def value(self, key: str) -> Cached | None:
@@ -59,16 +53,11 @@ class Cache:
self.set(key, value, expiry=expiry)
return value
async def invalidate_forever(self):
while settings.lnbits_running:
try:
await asyncio.sleep(self.interval)
ts = time()
expired = [k for k, v in self._values.items() if v.expiry < ts]
for k in expired:
self._values.pop(k)
except Exception:
logger.error("Error invalidating cache")
async def invalidate_cache(self):
ts = time()
expired = [k for k, v in self._values.items() if v.expiry < ts]
for k in expired:
self._values.pop(k)
cache = Cache()
+439
View File
@@ -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]

Some files were not shown because too many files have changed in this diff Show More