Compare commits
58
Commits
dev
...
wasm_extensions
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e71830817 | ||
|
|
0fb2334181 | ||
|
|
94c7796dd5 | ||
|
|
af4ce8da52 | ||
|
|
4e8a5e050b | ||
|
|
3fd7aae10d | ||
|
|
64ad19a65b | ||
|
|
ee6ff7179e | ||
|
|
684dfcc9fb | ||
|
|
a498d4a877 | ||
|
|
ef90c983f2 | ||
|
|
da8a82c78e | ||
|
|
51f98e8130 | ||
|
|
7b4436b288 | ||
|
|
ec88cf483e | ||
|
|
ca215063be | ||
|
|
2674cfb5f1 | ||
|
|
26f0b69085 | ||
|
|
2069e3de8b | ||
|
|
800be6042f | ||
|
|
c65d373f23 | ||
|
|
087e095bcf | ||
|
|
027a161494 | ||
|
|
83ef05696e | ||
|
|
42962308e6 | ||
|
|
783ee256a1 | ||
|
|
4e805fd00e | ||
|
|
f54824cc44 | ||
|
|
768d2cbe2a | ||
|
|
efd36d0221 | ||
|
|
4684939e13 | ||
|
|
193ee20da4 | ||
|
|
2fe7ab4f83 | ||
|
|
10c8ca4ca1 | ||
|
|
f079762b71 | ||
|
|
2e042d2597 | ||
|
|
49b57c9f0b | ||
|
|
656c6cac5b | ||
|
|
45e773b66f | ||
|
|
f692ac6f12 | ||
|
|
78202a027b | ||
|
|
28fa0906a6 | ||
|
|
91354f8be4 | ||
|
|
5f5d45e89f | ||
|
|
52bb125a25 | ||
|
|
2ca1ed897c | ||
|
|
631f4e8455 | ||
|
|
b22f88b264 | ||
|
|
c08bc02930 | ||
|
|
e020a7c5a0 | ||
|
|
d2e8914835 | ||
|
|
5e79ca35ab | ||
|
|
4690d178a2 | ||
|
|
c5825aa4c3 | ||
|
|
ebe0c4e3c3 | ||
|
|
383bdb6312 | ||
|
|
a947686d79 | ||
|
|
28e9d32b44 |
@@ -70,6 +70,7 @@ FORWARDED_ALLOW_IPS="*"
|
||||
# Inside this directory the `extensions` and `upgrades` sub-directories will be created.
|
||||
# LNBITS_EXTENSIONS_PATH="/path/to/some/dir"
|
||||
|
||||
|
||||
# ID of the super user. The user ID must exist.
|
||||
# SUPER_USER=""
|
||||
|
||||
|
||||
+28
-2
@@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
import glob
|
||||
import importlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
@@ -35,9 +36,12 @@ from lnbits.core.tasks import (
|
||||
purge_audit_data,
|
||||
run_by_the_minute_tasks,
|
||||
wait_for_audit_data,
|
||||
wait_for_paid_invoices,
|
||||
wait_notification_messages,
|
||||
)
|
||||
from lnbits.core.tasks import (
|
||||
wait_for_paid_invoices as wait_for_paid_invoices_core,
|
||||
)
|
||||
from lnbits.core.wasm.extension_host import register_wasm_ext_routes
|
||||
from lnbits.exceptions import register_exception_handlers
|
||||
from lnbits.helpers import version_parse
|
||||
from lnbits.settings import settings
|
||||
@@ -419,6 +423,8 @@ def register_new_ratelimiter(app: FastAPI) -> Callable:
|
||||
|
||||
def register_ext_tasks(ext: Extension) -> None:
|
||||
"""Register extension async tasks."""
|
||||
if ext.extension_type == "wasm":
|
||||
return
|
||||
ext_module = importlib.import_module(ext.module_name)
|
||||
|
||||
if hasattr(ext_module, f"{ext.code}_start"):
|
||||
@@ -428,6 +434,12 @@ def register_ext_tasks(ext: Extension) -> None:
|
||||
|
||||
def register_ext_routes(app: FastAPI, ext: Extension) -> None:
|
||||
"""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, [])
|
||||
register_wasm_ext_routes(app, ext)
|
||||
return
|
||||
ext_module = importlib.import_module(ext.module_name)
|
||||
|
||||
ext_route = getattr(ext_module, f"{ext.code}_ext")
|
||||
@@ -453,6 +465,20 @@ def register_ext_routes(app: FastAPI, ext: Extension) -> None:
|
||||
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:
|
||||
await check_installed_extensions(app)
|
||||
for ext in await get_valid_extensions(False):
|
||||
@@ -476,7 +502,7 @@ def register_async_tasks() -> None:
|
||||
# core invoice listener
|
||||
invoice_queue: asyncio.Queue = asyncio.Queue()
|
||||
register_invoice_listener(invoice_queue, "core")
|
||||
create_permanent_task(lambda: wait_for_paid_invoices(invoice_queue))
|
||||
create_permanent_task(lambda: wait_for_paid_invoices_core(invoice_queue))
|
||||
|
||||
create_permanent_task(run_by_the_minute_tasks)
|
||||
create_permanent_task(purge_audit_data)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import re
|
||||
|
||||
from lnbits.core.db import db
|
||||
from lnbits.core.models.extensions import (
|
||||
InstallableExtension,
|
||||
@@ -49,7 +51,8 @@ async def drop_extension_db(ext_id: str, conn: Connection | None = None) -> None
|
||||
)
|
||||
# Check that 'ext_id' is a valid extension id and not a malicious string
|
||||
if not row:
|
||||
raise Exception(f"Extension '{ext_id}' db version cannot be found")
|
||||
if not re.fullmatch(r"[a-zA-Z_][a-zA-Z0-9_]*", ext_id):
|
||||
raise Exception(f"Extension '{ext_id}' db version cannot be found")
|
||||
|
||||
is_file_based_db = await Database.clean_ext_db_files(ext_id)
|
||||
if is_file_based_db:
|
||||
|
||||
@@ -22,6 +22,8 @@ from lnbits.settings import settings
|
||||
async def migrate_extension_database(
|
||||
ext: InstallableExtension, current_version: DbVersion | None = None
|
||||
):
|
||||
if _is_wasm_extension(ext):
|
||||
return
|
||||
|
||||
try:
|
||||
ext_migrations = importlib.import_module(f"{ext.module_name}.migrations")
|
||||
@@ -58,6 +60,34 @@ async def run_migration(
|
||||
await update_migration_version(conn, db_name, version)
|
||||
|
||||
|
||||
def _is_wasm_extension(ext: InstallableExtension) -> bool:
|
||||
if ext.meta and ext.meta.extension_type == "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
|
||||
|
||||
|
||||
def to_valid_user_id(user_id: str) -> UUID:
|
||||
if len(user_id) < 32:
|
||||
raise ValueError("User ID must have at least 128 bits")
|
||||
|
||||
@@ -83,6 +83,11 @@ class ExtensionConfig(BaseModel):
|
||||
warning: str | None = ""
|
||||
min_lnbits_version: str | None
|
||||
max_lnbits_version: str | None
|
||||
permissions: list[ExtensionPermission] = []
|
||||
extension_type: str | None = "python"
|
||||
public_kv_keys: list[str] = []
|
||||
public_wasm_functions: list[str] = []
|
||||
payment_tags: list[str] = []
|
||||
|
||||
def is_version_compatible(self) -> bool:
|
||||
return is_lnbits_version_ok(self.min_lnbits_version, self.max_lnbits_version)
|
||||
@@ -115,6 +120,20 @@ class PayToEnableInfo(BaseModel):
|
||||
class UserExtensionInfo(BaseModel):
|
||||
paid_to_enable: bool | None = False
|
||||
payment_hash_to_enable: str | None = None
|
||||
granted_permissions: list[str] | None = None
|
||||
granted_payment_tags: list[str] | None = None
|
||||
|
||||
|
||||
class ExtensionPermission(BaseModel):
|
||||
id: str
|
||||
label: str
|
||||
description: str
|
||||
dangerous: bool | None = False
|
||||
|
||||
|
||||
class ExtensionPermissionsGrant(BaseModel):
|
||||
permissions: list[str] = []
|
||||
payment_tags: list[str] = []
|
||||
|
||||
|
||||
class UserExtension(BaseModel):
|
||||
@@ -147,6 +166,10 @@ class Extension(BaseModel):
|
||||
short_description: str | None = None
|
||||
tile: str | None = None
|
||||
upgrade_hash: str | None = ""
|
||||
extension_type: str | None = None
|
||||
public_kv_keys: list[str] = []
|
||||
public_wasm_functions: list[str] = []
|
||||
payment_tags: list[str] = []
|
||||
|
||||
@property
|
||||
def module_name(self) -> str:
|
||||
@@ -170,6 +193,12 @@ class Extension(BaseModel):
|
||||
short_description=ext_info.short_description,
|
||||
tile=ext_info.icon,
|
||||
upgrade_hash=ext_info.hash if ext_info.ext_upgrade_dir.is_dir() else "",
|
||||
extension_type=ext_info.meta.extension_type if ext_info.meta else None,
|
||||
public_kv_keys=ext_info.meta.public_kv_keys if ext_info.meta else [],
|
||||
public_wasm_functions=(
|
||||
ext_info.meta.public_wasm_functions if ext_info.meta else []
|
||||
),
|
||||
payment_tags=ext_info.meta.payment_tags if ext_info.meta else [],
|
||||
)
|
||||
|
||||
|
||||
@@ -331,6 +360,11 @@ class ExtensionMeta(BaseModel):
|
||||
pay_to_enable: PayToEnableInfo | None = None
|
||||
payments: list[ReleasePaymentInfo] = []
|
||||
dependencies: list[str] = []
|
||||
permissions: list[ExtensionPermission] = []
|
||||
extension_type: str | None = "python"
|
||||
public_kv_keys: list[str] = []
|
||||
public_wasm_functions: list[str] = []
|
||||
payment_tags: list[str] = []
|
||||
archive: str | None = None
|
||||
featured: bool = False
|
||||
paid_features: str | None = None
|
||||
@@ -454,6 +488,14 @@ class InstallableExtension(BaseModel):
|
||||
|
||||
self.name = config_json.get("name")
|
||||
self.short_description = config_json.get("short_description")
|
||||
if self.meta:
|
||||
self.meta.permissions = config_json.get("permissions", [])
|
||||
self.meta.extension_type = config_json.get("extension_type", "python")
|
||||
self.meta.public_kv_keys = config_json.get("public_kv_keys", [])
|
||||
self.meta.public_wasm_functions = config_json.get(
|
||||
"public_wasm_functions", []
|
||||
)
|
||||
self.meta.payment_tags = config_json.get("payment_tags", [])
|
||||
|
||||
if (
|
||||
self.meta
|
||||
@@ -572,6 +614,11 @@ class InstallableExtension(BaseModel):
|
||||
latest_release=ExtensionRelease.from_github_release(
|
||||
source_repo, latest_release
|
||||
),
|
||||
permissions=config.permissions,
|
||||
extension_type=config.extension_type,
|
||||
public_kv_keys=config.public_kv_keys,
|
||||
public_wasm_functions=config.public_wasm_functions,
|
||||
payment_tags=config.payment_tags,
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
@@ -617,7 +664,14 @@ class InstallableExtension(BaseModel):
|
||||
source_repo=f"{conf_path}",
|
||||
min_lnbits_version=config_json.get("min_lnbits_version"),
|
||||
max_lnbits_version=config_json.get("max_lnbits_version"),
|
||||
)
|
||||
),
|
||||
permissions=config_json.get("permissions", []),
|
||||
extension_type=config_json.get("extension_type", "python"),
|
||||
public_kv_keys=config_json.get("public_kv_keys", []),
|
||||
public_wasm_functions=config_json.get(
|
||||
"public_wasm_functions", []
|
||||
),
|
||||
payment_tags=config_json.get("payment_tags", []),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -841,3 +895,6 @@ def icon_to_github_url(source_repo: str, path: str | None) -> str:
|
||||
_, _, *rest = path.split("/")
|
||||
tail = "/".join(rest)
|
||||
return f"https://github.com/{source_repo}/raw/main/{tail}"
|
||||
|
||||
|
||||
ExtensionConfig.update_forward_refs(ExtensionPermission=ExtensionPermission)
|
||||
|
||||
@@ -10,13 +10,18 @@ from lnbits.core.crud import (
|
||||
get_db_version,
|
||||
get_installed_extension,
|
||||
update_installed_extension_state,
|
||||
update_migration_version,
|
||||
)
|
||||
from lnbits.core.crud.extensions import (
|
||||
get_installed_extensions,
|
||||
update_installed_extension,
|
||||
)
|
||||
from lnbits.core.helpers import migrate_extension_database
|
||||
from lnbits.db import Connection
|
||||
from lnbits.core.wasm.extension_host import (
|
||||
clear_schedules_for_extension,
|
||||
clear_tag_watches_for_extension,
|
||||
)
|
||||
from lnbits.db import COCKROACH, POSTGRES, Connection, Database
|
||||
from lnbits.settings import settings
|
||||
|
||||
from ..models.extensions import Extension, ExtensionMeta, InstallableExtension
|
||||
@@ -53,6 +58,9 @@ async def install_extension(
|
||||
else:
|
||||
await update_installed_extension(ext_info)
|
||||
|
||||
if ext_info.meta and ext_info.meta.extension_type == "wasm":
|
||||
await update_migration_version(None, ext_info.id, 0)
|
||||
|
||||
extension = Extension.from_installable_ext(ext_info)
|
||||
if extension.is_upgrade_extension:
|
||||
# call stop while the old routes are still active
|
||||
@@ -70,10 +78,26 @@ async def uninstall_extension(ext_id: str):
|
||||
|
||||
extension = await get_installed_extension(ext_id)
|
||||
if extension:
|
||||
if extension.meta and extension.meta.extension_type == "wasm":
|
||||
await clear_tag_watches_for_extension(ext_id)
|
||||
await clear_schedules_for_extension(ext_id)
|
||||
await _purge_wasm_extension_db(ext_id)
|
||||
extension.clean_extension_files()
|
||||
await delete_installed_extension(ext_id=ext_id)
|
||||
|
||||
|
||||
async def _purge_wasm_extension_db(ext_id: str) -> None:
|
||||
cleaned = await Database.clean_ext_db_files(ext_id)
|
||||
if cleaned:
|
||||
return
|
||||
try:
|
||||
db = Database(f"ext_{ext_id}")
|
||||
if db.type in {POSTGRES, COCKROACH}:
|
||||
await db.execute(f"DROP SCHEMA IF EXISTS {ext_id} CASCADE")
|
||||
except Exception as exc:
|
||||
logger.warning(f"Failed to drop WASM extension schema for '{ext_id}': {exc}")
|
||||
|
||||
|
||||
async def activate_extension(ext: Extension):
|
||||
core_app_extra.register_new_ext_routes(ext)
|
||||
await update_installed_extension_state(ext_id=ext.code, active=True)
|
||||
@@ -83,6 +107,10 @@ async def activate_extension(ext: Extension):
|
||||
async def deactivate_extension(ext_id: str):
|
||||
settings.deactivate_extension_paths(ext_id)
|
||||
await update_installed_extension_state(ext_id=ext_id, active=False)
|
||||
extension = await get_installed_extension(ext_id)
|
||||
if extension and extension.meta and extension.meta.extension_type == "wasm":
|
||||
await clear_tag_watches_for_extension(ext_id)
|
||||
await clear_schedules_for_extension(ext_id)
|
||||
await stop_extension_background_work(ext_id)
|
||||
|
||||
|
||||
@@ -91,6 +119,9 @@ async def stop_extension_background_work(ext_id: str) -> bool:
|
||||
Stop background work for extension (like asyncio.Tasks, WebSockets, etc).
|
||||
Extension must expose a `myextension_stop()` function if it is starting tasks.
|
||||
"""
|
||||
installed = await get_installed_extension(ext_id)
|
||||
if installed and installed.meta and installed.meta.extension_type == "wasm":
|
||||
return True
|
||||
upgrade_hash = settings.extension_upgrade_hash(ext_id)
|
||||
ext = Extension(code=ext_id, is_valid=True, upgrade_hash=upgrade_hash)
|
||||
|
||||
@@ -123,6 +154,9 @@ async def start_extension_background_work(ext_id: str) -> bool:
|
||||
Extension CAN expose a `myextension_start()` function if it is starting tasks.
|
||||
Extension MUST expose a `myextension_stop()` in that case.
|
||||
"""
|
||||
installed = await get_installed_extension(ext_id)
|
||||
if installed and installed.meta and installed.meta.extension_type == "wasm":
|
||||
return True
|
||||
upgrade_hash = settings.extension_upgrade_hash(ext_id)
|
||||
ext = Extension(code=ext_id, is_valid=True, upgrade_hash=upgrade_hash)
|
||||
|
||||
|
||||
@@ -313,6 +313,11 @@ async def send_ws_payment_notification(wallet: Wallet, payment: Payment):
|
||||
payment.payment_hash,
|
||||
json.dumps({"pending": payment.pending, "status": payment.status}),
|
||||
)
|
||||
if payment.tag and not payment.is_out:
|
||||
await websocket_manager.send(
|
||||
f"tag:{wallet.id}:{payment.tag}",
|
||||
payment.json(),
|
||||
)
|
||||
|
||||
|
||||
async def send_chat_payment_notification(wallet: Wallet, payment: Payment):
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import json
|
||||
import sys
|
||||
import traceback
|
||||
from http import HTTPStatus
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from bolt11 import decode as bolt11_decode
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException
|
||||
from fastapi.requests import Request
|
||||
from loguru import logger
|
||||
from starlette.routing import Match
|
||||
|
||||
from lnbits.core.crud.extensions import get_user_extensions
|
||||
from lnbits.core.crud.wallets import get_wallets_ids
|
||||
@@ -20,6 +23,8 @@ from lnbits.core.models.extensions import (
|
||||
Extension,
|
||||
ExtensionConfig,
|
||||
ExtensionMeta,
|
||||
ExtensionPermission,
|
||||
ExtensionPermissionsGrant,
|
||||
ExtensionRelease,
|
||||
ExtensionReview,
|
||||
ExtensionReviewPaymentRequest,
|
||||
@@ -40,6 +45,11 @@ from lnbits.core.services.extensions import (
|
||||
install_extension,
|
||||
uninstall_extension,
|
||||
)
|
||||
from lnbits.core.wasm import WASM_HOST_MANIFEST
|
||||
from lnbits.core.wasm.extension_host import (
|
||||
clear_schedules_for_user,
|
||||
clear_tag_watches_for_user,
|
||||
)
|
||||
from lnbits.db import Page
|
||||
from lnbits.decorators import (
|
||||
check_account_exists,
|
||||
@@ -174,38 +184,146 @@ async def api_update_pay_to_enable(
|
||||
|
||||
@extension_router.put("/{ext_id}/enable")
|
||||
async def api_enable_extension(
|
||||
ext_id: str, account_id: AccountId = Depends(check_account_id_exists)
|
||||
ext_id: str,
|
||||
request: Request,
|
||||
account_id: AccountId = Depends(check_account_id_exists),
|
||||
grant: ExtensionPermissionsGrant | None = Body(default=None),
|
||||
) -> SimpleStatus:
|
||||
await _ensure_extension_exists(ext_id)
|
||||
logger.info(f"Enabling extension: {ext_id}.")
|
||||
|
||||
ext = await _get_installed_active_extension(ext_id)
|
||||
user_ext = await _get_or_create_user_extension(account_id.id, ext_id)
|
||||
|
||||
required_permissions = _get_required_permissions(ext_id, ext)
|
||||
granted_permissions = _get_granted_permissions(grant, user_ext)
|
||||
granted_tags = _get_granted_payment_tags(grant, user_ext)
|
||||
required_tags = _get_required_payment_tags(ext_id, ext)
|
||||
if _is_wasm_extension(ext_id, ext):
|
||||
_ensure_api_permissions_available(
|
||||
request, _merge_permissions(required_permissions, granted_permissions)
|
||||
)
|
||||
_ensure_payment_tags_allowed(required_tags, granted_tags)
|
||||
_ensure_permissions(required_permissions, granted_permissions)
|
||||
|
||||
if grant and grant.permissions:
|
||||
await _store_granted_permissions(user_ext, granted_permissions)
|
||||
if grant and grant.payment_tags:
|
||||
await _store_granted_payment_tags(user_ext, granted_tags)
|
||||
|
||||
if account_id.is_admin_id or not ext.requires_payment:
|
||||
await _activate_user_extension(user_ext)
|
||||
return SimpleStatus(success=True, message=f"Extension '{ext_id}' enabled.")
|
||||
|
||||
return await _enable_paid_extension(ext_id, ext, user_ext)
|
||||
|
||||
|
||||
async def _ensure_extension_exists(ext_id: str) -> None:
|
||||
if ext_id not in [e.code for e in await get_valid_extensions()]:
|
||||
raise HTTPException(
|
||||
HTTPStatus.NOT_FOUND, f"Extension '{ext_id}' doesn't exist."
|
||||
)
|
||||
|
||||
logger.info(f"Enabling extension: {ext_id}.")
|
||||
|
||||
async def _get_installed_active_extension(ext_id: str) -> InstallableExtension:
|
||||
ext = await get_installed_extension(ext_id)
|
||||
if not ext:
|
||||
raise ValueError(f"Extension '{ext_id}' is not installed.")
|
||||
if not ext.active:
|
||||
raise ValueError(f"Extension '{ext_id}' is not activated.")
|
||||
return ext
|
||||
|
||||
user_ext = await get_user_extension(account_id.id, ext_id)
|
||||
|
||||
async def _get_or_create_user_extension(user_id: str, ext_id: str) -> UserExtension:
|
||||
user_ext = await get_user_extension(user_id, ext_id)
|
||||
if not user_ext:
|
||||
user_ext = UserExtension(user=account_id.id, extension=ext_id, active=False)
|
||||
user_ext = UserExtension(user=user_id, extension=ext_id, active=False)
|
||||
await create_user_extension(user_ext)
|
||||
return user_ext
|
||||
|
||||
if account_id.is_admin_id or not ext.requires_payment:
|
||||
user_ext.active = True
|
||||
await update_user_extension(user_ext)
|
||||
return SimpleStatus(success=True, message=f"Extension '{ext_id}' enabled.")
|
||||
|
||||
def _get_required_permissions(ext_id: str, ext: InstallableExtension) -> list[str]:
|
||||
permissions_source = (
|
||||
ext.meta.permissions
|
||||
if ext.meta and ext.meta.permissions
|
||||
else _load_permissions_from_config(ext_id)
|
||||
)
|
||||
return [p.id for p in permissions_source] if permissions_source else []
|
||||
|
||||
|
||||
def _get_granted_permissions(
|
||||
grant: ExtensionPermissionsGrant | None, user_ext: UserExtension
|
||||
) -> list[str]:
|
||||
if grant and grant.permissions:
|
||||
return grant.permissions
|
||||
if user_ext.extra and user_ext.extra.granted_permissions:
|
||||
return user_ext.extra.granted_permissions
|
||||
return []
|
||||
|
||||
|
||||
def _get_required_payment_tags(ext_id: str, ext: InstallableExtension) -> list[str]:
|
||||
tags_source = (
|
||||
ext.meta.payment_tags
|
||||
if ext.meta and ext.meta.payment_tags
|
||||
else _load_payment_tags_from_config(ext_id)
|
||||
)
|
||||
return tags_source if tags_source else []
|
||||
|
||||
|
||||
def _get_granted_payment_tags(
|
||||
grant: ExtensionPermissionsGrant | None, user_ext: UserExtension
|
||||
) -> list[str]:
|
||||
if grant and grant.payment_tags:
|
||||
return grant.payment_tags
|
||||
if user_ext.extra and user_ext.extra.granted_payment_tags:
|
||||
return user_ext.extra.granted_payment_tags
|
||||
return []
|
||||
|
||||
|
||||
def _ensure_permissions(required: list[str], granted: list[str]) -> None:
|
||||
if not required:
|
||||
return
|
||||
missing = [p for p in required if p not in granted]
|
||||
if missing:
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
"Missing required permissions to enable this extension.",
|
||||
)
|
||||
|
||||
|
||||
async def _store_granted_permissions(
|
||||
user_ext: UserExtension, granted_permissions: list[str]
|
||||
) -> None:
|
||||
user_ext_info = user_ext.extra or UserExtensionInfo()
|
||||
user_ext_info.granted_permissions = granted_permissions
|
||||
user_ext.extra = user_ext_info
|
||||
await update_user_extension(user_ext)
|
||||
|
||||
|
||||
async def _store_granted_payment_tags(
|
||||
user_ext: UserExtension, granted_tags: list[str]
|
||||
) -> None:
|
||||
user_ext_info = user_ext.extra or UserExtensionInfo()
|
||||
user_ext_info.granted_payment_tags = granted_tags
|
||||
user_ext.extra = user_ext_info
|
||||
await update_user_extension(user_ext)
|
||||
|
||||
|
||||
async def _activate_user_extension(user_ext: UserExtension) -> None:
|
||||
user_ext.active = True
|
||||
await update_user_extension(user_ext)
|
||||
|
||||
|
||||
async def _enable_paid_extension(
|
||||
ext_id: str, ext: InstallableExtension, user_ext: UserExtension
|
||||
) -> SimpleStatus:
|
||||
if not (user_ext.extra and user_ext.extra.payment_hash_to_enable):
|
||||
raise HTTPException(
|
||||
HTTPStatus.PAYMENT_REQUIRED, f"Extension '{ext_id}' requires payment."
|
||||
)
|
||||
|
||||
if user_ext.is_paid:
|
||||
user_ext.active = True
|
||||
await update_user_extension(user_ext)
|
||||
await _activate_user_extension(user_ext)
|
||||
return SimpleStatus(success=True, message=f"Paid extension '{ext_id}' enabled.")
|
||||
|
||||
if not ext.meta or not ext.meta.pay_to_enable or not ext.meta.pay_to_enable.wallet:
|
||||
@@ -222,9 +340,8 @@ async def api_enable_extension(
|
||||
f"Invoice generated but not paid for enabeling extension '{ext_id}'.",
|
||||
)
|
||||
|
||||
user_ext.active = True
|
||||
user_ext.extra.paid_to_enable = True
|
||||
await update_user_extension(user_ext)
|
||||
await _activate_user_extension(user_ext)
|
||||
return SimpleStatus(success=True, message=f"Paid extension '{ext_id}' enabled.")
|
||||
|
||||
|
||||
@@ -243,10 +360,117 @@ async def api_disable_extension(
|
||||
)
|
||||
logger.info(f"Disabling extension: {ext_id}.")
|
||||
user_ext.active = False
|
||||
if user_ext.extra and user_ext.extra.granted_permissions:
|
||||
user_ext.extra.granted_permissions = []
|
||||
if user_ext.extra and user_ext.extra.granted_payment_tags:
|
||||
user_ext.extra.granted_payment_tags = []
|
||||
await update_user_extension(user_ext)
|
||||
ext = await get_installed_extension(ext_id)
|
||||
if ext and ext.meta and ext.meta.extension_type == "wasm":
|
||||
await clear_tag_watches_for_user(ext_id, account_id.id)
|
||||
await clear_schedules_for_user(ext_id, account_id.id)
|
||||
return SimpleStatus(success=True, message=f"Extension '{ext_id}' disabled.")
|
||||
|
||||
|
||||
@extension_router.put("/{ext_id}/permissions")
|
||||
async def api_update_extension_permissions(
|
||||
ext_id: str,
|
||||
request: Request,
|
||||
account_id: AccountId = Depends(check_account_id_exists),
|
||||
grant: ExtensionPermissionsGrant | None = Body(default=None),
|
||||
) -> SimpleStatus:
|
||||
if ext_id not in [e.code for e in await get_valid_extensions()]:
|
||||
raise HTTPException(
|
||||
HTTPStatus.NOT_FOUND, f"Extension '{ext_id}' doesn't exist."
|
||||
)
|
||||
|
||||
ext = await get_installed_extension(ext_id)
|
||||
if not ext:
|
||||
raise ValueError(f"Extension '{ext_id}' is not installed.")
|
||||
if not ext.active:
|
||||
raise ValueError(f"Extension '{ext_id}' is not activated.")
|
||||
|
||||
user_ext = await get_user_extension(account_id.id, ext_id)
|
||||
if not user_ext:
|
||||
user_ext = UserExtension(user=account_id.id, extension=ext_id, active=False)
|
||||
await create_user_extension(user_ext)
|
||||
|
||||
permissions_source = (
|
||||
ext.meta.permissions
|
||||
if ext.meta and ext.meta.permissions
|
||||
else _load_permissions_from_config(ext_id)
|
||||
)
|
||||
required_permissions = (
|
||||
[p.id for p in permissions_source] if permissions_source else []
|
||||
)
|
||||
granted_permissions = grant.permissions if grant and grant.permissions else []
|
||||
granted_tags = grant.payment_tags if grant and grant.payment_tags else []
|
||||
if _is_wasm_extension(ext_id, ext):
|
||||
_ensure_api_permissions_available(
|
||||
request, _merge_permissions(required_permissions, granted_permissions)
|
||||
)
|
||||
_ensure_payment_tags_allowed(
|
||||
_get_required_payment_tags(ext_id, ext), granted_tags
|
||||
)
|
||||
|
||||
if required_permissions:
|
||||
missing = [p for p in required_permissions if p not in granted_permissions]
|
||||
if missing:
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
"Missing required permissions to save for this extension.",
|
||||
)
|
||||
|
||||
user_ext_info = user_ext.extra or UserExtensionInfo()
|
||||
user_ext_info.granted_permissions = granted_permissions
|
||||
user_ext_info.granted_payment_tags = granted_tags
|
||||
user_ext.extra = user_ext_info
|
||||
await update_user_extension(user_ext)
|
||||
|
||||
return SimpleStatus(success=True, message=f"Permissions saved for '{ext_id}'.")
|
||||
|
||||
|
||||
@extension_router.get("/{ext_id}/capabilities")
|
||||
async def api_extension_capabilities(
|
||||
ext_id: str,
|
||||
request: Request,
|
||||
account_id: AccountId = Depends(check_account_id_exists),
|
||||
) -> dict:
|
||||
await _ensure_extension_exists(ext_id)
|
||||
ext = await get_installed_extension(ext_id)
|
||||
if not ext:
|
||||
raise ValueError(f"Extension '{ext_id}' is not installed.")
|
||||
|
||||
permissions_source = (
|
||||
ext.meta.permissions
|
||||
if ext.meta and ext.meta.permissions
|
||||
else _load_permissions_from_config(ext_id)
|
||||
)
|
||||
permissions = [p.id for p in permissions_source] if permissions_source else []
|
||||
missing = (
|
||||
_missing_api_permissions(request, permissions)
|
||||
if _is_wasm_extension(ext_id, ext)
|
||||
else []
|
||||
)
|
||||
payment_tags = _get_required_payment_tags(ext_id, ext)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"extension": ext_id,
|
||||
"is_wasm": _is_wasm_extension(ext_id, ext),
|
||||
"permissions": permissions,
|
||||
"missing_permissions": missing,
|
||||
"payment_tags": payment_tags,
|
||||
}
|
||||
|
||||
|
||||
@extension_router.get("/wasm/manifest")
|
||||
async def api_wasm_manifest(
|
||||
account_id: AccountId = Depends(check_account_id_exists),
|
||||
) -> dict:
|
||||
return WASM_HOST_MANIFEST
|
||||
|
||||
|
||||
@extension_router.put("/{ext_id}/activate", dependencies=[Depends(check_admin)])
|
||||
async def api_activate_extension(ext_id: str) -> SimpleStatus:
|
||||
try:
|
||||
@@ -519,6 +743,7 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
|
||||
installed_exts: list[InstallableExtension] = await get_installed_extensions(
|
||||
conn=conn
|
||||
)
|
||||
user_exts = await get_user_extensions(account_id.id, conn=conn)
|
||||
all_ext_ids = [ext.code for ext in await get_valid_extensions(conn=conn)]
|
||||
inactive_extensions = [
|
||||
e.id for e in await get_installed_extensions(active=False, conn=conn)
|
||||
@@ -526,6 +751,7 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
|
||||
db_versions = await get_db_versions(conn=conn)
|
||||
|
||||
installed_exts_ids = [e.id for e in installed_exts]
|
||||
user_exts_map = {e.extension: e for e in user_exts}
|
||||
|
||||
installable_exts = await InstallableExtension.get_installable_extensions(
|
||||
post_refresh_cache=account_id.is_admin_id
|
||||
@@ -554,6 +780,10 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
|
||||
e.name = installed_ext.name
|
||||
e.short_description = installed_ext.short_description
|
||||
e.icon = installed_ext.icon
|
||||
if e.meta and not e.meta.permissions:
|
||||
e.meta.permissions = _load_permissions_from_config(e.id)
|
||||
if e.meta and not e.meta.payment_tags:
|
||||
e.meta.payment_tags = _load_payment_tags_from_config(e.id)
|
||||
|
||||
extension_data = [
|
||||
{
|
||||
@@ -571,6 +801,27 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
|
||||
"isAvailable": ext.id in all_ext_ids,
|
||||
"isAdminOnly": ext.id in settings.lnbits_admin_extensions,
|
||||
"isActive": ext.id not in inactive_extensions,
|
||||
"permissions": (
|
||||
[dict(p) for p in ext.meta.permissions]
|
||||
if ext.meta and ext.meta.permissions
|
||||
else [dict(p) for p in _load_permissions_from_config(ext.id)]
|
||||
),
|
||||
"paymentTags": (
|
||||
ext.meta.payment_tags
|
||||
if ext.meta and ext.meta.payment_tags
|
||||
else _load_payment_tags_from_config(ext.id)
|
||||
),
|
||||
"kvSchema": _load_kv_schema_from_config(ext.id),
|
||||
"grantedPermissions": (
|
||||
user_ext.extra.granted_permissions
|
||||
if (user_ext := user_exts_map.get(ext.id)) and user_ext.extra
|
||||
else []
|
||||
),
|
||||
"grantedPaymentTags": (
|
||||
user_ext.extra.granted_payment_tags
|
||||
if (user_ext := user_exts_map.get(ext.id)) and user_ext.extra
|
||||
else []
|
||||
),
|
||||
"latestRelease": (
|
||||
dict(ext.meta.latest_release)
|
||||
if ext.meta and ext.meta.latest_release
|
||||
@@ -598,6 +849,162 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
|
||||
return extension_data
|
||||
|
||||
|
||||
def _load_permissions_from_config(ext_id: str) -> list[ExtensionPermission]:
|
||||
try:
|
||||
conf_path = Path(
|
||||
settings.lnbits_extensions_path, "extensions", ext_id, "config.json"
|
||||
)
|
||||
if not conf_path.is_file():
|
||||
return []
|
||||
with open(conf_path, "r+") as json_file:
|
||||
config_json = json.load(json_file)
|
||||
permissions = config_json.get("permissions", [])
|
||||
return [ExtensionPermission(**p) for p in permissions]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _load_kv_schema_from_config(ext_id: str) -> dict:
|
||||
try:
|
||||
conf_path = Path(
|
||||
settings.lnbits_extensions_path, "extensions", ext_id, "config.json"
|
||||
)
|
||||
if not conf_path.is_file():
|
||||
return {}
|
||||
with open(conf_path, "r+") as json_file:
|
||||
config_json = json.load(json_file)
|
||||
schema = config_json.get("kv_schema", {})
|
||||
return schema if isinstance(schema, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _load_payment_tags_from_config(ext_id: str) -> list[str]:
|
||||
try:
|
||||
conf_path = Path(
|
||||
settings.lnbits_extensions_path, "extensions", ext_id, "config.json"
|
||||
)
|
||||
if not conf_path.is_file():
|
||||
return []
|
||||
with open(conf_path, "r+") as json_file:
|
||||
config_json = json.load(json_file)
|
||||
tags = config_json.get("payment_tags", [])
|
||||
return tags if isinstance(tags, list) else []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _merge_permissions(required: list[str], granted: list[str]) -> list[str]:
|
||||
merged = set()
|
||||
for perm in required or []:
|
||||
merged.add(perm)
|
||||
for perm in granted or []:
|
||||
merged.add(perm)
|
||||
return list(merged)
|
||||
|
||||
|
||||
def _ensure_payment_tags_allowed(required: list[str], granted: list[str]) -> None:
|
||||
if not required and granted:
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
"This extension does not declare any payment tags.",
|
||||
)
|
||||
if required and not granted:
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
"Select at least one payment tag before enabling this extension.",
|
||||
)
|
||||
if not required or not granted:
|
||||
return
|
||||
invalid = [t for t in granted if t not in required]
|
||||
if invalid:
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
f"Invalid payment tags requested: {', '.join(invalid)}",
|
||||
)
|
||||
|
||||
|
||||
def _ensure_api_permissions_available(request: Request, permissions: list[str]) -> None:
|
||||
if not permissions:
|
||||
return
|
||||
missing = []
|
||||
for perm in permissions:
|
||||
parsed = _parse_api_permission(perm)
|
||||
if not parsed:
|
||||
continue
|
||||
method, path = parsed
|
||||
if not _route_exists(request, method, path):
|
||||
missing.append(perm)
|
||||
if missing:
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
f"Permissions reference missing endpoints: {', '.join(missing)}",
|
||||
)
|
||||
|
||||
|
||||
def _parse_api_permission(perm: str) -> tuple[str, str] | None:
|
||||
if not perm.startswith("api."):
|
||||
return None
|
||||
try:
|
||||
method_part, path = perm.split(":", 1)
|
||||
method = method_part.replace("api.", "").upper()
|
||||
except ValueError:
|
||||
return None
|
||||
if not path.startswith("/"):
|
||||
return None
|
||||
return method, path
|
||||
|
||||
|
||||
def _missing_api_permissions(request: Request, permissions: list[str]) -> list[str]:
|
||||
missing = []
|
||||
for perm in permissions or []:
|
||||
parsed = _parse_api_permission(perm)
|
||||
if not parsed:
|
||||
continue
|
||||
method, path = parsed
|
||||
if not _route_exists(request, method, path):
|
||||
missing.append(perm)
|
||||
return missing
|
||||
|
||||
|
||||
def _route_exists(request: Request, method: str, path: str) -> bool:
|
||||
scope = {
|
||||
"type": "http",
|
||||
"method": method,
|
||||
"path": path,
|
||||
"root_path": "",
|
||||
"headers": [],
|
||||
}
|
||||
for route in request.app.router.routes:
|
||||
methods = getattr(route, "methods", None)
|
||||
if methods and method not in methods:
|
||||
continue
|
||||
try:
|
||||
match, _ = route.matches(scope)
|
||||
except Exception as exc:
|
||||
logger.debug(f"Route match failed for {method} {path}: {exc!s}")
|
||||
continue
|
||||
if match == Match.FULL:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _is_wasm_extension(ext_id: str, ext: InstallableExtension) -> bool:
|
||||
if ext.meta and ext.meta.extension_type == "wasm":
|
||||
return True
|
||||
try:
|
||||
conf_path = Path(
|
||||
settings.lnbits_extensions_path, "extensions", ext_id, "config.json"
|
||||
)
|
||||
if not conf_path.is_file():
|
||||
return False
|
||||
with open(conf_path, "r+") as json_file:
|
||||
config_json = json.load(json_file)
|
||||
return config_json.get("extension_type") == "wasm"
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@extension_router.get(
|
||||
"/reviews/tags",
|
||||
dependencies=[Depends(check_account_exists)],
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from fastapi import APIRouter, WebSocket
|
||||
|
||||
from ..crud.wallets import get_wallet_for_key
|
||||
from ..models import KeyType
|
||||
from ..services import websocket_manager
|
||||
|
||||
websocket_router = APIRouter(prefix="/api/v1/ws", tags=["Websocket"])
|
||||
@@ -11,6 +13,30 @@ async def websocket_connect(websocket: WebSocket, item_id: str) -> None:
|
||||
await websocket_manager.listen(conn)
|
||||
|
||||
|
||||
@websocket_router.websocket("/tag/{tag}")
|
||||
async def websocket_connect_tag(websocket: WebSocket, tag: str) -> None:
|
||||
api_key = websocket.headers.get("X-API-KEY") or websocket.query_params.get(
|
||||
"api-key"
|
||||
)
|
||||
if not api_key:
|
||||
await websocket.close(code=4401)
|
||||
return
|
||||
|
||||
wallet = await get_wallet_for_key(api_key)
|
||||
if not wallet:
|
||||
await websocket.close(code=4404)
|
||||
return
|
||||
|
||||
key_type = KeyType.admin if wallet.adminkey == api_key else KeyType.invoice
|
||||
if key_type not in {KeyType.admin, KeyType.invoice}:
|
||||
await websocket.close(code=4403)
|
||||
return
|
||||
|
||||
item_id = f"tag:{wallet.id}:{tag}"
|
||||
conn = await websocket_manager.connect(item_id, websocket)
|
||||
await websocket_manager.listen(conn)
|
||||
|
||||
|
||||
@websocket_router.post("/{item_id}")
|
||||
async def websocket_update_post(item_id: str, data: str):
|
||||
try:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
from lnbits.extensions.wasm.wasm_host import * # noqa: F401,F403
|
||||
@@ -0,0 +1 @@
|
||||
from lnbits.extensions.wasm.wasm_host.extension_host import * # noqa: F401,F403
|
||||
@@ -0,0 +1 @@
|
||||
from lnbits.extensions.wasm.wasm_host.runner import * # noqa: F401,F403
|
||||
@@ -0,0 +1 @@
|
||||
from lnbits.extensions.wasm.wasm_host.service import * # noqa: F401,F403
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -28,6 +28,14 @@ window.PageExtensions = {
|
||||
paylinkWebsocket: null,
|
||||
searchToggle: false,
|
||||
reviewsUrl: null,
|
||||
permissionsDialog: {
|
||||
show: false,
|
||||
extension: null,
|
||||
checked: [],
|
||||
missing: [],
|
||||
tags: [],
|
||||
tagOptions: []
|
||||
},
|
||||
reviewsDialog: {
|
||||
show: false,
|
||||
extension: null,
|
||||
@@ -92,6 +100,20 @@ window.PageExtensions = {
|
||||
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: {
|
||||
filterExtensions(term, tab) {
|
||||
// Filter the extensions list
|
||||
@@ -241,18 +263,49 @@ window.PageExtensions = {
|
||||
})
|
||||
},
|
||||
async enableExtensionForUser(extension) {
|
||||
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) {
|
||||
this.showPayToEnable(extension)
|
||||
return
|
||||
}
|
||||
this.enableExtension(extension)
|
||||
},
|
||||
async enableExtension(extension) {
|
||||
async enableExtension(extension, permissions) {
|
||||
const granted =
|
||||
permissions ||
|
||||
extension._grantedPermissions ||
|
||||
(extension.permissions ? extension.permissions.map(p => p.id) : [])
|
||||
const tags =
|
||||
extension._grantedPaymentTags ||
|
||||
(extension.paymentTags ? extension.paymentTags.slice() : [])
|
||||
LNbits.api
|
||||
.request(
|
||||
'PUT',
|
||||
`/api/v1/extension/${extension.id}/enable`,
|
||||
this.g.user.wallets[0].adminkey
|
||||
this.g.user.wallets[0].adminkey,
|
||||
granted.length || tags.length
|
||||
? {permissions: granted, payment_tags: tags}
|
||||
: null
|
||||
)
|
||||
.then(response => {
|
||||
this.g.user.extensions = this.g.user.extensions.concat([extension.id])
|
||||
@@ -287,13 +340,116 @@ window.PageExtensions = {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
},
|
||||
showPayToEnable(extension) {
|
||||
showPayToEnable(extension, permissions) {
|
||||
this.selectedExtension = extension
|
||||
if (permissions) {
|
||||
this.selectedExtension._grantedPermissions = permissions
|
||||
}
|
||||
this.selectedExtension.payToEnable.paidAmount =
|
||||
extension.payToEnable.amount
|
||||
this.selectedExtension.payToEnable.showQRCode = false
|
||||
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.permissions || !extension.permissions.length) {
|
||||
Quasar.Notify.create({
|
||||
type: 'warning',
|
||||
message: 'This extension does not declare 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',
|
||||
`/api/v1/extension/${extension.id}/capabilities`,
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
if (data && Array.isArray(data.missing_permissions)) {
|
||||
this.permissionsDialog.missing = data.missing_permissions
|
||||
}
|
||||
if (data && Array.isArray(data.payment_tags)) {
|
||||
this.permissionsDialog.tagOptions = data.payment_tags
|
||||
}
|
||||
} 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',
|
||||
`/api/v1/extension/${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) {
|
||||
LNbits.api
|
||||
.request(
|
||||
@@ -832,6 +988,16 @@ window.PageExtensions = {
|
||||
async fetchAllExtensions() {
|
||||
try {
|
||||
const {data} = await LNbits.api.request('GET', `/api/v1/extension/all`)
|
||||
data.forEach(ext => {
|
||||
if (ext.grantedPermissions && ext.grantedPermissions.length) {
|
||||
ext._grantedPermissions = ext.grantedPermissions
|
||||
}
|
||||
if (ext.grantedPaymentTags && ext.grantedPaymentTags.length) {
|
||||
ext._grantedPaymentTags = ext.grantedPaymentTags
|
||||
}
|
||||
const schema = ext.kvSchema || ext.kv_schema || {}
|
||||
ext.kvSchemaKeys = Object.keys(schema)
|
||||
})
|
||||
return data
|
||||
} catch (error) {
|
||||
console.warn(error)
|
||||
|
||||
@@ -96,6 +96,11 @@ def register_invoice_listener(send_chan: asyncio.Queue, name: str | None = None)
|
||||
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)
|
||||
|
||||
|
||||
|
||||
@@ -329,6 +329,19 @@
|
||||
<span v-text="$t('enable_extension_details')">
|
||||
</span> </q-tooltip
|
||||
></q-btn>
|
||||
<q-btn
|
||||
v-if="
|
||||
extension.isInstalled &&
|
||||
extension.isActive &&
|
||||
!g.user.extensions.includes(extension.id) &&
|
||||
extension.permissions &&
|
||||
extension.permissions.length
|
||||
"
|
||||
flat
|
||||
color="grey-5"
|
||||
@click="openPermissionsForExtension(extension)"
|
||||
label="Permissions"
|
||||
></q-btn>
|
||||
|
||||
<q-btn
|
||||
@click="showManageExtension(extension)"
|
||||
@@ -921,6 +934,104 @@
|
||||
</q-card>
|
||||
</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"
|
||||
clickable
|
||||
>
|
||||
<q-item-section>
|
||||
<q-item-label v-text="perm.label"></q-item-label>
|
||||
<q-item-label caption v-text="perm.description"></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-checkbox v-model="permissionsDialog.checked" :val="perm.id" />
|
||||
</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.extension &&
|
||||
permissionsDialog.extension.kvSchemaKeys &&
|
||||
permissionsDialog.extension.kvSchemaKeys.length
|
||||
"
|
||||
class="q-mt-md"
|
||||
>
|
||||
<div class="text-caption text-grey">
|
||||
Data this extension stores in its db:
|
||||
</div>
|
||||
<q-chip
|
||||
v-for="kvKey in permissionsDialog.extension.kvSchemaKeys"
|
||||
:key="kvKey"
|
||||
:label="kvKey"
|
||||
color="grey-3"
|
||||
text-color="black"
|
||||
class="q-mr-xs q-mt-xs"
|
||||
/>
|
||||
</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-card
|
||||
v-if="selectedExtensionDetails"
|
||||
|
||||
@@ -51,6 +51,7 @@ dependencies = [
|
||||
"pillow>=12.1.0",
|
||||
"python-dotenv>=1.2.1",
|
||||
"greenlet (>=3.3.0,<4.0.0)",
|
||||
"wasmtime>=16.0.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
@@ -1290,6 +1290,7 @@ dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "uvicorn" },
|
||||
{ name = "uvloop" },
|
||||
{ name = "wasmtime" },
|
||||
{ name = "websocket-client" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
@@ -1371,6 +1372,7 @@ requires-dist = [
|
||||
{ name = "sqlalchemy", specifier = "==1.4.54" },
|
||||
{ name = "sse-starlette", specifier = "==2.3.6" },
|
||||
{ name = "starlette", specifier = "==0.47.1" },
|
||||
{ name = "wasmtime", specifier = ">=16.0.0" },
|
||||
{ name = "typing-extensions", specifier = "==4.15.0" },
|
||||
{ name = "uvicorn", specifier = "==0.40.0" },
|
||||
{ name = "uvloop", specifier = "==0.22.1" },
|
||||
@@ -2710,6 +2712,25 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/49/4b/8e43c5d27753135fa8df3de333c1f1917d6dc315d119ffa60d0587d8d9ff/wallycore-1.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:0b932510c859ac3e1fc57161420ecb2bb9b6562fce7acd1d9351337cc2cc3515", size = 1728013, upload-time = "2025-08-22T04:26:40.774Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmtime"
|
||||
version = "41.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/be/68/6dc0e7156f883afe0129dd89e4031c8d1163131794ba6ce9e454a09168ad/wasmtime-41.0.0.tar.gz", hash = "sha256:fc2aaacf3ba794eac8baeb739939b2f7903e12d6b78edddc0b7f3ac3a9af6dfc", size = 117354, upload-time = "2026-01-20T18:18:00.565Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/31/f9/f6aef5de536d12652d97cf162f124cbdd642150c7da61ffa7863272cdab7/wasmtime-41.0.0-py3-none-android_26_arm64_v8a.whl", hash = "sha256:f5a6e237b5b94188ef9867926b447f779f540c729c92e4d91cc946f2bee7c282", size = 6837018, upload-time = "2026-01-20T18:17:41.489Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/b9/42ec977972b2dcc8c61e3a40644d24d229b41fba151410644e44e35e6eb1/wasmtime-41.0.0-py3-none-android_26_x86_64.whl", hash = "sha256:4a3e33d0d3cf49062eaa231f748f54af991e89e9a795c5ab9d4f0eee85736e4c", size = 7654957, upload-time = "2026-01-20T18:17:43.285Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/ca/6cce49b03c35c7fecb4437fd98990c64694a5e0024f9279bef0ddef000f7/wasmtime-41.0.0-py3-none-any.whl", hash = "sha256:5f6721406a6cd186d11f34e6d4991c4d536387b0c577d09a56bd93b8a3cf10c2", size = 6325757, upload-time = "2026-01-20T18:17:44.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/16/d91cb80322cc7ae10bfa5db8cea4e0b9bb112f0c100b4486783ab16c1c22/wasmtime-41.0.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:2107360212fce33ed2adcfc33b7e75ed7136380a17d3ed598a5bab376dcf9e1b", size = 7471888, upload-time = "2026-01-20T18:17:46.185Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/f0/dcc80973d2ec58a1978b838887ccbd84d56900cf66dec5fb730bec3bd081/wasmtime-41.0.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f475df32ce9bfec4f6d0e124a49ca4a89e2ee71ccca460677f5237b1c8ee92ae", size = 6507285, upload-time = "2026-01-20T18:17:48.138Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/df/0867edd9ec26eb2e5eee7674a55f82c23ec27dd1d38d2d401f0e308eb920/wasmtime-41.0.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:ad7e866430313eb2ee07c85811e524344884489d00896f3b2246b65553fe322c", size = 7732024, upload-time = "2026-01-20T18:17:50.207Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/48/b748a2e70478feabc5c876d90e90a39f4aba35378f5ee822f607e8f29c69/wasmtime-41.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:e0ea44584f60dcfa620af82d4fc2589248bcf64a93905b54ac3144242113b48a", size = 6800017, upload-time = "2026-01-20T18:17:51.7Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/29/43656c3a464d437d62421de16f2de2db645647bab0a0153deea30bfdade4/wasmtime-41.0.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1dabb20a2751f01b835095013426a76091bd0bdb36ca9bcfc49c910b78347438", size = 6840763, upload-time = "2026-01-20T18:17:53.125Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/09/4608b65fa35ce5fc1479e138293a1166b4ea817cfa9a79f019ab6d7013d8/wasmtime-41.0.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d9627dfc5625b4947ea35c819561da358838fe76f65bda8ffe01ce34df8b32b1", size = 7754016, upload-time = "2026-01-20T18:17:55.346Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/9d/236bb367270579e4f628fb7b04fe93541151df7953006f3766607fc667c9/wasmtime-41.0.0-py3-none-win_amd64.whl", hash = "sha256:4f29171d73b71f232b6fe86cba77526fee84139f1590071af5facba401b0c9eb", size = 6325764, upload-time = "2026-01-20T18:17:57.034Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/4a/bba9c0368c377250ab24fd005a7a1e9076121778c1e83b1bcc092ab84f86/wasmtime-41.0.0-py3-none-win_arm64.whl", hash = "sha256:0c4bcaba055e78fc161f497b85f39f1d35d475f0341b1e0259fa0a4b49e223e8", size = 5392238, upload-time = "2026-01-20T18:17:59.052Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "websocket-client"
|
||||
version = "1.9.0"
|
||||
|
||||
Reference in New Issue
Block a user