diff --git a/lnbits/core/extensions/api.py b/lnbits/core/extensions/api.py index c861b7dd4..cc3c7aa2f 100644 --- a/lnbits/core/extensions/api.py +++ b/lnbits/core/extensions/api.py @@ -292,6 +292,16 @@ def list_extension_api_methods( return sorted(methods, key=lambda method: method.method_id) +def extension_api_permission_ids( + api_cls: type[ExtensionAPI] = ExtensionAPI, +) -> set[str]: + return { + method.required_permission + for method in list_extension_api_methods(api_cls) + if method.required_permission + } + + def get_extension_api_method( method_id: str, api_cls: type[ExtensionAPI] = ExtensionAPI, diff --git a/lnbits/core/services/extensions.py b/lnbits/core/services/extensions.py index 436b9a4b5..cf64347b3 100644 --- a/lnbits/core/services/extensions.py +++ b/lnbits/core/services/extensions.py @@ -2,6 +2,7 @@ import asyncio import importlib import json import zipfile +from collections.abc import Iterable from pathlib import PurePosixPath from typing import Any @@ -20,6 +21,7 @@ from lnbits.core.crud.extensions import ( get_installed_extensions, update_installed_extension, ) +from lnbits.core.extensions.api import extension_api_permission_ids from lnbits.core.helpers import migrate_extension_database from lnbits.db import Connection from lnbits.settings import settings @@ -82,6 +84,32 @@ async def install_extension( return extension +def validate_extension_permissions( + ext_id: str, + permissions: Iterable[ExtensionPermission], + *, + strict: bool = True, +) -> list[ExtensionPermission]: + known_permission_ids = extension_api_permission_ids() + normalized_permissions: list[ExtensionPermission] = [] + unknown_ids: list[str] = [] + + for permission in permissions: + if permission.id not in known_permission_ids: + unknown_ids.append(permission.id) + if strict: + continue + normalized_permissions.append(permission.copy(update={"label": None})) + + if unknown_ids and strict: + raise ValueError( + f"Extension '{ext_id}' requests unknown permissions: " + + ", ".join(sorted(set(unknown_ids))) + ) + + return normalized_permissions + + def _validate_extension_permissions( ext_info: InstallableExtension, granted_permissions: list[ExtensionPermission] | None, @@ -90,25 +118,25 @@ def _validate_extension_permissions( 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") - ] + requested_permissions = validate_extension_permissions( + ext_info.id, + [ + 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." - ) + raise ValueError(f"Extension '{ext_info.id}' requires permission approval.") requested_ids = {permission.id for permission in requested_permissions} granted_ids = {permission.id for permission in granted_permissions} if requested_ids != granted_ids: raise ValueError( - f"WASM extension '{ext_info.id}' was not granted all requested " - "permissions." + f"Extension '{ext_info.id}' was not granted all requested permissions." ) return requested_permissions diff --git a/lnbits/core/views/extension_api.py b/lnbits/core/views/extension_api.py index b600d9aef..52b8f8392 100644 --- a/lnbits/core/views/extension_api.py +++ b/lnbits/core/views/extension_api.py @@ -39,6 +39,7 @@ from lnbits.core.services.extensions import ( get_valid_extensions, install_extension, uninstall_extension, + validate_extension_permissions, ) from lnbits.db import Page from lnbits.decorators import ( @@ -461,13 +462,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 config.permissions], + "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) @@ -566,6 +573,13 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)): extension_data = [] for ext in installable_exts: installed_ext = installed_exts_by_id.get(ext.id) + permissions = ( + validate_extension_permissions( + installed_ext.id, installed_ext.permissions, strict=False + ) + if installed_ext + else [] + ) extension_data.append( { "id": ext.id, @@ -603,11 +617,7 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)): ), "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 [] - ), + "permissions": [dict(permission) for permission in permissions], "inProgress": False, "selectedForUpdate": False, } diff --git a/lnbits/static/i18n/en.js b/lnbits/static/i18n/en.js index be3d57f3b..f3028d6c7 100644 --- a/lnbits/static/i18n/en.js +++ b/lnbits/static/i18n/en.js @@ -519,6 +519,14 @@ 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_request: 'This extension requests these permissions:', + extension_permissions_grant_install: 'Grant and install', + extension_permission_ext_storage_read_write: + 'Read and write extension storage', + extension_permission_payments_watch: 'Watch payments', + extension_permission_wallet_create_invoice: 'Create invoices', + extension_permission_wallet_list: 'List wallets', create_extension: 'Create Extension', release_details_error: 'Cannot get the release details.', pay_from_wallet: 'Pay from Wallet', diff --git a/lnbits/static/js/pages/extensions.js b/lnbits/static/js/pages/extensions.js index 2c0f650f6..e5d0a5ab6 100644 --- a/lnbits/static/js/pages/extensions.js +++ b/lnbits/static/js/pages/extensions.js @@ -24,6 +24,11 @@ window.PageExtensions = { selectedExtensionDetails: null, selectedExtensionRepos: null, selectedRelease: null, + permissionGrant: { + show: false, + permissions: [], + resolve: null + }, uninstallAndDropDb: false, maxStars: 5, paylinkWebsocket: null, @@ -664,12 +669,13 @@ window.PageExtensions = { if (release.grantedPermissions) { return release.grantedPermissions } - const granted = await this.confirmExtensionPermissions(permissions) - if (!granted) { + const grantedPermissions = + await this.confirmExtensionPermissions(permissions) + if (!grantedPermissions) { return null } - release.grantedPermissions = permissions - return permissions + release.grantedPermissions = grantedPermissions + return grantedPermissions }, extensionPermissionsForRelease(release) { return release.permissions || this.selectedExtension?.permissions || [] @@ -681,48 +687,43 @@ window.PageExtensions = { ) }, confirmExtensionPermissions(permissions) { - const permissionItems = permissions - .map(permission => { - const label = this.escapeHtml(permission.label || permission.id) - const description = permission.description - ? `
This WASM extension requests these permissions: