feat: ask permission

This commit is contained in:
Vlad Stan
2026-07-01 16:57:21 +03:00
parent 710464c05e
commit e83fc8cb86
6 changed files with 164 additions and 62 deletions
+10
View File
@@ -292,6 +292,16 @@ def list_extension_api_methods(
return sorted(methods, key=lambda method: method.method_id) 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( def get_extension_api_method(
method_id: str, method_id: str,
api_cls: type[ExtensionAPI] = ExtensionAPI, api_cls: type[ExtensionAPI] = ExtensionAPI,
+38 -10
View File
@@ -2,6 +2,7 @@ import asyncio
import importlib import importlib
import json import json
import zipfile import zipfile
from collections.abc import Iterable
from pathlib import PurePosixPath from pathlib import PurePosixPath
from typing import Any from typing import Any
@@ -20,6 +21,7 @@ from lnbits.core.crud.extensions import (
get_installed_extensions, get_installed_extensions,
update_installed_extension, update_installed_extension,
) )
from lnbits.core.extensions.api import extension_api_permission_ids
from lnbits.core.helpers import migrate_extension_database from lnbits.core.helpers import migrate_extension_database
from lnbits.db import Connection from lnbits.db import Connection
from lnbits.settings import settings from lnbits.settings import settings
@@ -82,6 +84,32 @@ async def install_extension(
return 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( def _validate_extension_permissions(
ext_info: InstallableExtension, ext_info: InstallableExtension,
granted_permissions: list[ExtensionPermission] | None, granted_permissions: list[ExtensionPermission] | None,
@@ -90,25 +118,25 @@ def _validate_extension_permissions(
if extension_config.get("extension_type") != "wasm": if extension_config.get("extension_type") != "wasm":
return [] return []
requested_permissions = [ requested_permissions = validate_extension_permissions(
ExtensionPermission.parse_obj(permission) ext_info.id,
for permission in extension_config.get("permissions") or [] [
if isinstance(permission, dict) and permission.get("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: if not requested_permissions:
return [] return []
if granted_permissions is None: if granted_permissions is None:
raise ValueError( raise ValueError(f"Extension '{ext_info.id}' requires permission approval.")
f"WASM extension '{ext_info.id}' requires permission approval."
)
requested_ids = {permission.id for permission in requested_permissions} requested_ids = {permission.id for permission in requested_permissions}
granted_ids = {permission.id for permission in granted_permissions} granted_ids = {permission.id for permission in granted_permissions}
if requested_ids != granted_ids: if requested_ids != granted_ids:
raise ValueError( raise ValueError(
f"WASM extension '{ext_info.id}' was not granted all requested " f"Extension '{ext_info.id}' was not granted all requested permissions."
"permissions."
) )
return requested_permissions return requested_permissions
+16 -6
View File
@@ -39,6 +39,7 @@ from lnbits.core.services.extensions import (
get_valid_extensions, get_valid_extensions,
install_extension, install_extension,
uninstall_extension, uninstall_extension,
validate_extension_permissions,
) )
from lnbits.db import Page from lnbits.db import Page
from lnbits.decorators import ( from lnbits.decorators import (
@@ -461,13 +462,19 @@ async def get_extension_release(org: str, repo: str, tag_name: str):
if not config: if not config:
return {} return {}
permissions = validate_extension_permissions(config.name, config.permissions)
return { return {
"min_lnbits_version": config.min_lnbits_version, "min_lnbits_version": config.min_lnbits_version,
"is_version_compatible": config.is_version_compatible(), "is_version_compatible": config.is_version_compatible(),
"warning": config.warning, "warning": config.warning,
"extension_type": config.extension_type, "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: except Exception as exc:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(exc) 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 = [] extension_data = []
for ext in installable_exts: for ext in installable_exts:
installed_ext = installed_exts_by_id.get(ext.id) 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( extension_data.append(
{ {
"id": ext.id, "id": ext.id,
@@ -603,11 +617,7 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
), ),
"isPaymentRequired": ext.requires_payment, "isPaymentRequired": ext.requires_payment,
"isWasm": installed_ext.is_wasm if installed_ext else ext.is_wasm, "isWasm": installed_ext.is_wasm if installed_ext else ext.is_wasm,
"permissions": ( "permissions": [dict(permission) for permission in permissions],
[dict(permission) for permission in installed_ext.permissions]
if installed_ext
else []
),
"inProgress": False, "inProgress": False,
"selectedForUpdate": False, "selectedForUpdate": False,
} }
+8
View File
@@ -519,6 +519,14 @@ window.localisation.en = {
admin_settings: 'Admin Settings', admin_settings: 'Admin Settings',
extension_cost: 'This release requires a payment of minimum {cost} sats.', extension_cost: 'This release requires a payment of minimum {cost} sats.',
extension_paid_sats: 'You have already paid {paid_sats} 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', create_extension: 'Create Extension',
release_details_error: 'Cannot get the release details.', release_details_error: 'Cannot get the release details.',
pay_from_wallet: 'Pay from Wallet', pay_from_wallet: 'Pay from Wallet',
+45 -44
View File
@@ -24,6 +24,11 @@ window.PageExtensions = {
selectedExtensionDetails: null, selectedExtensionDetails: null,
selectedExtensionRepos: null, selectedExtensionRepos: null,
selectedRelease: null, selectedRelease: null,
permissionGrant: {
show: false,
permissions: [],
resolve: null
},
uninstallAndDropDb: false, uninstallAndDropDb: false,
maxStars: 5, maxStars: 5,
paylinkWebsocket: null, paylinkWebsocket: null,
@@ -664,12 +669,13 @@ window.PageExtensions = {
if (release.grantedPermissions) { if (release.grantedPermissions) {
return release.grantedPermissions return release.grantedPermissions
} }
const granted = await this.confirmExtensionPermissions(permissions) const grantedPermissions =
if (!granted) { await this.confirmExtensionPermissions(permissions)
if (!grantedPermissions) {
return null return null
} }
release.grantedPermissions = permissions release.grantedPermissions = grantedPermissions
return permissions return grantedPermissions
}, },
extensionPermissionsForRelease(release) { extensionPermissionsForRelease(release) {
return release.permissions || this.selectedExtension?.permissions || [] return release.permissions || this.selectedExtension?.permissions || []
@@ -681,48 +687,43 @@ window.PageExtensions = {
) )
}, },
confirmExtensionPermissions(permissions) { confirmExtensionPermissions(permissions) {
const permissionItems = permissions
.map(permission => {
const label = this.escapeHtml(permission.label || permission.id)
const description = permission.description
? `<div class="text-caption text-grey-7">${this.escapeHtml(
permission.description
)}</div>`
: ''
return `<li><strong>${label}</strong>${description}</li>`
})
.join('')
return new Promise(resolve => { return new Promise(resolve => {
this.$q this.selectedRelease = null
.dialog({ this.permissionGrant = {
title: 'Grant extension permissions', show: true,
message: `<p>This WASM extension requests these permissions:</p><ul>${permissionItems}</ul>`, permissions,
html: true, resolve
persistent: true, }
ok: { this.showManageExtensionDialog = true
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) { grantExtensionPermissions() {
return String(value) this.resolveExtensionPermissionDialog(this.permissionGrant.permissions)
.replace(/&/g, '&amp;') },
.replace(/</g, '&lt;') cancelExtensionPermissions() {
.replace(/>/g, '&gt;') this.resolveExtensionPermissionDialog(null)
.replace(/"/g, '&quot;') },
.replace(/'/g, '&#039;') 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)
}
},
permissionLabel(permission) {
const key = `extension_permission_${permission.id.replace(/[^A-Za-z0-9]/g, '_')}`
const label = this.$t(key)
return label === key ? permission.id : label
}, },
async selectAllUpdatableExtensionss() { async selectAllUpdatableExtensionss() {
this.updatableExtensions.forEach(e => (e.selectedForUpdate = true)) this.updatableExtensions.forEach(e => (e.selectedForUpdate = true))
@@ -737,7 +738,7 @@ window.PageExtensions = {
if (ext.isWasm) { if (ext.isWasm) {
Quasar.Notify.create({ Quasar.Notify.create({
type: 'warning', type: 'warning',
message: `Skipping ${ext.id}; WASM updates require permission approval.` message: `Skipping ${ext.id}; this extension update requires permission approval.`
}) })
continue continue
} }
+47 -2
View File
@@ -455,8 +455,53 @@
</q-card> </q-card>
</q-dialog> </q-dialog>
<q-dialog v-model="showManageExtensionDialog" position="top"> <q-dialog
<q-card v-if="selectedRelease" class="q-pa-lg lnbits__dialog-card"> v-model="showManageExtensionDialog"
position="top"
@hide="onManageExtensionDialogHide"
>
<q-card v-if="permissionGrant.show" class="q-pa-lg lnbits__dialog-card">
<q-card-section>
<div class="text-h6" v-text="$t('extension_permissions_title')"></div>
<div
class="text-body2 q-mt-sm"
v-text="$t('extension_permissions_request')"
></div>
</q-card-section>
<q-list bordered separator class="q-mt-md">
<q-item
v-for="permission of permissionGrant.permissions"
:key="permission.id"
>
<q-item-section>
<q-item-label v-text="permissionLabel(permission)"></q-item-label>
<q-item-label
v-if="permission.description"
caption
v-text="permission.description"
></q-item-label>
</q-item-section>
</q-item>
</q-list>
<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> <q-card-section>
<div v-if="selectedRelease.paymentRequest"> <div v-if="selectedRelease.paymentRequest">
<lnbits-qrcode <lnbits-qrcode