From 26f0b690854e101f2c5283325f3e3ce5edafad12 Mon Sep 17 00:00:00 2001 From: Arc Date: Sat, 21 Feb 2026 09:18:13 +0000 Subject: [PATCH] init --- lnbits/app.py | 7 + lnbits/core/models/extensions.py | 34 ++- lnbits/core/services/extensions.py | 6 + lnbits/core/views/extension_api.py | 33 ++- lnbits/core/wasm/__init__.py | 0 lnbits/core/wasm/extension_host.py | 265 ++++++++++++++++++ lnbits/core/wasm/runner.py | 179 ++++++++++++ lnbits/core/wasm/service.py | 82 ++++++ lnbits/settings.py | 5 + lnbits/static/js/pages/extensions.js | 55 +++- lnbits/tasks.py | 5 + .../templates/components/admin/extensions.vue | 52 ++++ lnbits/templates/pages/extensions.vue | 40 +++ 13 files changed, 757 insertions(+), 6 deletions(-) create mode 100644 lnbits/core/wasm/__init__.py create mode 100644 lnbits/core/wasm/extension_host.py create mode 100644 lnbits/core/wasm/runner.py create mode 100644 lnbits/core/wasm/service.py diff --git a/lnbits/app.py b/lnbits/app.py index a00d71577..dd578635b 100644 --- a/lnbits/app.py +++ b/lnbits/app.py @@ -41,6 +41,7 @@ from lnbits.core.tasks import ( from lnbits.exceptions import register_exception_handlers from lnbits.helpers import version_parse from lnbits.settings import settings +from lnbits.core.wasm.extension_host import register_wasm_ext_routes from lnbits.tasks import ( cancel_all_tasks, create_permanent_task, @@ -419,6 +420,8 @@ def register_new_ratelimiter(app: FastAPI) -> Callable: def register_ext_tasks(ext: Extension) -> None: """Register extension async tasks.""" + if ext.extension_type == "wasm": + return ext_module = importlib.import_module(ext.module_name) if hasattr(ext_module, f"{ext.code}_start"): @@ -428,6 +431,10 @@ def register_ext_tasks(ext: Extension) -> None: def register_ext_routes(app: FastAPI, ext: Extension) -> None: """Register FastAPI routes for extension.""" + if ext.extension_type == "wasm": + settings.activate_extension_paths(ext.code, ext.upgrade_hash, []) + register_wasm_ext_routes(app, ext) + return ext_module = importlib.import_module(ext.module_name) ext_route = getattr(ext_module, f"{ext.code}_ext") diff --git a/lnbits/core/models/extensions.py b/lnbits/core/models/extensions.py index c0cfa3260..731eb9f45 100644 --- a/lnbits/core/models/extensions.py +++ b/lnbits/core/models/extensions.py @@ -83,6 +83,9 @@ class ExtensionConfig(BaseModel): warning: str | None = "" min_lnbits_version: str | None max_lnbits_version: str | None + permissions: list["ExtensionPermission"] = [] + extension_type: str | None = "python" + public_kv_keys: list[str] = [] def is_version_compatible(self) -> bool: return is_lnbits_version_ok(self.min_lnbits_version, self.max_lnbits_version) @@ -115,6 +118,18 @@ class PayToEnableInfo(BaseModel): class UserExtensionInfo(BaseModel): paid_to_enable: bool | None = False payment_hash_to_enable: str | None = None + granted_permissions: list[str] | None = None + + +class ExtensionPermission(BaseModel): + id: str + label: str + description: str + dangerous: bool | None = False + + +class ExtensionPermissionsGrant(BaseModel): + permissions: list[str] = [] class UserExtension(BaseModel): @@ -147,6 +162,8 @@ class Extension(BaseModel): short_description: str | None = None tile: str | None = None upgrade_hash: str | None = "" + extension_type: str | None = None + public_kv_keys: list[str] = [] @property def module_name(self) -> str: @@ -170,6 +187,8 @@ class Extension(BaseModel): short_description=ext_info.short_description, tile=ext_info.icon, upgrade_hash=ext_info.hash if ext_info.ext_upgrade_dir.is_dir() else "", + extension_type=ext_info.meta.extension_type if ext_info.meta else None, + public_kv_keys=ext_info.meta.public_kv_keys if ext_info.meta else [], ) @@ -331,6 +350,9 @@ class ExtensionMeta(BaseModel): pay_to_enable: PayToEnableInfo | None = None payments: list[ReleasePaymentInfo] = [] dependencies: list[str] = [] + permissions: list[ExtensionPermission] = [] + extension_type: str | None = "python" + public_kv_keys: list[str] = [] archive: str | None = None featured: bool = False paid_features: str | None = None @@ -454,6 +476,10 @@ class InstallableExtension(BaseModel): self.name = config_json.get("name") self.short_description = config_json.get("short_description") + if self.meta: + self.meta.permissions = config_json.get("permissions", []) + self.meta.extension_type = config_json.get("extension_type", "python") + self.meta.public_kv_keys = config_json.get("public_kv_keys", []) if ( self.meta @@ -572,6 +598,9 @@ class InstallableExtension(BaseModel): latest_release=ExtensionRelease.from_github_release( source_repo, latest_release ), + permissions=config.permissions, + extension_type=config.extension_type, + public_kv_keys=config.public_kv_keys, ), ) except Exception as e: @@ -617,7 +646,10 @@ class InstallableExtension(BaseModel): source_repo=f"{conf_path}", min_lnbits_version=config_json.get("min_lnbits_version"), max_lnbits_version=config_json.get("max_lnbits_version"), - ) + ), + permissions=config_json.get("permissions", []), + extension_type=config_json.get("extension_type", "python"), + public_kv_keys=config_json.get("public_kv_keys", []), ), ) diff --git a/lnbits/core/services/extensions.py b/lnbits/core/services/extensions.py index 8743efbc2..02eed304a 100644 --- a/lnbits/core/services/extensions.py +++ b/lnbits/core/services/extensions.py @@ -91,6 +91,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. """ + installed = await get_installed_extension(ext_id) + if installed and installed.meta and installed.meta.extension_type == "wasm": + return True upgrade_hash = settings.extension_upgrade_hash(ext_id) ext = Extension(code=ext_id, is_valid=True, upgrade_hash=upgrade_hash) @@ -123,6 +126,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. """ + installed = await get_installed_extension(ext_id) + if installed and installed.meta and installed.meta.extension_type == "wasm": + return True upgrade_hash = settings.extension_upgrade_hash(ext_id) ext = Extension(code=ext_id, is_valid=True, upgrade_hash=upgrade_hash) diff --git a/lnbits/core/views/extension_api.py b/lnbits/core/views/extension_api.py index 3192b832c..02b788278 100644 --- a/lnbits/core/views/extension_api.py +++ b/lnbits/core/views/extension_api.py @@ -4,7 +4,7 @@ from http import HTTPStatus import httpx from bolt11 import decode as bolt11_decode -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Body, Depends, HTTPException from fastapi.requests import Request from loguru import logger @@ -24,6 +24,7 @@ from lnbits.core.models.extensions import ( ExtensionReview, ExtensionReviewPaymentRequest, ExtensionReviewsStatus, + ExtensionPermissionsGrant, InstallableExtension, PayToEnableInfo, ReleasePaymentInfo, @@ -174,7 +175,9 @@ async def api_update_pay_to_enable( @extension_router.put("/{ext_id}/enable") async def api_enable_extension( - ext_id: str, account_id: AccountId = Depends(check_account_id_exists) + ext_id: str, + account_id: AccountId = Depends(check_account_id_exists), + grant: ExtensionPermissionsGrant | None = Body(default=None), ) -> SimpleStatus: if ext_id not in [e.code for e in await get_valid_extensions()]: raise HTTPException( @@ -193,6 +196,27 @@ async def api_enable_extension( user_ext = UserExtension(user=account_id.id, extension=ext_id, active=False) await create_user_extension(user_ext) + required_permissions = ( + [p.id for p in ext.meta.permissions] if ext.meta and ext.meta.permissions else [] + ) + granted_permissions = [] + if grant and grant.permissions: + granted_permissions = grant.permissions + elif user_ext.extra and user_ext.extra.granted_permissions: + granted_permissions = user_ext.extra.granted_permissions + + if required_permissions: + missing = [p for p in required_permissions if p not in granted_permissions] + if missing: + raise HTTPException( + HTTPStatus.BAD_REQUEST, + "Missing required permissions to enable this extension.", + ) + user_ext_info = user_ext.extra or UserExtensionInfo() + user_ext_info.granted_permissions = granted_permissions + user_ext.extra = user_ext_info + await update_user_extension(user_ext) + if account_id.is_admin_id or not ext.requires_payment: user_ext.active = True await update_user_extension(user_ext) @@ -571,6 +595,11 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)): "isAvailable": ext.id in all_ext_ids, "isAdminOnly": ext.id in settings.lnbits_admin_extensions, "isActive": ext.id not in inactive_extensions, + "permissions": ( + [dict(p) for p in ext.meta.permissions] + if ext.meta and ext.meta.permissions + else [] + ), "latestRelease": ( dict(ext.meta.latest_release) if ext.meta and ext.meta.latest_release diff --git a/lnbits/core/wasm/__init__.py b/lnbits/core/wasm/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/lnbits/core/wasm/extension_host.py b/lnbits/core/wasm/extension_host.py new file mode 100644 index 000000000..19382c11c --- /dev/null +++ b/lnbits/core/wasm/extension_host.py @@ -0,0 +1,265 @@ +from __future__ import annotations + +import time +from pathlib import Path + +from fastapi import APIRouter, Depends, HTTPException, Query, Request, WebSocket +from fastapi.responses import HTMLResponse +from fastapi.staticfiles import StaticFiles + +from lnbits.core.crud.extensions import get_user_extension +from lnbits.core.crud.wallets import get_wallet_for_key, get_wallets_ids +from lnbits.core.models import User +from lnbits.core.services import create_invoice, pay_invoice, websocket_updater +from lnbits.decorators import check_user_exists +from lnbits.db import Database +from lnbits.helpers import template_renderer +from lnbits.settings import settings +from lnbits.tasks import register_invoice_listener, unregister_invoice_listener +from .service import WasmExecutionError, wasm_call + + +def _renderer(ext_id: str): + return template_renderer([f"{ext_id}/templates"]) + + +def _ext_static_dir(ext_id: str, upgrade_hash: str | None = None) -> Path: + if upgrade_hash: + return Path( + settings.lnbits_extensions_upgrade_path, f"{ext_id}-{upgrade_hash}", "static" + ) + return Path(settings.lnbits_extensions_path, "extensions", ext_id, "static") + + +def _ensure_kv_table(db: Database, ext_id: str) -> str: + table = f"{ext_id}.kv" if db.schema else "kv" + query = f""" + CREATE TABLE IF NOT EXISTS {table} ( + key TEXT PRIMARY KEY, + value TEXT + ); + """ + return query + + +async def _kv_get(db: Database, ext_id: str, key: str) -> str | None: + await db.execute(_ensure_kv_table(db, ext_id)) + table = f"{ext_id}.kv" if db.schema else "kv" + row = await db.fetchone( + f"SELECT value FROM {table} WHERE key = :key", + {"key": key}, + ) + if not row: + return None + return row.get("value") + + +async def _kv_set(db: Database, ext_id: str, key: str, value: str) -> None: + await db.execute(_ensure_kv_table(db, ext_id)) + table = f"{ext_id}.kv" if db.schema else "kv" + existing = await db.fetchone( + f"SELECT key FROM {table} WHERE key = :key", + {"key": key}, + ) + if existing: + await db.execute( + f"UPDATE {table} SET value = :value WHERE key = :key", + {"key": key, "value": value}, + ) + else: + await db.execute( + f"INSERT INTO {table} (key, value) VALUES (:key, :value)", + {"key": key, "value": value}, + ) + + +async def _require_permission(user_id: str, ext_id: str, permission: str) -> None: + user_ext = await get_user_extension(user_id, ext_id) + if not user_ext or not user_ext.active: + raise HTTPException(403, "Extension not enabled.") + granted = user_ext.extra.granted_permissions if user_ext.extra else [] + if permission not in (granted or []): + raise HTTPException(403, f"Missing permission: {permission}") + + +async def _require_wallet_access(user_id: str, wallet_id: str) -> None: + wallet_ids = await get_wallets_ids(user_id, deleted=False) + if wallet_id not in wallet_ids: + raise HTTPException(403, "Wallet does not belong to user.") + + +def register_wasm_ext_routes(app, ext) -> None: + ext_id = ext.code + db = Database(f"ext_{ext_id}") + router = APIRouter(prefix=f"/{ext_id}", tags=[f"{ext_id} (wasm)"]) + + @router.get("/", response_class=HTMLResponse) + async def index(req: Request, user: User = Depends(check_user_exists)): + try: + return _renderer(ext_id).TemplateResponse( + f"{ext_id}/index.html", + {"request": req, "user": user.json()}, + ) + except Exception: + return HTMLResponse("Extension page not found", status_code=404) + + @router.get("/public/{key}", response_class=HTMLResponse) + async def public_page(req: Request, key: str): + try: + return _renderer(ext_id).TemplateResponse( + f"{ext_id}/public_page.html", + {"request": req, "key": key, "public": True}, + ) + except Exception: + return HTMLResponse("Public page not found", status_code=404) + + @router.get("/api/v1/kv/{key}") + async def api_kv_get(key: str, user: User = Depends(check_user_exists)): + await _require_permission(user.id, ext_id, "ext.db.read_write") + _check_quota( + user.id, ext_id, "db", settings.lnbits_wasm_max_db_ops_per_min + ) + value = await _kv_get(db, ext_id, key) + return {"key": key, "value": value} + + @router.get("/api/v1/public/kv/{key}") + async def api_kv_get_public(key: str): + public_keys = ext.public_kv_keys or [] + if key not in public_keys: + raise HTTPException(404, "Key not public") + _check_quota("public", ext_id, "db", settings.lnbits_wasm_max_db_ops_per_min) + value = await _kv_get(db, ext_id, key) + return {"key": key, "value": value} + + @router.post("/api/v1/kv/{key}") + async def api_kv_set(key: str, payload: dict, user: User = Depends(check_user_exists)): + await _require_permission(user.id, ext_id, "ext.db.read_write") + _check_quota( + user.id, ext_id, "db", settings.lnbits_wasm_max_db_ops_per_min + ) + value = payload.get("value") + if value is None: + raise HTTPException(400, "Missing value") + await _kv_set(db, ext_id, key, str(value)) + await websocket_updater(f"{ext_id}:{key}", str(value)) + return {"key": key, "value": value} + + @router.post("/api/v1/kv/{key}/increment") + async def api_kv_increment(key: str, user: User = Depends(check_user_exists)): + await _require_permission(user.id, ext_id, "ext.db.read_write") + _check_quota( + user.id, ext_id, "db", settings.lnbits_wasm_max_db_ops_per_min + ) + if key != "counter": + raise HTTPException(400, "Only 'counter' is supported in this example.") + try: + new_value = await wasm_call( + ext_id, "increment_counter", [], upgrade_hash=ext.upgrade_hash + ) + except WasmExecutionError as exc: + raise HTTPException(500, str(exc)) from exc + await _kv_set(db, ext_id, key, str(new_value)) + await websocket_updater(f"{ext_id}:{key}", str(new_value)) + return {"key": key, "value": new_value} + + @router.post("/api/v1/invoices") + async def api_create_invoice(payload: dict, user: User = Depends(check_user_exists)): + await _require_permission(user.id, ext_id, "lnbits.invoice.create") + _check_quota( + user.id, ext_id, "invoice", settings.lnbits_wasm_max_invoice_ops_per_min + ) + wallet_id = payload.get("wallet_id") + amount = payload.get("amount") + memo = payload.get("memo") or f"{ext_id} invoice" + if not wallet_id or not amount: + raise HTTPException(400, "Missing wallet_id or amount") + await _require_wallet_access(user.id, wallet_id) + + try: + amount = int(amount) + except (TypeError, ValueError): + raise HTTPException(400, "Invalid amount") + + payment = await create_invoice(wallet_id=wallet_id, amount=amount, memo=memo) + return { + "payment_hash": payment.payment_hash, + "payment_request": payment.bolt11, + "amount": payment.amount, + } + + @router.post("/api/v1/invoices/pay") + async def api_pay_invoice(payload: dict, user: User = Depends(check_user_exists)): + await _require_permission(user.id, ext_id, "lnbits.invoice.pay") + _check_quota( + user.id, ext_id, "invoice", settings.lnbits_wasm_max_invoice_ops_per_min + ) + wallet_id = payload.get("wallet_id") + payment_request = payload.get("payment_request") + if not wallet_id or not payment_request: + raise HTTPException(400, "Missing wallet_id or payment_request") + await _require_wallet_access(user.id, wallet_id) + + payment = await pay_invoice( + wallet_id=wallet_id, + payment_request=payment_request, + description=f"{ext_id} payment", + tag=ext_id, + ) + return { + "payment_hash": payment.payment_hash, + "amount_msat": payment.amount_msat, + "status": payment.status, + } + + @router.websocket("/api/v1/events/ws") + async def events_ws(websocket: WebSocket, api_key: str = Query(default="")): + await websocket.accept() + wallet = await get_wallet_for_key(api_key) if api_key else None + if not wallet: + await websocket.close(code=1008) + return + try: + await _require_permission(wallet.user, ext_id, "lnbits.payments.subscribe") + except HTTPException: + await websocket.close(code=1008) + return + + wallet_ids = await get_wallets_ids(wallet.user, deleted=False) + queue_name = f"wasm:{ext_id}:{wallet.user}:{id(websocket)}" + invoice_queue: asyncio.Queue = asyncio.Queue() + register_invoice_listener(invoice_queue, queue_name) + + try: + while True: + payment = await invoice_queue.get() + if payment.wallet_id in wallet_ids: + await websocket.send_json(payment.dict()) + except Exception: + pass + finally: + unregister_invoice_listener(queue_name) + + static_dir = _ext_static_dir(ext_id, ext.upgrade_hash) + if static_dir.is_dir(): + app.mount( + f"/{ext_id}/static", + StaticFiles(directory=static_dir), + name=f"{ext_id}_static", + ) + + prefix = f"/upgrades/{ext.upgrade_hash}" if ext.upgrade_hash else "" + app.include_router(router, prefix=prefix) +_quota_events: dict[tuple[str, str, str], list[float]] = {} + + +def _check_quota(user_id: str, ext_id: str, action: str, limit: int) -> None: + if limit <= 0: + return + now = time.time() + key = (user_id, ext_id, action) + events = _quota_events.get(key, []) + events = [t for t in events if now - t < 60] + if len(events) >= limit: + raise HTTPException(429, "WASM quota exceeded") + events.append(now) + _quota_events[key] = events diff --git a/lnbits/core/wasm/runner.py b/lnbits/core/wasm/runner.py new file mode 100644 index 000000000..adc7ce4f8 --- /dev/null +++ b/lnbits/core/wasm/runner.py @@ -0,0 +1,179 @@ +import json +import sys +from pathlib import Path + +import asyncio + +from wasmtime import ( + Caller, + Config, + Engine, + Func, + FuncType, + Linker, + Module, + Store, + ValType, +) + +from lnbits.db import Database +from lnbits.settings import settings + + +def _run(coro): + return asyncio.run(coro) + + +def _ensure_kv_table(db: Database, ext_id: str) -> str: + table = f"{ext_id}.kv" if db.schema else "kv" + return f""" + CREATE TABLE IF NOT EXISTS {table} ( + key TEXT PRIMARY KEY, + value TEXT + ); + """ + + +def _get_memory(caller: Caller): + memory = caller.get_export("memory") + if memory is None: + raise RuntimeError("WASM module does not export memory") + return memory + + +def _read_bytes(caller: Caller, ptr: int, length: int) -> bytes: + memory = _get_memory(caller) + return memory.read(caller, ptr, ptr + length) + + +def _write_bytes(caller: Caller, ptr: int, data: bytes) -> None: + memory = _get_memory(caller) + memory.write(caller, data, ptr) + + +def _db_get( + db: Database, + ext_id: str, + caller: Caller, + key_ptr: int, + key_len: int, + out_ptr: int, + out_len: int, +) -> int: + key = _read_bytes(caller, key_ptr, key_len).decode(errors="ignore") + _run(db.execute(_ensure_kv_table(db, ext_id))) + table = f"{ext_id}.kv" if db.schema else "kv" + row = _run(db.fetchone(f"SELECT value FROM {table} WHERE key = :key", {"key": key})) + if not row: + return 0 + value = str(row.get("value", "")) + data = value.encode()[: max(0, out_len)] + _write_bytes(caller, out_ptr, data) + return len(data) + + +def _db_set( + db: Database, + ext_id: str, + caller: Caller, + key_ptr: int, + key_len: int, + val_ptr: int, + val_len: int, +) -> int: + key = _read_bytes(caller, key_ptr, key_len).decode(errors="ignore") + value = _read_bytes(caller, val_ptr, val_len).decode(errors="ignore") + _run(db.execute(_ensure_kv_table(db, ext_id))) + table = f"{ext_id}.kv" if db.schema else "kv" + row = _run(db.fetchone(f"SELECT key FROM {table} WHERE key = :key", {"key": key})) + if row: + _run( + db.execute( + f"UPDATE {table} SET value = :value WHERE key = :key", + {"key": key, "value": value}, + ) + ) + else: + _run( + db.execute( + f"INSERT INTO {table} (key, value) VALUES (:key, :value)", + {"key": key, "value": value}, + ) + ) + return len(value) + + +def _load_module(module_path: Path, ext_id: str): + config = Config() + config.consume_fuel = True + engine = Engine(config) + store = Store(engine) + store.add_fuel(settings.lnbits_wasm_fuel) + module = Module.from_file(engine, str(module_path)) + db = Database(f"ext_{ext_id}") + + def db_get(caller: Caller, key_ptr: int, key_len: int, out_ptr: int, out_len: int) -> int: + return _db_get(db, ext_id, caller, key_ptr, key_len, out_ptr, out_len) + + def db_set( + caller: Caller, key_ptr: int, key_len: int, val_ptr: int, val_len: int + ) -> int: + return _db_set(db, ext_id, caller, key_ptr, key_len, val_ptr, val_len) + + linker = Linker(engine) + linker.define( + "host", + "db_get", + Func( + store, + FuncType([ValType.i32(), ValType.i32(), ValType.i32(), ValType.i32()], [ValType.i32()]), + db_get, + ), + ) + linker.define( + "host", + "db_set", + Func( + store, + FuncType([ValType.i32(), ValType.i32(), ValType.i32(), ValType.i32()], [ValType.i32()]), + db_set, + ), + ) + + instance = linker.instantiate(store, module) + return store, instance + + +def main() -> int: + if len(sys.argv) < 4: + sys.stderr.write( + "usage: runner.py [args...]\n" + ) + return 2 + + module_path = Path(sys.argv[1]) + ext_id = sys.argv[2] + function_name = sys.argv[3] + args = sys.argv[4:] + + if not module_path.exists(): + sys.stderr.write(f"module not found: {module_path}\n") + return 2 + + try: + store, instance = _load_module(module_path, ext_id) + func = instance.exports(store)[function_name] + int_args = [int(a) for a in args] + result = func(store, *int_args) + except Exception as exc: + payload = {"ok": False, "error": str(exc)} + sys.stdout.write(json.dumps(payload)) + return 1 + + payload = {"ok": True, "result": int(result)} + sys.stdout.write(json.dumps(payload)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/lnbits/core/wasm/service.py b/lnbits/core/wasm/service.py new file mode 100644 index 000000000..e85a49a5a --- /dev/null +++ b/lnbits/core/wasm/service.py @@ -0,0 +1,82 @@ +import asyncio +import json +import sys +from pathlib import Path + +from lnbits.settings import settings + +WASM_RUNNER = Path(__file__).with_name("runner.py") + + +class WasmExecutionError(RuntimeError): + pass + + +def resolve_module_path(ext_id: str, upgrade_hash: str | None = None) -> Path: + if upgrade_hash: + ext_dir = Path( + settings.lnbits_extensions_upgrade_path, f"{ext_id}-{upgrade_hash}" + ) + else: + ext_dir = Path(settings.lnbits_extensions_path, "extensions", ext_id) + wasm_dir = ext_dir / "wasm" + wasm_path = wasm_dir / "module.wasm" + if wasm_path.exists(): + if ( + settings.lnbits_wasm_max_module_bytes > 0 + and wasm_path.stat().st_size > settings.lnbits_wasm_max_module_bytes + ): + raise WasmExecutionError("WASM module exceeds size limit.") + return wasm_path + wat_path = wasm_dir / "module.wat" + if wat_path.exists(): + if ( + settings.lnbits_wasm_max_module_bytes > 0 + and wat_path.stat().st_size > settings.lnbits_wasm_max_module_bytes + ): + raise WasmExecutionError("WASM module exceeds size limit.") + return wat_path + raise WasmExecutionError(f"No wasm module found for extension '{ext_id}'.") + + +async def wasm_call( + ext_id: str, + function: str, + args: list[int], + timeout_s: float | None = None, + upgrade_hash: str | None = None, +) -> int: + module_path = resolve_module_path(ext_id, upgrade_hash) + if timeout_s is None: + timeout_s = settings.lnbits_wasm_timeout_seconds + + proc = await asyncio.create_subprocess_exec( + sys.executable, + str(WASM_RUNNER), + str(module_path), + ext_id, + function, + *[str(a) for a in args], + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + + try: + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout_s) + except asyncio.TimeoutError as exc: + proc.kill() + raise WasmExecutionError("WASM execution timed out") from exc + + if proc.returncode != 0: + detail = stderr.decode().strip() if stderr else "WASM runner error" + raise WasmExecutionError(detail) + + try: + payload = json.loads(stdout.decode()) if stdout else {} + except json.JSONDecodeError as exc: + raise WasmExecutionError("Invalid WASM runner output") from exc + + if not payload.get("ok"): + raise WasmExecutionError(payload.get("error", "WASM execution failed")) + + return int(payload["result"]) diff --git a/lnbits/settings.py b/lnbits/settings.py index f0ceb78ef..d3aa018ad 100644 --- a/lnbits/settings.py +++ b/lnbits/settings.py @@ -75,6 +75,11 @@ class ExtensionsSettings(LNbitsSettings): lnbits_extensions_builder_manifest_url: str = Field( default="https://raw.githubusercontent.com/lnbits/extension_builder_stub/refs/heads/main/manifest.json" ) + lnbits_wasm_timeout_seconds: float = Field(default=1.0, ge=0.1) + lnbits_wasm_fuel: int = Field(default=50_000, ge=1_000) + lnbits_wasm_max_module_bytes: int = Field(default=1_000_000, ge=0) + lnbits_wasm_max_db_ops_per_min: int = Field(default=120, ge=0) + lnbits_wasm_max_invoice_ops_per_min: int = Field(default=30, ge=0) @property def extension_builder_working_dir_path(self) -> Path: diff --git a/lnbits/static/js/pages/extensions.js b/lnbits/static/js/pages/extensions.js index a71160d54..27d59ef4d 100644 --- a/lnbits/static/js/pages/extensions.js +++ b/lnbits/static/js/pages/extensions.js @@ -28,6 +28,11 @@ window.PageExtensions = { paylinkWebsocket: null, searchToggle: false, reviewsUrl: null, + permissionsDialog: { + show: false, + extension: null, + checked: [] + }, reviewsDialog: { show: false, extension: null, @@ -92,6 +97,14 @@ window.PageExtensions = { this.filterExtensions(this.searchTerm, val) } }, + computed: { + permissionsAllChecked() { + const ext = this.permissionsDialog.extension + if (!ext || !Array.isArray(ext.permissions)) return true + const required = ext.permissions.map(p => p.id) + return required.every(p => this.permissionsDialog.checked.includes(p)) + } + }, methods: { filterExtensions(term, tab) { // Filter the extensions list @@ -241,18 +254,27 @@ window.PageExtensions = { }) }, async enableExtensionForUser(extension) { + if (extension.permissions && extension.permissions.length) { + this.openPermissionsDialog(extension) + return + } if (extension.isPaymentRequired) { this.showPayToEnable(extension) return } this.enableExtension(extension) }, - async enableExtension(extension) { + async enableExtension(extension, permissions) { + const granted = + permissions || + extension._grantedPermissions || + (extension.permissions ? extension.permissions.map(p => p.id) : []) LNbits.api .request( 'PUT', `/api/v1/extension/${extension.id}/enable`, - this.g.user.wallets[0].adminkey + this.g.user.wallets[0].adminkey, + granted.length ? {permissions: granted} : null ) .then(response => { this.g.user.extensions = this.g.user.extensions.concat([extension.id]) @@ -287,13 +309,40 @@ window.PageExtensions = { LNbits.utils.notifyApiError(err) }) }, - showPayToEnable(extension) { + showPayToEnable(extension, permissions) { this.selectedExtension = extension + if (permissions) { + this.selectedExtension._grantedPermissions = permissions + } this.selectedExtension.payToEnable.paidAmount = extension.payToEnable.amount this.selectedExtension.payToEnable.showQRCode = false this.showPayToEnableDialog = true }, + openPermissionsDialog(extension) { + this.permissionsDialog.extension = extension + this.permissionsDialog.checked = [] + this.permissionsDialog.show = true + }, + cancelPermissionsDialog() { + this.permissionsDialog.show = false + this.permissionsDialog.extension = null + this.permissionsDialog.checked = [] + }, + confirmPermissionsDialog() { + const ext = this.permissionsDialog.extension + const granted = this.permissionsDialog.checked.slice() + this.permissionsDialog.show = false + this.permissionsDialog.extension = null + this.permissionsDialog.checked = [] + if (!ext) return + ext._grantedPermissions = granted + if (ext.isPaymentRequired) { + this.showPayToEnable(ext, granted) + } else { + this.enableExtension(ext, granted) + } + }, updatePayToInstallData(extension) { LNbits.api .request( diff --git a/lnbits/tasks.py b/lnbits/tasks.py index f7428651e..a49dc7f11 100644 --- a/lnbits/tasks.py +++ b/lnbits/tasks.py @@ -96,6 +96,11 @@ def register_invoice_listener(send_chan: asyncio.Queue, name: str | None = None) invoice_listeners[name] = send_chan +def unregister_invoice_listener(name: str) -> None: + if name in invoice_listeners: + invoice_listeners.pop(name, None) + + internal_invoice_queue: asyncio.Queue = asyncio.Queue(0) diff --git a/lnbits/templates/components/admin/extensions.vue b/lnbits/templates/components/admin/extensions.vue index 6b341d1dc..f573b29fe 100644 --- a/lnbits/templates/components/admin/extensions.vue +++ b/lnbits/templates/components/admin/extensions.vue @@ -150,6 +150,58 @@ autocomplete="off" > +
+

+ WASM Sandbox Limits +

+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
diff --git a/lnbits/templates/pages/extensions.vue b/lnbits/templates/pages/extensions.vue index ae1ecd2e6..eea6744fa 100644 --- a/lnbits/templates/pages/extensions.vue +++ b/lnbits/templates/pages/extensions.vue @@ -921,6 +921,46 @@ + + + +
Permissions required
+
+ This extension can: +
+ + + + + + + + + + + +
+ + + + +
+
+