This commit is contained in:
Arc
2026-05-25 14:56:04 +03:00
committed by Vlad Stan
parent 190a466c0a
commit f3dd667f4a
7 changed files with 349 additions and 0 deletions
+32
View File
@@ -53,6 +53,10 @@ from lnbits.utils.logger import (
log_server_info, log_server_info,
) )
from lnbits.wallets import get_funding_source, set_funding_source from lnbits.wallets import get_funding_source, set_funding_source
try:
from lnbits.extensions.wasm.wasm_host.extension_host import register_wasm_ext_routes
except Exception: # pragma: no cover - optional parent extension
register_wasm_ext_routes = None
from .commands import migrate_databases from .commands import migrate_databases
from .core import init_core_routers from .core import init_core_routers
@@ -423,6 +427,8 @@ def register_new_ratelimiter(app: FastAPI) -> Callable:
def register_ext_tasks(ext: Extension) -> None: def register_ext_tasks(ext: Extension) -> None:
"""Register extension async tasks.""" """Register extension async tasks."""
if ext.extension_type == "wasm":
return
ext_module = importlib.import_module(ext.module_name) ext_module = importlib.import_module(ext.module_name)
if hasattr(ext_module, f"{ext.code}_start"): if hasattr(ext_module, f"{ext.code}_start"):
@@ -432,6 +438,18 @@ def register_ext_tasks(ext: Extension) -> None:
def register_ext_routes(app: FastAPI, ext: Extension) -> None: def register_ext_routes(app: FastAPI, ext: Extension) -> None:
"""Register FastAPI routes for extension.""" """Register FastAPI routes for extension."""
if ext.extension_type != "wasm":
ext.extension_type = _load_extension_type(ext.code) or ext.extension_type
if ext.extension_type == "wasm":
settings.activate_extension_paths(ext.code, ext.upgrade_hash, [])
if register_wasm_ext_routes is None:
logger.error(
"WASM host extension not installed; cannot register wasm extension "
f"{ext.code}."
)
return
register_wasm_ext_routes(app, ext)
return
ext_module = importlib.import_module(ext.module_name) ext_module = importlib.import_module(ext.module_name)
ext_route = getattr(ext_module, f"{ext.code}_ext") ext_route = getattr(ext_module, f"{ext.code}_ext")
@@ -457,6 +475,20 @@ def register_ext_routes(app: FastAPI, ext: Extension) -> None:
app.include_router(router=ext_route, prefix=prefix) app.include_router(router=ext_route, prefix=prefix)
def _load_extension_type(ext_id: str) -> str | None:
try:
conf_path = Path(
settings.lnbits_extensions_path, "extensions", ext_id, "config.json"
)
if not conf_path.is_file():
return None
with open(conf_path, "r+") as json_file:
config_json = json.load(json_file)
return config_json.get("extension_type")
except Exception:
return None
async def check_and_register_extensions(app: FastAPI) -> None: async def check_and_register_extensions(app: FastAPI) -> None:
await check_installed_extensions(app) await check_installed_extensions(app)
for ext in await get_valid_extensions(False): for ext in await get_valid_extensions(False):
+30
View File
@@ -22,6 +22,8 @@ from lnbits.settings import settings
async def migrate_extension_database( async def migrate_extension_database(
ext: InstallableExtension, current_version: DbVersion | None = None ext: InstallableExtension, current_version: DbVersion | None = None
): ):
if _is_wasm_extension(ext):
return
try: try:
ext_migrations = importlib.import_module(f"{ext.module_name}.migrations") ext_migrations = importlib.import_module(f"{ext.module_name}.migrations")
@@ -34,6 +36,34 @@ async def migrate_extension_database(
await run_migration(ext_conn, ext_migrations, ext.id, current_version) await run_migration(ext_conn, ext_migrations, ext.id, current_version)
def _is_wasm_extension(ext: InstallableExtension) -> bool:
if ext.meta and getattr(ext.meta, "extension_type", None) == "wasm":
return True
try:
import json
from pathlib import Path
candidate_dirs = [
Path(ext.ext_dir),
Path(settings.lnbits_extensions_path, "extensions", ext.id),
Path(settings.lnbits_path, "extensions", ext.id),
Path.cwd() / "extensions" / ext.id,
]
for base in candidate_dirs:
conf_path = Path(base, "config.json")
if not conf_path.is_file():
continue
with open(conf_path, "r+") as json_file:
config_json = json.load(json_file)
if config_json.get("extension_type") == "wasm":
return True
except Exception as exc:
logger.debug(f"Failed to load extension config for '{ext.id}': {exc!s}")
return False
async def run_migration( async def run_migration(
db: Connection, db: Connection,
migrations_module: Any, migrations_module: Any,
+3
View File
@@ -115,6 +115,8 @@ class PayToEnableInfo(BaseModel):
class UserExtensionInfo(BaseModel): class UserExtensionInfo(BaseModel):
paid_to_enable: bool | None = False paid_to_enable: bool | None = False
payment_hash_to_enable: str | None = None payment_hash_to_enable: str | None = None
granted_permissions: list[str] | None = None
granted_payment_tags: list[str] | None = None
class UserExtension(BaseModel): class UserExtension(BaseModel):
@@ -147,6 +149,7 @@ class Extension(BaseModel):
short_description: str | None = None short_description: str | None = None
tile: str | None = None tile: str | None = None
upgrade_hash: str | None = "" upgrade_hash: str | None = ""
extension_type: str | None = None
@property @property
def module_name(self) -> str: def module_name(self) -> str:
+17
View File
@@ -1,6 +1,8 @@
import json
import sys import sys
import traceback import traceback
from http import HTTPStatus from http import HTTPStatus
from pathlib import Path
import httpx import httpx
from bolt11 import decode as bolt11_decode from bolt11 import decode as bolt11_decode
@@ -48,6 +50,20 @@ from lnbits.decorators import (
) )
from lnbits.settings import settings from lnbits.settings import settings
def _load_extension_type(ext_id: str) -> str:
try:
conf_path = Path(
settings.lnbits_extensions_path, "extensions", ext_id, "config.json"
)
if not conf_path.is_file():
return "python"
with open(conf_path, "r+") as json_file:
config_json = json.load(json_file)
return config_json.get("extension_type", "python")
except Exception:
return "python"
from ..crud import ( from ..crud import (
create_user_extension, create_user_extension,
delete_dbversion, delete_dbversion,
@@ -596,6 +612,7 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
"isPaymentRequired": ext.requires_payment, "isPaymentRequired": ext.requires_payment,
"inProgress": False, "inProgress": False,
"selectedForUpdate": False, "selectedForUpdate": False,
"extensionType": _load_extension_type(ext.id),
} }
for ext in installable_exts for ext in installable_exts
] ]
+165
View File
@@ -28,6 +28,14 @@ window.PageExtensions = {
paylinkWebsocket: null, paylinkWebsocket: null,
searchToggle: false, searchToggle: false,
reviewsUrl: null, reviewsUrl: null,
permissionsDialog: {
show: false,
extension: null,
checked: [],
missing: [],
tags: [],
tagOptions: []
},
reviewsDialog: { reviewsDialog: {
show: false, show: false,
extension: null, extension: null,
@@ -92,6 +100,20 @@ window.PageExtensions = {
this.filterExtensions(this.searchTerm, val) this.filterExtensions(this.searchTerm, val)
} }
}, },
computed: {
permissionsAllChecked() {
const ext = this.permissionsDialog.extension
if (!ext || !Array.isArray(ext.permissions)) return true
const required = ext.permissions.map(p => p.id)
return required.every(p => this.permissionsDialog.checked.includes(p))
},
permissionsHasMissingEndpoints() {
return (
this.permissionsDialog.missing &&
this.permissionsDialog.missing.length > 0
)
}
},
methods: { methods: {
filterExtensions(term, tab) { filterExtensions(term, tab) {
// Filter the extensions list // Filter the extensions list
@@ -241,6 +263,37 @@ window.PageExtensions = {
}) })
}, },
async enableExtensionForUser(extension) { async enableExtensionForUser(extension) {
if (extension.extensionType === 'wasm') {
const wasmHost = this.extensions.find(ext => ext.id === 'wasm')
if (!wasmHost || !wasmHost.isInstalled || !wasmHost.isActive) {
Quasar.Notify.create({
type: 'warning',
message: 'Enable the WASM! host extension before using this extension.'
})
return
}
if (extension.permissions && extension.permissions.length) {
if (!extension._grantedPermissions) {
Quasar.Notify.create({
type: 'warning',
message: 'Save permissions before enabling this extension.'
})
return
}
}
if (extension.paymentTags && extension.paymentTags.length) {
if (
!extension._grantedPaymentTags ||
!extension._grantedPaymentTags.length
) {
Quasar.Notify.create({
type: 'warning',
message: 'Select payment tags before enabling this extension.'
})
return
}
}
}
if (extension.isPaymentRequired) { if (extension.isPaymentRequired) {
this.showPayToEnable(extension) this.showPayToEnable(extension)
return return
@@ -294,6 +347,118 @@ window.PageExtensions = {
this.selectedExtension.payToEnable.showQRCode = false this.selectedExtension.payToEnable.showQRCode = false
this.showPayToEnableDialog = true this.showPayToEnableDialog = true
}, },
openPermissionsDialog(extension) {
this.permissionsDialog.extension = extension
this.permissionsDialog.checked = extension._grantedPermissions
? extension._grantedPermissions.slice()
: extension.grantedPermissions
? extension.grantedPermissions.slice()
: []
this.permissionsDialog.missing = []
this.permissionsDialog.tags = extension._grantedPaymentTags
? extension._grantedPaymentTags.slice()
: extension.grantedPaymentTags
? extension.grantedPaymentTags.slice()
: []
this.permissionsDialog.tagOptions = []
this.permissionsDialog.show = true
},
cancelPermissionsDialog() {
this.permissionsDialog.show = false
this.permissionsDialog.extension = null
this.permissionsDialog.checked = []
this.permissionsDialog.missing = []
this.permissionsDialog.tags = []
this.permissionsDialog.tagOptions = []
},
async openPermissionsForExtension(extension) {
if (extension.extensionType !== 'wasm') {
Quasar.Notify.create({
type: 'warning',
message: 'This extension does not use WASM permissions.'
})
return
}
this.permissionsDialog.extension = extension
this.permissionsDialog.checked = extension._grantedPermissions
? extension._grantedPermissions.slice()
: extension.grantedPermissions
? extension.grantedPermissions.slice()
: []
this.permissionsDialog.missing = []
this.permissionsDialog.tags = extension._grantedPaymentTags
? extension._grantedPaymentTags.slice()
: extension.grantedPaymentTags
? extension.grantedPaymentTags.slice()
: []
this.permissionsDialog.tagOptions = []
try {
const {data} = await LNbits.api.request(
'GET',
`/wasm/api/v1/extensions/${extension.id}/capabilities`,
this.g.user.wallets[0].adminkey
)
if (data && Array.isArray(data.permissions)) {
extension.permissions = data.permissions
}
if (data && Array.isArray(data.missing_permissions)) {
this.permissionsDialog.missing = data.missing_permissions
}
if (data && Array.isArray(data.payment_tags)) {
extension.paymentTags = data.payment_tags
this.permissionsDialog.tagOptions = data.payment_tags
}
if (data && Array.isArray(data.granted_permissions)) {
extension.grantedPermissions = data.granted_permissions
this.permissionsDialog.checked = data.granted_permissions.slice()
}
if (data && Array.isArray(data.granted_payment_tags)) {
extension.grantedPaymentTags = data.granted_payment_tags
this.permissionsDialog.tags = data.granted_payment_tags.slice()
}
} catch (err) {
LNbits.utils.notifyApiError(err)
}
this.permissionsDialog.show = true
},
async confirmPermissionsDialog() {
const ext = this.permissionsDialog.extension
const granted = this.permissionsDialog.checked.slice()
const tags = this.permissionsDialog.tags.slice()
this.permissionsDialog.show = false
this.permissionsDialog.extension = null
this.permissionsDialog.checked = []
const missing = this.permissionsDialog.missing || []
this.permissionsDialog.missing = []
this.permissionsDialog.tags = []
this.permissionsDialog.tagOptions = []
if (missing.length) {
Quasar.Notify.create({
type: 'negative',
message: 'Missing API endpoints for one or more permissions.'
})
return
}
if (!ext) return
ext._grantedPermissions = granted
ext.grantedPermissions = granted
ext._grantedPaymentTags = tags
ext.grantedPaymentTags = tags
try {
await LNbits.api.request(
'PUT',
`/wasm/api/v1/extensions/${ext.id}/permissions`,
this.g.user.wallets[0].adminkey,
{permissions: granted, payment_tags: tags}
)
Quasar.Notify.create({
type: 'positive',
message: 'Permissions saved.'
})
} catch (err) {
LNbits.utils.notifyApiError(err)
}
},
updatePayToInstallData(extension) { updatePayToInstallData(extension) {
LNbits.api LNbits.api
.request( .request(
+5
View File
@@ -92,6 +92,11 @@ def register_invoice_listener(send_chan: asyncio.Queue, name: str | None = None)
invoice_listeners[name] = send_chan invoice_listeners[name] = send_chan
def unregister_invoice_listener(name: str) -> None:
if name in invoice_listeners:
invoice_listeners.pop(name, None)
internal_invoice_queue: asyncio.Queue = asyncio.Queue(0) internal_invoice_queue: asyncio.Queue = asyncio.Queue(0)
+97
View File
@@ -329,6 +329,18 @@
<span v-text="$t('enable_extension_details')"> <span v-text="$t('enable_extension_details')">
</span> </q-tooltip </span> </q-tooltip
></q-btn> ></q-btn>
<q-btn
v-if="
extension.isInstalled &&
extension.isActive &&
!g.user.extensions.includes(extension.id) &&
extension.extensionType === 'wasm'
"
flat
color="grey-5"
@click="openPermissionsForExtension(extension)"
label="Permissions"
></q-btn>
<q-btn <q-btn
@click="showManageExtension(extension)" @click="showManageExtension(extension)"
@@ -921,6 +933,91 @@
</q-card> </q-card>
</q-dialog> </q-dialog>
<q-dialog v-model="permissionsDialog.show" position="top">
<q-card class="q-pa-md" style="min-width: 360px; max-width: 90vw">
<q-card-section>
<div class="text-h6">Permissions required</div>
<div class="text-caption text-grey">This extension can:</div>
<q-list v-if="permissionsDialog.extension">
<q-item
v-for="perm in permissionsDialog.extension.permissions"
:key="perm.id || perm"
clickable
>
<q-item-section>
<q-item-label v-text="perm.label || perm"></q-item-label>
<q-item-label
caption
v-if="perm.description"
v-text="perm.description"
></q-item-label>
</q-item-section>
<q-item-section side>
<q-checkbox
v-model="permissionsDialog.checked"
:val="perm.id || perm"
/>
</q-item-section>
</q-item>
</q-list>
<div
v-if="
permissionsDialog.tagOptions && permissionsDialog.tagOptions.length
"
class="q-mt-md"
>
<div class="text-caption text-grey">
Allow this extension to listen for payment tags:
</div>
<q-list>
<q-item
v-for="tag in permissionsDialog.tagOptions"
:key="tag"
clickable
>
<q-item-section>
<q-item-label v-text="tag"></q-item-label>
</q-item-section>
<q-item-section side>
<q-checkbox v-model="permissionsDialog.tags" :val="tag" />
</q-item-section>
</q-item>
</q-list>
</div>
<div
v-if="permissionsDialog.missing && permissionsDialog.missing.length"
class="q-mt-md text-negative"
>
<div class="text-caption">
Missing API endpoints required by this extension:
</div>
<q-chip
v-for="perm in permissionsDialog.missing"
:key="perm"
:label="perm"
color="red-2"
text-color="black"
class="q-mr-xs q-mt-xs"
/>
</div>
</q-card-section>
<q-card-actions align="right">
<q-btn
flat
color="grey"
v-text="$t('cancel')"
@click="cancelPermissionsDialog"
></q-btn>
<q-btn
color="primary"
:disable="!permissionsAllChecked || permissionsHasMissingEndpoints"
label="Save"
@click="confirmPermissionsDialog"
></q-btn>
</q-card-actions>
</q-card>
</q-dialog>
<q-dialog v-model="showExtensionDetailsDialog" position="top"> <q-dialog v-model="showExtensionDetailsDialog" position="top">
<q-card <q-card
v-if="selectedExtensionDetails" v-if="selectedExtensionDetails"