diff --git a/lnbits/app.py b/lnbits/app.py index 3fb0d4491..b8a3ab3bd 100644 --- a/lnbits/app.py +++ b/lnbits/app.py @@ -112,7 +112,7 @@ async def startup(app: FastAPI): create_llms_txt_route(app) # initialize tasks - register_async_tasks() + register_async_tasks(app) enqueue_admin_notification( NotificationType.server_start_stop, @@ -481,7 +481,7 @@ async def check_and_register_extensions(app: FastAPI) -> None: await update_installed_extension_state(ext_id=ext.code, active=False) -def register_async_tasks() -> None: +def register_async_tasks(app: FastAPI) -> None: create_permanent_task(wait_for_audit_data) create_permanent_task(wait_notification_messages) @@ -499,7 +499,7 @@ def register_async_tasks() -> None: # core invoice listener invoice_queue: asyncio.Queue = asyncio.Queue() register_invoice_listener(invoice_queue, "core") - create_permanent_task(lambda: wait_for_paid_invoices(invoice_queue)) + create_permanent_task(lambda: wait_for_paid_invoices(invoice_queue, app)) create_permanent_task(run_by_the_minute_tasks) create_permanent_task(purge_audit_data) diff --git a/lnbits/core/extensions/prototype.py b/lnbits/core/extensions/prototype.py index 9565e0db4..fffc4fe35 100644 --- a/lnbits/core/extensions/prototype.py +++ b/lnbits/core/extensions/prototype.py @@ -113,6 +113,7 @@ class InMemoryExtensionAPI(ExtensionAPI): unit=request.currency or "sat", memo=request.memo, extra=request.extra, + extension=self.extension_id, ), ) return CreateInvoiceResponse( diff --git a/lnbits/core/extensions/wasm.py b/lnbits/core/extensions/wasm.py index 6ac327368..231435f93 100644 --- a/lnbits/core/extensions/wasm.py +++ b/lnbits/core/extensions/wasm.py @@ -9,6 +9,8 @@ from typing import Any from fastapi import FastAPI +from lnbits.core.crud.extensions import get_installed_extension + from .api import list_extension_api_methods from .loader import WasmExtension, register_wasm_extension from .models import UserWalletSummary @@ -27,7 +29,7 @@ async def invoke_wasm_extension_export( extension = _get_registered_extension(app, ext_id) state = _get_extension_state(app) state.user_wallets[extension.id] = _user_wallet_summaries(user) - permissions = _extension_permissions(extension) + permissions = await _extension_permissions(extension) api = InMemoryExtensionAPI( extension.id, permissions, @@ -204,12 +206,11 @@ def _get_extension_state(app: FastAPI) -> InMemoryExtensionState: return state -def _extension_permissions(extension: WasmExtension) -> set[str]: - permissions = set() - for permission in extension.config.get("permissions") or []: - if isinstance(permission, Mapping) and isinstance(permission.get("id"), str): - permissions.add(permission["id"]) - return permissions +async def _extension_permissions(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 _user_id(user: Any | None) -> str | None: diff --git a/lnbits/core/migrations.py b/lnbits/core/migrations.py index d39bf5932..97f3c3bc1 100644 --- a/lnbits/core/migrations.py +++ b/lnbits/core/migrations.py @@ -815,3 +815,12 @@ async def m045_add_external_id_to_payments(db: Connection): CREATE INDEX IF NOT EXISTS idx_payments_external_id ON apipayments (external_id); """) + + +async def m046_add_permissions_to_installed_extensions(db: Connection): + """ + Adds granted permissions to installed extensions. + """ + await db.execute( + "ALTER TABLE installed_extensions ADD COLUMN permissions TEXT DEFAULT '[]'" + ) diff --git a/lnbits/core/models/extensions.py b/lnbits/core/models/extensions.py index 70af071aa..b2e7a6b84 100644 --- a/lnbits/core/models/extensions.py +++ b/lnbits/core/models/extensions.py @@ -77,6 +77,13 @@ class GitHubRepo(BaseModel): default_branch: str +class ExtensionPermission(BaseModel): + id: str + label: str | None = None + description: str | None = None + policy: dict[str, Any] | None = None + + class ExtensionConfig(BaseModel): name: str short_description: str @@ -84,6 +91,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) @@ -350,6 +359,7 @@ class InstallableExtension(BaseModel): icon: str | None = None stars: int = 0 meta: ExtensionMeta | None = None + permissions: list[ExtensionPermission] = [] @property def hash(self) -> str: @@ -624,6 +634,11 @@ class InstallableExtension(BaseModel): version=version, short_description=config_json.get("short_description"), icon=config_json.get("tile"), + permissions=[ + ExtensionPermission.parse_obj(permission) + for permission in config_json.get("permissions") or [] + if isinstance(permission, dict) and permission.get("id") + ], meta=ExtensionMeta( installed_release=ExtensionRelease( name=ext_id, @@ -814,6 +829,7 @@ class CreateExtension(BaseModel): version: str cost_sats: int | None = 0 payment_hash: str | None = None + permissions: list[ExtensionPermission] = [] class ExtensionDetailsRequest(BaseModel): diff --git a/lnbits/core/services/extensions.py b/lnbits/core/services/extensions.py index 411ed6cb5..436b9a4b5 100644 --- a/lnbits/core/services/extensions.py +++ b/lnbits/core/services/extensions.py @@ -1,5 +1,9 @@ import asyncio import importlib +import json +import zipfile +from pathlib import PurePosixPath +from typing import Any from loguru import logger @@ -20,11 +24,18 @@ from lnbits.core.helpers import migrate_extension_database from lnbits.db import Connection from lnbits.settings import settings -from ..models.extensions import Extension, ExtensionMeta, InstallableExtension +from ..models.extensions import ( + Extension, + ExtensionMeta, + ExtensionPermission, + InstallableExtension, +) async def install_extension( - ext_info: InstallableExtension, skip_download: bool | None = False + ext_info: InstallableExtension, + skip_download: bool | None = False, + granted_permissions: list[ExtensionPermission] | None = None, ) -> Extension: ext_info.meta = ext_info.meta or ExtensionMeta() @@ -44,6 +55,11 @@ async def install_extension( if not skip_download: await ext_info.download_archive() + extension_config = _load_extension_archive_config(ext_info) + ext_info.permissions = _validate_extension_permissions( + ext_info, granted_permissions, extension_config + ) + ext_info.extract_archive() db_version = await get_db_version(ext_info.id) @@ -66,6 +82,63 @@ async def install_extension( return extension +def _validate_extension_permissions( + ext_info: InstallableExtension, + granted_permissions: list[ExtensionPermission] | None, + extension_config: dict[str, Any], +) -> list[ExtensionPermission]: + if extension_config.get("extension_type") != "wasm": + return [] + + requested_permissions = [ + ExtensionPermission.parse_obj(permission) + for permission in extension_config.get("permissions") or [] + if isinstance(permission, dict) and permission.get("id") + ] + if not requested_permissions: + return [] + + if granted_permissions is None: + raise ValueError( + f"WASM extension '{ext_info.id}' requires permission approval." + ) + + requested_ids = {permission.id for permission in requested_permissions} + granted_ids = {permission.id for permission in granted_permissions} + if requested_ids != granted_ids: + raise ValueError( + f"WASM extension '{ext_info.id}' was not granted all requested " + "permissions." + ) + + return requested_permissions + + +def _load_extension_archive_config(ext_info: InstallableExtension) -> dict[str, Any]: + if not ext_info.zip_path.is_file(): + return {} + + try: + with zipfile.ZipFile(ext_info.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 '{ext_info.id}'.") from exc + + return config if isinstance(config, dict) else {} + + +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 + + async def check_extensions_limit(installed_ext: InstallableExtension | None = None): if settings.lnbits_max_extensions == 0 or installed_ext: return diff --git a/lnbits/core/tasks.py b/lnbits/core/tasks.py index c5f41bc7f..6e07e2e8d 100644 --- a/lnbits/core/tasks.py +++ b/lnbits/core/tasks.py @@ -1,5 +1,8 @@ import asyncio +import json +from typing import Any +from fastapi import FastAPI from loguru import logger from lnbits.core.crud import ( @@ -89,7 +92,9 @@ 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: +async def wait_for_paid_invoices( + invoice_paid_queue: asyncio.Queue, app: FastAPI | None = None +) -> None: """ This worker dispatches events to all extensions and dispatches webhooks. """ @@ -100,6 +105,85 @@ async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue) -> None: wallet = await get_wallet(payment.wallet_id) if wallet: await send_payment_notification(wallet, payment) + if app: + await dispatch_wasm_invoice_paid(app, payment) + + +async def dispatch_wasm_invoice_paid(app: FastAPI, payment: Any) -> None: + extension_id = _payment_extension_id(payment) + if not extension_id: + return + + extensions = getattr(app.state, "lnbits_wasm_extensions", {}) + extension = extensions.get(extension_id) + if not extension: + return + + export_name = _wasm_invoice_paid_export(extension.config) + if not export_name: + return + + if not _is_wasm_event_export(extension, export_name): + logger.warning( + f"WASM extension '{extension.id}' declares invalid onInvoicePaid " + f"export '{export_name}'." + ) + return + + try: + from lnbits.core.extensions.wasm import invoke_wasm_extension_export + + await invoke_wasm_extension_export( + app, + extension.id, + export_name, + _wasm_invoice_paid_payload(payment), + ) + except Exception as exc: + logger.warning( + f"WASM extension '{extension.id}' failed to handle paid invoice " + f"'{payment.payment_hash}': {exc!s}" + ) + + +def _payment_extension_id(payment: Any) -> str | None: + if isinstance(payment.extension, str) and payment.extension: + return payment.extension + + extra = payment.extra or {} + tag = extra.get("tag") or payment.tag + return tag if isinstance(tag, str) and tag else None + + +def _wasm_invoice_paid_export(config: dict[str, Any]) -> str | None: + events = config.get("events") or {} + export_name = events.get("onInvoicePaid") + return export_name if isinstance(export_name, str) and export_name else None + + +def _is_wasm_event_export(extension: Any, export_name: str) -> bool: + for export in extension.exports: + if export.get("name") == export_name: + return export.get("visibility") == "event" + return False + + +def _wasm_invoice_paid_payload(payment: Any) -> dict[str, Any]: + return { + "checkingId": payment.checking_id, + "paymentHash": payment.payment_hash, + "walletId": payment.wallet_id, + "amount": payment.amount, + "fee": payment.fee, + "bolt11": payment.bolt11, + "memo": payment.memo, + "pending": payment.pending, + "status": payment.status, + "tag": payment.tag, + "extension": payment.extension, + "extra": payment.extra or {}, + "payment": json.loads(payment.json()), + } async def wait_for_audit_data() -> None: diff --git a/lnbits/core/views/extension_api.py b/lnbits/core/views/extension_api.py index bd53bd2c4..b600d9aef 100644 --- a/lnbits/core/views/extension_api.py +++ b/lnbits/core/views/extension_api.py @@ -89,7 +89,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) @@ -463,6 +465,8 @@ async def get_extension_release(org: str, repo: str, tag_name: str): "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 config.permissions], } except Exception as exc: raise HTTPException( @@ -535,9 +539,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: @@ -558,47 +563,55 @@ 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, - "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, - "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) + extension_data.append( + { + "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, + "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": installed_ext.is_wasm if installed_ext else ext.is_wasm, + "permissions": ( + [dict(permission) for permission in installed_ext.permissions] + if installed_ext + else [] + ), + "inProgress": False, + "selectedForUpdate": False, + } + ) return extension_data diff --git a/lnbits/static/js/pages/extensions.js b/lnbits/static/js/pages/extensions.js index e2a3ce445..2c0f650f6 100644 --- a/lnbits/static/js/pages/extensions.js +++ b/lnbits/static/js/pages/extensions.js @@ -132,6 +132,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 = @@ -143,7 +149,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 @@ -406,6 +413,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( @@ -451,6 +462,10 @@ window.PageExtensions = { } }, async showInstallQRCode(release) { + if ((await this.resolveExtensionPermissionGrant(release)) === null) { + return + } + this.selectedRelease = release try { @@ -628,6 +643,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 @@ -636,6 +653,77 @@ 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 granted = await this.confirmExtensionPermissions(permissions) + if (!granted) { + return null + } + release.grantedPermissions = permissions + return permissions + }, + extensionPermissionsForRelease(release) { + return release.permissions || this.selectedExtension?.permissions || [] + }, + releaseRequiresPermissionGrant(release) { + return ( + release.extension_type === 'wasm' || + this.selectedExtension?.isWasm === true + ) + }, + confirmExtensionPermissions(permissions) { + const permissionItems = permissions + .map(permission => { + const label = this.escapeHtml(permission.label || permission.id) + const description = permission.description + ? `
${this.escapeHtml( + permission.description + )}
` + : '' + return `
  • ${label}${description}
  • ` + }) + .join('') + + return new Promise(resolve => { + this.$q + .dialog({ + title: 'Grant extension permissions', + message: `

    This WASM extension requests these permissions:

    `, + html: true, + persistent: true, + ok: { + label: 'Grant and install', + color: 'primary', + flat: true + }, + cancel: { + label: this.$t('cancel'), + color: 'grey', + flat: true + } + }) + .onOk(() => resolve(true)) + .onCancel(() => resolve(false)) + .onDismiss(() => resolve(false)) + }) + }, + escapeHtml(value) { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + }, async selectAllUpdatableExtensionss() { this.updatableExtensions.forEach(e => (e.selectedForUpdate = true)) }, @@ -646,6 +734,13 @@ window.PageExtensions = { if (!ext.selectedForUpdate) { continue } + if (ext.isWasm) { + Quasar.Notify.create({ + type: 'warning', + message: `Skipping ${ext.id}; WASM updates require permission approval.` + }) + continue + } ext.inProgress = true await LNbits.api.request('POST', `/api/v1/extension`, null, { ext_id: ext.id,