diff --git a/lnbits/app.py b/lnbits/app.py index a0c9abbef..b581eddd6 100644 --- a/lnbits/app.py +++ b/lnbits/app.py @@ -53,6 +53,10 @@ from lnbits.utils.logger import ( log_server_info, ) from lnbits.wallets import get_funding_source, set_funding_source +try: + from lnbits.extensions.wasm.wasm_host.extension_host import register_wasm_ext_routes +except Exception: # pragma: no cover - optional parent extension + register_wasm_ext_routes = None from .commands import migrate_databases from .core import init_core_routers @@ -423,6 +427,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"): @@ -432,6 +438,18 @@ def register_ext_tasks(ext: Extension) -> None: def register_ext_routes(app: FastAPI, ext: Extension) -> None: """Register FastAPI routes for extension.""" + if ext.extension_type != "wasm": + ext.extension_type = _load_extension_type(ext.code) or ext.extension_type + if ext.extension_type == "wasm": + settings.activate_extension_paths(ext.code, ext.upgrade_hash, []) + if register_wasm_ext_routes is None: + logger.error( + "WASM host extension not installed; cannot register wasm extension " + f"{ext.code}." + ) + return + register_wasm_ext_routes(app, ext) + return ext_module = importlib.import_module(ext.module_name) ext_route = getattr(ext_module, f"{ext.code}_ext") @@ -457,6 +475,20 @@ def register_ext_routes(app: FastAPI, ext: Extension) -> None: app.include_router(router=ext_route, prefix=prefix) +def _load_extension_type(ext_id: str) -> str | None: + try: + conf_path = Path( + settings.lnbits_extensions_path, "extensions", ext_id, "config.json" + ) + if not conf_path.is_file(): + return None + with open(conf_path, "r+") as json_file: + config_json = json.load(json_file) + return config_json.get("extension_type") + except Exception: + return None + + async def check_and_register_extensions(app: FastAPI) -> None: await check_installed_extensions(app) for ext in await get_valid_extensions(False): diff --git a/lnbits/core/helpers.py b/lnbits/core/helpers.py index 1b1c24b1b..a66102adf 100644 --- a/lnbits/core/helpers.py +++ b/lnbits/core/helpers.py @@ -22,6 +22,8 @@ from lnbits.settings import settings async def migrate_extension_database( ext: InstallableExtension, current_version: DbVersion | None = None ): + if _is_wasm_extension(ext): + return try: ext_migrations = importlib.import_module(f"{ext.module_name}.migrations") @@ -34,6 +36,34 @@ async def migrate_extension_database( await run_migration(ext_conn, ext_migrations, ext.id, current_version) +def _is_wasm_extension(ext: InstallableExtension) -> bool: + if ext.meta and getattr(ext.meta, "extension_type", None) == "wasm": + return True + + try: + import json + from pathlib import Path + + candidate_dirs = [ + Path(ext.ext_dir), + Path(settings.lnbits_extensions_path, "extensions", ext.id), + Path(settings.lnbits_path, "extensions", ext.id), + Path.cwd() / "extensions" / ext.id, + ] + for base in candidate_dirs: + conf_path = Path(base, "config.json") + if not conf_path.is_file(): + continue + with open(conf_path, "r+") as json_file: + config_json = json.load(json_file) + if config_json.get("extension_type") == "wasm": + return True + except Exception as exc: + logger.debug(f"Failed to load extension config for '{ext.id}': {exc!s}") + + return False + + async def run_migration( db: Connection, migrations_module: Any, diff --git a/lnbits/core/models/extensions.py b/lnbits/core/models/extensions.py index c0cfa3260..01fe5555d 100644 --- a/lnbits/core/models/extensions.py +++ b/lnbits/core/models/extensions.py @@ -115,6 +115,8 @@ class PayToEnableInfo(BaseModel): class UserExtensionInfo(BaseModel): paid_to_enable: bool | None = False payment_hash_to_enable: str | None = None + granted_permissions: list[str] | None = None + granted_payment_tags: list[str] | None = None class UserExtension(BaseModel): @@ -147,6 +149,7 @@ class Extension(BaseModel): short_description: str | None = None tile: str | None = None upgrade_hash: str | None = "" + extension_type: str | None = None @property def module_name(self) -> str: diff --git a/lnbits/core/views/extension_api.py b/lnbits/core/views/extension_api.py index 46bf3b080..4628aba10 100644 --- a/lnbits/core/views/extension_api.py +++ b/lnbits/core/views/extension_api.py @@ -1,6 +1,8 @@ +import json import sys import traceback from http import HTTPStatus +from pathlib import Path import httpx from bolt11 import decode as bolt11_decode @@ -48,6 +50,20 @@ from lnbits.decorators import ( ) from lnbits.settings import settings + +def _load_extension_type(ext_id: str) -> str: + try: + conf_path = Path( + settings.lnbits_extensions_path, "extensions", ext_id, "config.json" + ) + if not conf_path.is_file(): + return "python" + with open(conf_path, "r+") as json_file: + config_json = json.load(json_file) + return config_json.get("extension_type", "python") + except Exception: + return "python" + from ..crud import ( create_user_extension, delete_dbversion, @@ -596,6 +612,7 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)): "isPaymentRequired": ext.requires_payment, "inProgress": False, "selectedForUpdate": False, + "extensionType": _load_extension_type(ext.id), } for ext in installable_exts ] diff --git a/lnbits/static/js/pages/extensions.js b/lnbits/static/js/pages/extensions.js index a71160d54..1b976575a 100644 --- a/lnbits/static/js/pages/extensions.js +++ b/lnbits/static/js/pages/extensions.js @@ -28,6 +28,14 @@ window.PageExtensions = { paylinkWebsocket: null, searchToggle: false, reviewsUrl: null, + permissionsDialog: { + show: false, + extension: null, + checked: [], + missing: [], + tags: [], + tagOptions: [] + }, reviewsDialog: { show: false, extension: null, @@ -92,6 +100,20 @@ window.PageExtensions = { this.filterExtensions(this.searchTerm, val) } }, + computed: { + permissionsAllChecked() { + const ext = this.permissionsDialog.extension + if (!ext || !Array.isArray(ext.permissions)) return true + const required = ext.permissions.map(p => p.id) + return required.every(p => this.permissionsDialog.checked.includes(p)) + }, + permissionsHasMissingEndpoints() { + return ( + this.permissionsDialog.missing && + this.permissionsDialog.missing.length > 0 + ) + } + }, methods: { filterExtensions(term, tab) { // Filter the extensions list @@ -241,6 +263,37 @@ window.PageExtensions = { }) }, async enableExtensionForUser(extension) { + if (extension.extensionType === 'wasm') { + const wasmHost = this.extensions.find(ext => ext.id === 'wasm') + if (!wasmHost || !wasmHost.isInstalled || !wasmHost.isActive) { + Quasar.Notify.create({ + type: 'warning', + message: 'Enable the WASM! host extension before using this extension.' + }) + return + } + if (extension.permissions && extension.permissions.length) { + if (!extension._grantedPermissions) { + Quasar.Notify.create({ + type: 'warning', + message: 'Save permissions before enabling this extension.' + }) + return + } + } + if (extension.paymentTags && extension.paymentTags.length) { + if ( + !extension._grantedPaymentTags || + !extension._grantedPaymentTags.length + ) { + Quasar.Notify.create({ + type: 'warning', + message: 'Select payment tags before enabling this extension.' + }) + return + } + } + } if (extension.isPaymentRequired) { this.showPayToEnable(extension) return @@ -294,6 +347,118 @@ window.PageExtensions = { this.selectedExtension.payToEnable.showQRCode = false this.showPayToEnableDialog = true }, + openPermissionsDialog(extension) { + this.permissionsDialog.extension = extension + this.permissionsDialog.checked = extension._grantedPermissions + ? extension._grantedPermissions.slice() + : extension.grantedPermissions + ? extension.grantedPermissions.slice() + : [] + this.permissionsDialog.missing = [] + this.permissionsDialog.tags = extension._grantedPaymentTags + ? extension._grantedPaymentTags.slice() + : extension.grantedPaymentTags + ? extension.grantedPaymentTags.slice() + : [] + this.permissionsDialog.tagOptions = [] + this.permissionsDialog.show = true + }, + cancelPermissionsDialog() { + this.permissionsDialog.show = false + this.permissionsDialog.extension = null + this.permissionsDialog.checked = [] + this.permissionsDialog.missing = [] + this.permissionsDialog.tags = [] + this.permissionsDialog.tagOptions = [] + }, + async openPermissionsForExtension(extension) { + if (extension.extensionType !== 'wasm') { + Quasar.Notify.create({ + type: 'warning', + message: 'This extension does not use WASM permissions.' + }) + return + } + this.permissionsDialog.extension = extension + this.permissionsDialog.checked = extension._grantedPermissions + ? extension._grantedPermissions.slice() + : extension.grantedPermissions + ? extension.grantedPermissions.slice() + : [] + this.permissionsDialog.missing = [] + this.permissionsDialog.tags = extension._grantedPaymentTags + ? extension._grantedPaymentTags.slice() + : extension.grantedPaymentTags + ? extension.grantedPaymentTags.slice() + : [] + this.permissionsDialog.tagOptions = [] + try { + const {data} = await LNbits.api.request( + 'GET', + `/wasm/api/v1/extensions/${extension.id}/capabilities`, + this.g.user.wallets[0].adminkey + ) + if (data && Array.isArray(data.permissions)) { + extension.permissions = data.permissions + } + if (data && Array.isArray(data.missing_permissions)) { + this.permissionsDialog.missing = data.missing_permissions + } + if (data && Array.isArray(data.payment_tags)) { + extension.paymentTags = data.payment_tags + this.permissionsDialog.tagOptions = data.payment_tags + } + if (data && Array.isArray(data.granted_permissions)) { + extension.grantedPermissions = data.granted_permissions + this.permissionsDialog.checked = data.granted_permissions.slice() + } + if (data && Array.isArray(data.granted_payment_tags)) { + extension.grantedPaymentTags = data.granted_payment_tags + this.permissionsDialog.tags = data.granted_payment_tags.slice() + } + } catch (err) { + LNbits.utils.notifyApiError(err) + } + this.permissionsDialog.show = true + }, + async confirmPermissionsDialog() { + const ext = this.permissionsDialog.extension + const granted = this.permissionsDialog.checked.slice() + const tags = this.permissionsDialog.tags.slice() + this.permissionsDialog.show = false + this.permissionsDialog.extension = null + this.permissionsDialog.checked = [] + const missing = this.permissionsDialog.missing || [] + this.permissionsDialog.missing = [] + this.permissionsDialog.tags = [] + this.permissionsDialog.tagOptions = [] + if (missing.length) { + Quasar.Notify.create({ + type: 'negative', + message: 'Missing API endpoints for one or more permissions.' + }) + return + } + if (!ext) return + ext._grantedPermissions = granted + ext.grantedPermissions = granted + ext._grantedPaymentTags = tags + ext.grantedPaymentTags = tags + try { + await LNbits.api.request( + 'PUT', + `/wasm/api/v1/extensions/${ext.id}/permissions`, + this.g.user.wallets[0].adminkey, + {permissions: granted, payment_tags: tags} + ) + Quasar.Notify.create({ + type: 'positive', + message: 'Permissions saved.' + }) + } catch (err) { + LNbits.utils.notifyApiError(err) + } + }, updatePayToInstallData(extension) { LNbits.api .request( diff --git a/lnbits/tasks.py b/lnbits/tasks.py index 8d2b05e88..9e44cb776 100644 --- a/lnbits/tasks.py +++ b/lnbits/tasks.py @@ -92,6 +92,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/pages/extensions.vue b/lnbits/templates/pages/extensions.vue index 0bfcbef83..228817dda 100644 --- a/lnbits/templates/pages/extensions.vue +++ b/lnbits/templates/pages/extensions.vue @@ -329,6 +329,18 @@ + + + + +
Permissions required
+
This extension can:
+ + + + + + + + + + + +
+
+ Allow this extension to listen for payment tags: +
+ + + + + + + + + + +
+
+
+ Missing API endpoints required by this extension: +
+ +
+
+ + + + +
+
+