feat: ask permission

This commit is contained in:
Vlad Stan
2026-07-08 11:54:57 +03:00
parent 9c0035c01d
commit 97a1a0b303
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)
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,
+38 -10
View File
@@ -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
+16 -6
View File
@@ -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,
}
+8
View File
@@ -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',
+45 -44
View File
@@ -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
? `<div class="text-caption text-grey-7">${this.escapeHtml(
permission.description
)}</div>`
: ''
return `<li><strong>${label}</strong>${description}</li>`
})
.join('')
return new Promise(resolve => {
this.$q
.dialog({
title: 'Grant extension permissions',
message: `<p>This WASM extension requests these permissions:</p><ul>${permissionItems}</ul>`,
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))
this.selectedRelease = null
this.permissionGrant = {
show: true,
permissions,
resolve
}
this.showManageExtensionDialog = true
})
},
escapeHtml(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
grantExtensionPermissions() {
this.resolveExtensionPermissionDialog(this.permissionGrant.permissions)
},
cancelExtensionPermissions() {
this.resolveExtensionPermissionDialog(null)
},
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() {
this.updatableExtensions.forEach(e => (e.selectedForUpdate = true))
@@ -737,7 +738,7 @@ window.PageExtensions = {
if (ext.isWasm) {
Quasar.Notify.create({
type: 'warning',
message: `Skipping ${ext.id}; WASM updates require permission approval.`
message: `Skipping ${ext.id}; this extension update requires permission approval.`
})
continue
}
+47 -2
View File
@@ -455,8 +455,53 @@
</q-card>
</q-dialog>
<q-dialog v-model="showManageExtensionDialog" position="top">
<q-card v-if="selectedRelease" class="q-pa-lg lnbits__dialog-card">
<q-dialog
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>
<div v-if="selectedRelease.paymentRequest">
<lnbits-qrcode