feat: ask permission
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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, '&')
|
},
|
||||||
.replace(/</g, '<')
|
cancelExtensionPermissions() {
|
||||||
.replace(/>/g, '>')
|
this.resolveExtensionPermissionDialog(null)
|
||||||
.replace(/"/g, '"')
|
},
|
||||||
.replace(/'/g, ''')
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user